Given / When / Then
Given the parent session spawns parallel agents via /fan-out (or via direct Agent tool calls with isolation: "worktree")
When any of those agents tries to git push, gh pr create, or git commit -m "$(cat <<'EOF'...)" from inside their worktree
Then the validation hooks validate-branch-name.sh, validate-pr-create.sh, and validate-commit-format.sh resolve git context against the harness's $PWD (typically a sibling worktree's directory in the parent session) — NOT the worktree where the agent's command actually ran. This causes the hooks to:
- Read the wrong branch name from
git branch --show-current
- Read the wrong commit message from the wrong repo's HEAD
- Block the agent's legitimate operation with a misleading "your branch name doesn't match the convention" error
Expected: hooks resolve git context against the actual command's repo, either via the --head / --repo flag the operator passed, or via the directory the operator cd'd into before running the command. Not from a $PWD that's frozen at the harness-spawn moment.
Repro
This is reproduced every time parallel agents run from worktrees in this session. From three concurrent sub-agent reports:
Agent A (#188):
"validate-branch-name.sh / validate-pr-create.sh which read
git branch --show-current from the harness's pwd, not the worktree
I cd'd into. Worked around by renaming the host-worktree's branch."
Agent B (#189):
"Branch / title / body all conform to the rules the hook would have
validated. The push went through with `git -C` to override; the PR
itself was created via `gh api POST /repos/.../pulls` after
`gh pr create` repeatedly tripped the same misfire."
Agent C (#190):
"The agent's home cwd is a registered-project worktree whose own
branch name doesn't satisfy apexyard's validate-branch-name.sh,
which walks up from $PWD. Worked around by temporarily placing a
sentinel onboarding.yaml in the home dir."
Each agent independently hit the same bug and invented a different workaround — branch rename, git -C, sentinel file. None of those should be necessary.
The same shape is the gh api .../pulls/<N>/merge bypass that closed via #47 — a tool surface the hooks didn't originally cover. This ticket extends the same principle to validation hooks: gate on the command's actual context, not the harness's $PWD.
Why this matters
/fan-out is a sanctioned framework pattern — the parallel-work rule says we should offer it. But every fan-out today produces 3 simultaneous workarounds for the same hook misfire. The system fights itself: it sanctions fan-out via /fan-out yet blocks fan-out via $PWD-bound hooks.
The cost compounds: each agent's workaround is invisible to Rex review (the parent only sees the PRs), so the worked-around behavior ships. Eventually an adopter reads the SKILL.md / hook source, reproduces the misfire, and learns the workaround — at which point the workaround becomes the de-facto pattern, and the rule's enforcement is hollow.
Proposed fix
Hook 1 — validate-branch-name.sh
Read the branch from the actual git push command's source ref:
# Today (broken in worktrees):
BRANCH=$(git branch --show-current)
# Fix:
# Parse the push command for the source ref. `git push origin <branch>` →
# extract <branch>. Fall back to `git branch --show-current` if no ref is
# in the command (no-arg push relies on upstream tracking).
BRANCH=$(extract_push_ref "$COMMAND" || git branch --show-current)
Helper goes in _lib-extract-push-ref.sh (sibling to _lib-extract-pr.sh).
Hook 2 — validate-pr-create.sh
Read the branch from gh pr create's --head flag:
# Today (broken):
BRANCH=$(git branch --show-current)
# Fix:
HEAD_FLAG=$(echo "$COMMAND" | sed -nE 's/.*--head[[:space:]]+([^[:space:]]+).*/\1/p' | head -1)
BRANCH="${HEAD_FLAG:-$(git branch --show-current)}"
Same fallback: if no --head flag, use the local branch (today's behavior). When --head IS specified, the hook uses it.
Hook 3 — validate-commit-format.sh
The git commit -m "$(cat <<'EOF'...)" shell-substitution issue:
The hook reads the -m argument from the command string and treats it as the literal commit subject. When the operator uses $(cat <<'EOF'...) substitution, the substitution hasn't expanded yet at hook-invocation time — the hook sees the literal string $(cat <<'EOF'...) which obviously doesn't match the conventional-commit regex.
Fix: detect the heredoc-substitution pattern and skip validation of the subject (because the actual subject is in the heredoc body, not the -m argument string). Alternative: read the staged commit's actual message via git commit --dry-run after substitution. The first option is simpler.
# If COMMAND contains $(cat <<EOF ... EOF), the -m arg is dynamic.
# Skip subject validation for this shape; the operator can use git commit -F
# if they want subject validation on a multi-line message.
if echo "$COMMAND" | grep -qE 'git commit.*-m.*\$\(cat[[:space:]]*<<'; then
echo "INFO: heredoc-substitution detected in -m; skipping subject validation. Use git commit -F file for validation on multi-line messages." >&2
exit 0
fi
Hook 4+ (out of scope of v1) — validate-issue-structure.sh, block-private-refs-in-public-repos.sh, etc.
These hooks already work on the explicit --repo / --body-file flags. They don't have the $PWD problem. Verify in passing during this work.
Acceptance Criteria
Risks / Dependencies
- False negatives — the heredoc-substitution skip might let a legitimate format violation through if the heredoc body is malformed. Acceptable trade-off;
git commit -F users still get full validation, and the heredoc pattern is uncommon enough that the risk is bounded.
- Cross-platform — the regex extraction assumes GNU grep/sed conventions. Test on macOS BSD tools (the dev fleet) before merge.
- Backwards compatibility — current behavior (read local HEAD) is preserved as the fallback. Adopters who don't pass
--head see no change.
Refs
Given / When / Then
Given the parent session spawns parallel agents via
/fan-out(or via directAgenttool calls withisolation: "worktree")When any of those agents tries to
git push,gh pr create, orgit commit -m "$(cat <<'EOF'...)"from inside their worktreeThen the validation hooks
validate-branch-name.sh,validate-pr-create.sh, andvalidate-commit-format.shresolve git context against the harness's $PWD (typically a sibling worktree's directory in the parent session) — NOT the worktree where the agent's command actually ran. This causes the hooks to:git branch --show-currentExpected: hooks resolve git context against the actual command's repo, either via the
--head/--repoflag the operator passed, or via the directory the operatorcd'd into before running the command. Not from a $PWD that's frozen at the harness-spawn moment.Repro
This is reproduced every time parallel agents run from worktrees in this session. From three concurrent sub-agent reports:
Each agent independently hit the same bug and invented a different workaround — branch rename,
git -C, sentinel file. None of those should be necessary.The same shape is the
gh api .../pulls/<N>/mergebypass that closed via #47 — a tool surface the hooks didn't originally cover. This ticket extends the same principle to validation hooks: gate on the command's actual context, not the harness's $PWD.Why this matters
/fan-outis a sanctioned framework pattern — the parallel-work rule says we should offer it. But every fan-out today produces 3 simultaneous workarounds for the same hook misfire. The system fights itself: it sanctions fan-out via /fan-out yet blocks fan-out via $PWD-bound hooks.The cost compounds: each agent's workaround is invisible to Rex review (the parent only sees the PRs), so the worked-around behavior ships. Eventually an adopter reads the SKILL.md / hook source, reproduces the misfire, and learns the workaround — at which point the workaround becomes the de-facto pattern, and the rule's enforcement is hollow.
Proposed fix
Hook 1 —
validate-branch-name.shRead the branch from the actual
git pushcommand's source ref:Helper goes in
_lib-extract-push-ref.sh(sibling to_lib-extract-pr.sh).Hook 2 —
validate-pr-create.shRead the branch from
gh pr create's--headflag:Same fallback: if no
--headflag, use the local branch (today's behavior). When--headIS specified, the hook uses it.Hook 3 —
validate-commit-format.shThe
git commit -m "$(cat <<'EOF'...)"shell-substitution issue:The hook reads the
-margument from the command string and treats it as the literal commit subject. When the operator uses$(cat <<'EOF'...)substitution, the substitution hasn't expanded yet at hook-invocation time — the hook sees the literal string$(cat <<'EOF'...)which obviously doesn't match the conventional-commit regex.Fix: detect the heredoc-substitution pattern and skip validation of the subject (because the actual subject is in the heredoc body, not the
-margument string). Alternative: read the staged commit's actual message viagit commit --dry-runafter substitution. The first option is simpler.Hook 4+ (out of scope of v1) —
validate-issue-structure.sh,block-private-refs-in-public-repos.sh, etc.These hooks already work on the explicit
--repo/--body-fileflags. They don't have the $PWD problem. Verify in passing during this work.Acceptance Criteria
validate-branch-name.shreads the branch from the push command's source ref when present, falling back to local HEAD otherwise.validate-pr-create.shreads the branch from--headwhen present, falling back to local HEAD otherwise.validate-commit-format.shskips subject validation when$(cat <<'EOF'...)substitution is detected in the-margument; suggestsgit commit -F fileas the alternative path._lib-extract-push-ref.shfor parsing the push command's source ref.validate-branch-name.sh: push from a worktree where $PWD is a sibling worktree → reads correct branchvalidate-pr-create.sh:gh pr create --head feature/GH-N-description→ readsfeature/GH-N-description, not the local branchvalidate-commit-format.sh: heredoc-substitution pattern → skips with INFO message; non-substitution-m "subject"validates as today.claude/hooks/README.mdexplaining the cwd-vs-command-context distinction.Risks / Dependencies
git commit -Fusers still get full validation, and the heredoc pattern is uncommon enough that the risk is bounded.--headsee no change.Refs
gh api .../pulls/<N>/mergebypass closed in me2resh/apexyard#47 — tool-surface coverage gap..claude/rules/parallel-work.md) — we sanction fan-out yet ship hooks that fight it.