From 4b8d22e3ecf09d2d85647f7f5e85781c8d9bd031 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 16 Sep 2026 08:11:52 -0400 Subject: [PATCH] feat(plugin): a PreCompact hook tells the summarizer what must survive (#3680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/hooks.json | 10 ++ plugin/hooks/scribe_precompact_preserve.sh | 74 +++++++++++++++ plugin/hooks/scribe_session_context.sh | 12 ++- tests/test_precompact_hook.py | 105 +++++++++++++++++++++ 5 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 plugin/hooks/scribe_precompact_preserve.sh create mode 100644 tests/test_precompact_hook.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index e67ef7b..b28ac5d 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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" }, diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index c55be67..ff1d27b 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -55,6 +55,16 @@ ] } ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_precompact_preserve.sh\"" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/plugin/hooks/scribe_precompact_preserve.sh b/plugin/hooks/scribe_precompact_preserve.sh new file mode 100644 index 0000000..bb5536d --- /dev/null +++ b/plugin/hooks/scribe_precompact_preserve.sh @@ -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 [] completed successfully: `, 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 diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index b486800..9d6b8be 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -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 diff --git a/tests/test_precompact_hook.py b/tests/test_precompact_hook.py new file mode 100644 index 0000000..cc91028 --- /dev/null +++ b/tests/test_precompact_hook.py @@ -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" + )