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
7.0 KiB
Hooks
Reference for this plugin's hook definitions: where to edit them, where they end up, and what the host reads.
This document lives in docs/ rather than next to the hooks it describes. plugins/kyberforge/hooks/
is a generated mirror — scripts/sync-plugin-content.sh runs rm -rf on it before every
rebuild, so any hand-written file placed there is deleted on the next sync with no drift warning
(a prior copy of this document was lost exactly that way). See ADR-0017.
Where to edit
Author hooks in plugins/kyberforge/.apm/hooks/*.json. apm pack --format plugin merges every
file in that directory into a single hooks.json, which sync-plugin-content.sh copies to
plugins/kyberforge/hooks/hooks.json — the path Claude Code convention-scans. Never edit the
mirrored file; the check-plugin-content-sync pre-push hook reports it as drift.
Claude Code structure
hooks/hooks.json is read by Claude Code. Structure:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "echo 'tool used'" }
]
}
]
}
}
Events (partial list): PreToolUse, PostToolUse, Notification, Stop. Claude Code's plugin
hook set is larger — SessionStart, SessionEnd, UserPromptSubmit, PreCompact and
SubagentStop also exist — and this repo's vendored corpus does not enumerate it anywhere:
docs/research/docs/claude-code-plugins/configuration.md:100 describes the file as "Event handlers
(PreToolUse, PostToolUse, etc.)", and agent-definition.md:53 covers only the per-agent hooks
field, not the plugin-level set. Treat the four names above as the ones this repo has verified, not
as the schema. Check Claude Code's own hooks documentation before wiring an event not listed here.
Use ${CLAUDE_PLUGIN_ROOT} to reference scripts inside this plugin — the plugin runs from a cache
path after install, not its original repo location.
GitHub Copilot CLI
Copilot reads a differently-shaped hooks.json: version: 1 is required, each entry is
type: "command" with separate bash and powershell scripts, and the lifecycle points are
lowercase and differently named (sessionStart, sessionEnd, userPromptSubmitted, preToolUse,
postToolUse, errorOccurred, agentStop). See
docs/research/docs/github-copilot-plugins/configuration.md.
There is no separate Copilot hooks file at this plugin root, and — as things stand — Copilot resolves to no hooks file at all. Two corrections to an earlier revision of this document, which got both halves of this wrong:
The deleted root hooks.json was not a stale sync artifact. plugins/kyberforge/hooks.json was
added in 2287ddc (2026-06-20), the commit that created the plugin, well before
scripts/sync-plugin-content.sh existed; plugins/lint/hooks.json arrived the same way in
f326df4. Main's Copilot manifest plugins/kyberforge/plugin.json declared "hooks": "hooks.json",
and plugins/lint/plugin.json did the same — these were deliberately pointed-at Copilot hooks files,
not leftovers. The sync (38f1ba4) later took ownership of that path, and ADR-0017's 2026-08-14
amendment moved the generated file to hooks/hooks.json because that, not the plugin root, is the
path Claude Code convention-scans.
Only Claude Code resolves to hooks/hooks.json. Claude Code finds it by auto-discovery.
Copilot does not: docs/research/docs/github-copilot-plugins/configuration.md:47 types hooks as a
plugin.json field of type "string or object" with no default, so there is no convention path to
scan, and jq 'has("hooks")' returns false for all six .github/plugin/plugin.json files that
apm pack emits. With the pointer gone and no auto-discovery to fall back on, the Copilot ecosystem
sees zero hooks.
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.
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.