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
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>
245 lines
12 KiB
Python
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)
|