diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index aad088e..d35356b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ { "name": "av-dev", "source": "./av-dev", - "description": "Личный процесс разработки одним плагином: форма раскладки, документы проекта, учёт работ и работа по задачам. Форму держит скилл canon — три операции одной машиной сравнения (check, adopt, upgrade) со скриптом docs.py и общим журналом версий, по которому повышается вся раскладка, включая каталог задач; имя без префикса, потому что форма общая у всех частей. Документы — канон раскладки (CLAUDE.md плюс docs/: паспорт, архитектура, схема БД, безопасность, конвенции, разведка, ADR, журнал ревью), заведение нового проекта интервью в doc-init, ведение содержимого по ходу разработки в doc-sync, суд о смысловом здоровье в doc-healthcheck двумя агентами разом. Учёт — задачи и цели каталогом markdown-файлов в task-track, у записи тип (goal, feature, fix, chore, research), и тип решает её схему; приоритет расставляет интерактивный task-groom порядком строк. Работа — одна задача от постановки до закрытия скиллом code-resolve: точка входа одна, сценария три (решение циклом SDD с чекпоинтом, разведка без кода, обслуживание без change), и выбирает сценарий сам скилл, прочитав постановку; плюс конвейер ревью code-review с детерминированным гейтом, сверкой со спеками и обязательным триажем, плюс code-openspec, который заводит и проверяет openspec/. Версия раскладки и настройки живут в .av-dev.toml в корне репозитория. Части включаются следом в проекте, а не установкой: нет docs/ — документы не ведутся, нет каталога задач — учёт остаётся владельцу, нет openspec/ — его заводит сценарий решения. Коммиты — отдельный плагин av-dev-git." + "description": "Личный процесс разработки одним плагином: форма раскладки, документы проекта, учёт работ и работа по задачам. Форму держит скилл canon — три операции одной машиной сравнения (check, adopt, upgrade) со скриптом docs.py и общим журналом версий, по которому повышается вся раскладка, включая каталог задач; имя без префикса, потому что форма общая у всех частей. Документы — канон раскладки (CLAUDE.md плюс docs/: паспорт, архитектура, схема БД, безопасность, конвенции, разведка, ADR, журнал ревью), заведение нового проекта интервью в doc-init, ведение содержимого по ходу разработки в doc-sync, суд о смысловом здоровье в doc-healthcheck двумя агентами разом. Учёт — задачи каталогом markdown-файлов в task-track: у задачи тип (feature, fix, chore, research), задающий её схему, а у проекта — стадия (build — беклог это план стройки от базы к деталям, порядок строк значит зависимость; support — очередь правок, порядок значит важность); очередь расставляет интерактивный task-groom порядком строк. Работа — одна задача от постановки до закрытия скиллом code-resolve: точка входа одна, сценария три (решение циклом SDD с чекпоинтом, разведка без кода, обслуживание без change), и выбирает сценарий сам скилл, прочитав постановку; плюс конвейер ревью code-review с детерминированным гейтом, сверкой со спеками и обязательным триажем, плюс code-openspec, который заводит и проверяет openspec/. Версия раскладки и настройки живут в .av-dev.toml в корне репозитория. Части включаются следом в проекте, а не установкой: нет docs/ — документы не ведутся, нет каталога задач — учёт остаётся владельцу, нет openspec/ — его заводит сценарий решения. Коммиты — отдельный плагин av-dev-git." }, { "name": "av-dev-git", diff --git a/README.md b/README.md index d4d0072..5c87ff1 100644 --- a/README.md +++ b/README.md @@ -241,11 +241,22 @@ claude plugin install av-dev-git@av-dev-skills --scope project } ``` -**При установке в проект, где лежали проектные копии** скиллов и агентов -(`.claude/skills/` — голые `task-pipeline`, `review-pipeline` -и с префиксом проекта `<проект>-task-pipeline`, -`.claude/agents/<проект>-review-*.md`) — снеси их. Две копии одного скилла -расходятся, и побеждает та, что короче названа. +**При установке в проект, где лежали проектные копии** скиллов и агентов — +снеси их. Перечень полный, и он же дом: скиллы носят его помеченной копией, +потому что предупреждают о том же в момент работы. + + + +Голые имена в `.claude/skills/`: `resolve`, `review`, а у проектов прошлого +поколения ещё `task-pipeline`, `review-pipeline`, `task-batch`. С префиксом +проекта: `<проект>-task-pipeline`, `<проект>-review-pipeline`. Агенты: +`.claude/agents/<проект>-review-*.md`. + +Две копии одного скилла расходятся, и побеждает та, что **короче названа**: +короткое имя разрешится в устаревшую проектную копию молча и без признаков +подмены. + + ## Обновление @@ -569,7 +580,7 @@ Glob разводит две половины: коммит, трогающий **Что судит `decisions.py`.** Номер записи журнала обязан быть один: буквенные метки решений выродились до пятибуквенных и разъехались молча — `АЕАКЛ` означала и тему 53, и тему 65. И подпись ссылки обязана называть свою цель: в -`[тема 5](05-project-start-lifecycle.md)` номер записан дважды, словом и путём, — +`[тема 5](decisions/05-project-start-lifecycle.md)` номер записан дважды, словом и путём, — дубль неизбежный (иначе ссылку не прочитать, не открыв) и потому сверяемый, как всякая копия. diff --git a/av-dev/.claude-plugin/plugin.json b/av-dev/.claude-plugin/plugin.json index 07f8c84..15df467 100644 --- a/av-dev/.claude-plugin/plugin.json +++ b/av-dev/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "av-dev", - "description": "Личный процесс разработки одним плагином: форма раскладки, документы проекта, учёт работ и работа по задачам. Форму держит скилл canon — три операции одной машиной сравнения (check, adopt, upgrade) со скриптом docs.py и общим журналом версий, по которому повышается вся раскладка, включая каталог задач; имя без префикса, потому что форма общая у всех частей. Документы — канон раскладки (CLAUDE.md плюс docs/: паспорт, архитектура, схема БД, безопасность, конвенции, разведка, ADR, журнал ревью), заведение нового проекта интервью в doc-init, ведение содержимого по ходу разработки в doc-sync, суд о смысловом здоровье в doc-healthcheck двумя агентами разом. Учёт — задачи и цели каталогом markdown-файлов в task-track, у записи тип (goal, feature, fix, chore, research), и тип решает её схему; приоритет расставляет интерактивный task-groom порядком строк. Работа — одна задача от постановки до закрытия скиллом code-resolve: точка входа одна, сценария три (решение циклом SDD с чекпоинтом, разведка без кода, обслуживание без change), и выбирает сценарий сам скилл, прочитав постановку; плюс конвейер ревью code-review с детерминированным гейтом, сверкой со спеками и обязательным триажем, плюс code-openspec, который заводит и проверяет openspec/. Версия раскладки и настройки живут в .av-dev.toml в корне репозитория. Части включаются следом в проекте, а не установкой: нет docs/ — документы не ведутся, нет каталога задач — учёт остаётся владельцу, нет openspec/ — его заводит сценарий решения. Коммиты — отдельный плагин av-dev-git.", + "description": "Личный процесс разработки одним плагином: форма раскладки, документы проекта, учёт работ и работа по задачам. Форму держит скилл canon — три операции одной машиной сравнения (check, adopt, upgrade) со скриптом docs.py и общим журналом версий, по которому повышается вся раскладка, включая каталог задач; имя без префикса, потому что форма общая у всех частей. Документы — канон раскладки (CLAUDE.md плюс docs/: паспорт, архитектура, схема БД, безопасность, конвенции, разведка, ADR, журнал ревью), заведение нового проекта интервью в doc-init, ведение содержимого по ходу разработки в doc-sync, суд о смысловом здоровье в doc-healthcheck двумя агентами разом. Учёт — задачи каталогом markdown-файлов в task-track: у задачи тип (feature, fix, chore, research), задающий её схему, а у проекта — стадия (build — беклог это план стройки от базы к деталям, порядок строк значит зависимость; support — очередь правок, порядок значит важность); очередь расставляет интерактивный task-groom порядком строк. Работа — одна задача от постановки до закрытия скиллом code-resolve: точка входа одна, сценария три (решение циклом SDD с чекпоинтом, разведка без кода, обслуживание без change), и выбирает сценарий сам скилл, прочитав постановку; плюс конвейер ревью code-review с детерминированным гейтом, сверкой со спеками и обязательным триажем, плюс code-openspec, который заводит и проверяет openspec/. Версия раскладки и настройки живут в .av-dev.toml в корне репозитория. Части включаются следом в проекте, а не установкой: нет docs/ — документы не ведутся, нет каталога задач — учёт остаётся владельцу, нет openspec/ — его заводит сценарий решения. Коммиты — отдельный плагин av-dev-git.", "author": { "name": "Anton Vakhrushev", "email": "anwinged@gmail.com" diff --git a/av-dev/agents/doc-wording.md b/av-dev/agents/doc-wording.md index 8f9fd5f..b9d4912 100644 --- a/av-dev/agents/doc-wording.md +++ b/av-dev/agents/doc-wording.md @@ -1,6 +1,6 @@ --- name: doc-wording -description: "Вычитка языка документов проекта по информационному стилю — паспорт, архитектура, конвенции, безопасность, решения ADR, записки разведки, CLAUDE.md. Смотрит отглагольные существительные и страдательный залог, оценку без факта, стоп-слова и канцелярит, «одна мысль — одно предложение», англицизм при живом русском слове, жаргон и метафоры вместо прямого называния, термин, которого нет в документах проекта, транслит в имени файла. Отдаёт готовые формулировки на замену и ничего не правит сам. Записи каталога задач вычитывает отдельный агент task-wording, их форму — task-form. Зовётся по названной пачке правленных документов, а не на весь канон: последним шагом синка документации (av-dev:doc-sync), шагом заведения проекта (av-dev:doc-init), шагами adopt и upgrade скилла av-dev:canon. Скилл healthcheck его не зовёт — там сверка утверждений, а не языка. Только чтение." +description: "Вычитка языка документов проекта по информационному стилю — паспорт, архитектура, конвенции, безопасность, решения ADR, записки разведки, CLAUDE.md. Смотрит отглагольные существительные и страдательный залог, оценку без факта, стоп-слова и канцелярит, «одна мысль — одно предложение», англицизм при живом русском слове, жаргон и метафоры вместо прямого называния, термин, которого нет в документах проекта, транслит в имени файла. Отдаёт готовые формулировки на замену и ничего не правит сам. Записи каталога задач вычитывает отдельный агент task-wording, их форму — task-form. Зовётся по названной пачке правленных документов, а не на весь канон: последним шагом синка документации (av-dev:doc-sync), шагом заведения проекта (av-dev:doc-init), сценарием разведки (av-dev:code-resolve), шагами adopt и upgrade скилла av-dev:canon. Скилл healthcheck его не зовёт — там сверка утверждений, а не языка. Только чтение." tools: Read, Grep, Glob model: sonnet color: green diff --git a/av-dev/agents/review-scope.md b/av-dev/agents/review-scope.md index 708b47e..058146b 100644 --- a/av-dev/agents/review-scope.md +++ b/av-dev/agents/review-scope.md @@ -213,11 +213,15 @@ color: green - **незнакомое** — форму предстоит нащупать по ходу. Признак один и проверяемый: **перед работой нельзя назвать, какие узлы будут тронуты**. -| | знакомое | незнакомое | + + +| | **знакомое** — форму решения можно назвать до начала | **незнакомое** — форму предстоит нащупать по ходу | |---|---|---| -| **малое** | `small` | `large` | -| **среднее** | `medium` | `large` | -| **крупное** | `large` | `large` | +| **малое** — один узел | `small` | `large` | +| **среднее** — несколько узлов одного слоя | `medium` | `large` | +| **крупное** — несколько слоёв, перенос ответственности, большой рефакторинг | `large` | `large` | + + **Метка — не синоним размера, и это главная ловушка таблицы.** Размер `малое` и метка `small` совпадают только в левом верхнем углу: малое **незнакомое** @@ -272,6 +276,8 @@ color: green **Ревью кода — раздача тем.** Кто закрывает тему, зависит от метки. Раскладка жёсткая, выдумывать её не надо: + + | Тема | `small` | `medium` | `large` | |---|---|---|---| | `requirements` | `specs`, сверка | `specs`, разбор | `specs`, разбор | @@ -282,6 +288,8 @@ color: green | `operations` | `code`, сверка по инвариантам | `basics`, разбор | `ops`, доказательство | | тема проекта | `basics`, сверка | `basics`, разбор | `basics`, разбор | + + Две глубины, которые ты назначаешь: - **сверка** — открыть дом, открыть дифф, сравнить. Один-два вопроса на тему, diff --git a/av-dev/agents/review-triage.md b/av-dev/agents/review-triage.md index 4eabdd8..7464788 100644 --- a/av-dev/agents/review-triage.md +++ b/av-dev/agents/review-triage.md @@ -1,6 +1,6 @@ --- name: review-triage -description: "Обязательный финальный проход конвейера ревью — единственный, кто агрегирует. Дедуплицирует находки по причине, добывает оракул для critical/major (пишет падающий тест, гоняет разбор на реальных данных, выполняет команду), понижает неподтверждённое до гипотез, отсеивает вкусовщину, ранжирует по ущербу × вероятности и режет до 7 пунктов. Помечает каждую находку «инлайн» или «развилка» для оркестратора. Сверяет план разметки задачи с пришедшими отчётами: тема, размеченная и оставшаяся без отчёта, — находка о самом прогоне. Формирует итоговый отчёт с планом, перечнем проходов и обязательной секцией границ покрытия." +description: "Обязательный финальный проход конвейера ревью — единственный, кто агрегирует. Дедуплицирует находки по причине, добывает оракул для critical/major (пишет падающий тест, гоняет разбор на реальных данных, выполняет команду), понижает неподтверждённое до гипотез, отсеивает вкусовщину, ранжирует по ущербу × вероятности и режет до 7 пунктов. Помечает каждую находку «инлайн» или «развилка» для оркестратора. Сверяет план с пришедшими отчётами: тема, стоявшая в плане и оставшаяся без отчёта, — находка о самом прогоне; на прогоне без метки план даёт сценарий обслуживания, а не разметчик. Формирует итоговый отчёт с планом, перечнем проходов и обязательной секцией границ покрытия." tools: Read, Grep, Glob, Bash, Write model: opus color: yellow @@ -21,18 +21,26 @@ color: yellow ## Вход -Сырые выводы всех запущенных проходов, `git diff <база>..HEAD`, **план разметки -задачи** (агент `review-scope`, один запуск после `propose`) и режим прогона. -Дельта-спеки — по мере надобности. +Сырые выводы всех запущенных проходов, `git diff <база>..HEAD`, **план прогона** +и режим. Дельта-спеки — по мере надобности. -План — это таблица «тема → дом → глубина → кто закрывает» плюс размер, сложность -и метка с обоснованием. Он твой главный инструмент сверки: ты единственный, кто -видит и то, что размечено, и то, что пришло. +План — таблица «тема → дом → глубина → кто закрывает». Он твой главный инструмент +сверки: ты единственный, кто видит и то, что заявлено, и то, что пришло. -**Плана нет — ты не запускаешься, и исключений нет.** Сверка размеченного с -пришедшим — твоя единственная защита от молчащего пропуска, и без плана она не -выполняется вовсе. Отчёт, собранный без неё, выглядит полным ровно настолько же, -насколько и неполный. +**Откуда план приходит, зависит от режима, и режимов два.** + +- **С меткой** — план собрал `review-scope` (один запуск после `propose`), и к + таблице прилагаются размер, сложность и метка с обоснованием. +- **Без метки** — так идёт прогон сценария обслуживания: изменение не меняет + поведения, размечать нечего, и разметчик не запускается вовсе. План + **фиксирован сценарием** (`av-dev:code-resolve`, `references/maintain.md`), а + размера, сложности и метки не существует. Не ищи их и не подставляй: в отчёте + на их месте — строка «прогон без метки, план сценария». + +**Плана нет ни от разметчика, ни от сценария — ты не запускаешься, и исключений +нет.** Сверка заявленного с пришедшим — твоя единственная защита от молчащего +пропуска, и без плана она не выполняется вовсе. Отчёт, собранный без неё, +выглядит полным ровно настолько же, насколько и неполный. Из документов проекта тебе нужны: @@ -174,7 +182,9 @@ severity: **Сигнала нет — тоже скажи строкой.** «Корректор метки отработал, возражений нет» и «корректор не запускался» — разные вещи, и отличить их по молчанию -нельзя. +нельзя. **На прогоне без метки корректору нечего поднимать**, и это третье +состояние: пиши «метки нет, корректор неприменим», а не «не запускался» — +последнее читается как пропуск. ## Границы покрытия — не сокращаются @@ -238,9 +248,12 @@ severity: Строго секциями из контракта: `Блокирует мердж` (≤3) / `Стоит исправить сейчас` (≤4) / `Гипотезы без доказательства` / `Promote candidates` / `Границы покрытия`. -Перед секциями — сводка: размер, сложность и метка с обоснованием разметки и режим прогона, -состояние гейта, **план с исходом по каждой теме**, сколько находок пришло на -вход и сколько осталось. +Перед секциями — сводка: режим прогона, состояние гейта, **план с исходом по +каждой теме**, сколько находок пришло на вход и сколько осталось. На прогоне +**с меткой** к этому добавляются размер, сложность и метка с обоснованием +разметки; на прогоне **без метки** их место занимает строка «прогон без метки, +план сценария обслуживания» — выдумывать метку задним числом нельзя, её никто +не снимал. ## Ограничения diff --git a/av-dev/agents/task-form.md b/av-dev/agents/task-form.md index a96a879..9fe2e80 100644 --- a/av-dev/agents/task-form.md +++ b/av-dev/agents/task-form.md @@ -98,7 +98,6 @@ color: green постановке. Он же путь понизить требования решением, принятым до проектирования. - ## Чего ты не проверяешь Не своё бывает двух разных родов, и поступают с ними по-разному. diff --git a/av-dev/shared/axes.md b/av-dev/shared/axes.md index 926fbc3..113692a 100644 --- a/av-dev/shared/axes.md +++ b/av-dev/shared/axes.md @@ -38,7 +38,10 @@ | --- | --- | --- | | стадия проекта | что значит порядок строк беклога: зависимость или важность | `task-track/SKILL.md`, «Две стадии» | | стадия проекта | сколько у беклога секций, как его пополняют, применим ли груминг | там же и `task-groom/SKILL.md`, «Груминг — операция доработки» | -| стадия проекта | метку, глубину и тип — **не влияет, и это записано явно** | `task-track/SKILL.md`, «Тип записи» | +| стадия проекта | метку и глубину — **не влияет, и это записано явно** | `task-track/SKILL.md`, «Тип записи» | +| стадия проекта | тип записи — **не влияет, и это записано явно** | `task-track/SKILL.md`, «Две стадии» | +| стадия проекта | как серьёзность находки ложится в список | `task-track/references/from-review.md` | +| стадия проекта | где сценарии кладут свой исход и чем закрывают переходное состояние | `code-resolve/references/research.md`, `task-track/references/adopt.md` | | тип записи | сценарий — **предлагает**, подтверждает предмет работы | `task-track/SKILL.md`, «Тип записи» | | тип записи | метку и глубину — **не влияет, и это записано явно** | там же | | сценарий | режим прогона: обслуживание идёт без метки | `code-resolve/references/maintain.md` | @@ -48,7 +51,7 @@ | категория документа | заводит ли документ направление проверки | `canon.md`, «Три категории» | | severity | что с находкой делают дальше | `code-review/SKILL.md`, «Что происходит с находками» | -**Три клетки пусты, и это сказано намеренно, а не забыто.** +**Пять клеток пусты, и это сказано намеренно, а не забыто.** **Категория документа × режим прогона.** На прогоне **с меткой** своя тема проекта закрыта при любом значении: `review-basics` — приёмник проектных тем и @@ -62,6 +65,15 @@ неё не входит. Заманчивая мысль «на стройке всё `small`, потому что приложения ещё нет» разбивается о первый же шаг, кладущий схему хранилища. +**Стадия проекта × режим прогона и × стадия ревью.** Не влияет ни на одну: +режим выбирает сценарий, стадию ревью — наличие дизайна. Прогон обслуживания на +стройке — обычное дело (первые шаги плана заводят гейт и сборку), и идёт он там +так же, как на доработке. + +**Стадия проекта × категория документа и × коды выхода.** Не влияет: категория — +свойство документа, коды — общий словарь скриптов. Названо потому, что перечень +объявлен полным, и клетка без ответа читается как забытая. + **Режим прогона × severity.** Триаж обязателен всегда, в том числе без метки. Но часть оснований `critical` — построенный путь к отказу, замер — добывается проходами, которые без метки не запускаются. Значит ли это, что `critical` на diff --git a/av-dev/shared/language.md b/av-dev/shared/language.md index f2945c7..fa6b379 100644 --- a/av-dev/shared/language.md +++ b/av-dev/shared/language.md @@ -21,9 +21,18 @@ проверяет вовсе, — а вложенных блоков разметка не знает, и внутри `язык-правила` его было бы не забрать отдельно. -Правила — для всего, что пишется словами: задачи и цели, документы канона, -решения ADR, записки разведки, сообщения коммитов. Не для кода и не для -сообщений программы пользователю — там свои конвенции проекта. +Правила — для всего, что пишется словами **в этом плагине**: задачи, документы +канона, решения ADR, записки разведки. Не для кода и не для сообщений программы +пользователю — там свои конвенции проекта. + +**Сообщения коммитов сюда не входят, и это названо намеренно.** Их форму держит +отдельный плагин `av-dev-git`, скилл `commit`, и она с этими правилами +расходится по существу: там предписан результат страдательным залогом +(«добавлены», «обновлено»), здесь — действие активным. Расхождение осознанное: +строка коммита отвечает на «что стало», а не на «что я сделал», и читают её в +`git log` подряд сотнями. Объявлять юрисдикцию над чужим плагином, до которого +отсюда нет и ссылки, значило бы завести правило, нарушение которого никто не +увидит. Основа — **информационный стиль** Максима Ильяхова ([учебник бюро](https://bureau.ru/projects/book-text/), книга «Пиши, сокращай»). Он @@ -228,8 +237,8 @@ ## Доклад вычитки Не правило языка, а **контракт прохода**: форма, в которой находка приходит к -человеку. Живёт здесь потому, что проходов вычитки несколько — по одному на -плагин, — и разойтись формой они не должны. +человеку. Живёт здесь потому, что проходов вычитки два — `doc-wording` по документам и +`task-wording` по записям задач, — и разойтись формой они не должны. diff --git a/av-dev/skills/canon/SKILL.md b/av-dev/skills/canon/SKILL.md index 5096f22..743c323 100644 --- a/av-dev/skills/canon/SKILL.md +++ b/av-dev/skills/canon/SKILL.md @@ -255,10 +255,11 @@ capability), `openspec/config.yaml`. **Задачи `docs.py` не проверяет** — их ведёт другой скилл, и согласованность каталога показывает только `tasks.py check`. Позвал на шаге 5 скилл задач — - его отчёт идёт в доклад отдельной строкой, и пункт «задачи без цели» в нём - зелёным не станет: цели не сочиняются адаптацией (запрет записан у того, кто - ведёт задачи), их проставляет человек порциями переоценки на первом груминге — - скилл `av-dev:task-groom`. + его отчёт идёт в доклад отдельной строкой, и зелёным он сразу не станет: + у перенесённых записей нет критериев приёмки, а `check` без объявленной + **стадии** отказывает вовсе. Стадию называет человек (`tasks.py stage + build|support`) — машина её не выводит: список пунктов одинаково выглядит и + планом стройки, и очередью правок. ### 5. Объяви переходное состояние diff --git a/av-dev/skills/canon/references/canon.md b/av-dev/skills/canon/references/canon.md index 844fa2a..d2a961d 100644 --- a/av-dev/skills/canon/references/canon.md +++ b/av-dev/skills/canon/references/canon.md @@ -351,9 +351,10 @@ kebab-case.** Причина не эстетическая: имя файла с скриптом и своими проверками; где каталог лежит и как названы его части, говорит секция `[tasks]` в `.av-dev.toml`. Версия там одна на всю раскладку: канон и каталог задач двигаются вместе, потому что ведёт их один плагин. -Канон **резервирует место** в `docs/` и внутрь не смотрит: -`docs.py` каталог не открывает, его отсутствия не считает дрейфом и согласованность -задач не проверяет. Проект, не заведший каталог задач, их не ведёт +Каталог лежит **в корне репозитория, вне `docs/`** — места в `docs/` канон ему +больше не резервирует (переезд — запись 11 закрытого журнала), а внутрь не +смотрит и подавно: `docs.py` каталог не открывает, его отсутствия не считает +дрейфом и согласованность задач не проверяет. Проект, не заведший каталог задач, их не ведёт вовсе, и отказом это быть не может. Раскладку, форму записи и команды держит скилл `av-dev:task-track`. Ниже — то, diff --git a/av-dev/skills/canon/references/changelog.md b/av-dev/skills/canon/references/changelog.md index 938f62c..d0fb759 100644 --- a/av-dev/skills/canon/references/changelog.md +++ b/av-dev/skills/canon/references/changelog.md @@ -40,27 +40,60 @@ `[tasks] completion_heading` — тоже. Появились ключ `[tasks] stage`, команда `tasks.py stage` и флаги `init --stage`, `adopt scan --stage`. -**Что сделать проекту.** +**Что сделать проекту. Порядок шагов обязателен**, и первый шаг — не команда: +пока в `[tasks]` лежит упразднённый ключ, **любая** подкоманда `tasks.py` +отвечает кодом 3 и работать нечем. -1. **Разобрать цели.** У каждой записи типа `goal` в `tasks/items/` два исхода, и - выбирает человек: она становится обычной задачей (`edit <слаг> --type - feature|fix|chore|research`) либо уходит (`close <слаг> --reason …`). Задачи, - носившие её тег, живут дальше сами по себе. Скрипт этого не решает и говорит +1. **Вычистить конфиг руками.** Из секции `[tasks]` в `.av-dev.toml` удалить + ключи `roadmap` (или `plan`) и `completion_heading`. Каждый из них — код 3 на + любой команде, и названы они здесь оба: второй легко пропустить, потому что + его упразднение не видно по имени файла. +2. **Удалить `tasks/ROADMAP.md`.** Секция `Готово` уходит вместе с ним и **не + переносится**: «что приложение умеет» отвечают спеки, «когда это появилось» — + `git log` беклога. Проект без `openspec/specs/` теряет здесь единственный + связный перечень достигнутого — если он нужен, сохрани его сам до удаления + (документом проекта, не задачами). +3. **Прогнать `tasks.py check --fix`.** Он снимет теги `goal:<слаг>` и + `decomposed`, переименует поле `Секция` → `Категория` и перепишет старую + форму меты — **в том числе у самих записей типа `goal`**. Записи `goal` при + этом останутся: во что превращается цель, машина не решает и говорит `НЕОДНОЗНАЧНО`. -2. **Перенести содержимое `ROADMAP.md`.** Секция `Готово` **удаляется**: «что - приложение умеет» отвечают спеки, «когда это появилось» — `git log`. Строки - `Запланировано`, `Направления` и `Сопровождение` — это цели, и они разбираются - шагом 1. Затем удалить сам файл и ключ `roadmap` из `.av-dev.toml`, если он там - был. -3. **Объявить стадию** — `tasks.py stage build` или `tasks.py stage support`. +4. **Разобрать цели поштучно.** У каждой два исхода, и выбирает человек: она + становится задачей (`edit <слаг> --type feature|fix|chore|research`) либо + уходит (`close <слаг> --reason …`). Строки в беклоге у неё нет — её жильём + был роадмап, — и `edit --type` заведёт её сам, в первую секцию и в конец, + сказав об этом; место назначь потом. Раздел `Завершение` в теле переехавшей + записи **удали руками**: схеме нового типа он не принадлежит, и `check` + оставит о нём замечание. Задачи, носившие тег цели, живут дальше сами по + себе — разбирать их не нужно. +5. **Объявить стадию** — `tasks.py stage build` или `tasks.py stage support`. Приложение ещё строится и список работ линеен по зависимости — `build`; работает и правится точечно — `support`. Без ключа `check` отказывает: порядок - строк нечем прочитать. На `build` секция беклога обязана остаться **одна** — - слить полки надо руками, порядок строк в слитом списке знает только человек. -4. **Поднять версию** — `docs.py bump`. Последним шагом. -5. `docs.py check` и `tasks.py check --dir <каталог задач>` — до отсутствия - дрейфа. Теги `goal:` и `decomposed`, поле `Секция` и старую форму меты снимет - `tasks.py check --fix`. + строк нечем прочитать. + + **Объявление беклог не трогает** — ни секций, ни файлов: оно называет то, что + уже верно. Поэтому проекту с несколькими полками, объявляющему `build`, + команда откажет и назовёт выход: слить полки самому (`move <слаг> --section + <куда> --reason …`), потому что порядок строк в слитом списке знает только + человек. Флаг `--sections` при объявлении не принимается — он для **смены** + стадии, где сливать просят явно. +6. **Поправить шапку `BACKLOG.md`.** Абзац про стадию теперь размечен парой + `` … ``, и по нему `check` сверяет шапку с + конфигом. В беклоге, заведённом до этой версии, разметки нет — `stage` об + этом скажет. Возьми готовый абзац из свежего каталога (`tasks.py init` во + временном месте) или напиши сам: он объясняет, что значит порядок строк, и + читают вместо документации именно его. +7. **Поднять версию** — `docs.py bump`. Последним шагом. Он двигает **одну** + запись за раз: отставшему на две записи проекту зовётся дважды, следом за + шагами каждой. +8. `docs.py check` и `tasks.py check --dir <каталог задач>` — до отсутствия + дрейфа. + +**Проект, не прошедший записи 1 и 2, начинает с этой.** Их собственные шаги +велят гонять `tasks.py check` до зелёного, а он на упразднённом ключе отвечает +кодом 3 — то есть пройти их сегодня нельзя, не сделав шаг 1 отсюда. Записи от +этого не переписываются: порядок между ними прежний, добавлено одно условие +входа. --- diff --git a/av-dev/skills/canon/scripts/docs.py b/av-dev/skills/canon/scripts/docs.py index 5b3f805..7644591 100644 --- a/av-dev/skills/canon/scripts/docs.py +++ b/av-dev/skills/canon/scripts/docs.py @@ -702,9 +702,22 @@ def cmd_bump(args: argparse.Namespace) -> int: if was is not None and was > LAYOUT_VERSION: fail(ENV, f"проект на версии {was}, скрипт знает {LAYOUT_VERSION}:" f" устарел плагин, обнови маркетплейс") - conf.set_version(root, LAYOUT_VERSION) + # Двигается **одна** запись за раз, а не сразу до текущей: число объявляет + # пройденными шаги журнала, и прыжок через запись объявил бы пройденным то, + # чего никто не делал. Отставшему на три записи проекту `bump` зовётся три + # раза — по разу на запись, следом за её шагами. + # + # Версии нет вовсе — случай другой: проект не жил ни одной записью журнала, + # его раскладку только что вывели сегодняшним форматом (`adopt`), и + # объявлять ему нечего, кроме текущего числа. + target = LAYOUT_VERSION if was is None else was + 1 + conf.set_version(root, target) print(f"версия раскладки: {was if was is not None else 'не была объявлена'}" - f" → {LAYOUT_VERSION} в {CONFIG}") + f" → {target} в {CONFIG}") + if target < LAYOUT_VERSION: + print(f" до текущей ({LAYOUT_VERSION}) осталось записей журнала:" + f" {LAYOUT_VERSION - target}. Пройди шаги следующей и позови bump" + f" снова — по разу на запись") return OK diff --git a/av-dev/skills/code-resolve/SKILL.md b/av-dev/skills/code-resolve/SKILL.md index 12e894b..c538cbb 100644 --- a/av-dev/skills/code-resolve/SKILL.md +++ b/av-dev/skills/code-resolve/SKILL.md @@ -41,12 +41,20 @@ description: "Взять одну задачу и довести её до за делает скилл `av-dev:code-openspec`. **Сценариям разведки и обслуживания OpenSpec не нужен** — они не заводят change; `opsx:explore` берётся разведкой, если плагин есть. -- **Проектные копии этих скиллов и агентов удаляются при установке плагина** - (`.claude/skills/` — голые имена `resolve`, `review`, а у проектов прошлого - поколения ещё `task-pipeline`, `review-pipeline`, `task-batch`, и с префиксом - проекта: `<проект>-task-pipeline`, `<проект>-review-pipeline`; - `.claude/agents/<проект>-review-*.md`). Две копии одного скилла расходятся, и - побеждает та, что короче названа. +- **Проектные копии этих скиллов и агентов удаляются при установке плагина.** + + + +Голые имена в `.claude/skills/`: `resolve`, `review`, а у проектов прошлого +поколения ещё `task-pipeline`, `review-pipeline`, `task-batch`. С префиксом +проекта: `<проект>-task-pipeline`, `<проект>-review-pipeline`. Агенты: +`.claude/agents/<проект>-review-*.md`. + +Две копии одного скилла расходятся, и побеждает та, что **короче названа**: +короткое имя разрешится в устаревшую проектную копию молча и без признаков +подмены. + + ### Чего может не быть @@ -108,8 +116,7 @@ description: "Взять одну задачу и довести её до за **Запись из каталога сперва проверяется на готовность, и проверяет её машина.** Вызови Skill `av-dev:task-track` и попроси прогнать `ready <слаг>`: он смотрит -тип, цель у `feature`, пустой ли раздел вопросов и собраны ли разделы схемы -типа. Судить это глазами нельзя — ровно тот случай, где машина дешевле и точнее, +тип, пустой ли раздел вопросов и собраны ли разделы схемы типа. Судить это глазами нельзя — ровно тот случай, где машина дешевле и точнее, а цена ошибки отложенная: недостающие критерии приёмки обнаружатся на приёмке, когда сверять уже не с чем. @@ -301,13 +308,14 @@ flowchart TD ## Границы: чем этот скилл не владеет -- **Беклогом, целями и приоритетами.** Задача приходит извне. Скилл её не - выбирает, не приоритизирует, не заводит и не переоценивает. +- **Беклогом и порядком работ.** Задача приходит извне. Скилл её не выбирает, + не переставляет, не заводит и не переоценивает. - **Форматом задач и документов.** Индексы и документы канона руками не правятся, путь к чужому скрипту не выдумывается: этим владеют `av-dev:task-track` и `av-dev:doc-sync`. Закрытие — работа этого скилла, и это осознанное решение с - названной ценой: **приёмщик и исполнитель совпали**. Закрытие поэтому **не окончательно** — человек на - груминге (`av-dev:task-groom`) возвращает задачу `reopen` с причиной, а доклад + названной ценой: **приёмщик и исполнитель совпали**. Закрытие поэтому **не окончательно** — человек + возвращает задачу `reopen` с причиной (на доработке это делают грумингом, + `av-dev:task-groom`, на стройке — сразу, как заметили), а доклад по критериям приёмки становится единственным, по чему приёмка вообще возможна. - **Определением ценности.** «Нужна ли эта функциональность» — не вопрос этого скилла ни на одном шаге и ни в одном сценарии. Чекпоинт решения спрашивает «так diff --git a/av-dev/skills/code-resolve/references/maintain.md b/av-dev/skills/code-resolve/references/maintain.md index b940c89..c37c029 100644 --- a/av-dev/skills/code-resolve/references/maintain.md +++ b/av-dev/skills/code-resolve/references/maintain.md @@ -81,10 +81,15 @@ - **чем задача становится** — `fix` или `feature`, с причиной из разреза выше; - **что уже сделано** и что из этого лежит в рабочем дереве. -Проверка на простой язык та же, что у чекпоинтов двух других сценариев: **в -тексте нет `SHALL`, нет имён файлов и функций там, где вещь называется +Проверка на простой язык — общая у трёх сценариев: + + + +**в тексте нет `SHALL`, нет имён файлов и функций там, где вещь называется по-русски, и нет слов, которых нет в паспорте проекта.** + + **3. Дай два решения и жди ответа.** Их ровно два, и оба законны: - **переформулировать запись** — тип меняется на названный, и дальше задача идёт @@ -172,9 +177,12 @@ flowchart TD остаётся в рабочем дереве незакоммиченным. Доклад называет **оба**: какой тип предложен и что человек выбрал; - **нужна разведка** — форма правки неизвестна (мажорное обновление, смена - сборщика, переезд гейта на другой инструмент) **или сработал триггер ADR**: - дорогой откат, намеренный отказ от очевидного подхода, пересмотр прежнего - решения. Стоп с названной причиной, разведка идёт следующим прогоном. + сборщика, переезд гейта на другой инструмент) **или сработал триггер ADR**. + Перечень триггеров не пересказывается: он живёт в + [canon.md](../../canon/references/canon.md#adr), и здесь он работает + стоп-признаком — то есть от его точности зависит выбор сценария, а пересказ + расходится с домом молча. Стоп с названной причиной, разведка идёт следующим + прогоном. **Последний исход — не редкость, и его стоит ждать.** Незнакомое обслуживание — это выбор подхода с ценой и с тем, что становится невозможным, — предмет чекпоинта @@ -223,6 +231,14 @@ ADR: список источников канон закрыл двумя — а Код и конфиги — по конвенциям проекта. Правка по размеру задачи: чинится названное в записи, соседнее не улучшается заодно. +**Гейта ещё нет — сказать это, а не изображать сверку.** Первые шаги плана +стройки заводят гейт, сборку и хуки: у них нет ни «до», ни «прежнего», и +определение сделанного через зелёный гейт на них не выполнимо буквально. Такая +задача сделана, когда **заведённое работает на чистом клоне** и это показано в +докладе; пункты 1 и 3 определения ниже закрываются строкой «заводится впервые, +сверять не с чем». Изображать сверку с несуществующим прежним состоянием нельзя — +это ровно то враньё, против которого весь абзац ниже и написан. + **Гейт правится — сверь состав, а не цвет.** Красный, ставший зелёным, виден сразу; убыли проверок гейт не покажет — он зелёный и до, и после. Это самая дорогая из возможных правок оснастки: молча выключено то, чем проверяется всё @@ -242,7 +258,9 @@ ADR: список источников канон закрыл двумя — а **Поведенческая верификация здесь другая, чем в решении.** Проверяется не новое поведение, а то, что прежнее не поехало: команда из `CLAUDE.md` поднимается, шаг -сборки отрабатывает, хук ставится на чистом клоне. Правка, которую нельзя +сборки отрабатывает, хук ставится на чистом клоне. **У оснастки, заводимой +впервые, прежнего нет** — тогда проверяется, что заведённое работает на чистом +клоне, и в докладе это называется своим именем, а не «регрессий не найдено». Правка, которую нельзя проверить ничем, кроме «у меня локально работает», называется в докладе строкой. ### 4. Ревью — план фиксирован сценарием @@ -259,12 +277,16 @@ Change ты не передаёшь — его нет. Поэтому план у сценария **свой и постоянный**, и глубину он называет сам — проходы берут её из метки, а метки здесь нет: + + | Тема | Дом | Кто закрывает | Глубина и вход | Когда | | --- | --- | --- | --- | --- | | `autotests` | `CLAUDE.md`, семантика гейта | `review-autotests` | как обычно: гейт запускается целиком | всегда | | `operations` | `architecture.*`, раздел эксплуатации | `review-basics` | **сверка**: дом темы против диффа, потолок 2 | всегда | | `conventions` + технический разбор | `conventions.*` | `review-code` | вход `small`: только индекс конвенций; потолки 3 технических и 2 конвенционных; **третья половина включена** — сверка с инвариантами `CLAUDE.md`, потолок 1 | дифф трогает код, а не только оснастку | + + **Глубина названа в плане потому, что иначе её неоткуда взять.** Вход и потолки `review-code` заданы меткой, у `review-basics` меткой задана и сама возможность запуска; на прогоне без метки оба взяли бы их наугад — то есть по-разному от @@ -324,9 +346,9 @@ Change ты не передаёшь — его нет. **`adr/` этот сценарий не пополняет, и это не пропуск.** У ADR закрытый список источников — архивный `design.md` либо записка разведки, — и ни того ни другого обслуживание не производит. Поэтому триггеры ADR здесь работают **стоп-признаком, -а не поводом завести запись**: сработал дорогой откат, намеренный отказ от -очевидного подхода или пересмотр прежнего решения — сценарий выбран неверно, -объявляй исход **нужна разведка** и останавливайся. Решение с ценой обязано +а не поводом завести запись**: сработал любой из них — сценарий выбран неверно, +объявляй исход **нужна разведка** и останавливайся. Перечень триггеров — в +[canon.md](../../canon/references/canon.md#adr) и здесь не пересказывается. Решение с ценой обязано пройти чекпоинт вариантов, а не появиться в коммите обслуживания, чей смысл — «ничего не решали, поменяли оснастку». diff --git a/av-dev/skills/code-resolve/references/research.md b/av-dev/skills/code-resolve/references/research.md index 63e8a3f..05f31b2 100644 --- a/av-dev/skills/code-resolve/references/research.md +++ b/av-dev/skills/code-resolve/references/research.md @@ -103,10 +103,10 @@ git и читается диффом, а второй стоп на каждой ходу. Исключение ровно одно и оно не про изменение системы: одноразовый **читающий** прогон (запрос, замер, скрипт в песочнице), чей результат уезжает в ответ с провенансом и который ничего не оставляет в репозитории. -- **Приоритетом.** Заведённая задача встаёт в конец своей секции; где ей стоять - в очереди, решает человек на груминге (`av-dev:task-groom`). Разведка, сама - ставящая свой исход первым в беклоге, назначает приоритет тому, что только что - придумала. +- **Местом в списке.** Заведённая задача встаёт в конец своей секции; куда её + поставить, решает человек — на доработке грумингом (`av-dev:task-groom`), на + стройке сразу же, по зависимости. Разведка, сама ставящая свой исход первым, + назначает место тому, что только что придумала. - **Форматом задач и документов.** Индексы и документы руками не правятся: их ведут `av-dev:task-track` и `av-dev:doc-sync`. Твоё — содержание ответа, их — форма и дом. @@ -227,9 +227,14 @@ git и читается диффом, а второй стоп на каждой отвергнутое (отвергнутый вариант с названной причиной — половина будущего ADR); приносить один вариант и называть это выбором. -Проверка на простой язык та же, что у чекпоинта решения: **в тексте нет `SHALL`, -нет имён файлов и функций там, где вещь называется по-русски, и нет слов, которых -нет в паспорте проекта.** +Проверка на простой язык — общая у трёх сценариев: + + + +**в тексте нет `SHALL`, нет имён файлов и функций там, где вещь называется +по-русски, и нет слов, которых нет в паспорте проекта.** + + Исходы чекпоинта: @@ -255,8 +260,8 @@ git и читается диффом, а второй стоп на каждой - **ответ на вопрос** — по адресу из шага 1; - **отвергнутые варианты с причинами.** Это половина будущего ADR и единственная защита от повторной разведки того же самого; -- **решение с ценой — в ADR**, если оно проходит триггер канона (дорогой откат, - намеренный отказ, пересмотр прежнего решения). У разведки, кончившейся без +- **решение с ценой — в ADR**, если оно проходит [триггер + канона](../../canon/references/canon.md#adr). У разведки, кончившейся без кода, `design.md` не будет никогда, поэтому здесь ADR цитирует **записку разведки**, а не архивный change; канон это допускает прямо, и в записи источник называется. diff --git a/av-dev/skills/code-resolve/references/solve.md b/av-dev/skills/code-resolve/references/solve.md index 0f567c5..68cba56 100644 --- a/av-dev/skills/code-resolve/references/solve.md +++ b/av-dev/skills/code-resolve/references/solve.md @@ -195,10 +195,17 @@ flowchart TD накопленные до этого места, и находки ревью с пометкой `развилка`; - **что дальше**, если возражений нет. -Проверка на «простой язык» одна и механическая: **в тексте нет `SHALL`, нет имён -файлов и функций там, где вещь называется по-русски, и нет слов, которых нет в -паспорте проекта.** Не проходит — переписывай, а не объясняй, почему иначе -нельзя. +Проверка на «простой язык» одна и механическая, и она общая у чекпоинтов всех +трёх сценариев — поэтому её дом здесь, а у соседей помеченные копии: + + + +**в тексте нет `SHALL`, нет имён файлов и функций там, где вещь называется +по-русски, и нет слов, которых нет в паспорте проекта.** + + + +Не проходит — переписывай, а не объясняй, почему иначе нельзя. Длина — экран. Длинное объяснение не читают, а пролистывают, и чекпоинт превращается в ритуал одобрения. diff --git a/av-dev/skills/code-review/SKILL.md b/av-dev/skills/code-review/SKILL.md index 9da9bc3..b24831c 100644 --- a/av-dev/skills/code-review/SKILL.md +++ b/av-dev/skills/code-review/SKILL.md @@ -59,12 +59,20 @@ description: "Конвейер ревью изменения, устроенны сценарий обслуживания зовёт конвейер без change и без дельта-спек, и ни один проход его плана на них не завязан. См. «Прогон без change». - **Документы канона** — см. следующий раздел. -- **Проектные копии этих скиллов и агентов удаляются при установке.** Если в - проекте уже лежат свои `.claude/skills/review`, - `.claude/skills/review-pipeline`, `.claude/skills/task-pipeline`, - `.claude/skills/task-batch`, `.claude/skills/resolve` или - `.claude/agents/<проект>-review-*.md` — снеси их. Иначе короткое имя разрешится - в устаревшую проектную копию, молча и без признаков подмены. +- **Проектные копии этих скиллов и агентов удаляются при установке.** + + + +Голые имена в `.claude/skills/`: `resolve`, `review`, а у проектов прошлого +поколения ещё `task-pipeline`, `review-pipeline`, `task-batch`. С префиксом +проекта: `<проект>-task-pipeline`, `<проект>-review-pipeline`. Агенты: +`.claude/agents/<проект>-review-*.md`. + +Две копии одного скилла расходятся, и побеждает та, что **короче названа**: +короткое имя разрешится в устаревшую проектную копию молча и без признаков +подмены. + + ### Чего может не быть @@ -335,6 +343,8 @@ charter'а, а модель потом двигает калибровка, и Ревью кода: + + | Тема | `small` | `medium` | `large` | |---|---|---|---| | `requirements` | `specs`, сверка | `specs`, разбор | `specs`, разбор | @@ -345,6 +355,8 @@ charter'а, а модель потом двигает калибровка, и | `operations` | `code`, сверка по инвариантам | `basics`, разбор | `ops`, доказательство | | тема проекта | `basics`, сверка | `basics`, разбор | `basics`, разбор | + + Весь процесс с выбором исполнителей на каждом этапе — одной схемой. **Метка считается один раз, в узле разметки, и дальше только читается:** @@ -452,8 +464,8 @@ flowchart TD — и очередь между ними была бы платой ни за что. Рёбер два вида, и они разной природы. Путать их нельзя: первое про -**осмысленность** (без плана задание не определено, на красном гейте проход с мнением -проход не о чем), второе про **железо**. +**осмысленность** (без плана задание не определено, а на красном гейте проходу с +мнением не о чем судить), второе про **железо**. | Ребро | Смысл | Между кем | |---|---|---| @@ -693,11 +705,15 @@ change**: у работы, не меняющей поведения, дельт называет глубину и вход каждого прохода** — их обычный источник метка, и без неё проходы взяли бы их наугад: -| Тема | Кто закрывает | Глубина и вход | Когда | -|---|---|---|---| -| `autotests` | `review-autotests` | как обычно | всегда | -| `operations` | `review-basics` | сверка, потолок 2 | всегда | -| `conventions` + технический разбор | `review-code` | вход `small` (индекс конвенций), потолки 3 и 2, третья половина включена — потолок 1 | дифф трогает код | + + +| Тема | Дом | Кто закрывает | Глубина и вход | Когда | +| --- | --- | --- | --- | --- | +| `autotests` | `CLAUDE.md`, семантика гейта | `review-autotests` | как обычно: гейт запускается целиком | всегда | +| `operations` | `architecture.*`, раздел эксплуатации | `review-basics` | **сверка**: дом темы против диффа, потолок 2 | всегда | +| `conventions` + технический разбор | `conventions.*` | `review-code` | вход `small`: только индекс конвенций; потолки 3 технических и 2 конвенционных; **третья половина включена** — сверка с инвариантами `CLAUDE.md`, потолок 1 | дифф трогает код, а не только оснастку | + + Триаж обязателен и здесь — он единственный сток и единственный, кто сверяет план с исходом; на его вход подаётся этот план вместо плана разметки. Тема @@ -747,7 +763,7 @@ change**: у работы, не меняющей поведения, дельт Два прохода, оба против **записанного** критерия. Машину не держат ни один, ребра между ними нет — уходят одним сообщением сразу после зелёного гейта, вместе со -стадией 3 или 4 — той, что в метки. +стадией 3 или 4 — той, которую назначила метка. - `review-specs` закрывает тему `requirements`. Критерий взят из **дельта-спек предлагаемого изменения**, а не из proposal, сообщения коммита или описания diff --git a/av-dev/skills/code-review/references/review-levels.md b/av-dev/skills/code-review/references/review-levels.md index d139f4c..27139cf 100644 --- a/av-dev/skills/code-review/references/review-levels.md +++ b/av-dev/skills/code-review/references/review-levels.md @@ -5,20 +5,26 @@ его достаточно. Здесь то, что читают, когда метку **выбирают, оспаривают или калибруют**. -Применяет правило `review-scope` при разметке задачи — не автор изменения. Его -рабочая выжимка лежит в уставе агента; расходиться она с этим файлом не вправе, а -при расхождении прав этот. +Применяет правило `review-scope` при разметке задачи — не автор изменения. Сама +матрица уехала в его устав **помеченной копией**, и дословность её держит +`copies.py`, а не обещание: прежде здесь стояло «расходиться не вправе», и +подкреплено это было ничем. Проза вокруг матрицы — отрицательный тест `small`, +доли, цена — принадлежит месту и живёт только здесь. ## Правило выбора — две оси, а не один вопрос **Оси две, они измеряют разное, и метка есть максимум по ним.** + + | | **знакомое** — форму решения можно назвать до начала | **незнакомое** — форму предстоит нащупать по ходу | |---|---|---| | **малое** — один узел | `small` | `large` | | **среднее** — несколько узлов одного слоя | `medium` | `large` | | **крупное** — несколько слоёв, перенос ответственности, большой рефакторинг | `large` | `large` | + + **Метка — не синоним размера.** Совпадают они только в левом верхнем углу: малое **незнакомое** изменение получает `large`, трогая один узел. Поэтому в плане стоят три строки, а не одна: размер, сложность и метка — каждая со своим diff --git a/av-dev/skills/doc-sync/SKILL.md b/av-dev/skills/doc-sync/SKILL.md index 88e89ca..5ee0040 100644 --- a/av-dev/skills/doc-sync/SKILL.md +++ b/av-dev/skills/doc-sync/SKILL.md @@ -36,7 +36,7 @@ description: Вести содержимое документов канона | `openspec/specs/` | всегда при изменении поведения | вливает `opsx:archive` | | `database.md` | тронуты миграции | `docs.py check --base` | | `architecture.md` | новый компонент, граница, внешняя зависимость, изменилось окружение | `docs.py`: capability без упоминания | -| `adr/` | дорогой откат, намеренный отказ, пересмотр прежнего | нет — только этот чек-лист | +| `adr/` | сработал триггер канона (перечень — [canon.md](../canon/references/canon.md#adr)) | нет — только этот чек-лист | | `research/` | узнали новое о внешнем формате или данных | нет | | `security.md` | новый недоверенный вход, токен, путь наружу, сдвиг периметра | нет | | `conventions/` | находка принята и не специфична для одного места | промоут | diff --git a/av-dev/skills/task-groom/SKILL.md b/av-dev/skills/task-groom/SKILL.md index c4848d5..ffb9f8f 100644 --- a/av-dev/skills/task-groom/SKILL.md +++ b/av-dev/skills/task-groom/SKILL.md @@ -53,11 +53,19 @@ description: "Груминг беклога — интерактивный ра не 5–8 задач из середины. Порционный разбор здесь вреден: он вынимает шаги из списка, порядок которого и есть его содержание. -Поэтому на стройке скилл говорит это строкой и **предлагает другую работу**: -пересмотр плана целиком, гигиену полей (`task-track`) или переход в доработку, -если беклог исчерпан. Три вещи он делает и там, потому что от стадии они не -зависят: `tasks.py check --fix`, разбор накопившихся вопросов и закрытие того, -что сделано попутно. +Поэтому на стройке скилл говорит это строкой и **отсылает к другой работе**: +[пересмотр плана целиком](../task-track/SKILL.md#пересмотр-плана-стройки) — +сценарий скилла `task-track`, гигиена полей — тоже его, а исчерпанный беклог +значит переход (`tasks.py stage support`). Четыре вещи он делает и на стройке, +потому что от стадии они не зависят: `tasks.py check --fix`, разбор +накопившихся вопросов, закрытие сделанного попутно и **возврат неудавшейся +приёмки** (`reopen`). + +**Возврат приёмки от стадии не зависит вовсе, и это надо сказать отдельно.** +Приёмщик и исполнитель у нас совпадают, и опор против этого две: независимый +отчёт ревью и `reopen`. Вторая привязана к грумингу только по привычке — заметил, +что закрытая задача сделана не тем, чем обещала, возвращай сразу, на любой +стадии и в любой момент. ## Когда груминг созрел @@ -69,6 +77,8 @@ description: "Груминг беклога — интерактивный ра - на верхних строках очереди есть задача с открытым вопросом — очередь показывает то, что взять нельзя; - `tasks.py check` печатает «готово к взятию: 0 из N» — брать сегодня нечего. + **На стройке этот признак читается иначе**: он значит «план ещё не дописан», а + не «пора грумить». Порога в неделях нет намеренно: счётчик простоя пришлось бы вести руками, а решает всё равно человек. Признак — **очередь перестала быть твоей**: взялся @@ -215,9 +225,9 @@ flowchart TD - **независимый отчёт ревью** — артефакт, написанный не исполнителем: отчёт триажа в `openspec/changes/archive//review/` (до архивации — `changes//review/`); -- **`reopen <слаг> --reason`** — закрытие не окончательно. Заметил на груминге, - что закрытая задача сделана не тем, чем обещала, — возвращай, это штатная - операция, а не скандал. Индексы под git: `git log -p` по беклогу показывает, +- **`reopen <слаг> --reason`** — закрытие не окончательно. Заметил, что закрытая + задача сделана не тем, чем обещала, — возвращай, это штатная операция, а не + скандал, и ждать груминга она не требует (на стройке его и не будет). Индексы под git: `git log -p` по беклогу показывает, что и когда закрыто, потому что закрытие коммитится отдельным коммитом учёта. Известные обходы: diff --git a/av-dev/skills/task-track/SKILL.md b/av-dev/skills/task-track/SKILL.md index 9faf767..ed1b017 100644 --- a/av-dev/skills/task-track/SKILL.md +++ b/av-dev/skills/task-track/SKILL.md @@ -190,7 +190,9 @@ stateDiagram-v2 Вместе с целью ушли: секция `Готово` (её ответ дают спеки и история), теги `goal:<слаг>` и `decomposed`, поле меты `Секция` (осталась `Категория`), раздел -`Завершение` и команды `list --goal`, `edit --goal`. Встретились в проекте — +`Завершение`, ключи `[tasks] roadmap` и `[tasks] completion_heading`, флаги +`add --goal`, `list --goal`, `list --index`, `edit --goal`, `edit --section` и +`init --roadmap`. Встретились в проекте — `check` назовёт их поимённо, `check --fix` снимет теги, а запись типа `goal` оставит человеку: во что она превращается — в задачу или в ничто, — машина не решает. @@ -517,7 +519,9 @@ python3 $tk adopt scan --from … --stage S | apply --plan … # разова пять файлов из одной мысли, а сорок файлов из сорока сырых находок. Защита та же, что в самом ревью: кластеризация по причине, дедуп против живых и `REJECTED.md`, находка без свидетельства → сырьё (`research`), а не задача, и карта кластеров -пользователю до создания файлов. Порядок и отображение серьёзности — +пользователю до создания файлов. **Стадию смотри и здесь**, тем же нулевым +шагом: от неё зависит, куда ляжет тяжёлая находка — наверх очереди или после +своей зависимости. Порядок и отображение серьёзности — [references/from-review.md](references/from-review.md). ### Прийти в репозиторий, где задачи уже как-то ведутся @@ -530,6 +534,32 @@ python3 $tk adopt scan --from … --stage S | apply --plan … # разова Если переводить надо не только задачи, а весь `docs/` — это скилл `av-dev:canon`, и он зовёт этот сценарий сам на своём шаге. +### Пересмотр плана стройки + +Операция стадии `build`, и на доработке её нет: там переоценка идёт порциями и +называется грумингом. **Повод один — сменился замысел**, а не «давно не +смотрели»: план стройки протухает не по частям, а целиком, потому что порядок в +нём — зависимость, и одна изменившаяся посылка переставляет всё, что ниже. + +Порционный разбор здесь вреден, и это не вкус: вынуть пять шагов из середины +списка, порядок которого и есть его содержание, — значит получить план, про +который никто уже не скажет, почему он такой. + +1. **Назови, что изменилось в замысле.** Одной фразой, и она уедет причиной в + каждое движение. Не находится — значит повода нет, и пересмотр не нужен. +2. **Прочитай список целиком**, сверху вниз, и по каждой строке ответь одно из + трёх: остаётся как есть, переезжает (`move --after` с причиной), уходит + (`close --reason`). Дописанное новое встаёт туда, куда велит зависимость, а + не в конец. +3. **Покажи человеку весь новый список**, а не отдельные решения: план читается + только целиком. `AskUserQuestion` с готовым порядком и доводом на каждое + движение. +4. `check` и доклад: сколько строк тронуто из скольких, что ушло и почему. + +**Границу с грумингом держи твёрдо.** Если хочется пересмотреть план «потому что +накопилось» — это не пересмотр, а признак того, что стройка кончилась: беклог +перестал быть планом и стал очередью. Проверь `stage`. + ### Декомпозиция и штурм сырья [references/split.md](references/split.md). Обе операции превращают одну запись в @@ -557,7 +587,7 @@ python3 $tk adopt scan --from … --stage S | apply --plan … # разова одну половину делает дорогой, а вторую — поверхностной. Модель у обоих одна, `sonnet`, и это не отменяет разреза. Оба судят по -**записанному правилу** — семь пунктов формы против правил языка, — а их находка +**записанному правилу** — шесть пунктов формы против правил языка, — а их находка приходит готовой формулировкой, которую читает и отклоняет человек, а не молча реализует оркестратор. Ошибка здесь стоит строки чтения, и платить за неё верхней моделью не за что. diff --git a/av-dev/skills/task-track/references/adopt.md b/av-dev/skills/task-track/references/adopt.md index 999f46c..e6f2311 100644 --- a/av-dev/skills/task-track/references/adopt.md +++ b/av-dev/skills/task-track/references/adopt.md @@ -100,9 +100,10 @@ python3 $tk adopt apply --plan tasks-adopt-plan.json \ `apply` печатает состояние по факту: сколько задач не собрало разделы своего типа (для `check` это не ошибка, а строка здоровья, но `ready` такую задачу не -пропустит). Закрывается это **порциями груминга** — скилл `groom`, 5–8 задач за -порцию: превратить «готово, когда» в критерии с оракулами, вынуть вопросы из -прозы в раздел «Вопросы». +пропустит). Закрывается это **порциями по 5–8 задач**: превратить «готово, +когда» в критерии с оракулами, вынуть вопросы из прозы в раздел «Вопросы». На +доработке это груминг (скилл `av-dev:task-groom`), на стройке — гигиена полей +этого скилла: груминга там нет. **Порядок строк проверяется глазами отдельно.** На стройке он выведен из нумерации источника, и там, где её не было, он случаен. На доработке машина diff --git a/av-dev/skills/task-track/references/from-review.md b/av-dev/skills/task-track/references/from-review.md index 0880fb5..3d192b7 100644 --- a/av-dev/skills/task-track/references/from-review.md +++ b/av-dev/skills/task-track/references/from-review.md @@ -83,6 +83,14 @@ [скилле груминга](../../task-groom/SKILL.md#приоритет-как-его-расставляют), и серьёзность попадает ровно в один из них. +**Всё это — про доработку.** На стройке порядок строк значит зависимость, и +`--first` там означает «ни от чего не зависит», а не «важнее всех»: находка, +поднятая наверх, встанет перед собственной зависимостью. Место находке на стройке +называет зависимость — `move --after <шаг, после которого её можно делать>`, — а +серьёзность идёт **причиной в мете** и разбирается ближайшим пересмотром плана +(`task-track`, «Пересмотр плана стройки»). Груминга там нет, и откладывать «до +него» некуда. + - **тяжёлая находка со свидетельством о сломанном сейчас** → задача **первой строкой секции**: `move <слаг> --first --reason «сломано сейчас: …»`. Это довод «что сломано сейчас» из перечня diff --git a/av-dev/skills/task-track/references/task-format.md b/av-dev/skills/task-track/references/task-format.md index 32e9586..d514be9 100644 --- a/av-dev/skills/task-track/references/task-format.md +++ b/av-dev/skills/task-track/references/task-format.md @@ -110,10 +110,13 @@ | поле **Хук** | поле **Зачем** | | мета одной строкой через `·` | мета списком, поле на строку | -Единственное, чего `--fix` не делает сам, — **проставить тип записи, у которой -его неоткуда взять**: `feature` от `chore` машина не отличает, и подставленное -наугад значение врало бы ровно там, где по нему принимают решение. Такие записи -он называет поимённо пометкой `НЕОДНОЗНАЧНО`. +Чего `--fix` не делает сам — **решает за человека, каким быть типу**. Случаев +три, и все три уезжают пометкой `НЕОДНОЗНАЧНО`: тип, которого неоткуда взять +(`feature` от `chore` машина не отличает); тип вне словаря; и запись типа `goal` +— целей больше нет, а во что превращается эта, в задачу или в ничто, машина не +знает. Подставленное наугад значение врало бы ровно там, где по нему принимают +решение. Мёртвые теги и имя поля места при этом снимаются у **любой** записи, +включая ту, чей тип остался неразобранным. ### Затрагивает diff --git a/av-dev/skills/task-track/references/task-research.md b/av-dev/skills/task-track/references/task-research.md index 1821afa..543725b 100644 --- a/av-dev/skills/task-track/references/task-research.md +++ b/av-dev/skills/task-track/references/task-research.md @@ -42,7 +42,8 @@ | Место в секции беклога | **конец**, `check --fix` сносит туда сам | среди прочих | | `tasks.py list --raw` | показывает | нет | -Порядок строк в беклоге назначает человек — это приоритет (правило 4 скилла). +Порядок строк в беклоге назначает человек, и стадия решает, что он значит: +зависимость на стройке, важность на доработке (правило 4 скилла). Место сырья **из него изъято**: оно производно от типа и заполненности, а не от чьего-то решения, и потому его проверяет и чинит машина. Приоритетом оно не становится: сырьё не берут вовсе, и место в конце говорит именно это. diff --git a/av-dev/skills/task-track/scripts/tasks.py b/av-dev/skills/task-track/scripts/tasks.py index 2e0fcb5..1221534 100755 --- a/av-dev/skills/task-track/scripts/tasks.py +++ b/av-dev/skills/task-track/scripts/tasks.py @@ -189,6 +189,16 @@ DEFAULTS = { # с диском первым делом, иначе кривой ключ выглядит как пропавший файл). PATH_KEYS = ("items", "backlog", "rejected") +# Упразднённые части каталога — с адресом, куда уехало содержимое. Перечень +# читает `scripts/addresses.py`: адрес, названный в чужой прозе, опровергается +# перечнем владельца, а не памятью. Без этой константы упразднение, сделанное +# здесь, не ловилось бы гейтом вовсе — то есть шаг гейта молчал бы ровно про то, +# ради чего заведён. +RETIRED = { + "ROADMAP.md": "→ BACKLOG.md: роадмап упразднён вместе с типом goal", + "SPRINT.md": "→ порядок строк беклога (спринты отменены)", +} + # Умолчания секций по стадиям. На доработке это **полки домена**: смысла они не # несут, называет их проект. На стройке секция ровно одна — список от базы к # деталям, — и её имя тоже дело проекта: различать ей нечего, она одна. @@ -262,6 +272,10 @@ BULLET = re.compile(r"^[-*]\s+(.*)$") REJECTED_ENTRY = re.compile(r"^- \d{4}-\d{2}-\d{2} `[a-z0-9-]+` — .+") RESEARCH = "research" +# Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; читается +# только затем, чтобы `check` назвал его вслух, а `--fix` снял его следы. +LEGACY_GOAL_TAG = "goal:" +LEGACY_GOAL = "goal" # Тип — ось записи и **закрытый словарь**. Открытый разъедется на синонимах # (`bug`, `bugfix`, `fix`, `defect`), и отбор по типу перестанет отвечать на # свой единственный вопрос. Ни один тип не подходит — это сигнал, что в записи @@ -272,17 +286,16 @@ TYPES = ("feature", "fix", "chore", RESEARCH) # дословно, — так тип виден там, где решают «брать или не брать», и инвариант # «заголовок в индексе дословно» остаётся нетронутым. TYPE_EMOJI = {"feature": "✨", "fix": "🐞", "chore": "🧹", RESEARCH: "🔬"} -EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} +# Эмодзи упразднённого типа читается по-прежнему — иначе она не **снимается**: +# заголовок разбирается на «значок + текст», и незнакомый значок уезжает в текст, +# а следующая правка типа ставит второй перед первым («✨ 🎯 …»). +EMOJI_TYPE = {v: k for k, v in TYPE_EMOJI.items()} | {"🎯": LEGACY_GOAL} TAKEABLE = TYPES # берутся в работу все четыре: целей больше нет # Заголовок в форме действия требуется там, где исход работы — изменение # системы. У разведки он называет предмет: её исход знание, и заголовок-действие # обещал бы решённость, которой ещё нет. ACTION_TYPES = ("feature", "fix", "chore") QUESTION_TAG = "question" -# Прежний дом направления. Тип `goal` упразднён вместе с роадмапом; тег -# читается только затем, чтобы `check` назвал его вслух, а `--fix` снял. -LEGACY_GOAL_TAG = "goal:" -LEGACY_GOAL = "goal" # Схема тела на тип: какие разделы обязательны, какие ещё допустимы. Значения — # **ключи конфига**, а не сами заголовки: имена заголовков проект настраивает, @@ -458,7 +471,12 @@ class Layout: # Стадия — не имя части, поэтому и не в `cfg`. Пустая строка значит «не # объявлена», и это отдельное состояние: без неё непонятно, что значит # порядок строк, и `check` об этом говорит. - self.stage = cfg.get(STAGE_KEY, "") + # + # Приводится к нижнему регистру ровно потому, что к нему же приводит + # валидация: `stage = "Build"` проходил её и не совпадал ни с одним + # значением здесь, так что каждое ветвление молча уходило в ветку + # «стадии нет» — при зелёном конфиге и объявленной стадии. + self.stage = str(cfg.get(STAGE_KEY, "")).strip().lower() def index(self, kind: str) -> Path: return self.root / self.cfg[kind] @@ -1145,6 +1163,7 @@ def check(lay: Layout, fix: bool = False) -> int: raw_names = {n for n, t in tasks.items() if raw_research(lay, t)} errors += sections_verdict(lay, sections, label) + errors += stage_block_verdict(lay, lines, label) for s in sections: if s.lower() in BLOCKER_SECTIONS: notes.append(f"{label}: секции «{s}» быть не должно —" @@ -1457,6 +1476,12 @@ def list_tasks(lay: Layout, a: argparse.Namespace) -> int: for t in rows: t["touched"] = dates.get(t["path"].name, "—") rows.sort(key=lambda t: (t["touched"] == "—", t["touched"])) + # Отбор работает на любой стадии, но значит он разное, и молчать об этом + # нельзя: на стройке шаг лежит долго законно — до него не дошла очередь, + # и он стоит там, где стоит, по зависимости. + if lay.stage == BUILD: + print(" стройка: залежалость здесь не мера — шаг ждёт своей" + " очереди по зависимости, а не потому, что его обходят\n") else: rows.sort(key=lambda t: (order.get(t["section"], 99), t["path"].name)) @@ -1700,8 +1725,8 @@ def cmd_add(lay: Layout, a: argparse.Namespace) -> int: section = a.section or (section_headers(lines)[0][1] if section_headers(lines) else "") hi, section = find_section(lines, section) if hi is None: - avail = ", ".join(n for _, n in section_headers(lines)) - raise Usage(f"нет секции «{a.section}» в {lay.name('backlog')} (есть: {avail})") + avail = ", ".join(n for _, n in section_headers(lines)) or "ни одной" + raise Usage(f"нет секции «{section}» в {lay.name('backlog')} (есть: {avail})") tags = split_tags(a.tag) if QUESTION_TAG in tags: @@ -1766,9 +1791,6 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") place = locate(lay, a.slug) - if place is None: - raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix") - task = parse_task(path) if a.title is not None and not a.title.strip(): raise Usage("пустой заголовок") @@ -1802,6 +1824,21 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: # правкой. Сверка принадлежности всё равно идёт по нижнему регистру. section = task["section_raw"] + # Строки индекса может не быть, и это не всегда поломка: запись, пережившая + # упразднение своего индекса, лежит файлом без строки, а `check --fix` + # восстановить её не может — секция в мете указывает на исчезнувшую полку. + # Отказ здесь запирал бы такую запись навсегда: строку не восстановить, пока + # не сменишь тип, и тип не сменить, пока нет строки. Заводим строку сами, в + # первую секцию, и говорим об этом. + lost_section = "" + if place is None: + heads = section_headers(read_lines(lay.index("backlog"))) + if not heads: + raise Usage(f"строки для {a.slug} нет, и в {lay.name('backlog')} нет" + f" ни одной секции — заводить её некуда") + if find_section(read_lines(lay.index("backlog")), section)[0] is None: + lost_section, section = section or "—", heads[0][1] + # Тип передаётся всегда, а не только при `--type`: у файла, не переехавшего # на поле, он выведен из прежнего дома, и без него пересборка меты потеряла # бы его вовсе. @@ -1823,8 +1860,12 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: if rtype == RESEARCH and not task["body"].get(lay.cfg["question_heading"].lower()): raw_now.add(f"{a.slug}.md") - lines, ei = place - lines[ei] = entry_line(lay, h1, a.slug, why) + if place is None: + lines = read_lines(lay.index("backlog")) + insert_entry(lines, section, entry_line(lay, h1, a.slug, why)) + else: + lines, ei = place + lines[ei] = entry_line(lay, h1, a.slug, why) plan = Plan() plan.file(path, new_text) @@ -1835,6 +1876,11 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: ("теги", a.add_tag or a.rm_tag)) if v is not None] print(f"{a.slug}: обновлено ({', '.join(changed)})") + if place is None: + print(f" строки в {lay.name('backlog')} не было — заведена в секции" + f" «{section}», в конец: позицию назначает человек" + + (f" (секции «{lost_section}» из меты в индексе нет)" + if lost_section else "")) if QUESTION_TAG in tags and QUESTION_TAG not in task["tags"]: print(f" вопрос открыт — в работу задача не берётся, пока он не разобран" f" (`tasks.py ready {a.slug}` это и скажет)") @@ -1844,11 +1890,16 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int: def cmd_move(lay: Layout, a: argparse.Namespace) -> int: """Перестановка строки: внутри своей секции или в другую. + **Что значит перестановка, говорит стадия, а не эта команда**: на стройке она + называет зависимость («этот шаг делается после того»), на доработке — + приоритет («это берут раньше»). Отсюда и `--reason`: причина у двух движений + разная, и через месяц её не восстановить. + `--section` необязателен, и это не удобство. Перестановка внутри секции — - самая частая операция груминга (`move --after` и есть расстановка - приоритета), а требовать в ней повторить текущую секцию значит приглашать - указать не ту: перенос в чужую секцию выглядел бы ровно так же. Без - `--section` секция берётся из индекса — та, в которой строка уже лежит. + самая частая операция и там и там, а требовать в ней повторить текущую секцию + значит приглашать указать не ту: перенос в чужую секцию выглядел бы ровно так + же. Без `--section` секция берётся из индекса — та, в которой строка уже + лежит. """ for err in (bad_slug(a.slug), bad_reason(a.reason), bad_slug(a.after) if a.after else None): if err: @@ -1873,6 +1924,15 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int: avail = ", ".join(n for _, n in section_headers(lines)) raise Usage(f"нет секции «{a.section}» в {label} (есть: {avail})") task = parse_task(path) + # Место сырья производно от типа, а не назначается: назначить его — значит + # получить дрейф, который следующий же `check --fix` отменит, стерев решение + # человека. Поэтому отказ, и с названным выходом: сырьё перестаёт быть + # сырьём, как только у него появляется «Вопрос». + if (a.after or a.first) and raw_research(lay, task): + raise Usage(f"{a.slug} — сырьё (`{RESEARCH}` без раздела" + f" «{lay.cfg['question_heading']}»), и место у него не" + f" назначается: конец секции, потому что его не берут." + f" Допиши «{lay.cfg['question_heading']}» — и переставляй") new_text = meta_updated(path, section=section, reason=a.reason, rtype=task["type"] or None) if new_text is None: @@ -1883,9 +1943,12 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int: insert_entry(lines, section, entry, a.after, a.first) except KeyError as e: raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e - if not (a.after or a.first): - lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items() - if raw_research(lay, t)}) + # Сырьё сносится в конец **всегда**, в том числе после `--after`/`--first`: + # переставленная строка от этого не двигается (она не сырьё — отказ выше), + # а вот сырьё, оказавшееся выше неё, встаёт на своё место сразу, а не до + # ближайшего `check --fix`. + lines[:] = raw_last(lines, {n for n, t in tasks_of(lay).items() + if raw_research(lay, t)}) plan = Plan() plan.file(path, new_text) @@ -1905,9 +1968,10 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int: path = lay.items / f"{a.slug}.md" if not path.exists(): raise Usage(f"{a.slug}.md не найден в {lay.cfg['items']}/") + # Строки может не быть, и отказ здесь запирал бы запись навсегда: чтобы + # строку восстановить, надо её куда-то класть, а класть незачем — запись + # закрывают. Так закрывается и то, что пережило упразднение своего индекса. place = locate(lay, a.slug) - if place is None: - raise Usage(f"строки индекса для {a.slug} нет — прогони check --fix") task = parse_task(path) plan = Plan() @@ -1922,13 +1986,16 @@ def cmd_close(lay: Layout, a: argparse.Namespace) -> int: if not prev.endswith("\n"): prev += "\n" plan.file(rej, prev + bullet + "\n") - lines, ei = place - lines.pop(ei) - plan.index(lay, "backlog", lines) + if place is not None: + lines, ei = place + lines.pop(ei) + plan.index(lay, "backlog", lines) plan.delete(path) plan.commit() print(f"{a.slug}: {'записано в ' + lay.name('rejected') + ' + удалено' if a.reason else 'удалено (реализовано, есть коммит)'}") + if place is None: + print(f" строки в {lay.name('backlog')} не было — удалён только файл") if not a.reason: print(" дорога назад: файл восстанавливается из git —" f" `tasks.py reopen {a.slug} --reason «приёмка не сошлась: …»`") @@ -1987,19 +2054,32 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: else: text = upd tmp = parse_task_text(text, path) - plan = Plan() - plan.file(path, text) # Написание — из меты как есть: имя секции уедет в доклад, а сверка # принадлежности всё равно идёт по нижнему регистру (`find_section`). section = tmp["section_raw"] or tmp["section"] lines = read_lines(lay.index("backlog")) + heads = section_headers(lines) + lost_section = "" + if find_section(lines, section)[0] is None: + # Секции могло не стать законно: смена стадии переразмечает беклог, и + # задача, закрытая до перехода, ссылается на исчезнувшую полку. Отказ + # здесь означал бы, что закрытое до перехода не возвращается никогда, — + # а `reopen` заведён ровно на случай, когда приёмка не сошлась. Кладём в + # первую секцию, правим мету и говорим об этом вслух. + if not heads: + raise Usage(f"в {lay.name('backlog')} нет ни одной секции —" + f" возвращать некуда") + lost_section, section = section, heads[0][1] + rebuilt = meta_rebuilt(text.splitlines(), section=section) + if rebuilt is not None: + text = "\n".join(rebuilt) + "\n" + tmp = parse_task_text(text, path) + + plan = Plan() + plan.file(path, text) 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('backlog')} (есть: {avail})") - insert_entry(lines, sec, entry_line(lay, title, a.slug, tmp["why"])) + insert_entry(lines, section, entry_line(lay, title, a.slug, tmp["why"])) # Место сырья производно от типа, и `reopen` обязан его соблюсти сразу: # вернуть разведку без «Вопроса» просто в конец секции — значит # поставить её после сырья, лежавшего там раньше, и получить ошибку @@ -2028,8 +2108,11 @@ def cmd_reopen(lay: Layout, a: argparse.Namespace) -> int: # а его назначает человек. Молча вернуть задачу наверх очереди значило бы # принять за него решение, которого он не принимал. print(f"{a.slug}: возвращён в {lay.name('backlog')} из истории git" - f" (секция «{section}», в конец: позиция это приоритет, и её" - f" назначает человек)") + f" (секция «{section}», в конец: позицию назначает человек)") + if lost_section: + print(f" секции «{lost_section}» в беклоге больше нет — положен в" + f" «{section}», «{PLACE_KEY}» в файле поправлена. Так бывает после" + f" смены стадии: состав секций там переразмечается") for line in removed: print(f" снята строка {lay.name('rejected')}: {line.strip()}") print(" сверь тело: оно восстановлено на момент удаления, всё позднейшее" @@ -2181,6 +2264,37 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: # принимают решение. for name, task in tasks.items(): rtype = task["type"] + # Мёртвые теги и прежнее имя поля места снимаются у **любой** записи, + # включая ту, чей тип машина исправить не берётся. Тег `goal:` мёртв + # независимо от того, что за запись его несёт, а «Секция» — прежнее имя + # того же самого поля. Стой эта правка после разбора типа, перевод + # проекта оставлял бы их ровно в файлах целей — то есть в тех, которые + # человек как раз и разбирает руками, и разбирал бы он их с мусором. + want_key = PLACE_KEY.lower() + tags = [t for t in task["tags"] + if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG)) + and t != LEGACY_DECOMPOSED_TAG] + renamed = bool(task["place_key"]) and task["place_key"] != want_key + retyped = bool(rtype) and rtype in TYPES and task["meta_type"] != rtype + if tags != task["tags"] or renamed or retyped: + if not stage(task, rtype=rtype if retyped else None, + tags=tags if tags != task["tags"] else None): + ambiguous.append(f"{name}: чинить мету некуда — в файле нет" + f" мета-блока") + else: + what = [] + if retyped: + what.append(f"тип «{rtype}» в поле **Тип:**") + if task["legacy_kind"]: + what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}") + if task["legacy_goal"]: + what.append(f"снят тег {task['legacy_goal']}") + if LEGACY_DECOMPOSED_TAG in task["tags"]: + what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}") + if renamed: + what.append(f"поле места → «{PLACE_KEY}»") + fixed.append(f"{name}: " + ", ".join(what)) + if not rtype: ambiguous.append(f"{name}: тип не выводится — нет ни поля **Тип:**, ни" f" тега {LEGACY_KIND_TAG}<род>, ни префикса заголовка." @@ -2197,29 +2311,6 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: f" ({', '.join(TYPES)}) — чем он заменяется," f" решает человек") continue - want_key = PLACE_KEY.lower() - # Прежние теги снимаются здесь же: у обоих не осталось того, на что они - # указывали, — целей нет, а разбирать нечего. - tags = [t for t in task["tags"] - if not t.startswith((LEGACY_KIND_TAG, LEGACY_GOAL_TAG)) - and t != LEGACY_DECOMPOSED_TAG] - renamed = bool(task["place_key"]) and task["place_key"] != want_key - if task["meta_type"] != rtype or tags != task["tags"] or renamed: - if not stage(task, rtype=rtype, - tags=tags if tags != task["tags"] else None): - ambiguous.append(f"{name}: тип «{rtype}» переносить некуда —" - f" в файле нет мета-блока") - else: - what = [f"тип «{rtype}» в поле **Тип:**"] - if task["legacy_kind"]: - what.append(f"снят тег {LEGACY_KIND_TAG}{task['legacy_kind']}") - if task["legacy_goal"]: - what.append(f"снят тег {task['legacy_goal']}") - if LEGACY_DECOMPOSED_TAG in task["tags"]: - what.append(f"снят тег {LEGACY_DECOMPOSED_TAG}") - if renamed: - what.append(f"поле места → «{PLACE_KEY}»") - fixed.append(f"{name}: " + ", ".join(what)) want_h1 = h1_of(rtype, task["bare"]) if task["title"] and task["title"] != want_h1: src = staged_lines(task) @@ -2287,11 +2378,14 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: insert_entry(idx[kind], section, entry_line(lay, task["title"], name[:-3], task["why"])) # Восстановленная строка встаёт в конец секции, и это надо сказать: - # позиция в беклоге — приоритет, а его назначает человек. Молчаливое - # восстановление выдало бы машинную позицию за его решение. + # позицию назначает человек, а машинная выдала бы себя за его + # решение. Чем именно она была бы — зависимостью или приоритетом, — + # решает стадия, и назвать её тут дешевле, чем заставлять вспоминать. + means = ("зависимость" if lay.stage == BUILD else + "приоритет" if lay.stage == SUPPORT else "порядок работ") fixed.append(f"{lay.name(kind)}: восстановлена строка {name}" - " — в конце секции, позицию назначь сам:" - " порядок строк это приоритет" + f" — в конце секции, позицию назначь сам:" + f" порядок строк это {means}" + ("" if task["why"] else " (в файле нет «зачем» — допиши)")) dirty.add(kind) continue @@ -2311,6 +2405,13 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]: # имеет; тем более он не сливает их на стройке — в каком порядке пойдут # строки слитых полок, знает только человек. for kind, lines in idx.items(): + if kind == "backlog" and lay.stage in STAGES: + span = stage_block_span(lines) + if span is not None and lines[span[0]:span[1]] != stage_block(lay.stage): + lines[span[0]:span[1]] = stage_block(lay.stage) + fixed.append(f"{lay.name(kind)}: шапка переписана под стадию" + f" «{STAGE_RU[lay.stage]}»") + dirty.add(kind) raw = {n for n, t in tasks.items() if raw_research(lay, t)} if (moved := raw_last(lines, raw)) != lines: lines[:] = moved @@ -2360,9 +2461,50 @@ BACKLOG_ORDER = { "одной, по мере появления; пустой беклог — нормальное состояние.", } +# Абзац шапки, объявляющий стадию, размечен парой комментариев — и это не +# украшение. Стадия решает, что значит порядок строк, а читают об этом **здесь**: +# индекс открывают вместо документации. Без разметки `stage` не знал бы, что +# переписывать, и абзац продолжал бы называть прежнюю стадию — молча и навсегда. +# С разметкой расхождение шапки с конфигом становится обычным дрейфом: `check` +# его называет, `check --fix` правит. +STAGE_OPEN = "" +STAGE_CLOSE = "" -def init_files(lay: Layout, sections: list[str], stage_name: str, - cfg: dict) -> dict[Path, str]: + +def stage_block(stage_name: str) -> list[str]: + return [STAGE_OPEN, + f"Стадия проекта — **{STAGE_RU[stage_name]}**" + f' (`[tasks] {STAGE_KEY} = "{stage_name}"`).', + BACKLOG_ORDER[stage_name], + STAGE_CLOSE] + + +def stage_block_span(lines: list[str]) -> tuple[int, int] | None: + """Границы размеченного абзаца — `[начало, конец)`. None, если разметки нет.""" + try: + start = next(i for i, ln in enumerate(lines) if ln.strip() == STAGE_OPEN) + end = next(i for i in range(start + 1, len(lines)) + if lines[i].strip() == STAGE_CLOSE) + except StopIteration: + return None + return start, end + 1 + + +def stage_block_verdict(lay: Layout, lines: list[str], label: str) -> list[str]: + """Шапка беклога против конфига. Разметки нет — молчим: индекс мог быть + заведён до её появления, и требовать её от чужого файла не за что.""" + span = stage_block_span(lines) + if span is None or lay.stage not in STAGES: + return [] + want = "\n".join(stage_block(lay.stage)) + if "\n".join(lines[span[0]:span[1]]).strip() == want.strip(): + return [] + return [f"{label}: шапка объявляет не ту стадию, что конфиг" + f" ({STAGE_RU[lay.stage]}) — а читают о смысле порядка строк" + f" именно её; перепишет `check --fix`"] + + +def init_files(lay: Layout, sections: list[str], stage_name: str) -> dict[Path, str]: out: dict[Path, str] = {} # Служебный файл здесь не заводится: его пишет `write_config` по живому # файлу — версию двигает построчно, ключи дописывает, чужого не затирает. @@ -2372,9 +2514,7 @@ def init_files(lay: Layout, sections: list[str], stage_name: str, "# Беклог\n\n" f"Что **можно взять**. Одна задача = один файл `{lay.cfg['items']}/.md`\n" "+ строка здесь. Ведётся скиллом `av-dev:task-track`.\n\n" - f"Стадия проекта — **{STAGE_RU[stage_name]}** (`[tasks] {STAGE_KEY} =" - f' "{stage_name}"`).\n' - + BACKLOG_ORDER[stage_name] + "\n\n" + + "\n".join(stage_block(stage_name)) + "\n\n" f"Одно место в очереди назначено не человеком, а типом: сырьё" f" (`{RESEARCH}`\nбез раздела «{lay.cfg['question_heading']}») стоит в" " конце секции — его не берут.\n\n" @@ -2491,7 +2631,7 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int: lay.items.mkdir(parents=True, exist_ok=True) plan = Plan() - for path, text in init_files(lay, sections, stage_name, cfg).items(): + for path, text in init_files(lay, sections, stage_name).items(): plan.file(path, text) plan.commit() said = write_config(project, cfg) @@ -2507,17 +2647,17 @@ def cmd_init(root: Path, a: argparse.Namespace) -> int: # --- stage: смена стадии проекта --- def cmd_stage(lay: Layout, a: argparse.Namespace) -> int: - """Показать стадию или сменить её. + """Показать стадию, объявить её впервые или сменить. - Смена — событие, а не настройка: она переворачивает смысл порядка строк - (зависимость ↔ важность) и состав секций. Записывается она ключом конфига, - и датой ей служит коммит — отдельного журнала для одной строки заводить не - стоит. + **Объявление и смена — разные операции, и различает их не флаг, а факт:** + была ли стадия названа раньше. Объявление беклога не трогает вовсе — оно + называет то, что уже верно, и переразметить при этом чужие полки значило бы + подменить ответ на вопрос вопросом о нём. Смена трогает шапку и конфиг, а + состав секций — **только если её об этом попросили** `--sections`. - Остаток беклога переезжает в первую новую секцию **целиком и молча не - остаётся**: строки, писавшиеся как план, на доработке продолжают жить - задачами, но порядок их с этого момента значит другое, и об этом говорится - вслух. + Слить полки сам скрипт не берётся ни в одном из случаев (решение Р240): в + каком порядке пойдут строки слитых полок, знает человек. Отсюда и отказ на + стройке при нескольких секциях — с названным выходом, а не глухой. """ if a.to is None: print(f"стадия: {STAGE_RU.get(lay.stage, 'не объявлена')}" @@ -2528,54 +2668,107 @@ def cmd_stage(lay: Layout, a: argparse.Namespace) -> int: want = a.to.strip().lower() if want == lay.stage: - raise Usage(f"стадия уже «{want}» — менять нечего") - sections = uniq_sections(a.sections or DEFAULT_SECTIONS[want]) - if not sections: - raise Usage("пустой список секций") - if want == BUILD and len(sections) > 1: - raise Usage(f"на стройке секция одна, а названо {len(sections)}" - f" ({', '.join(sections)})") + raise Usage(f"стадия уже «{STAGE_RU[want]}» ({want}) — менять нечего") + declaring = lay.stage not in STAGES lines = read_lines(lay.index("backlog")) heads = section_headers(lines) - # Тело каждой секции — всё, что под её заголовком: и строки-пункты, и проза. - # Собирается оно в первую новую секцию в прежнем порядке секций: другого - # порядка машина не знает, а выдумать его значило бы переставить чужую - # очередь. - head = lines[:heads[0][0]] if heads else lines - body: list[str] = [] - for k, (i, _) in enumerate(heads): - end = heads[k + 1][0] if k + 1 < len(heads) else len(lines) - body += lines[i + 1:end] - moved = sum(1 for line in body if INDEX_ENTRY.match(line)) + if not heads: + raise Usage(f"в {lay.name('backlog')} нет ни одной секции — стадию" + f" объявлять не над чем. Заведи секцию заголовком «## …»" + f" и повтори") + current = [name for _, name in heads] - out = [*head] - for k, s in enumerate(sections): - out.append(f"## {s}") - if k == 0: - out += body + if a.sections is not None and declaring: + raise Usage("--sections при объявлении стадии не принимается: объявление" + " называет то, что уже верно, и беклог не переразмечает." + " Секции меняет `move <слаг> --section <секция> --reason …`") + sections = uniq_sections(a.sections) if a.sections is not None else current + if not sections: + raise Usage("пустой список секций") + if want == BUILD and len(sections) > 1: + raise Usage( + f"на стройке беклог — один список от базы к деталям, а секций" + f" {len(sections)}: {', '.join(sections)}. Слить их машина не берётся —" + f" порядок строк в слитом списке знает только человек. Либо слей сам" + f" (`move <слаг> --section <куда> --reason …`) и повтори, либо назови" + f" итоговую секцию явно: `stage {BUILD} --sections <имя>`" + + (" (при объявлении стадии этот флаг не принимается —" + " сперва слей руками)" if declaring else "")) + + merge = sections != current plan = Plan() - plan.index(lay, "backlog", out) - # Категория в файлах — производна от заголовка индекса, и разъехаться ей - # нельзя: `check` назовёт это дрейфом на первой же записи. - retyped = 0 - for task in tasks_of(lay).values(): - if task["section_raw"] == sections[0]: - continue - text = meta_updated(task["path"], section=sections[0], - rtype=task["type"] or None) - if text is None: - continue - plan.file(task["path"], text) - retyped += 1 - plan.commit() - conf.set_section_key(lay.project, "tasks", STAGE_KEY, want) + skipped: list[str] = [] + moved, retyped = 0, 0 - was = STAGE_RU.get(lay.stage, "не объявлена") - print(f"стадия: {was} → {STAGE_RU[want]}") - print(f" секции беклога: {', '.join(sections)};" - f" перенесено строк {moved}, поправлена «{PLACE_KEY}» у {retyped} файлов") - if want == SUPPORT: + if merge: + # Тело каждой секции — всё, что под её заголовком: и строки-пункты, и + # проза. Собирается оно в первую новую секцию в прежнем порядке секций: + # другого порядка машина не знает, а выдумать его значило бы переставить + # чужую очередь. + head = lines[:heads[0][0]] + body: list[str] = [] + for k, (i, _) in enumerate(heads): + end = heads[k + 1][0] if k + 1 < len(heads) else len(lines) + body += lines[i + 1:end] + moved = sum(1 for line in body if INDEX_ENTRY.match(line)) + lines = [*head] + for k, s in enumerate(sections): + lines.append(f"## {s}") + if k == 0: + lines += body + # Категория в файлах производна от заголовка индекса, и разъехаться ей + # нельзя: `check` назовёт это дрейфом на первой же записи. + for name, task in tasks_of(lay).items(): + if task["section_raw"] == sections[0]: + continue + text = meta_updated(task["path"], section=sections[0], + rtype=task["type"] or None) + if text is None: + skipped.append(name) # мета не пересобирается — скажем вслух + continue + plan.file(task["path"], text) + retyped += 1 + + # Шапка объявляет стадию, и читают о смысле порядка строк именно её. Не + # переписать её значило бы оставить в индексе прямое враньё. + span = stage_block_span(lines) + if span is not None: + lines[span[0]:span[1]] = stage_block(want) + # Место сырья производно от типа, и слияние секций его нарушает: сырьё из + # второй полки оказывается в середине списка. Без этого шага переход + # оставлял бы каталог красным на ровном месте. + plan.index(lay, "backlog", raw_last(lines, {n for n, t in tasks_of(lay).items() + if raw_research(lay, t)})) + plan.commit() + + # Конфиг: файл есть — правим ключ, файла нет — заводим скелетом. Иначе + # объявление стадии в проекте без `.av-dev.toml` рождало бы конфиг без + # версии раскладки, то есть меняло один отказ `check` на другой. + if (lay.project / CONFIG_NAME).is_file(): + conf.set_section_key(lay.project, "tasks", STAGE_KEY, want) + said = [f"{CONFIG_NAME}: [tasks] {STAGE_KEY} = «{want}»"] + else: + said = write_config(lay.project, {STAGE_KEY: want}) + + print(f"стадия: {'объявлена' if declaring else STAGE_RU.get(lay.stage, '—') + ' →'}" + f" {STAGE_RU[want]}") + for line in said: + print(f" {line}") + if merge: + print(f" секции слиты в «{sections[0]}»: перенесено строк {moved}," + f" поправлена «{PLACE_KEY}» у {retyped} файлов") + else: + print(f" секции беклога не тронуты: {', '.join(sections)}") + if span is None: + print(f" шапка {lay.name('backlog')} не размечена ({STAGE_OPEN}) —" + f" абзац про стадию перепиши сам: он называет прежнюю") + for name in skipped: + print(f" НЕ ТРОНУТ {name}: мета не пересобирается (нет поля" + f" **{PLACE_KEY}:**) — «{sections[0]}» проставь сам") + if declaring: + print(" беклог не тронут: объявление называет то, что уже верно") + elif want == SUPPORT: print(" порядок строк с этого момента значит важность, а не зависимость:" " прежний план шёл по зависимости, и как очередь он не расставлен." " Первый заход — груминг (скилл av-dev:task-groom)") @@ -2931,10 +3124,10 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: # Версия та же, что у `init`: каталог выводится из чужой раскладки сегодня и # сегодняшним форматом, сколько бы лет ни было тому, из чего он выведен. # Имён частей здесь нет — адаптация раскладывает всё по умолчаниям. - for path, text in init_files(lay, sections, stage_name, {}).items(): + skeleton = init_files(lay, sections, stage_name) + for path, text in skeleton.items(): wr.file(path, text) - backlog_lines = init_files(lay, sections, stage_name, - {})[lay.index("backlog")].splitlines() + backlog_lines = skeleton[lay.index("backlog")].splitlines() renames: dict[str, str] = {} for it in pl.get("items", []): @@ -2968,7 +3161,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: entry_line(lay, title, slug, it.get("why", ""))) if pl.get("rejected"): - head = init_files(lay, sections, stage_name, {})[lay.index("rejected")] + head = skeleton[lay.index("rejected")] body = [] for line in pl["rejected"]: line = re.sub(r"Был приоритет:", "Была секция:", line) @@ -2979,8 +3172,14 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int: # Через `index`, а не `file`: отбивка секций живёт на записи индекса, и # каталог, собранный в обход неё, встречал бы человека ошибкой `check` - # на первом же прогоне. - wr.index(lay, "backlog", backlog_lines) + # на первом же прогоне. По той же причине здесь же место сырья: карта могла + # положить разведку без «Вопроса» в середину списка, и `check --fix` потом + # переставил бы её — то есть тронул бы порядок, который человек подтвердил. + raw_now = {f"{it.get('slug') or it['old_slug']}.md" for it in pl.get("items", []) + if (it.get("type") or "").lower() == RESEARCH + and not re.search(rf"^##\s+{re.escape(lay.cfg['question_heading'])}\s*$", + it.get("body", ""), flags=re.M | re.I)} + wr.index(lay, "backlog", raw_last(backlog_lines, raw_now)) if a.dry_run: print(f"пробный прогон: записалось бы файлов {len(wr.writes)}," @@ -3081,7 +3280,7 @@ def main() -> int: p.add_argument("--rm-tag", dest="rm_tag") p.add_argument("--dir") - p = sub.add_parser("move", help="переставить строку: место в очереди или другая секция") + p = sub.add_parser("move", help="переставить строку: место в списке или другая секция") p.add_argument("slug") p.add_argument("--section", help="другая категория беклога;" " без него — текущая секция записи") diff --git a/scripts/addresses.py b/scripts/addresses.py index af2e866..76be3c5 100644 --- a/scripts/addresses.py +++ b/scripts/addresses.py @@ -127,7 +127,10 @@ def vocabularies(root: Path) -> tuple[dict[str, set[str]], dict[str, str]]: tasks_names = {stem(tasks.DEFAULTS[k]) for k in tasks.PATH_KEYS} tasks_names |= {stem(tasks.CONFIG_NAME)} - retired = {stem(n): why for n, why in docs.RETIRED.items()} + # Упразднённое собирается у **обоих** владельцев: слоты канона знает + # `docs.py`, части каталога задач — `tasks.py`, и перечень одного из них + # молчал бы про упразднения другого. + retired = {stem(n): why for n, why in {**docs.RETIRED, **tasks.RETIRED}.items()} return {"docs": docs_names, "tasks": tasks_names}, retired