tasks.py: спринт снят, гейт готовности переехал в команду ready

Спринт был вплетён в 215 мест: конфиг, разбор индексов, состояния
задачи, add/edit/move/close/reopen, блок здоровья и CLI. Снято всё:
SPRINT.md, четыре команды sprint, автотег урожая sprint:<слаг>,
проверки набора в check.

Гейт готовности стоял на sprint take — единственном месте, где запись
судили целиком. Момент нужен и без спринта, иначе задача уезжает в
работу без критериев приёмки. Теперь это команда ready <слаг>: тип,
цель у feature, пустой раздел вопросов, схема типа. Отказ там рабочая
ситуация, а не ошибка употребления, — код 1, не 2.

Приоритет стал порядком строк в беклоге, и два места это уже знают:
reopen и check --fix ставят восстановленную строку в конец секции и
говорят, что позицию назначает человек. Молчаливое восстановление
выдавало бы машинную позицию за его решение.

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