a8cd5e8was squashed into598a7c3(#129) and is reachable from no branch, so the comments now cite the commit that exists on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
510 lines
28 KiB
Bash
Executable File
510 lines
28 KiB
Bash
Executable File
#!/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> [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 <thing> -> <name>` 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)
|
|
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)
|
|
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
|