# Plugin roots gain a compiled flat-directory mirror of `.apm/` content so Claude Code can discover it This ADR is a follow-on correction to ADR-0015 (Microsoft APM replaces hand-authored plugin/marketplace authoring), discovered during issue #90's post-execution review. It does not restate ADR-0015's rationale for adopting `.apm/` as the authoring source of truth — see that ADR for the parent decision. It resolves the one question ADR-0015's own execution flagged as open but did not block on: whether Claude Code's installer can actually load content out of `.apm/`. It could not. **Status: executed (2026-08-13, issue #90).** `scripts/sync-plugin-content.sh` has been run against all 6 plugins; flat `agents/`, `skills/`, `commands/` (etc., wherever `.apm/` populates them), and a merged `hooks.json` now exist at each plugin root as tracked, generated files. ## Context ADR-0015's execution comment on issue #90 (2026-08-12) flagged, before merge: "it's currently unverified whether Claude Code can actually discover any skill/agent content in these plugins... This needs to be checked... before treating this conversion as functionally complete, not just manifest-complete." That caveat did not block ADR-0015 from shipping "Status: executed" — the manifest-compilation deliverable (`.claude-plugin/marketplace.json`/`plugin.json` generated from `apm.yml` + `.apm/`) was genuinely complete, and every automated gate (`apm audit --ci`, `claude plugin validate --strict` ×6, `apm marketplace check`) passed clean — so the ADR merged with the caveat noted but unresolved. The caveat turned out to be a real defect, not a formality. `claude plugin install` against all three plugins tested (`git@holocron`, `gitea@holocron`, `kyberforge@holocron`) reported `Skills (0) Agents (0) Hooks (0)`. Root cause, confirmed two independent ways: 1. **Claude Code's installer scans flat convention directories only.** `strings` on the installed `claude` binary finds zero references to `.apm/` or `apm.yml` anywhere. The installed plugin cache (`~/.claude/plugins/cache/holocron/kyberforge/1.3.1/`) mirrors the pre-conversion flat `skills/`/`agents/`/`hooks/` layout verbatim — that is what the installer actually copies and reads. `plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md`'s own "Plugin Directory Layout" table documents the same flat convention (`skills//SKILL.md`, `agents/`, `hooks/hooks.json`, all "at the plugin root, not inside `.claude-plugin/`") — this was accurate before ADR-0015 and never stopped being accurate; ADR-0015 moved plugin content without adding a bridge to it. 2. **apm's own manifest compiler has no `.apm/` → host-path bridge, by design.** `apm_cli/core/plugin_manifest.py`'s `build_plugin_manifest` docstring states directly: "Convention directories (`agents/`, `skills/`, `commands/`) are auto-discovered by the host, so they are never listed explicitly in the manifest." apm's Claude/Copilot compiler assumes plugin content already lives in those flat root-level directories; it has no model of `.apm/` nesting being host-visible at all, so it never emits anything that would point a host at `.apm/`. Separately, `apm_cli/bundle/plugin_exporter.py`'s `export_plugin_bundle` (the engine behind `apm pack --format plugin`) *does* implement the correct mapping — `.apm/agents` → `agents/`, `.apm/skills` → `skills/` (subdirs preserved), `.apm/prompts` + `.apm/commands` → `commands/` (`*.prompt.md` renamed to `*.md`), `.apm/instructions` → `instructions/`, `.apm/extensions` → `extensions/`, and `.apm/hooks/*.json` merged into one `hooks.json`. But it was only ever wired to produce a distributable bundle under `build/-/` — a path nothing in root `apm.yml`'s per-package `marketplace.packages[].source:` fields (e.g. `./plugins/bin`) or `marketplace.json`'s equivalent points at. The correct mapping existed in apm's own codebase the whole time; it was simply never connected to the path this repo's marketplace actually installs plugins from. ## Decision Each plugin root gains a second, generated content category, produced by `scripts/sync-plugin-content.sh` (wraps `apm pack --format plugin`, copies the resulting bundle's `agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and merged `hooks.json` back to the plugin root) — same governance status as `.claude-plugin/plugin.json`/`marketplace.json`: **compiled output of `.apm/`, never hand-edited.** - `.apm/` remains the sole hand-edited authoring source, unchanged from ADR-0015. - The flat mirror is what Claude Code's (and Copilot's) installer actually convention-scans at install time — it exists purely to satisfy the host's discovery contract, a contract apm's own manifest compiler deliberately does not bridge. - `plugin.json`/`apm.lock.yaml`/`.mcp.json` from the bundle are excluded from the copy: `plugin.json` is already correctly generated by a separate, already-verified apm code path (`build_plugin_manifest`, run in the same `apm pack` invocation); `.mcp.json` is hand-authored at the plugin root per ADR-0015 and is not an `.apm/` primitive. - Drift is enforced by a pre-push gate (`scripts/sync-plugin-content.sh --check`, wired into `.pre-commit-config.yaml` as hook id `check-plugin-content-sync` by a parallel workstream on issue #90) — the same enforcement model `check-manifests.sh` already applies to the other compiled-output category. - Verified two ways before landing: `claude plugin validate --strict` passes on all 6 real (non-scratch) plugin directories, and a live behavioral test (`claude --plugin-dir plugins/kyberforge -p "list your skills and agents"`) against the real committed directory confirms `kyberforge:*` skills and the `kyberforge:apm-orchestrate` agent are now actually discovered — they were not, before this fix. - The stale root-level `plugins//plugin.json` files (a near-duplicate of `.claude-plugin/plugin.json` that nothing read or wrote, flagged separately in issue #90's review) were deleted across all 6 plugins as part of the same cleanup. ## Considered options **Patch `plugin.json`'s `skills`/`agents`/`commands`/`hooks` fields to point directly at `.apm/` paths (rejected).** Claude Code's manifest schema documents these as legitimate override fields that accept custom paths — `plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md` shows a real example (`"skills": "./custom/skills/"`, `"agents": ["./custom/agents/reviewer.md"]`), so the host side of this would work. Rejected because apm's compiler is not a passive pass-through: `build_plugin_manifest` unconditionally strips these keys from every manifest it generates, on the stated assumption that convention directories are always host-auto-discovered and therefore never need an explicit pointer. Honoring this option would mean post-processing apm's compiled output on every `apm pack` run to re-inject fields apm actively removes — fighting a stable, intentional apm code path indefinitely — rather than reusing `plugin_exporter.py`'s bundle-export mapping, which already does the right thing and only needed its output redirected to a path the installer reads. **Point `marketplace.json`'s `source:` at `apm pack`'s `build/-/` output directly (rejected).** Would reuse the bundle exporter's correct mapping without adding a new script. Rejected: `build/` is a version-suffixed, regenerate-on-every-pack directory — pointing the marketplace at it would mean either committing a moving-target build artifact to version control (defeating the point of it being generated) or requiring every consumer's marketplace to run `apm pack` before install, a build step Claude Code's installer has no hook for — it clones/fetches source and scans directories; it does not execute a package manager's build command first. Copying the relevant subset back to the stable `plugins//` path — where `marketplace.json` already points — needed no change to the marketplace source model at all. ## Amendment (2026-08-13): `mcpServers` is narrowly reinjected into Copilot's `plugin.json` PR #95's review (a follow-on to this same issue #90 workstream) found a second field apm's compiler strips for the Copilot ecosystem: `build_plugin_manifest` unconditionally removes `mcpServers` from every Copilot-ecosystem `plugin.json`, its docstring stating the field is "not part of the Copilot plugin manifest schema." That claim is contradicted by this repo's own researched documentation — `plugins/kyberforge/docs/research/docs/github-copilot-plugins/ configuration.md:49` documents `mcpServers` as a valid, optional `plugin.json` field for Copilot. This is not the same situation "Considered options" above rejected. That rejection concerned fields apm strips *correctly*, on a stable and accurate premise: convention directories (`skills/`, `agents/`, `commands/`) are host-auto-discovered, so an explicit pointer is redundant by design. Here, apm's own stated justification for stripping `mcpServers` is factually wrong against documented Copilot behavior — there is no host-auto-discovery mechanism that makes an explicit `mcpServers` declaration redundant, the way there is for skills/agents/commands. Applying the same "don't fight a stable, intentional apm code path" reasoning here would mean shipping a plugin manifest known to be missing a field Copilot actually reads. Given that, `scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()` (line 190, called from `sync_one()` at line 269, real syncs only) narrowly re-injects `mcpServers` into `.github/plugin/plugin.json` after a real sync, sourced from the plugin's own `.mcp.json`, and only when it declares at least one server — matching apm's own Claude-ecosystem builder, which omits the field entirely rather than emitting `mcpServers: {}`. This is scoped to one field found to be incorrectly stripped, not a reversal of the broader position above: the rejection of patching `skills`/`agents`/`commands`/`hooks` pointers still holds, since apm's premise for stripping those remains accurate. Consequence: if a future apm release corrects the Copilot `mcpServers` omission, `reinject_mcp_servers()` and its call site become dead code and should be deleted — nothing else in this ADR depends on the reinjection existing beyond working around this specific upstream gap. ## Consequences - Git now tracks real, visible duplication: `.apm/skills//SKILL.md` and `skills//SKILL.md` both exist and must match, likewise `.apm/agents/*.agent.md` vs. `agents/*.agent.md`, and `.apm/hooks/*.json` vs. the merged `hooks.json`. This is an accepted tradeoff of bridging a gap apm itself doesn't close, not a bug — `.apm/` stays the single hand-edited source, and the drift gate (`check-plugin-content-sync`) is what keeps the mirror honest rather than trusting authors to remember to regenerate it by hand. - `scripts/check-manifests.sh`'s existing blind spot (flagged in the same issue #90 review round: it validated `plugin.json` fields that ADR-0015 already stopped populating, so a plugin shipping zero content could pass it silently) is fixed as part of the same workstream: those field checks are removed (nothing to check — the fields are correctly absent by design), and the content-presence question they were standing in for is now answered by `check-plugin-content-sync`, not re-implemented inside `check-manifests.sh`. - ADR-0015's "Status: executed" now carries a pointer to this ADR (see that ADR's Consequences) rather than being rewritten — the manifest-compilation half of its execution was correct and stands; this ADR fixes the second, previously-unverified half. - `CONTEXT.md`'s "Plugin" and "Plugin marketplace" glossary entries are updated to describe the flat mirror as a second compiled-output category, alongside the existing `.claude-plugin/plugin.json`/`marketplace.json` description. - A future apm release that ships a native `.apm/`-aware plugin.json compiler (closing this gap upstream) would let `sync-plugin-content.sh` and its drift gate be deleted outright — nothing in this ADR's decision depends on the flat mirror existing beyond satisfying the current installer's convention-scan contract. - Reference: issue #90 (https://git.dev.rkdr.net/Defame1297/holocron/issues/90).