Skip to content

fix(hooks): anchor Claude project commands to project root - #2442

Open
danielmeppiel wants to merge 4 commits into
mainfrom
shepherd/claude-project-hook-cwd-independence
Open

fix(hooks): anchor Claude project commands to project root#2442
danielmeppiel wants to merge 4 commits into
mainfrom
shepherd/claude-project-hook-cwd-independence

Conversation

@danielmeppiel

@danielmeppiel danielmeppiel commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

fix(hooks): anchor Claude project commands to the project root

TL;DR

Project-scope Claude hook commands now use Claude's runtime project-root environment variable instead of paths that depend on the hook process working directory. PowerShell hooks use $env:CLAUDE_PROJECT_DIR; other commands use ${CLAUDE_PROJECT_DIR}. This closes #2408 while keeping .claude/settings.json portable across checkouts.

Note

Closes #2408. The lifecycle test installs a hook, starts it from outside the consumer project, and proves that the hook writes its consumer-project marker.

Problem (WHY)

Why these matter: the regression is proved by deterministic installation and hook execution, consistent with "Grounding outputs in deterministic tool execution transforms probabilistic generation into verifiable action."

Approach (WHAT)

# Fix
1 Resolve project-scope Claude script paths through CLAUDE_PROJECT_DIR, selecting PowerShell syntax from the command prefix.
2 Preserve existing absolute user-scope deployment and relative non-Claude project-target behavior.
3 Cover the external-working-directory lifecycle and guard the resolver's single authority.

Implementation (HOW)

  • src/apm_cli/integration/hook_integrator.py -- centralizes project-scoped command-path rendering and routes both plugin-root and relative-script rewrites through it; user-scope absolute deployment remains untouched.
  • tests/unit/integration/test_hook_integrator.py -- updates Claude project-path expectations and adds a PowerShell regression assertion.
  • tests/integration/test_hook_event_native_lifecycle.py -- installs a real Claude hook, runs its generated PowerShell command from an outside directory, and asserts the marker and portable settings content.
  • scripts/lint-architecture-boundaries.sh and tests/integration/test_architecture_authorities.py -- make HookIntegrator the only production owner of the Claude project-root path contract and prove the guard rejects a parallel owner.
  • docs/src/content/docs/integrations/ide-tool-integration.md and CHANGELOG.md -- document the runtime anchoring and record the user-visible fix.

Diagrams

Legend: the dashed node is the new portable resolution point; the installed setting remains checkout-independent while Claude resolves the script at execution time.

flowchart LR
    Install[apm install] --> Resolver[HookIntegrator]
    Resolver --> Path[Claude project command path]
    Path --> Settings[.claude settings.json]
    Settings --> Hook[Claude hook process]
    Hook --> Script[deployed hook script]
    classDef new stroke-dasharray: 5 5;
    class Path new;
Loading

Trade-offs

  • Runtime project-root anchoring over an absolute checkout path. Chose Claude's environment variable; rejected persisted absolute paths because project configuration must work after a clone moves.
  • Command-aware rendering over one shell-neutral string. Chose a PowerShell branch because $env: is required there; rejected POSIX syntax for all commands because it would not execute in PowerShell.
  • A single helper over duplicated rewrite branches. Chose one HookIntegrator authority, following "Favor small, chainable primitives over monolithic frameworks."

Benefits

  1. A generated PowerShell Claude hook exits 0 when launched outside the consumer project.
  2. The lifecycle proof verifies one marker file under the consumer project after execution.
  3. Generated project settings contain zero consumer absolute checkout paths.
  4. The architecture guard rejects a second production owner of CLAUDE_PROJECT_DIR path construction.

Validation

uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py tests/integration/test_hook_event_native_lifecycle.py::test_claude_project_hook_runs_from_external_cwd tests/integration/test_architecture_authorities.py::test_claude_project_hook_path_has_single_owner tests/integration/test_architecture_authorities.py::test_claude_project_hook_path_guard_rejects_parallel_owner:

170 passed in 148.34s (0:02:28)

uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/ && uv run --extra dev python -m pylint --disable=all --enable=R0801 --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/ && bash scripts/lint-auth-signals.sh && bash scripts/lint-architecture-boundaries.sh:

All checks passed!
1593 files already formatted
Your code has been rated at 10.00/10
[+] auth-signal lint clean
[+] architecture boundary lint clean

Scenario Evidence

# Scenario (user promise) Principle(s) Test(s) proving it Type
1 A developer installs a project Claude hook, Claude launches it from outside the project, and it runs against that project without an absolute checkout path. Portability by manifest, Multi-harness support, DevX (pragmatic as npm) tests/integration/test_hook_event_native_lifecycle.py::test_claude_project_hook_runs_from_external_cwd (regression-trap for #2408)
tests/unit/integration/test_hook_integrator.py::TestScriptPathRewriting::test_rewrite_claude_project_powershell_path_is_cwd_independent
integration, unit
2 A developer's Claude project hook configuration stays portable after the checkout location changes. Portability by manifest tests/unit/integration/test_hook_integrator.py::TestClaudeIntegration::test_project_scope_writes_portable_hook_paths unit

How to test

  • Create a Claude-targeted package with a PowerShell hook and install it in a consumer project; expect success.
  • Run the generated command from a directory outside the consumer with CLAUDE_PROJECT_DIR set; expect exit code 0 and the hook marker under the consumer project.
  • Inspect .claude/settings.json; expect $env:CLAUDE_PROJECT_DIR and no consumer absolute checkout path.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Copilot AI review requested due to automatic review settings August 3, 2026 12:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Ready to approve

The behavior change is well-scoped, covered by unit+integration regression tests, and the remaining review notes are minor documentation/consistency nits with straightforward fixes.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Anchors project-scope Claude hook command paths to Claude Code's runtime project root (CLAUDE_PROJECT_DIR) so hooks remain portable across checkouts while also being independent of the hook process working directory (fixing #2408).

Changes:

  • Centralize project-scope hook command-path rendering in HookIntegrator, emitting $env:CLAUDE_PROJECT_DIR/... for PowerShell and ${CLAUDE_PROJECT_DIR}/... otherwise.
  • Update/add unit + integration coverage for the external-CWD PowerShell lifecycle case, and add an architecture-boundary guard to keep this contract single-owned.
  • Document the behavior in the integrations docs and record the fix in the changelog.
File summaries
File Description
src/apm_cli/integration/hook_integrator.py Adds a helper to render project-scope script paths via CLAUDE_PROJECT_DIR for Claude while preserving existing deploy_root behavior.
tests/unit/integration/test_hook_integrator.py Updates expectations for portable Claude project commands; adds a PowerShell-specific regression assertion.
tests/integration/test_hook_event_native_lifecycle.py Adds an end-to-end lifecycle test that runs the generated PowerShell command from an external working directory.
tests/integration/test_architecture_authorities.py Adds authority tests ensuring the Claude project-root path logic remains single-owned and guarded.
scripts/lint-architecture-boundaries.sh Adds a boundary lint rule preventing parallel production owners of CLAUDE_PROJECT_DIR command-path construction.
docs/src/content/docs/integrations/ide-tool-integration.md Documents Claude project hook runtime anchoring behavior.
CHANGELOG.md Adds an Unreleased fixed entry for the Claude project hook path portability/CWD-independence fix.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines +202 to +204
root = Path(__file__).parents[2]
owner = (root / "src/apm_cli/integration/hook_integrator.py").read_text()
guard = (root / "scripts/lint-architecture-boundaries.sh").read_text()
Comment on lines +632 to +636
root_dir: Override root directory (e.g. ".copilot" for user scope)
deploy_root: Absolute root of the deployment directory. When provided,
rewritten script paths are resolved to absolute paths under this
root so the target (e.g. Claude Code) can execute them regardless
of the working directory. When *None*, rewritten paths stay
relative (backward-compatible behaviour).
root so the target can execute them regardless of the working
directory. When *None*, Claude uses its portable project-root
@danielmeppiel

Copy link
Copy Markdown
Collaborator Author

APM Review Panel: needs_rework

Anchor Claude hook project commands to the project root without regressing PowerShell handling or paths containing spaces.

cc @danielmeppiel -- a fresh advisory pass is ready for your review.

The panel converges that project-root anchoring is the right user promise: hooks should run reliably from outside the repository. It identified that the rewrite must preserve source shell intent for flat PowerShell handlers and quote POSIX project roots so ordinary consumer paths containing spaces remain executable. The local follow-up commit addresses these items and adds external-CWD lifecycle coverage for both shells; a subsequent panel pass should inspect the pushed revision.

Aligned with: Portable-by-manifest hook settings, multi-harness shell support, and pragmatic default execution.

Growth signal. Lead the release note with reliable hook execution outside the repository; the environment variable is an implementation detail.

Panel summary

Persona B R N Takeaway
Python Architect 1 0 0 Preserve PowerShell source-key semantics through the command-path owner.
CLI Logging Expert 0 0 0 User-facing path behavior is clear.
DevX UX Expert 1 0 0 Shell-specific command expansion must stay valid.
Supply Chain Security Expert 1 0 0 Quote POSIX project-root expansions.
OSS Growth Hacker 0 1 0 Emphasize reliable external-CWD execution.
Doc Writer 0 1 0 Scope documentation to APM-rewritten script paths.
Test Coverage Expert 0 1 0 Cover the POSIX execution branch end-to-end.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. **[Python Architect] (blocking-severity) Preserve source-key and effective-shell semantics through hook rewriting. -- Prevents POSIX project-root syntax in PowerShell commands.
  2. **[Supply Chain Security Expert] (blocking-severity) Shell-quote generated POSIX project-root paths without double quoting. -- Supports consumer project paths containing spaces.
  3. [Test Coverage Expert] Add external-CWD lifecycle coverage for POSIX and PowerShell hook rendering. -- Exercises shell-specific command paths.
  4. [Doc Writer] Limit documentation claims to APM-rewritten project script paths. -- Avoids promising every Claude hook is rewritten.
  5. [OSS Growth Hacker] Lead the changelog entry with reliable outside-repository execution. -- Communicates the user outcome.

Recommendation

Resolve the shell-preservation and POSIX-quoting correctness gaps, then demonstrate them with external-CWD lifecycle tests before treating the root-anchor behavior as dependable.


Full per-persona findings

Python Architect

  • [blocking] Preserve PowerShell source-key semantics when rendering Claude project paths at src/apm_cli/integration/hook_integrator.py:781
    Flat PowerShell handlers lost their shell context before Claude conversion.

CLI Logging Expert

No findings.

DevX UX Expert

  • [blocking] Explicit PowerShell handlers must use PowerShell environment syntax at src/apm_cli/integration/hook_integrator.py:605
    Commands not prefixed by pwsh or powershell still need the PowerShell path form.

Supply Chain Security Expert

  • [blocking] Quote generated POSIX project-root expansions at src/apm_cli/integration/hook_integrator.py:607
    Unquoted paths fail for consumer directories containing spaces.

OSS Growth Hacker

  • [recommended] Lead the changelog with the user-visible reliability outcome at CHANGELOG.md:12
    The environment variable is an implementation detail.

Auth Expert -- inactive

No authentication, credential, or remote authorization surface is affected.

Doc Writer

  • [recommended] Scope portability documentation to rewritten script paths at docs/src/content/docs/integrations/ide-tool-integration.md:70
    Other hook commands remain unchanged.

Test Coverage Expert

  • [recommended] Add POSIX external-CWD lifecycle coverage at tests/integration/test_hook_event_native_lifecycle.py
    The non-PowerShell rendering branch needs an execution regression trap.

Performance Expert -- inactive

No dependency, cache, materialization, or package-manager hot path is touched.

This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.

danielmeppiel and others added 3 commits August 3, 2026 20:43
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve PowerShell handler syntax and quote POSIX project-root paths so portable Claude hooks run from external directories, including consumer paths with spaces.\n\napm-spec-waiver: Hook command rendering is an implementation-specific portability fix, not a new OpenAPM contract.\n\nAddresses panel follow-ups for PR #2442.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fold panel follow-ups by rejecting shell-expansion filenames, proving PowerShell behavior from a spaced consumer path, and correcting the canonical hook guidance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel
danielmeppiel force-pushed the shepherd/claude-project-hook-cwd-independence branch from 0852c90 to 30de092 Compare August 3, 2026 19:20
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel

Copy link
Copy Markdown
Collaborator Author

APM Spec Guardian: fold_and_ship

Scope: editorial-patch; req-tg-010 citation plus conformance projections. Shocked-meter avg: 7.0/10.

Four specialist reviews found no new implementation-breaking concerns. The mechanical drift cluster was folded: the new requirement is indexed in Sections 8.7 and 11.3.2, Appendix C counts reconcile at 109 (104 MUST, 5 SHOULD), and CONFORMANCE artifacts were regenerated.

Convergence

Panel Recommendation Shocked New B New R New N
Swagger editor ship with followups 7/10 0 2 0
OCI editor ship with followups 7/10 0 1 1
Registry editor ship with followups 7/10 0 2 0
TAG architect ship with followups 7/10 0 2 1

Deferred follow-ups

  • Add a fixture-level oracle for req-tg-010.
  • Consider threat-model and unset-variable guidance in a patch release.
  • Reserve generalized target-variable registry design for v0.2.

Linter: all applicable mechanical checks PASS.

Panel notes

The portable anchor requirement now matches the implementation: Claude POSIX uses CLAUDE_PROJECT_DIR, PowerShell uses $env:CLAUDE_PROJECT_DIR, and paths containing dollar signs or backticks are rejected. No absolute checkout path is permitted.

This panel is advisory. Re-apply the spec-review label to run it again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Project-scope hook path rewriting produces cwd-dependent commands on Windows (Claude Code target)

2 participants