Files
FabledScribe/plugin/hooks/scribe_precompact_preserve.sh
T
bvandeusenandClaude Opus 5 4b8d22e3ec
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 45s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m0s
CI & Build / Build & push image (push) Skipped
feat(plugin): a PreCompact hook tells the summarizer what must survive (#3680)
Spike #3680 asked whether a PreCompact hook can reach the model. Read out of
the installed Claude Code build (2.1.273), the answer is yes — through a
different channel than note #3679 assumed:

  * `hookSpecificOutput.additionalContext` is NEVER read on PreCompact. The
    hook-output schema has no PreCompact variant; the field is honoured for
    SessionStart, SubagentStart and Stop, and silently dropped here.

  * A PreCompact hook's STDOUT becomes `newCustomInstructions`, merged with
    the operator's own `/compact` instructions and passed into the prompt that
    writes the summary. Manual, auto and partial compaction all do this.

So the hook does not interrupt the compaction — it steers the summary, which
is what the next turn reads. Blocking is the thing not to do: a PreCompact
block SKIPS compaction, tells the model nothing, and leaves the session
running on uncompacted with no summary at all.

scribe_precompact_preserve.sh names what the summary is the only copy of:
Scribe record ids WITH titles, the in-progress task and its milestone, work
done but not yet recorded, governing rules, and unfinished operator asks.

No network and no config — what must survive is already in the conversation
being summarized; the hook only says which parts are load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-16 08:11:52 -04:00

75 lines
3.8 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
# 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
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
exit 0