feat(moments): a mount that keeps arriving where it does not apply proposes its own removal (milestone 458 step 7b, #4955)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m3s
CI & Build / Build & push image (push) Successful in 18s

The open-after-moment signal proposes a mount; nothing proposed taking one
off, so a wrong mount was noise at every occurrence until someone happened
to notice. rule_misfired(rule_id, moment, why, reached_by) records a report
against a MOUNTED pair, counted per distinct day (the MCP door carries no
session id) on a new rule_moment_judgments.misfire column (migration 0119,
backup v24). At three days the response carries a line asking the agent to
offer the operator the fix - reject takes the rule off, unmap_action stops
the action reaching the moment, confirm keeps the mount and stops the
asking - and Settings > Moments lists it as an unmount proposal with the
reasons and the actions that reached it. A re-mount clears the count.

Taught in moments.md, missed-retrieval.md and the reply hold's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 18:19:59 -04:00
co-authored by Claude Opus 5.5
parent 8afd6da8af
commit 4e1320120d
19 changed files with 636 additions and 44 deletions
+51 -7
View File
@@ -147,10 +147,13 @@ async def propose_rule_moments(proposals: list[dict]) -> dict:
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.
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)
@@ -160,14 +163,54 @@ async def judge_rule_moments(judgments: list[dict]) -> dict:
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"
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="map_action")(map_action)
@@ -176,3 +219,4 @@ def register(mcp) -> None:
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)