A five-agent review of718c79aandd2480b8found no skill, agent or hook regressions (39 skills before and after) and confirmed both hook removals are genuinely moot -- verified against the tree, not taken on the commit's word. It did find one functional regression (fixed separately) and this documentation drift. Counting errors, all from a git pathspec `*` crossing `/`: - 17 .bats files shipped to consumers is really 10; 17 counted tracked paths merely containing /tests/, one of them a template asset - "roughly 88s off every push" is ~92.4s; 88 omitted validate-plugins - "roughly 70% of each plugin remains live" holds only for kyberforge; the real spread is 44.3% (bin) to 70.6%, now a table - the pre-push enforcement row was half-corrected: 33 entries stood unstruck (now 27) and 14 -> 11 switched counting basis mid-sentence - the root .claude-plugin/plugin.json was described as "kept"; it has never been tracked gates.md said "Ten hooks" above a nine-row table (11 was decremented for one removal, not two), and "both need the claude CLI" for one remaining validator. Its pretty-format-json exclude rationale claimed six alternations expanding to sixteen files in a passage headed "Mind which number you are quoting" -- four alternations, two live files; the two dead ones are dropped from the pattern. check-useless-excludes could not catch this: it only flags an exclude matching nothing at all. ADR-0024 cited ADR-0006 for a patch-bump rule it does not contain and which ADR-0015 explicitly retired; stated apm's marketplace probe order backwards (.claude-plugin/ is the last candidate, not the first, so the earlier .github/plugin/ deletion only demoted resolution); undercounted apm's skill-deploying targets as seven when there are fifteen; and never recorded that validate-plugins was removed. The symlink hedge is resolved: apm_cli/security/gate.py's ignore_non_content() drops symlinks silently on deploy while apm_modules/ materialization dereferences them, so content survives that far and vanishes at install. Accepted with no replacement guard, per decision -- kyberforge/docs/hooks.md previously asserted a guard that had been deleted with its script. Four plugin READMEs still advertised `claude plugin install`; ADRs 0001, 0006, 0013, 0014, 0015 and 0019 described deleted machinery in the present tense, 0019 most consequentially as the live justification for the SessionStart hook's .apm/ path. CONTEXT.md's "apm package" entry forbade "plugin" while using it in its own body, and "Output profile" lost the antecedent for "one catalogue serves both". run-tests.sh gains the .claude/skills/ exclusion run-bats.sh already had. Latent today -- no test-*.sh lives under any .apm/skills/*/tests/ -- but apm now deploys those directories, so one would be discovered twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
152 lines
8.8 KiB
Markdown
152 lines
8.8 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 `${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/<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
|
|
|
|
**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/<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.
|