ревью: ступень wide, цвета по модели, проверка фронтматтеров

Прыжок standard → deep стоил самого дорогого прохода конвейера, а платить
приходилось за одну архитектурную находку: изменений, которые трогают
публичный контракт, но не вводят нового правила слияния, — большинство.
Ступень wide это standard плюс architecture (вход шире диффа, отсюда имя),
семь проходов против восьми.

Заодно вычистилась давняя неровность: триггер reimpl стоял внутри deep, и
профиль означал то семь проходов, то восемь — реестр состава, который
«сверяется взглядом до коммита», проверять было нечем. Теперь условие
«новое правило идентичности, слияния или разбора» выбирает профиль, reimpl
в deep безусловен и есть единственное отличие от wide. Барьер стоимости
остался только в deep: в wide за ним стоял бы один дешёвый проход с
потолком в 3 находки, а барьер сериализует то, что могло идти разом.

Цвет charter'а теперь кодирует модель, а не роль: sonnet → green,
opus → yellow, fable → red. Роль видна из имени, стоимость прогона —
ниоткуда, а список агентов читается взглядом.

scripts/frontmatter.py ловит три класса ошибок, невидимых при чтении:
- двоеточие с пробелом в незакавыченном описании — для YAML это вложенное
  отображение, а не текст. Так было написано три описания из четырнадцати,
  и читались они правильно;
- name, разошедшееся с именем каталога скилла или файла charter'а;
- цвет, не отвечающий модели: он ставится один раз при заведении charter'а,
  а модель потом двигает калибровка.

Обе ветки проверены, коды выхода — общий словарь. Триггеры профиля в
canon.md и skeletons.md подтянуты под wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-04 16:49:02 +03:00
co-authored by Claude Opus 5
parent 3103526de2
commit 84134cac1e
19 changed files with 320 additions and 74 deletions
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Проверка фронтматтеров скиллов и charter'ов этого репозитория.
Фронтматтер — единственная часть скилла, которую читает не человек, а загрузчик:
по `name` он разрешает вызов, по `description` решает, звать ли скилл вообще.
Ошибка здесь не выглядит ошибкой. Текст остаётся читаемым, `git diff` показывает
разумную строку, а скилл либо не находится по имени, либо загружается с
обрезанным описанием и потому не срабатывает на своих же триггерах.
Ловится три класса.
**Двоеточие с пробелом в описании без кавычек.** В YAML `: ` внутри простого
скаляра начинает вложенное отображение — строка «конвейер ревью: гейт, сверка…»
это не текст с двоеточием, а синтаксическая ошибка. Так были написаны три
описания из четырнадцати; заметить это чтением нельзя, потому что читается оно
правильно.
**Имя, разошедшееся с каталогом.** Скилл зовётся по имени каталога, а `name`
внутри — то, чем он представляется. Разъехались — вызов не разрешается, и
сообщение об этом говорит «нет такого скилла», а не «имя не то».
**Цвет, не отвечающий модели.** Цвет charter'а кодирует **модель**, на которой
идёт проход, а не его роль: раскладка — в
`av-dev-pipeline/skills/review-pipeline/SKILL.md`, раздел «Модель по проходу».
Правило существует ровно затем, чтобы стоимость прогона читалась взглядом по
списку агентов, и держаться вниманием оно не может: цвет ставится один раз при
заведении charter'а, а модель потом меняется калибровкой.
Коды выхода — тот же словарь, что у tasks.py, docs.py, copies.py и diagrams.py:
0 все фронтматтеры в порядке
1 расхождение
2 ошибка употребления: аргументы
3 окружение: не тот каталог
4 внутренний сбой
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# Дом раскладки — «Модель по проходу» в review-pipeline/SKILL.md; здесь её
# механизация. Порядок цветов — порядок стоимости прогона.
PALETTE = {"sonnet": "green", "opus": "yellow", "fable": "red"}
SKILL_KEYS = {"name", "description"}
AGENT_KEYS = {"name", "description", "tools", "model", "color"}
class Sheet:
"""Разобранный фронтматтер одного файла."""
def __init__(self, path: Path, root: Path) -> None:
self.path = path
self.where = path.relative_to(root).as_posix()
self.fields: dict[str, str] = {}
self.problems: list[str] = []
# Разбор дошёл до полей. Ложь — фронтматтера нет вовсе, и спрашивать с
# него имя, набор полей и цвет бессмысленно: ответ будет один и тот же.
self.parsed = False
self._parse()
def _parse(self) -> None:
lines = self.path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
self.problems.append("нет фронтматтера: первая строка не `---`")
return
try:
end = lines.index("---", 1)
except ValueError:
self.problems.append("фронтматтер не закрыт строкой `---`")
return
self.parsed = True
for number, line in enumerate(lines[1:end], start=2):
if not line.strip():
continue
key, sep, value = line.partition(":")
if not sep or not key or key != key.strip():
self.problems.append(f"строка {number}: не `ключ: значение`")
continue
value = value.strip()
self.fields[key] = value
if value[:1] in ('"', "'"):
continue
if ": " in value:
self.problems.append(
f"строка {number}: у `{key}` двоеточие с пробелом в значении"
f" без кавычек — для YAML это вложенное отображение,"
f" а не текст. Обернуть значение в двойные кавычки"
)
def check(self, expected_name: str, required: set[str]) -> None:
missing = sorted(required - self.fields.keys())
if missing:
self.problems.append(f"нет обязательных полей: {', '.join(missing)}")
name = self.fields.get("name", "").strip("\"'")
if name and name != expected_name:
self.problems.append(
f"`name: {name}` разошлось с ожидаемым `{expected_name}`"
f" — вызов разрешается по второму"
)
model = self.fields.get("model", "").strip("\"'")
color = self.fields.get("color", "").strip("\"'")
if model and color:
if model not in PALETTE:
self.problems.append(
f"модель `{model}` не в раскладке цветов"
f" ({', '.join(sorted(PALETTE))}) — назначить ей цвет"
f" в «Модель по проходу» и здесь"
)
elif color != PALETTE[model]:
self.problems.append(
f"цвет `{color}` не отвечает модели `{model}`:"
f" по раскладке — `{PALETTE[model]}`"
)
def collect(root: Path) -> list[tuple[Sheet, str, set[str]]]:
"""Все фронтматтеры репозитория: лист, ожидаемое имя, обязательные поля."""
found: list[tuple[Sheet, str, set[str]]] = []
for plugin in sorted(root.glob("av-*/")):
for skill in sorted(plugin.glob("skills/*/SKILL.md")):
found.append((Sheet(skill, root), skill.parent.name, SKILL_KEYS))
for agent in sorted(plugin.glob("agents/*.md")):
found.append((Sheet(agent, root), agent.stem, AGENT_KEYS))
return found
def main() -> int:
ap = argparse.ArgumentParser(description="Проверка фронтматтеров.")
ap.add_argument("--dir", default=".", help="корень репозитория")
args = ap.parse_args()
root = Path(args.dir).resolve()
if not (root / ".claude-plugin").is_dir():
print(f"окружение: {root} не похож на корень репозитория"
f" (нет .claude-plugin)", file=sys.stderr)
return ENV
sheets = collect(root)
if not sheets:
print("окружение: не нашлось ни одного SKILL.md или charter'а",
file=sys.stderr)
return ENV
for sheet, expected, required in sheets:
if sheet.parsed:
sheet.check(expected, required)
skills = sum(1 for _, _, required in sheets if required is SKILL_KEYS)
print(f"фронтматтеров {len(sheets)}: скиллов {skills},"
f" charter'ов {len(sheets) - skills}")
broken = [sheet for sheet, _, _ in sheets if sheet.problems]
if broken:
print()
for sheet in broken:
for problem in sheet.problems:
print(f"ОШИБКА {sheet.where}\n {problem}")
print(f"\nИтог: с ошибками {len(broken)} из {len(sheets)}.")
return DRIFT
print("все в порядке")
return OK
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(INTERNAL)
except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю
print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr)
sys.exit(INTERNAL)