Hook Wrapper Needs Binary Preflight
What Happened
Every tool call in a Claude Code session surfaced 2-3 hook error blocks like:
PreToolUse:Bash hook error
Failed with non-blocking status code: /Users/pluto/.claude/hooks/peon-dispatch.sh: line 12: peon: command not found
Tools still completed: Claude Code treats the wrappers as non-blocking: but the error blocks pushed real signal off-screen. A freshly-pushed master -> master confirmation got buried under ~8-12 stderr lines per user turn. Every PreToolUse(Bash|Write|Edit|Task) plus PostToolUse(Write|Edit) fired 2 broken hooks (peon-dispatch.sh, peon-codeguard.sh).
The ironic twist: a fail-open fix had just shipped in the peon Rust binary (commit 9b9a96d). The errors continued anyway.
Root Cause
The fail-open fix lived in the wrong layer. Sequence of events on every hook firing:
- Claude Code invokes
/Users/pluto/.claude/hooks/peon-dispatch.sh. - Bash reads
PEON_BIN="${PEON_BIN:-peon}": no env override set, defaults to bare namepeon. - Bash reads
echo "$INPUT" | "$PEON_BIN" dispatch. - Bash resolves
peonon PATH → not found → exec fails. - Non-blocking error surfaced to Claude Code.
- (Never reached) the Rust binary’s fail-open logic.
Fail-open at the binary layer cannot help when the shell layer dies first.
Contributing second-order bug: ~/.local/bin/stella existed as a dangling symlink pointing at a target/release/stella that had never been built. Without a preflight, wrappers trust the symlink and exec-fail anyway.
Contributing third-order bug: five wrapper templates defaulted to a __STELLA_BIN__ placeholder, relying on an unstated contract that operators would export STELLA_BIN=... per session. The install script never performed the substitution. Every installed wrapper carried a literal __STELLA_BIN__ default that matches no binary on disk.
How to Avoid
Add a command -v preflight immediately after the binary-name assignment. If the binary isn’t resolvable, exit 0 silently: the wrapper has no work to do.
PEON_BIN="${PEON_BIN:-peon}"
command -v "$PEON_BIN" >/dev/null 2>&1 || exit 0
Works for both bare names (PATH lookup) and absolute paths (file exists + executable). Exit 0 matches “non-blocking hook” semantics: Claude Code sees success, no error noise.
Three layers of defense are required (all three, not one or the other):
- Install-time: binaries must land on a stable PATH entry. Symlinking to
target/release/lets iterative rebuilds self-heal. - Wrapper preflight:
command -v "$BIN" >/dev/null 2>&1 || exit 0at the top of every hook script. - Binary fail-open: the program itself handles missing data sources and broken configs gracefully.
Missing any one layer turns a silent skip into a visible error. The user-facing symptom always names the outermost failing layer, so hard-to-debug bugs live in whichever layer the operator forgot to harden.
Fix Applied
All 17 hook templates in ~/Documents/rusty-bloomnet/hooks/*.sh.template now carry the preflight. The __STELLA_BIN__ placeholder was replaced with stella (the real PATH name); safety moved from the sentinel to the preflight. Binaries symlinked into ~/.local/bin/ pointing at target/release/, so cargo build --release -p peon -p stella auto-updates deployed hooks without re-running the installer.
Verification: env -i PATH=/usr/bin:/bin bash ~/.claude/hooks/peon-codeguard.sh <<< '{...}' returns exit 0 with zero stderr, proving the wrapper handles a missing binary silently even when PATH is hostile.
Related
- topics/pitfalls/hook-paths-must-be-absolute:
$HOMEvs. absolute paths insettings.local.json(same project, complementary failure mode). - topics/pitfalls/hook-pipeline-data-integrity: ANSI-code leakage in failure manifests (same project, boundary-hygiene theme).
- breakthroughs/2026-04-20-self-healing-hook-install: positive framing of the three-layer robustness pattern.
- projects/bloomnet/_index