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
- 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.
- 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.
- 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.
Symptom
A
Stop-time report fromclaude-opssurfaced a hook failure that was never visible during the session: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.shis advisory by design. Its own header says so:That is accurate: there is no
exit 1anywhere in the script, and every branch terminates atexit 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
Writecalls occurred in the session, both.txtfiles in a non-repository directory (C:\Users\<user>\). One of the two failed.For a
.txtpath the script exits at its jq-free applicability pre-filter, ~30 lines in:Nothing downstream — the opt-in gate, the trust gate,
hook::require_jq(the one path inhook-utils.shthat exits non-zero, and only asexit 2underPreToolUse) — 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.shat line 22,hook::check_enabled,hook::buffer_stdin, or the launch itself.hook::buffer_stdinfailure is already handled (|| exit 0), and asourcefailure 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:
fork()pressure under concurrency is a known Windows failure class, though it usually does write to stderr.0.11.8through0.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-opsreports unsurfaced hook-failure records atStop— otherwise nothing would have indicated it. And because the hook is advisory and fails open, the tool call it guards proceeds as if approved. Formarkdown-formatspecifically 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:A bare
.shwith no explicit interpreter, relying on the shebang resolving on Windows. Contrast the form a hand-writtensettings.jsonhook 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
argsform is the more robust contract for the fleet.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.