From a73eedb893486e6cb10c3a4110d03d6a79652fcc Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 9 Aug 2026 16:30:27 +0300 Subject: [PATCH] =?UTF-8?q?tasks.py:=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=20=D1=81=D0=BD=D1=8F=D1=82,=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D1=85=D0=B0=D0=BB=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=83=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спринт был вплетён в 215 мест: конфиг, разбор индексов, состояния задачи, add/edit/move/close/reopen, блок здоровья и CLI. Снято всё: SPRINT.md, четыре команды sprint, автотег урожая sprint:<слаг>, проверки набора в check. Гейт готовности стоял на sprint take — единственном месте, где запись судили целиком. Момент нужен и без спринта, иначе задача уезжает в работу без критериев приёмки. Теперь это команда ready <слаг>: тип, цель у feature, пустой раздел вопросов, схема типа. Отказ там рабочая ситуация, а не ошибка употребления, — код 1, не 2. Приоритет стал порядком строк в беклоге, и два места это уже знают: reopen и check --fix ставят восстановленную строку в конец секции и говорят, что позицию назначает человек. Молчаливое восстановление выдавало бы машинную позицию за его решение. Переезд проекта: снести SPRINT.md, прогнать check --fix — задачи из набора вернутся в беклог. Проверено на фикстуре. --- av-dev-tasks/skills/tasks/scripts/tasks.py | 565 +++++---------------- 1 file changed, 124 insertions(+), 441 deletions(-) diff --git a/av-dev-tasks/skills/tasks/scripts/tasks.py b/av-dev-tasks/skills/tasks/scripts/tasks.py index 3db8a02..535a1ff 100755 --- a/av-dev-tasks/skills/tasks/scripts/tasks.py +++ b/av-dev-tasks/skills/tasks/scripts/tasks.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """Детерминированный инструмент управления задачами: файлы против индексов. -Преемник backlog.py. Разница по существу одна: **порядка нет, есть цель**. -Приоритеты и секция-как-уровень заменены на цель (`goal:<слаг>` тегом) и на -спринт — замороженный набор задач, обычно под одну цель. Индексов теперь -четыре, и задача живёт ровно в одном из них за раз. +Преемник backlog.py. Разница по существу одна: **секция-как-уровень заменена +целью** (`goal:<слаг>` тегом), а приоритет стал тем, чем он и является, — +**порядком строк в беклоге**. Индексов три, и задача живёт ровно в одном из них +за раз. Раскладка. Путь каталога — `tasks/` в корне репозитория, жёстко. Каталог принадлежит этому плагину, а не канону документов: `docs/` ведёт другой плагин, и @@ -17,16 +17,17 @@ Секции канонические и в этом порядке: Запланировано | Направления | Сопровождение | Готово (или Planned | Directions | Operations | Done — один язык на индекс) - BACKLOG.md что можно взять — только задачи, целей здесь нет - SPRINT.md текущий спринт: цель (или её отсутствие), набор, дата, слаг + BACKLOG.md что можно взять — только задачи, целей здесь нет. + Порядок строк внутри секции значим: это очередь REJECTED.md ушедшее БЕЗ реализации, с причиной и датой Источник истины — файл задачи в items/. Индексы производны: расходятся — неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса: иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда. -Исключение из производности одно и оно намеренное: **в каком индексе лежит -задача — знают индексы**, потому что «в спринте» это свойство спринта, а не -задачи; поля-состояния в файле нет, а рассогласование ловит check. +Исключений из производности два, и оба намеренные: **в каком индексе лежит +запись — знают индексы** (поля-состояния в файле нет), и **порядок строк в +беклоге** — приоритет это свойство очереди, а не задачи, и в файле ему места +нет. Рассогласование по обоим ловит check. Тип — единственная ось записи и **закрытый словарь из пяти значений**: goal | feature | fix | chore | research, по-английски, как и прочие токены @@ -50,18 +51,17 @@ goal | feature | fix | chore | research, по-английски, как и пр **Цель обязательна не у всякой задачи.** Новая возможность (`feature`) без цели не бывает — цель и есть её содержание. Починка, техдолг и разведка служат -работоспособности, а не направлению, и живут без цели законно; в набор спринта -они входят помимо его цели. +работоспособности, а не направлению, и живут без цели законно. **Тип определяет схему записи**: какие разделы тела обязательны, какие -допустимы, нужна ли цель, берётся ли запись в спринт. Схема — TYPE_SCHEMA; +допустимы, нужна ли цель, берётся ли запись в работу. Схема — TYPE_SCHEMA; проза с алгоритмом работы над каждым типом — `references/task-<тип>.md`. Использование: tasks.py init [--dir DIR] [--sections …] [--items …] [--backlog …] … tasks.py check [--dir DIR] [--fix] tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b] - [--goal S] [--index backlog|sprint|roadmap|all] [--questions] + [--goal S] [--index backlog|roadmap|all] [--questions] tasks.py add --slug S --title T --type goal|feature|fix|chore|research [--section S] [--goal G] [--why H] [--reason R] [--tag a,b] [--dir DIR] tasks.py edit S [--title T] [--why H] [--type T] [--goal G] @@ -69,10 +69,7 @@ goal | feature | fix | chore | research, по-английски, как и пр tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR] tasks.py close S (--reason R | --implemented) [--dir DIR] tasks.py reopen S [--reason R] [--dir DIR] - tasks.py sprint start (--goal S | --no-goal) [--date ГГГГ-ММ-ДД] [--slug S] [--dir DIR] - tasks.py sprint take S [S …] [--dir DIR] - tasks.py sprint drop S [S …] --reason R [--dir DIR] - tasks.py sprint close [--dissolve --reason R] [--dir DIR] + tasks.py ready S [S …] [--dir DIR] tasks.py adopt scan --from PATH [PATH …] [--target DIR] [--out PLAN.json] tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run] @@ -122,9 +119,7 @@ DEFAULTS = { "items": "items", "backlog": "BACKLOG.md", "roadmap": "ROADMAP.md", - "sprint": "SPRINT.md", "rejected": "REJECTED.md", - "sprint_section": "Набор", "criteria_heading": "Критерии приёмки", "surface_heading": "Затрагивает", "questions_heading": "Вопросы", @@ -138,7 +133,7 @@ DEFAULTS = { # Какие ключи конфига — имена файлов и каталогов (их существование сверяется # с диском первым делом, иначе кривой ключ выглядит как пропавший файл). -PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected") +PATH_KEYS = ("items", "backlog", "roadmap", "rejected") DEFAULT_SECTIONS = "Ядро,Инфра" @@ -227,9 +222,6 @@ TYPE_PREFIX = re.compile(r"^\[(.+?)\]\s*(.*)$") SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") SLUG = re.compile(SLUG_RE.pattern + r"\.md") GOAL_LINE = re.compile(r"^(?:-\s+)?\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)") -# `search`, а не `match`: строка меты идёт пунктом списка, а прежняя форма — -# третьим полем строки через `·`. Обе читаются, пишется новая. -SPRINT_SLUG_LINE = re.compile(r"\*\*(?:Спринт|Sprint):\*\*\s*`?([a-z0-9][a-z0-9.-]*)`?") DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") BULLET = re.compile(r"^[-*]\s+(.*)$") # Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст @@ -249,14 +241,13 @@ TYPES = ("goal", "feature", "fix", "chore", RESEARCH) TYPE_EMOJI = {"goal": "🎯", "feature": "✨", "fix": "🐞", "chore": "🧹", RESEARCH: "🔬"} EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} -TAKEABLE = ("feature", "fix", "chore", RESEARCH) # что берётся в спринт +TAKEABLE = ("feature", "fix", "chore", RESEARCH) # что берётся в работу # Заголовок в форме действия требуется там, где исход работы — изменение # системы. У цели он называет возможность, у разведки — предмет: её исход # знание, и заголовок-действие обещал бы решённость, которой ещё нет. ACTION_TYPES = ("feature", "fix", "chore") QUESTION_TAG = "question" GOAL_TAG = "goal:" -SPRINT_TAG = "sprint:" # Цель обязательна только у новой возможности: цель и есть возможность. # Починка, техдолг и разведка служат работоспособности, а не направлению — # придуманная им цель это то же враньё, от которого спасает тип. @@ -266,8 +257,8 @@ NEEDS_GOAL = ("feature",) # **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает, # и схема, хранящая текст, разошлась бы с ними на первой же настройке. # -# Обязательность проверяется там, где по ней принимают решение, — при взятии в -# спринт (и у задачи, уже стоящей в наборе). Раздел не из схемы даёт +# Обязательность проверяется там, где по ней принимают решение, — командой +# `ready` на входе в работу. Раздел не из схемы даёт # **замечание**, а не ошибку: свой раздел в теле — законная вольность проекта, # а вот раздел, которого тип не предполагает, чаще всего означает, что тип # проставлен не тот. @@ -380,8 +371,7 @@ class Plan: Правило одно: **мутация сперва проверяет всё и складывает правки сюда, и только когда ни одна проверка не отказала, зовёт `commit`.** Иначе отказ на втором слаге оставляет первый файл переписанным при нетронутых индексах — - задача числится в спринте и одновременно объясняет причиной, почему из него - вышла. + задача числится закрытой и одновременно лежит строкой в беклоге. `commit` пишет в два такта: сперва все временные файлы (тут и происходит ввод-вывод, тут же и все возможные отказы), потом переименования подряд. @@ -438,7 +428,7 @@ class Layout: @property def indexes(self) -> tuple[str, ...]: - return ("backlog", "sprint", "roadmap") + return ("backlog", "roadmap") def load_config(root: Path) -> dict: @@ -538,7 +528,7 @@ def config_problems(lay: Layout) -> list[str]: out = [] if not lay.items.is_dir(): out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет") - for kind in ("backlog", "roadmap", "sprint", "rejected"): + for kind in ("backlog", "roadmap", "rejected"): p = lay.index(kind) if not p.is_file(): out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет") @@ -668,7 +658,7 @@ def spaced_sections(lines: list[str]) -> list[str]: def raw_last(lines: list[str], raw: set[str]) -> list[str]: """Строки индекса, у которых сырьё снесено в конец своей секции. - Сырьё (`research` без раздела «Вопрос») в спринт не берётся, и стоя между + Сырьё (`research` без раздела «Вопрос») в работу не берётся, и стоя между берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядка «по важности» в беклоге по-прежнему нет: этот порядок **производен от типа**, а не назначен человеком, — потому его и можно проверять машиной. @@ -837,8 +827,7 @@ def tasks_of(lay: Layout) -> dict[str, dict]: def home_index(task: dict) -> str: - """Индекс, которому задача принадлежит по типу. Спринт — исключение: туда - задача попадает не по типу, а решением набора.""" + """Индекс, которому запись принадлежит по типу.""" return "roadmap" if task["type"] == GOAL else "backlog" @@ -935,7 +924,7 @@ SCHEMA_WHY = { " раздела ссылаются её задачи", "repro_heading": "расхождение, которое не воспроизводится, — это research," " а не fix: чинить нечего, пока непонятно, что ломается", - "question_heading": "без вопроса это не разведка, а сырьё — в спринт не берётся", + "question_heading": "без вопроса это не разведка, а сырьё — в работу не берётся", "answer_heading": "приёмка разведки — записанный ответ, и место ему" " (docs/research/, ADR, тело задачи) называется заранее," " иначе ответ останется в переписке", @@ -946,8 +935,8 @@ def place_key_of(rtype: str) -> str: """Имя поля меты, называющего, где запись числится. У цели это «Секция» — часть роадмапа, то есть состояние очереди. У всех - прочих «Категория» — полка домена, в которую задача возвращается из - спринта. Одно имя на два смысла и было конфляцией: секция роадмапа не + прочих «Категория» — полка домена, на которой задача лежит. Одно имя на два + смысла и было конфляцией: секция роадмапа не категория, а категория беклога не состояние очереди. """ return "Секция" if rtype == GOAL else "Категория" @@ -1010,70 +999,6 @@ def questions_open(lay: Layout, task: dict) -> bool: return bool(task["body"].get(lay.cfg["questions_heading"].lower())) -# --- Спринт --- - -def sprint_goal(lay: Layout) -> tuple[str, str]: - """Слаг и заголовок цели текущего спринта; ('', '') — цели нет. - - Пустой ответ значит «цель не названа», а НЕ «спринта нет»: спринт бывает - без цели законно (`--no-goal`). Идёт ли спринт — отвечает `sprint_started`. - """ - for line in read_lines(lay.index("sprint")): - if (m := GOAL_LINE.match(line.strip())): - return Path(m.group(2)).stem, m.group(1) - return "", "" - - -def sprint_slug(lay: Layout) -> str: - """Слаг самого спринта — им метится урожай (`sprint:<слаг>`).""" - for line in read_lines(lay.index("sprint")): - if (m := SPRINT_SLUG_LINE.search(line)): - return m.group(1) - return "" - - -def sprint_started(lay: Layout) -> bool: - """Идёт ли спринт. Признак — слаг, а не цель: цели может не быть. - - Слаг пишет `sprint start` и стирает `sprint close`, он есть у любого - спринта — без него не проставить `sprint:<слаг>`, то есть не собрать - урожай. Поэтому он и есть наблюдаемое «спринт открыт». - """ - return bool(sprint_slug(lay)) - - -def sprint_header(lay: Layout, goal_slug: str, goal_title: str, - date: str, slug: str) -> list[str]: - """Шапка спринта — мета-блок той же формы, что у задачи: поле на строку. - - Прежняя форма (все три поля одной строкой через `·`) читается по-прежнему - — обе регулярки её берут, — но чинить её нечем и не нужно: `SPRINT.md` - переписывается целиком на `sprint start` и очищается на `sprint close`, - так что старая шапка живёт не дольше идущего спринта. - - Спринт без цели пишет ту же строку прозой, без ссылки: поле остаётся на - месте, читатель видит решение, а `GOAL_LINE` его не берёт — «цель не - названа» и «цель потерялась» этим и различаются. - """ - goal_line = (f"- **Цель:** [{goal_title}]({lay.cfg['items']}/{goal_slug}.md)" - if goal_slug else "- **Цель:** не названа — набор без цели") - return ["# Спринт", "", - goal_line, - f"- **Начат:** {date}", - f"- **Спринт:** `{slug}`", "", - f"Урожай спринта поднимается `tasks.py list --tag {SPRINT_TAG}{slug}`" - " — это первая порция переоценки на сессии.", ""] - - -def empty_sprint(lay: Layout) -> str: - return ("# Спринт\n\n" - "Спринта нет. Цель называет человек, набор собирает агент:\n" - "`tasks.py sprint start --goal <слаг>`. Набор без цели —" - " `sprint start --no-goal`\n(багфикс, техдолг, здоровье:" - " работа на работоспособность, а не на направление).\n\n" - f"## {lay.cfg['sprint_section']}\n") - - # --- check --- def check(lay: Layout, fix: bool = False) -> int: @@ -1107,7 +1032,6 @@ def check(lay: Layout, fix: bool = False) -> int: known = {k: {s.lower() for s in sections[k]} for k in lay.indexes} goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL} raw_names = {n for n, t in tasks.items() if raw_research(lay, t)} - goal_of_sprint, _ = sprint_goal(lay) for s in sections["backlog"]: if s.lower() in BLOCKER_SECTIONS: @@ -1125,13 +1049,13 @@ def check(lay: Layout, fix: bool = False) -> int: errors.append(f"{name}: нет заголовка H1") # 0. Тип — единственная ось, и от него зависит всё остальное: схема - # тела, дом строки, имя поля меты, право на взятие в спринт. + # тела, дом строки, имя поля меты, право на взятие в работу. # Пропуск — замечание: записи, заведённые до появления типа, # законны, и переоформлять беклог «заодно» здесь не просят. # Обязательным тип становится там, где по нему принимают решение. if not task["type"]: notes.append(f"{name}: тип не назван — `tasks.py edit {name[:-3]}" - f" --type {'|'.join(TYPES)}`; в спринт без него не возьмут") + f" --type {'|'.join(TYPES)}`; в работу без него не возьмут") elif task["type"] not in TYPES: errors.append(f"{name}: тип «{task['type']}» вне словаря" f" ({', '.join(TYPES)}) — словарь закрыт, иначе отбор" @@ -1157,7 +1081,7 @@ def check(lay: Layout, fix: bool = False) -> int: f" поставит `check --fix`") # 1. Задача живёт ровно в одном индексе за раз. - allowed = {home} | ({"sprint"} if home == "backlog" else set()) + allowed = {home} if not where: errors.append(f"{name}: нет ни в одном индексе (ожидался {label[home]})") elif len(where) > 1: @@ -1171,8 +1095,7 @@ def check(lay: Layout, fix: bool = False) -> int: place = where[0] if len(where) == 1 else None entry = entries[place][name] if place else None - # 2. Секция файла — истина, секция индекса производна. В спринте секция - # файла хранит, куда задачу вернуть, и с «Набором» не сверяется. + # 2. Секция файла — истина, секция индекса производна. if task["legacy_meta"]: errors.append(f"{name}: мета одной строкой — старая форма;" f" `check --fix` перепишет её списком") @@ -1187,15 +1110,14 @@ def check(lay: Layout, fix: bool = False) -> int: and task["place_key"] != want_key.lower(): errors.append(f"{name}: поле меты названо «{task['place_key']}», а у типа" f" «{task['type']}» оно «{want_key}»: у цели это часть" - f" роадмапа (состояние очереди), у задачи — полка домена," - f" в которую она возвращается из спринта." + f" роадмапа (состояние очереди), у задачи — полка домена." f" Переименует `check --fix`") if not task["section"]: errors.append(f"{name}: нет поля **{want_key}:** в мета-блоке") elif task["section"] not in known[home]: errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной" f" секцией {label[home]} ({', '.join(sections[home])})") - elif place is not None and place != "sprint" and entry and entry["section"] \ + elif place is not None and entry and entry["section"] \ and entry["section"].lower() != task["section"]: errors.append(f"{name}: секция в файле «{task['section']}»," f" а в {label[place]} — «{entry['section']}»") @@ -1233,38 +1155,13 @@ def check(lay: Layout, fix: bool = False) -> int: errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель," f" которой нет в {lay.cfg['items']}/") - # 3а. Схема тела вне спринта — только замечанием: обязательным раздел - # становится там, где по нему принимают решение (`sprint take`). - # Лишний раздел говорит о неверном типе, и сказать об этом стоит - # сразу, не дожидаясь набора. + # 3а. Схема тела — только замечанием: обязательным раздел становится + # там, где по нему принимают решение, а решение это `ready` на входе + # в работу. Лишний раздел говорит о неверном типе, и сказать об этом + # стоит сразу, не дожидаясь взятия. if task["type"] in TYPES: notes += schema_verdict(lay, task)[1] - # 4. Спринт: задача с открытым вопросом в набор не берётся, и судит об - # этом раздел, а не тег. - if place == "sprint": - if questions_open(lay, task): - errors.append(f"{name}: в спринте с непустым разделом" - f" «{lay.cfg['questions_heading']}» — вопрос разбирается" - f" до взятия") - elif QUESTION_TAG in task["tags"]: - errors.append(f"{name}: в спринте с тегом «{QUESTION_TAG}» —" - f" либо вопрос открыт и задача выходит, либо тег снимается") - if task["type"] and task["type"] not in TAKEABLE: - errors.append(f"{name}: в спринте тип «{task['type']}»" - f" — цель не берут вовсе, её берут её задачи") - # Спринт без цели ничьей цели и не противоречит: проверять нечему, - # набор там собран по другому признаку (работоспособность). - if goal_of_sprint and task["goal"] and task["goal"] != goal_of_sprint: - errors.append(f"{name}: цель задачи «{task['goal']}» не цель спринта" - f" «{goal_of_sprint}» — набор служит одной цели") - if not task["type"]: - errors.append(f"{name}: в спринте без типа" - f" (`edit {name[:-3]} --type {'|'.join(TAKEABLE)}`)") - # Замечания схемы уже собраны выше — здесь берутся только отказы, - # иначе один и тот же лишний раздел печатался бы дважды. - errors += [f"{x} (в спринте)" for x in schema_verdict(lay, task)[0]] - # 5. Тег «question» производен от раздела: раздел — факт, тег — метка. has_q_section = questions_open(lay, task) if has_q_section and QUESTION_TAG not in task["tags"]: @@ -1304,22 +1201,6 @@ def check(lay: Layout, fix: bool = False) -> int: f" секции — его не берут, и между берущимся оно требует" f" открыть файл, чтобы это понять; переставит `check --fix`") - if goal_of_sprint and goal_of_sprint + ".md" not in tasks: - errors.append(f"{label['sprint']}: цель «{goal_of_sprint}» не найдена" - f" в {lay.cfg['items']}/") - # Спринт без цели законен (`sprint start --no-goal`), и по цели о том, идёт - # ли он, судить нельзя. Судим по слагу: он есть у любого спринта, потому - # что без него не проставить `sprint:<слаг>` и не собрать урожай. - if entries["sprint"] and not sprint_started(lay): - errors.append(f"{label['sprint']}: набор есть, а шапки спринта нет" - f" (- **Спринт:** `…`) — урожай не отобрать, а «спринт идёт»" - f" не отличить от «строки остались от прошлого»;" - f" перезапусти `sprint start`") - elif goal_of_sprint and not sprint_started(lay): - errors.append(f"{label['sprint']}: цель названа, а слага спринта нет" - f" (- **Спринт:** `…`) — урожай не отобрать;" - f" перезапусти `sprint start`") - rejected = lay.index("rejected") if rejected.is_file(): for num, line in enumerate(read_lines(rejected), 1): @@ -1352,7 +1233,7 @@ def check(lay: Layout, fix: bool = False) -> int: def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: - """Метрики здоровья: размер секций, спринт, открытые вопросы, залежалость, + """Метрики здоровья: размер секций, открытые вопросы, залежалость, цели без задач. Механизирует то, что иначе держится на дисциплине.""" backlog = [t for n, t in tasks.items() if n in entries["backlog"]] by_section = {s.lower(): 0 for s in sections["backlog"]} @@ -1371,9 +1252,9 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: if k in by_type) + (f" (сырьём, без «{lay.cfg['question_heading']}», {raw})" if raw else "")) - # Готовность к взятию — та же проверка, что откажет `sprint take`. Число, а - # не перечень: оно отвечает на «есть ли из чего собрать спринт», и когда - # ответ «нет», разбирать надо не список, а порцию переоценки. + # Готовность к взятию — та же проверка, что откажет `ready`. Число, а не + # перечень: оно отвечает на «есть ли что брать сегодня», и когда ответ + # «нет», разбирать надо не список, а порцию груминга. if backlog: ready = [t for t in backlog if t["type"] in TAKEABLE and not questions_open(lay, t) @@ -1384,18 +1265,10 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None: else " — прочим не хватает разделов своего типа, цели или ждут" " ответа на вопрос")) - goal_slug, _ = sprint_goal(lay) - if sprint_started(lay): - goal_part = f"цель «{goal_slug}»" if goal_slug else "без цели" - print(f" спринт: {goal_part}, слаг «{sprint_slug(lay)}»," - f" задач {len(entries['sprint'])}") - else: - print(" спринт: не начат") - questions = [n for n, t in tasks.items() if questions_open(lay, t)] if questions: print(f" с открытым вопросом: {len(questions)}" - f" — в спринт не берутся, разбор первым шагом сессии") + f" — в работу не берутся, разбор первым шагом груминга") # Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но # проверка эвристическая, а беклог, заведённый до правила, переоформляют не @@ -1505,7 +1378,7 @@ def build_meta(rtype: str, section: str, reason: str, why: str, tags: list[str]) """Мета-блок: тип первой строкой, дальше место, «зачем» и теги. Тип стоит первым не для красоты: он решает, что у записи вообще может быть - — какие разделы обязательны, нужна ли цель, берётся ли она в спринт, — и + — какие разделы обязательны, нужна ли цель, берётся ли она в работу, — и читается раньше всего остального. """ out = [f"- **Тип:** {rtype}"] if rtype else [] @@ -1738,7 +1611,7 @@ SECTION_HINT = { " видно вместо ожидаемого. Не воспроизводится — это research," " а не fix", "question_heading": "вопрос, на который отвечает эта разведка, — одной фразой." - " Пока его нет, это сырьё: в спринт не берут", + " Пока его нет, это сырьё: в работу не берут", "answer_heading": "куда ляжет ответ: docs/research/<тема>.md, ADR, тело этой" " задачи. Приёмка разведки — записанный ответ, а не" " изменённый код", @@ -1760,7 +1633,7 @@ def body_template(rtype: str, lay: Layout) -> str: """Шаблон тела по схеме типа: обязательные разделы плюс «Рамки». Шаблон и проверка растут из одного TYPE_SCHEMA: разойтись им нельзя, иначе - `add` кладёт то, на чём `sprint take` потом откажет. + `add` кладёт то, на чём `ready` потом откажет. """ schema = TYPE_SCHEMA.get(rtype, TYPE_SCHEMA["feature"]) out = [f""] @@ -1809,11 +1682,6 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: if rtype in NEEDS_GOAL and not any(t.startswith(GOAL_TAG) for t in tags): print(f" тип «{rtype}» без цели: новая возможность и есть содержание" f" цели — проставь `tasks.py edit {a.slug} --goal <слаг>`") - # Урожай спринта метится сам: тег, который никто не ставит, не отбирает - # первую порцию переоценки, а именно на ней держится правило «сперва урожай». - sslug = sprint_slug(lay) - if rtype != GOAL and sslug and not any(t.startswith(SPRINT_TAG) for t in tags): - tags.append(f"{SPRINT_TAG}{sslug}") if QUESTION_TAG in tags: print(f" тег «{QUESTION_TAG}»: не забудь раздел «{lay.cfg['questions_heading']}» в теле") @@ -1836,8 +1704,6 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: print(f"создано: {lay.cfg['items']}/{a.slug}.md," f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором") - if sslug and f"{SPRINT_TAG}{sslug}" in tags: - print(f" помечено тегом {SPRINT_TAG}{sslug} — урожай идущего спринта") if not a.why: print(f" без «зачем» — задай: tasks.py edit {a.slug} --why …") warn_rejected(lay, a.slug, a.title) @@ -1883,14 +1749,6 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: 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() old_home, new_home = home_index(task), home_index({"type": rtype}) - in_sprint = "sprint" in places - - # Задача в спринте меняет тип только через выход из набора: цель в наборе — - # состояние, которое check объявит ошибкой, а молчаливый успех оставит - # набор в нём. - if in_sprint and rtype not in TAKEABLE: - raise Usage(f"{a.slug} в спринте, а тип «{rtype}» в наборе не живёт:" - f" сперва выведи задачу — tasks.py sprint drop {a.slug} --reason …") h1 = h1_of(rtype, bare) @@ -1986,9 +1844,9 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: if old_home != new_home: print(f" строка переехала: {lay.name(old_home)} → {lay.name(new_home)}" f" (секция «{section}»)") - if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"] and in_sprint: - print(" задача в спринте, а вопрос открыт: либо остаток есть и вопрос ждёт сессии," - f" либо задача выходит — `tasks.py sprint drop {a.slug} --reason …`") + if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"]: + print(f" вопрос открыт — в работу задача не берётся, пока он не разобран" + f" (`tasks.py ready {a.slug}` это и скажет)") return EXIT_OK @@ -2002,9 +1860,6 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int: places = locate_all(lay, a.slug) if not places: raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix") - if "sprint" in places: - raise Usage(f"{a.slug} в спринте: секция — свойство беклога." - f" Сперва верни задачу: tasks.py sprint drop {a.slug} --reason …") if len(places) > 1: raise Usage(f"{a.slug} сразу в нескольких индексах" f" ({', '.join(lay.name(k) for k in places)}) — неоднозначно," @@ -2122,8 +1977,6 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int: f" {lay.name('roadmap')}, секция «{where}», файл удалён") else: print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") - if "sprint" in places and a.reason: - print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта") if not a.reason: print(" дорога назад: файл восстанавливается из git —" f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`") @@ -2185,15 +2038,8 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: plan = Plan() plan.file(path, text) - goal_of_sprint, _ = sprint_goal(lay) target = home_index({"type": rtype}) section = tmp["section"] - # Возврат идёт в набор идущего спринта — тем же тестом, что и `sprint take`: - # мешает только чужая цель. Спринт без цели не мешает ничем, и задача без - # цели не мешает спринту с целью. Спринта нет — возврат в беклог. - fits_sprint = not (goal_of_sprint and tmp["goal"] and tmp["goal"] != goal_of_sprint) - if target == "backlog" and sprint_started(lay) and fits_sprint: - target, section = "sprint", lay.cfg["sprint_section"] lines = read_lines(lay.index(target)) # Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая # правка читает индекс с диска, где первой ещё нет, и затирает её. @@ -2230,8 +2076,12 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: removed = [] plan.commit() + # Возвращённая строка встаёт в конец своей секции: позиция это приоритет, + # а его назначает человек. Молча вернуть задачу наверх очереди значило бы + # принять за него решение, которого он не принимал. print(f"{a.slug}: возвращён в {lay.name(target)} из истории git" - + (f" (секция «{section}»)" if target != "sprint" else " (набор спринта)")) + f" (секция «{section}», в конец: позиция это приоритет, и её" + f" назначает человек)") for line in removed: print(f" снята строка {lay.name('rejected')}: {line.strip()}") for line in unachieved: @@ -2259,83 +2109,21 @@ def meta_updated_text(text: str, reason: str, rtype: str | None = None) -> str | return None if out is None else "\n".join(out) + "\n" -# --- Спринт --- +# --- Готовность записи к работе --- -def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: - # Цель либо названа, либо явно не названа. Голое отсутствие `--goal` не - # проходит намеренно: забытый флаг и решение «этот спринт без цели» иначе - # неразличимы, а второе — продуктовое решение человека. - if not a.goal and not a.no_goal: - raise Usage("цель спринта: --goal <слаг> или явно --no-goal." - " Спринт без цели законен (багфикс, техдолг, здоровье)," - " но называется решением, а не пропуском флага") - if a.goal and (err := bad_slug(a.goal)): - raise Usage(err) - entries, _ = parse_entries(read_lines(lay.index("sprint"))) - if entries: - raise Usage(f"в {lay.name('sprint')} ещё есть набор ({len(entries)}) —" - f" закрой спринт: tasks.py sprint close") - goal_title = "" - if a.goal: - gpath = lay.items / f"{a.goal}.md" - if not gpath.exists(): - raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/") - goal = parse_task(gpath) - if goal["type"] != GOAL: - raise Usage(f"{a.goal} не цель (тип «{goal['type'] or '—'}») —" - f" спринт набирается под одну цель") - goal_title = goal["title"] - date = a.date or datetime.date.today().isoformat() - if not DATE_RE.fullmatch(date): - raise Usage("дата в формате ГГГГ-ММ-ДД") - # Слаг спринта — дата: естественный, монотонный и уже уникальный. Он нужен - # не для красоты: без него тег `sprint:<слаг>` некому проставить, а на нём - # держится правило «первая порция переоценки — урожай прошедшего спринта». - slug = (a.slug or date).lower() - if not re.fullmatch(r"[a-z0-9][a-z0-9.-]*", slug): - raise Usage(f"слаг спринта «{slug}» — латиница, цифры, дефис и точка") - tasks = tasks_of(lay) - if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()): - print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта" - f" — урожаи склеятся; задай другой `--slug`") - lines = [*sprint_header(lay, a.goal or "", goal_title, date, slug), - f"## {lay.cfg['sprint_section']}", ""] - plan = Plan() - plan.index(lay, "sprint", lines) - plan.commit() - ready = [n[:-3] for n, t in tasks.items() - if t["type"] in TAKEABLE - and (t["goal"] == a.goal if a.goal - else not (t["type"] in NEEDS_GOAL and not t["goal"])) - and not questions_open(lay, t) and not schema_verdict(lay, t)[0]] - if a.goal: - print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»") - print(f" кандидатов под цель без открытых вопросов: {len(ready)}" - + (f" ({', '.join(sorted(ready))})" if ready else "")) - else: - print(f"спринт начат: без цели, {date}, слаг «{slug}»") - print(f" набор без цели: цель не проверяется, в него идёт что угодно" - f" из готового к взятию ({len(ready)})." - f" Перечень — `tasks.py list --index backlog`") - print(" докладу это не безразлично: спринт без цели называется таковым" - " и объясняется (багфикс, техдолг, здоровье)") - print(f" заводимое по ходу метится тегом {SPRINT_TAG}{slug} само — это урожай") - print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта") - return EXIT_OK +def cmd_ready(lay: Layout, a: argparse.Namespace) -> int: + """Схема типа выполнена — запись можно брать в работу. + Прежде этот гейт стоял на взятии задачи в спринт: набор и был моментом, + когда запись впервые судили целиком. Спринтов нет, а момент нужен — иначе задача + уезжает в работу без критериев приёмки, и узнают об этом на приёмке, когда + сверять уже не с чем. Теперь момент называет тот, кто берёт: скилл решения + задачи зовёт `ready` первым делом. -def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: - goal_slug, _ = sprint_goal(lay) - # Начат ли спринт, судим по слагу, а не по цели: спринт без цели идёт так же. - if not sprint_started(lay): - raise Usage("спринт не начат:" - " tasks.py sprint start (--goal <слаг> | --no-goal)") - sprint_lines = read_lines(lay.index("sprint")) - backlog_lines = read_lines(lay.index("backlog")) - hi, section = find_section(sprint_lines, lay.cfg["sprint_section"]) - if hi is None: - raise Usage(f"в {lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»") - taken, warn = [], [] + Отказ здесь — **рабочая ситуация**, а не ошибка употребления: запись просто + ещё не дописана. Поэтому код 1, а не 2, и ветвиться на них надо по-разному. + """ + verdicts, warn = [], [] for slug in a.slugs: if (err := bad_slug(slug)): raise Usage(err) @@ -2343,133 +2131,50 @@ def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int: if not path.exists(): raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/") t = parse_task(path) + errs = [] if not t["type"]: - raise Usage(f"{slug}: тип не назван —" - f" `edit {slug} --type {'|'.join(TAKEABLE)}`." - f" Тип решает, каких разделов задача обязана иметь," + errs.append(f"тип не назван — `edit {slug} --type {'|'.join(TAKEABLE)}`." + f" Тип решает, каких разделов запись обязана иметь," f" и без него проверять нечего") - if t["type"] not in TAKEABLE: - raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —" + elif t["type"] not in TAKEABLE: + errs.append(f"тип «{t['type']}» в работу не берётся —" f" цель не берут вовсе, берут её задачи") - # Цель сверяется, только когда она у спринта есть. Спринт без цели - # берёт что угодно готовое: он собран по работоспособности, и чужой - # цели там нет — не с чем расходиться. - if goal_slug and t["goal"] and t["goal"] != goal_slug: - raise Usage(f"{slug}: цель «{t['goal']}» не цель спринта «{goal_slug}» —" - f" набор служит одной цели, даже если взять удобно") - if not t["goal"] and t["type"] in NEEDS_GOAL: - raise Usage(f"{slug}: тип «{t['type']}» без цели —" - f" новая возможность и есть содержание цели" - f" (`edit {slug} --goal <слаг>`)") - # Отказ по факту, а не по метке: непустой раздел «Вопросы» блокирует - # взятие независимо от тега. Забывший тег иначе проходил бы, а - # поставивший спотыкался — стимул ровно обратный правилу. - if questions_open(lay, t): - raise Usage(f"{slug}: непустой раздел «{lay.cfg['questions_heading']}» —" - f" вопрос разбирается до взятия, вне очереди порции." - f" Отвечен — запиши ответ в тело и очисти раздел" - f" (тег снимается `edit {slug} --rm-tag {QUESTION_TAG}`)") - if QUESTION_TAG in t["tags"]: - raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела" - f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда," - f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`") - errs, notes = schema_verdict(lay, t) - if errs: - raise Usage("; ".join(errs)) - warn += notes - ei = find_entry_index(backlog_lines, slug) - if ei is None: - raise Usage(f"{slug}: строки в {lay.name('backlog')} нет" - f" (уже в спринте? прогони check)") - entry = backlog_lines.pop(ei) - insert_entry(sprint_lines, section, entry) - taken.append(slug) + else: + if not t["goal"] and t["type"] in NEEDS_GOAL: + errs.append(f"тип «{t['type']}» без цели — новая возможность и есть" + f" содержание цели (`edit {slug} --goal <слаг>`)") + # Отказ по факту, а не по метке: непустой раздел «Вопросы» держит + # запись независимо от тега. Забывший тег иначе проходил бы, а + # поставивший спотыкался — стимул ровно обратный правилу. + 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)) - plan = Plan() - plan.index(lay, "backlog", backlog_lines) - plan.index(lay, "sprint", sprint_lines) - plan.commit() - total = len(parse_entries(sprint_lines)[0]) - print(f"взято в спринт: {', '.join(taken)}; в наборе {total}") + 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}") - return EXIT_OK - - -def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int: - if (err := bad_reason(a.reason)): - raise Usage(err) - sprint_lines = read_lines(lay.index("sprint")) - backlog_lines = read_lines(lay.index("backlog")) - plan = Plan() - dropped = [] - # Сперва все проверки и весь план правок, потом запись. Иначе отказ на - # втором слаге оставлял первый файл переписанным при нетронутых индексах: - # задача числилась в спринте и одновременно объясняла, почему из него вышла. - for slug in a.slugs: - if (err := bad_slug(slug)): - raise Usage(err) - ei = find_entry_index(sprint_lines, slug) - if ei is None: - raise Usage(f"{slug}: в наборе спринта такой строки нет") - path = lay.items / f"{slug}.md" - if not path.exists(): - raise Usage(f"{slug}: строка в {lay.name('sprint')} есть, а файла" - f" {lay.cfg['items']}/{slug}.md нет — прогони check") - t = parse_task(path) - hi, section = find_section(backlog_lines, t["section"]) - if hi is None: - avail = ", ".join(n for _, n in section_headers(backlog_lines)) - raise Usage(f"{slug}: секция «{t['section'] or '—'}» не найдена" - f" в {lay.name('backlog')} (есть: {avail})") - new_text = meta_updated(path, section=section, reason=a.reason, - rtype=t["type"] or None) - if new_text is None: - raise Usage(f"{slug}.md без поля **{place_key_of(t['type'])}:** в мете —" - f" прогони check и почини") - plan.file(path, new_text) - insert_entry(backlog_lines, section, sprint_lines.pop(ei)) - dropped.append(slug) - plan.index(lay, "backlog", backlog_lines) - plan.index(lay, "sprint", sprint_lines) - plan.commit() - print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мету") - print(" живого предложения оставаться не должно; наработки, которые жалко," - " переносятся в тело задачи текстом") - return EXIT_OK - - -def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int: - if (err := bad_reason(a.reason)): - raise Usage(err) - entries, _ = parse_entries(read_lines(lay.index("sprint"))) - goal_slug, _ = sprint_goal(lay) - slug = sprint_slug(lay) - if entries and not a.dissolve: - raise Usage(f"в наборе осталось задач: {len(entries)}" - f" ({', '.join(sorted(n[:-3] for n in entries))}). Спринт кончается, когда" - f" каждая либо сделана (close --implemented), либо вышла (sprint drop" - f" --reason …). Роспуск при блокере — sprint close --dissolve --reason …") - if a.dissolve: - if not a.reason: - raise Usage("--dissolve без --reason: роспуск объясняется —" - " сработавшим блокером или тем, что набор протух") - drop = argparse.Namespace(slugs=sorted(n[:-3] for n in entries), reason=a.reason) - if entries and (rc := cmd_sprint_drop(lay, drop)) != EXIT_OK: - return rc - plan = Plan() - plan.file(lay.index("sprint"), empty_sprint(lay)) - plan.commit() - print(f"спринт закрыт ({f'цель «{goal_slug}»' if goal_slug else 'без цели'}," - f" слаг «{slug or '—'}»)" - + (", набор распущен" if a.dissolve and entries else "")) - print(f" история наборов остаётся в git: `git log -p {lay.index('sprint')}`") - if slug: - print(f" урожай спринта — `tasks.py list --tag {SPRINT_TAG}{slug}`:" - f" завести найденное по ходу обязан закрывающий спринт, а не пайплайн") - print(f" автотег снят вместе со спринтом: заводимое СЕЙЧАС метится только" - f" вручную — `add … --tag {SPRINT_TAG}{slug}`, иначе выпадет из урожая") - print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор") + if bad: + print(f"\nИтог: не готово {len(bad)} из {len(verdicts)}." + f" Дописывается это на груминге или тем, кто берёт задачу.") + return EXIT_DRIFT return EXIT_OK @@ -2614,8 +2319,7 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: fixed.append(f"{lay.name(kind)}: строка синхронизирована с файлом: {name}") dirty.add(kind) - # 3. Нет строки вовсе / строка в чужом индексе / не в своей секции. Задачу - # из спринта не трогаем: «в спринте» — решение набора, а не свойство файла. + # 3. Нет строки вовсе / строка в чужом индексе / не в своей секции. for name, task in tasks.items(): where = [k for k in lay.indexes if find_entry_index(idx[k], name[:-3]) is not None] if len(where) > 1: @@ -2635,13 +2339,17 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: continue insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3], task["why"])) + # Восстановленная строка встаёт в конец секции, и это надо сказать: + # позиция в беклоге — приоритет, а его назначает человек. Молчаливое + # восстановление выдало бы машинную позицию за его решение. fixed.append(f"{lay.name(home)}: восстановлена строка {name}" + + (" — в конце секции, позицию назначь сам:" + " порядок строк это приоритет" + if home == "backlog" else "") + ("" if task["why"] else " (в файле нет «зачем» — допиши)")) dirty.add(home) continue kind = where[0] - if kind == "sprint": - continue if kind != home: # Задача ровно в одном индексе и не в своём: истина в типе, а тип # в файле — неоднозначности нет, переносим и говорим об этом. @@ -2762,15 +2470,16 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" "+ строка здесь. Целей тут нет — они в " f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\n" - "роадмап — то, подо что берут. Порядка «по важности» внутри секции нет:\n" - "«что делать дальше» отвечает набор спринта. Единственное исключение\n" + "роадмап — то, подо что берут. **Порядок строк внутри секции значим:**\n" + "это очередь, и первая строка — то, что делают следующим. Порядок\n" + "назначает человек на груминге, машина его не выводит. Одно исключение\n" f"производно от типа — сырьё (`{RESEARCH}` без раздела" f" «{lay.cfg['question_heading']}»)\nстоит в конце секции: его не берут." " Ведётся скиллом `tasks`.\n\n" "Тип записи стоит первым полем меты и решает, что у неё может быть:\n" + "".join(f"{TYPE_EMOJI[t]} `{t}` " for t in TAKEABLE) + "\n\n" "Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n" - "(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n" + "(работа не может продолжаться ни одной задачей), оно живёт до ответа\n" "человека, а его следы — вопросами в файлах задач.\n\n" + "".join(f"## {s}\n\n" for s in sections)) out[lay.index("roadmap")] = ( @@ -2793,7 +2502,6 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str], f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)}," " один язык на весь\nиндекс.\n\n" + "".join(f"## {s}\n\n" for s in roadmap_sections)) - out[lay.index("sprint")] = empty_sprint(lay) out[lay.index("rejected")] = ( "# Ушедшее без реализации\n\n" "Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n" @@ -2816,7 +2524,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int: if not dir_within_cwd(root): raise Usage(f"--dir вне рабочего каталога: {root}") cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap), - ("sprint", a.sprint), ("rejected", a.rejected)) if v} + ("rejected", a.rejected)) if v} lay = Layout(root, cfg) if lay.index("backlog").exists(): raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён") @@ -3302,11 +3010,11 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: f" (правится `tasks.py edit <слаг> --goal <цель>`)" + (f": {', '.join(sorted(x[:-3] for x in no_goal)[:5])}…" if no_goal else "")) print(f" задач, не собравших разделы своего типа: {len(unfit)} —" - f" check это ошибкой не считает, но `sprint take` их не возьмёт:" - f" собрать спринт сегодня физически нечем") + f" check это ошибкой не считает, но `ready` их не пропустит:" + f" брать сегодня физически нечего") print(f" закрывается порциями переоценки по 5–8 задач (скилл session, шаг 3):" f" проставить цели, превратить «готово, когда» в критерии с оракулами," - f" вынуть вопросы из прозы в раздел. Готовность к первому спринту —" + f" вынуть вопросы из прозы в раздел. Готовность к первой задаче —" f" не «check зелёный», а «есть {CRITERIA_MIN}+ критериев хотя бы у набора" f" под одну цель».") print(" источники не удалены: сверь глазами и убери сам" @@ -3331,11 +3039,11 @@ def main() -> int: p.add_argument("--section", help="категория беклога или часть роадмапа") p.add_argument("--type", choices=TYPES) p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):" - " goal:<слаг>, question, sprint:<слаг>") + " goal:<слаг>, question") p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)") p.add_argument("--raw", action="store_true", help=f"только сырьё: {RESEARCH} без раздела «Вопрос»") - p.add_argument("--index", choices=("backlog", "sprint", "roadmap", "all")) + p.add_argument("--index", choices=("backlog", "roadmap", "all")) p.add_argument("--questions", action="store_true", help="только с открытым вопросом") p = sub.add_parser("add", help="завести запись: цель, задачу или разведку") @@ -3343,7 +3051,7 @@ def main() -> int: p.add_argument("--slug", required=True) p.add_argument("--title", required=True) p.add_argument("--type", choices=TYPES, required=True, - help="тип решает схему записи: разделы, цель, право на спринт") + help="тип решает схему записи: разделы, цель, право на взятие") p.add_argument("--section", help="категория беклога или часть роадмапа") p.add_argument("--goal", help="слаг цели → тег goal:<слаг>") p.add_argument("--why") @@ -3382,31 +3090,9 @@ def main() -> int: p.add_argument("--reason", help="почему возвращена — уедет в мету") p.add_argument("--dir") - p = sub.add_parser("sprint", help="операции спринта") - ssub = p.add_subparsers(dest="sprint_command", required=True) - s = ssub.add_parser("start", help="начать спринт под названную цель или без цели") - # Группа взаимоисключающая, но не required: отказ за отсутствие обоих - # флагов даёт cmd_sprint_start — его текст объясняет, а argparse только - # называет флаги. - g = s.add_mutually_exclusive_group() - g.add_argument("--goal", help="слаг цели, под которую набирается спринт") - g.add_argument("--no-goal", dest="no_goal", action="store_true", - help="набор без цели: багфикс, техдолг, здоровье —" - " работа на работоспособность, а не на направление") - s.add_argument("--date") - s.add_argument("--slug", help="слаг спринта; по умолчанию дата начала") - s.add_argument("--dir") - s = ssub.add_parser("take", help="взять задачи в набор") - s.add_argument("slugs", nargs="+") - s.add_argument("--dir") - s = ssub.add_parser("drop", help="вернуть задачи в беклог") - s.add_argument("slugs", nargs="+") - s.add_argument("--reason", required=True) - s.add_argument("--dir") - s = ssub.add_parser("close", help="закрыть спринт") - s.add_argument("--dissolve", action="store_true", help="распустить набор (блокер)") - s.add_argument("--reason") - s.add_argument("--dir") + p = sub.add_parser("ready", help="схема типа выполнена — запись можно брать в работу") + p.add_argument("slugs", nargs="+") + p.add_argument("--dir") p = sub.add_parser("init", help="завести каталог задач в новом проекте") p.add_argument("--dir") @@ -3414,7 +3100,6 @@ def main() -> int: p.add_argument("--items") p.add_argument("--backlog") p.add_argument("--roadmap") - p.add_argument("--sprint") p.add_argument("--rejected") p = sub.add_parser("adopt", help="вывести каталог задач из того, что уже есть в репозитории") @@ -3436,9 +3121,6 @@ def main() -> int: if a.command == "adopt": return cmd_adopt_scan(a) if a.adopt_command == "scan" else cmd_adopt_apply(a) lay = resolve_layout(a.dir) - if a.command == "sprint": - return {"start": cmd_sprint_start, "take": cmd_sprint_take, - "drop": cmd_sprint_drop, "close": cmd_sprint_close}[a.sprint_command](lay, a) return { "check": lambda: check(lay, a.fix), "list": lambda: list_tasks(lay, a), @@ -3447,6 +3129,7 @@ def main() -> int: "move": lambda: cmd_move(lay, a), "close": lambda: cmd_close(lay, a), "reopen": lambda: cmd_reopen(lay, a), + "ready": lambda: cmd_ready(lay, a), }[a.command]()