ADR-0014 gains a dated correction: skill-size-check now sources the
boundary resolver from kyberforge (ef27c97), so restoring the external
hook contract needs it made self-contained first. ADR-0017's status
reflects its supersession, architecture.md and gates.md carry the
current duplication counts and reason, gates.md defines vacuous green
inline, and the gitleaks lesson is marked historical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
361 lines
27 KiB
Markdown
361 lines
27 KiB
Markdown
# Plugin roots gain a compiled flat-directory mirror of `.apm/` content so Claude Code can discover it
|
||
|
||
**Superseded by:** ADR-0024 (apm is the only supported install path; the flat content mirror is
|
||
deleted). The mirror this ADR created — `plugins/<name>/{skills,agents,hooks}/` — has been deleted,
|
||
along with `scripts/sync-plugin-content.sh`, its test suite, and the `check-plugin-content-sync`
|
||
pre-push gate. Native `claude plugin install` is no longer a supported path, so the host discovery
|
||
contract this ADR bridged is no longer one this repo satisfies. The diagnosis below is still
|
||
accurate about how Claude Code's installer works; what changed is that nothing consumes it. The
|
||
`mcpServers`, `hooks`-pointer and `hooks/hooks.json` amendments below are moot with the artifacts
|
||
they governed; the symlink amendment's underlying gap is not — see ADR-0024's consequences. This
|
||
ADR's content is kept below as the historical record; it is no longer the current model.
|
||
|
||
---
|
||
|
||
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: superseded by ADR-0024** (originally executed 2026-08-13, issue #90; the text below is the record of that execution). `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. `--all` derives
|
||
its work list from `marketplace.json`, a generated file, so it asserts its own coverage against
|
||
that list: it fails if it verified fewer plugins than the marketplace declares, not merely if it
|
||
verified none. A listed plugin whose `.apm/` has gone missing is skipped by the per-plugin sync
|
||
and would otherwise let the gate report success over a shrinking work list.
|
||
- 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.
|
||
|
||
Read "the host finds it by auto-discovery" above as **Claude Code**, not both hosts. Copilot has no
|
||
default for `hooks` and so discovers none — a real gap, examined and deliberately left open in the
|
||
next amendment.
|
||
|
||
## Amendment (2026-08-14): no `hooks` pointer is re-injected for Copilot — the gap stays documented
|
||
|
||
PR #95's review found a third field, and it looks like the `mcpServers` amendment's exact twin:
|
||
`plugins/kyberforge/docs/research/docs/github-copilot-plugins/configuration.md:47` types `hooks` as
|
||
a `plugin.json` field, **"string or object"**, with **no default** — so Copilot has no convention
|
||
path to scan — and `jq 'has("hooks")'` returns `false` for all six `plugins/*/.github/plugin/plugin.json`.
|
||
Copilot therefore resolves **zero hooks from every plugin in this repo**. The facts are not in
|
||
dispute; the remedy is.
|
||
|
||
State the mechanism correctly first, because it differs from `mcpServers` and the amendment above
|
||
depends on that distinction. `mcpServers` is *actively removed* — `build_plugin_manifest` runs
|
||
`manifest.pop("mcpServers", None)` on every Copilot manifest. `hooks` was **never in that strip
|
||
list** (its field list is `("agents", "skills", "commands", "instructions")`, and the loop is dead
|
||
code besides — see "Considered options"). This is an absence apm never fills, not a removal to
|
||
reverse.
|
||
|
||
**Decision: do not re-inject. Document the gap.** The `mcpServers` exception was granted on three
|
||
conditions, and `hooks` meets only two of them:
|
||
|
||
1. *A documented host schema field.* Met — `hooks` is in Copilot's own field table.
|
||
2. *apm has no input that produces it.* Met — `apm.yml` has no key for it.
|
||
3. *The payload is correct for the host regardless of content.* **Not met**, and this is the whole
|
||
difference. `.mcp.json` is one host-agnostic format that both ecosystems read, so the string
|
||
`".mcp.json"` is a true statement about the file no matter what is in it. Hooks have no such
|
||
shared format: Claude Code reads
|
||
`{"hooks": {"PreToolUse": [{"matcher": ..., "hooks": [...]}]}}` while Copilot requires
|
||
`{"version": 1, "hooks": {"sessionStart": [{"type": "command", "bash": ..., "powershell": ...}]}}`
|
||
— a mandatory `version`, lowercase and differently-named lifecycle events, and per-shell script
|
||
keys. apm's exporter merges `.apm/hooks/*.json` into **exactly one** `hooks.json` with no
|
||
per-target shaping (`_collect_hooks_from_apm`, `apm_cli/bundle/plugin_exporter.py`), and that one
|
||
file also sits at Claude Code's convention path, where Claude Code will read it whatever it
|
||
contains. So there is exactly one file and two incompatible readers of it.
|
||
|
||
A `hooks` pointer would therefore assert that a Claude-shaped file is Copilot-shaped. That trades an
|
||
*incomplete* manifest for a *wrong* one, which is the opposite of the `mcpServers` amendment's
|
||
reasoning ("shipping the manifest as apm produces it would ship a manifest known to be incomplete").
|
||
|
||
The "it changes nothing today, so it is zero-risk and correct-by-construction for the first real
|
||
hook" argument does not survive the same check, in both halves. It is not inert today: both
|
||
`hooks/hooks.json` files are `{"hooks": {}}`, which lacks the `version: 1` Copilot's schema
|
||
requires, so a pointer would name a file invalid against the schema it is being pointed at from —
|
||
a change from "declares no hooks" to "declares hooks, at an invalid file". And it is not
|
||
correct-by-construction later: whoever writes the first real hook writes it in one of the two
|
||
shapes, and the pointer is wrong in the Claude-shaped case (the case that actually happens, since
|
||
Claude Code auto-discovers the same file and is what these hooks are authored against) while the
|
||
Copilot-shaped case breaks Claude Code instead. No content makes both readers correct.
|
||
|
||
What would change this decision is upstream, not local: apm emitting a per-target hooks file (at
|
||
which point a pointer names a file genuinely shaped for its reader), or the two hook schemas
|
||
converging. Until then the honest artifact is a documented gap, recorded for authors in
|
||
`plugins/kyberforge/docs/hooks.md` and pinned by a test asserting the Copilot manifest carries no
|
||
`hooks` key — so that adding one is a deliberate act that has to confront the schema mismatch,
|
||
rather than a plausible-looking one-liner nobody re-derives.
|
||
|
||
This does not weaken the `mcpServers` amendment. That exception was narrow on purpose, and this is
|
||
what its third condition was for.
|
||
|
||
## Amendment (2026-08-14): symlinks under `.apm/` are dropped, and are now reported
|
||
|
||
apm's bundle exporter filters symlinks out of the bundle entirely — `f.is_file() and not
|
||
f.is_symlink()` in `_collect_flat` and `_collect_recursive`, and the same test in
|
||
`_collect_hooks_from_apm` (`apm_cli/bundle/plugin_exporter.py`). It emits no warning. A symlink
|
||
placed under a plugin's `.apm/` therefore never reaches the mirror, and until now nothing said so.
|
||
|
||
This was **silent content loss, not drift**, and that distinction is why no existing gate caught it.
|
||
Every other check in `sync-plugin-content.sh` compares the live mirror against a freshly synced
|
||
copy — and both sides are built from that same bundle. The symlink is absent from both, they agree,
|
||
and `--check` exits 0. There is no mismatch to detect, only an absence with nothing left to
|
||
mismatch against. Reproduced on a fixture: `ln -s real.md link.md` under `.apm/skills/hello/`
|
||
produced a mirror with no `link.md` and a `--check` at exit 0.
|
||
|
||
`check_apm_symlinks()` therefore reads the `.apm/` **source** tree directly — the only place the
|
||
loss is visible — and reports each symlink in both modes, failing the run. It is reported rather
|
||
than resolved: dereferencing and copying the target would make a real sync emit content the bundle
|
||
does not contain, which is precisely the "reimplement apm's mapping outside apm" this ADR rejects.
|
||
Telling the author is the in-contract half.
|
||
|
||
The scan covers only the `.apm/` directories apm's exporter actually reads
|
||
(`agents`, `skills`, `prompts`, `commands`, `instructions`, `extensions`, `hooks`), and carves out
|
||
`<category>/<name>/tests` to match the mirror's own exclusion — that subtree is not mirrored whether
|
||
or not it holds a symlink, so nothing is lost there. The carve-out is depth-scoped for the same
|
||
reason the `tests/` exclusion is: a symlink under `assets/templates/tests` sits in content the
|
||
mirror does carry, and is reported.
|
||
|
||
## 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. Superseded 2026-08-17: CONTEXT.md was
|
||
cut back to one-line definitions and no longer describes either compiled-output category;
|
||
`docs/spec/architecture.md` is where the mirror is documented.
|
||
- 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).
|