fix(#370): hook wrappers silent no-op outside an apexyard fork#371
Conversation
When Claude Code is launched from a directory with no apexyard ancestry (e.g. /tmp, a bare project clone, or any dir outside the ops fork), all 43 hook wrappers crashed with "No such file or directory" on every tool call: Failed with non-blocking status code: bash: /.claude/hooks/detect-role-trigger.sh: No such file or directory Root cause: the walk-up loop in each wrapper correctly stops at / when no anchor file is found, but the wrapper unconditionally execs $r/.claude/hooks/<name>.sh — landing on /.claude/hooks/<name>.sh when $r is /. One error per wrapper, per tool call → UI noise flood. Fix: insert an anchor-found guard between `done;` and `exec`: done;[ -f "$r/.apexyard-fork" ] || [ -f "$r/onboarding.yaml" ] || exit 0;exec ... When neither anchor is found, the wrapper exits 0 silently — the correct behaviour outside an ops fork (hooks are framework-internal and shouldn't fire on unrelated repos). Scope: all 43 wrappers in .claude/settings.json. Applied via a python JSON-safe text substitution + re-validated the JSON parses cleanly. Regression test (test_settings_wrappers_silent_noop.sh): - Invariant 1: every wrapper has the '|| exit 0;exec' guard (no unguarded `done;exec` patterns) — protects against a regression where a new wrapper added later forgets the guard - Invariant 2: a representative wrapper invoked from /tmp (no fork ancestry) exits 0 with empty stderr — empirical confirmation of the bug fix - Invariant 3: same wrapper invoked from inside the fork on a non- trigger path still exits 0 silently — negative control proving the guard doesn't accidentally block legitimate in-fork invocations 3/3 PASS at HEAD. Closes #370 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
atlas-apex
left a comment
There was a problem hiding this comment.
Code Review: PR #371
Commit: 4b426ab780aca4aa9f4350df477edd0d7155b5c5
Verdict intent: CHANGES REQUESTED. Filed as a comment review because GitHub disallows self-
--request-changes. The blocking item below is non-advisory and must be resolved before merge.
Summary
Targeted bug fix for the UI-flooding "No such file or directory" noise that every .claude/settings.json hook wrapper produced on every tool call when Claude Code was launched outside an apexyard ancestry. Root cause is correctly diagnosed: walk-up loop terminates at / when no anchor is found, but the wrapper unconditionally execs $r/.claude/hooks/<name>.sh, landing on /.claude/hooks/<name>.sh (doesn't exist). Fix inserts an anchor-found guard ([ -f "$r/.apexyard-fork" ] || [ -f "$r/onboarding.yaml" ] || exit 0) between done; and exec in all 43 wrappers. Scope is exactly 2 files (settings.json + new regression test). The fix is correct, the bug reproduces pre-fix and is silenced post-fix.
Checklist Results
- Architecture & Design: Pass (single-anchor laxity at wrapper level matches AgDR-0041 § Decision point 2; both-files-required strictness stays in the hook-internal resolver)
- Code Quality: Pass (uniform transform, JSON re-validated)
- Testing: Pass with one strengthening suggestion (see below)
- Security: N/A (no auth/crypto/secrets touched)
- Performance: Pass (one extra
-ftest per wrapper invocation, negligible) - PR Description & Glossary: Pass (narrative summary bullets, 4-row glossary)
- Technical Decisions (AgDR):N/A (faithful execution of existing AgDR-0021 + AgDR-0041; no new decision)
- Adopter Handbooks: N/A (no architecture / migration / language-specific findings)
Issues Found
⛔ BLOCKING: CI shellcheck workflow failing at HEAD 4b426ab — https://github.com/me2resh/apexyard/actions/runs/26239945147
The new regression test trips SC2164 twice:
./.claude/hooks/tests/test_settings_wrappers_silent_noop.sh:71:3: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164]
./.claude/hooks/tests/test_settings_wrappers_silent_noop.sh:89:3: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164]
The shellcheck .claude/hooks workflow runs at -S warning, so warnings block. Per .claude/rules/pr-quality.md § "No Red CI Before Merge" — never merge with red CI, even pre-existing or unrelated. Here the failure is new and caused by this PR.
Fix:
# line 71
cd /tmp || exit 1
# line 89
cd "$ROOT" || exit 1Verification (positive findings)
-
Uniform transform correctness:
grep -cF '|| exit 0;exec' .claude/settings.json→ 43 ✓grep -cF 'done;exec' .claude/settings.json→ 0 ✓grep -cF 'r=$PWD' .claude/settings.json→ 43 ✓- Unique JSON-escaped transform shape: 1 ✓
- No drive-by edits — every +/- in settings.json is wrapper-shape change
-
JSON validity post-edit:
jq . .claude/settings.json > /dev/nullexits 0 ✓ -
Guard semantics: walk-up loop and guard use the same
||shape (single-anchor laxity at the wrapper level, both-files-required stays in the in-hook resolver). Matches AgDR-0041 § Decision point 2. Sanity test: injected a.apexyard-forkanchor in/tmp/XXX/, ran the wrapper from there → hook DID run (stderrHOOK_RAN, rc=0). Confirms the guard ALLOWS exec when an anchor exists. -
Bug reproduction:
- Pre-fix shape (no guard) run from
/tmp:rc=126, stderr =bash: /.claude/hooks/detect-role-trigger.sh: No such file or directory; bash: line 0: exec: ... cannot execute: No such file or directory - Post-fix shape (this PR) run from
/tmp:rc=0, empty stderr - Fix works as advertised.
- Pre-fix shape (no guard) run from
-
Regression test 3/3 PASS locally.
-
Scope discipline: 2 files, +154/-43. No other edits.
Suggestions
-
(nit) Strengthen Invariant 3 with a positive in-fork case. Current invariant 3 runs the wrapper inside the fork on a non-trigger path and asserts
rc=0+ empty stderr. This passes whether the hook actually ran (silent on non-trigger) OR if the guard accidentally exits 0 always (hook never runs). The 3-invariant suite as a whole is fine — invariant 1 enforces the guard SHAPE textually, which makes the "always exits" failure mode impossible to reach without also tripping invariant 1. Still, an additional positive sub-case ("inside fork on a TRIGGER path → expected banner appears on stderr") would prove end-to-end behaviour rather than relying on the textual shape check. Worth a follow-up, not blocking. -
(nit) Test scope: one wrapper as proxy for 42. The bug is in the wrapper SHAPE, not in any individual hook body, and invariant 1 (textual count check) catches the shape regression for all 43. The runtime smoke tests only
detect-role-trigger.sh. Acceptable as-is given the uniform transform; a per-wrapper smoke would yield diminishing returns. PR-acceptable. -
(suggestion) Wire
test_settings_wrappers_silent_noop.shinto CI. The framework's convention is per-test CI wiring (seeextract-subpacks-on-release.yml,site-counts-check.yml). Without a CI entry, invariant 1 will only be checked when someone manually runs the test. A small dedicated workflow (or a genericrun-hook-tests.ymlthat globs.claude/hooks/tests/test_*.sh) would close the loop. Not blocking; can ship as a follow-up.
Verdict
CHANGES REQUESTED (filed as comment per self-PR constraint)
The fix itself is correct, well-scoped, and well-tested locally. The blocker is the new red CI status from the SC2164 shellcheck warnings on the test file — fixable in ~30 seconds (|| exit 1 on the two cd calls). Once CI is green at the updated HEAD, this is approval-ready and I'd recommend re-running /code-review on the new commit.
🤖 Reviewed by Rex (Code Reviewer Agent)
📌 Reviewed commit: 4b426ab780aca4aa9f4350df477edd0d7155b5c5
Rex review on PR #371 caught two SC2164 warnings on the new test that the framework's shellcheck CI workflow blocks at `-S warning`. Both calls were inside subshells (so `cd` failure wouldn't propagate to the test loop) — but shellcheck doesn't analyse subshell scope. Add explicit `|| exit 1` guards to satisfy the linter. Line 71: `cd /tmp || exit 1` Line 89: `cd "$ROOT" || exit 1` Test still 3/3 PASS locally. Refs #370 PR #371 Rex review (CI red blocker). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
atlas-apex
left a comment
There was a problem hiding this comment.
Code Review: PR #371 (re-review after shellcheck fix)
Commit: f486e4ade431251e473483f7ade8a34a41d1a3b0
Summary
Re-review at new HEAD f486e4a after the shellcheck SC2164 follow-up. Prior review (CHANGES REQUESTED on 4b426ab for CI red on SC2164 warnings at test_settings_wrappers_silent_noop.sh:71 + :89) is now resolved.
Verification against the 5 checks
| # | Check | Status |
|---|---|---|
| 1 | cd ... || exit 1 is the canonical SC2164 fix |
PASS — ` |
| 2 | Test still 3/3 PASS | PASS — ran bash .claude/hooks/tests/test_settings_wrappers_silent_noop.sh at HEAD f486e4a: 3 passed, 0 failed |
| 3 | shellcheck .claude/hooks CI workflow green at HEAD |
PASS — workflow completed SUCCESS at 2026-05-21T17:03:22Z (was the red blocker on prior HEAD) |
| 4 | No drive-by edits — diff is exactly the 2 lines | PASS — git diff 4b426ab..f486e4a --stat reports 1 file changed, 2 insertions(+), 2 deletions(-) on test_settings_wrappers_silent_noop.sh; lines 71 + 89 only |
| 5 | Original PR main work unchanged | PASS — at HEAD: 43 wrappers (r=$PWD count), 43 guarded (|| exit 0;exec count), 0 unguarded (done;exec count). Matches the prior approved-modulo-CI state. |
Checklist Results
- Architecture & Design: Pass (no architectural surface change; pure CI hygiene)
- Code Quality: Pass
- Testing: Pass — regression test still asserts the three invariants and now lints clean
- Security: Pass (no security surface)
- Performance: Pass (no performance surface)
- PR Description & Glossary: Pass (inherited from the prior commit; follow-up commit message clearly explains the why)
- Summary Bullet Narrative: Pass
- Technical Decisions (AgDR):N/A — pure linter-hygiene fix, no decision surface
- Adopter Handbooks: N/A — no handbooks loaded for shell-test diffs
Issues Found
None.
Suggestions
None.
Verdict
APPROVED — all three CI checks green, test passes, scope is exactly the 2 lines described, original 43-wrapper work intact. Cleared for merge from the code-review side; operator owns the marker write + per-PR CEO approval.
Reviewed by Rex (Code Reviewer Agent)
Reviewed commit: f486e4ade431251e473483f7ade8a34a41d1a3b0
* fix(#370): hook wrappers silent no-op outside an apexyard fork When Claude Code is launched from a directory with no apexyard ancestry (e.g. /tmp, a bare project clone, or any dir outside the ops fork), all 43 hook wrappers crashed with "No such file or directory" on every tool call: Failed with non-blocking status code: bash: /.claude/hooks/detect-role-trigger.sh: No such file or directory Root cause: the walk-up loop in each wrapper correctly stops at / when no anchor file is found, but the wrapper unconditionally execs $r/.claude/hooks/<name>.sh — landing on /.claude/hooks/<name>.sh when $r is /. One error per wrapper, per tool call → UI noise flood. Fix: insert an anchor-found guard between `done;` and `exec`: done;[ -f "$r/.apexyard-fork" ] || [ -f "$r/onboarding.yaml" ] || exit 0;exec ... When neither anchor is found, the wrapper exits 0 silently — the correct behaviour outside an ops fork (hooks are framework-internal and shouldn't fire on unrelated repos). Scope: all 43 wrappers in .claude/settings.json. Applied via a python JSON-safe text substitution + re-validated the JSON parses cleanly. Regression test (test_settings_wrappers_silent_noop.sh): - Invariant 1: every wrapper has the '|| exit 0;exec' guard (no unguarded `done;exec` patterns) — protects against a regression where a new wrapper added later forgets the guard - Invariant 2: a representative wrapper invoked from /tmp (no fork ancestry) exits 0 with empty stderr — empirical confirmation of the bug fix - Invariant 3: same wrapper invoked from inside the fork on a non- trigger path still exits 0 silently — negative control proving the guard doesn't accidentally block legitimate in-fork invocations 3/3 PASS at HEAD. Closes #370 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#370): shellcheck SC2164 — guard cd calls in regression test Rex review on PR #371 caught two SC2164 warnings on the new test that the framework's shellcheck CI workflow blocks at `-S warning`. Both calls were inside subshells (so `cd` failure wouldn't propagate to the test loop) — but shellcheck doesn't analyse subshell scope. Add explicit `|| exit 1` guards to satisfy the linter. Line 71: `cd /tmp || exit 1` Line 89: `cd "$ROOT" || exit 1` Test still 3/3 PASS locally. Refs #370 PR #371 Rex review (CI red blocker). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: me2resh <ahmed.abdelaliem@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
.claude/settings.jsonhook wrapper produced on every tool call when Claude Code was launched outside an apexyard ancestry (e.g./tmp, a bare project clone, any unrelated repo). Root cause: the walk-up loop correctly stops at/when no anchor file is found, but the wrapper unconditionallyexecs$r/.claude/hooks/<name>.sh— which lands on/.claude/hooks/<name>.sh(doesn't exist). One error per wrapper, per tool call → unusable terminal.done;andexecin all 43 wrappers:done;[ -f "$r/.apexyard-fork" ] || [ -f "$r/onboarding.yaml" ] || exit 0;exec .... When neither anchor is found, the wrapper silently exits 0 — the correct behaviour outside an ops fork (hooks are framework-internal and shouldn't fire on unrelated repos).done;execpatterns remain in.claude/settings.json.test_settings_wrappers_silent_noop.shlocks in three invariants: every wrapper has the guard (catches future-added wrapper without the guard), wrapper invoked from/tmpexits 0 silently (empirical bug-fix confirmation), wrapper invoked from inside the fork on a non-trigger path still exits 0 (negative control proving the guard doesn't accidentally block legitimate invocations).Testing
bash .claude/hooks/tests/test_settings_wrappers_silent_noop.sh— PASS at 3/3.(cd /tmp && bash -c '<wrapper>')— exits 0 with empty stderr (was:bash: /.claude/hooks/<name>.sh: No such file or directory).Glossary
bash -c '...'invocation in.claude/settings.jsonthat wraps each.claude/hooks/<name>.shcall. Pre-#370 shape: walk up from$PWDto find an ops-fork anchor, then exec the hook at the resolved path. Post-#370 shape: same walk-up, but if no anchor is found, silently exit 0 instead of attempting to exec at/..apexyard-fork(v2 marker) oronboarding.yaml(v1 legacy). The wrapper resolves the ops-fork root by walking up the directory tree looking for either. Per AgDR-0021 § B + AgDR-0041 § "Decision" point 2 — wrappers accept either form./tmpor a bare clone, the framework's hooks don't fire (correct — they're framework-internal), and the operator's terminal stays clean. Previously: noise flood per tool call./while ... && [ "$r" != / ]; do r=${r%/*}; done. When no anchor is found,$rarrives at/. The fix's `Closes #370