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

- factory-audit: no-op hooks, ./ after interpreters, split-quote and
  spaced ${PLUGIN_ROOT} paths, camelCase events in Claude-targeted flat
  files, case-insensitive routing stems, and non-string YAML keys are
  now caught; input: forms and prompt boundary clauses align with
  primitive-author; bats 347 -> 367
- primitive-author: routing forms, quoting guidance, install exit on
  hidden Unicode, argument-hint exception
- forge: drop duplicated gotcha, fit description and body budgets (#143)
- skill-author: primitive-author boundary, Claude-only env vars
- hook: exit unless CLAUDE_PROJECT_DIR is set, so Copilot/Codex never
  run apm update; ADR-0019 correction, ADR-0025 amendment, docs fixes

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:13:43 +00:00
parent b2d77b2945
commit df28351d3e
23 changed files with 591 additions and 147 deletions

View File

@@ -261,6 +261,20 @@ no hook at all, for the reasons already documented in `plugins/kyberforge/docs/h
> need a separate package declaring `target: claude` — the seventh-plugin alternative below, still > need a separate package declaring `target: claude` — the seventh-plugin alternative below, still
> rejected as disproportionate — because per-file routing (`claude-hooks.json`) is deprecated and > rejected as disproportionate — because per-file routing (`claude-hooks.json`) is deprecated and
> narrowing kyberforge's own `targets:` would drop its skills from Copilot and Codex. > narrowing kyberforge's own `targets:` would drop its skills from Copilot and Codex.
>
> *The "inert" rationale for the reach is superseded by the correction below.*
> **Correction (2026-09-28) — the lockfile guard never made the hook inert under Copilot or Codex;
> a `CLAUDE_PROJECT_DIR` guard now does.** The hook only reaches a project through `apm install`,
> which writes `apm.lock.yaml`, so in every project that receives it the lockfile guard passes. The
> script then fell back to `$PWD` for its project directory and ran `apm outdated`, and
> `apm update --yes` when anything was stale — a lock rewrite and full redeploy on a Copilot or Codex
> session start, with no `reloadSkills` or advice that harness understands. The hook is now Claude
> Code only: `check-apm-current.sh` opens with `[[ -n "${CLAUDE_PROJECT_DIR:-}" ]] || exit 0`, the
> cwd fallback is gone, and `tests/test-apm-current-hook.sh` pins that an unset or empty
> `CLAUDE_PROJECT_DIR` exits 0 silently without invoking `apm`. apm still deploys the hook to
> Copilot and Codex; it exits immediately there. The acceptance stands on that guard, not on the
> lockfile. The seventh-plugin alternative below remains rejected, now for the same reason.
**`scripts/git-hooks/` is now empty.** `post-push` and `test-post-push.sh` are deleted. **`scripts/git-hooks/` is now empty.** `post-push` and `test-post-push.sh` are deleted.
`install.sh`'s copy block is generic and is kept; `test-git-hooks-install.sh` now synthesizes its `install.sh`'s copy block is generic and is kept; `test-git-hooks-install.sh` now synthesizes its

View File

@@ -172,6 +172,31 @@ fallback row. It could not route a `SKILL.md` file path, a trigger its own descr
Its agent row ("a path under `.apm/agents/`… or an agent markdown file") was both wider than the Its agent row ("a path under `.apm/agents/`… or an agent markdown file") was both wider than the
script and circular. script and circular.
> **Amendment (2026-09-28) — three more modes: hook, instruction and prompt.** The "two accepted
> shapes" above are now five. The `primitive-author` change (issue #94, PR #144) added three rows to
> the Step 0 table, each with its own self-contained flow file and a shared
> `scripts/lib-checks-primitive.sh` that `validate.sh` sources for all three:
>
> - A file named `*.instructions.md` takes the instruction flow (`references/instruction-flow.md`).
> - A file named `*.prompt.md` takes the prompt flow (`references/prompt-flow.md`); the prompt rules
> themselves are ADR-0029's.
> - A `.json` file whose *immediate* parent directory is `hooks/` (`.apm/hooks/`, or a package's
> root `hooks/`) takes the hook flow (`references/hook-flow.md`).
>
> The suffix rows are checked before the `agents/`-parent rule, so a `*.prompt.md` or
> `*.instructions.md` under `agents/` is not audited as an agent. The fallback row is unchanged in
> kind: anything else stops, runs no validator, exits 2, and names every accepted shape rather than
> two. The exit-code table below applies to the new modes as written; its "matches neither shape"
> row now means "matches none of the five".
>
> This is ADR-0020's merge-siblings rule applied forward rather than a new decision. Auditing a hook,
> an instruction or a prompt is the same job as auditing a skill or agent — deterministic checks,
> a read, a qualitative pass, one shared report — over a different input type, which is exactly the
> case the rule says belongs in one skill behind a dispatch table, not in a new sibling audit skill.
> The authoring side follows the rule's other half: the author skills stay split because they emit
> genuinely different artifacts, so the three primitives got their own `primitive-author` rather
> than rows in `skill-author` or `agent-author`.
**3. One entry point per script, auto-detecting, with the mode-specific half sourced.** **3. One entry point per script, auto-detecting, with the mode-specific half sourced.**
- `scripts/validate.sh` detects the target type itself, then sources `scripts/lib-boundary-resolver.sh` - `scripts/validate.sh` detects the target type itself, then sources `scripts/lib-boundary-resolver.sh`

View File

@@ -10,16 +10,22 @@
# Refreshes in place and asks the host to re-scan, so the running session picks # Refreshes in place and asks the host to re-scan, so the running session picks
# the new content up without a restart. # the new content up without a restart.
# #
# Inert in any project that does not consume packages through apm. # Inert under any host but Claude Code, and in any project that does not
# consume packages through apm.
set -uo pipefail set -uo pipefail
# Anchor on the project root, not the session's cwd. Claude Code exports # Claude Code only. apm deploys this hook to Copilot and Codex too, and there
# CLAUDE_PROJECT_DIR for SessionStart hooks; a session opened in a subdirectory # the lockfile guard below would pass — apm wrote the lock — so without this
# would otherwise miss the lockfile, no-op silently, and — worse — run the apm # guard a non-Claude session start would run `apm update --yes` and rewrite the
# calls below against that wrong directory. Fall back to the cwd when the # working tree with nothing to re-scan it. Claude Code exports
# variable is absent, which keeps the hook inert-but-harmless under a host that # CLAUDE_PROJECT_DIR for SessionStart hooks and the other targets do not
# does not set it. # document it, so its absence is the exit (ADR-0019, amendment 2026-09-28).
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}" [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] || exit 0
# Anchor on the project root, not the session's cwd: a session opened in a
# subdirectory would otherwise miss the lockfile, no-op silently, and — worse —
# run the apm calls below against that wrong directory.
project_dir="$CLAUDE_PROJECT_DIR"
# No lockfile means nothing was installed through apm here — e.g. a host that # No lockfile means nothing was installed through apm here — e.g. a host that
# installed this plugin natively. Say nothing and cost nothing. # installed this plugin natively. Say nothing and cost nothing.

View File

@@ -13,6 +13,7 @@ Steps 1 to 3 for an apm hook — the target Step 0 matched as a `.json` file dir
- 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 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.
- 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. - 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.
## Step 1 — Deterministic checks ## Step 1 — Deterministic checks
@@ -22,15 +23,16 @@ Resolve the path against this skill's own directory. Run exactly:
bash scripts/validate.sh <hook-file> 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, 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. 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.
There is no provenance and no Vale step: a hook carries no `source_keys` and no prose. 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: Four 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/` 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. - 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 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 ## Step 2 — Read the hook and its scripts
@@ -51,7 +53,6 @@ Cite file and line for every finding.
- SUGGESTION: a tool event (`PreToolUse`, `PostToolUse`) or `SessionStart` with no `matcher` — Claude receives `"*"`. A `matcher` on an event Claude ignores it for (`Stop`, `UserPromptSubmit`) is inert, not wrong. - SUGGESTION: a tool event (`PreToolUse`, `PostToolUse`) or `SessionStart` with no `matcher` — Claude receives `"*"`. A `matcher` on an event Claude ignores it for (`Stop`, `UserPromptSubmit`) is inert, not wrong.
- 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: 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: `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. - 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: Then return to `SKILL.md` Step 4, opening the report with this coverage line:

View File

@@ -25,6 +25,8 @@ 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, 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. `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`. There is no provenance step: an instruction carries no `source_keys`.

View File

@@ -2,6 +2,7 @@
source_keys: source_keys:
- apm-cli-installed-source - apm-cli-installed-source
- apm-docs-llms-full - apm-docs-llms-full
- adr-0029-prompt-house-rule
--- ---
# Prompt Flow # Prompt Flow
@@ -23,7 +24,7 @@ bash scripts/validate.sh <prompt-file>
bash scripts/vale-wrap.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:`. 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, 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.
`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. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
@@ -48,7 +49,7 @@ Cite file and line for every finding.
**description** **description**
- SUGGESTION: the description does not read as one user-facing action, or does not name the skills or agents the prompt steers. On Claude the description is model-visible and apm drops `disable-model-invocation`, so naming what it steers keeps the router pointed at the capability rather than the wrapper. - SUGGESTION: the description does not read as one user-facing action, carries a `Not X -> Y` boundary clause (`primitive-author` prompt Should 6), or does not name the skills or agents the prompt steers. On Claude the description is model-visible and apm drops `disable-model-invocation`, so naming what it steers keeps the router pointed at the capability rather than the wrapper.
Then return to `SKILL.md` Step 4, opening the report with this coverage line: Then return to `SKILL.md` Step 4, opening the report with this coverage line:

View File

@@ -12,6 +12,7 @@ source_keys:
- github-custom-agents-configuration - github-custom-agents-configuration
- apm-cli-installed-source - apm-cli-installed-source
- apm-docs-llms-full - apm-docs-llms-full
- adr-0029-prompt-house-rule
--- ---
# Sources # Sources
@@ -169,3 +170,12 @@ source_keys:
- **Description:** Published apm docs bundle — the "Hooks and commands", "Instructions and agents" and "Author a prompt" guides: canonical hook shape and `${PLUGIN_ROOT}`, reach narrowed by `targets:` rather than filename routing, and "reach for a skill, instruction, or prompt first" - **Description:** Published apm docs bundle — the "Hooks and commands", "Instructions and agents" and "Author a prompt" guides: canonical hook shape and `${PLUGIN_ROOT}`, reach narrowed by `targets:` rather than filename routing, and "reach for a skill, instruction, or prompt first"
- **Contributing files:** SKILL.md, references/hook-flow.md, references/instruction-flow.md, references/prompt-flow.md - **Contributing files:** SKILL.md, references/hook-flow.md, references/instruction-flow.md, references/prompt-flow.md
- **Status:** `extracted` - **Status:** `extracted`
## adr-0029-prompt-house-rule
- **URL:** docs/adr/0029-prompts-are-thin-user-triggered-steering-messages.md
- **Research doc:** none
- **Basis:** docs/adr/0029-prompts-are-thin-user-triggered-steering-messages.md
- **Description:** The repo's prompt house rule — a prompt is a single-intent, user-triggered steering message with no procedure — and its description contract: one user-facing sentence naming the steered skills, no trigger or boundary clause
- **Contributing files:** references/prompt-flow.md
- **Status:** `extracted`

View File

@@ -178,32 +178,66 @@ CLAUDE_MAPPED_CAMEL = {'preToolUse', 'postToolUse', 'sessionStart', 'agentStop'}
HOOK_COMMAND_KEYS = ('command', 'bash', 'powershell', 'windows', 'linux', 'osx') HOOK_COMMAND_KEYS = ('command', 'bash', 'powershell', 'windows', 'linux', 'osx')
ROOT_TOKENS = ('PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT', 'KIRO_PLUGIN_ROOT') ROOT_TOKENS = ('PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT', 'KIRO_PLUGIN_ROOT')
ROOT_TOKEN_RE = re.compile(r'\$\{(' + '|'.join(ROOT_TOKENS) + r')\}') ROOT_TOKEN_RE = re.compile(r'\$\{(' + '|'.join(ROOT_TOKENS) + r')\}')
# apm 0.28.0's own patterns, hook_integrator.py _rewrite_command_for_target:
# the path must follow the token directly and ends at whitespace or a quote.
# The ./ pattern is applied with finditer over the whole command, so it
# matches after an interpreter (`bash ./x.sh`) too.
APM_ROOT_REF_RE = re.compile(r'\$\{(?:' + '|'.join(ROOT_TOKENS) + r')\}([\\/][^\s"\']+)')
APM_REL_REF_RE = re.compile(r'(\.[\\/][^\s"\']+)')
def extract_script_refs(cmd): def _is_first(prefix):
return not prefix.strip().strip('"\'').strip()
def is_handler(h):
# A handler runs something: a command key, or a non-command handler type
# (Claude's prompt/agent/http hooks) whose payload is not a script.
if not isinstance(h, dict):
return False
if any(isinstance(h.get(k), str) and h.get(k).strip() for k in HOOK_COMMAND_KEYS):
return True
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 """Yield (kind, relpath, is_first_token) for each package-relative script
reference in a hook command string. kind is 'root' for a ${*_PLUGIN_ROOT} reference apm would rewrite, reading the command exactly as apm does. kind
token, 'rel' for a leading ./path.""" 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."""
refs = [] refs = []
masked = cmd
for m in ROOT_TOKEN_RE.finditer(cmd): for m in ROOT_TOKEN_RE.finditer(cmd):
start, end = m.start(), m.end() start, end = m.start(), m.end()
opened = start > 0 and cmd[start - 1] in '"\'' if end < len(cmd) and cmd[end] in '"\'' and cmd[end + 1:end + 2] in ('/', '\\'):
quote = cmd[start - 1] if opened else None fail(f"script path '{cmd[start:]}' splits the quote after ${{{m.group(1)}}} — apm rewrites only a path that follows the token directly, so this one deploys unrewritten and unbundled; quote the whole token: \"${{PLUGIN_ROOT}}/<path>\" — {where}")
rest = cmd[end:] for m in APM_ROOT_REF_RE.finditer(cmd):
if opened and rest.startswith(quote): start, end = m.start(), m.end()
# split-quoted form: "${PLUGIN_ROOT}"/scripts/my\ hook.sh opener = cmd[start - 1] if start > 0 and cmd[start - 1] in '"\'' else None
rest = rest[1:] path = m.group(1)
path = re.match(r'((?:\\.|[^\s"\'])*)', rest).group(1).replace('\\', '') nxt = cmd[end:end + 1]
elif opened: # A backslash-escaped space, or a quoted token whose script name only
path = rest.split(quote, 1)[0] # completes past the whitespace apm stopped at ("…/my hook.sh").
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)
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: else:
path = re.match(r'((?:\\.|[^\s"\'])*)', rest).group(1).replace('\\', '') prefix = cmd[:start - 1] if opener else cmd[:start]
prefix = cmd[:start - 1] if opened else cmd[:start] refs.append(('root', path.replace('\\', '/').lstrip('/'), _is_first(prefix)))
refs.append(('root', path.lstrip('/'), not prefix.strip())) masked = masked[:start] + ' ' * (end - start) + masked[end:]
stripped = cmd.lstrip().lstrip('"\'') for m in APM_REL_REF_RE.finditer(masked):
if stripped.startswith('./'): start = m.start()
path = re.match(r'((?:\\.|[^\s"\'])*)', stripped).group(1).replace('\\', '') ref = m.group(1)
refs.append(('rel', path, True)) 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
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)))
return refs return refs
@@ -267,6 +301,45 @@ def check_script(kind_, rel, first, pkg_root, where):
fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}") fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}")
def owning_apm_yml():
"""The nearest apm.yml walking up from the hook file, or None."""
d = parent_dir
while True:
cand = os.path.join(d, 'apm.yml')
if os.path.isfile(cand):
return cand
up = os.path.dirname(d)
if up == d:
return None
d = up
def targets_claude():
"""Whether apm renders this package's hooks to Claude. 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."""
path = owning_apm_yml()
if path is None:
return True
try:
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
except (OSError, UnicodeDecodeError, yaml.YAMLError):
return True
if not isinstance(data, dict):
return True
raw = data.get('targets', data.get('target'))
if raw is None:
return True
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
def audit_hook(): def audit_hook():
check_not_linked(hardlinks=False) check_not_linked(hardlinks=False)
stem = fname[:-len('.json')] stem = fname[:-len('.json')]
@@ -280,7 +353,8 @@ def audit_hook():
if not os.path.isfile(os.path.join(pkg_root, 'apm.yml')): 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}") info(f"no apm.yml at the inferred package root {pkg_root} — script paths are resolved against it anyway — {fname}")
if ROUTING_STEM_RE.search(stem): # apm lowercases the stem before routing (hook_file_routing.py).
if ROUTING_STEM_RE.search(stem.lower()):
suggest(f"filename stem '{stem}' uses deprecated hook filename routing — name it plainly and narrow reach with target:/targets: in the package's apm.yml — {fname}") suggest(f"filename stem '{stem}' uses deprecated hook filename routing — name it plainly and narrow reach with target:/targets: in the package's apm.yml — {fname}")
content = read_text(target) content = read_text(target)
@@ -334,13 +408,31 @@ def audit_hook():
elif 'command' in entry: elif 'command' in entry:
claude_shaped = True claude_shaped = True
if shape_ok:
# hook.md Must 3: the file contributes at least one entry. An empty
# event list, or an entry with no handler, deploys nothing runnable.
total = 0
for event, entries in events.items():
for i, entry in enumerate(entries):
total += 1
handlers = entry['hooks'] if 'hooks' in entry else [entry]
if not any(is_handler(h) for h in handlers):
fail(f"event '{event}' entry {i} has no handler — no command (or other handler type) to run, so it deploys nothing — {fname}")
if total == 0:
fail(f"contributes no hook entries — every event list is empty, so apm deploys nothing — {fname}")
# 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()
for event in events: for event in events:
if not event.strip(): if not event.strip():
fail(f"empty event name — {fname}") fail(f"empty event name — {fname}")
elif not any(c.isupper() for c in event): 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}") fail(f"event '{event}' is all-lowercase — no target maps it and apm never warns, so it silently never fires — {fname}")
elif claude_shaped and event[0].islower() and event not in CLAUDE_MAPPED_CAMEL: elif camel_checked and event[0].islower() and event not in CLAUDE_MAPPED_CAMEL:
fail(f"event '{event}' is camelCase in a Claude-shaped file and Claude's map does not rename it — it deploys verbatim and never fires; write it in PascalCase — {fname}") 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}")
if not shape_ok: if not shape_ok:
return return
@@ -357,7 +449,7 @@ def audit_hook():
continue continue
if '${CLAUDE_PLUGIN_ROOT}' in cmd: if '${CLAUDE_PLUGIN_ROOT}' in cmd:
uses_claude_token = True uses_claude_token = True
refs = extract_script_refs(cmd) refs = extract_script_refs(cmd, where)
for kind_, rel, first in refs: for kind_, rel, first in refs:
check_script(kind_, rel, first, pkg_root, where) check_script(kind_, rel, first, pkg_root, where)
if not any(first for _, _, first in refs): if not any(first for _, _, first in refs):
@@ -446,7 +538,7 @@ def audit_instruction():
elif apply_to_ok and 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}") 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) extra = sorted(str(k) for k in fm if k not in INSTRUCTION_KEYS)
if extra: if extra:
suggest(f"frontmatter key(s) {', '.join(extra)} are read by no target and dropped on Claude — keep to description and applyTo (author, version optional) — {fname}") suggest(f"frontmatter key(s) {', '.join(extra)} are read by no target and dropped on Claude — keep to description and applyTo (author, version optional) — {fname}")
@@ -461,6 +553,8 @@ INPUT_NAME_RE = re.compile(r'^[A-Za-z][\w-]{0,63}$')
# apm's own rewrite pattern for ${input:x}, command_integrator.py. # apm's own rewrite pattern for ${input:x}, command_integrator.py.
INPUT_REF_RE = re.compile(r'\$\{\{?\s*input\s*:\s*([\w-]+)\s*\}?\}') INPUT_REF_RE = re.compile(r'\$\{\{?\s*input\s*:\s*([\w-]+)\s*\}?\}')
TRIGGER_RE = re.compile(r'\buse\s+(?:this\s+)?when\b', re.IGNORECASE) TRIGGER_RE = re.compile(r'\buse\s+(?:this\s+)?when\b', re.IGNORECASE)
# The skill boundary form `Not <thing> -> <target>` (ASCII or Unicode arrow).
BOUNDARY_RE = re.compile(r'\bnot\b[^.;]*?(?:->|\u2192)', re.IGNORECASE)
PROMPT_DESC_SUGGEST_CHARS = 250 PROMPT_DESC_SUGGEST_CHARS = 250
@@ -481,15 +575,20 @@ def prompt_input_names(spec):
return return
names.append(s) names.append(s)
form = "write each input as `- <name>: \"<description>\"` (primitive-author prompt Must 3)"
if spec is None: if spec is None:
return names return names
if isinstance(spec, str): if isinstance(spec, str):
fail(f"input: is a bare name, not the object form — {form}, so every argument carries its description — {fname}")
accept(spec) accept(spec)
elif isinstance(spec, dict): elif isinstance(spec, dict):
fail(f"input: is a map, not the object form — {form} — {fname}")
for k in spec: for k in spec:
accept(k) accept(k)
elif isinstance(spec, list): elif isinstance(spec, list):
for item in spec: for item in spec:
if not isinstance(item, dict):
fail(f"input entry {item!r} is a bare name, not the object form — {form} — {fname}")
if isinstance(item, dict): if isinstance(item, dict):
if len(item) > 1: if len(item) > 1:
keys = ', '.join(str(k) for k in item) keys = ', '.join(str(k) for k in item)
@@ -532,11 +631,13 @@ def audit_prompt():
suggest(f"description is {len(desc)} characters (> {PROMPT_DESC_SUGGEST_CHARS}) — it is one user-facing sentence (ADR-0029) — {fname}") suggest(f"description is {len(desc)} characters (> {PROMPT_DESC_SUGGEST_CHARS}) — it is one user-facing sentence (ADR-0029) — {fname}")
if TRIGGER_RE.search(desc): if TRIGGER_RE.search(desc):
suggest(f"description carries a 'Use when' trigger clause — a prompt is user-triggered (ADR-0029); a trigger clause invites the model to route to it on Claude — {fname}") suggest(f"description carries a 'Use when' trigger clause — a prompt is user-triggered (ADR-0029); a trigger clause invites the model to route to it on Claude — {fname}")
if BOUNDARY_RE.search(desc):
suggest(f"description carries a 'Not X -> Y' boundary clause — a prompt is user-triggered (ADR-0029, primitive-author prompt Should 6); name the skills it steers instead — {fname}")
for camel, kebab in PROMPT_CAMEL_ALIASES.items(): for camel, kebab in PROMPT_CAMEL_ALIASES.items():
if camel in fm: if camel in fm:
suggest(f"'{camel}' — use the kebab-case spelling '{kebab}' apm documents — {fname}") suggest(f"'{camel}' — use the kebab-case spelling '{kebab}' apm documents — {fname}")
extra = sorted(k for k in fm if k not in PROMPT_KEYS and k not in PROMPT_CAMEL_ALIASES) extra = sorted(str(k) for k in fm if k not in PROMPT_KEYS and k not in PROMPT_CAMEL_ALIASES)
if extra: if extra:
suggest(f"frontmatter key(s) {', '.join(extra)} are dropped on Claude (it keeps only {', '.join(sorted(PROMPT_KEYS))}) — keep them only if the Copilot-only behaviour is intended — {fname}") suggest(f"frontmatter key(s) {', '.join(extra)} are dropped on Claude (it keeps only {', '.join(sorted(PROMPT_KEYS))}) — keep them only if the Copilot-only behaviour is intended — {fname}")
@@ -559,15 +660,19 @@ def audit_prompt():
suggest(f"argument-hint is set alongside input: — apm synthesises the hint from input: names; drop it unless that form is inadequate — {fname}") suggest(f"argument-hint is set alongside input: — apm synthesises the hint from input: names; drop it unless that form is inadequate — {fname}")
if kind == 'hook': AUDITS = {'hook': lambda: audit_hook(), 'instruction': lambda: audit_instruction(), 'prompt': lambda: audit_prompt()}
audit_hook() if kind not in AUDITS:
elif kind == 'instruction':
audit_instruction()
elif kind == 'prompt':
audit_prompt()
else:
print(f"Error: unknown primitive kind '{kind}'", file=sys.stderr) print(f"Error: unknown primitive kind '{kind}'", file=sys.stderr)
sys.exit(2) sys.exit(2)
try:
AUDITS[kind]()
except Exception as exc: # an input shape no check anticipated
# Exit 1 means findings; a crash means the checks never completed, which
# is the never-ran tier, not a verdict on the file.
print(f"Error: the {kind} checks crashed ({type(exc).__name__}: {exc}) and did not complete — {fname}", file=sys.stderr)
print(" Why: a partial run reported as findings (exit 1) or as clean (exit 0) would be a verdict the checks never reached.", file=sys.stderr)
print(" Fix: report ### Structure as unverified, and file the input shape against factory-audit's lib-checks-primitive.sh.", file=sys.stderr)
sys.exit(2)
for s in suggestions: for s in suggestions:
print(f"SUGGESTION {s}") print(f"SUGGESTION {s}")

View File

@@ -196,7 +196,7 @@ TARGET="$1"
if [[ ! -e "$TARGET" && ! -L "$TARGET" ]]; then if [[ ! -e "$TARGET" && ! -L "$TARGET" ]]; then
echo "Error: '$TARGET' does not exist." >&2 echo "Error: '$TARGET' does not exist." >&2
echo " Why: the path shape says what would be audited, but there is nothing at this path to audit — and auditing a target that is not there would report the absence as findings about it, sending the reader after a spec violation instead of a typo." >&2 echo " Why: the path shape says what would be audited, but there is nothing at this path to audit — and auditing a target that is not there would report the absence as findings about it, sending the reader after a spec violation instead of a typo." >&2
echo " Fix: check the path, and pass an existing skill directory (or its SKILL.md) or an existing agent file." >&2 echo " Fix: check the path, and pass an existing skill directory (or its SKILL.md), agent file, hook file (.json under a hooks/ directory), *.instructions.md or *.prompt.md." >&2
exit 2 exit 2
fi fi
@@ -212,8 +212,8 @@ if [[ -d "$TARGET" ]]; then
MODE=skill MODE=skill
else else
echo "Error: '$TARGET' is a directory with no SKILL.md in it." >&2 echo "Error: '$TARGET' is a directory with no SKILL.md in it." >&2
echo " Why: a skill directory is identified by its SKILL.md, and an agent target is a file, never a directory — so this path matches neither mode and guessing one would report findings of the wrong kind." >&2 echo " Why: a skill directory is identified by its SKILL.md, and every other target (agent, hook, instruction, prompt) is a file, never a directory — so this path matches no mode and guessing one would report findings of the wrong kind." >&2
echo " Fix: pass the skill directory that holds SKILL.md, or an agent file (<name>.agent.md, or a .md file under an agents/ directory)." >&2 echo " Fix: pass the skill directory that holds SKILL.md, an agent file (<name>.agent.md, or a .md file under an agents/ directory), a hook file (.json under a hooks/ directory), a *.instructions.md or a *.prompt.md." >&2
exit 2 exit 2
fi fi
elif [[ "$TARGET_BASE" == "SKILL.md" ]]; then elif [[ "$TARGET_BASE" == "SKILL.md" ]]; then

View File

@@ -53,15 +53,17 @@ provenance suites stand in the same relation to `scripts/validate-provenance.sh`
Do not add a third script path here on the assumption that a differently named Do not add a third script path here on the assumption that a differently named
suite must mean a differently named script. suite must mean a differently named script.
### Auto-detection is pinned across the pair ### Auto-detection is pinned across the suites
Each entry point decides for itself what it was handed. ADR-0025 states the Each entry point decides for itself what it was handed. ADR-0025 states the
rule: a directory containing `SKILL.md` takes the skill flow; an `.agent.md` skill and agent rule: a directory containing `SKILL.md` takes the skill flow; an
file, or a file under a directory named `agents/`, takes the agent flow. `.agent.md` file, or a file under a directory named `agents/`, takes the agent
Anything else is rejected rather than guessed at. That behaviour is new with the flow. `scripts/validate.sh` adds three primitive shapes: a `*.instructions.md`
merge — before it, each script was hard-wired to one artifact type and nothing or `*.prompt.md` file takes the instruction or prompt flow wherever it sits, and
about classification could be wrong — so it is asserted from both sides rather a `.json` file directly under a `hooks/` directory takes the hook flow. Any
than in one place: shape outside the five is rejected rather than guessed at. Classification could
not be wrong before the merge — each script was hard-wired to one artifact type
— so it is asserted from every side rather than in one place:
- the skill-side suites pin the skill-directory classification and the - the skill-side suites pin the skill-directory classification and the
neither-shape rejection, neither-shape rejection,
@@ -69,6 +71,12 @@ than in one place:
directory that is not `agents/`, and a plain `.md` under `.apm/agents/` — so directory that is not `agents/`, and a plain `.md` under `.apm/agents/` — so
that a detector implementing only one of them cannot pass both. Plus a control that a detector implementing only one of them cannot pass both. Plus a control
asserting an agent file never picks up a skill-only gate. asserting an agent file never picks up a skill-only gate.
- `validate-primitive.bats` pins the hook, instruction and prompt shapes,
including the precedence that makes a `*.instructions.md` or `*.prompt.md`
under `agents/` take the primitive flow rather than the agent flow, a hook
file under a package-root `hooks/`, and a `.json` outside `hooks/` matching
no shape. It also pins the primitive exit tiers: every negative case asserts
exit 1 (`assert_failure 1`), and a missing python3 or PyYAML asserts exit 2.
Both skill-side suites additionally pin the `SKILL.md` **file** path, not just Both skill-side suites additionally pin the `SKILL.md` **file** path, not just
the directory: a pre-commit `files:` hook matches files, so every hook-driven the directory: a pre-commit `files:` hook matches files, so every hook-driven

View File

@@ -57,7 +57,7 @@ teardown() {
mkdir -p "$PKG/.apm/agents" mkdir -p "$PKG/.apm/agents"
printf -- '---\ndescription: x\n---\n\nbody\n' > "$PKG/.apm/agents/x.instructions.md" printf -- '---\ndescription: x\n---\n\nbody\n' > "$PKG/.apm/agents/x.instructions.md"
run bash "$SCRIPT" "$PKG/.apm/agents/x.instructions.md" run bash "$SCRIPT" "$PKG/.apm/agents/x.instructions.md"
assert_failure assert_failure 1
assert_output --partial "is not directly in a .apm/instructions/ directory" assert_output --partial "is not directly in a .apm/instructions/ directory"
refute_output --partial "counterpart" refute_output --partial "counterpart"
} }
@@ -77,49 +77,79 @@ teardown() {
@test "hook: invalid JSON is a FAIL" { @test "hook: invalid JSON is a FAIL" {
write_hook hooks.json '{"hooks": {' write_hook hooks.json '{"hooks": {'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "is not valid JSON" assert_output --partial "is not valid JSON"
} }
@test "hook: an event value that is not a list is a FAIL" { @test "hook: an event value that is not a list is a FAIL" {
write_hook hooks.json '{"hooks":{"PreToolUse":{"hooks":[]}}}' write_hook hooks.json '{"hooks":{"PreToolUse":{"hooks":[]}}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "event 'PreToolUse' is not a list" assert_output --partial "event 'PreToolUse' is not a list"
} }
@test "hook: a naked slice with a stray scalar key is a FAIL" { @test "hook: a naked slice with a stray scalar key is a FAIL" {
write_hook hooks.json '{"description":"x","PreToolUse":[{"hooks":[{"type":"command","command":"true"}]}]}' write_hook hooks.json '{"description":"x","PreToolUse":[{"hooks":[{"type":"command","command":"true"}]}]}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "naked settings-slice shape" assert_output --partial "naked settings-slice shape"
} }
@test "hook: an all-lowercase event is a FAIL" { @test "hook: an all-lowercase event is a FAIL" {
write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}' write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "event 'stop' is all-lowercase" assert_output --partial "event 'stop' is all-lowercase"
} }
@test "hook: camelCase userPromptSubmit in a Claude-shaped file is a FAIL; mapped sessionStart is not" { @test "hook: camelCase userPromptSubmit in a Claude-shaped file is a FAIL; mapped sessionStart is not" {
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"hooks":[{"type":"command","command":"true"}]}],"sessionStart":[{"hooks":[{"type":"command","command":"true"}]}]}}' write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"hooks":[{"type":"command","command":"true"}]}],"sessionStart":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "event 'userPromptSubmit' is camelCase" assert_output --partial "event 'userPromptSubmit' is camelCase"
refute_output --partial "event 'sessionStart'" refute_output --partial "event 'sessionStart'"
} }
@test "hook: a flat Copilot-shaped file may use camelCase events" { @test "hook: a flat Copilot-shaped file may use camelCase events when the package does not target Claude" {
printf 'targets:\n - copilot\n' >> "$PKG/apm.yml"
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}' write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success assert_success
refute_output --partial "camelCase"
}
@test "hook: camelCase in a flat file is a FAIL when the package targets Claude" {
printf 'targets: [claude, copilot]\n' >> "$PKG/apm.yml"
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "event 'userPromptSubmit' is camelCase and the package's apm.yml targets Claude"
}
@test "hook: camelCase in a flat file is a FAIL when apm.yml declares no targets (every target)" {
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "event 'userPromptSubmit' is camelCase"
}
@test "hook: targets are read from the nearest apm.yml walking up, and target: all counts as Claude" {
mkdir -p "$PKG/sub/hooks"
printf 'name: sub\nversion: 0.1.0\ntarget: copilot\n' > "$PKG/sub/apm.yml"
printf '%s\n' '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}' > "$PKG/sub/hooks/hooks.json"
run bash "$SCRIPT" "$PKG/sub/hooks/hooks.json"
assert_success
printf 'name: sub\nversion: 0.1.0\ntarget: all\n' > "$PKG/sub/apm.yml"
run bash "$SCRIPT" "$PKG/sub/hooks/hooks.json"
assert_failure 1
assert_output --partial "is camelCase"
} }
@test "hook: a referenced script that does not exist is a FAIL" { @test "hook: a referenced script that does not exist is a FAIL" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/.apm/hooks/scripts/missing.sh"}]}]}}' write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/.apm/hooks/scripts/missing.sh"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "script '.apm/hooks/scripts/missing.sh' does not exist" assert_output --partial "script '.apm/hooks/scripts/missing.sh' does not exist"
} }
@@ -127,7 +157,7 @@ teardown() {
chmod -x "$PKG/.apm/hooks/scripts/check.sh" chmod -x "$PKG/.apm/hooks/scripts/check.sh"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"./scripts/check.sh"}]}]}}' write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"./scripts/check.sh"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "is run directly but is not executable" assert_output --partial "is run directly but is not executable"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash ${PLUGIN_ROOT}/.apm/hooks/scripts/check.sh"}]}]}}' write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash ${PLUGIN_ROOT}/.apm/hooks/scripts/check.sh"}]}]}}'
@@ -139,7 +169,7 @@ teardown() {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/../outside.sh"}]}]}}' write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/../outside.sh"}]}]}}'
touch "$TMPDIR/outside.sh" touch "$TMPDIR/outside.sh"
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "resolves outside the package" assert_output --partial "resolves outside the package"
} }
@@ -157,11 +187,26 @@ teardown() {
assert_output --partial "deprecated hook filename routing" assert_output --partial "deprecated hook filename routing"
} }
@test "hook: routing detection matches apm — case-insensitive, hooks-<target>, prefixed and combined stems" {
local name
for name in Claude-Hooks.json HOOKS-Copilot.json x-claude-hooks.json claude-codex-hooks.json; do
write_hook "$name" '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/$name"
assert_success
assert_output --partial "deprecated hook filename routing"
done
write_hook claude-hooks-extra.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/claude-hooks-extra.json"
assert_success
refute_output --partial "deprecated hook filename routing"
}
@test "hook: a symlinked hook file is a FAIL" { @test "hook: a symlinked hook file is a FAIL" {
write_hook real.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true"}]}]}}' write_hook real.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
ln -s real.json "$PKG/.apm/hooks/link.json" ln -s real.json "$PKG/.apm/hooks/link.json"
run bash "$SCRIPT" "$PKG/.apm/hooks/link.json" run bash "$SCRIPT" "$PKG/.apm/hooks/link.json"
assert_failure assert_failure 1
assert_output --partial "is a symlink" assert_output --partial "is a symlink"
} }
@@ -176,7 +221,7 @@ teardown() {
@test "hook: an absolute script path is a FAIL; an absolute interpreter path is not" { @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}]}]}}' 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" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "is an absolute path" assert_output --partial "is an absolute path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/usr/bin/env true","timeout":5}]}]}}' write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/usr/bin/env true","timeout":5}]}]}}'
@@ -188,7 +233,7 @@ teardown() {
@test "hook: a bare relative path to a package script is a FAIL; a bare command is not" { @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}]}]}}' 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" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "is a bare relative path" assert_output --partial "is a bare relative path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"npx some-tool --check","timeout":5}]}]}}' write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"npx some-tool --check","timeout":5}]}]}}'
@@ -200,10 +245,99 @@ teardown() {
@test "hook: a file contributing no entries is a FAIL" { @test "hook: a file contributing no entries is a FAIL" {
write_hook hooks.json '{"hooks":{}}' write_hook hooks.json '{"hooks":{}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure assert_failure 1
assert_output --partial "contributes no hook entries" assert_output --partial "contributes no hook entries"
} }
@test "hook: a file whose every event list is empty is a FAIL" {
write_hook hooks.json '{"hooks":{"Stop":[],"PreToolUse":[]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "every event list is empty"
}
@test "hook: an entry with no handler is a FAIL, whether nested hooks is empty or absent" {
write_hook hooks.json '{"hooks":{"Stop":[{"matcher":"x"}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "event 'Stop' entry 0 has no handler"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "event 'Stop' entry 0 has no handler"
}
@test "hook: a non-command handler type (prompt) counts as a handler" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"prompt","prompt":"check","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "no handler"
}
@test "hook: a ./ script after an interpreter is existence-checked, as apm matches it" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash ./scripts/missing.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script 'scripts/missing.sh' does not exist"
chmod -x "$PKG/.apm/hooks/scripts/check.sh"
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_success
refute_output --partial "FAIL"
}
@test "hook: a ../ script path is a FAIL — apm reads it as ./ from the hook directory" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash ../scripts/x.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "starts with ../"
}
@test "hook: the whole-token-quoted \${PLUGIN_ROOT} form passes clean" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"\"${PLUGIN_ROOT}/.apm/hooks/scripts/check.sh\"","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
}
@test "hook: a split-quoted \"\${PLUGIN_ROOT}\"/path is a FAIL — apm never rewrites it" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"\"${PLUGIN_ROOT}\"/.apm/hooks/scripts/check.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "splits the quote"
}
@test "hook: a script path with a space is a FAIL, quoted or backslash-escaped" {
printf '#!/usr/bin/env bash\nexit 0\n' > "$PKG/.apm/hooks/scripts/my hook.sh"
chmod +x "$PKG/.apm/hooks/scripts/my hook.sh"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"\"${PLUGIN_ROOT}/.apm/hooks/scripts/my hook.sh\"","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "contains a space"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/.apm/hooks/scripts/my\\ hook.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "contains a space"
}
@test "hook: a hook file under a package-root hooks/ directory is audited, scripts resolved from the package root" {
mkdir -p "$PKG/hooks" "$PKG/scripts"
printf '#!/usr/bin/env bash\nexit 0\n' > "$PKG/scripts/run.sh"
chmod +x "$PKG/scripts/run.sh"
printf '%s\n' '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/scripts/run.sh","timeout":5}]}]}}' > "$PKG/hooks/hooks.json"
run bash "$SCRIPT" "$PKG/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
printf '%s\n' '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/scripts/gone.sh","timeout":5}]}]}}' > "$PKG/hooks/hooks.json"
run bash "$SCRIPT" "$PKG/hooks/hooks.json"
assert_failure 1
assert_output --partial "script 'scripts/gone.sh' does not exist"
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Instructions # Instructions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -220,7 +354,7 @@ applyTo: "**/*.py"' 'Use type hints on public functions.'
@test "instruction: missing description is a FAIL" { @test "instruction: missing description is a FAIL" {
write_instruction python 'applyTo: "**/*.py"' 'Use type hints.' write_instruction python 'applyTo: "**/*.py"' 'Use type hints.'
run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md" run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md"
assert_failure assert_failure 1
assert_output --partial "'description' is missing or empty" assert_output --partial "'description' is missing or empty"
} }
@@ -228,14 +362,14 @@ applyTo: "**/*.py"' 'Use type hints on public functions.'
write_instruction python 'description: x write_instruction python 'description: x
applyTo: "**/*.py"' '' applyTo: "**/*.py"' ''
run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md" run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md"
assert_failure assert_failure 1
assert_output --partial "body is empty" assert_output --partial "body is empty"
} }
@test "instruction: invalid frontmatter YAML is a FAIL" { @test "instruction: invalid frontmatter YAML is a FAIL" {
write_instruction python 'description: [unclosed' 'body' write_instruction python 'description: [unclosed' 'body'
run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md" run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md"
assert_failure assert_failure 1
assert_output --partial "frontmatter is not valid YAML" assert_output --partial "frontmatter is not valid YAML"
} }
@@ -261,7 +395,7 @@ name: python' 'body'
write_instruction empty 'description: x write_instruction empty 'description: x
applyTo: ""' 'body' applyTo: ""' 'body'
run bash "$SCRIPT" "$PKG/.apm/instructions/empty.instructions.md" run bash "$SCRIPT" "$PKG/.apm/instructions/empty.instructions.md"
assert_failure assert_failure 1
assert_output --partial "applyTo is present but empty" assert_output --partial "applyTo is present but empty"
refute_output --partial "SUGGESTION no applyTo" refute_output --partial "SUGGESTION no applyTo"
} }
@@ -270,10 +404,21 @@ applyTo: ""' 'body'
write_instruction broken 'description: x write_instruction broken 'description: x
applyTo: "**/*.{py"' 'body' applyTo: "**/*.{py"' 'body'
run bash "$SCRIPT" "$PKG/.apm/instructions/broken.instructions.md" run bash "$SCRIPT" "$PKG/.apm/instructions/broken.instructions.md"
assert_failure assert_failure 1
assert_output --partial "unbalanced braces or brackets" assert_output --partial "unbalanced braces or brackets"
} }
@test "instruction: a non-string frontmatter key is a clean SUGGESTION, not a crash" {
write_instruction python 'description: x
applyTo: "**/*.py"
1: a
zeta: b' 'body'
run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md"
assert_success
refute_output --partial "Traceback"
assert_output --partial "frontmatter key(s) 1, zeta"
}
@test "instruction: a top-level comma list with a brace group passes clean" { @test "instruction: a top-level comma list with a brace group passes clean" {
write_instruction multi 'description: x write_instruction multi 'description: x
applyTo: "**/*.py, **/*.{pyi,pyx}"' 'body' applyTo: "**/*.py, **/*.{pyi,pyx}"' 'body'
@@ -287,7 +432,7 @@ applyTo: "**/*.py, **/*.{pyi,pyx}"' 'body'
applyTo: "**/*.py"' 'body' applyTo: "**/*.py"' 'body'
cp "$PKG/.apm/instructions/python.instructions.md" "$PKG/python.instructions.md" cp "$PKG/.apm/instructions/python.instructions.md" "$PKG/python.instructions.md"
run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md" run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md"
assert_failure assert_failure 1
assert_output --partial "also exists at the package root" assert_output --partial "also exists at the package root"
} }
@@ -296,7 +441,7 @@ applyTo: "**/*.py"' 'body'
applyTo: "**/*.py"' 'body' applyTo: "**/*.py"' 'body'
ln "$PKG/.apm/instructions/python.instructions.md" "$TMPDIR/python.instructions.md" ln "$PKG/.apm/instructions/python.instructions.md" "$TMPDIR/python.instructions.md"
run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md" run bash "$SCRIPT" "$PKG/.apm/instructions/python.instructions.md"
assert_failure assert_failure 1
assert_output --partial "is a hardlink" assert_output --partial "is a hardlink"
} }
@@ -317,7 +462,7 @@ input:
@test "prompt: missing description is a FAIL" { @test "prompt: missing description is a FAIL" {
write_prompt review-pr 'model: sonnet' 'Review the PR.' write_prompt review-pr 'model: sonnet' 'Review the PR.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure assert_failure 1
assert_output --partial "'description' is missing or empty" assert_output --partial "'description' is missing or empty"
} }
@@ -327,23 +472,25 @@ input:
- name: pr_number - name: pr_number
description: The PR' 'Review ${input:pr_number}.' description: The PR' 'Review ${input:pr_number}.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure assert_failure 1
assert_output --partial "yields arguments [name, description]" assert_output --partial "yields arguments [name, description]"
} }
@test "prompt: an invalid input name is a FAIL" { @test "prompt: an invalid input name is a FAIL" {
write_prompt review-pr 'description: Review a PR. write_prompt review-pr 'description: Review a PR.
input: [1pr]' 'Review.' input:
- 1pr: "The PR"' 'Review.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure assert_failure 1
assert_output --partial "input name '1pr' does not match" assert_output --partial "input name '1pr' does not match"
} }
@test "prompt: an undeclared \${input:x} and an unused input are both FAILs" { @test "prompt: an undeclared \${input:x} and an unused input are both FAILs" {
write_prompt review-pr 'description: Review a PR. write_prompt review-pr 'description: Review a PR.
input: [pr_number]' 'Review ${input:branch}.' input:
- pr_number: "The PR"' 'Review ${input:branch}.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure assert_failure 1
assert_output --partial "input: does not declare 'branch'" assert_output --partial "input: does not declare 'branch'"
assert_output --partial "input 'pr_number' is declared but the body never uses" assert_output --partial "input 'pr_number' is declared but the body never uses"
} }
@@ -351,7 +498,7 @@ input: [pr_number]' 'Review ${input:branch}.'
@test "prompt: \${input:x} with no input: declared is a FAIL" { @test "prompt: \${input:x} with no input: declared is a FAIL" {
write_prompt review-pr 'description: Review a PR.' 'Review ${input:pr_number}.' write_prompt review-pr 'description: Review a PR.' 'Review ${input:pr_number}.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure assert_failure 1
assert_output --partial "but no input: is declared" assert_output --partial "but no input: is declared"
} }
@@ -369,10 +516,58 @@ allowedTools: Bash" 'Review the PR.'
assert_output --partial "'allowedTools' — use the kebab-case spelling" assert_output --partial "'allowedTools' — use the kebab-case spelling"
} }
@test "prompt: input: forms other than the object form are FAILs (primitive-author Must 3)" {
write_prompt review-pr 'description: Review a PR.
input: [pr_number]' 'Review ${input:pr_number}.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure 1
assert_output --partial "input entry 'pr_number' is a bare name"
write_prompt review-pr 'description: Review a PR.
input: pr_number' 'Review ${input:pr_number}.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure 1
assert_output --partial "input: is a bare name"
write_prompt review-pr 'description: Review a PR.
input:
pr_number: The PR' 'Review ${input:pr_number}.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure 1
assert_output --partial "input: is a map"
}
@test "prompt: a Not X -> Y boundary clause in the description is a SUGGESTION" {
write_prompt review-pr 'description: Review the PR with gitea-prs. Not fixing it -> skill-author.' 'Review the PR with gitea-prs.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_success
assert_output --partial "'Not X -> Y' boundary clause"
}
@test "prompt: a non-string frontmatter key is a clean SUGGESTION, not a crash" {
write_prompt review-pr 'description: Review a PR.
1: a
zeta: b' 'Review the PR.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_success
refute_output --partial "Traceback"
assert_output --partial "frontmatter key(s) 1, zeta"
}
@test "prompt: a *.prompt.md under an agents/ directory takes the prompt flow, not the agent flow" {
mkdir -p "$PKG/.apm/agents"
printf -- '---\ndescription: x\n---\n\nbody\n' > "$PKG/.apm/agents/x.prompt.md"
run bash "$SCRIPT" "$PKG/.apm/agents/x.prompt.md"
assert_failure 1
assert_output --partial "is not directly in a .apm/prompts/ directory"
refute_output --partial "counterpart"
}
@test "prompt: argument-hint alongside input: is a SUGGESTION" { @test "prompt: argument-hint alongside input: is a SUGGESTION" {
write_prompt review-pr 'description: Review a PR. write_prompt review-pr 'description: Review a PR.
argument-hint: <pr> argument-hint: <pr>
input: [pr_number]' 'Review ${input:pr_number}.' input:
- pr_number: "The PR"' 'Review ${input:pr_number}.'
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_success assert_success
assert_output --partial "argument-hint is set alongside input:" assert_output --partial "argument-hint is set alongside input:"
@@ -392,6 +587,34 @@ $(printf 'line\n%.0s' $(seq 1 80))
write_prompt review-pr 'description: Review a pull request with gitea-prs.' 'Review the PR with gitea-prs.' write_prompt review-pr 'description: Review a pull request with gitea-prs.' 'Review the PR with gitea-prs.'
ln "$PKG/.apm/prompts/review-pr.prompt.md" "$TMPDIR/review-pr.prompt.md" ln "$PKG/.apm/prompts/review-pr.prompt.md" "$TMPDIR/review-pr.prompt.md"
run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_failure assert_failure 1
assert_output --partial "is a hardlink" assert_output --partial "is a hardlink"
} }
# ---------------------------------------------------------------------------
# Never ran (exit 2)
# ---------------------------------------------------------------------------
@test "exit 2: a PATH with no python3 names the missing dependency in primitive mode" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
local emptybin="$TMPDIR/emptybin" cmd bash_bin
mkdir -p "$emptybin"
for cmd in cat sed; do
ln -s "$(command -v "$cmd")" "$emptybin/$cmd"
done
bash_bin="$(command -v bash)"
run env -i PATH="$emptybin" HOME="$HOME" "$bash_bin" "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_equal "$status" 2
assert_output --partial "python3 is required"
}
@test "exit 2: a python3 that cannot import PyYAML names the missing library in primitive mode" {
write_prompt review-pr 'description: Review a PR.' 'Review the PR.'
local noyaml="$TMPDIR/noyaml"
mkdir -p "$noyaml"
# Shadow PyYAML: PYTHONPATH precedes site-packages, so `import yaml` fails.
printf 'raise ImportError("PyYAML shadowed by the test")\n' > "$noyaml/yaml.py"
PYTHONPATH="$noyaml" run bash "$SCRIPT" "$PKG/.apm/prompts/review-pr.prompt.md"
assert_equal "$status" 2
assert_output --partial "PyYAML is required"
}

View File

@@ -1,11 +1,11 @@
--- ---
name: forge name: forge
description: > description: >
Use when the user wants to build or improve something but has not yet named Use when the user wants to build or improve something but has not named its
the artifact type; "not sure if this should be a skill or a plugin", "I have type ("skill or plugin?"). Not a skill -> skill-author.
an idea but don't know where it belongs". Routes to the matching author Not an agent -> agent-author.
skill. Do not use when the type is already named — invoke `skill-author`, Not a hook, instruction or prompt -> primitive-author.
`agent-author`, `primitive-author` or `apm-workflow` directly. Not a plugin -> apm-workflow.
metadata: metadata:
version: "1.0.2" version: "1.0.2"
category: factory category: factory
@@ -17,16 +17,15 @@ metadata:
## Gotchas ## Gotchas
- forge is an optional guided entry point, not a gate — `skill-author`, `agent-author`, `primitive-author`, `factory-audit` and `apm-workflow` all stay directly invokable, and forge never intercepts a direct call to one.
- Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite the shared word: `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. The route reference each classification loads spends that distinction: `references/author-routes.md` chooses between the two, `references/apm-routes.md` rules the fork out. - Claude Code's skill-level `context: fork` frontmatter field and the `/fork` subagent command are opposites despite the shared word: `context: fork` isolates (fresh context, no parent access), while `/fork` inherits the full conversation. The route reference each classification loads spends that distinction: `references/author-routes.md` chooses between the two, `references/apm-routes.md` rules the fork out.
## Step 1 — Grill the intent ## Step 1 — Grill the intent
Call `grill-with-docs` unless a grill session has already run and is available in the context. Call `grill-with-docs` unless a grill session has already run and is available in the context.
`grill-with-docs` ships in a sibling plugin that kyberforge does not declare as an apm dependency, so it resolves in the authoring monorepo but can be absent where kyberforge is installed alone. If it does not resolve, grill inline yourself rather than skipping the step: what problem the artifact solves, who invokes it and how, what it must refuse, and which existing skill or plugin already owns part of the job. Say which path you took. If `grill-with-docs` does not resolve, read `references/grill-fallback.md`.
Grilling regularly overturns the artifact type assumed at the start, or splits one idea into several artifacts, so it runs before classification rather than confirming it. Run it inline in the current conversation — grilling is interactive and a subagent cannot hold the back-and-forth. Grilling often overturns or splits the assumed type, so it runs before classification, inline — a subagent cannot hold the back-and-forth.
## Step 2 — Classify and dispatch ## Step 2 — Classify and dispatch

View File

@@ -0,0 +1,20 @@
---
source_keys: []
---
# Grilling without grill-with-docs
Reached from `SKILL.md` Step 1 when `grill-with-docs` does not resolve.
`grill-with-docs` ships in a sibling plugin that kyberforge does not declare as an apm dependency,
so it resolves in the authoring monorepo but can be absent where kyberforge is installed alone.
When it is absent, grill inline yourself rather than skipping the step. Cover four questions:
- What problem does the artifact solve?
- Who invokes it, and how?
- What must it refuse?
- Which existing skill or plugin already owns part of the job?
Say which path you took — `grill-with-docs` or the inline fallback — then return to `SKILL.md`
Step 2.

View File

@@ -15,7 +15,7 @@ metadata:
## Gotchas ## Gotchas
- `apm compile --validate` is not a gate: it reports instruction problems only as warnings, exits 0, and never reads prompts. `apm install` fails only on a hook payload Copilot would reject, and merely warns on bad prompt input names and dropped keys — `/factory-audit` is the only check that fails on the rest. - `apm compile --validate` is not a gate: it only warns on instructions, exits 0, and never reads prompts. `apm install` exits 1 only on a hook payload Copilot would reject or on critical hidden Unicode, which also blocks the package's deployment; bad prompt input names and dropped keys merely warn — `/factory-audit` is the only check that fails on the rest.
- Never draft with the real suffix outside `.apm/<type>/`. apm's local discovery globs `**/*.instructions.md` across the whole tree, so a draft or template named that way anywhere in the repo is picked up as a real instruction. The templates carry a trailing `.template` for this reason; drop it only on the final path. - Never draft with the real suffix outside `.apm/<type>/`. apm's local discovery globs `**/*.instructions.md` across the whole tree, so a draft or template named that way anywhere in the repo is picked up as a real instruction. The templates carry a trailing `.template` for this reason; drop it only on the final path.
- Never hand-write `.claude/settings.json`, even to test a hook. apm owns that file (ADR-0019), overwrites it outright when it is malformed, and `apm audit --ci` fails on anything it would not have written. - Never hand-write `.claude/settings.json`, even to test a hook. apm owns that file (ADR-0019), overwrites it outright when it is malformed, and `apm audit --ci` fails on anything it would not have written.

View File

@@ -86,9 +86,15 @@ Should:
Claude receives `"*"`. Claude receives `"*"`.
9. Do not author Copilot's flat `bash` / `powershell` / `timeoutSec` keys in a Claude-shaped file; 9. Do not author Copilot's flat `bash` / `powershell` / `timeoutSec` keys in a Claude-shaped file;
they render onto Claude as stray keys. they render onto Claude as stray keys.
10. Quote a script path that may contain spaces: `"${PLUGIN_ROOT}/scripts/my hook.sh"`. 10. Keep script paths free of spaces, and when quoting, quote the whole token:
`"${PLUGIN_ROOT}/scripts/my-hook.sh"`. apm rewrites `${PLUGIN_ROOT}` only when a path separator
follows it directly, and only up to the next space or quote — so `"${PLUGIN_ROOT}"/scripts/x.sh`
is left unrewritten and a spaced path is cut short.
11. Keep helper files in the hook directory non-JSON. Copilot's loader rejects any bundled `.json` 11. Keep helper files in the hook directory non-JSON. Copilot's loader rejects any bundled `.json`
without a `hooks` key. without a `hooks` key.
12. Prefer `${PLUGIN_ROOT}` over `${CLAUDE_PLUGIN_ROOT}`. 12. Prefer `${PLUGIN_ROOT}` over `${CLAUDE_PLUGIN_ROOT}`.
13. No `hooks-<target>` or `*-<target>-hooks` filename. That routing is deprecated and reach belongs 13. No filename that routes by target. Case-insensitively, apm routes a stem of exactly
to `targets:` (see Gate); the research allows it only when deprecated routing is intended. `hooks-<target>` and any stem ending `<target>-hooks` — bare (`claude-hooks`), prefixed
(`x-claude-hooks`) or combined (`claude-codex-hooks`, the union). That routing is deprecated and
reach belongs to `targets:` (see Gate); the research allows it only when deprecated routing is
intended.

View File

@@ -32,7 +32,8 @@ Must:
1. The path is `.apm/instructions/<stem>.instructions.md`, directly in that directory, not a 1. The path is `.apm/instructions/<stem>.instructions.md`, directly in that directory, not a
symlink or hardlink. symlink or hardlink.
2. `description` is a non-empty string. apm only warns when it is missing. 2. `description` is a non-empty string. Only `apm compile` warns when it is missing; `apm install`
deploys it silently.
3. The body is non-empty after trimming whitespace. apm deploys an empty rule silently. 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, 4. `applyTo` is a non-empty glob or comma-separated list — top-level commas only as separators,
alternation inside `{}` (`"**/*.{ts,tsx}"`), braces and brackets balanced — or absent after the alternation inside `{}` (`"**/*.{ts,tsx}"`), braces and brackets balanced — or absent after the

View File

@@ -59,7 +59,8 @@ Should:
6. The description follows the contract above: one plain sentence, no trigger or boundary clause, 6. The description follows the contract above: one plain sentence, no trigger or boundary clause,
naming the skills or agents it steers. naming the skills or agents it steers.
7. Spell keys in kebab-case — `allowed-tools`, `argument-hint` — not the camelCase aliases. 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. 8. Omit `argument-hint` when `input:` is set, unless the `<a> <b>` form apm synthesises from the
input names is inadequate; an explicit `argument-hint` wins.
9. Keep one intent per prompt, and write the body as second-person instructions. 9. Keep one intent per prompt, and write the body as second-person instructions.
10. Keep `description` to 250 characters or fewer. 10. Keep `description` to 250 characters or fewer.
11. Give `model` a slug the target accepts. Copilot ignores `allowed-tools` and `model`, so neither 11. Give `model` a slug the target accepts. Copilot ignores `allowed-tools` and `model`, so neither

View File

@@ -1,9 +1,10 @@
--- ---
name: skill-author name: skill-author
description: > description: >
Use when the user wants to create a new skill from scratch, or apply audit Use when creating a new skill, or applying audit findings, grill output, eval
findings, grill output, eval results, or inline feedback to an existing one. results or feedback to an existing one. Not read-only review ->
Not read-only review -> `factory-audit`. Not agent files -> `agent-author`. `factory-audit`. Not agents -> `agent-author`. Not hooks, instructions or
prompts -> `primitive-author`.
allowed-tools: Bash Read Write Edit allowed-tools: Bash Read Write Edit
metadata: metadata:
version: "1.0.6" version: "1.0.6"

View File

@@ -31,9 +31,9 @@ For a package (an `apm.yml`-governed `.apm/` source tree), the deployable artifa
Fix: duplicate the file into the skill's own `scripts/` or `assets/`, same as plugin mode. `apm.yml`'s `includes:` list (when explicit, not `auto`) controls what gets published from the package, but it is not a cross-skill sharing mechanism — each skill directory must still stand alone. Fix: duplicate the file into the skill's own `scripts/` or `assets/`, same as plugin mode. `apm.yml`'s `includes:` list (when explicit, not `auto`) controls what gets published from the package, but it is not a cross-skill sharing mechanism — each skill directory must still stand alone.
## Env vars (plugin mode only) ## Env vars (Claude Code plugin install only)
These variables are injected when the plugin is loaded from an install cache. They are **not available in standalone mode.** Claude Code injects these when it loads the plugin from its install cache. Other harnesses do not, and neither does standalone mode — so they are Claude Code-specific, unlike the target-neutral `${PLUGIN_ROOT}` hook token that apm rewrites per target.
| Variable | Value | | Variable | Value |
|----------|-------| |----------|-------|
@@ -48,7 +48,7 @@ Deployed directly to `~/.agents/skills/<name>/`. No plugin context, no env vars
## Cross-tool portability ## Cross-tool portability
`SKILL.md` is portable — the same file works in Claude Code and Copilot CLI, whether deployed standalone or compiled from an APM package. `apm.yml` is the source manifest: it is itself tool-agnostic (one file describes the package regardless of target), but `apm compile` produces per-target compiled output — a Claude Code plugin tree, a Copilot CLI tree, etc. — from it. Legacy hand-authored manifest files (`plugin.json`, `hooks.json`) are tool-specific and authored separately per tool; they sit outside the `apm.yml`-based flow. `SKILL.md` is portable — the same file works in Claude Code and Copilot CLI, whether deployed standalone or compiled from an APM package. `apm.yml` is the source manifest: it is itself tool-agnostic (one file describes the package regardless of target), but `apm compile` produces per-target compiled output — a Claude Code plugin tree, a Copilot CLI tree, etc. — from it. A legacy hand-authored `plugin.json` is tool-specific and sits outside the `apm.yml`-based flow. Hooks are apm primitives under `.apm/hooks/`, authored by `primitive-author`.
## Shared assets between skills ## Shared assets between skills

View File

@@ -33,7 +33,7 @@ Authoring source lives in `.apm/`; it is the only content source and the only th
| Agents | `.apm/agents/*.agent.md` | Role-based agents; one vendor-neutral `.agent.md` per agent (ADR-0016) | | Agents | `.apm/agents/*.agent.md` | Role-based agents; one vendor-neutral `.agent.md` per agent (ADR-0016) |
| Hooks | `.apm/hooks/` | Event-triggered automation — authored for Claude Code, see below | | Hooks | `.apm/hooks/` | Event-triggered automation — authored for Claude Code, see below |
**Hooks are authored for Claude Code, but apm writes them for every package target.** apm merges `.apm/hooks/*.json` into the consuming project's `.claude/settings.json` at install. Because kyberforge also targets Copilot and Codex, apm writes the same hook to `.github/hooks/kyberforge-hooks.json` (nested shape passed through, not reshaped) and into `.codex/hooks.json` when `.codex/` exists. Whether those harnesses execute it is unverified; the hook's behaviour is Claude-specific and it exits silently without an `apm.lock.yaml`. Details are in `docs/hooks.md` and ADR-0019's 2026-09-28 amendment. **Hooks are authored for Claude Code, but apm writes them for every package target.** apm merges `.apm/hooks/*.json` into the consuming project's `.claude/settings.json` at install. Because kyberforge also targets Copilot and Codex, apm writes the same hook to `.github/hooks/kyberforge-hooks.json` (nested shape passed through, not reshaped) and into `.codex/hooks.json` when `.codex/` exists. Whether those harnesses execute it is unverified; if they do, it exits immediately, because the script exits 0 unless `CLAUDE_PROJECT_DIR`, which Claude Code exports for SessionStart hooks, is set. Details are in `docs/hooks.md` and ADR-0019's 2026-09-28 amendment.
## Skills ## Skills

View File

@@ -24,7 +24,7 @@ The shape Claude Code reads, and therefore the shape to author under `.apm/hooks
{ {
"matcher": "Bash", "matcher": "Bash",
"hooks": [ "hooks": [
{ "type": "command", "command": "echo 'tool used'" } { "type": "command", "command": "echo 'tool used'", "timeout": 10 }
] ]
} }
] ]
@@ -71,9 +71,12 @@ tracked in a `.claude/apm-hooks.json` sidecar, so an uninstall removes them with
hand-authored hooks. Both `.claude/hooks/` and the sidecar are gitignored install output. hand-authored hooks. Both `.claude/hooks/` and the sidecar are gitignored install output.
Note that apm's **executable-trust gate is off** unless the consuming project's `apm.yml` has an Note that apm's **executable-trust gate is off** unless the consuming project's `apm.yml` has an
`executables:` block — without one, package hooks deploy with no prompt. The allow key is `executables:` block — without one, package hooks deploy with no prompt. The allow key carries a
version-pinned (`kyberforge#<version>`), so a version bump on one side alone stops the hook version (`kyberforge#<version>`), but apm 0.28.0 matches grants version-blind, so a version bump on
deploying; `check-executables-allow-sync` is the pre-push gate that catches it. See ADR-0019. one side does not stop the hook deploying. `check-executables-allow-sync` is a pre-push gate for this
repo's own convention that the key tracks `plugins/kyberforge/apm.yml`'s `version:`, not for an apm
mechanic. See ADR-0019, correction 2026-09-19, and the comment above `executables:` in the root
`apm.yml`.
## The SessionStart hook ## The SessionStart hook
@@ -82,14 +85,16 @@ and if anything is behind, runs `apm update --yes` and returns `reloadSkills: tr
session picks up the redeployed content. Rationale, measurements, and the failure modes are in session picks up the redeployed content. Rationale, measurements, and the failure modes are in
ADR-0019. ADR-0019.
**Where it looks for the lockfile.** The hook resolves a project directory as `${CLAUDE_PROJECT_DIR}` **Claude Code only, and where it looks for the lockfile.** The hook exits 0 at once, silently and
when the host exports it (Claude Code does, for SessionStart hooks) and the current directory without calling `apm`, unless `CLAUDE_PROJECT_DIR` is set and non-empty — Claude Code exports it for
otherwise, then exits silently unless that directory holds an `apm.lock.yaml` — which is what makes SessionStart hooks, and that guard is what keeps the hook inert under Copilot and Codex (see below).
it inert in any project that does not consume packages through apm. Both `apm` invocations run It then takes `${CLAUDE_PROJECT_DIR}` as the project directory and exits silently unless that
against the same resolved directory. The earlier spelling checked a bare `apm.lock.yaml` against the directory holds an `apm.lock.yaml` — which is what makes it inert in any project that does not
session's cwd, so a session opened in a subdirectory of an apm-consuming repo no-opped silently. consume packages through apm. Both `apm` invocations run against the same directory. The earlier
Keep the cwd fallback: a host that sets no `CLAUDE_PROJECT_DIR` must still get inert-but-harmless spelling checked a bare `apm.lock.yaml` against the session's cwd, so a session opened in a
behaviour, not an unset-variable error. subdirectory of an apm-consuming repo no-opped silently. Do not reintroduce a cwd fallback: under a
host that sets no `CLAUDE_PROJECT_DIR` the lockfile guard passes in every apm consumer, and the
fallback ran `apm update --yes` there (ADR-0019, correction 2026-09-28).
**The `timeout` in `hooks.json` must exceed the script's own budget.** The script spends at most **The `timeout` in `hooks.json` must exceed the script's own budget.** The script spends at most
`timeout 60 apm outdated` plus `timeout 300 apm update`; the hook entry declares `timeout: 380`, the `timeout 60 apm outdated` plus `timeout 300 apm update`; the hook entry declares `timeout: 380`, the
@@ -123,10 +128,12 @@ apm 0.28.0):
- **Codex** gets the entry merged into `.codex/hooks.json`, but only when `.codex/` already exists; - **Codex** gets the entry merged into `.codex/hooks.json`, but only when `.codex/` already exists;
otherwise nothing is written. otherwise nothing is written.
This is accepted rather than fixed (ADR-0019, amendment 2026-09-28). The hook's behaviour is This is accepted rather than fixed (ADR-0019, amendment and correction 2026-09-28). The hook's
Claude-specific anyway — the `startup` matcher, `CLAUDE_PROJECT_DIR`, and the `reloadSkills` behaviour is Claude-specific anyway — the `startup` matcher, `CLAUDE_PROJECT_DIR`, and the
output — and the script exits silently without an `apm.lock.yaml`, so a harness that does run it is `reloadSkills` output — and a harness that does run it exits immediately, because the script's
unharmed. The only apm-native way to keep it Claude-only is a separate package whose `apm.yml` first guard exits 0 when `CLAUDE_PROJECT_DIR` is unset. The `apm.lock.yaml` guard cannot do that
job: `apm install` wrote the lock, so it passes in every project the hook reaches. The only
apm-native way to keep it Claude-only is a separate package whose `apm.yml`
declares `target: claude`; per-file target routing (`claude-hooks.json`) is deprecated, and declares `target: claude`; per-file target routing (`claude-hooks.json`) is deprecated, and
kyberforge cannot narrow its own `targets:` without dropping its skills from Copilot and Codex. kyberforge cannot narrow its own `targets:` without dropping its skills from Copilot and Codex.

View File

@@ -13,7 +13,7 @@ Ground truth for this file is the installed apm-cli **0.28.0** source (`apm_cli/
- `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. - `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. - 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. - **Filename routing (deprecated, still active).** `hook_file_routing._hook_file_allowed_targets` lowercases the stem first (`hook_file.stem.lower()`), so matching is case-insensitive: `Claude-Hooks.json` routes like `claude-hooks.json`. It routes a file whose stem is `hooks-<token>`, is a bare `<token>-hooks`, or ends `-<token>-hooks` (tokens: `copilot`, `vscode`, `cursor`, `claude`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`) to that target only, with a deprecation warning. A combined stem whose trailing segments are all tokens, such as `claude-codex-hooks`, routes to the union of those targets (`_target_suffix_segments`, `_union_target_sets`). `copilot` and `vscode` are one set: either token selects both. 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 ## Accepted source shapes
@@ -77,7 +77,7 @@ Nested and flat entries can be mixed in one event array. Parse failure modes (al
**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. **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. **Confirmed for this repo:** `plugins/kyberforge/.apm/hooks/hooks.json` (`SessionStart`, `"matcher": "startup"`, `command: ${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 ### Copilot: one file per source file, *not* reshaped
@@ -110,7 +110,7 @@ Merge targets: `cursor` (`.cursor/hooks.json`, `version: 1` default), `codex` (`
## Security / trust gate ## 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. Hooks are an executable primitive (`security/executables.EXEC_TYPE_HOOKS`). If the consuming project's `apm.yml` has an `executables:` block (`executables: {allow: …, deny: …}`, the form this repo's root `apm.yml` uses), dependency hooks are deny-by-default until approved (`apm approve`). `allowExecutables` is the deprecated spelling, still read as an alias for one minor cycle and migrated into `executables.allow` on write (`security/executables.parse_project_executables`, `write_project_executables`). Non-interactive runs hard-error. Local project content (`_local`) is always trusted (`install/exec_gate.check_executable_approval`). With neither 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
@@ -127,13 +127,13 @@ Must (an author skill enforces these; an audit skill checks them):
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`.* 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`.* 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`.* 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`.* 6. Avoid a stem matching `hooks-<target>`, `<target>-hooks`, `*-<target>-hooks` or a combined `<a>-<b>-hooks`, in any letter case, unless you intend deprecated routing. Use `target:` in the package `apm.yml` instead. *Source: `hook_file_routing`.*
Should: 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`.* 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="*"`.* 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`.* 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`.* 10. Script paths must not contain spaces. apm's token pattern is `\$\{…PLUGIN_ROOT\}([\\/][^\s"']+)`, so the path must follow `}` directly and ends at the first whitespace or quote. Quoting the whole token, `"${PLUGIN_ROOT}/x.sh"`, is rewritten (and keeps its quotes); the split form `"${PLUGIN_ROOT}"/x.sh` is never matched and deploys unrewritten and unbundled; `"${PLUGIN_ROOT}/my hook.sh"` resolves only `my` and warns "Hook script not found". Quoting does not make a space safe. *Source: `plugin_root_pattern` and `rel_pattern` in `_rewrite_command_for_target` (`hook_integrator.py` ~L652, L694); the adjacent-quote check there only decides whether apm adds its own quotes.*
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)`.* 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. 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

@@ -45,11 +45,12 @@ EOF
chmod +x "$FAKE_BIN/apm" chmod +x "$FAKE_BIN/apm"
} }
# CLAUDE_PROJECT_DIR is cleared rather than merely left alone: a session in this # CLAUDE_PROJECT_DIR is set to the fixture rather than inherited: a session in
# repo exports it, and an inherited value would point every case at the real # this repo exports it, and an inherited value would point every case at the
# repo root (which has a real apm.lock.yaml) instead of the fixture. The # real repo root (which has a real apm.lock.yaml) instead of the fixture. It
# project-directory cases below set it deliberately. # must be set, not cleared — the hook is Claude Code only and exits at once
run_hook() { (cd "$WORK" && env -u CLAUDE_PROJECT_DIR PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); } # without it. The project-directory cases below vary it deliberately.
run_hook() { (cd "$WORK" && env CLAUDE_PROJECT_DIR="$WORK" PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
# Same, with an explicit cwd and CLAUDE_PROJECT_DIR. $1 is the cwd; $2 the value # Same, with an explicit cwd and CLAUDE_PROJECT_DIR. $1 is the cwd; $2 the value
# for CLAUDE_PROJECT_DIR, or the literal `-` to leave it unset. # for CLAUDE_PROJECT_DIR, or the literal `-` to leave it unset.
@@ -86,7 +87,7 @@ echo "--- inert when apm is absent ---"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
rm -f "$WORK/update-was-called" rm -f "$WORK/update-was-called"
out="$( (cd "$WORK" && PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$? out="$( (cd "$WORK" && env CLAUDE_PROJECT_DIR="$WORK" PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$?
[[ $rc -eq 0 ]] && pass "exits 0 when apm is not on PATH" || fail "should exit 0 when apm is missing" [[ $rc -eq 0 ]] && pass "exits 0 when apm is not on PATH" || fail "should exit 0 when apm is missing"
[[ -z "$out" ]] && pass "emits nothing when apm is not on PATH" || fail "should stay silent when apm is missing" [[ -z "$out" ]] && pass "emits nothing when apm is not on PATH" || fail "should stay silent when apm is missing"
@@ -252,7 +253,7 @@ echo "--- anchors on the project root, not the session cwd ---"
# subdirectory of an apm-consuming repo therefore no-opped silently — and would # subdirectory of an apm-consuming repo therefore no-opped silently — and would
# have run `apm outdated`/`apm update` against that wrong directory had the # have run `apm outdated`/`apm update` against that wrong directory had the
# guard passed. Claude Code exports CLAUDE_PROJECT_DIR for SessionStart hooks, # guard passed. Claude Code exports CLAUDE_PROJECT_DIR for SessionStart hooks,
# so that is the anchor; the cwd is only the fallback. # so that is the anchor, with no cwd fallback.
ELSEWHERE="$WORK/elsewhere" ELSEWHERE="$WORK/elsewhere"
mkdir -p "$ELSEWHERE" mkdir -p "$ELSEWHERE"
rm -f "$ELSEWHERE/apm.lock.yaml" rm -f "$ELSEWHERE/apm.lock.yaml"
@@ -269,17 +270,30 @@ out="$(run_hook_in "$ELSEWHERE" "$WORK")"
grep -q "6 package" <<< "$(json_field additionalContext <<< "$out")" \ grep -q "6 package" <<< "$(json_field additionalContext <<< "$out")" \
&& pass "reports the count found via CLAUDE_PROJECT_DIR" || fail "should report the count" && pass "reports the count found via CLAUDE_PROJECT_DIR" || fail "should report the count"
# The fallback is not cosmetic: a host that installed this plugin natively sets # Claude Code only (ADR-0019, amendment 2026-09-28). apm deploys this hook to
# no CLAUDE_PROJECT_DIR, and the hook must stay inert-but-harmless there rather # Copilot and Codex too, and in a consumer the lockfile guard passes there —
# than erroring on an unset variable (the script runs under `set -u`). # apm wrote the lock. A host that sets no CLAUDE_PROJECT_DIR must therefore
# exit before any apm call, even with a lockfile in the cwd and a stale install
# on offer; the old cwd fallback ran `apm update --yes` under such a host.
rm -f "$WORK/update-was-called" "$WORK/apm-cwd" rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
out="$(run_hook_in "$WORK" "-")" out="$(run_hook_in "$WORK" "-")"; rc=$?
[[ -f "$WORK/update-was-called" ]] \ [[ $rc -eq 0 ]] && pass "exits 0 when CLAUDE_PROJECT_DIR is unset" \
&& pass "falls back to the cwd when CLAUDE_PROJECT_DIR is unset" \ || fail "exited $rc with no CLAUDE_PROJECT_DIR — must exit 0"
|| fail "must still work with no CLAUDE_PROJECT_DIR in the environment" [[ -z "$out" ]] && pass "stays silent when CLAUDE_PROJECT_DIR is unset" \
[[ "$(cat "$WORK/apm-cwd" 2>/dev/null)" == "$WORK" ]] \ || fail "emitted output with no CLAUDE_PROJECT_DIR — a non-Claude host must see nothing"
&& pass "runs apm in the cwd under the fallback" \ [[ ! -f "$WORK/apm-cwd" ]] \
|| fail "apm ran in '$(cat "$WORK/apm-cwd" 2>/dev/null)' — should be the cwd" && pass "runs no apm command when CLAUDE_PROJECT_DIR is unset" \
|| fail "ran apm with no CLAUDE_PROJECT_DIR — a non-Claude host must never reach apm"
[[ ! -f "$WORK/update-was-called" ]] \
&& pass "does not run apm update when CLAUDE_PROJECT_DIR is unset" \
|| fail "ran apm update under a host that is not Claude Code"
# Set but empty is the same as unset: there is no project root to anchor on.
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
out="$(run_hook_in "$WORK" "")"; rc=$?
[[ $rc -eq 0 && -z "$out" && ! -f "$WORK/apm-cwd" ]] \
&& pass "treats an empty CLAUDE_PROJECT_DIR as unset" \
|| fail "an empty CLAUDE_PROJECT_DIR must exit 0 silently without running apm"
rm -f "$WORK/update-was-called" "$WORK/apm-cwd" rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
out="$(run_hook_in "$ELSEWHERE" "$ELSEWHERE")"; rc=$? out="$(run_hook_in "$ELSEWHERE" "$ELSEWHERE")"; rc=$?
@@ -296,9 +310,9 @@ echo ""
echo "--- hooks.json wiring ---" echo "--- hooks.json wiring ---"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# apm resolves script paths relative to the package root, and `apm pack` keeps # apm resolves script paths relative to the package root, and the script lives
# only *.json from .apm/hooks/ — so a ${PLUGIN_ROOT}/hooks/... reference # under .apm/hooks/ — so a ${PLUGIN_ROOT}/hooks/... reference points at a
# points at a directory the script never reaches. It must be .apm/-relative, and # directory that does not exist. It must be .apm/-relative, and
# it uses apm's target-neutral token, which apm rewrites identically to # it uses apm's target-neutral token, which apm rewrites identically to
# ${CLAUDE_PLUGIN_ROOT} for every target (ADR-0019, amendment 2026-09-28). # ${CLAUDE_PLUGIN_ROOT} for every target (ADR-0019, amendment 2026-09-28).
referenced="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["command"])' "$HOOKS_JSON")" referenced="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["command"])' "$HOOKS_JSON")"