The mirror's `tests/` exclusion was depth-agnostic, so it deleted `skill-author/assets/templates/tests/` — a template the skill scaffolds FROM — alongside the depth-2 dev fixtures it was meant to drop. Since ADR-0017 makes the mirror the installed content, the shipped scaffolder was broken: the mirror copy of `new-skill.sh` exited 2 on `sed: can't read .../tests/README.md`, leaving a half-written skill, while the byte-identical `.apm/` copy exited 0. `--check` was green about it. Check mode was restructured rather than patched because `diff -x` matches a basename at any depth and cannot express the depth-2 scoping the fix needs — the two modes could not be made to agree by construction. Check mode now runs the real `sync_dir` into a throwaway root and diffs with no exclusions, leaving the exclusion rule and the hooks destination each in exactly one place. Also fixed here, all previously invisible to `--check`: - Merged hooks were written to `<plugin>/hooks.json`, which Claude Code does not convention-scan, while ADR-0017 itself quoted `hooks/hooks.json` as the contract. Moved, with the legacy path cleaned up as stale. No `hooks` pointer is added to `plugin.json`, so this does not reopen the option ADR-0017 rejected. - Only the first drift per plugin was reported: `diff | sed` returns 1 under `pipefail`, and `set -e` killed the subshell before the remaining checks and before `FAIL=1`. - File-mode and symlink drift were invisible, so `--check` and a real sync disagreed; a find-based type/mode manifest now covers both. The tests pinned almost none of this — the stale-skill wipe, the check-mode stale branch, three `MIRROR_DIRS` entries and the hooks newline normalization could each be deleted with the suite still green. All are now mutation-tested. Refs: #90 ADR: 0017 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
17 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 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. - 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(), called from sync_one(),
narrowly re-injects mcpServers into .github/plugin/plugin.json after apm pack runs, 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: {}. 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 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.
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 on the same line it says "All content directories must be at the plugin root,
not inside .claude-plugin/"
(plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md:100). The
implementation read "at the plugin root" and dropped the file there; the row it was reading names
hooks/hooks.json. 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 — apm's build_plugin_manifest strips pointer fields unconditionally
and is right to, because convention directories need no pointer. Writing to the convention path is
what makes that premise true here rather than something to fight.
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.- 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).