fix(hooks): keep project-scope hook commands repo-relative (closes #1394)#1396
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Restricts the absolute-path rewriting of hook commands introduced in #1354 to user-scope deploys only, preserving repo-relative paths in project-scope hook configs that are typically checked into version control.
Changes:
- Gate
deploy_rootthreading in_integrate_merged_hookson aproject_root == Path.home()check to detect user-scope deploys. - Add regression tests for both project-scope (relative paths) and user-scope (absolute paths) behavior across Claude and Codex targets.
- Update an existing test that was documenting the regression to assert the corrected relative-path behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/apm_cli/integration/hook_integrator.py | Adds user-scope detection via home-directory equality check; only passes deploy_root when user-scope. |
| tests/unit/integration/test_hook_integrator.py | Adds regression tests for project-scope (relative) and user-scope (absolute) command rewriting; updates prior assertion. |
| CHANGELOG.md | Documents the fix under Unreleased / Fixed. |
| # Absolutize hook commands only for user-scope deploys. Claude | ||
| # Code (and the Codex/Cursor/Gemini equivalents) reads | ||
| # ``~/.claude/settings.json`` without a fixed cwd and does not | ||
| # expand ``${CLAUDE_PLUGIN_ROOT}`` in that file (see #1310 / #1354), | ||
| # so user-scope deploys must write absolute paths. Project-scope | ||
| # ``<repo>/.claude/settings.json`` is typically checked in and runs | ||
| # with cwd at the repo root, where repo-relative paths resolve | ||
| # correctly -- baking absolute machine paths into checked-in config | ||
| # breaks portability across clones, contributors, and CI (#1394). | ||
| _is_user_scope = project_root.resolve() == Path.home().resolve() |
There was a problem hiding this comment.
Addressed in b576258 -- replaced the project_root == Path.home() heuristic with an explicit user_scope: bool kwarg threaded from services.integrate_package_primitives (where the InstallScope is already in scope).
Concrete answers to the three concerns:
$HOME-as-project misclassification: gone. User-scope is now driven byscope is InstallScope.USER, not deploy-root shape.- Implicit coupling to
get_deploy_root: gone. If user-scope deploys later move to~/.config/..., the gate still holds because it doesn't inspectproject_rootat all. - Inference vs intent: now explicit at the call site. Sibling integrators (prompt/agent/command/instruction) keep their existing signatures -- the
user_scopekwarg is added only tointegrate_hooks_for_target, the three deprecated per-target wrappers, and_integrate_merged_hooks, and the dispatch inservices.pyincludes it only when_prim_name == "hooks".
The user-scope regression test (test_user_scope_still_writes_absolute_hook_paths) now sets user_scope=True directly instead of monkey-patching Path.home, so it exercises the same explicit signal production code uses.
Replace the ``project_root.resolve() == Path.home().resolve()`` heuristic in ``_integrate_merged_hooks`` with an explicit ``user_scope: bool`` kwarg threaded from the caller's ``InstallScope``. Surfaces three improvements raised in the Copilot review of microsoft#1396: * The gate no longer silently misclassifies the (admittedly rare) case where ``apm install`` runs with the repo at ``$HOME``. * ``_integrate_merged_hooks`` no longer couples to an implicit invariant of ``apm_cli.core.scope.get_deploy_root`` -- if user scope later moves to ``~/.config/...`` the gate keeps holding. * Intent is now explicit at the call site rather than inferred from deploy-root shape. Plumbing: * ``_integrate_merged_hooks``, ``integrate_hooks_for_target``, and the three deprecated per-target wrappers (``integrate_package_hooks_claude/cursor/codex``) accept ``user_scope: bool = False``. * ``services.integrate_package_primitives`` computes ``user_scope = scope is InstallScope.USER`` and passes it only on the hooks dispatch so sibling integrators (prompt/agent/command/ instruction) keep their existing signatures. ``InstallScope`` is imported at runtime inside the function, matching the local-import pattern already used in ``integrate_local_bundle``. The user-scope regression test now sets ``user_scope=True`` directly instead of monkey-patching ``Path.home``, so it asserts on the same explicit signal production code uses. ``unittest.mock.patch`` import removed (no longer needed).
The runner now ships an MCP server via process-compose's built-in JSON-RPC
surface ('ci run --mcp', juspay/ci commit 87f48d9f); the same PR also
publishes ci as an APM package whose 'apm.yml' exposes the MCP entry for
downstream consumers (6d42a62c). Consume it:
- 'juspay/ci#feat-platform-fanout-and-ssh' added under 'dependencies.apm'
in 'apm.yml'. Pinned to the PR branch because the MCP surface is not
on main yet; drop the '#…' suffix once the PR lands.
- '.apm/skills/ci/bin/serve' is the launcher (a four-line bash 'nix
run' wrapper) committed locally because our existing '.apm/skills/ci/
SKILL.md' (the /ci slash command) collides with upstream's package name,
and APM's directory-merge story drops the upstream package's bin/ subtree
on collision. Sourced verbatim from upstream with a header comment
explaining the divergence.
- 'agents/ai.just' switches 'apm_cmd' to srid/apm#fix/1394-project-scope-hook-paths
until microsoft/apm#1396 lands. 0.14.0 absolutized project-scope hook
paths to /home/<user>/<worktree>/… that only resolve on the author's
machine; the PR keeps them repo-relative.
- 'just ai::apm' regenerated the runtime tree against the patched apm-cli;
'.mcp.json' / 'opencode.json' / '.codex/config.toml' all carry the new
'ci' server entry.
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 2 | Solid scope-gating fix: explicit bool kwarg beats implicit home-equality check, but dispatch special-casing and PR body drift need attention before this pattern compounds. |
| CLI Logging Expert | 0 | 2 | 1 | Scope gate is correct but introduces a silent-failure path for project-scope missing scripts; add a warning and a verbose scope-path diagnostic. |
| DevX UX Expert | 0 | 2 | 1 | Fix restores the portability contract cleanly; two gaps need closing: doc prose still says all Claude installs absolutize paths (now false for project-scope), and the critical services.py scope-dispatch line has no regression trap at the services tier. |
| Supply Chain Security Expert | 0 | 1 | 1 | Info-disclosure fix is complete and correctly gated; Copilot user-scope silently drops user_scope (pre-existing, architectural); no tokens touched; traversal guards remain active in project-scope mode. |
| OSS Growth Hacker | 0 | 2 | 1 | High-value community regression fix against the core 'Portable by manifest' promise; CHANGELOG is user-facing and correct but lacks a migration call-to-action for existing repos with polluted committed configs; contributor story warrants amplification in release notes. |
| Doc Writer | 0 | 1 | 1 | One stale pitfall bullet in hooks-and-commands.md now misdescribes scope-conditional path behavior; CHANGELOG entry is accurate but violates the one-line convention. |
| Test Coverage Expert | 0 | 2 | 1 | Regression traps for #1394 pass at unit tier; the services.py scope-dispatch seam (new line 258) is uncovered at integration-with-fixtures floor; Cursor/Gemini/Windsurf have no explicit relative-path assertions. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Doc Writer] Patch
hooks-and-commands.mdL154-160 to replace the stale 'all installs absolutize paths' pitfall bullet with a scope-conditional description: user-scope (-g) rewrites to absolute; project-scope leaves paths repo-relative. -- Two panelists (doc-writer and devx-ux) independently flagged the same line. The doc currently makes a false claim about post-PR behavior for every new producer reading it; highest-priority documentation drift on this PR. - [Test Coverage Expert] Add a services-tier test in
tests/unit/install/test_services_hook_scope.pythat callsservices.integrate_package_primitiveswithInstallScope.PROJECTandInstallScope.USERthrough the production boundary, asserting the user_scope dispatch at L258 routes correctly for both scopes. -- The fix's single critical wiring line (services.py L258) has no test at the services boundary. Two panelists (test-coverage and devx-ux) converge on this gap. A future constant-swap refactor would silently restore the regression without a failing test; this is a regression-trap gap on a portability-by-manifest surface. - [CLI Logging Expert] Restore the missing-script warning for project-scope installs: post-PR, a project-scope hook install with a missing script reaches neither warning branch at
hook_integrator.pyL444 (deploy_root is now None), leaving${CLAUDE_PLUGIN_ROOT}/...unexpanded silently. -- This is a new UX regression introduced by the fix itself, not a pre-existing condition. A project-scope user with a misconfigured hook gets zero diagnostic output; the deployed config will contain an unexpanded variable with no hint of what went wrong. - [OSS Growth Hacker] Tighten the CHANGELOG entry to one sentence and append a migration call-to-action: 're-run
apm installon existing repos to rewrite committed absolutized configs back to repo-relative paths'. -- Three panelists (growth, doc-writer, devx-ux) flagged the CHANGELOG as too dense and missing a migration signal. Without the CTA, existing users with polluted committed configs have no prompt to act; the fix is invisible to them until they hit the next portability failure. - [Python Architect] Update the PR body to match the implemented design: replace the home-equality-gate description with the actual user_scope kwarg threading through five signatures. Optionally, document the Copilot branch's intentional user_scope omission with an inline comment. -- The PR body currently misdescribes the implementation. Future reviewers reading the body to understand a regression will get a false model. Low-cost fix (prose only) that prevents compounding confusion if this pattern is extended.
Architecture
classDiagram
direction LR
class HookIntegrator {
<<Integrator>>
+integrate_hooks_for_target(target, pkg, root, user_scope) HookIntegrationResult
+integrate_package_hooks_claude(pkg, root, user_scope) HookIntegrationResult
+integrate_package_hooks_cursor(pkg, root, user_scope) HookIntegrationResult
+integrate_package_hooks_codex(pkg, root, user_scope) HookIntegrationResult
+integrate_package_hooks(pkg, root) HookIntegrationResult
-_integrate_merged_hooks(config, pkg, root, user_scope) HookIntegrationResult
-_rewrite_command_for_target(cmd, pkg, name, target, deploy_root) tuple
}
class InstallScope {
<<Enum>>
PROJECT
USER
}
class integrate_package_primitives {
<<IOBoundary>>
scope InstallScope
_call_kwargs dict
}
class _MergeHookConfig {
<<ValueObject>>
target_key str
config_filename str
require_dir bool
}
class HookIntegrationResult {
<<ValueObject>>
files_integrated int
files_updated int
target_paths list
}
integrate_package_primitives ..> InstallScope : reads scope
integrate_package_primitives ..> HookIntegrator : dispatches with user_scope (hooks only)
HookIntegrator ..> _MergeHookConfig : reads per-target config
HookIntegrator ..> HookIntegrationResult : returns
class HookIntegrator:::touched
class integrate_package_primitives:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["[CLI] apm install or apm install -g"] --> B["services.integrate_package_primitives\nsrc/apm_cli/install/services.py:256"]
B --> C{_prim_name == hooks?}
C -- No --> D["_call_kwargs = force + managed_files + diagnostics\nno scope signal passed"]
C -- Yes --> E["[FS] _call_kwargs[user_scope] = scope is InstallScope.USER\nservices.py:252-257"]
D --> F2["getattr(integrator, integrate_method)(**_call_kwargs)\nnon-hooks dispatch unchanged"]
E --> F["integrate_hooks_for_target(target, pkg, root, user_scope)\nhook_integrator.py:1033"]
F --> G{target.name == copilot?}
G -- Yes --> H["integrate_package_hooks()\nhook_integrator.py:597\ncopies hook JSON verbatim\nno _rewrite_command_for_target call\nuser_scope silently dropped (correct)"]
G -- No --> I["_integrate_merged_hooks(config, pkg, root, user_scope)\nhook_integrator.py:706"]
I --> J["_deploy_root_for_rewrite = project_root if user_scope else None\nhook_integrator.py:735"]
J --> K["_rewrite_command_for_target(..., deploy_root=_deploy_root_for_rewrite)\nhook_integrator.py:795-813"]
K --> L{deploy_root is not None?}
L -- Yes user-scope --> M["[FS] str((deploy_root / target_rel).resolve())\nabsolute path written to ~/.claude/settings.json\npreserves #1310 / #1354 fix"]
L -- No project-scope --> N["[FS] target_rel only (repo-relative path)\n.claude/settings.json stays portable across clones\ncloses #1394"]
sequenceDiagram
participant CLI as apm CLI
participant SVC as services.py
participant HI as HookIntegrator
participant FS as Filesystem
note over CLI,FS: Project-scope: apm install (no -g)
CLI->>SVC: integrate_package_primitives(scope=PROJECT)
SVC->>SVC: user_scope = False (scope is not USER)
SVC->>HI: integrate_hooks_for_target(user_scope=False)
HI->>HI: _deploy_root_for_rewrite = None
HI->>HI: _rewrite_command_for_target(deploy_root=None)
HI->>FS: [FS] copy script to .claude/hooks/pkg/stop.sh
HI->>FS: [FS] write .claude/settings.json: command .claude/hooks/pkg/stop.sh (relative, portable)
note over CLI,FS: User-scope: apm install -g
CLI->>SVC: integrate_package_primitives(scope=USER)
SVC->>SVC: user_scope = True (scope is USER)
SVC->>HI: integrate_hooks_for_target(user_scope=True)
HI->>HI: _deploy_root_for_rewrite = project_root (equals ~)
HI->>HI: _rewrite_command_for_target(deploy_root=project_root)
HI->>FS: [FS] copy script to ~/.claude/hooks/pkg/stop.sh
HI->>FS: [FS] write ~/.claude/settings.json: command /home/user/.claude/hooks/pkg/stop.sh (absolute)
Recommendation
Merge as-is. The regression is correctly fixed, the unit-tier traps pass, and no panelist raised a blocking finding. Before the next minor release cut, address the docs drift in hooks-and-commands.md L154 (highest signal, two-panelist convergence) and add the services-tier regression trap at tests/unit/install/test_services_hook_scope.py (covers the one unwired production seam). The CHANGELOG migration CTA and the new silent-missing-script warning path are good candidates for the same follow-up PR.
Full per-persona findings
Python Architect
- [recommended] PR body promises home-equality gate and no kwarg threading; impl threads user_scope through 5 signatures -- body is stale and will mislead future reviewers at
src/apm_cli/integration/hook_integrator.py
The triage context flags this explicitly. PR body states the gate isproject_root.resolve() == Path.home().resolve()and explicitly says 'without threading a scope kwarg through the integrator stack'. The actual diff does the opposite:user_scope: bool = Falseis added to_integrate_merged_hooks, all three public wrappers,integrate_hooks_for_target, and the dispatch inservices.py-- five signatures total. The kwarg approach is architecturally better than the home-equality check (explicit, testable, not fragile to symlinked home dirs), so the body should be updated to match the real design rationale.
Suggested: Update PR body: replace the home-equality gate description with the actual user_scope kwarg approach. Document that user_scope is threaded from InstallScope at the services.py dispatch boundary, not inferred from deploy-root shape. - [recommended]
_prim_name == 'hooks'special-case in services.py dispatch breaks the uniform dispatch-table interface and will compound if a second integrator ever needs scope atsrc/apm_cli/install/services.py
At one exception the inline check is tolerable, but the risk is compounding: if commands or instructions ever need scope-dependent kwargs, engineers will add moreif _prim_name == Xbranches rather than refactoring. The Protocol approach pays off at the second integrator that needs scope, with zero cost at the current size since the base implementation is a one-linerreturn {}.
Suggested: Addbuild_dispatch_kwargs(self, scope: InstallScope) -> dictto the integrator base/protocol, defaultreturn {}. HookIntegrator overrides:return {'user_scope': scope is InstallScope.USER}. Dispatch loop:_call_kwargs = {..., **_integrator.build_dispatch_kwargs(scope)}. Remove theif _prim_name == 'hooks':block. - [nit] user_scope: bool is future-hostile if workspace or org scope is added; passing InstallScope enum directly would be more extensible at
src/apm_cli/integration/hook_integrator.py
The bool encodes exactly two states. A workspace scope would require refactoring all five signatures again. For two states the bool is adequate; flag only if workspace scope is on the roadmap. - [nit] integrate_hooks_for_target silently drops user_scope for the copilot branch without a comment explaining why the omission is safe at
src/apm_cli/integration/hook_integrator.py:1043
The copilot branch callsintegrate_package_hooks()which copies JSON files verbatim and never calls_rewrite_command_for_target, so dropping user_scope is correct. But without a comment the next reader must trace integrate_package_hooks to confirm.
Suggested:# user_scope intentionally not forwarded: integrate_package_hooks copies hook JSON verbatim and never calls _rewrite_command_for_target.
CLI Logging Expert
- [recommended] Missing-script fallback silently skips warning for project-scope installs, leaving ${CLAUDE_PLUGIN_ROOT}/... unexpanded in the deployed config with no diagnostic at
src/apm_cli/integration/hook_integrator.py:444
_rewrite_command_for_targethas two fallback branches (lines 444-448 and 476-480) gated onelif deploy_root is not None. Before this PR, deploy_root was always project_root, so a missing script always triggered_rich_warning('Hook script not found: ...'). After this PR, project-scope passesdeploy_root=None, so both branches are skipped entirely: no warning is emitted, the variable reference is left unexpanded, and the user sees nothing. This violates the 'name the thing + include the fix' rule.
Suggested: Add an else branch (no deploy_root guard) that fires_rich_warningfor project-scope missing scripts too. Mirror for the./pathbranch at line 476. - [recommended] No verbose/--verbose diagnostic distinguishes scope-aware hook path mode; silent divergence between project and user installs is undiscoverable at
src/apm_cli/integration/hook_integrator.py:749
A user comparing.claude/settings.jsonacross a fresh clone vs a-ginstall will see different path forms with no explanation in the CLI output. Without this, debugging scope-related hook failures requires reading source code.
Suggested: After_deploy_root_for_rewrite = project_root if user_scope else None, add_log.debug('hooks: %s scope -- command paths will be %s', 'user' if user_scope else 'project', 'absolutized' if user_scope else 'kept repo-relative'). - [nit] Comment at line 444-445 is now implicitly user-scope-only; add a parenthetical for navigability at
src/apm_cli/integration/hook_integrator.py:444
Adding '(user-scope only; project-scope leaves command as-is)' prevents confusion when someone traces the project-scope silent path.
DevX UX Expert
- [recommended] hooks-and-commands.md still claims all Claude installs rewrite paths to absolute -- now only true for --global (user-scope) at
docs/src/content/docs/producer/author-primitives/hooks-and-commands.md:154
The 'Claude target path resolution' bullet says: 'apm install rewrites ${PLUGIN_ROOT} and relative ./ references to absolute paths so Claude Code can execute scripts regardless of the working directory.' This is now only true forapm install -g. A hook author reading this doc expects their committed.claude/settings.jsonto carry absolute paths, sees repo-relative ones after the fix, and files a bug against the correct behavior.
Suggested: Change to: 'When installing at user scope (apm install -g) for the Claude target, apm install rewrites... Project-scope installs keep paths repo-relative so committed configs stay portable across clones, contributors, and CI runners.'
Proof (missing at):docs/src/content/docs/producer/author-primitives/hooks-and-commands.md-- proves: The doc accurately describes whether project-scope Claude installs write relative or absolute hook command paths. [portability-by-manifest,devx] - [recommended] No services-tier regression trap for the scope is InstallScope.USER dispatch -- the fix's critical wiring is untested at that layer at
tests/unit/install/test_services_rendering.py
All three new tests callHookIntegrator.integrate_package_hooks_claude()andintegrate_package_hooks_codex()directly withuser_scope=True/False, bypassingservices.integrate_package_primitivesentirely. The actual load-bearing line is_call_kwargs["user_scope"] = scope is InstallScope.USER. If deleted, inverted, or the guard condition widened, every new unit test would still pass and the portability regression would silently return.
Suggested: Add a test that callsintegrate_package_primitives()withscope=InstallScope.PROJECTandscope=InstallScope.USERfor a package that ships hooks, then asserts the written.claude/settings.jsoncommand is repo-relative for PROJECT and absolute for USER.
Proof (missing at):tests/unit/install/test_services_rendering.py-- proves: apm install (project-scope) keeps hook command paths repo-relative; apm install -g (user-scope) absolutizes them -- verified through the actual services dispatch boundary. [portability-by-manifest,devx] - [nit] CHANGELOG bullet is a single dense sentence (~500 chars); split at the period after 'CI runners' for scannability at
CHANGELOG.md:12
Supply Chain Security Expert
- [recommended] Copilot branch in integrate_hooks_for_target silently drops user_scope, leaving Copilot user-scope deploys without command absolutization at
src/apm_cli/integration/hook_integrator.py:1044
Inintegrate_hooks_for_target, whentarget.name == 'copilot'the method callsself.integrate_package_hooks(...)without forwarding user_scope. This was true before the PR (Copilot never absolutized), but the PR introduces a named user_scope contract that is now silently broken at the Copilot branch. A developer adding Copilot user-scope support later could miss this gap.
Suggested: Either (a) add a comment making the omission intentional and visible, or (b) thread user_scope intointegrate_package_hooksand pass it to_rewrite_hooks_dataasdeploy_root=project_rootwhenuser_scope=True. - [nit] New scope regression tests use deprecated per-target wrappers, not the production integrate_hooks_for_target dispatch path at
tests/unit/integration/test_hook_integrator.py
The production call site isintegrate_hooks_for_target. The user_scope wire inintegrate_hooks_for_target -> _integrate_merged_hooksis not covered by any new test at the target-dispatch level.
Suggested: Add one test:integrator.integrate_hooks_for_target(KNOWN_TARGETS['claude'], pkg_info, project_root, user_scope=True)and assert absolute command; pair withuser_scope=Falseasserting relative.
OSS Growth Hacker
- [recommended] CHANGELOG entry lacks a migration call-to-action for existing repos whose committed configs already contain absolute paths at
CHANGELOG.md
A user who ranapm installbetween fix: resolve hook paths to absolute in settings.json for --target claude #1354 and this fix has/Users/<them>/...baked into their checked-in.claude/settings.json. The fix is to re-runapm install, which rewrites those files -- but without a hint in the CHANGELOG, existing users have no signal to do that. This is an adoption-retention gap: the bug leaves a persistent artifact in git history that undermines the 'Portable by manifest' promise.
Suggested: Append: 'Existing repos with absolute paths already committed can restore portability by re-runningapm install; the integrator will rewrite the affected configs in place.' - [recommended] External contributor-found-and-fixed regression on a real consumer repo is an under-amplified contributor funnel signal worth naming in release notes
@srid is not a drive-by reporter -- he is the author ofsrid/koluandsrid/agency, two real APM consumer repos that depend on each other through the package manager. The PR body even contains the exact before/after diff on a live consumer repo, which is release-note-ready evidence.
Suggested: In the release notes for this release cycle, include: '@srid (author ofsrid/kolu) caught a portability regression in the wild and submitted the fix with regression tests.' - [nit] The PR body kolu before/after diff is social-copy-ready and should be mined before release notes are written
Suggested: Capture the kolu before/after diff in the release notes under a 'Portability restored' heading.
Auth Expert -- inactive
PR #1396 touches only CHANGELOG.md, hook_integrator.py, services.py, and test_hook_integrator.py; no auth files (auth.py, token_manager.py, azure_cli.py, github_downloader.py) are modified; change is scope-aware path rewriting, not token/credential/auth resolution.
Doc Writer
- [recommended] hooks-and-commands.md pitfall bullet describes pre-fix (always-absolute) path behavior, now stale after fix(hooks): keep project-scope hook commands repo-relative (closes #1394) #1396 at
docs/src/content/docs/producer/author-primitives/hooks-and-commands.md:154
The 'Claude target path resolution' pitfall omits the scope distinction introduced by this PR. After fix(hooks): keep project-scope hook commands repo-relative (closes #1394) #1396, project-scope installs keep repo-relative paths; only user-scope installs write absolute paths. A package author who ships hooks and reads this guide will incorrectly expect their committed settings.json to contain absolute machine paths. The invariant 'project-scope = repo-relative, user-scope = absolute' is now a documented user-observable contract and belongs in the pitfall.
Suggested: Replace the 'Claude target path resolution' pitfall bullet with a scope-conditional description namingapm install -gfor the absolute-path case and listing the merged-target families that stay repo-relative. - [nit] CHANGELOG entry is accurate but exceeds the one-line-per-PR convention (~95 words, 3 sentences) at
CHANGELOG.md
changelog.instructions.mdstates: 'One line per PR'. The new Fixed entry runs three full sentences. Neighboring entries are 1-2 sentences. The 'why' belongs in the PR body; the changelog entry should state the observable change.
Suggested: Trim to a single sentence ending with(#1394).
Test Coverage Expert
- [recommended] services.integrate_package_primitives scope-dispatch seam is untested: the 1-line fix
_call_kwargs[user_scope] = scope is InstallScope.USERhas no test through the seam atsrc/apm_cli/install/services.py:258
The new code in services.py is the cross-module boundary that translates InstallScope into user_scope for the hook integrator. The three new unit tests bypass this seam by callingintegrate_package_hooks_claude(..., user_scope=True/False)directly. A future refactor that accidentally swapsscope is InstallScope.USERforscope is InstallScope.PROJECT, or that changes InstallScope, would silently restore the [BUG] 0.14.0: project-scope hook paths rewritten to absolute, breaking portability of checked-in .claude/settings.json and .codex/hooks.json #1394 regression without any unit test failing. The hook surface floor is integration-with-fixtures per the tier-floor matrix.
Suggested: Addtest_project_scope_hooks_via_services_are_relativeintests/unit/install/test_services_hook_scope.py: callintegrate_package_primitives(..., scope=InstallScope.PROJECT, ...)with a fixture package containing hooks.json; assertcmd == .claude/hooks/scope-pkg/hooks/stop.sh. Mirror withscope=InstallScope.USERassertingPath(cmd).is_absolute().
Proof (missing at):tests/unit/install/test_services_hook_scope.py::test_project_scope_hooks_via_services_are_relative-- proves: When a user runsapm install(project scope), the hook command written to.claude/settings.jsonis repo-relative, not baked with the installer's machine-local absolute path. [portability-by-manifest,multi-harness-support] - [nit] Cursor (and Gemini/Windsurf) existing tests use 'in cmd' not '== relative_path': a reintroduction of absolute paths for those targets would not be caught at
tests/unit/integration/test_hook_integrator.py:1278
test_integrate_hookify_cursorasserts'.cursor/hooks/hookify/hooks/pretooluse.py' in cmd.replace('\\\\', '/')-- this passes whethercmdis the relative string alone or prefixed with an absolute machine path.
Suggested: Change Cursor assertion fromin cmdtoassert cmd == '.cursor/hooks/hookify/hooks/pretooluse.py'; assert not Path(cmd).is_absolute(). Add similar==assertions for Gemini and Windsurf.
Proof (missing at):tests/unit/integration/test_hook_integrator.py::TestCursorIntegration::test_cursor_project_scope_keeps_relative_hook_paths-- proves: Project-scope Cursor hook commands stay repo-relative. [portability-by-manifest,multi-harness-support] - [recommended] New regression tests pass at unit tier (4 passed in 0.83s); hooks surface floor is integration-with-fixtures; unit tests certify the integrator function but not the full install path at
tests/unit/integration/test_hook_integrator.py
The three new unit tests plus the updatedtest_reinstall_preserves_multiple_hook_files_same_eventexercise the hook integrator in isolation with real filesystem fixtures (no mocks). They pass at unit tier. The services.py scope-dispatch seam is the missing integration-with-fixtures gap.
Proof (passed):tests/unit/integration/test_hook_integrator.py::TestClaudeIntegration::test_project_scope_writes_relative_hook_paths-- proves: Project-scope hook configs keep repo-relative command paths so checked-in.claude/settings.jsonstays portable across clones, contributors, and CI runners. [portability-by-manifest,multi-harness-support,devx]
assert cmd == '.claude/hooks/scope-pkg/hooks/stop.sh'; assert not Path(cmd).is_absolute(); assert str(temp_project) not in cmd
Performance Expert -- inactive
PR changes CHANGELOG.md, services.py dispatch check, hook_integrator.py path-rewrite kwarg gate, and test_hook_integrator.py tests; none touch cache/, deps/, install/phases/, pipeline.py, resolve.py, tiered_ref_resolver.py, or perf instrumentation. Fallback self-check: no impact on install wall-time, download bytes, network RTTs, or parallelism.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
…sing-script warning, CHANGELOG) Folds in the five RECOMMENDED panel follow-ups from microsoft#1396 review so they land with the original fix rather than carrying over as separate issues. * Docs: replace the stale 'all Claude installs absolutize paths' pitfall in producer/author-primitives/hooks-and-commands.md with a scope-conditional description (user-scope rewrites to absolute; project-scope keeps repo-relative). Two panelists (doc-writer, devx-ux) converged on this line. * CLI logging: project-scope installs with a missing hook script reached neither warning branch in _rewrite_command_for_target, so a misconfigured hook deployed with an unexpanded ${CLAUDE_PLUGIN_ROOT}/... silently. Always emit the warning now; only rewrite to the absolute source path in user-scope so project-scope never bakes a machine-local prefix into committed configs (preserves the microsoft#1394 contract). * Services-tier regression trap: new tests/unit/install/test_services_hook_scope.py exercises integrate_package_primitives through the production boundary and asserts the L258 user_scope dispatch routes InstallScope.USER -> user_scope=True, InstallScope.PROJECT / None -> user_scope=False, and never threads the kwarg into sibling integrators. A constant swap that re-introduced microsoft#1394 now fails first at the services tier, not just at the integrator unit. * Unit-tier regression trap: missing-script warning fires in BOTH scopes via monkeypatched _rich_warning capture, covering both the ${PLUGIN_ROOT} and ./path code paths. * CHANGELOG: collapsed to one sentence with a migration call-to-action ('Re-run apm install on existing repos to rewrite committed absolutized configs back to repo-relative paths'), addressing the growth-hacker / doc-writer / devx-ux convergence. Mutation-break gate verified: reverting the silent-failure fix or swapping InstallScope.USER/PROJECT in services.py causes the new tests to fail. Lint contract verified silent: uv run --extra dev ruff check src/ tests/ uv run --extra dev ruff format --check src/ tests/ Co-authored-by: srid <srid@srid.ca> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Follow-ups from the APM Review Panel pass have landed. Summary:
Regression-trap evidence (mutation-break gate):
Lint contract: Local validation: CI: Workflow runs for b71850f have not been created automatically as of this comment (only Ready for maintainer review. |
…crosoft#1394) ``_integrate_merged_hooks`` to absolutize hook ``command`` paths. That fix was correct for user-scope (``~/.claude/settings.json`` is read without a fixed cwd, and ``${CLAUDE_PLUGIN_ROOT}`` is not expanded inside ``settings.json`` -- the bug microsoft#1310 was about), but it also rewrote project-scope ``<repo>/.claude/settings.json`` / ``<repo>/.codex/hooks.json`` / sidecar ``apm-hooks.json`` -- files that are typically checked into the repo. Every clone / contributor / CI runner then saw the installer's machine-local absolute prefix baked into committed config. Restrict the absolute-path rewrite to user-scope deploys by gating on ``project_root.resolve() == Path.home().resolve()``. The ``InstallScope.USER`` branch lands ``project_root`` at ``Path.home()`` (see ``apm_cli.core.scope.get_deploy_root``), so the home-equality check is a faithful, side-effect-free proxy without threading a scope kwarg through the integrator stack. ``CLAUDE_CONFIG_DIR`` remains honored upstream of this check -- it redirects the deploy *directory* (``target.root_dir``), not ``project_root`` itself. The behaviour applies to all targets that merge into a single JSON config (Claude / Codex / Cursor / Gemini / Windsurf) since they all flow through ``_integrate_merged_hooks``. Tests: - ``test_project_scope_writes_relative_hook_paths`` (regression for microsoft#1394): asserts the rewritten command is repo-relative and contains no absolute prefix. - ``test_user_scope_still_writes_absolute_hook_paths`` (preserves microsoft#1310 / microsoft#1354): patches ``Path.home`` to the deploy root and asserts the rewritten command is absolute, matching the original ``deploy_root`` resolution. - ``test_codex_project_scope_keeps_relative_hook_paths`` mirrors the Claude regression test through the Codex public entry point so the shared scope check is asserted from a second target. - ``test_reinstall_preserves_multiple_hook_files_same_event`` updated to assert relative commands (its prior absolute-form assertion was documenting the regression).
Replace the ``project_root.resolve() == Path.home().resolve()`` heuristic in ``_integrate_merged_hooks`` with an explicit ``user_scope: bool`` kwarg threaded from the caller's ``InstallScope``. Surfaces three improvements raised in the Copilot review of microsoft#1396: * The gate no longer silently misclassifies the (admittedly rare) case where ``apm install`` runs with the repo at ``$HOME``. * ``_integrate_merged_hooks`` no longer couples to an implicit invariant of ``apm_cli.core.scope.get_deploy_root`` -- if user scope later moves to ``~/.config/...`` the gate keeps holding. * Intent is now explicit at the call site rather than inferred from deploy-root shape. Plumbing: * ``_integrate_merged_hooks``, ``integrate_hooks_for_target``, and the three deprecated per-target wrappers (``integrate_package_hooks_claude/cursor/codex``) accept ``user_scope: bool = False``. * ``services.integrate_package_primitives`` computes ``user_scope = scope is InstallScope.USER`` and passes it only on the hooks dispatch so sibling integrators (prompt/agent/command/ instruction) keep their existing signatures. ``InstallScope`` is imported at runtime inside the function, matching the local-import pattern already used in ``integrate_local_bundle``. The user-scope regression test now sets ``user_scope=True`` directly instead of monkey-patching ``Path.home``, so it asserts on the same explicit signal production code uses. ``unittest.mock.patch`` import removed (no longer needed).
…sing-script warning, CHANGELOG) Folds in the five RECOMMENDED panel follow-ups from microsoft#1396 review so they land with the original fix rather than carrying over as separate issues. * Docs: replace the stale 'all Claude installs absolutize paths' pitfall in producer/author-primitives/hooks-and-commands.md with a scope-conditional description (user-scope rewrites to absolute; project-scope keeps repo-relative). Two panelists (doc-writer, devx-ux) converged on this line. * CLI logging: project-scope installs with a missing hook script reached neither warning branch in _rewrite_command_for_target, so a misconfigured hook deployed with an unexpanded ${CLAUDE_PLUGIN_ROOT}/... silently. Always emit the warning now; only rewrite to the absolute source path in user-scope so project-scope never bakes a machine-local prefix into committed configs (preserves the microsoft#1394 contract). * Services-tier regression trap: new tests/unit/install/test_services_hook_scope.py exercises integrate_package_primitives through the production boundary and asserts the L258 user_scope dispatch routes InstallScope.USER -> user_scope=True, InstallScope.PROJECT / None -> user_scope=False, and never threads the kwarg into sibling integrators. A constant swap that re-introduced microsoft#1394 now fails first at the services tier, not just at the integrator unit. * Unit-tier regression trap: missing-script warning fires in BOTH scopes via monkeypatched _rich_warning capture, covering both the ${PLUGIN_ROOT} and ./path code paths. * CHANGELOG: collapsed to one sentence with a migration call-to-action ('Re-run apm install on existing repos to rewrite committed absolutized configs back to repo-relative paths'), addressing the growth-hacker / doc-writer / devx-ux convergence. Mutation-break gate verified: reverting the silent-failure fix or swapping InstallScope.USER/PROJECT in services.py causes the new tests to fail. Lint contract verified silent: uv run --extra dev ruff check src/ tests/ uv run --extra dev ruff format --check src/ tests/ Co-authored-by: srid <srid@srid.ca> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
b71850f to
41ee035
Compare
|
Rebased onto current main and resolved conflicts at 41ee035. Local validation: lint silent (ruff check + ruff format --check), targeted tests pass (1393 in tests/unit/install/ + 181 in test_services_hook_scope.py / test_services.py / test_hook_integrator.py), mutation-break gate verified (regression tests in test_services_hook_scope.py fail when the L281 |
) * feat(batch-bug-shepherd): operator visibility + fold-in invariant Refactors the batch-bug-shepherd skill to address two genesis-validated blockers and ship two missing capabilities discovered during a real sweep-all run over 14 microsoft/apm bugs: 1. OPERATOR VISIBILITY (was: silent 30-minute fan-outs) - New asset assets/progress-diagram.md: mermaid template + 5-state palette (pending/active/done/blocked/skipped) + per-phase render rules + dispatch-table contract. - SKILL.md adds 'Operator visibility is a contract' invariant; each phase boundary re-renders the diagram with current-phase coloring and prints a subagent_id -> target dispatch table BEFORE fan-out. - Operator can follow long sagas at a glance instead of waiting in the dark for the next checkpoint. 2. FOLD-IN INVARIANT (was: panel recommendations silently dropped) - assets/verdict-schema.json: shepherd_return gains required recommended_followups[] channel; completion_return gains folded_followups[] + deferred_followups[]; extracted reusable followup_item definition. - assets/shepherd-prompt.md: fixed verdict mapping bug (ship_with_followups + 0 blocking -> ready-to-merge, not needs-author-changes); added recommended_followups extraction step with required source_persona + optional fold_hint tagging. - assets/completion-prompt.md: full rewrite. Adds RECOMMENDED_FOLLOWUPS input; encodes FOLD vs DEFER classifier (FOLD: touches diff / single helper / regression trap / hermetic test / inline comment; DEFER: cross-cutting refactor / new feature / broad doc / architectural addition); per-FOLD item consultation with source_persona + python-architect lens; DEFER items filed as gh issue create tracking issues (never silently dropped); mid-flight reclassify rule to avoid stalls. - SKILL.md adds 'Bias toward folding recommended items' invariant and rewrites Phase 4 spawn contract (9 steps) to thread the recommended_followups channel end-to-end. Eval gate - +3 rubric anchors per content fixture (progress-diagram-header, mermaid-flowchart-rendered, dispatch-table-before-fanout) and +3 invariant anchors (recommended-followups-channel, fold-defer-classifier, tracking-issue-for-defer). - All 12 new anchors MATCH with_skill fixtures and MISS without_skill fixtures (clean value delta). - +3 no-fire trigger items for single-PR fold-in phrasing so the dispatcher will not misfire the batch outer-loop on single-PR fold work (e.g. 'fold the panel recommendations into PR #1234' remains apm-review-panel completion territory). Validation - Schema validates via jsonschema Draft7; accepts new shapes, rejects shepherd_return missing recommended_followups[]. - SKILL.md: 367 lines / 4483 tokens (caps: 500 / 5000). - Description: 965 / 1024 chars; mentions FOLD invariant. - 0 non-ASCII bytes across all modified files. - All 4 changed JSON files parse. Real-task evidence (this skill iteration was driven by a live run) - 5 of 6 in-flight community PRs had their panel recommendations folded in-PR by completion subagents following the new contract, yielding 22 folded items and 8 deferred-to-tracking items across PRs #1387, #1396, #1441, #1443, #1444. The 6th (#1442) is in flight as this lands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * batch-bug-shepherd: add Phase 5 mergeability gate Adds a post-wave gate that re-probes mergeability for every PR the saga marked ready-to-merge, dispatches one conflict-resolution subagent per CONFLICTING PR, and partitions returns into four post-gate statuses before the final report claims anything is mergeable. Mergeability is post-wave truth, not pre-wave assumption: a PR that Phase 4 marked ready can stop being mergeable the moment the maintainer lands another PR onto main. Without this gate the report ships stale ready claims. Design driven through the genesis skill end-to-end (steps 1-6 handoff packet, steps 7a-7b coder pass, step 8 validation): - NEW Phase 5 (mergeability gate) between completion (Phase 4) and renamed final report (Phase 5 -> Phase 6). - Sub-phases 5a probe (read-only, single-thread, gh pr view --json mergeStateStatus), 5b fan-out (one conflict-resolution subagent per CONFLICTING PR), 5c trust-but-verify re-probe + four-way partition (resolved / requires-author-action / requires-human-judgment / resolution-failed). - NEW assets/conflict-resolution-prompt.md spawn body for 5b. Encodes rebase, faithful merge of both intents, mutation-break re-check, lint silent, --force-with-lease push, re-probe, resolution-confirmation comment. - NEW references/mergeability-gate.md load-on-demand orchestrator step-by-step (load trigger: WHEN ENTERING PHASE 5). Keeps SKILL.md under 5000-token budget. - Schema extends verdict-schema.json oneOf with conflict_resolution_return; --force-with-lease enforced via regex pattern guard on push_command field; bare --force rejected. Five rejection cases validated. - Two-comment-per-PR cap as new architecture invariant: at most one completion-confirmation (Phase 4) + one resolution-confirmation (Phase 5b) per PR. - Progress diagram extended with WAVE4 subgraph (P5a/P5b/P5c), skipped-state semantics, P5b dispatch table requirement. - Final report extended with three new partition sections plus a RESOLUTION CONFIRMATION COMMENT block and mergeability-gate disciplines line. - Evals: +3 content rubric anchors (mergeability-probe-cli, force-with-lease-on-push, post-wave-partition-columns) + 1 optional anchor (two-comment-cap); +1 fire + 2 no-fire trigger items; fixture diff shows the gate firing on a sweep with 2 conflicting PRs and the without-skill failure mode (stale ready claim). SKILL.md: 388 lines / 4867 tokens (budget 500/5000). ASCII only. CI lint pair silent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(bbs): add Phase 1.5 strategic-alignment gate + PRINCIPLES.md Adds a new wave between Phase 1 (triage) and Phase 2 (PR-in-flight cross-reference) that checks every LEGIT bug against the project's rejection contract before spending shepherd / fix / completion work on it. What changes: - NEW PRINCIPLES.md at repo root: 7 numbered principles encoding the project's hard nos (P1 no invented frontmatter; P2 multi-harness with traction gating; P3 vendor neutral; P4 UX floor is not a trade) plus 3 supporting principles (P5 portability; P6 reliability over magic; P7 community over feature count). Bound to apm-ceo + bbs Phase 1.5 + apm-triage-panel + apm-review-panel as the rejection contract. - NEW bbs Phase 1.5 strategic-alignment gate (WAVE 1.5): - one apm-ceo subagent per LEGIT row, in parallel - 4-state verdict: aligned | aligned-with-reservations | out-of-scope | wrong-direction - schema-validated returns; FAILS OPEN on infrastructure failure (malformed-x2 or non-citable principle) so legit bugs are never silently demoted under gate breakage - ABORTS only when apm-ceo.agent.md or PRINCIPLES.md itself is missing (operator-actionable error) - demoted rows flip to status triaged-deferred and SKIP Phase 2/3/4/5; surface in Phase 6 under 'Recommend close as out-of-scope' partition - aligned-with-reservations rows stay in saga; downstream phases surface the reservations in review prose - deferred-PR strategic-rejection comment subagent (S7+S4+A9) posts a courtesy comment on any open PR whose underlying issue was demoted, using the would-be Phase-4 completion-comment slot (two-comments-per-PR cap preserved) - Verdict schema extended with 5th oneOf member strategic_alignment_return (kind, issue, verdict, cited_principle, rationale, reservations). - Ground-truth table grows two columns (strategic_verdict + strategic_rationale) and one status value (triaged-deferred). - Progress diagram inserts P15 between P1 and P2; dispatch-table contract extends to Phase 1.5. - Final-report template adds 'Recommend close as out-of-scope' partition and 'Aligned with reservations' surfacing section. - 2 new fire trigger evals + 1 no-fire (PRINCIPLES.md authoring guard) + 1 new rubric anchor on the three-issues-mixed scenario. Genesis design artifact lives in the session plan store; SKILL.md body remains within 500-line / 5000-token budget (406 lines / 4943 tokens after trimming pre-existing verbose passages on operator-visibility, mergeability, fold-in, composition, and operating-contract sections to make room). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Description
#1354 unconditionally threaded
deploy_root=project_rootthrough_integrate_merged_hooksto absolutize hookcommandpaths. That fixwas correct for user-scope (
~/.claude/settings.jsonis read without afixed cwd, and
${CLAUDE_PLUGIN_ROOT}is not expanded insidesettings.json-- the bug #1310 was about), but it also rewroteproject-scope
<repo>/.claude/settings.json,<repo>/.codex/hooks.json, and the sidecar<repo>/.claude/apm-hooks.json-- files that are typically checkedinto the repo. Every clone / contributor / CI runner then saw the
installer's machine-local absolute prefix baked into committed config.
This PR restricts the absolute-path rewrite to user-scope deploys by
threading an explicit
user_scope: boolkwarg from theInstallScope-aware dispatch inapm_cli.install.services.integrate_package_primitivesdown throughintegrate_hooks_for_target, the per-targetintegrate_package_hooks_*methods,
_integrate_merged_hooks, and finally_rewrite_command_for_target'sdeploy_rootparameter. The dispatchsite (
services.py~L258) is the single line of wiring that gates theabsolutization:
Project-scope (or
scope=Nonelegacy call sites) landsdeploy_root=Nonein the integrator, which keepscommandpathsrepo-relative. User-scope lands
deploy_root=project_root, whichpreserves the #1310 / #1354 absolutization for
~/.claude/settings.json.CLAUDE_CONFIG_DIRremains honored upstream of this check -- itredirects the deploy directory (
target.root_dir), not the scopegate itself.
The behaviour applies to all targets that merge into a single JSON
config (Claude / Codex / Cursor / Gemini / Windsurf) since they all
flow through
_integrate_merged_hooks. Copilot'sintegrate_package_hooksaccepts and intentionally drops theuser_scopekwarg (its hook JSON is copied verbatim with no${PLUGIN_ROOT}rewrite, so the scope signal has nothing to gate).Panel-recommended follow-ups folded in
The APM Review Panel
returned
ship_with_followups(0 blocking, 5 RECOMMENDED). The cheap,on-path items were folded into this PR rather than deferred:
docs/src/content/docs/producer/author-primitives/hooks-and-commands.mdno longer claims all
apm installruns absolutize paths -- it nowdescribes the scope-conditional behaviour and the missing-script
warning.
tests/unit/install/test_services_hook_scope.pyexercisesintegrate_package_primitivesthrough the production boundary andasserts the L258
user_scopedispatch mapsInstallScope.USER -> True,InstallScope.PROJECT / None -> False, and never threads thekwarg into sibling integrators. A future constant-swap that
re-introduced [BUG] 0.14.0: project-scope hook paths rewritten to absolute, breaking portability of checked-in .claude/settings.json and .codex/hooks.json #1394 would fail at the services tier, not just the
integrator unit.
project-scope install with a missing hook script reached neither
warning branch in
_rewrite_command_for_target(the rewrite ranonly when
deploy_root is not None), so a misconfigured hookdeployed with
${CLAUDE_PLUGIN_ROOT}/...unexpanded and zerodiagnostic output. The warning now fires in both scopes; only
user-scope additionally rewrites the variable to an absolute source
path (project-scope leaves it in place to avoid re-introducing [BUG] 0.14.0: project-scope hook paths rewritten to absolute, breaking portability of checked-in .claude/settings.json and .codex/hooks.json #1394
in committed configs).
sentence with a migration call-to-action: re-run
apm installonexisting repos to rewrite committed absolutized configs back to
repo-relative paths.
Fixes #1394
Type of change
Testing
New / updated tests
test_project_scope_writes_relative_hook_paths(regression for[BUG] 0.14.0: project-scope hook paths rewritten to absolute, breaking portability of checked-in .claude/settings.json and .codex/hooks.json #1394): asserts the rewritten command is repo-relative and contains
no absolute prefix.
test_user_scope_still_writes_absolute_hook_paths(preserves[BUG] apm install --target claude writes ${CLAUDE_PLUGIN_ROOT} into ~/.claude/settings.json where Claude Code refuses to expand it #1310 / fix: resolve hook paths to absolute in settings.json for --target claude #1354): calls the Claude entry point with
user_scope=Trueand asserts the rewritten command is absolute.
test_codex_project_scope_keeps_relative_hook_pathsmirrors theClaude regression test through the Codex public entry point so the
shared scope check is asserted from a second target.
test_reinstall_preserves_multiple_hook_files_same_eventupdated toassert relative commands (its prior absolute-form assertion was
documenting the regression).
test_services_hook_scope.py(NEW, 4 tests): services-tierregression trap for the L258
user_scopedispatch.test_rewrite_command_missing_script_warns_in_both_scopes/test_rewrite_command_missing_relative_script_warns_in_project_scope:lock in the cli-logging follow-up so the silent-failure path cannot
silently regress.
Mutation-break gate
Verified that the new tests fail-first under the regressions they
trap:
scope is InstallScope.USER->scope is InstallScope.PROJECTinservices.pymakestest_services_hook_scope.pyfail bothuser_scopeassertions.elif deploy_root is not None:gate makes the project-scopewarning test fail with
warnings == [].Local validation
End-to-end repro
The original bug repro from #1394 uses
srid/kolu(an APM consumer withsrid/agencypinned as a dependency that ships aStophook, and.claude/settings.json+.codex/hooks.jsonchecked in). Running thepatched build against kolu produces a clean diff:
After the fix, the patched
apm installproduces byte-identicaloutput to the pre-0.14.0 (relative-path) state in kolu's committed
history.