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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user