Files
holocron/plugins/kyberforge/docs/research/docs/microsoft-apm/hooks-primitive-schema.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

18 KiB
Raw Blame History

topic, source_keys
topic source_keys
hooks-primitive-schema
apm-cli-installed-source
apm-docs-llms-full
apm-github-repo
context7-microsoft-apm

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/*.json first, then <pkg>/hooks/*.json (Claude-native layout). Non-recursive; symlinks skipped; stems deduplicated case-insensitively, so .apm/hooks/x.json shadows hooks/x.json. security/executables.scan_package_executables uses the same two directories.
  • Genuinely JSON, not Markdown-with-frontmatter. There is no Hook dataclass in primitives/models.py; hooks never enter discover_primitives(), so apm compile (and apm compile --validate) never see them. Hooks are deployed by apm install only.
  • Filename routing (deprecated, still active). hook_file_routing._hook_file_allowed_targets lowercases the stem first (hook_file.stem.lower()), so matching is case-insensitive: Claude-Hooks.json routes like claude-hooks.json. It routes a file whose stem is hooks-<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 as claude-codex-hooks, routes to the union of those targets (_target_suffix_segments, _union_target_sets). copilot and vscode are 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 like claude-hooks.json is therefore Claude-only. The replacement is target:/targets: in the package's own apm.yml, or object-form per-dependency targets: 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:

  • command wins. If it is absent, the first present key of bash (platform posix), powershell (windows) or windows (windows) becomes command. All other keys, including a second platform key, stay in metadata.
  • timeoutSec wins over timeout. Both are treated as seconds.
  • Every other handler key (type, async, statusMessage, shell, env, cwd, arbitrary keys) passes through in metadata. APM has no handler-field allowlist.
  • _entries_to_ir: matcher is popped from the entry and kept. A flat entry (no hooks list) 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 means PreCompact, Notification, SubagentStop, SessionEnd, UserPromptSubmit and others all work as long as they are authored in PascalCase.
  • _emit_hook_event_diagnostics warns (non-fatal) only when the name is unmapped and _detect_event_casing yields the wrong convention. _HOOK_EVENT_EXPECTED_CASING: copilot expects camelCase; every other target expects PascalCase. All-lowercase names (notification, stop) return casing None, so they never warn and silently never fire. Verified live: userPromptSubmit warned on Claude, PreCompact warned on Copilot, notification warned on neither.
  • The Claude map has no userPromptSubmit→UserPromptSubmit entry. The camelCase spelling is deployed verbatim into settings.json and will not fire, so author UserPromptSubmit.
  • Docs vs 0.28.0: the published "Session lifecycle event aliases" table says UserPromptSubmit/userPromptSubmitted → Copilot userPromptSubmitted. 0.28.0 maps to userPromptSubmit and has no userPromptSubmitted alias. 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:

  1. Renames events via the Claude map.
  2. Runs _to_claude_hook_entries, which is _render_nested_document(timeout_milliseconds=False, default_matcher="*"). Every entry becomes {matcher, hooks:[...]}. The source matcher is preserved verbatim. An entry without a matcher gets "matcher": "*", including events such as Stop or UserPromptSubmit where Claude ignores matchers.
  3. Rewrites script paths (see below) and copies the hook bundle to .claude/hooks/<pkg>/…, keeping the path relative to the package root.
  4. Tags entries with _apm_source, then strips the tags into the sidecar .claude/apm-hooks.json (schema-strict). settings.json holds only native fields.
  5. 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: 1 injected with setdefault
  • _validate_copilot_payload: version == 1, hooks is an object, each event is a list, each entry is an object, and any nested hooks is 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 with pwsh/powershell, or set handler "shell": "powershell". _project_scoped_command_path then renders $env:CLAUDE_PROJECT_DIR/... instead of "${CLAUDE_PROJECT_DIR}/...". Whether Claude Code itself honours a shell handler field is not verified here.
  • Copilot-correct package: author flat entries with bash + powershell + timeoutSec, which pass to Copilot verbatim. The Claude render takes bash as command and carries powershell along as a stray key.
  • Both targets, cleanly: split into per-target packages or files (target: claude / target: copilot in each package apm.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. ./path resolves against the hook file's directory first, then the package root (_resolve_relative_hook_script). Both are confined with ensure_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 raises ValueError. 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/ the apm.yml name (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.json is overwritten during install. This corrects the earlier version of this doc. In _integrate_merged_hooks, a JSONDecodeError on the existing config sets json_config = {}, and the rebuilt file is written. Verified live: a malformed file containing permissions was replaced and the permissions content lost. The "left byte-identical" fail-closed behaviour applies only to reconcile_dropped_targets (_hook_dropped_targets.py) when it cleans a target dropped from targets:.
  • Dropped targets are cleaned. manifest_reconcile.reconcile_dropped_merge_hook_targets runs reconcile_dropped_targets on the complement of active and declared targets on the next install/compile/update (published docs agree). Copilot's per-file hooks are cleaned through deployed_files instead.
  • 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 --ci covers 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):

  1. 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.
  2. 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.
  3. Every event value is a list of objects, and every nested hooks is a list of objects. Otherwise the Copilot install fails. Source: _validate_copilot_payload.
  4. 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.
  5. 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.
  6. Avoid a stem matching hooks-<target>, <target>-hooks, *-<target>-hooks or a combined <a>-<b>-hooks, in any letter case, unless you intend deprecated routing. Use target: in the package apm.yml instead. 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.