From 1fb006df4a211139de7671c0251e4dbed48d1587 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 3 Aug 2026 15:54:15 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B4=D0=B2?= =?UTF-8?q?=D1=83=D0=BC=D1=8F=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8:=2020=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?,=20=D0=B2=D1=81=D0=B5=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два независимых сабагента на av-dev-pm и av-dev-pipeline. Две находки нашли оба. Главная — моя же перестановка закрытия за коммит сломала reopen и батч. close печатал «дорога назад из git», а reopen искал коммит удаления, которого в новом порядке ещё нет: шаг 11 последний, учёт остаётся незакоммиченным. Проверено прогоном — отказ кодом 2 на свежезакрытой задаче. Тем же грязным деревом ломались rebase и worktree remove в батче: каждая закрывшая задачу ветка уехала бы в провалившиеся. Починено с обеих сторон: reopen берёт текст из HEAD, если коммита удаления нет, а шаг 11 коммитит учёт вторым коммитом. Вторая — канонический пример docs/.pm.json убивал tasks.py. Четыре документа показывали ключ tasks.sections, которого скрипт не знает: неизвестный ключ это код 3 на любой команде. Проект, заведённый по канону дословно, остался бы без работы с задачами, а docs.py при этом печатал «канон соблюдён». Секции живут в заголовках индекса и второго дома не получают. Остальные восемнадцать: init писал конфиг в упразднённый .tasks.json; looks_like_tasks не видел переименованный индекс; урожай спринта терял автотег после sprint close; ответ на вопрос по инструкции оставлял задачу незабираемой; adopt требовал недостижимого зелёного; путь отчёта триажа не переживал archive; review-specs не имел режима для стыка после слияния; три остатка «шаг 9а» несли предкоммитную позицию закрытия; sprint.md отрицал сам себя в пункте «Сделана». Co-Authored-By: Claude Opus 5 (1M context) --- DECISIONS.md | 50 ++++++++++++++ README.md | 3 +- TODO.md | 13 ++++ av-dev-pipeline/agents/review-specs.md | 29 +++++++- .../skills/review-pipeline/SKILL.md | 15 +++-- .../references/project-facts.md | 2 +- av-dev-pipeline/skills/task-batch/SKILL.md | 43 ++++++++---- av-dev-pipeline/skills/task-pipeline/SKILL.md | 20 ++++-- av-dev-pm/skills/canon/SKILL.md | 16 +++-- av-dev-pm/skills/canon/references/canon.md | 9 ++- .../skills/canon/references/skeletons.md | 6 +- av-dev-pm/skills/session/SKILL.md | 16 +++-- .../skills/session/references/cadence.md | 15 +++-- av-dev-pm/skills/session/references/sprint.md | 15 ++++- av-dev-pm/skills/tasks/SKILL.md | 36 ++++++---- av-dev-pm/skills/tasks/references/adopt.md | 3 +- .../skills/tasks/references/task-format.md | 15 +++-- av-dev-pm/skills/tasks/scripts/tasks.py | 67 +++++++++++++++---- 18 files changed, 294 insertions(+), 79 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index f2fb13b..40039f8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -759,3 +759,53 @@ pyrefly: в окружении нет ничего, кроме линтеров, 43. **Проверка не входит ни в один гейт.** CI у репозитория нет, хука нет; запускается руками командой из README. Заводить хук ради двух скриптов, которые правятся раз в месяц, — плата ритуалом без выгоды. + +## 10. Ревью готовых плагинов двумя проходами (2026-08-03) + +### Что было + +Два независимых сабагента `fable` — по одному на `av-dev-pm` и `av-dev-pipeline`. +**20 находок, из них две найдены обоими независимо.** Прошлые три круга ревью +шли по одному проходу на всё; два прохода с разными предметами дали и больший +урожай, и перекрёстное подтверждение самого дорогого дефекта. + +### Что оказалось сломано по существу + +**JJ. Перестановка закрытия за коммит (решение из темы 8) сломала `reopen` и +батч — и это нашли оба прохода.** `close --implemented` печатает «дорога назад: +файл восстанавливается из git», а `reopen` искал **коммит удаления**, которого в +новом порядке ещё нет: шаг 11 идёт последним, и учёт остаётся незакоммиченным. +Проверено прогоном: `reopen` отказывал кодом 2 на свежезакрытой задаче — то есть +в самом вероятном своём применении. Тем же грязным деревом ломался `task-batch`: +`git rebase` и `git worktree remove` отказывают, и **каждая успешно закрывшая +задачу ветка** уезжала бы в провалившиеся. + +Починено с обеих сторон: `reopen` берёт текст из `HEAD`, если коммита удаления +нет, а шаг 11 обязан **коммитить учёт вторым коммитом** — иначе закрытие не +доезжает до основной ветки и опора «`SPRINT.md` под git» остаётся словами. + +**KK. Канонический пример `docs/.pm.json` убивал `tasks.py`.** `canon.md`, +`skeletons.md`, `tasks/SKILL.md` и `adopt.md` показывали ключ `tasks.sections`, +которого скрипт не знает: `_validate_config` отвергает неизвестные ключи кодом 3 +на **любой** команде. Проект, заведённый по канону дословно, остался бы без +работы с задачами целиком — а `docs.py check` при этом печатал «канон соблюдён», +потому что чужой код 3 уходит в «не проверялось». Секции живут в заголовках `##` +индекса и второго дома не получают. + +### Что из этого следует + +44. **Класс находок тот же, что и в прошлые три круга: стыки.** Не новый код, а + место, где один файл ссылается на другой. `sprint.md` в пункте «Сделана» всё + ещё отсылал к порядку, который сам же тремя экранами ниже отменил; три + остатка «шаг 9а» несли **предкоммитную** позицию закрытия; путь отчёта + триажа не переживал `opsx:archive`, хотя по нему сверяют полноту ревью + четверо. +45. **Инструкция, которую нельзя выполнить, выглядит как выполненная.** Ответ на + вопрос по документированной процедуре (снять тег) оставлял задачу + незабираемой, потому что судит **раздел**, а не тег; `canon adopt` требовал + гнать `docs.py check` «до отсутствия дрейфа», недостижимого без нарушения + запрета сочинять цели; урожай спринта, заведённый после `sprint close`, + терял автотег молча. +46. **Два прохода по разным предметам дороже одного, но не вдвое.** Перекрытие + оказалось ровно в одной находке из двадцати — той самой, что подтвердилась + дважды. Практика остаётся: ревью на плагин, а не одно на репозиторий. diff --git a/README.md b/README.md index 6fae42e..27d4f42 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ openspec/ «плагин не работает». **При установке в проект, где лежали проектные копии** скиллов и агентов -(`.claude/skills/{task-pipeline,review-pipeline,task-batch}`, +(`.claude/skills/` — голые `task-pipeline`, `review-pipeline`, `task-batch` +и с префиксом проекта `<проект>-task-pipeline`, `.claude/agents/<проект>-review-*.md`) — снеси их. Две копии одного скилла расходятся, и побеждает та, что короче названа. diff --git a/TODO.md b/TODO.md index 4e18897..c415c35 100644 --- a/TODO.md +++ b/TODO.md @@ -81,6 +81,19 @@ - [x] починены 27 находок ruff и 14 pyrefly; `os` из `tasks.py` ушёл (40, 41) - [x] раздел «Проверка скриптов» в `README.md` +### 1.8 Ревью двумя проходами (тема 10) + +- [x] `reopen` берёт текст из `HEAD`, когда коммита удаления ещё нет (JJ) +- [x] шаг 11 коммитит учёт вторым коммитом; батч проверяет чистоту дерева (JJ) +- [x] фиктивный ключ `tasks.sections` убран из четырёх документов (KK) +- [x] `init` пишет конфиг в `docs/.pm.json`; `looks_like_tasks` его читает +- [x] урожай спринта заводится до `sprint close`, слаг — из его отчёта +- [x] ответ на вопрос опустошает раздел «Вопросы» — во всех трёх местах +- [x] путь отчёта триажа переживает архивацию (5 мест) +- [x] `review-specs` получил режим 3 — стык после слияния +- [x] остальные 12 находок: `sprint.md`, «9а», перечень проектных копий, + параллельность в батче, триаж в финальной сверке, триггеры профиля, 8–12 + ## 2. healthlog — первая боевая проверка - [ ] `canon adopt`; `docs/backlog/` → `docs/tasks/` diff --git a/av-dev-pipeline/agents/review-specs.md b/av-dev-pipeline/agents/review-specs.md index eb9194a..203a201 100644 --- a/av-dev-pipeline/agents/review-specs.md +++ b/av-dev-pipeline/agents/review-specs.md @@ -1,6 +1,6 @@ --- name: review-specs -description: "Сверка изменения с дельта-спеками в обе стороны — spec→code (каждое требование реализовано и подтверждено тестом) и, что важнее, code→spec (поведение, которое код имеет, а спека не заказывала: тихие ветки, самодеятельные дефолты, проглоченные ошибки, отброшенные поля, ретраи «на всякий случай»). Плюс границы спеки — что она не определяет и что пришлось домыслить. Работает в двух режимах: дизайн/спеки ДО кода и код против спек ПОСЛЕ apply. Только чтение." +description: "Сверка изменения с дельта-спеками в обе стороны — spec→code (каждое требование реализовано и подтверждено тестом) и, что важнее, code→spec (поведение, которое код имеет, а спека не заказывала: тихие ветки, самодеятельные дефолты, проглоченные ошибки, отброшенные поля, ретраи «на всякий случай»). Плюс границы спеки — что она не определяет и что пришлось домыслить. Работает в трёх режимах: дизайн/спеки ДО кода, код против спек ПОСЛЕ apply и стык после слияния нескольких задач, когда change уже заархивированы. Только чтение." tools: Read, Grep, Glob, Bash model: opus color: cyan @@ -43,6 +43,12 @@ Development на OpenSpec). Оптика — требования, а не ст намерение, а спека нормирует. Расхождение между proposal и дельтой — само по себе находка. +**Исключение — режим 3 (ниже): живого change нет.** Тогда источник требований — +**актуальные** `openspec/specs//spec.md`, а дельты поднимаются из +архива (`openspec/changes/archive//specs/`) как свидетельство о намерении +каждой слитой задачи. Задание обязано назвать этот режим явно; не названо — +работаешь по режиму 1 или 2 и говоришь в границах покрытия, что change не нашёл. + Дополнительно поднимаешь: `design.md` и `tasks.md` change, затронутые актуальные спеки, инварианты из `CLAUDE.md`. Если тема ещё не перенесена в спеки и живёт только в `docs/architecture.md` — источник истины там, и это фиксируется в @@ -120,6 +126,27 @@ Development на OpenSpec). Оптика — требования, а не ст скажи об этом прямо, с последствием. Такая находка всегда `Действие: развилка`: менять спеку — решение человека. +## Режим 3 — стык после слияния нескольких задач + +Зовётся финальной сверкой `task-batch`: несколько задач влиты в основную ветку, +их change **уже заархивированы**, живой дельта-спеки не существует. Предмет — +**только то, что появилось от слияния**, а не capability целиком заново: каждая +задача уже проверена в своём worktree, и повторение даст те же находки дороже. + +Ищешь ровно три вещи: + +- **отменённое требование** — одна задача его выполнила, соседняя незаметно + сняла; в актуальной спеке требование есть, в интегрированном коде его больше + нет; +- **два описания одного поведения** — два архивных change по-разному нормировали + одно и то же, и актуальная спека собрала из них противоречие; +- **осиротевшее поведение** — код, пришедший от слияния (разрешение конфликта, + правка при rebase), которого не заказывал ни один из change. + +База — интегрированный дифф основной ветки против точки, с которой батч начался. +В границах покрытия скажи прямо: **capability целиком в этом режиме не +сверялась**, проверялись стыки. + ## Чего этот проход принципиально не может поймать - Качество формы решения: код может точно соответствовать спеке и быть плохим. diff --git a/av-dev-pipeline/skills/review-pipeline/SKILL.md b/av-dev-pipeline/skills/review-pipeline/SKILL.md index 5c64215..f8e7944 100644 --- a/av-dev-pipeline/skills/review-pipeline/SKILL.md +++ b/av-dev-pipeline/skills/review-pipeline/SKILL.md @@ -378,7 +378,7 @@ Recall обоих равен длине их источника — это и е поэтому игнорируется; та же находка на предложении стоит абзаца обсуждения. `rubric` живёт **только** в этом профиле. Судить код по критерию, под который он -писался, — корреляция по построению; те же 8–14 свойств уже лежат приёмочными +писался, — корреляция по построению; те же 8–12 свойств уже лежат приёмочными критериями в `tasks.md`. ## Контракт находок @@ -409,10 +409,15 @@ Recall обоих равен длине их источника — это и е - Дефект, проскочивший ревью и всплывший позже, идёт в журнал проекта ([references/review-journal.md](references/review-journal.md)) — сразу, не ретроспективно: теряется именно причина непоймания. -- **Отчёт триажа сохраняется вместе с изменением** (например, в - `openspec/changes//review/`). Он единственное, по чему потом видно, что - было найдено и что из этого не заведено: нулевой урожай при непустом отчёте - виден сразу. +- **Отчёт триажа сохраняется вместе с изменением** — `openspec/changes//review/`. + Он единственное, по чему потом видно, что было найдено и что из этого не + заведено: нулевой урожай при непустом отчёте виден сразу. + **Вместе с изменением он и переезжает:** после `opsx:archive` его адрес — + `openspec/changes/archive//review/`. Кто ищет отчёт после архивации (батч + на финальной сверке, приёмщик на сессии), смотрит **оба** пути; «отчёта нет» + объявляется, только когда пуст и архивный, иначе самый дорогой сценарий + «состав ревью неизвестен, гоняем заново» срабатывает на каждой доведённой + задаче. ## Честный предел diff --git a/av-dev-pipeline/skills/review-pipeline/references/project-facts.md b/av-dev-pipeline/skills/review-pipeline/references/project-facts.md index b7e4c75..b27ac44 100644 --- a/av-dev-pipeline/skills/review-pipeline/references/project-facts.md +++ b/av-dev-pipeline/skills/review-pipeline/references/project-facts.md @@ -26,7 +26,7 @@ | измеренные числа **с провенансом**, поведение внешних систем на самом деле | `docs/research/` | | конвенции прозой и **что уже механизировано** правилом | `docs/conventions/` | | почему решено так, отвергнутые варианты | `docs/adr/` | -| типовые узлы, типовые ложноположительные, вопросы к проходам, недоступно проверке | `docs/review.md`, раздел настройки | +| типовые узлы, типовые ложноположительные, вопросы к проходам, **триггеры профиля**, недоступно проверке | `docs/review.md`, раздел настройки | | прецеденты: воспроизведённые дефекты с оракулом | `docs/review.md`, журнал | | нормативное поведение и дельты изменения | `openspec/specs/`, `openspec/changes//specs/` | diff --git a/av-dev-pipeline/skills/task-batch/SKILL.md b/av-dev-pipeline/skills/task-batch/SKILL.md index 8738822..88d86fc 100644 --- a/av-dev-pipeline/skills/task-batch/SKILL.md +++ b/av-dev-pipeline/skills/task-batch/SKILL.md @@ -46,9 +46,11 @@ description: Проводит несколько задач разом — пл - **Батч не владеет спринтом и целями.** Он сообщает исход по каждой задаче в тех же трёх словах, что и `task-pipeline`: сделана / не доведена / оказалась крупнее задачи. -- **Задачи закрывает пайплайн внутри каждого сабагента**, шагом 9а, вызовом - Skill `av-dev-pm:tasks`. Батч сам записей учёта не трогает: он не знает, чем - кончилась приёмка, и дублировать закрытие ему незачем. Урожай ревью батч +- **Задачи закрывает пайплайн внутри каждого сабагента**, шагом 11 — после + коммита работы и **отдельным коммитом учёта**, вызовом Skill `av-dev-pm:tasks`. + Батч сам записей учёта не трогает: он не знает, чем кончилась приёмка, и + дублировать закрытие ему незачем. Но грязное дерево после сабагента — **его** + проблема: на нём откажут и `rebase`, и `worktree remove` (см. шаг 6). Урожай ревью батч отдаёт списком, а задачи из него заводит тот, кто ведёт задачи проекта. ## Ключевое отличие от одиночного пайплайна @@ -183,7 +185,8 @@ fast-forward. Ветки после вливания удаляются. записаны и куда; изменённые файлы; добавлялся ли нумерованный артефакт и с каким номером; затронутые capability; состояние гейта; **перечень запущенных проходов ревью поимённо с исходом каждого**; **путь к - сохранённому отчёту триажа** (`openspec/changes//review/`); шло ли ревью + сохранённому отчёту триажа** (`openspec/changes//review/`, после + архивации — `openspec/changes/archive//review/`); шло ли ревью инлайн; границы покрытия. Сабагент, упершийся в вопрос, **не останавливает батч**: он записывает вопрос, @@ -204,7 +207,8 @@ fast-forward. Ветки после вливания удаляются. не с чем; это само по себе основание не вливать, пока сабагент не назовёт профиль и не обоснует его по факту изменения. 2. **Сверяй с независимым артефактом, а не с прозой отчёта.** Перечень проходов - бери из **сохранённого отчёта триажа** (`openspec/changes//review/`) — + бери из **сохранённого отчёта триажа** (`openspec/changes//review/` или + `openspec/changes/archive//review/` — задача доведена, change заархивирован) — пайплайн обязан его туда положить. Проза сабагента написана тем же, кто мог проход и пропустить: она подтверждает сама себя. Отчёта триажа на месте нет — считай, что состав неизвестен, и дозапускай ревью целиком. @@ -250,6 +254,11 @@ rebase делается **внутри worktree задачи**, а ff-слиян розданы заранее). Неавтоматический конфликт — **не форсируй**: прерви (`git -C rebase --abort`), оставь ветку и worktree как есть, вынеси это в доклад как нераспознанное пересечение; +- **дерево сабагента обязано быть чистым.** `git -C status --porcelain` + до `rebase`: непусто — значит сабагент не довёл шаг 11 до коммита учёта (или + оставил мусор). Не форсируй и не коммить за него: назови задачу в докладе + недоведённой и оставь ветку с worktree. Молчаливый `rebase` на грязном дереве + всё равно откажет, но с сообщением про unstaged changes — а причина другая; - **ненулевой код `rebase` относится к этой ветке и только к ней.** Прерванный rebase в чужом worktree не трогает ни главное дерево, ни остальные ветки: проверь `git -C status` и `git status` — обе чистые. Уводить весь батч @@ -286,17 +295,25 @@ rebase в файле X», а не «нераспознанное пересеч находки и удорожат триаж. Здесь проверяется **только то, что появилось от слияния**: -- запусти **по одному `review-specs` на каждую затронутую capability**. Набор - назван поимённо и замеров эти проходы не делают, поэтому их допустимо гнать - одним сообщением — это то самое отступление от последовательного режима, - которое правило разрешает. Задание сузь до стыков: не сверять capability - целиком заново, а искать **рассинхрон код↔спека, возникший от слияния** — +- запусти **по одному `review-specs` на каждую затронутую capability**, + последовательно. Параллельно — только если человек попросил явно и назвал + набор: правило и оба его условия живут в + `av-dev-pipeline:review-pipeline`, раздел «Режим запуска», и здесь не + ослабляются. «Замеров не делают» основанием не является; +- **режим у этих проходов особый, и его надо назвать в задании.** Живого change + здесь нет — все заархивированы, дельта-спек не существует. Источник требований + — **актуальные** `openspec/specs//spec.md`, а предмет — стык: требование, которое одна задача выполнила, а соседняя незаметно отменила; два - change, по-разному описавшие одно поведение; + архивных change, по-разному описавшие одно поведение. Скажи проходу это прямо, + иначе он пойдёт искать дельты и не найдёт ничего; - если задачи пересекались по файлам, добавь один `review-architecture` на интегрированный дифф с вопросом «не появился ли второй способ делать то, что уже делается» — именно он возникает, когда две задачи независимо решали - похожее. + похожее; +- **заверши триажем.** Он единственный, кто агрегирует, и без него у находок нет + ни оракула, ни пометки `инлайн`/`развилка` — а следующий абзац на неё + опирается. Прогон из двух проходов без триажа — это сырые находки, выданные за + разобранные. Замечания отрабатывай как одиночный пайплайн: `инлайн` чини сам, `развилка` — вопросом в запись; после правок — снова гейт. @@ -307,7 +324,7 @@ rebase в файле X», а не «нераспознанное пересеч `git worktree prune`. Worktree и ветки **провалившихся** не трогай — они нужны для ручного дожатия. - **Записей учёта батч не трогает** — их правит пайплайн внутри сабагента на - шаге 9а. Батч сообщает исход по каждой задаче; если какой-то сабагент дошёл до + шаге 11. Батч сообщает исход по каждой задаче; если какой-то сабагент дошёл до коммита, но закрытия не сделал (плагина нет, вызов не разрешился), скажи это строкой — иначе задача останется открытой молча. - Доложи кратко: diff --git a/av-dev-pipeline/skills/task-pipeline/SKILL.md b/av-dev-pipeline/skills/task-pipeline/SKILL.md index a62d62b..b3b2713 100644 --- a/av-dev-pipeline/skills/task-pipeline/SKILL.md +++ b/av-dev-pipeline/skills/task-pipeline/SKILL.md @@ -25,7 +25,9 @@ description: Автономно проводит одну задачу чере `av-dev-pm:docs`, `av-dev-pm:tasks`. Короткое имя может разрешиться в устаревшую проектную копию, и это произойдёт молча. - **Проектные копии этих скиллов и агентов удаляются при установке плагина** - (`.claude/skills/{task-pipeline,review-pipeline,task-batch}`, + (`.claude/skills/` — и голые имена `task-pipeline`, `review-pipeline`, + `task-batch`, и с префиксом проекта: `<проект>-task-pipeline`, + `<проект>-review-pipeline`; `.claude/agents/<проект>-review-*.md`). Две копии одного скилла расходятся, и побеждает та, что короче названа. @@ -45,7 +47,7 @@ description: Автономно проводит одну задачу чере проекте есть свой процесс управления задачами — он и решает, что брать. - **Форматом задач.** Пайплайн **не правит индексы руками и не выдумывает путь к скрипту учёта**: он зовёт Skill `av-dev-pm:tasks`, который этим владеет - (шаг 9а). Закрытие как таковое — его работа, и это осознанное решение с + (шаг 11). Закрытие как таковое — его работа, и это осознанное решение с названной ценой: **приёмщик и исполнитель совпали**. Закрытие поэтому **не окончательно** — человек на сессии возвращает задачу `reopen` с причиной, а доклад по критериям приёмки становится единственным, по чему приёмка вообще @@ -259,7 +261,8 @@ description: Автономно проводит одну задачу чере сжатой строкой. Отчёт, из которого исчезло «что проверить было невозможно», превращается в ложное ощущение проверенности. -**Отчёт триажа сохрани вместе с change (`openspec/changes//review/`) — это +**Отчёт триажа сохрани вместе с change (`openspec/changes//review/`; шаг 8 +унесёт его в `openspec/changes/archive//review/` вместе с change) — это обязательно, а не «если удобно».** По нему потом видно, что было найдено и что из этого осталось в урожае. И это единственный **независимый** артефакт о составе прогона: под оркестратором `task-batch` именно по нему сверяют полноту ревью @@ -295,7 +298,7 @@ description: Автономно проводит одну задачу чере основной ветке — коммит идёт прямо в неё; под оркестратором `task-batch` HEAD на ветке задачи в изолированном worktree, и делать дополнительно ничего не нужно. -Сообщение — по-русски, скиллом `commit`, если он подключён (первая строка «что +Сообщение — по-русски, скиллом `av-dev-git:commit`, если он подключён (первая строка «что сделано», тело списком 1–3 пункта, без трейлеров). Одна задача — один осмысленный коммит. @@ -309,6 +312,15 @@ description: Автономно проводит одну задачу чере **Порядок обязателен.** Закрытие удаляет файл задачи; сделанное до коммита оно оставило бы задачу закрытой без единого следа работы, если шаг 10 упадёт. +**Закрытие тоже коммитится — вторым коммитом, тут же.** Удаление +`items/.md` и правка `SPRINT.md` — это правки в рабочем дереве, и оставить +их незакоммиченными нельзя по трём причинам: `task-batch` следом делает `rebase` +и `worktree remove`, а те откажут на грязном дереве; закрытие, не доехавшее до +основной ветки, оставит задачу открытой молча; и опора «`SPRINT.md` под git +показывает, что и когда закрыто» без коммита — пустые слова. Сообщение короткое, +про учёт, а не про работу: `закрыта задача `. Это второй коммит осознанно: +правило «одна задача — один осмысленный коммит» про работу, а учёт — не работа. + Плагина в проекте нет — вызов не разрешится. Тогда **ничего не выдумывай**: скажи в докладе, что учёт задач остаётся за владельцем, и назови исход. diff --git a/av-dev-pm/skills/canon/SKILL.md b/av-dev-pm/skills/canon/SKILL.md index 3631a37..5b5d1d3 100644 --- a/av-dev-pm/skills/canon/SKILL.md +++ b/av-dev-pm/skills/canon/SKILL.md @@ -88,7 +88,9 @@ capability: незаполненный канон это переходное с ### 1. Осмотрись `docs.py check` — он уже назовёт упразднённые слоты с адресом, куда каждый -уезжает. Плюс прочитай: `CLAUDE.md`, корневые `*.md`, `openspec/specs/` (список +уезжает. **Но смотрит он только верхний уровень `docs/`:** упразднённое в корне +репозитория (`BRIEF.md`) и во вложенных каталогах он не назовёт никогда, поэтому +корневые `*.md` читаются глазами. Плюс: `CLAUDE.md`, `openspec/specs/` (список capability), `openspec/config.yaml`. ### 2. Составь карту @@ -135,9 +137,15 @@ capability), `openspec/config.yaml`. пропускаться. Передай ему базу диффа (`--base`) той же переменной, что и остальным шагам гейта: без неё сверка миграций со схемой не гоняется вовсе. Пример строки покажи человеку — гейт принадлежит проекту, и правит его он; -8. `docs.py check` — до **отсутствия дрейфа**. Замечания (незаполненные - плейсхолдеры, слабое упоминание capability) остаются: незаполненный канон это - объявленное переходное состояние из шага 5, а не отказ. +8. `docs.py check` — до **отсутствия дрейфа раскладки**. Замечания + (незаполненные плейсхолдеры, слабое упоминание capability) остаются: + незаполненный канон это объявленное переходное состояние из шага 5, а не + отказ. **Пункт «задачи без цели» из вложенной проверки `tasks.py` тоже + остаётся** и зелёным на этом шаге не станет: цели не сочиняются адаптацией + (запрет в [tasks/references/adopt.md](../tasks/references/adopt.md)), их + проставляет человек порциями переоценки на первой сессии. Пересчитай эти + пункты в докладе переходного состояния — не выдавай их за поломку и не + молчи о них. ### 5. Объяви переходное состояние diff --git a/av-dev-pm/skills/canon/references/canon.md b/av-dev-pm/skills/canon/references/canon.md index d47c877..39d7938 100644 --- a/av-dev-pm/skills/canon/references/canon.md +++ b/av-dev-pm/skills/canon/references/canon.md @@ -287,7 +287,7 @@ kebab-case. "canon": 1, "migrations": "internal/store/migrations", "tasks": { - "sections": ["ядро", "инфра"] + "backlog": "INDEX.md" } } ``` @@ -297,6 +297,13 @@ kebab-case. каталога миграций, если БД есть; по нему `docs.py` делает сверку с `database.md`. `tasks` — настройки каталога задач, переехавшие сюда из прежнего `/.tasks.json`: **один конфиг на весь канон, а не по одному на каталог**. +Внутри `tasks` — **только имена файлов и заголовков** (`items`, `backlog`, +`plan`, `sprint`, `rejected`, `sprint_section`, `questions_heading`, +`criteria_heading`, `oracle_word`), и ключ пишется, лишь когда имя отличается от +умолчания. **Секций беклога здесь нет:** их дом — заголовки `##` самого индекса, +и второй список сразу разошёлся бы с первым. Неизвестный ключ `tasks.py` +отвергает кодом 3, поэтому лишнее слово в этом объекте останавливает работу с +задачами целиком. Ключей будет больше по мере роста проверок; неизвестный ключ `docs.py` игнорирует, отсутствующий — считает «проверка неприменима» и говорит об этом diff --git a/av-dev-pm/skills/canon/references/skeletons.md b/av-dev-pm/skills/canon/references/skeletons.md index 17b8a87..996fe56 100644 --- a/av-dev-pm/skills/canon/references/skeletons.md +++ b/av-dev-pm/skills/canon/references/skeletons.md @@ -366,5 +366,7 @@ severity стоит здесь, а не выводится каждым прох } ``` -Плюс `"migrations": "<путь>"`, если есть БД, и `"tasks": {"sections": [...]}`, -если секции беклога отличаются от умолчания. +Плюс `"migrations": "<путь>"`, если есть БД. Ключ `"tasks"` заводится **только** +когда имя файла или заголовка отличается от умолчания (`{"backlog": +"INDEX.md"}`); секций беклога в нём нет — их дом заголовки `##` индекса. Состав +ключей — [canon.md](canon.md). diff --git a/av-dev-pm/skills/session/SKILL.md b/av-dev-pm/skills/session/SKILL.md index 62d04d7..7762d39 100644 --- a/av-dev-pm/skills/session/SKILL.md +++ b/av-dev-pm/skills/session/SKILL.md @@ -142,14 +142,17 @@ python3 $tk sprint close --dir D # конец спринта; --d python3 $tk reopen <слаг> --dir D --reason … # приёмка не сошлась после закрытия ``` -`D` — каталог задач проекта; цепочка его разрешения и вызов из чужого контекста -описаны в скилле `tasks` («Переносимость»). **Коды выхода** — там же: 1 это -дрейф в беклоге, 3 это «каталога нет», и ветвиться на них надо по-разному. +`D` — каталог задач проекта, по канону всегда `docs/tasks`; `--dir` передаётся +явно каждой командой. Вызов из чужого контекста описан в скилле `tasks` +(«Переносимость»). **Коды выхода** — там же: 1 это дрейф в беклоге, 3 это +«каталога нет», и ветвиться на них надо по-разному. **Слаг спринта заводит `sprint start`** (по умолчанию — дата) и пишет его в -`SPRINT.md`; всё заведённое при открытом спринте помечается `sprint:<слаг>` +`SPRINT.md`; всё заведённое **при открытом спринте** помечается `sprint:<слаг>` автоматически. Поэтому «первая порция — урожай прошедшего спринта» работает без -чьей-либо памяти. +чьей-либо памяти — но ровно до команды `sprint close`, которая `SPRINT.md` +очищает. Отсюда порядок: **урожай заводится до закрытия, слаг для сессии берётся +из отчёта `sprint close`** ([references/sprint.md](references/sprint.md)). Правки задач делаются мутациями (`edit`, `move`, `close`), а не редактором: руками правится только тело файла. Это правило скилла `tasks`, здесь оно не @@ -165,7 +168,8 @@ python3 $tk reopen <слаг> --dir D --reason … # приёмка не со пайплайн физически не мог. Теперь мост есть, и защита у трёх обходов ниже — **только текстовая**. Опоры, которые остались настоящими: -- **отчёт триажа** в `openspec/changes//review/` — независимый артефакт, +- **отчёт триажа** в `openspec/changes/archive//review/` (до архивации — + `changes//review/`) — независимый артефакт, написанный ревью, а не исполнителем; по нему сверяют состав прогона и урожай; - **`SPRINT.md` под git** — `git log -p` показывает, что и когда было закрыто; - **`reopen <слаг> --reason`** — закрытие не окончательно. Приёмка человеком на diff --git a/av-dev-pm/skills/session/references/cadence.md b/av-dev-pm/skills/session/references/cadence.md index 9542a04..b35a865 100644 --- a/av-dev-pm/skills/session/references/cadence.md +++ b/av-dev-pm/skills/session/references/cadence.md @@ -21,9 +21,11 @@ 2. **Сформулируй развилку** с вариантами и последствием каждого, рекомендация — первым вариантом. 3. **Вынеси пачкой** через `AskUserQuestion`, не больше трёх за раз. -4. **Ответ записывается в тело задачи**, тег снимается `edit --rm-tag - question`, **хук переписывается**: «Решено: …» на вопрос «почему это лежит в - беклоге» уже не отвечает. +4. **Ответ записывается в тело задачи, раздел «Вопросы» опустошается**, тег + снимается `edit --rm-tag question`, **хук переписывается**: «Решено: + …» на вопрос «почему это лежит в беклоге» уже не отвечает. Опустошение + раздела — не уборка, а условие взятия: правило и причина в скилле `tasks`, + [references/task-format.md](../../tasks/references/task-format.md). **Вопросы на задачах-кандидатах разбираются вне очереди порции** — здесь же, на этой сессии, даже если сама задача в порцию переоценки не попала. Иначе правило @@ -69,9 +71,10 @@ задачи, заведённые за спринт; при урожае в 15 это две-три порции. - **Отбор порций по порядку:** 1. **урожай спринта** — `list --tag sprint:<слаг>`: свежезаведённое ещё не - проходило ни одной проверки на нужность. Слаг спринта берётся из - `SPRINT.md` (его завёл `sprint start`), тег на задачах проставлен - автоматически при заведении — руками не метят и не вспоминают; + проходило ни одной проверки на нужность. Тег на задачах проставлен + автоматически при заведении — руками не метят и не вспоминают. **Слаг + берётся из отчёта `sprint close`, а не из `SPRINT.md`:** сессия идёт после + закрытия, а закрытие этот файл очищает; 2. дальше **по залежалости** — `list --stale`; 3. по потребности — одна секция целиком, один тег (партия ревью), одна цель (`--goal`), список от пользователя. diff --git a/av-dev-pm/skills/session/references/sprint.md b/av-dev-pm/skills/session/references/sprint.md index 3f58f21..b000f05 100644 --- a/av-dev-pm/skills/session/references/sprint.md +++ b/av-dev-pm/skills/session/references/sprint.md @@ -11,8 +11,9 @@ след. - **Сделана** — по определению готовности ниже. `close --implemented`: - файл и строка удаляются, следом остаётся коммит. **Закрывает владелец спринта - и только после вердикта приёмки** — см. «Кто и когда закрывает». + файл и строка удаляются, следом остаётся коммит. **Закрывает агент-оркестратор + последним шагом пайплайна, после коммита; приёмка человеком идёт позже и + отменяется `reopen`** — см. «Кто и когда закрывает». - **Вышла из спринта** — `sprint drop --reason …`: возвращается в беклог с вопросом в файле и **без живого незакоммиченного предложения** — иначе при следующем взятии оно столкнётся с новым. Наработки, которые жалко терять, @@ -35,6 +36,13 @@ сессии поднимается одной командой `list --tag sprint:<слаг>`. Спринт, закрытый без этого шага, оставляет находки жить в отчётах — то есть нигде. +**Порядок здесь обязателен: урожай заводится ДО команды `sprint close`.** +Автотег ставится по слагу из `SPRINT.md`, а `sprint close` этот файл очищает; +заведённое после команды остаётся без тега и в первую порцию следующей сессии +не попадёт — молча, потому что пустой `list --tag` выглядит как «урожая не +было». Если так уже вышло, тег ставится руками: `add … --tag sprint:<слаг>`, +слаг берётся из отчёта `sprint close`. + **Провал спринта.** Сработал блокер — спринт распускается (`sprint close --dissolve --reason …`), недоделанное возвращается в беклог, новый набор делается после ответа человека. Спринт не «ждёт»: ждать может человек, а @@ -99,7 +107,8 @@ 2. **Принимает человек на сессии, а не отдельный агент.** Декорреляция исполнителя и приёмщика в момент закрытия **снята** (решение о снятии и его цена — в `SKILL.md`, «Стимулы»). Опоры остались три: сохранённый отчёт триажа - в `openspec/changes//review/` — независимый артефакт, `SPRINT.md` под git + в `openspec/changes/archive//review/` (до архивации — `changes//review/`) + — независимый артефакт, `SPRINT.md` под git и `reopen`. Переоценка на сессии и есть момент, когда критерии видит не исполнитель. 3. **Расхождение — дефект критериев.** Приёмщик правит критерии и возвращает diff --git a/av-dev-pm/skills/tasks/SKILL.md b/av-dev-pm/skills/tasks/SKILL.md index 657fd6c..a189480 100644 --- a/av-dev-pm/skills/tasks/SKILL.md +++ b/av-dev-pm/skills/tasks/SKILL.md @@ -95,8 +95,9 @@ docs/tasks/ скрипт запретит. Единственная оговорка: цель без задач неотличима — «ещё не разобрана» или «всё закрыто». Различает **тег `decomposed`** в мета-строке цели: он ставится, когда цель разложена на задачи. Тег, а не строка в теле — - потому что проверяется механически: `check` требует его у пустой цели, а - `check --fix` сам проставляет его цели, у которой задачи есть. + потому что проверяется механически: `check` **напоминает** о нём у пустой цели + (замечанием, не ошибкой — неразобранная цель это законное состояние), а `check + --fix` сам проставляет его цели, у которой задачи есть. - **`[goal]` и `[epic]` — разные вещи.** Цель **постоянна**: живёт, пока живёт направление. Эпик **временен**: это задача, которая не мерджится целиком, её разбирают, и он исчезает. Два срока жизни под одним словом разъезжаются, @@ -163,9 +164,11 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап правки**, даже если правил мутациями: дрейф мог накопиться раньше. Накопившееся чини `check --fix` — он детерминированно правит то, где истина однозначна (секция, заголовок, дубли, хук из индекса в файл, строка в чужом индексе, -пометка `decomposed` у цели с задачами), а неоднозначное (ссылка на исчезнувший -файл, задача сразу в двух индексах) печатает отдельной пометкой -`НЕОДНОЗНАЧНО` — это тебе, и это идёт строкой доклада. +пометка `decomposed` у цели с задачами), а неоднозначное (задача сразу в двух +индексах, нечего восстанавливать) печатает отдельной пометкой `НЕОДНОЗНАЧНО` — +это тебе, и это идёт строкой доклада. **Ссылка на исчезнувший файл в пометку не +попадает:** `--fix` её просто не трогает, и она остаётся `ОШИБКА` обычного +`check` — то есть видна, но в докладе её надо назвать отдельно. `--fix` правит **и файлы** — ровно в двух местах, где источник ровно один и выбирать не из чего: хук, оставшийся только в индексе, переезжает в мета-строку, @@ -250,7 +253,8 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап `question` (`edit --add-tag question`), иначе он не виден ни `list --questions`, ни правилу «задача с открытым вопросом в набор не берётся»; - **тег, который некому снять** — `question` после ответа снимается `edit - --rm-tag question` вместе с записью ответа в тело; + --rm-tag question` вместе с записью ответа в тело **и опустошением раздела + «Вопросы»**: судит раздел, а не тег (`references/task-format.md`); - **свойство репозитория в рамках** — номер миграции, хеш, версия зависимости: в лежалой задаче протухает молча и становится ложной рамкой. Снимается; снимок берётся при постановке, а не при заведении; @@ -265,15 +269,19 @@ python3 $tk adopt scan --from … | apply --plan … # разовая адап просто каталог markdown. Текст задач — русский (язык документации проекта); зашита только латиница слага. OpenSpec ему тоже не нужен. -- **Каталог задач — `docs/tasks`, жёстко.** Цепочки разрешения нет: раскладка - канона одинакова во всех проектах, и искать больше нечего. Каталога нет — код - 3 и вопрос человеку; `init` заводит его **только** когда проект действительно - новый, а перевод чужой раскладки делает `av-dev-pm:canon`. -- **Настройки живут в `docs/.pm.json`**, ключ `tasks`: секции беклога и имена - индексов, если они отличаются от умолчания. Один конфиг на весь канон, а не по - одному на каталог. +- **Каталог задач — `docs/tasks`, жёстко**, и `--dir` передаётся явно всегда: + раскладка канона одинакова во всех проектах, и искать больше нечего. Каталога + нет — код 3 и вопрос человеку; `init` заводит его **только** когда проект + действительно новый, а перевод чужой раскладки делает `av-dev-pm:canon`. + У скрипта поиск вверх по дереву ещё жив — он для непереведённых проектов, и + полагаться на него скилл не должен: молча найденный чужой каталог это дрейф. +- **Настройки живут в `docs/.pm.json`**, ключ `tasks`: **имена** файлов и + заголовков, и только если они отличаются от умолчания. Один конфиг на весь + канон, а не по одному на каталог. Неизвестный ключ — код 3 на любой команде, + так что лишнее слово в этом объекте останавливает работу с задачами целиком. - **Секции беклога** берутся из заголовков `##` индекса как есть; их количество - и названия — дело проекта (умолчание `ядро` / `инфра`). + и названия — дело проекта (умолчание `ядро` / `инфра`). **В конфиге их нет** — + второй список разошёлся бы с заголовками молча. ### Вызов из другого плагина diff --git a/av-dev-pm/skills/tasks/references/adopt.md b/av-dev-pm/skills/tasks/references/adopt.md index 5d3fa02..949429c 100644 --- a/av-dev-pm/skills/tasks/references/adopt.md +++ b/av-dev-pm/skills/tasks/references/adopt.md @@ -64,7 +64,8 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \ 1. **Осмотрись.** Где лежат задачи, план, заметки. Каталог задач по канону — всегда `docs/tasks`. Секции беклога (`--sections`) — по умолчанию `ядро,инфра`; если у проекта деление другое по существу, оно называется - здесь, а не подгоняется под умолчание, и уезжает в `docs/.pm.json`. + здесь, а не подгоняется под умолчание, и становится **заголовками `##` + индекса** — их единственным домом. В `docs/.pm.json` секции не пишутся. 2. **`adopt scan`** по всем источникам разом. Один прогон, одна карта: два прохода дадут два несогласованных состояния. 3. **Заполни карту**: `slug` (английский), `section`, `goal` у каждой записи; diff --git a/av-dev-pm/skills/tasks/references/task-format.md b/av-dev-pm/skills/tasks/references/task-format.md index 6f8b10c..fa209ed 100644 --- a/av-dev-pm/skills/tasks/references/task-format.md +++ b/av-dev-pm/skills/tasks/references/task-format.md @@ -92,9 +92,15 @@ отсутствие при непустом разделе — замечание, а не лазейка. Тег без раздела тоже отказ, но с другим советом: либо вопрос записан не туда, либо тег пора снять. -Ответ записывается в тело, тег снимается `edit --rm-tag question`, а хук -переписывается: «Решено: …» на вопрос «почему это лежит в беклоге» уже не -отвечает. +**Ответ на вопрос — три правки, и первая обязательна.** Раздел «Вопросы» +опустошается: ответ переезжает в тело решением, а не остаётся вопросом рядом с +ответом. Затем снимается тег (`edit --rm-tag question`) и переписывается +хук: «Решено: …» на вопрос «почему это лежит в беклоге» уже не отвечает. + +**Порядок именно такой, потому что судит раздел, а не тег.** `sprint take` +смотрит в непустой раздел и откажет взять задачу даже со снятым тегом, а `check` +на снятый тег при непустом разделе посоветует тег вернуть. Снять тег, не +опустошив раздел, — значит закольцевать себя между двумя советами. ## Файл цели @@ -119,7 +125,8 @@ - **Тег `decomposed`** отличает «цель ещё не разобрана» от «все её задачи закрыты» — два состояния, у которых снаружи один и тот же признак: задач нет. Пометка именно **тегом**, а не строкой в теле: только так она проверяется. - `check` требует его у цели без задач, `check --fix` сам ставит его цели, у + `check` напоминает о нём у цели без задач замечанием — неразобранная цель + законна и зелёного прогона не ломает; `check --fix` сам ставит его цели, у которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть. - Цель живёт в `PLAN.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`. diff --git a/av-dev-pm/skills/tasks/scripts/tasks.py b/av-dev-pm/skills/tasks/scripts/tasks.py index b5b3a9c..31da9ba 100755 --- a/av-dev-pm/skills/tasks/scripts/tasks.py +++ b/av-dev-pm/skills/tasks/scripts/tasks.py @@ -328,6 +328,19 @@ def _validate_config(data: dict, path: Path) -> dict: return data +def config_home(root: Path) -> Path | None: + """Откуда настройки читаются на самом деле — и куда, значит, слать чинить. + + Порядок тот же, что в `load_config`: `docs/.pm.json` побеждает. Без этой + функции сообщения об ошибке звали править `.tasks.json`, который при живом + `.pm.json` вообще не читается. + """ + pm = (root / PM_CONFIG_REL).resolve() + if pm.is_file(): + return pm + return root / CONFIG_NAME if (root / CONFIG_NAME).is_file() else None + + def config_problems(lay: Layout) -> list[str]: """Каждый путь из конфига сверяется с диском ДО любых выводов о задачах. @@ -335,8 +348,7 @@ def config_problems(lay: Layout) -> list[str]: check обвинять невиновных: «ссылка на несуществующий файл», хотя файл на месте, а мимо смотрит конфиг. """ - src = lay.root / CONFIG_NAME - where = str(src) if src.is_file() else "умолчания (файла .tasks.json нет)" + where = str(config_home(lay.root) or "умолчания (конфига нет)") out = [] if not lay.items.is_dir(): out.append(f"{where}: items = «{lay.cfg['items']}» → {lay.items} — каталога нет") @@ -350,7 +362,11 @@ def config_problems(lay: Layout) -> list[str]: def looks_like_tasks(p: Path) -> bool: if (p / CONFIG_NAME).is_file(): return True - return (p / DEFAULTS["backlog"]).is_file() + try: # индекс мог быть переименован через docs/.pm.json + name = load_config(p).get("backlog", DEFAULTS["backlog"]) + except Env: + name = DEFAULTS["backlog"] + return (p / name).is_file() def resolve_layout(explicit: str | None) -> Layout: @@ -626,7 +642,8 @@ def check(lay: Layout, fix: bool = False) -> int: for p in problems: print(f"КОНФИГ {p}") print("\nсперва конфиг: пока он мимо, всё остальное диагностируется ложно" - f" (правь {lay.root / CONFIG_NAME} или переименуй файлы)") + f" (правь {config_home(lay.root) or lay.root / CONFIG_NAME}" + f" или переименуй файлы)") return EXIT_ENV if fix: @@ -1343,16 +1360,24 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int: def git_deleted_text(path: Path) -> str | None: - """Текст файла из коммита, в котором его удалили. Возврат закрытой задачи - возможен ровно потому, что удаление зафиксировано историей.""" + """Текст закрытой задачи из истории git. + + Два источника, и второй обязателен. Коммит удаления — обычный случай: + закрытие уже уехало в историю. Но пайплайн закрывает задачу **последним + шагом**, и между удалением файла и коммитом учёта есть окно, в котором + коммита удаления ещё нет, а текст лежит в `HEAD`. Без второго источника + `reopen` отказывал бы ровно на свежезакрытой задаче — то есть в самом + вероятном своём применении. + """ try: sha = subprocess.run(["git", "log", "--diff-filter=D", "--format=%H", "-n", "1", "--", str(path)], capture_output=True, text=True).stdout.strip() - 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 + for rev in ([f"{sha}^"] if sha else []) + ["HEAD"]: + out = subprocess.run(["git", "show", f"{rev}:{path}"], + capture_output=True, text=True) + if out.returncode == 0: + return out.stdout + return None except FileNotFoundError: return None @@ -1622,6 +1647,8 @@ def cmd_sprint_close(lay: Layout, a: argparse.Namespace) -> int: if slug: print(f" урожай спринта — `tasks.py list --tag {SPRINT_TAG}{slug}`:" f" завести найденное по ходу обязан закрывающий спринт, а не пайплайн") + print(f" автотег снят вместе со спринтом: заводимое СЕЙЧАС метится только" + f" вручную — `add … --tag {SPRINT_TAG}{slug}`, иначе выпадет из урожая") print(" дальше — сессия: разбор вопросов → разбор спринта → переоценка → новый набор") return EXIT_OK @@ -1782,7 +1809,20 @@ 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" + # Дом настроек один — `docs/.pm.json`, ключ `tasks`. Писать в + # `.tasks.json` при живом `.pm.json` значит писать туда, откуда никто + # не читает: load_config его в этом случае игнорирует. + pm = (lay.root / PM_CONFIG_REL).resolve() + if pm.is_file(): + data = _read_json(pm) + section = data.get("tasks") or {} + if not isinstance(section, dict): + raise Env(f"{pm}: ключ «tasks» — ожидался объект с настройками") + data["tasks"] = {**section, **cfg} + out[pm] = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + else: + out[lay.root / CONFIG_NAME] = json.dumps(cfg, ensure_ascii=False, + indent=2) + "\n" out[lay.index("backlog")] = ( "# Беклог\n\n" f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" @@ -1848,7 +1888,8 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int: print(f"каталог задач заведён: {root}") print(f" секции беклога: {', '.join(sections)}; части плана: {', '.join(plan_sections)}") if cfg: - print(f" имена частей записаны в {root / CONFIG_NAME}") + pm = (root / PM_CONFIG_REL).resolve() + print(f" имена частей записаны в {pm if pm.is_file() else root / CONFIG_NAME}") return EXIT_OK