diff --git a/DECISIONS.md b/DECISIONS.md index 8d40bed..27e4292 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -3083,3 +3083,47 @@ JJJ): у профиля обязан быть один правильный от вторую.** Файл на месте, валиден, с правильным именем — и пуст по существу: это худший вид пробела, потому что выглядит он как его отсутствие. + +## 48. Форма чужого инструмента держится опросом инструмента, а не памятью (2026-08-07) + +**АЕАИГ. Схема и артефакты OpenSpec записаны в скрипте как слепок, и у слепка +есть сторож.** Проверка формы `config.yaml` знает имя схемы и перечень артефактов +(`proposal`, `specs`, `design`, `tasks`). Это не наше решение, а состояние чужого +инструмента: OpenSpec переименует артефакт — правила под прежним именем перестанут +применяться, конфиг останется выглядеть написанным, а канон продолжит требовать +прежнее. Все три стороны при этом молчат. + +Сторожем сделано сравнение версий: `check` спрашивает `openspec --version` +(десятые доли секунды) и сравнивает `major.minor` с той, на которой форма +сверялась. Разошлось — **замечание**, не отказ, с именем команды, которая +перепроверяет. Патч-версия в сравнение не берётся намеренно: формы она не +меняет, а нагоняй на каждый багфикс приучает пролистывать весь блок. + +**АЕАИД. Перепроверка спрашивает инструмент, а не нас.** `docs.py openspec-form` +берёт `openspec templates --json` — перечень артефактов текущей схемы — и +печатает, что разошлось с константами. Дорогой вызов вынесен из `check` +сознательно: он стоит втрое дороже опроса версии, а ответ меняется только вместе +с версией. Дешёвая проверка служит **воротами** дорогой, и дорогая не ржавеет, +потому что зовут её не по памяти. + +**Чинится расхождение в плагине, а не в проекте, и это сказано в трёх местах.** +Проект в такой ситуации не виноват и починить ничего не может: у него нет ни +констант скрипта, ни скелета, ни журнала версий канона. Замечание поэтому +адресовано владельцу плагина, а команда печатает три адреса правки списком. + +**Заодно поймано ложное срабатывание на живом конфиге.** Первый вариант искал +ключи `rules` отступом по всему файлу и нашёл их внутри литерального блока +`context: |`: строки «Language: Russian» и «av-dev-pm:review-pipeline» выглядят +ключами. Проверка теперь идёт от строки `rules:` до следующего ключа нулевой +колонки. Правило, краснеющее на правде, хуже отсутствующего — его перестают +читать целиком. + +### Что из этого следует + +169. **Знание о чужом инструменте, записанное у себя, — это слепок с датой.** + Он законен, пока рядом стоит тот, кто заметит, что дата протухла; без + сторожа он превращается в уверенное враньё. +170. **Дешёвая проверка как ворота дорогой.** Опрос версии стоит копейки и + точно говорит, могла ли измениться дорогая величина. Так дорогая проверка + остаётся редкой и при этом не забытой. + diff --git a/av-dev-pm/skills/canon/SKILL.md b/av-dev-pm/skills/canon/SKILL.md index 9162830..61f8c0d 100644 --- a/av-dev-pm/skills/canon/SKILL.md +++ b/av-dev-pm/skills/canon/SKILL.md @@ -47,8 +47,19 @@ ds="$CLAUDE_PLUGIN_ROOT/skills/canon/scripts/docs.py" python3 $ds check --dir <корень> [--base ] # раскладка, ссылки, версия, сверки 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 внутренний сбой. Ветвись на коде, а не на тексте. diff --git a/av-dev-pm/skills/canon/references/canon.md b/av-dev-pm/skills/canon/references/canon.md index 3ee6dc6..c221699 100644 --- a/av-dev-pm/skills/canon/references/canon.md +++ b/av-dev-pm/skills/canon/references/canon.md @@ -429,7 +429,7 @@ kebab-case.** Причина не эстетическая: имя файла с знания, где лежит граница домена. Это ровно тот класс, против которого написан весь канон, и потому здесь он проверяется машиной, а не чтением. -Проверяется четыре вещи, и каждая — про молчащий пробел, а не про вкус: +Проверяется пять вещей, и каждая — про молчащий пробел, а не про вкус: 1. **`openspec/` есть.** Нет — нет и дома темы `requirements`. 2. **Имя файла `config.yaml`.** `config.yml` OpenSpec не читает и об этом не @@ -439,8 +439,22 @@ kebab-case.** Причина не эстетическая: имя файла с 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`, и раздел «Что проверяет машина, а что человек» называет её строкой. @@ -513,7 +527,8 @@ kebab-case.** Причина не эстетическая: имя файла с | маркеры долга — числом | **протухший факт, разошедшийся с кодом** | `doc-code-drift` | | миграция изменена, а `database.md` нет | зависимость в манифесте, не названная в обзоре | `doc-code-drift` | | capability без упоминания в `architecture.md` | второй способ там, где обзор обещал единственный | `doc-code-drift` | -| `openspec/config.yaml`: имя, `schema`, незаменённый пример, адреса паспорта и `CLAUDE.md` | **пересказ документа канона в `context` вместо ссылки** | `doc-consistency` | +| `openspec/config.yaml`: имя, `schema`, незаменённый пример, адреса паспорта и `CLAUDE.md`, ключи `rules` против артефактов схемы | **пересказ документа канона в `context` вместо ссылки** | `doc-consistency` | +| версия OpenSpec разошлась с той, на которой сверена форма `config.yaml` | придирки валидатора: сменились ли они | никакой — проявляются отказом `openspec validate --strict` | | | связность и читаемость | `doc-wording` | **Агентов двое, и разведены они по глубине, а не по охвату.** `doc-consistency` diff --git a/av-dev-pm/skills/canon/references/changelog.md b/av-dev-pm/skills/canon/references/changelog.md index 64c5d84..1001379 100644 --- a/av-dev-pm/skills/canon/references/changelog.md +++ b/av-dev-pm/skills/canon/references/changelog.md @@ -38,11 +38,17 @@ capability по имени пакета и без единого `SHALL`. артефакта**: язык, правила именования capability, придирки валидатора и **адреса** документов канона. Пересказ паспорта, инвариантов, конвенций и правил ревью в него не переносится. -3. **`docs.py check` проверяет четыре вещи:** каталог `openspec/` есть; файл +3. **`docs.py check` проверяет пять вещей:** каталог `openspec/` есть; файл называется `config.yaml` (`config.yml` OpenSpec читать не станет и об этом не сообщит); `context` и `rules.specs` не остались примером, а правила для - `specs` называют `SHALL`; `context` называет `passport` и `CLAUDE.md`. -4. **Пятое проверяет агент.** Отличить ссылку на документ от пересказа документа + `specs` называют `SHALL`; `context` называет `passport` и `CLAUDE.md`; ключи + под `rules:` — имена артефактов схемы, а не опечатки. +4. **За свежестью формы следит машина, а не память.** Схема и перечень + артефактов — слепок чужого инструмента; `check` сравнивает `major.minor` + установленного OpenSpec с версией, на которой форма сверялась, и при + расхождении даёт замечание. Перепроверяет `docs.py openspec-form`, и чинится + расхождение **в плагине, а не в проекте**. +5. **Шестое проверяет агент.** Отличить ссылку на документ от пересказа документа машина не умеет — это работа `doc-consistency`, и в таблице «Что проверяет машина, а что человек» она стоит строкой. diff --git a/av-dev-pm/skills/canon/references/skeletons.md b/av-dev-pm/skills/canon/references/skeletons.md index d0f7cd1..601c832 100644 --- a/av-dev-pm/skills/canon/references/skeletons.md +++ b/av-dev-pm/skills/canon/references/skeletons.md @@ -490,6 +490,13 @@ rules: дополняет своим (стек, разведка, особенности домена), но **адреса паспорта и `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 diff --git a/av-dev-pm/skills/canon/scripts/docs.py b/av-dev-pm/skills/canon/scripts/docs.py index 7bdac7c..337df0e 100644 --- a/av-dev-pm/skills/canon/scripts/docs.py +++ b/av-dev-pm/skills/canon/scripts/docs.py @@ -89,6 +89,27 @@ OPENSPEC_POINTERS = [ ("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"} @@ -468,6 +489,31 @@ def doc_text(root: Path, name: str) -> str | None: ) +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 заведена и не осталась примером из коробки. @@ -507,11 +553,13 @@ def check_openspec(root: Path, rep: Report) -> None: schema = re.search(r"(?m)^schema:\s*(\S+)", live) if schema is None: - rep.error("в openspec/config.yaml нет ключа schema — ожидается spec-driven") - elif schema.group(1) != "spec-driven": + 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"для spec-driven" + f"для {OPENSPEC_SCHEMA}" ) if "context" not in keys: @@ -539,6 +587,54 @@ def check_openspec(root: Path, rep: Report) -> None: "проекта об этом молчит" ) + # Ключ под 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" @@ -718,6 +814,71 @@ def cmd_version(args: argparse.Namespace) -> int: 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", @@ -734,6 +895,12 @@ def main() -> int: 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)