Files
FabledScribe/plugin/hooks/scribe_precompact_preserve.sh
T
bvandeusenandClaude Opus 5 ae773740b4
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
feat(plugin): the seam that erases the evidence is where the unresolved rules get named (#4216)
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
2026-09-21 00:53:29 -04:00

111 lines
5.6 KiB
Bash

#!/usr/bin/env bash
# Scribe plugin — PreCompact: tell the summarizer what must survive (#3680).
#
# WHAT THIS HOOK ACTUALLY DOES, and why it is not what the design first assumed.
#
# Spike #3680 asked whether a PreCompact hook can reach the model, and note
# #3679 assumed the channel would be `hookSpecificOutput.additionalContext`
# plus a block. Both halves of that are wrong, read out of the installed build
# (Claude Code 2.1.273):
#
# * `additionalContext` is NEVER read on PreCompact. The hook-output schema
# has no PreCompact variant at all; the field is honoured for SessionStart,
# SubagentStart, Stop and friends, and silently dropped here.
#
# * A PreCompact hook's STDOUT becomes the compaction's custom instructions.
# The handler collects every hook that exited 0 with non-empty stdout and
# returns it as `newCustomInstructions`, which is merged with whatever the
# operator typed after `/compact` and passed into the prompt that writes
# the summary. Every path does this — manual, auto and partial compaction.
#
# So there IS a model-reaching channel, and it is better than the one designed:
# we do not interrupt the compaction, we steer the summary it produces. The
# summary is what the next turn reads, so text that survives into it survives
# the compaction.
#
# WHAT NOT TO DO HERE: never block. A PreCompact block (exit 2, or
# `{"decision":"block"}`) does not pause for the model and cannot tell it
# anything — the compaction is SKIPPED, a warning goes to the operator's
# screen, and the session continues uncompacted toward its context limit with
# no summary at all. That failure is silent from the model's side, which is
# exactly the outcome #3680 existed to avoid shipping. This hook exits 0 on
# every path.
#
# SCOPE: in a subagent the handler discards hook stdout and keeps only a block,
# so this steers the main session's compaction and nothing else. That is the
# one we care about — a subagent's summary does not outlive it.
#
# NO NETWORK, NO CONFIG. What must be preserved is already in the conversation
# being summarized; this hook's job is to say which parts of it are load-bearing
# so the summarizer keeps them literally instead of compressing them away.
# Naming the in-flight task ids from the instance would need a server round-trip
# inside the compaction path, which is a separate, measurable question.
#
# PROOF THAT IT FIRED: on a manual `/compact` the handler shows the operator
# `PreCompact [<command>] completed successfully: <stdout>`, so the hook's own
# text is the receipt. (Auto-compaction suppresses that notification.)
set -uo pipefail
# 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
paraphrased or counted:
- Every Scribe record the conversation refers to, by id AND title: tasks,
issues, milestones, notes, rules, systems. A bare "#4061" is not enough; a
record whose name is lost has to be looked up again before it can be used.
- Which task is in progress and what its status was last set to, plus the
milestone it sits under.
- Work that was done but NOT yet recorded in Scribe — an edit with no work-log,
a fix not filed as an issue, a decision not written down. Carry these over as
outstanding; they exist nowhere else once this conversation is summarized.
- Any rule or preference that was retrieved and still governs the work, by id
and title.
- Anything the operator asked for that has not been done yet, in their words.
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