Files
avandClaude Opus 5 84134cac1e ревью: ступень 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>
2026-08-04 16:49:02 +03:00

178 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)