Claude Code's (and Copilot's) native plugin installer has zero awareness of .apm/ nesting -- it convention-scans only flat skills/, agents/, commands/, hooks.json at each plugin's root. Confirmed via strings on the installed claude binary and live installs of git@holocron/gitea@holocron/kyberforge@ holocron, all reporting Skills(0) Agents(0) Hooks(0) post ADR-0015's apm conversion. Root cause (apm_cli/core/plugin_manifest.py): apm's plugin.json compiler deliberately strips skills/agents/commands keys, assuming the host already auto-discovers those convention directories -- it has no model of .apm/ being host-visible at all. Separately, apm's own bundle exporter (apm_cli/bundle/plugin_exporter.py, behind `apm pack --format plugin`) implements the correct .apm/ -> flat mapping, but only ever targeted build/<name>-<version>/, a path nothing in marketplace.json's source: points at. scripts/sync-plugin-content.sh wraps that bundle exporter and copies its agents/, skills/, commands/, instructions/, extensions/, and merged hooks.json back into each plugin's own root as a second tracked compiled-output category -- same governance status as .claude-plugin/plugin.json: generated from .apm/, never hand-edited. tests/ subdirectories are excluded from the mirror (dev fixtures, not host-visible runtime content; several hardcode a relative repo-root walk-up sized for the .apm/-nested depth, which breaks when duplicated one level shallower). Applied for real across all 6 plugins and verified two ways: `claude plugin validate --strict` passes on every real plugin directory, and a live `claude --plugin-dir <path> -p "list skills/agents"` behavioral test confirms content is now actually discovered. Also, from the same issue #90 review round: - scripts/check-manifests.sh pointed at each plugin's root-level plugin.json (checking skills/hooks/mcpServers/agents pointer fields) -- that file was a stale near-duplicate of .claude-plugin/plugin.json nothing else read or wrote, now deleted across all 6 plugins. check-manifests.sh is rewritten to validate .claude-plugin/plugin.json instead, and drops the pointer-field checks entirely (nothing to check -- those fields are correctly absent by design). Content-presence drift is now check-plugin-content-sync's job, a new pre-push hook wired in .pre-commit-config.yaml. docs/adr/0017 records the root cause and decision in full, including two rejected alternatives (patching plugin.json's path fields directly -- apm's compiler strips them on every run; pointing marketplace.json at apm pack's build/ output -- a version-suffixed non-source directory nothing can install from without an extra build step). ADR-0015 and CONTEXT.md are updated to point at it. Refs: #90
9.9 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.
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).