Summary
Running apm install a second time (without wiping .claude/) appends a duplicate hook entry for every _apm_source-tagged hook already present in .claude/settings.json. The integrator extends the hook list unconditionally instead of upserting by _apm_source, so every re-install grows the file by one entry per hook per package.
Reproducer
apm version: 0.8.11 (current main).
Starting from a clean .claude/settings.json produced by apm install (one Stop hook, _apm_source: "agency", 16 lines), running apm install a second time with no other change produces:
],
"_apm_source": "agency"
+ },
+ {
+ "matcher": "",
+ "hooks": [
+ {
+ "type": "command",
+ "command": ".claude/hooks/agency/scripts/do-stop-guard.sh"
+ }
+ ],
+ "_apm_source": "agency"
}
The second entry is byte-identical to the first. A third apm install adds a third, and so on.
In my case this is reproducible with the srid/agency APM package (has a single Stop hook), consumed from a kolu checkout. Any package declaring a hook should reproduce it.
Root cause
src/apm_cli/integration/hook_integrator.py:534 — inside _integrate_merged_hooks — unconditionally extends the per-event hook list:
# Mark each entry with APM source for sync/cleanup
for entry in entries:
if isinstance(entry, dict):
entry["_apm_source"] = package_name
json_config["hooks"][event_name].extend(entries)
There is no check for existing entries tagged with the same _apm_source. The cleanup path elsewhere in the file (line 730, 775) already knows how to filter by _apm_source marker — the install path doesn't use the same guard.
Expected behaviour
apm install should be idempotent: rerunning it with no changes should leave settings.json byte-identical. Specifically, before .extend(entries), existing entries where entry["_apm_source"] == package_name for this event should be removed (or the new entries merged in place).
Proposed fix
Before the extend at line 534, strip existing entries for the same _apm_source from json_config["hooks"][event_name]. Roughly:
json_config["hooks"][event_name] = [
e for e in json_config["hooks"][event_name]
if not (isinstance(e, dict) and e.get("_apm_source") == package_name)
]
json_config["hooks"][event_name].extend(entries)
This matches the cleanup semantics already used by the uninstall/sync paths.
PR #562 (which closed #561) has "hook dedup" in the title but the body clarifies it is code-level deduplication (collapsing the three copy-pasted integrate_package_hooks_{claude,cursor,codex} methods) with "zero behavioral change". It did not address this issue. Downstream users currently reference #561 in workaround comments believing it tracks this bug — worth clarifying in docs once fixed.
Current workarounds
Consumers work around this today by wiping .claude/settings.json (or the entire .claude/ tree) before apm install, which is destructive and requires a dedicated recipe. See juspay/kolu's agents/ai.just for an example.
Summary
Running
apm installa second time (without wiping.claude/) appends a duplicate hook entry for every_apm_source-tagged hook already present in.claude/settings.json. The integrator extends the hook list unconditionally instead of upserting by_apm_source, so every re-install grows the file by one entry per hook per package.Reproducer
apm version: 0.8.11 (current main).
Starting from a clean
.claude/settings.jsonproduced byapm install(one Stop hook,_apm_source: "agency", 16 lines), runningapm installa second time with no other change produces:], "_apm_source": "agency" + }, + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/agency/scripts/do-stop-guard.sh" + } + ], + "_apm_source": "agency" }The second entry is byte-identical to the first. A third
apm installadds a third, and so on.In my case this is reproducible with the
srid/agencyAPM package (has a single Stop hook), consumed from a kolu checkout. Any package declaring a hook should reproduce it.Root cause
src/apm_cli/integration/hook_integrator.py:534— inside_integrate_merged_hooks— unconditionally extends the per-event hook list:There is no check for existing entries tagged with the same
_apm_source. The cleanup path elsewhere in the file (line 730, 775) already knows how to filter by_apm_sourcemarker — the install path doesn't use the same guard.Expected behaviour
apm installshould be idempotent: rerunning it with no changes should leavesettings.jsonbyte-identical. Specifically, before.extend(entries), existing entries whereentry["_apm_source"] == package_namefor this event should be removed (or the new entries merged in place).Proposed fix
Before the
extendat line 534, strip existing entries for the same_apm_sourcefromjson_config["hooks"][event_name]. Roughly:This matches the cleanup semantics already used by the uninstall/sync paths.
Note on #561 / #562
PR #562 (which closed #561) has "hook dedup" in the title but the body clarifies it is code-level deduplication (collapsing the three copy-pasted
integrate_package_hooks_{claude,cursor,codex}methods) with "zero behavioral change". It did not address this issue. Downstream users currently reference #561 in workaround comments believing it tracks this bug — worth clarifying in docs once fixed.Current workarounds
Consumers work around this today by wiping
.claude/settings.json(or the entire.claude/tree) beforeapm install, which is destructive and requires a dedicated recipe. See juspay/kolu'sagents/ai.justfor an example.