CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
85 lines
3.4 KiB
Python
85 lines
3.4 KiB
Python
"""The bundled skills declare the moments they are for.
|
|
|
|
Loading the reporting procedure is a report; loading the planning procedure
|
|
is planning. Each SKILL.md says which moment it is for in its frontmatter
|
|
(`metadata: moments:`), where an author editing the skill sees it — but the
|
|
server that resolves a skill load to its moments never sees the plugin's
|
|
files, so the same declaration ships as defaults in
|
|
`moment_actions.BUNDLED_SKILL_MOMENTS`. Two copies drift unless something
|
|
holds them together; this does, in both directions, and every skill has to
|
|
make the choice rather than inherit silence.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from grimoire.services import moment_actions as ma
|
|
from grimoire.services import moments
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SKILLS = ROOT / "plugin" / "skills"
|
|
MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
|
|
|
|
_FRONT = re.compile(r"\A---\n(.*?)\n---\n", re.S)
|
|
_METADATA = re.compile(r"^metadata:[ \t]*\n((?:[ \t]+\S.*(?:\n|\Z))*)", re.M)
|
|
_MOMENTS = re.compile(r"^[ \t]+moments:[ \t]*(.*)$", re.M)
|
|
|
|
|
|
def declared(text: str) -> tuple[str, ...]:
|
|
"""The moments a SKILL.md's frontmatter declares; () when it declares none.
|
|
|
|
Agent Skills metadata maps strings to strings, so several moments are one
|
|
comma-separated value.
|
|
"""
|
|
front = _FRONT.match(text)
|
|
meta = _METADATA.search(front.group(1)) if front else None
|
|
line = _MOMENTS.search(meta.group(1)) if meta else None
|
|
if not line:
|
|
return ()
|
|
return tuple(m.strip() for m in line.group(1).split(",") if m.strip())
|
|
|
|
|
|
def _on_disk() -> dict[str, tuple[str, ...]]:
|
|
return {p.parent.name: declared(p.read_text()) for p in sorted(SKILLS.glob("*/SKILL.md"))}
|
|
|
|
|
|
def test_the_reader_finds_a_declaration_and_only_in_the_frontmatter():
|
|
"""The guard below is only as good as this reader, so it must be able to
|
|
say no: a declaration in the body, or none at all, reads as nothing."""
|
|
assert declared(
|
|
"---\nname: x\ndescription: y\nmetadata:\n moments: work.plan, reply.report\n---\nbody\n"
|
|
) == ("work.plan", "reply.report")
|
|
assert declared("---\nname: x\ndescription: y\n---\nmetadata:\n moments: work.plan\n") == ()
|
|
assert declared("---\nname: x\nmetadata:\n author: someone\n---\n") == ()
|
|
assert declared("no frontmatter\n") == ()
|
|
|
|
|
|
def test_every_bundled_skill_declares_the_moments_it_is_for():
|
|
silent = [name for name, found in _on_disk().items() if not found]
|
|
assert not silent, (
|
|
f"{silent} declare no moment. Add `metadata:` / ` moments: <moment>` to "
|
|
f"the frontmatter (list_moments has the catalog) and the same entry to "
|
|
f"moment_actions.BUNDLED_SKILL_MOMENTS"
|
|
)
|
|
|
|
|
|
def test_the_frontmatter_and_the_shipped_defaults_agree():
|
|
assert _on_disk() == ma.BUNDLED_SKILL_MOMENTS, (
|
|
"a SKILL.md's `metadata: moments:` and moment_actions.BUNDLED_SKILL_MOMENTS "
|
|
"disagree — the server reads only the latter, so change both together"
|
|
)
|
|
|
|
|
|
def test_every_declared_moment_is_in_the_catalog():
|
|
bad = {name: found for name, found in _on_disk().items()
|
|
if any(m not in moments.MOMENTS for m in found)}
|
|
assert not bad, bad
|
|
|
|
|
|
def test_the_defaults_qualify_skills_by_the_plugins_own_name():
|
|
"""The harness names a plugin's skill `<plugin>:<skill>`, so the defaults
|
|
match only while this agrees with the manifest."""
|
|
assert json.loads(MANIFEST.read_text())["name"] == ma.BUNDLED_SKILL_PLUGIN
|