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:
@@ -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
|
||||
|
||||
@@ -131,8 +131,9 @@ the target:
|
||||
directory is named 'agents' (.apm/agents, .claude/agents,
|
||||
.github/agents, .copilot/agents).
|
||||
hook mode the target is a *.json file directly under a hooks/
|
||||
directory (.apm/hooks, or a hooks/ beside apm.yml; any
|
||||
other hooks/ directory is apm's deployed output and FAILs).
|
||||
directory (.apm/hooks, or a hooks/ at a package root: beside
|
||||
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.
|
||||
prompt mode the target is a *.prompt.md file.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user