классификация задачи: три категории документов и метка вместо ступени
Канон 5 объявил «каждый документ docs/ — тема ревью». Правило верно ровно наполовину и потому вредно целиком. Паспорт и схему хранилища ревью читает, но темами они не являются: по ним нельзя сказать «в этом изменении сделано не так», они задают границу, по которой судит чужая тема. Журнал решений и журнал наблюдений ревью изменения не нужны вовсе — ADR объясняет прошлое, а не предъявляет требование. Разметчик, применявший правило буквально, обязан был либо завести фантомные темы passport, adr, database, research и продублировать ими работу architecture и operations, либо потерять четыре документа молча; случались обе ветки, и в собственном образце плана docs/passport.md не попадал ни строкой, а обязательная арифметика покрытия при этом не сходилась. Категорий теперь три, разрез проверяемый. Тема — да, прямо: conventions, security, architecture и любой свой документ проекта. Источник темы — нет, но он задаёт границу для чужой: passport, database, CLAUDE.md, openspec/specs. Процессный — нет, он про то, как мы работаем: tasks, review, adr, research, .pm.json. Открыта одна категория из трёх, две другие перечислены поимённо, так что документ вне раскладки — однозначно своя тема. adr и research прогон больше не открывает ни одним проходом; docs/review остаётся читаемым, но как настройка конвейера, а не критерий. Цена записана и стала обязательной строкой границ покрытия: расхождение с записанным решением ловит теперь только сверка документации, а число под находкой обязано быть снято на этом прогоне, с приложенной командой. Классификация выдаёт задаче метку — small, medium, large. Прежние quick, standard и wide назывались ступенью и описывали ревью: как глубоко смотрим. Классифицируется же задача, и пока величина называлась свойством прогона, её естественно было пересчитывать на каждом прогоне — что конвейер и делал. Слово «ступень» удалено, а не оставлено синонимом: два имени одной вещи расходятся. Выводится метка из двух разведённых осей — размер (малое, среднее, крупное) и сложность (знакомое, незнакомое), — и равна максимуму по ним. Метка не синоним размера: малое незнакомое изменение получает large, трогая один узел, поэтому план печатает три строки с обоснованием каждая и выводить одну из другой запрещено. Оси остались русскими словами — это суждение прозой; метка английская — это идентификатор, который проходы сравнивают. Разметка переехала из ревью кода в шаг 4 пайплайна, сразу после propose. Она шла первым проходом каждого ревью кода, а перед ревью дизайна ту же величину называл сам пайплайн — то есть оркестратор, который только что довёл предложение до propose. Одно и то же измерялось дважды, и один из двух раз без разведённости с автором, ровно в той точке, ради которой разметчик заведён. Теперь запуск один на задачу, диффа он не видит, план обслуживает обе стадии, и метка после кода не пересматривается: расхождение факта с разметкой ловит журнал дефектов постфактум, как и всякую другую ошибку выбора. На диск план не пишется — четвёртый артефакт рядом с proposal, tasks и design пережил бы задачу и разошёлся бы с ней молча. Ревью дизайна тоже растёт меткой: small — specs, medium — плюс rubric, large — плюс architecture и вопрос автору о трёх формах решения. Раньше rubric и architecture включались одним условием, и medium получал ровно один проход, то есть не отличался от quick ничем. Разведены они потому, что зарабатывают на разном: рубрика порождает свойства узла и окупается уже на среднем изменении, её выход уезжает приёмочными критериями в tasks.md; архитектура отвечает на вопрос про второй способ, а он на среднем знакомом изменении отвечается «нет» ещё до запуска. small подешевел тремя способами сразу. Составом: приёмник тем не запускается, три темы ядра переходят к code сверкой по записанным инвариантам CLAUDE.md с потолком в одну находку, и это не «глубина ниже», а другой дом темы. Входом: specs читает только дельта-спеку, code — только индекс конвенций. Потолком: он появился у каждого опиниативного прохода, а не у одного basics, и у половин code он раздельный, потому что конвенционных находок больше по построению и в общем списке они вытеснили бы техническую половину. Сработавший потолок обязан быть объявлен строкой — молчащий срез неотличим от «больше не нашлось». Отрицательный тест small от этого стал жёстче, а не мягче: вопросы про обратимость миграции задавал приёмник тем, и на этой метке их не задаст никто. Пайплайн задачи вырос до двенадцати шагов. Тривиальность перестала решать состав ревью — она влияет только на explore; глубину обеих стадий называет метка. Проверено прогоном ревьюверов по готовому результату: девять расхождений найдено и починено — контракт находок печатал старый перечень проходов вместо плана по темам, три ссылки в task-batch указывали на шаг коммита вместо закрытия, запись changelog не переводила вопросы, адресованные passport и database, ops и adversary утверждали, что на нижних метках их вопросы задаёт basics, шаблон покрытия в review-code зашивал потолки small намертво, триггеры метки рассыпались на два списка против трёх, тема из директивы CLAUDE.md могла остаться без запуска исполнителя. Гейт зелёный: фронтматтеры, копии, одиннадцать диаграмм, ruff, pyrefly; docs.py прогнан на живом фикстуре и печатает категорию в отказе. Канон повышен до версии 6 с записью, выполнимой upgrade. Решения — 40–44. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,55 +25,69 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
CANON_VERSION = 5
|
||||
CANON_VERSION = 6
|
||||
|
||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||
|
||||
# --- Раскладка канона -------------------------------------------------------
|
||||
|
||||
# Тема канона: имя → на какой вопрос отвечает (для внятного отказа).
|
||||
# Документ канона: имя → (категория, на какой вопрос отвечает).
|
||||
#
|
||||
# **Тема живёт файлом `docs/<имя>.md` либо каталогом `docs/<имя>/` с README.md
|
||||
# внутри.** Форму выбирает проект: тема разрослась — стала каталогом, и это не
|
||||
# смена канона и не повод править скрипт. Обе формы сразу — ошибка: это два дома
|
||||
# для одного факта, ровно то, от чего канон и защищает.
|
||||
THEMES = {
|
||||
"passport": "зачем и для кого, чем НЕ является",
|
||||
"architecture": "как сложено — обзор, окружение, эксплуатация",
|
||||
"security": "периметр, недоверенный вход, что вне модели",
|
||||
"conventions": "как мы пишем код; индекс, промоут, что механизировано",
|
||||
"research": "что показала реальность: наблюдения и числа с провенансом",
|
||||
"adr": "почему решено так; индекс, статусы, правило замены",
|
||||
"review": "настройка конвейера + журнал дефектов",
|
||||
# Категории — из canon.md, раздел «Три категории документов». Разрез один: можно
|
||||
# ли по документу сказать «в этом изменении сделано не так»?
|
||||
# тема — да, прямо: документ заводит направление проверки изменения;
|
||||
# источник — нет, но он задаёт границу, по которой судит чужая тема;
|
||||
# процессный — нет: он про то, как мы работаем, а не про изменение.
|
||||
#
|
||||
# **Категория не меняет обязательности документа** — заводятся все три
|
||||
# одинаково и с первого дня. Она меняет только то, что с документом делает
|
||||
# конвейер ревью, и потому печатается в отказе: «нет источника passport»
|
||||
# читается иначе, чем «нет темы security», и чинится теми же руками, но с
|
||||
# другим приоритетом.
|
||||
#
|
||||
# **Документ живёт файлом `docs/<имя>.md` либо каталогом `docs/<имя>/` с
|
||||
# README.md внутри.** Форму выбирает проект: документ разросся — стал каталогом,
|
||||
# и это не смена канона и не повод править скрипт. Обе формы сразу — ошибка: это
|
||||
# два дома для одного факта, ровно то, от чего канон и защищает.
|
||||
DOCS = {
|
||||
"passport": ("источник", "зачем и для кого, чем НЕ является"),
|
||||
"architecture": ("тема", "как сложено — обзор, окружение, эксплуатация"),
|
||||
"security": ("тема", "периметр, недоверенный вход, что вне модели"),
|
||||
"conventions": ("тема", "как мы пишем код; индекс, промоут, что механизировано"),
|
||||
"research": ("процессный", "что показала реальность: наблюдения и числа"),
|
||||
"adr": ("процессный", "почему решено так; индекс, статусы, правило замены"),
|
||||
"review": ("процессный", "настройка конвейера + журнал дефектов"),
|
||||
}
|
||||
|
||||
# Тема, обязательная только при условии: имя → (ключ .pm.json, пояснение).
|
||||
CONDITIONAL_THEMES = {
|
||||
"database": ("migrations", "схема хранилища и настройки"),
|
||||
# Документ, обязательный только при условии: имя → (ключ .pm.json, категория,
|
||||
# пояснение).
|
||||
CONDITIONAL_DOCS = {
|
||||
"database": ("migrations", "источник", "схема хранилища и настройки"),
|
||||
}
|
||||
|
||||
# Обязательные файлы вне тем.
|
||||
# Обязательные файлы вне раскладки docs/.
|
||||
REQUIRED = {
|
||||
"CLAUDE.md": "памятка агенту: инварианты с severity, команды, семантика гейта",
|
||||
"docs/.pm.json": "версия канона и пути, нужные проверкам",
|
||||
}
|
||||
|
||||
# Файлы, которые тема-каталог обязана держать сверх README.md.
|
||||
THEME_EXTRA = {
|
||||
# Файлы, которые документ-каталог обязан держать сверх README.md.
|
||||
DOC_EXTRA = {
|
||||
"adr": {"template.md": "шаблон записи ADR"},
|
||||
}
|
||||
|
||||
# Служебное в docs/ и каталог, который ведёт tasks.py.
|
||||
NOT_THEMES = {".pm.json", "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",
|
||||
"review-journal.md": "→ документ review",
|
||||
"plan.md": "→ docs/tasks/ROADMAP.md",
|
||||
"local-research.md": "→ тема research",
|
||||
"local-research.md": "→ документ research",
|
||||
"specs": "поведение → openspec/specs/, обзор → тема architecture",
|
||||
"drafts": "идея → запись research, отказ → ADR, порядок → ROADMAP.md",
|
||||
"backlog": "→ docs/tasks/",
|
||||
@@ -122,11 +136,11 @@ def check_slugs(root: Path, rep: Report) -> None:
|
||||
if not docs.is_dir():
|
||||
return
|
||||
# Имена, выбранные каноном, а не проектом: их форма задана здесь же.
|
||||
fixed = {"README.md", "template.md"} | {f"{name}.md" for name in THEMES}
|
||||
# Все темы-каталоги, включая свои темы проекта: правило имён общее, а
|
||||
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_THEMES:
|
||||
if not folder.is_dir() or folder.name in NOT_DOCS:
|
||||
continue
|
||||
sub = folder.name
|
||||
for path in sorted(folder.rglob("*.md")):
|
||||
@@ -267,8 +281,8 @@ def check_version(root: Path, cfg: dict, rep: Report) -> None:
|
||||
)
|
||||
|
||||
|
||||
def theme_home(root: Path, name: str) -> tuple[Path | None, str | None]:
|
||||
"""Дом темы: файл `docs/<имя>.md` или каталог `docs/<имя>/`.
|
||||
def doc_home(root: Path, name: str) -> tuple[Path | None, str | None]:
|
||||
"""Дом документа: файл `docs/<имя>.md` или каталог `docs/<имя>/`.
|
||||
|
||||
Возвращает путь и жалобу. Обе формы сразу — это два дома для одного факта, и
|
||||
расходятся они молча: правят одну, читают другую.
|
||||
@@ -278,7 +292,7 @@ def theme_home(root: Path, name: str) -> tuple[Path | None, str | None]:
|
||||
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"{name} живёт сразу двумя домами — docs/{name}.md и docs/{name}/:"
|
||||
f" оставить один, иначе правят один, а читают другой"
|
||||
)
|
||||
if as_file.is_file():
|
||||
@@ -286,8 +300,8 @@ def theme_home(root: Path, name: str) -> tuple[Path | None, str | None]:
|
||||
if as_dir.is_dir():
|
||||
if not (as_dir / "README.md").is_file():
|
||||
return as_dir, (
|
||||
f"docs/{name}/ без README.md — у темы-каталога вход обязателен:"
|
||||
f" по нему её читают агенты"
|
||||
f"docs/{name}/ без README.md — у документа-каталога вход"
|
||||
f" обязателен: по нему его читают агенты"
|
||||
)
|
||||
return as_dir, None
|
||||
return None, None
|
||||
@@ -298,54 +312,60 @@ def check_required(root: Path, cfg: dict, rep: Report) -> None:
|
||||
if not (root / rel).exists():
|
||||
rep.error(f"нет {rel} — {what}")
|
||||
|
||||
for name, what in THEMES.items():
|
||||
home, complaint = theme_home(root, name)
|
||||
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}/) — {what}")
|
||||
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 THEME_EXTRA.get(name, {}).items():
|
||||
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, what) in CONDITIONAL_THEMES.items():
|
||||
home, complaint = theme_home(root, name)
|
||||
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}/) — {what}"
|
||||
f" (обязательна: в .pm.json объявлен {key})"
|
||||
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}, проверка неприменима")
|
||||
rep.skip(f"{name} — в .pm.json нет ключа {key}, проверка неприменима")
|
||||
|
||||
|
||||
def check_stray(root: Path, rep: Report) -> None:
|
||||
"""Лишнего в docs/ больше нет — есть темы проекта.
|
||||
"""Лишнего в docs/ больше нет — есть свои темы проекта.
|
||||
|
||||
Список тем **открытый**: каждый документ в docs/ и есть заявка на тему
|
||||
ревью, и запретить проекту завести свою нельзя. Проверяются только слоты,
|
||||
у которых дом в другом месте, — иначе переехавшее содержимое вернулось бы
|
||||
темой и выглядело законным.
|
||||
Категории `источник` и `процессный` **закрыты**: они перечислены в каноне
|
||||
поимённо и проектом не пополняются. Открыта только категория `тема` —
|
||||
поэтому любой документ в docs/, которого нет в раскладке, и есть заявка на
|
||||
свою тему, и запретить её нельзя. Проверяются только слоты, у которых дом в
|
||||
другом месте, — иначе переехавшее содержимое вернулось бы темой и выглядело
|
||||
законным.
|
||||
"""
|
||||
docs = root / "docs"
|
||||
if not docs.is_dir():
|
||||
rep.error("нет каталога docs/")
|
||||
return
|
||||
known = set(THEMES) | set(CONDITIONAL_THEMES)
|
||||
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_THEMES:
|
||||
if name in NOT_DOCS:
|
||||
continue
|
||||
theme = name[:-3] if entry.is_file() and name.endswith(".md") else name
|
||||
if theme in known:
|
||||
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: тема ревью читается как текст")
|
||||
@@ -356,7 +376,7 @@ def check_stray(root: Path, rep: Report) -> None:
|
||||
f" по нему её читают агенты"
|
||||
)
|
||||
continue
|
||||
own.append(theme)
|
||||
own.append(topic)
|
||||
if own:
|
||||
rep.note(
|
||||
f"свои темы проекта: {', '.join(own)} — именной оптики у них нет,"
|
||||
@@ -418,13 +438,13 @@ def check_placeholders_and_debt(root: Path, rep: Report) -> None:
|
||||
rep.debt(f"{rel}: {what}")
|
||||
|
||||
|
||||
def theme_text(root: Path, name: str) -> str | None:
|
||||
"""Текст темы целиком: файл или все markdown каталога, склеенные.
|
||||
def doc_text(root: Path, name: str) -> str | None:
|
||||
"""Текст документа целиком: файл или все markdown каталога, склеенные.
|
||||
|
||||
Проверке всё равно, одним файлом написана тема или десятью: она ищет
|
||||
Проверке всё равно, одним файлом написан документ или десятью: она ищет
|
||||
упоминание, а упоминание живёт в любом из них.
|
||||
"""
|
||||
home, _ = theme_home(root, name)
|
||||
home, _ = doc_home(root, name)
|
||||
if home is None:
|
||||
return None
|
||||
if home.is_file():
|
||||
@@ -437,7 +457,7 @@ def theme_text(root: Path, name: str) -> str | None:
|
||||
|
||||
def check_capabilities(root: Path, rep: Report) -> None:
|
||||
specs = root / "openspec" / "specs"
|
||||
text = theme_text(root, "architecture")
|
||||
text = doc_text(root, "architecture")
|
||||
if not specs.is_dir():
|
||||
rep.skip("openspec/specs/ нет — сверка capability с архитектурой неприменима")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user