feat(moments): skills and stored processes declare the moments they are for (milestone 458 step 5, #4923)
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
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
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>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user