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
12 KiB
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:
- Claude Code's installer scans flat convention directories only.
stringson the installedclaudebinary finds zero references to.apm/orapm.ymlanywhere. The installed plugin cache (~/.claude/plugins/cache/holocron/kyberforge/1.3.1/) mirrors the pre-conversion flatskills//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. - apm's own manifest compiler has no
.apm/→ host-path bridge, by design.apm_cli/core/plugin_manifest.py'sbuild_plugin_manifestdocstring 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.jsonfrom the bundle are excluded from the copy:plugin.jsonis already correctly generated by a separate, already-verified apm code path (build_plugin_manifest, run in the sameapm packinvocation);.mcp.jsonis 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.yamlas hook idcheck-plugin-content-syncby a parallel workstream on issue #90) — the same enforcement modelcheck-manifests.shalready applies to the other compiled-output category. - Verified two ways before landing:
claude plugin validate --strictpasses 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 confirmskyberforge:*skills and thekyberforge:apm-orchestrateagent are now actually discovered — they were not, before this fix. - The stale root-level
plugins/<name>/plugin.jsonfiles (a near-duplicate of.claude-plugin/plugin.jsonthat 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.mdandskills/<name>/SKILL.mdboth exist and must match, likewise.apm/agents/*.agent.mdvs.agents/*.agent.md, and.apm/hooks/*.jsonvs. the mergedhooks.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 validatedplugin.jsonfields 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 bycheck-plugin-content-sync, not re-implemented insidecheck-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.jsondescription.- A future apm release that ships a native
.apm/-aware plugin.json compiler (closing this gap upstream) would letsync-plugin-content.shand 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).