Files
FabledScribe/tests/test_skill_moments.py
T
bvandeusenandClaude Opus 5.5 f1fdc4a951
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
feat(moments): skills and stored processes declare the moments they are for (milestone 458 step 5, #4923)
Loading a procedure now also reaches the moment it is for. Loading the
reporting procedure is a report; loading the release procedure is a delivery.

- Bundled skills: each SKILL.md declares `metadata: moments:`. The same
  declaration ships as Skill defaults (BUNDLED_SKILL_MOMENTS), because the
  server never sees the plugin's files. test_skill_moments holds the two
  together and pins the plugin name that qualifies the skill.
- Stored processes: `moments` on create_process and update_process, stored
  in the note's data and returned by get_process. A `scribe-proc-<slug>`
  load resolves its process through the sync manifest at load time. The
  moments are not copied into the stub, which would go stale mid-session.
- reachable_tools lists the skill loader whenever anything is mounted, since
  a process's moments are known only when it loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:23:24 -04:00

85 lines
3.4 KiB
Python

"""The bundled skills declare the moments they are for (milestone 458 step 5).
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 scribe.services import moment_actions as ma
from scribe.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