From dfcf4df2e99718d9def3692ccdb3ff270a948784 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 5 Oct 2026 16:03:53 -0400 Subject: [PATCH] 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) 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 --- .../versions/0118_rule_moment_judgments.py | 61 ++ frontend/src/api/moments.ts | 56 ++ frontend/src/components/MomentProposals.vue | 138 +++++ frontend/src/components/MomentsSettings.vue | 3 + plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_moment.sh | 54 +- plugin/hooks/scribe_record_opened.sh | 35 ++ plugin/hooks/scribe_session_end.sh | 16 +- src/scribe/mcp/server.py | 7 + src/scribe/mcp/tools/moments.py | 67 +++ src/scribe/models/__init__.py | 2 + src/scribe/models/rule_moment_judgment.py | 81 +++ src/scribe/routes/plugin.py | 30 + src/scribe/routes/retrieval.py | 25 + src/scribe/services/backup.py | 64 ++- src/scribe/services/lesson_rules.py | 10 +- src/scribe/services/rule_moment_judgments.py | 527 ++++++++++++++++++ src/scribe/services/rulebooks.py | 15 +- .../test_integration_rule_moment_judgments.py | 193 +++++++ tests/test_integration_rule_moments.py | 16 + tests/test_moments.py | 12 +- tests/test_rule_moment_judgments.py | 60 ++ tests/test_rule_moment_signal_hooks.py | 168 ++++++ tests/test_services_backup.py | 8 +- 24 files changed, 1619 insertions(+), 31 deletions(-) create mode 100644 alembic/versions/0118_rule_moment_judgments.py create mode 100644 frontend/src/components/MomentProposals.vue create mode 100644 src/scribe/models/rule_moment_judgment.py create mode 100644 src/scribe/services/rule_moment_judgments.py create mode 100644 tests/test_integration_rule_moment_judgments.py create mode 100644 tests/test_rule_moment_judgments.py create mode 100644 tests/test_rule_moment_signal_hooks.py diff --git a/alembic/versions/0118_rule_moment_judgments.py b/alembic/versions/0118_rule_moment_judgments.py new file mode 100644 index 00000000..3884ea0e --- /dev/null +++ b/alembic/versions/0118_rule_moment_judgments.py @@ -0,0 +1,61 @@ +"""rule_moment_judgments — whether a rule belongs on a moment, and who said so +(milestone 458 step 7, #4925) + +Revision ID: 0118 +Revises: 0117 +Create Date: 2026-10-05 + +One row per (rule, moment) with a state: `suggested` while a proposal waits +for a judgment, `confirmed` or `rejected` once one is made. The mount itself +stays in rule_moments; this records the proposals that are not mounts yet and +the rejections that must stop them being proposed again. Moment '' is "no +moment fits this rule". CASCADE on the rule — a judgment about a rule that no +longer exists says nothing. No backfill: the mounts made before this table +were judgments too, but recording them now would invent who made them. +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision = "0118" +down_revision = "0117" +branch_labels = None +depends_on = None + +# One place each, so the CHECKs and the model's JUDGMENT_STATES / SOURCES +# cannot drift (rule 36: a new value later means DROP + ADD CONSTRAINT in the +# same migration). +_STATES = ("suggested", "confirmed", "rejected") +_SOURCES = ("pass", "signal", "edit") + + +def upgrade() -> None: + op.create_table( + "rule_moment_judgments", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False), + sa.Column("moment", sa.Text(), nullable=False), + sa.Column("state", sa.Text(), nullable=False, server_default="suggested"), + sa.Column("source", sa.Text(), nullable=False, server_default="pass"), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("evidence", postgresql.JSONB(), nullable=True), + sa.Column("judged_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.UniqueConstraint("rule_id", "moment", name="uq_rule_moment_judgments_pair"), + ) + op.create_check_constraint( + "ck_rule_moment_judgments_state", "rule_moment_judgments", + "state IN (" + ", ".join(f"'{s}'" for s in _STATES) + ")", + ) + op.create_check_constraint( + "ck_rule_moment_judgments_source", "rule_moment_judgments", + "source IN (" + ", ".join(f"'{s}'" for s in _SOURCES) + ")", + ) + op.create_index("ix_rule_moment_judgments_rule_id", "rule_moment_judgments", ["rule_id"]) + + +def downgrade() -> None: + op.drop_index("ix_rule_moment_judgments_rule_id", table_name="rule_moment_judgments") + op.drop_constraint("ck_rule_moment_judgments_source", "rule_moment_judgments", type_="check") + op.drop_constraint("ck_rule_moment_judgments_state", "rule_moment_judgments", type_="check") + op.drop_table("rule_moment_judgments") diff --git a/frontend/src/api/moments.ts b/frontend/src/api/moments.ts index 9a8f8581..70dd0b07 100644 --- a/frontend/src/api/moments.ts +++ b/frontend/src/api/moments.ts @@ -99,3 +99,59 @@ export function unmapAction(change: MappingChange): Promise { }); return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`); } + +/** + * Proposals that a rule belongs on a moment (milestone 458 step 7) — from a + * pass that read the rule, or from the rule being opened just after the + * moment fired. `GET /api/retrieval/moments/proposals`, the payload the + * `rule_moment_proposals` MCP tool returns. + */ +export type ProposalSource = "pass" | "signal" | "edit"; + +export interface MomentProposal { + moment: string; + source: ProposalSource; + why: string; + evidence: { situations: number; projects: number; co_surfaced: number }; + created_at: string | null; +} + +export interface RuleProposals { + id: number; + title: string; + kind: string; + statement: string; + when_to_apply: string; + home: "global" | "project"; + /** What the rule is mounted on now. */ + mounted: string[]; + proposals: MomentProposal[]; +} + +export interface ProposalsPayload { + rules: RuleProposals[]; + total: number; +} + +export type Verdict = "confirm" | "reject"; + +export interface MomentJudgment { + rule_id: number; + moment: string; + verdict: Verdict; + note?: string; +} + +export interface JudgeResult { + judged: { rule_id: number; moment: string; state: string }[]; + refused: { rule_id: number; moment?: string; error: string }[]; +} + +export function getProposals(ruleId?: number): Promise { + 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. */ +export function judgeProposals(judgments: MomentJudgment[]): Promise { + return apiPost("/api/retrieval/moments/proposals/judge", { judgments }); +} diff --git a/frontend/src/components/MomentProposals.vue b/frontend/src/components/MomentProposals.vue new file mode 100644 index 00000000..2250bf29 --- /dev/null +++ b/frontend/src/components/MomentProposals.vue @@ -0,0 +1,138 @@ + + + + + + +