#!/usr/bin/env python3 """Детерминированный инструмент управления задачами: файлы против индексов. Приоритет — не поле, а **порядок строк в беклоге**. Индекс один: беклог. `REJECTED.md` индексом не считается: он не говорит, где запись числится, он кладбище ушедшего. Раскладка. Путь каталога — `tasks/` в корне репозитория по умолчанию; другой называется ключом `[tasks] dir`. Каталог принадлежит этому скиллу, а не канону документов: учёт работ ведут и в проекте, который к канону не приведён. Имена частей, **стадия проекта** и **версия раскладки** живут в `.av-dev.toml` в корне; журнал версий — references/changelog.md скилла canon. tasks/ items/ задачи файлами, .md BACKLOG.md что можно взять. Порядок строк внутри секции значим, но значит он **разное на разных стадиях** (см. ниже) REJECTED.md ушедшее БЕЗ реализации, с причиной и датой Источник истины — файл задачи в items/. Индекс производен: расходятся — неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса: иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда. Исключение из производности одно и намеренное: **порядок строк в беклоге** — приоритет это свойство очереди, а не задачи, и в файле ему места нет. Рассогласование ловит check. **Стадия проекта — ось, и она решает, что значит порядок строк.** build (стройка) — приложение ещё строится. Беклог = план от базы к деталям, **секция ровно одна**, порядок = зависимость: раньше нельзя. Список пишется вперёд целиком, и это не гниение беклога, а замысел. Пустой беклог значит, что стройка окончена. support (доработка) — приложение работает, правки точечные. Секции — полки домена, порядок внутри полки = важность: раньше лучше. Заводится по одной, по мере появления; пустой беклог — нормальное состояние. Стадия объявляется ключом `[tasks] stage` и меняется командой `stage`. Молчание ответом не считается: без ключа check отказывает, потому что читать порядок строк не по чему. Тип — вторая ось записи и **закрытый словарь из четырёх значений**: feature | fix | chore | research, по-английски, как и прочие токены команд. Дом типа — **поле меты `Тип` первой строкой**; эмодзи в заголовке H1 производна от него, её ставит и чинит `check --fix`. Эмодзи нужна там, где принимают решение «брать или не брать», — в строке индекса, а она копирует H1 дословно. Прежних осей было две: тип записи (goal | idea | task) и род работы (`kind:<род>` тегом). Ортогональность была фальшивой — из двенадцати клеток произведения законны шесть, — а «алгоритм решения задач такого типа» крепится не к `task`, а к `fix` и `research`. Отдельного типа `idea` тоже не осталось: он значил не род работы, а **состояние незаполненности**, и это состояние теперь называется честно — `research` без раздела «Вопрос». **Тип `goal` и роадмап упразднены.** Цель была зонтиком над параллельными направлениями — она нужна там, где список работ нельзя выстроить в один порядок. У проекта, который ведёт один человек, такого не бывает: на стройке список линеен по зависимости, на доработке правки независимы. Половину своего вопроса роадмап при этом дублировал беклогом («чего ещё не умеет» = «что осталось в списке»), а вторую половину («что уже умеет») отвечают спеки и `git log` индекса. **Тип определяет схему записи**: какие разделы тела обязательны, какие допустимы, берётся ли запись в работу. Схема — TYPE_SCHEMA; проза с алгоритмом работы над каждым типом — `references/task-<тип>.md`. Использование: tasks.py init --stage build|support [--dir DIR] [--sections …] [--items …] … tasks.py stage [build|support] [--sections …] [--dir DIR] tasks.py check [--dir DIR] [--fix] tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b] [--raw] [--questions] tasks.py add --slug S --title T --type feature|fix|chore|research [--section S] [--why H] [--reason R] [--tag a,b] [--dir DIR] tasks.py edit S [--title T] [--why H] [--type T] [--add-tag a,b] [--rm-tag c,d] [--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 ready S [S …] [--dir DIR] tasks.py adopt scan --from PATH [PATH …] --stage S [--target DIR] [--out PLAN.json] tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run] Каталог задач: `--dir` (обязан быть внутри рабочего каталога) → `tasks/` вверх от текущего каталога. Прежние раскладки (`docs/tasks`, `doc/tasks`) читаются, пока живы непереехавшие проекты; переезд — запись 11 закрытого журнала канона (changelog-before-merge.md). Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф против окружения» — av-dev/shared/axes.md. Значения — в константах ниже; здесь код 1 приходит только от check. Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт заголовок, мета-блок и шаблон-плейсхолдер; агент дописывает редактором. Границы безопасности: слаг — только латиница kebab-case (traversal невозможен), --dir обязан быть внутри рабочего каталога, в заголовок, «зачем» и причину не пролезет перевод строки: поле меты — ровно одна строка. Все проверки идут до первой записи; запись — одним проходом (см. Plan). Язык не зашит инструментально: секции сопоставляются с заголовками индексов как есть, имена служебных файлов и заголовков настраиваются. Текст задач — русский. """ import argparse import datetime import importlib.util import json import re import subprocess import sys from pathlib import Path from types import ModuleType def _load_shared() -> ModuleType: """Общий читатель `.av-dev.toml` — `shared/config.py` этого же плагина. Путь считается от файла скрипта: зовут его из репозитория проекта, где дерева плагина в текущем каталоге нет. """ path = Path(__file__).resolve().parents[3] / "shared" / "config.py" # Проверка именно файлом: `spec_from_file_location` на отсутствующем пути # возвращает исправный спек, и падает уже `exec_module` — трейсбеком и кодом # 1, то есть «найден дрейф, чинится». Битая установка дрейфом не является. spec = importlib.util.spec_from_file_location("avdev_config", path) if not path.is_file() or spec is None or spec.loader is None: print(f"ОТКАЗ: не читается {path} — общий читатель настроек;" f" переустанови плагин av-dev", file=sys.stderr) sys.exit(3) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module conf = _load_shared() CONFIG_NAME = conf.CONFIG_NAME # дом настроек и версии: `.av-dev.toml` в корне # Версия раскладки — **одна на плагин**, и живёт она в `shared/config.py`. # Своей у каталога задач больше нет: пока плагинов было три и ставились они # порознь, проект мог иметь учёт работ без канона документов, и общее число # было бы домом, которого у половины проектов нет. Плагин один — довод ушёл, а # два числа вместо одного оставляли бы вопрос «по какому журналу повышать». # # Переезды каталога, случившиеся до слияния (в корень, отмена спринтов), задним # числом в журнал не переписаны: они названы прежними журналами, и второй # перечень тех же шагов разошёлся бы с первым. LAYOUT_VERSION = conf.VERSION VERSION_KEY = conf.VERSION_KEY EXIT_OK = 0 EXIT_DRIFT = 1 EXIT_USAGE = 2 EXIT_ENV = 3 EXIT_INTERNAL = 4 # Ключи `dir` и `stage` в DEFAULTS не входят намеренно: первый говорит, **где** # каталог, второй — **на какой стадии проект**, и ни один не называет имени # части. В `Layout` (тот про имена внутри) им делать нечего. DIR_KEY = "dir" DEFAULT_DIR = "tasks" # Стадия проекта — ось, и решает она, что значит порядок строк беклога. # На стройке порядок это зависимость (раньше нельзя), на доработке — важность # (раньше лучше). Отсюда и всё остальное: сколько у беклога секций, как его # пополняют и что означает его опустошение. STAGE_KEY = "stage" BUILD, SUPPORT = "build", "support" STAGES = (BUILD, SUPPORT) STAGE_RU = {BUILD: "стройка", SUPPORT: "доработка"} DEFAULTS = { "items": "items", "backlog": "BACKLOG.md", "rejected": "REJECTED.md", "criteria_heading": "Критерии приёмки", "surface_heading": "Затрагивает", "questions_heading": "Вопросы", "repro_heading": "Воспроизведение", "question_heading": "Вопрос", "answer_heading": "Куда ляжет ответ", "scope_heading": "Рамки", "oracle_word": "оракул", } # Какие ключи конфига — имена файлов и каталогов (их существование сверяется # с диском первым делом, иначе кривой ключ выглядит как пропавший файл). PATH_KEYS = ("items", "backlog", "rejected") # Умолчания секций по стадиям. На доработке это **полки домена**: смысла они не # несут, называет их проект. На стройке секция ровно одна — список от базы к # деталям, — и её имя тоже дело проекта: различать ей нечего, она одна. DEFAULT_SECTIONS = {BUILD: "План", SUPPORT: "Ядро,Инфра"} # Мета — список под заголовком, поле на строку. Старая форма (все поля одной # строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в # ней, и `check --fix` переписывает их в новую. Ради этого разделитель `·` и # оставался зарезервированным — в новой форме он ничего не разделяет. META_ITEM = re.compile(r"^-\s+\*\*(.+?):\*\*\s*(.*)$") META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$") # «Хук» — прежнее имя поля «Зачем». Читается, чтобы файлы проектов переезжали # сами; пишется всегда новое. WHY_KEYS = ("зачем", "why", "хук", "hook") # Ключи, которые скрипт у себя признаёт. Нужны не разбору (там ключи # перечислены по месту), а поиску поля, отбившегося от блока: сверять с # закрытым списком — единственный способ не спутать поле меты со строкой тела # вида `- **Важно:** …`. TYPE_KEYS = ("тип", "type") # Поле места называется **«Категория»**: у задачи оно называет полку беклога, на # которой она лежит. «Секция» — прежнее имя, оставшееся от целей и роадмапа; # разбор принимает его по-прежнему, чтобы файлы переезжали сами, а `check --fix` # переименовывает. PLACE_KEY = "Категория" PLACE_KEYS = ("категория", "category", "секция", "section") META_KEYS = {*TYPE_KEYS, *PLACE_KEYS, "теги", "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") 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-]+` — .+") RESEARCH = "research" # Тип — ось записи и **закрытый словарь**. Открытый разъедется на синонимах # (`bug`, `bugfix`, `fix`, `defect`), и отбор по типу перестанет отвечать на # свой единственный вопрос. Ни один тип не подходит — это сигнал, что в записи # их два и её надо разделить. TYPES = ("feature", "fix", "chore", RESEARCH) # Эмодзи **производна от типа**, а не второй его дом: её ставит `add` и чинит # `check --fix`. Живёт в H1 потому, что строка индекса копирует заголовок # дословно, — так тип виден там, где решают «брать или не брать», и инвариант # «заголовок в индексе дословно» остаётся нетронутым. TYPE_EMOJI = {"feature": "✨", "fix": "🐞", "chore": "🧹", RESEARCH: "🔬"} EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} TAKEABLE = TYPES # берутся в работу все четыре: целей больше нет # Заголовок в форме действия требуется там, где исход работы — изменение # системы. У разведки он называет предмет: её исход знание, и заголовок-действие # обещал бы решённость, которой ещё нет. ACTION_TYPES = ("feature", "fix", "chore") QUESTION_TAG = "question" # Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; тег # читается только затем, чтобы `check` назвал его вслух, а `--fix` снял. LEGACY_GOAL_TAG = "goal:" LEGACY_GOAL = "goal" # Схема тела на тип: какие разделы обязательны, какие ещё допустимы. Значения — # **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает, # и схема, хранящая текст, разошлась бы с ними на первой же настройке. # # Обязательность проверяется там, где по ней принимают решение, — командой # `ready` на входе в работу. Раздел не из схемы даёт # **замечание**, а не ошибку: свой раздел в теле — законная вольность проекта, # а вот раздел, которого тип не предполагает, чаще всего означает, что тип # проставлен не тот. TYPE_SCHEMA = { "feature": {"required": ("surface_heading", "criteria_heading"), "allowed": ("scope_heading", "questions_heading")}, "fix": {"required": ("repro_heading", "surface_heading", "criteria_heading"), "allowed": ("scope_heading", "questions_heading")}, "chore": {"required": ("surface_heading", "criteria_heading"), "allowed": ("scope_heading", "questions_heading")}, RESEARCH: {"required": ("question_heading", "answer_heading"), "allowed": ("scope_heading", "questions_heading")}, } # Прежние дома типа. Больше не пишутся; читаются, чтобы файлы переезжали сами: # `check --fix` переносит значение в поле «Тип», снимает тег и ставит эмодзи. LEGACY_KIND_TAG = "kind:" LEGACY_IDEA = "idea" # тип `[idea]` упразднён: это research без «Вопроса» LEGACY_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 title_parts(title: str) -> tuple[str, str]: """(тип, выведенный из заголовка; заголовок без эмодзи и префикса). Читаются обе формы: текущая (эмодзи) и прежняя (`[goal]`/`[idea]`). Тип из заголовка — **запасной источник**: дом типа поле меты, а эмодзи от него производна. Нужен он там, где меты ещё нет: у файлов, не переехавших на поле, и у текста, восстановленного из git. """ bare = title.strip() if (m := TYPE_PREFIX.match(bare)): return m.group(1).strip().lower(), m.group(2).strip() head = bare.split(maxsplit=1) if head and head[0] in EMOJI_TYPE: return EMOJI_TYPE[head[0]], (head[1].strip() if len(head) > 1 else "") return "", bare def h1_of(rtype: str, bare: str) -> str: """Заголовок H1 из типа и чистого текста: эмодзи производна от типа.""" emoji = TYPE_EMOJI.get(rtype) return f"{emoji} {bare}" if emoji else bare 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 "" head_type, bare = title_parts(title) # Мета — блок под заголовком (task-format.md). Порядок полей свободный: # поле распознаётся, где бы оно ни стояло. Пишется тип первым. section, section_raw, place_key = "", "", "" meta_type, 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 TYPE_KEYS: meta_type = value.strip().lower() elif key in PLACE_KEYS: section, _, reason = (p.strip() for p in value.partition("—")) section = section.rstrip(".,") section_raw, section = section, section.lower() place_key = key elif key in WHY_KEYS: why = value elif key in ("теги", "tags"): tags = [t.strip().lower() for t in value.split(",") if t.strip()] legacy_goal = next((t for t in tags if t.startswith(LEGACY_GOAL_TAG)), "") legacy_kind = next((t[len(LEGACY_KIND_TAG):] for t in tags if t.startswith(LEGACY_KIND_TAG)), "") # Дом типа — поле меты. Прежние дома читаются по убыванию определённости: # тег рода работы называл его прямо, заголовок — только у цели и идеи. rtype = meta_type or legacy_kind or head_type if rtype == LEGACY_IDEA: rtype = RESEARCH return {"title": title, "bare": bare, "type": rtype, "meta_type": meta_type, "head_type": head_type, "legacy_kind": legacy_kind, "section": section, "section_raw": section_raw, "place_key": place_key, "reason": reason, "why": why, "tags": tags, "legacy_goal": legacy_goal, "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 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 [], [] # --- Схема тела: тип решает, каких разделов запись обязана иметь --- # Зачем нужен раздел — по ключу конфига. Текст идёт в отказ: «нет раздела X» # без причины читается как придирка формы, а причина у каждого своя и # проектная. SCHEMA_WHY = { "repro_heading": "расхождение, которое не воспроизводится, — это research," " а не fix: чинить нечего, пока непонятно, что ломается", "question_heading": "без вопроса это не разведка, а сырьё — в работу не берётся", "answer_heading": "приёмка разведки — записанный ответ, и место ему" " (docs/research/, ADR, тело задачи) называется заранее," " иначе ответ останется в переписке", } def raw_research(lay: Layout, task: dict) -> bool: """Сырьё: разведка, у которой ещё нет вопроса. Прежде это был отдельный тип `[idea]`. Отдельным типом «ещё не описано» быть не может — это **состояние заполненности**, и различает его раздел, а не словарь. Отсюда и место сырья: конец секции, чтобы оно не стояло между тем, что берут. """ return (task["type"] == RESEARCH and not task["body"].get(lay.cfg["question_heading"].lower())) def schema_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]: """Отказы и замечания по схеме тела: разделы, которых требует тип. Обязательный раздел даёт отказ, лишний — замечание. Разница намеренная: свой раздел в теле законная вольность проекта, а раздел, которого тип не предполагает («Воспроизведение» у chore), чаще всего означает, что тип проставлен не тот, — и об этом стоит сказать, не запрещая. """ name, rtype = task["path"].name, task["type"] schema = TYPE_SCHEMA.get(rtype) if schema is None: return ([f"{name}: тип «{rtype or '—'}» вне словаря" f" ({', '.join(TYPES)}) — какие разделы обязательны, неизвестно"], []) errors: list[str] = [] notes: list[str] = [] for key in schema["required"]: if key == "criteria_heading": e, n = criteria_verdict(lay, task) elif key == "surface_heading": e, n = surface_verdict(lay, task) else: heading = lay.cfg[key] e = ([] if task["body"].get(heading.lower()) else [f"{name}: нет раздела «{heading}» — {SCHEMA_WHY[key]}"]) n = [] errors += e notes += n known = {lay.cfg[k].lower() for k in (*schema["required"], *schema["allowed"])} extra = sorted(h for h, v in task["body"].items() if h not in known and v) if extra: notes.append(f"{name}: разделы не из схемы типа «{rtype}»:" f" {', '.join(extra)} — либо тип проставлен не тот," f" либо это осознанный раздел проекта") return errors, notes def questions_open(lay: Layout, task: dict) -> bool: """Открытый вопрос — это **непустой раздел**, а не тег. Тег производен и забывается; отказ по тегу наказывал бы аккуратного и пропускал забывчивого — стимул ровно обратный записанному правилу. """ return bool(task["body"].get(lay.cfg["questions_heading"].lower())) # --- 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) or lay.project / 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() lines = read_lines(lay.index("backlog")) entries, sections = parse_entries(lines) tasks = tasks_of(lay) # Версия формата и стадия идут первыми строками расхождений: остальные # находки читаются иначе, когда каталог отстал от формата или когда неясно, # что значит порядок строк, — часть из них тогда не дрейф, а непройденный # шаг журнала. errors: list[str] = version_problems(lay) + stage_problems(lay) notes: list[str] = [] label = lay.name("backlog") known = {s.lower() for s in sections} raw_names = {n for n, t in tasks.items() if raw_research(lay, t)} errors += sections_verdict(lay, sections, label) for s in sections: if s.lower() in BLOCKER_SECTIONS: notes.append(f"{label}: секции «{s}» быть не должно —" f" блокер это состояние, а не полка: он живёт до ответа" f" человека, а его следы — вопросами в файлах задач") for name, task in tasks.items(): entry = entries.get(name) if not SLUG.fullmatch(name): errors.append(f"{name}: слаг не kebab-case латиницей") if not task["title"]: errors.append(f"{name}: нет заголовка H1") # 0. Тип — единственная ось, и от него зависит всё остальное: схема # тела, дом строки, имя поля меты, право на взятие в работу. # Пропуск — замечание: записи, заведённые до появления типа, # законны, и переоформлять беклог «заодно» здесь не просят. # Обязательным тип становится там, где по нему принимают решение. if not task["type"]: notes.append(f"{name}: тип не назван — `tasks.py edit {name[:-3]}" f" --type {'|'.join(TYPES)}`; в работу без него не возьмут") elif task["type"] == LEGACY_GOAL: errors.append(f"{name}: тип «{LEGACY_GOAL}» упразднён вместе с" f" роадмапом — цель была зонтиком над параллельными" f" направлениями, а список работ проекта линеен." f" Разбери запись сам: её задачи живут дальше, сама она" f" либо становится одной из них, либо уходит" f" (`close {name[:-3]} --reason …`)") elif task["type"] not in TYPES: errors.append(f"{name}: тип «{task['type']}» вне словаря" f" ({', '.join(TYPES)}) — словарь закрыт, иначе отбор" f" по типу разъедется на синонимах") elif not task["meta_type"]: was = (f"тегом {LEGACY_KIND_TAG}{task['legacy_kind']}" if task["legacy_kind"] else "префиксом заголовка") errors.append(f"{name}: тип задан прежним домом ({was}) — дом типа" f" поле **Тип:** первой строкой меты; перенесёт" f" `check --fix`") elif task["legacy_kind"]: errors.append(f"{name}: тег {LEGACY_KIND_TAG}{task['legacy_kind']} рядом с" f" полем **Тип:** — род работы стал типом," f" тег снимает `check --fix`") # 0а. Эмодзи производна от типа и живёт в H1: строка индекса копирует # заголовок дословно, и тип виден там, где решают «брать или нет». if task["type"] in TYPES and task["title"]: want_h1 = h1_of(task["type"], task["bare"]) if task["title"] != want_h1: errors.append(f"{name}: заголовок не несёт эмодзи типа" f" «{task['type']}» — надо «{want_h1}»;" f" поставит `check --fix`") # 1. У каждого файла есть строка индекса. if entry is None: errors.append(f"{name}: нет строки в {label} — восстановит `check --fix`," f" если секция файла есть в индексе") # 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 task["place_key"] and task["place_key"] != PLACE_KEY.lower(): errors.append(f"{name}: поле меты названо «{task['place_key']}», а зовётся" f" оно «{PLACE_KEY}»: это полка беклога, на которой задача" f" лежит. «Секция» была именем той же строки у цели —" f" целей нет. Переименует `check --fix`") if not task["section"]: errors.append(f"{name}: нет поля **{PLACE_KEY}:** в мета-блоке") elif task["section"] not in known: errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной" f" секцией {label} ({', '.join(sections)})") elif entry and entry["section"] \ and entry["section"].lower() != task["section"]: errors.append(f"{name}: секция в файле «{task['section']}»," f" а в {label} — «{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 entry and not task["why"] and entry["why"]: errors.append(f"{name}: «зачем» есть в {label}, а в файле нет —" f" `check --fix` перенесёт его в мета-блок") elif not task["why"]: notes.append(f"{name}: без «зачем» — по нему выбирают задачу" f" (`tasks.py edit {name[:-3]} --why …`)") # 3. Прежние теги направления. Цель была зонтиком над параллельными # направлениями; списку, который линеен, зонтик не нужен. for tag, why in ((task["legacy_goal"], "цели упразднены вместе с роадмапом"), (LEGACY_DECOMPOSED_TAG if LEGACY_DECOMPOSED_TAG in task["tags"] else "", "тег отличал разобранную цель от пустой")): if tag: errors.append(f"{name}: тег «{tag}» — {why}; снимет `check --fix`") # 3а. Схема тела — только замечанием: обязательным раздел становится # там, где по нему принимают решение, а решение это `ready` на входе # в работу. Лишний раздел говорит о неверном типе, и сказать об этом # стоит сразу, не дожидаясь взятия. if task["type"] in TYPES: notes += schema_verdict(lay, task)[1] # 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']}» нет — снять тег?") if BODY_PLACEHOLDER in task["text"]: notes.append(f"{name}: тело не дописано (остался плейсхолдер add)") for name, entry in entries.items(): if name not in tasks: errors.append(f"{label}:{entry['line']}: ссылка на несуществующий" f" {lay.cfg['items']}/{name}") errors += index_lint(lines, label) if raw_last(lines, raw_names) != lines: errors.append(f"{label}: сырьё (`{RESEARCH}` без раздела" f" «{lay.cfg['question_heading']}») стоит не в конце своей" f" секции — его не берут, и между берущимся оно требует" f" открыть файл, чтобы это понять; переставит `check --fix`") 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}, стадия" f" {STAGE_RU.get(lay.stage, '—')}, файлов {len(tasks)}," f" строк {label} {len(entries)}") 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 sections_verdict(lay: Layout, sections: list[str], label: str) -> list[str]: """Состав секций беклога — и он производен от стадии. На стройке беклог это **один** список от базы к деталям: порядок строк здесь зависимость, и разложенный по полкам он перестаёт быть планом — сравнить два шага из разных секций уже нельзя. На доработке полки законны и называет их проект: смысла они не несут, это раскладка домена. Слить секции машина не берётся: в каком порядке пойдут строки слитых полок, знает человек, а порядок здесь и есть содержание. """ if not sections: return [f"{label}: нет ни одной секции — заводить запись некуда" f" (`tasks.py check --fix` этого не чинит: имя секции — дело проекта)"] if lay.stage == BUILD and len(sections) > 1: return [f"{label}: секций {len(sections)} ({', '.join(sections)}), а на" f" стройке беклог — один список от базы к деталям: порядок строк" f" здесь зависимость, и по полкам он перестаёт быть планом." f" Слей секции руками (в каком порядке — знаешь только ты) или" f" объяви доработку: `tasks.py stage {SUPPORT}`"] return [] def health(lay: Layout, tasks: dict, entries: dict, sections: list[str]) -> None: """Метрики здоровья: размер секций, открытые вопросы, залежалость. Механизирует то, что иначе держится на дисциплине.""" backlog = [t for n, t in tasks.items() if n in entries] by_section = {s.lower(): 0 for s in sections} 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)) by_type: dict[str, int] = {} for t in tasks.values(): by_type[t["type"] or "без типа"] = by_type.get(t["type"] or "без типа", 0) + 1 if by_type: raw = sum(1 for t in tasks.values() if raw_research(lay, t)) print(" типы: " + ", ".join(f"{k} {by_type[k]}" for k in (*TYPES, "без типа") if k in by_type) + (f" (сырьём, без «{lay.cfg['question_heading']}», {raw})" if raw else "")) # Готовность к взятию — та же проверка, что откажет `ready`. Число, а не # перечень: оно отвечает на «есть ли что брать сегодня», и когда ответ # «нет», разбирать надо не список, а порцию груминга. if backlog: ready = [t for t in backlog if t["type"] in TAKEABLE and not questions_open(lay, t) and not schema_verdict(lay, t)[0]] print(f" готово к взятию: {len(ready)} из {len(backlog)}" + ("" if len(ready) == len(backlog) else " — прочим не хватает разделов своего типа или они ждут" " ответа на вопрос")) # Пустой беклог значит на двух стадиях разное, и молчать об этом нельзя: # на доработке это норма, на стройке — событие, ради которого стадия и # заведена. elif lay.stage == BUILD: print(f" беклог стройки пуст — план исчерпан, приложение построено." f" Дальше доработка: `tasks.py stage {SUPPORT}`") # Схема типа — **своя** строка, а не дубль предыдущей. «Готово к взятию» # валит запись за что угодно (открытый вопрос, разделы), эта называет ровно # одну причину. Строка нужна потому, что обязательность разделов проверяет # только `ready` на входе в работу, а между заведением и взятием запись # иначе не судит никто. bad_schema = {n: t for n, t in tasks.items() if t["type"] in TYPE_SCHEMA and schema_verdict(lay, t)[0]} if bad_schema: unfit = sorted(n[:-3] for n in bad_schema) # Сырьё названо отдельно: схему оно не выполняет по определению («Вопрос» # пуст — тем оно и сырьё), и без этой оговорки счётчик читался бы как # число недоделанных задач, хотя часть его — записи, ещё не ставшие ими. crude = sum(1 for t in bad_schema.values() if raw_research(lay, t)) print(f" схема типа не выполнена: {len(unfit)} из {len(tasks)}" f" ({', '.join(unfit[:5])}{', …' if len(unfit) > 5 else ''})" + (f", сырья из них {crude}" if crude else "") + " — нет разделов, которых требует тип; чего именно, скажет" " `tasks.py ready <слаг>`") 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 ACTION_TYPES and not action_title(t["bare"])) if flat: print(f" заголовков не в форме действия: {len(flat)}" f" ({', '.join(flat[:5])}{', …' if len(flat) > 5 else ''})" f" — задача отвечает на «что нужно сделать»:" f" «Печатать поле одним куском», а не «Поле печатается одним куском»") # Залежалость считается только на доработке. На стройке лежать долго — # нормальное состояние шага, до которого ещё не дошла очередь: он стоит там, # где стоит, по зависимости, и переоценивать его нечем. dates = touched_map(lay) if not dates or lay.stage == BUILD: 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, index_sections = parse_entries(read_lines(lay.index("backlog"))) order = {s.lower(): i for i, s in enumerate(index_sections)} 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["listed"] = "" if name in entries else "нет строки" 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.raw and not raw_research(lay, t): 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 a.type else f"{t['type'] or '—':<9}" flag = " ?" if questions_open(lay, t) else ("~" if raw_research(lay, t) else " ") print(f"{touched}{t['listed']:<11}{t['section']:<12}{rtype}{flag:<2}" f"{t['path'].stem:<44} {t['bare']}") 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(rtype: str, section: str, reason: str, why: str, tags: list[str]) -> str: """Мета-блок: тип первой строкой, дальше место, «зачем» и теги. Тип стоит первым не для красоты: он решает, что у записи вообще может быть — какие разделы обязательны, берётся ли она в работу, — и читается раньше всего остального. """ out = [f"- **Тип:** {rtype}"] if rtype else [] out.append(f"- **{PLACE_KEY}:** {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 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 section_at(lines: list[str], i: int) -> str: """Секция, в которой лежит строка `i`, — ближайший заголовок выше неё. Дом у этого вопроса один: его задаёт и перестановка внутри секции (`move` без `--section`), и починка «строка не в своей секции».""" return next((m.group(1) for j in range(i, -1, -1) if (m := SECTION.match(lines[j]))), "") 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(lay: Layout, slug: str) -> tuple[list[str], int] | None: """Строки беклога и позиция строки задачи в них. None — строки нет.""" lines = read_lines(lay.index("backlog")) ei = find_entry_index(lines, slug) return None if ei is None else (lines, ei) def insert_entry(lines: list[str], section: str, entry: str, after: str | None = None, first: bool = False) -> None: """Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом за указанной строкой, --first — первой. Позиция значима прежде всего в беклоге: порядок строк там и есть приоритет (правило 4), и назначает его человек — отсюда и умолчание «в конец», а не «наверх».""" 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, rtype: 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, cur_type, extra = "", "", "", "", "", [] seen_section = False for key, value in old: low = key.lower() if low in TYPE_KEYS: cur_type = value.strip().lower() elif low in PLACE_KEYS: 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` скажет это словами new_type = rtype if rtype is not None else cur_type out = [f"- **Тип:** {new_type}"] if new_type else [] out.append(f"- **{PLACE_KEY}:**" f" {section if section is not None else cur_section}") rsn = reason if reason is not None else cur_reason if rsn: out[-1] += 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, rtype: 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, rtype=rtype) 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 "") # Подсказка в шаблоне — по ключу конфига, один текст на раздел. Держать её # рядом со схемой, а не расписывать шаблон на каждый тип: тип решает, какие # разделы положить, а что писать в разделе, от типа не зависит. SECTION_HINT = { "surface_heading": "границы, которых изменение касается: эндпоинт или" " команда, таблица и миграция, формат на диске, публичный" " тип пакета, внешний сервис. Названы границы, а не то, как" " они изменятся: план реализации живёт в предложении", "criteria_heading": "проверяемые утверждения списком, у каждого назван оракул", "repro_heading": "что сделать, чтобы расхождение проявилось, и что при этом" " видно вместо ожидаемого. Не воспроизводится — это research," " а не fix", "question_heading": "вопрос, на который отвечает эта разведка, — одной фразой." " Пока его нет, это сырьё: в работу не берут", "answer_heading": "куда ляжет ответ: docs/research/<тема>.md, ADR, тело этой" " задачи. Приёмка разведки — записанный ответ, а не" " изменённый код", "scope_heading": "одна строка: чего касаться нельзя, что перезапускается, что" " считается необратимым", } BODY_LEAD = { "feature": "задача в одной фразе: что станет наблюдаемо иначе", "fix": "что расходится с заявленным — в одной фразе", "chore": "что обслуживаем и что перестанет мешать; адресат здесь" " разработчик, и это законно", RESEARCH: "о чём разведка: что непонятно и почему это мешает решать", } def body_template(rtype: str, lay: Layout) -> str: """Шаблон тела по схеме типа: обязательные разделы плюс «Рамки». Шаблон и проверка растут из одного TYPE_SCHEMA: разойтись им нельзя, иначе `add` кладёт то, на чём `ready` потом откажет. """ schema = TYPE_SCHEMA.get(rtype, TYPE_SCHEMA["feature"]) out = [f""] keys = list(schema["required"]) if "scope_heading" in schema["allowed"]: keys.append("scope_heading") for key in keys: hint = SECTION_HINT[key] if key == "criteria_heading": hint = (f"{CRITERIA_MIN}–{CRITERIA_MAX} проверяемых утверждений списком," f" у каждого назван {lay.cfg['oracle_word']}") out.append(f"## {lay.cfg[key]}\n\n") return "\n\n".join(out) + "\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_type(a.type)): if err: raise Usage(err) if not a.title.strip(): raise Usage("пустой заголовок") rtype = a.type.strip().lower() path = lay.items / f"{a.slug}.md" if path.exists(): raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый") lines = read_lines(lay.index("backlog")) if not lines: raise Usage(f"нет индекса {lay.name('backlog')} — прогони tasks.py init") if find_entry_index(lines, a.slug) is not None: raise Usage(f"строка в {lay.name('backlog')} для {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('backlog')} (есть: {avail})") tags = split_tags(a.tag) if QUESTION_TAG in tags: print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле") title_full = h1_of(rtype, a.title) meta = build_meta(rtype, section, a.reason or "", a.why or "", tags) insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.why or "")) # Сырьё держится в конце секции сразу, а не до ближайшего `check --fix`: # заводимая разведка вопроса ещё не несёт, а заводимая задача не должна # вставать после неё. raw = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)} if rtype == RESEARCH: raw.add(f"{a.slug}.md") lines[:] = raw_last(lines, raw) plan = Plan() plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(rtype, lay)}") plan.index(lay, "backlog", lines) plan.commit() print(f"создано: {lay.cfg['items']}/{a.slug}.md, строка в" f" {lay.name('backlog')} (секция «{section}»); допиши тело редактором") # Куда встала строка, значит на двух стадиях разное, и сказать это надо # там же, где строка появилась: на стройке место в списке это зависимость, # и «в конец» для шага, который делается раньше прочих, просто неверно. if lay.stage == BUILD: print(f" строка в конце списка — на стройке порядок это зависимость:" f" если шаг делается раньше, переставь его" f" (`tasks.py move {a.slug} --after <слаг>`)") 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_type(a.type)): if err: raise Usage(err) if all(v is None for v in (a.title, a.why, a.type, a.add_tag, a.rm_tag)): raise Usage("нечего менять: дай --title, --why, --type," " --add-tag или --rm-tag") path = lay.items / f"{a.slug}.md" if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") place = locate(lay, a.slug) if place is None: 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"] rtype = task["type"] if a.type is None else a.type.strip().lower() h1 = h1_of(rtype, 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.type is not None: tags = [t for t in tags if not t.startswith(LEGACY_KIND_TAG)] why = task["why"] if a.why is None else a.why # Написание секции берётся как есть, а не в нижнем регистре: имя секции # принадлежит **заголовку индекса**, и мета на него только ссылается (тот же # довод, что у шага 6 `apply_fixes`). Нижний регистр уезжал бы в файл # «Категория: ядро», а следующий `check --fix` чинил бы за собственной # правкой. Сверка принадлежности всё равно идёт по нижнему регистру. section = task["section_raw"] # Тип передаётся всегда, а не только при `--type`: у файла, не переехавшего # на поле, он выведен из прежнего дома, и без него пересборка меты потеряла # бы его вовсе. 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, rtype=rtype or None) if new_text is None: raise Usage(f"{a.slug}.md без поля **{PLACE_KEY}:** в мете —" f" прогони check и почини") tlines = new_text.splitlines() tlines[0] = f"# {h1}" new_text = "\n".join(tlines) + "\n" # Место сырья — конец секции, и оно производно от типа: смена типа обязана # переставить строку сразу, иначе индекс уезжает в дрейф на ровном месте. raw_now = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)} raw_now.discard(f"{a.slug}.md") if rtype == RESEARCH and not task["body"].get(lay.cfg["question_heading"].lower()): raw_now.add(f"{a.slug}.md") lines, ei = place lines[ei] = entry_line(lay, h1, a.slug, why) plan = Plan() plan.file(path, new_text) plan.index(lay, "backlog", raw_last(lines, raw_now)) plan.commit() changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type), ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"]: print(f" вопрос открыт — в работу задача не берётся, пока он не разобран" f" (`tasks.py ready {a.slug}` это и скажет)") return EXIT_OK def cmd_move(lay: Layout, a: argparse.Namespace) -> int: """Перестановка строки: внутри своей секции или в другую. `--section` необязателен, и это не удобство. Перестановка внутри секции — самая частая операция груминга (`move --after` и есть расстановка приоритета), а требовать в ней повторить текущую секцию значит приглашать указать не ту: перенос в чужую секцию выглядел бы ровно так же. Без `--section` секция берётся из индекса — та, в которой строка уже лежит. """ 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']}/") place = locate(lay, a.slug) if place is None: raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix") lines, ei = place label = lay.name("backlog") if a.section is None: section = section_at(lines, ei) if not section: raise Usage(f"строка {a.slug} в {label} стоит до первой" f" секции — переставлять внутри нечего. Назови секцию:" f" tasks.py move {a.slug} --section <секция> --reason …") else: 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}» в {label} (есть: {avail})") task = parse_task(path) new_text = meta_updated(path, section=section, reason=a.reason, rtype=task["type"] or None) if new_text is None: raise Usage(f"{a.slug}.md без поля **{PLACE_KEY}:** в мете —" f" прогони 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 if not (a.after or a.first): lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items() if raw_research(lay, t)}) plan = Plan() plan.file(path, new_text) plan.index(lay, "backlog", lines) plan.commit() where = ("первой" if a.first else f"после {a.after}" if a.after else "в конец") print(f"{a.slug}: " + (f"перенесено в «{section}»" if a.section is not None else f"переставлено внутри «{section}»") + f", {where} ({label})") return EXIT_OK 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']}/") place = locate(lay, a.slug) if place is None: raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix") task = parse_task(path) 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_raw'] 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") lines, ei = place lines.pop(ei) plan.index(lay, "backlog", lines) plan.delete(path) plan.commit() print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") 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 rtype = parse_task_text(text, path)["type"] if a.reason: upd = meta_updated_text(text, reason=a.reason, rtype=rtype or None) if upd is None: print(" внимание: меты нет, причина возврата не записана в файл") else: text = upd tmp = parse_task_text(text, path) plan = Plan() plan.file(path, text) # Написание — из меты как есть: имя секции уедет в доклад, а сверка # принадлежности всё равно идёт по нижнему регистру (`find_section`). section = tmp["section_raw"] or tmp["section"] lines = read_lines(lay.index("backlog")) 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('backlog')} (есть: {avail})") insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"])) # Место сырья производно от типа, и `reopen` обязан его соблюсти сразу: # вернуть разведку без «Вопроса» просто в конец секции — значит # поставить её после сырья, лежавшего там раньше, и получить ошибку # `check` на ровном месте. Возвращаемого файла ещё нет на диске, поэтому # он добавляется к набору вручную. raw = {n for n, t in tasks_of(lay).items() if raw_research(lay, t)} if raw_research(lay, tmp): raw.add(f"{a.slug}.md") plan.index(lay, "backlog", raw_last(lines, raw)) 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('backlog')} из истории git" f" (секция «{section}», в конец: позиция это приоритет, и её" f" назначает человек)") for line in removed: print(f" снята строка {lay.name('rejected')}: {line.strip()}") 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, rtype: str | None = None) -> str | None: """То же для текста, которого ещё нет на диске (возврат задачи из git).""" out = meta_rebuilt(text.splitlines(), reason=reason, rtype=rtype) return None if out is None else "\n".join(out) + "\n" # --- Готовность записи к работе --- def cmd_ready(lay: Layout, a: argparse.Namespace) -> int: """Схема типа выполнена — запись можно брать в работу. Прежде этот гейт стоял на взятии задачи в спринт: набор и был моментом, когда запись впервые судили целиком. Спринтов нет, а момент нужен — иначе задача уезжает в работу без критериев приёмки, и узнают об этом на приёмке, когда сверять уже не с чем. Теперь момент называет тот, кто берёт: скилл решения задачи зовёт `ready` первым делом. Отказ здесь — **рабочая ситуация**, а не ошибка употребления: запись просто ещё не дописана. Поэтому код 1, а не 2, и ветвиться на них надо по-разному. """ verdicts, 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) errs = [] if not t["type"]: errs.append(f"тип не назван — `edit {slug} --type {'|'.join(TAKEABLE)}`." f" Тип решает, каких разделов запись обязана иметь," f" и без него проверять нечего") elif t["type"] not in TAKEABLE: errs.append(f"тип «{t['type']}» вне словаря ({', '.join(TYPES)}) —" f" какие разделы обязательны, неизвестно") else: # Отказ по факту, а не по метке: непустой раздел «Вопросы» держит # запись независимо от тега. Забывший тег иначе проходил бы, а # поставивший спотыкался — стимул ровно обратный правилу. if questions_open(lay, t): errs.append(f"непустой раздел «{lay.cfg['questions_heading']}» —" f" вопрос разбирается до взятия. Отвечен — запиши ответ" f" в тело и очисти раздел" f" (тег снимается `edit {slug} --rm-tag {QUESTION_TAG}`)") if QUESTION_TAG in t["tags"] and not questions_open(lay, t): errs.append(f"тег «{QUESTION_TAG}» стоит, а раздела" f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан" f" не туда, либо тег пора снять:" f" `edit {slug} --rm-tag {QUESTION_TAG}`") serrs, notes = schema_verdict(lay, t) errs += serrs warn += [f"{slug}: {n}" for n in notes] verdicts.append((slug, errs)) bad = [(s, e) for s, e in verdicts if e] for slug, errs in verdicts: if errs: print(f"НЕ ГОТОВА {slug}") for e in errs: print(f" {e}") else: print(f"готова {slug}") for w in warn: print(f" замечание: {w}") if bad: print(f"\nИтог: не готово {len(bad)} из {len(verdicts)}." f" Дописывается это на груминге или тем, кто берёт задачу.") return EXIT_DRIFT return EXIT_OK # --- check --fix --- def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: """Детерминированная починка дрейфа. Чинит только то, где истина однозначно в файле или где источник ровно один: - дубли строк на один файл, рассинхрон заголовка, задача не в своей секции; - отсутствующая строка индекса — восстанавливается **вместе с «зачем»** из меты файла; - «зачем», оставшееся только в индексе, — переносится в файл (миграция со старого формата: другого экземпляра нет, неоднозначности тоже); - теги упразднённых целей (`goal:<слаг>`, `decomposed`) — снимаются. Неоднозначное (ссылка на исчезнувший файл, битые строки, запись типа `goal`) не трогает — это на суд человека, и о нём говорится вслух. """ 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() def staged_lines(task: dict) -> list[str]: """Текущий текст файла с учётом уже отложенных правок. Перечитать файл с диска посреди прохода значит стереть то, что положил предыдущий шаг: шагов, правящих мету, четыре, и каждый видит свою часть. """ staged = files.get(task["path"]) return (staged.splitlines() if staged is not None else task["path"].read_text(encoding="utf-8").splitlines()) def stage(task: dict, **kw) -> bool: """Отложить пересборку меты. False — меты нет, чинить нечем.""" rebuilt = meta_rebuilt(staged_lines(task), **kw) if rebuilt is None: return False files[task["path"]] = "\n".join(rebuilt) + "\n" return True # 0. Форма меты. Старая (всё одной строкой через `·`) переписывается # списком. Правка чисто механическая: поля те же, включая нераспознанные. for name, task in tasks.items(): if not task["legacy_meta"]: continue if not stage(task, rtype=task["type"] or None): ambiguous.append(f"{name}: мета одной строкой и без поля места" f" (**{PLACE_KEY}:**) — переписать нечего," f" чинится руками") continue fixed.append(f"{name}: мета переписана списком") # 0а. Тип переезжает в свой дом. Прежние дома — тег `kind:<род>` и префикс # заголовка — читаются, но больше не пишутся; заодно заголовок получает # эмодзи, а поле места — имя по типу. Тип, который не выводится # ниоткуда, машина не угадывает: `feature` от `chore` отличает человек, # и подставленное наугад значение врало бы ровно там, где по нему # принимают решение. for name, task in tasks.items(): rtype = task["type"] if not rtype: ambiguous.append(f"{name}: тип не выводится — нет ни поля **Тип:**, ни" f" тега {LEGACY_KIND_TAG}<род>, ни префикса заголовка." f" Назови руками: `edit {name[:-3]} --type" f" {'|'.join(TYPES)}`") continue if rtype == LEGACY_GOAL: ambiguous.append(f"{name}: тип «{LEGACY_GOAL}» упразднён вместе с" f" роадмапом — во что превращается эта запись," f" решает человек: в задачу или в ничто") continue if rtype not in TYPES: ambiguous.append(f"{name}: тип «{rtype}» вне словаря" f" ({', '.join(TYPES)}) — чем он заменяется," f" решает человек") continue want_key = PLACE_KEY.lower() # Прежние теги снимаются здесь же: у обоих не осталось того, на что они # указывали, — целей нет, а разбирать нечего. tags = [t for t in task["tags"] if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG)) and t != LEGACY_DECOMPOSED_TAG] renamed = bool(task["place_key"]) and task["place_key"] != want_key if task["meta_type"] != rtype or tags != task["tags"] or renamed: if not stage(task, rtype=rtype, tags=tags if tags != task["tags"] else None): ambiguous.append(f"{name}: тип «{rtype}» переносить некуда —" f" в файле нет мета-блока") else: what = [f"тип «{rtype}» в поле **Тип:**"] if task["legacy_kind"]: what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}") if task["legacy_goal"]: what.append(f"снят тег {task['legacy_goal']}") if LEGACY_DECOMPOSED_TAG in task["tags"]: what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}") if renamed: what.append(f"поле места → «{PLACE_KEY}»") fixed.append(f"{name}: " + ", ".join(what)) want_h1 = h1_of(rtype, task["bare"]) if task["title"] and task["title"] != want_h1: src = staged_lines(task) if src and src[0].startswith("#"): src[0] = f"# {want_h1}" files[task["path"]] = "\n".join(src) + "\n" # Шаг 2 сверяет строку индекса с этим полем — иначе индекс # остался бы с прежним заголовком до следующего прогона. task["title"] = want_h1 fixed.append(f"{name}: заголовок получил эмодзи типа — {want_h1}") # 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: if not stage(task, why=idx_why): ambiguous.append(f"{name}: «зачем» только в {lay.name(kind)}," f" а в файле нет меты — перенести некуда") continue 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. Нет строки вовсе / строка не в своей секции. kind = "backlog" for name, task in tasks.items(): ei = find_entry_index(idx[kind], name[:-3]) if ei is None: if not task["section"]: ambiguous.append(f"{name}: строки в индексе нет, и в файле" f" нет секции — восстанавливать не по чему") continue hi, section = find_section(idx[kind], task["section"]) if hi is None: ambiguous.append(f"{name}: строки нет, а секции «{task['section']}»" f" нет в {lay.name(kind)} — восстанавливать некуда") continue insert_entry(idx[kind], section, entry_line(lay, task["title"], name[:-3], task["why"])) # Восстановленная строка встаёт в конец секции, и это надо сказать: # позиция в беклоге — приоритет, а его назначает человек. Молчаливое # восстановление выдало бы машинную позицию за его решение. fixed.append(f"{lay.name(kind)}: восстановлена строка {name}" " — в конце секции, позицию назначь сам:" " порядок строк это приоритет" + ("" if task["why"] else " (в файле нет «зачем» — допиши)")) dirty.add(kind) continue if not task["section"]: continue hi, section = find_section(idx[kind], task["section"]) if hi is None: continue cur = section_at(idx[kind], ei) 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 kind, lines in idx.items(): raw = {n for n, t in tasks.items() if raw_research(lay, t)} if (moved := raw_last(lines, raw)) != lines: lines[:] = moved fixed.append(f"{lay.name(kind)}: сырьё снесено в конец своей секции" f" ({len(raw)} записей `{RESEARCH}` без раздела" f" «{lay.cfg['question_heading']}»)") dirty.add(kind) if spaced_sections(lines) != lines: fixed.append(f"{lay.name(kind)}: отбивка после заголовков секций") dirty.add(kind) # 5. Написание секции в мете. Имя секции принадлежит **заголовку индекса** — # файл на секцию только ссылается, а принадлежность сверяется по нижнему # регистру. Поэтому расхождение в одном регистре однозначно: побеждает # заголовок. for name, task in tasks.items(): if not task["section_raw"]: continue _, heading = find_section(idx["backlog"], task["section"]) if not heading or heading == task["section_raw"]: continue if not stage(task, section=heading): continue 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 --- BACKLOG_ORDER = { BUILD: "**Порядок строк — зависимость:** это план стройки от базы к деталям,\n" "и строка выше сделана раньше не потому, что важнее, а потому, что\n" "иначе нельзя. Секция здесь **одна**: разложенный по полкам план\n" "перестаёт быть планом. Список пишется вперёд целиком — это не\n" "гниение беклога, а замысел. Пустой беклог значит, что стройка\n" "окончена: дальше `tasks.py stage support`.", SUPPORT: "**Порядок строк внутри секции — важность:** первая строка это то,\n" "что делают следующим. Назначает его человек на груминге, машина не\n" "выводит. Секции — полки домена, смысла они не несут. Заводится по\n" "одной, по мере появления; пустой беклог — нормальное состояние.", } def init_files(lay: Layout, sections: list[str], stage_name: str, cfg: dict) -> dict[Path, str]: out: dict[Path, str] = {} # Служебный файл здесь не заводится: его пишет `write_config` по живому # файлу — версию двигает построчно, ключи дописывает, чужого не затирает. # Планом это сделать нельзя, потому что план перезаписывает целиком, а # перезапись стёрла бы комментарии — то, ради чего взят TOML. out[lay.index("backlog")] = ( "# Беклог\n\n" f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" "+ строка здесь. Ведётся скиллом `av-dev:task-track`.\n\n" f"Стадия проекта — **{STAGE_RU[stage_name]}** (`[tasks] {STAGE_KEY} =" f' "{stage_name}"`).\n' + BACKLOG_ORDER[stage_name] + "\n\n" f"Одно место в очереди назначено не человеком, а типом: сырьё" f" (`{RESEARCH}`\nбез раздела «{lay.cfg['question_heading']}») стоит в" " конце секции — его не берут.\n\n" "Тип записи стоит первым полем меты и решает, что у неё может быть:\n" + "".join(f"{TYPE_EMOJI[t]} `{t}` " for t in TAKEABLE) + "\n\n" "Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n" "(работа не может продолжаться ни одной задачей), оно живёт до ответа\n" "человека, а его следы — вопросами в файлах задач.\n\n" + "".join(f"## {s}\n\n" for s in sections)) 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 adopt_cfg(lay: Layout) -> dict: """Что адаптация обязана записать о себе: где встал каталог. Имён частей здесь нет — адаптация раскладывает всё по умолчаниям, — а путь есть всегда, даже умолчательный: `--target` задаёт его свободно, и молча записанное «tasks» указывало бы в пустоту. """ try: rel = lay.root.resolve().relative_to(lay.project.resolve()).as_posix() except ValueError: return {} return {DIR_KEY: rel} def write_config(project: Path, cfg: dict) -> list[str]: """Записать версию и настройки каталога; вернуть строки доклада. Файла нет — он заводится целиком скелетом, с комментариями. Файл есть — в нём двигается версия и дописываются недостающие ключи секции; чужое значение не затирается, но и не замалчивается: разошедшийся ключ уезжает в доклад строкой, потому что `dir`, указывающий не туда, куда только что заведён каталог, оставляет каталог недостижимым. """ path = project / CONFIG_NAME if not path.is_file(): path.write_text(conf.skeleton(LAYOUT_VERSION, tasks=cfg), encoding="utf-8") return [f"{CONFIG_NAME} заведён: версия {LAYOUT_VERSION}" f"{', ' + ', '.join(sorted(cfg)) if cfg else ''}"] out = [] if conf.version(conf.read(project)) != LAYOUT_VERSION: conf.set_version(project, LAYOUT_VERSION) out.append(f"версия раскладки в {CONFIG_NAME}: {LAYOUT_VERSION}") clash = conf.missing_keys(project, "tasks", cfg) added = conf.merge_section(project, "tasks", cfg) if added: out.append(f"дописано в [tasks]: {', '.join(added)}") for key, had in sorted(clash.items()): out.append(f"ВНИМАНИЕ [tasks] {key} = «{had}» оставлен как был, а каталог" f" заведён под «{cfg[key]}» — поправь {CONFIG_NAME} руками," f" иначе скрипт пойдёт не туда") return out or [f"{CONFIG_NAME} уже описывает эту раскладку"] def cmd_init(root: Path, a: argparse.Namespace) -> int: if not dir_within_cwd(root): raise Usage(f"--dir вне рабочего каталога: {root}") names = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("rejected", a.rejected)) if v} # Версия формата — первым ключом и всегда: каталог, заведённый сегодня, # приведён к сегодняшнему формату, и объявить это должен тот, кто его завёл. # Имена частей — следом и только те, что названы явно: умолчание, записанное # в файл, стало бы вторым домом для того же имени. В раскладку версия не # идёт — `Layout` про имена, и число среди имён там ничего не значит. # Каталог задач называется ключом `dir`, если он не умолчательный: без него # `.av-dev.toml` не сможет сказать, где искать, и разрешение уедет на # умолчание — молча и в другой каталог. project = conf.find_root() or Path.cwd().resolve() stage_name = a.stage.strip().lower() cfg = {**names, STAGE_KEY: stage_name} try: rel = root.resolve().relative_to(project).as_posix() except ValueError: raise Usage(f"каталог задач {root} вне проекта {project}") from None if rel != DEFAULT_DIR: cfg[DIR_KEY] = rel lay = Layout(root, cfg, project, load_config(project)) if lay.index("backlog").exists(): raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён") sections = uniq_sections(a.sections or DEFAULT_SECTIONS[stage_name]) if not sections: raise Usage("пустой список секций") # Секций на стройке ровно одна, и отказ здесь дешевле молчаливого выбора: # порядок строк на стройке — зависимость, и разложенный по полкам список # перестаёт быть планом. if stage_name == BUILD and len(sections) > 1: raise Usage(f"на стройке секция одна, а названо {len(sections)}" f" ({', '.join(sections)}): беклог стройки — один список от" f" базы к деталям. Полки заводятся при переходе в доработку") 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, stage_name, cfg).items(): plan.file(path, text) plan.commit() said = write_config(project, cfg) print(f"каталог задач заведён: {root}, стадия {STAGE_RU[stage_name]}") print(f" секции беклога: {', '.join(sections)}" + (" — порядок строк это зависимость" if stage_name == BUILD else " — порядок строк внутри секции это важность")) for line in said: print(f" {line}") return EXIT_OK # --- stage: смена стадии проекта --- def cmd_stage(lay: Layout, a: argparse.Namespace) -> int: """Показать стадию или сменить её. Смена — событие, а не настройка: она переворачивает смысл порядка строк (зависимость ↔ важность) и состав секций. Записывается она ключом конфига, и датой ей служит коммит — отдельного журнала для одной строки заводить не стоит. Остаток беклога переезжает в первую новую секцию **целиком и молча не остаётся**: строки, писавшиеся как план, на доработке продолжают жить задачами, но порядок их с этого момента значит другое, и об этом говорится вслух. """ if a.to is None: print(f"стадия: {STAGE_RU.get(lay.stage, 'не объявлена')}" + (f" ({lay.stage})" if lay.stage else "")) print(" " + (BACKLOG_ORDER[lay.stage].replace("\n", " ") if lay.stage in STAGES else f"объяви: tasks.py stage {BUILD}|{SUPPORT}")) return EXIT_OK if lay.stage in STAGES else EXIT_DRIFT want = a.to.strip().lower() if want == lay.stage: raise Usage(f"стадия уже «{want}» — менять нечего") sections = uniq_sections(a.sections or DEFAULT_SECTIONS[want]) if not sections: raise Usage("пустой список секций") if want == BUILD and len(sections) > 1: raise Usage(f"на стройке секция одна, а названо {len(sections)}" f" ({', '.join(sections)})") lines = read_lines(lay.index("backlog")) heads = section_headers(lines) # Тело каждой секции — всё, что под её заголовком: и строки-пункты, и проза. # Собирается оно в первую новую секцию в прежнем порядке секций: другого # порядка машина не знает, а выдумать его значило бы переставить чужую # очередь. head = lines[:heads[0][0]] if heads else lines body: list[str] = [] for k, (i, _) in enumerate(heads): end = heads[k + 1][0] if k + 1 < len(heads) else len(lines) body += lines[i + 1:end] moved = sum(1 for line in body if INDEX_ENTRY.match(line)) out = [*head] for k, s in enumerate(sections): out.append(f"## {s}") if k == 0: out += body plan = Plan() plan.index(lay, "backlog", out) # Категория в файлах — производна от заголовка индекса, и разъехаться ей # нельзя: `check` назовёт это дрейфом на первой же записи. retyped = 0 for task in tasks_of(lay).values(): if task["section_raw"] == sections[0]: continue text = meta_updated(task["path"], section=sections[0], rtype=task["type"] or None) if text is None: continue plan.file(task["path"], text) retyped += 1 plan.commit() conf.set_section_key(lay.project, "tasks", STAGE_KEY, want) was = STAGE_RU.get(lay.stage, "не объявлена") print(f"стадия: {was} → {STAGE_RU[want]}") print(f" секции беклога: {', '.join(sections)};" f" перенесено строк {moved}, поправлена «{PLACE_KEY}» у {retyped} файлов") if want == SUPPORT: print(" порядок строк с этого момента значит важность, а не зависимость:" " прежний план шёл по зависимости, и как очередь он не расставлен." " Первый заход — груминг (скилл av-dev:task-groom)") else: print(" порядок строк с этого момента значит зависимость, а не важность:" " выстрой список от базы к деталям") 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, bare = title_parts(title) if kind == LEGACY_IDEA: kind = RESEARCH 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", ""), "step": None, "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": [], "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() step = None if (s := STEP.match(text)): step, clean = int(s.group(1)), re.sub(r"[`*]", "", s.group(2)).strip() 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": "", "old_section": heading, "section": "", "reason": "", "why": "", "in_index": False, "translit": False, "questions_heading": "", "source": f"{path}:{num}", "body": text, "done": state.lower() == "x", "step": step, }) 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)) stage_name = a.stage.strip().lower() items, rejected, unclassified = [], [], [] for sc in scans: items += sc["items"] rejected += sc.get("rejected", []) unclassified += sc.get("unclassified", []) # Нумерованные шаги встают первыми и по своим номерам: их порядок назван # источником, а порядок — единственное, чего из файла задачи не восстановить. items.sort(key=lambda it: (it.get("step") is None, it.get("step") or 0)) 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": 2, "target": a.target, "stage": stage_name, "sources": [str(s) for s in sources], "path_map": path_map, "sections_backlog": uniq_sections(a.sections or DEFAULT_SECTIONS[stage_name]), "section_map": {}, "items": items, "rejected": rejected, "unclassified": unclassified, } print(f"адаптация: стадия {STAGE_RU[stage_name]}, найдено записей {len(items)}," 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: steps = sum(1 for it in sc["items"] if it.get("step") is not None) print(f" {sc['source']}: список — пунктов {len(sc['items'])}," f" из них нумерованных {steps} (их порядок сохранён)") 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` (английский), `type` и `section`" " у каждой записи, выстрой порядок `items`" + (" — на стройке он значит зависимость" if stage_name == BUILD else " — на доработке он значит важность") + f", покажи карту человеку и только потом —" 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`; из `tasks/items/x.md` тот же файл — уже `../docs/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}") project = conf.find_root() or Path.cwd().resolve() stage_name = str(pl.get("stage", "")).strip().lower() if stage_name not in STAGES: raise Usage(f"в карте нет стадии («stage»: {', '.join(STAGES)}) —" f" без неё непонятно, что значит порядок записей в ней") lay = Layout(root, {STAGE_KEY: stage_name}, project, load_config(project)) sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS[stage_name]) known_sections = {s.lower() for s in sections} # --- проверки: все до первой записи --- problems: list[str] = [] if lay.index("backlog").exists(): problems.append(f"{lay.index('backlog')} уже есть — адаптация не поверх живого" f" каталога; выбери пустой target") if stage_name == BUILD and len(sections) > 1: problems.append(f"стадия «{BUILD}», а секций {len(sections)}" f" ({', '.join(sections)}): беклог стройки — один список") slugs: set[str] = set() 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 (e := bad_type(it.get("type") or None)): problems.append(f"{slug}: {e}") elif not it.get("type"): problems.append(f"{slug}: тип не назван — заполни `type` в карте" f" ({', '.join(TAKEABLE)}). Машина его не угадывает:" f" от типа зависит, каких разделов запись требует") 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() # Версия та же, что у `init`: каталог выводится из чужой раскладки сегодня и # сегодняшним форматом, сколько бы лет ни было тому, из чего он выведен. # Имён частей здесь нет — адаптация раскладывает всё по умолчаниям. for path, text in init_files(lay, sections, stage_name, {}).items(): wr.file(path, text) backlog_lines = init_files(lay, sections, stage_name, {})[lay.index("backlog")].splitlines() renames: dict[str, str] = {} 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 rtype = (it.get("type") or "").lower() title = h1_of(rtype, it["title"]) tags = list(it.get("tags", [])) 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(rtype, 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, stage_name, {})[lay.index("rejected")] body = [] for line in pl["rejected"]: line = re.sub(r"Был приоритет:", "Была секция:", line) for old, new in renames.items(): line = re.sub(r"(? 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", help="категория беклога") p.add_argument("--type", choices=TYPES) p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ): question") p.add_argument("--raw", action="store_true", help=f"только сырьё: {RESEARCH} без раздела «Вопрос»") 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, required=True, help="тип решает схему записи: разделы и право на взятие") p.add_argument("--section", help="категория беклога") 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) p.add_argument("--add-tag", dest="add_tag") p.add_argument("--rm-tag", dest="rm_tag") p.add_argument("--dir") p = sub.add_parser("move", help="переставить строку: место в очереди или другая секция") p.add_argument("slug") p.add_argument("--section", help="другая категория беклога;" " без него — текущая секция записи") 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("ready", help="схема типа выполнена — запись можно брать в работу") p.add_argument("slugs", nargs="+") p.add_argument("--dir") p = sub.add_parser("init", help="завести каталог задач в новом проекте") p.add_argument("--dir") # Стадия обязательна: умолчания у неё нет и быть не может. Подставленное # значение отвечало бы за человека на единственный вопрос, который тут # вообще задаётся, — строится приложение или живёт. p.add_argument("--stage", choices=STAGES, required=True, help="build — беклог это план стройки (порядок = зависимость);" " support — очередь правок (порядок = важность)") p.add_argument("--sections", help="секции беклога; на стройке ровно одна") p.add_argument("--items") p.add_argument("--backlog") p.add_argument("--rejected") p = sub.add_parser("stage", help="показать или сменить стадию проекта") p.add_argument("to", nargs="?", choices=STAGES, help="без него — показать текущую") p.add_argument("--sections", help="секции беклога новой стадии") p.add_argument("--dir") 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("--stage", choices=STAGES, required=True) s.add_argument("--target", default="tasks") s.add_argument("--out", default="tasks-adopt-plan.json") s.add_argument("--sections", help="секции беклога; на стройке ровно одна") 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 "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) 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), "ready": lambda: cmd_ready(lay, a), "stage": lambda: cmd_stage(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)