Files
holocron/plugins/kyberforge/docs/hooks.md
Defame1297 641ebcac0e docs: source ADR-0029 claims and sync ADRs and hook docs with behaviour
- cite the VS Code prompt-file deprecation and the verbatim apm quote
- add ADR-0029 boundary-clause enforcement and Consequences
- mark superseded ADR-0019 passages; record neutral lock advice, source
  fork and reloadSkills, amend for the hook hardening
- move the ADR-0025 amendment out of the Decision list
- amend ADR-0022 for create keeping 0.1.0
- fix hooks.md merge and event claims, README guard caveat, gates.md Vale
  globs, and pin the research registry URL

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
2026-09-29 08:00:39 +00:00

180 lines
11 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.
For Claude, apm merges the event bindings from every `*.json` in that directory into the consuming
project's `.claude/settings.json`; see "Deployed shape" below. That merge is Claude's rendering, not
a general rule: Copilot gets one file per source file (see "GitHub Copilot CLI and Codex"). 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'", "timeout": 10 }
]
}
]
}
}
```
Events: see `plugins/kyberforge/docs/research/docs/microsoft-apm/hooks-primitive-schema.md`,
section "Events: `_HOOK_EVENT_MAP`", for which names apm renames per target and which it passes
through unchanged. For Claude, author every event in PascalCase (`SessionStart`, `UserPromptSubmit`,
`PreCompact`, and so on). A camelCase name apm does not map, such as `userPromptSubmit`, draws only
a non-fatal warning and never fires; an all-lowercase one draws no warning at all and never fires
either.
## 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. `.claude/hooks/` is gitignored install output; the sidecar is committed
alongside `.claude/settings.json`, because without it a fresh clone's `apm install` treats the
committed entry as user-owned and adds a duplicate, and `apm audit --ci` reports drift (ADR-0019,
correction 2026-09-16).
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 carries a
version (`kyberforge#<version>`), but apm 0.28.0 matches grants version-blind, so a version bump on
one side does not stop the hook deploying. `check-executables-allow-sync` is a pre-push gate for this
repo's own convention that the key tracks `plugins/kyberforge/apm.yml`'s `version:`, not for an apm
mechanic. See ADR-0019, correction 2026-09-19, and the comment above `executables:` in the root
`apm.yml`.
## 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. `reloadSkills` is a documented `SessionStart`
`hookSpecificOutput` field that makes Claude Code re-scan skill directories once the hooks finish
(code.claude.com/docs/en/hooks, checked 2026-09-29). Rationale, measurements, and the failure modes are in
ADR-0019.
**Claude Code only, and where it looks for the lockfile.** The hook exits 0 at once, silently and
without calling `apm`, unless `CLAUDE_PROJECT_DIR` is set and non-empty — Claude Code exports it for
SessionStart hooks, and Copilot and Codex do not document setting it, so the guard keeps the hook
inert there unless the variable is inherited from the user's environment (see below).
It then takes `${CLAUDE_PROJECT_DIR}` as the project directory and 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 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. Do not reintroduce a cwd fallback: under a
host that sets no `CLAUDE_PROJECT_DIR` the lockfile guard passes in every apm consumer, and the
fallback ran `apm update --yes` there (ADR-0019, correction 2026-09-28).
**The `timeout` in `hooks.json` must exceed the script's own budget.** The script spends at most
`timeout -k 5 60 apm outdated` plus `timeout -k 5 300 apm update`, 370 s counting each 5 s SIGKILL
grace; 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.
**The time limits are portable and hard to escape (ADR-0019, amendment 2026-09-29).** The script
uses `timeout`, or `gtimeout` where only Homebrew coreutils provides it (stock macOS). With neither,
it emits a notice and exits without running `apm`, instead of dying silently on exit 127. It exports
`GIT_TERMINAL_PROMPT=0`, so a remote that wants credentials fails at once instead of waiting out the
timeout on a prompt nobody can see. Where `flock` exists and `apm_modules/` does too, `apm update`
runs under a non-blocking lock on `apm_modules/.kyberforge-apm-update.lock`. A second session that
starts during a refresh skips its own and says so. Without `flock` the refresh runs unserialised.
**The lock advice is neutral when the default branch is unknown.** The notice tells you to discard
the rewritten `apm.lock.yaml` on a feature branch and to decide deliberately on the default branch.
It learns the default from `refs/remotes/origin/HEAD`, which `git remote add` never writes. When
that ref is unset, on a detached HEAD, or outside a git checkout, it says only "commit it or
discard it deliberately" rather than guessing `main` (ADR-0019, amendment 2026-09-19).
**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 and correction 2026-09-28). The hook's
behaviour is Claude-specific anyway — the `startup` matcher, `CLAUDE_PROJECT_DIR`, and the
`reloadSkills` output — and a harness that does run it exits immediately, because the script's
first guard exits 0 when `CLAUDE_PROJECT_DIR` is unset. The `apm.lock.yaml` guard cannot do that
job: `apm install` wrote the lock, so it passes in every project the hook reaches. 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.