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
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:
@@ -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")
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user