#!/usr/bin/env python3 """Детерминированный инструмент управления задачами: файлы против индексов. Преемник backlog.py. Разница по существу одна: **порядка нет, есть цель**. Приоритеты и секция-как-уровень заменены на цель (`goal:<слаг>` тегом) и на спринт — замороженный набор задач под одну цель. Индексов теперь четыре, и задача живёт ровно в одном из них за раз. Раскладка. Путь каталога — `docs/tasks`, жёстко: это часть канона документов av-dev, и подгоняется под него проект. Имена внутри настраиваются через `docs/.pm.json`, ключ `tasks`. docs/tasks/ items/ задачи и цели файлами, .md ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет. Секции канонические: готово | запланировано | направления | разработка (или done | planned | directions | tooling — один язык на весь индекс) BACKLOG.md что можно взять — только задачи, целей здесь нет SPRINT.md текущий спринт: цель, набор, дата, слаг REJECTED.md ушедшее БЕЗ реализации, с причиной и датой Источник истины — файл задачи в items/. Индексы производны: расходятся — неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса: иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда. Исключение из производности одно и оно намеренное: **в каком индексе лежит задача — знают индексы**, потому что «в спринте» это свойство спринта, а не задачи; поля-состояния в файле нет, а рассогласование ловит check. Тип — ключевое слово (goal | idea | task), по-английски, как и прочие токены команд. Обычная задача (task) префикса не несёт, остальные кодируются префиксом `[goal]`/`[idea]` в заголовке H1. Отдельного поля типа нет. **Цель — возможность приложения**, а не тема работ: она отвечает на вопрос «что приложение будет уметь», её «Завершение» — наблюдаемый признак того, что уже умеет. Задача — шаг к этой возможности, она отвечает на «что для этого нужно сделать». Отсюда роадмап и есть состояние проекта: достигнутая цель не исчезает, а переезжает строкой с датой в секцию достигнутого. **Цель обязательна не у всякой задачи.** Новая возможность (`kind:feature`) без цели не бывает — цель и есть её содержание. Починка, техдолг и разведка служат работоспособности, а не направлению, и живут без цели законно; в набор спринта они входят помимо его цели. **Род работы — вторая ось, и она отвечает на другой вопрос.** Тип записи говорит, что это за запись; род (`feature` | `fix` | `chore` | `research`, тегом `kind:<род>`) — какого рода работа. Одним значением на два вопроса не ответить: идея бывает *про* функцию, эпик *и есть* функция. Словарь закрыт — открытый разъедется на синонимах, и отбор по роду перестанет отвечать. Использование: tasks.py init [--dir DIR] [--sections …] [--items …] [--backlog …] … tasks.py check [--dir DIR] [--fix] tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b] [--goal S] [--kind K] [--index backlog|sprint|roadmap|all] [--questions] tasks.py add --slug S --title T [--type goal|idea] [--section S] [--goal G] [--kind K] [--why H] [--reason R] [--tag a,b] [--dir DIR] tasks.py edit S [--title T] [--why H] [--type T] [--goal G] [--kind K] [--add-tag a,b] [--rm-tag c,d] [--section S] [--dir DIR] tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR] tasks.py close S (--reason R | --implemented) [--dir DIR] tasks.py reopen S [--reason R] [--dir DIR] tasks.py sprint start --goal S [--date ГГГГ-ММ-ДД] [--slug S] [--dir DIR] tasks.py sprint take S [S …] [--dir DIR] tasks.py sprint drop S [S …] --reason R [--dir DIR] tasks.py sprint close [--dissolve --reason R] [--dir DIR] tasks.py adopt scan --from PATH [PATH …] [--target DIR] [--out PLAN.json] tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run] Каталог задач: `--dir` (обязан быть внутри рабочего каталога) → `docs/tasks` вверх от текущего каталога. Прежние раскладки (`tasks`, `doc/tasks`) читаются, пока живы непереехавшие проекты; переводит их скилл av-dev-pm:canon. Коды выхода (единый словарь, на нём ветвятся скиллы): 0 всё сошлось / операция выполнена 1 расхождения найдены (только check: дрейф индексов и файлов) 2 ошибка употребления: неверные аргументы, нарушенное правило процесса 3 окружение: каталог задач не найден, конфиг битый или указывает в никуда 4 внутренний сбой (непойманное исключение) — это дефект скрипта Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт заголовок, мета-блок и шаблон-плейсхолдер; агент дописывает редактором. Границы безопасности: слаг — только латиница kebab-case (traversal невозможен), --dir обязан быть внутри рабочего каталога, в заголовок, «зачем» и причину не пролезет перевод строки: поле меты — ровно одна строка. Все проверки идут до первой записи; запись — одним проходом (см. Plan). Язык не зашит инструментально: секции сопоставляются с заголовками индексов как есть, имена служебных файлов и заголовков настраиваются. Текст задач — русский. """ import argparse import datetime import json import re import subprocess import sys from pathlib import Path CONFIG_NAME = ".tasks.json" # прежний дом настроек, читается для совместимости PM_CONFIG_REL = "../.pm.json" # текущий дом: docs/.pm.json, ключ "tasks" EXIT_OK = 0 EXIT_DRIFT = 1 EXIT_USAGE = 2 EXIT_ENV = 3 EXIT_INTERNAL = 4 DEFAULTS = { "items": "items", "backlog": "BACKLOG.md", "roadmap": "ROADMAP.md", "sprint": "SPRINT.md", "rejected": "REJECTED.md", "sprint_section": "Набор", "criteria_heading": "Критерии приёмки", "surface_heading": "Затрагивает", "questions_heading": "Вопросы", "oracle_word": "оракул", } # Какие ключи конфига — имена файлов и каталогов (их существование сверяется # с диском первым делом, иначе кривой ключ выглядит как пропавший файл). PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected") DEFAULT_SECTIONS = "Ядро,Инфра" # Секции роадмапа **канонические**, в отличие от секций беклога. Причина не в # любви к единообразию: у каждой своя семантика — достигнутое, очередь, долгие # направления, работа над инструментом, — в первую пишет сам `close`, и роадмап, # названный по-своему, читался бы только своим автором. Секции беклога семантики # не несут, это полки, и остаются делом проекта. # # Пара на секцию: русское имя и английское. Проект держит **один язык на весь # индекс** — вперемешку это дрейф, который check называет вслух. Сверка везде # идёт по нижнему регистру, а пишется — как здесь: заголовок предложением, с # прописной. ROADMAP_SECTIONS = ( ("Готово", "Done"), # достигнутое: что приложение уже умеет ("Запланировано", "Planned"), # очередь значима, обоснована прозой ("Направления", "Directions"), # очереди нет, тянутся долго ("Разработка", "Tooling"), # инструмент и процесс, не про приложение ) ACHIEVED, PLANNED = 0, 1 # индексы в ROADMAP_SECTIONS DEFAULT_ROADMAP_SECTIONS = ",".join(ru for ru, _ in ROADMAP_SECTIONS) # Мета — список под заголовком, поле на строку. Старая форма (все поля одной # строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в # ней, и `check --fix` переписывает их в новую. Ради этого разделитель `·` и # оставался зарезервированным — в новой форме он ничего не разделяет. META_ITEM = re.compile(r"^-\s+\*\*(.+?):\*\*\s*(.*)$") META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$") # «Хук» — прежнее имя поля «Зачем». Читается, чтобы файлы проектов переезжали # сами; пишется всегда новое. WHY_KEYS = ("зачем", "why", "хук", "hook") # Ключи, которые скрипт у себя признаёт. Нужны не разбору (там ключи # перечислены по месту), а поиску поля, отбившегося от блока: сверять с # закрытым списком — единственный способ не спутать поле меты со строкой тела # вида `- **Важно:** …`. META_KEYS = {"секция", "section", "теги", "tags", *WHY_KEYS} def meta_span(lines: list[str]) -> tuple[int, int] | None: """Границы мета-блока — `[начало, конец)`. None, если меты нет. Мета начинается первой непустой строкой после заголовка. Список тянется, пока строки ему принадлежат; старая форма занимает ровно одну строку. """ i = next((i for i in range(1, len(lines)) if lines[i].strip()), None) if i is None: return None if META_ITEM.match(lines[i].strip()): j = i while j < len(lines) and META_ITEM.match(lines[j].strip()): j += 1 return i, j return (i, i + 1) if META_FIELD.match(lines[i].strip()) else None def meta_legacy(lines: list[str], span: tuple[int, int]) -> bool: """Мета в старой форме — одной строкой. Это дрейф, чинит `check --fix`.""" start, end = span return end - start == 1 and not META_ITEM.match(lines[start].strip()) def meta_fields(lines: list[str], span: tuple[int, int]) -> list[tuple[str, str]]: """Поля мета-блока парами «ключ, значение», в порядке файла.""" start, end = span chunks = lines[start].split("·") if meta_legacy(lines, span) else lines[start:end] out = [] for chunk in chunks: if (f := META_ITEM.match(chunk.strip()) or META_FIELD.match(chunk.strip())): out.append((f.group(1).strip(), f.group(2).strip())) return out 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"^(?:-\s+)?\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)") # `search`, а не `match`: строка меты идёт пунктом списка, а прежняя форма — # третьим полем строки через `·`. Обе читаются, пишется новая. SPRINT_SLUG_LINE = re.compile(r"\*\*(?:Спринт|Sprint):\*\*\s*`?([a-z0-9][a-z0-9.-]*)`?") DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") BULLET = re.compile(r"^[-*]\s+(.*)$") # Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+") GOAL = "goal" TYPES = ("goal", "idea") # непустые типы-ключевые слова, префикс [..] в H1 PLAIN_TYPE = "task" # обычная задача — без префикса TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять в спринт QUESTION_TAG = "question" GOAL_TAG = "goal:" SPRINT_TAG = "sprint:" # Род работы — вторая ось типа. Первая («тип записи»: goal/idea/epic/task) # отвечает «что это за запись», вторая — «какого рода работа». Смешивать их в # одном префиксе нельзя: идея *про* функцию, эпик *и есть* функция, и одно # значение на два вопроса не отвечает. Дом рода — тег, потому что теги здесь и # есть единственный механизм разметки, а `list --tag` уже умеет отбирать. KIND_TAG = "kind:" KINDS = ("feature", "fix", "chore", "research") # Цель обязательна только у новой возможности: цель и есть возможность. # Починка, техдолг и разведка служат работоспособности, а не направлению — # придуманная им цель это то же враньё, от которого спасает род работы. NEEDS_GOAL = ("feature",) DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели») STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки 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 "" rtype, bare = PLAIN_TYPE, title if (m := TYPE_PREFIX.match(title)): rtype, bare = m.group(1).strip().lower(), m.group(2).strip() # Мета — блок под заголовком (task-format.md). Порядок полей свободный: # секция распознаётся, где бы она ни стояла. section, section_raw, reason, why, tags, legacy = "", "", "", "", [], False if (span := meta_span(lines)): legacy = meta_legacy(lines, span) for key, value in meta_fields(lines, span): key = key.lower() if key in ("секция", "section"): section, _, reason = (p.strip() for p in value.partition("—")) section = section.rstrip(".,") section_raw, section = section, section.lower() elif key in WHY_KEYS: why = value 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)), "") kind = next((t[len(KIND_TAG):] for t in tags if t.startswith(KIND_TAG)), "") return {"title": title, "bare": bare, "type": rtype, "section": section, "section_raw": section_raw, "reason": reason, "why": why, "tags": tags, "goal": goal, "kind": kind, "path": path, "stray_meta": stray_meta(lines, span), "legacy_meta": legacy, "text": text, "body": body_sections(text)} def stray_meta(lines: list[str], span: tuple[int, int] | None) -> list[str]: """Поля меты, оставшиеся за пределами блока. Так выглядит мета, разорванная пустой строкой: разбор дочитывает блок до разрыва, а всё, что ниже, становится телом — и поля теряются молча. Ловить обязательно: молчаливая потеря «зачем» или рода работы неотличима от того, что их не задавали, а следующий `edit` допишет второе такое же поле в мету.""" if span is None: return [] out = [] for j in range(span[1], len(lines)): if (m := META_ITEM.match(lines[j].strip())) and m.group(1).strip().lower() in META_KEYS: out.append(m.group(1).strip()) return out 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 "roadmap" 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(Path(line).name, cur) return dates # --- Критерии приёмки: механизируемая часть --- def criteria_stats(lay: Layout, body: str) -> tuple[int, int]: """(сколько утверждений, у скольких не назван оракул). Честно механизируется только счёт пунктов. «Назван оракул» проверяется эвристикой — присутствием слова-маркера (`oracle_word`) в пункте, — и это именно эвристика: настоящий оракул от слова «оракул» она не отличает. Поэтому счёт даёт отказ, а оракул — только замечание. """ marker = lay.cfg["oracle_word"].lower() items: list[str] = [] for line in body.splitlines(): if (m := BULLET.match(line)): items.append(m.group(1)) elif items and line.strip(): items[-1] += " " + line.strip() return len(items), sum(1 for it in items if marker not in it.lower()) def criteria_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]: """Отказы и замечания по критериям приёмки задачи. Общее для check и take.""" crit = lay.cfg["criteria_heading"].lower() body = task["body"].get(crit, "") name = task["path"].name if not body: return ([f"{name}: нет раздела «{lay.cfg['criteria_heading']}»" f" — принимать будет не по чему"], []) n, no_oracle = criteria_stats(lay, body) errors, notes = [], [] if n < CRITERIA_MIN: errors.append(f"{name}: критериев приёмки {n}, надо {CRITERIA_MIN}–{CRITERIA_MAX}" f" проверяемых утверждений списком «- …»") elif n > CRITERIA_MAX: notes.append(f"{name}: критериев приёмки {n} — больше {CRITERIA_MAX};" f" это обычно признак, что задача крупнее задачи") if no_oracle: notes.append(f"{name}: у {no_oracle} из {n} критериев не назван оракул" f" (нет слова «{lay.cfg['oracle_word']}») — проверено только наличие" f" слова, годность оракула машине не видна") return errors, notes def surface_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]: """Отказы и замечания по разделу «Затрагивает». Общее для check и take. Раздел называет **границы**, которых изменение касается: эндпоинт, команду, таблицу и миграцию, формат на диске, публичный тип пакета. Без него задача оценивается по объёму текста, а не по объёму поверхности, — и оценка систематически занижена ровно там, где текст короткий, а границ много. Механизируется только наличие непустого раздела. Полнота перечня машине не видна: границу, которую забыли назвать, от отсутствующей она не отличает. """ name = task["path"].name if not task["body"].get(lay.cfg["surface_heading"].lower()): return ([f"{name}: нет раздела «{lay.cfg['surface_heading']}»" f" — оценивать будет не по чему: границы (эндпоинт, таблица и" f" миграция, формат на диске, публичный тип) не названы"], []) return [], [] def questions_open(lay: Layout, task: dict) -> bool: """Открытый вопрос — это **непустой раздел**, а не тег. Тег производен и забывается; отказ по тегу наказывал бы аккуратного и пропускал забывчивого — стимул ровно обратный записанному правилу. """ return bool(task["body"].get(lay.cfg["questions_heading"].lower())) # --- Спринт --- 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_slug(lay: Layout) -> str: """Слаг самого спринта — им метится урожай (`sprint:<слаг>`).""" for line in read_lines(lay.index("sprint")): if (m := SPRINT_SLUG_LINE.search(line)): return m.group(1) return "" def sprint_header(lay: Layout, goal_slug: str, goal_title: str, date: str, slug: str) -> list[str]: """Шапка спринта — мета-блок той же формы, что у задачи: поле на строку. Прежняя форма (все три поля одной строкой через `·`) читается по-прежнему — обе регулярки её берут, — но чинить её нечем и не нужно: `SPRINT.md` переписывается целиком на `sprint start` и очищается на `sprint close`, так что старая шапка живёт не дольше идущего спринта. """ link = f"{lay.cfg['items']}/{goal_slug}.md" return ["# Спринт", "", f"- **Цель:** [{goal_title}]({link})", f"- **Начат:** {date}", f"- **Спринт:** `{slug}`", "", f"Урожай спринта поднимается `tasks.py list --tag {SPRINT_TAG}{slug}`" " — это первая порция переоценки на сессии.", ""] 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: problems = config_problems(lay) if problems: print(f"задачи: {lay.root} — конфиг не сходится с диском") for p in problems: print(f"КОНФИГ {p}") print("\nсперва конфиг: пока он мимо, всё остальное диагностируется ложно" f" (правь {config_home(lay.root) or lay.root / CONFIG_NAME}" f" или переименуй файлы)") return EXIT_ENV if fix: fixed, ambiguous = apply_fixes(lay) for line in fixed: print(f"ПОЧИНЕНО {line}") for line in ambiguous: print(f"НЕОДНОЗНАЧНО {line}") if fixed or ambiguous: 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) for s in sections["backlog"]: if s.lower() in BLOCKER_SECTIONS: notes.append(f"{label['backlog']}: секции «{s}» быть не должно —" f" блокер это состояние, а не полка: он живёт до ответа" f" человека, а его следы — вопросами в файлах задач") 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]}" f" — перенесёт `check --fix`") place = where[0] if len(where) == 1 else None entry = entries[place][name] if place else None # 2. Секция файла — истина, секция индекса производна. В спринте секция # файла хранит, куда задачу вернуть, и с «Набором» не сверяется. if task["legacy_meta"]: errors.append(f"{name}: мета одной строкой — старая форма;" f" `check --fix` перепишет её списком") if task["stray_meta"]: errors.append(f"{name}: поле меты в теле" f" ({', '.join(task['stray_meta'])}) — мета разорвана" f" пустой строкой, и всё, что ниже разрыва, потеряно." f" Убери пустую строку внутри блока; `--fix` этого не" f" делает: какое из двух значений верное, знает человек") 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 is not None and place != "sprint" and entry 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']}") # 2а. «Зачем»: истина в файле, строка индекса производна. Пока поле # жило только в индексе, восстановление строки его теряло. if entry and task["why"] and entry["why"] != task["why"]: errors.append(f"{name}: «зачем» разошлось (истина в файле)\n" f" файл: {task['why']}\n" f" индекс: {entry['why']}") elif place is not None and entry and not task["why"] and entry["why"]: errors.append(f"{name}: «зачем» есть в {label[place]}, а в файле нет —" f" `check --fix` перенесёт его в мета-блок") elif not task["why"]: notes.append(f"{name}: без «зачем» — по нему выбирают задачу" f" (`tasks.py edit {name[:-3]} --why …`)") # 3. Цель. Связь однонаправленна: задача несёт goal:<слаг>, перечень # задач цели выводится `list --goal`, а не хранится. if task["type"] != GOAL: if not task["goal"]: if task["type"] == "idea": notes.append(f"{name}: идея без цели — цель проставляется," f" когда идея становится задачей") elif task["kind"] in NEEDS_GOAL: errors.append(f"{name}: род «{task['kind']}» без тега" f" {GOAL_TAG}<слаг> — новая возможность и есть" f" содержание цели. Либо цель заводится, либо" f" это не {task['kind']}") # Операционная задача (fix, chore, research) живёт без цели # законно: она служит работоспособности, а не направлению. elif task["goal"] not in goal_slugs: errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель," f" которой нет в {lay.cfg['items']}/") # 3а. Род работы. Замечание, а не отказ: беклог, заведённый до появления # рода, законен, и переоформлять его «заодно» здесь не просят. # Обязательным род становится там, где по нему принимают решение, — # при взятии в спринт. if task["kind"] and task["kind"] not in KINDS: errors.append(f"{name}: род работы «{task['kind']}» не из словаря" f" ({', '.join(KINDS)}) — словарь закрыт, иначе отбор" f" по роду разъедется на синонимах") elif task["type"] == GOAL and task["kind"]: errors.append(f"{name}: у цели род работы «{task['kind']}» —" f" цель это направление, а не работа; род несут её задачи") elif task["type"] in TAKEABLE and not task["kind"]: notes.append(f"{name}: без рода работы — `tasks.py edit {name[:-3]}" f" --kind {'|'.join(KINDS)}`; в спринт без него не возьмут") # 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об # этом раздел, а не тег. if place == "sprint": if questions_open(lay, task): errors.append(f"{name}: в спринте с непустым разделом" f" «{lay.cfg['questions_heading']}» — вопрос разбирается" f" до взятия") elif 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 not task["kind"]: errors.append(f"{name}: в спринте без рода работы" f" (`edit {name[:-3]} --kind …`)") for verdict in (criteria_verdict, surface_verdict): e, n = verdict(lay, task) errors += [f"{x} (в спринте)" for x in e] notes += n # 5. Тег «question» производен от раздела: раздел — факт, тег — метка. has_q_section = questions_open(lay, task) if has_q_section and QUESTION_TAG not in task["tags"]: notes.append(f"{name}: раздел «{lay.cfg['questions_heading']}» непуст," f" а тега «{QUESTION_TAG}» нет — отбор `list --questions`" f" её не увидит (`edit {name[:-3]} --add-tag {QUESTION_TAG}`)") if QUESTION_TAG in task["tags"] and not has_q_section: notes.append(f"{name}: тег «{QUESTION_TAG}» есть, а раздела" f" «{lay.cfg['questions_heading']}» нет — снять тег?") # 6. Цель: «не разобрана» и «всё закрыто» обязаны различаться. if task["type"] == GOAL: mine = sum(1 for t in tasks.values() if t["goal"] == name[:-3]) if not mine and DECOMPOSED_TAG not in task["tags"]: notes.append(f"{name}: цель без задач и без тега «{DECOMPOSED_TAG}» —" f" «ещё не разобрана» и «всё закрыто» неразличимы;" f" тег ставится, когда цель разложена на задачи") elif not mine: notes.append(f"{name}: цель разобрана, а открытых задач не осталось —" f" закрывай (`tasks.py close {name[:-3]} --implemented`)") 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']}: ссылка на несуществующий" f" {lay.cfg['items']}/{name}") lines = read_lines(lay.index(kind)) errors += index_lint(lines, label[kind]) if kind == "roadmap": errors += roadmap_lint(lines, 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']}: набор есть, а цель не названа") if goal_of_sprint and not sprint_slug(lay): errors.append(f"{label['sprint']}: у спринта нет слага (- **Спринт:** `…`) —" f" урожай не отобрать; перезапусти `sprint start`") 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"{lay.name('rejected')}:{num}:" f" строка не по формату «- ГГГГ-ММ-ДД `slug` — …»") # Причина в мете желательна, но не обязательна. Ругаемся только на # частичное покрытие: у части задач причина есть, у части нет — это дрейф. # Ноль из N — осознанный отказ проекта от причин, не расхождение; горящее # на каждом check замечание агент просто научится игнорировать. with_reason = sum(1 for t in tasks.values() if t["reason"]) if 0 < with_reason < len(tasks): notes.append(f"причина есть у {with_reason} из {len(tasks)} —" f" либо у всех, либо ни у кого: вперемешку это дрейф," f" а на причине держится всё, что переживает запись") 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 EXIT_DRIFT print("\nиндексы согласованы" + (f", замечаний: {len(notes)}" if notes else "")) return EXIT_OK 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, _ = sprint_goal(lay) if goal_slug: print(f" спринт: цель «{goal_slug}», слаг «{sprint_slug(lay) or '—'}»," f" задач {len(entries['sprint'])}") else: print(" спринт: не начат") questions = [n for n, t in tasks.items() if questions_open(lay, t)] if questions: print(f" с открытым вопросом: {len(questions)}" f" — в спринт не берутся, разбор первым шагом сессии") # Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но # проверка эвристическая, а беклог, заведённый до правила, переоформляют не # «заодно»: десятки одинаковых замечаний научили бы пропускать весь блок. flat = sorted(n[:-3] for n, t in tasks.items() if t["type"] in TAKEABLE and not action_title(t["bare"])) if flat: print(f" заголовков не в форме действия: {len(flat)}" f" ({', '.join(flat[:5])}{', …' if len(flat) > 5 else ''})" f" — задача отвечает на «что нужно сделать»:" 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] undecomposed = [g for g in empty if DECOMPOSED_TAG not in goals[g]["tags"]] print(f" цели: {len(goals)}, из них без открытых задач {len(empty)}" + (f" ({', '.join(sorted(empty))})" if empty else "") + (f"; не разобрано {len(undecomposed)}" if undecomposed 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: if (err := bad_tags(a.tag)): raise Usage(err) 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 "—" wanted = split_tags(a.tag) # список: отбираются задачи со ВСЕМИ тегами known_tags = {t for task in tasks.values() for t in task["tags"]} 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 wanted and not set(wanted) <= set(t["tags"]): continue if a.goal and t["goal"] != a.goal.lower(): continue if a.kind and t["kind"] != a.kind.lower(): continue if a.questions and not questions_open(lay, t): 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 "" rtype = "" if t["type"] == PLAIN_TYPE else f"[{t['type']}] " kind = "" if a.kind else f"{t['kind']:<9}" goal = f" →{t['goal']}" if t["goal"] and not a.goal else "" flag = " ?" if questions_open(lay, t) else " " print(f"{touched}{t['place']:<8}{t['section']:<12}{kind}{flag} " f"{t['path'].stem:<44} {rtype}{t['bare']}{goal}") print(f"\nвсего: {len(rows)}") # Пустой ответ обязан объясняться: молчаливый ноль читается как «таких # задач нет», хотя чаще это опечатка в теге или отбор по И вместо ИЛИ. if wanted: missing = [t for t in wanted if t not in known_tags] if missing: print(f" тегов нет ни у одной задачи: {', '.join(missing)}" f" (есть: {', '.join(sorted(known_tags)) or '—'})") elif len(wanted) > 1: print(f" отбор по нескольким тегам — это И, а не ИЛИ:" f" нужны все {len(wanted)} сразу") return EXIT_OK # --- Мутации: правят файл и индексы заодно, рассогласовать их вручную нельзя --- def build_meta(section: str, reason: str, why: str, tags: list[str]) -> str: out = [f"- **Секция:** {section}" + (f" — {reason}" if reason else "")] if why: out.append(f"- **Зачем:** {why}") if tags: out.append("- **Теги:** " + ", ".join(tags)) return "\n".join(out) def section_headers(lines: list[str]) -> list[tuple[int, str]]: return [(i, m.group(1)) for i, line in enumerate(lines) if (m := SECTION.match(line))] def canon_section(lines: list[str], which: int) -> str | None: """Имя канонической секции роадмапа **как она названа в этом индексе**. Проект пишет либо русские имена, либо английские; скрипт принимает оба и возвращает то, что нашёл, — иначе `insert_entry` промахнётся мимо секции, которая есть, но названа на другом языке. """ names = {n.lower() for n in ROADMAP_SECTIONS[which]} for _, section in section_headers(lines): if section.lower() in names: return section return None def roadmap_lint(lines: list[str], label: str) -> list[str]: """Секции роадмапа: все канонические, все на месте, все на одном языке.""" known = {n.lower(): i for i, pair in enumerate(ROADMAP_SECTIONS) for n in pair} errors: list[str] = [] seen: dict[int, str] = {} langs: set[int] = set() for _, section in section_headers(lines): i = known.get(section.lower()) if i is None: ru = ", ".join(pair[0] for pair in ROADMAP_SECTIONS) errors.append(f"{label}: секция «{section}» не из канона роадмапа" f" ({ru}). Секции роадмапа несут смысл и потому" f" закреплены; прозаический заголовок здесь — секция," f" в которую может уехать цель") continue langs.add(0 if section.lower() == ROADMAP_SECTIONS[i][0].lower() else 1) if section not in ROADMAP_SECTIONS[i]: errors.append(f"{label}: секция «{section}» написана не как в каноне" f" ({' | '.join(ROADMAP_SECTIONS[i])}) — заголовок" f" пишется с прописной; починит `check --fix`") if i in seen: errors.append(f"{label}: секция «{section}» повторяет «{seen[i]}» —" f" это одна и та же секция на двух языках") else: seen[i] = section missing = [ROADMAP_SECTIONS[i][0] for i in range(len(ROADMAP_SECTIONS)) if i not in seen] if missing: errors.append(f"{label}: нет секций: {', '.join(missing)}." f" Роадмап отвечает на «что умеет и чего не умеет»" f" целиком — отсутствующая секция это отсутствующий ответ") if len(langs) > 1: errors.append(f"{label}: секции вперемешку на двух языках — выбери один") return errors def find_section(lines: list[str], name: str) -> tuple[int | None, str]: for i, s in section_headers(lines): if s.lower() == (name or "").lower(): return i, s return None, "" def find_entry_index(lines: list[str], slug: str) -> int | None: for i, line in enumerate(lines): m = INDEX_ENTRY.match(line) if m and Path(m.group(2)).name == f"{slug}.md": return i return None def locate_all(lay: Layout, slug: str) -> dict[str, tuple[list[str], int]]: """Все индексы, где есть строка задачи. Правится **каждый**: иначе задача, случайно попавшая в два индекса, чинится наполовину и вторая строка остаётся со старым заголовком и старым «зачем».""" found: dict[str, tuple[list[str], int]] = {} for kind in lay.indexes: lines = read_lines(lay.index(kind)) ei = find_entry_index(lines, slug) if ei is not None: found[kind] = (lines, ei) return found 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) if hi is None: raise Usage(f"секции «{section}» в индексе нет") end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines)) if first: # Пустые строки после заголовка пропускаются, но только если за ними # что-то есть: у пустой секции пропускать нечего, и строка, вставленная # в её конец, съела бы отбивку перед следующим заголовком. ins = next((j for j in range(hi + 1, end) if lines[j].strip()), hi + 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 meta_rebuilt(lines: list[str], section: str | None = None, reason: str | None = None, why: str | None = None, tags: list[str] | None = None) -> list[str] | None: """Строки файла с пересобранным мета-блоком. None — меты нет. Пересобирается **весь блок**, а не правится по месту: заодно старая форма (всё одной строкой через `·`) переезжает в новую. Нераспознанные поля переносятся как есть, с их написанием ключа, и встают после известных — терять чужое поле нельзя, но и порядок ему диктовать незачем. `None` в аргументе — «не трогать», пустая строка — «убрать поле». Ничего не пишет: запись — дело Plan. """ span = meta_span(lines) if span is None: return None old = meta_fields(lines, span) cur_section, cur_reason, cur_why, cur_tags, extra = "", "", "", "", [] seen_section = False for key, value in old: low = key.lower() if low in ("секция", "section"): cur_section, _, cur_reason = (p.strip() for p in value.partition("—")) seen_section = True elif low in WHY_KEYS: cur_why = value elif low in ("теги", "tags"): cur_tags = value else: extra.append(f"- **{key}:** {value}") if not seen_section: return None # мета без секции сломана; `check` скажет это словами out = [f"- **Секция:** {section if section is not None else cur_section}"] rsn = reason if reason is not None else cur_reason if rsn: out[0] += f" — {rsn}" new_why = why if why is not None else cur_why if new_why: out.append(f"- **Зачем:** {new_why}") new_tags = ", ".join(tags) if tags is not None else cur_tags if new_tags: out.append(f"- **Теги:** {new_tags}") return [*lines[:span[0]], *out, *extra, *lines[span[1]:]] def meta_updated(path: Path, section: str | None = None, reason: str | None = None, why: str | None = None, tags: list[str] | None = None) -> str | None: lines = path.read_text(encoding="utf-8").splitlines() out = meta_rebuilt(lines, section=section, reason=reason, why=why, tags=tags) return None if out is None else "\n".join(out) + "\n" def entry_line(lay: Layout, title: str, slug: str, why: str) -> str: link = f"{lay.cfg['items']}/{slug}.md" return f"- [{title}]({link})" + (f" — {why}" if why 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['surface_heading']}\n\n" "\n\n" f"## {lay.cfg['criteria_heading']}\n\n" f"\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_why(a.why), bad_tags(a.tag), bad_reason(a.reason), bad_kind(a.kind), bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) if not a.title.strip(): raise Usage("пустой заголовок") rtype = (a.type or "").strip().lower() path = lay.items / f"{a.slug}.md" if path.exists(): raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый") target = "roadmap" if rtype == GOAL else "backlog" lines = read_lines(lay.index(target)) if not lines: raise Usage(f"нет индекса {lay.name(target)} — прогони tasks.py init") if find_entry_index(lines, a.slug) is not None: raise Usage(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)) raise Usage(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 a.kind: tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"] elif not rtype: print(f" без рода работы — проставь `tasks.py edit {a.slug} --kind" f" {'|'.join(KINDS)}`: в спринт без него не возьмут") if (rtype != GOAL and (a.kind or "") in NEEDS_GOAL and not any(t.startswith(GOAL_TAG) for t in tags)): print(f" род «{a.kind}» без цели: новая возможность и есть содержание" f" цели — проставь `tasks.py edit {a.slug} --goal <слаг>`") # Урожай спринта метится сам: тег, который никто не ставит, не отбирает # первую порцию переоценки, а именно на ней держится правило «сперва урожай». sslug = sprint_slug(lay) if rtype != GOAL and sslug and not any(t.startswith(SPRINT_TAG) for t in tags): tags.append(f"{SPRINT_TAG}{sslug}") if QUESTION_TAG in tags: print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле") title_full = f"[{rtype}] {a.title}" if rtype else a.title meta = build_meta(section, a.reason or "", a.why or "", tags) insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or "")) plan = Plan() plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(rtype, lay)}") plan.index(lay, target, lines) plan.commit() print(f"создано: {lay.cfg['items']}/{a.slug}.md," f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором") if sslug and f"{SPRINT_TAG}{sslug}" in tags: print(f" помечено тегом {SPRINT_TAG}{sslug} — урожай идущего спринта") if not a.why: print(f" без «зачем» — задай: tasks.py edit {a.slug} --why …") warn_rejected(lay, a.slug, a.title) return EXIT_OK 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_why(a.why), bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_kind(a.kind), bad_slug(a.goal) if a.goal else None): if err: raise Usage(err) if all(v is None for v in (a.title, a.why, a.type, a.goal, a.kind, a.add_tag, a.rm_tag, a.section)): raise Usage("нечего менять: дай --title, --why, --type, --goal, --kind," " --add-tag или --rm-tag") path = lay.items / f"{a.slug}.md" if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") places = locate_all(lay, a.slug) if not places: raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix") task = parse_task(path) if a.title is not None and not a.title.strip(): raise Usage("пустой заголовок") 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() old_home, new_home = home_index(task), home_index({"type": kind}) in_sprint = "sprint" in places # Задача в спринте меняет тип только через выход из набора: [epic] или # [idea] в наборе — состояние, которое check объявит ошибкой, а молчаливый # успех оставит набор в нём. if in_sprint and kind not in TAKEABLE: raise Usage(f"{a.slug} в спринте, а тип «{kind}» в наборе не живёт:" f" сперва выведи задачу — tasks.py sprint drop {a.slug} --reason …") 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("#"): raise Usage(f"{a.slug}.md без заголовка H1 — прогони check") 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 a.kind is not None: tags = [t for t in tags if not t.startswith(KIND_TAG)] + [f"{KIND_TAG}{a.kind.lower()}"] why = task["why"] if a.why is None else a.why section = task["section"] if a.section is not None: if old_home == new_home: raise Usage("--section у edit — только вместе со сменой типа, меняющей" f" индекс. Секция внутри индекса — это move (он пишет причину):" f" tasks.py move {a.slug} --section {a.section} --reason …") section = a.section.strip().lower() # Смена типа между целью и задачей — это переезд между индексами, а не # отказ: задача лежит ровно в одном индексе, неоднозначности нет. if old_home != new_home: target_lines = read_lines(lay.index(new_home)) hi, section_name = find_section(target_lines, section) if hi is None: avail = ", ".join(n for _, n in section_headers(target_lines)) raise Usage(f"смена типа переносит строку в {lay.name(new_home)}," f" а секции «{section}» там нет (есть: {avail}) —" f" задай `--section <из перечисленных>`") section = section_name.lower() new_text = meta_updated(path, section=section if section else None, why=why if a.why is not None else None, tags=tags if tags != task["tags"] else None) if new_text is None: raise Usage(f"{a.slug}.md без поля **Секция:** в мете — прогони check и почини") tlines = new_text.splitlines() tlines[0] = f"# {h1}" new_text = "\n".join(tlines) + "\n" plan = Plan() plan.file(path, new_text) if old_home != new_home: old_lines, ei = places[old_home] old_lines.pop(ei) # строка в новом индексе собирается заново plan.index(lay, old_home, old_lines) target_lines = read_lines(lay.index(new_home)) insert_entry(target_lines, section, entry_line(lay, h1, a.slug, why)) plan.index(lay, new_home, target_lines) else: for kind_index, (lines, ei) in places.items(): lines[ei] = entry_line(lay, h1, a.slug, why) plan.index(lay, kind_index, lines) plan.commit() changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type), ("цель", a.goal), ("род работы", a.kind), ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") if len(places) > 1: print(f" задача была сразу в нескольких индексах" f" ({', '.join(lay.name(k) for k in places)}) — поправлены все строки," f" но само это состояние ошибочно: прогони check") if old_home != new_home: print(f" строка переехала: {lay.name(old_home)} → {lay.name(new_home)}" f" (секция «{section}»)") if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"] and in_sprint: print(" задача в спринте, а вопрос открыт: либо остаток есть и вопрос ждёт сессии," f" либо задача выходит — `tasks.py sprint drop {a.slug} --reason …`") return EXIT_OK 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: raise Usage(err) path = lay.items / f"{a.slug}.md" if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") places = locate_all(lay, a.slug) if not places: raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix") if "sprint" in places: raise Usage(f"{a.slug} в спринте: секция — свойство беклога." f" Сперва верни задачу: tasks.py sprint drop {a.slug} --reason …") if len(places) > 1: raise Usage(f"{a.slug} сразу в нескольких индексах" f" ({', '.join(lay.name(k) for k in places)}) — неоднозначно," f" разбери сам: tasks.py check") kind_index, (lines, ei) = next(iter(places.items())) hi, section = find_section(lines, a.section) if hi is None: avail = ", ".join(n for _, n in section_headers(lines)) raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})") new_text = meta_updated(path, section=section, reason=a.reason) if new_text is None: raise Usage(f"{a.slug}.md без поля **Секция:** в мете — прогони check и почини") entry = lines.pop(ei) try: insert_entry(lines, section, entry, a.after, a.first) except KeyError as e: raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e plan = Plan() plan.file(path, new_text) plan.index(lay, kind_index, lines) plan.commit() print(f"{a.slug}: перенесено в «{section}» ({lay.name(kind_index)})") return EXIT_OK def section_of_achieved(lines: list[str]) -> str: """Секция достигнутого этого роадмапа. Нет её — отказ по существу: цель закрыть некуда, и молча удалить её значит стереть половину ответа.""" name = canon_section(lines, ACHIEVED) if name is None: ru, en = ROADMAP_SECTIONS[ACHIEVED] raise Usage(f"в роадмапе нет секции достигнутого («{ru}» или «{en}») —" f" закрывать цель некуда. Заведи секцию и повтори") return name def is_achieved(task: dict, a: argparse.Namespace) -> bool: """Цель, закрытая как достигнутая: `close --implemented` без причины.""" return task["type"] == GOAL and not a.reason def achieved_entry(task: dict, slug: str, date: str) -> str: """Строка секции достигнутого. Ссылки на файл в ней нет намеренно: файл удаляется, а битую ссылку `check` справедливо назовёт ошибкой. Поведение живёт в спеках проекта — здесь остаётся «что и когда стало возможно».""" why = task["why"] tail = f" {why[0].upper()}{why[1:]}" if why else "" if tail and not tail.rstrip().endswith((".", "!", "?")): tail = tail.rstrip() + "." dot = "" if task["bare"].endswith((".", "!", "?")) else "." return f"- {date} `{slug}` — {task['bare']}{dot}{tail}" def cmd_close(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_reason(a.reason)): if err: raise Usage(err) path = lay.items / f"{a.slug}.md" if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") places = locate_all(lay, a.slug) if not places: raise Usage(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: raise Usage(f"у цели {a.slug} осталось открытых задач: {len(open_tasks)}" f" ({', '.join(sorted(t[:-3] for t in open_tasks))})." f" Цель закрыта, когда не осталось её задач") plan = Plan() today = datetime.date.today().isoformat() if a.reason: reason = a.reason.rstrip() dot = "" if reason.endswith((".", "!", "?")) else "." bullet = (f"- {today} `{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" plan.file(rej, prev + bullet + "\n") # Достигнутая цель — единственная запись, переживающая удаление файла без # причины. Роадмап отвечает не только «что осталось», но и «что уже умеет», # а этот ответ иначе стирался вместе с целью, и его вели прозой руками. achieved = achieved_entry(task, a.slug, today) if is_achieved(task, a) else None for kind_index, (lines, ei) in places.items(): lines.pop(ei) if achieved is not None and kind_index == "roadmap": insert_entry(lines, section_of_achieved(lines), achieved, first=True) achieved = None plan.index(lay, kind_index, lines) if achieved is not None: # строки в роадмапе не было — чиним lines = read_lines(lay.index("roadmap")) insert_entry(lines, section_of_achieved(lines), achieved, first=True) plan.index(lay, "roadmap", lines) plan.delete(path) plan.commit() if is_achieved(task, a): # Имя берём из индекса: проект мог назвать секции по-английски. where = (canon_section(read_lines(lay.index("roadmap")), ACHIEVED) or ROADMAP_SECTIONS[ACHIEVED][0]) print(f"{a.slug}: цель достигнута — строка перенесена в" f" {lay.name('roadmap')}, секция «{where}», файл удалён") else: print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") if "sprint" in places and a.reason: print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта") if not a.reason: print(" дорога назад: файл восстанавливается из git —" f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`") return EXIT_OK def git_deleted_text(path: Path) -> str | None: """Текст закрытой задачи из истории git. Два источника, и второй обязателен. Коммит удаления — обычный случай: закрытие уже уехало в историю. Но пайплайн закрывает задачу **последним шагом**, и между удалением файла и коммитом учёта есть окно, в котором коммита удаления ещё нет, а текст лежит в `HEAD`. Без второго источника `reopen` отказывал бы ровно на свежезакрытой задаче — то есть в самом вероятном своём применении. """ try: sha = subprocess.run(["git", "log", "--diff-filter=D", "--format=%H", "-n", "1", "--", str(path)], capture_output=True, text=True).stdout.strip() for rev in ([f"{sha}^"] if sha else []) + ["HEAD"]: out = subprocess.run(["git", "show", f"{rev}:{path}"], capture_output=True, text=True) if out.returncode == 0: return out.stdout return None except FileNotFoundError: return None def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: """Задача была закрыта, а приёмка не сошлась. Закрытие удаляет файл, поэтому без возврата приёмщику, нашедшему расхождение, возвращать нечего. Порядок правильный — `close --implemented` после вердикта приёмки, — но ошибка порядка обязана иметь дорогу назад. """ for err in (bad_slug(a.slug), bad_reason(a.reason)): if err: raise Usage(err) path = lay.items / f"{a.slug}.md" if path.exists(): raise Usage(f"{a.slug}.md на месте — возвращать нечего;" f" пропала строка индекса — это `check --fix`") text = git_deleted_text(path) if text is None: raise Usage(f"в истории git нет удаления {path} — восстановить нечем." f" Заведи заново: tasks.py add --slug {a.slug} --title …") task_lines = text.splitlines() title = task_lines[0].removeprefix("#").strip() if task_lines else a.slug kind = PLAIN_TYPE if (m := TYPE_PREFIX.match(title)): kind = m.group(1).strip().lower() if a.reason: upd = meta_updated_text(text, reason=a.reason) if upd is None: print(" внимание: меты нет, причина возврата не записана в файл") else: text = upd tmp = parse_task_text(text, path) plan = Plan() plan.file(path, text) goal_of_sprint, _ = sprint_goal(lay) target = home_index({"type": kind}) section = tmp["section"] if target == "backlog" and goal_of_sprint and tmp["goal"] == goal_of_sprint: target, section = "sprint", lay.cfg["sprint_section"] lines = read_lines(lay.index(target)) # Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая # правка читает индекс с диска, где первой ещё нет, и затирает её. unachieved: list[str] = [] if kind == GOAL: kept = [] for line in lines: if REJECTED_ENTRY.match(line) and f"`{a.slug}`" in line: unachieved.append(line) else: kept.append(line) lines = kept if find_entry_index(lines, a.slug) is None: hi, sec = find_section(lines, section) if hi is None: avail = ", ".join(n for _, n in section_headers(lines)) raise Usage(f"секции «{section}» нет в {lay.name(target)} (есть: {avail})") insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"])) plan.index(lay, target, lines) elif unachieved: plan.index(lay, target, lines) rej = lay.index("rejected") if rej.is_file(): keep, removed = [], [] for line in read_lines(rej): if REJECTED_ENTRY.match(line) and f"`{a.slug}`" in line: removed.append(line) else: keep.append(line) if removed: plan.file(rej, "\n".join(keep)) else: removed = [] plan.commit() print(f"{a.slug}: возвращён в {lay.name(target)} из истории git" + (f" (секция «{section}»)" if target != "sprint" else " (набор спринта)")) for line in removed: print(f" снята строка {lay.name('rejected')}: {line.strip()}") for line in unachieved: print(f" снята строка достигнутого в" f" {lay.name('roadmap')}: {line.strip()}") print(" роадмап больше не утверждает, что приложение это умеет") print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее" " живёт только в коммите задачи") return EXIT_OK def parse_task_text(text: str, path: Path) -> dict: """parse_task для текста, которого ещё нет на диске (возврат из git).""" tmp = path.with_name(path.name + ".reopen-tmp") tmp.write_text(text, encoding="utf-8") try: return parse_task(tmp) finally: tmp.unlink(missing_ok=True) def meta_updated_text(text: str, reason: str) -> str | None: """То же для текста, которого ещё нет на диске (возврат задачи из git).""" out = meta_rebuilt(text.splitlines(), reason=reason) return None if out is None else "\n".join(out) + "\n" # --- Спринт --- def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_slug(a.goal)): raise Usage(err) entries, _ = parse_entries(read_lines(lay.index("sprint"))) if entries: raise Usage(f"в {lay.name('sprint')} ещё есть набор ({len(entries)}) —" f" закрой спринт: tasks.py sprint close") gpath = lay.items / f"{a.goal}.md" if not gpath.exists(): raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/") goal = parse_task(gpath) if goal["type"] != GOAL: raise Usage(f"{a.goal} не цель (тип «{goal['type']}») — спринт набирается под [goal]") date = a.date or datetime.date.today().isoformat() if not DATE_RE.fullmatch(date): raise Usage("дата в формате ГГГГ-ММ-ДД") # Слаг спринта — дата: естественный, монотонный и уже уникальный. Он нужен # не для красоты: без него тег `sprint:<слаг>` некому проставить, а на нём # держится правило «первая порция переоценки — урожай прошедшего спринта». slug = (a.slug or date).lower() if not re.fullmatch(r"[a-z0-9][a-z0-9.-]*", slug): raise Usage(f"слаг спринта «{slug}» — латиница, цифры, дефис и точка") tasks = tasks_of(lay) if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()): print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта" f" — урожаи склеятся; задай другой `--slug`") lines = [*sprint_header(lay, a.goal, goal["title"], date, slug), f"## {lay.cfg['sprint_section']}", ""] plan = Plan() plan.index(lay, "sprint", lines) plan.commit() ready = [n[:-3] for n, t in tasks.items() if t["goal"] == a.goal and t["type"] in TAKEABLE and not questions_open(lay, t)] print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»") print(f" кандидатов под цель без открытых вопросов: {len(ready)}" + (f" ({', '.join(sorted(ready))})" if ready else "")) print(f" заводимое по ходу метится тегом {SPRINT_TAG}{slug} само — это урожай") print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта") return EXIT_OK def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: goal_slug, _ = sprint_goal(lay) if not goal_slug: raise Usage("спринт не начат: 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: raise Usage(f"в {lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»") taken, warn = [], [] for slug in a.slugs: if (err := bad_slug(slug)): raise Usage(err) path = lay.items / f"{slug}.md" if not path.exists(): raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/") t = parse_task(path) if t["type"] not in TAKEABLE: raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —" f" идея идёт на штурм, цель не берут вовсе") if t["goal"] and t["goal"] != goal_slug: raise Usage(f"{slug}: цель «{t['goal']}» не цель спринта «{goal_slug}» —" f" набор служит одной цели, даже если взять удобно") if not t["goal"] and t["kind"] in NEEDS_GOAL: raise Usage(f"{slug}: род «{t['kind']}» без цели —" f" новая возможность и есть содержание цели" f" (`edit {slug} --goal <слаг>`)") # Отказ по факту, а не по метке: непустой раздел «Вопросы» блокирует # взятие независимо от тега. Забывший тег иначе проходил бы, а # поставивший спотыкался — стимул ровно обратный правилу. if questions_open(lay, t): raise Usage(f"{slug}: непустой раздел «{lay.cfg['questions_heading']}» —" f" вопрос разбирается до взятия, вне очереди порции." f" Отвечен — запиши ответ в тело и очисти раздел" f" (тег снимается `edit {slug} --rm-tag {QUESTION_TAG}`)") if QUESTION_TAG in t["tags"]: raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела" f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда," f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`") if not t["kind"]: raise Usage(f"{slug}: род работы не назван —" f" `edit {slug} --kind {'|'.join(KINDS)}`." f" По нему видно, что в наборе одни починки и ни одной" f" функции, а это разговор про цель, а не про набор") for verdict in (criteria_verdict, surface_verdict): errs, notes = verdict(lay, t) if errs: raise Usage("; ".join(errs)) warn += notes ei = find_entry_index(backlog_lines, slug) if ei is None: raise Usage(f"{slug}: строки в {lay.name('backlog')} нет" f" (уже в спринте? прогони check)") entry = backlog_lines.pop(ei) insert_entry(sprint_lines, section, entry) taken.append(slug) plan = Plan() plan.index(lay, "backlog", backlog_lines) plan.index(lay, "sprint", sprint_lines) plan.commit() total = len(parse_entries(sprint_lines)[0]) print(f"взято в спринт: {', '.join(taken)}; в наборе {total}") for w in warn: print(f" замечание: {w}") return EXIT_OK def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_reason(a.reason)): raise Usage(err) sprint_lines = read_lines(lay.index("sprint")) backlog_lines = read_lines(lay.index("backlog")) plan = Plan() dropped = [] # Сперва все проверки и весь план правок, потом запись. Иначе отказ на # втором слаге оставлял первый файл переписанным при нетронутых индексах: # задача числилась в спринте и одновременно объясняла, почему из него вышла. for slug in a.slugs: if (err := bad_slug(slug)): raise Usage(err) ei = find_entry_index(sprint_lines, slug) if ei is None: raise Usage(f"{slug}: в наборе спринта такой строки нет") path = lay.items / f"{slug}.md" if not path.exists(): raise Usage(f"{slug}: строка в {lay.name('sprint')} есть, а файла" f" {lay.cfg['items']}/{slug}.md нет — прогони check") t = parse_task(path) hi, section = find_section(backlog_lines, t["section"]) if hi is None: avail = ", ".join(n for _, n in section_headers(backlog_lines)) raise Usage(f"{slug}: секция «{t['section'] or '—'}» не найдена" f" в {lay.name('backlog')} (есть: {avail})") new_text = meta_updated(path, section=section, reason=a.reason) if new_text is None: raise Usage(f"{slug}.md без поля **Секция:** в мете — прогони check и почини") plan.file(path, new_text) insert_entry(backlog_lines, section, sprint_lines.pop(ei)) dropped.append(slug) plan.index(lay, "backlog", backlog_lines) plan.index(lay, "sprint", sprint_lines) plan.commit() print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мету") print(" живого предложения оставаться не должно; наработки, которые жалко," " переносятся в тело задачи текстом") return EXIT_OK def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_reason(a.reason)): raise Usage(err) entries, _ = parse_entries(read_lines(lay.index("sprint"))) goal_slug, _ = sprint_goal(lay) slug = sprint_slug(lay) if entries and not a.dissolve: raise Usage(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: raise Usage("--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)) != EXIT_OK: return rc plan = Plan() plan.file(lay.index("sprint"), empty_sprint(lay)) plan.commit() print(f"спринт закрыт (цель «{goal_slug or '—'}», слаг «{slug or '—'}»)" + (", набор распущен" if a.dissolve and entries else "")) print(f" история наборов остаётся в git: `git log -p {lay.index('sprint')}`") if slug: print(f" урожай спринта — `tasks.py list --tag {SPRINT_TAG}{slug}`:" f" завести найденное по ходу обязан закрывающий спринт, а не пайплайн") print(f" автотег снят вместе со спринтом: заводимое СЕЙЧАС метится только" f" вручную — `add … --tag {SPRINT_TAG}{slug}`, иначе выпадет из урожая") print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор") return EXIT_OK # --- check --fix --- def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: """Детерминированная починка дрейфа. Чинит только то, где истина однозначно в файле или где источник ровно один: - дубли строк на один файл, рассинхрон заголовка, задача не в своей секции; - отсутствующая строка индекса — восстанавливается **вместе с «зачем»** из меты файла; - «зачем», оставшееся только в индексе, — переносится в файл (миграция со старого формата: другого экземпляра нет, неоднозначности тоже); - строка в чужом индексе (цель в беклоге, задача в роадмапе) — переносится в домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из чего: истина в типе, а тип в файле; - цель, у которой есть задачи, помечается `decomposed`. Неоднозначное (ссылка на исчезнувший файл, задача сразу в двух индексах, битые строки) не трогает — это на суд человека, и о нём говорится вслух. """ fixed: list[str] = [] ambiguous: list[str] = [] tasks = tasks_of(lay) idx = {k: read_lines(lay.index(k)) for k in lay.indexes} files: dict[Path, str] = {} dirty: set[str] = set() # 0. Форма меты. Старая (всё одной строкой через `·`) переписывается # списком. Правка чисто механическая: поля те же, включая нераспознанные. for name, task in tasks.items(): if not task["legacy_meta"]: continue upd = meta_updated(task["path"]) if upd is None: ambiguous.append(f"{name}: мета одной строкой и без поля **Секция:** —" f" переписать нечего, чинится руками") continue files[task["path"]] = upd fixed.append(f"{name}: мета переписана списком") # 1. Дубли строк на один файл — оставляем первую. for kind, lines in idx.items(): seen: set[str] = set() out: list[str] = [] for line in lines: m = INDEX_ENTRY.match(line) 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(line) idx[kind] = out # 2. «Зачем»: истина в файле. Если в файле нет, а в индексе есть — это # старый формат, и единственный экземпляр надо спасти в файл. for kind, lines in idx.items(): for i, line in enumerate(lines): m = INDEX_ENTRY.match(line) if not m: continue name = Path(m.group(2)).name task = tasks.get(name) if not task: continue idx_why = (m.group(3) or "").strip() if not task["why"] and idx_why: upd = meta_updated(task["path"], why=idx_why) if upd is None: ambiguous.append(f"{name}: «зачем» только в {lay.name(kind)}," f" а в файле нет меты — перенести некуда") continue files[task["path"]] = upd task["why"] = idx_why fixed.append(f"{name}: «зачем» перенесено из {lay.name(kind)} в мету файла") if m.group(1) != task["title"] or idx_why != task["why"]: lines[i] = entry_line(lay, task["title"], name[:-3], task["why"]) 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: ambiguous.append(f"{name}: сразу в {', '.join(lay.name(k) for k in where)}" f" — какая строка лишняя, решает человек") continue home = home_index(task) if not where: if not task["section"]: ambiguous.append(f"{name}: строки нет ни в одном индексе, и в файле" f" нет секции — восстанавливать не по чему") continue hi, section = find_section(idx[home], task["section"]) if hi is None: ambiguous.append(f"{name}: строки нет, а секции «{task['section']}»" f" нет в {lay.name(home)} — восстанавливать некуда") continue insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3], task["why"])) fixed.append(f"{lay.name(home)}: восстановлена строка {name}" + ("" if task["why"] else " (в файле нет «зачем» — допиши)")) dirty.add(home) continue kind = where[0] if kind == "sprint": continue if kind != home: # Задача ровно в одном индексе и не в своём: истина в типе, а тип # в файле — неоднозначности нет, переносим и говорим об этом. hi, section = find_section(idx[home], task["section"]) if hi is None: ambiguous.append(f"{name}: лежит в {lay.name(kind)}, дом — {lay.name(home)}," f" но секции «{task['section']}» там нет" f" (есть: {', '.join(n for _, n in section_headers(idx[home]))})") continue ei = find_entry_index(idx[kind], name[:-3]) if ei is None: # сюда попали по where — строка обязана быть raise RuntimeError(f"{name}: строка в {lay.name(kind)} пропала посреди" f" прохода — чинить нечего, отчёт был бы враньём") insert_entry(idx[home], section, idx[kind].pop(ei)) fixed.append(f"{name}: строка перенесена {lay.name(kind)} → {lay.name(home)}" f" (секция «{section}») — по типу «{task['type']}» её место там") dirty.add(kind) dirty.add(home) continue if 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]) if ei is None: raise RuntimeError(f"{name}: строка в {lay.name(kind)} пропала посреди" f" прохода — чинить нечего, отчёт был бы враньём") cur = next((m.group(1) for j in range(ei, -1, -1) if (m := 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) # 4. Цель, у которой есть задачи, разобрана по факту — пометка производна. for name, task in tasks.items(): if task["type"] != GOAL or DECOMPOSED_TAG in task["tags"]: continue if any(t["goal"] == name[:-3] for t in tasks.values()): upd = meta_updated(task["path"], tags=[*task["tags"], DECOMPOSED_TAG]) if upd is None: continue files[task["path"]] = upd fixed.append(f"{name}: проставлен тег «{DECOMPOSED_TAG}» — у цели есть задачи") # 5. Форма индексов: канонический регистр секций роадмапа и отбивка после # заголовков. Регистр правится только у **канонических** секций: имена # секций беклога — дело проекта, и подгонять их под свой вкус скрипт # права не имеет. canon = {n.lower(): pair for pair in ROADMAP_SECTIONS for n in pair} for kind, lines in idx.items(): if kind == "roadmap": for j, line in enumerate(lines): if not (m := SECTION.match(line)): continue pair = canon.get(m.group(1).lower()) if pair is None or m.group(1) in pair: continue want = pair[0] if m.group(1).lower() == pair[0].lower() else pair[1] lines[j] = f"## {want}" fixed.append(f"{lay.name(kind)}: секция «{m.group(1)}» → «{want}»") dirty.add(kind) if spaced_sections(lines) != lines: fixed.append(f"{lay.name(kind)}: отбивка после заголовков секций") dirty.add(kind) # 6. Написание секции в мете. Имя секции принадлежит **заголовку индекса** — # файл на секцию только ссылается, а принадлежность сверяется по нижнему # регистру. Поэтому расхождение в одном регистре однозначно: побеждает # заголовок. Без этого шага переезд на канон оставил бы «Готово» в # роадмапе и «готово» в каждом файле цели. for name, task in tasks.items(): kind = home_index(task) if not task["section_raw"] or kind not in idx: continue _, heading = find_section(idx[kind], task["section"]) if not heading or heading == task["section_raw"]: continue # Правим уже отложенный текст, если файл трогали выше: перечитать его с # диска значило бы стереть проставленный шагом 4 тег. staged = files.get(task["path"]) src = (staged.splitlines() if staged is not None else task["path"].read_text(encoding="utf-8").splitlines()) rebuilt = meta_rebuilt(src, section=heading) if rebuilt is None: continue files[task["path"]] = "\n".join(rebuilt) + "\n" fixed.append(f"{name}: секция в мете «{task['section_raw']}» → «{heading}»") plan = Plan() for path, text in files.items(): plan.file(path, text) for kind in dirty: plan.index(lay, kind, idx[kind]) plan.commit() return fixed, ambiguous # --- init --- def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], cfg: dict) -> dict[Path, str]: out: dict[Path, str] = {} if cfg: # Дом настроек один — `docs/.pm.json`, ключ `tasks`. Писать в # `.tasks.json` при живом `.pm.json` значит писать туда, откуда никто # не читает: load_config его в этом случае игнорирует. pm = (lay.root / PM_CONFIG_REL).resolve() if pm.is_file(): data = _read_json(pm) section = data.get("tasks") or {} if not isinstance(section, dict): raise Env(f"{pm}: ключ «tasks» — ожидался объект с настройками") data["tasks"] = {**section, **cfg} out[pm] = json.dumps(data, ensure_ascii=False, indent=2) + "\n" else: out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False, indent=2) + "\n" out[lay.index("backlog")] = ( "# Беклог\n\n" f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" "+ строка здесь. Целей тут нет — они в " f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n" "роадмап — то, подо что берут. Порядка внутри секции нет: «что делать\n" f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n" "Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n" "(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n" "человека, а его следы — вопросами в файлах задач.\n\n" + "".join(f"## {s}\n\n" for s in sections)) out[lay.index("roadmap")] = ( "# Роадмап\n\n" "Состояние проекта: что приложение **уже умеет** и чего ещё не умеет.\n" f"Цель — возможность приложения, файл `[goal]` в `{lay.cfg['items']}/`; её\n" "задачи здесь **не перечисляются** — перечень даёт\n" "`tasks.py list --goal <слаг>`.\n\n" f"- **{ROADMAP_SECTIONS[ACHIEVED][0]}** — достигнутое: строку пишет\n" " `tasks.py close <цель> --implemented`, ссылки на файл в ней нет —\n" " файл удаляется, поведение живёт в спеках;\n" f"- **{ROADMAP_SECTIONS[PLANNED][0]}** — очередь значима и обосновывается прозой;\n" f"- **{ROADMAP_SECTIONS[2][0]}** — очереди нет, тянутся долго;\n" f"- **{ROADMAP_SECTIONS[3][0]}** — инструмент и процесс, не возможности\n" " приложения. Отдельно, чтобы не читаться как обещание продукта.\n\n" "Секции **канонические** и переименованию проектом не подлежат:\n" "у каждой свой смысл, и в первую пишет сам `close`. Английский\n" f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)}," " один язык на весь\nиндекс.\n\n" + "".join(f"## {s}\n\n" for s in roadmap_sections)) out[lay.index("sprint")] = empty_sprint(lay) out[lay.index("rejected")] = ( "# Ушедшее без реализации\n\n" "Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n" "Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n" "есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n" "\n") return out def uniq_sections(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 def cmd_init(root: Path, a: argparse.Namespace) -> int: if not dir_within_cwd(root): raise Usage(f"--dir вне рабочего каталога: {root}") cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap), ("sprint", a.sprint), ("rejected", a.rejected)) if v} lay = Layout(root, cfg) if lay.index("backlog").exists(): raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён") sections = uniq_sections(a.sections) roadmap_sections = uniq_sections(DEFAULT_ROADMAP_SECTIONS) if not sections: raise Usage("пустой список секций") blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS] if blockers: raise Usage(f"секции «{', '.join(blockers)}» в беклоге не заводим: блокер — это" f" состояние, а не полка. Он живёт до ответа человека, а следы" f" остаются вопросами в файлах задач; постоянно пустая секция" f" со старой семантикой «разбираются пачками» противоречит" f" правилу «спрашиваем немедленно»") lay.items.mkdir(parents=True, exist_ok=True) plan = Plan() for path, text in init_files(lay, sections, roadmap_sections, cfg).items(): plan.file(path, text) plan.commit() print(f"каталог задач заведён: {root}") print(f" секции беклога: {', '.join(sections)};" f" секции роадмапа канонические: {', '.join(roadmap_sections)}") if cfg: pm = (root / PM_CONFIG_REL).resolve() print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}") return EXIT_OK # --- adopt: прийти в чужой репозиторий и вывести каталог задач из того, что есть --- INDEX_CANDIDATES = ("README.md", "BACKLOG.md", "index.md", "INDEX.md") GRAVEYARD_CANDIDATES = ("CLOSED.md", "REJECTED.md", "DONE.md") OLD_META = re.compile(r"^-?\s*\*\*(Приоритет|Секция|Priority|Section):\*\*\s*(.*)$") QUESTION_HEADINGS = ("что решить", "варианты и цена", "открытый вопрос", "вопрос", "что заблокировано", "рекомендация") def translit_ish(slug: str) -> bool: """Явные признаки транслита — и только они. Это подсказка, а не приговор: отличить английское слово от транслита машина не умеет, поэтому scan печатает и общее число слагов, требуя проверить все. Сам перевод («taj-brejk» → «tie-break») делает агент. """ return bool(re.search(r"shch|zh|kh|sch|yu|ya|yj|ij|tsi|nyj|ost|enie", slug)) def scan_old_backlog(src: Path) -> dict: """Раскладка av-dev-backlog: индекс README.md, кладбище CLOSED.md, файлы рядом с индексом, приоритеты секциями.""" index_name = next((n for n in INDEX_CANDIDATES if (src / n).is_file()), None) graveyard = next((n for n in GRAVEYARD_CANDIDATES if (src / n).is_file()), None) found: dict = {"kind": "backlog-dir", "source": str(src), "index": index_name, "graveyard": graveyard, "items": [], "rejected": [], "unclassified": []} entries, sections = ({}, []) if index_name: entries, sections = parse_entries(read_lines(src / index_name)) found["sections"] = sections seen: set[str] = set() for path in sorted(src.glob("*.md")): if path.name in INDEX_CANDIDATES or path.name in GRAVEYARD_CANDIDATES: continue seen.add(path.name) text = path.read_text(encoding="utf-8") lines = text.splitlines() title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else "" kind = PLAIN_TYPE bare = title if (m := TYPE_PREFIX.match(title)): kind, bare = m.group(1).strip().lower(), m.group(2).strip() entry = entries.get(path.name, {}) old_section, reason = "", "" body_start = 1 for i, line in enumerate(lines[1:8], 1): if (m := OLD_META.match(line.strip())): old_section, _, reason = (p.strip() for p in m.group(2).partition("—")) body_start = i + 1 break body = "\n".join(lines[body_start:]).strip() headings = [h.lower() for h in re.findall(r"^##\s+(.+?)\s*$", body, flags=re.M)] qh = next((h for h in headings if h in QUESTION_HEADINGS), "") found["items"].append({ "old_slug": path.stem, "slug": path.stem, "title": bare, "type": kind, "old_section": old_section or entry.get("section", ""), "section": "", "reason": reason, "why": entry.get("why", ""), "goal": "", "in_index": path.name in entries, "translit": translit_ish(path.stem), "questions_heading": qh, "source": str(path), }) if index_name: for name, entry in entries.items(): if name not in seen: found["unclassified"].append({ "what": f"строка индекса «{entry['title']}» → {name}", "where": f"{src / index_name}:{entry['line']}", "why": "файла нет — переносить нечего, текст только в строке"}) if graveyard: for line in read_lines(src / graveyard): if line.startswith("- "): found["rejected"].append(line) return found STEP = re.compile(r"^\**\s*(\d+)[.)]\s*\**\s*(.+?)\**\s*$") def scan_list_file(path: Path) -> dict: """TODO.md, «планы» в README, список шагов в плане проекта. **Нумерованный шаг плана → кандидат в цель из «порядка»** (готовая цель: у него уже есть очередь и обоснование), прочий пункт → кандидат в задачу. Ничего не решает: и то и другое едет в карту предложением, назначает человек. """ found: dict = {"kind": "list-file", "source": str(path), "items": [], "goal_candidates": [], "unclassified": []} heading = "" # Пункт, перенесённый на следующие строки, — один пункт: иначе половина # абзаца уезжает в заголовок задачи обрубком. bullets: list[list] = [] # [строка, состояние, текст, заголовок] open_bullet = False for num, line in enumerate(read_lines(path), 1): if (m := re.match(r"^#{1,6}\s+(.+?)\s*$", line)): heading, open_bullet = m.group(1).strip(), False continue # Нумерованный пункт номер сохраняет: по нему он и опознаётся шагом. m = re.match(r"^\s*[-*]\s*(?:\[([ xX~])\]\s*)?(.+?)\s*$", line) \ or re.match(r"^\s*()(\d+[.)]\s+.+?)\s*$", line) if m: bullets.append([num, (m.group(1) or " "), m.group(2).strip(), heading]) open_bullet = True elif not line.strip(): open_bullet = False elif open_bullet: bullets[-1][2] += " " + line.strip() for num, state, text, heading in bullets: clean = re.sub(r"[`*]", "", text).strip() if (s := STEP.match(text)): found["goal_candidates"].append({ "title": re.sub(r"[`*]", "", s.group(2)).strip().rstrip("."), "from": f"{path}:{num}", "step": int(s.group(1)), "done": state.lower() == "x", "section": heading}) continue if len(clean) < 4: found["unclassified"].append({"what": clean[:60], "where": f"{path}:{num}", "why": "пункт короче четырёх символов"}) continue if len(clean) > 200: found["unclassified"].append({"what": clean[:60] + "…", "where": f"{path}:{num}", "why": "абзац прозой, а не пункт списка —" " задача из него не выводится машинально"}) continue found["items"].append({ "old_slug": "", "slug": "", "title": clean[:120], "type": PLAIN_TYPE, "old_section": heading, "section": "", "reason": "", "why": "", "goal": "", "in_index": False, "translit": False, "questions_heading": "", "source": f"{path}:{num}", "body": text, "done": state.lower() == "x", }) return found def cmd_adopt_scan(a: argparse.Namespace) -> int: sources = [Path(s) for s in a.sources] for s in sources: if not s.exists(): raise Usage(f"источник не найден: {s}") scans = [] for s in sources: scans.append(scan_old_backlog(s) if s.is_dir() else scan_list_file(s)) items, rejected, unclassified, goals = [], [], [], [] for sc in scans: items += sc["items"] rejected += sc.get("rejected", []) unclassified += sc.get("unclassified", []) goals += [{"slug": "", "title": g["title"], "section": ROADMAP_SECTIONS[PLANNED][0], "from": g["from"], "step": g.get("step"), "done": g.get("done"), "body": f"Выведена из шага «{g['title']}» ({g['from']})." + ("\n\nШаг помечен закрытым — цель, скорее всего," " заводить не надо." if g.get("done") else "")} for g in sc.get("goal_candidates", [])] dup: dict[str, int] = {} for it in items: dup[it["slug"] or it["title"]] = dup.get(it["slug"] or it["title"], 0) + 1 for key, n in dup.items(): if n > 1: unclassified.append({"what": key, "where": "несколько источников", "why": f"слаг встретился {n} раза — оставь один"}) # Куда переехали сами файлы: индекс, кладбище и каталог записей. Без этого # ссылки вида `docs/backlog/README.md` из чужих документов останутся битыми. path_map: list[list[str]] = [] for sc in scans: if sc["kind"] != "backlog-dir": continue src = sc["source"].rstrip("/") if sc.get("index"): path_map.append([f"{src}/{sc['index']}", f"{a.target}/{DEFAULTS['backlog']}"]) if sc.get("graveyard"): path_map.append([f"{src}/{sc['graveyard']}", f"{a.target}/{DEFAULTS['rejected']}"]) path_map.append([f"{src}/", f"{a.target}/{DEFAULTS['items']}/"]) plan = { "version": 1, "target": a.target, "sources": [str(s) for s in sources], "path_map": path_map, "sections_backlog": uniq_sections(a.sections), "sections_roadmap": uniq_sections(DEFAULT_ROADMAP_SECTIONS), "section_map": {}, "goals": goals, "items": items, "rejected": rejected, "unclassified": unclassified, } print(f"адаптация: найдено записей {len(items)}," f" кандидатов в цели {len(goals)}," f" строк кладбища {len(rejected)}," f" не разложилось {len(unclassified)}") for sc in scans: if sc["kind"] == "backlog-dir": print(f" {sc['source']}: раскладка беклога, индекс" f" {sc['index'] or '—'}, кладбище {sc['graveyard'] or '—'}," f" секции: {', '.join(sc['sections']) or '—'}") else: print(f" {sc['source']}: список — пунктов {len(sc['items'])}," f" кандидатов в цели {len(sc['goal_candidates'])}") by_section: dict[str, int] = {} for it in items: by_section[it["old_section"] or "—"] = by_section.get(it["old_section"] or "—", 0) + 1 print(" по исходным секциям: " + ", ".join(f"{k} {v}" for k, v in sorted(by_section.items()))) have_slug = [it for it in items if it["old_slug"]] translit = [it["old_slug"] for it in have_slug if it["translit"]] if have_slug: print(f" слагов на входе {len(have_slug)}, из них с явными признаками" f" транслита {len(translit)} — но проверить надо **все**:" f" английское слово от транслита машина не отличает," f" перевод и переименование делает агент") if translit: print(f" явные: {', '.join(translit[:6])}{' …' if len(translit) > 6 else ''}") no_index = [it["old_slug"] for it in items if it["old_slug"] and not it["in_index"]] if no_index: print(f" файлы вне индекса: {', '.join(no_index)}") done = [it["title"] for it in items if it.get("done")] if done: print(f" помечено сделанным на входе ({len(done)}) — сделанное не переносим," f" убери из карты: {', '.join(t[:40] for t in done[:5])}") withq = [it["old_slug"] for it in items if it["questions_heading"]] if withq: print(f" похоже на открытый вопрос в прозе ({len(withq)}):" f" {', '.join(withq[:8])}{' …' if len(withq) > 8 else ''}") if unclassified: print(" НЕ РАЗЛОЖИЛОСЬ (поимённо):") for u in unclassified: print(f" - {u['what']} [{u['where']}]: {u['why']}") out = Path(a.out) out.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"\nкарта записана: {out}") print(" дальше: заполни в карте `slug` (английский), `section`, `goal` у каждой" " записи и список `goals`, покажи карту человеку и только потом —" f" `tasks.py adopt apply --plan {out}`") return EXIT_OK def requalify_links(text: str, old_dir: Path, new_dir: Path) -> str: """Относительные ссылки тела после переезда файла глубже. `docs/backlog/x.md` знал соседей как `../passport.md`; из `docs/tasks/items/x.md` тот же файл — уже `../../passport.md`. Молча съехавшая на уровень ссылка — самый дешёвый способ развалить документацию. """ delta = len(new_dir.parts) - len(old_dir.parts) if delta <= 0: return text return re.sub(r"\]\((\.\./)", "](" + "../" * (delta + 1), text) def rewrite_refs(paths: list[Path], renames: dict[str, str], path_map: list[tuple[str, str]], dry: bool) -> tuple[int, dict[str, int]]: """Перекрёстные ссылки на переименованные слаги — одним проходом. Переименование, разнесённое по времени, оставляет битые ссылки, которых никто не проверяет: `[текст](старый.md)`, `` `старый` `` и голое упоминание в прозе. Считаем и говорим, сколько нашли и где. """ per_slug: dict[str, int] = {} touched = 0 pats = [(re.compile(r"(? int: plan_path = Path(a.plan) if not plan_path.is_file(): raise Usage(f"карты нет: {plan_path}") try: pl = json.loads(plan_path.read_text(encoding="utf-8")) except json.JSONDecodeError as e: raise Usage(f"{plan_path}: не разбирается как JSON — {e}") from e root = Path(pl["target"]) if not dir_within_cwd(root): raise Usage(f"target вне рабочего каталога: {root}") lay = Layout(root, {}) sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS) roadmap_sections = pl.get("sections_roadmap") or uniq_sections(DEFAULT_ROADMAP_SECTIONS) known_sections = {s.lower() for s in sections} known_roadmap = {s.lower() for s in roadmap_sections} # --- проверки: все до первой записи --- problems: list[str] = [] if lay.index("backlog").exists(): problems.append(f"{lay.index('backlog')} уже есть — адаптация не поверх живого" f" каталога; выбери пустой target") slugs: set[str] = set() for g in pl.get("goals", []): if not g.get("slug"): problems.append(f"цель «{g.get('title', '?')}» без слага — заполни карту") continue if (e := bad_slug(g["slug"])): problems.append(f"цель: {e}") if g["slug"] in slugs: problems.append(f"слаг «{g['slug']}» встречается дважды") slugs.add(g["slug"]) if g.get("section", "").lower() not in known_roadmap: problems.append(f"цель {g['slug']}: секция «{g.get('section', '')}»" f" не из роадмапа ({', '.join(roadmap_sections)})") goal_slugs = {g["slug"] for g in pl.get("goals", []) if g.get("slug")} for it in pl.get("items", []): slug = it.get("slug") or it.get("old_slug") if not slug: problems.append(f"запись «{it.get('title', '?')}» без слага") continue if (e := bad_slug(slug)): problems.append(e) if slug in slugs: problems.append(f"слаг «{slug}» встречается дважды") slugs.add(slug) if it.get("section", "").lower() not in known_sections: problems.append(f"{slug}: секция «{it.get('section', '')}» не из беклога" f" ({', '.join(sections)})") if it.get("goal") and it["goal"] not in goal_slugs: problems.append(f"{slug}: цель «{it['goal']}» не заведена в карте") for e in (bad_why(it.get("why")), bad_reason(it.get("reason"))): if e: problems.append(f"{slug}: {e}") if problems: for p in problems: print(f"ОШИБКА {p}") raise Usage(f"карта не готова: {len(problems)} проблем — правь {plan_path}") # --- план записи --- wr = Plan() for path, text in init_files(lay, sections, roadmap_sections, {}).items(): wr.file(path, text) backlog_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("backlog")].splitlines() roadmap_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("roadmap")].splitlines() renames: dict[str, str] = {} for g in pl.get("goals", []): title = f"[{GOAL}] {g['title']}" meta = build_meta(g["section"].lower(), g.get("reason", ""), g.get("why", ""), g.get("tags", [])) body = g.get("body", "").strip() wr.file(lay.items / f"{g['slug']}.md", f"# {title}\n\n{meta}\n\n{body}\n\n## Завершение\n\n" f"\n") insert_entry(roadmap_lines, g["section"].lower(), entry_line(lay, title, g["slug"], g.get("why", ""))) for it in pl.get("items", []): slug = it.get("slug") or it["old_slug"] if it.get("old_slug") and it["old_slug"] != slug: renames[it["old_slug"]] = slug kind = (it.get("type") or PLAIN_TYPE).lower() title = it["title"] if kind == PLAIN_TYPE else f"[{kind}] {it['title']}" tags = list(it.get("tags", [])) if it.get("goal"): tags.append(f"{GOAL_TAG}{it['goal']}") body = it.get("body", "") if not body and it.get("source") and Path(str(it["source"]).split(":")[0]).is_file(): src = Path(str(it["source"]).split(":")[0]) src_lines = src.read_text(encoding="utf-8").splitlines() start = 1 for i, line in enumerate(src_lines[1:8], 1): if OLD_META.match(line.strip()): start = i + 1 break body = "\n".join(src_lines[start:]).strip() body = requalify_links(body, src.parent, lay.items) qh = it.get("questions_heading", "") if qh: body = re.sub(rf"^##\s+{re.escape(qh)}\s*$", f"## {lay.cfg['questions_heading']}", body, count=1, flags=re.M | re.I) if QUESTION_TAG not in tags: tags.append(QUESTION_TAG) meta = build_meta(it["section"].lower(), it.get("reason", ""), it.get("why", ""), tags) wr.file(lay.items / f"{slug}.md", f"# {title}\n\n{meta}\n\n{body}\n") insert_entry(backlog_lines, it["section"].lower(), entry_line(lay, title, slug, it.get("why", ""))) if pl.get("rejected"): head = init_files(lay, sections, roadmap_sections, {})[lay.index("rejected")] body = [] for line in pl["rejected"]: line = re.sub(r"Был приоритет:", "Была секция:", line) for old, new in renames.items(): line = re.sub(r"(?= CRITERIA_MIN] print("\nпереходное состояние — назови его в докладе целиком:") print(f" задач без цели: {len(no_goal)} — это ОШИБКИ check" f" (правится `tasks.py edit <слаг> --goal <цель>`)" + (f": {', '.join(sorted(x[:-3] for x in no_goal)[:5])}…" if no_goal else "")) print(f" задач без {CRITERIA_MIN}+ критериев приёмки: {len(no_crit)} —" f" check это ошибкой не считает, но `sprint take` их не возьмёт:" f" собрать спринт сегодня физически нечем") print(f" закрывается порциями переоценки по 5–8 задач (скилл session, шаг 3):" f" проставить цели, превратить «готово, когда» в критерии с оракулами," f" вынуть вопросы из прозы в раздел. Готовность к первому спринту —" f" не «check зелёный», а «есть {CRITERIA_MIN}+ критериев хотя бы у набора" f" под одну цель».") print(" источники не удалены: сверь глазами и убери сам" f" ({', '.join(pl.get('sources', []))}) — удалять чужое молча нельзя.") print(" подписи ссылок машина не трогает: цель ссылки поправлена, а текст" " вида «[старый путь](новый путь)» правит агент глазами.") return EXIT_OK 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("--kind", choices=KINDS, help="род работы → тег kind:<род>") p.add_argument("--index", choices=("backlog", "sprint", "roadmap", "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("--kind", choices=KINDS, help="род работы → тег kind:<род>") p.add_argument("--why") 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("--why") p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) p.add_argument("--goal", help="заменить тег goal:<слаг>") p.add_argument("--kind", choices=KINDS, help="заменить тег kind:<род>") p.add_argument("--add-tag", dest="add_tag") p.add_argument("--rm-tag", dest="rm_tag") p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс") 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("reopen", help="вернуть закрытую задачу (приёмка не сошлась)") p.add_argument("slug") p.add_argument("--reason", 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("--slug", help="слаг спринта; по умолчанию дата начала") 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("--items") p.add_argument("--backlog") p.add_argument("--roadmap") p.add_argument("--sprint") p.add_argument("--rejected") p = sub.add_parser("adopt", help="вывести каталог задач из того, что уже есть в репозитории") asub = p.add_subparsers(dest="adopt_command", required=True) s = asub.add_parser("scan", help="только карта: что найдено и как разложилось") s.add_argument("--from", dest="sources", nargs="+", required=True) s.add_argument("--target", default="docs/tasks") s.add_argument("--out", default="tasks-adopt-plan.json") s.add_argument("--sections", default=DEFAULT_SECTIONS) s = asub.add_parser("apply", help="записать каталог по подтверждённой карте") s.add_argument("--plan", required=True) s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги") s.add_argument("--dry-run", dest="dry_run", action="store_true") a = ap.parse_args() if a.command == "init": return cmd_init(Path(a.dir or "docs/tasks"), a) if a.command == "adopt": return cmd_adopt_scan(a) if a.adopt_command == "scan" else cmd_adopt_apply(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), "reopen": lambda: cmd_reopen(lay, a), }[a.command]() if __name__ == "__main__": try: sys.exit(main()) except Usage as e: print(f"ошибка: {e}", file=sys.stderr) sys.exit(EXIT_USAGE) except Env as e: print(f"окружение: {e}", file=sys.stderr) sys.exit(EXIT_ENV) except KeyboardInterrupt: sys.exit(EXIT_INTERNAL) except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr) sys.exit(EXIT_INTERNAL)