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:
2026-09-28 16:30:26 +00:00
parent f30fbacf14
commit ff2b8b6c1b
4 changed files with 335 additions and 115 deletions

View File

@@ -1,59 +1,139 @@
--- ---
topic: hooks-primitive-schema topic: hooks-primitive-schema
source_keys: source_keys:
- context7-microsoft-apm - apm-cli-installed-source
- apm-docs-llms-full
- apm-github-repo - 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 ```json
// "Nested" wrapper (what the docs' canonical example shows) // Wrapped (canonical)
{ "hooks": { "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.sh", "timeout": 10} ] } ] } } { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ {"type": "command", "command": "./scripts/check.sh", "timeout": 10} ] } ] } }
// "Naked" top-level settings-slice (Claude Code settings.json shape, unwrapped) // Naked settings slice: promoted to wrapped only if EVERY top-level value is a list
{ "PreToolUse": [ { "hooks": [ {"type": "command", "command": "./scripts/validate.sh", "timeout": 10} ] } ] } { "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>/"): ## Events: `_HOOK_EVENT_MAP` (0.28.0, verbatim content)
- 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: | Target | Source name → native name |
```json |---|---|
{ | `copilot` | `PreToolUse`/`preToolUse`→`preToolUse`; `PostToolUse`/`postToolUse`→`postToolUse`; `UserPromptSubmit`/`userPromptSubmit`→`userPromptSubmit`; `SessionStart`/`sessionStart`→`sessionStart`; `Stop`/`AgentStop`/`agentStop`→`agentStop`; `PreTaskExecution`/`preTaskExecution`→`preTaskExecution`; `PostTaskExecution`/`postTaskExecution`→`postTaskExecution` |
"version": 1, | `claude` | `preToolUse`→`PreToolUse`; `postToolUse`→`PostToolUse`; `SessionStart`/`sessionStart`→`SessionStart`; `Stop`/`AgentStop`/`agentStop`→`Stop` |
"hooks": { "preToolUse": [ {"type": "command", "bash": "./scripts/validate.sh", "timeoutSec": 10} ] } | `gemini` | `PreToolUse`/`preToolUse`→`BeforeTool`; `PostToolUse`/`postToolUse`→`AfterTool`; `Stop`→`SessionEnd` |
} | `kiro` | PascalCase triggers, including `PreTaskExec`, `PostTaskExec`, `PostFileCreate`, `PostFileSave`, `PostFileDelete`, `promptSubmit`→`UserPromptSubmit` |
```
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 - **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 | ## Per-target rendering
|---|---|---|
| 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 | ### Claude Code: merged into `.claude/settings.json`
| Copilot CLI | `.github/hooks/<name>.json` | Dedicated per-file deploy, reshaped to Copilot's flat/camelCase/`version:1` schema |
`_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 ## 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 `.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:`.
- **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. - **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.
- **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`. - 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.
- **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. - `apm audit --ci` covers deployed-file presence, drift and hidden Unicode for hook outputs. It does not check hook semantics.
- **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.
## 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.

View File

@@ -1,66 +1,120 @@
--- ---
topic: instructions-primitive-schema topic: instructions-primitive-schema
source_keys: source_keys:
- context7-microsoft-apm - apm-cli-installed-source
- apm-docs-llms-full
- apm-github-repo - apm-github-repo
- context7-microsoft-apm
--- ---
## File location, naming, and frontmatter This file is checked against the installed apm-cli **0.28.0** source (`primitives/models.py`, `primitives/parser.py`, `primitives/discovery.py`, `utils/patterns.py`, `integration/instruction_integrator.py`, `integration/base_integrator.py`, `integration/targets.py`, `compilation/agents_compiler.py`, `commands/compile/cli.py`) and against a live `apm install` / `apm compile` of a scratch package (2026-09-28).
`.apm/instructions/*.instructions.md`. Confirmed as the genuine required extension (not assumed) via APM's own discovery glob in `apm_cli/primitives/discovery.py`: `**/.apm/instructions/*.instructions.md` (and the `.github/instructions/` mirror, plus a bare `**/*.instructions.md` fallback). ## File location, naming, discovery
Unlike prompts and hooks, instructions **do** have a small, concretely modeled dataclass — `apm_cli.primitives.models.Instruction` — because instructions feed APM's own compile pipeline (they get folded into root context files), not just pass-through deployment: APM finds instruction files in two different ways, depending on the command.
```python - **`apm install`** uses `InstructionIntegrator.find_instruction_files()`, which calls `find_files_by_glob(pkg, "*.instructions.md", subdirs=[".apm/instructions"])`. It searches the **package root and `.apm/instructions/`**. The search is non-recursive, so files in subdirectories of `.apm/instructions/` are never deployed. Symlinks and hardlinks (link count > 1) are rejected.
@dataclass - **`apm compile`** uses `discover_primitives()`, which has broader globs (`primitives/discovery.py`): `**/.apm/instructions/*.instructions.md`, `**/.github/instructions/*.instructions.md`, and a bare `**/*.instructions.md`. Dependencies are searched under `instructions/*.instructions.md` in both `.apm/` and `.github/`. In a live compile, the deployed `.github/instructions/` copies were not double-counted: 4 sources gave "Validated 4 primitives". The exact dedupe mechanism was not traced.
class Instruction: - The deployed stem is the filename minus `.instructions.md` (`_extract_primitive_name`; rename loop in `integrate_instructions_for_target`).
name: str
file_path: Path ## Frontmatter and the `Instruction` model
description: str
apply_to: str # from frontmatter key "applyTo"; empty means global/unconditional `primitives/parser._parse_instruction` builds `Instruction` with these fields:
content: str
author: str | None = None | Field | Source | Notes |
version: str | None = None |---|---|---|
source: str | None = None | `description` | `description:` | Defaults to `""` |
| `apply_to` | `applyTo:` | Normalised by `normalize_apply_to` |
| `author` | `author:` | Optional |
| `version` | `version:` | Optional |
| `content` | the body | |
Any other frontmatter key is ignored by the model. On Copilot, such keys still survive the verbatim copy.
**`applyTo` grammar** (`utils/patterns.py`):
- **Scalar string.** Either a single glob or a comma-separated list. `parse_apply_to` splits only on **top-level** commas, so brace groups stay intact: `"**/*.py, **/*.{pyi,pyx}"` gives `["**/*.py", "**/*.{pyi,pyx}"]`. Each segment is stripped of whitespace. Empty segments are dropped, so leading, trailing and doubled commas are tolerated.
- **YAML sequence.** `normalize_apply_to` joins non-null, non-empty entries with `,`. A literal top-level comma inside an entry is escaped as `\,`, so `"a,b/**"` survives as a single glob. You can also write `\,` by hand in a scalar value.
- Missing, `null`, an empty string or an empty list all produce `""`, which means **unconditional**.
- A non-string scalar (for example a number) is passed through `str()`. There is no glob syntax validation anywhere.
**`Instruction.validate()` messages (exact text).** All three are appended to one list:
- `"Missing 'description' in frontmatter"`
- `"No 'applyTo' pattern specified -- instruction will apply globally"`
- `"Empty content"` (a whitespace-only body counts as empty)
**Correction to the earlier version of this doc:** none of these is a hard error. `AgentsCompiler.validate_primitives()` turns every message into a **warning** (`self.warnings.append(f"{file_path}: {error}")`) and always returns `[]`. Consequences, all verified live:
- `apm compile` prints the warnings, still compiles, and exits 0.
- **`apm compile --validate` can never fail on primitive errors.** `_run_validation_mode` only exits 1 when `validate_primitives` returns errors, which never happens. It printed "All primitives validated successfully!" for a file with no `description` and an empty body. The published docs describe `--validate` as a "frontmatter + structure check", which overstates it.
- `apm install` never calls `validate()`. An instruction with no description and an empty body deployed silently to both targets.
- `validate_primitives` also emits broken-markdown-link warnings (`validate_link_targets`) during compile.
## Per-target mapping (`apm install`)
**Copilot: verbatim.** `PrimitiveMapping("instructions", ".instructions.md", "github_instructions")`, where `copy_instruction` does link resolution plus LF normalisation. In a live run, `diff` against the source was empty. Frontmatter is kept byte-for-byte, including a YAML-list `applyTo`. The published docs say Copilot splits comma-lists natively. **Unverified:** whether Copilot honours a YAML-list `applyTo`. Prefer the scalar comma form for Copilot.
**Copilot user scope** (`~/.copilot/`) uses `user_primitive_overrides` → `copilot_user_instructions`, handled by `_integrate_copilot_user_instructions`:
- Every instruction's body is **frontmatter-stripped**, so `applyTo` scoping is lost.
- The bodies are concatenated into `~/.copilot/copilot-instructions.md`, inside `<!-- apm:source:<pkg> -->` sections under an APM header.
- A pre-existing user-authored file without the header is a collision. APM skips it with a warning unless you pass `--force`.
**Claude: `.claude/rules/<stem>.md` via `_convert_to_claude_rules`.** Mapping: `PrimitiveMapping("rules", ".md", "claude_rules", output_compare=True)`.
- `applyTo` becomes a `paths:` list, with each glob run through `yaml_double_quote`.
- `description` and all other keys are **dropped**.
- With no `applyTo`, the output has no frontmatter and the body is left-stripped.
- An escaped-comma glob comes out unescaped (`a\,b/**` → `"a,b/**"`).
Live output:
```markdown
---
paths:
- "**/*.py"
- "**/*.{pyi,pyx}"
---
Use type hints.
``` ```
Frontmatter fields: `description` (required by convention — its absence is a validation error) and `applyTo` (a glob or comma-separated glob list, or a YAML sequence — APM normalizes all three input shapes into one canonical comma-separated form internally via `normalize_apply_to`/`parse_apply_to`). No `applyTo` means the rule is treated as **unconditional** — folded into root context files as always-on guidance rather than scoped to specific paths. - **Ownership.** Rule-dir files are APM-owned per file. An existing file at the target path is compared against the *transformed* output: it is adopted if identical and rewritten otherwise. `managed_files` is not consulted (apm#1662), so a hand-written `.claude/rules/<same-stem>.md` is overwritten.
- **Claude user scope.** `~/.claude/rules/`, or `$CLAUDE_CONFIG_DIR/rules/` if that variable is set. All Claude primitives are supported at user scope.
`Instruction.validate()` produces these built-in errors/warnings: - **`auto_create=False` for Claude.** `integrate_instructions_for_target` returns early when `<project>/.claude/` is not a directory. In a live run with `targets: [claude, copilot]` declared and no `.claude/`, the directory was created and the rules were deployed anyway. Which step creates it was not traced; the command integrator is a likely candidate.
- Missing `description` → error: `"Missing 'description' in frontmatter"`.
- Missing `applyTo` → warning-level: `"No 'applyTo' pattern specified -- instruction will apply globally"` (not fatal — it's accepted, just broad).
- Empty body → error: `"Empty content"`.
## Compile-time mapping: two entirely different mechanisms per target
This is the biggest divergence from the agent/skill/prompt primitives, and the one most likely to surprise: **Claude Code does not get a verbatim copy of the `.instructions.md` file at all.**
**Copilot CLI — verbatim, native primitive.** `PrimitiveMapping("instructions", ".instructions.md", "github_instructions")` on the `copilot` target has no `output_compare` flag, so `InstructionIntegrator` copies content through unchanged, preserving the original `applyTo:` frontmatter byte-for-byte (per the integrator's own docstring: "Copilot: `.github/instructions/` (verbatim, preserving applyTo:)"). This is deployed by `apm install`, not `apm compile`.
At **Copilot user scope only** (`~/.copilot/`), individual files are not deployed — Copilot CLI at user scope reads a single `copilot-instructions.md`, so APM concatenates all instructions into that one file instead (`user_primitive_overrides: {"instructions": PrimitiveMapping("", ".md", "copilot_user_instructions")}`). Project-scope behavior (per-file, `.github/instructions/`) is unaffected.
**Claude Code — real reconstruction into `.claude/rules/`, with field-dropping.** `PrimitiveMapping("rules", ".md", "claude_rules", output_compare=True)` marks this as one of APM's four "rule formats" (`RULE_FORMATS = {cursor_rules, claude_rules, windsurf_rules, kiro_steering}`) that transform their source rather than copy it. `InstructionIntegrator._convert_to_claude_rules()`:
- Parses the source frontmatter and pulls only `applyTo` — **`description` is dropped entirely**, not carried into the output in any form.
- Converts `applyTo` into a `paths:` YAML list (one `parse_apply_to()`-split glob per line), e.g. `applyTo: "**/*.py"` → `paths:\n - "**/*.py"`.
- If there was no `applyTo` (unconditional instruction), the output has **no frontmatter at all** — just the raw body, matching Claude's convention that files without `paths:` in `.claude/rules/` apply unconditionally.
- Filename is renamed: `<x>.instructions.md` → `<x>.md` (the primitive's `extension` field, `.md`, replaces the source suffix — this is the general rule for every `output_compare=True` "rule format").
This is architecturally the same category of lossy, real transformation the prior agent-primitive research found for Codex/Kiro agents — except here it's the default behavior for Claude specifically (not an opt-out edge case), and it applies even though Claude and Copilot are both first-class, actively-supported targets.
## Compile-time file placement
| Target | Output path | Transform | | Target | Output path | Transform |
|---|---|---| |---|---|---|
| Copilot CLI (project scope) | `.github/instructions/<name>.instructions.md` | Verbatim byte copy, `applyTo:` preserved as-is | | Copilot (project) | `.github/instructions/<stem>.instructions.md` | Verbatim |
| Copilot CLI (user scope, `~/.copilot/`) | `~/.copilot/copilot-instructions.md` | Concatenated — all instructions merged into one file, because Copilot CLI at user scope reads only that single file | | Copilot (user) | `~/.copilot/copilot-instructions.md` | Bodies concatenated; frontmatter stripped |
| Claude Code | `.claude/rules/<name>.md` | Reconstructed: `applyTo` → `paths:` YAML list; `description` dropped; no frontmatter at all if unconditional | | Claude (project/user) | `.claude/rules/<stem>.md` | `applyTo` → `paths:`; everything else dropped; no frontmatter if unconditional |
Additionally, **`apm compile`** (distinct from `apm install`) can also fold instruction content directly into root context files — `AGENTS.md` (single-file or per-directory "distributed" mode) and the Claude-specific parallel format `CLAUDE.md`/per-directory `CLAUDE.md` — grouped by directory using `applyTo` pattern analysis (`context_optimizer.optimize_instruction_placement`). To avoid duplicating content between the native `.claude/rules/`+`.github/instructions/` deployment (from `apm install`) and this root-context fold-in (from `apm compile`), a `skip_instructions` config flag (and `compilation.placement.min_instructions_per_file` in `apm.yml`) actively suppresses the redundant copy in AGENTS.md/CLAUDE.md once native per-target files exist — `apm compile --target claude --force-instructions` overrides this dedup when an author explicitly wants both. Other rule formats (`RULE_FORMATS`) work the same way: `cursor_rules` produces `.mdc` with `globs:` and derives `description` from the body when it is missing, `windsurf_rules`, `kiro_steering`, and `antigravity_rules`.
## Validation constraints and gotchas ## `apm compile`: fold-in to AGENTS.md / CLAUDE.md
- **The `description` field is real for Copilot but silently discarded for Claude.** An author who relies on `description` to explain *why* a rule exists (common practice, since Copilot's `.instructions.md` UI can surface it) gets that context deleted on every Claude compile — there's no config to keep it as a comment or otherwise. `compilation/distributed_compiler.py` and `context_optimizer.optimize_instruction_placement` group instruction bodies into `AGENTS.md` and `CLAUDE.md` files, placed per directory according to the `applyTo` patterns.
- **No content-level validation for the `paths:` conversion** — if `applyTo` contains a pattern `parse_apply_to` can't split sensibly, the resulting `paths:` list is whatever falls out; no dedicated schema check catches a malformed glob before deploy.
- **Directory-distribution logic for AGENTS.md/CLAUDE.md is heuristic, not declarative** — `context_optimizer.optimize_instruction_placement` picks placement directories from `applyTo` patterns algorithmically; `compilation.placement.min_instructions_per_file` in `apm.yml` (default effectively 1) is the only tuning knob, and setting it above 1 causes under-populated directories to have their instructions bubbled up to the parent directory rather than dropped. - **Dedup.** Instructions are omitted from `CLAUDE.md` when `.claude/rules/` is populated, and from `AGENTS.md` when `.github/instructions/` is populated. `--force-instructions` (alias `--no-dedup`) overrides this. In a live `apm compile -t claude,copilot` after install, no `CLAUDE.md` was produced. With `-t claude --force-instructions`, `CLAUDE.md` contained a "Global Instructions" section and one `### Files matching \`<applyTo>\`` section per pattern.
- **Same "no dedicated primitive validation function" gap noted for agents** — `Instruction.validate()` in `primitives/models.py` is the only validation, and it is invoked as part of the generic primitive-discovery/compile pipeline, not as a standalone `apm audit` check comparable to what exists for `apm.yml` itself. - **Headings show the normalised `applyTo` string verbatim**, including `\,` escapes (for example ``Files matching `src/**,a\,b/**` ``).
- `compilation.placement.min_instructions_per_file` in `apm.yml` controls when under-populated directories bubble their instructions up to the parent.
## Gotchas
- **`description` never reaches Claude.** It is kept for Copilot and is used as the index text in Cursor rules and compiled context. Keep the rationale for a rule in the body if Claude readers need it.
- **Copilot user scope loses `applyTo`.** Every instruction becomes global there.
- **A glob is never validated.** A typo gives a rule that silently never binds.
- `apm audit` checks deployed rules for hidden Unicode and drift. It does not validate frontmatter.
## Authoring checklist
**Must** (an author skill enforces these; an audit skill checks them):
1. The path is `.apm/instructions/<stem>.instructions.md`, directly in that directory with no subdirectories, and is not a symlink. *Source: `find_instruction_files`, `find_files_by_glob`.*
2. `description` is a non-empty string. *Source: `Instruction.validate()`; apm only warns during `apm compile`.*
3. The body is non-empty after trimming whitespace. *Source: `Instruction.validate()`; install deploys empty rules silently.*
4. `applyTo` is either absent (intentionally global) or a non-empty glob / comma-list. Use top-level commas only as separators, and put alternation inside `{}`. *Source: `parse_apply_to`, `has_top_level_comma`.*
5. The stem is unique across the package and its dependencies, because a `.claude/rules/<stem>.md` collision is overwritten. *Source: `integrate_instructions_for_target` rule-dir ownership.*
**Should:**
6. Use the scalar string form of `applyTo` rather than a YAML list, because Copilot receives the source verbatim. *Source: `copy_instruction`. Copilot's handling of list values is unverified.*
7. Omitting `applyTo` should be a deliberate choice. Without it, the file is always-on in Claude rules and is folded into `AGENTS.md`/`CLAUDE.md` global sections. *Source: `_convert_to_claude_rules`; `Instruction.validate()` warning text.*
8. Keep frontmatter to `description` and `applyTo`, plus the optional `author` and `version`. No target consumes other keys, and Claude drops them. *Source: `_parse_instruction`, `_convert_to_claude_rules`.*
9. Keep relative markdown links resolvable from the source file. *Source: `validate_link_targets`, `resolve_links`.*
**Audit-only** (apm does not check these): glob syntax validity; globs that match nothing in the repo; duplicate stems; description quality; YAML-list `applyTo` on Copilot-targeted packages. `apm compile --validate` cannot be relied on as a gate.

View File

@@ -1,53 +1,123 @@
--- ---
topic: prompt-primitive-schema topic: prompt-primitive-schema
source_keys: source_keys:
- context7-microsoft-apm - apm-cli-installed-source
- apm-docs-llms-full
- apm-github-repo - apm-github-repo
- context7-microsoft-apm
--- ---
## File location, naming, and frontmatter Checked against the installed apm-cli **0.28.0** source (`integration/prompt_integrator.py`, `integration/command_integrator.py`, `integration/base_integrator.py`, `integration/targets.py`, `security/gate.py`) and a live `apm install` of a scratch package targeting `claude` and `copilot` (2026-09-28).
`.apm/prompts/*.prompt.md` (also discovered at the package root). Filename minus the `.prompt.md` suffix becomes the prompt's identity — used verbatim as the Copilot filename and, after transformation, as the Claude command name. No required-extension ambiguity: it is genuinely `.prompt.md`, confirmed both in docs and in APM's own `PromptIntegrator.find_prompt_files` (`*.prompt.md`) and `CommandIntegrator.find_prompt_files` (same glob). ## File location, naming, discovery
There is no single closed frontmatter schema — APM's P1 "no invented primitive frontmatter" principle applies here too, so a prompt author writes whatever keys their primary target needs and APM passes or drops per-target. Keys seen in APM's own docs/examples: - `PromptIntegrator.find_prompt_files()` and `CommandIntegrator.find_prompt_files()` both run `find_files_by_glob(pkg, "*.prompt.md", subdirs=[".apm/prompts"])`.
- The search covers the package root and `.apm/prompts/`, and is non-recursive.
- Symlinks and hardlinks are rejected.
- `.apm/prompts/` is canonical. Root files are discovered for backward compatibility (published docs).
- Identity comes from the filename minus `.prompt.md`. That name is the Copilot filename unchanged, and the Claude `/command` name.
- `integrate_commands_for_target` runs `validate_path_segments(base_name, context="command filename")` against traversal names.
- A duplicate name in the root and in `.apm/prompts/` collides. The published docs say "later writer wins on copilot and the transform fails on Claude/Cursor". This was not verified here.
- Prompts are **not** in `discover_primitives()` and have no model class. `apm compile` and `apm compile --validate` never look at them.
- Prompts are deployed only by `apm install`.
- There is no `.apm/commands/` primitive. A Claude command is the compiled form of a prompt.
| Field | Purpose | ## Frontmatter
There is no closed schema. What survives is decided per target.
**`_PRESERVED_COMMAND_KEYS` (exact, 0.28.0):**
- `description`
- `allowed-tools`
- `allowedTools`
- `model`
- `argument-hint`
- `argumentHint`
- `input`
The user-facing list (`_PRESERVED_COMMAND_KEYS_DISPLAY`) omits the camelCase aliases.
**`input:` shapes accepted by `_extract_input_names`:**
| Shape | Names extracted |
|---|---| |---|---|
| `description` | Shown in Copilot's prompt picker / used for discovery | | `input: [file, focus]` (list of strings) | each string |
| `input` | List of parameter names (simple list, or list of `{name: description}` objects) referenced in body as `${input:name}` | | `input:` list of one-key maps (`- file: "desc"`) | each map's **keys** |
| `allowed-tools` (or `allowedTools`) | Tool allowlist for the prompt's execution | | `input: file` (single string) | that string |
| `argument-hint` (or `argumentHint`) | Human-readable hint for expected arguments | | `input: {file: desc, focus: desc}` (map) | its keys |
| `model` | Model override when the prompt runs |
| `author`, `mcp`, `parameters` | Cursor/other-target-specific metadata — **not preserved** by the shared Claude/Cursor command transformer (see below) |
**Workflow-prompt-only keys** (Copilot App / Copilot Workflows, not Copilot CLI): `name`, `interval` (`manual`/`hourly`/`daily`/`weekly`), `schedule_hour` (0–23 UTC), `schedule_day` (0–6, weekly only), `mode` (`interactive`/`plan`), `reasoning_effort`. These are flat top-level keys on the same `.prompt.md` file, consumed only by the Copilot App scheduler integration — irrelevant to Claude Code / Copilot CLI compilation and should not be treated as universal prompt schema. - Names must match `_INPUT_NAME_RE = ^[A-Za-z][\w-]{0,63}$`.
- Invalid names and non-string entries are rejected, with the warning `input: rejected N invalid name(s) (must match [A-Za-z][\w-]{0,63}): <first 5>`.
- Whitespace-only entries are dropped silently.
- **Upstream docs bug.** The published "Commands" example writes `- name: pr_number` / `description: …` inside a single map. `_extract_input_names` reads the map's *keys*, so the arguments come out as `name` and `description` rather than `pr_number`. This was verified live: `arguments: [name, description]`, `argument-hint: <name> <description>`. Use `- pr_number: "desc"` instead.
## Compile-time mapping: verbatim for Copilot, real reconstruction for Claude **Other keys seen in docs:**
- Copilot-only picker metadata: `name`, `agent`, `mode`, `tools`.
- Cursor and other targets: `author`, `mcp`, `parameters`.
- Copilot App workflow keys: `interval`, `schedule_hour`, `schedule_day`, `reasoning_effort`, per the published docs. The source has a `copilot_app_workflow_integrator` module, but it was not traced here.
**Copilot CLI target — verbatim copy.** `PrimitiveMapping("prompts", ".prompt.md", "github_prompt")` on the `copilot` target profile carries no `output_compare` flag, and `PromptIntegrator.copy_prompt()` reads the source file and writes it out unchanged (only markdown link targets get rewritten) via `copy_prompt: "Copy prompt file verbatim with link resolution."`. Every frontmatter key — including `author`, `mcp`, `parameters` — survives. Filename is untouched (`get_target_filename` returns `source_file.name`, "no -apm suffix"). None of these other keys survive the Claude transform.
**Claude Code target — real reconstruction into a slash command, with field-dropping.** There is no `prompts:` key at all in Claude's `TargetProfile.primitives` dict; instead prompts route through the shared `CommandIntegrator`, which transforms `.prompt.md` → Claude custom slash command markdown. `CommandIntegrator._transform_prompt_to_command()`: ## Per-target mapping
- Strips the `.prompt.md` suffix from the filename to derive `command_name`. **Copilot, verbatim.** Mapping: `PrimitiveMapping("prompts", ".prompt.md", "github_prompt")`. `PromptIntegrator.copy_prompt` resolves links and normalises line endings to LF. In the live run a `diff` against the source was empty, and every key survived, including dropped-for-Claude keys. `${input:x}` stays as written. At user scope the prompt goes to `~/.copilot/prompts/`.
- Builds an entirely new frontmatter object containing **only** these preserved keys: `description`, `allowed-tools` (accepts `allowedTools` alias), `model`, `argument-hint` (accepts `argumentHint` alias).
- Maps APM's `input:` list to Claude's `arguments:` list, and synthesizes `argument-hint` from it if not already set.
- Rewrites body placeholders `${input:name}` / `${{input:name}}` to Claude's native `$name` syntax via regex substitution.
- Computes `dropped_keys = source_frontmatter_keys - preserved_keys` and surfaces it as an install-time diagnostic warning — so `author`, `mcp`, `parameters`, and any other non-listed key are silently dropped from the compiled output but *not* silently dropped from the user's awareness (a warning fires).
- Cursor reuses this exact same transformer (`claude_command` format_id) — same preserved-key set, same drops.
## Compile-time file placement **Claude, reconstructed.** Mapping: `PrimitiveMapping("commands", ".md", "claude_command")`, which goes through `CommandIntegrator._transform_prompt_to_command`. The transform:
- Builds new frontmatter from `description`, `allowed-tools` (the `allowedTools` alias is accepted), `model`, and `argument-hint` (the `argumentHint` alias is accepted).
- Adds `arguments: [names]` from `input`. When there is no explicit `argument-hint`, it synthesises `argument-hint: "<a> <b>"`.
- Emits keys in alphabetical order, because `frontmatter.dumps` sorts them.
- Rewrites the body with the regex `\$\{\{?\s*input\s*:\s*([\w-]+)\s*\}?\}` → `$name`. This runs **only when at least one valid input name exists**, and then it rewrites **every** `${input:…}`, including names not declared in `input:`. Live: `${input:undeclared}` became `$undeclared`, while a prompt with no `input:` kept `${input:x}` literally.
- Reports dropped keys, `sorted(source_keys - _PRESERVED_COMMAND_KEYS)`, as the exact warning `Claude command <name>: frontmatter keys not supported for claude commands and were dropped: <keys>. Supported keys: allowed-tools, argument-hint, description, input, model.`
- Emits the info message `Mapped input -> command arguments in <file>: [...]`.
- Scans the compiled text with `SecurityGate.scan_text(BLOCK_POLICY)`. A critical hidden-character finding skips the write.
- Deploys at user scope to `~/.claude/commands/`, or to `$CLAUDE_CONFIG_DIR/commands/` if that variable is set.
Cursor, OpenCode and Grok Build reuse the same `claude_command` transformer. Gemini writes TOML. Windsurf writes workflows. Codex gets nothing.
Live output of `review.prompt.md`:
```markdown
---
allowed-tools:
- Read
- Grep
argument-hint: <file> <focus>
arguments:
- file
- focus
description: Review a file
model: sonnet
---
Review $file focusing on $focus and $undeclared.
```
| Target | Output path | Transform | | Target | Output path | Transform |
|---|---|---| |---|---|---|
| Copilot CLI | `.github/prompts/<name>.prompt.md` | Verbatim byte copy (links resolved) | | Copilot | `.github/prompts/<name>.prompt.md` | Verbatim (links resolved) |
| Claude Code | `.claude/commands/<name>.md` | Reconstructed: only `description`/`allowed-tools`/`model`/`argument-hint`/`arguments` survive; `input:` → `arguments:`; `${input:x}` → `$x` | | Claude | `.claude/commands/<name>.md` | Preserved-key subset; `input` becomes `arguments`; `${input:x}` becomes `$x` |
Invocation surface differs correspondingly: Copilot exposes it via the prompts picker UI (select by name); Claude exposes it as `/<name> <args>` (same pattern Cursor, OpenCode, Gemini CLI, and Windsurf's workflows menu use for their own compiled copies).
## Validation constraints and gotchas ## Validation constraints and gotchas
- **Input-name validation is real, not just documentation.** `_extract_input_names()` enforces `[A-Za-z][\w-]{0,63}` on every name pulled from `input:`; anything that fails is dropped from `arguments:` and reported as a warning listing up to 5 rejected names (`input: rejected N invalid name(s) ... `). A malformed `input:` entry does not fail the install — it silently loses that one argument. - APM never validates `description`: the transform only copies it if present. Deploying a prompt with no frontmatter at all was not tested.
- **Filename-derived identity is security-checked.** `integrate_commands_for_target` calls `validate_path_segments(base_name, context="command filename")` specifically to reject a package shipping a `.prompt.md` file with a manipulated relative name (e.g. `../../evil.prompt.md`) that would otherwise escape the target commands directory. - `$ARGUMENTS` and other native Claude syntax pass through untouched. They appear literally in the Copilot copy.
- **The dropped-key warning is the only signal a Claude-only author gets** that Cursor-specific frontmatter (`author`, `mcp`, `parameters`) never reached the deployed file — there is no error, no hard failure, and no config flag to preserve those keys for Claude; the shared transformer's preserved-key list is fixed in code (`_PRESERVED_COMMAND_KEYS`), not configurable per package. - A prompt that relies on Copilot-only keys (`agent`, `tools`, `mode`) loses them on Claude. The only signal is the install-time warning.
- **No dedicated `Prompt`/`PromptPrimitive` validation class exists** in `apm_cli/models/validation.py` or `apm_cli/primitives/models.py` — same gap pattern documented for the agent primitive. `apm.yml`'s `type: prompts` package-content-type ("Commands/prompts only, no instructions or skills") is validated at the package-type-detection level, not the individual-prompt level. - A pre-install hidden-Unicode scan (`install/helpers/security_scan`, `BLOCK_POLICY`) runs on source files. `apm compile` does not re-scan, so run `apm audit` before publishing (published docs).
- Slash commands and prompts share one source directory and one glob (`.apm/prompts/*.prompt.md`) — there is no separate `.apm/commands/` primitive; "command" is purely a per-target compiled *name* for the same source file, not a distinct authoring primitive. - `apm run <script> --param k=v` compiles a prompt with parameters bound. See `cli-reference.md`.
## Authoring checklist
**Must** (an author skill enforces these; an audit skill checks them):
1. The path is `.apm/prompts/<name>.prompt.md`, directly in that directory and not a symlink. `<name>` is unique across `.apm/prompts/` and the package root, and is a safe path segment. *Source: `find_prompt_files`, `validate_path_segments`.*
2. `description` is present and non-empty. It is the picker and command description on both targets, and apm does not check it. *Source: `_transform_prompt_to_command`.*
3. Every `input:` name matches `^[A-Za-z][\w-]{0,63}$`. The object form is `- <name>: "<desc>"`, never `- name: <name>`. *Source: `_INPUT_NAME_RE`, `_extract_input_names`.*
4. Every `${input:x}` in the body refers to a name declared in `input:`, and every declared name is used. If `input:` is empty or absent, no `${input:…}` may appear, because it would reach Claude unrewritten. *Source: the rewrite regex and its condition.*
5. Frontmatter keys are limited to the preserved set (`description`, `allowed-tools`, `model`, `argument-hint`, `input`) unless a Copilot-only key is intended and its Claude drop is accepted. *Source: `_PRESERVED_COMMAND_KEYS`.*
**Should:**
6. Use the kebab-case spellings `allowed-tools` and `argument-hint`, not the camelCase aliases. *Source: `_PRESERVED_COMMAND_KEYS_DISPLAY`.*
7. Omit `argument-hint` when `input:` is set, unless the synthesised `<a> <b>` form is inadequate. *Source: `_transform_prompt_to_command`.*
8. Give `model` a slug the target accepts. Copilot ignores `allowed-tools` and `model` (published docs).
9. Keep one intent per prompt, and write the body as second-person instructions (published "Author a prompt" guide).
**Audit-only** (apm does not check these): missing `description`; undeclared or unused inputs; `${input:…}` without `input:`; Copilot-only keys in a Claude-targeted package; name collisions between the root and `.apm/prompts/`.

View File

@@ -14,4 +14,20 @@
- **Contributing files:** agent-primitive-schema.md, prompt-primitive-schema.md, instructions-primitive-schema.md, hooks-primitive-schema.md, releasing.md - **Contributing files:** agent-primitive-schema.md, prompt-primitive-schema.md, instructions-primitive-schema.md, hooks-primitive-schema.md, releasing.md
- **Status:** `extracted` - **Status:** `extracted`
## apm-cli-installed-source
- **URL:** file:///root/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/
- **Description:** Installed apm-cli 0.28.0 package source, which is the version this repo runs. Read as ground truth for the hooks, instructions and prompts docs, including `integration/hook_integrator.py`, `hook_native_formats.py`, `hook_ir.py`, `hook_file_routing.py`, `instruction_integrator.py`, `command_integrator.py`, `prompt_integrator.py`, `targets.py`, `primitives/`, `utils/patterns.py`, `compilation/agents_compiler.py`, `commands/compile/cli.py` and `security/executables.py`. Cross-checked by live `apm install` / `apm compile` runs of a throwaway package (claude and copilot targets) in a scratch dir on 2026-09-28.
- **Contributing files:** hooks-primitive-schema.md, instructions-primitive-schema.md, prompt-primitive-schema.md
- **Status:** `extracted`
## apm-docs-llms-full
- **URL:** https://microsoft.github.io/apm/llms-full.txt
- **Description:** The full published APM docs bundle. It tracks upstream main and may be newer than 0.28.0. Used the "Hooks and commands", "Instructions and agents" and "Author a prompt" guides for public-facing claims and to flag where the docs diverge from 0.28.0.
- **Contributing files:** hooks-primitive-schema.md, instructions-primitive-schema.md, prompt-primitive-schema.md
- **Status:** `extracted`
Note (2026-09-28): during the verification pass for the hooks, instructions and prompts docs, the Context7 `/microsoft/apm` endpoint returned an invalid-API-key error. Those three files were re-verified against `apm-cli-installed-source` and `apm-docs-llms-full` only. Their `context7-microsoft-apm` key reflects the earlier pass.
Note: `releasing.md`'s `--check-clean`/`--check-versions` scope, `apm pack` exit-code semantics, and the `.apm/`-vs-root-flat-dir mutual exclusivity referenced there were additionally cross-checked directly against `apm_cli/bundle/plugin_exporter.py`, `apm_cli/commands/pack.py`, and `apm_cli/marketplace/drift_check.py` in the installed `apm-cli` 0.28.0 package (`/root/.local/pipx/venvs/apm-cli/`), not just Context7 doc snippets — confirmed by a live `apm pack --format plugin` run inside `plugins/bin` that reproduced the documented `[!] Skipping root-level skills/ because .apm/ is present` warning. Note: `releasing.md`'s `--check-clean`/`--check-versions` scope, `apm pack` exit-code semantics, and the `.apm/`-vs-root-flat-dir mutual exclusivity referenced there were additionally cross-checked directly against `apm_cli/bundle/plugin_exporter.py`, `apm_cli/commands/pack.py`, and `apm_cli/marketplace/drift_check.py` in the installed `apm-cli` 0.28.0 package (`/root/.local/pipx/venvs/apm-cli/`), not just Context7 doc snippets — confirmed by a live `apm pack --format plugin` run inside `plugins/bin` that reproduced the documented `[!] Skipping root-level skills/ because .apm/ is present` warning.