Files
holocron/docs/adr/0017-plugin-content-mirror-bridges-apm-to-host-discovery.md
Defame1297 9c140efa2e fix(kyberforge): harden plugin-content sync, reinject Copilot mcpServers
PR #95's review of the issue #90 apm-conversion work found several
defects in scripts/sync-plugin-content.sh and the gate wired to it:

- --check claimed never to mutate the plugin root, but apm pack still
  wrote .claude-plugin/plugin.json and .github/plugin/plugin.json into
  the real plugin_dir on first-time creation. --check now packs a
  throwaway copy instead.
- check-plugin-content-sync hardcoded the six plugin directories
  instead of deriving them the way check-manifests.sh already does.
  Added an --all flag that parses .claude-plugin/marketplace.json, and
  simplified the pre-commit hook to use it.
- A missing plugin_dir and one that legitimately has no .apm/ yet both
  reported SKIP/success; a missing directory now FAILs.
- The dispatch loop backgrounded every plugin with no concurrency cap,
  unlike the JOBS-bounded pattern this same PR added to
  tests/run-bats.sh and tests/run-tests.sh. Added the same
  bash-3.2-safe getconf + batched-wait cap here for consistency.
- Per-plugin scratch/log/status files were keyed only by basename, with
  no collision guard across arguments; added a fail-fast check.
- sync_hooks_json()'s trailing-newline normalization was duplicated
  between its --check and write branches; factored into one helper.
- tests/test-sync-plugin-content.sh set two competing `trap ... EXIT`
  statements, so the first (cleaning up $FIXTURE) was silently
  replaced by the second and its tmp dir leaked every run. Adopted the
  track()/CLEANUP_DIRS pattern already used in
  tests/test-check-release-needed.sh.

Separately: apm's Copilot-ecosystem plugin.json builder unconditionally
strips mcpServers, citing (in its own docstring) that the field is out
of schema for Copilot -- a claim this repo's own researched Copilot
plugin schema docs contradict. reinject_mcp_servers() narrowly restores
it from the plugin's .mcp.json on real syncs only, regenerating
plugins/bin/.github/plugin/plugin.json (the only plugin that currently
declares any MCP servers). Documented as an amendment to ADR-0017,
since it's a deliberate, narrow exception to that ADR's rejection of
patching apm's compiled output -- apm's premise for stripping
skills/agents/commands/hooks pointers is still accurate; its premise
for stripping mcpServers is not.

All 12 assertions in tests/test-sync-plugin-content.sh pass
individually, plus 5 new regression tests added for this round; the
full bats and shell-script suites are green; shellcheck is clean.

Refs: #95
ADR: 0017
2026-08-13 19:47:36 +00:00

12 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.
  • Reference: issue #90 (https://git.dev.rkdr.net/Defame1297/holocron/issues/90).