Pitfall Memory bloomnet

Hook Wrapper Needs Binary Preflight

pitfallhooksclaude-coderust

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:

  1. Claude Code invokes /Users/pluto/.claude/hooks/peon-dispatch.sh.
  2. Bash reads PEON_BIN="${PEON_BIN:-peon}": no env override set, defaults to bare name peon.
  3. Bash reads echo "$INPUT" | "$PEON_BIN" dispatch.
  4. Bash resolves peon on PATH → not found → exec fails.
  5. Non-blocking error surfaced to Claude Code.
  6. (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):

  1. Install-time: binaries must land on a stable PATH entry. Symlinking to target/release/ lets iterative rebuilds self-heal.
  2. Wrapper preflight: command -v "$BIN" >/dev/null 2>&1 || exit 0 at the top of every hook script.
  3. 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.