feat(factory-audit): audit hooks, instructions and prompts

factory-audit gains three Step 0 rows and flows for the apm primitives
that have no container of their own: a .json file under hooks/, a
*.instructions.md and a *.prompt.md. apm validates almost none of them
(invalid hook JSON is skipped silently, instruction validate() only
warns, input: names are never checked against ${input:x}), so the
deterministic checks live in a new scripts/lib-checks-primitive.sh,
wired into validate.sh's path-shape detection. Each check and tier
traces to the Authoring checklists in the microsoft-apm research docs.

- Hook: JSON/shape/event-list checks mirroring the Copilot payload
  validator, never-firing event casing, missing/escaping/non-executable
  scripts (FAIL); deprecated filename routing and ${CLAUDE_PLUGIN_ROOT}
  (SUGGESTION).
- Instruction: location, frontmatter, description, body, stem clash
  (FAIL); missing or list applyTo and unread keys (SUGGESTION).
- Prompt: location/name, frontmatter, description, input names, the
  upstream `- name: x` docs bug, declared-vs-used ${input:x} (FAIL);
  ADR-0029 description length and trigger clause, dropped keys,
  camelCase aliases, argument-hint with input (SUGGESTION). Whether a
  prompt carries procedure is judgment in prompt-flow.md, not a script
  heuristic.

Vale now lints *.instructions.md and *.prompt.md with the Kyberforge
style; test-vale-wrap.sh gains their probe rows. New
tests/validate-primitive.bats (31 cases). kyberforge 2.0.1 -> 2.1.0 with
the executables.allow key, catalog 0.5.1 -> 0.5.2, marketplace.json
regenerated.

Refs #94

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 17:02:04 +00:00
parent 701e96d4b3
commit 70210d6a7e
14 changed files with 1062 additions and 29 deletions

View File

@@ -0,0 +1,491 @@
#!/usr/bin/env bash
# lib-checks-primitive.sh — SOURCED, never executed.
#
# The structural check suite for the three apm primitives that have no author
# skill of their own and no SKILL.md-shaped container: 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 and their tiers come from 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.
#
# 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():
# apm's find_files_by_glob rejects symlinks and hardlinks (link count > 1),
# so a linked 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
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$')
# 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')\}')
def extract_script_refs(cmd):
"""Yield (kind, relpath, is_first_token) for each package-relative script
reference in a hook command string. kind is 'root' for a ${*_PLUGIN_ROOT}
token, 'rel' for a leading ./path."""
refs = []
for m in ROOT_TOKEN_RE.finditer(cmd):
start, end = m.start(), m.end()
opened = start > 0 and cmd[start - 1] in '"\''
quote = cmd[start - 1] if opened else None
rest = cmd[end:]
if opened and rest.startswith(quote):
# split-quoted form: "${PLUGIN_ROOT}"/scripts/my\ hook.sh
rest = rest[1:]
path = re.match(r'((?:\\.|[^\s"\'])*)', rest).group(1).replace('\\', '')
elif opened:
path = rest.split(quote, 1)[0]
else:
path = re.match(r'((?:\\.|[^\s"\'])*)', rest).group(1).replace('\\', '')
prefix = cmd[:start - 1] if opened else cmd[:start]
refs.append(('root', path.lstrip('/'), not prefix.strip()))
stripped = cmd.lstrip().lstrip('"\'')
if stripped.startswith('./'):
path = re.match(r'((?:\\.|[^\s"\'])*)', stripped).group(1).replace('\\', '')
refs.append(('rel', path, True))
return refs
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 audit_hook():
check_not_linked()
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}")
if ROUTING_STEM_RE.search(stem):
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
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 claude_shaped and event[0].islower() and event not in CLAUDE_MAPPED_CAMEL:
fail(f"event '{event}' is camelCase in a Claude-shaped file and Claude's map does not rename it — it deploys verbatim 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
for kind_, rel, first in extract_script_refs(cmd):
check_script(kind_, rel, first, 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 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')
if apply_to is None or apply_to == '' or apply_to == []:
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 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(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)
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)
if spec is None:
return names
if isinstance(spec, str):
accept(spec)
elif isinstance(spec, dict):
for k in spec:
accept(k)
elif isinstance(spec, list):
for item in spec:
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
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}")
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(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}")
if kind == 'hook':
audit_hook()
elif kind == 'instruction':
audit_instruction()
elif kind == 'prompt':
audit_prompt()
else:
print(f"Error: unknown primitive kind '{kind}'", 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'}"

View File

@@ -2,13 +2,16 @@
set -euo pipefail
# The ONE entry point for structural validation. It auto-detects whether the
# target is a skill directory or an agent definition file and runs the matching
# check suite; the two suites live in lib-checks-skill.sh and lib-checks-agent.sh
# and are unchanged from the skill-audit / agent-audit scripts they came from.
# target is a skill directory, an agent definition file, or one of the three apm
# primitives with no container of their own (a hook, an instruction or a prompt)
# and runs the matching check suite. The skill and agent suites live in
# lib-checks-skill.sh and lib-checks-agent.sh and are unchanged from the
# skill-audit / agent-audit scripts they came from; the primitive suite lives in
# lib-checks-primitive.sh.
# The ADR-0020 boundary resolver both of them need is sourced once, from
# lib-boundary-resolver.sh, instead of being embedded twice.
#
# Detection never guesses. A target that matches neither shape is a hard exit 2
# Detection never guesses. A target that matches no shape is a hard exit 2
# naming the mismatch, because the alternative — picking a mode and letting the
# suite fail on its own terms — reports a skill-shaped finding about an agent
# file, or the reverse, and sends the reader after the wrong problem.
@@ -115,17 +118,22 @@ _kf_require_lib() {
usage() {
cat <<EOF
Usage: validate.sh <skill-dir | agent-file>
Usage: validate.sh <skill-dir | agent-file | hook-file | instruction-file | prompt-file>
Validate a skill directory against the agentskills.io specification, or an agent
definition file against the agent definition spec. The mode is detected from the
target:
Validate a skill directory against the agentskills.io specification, an agent
definition file against the agent definition spec, or an apm hook, instruction or
prompt file against what apm 0.28.0 actually deploys. The mode is detected from
the target:
skill mode the target is a directory (a skill directory contains SKILL.md),
or the target IS a SKILL.md file.
agent mode the target is a *.agent.md file, or a *.md file whose parent
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 package's root hooks/).
instruction mode the target is a *.instructions.md file.
prompt mode the target is a *.prompt.md file.
Skill mode audits the directory named by <skill-dir>.
@@ -143,7 +151,7 @@ Arguments:
Exit codes:
0 All checks passed (may include SUGGESTIONs)
1 One or more checks failed
2 Nothing was audited (no argument, the target matches neither shape, the
2 Nothing was audited (no argument, the target matches no shape, the
target does not exist, an unrecognized file extension, a missing
references/agent-field-inventory.md, or a missing or unreadable lib-*.sh
beside this script)
@@ -156,7 +164,7 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
fi
if [[ $# -lt 1 ]]; then
echo "Error: a skill directory or an agent file is required." >&2
echo "Error: a skill directory, an agent file, or a hook, instruction or prompt file is required." >&2
echo "" >&2
usage >&2
exit 2
@@ -209,12 +217,21 @@ elif [[ "$TARGET_BASE" == "SKILL.md" ]]; then
TARGET="$(_kf_dirname "$TARGET")"
elif [[ "$TARGET_BASE" == *.agent.md ]]; then
MODE=agent
# The two primitive suffixes are tested before the agents/-parent rule: a
# *.prompt.md or *.instructions.md file is that primitive wherever it sits, and
# the parent-name rule would otherwise claim one that happened to sit in agents/.
elif [[ "$TARGET_BASE" == *.instructions.md ]]; then
MODE=instruction
elif [[ "$TARGET_BASE" == *.prompt.md ]]; then
MODE=prompt
elif [[ "$TARGET_BASE" == *.json && "$TARGET_PARENT" == "hooks" && ! -d "$TARGET" ]]; then
MODE=hook
elif [[ "$TARGET_BASE" == *.md && "$TARGET_PARENT" == "agents" ]]; then
MODE=agent
else
echo "Error: '$TARGET' matches neither a skill directory nor an agent file." >&2
echo " Why: skill mode needs a directory containing SKILL.md (or the SKILL.md itself); agent mode needs a <name>.agent.md file, or a .md file directly under an agents/ directory (.apm/agents, .claude/agents, .github/agents, .copilot/agents). Picking a mode anyway would audit this path against the wrong spec." >&2
echo " Fix: pass one of those two shapes." >&2
echo "Error: '$TARGET' matches no auditable shape." >&2
echo " Why: skill mode needs a directory containing SKILL.md (or the SKILL.md itself); agent mode needs a <name>.agent.md file, or a .md file directly under an agents/ directory (.apm/agents, .claude/agents, .github/agents, .copilot/agents); hook mode needs a .json file directly under a hooks/ directory; instruction and prompt modes need a *.instructions.md or *.prompt.md file. Picking a mode anyway would audit this path against the wrong spec." >&2
echo " Fix: pass one of those shapes." >&2
exit 2
fi
@@ -250,6 +267,13 @@ $KYBERFORGE_RESOLVER_PY
$KYBERFORGE_AGENT_BODY_PY"
python3 -u - "$TARGET" "$SCRIPT_DIR" <<< "$PROG" || RC=$?
;;
hook | instruction | prompt)
_kf_require_lib lib-checks-primitive.sh
# shellcheck source=lib-checks-primitive.sh
. "$SCRIPT_DIR/lib-checks-primitive.sh"
kyberforge_primitive_preflight
python3 -u - "$TARGET" "$MODE" <<< "$KYBERFORGE_PRIMITIVE_PY" || RC=$?
;;
esac
exit "$RC"