Files
dev-skills/av-dev-pm/skills/tasks/scripts/tasks.py
T
avandClaude Opus 5 2d39a77444 ревизия покрытия av-dev-pm: три решения из шести оказались «убрать»
Сабагент в роли продакт-менеджера оценил покрытие жизненного цикла личного
проекта скиллами и агентами av-dev-pm. Скоуп сужен по ходу разбора: деплой и
разбор инцидентов делаются вручную, скиллов под них не заводим — три находки
из восьми сняты этим сразу.

Шаг 2 сессии требовал чисел, которых процесс отказался собирать решением.
cadence.md делал обязанностью пересмотр «ориентира по размеру спринта, прироста
беклога на закрытую задачу, времени на задачу» и «сколько заняли задачи против
ожидания». Данных нет: у записи нет дат заведения, взятия и закрытия, close
удаляет файл, sprint close очищает SPRINT.md. Хуже, «против ожидания» и «время
на задачу» требуют оценки и тайм-бокса, а session/SKILL.md в «Почему не Scrum»
их прямо не берёт — пункт противоречил решению через файл от себя. Числа не
пересматривались ни разу, поэтому выкинуты, а не подперты учётом дат. Осталось
качественное; рядом записано, что замеров нет намеренно, иначе следующий
читатель заведёт их обратно как недостающие. Шаг 3 пункт 9 переименован из
«переоценки по измеренному» в «по пройденному».

doc-consistency переехал с каждого синка на сессию, к doc-code-drift. Агент на
opus звался шагом 9 пайплайна, то есть 5-8 opus-проходов за спринт по документам,
меняющимся на несколько абзацев. Довод сильнее денег: расхождение между двумя
документами по определению требует двух, а на большинстве задач синк правит
один. И пачка, отбираемая работой, не видит того, чего работа не касалась, —
а расхождение живёт ровно там. Это снимает открытый вопрос REMAINING про охват
парного статуса ADR. Цена — потеря привязки находки к задаче, принято сознательно.

Отмена цели получила порядок, но не флаг. close запрещал закрыть цель с живыми
задачами и не говорил, что с ними делать. Теперь: сперва задачи поштучно
(close --reason своей причиной либо edit --goal на другую), потом цель в
REJECTED.md, а не в Готово. Флаг --cascade отвергнут: поштучный разбор — не
церемония, а единственный момент, когда видно, что переживёт цель. Место
процедуры — переоценка на сессии, отмена цели и есть разбор её задач.

У брошенного спринта появился второй законный исход. --dissolve везде был
привязан к блокеру, и вернувшийся к месячному набору не имел законного хода:
двигать нельзя, распускать не по чему. Теперь роспуск объясняется блокером или
тем, что набор протух. Порога в неделях нет — тот же класс, что выкинутые
числа: счётчик простоя пришлось бы вести руками. Признак не срок, а что набор
перестал быть твоим. Плюс точка входа «вернулся, а спринт открыт» и триггер в
description скилла.

Журнал канона прогоняется как есть, схлопывать 3 и 4 не стали. Взамен появилась
проверка исхода: шагом 6 adopt и шагом 6 upgrade зовутся оба судьи документов.
Это ответ на открытый вопрос «как проверять, что канон не разошёлся с проектами
после upgrade»: check сверяет число в .pm.json с версией скрипта и про существо
записи не знает ничего, а записи применяются руками.

Износ обязательных «границ покрытия» не правится: это гипотеза, а не находка.
Записана наблюдением к первой обкатке. Предложение агента поднять обкатку выше
калибровки снято — TODO уже так устроен, агент спутал «главный риск» с «первое
в очереди»; в REMAINING добавлена оговорка против того же прочтения.

Тема 31 в DECISIONS.md, следствия 117-123.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:02:04 +03:00

3391 lines
194 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Детерминированный инструмент управления задачами: файлы против индексов.
Преемник backlog.py. Разница по существу одна: **порядка нет, есть цель**.
Приоритеты и секция-как-уровень заменены на цель (`goal:<слаг>` тегом) и на
спринт — замороженный набор задач под одну цель. Индексов теперь четыре, и
задача живёт ровно в одном из них за раз.
Раскладка. Путь каталога — `docs/tasks`, жёстко: это часть канона документов
av-dev, и подгоняется под него проект. Имена внутри настраиваются через
`docs/.pm.json`, ключ `tasks`.
docs/tasks/
items/ задачи и цели файлами, <slug>.md
ROADMAP.md состояние проекта: что уже умеет и чего ещё не умеет.
Секции канонические и в этом порядке: Запланировано |
Направления | Сопровождение | Готово (или Planned |
Directions | Operations | Done — один язык на индекс)
BACKLOG.md что можно взять — только задачи, целей здесь нет
SPRINT.md текущий спринт: цель, набор, дата, слаг
REJECTED.md ушедшее БЕЗ реализации, с причиной и датой
Источник истины — файл задачи в items/. Индексы производны: расходятся —
неправ индекс. **«Зачем» живёт в мете файла**, а не только в строке индекса:
иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда.
Исключение из производности одно и оно намеренное: **в каком индексе лежит
задача — знают индексы**, потому что «в спринте» это свойство спринта, а не
задачи; поля-состояния в файле нет, а рассогласование ловит check.
Тип — единственная ось записи и **закрытый словарь из пяти значений**:
goal | feature | fix | chore | research, по-английски, как и прочие токены
команд. Дом типа — **поле меты `Тип` первой строкой**; эмодзи в заголовке H1
производна от него, её ставит и чинит `check --fix`. Эмодзи нужна там, где
принимают решение «брать или не брать», — в строке индекса, а она копирует H1
дословно.
Прежних осей было две: тип записи (goal | idea | task) и род работы
(`kind:<род>` тегом). Ортогональность была фальшивой — из двенадцати клеток
произведения законны шесть, — а «алгоритм решения задач такого типа» крепится
не к `task`, а к `fix` и `research`. Отдельного типа `idea` тоже не осталось:
он значил не род работы, а **состояние незаполненности**, и это состояние
теперь называется честно — `research` без раздела «Вопрос».
**Цель — возможность приложения**, а не тема работ: она отвечает на вопрос «что
приложение будет уметь», её «Завершение» — наблюдаемый признак того, что уже
умеет. Задача — шаг к этой возможности, она отвечает на «что для этого нужно
сделать». Отсюда роадмап и есть состояние проекта: достигнутая цель не
исчезает, а переезжает строкой с датой в секцию достигнутого.
**Цель обязательна не у всякой задачи.** Новая возможность (`feature`) без цели
не бывает — цель и есть её содержание. Починка, техдолг и разведка служат
работоспособности, а не направлению, и живут без цели законно; в набор спринта
они входят помимо его цели.
**Тип определяет схему записи**: какие разделы тела обязательны, какие
допустимы, нужна ли цель, берётся ли запись в спринт. Схема — 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]
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]
[--add-tag a,b] [--rm-tag c,d] [--section S] [--dir DIR]
tasks.py move S --section S [--reason R] [--after S | --first] [--dir DIR]
tasks.py close S (--reason R | --implemented) [--dir DIR]
tasks.py reopen S [--reason R] [--dir DIR]
tasks.py sprint start --goal S [--date ГГГГ-ММ-ДД] [--slug S] [--dir DIR]
tasks.py sprint take S [S …] [--dir DIR]
tasks.py sprint drop S [S …] --reason R [--dir DIR]
tasks.py sprint close [--dissolve --reason R] [--dir DIR]
tasks.py adopt scan --from PATH [PATH …] [--target DIR] [--out PLAN.json]
tasks.py adopt apply --plan PLAN.json [--refs PATH …] [--dry-run]
Каталог задач: `--dir` (обязан быть внутри рабочего каталога) → `docs/tasks`
вверх от текущего каталога. Прежние раскладки (`tasks`, `doc/tasks`) читаются,
пока живы непереехавшие проекты; переводит их скилл av-dev-pm:canon.
Коды выхода (единый словарь, на нём ветвятся скиллы):
0 всё сошлось / операция выполнена
1 расхождения найдены (только check: дрейф индексов и файлов)
2 ошибка употребления: неверные аргументы, нарушенное правило процесса
3 окружение: каталог задач не найден, конфиг битый или указывает в никуда
4 внутренний сбой (непойманное исключение) — это дефект скрипта
Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт
заголовок, мета-блок и шаблон-плейсхолдер; агент дописывает редактором.
Границы безопасности: слаг — только латиница kebab-case (traversal невозможен),
--dir обязан быть внутри рабочего каталога, в заголовок, «зачем» и причину не
пролезет перевод строки: поле меты — ровно одна строка.
Все проверки идут до первой записи; запись — одним проходом (см. Plan).
Язык не зашит инструментально: секции сопоставляются с заголовками индексов как
есть, имена служебных файлов и заголовков настраиваются. Текст задач — русский.
"""
import argparse
import datetime
import json
import re
import subprocess
import sys
from pathlib import Path
CONFIG_NAME = ".tasks.json" # прежний дом настроек, читается для совместимости
PM_CONFIG_REL = "../.pm.json" # текущий дом: docs/.pm.json, ключ "tasks"
EXIT_OK = 0
EXIT_DRIFT = 1
EXIT_USAGE = 2
EXIT_ENV = 3
EXIT_INTERNAL = 4
DEFAULTS = {
"items": "items",
"backlog": "BACKLOG.md",
"roadmap": "ROADMAP.md",
"sprint": "SPRINT.md",
"rejected": "REJECTED.md",
"sprint_section": "Набор",
"criteria_heading": "Критерии приёмки",
"surface_heading": "Затрагивает",
"questions_heading": "Вопросы",
"completion_heading": "Завершение",
"repro_heading": "Воспроизведение",
"question_heading": "Вопрос",
"answer_heading": "Куда ляжет ответ",
"scope_heading": "Рамки",
"oracle_word": "оракул",
}
# Какие ключи конфига — имена файлов и каталогов (их существование сверяется
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected")
DEFAULT_SECTIONS = "Ядро,Инфра"
# Секции роадмапа **канонические**, в отличие от секций беклога. Причина не в
# любви к единообразию: у каждой своя семантика — достигнутое, очередь, долгие
# направления, работа по сопровождению, — в достигнутое пишет сам `close`, и роадмап,
# названный по-своему, читался бы только своим автором. Секции беклога семантики
# не несут, это полки, и остаются делом проекта.
#
# Пара на секцию: русское имя и английское. Проект держит **один язык на весь
# индекс** — вперемешку это дрейф, который check называет вслух. Сверка везде
# идёт по нижнему регистру, а пишется — как здесь: заголовок предложением, с
# прописной.
# Порядок значим и проверяется: достигнутое **копится**, и стоя первым оно со
# временем отодвигает за экран всё, ради чего роадмап открывают.
ROADMAP_SECTIONS = (
("Запланировано", "Planned"), # очередь значима, обоснована прозой
("Направления", "Directions"), # очереди нет, тянутся долго
("Сопровождение", "Operations"), # чем держат проект, а не что умеет приложение
("Готово", "Done"), # достигнутое: что приложение уже умеет
)
# Позиции — из самого кортежа, а не числами: переставили секцию — индексы
# переехали сами.
PLANNED, DIRECTIONS, OPERATIONS, ACHIEVED = range(len(ROADMAP_SECTIONS))
DEFAULT_ROADMAP_SECTIONS = ",".join(ru for ru, _ in ROADMAP_SECTIONS)
# Мета — список под заголовком, поле на строку. Старая форма (все поля одной
# строкой через `·`) читается по-прежнему: у проектов на диске лежат файлы в
# ней, и `check --fix` переписывает их в новую. Ради этого разделитель `·` и
# оставался зарезервированным — в новой форме он ничего не разделяет.
META_ITEM = re.compile(r"^-\s+\*\*(.+?):\*\*\s*(.*)$")
META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$")
# «Хук» — прежнее имя поля «Зачем». Читается, чтобы файлы проектов переезжали
# сами; пишется всегда новое.
WHY_KEYS = ("зачем", "why", "хук", "hook")
# Ключи, которые скрипт у себя признаёт. Нужны не разбору (там ключи
# перечислены по месту), а поиску поля, отбившегося от блока: сверять с
# закрытым списком — единственный способ не спутать поле меты со строкой тела
# вида `- **Важно:** …`.
TYPE_KEYS = ("тип", "type")
# «Секция» — прежнее имя поля, оставшееся у цели: она указывает на часть
# роадмапа, а это состояние очереди, а не полка домена. У всех прочих типов
# поле называется «Категория». Разбор принимает оба ключа у любого типа, чтобы
# файлы переезжали сами; `check` называет несовпадение дрейфом, `--fix` правит.
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")
GOAL_LINE = re.compile(r"^(?:-\s+)?\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)")
# `search`, а не `match`: строка меты идёт пунктом списка, а прежняя форма —
# третьим полем строки через `·`. Обе читаются, пишется новая.
SPRINT_SLUG_LINE = re.compile(r"\*\*(?:Спринт|Sprint):\*\*\s*`?([a-z0-9][a-z0-9.-]*)`?")
DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
BULLET = re.compile(r"^[-*]\s+(.*)$")
# Строка кладбища: - ГГГГ-ММ-ДД `slug` — текст
REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+")
GOAL = "goal"
RESEARCH = "research"
# Тип — единственная ось и **закрытый словарь**. Открытый разъедется на
# синонимах (`bug`, `bugfix`, `fix`, `defect`), и отбор по типу перестанет
# отвечать на свой единственный вопрос. Ни один тип не подходит — это сигнал,
# что в записи их два и её надо разделить.
TYPES = ("goal", "feature", "fix", "chore", RESEARCH)
# Эмодзи **производна от типа**, а не второй его дом: её ставит `add` и чинит
# `check --fix`. Живёт в H1 потому, что строка индекса копирует заголовок
# дословно, — так тип виден там, где решают «брать или не брать», и инвариант
# «заголовок в индексе дословно» остаётся нетронутым.
TYPE_EMOJI = {"goal": "🎯", "feature": "✨", "fix": "🐞",
"chore": "🧹", RESEARCH: "🔬"}
EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()}
TAKEABLE = ("feature", "fix", "chore", RESEARCH) # что берётся в спринт
# Заголовок в форме действия требуется там, где исход работы — изменение
# системы. У цели он называет возможность, у разведки — предмет: её исход
# знание, и заголовок-действие обещал бы решённость, которой ещё нет.
ACTION_TYPES = ("feature", "fix", "chore")
QUESTION_TAG = "question"
GOAL_TAG = "goal:"
SPRINT_TAG = "sprint:"
# Цель обязательна только у новой возможности: цель и есть возможность.
# Починка, техдолг и разведка служат работоспособности, а не направлению —
# придуманная им цель это то же враньё, от которого спасает тип.
NEEDS_GOAL = ("feature",)
# Схема тела на тип: какие разделы обязательны, какие ещё допустимы. Значения —
# **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает,
# и схема, хранящая текст, разошлась бы с ними на первой же настройке.
#
# Обязательность проверяется там, где по ней принимают решение, — при взятии в
# спринт (и у задачи, уже стоящей в наборе). Раздел не из схемы даёт
# **замечание**, а не ошибку: свой раздел в теле — законная вольность проекта,
# а вот раздел, которого тип не предполагает, чаще всего означает, что тип
# проставлен не тот.
TYPE_SCHEMA = {
"goal": {"required": ("completion_heading",), "allowed": ()},
"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 без «Вопроса»
DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели»)
STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check
CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки
BODY_PLACEHOLDER = "<!-- "
BLOCKER_SECTIONS = ("блокеры", "блокер", "blockers", "blocked")
class Usage(Exception):
"""Нарушение правила или неверный ввод — сообщение адресовано вызывающему."""
class Env(Exception):
"""Каталог задач или его конфиг непригодны — чинится не аргументами."""
# --- Валидация недоверенного ввода (аргументы могут прийти из текста задачи) ---
def bad_line(value: str | None, field: str) -> str | None:
"""Однострочность: перевод строки/управляющий символ ломает индекс и файл."""
if value is not None and (any(c in value for c in "\n\r") or any(ord(c) < 32 for c in value)):
return f"{field}: перевод строки или управляющий символ запрещён"
return None
def bad_slug(slug: str) -> str | None:
if not SLUG_RE.fullmatch(slug):
return f"слаг «{slug}» — только латиница kebab-case (без ../, точек, слэшей)"
return None
def bad_meta_text(value: str | None, field: str) -> str | None:
"""Поле меты — ровно одна строка: мета читается построчно."""
return None if value is None else bad_line(value, field)
def bad_reason(reason: str | None) -> str | None:
return bad_meta_text(reason, "причина")
def bad_why(why: str | None) -> str | None:
return bad_meta_text(why, "зачем")
def bad_tags(raw: str | None) -> str | None:
if raw is None:
return None
if (e := bad_line(raw, "теги")):
return e
for t in split_tags(raw):
if not re.fullmatch(r"[a-z0-9]+(?:[-:.][a-z0-9]+)*", t):
return f"тег «{t}» — латиница, цифры и разделители -:. (например goal:merge-robustness)"
return None
def bad_type(rtype: str | None) -> str | None:
"""Тип — закрытый словарь: открытый разъедется на синонимах.
Пять человек заведут `bug`, `bugfix`, `fix`, `defect` и `починка`, и отбор
по типу перестанет отвечать на свой единственный вопрос.
"""
if rtype is None:
return None
if rtype.strip().lower() not in TYPES:
return (f"тип «{rtype}» не из словаря: {', '.join(TYPES)}."
f" Не подходит ни один — это сигнал, что задача не одна")
return None
def split_tags(raw: str | None) -> list[str]:
return [t.strip().lower() for t in (raw or "").split(",") if t.strip()]
def dir_within_cwd(root: Path) -> bool:
try:
root.resolve().relative_to(Path.cwd().resolve())
return True
except ValueError:
return False
# --- Запись: сперва все проверки, потом один проход ---
def write_atomic(path: Path, text: str) -> None:
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(text, encoding="utf-8")
tmp.replace(path)
class Plan:
"""Намерения записи, собранные до первой записи.
Правило одно: **мутация сперва проверяет всё и складывает правки сюда, и
только когда ни одна проверка не отказала, зовёт `commit`.** Иначе отказ на
втором слаге оставляет первый файл переписанным при нетронутых индексах —
задача числится в спринте и одновременно объясняет причиной, почему из него
вышла.
`commit` пишет в два такта: сперва все временные файлы (тут и происходит
ввод-вывод, тут же и все возможные отказы), потом переименования подряд.
Полной транзакции на несколько файлов POSIX не даёт, но окно рассогласования
сжимается до цепочки rename без ввода-вывода; а поскольку **индексы
производны от файлов**, порванная цепочка чинится `check --fix` без потерь.
"""
def __init__(self) -> None:
self.writes: list[tuple[Path, str]] = []
self.deletes: list[Path] = []
def file(self, path: Path, text: str) -> None:
text = text if text.endswith("\n") else text + "\n"
for i, (p, _) in enumerate(self.writes):
if p == path: # один файл — одна запись, побеждает последняя
self.writes[i] = (path, text)
return
self.writes.append((path, text))
def index(self, lay: "Layout", kind: str, lines: list[str]) -> None:
self.file(lay.index(kind), "\n".join(spaced_sections(lines)))
def delete(self, path: Path) -> None:
self.deletes.append(path)
def commit(self) -> None:
staged: list[tuple[Path, Path]] = []
for path, text in self.writes:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(text, encoding="utf-8")
staged.append((tmp, path))
for tmp, path in staged:
tmp.replace(path)
for path in self.deletes:
path.unlink(missing_ok=True)
class Layout:
"""Каталог задач и имена его частей. Всё настраивается: у соседнего проекта
может быть другой подкаталог и другие имена индексов, а семантика та же."""
def __init__(self, root: Path, cfg: dict):
self.root = root
self.cfg = {**DEFAULTS, **cfg}
self.items = root / self.cfg["items"]
def index(self, kind: str) -> Path:
return self.root / self.cfg[kind]
def name(self, kind: str) -> str:
return self.cfg[kind]
@property
def indexes(self) -> tuple[str, ...]:
return ("backlog", "sprint", "roadmap")
def load_config(root: Path) -> dict:
"""Настройки каталога задач.
Дом один — `docs/.pm.json`, ключ `tasks`: один конфиг на весь канон, а не по
одному на каталог. Прежний `<tasks>/.tasks.json` читается, пока живы проекты,
которые ещё не переехали; когда есть оба, побеждает `.pm.json`, и об этом
говорится вслух, потому что молча выбранный из двух конфиг — это дрейф,
который потом никто не объяснит.
"""
pm = (root / PM_CONFIG_REL).resolve()
if pm.is_file():
data = _read_json(pm)
section = data.get("tasks", {})
if not isinstance(section, dict):
raise Env(f"{pm}: ключ «tasks» — ожидался объект с настройками")
if (root / CONFIG_NAME).is_file():
print(f"ЗАМЕЧАНИЕ настройки взяты из {pm}; {root / CONFIG_NAME}"
f" остался от прежней раскладки и не читается — удали его",
file=sys.stderr)
return _validate_config(section, pm)
path = root / CONFIG_NAME
if not path.is_file():
return {}
return _validate_config(_read_json(path), path)
def _read_json(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise Env(f"{path}: не разбирается как JSON — {e}") from e
if not isinstance(data, dict):
raise Env(f"{path}: ожидался объект с настройками")
return data
def _validate_config(data: dict, path: Path) -> dict:
unknown = set(data) - set(DEFAULTS)
# Ключ «plan» был домом оглавления целей до того, как файл стал ROADMAP.md.
# Без этой ветки проект со старым конфигом получал бы «неизвестный ключ» и
# искал опечатку там, где на самом деле переименование канона.
if "plan" in unknown:
raise Env(f"{path}: ключ «plan» переименован в «roadmap»,"
f" а PLAN.md — в ROADMAP.md. Повысь проект скиллом"
f" av-dev-pm:canon (upgrade), а не правь ключ в одиночку:"
f" файл и ссылки на него переезжают вместе с ним")
if unknown:
raise Env(f"{path}: неизвестные ключи: {', '.join(sorted(unknown))}"
f" (известны: {', '.join(sorted(DEFAULTS))})")
for key, value in data.items():
if not isinstance(value, str) or not value.strip():
raise Env(f"{path}: ключ «{key}» — ожидалась непустая строка")
if key in PATH_KEYS and (value.startswith("/") or ".." in Path(value).parts):
raise Env(f"{path}: ключ «{key}» = «{value}» — только имя внутри каталога задач")
return data
def config_home(root: Path) -> Path | None:
"""Откуда настройки читаются на самом деле — и куда, значит, слать чинить.
Порядок тот же, что в `load_config`: `docs/.pm.json` побеждает. Без этой
функции сообщения об ошибке звали править `.tasks.json`, который при живом
`.pm.json` вообще не читается.
"""
pm = (root / PM_CONFIG_REL).resolve()
if pm.is_file():
return pm
return root / CONFIG_NAME if (root / CONFIG_NAME).is_file() else None
def config_problems(lay: Layout) -> list[str]:
"""Каждый путь из конфига сверяется с диском ДО любых выводов о задачах.
Иначе неверный ключ (`items: "tasks"` при каталоге `items/`) заставляет
check обвинять невиновных: «ссылка на несуществующий файл», хотя файл на
месте, а мимо смотрит конфиг.
"""
where = str(config_home(lay.root) or "умолчания (конфига нет)")
out = []
if not lay.items.is_dir():
out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет")
for kind in ("backlog", "roadmap", "sprint", "rejected"):
p = lay.index(kind)
if not p.is_file():
out.append(f"{where}: {kind} = «{lay.cfg[kind]}» → {p} — файла нет")
return out
def looks_like_tasks(p: Path) -> bool:
if (p / CONFIG_NAME).is_file():
return True
try: # индекс мог быть переименован через docs/.pm.json
name = load_config(p).get("backlog", DEFAULTS["backlog"])
except Env:
name = DEFAULTS["backlog"]
return (p / name).is_file()
def resolve_layout(explicit: str | None) -> Layout:
"""Каталог задач для команд, кроме init.
Цепочка разрешения: явный `--dir` (обязан быть внутри рабочего каталога) →
`.tasks.json` или умолчания вверх от текущего каталога. Указатель в
`CLAUDE.md` проекта — звено между ними, но читает его агент и передаёт
сюда `--dir`: скрипт не разбирает чужую документацию.
"""
if explicit:
root = Path(explicit)
if not dir_within_cwd(root):
raise Env(f"--dir вне рабочего каталога: {explicit}")
if not looks_like_tasks(root):
raise Env(f"задач нет в «{explicit}»;"
f" новый проект — tasks.py init --dir {explicit}")
return Layout(root, load_config(root))
here = Path.cwd().resolve()
for base in (here, *here.parents):
for candidate in (base, base / "docs/tasks", base / "tasks", base / "doc/tasks"):
if looks_like_tasks(candidate):
try:
rel = candidate.relative_to(here)
except ValueError:
rel = candidate
return Layout(rel if str(rel) != "." else candidate, load_config(candidate))
if (base / ".git").exists():
break # выше корня репозитория не ищем
raise Env("каталог задач не найден: ни --dir, ни docs/tasks вверх от"
f" {here}. По канону путь всегда docs/tasks; чужую раскладку"
" переводит скилл av-dev-pm:canon, новый проект —"
" tasks.py init --dir docs/tasks")
# --- Чтение индексов ---
def read_lines(path: Path) -> list[str]:
return path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
def parse_entries(lines: list[str]) -> tuple[dict[str, dict], list[str]]:
"""Строки индекса по имени файла + порядок секций.
Дубли имени файла тут схлопываются (побеждает последний) — их отдельно
ловит index_lint, поэтому опираться на этот dict как на полноту нельзя.
"""
entries: dict[str, dict] = {}
sections: list[str] = []
section = None
for num, line in enumerate(lines, 1):
m = SECTION.match(line)
if m:
section = m.group(1)
sections.append(section)
continue
m = INDEX_ENTRY.match(line)
if m:
title, target, why = m.group(1), m.group(2), (m.group(3) or "").strip()
entries[Path(target).name] = {
"title": title, "section": section, "why": why,
"line": num, "target": target}
return entries, sections
INFINITIVE = re.compile(r"(?:ть|ти|чь)(?:ся)?$")
def action_title(title: str) -> bool:
"""Заголовок задачи в форме действия: первое слово — глагол в неопределённой
форме, перед ним допускается «не».
Эвристика, и намеренно грубая: русская морфология без словаря не разбирается,
а «Часть данных теряется» от «Печатать поле» отличается ровно окончанием
первого слова. Поэтому результат идёт **счётчиком в здоровье**, а не
замечанием: ошибиться на одном заголовке дешевле, чем не заметить двадцати.
"""
words = re.findall(r"[^\W\d_]+", title)
if not words:
return False
first = words[0].lower()
if first in ("не", "не-") and len(words) > 1:
first = words[1].lower()
return bool(INFINITIVE.search(first))
def spaced_sections(lines: list[str]) -> list[str]:
"""Отбивка вокруг заголовка секции: пустая строка перед ним и после него.
Живёт на записи, а не на вставке: через `Plan.index` проходит **каждая**
запись индекса, и чинить отбивку в каждом месте вставки значило бы
полагаться на то, что ни одно из них не забыли. Перед заголовком — не
педантизм: перестановка секций двигает целые блоки, и два заголовка легко
оказываются вплотную друг к другу.
Заодно схлопывает подряд идущие пустые строки: удаление строки индекса
оставляет после себя две, и без этого шага пустоты копятся."""
out: list[str] = []
for i, line in enumerate(lines):
if SECTION.match(line):
if out and out[-1].strip():
out.append("")
out.append(line)
if i + 1 < len(lines) and lines[i + 1].strip():
out.append("")
continue
if not line.strip() and out and not out[-1].strip():
continue
out.append(line)
return out
def raw_last(lines: list[str], raw: set[str]) -> list[str]:
"""Строки индекса, у которых сырьё снесено в конец своей секции.
Сырьё (`research` без раздела «Вопрос») в спринт не берётся, и стоя между
берущимися оно каждый раз требует открыть файл, чтобы это понять. Порядка
«по важности» в беклоге по-прежнему нет: этот порядок **производен от
типа**, а не назначен человеком, — потому его и можно проверять машиной.
Переставляются только сами строки-пункты, по своим же позициям: проза
внутри секции, отбивка и заголовки остаются на месте.
"""
out = list(lines)
heads = [i for i, line in enumerate(lines) if SECTION.match(line)]
for k, start in enumerate(heads):
end = heads[k + 1] if k + 1 < len(heads) else len(lines)
pos = [i for i in range(start + 1, end) if INDEX_ENTRY.match(lines[i])]
if not pos:
continue
def is_raw(line: str) -> bool:
m = INDEX_ENTRY.match(line)
return m is not None and Path(m.group(2)).name in raw
vals = [lines[i] for i in pos]
for i, v in zip(pos, [v for v in vals if not is_raw(v)]
+ [v for v in vals if is_raw(v)], strict=True):
out[i] = v
return out
def index_lint(lines: list[str], label: str) -> list[str]:
"""Структурные дефекты индекса, которых схлопнутый dict не видит: битые
строки-пункты, дубли на один файл, задачи до первой секции."""
errors: list[str] = []
section = None
seen: dict[str, int] = {}
for num, line in enumerate(lines, 1):
if (m := SECTION.match(line)):
section = m.group(1)
continue
if not line.startswith("- ["):
continue
m = INDEX_ENTRY.match(line)
if not m:
errors.append(f"{label}:{num}: строка-пункт не по формату"
f" «- [Заголовок](items/slug.md) — зачем»")
continue
target = Path(m.group(2)).name
if section is None:
errors.append(f"{label}:{num}: {target} стоит до первой секции")
if target in seen:
errors.append(f"{label}:{num}: дубль строки для {target}"
f" (первая — строка {seen[target]})")
else:
seen[target] = num
# Оформление сверяется **самим нормализатором**, а не своим набором условий:
# два описания одного правила разъедутся, и `check` начнёт молчать о том,
# что `--fix` правит (или наоборот).
if spaced_sections(lines) != lines:
errors.append(f"{label}: оформление секций — заголовок отбивается пустой"
f" строкой с обеих сторон, подряд идущих пустых строк не"
f" бывает; починит `check --fix`")
return errors
# --- Чтение файлов задач ---
def body_sections(text: str) -> dict[str, str]:
"""Содержимое разделов `## …` тела — по нижнему регистру заголовка.
Комментарии-плейсхолдеры вырезаются: раздел, в котором остался только
подсказочный `<!-- … -->`, считается пустым, иначе шаблон `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()]
goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(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, "goal": 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 home_index(task: dict) -> str:
"""Индекс, которому задача принадлежит по типу. Спринт — исключение: туда
задача попадает не по типу, а решением набора."""
return "roadmap" if task["type"] == GOAL else "backlog"
def touched_map(lay: Layout) -> dict[str, str]:
"""Дата последнего коммита каждого файла задач — одним вызовом git. Ключ —
имя файла (в items/ имена уникальны). Нет git / нет истории → пустая карта,
вызывающий подставит «—»."""
try:
out = subprocess.run(["git", "log", "--format=%as", "--name-only", "--", str(lay.items)],
capture_output=True, text=True).stdout
except FileNotFoundError:
return {}
dates: dict[str, str] = {}
cur = None
for line in out.splitlines():
if not line.strip():
continue
if DATE_RE.fullmatch(line):
cur = line # лог новейшие сверху → первая дата и есть последняя правка
elif cur:
dates.setdefault(Path(line).name, cur)
return dates
# --- Критерии приёмки: механизируемая часть ---
def criteria_stats(lay: Layout, body: str) -> tuple[int, int]:
"""(сколько утверждений, у скольких не назван оракул).
Честно механизируется только счёт пунктов. «Назван оракул» проверяется
эвристикой — присутствием слова-маркера (`oracle_word`) в пункте, — и это
именно эвристика: настоящий оракул от слова «оракул» она не отличает.
Поэтому счёт даёт отказ, а оракул — только замечание.
"""
marker = lay.cfg["oracle_word"].lower()
items: list[str] = []
for line in body.splitlines():
if (m := BULLET.match(line)):
items.append(m.group(1))
elif items and line.strip():
items[-1] += " " + line.strip()
return len(items), sum(1 for it in items if marker not in it.lower())
def criteria_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]:
"""Отказы и замечания по критериям приёмки задачи. Общее для check и take."""
crit = lay.cfg["criteria_heading"].lower()
body = task["body"].get(crit, "")
name = task["path"].name
if not body:
return ([f"{name}: нет раздела «{lay.cfg['criteria_heading']}»"
f" — принимать будет не по чему"], [])
n, no_oracle = criteria_stats(lay, body)
errors, notes = [], []
if n < CRITERIA_MIN:
errors.append(f"{name}: критериев приёмки {n}, надо {CRITERIA_MIN}{CRITERIA_MAX}"
f" проверяемых утверждений списком «- …»")
elif n > CRITERIA_MAX:
notes.append(f"{name}: критериев приёмки {n} — больше {CRITERIA_MAX};"
f" это обычно признак, что задача крупнее задачи")
if no_oracle:
notes.append(f"{name}: у {no_oracle} из {n} критериев не назван оракул"
f" (нет слова «{lay.cfg['oracle_word']}») — проверено только наличие"
f" слова, годность оракула машине не видна")
return errors, notes
def surface_verdict(lay: Layout, task: dict) -> tuple[list[str], list[str]]:
"""Отказы и замечания по разделу «Затрагивает». Общее для check и take.
Раздел называет **границы**, которых изменение касается: эндпоинт, команду,
таблицу и миграцию, формат на диске, публичный тип пакета. Без него задача
оценивается по объёму текста, а не по объёму поверхности, — и оценка
систематически занижена ровно там, где текст короткий, а границ много.
Механизируется только наличие непустого раздела. Полнота перечня машине не
видна: границу, которую забыли назвать, от отсутствующей она не отличает.
"""
name = task["path"].name
if not task["body"].get(lay.cfg["surface_heading"].lower()):
return ([f"{name}: нет раздела «{lay.cfg['surface_heading']}»"
f" — оценивать будет не по чему: границы (эндпоинт, таблица и"
f" миграция, формат на диске, публичный тип) не названы"], [])
return [], []
# --- Схема тела: тип решает, каких разделов запись обязана иметь ---
# Зачем нужен раздел — по ключу конфига. Текст идёт в отказ: «нет раздела X»
# без причины читается как придирка формы, а причина у каждого своя и
# проектная.
SCHEMA_WHY = {
"completion_heading": "по чему видно, что цель достигнута; на строки этого"
" раздела ссылаются её задачи",
"repro_heading": "расхождение, которое не воспроизводится, — это research,"
" а не fix: чинить нечего, пока непонятно, что ломается",
"question_heading": "без вопроса это не разведка, а сырьё — в спринт не берётся",
"answer_heading": "приёмка разведки — записанный ответ, и место ему"
" (docs/research/, ADR, тело задачи) называется заранее,"
" иначе ответ останется в переписке",
}
def place_key_of(rtype: str) -> str:
"""Имя поля меты, называющего, где запись числится.
У цели это «Секция» — часть роадмапа, то есть состояние очереди. У всех
прочих «Категория» — полка домена, в которую задача возвращается из
спринта. Одно имя на два смысла и было конфляцией: секция роадмапа не
категория, а категория беклога не состояние очереди.
"""
return "Секция" if rtype == GOAL else "Категория"
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()))
# --- Спринт ---
def sprint_goal(lay: Layout) -> tuple[str, str]:
"""Слаг и заголовок цели текущего спринта; ('', '') — спринта нет."""
for line in read_lines(lay.index("sprint")):
if (m := GOAL_LINE.match(line.strip())):
return Path(m.group(2)).stem, m.group(1)
return "", ""
def sprint_slug(lay: Layout) -> str:
"""Слаг самого спринта — им метится урожай (`sprint:<слаг>`)."""
for line in read_lines(lay.index("sprint")):
if (m := SPRINT_SLUG_LINE.search(line)):
return m.group(1)
return ""
def sprint_header(lay: Layout, goal_slug: str, goal_title: str,
date: str, slug: str) -> list[str]:
"""Шапка спринта — мета-блок той же формы, что у задачи: поле на строку.
Прежняя форма (все три поля одной строкой через `·`) читается по-прежнему
— обе регулярки её берут, — но чинить её нечем и не нужно: `SPRINT.md`
переписывается целиком на `sprint start` и очищается на `sprint close`,
так что старая шапка живёт не дольше идущего спринта.
"""
link = f"{lay.cfg['items']}/{goal_slug}.md"
return ["# Спринт", "",
f"- **Цель:** [{goal_title}]({link})",
f"- **Начат:** {date}",
f"- **Спринт:** `{slug}`", "",
f"Урожай спринта поднимается `tasks.py list --tag {SPRINT_TAG}{slug}`"
" — это первая порция переоценки на сессии.", ""]
def empty_sprint(lay: Layout) -> str:
return ("# Спринт\n\n"
"Спринта нет. Цель называет человек, набор собирает агент:\n"
"`tasks.py sprint start --goal <слаг>`.\n\n"
f"## {lay.cfg['sprint_section']}\n")
# --- check ---
def check(lay: Layout, fix: bool = False) -> int:
problems = config_problems(lay)
if problems:
print(f"задачи: {lay.root} — конфиг не сходится с диском")
for p in problems:
print(f"КОНФИГ {p}")
print("\nсперва конфиг: пока он мимо, всё остальное диагностируется ложно"
f" (правь {config_home(lay.root) or lay.root / CONFIG_NAME}"
f" или переименуй файлы)")
return EXIT_ENV
if fix:
fixed, ambiguous = apply_fixes(lay)
for line in fixed:
print(f"ПОЧИНЕНО {line}")
for line in ambiguous:
print(f"НЕОДНОЗНАЧНО {line}")
if fixed or ambiguous:
print()
idx = {k: parse_entries(read_lines(lay.index(k))) for k in lay.indexes}
entries = {k: v[0] for k, v in idx.items()}
sections = {k: v[1] for k, v in idx.items()}
tasks = tasks_of(lay)
errors: list[str] = []
notes: list[str] = []
label = {k: lay.name(k) for k in lay.indexes}
known = {k: {s.lower() for s in sections[k]} for k in lay.indexes}
goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL}
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:
notes.append(f"{label['backlog']}: секции «{s}» быть не должно —"
f" блокер это состояние, а не полка: он живёт до ответа"
f" человека, а его следы — вопросами в файлах задач")
for name, task in tasks.items():
where = [k for k in lay.indexes if name in entries[k]]
home = home_index(task)
if not SLUG.fullmatch(name):
errors.append(f"{name}: слаг не kebab-case латиницей")
if not task["title"]:
errors.append(f"{name}: нет заголовка H1")
# 0. Тип — единственная ось, и от него зависит всё остальное: схема
# тела, дом строки, имя поля меты, право на взятие в спринт.
# Пропуск — замечание: записи, заведённые до появления типа,
# законны, и переоформлять беклог «заодно» здесь не просят.
# Обязательным тип становится там, где по нему принимают решение.
if not task["type"]:
notes.append(f"{name}: тип не назван — `tasks.py edit {name[:-3]}"
f" --type {'|'.join(TYPES)}`; в спринт без него не возьмут")
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. Задача живёт ровно в одном индексе за раз.
allowed = {home} | ({"sprint"} if home == "backlog" else set())
if not where:
errors.append(f"{name}: нет ни в одном индексе (ожидался {label[home]})")
elif len(where) > 1:
errors.append(f"{name}: сразу в нескольких индексах"
f" ({', '.join(label[k] for k in where)}) — задача живёт в одном")
elif where[0] not in allowed:
errors.append(f"{name}: лежит в {label[where[0]]}, а по типу"
f" «{task['type']}» её место в {label[home]}"
f" — перенесёт `check --fix`")
place = where[0] if len(where) == 1 else None
entry = entries[place][name] if place else None
# 2. Секция файла — истина, секция индекса производна. В спринте секция
# файла хранит, куда задачу вернуть, и с «Набором» не сверяется.
if task["legacy_meta"]:
errors.append(f"{name}: мета одной строкой — старая форма;"
f" `check --fix` перепишет её списком")
if task["stray_meta"]:
errors.append(f"{name}: поле меты в теле"
f" ({', '.join(task['stray_meta'])}) — мета разорвана"
f" пустой строкой, и всё, что ниже разрыва, потеряно."
f" Убери пустую строку внутри блока; `--fix` этого не"
f" делает: какое из двух значений верное, знает человек")
want_key = place_key_of(task["type"])
if task["type"] in TYPES and task["place_key"] \
and task["place_key"] != want_key.lower():
errors.append(f"{name}: поле меты названо «{task['place_key']}», а у типа"
f" «{task['type']}» оно «{want_key}»: у цели это часть"
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"] \
and entry["section"].lower() != task["section"]:
errors.append(f"{name}: секция в файле «{task['section']}»,"
f" а в {label[place]} — «{entry['section']}»")
if entry and entry["title"] != task["title"]:
errors.append(f"{name}: заголовок разошёлся\n"
f" файл: {task['title']}\n"
f" индекс: {entry['title']}")
# 2а. «Зачем»: истина в файле, строка индекса производна. Пока поле
# жило только в индексе, восстановление строки его теряло.
if entry and task["why"] and entry["why"] != task["why"]:
errors.append(f"{name}: «зачем» разошлось (истина в файле)\n"
f" файл: {task['why']}\n"
f" индекс: {entry['why']}")
elif place is not None and entry and not task["why"] and entry["why"]:
errors.append(f"{name}: «зачем» есть в {label[place]}, а в файле нет —"
f" `check --fix` перенесёт его в мета-блок")
elif not task["why"]:
notes.append(f"{name}: без «зачем» — по нему выбирают задачу"
f" (`tasks.py edit {name[:-3]} --why …`)")
# 3. Цель. Связь однонаправленна: задача несёт goal:<слаг>, перечень
# задач цели выводится `list --goal`, а не хранится.
if task["type"] != GOAL:
if not task["goal"]:
if task["type"] in NEEDS_GOAL:
errors.append(f"{name}: тип «{task['type']}» без тега"
f" {GOAL_TAG}<слаг> — новая возможность и есть"
f" содержание цели. Либо цель заводится, либо"
f" это не {task['type']}")
# fix, chore и research живут без цели законно: они служат
# работоспособности, а не направлению.
elif task["goal"] not in goal_slugs:
errors.append(f"{name}: тег {GOAL_TAG}{task['goal']} указывает на цель,"
f" которой нет в {lay.cfg['items']}/")
# 3а. Схема тела вне спринта — только замечанием: обязательным раздел
# становится там, где по нему принимают решение (`sprint take`).
# Лишний раздел говорит о неверном типе, и сказать об этом стоит
# сразу, не дожидаясь набора.
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"]:
notes.append(f"{name}: раздел «{lay.cfg['questions_heading']}» непуст,"
f" а тега «{QUESTION_TAG}» нет — отбор `list --questions`"
f" её не увидит (`edit {name[:-3]} --add-tag {QUESTION_TAG}`)")
if QUESTION_TAG in task["tags"] and not has_q_section:
notes.append(f"{name}: тег «{QUESTION_TAG}» есть, а раздела"
f" «{lay.cfg['questions_heading']}» нет — снять тег?")
# 6. Цель: «не разобрана» и «всё закрыто» обязаны различаться.
if task["type"] == GOAL:
mine = sum(1 for t in tasks.values() if t["goal"] == name[:-3])
if not mine and DECOMPOSED_TAG not in task["tags"]:
notes.append(f"{name}: цель без задач и без тега «{DECOMPOSED_TAG}» —"
f" «ещё не разобрана» и «всё закрыто» неразличимы;"
f" тег ставится, когда цель разложена на задачи")
elif not mine:
notes.append(f"{name}: цель разобрана, а открытых задач не осталось —"
f" закрывай (`tasks.py close {name[:-3]} --implemented`)")
if BODY_PLACEHOLDER in task["text"]:
notes.append(f"{name}: тело не дописано (остался плейсхолдер add)")
for kind in lay.indexes:
for name, entry in entries[kind].items():
if name not in tasks:
errors.append(f"{label[kind]}:{entry['line']}: ссылка на несуществующий"
f" {lay.cfg['items']}/{name}")
lines = read_lines(lay.index(kind))
errors += index_lint(lines, label[kind])
if kind == "roadmap":
errors += roadmap_lint(lines, label[kind])
if kind == "backlog" and raw_last(lines, raw_names) != lines:
errors.append(f"{label[kind]}: сырьё (`{RESEARCH}` без раздела"
f" «{lay.cfg['question_heading']}») стоит не в конце своей"
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']}/")
if not goal_of_sprint and entries["sprint"]:
errors.append(f"{label['sprint']}: набор есть, а цель не названа")
if goal_of_sprint and not sprint_slug(lay):
errors.append(f"{label['sprint']}: у спринта нет слага (- **Спринт:** `…`) —"
f" урожай не отобрать; перезапусти `sprint start`")
rejected = lay.index("rejected")
if rejected.is_file():
for num, line in enumerate(read_lines(rejected), 1):
if line.startswith("- ") and not REJECTED_ENTRY.match(line):
errors.append(f"{lay.name('rejected')}:{num}:"
f" строка не по формату «- ГГГГ-ММ-ДД `slug` — …»")
# Причина в мете желательна, но не обязательна. Ругаемся только на
# частичное покрытие: у части задач причина есть, у части нет — это дрейф.
# Ноль из N — осознанный отказ проекта от причин, не расхождение; горящее
# на каждом check замечание агент просто научится игнорировать.
with_reason = sum(1 for t in tasks.values() if t["reason"])
if 0 < with_reason < len(tasks):
notes.append(f"причина есть у {with_reason} из {len(tasks)} —"
f" либо у всех, либо ни у кого: вперемешку это дрейф,"
f" а на причине держится всё, что переживает запись")
print(f"задачи: {lay.root}, файлов {len(tasks)}, строк индексов "
+ ", ".join(f"{label[k]} {len(entries[k])}" for k in lay.indexes))
health(lay, tasks, entries, sections)
for e in errors:
print(f"ОШИБКА {e}")
for n in notes:
print(f"замечание {n}")
if errors:
print(f"\nрасхождений: {len(errors)}")
return EXIT_DRIFT
print("\nиндексы согласованы" + (f", замечаний: {len(notes)}" if notes else ""))
return EXIT_OK
def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
"""Метрики здоровья: размер секций, спринт, открытые вопросы, залежалость,
цели без задач. Механизирует то, что иначе держится на дисциплине."""
backlog = [t for n, t in tasks.items() if n in entries["backlog"]]
by_section = {s.lower(): 0 for s in sections["backlog"]}
for t in backlog:
if t["section"] in by_section:
by_section[t["section"]] += 1
if by_section:
print(" беклог: " + ", ".join(f"{s} {by_section[s.lower()]}" for s in sections["backlog"]))
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 ""))
# Готовность к взятию — та же проверка, что откажет `sprint take`. Число, а
# не перечень: оно отвечает на «есть ли из чего собрать спринт», и когда
# ответ «нет», разбирать надо не список, а порцию переоценки.
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]
and not (t["type"] in NEEDS_GOAL and not t["goal"])]
print(f" готово к взятию: {len(ready)} из {len(backlog)}"
+ ("" if len(ready) == len(backlog)
else " — прочим не хватает разделов своего типа, цели или ждут"
" ответа на вопрос"))
goal_slug, _ = sprint_goal(lay)
if goal_slug:
print(f" спринт: цель «{goal_slug}», слаг «{sprint_slug(lay) or '—'}»,"
f" задач {len(entries['sprint'])}")
else:
print(" спринт: не начат")
questions = [n for n, t in tasks.items() if questions_open(lay, t)]
if questions:
print(f" с открытым вопросом: {len(questions)}"
f" — в спринт не берутся, разбор первым шагом сессии")
# Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но
# проверка эвристическая, а беклог, заведённый до правила, переоформляют не
# «заодно»: десятки одинаковых замечаний научили бы пропускать весь блок.
flat = sorted(n[:-3] for n, t in tasks.items()
if t["type"] in 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" «Печатать поле одним куском», а не «Поле печатается одним куском»")
goals = {n[:-3]: t for n, t in tasks.items() if t["type"] == GOAL}
if goals:
counts = {g: sum(1 for t in tasks.values() if t["goal"] == g) for g in goals}
empty = [g for g, c in counts.items() if c == 0]
undecomposed = [g for g in empty if DECOMPOSED_TAG not in goals[g]["tags"]]
print(f" цели: {len(goals)}, из них без открытых задач {len(empty)}"
+ (f" ({', '.join(sorted(empty))})" if empty else "")
+ (f"; не разобрано {len(undecomposed)}" if undecomposed else ""))
dates = touched_map(lay)
if not dates:
return
cutoff = (datetime.date.today() - datetime.timedelta(days=STALE_DAYS)).isoformat()
stale = sum(1 for t in tasks.values() if (d := dates.get(t["path"].name)) and d < cutoff)
if stale:
print(f" залежалось (>{STALE_DAYS} дней без правки): {stale}"
f" — переоценка просрочена, начни с `list --stale`")
# --- list ---
def list_tasks(lay: Layout, a: argparse.Namespace) -> int:
if (err := bad_tags(a.tag)):
raise Usage(err)
tasks = tasks_of(lay)
entries = {k: parse_entries(read_lines(lay.index(k)))[0] for k in lay.indexes}
order = {}
for k in lay.indexes:
for i, s in enumerate(parse_entries(read_lines(lay.index(k)))[1]):
order.setdefault(s.lower(), i)
def place(name: str) -> str:
for k in lay.indexes:
if name in entries[k]:
return k
return "—"
wanted = split_tags(a.tag) # список: отбираются задачи со ВСЕМИ тегами
known_tags = {t for task in tasks.values() for t in task["tags"]}
rows = []
for name, t in tasks.items():
t["place"] = place(name)
if a.index and a.index != "all" and t["place"] != a.index:
continue
if a.section and t["section"] != a.section.lower():
continue
if a.type and t["type"] != a.type.lower():
continue
if wanted and not set(wanted) <= set(t["tags"]):
continue
if a.goal and t["goal"] != a.goal.lower():
continue
if a.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}"
goal = f" →{t['goal']}" if t["goal"] and not a.goal else ""
flag = " ?" if questions_open(lay, t) else ("~" if raw_research(lay, t) else " ")
print(f"{touched}{t['place']:<8}{t['section']:<12}{rtype}{flag:<2}"
f"{t['path'].stem:<44} {t['bare']}{goal}")
print(f"\nвсего: {len(rows)}")
# Пустой ответ обязан объясняться: молчаливый ноль читается как «таких
# задач нет», хотя чаще это опечатка в теге или отбор по И вместо ИЛИ.
if wanted:
missing = [t for t in wanted if t not in known_tags]
if missing:
print(f" тегов нет ни у одной задачи: {', '.join(missing)}"
f" (есть: {', '.join(sorted(known_tags)) or '—'})")
elif len(wanted) > 1:
print(f" отбор по нескольким тегам — это И, а не ИЛИ:"
f" нужны все {len(wanted)} сразу")
return EXIT_OK
# --- Мутации: правят файл и индексы заодно, рассогласовать их вручную нельзя ---
def build_meta(rtype: str, section: str, reason: str, why: str, tags: list[str]) -> str:
"""Мета-блок: тип первой строкой, дальше место, «зачем» и теги.
Тип стоит первым не для красоты: он решает, что у записи вообще может быть
— какие разделы обязательны, нужна ли цель, берётся ли она в спринт, — и
читается раньше всего остального.
"""
out = [f"- **Тип:** {rtype}"] if rtype else []
out.append(f"- **{place_key_of(rtype)}:** {section}" + (f" — {reason}" if reason else ""))
if why:
out.append(f"- **Зачем:** {why}")
if tags:
out.append("- **Теги:** " + ", ".join(tags))
return "\n".join(out)
def section_headers(lines: list[str]) -> list[tuple[int, str]]:
return [(i, m.group(1)) for i, line in enumerate(lines) if (m := SECTION.match(line))]
def canon_section(lines: list[str], which: int) -> str | None:
"""Имя канонической секции роадмапа **как она названа в этом индексе**.
Проект пишет либо русские имена, либо английские; скрипт принимает оба и
возвращает то, что нашёл, — иначе `insert_entry` промахнётся мимо секции,
которая есть, но названа на другом языке.
"""
names = {n.lower() for n in ROADMAP_SECTIONS[which]}
for _, section in section_headers(lines):
if section.lower() in names:
return section
return None
def roadmap_ordered(lines: list[str]) -> list[str]:
"""Секции роадмапа, переставленные в канонический порядок вместе с их
содержимым. Преамбула остаётся на месте.
Чужая секция останавливает перестановку целиком: её место в порядке
неизвестно, а угадывать значило бы переложить чьи-то строки наугад. О ней
скажет `roadmap_lint`, и человек решит сам."""
heads = [(i, m.group(1)) for i, line in enumerate(lines) if (m := SECTION.match(line))]
if not heads:
return lines
order = {n.lower(): i for i, pair in enumerate(ROADMAP_SECTIONS) for n in pair}
if any(name.lower() not in order for _, name in heads):
return lines
blocks = []
for k, (i, name) in enumerate(heads):
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
blocks.append((order[name.lower()], lines[i:end]))
if [rank for rank, _ in blocks] == sorted(rank for rank, _ in blocks):
return lines
out = lines[:heads[0][0]]
for _, block in sorted(blocks, key=lambda b: b[0]):
out = out + block
return out
def roadmap_lint(lines: list[str], label: str) -> list[str]:
"""Секции роадмапа: все канонические, все на месте, все на одном языке и в
каноническом порядке."""
known = {n.lower(): i for i, pair in enumerate(ROADMAP_SECTIONS) for n in pair}
errors: list[str] = []
seen: dict[int, str] = {}
langs: set[int] = set()
for _, section in section_headers(lines):
i = known.get(section.lower())
if i is None:
ru = ", ".join(pair[0] for pair in ROADMAP_SECTIONS)
errors.append(f"{label}: секция «{section}» не из канона роадмапа"
f" ({ru}). Секции роадмапа несут смысл и потому"
f" закреплены; прозаический заголовок здесь — секция,"
f" в которую может уехать цель")
continue
langs.add(0 if section.lower() == ROADMAP_SECTIONS[i][0].lower() else 1)
if section not in ROADMAP_SECTIONS[i]:
errors.append(f"{label}: секция «{section}» написана не как в каноне"
f" ({' | '.join(ROADMAP_SECTIONS[i])}) — заголовок"
f" пишется с прописной; починит `check --fix`")
if i in seen:
errors.append(f"{label}: секция «{section}» повторяет «{seen[i]}» —"
f" это одна и та же секция на двух языках")
else:
seen[i] = section
missing = [ROADMAP_SECTIONS[i][0] for i in range(len(ROADMAP_SECTIONS))
if i not in seen]
if missing:
errors.append(f"{label}: нет секций: {', '.join(missing)}."
f" Роадмап отвечает на «что умеет и чего не умеет»"
f" целиком — отсутствующая секция это отсутствующий ответ")
if len(langs) > 1:
errors.append(f"{label}: секции вперемешку на двух языках — выбери один")
if not missing and roadmap_ordered(lines) != lines:
canon = ", ".join(pair[0] for pair in ROADMAP_SECTIONS)
errors.append(f"{label}: секции не в каноническом порядке ({canon}) —"
f" достигнутое копится и потому стоит последним, иначе оно"
f" отодвигает за экран то, ради чего роадмап открывают;"
f" переставит `check --fix`")
return errors
def find_section(lines: list[str], name: str) -> tuple[int | None, str]:
for i, s in section_headers(lines):
if s.lower() == (name or "").lower():
return i, s
return None, ""
def find_entry_index(lines: list[str], slug: str) -> int | None:
for i, line in enumerate(lines):
m = INDEX_ENTRY.match(line)
if m and Path(m.group(2)).name == f"{slug}.md":
return i
return None
def locate_all(lay: Layout, slug: str) -> dict[str, tuple[list[str], int]]:
"""Все индексы, где есть строка задачи. Правится **каждый**: иначе задача,
случайно попавшая в два индекса, чинится наполовину и вторая строка
остаётся со старым заголовком и старым «зачем»."""
found: dict[str, tuple[list[str], int]] = {}
for kind in lay.indexes:
lines = read_lines(lay.index(kind))
ei = find_entry_index(lines, slug)
if ei is not None:
found[kind] = (lines, ei)
return found
def insert_entry(lines: list[str], section: str, entry: str,
after: str | None = None, first: bool = False) -> None:
"""Вставляет строку в секцию: по умолчанию в конец, --after <слаг> — следом
за указанной строкой, --first — первой. Порядок нужен только упорядоченной
части роадмапа; в беклоге он значения не имеет."""
hi, _ = find_section(lines, section)
if hi is None:
raise Usage(f"секции «{section}» в индексе нет")
end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines))
if first:
# Пустые строки после заголовка пропускаются, но только если за ними
# что-то есть: у пустой секции пропускать нечего, и строка, вставленная
# в её конец, съела бы отбивку перед следующим заголовком.
ins = next((j for j in range(hi + 1, end) if lines[j].strip()), hi + 1)
elif after:
ai = find_entry_index(lines[hi:end], after)
if ai is None:
raise KeyError(after)
ins = hi + ai + 1
else:
ins = end
while ins - 1 > hi and not lines[ins - 1].strip():
ins -= 1
lines.insert(ins, entry)
def meta_rebuilt(lines: list[str], section: str | None = None, reason: str | None = None,
why: str | None = None, tags: list[str] | None = None,
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_of(new_type)}:**"
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 = {
"completion_heading": "по чему видно, что цель достигнута; задачи здесь НЕ"
" перечисляются — перечень даёт"
" `tasks.py list --goal <слаг>`",
"surface_heading": "границы, которых изменение касается: эндпоинт или"
" команда, таблица и миграция, формат на диске, публичный"
" тип пакета, внешний сервис. Названы границы, а не то, как"
" они изменятся: план реализации живёт в предложении",
"criteria_heading": "проверяемые утверждения списком, у каждого назван оракул",
"repro_heading": "что сделать, чтобы расхождение проявилось, и что при этом"
" видно вместо ожидаемого. Не воспроизводится — это research,"
" а не fix",
"question_heading": "вопрос, на который отвечает эта разведка, — одной фразой."
" Пока его нет, это сырьё: в спринт не берут",
"answer_heading": "куда ляжет ответ: docs/research/<тема>.md, ADR, тело этой"
" задачи. Приёмка разведки — записанный ответ, а не"
" изменённый код",
"scope_heading": "одна строка: чего касаться нельзя, что перезапускается, что"
" считается необратимым",
}
BODY_LEAD = {
"goal": "зачем эта цель: какое состояние продукта она создаёт",
"feature": "задача в одной фразе: что станет наблюдаемо иначе",
"fix": "что расходится с заявленным — в одной фразе",
"chore": "что обслуживаем и что перестанет мешать; адресат здесь"
" разработчик, и это законно",
RESEARCH: "о чём разведка: что непонятно и почему это мешает решать",
}
def body_template(rtype: str, lay: Layout) -> str:
"""Шаблон тела по схеме типа: обязательные разделы плюс «Рамки».
Шаблон и проверка растут из одного TYPE_SCHEMA: разойтись им нельзя, иначе
`add` кладёт то, на чём `sprint take` потом откажет.
"""
schema = TYPE_SCHEMA.get(rtype, TYPE_SCHEMA["feature"])
out = [f"<!-- {BODY_LEAD.get(rtype, BODY_LEAD['feature'])} -->"]
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<!-- {hint} -->")
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),
bad_slug(a.goal) if a.goal else None):
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} уже существует — дедуп: допиши в него, а не заводи новый")
target = "roadmap" if rtype == GOAL else "backlog"
lines = read_lines(lay.index(target))
if not lines:
raise Usage(f"нет индекса {lay.name(target)} — прогони tasks.py init")
if find_entry_index(lines, a.slug) is not None:
raise Usage(f"строка в {lay.name(target)} для {a.slug} уже есть")
section = a.section or (section_headers(lines)[0][1] if section_headers(lines) else "")
hi, section = find_section(lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {lay.name(target)} (есть: {avail})")
tags = split_tags(a.tag)
if a.goal:
tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"]
if not (lay.items / f"{a.goal}.md").exists():
print(f" внимание: цели {a.goal}.md нет — заведи её (--type goal) или поправь тег")
if 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']}» в теле")
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 ""))
if target == "backlog":
# Сырьё держится в конце секции сразу, а не до ближайшего `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, target, lines)
plan.commit()
print(f"создано: {lay.cfg['items']}/{a.slug}.md,"
f" строка в {lay.name(target)} (секция «{section}»); допиши тело редактором")
if sslug and f"{SPRINT_TAG}{sslug}" in tags:
print(f" помечено тегом {SPRINT_TAG}{sslug} — урожай идущего спринта")
if not a.why:
print(f" без «зачем» — задай: tasks.py edit {a.slug} --why …")
warn_rejected(lay, a.slug, a.title)
return EXIT_OK
def warn_rejected(lay: Layout, slug: str, title: str) -> None:
"""Дедупликация против ушедшего без реализации: та же задача возвращается
через квартал тем же текстом, и кладбище — единственный её след."""
path = lay.index("rejected")
if not path.is_file():
return
words = {w for w in re.findall(r"[^\W\d_]{5,}", title.lower())}
for line in read_lines(path):
if not REJECTED_ENTRY.match(line):
continue
low = line.lower()
if f"`{slug}`" in low or (words and len(words & set(re.findall(r"[^\W\d_]{5,}", low))) >= 2):
print(f" похоже на ушедшее без реализации — покажи пользователю, что изменилось:\n"
f" {line.strip()}")
def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_why(a.why),
bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_type(a.type),
bad_slug(a.goal) if a.goal else None):
if err:
raise Usage(err)
if all(v is None for v in (a.title, a.why, a.type, a.goal,
a.add_tag, a.rm_tag, a.section)):
raise Usage("нечего менять: дай --title, --why, --type, --goal,"
" --add-tag или --rm-tag")
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
places = locate_all(lay, a.slug)
if not places:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
task = parse_task(path)
if a.title is not None and not a.title.strip():
raise Usage("пустой заголовок")
bare = a.title if a.title is not None else task["bare"]
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)
flines = path.read_text(encoding="utf-8").splitlines()
if not flines or not flines[0].startswith("#"):
raise Usage(f"{a.slug}.md без заголовка H1 — прогони check")
tags = list(task["tags"])
if a.rm_tag is not None:
drop = set(split_tags(a.rm_tag))
tags = [t for t in tags if t not in drop]
if a.add_tag is not None:
for t in split_tags(a.add_tag):
if t not in tags:
tags.append(t)
if a.goal is not None:
tags = [t for t in tags if not t.startswith(GOAL_TAG)] + [f"{GOAL_TAG}{a.goal}"]
if not (lay.items / f"{a.goal}.md").exists():
print(f" внимание: цели {a.goal}.md нет — заведи её или поправь тег")
# Род работы стал типом: тег снимается вместе с проставлением типа, чтобы
# второй дом не пережил правку и не разошёлся с первым.
if a.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
section = task["section"]
if a.section is not None:
if old_home == new_home:
raise Usage("--section у edit — только вместе со сменой типа, меняющей"
f" индекс. Секция внутри индекса — это move (он пишет причину):"
f" tasks.py move {a.slug} --section {a.section} --reason …")
section = a.section.strip().lower()
# Смена типа между целью и задачей — это переезд между индексами, а не
# отказ: задача лежит ровно в одном индексе, неоднозначности нет.
if old_home != new_home:
target_lines = read_lines(lay.index(new_home))
hi, section_name = find_section(target_lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(target_lines))
raise Usage(f"смена типа переносит строку в {lay.name(new_home)},"
f" а секции «{section}» там нет (есть: {avail}) —"
f" задай `--section <из перечисленных>`")
section = section_name.lower()
# Тип передаётся всегда, а не только при `--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_of(rtype)}:** в мете —"
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")
def ordered(kind_index: str, lines: list[str]) -> list[str]:
return raw_last(lines, raw_now) if kind_index == "backlog" else lines
plan = Plan()
plan.file(path, new_text)
if old_home != new_home:
old_lines, ei = places[old_home]
old_lines.pop(ei) # строка в новом индексе собирается заново
plan.index(lay, old_home, ordered(old_home, old_lines))
target_lines = read_lines(lay.index(new_home))
insert_entry(target_lines, section, entry_line(lay, h1, a.slug, why))
plan.index(lay, new_home, ordered(new_home, target_lines))
else:
for kind_index, (lines, ei) in places.items():
lines[ei] = entry_line(lay, h1, a.slug, why)
plan.index(lay, kind_index, ordered(kind_index, lines))
plan.commit()
changed = [n for n, v in (("заголовок", a.title), ("зачем", a.why), ("тип", a.type),
("цель", a.goal), ("теги", a.add_tag or a.rm_tag))
if v is not None]
print(f"{a.slug}: обновлено ({', '.join(changed)})")
if len(places) > 1:
print(f" задача была сразу в нескольких индексах"
f" ({', '.join(lay.name(k) for k in places)}) — поправлены все строки,"
f" но само это состояние ошибочно: прогони check")
if old_home != new_home:
print(f" строка переехала: {lay.name(old_home)}{lay.name(new_home)}"
f" (секция «{section}»)")
if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"] and in_sprint:
print(" задача в спринте, а вопрос открыт: либо остаток есть и вопрос ждёт сессии,"
f" либо задача выходит — `tasks.py sprint drop {a.slug} --reason …`")
return EXIT_OK
def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_reason(a.reason), bad_slug(a.after) if a.after else None):
if err:
raise Usage(err)
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
places = locate_all(lay, a.slug)
if not places:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
if "sprint" in places:
raise Usage(f"{a.slug} в спринте: секция — свойство беклога."
f" Сперва верни задачу: tasks.py sprint drop {a.slug} --reason …")
if len(places) > 1:
raise Usage(f"{a.slug} сразу в нескольких индексах"
f" ({', '.join(lay.name(k) for k in places)}) — неоднозначно,"
f" разбери сам: tasks.py check")
kind_index, (lines, ei) = next(iter(places.items()))
hi, section = find_section(lines, a.section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})")
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_of(task['type'])}:** в мете —"
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 kind_index == "backlog" and 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, kind_index, lines)
plan.commit()
print(f"{a.slug}: перенесено в «{section}» ({lay.name(kind_index)})")
return EXIT_OK
def section_of_achieved(lines: list[str]) -> str:
"""Секция достигнутого этого роадмапа. Нет её — отказ по существу: цель
закрыть некуда, и молча удалить её значит стереть половину ответа."""
name = canon_section(lines, ACHIEVED)
if name is None:
ru, en = ROADMAP_SECTIONS[ACHIEVED]
raise Usage(f"в роадмапе нет секции достигнутого («{ru}» или «{en}») —"
f" закрывать цель некуда. Заведи секцию и повтори")
return name
def is_achieved(task: dict, a: argparse.Namespace) -> bool:
"""Цель, закрытая как достигнутая: `close --implemented` без причины."""
return task["type"] == GOAL and not a.reason
def achieved_entry(task: dict, slug: str, date: str) -> str:
"""Строка секции достигнутого. Ссылки на файл в ней нет намеренно: файл
удаляется, а битую ссылку `check` справедливо назовёт ошибкой. Поведение
живёт в спеках проекта — здесь остаётся «что и когда стало возможно»."""
why = task["why"]
tail = f" {why[0].upper()}{why[1:]}" if why else ""
if tail and not tail.rstrip().endswith((".", "!", "?")):
tail = tail.rstrip() + "."
dot = "" if task["bare"].endswith((".", "!", "?")) else "."
return f"- {date} `{slug}` — {task['bare']}{dot}{tail}"
def cmd_close(lay: Layout, a: argparse.Namespace) -> int:
for err in (bad_slug(a.slug), bad_reason(a.reason)):
if err:
raise Usage(err)
path = lay.items / f"{a.slug}.md"
if not path.exists():
raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/")
places = locate_all(lay, a.slug)
if not places:
raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix")
task = parse_task(path)
if task["type"] == GOAL:
open_tasks = [n for n, t in tasks_of(lay).items() if t["goal"] == a.slug]
if open_tasks:
raise Usage(f"у цели {a.slug} осталось открытых задач: {len(open_tasks)}"
f" ({', '.join(sorted(t[:-3] for t in open_tasks))})."
f" Цель закрыта, когда не осталось её задач: каждую либо"
f" close --reason со своей причиной, либо edit --goal на"
f" другую цель")
plan = Plan()
today = datetime.date.today().isoformat()
if a.reason:
reason = a.reason.rstrip()
dot = "" if reason.endswith((".", "!", "?")) else "."
bullet = (f"- {today} `{a.slug}` — {task['title']}. Причина: {reason}{dot}"
f" Была секция: {task['section'] or '—'}.")
rej = lay.index("rejected")
prev = rej.read_text(encoding="utf-8") if rej.exists() else "# Ушедшее без реализации\n"
if not prev.endswith("\n"):
prev += "\n"
plan.file(rej, prev + bullet + "\n")
# Достигнутая цель — единственная запись, переживающая удаление файла без
# причины. Роадмап отвечает не только «что осталось», но и «что уже умеет»,
# а этот ответ иначе стирался вместе с целью, и его вели прозой руками.
achieved = achieved_entry(task, a.slug, today) if is_achieved(task, a) else None
for kind_index, (lines, ei) in places.items():
lines.pop(ei)
if achieved is not None and kind_index == "roadmap":
insert_entry(lines, section_of_achieved(lines), achieved, first=True)
achieved = None
plan.index(lay, kind_index, lines)
if achieved is not None: # строки в роадмапе не было — чиним
lines = read_lines(lay.index("roadmap"))
insert_entry(lines, section_of_achieved(lines), achieved, first=True)
plan.index(lay, "roadmap", lines)
plan.delete(path)
plan.commit()
if is_achieved(task, a):
# Имя берём из индекса: проект мог назвать секции по-английски.
where = (canon_section(read_lines(lay.index("roadmap")), ACHIEVED)
or ROADMAP_SECTIONS[ACHIEVED][0])
print(f"{a.slug}: цель достигнута — строка перенесена в"
f" {lay.name('roadmap')}, секция «{where}», файл удалён")
else:
print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}")
if "sprint" in places and a.reason:
print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта")
if not a.reason:
print(" дорога назад: файл восстанавливается из git —"
f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`")
return EXIT_OK
def git_deleted_text(path: Path) -> str | None:
"""Текст закрытой задачи из истории git.
Два источника, и второй обязателен. Коммит удаления — обычный случай:
закрытие уже уехало в историю. Но пайплайн закрывает задачу **последним
шагом**, и между удалением файла и коммитом учёта есть окно, в котором
коммита удаления ещё нет, а текст лежит в `HEAD`. Без второго источника
`reopen` отказывал бы ровно на свежезакрытой задаче — то есть в самом
вероятном своём применении.
"""
try:
sha = subprocess.run(["git", "log", "--diff-filter=D", "--format=%H", "-n", "1",
"--", str(path)], capture_output=True, text=True).stdout.strip()
for rev in ([f"{sha}^"] if sha else []) + ["HEAD"]:
out = subprocess.run(["git", "show", f"{rev}:{path}"],
capture_output=True, text=True)
if out.returncode == 0:
return out.stdout
return None
except FileNotFoundError:
return None
def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int:
"""Задача была закрыта, а приёмка не сошлась.
Закрытие удаляет файл, поэтому без возврата приёмщику, нашедшему
расхождение, возвращать нечего. Порядок правильный — `close --implemented`
после вердикта приёмки, — но ошибка порядка обязана иметь дорогу назад.
"""
for err in (bad_slug(a.slug), bad_reason(a.reason)):
if err:
raise Usage(err)
path = lay.items / f"{a.slug}.md"
if path.exists():
raise Usage(f"{a.slug}.md на месте — возвращать нечего;"
f" пропала строка индекса — это `check --fix`")
text = git_deleted_text(path)
if text is None:
raise Usage(f"в истории git нет удаления {path} — восстановить нечем."
f" Заведи заново: tasks.py add --slug {a.slug} --title …")
task_lines = text.splitlines()
title = task_lines[0].removeprefix("#").strip() if task_lines else a.slug
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)
goal_of_sprint, _ = sprint_goal(lay)
target = home_index({"type": rtype})
section = tmp["section"]
if target == "backlog" and goal_of_sprint and tmp["goal"] == goal_of_sprint:
target, section = "sprint", lay.cfg["sprint_section"]
lines = read_lines(lay.index(target))
# Строка достигнутого снимается ДО вставки и на том же списке: иначе вторая
# правка читает индекс с диска, где первой ещё нет, и затирает её.
unachieved: list[str] = []
if rtype == GOAL:
kept = []
for line in lines:
if REJECTED_ENTRY.match(line) and f"`{a.slug}`" in line:
unachieved.append(line)
else:
kept.append(line)
lines = kept
if find_entry_index(lines, a.slug) is None:
hi, sec = find_section(lines, section)
if hi is None:
avail = ", ".join(n for _, n in section_headers(lines))
raise Usage(f"секции «{section}» нет в {lay.name(target)} (есть: {avail})")
insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"]))
plan.index(lay, target, lines)
elif unachieved:
plan.index(lay, target, lines)
rej = lay.index("rejected")
if rej.is_file():
keep, removed = [], []
for line in read_lines(rej):
if REJECTED_ENTRY.match(line) and f"`{a.slug}`" in line:
removed.append(line)
else:
keep.append(line)
if removed:
plan.file(rej, "\n".join(keep))
else:
removed = []
plan.commit()
print(f"{a.slug}: возвращён в {lay.name(target)} из истории git"
+ (f" (секция «{section}»)" if target != "sprint" else " (набор спринта)"))
for line in removed:
print(f" снята строка {lay.name('rejected')}: {line.strip()}")
for line in unachieved:
print(f" снята строка достигнутого в"
f" {lay.name('roadmap')}: {line.strip()}")
print(" роадмап больше не утверждает, что приложение это умеет")
print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее"
" живёт только в коммите задачи")
return EXIT_OK
def parse_task_text(text: str, path: Path) -> dict:
"""parse_task для текста, которого ещё нет на диске (возврат из git)."""
tmp = path.with_name(path.name + ".reopen-tmp")
tmp.write_text(text, encoding="utf-8")
try:
return parse_task(tmp)
finally:
tmp.unlink(missing_ok=True)
def meta_updated_text(text: str, reason: str, 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_sprint_start(lay: Layout, a: argparse.Namespace) -> int:
if (err := bad_slug(a.goal)):
raise Usage(err)
entries, _ = parse_entries(read_lines(lay.index("sprint")))
if entries:
raise Usage(f{lay.name('sprint')} ещё есть набор ({len(entries)}) —"
f" закрой спринт: tasks.py sprint close")
gpath = lay.items / f"{a.goal}.md"
if not gpath.exists():
raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/")
goal = parse_task(gpath)
if goal["type"] != GOAL:
raise Usage(f"{a.goal} не цель (тип «{goal['type'] or '—'}») —"
f" спринт набирается под одну цель")
date = a.date or datetime.date.today().isoformat()
if not DATE_RE.fullmatch(date):
raise Usage("дата в формате ГГГГ-ММ-ДД")
# Слаг спринта — дата: естественный, монотонный и уже уникальный. Он нужен
# не для красоты: без него тег `sprint:<слаг>` некому проставить, а на нём
# держится правило «первая порция переоценки — урожай прошедшего спринта».
slug = (a.slug or date).lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9.-]*", slug):
raise Usage(f"слаг спринта «{slug}» — латиница, цифры, дефис и точка")
tasks = tasks_of(lay)
if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()):
print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта"
f" — урожаи склеятся; задай другой `--slug`")
lines = [*sprint_header(lay, a.goal, goal["title"], date, slug),
f"## {lay.cfg['sprint_section']}", ""]
plan = Plan()
plan.index(lay, "sprint", lines)
plan.commit()
ready = [n[:-3] for n, t in tasks.items()
if t["goal"] == a.goal and t["type"] in TAKEABLE
and not questions_open(lay, t) and not schema_verdict(lay, t)[0]]
print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»")
print(f" кандидатов под цель без открытых вопросов: {len(ready)}"
+ (f" ({', '.join(sorted(ready))})" if ready else ""))
print(f" заводимое по ходу метится тегом {SPRINT_TAG}{slug} само — это урожай")
print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта")
return EXIT_OK
def cmd_sprint_take(lay: Layout, a: argparse.Namespace) -> int:
goal_slug, _ = sprint_goal(lay)
if not goal_slug:
raise Usage("спринт не начат: tasks.py sprint start --goal <слаг>")
sprint_lines = read_lines(lay.index("sprint"))
backlog_lines = read_lines(lay.index("backlog"))
hi, section = find_section(sprint_lines, lay.cfg["sprint_section"])
if hi is None:
raise Usage(f{lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»")
taken, warn = [], []
for slug in a.slugs:
if (err := bad_slug(slug)):
raise Usage(err)
path = lay.items / f"{slug}.md"
if not path.exists():
raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/")
t = parse_task(path)
if not t["type"]:
raise Usage(f"{slug}: тип не назван —"
f" `edit {slug} --type {'|'.join(TAKEABLE)}`."
f" Тип решает, каких разделов задача обязана иметь,"
f" и без него проверять нечего")
if t["type"] not in TAKEABLE:
raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —"
f" цель не берут вовсе, берут её задачи")
if t["goal"] and t["goal"] != goal_slug:
raise Usage(f"{slug}: цель «{t['goal']}» не цель спринта «{goal_slug}» —"
f" набор служит одной цели, даже если взять удобно")
if not t["goal"] and t["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)
plan = Plan()
plan.index(lay, "backlog", backlog_lines)
plan.index(lay, "sprint", sprint_lines)
plan.commit()
total = len(parse_entries(sprint_lines)[0])
print(f"взято в спринт: {', '.join(taken)}; в наборе {total}")
for w in warn:
print(f" замечание: {w}")
return EXIT_OK
def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int:
if (err := bad_reason(a.reason)):
raise Usage(err)
sprint_lines = read_lines(lay.index("sprint"))
backlog_lines = read_lines(lay.index("backlog"))
plan = Plan()
dropped = []
# Сперва все проверки и весь план правок, потом запись. Иначе отказ на
# втором слаге оставлял первый файл переписанным при нетронутых индексах:
# задача числилась в спринте и одновременно объясняла, почему из него вышла.
for slug in a.slugs:
if (err := bad_slug(slug)):
raise Usage(err)
ei = find_entry_index(sprint_lines, slug)
if ei is None:
raise Usage(f"{slug}: в наборе спринта такой строки нет")
path = lay.items / f"{slug}.md"
if not path.exists():
raise Usage(f"{slug}: строка в {lay.name('sprint')} есть, а файла"
f" {lay.cfg['items']}/{slug}.md нет — прогони check")
t = parse_task(path)
hi, section = find_section(backlog_lines, t["section"])
if hi is None:
avail = ", ".join(n for _, n in section_headers(backlog_lines))
raise Usage(f"{slug}: секция «{t['section'] or '—'}» не найдена"
f" в {lay.name('backlog')} (есть: {avail})")
new_text = meta_updated(path, section=section, reason=a.reason,
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"спринт закрыт (цель «{goal_slug or '—'}», слаг «{slug or '—'}»)"
+ (", набор распущен" if a.dissolve and entries else ""))
print(f" история наборов остаётся в git: `git log -p {lay.index('sprint')}`")
if slug:
print(f" урожай спринта — `tasks.py list --tag {SPRINT_TAG}{slug}`:"
f" завести найденное по ходу обязан закрывающий спринт, а не пайплайн")
print(f" автотег снят вместе со спринтом: заводимое СЕЙЧАС метится только"
f" вручную — `add … --tag {SPRINT_TAG}{slug}`, иначе выпадет из урожая")
print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор")
return EXIT_OK
# --- check --fix ---
def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
"""Детерминированная починка дрейфа. Чинит только то, где истина
однозначно в файле или где источник ровно один:
- дубли строк на один файл, рассинхрон заголовка, задача не в своей секции;
- отсутствующая строка индекса — восстанавливается **вместе с «зачем»** из
меты файла;
- «зачем», оставшееся только в индексе, — переносится в файл (миграция со
старого формата: другого экземпляра нет, неоднозначности тоже);
- строка в чужом индексе (цель в беклоге, задача в роадмапе) — переносится в
домашний. Задача лежит ровно в одном индексе и не в том, выбирать не из
чего: истина в типе, а тип в файле;
- цель, у которой есть задачи, помечается `decomposed`.
Неоднозначное (ссылка на исчезнувший файл, задача сразу в двух индексах,
битые строки) не трогает — это на суд человека, и о нём говорится вслух.
"""
fixed: list[str] = []
ambiguous: list[str] = []
tasks = tasks_of(lay)
idx = {k: read_lines(lay.index(k)) for k in lay.indexes}
files: dict[Path, str] = {}
dirty: set[str] = set()
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" (**Категория:**/**Секция:**) — переписать нечего,"
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 not in TYPES:
ambiguous.append(f"{name}: тип «{rtype}» вне словаря"
f" ({', '.join(TYPES)}) — чем он заменяется,"
f" решает человек")
continue
want_key = place_key_of(rtype).lower()
tags = [t for t in task["tags"] if not t.startswith(LEGACY_KIND_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 renamed:
what.append(f"поле места → «{place_key_of(rtype)}»")
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. Нет строки вовсе / строка в чужом индексе / не в своей секции. Задачу
# из спринта не трогаем: «в спринте» — решение набора, а не свойство файла.
for name, task in tasks.items():
where = [k for k in lay.indexes if find_entry_index(idx[k], name[:-3]) is not None]
if len(where) > 1:
ambiguous.append(f"{name}: сразу в {', '.join(lay.name(k) for k in where)}"
f" — какая строка лишняя, решает человек")
continue
home = home_index(task)
if not where:
if not task["section"]:
ambiguous.append(f"{name}: строки нет ни в одном индексе, и в файле"
f" нет секции — восстанавливать не по чему")
continue
hi, section = find_section(idx[home], task["section"])
if hi is None:
ambiguous.append(f"{name}: строки нет, а секции «{task['section']}»"
f" нет в {lay.name(home)} — восстанавливать некуда")
continue
insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3],
task["why"]))
fixed.append(f"{lay.name(home)}: восстановлена строка {name}"
+ ("" if task["why"] else " (в файле нет «зачем» — допиши)"))
dirty.add(home)
continue
kind = where[0]
if kind == "sprint":
continue
if kind != home:
# Задача ровно в одном индексе и не в своём: истина в типе, а тип
# в файле — неоднозначности нет, переносим и говорим об этом.
hi, section = find_section(idx[home], task["section"])
if hi is None:
ambiguous.append(f"{name}: лежит в {lay.name(kind)}, дом — {lay.name(home)},"
f" но секции «{task['section']}» там нет"
f" (есть: {', '.join(n for _, n in section_headers(idx[home]))})")
continue
ei = find_entry_index(idx[kind], name[:-3])
if ei is None: # сюда попали по where — строка обязана быть
raise RuntimeError(f"{name}: строка в {lay.name(kind)} пропала посреди"
f" прохода — чинить нечего, отчёт был бы враньём")
insert_entry(idx[home], section, idx[kind].pop(ei))
fixed.append(f"{name}: строка перенесена {lay.name(kind)}{lay.name(home)}"
f" (секция «{section}») — по типу «{task['type']}» её место там")
dirty.add(kind)
dirty.add(home)
continue
if not task["section"]:
continue
hi, section = find_section(idx[kind], task["section"])
if hi is None:
continue
ei = find_entry_index(idx[kind], name[:-3])
if ei is None:
raise RuntimeError(f"{name}: строка в {lay.name(kind)} пропала посреди"
f" прохода — чинить нечего, отчёт был бы враньём")
cur = next((m.group(1) for j in range(ei, -1, -1)
if (m := SECTION.match(idx[kind][j]))), None)
if cur and cur.lower() != section.lower():
insert_entry(idx[kind], section, idx[kind].pop(ei))
fixed.append(f"{lay.name(kind)}: перенесена в секцию «{section}»: {name}")
dirty.add(kind)
# 4. Цель, у которой есть задачи, разобрана по факту — пометка производна.
for name, task in tasks.items():
if task["type"] != GOAL or DECOMPOSED_TAG in task["tags"]:
continue
if any(t["goal"] == name[:-3] for t in tasks.values()):
if not stage(task, tags=[*task["tags"], DECOMPOSED_TAG]):
continue
fixed.append(f"{name}: проставлен тег «{DECOMPOSED_TAG}» — у цели есть задачи")
# 5. Форма индексов: канонический регистр секций роадмапа и отбивка после
# заголовков. Регистр правится только у **канонических** секций: имена
# секций беклога — дело проекта, и подгонять их под свой вкус скрипт
# права не имеет.
canon = {n.lower(): pair for pair in ROADMAP_SECTIONS for n in pair}
for kind, lines in idx.items():
if kind == "roadmap":
for j, line in enumerate(lines):
if not (m := SECTION.match(line)):
continue
pair = canon.get(m.group(1).lower())
if pair is None or m.group(1) in pair:
continue
want = pair[0] if m.group(1).lower() == pair[0].lower() else pair[1]
lines[j] = f"## {want}"
fixed.append(f"{lay.name(kind)}: секция «{m.group(1)}» → «{want}»")
dirty.add(kind)
if (moved := roadmap_ordered(lines)) != lines:
lines[:] = moved
fixed.append(f"{lay.name(kind)}: секции переставлены в канонический"
f" порядок ({', '.join(p[0] for p in ROADMAP_SECTIONS)})")
dirty.add(kind)
if kind == "backlog":
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)
# 6. Написание секции в мете. Имя секции принадлежит **заголовку индекса** —
# файл на секцию только ссылается, а принадлежность сверяется по нижнему
# регистру. Поэтому расхождение в одном регистре однозначно: побеждает
# заголовок. Без этого шага переезд на канон оставил бы «Готово» в
# роадмапе и «готово» в каждом файле цели.
for name, task in tasks.items():
kind = home_index(task)
if not task["section_raw"] or kind not in idx:
continue
_, heading = find_section(idx[kind], task["section"])
if not heading or heading == task["section_raw"]:
continue
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 ---
def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
cfg: dict) -> dict[Path, str]:
out: dict[Path, str] = {}
if cfg:
# Дом настроек один — `docs/.pm.json`, ключ `tasks`. Писать в
# `.tasks.json` при живом `.pm.json` значит писать туда, откуда никто
# не читает: load_config его в этом случае игнорирует.
pm = (lay.root / PM_CONFIG_REL).resolve()
if pm.is_file():
data = _read_json(pm)
section = data.get("tasks") or {}
if not isinstance(section, dict):
raise Env(f"{pm}: ключ «tasks» — ожидался объект с настройками")
data["tasks"] = {**section, **cfg}
out[pm] = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
else:
out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False,
indent=2) + "\n"
out[lay.index("backlog")] = (
"# Беклог\n\n"
f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/<slug>.md`\n"
"+ строка здесь. Целей тут нет — они в "
f"[{lay.name('roadmap')}]({lay.name('roadmap')}): беклог — то, что берут,\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"
+ "".join(f"## {s}\n\n" for s in sections))
out[lay.index("roadmap")] = (
"# Роадмап\n\n"
"Состояние проекта: что приложение **уже умеет** и чего ещё не умеет.\n"
f"Цель — возможность приложения: файл типа `{GOAL}` ({TYPE_EMOJI[GOAL]}) в\n"
f"`{lay.cfg['items']}/`. Её задачи здесь **не перечисляются** — перечень даёт\n"
"`tasks.py list --goal <слаг>`.\n\n"
f"- **{ROADMAP_SECTIONS[PLANNED][0]}** — очередь значима и обосновывается прозой;\n"
f"- **{ROADMAP_SECTIONS[DIRECTIONS][0]}** — очереди нет, тянутся долго;\n"
f"- **{ROADMAP_SECTIONS[OPERATIONS][0]}** — чем держат проект: инструмент,\n"
" процесс, эксплуатация. Не возможности приложения, и отдельно —\n"
" чтобы не читаться как обещание продукта;\n"
f"- **{ROADMAP_SECTIONS[ACHIEVED][0]}** — достигнутое: строку пишет\n"
" `tasks.py close <цель> --implemented`, ссылки на файл в ней нет —\n"
" файл удаляется, поведение живёт в спеках. Стоит последней: копится.\n\n"
"Секции **канонические** и переименованию проектом не подлежат:\n"
"у каждой свой смысл, и в достигнутое пишет сам `close`. Порядок\n"
"тоже канонический. Английский\n"
f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)},"
" один язык на весь\nиндекс.\n\n"
+ "".join(f"## {s}\n\n" for s in roadmap_sections))
out[lay.index("sprint")] = empty_sprint(lay)
out[lay.index("rejected")] = (
"# Ушедшее без реализации\n\n"
"Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n"
"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n"
"есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n"
"<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->\n")
return out
def uniq_sections(raw: str) -> list[str]:
out, seen = [], set()
for s in (s.strip() for s in raw.split(",")):
if s and s.lower() not in seen:
out.append(s)
seen.add(s.lower())
return out
def cmd_init(root: Path, a: argparse.Namespace) -> int:
if not dir_within_cwd(root):
raise Usage(f"--dir вне рабочего каталога: {root}")
cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("roadmap", a.roadmap),
("sprint", a.sprint), ("rejected", a.rejected)) if v}
lay = Layout(root, cfg)
if lay.index("backlog").exists():
raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён")
sections = uniq_sections(a.sections)
roadmap_sections = uniq_sections(DEFAULT_ROADMAP_SECTIONS)
if not sections:
raise Usage("пустой список секций")
blockers = [s for s in sections if s.lower() in BLOCKER_SECTIONS]
if blockers:
raise Usage(f"секции «{', '.join(blockers)}» в беклоге не заводим: блокер — это"
f" состояние, а не полка. Он живёт до ответа человека, а следы"
f" остаются вопросами в файлах задач; постоянно пустая секция"
f" со старой семантикой «разбираются пачками» противоречит"
f" правилу «спрашиваем немедленно»")
lay.items.mkdir(parents=True, exist_ok=True)
plan = Plan()
for path, text in init_files(lay, sections, roadmap_sections, cfg).items():
plan.file(path, text)
plan.commit()
print(f"каталог задач заведён: {root}")
print(f" секции беклога: {', '.join(sections)};"
f" секции роадмапа канонические: {', '.join(roadmap_sections)}")
if cfg:
pm = (root / PM_CONFIG_REL).resolve()
print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}")
return EXIT_OK
# --- adopt: прийти в чужой репозиторий и вывести каталог задач из того, что есть ---
INDEX_CANDIDATES = ("README.md", "BACKLOG.md", "index.md", "INDEX.md")
GRAVEYARD_CANDIDATES = ("CLOSED.md", "REJECTED.md", "DONE.md")
OLD_META = re.compile(r"^-?\s*\*\*(Приоритет|Секция|Priority|Section):\*\*\s*(.*)$")
QUESTION_HEADINGS = ("что решить", "варианты и цена", "открытый вопрос", "вопрос",
"что заблокировано", "рекомендация")
def translit_ish(slug: str) -> bool:
"""Явные признаки транслита — и только они.
Это подсказка, а не приговор: отличить английское слово от транслита машина
не умеет, поэтому scan печатает и общее число слагов, требуя проверить все.
Сам перевод («taj-brejk» → «tie-break») делает агент.
"""
return bool(re.search(r"shch|zh|kh|sch|yu|ya|yj|ij|tsi|nyj|ost|enie", slug))
def scan_old_backlog(src: Path) -> dict:
"""Раскладка av-dev-backlog: индекс README.md, кладбище CLOSED.md, файлы
рядом с индексом, приоритеты секциями."""
index_name = next((n for n in INDEX_CANDIDATES if (src / n).is_file()), None)
graveyard = next((n for n in GRAVEYARD_CANDIDATES if (src / n).is_file()), None)
found: dict = {"kind": "backlog-dir", "source": str(src), "index": index_name,
"graveyard": graveyard, "items": [], "rejected": [], "unclassified": []}
entries, sections = ({}, [])
if index_name:
entries, sections = parse_entries(read_lines(src / index_name))
found["sections"] = sections
seen: set[str] = set()
for path in sorted(src.glob("*.md")):
if path.name in INDEX_CANDIDATES or path.name in GRAVEYARD_CANDIDATES:
continue
seen.add(path.name)
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
title = lines[0].removeprefix("#").strip() if lines and lines[0].startswith("#") else ""
kind, 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", ""), "goal": "",
"in_index": path.name in entries,
"translit": translit_ish(path.stem),
"questions_heading": qh,
"source": str(path),
})
if index_name:
for name, entry in entries.items():
if name not in seen:
found["unclassified"].append({
"what": f"строка индекса «{entry['title']}» → {name}",
"where": f"{src / index_name}:{entry['line']}",
"why": "файла нет — переносить нечего, текст только в строке"})
if graveyard:
for line in read_lines(src / graveyard):
if line.startswith("- "):
found["rejected"].append(line)
return found
STEP = re.compile(r"^\**\s*(\d+)[.)]\s*\**\s*(.+?)\**\s*$")
def scan_list_file(path: Path) -> dict:
"""TODO.md, «планы» в README, список шагов в плане проекта.
**Нумерованный шаг плана → кандидат в цель из «порядка»** (готовая цель: у
него уже есть очередь и обоснование), прочий пункт → кандидат в задачу. Ничего
не решает: и то и другое едет в карту предложением, назначает человек.
"""
found: dict = {"kind": "list-file", "source": str(path), "items": [],
"goal_candidates": [], "unclassified": []}
heading = ""
# Пункт, перенесённый на следующие строки, — один пункт: иначе половина
# абзаца уезжает в заголовок задачи обрубком.
bullets: list[list] = [] # [строка, состояние, текст, заголовок]
open_bullet = False
for num, line in enumerate(read_lines(path), 1):
if (m := re.match(r"^#{1,6}\s+(.+?)\s*$", line)):
heading, open_bullet = m.group(1).strip(), False
continue
# Нумерованный пункт номер сохраняет: по нему он и опознаётся шагом.
m = re.match(r"^\s*[-*]\s*(?:\[([ xX~])\]\s*)?(.+?)\s*$", line) \
or re.match(r"^\s*()(\d+[.)]\s+.+?)\s*$", line)
if m:
bullets.append([num, (m.group(1) or " "), m.group(2).strip(), heading])
open_bullet = True
elif not line.strip():
open_bullet = False
elif open_bullet:
bullets[-1][2] += " " + line.strip()
for num, state, text, heading in bullets:
clean = re.sub(r"[`*]", "", text).strip()
if (s := STEP.match(text)):
found["goal_candidates"].append({
"title": re.sub(r"[`*]", "", s.group(2)).strip().rstrip("."),
"from": f"{path}:{num}", "step": int(s.group(1)),
"done": state.lower() == "x", "section": heading})
continue
if len(clean) < 4:
found["unclassified"].append({"what": clean[:60], "where": f"{path}:{num}",
"why": "пункт короче четырёх символов"})
continue
if len(clean) > 200:
found["unclassified"].append({"what": clean[:60] + "…", "where": f"{path}:{num}",
"why": "абзац прозой, а не пункт списка —"
" задача из него не выводится машинально"})
continue
found["items"].append({
"old_slug": "", "slug": "", "title": clean[:120],
"type": "", "old_section": heading, "section": "", "reason": "",
"why": "", "goal": "", "in_index": False, "translit": False,
"questions_heading": "", "source": f"{path}:{num}", "body": text,
"done": state.lower() == "x",
})
return found
def cmd_adopt_scan(a: argparse.Namespace) -> int:
sources = [Path(s) for s in a.sources]
for s in sources:
if not s.exists():
raise Usage(f"источник не найден: {s}")
scans = []
for s in sources:
scans.append(scan_old_backlog(s) if s.is_dir() else scan_list_file(s))
items, rejected, unclassified, goals = [], [], [], []
for sc in scans:
items += sc["items"]
rejected += sc.get("rejected", [])
unclassified += sc.get("unclassified", [])
goals += [{"slug": "", "title": g["title"],
"section": ROADMAP_SECTIONS[PLANNED][0],
"from": g["from"], "step": g.get("step"), "done": g.get("done"),
"body": f"Выведена из шага «{g['title']}» ({g['from']})."
+ ("\n\nШаг помечен закрытым — цель, скорее всего,"
" заводить не надо." if g.get("done") else "")}
for g in sc.get("goal_candidates", [])]
dup: dict[str, int] = {}
for it in items:
dup[it["slug"] or it["title"]] = dup.get(it["slug"] or it["title"], 0) + 1
for key, n in dup.items():
if n > 1:
unclassified.append({"what": key, "where": "несколько источников",
"why": f"слаг встретился {n} раза — оставь один"})
# Куда переехали сами файлы: индекс, кладбище и каталог записей. Без этого
# ссылки вида `docs/backlog/README.md` из чужих документов останутся битыми.
path_map: list[list[str]] = []
for sc in scans:
if sc["kind"] != "backlog-dir":
continue
src = sc["source"].rstrip("/")
if sc.get("index"):
path_map.append([f"{src}/{sc['index']}", f"{a.target}/{DEFAULTS['backlog']}"])
if sc.get("graveyard"):
path_map.append([f"{src}/{sc['graveyard']}", f"{a.target}/{DEFAULTS['rejected']}"])
path_map.append([f"{src}/", f"{a.target}/{DEFAULTS['items']}/"])
plan = {
"version": 1,
"target": a.target,
"sources": [str(s) for s in sources],
"path_map": path_map,
"sections_backlog": uniq_sections(a.sections),
"sections_roadmap": uniq_sections(DEFAULT_ROADMAP_SECTIONS),
"section_map": {},
"goals": goals,
"items": items,
"rejected": rejected,
"unclassified": unclassified,
}
print(f"адаптация: найдено записей {len(items)},"
f" кандидатов в цели {len(goals)},"
f" строк кладбища {len(rejected)},"
f" не разложилось {len(unclassified)}")
for sc in scans:
if sc["kind"] == "backlog-dir":
print(f" {sc['source']}: раскладка беклога, индекс"
f" {sc['index'] or '—'}, кладбище {sc['graveyard'] or '—'},"
f" секции: {', '.join(sc['sections']) or '—'}")
else:
print(f" {sc['source']}: список — пунктов {len(sc['items'])},"
f" кандидатов в цели {len(sc['goal_candidates'])}")
by_section: dict[str, int] = {}
for it in items:
by_section[it["old_section"] or "—"] = by_section.get(it["old_section"] or "—", 0) + 1
print(" по исходным секциям: "
+ ", ".join(f"{k} {v}" for k, v in sorted(by_section.items())))
have_slug = [it for it in items if it["old_slug"]]
translit = [it["old_slug"] for it in have_slug if it["translit"]]
if have_slug:
print(f" слагов на входе {len(have_slug)}, из них с явными признаками"
f" транслита {len(translit)} — но проверить надо **все**:"
f" английское слово от транслита машина не отличает,"
f" перевод и переименование делает агент")
if translit:
print(f" явные: {', '.join(translit[:6])}{' …' if len(translit) > 6 else ''}")
no_index = [it["old_slug"] for it in items if it["old_slug"] and not it["in_index"]]
if no_index:
print(f" файлы вне индекса: {', '.join(no_index)}")
done = [it["title"] for it in items if it.get("done")]
if done:
print(f" помечено сделанным на входе ({len(done)}) — сделанное не переносим,"
f" убери из карты: {', '.join(t[:40] for t in done[:5])}")
withq = [it["old_slug"] for it in items if it["questions_heading"]]
if withq:
print(f" похоже на открытый вопрос в прозе ({len(withq)}):"
f" {', '.join(withq[:8])}{' …' if len(withq) > 8 else ''}")
if unclassified:
print(" НЕ РАЗЛОЖИЛОСЬ (поимённо):")
for u in unclassified:
print(f" - {u['what']} [{u['where']}]: {u['why']}")
out = Path(a.out)
out.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"\nкарта записана: {out}")
print(" дальше: заполни в карте `slug` (английский), `section`, `goal` у каждой"
" записи и список `goals`, покажи карту человеку и только потом —"
f" `tasks.py adopt apply --plan {out}`")
return EXIT_OK
def requalify_links(text: str, old_dir: Path, new_dir: Path) -> str:
"""Относительные ссылки тела после переезда файла глубже.
`docs/backlog/x.md` знал соседей как `../passport.md`; из
`docs/tasks/items/x.md` тот же файл — уже `../../passport.md`. Молча
съехавшая на уровень ссылка — самый дешёвый способ развалить документацию.
"""
delta = len(new_dir.parts) - len(old_dir.parts)
if delta <= 0:
return text
return re.sub(r"\]\((\.\./)", "](" + "../" * (delta + 1), text)
def rewrite_refs(paths: list[Path], renames: dict[str, str],
path_map: list[tuple[str, str]], dry: bool) -> tuple[int, dict[str, int]]:
"""Перекрёстные ссылки на переименованные слаги — одним проходом.
Переименование, разнесённое по времени, оставляет битые ссылки, которых
никто не проверяет: `[текст](старый.md)`, `` `старый` `` и голое упоминание
в прозе. Считаем и говорим, сколько нашли и где.
"""
per_slug: dict[str, int] = {}
touched = 0
pats = [(re.compile(r"(?<![\w-])" + re.escape(old) + r"(?![\w-])"), old, new)
for old, new in renames.items() if old != new]
for path in paths:
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
original = text
for pat, old, new in pats:
text, n = pat.subn(new, text)
if n:
per_slug[old] = per_slug.get(old, 0) + n
for old_prefix, new_prefix in path_map:
text = text.replace(old_prefix, new_prefix)
if text != original:
touched += 1
if not dry:
write_atomic(path, text)
return touched, per_slug
def cmd_adopt_apply(a: argparse.Namespace) -> int:
plan_path = Path(a.plan)
if not plan_path.is_file():
raise Usage(f"карты нет: {plan_path}")
try:
pl = json.loads(plan_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise Usage(f"{plan_path}: не разбирается как JSON — {e}") from e
root = Path(pl["target"])
if not dir_within_cwd(root):
raise Usage(f"target вне рабочего каталога: {root}")
lay = Layout(root, {})
sections = pl.get("sections_backlog") or uniq_sections(DEFAULT_SECTIONS)
roadmap_sections = pl.get("sections_roadmap") or uniq_sections(DEFAULT_ROADMAP_SECTIONS)
known_sections = {s.lower() for s in sections}
known_roadmap = {s.lower() for s in roadmap_sections}
# --- проверки: все до первой записи ---
problems: list[str] = []
if lay.index("backlog").exists():
problems.append(f"{lay.index('backlog')} уже есть — адаптация не поверх живого"
f" каталога; выбери пустой target")
slugs: set[str] = set()
for g in pl.get("goals", []):
if not g.get("slug"):
problems.append(f"цель «{g.get('title', '?')}» без слага — заполни карту")
continue
if (e := bad_slug(g["slug"])):
problems.append(f"цель: {e}")
if g["slug"] in slugs:
problems.append(f"слаг «{g['slug']}» встречается дважды")
slugs.add(g["slug"])
if g.get("section", "").lower() not in known_roadmap:
problems.append(f"цель {g['slug']}: секция «{g.get('section', '')}»"
f" не из роадмапа ({', '.join(roadmap_sections)})")
goal_slugs = {g["slug"] for g in pl.get("goals", []) if g.get("slug")}
for it in pl.get("items", []):
slug = it.get("slug") or it.get("old_slug")
if not slug:
problems.append(f"запись «{it.get('title', '?')}» без слага")
continue
if (e := bad_slug(slug)):
problems.append(e)
if slug in slugs:
problems.append(f"слаг «{slug}» встречается дважды")
slugs.add(slug)
if it.get("section", "").lower() not in known_sections:
problems.append(f"{slug}: категория «{it.get('section', '')}» не из беклога"
f" ({', '.join(sections)})")
if (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" от типа зависит, каких разделов запись требует")
if it.get("goal") and it["goal"] not in goal_slugs:
problems.append(f"{slug}: цель «{it['goal']}» не заведена в карте")
for e in (bad_why(it.get("why")), bad_reason(it.get("reason"))):
if e:
problems.append(f"{slug}: {e}")
if problems:
for p in problems:
print(f"ОШИБКА {p}")
raise Usage(f"карта не готова: {len(problems)} проблем — правь {plan_path}")
# --- план записи ---
wr = Plan()
for path, text in init_files(lay, sections, roadmap_sections, {}).items():
wr.file(path, text)
backlog_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("backlog")].splitlines()
roadmap_lines = init_files(lay, sections, roadmap_sections, {})[lay.index("roadmap")].splitlines()
renames: dict[str, str] = {}
for g in pl.get("goals", []):
title = h1_of(GOAL, g["title"])
meta = build_meta(GOAL, g["section"].lower(), g.get("reason", ""),
g.get("why", ""), g.get("tags", []))
body = g.get("body", "").strip()
wr.file(lay.items / f"{g['slug']}.md",
f"# {title}\n\n{meta}\n\n{body}\n\n"
f"## {lay.cfg['completion_heading']}\n\n"
f"<!-- {SECTION_HINT['completion_heading']} -->\n")
insert_entry(roadmap_lines, g["section"].lower(),
entry_line(lay, title, g["slug"], g.get("why", "")))
for it in pl.get("items", []):
slug = it.get("slug") or it["old_slug"]
if it.get("old_slug") and it["old_slug"] != slug:
renames[it["old_slug"]] = slug
rtype = (it.get("type") or "").lower()
title = h1_of(rtype, it["title"])
tags = list(it.get("tags", []))
if it.get("goal"):
tags.append(f"{GOAL_TAG}{it['goal']}")
body = it.get("body", "")
if not body and it.get("source") and Path(str(it["source"]).split(":")[0]).is_file():
src = Path(str(it["source"]).split(":")[0])
src_lines = src.read_text(encoding="utf-8").splitlines()
start = 1
for i, line in enumerate(src_lines[1:8], 1):
if OLD_META.match(line.strip()):
start = i + 1
break
body = "\n".join(src_lines[start:]).strip()
body = requalify_links(body, src.parent, lay.items)
qh = it.get("questions_heading", "")
if qh:
body = re.sub(rf"^##\s+{re.escape(qh)}\s*$", f"## {lay.cfg['questions_heading']}",
body, count=1, flags=re.M | re.I)
if QUESTION_TAG not in tags:
tags.append(QUESTION_TAG)
meta = build_meta(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, roadmap_sections, {})[lay.index("rejected")]
body = []
for line in pl["rejected"]:
line = re.sub(r"Был приоритет:", "Была секция:", line)
for old, new in renames.items():
line = re.sub(r"(?<![\w-])" + re.escape(old) + r"(?![\w-])", new, line)
body.append(line)
wr.file(lay.index("rejected"), head + "\n".join(body) + "\n")
wr.file(lay.index("backlog"), "\n".join(backlog_lines))
wr.file(lay.index("roadmap"), "\n".join(roadmap_lines))
if a.dry_run:
print(f"пробный прогон: записалось бы файлов {len(wr.writes)},"
f" переименований слагов {len(renames)}")
return EXIT_OK
lay.items.mkdir(parents=True, exist_ok=True)
wr.commit()
# --- перекрёстные ссылки: тем же проходом, иначе они останутся битыми ---
ref_paths: list[Path] = [*lay.items.glob("*.md"), lay.index("rejected")]
for r in (a.refs or []):
p = Path(r)
ref_paths += sorted(p.rglob("*.md")) if p.is_dir() else [p]
touched, per_slug = rewrite_refs(ref_paths, renames,
[tuple(pair) for pair in pl.get("path_map", [])], False)
print(f"каталог задач собран: {root}")
print(f" целей {len(pl.get('goals', []))}, задач {len(pl.get('items', []))},"
f" строк кладбища {len(pl.get('rejected', []))}")
print(f" переименовано слагов: {len(renames)};"
f" ссылок поправлено: {sum(per_slug.values())} в {touched} файлах")
for old, n in sorted(per_slug.items(), key=lambda kv: -kv[1])[:10]:
print(f" {old}{renames[old]}: {n}")
if pl.get("unclassified"):
print(" не разложилось (поимённо, переносить руками):")
for u in pl["unclassified"]:
print(f" - {u['what']} [{u['where']}]: {u['why']}")
# --- честно про переходное состояние: считаем по написанным файлам ---
written = tasks_of(lay)
# Цель обязательна только у новой возможности: fix, chore и research живут
# без неё законно, и check об этом молчит.
no_goal = [n for n, t in written.items() if t["type"] in NEEDS_GOAL and not t["goal"]]
unfit = [n for n, t in written.items()
if t["type"] in TAKEABLE and schema_verdict(lay, t)[0]]
print("\nпереходное состояние — назови его в докладе целиком:")
print(f" задач типа {'/'.join(NEEDS_GOAL)} без цели: {len(no_goal)} — это ОШИБКИ check"
f" (правится `tasks.py edit <слаг> --goal <цель>`)"
+ (f": {', '.join(sorted(x[:-3] for x in no_goal)[:5])}…" if no_goal else ""))
print(f" задач, не собравших разделы своего типа: {len(unfit)} —"
f" check это ошибкой не считает, но `sprint take` их не возьмёт:"
f" собрать спринт сегодня физически нечем")
print(f" закрывается порциями переоценки по 5–8 задач (скилл session, шаг 3):"
f" проставить цели, превратить «готово, когда» в критерии с оракулами,"
f" вынуть вопросы из прозы в раздел. Готовность к первому спринту —"
f" не «check зелёный», а «есть {CRITERIA_MIN}+ критериев хотя бы у набора"
f" под одну цель».")
print(" источники не удалены: сверь глазами и убери сам"
f" ({', '.join(pl.get('sources', []))}) — удалять чужое молча нельзя.")
print(" подписи ссылок машина не трогает: цель ссылки поправлена, а текст"
" вида «[старый путь](новый путь)» правит агент глазами.")
return EXIT_OK
def main() -> int:
ap = argparse.ArgumentParser(prog="tasks.py")
sub = ap.add_subparsers(dest="command", required=True)
p = sub.add_parser("check", help="согласованность файлов и индексов")
p.add_argument("--dir")
p.add_argument("--fix", action="store_true",
help="починить безопасный дрейф (секция, заголовок, дубли, «зачем», форма меты)")
p = sub.add_parser("list", help="список задач и целей")
p.add_argument("--dir")
p.add_argument("--stale", action="store_true", help="от самой залежавшейся")
p.add_argument("--section", help="категория беклога или часть роадмапа")
p.add_argument("--type", choices=TYPES)
p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):"
" goal:<слаг>, question, sprint:<слаг>")
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("--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("--goal", help="слаг цели → тег goal:<слаг>")
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("--goal", help="заменить тег goal:<слаг>")
p.add_argument("--add-tag", dest="add_tag")
p.add_argument("--rm-tag", dest="rm_tag")
p.add_argument("--section", help="только вместе со сменой типа, меняющей индекс")
p.add_argument("--dir")
p = sub.add_parser("move", help="перенести в другую категорию беклога или часть роадмапа")
p.add_argument("slug")
p.add_argument("--section", required=True)
p.add_argument("--reason")
g = p.add_mutually_exclusive_group()
g.add_argument("--after", help="встать следом за этим слагом (упорядоченная часть роадмапа)")
g.add_argument("--first", action="store_true")
p.add_argument("--dir")
p = sub.add_parser("close", help="закрыть задачу или цель")
p.add_argument("slug")
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--reason", help="ушла без реализации → строка в REJECTED")
g.add_argument("--implemented", action="store_true", help="реализована → просто удалить")
p.add_argument("--dir")
p = sub.add_parser("reopen", help="вернуть закрытую задачу (приёмка не сошлась)")
p.add_argument("slug")
p.add_argument("--reason", help="почему возвращена — уедет в мету")
p.add_argument("--dir")
p = sub.add_parser("sprint", help="операции спринта")
ssub = p.add_subparsers(dest="sprint_command", required=True)
s = ssub.add_parser("start", help="начать спринт под названную цель")
s.add_argument("--goal", required=True)
s.add_argument("--date")
s.add_argument("--slug", help="слаг спринта; по умолчанию дата начала")
s.add_argument("--dir")
s = ssub.add_parser("take", help="взять задачи в набор")
s.add_argument("slugs", nargs="+")
s.add_argument("--dir")
s = ssub.add_parser("drop", help="вернуть задачи в беклог")
s.add_argument("slugs", nargs="+")
s.add_argument("--reason", required=True)
s.add_argument("--dir")
s = ssub.add_parser("close", help="закрыть спринт")
s.add_argument("--dissolve", action="store_true", help="распустить набор (блокер)")
s.add_argument("--reason")
s.add_argument("--dir")
p = sub.add_parser("init", help="завести каталог задач в новом проекте")
p.add_argument("--dir")
p.add_argument("--sections", default=DEFAULT_SECTIONS)
p.add_argument("--items")
p.add_argument("--backlog")
p.add_argument("--roadmap")
p.add_argument("--sprint")
p.add_argument("--rejected")
p = sub.add_parser("adopt", help="вывести каталог задач из того, что уже есть в репозитории")
asub = p.add_subparsers(dest="adopt_command", required=True)
s = asub.add_parser("scan", help="только карта: что найдено и как разложилось")
s.add_argument("--from", dest="sources", nargs="+", required=True)
s.add_argument("--target", default="docs/tasks")
s.add_argument("--out", default="tasks-adopt-plan.json")
s.add_argument("--sections", default=DEFAULT_SECTIONS)
s = asub.add_parser("apply", help="записать каталог по подтверждённой карте")
s.add_argument("--plan", required=True)
s.add_argument("--refs", nargs="*", help="файлы и каталоги, где чинить ссылки на слаги")
s.add_argument("--dry-run", dest="dry_run", action="store_true")
a = ap.parse_args()
if a.command == "init":
return cmd_init(Path(a.dir or "docs/tasks"), a)
if a.command == "adopt":
return cmd_adopt_scan(a) if a.adopt_command == "scan" else cmd_adopt_apply(a)
lay = resolve_layout(a.dir)
if a.command == "sprint":
return {"start": cmd_sprint_start, "take": cmd_sprint_take,
"drop": cmd_sprint_drop, "close": cmd_sprint_close}[a.sprint_command](lay, a)
return {
"check": lambda: check(lay, a.fix),
"list": lambda: list_tasks(lay, a),
"add": lambda: cmd_add(lay, a),
"edit": lambda: cmd_edit(lay, a),
"move": lambda: cmd_move(lay, a),
"close": lambda: cmd_close(lay, a),
"reopen": lambda: cmd_reopen(lay, a),
}[a.command]()
if __name__ == "__main__":
try:
sys.exit(main())
except Usage as e:
print(f"ошибка: {e}", file=sys.stderr)
sys.exit(EXIT_USAGE)
except Env as e:
print(f"окружение: {e}", file=sys.stderr)
sys.exit(EXIT_ENV)
except KeyboardInterrupt:
sys.exit(EXIT_INTERNAL)
except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю
print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr)
sys.exit(EXIT_INTERNAL)