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
255 lines
19 KiB
Markdown
255 lines
19 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 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).
|