Files
ersatztv/scripts/hook-fire-log.sh
T
timothyandClaude Opus 5 183df2bb3d fix(776): the instrumentation was silencing every hook's stderr
Round 2, from two independent cold reviews. The headline defect was found by
the test the reviews prompted rather than by the reviews themselves:

  exec 0<"$sin" 2>/dev/null

reads as "suppress errors from this redirection". It is not. `exec` with
redirections and no command applies them to the shell PERMANENTLY, so this sent
each hook's ENTIRE stderr to /dev/null. Every husky guard's user-facing output
is stderr — the H6 "push to main BLOCKED" message, the BOM guard's remediation
text, the missing-trailer message — so the guards went on blocking correctly
while telling the human nothing about why. Invisible to the original A/B, which
compared only stdout and exit status.

The finding that exposed it was ranked Low ("the A/B ignores stderr").

Also fixed, each with an executed proof:

- The fd restore sat inside the same conditional as the replay, and the replay
  read the PATH, so an unlinked-but-open scratch file meant a swallowed `deny`
  at exit 0. Restore is now unconditional and the replay reads through a held
  fd, which survives unlink.
- A SIGTERMed hook lost output it had already printed (52 bytes -> 0). Bash runs
  no EXIT trap for an untrapped fatal signal; INT/TERM/HUP are trapped now.
- The classifier misreported an uppercase decision as prose, read the bare word
  additionalContext in free text as a decision, and let a non-zero exit ERASE a
  decision the hook really printed.
- The session id reached the log FILENAME unscrubbed. The first fix reused the
  record scrubber, which deliberately keeps `/` and `.`, so `../../escaped` went
  through untouched: passing a value through a scrub is not the same as passing
  it through the right scrub.
- Preamble line 1 could abort a `set -e` hook, turning a fail-open guard
  fail-closed; and a missing sink printed `command not found` to stderr.

The big one is test quality. `test_instrumentation_changes_NOTHING_the_harness_
can_see` was 160/165 empty-vs-empty and 165/165 exit-0-vs-exit-0, so deleting
the whole replay left it green for four hooks, two of which issue `deny`. Three
places claimed the matrix reached every deciding branch, including the decision
record. It now constructs the missing cases — a foreign worktree marker, a BOM
`.cs` in an *ersatztv*-named repo, stubbed memory_pressure, a real bare remote
for the three pre-push guards, a stub Gitea for prepush-donewhen — and
test_the_AB_is_not_VACUOUS_for_any_hook fails unless each hook has a case where
the control side actually says something.

Running pytest also wrote 115 synthetic fires into the production log, so the
report described the test suite while looking like a record of real sessions.
scripts/tests/conftest.py isolates every test; verified live, a full suite run
now leaves the real log untouched.

Refs: #776

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:52:57 +02:00

434 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# Hook fire log — the WRITE side and the READ side, deliberately in ONE file (ersatztv#776).
#
# WHY THIS EXISTS. In this harness version only `Stop` hooks emit a structured transcript record
# (`stop_hook_summary`/`hookInfos`). `PreToolUse` and `PostToolUse` hooks leave no durable execution
# trace — which is every guard this repo actually relies on. #773 §5.4 could therefore only count
# *filename mentions in settings dumps*, i.e. inference. A guard that is neither proven nor
# observable is an assertion about the past, and a silently broken hook looks identical to a working
# one indefinitely. #756's standing lesson: make the system REPORT it rather than infer it.
#
# WHY ONE FILE. The reader and the writer share the record format. Two files means the format can
# drift and the report can quietly describe a shape nothing writes any more — the same argument that
# put `scripts/ci-step-ran.sh` in one script instead of an inline workflow body (#756).
#
# WHY IT CAPTURES STDOUT RATHER THAN BEING TOLD THE DECISION. Every hook here exits 0 always; the
# decision is communicated by *printing* `hookSpecificOutput.permissionDecision` (PreToolUse) or
# `decision` (Stop). `pretooluse-merge-consent.sh` alone reaches that print from ~40 call sites via
# its `decide` helper. Asking each site to also set a variable would (a) be 40 edits in the most
# load-bearing guard in the repo and (b) record what the author *meant*, which is the inference this
# issue exists to abolish. Capturing the bytes the hook actually emits records what the HARNESS
# sees. It cannot drift from the decision because it IS the decision.
#
# WHY IT CAPTURES STDIN. 8 of the 9 hooks already open with `input=$(cat)` — a full blocking slurp —
# so reading stdin once here and replaying it via `exec 0<` is not a new risk, it is the read they
# already perform, moved earlier. It buys the tool name and session id for the log, and it makes
# `pretooluse-agent-ram.sh` (which reads no stdin at all today) observable on the same terms as the
# rest instead of being a hole in the table.
#
# FAIL-OPEN, DELIBERATELY AND IN THIS DIRECTION ONLY. This file is observability, not a guard. If
# anything here fails — no temp dir, unwritable log, missing `date` — the hook must behave EXACTLY as
# it did before instrumentation. A logging bug that denies a merge, or that swallows a guard's deny
# JSON, would be far worse than the blindness it is fixing. Every function returns 0, the stdout
# replay is the first act of the exit path, and the original exit code is re-raised explicitly.
#
# USAGE (write side) — the first two lines of every hook, before anything reads stdin:
# . "${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)}/scripts/hook-fire-log.sh"
# etv_hook_fire_begin <hook-name> [label]
#
# USAGE (read side):
# scripts/hook-fire-log.sh report # this session (or every session, see below)
# scripts/hook-fire-log.sh report --all # every session in the log dir
# scripts/hook-fire-log.sh report --json
# scripts/hook-fire-log.sh path # where the current session logs
#
# `scripts/tests/test_hook_fire_log.py` is the guard: it derives the hook population from the
# filesystem (never a list — `testing.guard-derives-population-from-source`) and fails if any hook
# is uninstrumented, and it proves stdin, stdout and the exit code survive the wrapper.
# ---------------------------------------------------------------------------------------------
# Shared: where the log lives, and how a record is written
# ---------------------------------------------------------------------------------------------
etv_hook_fire_log_dir() {
printf '%s' "${ETV_HOOK_FIRE_LOG_DIR:-${HOME:-/tmp}/.cache/ersatztv/hook-fire}"
}
etv_hook_fire_session() {
# The harness does not export a session id to hooks in this version, so the payload's
# `.session_id` is the real source and the env var is only a fallback. Recorded rather than
# assumed: several hooks already read `.session_id` from stdin for exactly this reason.
printf '%s' "${ETV_HOOK_FIRE_SESSION:-${CLAUDE_SESSION_ID:-unknown-session}}"
}
etv_hook_fire_log_file() {
# SCRUBBED, like every other use of this value. The session id is extracted from the payload by a
# `sed` that only excludes `"`, so a `/` or `..` in it would otherwise steer the write outside the
# log dir. Harness-generated UUIDs make that unreachable today, but a file whose stated thesis is
# "restrict the value space so there is no escaping bug to have" should not exempt the one use
# that becomes a path.
printf '%s/%s.jsonl' "$(etv_hook_fire_log_dir)" "$(etv_hook_fire_scrub_component "$(etv_hook_fire_session)")"
}
# Sanitise a value to a safe JSON scalar charset. This is why no `jq` is needed on the write side:
# with the value space restricted there is nothing to escape, so there is no escaping bug to have.
# A quote, backslash or newline in a hook name or tool name is not a case worth supporting — it is a
# case worth flattening, loudly, to `_`.
etv_hook_fire_scrub() {
printf '%s' "${1:-}" | tr -c 'A-Za-z0-9._/:+@=-' '_' | cut -c1-200
}
# A STRICTER scrub for the one value that becomes a PATH. The record scrub above deliberately keeps
# `/` and `.` — tool names like `mcp__gitea__x` and event paths read better with them — but those
# are exactly the two characters that turn a session id into `../../escaped`. Passing a value
# through a scrub is not the same as passing it through the RIGHT scrub, and the first version of
# this fix reused the record scrubber and left the traversal wide open while reading as fixed.
etv_hook_fire_scrub_component() {
printf '%s' "${1:-}" | tr -c 'A-Za-z0-9_-' '_' | cut -c1-120
}
# Append one record. Never fails; never writes a partial line (built whole, appended once).
etv_hook_fire_record() {
[ "${ETV_HOOK_FIRE_DISABLE:-0}" = "1" ] && return 0
local file line
file="${ETV_HOOK_FIRE_LOG_FILE:-$(etv_hook_fire_log_file)}"
mkdir -p "$(dirname "$file")" 2>/dev/null || return 0
line=$(printf '{"ts":"%s","session":"%s","pid":"%s","hook":"%s","label":"%s","event":"%s","tool":"%s","phase":"%s","code":"%s","decision":"%s"}' \
"$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || printf 'unknown')" \
"$(etv_hook_fire_scrub "$(etv_hook_fire_session)")" \
"$(etv_hook_fire_scrub "${ETV_HOOK_FIRE_PID:-$$}")" \
"$(etv_hook_fire_scrub "${1:-}")" \
"$(etv_hook_fire_scrub "${2:-}")" \
"$(etv_hook_fire_scrub "${3:-}")" \
"$(etv_hook_fire_scrub "${4:-}")" \
"$(etv_hook_fire_scrub "${5:-}")" \
"$(etv_hook_fire_scrub "${6:-}")" \
"$(etv_hook_fire_scrub "${7:-}")" 2>/dev/null) || return 0
printf '%s\n' "$line" >> "$file" 2>/dev/null || true
return 0
}
# ---------------------------------------------------------------------------------------------
# Write side: what a hook calls
# ---------------------------------------------------------------------------------------------
# Classify what the hook printed. The harness reads these two shapes and nothing else:
# PreToolUse : {"hookSpecificOutput":{"permissionDecision":"allow|deny|ask", ...}}
# Stop : {"decision":"block", "reason": ...}
# Anything else printed on stdout is surfaced to the user but decides nothing.
etv_hook_fire_classify() {
local out="${1:-}" code="${2:-0}" mode="${3:-capture}" d=""
# A `stream`-mode hook is a git hook: git reads its exit code and nothing else, so that is the
# whole of its decision. Reporting `no-op` here because no JSON was captured would be an
# inference, and inference is what this file exists to replace.
if [ "$mode" = "stream" ]; then
if [ "$code" = "0" ]; then printf 'pass'; else printf 'blocked'; fi
return 0
fi
if [ -n "$out" ]; then
# `[A-Za-z]` then lowercased, not `[a-z]`: a hook printing `"permissionDecision":"Deny"` fell
# through the lowercase-only class and was recorded as `output` — a real decision filed as
# unclassified prose.
d=$(printf '%s' "$out" | sed -n 's/.*"permissionDecision"[[:space:]]*:[[:space:]]*"\([A-Za-z-]*\)".*/\1/p' | head -n1)
[ -z "$d" ] && d=$(printf '%s' "$out" | sed -n 's/.*"decision"[[:space:]]*:[[:space:]]*"\([A-Za-z-]*\)".*/\1/p' | head -n1)
d=$(printf '%s' "$d" | tr 'A-Z' 'a-z')
# `additionalContext` with no decision (design-sync-reminder's `start` path) informs, it does
# not gate. Naming it distinctly keeps it out of the allow/deny counts. Matched WITH its quotes
# and colon — a bare substring test also fired on human prose that merely used the word.
if [ -z "$d" ] && printf '%s' "$out" | grep -q '"additionalContext"[[:space:]]*:'; then d="context"; fi
[ -z "$d" ] && d="output"
fi
# Exit 2 is the harness's non-JSON block channel. No hook here uses it today; it is classified
# anyway so that a hook which starts using it is not silently recorded as a no-op.
if [ "$code" = "2" ] && [ -z "$d" ]; then d="deny-exit2"; fi
[ -z "$d" ] && d="no-op"
# A non-zero status NO LONGER OVERWRITES a parsed decision. It used to, so a hook that printed a
# real `deny` and then exited 1 was recorded as `error` — the status erasing the decision, when
# the status already has its own field in the record. `error` now means only "it failed and said
# nothing classifiable".
if [ "$code" != "0" ] && [ "$code" != "2" ]; then
case "$d" in
no-op|output) d="error" ;;
*) d="${d}+error" ;;
esac
fi
printf '%s' "$d"
}
# etv_hook_fire_begin <hook-name> [label] [stdout-mode]
#
# `stdout-mode` is `capture` (default) or `stream`, and the split is not a tuning knob — the two
# kinds of hook in this repo decide by different channels:
#
# Claude Code hooks (pretooluse-*, posttooluse-*, design-sync-reminder) always exit 0 and
# communicate by PRINTING JSON. Their decision is only observable by capturing stdout, and their
# output is a single line consumed by the harness after exit, so buffering costs nothing.
#
# Git hooks (prepush-*, decisions-guard) decide by EXIT CODE, and their stdout is progress text a
# human is watching in real time. Capturing it would hold a slow pre-push hook's output back until
# the end, turning a working progress display into an apparent hang. They pass `stream`, and their
# decision is read from the exit code, which is what git reads too.
etv_hook_fire_begin() {
ETV_HOOK_FIRE_NAME="${1:-unknown-hook}"
ETV_HOOK_FIRE_LABEL="${2:-}"
ETV_HOOK_FIRE_MODE="${3:-capture}"
ETV_HOOK_FIRE_PID="$$"
ETV_HOOK_FIRE_STDIN_TMP=""
ETV_HOOK_FIRE_STDOUT_TMP=""
[ "${ETV_HOOK_FIRE_DISABLE:-0}" = "1" ] && return 0
ETV_HOOK_FIRE_LOG_FILE="$(etv_hook_fire_log_file)"
mkdir -p "$(dirname "$ETV_HOOK_FIRE_LOG_FILE")" 2>/dev/null || { ETV_HOOK_FIRE_DISABLE=1; return 0; }
# --- stdin: slurp, replay, and read the payload's identifying fields ------------------------
#
# NEVER on a terminal. A `git commit` run interactively hands its hooks a TTY on fd 0, and `cat`
# would block there forever — instrumentation hanging the commit it was added to observe. Claude
# Code always writes the JSON payload and closes the pipe, which is why the 8 hooks that already
# open with `input=$(cat)` are safe today; that guarantee does not extend to the git hooks, so the
# capture is conditioned on stdin not being a tty rather than on which hook is calling.
local sin payload="" event="" tool="" sess=""
if [ ! -t 0 ]; then
sin=$(mktemp "${TMPDIR:-/tmp}/etv-hook-stdin.XXXXXX" 2>/dev/null) || sin=""
else
sin=""
fi
if [ -n "$sin" ]; then
ETV_HOOK_FIRE_STDIN_TMP="$sin"
cat > "$sin" 2>/dev/null || true
# Replay: even a partial capture is closer to the truth than the drained pipe the hook would
# otherwise inherit.
#
# NO `2>/dev/null` ON THIS LINE, EVER. `exec` with redirections and no command applies them to
# the shell PERMANENTLY, so `exec 0<"$sin" 2>/dev/null` does not suppress errors from this one
# redirection — it sends the HOOK'S ENTIRE STDERR to /dev/null for the rest of its life. That
# silenced every husky hook's user-facing output, which is stderr: the H6 "push to main BLOCKED"
# message, the BOM guard's remediation text, `husky - commit message missing Co-Authored-By`.
# The guards still blocked, and the human was told nothing about why.
# Readability is tested instead of relying on redirection-failure suppression.
if [ -r "$sin" ]; then exec 0<"$sin" || true; fi
payload=$(head -c 65536 "$sin" 2>/dev/null || true)
fi
if [ -n "$payload" ]; then
event=$(printf '%s' "$payload" | sed -n 's/.*"hook_event_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
tool=$(printf '%s' "$payload" | sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
sess=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
fi
# The payload's session id is authoritative when the env var is absent, which it is in this
# harness version. Re-point the log file at it before the first record is written.
if [ -n "$sess" ] && [ -z "${CLAUDE_SESSION_ID:-}" ] && [ -z "${ETV_HOOK_FIRE_SESSION:-}" ]; then
ETV_HOOK_FIRE_SESSION="$sess"
ETV_HOOK_FIRE_LOG_FILE="$(etv_hook_fire_log_file)"
mkdir -p "$(dirname "$ETV_HOOK_FIRE_LOG_FILE")" 2>/dev/null || true
fi
ETV_HOOK_FIRE_EVENT="$event"
ETV_HOOK_FIRE_TOOL="$tool"
etv_hook_fire_record "$ETV_HOOK_FIRE_NAME" "$ETV_HOOK_FIRE_LABEL" "$event" "$tool" "fire" "" ""
# --- stdout: divert, so the exit path can read the decision the hook emitted ----------------
local sout=""
if [ "$ETV_HOOK_FIRE_MODE" = "capture" ]; then
sout=$(mktemp "${TMPDIR:-/tmp}/etv-hook-stdout.XXXXXX" 2>/dev/null) || sout=""
fi
if [ -n "$sout" ]; then
ETV_HOOK_FIRE_STDOUT_TMP="$sout"
exec 3>&1 || { ETV_HOOK_FIRE_STDOUT_TMP=""; rm -f "$sout" 2>/dev/null; return 0; }
exec 1>"$sout" || { exec 1>&3 3>&-; ETV_HOOK_FIRE_STDOUT_TMP=""; rm -f "$sout" 2>/dev/null; return 0; }
# A READ FD held open on the same file, so the replay does not depend on the PATH still
# resolving at exit. Replaying with `cat "$path"` loses everything if anything unlinks the file
# mid-run — a $TMPDIR reaper, a hook that clears its own scratch — because the write fd stays
# valid on the unlinked inode while the name is gone: the hook prints its `deny` into a file
# nothing can reopen. An fd survives unlink, which turns that from silent guard-disarming into
# a non-event. Pinned by `test_output_SURVIVES_a_vanished_stdout_tempfile`.
exec 4<"$sout" || true
fi
# SIGNALS, not just EXIT. Bash runs no EXIT trap for an untrapped fatal signal, so a hook killed
# by the harness timeout would have its already-printed decision discarded along with the temp
# file: measured at 52 bytes delivered before instrumentation and 0 after. Trapping the signals
# makes bash run the handler and then the EXIT trap, which replays. Every `capture`-mode hook
# today prints and exits immediately, so the window is sub-millisecond — but the constraint is
# invisible to a future editor, so it is enforced here rather than relied upon.
trap 'etv_hook_fire_end "$?"' EXIT INT TERM HUP
return 0
}
etv_hook_fire_end() {
local code="${1:-0}" out=""
# RESTORE FIRST, UNCONDITIONALLY — then replay if there is anything to replay.
#
# These were one conditional, `[ -n "$TMP" ] && [ -f "$TMP" ]`, and that coupling was the bug:
# `-f` asks "is there output to replay", but the fd restore must happen whenever the redirect was
# ESTABLISHED, which is a different fact. Any route that makes `-f` false while the redirect is
# live — the temp file unlinked by a $TMPDIR reaper mid-run (the fd stays valid, so the hook
# writes happily into an unlinked inode), a $TMPDIR where mktemp yields a non-regular file — left
# fd 1 still pointing at the temp target with the restore skipped, so nothing later could rescue
# the bytes. A guard's `deny` was silently discarded and the hook still exited 0.
#
# Split, the worst case degrades from "the guard is disarmed" to "the log is short", which is the
# correct failure direction for observability. Pinned by
# `test_output_SURVIVES_a_vanished_stdout_tempfile`.
if [ -n "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
# Same rule as the stdin replay: a trailing `2>/dev/null` here would permanently silence stderr
# rather than suppress an error from this redirection. fd 3 is known open on this path.
exec 1>&3 3>&- || true
fi
if [ -n "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
# Read through the FD, not the path — see the `exec 4<` comment in begin. `cat <&4` also avoids
# the other trap: `out=$(cat ...)` strips trailing newlines, so replaying through a variable
# delivers the hook's JSON one byte short of what it wrote, which every JSON parser downstream
# accepts without complaint. Pinned by `test_stdout_is_replayed_BYTE_EXACT`.
out=$(cat <&4 2>/dev/null || true) # for CLASSIFICATION; losing a trailing newline is harmless
exec 4<&- 2>/dev/null || true
# Replay the exact bytes from the file if the name still resolves, else from what the fd gave
# us. The first path is byte-exact; the second is the rescue.
if [ -r "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ] && [ -s "${ETV_HOOK_FIRE_STDOUT_TMP:-}" ]; then
cat "$ETV_HOOK_FIRE_STDOUT_TMP" 2>/dev/null || true
elif [ -n "$out" ]; then
printf '%s' "$out"
fi
fi
etv_hook_fire_record \
"${ETV_HOOK_FIRE_NAME:-unknown-hook}" "${ETV_HOOK_FIRE_LABEL:-}" \
"${ETV_HOOK_FIRE_EVENT:-}" "${ETV_HOOK_FIRE_TOOL:-}" \
"exit" "$code" "$(etv_hook_fire_classify "$out" "$code" "${ETV_HOOK_FIRE_MODE:-capture}")" || true
rm -f "${ETV_HOOK_FIRE_STDIN_TMP:-}" "${ETV_HOOK_FIRE_STDOUT_TMP:-}" 2>/dev/null || true
# Re-raise the hook's own status explicitly rather than relying on the trap preserving it. Bash
# does not re-enter an EXIT trap, so this is not recursive.
exit "$code"
}
# ---------------------------------------------------------------------------------------------
# Read side: the report
# ---------------------------------------------------------------------------------------------
# The hook population, DERIVED from the filesystem, never listed
# (`testing.guard-derives-population-from-source`). A hook added tomorrow appears in the report as
# NEVER-FIRED the moment it exists, rather than being invisible because nobody updated an array.
etv_hook_fire_population() {
local root="${1:-}" f
[ -d "$root/.claude/hooks" ] || return 0
for f in "$root"/.claude/hooks/*.sh; do
[ -f "$f" ] || continue
basename "$f" .sh
done
}
etv_hook_fire_repo_root() {
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "$CLAUDE_PROJECT_DIR/.claude/hooks" ]; then
printf '%s' "$CLAUDE_PROJECT_DIR"
return 0
fi
( cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd )
}
etv_hook_fire_report() {
local all=0 as_json=0 dir root files hook
while [ $# -gt 0 ]; do
case "$1" in
--all) all=1 ;;
--json) as_json=1 ;;
--dir) shift; ETV_HOOK_FIRE_LOG_DIR="${1:-}" ;;
--session) shift; ETV_HOOK_FIRE_SESSION="${1:-}" ;;
*) printf 'hook-fire-log: unknown report option %s\n' "$1" >&2; return 2 ;;
esac
shift
done
dir="$(etv_hook_fire_log_dir)"
root="$(etv_hook_fire_repo_root)"
if [ "$all" = "1" ]; then
files=$(find "$dir" -name '*.jsonl' -type f 2>/dev/null | sort)
else
files="$(etv_hook_fire_log_file)"
[ -f "$files" ] || files=""
fi
if [ -z "$files" ]; then
printf 'hook-fire-log: no records under %s%s\n' "$dir" \
"$([ "$all" = "1" ] || printf ' for session %s (try --all)' "$(etv_hook_fire_session)")" >&2
fi
# ANTI-VACUITY. A report over an empty population would print a clean table of nothing and read
# as "all hooks accounted for" — the exact failure this file exists to end.
local pop
pop=$(etv_hook_fire_population "$root")
if [ -z "$pop" ]; then
printf 'hook-fire-log: found NO hook scripts under %s/.claude/hooks — refusing to report, because a report over an empty population reads as full coverage.\n' "$root" >&2
return 2
fi
local total_fires=0 rows=""
while IFS= read -r hook; do
[ -n "$hook" ] || continue
local fires decisions
fires=0
decisions=""
if [ -n "$files" ]; then
fires=$(cat $files 2>/dev/null | grep -c "\"hook\":\"$hook\",.*\"phase\":\"fire\"" || true)
decisions=$(cat $files 2>/dev/null \
| grep "\"hook\":\"$hook\",.*\"phase\":\"exit\"" \
| sed -n 's/.*"decision":"\([^"]*\)".*/\1/p' \
| sort | uniq -c | sort -rn \
| awk '{printf "%s=%s ", $2, $1}')
fi
[ -z "$fires" ] && fires=0
total_fires=$((total_fires + fires))
if [ "$as_json" = "1" ]; then
rows="${rows}{\"hook\":\"$hook\",\"fires\":$fires,\"decisions\":\"$(printf '%s' "$decisions" | tr -d '"')\"}\n"
else
rows="$(printf '%s%-34s %6s %s\n' "$rows" "$hook" "$fires" "${decisions:-—}")"$'\n'
fi
done <<EOF
$pop
EOF
if [ "$as_json" = "1" ]; then
printf '{"log_dir":"%s","total_fires":%s,"hooks":[%s]}\n' "$dir" "$total_fires" \
"$(printf '%b' "$rows" | sed '/^$/d' | paste -sd, -)"
return 0
fi
printf 'Hook fire log — %s\n' "$dir"
printf '%-34s %6s %s\n' 'HOOK' 'FIRES' 'DECISIONS'
printf '%s' "$rows"
printf '\n%s hook scripts on disk, %s recorded fires.\n' "$(printf '%s\n' "$pop" | wc -l | tr -d ' ')" "$total_fires"
printf 'A hook showing 0 has NOT been observed firing. That is a finding to investigate (broken\nwiring vs genuinely never matched in this window), not a blank to ignore.\n'
return 0
}
# ---------------------------------------------------------------------------------------------
# CLI (only when executed, never when sourced)
# ---------------------------------------------------------------------------------------------
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -uo pipefail
case "${1:-report}" in
report) shift 2>/dev/null || true; etv_hook_fire_report "$@" ;;
path) etv_hook_fire_log_file; printf '\n' ;;
record) shift; etv_hook_fire_record "$@" ;;
*)
printf 'usage: %s [report [--all|--json|--dir D|--session S] | path]\n' "$0" >&2
exit 2
;;
esac
fi