Switch the SessionStart hook to apm's target-neutral ${PLUGIN_ROOT} token.
Two scratch packages differing only in the token deploy byte-identical
SessionStart entries with apm 0.28.0, matching the committed
.claude/settings.json, so the deployed output does not change. The test
pin in tests/test-apm-current-hook.sh moves with it.
Correct the claim that Copilot loads no hooks from kyberforge. targets:
is package-wide, so apm also writes .github/hooks/kyberforge-hooks.json
(nested shape passed through, runtime unverified) and merges into
.codex/hooks.json when .codex/ exists. Recorded as accepted in an
ADR-0019 amendment dated 2026-09-28; README and docs/hooks.md updated.
Refs #94
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
154 lines
8.9 KiB
Markdown
154 lines
8.9 KiB
Markdown
# 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 `${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. `${PLUGIN_ROOT}` is apm's
|
|
target-neutral token; apm rewrites it exactly as it rewrites `${CLAUDE_PLUGIN_ROOT}` — verified
|
|
byte-identical in the deployed `.claude/settings.json` with apm 0.28.0 — so prefer it, and
|
|
`factory-audit` suggests it. **Address the script at its `.apm/` path:**
|
|
|
|
```json
|
|
"command": "${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
|
```
|
|
|
|
The obvious-looking `${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 and Codex
|
|
|
|
**apm writes this plugin's hook for Copilot and Codex too; whether they run it is unverified.**
|
|
kyberforge's `apm.yml` declares `targets: [claude, copilot, codex]`, and `targets:` is package-wide,
|
|
so the hook reaches every target the package does
|
|
(`plugins/kyberforge/docs/research/docs/microsoft-apm/hooks-primitive-schema.md`, verified against
|
|
apm 0.28.0):
|
|
|
|
- **Copilot** gets `.github/hooks/kyberforge-hooks.json`, one file per source file. apm renames the
|
|
event (`SessionStart` → `sessionStart`), rewrites the script path, adds `version: 1`, and otherwise
|
|
passes the nested Claude shape through — it does **not** reshape it into Copilot's flat
|
|
`bash`/`powershell`/`timeoutSec` form. Whether Copilot CLI executes a nested entry, or honours
|
|
`matcher`, has not been verified.
|
|
- **Codex** gets the entry merged into `.codex/hooks.json`, but only when `.codex/` already exists;
|
|
otherwise nothing is written.
|
|
|
|
This is accepted rather than fixed (ADR-0019, amendment 2026-09-28). The hook's behaviour is
|
|
Claude-specific anyway — the `startup` matcher, `CLAUDE_PROJECT_DIR`, and the `reloadSkills`
|
|
output — and the script exits silently without an `apm.lock.yaml`, so a harness that does run it is
|
|
unharmed. The only apm-native way to keep it Claude-only is a separate package whose `apm.yml`
|
|
declares `target: claude`; per-file target routing (`claude-hooks.json`) is deprecated, and
|
|
kyberforge cannot narrow its own `targets:` without dropping its skills from Copilot and Codex.
|
|
|
|
An earlier version of this section said Copilot loads no hooks from this plugin, because nothing
|
|
could point Copilot at a hooks file and apm did no per-target shaping. Both halves are superseded:
|
|
apm deploys the file into Copilot's hooks directory itself, and does rename events per target.
|
|
|
|
## 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.
|