- factory-audit: ./ and bare/absolute script checks scoped to command position (no false FAILs on ./src or printf); hook sources limited to .apm/hooks or package-root hooks/; Kiro-aware lowercase events; unfilled template placeholders FAIL; repo-only instructions FAIL at any scope; Vale description FAIL documented; bats 367 -> 378 - primitive-author: split-quote/spaced paths and handler-less entries promoted to Must; Step 4.2 renders into a scratch consumer instead of a no-op dry run; dispatch and gate hand-off trimmed - apm-workflow 1.0.2: mutual boundary with primitive-author - forge: no double package bump; gotcha wording - skill-author: create keeps seeded 0.1.0 (ADR-0022); portable, retry-safe new-skill.sh; template and flow consistency fixes - hook docs: cite the ADR-0019 correction; guard caveat Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
162 lines
9.6 KiB
Markdown
162 lines
9.6 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'", "timeout": 10 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
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 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. 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 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 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.
|