CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / integration (push) Failing after 43s
CI & Build / Python tests (push) Failing after 46s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Build & push image (push) Skipped
A rule written before moments existed is mounted on nothing. Step 7 records, per (rule, moment), whether it belongs there and who said so: - rule_moment_judgments (migration 0118, backup v23): suggested / confirmed / rejected, from a pass, the signal, or an edit. Moment "" is "no moment fits". - The pass: rules_to_mount lists unjudged rules; propose_rule_moments records suggestions that mount nothing; rule_moment_proposals and judge_rule_moments put them to the operator. A confirm mounts, a reject is kept so the pair is never proposed again. Same service behind REST and a "Waiting on you" panel in Settings > Moments. - Edits are judgments: set_rule_moments, the one mount write path, confirms what was added and rejects what was removed in the same transaction. - The signal: scribe_moment.sh keeps a per-session acts ledger; when a rule is opened, scribe_record_opened.sh sends the last three minutes of it to /api/plugin/rule-opened. The acts resolve through the install's mappings; work.run and work.change are not evidence. Counted per distinct session with lesson_rules' evidence model, and once due the open returns one line asking the reader to offer the mount. - scribe_session_end.sh removes the session's scribe-moment files. Plugin 2026.10.05.2003. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
8.4 KiB
Python
179 lines
8.4 KiB
Python
"""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 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
|
|
from scribe.services import rule_moment_judgments as judgments_svc
|
|
|
|
|
|
async def list_moments() -> dict:
|
|
"""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
|
|
about WHEN something is done rather than WHAT it is about: a rule on
|
|
finishing work belongs at `work.finish` and `reply.report`, where nothing
|
|
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 an action reached the
|
|
wrong moment — or none — and you are about to correct it with
|
|
`map_action` / `unmap_action`.
|
|
|
|
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.
|
|
"""
|
|
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",
|
|
)
|
|
|
|
|
|
async def rules_to_mount(limit: int = 25, offset: int = 0) -> dict:
|
|
"""The rules nobody has decided the moments of — the pass over the corpus, a page at a time.
|
|
|
|
A rule written before moments existed is mounted on nothing and arrives
|
|
only when its words resemble the work. Read each rule here, decide WHEN it
|
|
applies, and record the answer with `propose_rule_moments`: the moments it
|
|
belongs on, or that none fits because it is about WHAT is done rather than
|
|
when. A rule leaves this list as soon as any answer is recorded, so the
|
|
next call returns the next unread rules.
|
|
|
|
Returns `rules` (id, title, kind, statement, when_to_apply, home),
|
|
`total_unjudged`, and the `catalog` to choose from.
|
|
"""
|
|
return await judgments_svc.unjudged_rules(current_user_id(), limit=limit, offset=offset)
|
|
|
|
|
|
async def propose_rule_moments(proposals: list[dict]) -> dict:
|
|
"""Propose the moments rules belong on — for the operator to confirm, never mounted by this call.
|
|
|
|
Each item is either
|
|
`{"rule_id": N, "moments": ["work.finish", "reply.report"], "why": "…"}`
|
|
or `{"rule_id": N, "none": "why no moment fits"}`.
|
|
|
|
Propose a moment when the rule governs that point in the work whatever
|
|
the work is about — a rule about when work counts as done belongs where
|
|
work is finished, delivered, verified and reported. Say none fits when
|
|
the rule is about a subject (a library, a file, a style) and is best
|
|
reached by meaning. `why` is shown to the operator beside the proposal;
|
|
write it so they can say yes or no without opening the rule.
|
|
|
|
A moment proposal waits as `suggested` until judged; show the operator
|
|
what you proposed (`rule_moment_proposals`) and let them decide. A pair
|
|
already mounted or judged is skipped and listed under `skipped`; a bad
|
|
item is refused alone under `refused`.
|
|
"""
|
|
return await judgments_svc.propose(current_user_id(), proposals)
|
|
|
|
|
|
async def rule_moment_proposals(rule_id: int = 0) -> dict:
|
|
"""The moment proposals waiting on the operator, grouped by rule.
|
|
|
|
Each rule carries what it is mounted on now and its proposals: the moment,
|
|
where the proposal came from (`pass` — read from the rule; `signal` — the
|
|
rule kept being opened just after that moment fired), the reason, and the
|
|
evidence counts. `rule_id` narrows to one rule.
|
|
"""
|
|
return await judgments_svc.pending(current_user_id(), rule_id=rule_id or None)
|
|
|
|
|
|
async def judge_rule_moments(judgments: list[dict]) -> dict:
|
|
"""Confirm or reject moment proposals — on the operator's word, since a confirm mounts the rule.
|
|
|
|
Each item is `{"rule_id": N, "moment": "work.finish", "verdict":
|
|
"confirm" | "reject", "note": "why"}`. Confirm mounts the rule on that
|
|
moment beside what it already has; reject records that it does not belong
|
|
there (and unmounts it if it was mounted), so neither the pass nor the
|
|
signal proposes the pair again. Moment `""` judges a "no moment fits"
|
|
answer. Put the operator's reason in `note`.
|
|
"""
|
|
return await judgments_svc.judge(current_user_id(), judgments)
|
|
|
|
|
|
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)
|
|
mcp.tool(name="rules_to_mount")(rules_to_mount)
|
|
mcp.tool(name="propose_rule_moments")(propose_rule_moments)
|
|
mcp.tool(name="rule_moment_proposals")(rule_moment_proposals)
|
|
mcp.tool(name="judge_rule_moments")(judge_rule_moments)
|