- 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
886 lines
42 KiB
Bash
Executable File
886 lines
42 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# lib-checks-primitive.sh — SOURCED, never executed.
|
|
#
|
|
# The structural check suite for the three apm primitives with no
|
|
# SKILL.md-shaped container, all authored by primitive-author: hooks
|
|
# (.apm/hooks/*.json), instructions (*.instructions.md) and prompts
|
|
# (*.prompt.md). validate.sh detects which one it was handed from the path and
|
|
# feeds $KYBERFORGE_PRIMITIVE_PY to python3 with the target as argv[1] and the
|
|
# primitive kind (hook | instruction | prompt) as argv[2].
|
|
#
|
|
# Every check here exists because apm itself does not make it. apm 0.28.0
|
|
# silently skips invalid hook JSON, only warns on an instruction with no
|
|
# description or body, and never validates a prompt's input: names against its
|
|
# ${input:x} references — so `apm install` and `apm compile --validate` both exit
|
|
# 0 on files that deploy nothing, or deploy something that never fires. The
|
|
# checks follow primitive-author's hook, instruction and prompt reference
|
|
# checklists (research provenance: source key apm-cli-installed-source in
|
|
# references/sources.md), except where references/{hook,instruction,prompt}-flow.md
|
|
# documents a deliberate deviation (a tier moved, or a check the author leaves
|
|
# audit-only). A Must in primitive-author is a FAIL here, a Should a SUGGESTION.
|
|
#
|
|
# No boundary resolver and no word budgets: none of these files is routed on a
|
|
# description the way a skill is. A prompt's description IS model-visible on
|
|
# Claude, which is why it gets the three ADR-0029 description SUGGESTIONs below
|
|
# (length, trigger clause, boundary clause) — but whether
|
|
# a prompt body carries procedure that belongs in a skill is a judgment call the
|
|
# prompt flow makes by reading it, and deliberately has no heuristic here.
|
|
#
|
|
# Output follows lib-checks-agent.sh: FAIL lines on stderr, SUGGESTION and INFO
|
|
# on stdout, exit 1 on any FAIL, 0 otherwise.
|
|
#
|
|
# Consumed by: validate.sh, hook / instruction / prompt modes.
|
|
# shellcheck shell=bash
|
|
# shellcheck disable=SC2034
|
|
|
|
kyberforge_primitive_preflight() {
|
|
# Interpreter and library are checked separately so the message names the
|
|
# thing to install; see lib-checks-agent.sh for the history. PyYAML is needed
|
|
# for the two markdown kinds, and is required for hooks too so that one
|
|
# dependency set covers the whole suite rather than a hook audit passing on a
|
|
# machine where the next instruction audit cannot run.
|
|
if ! command -v python3 > /dev/null 2>&1; then
|
|
echo "Error: python3 is required but was not found on PATH." >&2
|
|
echo " Why: every primitive check parses the file; without python3 no check runs, and reporting that as a pass would be vacuous." >&2
|
|
echo " Fix: install python3." >&2
|
|
exit 2
|
|
fi
|
|
|
|
if ! python3 -c 'import yaml' > /dev/null 2>&1; then
|
|
echo "Error: PyYAML is required but is not importable by python3." >&2
|
|
echo " Why: instruction and prompt frontmatter has to be parsed the way apm parses it; a hand-rolled reader would disagree with it on exactly the edge cases these checks exist for." >&2
|
|
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
|
|
exit 2
|
|
fi
|
|
}
|
|
|
|
IFS='' read -r -d '' KYBERFORGE_PRIMITIVE_PY <<'KYBERFORGE_PRIMITIVE' || true
|
|
import sys
|
|
import os
|
|
import re
|
|
import json
|
|
import shlex
|
|
|
|
import yaml
|
|
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
_stream.reconfigure(encoding='utf-8')
|
|
except AttributeError: # pragma: no cover — Python < 3.7
|
|
pass
|
|
|
|
target = os.path.abspath(sys.argv[1])
|
|
kind = sys.argv[2]
|
|
fname = os.path.basename(target)
|
|
parent_dir = os.path.dirname(target)
|
|
|
|
failed = False
|
|
suggestions = []
|
|
|
|
|
|
def fail(msg):
|
|
global failed
|
|
failed = True
|
|
print(f"FAIL {msg}", file=sys.stderr)
|
|
|
|
|
|
def suggest(msg):
|
|
suggestions.append(msg)
|
|
|
|
|
|
def info(msg):
|
|
print(f"INFO {msg}")
|
|
|
|
|
|
def read_text(path):
|
|
try:
|
|
with open(path, encoding='utf-8') as f:
|
|
return f.read()
|
|
except UnicodeDecodeError as exc:
|
|
fail(f"not valid UTF-8 ({exc.reason} at byte {exc.start}) — apm reads primitives as UTF-8 — {fname}")
|
|
except OSError as exc:
|
|
fail(f"cannot be read ({exc.strerror}) — {fname}")
|
|
return None
|
|
|
|
|
|
def check_not_linked(hardlinks=True):
|
|
# apm's find_files_by_glob (instructions, prompts) rejects symlinks and
|
|
# hardlinks (link count > 1); find_hook_files skips symlinks only, so hooks
|
|
# pass hardlinks=False. A rejected file is silently never deployed.
|
|
if os.path.islink(target):
|
|
fail(f"is a symlink — apm's discovery skips symlinks, so it is never deployed — {fname}")
|
|
return
|
|
if not hardlinks:
|
|
return
|
|
try:
|
|
if os.stat(target).st_nlink > 1:
|
|
fail(f"is a hardlink (link count > 1) — apm's discovery rejects hardlinks, so it is never deployed — {fname}")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def package_root_for(subdir):
|
|
# <pkg>/.apm/<subdir>/<file> -> <pkg>. Returns None for any other layout.
|
|
if os.path.basename(parent_dir) != subdir:
|
|
return None
|
|
apm_dir = os.path.dirname(parent_dir)
|
|
if os.path.basename(apm_dir) != '.apm':
|
|
return None
|
|
return os.path.dirname(apm_dir)
|
|
|
|
|
|
FRONTMATTER_RE = re.compile(r'\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
|
|
|
|
|
|
def split_frontmatter(content):
|
|
"""Return (frontmatter dict | None, body, ok). ok is False on a parse FAIL."""
|
|
if content.startswith('\ufeff'):
|
|
content = content[1:]
|
|
m = FRONTMATTER_RE.match(content)
|
|
if not m:
|
|
fail(f"has no YAML frontmatter block (--- ... ---) — description and every other key live there — {fname}")
|
|
return None, content, False
|
|
try:
|
|
fm = yaml.safe_load(m.group(1))
|
|
except yaml.YAMLError as exc:
|
|
mark = getattr(exc, 'problem_mark', None)
|
|
where = f" at line {mark.line + 2}" if mark is not None else ''
|
|
fail(f"frontmatter is not valid YAML{where} — apm cannot read any key from it — {fname}")
|
|
return None, content[m.end():], False
|
|
if fm is None:
|
|
fm = {}
|
|
if not isinstance(fm, dict):
|
|
fail(f"frontmatter is not a YAML mapping — {fname}")
|
|
return None, content[m.end():], False
|
|
return fm, content[m.end():], True
|
|
|
|
|
|
def check_description(fm):
|
|
desc = fm.get('description')
|
|
if not isinstance(desc, str) or not desc.strip():
|
|
fail(f"'description' is missing or empty — apm does not require it, so nothing else will catch this — {fname}")
|
|
return None
|
|
return desc.strip()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hooks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ROUTING_TOKENS = ('copilot', 'vscode', 'cursor', 'claude', 'codex', 'gemini',
|
|
'antigravity', 'windsurf', 'kiro')
|
|
_TOK = '|'.join(ROUTING_TOKENS)
|
|
ROUTING_STEM_RE = re.compile(rf'^hooks-(?:{_TOK})$|(?:^|-)(?:{_TOK})-hooks$')
|
|
|
|
# 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')
|
|
ROOT_TOKEN_RE = re.compile(r'\$\{(' + '|'.join(ROOT_TOKENS) + r')\}')
|
|
# apm 0.28.0's own patterns, hook_integrator.py _rewrite_command_for_target:
|
|
# the path must follow the token directly and ends at whitespace or a quote.
|
|
# The ./ pattern is applied with finditer over the whole command, so it
|
|
# matches after an interpreter (`bash ./x.sh`) too.
|
|
APM_ROOT_REF_RE = re.compile(r'\$\{(?:' + '|'.join(ROOT_TOKENS) + r')\}([\\/][^\s"\']+)')
|
|
APM_REL_REF_RE = re.compile(r'(\.[\\/][^\s"\']+)')
|
|
|
|
|
|
# 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):
|
|
return [t.strip('"\'') for t in prefix.split()]
|
|
|
|
|
|
def _interp_arg_index(tokens):
|
|
"""(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 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):
|
|
"""(is_first_token, is_interpreter_arg) for a reference preceded by prefix."""
|
|
toks = [t for t in _prefix_tokens(prefix) if t]
|
|
if not toks:
|
|
return True, False
|
|
slot = _interp_arg_index(toks)
|
|
return False, slot is not None and slot[0] == len(toks)
|
|
|
|
|
|
def is_handler(h):
|
|
# A handler runs something: a command key, or a non-command handler type
|
|
# (Claude's prompt/agent/http hooks) whose payload is not a script.
|
|
if not isinstance(h, dict):
|
|
return False
|
|
if any(isinstance(h.get(k), str) and h.get(k).strip() for k in HOOK_COMMAND_KEYS):
|
|
return True
|
|
return h.get('type') not in (None, 'command')
|
|
|
|
|
|
def extract_script_refs(cmd, pkg_root, where):
|
|
"""Return (kind, relpath, is_first_token, is_interpreter_arg) for each
|
|
package-relative reference apm would rewrite, reading the command exactly
|
|
as apm does. kind is 'root' for a ${*_PLUGIN_ROOT} token, 'rel' for a
|
|
./path, 'up' for a ../path. A token apm reads wrongly — split-quoted, or a
|
|
path with a space — is a FAIL here, because apm leaves it unrewritten or
|
|
cuts it short."""
|
|
refs = []
|
|
masked = cmd
|
|
for m in ROOT_TOKEN_RE.finditer(cmd):
|
|
start, end = m.start(), m.end()
|
|
if end < len(cmd) and cmd[end] in '"\'' and cmd[end + 1:end + 2] in ('/', '\\'):
|
|
fail(f"script path '{cmd[start:]}' splits the quote after ${{{m.group(1)}}} — apm rewrites only a path that follows the token directly, so this one deploys unrewritten and unbundled; quote the whole token: \"${{PLUGIN_ROOT}}/<path>\" — {where}")
|
|
for m in APM_ROOT_REF_RE.finditer(cmd):
|
|
start, end = m.start(), m.end()
|
|
opener = cmd[start - 1] if start > 0 and cmd[start - 1] in '"\'' else None
|
|
path = m.group(1)
|
|
nxt = cmd[end:end + 1]
|
|
# A backslash-escaped space, or a quoted token whose script name only
|
|
# completes past the whitespace apm stopped at ("…/my hook.sh").
|
|
# A path apm read that exists as a file is exactly what apm bundles, so
|
|
# a later argument inside the same quotes (`bash -c "…/tool --x a.sh"`)
|
|
# is an argument, not the rest of a spaced name.
|
|
spaced = nxt.isspace() and path.endswith('\\')
|
|
if not spaced and opener is not None and nxt.isspace():
|
|
quoted = cmd[start:].split(opener, 1)[0]
|
|
exists = os.path.isfile(os.path.join(pkg_root, path.replace('\\', '/').lstrip('/')))
|
|
spaced = (not exists and bool(SCRIPT_EXT_RE.search(quoted))
|
|
and not SCRIPT_EXT_RE.search(path))
|
|
if spaced:
|
|
fail(f"script path '{cmd[start:]}' contains a space — apm reads a ${{PLUGIN_ROOT}} path only up to the first whitespace or quote, so it bundles the wrong file and the hook fails; rename the script without spaces — {where}")
|
|
else:
|
|
prefix = cmd[:start - 1] if opener else cmd[:start]
|
|
refs.append(('root', path.replace('\\', '/').lstrip('/')) + _position(prefix))
|
|
masked = masked[:start] + ' ' * (end - start) + masked[end:]
|
|
for m in APM_REL_REF_RE.finditer(masked):
|
|
start = m.start()
|
|
ref = m.group(1)
|
|
kind_ = 'rel'
|
|
if start > 0 and masked[start - 1] == '.':
|
|
kind_, start = 'up', start - 1
|
|
opener = masked[start - 1] if start > 0 and masked[start - 1] in '"\'' else None
|
|
prefix = masked[:start - 1] if opener else masked[:start]
|
|
refs.append((kind_, ref[2:].replace('\\', '/')) + _position(prefix))
|
|
return refs
|
|
|
|
|
|
SCRIPT_EXT_RE = re.compile(r'\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|ps1|rb|pl)$', re.IGNORECASE)
|
|
|
|
|
|
def command_tokens(cmd):
|
|
"""The command's leading whitespace-delimited tokens, quotes removed."""
|
|
try:
|
|
return shlex.split(cmd)
|
|
except ValueError:
|
|
return _prefix_tokens(cmd)
|
|
|
|
|
|
def check_unanchored_script(cmd, pkg_root, where):
|
|
# apm rewrites and bundles only ${*_PLUGIN_ROOT}/... and ./... references;
|
|
# a bare command (`npx foo`, `echo hi`) passes through untouched, which is
|
|
# fine. An absolute script path, or a bare relative path to a file in the
|
|
# package, also passes through untouched — so the script is not bundled
|
|
# 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 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[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:
|
|
continue
|
|
if tok.startswith('/'):
|
|
real_root = os.path.realpath(pkg_root)
|
|
inside = os.path.realpath(tok).startswith(real_root + os.sep)
|
|
# In the first slot an extension-less absolute path outside the
|
|
# package (`/usr/bin/env`, `/bin/bash`) is the host's interpreter;
|
|
# in the interpreter-argument slot it is the script being run.
|
|
if inside or SCRIPT_EXT_RE.search(tok) or idx > 0:
|
|
fail(f"script '{tok}' is an absolute path — apm neither bundles nor rewrites it, so it breaks on every other machine; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
|
|
continue
|
|
if '/' in tok:
|
|
for base in (parent_dir, pkg_root):
|
|
if os.path.isfile(os.path.join(base, tok)):
|
|
fail(f"script '{tok}' is a bare relative path — apm bundles and rewrites only ${{PLUGIN_ROOT}}/... and ./... references, so this one deploys unbundled; prefix it with ${{PLUGIN_ROOT}}/ or ./ — {where}")
|
|
break
|
|
|
|
|
|
def check_script(kind_, rel, first, interp_arg, pkg_root, where):
|
|
if not rel:
|
|
return
|
|
# apm's ./ pattern also matches plain arguments — a cwd directory
|
|
# (`npx prettier --check ./src`), a printf escape (`'.\\n'`), a sibling path.
|
|
# apm only warns on those and they run against the consumer's cwd as
|
|
# meant, so a ./ or ../ match is held to the script rules only in command
|
|
# position or when it names a script by extension (or a package entry that
|
|
# is not a file).
|
|
strong = kind_ == 'root' or first or interp_arg or bool(SCRIPT_EXT_RE.search(rel))
|
|
if kind_ == 'up':
|
|
in_pkg = os.path.exists(os.path.join(pkg_root, rel)) and not os.path.isfile(os.path.join(pkg_root, rel))
|
|
if strong or in_pkg:
|
|
fail(f"script path '../{rel}' starts with ../ — apm reads it as ./{rel} from the hook directory, not the parent, so the wrong file (or none) is bundled; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
|
|
else:
|
|
suggest(f"argument '../{rel}' matches apm's ./ script pattern — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}")
|
|
return
|
|
if '$' in rel or '`' in rel:
|
|
fail(f"script path '{rel}' contains '$' or a backtick — apm refuses to rewrite it for Claude — {where}")
|
|
return
|
|
candidates = []
|
|
if kind_ == 'root':
|
|
candidates.append(os.path.join(pkg_root, rel))
|
|
else:
|
|
candidates.append(os.path.join(parent_dir, rel))
|
|
candidates.append(os.path.join(pkg_root, rel))
|
|
real_root = os.path.realpath(pkg_root)
|
|
found = None
|
|
for c in candidates:
|
|
real = os.path.realpath(c)
|
|
if real != real_root and not real.startswith(real_root + os.sep):
|
|
fail(f"script '{rel}' resolves outside the package — apm confines hook scripts to the package root — {where}")
|
|
return
|
|
if os.path.isfile(c):
|
|
found = c
|
|
break
|
|
if found is None:
|
|
not_a_file = any(os.path.exists(c) for c in candidates)
|
|
if strong or not_a_file:
|
|
what = "exists in the package but is not a regular file" if not_a_file else "does not exist in the package"
|
|
fail(f"script '{rel}' {what} — apm only warns, then deploys a hook that fails every time it fires — {where}")
|
|
else:
|
|
suggest(f"argument './{rel}' matches apm's ./ script pattern but names no package file — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}")
|
|
return
|
|
if first and not os.access(found, os.X_OK):
|
|
fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}")
|
|
|
|
|
|
# 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 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(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:
|
|
data = yaml.safe_load(f)
|
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
return every
|
|
if not isinstance(data, dict):
|
|
return every
|
|
raw = data.get('targets', data.get('target'))
|
|
if raw is None:
|
|
return every
|
|
if isinstance(raw, list):
|
|
tokens = [str(t).strip().lower() for t in raw]
|
|
else:
|
|
tokens = [t.strip().lower() for t in str(raw).split(',')]
|
|
tokens = {TARGET_ALIASES.get(t, t) for t in tokens if t}
|
|
if not tokens or 'all' in tokens:
|
|
return every
|
|
return tokens & HOOK_TARGETS
|
|
|
|
|
|
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.
|
|
DEPLOY_ROOTS = ('.github', '.claude', '.cursor', '.codex', '.kiro', '.windsurf',
|
|
'.gemini', '.vscode', '.antigravity', '.copilot')
|
|
PLACEHOLDER_RE = re.compile(r'FILL IN|FILL_IN_')
|
|
|
|
|
|
def check_placeholders(content):
|
|
m = PLACEHOLDER_RE.search(content)
|
|
if m:
|
|
line = content.count('\n', 0, m.start()) + 1
|
|
fail(f"unfilled template placeholder '{m.group(0)}' at line {line} — primitive-author Step 3 fills every FILL IN and FILL_IN_ placeholder before the file ships — {fname}")
|
|
|
|
|
|
def audit_hook():
|
|
check_not_linked(hardlinks=False)
|
|
stem = fname[:-len('.json')]
|
|
# validate.sh dispatches only a .json directly under a hooks/ directory.
|
|
# apm discovers package source at .apm/hooks/*.json and at a package-root
|
|
# hooks/*.json; anything else under a hooks/ directory is apm's deployed
|
|
# output (.github/hooks/, .cursor/hooks/, ...) or not a package at all.
|
|
above = os.path.dirname(parent_dir)
|
|
if os.path.basename(above) == '.apm':
|
|
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.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 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).
|
|
if ROUTING_STEM_RE.search(stem.lower()):
|
|
suggest(f"filename stem '{stem}' uses deprecated hook filename routing — name it plainly and narrow reach with target:/targets: in the package's apm.yml — {fname}")
|
|
|
|
content = read_text(target)
|
|
if content is None:
|
|
return
|
|
check_placeholders(content)
|
|
try:
|
|
doc = json.loads(content)
|
|
except json.JSONDecodeError as exc:
|
|
fail(f"is not valid JSON (line {exc.lineno}, column {exc.colno}) — apm skips an unparseable hook file silently — {fname}")
|
|
return
|
|
if not isinstance(doc, dict):
|
|
fail(f"top level is not a JSON object — {fname}")
|
|
return
|
|
|
|
if 'hooks' in doc:
|
|
events = doc['hooks']
|
|
if not isinstance(events, dict):
|
|
fail(f"'hooks' is not an object — apm skips the file, and the Copilot install fails outright — {fname}")
|
|
return
|
|
else:
|
|
stray = [k for k, v in doc.items() if not isinstance(v, list)]
|
|
if stray:
|
|
fail(f"naked settings-slice shape with non-list top-level key(s) {', '.join(sorted(stray))} — apm does not promote it, Claude gets nothing and Copilot gets a junk file; wrap events in {{\"hooks\": {{...}}}} — {fname}")
|
|
return
|
|
events = doc
|
|
|
|
if not events:
|
|
fail(f"contributes no hook entries — apm warns and deploys nothing — {fname}")
|
|
return
|
|
|
|
shape_ok = True
|
|
for event, entries in events.items():
|
|
if not isinstance(entries, list):
|
|
fail(f"event '{event}' is not a list — the Copilot install fails on this payload — {fname}")
|
|
shape_ok = False
|
|
continue
|
|
for i, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
fail(f"event '{event}' entry {i} is not an object — the Copilot install fails on this payload — {fname}")
|
|
shape_ok = False
|
|
continue
|
|
if 'hooks' in entry:
|
|
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
|
|
|
|
if shape_ok:
|
|
# hook.md Must 3: the file contributes at least one entry. An empty
|
|
# event list, or an entry with no handler, deploys nothing runnable.
|
|
total = 0
|
|
for event, entries in events.items():
|
|
for i, entry in enumerate(entries):
|
|
total += 1
|
|
handlers = entry['hooks'] if 'hooks' in entry else [entry]
|
|
if not any(is_handler(h) for h in handlers):
|
|
fail(f"event '{event}' entry {i} has no handler — no command (or other handler type) to run, so it deploys nothing — {fname}")
|
|
if total == 0:
|
|
fail(f"contributes no hook entries — every event list is empty, so apm deploys nothing — {fname}")
|
|
|
|
deploys_to = package_targets(pkg_root)
|
|
for event in events:
|
|
if not event.strip():
|
|
fail(f"empty event name — {fname}")
|
|
else:
|
|
check_event(event, deploys_to)
|
|
|
|
if not shape_ok:
|
|
return
|
|
|
|
uses_claude_token = False
|
|
for event, entries in events.items():
|
|
for i, entry in enumerate(entries):
|
|
handlers = entry['hooks'] if 'hooks' in entry else [entry]
|
|
for j, handler in enumerate(handlers):
|
|
where = f"{fname} {event}[{i}]" + (f".hooks[{j}]" if 'hooks' in entry else '')
|
|
for key in HOOK_COMMAND_KEYS:
|
|
cmd = handler.get(key)
|
|
if not isinstance(cmd, str):
|
|
continue
|
|
if '${CLAUDE_PLUGIN_ROOT}' in cmd:
|
|
uses_claude_token = True
|
|
for kind_, rel, first, interp_arg in extract_script_refs(cmd, pkg_root, where):
|
|
check_script(kind_, rel, first, interp_arg, pkg_root, where)
|
|
check_unanchored_script(cmd, pkg_root, where)
|
|
|
|
if uses_claude_token:
|
|
suggest(f"uses ${{CLAUDE_PLUGIN_ROOT}} — apm documents the target-neutral ${{PLUGIN_ROOT}}, which it rewrites identically for every target — {fname}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Instructions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
INSTRUCTION_KEYS = {'description', 'applyTo', 'author', 'version'}
|
|
|
|
|
|
def split_top_level(value):
|
|
# apm's parse_apply_to: split on commas outside {} (and not escaped \,),
|
|
# strip each segment, drop empty ones.
|
|
segs, cur, depth, i = [], '', 0, 0
|
|
while i < len(value):
|
|
c = value[i]
|
|
if c == '\\' and i + 1 < len(value):
|
|
cur += value[i:i + 2]
|
|
i += 2
|
|
continue
|
|
if c == '{':
|
|
depth += 1
|
|
elif c == '}':
|
|
depth -= 1
|
|
if c == ',' and depth == 0:
|
|
segs.append(cur)
|
|
cur = ''
|
|
else:
|
|
cur += c
|
|
i += 1
|
|
segs.append(cur)
|
|
return [s.strip() for s in segs if s.strip()]
|
|
|
|
|
|
def check_apply_to(apply_to):
|
|
if isinstance(apply_to, list):
|
|
entries = [e for e in apply_to if e is not None and str(e).strip()]
|
|
globs = [str(e).strip() for e in entries]
|
|
elif isinstance(apply_to, str):
|
|
globs = split_top_level(apply_to)
|
|
else:
|
|
fail(f"applyTo is neither a string nor a list — apm cannot read a glob from it — {fname}")
|
|
return False
|
|
if not globs:
|
|
fail(f"applyTo is present but empty — remove the key for an intentionally always-on rule, or give it a glob — {fname}")
|
|
return False
|
|
ok = True
|
|
for g in globs:
|
|
if g.count('{') != g.count('}') or g.count('[') != g.count(']'):
|
|
fail(f"applyTo glob '{g}' has unbalanced braces or brackets — it matches nothing, so the rule never fires — {fname}")
|
|
ok = False
|
|
return ok
|
|
|
|
|
|
def audit_instruction():
|
|
check_not_linked()
|
|
stem = fname[:-len('.instructions.md')]
|
|
pkg_root = package_root_for('instructions')
|
|
if pkg_root is None:
|
|
fail(f"is not directly in a .apm/instructions/ directory — that is the authoring source; anything else is either deployed output or a legacy root file — {fname}")
|
|
else:
|
|
dup = os.path.join(pkg_root, fname)
|
|
if os.path.isfile(dup):
|
|
fail(f"stem '{stem}' also exists at the package root ({dup}) — both deploy to the same .claude/rules/{stem}.md, and one overwrites the other — {fname}")
|
|
|
|
content = read_text(target)
|
|
if content is None:
|
|
return
|
|
check_placeholders(content)
|
|
fm, body, ok = split_frontmatter(content)
|
|
if not ok:
|
|
return
|
|
check_description(fm)
|
|
if not body.strip():
|
|
fail(f"body is empty — apm deploys an empty rule without complaint — {fname}")
|
|
|
|
apply_to = fm.get('applyTo')
|
|
apply_to_ok = apply_to is not None and check_apply_to(apply_to)
|
|
if apply_to is None:
|
|
suggest(f"no applyTo — this loads into every session of every repo that installs the package; confirm always-on is intended, and that a rule for this repo alone is not really an AGENTS.md rule — {fname}")
|
|
elif apply_to_ok and isinstance(apply_to, list):
|
|
suggest(f"applyTo is a YAML list — Copilot receives the file verbatim and its handling of a list is unverified; use one comma-separated string — {fname}")
|
|
|
|
extra = sorted(str(k) for k in fm if k not in INSTRUCTION_KEYS)
|
|
if extra:
|
|
suggest(f"frontmatter key(s) {', '.join(extra)} are read by no target and dropped on Claude — keep to description and applyTo (author, version optional) — {fname}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROMPT_KEYS = {'description', 'allowed-tools', 'model', 'argument-hint', 'input'}
|
|
PROMPT_CAMEL_ALIASES = {'allowedTools': 'allowed-tools', 'argumentHint': 'argument-hint'}
|
|
INPUT_NAME_RE = re.compile(r'^[A-Za-z][\w-]{0,63}$')
|
|
# apm's own rewrite pattern for ${input:x}, command_integrator.py.
|
|
INPUT_REF_RE = re.compile(r'\$\{\{?\s*input\s*:\s*([\w-]+)\s*\}?\}')
|
|
TRIGGER_RE = re.compile(r'\buse\s+(?:this\s+)?when\b', re.IGNORECASE)
|
|
# The skill boundary form `Not <thing> -> <target>` (ASCII or Unicode arrow).
|
|
BOUNDARY_RE = re.compile(r'\bnot\b[^.;]*?(?:->|\u2192)', re.IGNORECASE)
|
|
PROMPT_DESC_SUGGEST_CHARS = 250
|
|
|
|
|
|
def prompt_input_names(spec):
|
|
"""Mirror apm's _extract_input_names, but FAIL on what it rejects or
|
|
misreads instead of warning. Returns the declared names."""
|
|
names = []
|
|
|
|
def accept(candidate):
|
|
if not isinstance(candidate, str):
|
|
fail(f"input entry {candidate!r} is not a string name — apm rejects it — {fname}")
|
|
return
|
|
s = candidate.strip()
|
|
if not s:
|
|
return
|
|
if not INPUT_NAME_RE.match(s):
|
|
fail(f"input name '{s}' does not match ^[A-Za-z][\\w-]{{0,63}}$ — apm rejects it, so the argument never exists — {fname}")
|
|
return
|
|
names.append(s)
|
|
|
|
form = "write each input as `- <name>: \"<description>\"` (primitive-author prompt Must 3)"
|
|
if spec is None:
|
|
return names
|
|
if isinstance(spec, str):
|
|
fail(f"input: is a bare name, not the object form — {form}, so every argument carries its description — {fname}")
|
|
accept(spec)
|
|
elif isinstance(spec, dict):
|
|
fail(f"input: is a map, not the object form — {form} — {fname}")
|
|
for k in spec:
|
|
accept(k)
|
|
elif isinstance(spec, list):
|
|
for item in spec:
|
|
if not isinstance(item, dict):
|
|
fail(f"input entry {item!r} is a bare name, not the object form — {form} — {fname}")
|
|
if isinstance(item, dict):
|
|
if len(item) > 1:
|
|
keys = ', '.join(str(k) for k in item)
|
|
hint = (" — this is the upstream docs example's `- name: x` / `description:` form, which yields arguments [name, description]"
|
|
if 'name' in item else '')
|
|
fail(f"input entry {{{keys}}} is one map with several keys — apm reads every key as an argument name{hint}; write `- <name>: \"<desc>\"` — {fname}")
|
|
for k in item:
|
|
accept(k)
|
|
else:
|
|
accept(item)
|
|
else:
|
|
fail(f"input is neither a name, a list nor a map — apm extracts no arguments from it — {fname}")
|
|
return names
|
|
|
|
|
|
def audit_prompt():
|
|
check_not_linked()
|
|
stem = fname[:-len('.prompt.md')]
|
|
segs = stem.replace('\\', '/').split('/')
|
|
if not stem.strip() or any(s in ('.', '..', '') for s in segs) or '/' in stem.replace('\\', '/'):
|
|
fail(f"name '{stem}' is not a safe path segment — apm's validate_path_segments rejects it — {fname}")
|
|
pkg_root = package_root_for('prompts')
|
|
if pkg_root is None:
|
|
fail(f"is not directly in a .apm/prompts/ directory — that is the authoring source; anything else is either deployed output or a legacy root file — {fname}")
|
|
else:
|
|
dup = os.path.join(pkg_root, fname)
|
|
if os.path.isfile(dup):
|
|
fail(f"name '{stem}' also exists at the package root ({dup}) — both deploy as /{stem}, and they collide — {fname}")
|
|
|
|
content = read_text(target)
|
|
if content is None:
|
|
return
|
|
check_placeholders(content)
|
|
fm, body, ok = split_frontmatter(content)
|
|
if not ok:
|
|
return
|
|
|
|
desc = check_description(fm)
|
|
if desc is not None:
|
|
if len(desc) > PROMPT_DESC_SUGGEST_CHARS:
|
|
suggest(f"description is {len(desc)} characters (> {PROMPT_DESC_SUGGEST_CHARS}) — it is one user-facing sentence (ADR-0029) — {fname}")
|
|
if TRIGGER_RE.search(desc):
|
|
suggest(f"description carries a 'Use when' trigger clause — a prompt is user-triggered (ADR-0029); a trigger clause invites the model to route to it on Claude — {fname}")
|
|
if BOUNDARY_RE.search(desc):
|
|
suggest(f"description carries a 'Not X -> Y' boundary clause — a prompt is user-triggered (ADR-0029, primitive-author prompt Should 6); name the skills it steers instead — {fname}")
|
|
|
|
for camel, kebab in PROMPT_CAMEL_ALIASES.items():
|
|
if camel in fm:
|
|
suggest(f"'{camel}' — use the kebab-case spelling '{kebab}' apm documents — {fname}")
|
|
extra = sorted(str(k) for k in fm if k not in PROMPT_KEYS and k not in PROMPT_CAMEL_ALIASES)
|
|
if extra:
|
|
suggest(f"frontmatter key(s) {', '.join(extra)} are dropped on Claude (it keeps only {', '.join(sorted(PROMPT_KEYS))}) — keep them only if the Copilot-only behaviour is intended — {fname}")
|
|
|
|
declared = prompt_input_names(fm.get('input'))
|
|
used = []
|
|
for m in INPUT_REF_RE.finditer(body):
|
|
if m.group(1) not in used:
|
|
used.append(m.group(1))
|
|
if used and not declared:
|
|
fail(f"body uses {', '.join('${input:' + u + '}' for u in used)} but no input: is declared — apm rewrites references only when input: names them, so Claude receives the literal text — {fname}")
|
|
else:
|
|
for u in used:
|
|
if u not in declared:
|
|
fail(f"body uses ${{input:{u}}} but input: does not declare '{u}' — {fname}")
|
|
for d in declared:
|
|
if d not in used:
|
|
fail(f"input '{d}' is declared but the body never uses ${{input:{d}}} — the user is asked for an argument that goes nowhere — {fname}")
|
|
|
|
if declared and ('argument-hint' in fm or 'argumentHint' in fm):
|
|
suggest(f"argument-hint is set alongside input: — apm synthesises the hint from input: names; drop it unless that form is inadequate — {fname}")
|
|
|
|
|
|
AUDITS = {'hook': lambda: audit_hook(), 'instruction': lambda: audit_instruction(), 'prompt': lambda: audit_prompt()}
|
|
if kind not in AUDITS:
|
|
print(f"Error: unknown primitive kind '{kind}'", file=sys.stderr)
|
|
sys.exit(2)
|
|
try:
|
|
AUDITS[kind]()
|
|
except Exception as exc: # an input shape no check anticipated
|
|
# Exit 1 means findings; a crash means the checks never completed, which
|
|
# is the never-ran tier, not a verdict on the file.
|
|
print(f"Error: the {kind} checks crashed ({type(exc).__name__}: {exc}) and did not complete — {fname}", file=sys.stderr)
|
|
print(" Why: a partial run reported as findings (exit 1) or as clean (exit 0) would be a verdict the checks never reached.", file=sys.stderr)
|
|
print(" Fix: report ### Structure as unverified, and file the input shape against factory-audit's lib-checks-primitive.sh.", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
for s in suggestions:
|
|
print(f"SUGGESTION {s}")
|
|
sys.exit(1 if failed else 0)
|
|
KYBERFORGE_PRIMITIVE
|
|
KYBERFORGE_PRIMITIVE_PY="${KYBERFORGE_PRIMITIVE_PY%$'\n'}"
|