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:
@@ -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