- 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
18 KiB
topic, source_keys
| topic | source_keys | ||||
|---|---|---|---|---|---|
| hooks-primitive-schema |
|
Ground truth for this file is the installed apm-cli 0.28.0 source (apm_cli/integration/hook_integrator.py, hook_native_formats.py, hook_ir.py, hook_file_routing.py, _hook_dropped_targets.py, targets.py, security/executables.py) plus a live apm install of a scratch package targeting claude and copilot (2026-09-28). Where the published docs (llms-full.txt) disagree with 0.28.0, the disagreement is called out; the published docs track upstream main and may describe a newer release.
File location, naming, discovery
HookIntegrator.find_hook_files()globs<pkg>/.apm/hooks/*.jsonfirst, then<pkg>/hooks/*.json(Claude-native layout). Non-recursive; symlinks skipped; stems deduplicated case-insensitively, so.apm/hooks/x.jsonshadowshooks/x.json.security/executables.scan_package_executablesuses the same two directories.- Genuinely JSON, not Markdown-with-frontmatter. There is no
Hookdataclass inprimitives/models.py; hooks never enterdiscover_primitives(), soapm compile(andapm compile --validate) never see them. Hooks are deployed byapm installonly. - Filename routing (deprecated, still active).
hook_file_routing._hook_file_allowed_targetslowercases the stem first (hook_file.stem.lower()), so matching is case-insensitive:Claude-Hooks.jsonroutes likeclaude-hooks.json. It routes a file whose stem ishooks-<token>, is a bare<token>-hooks, or ends-<token>-hooks(tokens:copilot,vscode,cursor,claude,codex,gemini,antigravity,windsurf,kiro) to that target only, with a deprecation warning. A combined stem whose trailing segments are all tokens, such asclaude-codex-hooks, routes to the union of those targets (_target_suffix_segments,_union_target_sets).copilotandvscodeare one set: either token selects both. If any file for a target is target-specific, universal files are ignored for that target (specific if specific else universal). A stem likeclaude-hooks.jsonis therefore Claude-only. The replacement istarget:/targets:in the package's ownapm.yml, or object-form per-dependencytargets:on the consumer side.
Accepted source shapes
_parse_hook_json() accepts:
// Wrapped (canonical)
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ {"type": "command", "command": "./scripts/check.sh", "timeout": 10} ] } ] } }
// Naked settings slice: promoted to wrapped only if EVERY top-level value is a list
{ "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/check.sh"} ] } ] }
// Flat Copilot-style entry (no inner "hooks" array)
{ "hooks": { "preToolUse": [ {"type": "command", "bash": "./scripts/check.sh", "powershell": "pwsh ./scripts/check.ps1", "timeoutSec": 5} ] } }
Nested and flat entries can be mixed in one event array. Parse failure modes (all verified live):
| Input | Behaviour |
|---|---|
| Invalid JSON | File silently skipped. No warning. |
"hooks" present but not an object |
Skipped; _log.warning "Skipping malformed hook file ...: 'hooks' must be a dict". |
Naked shape plus one stray scalar key (such as "description") |
Not promoted. Merge targets warn "Hook file X contributed no entries to claude settings; skipped." Copilot still writes a junk .github/hooks/<pkg>-X.json containing the original keys plus "hooks": {}. |
Event value not a list (such as {"PreToolUse": {...}}) |
Claude: "contributed no entries" warning. Copilot: _validate_copilot_payload error "Invalid Copilot hook payload", and the install fails. |
Vendor-neutral IR (hook_ir.py, hook_native_formats.py)
HookHandler(command, platform="all", timeout_seconds, provenance, metadata) inside HookBinding(event, handlers, matcher, provenance, metadata) inside HookDocument. The IR is used only when rendering merge targets (Claude, Gemini, Antigravity). Copilot never goes through it (see below).
_handler_to_ir rules:
commandwins. If it is absent, the first present key ofbash(platformposix),powershell(windows) orwindows(windows) becomescommand. All other keys, including a second platform key, stay inmetadata.timeoutSecwins overtimeout. Both are treated as seconds.- Every other handler key (
type,async,statusMessage,shell,env,cwd, arbitrary keys) passes through inmetadata. APM has no handler-field allowlist. _entries_to_ir:matcheris popped from the entry and kept. A flat entry (nohookslist) becomes a one-handler binding. Non-dict entries pass through raw.
Events: _HOOK_EVENT_MAP (0.28.0, verbatim content)
| Target | Source name → native name |
|---|---|
copilot |
PreToolUse/preToolUse→preToolUse; PostToolUse/postToolUse→postToolUse; UserPromptSubmit/userPromptSubmit→userPromptSubmit; SessionStart/sessionStart→sessionStart; Stop/AgentStop/agentStop→agentStop; PreTaskExecution/preTaskExecution→preTaskExecution; PostTaskExecution/postTaskExecution→postTaskExecution |
claude |
preToolUse→PreToolUse; postToolUse→PostToolUse; SessionStart/sessionStart→SessionStart; Stop/AgentStop/agentStop→Stop |
gemini |
PreToolUse/preToolUse→BeforeTool; PostToolUse/postToolUse→AfterTool; Stop→SessionEnd |
kiro |
PascalCase triggers, including PreTaskExec, PostTaskExec, PostFileCreate, PostFileSave, PostFileDelete, promptSubmit→UserPromptSubmit |
- No event is dropped. Any name absent from the map passes through unchanged (
event_map.get(raw, raw)). For Claude, that meansPreCompact,Notification,SubagentStop,SessionEnd,UserPromptSubmitand others all work as long as they are authored in PascalCase. _emit_hook_event_diagnosticswarns (non-fatal) only when the name is unmapped and_detect_event_casingyields the wrong convention._HOOK_EVENT_EXPECTED_CASING:copilotexpects camelCase; every other target expects PascalCase. All-lowercase names (notification,stop) return casingNone, so they never warn and silently never fire. Verified live:userPromptSubmitwarned on Claude,PreCompactwarned on Copilot,notificationwarned on neither.- The Claude map has no
userPromptSubmit→UserPromptSubmitentry. The camelCase spelling is deployed verbatim intosettings.jsonand will not fire, so authorUserPromptSubmit. - Docs vs 0.28.0: the published "Session lifecycle event aliases" table says
UserPromptSubmit/userPromptSubmitted→ CopilotuserPromptSubmitted. 0.28.0 maps touserPromptSubmitand has nouserPromptSubmittedalias. Which key Copilot CLI actually fires on has not been verified here. - Two source events that rename to the same native key are merged (Copilot: list extend; Claude: appended under one key).
Per-target rendering
Claude Code: merged into .claude/settings.json
_MERGE_HOOK_TARGETS["claude"] = _MergeHookConfig("settings.json", "claude", require_dir=False, schema_strict=True). _integrate_merged_hooks then:
- Renames events via the Claude map.
- Runs
_to_claude_hook_entries, which is_render_nested_document(timeout_milliseconds=False, default_matcher="*"). Every entry becomes{matcher, hooks:[...]}. The sourcematcheris preserved verbatim. An entry without a matcher gets"matcher": "*", including events such asStoporUserPromptSubmitwhere Claude ignores matchers. - Rewrites script paths (see below) and copies the hook bundle to
.claude/hooks/<pkg>/…, keeping the path relative to the package root. - Tags entries with
_apm_source, then strips the tags into the sidecar.claude/apm-hooks.json(schema-strict).settings.jsonholds only native fields. - Upsert is idempotent per source marker (
_should_remove_prior_merged_entry) and deduplicates by content.
Flat Copilot entry → Claude (live): bash becomes command, timeoutSec becomes timeout, and the unused powershell key and any other extras are left in the Claude handler (for example "powershell": "pwsh $env:CLAUDE_PROJECT_DIR/…"). Whether Claude Code tolerates unknown handler keys has not been verified here.
Confirmed for this repo: plugins/kyberforge/.apm/hooks/hooks.json (SessionStart, "matcher": "startup", command: ${PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh, timeout: 380) compiles in /root/ai-development/.claude/settings.json to {"matcher": "startup", "hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh\"", "timeout": 380}]}. Matcher and timeout are kept, and the path is re-anchored and quoted.
Copilot: one file per source file, not reshaped
integrate_package_hooks() writes <root>/hooks/<pkg>-<stem>.json (project .github/hooks/, user ~/.copilot/hooks/) and copies scripts to .github/hooks/scripts/<pkg>/…. Correction to the earlier version of this doc: 0.28.0 does not flatten, rename command→bash, or rename timeout→timeoutSec. The only transforms are:
- event renaming via the Copilot map
- script-path rewrite (repo-relative such as
.github/hooks/scripts/<pkg>/scripts/check.sh; absolute at user scope) version: 1injected withsetdefault_validate_copilot_payload:version == 1,hooksis an object, each event is a list, each entry is an object, and any nestedhooksis a list of objects. A failure is a diagnostics error, the file is not written, and the install reports failure.
So a Claude-shaped source reaches Copilot as nested {matcher, hooks:[{type, command, timeout}]} with the matcher preserved (live: sessionStart with "matcher": "startup"). A flat bash/powershell/timeoutSec entry reaches Copilot unchanged. The hook-integrator docstring describes the Copilot-native shape as flat {"type": "command", "bash": …, "timeoutSec": …}; HOOK_COMMAND_KEYS comments name bash/powershell (Copilot agent/CLI) and command/windows/linux/osx (VS Code). Unverified: whether Copilot CLI executes a nested entry or a command key, and whether it honours matcher. APM does not guarantee it.
Platform-specific commands: how to author
- Claude-only package: use
command(POSIX). For Windows, prefix the command withpwsh/powershell, or set handler"shell": "powershell"._project_scoped_command_paththen renders$env:CLAUDE_PROJECT_DIR/...instead of"${CLAUDE_PROJECT_DIR}/...". Whether Claude Code itself honours ashellhandler field is not verified here. - Copilot-correct package: author flat entries with
bash+powershell+timeoutSec, which pass to Copilot verbatim. The Claude render takesbashascommandand carriespowershellalong as a stray key. - Both targets, cleanly: split into per-target packages or files (
target: claude/target: copilotin each packageapm.yml), because no single source shape renders natively for both in 0.28.0.
Other targets (brief)
Merge targets: cursor (.cursor/hooks.json, version: 1 default), codex (.codex/hooks.json), gemini (.gemini/settings.json, timeouts ×1000 ms, nested), antigravity (.agents/hooks.json under container key apm), windsurf (.windsurf/hooks.json). Everything except Claude has require_dir=True: nothing is written unless the target dir exists. kiro writes one file per action. opencode has no hooks (unsupported_user_primitives=("hooks",)), and other hook-less targets are silently skipped.
Script path rewriting (_rewrite_command_for_target)
- Tokens
${CLAUDE_PLUGIN_ROOT},${CURSOR_PLUGIN_ROOT},${KIRO_PLUGIN_ROOT}and${PLUGIN_ROOT}followed by a path resolve against the package root../pathresolves against the hook file's directory first, then the package root (_resolve_relative_hook_script). Both are confined withensure_path_within. - The rewrite runs on every key in
HOOK_COMMAND_KEYS=command,bash,powershell,windows,linux,osx, at entry level and at nested-handler level. - Claude project scope:
"${CLAUDE_PROJECT_DIR}/.claude/hooks/<pkg>/<rel>", double-quoted unless the source already quoted it. PowerShell uses$env:CLAUDE_PROJECT_DIR/.... A target path containing$or a backtick raisesValueError. Other targets stay repo-relative. User scope (-g) uses absolute paths (_deploy_root_for_hook_rewrite). - Missing script:
_rich_warning("Hook script not found: …"). The install continues. At project scope the token is left unexpanded; at user scope it is rewritten to the absolute source path. - Bare commands with no
./and no token (echo hi,npx foo) pass through untouched and are not bundled. <pkg>is the dependency install dir name, or for the project's own.apm/theapm.ymlname(fallback_local).
Security / trust gate
Hooks are an executable primitive (security/executables.EXEC_TYPE_HOOKS). If the consuming project's apm.yml has an executables: block (executables: {allow: …, deny: …}, the form this repo's root apm.yml uses), dependency hooks are deny-by-default until approved (apm approve). allowExecutables is the deprecated spelling, still read as an alias for one minor cycle and migrated into executables.allow on write (security/executables.parse_project_executables, write_project_executables). Non-interactive runs hard-error. Local project content (_local) is always trusted (install/exec_gate.check_executable_approval). With neither block, everything deploys. The pre-deploy hidden-Unicode scan (install/helpers/security_scan, BLOCK_POLICY) also covers hook files.
Validation constraints and gotchas
- Malformed
.claude/settings.jsonis overwritten during install. This corrects the earlier version of this doc. In_integrate_merged_hooks, aJSONDecodeErroron the existing config setsjson_config = {}, and the rebuilt file is written. Verified live: a malformed file containingpermissionswas replaced and thepermissionscontent lost. The "left byte-identical" fail-closed behaviour applies only toreconcile_dropped_targets(_hook_dropped_targets.py) when it cleans a target dropped fromtargets:. - Dropped targets are cleaned.
manifest_reconcile.reconcile_dropped_merge_hook_targetsrunsreconcile_dropped_targetson the complement of active and declared targets on the next install/compile/update (published docs agree). Copilot's per-file hooks are cleaned throughdeployed_filesinstead. - APM's only hook-shape validation is
_validate_copilot_payload(Copilot only) plus the parse checks above. Nothing validates handler fields,type, timeout type or range, matcher syntax, or event-name existence. Casing mismatches only warn. apm audit --cicovers deployed-file presence, drift and hidden Unicode for hook outputs. It does not check hook semantics.
Authoring checklist
Must (an author skill enforces these; an audit skill checks them):
- Hook files live at
.apm/hooks/<name>.json(not a subdir, not a symlink) and parse as a JSON object. Source:find_hook_files; invalid JSON is silently skipped by_parse_hook_json. - Use the wrapped shape
{"hooks": {Event: [...]}}. In the naked shape, every top-level value must be a list, and no stray scalar keys are allowed anywhere. Source:_parse_hook_json; the Copilot junk-file behaviour above. - Every event value is a list of objects, and every nested
hooksis a list of objects. Otherwise the Copilot install fails. Source:_validate_copilot_payload. - Event names use PascalCase for Claude (
PreToolUse,UserPromptSubmit,SessionStart,Stop, …). Never use all-lowercase names, and never use camelCase for events outside the Claude map. Source:_HOOK_EVENT_MAP["claude"],_detect_event_casing. - Script references use
${CLAUDE_PLUGIN_ROOT}/…/${PLUGIN_ROOT}/…(package-root relative) or./…(hook-dir relative), and the referenced file exists inside the package. No absolute paths, and no$or backtick in the script path. Source:_rewrite_command_for_target,_project_scoped_command_path. - Avoid a stem matching
hooks-<target>,<target>-hooks,*-<target>-hooksor a combined<a>-<b>-hooks, in any letter case, unless you intend deprecated routing. Usetarget:in the packageapm.ymlinstead. Source:hook_file_routing.
Should:
7. Every handler sets "type": "command" and an explicit timeout in seconds. APM passes type through but never supplies it. Source: _handler_to_ir, _handler_from_ir.
8. Set matcher explicitly on tool events (PreToolUse/PostToolUse) and on SessionStart (startup / resume / …). If you omit it, Claude receives "*". Source: _to_claude_hook_entries default_matcher="*".
9. For a Claude-targeted package, do not author bash/powershell/timeoutSec. They render but leave stray keys. For Copilot-correct output, author the flat Copilot shape in a Copilot-targeted file. Source: live render; _handler_to_ir.
10. Script paths must not contain spaces. apm's token pattern is \$\{…PLUGIN_ROOT\}([\\/][^\s"']+), so the path must follow } directly and ends at the first whitespace or quote. Quoting the whole token, "${PLUGIN_ROOT}/x.sh", is rewritten (and keeps its quotes); the split form "${PLUGIN_ROOT}"/x.sh is never matched and deploys unrewritten and unbundled; "${PLUGIN_ROOT}/my hook.sh" resolves only my and warns "Hook script not found". Quoting does not make a space safe. Source: plugin_root_pattern and rel_pattern in _rewrite_command_for_target (hook_integrator.py ~L652, L694); the adjacent-quote check there only decides whether apm adds its own quotes.
11. Hook scripts must be executable and self-contained within the hook directory bundle. For Copilot, do not ship .json helper files in the bundle, because Copilot's loader rejects JSON without a hooks key. Source: published hooks guide; copy_deployed_hook_bundle(exclude_json_files=True).
Audit-only (apm does not check these): unknown or misspelled event names; missing type; non-numeric timeout; matcher on non-tool events; extra handler keys that the target ignores.