Files
holocron/plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-checks-skill.sh
Defame1297 620f20b0fd refactor(kyberforge)!: merge skill-audit and agent-audit into factory-audit
Why

The two audit skills carried 1,724 lines of byte-identical duplication: the ADR-0020 boundary
resolver (1,061), vale-wrap.sh (526), the Vale style rules (44) and the Contributing-files parser
(93). Nothing shared them — they were held in sync by a 413-line pre-push gate and its 797-line
test suite. Sync-by-gate had already failed once: at 484357a the two parser copies drifted into
different spellings of the bullet loop while a docstring asserted they were identical. That drift
was behaviour-neutral and was re-unified by hand at 598a7c3, so the copies were identical at merge
time — but nothing had caught it, and the next drift need not be neutral.

Implementation Notes

Self-containment binds BETWEEN skills, not within one. The agentskills.io spec forbids reaching
across skill directories, which is why two separate skills needed embedded copies; two files inside
ONE skill may source a third. That is the whole reason the merge removes duplication rather than
relocating it.

The union of both bodies measured 1,532 words against BODY_MAX_WORDS=900, and only 211 of those
words were shared, so SKILL.md is a dispatch body. Step 0 resolves the flow from the target path
before any validation, and its table mirrors validate.sh's detection exactly: a directory holding
SKILL.md or a SKILL.md file (skill); a *.agent.md, or a .md directly under an agents/ directory
(agent); anything else stops without running a validator. Steps 1-3 live in
references/skill-flow.md and references/agent-flow.md, and gotchas that apply to one flow live in
that flow's file, since it is loaded on every invocation anyway. If validate.sh reports on the
other artifact type, the body restarts at Step 0.

Named factory-audit rather than forge-audit because forge is a live skill, and a family prefix that
matches a live sibling reads as ownership rather than membership.

The description carries one arrow per boundary target, because ADR-0020 resolves only the first
target after an arrow. It drops the quoted "audit this skill"-style phrases, which restated
"audited" in a second register (ADR-0020's duplicate-register rule). 241 characters, Gotchas 16%
of the body: no size SUGGESTIONs.

The boundary resolver stays embedded in two files rather than imported: a cache-installed plugin
cannot read outside its own directory, and the repo-root hook resolves via .pre-commit-hooks.yaml
where entry[0] is the only token pre-commit rewrites, so no single file is reachable by both.
tests/test-adr0020-contract.sh hashes both copies for byte-identity, and asserts validate.sh sources
the resolver and that no third copy exists.

The entry scripts classify the target from its resolved parent directory, so a bare agent filename
typed inside agents/ works; resolve SCRIPT_DIR CDPATH-safely; and exit 2 when a lib-*.sh is
missing, rather than dying with exit 1, the tier the flows relay as real findings.

The provenance run functions stash their findings code in KYBERFORGE_PROV_RC and
return 0, so validate-provenance.sh calls them UNTESTED. Testing a function's
status (`f || RC=$?`) disables errexit for its entire body, and no subshell or
`set -e` inside can re-arm it once the call sits in a condition context
(measured, both spellings). Their error paths use `exit`, which is unaffected
either way; this keeps errexit armed for anything added later.

Case 0's readability guard reads the file instead of asking `[[ -r ]]`. `-r` is
access(2), which answers yes for uid 0 even on a mode-000 file, and this repo's
dev environment is root -- so the guard could never fire where it exists to fire.
A read attempt is also the stricter question, catching EIO. This is the reasoning
scripts/check-vale-style-sync.sh carried before this commit deleted it; the
hazard did not go with it.

All three entry scripts are CDPATH-safe, vale-wrap.sh included: both of its cd sites are cleared,
the --config resolution and the directory-mirror walk, where an exported CDPATH would otherwise
print a decoy path into the -print0 stream and build the mirror from the decoy's files. The two
remaining bare cd calls take absolute paths, which CDPATH is never consulted for.

Impact

BREAKING: skill-audit and agent-audit no longer exist as invocable skills. kyberforge goes to
2.0.0 (catalog 0.4.7).

Check logic is unchanged: differential runs of the old and new validators across every skill and
agent produced byte-identical stdout, stderr and exit codes, and the reconstructed Python payloads
differ only in comments and the references/field-inventory.md -> agent-field-inventory.md rename.
One doctrine governs the tiers: exit 0 is audited and clean, exit 1 is audited with findings OR a
target present but unreadable, exit 2 is that nothing was audited at all. Edge paths DID change,
deliberately (full table in ADR-0025):
- a missing target exits 2 (never ran), not 1, under its own "does not exist" message; detection is
  by path shape, so a shape-matching path that is simply absent used to reach the validator and come
  back as a FAIL against a file that never existed;
- an unshaped target exits 2 under the generic "matches neither" message, and a directory with no
  SKILL.md under a third, distinct one -- three exit-2 messages, not one;
- a dangling symlink or a symlink loop stays exit 1: it is present but broken, which is a finding
  about the artifact rather than a usage error;
- a SKILL.md file path is audited as its skill directory instead of refused;
- a .md agent outside an agents/ directory is refused rather than audited;
- a missing script library, a missing python3, a missing PyYAML, and no argument at all each exit 2.
  validate-provenance.sh already exited 2 for the last two; validate.sh now matches it.

.pre-commit-hooks.yaml is a published contract consumed by external repos. Both hook IDs and both
files: regexes are unchanged; only entry: and description: moved.

scripts/check-vale-style-sync.sh (413), scripts/sync-vale-styles.sh (21),
tests/test-check-vale-style-sync.sh (797) and agent-audit/scripts/README.md (47) are deleted. The
checker made 17 assertions: 6 compared the two Vale copies and are moot; 10 are rehomed into
tests/test-vale-wrap.sh (case 0, cases 28-31, and the suite's Vale-absent skip); and the
cross-manifest files: agreement check, which selected hooks by entry: and so could not survive both
hooks sharing one, is ported as case 33 pairing hooks by id:. Cases 28, 30 and 33 carry mutation
self-tests; narrowing the local skill prefilter to 6 of 38 SKILL.md files now fails the suite.

Skills go 39 to 38. Pre-push goes 9 repo-authored hooks to 8.

ADR: 0025
BREAKING-CHANGE: the skill-audit and agent-audit skills are removed. Both flows are served by
  factory-audit, which auto-detects whether it was handed a skill directory or an agent file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
2026-09-16 09:13:57 +00:00

622 lines
30 KiB
Bash
Executable File

#!/usr/bin/env bash
# lib-checks-skill.sh — SOURCED, never executed.
#
# skill-audit's structural check suite: everything in its validate.sh that is
# NOT the ADR-0020 shared boundary resolver, lifted verbatim and split at the
# resolver's markers. validate.sh reassembles
#
# $KYBERFORGE_SKILL_PREAMBLE_PY
# $KYBERFORGE_RESOLVER_PY (from lib-boundary-resolver.sh)
# $KYBERFORGE_SKILL_BODY_PY
#
# in that order — the order the resolver block sat in the original file — and
# feeds the result to python3, so every check runs against the same names it
# always did.
#
# The dimension vocabulary, the message wording and the PASS/FAIL/SUGGESTION/
# INFO tiers here are skill-audit's and are deliberately NOT reconciled with
# lib-checks-agent.sh's. The two suites disagree on purpose: a skill body is
# loaded into the caller's context, an agent body becomes the system prompt of
# a fresh one, so ADR-0020 gives skills a body word budget and agents none.
#
# Consumed by: validate.sh, skill mode.
# shellcheck shell=bash
# shellcheck disable=SC2034
kyberforge_skill_preflight() {
# PyYAML is a HARD dependency, not a nice-to-have. The description VALUE has to
# be measured after YAML folding is resolved, and the hand-rolled reader that
# used to stand in for PyYAML disagreed with it across the 400-character FAIL
# boundary — same description, two verdicts, depending on which reader ran.
# Refusing to start is the only honest option; the repo's jq / apm / vale
# dependencies are declared the same way.
# Check the interpreter separately from the library: `python3 -c` fails the same
# way whether python3 is missing or PyYAML is, and reporting the wrong missing
# dependency sends the reader to install the wrong thing.
if ! command -v python3 > /dev/null 2>&1; then
echo "Error: python3 is required but was not found on PATH." >&2
echo " Why: skipping the ADR-0020 description, body and boundary-target gates would be a vacuous pass." >&2
echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2
# Exit 2, the never-ran tier: no check ran, so this is not a findings result.
# lib-provenance-*.sh has always exited 2 here; this matches it.
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: skipping the ADR-0020 description, body and boundary-target gates would be a vacuous pass." >&2
echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2
# Exit 2, the never-ran tier: a missing hard dependency is not a findings result.
exit 2
fi
}
IFS='' read -r -d '' KYBERFORGE_SKILL_PREAMBLE_PY <<'KYBERFORGE_SKILL_PREAMBLE' || true
import sys
import os
import re
import glob
import yaml
# Output is UTF-8 for the same reason input is: under LC_ALL=C the streams
# default to ASCII, and this script's own message text carries em dashes (the
# ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash
# from the read to the write — a UnicodeEncodeError raised while PRINTING, after
# every check has already run, which loses the whole report and (here) flips a
# clean exit 0 into a traceback and an exit 1. read_text() in the shared
# resolver block below pins the reads; this pins the writes.
#
# Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: the two
# validate.sh copies print findings, skill-size-check.sh has its own top-level
# equivalent, and tests/test-adr0020-contract.sh hashes that block for
# byte-identity across all three.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding='utf-8')
except AttributeError: # pragma: no cover — Python < 3.7
pass
skill_dir = os.path.abspath(sys.argv[1])
skill_md = os.path.join(skill_dir, "SKILL.md")
if not os.path.isfile(skill_md):
print(f"Error: '{skill_md}' not found.", file=sys.stderr)
sys.exit(1)
failed = False
suggestions = []
def ok(msg):
print(f"PASS {msg}")
def fail(msg):
# stderr, matching scripts/skill-size-check.sh's ERROR routing. All three
# scripts in the ADR-0020 family now agree: findings that fail the run go to
# stderr, everything advisory (PASS / SUGGESTION / INFO) goes to stdout.
# Both repo callers capture `2>&1`, so nothing a human reads moves.
global failed
print(f"FAIL {msg}", file=sys.stderr)
failed = True
def suggest(msg):
# SUGGESTIONs are printed after every check and NEVER touch the exit code.
# factory-audit's SKILL.md Step 4 report counts them into its
# `PASS (N suggestions)` result line, which is what makes the ADR-0020 SUGGESTION tier visible
# rather than another silently-ignored warning (ADR-0013).
suggestions.append(msg)
def info(msg):
# A check that DECLINED to run says so out loud, rather than passing
# silently. Silence is what let a whole gate family go missing unnoticed.
print(f"INFO {msg}")
KYBERFORGE_SKILL_PREAMBLE
KYBERFORGE_SKILL_PREAMBLE_PY="${KYBERFORGE_SKILL_PREAMBLE_PY%$'\n'}"
IFS='' read -r -d '' KYBERFORGE_SKILL_BODY_PY <<'KYBERFORGE_SKILL_BODY' || true
# A leading BOM is stripped before anything is parsed or counted. It changes
# neither count below — it is not a line separator and str.split() does not
# treat it as whitespace — but it did defeat the frontmatter match.
try:
content = strip_bom(read_text(skill_md))
except EncodingError as exc:
fail(f"SKILL.md is {exc}. Nothing downstream can be measured, so this is a "
f"hard failure, not a skip")
print("One or more checks failed.")
sys.exit(1)
# --- Parse frontmatter ---
fm_match = FRONTMATTER_RE.match(content)
if not fm_match:
fail("No parseable YAML frontmatter block found. Expected a `---` line, the "
"fields, then a closing `---` line (a BOM, leading blank lines, trailing "
"spaces after either marker and CRLF endings are all tolerated). Nothing "
"downstream can be measured, so this is a hard failure, not a skip")
print("One or more checks failed.")
sys.exit(1)
fm = fm_match.group(1)
body_start = fm_match.end()
# Extract name. The character class is `[ \t]`, never `\s`: under re.MULTILINE
# a `\s*` after the colon crosses the newline, so a valueless `name:` followed
# by `description: ...` captured the NEXT KEY as the name and reported a
# mismatch instead of an absence. Same class of bug as the `description:` one
# the shared resolver's description_value() docstring records.
name_m = re.search(r'^name:[ \t]*(\S+)', fm, re.MULTILINE)
name = name_m.group(1).strip('"\'') if name_m else ""
# Extract description — the VALUE, with YAML folding resolved. Most of this
# corpus writes descriptions as `>`-folded block scalars, so the raw lines
# carry indentation and newlines that are not part of the value: every length
# measurement below is wrong unless the scalar is folded first.
try:
desc = description_value(fm)
except FrontmatterError as exc:
# `exc` carries the whole clause — invalid YAML, a non-mapping block, or a
# description of the wrong type. Do not prefix a diagnosis here; the last
# one named a syntax error for two failures that have none.
fail(f"{exc}. Nothing downstream can be measured, so this is a hard "
f"failure, not a skip")
print("One or more checks failed.")
sys.exit(1)
dir_name = os.path.basename(skill_dir)
# ADR-0020's hand-invocation carve-out (issue #108). `disable-model-invocation:
# true` takes the skill out of the model-visible listing entirely, so the
# trigger/capability/boundary rules and the 250-character routing target do not
# apply to it — the audit's own references/skill-description-quality.md Step 0 says
# so, and until this line existed no check here knew the field existed. What the
# flag does NOT lift: the body word budget and the 400-character description
# ceiling. See the shared resolver's hand_invoked().
by_hand = hand_invoked(fm)
# --- Checks ---
# name present
if name:
ok(f"name present: '{name}'")
else:
fail("name field is missing or empty")
# name matches directory
if name and dir_name:
if name == dir_name:
ok(f"name '{name}' matches directory '{dir_name}'")
else:
fail(f"name '{name}' does not match directory '{dir_name}'")
# name length
if name:
if len(name) <= 64:
ok(f"name length {len(name)} chars (limit: 64)")
else:
fail(f"name '{name}' is {len(name)} chars — exceeds 64-character limit")
# name format
if name:
if re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name):
ok("name format valid (kebab-case)")
else:
fail(f"name '{name}' is invalid — use lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens")
# description present
if desc:
ok("description present")
else:
fail("description field is missing or empty")
# description length — agentskills.io spec backstop. UNCHANGED by ADR-0020:
# 1024 is the specification's hard limit, and the ADR-0020 budget gate below
# sits underneath it rather than replacing it.
if desc:
dlen = len(desc)
if dlen <= 1024:
ok(f"description length {dlen} chars (agentskills.io spec limit: 1024)")
else:
fail(f"description length {dlen} chars — exceeds 1024-character limit")
# Unfilled placeholder detection — matches FILL IN: followed by actual content,
# but not backtick-quoted references like `FILL IN:` used in instructions.
PLACEHOLDER_RE = re.compile(r'(?<!`)FILL IN:[^`\n]')
# description contains unfilled placeholder
if desc and PLACEHOLDER_RE.search(desc):
fail("description still contains 'FILL IN:' placeholder — replace before shipping")
else:
if desc:
ok("description has no unfilled placeholders")
# --- ADR-0022: metadata.version is mandatory -------------------------------
# FAIL, not SUGGESTION, and the tier is set by the gate rather than by taste.
# `.pre-commit-config.yaml`'s `skill-size-check` hook REJECTS a SKILL.md with
# no `metadata.version`, and rejects a value that is not three-part semver.
# skill-author's Step 4 says to run this audit and "resolve every FAIL", so any
# tier below FAIL lets that step report done on a skill the commit gate then
# refuses — the same audit-disagrees-with-the-gate failure the MAX_LINES note
# below warns about, arrived at from the other direction. Verified before this
# check existed: a SKILL.md with no `metadata:` block at all reported "All
# checks passed".
#
# The rule is DUPLICATED from that hook for the same cache-isolation reason as
# every other constant here — an installed plugin's scripts cannot read the
# repo-root config. Keep the two in step: this check must accept exactly what
# the hook accepts.
SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$')
try:
fm_data = yaml.safe_load(fm)
except Exception:
# Unreachable in practice: description_value() above parses the same text
# and hard-exits on a YAML error, so anything arriving here already parsed.
fm_data = None
metadata_block = fm_data.get('metadata') if isinstance(fm_data, dict) else None
if not isinstance(metadata_block, dict) or metadata_block.get('version') is None:
fail("frontmatter has no metadata.version — ADR-0022 makes it mandatory for "
"every skill, and the skill-size-check pre-commit hook rejects the file "
"without it. Add `metadata:` / ` version: \"1.0.0\"` (new skills start "
"at \"0.1.0\")")
else:
version_value = metadata_block['version']
# NOT str()-coerced blind: `version: 1.0` is a YAML float, and its "1.0"
# spelling is exactly the two-part value the hook rejects — coercing and
# then matching keeps this check and the hook agreeing on that case.
version_text = version_value if isinstance(version_value, str) else str(version_value)
version_text = version_text.strip()
if SEMVER_RE.match(version_text):
ok(f"metadata.version present: '{version_text}' (ADR-0022)")
else:
fail(f"metadata.version '{version_text}' is not three-part semver — the "
f"skill-size-check pre-commit hook rejects it. Use MAJOR.MINOR.PATCH, "
f"e.g. \"1.0.0\"")
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
# outside the plugin directory once the plugin is cache-installed, so there is
# no single source to share. Keep the two in sync by hand: if they drift, this
# audit will report a skill ready to ship that the commit hook then rejects.
MAX_LINES = 500
# Word-count proxy for the ~5,000-token ceiling, calibrated to the densest
# prose in the corpus (7.22 chars/word): 2770 words is ~20,000 characters,
# ~5,000 tokens at 4 characters per token. See skill-size-check.sh's header
# for the full measurement.
MAX_WORDS = 2770
# ADR-0020 context-budget gates. DUPLICATED from scripts/skill-size-check.sh
# for exactly the same cache-isolation reason as MAX_LINES/MAX_WORDS above, and
# carrying the same warning — tests/test-skill-size-check.sh asserts the copies
# agree, so drift fails CI instead of shipping an audit that disagrees with the
# commit hook. lib-checks-agent.sh, beside this file, holds a third copy of the
# two description constants; per ADR-0020 agents take the description gates and
# deliberately take NO body word gate, because an agent body becomes the system
# prompt of a fresh context rather than competing with a live conversation.
#
# These are NOT the same measurements as MAX_LINES/MAX_WORDS and must not be
# unified with them: MAX_WORDS counts the WHOLE FILE including frontmatter and
# is a spec-conformance backstop; BODY_MAX_WORDS counts the body ONLY and is a
# quality gate. Likewise the 1024-character description limit above is the
# agentskills.io spec ceiling and stays exactly as it is — DESC_MAX_CHARS sits
# underneath it.
DESC_SUGGEST_CHARS = 250
DESC_MAX_CHARS = 400
BODY_SUGGEST_WORDS = 600
BODY_MAX_WORDS = 900
line_count = len(content.splitlines())
if line_count <= MAX_LINES:
ok(f"SKILL.md line count {line_count} (limit: {MAX_LINES})")
else:
fail(f"SKILL.md line count {line_count} — exceeds {MAX_LINES}-line limit")
# str.split() with no argument splits on runs of whitespace, matching the
# `wc -w` the hook uses, and counts the whole file including frontmatter.
word_count = len(content.split())
if word_count <= MAX_WORDS:
ok(f"SKILL.md word count {word_count} (limit: {MAX_WORDS}, proxy for ~5,000 tokens)")
else:
fail(f"SKILL.md word count {word_count} — exceeds {MAX_WORDS}-word limit (proxy for ~5,000 tokens)")
body = content[body_start:]
# --- ADR-0020: description budget -----------------------------------------
if desc:
dlen = len(desc)
if dlen > DESC_MAX_CHARS:
fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character "
f"ADR-0020 ceiling. It is preloaded into every session whether or not the "
f"skill is invoked. Keep a trigger clause, at most one capability clause, "
f"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body or README.md")
elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character "
f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is "
f"what moves the corpus average; the FAIL tier only stops outliers")
elif by_hand:
ok(f"description length {dlen} chars (hand-invoked: the {DESC_SUGGEST_CHARS}-character "
f"routing target does not apply, the {DESC_MAX_CHARS}-character ceiling still does)")
else:
ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})")
# --- ADR-0020: body budget -------------------------------------------------
# Counts the BODY ONLY — everything after the closing --- of the frontmatter.
# This is a different measurement from MAX_WORDS above, which counts the whole
# file including frontmatter as a spec-conformance backstop. Both are reported.
body_word_count = len(body.split())
if body_word_count > BODY_MAX_WORDS:
fail(f"SKILL.md body is {body_word_count} words — exceeds the {BODY_MAX_WORDS}-word "
f"ADR-0020 ceiling (body only; separate from the {MAX_WORDS}-word whole-file "
f"limit above). Move lookup tables, spec restatements, output schemas, templates "
f"and rationale prose to references/ behind an explicit "
f"\"If X, read `references/file.md`\" trigger. At two or more mutually exclusive "
f"flows, dispatch is mandatory: the body carries the dispatch table and the gates "
f"common to every branch, each flow gets its own self-contained references/ file")
elif body_word_count > BODY_SUGGEST_WORDS:
suggest(f"SKILL.md body is {body_word_count} words — over the {BODY_SUGGEST_WORDS}-word "
f"ADR-0020 target (hard fail at {BODY_MAX_WORDS})")
else:
ok(f"SKILL.md body word count {body_word_count} (ADR-0020 target: {BODY_SUGGEST_WORDS})")
# --- Reference pointers must exist -----------------------------------------
# FAIL, not SUGGESTION: a dispatch table naming a references/ file that is not
# on disk is a hard break, and until this check existed nothing in the
# gate/audit/vale stack noticed it — all three exited 0.
missing_refs = missing_reference_pointers(body, skill_dir)
for ref in missing_refs:
fail(f"SKILL.md body points at {ref}, which does not exist on disk — a dispatch "
f"table or \"read X\" trigger naming a missing file sends the agent nowhere")
if not missing_refs:
ok("all referenced references/ files exist")
# --- Gotchas discipline -----------------------------------------------------
# SUGGESTION on both counts: the measurement is deterministic, but whether a
# given gotcha earns its place in the body is the auditor's judgment.
gotchas = gotcha_stats(body)
if gotchas is not None:
gotcha_entries, gotcha_words = gotchas
if gotcha_entries > GOTCHA_MAX_ENTRIES:
suggest(f"Gotchas section has {gotcha_entries} entries — over the "
f"{GOTCHA_MAX_ENTRIES}-entry guideline. A list that long is usually a "
f"missing references/ file or a design problem written up as a warning")
if body_word_count and gotcha_words > body_word_count * GOTCHA_MAX_BODY_FRACTION:
suggest(f"Gotchas section is {gotcha_words} of {body_word_count} body words "
f"({round(100.0 * gotcha_words / body_word_count)}%) — over the "
f"{round(100.0 * GOTCHA_MAX_BODY_FRACTION)}% guideline. Move the durable "
f"parts to references/ and keep the section for live traps")
# --- ADR-0020: boundary clause present -------------------------------------
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
# this particular skill warrants a boundary clause is judgment. Both accepted
# shapes count — the prose markers and the compressed `Not <thing> -> <name>`.
#
# THREE outcomes, not two: "no boundary clause" and "boundary clause I could not
# parse" are different findings, and reporting the first for the second sends
# the author hunting for a problem that is not there (issue #110).
#
# Skipped entirely for a hand-invoked skill — the contract gives it one plain
# sentence with no boundary clause, so the finding would be wrong and its remedy
# names a router that cannot see the skill (issue #108).
if desc and by_hand:
ok("hand-invoked (disable-model-invocation) — the boundary-clause and trigger "
"rules do not apply; audited as one plain human-facing sentence")
elif desc:
status = boundary_clause_status(desc)
if status == 'present':
ok("description has a boundary clause")
elif status == 'absent':
suggest("description has no boundary clause — add the prose form (\"Do not use "
"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
"so the router knows where NOT to send this skill")
else:
suggest("description has an arrow boundary clause (\"Not X -> y\") from which no "
"target could be read, so the dangling-target check did not run on it — "
"the clause is PRESENT and unparsed, not missing. Most often the target is "
"a single word, which is deliberately not matchable bare because "
"`research`, `triage` and `forge` are all ordinary English: write it as "
"`name` or /name")
# One arrow, one target. A second name after the same arrow is resolved by
# nothing and reported by nothing, so the clause claims coverage it does not
# have and this script printed "1 of 1 boundary target(s) resolve" on a
# clause naming two (issue #107).
for first, second in multi_target_arrow_clauses(desc):
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
f"'{second}') and only the first is resolved — the second is checked by "
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
f"Not Y -> {second}.\"")
# --- ADR-0020: resolvable boundary targets ---------------------------------
# The resolution universe comes from the SKILL's own location: the authoring
# root above it (every sibling plugin in the monorepo), its own apm package, and
# the packages that package declares in apm.yml dependencies.apm. It is never
# derived from this script's own path, and — when an authoring root exists — it
# never reads a deployed .claude/ tree, so a fresh clone and a machine that has
# run `apm install` return the same verdict. See the shared resolver's header.
if desc:
routing_targets = boundary_targets(desc)
known = known_targets(skill_dir) if routing_targets else set()
if routing_targets and not known:
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
f"determined for this path (no authoring root above it, no apm package "
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
f"tree). Unchecked target(s): {', '.join(routing_targets)}")
elif routing_targets:
# blocking vs reported: a target only earns a FAIL when it is written in
# route notation or its own sentence corroborates it by naming another
# target that resolves. See the shared resolver's CORROBORATION note.
unresolved, soft = unresolved_targets(desc, known)
for target in unresolved:
fail(f"description routes to '{target}', which resolves to no skill or agent "
f"in this monorepo, in this package, or in a package it declares in "
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
f"target sends the router nowhere")
for target in soft:
suggest(f"description routes to '{target}', which resolves to no skill or agent "
f"in this monorepo, in this package, or in a package it declares in "
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
f"else in that sentence resolves, so it is equally likely to be a tool, a "
f"file format or an English compound. If it IS a route, write it as "
f"`/{target}` or `-> {target}` and it will be checked properly")
if not unresolved:
# Counts the targets that ACTUALLY resolve, not every target found:
# a confirm-only target (one used attributively — see the resolver's
# ATTRIBUTIVE USE note) is exempt from the failure above, so
# reporting it as resolved would be a false claim.
resolved = [t for t in routing_targets if normalize_target(t) in known]
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
f"{', '.join(resolved) if resolved else '(none)'}")
# Body unfilled placeholders
fill_matches = PLACEHOLDER_RE.findall(body)
if fill_matches:
fail(f"SKILL.md body contains {len(fill_matches)} unfilled 'FILL IN:' placeholder(s)")
else:
ok("SKILL.md body has no unfilled placeholders")
# Interactive prompt heuristic.
#
# A line-initial `read` only blocks an agent when its stdin is the terminal.
# These forms never touch a TTY and are ordinary data plumbing, so flagging
# them is a false positive — one that has already cost two authors a
# contorted rewrite of working source:
#
# read -r MODE ROOT <<< "$WALK_OUTPUT" here-string
# read -r X <<EOF here-doc
# read -r line < "$file" redirect from a file
# printf '%s' "$v" | piped stdin — the pipe ends the
# read -r X PREVIOUS line, not this one
#
# So a `read` is reported only when it has neither a stdin redirection on its
# own line nor a pipe terminating the previous logical line. `read -r ANSWER`,
# `read -p "..." X` and a bare `read` still fail, which is the case the check
# exists for.
def stdin_redirected(line, prev_line):
# Quoted spans are stripped first so a `<` inside a prompt string is not
# mistaken for a redirect: `read -p "enter <name>: " X` is interactive and
# must still fail.
unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line)
return '<' in unquoted or prev_line.rstrip().endswith('|')
# A here-doc body is DATA, not command position. Every script in this corpus
# carries a `usage() { cat <<EOF ... EOF; }`, and prose wrapped inside one puts
# ordinary English at the start of a line — "read is reported as an INFO ..."
# in this skill's own validate-provenance.sh, which made the skill audit
# hard-FAIL on its own script. Reflowing that one sentence would have cleared the finding
# and left the cause: every future usage text is one wrap away from the same
# false positive, and the remedy an author reaches for is contorting working
# source, which the note above records has already happened twice.
#
# Detection is deliberately conservative in the direction that matters. A
# here-doc body is skipped only when its terminator is actually found further
# down the file; an opener with no terminator — the shape a stray `<<` inside a
# string would produce — is ignored rather than allowed to swallow the tail,
# because swallowing the tail is a false NEGATIVE and this check exists to fail
# closed. `<<<` here-strings open nothing and are excluded by the lookbehind.
HEREDOC_START_RE = re.compile(r'(?<!<)<<-?\s*(["\']?)([A-Za-z_][A-Za-z0-9_]*)\1')
def heredoc_delimiter(line):
"""The here-doc terminator this line opens, or None."""
m = HEREDOC_START_RE.search(line)
return m.group(2) if m else None
def heredoc_body_indices(lines):
"""Line indices that are here-doc BODY (plus its terminator), not code."""
skip = set()
i, n = 0, len(lines)
while i < n:
stripped = lines[i].strip()
delim = None if stripped.startswith('#') else heredoc_delimiter(lines[i])
if delim:
# `<<-` allows an indented terminator, so compare stripped.
for j in range(i + 1, n):
if lines[j].strip() == delim:
skip.update(range(i + 1, j + 1))
i = j
break
i += 1
return skip
# The here-doc exemption applies to the `read` heuristic ONLY, and the
# asymmetry is the point. `read` is an ordinary English verb, so any prose a
# script prints is one line-wrap away from opening with it. `input(` is not a
# word — a line beginning `input(` inside a here-doc is an embedded Python
# program pausing for a keypress, which is exactly what this check is for, and
# these scripts embed Python in a here-doc as a matter of course. Exempting the
# whole body would have disarmed the check across every script in the corpus.
def interactive_reads(source):
hits = []
prev_line = ''
lines = source.splitlines()
in_heredoc = heredoc_body_indices(lines)
for idx, line in enumerate(lines):
stripped = line.strip()
if idx in in_heredoc:
if re.match(r'input\(', stripped):
hits.append(stripped)
continue
if re.match(r'read(\s|$)', stripped):
if not stdin_redirected(line, prev_line):
hits.append(stripped)
elif re.match(r'input\(', stripped):
hits.append(stripped)
# Blank lines and comments cannot carry the pipe that feeds a
# following `read`, so they never displace the previous line.
if stripped and not stripped.startswith('#'):
prev_line = line
return hits
# Scripts checks
scripts_dir = os.path.join(skill_dir, "scripts")
if os.path.isdir(scripts_dir):
scripts = [f for f in os.listdir(scripts_dir)
if os.path.isfile(os.path.join(scripts_dir, f)) and not f.endswith('.md')]
for fname in scripts:
fpath = os.path.join(scripts_dir, fname)
try:
sc = read_text(fpath)
except EncodingError as exc:
# The executable-bit check below still runs — one unreadable byte
# must not silently drop a second, independent check.
sc = None
fail(f"scripts/{fname}: {exc} — it could not be scanned for "
f"interactive prompts")
interactive = interactive_reads(sc) if sc is not None else []
if interactive:
fail(f"scripts/{fname}: may use interactive input "
f"(read/input from a terminal detected): {interactive[0]}")
elif sc is not None:
ok(f"scripts/{fname}: no interactive prompts detected")
# Executable bit
if os.access(fpath, os.X_OK):
ok(f"scripts/{fname}: is executable")
else:
fail(f"scripts/{fname}: not executable — run: chmod +x {fpath}")
# Summary
print()
for s in suggestions:
print(f"SUGGESTION {s}")
if suggestions:
print()
if not failed:
if suggestions:
# Feeds SKILL.md Step 4's `PASS (N suggestions)` result line. A
# SUGGESTION never changes the exit code — only a FAIL does.
print(f"All checks passed ({len(suggestions)} suggestion(s)).")
else:
print("All checks passed.")
sys.exit(0)
else:
print("One or more checks failed.")
sys.exit(1)
KYBERFORGE_SKILL_BODY
KYBERFORGE_SKILL_BODY_PY="${KYBERFORGE_SKILL_BODY_PY%$'\n'}"