Skip to content

fix(hooks): keep project-scope hook commands repo-relative (closes #1394)#1396

Merged
danielmeppiel merged 3 commits into
microsoft:mainfrom
srid:fix/1394-project-scope-hook-paths
May 22, 2026
Merged

fix(hooks): keep project-scope hook commands repo-relative (closes #1394)#1396
danielmeppiel merged 3 commits into
microsoft:mainfrom
srid:fix/1394-project-scope-hook-paths

Conversation

@srid
Copy link
Copy Markdown
Contributor

@srid srid commented May 19, 2026

Description

#1354 unconditionally threaded deploy_root=project_root through
_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 #1310 was about), but it also rewrote
project-scope <repo>/.claude/settings.json,
<repo>/.codex/hooks.json, and the sidecar
<repo>/.claude/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.

This PR restricts the absolute-path rewrite to user-scope deploys by
threading an explicit user_scope: bool kwarg from the
InstallScope-aware dispatch in
apm_cli.install.services.integrate_package_primitives down through
integrate_hooks_for_target, the per-target integrate_package_hooks_*
methods, _integrate_merged_hooks, and finally
_rewrite_command_for_target's deploy_root parameter. The dispatch
site (services.py ~L258) is the single line of wiring that gates the
absolutization:

if _prim_name == "hooks":
    _call_kwargs["user_scope"] = scope is InstallScope.USER

Project-scope (or scope=None legacy call sites) lands
deploy_root=None in the integrator, which keeps command paths
repo-relative. User-scope lands deploy_root=project_root, which
preserves the #1310 / #1354 absolutization for ~/.claude/settings.json.
CLAUDE_CONFIG_DIR remains honored upstream of this check -- it
redirects the deploy directory (target.root_dir), not the scope
gate 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's
integrate_package_hooks accepts and intentionally drops the
user_scope kwarg (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:

  • Doc drift (doc-writer + devx-ux).
    docs/src/content/docs/producer/author-primitives/hooks-and-commands.md
    no longer claims all apm install runs absolutize paths -- it now
    describes the scope-conditional behaviour and the missing-script
    warning.
  • Services-tier regression trap (test-coverage + devx-ux). New
    tests/unit/install/test_services_hook_scope.py exercises
    integrate_package_primitives through the production boundary and
    asserts the L258 user_scope dispatch maps InstallScope.USER -> True, InstallScope.PROJECT / None -> False, and never threads the
    kwarg 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.
  • Silent missing-script failure (cli-logging). Pre-fold, a
    project-scope install with a missing hook script reached neither
    warning branch in _rewrite_command_for_target (the rewrite ran
    only when deploy_root is not None), so a misconfigured hook
    deployed with ${CLAUDE_PLUGIN_ROOT}/... unexpanded and zero
    diagnostic 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).
  • CHANGELOG (growth + doc-writer + devx-ux). 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.

Fixes #1394

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

New / updated tests

Mutation-break gate

Verified that the new tests fail-first under the regressions they
trap:

  • Swapping scope is InstallScope.USER -> scope is InstallScope.PROJECT in services.py makes
    test_services_hook_scope.py fail both user_scope assertions.
  • Reverting the missing-script warning to its prior
    elif deploy_root is not None: gate makes the project-scope
    warning test fail with warnings == [].

Local validation

uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py tests/unit/install/test_services_hook_scope.py tests/unit/install/test_services.py -q
# 161 passed in 14.24s
uv run --extra dev pytest tests/unit/ -q
# 8741 passed, 1 skipped (unrelated test_runtime_windows env-specific)
uv run --extra dev ruff check src/ tests/         # silent
uv run --extra dev ruff format --check src/ tests/ # silent

End-to-end repro

The original bug repro from #1394 uses
srid/kolu (an APM consumer with
srid/agency pinned as a dependency that ships a Stop hook, and
.claude/settings.json + .codex/hooks.json checked in). Running the
patched build against kolu produces a clean diff:

--- a/.claude/settings.json
-          "command": "/Users/srid/code/kolu/.claude/hooks/agency/scripts/do-stop-guard.sh"
+          "command": ".claude/hooks/agency/scripts/do-stop-guard.sh"
--- a/.codex/hooks.json
-          "command": "/Users/srid/code/kolu/.codex/hooks/agency/scripts/do-stop-guard.sh"
+          "command": ".codex/hooks/agency/scripts/do-stop-guard.sh"
--- a/.claude/apm-hooks.json
-          "command": "/Users/srid/code/kolu/.claude/hooks/agency/scripts/do-stop-guard.sh"
+          "command": ".claude/hooks/agency/scripts/do-stop-guard.sh"

After the fix, the patched apm install produces byte-identical
output to the pre-0.14.0 (relative-path) state in kolu's committed
history.

Copilot AI review requested due to automatic review settings May 19, 2026 13:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_root threading in _integrate_merged_hooks on a project_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.

Comment on lines +737 to +746
# 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()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 by scope 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 inspect project_root at all.
  • Inference vs intent: now explicit at the call site. Sibling integrators (prompt/agent/command/instruction) keep their existing signatures -- the user_scope kwarg is added only to integrate_hooks_for_target, the three deprecated per-target wrappers, and _integrate_merged_hooks, and the dispatch in services.py includes 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.

srid added a commit to srid/apm that referenced this pull request May 19, 2026
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).
srid added a commit to juspay/kolu that referenced this pull request May 19, 2026
srid added a commit to juspay/kolu that referenced this pull request May 19, 2026
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.
@danielmeppiel
Copy link
Copy Markdown
Collaborator

APM Review Panel: ship_with_followups

Scope hook-command absolutization to user installs only, restoring portability-by-manifest for project configs broken by #1354 (closes #1394).

cc @srid @danielmeppiel @sergio-sisternes-epam -- a fresh advisory pass is ready for your review.

This PR correctly and minimally fixes a regression introduced in #1354: absolutizing hook command paths unconditionally caused project-scope configs (.claude/settings.json, .codex/hooks.json, .claude/apm-hooks.json) to embed the installer machine's local FS layout, silently breaking portability across clones, contributors, and CI. The fix threads a user_scope: bool flag from InstallScope through five call-site signatures and gates absolutization on that flag. The test-coverage panelist ran all four new/updated unit tests against the PR branch (4 passed, 0.83s); the regression IS trapped at the unit tier. The panel is unanimous: no blocking findings, and the fix is correct as implemented.

Two convergences rise to the top. First, docs/src/content/docs/producer/author-primitives/hooks-and-commands.md L154 still claims ALL apm install runs rewrite to absolute paths -- now false for project-scope. Both doc-writer and devx-ux independently flagged the same line; this is the highest-priority post-merge edit. Second, the services.py dispatch seam at L258 (user_scope = scope is InstallScope.USER) is the single line of wiring that makes the entire fix work -- yet no test exercises it through the production services.integrate_package_primitives boundary. devx-ux and test-coverage converge on this gap; a future refactor that swapped the InstallScope constants would silently restore the regression without a failing test to catch it. A services-tier fixture test is the second-highest follow-up.

Three lower-tier findings are worth tracking but do not block. The cli-logging panelist identified a new silent-failure path: post-PR, a project-scope install with a missing hook script reaches neither warning branch (L444 deploy_root is now None for project-scope), leaving ${CLAUDE_PLUGIN_ROOT}/... unexpanded with zero diagnostic output. This is a real UX regression introduced by the fix itself and should be addressed before the next minor release. The CHANGELOG entry, flagged by three panelists (too long, too dense, missing migration CTA), should be tightened to one sentence with a call-to-action to re-run apm install on repos that committed absolutized configs. The python-architect's body-drift finding (PR body describes a home-equality gate that was not implemented; actual impl threads a kwarg) is accurate and the author should update the PR description to prevent future reviewer confusion.

Aligned with: Portable by manifest -- directly restored: project-scope hook commands are now repo-relative, safe to commit and reproduce identically on any clone or CI runner. Secure by default -- incidentally improved: the installer no longer leaks the developer machine's absolute FS layout into committed project configs. Multi-harness / multi-host -- fix is symmetric across Claude, Codex, Cursor, Gemini, Windsurf; no harness is special-cased. OSS community-driven -- regression discovered and fixed by an external contributor (@srid) using APM in a real consumer graph (srid/kolu depends on srid/agency). Pragmatic as npm -- project-scope now behaves portably by default, matching the mental model users bring from npm/pip.

Growth signal. @srid is not a toy-project contributor: kolu and agency are production APM consumer repos with real dependency graphs. The before/after diff (machine-absolute paths in committed config replaced by repo-relative commands) is self-explanatory release-note evidence. This is the kind of external validation -- real user, real bug, real fix, fully tested -- that the contributor flywheel is built on. The release notes for the next tag should name the consumer repos explicitly and link the before/after snippet from the PR body; it is social-copy-ready as written.

Panel summary

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

  1. [Doc Writer] Patch hooks-and-commands.md L154-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.
  2. [Test Coverage Expert] Add a services-tier test in tests/unit/install/test_services_hook_scope.py that calls services.integrate_package_primitives with InstallScope.PROJECT and InstallScope.USER through 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.
  3. [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.py L444 (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.
  4. [OSS Growth Hacker] Tighten the CHANGELOG entry to one sentence and append a migration call-to-action: 're-run apm install on 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.
  5. [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
Loading
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"]
Loading
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)
Loading

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 is project_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 = False is added to _integrate_merged_hooks, all three public wrappers, integrate_hooks_for_target, and the dispatch in services.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 at src/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 more if _prim_name == X branches 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-liner return {}.
    Suggested: Add build_dispatch_kwargs(self, scope: InstallScope) -> dict to the integrator base/protocol, default return {}. HookIntegrator overrides: return {'user_scope': scope is InstallScope.USER}. Dispatch loop: _call_kwargs = {..., **_integrator.build_dispatch_kwargs(scope)}. Remove the if _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 calls integrate_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_target has two fallback branches (lines 444-448 and 476-480) gated on elif 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 passes deploy_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_warning for project-scope missing scripts too. Mirror for the ./path branch 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.json across a fresh clone vs a -g install 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 for apm install -g. A hook author reading this doc expects their committed .claude/settings.json to 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 call HookIntegrator.integrate_package_hooks_claude() and integrate_package_hooks_codex() directly with user_scope=True/False, bypassing services.integrate_package_primitives entirely. 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 calls integrate_package_primitives() with scope=InstallScope.PROJECT and scope=InstallScope.USER for a package that ships hooks, then asserts the written .claude/settings.json command 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
    In integrate_hooks_for_target, when target.name == 'copilot' the method calls self.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 into integrate_package_hooks and pass it to _rewrite_hooks_data as deploy_root=project_root when user_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 is integrate_hooks_for_target. The user_scope wire in integrate_hooks_for_target -> _integrate_merged_hooks is 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 with user_scope=False asserting 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 ran apm install between 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-run apm 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-running apm 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 of srid/kolu and srid/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 of srid/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 naming apm install -g for 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.md states: '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.USER has no test through the seam at src/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 calling integrate_package_hooks_claude(..., user_scope=True/False) directly. A future refactor that accidentally swaps scope is InstallScope.USER for scope 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: Add test_project_scope_hooks_via_services_are_relative in tests/unit/install/test_services_hook_scope.py: call integrate_package_primitives(..., scope=InstallScope.PROJECT, ...) with a fixture package containing hooks.json; assert cmd == .claude/hooks/scope-pkg/hooks/stop.sh. Mirror with scope=InstallScope.USER asserting Path(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 runs apm install (project scope), the hook command written to .claude/settings.json is 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_cursor asserts '.cursor/hooks/hookify/hooks/pretooluse.py' in cmd.replace('\\\\', '/') -- this passes whether cmd is the relative string alone or prefixed with an absolute machine path.
    Suggested: Change Cursor assertion from in cmd to assert 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 updated test_reinstall_preserves_multiple_hook_files_same_event exercise 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.json stays 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.

danielmeppiel pushed a commit to srid/apm that referenced this pull request May 22, 2026
…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>
@danielmeppiel
Copy link
Copy Markdown
Collaborator

Follow-ups from the APM Review Panel pass have landed. Summary:

  • doc-writer / devx-ux: hooks-and-commands.md pitfall now scope-conditional (user-scope absolutizes; project-scope stays repo-relative; missing-script behaviour documented) -- resolved in b71850f (docs/src/content/docs/producer/author-primitives/hooks-and-commands.md:154-167).
  • test-coverage / devx-ux: services-tier regression trap for the L258 user_scope dispatch -- resolved in b71850f (tests/unit/install/test_services_hook_scope.py, 4 tests).
  • cli-logging: silent missing-script failure in project-scope closed -- both branches of _rewrite_command_for_target now emit _rich_warning regardless of scope; user-scope still rewrites the variable to an absolute source path, project-scope leaves it in place so committed configs never embed a machine-local prefix -- resolved in b71850f (src/apm_cli/integration/hook_integrator.py:444-454, 478-484).
  • growth / doc-writer / devx-ux: 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") -- resolved in b71850f (CHANGELOG.md:12).
  • python-architect: PR body updated to match the implemented design (explicit user_scope kwarg threading via services.py L258, not the home-equality gate the prior body described) -- resolved by PR-body edit on b71850f.

Regression-trap evidence (mutation-break gate):

  • tests/unit/install/test_services_hook_scope.py::test_user_scope_dispatches_user_scope_true / ::test_project_scope_dispatches_user_scope_false -- swapped scope is InstallScope.USER -> scope is InstallScope.PROJECT in services.py; both tests FAILED as expected with user_scope: True flipped; guard restored.
  • tests/unit/integration/test_hook_integrator.py::TestIssue1007Fixes::test_rewrite_command_missing_script_warns_in_both_scopes -- reverted the warning branch to the prior elif deploy_root is not None: gate; test FAILED as expected with warnings == []; guard restored.

Lint contract: uv run --extra dev ruff check src/ tests/ and uv run --extra dev ruff format --check src/ tests/ both silent.

Local validation: uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py tests/unit/install/test_services_hook_scope.py tests/unit/install/test_services.py -q -> 161 passed in 14.24s. Full tests/unit/ suite: 8741 passed, 1 skipped, 1 unrelated environment-specific failure in test_runtime_windows.py (codex binary on PATH; pre-existing, untouched by this PR).

CI: Workflow runs for b71850f have not been created automatically as of this comment (only license/cla is green; no CI/Merge Gate/NOTICE Drift Check/CodeQL runs are queued). Author @srid is an external contributor whose previous commit's workflows required explicit "Approve and run" via the Actions UI. A maintainer click on "Approve and run workflows" should kick off the full pre-merge gate; the prior commit b576258 went green after the same approval step. Local unit + lint evidence above stands until CI confirms.

Ready for maintainer review.

srid and others added 3 commits May 22, 2026 09:57
…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>
@danielmeppiel danielmeppiel force-pushed the fix/1394-project-scope-hook-paths branch from b71850f to 41ee035 Compare May 22, 2026 08:01
@danielmeppiel
Copy link
Copy Markdown
Collaborator

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 scope is InstallScope.USER guard is broken, then restored). Conflicts resolved across CHANGELOG.md (preserved main's #1430 / #1392 fixes alongside the new #1394 entry; dropped duplicate #1385 already in 0.14.1), src/apm_cli/install/services.py (kept the dict-based dispatch and threaded main's new scope=scope kwarg into the base kwargs), and src/apm_cli/integration/hook_integrator.py (signature accepts both scope=None and user_scope=False). CI awaits maintainer Approve-and-run as before.

@danielmeppiel danielmeppiel enabled auto-merge May 22, 2026 12:31
@danielmeppiel danielmeppiel disabled auto-merge May 22, 2026 12:31
@danielmeppiel danielmeppiel merged commit 8ac4c23 into microsoft:main May 22, 2026
14 checks passed
danielmeppiel added a commit that referenced this pull request May 22, 2026
)

* 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>
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.

[BUG] 0.14.0: project-scope hook paths rewritten to absolute, breaking portability of checked-in .claude/settings.json and .codex/hooks.json

4 participants