fix(kyberforge): align primitive-author and factory-audit rule tiers

Second clean-context audit found author Must/Should and audit FAIL/SUGGESTION
tiers drifting apart, and author Musts the audit never checked.

- factory-audit: FAIL on absolute or bare relative hook script paths, an
  applyTo present but empty, and unbalanced braces/brackets in applyTo;
  judgment steps for dependency stem collisions, helper .json in hook dirs,
  unresolvable instruction links, prompt model slugs and second-person
  bodies; an unmatched glob drops to SUGGESTION; deliberate tier deviations
  recorded in hook-flow.md; validate.sh --help lists the three new modes;
  DescriptionOpener message no longer prescribes "Use when".
- primitive-author: deprecated routing, extra prompt keys and the prompt
  description contract become Shoulds; hook Musts gain "contributes an
  entry", no bare relative paths, and executable-when-run-directly;
  prompt Must 1 covers hardlinks; Vale prose FAILs resolved at close.
- forge: say "hook, instruction or prompt" rather than "apm primitive".

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 18:02:05 +00:00
parent 9ac5340e15
commit 0ea3f69dc6
13 changed files with 211 additions and 47 deletions

View File

@@ -1,5 +1,5 @@
extends: existence
message: "Description opens with '%s' — use an imperative 'Use when...' opener instead"
message: "Description opens with '%s' — lead with the action or trigger, not 'This'"
level: error
scope: text.frontmatter.description
ignorecase: true

View File

@@ -22,10 +22,16 @@ Resolve the path against this skill's own directory. Run exactly:
bash scripts/validate.sh <hook-file>
```
Its findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: JSON validity, the wrapped-or-naked shape, event lists and nested handler lists (the checks whose failure makes the Copilot install fail), event names that never fire, referenced scripts that are missing, outside the package or not executable, deprecated filename routing, and `${CLAUDE_PLUGIN_ROOT}` where `${PLUGIN_ROOT}` would do. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
Its findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: JSON validity, the wrapped-or-naked shape, event lists and nested handler lists (the checks whose failure makes the Copilot install fail), a file contributing no entries, event names that never fire, referenced scripts that are missing, outside the package, not executable when run directly, or referenced by an absolute or bare relative path apm will not bundle, deprecated filename routing, and `${CLAUDE_PLUGIN_ROOT}` where `${PLUGIN_ROOT}` would do. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
There is no provenance and no Vale step: a hook carries no `source_keys` and no prose.
Three tiers deliberately differ from `primitive-author`'s checklist or the research's. Do not re-tier them by judgment:
- A hook file directly under a package-root `hooks/` passes. apm discovers both `.apm/hooks/` and `hooks/`, and this audit may target a third-party package; `primitive-author` authors only in `.apm/hooks/`.
- Deprecated filename routing is a SUGGESTION, matching the author's Should: the research allows it when deprecated routing is intended.
- A non-executable script run as the command's first token is a FAIL, stricter than the research's Should, because it fails every time it fires.
## Step 2 — Read the hook and its scripts
Read the hook file, every script it references, and the package's `apm.yml` `targets:` — reach is narrowed there, never in the hook file.
@@ -46,6 +52,7 @@ Cite file and line for every finding.
- SUGGESTION: a PascalCase event name that is not a real Claude Code event (a misspelling deploys verbatim and never fires; the script cannot tell a typo from an event it does not know).
- SUGGESTION: `bash`/`powershell`/`timeoutSec` keys in a Claude-shaped file — they render, but leave stray keys in `settings.json`.
- SUGGESTION: an unquoted script path that could contain spaces.
- SUGGESTION: a helper `.json` file in the hook directory without a `hooks` key — Copilot's loader scans the bundled scripts directory and rejects it. Keep helper configuration non-JSON.
Then return to `SKILL.md` Step 4, opening the report with this coverage line:

View File

@@ -23,7 +23,7 @@ bash scripts/validate.sh <instruction-file>
bash scripts/vale-wrap.sh <instruction-file>
```
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path, frontmatter, `description`, body, an `applyTo` that is present but empty or has unbalanced braces or brackets, a missing or list-form `applyTo`, extra keys, and a stem duplicated at the package root. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
`vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
@@ -31,7 +31,7 @@ There is no provenance step: an instruction carries no `source_keys`.
## Step 2 — Read the instruction and its context
Read the file, the package's `apm.yml`, and the repo's root `AGENTS.md`. For a scoped file, list the tracked files its `applyTo` matches (`rtk git ls-files` filtered by the glob).
Read the file, the package's `apm.yml`, and the repo's root `AGENTS.md`. For a scoped file, list the tracked files its `applyTo` matches (`rtk git ls-files` filtered by the glob). List the instruction stems the installed dependencies ship (`apm_modules/**/.apm/instructions/*.instructions.md`) — the script checks only the package root for a duplicate.
## Step 3 — Qualitative audit
@@ -40,13 +40,15 @@ Cite file and line for every finding.
**scope** — an instruction applies when files matching `applyTo` are touched; with no `applyTo` it loads into every session of every repo that installs the package.
- FAIL: an always-on file whose content is a rule for this repo alone — it belongs in `AGENTS.md`, which is the repo's single always-on source, not in a package that ships it to every consumer.
- FAIL: an `applyTo` glob that matches no tracked file in any repo the package plausibly targets, so the rule never loads.
- FAIL: the stem matches an instruction an installed dependency ships — both deploy to `.claude/rules/<stem>.md`, and one silently overwrites the other.
- SUGGESTION: an `applyTo` glob that matches no tracked file here. It is legitimate for files the package's consumers have and this repo does not, so name the mismatch rather than failing it.
- SUGGESTION: an always-on file whose content is really file-type specific — narrow it with `applyTo`.
- SUGGESTION: a glob much broader than the content (`**` for a rule about Python).
**description**
- SUGGESTION: the description does not say what the rule covers, or contradicts the body. Any rationale Claude readers need belongs in the body, because Claude drops the description.
- SUGGESTION: a relative markdown link that does not resolve from the source file — apm rewrites links on deploy, and a broken one stays broken on every target.
Then return to `SKILL.md` Step 4, opening the report with this coverage line:

View File

@@ -23,7 +23,7 @@ bash scripts/validate.sh <prompt-file>
bash scripts/vale-wrap.sh <prompt-file>
```
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, frontmatter, `description` presence, length and trigger clause, keys Claude drops, `input:` names and shapes, and `${input:x}` references against `input:`. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, frontmatter, `description` presence, length and trigger clause, keys Claude drops, `input:` names and shapes, and `${input:x}` references against `input:`. Keys Claude drops are a SUGGESTION, not a FAIL, on purpose: a Copilot-only key is legitimate when its Claude drop is intended, and only the author can say which. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
`vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
@@ -43,6 +43,8 @@ Cite file and line for every finding.
- FAIL: the body names a skill or agent that does not resolve, or one carrying `disable-model-invocation: true`, which the model cannot invoke.
- SUGGESTION: borderline — some how-to detail beyond steering, but not a full procedure.
- SUGGESTION: more than one intent in one prompt.
- SUGGESTION: the body is not written as second-person instructions to the agent.
- SUGGESTION: a `model` value that is not a model slug the package's Claude target accepts. Copilot ignores `model` and `allowed-tools`, so neither constrains a Copilot run.
**description**

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# lib-checks-primitive.sh — SOURCED, never executed.
#
# The structural check suite for the three apm primitives that have no author
# skill of their own and no SKILL.md-shaped container: hooks
# The structural check suite for the three apm primitives with no
# SKILL.md-shaped container, all authored by primitive-author: hooks
# (.apm/hooks/*.json), instructions (*.instructions.md) and prompts
# (*.prompt.md). validate.sh detects which one it was handed from the path and
# feeds $KYBERFORGE_PRIMITIVE_PY to python3 with the target as argv[1] and the
@@ -13,9 +13,12 @@
# description or body, and never validates a prompt's input: names against its
# ${input:x} references — so `apm install` and `apm compile --validate` both exit
# 0 on files that deploy nothing, or deploy something that never fires. The
# checks and their tiers come from the Authoring checklists at the end of
# checks follow the Authoring checklists at the end of
# plugins/kyberforge/docs/research/docs/microsoft-apm/{hooks,instructions,prompt}-primitive-schema.md,
# which trace each rule to the apm source that makes it matter.
# which trace each rule to the apm source that makes it matter, except where
# references/{hook,instruction,prompt}-flow.md documents a deliberate deviation
# (a tier moved, or a check the research leaves audit-only). A Must in
# primitive-author is a FAIL here, a Should a SUGGESTION.
#
# No boundary resolver and no word budgets: none of these files is routed on a
# description the way a skill is. A prompt's description IS model-visible on
@@ -204,6 +207,37 @@ def extract_script_refs(cmd):
return refs
SCRIPT_EXT_RE = re.compile(r'\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|ps1|rb|pl)$', re.IGNORECASE)
def first_token(cmd):
m = re.match(r'\s*(["\']?)((?:\\.|[^\s"\'])*)\1', cmd)
return m.group(2).replace('\\', '') if m else ''
def check_unanchored_script(cmd, pkg_root, where):
# apm rewrites and bundles only ${*_PLUGIN_ROOT}/... and ./... references;
# a bare command (`npx foo`, `echo hi`) passes through untouched, which is
# fine. An absolute script path, or a bare relative path to a file in the
# package, also passes through untouched — so the script is not bundled
# and the deployed hook points at a path that does not exist on the
# consumer's machine.
tok = first_token(cmd)
if not tok or tok.startswith('./') or '$' in tok or tok.startswith('~'):
return
if tok.startswith('/'):
real_root = os.path.realpath(pkg_root)
inside = os.path.realpath(tok).startswith(real_root + os.sep)
if inside or SCRIPT_EXT_RE.search(tok):
fail(f"script '{tok}' is an absolute path — apm neither bundles nor rewrites it, so it breaks on every other machine; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
return
if '/' in tok:
for base in (parent_dir, pkg_root):
if os.path.isfile(os.path.join(base, tok)):
fail(f"script '{tok}' is a bare relative path — apm bundles and rewrites only ${{PLUGIN_ROOT}}/... and ./... references, so this one deploys unbundled; prefix it with ${{PLUGIN_ROOT}}/ or ./ — {where}")
return
def check_script(kind_, rel, first, pkg_root, where):
if not rel:
return
@@ -323,8 +357,11 @@ def audit_hook():
continue
if '${CLAUDE_PLUGIN_ROOT}' in cmd:
uses_claude_token = True
for kind_, rel, first in extract_script_refs(cmd):
refs = extract_script_refs(cmd)
for kind_, rel, first in refs:
check_script(kind_, rel, first, pkg_root, where)
if not any(first for _, _, first in refs):
check_unanchored_script(cmd, pkg_root, where)
if uses_claude_token:
suggest(f"uses ${{CLAUDE_PLUGIN_ROOT}} — apm documents the target-neutral ${{PLUGIN_ROOT}}, which it rewrites identically for every target — {fname}")
@@ -337,6 +374,50 @@ def audit_hook():
INSTRUCTION_KEYS = {'description', 'applyTo', 'author', 'version'}
def split_top_level(value):
# apm's parse_apply_to: split on commas outside {} (and not escaped \,),
# strip each segment, drop empty ones.
segs, cur, depth, i = [], '', 0, 0
while i < len(value):
c = value[i]
if c == '\\' and i + 1 < len(value):
cur += value[i:i + 2]
i += 2
continue
if c == '{':
depth += 1
elif c == '}':
depth -= 1
if c == ',' and depth == 0:
segs.append(cur)
cur = ''
else:
cur += c
i += 1
segs.append(cur)
return [s.strip() for s in segs if s.strip()]
def check_apply_to(apply_to):
if isinstance(apply_to, list):
entries = [e for e in apply_to if e is not None and str(e).strip()]
globs = [str(e).strip() for e in entries]
elif isinstance(apply_to, str):
globs = split_top_level(apply_to)
else:
fail(f"applyTo is neither a string nor a list — apm cannot read a glob from it — {fname}")
return False
if not globs:
fail(f"applyTo is present but empty — remove the key for an intentionally always-on rule, or give it a glob — {fname}")
return False
ok = True
for g in globs:
if g.count('{') != g.count('}') or g.count('[') != g.count(']'):
fail(f"applyTo glob '{g}' has unbalanced braces or brackets — it matches nothing, so the rule never fires — {fname}")
ok = False
return ok
def audit_instruction():
check_not_linked()
stem = fname[:-len('.instructions.md')]
@@ -359,9 +440,10 @@ def audit_instruction():
fail(f"body is empty — apm deploys an empty rule without complaint — {fname}")
apply_to = fm.get('applyTo')
if apply_to is None or apply_to == '' or apply_to == []:
apply_to_ok = apply_to is not None and check_apply_to(apply_to)
if apply_to is None:
suggest(f"no applyTo — this loads into every session of every repo that installs the package; confirm always-on is intended, and that a rule for this repo alone is not really an AGENTS.md rule — {fname}")
elif isinstance(apply_to, list):
elif apply_to_ok and isinstance(apply_to, list):
suggest(f"applyTo is a YAML list — Copilot receives the file verbatim and its handling of a list is unverified; use one comma-separated string — {fname}")
extra = sorted(k for k in fm if k not in INSTRUCTION_KEYS)

View File

@@ -145,16 +145,20 @@ At project or user scope, <agent-file> is either half of a Claude Code .md /
Copilot .agent.md pair.
Arguments:
skill-dir Path to the skill directory containing SKILL.md.
agent-file Path to the agent file (or either half of a project/user-scope pair).
skill-dir Path to the skill directory containing SKILL.md.
agent-file Path to the agent file (or either half of a project/user-scope pair).
hook-file Path to a hook JSON file directly under .apm/hooks/ or hooks/.
instruction-file Path to a *.instructions.md file.
prompt-file Path to a *.prompt.md file.
Exit codes:
0 All checks passed (may include SUGGESTIONs)
1 One or more checks failed
2 Nothing was audited (no argument, the target matches no shape, the
target does not exist, an unrecognized file extension, a missing
references/agent-field-inventory.md, or a missing or unreadable lib-*.sh
beside this script)
references/agent-field-inventory.md, a missing or unreadable lib-*.sh
beside this script, or, for a hook, instruction or prompt, a missing
python3 or PyYAML)
EOF
}

View File

@@ -173,6 +173,37 @@ teardown() {
refute_output --partial "hardlink"
}
@test "hook: an absolute script path is a FAIL; an absolute interpreter path is not" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/usr/local/bin/check.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure
assert_output --partial "is an absolute path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/usr/bin/env true","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "absolute path"
}
@test "hook: a bare relative path to a package script is a FAIL; a bare command is not" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":".apm/hooks/scripts/check.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure
assert_output --partial "is a bare relative path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"npx some-tool --check","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "bare relative path"
}
@test "hook: a file contributing no entries is a FAIL" {
write_hook hooks.json '{"hooks":{}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure
assert_output --partial "contributes no hook entries"
}
# ---------------------------------------------------------------------------
# Instructions
# ---------------------------------------------------------------------------
@@ -226,6 +257,31 @@ name: python' 'body'
assert_output --partial "frontmatter key(s) name"
}
@test "instruction: an applyTo that is present but empty is a FAIL" {
write_instruction empty 'description: x
applyTo: ""' 'body'
run bash "$SCRIPT" "$PKG/.apm/instructions/empty.instructions.md"
assert_failure
assert_output --partial "applyTo is present but empty"
refute_output --partial "SUGGESTION no applyTo"
}
@test "instruction: an applyTo glob with unbalanced braces is a FAIL" {
write_instruction broken 'description: x
applyTo: "**/*.{py"' 'body'
run bash "$SCRIPT" "$PKG/.apm/instructions/broken.instructions.md"
assert_failure
assert_output --partial "unbalanced braces or brackets"
}
@test "instruction: a top-level comma list with a brace group passes clean" {
write_instruction multi 'description: x
applyTo: "**/*.py, **/*.{pyi,pyx}"' 'body'
run bash "$SCRIPT" "$PKG/.apm/instructions/multi.instructions.md"
assert_success
refute_output --partial "applyTo"
}
@test "instruction: the same stem at the package root is a FAIL" {
write_instruction python 'description: x
applyTo: "**/*.py"' 'body'

View File

@@ -36,7 +36,7 @@ Match the grilled intent against exactly one row — or more than one, if the in
|---|---|---|---|
| A reusable capability the agent loads inline in the main conversation, triggered by description-matching, free to bundle its own `references/`, `scripts/` or `assets/` | Skill | `skill-author` | `references/author-routes.md` |
| A recurring task needs its own reusable definition — dedicated system prompt, tools and description, invokable by name across sessions | Agent / subagent | `agent-author` | `references/author-routes.md` |
| A runtime callback at a harness event, a rule scoped to a file pattern, or a reusable user-typed message steering existing skills | Hook / instruction / prompt (apm primitive) | `primitive-author` | `references/author-routes.md` |
| A runtime callback at a harness event, a rule scoped to a file pattern, or a reusable user-typed message steering existing skills or agents | Hook / instruction / prompt | `primitive-author` | `references/author-routes.md` |
| A new distributable unit — no existing plugin is the right home for the skill, agent, hook or MCP server being built, or the bundle needs its own manifest, versioning and install lifecycle | Plugin | `apm-workflow` (`apm plugin init`) | `references/apm-routes.md` |
| The plugin already exists and only its marketplace-facing metadata changes — a first listing, or a version/description update, never the plugin's contents | Marketplace entry | `apm-workflow` (`apm marketplace package add`) | `references/apm-routes.md` |

View File

@@ -3,7 +3,7 @@ source_keys:
- claude-code-subagents-docs
---
# Routing a skill, agent or apm primitive to its author skill
# Routing a skill, agent, hook, instruction or prompt to its author skill
Reached from `SKILL.md` Step 2 when the classified artifact is a skill, an agent/subagent
definition, or a hook, instruction or prompt. Route a skill to `skill-author`, an agent to

View File

@@ -21,14 +21,14 @@ metadata:
## Step 1 — Dispatch
| Target or intent | Primitive | Reference |
| Target or intent | Type | Reference |
|---|---|---|
| A hook — `.apm/hooks/<name>.json`, or "run X whenever Y happens" | hook | `references/hook.md` |
| An instruction — `.apm/instructions/<name>.instructions.md`, or a rule for files matching a pattern | instruction | `references/instruction.md` |
| A prompt — `.apm/prompts/<name>.prompt.md`, or a reusable message the user types to kick off work | prompt | `references/prompt.md` |
| A skill or an agent | — | stop: route to `skill-author` or `agent-author` |
Read only the reference matching the resolved primitive — each is self-contained. If the target sits inside a git worktree, capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
Read only the reference matching the resolved type — each is self-contained. If the target sits inside a git worktree, capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
## Step 2 — Boundary gate
@@ -46,7 +46,7 @@ Signals: grill output, `/factory-audit` findings, inline feedback, session conte
## Step 4 — Validate and close
1. Run `/factory-audit` on the file, inline in this context; resolve every FAIL before reporting done.
1. Run `/factory-audit` on the file, inline in this context; resolve every FAIL before reporting done, including the `### Prose` FAILs Vale raises on an instruction or prompt body.
2. Run `rtk apm install --dry-run` from the repo root and read what each target will receive. On a feature branch, discard `apm.lock.yaml` churn afterwards (`rtk git checkout -- apm.lock.yaml`).
3. Bump the owning package's `apm.yml` `version:` — minor for a new primitive, patch for a fix — unless this branch already bumped it for unreleased work. A primitive has no version of its own.
3. Bump the owning package's `apm.yml` `version:` — minor for a new hook, instruction or prompt, patch for a fix — unless this branch already bumped it for unreleased work. None of these has a version of its own.
4. **Commit verification.** Inside a git worktree, once the audit is clean, run `rtk git add` and `rtk git commit`, then re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. Staged-but-uncommitted work is silently lost if the tree is cleaned up. Outside a worktree, report done on a clean audit and name that as the reason.

View File

@@ -47,7 +47,8 @@ target:
- **`${PLUGIN_ROOT}`** is the target-neutral token; apm rewrites it per target
(`"${CLAUDE_PROJECT_DIR}/.claude/hooks/<pkg>/…"` on Claude, repo-relative elsewhere).
`${CLAUDE_PLUGIN_ROOT}` also works but ties the source to one harness's name.
`${CLAUDE_PLUGIN_ROOT}` is rewritten identically, so it is valid, but it ties the source to one
harness's name (Should 12).
- **Claude is the verified target.** apm 0.28.0 passes this nested shape to Copilot without
reshaping it, and whether Copilot CLI runs nested entries or honours `matcher` is unverified. That
gap is apm's to close. Per-file target routing is deprecated, so a Copilot-native flat hook
@@ -59,20 +60,23 @@ target:
Must:
1. The file sits directly in `.apm/hooks/`, is not a symlink, and parses as a JSON object. apm
skips invalid JSON silently.
skips invalid JSON silently. (apm also discovers a package-root `hooks/`, and `factory-audit`
accepts it for third-party packages; author in `.apm/hooks/`.)
2. Use the wrapped shape `{"hooks": {Event: [...]}}`. If a naked settings slice is used instead,
every top-level value must be a list, with no stray scalar keys anywhere.
3. Every event value is a list of objects, and every nested `hooks` is a list of objects. Anything
else fails the Copilot install outright.
else fails the Copilot install outright. The file contributes at least one entry: an empty one
deploys nothing, with only a warning.
4. Event names are PascalCase (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`,
`Stop`, …). An all-lowercase name (`stop`) never warns and never fires; a camelCase name outside
apm's rename map (`userPromptSubmit`) deploys verbatim to Claude and never fires.
5. The script is referenced as `${PLUGIN_ROOT}/…` (package root) or `./…` (hook directory),
exists inside the package, and is executable. No absolute path, and no `$` or backtick in the
path itself. A missing script is only a warning at install time. Executable is stricter than the
research's Should: a script invoked as the command's first token fails at runtime without it.
6. No `hooks-<target>` or `*-<target>-hooks` filename. That routing is deprecated; reach belongs to
`targets:` (see Gate).
5. The script is referenced as `${PLUGIN_ROOT}/…` (or `${CLAUDE_PLUGIN_ROOT}/…`, see Shape) for
the package root, or `./…` for the hook directory, and exists inside the package. No absolute
path and no bare relative path (`scripts/x.sh`): apm bundles and rewrites neither. No `$` or
backtick in the path itself. A missing script is only a warning at install time.
6. A script run directly as the command's first token is executable. This is stricter than the
research's Should: without it the hook fails every time it fires. A script passed to an
interpreter (`bash ${PLUGIN_ROOT}/x.sh`) needs no executable bit.
Should:
@@ -85,3 +89,6 @@ Should:
10. Quote a script path that may contain spaces: `"${PLUGIN_ROOT}/scripts/my hook.sh"`.
11. Keep helper files in the hook directory non-JSON. Copilot's loader rejects any bundled `.json`
without a `hooks` key.
12. Prefer `${PLUGIN_ROOT}` over `${CLAUDE_PLUGIN_ROOT}`.
13. No `hooks-<target>` or `*-<target>-hooks` filename. That routing is deprecated and reach belongs
to `targets:` (see Gate); the research allows it only when deprecated routing is intended.

View File

@@ -35,7 +35,8 @@ Must:
2. `description` is a non-empty string. apm only warns when it is missing.
3. The body is non-empty after trimming whitespace. apm deploys an empty rule silently.
4. `applyTo` is a non-empty glob or comma-separated list — top-level commas only as separators,
alternation inside `{}` (`"**/*.{ts,tsx}"`) — or absent after the Gate's explicit yes.
alternation inside `{}` (`"**/*.{ts,tsx}"`), braces and brackets balanced — or absent after the
Gate's explicit yes. An empty `applyTo: ""` is neither.
5. The stem is unique across the package and its dependencies: a `.claude/rules/<stem>.md`
collision is silently overwritten.
@@ -48,5 +49,6 @@ Should:
8. Put any rationale Claude needs in the body. `description` never reaches Claude — it survives only
for Copilot and as Cursor's index text.
9. Keep relative markdown links resolvable from the source file.
10. Check the glob against the tree: one that matches nothing never fires, and one broader than the
rule's real scope spends context on every file it touches.
10. Check the glob against the tree: one that matches nothing here fires only in consumer repos
that have such files, and one broader than the rule's real scope spends context on every file
it touches.

View File

@@ -40,25 +40,27 @@ Copy `assets/templates/name.prompt.md.template` and drop `.template` only on the
Must:
1. The path is `.apm/prompts/<name>.prompt.md`, directly in that directory, not a symlink. `<name>`
is a safe path segment and unique across `.apm/prompts/` and the package root; it becomes the
Copilot filename and the Claude `/command` name.
2. `description` is present and non-empty, per the contract above.
1. The path is `.apm/prompts/<name>.prompt.md`, directly in that directory, not a symlink or
hardlink. `<name>` is a safe path segment and unique across `.apm/prompts/` and the package
root; it becomes the Copilot filename and the Claude `/command` name.
2. `description` is present and non-empty.
3. Every `input:` name matches `^[A-Za-z][\w-]{0,63}$`, written in the object form
`- pr_number: "The PR to review"`. Never copy apm's published `- name: pr_number` /
`description: …` example: apm reads the map's keys, so it produces the arguments `name` and
`description`.
4. Every `${input:x}` in the body is declared in `input:`, and every declared name is used. Without
`input:`, no `${input:…}` may appear — it would reach Claude unrewritten.
5. Frontmatter keys stay within `description`, `allowed-tools`, `model`, `argument-hint` and
`input`. Claude drops everything else with only a warning. The one exception: a Copilot-only key
(`agent`, `tools`, …) that is intended, with its Claude drop accepted and said so.
Should:
6. Spell keys in kebab-case — `allowed-tools`, `argument-hint` — not the camelCase aliases.
7. Omit `argument-hint` when `input:` is set; apm synthesises `<a> <b>` from the input names.
8. Keep one intent per prompt, and write the body as second-person instructions.
9. Keep `description` to 250 characters or fewer.
10. Give `model` a slug the target accepts. Copilot ignores `allowed-tools` and `model`, so neither
5. Frontmatter keys stay within `description`, `allowed-tools`, `model`, `argument-hint` and
`input`. Claude drops everything else with only a warning. The exception: a Copilot-only key
(`agent`, `tools`, …) that is intended, with its Claude drop accepted and said so.
6. The description follows the contract above: one plain sentence, no trigger or boundary clause,
naming the skills or agents it steers.
7. Spell keys in kebab-case — `allowed-tools`, `argument-hint` — not the camelCase aliases.
8. Omit `argument-hint` when `input:` is set; apm synthesises `<a> <b>` from the input names.
9. Keep one intent per prompt, and write the body as second-person instructions.
10. Keep `description` to 250 characters or fewer.
11. Give `model` a slug the target accepts. Copilot ignores `allowed-tools` and `model`, so neither
constrains a Copilot run.