Files
FabledGrimoire/tests/test_skill_structure.py
T
bvandeusenandClaude Opus 5.5 c5eeec471f
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
rename(547): the plugin is grimoire — manifest, MCP key, hooks, env, skills
Step 3 of the Scribe -> Grimoire rename.

- Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key
  `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*.
- hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR
  ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR /
  SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`.
- skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes
  arrive as grimoire-proc-<slug>; moment_actions names them the same way.
- Migration 0124 rewrites stored skill names (rule_moments,
  rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to
  grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing.
- Settings' install commands and MCP-only defaults, the docs and the
  tests follow.
- scripts/check_plugin.py drops the smoke event for a report-check hook
  that has no file in plugin/hooks.

Kept on purpose: the secret-sealing label, and the names that let backups
written as Scribe restore and be counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:52:55 -04:00

109 lines
4.2 KiB
Python

"""Every bundled skill keeps the shape Agent Skills can load well.
WHY THIS EXISTS
Anthropic's skill authoring guidance
(platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices):
keep the SKILL.md body under 500 lines, split into reference files as it
nears that, and link each reference file ONE level deep from SKILL.md —
Claude may only preview a file reached through another reference file.
using-grimoire grows because every new practice lands there. What matters on
most turns stays in SKILL.md; what matters at one moment (placing a rule, a
missed retrieval, starting a project) lives in files SKILL.md names with the
moment to open them. These tests keep that shape from quietly undoing
itself.
WHAT IT PINS
1. A BUDGET BELOW THE GUIDELINE. 500 is where loading degrades; the budget
sits under it so the next addition is a choice about what moves out, not
a squeeze past the line.
2. EVERY REFERENCE FILE IS LINKED FROM SKILL.md. A file nothing links is
never read — it states guidance that reaches nobody.
3. NO REFERENCE FILE LINKS ANOTHER. One level deep, per the guidance.
4. A LONG REFERENCE FILE OPENS WITH ITS CONTENTS, so a partial read still
shows what the file covers.
"""
from __future__ import annotations
import pathlib
import re
SKILLS = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills"
SKILL_LINE_BUDGET = 350
TOC_AFTER_LINES = 100
_MD_LINK = re.compile(r"\]\(([^)#\s]+\.md)\)")
def _skill_dirs() -> list[pathlib.Path]:
return sorted(p.parent for p in SKILLS.glob("*/SKILL.md"))
def _refs(folder: pathlib.Path) -> list[pathlib.Path]:
return sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md")
def over_budget(texts: dict[str, str], budget: int) -> list[str]:
return [f"{k}: {len(t.splitlines())} lines" for k, t in texts.items()
if len(t.splitlines()) > budget]
def unlinked(skill_md: str, ref_names: list[str]) -> list[str]:
linked = set(_MD_LINK.findall(skill_md))
return [n for n in ref_names if n not in linked]
def nested(refs: dict[str, str]) -> list[str]:
return [f"{name} → {target}" for name, text in refs.items()
for target in _MD_LINK.findall(text)]
def missing_contents(refs: dict[str, str], after: int) -> list[str]:
return [name for name, text in refs.items()
if len(text.splitlines()) > after and "## Contents" not in text]
def test_every_skill_md_fits_its_budget():
found = over_budget({d.name: (d / "SKILL.md").read_text() for d in _skill_dirs()},
SKILL_LINE_BUDGET)
assert not found, (
f"SKILL.md over {SKILL_LINE_BUDGET} lines: {found}. Move what matters at "
f"one moment into a reference file SKILL.md links with that moment, "
f"rather than raising the budget toward 500."
)
def test_every_reference_file_is_linked_from_skill_md():
found = [f"{d.name}/{n}" for d in _skill_dirs()
for n in unlinked((d / "SKILL.md").read_text(), [p.name for p in _refs(d)])]
assert not found, (
f"reference files no SKILL.md links: {found}. Nothing reads a file "
f"nothing names — link it from SKILL.md with the moment to open it."
)
def test_reference_files_are_one_level_deep():
found = [f"{d.name}/{x}" for d in _skill_dirs()
for x in nested({p.name: p.read_text() for p in _refs(d)})]
assert not found, (
f"reference files linking other files: {found}. Link each from SKILL.md "
f"directly; a file reached through another may only be previewed."
)
def test_long_reference_files_open_with_contents():
found = [f"{d.name}/{n}" for d in _skill_dirs()
for n in missing_contents({p.name: p.read_text() for p in _refs(d)},
TOC_AFTER_LINES)]
assert not found, f"reference files over {TOC_AFTER_LINES} lines with no '## Contents': {found}"
def test_the_guards_can_fail():
"""Each guard bites on the failure it exists for."""
assert over_budget({"s": "x\n" * 400}, SKILL_LINE_BUDGET) == ["s: 400 lines"]
assert unlinked("see [a](a.md)", ["a.md", "b.md"]) == ["b.md"]
assert nested({"a.md": "then [b](b.md)"}) == ["a.md → b.md"]
assert missing_contents({"a.md": "x\n" * 150}, TOC_AFTER_LINES) == ["a.md"]