feat(moments): a mount that keeps arriving where it does not apply proposes its own removal (milestone 458 step 7b, #4955)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m3s
CI & Build / Build & push image (push) Successful in 18s

The open-after-moment signal proposes a mount; nothing proposed taking one
off, so a wrong mount was noise at every occurrence until someone happened
to notice. rule_misfired(rule_id, moment, why, reached_by) records a report
against a MOUNTED pair, counted per distinct day (the MCP door carries no
session id) on a new rule_moment_judgments.misfire column (migration 0119,
backup v24). At three days the response carries a line asking the agent to
offer the operator the fix - reject takes the rule off, unmap_action stops
the action reaching the moment, confirm keeps the mount and stops the
asking - and Settings > Moments lists it as an unmount proposal with the
reasons and the actions that reached it. A re-mount clears the count.

Taught in moments.md, missed-retrieval.md and the reply hold's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 18:19:59 -04:00
co-authored by Claude Opus 5.5
parent 8afd6da8af
commit 4e1320120d
19 changed files with 636 additions and 44 deletions
+3
View File
@@ -195,6 +195,9 @@ TOPICS: tuple[Topic, ...] = (
Topic("a new rule about when gets its moments as it is written", U,
("moments=[...]", "create_process(moments="),
"then ask when it applies, as well as what it is about"),
Topic("a mount that arrives where it does not apply is reported, and reports become the offer", U,
("rule_misfired",),
"a rule you read and set aside leaves no trace anyone else can see"),
Topic("a missed when is mounted or mapped, not reworded", U,
("update_rule(moments=",),
"first ask whether it missed a when or a what"),
+140 -1
View File
@@ -7,7 +7,10 @@ What the step promises, against the real tables:
pair is never proposed again by either source;
- an edit that takes a moment off a rule is a rejection the signal respects;
- the signal counts SESSIONS, not opens, and asks once the bar is crossed;
- only the owner can propose, judge, or feed the signal.
- only the owner can propose, judge, or feed the signal;
- the misfire (step 7b): only a mount can misfire, reports count DAYS, the
bar makes an unmount proposal, reject takes it off, confirm keeps it and
stops the asking, and a re-mount starts the count again.
"""
import pytest
import pytest_asyncio
@@ -191,3 +194,139 @@ async def test_the_open_resolves_acts_through_the_installs_mappings(world):
assert "work.deliver" in out["moments"]
assert "work.run" not in out["moments"]
assert (await _row(done.id, "work.deliver")).source == "signal"
# ── The misfire (step 7b) ────────────────────────────────────────────────────
@pytest.fixture
def days(monkeypatch):
"""Each report lands on the day named next, so a test can cross days
without waiting for them. `misfire_situation` is the one place a day
becomes a situation, so this is what a real date change does."""
queue: list[str] = []
def situation(_now):
from scribe.services import lesson_rules
return lesson_rules.situation_key("d", queue.pop(0))
monkeypatch.setattr(judgments_svc, "misfire_situation", situation)
return queue
async def _misfire(uid, rule_id, moment="work.finish", why="closing a docs-only task",
reached_by="status=done"):
return await judgments_svc.misfired(uid, rule_id, moment, why=why, reached_by=reached_by)
async def test_only_a_mount_can_misfire_and_only_with_a_reason(world, days):
uid, sid, done = world["uid"], world["sid"], world["done"]
out = await _misfire(uid, done.id)
assert not out["recorded"] and "not mounted on work.finish" in out["error"]
assert "when_to_apply" in out["error"]
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
out = await judgments_svc.misfired(uid, done.id, "work.finish", why=" ")
assert not out["recorded"] and "why" in out["error"]
out = await judgments_svc.misfired(uid, done.id, "work.finished", why="x")
assert not out["recorded"] and "work.finished" in out["error"]
out = await _misfire(sid, done.id)
assert out == {"recorded": False, "error": "not a rule you own"}
assert (await _row(done.id, "work.finish")).misfire is None
async def test_misfires_count_days_and_propose_the_unmount(world, days):
uid, done = world["uid"], world["done"]
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
days.extend(["2026-10-01", "2026-10-01", "2026-10-02"])
for _ in range(3):
out = await _misfire(uid, done.id)
assert out["recorded"] and out["context"] == ""
assert out["days"] == 2 and out["bar"] == 3
assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
days.append("2026-10-03")
out = await _misfire(uid, done.id, why="closing a planning stub",
reached_by="status=cancelled")
line = out["context"]
assert out["days"] == 3
assert f"#{done.id}" in line and "`work.finish`" in line and "3 distinct days" in line
assert "`status=done` ×3" in line and "`status=cancelled` ×1" in line
assert '"verdict": "reject"' in line and "unmap_action" in line and '"confirm"' in line
# Asked once; the cooldown keeps the next report quiet.
days.append("2026-10-04")
assert (await _misfire(uid, done.id))["context"] == ""
[entry] = (await judgments_svc.pending(uid, rule_id=done.id))["rules"]
assert entry["mounted"] == ["work.finish"]
[proposal] = entry["proposals"]
assert (proposal["proposal"], proposal["source"], proposal["moment"]) == (
"unmount", "misfire", "work.finish")
assert proposal["evidence"]["situations"] == 4
assert proposal["reached_by"] == {"status=done": 4, "status=cancelled": 1}
assert len(proposal["reasons"]) == judgments_svc.MISFIRE_REASONS
assert proposal["why"] == "closing a docs-only task"
async def _past_the_bar(uid, rule_id, days, moment="work.finish"):
days.extend(["2026-10-01", "2026-10-02", "2026-10-03"])
for _ in range(3):
out = await _misfire(uid, rule_id, moment=moment)
assert out["context"]
async def test_reject_takes_the_mount_off(world, days):
uid, done = world["uid"], world["done"]
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish", "reply.report"])
await _past_the_bar(uid, done.id, days)
out = await judgments_svc.judge(uid, [
{"rule_id": done.id, "moment": "work.finish", "verdict": "reject", "note": "not at task close"},
])
assert out["refused"] == []
assert (await rulebooks_svc.list_rule_moments([done.id]))[done.id] == ["reply.report"]
assert (await _row(done.id, "work.finish")).state == "rejected"
assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
# And it cannot misfire where it no longer is.
assert not (await _misfire(uid, done.id))["recorded"]
async def test_confirm_keeps_the_mount_and_stops_the_asking(world, days):
uid, done = world["uid"], world["done"]
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
await _past_the_bar(uid, done.id, days)
await judgments_svc.judge(uid, [
{"rule_id": done.id, "moment": "work.finish", "verdict": "confirm", "note": "it does belong"},
])
assert (await rulebooks_svc.list_rule_moments([done.id]))[done.id] == ["work.finish"]
row = await _row(done.id, "work.finish")
assert row.state == "confirmed" and row.misfire["kept_note"] == "it does belong"
assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
# Reports still count, and ask nothing — past the cooldown too.
days.extend(["2026-10-10", "2026-10-11"])
for _ in range(2):
out = await _misfire(uid, done.id)
assert out["recorded"] and out["context"] == ""
assert out["kept"]["note"] == "it does belong"
async def test_a_remount_starts_the_count_again(world, days):
uid, done = world["uid"], world["done"]
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
await _past_the_bar(uid, done.id, days)
await rulebooks_svc.set_rule_moments(done.id, uid, [])
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
row = await _row(done.id, "work.finish")
assert row.state == "confirmed" and row.misfire is None
async def test_a_mount_with_no_judgment_row_gets_one_on_its_first_misfire(world, days):
"""Mounts made before migration 0118 have no row; a misfire must still count."""
uid, done = world["uid"], world["done"]
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
async with async_session() as s:
await s.delete(await s.get(RuleMomentJudgment, (await _row(done.id, "work.finish")).id))
await s.commit()
days.append("2026-10-01")
assert (await _misfire(uid, done.id))["recorded"]
row = await _row(done.id, "work.finish")
assert (row.state, row.source) == ("confirmed", "edit")
assert len(row.misfire["situations"]) == 1
+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):
uid, rule = world["uid"], world["rule"]
await rulebooks_svc.set_rule_moments(rule.id, uid, ["work.finish", "reply.report"])
# A misfire report on one of them (v24) — the operator's evidence for
# taking a mount off must not be lost to a restore.
from scribe.services import rule_moment_judgments as judgments_svc
reported = await judgments_svc.misfired(
uid, rule.id, "reply.report", why="the reply only asked a question",
reached_by="the reply that ends this turn",
)
assert reported["recorded"]
async with async_session() as s:
payload = {
@@ -172,9 +180,14 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
]
async with async_session() as s:
judged = (await s.execute(
select(RuleMomentJudgment.moment).where(RuleMomentJudgment.rule_id == restored_rule.id)
select(RuleMomentJudgment).where(RuleMomentJudgment.rule_id == restored_rule.id)
)).scalars().all()
assert sorted(judged) == ["reply.report", "work.finish"]
assert sorted(j.moment for j in judged) == ["reply.report", "work.finish"]
misfire = {j.moment: j.misfire for j in judged}
assert misfire["work.finish"] is None
assert [r["why"] for r in misfire["reply.report"]["reasons"]] == [
"the reply only asked a question",
]
async def _purge_projects(username: str) -> None:
+2 -2
View File
@@ -118,12 +118,12 @@ def test_the_tools_are_registered_and_classified():
assert mcp.names == [
"list_moments", "map_action", "unmap_action",
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals",
"judge_rule_moments",
"judge_rule_moments", "rule_misfired",
]
assert {"list_moments", "rules_to_mount", "rule_moment_proposals"} <= _READ_ONLY_TOOLS
# A confirm mounts a rule, so a read key must not reach it.
assert {"map_action", "unmap_action",
"propose_rule_moments", "judge_rule_moments"} <= _WRITE_TOOLS
"propose_rule_moments", "judge_rule_moments", "rule_misfired"} <= _WRITE_TOOLS
def test_both_doors_read_one_catalog():
+44
View File
@@ -8,6 +8,7 @@ the signal hands a session names the tool that answers it.
from __future__ import annotations
import importlib.util
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
@@ -58,3 +59,46 @@ def test_the_proposal_line_names_the_tool_that_answers_it():
assert "`work.verify`" in line and "3 distinct sessions" in line
assert '"rule_id": 11' in line and '"moment": "work.verify"' in line
assert "judge_rule_moments" in line and '"reject"' in line
# ── The misfire (step 7b) ────────────────────────────────────────────────────
def test_a_day_is_one_situation_and_days_are_distinct():
"""The day arm keys verbatim: tokenised, "10" and "05" fall under the
minimum token length and every day of a year would be one situation."""
a = datetime(2026, 10, 5, 1, tzinfo=timezone.utc)
assert svc.misfire_situation(a) == svc.misfire_situation(a + timedelta(hours=20))
assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=1))
assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=31))
def test_a_misfire_counts_reasons_and_actions():
now = datetime(2026, 10, 5, tzinfo=timezone.utc)
mf = None
for i in range(svc.MISFIRE_REASONS + 2):
mf = svc.add_misfire(mf, svc.misfire_situation(now + timedelta(days=i)), 7, now,
why=f"reason {i}", reached_by="git push" if i % 2 else "make ship")
assert len(mf["situations"]) == svc.MISFIRE_REASONS + 2
assert [r["why"] for r in mf["reasons"]][-1] == f"reason {svc.MISFIRE_REASONS + 1}"
assert len(mf["reasons"]) == svc.MISFIRE_REASONS
assert mf["reached_by"] == {"make ship": 4, "git push": 3}
assert mf["projects"] == [7]
def test_the_unmount_line_offers_both_fixes():
rule = SimpleNamespace(id=10, title="CI verifies", kind="rule")
mf = {"situations": ["a", "b", "c"], "reached_by": {"actions_run_read": 3}}
line = svc._unmount_line(rule, "work.verify", mf)
assert line.startswith("> Rule #10") and "3 distinct days" in line
assert "`actions_run_read` ×3" in line
assert '"verdict": "reject"' in line and "unmap_action" in line and '"confirm"' in line
def test_a_held_reply_names_the_misfire_call_only_for_a_mount():
from scribe.services.moment_delivery import reply_hold_reason
mounted = {"rule_id": 11, "title": "Definition of done", "moment": "reply.report", "trigger": ""}
scored = {"rule_id": 12, "title": "Other", "moment": "", "score": 0.81, "trigger": ""}
assert 'rule_misfired(11, "reply.report", why)' in reply_hold_reason([mounted])
assert "rule_misfired" not in reply_hold_reason([scored])
+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
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):