Прыжок 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>
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", "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)
|