Skip to content

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

Closed

Description

@kyle-sexton

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: 1 and
    durationMs: 1190 carrying 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 exitCode is 126 or 127, or whose stderr carries an exec-failure signature
    such as execvpe, still receives the launch-failure wording — the branch must not delete the
    sentence where it is correct.
  • The placeholder at :131 additionally matches the literal harness string
    Failed with non-blocking status code: No stderr output and 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_error record 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/main via git merge-base --is-ancestor.
  • plugins/claude-ops/hooks/hook-failure-audit.sh:43-46 records 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageNot yet classified. Floor until a type and one priority tier are set.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions