fix(scripts): detect the .apm/ content the mirror loses silently

apm's bundle exporter drops symlinks entirely, so a symlink under .apm/ never
reaches the mirror -- and no gate could see it, because every existing check
diffs the live mirror against a bundle-derived copy and both sides lack the
file. It is an absence with nothing left to mismatch against, the only class of
.apm/ content that vanishes without a trace. check_apm_symlinks reads the .apm/
source tree, where the loss is visible, and fails both modes. Reported rather
than resolved: dereferencing would make a real sync emit content the bundle does
not contain, which is the reimplementation ADR-0017 rejects.

--check --all could also pass having verified fewer plugins than the marketplace
lists: a plugin whose .apm/ had gone was SKIPped rather than counted, and the
earlier floor only caught zero. The count is now checked against the marketplace's
own local-package list. There is no exempt state -- ADR-0015 makes .apm/ the sole
authoring source for every local plugin, so a listed plugin without one is drift.

On the Copilot hooks gap, the decision is to document, not implement. Copilot
declares no hooks path and apm emits none, which looks like the mcpServers case
-- but that exception holds because .mcp.json is one host-agnostic format both
ecosystems read, so a pointer to it is true whatever it contains. Hooks have no
shared format: Claude expects nested matcher groups under PascalCase events,
Copilot requires version: 1, camelCase, and a bash/powershell split. apm merges
.apm/hooks/*.json into exactly one file, at Claude's convention path. A pointer
would assert a Claude-shaped file is Copilot-shaped -- an incomplete manifest
traded for a wrong one -- and it is not inert today either, since {"hooks": {}}
lacks Copilot's mandatory version key. A test pins the decision, so restoring the
pointer fails until someone confronts the schema mismatch.

Tests: 77 -> 92 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
This commit is contained in:
2026-08-14 14:20:47 +00:00
parent a155af6827
commit cf625229f7
4 changed files with 459 additions and 21 deletions

View File

@@ -86,7 +86,11 @@ governance status as `.claude-plugin/plugin.json`/`marketplace.json`:
`.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.
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
@@ -217,6 +221,94 @@ convention path. (`hooks` was never in `build_plugin_manifest`'s strip list at a
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