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
+201 -4
View File
@@ -19,6 +19,16 @@ Neither source mounts anything by itself. A suggestion that delivered its rule
would manufacture the opens it counts, and the operator's corpus is theirs to
mount.
THE MISFIRE (step 7b) is the same machinery pointed the other way. A session
that was handed a mounted rule at a moment where it did not apply says so
(`misfired`), with why and the action that reached the moment. Reports count
per distinct DAY — the MCP door is stateless and no session id reaches it, and
a misfire seen on three days is a pattern where three in one afternoon may be
one piece of work. At the bar the pair becomes an UNMOUNT proposal; the
operator takes the rule off (reject), or unmaps the action, or keeps it
(confirm), which stops the asking. A rule left unopened is never counted:
most delivered rules go unopened, and the reply hold forces an open.
EDITS ARE JUDGMENTS TOO. A person changing a rule's moments directly says
which moments it belongs on, so `record_mount_change` (called from the one
write path, `rulebooks.set_rule_moments`) confirms what was added and rejects
@@ -68,6 +78,17 @@ SIGNAL_WINDOW_SECONDS = 180
# propose every rule onto both.
SIGNAL_SKIP = frozenset({"work.run", "work.change"})
# The reasons a misfire proposal carries, newest kept. Enough for the operator
# to see whether the reports agree; few enough to read in the panel.
MISFIRE_REASONS = 5
# Distinct actions counted per pair. A moment is reached by a handful of
# actions; past this the counts stop telling the operator which one to unmap.
_MISFIRE_ACTIONS_CAP = 10
_MISFIRE_WHY_CHARS = 400
# On a mounted pair that has no judgment row — mounted before the rows were
# kept (migration 0118) — when its first misfire gives it one.
_UNRECORDED_MOUNT_NOTE = "mounted before judgments were recorded"
def _clean_moment(name) -> str:
"""A catalog moment, normalised; "" stays "" (no moment fits)."""
@@ -279,22 +300,73 @@ async def pending(user_id: int, rule_id: int | None = None, limit: int = 200) ->
"proposals": [],
})
entry["proposals"].append({
"proposal": "mount",
"moment": judgment.moment,
"source": judgment.source,
"why": judgment.note or "",
"evidence": lesson_rules.evidence_summary(judgment.evidence),
"created_at": judgment.created_at.isoformat() if judgment.created_at else None,
})
rules = list(grouped.values())
for judgment, rule, mounted in await _misfire_proposals(user_id, rule_id, limit):
entry = grouped.setdefault(rule.id, {
**_brief(rule),
"mounted": sorted(mounted, key=lambda n: (order.get(n, len(order)), n)),
"proposals": [],
})
entry["proposals"].append(_unmount_item(judgment))
rules = sorted(grouped.values(), key=lambda r: r["id"])
return {"rules": rules, "total": sum(len(r["proposals"]) for r in rules)}
def _misfire_due_clause():
"""Proposed by the misfire signal and not yet kept by the operator."""
mf = RuleMomentJudgment.misfire
return mf["proposed_at"].astext.isnot(None), mf["kept_at"].astext.is_(None)
async def _misfire_proposals(user_id: int, rule_id: int | None, limit: int):
"""The unmount proposals: pairs past the misfire bar, unanswered, and
still mounted — a pair taken off since has had its answer."""
from scribe.services.rulebooks import _owned_rules_clause
async with async_session() as session:
stmt = (
select(RuleMomentJudgment, Rule)
.join(Rule, Rule.id == RuleMomentJudgment.rule_id)
.where(_owned_rules_clause(user_id), *_misfire_due_clause())
)
if rule_id:
stmt = stmt.where(Rule.id == int(rule_id))
rows = (await session.execute(
stmt.order_by(Rule.id, RuleMomentJudgment.moment).limit(limit)
)).all()
mounts = await _mounts(session, {r.id for _, r in rows})
return [(j, r, mounts.get(r.id, set())) for j, r in rows
if j.moment in mounts.get(r.id, set())]
def _unmount_item(judgment: RuleMomentJudgment) -> dict:
mf = judgment.misfire or {}
reasons = list(mf.get("reasons") or [])
return {
"proposal": "unmount",
"moment": judgment.moment,
"source": "misfire",
"why": reasons[-1]["why"] if reasons else "",
"reasons": reasons,
"reached_by": dict(mf.get("reached_by") or {}),
"evidence": lesson_rules.evidence_summary(mf),
"created_at": mf.get("first_at"),
}
async def judge(user_id: int, judgments: list[dict]) -> dict:
"""Confirm or reject proposals — or any (rule, moment) pair, proposed or not.
Confirm MOUNTS the rule on the moment (alongside what it already has);
reject records that it does not belong there and, if it was mounted,
unmounts it. Both go through `rulebooks.set_rule_moments`, the one write
Confirm MOUNTS the rule on the moment (alongside what it already has) —
on a pair already mounted, it KEEPS the mount and answers a misfire
proposal; reject records that it does not belong there and, if it was
mounted, unmounts it. Both go through `rulebooks.set_rule_moments`, the one write
path, which records the judgment with this item's `note`. A moment of ""
judges the "no moment fits" answer itself. A bad item is refused alone.
"""
@@ -382,11 +454,16 @@ async def record_mount_change(
for moment in added:
put(moment, CONFIRMED, _MOUNTED_NOTE)
# A fresh mount is a fresh judgment: misfires counted against an
# earlier mount of the pair are not evidence against this one.
rows[(rule_id, moment)].misfire = None
for moment in removed:
put(moment, REJECTED, _UNMOUNTED_NOTE)
for moment in explicit:
if verdict in (CONFIRMED, REJECTED):
put(moment, verdict, "")
if verdict == CONFIRMED and moment in after:
_keep(rows[(rule_id, moment)], note, now)
if after:
none_row = rows.get((rule_id, NO_MOMENT))
if none_row is not None and none_row.state == CONFIRMED:
@@ -394,6 +471,15 @@ async def record_mount_change(
none_row.note = _OVERTURNED_NOTE
def _keep(row: RuleMomentJudgment, note: str, now: datetime) -> None:
"""The operator kept a mount the misfire signal proposed taking off. The
reports stay; the asking stops."""
mf = row.misfire
if not isinstance(mf, dict) or not mf.get("proposed_at") or mf.get("kept_at"):
return
row.misfire = {**mf, "kept_at": now.isoformat(), "kept_note": note or ""}
# ── The signal ───────────────────────────────────────────────────────────────
@@ -525,3 +611,114 @@ async def opened_after(
user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id,
)
return {"moments": moments, "context": context}
# ── The misfire: a mounted rule that arrived where it does not apply ─────────
def misfire_situation(now: datetime) -> str:
"""One situation per UTC day. The MCP door carries no session id; a day
is the coarser, honest unit, and the slower of the two to cross the bar."""
return lesson_rules.situation_key("d", now.date().isoformat())
def add_misfire(misfire, key: str, project_id: int | None, now: datetime, *,
why: str, reached_by: str) -> dict:
"""A NEW misfire dict with this report counted. Pure, like
`lesson_rules.add_evidence`, whose counting it reuses: the per-day
situations and the bar are the same as every other proposal's."""
ev = lesson_rules.add_evidence(misfire, key, project_id, now)
reasons = list(ev.get("reasons") or [])
reasons.append({"why": why[:_MISFIRE_WHY_CHARS], "reached_by": reached_by,
"at": now.isoformat()})
ev["reasons"] = reasons[-MISFIRE_REASONS:]
actions = dict(ev.get("reached_by") or {})
if reached_by and (reached_by in actions or len(actions) < _MISFIRE_ACTIONS_CAP):
actions[reached_by] = int(actions.get(reached_by) or 0) + 1
ev["reached_by"] = actions
return ev
def _unmount_line(rule: Rule, moment: str, misfire: dict) -> str:
days = len(misfire.get("situations") or [])
actions = ", ".join(f"`{a}` ×{n}" for a, n in sorted(
(misfire.get("reached_by") or {}).items(), key=lambda kv: -kv[1]))
kind = rule.kind or "rule"
return (
f"> {kind.capitalize()} #{rule.id} \"{rule.title}\" has been reported arriving at "
f"`{moment}` where it did not apply, on {days} distinct days"
+ (f" (reached by {actions})" if actions else "")
+ ". Offer the operator the fix in one line. If the rule does not belong "
f"at that moment, `judge_rule_moments([{{\"rule_id\": {rule.id}, \"moment\": "
f"\"{moment}\", \"verdict\": \"reject\", \"note\": \"why\"}}])` takes it off. "
"If it is the ACTION that is not that moment here, `unmap_action` for "
"it instead (`list_moments` shows the mapping). If they keep it, "
"`\"confirm\"` with their why stops the asking."
)
async def misfired(
user_id: int, rule_id: int, moment: str, *, why: str,
reached_by: str = "", project_id: int | None = None,
) -> dict:
"""Record that a mounted rule arrived at `moment` where it did not apply.
Only a MOUNT can misfire: a rule that arrived by resemblance is a
retrieval question, answered by its trigger. `why` is required — a report
without its reason cannot tell the operator what to fix. Returns the
count so far and, once the pair crosses the bar, `context`: the line
asking the reader to offer the unmount. A kept mount still counts its
reports and asks nothing.
"""
try:
name = moments_svc.require_moment((moment or "").strip())
except ValueError as exc:
return {"recorded": False, "error": str(exc)}
why = (why or "").strip() if isinstance(why, str) else ""
if not why:
return {"recorded": False, "error": (
"say why it did not apply here — the reason is what tells the "
"operator whether the rule or the action is wrong")}
rid = _int(rule_id)
reached_by = (reached_by or "").strip()[:200] if isinstance(reached_by, str) else ""
now = datetime.now(timezone.utc)
async with async_session() as session:
owned = await _owned_rules(session, user_id, [rid] if rid else [])
rule = owned.get(rid) if rid else None
if rule is None:
return {"recorded": False, "error": "not a rule you own"}
mounted = (await _mounts(session, [rid])).get(rid, set())
if name not in mounted:
return {"recorded": False, "error": (
f"rule {rid} is not mounted on {name}"
+ (f" (it is on {', '.join(sorted(mounted))})" if mounted else "")
+ ". A rule that arrived by its words rather than a mount is "
"fixed through its trigger — update_rule(when_to_apply=...).")}
row = (await _rows(session, [rid])).get((rid, name))
if row is None:
row = RuleMomentJudgment(rule_id=rid, moment=name, state=CONFIRMED,
source="edit", note=_UNRECORDED_MOUNT_NOTE,
judged_at=now)
session.add(row)
mf = add_misfire(row.misfire, misfire_situation(now), project_id, now,
why=why, reached_by=reached_by)
kept = bool(mf.get("kept_at"))
due = not kept and lesson_rules.proposal_due(mf, now)
if due:
mf["proposed_at"] = now.isoformat()
mf["proposed_count"] = int(mf.get("proposed_count") or 0) + 1
row.misfire = mf
try:
await session.commit()
except IntegrityError:
await session.rollback()
return {"recorded": False, "error": "a report for this pair landed at the same moment; try again"}
out = {
"recorded": True, "rule_id": rid, "moment": name,
"days": len(mf.get("situations") or []),
"bar": lesson_rules.PROPOSE_SITUATIONS,
"context": _unmount_line(rule, name, mf) if due else "",
}
if kept:
out["kept"] = {"at": mf.get("kept_at"), "note": mf.get("kept_note") or ""}
return out