Hooks: add CLAUDE_AGENT_ROOT_DIR (worktree-aware root) — today absolute and relative hook paths are each broken in a different way
Summary
There is no way to register a PreToolUse hook that is both cwd-proof and worktree-correct. The two available spellings each fail, in opposite directions, and one of them fails silently.
Request: expose CLAUDE_AGENT_ROOT_DIR — the root of the tree the tool call actually belongs to (the git worktree root, or the primary clone when not in a worktree) — so hooks can be registered as "$CLAUDE_AGENT_ROOT_DIR/.claude/hooks/x.sh" and get both properties at once.
The two options today, and why neither works
Option 1 — $CLAUDE_PROJECT_DIR/.claude/hooks/x.sh (absolute).
Cwd-proof: the agent can cd anywhere and the hook still resolves. But CLAUDE_PROJECT_DIR is pinned to the primary clone and does not move for a worktree. So a worktree is policed by the primary's hook scripts — the primary's version, the primary's pinned dependency — never the ones its own branch/commit contains.
That is backwards for the case worktrees exist for. If I check out a branch in a worktree to test a change to a guard, the guard under test never runs; the primary's copy runs instead. The tree being measured and the code doing the measuring come from different commits.
Option 2 — .claude/hooks/x.sh (relative).
Correct version: per the hooks reference a relative command resolves against the cwd from the JSON input, so each tree runs its own hooks. But now any cd into a subdirectory that lacks .claude/ makes the path unresolvable — and this is the dangerous half:
an unresolvable command exits 127, and any non-2 non-zero exit is a non-blocking error
So the hook does not fail loudly. It silently allows the tool call. A safety hook that stops running looks identical to a safety hook that ran and approved.
What this forces downstream
The @webpieces framework hit exactly this and had to build a third hook purely to make option 2 survivable. From its own source (@webpieces/ai-hook-rules, src/bin/hook-registration.js), documenting measurements across four worktrees:
.claude/settings.jsonused to register two hooks, BOTH absolute via$CLAUDE_PROJECT_DIR. That variable NEVER moves — proven from four separate worktrees' own logs, every line readingroot=<worktree> projectDir=<primary>— so every tree was governed by the PRIMARY's shim, the PRIMARY's binary and the PRIMARY's pin, forever.
Its workaround is a three-hook arrangement:
H1 absolute sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh" matcher Bash
H2 relative sh ".claude/webpieces/ai-hook.sh" wp-ai-guards-hook matcher Write|Edit|MultiEdit|Bash|Read
H3 relative sh ".claude/webpieces/ai-hook.sh" wp-ai-rules-hook matcher Write|Edit|MultiEdit
H1 exists only to protect H2/H3: it stays absolute (so it always resolves) and its entire job is to refuse any cd that would park the shell where the relative hooks cannot launch. Their comment is explicit that this is compensating for the 127 behaviour:
H1 stays absolute because it is the one hook that must ALWAYS resolve: a relative hook that cannot resolve exits 127, and per the same reference any non-2 non-zero exit is a NON-BLOCKING error — i.e. a SILENT UNGUARDED ALLOW.
That is a whole extra hook, a cd-restriction on the agent, and a documented drift surface — all to work around a missing environment variable. And it only holds while every hook author remembers not to "tidy" H1 into a relative path, which would silently disable the entire set.
Requested behaviour
{
"matcher": "Bash",
"hooks": [{ "type": "command",
"command": "sh \"$CLAUDE_AGENT_ROOT_DIR/.claude/hooks/guard-deploy.sh\"" }]
}
CLAUDE_AGENT_ROOT_DIR= the root of the tree this tool call belongs to. In a git worktree, that worktree's root; otherwise the same asCLAUDE_PROJECT_DIR.- Cwd-proof like option 1 — an absolute path, immune to
cd. - Version-correct like option 2 — the worktree runs the hooks its own commit contains.
- Additive:
CLAUDE_PROJECT_DIRkeeps its current meaning, so nothing breaks.
Secondary ask, independent of the above
Reconsider making an unresolvable hook command (exit 127 / ENOENT) a blocking error rather than a non-blocking one, or at minimum surface it loudly. The current behaviour means a typo'd or missing hook path degrades to "no guard" with no signal. For hooks whose purpose is to block unsafe operations, failing open on a resolution error is the wrong default — and it is unobservable from inside the session.
Environment
Claude Code, macOS (darwin 25.3.0), git worktrees plus several sibling clones of the same repo. Encountered while migrating a monorepo's guard hooks; the sibling-clone case makes it concrete — monorepo7's tool calls were being judged by monorepo3's hook scripts.