Repository navigation
claude-ops: 2593's empty-stderr placeholder keys on a shape the harness never emits, so completed non-zero exits are still reported as launch failures #2849
Description
Severity carried: informational (disk-hygiene-hooks F10) · low-medium as an isolated defect
(fleet-stop-hooks S-2) · medium (S-4a) · low (S-4b) — uncalibrated
Problem
Issue 2593 was closed by PR 2625 (d0cd63ffded80a9f0227dfb61791688bd489d9fc, "attribute silent Stop
hook failures with exitCode"), which is an ancestor of origin/main. The exitCode half shipped and
works.
The empty-stderr half does not fire on any record observed in this corpus. The placeholder branch
keys on .stderr == "", but Claude Code does not hand the hook an empty stderr — it synthesizes a
sentence. So the placeholder is dead code, while the misleading "A hook that fails to launch
enforces nothing" sentence it was shipped alongside is unconditional and still reaches the
operator on every record, including the completed-non-zero-exit case that motivated 2593.
A third defect ships in the same string: maintainer-facing prose describing the fix is embedded in the
operator-facing message.
Evidence
All line numbers re-read from origin/main content at 534eac1382a1e18212295210e18fbbf5a3b8f4dc
(blob OID verified with git rev-parse origin/main:plugins/claude-ops/hooks/hook-failure-audit.sh).
plugins/claude-ops/hooks/hook-failure-audit.sh:131 — the placeholder, gated on empty stderr:
(if .stderr == "" then "(no stderr output)" else .stderr end) as $err |
:132 is 2625's exitCode attribution, which does work:
(if .exitCode == null then "?" else (.exitCode|tostring) end) as $ec |
:136 is the emitted message — a single unconditional string, with no branch on exitCode or
durationMs (emphasis added, and the angle brackets around marketplace are HTML-escaped here;
the source line is otherwise verbatim):
claude-ops: ${TOTAL} hook failure record(s) in this session's transcript were never surfaced:
${DETAIL}. A hook that fails to launch enforces nothing — the tool calls it guards proceed as if
approved (fail-open). Includes exitCode and an empty-stderr placeholder so silent Stop failures
remain attributable (hookName + command). Confirm hook_failure_audit_enabled stays true via
/plugin configure claude-ops@<marketplace> (default true). …
Measured corpus-wide, 2026-08-16, over ~/.claude/projects/**/*.jsonl (596 .jsonl files
scanned, 10 containing at least one record), counting attachment.type == "hook_non_blocking_error":
hook_non_blocking_error records TOTAL : 175
with EMPTY stderr (== '' or absent) : 0
carrying the harness placeholder : 12
carrying an execvpe message : 163
other : 0
The 12 carry the literal string Failed with non-blocking status code: No stderr output — Claude
Code's own synthesized text for a hook that produced no stderr. The 163 carry a WSL execvpe
message. Zero records in the entire corpus have an empty .stderr, so the :131 branch has never
been taken. The upstream extraction at :97 is ((.stderr // "") | .[0:160]), which only yields ""
when the attachment's stderr is genuinely absent or empty — a shape the harness does not emit.
exitCode alone cannot carry the branch, measured over the same 175 records:
exitCode ABSENT/null : 0 durationMs ABSENT : 0 durationMs == 0 : 0
exitCode distribution: {'1': 173, '66': 1, '127': 1}
exit=1 execvpe 163 | exit=1 placeholder 10 | exit=66 placeholder 1 | exit=127 placeholder 1
Every record carries a non-null exitCode, so "exitCode is not null" selects everything. And the
launch-failure wording is not always wrong: the 163 execvpe records and the single exit=127 really
are launch failures. The discriminator must separate those from a hook that ran and returned non-zero
— 2593's record is exitCode: 1, durationMs: 1190, carrying the placeholder.
Why the current behavior is wrong
2593 asked for a silent Stop failure to be attributable. The fix delivered attribution of which
hook (hookName + command + exitCode) but left the diagnosis wrong in the exact case it was
written for. 2593's own record — a hook that ran 1190 ms and exited 1 — is reported to the operator as
one that "fails to launch", with the remedy "restart the session to load the fix": advice that
changes nothing for a completed non-zero exit. The failure mode the message describes and the one that
occurred are different, and nothing lets the operator tell them apart even though exitCode is now
printed in the same line.
The placeholder makes this worse rather than better: it was the mechanism intended to keep a silent
failure legible, it never runs, and the string it was meant to replace passes through verbatim — so
last stderr: Failed with non-blocking status code: No stderr output reads as though the hook emitted
that sentence rather than the harness.
The maintainer-facing sentence is a separate, cheaper problem: it explains the fix's implementation to
a reader who is trying to act on an incident.
Acceptance criteria
- The "fails to launch" sentence is branched. A record with
exitCode: 1and
durationMs: 1190carrying the harness placeholder is described as a completed non-zero
exit, not a launch failure, and does not carry the restart-the-session remedy. - A record whose
exitCodeis126or127, or whose stderr carries an exec-failure signature
such asexecvpe, still receives the launch-failure wording — the branch must not delete the
sentence where it is correct. - The placeholder at
:131additionally matches the literal harness string
Failed with non-blocking status code: No stderr outputand renders it as
last stderr: (none — hook produced no stderr), so it is not attributed to the hook. - The sentence "Includes exitCode and an empty-stderr placeholder so silent Stop failures remain
attributable (hookName + command)" is removed from the operator-facing message at:136. - A test feeds a
hook_non_blocking_errorrecord carrying the harness placeholder string and
asserts the emitted message contains neither "fails to launch" nor the raw placeholder text. - A test feeds a record with a non-empty real stderr and asserts that stderr is still passed
through unchanged.
Related
- Closed issue 2593 — the original, closed by PR 2625 (
d0cd63ffd), confirmed an ancestor of
origin/mainviagit merge-base --is-ancestor. plugins/claude-ops/hooks/hook-failure-audit.sh:43-46records the deliberate overlap with
disk-hygiene's Stop monitor; the keep/drop judgement there is out of scope here.- Issue 2691 — stale-base squash merges silently reverting merged fixes; 2625's content was confirmed
present on main rather than inferred from the issue being closed.
Carried-forward auditor caveats
- The offending third-party hook's internal trigger is unverified and non-reproducible on demand;
identity was proven, cause was not. No upstream issue is being filed, and nothing here depends on
the cause. - Corpus-wide, not per-session (named assumption 6). The 175/0/12/163 census aggregates
~/.claude/projects/on one Windows machine and mixes sessions that were concurrently mutating the
same filesystem. At least 19 other project directories exist and were not harvested, so these are
floors, not totals. The counts are dated 2026-08-16 and will grow. - Windows-observed only. No Linux behavior is asserted.
Activity
fix(claude-ops): count hook-failure classes per record, not per group
fix(claude-ops): report a bare 126/127 hook exit as ambiguous, not a …
fix(claude-ops): branch hook-failure-audit on launch failure vs compl…
fix(claude-ops): count hook-failure classes per record, not per group
fix(claude-ops): report a bare 126/127 hook exit as ambiguous, not a …
fix(claude-ops): branch hook-failure-audit on launch failure vs compl…