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