Files
holocron/docs/adr/0017-plugin-content-mirror-bridges-apm-to-host-discovery.md
Defame1297 560154c727 docs(kyberforge): note repro caveat for ADR-0017 verification command
Running ADR-0017's cited live behavioral test literally from this
repo's root gives a contaminated signal: this repo's own project-level
.claude/settings.json enables all 6 holocron plugins, so Claude Code
loads all of them rather than isolating kyberforge's discoverability.
Documents the neutral-cwd + absolute --plugin-dir reproduction needed
to isolate the signal.
2026-08-13 20:51:34 +00:00

13 KiB
Raw Blame History

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/<name>/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/<name>-<version>/ — 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/<name>/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/<name>-<version>/ 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/<name>/ 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/<name>/SKILL.md and skills/<name>/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.
  • Reproduction note (2026-08-13): the live behavioral test cited in "Decision" above (claude --plugin-dir plugins/kyberforge -p "list your skills and agents") is only a clean kyberforge-only signal when run from a working directory outside this repo. Run literally as written, from this repo's root, this repo's own project-level .claude/settings.json sets enabledPlugins to true for all 6 holocron plugins (kyberforge, git, gitea, core, lint, bin), so Claude Code loads all 6 plugins' skills/agents, not just kyberforge's — conflating kyberforge's discoverability with the other 5 plugins' already-enabled content. To isolate the signal, run from a neutral cwd outside /root/ai-development with an absolute --plugin-dir path, e.g. cd /some/neutral/dir && claude --plugin-dir /root/ai-development/plugins/kyberforge -p "list your skills and agents".
  • Reference: issue #90 (https://git.dev.rkdr.net/Defame1297/holocron/issues/90).