av-dev-pm расколот на av-dev-docs и av-dev-tasks

Плагин владел двумя разными вещами сразу — документацией проекта и учётом работ,
— и это мешало обеим. Канон нельзя было поставить без задач, задачи без канона, а
язык проектных текстов лежал внутри скилла canon и потому принадлежал половине.
Теперь плагина два, каждый ставится сам по себе.

av-dev-docs: скиллы canon, docs, init; агенты doc-consistency, doc-code-drift,
doc-wording; скрипт docs.py. av-dev-tasks: скиллы tasks, session; агенты
task-form, task-wording; скрипт tasks.py.

Между собой они зовутся через пространство имён, а не по пути в чужое дерево.
Все относительные ссылки, пересекшие границу плагина, сняты: tasks больше не
указывает в canon, canon не указывает в tasks. Вместо ссылки — имя скилла и
оговорка, что вызов может не разрешиться, и это исход, а не поломка.

То, что нужно обоим дословно, стало вторым общим домом. Словарь «Сопровождение и
эксплуатация» назван в трёх местах трёх плагинов — секция роадмапа, раздел
«Эксплуатация» в architecture.md, тема ревью operations — и ни один из трёх им не
владеет; он уехал в shared/operations.md, а canon.md и скилл задач везут копии.
Три перечня «чем держат проект» уже разъезжались на «метриках и логах» против
«мониторинга», так что ссылка тут не годится: плагин, поставленный в одиночку,
получил бы указатель в никуда. Тем же способом язык: у av-dev-tasks появилась
своя копия language.md.

Копий стало 18 при 8 домах.

Переименования разведены по смыслу, а не заменой строки: где речь о каноне —
av-dev-docs, где об учёте задач — av-dev-tasks. В пайплайне таких мест
одиннадцать, и оба адресата там встречаются вперемешку.

Журналы (DECISIONS, TODO, HISTORY) намеренно не тронуты: они описывают состояние
на момент записи. По той же причине оставлена наблюдённая строка в комментарии
docs.py — она цитирует конфиг живого проекта, а не называет плагин.

Не входит в этот заход и названо отдельно: слияние canon и docs в один скилл,
разделение docs/.pm.json на два конфига и переезд openspec в пайплайн.

Гейт зелёный: копии, фронтматтеры, диаграммы, json. Оба скрипта прогнаны после
переезда — docs.py version и tasks.py check на фикстуре.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-09 14:06:26 +03:00
co-authored by Claude Opus 5
parent 86e22d932c
commit 00ddfb0dde
42 changed files with 417 additions and 97 deletions
@@ -0,0 +1,425 @@
# Формат записей и индексов
Заголовок, мета-блок и строку индекса ставит `tasks.py add` — руками их не
пишут. Этот файл описывает **общую форму** любой записи и то, что проверяет
`check`; тело дописывает агент.
Чем разделы тела отличаются от типа к типу, какой алгоритм у каждого типа и что
у него обязательно — **отдельным файлом на тип**:
| Тип | Файл | Одной строкой |
| --- | --- | --- |
| 🎯 `goal` | [task-goal.md](task-goal.md) | возможность приложения |
| ✨ `feature` | [task-feature.md](task-feature.md) | снаружи появляется то, чего не было |
| 🐞 `fix` | [task-fix.md](task-fix.md) | поведение расходится с заявленным |
| 🧹 `chore` | [task-chore.md](task-chore.md) | обслуживание, поведение не меняется |
| 🔬 `research` | [task-research.md](task-research.md) | исход — знание, а не изменение |
## Файл записи
`items/<slug>.md`:
```markdown
# 🐞 Не отбрасывать молча лишние символы в ходе
- **Тип:** fix
- **Категория:** Ядро — вышла из спринта: остаток писал нерешённое в журнал
- **Зачем:** ввод «а1б2» ходит в a1 — игрок не видит, что ошибся, и винит игру
- **Теги:** goal:merge-robustness, sprint:2026-08-03
Разбор хода читает первые два символа и молча выбрасывает остаток строки.
## Воспроизведение
Ввести `а1б2` в свой ход: программа ходит в `a1` и ничего не сообщает.
Ожидалось — отказ с ошибкой разбора.
## Затрагивает
Разбор строки хода; текст ошибки в выводе партии. Формат сохранения партии
не трогается.
## Критерии приёмки
- ввод «а1б2» отвергается с ошибкой — оракул: тест разбора
- ввод «а1» принимается по-прежнему — оракул: тест разбора
## Рамки
Схема не трогается; данные только читаются; перезапуск допустим.
Связано: решение о канонической форме содержимого.
```
- **Заголовок H1** — он же заголовок строки в индексе, дословно. Начинается
**эмодзи типа**, и она **производна**: её ставит `add` и чинит `check --fix`
по полю меты. Второго дома у типа нет — эмодзи это его отображение, как
строка индекса это отображение файла.
- **Форма заголовка — по типу.** Цель отвечает на «что приложение будет уметь»;
`feature`, `fix` и `chore` — на «что нужно сделать», глаголом в неопределённой
форме, перед ним допускается «не»; `research` называет предмет разведки и
формы действия **не несёт намеренно**. Почему так — SKILL.md, «Как написана
задача». `check` считает заголовки не в форме действия и печатает число в
здоровье; годность формулировки смотрит агент `task-form`.
- **Мета-блок** — список сразу после заголовка, **поле на строку**. Обязательны
**тип** и **место**, причина после тире желательна (именно она объясняет,
почему задача здесь оказалась — в том числе «вышла из спринта: …»), «зачем» и
теги необязательны. Нераспознанные поля сохраняются: скрипт правит свои и не
трогает чужие.
- **Тип — первым полем.** Он решает, что у записи вообще может быть: какие
разделы обязательны, нужна ли цель, берётся ли она в спринт, — и читается
раньше всего остального. Словарь **закрыт**: `goal` | `feature` | `fix` |
`chore` | `research`. Не подходит ни один — это сигнал, что в записи их два и
её надо разделить.
- **«Зачем» отвечает на «зачем нужна эта задача»** — состояние, остаток, боль.
Не пересказ задачи: пересказ уже есть по ссылке. Живёт здесь, а не только в
индексе: строка индекса его повторяет и производна от него, `check` сверяет,
`check --fix` восстанавливает пропавшую строку **вместе с ним**. Пока поле
лежало только в индексе, штатная починка дрейфа теряла его молча и
навсегда — а это единственное, по чему задачу выбирают, не открывая.
- **Тело** — одна фраза «что станет наблюдаемо иначе», дальше разделы по схеме
типа. Пишется на языке документации проекта: предметно, без англицизмов, у
которых есть русское слово, и без терминов, которых нет ни в паспорте, ни в
архитектуре, ни в конвенциях (правило и его причина — в SKILL.md, раздел «Как
написана задача»).
Тело — не план реализации и не спецификация: принятое и реализованное переезжает
в документацию проекта, а файл задачи удаляется.
### Поле места: «Категория» и «Секция»
Поле называет, **где числится строка**, и имя у него **зависит от типа**:
| Тип | Поле | Значения | Что это |
| --- | --- | --- | --- |
| `goal` | **Секция** | `Запланировано`, `Направления`, `Сопровождение` | часть роадмапа: состояние очереди |
| прочие | **Категория** | секции беклога проекта (`Ядро`, `Инфра`, …) | полка домена, в которую задача вернётся из спринта |
Разные имена потому, что это **разные вещи**. У задачи поле переживает спринт:
`sprint drop` возвращает её именно туда. У цели оно называет не полку, а место в
очереди работ. Одно имя на два смысла их и смешивало; `check` называет
несовпадение дрейфом, `check --fix` переименовывает.
Имя самого места принадлежит **заголовку индекса** — файл на него лишь
ссылается, и принадлежность сверяется по нижнему регистру.
### Прежние формы, которые читаются, но не пишутся
Всё это `check` называет дрейфом, а `check --fix` переписывает:
| Было | Стало |
| --- | --- |
| префикс `[goal]` / `[idea]` в H1 | поле **Тип** + эмодзи в H1; `[idea]``research` |
| тег `kind:<род>` | поле **Тип** (род работы стал типом) |
| поле **Секция** у задачи | поле **Категория** |
| поле **Хук** | поле **Зачем** |
| мета одной строкой через `·` | мета списком, поле на строку |
Единственное, чего `--fix` не делает сам, — **проставить тип записи, у которой
его неоткуда взять**: `feature` от `chore` машина не отличает, и подставленное
наугад значение врало бы ровно там, где по нему принимают решение. Такие записи
он называет поимённо пометкой `НЕОДНОЗНАЧНО`.
### Затрагивает
Перечень **границ**, которых изменение касается. Границей считается то, у чего
есть внешняя сторона и цена изменения:
- эндпоинт, команда, форма ответа, код ответа;
- таблица, поле, миграция, формат на диске, формат сообщения в очереди;
- публичный тип или функция пакета, конфиг и его образцы;
- внешний сервис или библиотека, чьё поведение становится нужным.
Ничего из этого не трогается — так и пишется: «границ не трогает, изменение
внутри одного узла». Это ответ, а не пустой раздел.
**Границы, а не замысел.** «Переписать хранилище на новый драйвер» — замысел;
`таблица points и её миграция`, `эндпоинт POST /ingest` — границы. Разница
проверяется вопросом «это можно назвать до того, как решено *как* делать?»: если
нет, строка описывает реализацию, и её место в предложении об изменении.
**Свойства репозитория сюда не пишутся** — по той же причине, что и в рамки:
имя таблицы стабильно, номер последней миграции протухает молча. Пишется
`таблица points и её миграция`, а не `миграция 0042`.
**Что из этого механизировано.** `check` и `sprint take` смотрят только на
**наличие непустого раздела**. Полнота перечня машине не видна: границу, которую
забыли назвать, она от отсутствующей не отличает. Раздела нет — отказ во взятии:
оценивать нечем.
**У `goal` и `research` раздела нет** — у первой границы называют её задачи, у
второй они становятся известны, когда из разведки родятся задачи.
### Критерии приёмки
2–5 проверяемых утверждений **списком** `- …`, **у каждого назван оракул**. Не
«работает корректно», а «повторный прогон даёт тот же отпечаток — оракул:
команда сверки». Это не второе определение готовности, а проектная
конкретизация вопроса «по чему видно, что закончено» из теста готовности ниже:
там сказано «признак завершённости», здесь — «признак плюс чем проверяется».
**Что из этого механизировано.** `check` и `sprint take` считают пункты: меньше
двух — отказ («— работает» одной строкой больше не проходит), больше пяти —
замечание, обычно это признак, что задача крупнее задачи. Наличие оракула
проверяется **эвристикой** — словом «оракул» в пункте, — и потому даёт только
замечание: настоящий оракул от слова «оракул» машина не отличает, и делать вид,
что проверено больше проверенного, хуже, чем не проверять вовсе.
**У `research` критериев нет** — её приёмка это записанный ответ, и описывается
она разделами «Вопрос» и «Куда ляжет ответ». **У `goal` их заменяет
«Завершение».**
**Критерии — пол, но расхождение с ними есть дефект критериев.** Если приёмщик
видит, что критерии закрыты, а суть задачи не достигнута, он **правит критерии и
возвращает задачу исполнителю**, а не держит невидимое сверх-требование. Иначе
исполнитель никогда не знает, закончил ли, и мотивирован занижать критерии
заранее.
### Рамки
Одна строка: чего касаться нельзя, что перезапускается, что считается
необратимым, трогается ли схема данных. Раздел **допустим у любого типа задачи и
ни у одного не обязателен**. **Свойства репозитория сюда не пишутся** — номер
последней миграции, версия зависимости, хеш: в лежалой задаче они протухают
молча и становятся ложной рамкой. Снимок берётся при постановке, а не при
заведении.
### Вопросы
Неразобранное решение человека живёт разделом `## Вопросы` **плюс тегом
`question`**. Раздел без тега или тег без раздела — дрейф, `check` о нём скажет.
Раздел `Вопросы` (о решении человека) и раздел `Вопрос` у `research` (предмет
разведки) — **разные вещи и разные слова**: первый блокирует взятие, второй его
разрешает.
**Судит факт, а не метка.** Отказ во взятии даёт **непустой раздел «Вопросы»**,
независимо от того, стоит ли тег: иначе забывший тег проходил бы, а поставивший
спотыкался — стимул ровно обратный записанному правилу. Тег производен: он нужен
отбору снаружи файла (`list --questions`, `list --tag question`), и его
отсутствие при непустом разделе — замечание, а не лазейка. Тег без раздела тоже
отказ, но с другим советом: либо вопрос записан не туда, либо тег пора снять.
**Ответ на вопрос — три правки, и первая обязательна.** Раздел «Вопросы»
опустошается: ответ переезжает в тело решением, а не остаётся вопросом рядом с
ответом. Затем снимается тег (`edit <slug> --rm-tag question`) и переписывается
«зачем»: «Решено: …» на вопрос «зачем нужна эта задача» уже не отвечает.
**Порядок именно такой, потому что судит раздел, а не тег.** `sprint take`
смотрит в непустой раздел и откажет взять задачу даже со снятым тегом, а `check`
на снятый тег при непустом разделе посоветует тег вернуть. Снять тег, не
опустошив раздел, — значит закольцевать себя между двумя советами.
## Файл цели
Форма та же, разделы и алгоритм — [task-goal.md](task-goal.md).
```markdown
# 🎯 Исход слияния не зависит от порядка доставки
- **Тип:** goal
- **Секция:** Направления
- **Теги:** decomposed
Ради чего: точки из разных доставок сходятся в один часовой объект, и сегодня
исход столкновения зависит от порядка доставки, а не от содержания.
## Завершение
- повторная доставка тех же точек в другом порядке даёт то же состояние;
- накопительная метрика за сутки не уменьшается после повторной доставки;
- в логе видно, какая из двух точек выиграла и почему.
```
- **Задачи цели здесь не перечисляются.** Перечень даёт
`tasks.py list --goal <слаг>`; хранимый список стал бы третьим индексом и
поехал бы на первой же закрытой задаче.
- **Тег `decomposed`** отличает «цель ещё не разобрана» от «все её задачи
закрыты» — два состояния, у которых снаружи один и тот же признак: задач нет.
Пометка именно **тегом**, а не строкой в теле: только так она проверяется.
`check` напоминает о нём у цели без задач замечанием — неразобранная цель
законна и зелёного прогона не ломает; `check --fix` сам ставит его цели, у
которой задачи есть, а цель с тегом и без задач — прямое приглашение закрыть.
- Цель живёт в `ROADMAP.md` и **никогда** — в `BACKLOG.md` или `SPRINT.md`.
- **Достигнутая цель не исчезает.** `close <слаг> --implemented` удаляет файл и
переносит строку в секцию `Готово` с датой:
`- 2026-08-04 \`merge-order\` — Исход слияния не зависит от порядка доставки. …`
Ссылки на файл в ней нет — файл удалён, а битая ссылка это ошибка `check`.
Поведение живёт в спеках проекта; роадмап отвечает, **когда и в каком порядке**
оно появилось.
## Слаг
Латиница и цифры, kebab-case, без ведущих, хвостовых и двойных дефисов
(`foo-bar`, не `-foo`, `a--b`). Именуется **по сути, а не по текущей
формулировке**: заголовок будет переписан при переоценке, а слаг стоит в ссылках
из других задач, коммитов и черновиков. **Транслита не заводим** —
`tie-break-equal-completeness`, а не `taj-brejk-pri-ravnoj-polnote`: транслит
нечитаем для того, кто ищет по смыслу, и не сокращается.
Переименование слага — не правка, а перенос ссылок: делается одним атомарным
проходом по всем местам, где слаг упомянут, иначе останутся битые ссылки,
которых никто не проверяет.
## Индексы
Строка везде одной формы:
```markdown
- [🐞 Заголовок дословно](items/slug.md) — зачем
```
«Зачем» отвечает на «зачем нужна эта задача» одним предложением: состояние,
остаток, боль. Пересказ первого абзаца бесполезен — он уже есть по ссылке.
Эмодзи внутри квадратных скобок не украшение: заголовок копируется **дословно**,
и тип виден там, где решают «брать или не брать».
| Файл | Что отвечает | Секции |
| --- | --- | --- |
| `ROADMAP.md` | что приложение уже умеет и чего ещё не умеет | канонические и в этом порядке: `Запланировано`, `Направления`, `Сопровождение`, `Готово` (англ. `Planned`, `Directions`, `Operations`, `Done`) |
| `BACKLOG.md` | что **можно взять** — только задачи | категории проекта (по умолчанию Ядро/Инфра) |
| `SPRINT.md` | какая цель (или что её нет) и какой набор заморожен | одна: «Набор» |
| `REJECTED.md` | что ушло без реализации и почему | — |
Шапку `SPRINT.md` пишет `sprint start` — **тем же мета-блоком, что у задачи**:
поле на строку, `- **Цель:** [Заголовок](items/slug.md)`, `- **Начат:**` датой,
`- **Спринт:**` слагом, которым метится урожай. У спринта без цели
(`sprint start --no-goal`) поле «Цель» остаётся на месте и пишется прозой без
ссылки — «не названа»: **«цели нет» и «цель потерялась» обязаны различаться**.
Поэтому и признак «спринт идёт» — слаг, а не цель: слаг есть у любого спринта,
без него нечем метить урожай. Прежняя форма (три поля одной
строкой через `·`) читается по-прежнему и уходит сама: файл переписывается на
следующем `sprint start` и очищается на `sprint close`.
Секции — **единственные заголовки `##` в индексе**: любой другой `##` в
преамбуле проверка сочтёт секцией.
**Порядка «по важности» внутри секции беклога нет** — «что делать дальше»
отвечает набор спринта. Единственный порядок, который есть, **производен от типа
и заполненности**: **сырьё** (`research` без раздела «Вопрос») стоит в конце
своей секции, потому что его не берут, и между берущимся оно каждый раз требует
открыть файл, чтобы это понять. Проверяет `check`, переставляет `check --fix`,
и человек этот порядок не назначает — иначе он был бы приоритетом, которого
здесь нет.
**Секции «блокеры» среди них нет.** Блокер — состояние, а не полка: он живёт до
ответа человека, а следы остаются вопросами в файлах задач распущенного спринта.
Постоянно пустая секция со старой семантикой «разбираются пачками» противоречила
бы правилу «эскалируем немедленно», поэтому `init` её не заводит, а `check`
говорит о ней в чужом беклоге. Переезжаешь с такой секцией — удали её. В секции
**`Запланировано`** очередь значима и обосновывается прозой; двигают строку
`move <slug> --section Запланировано --after <другой>`. В секции **`Готово`**
строки не той формы, что у прочих индексов: дата, слаг, заголовок — как в
`REJECTED.md`, и по той же причине (файла уже нет, ссылаться некуда).
**Секции роадмапа закреплены** — состав, полнота, единство языка и **порядок**
проверяются `check`; категории беклога проект называет сам. Почему так —
SKILL.md. Порядок закреплён потому, что `Готово` копится: стоя первым,
достигнутое отодвигает за экран то, ради чего роадмап открывают чаще всего.
**Заголовок секции пишется с прописной и отбивается пустой строкой с обеих
сторон** — во всех индексах, включая категории беклога, имена которых выбирает
проект. Написание канонических секций и отбивку правит `check --fix`; он же
сводит написание места в мете файла с заголовком индекса.
Индексы **производны**: расходятся с файлом — правим индексы (`check --fix`).
Строку руками не пишут.
Отсюда же ответ на «а если оборвётся посередине». Мутация сперва проверяет всё
и складывает правки, и только потом пишет: сначала все временные файлы, потом
переименования подряд. Полной транзакции на несколько файлов файловая система не
даёт, но окно сжато до цепочки переименований, а **всё, что в нём может
разъехаться, — производное**: файлы целы, индексы восстанавливает `check --fix`.
Поэтому отказ на второй задаче из пяти не оставляет первую переписанной при
нетронутых индексах.
`SPRINT.md` и есть артефакт заморозки: без него набор существует только в
контексте сессии, и нарушение заморозки ненаблюдаемо.
## `REJECTED.md`
Туда уходит задача, покинувшая беклог **без реализации**. Строку пишет
`tasks.py close --reason`, а `check` следит за форматом:
```markdown
- 2026-07-23 `versii-kachestvo-repaki` — Версии и качество одного тайтла.
Причина: калибровка болей — не боль, ни разу не возникло за полгода.
Была секция: Инфра.
```
Реализованные сюда не попадают: у них остаётся коммит и документация. У
выкинутой не остаётся ничего — и через квартал она возвращается тем же текстом.
Это первое место, куда смотрит дедупликация при заведении.
Запись не запрещает завести задачу заново: изменился контекст — заводим и
ссылаемся на строку, объясняя, что изменилось.
## Теги
Разметка сверх типа. Тип полем, потому что он один и обязателен; теги — потому
что их много и `list --tag` уже умеет отбирать по ним порцию разбора.
- `goal:<слаг>` — цель, которой служит задача. Обязателен **у `feature`**:
новая возможность и есть содержание цели. У `fix`, `chore` и `research` его
может не быть — они служат работоспособности, а не направлению, и в набор
спринта входят помимо его цели.
- `question` — в файле есть неразобранный раздел «Вопросы».
- `sprint:<слаг>` — задача заведена в этом спринте; по нему отбирается первая
порция разбора («урожай спринта»). **Ставится сам**: слаг спринта заводит
`sprint start` (по умолчанию — дата начала, он же пишется в `SPRINT.md`), и
`add` при открытом спринте помечает заводимое. Тег, который надо помнить
ставить руками, не ставится никогда — а на нём висит правило «первая порция
разбора — урожай прошедшего спринта».
- `decomposed` — на цели: разложена на задачи (см. «Файл цели»).
Тега `kind:<род>` больше нет: род работы стал типом. Оставшийся в файле `check`
называет дрейфом, а `check --fix` снимает, перенеся значение в поле «Тип».
Отбор — `list --tag a,b`: перечисленные через запятую теги требуются **все
сразу** (это И, не ИЛИ). Тег, которого нет ни у одной задачи, `list` называет
вслух: молчаливый ноль читается как «таких задач нет», а чаще это опечатка.
Свои теги проект заводит свободно (партия ревью `review-ГГГГ-ММ-ДД`, тема,
источник) — словарь не фиксирован. В индексы теги не выносим: индексы
производны, отбор делает `list --tag`, а не глаза.
## Тест «готова к взятию»
Задача готова, если из файла отвечаются четыре вопроса. Первый и четвёртый —
общие, второй и третий у каждого типа свои и перечислены в его файле.
1. **Что станет наблюдаемо иначе**, когда она сделана — снаружи: пользователю,
владельцу сервиса или разработчику. «Отрефакторить X» — не ответ; «перестанет
ломаться Y при Z» — ответ. **У `chore` адресат — разработчик, и это
законно**: «уедет последний вызов устаревшего API» — ответ, а не отговорка.
Тип объявлен как раз затем, чтобы такие задачи не выдумывали себе
пользовательскую пользу.
2. **Что известно про сегодня** — то, что тип требует знать до работы:
у `fix` это `Воспроизведение`, у `research` — `Вопрос`, у `feature` и
`chore` — `Затрагивает`.
3. **По чему видно, что закончено** — критерии приёмки с оракулами;
у `research` вместо них `Куда ляжет ответ`.
4. **Какую часть «Завершения» своей цели она двигает** — у задачи с целью.
Строкой: «двигает пункт 2 «Завершения» — накопительная метрика перестаёт
уменьшаться». Это и есть защита от задачи «отрефакторить X»: она проваливает
тест не потому, что невидима снаружи, а потому, что не находит строки, к
которой относится. Заодно видно обратное — достаточен ли набор задач для
цели: строка «Завершения», к которой не относится ни одна задача, это
незакрытая часть возможности.
**У задачи без цели** (`fix`, `chore`, `research`) вопрос не задаётся: они
служат работоспособности, а не направлению.
Не отвечается первый, второй или третий вопрос → это ещё не задача, а **сырьё**:
тип `research` без раздела «Вопрос», место — конец секции, работа над ним —
штурм. Не отвечается четвёртый у `feature` → либо цель есть и не проставлена,
либо это не новая возможность.
Отвечается всё, но задача не делается одним заходом и не мерджится целиком →
это **несколько задач под одной целью**, дроби сразу. Промежуточного зонтика
между целью и задачей нет: тип `[epic]` упразднён, потому что зонтиком стала
сама цель.
Тест применяется при заведении и при переоценке. К старым задачам, которых
операция не касается, задним числом не применяется — беклог не переоформляют
«заодно».