CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
Step 3 of the Scribe -> Grimoire rename. - Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*. - hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR / SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`. - skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes arrive as grimoire-proc-<slug>; moment_actions names them the same way. - Migration 0124 rewrites stored skill names (rule_moments, rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing. - Settings' install commands and MCP-only defaults, the docs and the tests follow. - scripts/check_plugin.py drops the smoke event for a report-check hook that has no file in plugin/hooks. Kept on purpose: the secret-sealing label, and the names that let backups written as Scribe restore and be counted. 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 Grimoire 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" / "grimoire_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/grimoire-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", "grimoire", "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"
|
|
)
|