CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 19s
The hook's header quotes `{"decision":"block"}` while explaining why this hook
must never emit one, and the instructions it prints use "decision" in a
sentence. Grepping the whole file caught both and failed the guard on the file
doing its job.
Strips comments and the heredoc first, and guards the stripper: `_code()`
returning nothing would make all three static checks pass against an empty
string, which is the circularity rule 167 is about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
139 lines
6.0 KiB
Python
139 lines
6.0 KiB
Python
"""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)
|
|
|
|
|
|
def _code() -> str:
|
|
"""The script with its comments and its heredoc removed.
|
|
|
|
The static checks below are about what the shell DOES. Run over the whole
|
|
file they also read the header — which quotes `{"decision":"block"}` in the
|
|
course of explaining why this hook must never emit one — and the emitted
|
|
instructions, which use the word "decision" in a sentence. Both are the
|
|
file doing its job, and neither is code.
|
|
"""
|
|
lines, in_heredoc = [], False
|
|
for line in HOOK.read_text().splitlines():
|
|
if in_heredoc:
|
|
in_heredoc = line.strip() != "EOF"
|
|
continue
|
|
if line.lstrip().startswith("cat <<'EOF'"):
|
|
in_heredoc = True
|
|
continue
|
|
if not line.lstrip().startswith("#"):
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def test_the_comment_stripper_still_leaves_the_shell_behind():
|
|
"""Guard on the three checks below it. `_code()` returning nothing would
|
|
make every one of them pass while checking an empty string — the same
|
|
circularity the plugin version check has to defend against."""
|
|
code = _code()
|
|
assert "set -uo pipefail" in code and "exit 0" in code
|
|
# It really did strip: both of the words the checks look for are present
|
|
# in the file, and neither is in the code.
|
|
assert "decision" in HOOK.read_text()
|
|
|
|
|
|
@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."""
|
|
code = _code()
|
|
assert "decision" not in code, "a block decision would skip the compaction"
|
|
assert "exit 2" not in code
|
|
# 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 _code()
|
|
|
|
|
|
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"
|
|
)
|