#!/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 # factory-audit/scripts/lib-checks-skill.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 # factory-audit/scripts/lib-checks-skill.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). # # THE MEASUREMENT BASIS, stated because the previous re-measure drifted onto a # different one and the numbers moved without the prose noticing: characters # per word is len(text) / len(text.split()) over the WHOLE FILE, whitespace # included, on plugins/*/.apm/skills/*/SKILL.md. Counting only non-whitespace # characters gives a materially lower figure (4.90 / 5.52 / 5.54 / 6.19 today) # and is not the basis MAX_WORDS is calibrated against. # # Measured over this repo's 39 in-scope SKILL.md files (2026-08-31, after the # ADR-0020 retrofit), characters per word runs min 5.93 / median 6.67 / mean # 6.63 / max 7.34. At the standard ~4-characters-per-token English # approximation that is 1.48 / 1.67 / 1.66 / 1.84 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.34 chars/word is ~20,300 # characters, or ~5,090 tokens at the 4-characters-per-token approximation. So # what this gate guarantees is "about 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 spend ~5,320 tokens. A # median-density file at 2770 words spends ~4,620 tokens, so typical prose # gives up ~140 words of headroom to close that gap. Densest file today: # git-commits at 7.34 chars/word. # # THE CORPUS IS NOWHERE NEAR THIS CEILING ANY MORE, and the note that used to # stand here — "a gate two files have already grown into" — described the # pre-retrofit corpus and is now wrong by a factor of three. The largest # SKILL.md is write-docs at 914 whole-file words, then vale-run at 874; # skill-author, the old high-water mark at 2,760, is down to 661. MAX_WORDS is # a spec-conformance backstop with roughly 1,850 words of slack, and the gate # that actually bites is ADR-0020's 900-word body budget below it. Do not read # the two as redundant: they measure different spans, and a file can sit well # inside one while failing the other. # # 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, on the basis # stated above, 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 # factory-audit/scripts/lib-checks-skill.sh (Python) rather than shared from one # file: this script is a standalone bash pre-commit hook, that one is an # in-skill Python check library invoked in a different context. # 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: factory-audit/scripts/lib-checks-skill.sh holds a second copy of # DESC_SUGGEST_CHARS / DESC_MAX_CHARS / BODY_SUGGEST_WORDS / BODY_MAX_WORDS, # and factory-audit/scripts/lib-checks-agent.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). ADR-0025's merge put those two libraries # in one directory but did NOT collapse the copies — the two flows gate # different spans — so the drift risk is unchanged: if they diverge, the 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 # ZERO ARGUMENTS IS A USAGE ERROR, exit 2 — not a clean run. # # This hook is `pass_filenames: true` in .pre-commit-config.yaml, and # pre-commit skips a filename-passing hook entirely when nothing matches its # `files:` pattern, so it never invokes this script with an empty argument list. Every no-argument invocation therefore comes from # somewhere else — a hand-run command, a wrapper, or a `files:` pattern edited # into matching nothing — and printing nothing and exiting 0 made all three # indistinguishable from a clean corpus. A mis-scoped pattern would have # silently disabled the whole ADR-0020 gate family while every hook reported # green. # # Exit 2, not 1, for the same reason 598a7c3 split validate-adapter.sh's usage # exits out: {0,1} are this script's verdict codes (clean / findings), and a # caller that reads a non-zero exit as "the SKILL.md needs editing" must be able # to tell a broken invocation from a real finding. if [[ $# -eq 0 ]]; then echo "usage: skill-size-check.sh [SKILL.md ...]" >&2 echo " Measures the agentskills.io spec ceilings and the ADR-0020 context" >&2 echo " budget for each SKILL.md named on the command line." >&2 echo " No paths were given. This is a usage error, not a clean run: a hook" >&2 echo " whose files: pattern matches nothing would otherwise be" >&2 echo " indistinguishable from a corpus with no findings." >&2 exit 2 fi # An unreadable path — a broken symlink named SKILL.md is storable in git, and a # directory named SKILL.md reaches this hook the same way — is diagnosed ONCE, # in the Python per-file loop below. There used to be a bash pre-loop here doing # exactly the same stat dance and printing exactly the same sentence, so every # such path was reported twice with two ERROR lines for one broken file. It is # still NOT a silent skip; the diagnosis simply lives where the file is read. # # The MAX_LINES / MAX_WORDS ceilings are not measured in bash either. 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 # factory-audit/scripts/lib-checks-skill.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. 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 # The Python program is assembled from three chunks: the hook's own preamble, # the ADR-0020 shared boundary resolver, and the hook's own per-file checks. The # resolver is not embedded here. It has exactly one copy, in factory-audit's # lib-boundary-resolver.sh, which this hook and factory-audit's validate.sh # both source, so the two cannot drift apart. It used to be embedded verbatim # because this hook was also exported through a published .pre-commit-hooks.yaml; # 4de5b6b retired that export (ADR-0014), so this script now only ever runs # inside this repo, where the plugin path always exists. # # Both chunks are read with the `read` builtin from QUOTED here-docs, so nothing # inside them is expanded, and exactly one trailing newline is stripped from each # — the same rule lib-boundary-resolver.sh documents — so the reassembled # program is line-for-line the program this script used to run. IFS='' read -r -d '' SSC_PREAMBLE_PY <<'SSC_PREAMBLE_PY' || true import glob import os import re import sys import yaml # Output is UTF-8 for the same reason input is: under LC_ALL=C the streams # default to ASCII, and this script's own message text carries em dashes (the # ADR-0020 boundary SUGGESTION is one). Pinning only the reads moved the crash # from the read to the write — a UnicodeEncodeError raised while PRINTING, after # every check has already run, which loses the whole report. read_text() in the # shared resolver block below pins the reads; this pins the writes. # # Deliberately OUTSIDE the ADR-0020 shared boundary resolver block: every # consumer of that block needs this, but the block is one sourced copy shared # with factory-audit, so each consumer's own startup belongs in its own # preamble, not inside the block. for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding='utf-8') except AttributeError: # pragma: no cover — Python < 3.7 pass 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) # The ADR-0020 shared boundary resolver is spliced in HERE, between this # chunk and the next, from factory-audit's lib-boundary-resolver.sh — the one # copy of it in the repo. See the composition step after the two here-docs. # Keep this note at four lines: the program's line numbers match the old one. SSC_PREAMBLE_PY SSC_PREAMBLE_PY="${SSC_PREAMBLE_PY%$'\n'}" IFS='' read -r -d '' SSC_CHECKS_PY <<'SSC_CHECKS_PY' || true # --- 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. This is # the ONLY place that diagnosis is made; the bash pre-loop that used to # duplicate it printed a second ERROR line for the same broken file. 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 factory-audit/scripts/lib-checks-skill.sh uses for these # two constants — see the note in bash 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 # Required-field presence (folded in from the former standalone # `skill-frontmatter` hook). description_value() above already proved the # frontmatter is valid YAML and a mapping, so a second yaml.safe_load here # cannot raise. fm_data = yaml.safe_load(fm_match.group(1)) or {} name_val = fm_data.get('name') if not isinstance(name_val, str) or not name_val.strip(): error("%s: name field is missing or empty (required frontmatter field)." % path) version_val = None metadata_val = fm_data.get('metadata') if isinstance(metadata_val, dict): version_val = metadata_val.get('version') if version_val is None: error("%s: metadata.version field is missing (required frontmatter " "field, e.g. \"1.0.0\")." % path) # Same shape as check-skill-version-bump.sh: ASCII digits, at most nine per # part (bash arithmetic), no leading zero (semver 2.0.0 item 2). elif not re.fullmatch(r'(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})', str(version_val).strip().strip('\'"')): error("%s: metadata.version is malformed (%r) -- expected a " "three-part semver, e.g. \"1.0.0\"." % (path, version_val)) body = content[fm_match.end():] skill_dir = os.path.dirname(os.path.abspath(path)) # ADR-0020's hand-invocation carve-out. See hand_invoked() for what it lifts # and, more importantly, what it does not (issue #108). by_hand = hand_invoked(fm_match.group(1)) # 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 a references/ file." % (path, dlen, DESC_MAX_CHARS)) elif dlen > DESC_SUGGEST_CHARS and not by_hand: # The 250-character TARGET is a routing-quality budget: it exists to # keep the preloaded listing small and the trigger clause sharp. A # hand-invoked description is in no listing, so there is no budget to # spend and no shape to enforce. The 400-character FAIL above still # applies — see hand_invoked(). 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))) # 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. # # THREE outcomes, not two. Reporting "no boundary clause" for a clause that # is present and merely unparsed is a wrong finding, not a strict one, and # it cost three authors a reworded clause before it was diagnosed (#110). # # Skipped entirely for a hand-invoked skill: the contract gives it one plain # sentence with no boundary clause, so the finding is wrong and its remedy # names a router that cannot see the skill (#108). if desc and not by_hand: status = boundary_clause_status(desc) if status == 'absent': 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) elif status == 'unparsed': suggest("%s: description has an arrow boundary clause (\"Not X -> y\") from which " "no target could be read, so the dangling-target check did not run on it " "(ADR-0020). The clause is present — this is a PARSE failure, not a " "missing clause. Most often the target is a single word, which is " "deliberately not matchable bare because `research`, `triage` and `forge` " "are all ordinary English: write it as `name` or /name." % path) # One arrow, one target. A second name after the arrow is resolved by # nothing and reported by nothing, so the clause claims coverage it does # not have (#107). for first, second in multi_target_arrow_clauses(desc): suggest("%s: an arrow boundary clause names more than one target ('%s', then " "'%s'), and only the first is resolved — the second is checked by " "nothing (ADR-0020). Split it into one arrow per target: " "\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second)) targets = boundary_targets(desc) # Body-level targets (issue #124): notation only (`/name`, `-> name`), so # every hit is unconditionally blocking — see body_targets()'s header for # why the description gate's SUGGESTION tier has no counterpart here. body_route_names = body_targets(body) if targets or body_route_names: known = known_targets(skill_dir) if known: if targets: 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)) for target in unresolved_body_targets(body, known): error("%s: body routes to '%s' (`/%s` or `-> %s` notation), 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 dispatch " "table or \"run X\" step naming a non-existent target sends the agent " "nowhere." % (path, target, target, target)) else: unchecked = sorted(set(targets) | set(body_route_names)) 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(unchecked))) sys.exit(1 if failed else 0) SSC_CHECKS_PY SSC_CHECKS_PY="${SSC_CHECKS_PY%$'\n'}" # The resolver library's absence is a hard failure, never a skip: without it # the boundary-target gate has nothing to run, and a hook that exits 0 having # checked nothing looks exactly like a clean pass in pre-commit's output. # Located by parameter expansion rather than `dirname`, so no external command # runs between the python3 preflight above and the gates below. SSC_DIR="${BASH_SOURCE[0]%/*}" [[ "$SSC_DIR" == "${BASH_SOURCE[0]}" ]] && SSC_DIR=. RESOLVER_LIB="$SSC_DIR/../plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh" if [[ ! -f "$RESOLVER_LIB" ]]; then echo "ERROR: the ADR-0020 boundary resolver library was not found at $RESOLVER_LIB." >&2 echo " Why: this hook sources its boundary resolver from factory-audit rather than carrying a copy, so without it no description, body or boundary-target gate can run — and skipping them would be a vacuous pass." >&2 echo " Fix: factory-audit's scripts/ directory has moved or been renamed. Update RESOLVER_LIB in this script to wherever lib-boundary-resolver.sh now lives." >&2 exit 1 fi KYBERFORGE_RESOLVER_PY='' # shellcheck source=../plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh . "$RESOLVER_LIB" if [[ "$KYBERFORGE_RESOLVER_PY" != *'BEGIN ADR-0020 SHARED BOUNDARY RESOLVER'* ]]; then echo "ERROR: $RESOLVER_LIB did not define the ADR-0020 boundary resolver (KYBERFORGE_RESOLVER_PY is empty or has lost its BEGIN marker)." >&2 echo " Why: running the gates without it would either crash on an undefined name or, worse, pass having resolved nothing." >&2 echo " Fix: restore the resolver here-doc in lib-boundary-resolver.sh." >&2 exit 1 fi if ! python3 -u - \ "$DESC_SUGGEST_CHARS" "$DESC_MAX_CHARS" \ "$BODY_SUGGEST_WORDS" "$BODY_MAX_WORDS" "$MAX_WORDS" "$MAX_LINES" "$@" <<< "$SSC_PREAMBLE_PY $KYBERFORGE_RESOLVER_PY $SSC_CHECKS_PY" then FAIL=1 fi exit $FAIL