docs(kyberforge): complete apm hooks, instructions and prompts research
Re-verify the three primitive schema docs against the installed apm-cli 0.28.0 source and live installs, and add an authoring checklist to each. Corrections to the earlier docs: - Copilot hooks are not reshaped: events are renamed, paths rewritten and version: 1 added, but command/timeout are not renamed to bash/timeoutSec. - A malformed .claude/settings.json is overwritten on install, losing user content. - Instruction validate() messages are warnings only; apm compile --validate never fails on them. Refs #94 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
This commit is contained in:
@@ -1,59 +1,139 @@
|
||||
---
|
||||
topic: hooks-primitive-schema
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
- apm-cli-installed-source
|
||||
- apm-docs-llms-full
|
||||
- apm-github-repo
|
||||
- context7-microsoft-apm
|
||||
---
|
||||
|
||||
## File location, naming, and format — confirmed `.json`, not assumed
|
||||
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.
|
||||
|
||||
`.apm/hooks/*.json` (legacy fallback: bare `hooks/*.json` at package root, still discovered — `_has_hook_json()` checks both `hooks/` and `.apm/hooks/`). This is genuinely JSON, not YAML or Markdown-with-frontmatter like every other primitive — confirmed directly from source (`apm_cli/integration/hook_integrator.py` module docstring: "Integrates hook JSON files...") and from `apm_cli/models/validation.py`, which states a hook-only package's files define "hook handlers per the Claude Code hooks specification" — i.e. the canonical authoring shape APM expects is Claude Code's own native hook JSON shape, not an APM-invented one. This is consistent with APM's general P1 principle (no invented primitive frontmatter/format) extending even to hooks: author in whichever native harness shape you like, and APM normalizes.
|
||||
## File location, naming, discovery
|
||||
|
||||
**Accepted input shapes** (APM normalizes both into an internal vendor-neutral IR before rendering per target):
|
||||
- `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` routes a file whose stem is `hooks-<token>` or ends `-<token>-hooks` (tokens: `copilot`, `vscode`, `cursor`, `claude`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`) to that target only, with a deprecation warning. 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:
|
||||
|
||||
```json
|
||||
// "Nested" wrapper (what the docs' canonical example shows)
|
||||
{ "hooks": { "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.sh", "timeout": 10} ] } ] } }
|
||||
// Wrapped (canonical)
|
||||
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ {"type": "command", "command": "./scripts/check.sh", "timeout": 10} ] } ] } }
|
||||
|
||||
// "Naked" top-level settings-slice (Claude Code settings.json shape, unwrapped)
|
||||
{ "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.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} ] } }
|
||||
```
|
||||
|
||||
Both are accepted; APM's discovery/parsing layer detects and unwraps either. There is no separate `Hook`/`HookPrimitive` dataclass in `primitives/models.py` (unlike `Instruction`) — hooks are represented instead by a dedicated vendor-neutral IR (`apm_cli/integration/hook_ir.py`): `HookHandler(command, platform="all", timeout_seconds, provenance, metadata)` grouped into `HookBinding(event, handlers, matcher, provenance, metadata)` grouped into `HookDocument(bindings)`. This IR is populated during install-time integration, not during the generic primitive-discovery pass used for instructions/contexts/agents.
|
||||
Nested and flat entries can be mixed in one event array. Parse failure modes (all verified live):
|
||||
|
||||
**Event names are case-convention-sensitive by target and get remapped, not just passed through.** Author in either PascalCase (Claude convention: `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`) or camelCase (Copilot convention: `preToolUse`, `postToolUse`, etc.) — `_HOOK_EVENT_MAP` per-target dictionaries translate between them during merge/deploy. An event name whose casing doesn't match the target's expected convention *and* has no explicit mapping entry triggers a non-fatal warning at install time (`_emit_hook_event_diagnostics`) — not a hard failure, but a real signal that the event likely won't fire.
|
||||
| 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**. |
|
||||
|
||||
**Script path placeholders** are rewritten per target during deploy: `${CLAUDE_PLUGIN_ROOT}/path`, `${CURSOR_PLUGIN_ROOT}/path`, `${PLUGIN_ROOT}/path`, and bare `./path` all get resolved relative to the package root and rewritten to whatever the target expects; bare system commands (no path separators) pass through unchanged.
|
||||
## Vendor-neutral IR (`hook_ir.py`, `hook_native_formats.py`)
|
||||
|
||||
## Compile-time mapping per target — both are real reconstruction, differently shaped
|
||||
`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).
|
||||
|
||||
Neither Claude nor Copilot receives a byte-verbatim copy of the source hook JSON — this is a genuine, structural transform on both sides, driven by `apm_cli/integration/hook_native_formats.py` and `hook_integrator.py`.
|
||||
`_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.
|
||||
|
||||
**Claude Code — merged into `.claude/settings.json`, not a standalone file.** `claude` is registered in `_MERGE_HOOK_TARGETS` with `config_filename="settings.json"`, `schema_strict=True`. Behavior (per the integrator's own class docstring: "Claude: Merged into .claude/settings.json hooks key + .claude/hooks/<pkg>/"):
|
||||
- Event bindings are merged into the `"hooks"` key of `.claude/settings.json`, using Claude's native nested-matcher-group shape (`{"hooks": {"PreToolUse": [{"hooks": [{"type": "command", "command": "...", "timeout": N}]}]}}`), with PascalCase event names.
|
||||
- Any referenced script files are physically copied to `.claude/hooks/<package-name>/`, and the `command` field is rewritten to point at the copied location.
|
||||
- An ownership sidecar (`apm-hooks.json`) tracks which entries in the shared `settings.json` were APM-installed, so `apm install`/uninstall can cleanly add/remove only its own entries without clobbering hand-authored hooks a user already had in that file.
|
||||
## Events: `_HOOK_EVENT_MAP` (0.28.0, verbatim content)
|
||||
|
||||
**Copilot CLI — dedicated per-file deployment, flat/camelCase, field-renamed.** `copilot` is deliberately **not** in `_MERGE_HOOK_TARGETS` (confirmed in `_hook_dropped_targets.py`: "Names not registered in `_MERGE_HOOK_TARGETS` (e.g. `copilot`, which uses per-file, not merged, hook deployment...)"). Instead `PrimitiveMapping("hooks", ".json", "github_hooks")` deploys a dedicated file per source hook file. The native Copilot shape differs structurally from Claude's, per the module docstring:
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": { "preToolUse": [ {"type": "command", "bash": "./scripts/validate.sh", "timeoutSec": 10} ] }
|
||||
}
|
||||
```
|
||||
Differences from the Claude/source shape: flat arrays (no nested matcher-group wrapper), camelCase event keys, a required top-level `"version": 1`, and handler commands split by platform (`bash` / `powershell` keys) instead of a single `command` key, with `timeoutSec` replacing `timeout`.
|
||||
| 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` |
|
||||
|
||||
## Compile-time file placement
|
||||
- **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).
|
||||
|
||||
| Target | Output location | Mechanism |
|
||||
|---|---|---|
|
||||
| Claude Code | `.claude/settings.json` (`"hooks"` key, merged) + scripts copied to `.claude/hooks/<pkg>/` | Merge into existing shared config file, ownership tracked via `apm-hooks.json` sidecar |
|
||||
| Copilot CLI | `.github/hooks/<name>.json` | Dedicated per-file deploy, reshaped to Copilot's flat/camelCase/`version:1` schema |
|
||||
## 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: ${CLAUDE_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 `allowExecutables` block, dependency hooks are deny-by-default until approved (`apm approve`). Non-interactive runs hard-error. Local project content (`_local`) is always trusted (`install/exec_gate.check_executable_approval`). With no `allowExecutables` block, everything deploys. The pre-deploy hidden-Unicode scan (`install/helpers/security_scan`, `BLOCK_POLICY`) also covers hook files.
|
||||
|
||||
## Validation constraints and gotchas
|
||||
|
||||
- **Copilot's native payload has an enforced shape** (`_validate_copilot_payload`): top-level `"version"` must equal `1`; `"hooks"` must be an object; each event's value must be a list; each entry must be an object; if an entry has a `"hooks"` key, its value must be a list of objects. These errors are collected and surfaced before any file is written (fail before mutation, not after).
|
||||
- **Malformed existing config fails closed, not silently.** If `.claude/settings.json` (or another merge target's config) is unreadable/malformed JSON, APM leaves it **byte-identical** and logs an actionable warning rather than overwriting or corrupting it — the same fail-closed posture applies to orphaned `apm-hooks.json` sidecars when their native JSON counterpart is already gone.
|
||||
- **Dropping a target from `apm.yml`'s `targets:` list does not auto-clean its merged hook entries** unless `apm install`/reconcile logic explicitly walks the complement set (`reconcile_dropped_targets`) — a real, documented gap the code works around rather than a design guarantee; relying on "just remove the target and hooks disappear" is not safe without a fresh `apm install`.
|
||||
- **Event-casing mismatches are warnings, not errors** — a hook authored with the wrong casing for a target and no applicable rename mapping will silently not fire at runtime; APM only logs a warning at install time, it does not block the install or refuse to deploy the file.
|
||||
- **No dedicated `Hook`/`HookPrimitive` validation dataclass** exists comparable to `Instruction.validate()` — validation is distributed across `_validate_copilot_payload` (Copilot-shape-specific) and general JSON-parseability checks, not a single primitive-level contract. This mirrors the same "no independent validation model" gap already documented for the agent primitive.
|
||||
- **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>` or `*-<target>-hooks` 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. Quote script paths that may contain spaces. *Source: published hooks guide; quote detection in `_rewrite_command_for_target`.*
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user