${CLAUDE_PROJECT_DIR} resolves against a stale, drifted cwd in exec-form hooks after a mid-session cd
${CLAUDE_PROJECT_DIR} resolves against a stale, drifted cwd in exec-form hooks after a mid-session cd
Environment
- Claude Code: 2.1.220
- OS: macOS (Darwin), arm64
- Node: v24.17.0
- Hook form:
"command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/<name>.mjs"](exec form — no shell involved, confirmed via docs: hooks.md)
Summary
${CLAUDE_PROJECT_DIR} is documented to let hook scripts be referenced "regardless of the working directory when the hook runs" (hooks.md). In practice, in the main session, once the Bash tool's cwd drifts away from the project root via a cd (which persists across Bash calls by design — tools-reference.md), every hook fired by a subsequent tool call resolves ${CLAUDE_PROJECT_DIR} against that drifted directory instead of the actual project root, breaking module resolution for any hook registered in exec form (args array, no shell — so no ${VAR:-fallback} bash trick is possible inside the hook definition itself).
Repro
- In a repo with a
.claude/settings.jsonPreToolUse hook registered in exec form, e.g.:
``json``
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/my-hook.mjs"] }
]
}
- In the main session, run a Bash command that
cds into a subdirectory of the project and stays there, e.g.:
````
cd some/subdirectory && <any command>
- Trigger an Edit/Write (or whatever the hook's matcher covers) in the same session, without an intervening
cdback to the project root.
Expected: the hook resolves ${CLAUDE_PROJECT_DIR}/.claude/hooks/my-hook.mjs to the real project root, as documented.
Actual: the hook fails with Cannot find module '<project-root>/some/subdirectory/.claude/hooks/my-hook.mjs' — ${CLAUDE_PROJECT_DIR} resolved against the drifted cwd, producing a nonexistent nested path.
Confirmed via a real session transcript: a legitimate cd into a tooling subdirectory (to run that subdirectory's own test suite) was immediately followed by 11 hook failures on subsequent tool calls, all with the drifted subdirectory baked into the failing path. A later cd back to the project root resolved the issue for subsequent calls.
Why this is surprising given the docs
The docs frame ${CLAUDE_PROJECT_DIR} as stable/independent of runtime cwd, but the only related documented behavior (Bash cwd carry-over in the main session) is never connected to hook path resolution. Nothing in the hooks docs suggests ${CLAUDE_PROJECT_DIR} is sensitive to Bash-tool cwd drift.
Workaround found
Setting CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR=1 (documented in tools-reference.md) disables the cwd carry-over in the main session, which removes the trigger for this specific variant. This is a behavior trade-off (loses cd persistence across separate Bash calls), not a fix to ${CLAUDE_PROJECT_DIR}'s resolution itself, and doesn't address other possible triggers (e.g. worktree-flag-related, see below).
Possibly related (not confirmed identical — filing separately since none is an exact match)
- #50960 — cwd drift mid-session causing relative (no placeholder) hook commands to resolve wrong. Closed, not planned.
- #33815 —
$CLAUDE_PROJECT_DIRempty/unset in hooks and Bash. Closed, not planned. - #27343 —
$CLAUDE_PROJECT_DIRpoints at the main repo instead of the worktree underclaude -w. Closed, not planned. - #36360 — hook
MODULE_NOT_FOUNDin a worktree because a referenced helper doesn't exist there. Closed, duplicate. - #22343 —
ExitPlanModehook runs with cwd~instead of the project. Closed, duplicate. - #6023 —
$CLAUDE_PROJECT_DIR"not recognized" with hooks enabled. Closed, duplicate.
This report differs from all of the above in that (a) the hook definition already uses the documented ${CLAUDE_PROJECT_DIR} placeholder correctly (not a relative path), (b) the trigger is a plain in-session cd, not a worktree flag, and (c) the value isn't empty/unset — it resolves to a real (but wrong) directory. Given the number of adjacent-but-not-identical closed reports in this area, it seems like ${CLAUDE_PROJECT_DIR}'s resolution during a session may benefit from being anchored once at session start (or re-derived via the git toplevel) rather than tracking live session/tool cwd — happy to provide the full transcript privately if useful for reproduction.