#!/usr/bin/env bash set -euo pipefail usage() { cat < Validate a skill directory against the agentskills.io specification. Arguments: skill-dir Path to the skill directory containing SKILL.md. Exit codes: 0 All checks passed (may include SUGGESTIONs) 1 One or more checks failed EOF } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then usage exit 0 fi if [[ $# -lt 1 ]]; then echo "Error: skill-dir is required." >&2 echo "" >&2 usage >&2 exit 1 fi # 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 1 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 1 fi python3 -u - "$1" <<'PYTHON' import sys import os import re import glob import yaml 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. # skill-audit's 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}") # ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER ===== # ONE resolver, embedded VERBATIM in three scripts: # scripts/skill-size-check.sh # plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh # plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh # The block between these markers must stay byte-identical in all three. It is # copied rather than imported because a cache-installed plugin's scripts cannot # read files outside their own plugin directory, so there is no single file all # three can share (same constraint that forces the ADR-0020 constants to be # duplicated). Edit one copy, then paste it over the other two. # # Requires: glob, os, re, yaml (imported by the host script; PyYAML is a hard # dependency, preflighted in bash before the interpreter starts). # --- Input ---------------------------------------------------------------- # Every file this resolver's callers read goes through read_text(), which pins # UTF-8 explicitly instead of inheriting locale.getpreferredencoding(). Under # LC_ALL=C that inherited encoding is ASCII, so a perfectly ordinary em dash in # a SKILL.md aborted the run with a bare UnicodeDecodeError traceback — loud, # but pointing at the interpreter rather than at the file or the fix. A file # that genuinely is not UTF-8 still fails; it just says so. class EncodingError(Exception): pass def read_text(path): """File contents as text, UTF-8, with a diagnostic instead of a traceback.""" try: with open(path, encoding='utf-8') as fh: return fh.read() except UnicodeDecodeError as exc: raise EncodingError( "not valid UTF-8 (%s at byte %d) — re-save the file as UTF-8; " "this gate does not guess at other encodings" % (exc.reason, exc.start)) # --- Universe ------------------------------------------------------------ # The set of names a boundary clause may resolve against is derived from an # AUTHORING ROOT found by walking up FROM THE TARGET FILE. It is NEVER derived # from this script's own location: deriving it from ${BASH_SOURCE} leaked # holocron's 39-skill universe into every consumer repo that ran this hook # through pre-commit, so a consumer skill routing to `skill-audit` resolved # against a plugin it had never installed. # # An authoring root is the nearest ancestor holding plugins/*/.apm/skills/ or # plugins/*/.apm/agents/ (a plugin monorepo), falling back to the nearest # ancestor holding .git. When one is found the universe is: # 1. every skill and agent under /plugins/*/ — sibling plugins resolve, # which is what a monorepo means, # 2. the target's own apm package, # 3. the packages that package DECLARES in apm.yml dependencies.apm. # Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the # root came from the plugins/ probe. They are `apm install` output, gitignored, # and present only on a machine that has run it: four cross-plugin targets in # this repo (gitea-branches -> git-branches, gitea-branches -> git-history, # gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through # .claude/skills/ alone, so the same commit measured 2 dangling targets on a # developer machine and 6 on a fresh clone. A gate shipping hot with no baseline # cannot give two answers. # # Deployed trees ARE used when no plugin monorepo was found — whether the walk # landed on a bare .git ancestor or on nothing at all. That is the consumer # case: the file being checked lives in or beside a deployed tree, inside an # ordinary git repo, with no monorepo to read. The two cases are told apart by # which probe matched, never by how many names a root contributed; see # known_targets(). def _is_fs_root(path): return os.path.dirname(path) == path def _collect_package(pkg_dir, names): """Add every skill/agent name a package directory exposes, any layout.""" # glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`, # `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the # whole pattern into a character class that matches nothing, and the # resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file # in the tree. The wildcards in `sub` are the intended ones and stay raw. safe_dir = glob.escape(pkg_dir) for sub in ('.apm/skills/*/', 'skills/*/'): for path in glob.glob(os.path.join(safe_dir, sub)): names.add(os.path.basename(path.rstrip('/')).lower()) for sub in ('.apm/agents/*.md', 'agents/*.md'): for path in glob.glob(os.path.join(safe_dir, sub)): base = os.path.basename(path) if base.endswith('.agent.md'): base = base[:-len('.agent.md')] else: base = base[:-len('.md')] names.add(base.lower()) def _apm_package_root(start_dir): """Nearest ancestor that is an apm package root (apm.yml or .apm/). The filesystem root is never a candidate: a stray /.apm/skills/ — a scaffolding test's leftover, say, and one really does exist on at least one machine here — would otherwise become the package root of every path on it. Capped at ten levels so a pathological path can't become a filesystem crawl; that covers every real layout by a wide margin. """ current = os.path.abspath(start_dir) for _ in range(10): if _is_fs_root(current): return None if (os.path.isfile(os.path.join(current, 'apm.yml')) or os.path.isdir(os.path.join(current, '.apm'))): return current current = os.path.dirname(current) return None def _authoring_root(start_dir): """Nearest ancestor that is a plugin monorepo, else the nearest .git tree. Returns (root, matched_plugins_probe). The flag reports WHICH probe matched: True for the plugins/*/.apm/{skills,agents} glob, False for the .git fallback and for no match at all. known_targets() needs that distinction — only a real plugins/ root makes the deployed trees redundant, and a name-count delta cannot tell the two apart. Two passes, not one interleaved walk: a nested .git (a submodule, a worktree of a sub-package) must not win over a real plugins/ root further up. Both passes stop before the filesystem root for the same reason _apm_package_root does. """ probes = ( lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills')) or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))), lambda d: os.path.exists(os.path.join(d, '.git'))) for index, probe in enumerate(probes): current = os.path.abspath(start_dir) for _ in range(12): if _is_fs_root(current): break if probe(current): return current, index == 0 current = os.path.dirname(current) return None, False def _collect_authoring_root(root, names): """Every plugin in the monorepo contributes its names.""" for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')): if os.path.isdir(pkg): _collect_package(pkg, names) def _declared_dependency_dirs(pkg_dir): """Directories of the apm packages pkg_dir's manifest DECLARES. Reads dependencies.apm and resolves each entry to a directory on disk: a monorepo-relative `path:` (against the package root and the nearest ancestor manifest, which is the monorepo root) or an installed apm_modules//. Entries that resolve to nothing are skipped — an undeployed dependency contributes no names rather than an error. """ manifest = os.path.join(pkg_dir, 'apm.yml') if not os.path.isfile(manifest): return [] try: data = yaml.safe_load(read_text(manifest)) or {} except Exception: return [] if not isinstance(data, dict): return [] deps = data.get('dependencies') deps = deps.get('apm') if isinstance(deps, dict) else None if not isinstance(deps, list): return [] roots = [pkg_dir] ancestor = os.path.dirname(os.path.abspath(pkg_dir)) for _ in range(10): if _is_fs_root(ancestor): break if os.path.isfile(os.path.join(ancestor, 'apm.yml')): roots.append(ancestor) break ancestor = os.path.dirname(ancestor) found = [] for entry in deps: candidates = [] if isinstance(entry, dict): rel = entry.get('path') name = entry.get('name') if not name and rel: name = os.path.basename(str(rel).rstrip('/')) if rel: candidates.extend(os.path.join(r, str(rel)) for r in roots) if name: candidates.append(os.path.join(pkg_dir, 'apm_modules', str(name))) elif isinstance(entry, str): name = re.split(r'[#@]', entry)[0].strip().rstrip('/').split('/')[-1] if name: candidates.append(os.path.join(pkg_dir, 'apm_modules', name)) candidates.extend(os.path.join(r, 'plugins', name) for r in roots) for candidate in candidates: if os.path.isdir(candidate): found.append(candidate) return found def _deployed_roots(start_dir): """.claude/ and .agents/ trees above start_dir — what a host really sees. Consulted ONLY when no plugin monorepo root was found; see the header. The filesystem root is skipped for the same reason _apm_package_root skips it: a stray /.claude/skills/ must not join every path's universe. """ found = [] current = os.path.abspath(start_dir) for _ in range(10): if _is_fs_root(current): break for name in ('.claude', '.agents'): base = os.path.join(current, name) if os.path.isdir(base): found.append(base) current = os.path.dirname(current) return found def known_targets(start_dir): """Every skill/agent name a boundary clause in start_dir may name.""" names = set() start = os.path.abspath(start_dir) # Siblings: a cache-installed plugin and a deployed .claude/skills/ tree # both put peers one level up, with no plugins/ directory above them. The # grandparent is guarded against the filesystem root exactly like the two # walk-up loops above — for a start dir of /skills/ the grandparent is # `/`, and collecting there picks up this machine's stray /.apm/skills/. parent = os.path.dirname(start) grandparent = os.path.dirname(parent) if (os.path.basename(parent) in ('skills', 'agents') and os.path.isdir(parent) and not _is_fs_root(grandparent)): _collect_package(grandparent, names) package = _apm_package_root(start) if package: _collect_package(package, names) for dep_dir in _declared_dependency_dirs(package): _collect_package(dep_dir, names) # A .git ancestor is an authoring root only if it actually holds plugins. # _authoring_root() falls back to the nearest .git, so it is truthy in ANY # git repo; without the distinction that fallback wins in every consumer # checkout, _collect_authoring_root() contributes nothing, and the deployed # branch below is dead code in the exact case it exists for. So condition # on WHICH probe matched, which _authoring_root() reports directly. A # name-count delta looks equivalent and is not: _collect_authoring_root() # re-collects the checked file's own plugin, whose names the blocks above # already added, so a one-plugin monorepo shows a delta of zero and would # wrongly reach for the deployed trees — including the user's global # ~/.claude/skills, making the verdict depend on what happens to be # installed (ADR-0020 lines 118-127). root, root_has_plugins = _authoring_root(start) if root: _collect_authoring_root(root, names) if not root_has_plugins: for base in _deployed_roots(start): _collect_package(base, names) return names # --- Extraction ----------------------------------------------------------- # False positives are the design constraint here, not recall. The rules: # * A BARE target must be hyphenated AND sit in a boundary sentence (one # carrying "do not"/"instead"/"rather than"/"not for"). Without the second # condition, pc-run's "run pre-commit hooks" reads as a route to a # non-existent `pre-commit` skill. # * A BARE arrow target counts only in ADR-0020's compressed boundary form, # `Not -> `. The example that motivated it is gone: # diagnose's process chain "fix -> regression-test", which without the # gate read as a route to a non-existent `regression-test` skill, was cut # when issue #99 retrofitted that description. So the gate is currently # UNEXERCISED — gating and not gating produce the same verdict corpus-wide. # Keep it anyway. It is a false-positive guard against prose no one has # written yet, and any new process chain re-arms it. Unexercised is not the # same as unnecessary, and the branch it guards is still load-bearing: the # bare-arrow rule is the sole extractor for three real targets in # kyberforge's audit skills (agent-audit -> agent-author, agent-audit -> # skill-audit, skill-audit -> skill-author), all written unbackticked. # * A backticked hyphenated token counts only inside a boundary sentence. # Unconditionally, `pre-push` or `commit-msg` in a TRIGGER clause is a hard # FAIL with no escape hatch. Gating it costs nothing (measured over this # corpus: 54 targets before and after); DELETING it costs 7 real targets # across three gitea skills, so it is gated, not removed. # * SINGLE-WORD targets are deliberately NOT matchable bare — `research`, # `triage`, `forge`, `prototype` and `tdd` are all real skill names and all # ordinary English, so a bare-word rule would flag most of the corpus. A # single-word target must be written `` `forge` `` or /forge to be seen. # That is a known recall limitation, accepted over the false positives. # Tool names (Read/Write/Edit) are excluded by the lowercase-only pattern; MCP # tool names (issue_write) by its rejection of underscores; file names by its # rejection of dots and slashes. # # ATTRIBUTIVE USE. The boundary-sentence gate above does NOT solve the # `pre-commit` false positive, and the comment that claimed it did was wrong: # "instead", "rather than", "do not" and "not for" are exactly the words a # boundary clause uses, so the gate is open precisely where the risk is. All of # these were hard dangling FAILs with no suppression: # Use pre-commit hooks instead of ad-hoc scripts. # Invoke the pull-request template instead of writing one by hand. # Use conventional-commits formatting rather than free-form messages. # Composes label-resolution logic instead of duplicating it. # Do not use for X — run the `pre-push` hooks instead. # What separates every one of them from a real route is grammar, not marking: # the hyphenated token is a compound MODIFIER of the noun that follows it # ("pre-commit hooks", "pull-request template"), where a route target is # terminal — followed by punctuation, a conjunction, or a boundary word. So a # target whose next token is an ordinary lowercase noun is CONFIRM-ONLY: it # still resolves and still counts as a route when the name exists, but it can # never raise a dangling error on its own. # # This is deliberately NOT the simpler "only marked targets may dangle" rule, # which would have been wrong here: BOTH live true positives in this corpus are # BARE — research's "(use neuledge-context)" and gitea-issues' "Composes # gitea-labels-\n milestones", where the `>` fold yields "gitea-labels- # milestones" and the trailing hyphen is what keeps it terminal. Marking is a # poor proxy, so the follower token is the signal, and it is applied to # backticked targets too. # # TERMINAL IS NOT ENOUGH — IN-SENTENCE CORROBORATION. The follower test clears # `pre-push` in the example above only because that example happens to be # followed by the noun "hooks". Move the same token into terminal position and # it was a hard FAIL again, with no suppression mechanism anywhere in this gate: # Do not use for running hooks — run `pre-commit` instead. # Do not use for the commit message — see `commit-msg`. # Do not use for type errors — run `type-check` first. # Instead, use `semantic-release`. # Do not use for the old flow — use the clean-up instead. # Do not run end-to-end, run unit-tests. # Every one of those is grammatically identical to a genuinely broken route: # "route verb + hyphenated name + terminal" is also exactly how prose cites a # tool, a hook, a file format or an English compound. Nothing local separates # them, and the skills most exposed are the ones this contract sends authors # back to rewrite first — pc-run, pc-author, vale-run, vale-config and the apm-* # family are all ABOUT hyphenated tools. # # So the confidence to BLOCK a commit comes from the sentence, not the token: a # prose-form target may raise a hard error only when its own sentence names at # least one OTHER target that RESOLVES. A routing sentence proves itself by # routing somewhere real; a lone unresolvable name proves nothing. That is not a # rule fitted to the fixtures — it is the shape of both live true positives, # which sit beside `write-docs` and `gitea-labels-milestones` respectively, and # it changes this corpus's verdict by exactly nothing. # # An uncorroborated unresolvable target is NOT discarded: every caller reports # it at its SUGGESTION tier, naming the target. The finding stays visible on # every run; only the power to block a commit is withdrawn, which is the part # that had no escape hatch. # # EXPLICIT ROUTE NOTATION is exempt from corroboration and always blocks: # ADR-0020's compressed arrow (`Not -> `) and Claude Code's # invocation form (`/`). Neither is ever how English cites a tool — nobody # writes `-> pre-commit` or `/pre-commit` to mean the hook — so there is no # ambiguity to resolve, and an author who wants a route checked unconditionally # has two ways to say so. # # NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs # still resolve `gitea:gitea-prs`), so the patterns admit an optional # `:` prefix and normalize_target() strips it before resolution. NS = r"(?:[a-z0-9]+(?:-[a-z0-9]+)*:)?" NAME_ANY = NS + r"[a-z0-9]+(?:-[a-z0-9]+)*" NAME_HYPH = NS + r"[a-z0-9]+(?:-[a-z0-9]+)+" ROUTE_VERB = (r"(?:use|uses|using|run|runs|invoke|invokes|invoking|try|see" r"|that'?s|compose|composes|call|calls" r"|routes?\s+to|delegates?\s+to|prefers?|switch(?:es)?\s+to" r"|hands?\s+off\s+to)") MARKED_TARGET = r"(?:`/?(%s)`|(?|→)\s*%s" % MARKED_TARGET, re.I) ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) # A boundary clause takes two shapes and BOTH count: the prose markers, and # ADR-0020's compressed arrow form `Not -> `. BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) # Sentence boundaries decide the CORROBORATION scope above, so getting one wrong # is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two # shapes common in these descriptions defeat the naive "period, space, capital" # rule, in OPPOSITE directions: # OVER-SPLIT. `e.g. "set up the manifest"` ends no sentence, but the quote # looks like one starting. The clause is cut in half, the corroborating # target lands on the far side of the cut, and a genuinely dangling target # silently demotes to SUGGESTION — the gate takes a measurement and then # throws it away, which is the vacuous-green shape this file exists to stop. # UNDER-SPLIT. A real sentence opening with a code span or a lowercase skill # name ("... Composes it. `gitea-prs` also uses it.") is not seen as a start # at all, so two sentences merge and a resolving target vouches for an # unresolvable one it never stood beside — a hard FAIL with no escape hatch, # which is exactly the failure the corroboration rule was added to prevent. # Both are closed here: the five abbreviations that actually occur in routing # prose are excluded as sentence ends, and the opener class admits a backtick or # a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 # SUGGESTION / 2 dangling before and after) — this protects the descriptions # issue #99 is about to rewrite, not the ones already measured. SENTENCE_SPLIT = re.compile( u'(? name` (ADR-0020's compressed boundary form, passed in by the caller that matched the arrow). A backticked name does NOT qualify — a code span is how a tool, a file and a skill are all cited, so it carries no intent the follower test hasn't already read. """ return arrow or (start > 0 and text[start - 1] == '/') def _add(out, text, name, start, end, strict=None, arrow=False): if not name: return out.append((name, _terminal(text, end) if strict is None else strict, _notation(text, start, arrow))) def _scan(text, route_re, cont_re, out): for match in route_re.finditer(text): name, start, end = _first(match) if not name: continue _add(out, text, name, start, end) # "use git-history or git-branches instead" / "use gitea-issues / # gitea-prs" — keep consuming conjoined targets after the first. pos = match.end() while True: cont = cont_re.match(text, pos) if not cont: break _add(out, text, *_first(cont)) pos = cont.end() def _extract_sentence(sentence): """[(name, may_dangle, notation)] for the routing targets in ONE sentence. Kept separate from _extract() because corroboration is scoped to a single sentence: a target's evidence is what stands beside it, not what the rest of the description happens to mention. """ out = [] boundary = bool(BOUNDARY_MARKER.search(sentence)) _scan(sentence, ROUTE_ANY if boundary else ROUTE_MARKED, CONT_ANY if boundary else CONT_MARKED, out) for match in ARROW_MARKED.finditer(sentence): # `-> name` and `-> /name` are route notation, not prose: nothing # reads as a compound modifier after an arrow, so no follower test. _add(out, sentence, *_first(match), strict=True, arrow=True) for match in ARROW_BOUNDARY.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1), strict=True, arrow=True) if boundary: for match in BACKTICK.finditer(sentence): _add(out, sentence, match.group(1), match.start(1), match.end(1)) return out def _extract(description): """[(name, may_dangle, notation)] for every routing target.""" out = [] for sentence in SENTENCE_SPLIT.split(description): out.extend(_extract_sentence(sentence)) return out def boundary_targets(description): """Every routing target, for reporting and for confirming a route.""" return sorted({name for name, _, _ in _extract(description)}) def unresolved_targets(description, known): """Targets resolving to nothing, split into (blocking, reported). `blocking` earns a hard error; `reported` is SUGGESTION tier — named on every run, never fatal. Three conditions gate the promotion, and all of them are documented at length in the ATTRIBUTIVE USE and CORROBORATION notes above: 1. the target must be terminal, not a compound modifier ("pre-commit hooks" is prose about a tool, not a route), 2. it must be written in route notation (`/name`, `-> name`), OR 3. its own sentence must name another target that DOES resolve. Everything else is reported and left alone. `known` is the resolved universe from known_targets(); passing an empty set is not meaningful — callers check for that first and decline out loud instead. """ blocking, reported = set(), set() for sentence in SENTENCE_SPLIT.split(description): found = _extract_sentence(sentence) resolved = {normalize_target(name) for name, _, _ in found if normalize_target(name) in known} for name, may_dangle, notation in found: key = normalize_target(name) if key in known or not may_dangle: continue if notation or (resolved - {key}): blocking.add(name) else: reported.add(name) return sorted(blocking), sorted(reported - blocking) # --- Frontmatter ---------------------------------------------------------- # Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading # blank line, trailing whitespace after either `---`, or CRLF line endings all # defeated the old `^---\n(.*?)\n---`, and the miss was SILENT: every ADR-0020 # check was skipped and the file reported green (measured: a 550-character # description with a 1,000-word body exited 0 behind a BOM). A file that cannot # be measured must never report green, so every caller of these two ERRORs on a # miss instead of moving on. # # The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`. # YAML block-scalar content must be indented deeper than its key, so an # indented `---` inside a folded description is CONTENT; letting it close the # frontmatter truncated the description mid-value and silently reclassified the # rest as body, which is a vacuous green in both directions at once. Leading # whitespace is still tolerated on the OPENING marker, where no such content # can exist. FRONTMATTER_RE = re.compile( r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL) def strip_bom(text): return text[1:] if text.startswith(u'') else text class FrontmatterError(Exception): pass def description_value(fm_text): """The description VALUE, with YAML folding resolved. PyYAML is a HARD requirement, preflighted in bash. The hand-rolled fallback this replaced diverged from a real parser across the FAIL boundary — one corpus description measured 270 characters parsed and 412 unparsed, and a quoted `"description"` key or an explicit `description: null` returned empty from it, silently skipping the description AND routing checks. A gate that disagrees with itself depending on which reader ran is worse than no gate. This is the ONLY reader any of the three scripts may use to decide whether a description is present. A line regex cannot: `description:` with no value followed by `model: sonnet` lets `\\s*` cross the newline and captures the NEXT key, which reads as a non-empty description, skips the "missing or empty" failure, and then early-returns out of every ADR-0020 gate on the genuinely empty folded value. That combination exited 0 with zero output on a BLOCKING pre-push gate. """ try: data = yaml.safe_load(fm_text) except Exception as exc: # Every FrontmatterError message is a COMPLETE clause, never a detail a # caller wraps in one. Callers used to prefix a hard-coded "frontmatter # is not valid YAML (...)", which is true only of this branch: the two # type failures below come from frontmatter that parsed fine, and # telling their author the YAML is invalid sends them hunting for a # syntax error that is not there — on a blocking gate with no baseline. raise FrontmatterError('frontmatter is not valid YAML (%s)' % re.sub(r'\s+', ' ', str(exc)).strip()) if not isinstance(data, dict): raise FrontmatterError('frontmatter is not a YAML mapping') value = data.get('description') if value is None: return '' if not isinstance(value, str): # NOT str()-coerced. `description: true` became the 4-character "True" # and sailed through the 400-character gate; a list or mapping was # measured as its Python repr. Neither is a description a host can # preload, so this is a parse failure, reported as one. raise FrontmatterError( 'description is a %s, not a string' % type(value).__name__) return re.sub(r'\s+', ' ', value).strip() # --- Body-shape checks (skills only; agents have no references/ dir) ------- # Deterministic and countable, so they are enforced here. Whether a given # gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why # both gotcha checks are SUGGESTION tier. A missing reference file is not a # style opinion — it is a broken pointer — so that one is ERROR tier. # # Both read a FENCE-MASKED copy of the body. Scanning the raw body made a # ```-fenced example a hard ERROR — and the skills most likely to carry one are # skill-author and skill-audit, which DOCUMENT the references/ convention — and # let a `## Gotchas` heading inside a fenced block stand in for the real # section. Masking preserves every byte offset (content becomes spaces, # newlines stay), so a span found in the mask slices the original. GOTCHA_MAX_ENTRIES = 5 GOTCHA_MAX_BODY_FRACTION = 0.25 # The heading has to BE "Gotchas", not merely contain the word: `## Gotcha # handling` and `## Why gotchas matter` are prose sections, and treating one as # the Gotchas section measured a span that was never a gotcha list. GOTCHA_HEADING = re.compile(r'^(#{1,6})[ \t]+(?:[^\n]*?[ \t])?gotchas?[ \t]*:?[ \t]*$', re.I | re.M) # Column 0 only. `^[ \t]{0,3}` counted a two-space-indented CHILD bullet as a # top-level entry, so a five-entry section with sub-bullets reported nine. GOTCHA_ENTRY = re.compile(r'^(?:[-*+]|\d+[.)])[ \t]+', re.M) FENCE_OPEN = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})') REFERENCE_POINTER = re.compile( r'(?= len(fence) and not stripped.strip()[len(marker):].strip()): fence = None # An UNCLOSED fence has no cost-free answer, only a choice of which way to # be wrong. Masking to end-of-body blanks the rest of the body, silently # disabling the ERROR-tier references/ check and the gotcha counts. # Returning the raw text instead exposes the unclosed example's own # content, so a fenced example naming a nonexistent references/ file # becomes a hard ERROR it would not have been had the fence been closed — # confirmed, not hypothetical. The loud-false-positive direction is the one # chosen: this script's rule is that a file it cannot measure must never # report green, and masking-onward is exactly that failure. Both outcomes # need an already-malformed file, and the false positive costs one fence. if fence is not None: return text return ''.join(out) def gotcha_stats(body): """(entry count, section word count) for the first Gotchas section, or None. The section runs to the next heading at the same level or shallower. Entries are top-level list items; a section written as subheadings instead of a list counts those. Headings and entries are read from the fence mask; the word count is taken from the original slice, because fenced lines are real body words and the fraction is measured against the whole body. """ masked = mask_fenced(body) match = GOTCHA_HEADING.search(masked) if not match: return None level = len(match.group(1)) rest = masked[match.end():] nxt = re.search(r'^#{1,%d}[ \t]+' % level, rest, re.M) end = match.end() + (nxt.start() if nxt else len(rest)) section = masked[match.end():end] entries = len(GOTCHA_ENTRY.findall(section)) if entries == 0 and level < 6: entries = len(re.findall(r'^#{%d,6}[ \t]+' % (level + 1), section, re.M)) return entries, len(body[match.end():end].split()) def missing_reference_pointers(body, skill_dir): """references/.md named in the body but absent from disk.""" masked = mask_fenced(body) missing = set() for match in REFERENCE_POINTER.finditer(masked): start = masked.rfind('\n', 0, match.start()) + 1 end = masked.find('\n', match.end()) if end < 0: end = len(masked) if REFERENCE_PAST.search(masked[start:end]): continue if REFERENCE_QUALIFIER.search(masked[start:match.start()]): continue if not os.path.isfile(os.path.join(skill_dir, 'references', match.group(1))): missing.add('references/' + match.group(1)) return sorted(missing) # ===== END ADR-0020 SHARED BOUNDARY RESOLVER ===== # 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) # --- 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 README.md") elif dlen > DESC_SUGGEST_CHARS: 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") 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 -> `. if desc: if has_boundary_clause(desc): ok("description has a boundary clause") else: 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") # --- 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('|') def interactive_reads(source): hits = [] prev_line = '' for line in source.splitlines(): stripped = line.strip() 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-audit's Step 4 `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) PYTHON