feat(moments): actions map onto moments, with in-session corrections (milestone 458 step 2, #4920)
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m23s
CI & Build / Python tests (push) Successful in 2m0s
CI & Build / Build & push image (push) Successful in 36s

moment_actions.resolve(tool, input) names every moment a call reaches and
the action that reached it. One call can reach several: kubectl apply is
a run, a deliver and a reach outside the workspace. Command tools match by
how each segment of the line starts, with a word boundary; other tools by
field=value arguments. The MCP server prefix and case are ignored.

56 shipped defaults cover the harness tools, Scribe tools and common
command shapes. moment_mappings (migration 0116) holds what an install
adds and the defaults it switches off. A removal is a stored row, so an
upgrade does not switch the default back on.

Per the operator ruling, corrections happen in the session: map_action
and unmap_action (write tools) return now_reaches so the fix can be
confirmed in the same reply. list_moments now shows each moment's
actions on this install. REST mirrors both doors, recorded as human.
Backup v21 carries the mappings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:58:00 -04:00
co-authored by Claude Opus 5.5
parent 2ff7f2f34f
commit cf3de5bae1
13 changed files with 1039 additions and 29 deletions
+89 -14
View File
@@ -1,17 +1,24 @@
"""The moment catalog as an MCP tool (milestone 458 step 1).
"""Moments as MCP tools: the catalog, and the in-session corrections to it (milestone 458).
A session needs the vocabulary in hand to mount a rule, to correct which
action reaches which moment, and to read a moment line it was shown — and it
needs it without leaving the session for a settings page. So the catalog is a
tool from the first step, ahead of the writes that will use it.
A session needs the vocabulary in hand to mount a rule, to read a line that
names a moment, and to fix a misfire — and the operator's ruling is that the
fix happens in the session, not on a settings page:
"making the user leave the session to fix a misfire is not desirable and
the llm session should be able to offer corrections"
So the mapping writes are tools, built to be offered mid-work and made on the
operator's yes.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import moment_actions as actions_svc
from scribe.services import moments as moments_svc
async def list_moments() -> dict:
"""The moments of work that rules mount on — their names and what each means.
"""The moments of work that rules mount on, and which actions reach each one here.
A rule mounted on a moment arrives whenever that moment happens, whatever
the words of the work look like. That is how a rule reaches you when it is
@@ -20,17 +27,85 @@ async def list_moments() -> dict:
said needs to resemble it.
Read this when you are about to mount a rule, when a line you were shown
names a moment and you want its meaning, or when you are correcting which
of your actions reaches which moment. Names are `<area>.<verb>`; a named
procedure (a skill or a stored process) is its own moment,
`skill.<name>`, listed under `families`.
names a moment and you want its meaning, or when an action reached the
wrong moment — or none — and you are about to correct it with
`map_action` / `unmap_action`.
Each moment says what is happening at it (`means`) and the kinds of action
that typically reach it (`reached_by`). The actions are examples: which of
YOUR actions reach a moment varies by install and is corrected in-session.
Names are `<area>.<verb>`; a named procedure (a skill or a stored process)
is its own moment, `skill.<name>`, listed under `families`. Each moment
says what is happening at it (`means`) and the kinds of action that
typically reach it (`reached_by`).
`actions` maps each moment to the actions that reach it on this install:
`via: "default"` ships with the product, `via: "install"` is this
install's own mapping. `removed_defaults` are shipped defaults this install
switched off.
"""
return moments_svc.catalog()
out = moments_svc.catalog()
out.update(await actions_svc.actions_by_moment(current_user_id()))
return out
async def map_action(tool: str, moment: str, match: str = "", reason: str = "") -> dict:
"""Make an action reach a moment on this install — the in-session fix for a missed moment.
Use it when an action plainly happened at a moment and the moment did not
fire: the operator ships with `make ship`, and nothing mounted on
`work.deliver` arrived. Offer the mapping when you notice, in one line
("that `make ship` was a deliver and nothing fired — map it?"), and make it
on their yes. The correction lasts: every later session on this install
gets it.
Args:
tool: the tool as the harness names it — `Bash`, `Edit`,
`update_task`. An MCP server prefix is ignored.
moment: a moment from `list_moments`, or `skill.<name>`.
match: which calls of the tool. Empty = every call. For a tool that
runs a command, how the command starts (`make ship`, `./deploy.sh`)
— it is checked against each part of a compound command line. For
any other tool, its arguments as `field=value` pairs, comma-separated
(`status=done`).
reason: what misfired, or what this action is for here. Optional; it
is shown beside the mapping when someone reviews it later.
Returns the change made and `now_reaches`: every moment that action
reaches after the change, so you can confirm it in the same reply.
Mapping a shipped default this install had switched off switches it back
on.
"""
return await actions_svc.map_action(
current_user_id(), tool, match, moment, reason=reason, actor="model",
)
async def unmap_action(tool: str, moment: str, match: str = "", reason: str = "") -> dict:
"""Stop an action reaching a moment on this install — the fix for a moment that fires wrongly.
Use it when a moment fires on an action that is not that moment here — a
`curl` to a local test server reaching `env.reach`, say — and the rules it
brings are noise every time. Offer it when you notice, and make it on the
operator's yes.
The arguments name the mapping exactly as `list_moments` shows it. This
install's own mapping is removed; a shipped default is switched off for
this install only, and stays off across upgrades. `map_action` with the
same arguments switches a default back on.
Args:
tool: the tool as `list_moments` names it.
moment: the moment it should stop reaching.
match: the mapping's match, as listed (empty for a whole-tool mapping).
reason: why it misfires here — worth giving for a default, since it is
the record of why this install differs from the product.
Returns the change made and `now_reaches` for that action.
"""
return await actions_svc.unmap_action(
current_user_id(), tool, match, moment, reason=reason, actor="model",
)
def register(mcp) -> None:
mcp.tool(name="list_moments")(list_moments)
mcp.tool(name="map_action")(map_action)
mcp.tool(name="unmap_action")(unmap_action)