Files
FabledScribe/tests/test_precompact_hook.py
T
bvandeusenandClaude Opus 5 ae773740b4
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 14s
feat(plugin): the seam that erases the evidence is where the unresolved rules get named (#4216)
Step 5 of milestone 419. The milestone's subject is that a rule read and
ignored is arithmetically identical to a rule read and followed, and the
compaction is where that identity becomes permanent — the turns holding the
evidence are summarised away, and the unjudged thing survives as nothing.

WHY THIS IS ASSEMBLED IN THE HOOK. `rule_usage_events` has no session column;
it is per user over a window. A session-scoped answer therefore cannot be
asked of the server, and has to be built where a session is a thing that
exists. Four ledgers four hooks already write:

  .rules.ids      an arm NAMED the rule
  .opened.ids     the session called get_rule      (#4100)
  .acted.ids      the session called rule_outcome  (new here)
  .checkpoint.ids the rule HELD an act             (#4214)

Every one is an observed tool call. Nothing asks the model what it followed —
milestone 386 ruled that out, because a model asked "did you apply rule 156?"
says yes. Two subtractions: named-minus-opened is the arm talking to nobody,
opened-minus-acted is the milestone's whole subject.

PreCompact stdout is the compaction's custom instructions (#3680), not a
message to the model, so the readout does not say "you slipped" — it says
which ids must be carried through, which is the one thing a summary can do
about an unjudged finding.

SILENT WHEN NOTHING HAPPENED, and the accusations are conditional on having
members. "0 rules unresolved" on every compaction is how a readout teaches
its reader to skip it. Traffic is still reported, because the static
instructions already ask for it in prose; these lines are the measured
version.

scribe_record_outcome.sh is the third ledger's writer, matched on
mcp__.*__rule_outcome and mirroring scribe_record_opened.sh: TMPDIR only,
silent, exit 0 on every path. A PostToolUse hook that spoke would put a line
after every rule_outcome call and give recording an outcome a cost.

Also: check_plugin.py skipped the new hook for want of a smoke event, which
would have left the newest of the three ledgers as the only one the plugin
lane never runs. Added, mirroring its sibling.

tests/test_precompact_hook.py now isolates TMPDIR — the hook reads session
ledgers from there, so without isolation a test would see whatever this real
session had accumulated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-21 00:53:29 -04:00

154 lines
6.8 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 tempfile
import os
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, tmpdir: str | None = None) -> subprocess.CompletedProcess:
"""Run the hook with its ledger directory ISOLATED.
The hook reads this session's rule ledgers since #4216, and they live under
`$TMPDIR/scribe-priorart`. Without an override these tests would read the
machine's real /tmp: on a developer box mid-session that is not empty, and
the output would depend on what some other session happened to leave
behind. None of the assertions below would fail on it today, which is
exactly why it is worth closing now rather than after it starts flaking.
"""
if shutil.which("bash") is None:
pytest.skip("bash not installed")
env = dict(os.environ)
env["TMPDIR"] = tmpdir or tempfile.mkdtemp()
return subprocess.run(["bash", str(HOOK)], input=json.dumps(event),
capture_output=True, text=True, timeout=30, env=env)
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.
env = dict(os.environ)
env["TMPDIR"] = tempfile.mkdtemp()
for event in ("", "not json", "{}"):
out = subprocess.run(["bash", str(HOOK)], input=event,
capture_output=True, text=True, timeout=30, env=env)
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"
)