Skip to content

markdown-format: PostToolUse hook exited non-zero on a path where the script cannot exit non-zero #2867

Description

@kyle-sexton

Symptom

A Stop-time report from claude-ops surfaced a hook failure that was never visible during the session:

PostToolUse:Write [Formatting Markdown...] (1x; last stderr: Failed with non-blocking status code: No stderr output)

Session: Windows 11, Git Bash, Claude Code 2.1.228 (updated to 2.1.233 mid-session).

Why this is odd rather than routine

hooks/markdown-format.sh is advisory by design. Its own header says so:

ADVISORY: always exits 0 — unfixable markdownlint violations surface via additionalContext but never block the edit.

That is accurate: there is no exit 1 anywhere in the script, and every branch terminates at exit 0.

So a non-zero status from this hook cannot be a verdict the script rendered. It means the script never reached any of its own exit paths.

What the failing invocation would have done

Only two Write calls occurred in the session, both .txt files in a non-repository directory (C:\Users\<user>\). One of the two failed.

For a .txt path the script exits at its jq-free applicability pre-filter, ~30 lines in:

RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0
case "$RAW_FILE" in
*.md | *.mdc) ;;
*) exit 0 ;;
esac

Nothing downstream — the opt-in gate, the trust gate, hook::require_jq (the one path in hook-utils.sh that exits non-zero, and only as exit 2 under PreToolUse) — is reachable. The failing invocation therefore had essentially no logic to fail in.

That leaves the window between process launch and line ~50: the source hook-utils.sh at line 22, hook::check_enabled, hook::buffer_stdin, or the launch itself. hook::buffer_stdin failure is already handled (|| exit 0), and a source failure would normally write to stderr — and the report explicitly says there was none.

Root cause undetermined. Filing for the observability gap rather than claiming a diagnosis.

Why it looks transient

Two structurally identical invocations, one failed. Contributing conditions worth noting:

  • A background subagent was running concurrently (65 tool calls over ~38 minutes). Git Bash / MSYS2 fork() pressure under concurrency is a known Windows failure class, though it usually does write to stderr.
  • Seven versions of this plugin are in the cache (0.11.8 through 0.11.19) and Claude Code updated itself mid-session. Its own warning covers this: a session runs the hook config it loaded at startup, so a mid-session plugin update can leave a stale path loaded.

The part worth fixing

A launch failure is invisible to the hook and nearly invisible to the operator. It surfaced only because claude-ops reports unsurfaced hook-failure records at Stop — otherwise nothing would have indicated it. And because the hook is advisory and fails open, the tool call it guards proceeds as if approved. For markdown-format specifically that is low-stakes (it formats Markdown; it enforces nothing), but the launch path is shared.

This is not specific to this plugin. Every melodic-software hook plugin checked — bash-format, eol-normalizer, typos-format, powershell-format, guardrails — invokes its hook the same way:

"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/<name>.sh"

A bare .sh with no explicit interpreter, relying on the shebang resolving on Windows. Contrast the form a hand-written settings.json hook on the same machine uses:

{
  "type": "command",
  "command": "C:/Program Files/Git/bin/bash.EXE",
  "args": ["C:/Users/<user>/.claude/hooks/block-destructive-removal.sh"]
}

If the bare-script form is the fragility, every hook plugin in the marketplace shares it and only this one instance happened to be recorded. That is the question worth answering before touching anything else — and it should be answered with evidence, not by preemptively rewriting six plugins.

Suggested investigation

  1. Determine whether the bare-script command form is reliable on Windows across the conditions above (mid-session plugin update, concurrent subagent load), or whether an explicit-interpreter + args form is the more robust contract for the fleet.
  2. Decide whether a hook that is advisory by construction should be able to report "I did not run" distinctly from "I ran and found nothing". Today those are indistinguishable to the operator.
  3. If the bare-script form is confirmed fragile, treat it as a marketplace-wide convention change rather than a per-plugin patch.

Not asking for

A change to the advisory/fail-open semantics. That behavior is deliberate, documented, and correct for this hook — the gap is that a failure to launch is unobservable, not that the hook declines to block.

Activity

  1. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Aug 16, 2026
  2. self-assigned this
    on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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