#!/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)