diff --git a/docs/conventions/hook-observability/README.md b/docs/conventions/hook-observability/README.md index b5390142b..864eaa50f 100644 --- a/docs/conventions/hook-observability/README.md +++ b/docs/conventions/hook-observability/README.md @@ -27,11 +27,13 @@ A static field on a `hooks.json` **handler object**, sibling of `type`/`command` ``` Displayed as the UI spinner label while the hook process runs. **A hook script never emits this — -there is no runtime JSON output field by this name.** **Rollout status: pending.** As of this -doc's introduction, no `hooks.json` in the fleet declares `statusMessage` yet — all 27 wired -`type: "command"` handlers across 12 plugins need it added. Tracked as required fleet adoption -against melodic-software/claude-code-plugins#836 (this doc lands first per the convention-registry -rule; adoption is the follow-up PR). Wording convention for that rollout: a present-tense gerund +there is no runtime JSON output field by this name.** **Rollout status: near-complete.** As of +2026-07-23, 30 of the 31 wired `type: "command"` handlers across the fleet's 15 hook-bearing +plugins declare `statusMessage`; the sole remaining holdout is +`plugins/disk-hygiene/hooks/hooks.json`. Tracked against +melodic-software/claude-code-plugins#836 (this doc landed first per the convention-registry rule; +adoption was the follow-up wave, now all but one site complete — close #836 once `disk-hygiene` +declares it or is recorded as a deliberate exception). Wording convention: a present-tense gerund phrase naming what the hook is doing, specific to the tool or check (`"Formatting Go imports..."`, `"Checking for secrets..."`, `"Recording tool-failure telemetry..."`) — not a generic `"Running hook..."`. @@ -49,6 +51,12 @@ file, `jq`) causes the hook to silently no-op instead of performing its check. D (`lib/hook-utils.sh:26-30`): *"a missing runtime prerequisite must surface to BOTH the agent (additionalContext) and the user (systemMessage) — a silently skipped feature is a defect."* +This is the doctrine that fleet hook scripts cite in comments as the **"dim-9 doctrine"** — the +label names *this* visible-skip rule and nothing more, and this section is its authoritative +definition. (The `dim-N` numbers are an informal fleet-conformance shorthand — e.g. dim-8 = the +uniform setup-skill wave, dim-11 = seam phrasing — with no central registry defining the numbering; +giving the whole scheme a documented home is a separate follow-up, tracked outside this doc.) + **Not required** for two situations that are already visible or already correctly agent-scoped: - **Exit-2 blocking paths.** A `PreToolUse` hook that blocks a tool call via exit code 2 is diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index fa6f12d15..d632a5ccc 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -813,6 +813,53 @@ else fail "git alias (env .command masked): rc=$rc" fi +# --- Test 18: hook::buffer_stdin — timeout path (return 2 / BLOCKED) ---------- +# Incomplete JSON on a pipe that stays open past the read timeout must trip the +# bounded-read timeout branch: return 2 and a `BLOCKED:` diagnostic on stderr +# (the Win32-pipe late-EOF stall the bounded read exists to survive). Drives the +# real function: a producer emits a partial payload then sleeps to hold the pipe +# open, and STDIN_READ_TIMEOUT is shortened so the case is fast. jq present (this +# host) is what lets the function distinguish a truncated read from a small-but- +# complete one; without it the branch fails open to return 1, so this asserts the +# jq-present timeout shape specifically. +bs_rc_file="$(mktemp)" +bs_err_file="$(mktemp)" +{ printf '{"incomplete":'; sleep 1; } | { + CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT=0.4 hook::buffer_stdin >/dev/null 2>"$bs_err_file" + echo "$?" >"$bs_rc_file" +} +bs_rc=$(cat "$bs_rc_file") +if [[ "$bs_rc" == "2" ]] && grep -q 'BLOCKED:' "$bs_err_file"; then + ok "buffer_stdin: incomplete JSON past timeout → return 2 + BLOCKED on stderr" +else + fail "buffer_stdin timeout: rc=$bs_rc err=$(cat "$bs_err_file")" +fi +rm -f "$bs_rc_file" "$bs_err_file" + +# --- Test 19: hook::emit_telemetry — EPOCHREALTIME-absent (Bash < 5.0) skip --- +# On Bash < 5.0 EPOCHREALTIME is unset, so the caller's `start=${EPOCHREALTIME:-}` +# snapshot is empty. emit_telemetry must then skip fail-open (return 0, emit no +# envelope) rather than abort under `set -u` — the same silent-skip the caller's +# guard intends. Simulated by unsetting EPOCHREALTIME in the command-substitution +# subshell (its special attribute drops, so references yield empty), which does +# not leak into the rest of the suite. A wired sink proves nothing is dispatched. +tel19="$(mktemp)" +: >"$tel19" +sink19="$(make_sink "$tel19")" +rc19=$( + unset EPOCHREALTIME + start=${EPOCHREALTIME:-} + HOOK_TELEMETRY_SINK="$sink19" hook::emit_telemetry "t" "PostToolUse" "ok" "$start" '{"tool":"x","file":"y","findings":[]}' + echo $? +) +sleep 0.1 # allow any (erroneous) background dispatch to land before asserting empty +if [[ "$rc19" == "0" && ! -s "$tel19" ]]; then + ok "emit_telemetry: EPOCHREALTIME-absent (empty start) → skip fail-open (rc 0, no envelope)" +else + fail "emit_telemetry EPOCHREALTIME-absent: rc=$rc19 sink=[$(cat "$tel19")]" +fi +rm -f "$tel19" "$sink19" + echo echo "PASS=$PASS FAIL=$FAIL" [[ $FAIL -eq 0 ]]