apm becomes the only supported install path. The flat mirror at each plugin root existed solely so Claude Code's native `claude plugin install` could convention-scan plugin content (ADR-0017). With no native consumers, it cost ~20,000 tracked lines plus ~2,100 lines of sync tooling and ~88s of every push to guard content apm never reads — and its only automated gate, `claude plugin validate --strict`, passes on a plugin with zero content, so it could not detect the defect ADR-0017 was created to fix. Removes the mirror (213 files), the six per-plugin manifest pairs, sync-plugin-content.sh, its 1,289-line test, the orphaned marketplace-plugins.sh, and the check-plugin-content-sync and validate-plugins pre-push hooks. The root `marketplace:` block and .claude-plugin/ catalogue stay: apm's own marketplace consumers read that same file, so `<name>@holocron` short names keep working. tests/run-bats.sh now excludes .claude/skills/. apm installs from .apm/, which carries the tests/ dirs the mirror stripped, so deployed .bats files would otherwise be discovered and double-run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
27 KiB
Plugin roots gain a compiled flat-directory mirror of .apm/ content so Claude Code can discover it
Superseded by: ADR-0024 (apm is the only supported install path; the flat content mirror is
deleted). The mirror this ADR created — plugins/<name>/{skills,agents,hooks}/ — has been deleted,
along with scripts/sync-plugin-content.sh, its test suite, and the check-plugin-content-sync
pre-push gate. Native claude plugin install is no longer a supported path, so the host discovery
contract this ADR bridged is no longer one this repo satisfies. The diagnosis below is still
accurate about how Claude Code's installer works; what changed is that nothing consumes it. The
mcpServers, hooks-pointer and hooks/hooks.json amendments below are moot with the artifacts
they governed; the symlink amendment's underlying gap is not — see ADR-0024's consequences. This
ADR's content is kept below as the historical record; it is no longer the current model.
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 file now exist at each plugin root as tracked, generated files. The
merged hooks file lands at hooks/hooks.json, not at the plugin root itself — see the second
amendment below, which corrects the path this ADR originally recorded.
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 file back to
the plugin root — the hooks file to hooks/hooks.json, per the second amendment below) — 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.- Dev-fixture
tests/directories are excluded too — they are dev-time fixtures no plugin host ever needs to discover, and several reference their own repo root through a hardcoded relative walk-up sized for.apm/-nested depth, so a copy one directory level shallower breaks the duplicate and double-runs the original under repo-wide bats discovery. The exclusion is depth-scoped to<category>/<name>/tests, deliberately: a skill may legitimately ship a directory literally namedtestsas a template asset it scaffolds from (skills/skill-author/assets/templates/tests, at depth 4). A depth-agnostic-name testsmatched that too and stripped it, making the mirrorednew-skill.shdie mid-run onsed: can't read .../tests/README.md— the scaffolder seds its way through the template tree file by file. Scaffolding assets survive; fixtures do not. - Drift is enforced by a pre-push gate (
scripts/sync-plugin-content.sh --check --all, 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.--checkalone is not the gate: the script requires either--allor an explicit list of plugin directories, and run bare it prints usage and exits 1.--allderives its work list frommarketplace.json, a generated file, so it asserts its own coverage against that list: it fails if it verified fewer plugins than the marketplace declares, not merely if it verified none. A listed plugin whose.apm/has gone missing is skipped by the per-plugin sync and would otherwise let the gate report success over a shrinking work list. - 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 content-pointer 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 never emits such a pointer and would have to be
worked around on every run to make it do so.
Be precise about the mechanism, because an earlier revision of this ADR overstated it. apm 0.28.0's
build_plugin_manifest (apm_cli/core/plugin_manifest.py) does carry a strip loop, but its field
list is ("agents", "skills", "commands", "instructions") — hooks is not in it, and
instructions is, which this ADR previously did not mention. More to the point, that loop can
never fire: the manifest it operates on comes from synthesize_plugin_json_from_apm_yml
(apm_cli/deps/plugin_parser.py), which only ever emits name, version, description,
author, license, homepage, repository and keywords. The pointer fields are absent from
apm's output because apm.yml has no schema for them, not because apm actively removes them — the
pop loop is defensive dead code against a manifest shape apm does not produce.
The rejection is unaffected by that correction, only its framing. Honoring this option would still
mean post-processing apm's compiled output on every apm pack run to add fields apm's schema has
no way to express, 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. What it
is not is a fight against a load-bearing apm code path — the honest statement is that apm has no
input for these fields, and inventing one downstream is a workaround this ADR did not need.
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, revised 2026-08-14): Copilot's plugin.json gets an mcpServers path
PR #95's review (a follow-on to this same issue #90 workstream) found a second field apm's
compiler drops for the Copilot ecosystem: build_plugin_manifest runs
manifest.pop("mcpServers", None) on 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, typed "string or object — MCP server
config path or inline definitions."
This is not the same situation "Considered options" above rejected. There, apm emits no pointer because its schema has no input for one and the host auto-discovers the directories anyway, so nothing is missing. Here a field Copilot actually reads is actively removed on a premise that is wrong against documented Copilot behavior, and there is no auto-discovery mechanism that makes it redundant. Shipping the manifest as apm produces it would ship a manifest known to be incomplete.
scripts/sync-plugin-content.sh's reinject_mcp_servers(), called from sync_one(), therefore
sets mcpServers on .github/plugin/plugin.json after apm pack runs — to the string
".mcp.json", the path form of the documented type, not the resolved server objects. Only when
the plugin's .mcp.json declares at least one server, matching apm's own Claude-ecosystem builder,
which omits the field entirely rather than emitting mcpServers: {}.
The payload is a path because an inlined object is a credential-leak path. The original
implementation copied .mcp.json's resolved mcpServers object into the manifest with jq. That
route bypasses apm's own _sanitize_mcp_servers() (apm_cli/core/plugin_manifest.py), which
strips credential keys and redacts secret values out of .mcp.json precisely because — in its own
words — "copying them verbatim into a committed plugin.json would exfiltrate them into the
distributed artefact." Today's .mcp.json files here carry no env block, so nothing leaked; the
first one that did would have written a live token into a tracked, published manifest, with the
sanitizer sitting one code path away and never invoked. A path reference cannot carry a secret at
all: the manifest names a file, and resolution happens in the host at load time. This also matches
apm's documented posture for MCP secrets — microsoft-apm/configuration.md:96-98 requires ${VAR}
indirection so secrets are "never committed to the manifest."
Both modes re-inject, not just real syncs: real mode writes into the plugin root directly,
--check into its throwaway copy first, so the manifest diff compares against the same content a
real sync would actually produce (see the script's own header). A check-mode re-injection is what
keeps --check from reporting permanent phantom drift on every plugin that ships an .mcp.json.
This remains scoped to one field found to be incorrectly dropped. It does not reopen the content-pointer option rejected above: those fields stay absent because apm has no schema input for them and the host needs no pointer, which is a different situation from a documented field being actively removed.
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.
Line numbers are deliberately omitted above. An earlier revision of this amendment cited
reinject_mcp_servers() at line 190 and its call site at line 269; both had already moved by the
next review round of the same PR, and moved again with the edits recorded in the amendment below.
A function name is stable enough to grep for; a line number in an ADR is stale by the next commit.
Amendment (2026-08-14): the merged hooks file lands at hooks/hooks.json, not the plugin root
As originally executed, sync-plugin-content.sh wrote the merged hooks file to
plugins/<name>/hooks.json. That path is scanned by nothing. Claude Code convention-scans
hooks/hooks.json, and the "Plugin Directory Layout" table this ADR's own root-cause analysis
quotes above says so:
plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md:100 is the row naming
hooks/hooks.json, six lines below the table's preamble at :94 — "All content directories must
be at the plugin root, not inside .claude-plugin/". The two are not the same line; an earlier
revision of this amendment said they were. The implementation read the preamble's "at the plugin
root" and dropped the file there, without reading the row that names the path. So this ADR shipped
with the contract quoted correctly in its diagnosis and violated in its output — the flat mirror
bridged skills and agents into discovery and left hooks exactly as undiscoverable as before the
fix.
The merged file therefore moves to plugins/<name>/hooks/hooks.json. A root-level hooks.json
left over from a prior sync is stale output: a real sync deletes it, --check reports it as
drift. The real sync produced exactly these working-tree changes — plugins/kyberforge/hooks.json
and plugins/lint/hooks.json deleted, plugins/kyberforge/hooks/hooks.json and
plugins/lint/hooks/hooks.json created. Only those two plugins have an .apm/hooks/ tree, so
only those two grow a mirrored hooks file at all.
This does not reopen the "patch plugin.json pointer fields" option rejected above. The move
needs no hooks pointer in plugin.json: hooks/hooks.json is the convention path, so the
host finds it by auto-discovery, exactly as it finds skills/ and agents/. The rejection stands
for the reason it was made, once stated accurately — apm emits no pointer field for any of these,
because apm.yml has no key that produces one, and none is needed when content sits at the
convention path. (hooks was never in build_plugin_manifest's strip list at all; see the
corrected mechanism note under "Considered options".) Writing to the convention path is what makes
the no-pointer premise true here rather than something to work around.
Read "the host finds it by auto-discovery" above as Claude Code, not both hosts. Copilot has no
default for hooks and so discovers none — a real gap, examined and deliberately left open in the
next amendment.
Amendment (2026-08-14): no hooks pointer is re-injected for Copilot — the gap stays documented
PR #95's review found a third field, and it looks like the mcpServers amendment's exact twin:
plugins/kyberforge/docs/research/docs/github-copilot-plugins/configuration.md:47 types hooks as
a plugin.json field, "string or object", with no default — so Copilot has no convention
path to scan — and jq 'has("hooks")' returns false for all six plugins/*/.github/plugin/plugin.json.
Copilot therefore resolves zero hooks from every plugin in this repo. The facts are not in
dispute; the remedy is.
State the mechanism correctly first, because it differs from mcpServers and the amendment above
depends on that distinction. mcpServers is actively removed — build_plugin_manifest runs
manifest.pop("mcpServers", None) on every Copilot manifest. hooks was never in that strip
list (its field list is ("agents", "skills", "commands", "instructions"), and the loop is dead
code besides — see "Considered options"). This is an absence apm never fills, not a removal to
reverse.
Decision: do not re-inject. Document the gap. The mcpServers exception was granted on three
conditions, and hooks meets only two of them:
- A documented host schema field. Met —
hooksis in Copilot's own field table. - apm has no input that produces it. Met —
apm.ymlhas no key for it. - The payload is correct for the host regardless of content. Not met, and this is the whole
difference.
.mcp.jsonis one host-agnostic format that both ecosystems read, so the string".mcp.json"is a true statement about the file no matter what is in it. Hooks have no such shared format: Claude Code reads{"hooks": {"PreToolUse": [{"matcher": ..., "hooks": [...]}]}}while Copilot requires{"version": 1, "hooks": {"sessionStart": [{"type": "command", "bash": ..., "powershell": ...}]}}— a mandatoryversion, lowercase and differently-named lifecycle events, and per-shell script keys. apm's exporter merges.apm/hooks/*.jsoninto exactly onehooks.jsonwith no per-target shaping (_collect_hooks_from_apm,apm_cli/bundle/plugin_exporter.py), and that one file also sits at Claude Code's convention path, where Claude Code will read it whatever it contains. So there is exactly one file and two incompatible readers of it.
A hooks pointer would therefore assert that a Claude-shaped file is Copilot-shaped. That trades an
incomplete manifest for a wrong one, which is the opposite of the mcpServers amendment's
reasoning ("shipping the manifest as apm produces it would ship a manifest known to be incomplete").
The "it changes nothing today, so it is zero-risk and correct-by-construction for the first real
hook" argument does not survive the same check, in both halves. It is not inert today: both
hooks/hooks.json files are {"hooks": {}}, which lacks the version: 1 Copilot's schema
requires, so a pointer would name a file invalid against the schema it is being pointed at from —
a change from "declares no hooks" to "declares hooks, at an invalid file". And it is not
correct-by-construction later: whoever writes the first real hook writes it in one of the two
shapes, and the pointer is wrong in the Claude-shaped case (the case that actually happens, since
Claude Code auto-discovers the same file and is what these hooks are authored against) while the
Copilot-shaped case breaks Claude Code instead. No content makes both readers correct.
What would change this decision is upstream, not local: apm emitting a per-target hooks file (at
which point a pointer names a file genuinely shaped for its reader), or the two hook schemas
converging. Until then the honest artifact is a documented gap, recorded for authors in
plugins/kyberforge/docs/hooks.md and pinned by a test asserting the Copilot manifest carries no
hooks key — so that adding one is a deliberate act that has to confront the schema mismatch,
rather than a plausible-looking one-liner nobody re-derives.
This does not weaken the mcpServers amendment. That exception was narrow on purpose, and this is
what its third condition was for.
Amendment (2026-08-14): symlinks under .apm/ are dropped, and are now reported
apm's bundle exporter filters symlinks out of the bundle entirely — f.is_file() and not f.is_symlink() in _collect_flat and _collect_recursive, and the same test in
_collect_hooks_from_apm (apm_cli/bundle/plugin_exporter.py). It emits no warning. A symlink
placed under a plugin's .apm/ therefore never reaches the mirror, and until now nothing said so.
This was silent content loss, not drift, and that distinction is why no existing gate caught it.
Every other check in sync-plugin-content.sh compares the live mirror against a freshly synced
copy — and both sides are built from that same bundle. The symlink is absent from both, they agree,
and --check exits 0. There is no mismatch to detect, only an absence with nothing left to
mismatch against. Reproduced on a fixture: ln -s real.md link.md under .apm/skills/hello/
produced a mirror with no link.md and a --check at exit 0.
check_apm_symlinks() therefore reads the .apm/ source tree directly — the only place the
loss is visible — and reports each symlink in both modes, failing the run. It is reported rather
than resolved: dereferencing and copying the target would make a real sync emit content the bundle
does not contain, which is precisely the "reimplement apm's mapping outside apm" this ADR rejects.
Telling the author is the in-contract half.
The scan covers only the .apm/ directories apm's exporter actually reads
(agents, skills, prompts, commands, instructions, extensions, hooks), and carves out
<category>/<name>/tests to match the mirror's own exclusion — that subtree is not mirrored whether
or not it holds a symlink, so nothing is lost there. The carve-out is depth-scoped for the same
reason the tests/ exclusion is: a symlink under assets/templates/tests sits in content the
mirror does carry, and is reported.
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/hooks.json(see the 2026-08-14 amendment above for that path). 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. Superseded 2026-08-17: CONTEXT.md was cut back to one-line definitions and no longer describes either compiled-output category;docs/spec/architecture.mdis where the mirror is documented.- 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. - 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.jsonsetsenabledPluginsto 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-developmentwith an absolute--plugin-dirpath, 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).