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:
@@ -73,17 +73,48 @@ scan, and `jq 'has("hooks")'` returns `false` for all six `.github/plugin/plugin
|
||||
`apm pack` emits. With the pointer gone and no auto-discovery to fall back on, the Copilot ecosystem
|
||||
sees zero hooks.
|
||||
|
||||
The effect is the twin of the `mcpServers` gap that ADR-0017's 2026-08-13 amendment re-injects for:
|
||||
same "string or object" type, same absence of a default, same outcome of a Copilot manifest with no
|
||||
pointer. The *mechanism* differs, and ADR-0017 is explicit about it — `mcpServers` is actively
|
||||
stripped by `build_plugin_manifest`, whereas `hooks` "was never in `build_plugin_manifest`'s strip
|
||||
list at all"; it is simply never emitted, because `apm.yml` has no key that produces one. So this is
|
||||
an absence apm never fills, not a removal to reverse. It is harmless **today only** because both
|
||||
generated files are empty (`{"hooks": {}}`), so nothing is being lost. The first real hook authored
|
||||
under `.apm/hooks/` would ship to Claude Code and silently not to Copilot.
|
||||
The effect looks like the twin of the `mcpServers` gap that ADR-0017's 2026-08-13 amendment
|
||||
re-injects for: same "string or object" type, same absence of a default, same outcome of a Copilot
|
||||
manifest with no pointer. The *mechanism* differs, and ADR-0017 is explicit about it — `mcpServers`
|
||||
is actively stripped by `build_plugin_manifest`, whereas `hooks` "was never in
|
||||
`build_plugin_manifest`'s strip list at all"; it is simply never emitted, because `apm.yml` has no
|
||||
key that produces one. So this is an absence apm never fills, not a removal to reverse.
|
||||
|
||||
Follow-up, not fixed here: decide whether `sync-plugin-content.sh` should re-inject a `hooks` pointer
|
||||
into `.github/plugin/plugin.json` the way `reinject_mcp_servers()` already does for `mcpServers`. That
|
||||
decision has a prerequisite — the two hook schemas differ (see above), so a single `hooks/hooks.json`
|
||||
pointed at from both manifests would hand Copilot a file shaped for Claude Code. Reconcile the schemas
|
||||
before wiring the pointer.
|
||||
## Why no `hooks` pointer is injected
|
||||
|
||||
**Decided (2026-08-14, PR #95): the gap stays documented rather than patched.** `sync-plugin-content.sh`
|
||||
does *not* re-inject a `hooks` pointer into `.github/plugin/plugin.json`, and a test pins that
|
||||
absence. Full reasoning is in ADR-0017's "no `hooks` pointer" amendment; the short version, because
|
||||
the one-line fix looks obvious and someone will propose it again:
|
||||
|
||||
The `mcpServers` re-injection is safe because `.mcp.json` is **one format both ecosystems read**, so
|
||||
the pointer is a true statement about the file whatever it contains. Hooks have no shared format.
|
||||
Compare the two structures above: Claude Code wants `PreToolUse` with `matcher` objects; Copilot
|
||||
requires `version: 1`, lowercase event names, and per-shell `bash`/`powershell` keys. And apm merges
|
||||
`.apm/hooks/*.json` into **exactly one** `hooks.json` with no per-target shaping — the same file
|
||||
Claude Code convention-scans. One file, two incompatible readers.
|
||||
|
||||
So a pointer would tell Copilot that a Claude-shaped file is Copilot-shaped: an incomplete manifest
|
||||
traded for a wrong one. It is not inert even today — `{"hooks": {}}` has no `version: 1`, so the
|
||||
pointer would name a file invalid against the very schema it is pointed at from. And it does not
|
||||
become correct later: whoever writes the first real hook writes it in one shape, and it is the
|
||||
Claude shape in practice, since Claude Code auto-discovers the same file and is what hooks here are
|
||||
authored against.
|
||||
|
||||
**What this costs you:** a hook authored under `.apm/hooks/` reaches Claude Code and not Copilot.
|
||||
That is a real limitation, and it is the accepted one until apm emits a per-target hooks file or the
|
||||
two schemas converge. If you need a Copilot hook today, raise it — it needs an upstream change or a
|
||||
second authoring path, not a pointer.
|
||||
|
||||
## Symlinks under `.apm/` do not survive
|
||||
|
||||
Do not author any file under `plugins/kyberforge/.apm/` as a symlink. apm's bundle exporter filters
|
||||
symlinks out of the bundle entirely and says nothing, so the file never reaches the mirror. Since
|
||||
`sync-plugin-content.sh` builds both sides of its drift comparison from that same bundle, the loss
|
||||
used to be invisible to `--check` as well. `check_apm_symlinks()` now reads the `.apm/` source tree
|
||||
directly and fails the sync with the offending path — replace the symlink with a regular file.
|
||||
|
||||
It stays quiet about one place: `.apm/<category>/<name>/tests/`, the dev-fixture directory the
|
||||
mirror excludes anyway (a symlink there loses nothing, because nothing under it is mirrored). A
|
||||
`tests/` deeper than that — `assets/templates/tests/`, a scaffolding asset the mirror does carry —
|
||||
is reported like anywhere else. See ADR-0017's symlink amendment.
|
||||
|
||||
Reference in New Issue
Block a user