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

@@ -172,9 +172,76 @@ ROUTING_TOKENS = ('copilot', 'vscode', 'cursor', 'claude', 'codex', 'gemini',
_TOK = '|'.join(ROUTING_TOKENS)
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
# native event. Any other camelCase name is deployed verbatim and never fires.
CLAUDE_MAPPED_CAMEL = {'preToolUse', 'postToolUse', 'sessionStart', 'agentStop'}
# apm 0.28.0 _HOOK_EVENT_MAP (apm_cli/integration/hook_integrator.py): the
# rename each target applies before deploying. A name absent from a target's
# 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')
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"\']+)')
# An interpreter whose first argument is the script it runs. A reference in
# that argument slot is in command position just as a first token is.
# An interpreter whose first operand is the script it runs. A reference in
# that operand slot is in command position just as a first token is.
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):
@@ -197,14 +277,34 @@ def _prefix_tokens(prefix):
def _interp_arg_index(tokens):
"""Index of the token that is the first argument after a known interpreter
(optionally behind `env`), or None when the command does not open with one."""
"""(index, is_command_string) of the script operand after a known
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
if tokens and os.path.basename(tokens[0]) == 'env':
i = 1
if len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS:
return i + 1
return None
if not (len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS):
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):
@@ -212,7 +312,8 @@ def _position(prefix):
toks = [t for t in _prefix_tokens(prefix) if t]
if not toks:
return True, False
return False, _interp_arg_index(toks) == len(toks)
slot = _interp_arg_index(toks)
return False, slot is not None and slot[0] == len(toks)
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
# 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,
# and the first argument after a known interpreter (`bash scripts/x.sh`).
# A later argument is data, not a script apm is asked to run.
# and the first operand after a known interpreter, past its options
# (`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)
if not toks:
return
slots = [0]
arg = _interp_arg_index(toks)
if arg is not None and arg < len(toks):
slots.append(arg)
if arg is not None and arg[0] < len(toks):
if arg[1]:
check_unanchored_script(toks[arg[0]], pkg_root, where)
else:
slots.append(arg[0])
for idx in slots:
tok = toks[idx]
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}")
def owning_apm_yml():
"""The nearest apm.yml walking up from the hook file, or None."""
d = parent_dir
while True:
cand = os.path.join(d, 'apm.yml')
if os.path.isfile(cand):
return cand
up = os.path.dirname(d)
if up == d:
return None
d = up
# apm's package manifests (apm.yml, and utils/helpers.py find_plugin_json): a
# directory holding any of these is a package root, and its hooks/*.json is
# hook source. A Claude plugin needs no apm.yml.
PACKAGE_MANIFESTS = ('apm.yml', 'plugin.json', os.path.join('.github', 'plugin', 'plugin.json'),
os.path.join('.claude-plugin', 'plugin.json'),
os.path.join('.cursor-plugin', 'plugin.json'))
def package_targets():
"""The set of targets apm renders this package's hooks to. Mirrors
parse_targets_field: no target:/targets: (or no apm.yml) means every
def is_package_root(d):
return any(os.path.isfile(os.path.join(d, m)) for m in PACKAGE_MANIFESTS)
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
as every target, the reading that keeps the stricter checks on."""
every = set(ROUTING_TOKENS)
path = owning_apm_yml()
if path is None:
every = set(HOOK_TARGETS)
path = os.path.join(pkg_root, 'apm.yml')
if not os.path.isfile(path):
return every
try:
with open(path, encoding='utf-8') as f:
@@ -403,16 +507,32 @@ def package_targets():
tokens = [str(t).strip().lower() for t in raw]
else:
tokens = [t.strip().lower() for t in str(raw).split(',')]
tokens = {t for t in tokens if t}
tokens = {TARGET_ALIASES.get(t, t) for t in tokens if t}
if not tokens or 'all' in tokens:
return every
return tokens
return tokens & HOOK_TARGETS
# apm 0.28.0 _HOOK_EVENT_MAP: the only all-lowercase source name any target
# renames is Kiro's `stop` -> `Stop`. Every other target deploys an
# all-lowercase name verbatim, where it never fires.
LOWERCASE_EVENT_TARGETS = {'stop': {'kiro'}}
def check_event(event, deploys_to):
"""hook.md Must 4: the event fires on every target the package deploys
to, after apm's rename for that target. A target with a published event
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
# one of them is install output, not package source.
@@ -440,12 +560,12 @@ def audit_hook():
pkg_root = os.path.dirname(above)
if not os.path.isfile(os.path.join(pkg_root, 'apm.yml')):
info(f"no apm.yml at the inferred package root {pkg_root} — script paths are resolved against it anyway — {fname}")
elif os.path.isfile(os.path.join(above, 'apm.yml')):
elif os.path.basename(above) not in DEPLOY_ROOTS and is_package_root(above):
pkg_root = above
else:
kind_of = (f"apm's deployed output ({os.path.basename(above)}/hooks/)"
if os.path.basename(above) in DEPLOY_ROOTS else 'no package source')
fail(f"is {kind_of} — apm reads hook source only from <package>/.apm/hooks/*.json or a package-root hooks/*.json beside apm.yml; audit the source file in the package's .apm/hooks/ instead — {fname}")
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
# 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}")
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
for event, entries in events.items():
if not isinstance(entries, list):
@@ -496,13 +613,10 @@ def audit_hook():
shape_ok = False
continue
if 'hooks' in entry:
claude_shaped = True
nested = entry['hooks']
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}")
shape_ok = False
elif 'command' in entry:
claude_shaped = True
if shape_ok:
# hook.md Must 3: the file contributes at least one entry. An empty
@@ -517,24 +631,12 @@ def audit_hook():
if total == 0:
fail(f"contributes no hook entries — every event list is empty, so apm deploys nothing — {fname}")
# camelCase outside Claude's map never fires on Claude. A flat
# Copilot-shaped file still renders to Claude whenever the package targets
# it, so the exemption holds only for a package that does not.
deploys_to = package_targets()
camel_checked = claude_shaped or 'claude' in deploys_to
deploys_to = package_targets(pkg_root)
for event in events:
if not event.strip():
fail(f"empty event name — {fname}")
elif not any(c.isupper() for c in event):
mapping = LOWERCASE_EVENT_TARGETS.get(event, set())
unmapped = sorted(deploys_to - mapping)
if not mapping & deploys_to:
fail(f"event '{event}' is all-lowercase — no target this package deploys to maps it, and apm never warns, so it silently never fires; write it in PascalCase — {fname}")
elif unmapped:
suggest(f"event '{event}' is all-lowercase — only Kiro renames it; {', '.join(unmapped)} receive it verbatim and it never fires there; write it in PascalCase — {fname}")
elif camel_checked and event[0].islower() and event not in CLAUDE_MAPPED_CAMEL:
why = 'in a Claude-shaped file' if claude_shaped else "and the package's apm.yml targets Claude (no targets: means every target)"
fail(f"event '{event}' is camelCase {why}, and Claude's map does not rename it — it deploys verbatim to Claude and never fires; write it in PascalCase — {fname}")
else:
check_event(event, deploys_to)
if not shape_ok:
return