#!/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 modes print findings, skill-size-check.sh has its own top-level # equivalent, and the block is one sourced copy all three share, so each # consumer's own startup stays in its own preamble. 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'(? 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 a references/ file") 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 -> `. # # 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 <: " 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 <