feat(kyberforge): enforce the ADR-0020 context contract for skills and agents

Skill name+description pairs are preloaded into every session, costing
~6,200 tokens across 39 skills before any skill is invoked. The authoring
rules mandated that growth: skill-author:104 and description-quality.md:21
both required padding, while skill-author:102 (the deflating rule) had no
FAIL condition behind it.

Gates (blocking, no baseline file):
- description 250 chars SUGGESTION / 400 FAIL, measured on the folded
  YAML value
- body-only 600 words SUGGESTION / 900 FAIL, independent of the unchanged
  whole-file 2770-word / 500-line spec backstop
- every boundary-clause routing target must resolve to a real skill or
  agent; catches skill-improve, neuledge-context and gitea-labels
- agents take the description gates but deliberately no body gate; a test
  pins that absence

Vale: DescriptionOpener widened to ^This\b, new CompositionNote rule
banning architecture notes from descriptions. 10 hits, 0 false positives.

Kyberforge's own four skills retrofitted: descriptions 3,364 -> 938 chars
(-72%), bodies 8,306 -> 2,487 words (-70%), all via the apm-workflow
dispatch pattern. Fixes the skill-improve dangling route and the
agent-author misroute to manual review.

Also fixes a pre-existing false positive where any line-initial 'read '
was flagged as interactive input, which had already caused two scripts to
be rewritten around it.

Refs: ADR-0020
This commit is contained in:
2026-08-14 21:13:13 +00:00
parent 1c6eababb0
commit 4a5c3c0cff
104 changed files with 6272 additions and 1880 deletions

View File

@@ -90,6 +90,23 @@ SUBAGENT_UNAVAILABLE_TOOLS = {
# Copilot body length limit (chars) — content beyond this is silently truncated
COPILOT_BODY_LIMIT = 30000
# ADR-0020 description budget. An agent's name + description is preloaded into
# every session exactly like a skill's, so agents take the SAME description
# gates. These two constants are DUPLICATED from scripts/skill-size-check.sh
# and skill-audit/scripts/validate.sh rather than shared from one file: a
# cache-installed plugin's scripts cannot read files outside their own plugin
# directory, so there is no single source to share (same rationale as
# vale-wrap.sh's per-plugin duplication). tests/test-skill-size-check.sh
# asserts all copies agree, so drift fails CI rather than silently diverging.
#
# Agents deliberately take NO body word gate, and adding one here would
# contradict ADR-0020: a skill body is loaded into the caller's context and
# competes with the live conversation, while an agent body becomes the system
# prompt of a fresh context. The rationale for the 900-word skill ceiling does
# not transfer. Agent body length falls out of the delegation rule instead.
DESC_SUGGEST_CHARS = 250
DESC_MAX_CHARS = 400
# --- Helpers (shared by every scope) ---
failed = False
suggestions = []
@@ -122,6 +139,80 @@ def get_frontmatter_keys(fm):
keys.add(m.group(1))
return keys
def normalize_scalar(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 agent frontmatter
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 description_value(fm):
"""The description VALUE with YAML folding resolved.
extract_field() reads one raw line, which is the right shape for the
presence and placeholder checks but the wrong one for a length gate: a
`>`-folded description measured off its first raw line is not the value the
host preloads. Parse instead of regexing the raw text.
"""
try:
import yaml
data = yaml.safe_load(fm)
if isinstance(data, dict):
value = data.get('description')
if isinstance(value, str):
return normalize_scalar(value)
if value is not None:
return normalize_scalar(str(value))
return ''
except Exception:
pass
return normalize_scalar(fold_description_fallback(fm))
def check_description_budget(fm, local_fname):
"""ADR-0020 description gates — identical for every scope."""
value = description_value(fm)
if not value:
return
dlen = len(value)
if dlen > DESC_MAX_CHARS:
fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character "
f"ADR-0020 ceiling. It is preloaded into every session whether or not the "
f"agent is invoked. Keep a trigger clause, at most one capability clause, "
f"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body — {local_fname}")
elif dlen > DESC_SUGGEST_CHARS:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character "
f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is "
f"what moves the corpus average; the FAIL tier only stops outliers "
f"— {local_fname}")
def extract_tools_list(fm):
"""Extract tool names from the tools frontmatter field (space or comma separated)."""
val = extract_field(fm, 'tools')
@@ -273,6 +364,7 @@ def check_apm_agent_file(fpath, allowlist, stem):
else:
if PLACEHOLDER_RE.search(desc_val):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(fm, local_fname)
# body — required, non-empty, no placeholder; same Copilot truncation risk
# applies since this file compiles verbatim into a real Copilot file downstream.
@@ -346,6 +438,7 @@ def check_file(fpath, file_provider):
else:
if PLACEHOLDER_RE.search(desc_val):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(fm, local_fname)
# body
if not body.strip():