From 20dca29add52daed0564ee29fdc89bcdc8e9baeb Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 3 Aug 2026 11:45:23 +0300 Subject: [PATCH] =?UTF-8?q?av-dev-tasks:=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E,=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87=D1=83=D0=B6=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - атомарность: sprint drop и move собирают план правок целиком и пишут одним проходом; раньше отказ на втором слаге оставлял первый файл переписанным при нетронутом индексе - хук переехал в мета-строку файла: индекс стал производным, и check --fix больше не теряет текст, восстанавливая строку - механизировано то, что было записано, но не проверялось: слаг спринта и автотег, отказ по факту непустого раздела вопросов, число критериев, пометка decomposed, покрытие причин - reopen возвращает закрытую задачу: без него порядок «пайплайн доложил → приёмщик судит → владелец закрывает» был односторонним - скилл adopt: приходит в чужой репозиторий и выводит заполненный каталог задач. На копии беклога healthlog — 12 целей, 38 задач, 36 переименований, 86 ссылок в 36 файлах, check зелёный --- av-dev-tasks/.claude-plugin/plugin.json | 2 +- av-dev-tasks/skills/adopt/SKILL.md | 122 ++ av-dev-tasks/skills/session/SKILL.md | 42 +- .../skills/session/references/cadence.md | 9 +- .../skills/session/references/sprint.md | 38 +- av-dev-tasks/skills/tasks/SKILL.md | 147 +- .../skills/tasks/references/task-format.md | 76 +- av-dev-tasks/skills/tasks/scripts/tasks.py | 1661 ++++++++++++++--- 8 files changed, 1768 insertions(+), 329 deletions(-) create mode 100644 av-dev-tasks/skills/adopt/SKILL.md diff --git a/av-dev-tasks/.claude-plugin/plugin.json b/av-dev-tasks/.claude-plugin/plugin.json index fc41866..3b6115a 100644 --- a/av-dev-tasks/.claude-plugin/plugin.json +++ b/av-dev-tasks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "av-dev-tasks", - "description": "Управление задачами как каталогом markdown-файлов: цели вместо приоритетов, спринт под одну цель с заморозкой набора, вопросы и блокеры, каденция «разбор — переоценка — набор». Заведение задач из диалога и из находок ревью, декомпозиция, штурм идей. Не выполняет задачи — этим занимается пайплайн проекта.", + "description": "Управление задачами как каталогом markdown-файлов: цели вместо приоритетов, спринт под одну цель с заморозкой набора, вопросы и блокеры, каденция «разбор — переоценка — набор». Заведение задач из диалога и из находок ревью, декомпозиция, штурм идей, разовая адаптация чужого репозитория под этот формат. Не выполняет задачи — этим занимается пайплайн проекта.", "author": { "name": "Anton Vakhrushev", "email": "anwinged@gmail.com" diff --git a/av-dev-tasks/skills/adopt/SKILL.md b/av-dev-tasks/skills/adopt/SKILL.md new file mode 100644 index 0000000..92c6dae --- /dev/null +++ b/av-dev-tasks/skills/adopt/SKILL.md @@ -0,0 +1,122 @@ +--- +name: adopt +description: Прийти в чужой репозиторий и вывести каталог задач из того, что там уже есть — старая раскладка беклога (README-индекс, CLOSED-кладбище, транслитные слаги), TODO.md, россыпь заметок, раздел «планы» в README, список шагов в плане проекта. Сперва карта находок и целей человеку, запись только после подтверждения; слаги переименовываются в английские вместе с починкой перекрёстных ссылок. Использовать, когда просят перевести проект на этот формат задач, перенести беклог, адаптировать существующие заметки под цели и спринты. Разовая операция: дальше проект ведут скиллы tasks и session. +--- + +# Адаптация чужого репозитория + +Плагин приходит в проект, где задачи уже как-то ведутся, и **выводит** из +имеющегося материала заполненный каталог задач: цели, задачи, кладбище, индексы. +Операция разовая — после неё проект живёт скиллами `tasks` и `session`. + +Вход какой угодно: старая раскладка `av-dev-backlog` (индекс `README.md`, +кладбище `CLOSED.md`, приоритеты секциями, транслитные слаги, файлы рядом с +индексом), `TODO.md`, россыпь заметок, раздел «планы» в `README.md`, список +шагов в плане проекта. + +## Три правила, из которых всё следует + +1. **Сперва карта, потом файлы.** Человеку показывается, что найдено, как + разложилось по целям и **что не разложилось**, — и только после подтверждения + пишется хоть один файл. Это то же правило, что у интейка находок ревью: + массовое заведение записей без подтверждения — самый дорогой отказ, потому + что разгребает его потом переоценка. +2. **Ничего не терять.** Исходный текст переезжает в тело, хук и причина + сохраняются, кладбище переносится строка в строку. Переименование слага — + не правка, а **перенос ссылок**: он делается одним проходом вместе с + переименованием, иначе останутся битые ссылки, которых никто не проверяет. +3. **Что не классифицировалось — назвать поимённо.** Проглоченный пункт + выглядит как «всё перенеслось». Список «не разложилось» идёт в доклад + целиком, с причиной по каждому пункту. + +## Форма: карта — суждение — запись + +Механику несёт `tasks.py adopt`, суждение — ты. Разделено ровно по границе +«машина умеет / не умеет»: + +``` +tk="$CLAUDE_PLUGIN_ROOT/skills/tasks/scripts/tasks.py" + +python3 $tk adopt scan --from docs/backlog docs/plan.md TODO.md \ + --target docs/tasks --out tasks-adopt-plan.json # только чтение +python3 $tk adopt apply --plan tasks-adopt-plan.json \ + --refs docs openspec CLAUDE.md README.md # запись +``` + +`scan` ничего не пишет, кроме карты: он распознаёт раскладку, собирает записи, +хуки, причины, кладбище, помечает похожее на транслит и на открытый вопрос в +прозе, и **называет поимённо** то, что не разложилось. `apply` пишет каталог +целиком одним проходом и чинит перекрёстные ссылки. + +Между ними — твоя работа, которую машина не сделает: + +- **английские слаги.** Перевести `taj-brejk-pri-ravnoj-polnote` в + `tie-break-equal-completeness` может только тот, кто понимает смысл. `scan` + честно говорит: проверить надо **все** слаги, признаки транслита — эвристика; +- **цели.** Шаги плана — готовые цели **линии** (порядок и обоснование у них уже + есть); тематические скопления задач — **кусты** («прочность слияния», «журнал + и пересборка»). Предлагаешь ты, назначает человек; +- **что вообще не задача.** Обоснование порядка шагов, абзац прозой, заголовок + раздела — это не пункты беклога, и они уходят в «не разложилось» с причиной. + +## Порядок + +1. **Осмотрись.** Где лежат задачи, план, заметки; читается ли `CLAUDE.md` + проекта — там может быть указатель на каталог. Секции беклога проекта + (`--sections`) — по умолчанию `ядро,инфра`; если у проекта деление другое по + существу, оно называется здесь, а не подгоняется под умолчание. +2. **`adopt scan`** по всем источникам разом. Один прогон, одна карта: два + прохода дадут два несогласованных состояния. +3. **Заполни карту**: `slug` (английский), `section`, `goal` у каждой записи; + список `goals` — из шагов плана и из кустов. Закрытый шаг плана целью не + заводится. Пустой `goal` — законный исход только у идеи. +4. **Покажи человеку карту** через `AskUserQuestion`, ≤3 вопроса за итерацию, + рекомендация первым вариантом. Показывается: сколько записей, предлагаемые + цели (линия и кусты) с обоснованием, спорные отнесения, список «не + разложилось». Массовые механические решения (слаги, порядок строк) не + выносятся — это механика. +5. **`adopt apply`.** `--refs` перечисляет **всё**, где могут стоять ссылки на + слаги: документация, архив изменений, `CLAUDE.md`, `README.md`. Скрипт + посчитает и покажет, сколько ссылок поправлено и по каким слагам. +6. **`tasks.py check`** и доклад. + +`apply` отказывается писать поверх живого каталога и проверяет карту целиком +**до** первой записи: неверная секция, дубль слага, цель, которой нет в карте — +всё это отказ до того, как на диске появился хотя бы один файл. + +## Переходное состояние — объявляется, а не заминается + +Сразу после адаптации задачи в большинстве своём **не готовы к взятию**: у них +нет критериев приёмки, а у части может не быть цели. Это нормально, но обязано +быть названо, иначе следующий агент примет пустой беклог за поломку. + +`apply` печатает состояние по факту: сколько задач без цели (это **ошибки** +`check`) и сколько без критериев (`check` их ошибкой не считает, но `sprint +take` такую задачу не возьмёт). Закрывается это **порциями переоценки** — шаг 3 +скилла `session`, 5–8 задач за порцию: проставить цели, превратить «готово, +когда» в критерии с оракулами, вынуть вопросы из прозы в раздел «Вопросы». + +Готовность к первому спринту — не «`check` зелёный», а «есть 2–5 критериев хотя +бы у набора под одну цель». + +## Чего адаптация не делает + +- **Не удаляет источники.** Старый каталог остаётся на месте: сверить и убрать — + дело человека, удалять чужое молча нельзя. В доклад идёт готовая команда. +- **Не переписывает подписи ссылок.** `[docs/backlog](docs/tasks/BACKLOG.md)` — + цель поправлена, текст остался; это правится глазами, и таких мест немного. +- **Не сочиняет критерии приёмки и не придумывает цели**, которых в материале + нет. Придуманная цель хуже отсутствующей: под неё соберут спринт. +- **Не трогает историю.** В коммитах старые слаги остаются, и это нормально. + +## Доклад + +- Источники и что в каждом распознано (раскладка, индекс, кладбище, секции). +- Сколько записей перенесено, сколько целей заведено (линия / кусты) и откуда + каждая выведена. +- **Переименования**: сколько слагов, сколько ссылок поправлено и в скольких + файлах — числом, а не «поправлены ссылки». +- **Не разложилось**: поимённо, с причиной. +- Переходное состояние: сколько задач без цели, сколько без критериев, чем и за + сколько порций закрывается. +- `tasks.py check` — результат строкой. diff --git a/av-dev-tasks/skills/session/SKILL.md b/av-dev-tasks/skills/session/SKILL.md index 7e963b7..e4116bd 100644 --- a/av-dev-tasks/skills/session/SKILL.md +++ b/av-dev-tasks/skills/session/SKILL.md @@ -63,7 +63,11 @@ description: Ритуал между спринтами и ведение сам немедленно. Иначе спринт, из которого задачи вышли поштучно, выглядел бы штатно завершённым, а вопросы тихо ждали бы сессии. -**Отличать вопрос от застревания:** +**Отличать вопрос от застревания.** Правило про остаток принадлежит управлению +задачами: оно решает, **сделана задача или вышла**, а это исход планирования, не +исполнения. **Ниже канонический текст; пайплайн проекта на него ссылается, а не +пересказывает** — два экземпляра одного правила разъезжаются, и разъезжаются +незаметно, потому что расхождение видно только на редком входе. > Есть остаток, который доводится без ответа, — задача продолжается, вопрос > записывается в файл. Остатка нет — задача выходит из спринта. @@ -71,9 +75,11 @@ description: Ритуал между спринтами и ведение сам С двумя оговорками, без которых тест ошибается: > **Остаток, который материализует нерешённое** — записывает в хранилище, -> журнал, витрину или наружу состояние, зависящее от неотвеченного вопроса, — -> **не остаток**. Решение поднимается до начала записи: откатить запись дороже, -> чем подождать ответ, а иногда невозможно. +> журнал, витрину **или наружу** состояние, зависящее от неотвеченного вопроса, +> — **не остаток**. Решение поднимается до начала записи: откатить запись +> дороже, чем подождать ответ, а иногда невозможно. «Наружу» — часть правила, а +> не пример: выкладка, публикация и отправка данных третьей стороне не +> откатываются тем более. > **Пол для остатка:** остаток, из которого пропала польза, названная в хуке, — > это не сделанная задача, а вышедшая из спринта. @@ -125,15 +131,26 @@ description: Ритуал между спринтами и ведение сам прежде всего: ``` -python3 $tk check # с этого начинается любая сессия -python3 $tk list --questions # шаг 1: что накопилось -python3 $tk list --tag sprint:<слаг> # шаг 3: урожай прошедшего спринта, первая порция -python3 $tk list --stale # шаг 3: дальше по залежалости -python3 $tk list --goal <слаг> # шаг 4: кандидаты под названную цель -python3 $tk sprint start --goal <слаг> # шаг 4 -python3 $tk sprint take <слаг> … # шаг 4: набор +python3 $tk check --dir D # с этого начинается любая сессия +python3 $tk list --dir D --questions # шаг 1: что накопилось +python3 $tk list --dir D --tag sprint:<слаг> # шаг 3: урожай спринта, первая порция +python3 $tk list --dir D --stale # шаг 3: дальше по залежалости +python3 $tk list --dir D --goal <слаг> # шаг 4: кандидаты под названную цель +python3 $tk sprint start --dir D --goal <слаг> # шаг 4: заводит и слаг спринта +python3 $tk sprint take --dir D <слаг> … # шаг 4: набор +python3 $tk sprint close --dir D # конец спринта; --dissolve при блокере +python3 $tk reopen <слаг> --dir D --reason … # приёмка не сошлась после закрытия ``` +`D` — каталог задач проекта; цепочка его разрешения и вызов из чужого контекста +описаны в скилле `tasks` («Переносимость»). **Коды выхода** — там же: 1 это +дрейф в беклоге, 3 это «каталога нет», и ветвиться на них надо по-разному. + +**Слаг спринта заводит `sprint start`** (по умолчанию — дата) и пишет его в +`SPRINT.md`; всё заведённое при открытом спринте помечается `sprint:<слаг>` +автоматически. Поэтому «первая порция — урожай прошедшего спринта» работает без +чьей-либо памяти. + Правки задач делаются мутациями (`edit`, `move`, `close`), а не редактором: руками правится только тело файла. Это правило скилла `tasks`, здесь оно не пересказывается. @@ -181,6 +198,9 @@ python3 $tk sprint take <слаг> … # шаг 4: набор решает проект. 6. **Ориентир по размеру спринта**, если он замерялся. Умолчание — 5–8 задач, и это **ориентир, а не закон**. +7. **Команда учёта задач** — готовая строка вызова `tasks.py` (слот скилла + `tasks`). Ею владелец спринта закрывает задачи и заводит урожай; чужой + контекст сам путь к плагину не знает и знать не должен. Числа проекта (сколько задач в спринте, сколько времени на задачу, каков прирост беклога) — предмет шага 2, а не константы этого скилла. diff --git a/av-dev-tasks/skills/session/references/cadence.md b/av-dev-tasks/skills/session/references/cadence.md index 3794e5e..e6f3313 100644 --- a/av-dev-tasks/skills/session/references/cadence.md +++ b/av-dev-tasks/skills/session/references/cadence.md @@ -68,7 +68,9 @@ задачи, заведённые за спринт; при урожае в 15 это две-три порции. - **Отбор порций по порядку:** 1. **урожай спринта** — `list --tag sprint:<слаг>`: свежезаведённое ещё не - проходило ни одной проверки на нужность; + проходило ни одной проверки на нужность. Слаг спринта берётся из + `SPRINT.md` (его завёл `sprint start`), тег на задачах проставлен + автоматически при заведении — руками не метят и не вспоминают; 2. дальше **по залежалости** — `list --stale`; 3. по потребности — одна секция целиком, один тег (партия ревью), одна цель (`--goal`), список от пользователя. @@ -176,8 +178,9 @@ 4. **Набор показывается человеку до старта работ.** Показ — это и есть момент заморозки: после него набор не двигается. 5. Задача, которой для взятия не хватает только критериев приёмки, дописывается - здесь же — но если для критериев нужен ответ человека, это вопрос, и задача в - набор не идёт. + здесь же — 2–5 утверждений, у каждого назван оракул (меньше двух `sprint + take` не примет). Но если для критериев нужен ответ человека, это вопрос, и + задача в набор не идёт. **Размер — ориентир, а не закон:** 5–8 задач. Можно взять больше, можно меньше — набор под цель важнее круглого числа; одна крупная задача спринтом тоже бывает. diff --git a/av-dev-tasks/skills/session/references/sprint.md b/av-dev-tasks/skills/session/references/sprint.md index 01d693a..785ef4d 100644 --- a/av-dev-tasks/skills/session/references/sprint.md +++ b/av-dev-tasks/skills/session/references/sprint.md @@ -11,7 +11,8 @@ след. - **Сделана** — по определению готовности ниже. `close --implemented`: - файл и строка удаляются, следом остаётся коммит. + файл и строка удаляются, следом остаётся коммит. **Закрывает владелец спринта + и только после вердикта приёмки** — см. «Кто и когда закрывает». - **Вышла из спринта** — `sprint drop --reason …`: возвращается в беклог с вопросом в файле и **без живого незакоммиченного предложения** — иначе при следующем взятии оно столкнётся с новым. Наработки, которые жалко терять, @@ -27,6 +28,13 @@ «все сделаны»: иначе одна застрявшая задача держит спринт бесконечно. Затем `sprint close`. +**Урожай заводится при закрытии спринта, а не при закрытии задачи.** Это +обязанность закрывающего: пройти по спискам находок от исполнителей и завести +недостающее интейком скилла `tasks` — с дедупликацией и картой человеку. Заводимое +метится тегом спринта само (`sprint:<слаг>`), поэтому первая порция следующей +сессии поднимается одной командой `list --tag sprint:<слаг>`. Спринт, закрытый +без этого шага, оставляет находки жить в отчётах — то есть нигде. + **Провал спринта.** Сработал блокер — спринт распускается (`sprint close --dissolve --reason …`), недоделанное возвращается в беклог, новый набор делается после ответа человека. Спринт не «ждёт»: ждать может человек, а @@ -44,8 +52,32 @@ 2. **Критерии приёмки проверены поимённо** — каждый со своим оракулом, исход по каждому назван. Это единственное, что добавляет управление задачами: пайплайн отвечает «сделано по правилам», критерии — «сделано то, что заказывали». -3. **Урожай заведён** — вопросы и задачи, найденные по ходу, лежат в беклоге, а - не в отчёте. +3. **Находки по ходу отданы списком** — исполнитель обязан их **назвать** + (каждую, с пометкой «заведена / не заведена: причина»), но **не обязан + заводить**: заведение интерактивно, оно требует дедупликации против беклога и + кладбища и решений человека. Обязанность **завести урожай** — на закрытии + спринта, ниже. Так автономный исполнитель не оказывается одновременно обязан + завести задачи и не вправе это сделать в одиночку. + +### Кто и когда закрывает + +**Задачу закрывает не пайплайн, а владелец спринта — после приёмки.** Порядок: + +1. пайплайн доводит задачу до коммита и **докладывает исход**; файл задачи он не + трогает — своей процедуры закрытия у него нет; +2. приёмщик (не исполнитель) сверяет критерии поимённо и выносит вердикт; +3. вердикт сошёлся — владелец спринта зовёт `close --implemented` + командой учёта задач из `CLAUDE.md` проекта. + +Обратный порядок ломает приёмку физически: закрытие **удаляет файл**, и +приёмщику, нашедшему расхождение, возвращать нечего. + +**Дорога назад существует и обязана быть названа.** Закрыли раньше вердикта, а +приёмка не сошлась — `tasks.py reopen --reason "приёмка не сошлась: …"`: +файл восстанавливается из истории git, строка возвращается в набор идущего +спринта (или в беклог, если спринта нет), строка кладбища снимается. Тело +восстанавливается **на момент удаления** — всё, что было дописано позже, живёт +только в коммите задачи, и это называется в докладе. ### Кто и по чему принимает diff --git a/av-dev-tasks/skills/tasks/SKILL.md b/av-dev-tasks/skills/tasks/SKILL.md index e59e712..a0af981 100644 --- a/av-dev-tasks/skills/tasks/SKILL.md +++ b/av-dev-tasks/skills/tasks/SKILL.md @@ -25,8 +25,11 @@ description: Ведение задач и целей как каталога mar 2. **Файл — источник истины, индексы производны.** Разошлись — неправы индексы. Согласованность механизируема и проверяется командой, а не вниманием: всё, что ловит `tasks.py check`, не должно попадать ни в чек-лист, ни в промпт. - Единственное исключение намеренное: **в каком индексе лежит задача, знают - индексы** — «в спринте» это свойство спринта, а не файла, поля-состояния нет. + Поэтому **хук живёт в мета-строке файла**, а строка индекса его лишь + повторяет: пока хук лежал только в индексе, восстановление пропавшей строки + теряло его молча и навсегда. Единственное исключение намеренное: **в каком + индексе лежит задача, знают индексы** — «в спринте» это свойство спринта, а + не файла, поля-состояния нет. 3. **Причина переживает запись.** Выкинутая без причины задача вернётся через квартал тем же текстом. Реализованная оставляет след в коммите — выкинутая не оставляет ничего, поэтому у неё есть `REJECTED.md`. @@ -51,6 +54,15 @@ description: Ведение задач и целей как каталога mar подо что берут.** Цель в спринт взять нельзя, поэтому в списке берущихся ей не место. +**Секции «блокеры» в беклоге нет.** Блокер — это *состояние* (спринт не может +продолжаться ни одной задачей), а не полка: он живёт ровно до ответа человека, и +записи в такой секции не успевают жить. Следы блокера остаются вопросами в +файлах задач распущенного спринта. Постоянно пустая секция со старой семантикой +«разбираются пачками» противоречила бы правилу «блокер эскалируется немедленно», +поэтому `init` её заводить отказывается, а `check` о ней говорит. **Проекту, +который переезжает с такой секцией, её надо удалить** — это единственное место, +где это сказано. + **Задача живёт в одном индексе за раз.** Взята в спринт — строка переезжает из `BACKLOG.md` в `SPRINT.md`; вышла — обратно. Файл в `items/` при этом **не двигается**: он и есть запись, индексы лишь показывают, где она числится. @@ -77,8 +89,10 @@ description: Ведение задач и целей как каталога mar - **Статус цели выводится.** Цель закрыта, когда у неё не осталось открытых задач; `[x]`/`[~]` руками не ведутся, а `close` цели с живыми задачами скрипт запретит. Единственная оговорка: цель без задач неотличима — «ещё не - декомпозирована» или «всё закрыто». Различает пометка «декомпозирована» в - теле, проставляемая при переоценке. + разобрана» или «всё закрыто». Различает **тег `decomposed`** в мета-строке + цели: он ставится, когда цель разложена на задачи. Тег, а не строка в теле — + потому что проверяется механически: `check` требует его у пустой цели, а + `check --fix` сам проставляет его цели, у которой задачи есть. - **`[goal]` и `[epic]` — разные вещи.** Цель **постоянна**: живёт, пока живёт направление. Эпик **временен**: это задача, которая не мерджится целиком, её разбирают, и он исчезает. Два срока жизни под одним словом разъезжаются, @@ -86,21 +100,38 @@ description: Ведение задач и целей как каталога mar ## Инструмент (`tasks.py`) -Пусть `tk="$CLAUDE_PLUGIN_ROOT/skills/tasks/scripts/tasks.py"`. +Пусть `tk="$CLAUDE_PLUGIN_ROOT/skills/tasks/scripts/tasks.py"`, а `D` — каталог +задач проекта (см. «Переносимость»; `--dir` опускается только если каталог +лежит в умолчаниях под текущим каталогом). ``` -python3 $tk check # согласованность всех индексов + здоровье, exit 1 при расхождениях -python3 $tk check --fix # + починить безопасный дрейф (секция, заголовок, дубли) -python3 $tk list [--stale] [--section S] [--type T] [--tag T] [--goal S] [--index …] [--questions] -python3 $tk add --slug S --title T [--type goal|idea|epic] [--section S] [--goal G] [--hook H] [--tag a,b] -python3 $tk edit S [--title T] [--hook H] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c] -python3 $tk move S --section S [--reason R] [--after S | --first] -python3 $tk close S --reason R # в REJECTED.md + удалить (ушла без реализации) -python3 $tk close S --implemented # просто удалить (реализована, есть коммит) -python3 $tk sprint start --goal S | take S… | drop S… --reason R | close [--dissolve --reason R] -python3 $tk init [--dir D] [--sections …] [--plan-sections …] [--items …] [--backlog …] … +python3 $tk check --dir D # согласованность индексов + здоровье +python3 $tk check --dir D --fix # + починить дрейф (секция, заголовок, дубли, хук, дом) +python3 $tk list --dir D [--stale] [--section S] [--type T] [--tag a,b] [--goal S] [--index …] [--questions] +python3 $tk add --dir D --slug S --title T [--type goal|idea|epic] [--section S] [--goal G] [--hook H] [--tag a,b] +python3 $tk edit S --dir D [--title T] [--hook H] [--type T] [--goal G] [--add-tag a,b] [--rm-tag c] +python3 $tk move S --dir D --section S [--reason R] [--after S | --first] +python3 $tk close S --dir D --reason R # в REJECTED.md + удалить (ушла без реализации) +python3 $tk close S --dir D --implemented # просто удалить (реализована, приёмка сошлась) +python3 $tk reopen S --dir D --reason R # вернуть закрытую: приёмка не сошлась +python3 $tk sprint start --goal S --dir D | take S… | drop S… --reason R | close [--dissolve --reason R] +python3 $tk init --dir D [--sections …] [--plan-sections …] [--items …] [--backlog …] … +python3 $tk adopt scan --from … | apply --plan … # разовая адаптация чужого репозитория, скилл adopt ``` +**Коды выхода — единый словарь; на нём ветвятся скиллы, а не на тексте вывода:** + +| Код | Что случилось | Что делать | +| --- | --- | --- | +| 0 | сошлось / сделано | дальше по сценарию | +| 1 | **только `check`:** найден дрейф индексов и файлов | `check --fix`, остаток разобрать | +| 2 | ошибка употребления: аргументы или нарушенное правило | читать сообщение, это отказ по существу | +| 3 | окружение: каталог не найден, конфиг битый или мимо диска | чинится путём или `.tasks.json`, повтор не поможет | +| 4 | внутренний сбой | дефект скрипта, доложить | + +Различать 1 и 3 обязательно: «дрейф в беклоге» — рабочая ситуация, «каталога +нет» — нерабочая, и одинаковая реакция на них была бы неверна в обоих случаях. + Тип — английское ключевое слово `goal` / `idea` / `epic` / `task` (как и прочие токены команд); `task` префикса не несёт, остальные кодируются `[goal]`/ `[idea]`/`[epic]` в заголовке. Текст задачи при этом русский. @@ -109,16 +140,40 @@ python3 $tk init [--dir D] [--sections …] [--plan-sections …] [--items …] не пиши, зови `add`/`edit`/`move`/`close`/`sprint`. Смена заголовка, хука, типа, цели и **тегов** — это `edit`: он держит H1, мета-строку и индекс в синхроне. Снятие тега — `--rm-tag` (после ответа на вопрос снимается `question`), смена -цели — `--goal`, он заменяет прежний `goal:*`. Тело задачи скрипт не трогает: +цели — `--goal`, он заменяет прежний `goal:*`. + +**Переезд между индексами — следствие смены типа, а не отдельная команда.** +`edit --type goal --section <часть плана>` переносит строку из +`BACKLOG.md` в `PLAN.md` (и обратно `--type task --section <секция беклога>`); +`move` двигает только внутри одного индекса и пишет причину. `--section` у +`edit` работает **только** при таком переезде — иначе он отсылает к `move`, +потому что смена секции без причины и есть тот дрейф, который потом никто не +объяснит. Задача в наборе спринта тип не меняет вовсе: сперва `sprint drop`. + +Тело задачи скрипт не трогает: `add` кладёт заголовок, мета-строку и шаблон с подсказками, тело дописываешь редактором (пока плейсхолдер на месте, `check` напоминает). `check` — единственный судья согласованности; что именно он ловит, скажет его вывод, здесь не пересказываем. Гоняй его **в начале сессии** и **после каждой правки**, даже если правил мутациями: дрейф мог накопиться раньше. Накопившееся -чини `check --fix` — он детерминированно правит безопасное, а неоднозначное -(ссылка на исчезнувший файл, задача сразу в двух индексах) выносит тебе. Это -идёт строкой доклада. +чини `check --fix` — он детерминированно правит то, где истина однозначна +(секция, заголовок, дубли, хук из индекса в файл, строка в чужом индексе, +пометка `decomposed` у цели с задачами), а неоднозначное (ссылка на исчезнувший +файл, задача сразу в двух индексах) печатает отдельной пометкой +`НЕОДНОЗНАЧНО` — это тебе, и это идёт строкой доклада. + +`--fix` правит **и файлы** — ровно в двух местах, где источник ровно один и +выбирать не из чего: хук, оставшийся только в индексе, переезжает в мета-строку, +и цель, у которой есть задачи, получает тег `decomposed`. Оба случая печатаются +поимённо. + +**Что механизировано, а что нет.** Критерии приёмки проверяются у задачи, взятой +в набор (`sprint take` и `check` по задачам спринта): число пунктов — жёстко +(меньше двух — отказ, больше пяти — замечание), наличие оракула — **эвристикой** +по слову «оракул» в пункте. Настоящий оракул от слова «оракул» машина не +отличает, поэтому эвристика даёт только замечание, и в докладе это называется +как есть: «проверено число пунктов, годность оракулов — глазами». Формат файла, мета-строки, слага, индексов и `REJECTED.md` — [references/task-format.md](references/task-format.md). Там же тест «готова к @@ -162,6 +217,13 @@ python3 $tk init [--dir D] [--sections …] [--plan-sections …] [--items …] пользователю до создания файлов. Порядок, отображение серьёзности и привязка к целям — [references/from-review.md](references/from-review.md). +### Прийти в репозиторий, где задачи уже как-то ведутся + +Разовая операция: вывести каталог задач из старой раскладки беклога, `TODO.md`, +заметок или списка шагов в плане — скилл `adopt`. Сюда же относится +переименование транслитных слагов в английские: оно делается **одним проходом +вместе с починкой перекрёстных ссылок**, а не по одному слагу. + ### Декомпозиция и штурм идеи [references/split.md](references/split.md). Обе операции превращают одну запись в @@ -175,7 +237,8 @@ python3 $tk init [--dir D] [--sections …] [--plan-sections …] [--items …] - **протухший хук** — задача изменилась, а хук отвечает на старый вопрос; особенно после ответа на вопрос задачи: «Решено: …» на «почему это лежит в - беклоге» уже не отвечает, хук переписывается; + беклоге» уже не отвечает. Переписывается `edit --hook …` — он правит + мета-строку файла и строку индекса заодно; - **вопрос, застрявший в прозе** — вынимается в раздел «Вопросы» плюс тег `question` (`edit --add-tag question`), иначе он не виден ни `list --questions`, ни правилу «задача с открытым вопросом в набор не берётся»; @@ -188,6 +251,40 @@ python3 $tk init [--dir D] [--sections …] [--plan-sections …] [--items …] такой-то агент»: это второй дом для правила выбора и путь понизить требования решением, принятым до проектирования. Снимается. +## Переносимость + +Скилл независим от **языка программирования, сборки, CI и трекера**: он ничего +не знает ни про Go, ни про npm, ни про конкретный багтрекер — задачи для него +просто каталог markdown. Текст задач — русский (язык документации проекта); +зашита только латиница слага. + +- **Каталог задач** ищется цепочкой: `--dir` → **указатель в `CLAUDE.md` + проекта** (его читаешь ты и передаёшь `--dir`; скрипт чужую документацию не + разбирает) → `.tasks.json` вверх от текущего каталога → умолчания + (`docs/tasks`, `tasks`, `doc/tasks`) вверх от текущего каталога, до корня + репозитория. Не нашлось — код 3 и вопрос человеку, а не догадка: `init` + заводит каталог **только** когда проект действительно новый. + **В примерах `--dir` стоит намеренно:** каталог вне умолчаний иначе не + находится, а вызов из подкаталога — обычное дело. +- **Секции беклога** берутся из заголовков `##` индекса как есть; их количество + и названия — дело проекта (умолчание `ядро` / `инфра`). +- **Имена индексов и подкаталога** — параметры `init`, живут в + `/.tasks.json`. Ничего не зашито именем файла. + +### Вызов из другого плагина + +`$CLAUDE_PLUGIN_ROOT` раскрывается **только внутри своего плагина**: чужой +контекст — пайплайн задачи, конвейер ревью, любой другой скилл — до `tasks.py` +по этой переменной не дотянется. Поэтому контракт такой: + +> **Проект называет команду учёта задач в своём `CLAUDE.md`** — целиком, готовой +> к запуску строкой (слот 6 ниже). Вызывающий берёт её оттуда. Слота нет — +> вызывающий **не выдумывает путь и не правит индекс руками**, а сообщает в +> докладе, что закрытие/заведение остаётся за владельцем задач. + +Так вызывающему не нужно знать ни про плагин, ни про его расположение: он знает +проект, а проект знает команду. + ## Слоты проекта Скилл не знает ни языка программирования, ни сборки, ни CI, ни трекера — задачи @@ -207,6 +304,16 @@ python3 $tk init [--dir D] [--sections …] [--plan-sections …] [--items …] (деплой, выкладка наружу, удаление или перезапись данных). 5. **Оракулы, которые в проекте вообще есть** — чем проверяется критерий приёмки: тест, команда, прогон на реальных данных, глазами по логу. +6. **Команда учёта задач** — готовая строка, которой чужой контекст зовёт + `tasks.py`, потому что путь к плагину ему неизвестен. Например: + + ``` + Команда учёта задач: python3 ~/.claude/plugins/marketplaces/av-dev-skills/\ + av-dev-tasks/skills/tasks/scripts/tasks.py --dir docs/tasks + ``` + + Слот заполняется один раз при подключении плагина. Он же отвечает на вопрос + «кто закрывает задачу»: команду знает проект, зовёт её владелец спринта. Ничего из этого скилл не угадывает: не нашёл — спрашивает пользователя, а не подставляет умолчание. diff --git a/av-dev-tasks/skills/tasks/references/task-format.md b/av-dev-tasks/skills/tasks/references/task-format.md index 12ddd00..6f8b10c 100644 --- a/av-dev-tasks/skills/tasks/references/task-format.md +++ b/av-dev-tasks/skills/tasks/references/task-format.md @@ -11,7 +11,7 @@ ```markdown # Тай-брейк при равной полноте -**Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал · **Теги:** goal:merge-robustness, sprint:2026-08 +**Секция:** ядро — вышла из спринта: остаток писал нерешённое в журнал · **Хук:** порядок канонических форм берёт меньшее в 96% случаев — для накопительных это систематический недосчёт · **Теги:** goal:merge-robustness, sprint:2026-08-03 При столкновении точек выигрывает более полная, но при равной полноте побеждает последняя доставка — а она систематически беднее первой. @@ -35,9 +35,14 @@ префикс виден прямо в индексе, где и принимается решение «брать или не брать». - **Мета-строка** — первая непустая строка после заголовка. Обязательна секция, причина после тире желательна (именно она объясняет, почему задача здесь - оказалась — в том числе «вышла из спринта: …»), теги опциональны. Поля + оказалась — в том числе «вышла из спринта: …»), хук и теги опциональны. Поля разделяются ` · `, порядок свободный. `·` — служебный разделитель: в тексте - причины его быть не должно. + причины и хука его быть не должно. +- **Хук живёт здесь, а не только в индексе.** Строка индекса его повторяет и + производна от него: `check` сверяет, `check --fix` восстанавливает пропавшую + строку **вместе с хуком**. Пока хук лежал только в индексе, штатная починка + дрейфа теряла его молча и навсегда — а хук это единственное, по чему задачу + выбирают, не открывая. - **Тело** — одна фраза «что станет наблюдаемо иначе», критерии приёмки, рамки, контекст, ссылки. Пишется на языке документации проекта. @@ -46,11 +51,18 @@ ### Критерии приёмки -2–5 проверяемых утверждений, **у каждого назван оракул**. Не «работает -корректно», а «повторный прогон даёт тот же отпечаток — оракул: команда сверки». -Это не второе определение готовности, а проектная конкретизация вопроса «по чему -видно, что закончено» из теста готовности ниже: там сказано «признак -завершённости», здесь — «признак плюс чем проверяется». +2–5 проверяемых утверждений **списком** `- …`, **у каждого назван оракул**. Не +«работает корректно», а «повторный прогон даёт тот же отпечаток — оракул: +команда сверки». Это не второе определение готовности, а проектная +конкретизация вопроса «по чему видно, что закончено» из теста готовности ниже: +там сказано «признак завершённости», здесь — «признак плюс чем проверяется». + +**Что из этого механизировано.** `check` и `sprint take` считают пункты: меньше +двух — отказ («— работает» одной строкой больше не проходит), больше пяти — +замечание, обычно это признак, что задача крупнее задачи. Наличие оракула +проверяется **эвристикой** — словом «оракул» в пункте, — и потому даёт только +замечание: настоящий оракул от слова «оракул» машина не отличает, и делать вид, +что проверено больше проверенного, хуже, чем не проверять вовсе. **У идей критериев нет — именно поэтому они идеи.** @@ -71,9 +83,14 @@ ### Вопросы Неразобранное решение человека живёт разделом `## Вопросы` **плюс тегом -`question`**. Тег — то, по чему вопрос виден снаружи файла (`list --questions`) и -чем работает правило «задача с открытым вопросом в набор не берётся». Раздел без -тега или тег без раздела — дрейф, `check` о нём скажет. +`question`**. Раздел без тега или тег без раздела — дрейф, `check` о нём скажет. + +**Судит факт, а не метка.** Отказ во взятии даёт **непустой раздел «Вопросы»**, +независимо от того, стоит ли тег: иначе забывший тег проходил бы, а поставивший +спотыкался — стимул ровно обратный записанному правилу. Тег производен: он нужен +отбору снаружи файла (`list --questions`, `list --tag question`), и его +отсутствие при непустом разделе — замечание, а не лазейка. Тег без раздела тоже +отказ, но с другим советом: либо вопрос записан не туда, либо тег пора снять. Ответ записывается в тело, тег снимается `edit --rm-tag question`, а хук переписывается: «Решено: …» на вопрос «почему это лежит в беклоге» уже не @@ -98,10 +115,12 @@ - **Задачи цели здесь не перечисляются.** Перечень даёт `tasks.py list --goal <слаг>`; хранимый список стал бы третьим индексом и поехал бы на первой же закрытой задаче. -- **Раздел «Завершение»** — то, по чему видно, что цель достигнута. Он же - отличает «цель ещё не декомпозирована» от «все её задачи закрыты»: пометка - вроде тега `decomposed` или строки в теле ставится, когда цель разложена на - задачи. +- **Раздел «Завершение»** — то, по чему видно, что цель достигнута. +- **Тег `decomposed`** отличает «цель ещё не разобрана» от «все её задачи + закрыты» — два состояния, у которых снаружи один и тот же признак: задач нет. + Пометка именно **тегом**, а не строкой в теле: только так она проверяется. + `check` требует его у цели без задач, `check --fix` сам ставит его цели, у + которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть. - Цель живёт в `PLAN.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`. ## Слаг @@ -137,13 +156,27 @@ Секции — **единственные заголовки `##` в индексе**: любой другой `##` в преамбуле проверка сочтёт секцией. Внутри секции беклога порядок значения не -имеет — порядка в беклоге нет вовсе. В **линии** плана порядок значим и +имеет — порядка в беклоге нет вовсе. + +**Секции «блокеры» среди них нет.** Блокер — состояние, а не полка: он живёт до +ответа человека, а следы остаются вопросами в файлах задач распущенного спринта. +Постоянно пустая секция со старой семантикой «разбираются пачками» противоречила +бы правилу «эскалируем немедленно», поэтому `init` её не заводит, а `check` +говорит о ней в чужом беклоге. Переезжаешь с такой секцией — удали её. В **линии** плана порядок значим и обосновывается прозой; двигают строку `move --section линия --after <другой>`. Индексы **производны**: расходятся с файлом — правим индексы (`check --fix`). Строку руками не пишут. +Отсюда же ответ на «а если оборвётся посередине». Мутация сперва проверяет всё +и складывает правки, и только потом пишет: сначала все временные файлы, потом +переименования подряд. Полной транзакции на несколько файлов файловая система не +даёт, но окно сжато до цепочки переименований, а **всё, что в нём может +разъехаться, — производное**: файлы целы, индексы восстанавливает `check --fix`. +Поэтому отказ на второй задаче из пяти не оставляет первую переписанной при +нетронутых индексах. + `SPRINT.md` и есть артефакт заморозки: без него набор существует только в контексте сессии, и нарушение заморозки ненаблюдаемо. @@ -174,7 +207,16 @@ попадёт ни в один спринт. - `question` — в файле есть неразобранный раздел «Вопросы». - `sprint:<слаг>` — задача заведена в этом спринте; по нему отбирается первая - порция разбора («урожай спринта»). + порция разбора («урожай спринта»). **Ставится сам**: слаг спринта заводит + `sprint start` (по умолчанию — дата начала, он же пишется в `SPRINT.md`), и + `add` при открытом спринте помечает заводимое. Тег, который надо помнить + ставить руками, не ставится никогда — а на нём висит правило «первая порция + разбора — урожай прошедшего спринта». +- `decomposed` — на цели: разложена на задачи (см. «Файл цели»). + +Отбор — `list --tag a,b`: перечисленные через запятую теги требуются **все +сразу** (это И, не ИЛИ). Тег, которого нет ни у одной задачи, `list` называет +вслух: молчаливый ноль читается как «таких задач нет», а чаще это опечатка. Свои теги проект заводит свободно (партия ревью `review-ГГГГ-ММ-ДД`, тема, источник) — словарь не фиксирован. В индексы теги не выносим: индексы diff --git a/av-dev-tasks/skills/tasks/scripts/tasks.py b/av-dev-tasks/skills/tasks/scripts/tasks.py index 2e55fce..a003ddb 100755 --- a/av-dev-tasks/skills/tasks/scripts/tasks.py +++ b/av-dev-tasks/skills/tasks/scripts/tasks.py @@ -12,11 +12,13 @@ items/ задачи и цели файлами, .md PLAN.md оглавление целей: линия (упорядоченная) и кусты BACKLOG.md что можно взять — только задачи, целей здесь нет - SPRINT.md текущий спринт: цель, набор, дата + SPRINT.md текущий спринт: цель, набор, дата, слаг REJECTED.md ушедшее БЕЗ реализации, с причиной и датой Источник истины — файл задачи в items/. Индексы производны: расходятся — -неправ индекс. Исключение одно и оно намеренное: **в каком индексе лежит +неправ индекс. **Хук живёт в мета-строке файла**, а не только в строке индекса: +иначе восстановление пропавшей строки (`check --fix`) теряло бы его навсегда. +Исключение из производности одно и оно намеренное: **в каком индексе лежит задача — знают индексы**, потому что «в спринте» это свойство спринта, а не задачи; поля-состояния в файле нет, а рассогласование ловит check. @@ -31,25 +33,42 @@ Использование: tasks.py init [--dir DIR] [--sections …] [--plan-sections …] [--items …] tasks.py check [--dir DIR] [--fix] - tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag T] + tasks.py list [--dir DIR] [--stale] [--section S] [--type T] [--tag a,b] [--goal S] [--index backlog|sprint|plan|all] [--questions] tasks.py add --slug S --title T [--type goal|idea|epic] [--section S] - [--goal G] [--hook H] [--reason R] [--tag a,b] + [--goal G] [--hook H] [--reason R] [--tag a,b] [--dir DIR] tasks.py edit S [--title T] [--hook H] [--type T] [--goal G] - [--add-tag a,b] [--rm-tag c,d] - tasks.py move S --section S [--reason R] [--after S | --first] - tasks.py close S (--reason R | --implemented) - tasks.py sprint start --goal S [--date ГГГГ-ММ-ДД] - tasks.py sprint take S [S …] - tasks.py sprint drop S [S …] --reason R - tasks.py sprint close [--dissolve --reason R] + [--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` → указатель в `CLAUDE.md` проекта (его читает +агент и передаёт `--dir`) → `.tasks.json` вверх от текущего каталога → умолчания +(`docs/tasks`, `tasks`, `doc/tasks`) вверх от текущего каталога. + +Коды выхода (единый словарь, на нём ветвятся скиллы): + + 0 всё сошлось / операция выполнена + 1 расхождения найдены (только check: дрейф индексов и файлов) + 2 ошибка употребления: неверные аргументы, нарушенное правило процесса + 3 окружение: каталог задач не найден, конфиг битый или указывает в никуда + 4 внутренний сбой (непойманное исключение) — это дефект скрипта Тело задачи (контекст, критерии, вопросы, ссылки) остаётся агенту — add кладёт заголовок, мета-строку и шаблон-плейсхолдер; агент дописывает редактором. Границы безопасности: слаг — только латиница kebab-case (traversal невозможен), --dir обязан быть внутри рабочего каталога, в заголовок/хук/причину не пролезет -перевод строки, `·` в причине запрещён (это разделитель мета-полей). +перевод строки, `·` в хуке и причине запрещён (это разделитель мета-полей). + +Все проверки идут до первой записи; запись — одним проходом (см. Plan). Язык не зашит инструментально: секции сопоставляются с заголовками индексов как есть, имена служебных файлов и заголовков настраиваются. Текст задач — русский. @@ -66,6 +85,12 @@ from pathlib import Path CONFIG_NAME = ".tasks.json" +EXIT_OK = 0 +EXIT_DRIFT = 1 +EXIT_USAGE = 2 +EXIT_ENV = 3 +EXIT_INTERNAL = 4 + DEFAULTS = { "items": "items", "backlog": "BACKLOG.md", @@ -75,8 +100,13 @@ DEFAULTS = { "sprint_section": "Набор", "criteria_heading": "Критерии приёмки", "questions_heading": "Вопросы", + "oracle_word": "оракул", } +# Какие ключи конфига — имена файлов и каталогов (их существование сверяется +# с диском первым делом, иначе кривой ключ выглядит как пропавший файл). +PATH_KEYS = ("items", "backlog", "plan", "sprint", "rejected") + DEFAULT_SECTIONS = "ядро,инфра" DEFAULT_PLAN_SECTIONS = "линия,кусты" @@ -87,7 +117,9 @@ 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"^\*\*(?:Цель|Goal):\*\*\s*\[(.+?)\]\((.+?\.md)\)") +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-]+` — .+") @@ -97,8 +129,20 @@ PLAIN_TYPE = "task" # обычная задача — без пр TAKEABLE = (PLAIN_TYPE,) # что вообще можно взять в спринт QUESTION_TAG = "question" GOAL_TAG = "goal:" +SPRINT_TAG = "sprint:" +DECOMPOSED_TAG = "decomposed" # цель разложена на задачи (см. «Статус цели») STALE_DAYS = 180 # порог «залежалась» для метрики здоровья в check +CRITERIA_MIN, CRITERIA_MAX = 2, 5 # сколько утверждений в критериях приёмки BODY_PLACEHOLDER = "\n") return ("\n\n" f"## {lay.cfg['criteria_heading']}\n\n" - "\n\n" + f"\n\n" "## Рамки\n\n" "\n") def cmd_add(lay: Layout, a: argparse.Namespace) -> int: - for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук"), + for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_hook(a.hook), bad_tags(a.tag), bad_reason(a.reason), bad_slug(a.goal) if a.goal else None): if err: - return fail(err) + raise Usage(err) if not a.title.strip(): - return fail("пустой заголовок") + raise Usage("пустой заголовок") kind = (a.type or "").strip().lower() path = lay.items / f"{a.slug}.md" if path.exists(): - return fail(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый") + raise Usage(f"{path.name} уже существует — дедуп: допиши в него, а не заводи новый") target = "plan" if kind == GOAL else "backlog" lines = read_lines(lay.index(target)) if not lines: - return fail(f"нет индекса {lay.name(target)} — прогони tasks.py init") + raise Usage(f"нет индекса {lay.name(target)} — прогони tasks.py init") if find_entry_index(lines, a.slug) is not None: - return fail(f"строка в {lay.name(target)} для {a.slug} уже есть") + 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)) - return fail(f"нет секции «{a.section}» в {lay.name(target)} (есть: {avail})") + raise Usage(f"нет секции «{a.section}» в {lay.name(target)} (есть: {avail})") tags = split_tags(a.tag) if a.goal: @@ -780,22 +1074,31 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: if kind != GOAL and not any(t.startswith(GOAL_TAG) for t in tags): print(" без цели: задача вне цели не попадёт ни в один спринт —" f" проставь `tasks.py edit {a.slug} --goal <слаг>`") + # Урожай спринта метится сам: тег, который никто не ставит, не отбирает + # первую порцию переоценки, а именно на ней держится правило «сперва урожай». + sslug = sprint_slug(lay) + if kind != 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 = f"[{kind}] {a.title}" if kind else a.title - meta = build_meta(section, a.reason or "", tags) - lay.items.mkdir(parents=True, exist_ok=True) - write_atomic(path, f"# {title_full}\n\n{meta}\n\n{body_template(kind, lay)}") + meta = build_meta(section, a.reason or "", a.hook or "", tags) insert_entry(lines, section, entry_line(lay, title_full, a.slug, a.hook or "")) - save_index(lay, target, lines) + + plan = Plan() + plan.file(path, f"# {title_full}\n\n{meta}\n\n{body_template(kind, 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.hook: print(f" без хука — задай: tasks.py edit {a.slug} --hook …") warn_rejected(lay, a.slug, a.title) - return 0 + return EXIT_OK def warn_rejected(lay: Layout, slug: str, title: str) -> None: @@ -815,35 +1118,40 @@ def warn_rejected(lay: Layout, slug: str, title: str) -> None: def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: - for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_line(a.hook, "хук"), + for err in (bad_slug(a.slug), bad_line(a.title, "заголовок"), bad_hook(a.hook), bad_tags(a.add_tag), bad_tags(a.rm_tag), bad_slug(a.goal) if a.goal else None): if err: - return fail(err) - if all(v is None for v in (a.title, a.hook, a.type, a.goal, a.add_tag, a.rm_tag)): - return fail("нечего менять: дай --title, --hook, --type, --goal, --add-tag или --rm-tag") + raise Usage(err) + if all(v is None for v in (a.title, a.hook, a.type, a.goal, a.add_tag, a.rm_tag, a.section)): + raise Usage("нечего менять: дай --title, --hook, --type, --goal, --add-tag или --rm-tag") path = lay.items / f"{a.slug}.md" if not path.exists(): - return fail(f"{a.slug}.md не найден в {lay.cfg['items']}/") - kind_index, lines, ei = locate(lay, a.slug) - if ei is None: - return fail(f"строки индекса для {a.slug} нет — прогони check --fix") + 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(): - return fail("пустой заголовок") + raise Usage("пустой заголовок") bare = a.title if a.title is not None else task["bare"] kind = task["type"] if a.type is None else a.type.strip().lower() - if (kind == GOAL) != (task["type"] == GOAL): - return fail("тип goal не меняется на месте: цель и задача живут в разных индексах —" - " заведи новую запись и закрой старую с причиной") + old_home, new_home = home_index(task), home_index({"type": kind}) + in_sprint = "sprint" in places + + # Задача в спринте меняет тип только через выход из набора: [epic] или + # [idea] в наборе — состояние, которое check объявит ошибкой, а молчаливый + # успех оставит набор в нём. + if in_sprint and kind not in TAKEABLE: + raise Usage(f"{a.slug} в спринте, а тип «{kind}» в наборе не живёт:" + f" сперва выведи задачу — tasks.py sprint drop {a.slug} --reason …") + prefix = "" if kind in ("", PLAIN_TYPE) else f"[{kind}] " h1 = f"{prefix}{bare}" flines = path.read_text(encoding="utf-8").splitlines() if not flines or not flines[0].startswith("#"): - return fail(f"{a.slug}.md без заголовка H1 — прогони check") - flines[0] = f"# {h1}" - write_atomic(path, "\n".join(flines) + "\n") + raise Usage(f"{a.slug}.md без заголовка H1 — прогони check") tags = list(task["tags"]) if a.rm_tag is not None: @@ -857,70 +1165,127 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: 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 tags != task["tags"] and not update_meta(path, None, None, tags): - return fail(f"{a.slug}.md без мета-строки — прогони check и почини") - m = INDEX_ENTRY.match(lines[ei]) - hook = a.hook if a.hook is not None else (m.group(3) or "").strip() - lines[ei] = entry_line(lay, h1, a.slug, hook) - save_index(lay, kind_index, lines) + hook = task["hook"] if a.hook is None else a.hook + 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() + + new_text = meta_updated(path, section=section if section else None, + hook=hook if a.hook is not None else None, + tags=tags if tags != task["tags"] else None) + if new_text is None: + raise Usage(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини") + tlines = new_text.splitlines() + tlines[0] = f"# {h1}" + new_text = "\n".join(tlines) + "\n" + + plan = Plan() + plan.file(path, new_text) + if old_home != new_home: + old_lines, ei = places[old_home] + entry = old_lines.pop(ei) + plan.index(lay, old_home, old_lines) + target_lines = read_lines(lay.index(new_home)) + insert_entry(target_lines, section, entry_line(lay, h1, a.slug, hook)) + plan.index(lay, new_home, target_lines) + else: + for kind_index, (lines, ei) in places.items(): + lines[ei] = entry_line(lay, h1, a.slug, hook) + plan.index(lay, kind_index, lines) + plan.commit() changed = [n for n, v in (("заголовок", a.title), ("хук", a.hook), ("тип", a.type), ("цель", a.goal), ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") - if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"] and kind_index == "sprint": + 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 0 + 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: - return fail(err) + raise Usage(err) path = lay.items / f"{a.slug}.md" if not path.exists(): - return fail(f"{a.slug}.md не найден в {lay.cfg['items']}/") - kind_index, lines, ei = locate(lay, a.slug) - if ei is None: - return fail(f"строки индекса для {a.slug} нет — прогони check --fix") - if kind_index == "sprint": - return fail(f"{a.slug} в спринте: секция — свойство беклога." + 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)) - return fail(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})") - if not update_meta(path, section, a.reason, None): - return fail(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини") + raise Usage(f"нет секции «{a.section}» в {lay.name(kind_index)} (есть: {avail})") + new_text = meta_updated(path, section=section, reason=a.reason) + if new_text is None: + raise Usage(f"{a.slug}.md без мета-строки **Секция:** — прогони check и почини") entry = lines.pop(ei) try: insert_entry(lines, section, entry, a.after, a.first) except KeyError: - return fail(f"--after {a.after}: такой строки в секции «{section}» нет") - save_index(lay, kind_index, lines) + raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") + + 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 0 + return EXIT_OK def cmd_close(lay: Layout, a: argparse.Namespace) -> int: for err in (bad_slug(a.slug), bad_reason(a.reason)): if err: - return fail(err) + raise Usage(err) path = lay.items / f"{a.slug}.md" if not path.exists(): - return fail(f"{a.slug}.md не найден в {lay.cfg['items']}/") - kind_index, lines, ei = locate(lay, a.slug) - if ei is None: - return fail(f"строки индекса для {a.slug} нет — прогони check --fix") + 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: - return fail(f"у цели {a.slug} осталось открытых задач: {len(open_tasks)}" + raise Usage(f"у цели {a.slug} осталось открытых задач: {len(open_tasks)}" f" ({', '.join(sorted(t[:-3] for t in open_tasks))})." f" Цель закрыта, когда не осталось её задач") + + plan = Plan() if a.reason: reason = a.reason.rstrip() dot = "" if reason.endswith((".", "!", "?")) else "." @@ -931,155 +1296,330 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int: prev = rej.read_text(encoding="utf-8") if rej.exists() else "# Ушедшее без реализации\n" if not prev.endswith("\n"): prev += "\n" - write_atomic(rej, prev + bullet + "\n") - # Порядок: индекс без строки → потом unlink. Обратный порядок оставил бы - # ссылку в никуда, если бы unlink упал. - lines.pop(ei) - save_index(lay, kind_index, lines) - path.unlink() + plan.file(rej, prev + bullet + "\n") + for kind_index, (lines, ei) in places.items(): + lines.pop(ei) + plan.index(lay, kind_index, lines) + plan.delete(path) + plan.commit() + print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") - if kind_index == "sprint" and a.reason: + if "sprint" in places and a.reason: print(" задача закрыта прямо из спринта без реализации — назови это в докладе спринта") - return 0 + if not a.reason: + print(" дорога назад: файл восстанавливается из git —" + f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`") + return EXIT_OK + + +def git_deleted_text(path: Path) -> str | None: + """Текст файла из коммита, в котором его удалили. Возврат закрытой задачи + возможен ровно потому, что удаление зафиксировано историей.""" + try: + sha = subprocess.run(["git", "log", "--diff-filter=D", "--format=%H", "-n", "1", + "--", str(path)], capture_output=True, text=True).stdout.strip() + if not sha: + return None + out = subprocess.run(["git", "show", f"{sha}^:{path}"], + capture_output=True, text=True) + return out.stdout if out.returncode == 0 else 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 + kind = PLAIN_TYPE + if (m := TYPE_PREFIX.match(title)): + kind = m.group(1).strip().lower() + + if a.reason: + upd = meta_updated_text(text, reason=a.reason) + 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": kind}) + 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)) + 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["hook"])) + 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()}") + 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) -> str | None: + lines = text.splitlines() + mi = next((i for i in range(1, len(lines)) if lines[i].strip()), None) + if mi is None or not META_FIELD.match(lines[mi].strip()): + return None + chunks = lines[mi].split("·") + for idx, chunk in enumerate(chunks): + f = META_FIELD.match(chunk.strip()) + if f and f.group(1).strip().lower() in ("секция", "section"): + section = f.group(2).partition("—")[0].strip() + chunks[idx] = f"**Секция:** {section} — {reason}" + return "\n".join((*lines[:mi], "·".join(chunks), *lines[mi + 1:])) + "\n" + return None # --- Спринт --- def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_slug(a.goal)): - return fail(err) + raise Usage(err) entries, _ = parse_entries(read_lines(lay.index("sprint"))) if entries: - return fail(f"в {lay.name('sprint')} ещё есть набор ({len(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(): - return fail(f"цели {a.goal}.md нет в {lay.cfg['items']}/") + raise Usage(f"цели {a.goal}.md нет в {lay.cfg['items']}/") goal = parse_task(gpath) if goal["type"] != GOAL: - return fail(f"{a.goal} не цель (тип «{goal['type']}») — спринт набирается под [goal]") + raise Usage(f"{a.goal} не цель (тип «{goal['type']}») — спринт набирается под [goal]") date = a.date or datetime.date.today().isoformat() if not DATE_RE.fullmatch(date): - return fail("дата в формате ГГГГ-ММ-ДД") - lines = sprint_header(lay, a.goal, goal["title"], date) + [f"## {lay.cfg['sprint_section']}", ""] - save_index(lay, "sprint", lines) - ready = [n[:-3] for n, t in tasks_of(lay).items() + 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 QUESTION_TAG not in t["tags"]] - print(f"спринт начат: цель «{a.goal}», {date}") + and not questions_open(lay, t)] + print(f"спринт начат: цель «{a.goal}», {date}, слаг «{slug}»") print(f" кандидатов под цель без открытых вопросов: {len(ready)}" + (f" ({', '.join(sorted(ready))})" if ready else "")) - print(" набери: tasks.py sprint take <слаг> …; набор показывается человеку до старта работ") - return 0 + 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: - return fail("спринт не начат: tasks.py sprint start --goal <слаг>") + 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: - return fail(f"в {lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»") - taken = [] + raise Usage(f"в {lay.name('sprint')} нет секции «{lay.cfg['sprint_section']}»") + taken, warn = [], [] for slug in a.slugs: if (err := bad_slug(slug)): - return fail(err) + raise Usage(err) path = lay.items / f"{slug}.md" if not path.exists(): - return fail(f"{slug}.md не найден в {lay.cfg['items']}/") + raise Usage(f"{slug}.md не найден в {lay.cfg['items']}/") t = parse_task(path) if t["type"] not in TAKEABLE: - return fail(f"{slug}: тип «{t['type']}» в спринт не берётся —" + raise Usage(f"{slug}: тип «{t['type']}» в спринт не берётся —" f" идея идёт на штурм, эпик на декомпозицию, цель не берут вовсе") if t["goal"] != goal_slug: - return fail(f"{slug}: цель «{t['goal'] or '—'}» не цель спринта «{goal_slug}» —" + raise Usage(f"{slug}: цель «{t['goal'] or '—'}» не цель спринта «{goal_slug}» —" f" набор служит одной цели, даже если взять удобно") + # Отказ по факту, а не по метке: непустой раздел «Вопросы» блокирует + # взятие независимо от тега. Забывший тег иначе проходил бы, а + # поставивший спотыкался — стимул ровно обратный правилу. + 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"]: - return fail(f"{slug}: открыт вопрос — разбирается до взятия," - f" вне очереди порции (`tasks.py edit {slug} --rm-tag {QUESTION_TAG}`)") - crit = lay.cfg["criteria_heading"].lower() - if not t["body"].get(crit): - return fail(f"{slug}: нет раздела «{lay.cfg['criteria_heading']}» —" - f" принимать будет не по чему") + raise Usage(f"{slug}: тег «{QUESTION_TAG}» стоит, а раздела" + f" «{lay.cfg['questions_heading']}» нет — либо вопрос записан не туда," + f" либо тег пора снять: `edit {slug} --rm-tag {QUESTION_TAG}`") + errs, notes = criteria_verdict(lay, t) + if errs: + raise Usage("; ".join(errs)) + warn += notes ei = find_entry_index(backlog_lines, slug) if ei is None: - return fail(f"{slug}: строки в {lay.name('backlog')} нет" + raise Usage(f"{slug}: строки в {lay.name('backlog')} нет" f" (уже в спринте? прогони check)") entry = backlog_lines.pop(ei) insert_entry(sprint_lines, section, entry) taken.append(slug) - save_index(lay, "backlog", backlog_lines) - save_index(lay, "sprint", sprint_lines) + + 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}") - return 0 + 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)): - return fail(err) + 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)): - return fail(err) + raise Usage(err) ei = find_entry_index(sprint_lines, slug) if ei is None: - return fail(f"{slug}: в наборе спринта такой строки нет") + 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: - return fail(f"{slug}: секция «{t['section']}» не найдена в {lay.name('backlog')}") - if not update_meta(path, section, a.reason, None): - return fail(f"{slug}.md без мета-строки **Секция:** — прогони check и почини") + 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) + if new_text is None: + raise Usage(f"{slug}.md без мета-строки **Секция:** — прогони check и почини") + plan.file(path, new_text) insert_entry(backlog_lines, section, sprint_lines.pop(ei)) dropped.append(slug) - save_index(lay, "backlog", backlog_lines) - save_index(lay, "sprint", sprint_lines) + plan.index(lay, "backlog", backlog_lines) + plan.index(lay, "sprint", sprint_lines) + plan.commit() print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мета-строку") print(f" живого предложения оставаться не должно; наработки, которые жалко," f" переносятся в тело задачи текстом") - return 0 + return EXIT_OK def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int: if (err := bad_reason(a.reason)): - return fail(err) + 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: - return fail(f"в наборе осталось задач: {len(entries)}" + 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: - return fail("--dissolve без --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)) != 0: + if entries and (rc := cmd_sprint_drop(lay, drop)) != EXIT_OK: return rc - write_atomic(lay.index("sprint"), empty_sprint(lay)) - print(f"спринт закрыт (цель «{goal_slug or '—'}»)" + 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(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор") - return 0 + return EXIT_OK # --- check --fix --- -def apply_fixes(lay: Layout) -> list[str]: - """Детерминированная починка дрейфа. Чинит только безопасное, где истина - однозначно в файле: дубли строк, рассинхрон заголовка, задача не в своей - секции, отсутствующая строка. Неоднозначное (ссылка на исчезнувший файл, - задача сразу в двух индексах, битые строки) не трогает — это на суд человека.""" +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() # 1. Дубли строк на один файл — оставляем первую. @@ -1097,7 +1637,8 @@ def apply_fixes(lay: Layout) -> list[str]: out.append(l) idx[kind] = out - # 2. Заголовок в индексе разошёлся с H1 — истина в файле, хук сохраняем. + # 2. Хук: истина в файле. Если в файле его нет, а в индексе есть — это + # старый формат, и единственный экземпляр надо спасти в файл. for kind, lines in idx.items(): for i, l in enumerate(lines): m = INDEX_ENTRY.match(l) @@ -1105,30 +1646,69 @@ def apply_fixes(lay: Layout) -> list[str]: continue name = Path(m.group(2)).name task = tasks.get(name) - if task and m.group(1) != task["title"]: - lines[i] = entry_line(lay, task["title"], name[:-3], (m.group(3) or "").strip()) - fixed.append(f"{lay.name(kind)}: заголовок синхронизирован с файлом: {name}") + if not task: + continue + idx_hook = (m.group(3) or "").strip() + if not task["hook"] and idx_hook: + upd = meta_updated(task["path"], hook=idx_hook) + if upd is None: + ambiguous.append(f"{name}: хук только в {lay.name(kind)}," + f" а в файле нет мета-строки — перенести некуда") + continue + files[task["path"]] = upd + task["hook"] = idx_hook + fixed.append(f"{name}: хук перенесён из {lay.name(kind)} в мета-строку файла") + if m.group(1) != task["title"] or idx_hook != task["hook"]: + lines[i] = entry_line(lay, task["title"], name[:-3], task["hook"]) + fixed.append(f"{lay.name(kind)}: строка синхронизирована с файлом: {name}") dirty.add(kind) - # 3. Нет строки вовсе / задача не в своей секции. Задачу из спринта не - # трогаем: «в спринте» — решение набора, а не свойство файла. + # 3. Нет строки вовсе / строка в чужом индексе / не в своей секции. Задачу + # из спринта не трогаем: «в спринте» — решение набора, а не свойство файла. for name, task in tasks.items(): where = [k for k in lay.indexes if find_entry_index(idx[k], name[:-3]) is not None] if len(where) > 1: - continue # неоднозначно — человеку + 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], "")) - fixed.append(f"{lay.name(home)}: добавлена строка без хука: {name}") + insert_entry(idx[home], section, entry_line(lay, task["title"], name[:-3], + task["hook"])) + fixed.append(f"{lay.name(home)}: восстановлена строка {name}" + + ("" if task["hook"] else " (в файле нет хука — допиши)")) dirty.add(home) continue kind = where[0] - if kind == "sprint" or kind != home or not task["section"]: + 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]) + entry = idx[kind].pop(ei) + insert_entry(idx[home], section, entry) + 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: @@ -1141,66 +1721,563 @@ def apply_fixes(lay: Layout) -> list[str]: 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()): + upd = meta_updated(task["path"], tags=[*task["tags"], DECOMPOSED_TAG]) + if upd is None: + continue + files[task["path"]] = upd + fixed.append(f"{name}: проставлен тег «{DECOMPOSED_TAG}» — у цели есть задачи") + + plan = Plan() + for path, text in files.items(): + plan.file(path, text) for kind in dirty: - save_index(lay, kind, idx[kind]) - return fixed + plan.index(lay, kind, idx[kind]) + plan.commit() + return fixed, ambiguous # --- init --- +def init_files(lay: Layout, sections: list[str], plan_sections: list[str], + cfg: dict) -> dict[Path, str]: + out: dict[Path, str] = {} + if cfg: + out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False, indent=2) + "\n" + out[lay.index("backlog")] = ( + "# Беклог\n\n" + f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" + "+ строка здесь. Целей тут нет — они в " + f"[{lay.name('plan')}]({lay.name('plan')}): беклог — то, что берут,\n" + "план — то, подо что берут. Порядка внутри секции нет: «что делать\n" + f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n" + "Секции «блокеры» здесь нет и не заводится: блокер — это состояние\n" + "(спринт не может продолжаться ни одной задачей), оно живёт до ответа\n" + "человека, а его следы — вопросами в файлах задач.\n\n" + + "".join(f"## {s}\n\n" for s in sections)) + out[lay.index("plan")] = ( + "# План\n\n" + f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n" + "здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n" + f"Первая секция («{plan_sections[0]}») упорядочена, и порядок обосновывается\n" + "прозой; остальные — тематические кусты без порядка.\n\n" + + "".join(f"## {s}\n\n" for s in plan_sections)) + out[lay.index("sprint")] = empty_sprint(lay) + out[lay.index("rejected")] = ( + "# Ушедшее без реализации\n\n" + "Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n" + f"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n" + "есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n" + "\n") + return out + + +def uniq_sections(raw: str) -> list[str]: + out, seen = [], set() + for s in (s.strip() for s in raw.split(",")): + if s and s.lower() not in seen: + out.append(s) + seen.add(s.lower()) + return out + + def cmd_init(root: Path, a: argparse.Namespace) -> int: if not dir_within_cwd(root): - return fail(f"--dir вне рабочего каталога: {root}") + raise Usage(f"--dir вне рабочего каталога: {root}") cfg = {k: v for k, v in (("items", a.items), ("backlog", a.backlog), ("plan", a.plan), ("sprint", a.sprint), ("rejected", a.rejected)) if v} lay = Layout(root, cfg) if lay.index("backlog").exists(): - return fail(f"{lay.index('backlog')} уже есть — каталог задач заведён") + raise Usage(f"{lay.index('backlog')} уже есть — каталог задач заведён") - def uniq(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 - - sections, plan_sections = uniq(a.sections), uniq(a.plan_sections) + sections, plan_sections = uniq_sections(a.sections), uniq_sections(a.plan_sections) if not sections or not plan_sections: - return fail("пустой список секций") + 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" правилу «спрашиваем немедленно»") - root.mkdir(parents=True, exist_ok=True) lay.items.mkdir(parents=True, exist_ok=True) - if cfg: - write_atomic(root / CONFIG_NAME, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n") - - write_atomic(lay.index("backlog"), - "# Беклог\n\n" - f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" - "+ строка здесь. Целей тут нет — они в " - f"[{lay.name('plan')}]({lay.name('plan')}): беклог — то, что берут,\n" - "план — то, подо что берут. Порядка внутри секции нет: «что делать\n" - f"дальше» отвечает набор спринта. Ведётся скиллом `tasks`.\n\n" - + "".join(f"## {s}\n\n" for s in sections)) - write_atomic(lay.index("plan"), - "# План\n\n" - f"Оглавление целей. Цель — файл `[goal]` в `{lay.cfg['items']}/`; её задачи\n" - "здесь **не перечисляются** — перечень даёт `tasks.py list --goal <слаг>`.\n" - f"Первая секция («{plan_sections[0]}») упорядочена, и порядок обосновывается\n" - "прозой; остальные — тематические кусты без порядка.\n\n" - + "".join(f"## {s}\n\n" for s in plan_sections)) - write_atomic(lay.index("sprint"), empty_sprint(lay)) - write_atomic(lay.index("rejected"), - "# Ушедшее без реализации\n\n" - "Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n" - f"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n" - "есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n" - "\n") + plan = Plan() + for path, text in init_files(lay, sections, plan_sections, cfg).items(): + plan.file(path, text) + plan.commit() print(f"каталог задач заведён: {root}") print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}") if cfg: print(f" имена частей записаны в {root / CONFIG_NAME}") - return 0 + 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"^\*\*(Приоритет|Секция|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 = PLAIN_TYPE + bare = title + if (m := TYPE_PREFIX.match(title)): + kind, bare = m.group(1).strip().lower(), m.group(2).strip() + 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, + "hook": entry.get("hook", ""), "goal": "", + "in_index": path.name in entries, + "translit": translit_ish(path.stem), + "questions_heading": qh, + "source": str(path), + }) + 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": PLAIN_TYPE, "old_section": heading, "section": "", "reason": "", + "hook": "", "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": (a.plan_sections.split(",")[0].strip() or "линия"), + "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_plan": uniq_sections(a.plan_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"(? 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}") + + 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) + plan_sections = pl.get("sections_plan") or uniq_sections(DEFAULT_PLAN_SECTIONS) + known_sections = {s.lower() for s in sections} + known_plan = {s.lower() for s in plan_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_plan: + problems.append(f"цель {g['slug']}: секция «{g.get('section', '')}»" + f" не из плана ({', '.join(plan_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 it.get("goal") and it["goal"] not in goal_slugs: + problems.append(f"{slug}: цель «{it['goal']}» не заведена в карте") + for e in (bad_hook(it.get("hook")), 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, plan_sections, {}).items(): + wr.file(path, text) + backlog_lines = init_files(lay, sections, plan_sections, {})[lay.index("backlog")].splitlines() + plan_lines = init_files(lay, sections, plan_sections, {})[lay.index("plan")].splitlines() + + renames: dict[str, str] = {} + for g in pl.get("goals", []): + title = f"[{GOAL}] {g['title']}" + meta = build_meta(g["section"].lower(), g.get("reason", ""), g.get("hook", ""), + 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## Завершение\n\n" + f"\n") + insert_entry(plan_lines, g["section"].lower(), + entry_line(lay, title, g["slug"], g.get("hook", ""))) + + 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 + kind = (it.get("type") or PLAIN_TYPE).lower() + title = it["title"] if kind == PLAIN_TYPE else f"[{kind}] {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(it["section"].lower(), it.get("reason", ""), it.get("hook", ""), 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("hook", ""))) + + if pl.get("rejected"): + head = init_files(lay, sections, plan_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"(?= CRITERIA_MIN] + print("\nпереходное состояние — назови его в докладе целиком:") + print(f" задач без цели: {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" задач без {CRITERIA_MIN}+ критериев приёмки: {len(no_crit)} —" + 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: @@ -1210,14 +2287,15 @@ def main() -> int: p = sub.add_parser("check", help="согласованность файлов и индексов") p.add_argument("--dir") p.add_argument("--fix", action="store_true", - help="починить безопасный дрейф (секция, заголовок, дубли)") + help="починить безопасный дрейф (секция, заголовок, дубли, хук, дом)") p = sub.add_parser("list", help="список задач и целей") p.add_argument("--dir") p.add_argument("--stale", action="store_true", help="от самой залежавшейся") p.add_argument("--section") p.add_argument("--type", choices=(*TYPES, PLAIN_TYPE)) - p.add_argument("--tag", help="в том числе goal:<слаг>, question, sprint:<слаг>") + p.add_argument("--tag", help="тег или список через запятую (нужны ВСЕ):" + " goal:<слаг>, question, sprint:<слаг>") p.add_argument("--goal", help="задачи одной цели (перечень выводится, а не хранится)") p.add_argument("--index", choices=("backlog", "sprint", "plan", "all")) p.add_argument("--questions", action="store_true", help="только с открытым вопросом") @@ -1241,6 +2319,7 @@ def main() -> int: 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="перенести в другую секцию беклога или часть плана") @@ -1259,11 +2338,17 @@ def main() -> int: 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="+") @@ -1287,9 +2372,24 @@ def main() -> int: 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.add_argument("--plan-sections", dest="plan_sections", default=DEFAULT_PLAN_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, @@ -1301,8 +2401,21 @@ def main() -> int: "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__": - sys.exit(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 + print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr) + sys.exit(EXIT_INTERNAL)