feat(kyberforge): primitive-author and factory-audit support for apm hooks, instructions and prompts #144
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
sed "s/SKILL_NAME/$SKILL_NAME/g" "$f" > "$f.tmp"
|
||||||
for rel in "${SUBST_FILES[@]}"; do
|
mv "$f.tmp" "$f"
|
||||||
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.
|
# 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"
|
||||||
return 0
|
done
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user