A five-agent review of718c79aandd2480b8found no skill, agent or hook regressions (39 skills before and after) and confirmed both hook removals are genuinely moot -- verified against the tree, not taken on the commit's word. It did find one functional regression (fixed separately) and this documentation drift. Counting errors, all from a git pathspec `*` crossing `/`: - 17 .bats files shipped to consumers is really 10; 17 counted tracked paths merely containing /tests/, one of them a template asset - "roughly 88s off every push" is ~92.4s; 88 omitted validate-plugins - "roughly 70% of each plugin remains live" holds only for kyberforge; the real spread is 44.3% (bin) to 70.6%, now a table - the pre-push enforcement row was half-corrected: 33 entries stood unstruck (now 27) and 14 -> 11 switched counting basis mid-sentence - the root .claude-plugin/plugin.json was described as "kept"; it has never been tracked gates.md said "Ten hooks" above a nine-row table (11 was decremented for one removal, not two), and "both need the claude CLI" for one remaining validator. Its pretty-format-json exclude rationale claimed six alternations expanding to sixteen files in a passage headed "Mind which number you are quoting" -- four alternations, two live files; the two dead ones are dropped from the pattern. check-useless-excludes could not catch this: it only flags an exclude matching nothing at all. ADR-0024 cited ADR-0006 for a patch-bump rule it does not contain and which ADR-0015 explicitly retired; stated apm's marketplace probe order backwards (.claude-plugin/ is the last candidate, not the first, so the earlier .github/plugin/ deletion only demoted resolution); undercounted apm's skill-deploying targets as seven when there are fifteen; and never recorded that validate-plugins was removed. The symlink hedge is resolved: apm_cli/security/gate.py's ignore_non_content() drops symlinks silently on deploy while apm_modules/ materialization dereferences them, so content survives that far and vanishes at install. Accepted with no replacement guard, per decision -- kyberforge/docs/hooks.md previously asserted a guard that had been deleted with its script. Four plugin READMEs still advertised `claude plugin install`; ADRs 0001, 0006, 0013, 0014, 0015 and 0019 described deleted machinery in the present tense, 0019 most consequentially as the live justification for the SessionStart hook's .apm/ path. CONTEXT.md's "apm package" entry forbade "plugin" while using it in its own body, and "Output profile" lost the antecedent for "one catalogue serves both". run-tests.sh gains the .claude/skills/ exclusion run-bats.sh already had. Latent today -- no test-*.sh lives under any .apm/skills/*/tests/ -- but apm now deploys those directories, so one would be discovered twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
8.8 KiB
Hooks
Reference for this plugin's hook definitions: where to edit them, what apm does with them, and what the host ends up reading.
Where to edit
Author hooks in plugins/kyberforge/.apm/hooks/*.json. .apm/ is the only content source and
apm install is the only supported install path (ADR-0024) — there is no generated mirror at the
plugin root and no per-plugin plugin.json, so .apm/hooks/ is both where you edit and what ships.
apm merges every *.json in that directory into a single hook definition and writes the event
bindings into the consuming project's .claude/settings.json; see "Deployed shape" below. Scripts a
hook invokes live in the same directory, alongside the JSON that references them.
Hook file structure
The shape Claude Code reads, and therefore the shape to author under .apm/hooks/:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "echo 'tool used'" }
]
}
]
}
}
Events (partial list): PreToolUse, PostToolUse, Notification, Stop, and SessionStart
(verified end-to-end by the hook below). Claude Code's plugin hook set is larger — 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 five 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.
Referencing a script — use the .apm/ path
Use ${CLAUDE_PLUGIN_ROOT} to reference scripts inside this plugin; the plugin runs from a cache or
apm_modules/ path after install, not its original repo location. Address the script at its
.apm/ path:
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
The obvious-looking ${CLAUDE_PLUGIN_ROOT}/hooks/check-apm-current.sh does not work, and fails
quietly enough to be worth spelling out. apm resolves the placeholder against the installed package
root, and there is no hooks/ directory there at all — the package's content is .apm/. apm prints
Hook script not found: .../hooks/check-apm-current.sh and then deploys the hook anyway, pointing at
a path with no file behind it.
tests/test-apm-current-hook.sh pins the reference so a well-meaning "simplification" back to
hooks/ fails the suite rather than silently disabling the hook.
Deployed shape
At install, apm merges the event bindings into .claude/settings.json, copies the referenced script
to .claude/hooks/<pkg>/ (preserving its executable bit, preserving the .apm/hooks/ subpath), and
rewrites command to a ${CLAUDE_PROJECT_DIR}-relative path. Ownership of its own entries is
tracked in a .claude/apm-hooks.json sidecar, so an uninstall removes them without touching
hand-authored hooks. Both .claude/hooks/ and the sidecar are gitignored install output.
Note that apm's executable-trust gate is off unless the consuming project's apm.yml has an
executables: block — without one, package hooks deploy with no prompt. The allow key is
version-pinned (kyberforge#<version>), so a version bump on one side alone stops the hook
deploying; check-executables-allow-sync is the pre-push gate that catches it. See ADR-0019.
The SessionStart hook
check-apm-current.sh keeps an apm-consumed install level with its remote: it runs apm outdated,
and if anything is behind, runs apm update --yes and returns reloadSkills: true so the running
session picks up the redeployed content. Rationale, measurements, and the failure modes are in
ADR-0019.
Where it looks for the lockfile. The hook resolves a project directory as ${CLAUDE_PROJECT_DIR}
when the host exports it (Claude Code does, for SessionStart hooks) and the current directory
otherwise, then exits silently unless that directory holds an apm.lock.yaml — which is what makes
it inert in any project that does not consume packages through apm. Both apm invocations run
against the same resolved directory. The earlier spelling checked a bare apm.lock.yaml against the
session's cwd, so a session opened in a subdirectory of an apm-consuming repo no-opped silently.
Keep the cwd fallback: a host that sets no CLAUDE_PROJECT_DIR must still get inert-but-harmless
behaviour, not an unset-variable error.
The timeout in hooks.json must exceed the script's own budget. The script spends at most
timeout 60 apm outdated plus timeout 300 apm update; the hook entry declares timeout: 380, the
sum plus a buffer. Set it lower and a slow remote gets the hook SIGKILLed mid-apm update, leaving a
partially redeployed .claude/skills/ and emitting no notice — precisely the silent failure the hook
exists to prevent. tests/test-apm-current-hook.sh pins the relationship (host timeout > sum of the
script's internal timeouts) rather than the literal, so raising either side alone fails the suite.
Staleness is detected by matching apm's summary line, and both spellings count. apm outdated
has no --json or otherwise machine-readable output (verified against apm 0.28.0), so the hook
greps its text. apm prints 1 outdated dependency found in the singular when exactly one package is
behind and N outdated dependencies found otherwise; matching only the plural silently misses a
one-package drift. Because a mocked apm would keep a reworded release invisible, the test suite
stages a genuinely outdated dependency against the real apm — a local git repo reached through
url.<path>.insteadOf rewrites, so it needs no network — and replays that genuine output through the
hook.
GitHub Copilot CLI
Copilot loads no hooks from this plugin. Two independent reasons, either one sufficient:
- Nothing can point Copilot at a hooks file. Copilot types
hooksas aplugin.jsonfield of type "string or object" with no default (docs/research/docs/github-copilot-plugins/configuration.md:47), so there is no convention path for it to scan — it reads hooks only via an explicit pointer. Since ADR-0024 there is no per-plugin Copilot manifest at all, so there is nothing to carry that pointer. - The two ecosystems do not share a hooks format. Copilot reads a differently-shaped
hooks.json:version: 1is required, each entry istype: "command"with separatebashandpowershellscripts, and the lifecycle points are lowercase and differently named (sessionStart,sessionEnd,userPromptSubmitted,preToolUse,postToolUse,errorOccurred,agentStop). Seedocs/research/docs/github-copilot-plugins/configuration.md. apm merges.apm/hooks/*.jsoninto one definition with no per-target shaping, and that definition is Claude-shaped.
The second reason is why "just add a pointer" was rejected even while a Copilot manifest existed: a pointer would tell Copilot that a Claude-shaped file is Copilot-shaped, trading an incomplete manifest for a wrong one. ADR-0024 consequence 7 records that the question is now moot — the manifest it argued about is gone — but the schema mismatch it turned on is not, and it is what any future Copilot hooks support has to solve.
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, and nothing reports it
Do not author any file under plugins/kyberforge/.apm/ as a symlink. apm's copy path filters
symlinks out silently: ignore_non_content() in apm_cli/security/gate.py is a
shutil.copytree ignore callback that drops every entry answering is_symlink(), commented
"Excludes symlinks (security)". The file never reaches the consumer's install, and no warning is
printed at any point.
No gate catches this. The pre-push check that used to read the .apm/ tree and fail on the
offending path was deleted along with the content mirror, and the decision was taken not to replace
it (ADR-0024 consequence 7). This document is the only thing standing between a symlink and silent
content loss. No symlink exists under any .apm/ today; add one and it is dropped on deploy with
nothing reporting it. Replace it with a regular file.
The old exemption for .apm/<category>/<name>/tests/ no longer applies either. That directory was
exempt only because the mirror excluded it; apm deploys it like any other content (ADR-0024
consequence 2), so a symlink there loses content the same as anywhere else.