fix(kyberforge): resolve PR #144 review and audit round 2

- factory-audit: ./ and bare/absolute script checks scoped to command
  position (no false FAILs on ./src or printf); hook sources limited to
  .apm/hooks or package-root hooks/; Kiro-aware lowercase events;
  unfilled template placeholders FAIL; repo-only instructions FAIL at
  any scope; Vale description FAIL documented; bats 367 -> 378
- primitive-author: split-quote/spaced paths and handler-less entries
  promoted to Must; Step 4.2 renders into a scratch consumer instead of
  a no-op dry run; dispatch and gate hand-off trimmed
- apm-workflow 1.0.2: mutual boundary with primitive-author
- forge: no double package bump; gotcha wording
- skill-author: create keeps seeded 0.1.0 (ADR-0022); portable,
  retry-safe new-skill.sh; template and flow consistency fixes
- hook docs: cite the ADR-0019 correction; guard caveat

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 20:50:22 +00:00
parent df28351d3e
commit 965208bddd
29 changed files with 462 additions and 156 deletions

View File

@@ -42,7 +42,7 @@ Resolve the flow from the target path **before running anything**. The flows run
| A file named `*.instructions.md` | instruction | `references/instruction-flow.md` |
| A file named `*.prompt.md` | prompt | `references/prompt-flow.md` |
| A `.md` file whose immediate parent directory is `agents/` (`.apm/agents`, `.claude/agents`, `.github/agents`, `.copilot/agents`) | agent | `references/agent-flow.md` |
| A `.json` file whose immediate parent directory is `hooks/` (`.apm/hooks`, or a package's root `hooks/`) | hook | `references/hook-flow.md` |
| A `.json` file whose immediate parent directory is `hooks/` (`.apm/hooks`, or `hooks/` beside `apm.yml`) | hook | `references/hook-flow.md` |
| Anything else — a missing path, a directory without `SKILL.md`, any other file | none | — |
Read only the file its row matched. Each carries Steps 1 to 3 — the deterministic checks, the read, and the qualitative audit — and is self-contained. Return here for Step 4.

View File

@@ -11,7 +11,7 @@ Steps 1 to 3 for an apm hook — the target Step 0 matched as a `.json` file dir
## Gotchas
- apm checks almost nothing here. Invalid JSON is skipped without a word, an all-lowercase event deploys and never fires, and a missing script only warns — so `apm install` exiting 0 says nothing about whether the hook works. Never cite a clean install as evidence against a finding.
- apm checks almost nothing here. Invalid JSON is skipped without a word, an all-lowercase event deploys verbatim and never fires on every target but Kiro (whose map alone renames `stop`), and a missing script only warns — so `apm install` exiting 0 says nothing about whether the hook works. Never cite a clean install as evidence against a finding.
- Copilot receiving a Claude-shaped file is not a finding. apm renders one source for every target and documents that it owns the per-target shape; whether Copilot CLI honours a nested entry or `matcher` is unverified upstream, not a defect in the file.
- Quoting is not the fix for a script path with a space. apm rewrites a whole-token-quoted `"${PLUGIN_ROOT}/scripts/x.sh"`, but still stops reading the path at the space; the only fix is a path without one.
@@ -23,16 +23,15 @@ 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), a file contributing no entries (no events, only empty event lists, or an entry with no handler), event names that never fire, referenced scripts that are missing, outside the package, not executable when run directly, or referenced by an absolute, bare relative, `../`, split-quoted or space-containing path apm will not bundle correctly, deprecated filename routing, and `${CLAUDE_PLUGIN_ROOT}` where `${PLUGIN_ROOT}` would do. Script references are read with apm 0.28.0's own patterns: `${PLUGIN_ROOT}/…` only when the path follows the token directly, up to the first whitespace or quote, and `./…` anywhere in the command, including after an interpreter. A camelCase event outside Claude's rename map FAILs in a Claude-shaped file, and in a flat Copilot-shaped file too whenever the nearest `apm.yml` above the file targets Claude — no `target:`/`targets:` means every target. 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 (no events, only empty event lists, or an entry with no handler), event names that never fire, unfilled `FILL IN` or `FILL_IN_` template placeholders, a file under apm's deployed output rather than package source, referenced scripts that are missing, outside the package, not executable when run directly, or referenced by an absolute, bare relative, `../`, split-quoted or space-containing path apm will not bundle correctly, deprecated filename routing, and `${CLAUDE_PLUGIN_ROOT}` where `${PLUGIN_ROOT}` would do. Script references are read with apm 0.28.0's own patterns: `${PLUGIN_ROOT}/…` only when the path follows the token directly, up to the first whitespace or quote, and `./…` anywhere in the command. A `./` or `../` match is held to the script rules — a FAIL when missing — only in command position (the first token, or the first argument after `bash`, `sh`, `zsh`, `python`, `python3`, `node`, `pwsh`, `ruby` or `perl`), when it ends in a script extension, or when it names a package entry that is not a file; any other match (`npx prettier --check ./src`, `printf '.\n'`) is at most a SUGGESTION, because apm only warns and it runs against the consumer's working directory as meant. Absolute and bare relative script paths are checked in the same command positions. An all-lowercase event FAILs only when no target the package deploys to renames it, and is a SUGGESTION when only some do. A camelCase event outside Claude's rename map FAILs in a Claude-shaped file, and in a flat Copilot-shaped file too whenever the nearest `apm.yml` above the file targets Claude — no `target:`/`targets:` means every target. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason. An INFO line is observational: report it under `### Structure` and count it in `· P info`.
There is no provenance and no Vale step: a hook carries no `source_keys` and no prose.
Four tiers deliberately differ from `primitive-author`'s checklist or the research's. Do not re-tier them by judgment:
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/`.
- A hook file directly under a package-root `hooks/` — beside the package's `apm.yml` — passes. apm discovers both `.apm/hooks/` and `hooks/`, and this audit may target a third-party package; `primitive-author` authors only in `.apm/hooks/`. Any other `hooks/` directory (`.github/hooks/`, `.cursor/hooks/`, …) is apm's deployed output and FAILs.
- 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.
- A split-quoted `"${PLUGIN_ROOT}"/x.sh` or a script path containing a space is a FAIL, stricter than the author's Should 10: apm leaves the first unrewritten and cuts the second at the space, so either deploys a hook that fails every time it fires.
## Step 2 — Read the hook and its scripts

View File

@@ -23,11 +23,11 @@ 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: 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.
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path, unfilled `FILL IN` or `FILL_IN_` template placeholders, 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.
A missing `applyTo` is a SUGGESTION, not the FAIL `primitive-author`'s instruction Must 4 implies: absence is legal after the author Gate's explicit yes, which the audit cannot see. The **scope** dimension's always-on FAILs below cover the misuse. Do not re-tier it by judgment.
`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.
`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. That includes the rules scoped to the `description` (`Kyberforge.DescriptionOpener`, `VagueWording`, `CompositionNote`), a deliberate deviation from `primitive-author`, which holds description wording to at most a Should: the house prose rules apply to every model- or user-visible description, and a deterministic rule does not change tier by file kind. Do not re-tier them. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
There is no provenance step: an instruction carries no `source_keys`.
@@ -41,7 +41,7 @@ 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: the content is a rule for this repo alone, scoped or always-on — it belongs in `AGENTS.md` (a nested `AGENTS.md` for a subtree), which is the repo's own instruction source, not in a package that ships it to every consumer. This matches `primitive-author`'s Gate, which routes every repo-only rule there.
- 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`.

View File

@@ -24,9 +24,9 @@ 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 or `Not X -> Y` boundary clause, keys Claude drops, `input:` names and the object form `- <name>: "<desc>"` (`primitive-author` prompt Must 3 — a bare name, a string list or a plain map is a FAIL even though apm reads them), 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.
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description` presence, length, and trigger or `Not X -> Y` boundary clause, keys Claude drops, the camelCase spelling of `allowed-tools` or `argument-hint` and an `argument-hint` alongside `input:` (both SUGGESTION), `input:` names and the object form `- <name>: "<desc>"` (`primitive-author` prompt Must 3 — a bare name, a string list or a plain map is a FAIL even though apm reads them), and `${input:x}` references against `input:`. Keys Claude drops are a SUGGESTION, per `primitive-author` prompt Should 5: 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.
`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. That includes the rules scoped to the `description` (`Kyberforge.DescriptionOpener`, `VagueWording`, `CompositionNote`), a deliberate deviation from `primitive-author`, which holds description wording to at most a Should: the house prose rules apply to every model- or user-visible description, and a deterministic rule does not change tier by file kind. Do not re-tier them. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
There is no provenance step: a prompt carries no `source_keys`.

View File

@@ -13,16 +13,16 @@
# 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 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, 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.
# checks follow primitive-author's hook, instruction and prompt reference
# checklists (research provenance: source key apm-cli-installed-source in
# references/sources.md), except where references/{hook,instruction,prompt}-flow.md
# documents a deliberate deviation (a tier moved, or a check the author 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
# Claude, which is why it gets the two ADR-0029 SUGGESTIONs below — but whether
# Claude, which is why it gets the three ADR-0029 description SUGGESTIONs below
# (length, trigger clause, boundary clause) — but whether
# a prompt body carries procedure that belongs in a skill is a judgment call the
# prompt flow makes by reading it, and deliberately has no heuristic here.
#
@@ -59,6 +59,7 @@ import sys
import os
import re
import json
import shlex
import yaml
@@ -186,8 +187,32 @@ APM_ROOT_REF_RE = re.compile(r'\$\{(?:' + '|'.join(ROOT_TOKENS) + r')\}([\\/][^\
APM_REL_REF_RE = re.compile(r'(\.[\\/][^\s"\']+)')
def _is_first(prefix):
return not prefix.strip().strip('"\'').strip()
# An interpreter whose first argument is the script it runs. A reference in
# that argument slot is in command position just as a first token is.
INTERPRETERS = {'bash', 'sh', 'zsh', 'python', 'python3', 'node', 'pwsh', 'ruby', 'perl'}
def _prefix_tokens(prefix):
return [t.strip('"\'') for t in prefix.split()]
def _interp_arg_index(tokens):
"""Index of the token that is the first argument after a known interpreter
(optionally behind `env`), or None when the command does not open with one."""
i = 0
if tokens and os.path.basename(tokens[0]) == 'env':
i = 1
if len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS:
return i + 1
return None
def _position(prefix):
"""(is_first_token, is_interpreter_arg) for a reference preceded by prefix."""
toks = [t for t in _prefix_tokens(prefix) if t]
if not toks:
return True, False
return False, _interp_arg_index(toks) == len(toks)
def is_handler(h):
@@ -200,12 +225,13 @@ def is_handler(h):
return h.get('type') not in (None, 'command')
def extract_script_refs(cmd, where):
"""Yield (kind, relpath, is_first_token) for each package-relative script
reference apm would rewrite, reading the command exactly as apm does. kind
is 'root' for a ${*_PLUGIN_ROOT} token, 'rel' for a ./path. A token apm
reads wrongly — split-quoted, or a path with a space — is a FAIL here,
because apm leaves it unrewritten or cuts it short."""
def extract_script_refs(cmd, pkg_root, where):
"""Return (kind, relpath, is_first_token, is_interpreter_arg) for each
package-relative reference apm would rewrite, reading the command exactly
as apm does. kind is 'root' for a ${*_PLUGIN_ROOT} token, 'rel' for a
./path, 'up' for a ../path. A token apm reads wrongly — split-quoted, or a
path with a space — is a FAIL here, because apm leaves it unrewritten or
cuts it short."""
refs = []
masked = cmd
for m in ROOT_TOKEN_RE.finditer(cmd):
@@ -219,34 +245,42 @@ def extract_script_refs(cmd, where):
nxt = cmd[end:end + 1]
# A backslash-escaped space, or a quoted token whose script name only
# completes past the whitespace apm stopped at ("…/my hook.sh").
# A path apm read that exists as a file is exactly what apm bundles, so
# a later argument inside the same quotes (`bash -c "…/tool --x a.sh"`)
# is an argument, not the rest of a spaced name.
spaced = nxt.isspace() and path.endswith('\\')
if not spaced and opener is not None and nxt.isspace():
quoted = cmd[start:].split(opener, 1)[0]
spaced = bool(SCRIPT_EXT_RE.search(quoted)) and not SCRIPT_EXT_RE.search(path)
exists = os.path.isfile(os.path.join(pkg_root, path.replace('\\', '/').lstrip('/')))
spaced = (not exists and bool(SCRIPT_EXT_RE.search(quoted))
and not SCRIPT_EXT_RE.search(path))
if spaced:
fail(f"script path '{cmd[start:]}' contains a space — apm reads a ${{PLUGIN_ROOT}} path only up to the first whitespace or quote, so it bundles the wrong file and the hook fails; rename the script without spaces — {where}")
else:
prefix = cmd[:start - 1] if opener else cmd[:start]
refs.append(('root', path.replace('\\', '/').lstrip('/'), _is_first(prefix)))
refs.append(('root', path.replace('\\', '/').lstrip('/')) + _position(prefix))
masked = masked[:start] + ' ' * (end - start) + masked[end:]
for m in APM_REL_REF_RE.finditer(masked):
start = m.start()
ref = m.group(1)
kind_ = 'rel'
if start > 0 and masked[start - 1] == '.':
fail(f"script path '..{ref[1:]}' starts with ../ — apm reads it as ./{ref[2:]} from the hook directory, not the parent, so the wrong file (or none) is bundled; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
continue
kind_, start = 'up', start - 1
opener = masked[start - 1] if start > 0 and masked[start - 1] in '"\'' else None
prefix = masked[:start - 1] if opener else masked[:start]
refs.append(('rel', ref[2:].replace('\\', '/'), _is_first(prefix)))
refs.append((kind_, ref[2:].replace('\\', '/')) + _position(prefix))
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 command_tokens(cmd):
"""The command's leading whitespace-delimited tokens, quotes removed."""
try:
return shlex.split(cmd)
except ValueError:
return _prefix_tokens(cmd)
def check_unanchored_script(cmd, pkg_root, where):
@@ -255,26 +289,53 @@ def check_unanchored_script(cmd, pkg_root, where):
# 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('~'):
# consumer's machine. Checked in command position only: the first token,
# and the first argument after a known interpreter (`bash scripts/x.sh`).
# A later argument is data, not a script apm is asked to run.
toks = command_tokens(cmd)
if not toks:
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
slots = [0]
arg = _interp_arg_index(toks)
if arg is not None and arg < len(toks):
slots.append(arg)
for idx in slots:
tok = toks[idx]
if not tok or tok.startswith(('./', '../', '~', '-')) or '$' in tok:
continue
if tok.startswith('/'):
real_root = os.path.realpath(pkg_root)
inside = os.path.realpath(tok).startswith(real_root + os.sep)
# In the first slot an extension-less absolute path outside the
# package (`/usr/bin/env`, `/bin/bash`) is the host's interpreter;
# in the interpreter-argument slot it is the script being run.
if inside or SCRIPT_EXT_RE.search(tok) or idx > 0:
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}")
continue
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}")
break
def check_script(kind_, rel, first, pkg_root, where):
def check_script(kind_, rel, first, interp_arg, pkg_root, where):
if not rel:
return
# apm's ./ pattern also matches plain arguments — a cwd directory
# (`npx prettier --check ./src`), a printf escape (`'.\\n'`), a sibling path.
# apm only warns on those and they run against the consumer's cwd as
# meant, so a ./ or ../ match is held to the script rules only in command
# position or when it names a script by extension (or a package entry that
# is not a file).
strong = kind_ == 'root' or first or interp_arg or bool(SCRIPT_EXT_RE.search(rel))
if kind_ == 'up':
in_pkg = os.path.exists(os.path.join(pkg_root, rel)) and not os.path.isfile(os.path.join(pkg_root, rel))
if strong or in_pkg:
fail(f"script path '../{rel}' starts with ../ — apm reads it as ./{rel} from the hook directory, not the parent, so the wrong file (or none) is bundled; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
else:
suggest(f"argument '../{rel}' matches apm's ./ script pattern — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}")
return
if '$' in rel or '`' in rel:
fail(f"script path '{rel}' contains '$' or a backtick — apm refuses to rewrite it for Claude — {where}")
return
@@ -295,7 +356,12 @@ def check_script(kind_, rel, first, pkg_root, where):
found = c
break
if found is None:
fail(f"script '{rel}' does not exist in the package — apm only warns, then deploys a hook that fails every time it fires — {where}")
not_a_file = any(os.path.exists(c) for c in candidates)
if strong or not_a_file:
what = "exists in the package but is not a regular file" if not_a_file else "does not exist in the package"
fail(f"script '{rel}' {what} — apm only warns, then deploys a hook that fails every time it fires — {where}")
else:
suggest(f"argument './{rel}' matches apm's ./ script pattern but names no package file — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}")
return
if first and not os.access(found, os.X_OK):
fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}")
@@ -314,44 +380,73 @@ def owning_apm_yml():
d = up
def targets_claude():
"""Whether apm renders this package's hooks to Claude. Mirrors
def package_targets():
"""The set of targets apm renders this package's hooks to. Mirrors
parse_targets_field: no target:/targets: (or no apm.yml) means every
target, and 'all' folds to every target. An unreadable apm.yml is treated
as every target, the reading that keeps the stricter check on."""
as every target, the reading that keeps the stricter checks on."""
every = set(ROUTING_TOKENS)
path = owning_apm_yml()
if path is None:
return True
return every
try:
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
except (OSError, UnicodeDecodeError, yaml.YAMLError):
return True
return every
if not isinstance(data, dict):
return True
return every
raw = data.get('targets', data.get('target'))
if raw is None:
return True
return every
if isinstance(raw, list):
tokens = [str(t).strip().lower() for t in raw]
else:
tokens = [t.strip().lower() for t in str(raw).split(',')]
tokens = [t for t in tokens if t]
return not tokens or 'claude' in tokens or 'all' in tokens
tokens = {t for t in tokens if t}
if not tokens or 'all' in tokens:
return every
return tokens
# apm 0.28.0 _HOOK_EVENT_MAP: the only all-lowercase source name any target
# renames is Kiro's `stop` -> `Stop`. Every other target deploys an
# all-lowercase name verbatim, where it never fires.
LOWERCASE_EVENT_TARGETS = {'stop': {'kiro'}}
# The directories apm deploys hooks into for each harness. A hook file under
# one of them is install output, not package source.
DEPLOY_ROOTS = ('.github', '.claude', '.cursor', '.codex', '.kiro', '.windsurf',
'.gemini', '.vscode', '.antigravity', '.copilot')
PLACEHOLDER_RE = re.compile(r'FILL IN|FILL_IN_')
def check_placeholders(content):
m = PLACEHOLDER_RE.search(content)
if m:
line = content.count('\n', 0, m.start()) + 1
fail(f"unfilled template placeholder '{m.group(0)}' at line {line} — primitive-author Step 3 fills every FILL IN and FILL_IN_ placeholder before the file ships — {fname}")
def audit_hook():
check_not_linked(hardlinks=False)
stem = fname[:-len('.json')]
hooks_dir = os.path.basename(parent_dir)
if hooks_dir != 'hooks':
fail(f"is not directly in a hooks/ directory — apm discovers hook files only at .apm/hooks/*.json and hooks/*.json, non-recursively — {fname}")
if os.path.basename(os.path.dirname(parent_dir)) == '.apm':
pkg_root = os.path.dirname(os.path.dirname(parent_dir))
# validate.sh dispatches only a .json directly under a hooks/ directory.
# apm discovers package source at .apm/hooks/*.json and at a package-root
# hooks/*.json; anything else under a hooks/ directory is apm's deployed
# output (.github/hooks/, .cursor/hooks/, ...) or not a package at all.
above = os.path.dirname(parent_dir)
if os.path.basename(above) == '.apm':
pkg_root = os.path.dirname(above)
if not os.path.isfile(os.path.join(pkg_root, 'apm.yml')):
info(f"no apm.yml at the inferred package root {pkg_root} — script paths are resolved against it anyway — {fname}")
elif os.path.isfile(os.path.join(above, 'apm.yml')):
pkg_root = above
else:
pkg_root = os.path.dirname(parent_dir)
if not os.path.isfile(os.path.join(pkg_root, 'apm.yml')):
info(f"no apm.yml at the inferred package root {pkg_root} — script paths are resolved against it anyway — {fname}")
kind_of = (f"apm's deployed output ({os.path.basename(above)}/hooks/)"
if os.path.basename(above) in DEPLOY_ROOTS else 'no package source')
fail(f"is {kind_of} — apm reads hook source only from <package>/.apm/hooks/*.json or a package-root hooks/*.json beside apm.yml; audit the source file in the package's .apm/hooks/ instead — {fname}")
return
# apm lowercases the stem before routing (hook_file_routing.py).
if ROUTING_STEM_RE.search(stem.lower()):
@@ -360,6 +455,7 @@ def audit_hook():
content = read_text(target)
if content is None:
return
check_placeholders(content)
try:
doc = json.loads(content)
except json.JSONDecodeError as exc:
@@ -424,12 +520,18 @@ def audit_hook():
# camelCase outside Claude's map never fires on Claude. A flat
# Copilot-shaped file still renders to Claude whenever the package targets
# it, so the exemption holds only for a package that does not.
camel_checked = claude_shaped or targets_claude()
deploys_to = package_targets()
camel_checked = claude_shaped or 'claude' in deploys_to
for event in events:
if not event.strip():
fail(f"empty event name — {fname}")
elif not any(c.isupper() for c in event):
fail(f"event '{event}' is all-lowercase — no target maps it and apm never warns, so it silently never fires — {fname}")
mapping = LOWERCASE_EVENT_TARGETS.get(event, set())
unmapped = sorted(deploys_to - mapping)
if not mapping & deploys_to:
fail(f"event '{event}' is all-lowercase — no target this package deploys to maps it, and apm never warns, so it silently never fires; write it in PascalCase — {fname}")
elif unmapped:
suggest(f"event '{event}' is all-lowercase — only Kiro renames it; {', '.join(unmapped)} receive it verbatim and it never fires there; write it in PascalCase — {fname}")
elif camel_checked and event[0].islower() and event not in CLAUDE_MAPPED_CAMEL:
why = 'in a Claude-shaped file' if claude_shaped else "and the package's apm.yml targets Claude (no targets: means every target)"
fail(f"event '{event}' is camelCase {why}, and Claude's map does not rename it — it deploys verbatim to Claude and never fires; write it in PascalCase — {fname}")
@@ -449,11 +551,9 @@ def audit_hook():
continue
if '${CLAUDE_PLUGIN_ROOT}' in cmd:
uses_claude_token = True
refs = extract_script_refs(cmd, where)
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)
for kind_, rel, first, interp_arg in extract_script_refs(cmd, pkg_root, where):
check_script(kind_, rel, first, interp_arg, pkg_root, where)
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}")
@@ -524,6 +624,7 @@ def audit_instruction():
content = read_text(target)
if content is None:
return
check_placeholders(content)
fm, body, ok = split_frontmatter(content)
if not ok:
return
@@ -621,6 +722,7 @@ def audit_prompt():
content = read_text(target)
if content is None:
return
check_placeholders(content)
fm, body, ok = split_frontmatter(content)
if not ok:
return

View File

@@ -131,7 +131,8 @@ the target:
directory is named 'agents' (.apm/agents, .claude/agents,
.github/agents, .copilot/agents).
hook mode the target is a *.json file directly under a hooks/
directory (.apm/hooks, or a package's root hooks/).
directory (.apm/hooks, or a hooks/ beside apm.yml; any
other hooks/ directory is apm's deployed output and FAILs).
instruction mode the target is a *.instructions.md file.
prompt mode the target is a *.prompt.md file.

View File

@@ -95,11 +95,28 @@ teardown() {
assert_output --partial "naked settings-slice shape"
}
@test "hook: an all-lowercase event is a FAIL" {
write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
@test "hook: an all-lowercase event no target maps is a FAIL" {
write_hook hooks.json '{"hooks":{"pretooluse":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "event 'stop' is all-lowercase"
assert_output --partial "event 'pretooluse' is all-lowercase — no target this package deploys to maps it"
}
@test "hook: lowercase stop is a SUGGESTION for every target, clean for Kiro only, a FAIL without Kiro" {
write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
assert_output --partial "SUGGESTION event 'stop' is all-lowercase — only Kiro renames it"
printf 'name: test-package\nversion: 0.1.0\ntargets: [kiro]\n' > "$PKG/apm.yml"
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "all-lowercase"
printf 'name: test-package\nversion: 0.1.0\ntargets: [claude, copilot]\n' > "$PKG/apm.yml"
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "event 'stop' is all-lowercase — no target this package deploys to maps it"
}
@test "hook: camelCase userPromptSubmit in a Claude-shaped file is a FAIL; mapped sessionStart is not" {
@@ -338,6 +355,101 @@ teardown() {
assert_output --partial "script 'scripts/gone.sh' does not exist"
}
@test "hook: a ./ argument outside command position is at most a SUGGESTION (npx prettier --check ./src)" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"npx prettier --check ./src","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
assert_output --partial "SUGGESTION argument './src' matches apm's ./ script pattern"
}
@test "hook: a printf escape after a script is not a missing-script FAIL (printf '.\n')" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"\"${PLUGIN_ROOT}/.apm/hooks/scripts/check.sh\" && printf '"'"'.\\n'"'"'","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
refute_output --partial "does not exist"
}
@test "hook: a ../ argument outside command position is a SUGGESTION; in command position a FAIL" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"git -C ../sibling status","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
assert_output --partial "SUGGESTION argument '../sibling'"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"python3 ../tool","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "starts with ../"
}
@test "hook: a non-command-position ./ match is still a FAIL with a script extension or when it is a package directory" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"cat ./gone.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script 'gone.sh' does not exist in the package"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"npx prettier --check ./scripts","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script 'scripts' exists in the package but is not a regular file"
}
@test "hook: a quoted \${PLUGIN_ROOT} path followed by arguments is not a spaced path (bash -c \"…/bin/tool --config conf.sh\")" {
mkdir -p "$PKG/bin"
printf '#!/usr/bin/env bash\nexit 0\n' > "$PKG/bin/tool"
chmod +x "$PKG/bin/tool"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -c \"${PLUGIN_ROOT}/bin/tool --config conf.sh\"","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
}
@test "hook: a bare relative or absolute script after an interpreter is a FAIL; as a later argument it is not" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash scripts/check.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script 'scripts/check.sh' is a bare relative path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash /opt/x.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script '/opt/x.sh' is an absolute path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/usr/bin/env python3 /opt/x","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script '/opt/x' is an absolute path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"echo scripts/check.sh /opt/x.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
}
@test "hook: a hook file under apm's deployed output is a FAIL naming the source location" {
mkdir -p "$PKG/.github/hooks"
printf '%s\n' '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true","timeout":5}]}]}}' > "$PKG/.github/hooks/p.json"
run bash "$SCRIPT" "$PKG/.github/hooks/p.json"
assert_failure 1
assert_output --partial "is apm's deployed output (.github/hooks/)"
assert_output --partial ".apm/hooks/"
mkdir -p "$TMPDIR/loose/hooks"
printf '%s\n' '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true","timeout":5}]}]}}' > "$TMPDIR/loose/hooks/x.json"
run bash "$SCRIPT" "$TMPDIR/loose/hooks/x.json"
assert_failure 1
assert_output --partial "is no package source"
}
@test "hook: an unedited primitive-author hook template is an unfilled-placeholder FAIL" {
cp "$REPO_ROOT/plugins/kyberforge/.apm/skills/primitive-author/assets/templates/hook.json.template" "$PKG/.apm/hooks/hooks.json"
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "unfilled template placeholder 'FILL_IN_'"
}
# ---------------------------------------------------------------------------
# Instructions
# ---------------------------------------------------------------------------
@@ -445,6 +557,13 @@ applyTo: "**/*.py"' 'body'
assert_output --partial "is a hardlink"
}
@test "instruction: an unedited primitive-author template is an unfilled-placeholder FAIL" {
cp "$REPO_ROOT/plugins/kyberforge/.apm/skills/primitive-author/assets/templates/name.instructions.md.template" "$PKG/.apm/instructions/name.instructions.md"
run bash "$SCRIPT" "$PKG/.apm/instructions/name.instructions.md"
assert_failure 1
assert_output --partial "unfilled template placeholder 'FILL IN'"
}
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
@@ -591,6 +710,13 @@ $(printf 'line\n%.0s' $(seq 1 80))
assert_output --partial "is a hardlink"
}
@test "prompt: an unedited primitive-author template is an unfilled-placeholder FAIL" {
cp "$REPO_ROOT/plugins/kyberforge/.apm/skills/primitive-author/assets/templates/name.prompt.md.template" "$PKG/.apm/prompts/name.prompt.md"
run bash "$SCRIPT" "$PKG/.apm/prompts/name.prompt.md"
assert_failure 1
assert_output --partial "unfilled template placeholder 'FILL IN'"
}
# ---------------------------------------------------------------------------
# Never ran (exit 2)
# ---------------------------------------------------------------------------