#!/usr/bin/env python3 """Детерминированный инструмент управления задачами: файлы против индексов. Преемник backlog.py. Разница по существу одна: **порядка нет, есть цель**. Приоритеты и секция-как-уровень заменены на цель (`goal:<слаг>` тегом) и на спринт — замороженный набор задач под одну цель. Индексов теперь четыре, и задача живёт ровно в одном из них за раз. Раскладка (все имена настраиваются, см. init и `.tasks.json`): / items/ задачи и цели файлами, .md PLAN.md оглавление целей: линия (упорядоченная) и кусты BACKLOG.md что можно взять — только задачи, целей здесь нет SPRINT.md текущий спринт: цель, набор, дата REJECTED.md ушедшее БЕЗ реализации, с причиной и датой Источник истины — файл задачи в items/. Индексы производны: расходятся — неправ индекс. Исключение одно и оно намеренное: **в каком индексе лежит задача — знают индексы**, потому что «в спринте» это свойство спринта, а не задачи; поля-состояния в файле нет, а рассогласование ловит check. Тип — ключевое слово (goal | idea | epic | task), по-английски, как и прочие токены команд. Обычная задача (task) префикса не несёт, остальные кодируются префиксом `[goal]`/`[idea]`/`[epic]` в заголовке H1. Отдельного поля типа нет. `[goal]` и `[epic]` — разные вещи: цель постоянна и живёт, пока живёт направление; эпик временен — это задача, которая не мерджится целиком, её разбирают, и он исчезает. Использование: tasks.py init [--dir DIR] [--sections …] [--plan-sections …] [--items …] tasks.py check [--dir DIR] [--fix] tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag T] [--goal S] [--index backlog|sprint|plan|all] [--questions] tasks.py add --slug S --title T [--type goal|idea|epic] [--section S] [--goal G] [--hook H] [--reason R] [--tag a,b] tasks.py edit S [--title T] [--hook H] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c,d] tasks.py move S --section S [--reason R] [--after S | --first] tasks.py close S (--reason R | --implemented) tasks.py sprint start --goal S [--date ГГГГ-ММ-ДД] tasks.py sprint take S [S …] tasks.py sprint drop S [S …] --reason R tasks.py sprint close [--dissolve --reason R] Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт заголовок, мета-строку и шаблон-плейсхолдер; агент дописывает редактором. Границы безопасности: слаг — только латиница kebab-case (traversal невозможен), --dir обязан быть внутри рабочего каталога, в заголовок/хук/причину не пролезет перевод строки, `·` в причине запрещён (это разделитель мета-полей). Язык не зашит инструментально: секции сопоставляются с заголовками индексов как есть, имена служебных файлов и заголовков настраиваются. Текст задач — русский. """ import argparse import datetime import json import os import re import subprocess import sys from pathlib import Path CONFIG_NAME = ".tasks.json" DEFAULTS = { "items": "items", "backlog": "BACKLOG.md", "plan": "PLAN.md", "sprint": "SPRINT.md", "rejected": "REJECTED.md", "sprint_section": "Набор", "criteria_heading": "Критерии приёмки", "questions_heading": "Вопросы", } DEFAULT_SECTIONS = "ядро,инфра" DEFAULT_PLAN_SECTIONS = "линия,кусты" META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$") INDEX_ENTRY = re.compile(r"^- \[(.+?)\]\((.+?\.md)\)\s*(?:—\s*(.*))?$") SECTION = re.compile(r"^##\s+(.+?)\s*$") TYPE_PREFIX = re.compile(r"^\[(.+?)\]\s*(.*)$") SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") SLUG = re.compile(SLUG_RE.pattern + r"\.md") GOAL_LINE = re.compile(r"^\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)") DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") # Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+") GOAL = "goal" TYPES = ("goal", "idea", "epic") # непустые типы-ключевые слова, префикс [..] в H1 PLAIN_TYPE = "task" # обычная задача — без префикса TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять в спринт QUESTION_TAG = "question" GOAL_TAG = "goal:" STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check BODY_PLACEHOLDER = "`, считается пустым, иначе шаблон `add` сам себя засчитывал бы за заполненные критерии. """ text = re.sub(r"", "", text, flags=re.S) out: dict[str, list[str]] = {} cur = None for line in text.splitlines()[1:]: if (m := SECTION.match(line)): cur = m.group(1).strip().lower() out.setdefault(cur, []) continue if cur is not None: out[cur].append(line) return {k: "\n".join(v).strip() for k, v in out.items()} def parse_task(path: Path) -> dict: text = path.read_text(encoding="utf-8") lines = text.splitlines() title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else "" kind, bare = PLAIN_TYPE, title if (m := TYPE_PREFIX.match(title)): kind, bare = m.group(1).strip().lower(), m.group(2).strip() # Мета-строка — первая непустая строка после заголовка (task-format.md). # Поля разделены `·`, порядок свободный: секция распознаётся, где бы она ни # стояла. Причина не должна содержать `·` — это разделитель. meta = next((ln.strip() for ln in lines[1:] if ln.strip()), "") section, reason, tags = "", "", [] if META_FIELD.match(meta): for chunk in meta.split("·"): f = META_FIELD.match(chunk.strip()) if not f: continue key, value = f.group(1).strip().lower(), f.group(2).strip() if key in ("секция", "section"): section, _, reason = (p.strip() for p in value.partition("—")) section = section.rstrip(".,").lower() elif key in ("теги", "tags"): tags = [t.strip().lower() for t in value.split(",") if t.strip()] goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "") return {"title": title, "bare": bare, "type": kind, "section": section, "reason": reason, "tags": tags, "goal": goal, "path": path, "text": text, "body": body_sections(text)} def tasks_of(lay: Layout) -> dict[str, dict]: if not lay.items.is_dir(): return {} return {p.name: parse_task(p) for p in sorted(lay.items.glob("*.md"))} def home_index(task: dict) -> str: """Индекс, которому задача принадлежит по типу. Спринт — исключение: туда задача попадает не по типу, а решением набора.""" return "plan" if task["type"] == GOAL else "backlog" def touched_map(lay: Layout) -> dict[str, str]: """Дата последнего коммита каждого файла задач — одним вызовом git. Ключ — имя файла (в items/ имена уникальны). Нет git / нет истории → пустая карта, вызывающий подставит «—».""" try: out = subprocess.run(["git", "log", "--format=%as", "--name-only", "--", str(lay.items)], capture_output=True, text=True).stdout except FileNotFoundError: return {} dates: dict[str, str] = {} cur = None for line in out.splitlines(): if not line.strip(): continue if DATE_RE.fullmatch(line): cur = line # лог новейшие сверху → первая дата и есть последняя правка elif cur: dates.setdefault(os.path.basename(line), cur) return dates # --- Спринт --- def sprint_goal(lay: Layout) -> tuple[str, str]: """Слаг и заголовок цели текущего спринта; ('', '') — спринта нет.""" for line in read_lines(lay.index("sprint")): if (m := GOAL_LINE.match(line.strip())): return Path(m.group(2)).stem, m.group(1) return "", "" def sprint_header(lay: Layout, goal_slug: str, goal_title: str, date: str) -> list[str]: link = f"{lay.cfg['items']}/{goal_slug}.md" return ["# Спринт", "", f"**Цель:** [{goal_title}]({link}) · **Начат:** {date}", ""] def empty_sprint(lay: Layout) -> str: return ("# Спринт\n\n" "Спринта нет. Цель называет человек, набор собирает агент:\n" "`tasks.py sprint start --goal <слаг>`.\n\n" f"## {lay.cfg['sprint_section']}\n") # --- check --- def check(lay: Layout, fix: bool = False) -> int: if fix: fixed = apply_fixes(lay) for line in fixed: print(f"ПОЧИНЕНО {line}") if fixed: print() idx = {k: parse_entries(read_lines(lay.index(k))) for k in lay.indexes} entries = {k: v[0] for k, v in idx.items()} sections = {k: v[1] for k, v in idx.items()} tasks = tasks_of(lay) errors: list[str] = [] notes: list[str] = [] label = {k: lay.name(k) for k in lay.indexes} known = {k: {s.lower() for s in sections[k]} for k in lay.indexes} goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL} goal_of_sprint, _ = sprint_goal(lay) crit = lay.cfg["criteria_heading"].lower() ques = lay.cfg["questions_heading"].lower() for name, task in tasks.items(): where = [k for k in lay.indexes if name in entries[k]] home = home_index(task) if not SLUG.fullmatch(name): errors.append(f"{name}: слаг не kebab-case латиницей") if not task["title"]: errors.append(f"{name}: нет заголовка H1") if task["type"] not in TYPES and task["type"] != PLAIN_TYPE: notes.append(f"{name}: тип «{task['type']}» вне словаря" f" ({'/'.join(TYPES)} или без префикса)") # 1. Задача живёт ровно в одном индексе за раз. allowed = {home} | ({"sprint"} if home == "backlog" else set()) if not where: errors.append(f"{name}: нет ни в одном индексе (ожидался {label[home]})") elif len(where) > 1: errors.append(f"{name}: сразу в нескольких индексах" f" ({', '.join(label[k] for k in where)}) — задача живёт в одном") elif where[0] not in allowed: errors.append(f"{name}: лежит в {label[where[0]]}, а по типу" f" «{task['type']}» её место в {label[home]}") place = where[0] if len(where) == 1 else None entry = entries[place][name] if place else None # 2. Секция файла — истина, секция индекса производна. В спринте секция # файла хранит, куда задачу вернуть, и с «Набором» не сверяется. if not task["section"]: errors.append(f"{name}: нет поля **Секция:** в мета-строке") elif task["section"] not in known[home]: errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной" f" секцией {label[home]} ({', '.join(sections[home])})") elif place and place != "sprint" and entry["section"] \ and entry["section"].lower() != task["section"]: errors.append(f"{name}: секция в файле «{task['section']}»," f" а в {label[place]} — «{entry['section']}»") if entry and entry["title"] != task["title"]: errors.append(f"{name}: заголовок разошёлся\n" f" файл: {task['title']}\n" f" индекс: {entry['title']}") if entry and not entry["hook"]: notes.append(f"{name}: строка индекса без хука — по ней не выбрать задачу") # 3. Цель. Связь однонаправленна: задача несёт goal:<слаг>, перечень # задач цели выводится `list --goal`, а не хранится. if task["type"] != GOAL: if not task["goal"]: if task["type"] == "idea": notes.append(f"{name}: идея без цели — цель проставляется," f" когда идея становится задачей") else: errors.append(f"{name}: нет тега {GOAL_TAG}<слаг> — задача вне цели" f" не попадёт ни в один спринт") elif task["goal"] not in goal_slugs: errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель," f" которой нет в {lay.cfg['items']}/") # 4. Спринт: задача с открытым вопросом в набор не берётся. if place == "sprint": if QUESTION_TAG in task["tags"]: errors.append(f"{name}: в спринте, но с тегом «{QUESTION_TAG}» —" f" вопрос разбирается до взятия") if task["type"] not in TAKEABLE: errors.append(f"{name}: в спринте тип «{task['type']}»" f" — берутся только задачи") if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint: errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта" f" «{goal_of_sprint}» — набор служит одной цели") if crit not in task["body"] or not task["body"].get(crit): errors.append(f"{name}: в спринте без раздела «{lay.cfg['criteria_heading']}»" f" — принимать будет не по чему") # 5. Тег «question» и раздел вопросов — один факт, два места. has_q_section = bool(task["body"].get(ques)) if has_q_section and QUESTION_TAG not in task["tags"]: notes.append(f"{name}: раздел «{lay.cfg['questions_heading']}» есть," f" а тега «{QUESTION_TAG}» нет") if QUESTION_TAG in task["tags"] and not has_q_section: notes.append(f"{name}: тег «{QUESTION_TAG}» есть, а раздела" f" «{lay.cfg['questions_heading']}» нет — снять тег?") if BODY_PLACEHOLDER in task["text"]: notes.append(f"{name}: тело не дописано (остался плейсхолдер add)") for kind in lay.indexes: for name, entry in entries[kind].items(): if name not in tasks: errors.append(f"{label[kind]}:{entry['line']}: ссылка на несуществующий {name}") errors += index_lint(read_lines(lay.index(kind)), label[kind]) if goal_of_sprint and goal_of_sprint + ".md" not in tasks: errors.append(f"{label['sprint']}: цель «{goal_of_sprint}» не найдена" f" в {lay.cfg['items']}/") if not goal_of_sprint and entries["sprint"]: errors.append(f"{label['sprint']}: набор есть, а цель не названа") rejected = lay.index("rejected") if rejected.is_file(): for num, line in enumerate(read_lines(rejected), 1): if line.startswith("- ") and not REJECTED_ENTRY.match(line): errors.append(f"{label.get('rejected', lay.name('rejected'))}:{num}:" f" строка не по формату «- ГГГГ-ММ-ДД `slug` — …»") print(f"задачи: {lay.root}, файлов {len(tasks)}, строк индексов " + ", ".join(f"{label[k]} {len(entries[k])}" for k in lay.indexes)) health(lay, tasks, entries, sections) for e in errors: print(f"ОШИБКА {e}") for n in notes: print(f"замечание {n}") if errors: print(f"\nрасхождений: {len(errors)}") return 1 print("\nиндексы согласованы" + (f", замечаний: {len(notes)}" if notes else "")) return 0 def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: """Метрики здоровья: размер секций, спринт, открытые вопросы, залежалость, цели без задач. Механизирует то, что иначе держится на дисциплине.""" backlog = [t for n, t in tasks.items() if n in entries["backlog"]] by_section = {s.lower(): 0 for s in sections["backlog"]} for t in backlog: if t["section"] in by_section: by_section[t["section"]] += 1 if by_section: print(" беклог: " + ", ".join(f"{s} {by_section[s.lower()]}" for s in sections["backlog"])) goal_slug, goal_title = sprint_goal(lay) if goal_slug: print(f" спринт: цель «{goal_slug}», задач {len(entries['sprint'])}") else: print(" спринт: не начат") questions = [n for n, t in tasks.items() if QUESTION_TAG in t["tags"]] if questions: print(f" с открытым вопросом: {len(questions)}" f" — в спринт не берутся, разбор первым шагом сессии") goals = {n[:-3]: t for n, t in tasks.items() if t["type"] == GOAL} if goals: counts = {g: sum(1 for t in tasks.values() if t["goal"] == g) for g in goals} empty = [g for g, c in counts.items() if c == 0] print(f" цели: {len(goals)}, из них без открытых задач {len(empty)}" + (f" ({', '.join(sorted(empty))})" if empty else "")) dates = touched_map(lay) if not dates: return cutoff = (datetime.date.today() - datetime.timedelta(days=STALE_DAYS)).isoformat() stale = sum(1 for t in tasks.values() if (d := dates.get(t["path"].name)) and d < cutoff) if stale: print(f" залежалось (>{STALE_DAYS} дней без правки): {stale}" f" — переоценка просрочена, начни с `list --stale`") # --- list --- def list_tasks(lay: Layout, a: argparse.Namespace) -> int: tasks = tasks_of(lay) entries = {k: parse_entries(read_lines(lay.index(k)))[0] for k in lay.indexes} order = {} for k in lay.indexes: for i, s in enumerate(parse_entries(read_lines(lay.index(k)))[1]): order.setdefault(s.lower(), i) def place(name: str) -> str: for k in lay.indexes: if name in entries[k]: return k return "—" rows = [] for name, t in tasks.items(): t["place"] = place(name) if a.index and a.index != "all" and t["place"] != a.index: continue if a.section and t["section"] != a.section.lower(): continue if a.type and t["type"] != a.type.lower(): continue if a.tag and a.tag.lower() not in t["tags"]: continue if a.goal and t["goal"] != a.goal.lower(): continue if a.questions and QUESTION_TAG not in t["tags"]: continue rows.append(t) if a.stale: dates = touched_map(lay) for t in rows: t["touched"] = dates.get(t["path"].name, "—") rows.sort(key=lambda t: (t["touched"] == "—", t["touched"])) else: rows.sort(key=lambda t: (order.get(t["section"], 99), t["path"].name)) for t in rows: touched = f"{t.get('touched', ''):<11}" if a.stale else "" kind = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] " goal = f" →{t['goal']}" if t["goal"] and not a.goal else "" flag = " ?" if QUESTION_TAG in t["tags"] else " " print(f"{touched}{t['place']:<8}{t['section']:<8}{flag} " f"{t['path'].stem:<44} {kind}{t['bare']}{goal}") print(f"\nвсего: {len(rows)}") return 0 # --- Мутации: правят файл и индексы заодно, рассогласовать их вручную нельзя --- def fail(msg: str) -> int: print(f"ошибка: {msg}", file=sys.stderr) return 2 def build_meta(section: str, reason: str, tags: list[str]) -> str: s = f"**Секция:** {section}" if reason: s += f" — {reason}" if tags: s += " · **Теги:** " + ", ".join(tags) return s def save_index(lay: Layout, kind: str, lines: list[str]) -> None: write_atomic(lay.index(kind), "\n".join(lines) + "\n") def section_headers(lines: list[str]) -> list[tuple[int, str]]: return [(i, m.group(1)) for i, l in enumerate(lines) if (m := SECTION.match(l))] def find_section(lines: list[str], name: str) -> tuple[int | None, str]: for i, s in section_headers(lines): if s.lower() == name.lower(): return i, s return None, "" def find_entry_index(lines: list[str], slug: str) -> int | None: for i, l in enumerate(lines): m = INDEX_ENTRY.match(l) if m and Path(m.group(2)).name == f"{slug}.md": return i return None def locate(lay: Layout, slug: str) -> tuple[str | None, list[str], int | None]: """В каком индексе лежит строка задачи: (вид, строки индекса, номер строки).""" for kind in lay.indexes: lines = read_lines(lay.index(kind)) ei = find_entry_index(lines, slug) if ei is not None: return kind, lines, ei return None, [], None def insert_entry(lines: list[str], section: str, entry: str, after: str | None = None, first: bool = False) -> None: """Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом за указанной строкой, --first — первой. Порядок нужен только упорядоченной части плана; в беклоге он значения не имеет.""" hi, _ = find_section(lines, section) end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines)) if first: ins = hi + 1 while ins < end and not lines[ins].strip(): ins += 1 elif after: ai = find_entry_index(lines[hi:end], after) if ai is None: raise KeyError(after) ins = hi + ai + 1 else: ins = end while ins - 1 > hi and not lines[ins - 1].strip(): ins -= 1 lines.insert(ins, entry) def update_meta(path: Path, section: str | None, new_reason: str | None, tags: list[str] | None) -> bool: """Хирургически правит мета-строку файла, сохраняя нераспознанные поля и регистр. False — мета-строки нет (тогда правку делать нельзя).""" flines = path.read_text(encoding="utf-8").splitlines() mi = next((i for i in range(1, len(flines)) if flines[i].strip()), None) if mi is None or not META_FIELD.match(flines[mi].strip()): return False chunks = flines[mi].split("·") seen_section = seen_tags = False for idx, chunk in enumerate(chunks): f = META_FIELD.match(chunk.strip()) if not f: continue key = f.group(1).strip().lower() lead = chunk[:len(chunk) - len(chunk.lstrip())] trail = chunk[len(chunk.rstrip()):] if key in ("секция", "section") and section is not None: _, _, old_reason = (p.strip() for p in f.group(2).partition("—")) reason = new_reason if new_reason is not None else old_reason field = f"**Секция:** {section}" + (f" — {reason}" if reason else "") chunks[idx] = lead + field + trail seen_section = True elif key in ("теги", "tags") and tags is not None: seen_tags = True if tags: chunks[idx] = lead + "**Теги:** " + ", ".join(tags) + trail else: chunks[idx] = None # тегов не осталось — поле убираем chunks = [c for c in chunks if c is not None] if tags and not seen_tags: chunks.append(" **Теги:** " + ", ".join(tags)) if section is not None and not seen_section: return False write_atomic(path, "\n".join((*flines[:mi], "·".join(chunks), *flines[mi + 1:])) + "\n") return True def entry_line(lay: Layout, title: str, slug: str, hook: str) -> str: link = f"{lay.cfg['items']}/{slug}.md" return f"- [{title}]({link})" + (f" — {hook}" if hook else "") def body_template(kind: str, lay: Layout) -> str: if kind == GOAL: return ("\n\n" "## Завершение\n\n" "\n") if kind == "idea": return ("\n") return ("\n\n" f"## {lay.cfg['criteria_heading']}\n\n" "\n\n" "## Рамки\n\n" "\n") def cmd_add(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук"), bad_tags(a.tag), bad_reason(a.reason), bad_slug(a.goal) if a.goal else None): if err: return fail(err) if not a.title.strip(): return fail("пустой заголовок") kind = (a.type or "").strip().lower() path = lay.items / f"{a.slug}.md" if path.exists(): return fail(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый") target = "plan" if kind == GOAL else "backlog" lines = read_lines(lay.index(target)) if not lines: return fail(f"нет индекса {lay.name(target)} — прогони tasks.py init") if find_entry_index(lines, a.slug) is not None: return fail(f"строка в {lay.name(target)} для {a.slug} уже есть") section = a.section or (section_headers(lines)[0][1] if section_headers(lines) else "") hi, section = find_section(lines, section) if hi is None: avail = ", ".join(n for _, n in section_headers(lines)) return fail(f"нет секции «{a.section}» в {lay.name(target)} (есть: {avail})") tags = split_tags(a.tag) if a.goal: tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"] if not (lay.items / f"{a.goal}.md").exists(): print(f" внимание: цели {a.goal}.md нет — заведи её (--type goal) или поправь тег") if kind != GOAL and not any(t.startswith(GOAL_TAG) for t in tags): print(" без цели: задача вне цели не попадёт ни в один спринт —" f" проставь `tasks.py edit {a.slug} --goal <слаг>`") if QUESTION_TAG in tags: print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле") title_full = f"[{kind}] {a.title}" if kind else a.title meta = build_meta(section, a.reason or "", tags) lay.items.mkdir(parents=True, exist_ok=True) write_atomic(path, f"# {title_full}\n\n{meta}\n\n{body_template(kind, lay)}") insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.hook or "")) save_index(lay, target, lines) print(f"создано: {lay.cfg['items']}/{a.slug}.md," f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором") if not a.hook: print(f" без хука — задай: tasks.py edit {a.slug} --hook …") warn_rejected(lay, a.slug, a.title) return 0 def warn_rejected(lay: Layout, slug: str, title: str) -> None: """Дедупликация против ушедшего без реализации: та же задача возвращается через квартал тем же текстом, и кладбище — единственный её след.""" path = lay.index("rejected") if not path.is_file(): return words = {w for w in re.findall(r"[^\W\d_]{5,}", title.lower())} for line in read_lines(path): if not REJECTED_ENTRY.match(line): continue low = line.lower() if f"`{slug}`" in low or (words and len(words & set(re.findall(r"[^\W\d_]{5,}", low))) >= 2): print(f" похоже на ушедшее без реализации — покажи пользователю, что изменилось:\n" f" {line.strip()}") def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук"), bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_slug(a.goal) if a.goal else None): if err: return fail(err) if all(v is None for v in (a.title, a.hook, a.type, a.goal, a.add_tag, a.rm_tag)): return fail("нечего менять: дай --title, --hook, --type, --goal, --add-tag или --rm-tag") path = lay.items / f"{a.slug}.md" if not path.exists(): return fail(f"{a.slug}.md не найден в {lay.cfg['items']}/") kind_index, lines, ei = locate(lay, a.slug) if ei is None: return fail(f"строки индекса для {a.slug} нет — прогони check --fix") task = parse_task(path) if a.title is not None and not a.title.strip(): return fail("пустой заголовок") bare = a.title if a.title is not None else task["bare"] kind = task["type"] if a.type is None else a.type.strip().lower() if (kind == GOAL) != (task["type"] == GOAL): return fail("тип goal не меняется на месте: цель и задача живут в разных индексах —" " заведи новую запись и закрой старую с причиной") prefix = "" if kind in ("", PLAIN_TYPE) else f"[{kind}] " h1 = f"{prefix}{bare}" flines = path.read_text(encoding="utf-8").splitlines() if not flines or not flines[0].startswith("#"): return fail(f"{a.slug}.md без заголовка H1 — прогони check") flines[0] = f"# {h1}" write_atomic(path, "\n".join(flines) + "\n") tags = list(task["tags"]) if a.rm_tag is not None: drop = set(split_tags(a.rm_tag)) tags = [t for t in tags if t not in drop] if a.add_tag is not None: for t in split_tags(a.add_tag): if t not in tags: tags.append(t) if a.goal is not None: tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"] if not (lay.items / f"{a.goal}.md").exists(): print(f" внимание: цели {a.goal}.md нет — заведи её или поправь тег") if tags != task["tags"] and not update_meta(path, None, None, tags): return fail(f"{a.slug}.md без мета-строки — прогони check и почини") m = INDEX_ENTRY.match(lines[ei]) hook = a.hook if a.hook is not None else (m.group(3) or "").strip() lines[ei] = entry_line(lay, h1, a.slug, hook) save_index(lay, kind_index, lines) changed = [n for n, v in (("заголовок", a.title), ("хук", a.hook), ("тип", a.type), ("цель", a.goal), ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"] and kind_index == "sprint": print(" задача в спринте, а вопрос открыт: либо остаток есть и вопрос ждёт сессии," f" либо задача выходит — `tasks.py sprint drop {a.slug} --reason …`") return 0 def cmd_move(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_reason(a.reason), bad_slug(a.after) if a.after else None): if err: return fail(err) path = lay.items / f"{a.slug}.md" if not path.exists(): return fail(f"{a.slug}.md не найден в {lay.cfg['items']}/") kind_index, lines, ei = locate(lay, a.slug) if ei is None: return fail(f"строки индекса для {a.slug} нет — прогони check --fix") if kind_index == "sprint": return fail(f"{a.slug} в спринте: секция — свойство беклога." f" Сперва верни задачу: tasks.py sprint drop {a.slug} --reason …") hi, section = find_section(lines, a.section) if hi is None: avail = ", ".join(n for _, n in section_headers(lines)) return fail(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})") if not update_meta(path, section, a.reason, None): return fail(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини") entry = lines.pop(ei) try: insert_entry(lines, section, entry, a.after, a.first) except KeyError: return fail(f"--after {a.after}: такой строки в секции «{section}» нет") save_index(lay, kind_index, lines) print(f"{a.slug}: перенесено в «{section}» ({lay.name(kind_index)})") return 0 def cmd_close(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_reason(a.reason)): if err: return fail(err) path = lay.items / f"{a.slug}.md" if not path.exists(): return fail(f"{a.slug}.md не найден в {lay.cfg['items']}/") kind_index, lines, ei = locate(lay, a.slug) if ei is None: return fail(f"строки индекса для {a.slug} нет — прогони check --fix") task = parse_task(path) if task["type"] == GOAL: open_tasks = [n for n, t in tasks_of(lay).items() if t["goal"] == a.slug] if open_tasks: return fail(f"у цели {a.slug} осталось открытых задач: {len(open_tasks)}" f" ({', '.join(sorted(t[:-3] for t in open_tasks))})." f" Цель закрыта, когда не осталось её задач") if a.reason: reason = a.reason.rstrip() dot = "" if reason.endswith((".", "!", "?")) else "." date = datetime.date.today().isoformat() bullet = (f"- {date} `{a.slug}` — {task['title']}. Причина: {reason}{dot}" f" Была секция: {task['section'] or '—'}.") rej = lay.index("rejected") prev = rej.read_text(encoding="utf-8") if rej.exists() else "# Ушедшее без реализации\n" if not prev.endswith("\n"): prev += "\n" write_atomic(rej, prev + bullet + "\n") # Порядок: индекс без строки → потом unlink. Обратный порядок оставил бы # ссылку в никуда, если бы unlink упал. lines.pop(ei) save_index(lay, kind_index, lines) path.unlink() print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") if kind_index == "sprint" and a.reason: print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта") return 0 # --- Спринт --- def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_slug(a.goal)): return fail(err) entries, _ = parse_entries(read_lines(lay.index("sprint"))) if entries: return fail(f"в {lay.name('sprint')} ещё есть набор ({len(entries)}) —" f" закрой спринт: tasks.py sprint close") gpath = lay.items / f"{a.goal}.md" if not gpath.exists(): return fail(f"цели {a.goal}.md нет в {lay.cfg['items']}/") goal = parse_task(gpath) if goal["type"] != GOAL: return fail(f"{a.goal} не цель (тип «{goal['type']}») — спринт набирается под [goal]") date = a.date or datetime.date.today().isoformat() if not DATE_RE.fullmatch(date): return fail("дата в формате ГГГГ-ММ-ДД") lines = sprint_header(lay, a.goal, goal["title"], date) + [f"## {lay.cfg['sprint_section']}", ""] save_index(lay, "sprint", lines) ready = [n[:-3] for n, t in tasks_of(lay).items() if t["goal"] == a.goal and t["type"] in TAKEABLE and QUESTION_TAG not in t["tags"]] print(f"спринт начат: цель «{a.goal}», {date}") print(f" кандидатов под цель без открытых вопросов: {len(ready)}" + (f" ({', '.join(sorted(ready))})" if ready else "")) print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта работ") return 0 def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: goal_slug, _ = sprint_goal(lay) if not goal_slug: return fail("спринт не начат: tasks.py sprint start --goal <слаг>") sprint_lines = read_lines(lay.index("sprint")) backlog_lines = read_lines(lay.index("backlog")) hi, section = find_section(sprint_lines, lay.cfg["sprint_section"]) if hi is None: return fail(f"в {lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»") taken = [] for slug in a.slugs: if (err := bad_slug(slug)): return fail(err) path = lay.items / f"{slug}.md" if not path.exists(): return fail(f"{slug}.md не найден в {lay.cfg['items']}/") t = parse_task(path) if t["type"] not in TAKEABLE: return fail(f"{slug}: тип «{t['type']}» в спринт не берётся —" f" идея идёт на штурм, эпик на декомпозицию, цель не берут вовсе") if t["goal"] != goal_slug: return fail(f"{slug}: цель «{t['goal'] or '—'}» не цель спринта «{goal_slug}» —" f" набор служит одной цели, даже если взять удобно") if QUESTION_TAG in t["tags"]: return fail(f"{slug}: открыт вопрос — разбирается до взятия," f" вне очереди порции (`tasks.py edit {slug} --rm-tag {QUESTION_TAG}`)") crit = lay.cfg["criteria_heading"].lower() if not t["body"].get(crit): return fail(f"{slug}: нет раздела «{lay.cfg['criteria_heading']}» —" f" принимать будет не по чему") ei = find_entry_index(backlog_lines, slug) if ei is None: return fail(f"{slug}: строки в {lay.name('backlog')} нет" f" (уже в спринте? прогони check)") entry = backlog_lines.pop(ei) insert_entry(sprint_lines, section, entry) taken.append(slug) save_index(lay, "backlog", backlog_lines) save_index(lay, "sprint", sprint_lines) total = len(parse_entries(sprint_lines)[0]) print(f"взято в спринт: {', '.join(taken)}; в наборе {total}") return 0 def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_reason(a.reason)): return fail(err) sprint_lines = read_lines(lay.index("sprint")) backlog_lines = read_lines(lay.index("backlog")) dropped = [] for slug in a.slugs: if (err := bad_slug(slug)): return fail(err) ei = find_entry_index(sprint_lines, slug) if ei is None: return fail(f"{slug}: в наборе спринта такой строки нет") path = lay.items / f"{slug}.md" t = parse_task(path) hi, section = find_section(backlog_lines, t["section"]) if hi is None: return fail(f"{slug}: секция «{t['section']}» не найдена в {lay.name('backlog')}") if not update_meta(path, section, a.reason, None): return fail(f"{slug}.md без мета-строки **Секция:** — прогони check и почини") insert_entry(backlog_lines, section, sprint_lines.pop(ei)) dropped.append(slug) save_index(lay, "backlog", backlog_lines) save_index(lay, "sprint", sprint_lines) print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мета-строку") print(f" живого предложения оставаться не должно; наработки, которые жалко," f" переносятся в тело задачи текстом") return 0 def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_reason(a.reason)): return fail(err) entries, _ = parse_entries(read_lines(lay.index("sprint"))) goal_slug, _ = sprint_goal(lay) if entries and not a.dissolve: return fail(f"в наборе осталось задач: {len(entries)}" f" ({', '.join(sorted(n[:-3] for n in entries))}). Спринт кончается, когда" f" каждая либо сделана (close --implemented), либо вышла (sprint drop" f" --reason …). Роспуск при блокере — sprint close --dissolve --reason …") if a.dissolve: if not a.reason: return fail("--dissolve без --reason: роспуск объясняется блокером") drop = argparse.Namespace(slugs=sorted(n[:-3] for n in entries), reason=a.reason) if entries and (rc := cmd_sprint_drop(lay, drop)) != 0: return rc write_atomic(lay.index("sprint"), empty_sprint(lay)) print(f"спринт закрыт (цель «{goal_slug or '—'}»)" + (", набор распущен" if a.dissolve and entries else "")) print(f" история наборов остаётся в git: `git log -p {lay.index('sprint')}`") print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор") return 0 # --- check --fix --- def apply_fixes(lay: Layout) -> list[str]: """Детерминированная починка дрейфа. Чинит только безопасное, где истина однозначно в файле: дубли строк, рассинхрон заголовка, задача не в своей секции, отсутствующая строка. Неоднозначное (ссылка на исчезнувший файл, задача сразу в двух индексах, битые строки) не трогает — это на суд человека.""" fixed: list[str] = [] tasks = tasks_of(lay) idx = {k: read_lines(lay.index(k)) for k in lay.indexes} dirty: set[str] = set() # 1. Дубли строк на один файл — оставляем первую. for kind, lines in idx.items(): seen: set[str] = set() out: list[str] = [] for l in lines: m = INDEX_ENTRY.match(l) if m and Path(m.group(2)).name in seen: fixed.append(f"{lay.name(kind)}: убран дубль строки {Path(m.group(2)).name}") dirty.add(kind) continue if m: seen.add(Path(m.group(2)).name) out.append(l) idx[kind] = out # 2. Заголовок в индексе разошёлся с H1 — истина в файле, хук сохраняем. for kind, lines in idx.items(): for i, l in enumerate(lines): m = INDEX_ENTRY.match(l) if not m: continue name = Path(m.group(2)).name task = tasks.get(name) if task and m.group(1) != task["title"]: lines[i] = entry_line(lay, task["title"], name[:-3], (m.group(3) or "").strip()) fixed.append(f"{lay.name(kind)}: заголовок синхронизирован с файлом: {name}") dirty.add(kind) # 3. Нет строки вовсе / задача не в своей секции. Задачу из спринта не # трогаем: «в спринте» — решение набора, а не свойство файла. for name, task in tasks.items(): where = [k for k in lay.indexes if find_entry_index(idx[k], name[:-3]) is not None] if len(where) > 1: continue # неоднозначно — человеку home = home_index(task) if not where: if not task["section"]: continue hi, section = find_section(idx[home], task["section"]) if hi is None: continue insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3], "")) fixed.append(f"{lay.name(home)}: добавлена строка без хука: {name}") dirty.add(home) continue kind = where[0] if kind == "sprint" or kind != home or not task["section"]: continue hi, section = find_section(idx[kind], task["section"]) if hi is None: continue ei = find_entry_index(idx[kind], name[:-3]) cur = next((SECTION.match(idx[kind][j]).group(1) for j in range(ei, -1, -1) if SECTION.match(idx[kind][j])), None) if cur and cur.lower() != section.lower(): insert_entry(idx[kind], section, idx[kind].pop(ei)) fixed.append(f"{lay.name(kind)}: перенесена в секцию «{section}»: {name}") dirty.add(kind) for kind in dirty: save_index(lay, kind, idx[kind]) return fixed # --- init --- def cmd_init(root: Path, a: argparse.Namespace) -> int: if not dir_within_cwd(root): return fail(f"--dir вне рабочего каталога: {root}") cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("plan", a.plan), ("sprint", a.sprint), ("rejected", a.rejected)) if v} lay = Layout(root, cfg) if lay.index("backlog").exists(): return fail(f"{lay.index('backlog')} уже есть — каталог задач заведён") def uniq(raw: str) -> list[str]: out, seen = [], set() for s in (s.strip() for s in raw.split(",")): if s and s.lower() not in seen: out.append(s) seen.add(s.lower()) return out sections, plan_sections = uniq(a.sections), uniq(a.plan_sections) if not sections or not plan_sections: return fail("пустой список секций") root.mkdir(parents=True, exist_ok=True) lay.items.mkdir(parents=True, exist_ok=True) if cfg: write_atomic(root / CONFIG_NAME, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n") write_atomic(lay.index("backlog"), "# Беклог\n\n" f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" "+ строка здесь. Целей тут нет — они в " f"[{lay.name('plan')}]({lay.name('plan')}): беклог — то, что берут,\n" "план — то, подо что берут. Порядка внутри секции нет: «что делать\n" f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n" + "".join(f"## {s}\n\n" for s in sections)) write_atomic(lay.index("plan"), "# План\n\n" f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n" "здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n" f"Первая секция («{plan_sections[0]}») упорядочена, и порядок обосновывается\n" "прозой; остальные — тематические кусты без порядка.\n\n" + "".join(f"## {s}\n\n" for s in plan_sections)) write_atomic(lay.index("sprint"), empty_sprint(lay)) write_atomic(lay.index("rejected"), "# Ушедшее без реализации\n\n" "Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n" f"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n" "есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n" "\n") print(f"каталог задач заведён: {root}") print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}") if cfg: print(f" имена частей записаны в {root / CONFIG_NAME}") return 0 def main() -> int: ap = argparse.ArgumentParser(prog="tasks.py") sub = ap.add_subparsers(dest="command", required=True) p = sub.add_parser("check", help="согласованность файлов и индексов") p.add_argument("--dir") p.add_argument("--fix", action="store_true", help="починить безопасный дрейф (секция, заголовок, дубли)") p = sub.add_parser("list", help="список задач и целей") p.add_argument("--dir") p.add_argument("--stale", action="store_true", help="от самой залежавшейся") p.add_argument("--section") p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) p.add_argument("--tag", help="в том числе goal:<слаг>, question, sprint:<слаг>") p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)") p.add_argument("--index", choices=("backlog", "sprint", "plan", "all")) p.add_argument("--questions", action="store_true", help="только с открытым вопросом") p = sub.add_parser("add", help="завести задачу, идею или цель") p.add_argument("--dir") p.add_argument("--slug", required=True) p.add_argument("--title", required=True) p.add_argument("--type", choices=TYPES) p.add_argument("--section") p.add_argument("--goal", help="слаг цели → тег goal:<слаг>") p.add_argument("--hook") p.add_argument("--reason") p.add_argument("--tag") p = sub.add_parser("edit", help="сменить заголовок/хук/тип/цель/теги") p.add_argument("slug") p.add_argument("--title") p.add_argument("--hook") p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) p.add_argument("--goal", help="заменить тег goal:<слаг>") p.add_argument("--add-tag", dest="add_tag") p.add_argument("--rm-tag", dest="rm_tag") p.add_argument("--dir") p = sub.add_parser("move", help="перенести в другую секцию беклога или часть плана") p.add_argument("slug") p.add_argument("--section", required=True) p.add_argument("--reason") g = p.add_mutually_exclusive_group() g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть плана)") g.add_argument("--first", action="store_true") p.add_argument("--dir") p = sub.add_parser("close", help="закрыть задачу или цель") p.add_argument("slug") g = p.add_mutually_exclusive_group(required=True) g.add_argument("--reason", help="ушла без реализации → строка в REJECTED") g.add_argument("--implemented", action="store_true", help="реализована → просто удалить") p.add_argument("--dir") p = sub.add_parser("sprint", help="операции спринта") ssub = p.add_subparsers(dest="sprint_command", required=True) s = ssub.add_parser("start", help="начать спринт под названную цель") s.add_argument("--goal", required=True) s.add_argument("--date") s.add_argument("--dir") s = ssub.add_parser("take", help="взять задачи в набор") s.add_argument("slugs", nargs="+") s.add_argument("--dir") s = ssub.add_parser("drop", help="вернуть задачи в беклог") s.add_argument("slugs", nargs="+") s.add_argument("--reason", required=True) s.add_argument("--dir") s = ssub.add_parser("close", help="закрыть спринт") s.add_argument("--dissolve", action="store_true", help="распустить набор (блокер)") s.add_argument("--reason") s.add_argument("--dir") p = sub.add_parser("init", help="завести каталог задач в новом проекте") p.add_argument("--dir") p.add_argument("--sections", default=DEFAULT_SECTIONS) p.add_argument("--plan-sections", dest="plan_sections", default=DEFAULT_PLAN_SECTIONS) p.add_argument("--items") p.add_argument("--backlog") p.add_argument("--plan") p.add_argument("--sprint") p.add_argument("--rejected") a = ap.parse_args() if a.command == "init": return cmd_init(Path(a.dir or "docs/tasks"), a) lay = resolve_layout(a.dir) if a.command == "sprint": return {"start": cmd_sprint_start, "take": cmd_sprint_take, "drop": cmd_sprint_drop, "close": cmd_sprint_close}[a.sprint_command](lay, a) return { "check": lambda: check(lay, a.fix), "list": lambda: list_tasks(lay, a), "add": lambda: cmd_add(lay, a), "edit": lambda: cmd_edit(lay, a), "move": lambda: cmd_move(lay, a), "close": lambda: cmd_close(lay, a), }[a.command]() if __name__ == "__main__": sys.exit(main())