#!/usr/bin/env bash # Scribe plugin — Stop hook: a reply that closes a task carries the completion # sections (milestone 409 step 5). # # Everything else the plugin does happens BEFORE the agent writes: context, # retrieval, the reporting-back skill. This is the one moment the finished # reply exists, so it is both the last chance to fix a report the operator # cannot read and the only place adherence to the shape can be measured. # # DETERMINISTIC, NO MODEL CALL. Three questions, cheapest first: # # 1. Did this turn close a Scribe task? An `update_task` / `create_task` tool # call with status "done" since the turn's prompt, whose result was not an # error. Most turns stop here, silently. # 2. Does the reply that ends the turn have the completion sections? Loosely: # where the work sits (a record named by id and title, or step N of M), # what needs the operator, and what comes next. Matched on the words that # carry the meaning rather than exact headings, so the skill's wording can # change without breaking this. # 3. If sections are missing, block once with a reason naming them. The agent # rewrites; the rewrite is checked and recorded, and never blocked again. # # MEASURED FROM THE FIRST CALL. Each checked reply is reported to the instance # (`/api/plugin/report-check`): passed, blocked, and after a rewrite either # passed_after_rewrite or missing_after_rewrite. Turns that closed no task are # not reported — they would cost a request on every turn and add nothing to # the rate step 6 reads (blocked among checked replies). # # IT BLOCKS ONLY WHEN THE BLOCK IS RECORDED, AND ONLY IN THE SERVER'S WORDS. # The report goes out first; the instance answers a recorded `blocked` with # the reason to send the agent back with, and the hook blocks only on that # reason. An unconfigured or unreachable instance therefore never stops a # session, every intervention is one the numbers can see, and the guidance # text lives on the server (plugin/PACKAGING.md: hooks carry timing and # transport). # # THE TRANSCRIPT FORMAT IS OBSERVED, NOT DOCUMENTED. Claude Code documents # `transcript_path` and `stop_hook_active` for Stop, not the JSONL inside. As # read from real transcripts (2026-09-14): one content block per line; # `type: "assistant"` lines carry `message.content[]` blocks of `text` / # `tool_use` ({id, name, input}); tool results arrive as `type: "user"` lines # whose content is a `tool_result` array ({tool_use_id, is_error}); a turn's # prompt — typed, or a background-task notification — is a `user` line whose # content is a plain string and which is not `isMeta`. Anything that does not # parse that way makes the hook stay out of the way rather than guess. # # Config (same as the other hooks): # CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash # CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive) # SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path. set -uo pipefail command -v curl >/dev/null 2>&1 || exit 0 # shellcheck source=plugin/hooks/scribe_defs.sh . "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh" # Stop delivers { session_id, transcript_path, cwd, hook_event_name, stop_hook_active }. event=$(cat 2>/dev/null || true) event_flat=$(printf '%s' "$event" | scribe_json_flat) transcript=$(scribe_json_pick "$event_flat" '.transcript_path') [ -n "$transcript" ] && [ -f "$transcript" ] || exit 0 session_id=$(scribe_json_pick "$event_flat" '.session_id') active=$(scribe_json_pick "$event_flat" '.stop_hook_active') event_cwd=$(scribe_json_pick "$event_flat" '.cwd') safe_sid=$(printf '%s' "${session_id:-nosession}" | tr -c 'A-Za-z0-9._-' '_') state_dir="${TMPDIR:-/tmp}/scribe-reportcheck" mkdir -p "$state_dir" 2>/dev/null || true marker="$state_dir/${safe_sid}.blocked" # Cheap prefilter: no task tool anywhere in the recent transcript → nothing to # check. Keeps the ordinary turn at one grep. Process substitution, NOT a pipe: # under `pipefail`, `grep -q` exiting on the first match kills `tail` with # SIGPIPE, and the pipeline then reports failure precisely when it matched. grep -q -E '"name":[[:space:]]*"([^"]*__)?(update|create)_task"' < <(tail -c 2000000 "$transcript" 2>/dev/null) || { rm -f "$marker" 2>/dev/null || true exit 0 } # The turn, parsed once. A window of recent lines, flattened record by record # and then read by scribe_turn.awk, which carries the turn-bounding rules. A # line that does not parse is dropped and the rest are still read — the first # line of a `tail -n 3000` window is routinely half a record. If the window # holds no prompt, the turn cannot be bounded, so the hook reports nothing and # stays out of the way. window() { tail -n 3000 "$transcript" 2>/dev/null; } turn_facts() { window | tail -n +"${1:-1}" | scribe_json_flat_lines \ | awk -f "$SCRIBE_HOOK_DIR/scribe_turn.awk" 2>/dev/null; } # WHERE THE TURN STARTS, FOUND BEFORE PARSING RATHER THAN AFTER. The window is # 3000 lines and routinely 7MB, of which a turn is the last few hundred lines # and about a sixth of the bytes — the rest is tool results this check never # looks at. The predecessor parsed all of it and threw most away, which jq # could afford and a parser written in awk cannot: measured at 6.5s for a 7MB # window against 94ms, on a hook that runs at the end of every turn. # # So grep — C, and reading a FIXED string — narrows first. A prompt record is # `"type":"user"` whose `content` is a STRING; a tool result is the same type # with an ARRAY, and the two are told apart by the character after `"content":`. # # WHY A FIXED STRING IS EXACT HERE, and not the usual regex-over-JSON guess. # Every quote inside a JSON string is backslash-escaped, so a needle carrying # UNESCAPED quotes cannot occur inside any string value — it can only match at # a record's own top level. `"message":{"role":"user","content":"` therefore # matches real prompt records and nothing else. Measured over a 27MB transcript # against a full JSON parse: 152 prompt records, 152 matches, no misses and no # extras. The looser `"content":"` matched 1101 lines, because a tool_result # block has a `content` key of its own — which is the trap this avoids. # # THE LAST MATCH, not a few before it, because the margin is not free: the # lines between two prompts are mostly tool results, and backing off three # matches took the window from 53KB to 1.3MB and the parse from 21ms to 2.6s. # The fallback below is the safety net instead — it is exact where a margin is # only approximate, and it costs nothing in the case that actually happens. # # The needle assumes a key ORDER that a future Claude Code could change. If it # does, grep matches nothing, `start` stays 1, and the whole window is read the # slow way — correct, and slow, which is the right way round for a check that # can block a stop. start=$(window | grep -n -F '"message":{"role":"user","content":"' 2>/dev/null \ | cut -d: -f1 | awk '{ last = $0 } END { if (NR) print last }') case "$start" in ''|*[!0-9]*) start=1 ;; esac facts=$(turn_facts "$start") fact() { printf '%s\n' "$facts" | awk -F'\t' -v k="$1" '$1 == k { print substr($0, index($0, "\t") + 1); exit }'; } if [ "$(fact bounded)" != "1" ] && [ "$start" != "1" ]; then facts=$(turn_facts 1) fi [ "$(fact bounded)" = "1" ] || exit 0 closed=$(fact closed) if [ "${closed:-0}" = "0" ]; then rm -f "$marker" 2>/dev/null || true exit 0 fi # Escaped on one line coming out of awk, so the format survives a multi-line # reply; decoded here, once, where it is about to be read as text. reply=$(fact reply | scribe_json_unescape) task_ids=$(fact task_ids) # The reply may not be written to the transcript yet when the hook fires. An # empty reply is "cannot tell", not "missing everything" — stay out of the way. [ -n "$(printf '%s' "$reply" | tr -d '[:space:]')" ] || exit 0 missing=() # Where the work sits: a record named by id AND title (#12 "…", milestone 3 "…"), # or a step position. A bare id is exactly the homework this shape removes. # shellcheck disable=SC2016 # backticks here are literal markdown, not an expansion grep -q -i -E '(#[0-9]+|milestone [0-9]+|task [0-9]+)[*_`]*[[:space:]]*[*_`]*["“]|step [0-9]+ of [0-9]+' <<< "$reply" \ || missing+=("where it sits") # What needs the operator — "needs you: nothing" counts; it is an answer. grep -q -i -E 'needs? (from )?you|nothing (is )?needed from you|your (call|decision)' <<< "$reply" \ || missing+=("needs you") # What comes next. grep -q -i -E '\bnext\b' <<< "$reply" \ || missing+=("next") # Reports the outcome; prints the instance's reply and returns 0 only if the # instance recorded it. report() { scribe_config || return 1 local q repo enc m q="outcome=$1&task_ids=${task_ids}" m=$(IFS=,; printf '%s' "${missing[*]:-}") if [ -n "$m" ]; then enc=$(printf '%s' "$m" | scribe_urlenc) || enc="" q="${q}&missing=${enc}" fi scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}") [ -n "$scope" ] && q="${q}&${scope}" curl -fsS --max-time 4 \ -H "Authorization: Bearer ${token}" \ "${url%/}/api/plugin/report-check?${q}" 2>/dev/null } if [ "$active" = "true" ]; then # A Stop hook already blocked this stop. If it was this one, the reply is # the rewrite: record how it came out, and let the session stop whatever # the answer. If it was another plugin's block, this hook has nothing to add. [ -f "$marker" ] || exit 0 rm -f "$marker" 2>/dev/null || true if [ ${#missing[@]} -eq 0 ]; then report passed_after_rewrite >/dev/null; else report missing_after_rewrite >/dev/null; fi exit 0 fi rm -f "$marker" 2>/dev/null || true if [ ${#missing[@]} -eq 0 ]; then report passed >/dev/null exit 0 fi # The words the agent is sent back with are the server's (plugin/PACKAGING.md: # a hook carries timing and transport). No reason back → nothing recorded → # no block. answer=$(report blocked) || exit 0 reason=$(scribe_json_pick "$(printf '%s' "$answer" | scribe_json_flat)" '.reason') [ -n "$reason" ] || exit 0 : > "$marker" 2>/dev/null || true printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | scribe_json_escape)" exit 0