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
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
154 lines
6.8 KiB
Python
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"
|
|
)
|