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
+249
View File
@@ -0,0 +1,249 @@
---
name: canon
description: Привести проект к канону документов av-dev и держать его в соответствии — три операции одной машиной сравнения. check — что разошлось с текущей версией канона; adopt — перевод проекта из любой прежней раскладки (docs/specs, drafts, backlog, BRIEF.md, review-brief) в канон с переносом файлов; upgrade — повышение проекта с версии канона N до текущей по журналу версий. Использовать, когда просят проверить документацию проекта, перевести проект на канон, обновить его под новую версию канона или когда пришли в старый проект и надо понять, что в нём не так. Заведение нового проекта с нуля — скилл init.
---
# Приведение проекта к канону
Три операции, одна машина сравнения с разными исходами:
| Операция | Когда | Исход |
| --- | --- | --- |
| `check` | начало сессии, шаг синка, гейт | что разошлось |
| `adopt` | проект в чужой раскладке | перенос в канон |
| `upgrade` | канон вырос, проект отстал | по журналу версий |
**Определение канона — [references/canon.md](references/canon.md).** Здесь оно не
пересказывается: два описания одной раскладки разъедутся, и работать будет то,
которое прочитали последним. Прочитай его **до** первой правки.
- [references/skeletons.md](references/skeletons.md) — **что именно класть** в
каждый незаполненный слот. Не выдумывай заглушку своей формы: `docs.py`
узнаёт только плейсхолдер `<!-- заполнить: … -->` из шаблонов.
- [references/language.md](references/language.md) — **как это написано словами**:
информационный стиль, применённый к проектным текстам, таблицы англицизмов и
жаргона. Раскладка отвечает, где текст лежит, — этот файл отвечает, каким он
должен быть. Правила общие для документов канона, задач, решений ADR и
записок разведки, и дом у них общий — `shared/language.md` в репозитории
плагинов, а этот файл его копия. Вычитывают их два прохода по охвату:
документы — `doc-wording`, записи каталога задач — `task-wording`.
- [references/changelog.md](references/changelog.md) — журнал версий канона.
## Три правила, из которых всё следует
1. **Сперва карта, потом файлы.** Человеку показывается, что найдено, как
разложилось и **что не разложилось**, — и только после подтверждения
переносится хоть один файл. Массовый перенос без подтверждения разгребать
дороже, чем согласовать.
2. **Ничего не терять.** Содержимое переезжает целиком; ссылки чинятся тем же
проходом, что и перенос. Старый файл удаляется **только** после того, как
всё его содержимое нашло дом, и это названо поимённо.
3. **Что не классифицировалось — назвать.** Проглоченный абзац выглядит как
«всё перенеслось». Список «не разложилось» идёт в доклад целиком, с причиной
по каждому пункту.
## Инструмент
```
ds="$CLAUDE_PLUGIN_ROOT/skills/canon/scripts/docs.py"
python3 $ds check --dir <корень> [--base <rev>] # раскладка, ссылки, версия, сверки
python3 $ds version --dir <корень> # версия канона скрипта и проекта
python3 $ds openspec-form # форма config.yaml против живого OpenSpec
```
**`openspec-form` зовут не на каждом прогоне, а когда о нём попросил `check`.**
Форма `openspec/config.yaml` описана в каноне слепком чужого инструмента — имя
схемы и перечень артефактов, — и слепок стареет молча: OpenSpec переименует
артефакт, правила под старым именем перестанут применяться, а конфиг останется
выглядеть написанным. Поэтому `check` каждым прогоном сравнивает `major.minor`
установленного OpenSpec с тем, на котором форма сверялась, и при расхождении
даёт замечание с этой командой. Команда ничего не правит: она спрашивает
инструмент и печатает, что разошлось. **Чинится это в плагине, а не в проекте**
константы `docs.py`, скелет в `skeletons.md` и запись в журнал версий канона.
**Коды выхода — тот же словарь, что у `tasks.py`:** 0 сошлось, 1 дрейф, 2 ошибка
употребления, 3 окружение, 4 внутренний сбой. Ветвись на коде, а не на тексте.
Различать 1 и 3 обязательно: «дрейф раскладки» — рабочая ситуация, «это не
корень проекта» — нерабочая.
### Граница механизируемого — объявляется вслух
Скрипт печатает её сам последним абзацем, и **эту строку из доклада выбрасывать
нельзя**. `check`, отчитавшийся «канон соблюдён» на проекте, где из шести файлов
три лишние, хуже отсутствующего.
Машина **дрейфом** считает: отсутствующий путь канона, файл вне канона, битую
ссылку, отставшую версию, capability без упоминания в обзоре, миграцию без правки
`database.md`. **Замечанием** — незаполненный плейсхолдер и слабое упоминание
capability: незаполненный канон это переходное состояние, а не отказ. Маркеры
долга просто считает числом.
Того, чего она не умеет, **ты не судишь сам** — для этого есть два агента, и
разведены они по глубине:
| Агент | Что смотрит | Читает |
| --- | --- | --- |
| `doc-consistency` | смысловой дубль, прямое противоречие между документами, поведение в `architecture.md` вместо спек, ADR без ссылки и парного статуса, число без провенанса, заглушка вместо честной строки | `docs/`, `openspec/` |
| `doc-code-drift` | протухший факт: имя ветки, команды, пути, зависимости поимённо, настройки с числом, единые точки проекта, capability | весь репозиторий |
Судит **не тот, кто писал**: самопроверка документа слабее всего ровно там, где
формулировка казалась удачной при написании. Ни один из них ничего не правит —
оба возвращают готовые формулировки, подставляешь ты.
## `check`
1. `docs.py check`, при наличии базы диффа — с `--base`.
2. **Агентов на каждом `check` не зови.** Оба — `doc-consistency` и
`doc-code-drift` — зовутся раз в спринт (шаг сессии), а также шагом 6 `adopt`
и шагом 6 `upgrade`, на весь канон разом. Они дороги: `doc-consistency` — тем,
что на `opus` (сличение утверждений это суждение), `doc-code-drift` — тем, что
читает репозиторий целиком, хотя сам идёт на `sonnet`. Позвал
`doc-code-drift` — передай ему раздел запретов `CLAUDE.md`.
3. Доклад: вывод скрипта строкой исхода, находки агентов поимённо, **граница
покрытия** — что смотрели и чего не смотрели, и **кого из двоих позвал**:
доклад, умолчавший об этом, читается как «сверено».
Дрейф раскладки чинится переносом; смысловые находки — это либо правка
документа, либо задача, если работы больше чем на абзац.
## `adopt` — проект в чужой раскладке
### 1. Осмотрись
`docs.py check` — он уже назовёт упразднённые слоты с адресом, куда каждый
уезжает. **Но смотрит он только верхний уровень `docs/`:** упразднённое в корне
репозитория (`BRIEF.md`) и во вложенных каталогах он не назовёт никогда, поэтому
корневые `*.md` читай глазами. Плюс: `CLAUDE.md`, `openspec/specs/` (список
capability), `openspec/config.yaml`.
### 2. Составь карту
Каждый найденный файл получает строку: **куда едет, целиком или разбирается, что
делать с оригиналом**. Разбор `docs/specs/` — самое дорогое место, и он делается
поимённо по capability:
| Что в файле | Куда |
| --- | --- |
| требования, сценарии, поведение | `openspec/specs/<capability>/spec.md`**или уже там**, тогда файл дубль |
| компоненты, транспорты, раскладка, деплой | `docs/architecture.md` |
| конвенции чужой системы, формат чужих данных | `docs/research/` |
| обоснование принятого решения | `docs/adr/` |
**Дубль удаляется только после поимённой сверки**: открыть спеку capability,
открыть файл, убедиться, что в файле нет ничего сверх спеки. Нашлось сверх —
сперва переезжает в спеку дельтой, потом файл удаляется.
### 3. Покажи карту человеку
`AskUserQuestion`, **не больше трёх вопросов за итерацию**, рекомендация первым
вариантом. Показывается: сколько файлов, куда каждый, спорные отнесения, список
«не разложилось». Механику (порядок строк, имена файлов внутри `research/`) не
выноси — это не развилка.
### 4. Перенеси
Порядок важен — он минимизирует окно, в котором ссылки битые:
1. `docs/.pm.json` с `{"canon": <текущая версия>}` и путём миграций, если БД есть;
2. каталоги канона и скелет **по [references/skeletons.md](references/skeletons.md)**:
незаполненное — одной честной информативной строкой, а не «TBD»;
3. **OpenSpec, если его нет**`openspec init --tools claude`, и `config.yaml`
по тому же скелету. Каталог есть, а `config.yaml` из коробки — тот же случай,
что отсутствие: закомментированный пример выглядит настройкой и не является
ею. Пересказ инвариантов, конвенций и правил ревью из `context` вычисти
ссылкой на дом — на переводимом проекте он там почти наверняка есть;
4. переносы содержимого;
5. каталог задач — **вызови скилл `av-dev-tasks:tasks`**, сценарий адаптации: он
владеет форматом задач. Он же переименует транслитные слаги в английские и
тем же проходом починит перекрёстные ссылки;
6. починка ссылок на перенесённое во всём репозитории — `docs/`, `openspec/`,
`CLAUDE.md`, `README.md`;
7. удаление оригиналов — **только тех, чьё содержимое найдено в новом доме**;
8. **шаг `docs.py check` в гейт проекта.** Путь к скрипту — переменной с
умолчанием на канонический путь маркетплейса, чтобы переустановка плагина не
меняла `Taskfile`; шаг обязан **краснеть внятно**, если скрипт не найден, а не
пропускаться. Передай ему базу диффа (`--base`) той же переменной, что и
остальным шагам гейта: без неё сверка миграций со схемой не гоняется вовсе.
Пример строки покажи человеку — гейт принадлежит проекту, и правит его он;
9. `docs.py check` — до **отсутствия дрейфа раскладки**. Замечания
(незаполненные плейсхолдеры, слабое упоминание capability) остаются:
незаполненный канон это объявленное переходное состояние из шага 5, а не
отказ. **Пункт «задачи без цели» из вложенной проверки `tasks.py` тоже
остаётся** и зелёным на этом шаге не станет: цели не сочиняются адаптацией
(запрет записан у того, кто ведёт задачи, — сценарий адаптации скилла
`av-dev-tasks:tasks`), их
проставляет человек порциями переоценки на первой сессии. Пересчитай эти
пункты в докладе переходного состояния — не выдавай их за поломку и не
молчи о них.
### 5. Объяви переходное состояние
Сразу после переноса канон **заполнен не весь**, и это нормально, но обязано
быть названо, иначе следующий агент примет скелет за поломку.
Печатается по факту: сколько документов стоят честной строкой вместо
содержания, сколько маркеров долга в `architecture.md`, сколько задач без
критериев приёмки. Закрывается порциями по ходу работы, а не одним заходом.
### 6. Позови обоих судей
`docs.py` увидел раскладку, а не смысл: перенос растащил один факт по двум домам,
оставил в `architecture.md` поведение, которому место в спеке, и оторвал ADR от
его `design.md`. Ничего из этого скрипт не видит, и первый прогон на живом
проекте обычно самый урожайный — правило единственного дома до адаптации никто не
проверял.
Зови **`doc-consistency`** (документы между собой и с openspec) и
**`doc-code-drift`** (факты против кода). Разбирай порциями, а не одним заходом.
**Передай им объявленное переходное состояние из шага 5** — иначе честная строка
в незаполненном слоте вернётся находкой, а это не поломка, а объявленный долг.
## `upgrade` — канон вырос
1. `docs.py version` — версия проекта и версия скрипта.
2. Проект новее скрипта — **обнови маркетплейс**, а не проект: это отстал
плагин.
3. Иначе иди по [changelog.md](references/changelog.md) снизу вверх от версии
проекта до текущей и делай названное в каждой записи. Записи независимы и
применяются по порядку.
4. Подними `canon` в `docs/.pm.json` до текущей.
5. `docs.py check`.
6. **Позови обоих судей**`doc-consistency` и `doc-code-drift`.
Записи журнала описывают **что сделать проекту**. Если запись этого не говорит —
это дефект журнала, и о нём надо сказать, а не догадываться.
**Шаг 6 обязателен, и вот почему.** `check` сверяет **число** в `.pm.json` с
версией скрипта — и только его. Применена ли запись журнала **по существу**, он
не знает: проект несёт `"canon": 6` и может не иметь того, чего требовала любая
из пройденных версий. Записи применяются руками (переименовать секцию, проставить
типы, дописать раздел каждому `fix`), а ручной проход по нескольким записям
подряд — ровно то место, где половина шага делается и забывается. Судьи и есть
проверка, которой у `upgrade` иначе нет: `doc-consistency` увидит, что документы
разошлись после переименований, `doc-code-drift` — что переехавший факт
разошёлся с кодом.
## Чего этот скилл не делает
- **Не сочиняет содержание.** Пустой слот получает честную строку о том, что его
наполнить пока нечем, а не выдуманный абзац. Придуманный периметр модели угроз
хуже отсутствующего: по нему будут строиться находки.
- **Не удаляет то, чьё содержимое не нашло дом.** Оригинал живёт, пока не
названо поимённо, куда переехал каждый его кусок.
- **Не ведёт содержимое канона** — это скилл `docs`. Здесь только раскладка.
- **Не заводит проект с нуля** — это скилл `init`.
- **Не правит историю.** В старых коммитах старые пути остаются, и это нормально.
## Доклад
- Что нашёл `docs.py`: код выхода и число пунктов дрейфа.
- Что перенесено: файл → дом, числом и поимённо для спорного.
- **Удалённые дубли** — с указанием, против какой спеки сверялся каждый.
- **Не разложилось** — поимённо, с причиной.
- Переходное состояние числами: честных строк, маркеров долга, задач без
критериев.
- **Граница покрытия**: что проверила машина, что судил ты, чего не смотрел
никто.
@@ -0,0 +1,593 @@
# Канон документов проекта
**Версия 7.**
Это **единственный дом определения канона**. Скиллы `init`, `canon` и `docs`
читают его, а не пересказывают: три описания одной раскладки разъедутся, и
работать будет то, которое прочитали последним. Меняется канон — меняется этот
файл и появляется запись в [changelog.md](changelog.md).
## Зачем канон жёсткий
Пути фиксированы, и проект под них подгоняется, а не наоборот. Причина не
техническая: проектов много, все малого и среднего размера, и ориентироваться в
слегка похожих, но разных раскладках дороже, чем один раз привести их к общей.
Рядом лежит OpenSpec, у которого структура тоже строгая.
Цена принята сознательно: плагин не переносится на чужой репозиторий как есть —
чужой репозиторий **приводится** к канону скиллом `canon`.
Раскладка отвечает, **где** текст лежит и на какой вопрос отвечает. Каким он
должен быть **словами** — общий для всех документов канона файл
[language.md](language.md): информационный стиль, англицизмы, жаргон. Он
относится и к задачам, и к решениям ADR, и к запискам разведки.
## Сопровождение и эксплуатация — целое и часть
**Копия.** Дом — `shared/operations.md` в репозитории плагинов: словарь
делят роадмап, архитектура и тема ревью `operations`, то есть три плагина,
и ни один из трёх им не владеет. Правится дом, а не этот файл.
<!-- копия: сопровождение-словарь из shared/operations.md -->
Одна тема живёт в трёх местах, и путать их слова нельзя.
**Сопровождение** — всё, чем держат проект: инструмент и сборка, процесс,
выкладка, метрики и логи, инфраструктура, дежурство. **Эксплуатация** — его
часть: работа системы на проде. Целое и часть, и никогда наоборот.
| Место | Уровень | Что там |
| --- | --- | --- |
| `ROADMAP.md`, секция `Сопровождение` | план | **работы**, которые собираемся делать: цели и их задачи |
| `architecture.md`, раздел «Эксплуатация» | состояние | **как устроено сейчас**: где работает, что рядом, кто перезапускает |
| тема ревью `operations` | оптика | **чем проверяем**: «это упало через неделю на проде» |
Слово **«поддержка» не употребляется вовсе** — в нём слышится помощь
пользователю, а это другая работа.
**Граница с возможностями проходит по тому, кто наблюдает.** «Приложение
сообщает о своём состоянии» — возможность приложения, её место среди прочих
целей: наблюдает пользователь сервиса. «Дежурный видит состояние на одном
экране» — сопровождение: наблюдаем мы. Одни и те же метрики попадают в разные
секции роадмапа, и это верно — секции отвечают на разные вопросы.
<!-- /копия: сопровождение-словарь -->
## Раскладка
**Документ канона живёт файлом или каталогом.** `docs/security.md` и
`docs/security/` — одно и то же; форму выбирает проект по объёму написанного, и
переход между формами не меняет ни канон, ни версию. Обе формы сразу — ошибка:
два дома для одного факта расходятся молча.
```
CLAUDE.md памятка агенту: что это, стек, инварианты с
severity, команды, семантика гейта, запреты
AGENTS.md необязателен, лежит рядом; читается теми же
docs/
.pm.json версия канона и пути, нужные проверкам
passport.md | passport/ зачем и для кого; чем НЕ является; сценарии
architecture.md | architecture/ как сложено — обзор; окружение и эксплуатация
database.md | database/ схема хранилища; представление данных и настройки
security.md | security/ периметр; недоверенный вход; что вне модели
conventions.md | conventions/ как пишем код; что механизировано
research.md | research/ наблюдения и числа с провенансом
adr.md | adr/ почему решено так; статусы, правило замены
review.md | review/ настройка конвейера под проект + журнал дефектов
<своя тема>.md | <своя тема>/ всё, что проект счёл нужным проверять
tasks/ скилл tasks: items/, ROADMAP.md, BACKLOG.md,
SPRINT.md, REJECTED.md
openspec/
config.yaml только нужды генерации артефактов + ссылки
specs/<capability>/spec.md что система делает — нормативно
changes/archive/ архив изменений с design.md — сырьё для ADR
```
**У документа-каталога обязателен `README.md`** — вход, по которому его читают агенты.
`adr/` в форме каталога держит ещё и `template.md`, а записи именуются
`ADR-ГГГГ-ММ-ДД-slug.md`.
## Три категории документов
Раньше здесь стояло плоское правило «каждый документ `docs/` — тема ревью». Оно
неверно ровно наполовину: паспорт и схема хранилища ревью нужны, но темами не
являются, а журнал решений и журнал наблюдений ревью изменения не нужны вовсе.
Плоское правило заставляло разметчика либо плодить фантомные темы, либо терять
документы молча — а молчащая потеря и есть то, против чего канон написан.
**Разрез один и проверяемый: можно ли по документу сказать «в этом изменении
сделано не так»?**
| Категория | Ответ на разрез | Что с ней делает ревью |
| --- | --- | --- |
| **тема** | да, прямо | заводит направление проверки и требует исполнителя |
| **источник темы** | нет, но он задаёт границу, по которой судит чужая тема | читается как материал, своей темы не порождает |
| **процессный документ** | нет: он про то, как мы работаем, а не про изменение | не судит по нему изменение |
| Документ | Категория | Куда питает |
| --- | --- | --- |
| `conventions.*` | тема | `conventions` |
| `security.*` | тема | `security` |
| `architecture.*` | тема | `architecture`; раздел эксплуатации — `operations` |
| *свой документ проекта* | тема | своя тема, именем документа |
| `passport.*` | источник | `architecture` — граница домена, «чем **не** является» |
| `database.*` | источник | `operations` — схема и настройки с числами |
| `CLAUDE.md`, `AGENTS.md` | источник | `autotests` (семантика гейта); инварианты — сквозные |
| `openspec/specs/` | источник | `requirements` |
| `openspec/config.yaml` | процессный | — (настройка порождения артефактов, слой **до** тем) |
| `tasks/` | процессный | — |
| `review.*` | процессный | — (настройка самого конвейера, слой **над** темами) |
| `adr.*` | процессный | — |
| `research.*` | процессный | — |
| `.pm.json` | процессный | — (служебный файл, не документ) |
**Список тем открытый, и это не послабление, а механизм.** Категории
`источник` и `процессный` **закрыты** — они перечислены здесь поимённо и
проектом не пополняются. Всё остальное, что проект кладёт в `docs/`, — тема: у
конвейера есть приёмник для темы, к которой нет именной оптики, и заведён он
ровно за этим. Завёл `docs/accessibility.md` — появилась тема `accessibility`, и
она попадает в план каждого прогона.
Отсюда следствие, ради которого правило и заведено: **`docs/` — это конфигурация
ревью.** Проект настраивает проверку тем, что пишет о себе, а не отдельным файлом
настроек, который разошёлся бы с документами.
**«Не судит по нему» и «не открывает» — не одно и то же, и разница существенна.**
`docs/review.*` проходы читают на каждом прогоне: там лежат вопросы по темам,
журнал дефектов, типовые узлы и типовые ложноположительные. Это чтение конвейером
**собственной настройки**, а не суждение об изменении, и потому оно законно.
`adr/`, `research/` и `tasks/` не открывает никто: по ним изменение не судят, и
настройкой конвейера они не являются.
**Процессный документ — не документ второго сорта.** `adr/` и `research/`
проверяются наравне с остальными, но **сверкой документации**, а не прогоном
ревью: ADR без ссылки на архивный `design.md`, замена без парного статуса, число
без провенанса — это работа агентов `doc-consistency` и `doc-code-drift`, и она
осталась там же, где была. Изменилось одно: прогон ревью не открывает их как
критерий и не судит по ним изменение.
Цена этого решения записана, а не подразумевается: **расхождение изменения с
записанным решением прогоном больше не ловится.** Раньше архитектурный проход
читал `adr/` и мог сказать «здесь отменено решение ADR-2026-03-11, а парного
статуса нет»; теперь это скажет только `doc-consistency` на сессии между
спринтами. Сделка сознательная — ADR объясняет прошлое, а не предъявляет
требование к изменению, и чтение всего каталога решений на каждой задаче
оплачивалось на каждой, а срабатывало на единицах.
### Имена файлов английские, текст русский
**Текст документов русский; имена файлов, capability и задач — английские,
kebab-case.** Причина не эстетическая: имя файла стоит в ссылках из других
документов, в коммитах и в путях, которые набирают руками, — а кириллица в пути
ломается по-разному в разных местах и не набирается на английской раскладке.
**Транслита не заводим.** Слаг именуется английским словом **по сути**, а не
записью русского латиницей: `queue-as-table`, а не `ochered-tablicej`. Транслит
нечитаем тому, кто ищет по смыслу, и не сокращается.
У ADR имя вдобавок несёт форму — `ADR-ГГГГ-ММ-ДД-slug.md`: по ней записи
сортируются, и по ней же ищется дата решения.
`docs.py check` проверяет кириллицу и kebab-case **жёстко**, форму имени ADR —
тоже, а транслит **эвристикой**, то есть замечанием: английское слово от
транслита машина не отличает. Слаги каталога задач ведёт `tasks.py` — там та же
проверка и тот же разрез.
**Переименование — не правка, а перенос ссылок**: делается одним проходом по
всем местам, где имя упомянуто, иначе останутся битые ссылки. Для задач это
умеет `tasks.py adopt`; для документов канона правит человек, а `docs.py` потом
показывает, что ссылки целы.
## Роли документов и темы ревью
Одна строка на каждый — на какой вопрос он отвечает, в какой он категории и
какую тему питает. **Кто именно закрывает тему, здесь не указано намеренно**: это
зависит от метки прогона и меняется вместе с конвейером, а документ живёт
дольше. Раскладку «тема → проход → глубина» держит скилл
`av-dev-pipeline:review-pipeline`.
**Общего словаря у канона с конвейером ровно три вида имён: имена категорий,
имена тем и имена меток.** Категорий три — `тема`, `источник`, `процессный`;
**меток тоже три, и они закрыты: `small`, `medium`, `large`.** Метка это итог
классификации задачи, и по ней конвейер выбирает исполнителей на обеих стадиях
ревью; проект её не выдумывает, а только уточняет триггеры. Ими проект и
настраивает ревью — вопросами по темам и триггерами метки. **Имён проходов канон не называет нигде**, включая вывод `docs.py`:
проход переименовывается и переезжает между метками, и канон, назвавший его, в
этот день соврёт молча. Обратное направление законно — конвейер называет
документы канона поимённо, потому что он их читатель.
| Документ | Вопрос | Категория и тема |
| --- | --- | --- |
| `CLAUDE.md`, `AGENTS.md` | что нельзя нарушать, чем краснеет гейт | источник: `autotests`; инварианты — сквозные, во все темы |
| `passport.*` | зачем и для кого, чем это **не** является | источник: `architecture` |
| `architecture.*` | как сложено и где что работает | тема `architecture`; раздел эксплуатации — `operations` |
| `database.*` | что лежит в хранилище и какими настройками | источник: `operations` |
| `security.*` | против кого защищаемся и что вне модели | тема `security` |
| `conventions.*` | как мы пишем код | тема `conventions` |
| `openspec/specs/` | что система делает — нормативно | источник: `requirements` |
| `research.*` | что показала реальность, а не документация | процессный |
| `adr.*` | почему решено именно так | процессный |
| `review.*` | как настроен конвейер и что уже проскакивало | процессный: слой **над** темами |
| `tasks/` | что делаем и в каком порядке | процессный |
| *свой документ проекта* | что проект счёл нужным проверять | **своя тема**, именем документа |
### `passport.md`
Цель; закрытый список потребителей и что каждому нужно; **чем целью не
является** — это граница домена, по которой архитектурный проход судит о
переносе понятия; типовые сценарии; мера, по которой проект считается удавшимся;
референсы, у кого подсматривать.
### `architecture.md` — **обзор, не поведение**
Принципы; компоненты **со ссылками на capability**, а не с пересказом их
требований; **единые точки проекта** — где генерируются идентификаторы и время,
где единственный парсер входного формата, где маппинг доменной ошибки в код
ответа, где общий путь приёма (это материал для вопроса «не появился ли второй
способ»); внешние границы и форматы чужих систем; окружение — где работает, что
рядом, кто перезапускает; **внешние зависимости поимённо** и чем каждая
отказывает (не только «падает», но и «отвечает медленно», «молчит», «отдаёт
мусор»); кто заметит отказ и когда; характер потока — непрерывный, по запросу,
по расписанию; деплой; открытые вопросы.
**Обратимости здесь нет** — её единственный дом `CLAUDE.md`: туда ходят пять
проходов, и раздвоение адреса означало бы, что проект написал ответ, а ревью его
не прочитало.
**Поведение системы сюда не пишется.** Его нормативный дом — `openspec/specs/`,
куда `opsx:archive` вливает дельты; второй дом синхронизировать руками
невозможно, и он разойдётся.
Раздел, ещё не разнесённый при переезде, помечается маркером долга:
```
<!-- канон: поведение → openspec/specs/<capability> -->
```
`docs.py` считает маркеры и печатает остаток числом. Гейт от них **не краснеет**:
это долг, а не отказ, иначе постепенный переезд стал бы невозможен.
### `database.md`
Схема: таблицы, ключи, связи, правило времени и идентификаторов. Плюс то, чего
нет в схеме, но без чего замер не превращается в находку: **чем физически лежит
запись** (сжатый BLOB, JSON-строка, колонки), что происходит при чтении и записи
(распаковка целиком, read-modify-write), и **настройки с числовым значением**
таймаут занятости, режим журналирования, лимит тела, размер пула, ретеншен.
Конвенции идентификаторов и именования — не схема, они в `conventions/`.
### `security.md`
**Периметр первой строкой.** «Сервис открыт наружу» и «контур доверенный,
публичного интернета здесь нет, не выдумывай его» — противоположные постановки
под одним заголовком, и разбор темы `security` между ними сам не выберет. Контур ещё
не развёрнут — назови **оба** периметра, целевой и сегодняшний, и скажи прямо,
против какого строятся находки.
Дальше: что недоверенное и каким каналом приходит; **из чего строятся пути и
ключи** (раскладка файлов, состав координатного ключа, имя каталога) — отсюда
строится выход за пределы песочницы; что разграничивает доступ; что
чувствительнее чего; **что вне модели** — перечислить явно.
### `conventions/`
Прозой остаётся **только то, что не выражается правилом**. `README.md` держит
индекс, правило промоута и **перечень уже механизированного** со ссылкой на
место механизации — конфиг линтера, собственный анализатор, тест-сканер
исходников. Не названное место механизации означает, что проход добросовестно
проверит уже проверенное.
### `research/`
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
расходится с практикой, какие числа сняты с живого потока. **Числа — с
провенансом**, то есть с командой или условиями, которыми получены.
`README.md` — как снималось и индекс тем.
Число без источника проход обязан читать как условие, а не как замер. Число, чей
источник по ссылке не подтвердился, не выбрасывается и не переписывается по
догадке — остаётся с пометкой «расходится с источником: там <что нашли>».
### `adr/`
**ADR — промоут поверх архивных `design.md`, а не второе сочинение.** Запись
цитирует решение и ссылается на `openspec/changes/archive/<id>/design.md`.
Заводится, когда верно одно из трёх:
<!-- дом: adr-когда-заводить -->
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
<!-- /дом: adr-когда-заводить -->
Не заводится для рутины и для того, что видно из кода и `git log`.
Записи неизменяемы: передумали — заводится новая, старая получает статус.
Активная запись статуса не имеет.
**Статус живёт полем меты записи**, там же, где дата и источник:
`- **Статус:** заменено на ADR-…` либо `- **Статус:** устарело`. Места ему в
шаблоне не отводилось, и каждая запись изобретала своё — то абзацем, то
заголовком; в таблице `adr/README.md` статус при этом обязан быть, а брать его
оттуда, где он у каждого свой, нельзя.
### `review.md`
Два раздела с разными сроками жизни.
**Настройка конвейера под проект**, пять подразделов с точными именами — по ним
проходы находят свой кусок:
- **Типовые узлы** — рода узлов проекта и 3–5 проверяемых свойств к каждому;
- **Типовые ложноположительные** — находки, которые здесь выглядят убедительно и
всегда неверны, каждая со строкой «почему здесь это не дефект»;
- **Вопросы по темам** — в форме `<тема>: <вопрос> (<провенанс>)`. **Не по именам
проходов**: проход уезжает между метками, а тема остаётся, и вопрос,
адресованный проходу, перестал бы задаваться молча в тот день, когда тот уехал
в старшую метку. Задаёт вопрос тот, кто закрывает тему на этом прогоне.
Адресовать можно только теме: `passport`, `database`, `adr`, `research` и
`review` — не темы, и вопрос, адресованный им, не задаст никто;
- **Триггеры метки** — проектная конкретизация правила выбора метки ревью,
**тремя списками**. Два поднимают, по одному на ось: что в этом проекте считается
**крупным** (объём: сколько узлов и слоёв трогает) и что считается
**незнакомым** (форма решения: известна до начала или нащупывается по ходу).
Любая из двух осей поднимает прогон до `large`, старшей метки, — а она
рассчитана на 5–10% задач. Третий список — что считается **мелким** (опускает
до `small`); он один, потому что вниз метку опускает только совпадение обеих
осей сразу. Перечнем мест, узлами или capability, а не вторым определением
класса. Уточняет умолчания, а не отменяет их. Рабочее умолчание — `medium`:
миграция схемы и публичный контракт метку **не** поднимают, их проверяют
проходы, которые в `medium` и так есть;
- **Недоступно проверке** — два подраздела, оба **по темам**: «не проверит ни
один проход» (принципиальная граница, по факту промаха не пересматривается) и
«перестали проверять сознательно» (пересматривается первым). Тема, у которой в
проекте нет дома, сюда не пишется: её и так называет план каждого прогона.
**Журнал дефектов:** запись на каждый воспроизведённый дефект с пометкой
**проскочил / пойман ревью**. Проскочившие — проверочный набор для калибровки конвейера,
выборка по пометке. Пойманные с оракулом — лучшая опора для прохода: проектные,
воспроизводимые, однажды оказавшиеся правдой.
### `tasks/`
Раскладку, форму записи и команды держит скилл `tasks` — канон фиксирует имена
файлов (`items/`, `ROADMAP.md`, `BACKLOG.md`, `SPRINT.md`, `REJECTED.md`) и то,
от чего зависит, читается ли проект как продукт.
**`ROADMAP.md` отвечает на «что приложение уже умеет и чего ещё не умеет».** Это
не очередь работ: цель — **возможность приложения**, задача — шаг к ней.
Достигнутая цель из роадмапа **не исчезает** — строка с датой переезжает в
секцию достигнутого, потому что «что умеет» и есть половина вопроса, ради
которого документ открывают. Вторым домом поведения роадмап при этом не
становится: нормативное поведение живёт в `openspec/specs/`, роадмап отвечает,
**когда и в каком порядке** оно появилось.
**У каждой записи есть тип, и тип решает, что с ней можно делать.** Дом типа —
поле меты `Тип` первой строкой; эмодзи в заголовке от него производна. Словарь
закрыт:
| Тип | Что это |
| --- | --- |
| 🎯 `goal` | возможность приложения |
| ✨ `feature` | снаружи появляется то, чего не было |
| 🐞 `fix` | поведение расходится с заявленным |
| 🧹 `chore` | обслуживание, поведение не меняется |
| 🔬 `research` | исход — знание, а не изменение |
**Схемы записи здесь нет намеренно.** Какие разделы тип требует, нужна ли ему
цель и берётся ли он в спринт — скилл `av-dev-tasks:tasks`, раздел «Тип
записи», подробно — по файлу на тип в его `references/task-<тип>.md`. Ссылки в
дерево того плагина здесь нет намеренно: он ставится отдельно, и путь наружу
разрешился бы не всегда. Канон фиксирует **словарь**, потому что
от него зависит, читается ли проект как продукт; схема — механика ведения задач,
и второй её экземпляр разошёлся бы с первым (он и разошёлся: канон успел
объявить цель у `fix` запрещённой, хотя она там необязательна).
Схема требуется **к взятию в спринт**, а не к заведению: беклог пополняется чаще,
чем разбирается, и требование на входе выгоняло бы в заметки то, что должно
лежать задачей. Запись, не собравшая разделы своего типа, — законное состояние
беклога; невзятой её делает `sprint take`.
Отдельного типа для незаполненной записи нет: «ещё не описано» — состояние, а не
род работы, и называется оно **`research` без раздела «Вопрос»**. Такая запись в
спринт не берётся и лежит в конце своей категории.
Раскладку, форму записи и алгоритм работы над каждым типом держит скилл `tasks`.
### `CLAUDE.md`
Что это и стек; **инварианты с severity рядом с формулировкой** — по ним проходы
присваивают `critical`, поэтому severity стоит здесь, а не выводится каждым
проходом заново; команды; **семантика гейта** — чем краснеет безусловно и почему,
где логи, что означает исход, чего в гейте намеренно нет, **кто и когда обязан
гонять дорогое вне гейта**.
Плюс то, что нужно git-операциям и проходам и не выводится ниоткуда:
- **имя основной ветки** — от неё считается база диффа
(`git merge-base HEAD <ветка>`), в неё вливает батч, от неё ветвятся задачи.
Угадывание между `master` и `main` ломает интеграцию целиком;
- **что запускать запрещено, с путями** — рабочая БД, боевой каталог данных,
внешние сервисы. Запретом с путями, а не «будь осторожен»;
- **где `testdata`** и что в них лежит; **куда писать временное**;
- **что считается необратимым** — единственный дом: от обратимости зависит вся
шкала ранжирования триажа и право проходов на `critical`;
- **общий станок**, врывающийся в замороженный спринт; **ориентир по размеру
спринта**.
### `openspec/config.yaml`
**Только нужды генерации артефактов** — язык, правила именования capability,
придирки валидатора RFC 2119 — плюс **адреса** документов канона. Правило ревью,
пересказ конвенций и инварианты сюда не пишутся: у них есть свои дома, и второй
дом разойдётся на первой же правке.
**Каталог `openspec/` — часть канона, а не соседняя технология.** В нём дом темы
`requirements`, и заводится он командой: `openspec init --tools claude`. Её
выполняет `init` на новом проекте и `adopt` на переводимом; из канона она названа
поимённо потому, что её печатает отказ `docs.py`, а отказ без команды заставляет
искать её в другом месте.
**Файл из коробки настройкой не является.** `openspec init` кладёт `config.yaml`,
где и `context`, и `rules` лежат закомментированным примером. Такой файл читается
как настроенный — он есть, он валиден, у него правильное имя, — а работает как
пустой: предложение пишется без языка, без правил именования capability и без
знания, где лежит граница домена. Это ровно тот класс, против которого написан
весь канон, и потому здесь он проверяется машиной, а не чтением.
Проверяется пять вещей, и каждая — про молчащий пробел, а не про вкус:
1. **`openspec/` есть.** Нет — нет и дома темы `requirements`.
2. **Имя файла `config.yaml`.** `config.yml` OpenSpec не читает и об этом не
сообщает: настройка, написанная в файл с таким именем, пропадает целиком.
3. **`context` и `rules.specs` не остались примером.** Правила для `specs`
обязаны называть `SHALL`: требование без этого литерала валидатор отвергает.
4. **`context` называет `passport` и `CLAUDE.md`.** Предложение пишется **до**
того, как кто-либо откроет `docs/`; без этих двух адресов его пишут, не зная
ни границы домена, ни инвариантов.
5. **Ключи под `rules:` — имена артефактов схемы** (`proposal`, `specs`,
`design`, `tasks`). Правило, адресованное несуществующему артефакту, не
применяется и об этом молчит: `rules.spec` вместо `rules.specs` — конфиг,
выглядящий написанным и не работающий.
**Схема и перечень артефактов — слепок чужого инструмента, и он стареет.**
OpenSpec переименует артефакт или сменит схему — правила под прежним именем
перестанут действовать молча, а канон будет продолжать требовать прежнее.
Поэтому за свежестью слепка следит машина: `check` сравнивает `major.minor`
установленного OpenSpec с версией, на которой форма сверялась, и при расхождении
даёт **замечание** (не отказ: патч-версии формы не меняют, а нагоняй на каждый
багфикс приучает пролистывать блок). Перепроверяет `docs.py openspec-form` — он
спрашивает сам инструмент и печатает, что разошлось. **Чинится это в плагине, а
не в проекте:** константы скрипта, скелет и запись в журнал версий канона.
Шестого — «нет ли здесь пересказа» — машина не проверяет: отличить ссылку от
пересказа она не умеет. Это работа `doc-consistency`, и раздел «Что проверяет
машина, а что человек» называет её строкой.
Форма — [skeletons.md](skeletons.md).
## Правило единственного дома
Факт живёт ровно в одном файле; остальные ссылаются. Карта на случай спора:
<!-- дом: карта-домов -->
| Факт | Дом |
| --- | --- |
| поведение системы | `openspec/specs/<capability>/spec.md` |
| почему решено так | `adr/`, источник — архивный `design.md` |
| граница домена, «чем не является» | `passport.md` |
| инвариант и его severity | `CLAUDE.md` |
| что приложение умеет и чего не умеет; порядок работ | `docs/tasks/ROADMAP.md` |
| измеренное число | `research/` |
| настройка с числовым значением | `database.md` |
| периметр и модель угроз | `security.md` |
| что необратимо | `CLAUDE.md`**не** `architecture.md` |
| единые точки проекта | `architecture.md` |
| имя основной ветки, `testdata`, временный каталог | `CLAUDE.md` |
| что уже механизировано правилом | `conventions/README.md` |
<!-- /дом: карта-домов -->
## Пустое называется пустым
Скелет канона заводится **целиком** с первого дня. Незаполненный документ держит
**одну честную информативную строку**, а не заглушку:
- «внешних зависимостей нет — смотри на диск и на СУБД»;
- «наблюдений на живых данных нет: внешний источник один, формат документирован»;
- «прецедентов не накоплено»;
- «сознательно ничего не отключали»;
- «архитектуры пока нет: кода нет, заводится первой задачей».
Проход читает такую строку **как факт** и не тратит на неё обязательный вопрос.
Отсутствие файла он не может прочитать никак, а «TBD» читает как пробел —
поэтому `docs.py check` отличает честную строку от нетронутого плейсхолдера
шаблона и напоминает о втором.
## Слотов нет
Файлы и каталоги, которых в каноне **нет**, и куда уезжает их содержимое:
| Было | Куда |
| --- | --- |
| `docs/review-brief.md` | документы канона и есть бриф; остаток — в `review.md` |
| `docs/specs/` | `openspec/specs/` (поведение) и `architecture.md` (обзор) |
| `docs/drafts/` | идея → запись `research`; отказ → ADR; порядок → `ROADMAP.md`; размышление → `opsx:explore` |
| `docs/plan.md` | `docs/tasks/ROADMAP.md` |
| `BRIEF.md` | `passport.md` |
| `docs/backlog/` | `docs/tasks/` |
| `docs/review-journal.md`, `docs/review/journal.md` | `docs/review.md` |
## Что проверяет машина, а что человек
Граница объявляется вслух в каждом отчёте: `check`, отчитавшийся «канон
соблюдён» на проекте, где из шести файлов три лишние, хуже отсутствующего.
| Проверяет `docs.py` | Судит агент | Какой |
| --- | --- | --- |
| отсутствующие пути канона | смысловой дубль документа и capability | `doc-consistency` |
| файлы в `docs/` вне канона | поведение, оставшееся в `architecture.md` | `doc-consistency` |
| имя файла не kebab-case латиницей; форма имени ADR | транслит в имени — сверх эвристики | `doc-wording` |
| битые относительные ссылки | прямое противоречие между документами | `doc-consistency` |
| версия канона и её отставание | достаточность честной строки в пустом слоте | `doc-consistency` |
| нетронутый плейсхолдер шаблона | ADR без ссылки на `design.md`, замена без парного статуса | `doc-consistency` |
| маркеры долга — числом | **протухший факт, разошедшийся с кодом** | `doc-code-drift` |
| миграция изменена, а `database.md` нет | зависимость в манифесте, не названная в обзоре | `doc-code-drift` |
| capability без упоминания в `architecture.md` | второй способ там, где обзор обещал единственный | `doc-code-drift` |
| `openspec/config.yaml`: имя, `schema`, незаменённый пример, адреса паспорта и `CLAUDE.md`, ключи `rules` против артефактов схемы | **пересказ документа канона в `context` вместо ссылки** | `doc-consistency` |
| версия OpenSpec разошлась с той, на которой сверена форма `config.yaml` | придирки валидатора: сменились ли они | никакой — проявляются отказом `openspec validate --strict` |
| | связность и читаемость | `doc-wording` |
**Агентов двое, и разведены они по глубине, а не по охвату.** `doc-consistency`
читает только `docs/` и `openspec/`, `doc-code-drift` — весь репозиторий и гоняет
читающие команды. Слитый агент делал бы одну половину поверхностной; тот же
разрез, что между `task-form` и `task-wording`.
**Зовутся оба одинаково — раз в спринт на сессии, а также после `adopt` и после
`upgrade`, на весь канон разом.** Не на синке документации: `doc-consistency` на
`opus` по каждой сделанной задаче не окупается, а расхождение между двумя документами по
определению требует двух, и на большинстве задач синк правит один. Пачка,
отбираемая работой, вдобавок не видит того, чего работа не касалась, — а именно
там расхождение и живёт: правка отменяет решение в одном документе, парный статус
нужен в другом.
**Перечень фактов, которые `doc-code-drift` сверяет с кодом, закрыт** — имя
основной ветки, команды, пути, зависимости поимённо, настройки с числовым
значением, единые точки проекта, capability, проверяемые инварианты. «Сверить
архитектуру с кодом» задача без дна, и агент, которому её поставили, выдаёт
правдоподобную труху вместо находок.
## `docs/.pm.json`
```json
{
"canon": 7,
"migrations": "internal/store/migrations",
"tasks": {
"backlog": "INDEX.md"
}
}
```
`canon` — версия канона, под которую проект приведён, целым числом: обратной
совместимости у канона нет, есть «приведён» и «не приведён». `migrations` — путь
каталога миграций, если БД есть; по нему `docs.py` делает сверку с
`database.md`. `tasks` — настройки каталога задач, переехавшие сюда из прежнего
`<tasks>/.tasks.json`: **один конфиг на весь канон, а не по одному на каталог**.
Внутри `tasks`**только имена файлов и заголовков** (`items`, `backlog`,
`roadmap`, `sprint`, `rejected`, `sprint_section`, `oracle_word` и заголовки
разделов тела: `criteria_heading`, `surface_heading`, `questions_heading`,
`completion_heading`, `repro_heading`, `question_heading`, `answer_heading`,
`scope_heading`), и ключ пишется, лишь когда имя отличается от умолчания.
**Словаря типов здесь нет** — он закрыт каноном, а не настраивается проектом:
настраиваемый словарь типов разъехался бы на синонимах ровно так же, как
открытый. **Категорий беклога здесь тоже нет:** их дом — заголовки `##` самого
индекса, и второй список сразу разошёлся бы с первым. Неизвестный ключ `tasks.py`
отвергает кодом 3, поэтому лишнее слово в этом объекте останавливает работу с
задачами целиком.
Ключей будет больше по мере роста проверок; неизвестный ключ `docs.py`
игнорирует, отсутствующий — считает «проверка неприменима» и говорит об этом
строкой, а не молчит.
@@ -0,0 +1,514 @@
# Журнал версий канона
Одна запись на версию. Проект знает свою версию из `docs/.pm.json`; `canon
upgrade` идёт по записям снизу вверх от версии проекта до текущей и делает то,
что в них названо.
Правило записи: **что добавилось, что переехало, что удалено, что сделать
проекту**. Без последнего пункта запись бесполезна — по ней и работает
`upgrade`.
Версия — целое число. Обратной совместимости у канона нет: есть «приведён» и «не
приведён».
---
## Версия 7 — 2026-08-07
`openspec/` был предпосылкой, о которой канон говорил, но за которой не следил.
Каталог назван в раскладке, `openspec/specs/` объявлен домом темы `requirements`,
`config.yaml` описан абзацем — а заводил всё это человек руками, и проверялось
из перечисленного ничего. Заведение нового проекта проходило мимо: `init`
собирал документы канона и оставлял проект без каталога, без которого не работают
ни `opsx:propose`, ни ревью дизайна, ни сверка требований.
Хуже отсутствия оказался файл из коробки. `openspec init` кладёт `config.yaml`,
где `context` и `rules` — закомментированный пример на английском. Такой файл
читается как настроенный: он есть, он валиден, имя правильное. Работает он как
пустой, и узнаётся это по предложению, написанному на другом языке, с
capability по имени пакета и без единого `SHALL`.
**Что изменилось:**
1. **`init` заводит OpenSpec сам** — `openspec init --tools claude`, до первого
документа канона. Команда названа в каноне поимённо, потому что её печатает
отказ `docs.py`.
2. **У `openspec/config.yaml` появилась каноническая форма** и скелет в
`skeletons.md`. Содержание — только то, что нужно **в момент порождения
артефакта**: язык, правила именования capability, придирки валидатора и
**адреса** документов канона. Пересказ паспорта, инвариантов, конвенций и
правил ревью в него не переносится.
3. **`docs.py check` проверяет пять вещей:** каталог `openspec/` есть; файл
называется `config.yaml` (`config.yml` OpenSpec читать не станет и об этом не
сообщит); `context` и `rules.specs` не остались примером, а правила для
`specs` называют `SHALL`; `context` называет `passport` и `CLAUDE.md`; ключи
под `rules:` — имена артефактов схемы, а не опечатки.
4. **За свежестью формы следит машина, а не память.** Схема и перечень
артефактов — слепок чужого инструмента; `check` сравнивает `major.minor`
установленного OpenSpec с версией, на которой форма сверялась, и при
расхождении даёт замечание. Перепроверяет `docs.py openspec-form`, и чинится
расхождение **в плагине, а не в проекте**.
5. **Шестое проверяет агент.** Отличить ссылку на документ от пересказа документа
машина не умеет — это работа `doc-consistency`, и в таблице «Что проверяет
машина, а что человек» она стоит строкой.
**Что переехало:** ничего в раскладке `docs/`. Ни один файл не переименовывается
и не перемещается.
**Что сделать проекту:**
1. Нет `openspec/` — завести: `openspec init --tools claude`. Команда кладёт ещё
и `.claude/skills/openspec-*` с `.claude/commands/opsx/*`; это её нормальная
работа, удалять их не надо.
2. Открыть `openspec/config.yaml` и привести к скелету из
[skeletons.md](skeletons.md): блок `context` с языком, правилами именования
capability, требованием `SHALL` и **адресами** `docs/passport.md` и
`CLAUDE.md`; блок `rules` с четырьмя правилами для `specs`.
3. **Вычистить из `context` пересказ.** Инварианты, перечень конвенций, состав
шагов гейта, правило выбора метки и состав проходов ревью — заменить ссылкой
на дом. Признак пересказа простой: строку можно опровергнуть, открыв другой
файл проекта.
4. Проверить имя файла: `config.yml` переименовать в `config.yaml`. Если жили оба
— содержимое `.yml` до сих пор не читалось никем, и переносить из него нужно
именно то, чего нет в `.yaml`.
5. `docs/.pm.json`: `"canon": 7`.
---
## Версия 6 — 2026-08-07
Версия 5 объявила: **каждый документ `docs/` — тема ревью**. Правило оказалось
верным ровно наполовину и потому вредным целиком. Паспорт и схему хранилища
ревью читает, но темами они не являются — они задают границу, по которой судит
чужая тема. Журнал решений и журнал наблюдений ревью изменения не нужны вовсе:
ADR объясняет прошлое решение, а не предъявляет требование к изменению.
Разметчик, применявший плоское правило буквально, обязан был либо завести
фантомные темы `passport`, `adr`, `database`, `research` и продублировать ими
работу тем `architecture` и `operations`, либо потерять четыре документа молча —
а молчащая потеря и есть то, против чего канон написан.
**Что изменилось:**
1. **Три категории документов вместо одной.** Разрез проверяемый: можно ли по
документу сказать «в этом изменении сделано не так»? **Тема** — да, прямо
(`conventions`, `security`, `architecture`, свои документы проекта).
**Источник темы** — нет, но он задаёт границу для чужой темы (`passport.*`
`architecture`, `database.*``operations`, `CLAUDE.md``autotests`,
`openspec/specs/``requirements`). **Процессный документ** — нет, он про то,
как мы работаем (`tasks/`, `review.*`, `adr.*`, `research.*`, `.pm.json`).
2. **Категории `источник` и `процессный` закрыты, категория `тема` открыта.**
Прежде открытым был весь список, и «не темы ровно две» противоречило
собственной раскладке канона. Теперь пополняется только одно множество, и
документ, которого нет в раскладке, — однозначно своя тема проекта.
3. **`adr/` и `research/` уходят из входа ревью изменения.** Прогон их больше не
открывает. Проверяться они не перестали: ADR без ссылки на архивный
`design.md`, замена без парного статуса, число без провенанса — это по-прежнему
работа `doc-consistency` и `doc-code-drift`, на сессии между спринтами.
4. **`docs.py` печатает категорию в отказе.** «Нет источника passport» читается
иначе, чем «нет темы security». Обязательность при этом не изменилась:
заводятся все документы одинаково и с первого дня.
5. **У задачи появилась метка — `small`, `medium` или `large`.** Это итог
классификации и **единственный вход, по которому конвейер выбирает
исполнителей** на обеих стадиях ревью. Прежние имена `quick`, `standard` и
`wide` описывали глубину прогона, то есть свойство ревью; метка описывает
**задачу** — а выбирают по ней одно и то же. Слово «ступень» уходит:
у одной вещи одно имя.
6. **Метка выводится из двух осей и не равна ни одной из них.** Размер (малое,
среднее, крупное) и сложность (знакомое, незнакомое); метка — максимум по
ним. Малое **незнакомое** изменение получает `large`, трогая один узел, —
поэтому размер и метка пишутся отдельными строками, и выводить одно из
другого нельзя.
**Цена, записанная явно:** расхождение изменения с записанным решением прогоном
больше не ловится. Раньше архитектурный проход мог сказать «здесь отменено
решение ADR-2026-03-11, парного статуса нет»; теперь это скажет только сверка
документации. Сделка сознательная: чтение всего каталога решений оплачивалось на
каждой задаче, а срабатывало на единицах.
**Что переехало:** ничего в раскладке. Ни один файл не переименовывается и не
перемещается.
**Что сделать проекту:**
1. `docs/review.*`, подраздел «Вопросы по темам»: убрать вопросы, адресованные
`passport`, `database`, `adr`, `research` и `review` — **ни одно из этих имён
больше не тема**. Под каноном 5 темой был каждый документ `docs/`, поэтому
такие вопросы там законны и почти наверняка есть. Переадресовать:
про границу домена и про решение → `architecture`; про хранилище, настройку и
измеренное число → `operations`. Вопрос, который никуда не переадресовывается,
удалить, а не оставить висеть: адресованный несуществующей теме, он не
задаётся никем и молча.
2. Там же, «Недоступно проверке»: те же пять имён убрать из разнесения по темам,
переразнеся содержимое по оставшимся.
3. Там же: подраздел **«Триггеры профиля» → «Триггеры метки»**, и разнести его
на **три** списка вместо двух — «крупное здесь» (про объём), «незнакомое
здесь» (про форму решения) и «мелкое здесь» (опускает до `small`). Раньше
первые две оси были склеены в один список, и потому объём в правило по факту
не входил.
4. **Переименовать метки прогона везде, где проект их называет** — в «Триггерах
метки», в «Недоступно проверке», в журнале дефектов: `quick`**`small`**,
`standard`**`medium`**, `wide`**`large`**. Метка это итог классификации
задачи, и три её значения — часть общего словаря канона и конвейера. Слово
«ступень» из документов уходит: у одной вещи одно имя.
5. Проверить, что свои темы проекта не совпадают именем с закрытыми категориями:
`docs/passport/`, `docs/adr/`, `docs/research/`, `docs/database/`,
`docs/review/` — это слоты канона, а не свои темы, и своим смыслом их
наполнять нельзя.
6. Ничего не заводить и не удалять: раскладка канона 6 совпадает с раскладкой
канона 5 файл в файл.
7. `docs/.pm.json`: `"canon": 6`.
---
## Версия 5 — 2026-08-06
Канон перестал быть списком файлов и стал **списком тем ревью**. Раскладка та же,
но читается иначе: документ в `docs/` — это направление проверки, а не просто
текст. Отсюда три правки, и все три развязывают то, что раньше было жёстко
сцеплено.
**Что изменилось:**
1. **Тема живёт файлом или каталогом, на выбор проекта.** `docs/security.md` и
`docs/security/` — одно и то же; тема разрослась, стала каталогом с
`README.md` — канон не сменился и версия не двинулась. Прежде форма была
задана поимённо: `conventions`, `research` и `adr` обязаны были быть
каталогами, остальные — файлами, и обосновать это было нечем. Обе формы сразу
— ошибка: два дома для одного факта расходятся молча.
2. **Список тем открытый.** Всё, что проект кладёт в `docs/`, становится темой
ревью и попадает в план каждого прогона; именной оптики у такой темы нет, её
разбирает общий проход конвейера, заведённый ровно за этим.
Прежде `docs.py` называл незнакомый файл «вне канона» — теперь называет своей
темой проекта и перечисляет их в отчёте. Не темы ровно две: `docs/tasks/` и
`docs/review.*`.
3. **`AGENTS.md` рядом с `CLAUDE.md` — законно.** Он почти стандарт; обязателен
по-прежнему только `CLAUDE.md`, но если лежат оба, читаются оба, и проверки
канона смотрят на второй так же, как на первый.
**Что переехало:**
- в `docs/review.*`: **«Вопросы к проходам» → «Вопросы по темам»**, форма
`<тема>: <вопрос> (<провенанс>)`. Причина не косметическая: вопрос,
адресованный проходу, перестал задаваться молча в тот день, когда тот уехал в
верхнюю ступень ревью. Тема переезд прохода переживает, имя прохода — нет;
- там же **«Недоступно проверке» — по темам**, оба подраздела.
**Что сделать проекту:**
1. Ничего не переименовывать, если всё уже разложено по канону 4: обе формы
дома законны, и текущая — одна из них.
2. `docs/review.*`, подраздел «Вопросы к проходам»: переименовать в «Вопросы по
темам» и переадресовать каждый вопрос теме вместо имени прохода. Темы ядра —
`requirements`, `autotests`, `conventions`, `architecture`, `security`,
`operations`.
3. Там же «Недоступно проверке»: разнести обе половины по темам.
4. Проверить, не лежит ли в `docs/` документ, который раньше считался лишним и
потому не заводился. Теперь он законен и станет темой ревью — это и есть
способ добавить проверку, которой в конвейере нет.
5. `docs/.pm.json`: `"canon": 5`.
6. Позвать судей `doc-consistency` и `doc-code-drift` — шагом 6 `upgrade`.
## Версия 4 — 2026-08-05
Две правки, обе про то, как читается каталог задач. Первая — секция роадмапа
переименована, и вместе с именем расширен её смысл; достигнутое переехало вниз.
Вторая — **у каждой записи появился тип, и тип определяет, что с записью можно
делать**. Раскладка не меняется, файлов канона не прибавляется.
**Что переехало:**
- секция роадмапа `Разработка`**`Сопровождение`** (англ. `Tooling`
**`Operations`**). Прежнее имя называло слишком много: роадмап **весь** про
разработку, и секция с таким именем не отличалась от остальных ничем;
- **тип записи** — из префикса заголовка (`[goal]`/`[idea]`) и тега
`kind:<род>` в **поле меты `Тип`** первой строкой. Эмодзи в заголовке от него
производна;
- **поле места** у задачи: `Секция`**`Категория`**. У цели остаётся `Секция`:
у задачи поле называет полку домена, в которую она вернётся из спринта, у цели
— часть роадмапа, то есть состояние очереди. Одно имя на два смысла их и
смешивало.
**Что добавилось:**
1. **Смысл секции расширен.** Было «инструмент и процесс», стало «чем держат
проект: инструмент, процесс, эксплуатация». Метрики, логи, инфраструктура,
выкладка и дежурство — сюда же. Расширение не косметическое: английское
`Operations` при узком смысле обещало бы эксплуатацию, а внутри лежал бы
линтер.
2. **Общий словарь трёх мест** — [canon.md](canon.md), раздел «Сопровождение и
эксплуатация». Сопровождение — всё, чем держат проект; эксплуатация — его
часть, работа системы на проде. `ROADMAP.md`, секция `Сопровождение` — план
работ; `architecture.md`, раздел «Эксплуатация» — как устроено сейчас;
эксплуатационный проход ревью — оптика проверки. Слить их в одно слово
нельзя: они отвечают на разные вопросы. Слово **«поддержка» не употребляется
вовсе** — в нём слышится помощь пользователю.
3. **Граница с возможностями проходит по тому, кто наблюдает.** «Приложение
сообщает о своём состоянии» — возможность приложения, её место среди прочих
целей. «Дежурный видит состояние на одном экране» — сопровождение. Одни и те
же метрики попадают в разные секции роадмапа, и это верно.
4. **Порядок секций стал каноническим**, и `Готово` переехало **вниз**:
`Запланировано` | `Направления` | `Сопровождение` | `Готово`. Достигнутое
копится — через год этой секции больше, чем всех остальных вместе, — и стоя
первой она отодвигает за экран то, ради чего роадмап открывают чаще всего.
Порядок проверяет `tasks.py check`, переставляет `check --fix`.
5. **Заголовок секции отбивается пустой строкой с обеих сторон.** Прежде
проверялась только строка после заголовка; перестановка секций двигает целые
блоки, и два заголовка оказываются вплотную. Правит `check --fix`.
6. **Тип — единственная ось записи, закрытый словарь из пяти значений:**
`goal` | `feature` | `fix` | `chore` | `research`. Осей было две — тип записи
(`goal`/`idea`/`task`) и род работы (`kind:` тегом), — но из двенадцати
клеток произведения законны были шесть, а алгоритм работы крепится к роду, а
не к типу. Оси схлопнуты.
7. **Тип задаёт схему тела:** какие разделы обязательны, какие допустимы, нужна
ли цель, берётся ли запись в спринт. Проверяет `sprint take`, замечания даёт
`check`. Два раздела новые: **`Воспроизведение`** у `fix` (не
воспроизводится — это `research`, а не `fix`; правило было записано и не
проверялось) и **`Вопрос` + `Куда ляжет ответ`** у `research` вместо
критериев приёмки (приёмка разведки — записанный ответ, и критерии в форме
«оракул: тест» ей натянуты).
8. **Тип `idea` упразднён.** Он значил не род работы, а состояние
незаполненности, а состояние типом быть не может. Теперь оно называется
честно: `research` без раздела «Вопрос» — **сырьё**. В спринт не берётся, как
и прежняя идея, лежит **в конце своей категории** (проверяет `check`,
переставляет `--fix`) и отбирается `list --raw`. Порядка «по важности» в
беклоге по-прежнему нет: этот порядок производен от типа, а не назначен
человеком.
9. **Алгоритм работы над каждым типом** — отдельным файлом,
`skills/tasks/references/task-<тип>.md`: схема, что проверяет машина, что
человек, и порядок шагов.
10. **Имена файлов проверяются.** Правило «текст русский, имена английские»
стояло в каноне и не было подкреплено ничем: `docs.py` имён не смотрел вовсе.
Теперь смотрит — кириллица и не-kebab-case **жёстко**, форма имени
`ADR-ГГГГ-ММ-ДД-slug.md` жёстко, транслит **эвристикой**, то есть
замечанием. Заодно из раскладки канона убраны плейсхолдеры `<тема>.md`,
приглашавшие называть файлы по-русски.
11. **Два агента вместо обещания.** В каноне была таблица «Что проверяет машина,
а что человек», и её правая колонка три версии описывала судью, которого не
существовало. Судьи заведены и разведены по глубине: **`doc-consistency`**
(документ ↔ документ ↔ openspec: факт в двух домах, прямое противоречие,
поведение в `architecture.md` вместо спек, ADR без ссылки и парного статуса,
число без провенанса, заглушка вместо честной строки); **`doc-code-drift`**
(документ ↔ код по закрытому перечню фактов). Оба зовутся раз в спринт на
сессии, а также после adopt и после upgrade, на весь канон разом.
**Что сделать проекту:**
1. Переименовать заголовок секции в `docs/tasks/ROADMAP.md`: `## Разработка`
`## Сопровождение` (или `## Tooling``## Operations`, если индекс
английский). **`check --fix` этого не сделает**: регистр канонической секции
он правит сам, а чужую секцию только называет ошибкой — смысл за человеком.
2. Поправить поле `- **Секция:**` в файлах целей, которые в ней лежат. Порядок
именно такой: сперва заголовок, потом `python3 tasks.py check --dir
docs/tasks` покажет расхождение поимённо.
3. Перечитать состав секции: цели про выкладку, метрики, логи и инфраструктуру,
если они лежали в `Направлениях` за неимением места, переезжают сюда.
4. Прогнать `python3 tasks.py check --dir docs/tasks --fix`. За один проход он
переставит секции роадмапа в канонический порядок (`Готово` уедет вниз вместе
со всем содержимым), поправит отбивку заголовков и **переведёт записи на
типы**: перенесёт значение из тега `kind:` и префикса `[goal]`/`[idea]` в поле
`Тип`, снимет тег, поставит эмодзи в заголовок, переименует `Секция`
`Категория` у задач и снесёт сырьё в конец категорий.
5. Разобрать то, что `--fix` вернул пометкой `НЕОДНОЗНАЧНО`. Главный случай —
**записи без типа**: заведённые до появления рода работы, они не несут ни
тега, ни префикса, и машина их не угадывает (`feature` от `chore` не
отличает). Проставить руками: `edit <слаг> --type …`.
6. Дописать новые обязательные разделы у задач, которые собираются в спринт:
`Воспроизведение` у каждого `fix`, `Вопрос` и `Куда ляжет ответ` у каждого
`research`. Не «заодно по всему беклогу», а порциями переоценки: `check`
ошибкой это не считает, отказывает только `sprint take`. Сколько задач готово
к взятию, печатает блок здоровья `check`.
7. Прогнать `python3 docs.py check`: он назовёт имена файлов не по правилу.
Кириллицу и не-kebab-case править обязательно, транслит — по решению
человека. **Переименование ADR это перенос ссылок**: слаг стоит в
`adr/README.md`, в `architecture.md` и в чужих документах, и делается одним
проходом, иначе останутся битые ссылки (их `docs.py` потом и покажет).
8. `docs/review.md`, подраздел «Триггеры профиля» — переписать целиком, он
отстал дважды. Снести перечень мест для `deep`: профиль упразднён вместе с
проходом независимой реализации, и перечень стал указателем в пустоту.
Оставшийся перечень перевести на новое правило: `wide` теперь означает не
«новое понятие», а **крупное или незнакомое** изменение и рассчитан на 5–10%
задач; отдельным списком назвать, что здесь считается **мелким** (это `quick`).
Форма подраздела — в [skeletons.md](skeletons.md). Там же проверить журнал
дефектов и «Недоступно проверке» на упоминания независимой реализации: класс
«форма решения, где спека выбора не сделала» переезжает в подраздел «перестали
проверять сознательно», а рядом с ним встаёт вторая честная строка — на
`quick` и `standard` не проверяется ничего, что требует запуска.
9. `docs/.pm.json`: `"canon": 4`.
10. Позвать **обоих судей**`doc-consistency` и `doc-code-drift`, шагом 6
`upgrade`. Пунктов выше десять, половина из них ручная, и именно здесь видно,
какие сделаны только наполовину: переименования секций и полей разводят
документы, а `check` сверяет число версии, а не существо. Первый прогон на
живом проекте вдобавок самый урожайный — правило единственного дома до сих пор
никто не проверял. Разбирать порциями, а не одним заходом.
## Версия 3 — 2026-08-04
Роадмап стал **состоянием проекта**, а не очередью работ: цель — возможность
приложения, задача — шаг к ней, достигнутое из роадмапа не исчезает. Плюс род
работы, раздел «Затрагивает» и новое умолчание профиля ревью. Раскладка меняется
в одном файле, но переименование и смена секций тянут за собой ссылки, поэтому
шаги делаются одним заходом.
**Что добавилось:**
1. **Род работы** — тег `kind:<род>` в мете задачи, словарь закрыт:
`feature` | `fix` | `chore` | `research`. Обязателен у задачи, у цели
запрещён. `sprint take` без него отказывает, `check` о пропаже напоминает
замечанием. Определение — [canon.md](canon.md), раздел `tasks/`; смысл и
причина, почему тегом, — в SKILL.md скилла `tasks`, раздел «Род работы».
2. **Раздел «Затрагивает»** в теле задачи — перечень границ, которых изменение
касается (эндпоинт, таблица и миграция, формат на диске, публичный тип). Как
и критерии приёмки, требуется к взятию в спринт, а не к заведению.
3. **Секции роадмапа** — четыре вместо двух и **канонические**, в отличие от
секций беклога: `Готово` (достигнутые цели строкой с датой, без ссылки на
файл), `Запланировано` (очередь значима), `Направления` (очереди нет),
`Разработка` (инструмент и процесс, не возможности приложения). Английский
вариант — `Done` | `Planned` | `Directions` | `Tooling`, один язык на весь
индекс. Переименованию проектом не подлежат: у каждой свой смысл, и в первую
пишет сам `close`; `tasks.py check` проверяет состав.
4. **Форма заголовка записи** — по типу: задача отвечает на «что нужно сделать»
и пишется глаголом в неопределённой форме («Не отбрасывать молча лишние
символы»), цель — на «что приложение будет уметь», идея просто называет, о
чём она. `check` считает заголовки не в форме действия и печатает число в
блоке здоровья. Годность формулировки — не машине: её смотрит новый агент
`task-form` (форма записи, только чтение), а язык текста — `doc-wording`.
5. **Заголовки секций — с прописной, после заголовка пустая строка**, во всех
индексах. Написание канонических секций и отбивку правит `check --fix`; он
же сводит написание секции в мете файла с заголовком индекса.
6. **Язык проектных текстов** — [language.md](language.md), общий дом для
документов канона, задач, решений ADR и записок разведки: информационный
стиль (глагол вместо отглагольного существительного, активный залог, факт
вместо оценки, стоп-слова, параллельность), таблицы англицизмов и жаргона и
то, что из стиля отброшено намеренно. Проектных файлов не добавляет и
раскладку не меняет — это правила письма, а не новый слот.
7. **Умолчание профиля ревью сменилось** — это не раскладка, но проектный текст
под него уже написан. `standard` стал рабочим умолчанием: миграция схемы,
публичный контракт и инвариант ступень больше **не** поднимают, `wide`
означает новое понятие или структурную единицу. Подраздел «Триггеры профиля»
в `docs/review.md` остаётся на месте, но его содержимое надо перечитать.
**Что переехало:** `docs/tasks/PLAN.md``docs/tasks/ROADMAP.md`; достигнутая
цель — из небытия в секцию `Готово`: `close <цель> --implemented` удаляет файл, но
**оставляет строку с датой**. Прежде роадмап отвечал только «что осталось», и
половину его вопроса вели прозой руками. Вместе с
файлом переименован ключ конфига `tasks.plan``tasks.roadmap` и токены
команд: `--index plan``--index roadmap`, `init --plan-sections`
`--roadmap-sections`, `init --plan``--roadmap`. Старый ключ в
`docs/.pm.json` не игнорируется молча — `tasks.py` останавливается и называет
переименование.
**Что удалено:** тип `[epic]`. Он был зонтиком между целью и задачами; зонтиком
стала цель, а слишком крупный шаг дробится на шаги помельче под ней. Ноль
употреблений на 97 записей двух живых проектов.
**Что сделать проекту:**
1. `git mv docs/tasks/PLAN.md docs/tasks/ROADMAP.md`.
2. Починить ссылки на прежнее имя: `grep -rn 'PLAN\.md' docs/ CLAUDE.md`
заголовок самого файла («# План» → «# Роадмап»), строка в `docs/tasks/BACKLOG.md`,
упоминания в `docs/passport.md` и в телах задач.
3. `docs/.pm.json`: ключ `tasks.plan`, если он там был, — в `tasks.roadmap`.
4. Проставить род работы живым задачам: `python3 tasks.py check --dir docs/tasks`
перечислит те, у кого его нет. Задним числом весь беклог не переоформляется —
род нужен к взятию, так что порядок такой: сперва то, что берётся в ближайший
спринт, остальное по ходу переоценки.
5. Дописать раздел «Затрагивает» — тем же порядком и по той же причине: сперва
набор спринта, остальное по мере того, как задача попадает в работу.
6. Перечитать «Триггеры профиля» в `docs/review.md`: строки вида «миграция →
`deep`» теперь дублируют умолчание с обратным знаком. Оставить там только то,
что для этого проекта считается **новым понятием** и **правилом
идентичности**, — и убрать остальное, иначе проект возвращает себе прежнюю
частоту полного набора уточнением.
7. Переименовать секции роадмапа: `порядок``Запланировано`, `темы`
`Направления`; завести `Готово` **первой** и `Разработка` последней
(порядок секций поменялся в версии 4 — если едешь сразу на неё, заводи
`Готово` последней и не переставляй дважды).
Прозаические разделы вроде «Что уже пройдено», которые велись руками,
разложить: звенья — строками в `Готово` (дата, слаг, что стало возможно),
обоснование очереди оставить прозой в `Запланировано`. Любой `##` в индексе
проверка считает секцией, и теперь `check` называет чужую секцию ошибкой.
8. Переформулировать цели ответом на **«что приложение будет уметь»**: не
«Работа со слиянием», а «Исход слияния не зависит от порядка доставки».
Свойство поведения — законная цель. Цель, которая не про приложение
(процесс, инструмент), переезжает в `Разработка`.
9. `[epic]`, если он в проекте заводился: это либо цель, либо набор задач под
общей целью. `check` назовёт его неизвестным типом.
10. Прогнать `python3 tasks.py check --dir docs/tasks --fix`: он поднимет
написание канонических секций, поставит отбивку после заголовков и сведёт
секцию в мете файлов с заголовками индексов. Секции беклога проект
переименовывает сам — их имена он выбирал, и трогать их скрипт не вправе.
11. Переписать заголовки задач в форму действия — по мере того, как задача
попадает в работу, а не «заодно»: `check` печатает их число, а `task-form`
предложит формулировки на замену пачкой.
12. Прочитать [language.md](language.md) — и **ничего не переписывать задним
числом**. Правила языка применяются к тому, что пишется и правится сейчас;
сплошная вычитка старых документов стоит дороже, чем даёт.
13. `docs/.pm.json`: `"canon": 3`.
## Версия 2 — 2026-08-03
Шапка записи ADR — мета-блоком общей формы, и у статуса появился объявленный
дом. Раскладка не менялась: правка касается одного шаблона.
**Что добавилось:** поле `- **Статус:**` в шапке `docs/adr/template.md`
`заменено на ADR-…` либо `устарело`, у активной записи поля нет. Правило
«старая запись получает статус» было и раньше ([canon.md](canon.md), `adr/`),
но места под него шаблон не отводил: каждая запись изобретала своё, а колонка
«Статус» таблицы `adr/README.md` брала его оттуда, где он у каждого свой.
**Что переехало:** поля `Дата` и `Источник` в шаблоне стали жирными
(`- **Дата:**`, `- **Источник:**`) — та же форма, что у меты задачи и у записи
журнала дефектов: поле на строку, имя жирным.
**Что удалено:** ничего.
**Что сделать проекту:**
1. Привести `docs/adr/template.md` к скелету версии 2
([skeletons.md](skeletons.md), раздел `docs/adr/template.md`).
2. В существующих записях `docs/adr/ADR-*.md`: жирным поля шапки; если статус
записан прозой или заголовком — перенести его полем `- **Статус:**` в шапку
и сверить с колонкой «Статус» таблицы в `docs/adr/README.md`.
3. `docs/.pm.json`: `"canon": 2`.
## Версия 1 — 2026-08-03
Первая версия. Проект любой прежней раскладки приводится к ней скиллом `canon`
в режиме `adopt`, а не `upgrade`.
**Что вводится:** раскладка целиком — см. [canon.md](canon.md).
**Что сделать проекту, который приходит из свободной раскладки:**
1. `docs/.pm.json` с `{"canon": 1}` и путём миграций, если БД есть.
2. Скелет канона целиком; незаполненное — одной честной строкой.
3. `docs/specs/` разобрать: поведение — в `openspec/specs/`, обзор — в
`docs/architecture.md`, знание о чужих системах — в `docs/research/`.
Дубли capability удалить, сверив поимённо.
4. `docs/plan.md``docs/tasks/PLAN.md`, шаги плана — целями в «порядок».
5. `BRIEF.md``docs/passport.md`.
6. `docs/backlog/``docs/tasks/`.
7. `docs/review-journal.md` или `docs/review/journal.md``docs/review.md`,
плюс раздел настройки конвейера.
8. `docs/drafts/` растворить: идея → задача `[idea]`, намеренный отказ → ADR,
порядок работ → `PLAN.md`.
9. `docs/review-brief.md`, если заводился, удалить: его разделы разошлись по
документам канона.
10. `conventions.md``conventions/`, `local-research.md``research/`.
11. Завести `docs/security.md` с периметром первой строкой и `docs/adr/`.
12. В `CLAUDE.md`: severity рядом с каждым инвариантом; семантика гейта (чем
краснеет безусловно, где логи, чего в нём нет и кто тогда гоняет дорогое);
**имя основной ветки**; запреты с путями; где `testdata` и куда писать
временное; **что считается необратимым**; общий станок; ориентир по размеру
спринта. Убрать раздел «Процесс», если он пересказывает пайплайн.
13. В `openspec/config.yaml` оставить только нужды генерации и ссылки.
14. Добавить шаг `docs.py check` в гейт проекта.
**Копии правил в шаблонах, которые версия 1 уносит в проект** — их правка в
каноне обязана появляться здесь отдельной версией:
| Что копируется | Дом определения |
| --- | --- |
| форма записи журнала дефектов в `docs/review.md` | `av-dev-pipeline/skills/review-pipeline/references/review-journal.md` |
| правило заведения ADR в `docs/adr/README.md` | [canon.md](canon.md), раздел `adr/` |
@@ -0,0 +1,211 @@
# Язык проектных текстов
**Копия.** Дом — `shared/language.md` в репозитории плагинов; язык общий для
документов канона и для задач, и потому не принадлежит ни одному плагину.
Правится дом, а не этот файл: расхождение ловит `copies.py` на гейте коммита.
<!-- копия: язык-доктрина из shared/language.md -->
Правила — для всего, что пишется словами: задачи и цели, документы канона,
решения ADR, записки разведки, сообщения коммитов. Не для кода и не для
сообщений программы пользователю — там свои конвенции проекта.
Основа — **информационный стиль** Максима Ильяхова ([учебник
бюро](https://bureau.ru/projects/book-text/), книга «Пиши, сокращай»). Он
написан для рекламы, статей и писем, поэтому взят не целиком.
## Зачем он здесь
Проектный текст читают в двух положениях, и оба неудобные: **выбирают, брать ли
задачу**, глядя в строку индекса и один экран тела; и **возвращаются через
квартал**, не помня контекста. Оба положения наказывают одно и то же — слова, не
несущие сведений. Информационный стиль ровно про это, и его польза здесь не
эстетическая: текст, из которого нельзя достать факт, заставляет открывать код,
а это и есть цена, которой мы избегаем.
## Что взято сверх правил вычитки
Эти три требования судит человек, а не проход вычитки: находка по ним требует
увидеть текст целиком, а не фразу.
**Полезное действие.** У каждого текста есть вопрос, на который он отвечает, и
читатель, который этот вопрос задаёт. Не отвечает — не пишется. У задачи это
«зачем она нужна» и «что станет наблюдаемо иначе»; у документа канона — его
собственный вопрос («что это за система», «как сложено», «почему так решили»).
Текст, который не отвечает ни на чей вопрос, сокращается до нуля — это законный
исход правки.
**Параллельность.** Однородное пишется одинаково: пункты списка — одной
грамматической формой, разделы одного вида — одним порядком, заголовки одного
уровня — одним типом фразы. Разнобой читатель принимает за разницу по существу и
ищет её.
**Заголовок работает.** Заголовок называет содержание раздела, а не тему
вообще: «Что проверяет `check`», а не «О проверках». Заголовков ставится
столько, чтобы длинный текст можно было просматривать, а не только читать
подряд.
## Что отброшено намеренно
Инфостиль написан для текстов, где читателя надо удержать. Проектный текст
читают потому, что надо, и держать его нечем. Отсюда три расхождения:
- **Парцелляция и рубленые фразы — нет.** Приём «Коротко. Ещё короче. Вот так»
ломает причинную связь, а в решении и в задаче ценность именно в ней:
«поэтому», «иначе», «раз так» несут смысл и остаются.
- **Не всякое вводное — мусор.** «Если», «иначе», «при таком-то условии»,
«в отличие от» — это условия и противопоставления, то есть сведения. Режутся
вводные, которые не меняют смысл предложения.
- **Скобки и точка с запятой остаются.** В технической записи скобки несут
уточнение — имя команды, единицы, слаг, — и запрет на них удлинил бы текст, а
не сократил. Запрет на многоточие принимаем: в проектном тексте оно значит
«дописать позже», и такой текст лучше не публиковать.
И общее: инфостиль призывает «снять корону с себя и надеть на читателя». Здесь
читатель — **ты сам через квартал** и тот, кто возьмёт задачу. Писать для них
значит называть состояние и остаток, а не пересказывать, как было интересно
разбираться.
<!-- /копия: язык-доктрина -->
## Правила
<!-- копия: язык-правила из shared/language.md -->
У каждого правила названа причина: она же говорит, где правило **не**
применяется.
1. **Глагол вместо отглагольного существительного, активный залог.** «Обработчик
не проверяет владельца», а не «проверка владельца не осуществляется»;
«скрипт переписывает индекс», а не «индекс переписывается скриптом».
Отглагольное существительное прячет того, кто действует, — а в техническом
тексте важен именно он. Страдательный залог **остаётся**, когда деятель
неизвестен или неважен: «файл удаляется» верно, если удаляет любая из трёх
команд.
2. **Факт вместо оценки.** «Время ответа доходит до 800 мс», а не «работает
медленно»; «тело 40 МиБ держит блокировку 5 секунд», а не «большие тела
тормозят». Оценка допустима, когда факт стоит рядом, в той же фразе. Без
факта это настроение, а не сведение, — и находка тем ценнее, что оценку
потом не проверить.
3. **Стоп-слова.** Канцелярит (является, осуществляется, в целях, в рамках,
данный, вышеуказанный), вводные-паразиты (в общем, как известно, стоит
отметить), усилители (очень, крайне, достаточно, абсолютно, максимально),
синонимы одного качества («понятный и простой»), неопределённое
(соответствующий, определённый, некоторый).
Проверка одна: **вычеркни слово — смысл изменился, оставляй.** И осторожно с
вводными: «если», «иначе», «при таком-то условии», «в отличие от» несут
условие и противопоставление, то есть сведения, — их не трогают.
4. **Одна мысль — одно предложение.** Предложение с двумя независимыми
утверждениями делится. **Причинную связь не режут**: «поэтому», «иначе», «раз
так» — смысл, а не длина; рубленые фразы ради краткости тут вредят.
**Поля меты не делятся.** «Зачем» в мете задачи по формату — одно
предложение: оно повторяется строкой индекса, и второму там не поместиться.
Тесно — сокращают, но не делят. То же с любым полем вида `- **Имя:** …`.
5. **Англицизм, у которого есть живое русское слово, заменяется.**
| Калька | Русский аналог |
| --- | --- |
| флоу | поток, процесс, сценарий |
| фикс, зафиксить | исправление, исправить, починить |
| чекать | проверять |
| апрув, заапрувить | согласование, согласовать |
| best-effort | по возможности |
| кейс | случай, сценарий |
| перформанс | производительность |
| матчинг, смэтчить | сопоставление, сопоставить |
| зарелизить | выпустить, выложить |
| отрефакторить | переписать, разделить, убрать второй путь |
Насильно не переводится то, что является **именем вещи**: термины технологий
и протоколов (`SQL`, `API`, `CSV`, `N+1`, `IDOR`), имена классов, методов,
полей, таблиц и команд, слаг, а также термин, у которого нет точного русского
эквивалента и который в команде уже прижился.
Цель — простой и точный текст, а не пуризм. Русский аналог звучит коряво или
искажает смысл — остаётся термин.
6. **Слово из своего словаря не трогается — список закрыт.** Оговорка «термин
прижился» без списка проверяема на глаз и потому не проверяема: прижившимся
выглядит любое слово, встреченное трижды.
| Термин | Что называет |
| --- | --- |
| интейк | заведение записи с фильтром и дедупом: «заведение» называет создание файла, слить их — смешать две операции |
| триаж | стадия конвейера, сводящая находки в решение |
| провенанс | обязательное свойство числа: чем и при каких условиях получено. «Источник» рядом называет саму запись, а не свойство |
| дедуп, дедупликация | сверка нового против уже лежащего |
| чек-лист | перечень, по которому идут сверху вниз, называя исход каждой строки |
| дифф, `--base` | разница между состояниями в git |
| промпт | текст, которым зовут модель |
| change, capability, spec | сущности OpenSpec, имена вещей чужого инструмента |
| generative, applicative | роды проходов ревью, вводятся определением по месту |
**Список закрыт.** Слово не отсюда и не из таблицы имён вещей выше — находка,
а не «принятый стиль»: у него либо есть живой русский аналог, либо оно
требует ввода одной строкой при первом употреблении.
Отсюда же читается снятое. Эти слова из текстов убраны, и возвращать их не
надо: **конфляция** (смешение), **декорреляция** (разведённость, разведён с
кем-то), **непоймание** (почему не поймали), **эвал-сет** (проверочный
набор), **гайд** (руководство). Каждое было латинизмом или калькой при живом
русском слове, и каждое к моменту снятия жило в трёх-шести файлах разом — то
есть выглядело словарём, не будучи им.
7. **Жаргон и метафоры заменяются прямым называнием.** Автору образ понятен,
читателю — нет.
| Метафора-жаргон | Прямо |
| --- | --- |
| рычаг (кэша, отбора) | условие отбора, параметр |
| навешен не на тот счётчик | завязан не на тот счётчик |
| переширокий матчинг по имени | слишком грубое сопоставление по имени, слишком много слабых совпадений |
| костыль | временное решение, обходной путь — и в чём именно |
| просело, отвалилось | стало медленнее на столько-то, перестало отвечать |
Проверка: **фраза требует, чтобы читатель додумал образ, — заменяется
буквальным описанием того, что происходит.**
8. **Термин, которого нет в документах проекта, вводится одной строкой или не
употребляется.** Термин, не встречающийся ни в паспорте, ни в архитектуре, ни
в конвенциях, — свой словарь у отдельной записи, а это самый дешёвый способ
сделать беклог нечитаемым для того, кто вернётся к нему через квартал.
Заменять незнакомый термин догадкой нельзя: догадка о предметной области
дороже непонятного слова, потому что выглядит понятной.
**Слово, занятое в другом смысле, — то же нарушение.** Термин, который в
одном документе проекта значит одно, а здесь другое, ломает оба.
9. **Имя файла — английское слово по сути, а не транслит.** `queue-as-table`, а
не `ochered-tablicej`; `move-parse-strict`, а не `razbor-hoda`. Транслит
нечитаем тому, кто ищет по смыслу, и не сокращается, а имя стоит в ссылках,
коммитах и путях, которые набирают руками. Переименование — **перенос ссылок
одним проходом**, а не правка одного файла.
<!-- /копия: язык-правила -->
## Порог правки
<!-- копия: порог-правки из shared/language.md -->
**Правка без нарушенного правила не делается.** Текст, переписанный «чтобы
звучало лучше», обесценивает список замечаний: когда половина из них вкусовая,
перестают читать весь список, и вместе с ним пропадают настоящие находки.
Сомневаешься — не правь. Формулировка, которая просто **не твоя**, — не находка.
**Систематичность нарушения — не довод в его пользу.** Одна и та же ошибка в
пяти файлах не становится «принятым стилем»: чаще это значит, что правило не
применялось вовсе, — и находка тем важнее. «Так сделано везде» годится как
основание для **одной находки на весь набор** («правило N нарушено в пяти
записях, перечень: …»), но не как основание промолчать. Принятым считается
только то, что назвал зовущий или что записано в конвенциях проекта.
<!-- /копия: порог-правки -->
И обратное: язык правится **по ходу той операции, которая записи касается**.
Беклог не переписывают ради языка.
@@ -0,0 +1,511 @@
# Скелеты документов канона
Что кладут `init` и `canon adopt` в незаполненный слот. Правило одно:
**честная информативная строка вместо заглушки**. Проход читает строку как факт;
`<!-- заполнить: … -->` он читает как пробел, и `docs.py check` о таком
плейсхолдере напоминает.
Плейсхолдер ставится только там, где ответ **обязан** быть и его не спросили.
Всё, чего в проекте пока просто нет, описывается словами, а не плейсхолдером.
**Шаблоны — единственное место, где правило канона копируется намеренно.**
`adr/README.md` и `review.md` уезжают в репозиторий проекта и обязаны там что-то
говорить; определение при этом остаётся в [canon.md](canon.md). Отсюда
обязанность: **правка такого правила в каноне тянет запись в
[changelog.md](changelog.md)** — и запись называет, какой файл проекта поднимает
`upgrade`. Без этого копия в проекте останется на старой версии молча.
**Каждая такая копия помечена и сверяется машиной.** Дом обрамляется
`<!-- дом: <id> -->``<!-- /дом: <id> -->`, копия —
`<!-- копия: <id> из <путь> -->``<!-- /копия: <id> -->`;
`scripts/copies.py` маркетплейса требует дословного
совпадения. Комментарии невидимы в отрендеренном markdown и уезжают в проект
вместе со скелетом — там они говорят читателю, что у текста есть дом. Правишь
текст внутри маркеров — правь дом, а не копию.
## `docs/passport.md`
```markdown
# Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/ROADMAP.md](tasks/ROADMAP.md) — «в каком порядке», паспорт —
«зачем и для кого».
## Цель
<!-- заполнить: одна фраза без технических деталей -->
**Потребители** — список закрытый: он определяет, что считать нужным, а что
интересным.
| Кто | Что ему нужно от нас |
| --- | --- |
Цель достигнута, когда:
## Что целью не является
Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие
через границу.
## Типовые сценарии
## Референсы
Где смотреть prior art, когда упёрлись.
```
## `docs/architecture.md`
```markdown
# Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не
описывается** — его нормативный дом `openspec/specs/`.
## Принципы
## Компоненты
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
## Внешние границы и форматы
## Эксплуатация
- Где работает, что рядом, кто перезапускает:
- Внешние зависимости поимённо и чем каждая отказывает (падает, отвечает
медленно, молчит, отдаёт мусор):
- Кто заметит отказ и когда:
- Характер потока (непрерывный, по запросу, по расписанию):
## Единые точки проекта
Где генерируются идентификаторы и время; где единственный парсер входного
формата; где маппинг доменной ошибки в код ответа; где общий путь приёма.
Материал для вопроса «не появился ли второй способ делать то, что уже делается».
## Деплой
## Открытые вопросы
```
Пустой проект: «Архитектуры пока нет: кода нет. Наполняется первой задачей.»
Нет внешних зависимостей: «Внешних зависимостей нет — смотри на диск и на СУБД.»
## `docs/database.md`
```markdown
# Схема хранилища
СУБД, миграции, правило времени и идентификаторов.
## Таблицы
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
## Настройки с числовым значением
Таймаут занятости, режим журналирования, лимит тела, размер пула, ретеншен.
Без них замер не превращается в находку: пик памяти — аномалия только рядом
со строкой «запись лежит сжатой и распаковывается целиком».
```
Нет БД — файла нет, и в `docs/.pm.json` нет ключа `migrations`.
## `docs/security.md`
```markdown
# Модель угроз
## Периметр
<!-- заполнить: первой строкой, против кого защищаемся -->
Контур не развёрнут — назови оба периметра, целевой и сегодняшний, и скажи
прямо, против какого строятся находки.
## Недоверенный вход
Что приходит извне и каким каналом: тело запроса, файл, аргумент команды,
ответ внешней системы, содержимое архива.
## Из чего строятся пути и ключи
Раскладка файлов на диске, состав координатного ключа записи, имя каталога.
Отсюда строится выход за пределы песочницы.
## Что разграничивает доступ
## Что чувствительнее чего
## Что вне модели
Перечислить явно. Пустой пункт означает, что в теме `security` угрозу выдумают
за тебя, и находка никогда не будет исправлена.
```
## `docs/conventions/README.md`
```markdown
# Конвенции кода
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
система делает.
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера, отсюда удаляется и переезжает в перечень ниже.
## Записи
## Механизировано
| Правило | Где механизировано |
| --- | --- |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
```
Пустой проект: «Конвенций пока нет: код не написан. Наполняется по мере
реального трения, а не вперёд.»
## `docs/research/README.md`
```markdown
# Разведка
Наблюдения за внешним миром: что реально шлёт источник, чем документация
формата расходится с практикой. Источник истины — этот каталог, а не чужая
документация.
**Каждый вывод — с числами и командой, которой получен**, чтобы его можно было
перепроверить.
## Как снималось
## Записи
```
Нет внешних источников: «Внешних источников данных нет — разведка неприменима.»
## `docs/adr/README.md`
```markdown
# Журнал решений
Одна запись — одно решение. **ADR это промоут поверх архивного `design.md`**,
а не второе сочинение: запись цитирует решение и ссылается на
`openspec/changes/archive/<id>/design.md`.
## Когда заводить
Верно одно из трёх:
<!-- копия: adr-когда-заводить из av-dev-docs/skills/canon/references/canon.md -->
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
<!-- /копия: adr-когда-заводить -->
Не заводить для рутины и для того, что видно из кода и `git log`.
## Соглашения
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле.
## Записи
Новые сверху.
| Дата | Запись | Статус |
| --- | --- | --- |
```
## `docs/adr/template.md`
```markdown
# Краткий заголовок решения
- **Дата:** ГГГГ-ММ-ДД
- **Источник:** openspec/changes/archive/<id>/design.md
Статус ставится тем же полем и только при пересмотре:
`- **Статус:** заменено на ADR-…` либо `- **Статус:** устарело`.
У активной записи поля нет.
## Решение
Что именно решено — одной фразой.
## Почему
Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через
год было понятно без чтения переписки.
## Последствия
- `+` что стало лучше.
- `` чем платим: ограничения, риски, нагрузка на поддержку.
```
## `docs/review.md`
```markdown
# Ревью: настройка и журнал
## Как настроен конвейер
### Типовые узлы
Рода узлов проекта и 3–5 проверяемых свойств к каждому. Рода, а не инвентарь
пакетов: род, который проект задумал, но ещё не написал, включать полезно.
### Типовые ложноположительные
Находки, которые здесь выглядят убедительно и всегда неверны. Каждая — с одной
строкой «почему здесь это не дефект».
### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`. Главный источник — журнал ниже. Вопрос
задаёт тот проход, который закрывает эту тему на текущем прогоне, дополнительно
к обязательным.
**Адресуй теме, а не имени прохода.** Проходы переезжают между метками и
упраздняются; вопрос, адресованный проходу, перестанет задаваться в тот день,
когда тот уедет в старшую метку, — и заметить это будет нечем. Тема переезд
переживает.
Темы ядра: `requirements`, `autotests`, `conventions`, `architecture`,
`security`, `operations`. Плюс любая своя — та, под которую проект завёл в
`docs/` **свой** документ. Документы категорий `источник` и `процессный` тем не
порождают, и адресовать вопрос `passport`, `database`, `adr`, `research` или
`review` нельзя — таких тем нет. Вопрос про границу домена адресуй
`architecture`, вопрос про хранилище и числа — `operations`.
### Триггеры метки
Проектная конкретизация правила выбора метки. **Списка три: по одному на
каждую ось вверх и один вниз** — поимённо, узлами или capability.
**Крупное здесь** — про объём: что трогает несколько узлов или слоёв, переносит
ответственность между ними, перекладывает существующий код в новую форму.
**Незнакомое здесь** — про форму решения: то, чего в проекте ещё не было и чью
форму предстоит нащупать по ходу. Признак простой: перед работой нельзя назвать,
какие узлы будут тронуты.
Любая из двух осей поднимает прогон до `large`, старшей метки: там `security`,
`operations` и `architecture` проверяют запуском, и там же единственные замеры.
Метка рассчитана на **510% задач**; если сюда попадает каждая третья, списки
написаны слишком широко.
**Мелкое здесь** — опускает до `small`. Ориентир по доле — до трети задач, и в
любом случае меньше, чем `medium`: перевес `small` значит, что рабочее умолчание
сместилось само. Помни отрицательный тест конвейера: что
после мерджа не откатывается обратной правкой (миграция, формат на диске,
публичный контракт, имя), — не `small`, каким бы маленьким ни был дифф.
Уточняет умолчания конвейера, не отменяет их; рабочее умолчание — `medium`.
### Недоступно проверке
Оба подраздела — **по темам**: «в теме `operations` не проверяется X» читается,
а «не проверяется X» через месяц не найдёт ни один проход.
**Не проверит ни один проход** — принципиальная граница; по факту промаха не
пересматривается.
**Перестали проверять сознательно** — что, когда и почему, со ссылкой на запись
журнала. Пересматривается **первым**, как только что-то проскочило.
Тему, у которой в проекте нет дома, сюда писать не надо: её называет план
каждого прогона, и это честнее разовой записи.
## Журнал дефектов
Запись на каждый воспроизведённый дефект, сразу, а не ретроспективно: со
временем теряется не факт, а то, почему дефект не поймали.
Форма:
<!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
- **Где:** путь:строка либо «конвейер, а не код»
- **Симптом:** как обнаружилось, кем и когда
- **Причина:** что на самом деле было не так
- **Чем воспроизведён:** тест, команда, замер — с числами
- **Почему не поймали:** только для проскочивших — какой проход обязан был найти
и что ему помешало
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
проекта — либо «ничего, цена поимки выше цены дефекта»
<!-- /копия: журнал-дефектов-форма -->
```
Новый проект: «Дефектов пока не было. Настройка конвейера появится с первым
ревью.»
## `CLAUDE.md`
Лежит в корне, не в `docs/`. Единственный файл канона, который агент читает
**всегда**, поэтому в нём то, без чего нельзя сделать ни шага.
```markdown
# CLAUDE.md
Памятка для работы над <проект>. Перед задачей прочитай также
[docs/passport.md](docs/passport.md), [docs/architecture.md](docs/architecture.md)
и [docs/conventions/](docs/conventions/README.md).
## Что это
Абзац: что делает и чего **не** делает.
## Стек
## Инварианты
Что нарушать нельзя. Каждый пункт — три вещи: формулировка **как проверяемое
свойство**, а не лозунг; последствие нарушения и его обратимость; **severity**
рядом. По этим формулировкам проходы ревью присваивают `critical`, поэтому
severity стоит здесь, а не выводится каждым проходом заново.
## Команды
## Гейт
- Команда целиком и как определяется база диффа:
- Где логи шагов:
- Что означает каждый исход:
- **Что красит безусловно и почему:**
- Чего в гейте намеренно нет и **кто тогда обязан это гонять:**
## Запреты
Что запускать нельзя, **с путями**: рабочая БД, боевой каталог данных, внешние
сервисы. Плюс где `testdata` и куда писать временное.
## Работа
- **Основная ветка:** <имя>
- **Необратимое** (спрашивается у человека всегда):
- **Общий станок** — какая проверка, покраснев, врывается в замороженный спринт:
- **Ориентир по размеру спринта:** 5–8 задач, ориентир а не закон
- **Что такое «сделана»:** пайплайн проекта пройден + критерии приёмки проверены
поимённо
## Язык
- Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский.
```
Имя основной ветки, запреты с путями и «что необратимо» — не украшение: без
первого падают git-операции батча и расчёт базы диффа, без второго проход может
тронуть рабочие данные, без третьего вся шкала ранжирования триажа держится на
догадке.
## `openspec/config.yaml`
Каталог `openspec/` заводится командой — `openspec init --tools claude`, — и она
кладёт `config.yaml` с закомментированным примером внутри. Пример **заменяется
целиком**: нетронутый файл выглядит настроенным, а работает как пустой.
**Это маршрутизатор, а не второй дом фактов.** Сюда пишут ровно то, что нужно
**в момент порождения артефакта** и чего в этот момент ещё никто не открыл:
язык, правила именования capability, придирки валидатора и **адреса** документов
канона. Пересказ паспорта, инвариантов, конвенций и правил ревью сюда не
переносится: расходится он молча, а замечают это в уже написанном предложении.
```yaml
schema: spec-driven
context: |
Language: Russian
Пиши на русском, но:
- Структурные заголовки оставляй на английском:
## ADDED/MODIFIED/REMOVED Requirements, ### Requirement:, #### Scenario:
- Ключевые слова GIVEN/WHEN/THEN и RFC 2119 (SHALL/MUST/SHOULD) — на английском
- Технические термины, пути и код — на английском
Имена capabilities:
- Capability — это ПОВЕДЕНИЕ или домен системы, а не пакет кода (совпадение с
именем пакета допустимо, но не критерий).
- Существительное, понятное без знания кода: ingest, parsing, storage,
read-api. НЕ store/httpapi — это реализация.
- Гранулярность по принципу «требования меняются вместе». Дробить, когда в
одной спеке смешиваются разные заботы. Переименовать дёшево (RENAMED
Requirements) — не дроби преждевременно в маленьком проекте.
RFC 2119 — требование валидатора, не стиль:
- Каждое ### Requirement ОБЯЗАНО содержать литерал SHALL или MUST, иначе
`openspec validate` падает. Поэтому эти слова и WHEN/THEN не русифицируем.
Что это за проект — читай перед предложением, а не отсюда:
- docs/passport.md — цель, её граница (чем проект НЕ является), потребители,
типовые сценарии, референсы;
- CLAUDE.md — инварианты с severity и семантика гейта;
- docs/architecture.md — устройство; docs/security.md — периметр;
docs/adr/ — почему решено так; docs/research/ — что уже измерено.
Пересказа этих документов здесь нет намеренно: второй дом факта расходится с
первым молча, и заметно это становится в предложении, которое уже написано.
Ревью: правило выбора метки и состав проходов здесь не пересказываем — их дом
скилл av-dev-pipeline:review-pipeline, проектная настройка — docs/review.md.
Конвенции кода: механизированное проверяет гейт, прозой остаётся
docs/conventions/. Ни состав шагов гейта, ни перечень конвенций здесь не
пересказываем: и то и другое растёт по ходу задач.
Развилка или блокер — сперва prior art. Готовые решения смотрим в референсах
паспорта, отвергаем — с названной причиной, и причина идёт в design.md этого
же изменения.
rules:
proposal:
- Capabilities называй по поведению или домену системы, не по пакету кода
specs:
# Кавычки обязательны: без них YAML обрежет строку на первом '#'.
- "Каждое ### Requirement обязано содержать SHALL или MUST (иначе валидация падает)"
- "Сценарий — ровно #### (четыре решётки); три или список молча теряются"
- "SHALL/MUST должно стоять в ПЕРВОМ абзаце требования: валидатор смотрит только его"
- "Заголовки и WHEN/THEN/GIVEN — на английском, остальной текст на русском"
```
**Четыре правила для `specs` сняты отказами валидатора, а не выведены из
документации** — потому и записаны дословно: без них каждое второе предложение
узнаёт их падением `openspec validate --strict`. Блок `context` проект
дополняет своим (стек, разведка, особенности домена), но **адреса паспорта и
`CLAUDE.md` обязательны** — их отсутствие `docs.py check` называет отказом.
**Ключи под `rules:` — имена артефактов схемы**, а не свободные слова:
`proposal`, `specs`, `design`, `tasks`. Правило под чужим именем не применяется
и об этом не сообщает, поэтому `rules.spec` вместо `rules.specs` даёт конфиг,
выглядящий написанным и не работающий; `docs.py check` такой ключ называет.
Перечень артефактов задаёт OpenSpec, а не канон, — за его актуальностью следит
`docs.py openspec-form`.
## `docs/.pm.json`
```json
{
"canon": 7
}
```
Плюс `"migrations": "<путь>"`, если есть БД. Ключ `"tasks"` заводится **только**
когда имя файла или заголовка отличается от умолчания (`{"backlog":
"INDEX.md"}`); секций беклога в нём нет — их дом заголовки `##` индекса. Состав
ключей — [canon.md](canon.md).
+915
View File
@@ -0,0 +1,915 @@
#!/usr/bin/env python3
"""Проверка раскладки документов проекта против канона av-dev.
Определение канона — references/canon.md рядом со скриптом. Здесь только
механизируемая часть: пути, лишние файлы, битые ссылки, версия, плейсхолдеры,
маркеры долга и две сверки с кодом. Смысловые дубли и оставшееся в архитектуре
поведение судит агент — скрипт об этом говорит вслух в конце отчёта.
Коды выхода — тот же словарь, что у tasks.py:
0 сошлось
1 дрейф раскладки (рабочая ситуация, чинится)
2 ошибка употребления
3 окружение: не тот каталог, битый конфиг
4 внутренний сбой
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
CANON_VERSION = 7
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# --- Раскладка канона -------------------------------------------------------
# Документ канона: имя → (категория, на какой вопрос отвечает).
#
# Категории — из canon.md, раздел «Три категории документов». Разрез один: можно
# ли по документу сказать «в этом изменении сделано не так»?
# тема — да, прямо: документ заводит направление проверки изменения;
# источник — нет, но он задаёт границу, по которой судит чужая тема;
# процессный — нет: он про то, как мы работаем, а не про изменение.
#
# **Категория не меняет обязательности документа** — заводятся все три
# одинаково и с первого дня. Она меняет только то, что с документом делает
# конвейер ревью, и потому печатается в отказе: «нет источника passport»
# читается иначе, чем «нет темы security», и чинится теми же руками, но с
# другим приоритетом.
#
# **Документ живёт файлом `docs/<имя>.md` либо каталогом `docs/<имя>/` с
# README.md внутри.** Форму выбирает проект: документ разросся — стал каталогом,
# и это не смена канона и не повод править скрипт. Обе формы сразу — ошибка: это
# два дома для одного факта, ровно то, от чего канон и защищает.
DOCS = {
"passport": ("источник", "зачем и для кого, чем НЕ является"),
"architecture": ("тема", "как сложено — обзор, окружение, эксплуатация"),
"security": ("тема", "периметр, недоверенный вход, что вне модели"),
"conventions": ("тема", "как мы пишем код; индекс, промоут, что механизировано"),
"research": ("процессный", "что показала реальность: наблюдения и числа"),
"adr": ("процессный", "почему решено так; индекс, статусы, правило замены"),
"review": ("процессный", "настройка конвейера + журнал дефектов"),
}
# Документ, обязательный только при условии: имя → (ключ .pm.json, категория,
# пояснение).
CONDITIONAL_DOCS = {
"database": ("migrations", "источник", "схема хранилища и настройки"),
}
# Обязательные файлы вне раскладки docs/.
REQUIRED = {
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
"docs/.pm.json": "версия канона и пути, нужные проверкам",
}
# Файлы, которые документ-каталог обязан держать сверх README.md.
DOC_EXTRA = {
"adr": {"template.md": "шаблон записи ADR"},
}
# Настройка OpenSpec. Команда заведения — она же в скилле init; здесь потому,
# что её печатает отказ, а отказ без команды заставляет искать её в другом месте.
OPENSPEC_INIT = "openspec init --tools claude"
# Адреса, которые обязан назвать блок context. Не пересказ документов, а именно
# ссылки: предложение пишется до того, как кто-либо откроет docs/, и без этих
# двух строк его пишут, не зная ни границы домена, ни инвариантов. Список
# короткий намеренно — длинный превращает context во второй дом фактов.
OPENSPEC_POINTERS = [
("passport", "граница домена и «чем НЕ является» останутся непрочитанными"),
("CLAUDE.md", "инварианты и семантика гейта останутся непрочитанными"),
]
# --- Форма config.yaml сверена с живым OpenSpec ------------------------------
#
# Три константы ниже — **слепок чужого инструмента**, а не наше решение. Схема,
# перечень артефактов и версия, на которой это проверено, живут в OpenSpec и
# меняются без нашего участия; здесь они записаны, чтобы проверка шла без запуска
# node на каждом прогоне.
#
# Слепок стареет, и потому есть кто, кто это замечает: `check` сравнивает
# major.minor установленного OpenSpec с OPENSPEC_CHECKED и, если они разошлись,
# говорит замечанием «форма не перепроверена». Перепроверяет `docs.py
# openspec-form` — он спрашивает сам инструмент и печатает, что разошлось.
# Патч-версия сравнением намеренно не берётся: форма конфига в ней не меняется, а
# замечание на каждый багфикс приучило бы пролистывать весь блок.
OPENSPEC_CHECKED = "1.5"
OPENSPEC_SCHEMA = "spec-driven"
# Артефакты схемы. Ключ `rules:` адресуется артефакту, и адресованный
# несуществующему **молча не действует** — ровно тот класс, ради которого вся
# проверка и заведена.
OPENSPEC_ARTIFACTS = ("proposal", "specs", "design", "tasks")
# Служебное в docs/ и каталог, который ведёт tasks.py. Оба процессные, но
# проверок формы у них нет: .pm.json не markdown, tasks/ ведёт другой скрипт.
NOT_DOCS = {".pm.json", "tasks"}
# Слоты, которых в каноне нет, — с адресом, куда уезжает содержимое. Имена,
# совпадающие с темой, отсюда убраны намеренно: `docs/conventions.md` и
# `docs/review/` теперь законные формы своих тем.
RETIRED = {
"review-brief.md": "документы канона и есть бриф; остаток — в review",
"review-journal.md": "→ документ review",
"plan.md": "→ docs/tasks/ROADMAP.md",
"local-research.md": "→ документ research",
"specs": "поведение → openspec/specs/, обзор → тема architecture",
"drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md",
"backlog": "→ docs/tasks/",
}
# --- Слаги в именах файлов --------------------------------------------------
# Текст документов русский, а **имена файлов английские, kebab-case**. Причина
# не в эстетике: имя файла стоит в ссылках из других документов, в коммитах и в
# путях, которые люди набирают руками, — а кириллица в пути ломается по-разному
# в разных местах и не набирается на английской раскладке.
SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
ADR_NAME = re.compile(r"ADR-(\d{4})-(\d{2})-(\d{2})-(.+)")
CYRILLIC = re.compile(r"[а-яёА-ЯЁ]")
# Признаки транслита — и только они. Отличить английское слово от транслита
# машина не умеет, поэтому находка идёт **замечанием**: кластеры, которых в
# английском практически не бывает, плюс окончания русских падежей.
#
# Слабые маркеры выброшены намеренно, каждый по своему ложному срабатыванию:
# `ost` ловит `post` и `cost`, `sch` — `schema`, `ya` — `yaml`, `nost` —
# `nostalgia`, хвост `ii` — `radii`. Набор подобран так, чтобы ложных
# срабатываний не было вовсе: правило, краснеющее на правде, приучает
# пролистывать весь блок. Цена известна и принята — `sostoyanie-partii`
# проходит мимо.
#
# Тот же приём, что `translit_ish` в tasks.py; скрипты независимы намеренно —
# каждый уезжает в чужой проект в одиночку.
TRANSLIT_CLUSTER = re.compile(r"zh|kh|shch|tsy|iya|ovanie|enie|stvo")
TRANSLIT_TAIL = re.compile(r"(?:ej|oj|ij|yj|yy|aya)$")
def translit_ish(slug: str) -> bool:
if TRANSLIT_CLUSTER.search(slug):
return True
return any(TRANSLIT_TAIL.search(part) for part in slug.split("-"))
def check_slugs(root: Path, rep: Report) -> None:
"""Имена файлов канона: латиница kebab-case, у ADR — ещё и форма имени.
Каталог задач не трогаем: его слаги ведёт и проверяет tasks.py, и вторая
проверка того же места разошлась бы с первой.
"""
docs = root / "docs"
if not docs.is_dir():
return
# Имена, выбранные каноном, а не проектом: их форма задана здесь же.
fixed = {"README.md", "template.md"} | {f"{name}.md" for name in DOCS}
# Все документы-каталоги, включая свои темы проекта: правило имён общее, а
# перечислять их поимённо значило бы закрыть открытый список.
for folder in sorted(docs.iterdir()):
if not folder.is_dir() or folder.name in NOT_DOCS:
continue
sub = folder.name
for path in sorted(folder.rglob("*.md")):
name = path.name
rel = path.relative_to(root)
if name in fixed:
continue
stem = path.stem
if sub == "adr":
m = ADR_NAME.fullmatch(stem)
if not m:
rep.error(
f"{rel}: имя не по форме ADR-ГГГГ-ММ-ДД-slug.md — "
f"по имени сортируются записи и ищется дата решения"
)
continue
stem = m.group(4)
if CYRILLIC.search(stem):
rep.error(
f"{rel}: кириллица в имени файла — слаги английские, "
f"kebab-case (текст документа при этом русский)"
)
continue
if not SLUG.fullmatch(stem):
rep.error(
f"{rel}: имя не kebab-case латиницей — только строчные "
f"буквы, цифры и одиночные дефисы"
)
continue
if translit_ish(stem):
rep.note(
f"{rel}: имя похоже на транслит («{stem}») — слаг именуется "
f"английским словом по сути, а не записью русского латиницей: "
f"транслит нечитаем тому, кто ищет по смыслу. Проверено "
f"эвристикой: английское слово от транслита машина не отличает"
)
check_capability_slugs(root, rep)
def check_capability_slugs(root: Path, rep: Report) -> None:
specs = root / "openspec" / "specs"
if not specs.is_dir():
return
for folder in sorted(specs.iterdir()):
if not folder.is_dir():
continue
if CYRILLIC.search(folder.name) or not SLUG.fullmatch(folder.name):
rep.error(
f"openspec/specs/{folder.name}/: имя capability — латиница "
f"kebab-case; оно стоит в ссылках из architecture.md и в спеках"
)
DEBT_MARKER = re.compile(r"<!--\s*канон:\s*(.+?)\s*-->")
PLACEHOLDER = re.compile(r"<!--\s*заполнить:\s*(.+?)\s*-->")
MD_LINK = re.compile(r"\[[^\]]*\]\(\s*<?([^)>\s]+)>?(?:\s+[\"'(][^)]*)?\)")
FENCE = re.compile(r"^\s*(```|~~~)")
INLINE_CODE = re.compile(r"`[^`\n]*`")
def strip_code(text: str) -> str:
"""Выкинуть блоки кода и вставки в обратных кавычках.
Путь в примере или в шаблоне — не ссылка, и краснеть на нём значит краснеть
на каждом образце документа. Инлайн-код тоже: `[docs/backlog](docs/tasks/…)`
в тексте про подписи ссылок — иллюстрация, а не ссылка."""
out, inside = [], False
for line in text.splitlines():
if FENCE.match(line):
inside = not inside
continue
out.append("" if inside else INLINE_CODE.sub("", line))
return "\n".join(out)
@dataclass
class Report:
errors: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
debts: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
def error(self, msg: str) -> None:
self.errors.append(msg)
def note(self, msg: str) -> None:
self.notes.append(msg)
def debt(self, msg: str) -> None:
self.debts.append(msg)
def skip(self, msg: str) -> None:
self.skipped.append(msg)
def fail(code: int, msg: str) -> NoReturn:
print(f"ОТКАЗ: {msg}", file=sys.stderr)
sys.exit(code)
def read_config(root: Path, rep: Report) -> dict:
path = root / "docs" / ".pm.json"
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
fail(ENV, f"docs/.pm.json не разбирается: {exc}")
if not isinstance(data, dict):
fail(ENV, "docs/.pm.json должен быть объектом")
return data
# --- Проверки ---------------------------------------------------------------
def check_version(root: Path, cfg: dict, rep: Report) -> None:
if not (root / "docs" / ".pm.json").exists():
return # об отсутствии файла скажет check_required, второй раз не нужно
if "canon" not in cfg:
rep.error("в docs/.pm.json нет ключа canon — версия канона не объявлена")
return
got = cfg["canon"]
if not isinstance(got, int):
rep.error(f"canon в docs/.pm.json должен быть целым числом, а не {got!r}")
return
if got < CANON_VERSION:
rep.error(
f"проект приведён к канону версии {got}, текущая — {CANON_VERSION}: "
f"нужен canon upgrade"
)
elif got > CANON_VERSION:
rep.error(
f"проект приведён к канону версии {got}, а скрипт знает {CANON_VERSION}: "
f"устарел плагин, обнови маркетплейс"
)
def doc_home(root: Path, name: str) -> tuple[Path | None, str | None]:
"""Дом документа: файл `docs/<имя>.md` или каталог `docs/<имя>/`.
Возвращает путь и жалобу. Обе формы сразу — это два дома для одного факта, и
расходятся они молча: правят одну, читают другую.
"""
docs = root / "docs"
as_file = docs / f"{name}.md"
as_dir = docs / name
if as_file.is_file() and as_dir.is_dir():
return as_file, (
f"{name} живёт сразу двумя домами — docs/{name}.md и docs/{name}/:"
f" оставить один, иначе правят один, а читают другой"
)
if as_file.is_file():
return as_file, None
if as_dir.is_dir():
if not (as_dir / "README.md").is_file():
return as_dir, (
f"docs/{name}/ без README.md — у документа-каталога вход"
f" обязателен: по нему его читают агенты"
)
return as_dir, None
return None, None
def check_required(root: Path, cfg: dict, rep: Report) -> None:
for rel, what in REQUIRED.items():
if not (root / rel).exists():
rep.error(f"нет {rel}{what}")
for name, (kind, what) in DOCS.items():
home, complaint = doc_home(root, name)
if home is None:
rep.error(
f"нет документа {name} (docs/{name}.md или docs/{name}/),"
f" категория «{kind}» — {what}"
)
continue
if complaint:
rep.error(complaint)
if home.is_dir():
for extra, why in DOC_EXTRA.get(name, {}).items():
if not (home / extra).is_file():
rep.error(f"нет docs/{name}/{extra}{why}")
for name, (key, kind, what) in CONDITIONAL_DOCS.items():
home, complaint = doc_home(root, name)
if complaint:
rep.error(complaint)
if key in cfg and home is None:
rep.error(
f"нет документа {name} (docs/{name}.md или docs/{name}/),"
f" категория «{kind}» — {what}"
f" (обязателен: в .pm.json объявлен {key})"
)
elif key not in cfg and home is None:
rep.skip(f"{name} — в .pm.json нет ключа {key}, проверка неприменима")
def check_stray(root: Path, rep: Report) -> None:
"""Лишнего в docs/ больше нет — есть свои темы проекта.
Категории `источник` и `процессный` **закрыты**: они перечислены в каноне
поимённо и проектом не пополняются. Открыта только категория `тема` —
поэтому любой документ в docs/, которого нет в раскладке, и есть заявка на
свою тему, и запретить её нельзя. Проверяются только слоты, у которых дом в
другом месте, — иначе переехавшее содержимое вернулось бы темой и выглядело
законным.
"""
docs = root / "docs"
if not docs.is_dir():
rep.error("нет каталога docs/")
return
known = set(DOCS) | set(CONDITIONAL_DOCS)
own: list[str] = []
for entry in sorted(docs.iterdir()):
name = entry.name
if name in RETIRED:
rep.error(f"docs/{name} — слота нет в каноне: {RETIRED[name]}")
continue
if name in NOT_DOCS:
continue
topic = name[:-3] if entry.is_file() and name.endswith(".md") else name
if topic in known:
continue
if entry.is_file() and not name.endswith(".md"):
rep.error(f"docs/{name} — не markdown: тема ревью читается как текст")
continue
if entry.is_dir() and not (entry / "README.md").is_file():
rep.error(
f"docs/{name}/ без README.md — у темы-каталога вход обязателен:"
f" по нему её читают агенты"
)
continue
own.append(topic)
if own:
rep.note(
f"свои темы проекта: {', '.join(own)} — именной оптики у них нет,"
f" их разбирает общий проход конвейера"
)
def canon_docs(root: Path) -> list[Path]:
"""Документы канона. Каталог задач ведёт tasks.py; упразднённые каталоги
уже названы отдельной строкой, и их внутренние ссылки не наша забота —
они переезжают целиком."""
out = []
docs = root / "docs"
skip = {"tasks"} | {name for name in RETIRED if not name.endswith(".md")}
if docs.is_dir():
for path in sorted(docs.rglob("*.md")):
head = path.relative_to(docs).parts[0]
if head in skip or head in RETIRED:
continue
out.append(path)
# AGENTS.md лежит рядом с CLAUDE.md и читается теми же агентами: он почти
# стандарт, и проект вправе держать оба. Обязателен по-прежнему только
# первый.
for name in ("CLAUDE.md", "AGENTS.md"):
path = root / name
if path.exists():
out.append(path)
return out
def check_links(root: Path, rep: Report) -> None:
for path in canon_docs(root):
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
rep.error(f"{path.relative_to(root)} не читается: {exc}")
continue
for target in MD_LINK.findall(strip_code(text)):
target = target.strip()
if not target or target.startswith(("http://", "https://", "#", "mailto:")):
continue
clean = target.split("#", 1)[0]
if not clean:
continue
if (path.parent / clean).exists():
continue
rep.error(f"{path.relative_to(root)}: битая ссылка на {target}")
def check_placeholders_and_debt(root: Path, rep: Report) -> None:
for path in canon_docs(root):
text = strip_code(path.read_text(encoding="utf-8", errors="replace"))
rel = path.relative_to(root)
for what in PLACEHOLDER.findall(text):
# Замечание, а не дрейф: незаполненный канон — объявленное переходное
# состояние, и краснеть на нём значит требовать выдумать содержание.
rep.note(f"{rel}: плейсхолдер шаблона не заполнен — {what}")
for what in DEBT_MARKER.findall(text):
rep.debt(f"{rel}: {what}")
def doc_text(root: Path, name: str) -> str | None:
"""Текст документа целиком: файл или все markdown каталога, склеенные.
Проверке всё равно, одним файлом написан документ или десятью: она ищет
упоминание, а упоминание живёт в любом из них.
"""
home, _ = doc_home(root, name)
if home is None:
return None
if home.is_file():
return home.read_text(encoding="utf-8", errors="replace")
return "\n".join(
path.read_text(encoding="utf-8", errors="replace")
for path in sorted(home.rglob("*.md"))
)
def rules_keys(live: str) -> list[str]:
"""Имена артефактов, которым адресованы правила, — и только они.
Идём от строки `rules:` до следующего ключа нулевой колонки, а не ищем
отступ по всему файлу: блок `context: |` — литеральный скаляр, внутри него
строки вида «Language: Russian» и «av-dev-pm:review-pipeline» выглядят
ключами и дали бы находку на ровном месте. Проверено на живом конфиге,
который так и падал.
"""
out: list[str] = []
inside = False
for line in live.splitlines():
if not line.strip():
continue
if not line[0].isspace():
inside = line.startswith("rules:")
continue
if not inside:
continue
m = re.fullmatch(r" ([A-Za-z_-]+):\s*", line)
if m:
out.append(m.group(1))
return out
def check_openspec(root: Path, rep: Report) -> None:
"""Настройка OpenSpec заведена и не осталась примером из коробки.
Разбираем текстом, а не YAML-парсером: у скриптов канона ноль внешних
зависимостей, а PyYAML в стандартной библиотеке нет. Всё, что проверяется
ниже, различимо построчно, и ложных срабатываний это не даёт: комментарии
отброшены, ключи верхнего уровня стоят в первой колонке.
"""
os_dir = root / "openspec"
if not os_dir.is_dir():
rep.error(
"нет openspec/ — там дом темы requirements (openspec/specs/) и "
f"настройка генерации артефактов; заводится `{OPENSPEC_INIT}`"
)
return
if (os_dir / "config.yml").is_file():
rep.error(
"openspec/config.yml — читается только config.yaml, и этот файл "
"останется незамеченным: настройка будет пустой, а выглядеть будет "
"заполненной"
)
path = os_dir / "config.yaml"
if not path.is_file():
rep.error(
"нет openspec/config.yaml — язык, правила именования capability и "
"придирки валидатора будут заново угадываться на каждом предложении"
)
return
text = path.read_text(encoding="utf-8")
live = "\n".join(
line for line in text.splitlines() if not line.lstrip().startswith("#")
)
keys = set(re.findall(r"(?m)^([A-Za-z_]+):", live))
schema = re.search(r"(?m)^schema:\s*(\S+)", live)
if schema is None:
rep.error(
f"в openspec/config.yaml нет ключа schema — ожидается {OPENSPEC_SCHEMA}"
)
elif schema.group(1) != OPENSPEC_SCHEMA:
rep.error(
f"schema в openspec/config.yaml — {schema.group(1)}, а канон описан "
f"для {OPENSPEC_SCHEMA}"
)
if "context" not in keys:
rep.error(
"в openspec/config.yaml нет ключа context: файл остался примером из "
"коробки — предложение пишется без языка, правил именования "
"capability и адресов документов проекта"
)
else:
for pointer, why in OPENSPEC_POINTERS:
if pointer not in live:
rep.error(
f"openspec/config.yaml не называет {pointer}{why}"
)
if "rules" not in keys or "specs:" not in live:
rep.error(
"в openspec/config.yaml нет rules.specs — придирки валидатора "
"нигде не записаны, и каждое предложение узнаёт их отказом"
)
elif "SHALL" not in live:
rep.error(
"rules.specs в openspec/config.yaml не называет SHALL — "
"требование без этого литерала валидатор отвергает, а правило "
"проекта об этом молчит"
)
# Ключ под rules: — имя артефакта схемы. Опечатка или устаревшее имя не
# ломает ничего видимого: правила просто не применяются, а конфиг выглядит
# написанным.
for name in rules_keys(live):
if name not in OPENSPEC_ARTIFACTS:
rep.error(
f"rules.{name} в openspec/config.yaml — такого артефакта у схемы "
f"{OPENSPEC_SCHEMA} нет ({', '.join(OPENSPEC_ARTIFACTS)}): правила "
f"под ним не применяются и молчат об этом"
)
check_openspec_fresh(rep)
def openspec_cli(args: list[str]) -> str | None:
"""Спросить сам инструмент. None — его нет или он не ответил."""
try:
out = subprocess.run(
["openspec", *args], capture_output=True, text=True, timeout=30
)
except (FileNotFoundError, OSError, subprocess.SubprocessError):
return None
return out.stdout.strip() if out.returncode == 0 else None
def check_openspec_fresh(rep: Report) -> None:
"""Не устарел ли наш слепок формы config.yaml.
Стоит один запуск `openspec --version` — десятые доли секунды. Перечень
артефактов и имя схемы отсюда не спрашиваются намеренно: они стоят втрое
дороже, а меняются только вместе с версией, и потому за ними ходит отдельная
команда `openspec-form`, а эта проверка говорит, когда её звать.
"""
got = openspec_cli(["--version"])
if got is None:
rep.skip(
"openspec не отвечает (нет на PATH?) — актуальность формы "
"config.yaml не проверялась"
)
return
installed = ".".join(got.split(".")[:2])
if installed != OPENSPEC_CHECKED:
rep.note(
f"форма openspec/config.yaml сверена с OpenSpec {OPENSPEC_CHECKED}, "
f"установлен {got}: перепроверить — `docs.py openspec-form`. Пока не "
f"перепроверено, проверки формы судят по прежней схеме"
)
def check_capabilities(root: Path, rep: Report) -> None:
specs = root / "openspec" / "specs"
text = doc_text(root, "architecture")
if not specs.is_dir():
rep.skip("openspec/specs/ нет — сверка capability с архитектурой неприменима")
return
if text is None:
rep.skip(
"темы architecture нет — capability не сверены с обзором "
"(об отсутствии сказано отдельной строкой)"
)
return
for d in sorted(specs.iterdir()):
if not d.is_dir():
continue
name = d.name
# Засчитываем только явное упоминание: ссылку на спеку или имя в обратных
# кавычках. Голая подстрока совпадает с именем пакета или CLI-команды и
# даёт ложное «упомянуто» — то есть проверку, проходящую не по той причине.
explicit = f"openspec/specs/{name}" in text or f"`{name}`" in text
loose = re.search(rf"\b{re.escape(name)}\b", text) is not None
if explicit:
continue
if loose:
rep.note(
f"capability {name}: в теме architecture есть слово «{name}», но "
f"нет ни ссылки на openspec/specs/{name}, ни имени в обратных "
f"кавычках — проверь, это про capability или про пакет"
)
else:
rep.error(
f"capability {name} есть в openspec/specs/, но не упомянута в "
f"теме architecture — обзор отстал от нормативных спек"
)
def changed_files(root: Path, base: str, rep: Report) -> list[str] | None:
"""Объединение закоммиченного, рабочего дерева и untracked.
Гейт гоняют ДО коммита, поэтому `base...HEAD` не видит ровно ту правку, ради
которой проверка и заводилась: миграция уже лежит в дереве, но ещё не в
истории. Пропущенная правка выглядела бы как зелёный шаг."""
cmds = [
["diff", "--name-only", base],
["ls-files", "--others", "--exclude-standard"],
]
seen: list[str] = []
for cmd in cmds:
try:
out = subprocess.run(
["git", "-C", str(root), *cmd],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
rep.skip(f"сверка миграций пропущена: git не отдал дифф ({exc})")
return None
seen.extend(line for line in out.stdout.splitlines() if line)
return sorted(set(seen))
def check_migrations(root: Path, cfg: dict, base: str | None, rep: Report) -> None:
migrations = cfg.get("migrations")
if not migrations:
rep.skip("в .pm.json нет ключа migrations — сверка со схемой неприменима")
return
if not base:
rep.skip("база диффа не названа (--base) — сверка миграций со схемой не гонялась")
return
changed = changed_files(root, base, rep)
if changed is None:
return
touched = [f for f in changed if f.startswith(migrations.rstrip("/") + "/")]
if not touched:
return
# Тема database бывает файлом и каталогом — правкой считается любой её файл.
if not any(
f == "docs/database.md" or f.startswith("docs/database/") for f in changed
):
rep.error(
f"миграции изменены ({len(touched)} файлов), а тема database — нет: "
f"схема в документации отстала"
)
def check_tasks(root: Path, rep: Report) -> None:
tasks = root / "docs" / "tasks"
if not tasks.is_dir():
rep.error("нет docs/tasks/ — каталог задач часть канона")
return
script = Path(__file__).resolve().parents[2] / "tasks" / "scripts" / "tasks.py"
if not script.exists():
rep.skip(f"tasks.py не найден по пути {script} — согласованность задач не проверена")
return
# cwd=root обязателен: tasks.py отвергает --dir вне текущего каталога, и без
# этого его отказ окружения (код 3) схлопнулся бы в наш дрейф (код 1).
proc = subprocess.run(
[sys.executable, str(script), "check", "--dir", "docs/tasks"],
capture_output=True,
text=True,
cwd=str(root),
)
if proc.returncode == 0:
return
if proc.returncode == 1:
rep.error("tasks.py check нашёл дрейф в docs/tasks/ — разбирать его командой tasks.py")
else:
# Чужой код выхода не выдаём за свой: 3 это окружение, а не дрейф.
rep.skip(
f"tasks.py check не отработал (код {proc.returncode}): "
f"{(proc.stderr or proc.stdout).strip().splitlines()[0] if (proc.stderr or proc.stdout).strip() else 'без сообщения'}"
)
# --- Отчёт ------------------------------------------------------------------
def report(rep: Report) -> int:
for msg in rep.errors:
print(f"ДРЕЙФ {msg}")
for msg in rep.notes:
print(f"ЗАМЕЧАНИЕ {msg}")
if rep.debts:
print(f"\nДОЛГ ({len(rep.debts)} маркеров, гейт от них не краснеет):")
for msg in rep.debts:
print(f" {msg}")
if rep.skipped:
print("\nНЕ ПРОВЕРЯЛОСЬ:")
for msg in rep.skipped:
print(f" {msg}")
print(
"\nМашина проверила раскладку, имена файлов, ссылки, версию, форму\n"
"openspec/config.yaml и две сверки с кодом. Согласованность документов\n"
"между собой и с кодом она не проверяет — как и то, ссылается ли\n"
"config.yaml на документы или пересказывает их. Это суждение агентов\n"
"`doc-consistency` (документ ↔ документ ↔ openspec) и `doc-code-drift`\n"
"(документ ↔ код)."
)
if rep.errors:
print(f"\nИтог: дрейф, {len(rep.errors)} пунктов.")
return DRIFT
print("\nИтог: канон соблюдён в механизируемой части.")
return OK
def cmd_check(args: argparse.Namespace) -> int:
root = Path(args.dir).resolve()
if not root.is_dir():
fail(ENV, f"каталог {root} не найден")
if not (root / "docs").exists() and not (root / "CLAUDE.md").exists():
fail(ENV, f"{root} не похож на корень проекта: нет ни docs/, ни CLAUDE.md")
rep = Report()
cfg = read_config(root, rep)
check_version(root, cfg, rep)
check_required(root, cfg, rep)
check_stray(root, rep)
check_slugs(root, rep)
check_links(root, rep)
check_placeholders_and_debt(root, rep)
check_openspec(root, rep)
check_capabilities(root, rep)
check_migrations(root, cfg, args.base, rep)
check_tasks(root, rep)
return report(rep)
def cmd_version(args: argparse.Namespace) -> int:
root = Path(args.dir).resolve()
cfg = read_config(root, Report())
got = cfg.get("canon", "не объявлена")
print(f"канон скрипта: {CANON_VERSION}")
print(f"канон проекта: {got}")
return OK
def cmd_openspec_form(args: argparse.Namespace) -> int:
"""Перепроверить слепок формы config.yaml по живому OpenSpec.
Ничего не правит и не трогает проект: спрашивает инструмент и печатает, что
разошлось с константами скрипта. Чинит человек — правкой констант, скелета в
skeletons.md и записью в журнал версий канона, если форма действительно
поменялась.
"""
version = openspec_cli(["--version"])
if version is None:
fail(
ENV,
"openspec не отвечает: поставь его или проверь PATH — "
"перепроверять форму нечем",
)
raw = openspec_cli(["templates", "--json"])
if raw is None:
fail(ENV, "`openspec templates --json` не отработал — схему не спросить")
try:
artifacts = tuple(json.loads(raw))
except json.JSONDecodeError as exc:
fail(ENV, f"`openspec templates --json` отдал неразбираемое: {exc}")
print(f"OpenSpec установлен: {version}")
print(f"форма сверена с: {OPENSPEC_CHECKED}")
print(f"артефакты схемы: {', '.join(artifacts)}")
print(f"записано в скрипте: {', '.join(OPENSPEC_ARTIFACTS)}")
diffs: list[str] = []
if ".".join(version.split(".")[:2]) != OPENSPEC_CHECKED:
diffs.append(
f"версия: поднять OPENSPEC_CHECKED до "
f"{'.'.join(version.split('.')[:2])} — но только после того, как "
f"остальные строки этого отчёта сойдутся"
)
for name in artifacts:
if name not in OPENSPEC_ARTIFACTS:
diffs.append(
f"новый артефакт {name}: решить, нужны ли ему правила в rules, "
f"и добавить имя в OPENSPEC_ARTIFACTS"
)
for name in OPENSPEC_ARTIFACTS:
if name not in artifacts:
diffs.append(
f"артефакта {name} у схемы больше нет: правила под ним в конфигах "
f"проектов молчат — убрать из OPENSPEC_ARTIFACTS, из скелета и "
f"записать в журнал версий канона"
)
print()
if not diffs:
print("Слепок сходится. Осталось глазами: не изменились ли придирки")
print("валидатора — их скрипт проверить не может, они проявляются только")
print("отказом `openspec validate --strict` на живой спеке.")
return OK
print("Разошлось:")
for line in diffs:
print(f" - {line}")
print()
print("Правится в трёх местах сразу: константы этого скрипта, скелет")
print("`openspec/config.yaml` в skeletons.md и запись в changelog.md —")
print("иначе проекты останутся на прежней форме молча.")
return DRIFT
def main() -> int:
parser = argparse.ArgumentParser(
prog="docs.py",
description="механическая проверка канона документов проекта",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_check = sub.add_parser("check", help="раскладка, ссылки, версия, сверки с кодом")
p_check.add_argument("--dir", default=".", help="корень проекта (по умолчанию текущий)")
p_check.add_argument("--base", default=None, help="база диффа для сверки миграций")
p_check.set_defaults(func=cmd_check)
p_ver = sub.add_parser("version", help="версия канона скрипта и проекта")
p_ver.add_argument("--dir", default=".", help="корень проекта")
p_ver.set_defaults(func=cmd_version)
p_form = sub.add_parser(
"openspec-form",
help="перепроверить форму config.yaml по живому OpenSpec",
)
p_form.set_defaults(func=cmd_openspec_form)
args = parser.parse_args()
try:
return args.func(args)
except SystemExit:
raise
except Exception as exc: # noqa: BLE001 — последний рубеж, код 4 по словарю
print(f"ВНУТРЕННИЙ СБОЙ: {exc}", file=sys.stderr)
return INTERNAL
if __name__ == "__main__":
sys.exit(main())