#!/usr/bin/env bash set -euo pipefail # Two independent gate families live in this script. Do not conflate them. # # 1. SPEC CONFORMANCE (MAX_LINES / MAX_WORDS, unchanged). Enforces # agentskills.io's skill-authoring.md guidance: keep SKILL.md within 500 # lines and roughly 5,000 tokens, so the full body doesn't crowd out # conversation history and other active skills once loaded into context. # MAX_WORDS counts the WHOLE FILE, frontmatter included. # # 2. CONTEXT BUDGET (ADR-0020: description chars, body-only words, resolvable # boundary targets). A skill's name + description is preloaded into every # agent's context every session whether or not the skill is ever invoked; # the body is loaded only on invocation, and then competes with the caller's # live conversation. Those are different costs with different ceilings, so # they get their own numbers and their own measurements. # # The two families measure different things on purpose and neither replaces the # other: 2,770 whole-file words is a conformance backstop, 900 body-only words # is a quality gate, and a file can sit well inside one while failing the other. # # Vale can't express any of this (its checks operate on text patterns, not raw # file size), so this is a plain script instead of a Vale rule. # # Both spec ceilings are inclusive: a file at exactly MAX_LINES or MAX_WORDS # passes, and only one past it fails. That matches # skill-audit/scripts/validate.sh, which has always used `line_count <= 500` as # its pass condition — the two previously disagreed at exactly 500 lines, so a # SKILL.md could pass its own audit and still be blocked by the commit hook. # The ADR-0020 ceilings are inclusive the same way. # # Token counts aren't computed exactly here — a whitespace word count is used # as a proxy (Python's str.split(), the same primitive # skill-audit/scripts/validate.sh applies to these two constants; `wc -w` # disagrees with it on Unicode separators, which is why the awk pass that used # to live in the loop below is gone). Measured over this repo's 39 in-scope SKILL.md files, characters per # word runs min 5.97 / median 6.79 / mean 6.77 / max 7.22. At the standard # ~4-characters-per-token English approximation that is 1.49 / 1.70 / 1.69 / # 1.81 tokens per word. # # MAX_WORDS=2770 is therefore calibrated to the corpus WORST case rather than # its median: 2770 words at the densest observed 7.22 chars/word is ~20,000 # characters, or ~5,000 tokens at the 4-characters-per-token approximation. So # what this gate guarantees is "under 5,000 tokens even for the densest prose # the corpus has produced" — the earlier median-calibrated MAX_WORDS=2900 let # such a file sit at exactly the ceiling and still spend ~5,240 tokens. A # median-density file at 2770 words spends ~4,700 tokens, so typical prose # gives up ~130 words of headroom to close that gap. The largest SKILL.md in # the repo is 2,760 words whole-file (skill-author), twelve words under the # ceiling — this is a gate two files have already grown into, not headroom. # # It is a one-sided proxy in the useful direction — nothing under the word # ceiling is wildly over the token ceiling — but it is not exact BPE # tokenization and does not replace one. Re-measure the corpus before treating # any of these numbers as still current. # # python3 AND PyYAML are required for the ADR-0020 half, and both are hard # dependencies rather than best-effort: python3 because pre-commit (which is how # this script runs) is itself a Python application, and PyYAML because the # hand-rolled folding reader that used to cover its absence disagreed with a # real parser across the FAIL boundary. Two readers that measure the same # description differently is worse than one reader that refuses to start. # These constants are intentionally duplicated in # skill-audit/scripts/validate.sh (Python) rather than shared from one file: # this script is a standalone bash pre-commit hook, that one is an in-skill # Python validator invoked in a different context (same rationale as # vale-wrap.sh's per-plugin duplication — see its own header comment). # tests/test-skill-size-check.sh asserts both files agree on these values, so # drift between them fails CI rather than silently diverging. # # The ADR-0020 constants below are duplicated the same way and carry the same # warning: skill-audit/scripts/validate.sh holds a second copy of # DESC_SUGGEST_CHARS / DESC_MAX_CHARS / BODY_SUGGEST_WORDS / BODY_MAX_WORDS, # and agent-audit/scripts/validate.sh holds a third copy of the two # description constants (agents take the description gates and, per ADR-0020, # deliberately take NO body word gate). If they drift, this audit reports a # skill ready to ship that the commit hook then rejects. MAX_LINES=500 MAX_WORDS=2770 # ADR-0020 context-budget gates. SUGGESTION does not fail; FAIL does. DESC_SUGGEST_CHARS=250 DESC_MAX_CHARS=400 BODY_SUGGEST_WORDS=600 BODY_MAX_WORDS=900 FAIL=0 for f in "$@"; do # NOT a silent skip — see the matching note on the Python side. A broken # symlink named SKILL.md is storable in git and a directory named SKILL.md # reaches this hook the same way; both used to make the whole run exit 0 with # no output at all, which is the one thing this script must never do. if [[ ! -f "$f" ]]; then if [[ -d "$f" ]]; then why="is a directory, not a file" elif [[ -L "$f" ]]; then why="is a symlink that does not resolve to a file" elif [[ -e "$f" ]]; then why="is not a regular file" else why="does not exist" fi echo "ERROR: $f $why, so the line and word ceilings could not be measured. A path this hook was handed and could not read does not get to pass in silence." >&2 FAIL=1 continue fi # The MAX_LINES / MAX_WORDS ceilings are NOT measured here. They used to be, # in a single awk pass, and that pass was wrong twice over: # * `read -r lines words <<< "$(awk ...)"` discarded awk's exit status, so a # file awk could not read yielded empty variables, bash arithmetic read # them as 0, and both ceilings passed in total silence — the one outcome # this script forbids itself. # * awk's NR/NF do not agree with the Python splitlines()/split() that # skill-audit/scripts/validate.sh uses for the SAME two constants. # splitlines() also breaks on \x0b \x0c \x1c \x1d \x1e \x85 U+2028 U+2029 # and split() on every Unicode space, so a body padded with U+2028 read as # 6 lines here and 606 lines there — hook green, audit FAIL. # One implementation now owns both: the Python block below already reads every # file (with a real diagnostic on failure), so it counts there. done if ! command -v python3 > /dev/null 2>&1; then echo "ERROR: python3 is required for the ADR-0020 description/body/boundary-target gates but was not found on PATH." >&2 echo " Why: skipping them would be a vacuous pass — the hook would go green having checked only the spec ceilings." >&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 for the ADR-0020 description/body/boundary-target gates but is not importable by python3." >&2 echo " Why: 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. Falling back would make the verdict depend on which reader ran." >&2 echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2 exit 1 fi if ! python3 -u - \ "$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \ "$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$MAX_LINES" "$@" <<'PYTHON' import glob import os import re import sys import yaml DESC_SUGGEST_CHARS = int(sys.argv[1]) DESC_MAX_CHARS = int(sys.argv[2]) BODY_SUGGEST_WORDS = int(sys.argv[3]) BODY_MAX_WORDS = int(sys.argv[4]) MAX_WORDS = int(sys.argv[5]) MAX_LINES = int(sys.argv[6]) files = sys.argv[7:] failed = False def error(msg): global failed failed = True print("ERROR: %s" % msg, file=sys.stderr) def suggest(msg): # stdout, not stderr, and never touches the exit code. The hook is declared # `verbose: true` in .pre-commit-config.yaml so this actually reaches a # human — pre-commit prints nothing at all for a passing hook otherwise, # which is the exact way ADR-0013 records Vale warnings going invisible. print("SUGGESTION: %s" % msg) def info(msg): # A check that DECLINED to run says so out loud. The one thing this script # must never do is stay quiet about a measurement it did not take. print("INFO: %s" % 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 -> `. Without that, diagnose's process chain # "fix -> regression-test" reads as a route to `regression-test`. # * 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 ===== # --- Per-file checks ------------------------------------------------------ for path in files: if not os.path.isfile(path): # NOT a silent skip. A broken symlink named SKILL.md is storable in git, # so pre-commit really can hand one to this hook, and a directory named # SKILL.md reaches it the same way — both used to exit 0 with zero # output, which is precisely the "stay quiet about a measurement it did # not take" failure this script forbids itself two screens up. if os.path.isdir(path): why = "is a directory, not a file" elif os.path.islink(path): why = "is a symlink that does not resolve to a file" elif os.path.exists(path): why = "is not a regular file" else: why = "does not exist" error("%s: %s, so none of the ADR-0020 gates could run on it. A path " "this gate was handed and could not read does not get to pass in " "silence." % (path, why)) continue try: raw = read_text(path) except EncodingError as exc: error("%s: %s. Neither the spec line/word ceilings nor any of the " "ADR-0020 gates could run on this file." % (path, exc)) continue # SPEC CONFORMANCE (family 1). Whole file, frontmatter included, counted # with the SAME primitives skill-audit/scripts/validate.sh uses for these # two constants — see the note in the bash loop above for what the previous # awk pass got wrong. lines = len(raw.splitlines()) words = len(raw.split()) if lines > MAX_LINES: error("%s has %d lines, exceeding the %d-line ceiling " "(agentskills.io skill-authoring.md)" % (path, lines, MAX_LINES)) if words > MAX_WORDS: error("%s has %d words (proxy for tokens), exceeding the %d-word ceiling " "(~5,000 tokens, agentskills.io skill-authoring.md)" % (path, words, MAX_WORDS)) content = strip_bom(raw) fm_match = FRONTMATTER_RE.match(content) if not fm_match: error("%s: no parseable YAML frontmatter block. 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). None of the ADR-0020 gates could run on this " "file — a file that cannot be measured does not get to pass." % path) continue try: desc = description_value(fm_match.group(1)) 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. error("%s: %s. None of the ADR-0020 gates could run on this file." % (path, exc)) continue body = content[fm_match.end():] skill_dir = os.path.dirname(os.path.abspath(path)) # An absent or empty description is an ERROR here too, not a silent skip. # All three ADR-0020 scripts have to agree on this input: the description is # the one field preloaded into every session, so a SKILL.md that ships # without one is the worst case the contract exists for, and a gate that # merely declines to measure it reports green. if not desc: error("%s: description field is missing or empty. It is the only part of a " "skill preloaded into every session, so a skill without one can never " "be routed to — and none of the ADR-0020 description or boundary gates " "have anything to measure." % path) if desc: dlen = len(desc) if dlen > DESC_MAX_CHARS: error("%s: description is %d characters, exceeding the %d-character ceiling " "(ADR-0020). It is preloaded into every session. Keep a trigger clause, " "at most one capability clause, and a boundary clause; move capability " "enumeration, output-format detail, composition notes and implementation " "detail to the body or README.md." % (path, dlen, DESC_MAX_CHARS)) elif dlen > DESC_SUGGEST_CHARS: suggest("%s: description is %d characters, over the %d-character target " "(ADR-0020, hard fail at %d)." % (path, dlen, DESC_SUGGEST_CHARS, DESC_MAX_CHARS)) body_words = len(body.split()) if body_words > BODY_MAX_WORDS: error("%s: body is %d words, exceeding the %d-word ceiling (ADR-0020). This counts " "the body ONLY — it is a separate measurement from the %d-word whole-file " "spec ceiling above. Move lookup tables, spec restatements, output schemas, " "templates and rationale prose to references/ behind an explicit " "\"If X, read references/file.md\" trigger." % (path, body_words, BODY_MAX_WORDS, MAX_WORDS)) elif body_words > BODY_SUGGEST_WORDS: suggest("%s: body is %d words, over the %d-word target (ADR-0020, hard fail at %d)." % (path, body_words, BODY_SUGGEST_WORDS, BODY_MAX_WORDS)) # Reference pointers must exist. ERROR, not SUGGESTION: a dispatch table # naming a file that is not on disk is a hard break, and nothing else in # the gate/audit/vale stack notices it. for ref in missing_reference_pointers(body, skill_dir): error("%s: body points at %s, which does not exist on disk. A dispatch " "table or \"read X\" trigger naming a missing file sends the " "agent nowhere." % (path, ref)) # Gotchas discipline. SUGGESTION on both counts: the measurement is # deterministic, but whether a given gotcha earns its place is judgment. stats = gotcha_stats(body) if stats is not None: entries, section_words = stats if entries > GOTCHA_MAX_ENTRIES: suggest("%s: Gotchas section has %d entries, over the %d-entry guideline. " "A list that long is usually a missing references/ file or a design " "problem written up as a warning." % (path, entries, GOTCHA_MAX_ENTRIES)) if body_words and section_words > body_words * GOTCHA_MAX_BODY_FRACTION: suggest("%s: Gotchas section is %d of %d body words (%d%%), over the %d%% " "guideline. Move the durable parts to references/ and keep the " "section for live traps." % (path, section_words, body_words, round(100.0 * section_words / body_words), round(100.0 * GOTCHA_MAX_BODY_FRACTION))) # Missing boundary clause. SUGGESTION, not ERROR: detecting the absence is # deterministic, but whether this particular skill warrants one is the # auditor's call. Both accepted shapes count — the prose markers and # ADR-0020's compressed `Not -> ` arrow. if desc and not has_boundary_clause(desc): suggest("%s: description has no boundary clause (ADR-0020). Add the prose form " "(\"Do not use for X — use `y` instead\") or the compressed form " "(\"Not X -> y\") so the router knows where NOT to send this skill." % path) targets = boundary_targets(desc) if targets: known = known_targets(skill_dir) if known: blocking, reported = unresolved_targets(desc, known) for target in blocking: error("%s: description routes to '%s', which does not resolve to a skill " "or agent in this monorepo, in this package, or in a package it " "declares in apm.yml dependencies.apm (ADR-0020). A boundary clause " "that names a non-existent target sends the router nowhere." % (path, target)) for target in reported: suggest("%s: description routes to '%s', which does not resolve to a skill " "or agent in this monorepo, in this package, or in a package it " "declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather " "than a hard failure because nothing else in the sentence resolves, " "so this is equally likely to be a tool, a file format or an English " "compound. If it IS a route, write it as `/%s` or `-> %s` and it will " "be checked properly." % (path, target, target, target)) else: info("%s: boundary-target resolution DID NOT RUN — no skill universe " "could be determined for this path (no authoring root above it, no " "apm package root, no declared apm dependencies, no deployed " ".claude/ or .agents/ tree). Unchecked target(s): %s" % (path, ", ".join(targets))) sys.exit(1 if failed else 0) PYTHON then FAIL=1 fi exit $FAIL