Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions docs/conventions/hook-observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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..."`.
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]]
Loading