feat(kyberforge): primitive-author and factory-audit support for apm hooks, instructions and prompts #144

Open
Claude wants to merge 19 commits from feat/primitive-author into main
29 changed files with 462 additions and 156 deletions
Showing only changes of commit 965208bddd - Show all commits

View File

@@ -274,7 +274,9 @@ no hook at all, for the reasons already documented in `plugins/kyberforge/docs/h
> cwd fallback is gone, and `tests/test-apm-current-hook.sh` pins that an unset or empty > 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 > `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 > 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. > lockfile. The seventh-plugin alternative below remains rejected as disproportionate, but no
> longer because either guard keeps the hook off external kyberforge consumers: on Claude Code
> neither guard stops it for an apm consumer, and that is intended.
**`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
@@ -292,3 +294,7 @@ be used again if a hook git actually invokes is ever wanted.
consumers. Rejected as disproportionate: the `apm.lock.yaml` guard already makes the hook inert consumers. Rejected as disproportionate: the `apm.lock.yaml` guard already makes the hook inert
for anyone not consuming through apm, and a package exists to be maintained, versioned, and for anyone not consuming through apm, and a package exists to be maintained, versioned, and
registered in the marketplace. registered in the marketplace.
*Rationale superseded by the 2026-09-28 correction above: the `CLAUDE_PROJECT_DIR` guard keeps
the hook off non-Claude hosts, and on Claude Code it runs for every apm consumer, including
external kyberforge consumers, by design. The alternative stays rejected as disproportionate.*

View File

@@ -19,7 +19,9 @@ set -uo pipefail
# guard a non-Claude session start would run `apm update --yes` and rewrite the # guard a non-Claude session start would run `apm update --yes` and rewrite the
# working tree with nothing to re-scan it. Claude Code exports # working tree with nothing to re-scan it. Claude Code exports
# CLAUDE_PROJECT_DIR for SessionStart hooks and the other targets do not # CLAUDE_PROJECT_DIR for SessionStart hooks and the other targets do not
# document it, so its absence is the exit (ADR-0019, amendment 2026-09-28). # document setting it, so its absence is the exit (ADR-0019, correction
# 2026-09-28). A heuristic: if the variable is inherited from the user's
# environment, a non-Claude session start gets past this guard.
[[ -n "${CLAUDE_PROJECT_DIR:-}" ]] || exit 0 [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] || exit 0
# Anchor on the project root, not the session's cwd: a session opened in a # Anchor on the project root, not the session's cwd: a session opened in a
@@ -27,8 +29,8 @@ set -uo pipefail
# run the apm calls below against that wrong directory. # run the apm calls below against that wrong directory.
project_dir="$CLAUDE_PROJECT_DIR" project_dir="$CLAUDE_PROJECT_DIR"
# No lockfile means nothing was installed through apm here — e.g. a host that # No lockfile means this project consumes nothing through apm, so there is
# installed this plugin natively. Say nothing and cost nothing. # nothing for apm update to refresh. Say nothing and cost nothing.
[[ -f "$project_dir/apm.lock.yaml" ]] || exit 0 [[ -f "$project_dir/apm.lock.yaml" ]] || exit 0
command -v apm > /dev/null 2>&1 || exit 0 command -v apm > /dev/null 2>&1 || exit 0

View File

@@ -1,11 +1,12 @@
--- ---
name: apm-workflow name: apm-workflow
description: > description: >
Use when authoring, installing, or publishing an apm package, its apm.yml and Use when authoring, installing or publishing an apm package, its apm.yml and
the dependencies it declares, or an apm marketplace — even when the user does dependencies, or a marketplace, even if "apm" goes unsaid.
not say "apm". Not the apm binary or an agent runtime -> `apm-install`. Not the apm binary or an agent runtime -> apm-install.
Not a hook, instruction or prompt file -> primitive-author.
metadata: metadata:
version: "1.0.1" version: "1.0.2"
category: apm category: apm
source_keys: source_keys:
- context7-microsoft-apm - context7-microsoft-apm
@@ -13,9 +14,8 @@ metadata:
## Gotchas ## Gotchas
- MCP server secrets in `apm.yml` (headers, env vars) must use `${VAR}` indirection, never literal values, so they resolve at install or runtime and are never committed. - `apm experimental enable registries` must run before a `registries:` block or `registry.*` config takes effect in any flow; without it they silently do nothing.
- `apm experimental enable registries` must run before a `registries:` block or `registry.*` config takes effect anywhere — configure, install or publish. Without it, declaring one silently does nothing: no error, no warning. - `apm.yml`'s `type:` is never checked against what `.apm/` holds, so `apm install` and `apm compile` can exit 0 shipping none of the primitives you expected. Confirm the deployed output, not the exit code (`references/configure.md`).
- `apm.yml`'s `type:` selects which primitives are processed and is never checked against what `.apm/` holds, so `apm install` and `apm compile` can exit 0 having shipped none of the ones you expected. Set it to cover every primitive the package ships, and confirm the deployed output, not the exit code. Mechanics: `references/configure.md`.
## Step 1 — Dispatch ## Step 1 — Dispatch

View File

@@ -73,7 +73,7 @@ version follows a separate rule — see `references/marketplace.md`.
## MCP server secrets ## MCP server secrets
`${VAR}` indirection is required for MCP server secrets (headers, env vars) in `apm.yml`, never literal values — see SKILL.md Gotchas. MCP server secrets (headers, env vars) in `apm.yml` must use `${VAR}` indirection, never literal values, so they resolve at install or runtime and are never committed.
## Registries (config-level, not `apm.yml`) ## Registries (config-level, not `apm.yml`)

View File

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

View File

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

View File

@@ -23,11 +23,11 @@ bash scripts/validate.sh <instruction-file>
bash scripts/vale-wrap.sh <instruction-file> bash scripts/vale-wrap.sh <instruction-file>
``` ```
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path, frontmatter, `description`, body, an `applyTo` that is present but empty or has unbalanced braces or brackets, a missing or list-form `applyTo`, extra keys, and a stem duplicated at the package root. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason. `validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description`, body, an `applyTo` that is present but empty or has unbalanced braces or brackets, a missing or list-form `applyTo`, extra keys, and a stem duplicated at the package root. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
A missing `applyTo` is a SUGGESTION, not the FAIL `primitive-author`'s instruction Must 4 implies: absence is legal after the author Gate's explicit yes, which the audit cannot see. The **scope** dimension's always-on FAILs below cover the misuse. Do not re-tier it by judgment. A missing `applyTo` is a SUGGESTION, not the FAIL `primitive-author`'s instruction Must 4 implies: absence is legal after the author Gate's explicit yes, which the audit cannot see. The **scope** dimension's always-on FAILs below cover the misuse. Do not re-tier it by judgment.
`vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading. `vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. That includes the rules scoped to the `description` (`Kyberforge.DescriptionOpener`, `VagueWording`, `CompositionNote`), a deliberate deviation from `primitive-author`, which holds description wording to at most a Should: the house prose rules apply to every model- or user-visible description, and a deterministic rule does not change tier by file kind. Do not re-tier them. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
There is no provenance step: an instruction carries no `source_keys`. There is no provenance step: an instruction carries no `source_keys`.
@@ -41,7 +41,7 @@ Cite file and line for every finding.
**scope** — an instruction applies when files matching `applyTo` are touched; with no `applyTo` it loads into every session of every repo that installs the package. **scope** — an instruction applies when files matching `applyTo` are touched; with no `applyTo` it loads into every session of every repo that installs the package.
- FAIL: an always-on file whose content is a rule for this repo alone — it belongs in `AGENTS.md`, which is the repo's single always-on source, not in a package that ships it to every consumer. - FAIL: the content is a rule for this repo alone, scoped or always-on — it belongs in `AGENTS.md` (a nested `AGENTS.md` for a subtree), which is the repo's own instruction source, not in a package that ships it to every consumer. This matches `primitive-author`'s Gate, which routes every repo-only rule there.
- FAIL: the stem matches an instruction an installed dependency ships — both deploy to `.claude/rules/<stem>.md`, and one silently overwrites the other. - FAIL: the stem matches an instruction an installed dependency ships — both deploy to `.claude/rules/<stem>.md`, and one silently overwrites the other.
- SUGGESTION: an `applyTo` glob that matches no tracked file here. It is legitimate for files the package's consumers have and this repo does not, so name the mismatch rather than failing it. - SUGGESTION: an `applyTo` glob that matches no tracked file here. It is legitimate for files the package's consumers have and this repo does not, so name the mismatch rather than failing it.
- SUGGESTION: an always-on file whose content is really file-type specific — narrow it with `applyTo`. - SUGGESTION: an always-on file whose content is really file-type specific — narrow it with `applyTo`.

View File

@@ -24,9 +24,9 @@ bash scripts/validate.sh <prompt-file>
bash scripts/vale-wrap.sh <prompt-file> bash scripts/vale-wrap.sh <prompt-file>
``` ```
`validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, frontmatter, `description` presence, length, and trigger or `Not X -> Y` boundary clause, keys Claude drops, `input:` names and the object form `- <name>: "<desc>"` (`primitive-author` prompt Must 3 — a bare name, a string list or a plain map is a FAIL even though apm reads them), and `${input:x}` references against `input:`. Keys Claude drops are a SUGGESTION, not a FAIL, on purpose: a Copilot-only key is legitimate when its Claude drop is intended, and only the author can say which. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason. `validate.sh` findings become the `### Structure` dimension, FAILs and SUGGESTIONs both, at the tier the script assigned: path and name, unfilled `FILL IN` or `FILL_IN_` template placeholders, frontmatter, `description` presence, length, and trigger or `Not X -> Y` boundary clause, keys Claude drops, the camelCase spelling of `allowed-tools` or `argument-hint` and an `argument-hint` alongside `input:` (both SUGGESTION), `input:` names and the object form `- <name>: "<desc>"` (`primitive-author` prompt Must 3 — a bare name, a string list or a plain map is a FAIL even though apm reads them), and `${input:x}` references against `input:`. Keys Claude drops are a SUGGESTION, per `primitive-author` prompt Should 5: a Copilot-only key is legitimate when its Claude drop is intended, and only the author can say which. It exits **0** with no FAIL, **1** on real findings, **2** when it never ran — report that as `### Structure` unverified, quoting the stderr reason.
`vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading. `vale-wrap.sh` applies the bundled `Kyberforge` style. Every alert is a FAIL under `### Prose`, cited by rule ID; do not re-derive it by judgment. That includes the rules scoped to the `description` (`Kyberforge.DescriptionOpener`, `VagueWording`, `CompositionNote`), a deliberate deviation from `primitive-author`, which holds description wording to at most a Should: the house prose rules apply to every model- or user-visible description, and a deterministic rule does not change tier by file kind. Do not re-tier them. `0 files` scanned means NOT RUN, not clean — say so and judge prose by reading.
There is no provenance step: a prompt carries no `source_keys`. There is no provenance step: a prompt carries no `source_keys`.

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ metadata:
## Gotchas ## Gotchas
- 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 a `/fork` subagent and an inline run, and `references/apm-routes.md` rules the fork out.
## Step 1 — Grill the intent ## Step 1 — Grill the intent
@@ -50,4 +50,4 @@ When the intent spans several rows, chain the routes in dependency order — an
## Step 3 — Closing gates, common to every route ## Step 3 — Closing gates, common to every route
- **Resolve before closing.** A route is finished only when its verification reports nothing unresolved. An actionable finding reopens the route; it is never reported onward as a caveat. - **Resolve before closing.** A route is finished only when its verification reports nothing unresolved. An actionable finding reopens the route; it is never reported onward as a caveat.
- **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one. `agent-author`, `primitive-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output does not say they did. - **Bump the package version.** A skill route always lands here: `skill-author` moves only a skill's own `metadata.version`, which is not the package `apm.yml`'s number — so read `references/version-bump.md` after one; it skips the bump when the branch already has one. `agent-author`, `primitive-author` and the apm routes bump the package themselves at plugin scope; after those, read it only when their output says neither that they bumped nor that the branch already had.

View File

@@ -24,6 +24,11 @@ than declaring one, so it does not count as a match. Skip it and keep walking up
Skip this step entirely if no ancestor `apm.yml` carries a `type:` field: the artifact is then Skip this step entirely if no ancestor `apm.yml` carries a `type:` field: the artifact is then
standalone or scoped to a user agent directory, and there is no package to version. standalone or scoped to a user agent directory, and there is no package to version.
Skip the bump, and report that you skipped it, if the branch already moved this package's `version`
for unreleased work: `git diff $(git merge-base HEAD main) -- <package>/apm.yml` shows a changed
`version:` line. One bump covers all unreleased work on a branch — `primitive-author` skips on the
same condition — so bumping again here double-counts it.
## Delegate the bump ## Delegate the bump
Invoke `apm-workflow` as a **clean-context subagent** — fresh, not forked — with this Invoke `apm-workflow` as a **clean-context subagent** — fresh, not forked — with this

View File

@@ -15,8 +15,8 @@ metadata:
## Gotchas ## Gotchas
- `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. - `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; 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 named that way anywhere in the repo becomes 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.
## Step 1 — Dispatch ## Step 1 — Dispatch
@@ -26,13 +26,12 @@ metadata:
| A hook — `.apm/hooks/<name>.json`, or "run X whenever Y happens" | hook | `references/hook.md` | | A hook — `.apm/hooks/<name>.json`, or "run X whenever Y happens" | hook | `references/hook.md` |
| An instruction — `.apm/instructions/<name>.instructions.md`, or a rule for files matching a pattern | instruction | `references/instruction.md` | | An instruction — `.apm/instructions/<name>.instructions.md`, or a rule for files matching a pattern | instruction | `references/instruction.md` |
| A prompt — `.apm/prompts/<name>.prompt.md`, or a reusable message the user types to kick off work | prompt | `references/prompt.md` | | A prompt — `.apm/prompts/<name>.prompt.md`, or a reusable message the user types to kick off work | prompt | `references/prompt.md` |
| A skill or an agent | — | stop: route to `skill-author` or `agent-author` |
Read only the reference matching the resolved type — each is self-contained. If the target sits inside a git worktree, capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it. Read only the reference matching the resolved type — each is self-contained. If the target sits inside a git worktree, capture `rtk git log --oneline -1` before touching the filesystem; Step 4 needs it.
## Step 2 — Boundary gate ## Step 2 — Boundary gate
Run the reference's **Gate** section before writing anything. A failed gate stops this skill: name the owner it points to — `skill-author` for procedure, `agentsmd-author` for a repo-only rule, `apm-workflow` for reach or `targets:` — and hand over. Never bend the artifact to pass the gate. Run the reference's **Gate** section before writing anything. A failed gate stops this skill: hand over to the owner the Gate names. Never bend the artifact to pass the gate.
## Step 3 — Create or improve ## Step 3 — Create or improve
@@ -42,11 +41,11 @@ Run the reference's **Gate** section before writing anything. A failed gate stop
| File exists, at least one signal | Improve: read the whole file, then apply each signal against the reference's checklist | | File exists, at least one signal | Improve: read the whole file, then apply each signal against the reference's checklist |
| File exists, no signal | Stop and ask whether the user meant a new file or has feedback to apply | | File exists, no signal | Stop and ask whether the user meant a new file or has feedback to apply |
Signals: grill output, `/factory-audit` findings, inline feedback, session context describing what went wrong. Group findings by root cause and fix the cause once. Signals: grill output, `/factory-audit` findings, inline feedback, session context. Group findings by root cause and fix the cause once.
## Step 4 — Validate and close ## Step 4 — Validate and close
1. Run `/factory-audit` on the file, inline in this context; resolve every FAIL before reporting done, including the `### Prose` FAILs Vale raises on an instruction or prompt body. 1. Run `/factory-audit` on the file, inline in this context; resolve every FAIL before reporting done, including the `### Prose` FAILs Vale raises on an instruction or prompt body or description.
2. Run `rtk apm install --dry-run` from the repo root and read what each target will receive. On a feature branch, discard `apm.lock.yaml` churn afterwards (`rtk git checkout -- apm.lock.yaml`). 2. Render it: in a fresh `mktemp -d` directory, run `rtk apm install <absolute path to the owning package> --target <its targets:>`, then read what each target received — `.claude/settings.json` and `.github/hooks/`, `.claude/rules/` and `.github/instructions/`, or `.claude/commands/` and `.github/prompts/`. A local path deploys the working tree; `--dry-run` renders nothing, and a repo-root install resolves the remote's `main`, so never use either.
3. Bump the owning package's `apm.yml` `version:` — minor for a new hook, instruction or prompt, patch for a fix — unless this branch already bumped it for unreleased work. None of these has a version of its own. 3. Bump the owning package's `apm.yml` `version:` — minor for a new hook, instruction or prompt, patch for a fix — unless this branch already bumped it for unreleased work. None of these is released on its own version — bump the package even if an instruction carries an optional `version:` key.
4. **Commit verification.** Inside a git worktree, once the audit is clean, run `rtk git add` and `rtk git commit`, then re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. Staged-but-uncommitted work is silently lost if the tree is cleaned up. Outside a worktree, report done on a clean audit and name that as the reason. 4. **Commit verification.** Inside a git worktree, once the audit is clean, run `rtk git add` and `rtk git commit`, then confirm `rtk git log --oneline -1` changed from Step 1's hash: staged-but-uncommitted work is lost if the tree is cleaned up. Outside a worktree, report done on a clean audit and name that as the reason.

View File

@@ -48,7 +48,7 @@ target:
- **`${PLUGIN_ROOT}`** is the target-neutral token; apm rewrites it per target - **`${PLUGIN_ROOT}`** is the target-neutral token; apm rewrites it per target
(`"${CLAUDE_PROJECT_DIR}/.claude/hooks/<pkg>/…"` on Claude, repo-relative elsewhere). (`"${CLAUDE_PROJECT_DIR}/.claude/hooks/<pkg>/…"` on Claude, repo-relative elsewhere).
`${CLAUDE_PLUGIN_ROOT}` is rewritten identically, so it is valid, but it ties the source to one `${CLAUDE_PLUGIN_ROOT}` is rewritten identically, so it is valid, but it ties the source to one
harness's name (Should 12). harness's name (Should 11).
- **Claude is the verified target.** apm 0.28.0 passes this nested shape to Copilot without - **Claude is the verified target.** apm 0.28.0 passes this nested shape to Copilot without
reshaping it, and whether Copilot CLI runs nested entries or honours `matcher` is unverified. That reshaping it, and whether Copilot CLI runs nested entries or honours `matcher` is unverified. That
gap is apm's to close. Per-file target routing is deprecated, so a Copilot-native flat hook gap is apm's to close. Per-file target routing is deprecated, so a Copilot-native flat hook
@@ -65,15 +65,25 @@ Must:
2. Use the wrapped shape `{"hooks": {Event: [...]}}`. If a naked settings slice is used instead, 2. Use the wrapped shape `{"hooks": {Event: [...]}}`. If a naked settings slice is used instead,
every top-level value must be a list, with no stray scalar keys anywhere. every top-level value must be a list, with no stray scalar keys anywhere.
3. Every event value is a list of objects, and every nested `hooks` is a list of objects. Anything 3. Every event value is a list of objects, and every nested `hooks` is a list of objects. Anything
else fails the Copilot install outright. The file contributes at least one entry: an empty one else fails the Copilot install outright. The file contributes at least one entry, and every
deploys nothing, with only a warning. entry carries at least one handler: an empty list or a handler-less entry deploys nothing, with
only a warning.
4. Event names are PascalCase (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, 4. Event names are PascalCase (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`,
`Stop`, …). An all-lowercase name (`stop`) never warns and never fires; a camelCase name outside `Stop`, …). An all-lowercase name never warns: only Kiro's rename map covers one (`stop` →
apm's rename map (`userPromptSubmit`) deploys verbatim to Claude and never fires. `Stop`), and every other target deploys it verbatim, where it never fires. A camelCase name
outside apm's rename map (`userPromptSubmit`) likewise deploys verbatim to Claude and never
fires.
5. The script is referenced as `${PLUGIN_ROOT}/…` (or `${CLAUDE_PLUGIN_ROOT}/…`, see Shape) for 5. The script is referenced as `${PLUGIN_ROOT}/…` (or `${CLAUDE_PLUGIN_ROOT}/…`, see Shape) for
the package root, or `./…` for the hook directory, and exists inside the package. No absolute the package root, or `./…` for the hook directory, and exists inside the package. The script is
path and no bare relative path (`scripts/x.sh`): apm bundles and rewrites neither. No `$` or the command's first token or the first argument after an interpreter (`bash`, `sh`, `zsh`,
backtick in the path itself. A missing script is only a warning at install time. `python`, `python3`, `node`, `pwsh`, `ruby`, `perl`); in either position, no absolute path and
no bare relative path (`scripts/x.sh`): apm bundles and rewrites neither. No `$` or backtick in
the path itself, and no space. When quoting, quote the whole token —
`"${PLUGIN_ROOT}/scripts/my-hook.sh"`, never `"${PLUGIN_ROOT}"/scripts/x.sh`: apm rewrites
`${PLUGIN_ROOT}` only when a path separator follows it directly, and only up to the next space
or quote, so a split quote is left unrewritten and a spaced path is cut short. This is stricter
than the research's Should, as with Must 6: either defect fails every time the hook fires. A
missing script is only a warning at install time.
6. A script run directly as the command's first token is executable. This is stricter than the 6. A script run directly as the command's first token is executable. This is stricter than the
research's Should: without it the hook fails every time it fires. A script passed to an research's Should: without it the hook fails every time it fires. A script passed to an
interpreter (`bash ${PLUGIN_ROOT}/x.sh`) needs no executable bit. interpreter (`bash ${PLUGIN_ROOT}/x.sh`) needs no executable bit.
@@ -86,14 +96,10 @@ 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. Keep script paths free of spaces, and when quoting, quote the whole token: 10. Keep helper files in the hook directory non-JSON. Copilot's loader rejects any bundled `.json`
`"${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`
without a `hooks` key. without a `hooks` key.
12. Prefer `${PLUGIN_ROOT}` over `${CLAUDE_PLUGIN_ROOT}`. 11. Prefer `${PLUGIN_ROOT}` over `${CLAUDE_PLUGIN_ROOT}`.
13. No filename that routes by target. Case-insensitively, apm routes a stem of exactly 12. No filename that routes by target. Case-insensitively, apm routes a stem of exactly
`hooks-<target>` and any stem ending `<target>-hooks` — bare (`claude-hooks`), prefixed `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 (`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 reach belongs to `targets:` (see Gate); the research allows it only when deprecated routing is

View File

@@ -18,8 +18,8 @@ glob. On Claude it deploys to `.claude/rules/<stem>.md` with `applyTo` renamed t
always-on source. Stop and hand to `agentsmd-author`. always-on source. Stop and hand to `agentsmd-author`.
- **No file pattern fits** → an instruction without `applyTo` is always-on in every session of - **No file pattern fits** → an instruction without `applyTo` is always-on in every session of
every repo that installs this package, and `apm compile` can fold it into the global sections of every repo that installs this package, and `apm compile` can fold it into the global sections of
`AGENTS.md` and `CLAUDE.md` (skipped when `.github/instructions/` or `.claude/rules/` is already `AGENTS.md` and `CLAUDE.md` (CLAUDE.md is skipped when `.claude/rules/` is populated, AGENTS.md
populated, unless `--force-instructions`). Say exactly that to the user and continue only on an explicit yes. when `.github/instructions/` is, unless `--force-instructions`). Say exactly that to the user and continue only on an explicit yes.
Legitimate when a package deliberately ships guidance to its consumers; never a default. Legitimate when a package deliberately ships guidance to its consumers; never a default.
- **Procedure the agent follows step by step** → a skill. Stop and hand to `skill-author`. - **Procedure the agent follows step by step** → a skill. Stop and hand to `skill-author`.
- **A rule scoped to a file pattern** → continue. - **A rule scoped to a file pattern** → continue.
@@ -47,8 +47,8 @@ Should:
its handling of a list is unverified. its handling of a list is unverified.
7. Keep frontmatter to `description` and `applyTo`, plus optional `author` and `version`. No target 7. Keep frontmatter to `description` and `applyTo`, plus optional `author` and `version`. No target
consumes other keys, and Claude drops them. consumes other keys, and Claude drops them.
8. Put any rationale Claude needs in the body. `description` never reaches Claude — it survives only 8. Put any rationale Claude needs in the body. `description` never reaches Claude — it survives for
for Copilot and as Cursor's index text. Copilot and as index text in Cursor rules and compiled AGENTS.md/CLAUDE.md.
9. Keep relative markdown links resolvable from the source file. 9. Keep relative markdown links resolvable from the source file.
10. Check the glob against the tree: one that matches nothing here fires only in consumer repos 10. Check the glob against the tree: one that matches nothing here fires only in consumer repos
that have such files, and one broader than the rule's real scope spends context on every file that have such files, and one broader than the rule's real scope spends context on every file

View File

@@ -56,6 +56,8 @@ Should:
5. Frontmatter keys stay within `description`, `allowed-tools`, `model`, `argument-hint` and 5. Frontmatter keys stay within `description`, `allowed-tools`, `model`, `argument-hint` and
`input`. Claude drops everything else with only a warning. The exception: a Copilot-only key `input`. Claude drops everything else with only a warning. The exception: a Copilot-only key
(`agent`, `tools`, …) that is intended, with its Claude drop accepted and said so. (`agent`, `tools`, …) that is intended, with its Claude drop accepted and said so.
The research files this as a Must; it is a Should here because only the author can say a
Copilot-only key is intended.
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.

View File

@@ -4,7 +4,7 @@
- **URL:** file:///root/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/ - **URL:** file:///root/.local/pipx/venvs/apm-cli/lib/python3.11/site-packages/apm_cli/
- **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md - **Research doc:** plugins/kyberforge/docs/research/docs/microsoft-apm/sources.md
- **Description:** Installed apm-cli 0.28.0 source — ground truth for what apm deploys from a hook, instruction or prompt file and what it silently skips or only warns on; every Must/Should checklist item traces to the research docs' Authoring checklists, and the `**/*.instructions.md` local-discovery glob behind the template-suffix Gotcha is `primitives/discovery.py` `LOCAL_PRIMITIVE_PATTERNS` - **Description:** Installed apm-cli 0.28.0 source — ground truth for what apm deploys from a hook, instruction or prompt file and what it silently skips or only warns on; each Must/Should traces to the research docs' Authoring checklists or audit-only lists, or to ADR-0029, with tier moves annotated inline, and the `**/*.instructions.md` local-discovery glob behind the template-suffix Gotcha is `primitives/discovery.py` `LOCAL_PRIMITIVE_PATTERNS`; the per-target event rename maps are `integration/hook_integrator.py` `_HOOK_EVENT_MAP`; and Step 4.2's render relies on `apm install <local path>` deploying the working tree to every `--target`, verified against 0.28.0
- **Contributing files:** SKILL.md, references/hook.md, references/instruction.md, references/prompt.md - **Contributing files:** SKILL.md, references/hook.md, references/instruction.md, references/prompt.md
- **Status:** `extracted` - **Status:** `extracted`

View File

@@ -21,9 +21,8 @@ metadata:
## Gotchas ## Gotchas
- The word gates are two measurements, not two tiers of one rule: the 2,770-word / 500-line spec backstop counts the whole file, Step 3's gate the body alone. Never unify them. - The 2,770-word / 500-line spec backstop counts the whole file, frontmatter included — a separate measurement from Step 3's body-only gate. Never unify them.
- Never spawn a subagent to audit or recheck your own work — run `/factory-audit` inline, in the same context as the edits. Clean-context recheck belongs to `/forge`'s outer loop, and a self-spawned subagent's worktree can be torn down by concurrent cleanup, destroying an uncommitted draft. - Never spawn a subagent to audit or recheck your own work — run `/factory-audit` inline, in the same context as the edits. Clean-context recheck belongs to `/forge`'s outer loop, and a self-spawned subagent's worktree can be torn down by concurrent cleanup, destroying an uncommitted draft.
- Do not create new scripts unless a signal explicitly calls for it. Writing one from scratch requires out-of-scope transcript analysis — flag the opportunity as a suggestion instead.
## Step 1 — Dispatch ## Step 1 — Dispatch
@@ -58,6 +57,6 @@ Gates `/factory-audit` enforces in both flows:
Run `/factory-audit` on the resolved skill directory; resolve every FAIL before reporting done. It checks name-to-directory match, placeholders, both size budgets, boundary-target resolution and script hygiene — do not hand-check those. Hand-check the one thing it misses: an empty body reports `PASS SKILL.md body word count 0 (ADR-0020 target: 600)`, so confirm at least one non-empty section exists. Run `/factory-audit` on the resolved skill directory; resolve every FAIL before reporting done. It checks name-to-directory match, placeholders, both size budgets, boundary-target resolution and script hygiene — do not hand-check those. Hand-check the one thing it misses: an empty body reports `PASS SKILL.md body word count 0 (ADR-0020 target: 600)`, so confirm at least one non-empty section exists.
Bump `metadata.version`: the **minor** version on create (new skills start at `0.1.0`) and the **patch** version on improve. Versioning: on create, keep the scaffold's `0.1.0` — do not bump it (ADR-0022); on improve, bump the **patch** version.
**Commit verification.** Inside a git worktree: once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is silently lost if the tree is cleaned up. Report done only once the hash has changed. Outside a worktree (a skill under `~/.claude/skills/`, say) nothing is committable — report done on a clean audit, naming that as the reason. **Commit verification.** Inside a git worktree: once the audit is clean, run `rtk git add` and `rtk git commit` — do not stop at staging. Re-run `rtk git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is silently lost if the tree is cleaned up. Report done only once the hash has changed. Outside a worktree (a skill under `~/.claude/skills/`, say) nothing is committable — report done on a clean audit, naming that as the reason.

View File

@@ -16,14 +16,12 @@ description: >
Not FILL IN: near-miss case -> FILL IN: real sibling skill. Not FILL IN: near-miss case -> FILL IN: real sibling skill.
# Required. Preloaded into EVERY session whether or not the skill is invoked. # Required. Preloaded into EVERY session whether or not the skill is invoked.
# Exactly three parts, in this order: trigger clause, at most one capability # Exactly three parts, in this order: trigger clause, at most one capability
# clause, boundary clause. Drop the boundary line if no near-miss skill exists. # clause, boundary clause. Write one boundary clause per genuine near-miss; at least one.
# Trigger clause: when should an agent activate this skill? Describe the user's # Trigger clause: when should an agent activate this skill? Describe the user's
# intent, not the skill's internal mechanics. # intent, not the skill's internal mechanics.
# Budget: 250 characters target, 400 hard ceiling (counting this value only, # Budget: 250 characters target, 400 hard ceiling (counting this value only,
# with YAML folding resolved). This scaffold sits at 214 — keep the fill-in # with YAML folding resolved). Keep the fill-in under the target rather
# under the target rather than growing past it. # than growing past it.
# Boundary clauses may be plural: write one per genuine near-miss, and none
# where no sibling could steal activations.
# Never let a hyphenated skill name wrap across two lines of this folded block # Never let a hyphenated skill name wrap across two lines of this folded block
# — folding turns the break into a space and the routing target stops resolving. # — folding turns the break into a space and the routing target stops resolving.
# Banned here: capability lists, output-format detail, composition notes, # Banned here: capability lists, output-format detail, composition notes,
@@ -89,7 +87,7 @@ metadata:
after the mistake is worthless. after the mistake is worthless.
Each entry states a fact that CONTRADICTS a reasonable default: Each entry states a fact that CONTRADICTS a reasonable default:
something the agent gets wrong by acting sensibly. Maximum 5 entries. something the agent gets wrong by acting sensibly. Aim for at most five; more is a SUGGESTION.
An entry that paraphrases a step below it is a failure, not a gotcha. An entry that paraphrases a step below it is a failure, not a gotcha.
## Gotchas ## Gotchas

View File

@@ -1,6 +1,6 @@
# Sources # Sources
<!-- Populated at Step 5 of skill authoring, after all skill files are written. <!-- Populated at Step 6 of skill authoring (`references/create.md`), after all skill files are written.
For each research source with status `extracted`, record which skill files For each research source with status `extracted`, record which skill files
it contributed to under Contributing files. it contributed to under Contributing files.
Delete this file if no research sources were provided as input. --> Delete this file if no research sources were provided as input. -->

View File

@@ -126,16 +126,16 @@ blocks, rationale prose, and any content only one branch reaches. Each reference
self-contained for its concern, and every one is wired from the body with the literal conditional self-contained for its concern, and every one is wired from the body with the literal conditional
form: form:
````markdown
If <condition>, read `references/<file>.md`.
````
**The one exception, stated once so it is not re-litigated:** an output schema stays in the body **The one exception, stated once so it is not re-litigated:** an output schema stays in the body
only when it applies to *every* flow and is short — roughly 50 words or less, which is the "Output only when it applies to *every* flow and is short — roughly 50 words or less, which is the "Output
format template" pattern below. An output schema that is longer than that, or that only one flow format template" pattern below. An output schema that is longer than that, or that only one flow
produces, moves to `references/` like any other schema. No third option exists, and the two rules produces, moves to `references/` like any other schema. No third option exists, and the two rules
do not disagree. do not disagree.
````markdown
If <condition>, read `references/<file>.md`.
````
A generic pointer ("see references/ for details") is a Vale error — the agent cannot act on it. A generic pointer ("see references/ for details") is a Vale error — the agent cannot act on it.
**A dispatch table is the wiring.** Where the body dispatches, a row already pairs a condition with **A dispatch table is the wiring.** Where the body dispatches, a row already pairs a condition with

View File

@@ -100,8 +100,9 @@ plain sentence and `disable-model-invocation: true` instead.
**`metadata.version`** — required on every skill (ADR-0022), not a per-skill or per-plugin choice, **`metadata.version`** — required on every skill (ADR-0022), not a per-skill or per-plugin choice,
and enforced by the `skill-size-check` pre-commit hook. The scaffold seeds a new skill at and enforced by the `skill-size-check` pre-commit hook. The scaffold seeds a new skill at
`"0.1.0"`; leave that value alone here and let `SKILL.md` Step 4 bump it. (`"1.0.0"` is the seed `"0.1.0"`; leave that value alone — `SKILL.md` Step 4 leaves it at `"0.1.0"` too, which ADR-0022
for a pre-existing skill retrofitted into the rule, and never applies to a skill created here.) reserves for "created and never yet revised". (`"1.0.0"` is the seed for a pre-existing skill
retrofitted into the rule, and never applies to a skill created here.)
**Optional frontmatter** — uncomment and fill in, or remove entirely: **Optional frontmatter** — uncomment and fill in, or remove entirely:

View File

@@ -10,13 +10,9 @@ source_keys:
Return to `SKILL.md` Step 4 once Step 4 below is done — validation, versioning and commit Return to `SKILL.md` Step 4 once Step 4 below is done — validation, versioning and commit
verification are shared with the create flow and are not repeated here. verification are shared with the create flow and are not repeated here.
## Step 1 — Verify inputs ## Step 1 — Signal sources
Confirm the skill directory path exists and that at least one improvement signal is present in the The dispatch in `SKILL.md` Step 1 has already confirmed the directory and at least one signal.
conversation or a referenced file.
If the skill directory is missing, ask for it. If no signals are present, stop: "This skill applies
existing signals to a skill. For a blind review without signals, use `/factory-audit` instead."
Signals can come from anywhere in the conversation or referenced files: Signals can come from anywhere in the conversation or referenced files:
@@ -25,8 +21,6 @@ Signals can come from anywhere in the conversation or referenced files:
- Human feedback (feedback.json, inline in conversation, PR or issue comments) - Human feedback (feedback.json, inline in conversation, PR or issue comments)
- Session context describing what went wrong - Session context describing what went wrong
Also verify the `name` field in frontmatter matches the skill's directory name exactly.
## Step 2 — Gather and group signals ## Step 2 — Gather and group signals
Read the current skill files (SKILL.md and any files in `scripts/`, `references/`, `assets/`, Read the current skill files (SKILL.md and any files in `scripts/`, `references/`, `assets/`,
@@ -102,6 +96,9 @@ exactly one flow reaches it, otherwise in the body's common-gates section.
If a signal points to a script or reference file, edit that file directly rather than adding a If a signal points to a script or reference file, edit that file directly rather than adding a
workaround in SKILL.md. workaround in SKILL.md.
**Do not create a new script unless a signal explicitly calls for it.** Writing one from scratch
requires out-of-scope transcript analysis — flag the opportunity as a suggestion instead.
**A skill carrying no `metadata.version` is seeded at `"1.0.0"`, not bumped** — `SKILL.md` Step 4's **A skill carrying no `metadata.version` is seeded at `"1.0.0"`, not bumped** — `SKILL.md` Step 4's
patch bump presumes a version to bump, and ADR-0022 reserves `"0.1.0"` for a newly created skill. patch bump presumes a version to bump, and ADR-0022 reserves `"0.1.0"` for a newly created skill.

View File

@@ -40,7 +40,8 @@ Output:
Standalone mode: <path>/<skill-name>/ Standalone mode: <path>/<skill-name>/
Exit codes: Exit codes:
0 Scaffold created successfully, or destination already exists (no-op) 0 Scaffold created, destination already complete (no-op), or a partial
scaffold from an earlier failed run repaired
1 Invalid arguments, missing path, or templates not found 1 Invalid arguments, missing path, or templates not found
EOF EOF
} }
@@ -152,20 +153,56 @@ else
TARGET="$TARGET_INPUT/$SKILL_NAME" TARGET="$TARGET_INPUT/$SKILL_NAME"
fi fi
# Destination already exists — treat as a no-op so retries are safe # Files carrying the SKILL_NAME placeholder token.
SUBST_FILES=("SKILL.md" "tests/README.md")
# Replace SKILL_NAME in each placeholder file under dir $1. `sed -i` is not
# portable — GNU takes an optional attached suffix, BSD/macOS requires a
# separate suffix argument and reads the expression as one — so write to a
# temp file and move it over the original instead.
substitute_name() {
local dir="$1" rel f
for rel in "${SUBST_FILES[@]}"; do
f="$dir/$rel"
[[ -f "$f" ]] || continue
sed "s/SKILL_NAME/$SKILL_NAME/g" "$f" > "$f.tmp"
mv "$f.tmp" "$f"
done
}
# True if any placeholder file under dir $1 still carries the SKILL_NAME token.
has_placeholder() {
local dir="$1" rel
for rel in "${SUBST_FILES[@]}"; do
if [[ -f "$dir/$rel" ]] && grep -q 'SKILL_NAME' "$dir/$rel"; then
return 0
fi
done
return 1
}
if [[ -d "$TARGET" ]]; then if [[ -d "$TARGET" ]]; then
# A scaffold left half-built by an earlier failed run still carries the
# placeholder token; finish it instead of reporting a silent no-op.
if has_placeholder "$TARGET"; then
substitute_name "$TARGET"
echo "Repaired partial scaffold at '$TARGET' — substituted SKILL_NAME." >&2
exit 0
fi
echo "Scaffold already exists at '$TARGET' — nothing to do." >&2 echo "Scaffold already exists at '$TARGET' — nothing to do." >&2
exit 0 exit 0
fi fi
mkdir -p "$(dirname "$TARGET")" mkdir -p "$(dirname "$TARGET")"
# Copy templates to destination # Build in a sibling staging directory and rename it into place only once
cp -r "$TEMPLATES_DIR" "$TARGET" # complete, so a failure mid-build never leaves a half-built $TARGET behind.
STAGING="$TARGET.partial.$$"
# Set skill name in templates trap 'rm -rf "$STAGING"' EXIT
sed -i "s/SKILL_NAME/$SKILL_NAME/g" "$TARGET/SKILL.md" cp -r "$TEMPLATES_DIR" "$STAGING"
sed -i "s/SKILL_NAME/$SKILL_NAME/g" "$TARGET/tests/README.md" substitute_name "$STAGING"
mv "$STAGING" "$TARGET"
trap - EXIT
if [[ "$MODE" == "package" ]]; then if [[ "$MODE" == "package" ]]; then
echo "Mode: package — type-bearing apm.yml found at '$PKG_ROOT'" >&2 echo "Mode: package — type-bearing apm.yml found at '$PKG_ROOT'" >&2

View File

@@ -107,6 +107,31 @@ teardown() {
assert_output --partial "nothing to do" assert_output --partial "nothing to do"
} }
@test "no SKILL_NAME placeholder remains anywhere in a fresh scaffold" {
bash "$SCRIPT" my-tool "$DEST"
run grep -r "SKILL_NAME" "$DEST/my-tool"
assert_failure
}
@test "leaves no staging directory behind after a successful run" {
bash "$SCRIPT" my-tool "$DEST"
run bash -c "ls -d '$DEST'/my-tool.partial.* 2>/dev/null"
assert_output ""
}
@test "retry repairs a half-built scaffold that still carries SKILL_NAME" {
# Simulate an earlier run that copied the templates but died before the
# name substitution: the retry must finish the job, not no-op.
cp -r "$BATS_TEST_DIRNAME/../assets/templates" "$DEST/my-tool"
run bash "$SCRIPT" my-tool "$DEST"
assert_success
assert_output --partial "Repaired partial scaffold"
run grep -r "SKILL_NAME" "$DEST/my-tool"
assert_failure
run grep -E '^name: my-tool$' "$DEST/my-tool/SKILL.md"
assert_success
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mode detection: package vs standalone # Mode detection: package vs standalone
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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; 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. **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 and correction.
## Skills ## Skills

View File

@@ -87,7 +87,8 @@ ADR-0019.
**Claude Code only, and where it looks for the lockfile.** The hook exits 0 at once, silently and **Claude Code only, and where it looks for the lockfile.** The hook exits 0 at once, silently and
without calling `apm`, unless `CLAUDE_PROJECT_DIR` is set and non-empty — Claude Code exports it for without calling `apm`, unless `CLAUDE_PROJECT_DIR` is set and non-empty — Claude Code exports it for
SessionStart hooks, and that guard is what keeps the hook inert under Copilot and Codex (see below). SessionStart hooks, and Copilot and Codex do not document setting it, so the guard keeps the hook
inert there unless the variable is inherited from the user's environment (see below).
It then takes `${CLAUDE_PROJECT_DIR}` as the project directory and exits silently unless that It then takes `${CLAUDE_PROJECT_DIR}` as the project directory and exits silently unless that
directory holds an `apm.lock.yaml` — which is what makes it inert in any project that does not directory holds an `apm.lock.yaml` — which is what makes it inert in any project that does not
consume packages through apm. Both `apm` invocations run against the same directory. The earlier consume packages through apm. Both `apm` invocations run against the same directory. The earlier

View File

@@ -270,7 +270,7 @@ 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"
# Claude Code only (ADR-0019, amendment 2026-09-28). apm deploys this hook to # Claude Code only (ADR-0019, correction 2026-09-28). apm deploys this hook to
# Copilot and Codex too, and in a consumer the lockfile guard passes there — # Copilot and Codex too, and in a consumer the lockfile guard passes there —
# apm wrote the lock. A host that sets no CLAUDE_PROJECT_DIR must therefore # 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 # exit before any apm call, even with a lockfile in the cwd and a stale install