Describe the bug
Root .apm/hooks/*.json hooks can be duplicated in generated merged hook configs when the _apm_source value changes between installs.
The source of truth has exactly one hook entry, but generated files can contain two semantically identical hook entries. The only difference is _apm_source, for example:
<current-checkout-dir>
<old-checkout-dir-or-generated-source-id>
The stale source id does not exist in the source of truth and appears only in previously generated hook config files.
This looks like a stale source-id healing/idempotency bug. The hook integrator removes prior entries only when _apm_source matches the current package/source name. If the same root .apm content is later installed from a different checkout directory name, stale hook entries from the old _apm_source survive and the fresh hook is appended.
To Reproduce
- In a repository with root
.apm/hooks/pre-push-review.json, ensure the hook source of truth contains one PreToolUse entry.
- Seed generated config with the same hook tagged by an old source id, for example:
.codex/hooks.json entry with _apm_source: "<old-checkout-dir-or-generated-source-id>"
.claude/settings.json entry with _apm_source: "<old-checkout-dir-or-generated-source-id>"
- Run
apm install / the repository sync flow from a checkout whose directory name is different.
- Inspect the generated merged hook config.
Observed result:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .codex/hooks/pre-push-review.sh"
}
],
"_apm_source": "<current-checkout-dir>"
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .codex/hooks/pre-push-review.sh"
}
],
"_apm_source": "<old-checkout-dir-or-generated-source-id>"
}
]
}
}
Expected behavior
Re-running install/sync for the same root .apm content should be idempotent.
The generated merged hook configs should contain exactly one semantic copy of the root hook. Stale entries created by an old root-content _apm_source should either be removed or migrated.
For root .apm content, _apm_source should preferably be stable across checkout directories, for example derived from apm.yml metadata rather than the current filesystem directory name.
Environment (please complete the following information):
- OS: macOS
- Python Version: 3.12.12
- APM Version: 0.13.0
- VSCode Version (if relevant): N/A
Logs
No command failure is emitted. Validation can still pass because current verification does not check for duplicate hook entries.
A minimal local reproduction against the current hook integrator produced:
2 ['<old-checkout-dir-or-generated-source-id>', '<current-checkout-dir>']
Additional context
This likely affects all merge-based hook targets:
- Claude:
.claude/settings.json
- Codex:
.codex/hooks.json
- Gemini:
.gemini/settings.json
- Cursor:
.cursor/hooks.json
- Windsurf:
.windsurf/hooks.json
These targets share the same merged-hook implementation. Copilot is different because it writes individual hook JSON files under .github/hooks/, so it is not affected by the same in-array duplicate-entry failure mode. However, checkout-directory-derived names may still create stale-file cleanup edge cases for Copilot.
Related issues:
The distinct bug here is source-id drift for root .apm hooks: the semantic hook is identical, but the stale entry survives because its old _apm_source no longer matches the current install's source id.
Describe the bug
Root
.apm/hooks/*.jsonhooks can be duplicated in generated merged hook configs when the_apm_sourcevalue changes between installs.The source of truth has exactly one hook entry, but generated files can contain two semantically identical hook entries. The only difference is
_apm_source, for example:<current-checkout-dir><old-checkout-dir-or-generated-source-id>The stale source id does not exist in the source of truth and appears only in previously generated hook config files.
This looks like a stale source-id healing/idempotency bug. The hook integrator removes prior entries only when
_apm_sourcematches the current package/source name. If the same root.apmcontent is later installed from a different checkout directory name, stale hook entries from the old_apm_sourcesurvive and the fresh hook is appended.To Reproduce
.apm/hooks/pre-push-review.json, ensure the hook source of truth contains onePreToolUseentry..codex/hooks.jsonentry with_apm_source: "<old-checkout-dir-or-generated-source-id>".claude/settings.jsonentry with_apm_source: "<old-checkout-dir-or-generated-source-id>"apm install/ the repository sync flow from a checkout whose directory name is different.Observed result:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "bash .codex/hooks/pre-push-review.sh" } ], "_apm_source": "<current-checkout-dir>" }, { "matcher": "Bash", "hooks": [ { "type": "command", "command": "bash .codex/hooks/pre-push-review.sh" } ], "_apm_source": "<old-checkout-dir-or-generated-source-id>" } ] } }Expected behavior
Re-running install/sync for the same root
.apmcontent should be idempotent.The generated merged hook configs should contain exactly one semantic copy of the root hook. Stale entries created by an old root-content
_apm_sourceshould either be removed or migrated.For root
.apmcontent,_apm_sourceshould preferably be stable across checkout directories, for example derived fromapm.ymlmetadata rather than the current filesystem directory name.Environment (please complete the following information):
Logs
No command failure is emitted. Validation can still pass because current verification does not check for duplicate hook entries.
A minimal local reproduction against the current hook integrator produced:
Additional context
This likely affects all merge-based hook targets:
.claude/settings.json.codex/hooks.json.gemini/settings.json.cursor/hooks.json.windsurf/hooks.jsonThese targets share the same merged-hook implementation. Copilot is different because it writes individual hook JSON files under
.github/hooks/, so it is not affected by the same in-array duplicate-entry failure mode. However, checkout-directory-derived names may still create stale-file cleanup edge cases for Copilot.Related issues:
apm installduplicates hook entries in settings.json #708 fixed duplicate growth when re-running install with the same_apm_source..claude/settings.jsonemits Cursor-formatted hooks, bare${PLUGIN_ROOT}, and duplicate entries after fresh install ofmicrosoft/azure-skills#1007 involved duplicate hook output and target-format leakage after fresh install._apm_sourceinto.claude/settings.jsoninvalidates against schema #1279 tracks_apm_sourcemaking.claude/settings.jsonfail schema validation.The distinct bug here is source-id drift for root
.apmhooks: the semantic hook is identical, but the stale entry survives because its old_apm_sourceno longer matches the current install's source id.