Прогоны стали долгими, а счёт в токенах заметным. Разбор шёл не по находкам, а по статьям расхода. Две названы прямо: убрать reimpl и убрать fable. reimpl писал свою реализацию узла, не открывая существующую, и диффил по решениям. Его счёт определялся объёмом вывода — он один писал код, а не читал его, — и на прогоне это была самая большая строка. Снят по цене. Профиль deep от этого не похудел, а исчез: reimpl был единственным, чем он отличался от wide, и без него у двух имён оказался бы один состав. Ровно от этой болезни лечилась ступень wide решением JJJ — у профиля обязан быть один правильный ответ, иначе реестр состава нечем проверять. Ступеней три: quick, standard, wide. Вместе с профилем снято всё, что обслуживало только его. Барьер стоимости — он держал дорогой проход, чтобы тот не писал реализацию против кода, который через час перепишут; дорогого прохода нет, граф стал плоским во всех профилях, рёбер осталось два вида вместо трёх. Тест «идентичность, слияние, разбор» — полторы страницы, служившие единственной цели: выбрать deep не по ощущению; вместе с ним ушёл проектный перечень мест в docs/review.md и его скелет в каноне. Стадии перенумерованы: 0 гейт, 1 сверка, 2 враждебный и эксплуатационный, 3 архитектурный, 4 триаж — дыра на месте третьей читалась бы как пропущенная стадия. Снятие записано как сознательное сужение, а не как «класс оказался пустым». calibration.md требует замера на двух проектах перед удалением прохода; замера не было, было решение о цене. Поэтому в «Честном пределе» стоит строка: «не знаю, чего не знаю» больше не достаёт никто. Остаток независимого взгляда дают профиль design и architecture, но альтернативной реализации, с которой можно сдиффить решения, у конвейера нет. Класс уходит в границы покрытия каждого прогона, у проекта — в подраздел «перестали проверять сознательно». Без этой записи снятие через месяц читается как «проверено и признано лишним». fable снят с троих: review-triage, review-architecture, doc-code-drift — все на opus. Основание верхней модели «ошибка распространяется дальше самой находки» осталось, но оно объясняет, почему двое не опускаются до sonnet, а не почему им нужна ступень выше opus: разницы в пользу более дорогой модели не показал ни один прогон, а время и счёт она множила. Палитра схлопнулась до двух цветов, красного в репозитории больше нет, frontmatter.py теперь отвергнет модель вне sonnet и opus. Версия канона не поднята сознательно. Проектам всё равно надо снести перечень мест для deep из docs/review.md, поэтому пункт вписан в «Что сделать проекту» записи «Версия 4» — её ещё не гонял ни один проект, оба ждут в TODO. Тема 33 в DECISIONS.md, следствия 127-129. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
8.8 KiB
Python
178 lines
8.8 KiB
Python
#!/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"}
|
||
|
||
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)
|