Merge pull request 'Mount the corpus by proposal: a pass and an open-after-moment signal (milestone 458 step 7)' (#203) from dev into main
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 22s

This commit was merged in pull request #203.
This commit is contained in:
2026-10-05 16:31:42 -04:00
25 changed files with 1622 additions and 31 deletions
@@ -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")
+56
View File
@@ -99,3 +99,59 @@ export function unmapAction(change: MappingChange): Promise<void> {
}); });
return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`); 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<ProposalsPayload> {
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<JudgeResult> {
return apiPost("/api/retrieval/moments/proposals/judge", { judgments });
}
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { getProposals, judgeProposals } from "@/api/moments";
import type { MomentProposal, ProposalsPayload, RuleProposals, Verdict } from "@/api/moments";
import { apiErrorMessage } from "@/api/client";
import { useMomentsStore } from "@/stores/moments";
import { useToastStore } from "@/stores/toast";
/**
* Proposals that a rule belongs on a moment, waiting on a person (milestone
* 458 step 7). They come from a pass that read the rule, or from the rule
* being opened just after the moment fired in several sessions. Nothing here
* is mounted until "Mount" is pressed — the same judgment `judge_rule_moments`
* makes in a session, through the same service.
*/
const store = useMomentsStore();
const toast = useToastStore();
const data = ref<ProposalsPayload | null>(null);
const failed = ref(false);
const busy = ref<string | null>(null);
const rules = computed<RuleProposals[]>(() => data.value?.rules ?? []);
async function load() {
try {
data.value = await getProposals();
failed.value = false;
} catch {
failed.value = true;
}
}
function key(rule: RuleProposals, p: MomentProposal): string {
return `${rule.id}:${p.moment}`;
}
function sourceLabel(p: MomentProposal): string {
if (p.source === "signal") {
return `opened just after this moment in ${p.evidence.situations} sessions`;
}
return "proposed from the rule's text";
}
async function decide(rule: RuleProposals, p: MomentProposal, verdict: Verdict) {
busy.value = key(rule, p);
try {
const out = await judgeProposals([{
rule_id: rule.id, moment: p.moment, verdict,
note: verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.",
}]);
if (out.refused.length) {
toast.show(out.refused[0].error, "error");
return;
}
await load();
// A mount changes the per-moment counts the list below shows.
if (verdict === "confirm") await store.load(true);
} catch (e) {
toast.show(apiErrorMessage(e, "Could not record that"), "error");
} finally {
busy.value = null;
}
}
onMounted(load);
</script>
<template>
<section v-if="failed || rules.length" class="proposals" aria-labelledby="proposals-title">
<h3 id="proposals-title" class="sub-title">Waiting on you</h3>
<p v-if="failed" class="error-msg">
The proposals could not be loaded.
<button type="button" class="btn-text" @click="load">Try again</button>
</p>
<template v-else>
<p class="field-hint">
Moments these rules may belong on. Mounting one makes the rule arrive whenever that
moment happens, whatever the work is about; declining keeps it from being proposed again.
</p>
<ul class="proposal-list">
<li v-for="rule in rules" :key="rule.id" class="proposal-rule">
<div class="rule-head">
<span class="rule-title">#{{ rule.id }} {{ rule.title }}</span>
<span v-if="rule.mounted.length" class="rule-mounted">
on <code v-for="m in rule.mounted" :key="m" class="moment-name">{{ m }}</code>
</span>
</div>
<p class="rule-statement">{{ rule.statement }}</p>
<ul class="proposal-items">
<li v-for="p in rule.proposals" :key="key(rule, p)" class="proposal-item">
<code class="moment-name">{{ p.moment }}</code>
<span class="proposal-why">
{{ p.why || sourceLabel(p) }}
<span v-if="p.why" class="proposal-source">· {{ sourceLabel(p) }}</span>
</span>
<span class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Mount</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Not this</button>
</span>
</li>
</ul>
</li>
</ul>
</template>
</section>
</template>
<style scoped>
.proposals { display: flex; flex-direction: column; gap: var(--fs-space-2); }
.sub-title { margin: 0; font-size: 0.95rem; color: var(--fs-text-primary); }
.proposal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-rule {
padding: var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.rule-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); }
.rule-title { font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); font-weight: var(--fs-weight-medium); }
.rule-mounted { display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-1); font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
.rule-statement { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
.proposal-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
.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-actions { display: inline-flex; gap: var(--fs-space-2); margin-left: auto; }
</style>
<style src="@/assets/moments-shared.css" />
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useMomentsStore } from "@/stores/moments"; import { useMomentsStore } from "@/stores/moments";
import MomentProposals from "@/components/MomentProposals.vue";
import type { MomentAction, MomentUsage } from "@/api/moments"; import type { MomentAction, MomentUsage } from "@/api/moments";
import { fmtDate } from "@/utils/dateFormat"; import { fmtDate } from "@/utils/dateFormat";
@@ -123,6 +124,8 @@ onMounted(() => store.load(true));
The mount counts could not be read, so they are missing below — not zero. The mount counts could not be read, so they are missing below — not zero.
</p> </p>
<MomentProposals />
<ul class="moment-list"> <ul class="moment-list">
<li v-for="m in catalog" :key="m.name" class="moment-row"> <li v-for="m in catalog" :key="m.name" class="moment-row">
<div class="moment-head"> <div class="moment-head">
+1 -1
View File
@@ -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.1823", "version": "2026.10.05.2003",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+41 -13
View File
@@ -16,10 +16,10 @@
# other tool costs one file read. An install that has mounted nothing gets an # other tool costs one file read. An install that has mounted nothing gets an
# empty list and never sends a moment request at all. # empty list and never sends a moment request at all.
# #
# SCRIBE'S OWN TOOLS ARE SKIPPED. Closing a task or recording a lesson reaches # SCRIBE'S OWN TOOLS ARE SKIPPED for delivery. Closing a task or recording a
# its moment through the tool's own response (`moment_rules`), which works for # lesson reaches its moment through the tool's own response (`moment_rules`),
# a client without this plugin too; delivering it here as well would say it # which works for a client without this plugin too; delivering it here as well
# twice. # would say it twice. They are still written to the acts ledger below.
# #
# SILENT ON OUTAGE, like scribe_tool_rules.sh and for its reason: this fires # SILENT ON OUTAGE, like scribe_tool_rules.sh and for its reason: this fires
# before many calls, and an outage line before each is noise. A failed list # before many calls, and an outage line before each is noise. A failed list
@@ -42,10 +42,6 @@ event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
[ -n "$tool_name" ] && [ -n "$session_id" ] || exit 0 [ -n "$tool_name" ] && [ -n "$session_id" ] || exit 0
case "$tool_name" in
mcp__*scribe*__*) exit 0 ;;
esac
scribe_config || exit 0 scribe_config || exit 0
# The tool's KEY, as the server writes mappings: no MCP server prefix, lowercase. # The tool's KEY, as the server writes mappings: no MCP server prefix, lowercase.
@@ -56,16 +52,48 @@ state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_') safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# Its own directory, outside SCRIBE_LEDGER_DIRS, so a compaction does not
# sweep it: what is kept here describes the install and what the session
# DID, not what its context holds. scribe_session_end.sh removes it.
cache_dir="${TMPDIR:-/tmp}/scribe-moment"
mkdir -p "$cache_dir" 2>/dev/null || true
now=$(date +%s 2>/dev/null) || now=0
# ── The acts ledger (milestone 458 step 7) ────────────────────────────────
#
# Every call, before any filter: `epoch<TAB>event`, one line each. When the
# session then opens a rule, scribe_record_opened.sh sends the last few
# minutes of these, and the server counts the open as evidence the rule
# belongs on the moments they reached — which is how a rule nobody mounted
# gets proposed for one. Recorded whether or not anything is mounted, since
# an unmounted moment is exactly what the evidence is for, and including
# Scribe's own tools (closing a task is a moment too). The open itself is
# not an act. A large event (a file write) is kept as its tool name alone.
if [ "$now" -gt 0 ] && [ "$tool_key" != "get_rule" ]; then
acts_file="$cache_dir/${safe_sid}.acts"
act=$(printf '%s' "$event" | tr -d '\n\r')
if [ "${#act}" -gt 16384 ]; then
esc=$(printf '%s' "$tool_name" | scribe_json_escape) || esc=""
act="{\"tool_name\":\"${esc}\",\"tool_input\":{}}"
fi
printf '%s\t%s\n' "$now" "$act" >> "$acts_file" 2>/dev/null || true
# Kept short: only the last few minutes are ever read.
size=$(wc -c < "$acts_file" 2>/dev/null) || size=0
if [ "${size:-0}" -gt 262144 ]; then
tail -n 50 "$acts_file" > "$acts_file.tmp" 2>/dev/null \
&& mv -f "$acts_file.tmp" "$acts_file" 2>/dev/null
fi
fi
case "$tool_name" in
mcp__*scribe*__*) exit 0 ;;
esac
# ── Which tools can reach a mounted rule: cached, first line a timestamp ── # ── Which tools can reach a mounted rule: cached, first line a timestamp ──
# #
# Five minutes. A mount or a mapping made in this session reaches the hook # Five minutes. A mount or a mapping made in this session reaches the hook
# within that window; the MCP door delivers Scribe's own moments at once. # within that window; the MCP door delivers Scribe's own moments at once.
# Its own directory, outside SCRIBE_LEDGER_DIRS, so a compaction does not
# sweep it: it describes the install, not what this context holds.
cache_dir="${TMPDIR:-/tmp}/scribe-moment"
mkdir -p "$cache_dir" 2>/dev/null || true
tools_file="$cache_dir/${safe_sid}.tools" tools_file="$cache_dir/${safe_sid}.tools"
now=$(date +%s 2>/dev/null) || now=0
stamp="" stamp=""
[ -f "$tools_file" ] && stamp=$(head -n 1 "$tools_file" 2>/dev/null) [ -f "$tools_file" ] && stamp=$(head -n 1 "$tools_file" 2>/dev/null)
case "$stamp" in ''|*[!0-9]*) stamp=0 ;; esac case "$stamp" in ''|*[!0-9]*) stamp=0 ;; esac
+35
View File
@@ -33,6 +33,10 @@
# claim it supports is only ever "you opened this, pull it again if you no # claim it supports is only ever "you opened this, pull it again if you no
# longer hold it", which stays true in every case and carries its own remedy. # longer hold it", which stays true in every case and carries its own remedy.
# #
# SINCE MILESTONE 458 STEP 7 it also reports the open, with the calls that
# came just before it, so the server can learn which moments a rule belongs
# on (below). Silent on outage, like every arm that fires per call.
#
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never # EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
# turn a successful tool call into a hook error. Worst case the id is missed # turn a successful tool call into a hook error. Worst case the id is missed
# and the reader is offered a rule it already read — the cost of a wrong guess # and the reader is offered a rule it already read — the cost of a wrong guess
@@ -68,4 +72,35 @@ safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# Stamped and append-only, exactly like the naming ledger — so the same reader # Stamped and append-only, exactly like the naming ledger — so the same reader
# (`scribe_rules_live`) ages both, and the last entry for an id wins. # (`scribe_rules_live`) ages both, and the last entry for an id wins.
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.opened.ids" printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.opened.ids"
# ── What came just before the open (milestone 458 step 7) ─────────────────
#
# scribe_moment.sh writes every call to `scribe-moment/<sid>.acts`. The ones
# from the last three minutes go to the server with this rule's id: a rule
# opened just after a moment fired is evidence it belongs on that moment, and
# once that repeats across sessions the server answers with one line asking
# the reader to offer the operator the mount. Nothing is mounted by this.
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
acts_file="${TMPDIR:-/tmp}/scribe-moment/${safe_sid}.acts"
[ -f "$acts_file" ] || exit 0
now=$(date +%s 2>/dev/null) || exit 0
acts=$(awk -F '\t' -v since=$((now - 180)) \
'$1 + 0 >= since { sub(/^[^\t]*\t/, ""); print }' "$acts_file" 2>/dev/null \
| tail -n 30 | paste -sd ',' - 2>/dev/null)
[ -n "$acts" ] || exit 0
sid_esc=$(printf '%s' "$session_id" | scribe_json_escape) || exit 0
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
body=$(printf '{"rule_id":%s,"session_id":"%s","acts":[%s]}' "$rule_id" "$sid_esc" "$acts" \
| curl -fsS --max-time 3 \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
--data-binary @- \
"${url%/}/api/plugin/rule-opened${scope:+?$scope}" 2>/dev/null) || exit 0
context=$(scribe_json_pick "$(printf '%s' "$body" | scribe_json_flat)" '.context')
[ -n "$context" ] || exit 0
scribe_json_out PostToolUse "$context"
exit 0 exit 0
+11 -5
View File
@@ -24,18 +24,24 @@ set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh # shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh" . "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
event=$(cat 2>/dev/null || true) event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0 [ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat) event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
# The moment arm's per-session files (its tool cache and the acts ledger,
# milestone 458) describe a session that is over, on any exit — a clear
# included, since the next conversation is a new session id.
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rm -f "${TMPDIR:-/tmp}/scribe-moment/${safe_sid}".* 2>/dev/null || true
reason=$(scribe_json_pick "$event_flat" '.reason') reason=$(scribe_json_pick "$event_flat" '.reason')
[ "$reason" = "clear" ] && exit 0 [ "$reason" = "clear" ] && exit 0
session_id=$(scribe_json_pick "$event_flat" '.session_id') command -v curl >/dev/null 2>&1 || exit 0
[ -n "$session_id" ] || exit 0 scribe_config || exit 0
sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0 sid_enc=$(printf '%s' "$session_id" | scribe_urlenc) || exit 0
curl -fsS --max-time 4 \ curl -fsS --max-time 4 \
+7
View File
@@ -159,6 +159,10 @@ _READ_ONLY_TOOLS = frozenset({
# retrieval_telemetry's reason, and needed by a read key so that a line # retrieval_telemetry's reason, and needed by a read key so that a line
# naming a moment can be understood by whoever was shown it. # naming a moment can be understood by whoever was shown it.
"list_moments", "list_moments",
# The pass over the corpus and its queue (milestone 458 step 7): which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
"rules_to_mount", "rule_moment_proposals",
}) })
# Every tool that WRITES, by name. Nothing reads this set at runtime — a tool # Every tool that WRITES, by name. Nothing reads this set at runtime — a tool
@@ -210,6 +214,9 @@ _WRITE_TOOLS = frozenset({
# Which actions reach which moment on this install (milestone 458). Each # Which actions reach which moment on this install (milestone 458). Each
# changes what fires for every later session, so a read key is refused. # changes what fires for every later session, so a read key is refused.
"map_action", "unmap_action", "map_action", "unmap_action",
# Proposing moments for a rule writes a judgment row; judging one mounts
# or unmounts the rule (step 7).
"propose_rule_moments", "judge_rule_moments",
# 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",
+67
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from scribe.services import moment_actions as actions_svc from scribe.services import moment_actions as actions_svc
from scribe.services import moments as moments_svc from scribe.services import moments as moments_svc
from scribe.services import rule_moment_judgments as judgments_svc
async def list_moments() -> dict: async def list_moments() -> dict:
@@ -105,7 +106,73 @@ async def unmap_action(tool: str, moment: str, match: str = "", reason: str = ""
) )
async def rules_to_mount(limit: int = 25, offset: int = 0) -> dict:
"""The rules nobody has decided the moments of — the pass over the corpus, a page at a time.
A rule written before moments existed is mounted on nothing and arrives
only when its words resemble the work. Read each rule here, decide WHEN it
applies, and record the answer with `propose_rule_moments`: the moments it
belongs on, or that none fits because it is about WHAT is done rather than
when. A rule leaves this list as soon as any answer is recorded, so the
next call returns the next unread rules.
Returns `rules` (id, title, kind, statement, when_to_apply, home),
`total_unjudged`, and the `catalog` to choose from.
"""
return await judgments_svc.unjudged_rules(current_user_id(), limit=limit, offset=offset)
async def propose_rule_moments(proposals: list[dict]) -> dict:
"""Propose the moments rules belong on — for the operator to confirm, never mounted by this call.
Each item is either
`{"rule_id": N, "moments": ["work.finish", "reply.report"], "why": "…"}`
or `{"rule_id": N, "none": "why no moment fits"}`.
Propose a moment when the rule governs that point in the work whatever
the work is about — a rule about when work counts as done belongs where
work is finished, delivered, verified and reported. Say none fits when
the rule is about a subject (a library, a file, a style) and is best
reached by meaning. `why` is shown to the operator beside the proposal;
write it so they can say yes or no without opening the rule.
A moment proposal waits as `suggested` until judged; show the operator
what you proposed (`rule_moment_proposals`) and let them decide. A pair
already mounted or judged is skipped and listed under `skipped`; a bad
item is refused alone under `refused`.
"""
return await judgments_svc.propose(current_user_id(), proposals)
async def rule_moment_proposals(rule_id: int = 0) -> dict:
"""The moment proposals waiting on the operator, grouped by rule.
Each rule carries what it is mounted on now and its proposals: the moment,
where the proposal came from (`pass` — read from the rule; `signal` — the
rule kept being opened just after that moment fired), 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)
async def judge_rule_moments(judgments: list[dict]) -> dict:
"""Confirm or reject moment proposals — on the operator's word, since a confirm mounts the rule.
Each item is `{"rule_id": N, "moment": "work.finish", "verdict":
"confirm" | "reject", "note": "why"}`. Confirm mounts the rule on that
moment beside what it already has; reject records that it does not 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`.
"""
return await judgments_svc.judge(current_user_id(), judgments)
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)
mcp.tool(name="unmap_action")(unmap_action) mcp.tool(name="unmap_action")(unmap_action)
mcp.tool(name="rules_to_mount")(rules_to_mount)
mcp.tool(name="propose_rule_moments")(propose_rule_moments)
mcp.tool(name="rule_moment_proposals")(rule_moment_proposals)
mcp.tool(name="judge_rule_moments")(judge_rule_moments)
+2
View File
@@ -82,6 +82,8 @@ from scribe.models.rulebook import ( # noqa: E402, F401
) )
# After notes and rules: it foreign-keys both (milestone 440). # After notes and rules: it foreign-keys both (milestone 440).
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink # noqa: E402, F401 from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink # noqa: E402, F401
# After rules: it foreign-keys them (milestone 458 step 7).
from scribe.models.rule_moment_judgment import RuleMomentJudgment # noqa: E402, F401
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401 from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401 from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
+81
View File
@@ -0,0 +1,81 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
from scribe.models.lesson_rule_link import CONFIRMED, LINK_STATES, REJECTED, SUGGESTED
# The same three states as a lesson's link to a rule, for the same reasons —
# re-exported so a reader of this table need not know where they were first
# named. CHECK ck_rule_moment_judgments_state (migration 0118, rule 36).
JUDGMENT_STATES = LINK_STATES
__all__ = [
"CONFIRMED", "JUDGMENT_STATES", "NO_MOMENT", "REJECTED", "SUGGESTED",
"SOURCES", "RuleMomentJudgment",
]
# The moment recorded for "this rule is about WHAT, not WHEN": no moment in
# the catalog is when it applies, and it is right to reach it by meaning. A
# row rather than an absence, for the reason LessonNoRule is one — a rule
# looked at and found to have no moment must not read as one nobody looked at,
# or every pass over the corpus would propose it again.
NO_MOMENT = ""
# Where a judgment came from. `pass` — an agent read the rule and proposed;
# `signal` — the rule kept being opened just after a moment fired; `edit` — a
# person changed the rule's moments directly, which is a judgment too.
SOURCES = ("pass", "signal", "edit")
class RuleMomentJudgment(Base, CreatedAtMixin):
"""Whether a rule belongs on a moment, and who said so (milestone 458 step 7).
A mount (`rule_moments`) is the current answer; this is the reasoning
behind it and the answers that are NOT mounts. One row per (rule, moment):
- ``suggested`` — proposed by a pass, or by repeated opens after the
moment fired. Delivers nothing: a suggestion that mounted itself would
manufacture the opens it counts.
- ``confirmed`` — a judgment put the rule on the moment. The mount exists.
- ``rejected`` — a judgment said it does not belong there, kept with its
reason so the pair is never proposed again, by either source.
Moment ``""`` (NO_MOMENT), confirmed, is "no moment fits this rule".
"""
__tablename__ = "rule_moment_judgments"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
rule_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True,
)
moment: Mapped[str] = mapped_column(Text)
state: Mapped[str] = mapped_column(Text, default=SUGGESTED, server_default=SUGGESTED)
source: Mapped[str] = mapped_column(Text, default="pass", server_default="pass")
# Why — for a suggestion, what the proposer read in the rule; for a
# judgment, why it was confirmed or rejected.
note: Mapped[str | None] = mapped_column(Text, nullable=True)
# The co-occurrence evidence (signal source), in lesson_rules' shape.
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
judged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
__table_args__ = (
UniqueConstraint("rule_id", "moment", name="uq_rule_moment_judgments_pair"),
)
def to_dict(self) -> dict:
return {
"rule_id": self.rule_id,
"moment": self.moment,
"state": self.state,
"source": self.source,
"note": self.note or "",
"evidence": self.evidence or {},
"judged_at": iso(self.judged_at),
"created_at": iso(self.created_at),
}
+30
View File
@@ -17,6 +17,7 @@ from scribe.services import moment_delivery as moment_delivery_svc
from scribe.services import plugin_context as plugin_ctx_svc from scribe.services import plugin_context as plugin_ctx_svc
from scribe.services import repo_bindings as repo_bindings_svc from scribe.services import repo_bindings as repo_bindings_svc
from scribe.services import report_check as report_check_svc from scribe.services import report_check as report_check_svc
from scribe.services import rule_moment_judgments as judgments_svc
from scribe.services import shape_check as shape_check_svc from scribe.services import shape_check as shape_check_svc
from scribe.services import task_claims as task_claims_svc from scribe.services import task_claims as task_claims_svc
from scribe.services.embeddings import memoized_query_embeddings from scribe.services.embeddings import memoized_query_embeddings
@@ -294,6 +295,35 @@ async def moment():
}) })
@plugin_bp.post("/rule-opened")
@login_required
async def rule_opened():
"""A session opened a rule shortly after these acts (milestone 458 step 7).
Body: `{"rule_id", "session_id", "acts": [<PreToolUse event>, ...]}` —
the calls the plugin saw in the window before the `get_rule`. Each is
resolved to its moments, and the open is counted as evidence that the
rule belongs on them; once a pair has repeated across enough distinct
sessions, `context` carries the line asking the reader to offer the
mount. Nothing is mounted here. `repo` / `project_id` scope the evidence.
"""
data = await request.get_json(silent=True) or {}
if not isinstance(data, dict):
return jsonify({"moments": [], "context": ""})
try:
rule_id = int(data.get("rule_id") or 0)
except (TypeError, ValueError):
rule_id = 0
acts = data.get("acts")
if rule_id <= 0 or not isinstance(acts, list):
return jsonify({"moments": [], "context": ""})
project_id, _repo, _unbound = await _project_scope()
return jsonify(await judgments_svc.opened_after(
g.user.id, rule_id, acts, session_id=str(data.get("session_id") or ""),
project_id=project_id or None,
))
@plugin_bp.post("/reply-rules") @plugin_bp.post("/reply-rules")
@login_required @login_required
@memoized_query_embeddings @memoized_query_embeddings
+25
View File
@@ -20,6 +20,7 @@ from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from scribe.services import moment_actions as moment_actions_svc from scribe.services import moment_actions as moment_actions_svc
from scribe.services import moments as moments_svc from scribe.services import moments as moments_svc
from scribe.services import rule_moment_judgments as judgments_svc
from scribe.services import rulebooks as rulebooks_svc from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import moment_usage from scribe.services.retrieval_telemetry import moment_usage
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
@@ -177,3 +178,27 @@ async def map_action_route():
@login_required @login_required
async def unmap_action_route(): async def unmap_action_route():
return await _mapping_change(moment_actions_svc.unmap_action) return await _mapping_change(moment_actions_svc.unmap_action)
@retrieval_bp.route("/moments/proposals", methods=["GET"])
@login_required
async def moment_proposals_route():
"""The moment proposals waiting on a judgment (milestone 458 step 7) —
`rule_moment_proposals`' payload, from the same service. `?rule_id=`
narrows to one rule, for the rule editor."""
try:
rule_id = int(request.args.get("rule_id") or 0)
except ValueError:
return jsonify({"error": "rule_id must be a whole number"}), 400
return jsonify(await judgments_svc.pending(get_current_user_id(), rule_id=rule_id or None))
@retrieval_bp.route("/moments/proposals/judge", methods=["POST"])
@login_required
async def judge_moment_proposals_route():
"""Confirm or reject proposals: `{"judgments": [{rule_id, moment, verdict,
note}]}` — `judge_rule_moments` from the browser. A confirm mounts."""
data = await request.get_json(silent=True)
if not isinstance(data, dict) or not isinstance(data.get("judgments"), list):
return jsonify({"error": "Expected {\"judgments\": [...]}"}), 400
return jsonify(await judgments_svc.judge(get_current_user_id(), data["judgments"]))
+62 -2
View File
@@ -21,6 +21,7 @@ from scribe.models.rulebook import (
RuleRelation, rule_moments as rule_moments_t, rule_systems as rule_systems_t, RuleRelation, rule_moments as rule_moments_t, rule_systems as rule_systems_t,
) )
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink
from scribe.models.rule_moment_judgment import RuleMomentJudgment
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.repo_binding import RepoBinding from scribe.models.repo_binding import RepoBinding
@@ -104,8 +105,12 @@ logger = logging.getLogger(__name__)
# arrives at. A mount is a judgment about when a rule applies that nothing in # arrives at. A mount is a judgment about when a rule applies that nothing in
# the rule's text records, and losing it would put back exactly the misses the # the rule's text records, and losing it would put back exactly the misses the
# mounts were made to fix. # mounts were made to fix.
# v23 (2026-10) added rule_moment_judgments (milestone 458 step 7): the
# 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
# pair again. Losing them puts every answered question back on the list.
# Bump when the serialized schema changes. # Bump when the serialized schema changes.
BACKUP_VERSION = 22 BACKUP_VERSION = 23
# 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
@@ -154,6 +159,8 @@ _BACKED_UP = [
"moment_mappings", "moment_mappings",
# v22 (2026-10): the moments each rule arrives at (milestone 458). # v22 (2026-10): the moments each rule arrives at (milestone 458).
"rule_moments", "rule_moments",
# v23 (2026-10): proposals and judgments about those mounts (step 7).
"rule_moment_judgments",
] ]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is # Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -253,6 +260,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"rule_relations": {"id", "created_at"}, "rule_relations": {"id", "created_at"},
# The pair is the row; everything else is the judgment and its evidence. # The pair is the row; everything else is the judgment and its evidence.
"lesson_rule_links": {"id"}, "lesson_rule_links": {"id"},
# The same shape: the (rule, moment) pair is the row.
"rule_moment_judgments": {"id"},
# Keyed by the lesson itself; nothing to exclude, every column travels. # Keyed by the lesson itself; nothing to exclude, every column travels.
"lesson_no_rule": set(), "lesson_no_rule": set(),
"note_usage_events": {"id"}, "note_usage_events": {"id"},
@@ -346,6 +355,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"note_supersessions": {"id", "created_at"}, "note_supersessions": {"id", "created_at"},
"rule_relations": {"id", "created_at"}, "rule_relations": {"id", "created_at"},
"lesson_rule_links": {"id"}, "lesson_rule_links": {"id"},
"rule_moment_judgments": {"id"},
"lesson_no_rule": set(), "lesson_no_rule": set(),
"note_usage_events": {"id"}, "note_usage_events": {"id"},
"rule_usage_events": {"id"}, "rule_usage_events": {"id"},
@@ -755,6 +765,20 @@ def _rule_moment_rows(rows) -> list[dict]:
return [{"rule_id": rule_id, "moment": moment} for rule_id, moment in rows] return [{"rule_id": rule_id, "moment": moment} for rule_id, moment in rows]
def _rule_moment_judgment_rows(rows) -> list[dict]:
"""Proposals and judgments about a rule's moments (v23). The rule id is a
SOURCE id, remapped at restore; the moment is a catalog name."""
return [
{
"rule_id": r.rule_id, "moment": r.moment, "state": r.state,
"source": r.source, "note": r.note, "evidence": r.evidence,
"judged_at": r.judged_at.isoformat() if r.judged_at else None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _rule_system_rows(rows) -> list[dict]: def _rule_system_rows(rows) -> list[dict]:
"""A rule's area tags, carried by canonical SLUG for the same reason the """A rule's area tags, carried by canonical SLUG for the same reason the
Systems are: the catalog is global and its ids are per-install.""" Systems are: the catalog is global and its ids are per-install."""
@@ -850,6 +874,9 @@ async def export_full_backup() -> dict:
rule_moment_rows = (await session.execute( rule_moment_rows = (await session.execute(
select(rule_moments_t.c.rule_id, rule_moments_t.c.moment) select(rule_moments_t.c.rule_id, rule_moments_t.c.moment)
)).all() )).all()
rule_moment_judgments = (await session.execute(
select(RuleMomentJudgment)
)).scalars().all()
rule_relations = (await session.execute(select(RuleRelation))).scalars().all() rule_relations = (await session.execute(select(RuleRelation))).scalars().all()
lesson_rule_links = (await session.execute(select(LessonRuleLink))).scalars().all() lesson_rule_links = (await session.execute(select(LessonRuleLink))).scalars().all()
lesson_no_rule = (await session.execute(select(LessonNoRule))).scalars().all() lesson_no_rule = (await session.execute(select(LessonNoRule))).scalars().all()
@@ -917,6 +944,7 @@ async def export_full_backup() -> dict:
"canonical_systems": _canonical_system_rows(canonical_systems), "canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows), "rule_systems": _rule_system_rows(rule_system_rows),
"rule_moments": _rule_moment_rows(rule_moment_rows), "rule_moments": _rule_moment_rows(rule_moment_rows),
"rule_moment_judgments": _rule_moment_judgment_rows(rule_moment_judgments),
"rule_relations": _rule_relation_rows(rule_relations), "rule_relations": _rule_relation_rows(rule_relations),
"lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links), "lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links),
"lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule), "lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule),
@@ -1055,6 +1083,9 @@ async def export_user_backup(user_id: int) -> dict:
select(rule_moments_t.c.rule_id, rule_moments_t.c.moment) select(rule_moments_t.c.rule_id, rule_moments_t.c.moment)
.where(rule_moments_t.c.rule_id.in_(_rule_ids)) .where(rule_moments_t.c.rule_id.in_(_rule_ids))
)).all() if _rule_ids else [] )).all() if _rule_ids else []
rule_moment_judgments = (await session.execute(
select(RuleMomentJudgment).where(RuleMomentJudgment.rule_id.in_(_rule_ids))
)).scalars().all() if _rule_ids else []
# Scoped through the RULE, not the version's user_id. That column is # Scoped through the RULE, not the version's user_id. That column is
# the ACTOR (milestone 323), so filtering on it would carry the # the ACTOR (milestone 323), so filtering on it would carry the
# versions this user wrote on someone ELSE's rule and drop the ones # versions this user wrote on someone ELSE's rule and drop the ones
@@ -1137,6 +1168,7 @@ async def export_user_backup(user_id: int) -> dict:
"canonical_systems": _canonical_system_rows(canonical_systems), "canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows), "rule_systems": _rule_system_rows(rule_system_rows),
"rule_moments": _rule_moment_rows(rule_moment_rows), "rule_moments": _rule_moment_rows(rule_moment_rows),
"rule_moment_judgments": _rule_moment_judgment_rows(rule_moment_judgments),
"rule_relations": _rule_relation_rows(rule_relations), "rule_relations": _rule_relation_rows(rule_relations),
"lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links), "lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links),
"lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule), "lesson_no_rule": _lesson_no_rule_rows(lesson_no_rule),
@@ -1535,6 +1567,25 @@ def _build_lesson_rule_link(row: dict, maps: _Maps) -> LessonRuleLink | None:
) )
def _build_rule_moment_judgment(row: dict, maps: _Maps) -> RuleMomentJudgment | None:
"""Skipped when its rule did not restore — the judgment is about that
rule, and a remap miss would hang it on whatever took the number."""
rule = maps.rules.get(row.get("rule_id", 0))
if rule is None or row.get("moment") is None:
return None
return RuleMomentJudgment(
rule_id=rule,
moment=row["moment"],
state=row.get("state") or "suggested",
source=row.get("source") or "pass",
note=row.get("note") or None,
evidence=row.get("evidence"),
# Absent stays absent: a suggestion was never judged.
judged_at=_dt_or_none(row.get("judged_at")),
created_at=_dt(row.get("created_at")),
)
def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None: def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None:
rid = maps.rules.get(row.get("rule_id", 0)) rid = maps.rules.get(row.get("rule_id", 0))
if rid is None: if rid is None:
@@ -1949,7 +2000,7 @@ async def _restore_v2(data: dict) -> dict:
"repo_bindings": 0, "repo_bindings": 0,
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0, "note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
"code_shape_uses": 0, "canonical_systems": 0, "code_shape_uses": 0, "canonical_systems": 0,
"rule_systems": 0, "rule_moments": 0, "rule_systems": 0, "rule_moments": 0, "rule_moment_judgments": 0,
"rule_relations": 0, "rule_versions": 0, "rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 0, "retrieval_tuning_events": 0, "lesson_rule_links": 0,
"lesson_no_rule": 0, "moment_mappings": 0, "lesson_no_rule": 0, "moment_mappings": 0,
@@ -2160,6 +2211,15 @@ async def _restore_v2(data: dict) -> dict:
)) ))
stats["rule_moments"] += 1 stats["rule_moments"] += 1
# Proposals and judgments about those mounts (v23); archives before
# v23 carry none, and every rule restores unjudged.
for rj in data.get("rule_moment_judgments", []):
judgment = _build_rule_moment_judgment(rj, maps)
if judgment is None:
continue
session.add(judgment)
stats["rule_moment_judgments"] += 1
for rr in data.get("rule_relations", []): for rr in data.get("rule_relations", []):
relation = _build_rule_relation(rr, maps) relation = _build_rule_relation(rr, maps)
if relation is None: if relation is None:
+6 -4
View File
@@ -485,16 +485,18 @@ async def attach_rule_lessons(user_id: int, data: dict, rule_id: int) -> None:
def situation_key(arm: str, text: str) -> str: 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,
— because the two name different kinds of situation and must not collide. "s" for a session (the rule↔moment signal, milestone 458 step 7) —
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. Empty when there is nothing to key on. them. The session arm keys on the session id as given. Empty when there
is nothing to key on.
""" """
if arm == "w": if arm in ("w", "s"):
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())
@@ -0,0 +1,527 @@
"""Which rules belong on which moments — proposals and judgments (milestone 458 step 7).
A rule written before moments existed is mounted on nothing, and reaches a
session only when its words resemble the work. Mounting the corpus is a
judgment about each rule — WHEN does it apply? — and nothing in a rule's row
says whether that judgment was ever made. This service records it, from two
sources that both stop at a proposal:
- THE PASS. An agent reads the rules nobody has judged (`unjudged_rules`),
proposes moments for each, or says none fits (`propose`). A proposal mounts
nothing; a person confirms it (`judge`).
- THE SIGNAL. A rule a session OPENS shortly after a moment fired is evidence
it belongs on that moment. Recorded per distinct session with the evidence
model lesson↔rule soft links use (lesson_rules: three situations, a
cooldown), and once it crosses the bar the next open asks the reader to
offer the mount (`co_occurred`).
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
mount.
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
write path, `rulebooks.set_rule_moments`) confirms what was added and rejects
what was removed — a moment taken off a rule must not be proposed straight
back by the signal.
ACL (rule 78): rules are owner-scoped, and every read and write here goes
through `rulebooks._owned_rules_clause` / `_fetch_owned_rule`.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlalchemy import exists, func, not_, select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.rule_moment_judgment import (
CONFIRMED, NO_MOMENT, REJECTED, SUGGESTED, RuleMomentJudgment,
)
from scribe.models.rulebook import Rule, rule_moments as rule_moments_t
from scribe.services import lesson_rules
from scribe.services import moments as moments_svc
logger = logging.getLogger(__name__)
# What a caller says to judge one pair — acts, not the states they produce.
VERDICTS = {"confirm": CONFIRMED, "reject": REJECTED}
# Recorded on the rows an edit wrote, so a reader can tell a direct change from
# an accepted proposal.
_MOUNTED_NOTE = "mounted by an edit"
_UNMOUNTED_NOTE = "unmounted by an edit"
# On a "no moment fits" answer a later mount overturned.
_OVERTURNED_NOTE = "a moment was mounted after all"
# How many unjudged rules one pass page hands over. Enough to batch the
# reading, few enough that every rule is actually read.
PASS_PAGE = 25
# How long after a moment an open still counts as following it. Long enough
# for a session to see a line, decide it matters and open the rule; short
# enough that the moment is still what the work is doing.
SIGNAL_WINDOW_SECONDS = 180
# Moments too ubiquitous to say anything about the rule opened after them:
# nearly every open follows a command or a change, so counting them would
# propose every rule onto both.
SIGNAL_SKIP = frozenset({"work.run", "work.change"})
def _clean_moment(name) -> str:
"""A catalog moment, normalised; "" stays "" (no moment fits)."""
raw = (name or "").strip() if isinstance(name, str) else ""
if raw == NO_MOMENT:
return NO_MOMENT
return moments_svc.require_moment(raw)
def _int(v) -> int | None:
try:
i = int(v)
except (TypeError, ValueError):
return None
return i if i > 0 else None
async def _owned_rules(session, user_id: int, rule_ids) -> dict[int, Rule]:
from scribe.services.rulebooks import _owned_rules_clause
ids = [i for i in (_int(r) for r in rule_ids or []) if i]
if not ids:
return {}
rows = (await session.execute(
select(Rule).where(Rule.id.in_(ids)).where(_owned_rules_clause(user_id))
)).scalars().all()
return {r.id: r for r in rows}
async def _mounts(session, rule_ids) -> dict[int, set[str]]:
if not rule_ids:
return {}
rows = (await session.execute(
select(rule_moments_t.c.rule_id, rule_moments_t.c.moment)
.where(rule_moments_t.c.rule_id.in_(list(rule_ids)))
)).all()
out: dict[int, set[str]] = {}
for rid, moment in rows:
out.setdefault(rid, set()).add(moment)
return out
async def _rows(session, rule_ids) -> dict[tuple[int, str], RuleMomentJudgment]:
if not rule_ids:
return {}
rows = (await session.execute(
select(RuleMomentJudgment).where(RuleMomentJudgment.rule_id.in_(list(rule_ids)))
)).scalars().all()
return {(r.rule_id, r.moment): r for r in rows}
def _brief(rule: Rule) -> dict:
return {
"id": rule.id,
"title": rule.title,
"kind": rule.kind or "rule",
"statement": rule.statement,
"when_to_apply": rule.when_to_apply or "",
"home": "project" if rule.project_id else "global",
}
# ── The pass ─────────────────────────────────────────────────────────────────
def _unjudged_clause():
"""No mount and no judgment row of any kind — a rule nobody has looked at."""
mounted = exists().where(rule_moments_t.c.rule_id == Rule.id)
judged = exists().where(RuleMomentJudgment.rule_id == Rule.id)
return not_(mounted), not_(judged)
async def unjudged_rules(user_id: int, limit: int = PASS_PAGE, offset: int = 0) -> dict:
"""The caller's rules with no moment and no judgment, a page at a time.
A rule leaves this list the moment any answer is recorded for it — a
proposal, a mount, or "no moment fits" — so a pass run in pieces, or by
two sessions, does not read the same rule twice.
"""
from scribe.services.rulebooks import _owned_rules_clause
limit = max(1, min(int(limit or PASS_PAGE), 100))
offset = max(0, int(offset or 0))
clauses = (_owned_rules_clause(user_id), *_unjudged_clause())
async with async_session() as session:
total = (await session.execute(
select(func.count()).select_from(Rule).where(*clauses)
)).scalar_one()
rows = (await session.execute(
select(Rule).where(*clauses).order_by(Rule.id).limit(limit).offset(offset)
)).scalars().all()
return {
"rules": [_brief(r) for r in rows],
"total_unjudged": int(total),
"offset": offset,
"catalog": moments_svc.catalog(),
}
async def propose(user_id: int, proposals: list[dict]) -> dict:
"""Record a pass's proposals: moments for a rule, or that none fits.
Each item is `{rule_id, moments: [...], why}` or `{rule_id, none: "why"}`.
A moment proposal lands `suggested` and mounts nothing. "No moment fits"
lands as the agent's own answer (confirmed, moment ""): it changes nothing
that surfaces, and it is what keeps the rule off the next pass — the
signal can still propose a moment for it later, on evidence.
A pair already mounted or already judged is skipped and said so; a pair
already suggested has its reason refreshed. A bad item is refused alone,
with why, and the rest are written.
"""
now = datetime.now(timezone.utc)
proposed, no_moment = 0, 0
skipped: list[dict] = []
refused: list[dict] = []
async with async_session() as session:
owned = await _owned_rules(session, user_id, [p.get("rule_id") for p in proposals or []
if isinstance(p, dict)])
mounts = await _mounts(session, list(owned))
existing = await _rows(session, list(owned))
for item in proposals or []:
if not isinstance(item, dict):
refused.append({"item": item, "error": "each proposal is an object"})
continue
rid = _int(item.get("rule_id"))
if rid is None or rid not in owned:
refused.append({"rule_id": item.get("rule_id"),
"error": "not a rule you own"})
continue
none_why = (item.get("none") or "").strip() if isinstance(item.get("none"), str) else ""
names = item.get("moments")
if none_why and names:
refused.append({"rule_id": rid, "error": (
"moments and none are the two answers to \"when does this rule "
"apply?\" — give the one that holds")})
continue
if none_why:
if mounts.get(rid):
skipped.append({"rule_id": rid, "moment": NO_MOMENT,
"why": "already mounted on " + ", ".join(sorted(mounts[rid]))})
continue
row = existing.get((rid, NO_MOMENT))
if row is None:
row = RuleMomentJudgment(rule_id=rid, moment=NO_MOMENT, source="pass")
session.add(row)
existing[(rid, NO_MOMENT)] = row
row.state, row.note, row.judged_at = CONFIRMED, none_why, now
no_moment += 1
continue
try:
wanted = moments_svc.require_moments(names if names is not None else []) or []
except ValueError as exc:
refused.append({"rule_id": rid, "error": str(exc)})
continue
if not wanted:
refused.append({"rule_id": rid, "error": (
"name at least one moment, or say none fits with none=\"why\"")})
continue
why = (item.get("why") or "").strip() if isinstance(item.get("why"), str) else ""
for moment in wanted:
if moment in mounts.get(rid, set()):
skipped.append({"rule_id": rid, "moment": moment, "why": "already mounted"})
continue
row = existing.get((rid, moment))
if row is not None and row.state != SUGGESTED:
skipped.append({"rule_id": rid, "moment": moment,
"why": f"already {row.state}" + (f": {row.note}" if row.note else "")})
continue
if row is None:
row = RuleMomentJudgment(rule_id=rid, moment=moment, state=SUGGESTED, source="pass")
session.add(row)
existing[(rid, moment)] = row
row.note = why or row.note
proposed += 1
await session.commit()
return {"proposed": proposed, "no_moment": no_moment,
"skipped": skipped, "refused": refused}
async def pending(user_id: int, rule_id: int | None = None, limit: int = 200) -> dict:
"""The proposals waiting on a judgment, grouped by rule.
Each rule carries what it is mounted on now, so a proposal is read beside
the mounts it would add to.
"""
from scribe.services.rulebooks import _owned_rules_clause
limit = max(1, min(int(limit or 200), 500))
async with async_session() as session:
stmt = (
select(RuleMomentJudgment, Rule)
.join(Rule, Rule.id == RuleMomentJudgment.rule_id)
.where(RuleMomentJudgment.state == SUGGESTED, _owned_rules_clause(user_id))
)
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})
order = {name: i for i, name in enumerate(moments_svc.MOMENTS)}
grouped: dict[int, dict] = {}
for judgment, rule in rows:
entry = grouped.setdefault(rule.id, {
**_brief(rule),
"mounted": sorted(mounts.get(rule.id, set()),
key=lambda n: (order.get(n, len(order)), n)),
"proposals": [],
})
entry["proposals"].append({
"moment": judgment.moment,
"source": judgment.source,
"why": judgment.note or "",
"evidence": lesson_rules.evidence_summary(judgment.evidence),
"created_at": judgment.created_at.isoformat() if judgment.created_at else None,
})
rules = list(grouped.values())
return {"rules": rules, "total": sum(len(r["proposals"]) for r in rules)}
async def judge(user_id: int, judgments: list[dict]) -> dict:
"""Confirm or reject proposals — or any (rule, moment) pair, proposed or not.
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,
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 ""
judges the "no moment fits" answer itself. A bad item is refused alone.
"""
from scribe.services.rulebooks import list_rule_moments, set_rule_moments
done: list[dict] = []
refused: list[dict] = []
for item in judgments or []:
if not isinstance(item, dict):
refused.append({"item": item, "error": "each judgment is an object"})
continue
rid = _int(item.get("rule_id"))
state = VERDICTS.get(str(item.get("verdict") or "").strip().lower())
if rid is None or state is None:
refused.append({"rule_id": item.get("rule_id"), "moment": item.get("moment"),
"error": f"needs rule_id and a verdict, one of {sorted(VERDICTS)}"})
continue
try:
moment = _clean_moment(item.get("moment"))
except ValueError as exc:
refused.append({"rule_id": rid, "error": str(exc)})
continue
note = (item.get("note") or "").strip() if isinstance(item.get("note"), str) else ""
if moment == NO_MOMENT:
ok = await _judge_no_moment(user_id, rid, state, note)
else:
current = (await list_rule_moments([rid])).get(rid, [])
after = (current + [moment] if state == CONFIRMED and moment not in current
else [m for m in current if m != moment] if state == REJECTED
else current)
ok = await set_rule_moments(rid, user_id, after, note=note, judged=[moment],
verdict=state) is not None
if not ok:
refused.append({"rule_id": rid, "moment": moment, "error": "not a rule you own"})
continue
done.append({"rule_id": rid, "moment": moment, "state": state})
return {"judged": done, "refused": refused}
async def _judge_no_moment(user_id: int, rule_id: int, state: str, note: str) -> bool:
now = datetime.now(timezone.utc)
async with async_session() as session:
if not await _owned_rules(session, user_id, [rule_id]):
return False
row = (await _rows(session, [rule_id])).get((rule_id, NO_MOMENT))
if row is None:
row = RuleMomentJudgment(rule_id=rule_id, moment=NO_MOMENT, source="edit")
session.add(row)
row.state, row.judged_at = state, now
row.note = note or row.note
await session.commit()
return True
async def record_mount_change(
session, rule_id: int, before, after, *, note: str = "",
judged=(), verdict: str | None = None,
) -> None:
"""Record the judgments a change of mounts makes, in the caller's session.
Added moments are confirmed, removed ones rejected — a mount taken off
must not be proposed straight back. `judged` names moments a `judge`
call decided explicitly, so a rejection of a moment that was never
mounted is recorded too. A suggestion that was accepted keeps its source,
so the record still says where the idea came from. Any mount overturns a
"no moment fits" answer.
"""
before, after = set(before or ()), set(after or ())
added = after - before
removed = before - after
explicit = {m for m in judged or () if m} - added - removed
if not (added or removed or explicit):
return
now = datetime.now(timezone.utc)
rows = await _rows(session, [rule_id])
def put(moment: str, state: str, default_note: str) -> None:
row = rows.get((rule_id, moment))
if row is None:
row = RuleMomentJudgment(rule_id=rule_id, moment=moment, source="edit")
session.add(row)
rows[(rule_id, moment)] = row
row.state, row.judged_at = state, now
row.note = note or default_note or row.note
for moment in added:
put(moment, CONFIRMED, _MOUNTED_NOTE)
for moment in removed:
put(moment, REJECTED, _UNMOUNTED_NOTE)
for moment in explicit:
if verdict in (CONFIRMED, REJECTED):
put(moment, verdict, "")
if after:
none_row = rows.get((rule_id, NO_MOMENT))
if none_row is not None and none_row.state == CONFIRMED:
none_row.state, none_row.judged_at = REJECTED, now
none_row.note = _OVERTURNED_NOTE
# ── The signal ───────────────────────────────────────────────────────────────
def _proposal_line(rule: Rule, moment: str, evidence: dict) -> str:
counts = lesson_rules.evidence_summary(evidence)
kind = rule.kind or "rule"
return (
f"> {kind.capitalize()} #{rule.id} \"{rule.title}\" has been opened just after "
f"`{moment}` in {counts['situations']} distinct sessions — it may belong on "
f"that moment, where it would arrive without being searched for. Offer the "
f"operator the mount in one line; on their yes, `judge_rule_moments("
f"[{{\"rule_id\": {rule.id}, \"moment\": \"{moment}\", \"verdict\": \"confirm\", "
f"\"note\": \"why\"}}])` mounts it; on a no, `\"reject\"` with their why stops "
f"the asking."
)
def signal_moments(moments) -> list[str]:
"""The moments an open may be counted against: catalog or procedure
moments, minus the ubiquitous ones, de-duplicated in order."""
out: list[str] = []
for m in moments or []:
name = (m or "").strip().lower() if isinstance(m, str) else ""
if name and name not in SIGNAL_SKIP and moments_svc.is_moment(name) and name not in out:
out.append(name)
return out
async def co_occurred(
user_id: int, rule_id: int, moments, *, situation: str,
project_id: int | None = None,
) -> str:
"""Record that a rule was opened shortly after these moments fired, and
return a proposal line once a (rule, moment) pair has earned one.
`situation` is what makes two opens distinct — the session, so a rule
opened after every push in one long session counts once. A pair already
mounted or judged gathers nothing. One proposal per open at most. Fails
open: this rides a hook, and bookkeeping must never break the read.
"""
try:
return await _co_occurred(user_id, rule_id, moments, situation, project_id)
except Exception:
logger.warning("rule/moment co-occurrence could not be recorded", exc_info=True)
return ""
async def _co_occurred(user_id, rule_id, moments, situation, project_id) -> str:
wanted = signal_moments(moments)
key = lesson_rules.situation_key("s", situation)
rid = _int(rule_id)
if not wanted or not key or rid is None:
return ""
now = datetime.now(timezone.utc)
proposal = None
async with async_session() as session:
owned = await _owned_rules(session, user_id, [rid])
rule = owned.get(rid)
if rule is None:
return ""
mounted = (await _mounts(session, [rid])).get(rid, set())
existing = await _rows(session, [rid])
for moment in wanted:
if moment in mounted:
continue
row = existing.get((rid, moment))
if row is not None and row.state != SUGGESTED:
continue
if row is None:
row = RuleMomentJudgment(rule_id=rid, moment=moment, state=SUGGESTED, source="signal")
session.add(row)
ev = lesson_rules.add_evidence(row.evidence, key, project_id, now)
if proposal is None and lesson_rules.proposal_due(ev, now):
ev["proposed_at"] = now.isoformat()
ev["proposed_count"] = int(ev.get("proposed_count") or 0) + 1
proposal = (moment, ev)
row.evidence = ev
try:
await session.commit()
except IntegrityError:
# Two opens created the same pair at once; the other stands.
await session.rollback()
return ""
if proposal is None:
return ""
return _proposal_line(rule, *proposal)
# The most acts one open is checked against. The hook sends only the window's
# acts, so this bounds a misbehaving client rather than an ordinary session.
_ACTS_CAP = 30
async def opened_after(
user_id: int, rule_id: int, acts, *, session_id: str,
project_id: int | None = None,
) -> dict:
"""A rule was opened; `acts` are the tool calls that came just before it.
Each act is a PreToolUse event as the plugin recorded it (`tool_name`,
`tool_input`), resolved to its moments by the same mappings delivery
uses — so a moment counted here is one that fired, on this install, for
that call. Returns `moments` (what the window reached, after the skip
list) and `context` (a proposal line, or "").
"""
from scribe.services.moment_actions import moments_for
reached: list[str] = []
for act in list(acts or [])[:_ACTS_CAP]:
if not isinstance(act, dict):
continue
tool = str(act.get("tool_name") or "").strip()
if not tool:
continue
tool_input = act.get("tool_input")
try:
hits = await moments_for(user_id, tool, tool_input if isinstance(tool_input, dict) else {})
except Exception:
logger.debug("acts before an open could not be resolved", exc_info=True)
continue
for hit in hits:
name = hit.get("moment") if isinstance(hit, dict) else None
if name and name not in reached:
reached.append(name)
moments = signal_moments(reached)
context = ""
if moments and (session_id or "").strip():
context = await co_occurred(
user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id,
)
return {"moments": moments, "context": context}
+14 -1
View File
@@ -1111,7 +1111,8 @@ async def set_rule_systems(
async def set_rule_moments( async def set_rule_moments(
rule_id: int, user_id: int, moments: list[str], rule_id: int, user_id: int, moments: list[str], *,
note: str = "", judged=(), verdict: str | None = None,
) -> list[str] | None: ) -> list[str] | None:
"""Replace which MOMENTS a rule arrives at (milestone 458). None if not owned. """Replace which MOMENTS a rule arrives at (milestone 458). None if not owned.
@@ -1119,15 +1120,24 @@ async def set_rule_moments(
Every name is checked against the catalog first and the whole write is Every name is checked against the catalog first and the whole write is
refused on the first unknown one — a typo stored as a mount would read refused on the first unknown one — a typo stored as a mount would read
back as attached and never fire, the silent miss moments exist to end. back as attached and never fire, the silent miss moments exist to end.
THE ONE WRITE PATH for mounts, so it is also where a change is recorded as
a judgment (step 7): what was added is confirmed, what was removed is
rejected, in the same transaction as the mounts. `note`, `judged` and
`verdict` come from `rule_moment_judgments.judge`, which says why.
""" """
from scribe.models.rulebook import rule_moments as rule_moments_t from scribe.models.rulebook import rule_moments as rule_moments_t
from scribe.services.moments import require_moments from scribe.services.moments import require_moments
from scribe.services.rule_moment_judgments import record_mount_change
wanted = require_moments(moments) or [] wanted = require_moments(moments) or []
async with async_session() as session: async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id) rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None: if rule is None:
return None return None
before = (await session.execute(
select(rule_moments_t.c.moment).where(rule_moments_t.c.rule_id == rule_id)
)).scalars().all()
await session.execute( await session.execute(
sql_delete(rule_moments_t).where(rule_moments_t.c.rule_id == rule_id) sql_delete(rule_moments_t).where(rule_moments_t.c.rule_id == rule_id)
) )
@@ -1135,6 +1145,9 @@ async def set_rule_moments(
await session.execute( await session.execute(
insert(rule_moments_t).values(rule_id=rule_id, moment=moment) insert(rule_moments_t).values(rule_id=rule_id, moment=moment)
) )
await record_mount_change(
session, rule_id, before, wanted, note=note, judged=judged, verdict=verdict,
)
await session.commit() await session.commit()
return wanted return wanted
@@ -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 import async_session
from scribe.models.project import Project 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.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User from scribe.models.user import User
from scribe.services import backup 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"]) == [ assert sorted(r["moment"] for r in payload["rule_moments"]) == [
"reply.report", "work.finish", "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 payload["users"][0]["username"] = RESTORED_USERNAME
stats = await backup.restore_full_backup(payload) stats = await backup.restore_full_backup(payload)
assert stats["rule_moments"] == 2 assert stats["rule_moments"] == 2
assert stats["rule_moment_judgments"] == 2
async with async_session() as s: async with async_session() as s:
restored_user = (await s.execute( 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] == [ assert (await rulebooks_svc.list_rule_moments([restored_rule.id]))[restored_rule.id] == [
"work.finish", "reply.report", "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: 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() mcp = FakeMCP()
tool.register(mcp) tool.register(mcp)
assert mcp.names == ["list_moments", "map_action", "unmap_action"] assert mcp.names == [
assert "list_moments" in _READ_ONLY_TOOLS "list_moments", "map_action", "unmap_action",
assert {"map_action", "unmap_action"} <= _WRITE_TOOLS "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(): def test_both_doors_read_one_catalog():
+2
View File
@@ -44,6 +44,8 @@ def test_every_endpoint_is_reachable_on_the_app():
"/api/retrieval/tuning-history", "/api/retrieval/tuning-history",
"/api/retrieval/moments", "/api/retrieval/moments",
"/api/retrieval/moments/mappings", "/api/retrieval/moments/mappings",
"/api/retrieval/moments/proposals",
"/api/retrieval/moments/proposals/judge",
} }
+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)
+7 -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 (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 == 22 assert backup.BACKUP_VERSION == 23
def _exportable_note(**over): def _exportable_note(**over):
@@ -132,6 +132,7 @@ def _column_guard_targets():
from scribe.models.canonical_system import CanonicalSystem from scribe.models.canonical_system import CanonicalSystem
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink 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.design_system import DesignSystem, DesignToken
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
from scribe.models.note import Note from scribe.models.note import Note
@@ -172,6 +173,7 @@ def _column_guard_targets():
"rule_relations": (RuleRelation, backup._rule_relation_rows), "rule_relations": (RuleRelation, backup._rule_relation_rows),
"lesson_rule_links": (LessonRuleLink, backup._lesson_rule_link_rows), "lesson_rule_links": (LessonRuleLink, backup._lesson_rule_link_rows),
"lesson_no_rule": (LessonNoRule, backup._lesson_no_rule_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), "note_usage_events": (NoteUsageEvent, backup._usage_event_rows),
"rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows), "rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows),
"system_usage_events": (SystemUsageEvent, backup._system_usage_event_rows), "system_usage_events": (SystemUsageEvent, backup._system_usage_event_rows),
@@ -292,6 +294,7 @@ def _import_guard_targets():
"rule_relations": backup._build_rule_relation, "rule_relations": backup._build_rule_relation,
"lesson_rule_links": backup._build_lesson_rule_link, "lesson_rule_links": backup._build_lesson_rule_link,
"lesson_no_rule": backup._build_lesson_no_rule, "lesson_no_rule": backup._build_lesson_no_rule,
"rule_moment_judgments": backup._build_rule_moment_judgment,
"note_usage_events": backup._build_usage_event, "note_usage_events": backup._build_usage_event,
"rule_usage_events": backup._build_rule_usage_event, "rule_usage_events": backup._build_rule_usage_event,
"system_usage_events": backup._build_system_usage_event, "system_usage_events": backup._build_system_usage_event,
@@ -551,7 +554,9 @@ async def test_export_full_backup_contains_every_declared_section():
# v19: the lessons judged to fall under no rule. # v19: the lessons judged to fall under no rule.
"lesson_no_rule", "lesson_no_rule",
# v20: whether an area's rulings were read once shown. # 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 key in out, f"missing export section: {key}"
assert out[key] == [] assert out[key] == []