Сабагент в роли продакт-менеджера оценил покрытие жизненного цикла личного проекта скиллами и агентами 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>
3391 lines
194 KiB
Python
Executable File
3391 lines
194 KiB
Python
Executable File
#!/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)
|