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

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:
2026-10-05 14:23:24 -04:00
co-authored by Claude Opus 5.5
parent b29689d4de
commit f1fdc4a951
16 changed files with 420 additions and 21 deletions
+43 -5
View File
@@ -10,6 +10,8 @@ from scribe.mcp._context import current_user_id
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import knowledge as knowledge_svc
from scribe.services import moment_actions
from scribe.services import moments as moments_svc
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
@@ -53,7 +55,8 @@ async def list_processes(
async def create_process(
title: str, body: str, tags: list[str] | None = None,
system_ids: list[int] | None = None, force: bool = False,
system_ids: list[int] | None = None, moments: list[str] | None = None,
force: bool = False,
) -> dict:
"""Create a stored process (a reusable saved prompt).
@@ -79,6 +82,12 @@ async def create_process(
system_ids: Systems (subsystems/areas) to file this process under, so
an area-scoped read finds it. A process is a note, so it has always
been taggable in the data model; neither door offered it (#4249).
moments: The moments from list_moments this procedure is FOR — a
release procedure is a deliver, a review procedure a verify.
Loading the process then reaches each one, as well as its own
`skill.scribe-proc-<slug>`, so a rule mounted on work.deliver
arrives when the release procedure is loaded, before any of its
steps run.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar process already exists, creation is BLOCKED and the
existing one's id is returned so you update it instead. Set true
@@ -102,12 +111,33 @@ async def create_process(
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "process")
declared = moments_svc.require_moments(moments)
note = await notes_svc.create_note(
uid, title=title.strip(), body=body, note_type="process", tags=tags,
data=_with_moments(None, declared),
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
return await moment_delivery.attach_moment_rules(uid, "create_process", {}, note.to_dict())
return await moment_delivery.attach_moment_rules(uid, "create_process", {}, _process_dict(note))
def _with_moments(data: dict | None, declared: list[str] | None) -> dict | None:
"""`data` with its moments replaced by `declared` (None leaves them)."""
out = dict(data or {})
if declared is None:
return out or None
if declared:
out[moment_actions.PROCESS_MOMENTS_FIELD] = declared
else:
out.pop(moment_actions.PROCESS_MOMENTS_FIELD, None)
return out or None
def _process_dict(note) -> dict:
"""A process as the tools return it: the note, plus the moments it declares."""
out = note.to_dict()
out["moments"] = moment_actions.declared_moments(note.data)
return out
async def get_process(name_or_id: str, project_id: int = 0) -> dict:
@@ -144,7 +174,7 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
if note is None:
raise ValueError(f"process {name_or_id!r} not found")
out = note.to_dict()
out = _process_dict(note)
if candidates:
out["other_matches"] = candidates
out.update(await access_svc.describe_provenance(uid, note))
@@ -162,13 +192,18 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
async def update_process(process_id: int, title: str = "", body: str = "",
tags: list[str] | None = None,
system_ids: list[int] | None = None) -> dict:
system_ids: list[int] | None = None,
moments: list[str] | None = None) -> dict:
"""Update a stored process. Only provided fields change — empty title/body
leave that field unchanged; pass tags to replace the tag set.
`system_ids` replaces the Systems this process is filed under: None leaves
them alone, a list (including `[]`) replaces them.
`moments` replaces the moments the procedure is for (see create_process)
the same way: None leaves them, [] clears them. The change reaches the
next load of its skill — the skill file itself does not carry them.
Editing another user's process requires an editor or admin share from them; a
read-only share is not enough and says so rather than claiming not-found.
"""
@@ -189,6 +224,9 @@ async def update_process(process_id: int, title: str = "", body: str = "",
fields["body"] = body
if tags is not None:
fields["tags"] = tags
declared = moments_svc.require_moments(moments)
if declared is not None:
fields["data"] = _with_moments(note.data, declared)
# As the owner — update_note is owner-scoped and the write is authorised above.
updated = await notes_svc.update_note(note.user_id, process_id, **fields)
# The CALLER, not the owner. `update_note` above is owner-scoped because
@@ -199,7 +237,7 @@ async def update_process(process_id: int, title: str = "", body: str = "",
await systems_svc.set_record_systems(uid, process_id, system_ids)
if updated is None:
raise ValueError(f"process {process_id} not found")
out = updated.to_dict()
out = _process_dict(updated)
out.update(await access_svc.describe_provenance(uid, updated))
return out
+94 -7
View File
@@ -63,6 +63,33 @@ COMMAND_TOOLS = frozenset({"bash"})
# procedures' own.
SKILL_TOOL, SKILL_FIELD = "skill", "skill"
# A procedure also reaches the moment it is FOR (step 5): loading the
# reporting procedure is a report as surely as the reply that follows it.
#
# The product's own skills declare theirs in their SKILL.md frontmatter
# (`metadata: moments:`), and the declaration ships HERE as defaults, because
# the server never sees the plugin's files. `test_skill_moments` holds the two
# together. The plugin's name qualifies the skill, as the harness names it.
BUNDLED_SKILL_PLUGIN = "scribe"
BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = {
"using-scribe": ("session.start",),
"brainstorming": ("work.plan",),
"writing-plans": ("work.plan",),
"reusing-code": ("work.change",),
"systematic-debugging": ("work.debug",),
"verification": ("work.verify",),
"shape-accounting": ("work.record",),
"reporting-back": ("reply.report",),
}
# A stored process arrives as the skill `scribe-proc-<slug>`
# (scribe_sync_processes.sh). Its moments are the process's own field, read
# when it loads — not copied into the stub, which is written once a session
# and would go on firing a moment its process no longer declares.
PROCESS_SKILL_PREFIX = "scribe-proc-"
PROCESS_MOMENTS_FIELD = "moments"
PROCESS = "process"
_SEGMENT_SPLIT = re.compile(r"&&|\|\||[;|\n]")
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+")
@@ -92,6 +119,9 @@ def _defaults() -> tuple[Action, ...]:
on("work.plan", "EnterPlanMode")
on("work.plan", "ExitPlanMode")
on("reply.ask", "AskUserQuestion")
for skill, reached in BUNDLED_SKILL_MOMENTS.items():
for moment in reached:
on(moment, "Skill", f"{SKILL_FIELD}={BUNDLED_SKILL_PLUGIN}:{skill}")
# Scribe's own tools — every install has these, so their moments ship.
on("work.start", "update_task", "status=in_progress")
@@ -201,12 +231,38 @@ def effective_actions(mappings: Iterable) -> list[tuple[Action, str]]:
return out
def resolve(tool: str, tool_input: dict | None, mappings: Iterable = ()) -> list[dict]:
def skill_name(tool: str, tool_input: dict | None) -> str:
"""The procedure a skill-loader call loads, lowercased; "" for any other call."""
if tool_key(tool) != SKILL_TOOL:
return ""
return str((tool_input or {}).get(SKILL_FIELD) or "").strip().lower()
def declared_moments(data: dict | None) -> list[str]:
"""The moments a stored process declares — the valid ones, in its order.
Read defensively: the field is written through require_moments, but a
moment the catalog later drops must stop firing, not break the load.
"""
raw = (data or {}).get(PROCESS_MOMENTS_FIELD) or []
if isinstance(raw, str):
raw = [raw]
return [m for m in dict.fromkeys(str(n).strip().lower() for n in raw)
if catalog.is_moment(m)]
def resolve(
tool: str, tool_input: dict | None, mappings: Iterable = (),
declared: Iterable[str] = (),
) -> list[dict]:
"""Every moment this call reaches, each with the action that reached it.
One entry per moment, in catalog order. When two actions reach the same
moment the more specific one — the longer match — is the one named, since
"reached by `git push`" says more than "reached by Bash".
`declared` are the moments the procedure this call loads says it is for
(a stored process's own field); each is reached by the load itself.
"""
best: dict[str, dict] = {}
for action, via in effective_actions(mappings):
@@ -219,12 +275,15 @@ def resolve(tool: str, tool_input: dict | None, mappings: Iterable = ()) -> list
"match": action.match, "via": via,
}
if tool_key(tool) == SKILL_TOOL:
name = str((tool_input or {}).get(SKILL_FIELD) or "").strip().lower()
name = skill_name(tool, tool_input)
if name:
moment = catalog.SKILL_PREFIX + name
if name and catalog.is_moment(moment):
best[moment] = {"moment": moment, "tool": tool, "match": f"skill={name}",
"via": DEFAULT}
if catalog.is_moment(moment):
best[moment] = {"moment": moment, "tool": tool,
"match": f"{SKILL_FIELD}={name}", "via": DEFAULT}
for moment in declared:
best.setdefault(moment, {"moment": moment, "tool": tool,
"match": f"{SKILL_FIELD}={name}", "via": PROCESS})
order = {name: i for i, name in enumerate(catalog.MOMENTS)}
return sorted(best.values(), key=lambda h: (order.get(h["moment"], len(order)), h["moment"]))
@@ -286,7 +345,35 @@ async def moments_for(user_id: int, tool: str, tool_input: dict | None) -> list[
except Exception:
logger.warning("moment mappings unreadable; using the defaults", exc_info=True)
mappings = []
return resolve(tool, tool_input, mappings)
name = skill_name(tool, tool_input)
declared = (await process_moments(user_id, name)
if name.startswith(PROCESS_SKILL_PREFIX) else [])
return resolve(tool, tool_input, mappings, declared)
async def process_moments(user_id: int, skill: str) -> list[str]:
"""The moments the stored process behind `scribe-proc-<slug>` declares.
The slug is resolved through the same manifest the sync script wrote the
skill from, so the two agree on which process a slug names. Fails open to
none: a process that cannot be read still loads, it just reaches no more
than its own `skill.<name>`.
"""
from scribe.services import notes as notes_svc
from scribe.services import plugin_context
slug = skill[len(PROCESS_SKILL_PREFIX):]
try:
manifest = await plugin_context.build_process_manifest(user_id)
entry = next((p for p in manifest.get("processes", []) if p.get("slug") == slug), None)
if entry is None:
return []
loaded = await notes_svc.get_note_for_user(user_id, int(entry["id"]))
note = loaded[0] if loaded else None
return declared_moments(note.data if note is not None else None)
except Exception:
logger.debug("process moments for %s unreadable", skill, exc_info=True)
return []
async def _find(session, user_id: int, tool: str, match: str, moment: str):
+5 -5
View File
@@ -17,7 +17,6 @@ from __future__ import annotations
import logging
from scribe.services import moment_actions
from scribe.services import moments as catalog
from scribe.services import retrieval_pipeline as rp
logger = logging.getLogger(__name__)
@@ -39,8 +38,10 @@ async def reachable_tools(user_id: int) -> list[str]:
What the plugin's catch-all hook reads once per session window, so the
calls that cannot reach a mounted rule — most of them, and every one on
an install that has mounted nothing — never leave the machine. An action
counts only when its moment carries a mount; the skill loader counts when
any `skill.<name>` moment does.
counts only when its moment carries a mount. The skill loader counts
whenever anything is mounted: a stored process declares its own moments,
which only the load itself can resolve, and a load is rare enough that
asking costs nothing.
"""
from scribe.services import rulebooks
@@ -53,8 +54,7 @@ async def reachable_tools(user_id: int) -> list[str]:
for action, _via in moment_actions.effective_actions(mappings)
if action.moment in mounted
}
if any(name.startswith(catalog.SKILL_PREFIX) for name in mounted):
keys.add(moment_actions.SKILL_TOOL)
keys.add(moment_actions.SKILL_TOOL)
return sorted(keys)