Files
holocron/plugins/kyberforge/docs/hooks.md
Defame1297 df28351d3e fix(kyberforge): resolve PR #144 review and audit round 1
- factory-audit: no-op hooks, ./ after interpreters, split-quote and
  spaced ${PLUGIN_ROOT} paths, camelCase events in Claude-targeted flat
  files, case-insensitive routing stems, and non-string YAML keys are
  now caught; input: forms and prompt boundary clauses align with
  primitive-author; bats 347 -> 367
- primitive-author: routing forms, quoting guidance, install exit on
  hidden Unicode, argument-hint exception
- forge: drop duplicated gotcha, fit description and body budgets (#143)
- skill-author: primitive-author boundary, Claude-only env vars
- hook: exit unless CLAUDE_PROJECT_DIR is set, so Copilot/Codex never
  run apm update; ADR-0019 correction, ADR-0025 amendment, docs fixes

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

9.6 KiB

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/:

{
  "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:

"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 that guard is what keeps the hook inert under Copilot and Codex (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.

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.