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", "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).", "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": { "author": {
"name": "Bryan Van Deusen" "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": [ "Stop": [
{ {
"hooks": [ "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 # fires after a compaction (SessionStart input `source` == "compact"), when
# earlier turns have just been summarized and in-flight state is most at risk. # 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 + # 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 # in-flight tasks from Scribe. This stays the durable path: record-as-you-go
# can't make the model flush, and can't know the in-flight task ids; the durable # plus a post-compaction reload from the instance.
# path is record-as-you-go + this post-compaction reload.) #
# 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 # IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a
# shell-form hooks.json command — Claude Code rejects that outright (splicing a # shell-form hooks.json command — Claude Code rejects that outright (splicing a
+105
View File
@@ -0,0 +1,105 @@
"""The PreCompact hook steers the summary and never blocks the compaction (#3680).
The mechanism this pins was read out of the installed Claude Code build, not
out of the documentation, which describes a different one. Three facts decide
whether the hook works at all, and all three are properties of what the shell
writes rather than of anything Scribe runs:
* **Exit 0.** The handler keeps a hook's stdout only when it `succeeded`,
which is `status === 0`. A non-zero exit sends the same bytes down the
failure branch instead, where they become a line on the operator's screen
and reach the model not at all.
* **Plain text on stdout.** That text is returned as `newCustomInstructions`
and merged into the prompt that writes the summary. JSON is not unwrapped
for this event — the hook-output schema has no PreCompact variant — so a
JSON envelope would be spliced into the summarizer's instructions verbatim,
braces and all.
* **Never blocked.** `exit 2` or `{"decision": "block"}` makes the handler
SKIP the compaction. The model is never told; the session simply runs on
uncompacted toward its context limit with no summary. That is strictly
worse than having no hook, and it is the outcome the spike existed to keep
out of the plugin — so it is pinned here rather than left to review.
The fourth test is the one that catches a rewrite drifting back toward the
original design: a future edit that reaches for `additionalContext` would look
correct beside every other hook in this directory and would inject nothing.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_precompact_preserve.sh"
HOOKS_JSON = ROOT / "plugin" / "hooks" / "hooks.json"
EVENT = {"session_id": "s1", "transcript_path": "/tmp/t.jsonl", "cwd": "/repo",
"hook_event_name": "PreCompact", "trigger": "manual",
"custom_instructions": None}
def _run(event: dict) -> subprocess.CompletedProcess:
if shutil.which("bash") is None:
pytest.skip("bash not installed")
return subprocess.run(["bash", str(HOOK)], input=json.dumps(event),
capture_output=True, text=True, timeout=30)
@pytest.mark.parametrize("trigger", ["manual", "auto"])
def test_it_exits_zero_with_instructions_on_stdout(trigger):
"""Exit 0 plus non-empty stdout is the entire contract for reaching the
summarizer; either half missing and the hook is decoration."""
out = _run({**EVENT, "trigger": trigger})
assert out.returncode == 0, out.stderr
assert out.stdout.strip(), "empty stdout is dropped by the handler"
def test_the_instructions_name_what_has_to_survive():
"""The summary is the next turn's only copy of these, so the hook says so
in the words a summarizer can act on."""
said = _run(EVENT).stdout.lower()
assert "id" in said and "title" in said
for anchor in ("in progress", "scribe", "not yet recorded"):
assert anchor in said, f"the instruction no longer mentions {anchor!r}"
def test_it_emits_text_and_not_a_json_envelope():
"""Every other hook here answers in JSON. This one must not: for PreCompact
the envelope is not unwrapped, it is pasted into the summarizer's prompt."""
assert not _run(EVENT).stdout.lstrip().startswith("{")
def test_it_never_blocks_the_compaction():
"""A block skips compaction silently from the model's side. Nothing in the
script may produce one — not an exit code, not a decision."""
body = HOOK.read_text()
assert '"decision"' not in body and "'decision'" not in body
assert "exit 2" not in body
# A truncated or absent event must not turn into a non-zero exit either.
for event in ("", "not json", "{}"):
out = subprocess.run(["bash", str(HOOK)], input=event,
capture_output=True, text=True, timeout=30)
assert out.returncode == 0, f"{event!r}{out.returncode}: {out.stderr}"
def test_additional_context_is_not_how_this_event_works():
"""SessionStart's channel, which does not exist on PreCompact. A rewrite
that reaches for it would read as consistent with the other hooks and
inject nothing at all."""
assert "additionalContext" not in HOOK.read_text().split("set -uo pipefail")[-1]
def test_the_plugin_registers_it_on_precompact():
entries = json.loads(HOOKS_JSON.read_text())["hooks"]["PreCompact"]
commands = [h["command"] for e in entries for h in e["hooks"]]
assert any(HOOK.name in c for c in commands)
assert all(h["type"] == "command" for e in entries for h in e["hooks"]), (
"PreCompact accepts command hooks only — a prompt or agent hook is "
"rejected at registration"
)