#!/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 — word count (`wc -w`) is used as # a proxy. 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 is required for the ADR-0020 half. That is not a new dependency in # practice: pre-commit, which is how this script runs, is itself a Python # application. PyYAML is used when importable and is genuinely optional — the # fallback reader below recognises the frontmatter shapes this corpus uses. # 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 [[ -f "$f" ]] || continue # Single awk pass computes both line count and word count, avoiding a # second read of the file. NR counts the final line even without a # trailing newline, matching Python's splitlines() semantics (used by # skill-audit/scripts/validate.sh for its own line count) — `wc -l` # undercounts by 1 in that case. Word count uses awk's default # whitespace-splitting NF, matching `wc -w` semantics. read -r lines words <<< "$(awk '{w += NF} END{print NR, w+0}' "$f")" if (( lines > MAX_LINES )); then echo "ERROR: $f has $lines lines, exceeding the $MAX_LINES-line ceiling (agentskills.io skill-authoring.md)" >&2 FAIL=1 fi if (( words > MAX_WORDS )); then echo "ERROR: $f has $words words (proxy for tokens), exceeding the $MAX_WORDS-word ceiling (~5,000 tokens, agentskills.io skill-authoring.md)" >&2 FAIL=1 fi 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 # REPO_ROOT is passed in so the boundary-target resolver has one guaranteed # place to look for the authoring source (plugins/*/.apm/skills/), independent # of the cwd pre-commit happens to invoke this hook from. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" if ! python3 -u - \ "$REPO_ROOT" "$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \ "$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$@" <<'PYTHON' import glob import os import re import sys repo_root = sys.argv[1] DESC_SUGGEST_CHARS = int(sys.argv[2]) DESC_MAX_CHARS = int(sys.argv[3]) BODY_SUGGEST_WORDS = int(sys.argv[4]) BODY_MAX_WORDS = int(sys.argv[5]) MAX_WORDS = 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) # --- Frontmatter / description / body ------------------------------------ # The description VALUE must be measured after YAML folding is resolved: this # corpus writes most descriptions as `>`-folded block scalars, so the raw lines # carry indentation and newlines that are not part of the value. Parse, don't # regex the raw text. def normalize(value): return re.sub(r'\s+', ' ', value).strip() def fold_description_fallback(fm_text): """Resolve `description:` without PyYAML. Not a YAML parser — it recognises exactly the shapes this corpus uses: an inline scalar (optionally quoted, optionally continued on following indented lines) and a `>`/`|` block scalar with optional indentation and chomping indicators. """ lines = fm_text.splitlines() for i, line in enumerate(lines): m = re.match(r'^description:[ \t]*(.*)$', line) if not m: continue head = m.group(1).strip() block = bool(re.match(r'^[>|][0-9]*[-+]?$|^[>|][-+]?[0-9]*$', head)) parts = [] if block else [head] for nxt in lines[i + 1:]: if not nxt.strip(): parts.append('') continue if not re.match(r'^[ \t]', nxt): break parts.append(nxt.strip()) value = ' '.join(parts) if not block: value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in '"\'': value = value[1:-1] return value return '' def extract_description(fm_text): try: import yaml data = yaml.safe_load(fm_text) if isinstance(data, dict): value = data.get('description') if isinstance(value, str): return normalize(value) if value is not None: return normalize(str(value)) return '' except Exception: pass return normalize(fold_description_fallback(fm_text)) # --- Boundary-target resolution ------------------------------------------ # A description's boundary clause names another skill (or an agent — agents are # legitimate routing targets: git-workflow routes to git-orchestrate). Resolve # every named target against the AUTHORING SOURCE, plugins/*/.apm/skills/ and # plugins/*/.apm/agents/, so the check works offline and before `apm install` # has deployed anything into .claude/skills/. def known_targets(start_dir): names = set() # Sibling skills/agents: covers a cache-installed plugin and a deployed # .claude/skills/ tree, neither of which has a plugins/ directory above it. parent = os.path.dirname(os.path.abspath(start_dir)) if os.path.basename(parent) == 'skills' and os.path.isdir(parent): for entry in os.listdir(parent): if os.path.isdir(os.path.join(parent, entry)): names.add(entry) agents_dir = os.path.join(os.path.dirname(parent), 'agents') if os.path.isdir(agents_dir): for entry in os.listdir(agents_dir): if entry.endswith('.agent.md'): names.add(entry[:-len('.agent.md')]) elif entry.endswith('.md'): names.add(entry[:-len('.md')]) # Walk up looking for a monorepo root (plugins/*/.apm/) or a plugin root # (.apm/). Capped so a pathological path can't turn this into a filesystem # crawl; ten levels covers every real layout by a wide margin. current = os.path.abspath(start_dir) for _ in range(10): # Never glob the filesystem root: a stray /.apm/skills/ (a scaffolding # test's leftover, say) would otherwise become part of every skill's # resolution universe on that machine. if os.path.dirname(current) == current: break for pattern in ('plugins/*/.apm/skills/*/', '.apm/skills/*/'): for path in glob.glob(os.path.join(current, pattern)): names.add(os.path.basename(path.rstrip('/'))) for pattern in ('plugins/*/.apm/agents/*.agent.md', '.apm/agents/*.agent.md'): for path in glob.glob(os.path.join(current, pattern)): names.add(os.path.basename(path)[:-len('.agent.md')]) current = os.path.dirname(current) return names NAME_ANY = r"[a-z0-9]+(?:-[a-z0-9]+)*" NAME_HYPH = 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)") # A "marked" target is unambiguous on its own: backticked (`git-commits`) or # slash-command form (/skill-improve). A bare target is just a hyphenated word # and is only read as a routing target inside a boundary sentence — otherwise # "run pre-commit hooks" would be reported as a dangling route to `pre-commit`. MARKED_TARGET = r"(?:`/?(%s)`|(?|→)\s*%s" % MARKED_TARGET) # ADR-0020's compressed boundary form, `Not -> .`, with a # bare target. Anchored on "not" so diagnose's process arrow chain # ("fix -> regression-test") is not mistaken for a route. ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH) BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) SENTENCE_SPLIT = re.compile(u'(?<=[.!?])\\s+(?=[A-Z"“(])') # Tool names (Read/Write/Edit) are excluded by construction: the name pattern is # lowercase-only. MCP tool names (issue_write, pull_request_write) are excluded # by construction too: the pattern admits no underscores. File names are # excluded because the pattern admits no dots or slashes inside the name. def _first(groups): for g in groups: if g: return g return None def _scan(text, route_re, cont_re, out): for m in route_re.finditer(text): name = _first(m.groups()) if not name: continue out.append(name) # "use git-history or git-branches instead" / "use gitea-issues / # gitea-prs" — keep consuming conjoined targets after the first. pos = m.end() while True: cm = cont_re.match(text, pos) if not cm: break nxt = _first(cm.groups()) if nxt: out.append(nxt) pos = cm.end() def boundary_targets(desc): out = [] for sentence in SENTENCE_SPLIT.split(desc): boundary = bool(BOUNDARY_MARKER.search(sentence)) _scan(sentence, ROUTE_ANY if boundary else ROUTE_MARKED, CONT_ANY if boundary else CONT_MARKED, out) for m in ARROW_MARKED.finditer(sentence): name = _first(m.groups()) if name: out.append(name) for m in ARROW_BOUNDARY.finditer(sentence): out.append(m.group(1)) out.extend(BACKTICK.findall(sentence)) return sorted(set(out)) # --- Per-file checks ------------------------------------------------------ for path in files: if not os.path.isfile(path): continue with open(path) as fh: content = fh.read() fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) if not fm_match: # A missing frontmatter block is skill-frontmatter's / validate.sh's # finding, not this hook's — nothing to measure, so say nothing. continue desc = extract_description(fm_match.group(1)) body = content[fm_match.end():] 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)) targets = boundary_targets(desc) if targets: # Two roots, unioned: the file's own directory (which finds siblings in # a cache install and walks up to a monorepo root in a checkout), and # this script's own repo root (which is authoritative when pre-commit # hands over a relative path from an unrelated cwd). known = known_targets(os.path.dirname(os.path.abspath(path))) known |= known_targets(repo_root) # An empty universe means the resolver found no authoring source at all # (a SKILL.md audited outside any plugin tree). Reporting every target # as dangling there would be noise, not a finding. if known: for target in targets: if target not in known: error("%s: description routes to '%s', which does not resolve to a skill " "under plugins/*/.apm/skills/ or an agent under " "plugins/*/.apm/agents/ (ADR-0020). A boundary clause that names a " "non-existent target sends the router nowhere." % (path, target)) sys.exit(1 if failed else 0) PYTHON then FAIL=1 fi exit $FAIL