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
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
170 lines
7.0 KiB
Python
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"]
|