Files
dev-skills/scripts/frontmatter.py
T
av df5af47dc3 хвосты после снятия спринтов: четыре штуки
resolve судил готовность записи глазами — текст писался до того, как
появилась команда ready. Теперь зовёт её через av-dev-tasks:tasks, а
отказ читает исходом «не доведена». Это ровно тот случай, где машина
дешевле и точнее, а цена ошибки отложенная.

canon.md всё ещё говорил, что невзятой запись делает sprint take.

REMAINING перечислял три опоры против совпадения приёмщика с
исполнителем, включая SPRINT.md под git. Опор осталось две, и третья не
заменилась, а исчезла: у приёмки больше нет момента. Записано как есть.

frontmatter.py: устаревший путь review-pipeline/SKILL.md в комментарии.

В план добавлена дыра, которую открыло снятие сессии: двух агентов
канона на живом проекте больше не зовёт никто, кроме adopt и upgrade.
2026-08-09 16:53:20 +03:00

178 lines
8.8 KiB
Python
Raw 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-code/skills/review/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
# Дом раскладки — «Модель по проходу» в av-dev-code/skills/review/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)