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

- factory-audit: hook events judged per deployed target after apm's
  rename (Claude/Copilot event sets FAIL, others SUGGESTION); Claude
  plugin layouts accepted as hook sources; interpreter options and
  sh -c strings checked; bats 378 -> 386
- primitive-author: Must 4/5 match the audit; reference hand-back
  points at the right steps; Step 4.2 --target all fallback
- skill-author: new-skill.sh repair only on the template marker line,
  so complete skills stay a no-op; provenance and calibration text
- forge: restore "already named" qualifier; drop false HITL claim
- apm-workflow: token example uses an env var
- docs/hooks.md: the apm-hooks.json sidecar is committed, not ignored

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
This commit is contained in:
2026-09-28 21:32:18 +00:00
parent 965208bddd
commit 5d0f988ed8
18 changed files with 395 additions and 140 deletions

View File

@@ -82,7 +82,7 @@ Any git repo is a valid package source by default — no registry required. To d
```bash ```bash
apm experimental enable registries # required first — see Gotchas apm experimental enable registries # required first — see Gotchas
apm config set registry.corp-main.url https://artifactory.corp.example.com/apm apm config set registry.corp-main.url https://artifactory.corp.example.com/apm
apm config set registry.corp-main.token eyJ... apm config set registry.corp-main.token "$CORP_APM_TOKEN"
apm config set registry.corp-main.default true apm config set registry.corp-main.default true
``` ```

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 `hooks/` beside `apm.yml`) | hook | `references/hook-flow.md` | | A `.json` file whose immediate parent directory is `hooks/` (`.apm/hooks`, or a package-root `hooks/`; any other `hooks/` is deployed output the hook flow FAILs) | 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.
@@ -71,7 +71,7 @@ On the agent flow at plugin/APM scope, drop `pair-consistency` — there is no p
Hook, instruction and prompt flows: the line their flow file ends with. Hook, instruction and prompt flows: the line their flow file ends with.
Then output only the dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each. Omit clean dimensions — their absence is what confirms they passed. Then output only the dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each. Omit clean dimensions.
Each finding: Each finding:

View File

@@ -2,6 +2,8 @@
source_keys: source_keys:
- apm-cli-installed-source - apm-cli-installed-source
- apm-docs-llms-full - apm-docs-llms-full
- claude-code-hooks-reference
- github-copilot-hooks-configuration
--- ---
# Hook Flow # Hook Flow
@@ -11,7 +13,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 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. - apm checks almost nothing here. Invalid JSON is skipped without a word, an event name its rename map does not cover deploys verbatim with no warning (a lowercase `stop` or a typo such as `PreToolUSe` never fires on Claude or Copilot), 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,13 +25,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, 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`. 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 symlinked file or one 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, space-containing, or `$`/backtick-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 operand after `bash`, `sh`, `zsh`, `python`, `python3`, `node`, `pwsh`, `ruby` or `perl`, past its options such as `-e` or `-u`; after a `sh`-family `-c`, the first token of the command string; inline code such as `python3 -c` has none), 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. Each event is judged per target the package root's `apm.yml` deploys to — no `target:`/`targets:`, `all`, or no `apm.yml` means every hook target — after apm's rename map for that target: it FAILs when a target with a published event list (Claude, Copilot) does not fire the renamed name, whatever its casing. 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.
Three tiers deliberately differ from `primitive-author`'s checklist or the research's. Do not re-tier them by judgment: Five 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/` — 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. - A hook file directly under a package-root `hooks/` — beside the package's `apm.yml`, or beside a `plugin.json` at any location apm's `find_plugin_json` reads (root, `.github/plugin/`, `.claude-plugin/`, `.cursor-plugin/`) — passes. apm discovers both `.apm/hooks/` and `hooks/`, installs a Claude plugin with no `apm.yml`, 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.
- An event that every listed target fires, but that reaches a target with no published event list (Cursor, Kiro, Gemini, Codex, Antigravity, Windsurf) in a non-PascalCase form after apm's rename, is a SUGGESTION, not the FAIL Must 4 implies: the script cannot tell a harness's native spelling (Cursor's `stop`, Windsurf's `pre_run_command`) from a typo. A Cursor-only `stop` therefore exits 0.
- Copilot counts every name apm's own Copilot map emits as fired (`userPromptSubmit`, although Copilot documents `userPromptSubmitted`): the author cannot route around apm's rename, so that is not a finding in the file.
- 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.
@@ -44,13 +48,13 @@ Cite file and line for every finding.
**purpose** — apm's own rule is to reach for a skill, instruction or prompt first; a hook is for "this must always happen at this event". **purpose** — apm's own rule is to reach for a skill, instruction or prompt first; a hook is for "this must always happen at this event".
- FAIL: the script carries procedure the agent should follow — instructions printed to the model, a multi-step workflow — rather than a runtime callback. That is a skill. - FAIL: the script carries procedure the agent should follow — instructions printed to the model, a multi-step workflow — rather than a runtime callback. That is a skill.
- SUGGESTION: the behaviour is harness-specific (a Claude-only event, a Claude-only matcher value) in a package whose `targets:` includes other harnesses, and nothing records that the other targets receiving it was accepted. The apm-native fix is a separate package with its own `targets:`, not a routing filename. - SUGGESTION: the behaviour is harness-specific (a Claude-only event reaching a harness the script has no event list for, a Claude-only matcher value) in a package whose `targets:` includes other harnesses, and nothing records that the other targets receiving it was accepted. The apm-native fix is a separate package with its own `targets:`, not a routing filename.
**handlers** — the research checklist's Should and audit-only items, which apm never checks: **handlers** — the research checklist's Should and audit-only items, which apm never checks:
- SUGGESTION: a handler without `"type": "command"` or an explicit numeric `timeout` in seconds. - SUGGESTION: a handler without `"type": "command"` or an explicit numeric `timeout` in seconds.
- SUGGESTION: a tool event (`PreToolUse`, `PostToolUse`) or `SessionStart` with no `matcher` — Claude receives `"*"`. A `matcher` on an event Claude ignores it for (`Stop`, `UserPromptSubmit`) is inert, not wrong. - SUGGESTION: a tool event (`PreToolUse`, `PostToolUse`) or `SessionStart` with no `matcher` — Claude receives `"*"`. A `matcher` on an event Claude ignores it for (`Stop`, `UserPromptSubmit`) is inert, not wrong.
- SUGGESTION: a PascalCase event name that is not a real Claude Code event (a misspelling deploys verbatim and never fires; the script cannot tell a typo from an event it does not know). - SUGGESTION: a PascalCase event, in a package reaching only harnesses with no published event list, that is not one of that harness's events — the script FAILs a misspelling only where it has the list (Claude, Copilot).
- SUGGESTION: `bash`/`powershell`/`timeoutSec` keys in a Claude-shaped file — they render, but leave stray keys in `settings.json`. - SUGGESTION: `bash`/`powershell`/`timeoutSec` keys in a Claude-shaped file — they render, but leave stray keys in `settings.json`.
- SUGGESTION: a helper `.json` file in the hook directory without a `hooks` key — Copilot's loader scans the bundled scripts directory and rejects it. Keep helper configuration non-JSON. - SUGGESTION: a helper `.json` file in the hook directory without a `hooks` key — Copilot's loader scans the bundled scripts directory and rejects it. Keep helper configuration non-JSON.

View File

@@ -13,6 +13,8 @@ source_keys:
- apm-cli-installed-source - apm-cli-installed-source
- apm-docs-llms-full - apm-docs-llms-full
- adr-0029-prompt-house-rule - adr-0029-prompt-house-rule
- claude-code-hooks-reference
- github-copilot-hooks-configuration
--- ---
# Sources # Sources
@@ -179,3 +181,21 @@ source_keys:
- **Description:** The repo's prompt house rule — a prompt is a single-intent, user-triggered steering message with no procedure — and its description contract: one user-facing sentence naming the steered skills, no trigger or boundary clause - **Description:** The repo's prompt house rule — a prompt is a single-intent, user-triggered steering message with no procedure — and its description contract: one user-facing sentence naming the steered skills, no trigger or boundary clause
- **Contributing files:** references/prompt-flow.md - **Contributing files:** references/prompt-flow.md
- **Status:** `extracted` - **Status:** `extracted`
## claude-code-hooks-reference
- **URL:** https://code.claude.com/docs/en/hooks
- **Research doc:** none
- **Basis:** plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-primitive.sh (KNOWN_EVENTS, transcribed from the URL on 2026-09-28; the vendored corpus lists Claude's hook events only partially)
- **Description:** Claude Code's hook reference — the full list of hook event names Claude fires, which `scripts/lib-checks-primitive.sh` carries as `KNOWN_EVENTS['claude']` to FAIL an event Claude never fires after apm's rename
- **Contributing files:** references/hook-flow.md
- **Status:** `extracted`
## github-copilot-hooks-configuration
- **URL:** https://docs.github.com/en/copilot/reference/hooks-configuration
- **Research doc:** none
- **Basis:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/configuration.md (the camelCase list; the PascalCase alternative is from the URL, 2026-09-28)
- **Description:** GitHub Copilot's hook configuration reference — the camelCase event names Copilot fires and its PascalCase "VS Code compatible" alternative, carried as `KNOWN_EVENTS['copilot']`
- **Contributing files:** references/hook-flow.md
- **Status:** `extracted`

View File

@@ -172,9 +172,76 @@ ROUTING_TOKENS = ('copilot', 'vscode', 'cursor', 'claude', 'codex', 'gemini',
_TOK = '|'.join(ROUTING_TOKENS) _TOK = '|'.join(ROUTING_TOKENS)
ROUTING_STEM_RE = re.compile(rf'^hooks-(?:{_TOK})$|(?:^|-)(?:{_TOK})-hooks$') ROUTING_STEM_RE = re.compile(rf'^hooks-(?:{_TOK})$|(?:^|-)(?:{_TOK})-hooks$')
# Claude's rename map, 0.28.0: the only camelCase names that reach Claude as a # apm 0.28.0 _HOOK_EVENT_MAP (apm_cli/integration/hook_integrator.py): the
# native event. Any other camelCase name is deployed verbatim and never fires. # rename each target applies before deploying. A name absent from a target's
CLAUDE_MAPPED_CAMEL = {'preToolUse', 'postToolUse', 'sessionStart', 'agentStop'} # map deploys to it verbatim, with no warning for an all-lowercase name.
_STOP_ALIASES = ('Stop', 'AgentStop', 'agentStop')
HOOK_EVENT_MAP = {
'copilot': {
'PreToolUse': 'preToolUse', 'preToolUse': 'preToolUse',
'PostToolUse': 'postToolUse', 'postToolUse': 'postToolUse',
'UserPromptSubmit': 'userPromptSubmit', 'userPromptSubmit': 'userPromptSubmit',
'SessionStart': 'sessionStart', 'sessionStart': 'sessionStart',
**dict.fromkeys(_STOP_ALIASES, 'agentStop'),
'PreTaskExecution': 'preTaskExecution', 'preTaskExecution': 'preTaskExecution',
'PostTaskExecution': 'postTaskExecution', 'postTaskExecution': 'postTaskExecution',
},
'claude': {
'preToolUse': 'PreToolUse', 'postToolUse': 'PostToolUse',
'SessionStart': 'SessionStart', 'sessionStart': 'SessionStart',
**dict.fromkeys(_STOP_ALIASES, 'Stop'),
},
'gemini': {
'PreToolUse': 'BeforeTool', 'preToolUse': 'BeforeTool',
'PostToolUse': 'AfterTool', 'postToolUse': 'AfterTool',
'Stop': 'SessionEnd',
},
'kiro': {
'PreToolUse': 'PreToolUse', 'preToolUse': 'PreToolUse',
'PostToolUse': 'PostToolUse', 'postToolUse': 'PostToolUse',
'UserPromptSubmit': 'UserPromptSubmit', 'userPromptSubmit': 'UserPromptSubmit',
'promptSubmit': 'UserPromptSubmit',
'Stop': 'Stop', 'stop': 'Stop', 'AgentStop': 'Stop', 'agentStop': 'Stop',
'SessionStart': 'SessionStart', 'sessionStart': 'SessionStart',
'PreTaskExecution': 'PreTaskExec', 'preTaskExecution': 'PreTaskExec',
'PreTaskExec': 'PreTaskExec',
'PostTaskExecution': 'PostTaskExec', 'postTaskExecution': 'PostTaskExec',
'PostTaskExec': 'PostTaskExec',
'PostFileCreate': 'PostFileCreate', 'PostFileSave': 'PostFileSave',
'PostFileDelete': 'PostFileDelete',
},
}
# apm's target aliases (core/target_catalog.py): vscode and agents are copilot.
TARGET_ALIASES = {'vscode': 'copilot', 'agents': 'copilot'}
# The targets apm 0.28.0 deploys hooks to (KNOWN_TARGETS with a hooks primitive).
HOOK_TARGETS = {'copilot', 'claude', 'cursor', 'kiro', 'gemini', 'antigravity',
'codex', 'windsurf'}
# The events each harness fires, for the harnesses with a published list.
# Claude: code.claude.com/docs/en/hooks. Copilot: docs.github.com hooks
# configuration reference, which also accepts each event in PascalCase (its
# "VS Code compatible" format), plus the camelCase names apm's own Copilot map
# emits — a rename the author cannot route around is not a finding here.
# No list is published in apm's source or this repo's research for cursor,
# kiro, gemini, antigravity, codex or windsurf, so those are judged by
# convention only (hook-flow.md). Checked 2026-09.
_COPILOT_CAMEL = {'sessionStart', 'sessionEnd', 'userPromptSubmitted', 'preToolUse',
'postToolUse', 'postToolUseFailure', 'preCompact', 'agentStop',
'subagentStart', 'subagentStop', 'errorOccurred',
'permissionRequest', 'notification'}
KNOWN_EVENTS = {
'claude': {'SessionStart', 'Setup', 'UserPromptSubmit', 'UserPromptExpansion',
'PreToolUse', 'PermissionRequest', 'PermissionDenied', 'PostToolUse',
'PostToolUseFailure', 'PostToolBatch', 'Notification', 'MessageDisplay',
'SubagentStart', 'SubagentStop', 'TaskCreated', 'TaskCompleted', 'Stop',
'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange',
'CwdChanged', 'DirectoryAdded', 'FileChanged', 'WorktreeCreate',
'WorktreeRemove', 'PreCompact', 'PostCompact', 'PreModelSwitch',
'PostModelSwitch', 'Elicitation', 'ElicitationResult', 'SessionEnd'},
'copilot': (_COPILOT_CAMEL | {e[0].upper() + e[1:] for e in _COPILOT_CAMEL}
| {'Stop', 'UserPromptSubmit'} | set(HOOK_EVENT_MAP['copilot'].values())),
}
HOOK_COMMAND_KEYS = ('command', 'bash', 'powershell', 'windows', 'linux', 'osx') HOOK_COMMAND_KEYS = ('command', 'bash', 'powershell', 'windows', 'linux', 'osx')
ROOT_TOKENS = ('PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT', 'KIRO_PLUGIN_ROOT') ROOT_TOKENS = ('PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT', 'KIRO_PLUGIN_ROOT')
@@ -187,9 +254,22 @@ 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"\']+)')
# An interpreter whose first argument is the script it runs. A reference in # An interpreter whose first operand is the script it runs. A reference in
# that argument slot is in command position just as a first token is. # that operand slot is in command position just as a first token is.
INTERPRETERS = {'bash', 'sh', 'zsh', 'python', 'python3', 'node', 'pwsh', 'ruby', 'perl'} INTERPRETERS = {'bash', 'sh', 'zsh', 'python', 'python3', 'node', 'pwsh', 'ruby', 'perl'}
SH_FAMILY = {'bash', 'sh', 'zsh'}
# Options that consume the next token as their value, per interpreter.
VALUE_OPTS = {
'bash': {'-o', '+o', '-O', '+O'}, 'sh': {'-o', '+o'}, 'zsh': {'-o', '+o'},
'python': {'-W', '-X'}, 'python3': {'-W', '-X'},
'node': {'-r', '--require', '--import'}, 'ruby': {'-I', '-r'}, 'perl': {'-I', '-M'},
}
# Options after which the rest is inline code or a module, never a script path.
CODE_OPTS = {
'python': {'-c', '-m'}, 'python3': {'-c', '-m'},
'node': {'-e', '-p', '--eval', '--print'}, 'ruby': {'-e'}, 'perl': {'-e', '-E'},
'pwsh': {'-c', '-command', '-encodedcommand'},
}
def _prefix_tokens(prefix): def _prefix_tokens(prefix):
@@ -197,14 +277,34 @@ def _prefix_tokens(prefix):
def _interp_arg_index(tokens): def _interp_arg_index(tokens):
"""Index of the token that is the first argument after a known interpreter """(index, is_command_string) of the script operand after a known
(optionally behind `env`), or None when the command does not open with one.""" interpreter (optionally behind `env`), or None when the command does not
open with one. Option flags are skipped (`bash -e x.sh`, `python3 -u x.py`);
for a sh-family `-c` the operand is the command string, whose own first
token is the script (`sh -c 'scripts/x.sh'`). Inline code (`python3 -c`,
`node -e`) has no script operand."""
i = 0 i = 0
if tokens and os.path.basename(tokens[0]) == 'env': if tokens and os.path.basename(tokens[0]) == 'env':
i = 1 i = 1
if len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS: if not (len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS):
return i + 1
return None return None
interp = os.path.basename(tokens[i])
j = i + 1
while j < len(tokens):
tok = tokens[j]
low = tok.lower()
if tok == '--':
return j + 1, False
if not tok.startswith(('-', '+')) or tok in ('-', '+'):
return j, False
if interp in SH_FAMILY and not tok.startswith('--') and 'c' in tok[1:]:
return j + 1, True
if interp == 'pwsh' and low in ('-file', '-f'):
return j + 1, False
if low in CODE_OPTS.get(interp, ()):
return None
j += 2 if tok in VALUE_OPTS.get(interp, ()) else 1
return j, False
def _position(prefix): def _position(prefix):
@@ -212,7 +312,8 @@ def _position(prefix):
toks = [t for t in _prefix_tokens(prefix) if t] toks = [t for t in _prefix_tokens(prefix) if t]
if not toks: if not toks:
return True, False return True, False
return False, _interp_arg_index(toks) == len(toks) slot = _interp_arg_index(toks)
return False, slot is not None and slot[0] == len(toks)
def is_handler(h): def is_handler(h):
@@ -290,15 +391,19 @@ def check_unanchored_script(cmd, pkg_root, where):
# 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. Checked in command position only: the first token, # consumer's machine. Checked in command position only: the first token,
# and the first argument after a known interpreter (`bash scripts/x.sh`). # and the first operand after a known interpreter, past its options
# A later argument is data, not a script apm is asked to run. # (`bash -e scripts/x.sh`); a sh-family `-c` string is checked as a command
# of its own. A later argument is data, not a script apm is asked to run.
toks = command_tokens(cmd) toks = command_tokens(cmd)
if not toks: if not toks:
return return
slots = [0] slots = [0]
arg = _interp_arg_index(toks) arg = _interp_arg_index(toks)
if arg is not None and arg < len(toks): if arg is not None and arg[0] < len(toks):
slots.append(arg) if arg[1]:
check_unanchored_script(toks[arg[0]], pkg_root, where)
else:
slots.append(arg[0])
for idx in slots: for idx in slots:
tok = toks[idx] tok = toks[idx]
if not tok or tok.startswith(('./', '../', '~', '-')) or '$' in tok: if not tok or tok.startswith(('./', '../', '~', '-')) or '$' in tok:
@@ -367,27 +472,26 @@ def check_script(kind_, rel, first, interp_arg, pkg_root, where):
fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}") fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}")
def owning_apm_yml(): # apm's package manifests (apm.yml, and utils/helpers.py find_plugin_json): a
"""The nearest apm.yml walking up from the hook file, or None.""" # directory holding any of these is a package root, and its hooks/*.json is
d = parent_dir # hook source. A Claude plugin needs no apm.yml.
while True: PACKAGE_MANIFESTS = ('apm.yml', 'plugin.json', os.path.join('.github', 'plugin', 'plugin.json'),
cand = os.path.join(d, 'apm.yml') os.path.join('.claude-plugin', 'plugin.json'),
if os.path.isfile(cand): os.path.join('.cursor-plugin', 'plugin.json'))
return cand
up = os.path.dirname(d)
if up == d:
return None
d = up
def package_targets(): def is_package_root(d):
"""The set of targets apm renders this package's hooks to. Mirrors return any(os.path.isfile(os.path.join(d, m)) for m in PACKAGE_MANIFESTS)
parse_targets_field: no target:/targets: (or no apm.yml) means every
def package_targets(pkg_root):
"""The hook targets apm renders this package to, aliases folded. No
target:/targets: (or no apm.yml, as in a plain Claude plugin) 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 checks on.""" as every target, the reading that keeps the stricter checks on."""
every = set(ROUTING_TOKENS) every = set(HOOK_TARGETS)
path = owning_apm_yml() path = os.path.join(pkg_root, 'apm.yml')
if path is None: if not os.path.isfile(path):
return every return every
try: try:
with open(path, encoding='utf-8') as f: with open(path, encoding='utf-8') as f:
@@ -403,16 +507,32 @@ def package_targets():
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 = {TARGET_ALIASES.get(t, t) for t in tokens if t}
if not tokens or 'all' in tokens: if not tokens or 'all' in tokens:
return every return every
return tokens return tokens & HOOK_TARGETS
# apm 0.28.0 _HOOK_EVENT_MAP: the only all-lowercase source name any target def check_event(event, deploys_to):
# renames is Kiro's `stop` -> `Stop`. Every other target deploys an """hook.md Must 4: the event fires on every target the package deploys
# all-lowercase name verbatim, where it never fires. to, after apm's rename for that target. A target with a published event
LOWERCASE_EVENT_TARGETS = {'stop': {'kiro'}} list (KNOWN_EVENTS) that does not fire the rendered name is a FAIL. A
target without one is judged by apm's own expectation (PascalCase), and at
most a SUGGESTION: a harness's native spelling (Cursor's `stop`, Windsurf's
snake_case) may be exactly right there."""
broken, unverified = [], []
for t in sorted(deploys_to):
name = HOOK_EVENT_MAP.get(t, {}).get(event, event)
if t in KNOWN_EVENTS:
if name not in KNOWN_EVENTS[t]:
broken.append(f"{t} (as '{name}')" if name != event else t)
elif not name[:1].isupper():
unverified.append(t)
if broken:
fail(f"event '{event}' never fires on {', '.join(broken)} — after apm's rename it is not an event that harness fires, and apm never warns; write the harness's PascalCase name (PreToolUse, UserPromptSubmit, Stop, …), or narrow targets: in apm.yml to the harnesses that fire it — {fname}")
elif unverified:
suggest(f"event '{event}' reaches {', '.join(unverified)} verbatim and is not PascalCase — this audit has no published event list for that harness; confirm it is the harness's own spelling — {fname}")
# The directories apm deploys hooks into for each harness. A hook file under # The directories apm deploys hooks into for each harness. A hook file under
# one of them is install output, not package source. # one of them is install output, not package source.
@@ -440,12 +560,12 @@ def audit_hook():
pkg_root = os.path.dirname(above) 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')): elif os.path.basename(above) not in DEPLOY_ROOTS and is_package_root(above):
pkg_root = above pkg_root = above
else: else:
kind_of = (f"apm's deployed output ({os.path.basename(above)}/hooks/)" kind_of = (f"apm's deployed output ({os.path.basename(above)}/hooks/)"
if os.path.basename(above) in DEPLOY_ROOTS else 'no package source') 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}") fail(f"is {kind_of} — apm reads hook source only from <package>/.apm/hooks/*.json or a package-root hooks/*.json beside apm.yml or a plugin.json manifest; audit the source file in the package's .apm/hooks/ instead — {fname}")
return return
# apm lowercases the stem before routing (hook_file_routing.py). # apm lowercases the stem before routing (hook_file_routing.py).
@@ -481,9 +601,6 @@ def audit_hook():
fail(f"contributes no hook entries — apm warns and deploys nothing — {fname}") fail(f"contributes no hook entries — apm warns and deploys nothing — {fname}")
return return
# A file is Claude-shaped when its entries nest handlers under "hooks" or
# its handlers use "command"; the flat bash/powershell form is Copilot's.
claude_shaped = False
shape_ok = True shape_ok = True
for event, entries in events.items(): for event, entries in events.items():
if not isinstance(entries, list): if not isinstance(entries, list):
@@ -496,13 +613,10 @@ def audit_hook():
shape_ok = False shape_ok = False
continue continue
if 'hooks' in entry: if 'hooks' in entry:
claude_shaped = True
nested = entry['hooks'] nested = entry['hooks']
if not isinstance(nested, list) or not all(isinstance(h, dict) for h in nested): if not isinstance(nested, list) or not all(isinstance(h, dict) for h in nested):
fail(f"event '{event}' entry {i}: nested 'hooks' is not a list of objects — the Copilot install fails on this payload — {fname}") fail(f"event '{event}' entry {i}: nested 'hooks' is not a list of objects — the Copilot install fails on this payload — {fname}")
shape_ok = False shape_ok = False
elif 'command' in entry:
claude_shaped = True
if shape_ok: if shape_ok:
# hook.md Must 3: the file contributes at least one entry. An empty # hook.md Must 3: the file contributes at least one entry. An empty
@@ -517,24 +631,12 @@ def audit_hook():
if total == 0: if total == 0:
fail(f"contributes no hook entries — every event list is empty, so apm deploys nothing — {fname}") fail(f"contributes no hook entries — every event list is empty, so apm deploys nothing — {fname}")
# camelCase outside Claude's map never fires on Claude. A flat deploys_to = package_targets(pkg_root)
# Copilot-shaped file still renders to Claude whenever the package targets
# it, so the exemption holds only for a package that does not.
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): else:
mapping = LOWERCASE_EVENT_TARGETS.get(event, set()) check_event(event, deploys_to)
unmapped = sorted(deploys_to - mapping)
if not mapping & deploys_to:
fail(f"event '{event}' is all-lowercase — no target this package deploys to maps it, and apm never warns, so it silently never fires; write it in PascalCase — {fname}")
elif unmapped:
suggest(f"event '{event}' is all-lowercase — only Kiro renames it; {', '.join(unmapped)} receive it verbatim and it never fires there; write it in PascalCase — {fname}")
elif camel_checked and event[0].islower() and event not in CLAUDE_MAPPED_CAMEL:
why = 'in a Claude-shaped file' if claude_shaped else "and the package's apm.yml targets Claude (no targets: means every target)"
fail(f"event '{event}' is camelCase {why}, and Claude's map does not rename it — it deploys verbatim to Claude and never fires; write it in PascalCase — {fname}")
if not shape_ok: if not shape_ok:
return return

View File

@@ -131,8 +131,9 @@ 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 hooks/ beside apm.yml; any directory (.apm/hooks, or a hooks/ at a package root: beside
other hooks/ directory is apm's deployed output and FAILs). apm.yml or a plugin.json manifest; 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,36 +95,72 @@ teardown() {
assert_output --partial "naked settings-slice shape" assert_output --partial "naked settings-slice shape"
} }
@test "hook: an all-lowercase event no target maps is a FAIL" { @test "hook: an all-lowercase event no target fires is a FAIL naming the targets" {
write_hook hooks.json '{"hooks":{"pretooluse":[{"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 'pretooluse' is all-lowercase — no target this package deploys to maps it" assert_output --partial "event 'pretooluse' never fires on claude, copilot"
} }
@test "hook: lowercase stop is a SUGGESTION for every target, clean for Kiro only, a FAIL without Kiro" { @test "hook: lowercase stop with no targets: is a FAIL — Claude and Copilot never fire it" {
write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}' write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success assert_failure 1
assert_output --partial "SUGGESTION event 'stop' is all-lowercase — only Kiro renames it" assert_output --partial "event 'stop' never fires on claude, copilot"
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" printf 'name: test-package\nversion: 0.1.0\ntargets: [claude, copilot]\n' > "$PKG/apm.yml"
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 — no target this package deploys to maps it" assert_output --partial "event 'stop' never fires on claude, copilot"
} }
@test "hook: camelCase userPromptSubmit in a Claude-shaped file is a FAIL; mapped sessionStart is not" { @test "hook: lowercase stop is clean for Kiro only (Kiro renames it to Stop)" {
printf 'name: test-package\nversion: 0.1.0\ntargets: [kiro]\n' > "$PKG/apm.yml"
write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "event 'stop'"
}
@test "hook: lowercase stop in a Cursor-only package passes, at most a SUGGESTION (no published Cursor event list)" {
printf 'name: test-package\nversion: 0.1.0\ntargets: [cursor]\n' > "$PKG/apm.yml"
write_hook hooks.json '{"hooks":{"stop":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
assert_output --partial "SUGGESTION event 'stop' reaches cursor verbatim"
}
@test "hook: a Windsurf-only snake_case event is a SUGGESTION, not a FAIL" {
printf 'name: test-package\nversion: 0.1.0\ntargets: [windsurf]\n' > "$PKG/apm.yml"
write_hook hooks.json '{"hooks":{"pre_run_command":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
assert_output --partial "SUGGESTION event 'pre_run_command' reaches windsurf verbatim"
}
@test "hook: a misspelled PascalCase event targeting Claude is a FAIL" {
printf 'name: test-package\nversion: 0.1.0\ntargets: [claude]\n' > "$PKG/apm.yml"
write_hook hooks.json '{"hooks":{"PreToolUSe":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "event 'PreToolUSe' never fires on claude"
}
@test "hook: a real Claude event apm does not rename for Copilot FAILs on Copilot only when Copilot does not fire it" {
write_hook hooks.json '{"hooks":{"SubagentStop":[{"hooks":[{"type":"command","command":"true"}]}],"PostToolBatch":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
refute_output --partial "event 'SubagentStop'"
assert_output --partial "event 'PostToolBatch' never fires on copilot"
}
@test "hook: camelCase userPromptSubmit is a FAIL on Claude; mapped sessionStart is not" {
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"hooks":[{"type":"command","command":"true"}]}],"sessionStart":[{"hooks":[{"type":"command","command":"true"}]}]}}' write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"hooks":[{"type":"command","command":"true"}]}],"sessionStart":[{"hooks":[{"type":"command","command":"true"}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1 assert_failure 1
assert_output --partial "event 'userPromptSubmit' is camelCase" assert_output --partial "event 'userPromptSubmit' never fires on claude"
refute_output --partial "event 'sessionStart'" refute_output --partial "FAIL event 'sessionStart'"
} }
@test "hook: a flat Copilot-shaped file may use camelCase events when the package does not target Claude" { @test "hook: a flat Copilot-shaped file may use camelCase events when the package does not target Claude" {
@@ -132,7 +168,7 @@ teardown() {
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}' write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success assert_success
refute_output --partial "camelCase" refute_output --partial "never fires"
} }
@test "hook: camelCase in a flat file is a FAIL when the package targets Claude" { @test "hook: camelCase in a flat file is a FAIL when the package targets Claude" {
@@ -140,17 +176,17 @@ teardown() {
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}' write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1 assert_failure 1
assert_output --partial "event 'userPromptSubmit' is camelCase and the package's apm.yml targets Claude" assert_output --partial "event 'userPromptSubmit' never fires on claude —"
} }
@test "hook: camelCase in a flat file is a FAIL when apm.yml declares no targets (every target)" { @test "hook: camelCase in a flat file is a FAIL when apm.yml declares no targets (every target)" {
write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}' write_hook hooks.json '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1 assert_failure 1
assert_output --partial "event 'userPromptSubmit' is camelCase" assert_output --partial "event 'userPromptSubmit' never fires on claude"
} }
@test "hook: targets are read from the nearest apm.yml walking up, and target: all counts as Claude" { @test "hook: targets are read from the package root's apm.yml, and target: all counts as Claude" {
mkdir -p "$PKG/sub/hooks" mkdir -p "$PKG/sub/hooks"
printf 'name: sub\nversion: 0.1.0\ntarget: copilot\n' > "$PKG/sub/apm.yml" printf 'name: sub\nversion: 0.1.0\ntarget: copilot\n' > "$PKG/sub/apm.yml"
printf '%s\n' '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}' > "$PKG/sub/hooks/hooks.json" printf '%s\n' '{"hooks":{"userPromptSubmit":[{"type":"command","bash":"true","timeoutSec":5}]}}' > "$PKG/sub/hooks/hooks.json"
@@ -160,7 +196,7 @@ teardown() {
printf 'name: sub\nversion: 0.1.0\ntarget: all\n' > "$PKG/sub/apm.yml" printf 'name: sub\nversion: 0.1.0\ntarget: all\n' > "$PKG/sub/apm.yml"
run bash "$SCRIPT" "$PKG/sub/hooks/hooks.json" run bash "$SCRIPT" "$PKG/sub/hooks/hooks.json"
assert_failure 1 assert_failure 1
assert_output --partial "is camelCase" assert_output --partial "never fires on claude"
} }
@test "hook: a referenced script that does not exist is a FAIL" { @test "hook: a referenced script that does not exist is a FAIL" {
@@ -443,6 +479,62 @@ teardown() {
assert_output --partial "is no package source" assert_output --partial "is no package source"
} }
@test "hook: a Claude-plugin layout (hooks/ beside .claude-plugin/plugin.json, no apm.yml) is package source" {
mkdir -p "$TMPDIR/cplug/.claude-plugin" "$TMPDIR/cplug/hooks" "$TMPDIR/cplug/scripts"
printf '{"name":"cplug"}\n' > "$TMPDIR/cplug/.claude-plugin/plugin.json"
printf '#!/usr/bin/env bash\nexit 0\n' > "$TMPDIR/cplug/scripts/ok.sh"
chmod +x "$TMPDIR/cplug/scripts/ok.sh"
printf '%s\n' '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"${PLUGIN_ROOT}/scripts/ok.sh","timeout":5}]}]}}' > "$TMPDIR/cplug/hooks/hooks.json"
run bash "$SCRIPT" "$TMPDIR/cplug/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
rm -rf "$TMPDIR/cplug/.claude-plugin"
printf '{"name":"cplug"}\n' > "$TMPDIR/cplug/plugin.json"
run bash "$SCRIPT" "$TMPDIR/cplug/hooks/hooks.json"
assert_success
refute_output --partial "no package source"
}
@test "hook: interpreter options are skipped to reach the script (bash -e, python3 -u)" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -e 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":"python3 -u /opt/hook.py","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script '/opt/hook.py' is an absolute path"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -e ./gone.sh","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_failure 1
assert_output --partial "script 'gone.sh' does not exist"
}
@test "hook: sh -c checks the first token of its command string; inline code (python3 -c) is not a script" {
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"sh -c 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":"sh -c \"/opt/x.sh --flag\"","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":"python3 -c \"import sys; sys.exit(0)\"","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
write_hook hooks.json '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -e \"${PLUGIN_ROOT}/.apm/hooks/scripts/check.sh\"","timeout":5}]}]}}'
run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"
assert_success
refute_output --partial "FAIL"
}
@test "hook: an unedited primitive-author hook template is an unfilled-placeholder FAIL" { @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" 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" run bash "$SCRIPT" "$PKG/.apm/hooks/hooks.json"

View File

@@ -1,11 +1,10 @@
--- ---
name: forge name: forge
description: > description: >
Use when the user wants to build or improve something but has not named its Use when the user wants something built or improved but has not yet named
type ("skill or plugin?"). Not a skill -> skill-author. its type ("skill or plugin?"). If named, use instead: skill -> `skill-author`,
Not an agent -> agent-author. agent -> `agent-author`, hook/instruction/prompt -> `primitive-author`,
Not a hook, instruction or prompt -> primitive-author. plugin -> `apm-workflow`.
Not a plugin -> apm-workflow.
metadata: metadata:
version: "1.0.2" version: "1.0.2"
category: factory category: factory

View File

@@ -15,9 +15,9 @@ removed per ADR-0015 once issue #90 landed, and `apm-workflow` is their sole suc
## Always inline, never forked ## Always inline, never forked
Run these routes inline, in the current conversation. Their flows are short, prompt-heavy or Run these routes inline, in the current conversation. Their flows are short, prompt-heavy or
gated — `apm-workflow`'s publish and release steps take a HITL gate, and removing a marketplace gated — `apm publish` is not trivially reversible and removing a marketplace entry takes a
entry takes a conversational confirmation — and a backgrounded fork cannot surface those conversational confirmation — and a backgrounded fork cannot surface those checkpoints to the
checkpoints to the user in real time. user in real time.
## No clean-context recheck, and no automatic audit ## No clean-context recheck, and no automatic audit

View File

@@ -46,6 +46,6 @@ Signals: grill output, `/factory-audit` findings, inline feedback, session conte
## 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 or description. 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. 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. 2. Render it: in a fresh `mktemp -d` directory, run `rtk apm install <absolute path to the owning package> --target <its targets:, or all when it declares none>`, 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`.
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. 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 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. 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

@@ -6,8 +6,8 @@ source_keys:
# Authoring an apm hook # Authoring an apm hook
Reached from `SKILL.md` Step 1 for a hook. Run the Gate, then write against the shape and the Reached from `SKILL.md` Step 1 for a hook. `SKILL.md` Step 2 runs the Gate below; Step 3 writes
checklist, then return to `SKILL.md` Step 3. against the shape and the checklist.
## Gate ## Gate
@@ -68,15 +68,18 @@ Must:
else fails the Copilot install outright. The file contributes at least one entry, and every else fails the Copilot install outright. The file contributes at least one entry, and every
entry carries at least one handler: an empty list or a handler-less entry deploys nothing, with entry carries at least one handler: an empty list or a handler-less entry deploys nothing, with
only a warning. only a warning.
4. Event names are PascalCase (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, 4. Every event is one each target the package deploys to fires, after apm's rename for that target
`Stop`, …). An all-lowercase name never warns: only Kiro's rename map covers one (`stop` → (`_HOOK_EVENT_MAP`; no `targets:` means every target). Write Claude's PascalCase names
`Stop`), and every other target deploys it verbatim, where it never fires. A camelCase name (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`, …), which apm renames
outside apm's rename map (`userPromptSubmit`) likewise deploys verbatim to Claude and never for each target its map covers. A name the map does not cover deploys verbatim with no warning,
fires. so a lowercase `stop`, a camelCase `userPromptSubmit` or a typo such as `PreToolUSe` never fires
on Claude or Copilot. A harness's own spelling (Cursor's `stop`, Windsurf's `pre_run_command`)
belongs only in a package whose `targets:` reach no harness that would break it.
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. The script is the package root, or `./…` for the hook directory, and exists inside the package. The script is
the command's first token or the first argument after an interpreter (`bash`, `sh`, `zsh`, the command's first token or the first argument after an interpreter (`bash`, `sh`, `zsh`,
`python`, `python3`, `node`, `pwsh`, `ruby`, `perl`); in either position, no absolute path and `python`, `python3`, `node`, `pwsh`, `ruby`, `perl`), skipping its options (`-e`, `-u`, …) —
after `-c`, the first token of the command string. In any position, no absolute path and
no bare relative path (`scripts/x.sh`): apm bundles and rewrites neither. No `$` or backtick in 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 — 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}/scripts/my-hook.sh"`, never `"${PLUGIN_ROOT}"/scripts/x.sh`: apm rewrites

View File

@@ -6,8 +6,8 @@ source_keys:
# Authoring an apm instruction # Authoring an apm instruction
Reached from `SKILL.md` Step 1 for an instruction. Run the Gate, then write against the checklist, Reached from `SKILL.md` Step 1 for an instruction. `SKILL.md` Step 2 runs the Gate below; Step 3
then return to `SKILL.md` Step 3. writes against the checklist.
## Gate ## Gate

View File

@@ -7,8 +7,8 @@ source_keys:
# Authoring an apm prompt # Authoring an apm prompt
Reached from `SKILL.md` Step 1 for a prompt. Run the Gate, then write against the description Reached from `SKILL.md` Step 1 for a prompt. `SKILL.md` Step 2 runs the Gate below; Step 3 writes
contract and the checklist, then return to `SKILL.md` Step 3. against the description contract and the checklist.
## Gate ## Gate

View File

@@ -153,9 +153,10 @@ an edit to either belongs in both.
**Dispatch is mandatory at two or more mutually exclusive flows.** The body carries the dispatch **Dispatch is mandatory at two or more mutually exclusive flows.** The body carries the dispatch
table and the gates common to every branch; each flow gets its own self-contained `references/` table and the gates common to every branch; each flow gets its own self-contained `references/`
file. Exemplar: the `apm-workflow` skill — a **294-word body** dispatching to 3,154 words of file. Exemplar: the `apm-workflow` skill — a body of roughly 240 words dispatching to over
references across five flow files. Calibrate against 294: that file's whole-file count is 348 3,000 words of references across five flow files. Calibrate against the body-only count
words, and aiming at that number instead overshoots the body budget by ~18%. The 3,154 excludes `factory-audit` reports, not the whole-file count: there the whole file runs about a fifth larger,
so aiming at it overshoots the body budget by that much. The reference total excludes
`references/sources.md`, which is a provenance record and is never loaded at runtime. `references/sources.md`, which is a provenance record and is never loaded at runtime.
**Length.** 600 words SUGGESTION, 900 words FAIL, counting the **body only** — everything after **Length.** 600 words SUGGESTION, 900 words FAIL, counting the **body only** — everything after

View File

@@ -85,8 +85,9 @@ Still over after all four means the skill does two jobs: split it rather than co
**Re-cite what moved.** After content moves between files, update `references/sources.md`'s **Re-cite what moved.** After content moves between files, update `references/sources.md`'s
`Contributing files` for every slug whose content moved, and drop any file the edit deleted. `Contributing files` for every slug whose content moved, and drop any file the edit deleted.
`factory-audit`'s `scripts/validate-provenance.sh` exits 0 on exactly that drift, so a stale `factory-audit`'s `scripts/validate-provenance.sh` fails a listed file that no longer exists, but
provenance claim ships unless you fix it here. exits 0 when content moved out of a file that still exists and still lists the slug, so that stale
claim ships unless you fix it here.
**Re-check every relocated gate's reachability.** A Gotcha or gate moved out of the body into one **Re-check every relocated gate's reachability.** A Gotcha or gate moved out of the body into one
flow's `references/` file is invisible to every other branch, and the word counts improve either flow's `references/` file is invisible to every other branch, and the word counts improve either

View File

@@ -153,39 +153,49 @@ else
TARGET="$TARGET_INPUT/$SKILL_NAME" TARGET="$TARGET_INPUT/$SKILL_NAME"
fi fi
# Files carrying the SKILL_NAME placeholder token. # Files carrying the SKILL_NAME placeholder token, each paired with the exact
# template line that marks it as still unsubstituted. Only that whole line
# counts: a finished skill may legitimately mention SKILL_NAME in its prose.
SUBST_FILES=("SKILL.md" "tests/README.md") SUBST_FILES=("SKILL.md" "tests/README.md")
SUBST_MARKERS=("name: SKILL_NAME" "bats <destination-dir>/SKILL_NAME/tests/")
# Replace SKILL_NAME in each placeholder file under dir $1. `sed -i` is not # Replace SKILL_NAME in one file. `sed -i` is not portable — GNU takes an
# portable — GNU takes an optional attached suffix, BSD/macOS requires a # optional attached suffix, BSD/macOS requires a separate suffix argument and
# separate suffix argument and reads the expression as one — so write to a # reads the expression as one — so write to a temp file and move it over.
# temp file and move it over the original instead. substitute_file() {
substitute_name() { local f="$1"
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" sed "s/SKILL_NAME/$SKILL_NAME/g" "$f" > "$f.tmp"
mv "$f.tmp" "$f" mv "$f.tmp" "$f"
done
} }
# True if any placeholder file under dir $1 still carries the SKILL_NAME token. # Substitute every placeholder file under dir $1 (a fresh template copy).
has_placeholder() { substitute_name() {
local dir="$1" rel local dir="$1" rel
for rel in "${SUBST_FILES[@]}"; do for rel in "${SUBST_FILES[@]}"; do
if [[ -f "$dir/$rel" ]] && grep -q 'SKILL_NAME' "$dir/$rel"; then [[ -f "$dir/$rel" ]] && substitute_file "$dir/$rel"
done
return 0 return 0
}
# Substitute only the placeholder files under dir $1 that still carry their
# template marker line; print how many were repaired.
repair_placeholders() {
local dir="$1" i f n=0
for i in "${!SUBST_FILES[@]}"; do
f="$dir/${SUBST_FILES[$i]}"
if [[ -f "$f" ]] && grep -qxF "${SUBST_MARKERS[$i]}" "$f"; then
substitute_file "$f"
n=$((n + 1))
fi fi
done done
return 1 echo "$n"
} }
if [[ -d "$TARGET" ]]; then if [[ -d "$TARGET" ]]; then
# A scaffold left half-built by an earlier failed run still carries the # A scaffold left half-built by an earlier failed run still carries a
# placeholder token; finish it instead of reporting a silent no-op. # template marker line; finish it instead of reporting a silent no-op.
if has_placeholder "$TARGET"; then # Anything else — including a complete skill — is left untouched.
substitute_name "$TARGET" if [[ "$(repair_placeholders "$TARGET")" -gt 0 ]]; then
echo "Repaired partial scaffold at '$TARGET' — substituted SKILL_NAME." >&2 echo "Repaired partial scaffold at '$TARGET' — substituted SKILL_NAME." >&2
exit 0 exit 0
fi fi

View File

@@ -132,6 +132,25 @@ teardown() {
assert_success assert_success
} }
@test "a complete skill whose text mentions SKILL_NAME stays a true no-op" {
# Only the template's exact marker lines mark a half-built scaffold. A
# finished skill that merely mentions the token must not be rewritten.
mkdir -p "$DEST/my-tool/tests"
printf -- '---\nname: my-tool\n---\n\nUse `__SKILL_NAME_PLACEHOLDER__` here.\n' \
> "$DEST/my-tool/SKILL.md"
printf 'Set SKILL_NAME before running.\n' > "$DEST/my-tool/tests/README.md"
cp "$DEST/my-tool/SKILL.md" "$DEST/skill.orig"
cp "$DEST/my-tool/tests/README.md" "$DEST/readme.orig"
run bash "$SCRIPT" my-tool "$DEST"
assert_success
assert_output --partial "nothing to do"
refute_output --partial "Repaired"
run cmp "$DEST/skill.orig" "$DEST/my-tool/SKILL.md"
assert_success
run cmp "$DEST/readme.orig" "$DEST/my-tool/tests/README.md"
assert_success
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mode detection: package vs standalone # Mode detection: package vs standalone
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -68,7 +68,10 @@ At install, apm merges the event bindings into `.claude/settings.json`, copies t
to `.claude/hooks/<pkg>/` (preserving its executable bit, preserving the `.apm/hooks/` subpath), and to `.claude/hooks/<pkg>/` (preserving its executable bit, preserving the `.apm/hooks/` subpath), and
rewrites `command` to a `${CLAUDE_PROJECT_DIR}`-relative path. Ownership of its own entries is rewrites `command` to a `${CLAUDE_PROJECT_DIR}`-relative path. Ownership of its own entries is
tracked in a `.claude/apm-hooks.json` sidecar, so an uninstall removes them without touching tracked in a `.claude/apm-hooks.json` sidecar, so an uninstall removes them without touching
hand-authored hooks. Both `.claude/hooks/` and the sidecar are gitignored install output. hand-authored hooks. `.claude/hooks/` is gitignored install output; the sidecar is committed
alongside `.claude/settings.json`, because without it a fresh clone's `apm install` treats the
committed entry as user-owned and adds a duplicate, and `apm audit --ci` reports drift (ADR-0019,
correction 2026-09-16).
Note that apm's **executable-trust gate is off** unless the consuming project's `apm.yml` has an Note that apm's **executable-trust gate is off** unless the consuming project's `apm.yml` has an
`executables:` block — without one, package hooks deploy with no prompt. The allow key carries a `executables:` block — without one, package hooks deploy with no prompt. The allow key carries a