"""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 `.`; a named procedure (a skill or a stored process) is its own moment, `skill.`, 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.`. 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)