#!/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 the Authoring checklists at the end of # plugins/kyberforge/docs/research/docs/microsoft-apm/{hooks,instructions,prompt}-primitive-schema.md, # which trace each rule to the apm source that makes it matter, except where # references/{hook,instruction,prompt}-flow.md documents a deliberate deviation # (a tier moved, or a check the research 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 two ADR-0029 SUGGESTIONs below — 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 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): # /.apm// -> . 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$') # 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'} 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"\']+)') def _is_first(prefix): return not prefix.strip().strip('"\'').strip() 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, where): """Yield (kind, relpath, is_first_token) for each package-relative script reference apm would rewrite, reading the command exactly as apm does. kind is 'root' for a ${*_PLUGIN_ROOT} token, 'rel' 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}}/\" — {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"). spaced = nxt.isspace() and path.endswith('\\') if not spaced and opener is not None and nxt.isspace(): quoted = cmd[start:].split(opener, 1)[0] spaced = 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('/'), _is_first(prefix))) masked = masked[:start] + ' ' * (end - start) + masked[end:] for m in APM_REL_REF_RE.finditer(masked): start = m.start() ref = m.group(1) if start > 0 and masked[start - 1] == '.': fail(f"script path '..{ref[1:]}' starts with ../ — apm reads it as ./{ref[2:]} from the hook directory, not the parent, so the wrong file (or none) is bundled; reference it as ${{PLUGIN_ROOT}}/ — {where}") continue 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(('rel', ref[2:].replace('\\', '/'), _is_first(prefix))) return refs SCRIPT_EXT_RE = re.compile(r'\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|ps1|rb|pl)$', re.IGNORECASE) def first_token(cmd): m = re.match(r'\s*(["\']?)((?:\\.|[^\s"\'])*)\1', cmd) return m.group(2).replace('\\', '') if m else '' 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. tok = first_token(cmd) if not tok or tok.startswith('./') or '$' in tok or tok.startswith('~'): return if tok.startswith('/'): real_root = os.path.realpath(pkg_root) inside = os.path.realpath(tok).startswith(real_root + os.sep) if inside or SCRIPT_EXT_RE.search(tok): 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}}/ — {where}") return 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}") return def check_script(kind_, rel, first, pkg_root, where): if not rel: 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: fail(f"script '{rel}' does not exist in the package — apm only warns, then deploys a hook that fails every time it fires — {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}") 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 def targets_claude(): """Whether apm renders this package's hooks to Claude. Mirrors parse_targets_field: no target:/targets: (or no apm.yml) means every target, and 'all' folds to every target. An unreadable apm.yml is treated as every target, the reading that keeps the stricter check on.""" path = owning_apm_yml() if path is None: return True try: with open(path, encoding='utf-8') as f: data = yaml.safe_load(f) except (OSError, UnicodeDecodeError, yaml.YAMLError): return True if not isinstance(data, dict): return True raw = data.get('targets', data.get('target')) if raw is None: return True 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 = [t for t in tokens if t] return not tokens or 'claude' in tokens or 'all' in tokens def audit_hook(): check_not_linked(hardlinks=False) stem = fname[:-len('.json')] hooks_dir = os.path.basename(parent_dir) if hooks_dir != 'hooks': fail(f"is not directly in a hooks/ directory — apm discovers hook files only at .apm/hooks/*.json and hooks/*.json, non-recursively — {fname}") if os.path.basename(os.path.dirname(parent_dir)) == '.apm': pkg_root = os.path.dirname(os.path.dirname(parent_dir)) else: pkg_root = os.path.dirname(parent_dir) 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}") # 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 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 # 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): 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: 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 # 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}") # 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. camel_checked = claude_shaped or targets_claude() for event in events: if not event.strip(): fail(f"empty event name — {fname}") elif not any(c.isupper() for c in event): fail(f"event '{event}' is all-lowercase — no target maps it and apm never warns, so it silently never fires — {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: 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 refs = extract_script_refs(cmd, where) for kind_, rel, first in refs: check_script(kind_, rel, first, pkg_root, where) if not any(first for _, _, first in refs): 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 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 -> ` (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 `- : \"\"` (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 `- : \"\"` — {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 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'}"