apm prints "1 outdated dependency found" in the singular when exactly one package is behind (apm_cli/commands/outdated.py). check-apm-current.sh matched only "outdated dependencies found", so one stale package was invisible: the hook exited 0 silently and no refresh ran. With six packages merging independently, one-behind is the ordinary case, so the freshness mechanism failed most often in the situation it exists for. Three further defects in the same hook: - The host timeout was below the script's own budget. hooks.json declared 320s while the script allows `timeout 60` plus `timeout 300` = 360s, so a slow remote let the host kill the hook mid-update and leave .claude/skills/ half-deployed with nothing emitted. Now 380. A test asserts the invariant rather than the literal: it sums every `timeout N` parsed out of the script and requires hooks.json to exceed it, so changing either side alone fails. - The lockfile guard was cwd-relative, so a session opened in a subdirectory no-opped silently and ran both apm calls against the wrong directory. Now anchored on CLAUDE_PROJECT_DIR, falling back to the cwd so the hook stays inert under a host that does not set it. - Every assertion mocked apm, so the suite was green over code that could not detect its own most common trigger. That blind spot is what hid the singular/plural bug, and it is the same shape as the deleted post-push tests. The suite now stages a genuinely outdated dependency against a local git remote — offline, via url.<path>.insteadOf, so the pass-under-unshare property survives — runs the real `apm outdated`, and replays its output through the real hook. Reverting the grep to plural-only fails it. 23 -> 35 assertions. Each fix mutation-tested individually. kyberforge stays at 1.5.0: it is untagged, so this changes what 1.5.0 ships rather than superseding it, and executables.allow needs no edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
187 lines
11 KiB
Markdown
187 lines
11 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{
|
|
"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, not the mirror
|
|
|
|
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:**
|
|
|
|
```json
|
|
"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, where `hooks/` is the **generated mirror** — and `apm pack` merges only `*.json` out of
|
|
`.apm/hooks/`, dropping every non-JSON file. So the mirror contains `hooks.json` and nothing else.
|
|
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.
|
|
|
|
Nor can the script be hand-placed in `plugins/kyberforge/hooks/` to satisfy that path:
|
|
`sync-plugin-content.sh` runs `rm -rf` on the directory before every rebuild (ADR-0017), so it would
|
|
be deleted on the next sync with no drift warning — the same trap that ate this document's
|
|
predecessor.
|
|
|
|
`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. 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 for any host that installed this plugin natively rather than 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 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.
|