# 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/`: ```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 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, 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//` (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#`), 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..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 `hooks` as a `plugin.json` field 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: 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`. apm merges `.apm/hooks/*.json` into 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///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.