Merge pull request 'Moments: skills teach moments, and a mount that misfires proposes its own removal (milestone 458 steps 8, 7b)' (#204) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 26s

This commit was merged in pull request #204.
This commit is contained in:
2026-10-05 18:29:53 -04:00
22 changed files with 765 additions and 49 deletions
@@ -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")
+21 -5
View File
@@ -101,19 +101,32 @@ export function unmapAction(change: MappingChange): Promise<void> {
} }
/** /**
* Proposals that a rule belongs on a moment (milestone 458 step 7) — from a * Proposals about a rule's moments, waiting on a person. A `mount` proposal
* pass that read the rule, or from the rule being opened just after the * (milestone 458 step 7) comes from a pass that read the rule, or from the
* moment fired. `GET /api/retrieval/moments/proposals`, the payload 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. * `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 { export interface MomentProposal {
proposal: "mount" | "unmount";
moment: string; moment: string;
source: ProposalSource; source: ProposalSource;
why: string; why: string;
/** For a misfire, `situations` counts distinct days and `co_surfaced` the reports. */
evidence: { situations: number; projects: number; co_surfaced: number }; evidence: { situations: number; projects: number; co_surfaced: number };
created_at: string | null; created_at: string | null;
/** Unmount proposals only: the newest reasons given, and how often each action reached the moment. */
reasons?: MisfireReason[];
reached_by?: Record<string, number>;
} }
export interface RuleProposals { export interface RuleProposals {
@@ -151,7 +164,10 @@ export function getProposals(ruleId?: number): Promise<ProposalsPayload> {
return apiGet(`/api/retrieval/moments/proposals${ruleId ? `?rule_id=${ruleId}` : ""}`); 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<JudgeResult> { export function judgeProposals(judgments: MomentJudgment[]): Promise<JudgeResult> {
return apiPost("/api/retrieval/moments/proposals/judge", { judgments }); return apiPost("/api/retrieval/moments/proposals/judge", { judgments });
} }
+64 -12
View File
@@ -7,11 +7,13 @@ import { useMomentsStore } from "@/stores/moments";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
/** /**
* Proposals that a rule belongs on a moment, waiting on a person (milestone * Proposals about a rule's moments, waiting on a person. A mount proposal
* 458 step 7). They come from a pass that read the rule, or from the rule * (milestone 458 step 7) comes from a pass that read the rule, or from the
* being opened just after the moment fired in several sessions. Nothing here * rule being opened just after the moment fired in several sessions; nothing
* is mounted until "Mount" is pressed — the same judgment `judge_rule_moments` * is mounted until "Mount" is pressed. An unmount proposal (step 7b) comes
* makes in a session, through the same service. * 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 store = useMomentsStore();
const toast = useToastStore(); const toast = useToastStore();
@@ -35,27 +37,48 @@ function key(rule: RuleProposals, p: MomentProposal): string {
return `${rule.id}:${p.moment}`; 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 { 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") { if (p.source === "signal") {
return `opened just after this moment in ${p.evidence.situations} sessions`; return `opened just after this moment in ${p.evidence.situations} sessions`;
} }
return "proposed from the rule's text"; 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) { async function decide(rule: RuleProposals, p: MomentProposal, verdict: Verdict) {
busy.value = key(rule, p); busy.value = key(rule, p);
try { try {
const out = await judgeProposals([{ const out = await judgeProposals([{
rule_id: rule.id, moment: p.moment, verdict, rule_id: rule.id, moment: p.moment, verdict, note: note(p, verdict),
note: verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.",
}]); }]);
if (out.refused.length) { if (out.refused.length) {
toast.show(out.refused[0].error, "error"); toast.show(out.refused[0].error, "error");
return; return;
} }
await load(); await load();
// A mount changes the per-moment counts the list below shows. // A mount or an unmount changes the per-moment counts the list below shows.
if (verdict === "confirm") await store.load(true); if ((p.proposal === "unmount") === (verdict === "reject")) await store.load(true);
} catch (e) { } catch (e) {
toast.show(apiErrorMessage(e, "Could not record that"), "error"); toast.show(apiErrorMessage(e, "Could not record that"), "error");
} finally { } finally {
@@ -75,8 +98,10 @@ onMounted(load);
</p> </p>
<template v-else> <template v-else>
<p class="field-hint"> <p class="field-hint">
Moments these rules may belong on. Mounting one makes the rule arrive whenever that Moments these rules may belong on, or may not. Mounting one makes the rule arrive
moment happens, whatever the work is about; declining keeps it from being proposed again. 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.
</p> </p>
<ul class="proposal-list"> <ul class="proposal-list">
<li v-for="rule in rules" :key="rule.id" class="proposal-rule"> <li v-for="rule in rules" :key="rule.id" class="proposal-rule">
@@ -89,12 +114,28 @@ onMounted(load);
<p class="rule-statement">{{ rule.statement }}</p> <p class="rule-statement">{{ rule.statement }}</p>
<ul class="proposal-items"> <ul class="proposal-items">
<li v-for="p in rule.proposals" :key="key(rule, p)" class="proposal-item"> <li v-for="p in rule.proposals" :key="key(rule, p)" class="proposal-item">
<span v-if="p.proposal === 'unmount'" class="proposal-kind">take off</span>
<code class="moment-name">{{ p.moment }}</code> <code class="moment-name">{{ p.moment }}</code>
<span class="proposal-why"> <span class="proposal-why">
{{ p.why || sourceLabel(p) }} {{ p.why || sourceLabel(p) }}
<span v-if="p.why" class="proposal-source">· {{ sourceLabel(p) }}</span> <span v-if="p.why" class="proposal-source">· {{ sourceLabel(p) }}</span>
<span v-if="actions(p).length" class="proposal-source">
· reached by {{ actions(p).join(", ") }}
</span>
</span> </span>
<span class="proposal-actions"> <span v-if="p.proposal === 'unmount'" class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Take it off</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Keep it</button>
</span>
<span v-else class="proposal-actions">
<button <button
type="button" class="btn-primary btn-sm" type="button" class="btn-primary btn-sm"
:disabled="busy !== null" :disabled="busy !== null"
@@ -106,6 +147,9 @@ onMounted(load);
@click="decide(rule, p, 'reject')" @click="decide(rule, p, 'reject')"
>Not this</button> >Not this</button>
</span> </span>
<ul v-if="p.reasons && p.reasons.length > 1" class="proposal-reasons">
<li v-for="r in p.reasons.slice(0, -1).reverse()" :key="r.at">{{ r.why }}</li>
</ul>
</li> </li>
</ul> </ul>
</li> </li>
@@ -133,6 +177,14 @@ onMounted(load);
.proposal-why { flex: 1 1 16rem; color: var(--fs-text-secondary); } .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-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-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);
}
</style> </style>
<style src="@/assets/moments-shared.css" /> <style src="@/assets/moments-shared.css" />
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "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).", "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.2219",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+4 -2
View File
@@ -16,8 +16,10 @@ What only Claude Code needs said:
Scribe works alongside them. Scribe works alongside them.
- **Lines injected beside your work are retrieval.** When the operator sends a - **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, 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 notes and prior art that resemble what you are doing — and, when a tool call
apply; using-scribe says what a quiet turn means. 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 - **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 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 long session, log it to Scribe, then tell the operator it's a good moment to
+9
View File
@@ -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 then the global rules plus that project's own, never another project's. `enter_project(id)` lists the project's own
rules by title. 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 **`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 infer.** A **rule** must be followed: ignoring it breaks something or
crosses a boundary. A **preference** records how the operator wants work 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. before giving a note a check.
- [missed-retrieval.md](missed-retrieval.md) — a rule that missed the moment it - [missed-retrieval.md](missed-retrieval.md) — a rule that missed the moment it
governed, or keeps arriving where it doesn't apply. 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 ## You are the judge of what the record says
@@ -11,6 +11,22 @@ 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 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. 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. 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.
**Take it to the record first and the dial second.** A rule's `when_to_apply` **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 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 moment it governs, the overwhelmingly likely cause is that its trigger does not
+87
View File
@@ -0,0 +1,87 @@
# 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, when a mounted rule arrived and did not apply, and when a line
proposes mounting or unmounting 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.<name>`, 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 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
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 the
skill's guide to writing records says how.
+19 -1
View File
@@ -1,13 +1,15 @@
# Writing a rule, a lesson, or a note that asserts a fact # Writing a rule, a lesson, or a note that asserts a fact
Part of the using-scribe skill. Read it before `create_rule`, 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 arrives that names the situation you are actually in; when the operator
decides how some area of the work must behave; and before filling decides how some area of the work must behave; and before filling
`verify_with` or `expires_when` on a note. `verify_with` or `expires_when` on a note.
## Contents ## Contents
- Where a new rule goes — its home, its trigger, what already covers the moment - 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 ruling goes on the System it governs
- A lesson grows each time it proves itself - A lesson grows each time it proves itself
- A lesson names the rule it is an instance of - 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 in the words a session actually produces — the command, the error, the
half-formed ask — not the category it belongs to. 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.<name>`.
**Before writing one, ask what already covers that moment.** **Before writing one, ask what already covers that moment.**
`what_might_apply("the moment you are about to write a record for")` — fifty `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 candidates and no bar, so an existing record cannot hide under a threshold the
+8 -4
View File
@@ -43,14 +43,15 @@ from quart import Quart
# decision #4027 and the notes it supersedes. # decision #4027 and the notes it supersedes.
_INSTRUCTIONS = """ _INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting, 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 record as you go, keep one copy here, not in local memory files. Each
practice below is stated in full in the using-scribe skill (if your client practice is stated in full in the using-scribe skill and each tool's
reads Agent Skills) and in each tool's description. description.
- Start with enter_project(id): the project, open work, Systems and design - Start with enter_project(id): the project, open work, Systems and design
system. An `inception` key: ask what it inherits, then system. An `inception` key: ask what it inherits, then
decide_project_inception. 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"); consequential act, what_might_apply("what you are about to do");
search(content_type="rule") reads one you suspect. Silence means nothing search(content_type="rule") reads one you suspect. Silence means nothing
matched, not none. Rules bind; preferences guide and you keep them current; matched, not none. Rules bind; preferences guide and you keep them current;
@@ -217,6 +218,9 @@ _WRITE_TOOLS = frozenset({
# Proposing moments for a rule writes a judgment row; judging one mounts # Proposing moments for a rule writes a judgment row; judging one mounts
# or unmounts the rule (step 7). # or unmounts the rule (step 7).
"propose_rule_moments", "judge_rule_moments", "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 # A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
# prose the agent authored, `rule_outcome`'s reason for being a write. # prose the agent authored, `rule_outcome`'s reason for being a write.
"judge_menu", "judge_menu",
+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: async def rule_moment_proposals(rule_id: int = 0) -> dict:
"""The moment proposals waiting on the operator, grouped by rule. """The moment proposals waiting on the operator, grouped by rule.
Each rule carries what it is mounted on now and its proposals: the moment, Each rule carries what it is mounted on now and its proposals. Each
where the proposal came from (`pass` — read from the rule; `signal` — the proposal is a `mount` or an `unmount`, with the moment, where it came
rule kept being opened just after that moment fired), the reason, and the from (`pass` — read from the rule; `signal` — the rule kept being opened
evidence counts. `rule_id` narrows to one rule. 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) 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": Each item is `{"rule_id": N, "moment": "work.finish", "verdict":
"confirm" | "reject", "note": "why"}`. Confirm mounts the rule on that "confirm" | "reject", "note": "why"}`. Confirm mounts the rule on that
moment beside what it already has; reject records that it does not belong moment beside what it already has — on an `unmount` proposal, it keeps
there (and unmounts it if it was mounted), so neither the pass nor the the mount and stops the misfire question; reject records that it does not
signal proposes the pair again. Moment `""` judges a "no moment fits" 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`. answer. Put the operator's reason in `note`.
""" """
return await judgments_svc.judge(current_user_id(), judgments) 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: def register(mcp) -> None:
mcp.tool(name="list_moments")(list_moments) mcp.tool(name="list_moments")(list_moments)
mcp.tool(name="map_action")(map_action) 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="propose_rule_moments")(propose_rule_moments)
mcp.tool(name="rule_moment_proposals")(rule_moment_proposals) mcp.tool(name="rule_moment_proposals")(rule_moment_proposals)
mcp.tool(name="judge_rule_moments")(judge_rule_moments) mcp.tool(name="judge_rule_moments")(judge_rule_moments)
mcp.tool(name="rule_misfired")(rule_misfired)
@@ -60,6 +60,11 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
note: Mapped[str | None] = mapped_column(Text, nullable=True) note: Mapped[str | None] = mapped_column(Text, nullable=True)
# The co-occurrence evidence (signal source), in lesson_rules' shape. # The co-occurrence evidence (signal source), in lesson_rules' shape.
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True) 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( judged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, DateTime(timezone=True), nullable=True,
) )
@@ -76,6 +81,7 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
"source": self.source, "source": self.source,
"note": self.note or "", "note": self.note or "",
"evidence": self.evidence or {}, "evidence": self.evidence or {},
"misfire": self.misfire or {},
"judged_at": iso(self.judged_at), "judged_at": iso(self.judged_at),
"created_at": iso(self.created_at), "created_at": iso(self.created_at),
} }
+7 -1
View File
@@ -109,8 +109,11 @@ logger = logging.getLogger(__name__)
# proposals waiting on a mount, and the rejections and "no moment fits" # 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 # 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. # 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. # 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 # 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 # 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, "rule_id": r.rule_id, "moment": r.moment, "state": r.state,
"source": r.source, "note": r.note, "evidence": r.evidence, "source": r.source, "note": r.note, "evidence": r.evidence,
"misfire": r.misfire,
"judged_at": r.judged_at.isoformat() if r.judged_at else None, "judged_at": r.judged_at.isoformat() if r.judged_at else None,
"created_at": r.created_at.isoformat() if r.created_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", source=row.get("source") or "pass",
note=row.get("note") or None, note=row.get("note") or None,
evidence=row.get("evidence"), evidence=row.get("evidence"),
# Archives before v24 carry no misfire reports.
misfire=row.get("misfire"),
# Absent stays absent: a suggestion was never judged. # Absent stays absent: a suggestion was never judged.
judged_at=_dt_or_none(row.get("judged_at")), judged_at=_dt_or_none(row.get("judged_at")),
created_at=_dt(row.get("created_at")), created_at=_dt(row.get("created_at")),
+6 -4
View File
@@ -486,17 +486,19 @@ def situation_key(arm: str, text: str) -> str:
"""A fingerprint of one situation, so a repeat counts once. """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, `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. 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 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 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 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 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 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 them. The session and day arms key on the id or date as given — a date's
is nothing to key on. "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() basis = (text or "").strip()
else: else:
tokens = sorted({t for t in re.findall(r"[a-z0-9]+", (text or "").lower()) tokens = sorted({t for t in re.findall(r"[a-z0-9]+", (text or "").lower())
+11 -2
View File
@@ -181,13 +181,22 @@ def reply_hold_reason(held: list[dict]) -> str:
trigger = f"; it applies when {item['trigger']}" if item.get("trigger") else "" trigger = f"; it applies when {item['trigger']}" if item.get("trigger") else ""
parts.append(f"“{item['title']}” ({why}{trigger}) — get_rule({item['rule_id']})") parts.append(f"“{item['title']}” ({why}{trigger}) — get_rule({item['rule_id']})")
noun = "a standing rule" if len(held) == 1 else "standing rules" 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 ( return (
f"Held for one read before this reply goes out: {noun} this session " f"Held for one read before this reply goes out: {noun} this session "
f"has not opened: " + "; ".join(parts) + ". Read " f"has not opened: " + "; ".join(parts) + ". Read "
+ ("it" if len(held) == 1 else "them") + ("it" if len(held) == 1 else "them")
+ ", then send the reply — unchanged if it already does what the rule " + ", 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 " "asks, which is a judgement only you can make." + misfire
"per rule; the rewrite is never held." + " This check runs once per rule; the rewrite is never held."
) )
+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 would manufacture the opens it counts, and the operator's corpus is theirs to
mount. 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 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 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 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. # propose every rule onto both.
SIGNAL_SKIP = frozenset({"work.run", "work.change"}) 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: def _clean_moment(name) -> str:
"""A catalog moment, normalised; "" stays "" (no moment fits).""" """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": [], "proposals": [],
}) })
entry["proposals"].append({ entry["proposals"].append({
"proposal": "mount",
"moment": judgment.moment, "moment": judgment.moment,
"source": judgment.source, "source": judgment.source,
"why": judgment.note or "", "why": judgment.note or "",
"evidence": lesson_rules.evidence_summary(judgment.evidence), "evidence": lesson_rules.evidence_summary(judgment.evidence),
"created_at": judgment.created_at.isoformat() if judgment.created_at else None, "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)} 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: async def judge(user_id: int, judgments: list[dict]) -> dict:
"""Confirm or reject proposals — or any (rule, moment) pair, proposed or not. """Confirm or reject proposals — or any (rule, moment) pair, proposed or not.
Confirm MOUNTS the rule on the moment (alongside what it already has); 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, on a pair already mounted, it KEEPS the mount and answers a misfire
unmounts it. Both go through `rulebooks.set_rule_moments`, the one write 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 "" 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. 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: for moment in added:
put(moment, CONFIRMED, _MOUNTED_NOTE) 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: for moment in removed:
put(moment, REJECTED, _UNMOUNTED_NOTE) put(moment, REJECTED, _UNMOUNTED_NOTE)
for moment in explicit: for moment in explicit:
if verdict in (CONFIRMED, REJECTED): if verdict in (CONFIRMED, REJECTED):
put(moment, verdict, "") put(moment, verdict, "")
if verdict == CONFIRMED and moment in after:
_keep(rows[(rule_id, moment)], note, now)
if after: if after:
none_row = rows.get((rule_id, NO_MOMENT)) none_row = rows.get((rule_id, NO_MOMENT))
if none_row is not None and none_row.state == CONFIRMED: 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 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 ─────────────────────────────────────────────────────────────── # ── The signal ───────────────────────────────────────────────────────────────
@@ -525,3 +611,114 @@ async def opened_after(
user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id, user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id,
) )
return {"moments": moments, "context": context} 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
+16
View File
@@ -185,6 +185,22 @@ TOPICS: tuple[Topic, ...] = (
Topic("where a new rule goes, and its trigger", U, ("create_project_rule", "when_to_apply"), Topic("where a new rule goes, and its trigger", U, ("create_project_rule", "when_to_apply"),
"whichever home it gets"), "whichever home it gets"),
Topic("a rule vs the other entities", U, ("standing instruction",), "first ask whether it's a rule at all"), 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 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"),
# Milestone 416 step 9: the tuning tools shipped in #4102/#4104 and were # Milestone 416 step 9: the tuning tools shipped in #4102/#4104 and were
# named on NO instruction surface — measured, `retrieval_tuning_history` # named on NO instruction surface — measured, `retrieval_tuning_history`
# returned zero events. Machinery with no route to it. # returned zero events. Machinery with no route to it.
+140 -1
View File
@@ -7,7 +7,10 @@ What the step promises, against the real tables:
pair is never proposed again by either source; pair is never proposed again by either source;
- an edit that takes a moment off a rule is a rejection the signal respects; - 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; - 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
import pytest_asyncio 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.deliver" in out["moments"]
assert "work.run" not in out["moments"] assert "work.run" not in out["moments"]
assert (await _row(done.id, "work.deliver")).source == "signal" 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
+15 -2
View File
@@ -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): async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
uid, rule = world["uid"], world["rule"] uid, rule = world["uid"], world["rule"]
await rulebooks_svc.set_rule_moments(rule.id, uid, ["work.finish", "reply.report"]) 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: async with async_session() as s:
payload = { payload = {
@@ -172,9 +180,14 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
] ]
async with async_session() as s: async with async_session() as s:
judged = (await s.execute( 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() )).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: async def _purge_projects(username: str) -> None:
+2 -2
View File
@@ -118,12 +118,12 @@ def test_the_tools_are_registered_and_classified():
assert mcp.names == [ assert mcp.names == [
"list_moments", "map_action", "unmap_action", "list_moments", "map_action", "unmap_action",
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals", "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 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. # A confirm mounts a rule, so a read key must not reach it.
assert {"map_action", "unmap_action", 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(): def test_both_doors_read_one_catalog():
+44
View File
@@ -8,6 +8,7 @@ the signal hands a session names the tool that answers it.
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from types import SimpleNamespace 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 "`work.verify`" in line and "3 distinct sessions" in line
assert '"rule_id": 11' in line and '"moment": "work.verify"' in line assert '"rule_id": 11' in line and '"moment": "work.verify"' in line
assert "judge_rule_moments" in line and '"reject"' 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])
+1 -1
View File
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a (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.)""" 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): def _exportable_note(**over):