feat(moments): mount the corpus by proposal - a pass and an open-after-moment signal, both stopping at the operator (milestone 458 step 7, #4925)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / integration (push) Failing after 43s
CI & Build / Python tests (push) Failing after 46s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Build & push image (push) Skipped

A rule written before moments existed is mounted on nothing. Step 7 records,
per (rule, moment), whether it belongs there and who said so:

- rule_moment_judgments (migration 0118, backup v23): suggested / confirmed /
  rejected, from a pass, the signal, or an edit. Moment "" is "no moment fits".
- The pass: rules_to_mount lists unjudged rules; propose_rule_moments records
  suggestions that mount nothing; rule_moment_proposals and
  judge_rule_moments put them to the operator. A confirm mounts, a reject is
  kept so the pair is never proposed again. Same service behind REST and a
  "Waiting on you" panel in Settings > Moments.
- Edits are judgments: set_rule_moments, the one mount write path, confirms
  what was added and rejects what was removed in the same transaction.
- The signal: scribe_moment.sh keeps a per-session acts ledger; when a rule
  is opened, scribe_record_opened.sh sends the last three minutes of it to
  /api/plugin/rule-opened. The acts resolve through the install's mappings;
  work.run and work.change are not evidence. Counted per distinct session
  with lesson_rules' evidence model, and once due the open returns one line
  asking the reader to offer the mount.
- scribe_session_end.sh removes the session's scribe-moment files.

Plugin 2026.10.05.2003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 16:03:53 -04:00
co-authored by Claude Opus 5.5
parent 5bcc603310
commit dfcf4df2e9
24 changed files with 1619 additions and 31 deletions
@@ -0,0 +1,193 @@
"""Real-Postgres tests for moment proposals and judgments (milestone 458 step 7).
What the step promises, against the real tables:
- the pass reads only rules nobody has answered for, and any answer — a
proposal, a mount, "no moment fits" — takes a rule off that list;
- a proposal mounts nothing; a confirm mounts, a reject is kept, and a judged
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.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rule_moment_judgment import RuleMomentJudgment
from scribe.models.rulebook import Rulebook
from scribe.models.user import User
from scribe.services import rule_moment_judgments as judgments_svc
from scribe.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER_USERNAME = "moment_judgments_owner"
STRANGER_USERNAME = "moment_judgments_stranger"
async def _purge_books(username: str) -> None:
"""At SETUP, for the reason test_integration_rule_moments gives."""
async with async_session() as s:
for user in (await s.execute(
select(User).where(User.username == username)
)).scalars().all():
for book in (await s.execute(
select(Rulebook).where(Rulebook.owner_user_id == user.id)
)).scalars().all():
await s.delete(book)
await s.commit()
@pytest_asyncio.fixture
async def world():
for name in (OWNER_USERNAME, STRANGER_USERNAME):
await _purge_books(name)
async with async_session() as s:
owner = await ensure_user(s, OWNER_USERNAME)
stranger = await ensure_user(s, STRANGER_USERNAME)
await s.commit()
uid, sid = owner.id, stranger.id
book = await rulebooks_svc.create_rulebook(uid, "Judgment fixtures")
topic = await rulebooks_svc.create_topic(book.id, uid, "verification")
done = await rulebooks_svc.create_rule(
topic.id, uid, "Done means delivered and checked",
"Work is done when it is delivered and its automated checks pass.",
when_to_apply="about to tell the operator a piece of work is finished",
)
style = await rulebooks_svc.create_rule(
topic.id, uid, "Dates are written day first",
"Every date shown to a person is written day, month, year.",
when_to_apply="formatting a date for display",
)
return {"uid": uid, "sid": sid, "done": done, "style": style}
async def _row(rule_id: int, moment: str) -> RuleMomentJudgment | None:
async with async_session() as s:
return (await s.execute(
select(RuleMomentJudgment).where(
RuleMomentJudgment.rule_id == rule_id, RuleMomentJudgment.moment == moment,
)
)).scalar_one_or_none()
async def _unjudged_ids(uid: int) -> set[int]:
return {r["id"] for r in (await judgments_svc.unjudged_rules(uid, limit=100))["rules"]}
async def test_any_answer_takes_a_rule_off_the_pass(world):
uid, done, style = world["uid"], world["done"], world["style"]
assert {done.id, style.id} <= await _unjudged_ids(uid)
out = await judgments_svc.propose(uid, [
{"rule_id": done.id, "moments": ["work.finish", "reply.report"], "why": "about finishing"},
{"rule_id": style.id, "none": "about how a date looks, whenever one is shown"},
])
assert out["proposed"] == 2 and out["no_moment"] == 1 and not out["refused"]
assert not ({done.id, style.id} & await _unjudged_ids(uid))
# A proposal mounted nothing.
assert (await rulebooks_svc.list_rule_moments([done.id])).get(done.id) is None
async def test_a_confirm_mounts_and_a_reject_is_never_proposed_again(world):
uid, done = world["uid"], world["done"]
await judgments_svc.propose(uid, [
{"rule_id": done.id, "moments": ["work.finish", "work.plan"], "why": "w"},
])
pending = await judgments_svc.pending(uid, rule_id=done.id)
assert [p["moment"] for p in pending["rules"][0]["proposals"]] == ["work.finish", "work.plan"]
out = await judgments_svc.judge(uid, [
{"rule_id": done.id, "moment": "work.finish", "verdict": "confirm", "note": "yes"},
{"rule_id": done.id, "moment": "work.plan", "verdict": "reject", "note": "too early"},
])
assert not out["refused"]
assert (await rulebooks_svc.list_rule_moments([done.id]))[done.id] == ["work.finish"]
confirmed = await _row(done.id, "work.finish")
assert (confirmed.state, confirmed.source, confirmed.note) == ("confirmed", "pass", "yes")
assert (await _row(done.id, "work.plan")).state == "rejected"
assert (await judgments_svc.pending(uid))["total"] == 0
again = await judgments_svc.propose(uid, [
{"rule_id": done.id, "moments": ["work.plan", "work.finish"], "why": "w"},
])
assert again["proposed"] == 0
assert {s["moment"] for s in again["skipped"]} == {"work.plan", "work.finish"}
async def test_an_edit_that_unmounts_is_a_rejection_the_signal_respects(world):
uid, done = world["uid"], world["done"]
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.deliver"])
await rulebooks_svc.set_rule_moments(done.id, uid, [])
row = await _row(done.id, "work.deliver")
assert (row.state, row.source) == ("rejected", "edit")
for session in ("a", "b", "c", "d"):
assert await judgments_svc.co_occurred(
uid, done.id, ["work.deliver"], situation=session,
) == ""
assert (await _row(done.id, "work.deliver")).evidence is None
async def test_the_signal_counts_sessions_and_asks_once_the_bar_is_crossed(world):
uid, done = world["uid"], world["done"]
# Ubiquitous moments are not evidence; a repeat in one session counts once.
assert await judgments_svc.co_occurred(uid, done.id, ["work.run", "work.change"],
situation="s1") == ""
assert await _row(done.id, "work.run") is None
for situation in ("s1", "s1", "s2"):
assert await judgments_svc.co_occurred(uid, done.id, ["work.verify"],
situation=situation) == ""
line = await judgments_svc.co_occurred(uid, done.id, ["work.verify"], situation="s3")
assert f"#{done.id}" in line and "`work.verify`" in line and "3 distinct sessions" in line
assert "judge_rule_moments" in line
row = await _row(done.id, "work.verify")
assert (row.state, row.source) == ("suggested", "signal")
# Asked once; the cooldown keeps the next open quiet.
assert await judgments_svc.co_occurred(uid, done.id, ["work.verify"], situation="s4") == ""
# And it waits for the operator like any proposal.
pending = await judgments_svc.pending(uid, rule_id=done.id)
[proposal] = pending["rules"][0]["proposals"]
assert proposal["source"] == "signal" and proposal["evidence"]["situations"] == 4
async def test_a_mount_overturns_no_moment_fits(world):
uid, style = world["uid"], world["style"]
await judgments_svc.propose(uid, [{"rule_id": style.id, "none": "about how a date looks"}])
assert (await _row(style.id, "")).state == "confirmed"
await judgments_svc.judge(uid, [
{"rule_id": style.id, "moment": "reply.report", "verdict": "confirm", "note": "dates in reports"},
])
assert (await _row(style.id, "")).state == "rejected"
async def test_only_the_owner_can_propose_judge_or_feed_the_signal(world):
sid, done = world["sid"], world["done"]
out = await judgments_svc.propose(sid, [{"rule_id": done.id, "moments": ["work.finish"]}])
assert out["proposed"] == 0 and out["refused"]
judged = await judgments_svc.judge(sid, [
{"rule_id": done.id, "moment": "work.finish", "verdict": "confirm"},
])
assert judged["refused"] and not judged["judged"]
for situation in ("a", "b", "c"):
assert await judgments_svc.co_occurred(sid, done.id, ["work.verify"],
situation=situation) == ""
assert await _row(done.id, "work.verify") is None
assert done.id not in await _unjudged_ids(sid)
assert (await judgments_svc.pending(sid))["total"] == 0
async def test_the_open_resolves_acts_through_the_installs_mappings(world):
"""`opened_after` reads the acts as delivery does: a push is a deliver."""
uid, done = world["uid"], world["done"]
acts = [
{"tool_name": "Bash", "tool_input": {"command": "git push origin dev"}},
{"tool_name": "Bash", "tool_input": {"command": "ls"}},
"not an act",
]
out = await judgments_svc.opened_after(uid, done.id, acts, session_id="s1")
assert "work.deliver" in out["moments"]
assert "work.run" not in out["moments"]
assert (await _row(done.id, "work.deliver")).source == "signal"
+16
View File
@@ -16,6 +16,7 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rule_moment_judgment import RuleMomentJudgment
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import backup
@@ -141,10 +142,20 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
assert sorted(r["moment"] for r in payload["rule_moments"]) == [
"reply.report", "work.finish",
]
# The mount was an edit, so it was also recorded as two judgments (step 7)
# — and those travel too, onto the restored rule.
payload["rule_moment_judgments"] = [
row for row in exported["rule_moment_judgments"] if row["rule_id"] == rule.id
]
assert sorted((r["moment"], r["state"], r["source"])
for r in payload["rule_moment_judgments"]) == [
("reply.report", "confirmed", "edit"), ("work.finish", "confirmed", "edit"),
]
payload["users"][0]["username"] = RESTORED_USERNAME
stats = await backup.restore_full_backup(payload)
assert stats["rule_moments"] == 2
assert stats["rule_moment_judgments"] == 2
async with async_session() as s:
restored_user = (await s.execute(
@@ -159,6 +170,11 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
assert (await rulebooks_svc.list_rule_moments([restored_rule.id]))[restored_rule.id] == [
"work.finish", "reply.report",
]
async with async_session() as s:
judged = (await s.execute(
select(RuleMomentJudgment.moment).where(RuleMomentJudgment.rule_id == restored_rule.id)
)).scalars().all()
assert sorted(judged) == ["reply.report", "work.finish"]
async def _purge_projects(username: str) -> None:
+9 -3
View File
@@ -115,9 +115,15 @@ def test_the_tools_are_registered_and_classified():
mcp = FakeMCP()
tool.register(mcp)
assert mcp.names == ["list_moments", "map_action", "unmap_action"]
assert "list_moments" in _READ_ONLY_TOOLS
assert {"map_action", "unmap_action"} <= _WRITE_TOOLS
assert mcp.names == [
"list_moments", "map_action", "unmap_action",
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals",
"judge_rule_moments",
]
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
def test_both_doors_read_one_catalog():
+60
View File
@@ -0,0 +1,60 @@
"""Moment proposals and judgments — what a mock can see (milestone 458 step 7).
The behaviour against real tables is tests/test_integration_rule_moment_judgments.py.
These pin the pieces that need no database: that the migration's CHECKs and
the model agree (rule 36), which moments count as evidence, and that the line
the signal hands a session names the tool that answers it.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from scribe.models.rule_moment_judgment import JUDGMENT_STATES, NO_MOMENT, SOURCES
from scribe.services import moments as moments_svc
from scribe.services import rule_moment_judgments as svc
ROOT = Path(__file__).resolve().parents[1]
def _migration():
path = ROOT / "alembic" / "versions" / "0118_rule_moment_judgments.py"
spec = importlib.util.spec_from_file_location("m0118", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_the_migration_checks_and_the_model_agree():
m = _migration()
assert tuple(m._STATES) == tuple(JUDGMENT_STATES)
assert tuple(m._SOURCES) == tuple(SOURCES)
def test_verdicts_name_real_states():
assert set(svc.VERDICTS.values()) <= set(JUDGMENT_STATES)
def test_no_moment_is_not_a_mountable_name():
""""" is the "none fits" answer; if it ever became mountable, a pass's
"no moment" would read as a mount on nothing."""
assert not moments_svc.is_moment(NO_MOMENT)
def test_the_ubiquitous_moments_are_not_evidence():
assert svc.signal_moments(["work.run", "work.verify", "work.change", "work.verify",
"not.a.moment", "skill.brainstorming"]) == [
"work.verify", "skill.brainstorming",
]
# Every skipped name is a real moment — a typo here would skip nothing.
assert all(moments_svc.is_moment(m) for m in svc.SIGNAL_SKIP)
def test_the_proposal_line_names_the_tool_that_answers_it():
rule = SimpleNamespace(id=11, title="Definition of done", kind="rule")
line = svc._proposal_line(rule, "work.verify", {"situations": ["a", "b", "c"]})
assert line.startswith("> Rule #11")
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
+168
View File
@@ -0,0 +1,168 @@
"""The plugin half of the rule↔moment signal (milestone 458 step 7), run for real.
`scribe_moment.sh` writes every call to an acts ledger; `scribe_record_opened.sh`
sends the last few minutes of it with the rule a session opened; the server
answers with a proposal line when one is due. These drive both scripts through
bash with a stand-in `curl` on PATH that records what it was sent, so what is
pinned is what the scripts actually do — the window, the body's shape, the
reduction of a large event, the line coming back out — not what their source
looks like. `scribe_session_end.sh` removing the session's files is pinned the
same way.
"""
from __future__ import annotations
import inspect
import json
import os
import subprocess
import time
from pathlib import Path
import pytest
from scribe.routes import plugin as plugin_routes
from scribe.services import rule_moment_judgments as judgments_svc
from tests.helpers import need_tools
ROOT = Path(__file__).resolve().parents[1]
HOOKS = ROOT / "plugin" / "hooks"
FAKE_CURL = """#!/usr/bin/env bash
data=""
for a in "$@"; do
if [ "$a" = "@-" ]; then data=$(cat); fi
done
printf '%s\\n' "$*" >> "$FAKE_CURL_LOG"
if [ -n "$data" ]; then printf '%s' "$data" > "$FAKE_CURL_BODY"; fi
if [ -n "${FAKE_CURL_REPLY:-}" ]; then printf '%s' "$FAKE_CURL_REPLY"; else printf '{}'; fi
"""
@pytest.fixture
def sandbox(tmp_path):
need_tools("bash", "awk", "paste", "tail")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
curl = bin_dir / "curl"
curl.write_text(FAKE_CURL)
curl.chmod(0o755)
env = {
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"HOME": str(tmp_path), "TMPDIR": str(tmp_path),
"SCRIBE_URL": "http://scribe.invalid", "SCRIBE_TOKEN": "t",
"FAKE_CURL_LOG": str(tmp_path / "curl.log"),
"FAKE_CURL_BODY": str(tmp_path / "curl.body"),
"CLAUDE_PROJECT_DIR": str(tmp_path),
}
return tmp_path, env
def _run(script: str, event: dict, env: dict, **extra) -> subprocess.CompletedProcess:
out = subprocess.run(
["bash", str(HOOKS / script)], input=json.dumps(event),
capture_output=True, text=True, env={**env, **extra}, timeout=30,
)
assert out.returncode == 0, out.stderr
return out
def _acts(tmp: Path, sid: str = "s1") -> list[tuple[int, dict]]:
path = tmp / "scribe-moment" / f"{sid}.acts"
rows = []
for line in path.read_text().splitlines():
stamp, _, act = line.partition("\t")
rows.append((int(stamp), json.loads(act)))
return rows
def test_every_call_is_written_to_the_acts_ledger_scribes_own_included(sandbox):
tmp, env = sandbox
push = {"session_id": "s1", "tool_name": "Bash",
"tool_input": {"command": "git push origin dev"}}
close = {"session_id": "s1", "tool_name": "mcp__plugin_scribe_scribe__update_task",
"tool_input": {"task_id": 4, "status": "done"}}
_run("scribe_moment.sh", push, env, FAKE_CURL_REPLY='{"tools":[]}')
_run("scribe_moment.sh", close, env)
acts = _acts(tmp)
assert [a for _, a in acts] == [push, close]
assert all(abs(stamp - time.time()) < 60 for stamp, _ in acts)
def test_the_open_itself_is_not_an_act(sandbox):
tmp, env = sandbox
_run("scribe_moment.sh", {"session_id": "s1", "tool_name": "Bash",
"tool_input": {"command": "ls"}}, env,
FAKE_CURL_REPLY='{"tools":[]}')
_run("scribe_moment.sh", {"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
"tool_input": {"rule_id": 11}}, env)
assert [a["tool_name"] for _, a in _acts(tmp)] == ["Bash"]
def test_a_large_event_is_kept_as_its_tool_name(sandbox):
tmp, env = sandbox
big = {"session_id": "s1", "tool_name": "Write",
"tool_input": {"file_path": "x", "content": "y" * 40000}}
_run("scribe_moment.sh", big, env, FAKE_CURL_REPLY='{"tools":[]}')
[(_, act)] = _acts(tmp)
assert act == {"tool_name": "Write", "tool_input": {}}
def test_an_open_sends_the_window_and_prints_the_answer(sandbox):
tmp, env = sandbox
ledger = tmp / "scribe-moment"
ledger.mkdir()
now = int(time.time())
old = {"tool_name": "Bash", "tool_input": {"command": "make old"}}
recent = {"tool_name": "Bash", "tool_input": {"command": "git push"}}
(ledger / "s1.acts").write_text(
f"{now - 1000}\t{json.dumps(old)}\n{now - 5}\t{json.dumps(recent)}\n"
)
line = "> Rule #11 has been opened just after `work.deliver` in 3 distinct sessions"
out = _run("scribe_record_opened.sh",
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
"tool_input": {"rule_id": 11}}, env,
FAKE_CURL_REPLY=json.dumps({"moments": ["work.deliver"], "context": line}))
body = json.loads((tmp / "curl.body").read_text())
assert body == {"rule_id": 11, "session_id": "s1", "acts": [recent]}
assert "/api/plugin/rule-opened" in (tmp / "curl.log").read_text()
printed = json.loads(out.stdout)["hookSpecificOutput"]
assert printed["hookEventName"] == "PostToolUse"
assert printed["additionalContext"] == line
# The open is still recorded in its own ledger, as before.
assert (tmp / "scribe-priorart" / "s1.opened.ids").read_text().startswith("11\t")
def test_an_open_with_nothing_in_the_window_sends_nothing(sandbox):
tmp, env = sandbox
ledger = tmp / "scribe-moment"
ledger.mkdir()
(ledger / "s1.acts").write_text(
f"{int(time.time()) - 1000}\t{json.dumps({'tool_name': 'Bash', 'tool_input': {}})}\n"
)
out = _run("scribe_record_opened.sh",
{"session_id": "s1", "tool_name": "mcp__scribe__get_rule",
"tool_input": {"rule_id": 11}}, env)
assert out.stdout == ""
assert not (tmp / "curl.log").exists()
def test_session_end_removes_the_sessions_moment_files_and_no_others(sandbox):
tmp, env = sandbox
ledger = tmp / "scribe-moment"
ledger.mkdir()
for name in ("s1.acts", "s1.tools", "s2.acts"):
(ledger / name).write_text("x")
_run("scribe_session_end.sh", {"session_id": "s1", "reason": "clear"}, env)
assert sorted(p.name for p in ledger.iterdir()) == ["s2.acts"]
def test_the_hook_and_the_route_agree_on_the_body():
"""Rule 33 across the shell/Python seam: a renamed field fails silently."""
src = (HOOKS / "scribe_record_opened.sh").read_text()
route = inspect.getsource(plugin_routes.rule_opened)
assert "/api/plugin/rule-opened" in src
for field in ("rule_id", "session_id", "acts"):
assert f'"{field}"' in src and f'"{field}"' in route
assert "'.context'" in src
assert '"context"' in inspect.getsource(judgments_svc.opened_after)
+6 -2
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 == 22
assert backup.BACKUP_VERSION == 23
def _exportable_note(**over):
@@ -132,6 +132,7 @@ def _column_guard_targets():
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink
from scribe.models.rule_moment_judgment import RuleMomentJudgment
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.milestone import Milestone
from scribe.models.note import Note
@@ -172,6 +173,7 @@ def _column_guard_targets():
"rule_relations": (RuleRelation, backup._rule_relation_rows),
"lesson_rule_links": (LessonRuleLink, backup._lesson_rule_link_rows),
"lesson_no_rule": (LessonNoRule, backup._lesson_no_rule_rows),
"rule_moment_judgments": (RuleMomentJudgment, backup._rule_moment_judgment_rows),
"note_usage_events": (NoteUsageEvent, backup._usage_event_rows),
"rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows),
"system_usage_events": (SystemUsageEvent, backup._system_usage_event_rows),
@@ -551,7 +553,9 @@ async def test_export_full_backup_contains_every_declared_section():
# v19: the lessons judged to fall under no rule.
"lesson_no_rule",
# v20: whether an area's rulings were read once shown.
"system_usage_events"):
"system_usage_events",
# v23: proposals and judgments about a rule's moments.
"rule_moment_judgments"):
assert key in out, f"missing export section: {key}"
assert out[key] == []