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