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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user