Files
holocron/docs/adr/0017-plugin-content-mirror-bridges-apm-to-host-discovery.md
Defame1297 38f1ba4e03 fix(kyberforge): bridge apm content to Claude Code's flat plugin discovery
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
2026-08-13 16:59:03 +00:00

135 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
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.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.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.
- Drift is enforced by a pre-push gate (`scripts/sync-plugin-content.sh --check`, 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.
- 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 `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.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.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 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.
- Reference: issue #90 (https://git.dev.rkdr.net/Defame1297/holocron/issues/90).