Files
FabledScribe/tests/test_report_check_hook.py
T
bvandeusenandClaude Opus 5 a49e7ed2af
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
fix(plugin): the hooks need no jq and no tac (#4107)
Every hook opened `command -v jq >/dev/null 2>&1 || exit 0`, so on a machine
without jq the operator got no session context, no rules, no prior art and no
process sync — and not one word saying why, because `exit 0` is
indistinguishable from "ran fine, nothing to say". jq is absent by default on
macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers.
That is not a prerequisite to document; it is the plugin handing its own
packaging problem to whoever installs it.

`tac` was worse: GNU-only, so the prior-art hook's enclosing-definition arm
did nothing at all on every Mac, silently, from the day it shipped. It is not
replaced but removed — scribe_defs judges each line independently, so
extracting forward and taking `tail -1` is the same answer as reversing and
taking the head, and it drops the early-exit `head` that #4042 was filed for.

No server contract changed, so a lagging plugin cache keeps working.

  scribe_json.awk   JSON -> IDX<TAB>PATH<TAB>VALUE. Two modes: `whole` for an
                    event or a response body, `lines` for a transcript, where
                    an unparseable record is dropped and the rest still read —
                    the `map(try fromjson catch empty)` the jq program opened
                    with. Arrays also report their LENGTH at `[#]`, which is
                    what keeps "zero notes" distinct from "no answer" (#2932).
  scribe_turn.awk   the turn-bounding program, replacing the thirty lines of
                    jq in the Stop hook.
  scribe_defs.sh    scribe_json_flat / _pick / _list / _len / _list_minus read,
                    scribe_json_out writes the envelope (five copies of one
                    shape, gone), scribe_urlenc replaces `jq -sRr '@uri'`.

Percent-encoding goes through `od -tu1` rather than an awk character loop on
purpose: awk's idea of a character follows the locale, so gawk reads an
accented letter as one and mawk as two, and an encoder built on substr() would
emit a different URL depending on which awk is installed. Encoding is defined
on bytes. Verified byte-identical to `jq -sRr '@uri'`.

Measured, not assumed. The per-event path costs 8ms against jq's 3ms. The
transcript path was 70x slower until two fixes: the Stop hook now finds where
the turn starts with a fixed-string grep before parsing (a needle carrying
unescaped quotes cannot occur inside a JSON string, so it matches only at a
record's top level — checked against a full JSON parse of a 27MB transcript:
152 prompt records, 152 matches, no misses, no extras), and the parser reads
each token out of a 1024-byte window instead of copying the rest of the buffer
per token, which was quadratic in line length on the 400KB tool results a
transcript carries.

Differential-tested against the jq program it replaces over 724 windows cut
from three real transcripts — 724 identical, 0 mismatched, 45 of them
exercising a real task close and a real reply. That sweep is what caught
`scribe_turn.awk` never setting FS, which truncated every multi-word reply at
its first space and was invisible to a test whose replies were all empty.

check_plugin.py's `jq -R` lint becomes a guard against either binary coming
back, and three smoke checks lose their `shutil.which("jq")` skip. jq is not
in `ci-python` either, so those three announced a skip on every CI run and had
never once run there: removing the dependency from the product also closed a
permanent hole in its verification. They pass now across all ten hooks.

tests/test_hook_json_reader.py is a differential against Python's `json` over
nested objects, arrays, unicode, escapes, control characters, empty cases and
a value longer than the token window, plus the envelope, the encoder and the
turn analyzer. 139 cases.

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

170 lines
7.0 KiB
Python

"""The Stop hook that checks a task-closing reply for the completion sections
(milestone 409 step 5).
Runs the real shell against synthetic transcripts in the shape Claude Code
writes (one content block per JSONL line) and the shared HTTP sink. What it
pins: silence on every turn that closed nothing; a block only when the
instance recorded it, in the words the instance returned; one rewrite at most,
recorded; and no block from another plugin's loop or a failed task write.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers import http_sink
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_report_check.sh"
TOOL = "mcp__plugin_scribe_scribe__update_task"
GOOD = ('**Where this sits:** milestone 12 "Move the backups offsite", step 3 of 5.\n'
"**What now works:** the sync runs nightly.\n**Needs you:** nothing.\n**Next:** alerts.")
BAD = "All done, pushed it."
REASON = "SERVER REASON: rewrite as a completion report"
def _env(tmp_path, url="http://127.0.0.1:9"):
for tool in ("curl", "bash"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
"TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
def _prompt(text="please finish it"):
return {"type": "user", "message": {"role": "user", "content": text}}
def _tool_use(tid="toolu_1", status="done", name=TOOL, task_id=41):
return {"type": "assistant", "message": {"content": [
{"type": "tool_use", "id": tid, "name": name, "input": {"task_id": task_id, "status": status}}]}}
def _result(tid="toolu_1", is_error=False):
return {"type": "user", "message": {"content": [
{"type": "tool_result", "tool_use_id": tid, "is_error": is_error, "content": "{}"}]}}
def _text(text):
return {"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}}
def _transcript(tmp_path, lines):
path = tmp_path / "t.jsonl"
# Compact, like the file Claude Code writes ({"name":"…"}, no spaces).
path.write_text("\n".join(json.dumps(line, separators=(",", ":")) for line in lines) + "\n")
return path
def _run(env, transcript, active=False, session="s1"):
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({"session_id": session, "transcript_path": str(transcript),
"cwd": str(transcript.parent), "hook_event_name": "Stop",
"stop_hook_active": active}),
capture_output=True, text=True, env=env, timeout=30,
)
assert out.returncode == 0, out.stderr
return out.stdout.strip()
def _closing_turn(reply):
return [_prompt(), _text("On it."), _tool_use(), _result(), _text(reply)]
def test_a_turn_that_closed_nothing_is_silent_and_reports_nothing(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, [_prompt(), _tool_use(status="in_progress"), _result(), _text(BAD)])
assert _run(env, t) == ""
assert seen == []
def test_a_complete_report_passes_silently_and_is_recorded(tmp_path):
with http_sink(b'{"status":"ok"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
assert _run(env, _transcript(tmp_path, _closing_turn(GOOD))) == ""
assert [q["outcome"] for q in seen] == [["passed"]]
assert seen[0]["task_ids"] == ["41"]
def test_a_missing_section_blocks_once_in_the_servers_words_then_records_the_rewrite(tmp_path):
reply = json.dumps({"status": "ok", "reason": REASON}).encode()
with http_sink(reply) as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
out = json.loads(_run(env, _transcript(tmp_path, _closing_turn(BAD))))
assert out == {"decision": "block", "reason": REASON}
assert seen[0]["outcome"] == ["blocked"]
assert seen[0]["missing"] == ["where it sits,needs you,next"]
# The rewrite: Claude Code sets stop_hook_active; the hook records and never blocks again.
rewritten = _transcript(tmp_path, _closing_turn(BAD) + [_text(GOOD)])
assert _run(env, rewritten, active=True) == ""
assert seen[1]["outcome"] == ["passed_after_rewrite"]
assert _run(env, rewritten, active=True) == ""
assert len(seen) == 2
def test_a_rewrite_that_still_misses_is_recorded_and_not_blocked(tmp_path):
reply = json.dumps({"status": "ok", "reason": REASON}).encode()
with http_sink(reply) as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, _closing_turn(BAD))
_run(env, t)
assert _run(env, t, active=True) == ""
assert [q["outcome"][0] for q in seen] == ["blocked", "missing_after_rewrite"]
def test_another_hooks_block_loop_is_left_alone(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
assert _run(env, _transcript(tmp_path, _closing_turn(BAD)), active=True) == ""
assert seen == []
def test_a_task_write_that_failed_closed_nothing(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, [_prompt(), _tool_use(), _result(is_error=True), _text(BAD)])
assert _run(env, t) == ""
assert seen == []
def test_a_task_closed_in_an_earlier_turn_does_not_count(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, _closing_turn(GOOD) + [_prompt("thanks, what else?"), _text(BAD)])
assert _run(env, t) == ""
assert seen == []
def test_a_reply_not_yet_written_is_not_judged(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
t = _transcript(tmp_path, [_prompt(), _tool_use(), _result()])
assert _run(env, t) == ""
assert seen == []
def test_no_block_without_a_recorded_check(tmp_path):
t = _transcript(tmp_path, _closing_turn(BAD))
# Unreachable instance.
assert _run(_env(tmp_path), t) == ""
# An instance that answered but returned no reason.
with http_sink(b'{"status":"ok"}') as (port, seen):
assert _run(_env(tmp_path, f"http://127.0.0.1:{port}"), t, session="s2") == ""
assert seen[0]["outcome"] == ["blocked"]
def test_a_bare_id_does_not_count_as_placing_the_work(tmp_path):
reply = json.dumps({"status": "ok", "reason": REASON}).encode()
with http_sink(reply) as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
bare = "Closed #41.\n**Needs you:** nothing.\n**Next:** #42."
assert json.loads(_run(env, _transcript(tmp_path, _closing_turn(bare))))["decision"] == "block"
assert seen[0]["missing"] == ["where it sits"]