From 4e1320120dd40f17801956587b1ec1479ccec8b1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 5 Oct 2026 18:19:59 -0400 Subject: [PATCH] feat(moments): a mount that keeps arriving where it does not apply proposes its own removal (milestone 458 step 7b, #4955) 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 --- alembic/versions/0119_rule_moment_misfires.py | 36 +++ frontend/src/api/moments.ts | 26 ++- frontend/src/components/MomentProposals.vue | 76 ++++++- plugin/.claude-plugin/plugin.json | 2 +- .../skills/using-scribe/missed-retrieval.md | 3 +- plugin/skills/using-scribe/moments.md | 23 +- src/scribe/mcp/server.py | 3 + src/scribe/mcp/tools/moments.py | 58 ++++- src/scribe/models/rule_moment_judgment.py | 6 + src/scribe/services/backup.py | 8 +- src/scribe/services/lesson_rules.py | 10 +- src/scribe/services/moment_delivery.py | 13 +- src/scribe/services/rule_moment_judgments.py | 205 +++++++++++++++++- tests/test_guidance_ownership.py | 3 + .../test_integration_rule_moment_judgments.py | 141 +++++++++++- tests/test_integration_rule_moments.py | 17 +- tests/test_moments.py | 4 +- tests/test_rule_moment_judgments.py | 44 ++++ tests/test_services_backup.py | 2 +- 19 files changed, 636 insertions(+), 44 deletions(-) create mode 100644 alembic/versions/0119_rule_moment_misfires.py diff --git a/alembic/versions/0119_rule_moment_misfires.py b/alembic/versions/0119_rule_moment_misfires.py new file mode 100644 index 00000000..655a4f74 --- /dev/null +++ b/alembic/versions/0119_rule_moment_misfires.py @@ -0,0 +1,36 @@ +"""rule_moment_judgments.misfire — a mount reported as arriving where it does +not apply (milestone 458 step 7b, #4955) + +Revision ID: 0119 +Revises: 0118 +Create Date: 2026-10-05 + +The open-after-moment signal proposes a mount; nothing proposed taking one +off. This column is the evidence for that direction: each time a session says +a mounted rule arrived at a moment where it did not apply, the report is +counted here per distinct day, with its reason and the action that reached +the moment. At the bar it becomes an unmount proposal for the operator. + +A column on the judgment row rather than a table of its own: a misfire is +always about a (rule, moment) pair that is mounted, and that pair already has +its row. Nullable and no backfill — nobody has reported a misfire yet. +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision = "0119" +down_revision = "0118" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "rule_moment_judgments", + sa.Column("misfire", postgresql.JSONB(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("rule_moment_judgments", "misfire") diff --git a/frontend/src/api/moments.ts b/frontend/src/api/moments.ts index 70dd0b07..381b567b 100644 --- a/frontend/src/api/moments.ts +++ b/frontend/src/api/moments.ts @@ -101,19 +101,32 @@ export function unmapAction(change: MappingChange): Promise { } /** - * Proposals that a rule belongs on a moment (milestone 458 step 7) — from a - * pass that read the rule, or from the rule being opened just after the - * moment fired. `GET /api/retrieval/moments/proposals`, the payload the + * Proposals about a rule's moments, waiting on a person. A `mount` proposal + * (milestone 458 step 7) comes from a pass that read the rule, or from the + * rule being opened just after the moment fired. An `unmount` proposal (step + * 7b) comes from sessions reporting that the mount arrived where it did not + * apply. `GET /api/retrieval/moments/proposals`, the payload the * `rule_moment_proposals` MCP tool returns. */ -export type ProposalSource = "pass" | "signal" | "edit"; +export type ProposalSource = "pass" | "signal" | "edit" | "misfire"; + +export interface MisfireReason { + why: string; + reached_by: string; + at: string; +} export interface MomentProposal { + proposal: "mount" | "unmount"; moment: string; source: ProposalSource; why: string; + /** For a misfire, `situations` counts distinct days and `co_surfaced` the reports. */ evidence: { situations: number; projects: number; co_surfaced: number }; created_at: string | null; + /** Unmount proposals only: the newest reasons given, and how often each action reached the moment. */ + reasons?: MisfireReason[]; + reached_by?: Record; } export interface RuleProposals { @@ -151,7 +164,10 @@ export function getProposals(ruleId?: number): Promise { return apiGet(`/api/retrieval/moments/proposals${ruleId ? `?rule_id=${ruleId}` : ""}`); } -/** A confirm MOUNTS the rule on the moment; a reject stops it being proposed again. */ +/** + * A confirm MOUNTS the rule on the moment (on an unmount proposal, keeps it); + * a reject unmounts it if mounted and stops the pair being proposed again. + */ export function judgeProposals(judgments: MomentJudgment[]): Promise { return apiPost("/api/retrieval/moments/proposals/judge", { judgments }); } diff --git a/frontend/src/components/MomentProposals.vue b/frontend/src/components/MomentProposals.vue index 2250bf29..c5af5293 100644 --- a/frontend/src/components/MomentProposals.vue +++ b/frontend/src/components/MomentProposals.vue @@ -7,11 +7,13 @@ import { useMomentsStore } from "@/stores/moments"; import { useToastStore } from "@/stores/toast"; /** - * Proposals that a rule belongs on a moment, waiting on a person (milestone - * 458 step 7). They come from a pass that read the rule, or from the rule - * being opened just after the moment fired in several sessions. Nothing here - * is mounted until "Mount" is pressed — the same judgment `judge_rule_moments` - * makes in a session, through the same service. + * Proposals about a rule's moments, waiting on a person. A mount proposal + * (milestone 458 step 7) comes from a pass that read the rule, or from the + * rule being opened just after the moment fired in several sessions; nothing + * is mounted until "Mount" is pressed. An unmount proposal (step 7b) comes + * from sessions reporting that the mount arrived where it did not apply; + * nothing is taken off until "Take it off" is pressed. Both are the judgment + * `judge_rule_moments` makes in a session, through the same service. */ const store = useMomentsStore(); const toast = useToastStore(); @@ -35,27 +37,48 @@ function key(rule: RuleProposals, p: MomentProposal): string { return `${rule.id}:${p.moment}`; } +function plural(n: number, one: string, many: string): string { + return `${n} ${n === 1 ? one : many}`; +} + function sourceLabel(p: MomentProposal): string { + if (p.proposal === "unmount") { + return `reported as not applying here on ${plural(p.evidence.situations, "day", "days")}`; + } if (p.source === "signal") { return `opened just after this moment in ${p.evidence.situations} sessions`; } return "proposed from the rule's text"; } +// What the action that reached the moment was, most frequent first — so the +// operator can see whether it is the rule or one action that keeps misfiring. +function actions(p: MomentProposal): string[] { + return Object.entries(p.reached_by ?? {}) + .sort((a, b) => b[1] - a[1]) + .map(([action, n]) => `${action} ×${n}`); +} + +function note(p: MomentProposal, verdict: Verdict): string { + if (p.proposal === "unmount") { + return verdict === "confirm" ? "Kept in Settings." : "Taken off in Settings."; + } + return verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings."; +} + async function decide(rule: RuleProposals, p: MomentProposal, verdict: Verdict) { busy.value = key(rule, p); try { const out = await judgeProposals([{ - rule_id: rule.id, moment: p.moment, verdict, - note: verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.", + rule_id: rule.id, moment: p.moment, verdict, note: note(p, verdict), }]); if (out.refused.length) { toast.show(out.refused[0].error, "error"); return; } await load(); - // A mount changes the per-moment counts the list below shows. - if (verdict === "confirm") await store.load(true); + // A mount or an unmount changes the per-moment counts the list below shows. + if ((p.proposal === "unmount") === (verdict === "reject")) await store.load(true); } catch (e) { toast.show(apiErrorMessage(e, "Could not record that"), "error"); } finally { @@ -75,8 +98,10 @@ onMounted(load);