Files
holocron/plugins/kyberforge/.apm/skills/primitive-author/references/hook.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

5.1 KiB

source_keys
source_keys
apm-cli-installed-source
apm-docs-llms-full

Authoring an apm hook

Reached from SKILL.md Step 1 for a hook. Run the Gate, then write against the shape and the checklist, then return to SKILL.md Step 3.

Gate

A hook is a runtime callback the harness fires inside its own tool loop — "this must always happen at this event", enforced deterministically rather than left to the model. apm's own guidance is to reach for a skill, instruction or prompt first, and to treat hooks as opt-in surface: they ship to a strict subset of harnesses and are silently skipped everywhere else.

  • Procedure, know-how, or anything the model should decide to do → a skill. Stop and hand to skill-author.
  • The hook must reach only some harnesses → reach is set by the package apm.yml targets:, never by the hook file. Stop and hand to apm-workflow. targets: is package-wide, so a harness-specific hook in a multi-target package means either a separate package or accepting that the other targets receive it too.
  • A runtime callback → continue.

Shape

One JSON file per concern at .apm/hooks/<name>.json, with a plain name. Copy assets/templates/hook.json.template. Write the canonical shape apm documents and renders per target:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {"type": "command", "command": "${PLUGIN_ROOT}/.apm/hooks/check.sh", "timeout": 10}
        ]
      }
    ]
  }
}
  • ${PLUGIN_ROOT} is the target-neutral token; apm rewrites it per target ("${CLAUDE_PROJECT_DIR}/.claude/hooks/<pkg>/…" on Claude, repo-relative elsewhere). ${CLAUDE_PLUGIN_ROOT} is rewritten identically, so it is valid, but it ties the source to one harness's name (Should 12).
  • Claude is the verified target. apm 0.28.0 passes this nested shape to Copilot without reshaping it, and whether Copilot CLI runs nested entries or honours matcher is unverified. That gap is apm's to close. Per-file target routing is deprecated, so a Copilot-native flat hook (bash / powershell / timeoutSec) can only live in a separate Copilot-targeted package — hand that to apm-workflow rather than adding a second file here.

Checklist

Must:

  1. The file sits directly in .apm/hooks/, is not a symlink, and parses as a JSON object. apm skips invalid JSON silently. (apm also discovers a package-root hooks/, and factory-audit accepts it for third-party packages; author in .apm/hooks/.)
  2. Use the wrapped shape {"hooks": {Event: [...]}}. If a naked settings slice is used instead, every top-level value must be a list, with no stray scalar keys anywhere.
  3. Every event value is a list of objects, and every nested hooks is a list of objects. Anything else fails the Copilot install outright. The file contributes at least one entry: an empty one deploys nothing, with only a warning.
  4. Event names are PascalCase (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop, …). An all-lowercase name (stop) never warns and never fires; a camelCase name outside apm's rename map (userPromptSubmit) deploys verbatim to Claude and never fires.
  5. The script is referenced as ${PLUGIN_ROOT}/… (or ${CLAUDE_PLUGIN_ROOT}/…, see Shape) for the package root, or ./… for the hook directory, and exists inside the package. No absolute path and no bare relative path (scripts/x.sh): apm bundles and rewrites neither. No $ or backtick in the path itself. A missing script is only a warning at install time.
  6. A script run directly as the command's first token is executable. This is stricter than the research's Should: without it the hook fails every time it fires. A script passed to an interpreter (bash ${PLUGIN_ROOT}/x.sh) needs no executable bit.

Should:

  1. Every handler sets "type": "command" and an explicit timeout in seconds — apm passes type through but never supplies it.
  2. Set matcher explicitly on tool events and on SessionStart (startup, resume, …). Omitted, Claude receives "*".
  3. Do not author Copilot's flat bash / powershell / timeoutSec keys in a Claude-shaped file; they render onto Claude as stray keys.
  4. Keep script paths free of spaces, and when quoting, quote the whole token: "${PLUGIN_ROOT}/scripts/my-hook.sh". apm rewrites ${PLUGIN_ROOT} only when a path separator follows it directly, and only up to the next space or quote — so "${PLUGIN_ROOT}"/scripts/x.sh is left unrewritten and a spaced path is cut short.
  5. Keep helper files in the hook directory non-JSON. Copilot's loader rejects any bundled .json without a hooks key.
  6. Prefer ${PLUGIN_ROOT} over ${CLAUDE_PLUGIN_ROOT}.
  7. No filename that routes by target. Case-insensitively, apm routes a stem of exactly hooks-<target> and any stem ending <target>-hooks — bare (claude-hooks), prefixed (x-claude-hooks) or combined (claude-codex-hooks, the union). That routing is deprecated and reach belongs to targets: (see Gate); the research allows it only when deprecated routing is intended.