feat(plugin): the seam that erases the evidence is where the unresolved rules get named (#4216)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 14s

Step 5 of milestone 419. The milestone's subject is that a rule read and
ignored is arithmetically identical to a rule read and followed, and the
compaction is where that identity becomes permanent — the turns holding the
evidence are summarised away, and the unjudged thing survives as nothing.

WHY THIS IS ASSEMBLED IN THE HOOK. `rule_usage_events` has no session column;
it is per user over a window. A session-scoped answer therefore cannot be
asked of the server, and has to be built where a session is a thing that
exists. Four ledgers four hooks already write:

  .rules.ids      an arm NAMED the rule
  .opened.ids     the session called get_rule      (#4100)
  .acted.ids      the session called rule_outcome  (new here)
  .checkpoint.ids the rule HELD an act             (#4214)

Every one is an observed tool call. Nothing asks the model what it followed —
milestone 386 ruled that out, because a model asked "did you apply rule 156?"
says yes. Two subtractions: named-minus-opened is the arm talking to nobody,
opened-minus-acted is the milestone's whole subject.

PreCompact stdout is the compaction's custom instructions (#3680), not a
message to the model, so the readout does not say "you slipped" — it says
which ids must be carried through, which is the one thing a summary can do
about an unjudged finding.

SILENT WHEN NOTHING HAPPENED, and the accusations are conditional on having
members. "0 rules unresolved" on every compaction is how a readout teaches
its reader to skip it. Traffic is still reported, because the static
instructions already ask for it in prose; these lines are the measured
version.

scribe_record_outcome.sh is the third ledger's writer, matched on
mcp__.*__rule_outcome and mirroring scribe_record_opened.sh: TMPDIR only,
silent, exit 0 on every path. A PostToolUse hook that spoke would put a line
after every rule_outcome call and give recording an outcome a cost.

Also: check_plugin.py skipped the new hook for want of a smoke event, which
would have left the newest of the three ledgers as the only one the plugin
lane never runs. Added, mirroring its sibling.

tests/test_precompact_hook.py now isolates TMPDIR — the hook reads session
ledgers from there, so without isolation a test would see whatever this real
session had accumulated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 00:53:29 -04:00
co-authored by Claude Opus 5
parent fdfb2d94ac
commit ae773740b4
8 changed files with 484 additions and 9 deletions
+9
View File
@@ -62,6 +62,15 @@
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_opened.sh\""
}
]
},
{
"matcher": "mcp__.*__rule_outcome",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_outcome.sh\""
}
]
}
],
"PreCompact": [
+92
View File
@@ -525,6 +525,98 @@ scribe_contract_block() {
return 0
}
# ── The session slippage readout (#4216, milestone 419) ───────────────────
#
# WHAT IT ANSWERS, AND WHY IT CANNOT BE ASKED OF THE SERVER. Which rules fired
# this session, which changed an action, which did not. `rule_usage_events`
# carries no session column — it is per user over a window — so a
# session-scoped answer has to be assembled where a session is a thing that
# exists. That is here, from ledgers four hooks already write:
#
# .rules.ids an arm NAMED the rule (a teaser was shown)
# .opened.ids the session called get_rule (#4100, an observed event)
# .acted.ids the session called rule_outcome (#4216)
# .checkpoint.ids the rule HELD an act (#4214, the strongest)
#
# EVERY LINE IS AN OBSERVED TOOL CALL. Nothing here asks the model what it
# followed — milestone 386 ruled that out, and rightly: a model asked "did you
# apply rule 156?" will say yes. These four files record what HAPPENED.
#
# THE SUBTRACTIONS ARE THE POINT. Named-minus-opened is the arm talking to
# nobody; opened-minus-acted is the milestone's whole subject, a rule read and
# then indistinguishable from one that worked. Neither is an accusation — a
# rule may be read and correctly judged not to apply — which is why the lines
# below ask for the leftovers to be CARRIED, not explained.
scribe_ledger_ids() {
# Live ids from one ledger as space-separated words, for set arithmetic.
# Aged through scribe_rules_live so a rule named two hours ago does not read
# as something this context still holds; a bare-id ledger (the checkpoint
# one) simply has no stamps and survives the ageing unchanged.
local f="$1"
[ -n "$f" ] && [ -f "$f" ] || return 0
scribe_rules_live "$f" | tr ',' ' '
}
scribe_slippage_lines() {
# $1 state dir, $2 sanitised session id.
#
# SILENT ONLY WHEN NO RULE TOUCHED THE SESSION AT ALL. Traffic that did
# happen is always reported, because "which rules governed this work" is
# what the static instructions above already ask the summariser to preserve
# in prose — these lines are the measured version of that, and they are
# three short lines.
#
# What is conditional is the ACCUSATION. Each subtraction prints only when
# it has members, so a session that opened everything it was shown and
# resolved everything it opened gets the traffic and no more. "0 rules
# unresolved" on every compaction is how a readout teaches its reader to
# skip it.
local dir="$1" sid="$2" named opened acted held
[ -n "$dir" ] && [ -n "$sid" ] || return 0
named=$(scribe_ledger_ids "$dir/${sid}.rules.ids")
opened=$(scribe_ledger_ids "$dir/${sid}.opened.ids")
acted=$(scribe_ledger_ids "$dir/${sid}.acted.ids")
held=$(scribe_ledger_ids "$dir/${sid}.checkpoint.ids")
[ -n "$named$opened" ] || return 0
local unread unresolved
unread=$(scribe_ids_minus "$named" "$opened")
unresolved=$(scribe_ids_minus "$opened" "$acted")
printf -- '- This session'"'"'s rule traffic, from what actually happened rather than from recollection:\n'
[ -n "$opened" ] && printf -- ' read: %s\n' "$opened"
[ -n "$held" ] && printf -- ' held an act before it ran: %s\n' "$held"
[ -n "$unread" ] && printf -- ' named by an arm and never opened: %s\n' "$unread"
if [ -n "$unresolved" ]; then
printf -- ' READ WITH NO OUTCOME RECORDED: %s. Carry these over as outstanding. A rule read and left unresolved looks exactly like one that worked, and the summary is where that difference is lost for good — say `rule_outcome(id, "applied")`, or `rule_outcome(id, "departed", why=...)` where you deliberately went another way.\n' "$unresolved"
fi
return 0
}
scribe_ids_minus() {
# Set difference over two space-separated id lists, order preserved. Written
# as one awk pass rather than a nested shell loop because the ledgers can
# hold a few dozen ids by the end of a long session and this runs inside the
# compaction path, where a slow hook delays the thing it is decorating.
local a="$1" b="$2"
[ -n "$a" ] || return 0
awk -v a="$a" -v b="$b" '
BEGIN {
n = split(b, drop, " ")
for (i = 1; i <= n; i++) if (drop[i] != "") skip[drop[i]] = 1
m = split(a, keep, " ")
out = ""
for (i = 1; i <= m; i++) {
id = keep[i]
if (id == "" || (id in skip) || (id in done)) continue
done[id] = 1
out = out (out == "" ? "" : " ") id
}
if (out != "") print out
}
' </dev/null 2>/dev/null
}
scribe_local_dups() {
local root="$1" rel="$2" kind name pat hits count label files
while IFS=$'\t' read -r kind name; do
+41 -5
View File
@@ -46,11 +46,22 @@
# text is the receipt. (Auto-compaction suppresses that notification.)
set -uo pipefail
# Drain the event so the caller never sees a broken pipe. Nothing in it changes
# what we emit: the instruction is the same whether the operator typed
# `/compact` or the session hit its limit, and it composes with any custom
# instructions they gave, which are merged ahead of ours.
cat >/dev/null 2>&1 || true
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# THE EVENT IS NOW READ, where it used to be drained. The old comment here said
# nothing in it changes what we emit, and that was true while every line was
# static. It stopped being true at #4216: the slippage readout below is
# specific to THIS session, and `session_id` is the key to the ledgers that
# hold it. The instruction is still the same whether the operator typed
# `/compact` or the session hit its limit — what varies is the measurement
# appended to it.
event=$(cat 2>/dev/null || true)
session_id=""
if [ -n "$event" ]; then
event_flat=$(printf '%s' "$event" | scribe_json_flat 2>/dev/null || true)
session_id=$(scribe_json_pick "$event_flat" '.session_id' 2>/dev/null || true)
fi
cat <<'EOF'
Preserve the following literally in the summary — copied through, not
@@ -71,4 +82,29 @@ paraphrased or counted:
Everything else here can be recovered from the repository or from Scribe. These
cannot: they are this session's only copy.
EOF
# ── The slippage readout (#4216, milestone 419) ───────────────────────────
#
# WHY IT BELONGS HERE RATHER THAN IN A REPLY. A rule read and left unresolved
# is invisible by construction: it looks exactly like a rule that worked. The
# compaction is where that invisibility becomes permanent — the turns holding
# the evidence are summarised away, and an unjudged thing that survives as
# nothing is how a decision quietly becomes nobody's.
#
# STDOUT HERE IS THE SUMMARISER'S INSTRUCTIONS, not a message to the model
# (the header records how that was established). So this does not say "you
# slipped"; it says WHICH ids must be carried through, which is the one thing
# the summary can do about it. The next turn then reads them in the summary
# with the work still attached.
#
# SILENT WHEN THERE IS NOTHING TO SAY. A session that opened everything it was
# shown gets no extra lines. "0 rules unresolved" on every compaction is how a
# readout teaches its reader to skip it.
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
slippage=$(scribe_slippage_lines "${TMPDIR:-/tmp}/scribe-priorart" "$safe_sid" 2>/dev/null || true)
if [ -n "$slippage" ]; then
printf '\n%s\n' "$slippage"
fi
fi
exit 0
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Scribe — record that this session said what a rule DID, not merely that it
# read one (#4216, milestone 419).
#
# THE THIRD LEDGER, AND WHY THE TWO THAT EXIST ARE NOT ENOUGH.
#
# `.rules.ids` says a rule was NAMED. `.opened.ids` says it was READ (#4100 —
# a PostToolUse hook watches the `get_rule` call, so it is a recorded event
# rather than a model's claim about its own context). Neither can say what
# happened next, and that is the whole of what milestone 419 is about: a rule
# read and followed and a rule read and forgotten leave identical traces.
#
# `rule_outcome` (#4212) is the call that closes that gap, and the server
# records it. But the server's row carries no session — `rule_usage_events` is
# per user over a window — so a SESSION-scoped readout cannot be asked of it.
# The question "which rules changed something in THIS session" has to be
# answered where a session is a thing that exists, which is here.
#
# SAME EVIDENCE CLASS AS `.opened.ids`, deliberately. A tool call happened or
# it did not, and the harness reports it either way; nothing here asks the
# model whether it followed anything. That is the distinction milestone 386
# drew when it ruled out self-report, and this stays on the right side of it.
#
# WHAT IT CANNOT SAY: that the rule was followed WELL, or that `applied` was
# honest. It records that an outcome was declared. The value of that is not the
# claim itself — it is that the absence of one becomes visible, which is the
# state nothing could previously name.
#
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
# turn a successful tool call into a hook error.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
# The matcher in hooks.json narrows to the rule_outcome tools, but the server
# segment of an MCP tool name varies with how the plugin was installed, so the
# id is read from the field rather than from an assumed tool name — the same
# reasoning scribe_record_opened.sh gives.
rule_id=$(scribe_json_pick "$event_flat" '.tool_input.rule_id')
rule_id=$(printf '%s' "$rule_id" | tr -cd '0-9')
[ -n "$rule_id" ] || exit 0
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# Stamped and append-only like its two siblings, so one reader ages them all.
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.acted.ids"
exit 0