Files
holocron/docs/adr/0017-plugin-content-mirror-bridges-apm-to-host-discovery.md
Defame1297 430f46b8e8 docs: correct the claims this review found false
AGENTS.md told an offline agent to push with SKIP=apm-marketplace-check and
asserted that hook was "the only one whose failure mode is 'no network'".
Running all 12 pre-push hooks under a network namespace shows two fail, for
one shared cause: apm-pack-check-clean resolves the same remote entry. An
exact pin does not remove the ls-remote, so both hooks are named now.

AGENTS.md also said everything in a plugin root except .apm/ is generated.
Plugin roots carry hand-authored README.md, docs/, bin/, sources.md and
.mcp.json, so an agent would hunt for an .apm/ source that does not exist or
refuse the edit. The rule is positional: immunity belongs to the plugin root,
and anything inside a mirrored directory is still rm -rf'd.

ADR-0017 said apm strips a hooks field. The real loop is (agents, skills,
commands, instructions) -- hooks absent, instructions never mentioned -- and
it can never fire, because synthesize_plugin_json_from_apm_yml only emits the
eight identity fields. The decision stands; the mechanism was overstated. Its
mcpServers amendment is rewritten for the pointer payload and now records the
real reason: inlining bypassed apm's credential sanitizer.

ADR-0015's owner.email and version-pin passages are corrected against the apm
source, and ADR-0016 gains the disallowedTools amendment. agent-audit's
allowlist is data, so it gains disallowedTools too -- the ADR and the
validator that enforces it had come apart.

architecture.md described a root CLAUDE.md that imports two files (it imports
one, plus an RTK block) and pointed at an ADR index that does not exist.
Seven skill READMEs listed tests/ files the mirror strips, promising installed
users files their install lacks; those rows are marked source-only, with the
depth-4 template tests explicitly called out as surviving. And
plugins/kyberforge/hooks/README.md, deleted during the conversion and
preserved nowhere, is restored to a path the mirror does not own -- verified
by running a sync against a scratch copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 11:04:56 +00:00

19 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 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:

  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 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.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.
  • 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 named tests as a template asset it scaffolds from (skills/skill-author/assets/templates/tests, at depth 4). A depth-agnostic -name tests matched that too and stripped it, making the mirrored new-skill.sh die mid-run on sed: 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.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. --check alone is not the gate: the script requires either --all or an explicit list of plugin directories, and run bare it prints usage and exits 1.
  • 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 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.

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/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 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.
  • 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.json sets enabledPlugins to 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-development with an absolute --plugin-dir path, 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).