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