docs(kyberforge): research all five APM primitive schemas
skill-author/agent-author's #89 retarget needs to know exactly how each .apm/ primitive compiles to Claude Code and Copilot CLI output. The existing microsoft-apm corpus only had a full schema for skills and one minimal example for agents, and nothing for prompts/instructions/hooks. Deepened via APM's own Python source (not just docs) where prose was thin. Key finding for #89: agents have no per-target integrator, so apm compile does a naive verbatim copy to both Claude and Copilot, unlike prompts/instructions/hooks which each get real per-target reconstruction. That means the agent primitive's `tools:` field can't express both harnesses' incompatible vocabularies at once — a real upstream gap, not something we can schema our way around.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
---
|
||||
topic: hooks-primitive-schema
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
- apm-github-repo
|
||||
---
|
||||
|
||||
## File location, naming, and format — confirmed `.json`, not assumed
|
||||
|
||||
`.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.
|
||||
|
||||
**Accepted input shapes** (APM normalizes both into an internal vendor-neutral IR before rendering per target):
|
||||
|
||||
```json
|
||||
// "Nested" wrapper (what the docs' canonical example shows)
|
||||
{ "hooks": { "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.sh", "timeout": 10} ] } ] } }
|
||||
|
||||
// "Naked" top-level settings-slice (Claude Code settings.json shape, unwrapped)
|
||||
{ "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.sh", "timeout": 10} ] } ] }
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
## Compile-time mapping per target — both are real reconstruction, differently shaped
|
||||
|
||||
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`.
|
||||
|
||||
**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.
|
||||
|
||||
**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`.
|
||||
|
||||
## Compile-time file placement
|
||||
|
||||
| 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 |
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user