Running ADR-0017's cited live behavioral test literally from this repo's root gives a contaminated signal: this repo's own project-level .claude/settings.json enables all 6 holocron plugins, so Claude Code loads all of them rather than isolating kyberforge's discoverability. Documents the neutral-cwd + absolute --plugin-dir reproduction needed to isolate the signal.
175 lines
13 KiB
Markdown
175 lines
13 KiB
Markdown
# 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.
|
||
|
||
## 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()` (line 190, called from
|
||
`sync_one()` at line 269, real syncs only) narrowly re-injects `mcpServers` into
|
||
`.github/plugin/plugin.json` after a real sync, 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: {}`. 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.
|
||
|
||
## 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.
|
||
- **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).
|