fix(hooks): stabilize root-local hook source ids#1330
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Stabilizes root-local hook source IDs so re-running hook installation/sync remains idempotent across different checkout directory names, and adds targeted healing to remove stale same-content root entries in merged hook targets.
Changes:
- Derive root-local hook “source” from
apm.ymlname (sanitized) and namespace merged-target ownership as_local/<name>. - Heal stale merged-hook entries for root-local installs by removing prior same-content entries with outdated
_apm_sourcevalues while preserving dependency- and user-owned entries. - Add regression tests for Claude/Codex merged-hook stale-source healing and adjust an integration test cleanup block.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/apm_cli/integration/hook_integrator.py | Implements stable root-local source markers and stale-entry healing in merged hook targets |
| tests/unit/integration/test_hook_integrator.py | Adds unit coverage for root-local stable naming + stale-source healing across merged targets |
| tests/integration/test_registry.py | Replaces try/except cleanup with contextlib.suppress (no behavior change intended) |
| CHANGELOG.md | Documents the root hook source drift + stale-entry healing fix |
| if not heal_stale_root_source or not source or source in dependency_sources: | ||
| return False | ||
| return self._hook_entry_content_key(entry) in fresh_content_keys |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 3 | Solid fix; one recommended refactor (RemovalContext dataclass) and one I/O scope concern (rglob). No blocking issues. |
| CLI Logging Expert | 0 | 2 | 1 | No new user-visible output (correct). Two debug-log gaps matter for verbose/agent mode diagnostics. |
| DevX UX Expert | 0 | 2 | 1 | Idempotency contract well-addressed. Two recommended items: silent healing visibility and bare _local fallback edge case. |
| Supply Chain Security Expert | 0 | 0 | 2 | No blocking security issues. Two nits: symlink traversal in rglob, and a comment suggestion on _safe_source_name. |
| OSS Growth Hacker | 0 | 0 | 1 | Strong trust-signal fix. CHANGELOG wording leads with implementation detail rather than user outcome. |
| Doc Writer | 0 | 1 | 1 | CHANGELOG entry leaks internal symbols. No docs drift found elsewhere. |
| Test Coverage Expert | 0 | 2 | 0 | Six targeted scenario tests defend the core user promise. Two gaps: no integration-tier test; private helper edge cases uncovered. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Doc Writer] Rewrite CHANGELOG entry to user-observable outcome before merge. -- Current entry exposes
_apm_sourceand 'heal stale same-content merged hook entries' -- internal vocabulary obscuring the user benefit. doc-writer and oss-growth-hacker converge on a user-first rewrite. CHANGELOG is the public release surface; this is a one-line fix with zero risk. - [Test Coverage Expert] Add integration-tier test: seed stale
.claude/settings.json, runapm installvia subprocess, assert stale entry replaced by_local/{name}. -- All six new tests call HookIntegrator directly. The full user promise -- thatapm installheals stale entries -- is untested at the CLI-entry-point tier. Evidence: outcome=missing attests/integration/. - [CLI Logging Expert] Add
_log.debug()calls for silent OSError/YAMLError swallows and for the stale-entry healing count. -- Unexpected_localfallbacks and healed-entry counts are invisible in verbose mode. Two targeted debug log lines would make agent-assisted debugging meaningfully more informative. - [Python Architect] Bound
_dependency_hook_sourcesto depth-1/2 iteration instead ofrglob('*')across the fullapm_modulestree. --rglob('*')is O(total files in all deps) and follows symlinks. A depth-bounded walk also closes the supply-chain-security-expert's symlink-cycle concern in one change. - [Test Coverage Expert] Add unit-tier coverage for private helper edge cases:
_is_root_local_packagewith None path,_get_root_local_package_namewithname: nullorname: ''in apm.yml. -- The bare_localfallback produces a source marker that differs from_local/{name}, meaning a future add ofname:to apm.yml triggers a one-time re-heal. No test currently asserts this fallback path.
Architecture
classDiagram
direction LR
class BaseIntegrator {
<<AbstractBase>>
+check_collision()
+is_content_identical_to_source()
+find_hook_files()
}
class HookIntegrator {
<<ConcreteIntegrator>>
+integrate_package_hooks()
+integrate_package_hooks_merged_target()
+_get_package_name()
+_get_hook_source_marker()
+_is_root_local_package()
+_safe_source_name()
+_get_root_local_package_name()
+_hook_entry_content_key()
+_dependency_hook_sources()
+_should_remove_prior_merged_entry()
}
class HookIntegrationResult {
<<ValueObject>>
+hooks_integrated int
}
class IntegrationResult {
<<ValueObject>>
+files_integrated int
+files_updated int
+files_skipped int
}
class _MergeHookConfig {
<<ValueObject>>
+config_filename str
+target_key str
}
BaseIntegrator <|-- HookIntegrator : extends
IntegrationResult <|-- HookIntegrationResult : extends
HookIntegrator ..> _MergeHookConfig : reads
HookIntegrator ..> HookIntegrationResult : produces
class HookIntegrator:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A([apm install -- root package]) --> B[integrate_package_hooks_merged_target]
B --> C{_is_root_local_package?}
C -- No --> D[source_marker = install_path.name]
C -- Yes --> E[_get_root_local_package_name]
E --> E1[read apm.yml via load_yaml]
E1 -- success --> E2[_safe_source_name from manifest name]
E1 -- OSError/YAMLError --> E3[fallback to package.name or _local]
E2 --> F[source_marker = _local/name]
E3 --> F
D --> G[heal_stale_root_source = False]
F --> H[heal_stale_root_source = True]
H --> I[_dependency_hook_sources: scan apm_modules/]
G --> J[dependency_sources = empty set]
I --> J2[dependency_sources = set of dep dir names]
J --> K[per hook_file loop]
J2 --> K
K --> L[build fresh_content_keys via _hook_entry_content_key]
L --> M{remove_current_source OR heal_stale_root_source?}
M -- Yes --> N[_should_remove_prior_merged_entry filter]
N --> O{owns current source?}
O -- Yes --> REMOVE[remove entry]
O -- No --> P{heal AND not dep source AND content matches?}
P -- Yes --> REMOVE
P -- No --> KEEP[keep entry]
M -- No --> KEEP
KEEP --> Q[extend with new entries]
REMOVE --> Q
Q --> R[write merged JSON config file]
Recommendation
Ship after the CHANGELOG entry is rewritten to user-observable language (follow-up #1 above -- one line, zero risk, should land in this PR). All other follow-ups are post-merge: the integration-tier test gap is the highest-signal item to track on the milestone. The fix is correct, safe, and well-tested at unit tier; blocking it for the integration test would penalize a solid contributor fix for a gap that predates this PR.
Full per-persona findings
Python Architect
-
[recommended]
_should_remove_prior_merged_entryhas 5 keyword-only parameters; extract a RemovalContext frozen dataclass atsrc/apm_cli/integration/hook_integrator.py:603
The method signature is repeated verbatim at two call-sites. A frozen_RemovalContextdataclass encapsulates call-state once, aligns with the BaseIntegrator value-object pattern, and makes future flag additions non-invasive.
Suggested:@dataclass(frozen=True) class _RemovalContext: source_marker: str; fresh_content_keys: set[str]; heal_stale_root_source: bool; dependency_sources: set[str]; remove_current_source: bool -
[recommended]
_dependency_hook_sourcesrglobs the entireapm_modulestree on every root-package install atsrc/apm_cli/integration/hook_integrator.py:585
O(total files in all deps) and unbounded I/O on the install path. A depth-1/2 bounded walk correctly identifies installed package roots without traversing deep script trees.
Suggested: Replacerglob('*')with explicititerdir()at depth 1 and 2. -
[nit]
sort_keys=Truein_hook_entry_content_keyis redundant aftersorted(entry.items())atsrc/apm_cli/integration/hook_integrator.py:581
Dropsort_keys=True. -
[nit]
import yamlat module level solely foryaml.YAMLErroratsrc/apm_cli/integration/hook_integrator.py:54
Replace withfrom yaml import YAMLError. -
[nit]
_is_root_local_packagecalled 3x in the hot path atsrc/apm_cli/integration/hook_integrator.py:768
Compute once and thread through.
CLI Logging Expert
-
[recommended] Silent swallow of
OSError/YAMLErrorin_get_root_local_package_nameshould emit a debug log atsrc/apm_cli/integration/hook_integrator.py:543
Without it, unexpected_localfallbacks are invisible in verbose/agent mode. Add_log.debug("apm.yml name read failed (%s); falling back to package name", e)before thepass. -
[recommended] Stale-entry healing is silent at
src/apm_cli/integration/hook_integrator.py:619
Users and agents cannot confirm old checkout-derived entries were cleaned up. Add_log.debug("Healed %d stale root-source entries for event '%s'", removed, event_name)after the filter step whenheal_stale_root_sourceis True andremoved > 0. -
[nit] Silent JSON decode failure when reading existing hook config is not logged at
src/apm_cli/integration/hook_integrator.py:791
Pre-existing pattern; new healing logic makes a debug warning more relevant.
DevX UX Expert
-
[recommended] Silent healing provides no user-visible signal when stale entries are removed at
src/apm_cli/integration/hook_integrator.py:619
Users inspecting.claude/settings.jsonpost-reinstall and seeing missing entries have no explanation. A--verbose-gated line would satisfy the discoverability contract. -
[recommended] When
apm.ymlhas nonamefield, source marker degrades to bare_localatsrc/apm_cli/integration/hook_integrator.py:550
Weakens the namespace convention. When user later addsname:, the source ID changes and triggers a one-time re-heal. A doc comment or debug log noting this edge case prevents future confusion. -
[nit]
_hook_entry_content_keyexcluding_apm_sourcemeans a byte-identical user-written entry could silently get APM's source marker atsrc/apm_cli/integration/hook_integrator.py:580
Low probability; worth a code comment since the new healing path increases exposure.
Supply Chain Security Expert
No blocking findings.
-
[nit]
apm_modules.rglob('*')follows symlinks by default atsrc/apm_cli/integration/hook_integrator.py:591
A malicious package could plant a symlink cycle insideapm_modules, causing unbounded traversal. Addif path.is_symlink(): continuebefore theis_dir()check. -
[nit]
_safe_source_namepermits interior dots after stripping atsrc/apm_cli/integration/hook_integrator.py:526
No file-system risk (value is only used as a JSON string), but a comment clarifying it must never be used as a path component without containment would prevent future misuse.
OSS Growth Hacker
- [nit] CHANGELOG entry leads with internal implementation detail at
CHANGELOG.md
Current: "...use a stable local hook source id and heal stale same-content merged hook entries left behind by older checkout-derived_apm_sourcevalues..." Suggested: "Hooks installed from root.apm/hooks/*.jsonare now idempotent across different checkout directories -- duplicate hook entries in Claude/Codex/Cursor/Gemini/Windsurf config files no longer accumulate when the same repo is cloned to a different path."
Auth Expert -- inactive
No auth-relevant files changed; hook_integrator.py does not touch AuthResolver, token management, credential resolution, or host classification.
Doc Writer
-
[recommended] CHANGELOG entry exposes internal implementation vocabulary at
CHANGELOG.md:12
_apm_sourceand "heal stale same-content merged hook entries left behind by older checkout-derived_apm_sourcevalues" mean nothing to users. The user-observable behavior is simply: re-runningapm installno longer creates duplicate hook entries.
Suggested: "Root.apm/hooks/*.jsoninstalls now use a stable source identifier, making hook entries in Claude/Codex/Cursor/Gemini/Windsurf configs idempotent across re-installs; stale duplicate entries from older installs are removed automatically. ([BUG] Root .apm hooks can duplicate when _apm_source changes with checkout directory #1329)" -
[nit] "keeping ... idempotent" uses gerund where adjacent entries use declarative present tense at
CHANGELOG.md:12
Restructure to match the section's tense pattern.
Test Coverage Expert
-
[recommended] No integration-tier test for end-to-end "install heals stale root source" flow
All 6 new tests callHookIntegratordirectly (unit tier). The full user promise only manifests when the realapm installCLI entry point drives the integrator. Greppedtests/integration/*.pyfor_apm_source,stale,root_local,heal-- zero matches in hook-healing context.
Proof (missing at integration-with-fixtures):tests/integration/test_local_install.py-- proves: Runningapm installheals stale checkout-name entries in.claude/settings.json[portability-by-manifest, devx]
Suggested: Add a fixture-based integration test that seeds a stale.claude/settings.json, runsapm installvia subprocess, and asserts the stale entry is replaced by_local/{name}. Mark@pytest.mark.integration. -
[recommended] Private helper edge cases uncovered at any tier
_is_root_local_package(pkg_info, None)-> False;path.resolve()raising OSError -> False;_get_root_local_package_namewithname: ""orname: nullin apm.yml falling back to_local(not_local/_local). Grepped for direct calls to private methods -- zero matches.
Proof (missing at unit):tests/unit/integration/test_hook_integrator.py::TestIssue1007Fixes::test_root_local_source_marker_when_apm_yml_name_is_empty-- proves: When apm.yml has name: '' or null, source marker degrades gracefully to '_local' rather than crashing [devx]
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Note
🔒 Integrity filter blocked 2 items
The following items were blocked because they don't meet the GitHub integrity level.
- #1330
pull_request_read: has lower integrity than agent requires. The agent cannot read data with integrity below "approved". - fix(hooks): stabilize root-local hook source ids #1330
pull_request_read: has lower integrity than agent requires. The agent cannot read data with integrity below "approved".
To allow these resources, lower min-integrity in your GitHub frontmatter:
tools:
github:
min-integrity: approved # merged | approved | unapproved | noneGenerated by PR Review Panel for issue #1330 · ● 2.9M · ◷
|
Maintainer sweep [2026-05-15]: tried to land your rebase from a maintainer terminal but my OAuth scope refuses workflow-file pushes (today's main contains a Trivial rebase, two conflicts, both safe to resolve as below: Conflict 1 -- Conflict 2 -- I verified the rebase locally and the panel verdict ( Thanks @imk1t! |
a6925c8 to
e425ab3
Compare
|
Thanks for the maintainer sweep. I rebased the branch onto current
I also made one follow-up change beyond the rebase, based on the later inline review around The updated behavior is:
This keeps the root-local stale-source healing behavior, but avoids treating deep package internals as dependency sources. Local validation:
I also ran the local I checked my repository permissions and I do not have permission to re-apply |
PR-shepherd review (automated by orchestrator session)Verdict: ready-to-merge (with one non-blocking follow-up) Root-cause alignmentALIGNED. The triage of #1329 named two coupled defects: (1) Specialist panel findings (material only)A full apm-review-panel pass already ran on this PR; CEO synthesis
E2E coverageMISSING at the integration tier. Six well-scoped unit tests in """End-to-end regression for #1329: stale root hook _apm_source heals on reinstall."""
import json
import subprocess
import sys
from pathlib import Path
import pytest
CLI = [sys.executable, "-m", "apm_cli.cli"]
def _run(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(CLI + list(args), cwd=str(cwd), capture_output=True, text=True)
def test_root_hook_source_drift_heals_on_reinstall(tmp_path: Path) -> None:
project = tmp_path / "myapp"
project.mkdir()
(project / "apm.yml").write_text(
"name: myapp\nversion: 0.0.0\ntargets:\n - claude\n",
encoding="utf-8",
)
hooks_dir = project / ".apm" / "hooks"
hooks_dir.mkdir(parents=True)
(hooks_dir / "pre.json").write_text(
json.dumps(
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "bash .codex/hooks/pre.sh"}],
}
]
}
}
),
encoding="utf-8",
)
# Simulate a legacy install whose _apm_source came from an old checkout basename.
settings = project / ".claude" / "settings.json"
settings.parent.mkdir(parents=True, exist_ok=True)
settings.write_text(
json.dumps(
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "bash .codex/hooks/pre.sh"}
],
"_apm_source": "old-checkout-name",
},
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "echo user-owned"}],
},
]
}
}
),
encoding="utf-8",
)
result = _run(project, "install")
assert result.returncode == 0, result.stderr
entries = json.loads(settings.read_text())["hooks"]["PreToolUse"]
managed = [e for e in entries if isinstance(e, dict) and "_apm_source" in e]
user_owned = [e for e in entries if isinstance(e, dict) and "_apm_source" not in e]
assert [e["_apm_source"] for e in managed] == ["_local/myapp"], (
f"Expected single _local/myapp entry, got {[e.get('_apm_source') for e in entries]}"
)
assert len(user_owned) == 1, "User-owned hook entry must survive healing"Empirical reproCloned PR branch into Both halves of #1329 are closed empirically: source id is decoupled from checkout basename, and the same-content healer removes the legacy stale entry while preserving the user-owned hook. (The PR's own unit tests exercise the same matrix; the subprocess-level integration test above is the missing rung in the ladder.) Suggested follow-ups
None of the above are blockers given the verified empirical repro and the strong unit-tier defense. Recommending merge with the CHANGELOG rewrite landed in this PR and the integration test as a fast follow. Orchestrator session: danielmeppiel/triage-issues-1363-1364-1366 |
|
Closing this in favor of #1392, which carries your commits forward (authorship preserved) plus the three follow-ups noted in the shepherd review:
Why a new PR instead of force-pushing onto this branch: the rebase onto current Thank you @imk1t for the careful fix and the thorough test coverage at the integrator tier — the root-cause analysis (decoupling |
…ees (supersedes #1330, closes #1329) (#1392) * fix: stabilize root hook source ids * test: remove project-specific hook fixture names * fix(hooks): bound dependency hook source discovery * fix(hooks): reject symlinked dependency markers * test(hooks): subprocess-tier coverage + debug logs for #1329 - Add tests/integration/test_hook_root_source_drift_e2e.py: subprocess test that seeds stale .claude/settings.json + apm-hooks.json sidecar with old source-id, runs apm install, asserts entries heal to _local/<manifest-name> and user-owned hook entry survives. - Add two _log.debug lines in hook_integrator.py: - manifest-parse fallback in _get_root_local_package_name - heal count when stale same-content merged entries are removed - Update affected unit tests to read _apm_source from Claude sidecar (post-#1359 schema-strict layout). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hooks): act on review-panel recommendations for #1329 follow-up Addresses the top recommended-tier follow-ups from the APM review panel on #1392 (#1392 (comment)). In-PR: * CHANGELOG: add [Unreleased] > Fixed entry with user-outcome framing, enumerating all five affected harnesses (Claude / Codex / Cursor / Gemini / Windsurf) and the explicit parenthetical that Copilot is unaffected because its hooks live in per-file namespaces rather than a shared merged config. (doc-writer + oss-growth-hacker convergence) * Integration coverage: parametrize the e2e heal regression test across all five merged-hook harnesses instead of Claude only. Uses an install-then-corrupt-then-reinstall pattern so the seeded entries match each target's on-disk shape (sidecar for Claude; in-file _apm_source for Codex / Cursor / Gemini / Windsurf), closing the silent-drift gap the test-coverage-expert flagged as highest-signal. * _safe_source_name hardening: collapse runs of 2+ dots to a single dot before stripping edges. No traversal risk today (the marker is JSON metadata), but guards any future caller that passes the name into a Path join. (supply-chain-security recommendation.) * Nits: include exception message (not just class name) in the manifest-unreadable debug log; replace O(n*m) list membership in the heal counter with an id-set; document the project_root parameter on _get_package_name; promote _get_root_local_package_name, _get_hook_source_marker, and _should_remove_prior_merged_entry to @staticmethod (they touch no instance state). The DependencySourceScanner extraction the python-architect proposed is explicitly out of scope for this PR per the panel's own guidance ("does not need to land in this PR but should not slip past the next meaningful touch to hook_integrator.py"). Validation: * uv run --extra dev ruff check src/ tests/ -- clean * uv run --extra dev ruff format --check src/ tests/ -- clean * uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py -- 156 passed * uv run --extra dev pytest tests/integration/test_hook_root_source_drift_e2e.py -- 5 passed (claude, codex, cursor, gemini, windsurf) * uv run --extra dev pytest tests/unit/integration/ tests/integration/test_hook_root_source_drift_e2e.py -- 1364 passed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): resolve R0801 duplication; expand lint contract to all 7 CI gates CI lint was failing the PR with R0801 (pylint duplicate-code). The duplication was introduced when main was merged into this branch: the recent MCP-client work on main left an identical `_select_best_package` method in both CodexClientAdapter and CopilotClientAdapter, each ~22 lines long. pylint --fail-on=R0801 catches this at the merge commit CI tests, so the PR cannot pass until the dup is resolved. Fix: hoist `_select_best_package` to the `MCPClientAdapter` base class (alongside the existing `_infer_registry_name` it already calls) and delete the duplicates from codex.py and copilot.py. Both call sites use `self._select_best_package(packages)`, so inheritance resolves the lookup transparently. Mirrors the in-flight refactor on `daf99e0f refactor(adapters): hoist _select_remote_with_url and _select_best_package to base` (not yet on main). Also: expand the canonical lint contract at `.apm/instructions/linting.instructions.md` to cover all seven CI Lint steps (ruff check, ruff format, YAML I/O guard, file-length guard, no raw `str(relative_to)`, pylint R0801, auth-signal lint) instead of only ruff. Adds the full `uv run ... && ...` mirror chain and calls out the merge-commit semantics (CI tests HEAD merged with main, so duplication on main can fail a clean PR diff). Apm compile flows the change into the generated AGENTS.md (gitignored at root) and into `.github/copilot-instructions.md`. Validation: * uv run --extra dev ruff check src/ tests/ -- clean * uv run --extra dev ruff format --check src/ tests/ -- clean * uv run --extra dev python -m pylint --disable=all --enable=R0801 \ --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/ -- 10.00/10, exit 0 * bash scripts/lint-auth-signals.sh -- clean * uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py \ tests/integration/test_hook_root_source_drift_e2e.py -- 161 passed Pre-existing main-side test failures in `tests/unit/test_codex_adapter_compatibility.py` (introduced by #1262 / #1277 on main and tracked by the in-flight `32991fb6 fix(adapters): restore _resolve_env_placeholders shim and reject SSE remotes in tests` branch) are out of scope for this PR and will resolve when that follow-up lands on main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(adapters): restore _resolve_env_placeholders shim and reject SSE remotes in tests PR #1277 removed the _resolve_env_placeholders legacy wrapper from adapters and softened Codex remote-server rejection (https URLs are now accepted as streamable-http), but did not update the phase-3 and compatibility test suites. The result was 6 pre-existing test failures on Build & Test that this PR's Windows-targeted changes inherited. - Restore _resolve_env_placeholders on MCPClientAdapter as a thin delegate to _resolve_variable_placeholders (empty runtime_vars). External callers and 4 test cases rely on the legacy name. - Update test_returns_false_for_remote_only_server in both codex test files to use an SSE-transport remote, which is still the rejection contract post-#1277 (streamable-http is now accepted). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Koichi Takahashi <tkou15.hi@gmail.com> Co-authored-by: Koichi Takahashi <work@k1t.space> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Daniel Meppiel <copilot-rework@github.com>
Description
TL;DR
Root-local
.apm/hooks/*.jsoninstalls now use a checkout-stable source marker derived fromapm.ymlinstead of the worktree basename. Merged hook targets heal stale same-content root hook entries whose old_apm_sourcecame from a previous checkout name, so re-running install does not append duplicates. User-authored hooks and dependency package hooks remain untouched unless they are the current source being replaced.Note
Closes #1329. The array-dedup healer is intentionally scoped to merged hook targets; Copilot's individual-file hook model is not part of this duplicate-array failure mode.
Problem (WHY)
_apm_sourcechanges between installs..apmcontent between worktrees could create install drift.Why these matter: install idempotency is a DevX contract, and hook emission is a multi-harness surface. The issue explicitly asks for root
.apm_apm_sourceto be "stable across checkout directories" and lists Claude, Codex, Gemini, Cursor, and Windsurf as affected merged-hook targets.Approach (WHAT)
install_path.name; use safeapm.ymlnamewith_localfallback._local/<name>so root content cannot collide with dependency source names._apm_source, while preserving user hooks and dependency sources.install_path.namemarker so package aliases and cross-package installs do not collapse.Implementation (HOW)
src/apm_cli/integration/hook_integrator.py-- adds root-local package detection, safe manifest-name normalization,_local/<name>merged-source markers, and content-key based stale-source healing. Current-source cleanup still runs once per event, while stale-source healing runs per root hook file so multiple files targeting the same event are handled.tests/unit/integration/test_hook_integrator.py-- adds regression coverage for Claude and Codex stale-source healing, stable root source naming, user-hook preservation, dependency-hook preservation, dependency-name collision avoidance, and multiple root hook files sharing one event.tests/integration/test_registry.py-- keeps Ruff green by replacing a narrow cleanuptry/except/passblock withcontextlib.suppress; no registry behavior change.CHANGELOG.md-- documents the root hook source-drift fix underUnreleased / Fixed.Diagrams
Legend: root hook content now gets a stable local marker before merged-target cleanup, while dependency and user-owned entries stay outside the stale-root healer.
flowchart LR subgraph Source[Root hooks] H1[".apm/hooks/*.json"] H2["apm.yml name"] end subgraph Integrator[HookIntegrator] I1["stable root package name"]:::new I2["merged source marker _local/name"]:::new I3["same-content stale root healer"]:::new end subgraph Outputs[Merged hook targets] O1[".claude/settings.json"] O2[".codex/hooks.json"] O3["Cursor Gemini Windsurf"] end D1["dependency package sources"] U1["user hooks without _apm_source"] H2 --> I1 H1 --> I2 I1 --> I2 I2 --> I3 I3 --> O1 I3 --> O2 I3 --> O3 D1 --> O1 U1 --> O1 classDef new stroke-dasharray: 5 5; class I1,I2,I3 new;Trade-offs
_local/<safe manifest name>for merged hook ownership; rejected using the raw manifest name because a dependency package can legitimately have the same source id._localwhenapm.ymlcannot provide a safe name; rejected failing install because this path is cleanup/idempotency support, not manifest validation.Benefits
.apmhooks over an old checkout-derived_apm_sourceleaves one managed semantic hook, not two._local/<apm.yml name>across checkout directory names._apm_sourceremain in the generated merged config.Closes #1329
Type of change
Testing
uv run pytest tests/unit/integration/test_hook_integrator.py -x:uv run pytest tests/unit tests/test_console.py -x:uv run --extra dev ruff check src/ tests/:uv run --extra dev ruff format --check src/ tests/:git diff --check:npx --yes -p @mermaid-js/mermaid-cli mmdc -i /private/tmp/apm-pr-diag-1329.mmd -o /private/tmp/apm-pr-diag-1329.svg --quiet:Scenario Evidence
tests/unit/integration/test_hook_integrator.py::test_root_local_heals_stale_source_in_claude_settings(regression-trap for #1329)tests/unit/integration/test_hook_integrator.py::test_root_local_heals_stale_source_in_codex_hooks(regression-trap for #1329).apmhook ownership stays stable when the checkout directory basename changes.tests/unit/integration/test_hook_integrator.py::test_root_local_source_uses_manifest_name_apm_sourcesurvive root hook healing.tests/unit/integration/test_hook_integrator.py::test_root_local_heals_stale_source_in_claude_settingstests/unit/integration/test_hook_integrator.py::test_root_local_healer_preserves_dependency_source_entriestests/unit/integration/test_hook_integrator.py::test_content_dedup_preserves_cross_packagetests/unit/integration/test_hook_integrator.py::test_root_local_source_marker_does_not_collide_with_dependency_nametests/unit/integration/test_hook_integrator.py::test_root_local_heals_stale_source_for_multiple_hook_files_same_eventHow to test
.claude/settings.jsonwith a root hook tagged by an old_apm_source, then run root-local hook integration; expect one managed_local/<name>entry plus any user-owned entries..codex/hooks.json; expect exactly one managed root hook entry._local/<name>sources to remain.uv run pytest tests/unit/integration/test_hook_integrator.py -x; expect all hook-integrator tests to pass.