From a72a422534c063523a2c3c48e023d60fe5803148 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 5 Oct 2026 16:39:47 -0400
Subject: [PATCH 1/3] docs(plugin): the instruction surfaces teach moments -
reading a line that arrived at one, correcting a misfire, and giving a new
rule its moments (milestone 458 step 8, #4926)
Until now only the tool arguments knew moments existed. The guidance
surfaces described rules as reached by resemblance alone:
- using-scribe: a short reflex paragraph and a new reference file,
moments.md. It covers reading "at , reached by ", the
reply held once at reply.report, map_action / unmap_action offered in
one line, and the step 7 proposal line answered with judge_rule_moments.
- writing-records: asks WHEN a rule applies as well as what it is about.
A rule, preference or process about a point in the work gets
moments=[...] as it is written, and the trigger stays as the net.
- missed-retrieval: a missed WHEN is mounted or mapped, not reworded. A
misfire is unmounted or unmapped.
- _INSTRUCTIONS: one clause (list_moments; mount rules about WHEN),
1594 of 1600 chars.
- static context: injected lines include the rules mounted on a moment
that was reached.
- test_guidance_ownership: three owned topics, so the text cannot quietly
drop out.
Plugin minted.
Co-Authored-By: Claude Opus 5.5
---
plugin/.claude-plugin/plugin.json | 2 +-
plugin/hooks/scribe_static_context.md | 6 +-
plugin/skills/using-scribe/SKILL.md | 9 +++
.../skills/using-scribe/missed-retrieval.md | 15 +++++
plugin/skills/using-scribe/moments.md | 66 +++++++++++++++++++
plugin/skills/using-scribe/writing-records.md | 20 +++++-
src/scribe/mcp/server.py | 9 +--
tests/test_guidance_ownership.py | 13 ++++
8 files changed, 132 insertions(+), 8 deletions(-)
create mode 100644 plugin/skills/using-scribe/moments.md
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index c8493faf..1c8d3f57 100644
--- a/plugin/.claude-plugin/plugin.json
+++ b/plugin/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
- "version": "2026.10.05.2003",
+ "version": "2026.10.05.2039",
"author": {
"name": "Bryan Van Deusen"
},
diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md
index 062af163..64550e14 100644
--- a/plugin/hooks/scribe_static_context.md
+++ b/plugin/hooks/scribe_static_context.md
@@ -16,8 +16,10 @@ What only Claude Code needs said:
Scribe works alongside them.
- **Lines injected beside your work are retrieval.** When the operator sends a
message, and before a write or a command, Scribe may add rules, preferences,
- notes and prior art that resemble what you are doing. Open the ones that
- apply; using-scribe says what a quiet turn means.
+ notes and prior art that resemble what you are doing — and, when a tool call
+ or the reply ending a turn reaches a moment of work, the rules mounted on
+ it. Open the ones that apply; using-scribe says what a quiet turn means and
+ how to correct a moment that fired wrongly.
- **Compact at clean seams.** Because work is recorded as you go, a compaction
is safe once in-flight state is logged. After finishing a block of work in a
long session, log it to Scribe, then tell the operator it's a good moment to
diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md
index 850ffbf0..8fcba475 100644
--- a/plugin/skills/using-scribe/SKILL.md
+++ b/plugin/skills/using-scribe/SKILL.md
@@ -87,6 +87,12 @@ Two constraints on *how* that's achieved:
then the global rules plus that project's own, never another project's. `enter_project(id)` lists the project's own
rules by title.
+ **A rule about WHEN arrives at its moment, by lookup.** A rule mounted on a
+ moment of work (`list_moments`: `work.deliver`, `reply.report`, …) arrives
+ whenever an action reaches that moment, in a line naming both — nothing
+ said needs to resemble it. [moments.md](moments.md) says how to read those
+ lines, how to correct a moment that misfires, and when to mount one.
+
**`kind` says how much force a record carries, and it is never something to
infer.** A **rule** must be followed: ignoring it breaks something or
crosses a boundary. A **preference** records how the operator wants work
@@ -288,6 +294,9 @@ moment rather than on every turn:
before giving a note a check.
- [missed-retrieval.md](missed-retrieval.md) — a rule that missed the moment it
governed, or keeps arriving where it doesn't apply.
+- [moments.md](moments.md) — a line that says a rule arrived *at* a moment, a
+ reply held for one read, an action that reached the wrong moment or none,
+ and a line proposing a mount.
## You are the judge of what the record says
diff --git a/plugin/skills/using-scribe/missed-retrieval.md b/plugin/skills/using-scribe/missed-retrieval.md
index c062d255..affaa00a 100644
--- a/plugin/skills/using-scribe/missed-retrieval.md
+++ b/plugin/skills/using-scribe/missed-retrieval.md
@@ -11,6 +11,21 @@ does **either noticer**: the operator saying *"that should have fired"*, and you
noticing it yourself — you reached for a rule nobody offered you, or you were
handed the same rule five times and set it aside five times.
+**First ask whether it missed a WHEN or a WHAT.** A rule about a point in the
+work — it governs delivering, finishing, verifying, reporting, whatever the
+work is about — is not a trigger to reword: no wording resembles every piece
+of work that reaches that point. Mount it on the moment instead
+(`update_rule(moments=[...])`, from `list_moments`); a mount is a lookup, and
+it arrives every time the moment happens. When it is already mounted and still
+did not arrive, the action did not reach the moment on this install — offer
+`map_action` for that action. The reverse — a mounted rule arriving where it
+does not apply — is a mount or a mapping that is wrong: take the moment off
+the rule, or `unmap_action` the action that reached it. Each of these
+changes what the operator's sessions receive, so offer it in one line and make
+it on their yes. [moments.md](moments.md) has the
+detail. A rule about a subject missed its WHAT, and the rest of this page is
+for that.
+
**Take it to the record first and the dial second.** A rule's `when_to_apply`
IS the text its similarity score is computed against, so when a rule misses a
moment it governs, the overwhelmingly likely cause is that its trigger does not
diff --git a/plugin/skills/using-scribe/moments.md b/plugin/skills/using-scribe/moments.md
new file mode 100644
index 00000000..31ddb5eb
--- /dev/null
+++ b/plugin/skills/using-scribe/moments.md
@@ -0,0 +1,66 @@
+# Moments — rules that arrive when the work reaches a point
+
+Part of the using-scribe skill. Read it when a line says a rule arrived *at* a
+moment, when a reply is held for one read, when an action reached the wrong
+moment or none, and when a line proposes mounting a rule.
+
+## What a moment is
+
+Most rules reach you by resemblance: what you are doing looks like what the
+rule is about. A rule about WHEN — finishing, delivering, verifying,
+reporting — resembles nothing said at that point, so resemblance misses it.
+Such a rule is **mounted** on the moments of work it belongs to, and arrives
+by lookup whenever an action reaches one. `list_moments` names them, each
+with what is happening at it and the kinds of action that typically reach it:
+`work.deliver` when work is sent beyond the place it was made, `work.finish`
+when a piece of work is declared done, `reply.report` at the reply that ends a
+turn, and so on. A named procedure is its own moment, `skill.`, reached when it is
+loaded.
+
+Which ACTIONS reach a moment is the install's: one operator delivers with a
+push, another with a deploy script. Shipped defaults cover the common ones,
+and each install corrects them for itself.
+
+## Reading a line that arrived at a moment
+
+The line names the moment and the action that reached it — *"at work.deliver,
+reached by `git push`"* — and the rule, with `get_rule(N)` to read it. Read it
+as you would any rule that arrived beside your work: it binds just as hard,
+and it came because of what you are doing now, not because of what you said.
+
+The reply that ends a turn is a moment too. When a rule mounted at
+`reply.report` has not been opened this session, the reply may be held once
+with its name: open it, then send the reply — unchanged, if it already does
+what the rule asks. The same rule is never held twice.
+
+## When the moment is wrong — correct it in the session
+
+A moment can fire on an action that is not that moment here, or an action can
+plainly be a moment and fire nothing. Either way the fix is one call, and it
+belongs in the session that noticed, not on a settings page:
+
+- **An action reached the wrong moment** — the line names a moment that is not
+ what you did: offer `unmap_action(tool, moment, match, reason)`.
+- **An action was a moment and nothing arrived** — the operator ships with
+ their own script, and nothing mounted on `work.deliver` came:
+ offer `map_action(tool, moment, match, reason)`.
+
+Offer it in one line, the way the operator would say it ("that deploy script
+is a deliver and nothing fired — map it?"), and make it on their yes. The
+correction lasts for every later session on the install; `list_moments` shows
+what each action reaches now.
+
+## When a line proposes a mount
+
+A rule that keeps being opened just after the same moment, across several
+sessions, probably belongs on that moment. A line says so, naming the rule,
+the moment and how often. It is a question for the operator, not a change you
+make: offer it in one line, and record their answer with
+`judge_rule_moments` — `confirm` mounts the rule, `reject` with their reason
+stops the question being asked again.
+
+The same tool answers proposals from a pass over the rules
+(`rules_to_mount`, `propose_rule_moments`, `rule_moment_proposals`): a pass
+proposes, and only the operator's yes mounts. Writing a NEW rule is different
+— its moments are part of writing it, and
+[writing-records.md](writing-records.md) says how.
diff --git a/plugin/skills/using-scribe/writing-records.md b/plugin/skills/using-scribe/writing-records.md
index 1de8793e..854d4eee 100644
--- a/plugin/skills/using-scribe/writing-records.md
+++ b/plugin/skills/using-scribe/writing-records.md
@@ -1,13 +1,15 @@
# Writing a rule, a lesson, or a note that asserts a fact
Part of the using-scribe skill. Read it before `create_rule`,
-`create_project_rule`, `create_preference` or `create_lesson`; when a lesson
+`create_project_rule`, `create_preference`, `create_process` or
+`create_lesson`; when a lesson
arrives that names the situation you are actually in; when the operator
decides how some area of the work must behave; and before filling
`verify_with` or `expires_when` on a note.
## Contents
- Where a new rule goes — its home, its trigger, what already covers the moment
+- When it applies — the moments a rule, a preference or a process is for
- A ruling goes on the System it governs
- A lesson grows each time it proves itself
- A lesson names the rule it is an instance of
@@ -39,6 +41,22 @@ with no trigger is not a quiet rule, it is an unreachable one. Write the moment
in the words a session actually produces — the command, the error, the
half-formed ask — not the category it belongs to.
+**Then ask WHEN it applies, as well as what it is about.** A trigger is
+matched by resemblance, and a rule about a point in the work — finishing,
+delivering, verifying, reporting, asking — resembles nothing said at that
+point. Give such a rule its moments as you write it: `list_moments` names
+them, and `moments=[...]` on `create_rule`, `create_project_rule` or
+`create_preference` mounts it, so it arrives whenever an action reaches one.
+Keep the trigger anyway: it is the net for the moments nobody mapped. A rule
+about a subject — a library, a file, a style — has no moment; it is reached
+by meaning, and leaving `moments` empty is the answer, not an omission. A rule
+often has both: a point in the work, and the words a session uses at it.
+
+A stored **process** says the same about itself: `create_process(moments=…)`
+names the moments the procedure is for, so loading it reaches them and the
+rules mounted there arrive with it. A rule that only applies inside one
+procedure mounts on that procedure's own moment, `skill.`.
+
**Before writing one, ask what already covers that moment.**
`what_might_apply("the moment you are about to write a record for")` — fifty
candidates and no bar, so an existing record cannot hide under a threshold the
diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py
index 5b1d5ce2..1457a511 100644
--- a/src/scribe/mcp/server.py
+++ b/src/scribe/mcp/server.py
@@ -43,14 +43,15 @@ from quart import Quart
# decision #4027 and the notes it supersedes.
_INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting,
-record as you go, keep one copy here rather than in local memory files. Each
-practice below is stated in full in the using-scribe skill (if your client
-reads Agent Skills) and in each tool's description.
+record as you go, keep one copy here, not in local memory files. Each
+practice is stated in full in the using-scribe skill and each tool's
+description.
- Start with enter_project(id): the project, open work, Systems and design
system. An `inception` key: ask what it inherits, then
decide_project_inception.
-- Rules are not preloaded; one arrives when your work matches it. Before a
+- Rules are not preloaded; one arrives when your work matches it or reaches
+ a moment it is mounted on (list_moments; mount rules about WHEN). Before a
consequential act, what_might_apply("what you are about to do");
search(content_type="rule") reads one you suspect. Silence means nothing
matched, not none. Rules bind; preferences guide and you keep them current;
diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py
index 7fd8b707..fba0bfea 100644
--- a/tests/test_guidance_ownership.py
+++ b/tests/test_guidance_ownership.py
@@ -185,6 +185,19 @@ TOPICS: tuple[Topic, ...] = (
Topic("where a new rule goes, and its trigger", U, ("create_project_rule", "when_to_apply"),
"whichever home it gets"),
Topic("a rule vs the other entities", U, ("standing instruction",), "first ask whether it's a rule at all"),
+ # Moments (milestone 458 step 8). Delivery at a moment and its in-session
+ # corrections are owned by using-scribe's moments.md; the index carries one
+ # clause, and a new rule's moments are asked where its home and trigger are.
+ Topic("rules mounted on a moment arrive by lookup; correct a misfire in-session", U,
+ ("list_moments", "map_action", "unmap_action", "judge_rule_moments"),
+ "it belongs in the session that noticed, not on a settings page",
+ index=("list_moments", "moment")),
+ Topic("a new rule about when gets its moments as it is written", U,
+ ("moments=[...]", "create_process(moments="),
+ "then ask when it applies, as well as what it is about"),
+ Topic("a missed when is mounted or mapped, not reworded", U,
+ ("update_rule(moments=",),
+ "first ask whether it missed a when or a what"),
# Milestone 416 step 9: the tuning tools shipped in #4102/#4104 and were
# named on NO instruction surface — measured, `retrieval_tuning_history`
# returned zero events. Machinery with no route to it.
From 8afd6da8aff195aa4f677b0159e855a14720316e Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 5 Oct 2026 16:44:22 -0400
Subject: [PATCH 2/3] docs(plugin): reference files point at each other in
words, not links - one level deep (milestone 458 step 8, #4926)
Co-Authored-By: Claude Opus 5.5
---
plugin/skills/using-scribe/missed-retrieval.md | 4 ++--
plugin/skills/using-scribe/moments.md | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/plugin/skills/using-scribe/missed-retrieval.md b/plugin/skills/using-scribe/missed-retrieval.md
index affaa00a..92524151 100644
--- a/plugin/skills/using-scribe/missed-retrieval.md
+++ b/plugin/skills/using-scribe/missed-retrieval.md
@@ -22,8 +22,8 @@ did not arrive, the action did not reach the moment on this install — offer
does not apply — is a mount or a mapping that is wrong: take the moment off
the rule, or `unmap_action` the action that reached it. Each of these
changes what the operator's sessions receive, so offer it in one line and make
-it on their yes. [moments.md](moments.md) has the
-detail. A rule about a subject missed its WHAT, and the rest of this page is
+it on their yes. The skill's moments
+reference has the detail. A rule about a subject missed its WHAT, and the rest of this page is
for that.
**Take it to the record first and the dial second.** A rule's `when_to_apply`
diff --git a/plugin/skills/using-scribe/moments.md b/plugin/skills/using-scribe/moments.md
index 31ddb5eb..22840e8b 100644
--- a/plugin/skills/using-scribe/moments.md
+++ b/plugin/skills/using-scribe/moments.md
@@ -62,5 +62,5 @@ stops the question being asked again.
The same tool answers proposals from a pass over the rules
(`rules_to_mount`, `propose_rule_moments`, `rule_moment_proposals`): a pass
proposes, and only the operator's yes mounts. Writing a NEW rule is different
-— its moments are part of writing it, and
-[writing-records.md](writing-records.md) says how.
+— its moments are part of writing it, and the
+skill's guide to writing records says how.
From 4e1320120dd40f17801956587b1ec1479ccec8b1 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 5 Oct 2026 18:19:59 -0400
Subject: [PATCH 3/3] 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);
- Moments these rules may belong on. Mounting one makes the rule arrive whenever that
- moment happens, whatever the work is about; declining keeps it from being proposed again.
+ Moments these rules may belong on, or may not. Mounting one makes the rule arrive
+ whenever that moment happens, whatever the work is about; declining keeps it from being
+ proposed again. A mount that sessions keep reporting as beside the point can be taken
+ off — or, when one action is reaching the moment wrongly, unmapped in the list below.
@@ -133,6 +177,14 @@ onMounted(load);
.proposal-why { flex: 1 1 16rem; color: var(--fs-text-secondary); }
.proposal-source { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
.proposal-actions { display: inline-flex; gap: var(--fs-space-2); margin-left: auto; }
+.proposal-kind { font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
+.proposal-reasons {
+ flex-basis: 100%;
+ margin: 0;
+ padding-left: var(--fs-space-4);
+ font-size: var(--fs-size-tiny);
+ color: var(--fs-text-tertiary);
+}
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index 1c8d3f57..bc96d731 100644
--- a/plugin/.claude-plugin/plugin.json
+++ b/plugin/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
- "version": "2026.10.05.2039",
+ "version": "2026.10.05.2219",
"author": {
"name": "Bryan Van Deusen"
},
diff --git a/plugin/skills/using-scribe/missed-retrieval.md b/plugin/skills/using-scribe/missed-retrieval.md
index 92524151..2e42d598 100644
--- a/plugin/skills/using-scribe/missed-retrieval.md
+++ b/plugin/skills/using-scribe/missed-retrieval.md
@@ -22,7 +22,8 @@ did not arrive, the action did not reach the moment on this install — offer
does not apply — is a mount or a mapping that is wrong: take the moment off
the rule, or `unmap_action` the action that reached it. Each of these
changes what the operator's sessions receive, so offer it in one line and make
-it on their yes. The skill's moments
+it on their yes. When it is not plain enough to offer yet, `rule_misfired`
+records it, and repeated reports become the offer. The skill's moments
reference has the detail. A rule about a subject missed its WHAT, and the rest of this page is
for that.
diff --git a/plugin/skills/using-scribe/moments.md b/plugin/skills/using-scribe/moments.md
index 22840e8b..94e43683 100644
--- a/plugin/skills/using-scribe/moments.md
+++ b/plugin/skills/using-scribe/moments.md
@@ -2,7 +2,8 @@
Part of the using-scribe skill. Read it when a line says a rule arrived *at* a
moment, when a reply is held for one read, when an action reached the wrong
-moment or none, and when a line proposes mounting a rule.
+moment or none, when a mounted rule arrived and did not apply, and when a line
+proposes mounting or unmounting a rule.
## What a moment is
@@ -50,6 +51,26 @@ is a deliver and nothing fired — map it?"), and make it on their yes. The
correction lasts for every later session on the install; `list_moments` shows
what each action reaches now.
+## When a mounted rule arrives and does not apply
+
+A mount delivers its rule at every occurrence of the moment, so a mount that
+is wrong is noise every time — and a rule you read and set aside leaves no
+trace anyone else can see. When a rule arrived *at* a moment, you read it,
+and it has nothing to do with what you were doing there, say so with
+`rule_misfired(rule_id, moment, why, reached_by)`: one call, nothing asked of
+the operator. Name the action the line said reached the moment, and say what
+you were doing, because the reports are what the operator decides from —
+whether the rule does not belong at the moment, or one action is reaching it
+that is not that moment here.
+
+Reports gather across days. Once a rule has them on three, the response
+carries a line asking you to offer the fix, and the operator sees it under
+Settings › Moments too: `judge_rule_moments` `reject` takes the rule off,
+`unmap_action` stops the action reaching the moment, and `confirm` keeps the
+mount and ends the question. A rule that applied — even one you were already
+following — is not a misfire, and a rule that arrived by its words rather
+than a mount is fixed through its trigger instead.
+
## When a line proposes a mount
A rule that keeps being opened just after the same moment, across several
diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py
index 1457a511..7e4f628c 100644
--- a/src/scribe/mcp/server.py
+++ b/src/scribe/mcp/server.py
@@ -218,6 +218,9 @@ _WRITE_TOOLS = frozenset({
# Proposing moments for a rule writes a judgment row; judging one mounts
# or unmounts the rule (step 7).
"propose_rule_moments", "judge_rule_moments",
+ # A misfire report writes a counted row that can become an unmount
+ # proposal (step 7b).
+ "rule_misfired",
# A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
# prose the agent authored, `rule_outcome`'s reason for being a write.
"judge_menu",
diff --git a/src/scribe/mcp/tools/moments.py b/src/scribe/mcp/tools/moments.py
index 731a6ade..d943384c 100644
--- a/src/scribe/mcp/tools/moments.py
+++ b/src/scribe/mcp/tools/moments.py
@@ -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)
diff --git a/src/scribe/models/rule_moment_judgment.py b/src/scribe/models/rule_moment_judgment.py
index 2f83721e..8cdbf14d 100644
--- a/src/scribe/models/rule_moment_judgment.py
+++ b/src/scribe/models/rule_moment_judgment.py
@@ -60,6 +60,11 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
note: Mapped[str | None] = mapped_column(Text, nullable=True)
# The co-occurrence evidence (signal source), in lesson_rules' shape.
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ # The other direction (step 7b, migration 0119): sessions saying this
+ # MOUNTED rule arrived at the moment and did not apply. lesson_rules'
+ # evidence shape keyed per day, plus the reasons given and the actions
+ # that reached the moment; `kept_at` once the operator kept the mount.
+ misfire: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
judged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
@@ -76,6 +81,7 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
"source": self.source,
"note": self.note or "",
"evidence": self.evidence or {},
+ "misfire": self.misfire or {},
"judged_at": iso(self.judged_at),
"created_at": iso(self.created_at),
}
diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py
index 1bb8bfc4..4fd60a97 100644
--- a/src/scribe/services/backup.py
+++ b/src/scribe/services/backup.py
@@ -109,8 +109,11 @@ logger = logging.getLogger(__name__)
# proposals waiting on a mount, and the rejections and "no moment fits"
# answers that stop a pass or the open-after-moment signal proposing the same
# pair again. Losing them puts every answered question back on the list.
+# v24 (2026-10) added rule_moment_judgments.misfire (milestone 458 step 7b):
+# the reports that a mounted rule arrived where it did not apply, and the
+# operator's "keep it" that stops them being proposed as an unmount again.
# Bump when the serialized schema changes.
-BACKUP_VERSION = 23
+BACKUP_VERSION = 24
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -772,6 +775,7 @@ def _rule_moment_judgment_rows(rows) -> list[dict]:
{
"rule_id": r.rule_id, "moment": r.moment, "state": r.state,
"source": r.source, "note": r.note, "evidence": r.evidence,
+ "misfire": r.misfire,
"judged_at": r.judged_at.isoformat() if r.judged_at else None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
@@ -1580,6 +1584,8 @@ def _build_rule_moment_judgment(row: dict, maps: _Maps) -> RuleMomentJudgment |
source=row.get("source") or "pass",
note=row.get("note") or None,
evidence=row.get("evidence"),
+ # Archives before v24 carry no misfire reports.
+ misfire=row.get("misfire"),
# Absent stays absent: a suggestion was never judged.
judged_at=_dt_or_none(row.get("judged_at")),
created_at=_dt(row.get("created_at")),
diff --git a/src/scribe/services/lesson_rules.py b/src/scribe/services/lesson_rules.py
index 8c073f6f..eac1c2d3 100644
--- a/src/scribe/services/lesson_rules.py
+++ b/src/scribe/services/lesson_rules.py
@@ -486,17 +486,19 @@ def situation_key(arm: str, text: str) -> str:
"""A fingerprint of one situation, so a repeat counts once.
`arm` is part of the key — "p" for a prompt, "w" for a file being written,
- "s" for a session (the rule↔moment signal, milestone 458 step 7) —
+ "s" for a session (the rule↔moment signal, milestone 458 step 7), "d"
+ for a day (the misfire signal, step 7b, whose door carries no session) —
because each names a different kind of situation and must not collide.
On the prompt arm the text is the prompt: lowercased, split into word
tokens of _MIN_TOKEN or more, de-duplicated and sorted, so the same ask
re-sent with different spacing, punctuation, case or word order is one
situation. On the write arm the text is the PATH, not the code: every
edit to one file is one situation, however much the code differs between
- them. The session arm keys on the session id as given. Empty when there
- is nothing to key on.
+ them. The session and day arms key on the id or date as given — a date's
+ "10" and "05" are shorter than _MIN_TOKEN, so tokenising it would make
+ every day of a year one situation. Empty when there is nothing to key on.
"""
- if arm in ("w", "s"):
+ if arm in ("w", "s", "d"):
basis = (text or "").strip()
else:
tokens = sorted({t for t in re.findall(r"[a-z0-9]+", (text or "").lower())
diff --git a/src/scribe/services/moment_delivery.py b/src/scribe/services/moment_delivery.py
index 34c7148c..66f1c1ad 100644
--- a/src/scribe/services/moment_delivery.py
+++ b/src/scribe/services/moment_delivery.py
@@ -181,13 +181,22 @@ def reply_hold_reason(held: list[dict]) -> str:
trigger = f"; it applies when {item['trigger']}" if item.get("trigger") else ""
parts.append(f"“{item['title']}” ({why}{trigger}) — get_rule({item['rule_id']})")
noun = "a standing rule" if len(held) == 1 else "standing rules"
+ # A mount that does not bear on this reply is a misfire worth one call:
+ # reports gather into an unmount proposal for the operator (step 7b).
+ mounted = [item for item in held if item.get("moment")]
+ misfire = (
+ " If one mounted on a moment has nothing to do with this reply, "
+ f"rule_misfired({mounted[0]['rule_id']}, \"{mounted[0]['moment']}\", why) "
+ "records that."
+ if mounted else ""
+ )
return (
f"Held for one read before this reply goes out: {noun} this session "
f"has not opened: " + "; ".join(parts) + ". Read "
+ ("it" if len(held) == 1 else "them")
+ ", then send the reply — unchanged if it already does what the rule "
- "asks, which is a judgement only you can make. This check runs once "
- "per rule; the rewrite is never held."
+ "asks, which is a judgement only you can make." + misfire
+ + " This check runs once per rule; the rewrite is never held."
)
diff --git a/src/scribe/services/rule_moment_judgments.py b/src/scribe/services/rule_moment_judgments.py
index 3eced3b3..12243afc 100644
--- a/src/scribe/services/rule_moment_judgments.py
+++ b/src/scribe/services/rule_moment_judgments.py
@@ -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
diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py
index fba0bfea..897ddf19 100644
--- a/tests/test_guidance_ownership.py
+++ b/tests/test_guidance_ownership.py
@@ -195,6 +195,9 @@ TOPICS: tuple[Topic, ...] = (
Topic("a new rule about when gets its moments as it is written", U,
("moments=[...]", "create_process(moments="),
"then ask when it applies, as well as what it is about"),
+ Topic("a mount that arrives where it does not apply is reported, and reports become the offer", U,
+ ("rule_misfired",),
+ "a rule you read and set aside leaves no trace anyone else can see"),
Topic("a missed when is mounted or mapped, not reworded", U,
("update_rule(moments=",),
"first ask whether it missed a when or a what"),
diff --git a/tests/test_integration_rule_moment_judgments.py b/tests/test_integration_rule_moment_judgments.py
index eba4f22c..691d3eb4 100644
--- a/tests/test_integration_rule_moment_judgments.py
+++ b/tests/test_integration_rule_moment_judgments.py
@@ -7,7 +7,10 @@ What the step promises, against the real tables:
pair is never proposed again by either source;
- an edit that takes a moment off a rule is a rejection the signal respects;
- the signal counts SESSIONS, not opens, and asks once the bar is crossed;
-- only the owner can propose, judge, or feed the signal.
+- only the owner can propose, judge, or feed the signal;
+- the misfire (step 7b): only a mount can misfire, reports count DAYS, the
+ bar makes an unmount proposal, reject takes it off, confirm keeps it and
+ stops the asking, and a re-mount starts the count again.
"""
import pytest
import pytest_asyncio
@@ -191,3 +194,139 @@ async def test_the_open_resolves_acts_through_the_installs_mappings(world):
assert "work.deliver" in out["moments"]
assert "work.run" not in out["moments"]
assert (await _row(done.id, "work.deliver")).source == "signal"
+
+
+# ── The misfire (step 7b) ────────────────────────────────────────────────────
+
+
+@pytest.fixture
+def days(monkeypatch):
+ """Each report lands on the day named next, so a test can cross days
+ without waiting for them. `misfire_situation` is the one place a day
+ becomes a situation, so this is what a real date change does."""
+ queue: list[str] = []
+
+ def situation(_now):
+ from scribe.services import lesson_rules
+ return lesson_rules.situation_key("d", queue.pop(0))
+
+ monkeypatch.setattr(judgments_svc, "misfire_situation", situation)
+ return queue
+
+
+async def _misfire(uid, rule_id, moment="work.finish", why="closing a docs-only task",
+ reached_by="status=done"):
+ return await judgments_svc.misfired(uid, rule_id, moment, why=why, reached_by=reached_by)
+
+
+async def test_only_a_mount_can_misfire_and_only_with_a_reason(world, days):
+ uid, sid, done = world["uid"], world["sid"], world["done"]
+ out = await _misfire(uid, done.id)
+ assert not out["recorded"] and "not mounted on work.finish" in out["error"]
+ assert "when_to_apply" in out["error"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ out = await judgments_svc.misfired(uid, done.id, "work.finish", why=" ")
+ assert not out["recorded"] and "why" in out["error"]
+ out = await judgments_svc.misfired(uid, done.id, "work.finished", why="x")
+ assert not out["recorded"] and "work.finished" in out["error"]
+ out = await _misfire(sid, done.id)
+ assert out == {"recorded": False, "error": "not a rule you own"}
+ assert (await _row(done.id, "work.finish")).misfire is None
+
+
+async def test_misfires_count_days_and_propose_the_unmount(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ days.extend(["2026-10-01", "2026-10-01", "2026-10-02"])
+ for _ in range(3):
+ out = await _misfire(uid, done.id)
+ assert out["recorded"] and out["context"] == ""
+ assert out["days"] == 2 and out["bar"] == 3
+ assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
+
+ days.append("2026-10-03")
+ out = await _misfire(uid, done.id, why="closing a planning stub",
+ reached_by="status=cancelled")
+ line = out["context"]
+ assert out["days"] == 3
+ assert f"#{done.id}" in line and "`work.finish`" in line and "3 distinct days" in line
+ assert "`status=done` ×3" in line and "`status=cancelled` ×1" in line
+ assert '"verdict": "reject"' in line and "unmap_action" in line and '"confirm"' in line
+ # Asked once; the cooldown keeps the next report quiet.
+ days.append("2026-10-04")
+ assert (await _misfire(uid, done.id))["context"] == ""
+
+ [entry] = (await judgments_svc.pending(uid, rule_id=done.id))["rules"]
+ assert entry["mounted"] == ["work.finish"]
+ [proposal] = entry["proposals"]
+ assert (proposal["proposal"], proposal["source"], proposal["moment"]) == (
+ "unmount", "misfire", "work.finish")
+ assert proposal["evidence"]["situations"] == 4
+ assert proposal["reached_by"] == {"status=done": 4, "status=cancelled": 1}
+ assert len(proposal["reasons"]) == judgments_svc.MISFIRE_REASONS
+ assert proposal["why"] == "closing a docs-only task"
+
+
+async def _past_the_bar(uid, rule_id, days, moment="work.finish"):
+ days.extend(["2026-10-01", "2026-10-02", "2026-10-03"])
+ for _ in range(3):
+ out = await _misfire(uid, rule_id, moment=moment)
+ assert out["context"]
+
+
+async def test_reject_takes_the_mount_off(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish", "reply.report"])
+ await _past_the_bar(uid, done.id, days)
+ out = await judgments_svc.judge(uid, [
+ {"rule_id": done.id, "moment": "work.finish", "verdict": "reject", "note": "not at task close"},
+ ])
+ assert out["refused"] == []
+ assert (await rulebooks_svc.list_rule_moments([done.id]))[done.id] == ["reply.report"]
+ assert (await _row(done.id, "work.finish")).state == "rejected"
+ assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
+ # And it cannot misfire where it no longer is.
+ assert not (await _misfire(uid, done.id))["recorded"]
+
+
+async def test_confirm_keeps_the_mount_and_stops_the_asking(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ await _past_the_bar(uid, done.id, days)
+ await judgments_svc.judge(uid, [
+ {"rule_id": done.id, "moment": "work.finish", "verdict": "confirm", "note": "it does belong"},
+ ])
+ assert (await rulebooks_svc.list_rule_moments([done.id]))[done.id] == ["work.finish"]
+ row = await _row(done.id, "work.finish")
+ assert row.state == "confirmed" and row.misfire["kept_note"] == "it does belong"
+ assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
+ # Reports still count, and ask nothing — past the cooldown too.
+ days.extend(["2026-10-10", "2026-10-11"])
+ for _ in range(2):
+ out = await _misfire(uid, done.id)
+ assert out["recorded"] and out["context"] == ""
+ assert out["kept"]["note"] == "it does belong"
+
+
+async def test_a_remount_starts_the_count_again(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ await _past_the_bar(uid, done.id, days)
+ await rulebooks_svc.set_rule_moments(done.id, uid, [])
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ row = await _row(done.id, "work.finish")
+ assert row.state == "confirmed" and row.misfire is None
+
+
+async def test_a_mount_with_no_judgment_row_gets_one_on_its_first_misfire(world, days):
+ """Mounts made before migration 0118 have no row; a misfire must still count."""
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ async with async_session() as s:
+ await s.delete(await s.get(RuleMomentJudgment, (await _row(done.id, "work.finish")).id))
+ await s.commit()
+ days.append("2026-10-01")
+ assert (await _misfire(uid, done.id))["recorded"]
+ row = await _row(done.id, "work.finish")
+ assert (row.state, row.source) == ("confirmed", "edit")
+ assert len(row.misfire["situations"]) == 1
diff --git a/tests/test_integration_rule_moments.py b/tests/test_integration_rule_moments.py
index 9f7ce1f3..8b883453 100644
--- a/tests/test_integration_rule_moments.py
+++ b/tests/test_integration_rule_moments.py
@@ -117,6 +117,14 @@ async def test_the_detail_carries_the_mounts_and_omits_an_empty_set(world):
async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
uid, rule = world["uid"], world["rule"]
await rulebooks_svc.set_rule_moments(rule.id, uid, ["work.finish", "reply.report"])
+ # A misfire report on one of them (v24) — the operator's evidence for
+ # taking a mount off must not be lost to a restore.
+ from scribe.services import rule_moment_judgments as judgments_svc
+ reported = await judgments_svc.misfired(
+ uid, rule.id, "reply.report", why="the reply only asked a question",
+ reached_by="the reply that ends this turn",
+ )
+ assert reported["recorded"]
async with async_session() as s:
payload = {
@@ -172,9 +180,14 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
]
async with async_session() as s:
judged = (await s.execute(
- select(RuleMomentJudgment.moment).where(RuleMomentJudgment.rule_id == restored_rule.id)
+ select(RuleMomentJudgment).where(RuleMomentJudgment.rule_id == restored_rule.id)
)).scalars().all()
- assert sorted(judged) == ["reply.report", "work.finish"]
+ assert sorted(j.moment for j in judged) == ["reply.report", "work.finish"]
+ misfire = {j.moment: j.misfire for j in judged}
+ assert misfire["work.finish"] is None
+ assert [r["why"] for r in misfire["reply.report"]["reasons"]] == [
+ "the reply only asked a question",
+ ]
async def _purge_projects(username: str) -> None:
diff --git a/tests/test_moments.py b/tests/test_moments.py
index c4eab398..505c26e8 100644
--- a/tests/test_moments.py
+++ b/tests/test_moments.py
@@ -118,12 +118,12 @@ def test_the_tools_are_registered_and_classified():
assert mcp.names == [
"list_moments", "map_action", "unmap_action",
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals",
- "judge_rule_moments",
+ "judge_rule_moments", "rule_misfired",
]
assert {"list_moments", "rules_to_mount", "rule_moment_proposals"} <= _READ_ONLY_TOOLS
# A confirm mounts a rule, so a read key must not reach it.
assert {"map_action", "unmap_action",
- "propose_rule_moments", "judge_rule_moments"} <= _WRITE_TOOLS
+ "propose_rule_moments", "judge_rule_moments", "rule_misfired"} <= _WRITE_TOOLS
def test_both_doors_read_one_catalog():
diff --git a/tests/test_rule_moment_judgments.py b/tests/test_rule_moment_judgments.py
index 611fb051..2084dccf 100644
--- a/tests/test_rule_moment_judgments.py
+++ b/tests/test_rule_moment_judgments.py
@@ -8,6 +8,7 @@ the signal hands a session names the tool that answers it.
from __future__ import annotations
import importlib.util
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
@@ -58,3 +59,46 @@ def test_the_proposal_line_names_the_tool_that_answers_it():
assert "`work.verify`" in line and "3 distinct sessions" in line
assert '"rule_id": 11' in line and '"moment": "work.verify"' in line
assert "judge_rule_moments" in line and '"reject"' in line
+
+
+# ── The misfire (step 7b) ────────────────────────────────────────────────────
+
+
+def test_a_day_is_one_situation_and_days_are_distinct():
+ """The day arm keys verbatim: tokenised, "10" and "05" fall under the
+ minimum token length and every day of a year would be one situation."""
+ a = datetime(2026, 10, 5, 1, tzinfo=timezone.utc)
+ assert svc.misfire_situation(a) == svc.misfire_situation(a + timedelta(hours=20))
+ assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=1))
+ assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=31))
+
+
+def test_a_misfire_counts_reasons_and_actions():
+ now = datetime(2026, 10, 5, tzinfo=timezone.utc)
+ mf = None
+ for i in range(svc.MISFIRE_REASONS + 2):
+ mf = svc.add_misfire(mf, svc.misfire_situation(now + timedelta(days=i)), 7, now,
+ why=f"reason {i}", reached_by="git push" if i % 2 else "make ship")
+ assert len(mf["situations"]) == svc.MISFIRE_REASONS + 2
+ assert [r["why"] for r in mf["reasons"]][-1] == f"reason {svc.MISFIRE_REASONS + 1}"
+ assert len(mf["reasons"]) == svc.MISFIRE_REASONS
+ assert mf["reached_by"] == {"make ship": 4, "git push": 3}
+ assert mf["projects"] == [7]
+
+
+def test_the_unmount_line_offers_both_fixes():
+ rule = SimpleNamespace(id=10, title="CI verifies", kind="rule")
+ mf = {"situations": ["a", "b", "c"], "reached_by": {"actions_run_read": 3}}
+ line = svc._unmount_line(rule, "work.verify", mf)
+ assert line.startswith("> Rule #10") and "3 distinct days" in line
+ assert "`actions_run_read` ×3" in line
+ assert '"verdict": "reject"' in line and "unmap_action" in line and '"confirm"' in line
+
+
+def test_a_held_reply_names_the_misfire_call_only_for_a_mount():
+ from scribe.services.moment_delivery import reply_hold_reason
+
+ mounted = {"rule_id": 11, "title": "Definition of done", "moment": "reply.report", "trigger": ""}
+ scored = {"rule_id": 12, "title": "Other", "moment": "", "score": 0.81, "trigger": ""}
+ assert 'rule_misfired(11, "reply.report", why)' in reply_hold_reason([mounted])
+ assert "rule_misfired" not in reply_hold_reason([scored])
diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py
index 66d12c2b..5c8e159b 100644
--- a/tests/test_services_backup.py
+++ b/tests/test_services_backup.py
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
- assert backup.BACKUP_VERSION == 23
+ assert backup.BACKUP_VERSION == 24
def _exportable_note(**over):