CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
Docstrings, section headers and assertion messages drop the record number, milestone or step that introduced the behaviour, and incident accounts are restated as the present-tense failure shape. A dead pointer to a design doc that is not in the repo is removed. Renamed to the property they check: test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note Fixture and example ids stay: "note 5" in a supersession chain, "rule 156" in an illustrative question, `#2275` as the hex-versus-citation case, `#3244` as the example of a bare number, fixed dates in date-filter tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
6.7 KiB
Python
154 lines
6.7 KiB
Python
"""The PreCompact hook steers the summary and never blocks the compaction.
|
|
|
|
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, 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"
|
|
)
|