feat(plugin): a PreCompact hook tells the summarizer what must survive (#3680)
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

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
This commit is contained in:
2026-09-16 08:11:52 -04:00
co-authored by Claude Opus 5
parent 5e4fd017ae
commit 4b8d22e3ec
5 changed files with 199 additions and 4 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"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).",
"version": "2026.09.15.1921",
"version": "2026.09.16.1211",
"author": {
"name": "Bryan Van Deusen"
},
+10
View File
@@ -55,6 +55,16 @@
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_precompact_preserve.sh\""
}
]
}
],
"Stop": [
{
"hooks": [
@@ -0,0 +1,74 @@
#!/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
+9 -3
View File
@@ -24,9 +24,15 @@
# fires after a compaction (SessionStart input `source` == "compact"), when
# earlier turns have just been summarized and in-flight state is most at risk.
# On that source we lead with a banner telling the model to reload project +
# in-flight tasks from Scribe. (PreCompact is the wrong tool here — a host hook
# can't make the model flush, and can't know the in-flight task ids; the durable
# path is record-as-you-go + this post-compaction reload.)
# in-flight tasks from Scribe. This stays the durable path: record-as-you-go
# plus a post-compaction reload from the instance.
#
# The other half arrived with #3680. A host hook still cannot make the model
# flush before a compaction — but scribe_precompact_preserve.sh steers the
# SUMMARY, because a PreCompact hook's stdout becomes the compaction's custom
# instructions. The two are complementary, and neither replaces the other: that
# hook decides what survives into the summary, this one reloads from the record
# once the summary lands.
#
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a
# shell-form hooks.json command — Claude Code rejects that outright (splicing a