Files
FabledScribe/src/scribe/mcp/tools/moments.py
T
bvandeusenandClaude Opus 5.5 dfcf4df2e9
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
feat(moments): mount the corpus by proposal - a pass and an open-after-moment signal, both stopping at the operator (milestone 458 step 7, #4925)
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>
2026-10-05 16:03:53 -04:00

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)