Files
holocron/plugins/kyberforge/docs/hooks.md
Defame1297 ae178a95a2 fix(kyberforge): detect a single stale package at SessionStart
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
2026-08-14 18:31:30 +00:00

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.