Skip to content

[BUG] apm uninstall -g leaves Copilot hook files in ~/.copilot/hooks/ #2558

Description

@sproott

Describe the bug

Uninstalling a user-scope package (apm install -gapm uninstall -g) removes
every other primitive it deployed but leaves its Copilot hook files behind in
~/.copilot/hooks/. The orphaned file is live wiring: Copilot CLI keeps firing a
hook belonging to a package that is gone from apm.yml, apm_modules/, and the
lockfile. Reinstalling never clears it either — the path is simply never a
deletion candidate.

Copilot is the only harness affected, and only at user scope. Uninstall's
file-level removal routes each lockfile deployed_files path into a per-primitive
bucket via
BaseIntegrator.partition_managed_files,
which walks a segment trie built from every non-skills/hooks primitive prefix
and falls back to the skills/hooks prefix tuples only when the trie yields no
match:

if last_bucket is not None:
    buckets[last_bucket].add(p)
    continue
# Fall back to cross-target buckets
if p.startswith(skill_tuple):
    buckets["skills"].add(p)
elif p.startswith(hook_tuple):
    buckets["hooks"].add(p)

At user scope the Copilot target's
user_primitive_overrides
maps instructions to PrimitiveMapping("", ".md", "copilot_user_instructions")
an empty subdir, so its prefix is the bare target root .copilot/. That prefix
becomes a catch-all trie node: .copilot/hooks/<pkg>-<hook>.json matches at the
first segment, last_bucket is set to instructions_copilot, and the continue
skips the hook fallback. The hook path is handed to
InstructionIntegrator.sync_for_target,
whose copilot_user_instructions guard narrows the prefix to
.copilot/copilot-instructions.md and discards it. No integrator ever deletes the
file, and HookIntegrator.sync_integration never sees it in managed_files.

Nothing shadows .copilot/prompts/ or .copilot/agents/ — they own deeper trie
nodes and win by longest prefix. Hooks lose because they are routed by the
fallback tuples rather than by the trie. At project scope Copilot hooks land in
.github/hooks/, outside the .copilot/ root, so the shadowing never applies and
removal works. The other harnesses escape for a different reason: their hooks are
merged into a shared config (.claude/settings.json, .cursor/hooks.json, …) and
cleaned by _apm_source marker rather than by managed_files.

The symptom resembles #2245 / #2250, but those cover the merged-hook JSON path;
this is the per-file deployment path and a different root cause. Same target and
scope as #2232, also a different cause.

To Reproduce

Steps to reproduce the behavior:

  1. cd uninstall-copilot-user-hook-orphan
  2. make user — installs a package holding one hook and one prompt to user scope
    (--target copilot) under an isolated fake $HOME, then uninstalls it
  3. See the prompt is removed and reported (Cleaned up 1 integrated prompts),
    while .copilot/hooks/hookpkg-notify.json survives and no hook cleanup is
    reported at all
  4. make matrix — repeats install + uninstall at user scope for every hook-capable
    harness; only copilot reports an orphan
  5. make project — installs the same package at project scope across all those
    harnesses; every hook is removed

Expected behavior

apm uninstall -g removes the hook files it deployed to ~/.copilot/hooks/, the
same way it removes the package's prompts, and the same way project-scope
uninstall removes Copilot hooks from .github/hooks/. After uninstall no
harness still fires a hook from the removed package.

Environment (please complete the following information):

  • OS: Linux (Arch Linux)
  • Python Version: 3.14.6
  • APM Version: 0.28.0 (e041462)

Logs

make user — prompt cleaned, hook orphaned:

  [+] ./hookpkg (local)
  |-- 1 prompts integrated -> .copilot/prompts/
  |-- 1 hook(s) integrated -> .copilot/hooks/
  |   postToolUse: runs echo hookpkg-ran (notify.json)

[*] Installed 1 APM dependency in 1.9s.
--- deployed under ~/.copilot/ ---
./.copilot/hooks/hookpkg-notify.json
./.copilot/prompts/greet.prompt.md

[i] Uninstalling from user scope (~/.apm/)
[>] Uninstalling 1 package(s)...
[+] _local/hookpkg - found in apm.yml
[i] Removed _local/hookpkg from dependencies.apm in apm.yml
[i] Removed _local/hookpkg from apm_modules/
[+] Cleaned up 1 integrated prompts
[*] Uninstall complete: Removed 1 package(s) from apm.yml, Removed 1 package(s) from apm_modules/
--- after uninstall (prompt gone, hook orphaned) ---
./.copilot/hooks/hookpkg-notify.json

make matrix — user scope, every hook-capable harness:

claude: clean
codex: clean
copilot: ORPHANED ./.copilot/hooks/hookpkg-notify.json
cursor: clean
gemini: clean
windsurf: clean

make project — same package, project scope, all six harnesses:

[+] Cleaned up 1 integrated prompts
[+] Cleaned up 4 integrated commands
[+] Cleaned up 6 integrated hooks
[*] Uninstall complete: Removed 1 package(s) from apm.yml, Removed 1 package(s) from apm_modules/
--- after uninstall (no file still carries the hook command) ---

Additional context

Minimal reproduction: https://github.com/sproott/apm-bugs
(make user / make matrix / make project in
uninstall-copilot-user-hook-orphan/).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/cliCLI command surface, flags, help text (cross-cutting).priority/highShips in current or next milestonestatus/triagedInitial agentic triage complete; pending maintainer ratification (silence = approval).type/bugSomething does not work as documented.

    Type

    No type

    Projects

    Status
    In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions