Files
FabledScribe/src/scribe/mcp/tools/moments.py
T
bvandeusenandClaude Opus 5.5 a0390d9da7
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped
feat(500): the reply shapes become server product content - a compact core and per-kind slices, each tied to its moment (#5493)
services/reply_shapes.py is the single source of the default reply shapes:
the core (every reply, ~1,800 chars against a 2,200 budget) and three
slices - completion on work.finish, asks on reply.ask, plan on work.plan.
Read through list_reply_shapes (MCP, read-only) and GET
/api/retrieval/reply-shapes, one service behind both doors.

Nothing delivers them yet; that is step 3. The skill still carries its
copy until step 4 shrinks it to the long-form reference.

The software-only vocabulary guard moves into tests/helpers.py
(DEV_ONLY, dev_only_hits) rather than becoming a fourth copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:29:44 -04:00

245 lines
12 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 reply_shapes as shapes_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 list_reply_shapes() -> dict:
"""The default shapes of a reply, and the moment each one arrives at.
You do not need this to write a reply: the shapes come to you. The core
arrives with the turn, and each kind's shape arrives at the moment before
that kind of reply is written (closing a task, putting a question, opening
a plan). Read this to see them all at once, to answer the operator about
what the default is, or before writing a preference that changes one.
An operator's own adjustment to a shape is a `preference` mounted on that
shape's `moment` (create_preference with `moments=[...]`); it arrives
beside the default, and where the two differ the preference is what the
operator asked for.
Each shape carries `key`, `title`, `moment` (and what the moment `means`),
`delivered` (when it arrives) and `text`.
"""
return shapes_svc.catalog()
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. Each
proposal is a `mount` or an `unmount`, with the moment, where it came
from (`pass` — read from the rule; `signal` — the rule kept being opened
just after that moment fired; `misfire` — sessions reported the mount
arriving where it did not apply, with their `reasons` and the actions
that `reached_by` the moment), 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 — on an `unmount` proposal, it keeps
the mount and stops the misfire question; 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)
async def rule_misfired(rule_id: int, moment: str, why: str,
reached_by: str = "", project_id: int = 0) -> dict:
"""Report that a rule MOUNTED on a moment arrived there and did not apply.
A mount delivers its rule every time the moment fires, whatever the work
is about — so a mount that is wrong is noise at every occurrence, and
nothing else notices. Call this when a line said a rule arrived *at* a
moment ("at work.verify, reached by `actions_run_read`"), you read it,
and it does not govern what you were doing there. It costs one call and
asks nothing of the operator; reports gather, counted once per day, and
once a pair has them on three distinct days the response carries a line
asking you to offer the operator the fix — take the rule off the moment,
or unmap the action when it is the action that is wrong here.
A rule that applied, even one you were already following, is not a
misfire. A rule that arrived by resemblance rather than a mount is
refused with the fix for that (its trigger).
Args:
rule_id: the rule the line named.
moment: the moment it arrived at, as the line names it.
why: what you were doing and why the rule did not bear on it. Required
— it is what tells the operator whether the rule or the action
is wrong.
reached_by: the action the line says reached the moment (`git push`,
`status=done`). Counted, so the operator can see which action
keeps bringing it.
project_id: the project you are working in (0 = none).
Returns `recorded`, `days` so far against the `bar`, and `context`: a line
to act on once the bar is crossed, else "". A mount the operator chose to
keep says so under `kept`.
"""
return await judgments_svc.misfired(
current_user_id(), rule_id, moment, why=why, reached_by=reached_by,
project_id=project_id or None,
)
def register(mcp) -> None:
mcp.tool(name="list_moments")(list_moments)
mcp.tool(name="list_reply_shapes")(list_reply_shapes)
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)
mcp.tool(name="rule_misfired")(rule_misfired)