#!/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 [] completed successfully: `, 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