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
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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user