fix(gates): make the ADR-0020 boundary check parse what skills actually write

The routing-target check understood only a single-arrow clause naming a bare
skill, so most real boundary prose was silently skipped rather than verified.
Two of those silences were fail-open: an unrecognised token following a target
dropped that target from the check entirely, and a skill directory with no
SKILL.md still resolved as a valid routing target, so a broken route passed.

Multi-target arrow clauses now draw a SUGGESTION instead of being ignored,
hand-invocation phrasing is carved out so it is not read as a route, and a
dotted filename parses into a new `unparsed` status rather than disappearing.
Three test fixtures had been relying on the SKILL.md-less directory resolving
as a target; they are corrected alongside the check.

Addresses #107, #108, #110.
This commit is contained in:
2026-08-31 08:01:07 +00:00
parent 095929142f
commit db5a426416
8 changed files with 1688 additions and 90 deletions

View File

@@ -253,6 +253,14 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir) safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'): for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
# A directory is a skill only if it HOLDS a SKILL.md. An empty
# leftover — a deleted skill whose directory survived, a scaffolding
# stub, an editor's stray mkdir — is untracked by git, so it exists
# on the machine that made it and nowhere else. Counting it made a
# boundary target resolve locally and dangle in a fresh clone: the
# same install-dependence the deployed-tree rule above exists to
# remove, arriving through a different door.
if os.path.isfile(os.path.join(path, 'SKILL.md')):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
@@ -557,12 +565,33 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I) CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I) CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) # CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
# `.vale.ini` — was invisible to both patterns below, and the two resulting
# failures were different sizes (issue #110):
# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep
# still extracted the target, so the route was checked, but the gate
# reported "no boundary clause" on a clause that was present and working.
# Three authors in two retrofit waves reworded a correct clause to satisfy
# the regex, one of them stripping the very filename that discriminates the
# skill from its neighbour.
# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only
# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill`
# produced no target, no dangling report and no missing-clause SUGGESTION.
# Silence, not noise — the worse of the two failure modes.
# A dot inside a filename is followed by a non-space; a sentence-ending dot is
# followed by whitespace or by end of string. So the class admits a `.` only
# when the next character is not whitespace, which crosses `AGENTS.md` and
# still stops at a real sentence end.
CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))"
ARROW_BOUNDARY = re.compile(
r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, NAME_HYPH), re.I)
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
# A boundary clause takes two shapes and BOTH count: the prose markers, and # A boundary clause takes two shapes and BOTH count: the prose markers, and
# ADR-0020's compressed arrow form `Not <thing> -> <name>`. # ADR-0020's compressed arrow form `Not <thing> -> <name>`.
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, re.I)
# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong # Sentence boundaries decide the CORROBORATION scope above, so getting one wrong
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two # is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# shapes common in these descriptions defeat the naive "period, space, capital" # shapes common in these descriptions defeat the naive "period, space, capital"
@@ -582,9 +611,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 # a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58
# SUGGESTION / 2 dangling before and after) — this protects the descriptions # SUGGESTION / 2 dangling before and after) — this protects the descriptions
# issue #99 is about to rewrite, not the ones already measured. # issue #99 is about to rewrite, not the ones already measured.
# re.I here too, and NOT as a tidy-up: this was the one pattern in the file
# built without it, contradicting the uniformity note on CONT_*/ARROW_* above.
# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is
# where an abbreviation most often lands — matched none of the lookbehinds, so
# the clause split at the abbreviation, the corroborating target was stranded on
# the far side of the cut, and a genuinely dangling target silently demoted from
# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described
# directly above, still live for exactly the capitalised half of the input.
SENTENCE_SPLIT = re.compile( SENTENCE_SPLIT = re.compile(
u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)' u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])') u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
# The token that may follow a route target without turning it into a compound # The token that may follow a route target without turning it into a compound
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a # modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
@@ -643,11 +680,26 @@ def _notation(text, start, arrow):
def _add(out, text, name, start, end, strict=None, arrow=False): def _add(out, text, name, start, end, strict=None, arrow=False):
"""Record one target as (name, may_dangle, notation).
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
The header above promises that route notation "always blocks", and for the
`/name` form that was false: `-> name` reached this function with
strict=True from its two call sites, but `/name` did not, so it fell to
_terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The
target then reached unresolved_targets() unblockable — and, before the
companion fix there, unreported as well. `... use /no-such-skill
afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an
author who wants a route checked unconditionally.
"""
if not name: if not name:
return return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name, out.append((name,
_terminal(text, end) if strict is None else strict, _terminal(text, end) if strict is None else strict,
_notation(text, start, arrow))) notation))
def _scan(text, route_re, cont_re, out): def _scan(text, route_re, cont_re, out):
@@ -706,6 +758,85 @@ def boundary_targets(description):
return sorted({name for name, _, _ in _extract(description)}) return sorted({name for name, _, _ in _extract(description)})
def _arrow_targets(description):
"""Names extracted from ARROW notation specifically.
Kept apart from boundary_targets() because the arrow form is the one shape
that ALWAYS names a target: ADR-0020's `Not <thing> -> <name>`. A clause
written that way from which nothing could be extracted is a parse failure
that deserves its own message, and telling it apart needs the arrow targets
alone rather than every target in the description.
"""
out = []
for sentence in SENTENCE_SPLIT.split(description):
for match in ARROW_MARKED.finditer(sentence):
name, _, _ = _first(match)
if name:
out.append(name)
for match in ARROW_BOUNDARY.finditer(sentence):
out.append(match.group(1))
return out
def boundary_clause_status(description):
"""'absent', 'unparsed' or 'present' — three outcomes, not two.
Issue #110's standing request: the gate must distinguish "no boundary
clause" from "boundary clause I could not parse". Reporting the first for
the second sends the author hunting for a problem that is not there, and
three of them reworded a correct clause to satisfy a regex instead.
'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was
detected and NO target came out of it. The arrow form always names one, so
zero targets means the name is written in a shape the extractor cannot see
— a single-word bare target (`Not X -> forge`, which has to be written
`` `forge` `` or `/forge`) is the live example, since single-word names are
deliberately not matchable bare.
A PROSE clause yielding no target is NOT reported: "Do not use for anything
else" is a complete and legitimate boundary clause that names nowhere to go.
"""
if BOUNDARY_ARROW.search(description) and not _arrow_targets(description):
return 'unparsed'
if has_boundary_clause(description):
return 'present'
return 'absent'
def multi_target_arrow_clauses(description):
"""[(first, second)] for arrow clauses naming more than one target.
Issue #107: only the FIRST target after an arrow is resolved. The
conjunction continuation (CONT_*) is wired to the prose route verbs and
never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither
resolved nor reported, and then printed "1 of 1 boundary target(s) resolve"
on a clause naming two — a gate under-reporting its own coverage, which is
the one failure mode ADR-0020 says a gate must not have.
The clause is REJECTED rather than the arrow scan extended. Extending it
would widen the resolver's deliberately conservative false-positive tuning
across every arrow in the corpus; rejecting costs nothing and makes the
one-arrow-per-target convention — already what every retrofitted gitea
skill does in practice — explicit instead of folkloric. The caller emits a
SUGGESTION telling the author to split.
"""
hits = []
for sentence in SENTENCE_SPLIT.split(description):
matches = (list(ARROW_MARKED.finditer(sentence))
+ list(ARROW_BOUNDARY.finditer(sentence)))
for match in matches:
first, _, _ = _first(match)
if not first:
continue
cont = CONT_ANY.match(sentence, match.end())
if not cont:
continue
second, _, _ = _first(cont)
if second:
hits.append((first, second))
return hits
def unresolved_targets(description, known): def unresolved_targets(description, known):
"""Targets resolving to nothing, split into (blocking, reported). """Targets resolving to nothing, split into (blocking, reported).
@@ -722,6 +853,17 @@ def unresolved_targets(description, known):
Everything else is reported and left alone. `known` is the resolved Everything else is reported and left alone. `known` is the resolved
universe from known_targets(); passing an empty set is not meaningful — universe from known_targets(); passing an empty set is not meaningful —
callers check for that first and decline out loud instead. callers check for that first and decline out loud instead.
A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed
whitelist of maybe eighty words, so the follower rule says "this token is
outside a list I keep" and not "this is prose" — and the old `continue`
turned that into invisibility at every tier. The gate then failed OPEN on
its own unfamiliarity: any target followed by a word nobody thought to
enumerate was neither blocked nor mentioned, so the check that did not run
said nothing about not running. The follower rule may withdraw the power to
BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note
above is the argument for it — but it may not withdraw visibility, which is
the same rule the corroboration tier already follows.
""" """
blocking, reported = set(), set() blocking, reported = set(), set()
for sentence in SENTENCE_SPLIT.split(description): for sentence in SENTENCE_SPLIT.split(description):
@@ -730,7 +872,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known} if normalize_target(name) in known}
for name, may_dangle, notation in found: for name, may_dangle, notation in found:
key = normalize_target(name) key = normalize_target(name)
if key in known or not may_dangle: if key in known:
continue
if not may_dangle:
reported.add(name)
continue continue
if notation or (resolved - {key}): if notation or (resolved - {key}):
blocking.add(name) blocking.add(name)
@@ -810,6 +955,47 @@ def description_value(fm_text):
return re.sub(r'\s+', ' ', value).strip() return re.sub(r'\s+', ' ', value).strip()
def hand_invoked(fm_text):
"""True when the frontmatter marks this file as reached only by hand.
`disable-model-invocation: true` removes a skill from the model-visible
listing entirely — it is not preloaded, and the Skill tool refuses to call
it — so its description is never matched against user intent. ADR-0020 and
skill-author's contract give such a skill ONE plain human-facing sentence:
no trigger list, no boundary clause. No validator knew the field existed
(issue #108), so the boundary-clause SUGGESTION fired on exactly the shape
the contract mandates, and its remedy — "add a boundary clause so the router
knows where NOT to send this skill" — was addressed to a router that cannot
see the skill at all. An author who followed the advice made the file worse.
Only the ROUTING rules are lifted. The body word budget still applies: the
body is loaded on invocation like any other, and competes with the caller's
live conversation the same way. So does the 400-character description FAIL —
a hand-invoked description is not preloaded, but it is still the one line
the user reads when choosing from the `/` menu, and the ceiling is the
outlier stop rather than the style target.
A parse failure returns False rather than raising. This is a MODIFIER on
other checks, not a check of its own: the frontmatter's validity is decided,
and failed, by description_value() on the same text, and raising a second
exception here would report one broken file twice with two different
diagnoses.
"""
try:
data = yaml.safe_load(fm_text)
except Exception:
return False
if not isinstance(data, dict):
return False
value = data.get('disable-model-invocation')
if isinstance(value, str):
# PyYAML already resolves the unquoted YAML 1.1 booleans, so this only
# catches a QUOTED "true" — which a host reads as truthy and which no
# gate should treat as opting back in to the routing rules.
return value.strip().lower() in ('true', 'yes', 'on')
return value is True
# --- Body-shape checks (skills only; agents have no references/ dir) ------- # --- Body-shape checks (skills only; agents have no references/ dir) -------
# Deterministic and countable, so they are enforced here. Whether a given # Deterministic and countable, so they are enforced here. Whether a given
# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why # gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why
@@ -977,8 +1163,14 @@ def agent_description(fm, local_fname):
f"not run — {local_fname}") f"not run — {local_fname}")
return None return None
def check_description_budget(value, local_fname): def check_description_budget(value, local_fname, by_hand=False):
"""ADR-0020 description gates — identical for every scope.""" """ADR-0020 description gates — identical for every scope.
`by_hand` is ADR-0020's hand-invocation carve-out (issue #108): an agent
carrying `disable-model-invocation: true` is absent from the model-visible
listing, so the 250-character SUGGESTION — a routing-quality budget — has
no listing to apply to. The 400-character ceiling is unaffected.
"""
if not value: if not value:
return return
dlen = len(value) dlen = len(value)
@@ -988,13 +1180,13 @@ def check_description_budget(value, local_fname):
f"agent is invoked. Keep a trigger clause, at most one capability clause, " 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"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body — {local_fname}") f"composition notes and implementation detail to the body — {local_fname}")
elif dlen > DESC_SUGGEST_CHARS: elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character " 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"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"what moves the corpus average; the FAIL tier only stops outliers "
f"— {local_fname}") f"— {local_fname}")
def check_boundary(value, fpath, local_fname): def check_boundary(value, fpath, local_fname, by_hand=False):
"""ADR-0020 boundary clause + resolvable boundary targets. """ADR-0020 boundary clause + resolvable boundary targets.
agent-author's SKILL.md states that an agent's boundary targets must agent-author's SKILL.md states that an agent's boundary targets must
@@ -1011,10 +1203,31 @@ def check_boundary(value, fpath, local_fname):
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether # SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
# this particular agent warrants a boundary clause is judgment. All four # this particular agent warrants a boundary clause is judgment. All four
# agents in this corpus currently lack one. # agents in this corpus currently lack one.
if not has_boundary_clause(value): #
# THREE outcomes, not two: "no boundary clause" and "boundary clause I could
# not parse" are different findings (issue #110). And a hand-invoked agent is
# exempt from the clause altogether (issue #108) — the boundary-target
# resolution below still runs, because a target it DOES name should still
# resolve.
status = boundary_clause_status(value) if not by_hand else 'present'
if status == 'absent':
suggest(f"description has no boundary clause — add the prose form (\"Do not use " suggest(f"description has no boundary clause — add the prose form (\"Do not use "
f"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") " f"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
f"so the router knows where NOT to send this agent — {local_fname}") f"so the router knows where NOT to send this agent — {local_fname}")
elif status == 'unparsed':
suggest(f"description has an arrow boundary clause (\"Not X -> y\") from which no "
f"target could be read, so the dangling-target check did not run on it — "
f"the clause is PRESENT and unparsed, not missing. Most often the target "
f"is a single word, which is deliberately not matchable bare: write it as "
f"`name` or /name — {local_fname}")
if not by_hand:
# One arrow, one target: a second name after the same arrow is resolved
# by nothing and reported by nothing (issue #107).
for first, second in multi_target_arrow_clauses(value):
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
f"'{second}') and only the first is resolved — the second is checked by "
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
f"Not Y -> {second}.\" — {local_fname}")
targets = boundary_targets(value) targets = boundary_targets(value)
if not targets: if not targets:
return return
@@ -1249,8 +1462,9 @@ def check_apm_agent_file(fpath, allowlist, stem):
else: else:
if PLACEHOLDER_RE.search(folded): if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname) by_hand = hand_invoked(fm)
check_boundary(folded, fpath, local_fname) check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body — required, non-empty, no placeholder; same Copilot truncation risk # body — required, non-empty, no placeholder; same Copilot truncation risk
# applies since this file compiles verbatim into a real Copilot file downstream. # applies since this file compiles verbatim into a real Copilot file downstream.
@@ -1345,8 +1559,9 @@ def check_file(fpath, file_provider):
else: else:
if PLACEHOLDER_RE.search(folded): if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname) by_hand = hand_invoked(fm)
check_boundary(folded, fpath, local_fname) check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body # body
if not body.strip(): if not body.strip():

View File

@@ -179,6 +179,14 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir) safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'): for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
# A directory is a skill only if it HOLDS a SKILL.md. An empty
# leftover — a deleted skill whose directory survived, a scaffolding
# stub, an editor's stray mkdir — is untracked by git, so it exists
# on the machine that made it and nowhere else. Counting it made a
# boundary target resolve locally and dangle in a fresh clone: the
# same install-dependence the deployed-tree rule above exists to
# remove, arriving through a different door.
if os.path.isfile(os.path.join(path, 'SKILL.md')):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
@@ -483,12 +491,33 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I) CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I) CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) # CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
# `.vale.ini` — was invisible to both patterns below, and the two resulting
# failures were different sizes (issue #110):
# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep
# still extracted the target, so the route was checked, but the gate
# reported "no boundary clause" on a clause that was present and working.
# Three authors in two retrofit waves reworded a correct clause to satisfy
# the regex, one of them stripping the very filename that discriminates the
# skill from its neighbour.
# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only
# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill`
# produced no target, no dangling report and no missing-clause SUGGESTION.
# Silence, not noise — the worse of the two failure modes.
# A dot inside a filename is followed by a non-space; a sentence-ending dot is
# followed by whitespace or by end of string. So the class admits a `.` only
# when the next character is not whitespace, which crosses `AGENTS.md` and
# still stops at a real sentence end.
CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))"
ARROW_BOUNDARY = re.compile(
r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, NAME_HYPH), re.I)
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
# A boundary clause takes two shapes and BOTH count: the prose markers, and # A boundary clause takes two shapes and BOTH count: the prose markers, and
# ADR-0020's compressed arrow form `Not <thing> -> <name>`. # ADR-0020's compressed arrow form `Not <thing> -> <name>`.
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, re.I)
# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong # Sentence boundaries decide the CORROBORATION scope above, so getting one wrong
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two # is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# shapes common in these descriptions defeat the naive "period, space, capital" # shapes common in these descriptions defeat the naive "period, space, capital"
@@ -508,9 +537,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 # a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58
# SUGGESTION / 2 dangling before and after) — this protects the descriptions # SUGGESTION / 2 dangling before and after) — this protects the descriptions
# issue #99 is about to rewrite, not the ones already measured. # issue #99 is about to rewrite, not the ones already measured.
# re.I here too, and NOT as a tidy-up: this was the one pattern in the file
# built without it, contradicting the uniformity note on CONT_*/ARROW_* above.
# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is
# where an abbreviation most often lands — matched none of the lookbehinds, so
# the clause split at the abbreviation, the corroborating target was stranded on
# the far side of the cut, and a genuinely dangling target silently demoted from
# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described
# directly above, still live for exactly the capitalised half of the input.
SENTENCE_SPLIT = re.compile( SENTENCE_SPLIT = re.compile(
u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)' u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])') u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
# The token that may follow a route target without turning it into a compound # The token that may follow a route target without turning it into a compound
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a # modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
@@ -569,11 +606,26 @@ def _notation(text, start, arrow):
def _add(out, text, name, start, end, strict=None, arrow=False): def _add(out, text, name, start, end, strict=None, arrow=False):
"""Record one target as (name, may_dangle, notation).
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
The header above promises that route notation "always blocks", and for the
`/name` form that was false: `-> name` reached this function with
strict=True from its two call sites, but `/name` did not, so it fell to
_terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The
target then reached unresolved_targets() unblockable — and, before the
companion fix there, unreported as well. `... use /no-such-skill
afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an
author who wants a route checked unconditionally.
"""
if not name: if not name:
return return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name, out.append((name,
_terminal(text, end) if strict is None else strict, _terminal(text, end) if strict is None else strict,
_notation(text, start, arrow))) notation))
def _scan(text, route_re, cont_re, out): def _scan(text, route_re, cont_re, out):
@@ -632,6 +684,85 @@ def boundary_targets(description):
return sorted({name for name, _, _ in _extract(description)}) return sorted({name for name, _, _ in _extract(description)})
def _arrow_targets(description):
"""Names extracted from ARROW notation specifically.
Kept apart from boundary_targets() because the arrow form is the one shape
that ALWAYS names a target: ADR-0020's `Not <thing> -> <name>`. A clause
written that way from which nothing could be extracted is a parse failure
that deserves its own message, and telling it apart needs the arrow targets
alone rather than every target in the description.
"""
out = []
for sentence in SENTENCE_SPLIT.split(description):
for match in ARROW_MARKED.finditer(sentence):
name, _, _ = _first(match)
if name:
out.append(name)
for match in ARROW_BOUNDARY.finditer(sentence):
out.append(match.group(1))
return out
def boundary_clause_status(description):
"""'absent', 'unparsed' or 'present' — three outcomes, not two.
Issue #110's standing request: the gate must distinguish "no boundary
clause" from "boundary clause I could not parse". Reporting the first for
the second sends the author hunting for a problem that is not there, and
three of them reworded a correct clause to satisfy a regex instead.
'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was
detected and NO target came out of it. The arrow form always names one, so
zero targets means the name is written in a shape the extractor cannot see
— a single-word bare target (`Not X -> forge`, which has to be written
`` `forge` `` or `/forge`) is the live example, since single-word names are
deliberately not matchable bare.
A PROSE clause yielding no target is NOT reported: "Do not use for anything
else" is a complete and legitimate boundary clause that names nowhere to go.
"""
if BOUNDARY_ARROW.search(description) and not _arrow_targets(description):
return 'unparsed'
if has_boundary_clause(description):
return 'present'
return 'absent'
def multi_target_arrow_clauses(description):
"""[(first, second)] for arrow clauses naming more than one target.
Issue #107: only the FIRST target after an arrow is resolved. The
conjunction continuation (CONT_*) is wired to the prose route verbs and
never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither
resolved nor reported, and then printed "1 of 1 boundary target(s) resolve"
on a clause naming two — a gate under-reporting its own coverage, which is
the one failure mode ADR-0020 says a gate must not have.
The clause is REJECTED rather than the arrow scan extended. Extending it
would widen the resolver's deliberately conservative false-positive tuning
across every arrow in the corpus; rejecting costs nothing and makes the
one-arrow-per-target convention — already what every retrofitted gitea
skill does in practice — explicit instead of folkloric. The caller emits a
SUGGESTION telling the author to split.
"""
hits = []
for sentence in SENTENCE_SPLIT.split(description):
matches = (list(ARROW_MARKED.finditer(sentence))
+ list(ARROW_BOUNDARY.finditer(sentence)))
for match in matches:
first, _, _ = _first(match)
if not first:
continue
cont = CONT_ANY.match(sentence, match.end())
if not cont:
continue
second, _, _ = _first(cont)
if second:
hits.append((first, second))
return hits
def unresolved_targets(description, known): def unresolved_targets(description, known):
"""Targets resolving to nothing, split into (blocking, reported). """Targets resolving to nothing, split into (blocking, reported).
@@ -648,6 +779,17 @@ def unresolved_targets(description, known):
Everything else is reported and left alone. `known` is the resolved Everything else is reported and left alone. `known` is the resolved
universe from known_targets(); passing an empty set is not meaningful — universe from known_targets(); passing an empty set is not meaningful —
callers check for that first and decline out loud instead. callers check for that first and decline out loud instead.
A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed
whitelist of maybe eighty words, so the follower rule says "this token is
outside a list I keep" and not "this is prose" — and the old `continue`
turned that into invisibility at every tier. The gate then failed OPEN on
its own unfamiliarity: any target followed by a word nobody thought to
enumerate was neither blocked nor mentioned, so the check that did not run
said nothing about not running. The follower rule may withdraw the power to
BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note
above is the argument for it — but it may not withdraw visibility, which is
the same rule the corroboration tier already follows.
""" """
blocking, reported = set(), set() blocking, reported = set(), set()
for sentence in SENTENCE_SPLIT.split(description): for sentence in SENTENCE_SPLIT.split(description):
@@ -656,7 +798,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known} if normalize_target(name) in known}
for name, may_dangle, notation in found: for name, may_dangle, notation in found:
key = normalize_target(name) key = normalize_target(name)
if key in known or not may_dangle: if key in known:
continue
if not may_dangle:
reported.add(name)
continue continue
if notation or (resolved - {key}): if notation or (resolved - {key}):
blocking.add(name) blocking.add(name)
@@ -736,6 +881,47 @@ def description_value(fm_text):
return re.sub(r'\s+', ' ', value).strip() return re.sub(r'\s+', ' ', value).strip()
def hand_invoked(fm_text):
"""True when the frontmatter marks this file as reached only by hand.
`disable-model-invocation: true` removes a skill from the model-visible
listing entirely — it is not preloaded, and the Skill tool refuses to call
it — so its description is never matched against user intent. ADR-0020 and
skill-author's contract give such a skill ONE plain human-facing sentence:
no trigger list, no boundary clause. No validator knew the field existed
(issue #108), so the boundary-clause SUGGESTION fired on exactly the shape
the contract mandates, and its remedy — "add a boundary clause so the router
knows where NOT to send this skill" — was addressed to a router that cannot
see the skill at all. An author who followed the advice made the file worse.
Only the ROUTING rules are lifted. The body word budget still applies: the
body is loaded on invocation like any other, and competes with the caller's
live conversation the same way. So does the 400-character description FAIL —
a hand-invoked description is not preloaded, but it is still the one line
the user reads when choosing from the `/` menu, and the ceiling is the
outlier stop rather than the style target.
A parse failure returns False rather than raising. This is a MODIFIER on
other checks, not a check of its own: the frontmatter's validity is decided,
and failed, by description_value() on the same text, and raising a second
exception here would report one broken file twice with two different
diagnoses.
"""
try:
data = yaml.safe_load(fm_text)
except Exception:
return False
if not isinstance(data, dict):
return False
value = data.get('disable-model-invocation')
if isinstance(value, str):
# PyYAML already resolves the unquoted YAML 1.1 booleans, so this only
# catches a QUOTED "true" — which a host reads as truthy and which no
# gate should treat as opting back in to the routing rules.
return value.strip().lower() in ('true', 'yes', 'on')
return value is True
# --- Body-shape checks (skills only; agents have no references/ dir) ------- # --- Body-shape checks (skills only; agents have no references/ dir) -------
# Deterministic and countable, so they are enforced here. Whether a given # Deterministic and countable, so they are enforced here. Whether a given
# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why # gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why
@@ -909,6 +1095,15 @@ except FrontmatterError as exc:
dir_name = os.path.basename(skill_dir) dir_name = os.path.basename(skill_dir)
# ADR-0020's hand-invocation carve-out (issue #108). `disable-model-invocation:
# true` takes the skill out of the model-visible listing entirely, so the
# trigger/capability/boundary rules and the 250-character routing target do not
# apply to it — the audit's own references/description-quality.md Step 0 says
# so, and until this line existed no check here knew the field existed. What the
# flag does NOT lift: the body word budget and the 400-character description
# ceiling. See the shared resolver's hand_invoked().
by_hand = hand_invoked(fm)
# --- Checks --- # --- Checks ---
# name present # name present
@@ -1023,10 +1218,13 @@ if desc:
f"skill is invoked. Keep a trigger clause, at most one capability clause, " f"skill is invoked. Keep a trigger clause, at most one capability clause, "
f"and a boundary clause; move capability enumeration, output-format detail, " f"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body or README.md") f"composition notes and implementation detail to the body or README.md")
elif dlen > DESC_SUGGEST_CHARS: elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character " 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"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"what moves the corpus average; the FAIL tier only stops outliers")
elif by_hand:
ok(f"description length {dlen} chars (hand-invoked: the {DESC_SUGGEST_CHARS}-character "
f"routing target does not apply, the {DESC_MAX_CHARS}-character ceiling still does)")
else: else:
ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})") ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})")
@@ -1080,13 +1278,41 @@ if gotchas is not None:
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether # SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
# this particular skill warrants a boundary clause is judgment. Both accepted # this particular skill warrants a boundary clause is judgment. Both accepted
# shapes count — the prose markers and the compressed `Not <thing> -> <name>`. # shapes count — the prose markers and the compressed `Not <thing> -> <name>`.
if desc: #
if has_boundary_clause(desc): # THREE outcomes, not two: "no boundary clause" and "boundary clause I could not
# parse" are different findings, and reporting the first for the second sends
# the author hunting for a problem that is not there (issue #110).
#
# Skipped entirely for a hand-invoked skill — the contract gives it one plain
# sentence with no boundary clause, so the finding would be wrong and its remedy
# names a router that cannot see the skill (issue #108).
if desc and by_hand:
ok("hand-invoked (disable-model-invocation) — the boundary-clause and trigger "
"rules do not apply; audited as one plain human-facing sentence")
elif desc:
status = boundary_clause_status(desc)
if status == 'present':
ok("description has a boundary clause") ok("description has a boundary clause")
else: elif status == 'absent':
suggest("description has no boundary clause — add the prose form (\"Do not use " suggest("description has no boundary clause — add the prose form (\"Do not use "
"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") " "for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
"so the router knows where NOT to send this skill") "so the router knows where NOT to send this skill")
else:
suggest("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 — "
"the clause is PRESENT and unparsed, not missing. 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")
# One arrow, one target. A second name after the same arrow is resolved by
# nothing and reported by nothing, so the clause claims coverage it does not
# have and this script printed "1 of 1 boundary target(s) resolve" on a
# clause naming two (issue #107).
for first, second in multi_target_arrow_clauses(desc):
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
f"'{second}') and only the first is resolved — the second is checked by "
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
f"Not Y -> {second}.\"")
# --- ADR-0020: resolvable boundary targets --------------------------------- # --- ADR-0020: resolvable boundary targets ---------------------------------
# The resolution universe comes from the SKILL's own location: the authoring # The resolution universe comes from the SKILL's own location: the authoring

View File

@@ -70,13 +70,23 @@ PY
# this repo's live skills. Echoes the subject skill's directory. # this repo's live skills. Echoes the subject skill's directory.
# #
# <root>/plugins/fixture-plugin/.apm/skills/<subject>/SKILL.md # <root>/plugins/fixture-plugin/.apm/skills/<subject>/SKILL.md
# <root>/plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/ # <root>/plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/SKILL.md
# <root>/plugins/fixture-plugin/.apm/agents/fixture-sibling-agent.agent.md # <root>/plugins/fixture-plugin/.apm/agents/fixture-sibling-agent.agent.md
#
# The sibling gets a real SKILL.md, and that is load-bearing rather than
# tidiness: a directory under skills/ is a resolvable name only when it
# HOLDS one. An empty leftover directory is untracked by git, so counting
# one made a target resolve on the machine that made it and dangle in a
# fresh clone. This helper used to mkdir the sibling and write nothing into
# it, so the corroborator every blocking-tier test depends on silently
# stopped resolving the moment that rule was enforced.
make_fixture_tree() { make_fixture_tree() {
local root="$1" subject="$2" local root="$1" subject="$2"
local apm="$root/plugins/fixture-plugin/.apm" local apm="$root/plugins/fixture-plugin/.apm"
mkdir -p "$apm/skills/$subject" "$apm/skills/fixture-sibling-skill" "$apm/agents" mkdir -p "$apm/skills/$subject" "$apm/skills/fixture-sibling-skill" "$apm/agents"
touch "$apm/agents/fixture-sibling-agent.agent.md" touch "$apm/agents/fixture-sibling-agent.agent.md"
make_sized_skill "$apm/skills/fixture-sibling-skill" \
"Use when doing the other thing. Do not use for anything else." 10
echo "$apm/skills/$subject" echo "$apm/skills/$subject"
} }
} }
@@ -568,3 +578,168 @@ EOF
assert_output --partial "boundary-target resolution DID NOT RUN" assert_output --partial "boundary-target resolution DID NOT RUN"
assert_output --partial "Unchecked target(s): some-other-skill" assert_output --partial "Unchecked target(s): some-other-skill"
} }
# ---------------------------------------------------------------------------
# ADR-0020 — the hand-invocation carve-out (issue #108)
#
# A skill carrying `disable-model-invocation: true` is absent from the
# model-visible listing entirely: not preloaded, and the Skill tool refuses to
# call it. Its description is never matched against user intent, so
# references/description-quality.md Step 0 gives it ONE plain human-facing
# sentence — no trigger list, no boundary clause — and calls a
# missing-boundary-clause finding on such a skill "a wrong finding, not a strict
# one". Until this ran, nothing here knew the field existed, so the audit
# reported exactly the shape its own rubric mandates, with advice naming a
# router that cannot see the skill.
#
# The carve-out is narrow. Both size gates are unaffected and both are pinned
# below: the body is loaded on invocation like any other body, and the
# 400-character ceiling is an outlier stop rather than a routing budget.
# ---------------------------------------------------------------------------
# Helper: a skill directory carrying `disable-model-invocation: true`.
make_hand_invoked_skill() {
local dir="$1" desc="$2" body_words="$3"
local name
name="$(basename "$dir")"
mkdir -p "$dir"
{
echo "---"
echo "name: $name"
echo "description: $desc"
echo "disable-model-invocation: true"
echo "---"
echo ""
python3 -c "print(' '.join(['word'] * $body_words))"
} > "$dir/SKILL.md"
}
@test "ADR-0020: a hand-invoked skill is not asked for a boundary clause" {
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" \
"Tell the agent to zoom out and give broader context or a higher level perspective." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "has no boundary clause"
assert_output --partial "hand-invoked"
}
@test "ADR-0020: the SAME description without the flag IS asked for a boundary clause" {
# The control. Without it the case above is satisfied by an audit that
# stopped checking boundary clauses altogether.
local skill="$TMPDIR/my-skill"
make_sized_skill "$skill" \
"Tell the agent to zoom out and give broader context or a higher level perspective." 10
run bash "$SCRIPT" "$skill"
assert_success
assert_output --partial "has no boundary clause"
}
@test "ADR-0020: a hand-invoked skill is exempt from the 250-character description target" {
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" \
"$(python3 -c "print('Tell the agent to zoom out. ' + 'x' * 273)")" 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "over the 250-character"
}
@test "ADR-0020: a hand-invoked description over 400 chars still FAILS" {
# The half the carve-out does NOT lift. 400 is an outlier stop, not a
# routing-quality target: a hand-invoked description is still the one line
# the user reads when choosing from the `/` menu.
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" \
"$(python3 -c "print('Tell the agent to zoom out. ' + 'x' * 374)")" 10
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "400-character"
}
@test "ADR-0020: a hand-invoked body over 900 words still FAILS" {
# The body is loaded on invocation exactly like any other body and competes
# with the caller's live conversation the same way, so no body tier moves.
local skill="$TMPDIR/my-skill"
make_hand_invoked_skill "$skill" "Tell the agent to zoom out." 901
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "900-word"
}
# ---------------------------------------------------------------------------
# ADR-0020 — one arrow, one target (issue #107)
#
# Only the FIRST target after an arrow was resolved: the conjunction
# continuation is wired to the prose route verbs and never to arrows. So this
# script printed "1 of 1 boundary target(s) resolve" on a clause naming two,
# and the second was resolved by nothing and reported by nothing. A typo in it
# shipped through a green gate. The shape is now rejected rather than the
# extractor widened.
# ---------------------------------------------------------------------------
@test "ADR-0020: an arrow clause naming two targets is reported, not silently half-checked" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
# A bare `Not ... ->` sentence carries no BOUNDARY_MARKER, so the backtick
# sweep does not run and the second target is invisible to every other rule
# in the resolver — this is the exact shape #107 measured.
make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> \`fixture-sibling-skill\` or \`fixture-missing-second\`." 10
run bash "$SCRIPT" "$skill"
assert_success
assert_output --partial "names more than one target"
}
@test "ADR-0020: one arrow per target — the convention the suggestion asks for — is silent" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> \`fixture-sibling-skill\`." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "names more than one target"
}
# ---------------------------------------------------------------------------
# ADR-0020 — a dotted filename in a boundary clause (issue #110)
#
# `[^.;]` could not cross the `.` in `AGENTS.md`, so a clause naming a dotted
# file between "Not" and the arrow was invisible. With a backticked target that
# was a MISDIAGNOSIS — "no boundary clause" reported on a clause that was
# present and working. With a BARE target it was worse: the target was never
# extracted, so the dangling check silently did not run on it.
# ---------------------------------------------------------------------------
@test "ADR-0020: a boundary clause naming a dotted filename is not reported as missing" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
make_sized_skill "$skill" "Use when doing the thing. Not AGENTS.md -> \`fixture-sibling-skill\`." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "has no boundary clause"
assert_output --partial "description has a boundary clause"
}
@test "ADR-0020: a BARE target after a dotted filename is extracted and checked" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
# The silent half of #110: this clause produced no target at all, so it was
# neither resolved nor reported — a route to a non-existent skill shipping
# through a green gate with no finding of any kind.
make_sized_skill "$skill" "Use when doing the thing. Not AGENTS.md -> fixture-missing-dotted." 10
run bash "$SCRIPT" "$skill"
assert_failure
assert_output --partial "routes to 'fixture-missing-dotted'"
}
@test "ADR-0020: an arrow clause yielding no target is reported as unparsed, not as missing" {
local skill
skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")"
# A single-word target is deliberately not matchable bare, because
# `research`, `triage` and `forge` are all skill names AND ordinary English.
# The clause is present; saying it is missing sends the author to add a
# second copy of a clause that is already there.
make_sized_skill "$skill" "Use when doing the thing. Not the other thing -> forge." 10
run bash "$SCRIPT" "$skill"
assert_success
refute_output --partial "has no boundary clause"
assert_output --partial "no target could be read"
}

View File

@@ -253,6 +253,14 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir) safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'): for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
# A directory is a skill only if it HOLDS a SKILL.md. An empty
# leftover — a deleted skill whose directory survived, a scaffolding
# stub, an editor's stray mkdir — is untracked by git, so it exists
# on the machine that made it and nowhere else. Counting it made a
# boundary target resolve locally and dangle in a fresh clone: the
# same install-dependence the deployed-tree rule above exists to
# remove, arriving through a different door.
if os.path.isfile(os.path.join(path, 'SKILL.md')):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
@@ -557,12 +565,33 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I) CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I) CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) # CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
# `.vale.ini` — was invisible to both patterns below, and the two resulting
# failures were different sizes (issue #110):
# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep
# still extracted the target, so the route was checked, but the gate
# reported "no boundary clause" on a clause that was present and working.
# Three authors in two retrofit waves reworded a correct clause to satisfy
# the regex, one of them stripping the very filename that discriminates the
# skill from its neighbour.
# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only
# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill`
# produced no target, no dangling report and no missing-clause SUGGESTION.
# Silence, not noise — the worse of the two failure modes.
# A dot inside a filename is followed by a non-space; a sentence-ending dot is
# followed by whitespace or by end of string. So the class admits a `.` only
# when the next character is not whitespace, which crosses `AGENTS.md` and
# still stops at a real sentence end.
CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))"
ARROW_BOUNDARY = re.compile(
r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, NAME_HYPH), re.I)
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
# A boundary clause takes two shapes and BOTH count: the prose markers, and # A boundary clause takes two shapes and BOTH count: the prose markers, and
# ADR-0020's compressed arrow form `Not <thing> -> <name>`. # ADR-0020's compressed arrow form `Not <thing> -> <name>`.
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, re.I)
# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong # Sentence boundaries decide the CORROBORATION scope above, so getting one wrong
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two # is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# shapes common in these descriptions defeat the naive "period, space, capital" # shapes common in these descriptions defeat the naive "period, space, capital"
@@ -582,9 +611,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 # a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58
# SUGGESTION / 2 dangling before and after) — this protects the descriptions # SUGGESTION / 2 dangling before and after) — this protects the descriptions
# issue #99 is about to rewrite, not the ones already measured. # issue #99 is about to rewrite, not the ones already measured.
# re.I here too, and NOT as a tidy-up: this was the one pattern in the file
# built without it, contradicting the uniformity note on CONT_*/ARROW_* above.
# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is
# where an abbreviation most often lands — matched none of the lookbehinds, so
# the clause split at the abbreviation, the corroborating target was stranded on
# the far side of the cut, and a genuinely dangling target silently demoted from
# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described
# directly above, still live for exactly the capitalised half of the input.
SENTENCE_SPLIT = re.compile( SENTENCE_SPLIT = re.compile(
u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)' u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])') u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
# The token that may follow a route target without turning it into a compound # The token that may follow a route target without turning it into a compound
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a # modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
@@ -643,11 +680,26 @@ def _notation(text, start, arrow):
def _add(out, text, name, start, end, strict=None, arrow=False): def _add(out, text, name, start, end, strict=None, arrow=False):
"""Record one target as (name, may_dangle, notation).
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
The header above promises that route notation "always blocks", and for the
`/name` form that was false: `-> name` reached this function with
strict=True from its two call sites, but `/name` did not, so it fell to
_terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The
target then reached unresolved_targets() unblockable — and, before the
companion fix there, unreported as well. `... use /no-such-skill
afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an
author who wants a route checked unconditionally.
"""
if not name: if not name:
return return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name, out.append((name,
_terminal(text, end) if strict is None else strict, _terminal(text, end) if strict is None else strict,
_notation(text, start, arrow))) notation))
def _scan(text, route_re, cont_re, out): def _scan(text, route_re, cont_re, out):
@@ -706,6 +758,85 @@ def boundary_targets(description):
return sorted({name for name, _, _ in _extract(description)}) return sorted({name for name, _, _ in _extract(description)})
def _arrow_targets(description):
"""Names extracted from ARROW notation specifically.
Kept apart from boundary_targets() because the arrow form is the one shape
that ALWAYS names a target: ADR-0020's `Not <thing> -> <name>`. A clause
written that way from which nothing could be extracted is a parse failure
that deserves its own message, and telling it apart needs the arrow targets
alone rather than every target in the description.
"""
out = []
for sentence in SENTENCE_SPLIT.split(description):
for match in ARROW_MARKED.finditer(sentence):
name, _, _ = _first(match)
if name:
out.append(name)
for match in ARROW_BOUNDARY.finditer(sentence):
out.append(match.group(1))
return out
def boundary_clause_status(description):
"""'absent', 'unparsed' or 'present' — three outcomes, not two.
Issue #110's standing request: the gate must distinguish "no boundary
clause" from "boundary clause I could not parse". Reporting the first for
the second sends the author hunting for a problem that is not there, and
three of them reworded a correct clause to satisfy a regex instead.
'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was
detected and NO target came out of it. The arrow form always names one, so
zero targets means the name is written in a shape the extractor cannot see
— a single-word bare target (`Not X -> forge`, which has to be written
`` `forge` `` or `/forge`) is the live example, since single-word names are
deliberately not matchable bare.
A PROSE clause yielding no target is NOT reported: "Do not use for anything
else" is a complete and legitimate boundary clause that names nowhere to go.
"""
if BOUNDARY_ARROW.search(description) and not _arrow_targets(description):
return 'unparsed'
if has_boundary_clause(description):
return 'present'
return 'absent'
def multi_target_arrow_clauses(description):
"""[(first, second)] for arrow clauses naming more than one target.
Issue #107: only the FIRST target after an arrow is resolved. The
conjunction continuation (CONT_*) is wired to the prose route verbs and
never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither
resolved nor reported, and then printed "1 of 1 boundary target(s) resolve"
on a clause naming two — a gate under-reporting its own coverage, which is
the one failure mode ADR-0020 says a gate must not have.
The clause is REJECTED rather than the arrow scan extended. Extending it
would widen the resolver's deliberately conservative false-positive tuning
across every arrow in the corpus; rejecting costs nothing and makes the
one-arrow-per-target convention — already what every retrofitted gitea
skill does in practice — explicit instead of folkloric. The caller emits a
SUGGESTION telling the author to split.
"""
hits = []
for sentence in SENTENCE_SPLIT.split(description):
matches = (list(ARROW_MARKED.finditer(sentence))
+ list(ARROW_BOUNDARY.finditer(sentence)))
for match in matches:
first, _, _ = _first(match)
if not first:
continue
cont = CONT_ANY.match(sentence, match.end())
if not cont:
continue
second, _, _ = _first(cont)
if second:
hits.append((first, second))
return hits
def unresolved_targets(description, known): def unresolved_targets(description, known):
"""Targets resolving to nothing, split into (blocking, reported). """Targets resolving to nothing, split into (blocking, reported).
@@ -722,6 +853,17 @@ def unresolved_targets(description, known):
Everything else is reported and left alone. `known` is the resolved Everything else is reported and left alone. `known` is the resolved
universe from known_targets(); passing an empty set is not meaningful — universe from known_targets(); passing an empty set is not meaningful —
callers check for that first and decline out loud instead. callers check for that first and decline out loud instead.
A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed
whitelist of maybe eighty words, so the follower rule says "this token is
outside a list I keep" and not "this is prose" — and the old `continue`
turned that into invisibility at every tier. The gate then failed OPEN on
its own unfamiliarity: any target followed by a word nobody thought to
enumerate was neither blocked nor mentioned, so the check that did not run
said nothing about not running. The follower rule may withdraw the power to
BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note
above is the argument for it — but it may not withdraw visibility, which is
the same rule the corroboration tier already follows.
""" """
blocking, reported = set(), set() blocking, reported = set(), set()
for sentence in SENTENCE_SPLIT.split(description): for sentence in SENTENCE_SPLIT.split(description):
@@ -730,7 +872,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known} if normalize_target(name) in known}
for name, may_dangle, notation in found: for name, may_dangle, notation in found:
key = normalize_target(name) key = normalize_target(name)
if key in known or not may_dangle: if key in known:
continue
if not may_dangle:
reported.add(name)
continue continue
if notation or (resolved - {key}): if notation or (resolved - {key}):
blocking.add(name) blocking.add(name)
@@ -810,6 +955,47 @@ def description_value(fm_text):
return re.sub(r'\s+', ' ', value).strip() return re.sub(r'\s+', ' ', value).strip()
def hand_invoked(fm_text):
"""True when the frontmatter marks this file as reached only by hand.
`disable-model-invocation: true` removes a skill from the model-visible
listing entirely — it is not preloaded, and the Skill tool refuses to call
it — so its description is never matched against user intent. ADR-0020 and
skill-author's contract give such a skill ONE plain human-facing sentence:
no trigger list, no boundary clause. No validator knew the field existed
(issue #108), so the boundary-clause SUGGESTION fired on exactly the shape
the contract mandates, and its remedy — "add a boundary clause so the router
knows where NOT to send this skill" — was addressed to a router that cannot
see the skill at all. An author who followed the advice made the file worse.
Only the ROUTING rules are lifted. The body word budget still applies: the
body is loaded on invocation like any other, and competes with the caller's
live conversation the same way. So does the 400-character description FAIL —
a hand-invoked description is not preloaded, but it is still the one line
the user reads when choosing from the `/` menu, and the ceiling is the
outlier stop rather than the style target.
A parse failure returns False rather than raising. This is a MODIFIER on
other checks, not a check of its own: the frontmatter's validity is decided,
and failed, by description_value() on the same text, and raising a second
exception here would report one broken file twice with two different
diagnoses.
"""
try:
data = yaml.safe_load(fm_text)
except Exception:
return False
if not isinstance(data, dict):
return False
value = data.get('disable-model-invocation')
if isinstance(value, str):
# PyYAML already resolves the unquoted YAML 1.1 booleans, so this only
# catches a QUOTED "true" — which a host reads as truthy and which no
# gate should treat as opting back in to the routing rules.
return value.strip().lower() in ('true', 'yes', 'on')
return value is True
# --- Body-shape checks (skills only; agents have no references/ dir) ------- # --- Body-shape checks (skills only; agents have no references/ dir) -------
# Deterministic and countable, so they are enforced here. Whether a given # Deterministic and countable, so they are enforced here. Whether a given
# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why # gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why
@@ -977,8 +1163,14 @@ def agent_description(fm, local_fname):
f"not run — {local_fname}") f"not run — {local_fname}")
return None return None
def check_description_budget(value, local_fname): def check_description_budget(value, local_fname, by_hand=False):
"""ADR-0020 description gates — identical for every scope.""" """ADR-0020 description gates — identical for every scope.
`by_hand` is ADR-0020's hand-invocation carve-out (issue #108): an agent
carrying `disable-model-invocation: true` is absent from the model-visible
listing, so the 250-character SUGGESTION — a routing-quality budget — has
no listing to apply to. The 400-character ceiling is unaffected.
"""
if not value: if not value:
return return
dlen = len(value) dlen = len(value)
@@ -988,13 +1180,13 @@ def check_description_budget(value, local_fname):
f"agent is invoked. Keep a trigger clause, at most one capability clause, " 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"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body — {local_fname}") f"composition notes and implementation detail to the body — {local_fname}")
elif dlen > DESC_SUGGEST_CHARS: elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character " 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"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"what moves the corpus average; the FAIL tier only stops outliers "
f"— {local_fname}") f"— {local_fname}")
def check_boundary(value, fpath, local_fname): def check_boundary(value, fpath, local_fname, by_hand=False):
"""ADR-0020 boundary clause + resolvable boundary targets. """ADR-0020 boundary clause + resolvable boundary targets.
agent-author's SKILL.md states that an agent's boundary targets must agent-author's SKILL.md states that an agent's boundary targets must
@@ -1011,10 +1203,31 @@ def check_boundary(value, fpath, local_fname):
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether # SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
# this particular agent warrants a boundary clause is judgment. All four # this particular agent warrants a boundary clause is judgment. All four
# agents in this corpus currently lack one. # agents in this corpus currently lack one.
if not has_boundary_clause(value): #
# THREE outcomes, not two: "no boundary clause" and "boundary clause I could
# not parse" are different findings (issue #110). And a hand-invoked agent is
# exempt from the clause altogether (issue #108) — the boundary-target
# resolution below still runs, because a target it DOES name should still
# resolve.
status = boundary_clause_status(value) if not by_hand else 'present'
if status == 'absent':
suggest(f"description has no boundary clause — add the prose form (\"Do not use " suggest(f"description has no boundary clause — add the prose form (\"Do not use "
f"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") " f"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
f"so the router knows where NOT to send this agent — {local_fname}") f"so the router knows where NOT to send this agent — {local_fname}")
elif status == 'unparsed':
suggest(f"description has an arrow boundary clause (\"Not X -> y\") from which no "
f"target could be read, so the dangling-target check did not run on it — "
f"the clause is PRESENT and unparsed, not missing. Most often the target "
f"is a single word, which is deliberately not matchable bare: write it as "
f"`name` or /name — {local_fname}")
if not by_hand:
# One arrow, one target: a second name after the same arrow is resolved
# by nothing and reported by nothing (issue #107).
for first, second in multi_target_arrow_clauses(value):
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
f"'{second}') and only the first is resolved — the second is checked by "
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
f"Not Y -> {second}.\" — {local_fname}")
targets = boundary_targets(value) targets = boundary_targets(value)
if not targets: if not targets:
return return
@@ -1249,8 +1462,9 @@ def check_apm_agent_file(fpath, allowlist, stem):
else: else:
if PLACEHOLDER_RE.search(folded): if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname) by_hand = hand_invoked(fm)
check_boundary(folded, fpath, local_fname) check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body — required, non-empty, no placeholder; same Copilot truncation risk # body — required, non-empty, no placeholder; same Copilot truncation risk
# applies since this file compiles verbatim into a real Copilot file downstream. # applies since this file compiles verbatim into a real Copilot file downstream.
@@ -1345,8 +1559,9 @@ def check_file(fpath, file_provider):
else: else:
if PLACEHOLDER_RE.search(folded): if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname) by_hand = hand_invoked(fm)
check_boundary(folded, fpath, local_fname) check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body # body
if not body.strip(): if not body.strip():

View File

@@ -179,6 +179,14 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir) safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'): for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
# A directory is a skill only if it HOLDS a SKILL.md. An empty
# leftover — a deleted skill whose directory survived, a scaffolding
# stub, an editor's stray mkdir — is untracked by git, so it exists
# on the machine that made it and nowhere else. Counting it made a
# boundary target resolve locally and dangle in a fresh clone: the
# same install-dependence the deployed-tree rule above exists to
# remove, arriving through a different door.
if os.path.isfile(os.path.join(path, 'SKILL.md')):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
@@ -483,12 +491,33 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I) CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I) CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) # CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
# `.vale.ini` — was invisible to both patterns below, and the two resulting
# failures were different sizes (issue #110):
# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep
# still extracted the target, so the route was checked, but the gate
# reported "no boundary clause" on a clause that was present and working.
# Three authors in two retrofit waves reworded a correct clause to satisfy
# the regex, one of them stripping the very filename that discriminates the
# skill from its neighbour.
# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only
# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill`
# produced no target, no dangling report and no missing-clause SUGGESTION.
# Silence, not noise — the worse of the two failure modes.
# A dot inside a filename is followed by a non-space; a sentence-ending dot is
# followed by whitespace or by end of string. So the class admits a `.` only
# when the next character is not whitespace, which crosses `AGENTS.md` and
# still stops at a real sentence end.
CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))"
ARROW_BOUNDARY = re.compile(
r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, NAME_HYPH), re.I)
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
# A boundary clause takes two shapes and BOTH count: the prose markers, and # A boundary clause takes two shapes and BOTH count: the prose markers, and
# ADR-0020's compressed arrow form `Not <thing> -> <name>`. # ADR-0020's compressed arrow form `Not <thing> -> <name>`.
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, re.I)
# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong # Sentence boundaries decide the CORROBORATION scope above, so getting one wrong
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two # is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# shapes common in these descriptions defeat the naive "period, space, capital" # shapes common in these descriptions defeat the naive "period, space, capital"
@@ -508,9 +537,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 # a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58
# SUGGESTION / 2 dangling before and after) — this protects the descriptions # SUGGESTION / 2 dangling before and after) — this protects the descriptions
# issue #99 is about to rewrite, not the ones already measured. # issue #99 is about to rewrite, not the ones already measured.
# re.I here too, and NOT as a tidy-up: this was the one pattern in the file
# built without it, contradicting the uniformity note on CONT_*/ARROW_* above.
# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is
# where an abbreviation most often lands — matched none of the lookbehinds, so
# the clause split at the abbreviation, the corroborating target was stranded on
# the far side of the cut, and a genuinely dangling target silently demoted from
# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described
# directly above, still live for exactly the capitalised half of the input.
SENTENCE_SPLIT = re.compile( SENTENCE_SPLIT = re.compile(
u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)' u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])') u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
# The token that may follow a route target without turning it into a compound # The token that may follow a route target without turning it into a compound
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a # modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
@@ -569,11 +606,26 @@ def _notation(text, start, arrow):
def _add(out, text, name, start, end, strict=None, arrow=False): def _add(out, text, name, start, end, strict=None, arrow=False):
"""Record one target as (name, may_dangle, notation).
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
The header above promises that route notation "always blocks", and for the
`/name` form that was false: `-> name` reached this function with
strict=True from its two call sites, but `/name` did not, so it fell to
_terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The
target then reached unresolved_targets() unblockable — and, before the
companion fix there, unreported as well. `... use /no-such-skill
afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an
author who wants a route checked unconditionally.
"""
if not name: if not name:
return return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name, out.append((name,
_terminal(text, end) if strict is None else strict, _terminal(text, end) if strict is None else strict,
_notation(text, start, arrow))) notation))
def _scan(text, route_re, cont_re, out): def _scan(text, route_re, cont_re, out):
@@ -632,6 +684,85 @@ def boundary_targets(description):
return sorted({name for name, _, _ in _extract(description)}) return sorted({name for name, _, _ in _extract(description)})
def _arrow_targets(description):
"""Names extracted from ARROW notation specifically.
Kept apart from boundary_targets() because the arrow form is the one shape
that ALWAYS names a target: ADR-0020's `Not <thing> -> <name>`. A clause
written that way from which nothing could be extracted is a parse failure
that deserves its own message, and telling it apart needs the arrow targets
alone rather than every target in the description.
"""
out = []
for sentence in SENTENCE_SPLIT.split(description):
for match in ARROW_MARKED.finditer(sentence):
name, _, _ = _first(match)
if name:
out.append(name)
for match in ARROW_BOUNDARY.finditer(sentence):
out.append(match.group(1))
return out
def boundary_clause_status(description):
"""'absent', 'unparsed' or 'present' — three outcomes, not two.
Issue #110's standing request: the gate must distinguish "no boundary
clause" from "boundary clause I could not parse". Reporting the first for
the second sends the author hunting for a problem that is not there, and
three of them reworded a correct clause to satisfy a regex instead.
'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was
detected and NO target came out of it. The arrow form always names one, so
zero targets means the name is written in a shape the extractor cannot see
— a single-word bare target (`Not X -> forge`, which has to be written
`` `forge` `` or `/forge`) is the live example, since single-word names are
deliberately not matchable bare.
A PROSE clause yielding no target is NOT reported: "Do not use for anything
else" is a complete and legitimate boundary clause that names nowhere to go.
"""
if BOUNDARY_ARROW.search(description) and not _arrow_targets(description):
return 'unparsed'
if has_boundary_clause(description):
return 'present'
return 'absent'
def multi_target_arrow_clauses(description):
"""[(first, second)] for arrow clauses naming more than one target.
Issue #107: only the FIRST target after an arrow is resolved. The
conjunction continuation (CONT_*) is wired to the prose route verbs and
never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither
resolved nor reported, and then printed "1 of 1 boundary target(s) resolve"
on a clause naming two — a gate under-reporting its own coverage, which is
the one failure mode ADR-0020 says a gate must not have.
The clause is REJECTED rather than the arrow scan extended. Extending it
would widen the resolver's deliberately conservative false-positive tuning
across every arrow in the corpus; rejecting costs nothing and makes the
one-arrow-per-target convention — already what every retrofitted gitea
skill does in practice — explicit instead of folkloric. The caller emits a
SUGGESTION telling the author to split.
"""
hits = []
for sentence in SENTENCE_SPLIT.split(description):
matches = (list(ARROW_MARKED.finditer(sentence))
+ list(ARROW_BOUNDARY.finditer(sentence)))
for match in matches:
first, _, _ = _first(match)
if not first:
continue
cont = CONT_ANY.match(sentence, match.end())
if not cont:
continue
second, _, _ = _first(cont)
if second:
hits.append((first, second))
return hits
def unresolved_targets(description, known): def unresolved_targets(description, known):
"""Targets resolving to nothing, split into (blocking, reported). """Targets resolving to nothing, split into (blocking, reported).
@@ -648,6 +779,17 @@ def unresolved_targets(description, known):
Everything else is reported and left alone. `known` is the resolved Everything else is reported and left alone. `known` is the resolved
universe from known_targets(); passing an empty set is not meaningful — universe from known_targets(); passing an empty set is not meaningful —
callers check for that first and decline out loud instead. callers check for that first and decline out loud instead.
A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed
whitelist of maybe eighty words, so the follower rule says "this token is
outside a list I keep" and not "this is prose" — and the old `continue`
turned that into invisibility at every tier. The gate then failed OPEN on
its own unfamiliarity: any target followed by a word nobody thought to
enumerate was neither blocked nor mentioned, so the check that did not run
said nothing about not running. The follower rule may withdraw the power to
BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note
above is the argument for it — but it may not withdraw visibility, which is
the same rule the corroboration tier already follows.
""" """
blocking, reported = set(), set() blocking, reported = set(), set()
for sentence in SENTENCE_SPLIT.split(description): for sentence in SENTENCE_SPLIT.split(description):
@@ -656,7 +798,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known} if normalize_target(name) in known}
for name, may_dangle, notation in found: for name, may_dangle, notation in found:
key = normalize_target(name) key = normalize_target(name)
if key in known or not may_dangle: if key in known:
continue
if not may_dangle:
reported.add(name)
continue continue
if notation or (resolved - {key}): if notation or (resolved - {key}):
blocking.add(name) blocking.add(name)
@@ -736,6 +881,47 @@ def description_value(fm_text):
return re.sub(r'\s+', ' ', value).strip() return re.sub(r'\s+', ' ', value).strip()
def hand_invoked(fm_text):
"""True when the frontmatter marks this file as reached only by hand.
`disable-model-invocation: true` removes a skill from the model-visible
listing entirely — it is not preloaded, and the Skill tool refuses to call
it — so its description is never matched against user intent. ADR-0020 and
skill-author's contract give such a skill ONE plain human-facing sentence:
no trigger list, no boundary clause. No validator knew the field existed
(issue #108), so the boundary-clause SUGGESTION fired on exactly the shape
the contract mandates, and its remedy — "add a boundary clause so the router
knows where NOT to send this skill" — was addressed to a router that cannot
see the skill at all. An author who followed the advice made the file worse.
Only the ROUTING rules are lifted. The body word budget still applies: the
body is loaded on invocation like any other, and competes with the caller's
live conversation the same way. So does the 400-character description FAIL —
a hand-invoked description is not preloaded, but it is still the one line
the user reads when choosing from the `/` menu, and the ceiling is the
outlier stop rather than the style target.
A parse failure returns False rather than raising. This is a MODIFIER on
other checks, not a check of its own: the frontmatter's validity is decided,
and failed, by description_value() on the same text, and raising a second
exception here would report one broken file twice with two different
diagnoses.
"""
try:
data = yaml.safe_load(fm_text)
except Exception:
return False
if not isinstance(data, dict):
return False
value = data.get('disable-model-invocation')
if isinstance(value, str):
# PyYAML already resolves the unquoted YAML 1.1 booleans, so this only
# catches a QUOTED "true" — which a host reads as truthy and which no
# gate should treat as opting back in to the routing rules.
return value.strip().lower() in ('true', 'yes', 'on')
return value is True
# --- Body-shape checks (skills only; agents have no references/ dir) ------- # --- Body-shape checks (skills only; agents have no references/ dir) -------
# Deterministic and countable, so they are enforced here. Whether a given # Deterministic and countable, so they are enforced here. Whether a given
# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why # gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why
@@ -909,6 +1095,15 @@ except FrontmatterError as exc:
dir_name = os.path.basename(skill_dir) dir_name = os.path.basename(skill_dir)
# ADR-0020's hand-invocation carve-out (issue #108). `disable-model-invocation:
# true` takes the skill out of the model-visible listing entirely, so the
# trigger/capability/boundary rules and the 250-character routing target do not
# apply to it — the audit's own references/description-quality.md Step 0 says
# so, and until this line existed no check here knew the field existed. What the
# flag does NOT lift: the body word budget and the 400-character description
# ceiling. See the shared resolver's hand_invoked().
by_hand = hand_invoked(fm)
# --- Checks --- # --- Checks ---
# name present # name present
@@ -1023,10 +1218,13 @@ if desc:
f"skill is invoked. Keep a trigger clause, at most one capability clause, " f"skill is invoked. Keep a trigger clause, at most one capability clause, "
f"and a boundary clause; move capability enumeration, output-format detail, " f"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body or README.md") f"composition notes and implementation detail to the body or README.md")
elif dlen > DESC_SUGGEST_CHARS: elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character " 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"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"what moves the corpus average; the FAIL tier only stops outliers")
elif by_hand:
ok(f"description length {dlen} chars (hand-invoked: the {DESC_SUGGEST_CHARS}-character "
f"routing target does not apply, the {DESC_MAX_CHARS}-character ceiling still does)")
else: else:
ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})") ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})")
@@ -1080,13 +1278,41 @@ if gotchas is not None:
# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether # SUGGESTION, not FAIL: detecting the absence is deterministic, but whether
# this particular skill warrants a boundary clause is judgment. Both accepted # this particular skill warrants a boundary clause is judgment. Both accepted
# shapes count — the prose markers and the compressed `Not <thing> -> <name>`. # shapes count — the prose markers and the compressed `Not <thing> -> <name>`.
if desc: #
if has_boundary_clause(desc): # THREE outcomes, not two: "no boundary clause" and "boundary clause I could not
# parse" are different findings, and reporting the first for the second sends
# the author hunting for a problem that is not there (issue #110).
#
# Skipped entirely for a hand-invoked skill — the contract gives it one plain
# sentence with no boundary clause, so the finding would be wrong and its remedy
# names a router that cannot see the skill (issue #108).
if desc and by_hand:
ok("hand-invoked (disable-model-invocation) — the boundary-clause and trigger "
"rules do not apply; audited as one plain human-facing sentence")
elif desc:
status = boundary_clause_status(desc)
if status == 'present':
ok("description has a boundary clause") ok("description has a boundary clause")
else: elif status == 'absent':
suggest("description has no boundary clause — add the prose form (\"Do not use " suggest("description has no boundary clause — add the prose form (\"Do not use "
"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") " "for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") "
"so the router knows where NOT to send this skill") "so the router knows where NOT to send this skill")
else:
suggest("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 — "
"the clause is PRESENT and unparsed, not missing. 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")
# One arrow, one target. A second name after the same arrow is resolved by
# nothing and reported by nothing, so the clause claims coverage it does not
# have and this script printed "1 of 1 boundary target(s) resolve" on a
# clause naming two (issue #107).
for first, second in multi_target_arrow_clauses(desc):
suggest(f"an arrow boundary clause names more than one target ('{first}', then "
f"'{second}') and only the first is resolved — the second is checked by "
f"nothing. Split it into one arrow per target: \"Not X -> {first}. "
f"Not Y -> {second}.\"")
# --- ADR-0020: resolvable boundary targets --------------------------------- # --- ADR-0020: resolvable boundary targets ---------------------------------
# The resolution universe comes from the SKILL's own location: the authoring # The resolution universe comes from the SKILL's own location: the authoring

View File

@@ -34,26 +34,44 @@ set -euo pipefail
# as a proxy (Python's str.split(), the same primitive # as a proxy (Python's str.split(), the same primitive
# skill-audit/scripts/validate.sh applies to these two constants; `wc -w` # skill-audit/scripts/validate.sh applies to these two constants; `wc -w`
# disagrees with it on Unicode separators, which is why the awk pass that used # disagrees with it on Unicode separators, which is why the awk pass that used
# to live in the loop below is gone). Measured over this repo's 39 in-scope SKILL.md files, characters per # to live in the loop below is gone).
# 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 / # THE MEASUREMENT BASIS, stated because the previous re-measure drifted onto a
# 1.81 tokens per word. # 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 # 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 # its median: 2770 words at the densest observed 7.34 chars/word is ~20,300
# characters, or ~5,000 tokens at the 4-characters-per-token approximation. So # characters, or ~5,090 tokens at the 4-characters-per-token approximation. So
# what this gate guarantees is "under 5,000 tokens even for the densest prose # 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 # 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 # such a file sit at exactly the ceiling and spend ~5,320 tokens. A
# median-density file at 2770 words spends ~4,700 tokens, so typical prose # median-density file at 2770 words spends ~4,620 tokens, so typical prose
# gives up ~130 words of headroom to close that gap. The largest SKILL.md in # gives up ~140 words of headroom to close that gap. Densest file today:
# the repo is 2,760 words whole-file (skill-author), twelve words under the # git-commits at 7.34 chars/word.
# ceiling — this is a gate two files have already grown into, not headroom. #
# 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 # 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 # 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 # tokenization and does not replace one. Re-measure the corpus, on the basis
# any of these numbers as still current. # 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 # 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 # dependencies rather than best-effort: python3 because pre-commit (which is how
@@ -263,6 +281,14 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir) safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'): for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
# A directory is a skill only if it HOLDS a SKILL.md. An empty
# leftover — a deleted skill whose directory survived, a scaffolding
# stub, an editor's stray mkdir — is untracked by git, so it exists
# on the machine that made it and nowhere else. Counting it made a
# boundary target resolve locally and dangle in a fresh clone: the
# same install-dependence the deployed-tree rule above exists to
# remove, arriving through a different door.
if os.path.isfile(os.path.join(path, 'SKILL.md')):
names.add(os.path.basename(path.rstrip('/')).lower()) names.add(os.path.basename(path.rstrip('/')).lower())
for sub in ('.apm/agents/*.md', 'agents/*.md'): for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)): for path in glob.glob(os.path.join(safe_dir, sub)):
@@ -567,12 +593,33 @@ ROUTE_ANY = re.compile(r"\b%s\s+(?:the\s+|an?\s+)?%s" % (ROUTE_VERB, ANY_TARGET)
CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I) CONT_MARKED = re.compile(r"\s*(?:or|and|/|,)\s*%s" % MARKED_TARGET, re.I)
CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I) CONT_ANY = re.compile(r"\s*(?:or|and|/|,)\s*%s" % ANY_TARGET, re.I)
ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I) ARROW_MARKED = re.compile(r"(?:->|→)\s*%s" % MARKED_TARGET, re.I)
ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) # CLAUSE_BODY is what may sit between `Not` and the arrow, and it is NOT
# `[^.;]`. That class cannot cross a `.`, so every boundary clause naming a
# DOTTED FILENAME between the two — `.pre-commit-config.yaml`, `AGENTS.md`,
# `.vale.ini` — was invisible to both patterns below, and the two resulting
# failures were different sizes (issue #110):
# * with a BACKTICKED target the clause was MISDIAGNOSED. The backtick sweep
# still extracted the target, so the route was checked, but the gate
# reported "no boundary clause" on a clause that was present and working.
# Three authors in two retrofit waves reworded a correct clause to satisfy
# the regex, one of them stripping the very filename that discriminates the
# skill from its neighbour.
# * with a BARE target the clause was UNCHECKED. ARROW_BOUNDARY is the only
# extractor for a bare arrow target, so `Not AGENTS.md -> no-such-skill`
# produced no target, no dangling report and no missing-clause SUGGESTION.
# Silence, not noise — the worse of the two failure modes.
# A dot inside a filename is followed by a non-space; a sentence-ending dot is
# followed by whitespace or by end of string. So the class admits a `.` only
# when the next character is not whitespace, which crosses `AGENTS.md` and
# still stops at a real sentence end.
CLAUSE_BODY = r"(?:[^.;]|\.(?=\S))"
ARROW_BOUNDARY = re.compile(
r"\bnot\b%s*?(?:->|→)\s*(%s)\b" % (CLAUSE_BODY, NAME_HYPH), re.I)
BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I)
# A boundary clause takes two shapes and BOTH count: the prose markers, and # A boundary clause takes two shapes and BOTH count: the prose markers, and
# ADR-0020's compressed arrow form `Not <thing> -> <name>`. # ADR-0020's compressed arrow form `Not <thing> -> <name>`.
BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I)
BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) BOUNDARY_ARROW = re.compile(r"\bnot\b%s*?(?:->|→)" % CLAUSE_BODY, re.I)
# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong # Sentence boundaries decide the CORROBORATION scope above, so getting one wrong
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two # is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# shapes common in these descriptions defeat the naive "period, space, capital" # shapes common in these descriptions defeat the naive "period, space, capital"
@@ -592,9 +639,17 @@ BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I)
# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 # a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58
# SUGGESTION / 2 dangling before and after) — this protects the descriptions # SUGGESTION / 2 dangling before and after) — this protects the descriptions
# issue #99 is about to rewrite, not the ones already measured. # issue #99 is about to rewrite, not the ones already measured.
# re.I here too, and NOT as a tidy-up: this was the one pattern in the file
# built without it, contradicting the uniformity note on CONT_*/ARROW_* above.
# Without the flag `E.g.` and `I.e.` — the sentence-initial spellings, which is
# where an abbreviation most often lands — matched none of the lookbehinds, so
# the clause split at the abbreviation, the corroborating target was stranded on
# the far side of the cut, and a genuinely dangling target silently demoted from
# blocking ERROR to SUGGESTION. That is the OVER-SPLIT failure described
# directly above, still live for exactly the capitalised half of the input.
SENTENCE_SPLIT = re.compile( SENTENCE_SPLIT = re.compile(
u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)' u'(?<!\\be\\.g\\.)(?<!\\bi\\.e\\.)(?<!\\betc\\.)(?<!\\bvs\\.)(?<!\\bcf\\.)'
u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])') u'(?<=[.!?])\\s+(?=[A-Za-z`"“(])', re.I)
# The token that may follow a route target without turning it into a compound # The token that may follow a route target without turning it into a compound
# modifier: punctuation, end of sentence, a conjunction, a boundary word, or a # modifier: punctuation, end of sentence, a conjunction, a boundary word, or a
@@ -653,11 +708,26 @@ def _notation(text, start, arrow):
def _add(out, text, name, start, end, strict=None, arrow=False): def _add(out, text, name, start, end, strict=None, arrow=False):
"""Record one target as (name, may_dangle, notation).
NOTATION IS DECIDED FIRST, and when it is set the follower test is skipped.
The header above promises that route notation "always blocks", and for the
`/name` form that was false: `-> name` reached this function with
strict=True from its two call sites, but `/name` did not, so it fell to
_terminal() and a follower outside FOLLOWER_OK set may_dangle=False. The
target then reached unresolved_targets() unblockable — and, before the
companion fix there, unreported as well. `... use /no-such-skill
afterwards.` exited 0 in total silence, on the one form ADR-0020 offers an
author who wants a route checked unconditionally.
"""
if not name: if not name:
return return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name, out.append((name,
_terminal(text, end) if strict is None else strict, _terminal(text, end) if strict is None else strict,
_notation(text, start, arrow))) notation))
def _scan(text, route_re, cont_re, out): def _scan(text, route_re, cont_re, out):
@@ -716,6 +786,85 @@ def boundary_targets(description):
return sorted({name for name, _, _ in _extract(description)}) return sorted({name for name, _, _ in _extract(description)})
def _arrow_targets(description):
"""Names extracted from ARROW notation specifically.
Kept apart from boundary_targets() because the arrow form is the one shape
that ALWAYS names a target: ADR-0020's `Not <thing> -> <name>`. A clause
written that way from which nothing could be extracted is a parse failure
that deserves its own message, and telling it apart needs the arrow targets
alone rather than every target in the description.
"""
out = []
for sentence in SENTENCE_SPLIT.split(description):
for match in ARROW_MARKED.finditer(sentence):
name, _, _ = _first(match)
if name:
out.append(name)
for match in ARROW_BOUNDARY.finditer(sentence):
out.append(match.group(1))
return out
def boundary_clause_status(description):
"""'absent', 'unparsed' or 'present' — three outcomes, not two.
Issue #110's standing request: the gate must distinguish "no boundary
clause" from "boundary clause I could not parse". Reporting the first for
the second sends the author hunting for a problem that is not there, and
three of them reworded a correct clause to satisfy a regex instead.
'unparsed' is the narrow, certain case: an ADR-0020 arrow clause was
detected and NO target came out of it. The arrow form always names one, so
zero targets means the name is written in a shape the extractor cannot see
— a single-word bare target (`Not X -> forge`, which has to be written
`` `forge` `` or `/forge`) is the live example, since single-word names are
deliberately not matchable bare.
A PROSE clause yielding no target is NOT reported: "Do not use for anything
else" is a complete and legitimate boundary clause that names nowhere to go.
"""
if BOUNDARY_ARROW.search(description) and not _arrow_targets(description):
return 'unparsed'
if has_boundary_clause(description):
return 'present'
return 'absent'
def multi_target_arrow_clauses(description):
"""[(first, second)] for arrow clauses naming more than one target.
Issue #107: only the FIRST target after an arrow is resolved. The
conjunction continuation (CONT_*) is wired to the prose route verbs and
never to arrows, so `Not X -> a or b` resolved `a`, left `b` neither
resolved nor reported, and then printed "1 of 1 boundary target(s) resolve"
on a clause naming two — a gate under-reporting its own coverage, which is
the one failure mode ADR-0020 says a gate must not have.
The clause is REJECTED rather than the arrow scan extended. Extending it
would widen the resolver's deliberately conservative false-positive tuning
across every arrow in the corpus; rejecting costs nothing and makes the
one-arrow-per-target convention — already what every retrofitted gitea
skill does in practice — explicit instead of folkloric. The caller emits a
SUGGESTION telling the author to split.
"""
hits = []
for sentence in SENTENCE_SPLIT.split(description):
matches = (list(ARROW_MARKED.finditer(sentence))
+ list(ARROW_BOUNDARY.finditer(sentence)))
for match in matches:
first, _, _ = _first(match)
if not first:
continue
cont = CONT_ANY.match(sentence, match.end())
if not cont:
continue
second, _, _ = _first(cont)
if second:
hits.append((first, second))
return hits
def unresolved_targets(description, known): def unresolved_targets(description, known):
"""Targets resolving to nothing, split into (blocking, reported). """Targets resolving to nothing, split into (blocking, reported).
@@ -732,6 +881,17 @@ def unresolved_targets(description, known):
Everything else is reported and left alone. `known` is the resolved Everything else is reported and left alone. `known` is the resolved
universe from known_targets(); passing an empty set is not meaningful — universe from known_targets(); passing an empty set is not meaningful —
callers check for that first and decline out loud instead. callers check for that first and decline out loud instead.
A NON-TERMINAL target is reported, never dropped. FOLLOWER_OK is a closed
whitelist of maybe eighty words, so the follower rule says "this token is
outside a list I keep" and not "this is prose" — and the old `continue`
turned that into invisibility at every tier. The gate then failed OPEN on
its own unfamiliarity: any target followed by a word nobody thought to
enumerate was neither blocked nor mentioned, so the check that did not run
said nothing about not running. The follower rule may withdraw the power to
BLOCK a commit — that is what it was added for, and the ATTRIBUTIVE USE note
above is the argument for it — but it may not withdraw visibility, which is
the same rule the corroboration tier already follows.
""" """
blocking, reported = set(), set() blocking, reported = set(), set()
for sentence in SENTENCE_SPLIT.split(description): for sentence in SENTENCE_SPLIT.split(description):
@@ -740,7 +900,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known} if normalize_target(name) in known}
for name, may_dangle, notation in found: for name, may_dangle, notation in found:
key = normalize_target(name) key = normalize_target(name)
if key in known or not may_dangle: if key in known:
continue
if not may_dangle:
reported.add(name)
continue continue
if notation or (resolved - {key}): if notation or (resolved - {key}):
blocking.add(name) blocking.add(name)
@@ -820,6 +983,47 @@ def description_value(fm_text):
return re.sub(r'\s+', ' ', value).strip() return re.sub(r'\s+', ' ', value).strip()
def hand_invoked(fm_text):
"""True when the frontmatter marks this file as reached only by hand.
`disable-model-invocation: true` removes a skill from the model-visible
listing entirely — it is not preloaded, and the Skill tool refuses to call
it — so its description is never matched against user intent. ADR-0020 and
skill-author's contract give such a skill ONE plain human-facing sentence:
no trigger list, no boundary clause. No validator knew the field existed
(issue #108), so the boundary-clause SUGGESTION fired on exactly the shape
the contract mandates, and its remedy — "add a boundary clause so the router
knows where NOT to send this skill" — was addressed to a router that cannot
see the skill at all. An author who followed the advice made the file worse.
Only the ROUTING rules are lifted. The body word budget still applies: the
body is loaded on invocation like any other, and competes with the caller's
live conversation the same way. So does the 400-character description FAIL —
a hand-invoked description is not preloaded, but it is still the one line
the user reads when choosing from the `/` menu, and the ceiling is the
outlier stop rather than the style target.
A parse failure returns False rather than raising. This is a MODIFIER on
other checks, not a check of its own: the frontmatter's validity is decided,
and failed, by description_value() on the same text, and raising a second
exception here would report one broken file twice with two different
diagnoses.
"""
try:
data = yaml.safe_load(fm_text)
except Exception:
return False
if not isinstance(data, dict):
return False
value = data.get('disable-model-invocation')
if isinstance(value, str):
# PyYAML already resolves the unquoted YAML 1.1 booleans, so this only
# catches a QUOTED "true" — which a host reads as truthy and which no
# gate should treat as opting back in to the routing rules.
return value.strip().lower() in ('true', 'yes', 'on')
return value is True
# --- Body-shape checks (skills only; agents have no references/ dir) ------- # --- Body-shape checks (skills only; agents have no references/ dir) -------
# Deterministic and countable, so they are enforced here. Whether a given # Deterministic and countable, so they are enforced here. Whether a given
# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why # gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why
@@ -1009,6 +1213,9 @@ for path in files:
body = content[fm_match.end():] body = content[fm_match.end():]
skill_dir = os.path.dirname(os.path.abspath(path)) 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. # 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 # All three ADR-0020 scripts have to agree on this input: the description is
@@ -1030,7 +1237,12 @@ for path in files:
"enumeration, output-format detail, composition notes and implementation " "enumeration, output-format detail, composition notes and implementation "
"detail to the body or README.md." "detail to the body or README.md."
% (path, dlen, DESC_MAX_CHARS)) % (path, dlen, DESC_MAX_CHARS))
elif dlen > DESC_SUGGEST_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 " suggest("%s: description is %d characters, over the %d-character target "
"(ADR-0020, hard fail at %d)." "(ADR-0020, hard fail at %d)."
% (path, dlen, DESC_SUGGEST_CHARS, DESC_MAX_CHARS)) % (path, dlen, DESC_SUGGEST_CHARS, DESC_MAX_CHARS))
@@ -1073,15 +1285,40 @@ for path in files:
round(100.0 * section_words / body_words), round(100.0 * section_words / body_words),
round(100.0 * GOTCHA_MAX_BODY_FRACTION))) round(100.0 * GOTCHA_MAX_BODY_FRACTION)))
# Missing boundary clause. SUGGESTION, not ERROR: detecting the absence is # Boundary clause. SUGGESTION, not ERROR: detecting the absence is
# deterministic, but whether this particular skill warrants one is the # deterministic, but whether this particular skill warrants one is the
# auditor's call. Both accepted shapes count — the prose markers and # auditor's call. Both accepted shapes count — the prose markers and
# ADR-0020's compressed `Not <thing> -> <name>` arrow. # ADR-0020's compressed `Not <thing> -> <name>` arrow.
if desc and not has_boundary_clause(desc): #
# 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 " 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 " "(\"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." "(\"Not X -> y\") so the router knows where NOT to send this skill."
% path) % 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) targets = boundary_targets(desc)
if targets: if targets:

View File

@@ -92,8 +92,14 @@ build_tree "$TMPDIR_T/no-claude"
build_tree "$TMPDIR_T/with-claude" build_tree "$TMPDIR_T/with-claude"
# The deployed tree, present only in the second root. Both a skill and an agent, # The deployed tree, present only in the second root. Both a skill and an agent,
# because both are valid routing targets and both would leak. # because both are valid routing targets and both would leak.
mkdir -p "$TMPDIR_T/with-claude/.claude/skills/deployed-only-skill" \ #
"$TMPDIR_T/with-claude/.claude/agents" # The skill gets a real SKILL.md. That is not decoration: a directory under
# skills/ is a resolvable name only when it HOLDS one, so an empty directory
# would dangle for the wrong reason and the assertion below would pass without
# testing the deployed-tree rule at all.
mkdir -p "$TMPDIR_T/with-claude/.claude/agents"
write_skill "$TMPDIR_T/with-claude/.claude/skills/deployed-only-skill" deployed-only-skill \
"Use when doing the deployed thing. Do not use for anything else."
: > "$TMPDIR_T/with-claude/.claude/agents/deployed-only-agent.md" : > "$TMPDIR_T/with-claude/.claude/agents/deployed-only-agent.md"
run_subject() { run_subject() {
@@ -133,7 +139,8 @@ fi
echo "" echo ""
echo "--- with no authoring root, a deployed .claude/ tree IS the universe ---" echo "--- with no authoring root, a deployed .claude/ tree IS the universe ---"
CONSUMER="$TMPDIR_T/consumer" CONSUMER="$TMPDIR_T/consumer"
mkdir -p "$CONSUMER/.claude/skills/deployed-only-skill" write_skill "$CONSUMER/.claude/skills/deployed-only-skill" deployed-only-skill \
"Use when doing the deployed thing. Do not use for anything else."
write_skill "$CONSUMER/.claude/skills/my-skill" my-skill \ write_skill "$CONSUMER/.claude/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use deployed-only-skill instead." "Use when doing the thing. Do not use for the other thing — use deployed-only-skill instead."
set +e set +e
@@ -384,8 +391,12 @@ for bait_root in "$BAIT_FRESH" "$BAIT_DEPLOYED"; do
write_skill "$bait_root/plugins/bin/.apm/skills/deployed-tree-probe" deployed-tree-probe \ write_skill "$bait_root/plugins/bin/.apm/skills/deployed-tree-probe" deployed-tree-probe \
"Use when doing the probe thing. Do not use for the other thing — use $BAIT_NAME instead." "Use when doing the probe thing. Do not use for the other thing — use $BAIT_NAME instead."
done done
# Only the deployed copy gets the name planted where `apm install` would put it. # Only the deployed copy gets the name planted where `apm install` would put it,
mkdir -p "$BAIT_DEPLOYED/.claude/skills/$BAIT_NAME" "$BAIT_DEPLOYED/.claude/agents" # as a REAL skill directory holding a SKILL.md — an empty directory is not a
# resolvable name, so baiting with one would make the A/B pass vacuously.
mkdir -p "$BAIT_DEPLOYED/.claude/agents"
write_skill "$BAIT_DEPLOYED/.claude/skills/$BAIT_NAME" "$BAIT_NAME" \
"Use when doing the bait thing. Do not use for anything else."
BAIT_FRESH_DANGLING="$(dangling_set "$BAIT_FRESH/plugins")" BAIT_FRESH_DANGLING="$(dangling_set "$BAIT_FRESH/plugins")"
BAIT_DEPLOYED_DANGLING="$(dangling_set "$BAIT_DEPLOYED/plugins")" BAIT_DEPLOYED_DANGLING="$(dangling_set "$BAIT_DEPLOYED/plugins")"
@@ -511,13 +522,24 @@ grammar_case() {
# The four phrasings that were hard dangling FAILs with no suppression. All four # The four phrasings that were hard dangling FAILs with no suppression. All four
# are lifted from real descriptions in this corpus. # are lifted from real descriptions in this corpus.
grammar_case fp-precommit-hooks silent "" \ #
# THEY ARE `suggests`, NOT `silent`, AND THE DIFFERENCE IS THE POINT. The
# follower rule takes away the power to BLOCK a commit on a compound modifier;
# it does not take away visibility, and it used to. FOLLOWER_OK is a closed
# whitelist of about eighty words, so a non-terminal verdict means "the next
# token is outside a list someone maintains by hand", not "this is prose" — and
# `continue`ing on it made the gate fail OPEN on its own unfamiliarity: any
# target followed by an unlisted word was neither blocked nor mentioned at any
# tier. Asserting silence here pinned that hole in place. The assertion that
# still matters is `!= ERROR`, which `suggests` checks, and which is what keeps
# a false positive from stopping a commit.
grammar_case fp-precommit-hooks suggests "routes to 'pre-commit'" \
"Use when running the linter. Use pre-commit hooks instead of ad-hoc scripts." "Use when running the linter. Use pre-commit hooks instead of ad-hoc scripts."
grammar_case fp-pull-request silent "" \ grammar_case fp-pull-request suggests "routes to 'pull-request'" \
"Use when opening changes. Invoke the pull-request template instead of writing one by hand." "Use when opening changes. Invoke the pull-request template instead of writing one by hand."
grammar_case fp-conventional silent "" \ grammar_case fp-conventional suggests "routes to 'conventional-commits'" \
"Use when writing history. Use conventional-commits formatting rather than free-form messages." "Use when writing history. Use conventional-commits formatting rather than free-form messages."
grammar_case fp-prepush-backticked silent "" \ grammar_case fp-prepush-backticked suggests "routes to 'pre-push'" \
"Use when checking a branch. Do not use for local edits — run the \`pre-push\` hooks instead." "Use when checking a branch. Do not use for local edits — run the \`pre-push\` hooks instead."
echo "" echo ""
@@ -599,6 +621,165 @@ grammar_case lowercase-start suggests "routes to 'no-such-lower-skill'" \
grammar_case backtick-start suggests "routes to 'no-such-tick-skill'" \ grammar_case backtick-start suggests "routes to 'no-such-tick-skill'" \
"Use when doing the thing. Use sibling-skill for the main case. \`no-such-tick-skill\` is not for this — do not use it instead." "Use when doing the thing. Use sibling-skill for the main case. \`no-such-tick-skill\` is not for this — do not use it instead."
# ---------------------------------------------------------------------------
# 2a. Route NOTATION always blocks, whatever token follows it
# ---------------------------------------------------------------------------
# FOLLOWER_OK is a closed whitelist of about eighty words. A target followed by
# anything outside it was non-terminal, and `/name` reached _add() with
# strict=None, so it fell to the follower test and lost the power to block —
# contradicting the header's own promise that route notation "always blocks",
# for the one form Claude Code actually uses. Combined with the old `continue`
# in unresolved_targets(), `use /no-such-skill afterwards.` exited 0 with no
# output at all: the gate failed OPEN on a word nobody had thought to enumerate.
#
# "afterwards" is the probe in every case below. It is ordinary English, it is
# not in FOLLOWER_OK, and it is not going to be added to it.
echo ""
echo "--- route notation blocks even when the following token is outside FOLLOWER_OK ---"
grammar_case notation-slash-unlisted errors "routes to 'no-such-slash-skill'" \
"Use when doing the thing. Do not use for improvements — use /no-such-slash-skill afterwards."
grammar_case notation-arrow-unlisted errors "routes to 'no-such-arrow-skill'" \
"Use when doing the thing. Not the other thing -> no-such-arrow-skill afterwards."
echo ""
echo "--- a target the follower rule cannot vouch for is REPORTED, never invisible ---"
# The other half of the same defect, and the one that cost visibility rather
# than enforcement: a PROSE-form target with an unlisted follower may not block
# (that is what the follower rule is for) but it must still be named. Silence
# here is the vacuous-green shape the whole script forbids itself.
grammar_case follower-unlisted-bare suggests "routes to 'no-such-modifier-skill'" \
"Use when doing the thing. Do not use for improvements — use no-such-modifier-skill afterwards."
grammar_case follower-unlisted-backticked suggests "routes to 'no-such-ticked-skill'" \
"Use when doing the thing. Do not use for improvements — use \`no-such-ticked-skill\` afterwards."
# ---------------------------------------------------------------------------
# 2b. Capitalised abbreviations do not over-split a sentence
# ---------------------------------------------------------------------------
# SENTENCE_SPLIT was the one pattern in the resolver built without re.I, so its
# five abbreviation lookbehinds only covered the lowercase spelling. `E.g.` and
# `I.e.` — the SENTENCE-INITIAL spellings, which is exactly where an
# abbreviation lands — matched none of them. The clause split at the
# abbreviation, the corroborating target was stranded on the far side of the
# cut, and a genuinely dangling target silently demoted from blocking ERROR to
# SUGGESTION. The lowercase twin of each case below is `abbrev-split` above and
# already passed, which is precisely why the gap survived.
echo ""
echo "--- a CAPITALISED abbreviation does not strand the corroborator ---"
grammar_case abbrev-split-caps-eg errors "routes to 'no-such-caps-eg-skill'" \
"Use when doing the thing. Do not use for improvements — use sibling-skill first, E.g. \"run the audit\", then use no-such-caps-eg-skill instead."
grammar_case abbrev-split-caps-ie errors "routes to 'no-such-caps-ie-skill'" \
"Use when doing the thing. Do not use for improvements — use sibling-skill first, I.e. \"run the audit\", then use no-such-caps-ie-skill instead."
# ---------------------------------------------------------------------------
# 2c. A boundary clause naming a dotted filename (issue #110)
# ---------------------------------------------------------------------------
# `[^.;]` cannot cross the `.` in `AGENTS.md` or `.pre-commit-config.yaml`, so a
# clause naming a dotted file between "Not" and the arrow was invisible to both
# BOUNDARY_ARROW and ARROW_BOUNDARY. Two different failures came out of that:
# with a backticked target the clause was merely MISDIAGNOSED as missing, and
# with a BARE target it was never extracted at all, so the dangling check
# silently did not run on it. Both directions are pinned.
echo ""
echo "--- a boundary clause naming a dotted filename is seen, and its target is checked ---"
grammar_case dotted-bare-target errors "routes to 'no-such-dotted-skill'" \
"Use when doing the thing. Not AGENTS.md -> no-such-dotted-skill."
grammar_case dotted-clause-seen silent "" \
"Use when doing the thing. Not .pre-commit-config.yaml -> sibling-skill."
# The guard that makes the fix a fix and not a hole: a REAL sentence end still
# ends the clause. A `.` followed by whitespace terminates it exactly as before,
# so "Not applicable here." plus an arrow two sentences later is not a boundary
# clause and is still reported as one missing.
grammar_case dotted-sentence-end-guard suggests "has no boundary clause" \
"Use when doing the thing. Not applicable here. Reproduce -> minimise."
# ---------------------------------------------------------------------------
# 2d. "Present but unparsed" is a different finding from "missing"
# ---------------------------------------------------------------------------
# Issue #110's standing request. An arrow clause ALWAYS names a target, so one
# that yields none is a parse failure and must say so — telling the author the
# clause is missing sends them to add a second copy of a clause that is already
# there. The live shape is a single-word target, which is deliberately not
# matchable bare because `research`, `triage` and `forge` are all skill names
# AND ordinary English.
echo ""
echo "--- an arrow clause that yields no target is reported as unparsed, not as missing ---"
grammar_case arrow-single-word-target suggests "no target could be read" \
"Use when doing the thing. Not the other thing -> forge."
# Control, so the case above is not satisfied by a check that fires on every
# arrow clause: the same clause with the target written in a shape the extractor
# can see produces nothing at all.
grammar_case arrow-single-word-marked silent "" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\`."
# ---------------------------------------------------------------------------
# 2e. One arrow, one target (issue #107)
# ---------------------------------------------------------------------------
# Only the first target after an arrow is resolved: the conjunction continuation
# is wired to the prose route verbs and never to arrows. So the second name in
# `Not X -> a or b` was resolved by nothing and reported by nothing, and the
# audit then printed "1 of 1 boundary target(s) resolve" on a clause naming two.
# A typo in the second target shipped through a green gate.
#
# The fix rejects the shape rather than widening the extractor. The case below
# is the exact failure: a bare `Not ... ->` sentence carries no BOUNDARY_MARKER,
# so the backtick sweep does not run and the second target is genuinely
# invisible to every other rule in the resolver.
echo ""
echo "--- an arrow clause naming two targets is rejected, so the unchecked one is visible ---"
grammar_case multi-arrow-second-target suggests "names more than one target" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\` or \`no-such-second-target\`."
grammar_case multi-arrow-comma suggests "names more than one target" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\`, \`no-such-comma-target\`."
# Control: one arrow, one target — the convention the SUGGESTION is asking for —
# stays silent. Without this the case above is satisfied by a check that fires
# on every arrow clause in the corpus.
grammar_case multi-arrow-control silent "" \
"Use when doing the thing. Not the other thing -> \`sibling-skill\`."
# ---------------------------------------------------------------------------
# 2f. A skill directory with no SKILL.md is not a skill
# ---------------------------------------------------------------------------
# _collect_package() added a name for every directory matching skills/*/, with
# no check that anything was in it. A leftover empty directory — a deleted skill
# whose directory survived, a scaffolding stub, an editor's stray mkdir — is
# untracked by git, so it exists on the machine that made it and nowhere else.
# The hook went green locally and red in a fresh clone: the same
# install-dependence the deployed-tree rule exists to remove, arriving through a
# different door. Both directions are asserted, because "never resolve" would
# also satisfy the first half.
echo ""
echo "--- an empty skills/<name>/ directory does not make a routing target resolve ---"
GHOST="$TMPDIR_T/ghost-dir"
write_skill "$GHOST/plugins/p/.apm/skills/my-skill" my-skill \
"Use when doing the thing. Do not use for the other thing — use /ghost-skill instead."
mkdir -p "$GHOST/plugins/p/.apm/skills/ghost-skill"
# NOT wrapped in a helper function: command substitution runs the body in a
# subshell, so an exit status assigned inside one never reaches the caller —
# under `set -u` the second read of it aborts the suite.
set +e
GHOST_OUT="$(bash "$HOOK" "$GHOST/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)"
GHOST_RC=$?
set -e
if [[ $GHOST_RC -ne 0 && "$GHOST_OUT" == *"routes to 'ghost-skill'"* ]]; then
pass "a directory with no SKILL.md in it is not a resolvable name"
else
fail "an empty skills/ghost-skill/ directory resolved a routing target (exit $GHOST_RC): ${GHOST_OUT:-<empty>}"
fi
# The confirming half: drop a SKILL.md into the same directory and the identical
# description resolves. Without this the rule could be implemented as "skills/
# never contributes anything" and still pass above.
write_skill "$GHOST/plugins/p/.apm/skills/ghost-skill" ghost-skill \
"Use when doing the other thing. Do not use for anything else."
set +e
GHOST_OUT="$(bash "$HOOK" "$GHOST/plugins/p/.apm/skills/my-skill/SKILL.md" 2>&1)"
GHOST_RC=$?
set -e
if [[ $GHOST_RC -eq 0 && -z "$GHOST_OUT" ]]; then
pass "the same directory WITH a SKILL.md resolves, so the rule is 'no SKILL.md' and not 'never'"
else
fail "a populated skills/ghost-skill/ directory still did not resolve (exit $GHOST_RC): ${GHOST_OUT:-<empty>}"
fi
# And the confirming half of the grammar rule: a compound-modifier target is # And the confirming half of the grammar rule: a compound-modifier target is
# CONFIRM-ONLY, not ignored. When the name does exist it still counts as a route # CONFIRM-ONLY, not ignored. When the name does exist it still counts as a route
# — the rule suppresses the ERROR, it does not delete the target. # — the rule suppresses the ERROR, it does not delete the target.

View File

@@ -331,13 +331,32 @@ PY
# #
# The sibling plugin is what makes "every plugin in the monorepo contributes its # The sibling plugin is what makes "every plugin in the monorepo contributes its
# names" testable; without it a cross-plugin target and a typo are the same. # names" testable; without it a cross-plugin target and a typo are the same.
#
# Both sibling skill directories get a real SKILL.md, and that is load-bearing
# rather than tidiness: a skill directory is a resolvable name only if it HOLDS
# a SKILL.md. An empty leftover directory is untracked by git, so counting one
# made a target resolve on the machine that made it and dangle in a fresh clone
# — the same install-dependence the deployed-tree rule exists to remove. This
# fixture used to `mkdir` the two siblings and write nothing into them, so it
# was itself relying on the behaviour the resolver no longer has.
make_tree_fixture() { make_tree_fixture() {
local label="$1" desc="$2" body_words="$3" root apm local label="$1" desc="$2" body_words="$3" root apm sib
root="$TMPDIR/tree-$label" root="$TMPDIR/tree-$label"
apm="$root/plugins/subject-plugin/.apm" apm="$root/plugins/subject-plugin/.apm"
mkdir -p "$apm/skills/$label" "$apm/skills/sibling-skill" "$apm/agents" \ mkdir -p "$apm/skills/$label" "$apm/skills/sibling-skill" "$apm/agents" \
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill" "$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"
: > "$apm/agents/sibling-agent.agent.md" : > "$apm/agents/sibling-agent.agent.md"
for sib in "$apm/skills/sibling-skill" \
"$root/plugins/other-plugin/.apm/skills/cross-plugin-skill"; do
{
echo "---"
echo "name: $(basename "$sib")"
echo "description: Use when doing the other thing. Do not use for anything else."
echo "---"
echo ""
echo "Do the thing."
} > "$sib/SKILL.md"
done
{ {
echo "---" echo "---"
echo "name: $label" echo "name: $label"
@@ -364,8 +383,17 @@ expect_gate() {
fail "$label (exit $status, output: ${out:-<empty>})" fail "$label (exit $status, output: ${out:-<empty>})"
fi fi
;; ;;
# The tier and the needle are matched ADJACENTLY — `*"SUGGESTION"*"$needle"*`
# — not as two independent substring tests. Independently, any output
# carrying a SUGGESTION anywhere and the needle anywhere satisfied the
# assertion, so a needle emitted at the WRONG TIER still passed: a finding
# that moved from SUGGESTION to a blocking ERROR line would be caught only
# by the exit-status test, and one that moved from SUGGESTION to INFO would
# not be caught at all. grammar_case's `suggests` branch in
# tests/test-adr0020-targets.sh has always matched them adjacently; this is
# the same rule.
suggest) suggest)
if [[ $status -eq 0 && "$out" == *"SUGGESTION"* && "$out" == *"$needle"* ]]; then if [[ $status -eq 0 && "$out" == *"SUGGESTION"*"$needle"* ]]; then
pass "$label" pass "$label"
else else
fail "$label (exit $status, output: ${out:-<empty>})" fail "$label (exit $status, output: ${out:-<empty>})"
@@ -450,9 +478,14 @@ expect_gate "body at $((BODY_MAX_WORDS + 1)) words fails" \
echo "" echo ""
echo "--- the body gate and the whole-file gate are independent measurements ---" echo "--- the body gate and the whole-file gate are independent measurements ---"
BODY_ONLY_DESC="$(python3 -c "print(' '.join(['w'] * 100))")" BODY_ONLY_DESC="$(python3 -c "print(' '.join(['w'] * 100))")"
# The needle pins the COUNT, not the bare word "words". "words" appears in the
# whole-file ceiling message, in the body ceiling message and in the body target
# message alike, so it was satisfied by any of the three — including the one
# this case exists to prove does NOT fire. Naming the number is what makes the
# assertion about the body-only measurement.
expect_gate "frontmatter words do not count toward the $BODY_MAX_WORDS-word body ceiling" \ expect_gate "frontmatter words do not count toward the $BODY_MAX_WORDS-word body ceiling" \
suggest "$(make_budget_fixture body-independent "$BODY_ONLY_DESC" "$((BODY_MAX_WORDS - 5))")" \ suggest "$(make_budget_fixture body-independent "$BODY_ONLY_DESC" "$((BODY_MAX_WORDS - 5))")" \
"words" "body is $((BODY_MAX_WORDS - 5)) words"
BIG_BODY="$(make_budget_fixture body-over-not-whole-file "$CLEAN_DESC" "$((BODY_MAX_WORDS + 1))")" BIG_BODY="$(make_budget_fixture body-over-not-whole-file "$CLEAN_DESC" "$((BODY_MAX_WORDS + 1))")"
BIG_BODY_WORDS="$(wc -w < "$BIG_BODY")" BIG_BODY_WORDS="$(wc -w < "$BIG_BODY")"
if [[ "$BIG_BODY_WORDS" -le "$MAX_WORDS" ]]; then if [[ "$BIG_BODY_WORDS" -le "$MAX_WORDS" ]]; then
@@ -461,6 +494,96 @@ else
fail "the body-gate fixture is $BIG_BODY_WORDS whole-file words, which also trips MAX_WORDS=$MAX_WORDS — the test no longer isolates the body gate" fail "the body-gate fixture is $BIG_BODY_WORDS whole-file words, which also trips MAX_WORDS=$MAX_WORDS — the test no longer isolates the body gate"
fi fi
# ---------------------------------------------------------------------------
# ADR-0020's hand-invocation carve-out (issue #108)
# ---------------------------------------------------------------------------
# A skill carrying `disable-model-invocation: true` is removed from the
# model-visible listing entirely — it is not preloaded, and the Skill tool
# refuses to call it — so its description is never matched against user intent.
# ADR-0020, skill-author Step 2 and skill-audit's own Step 0 all give it ONE
# plain human-facing sentence: no trigger list, no boundary clause. No validator
# knew the field existed, so the boundary-clause SUGGESTION fired on exactly the
# shape the contract mandates, and its remedy — "so the router knows where NOT
# to send this skill" — named a router that cannot see the skill at all.
#
# The carve-out is NARROW and the half it does not cover is the half worth
# testing: the body is still loaded on invocation, so the body budget stands,
# and the 400-character ceiling stands because it is an outlier stop rather than
# a routing-quality target. Every case below asserts one of those two halves.
make_hand_invoked_fixture() {
local name="$1" desc="$2" body_words="$3" file
file="$TMPDIR/$name.md"
{
echo "---"
echo "name: $name"
echo "description: $desc"
echo "disable-model-invocation: true"
echo "---"
echo ""
python3 -c "print(' '.join(['word'] * $body_words))"
} > "$file"
echo "$file"
}
# A description over the 250-character target, carrying no boundary clause and
# no routing target — the exact shape `zoom-out` and `caveman` ship. Built with
# no hyphens so nothing in it reads as a target.
HAND_DESC="$(python3 -c "
prefix = 'Tell the agent to zoom out and give broader context. '
print(prefix + 'x' * (300 - len(prefix)))")"
echo ""
echo "--- a hand-invoked skill is exempt from the routing rules, and only those ---"
expect_gate "a hand-invoked skill with a 300-char description and no boundary clause is silent" \
pass "$(make_hand_invoked_fixture hand-quiet "$HAND_DESC" 10)"
# The control that makes the case above mean something. Same description, same
# body, only the frontmatter flag removed: both findings must appear, or the
# exemption is being credited for silence it did not cause.
expect_gate "control: the SAME description without the flag is over the 250-char target" \
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
"description is 300 characters"
expect_gate "control: the SAME description without the flag has no boundary clause" \
suggest "$(make_budget_fixture hand-control "$HAND_DESC" 10)" \
"has no boundary clause"
echo ""
echo "--- the carve-out lifts the routing rules ONLY: both size gates still bite ---"
# The description ceiling is not a routing budget: a hand-invoked description is
# still the one line a human reads in the `/` menu, and 400 characters is the
# outlier stop either way.
HAND_OVER_MAX="$(python3 -c "
prefix = 'Tell the agent to zoom out and give broader context. '
print(prefix + 'x' * (401 - len(prefix)))")"
expect_gate "a hand-invoked description over $DESC_MAX_CHARS chars still FAILS" \
fail "$(make_hand_invoked_fixture hand-over-max "$HAND_OVER_MAX" 10)" \
"$DESC_MAX_CHARS-character ceiling"
# The body is loaded on invocation like any other body and competes with the
# caller's live conversation exactly the same way, so neither body tier moves.
expect_gate "a hand-invoked body over $BODY_MAX_WORDS words still FAILS" \
fail "$(make_hand_invoked_fixture hand-over-body "$HAND_DESC" "$((BODY_MAX_WORDS + 1))")" \
"$BODY_MAX_WORDS-word ceiling"
expect_gate "a hand-invoked body over $BODY_SUGGEST_WORDS words is still suggested" \
suggest "$(make_hand_invoked_fixture hand-over-body-suggest "$HAND_DESC" "$((BODY_SUGGEST_WORDS + 1))")" \
"body is $((BODY_SUGGEST_WORDS + 1)) words"
echo ""
echo "--- the flag is read as a BOOLEAN, not as any mention of the key ---"
# `disable-model-invocation: false` is the model-invoked case written out
# longhand. Reading the key's presence instead of its value would hand every
# routing exemption to anyone who typed the field at all.
HAND_FALSE="$TMPDIR/hand-false.md"
{
echo "---"
echo "name: hand-false"
echo "description: $HAND_DESC"
echo "disable-model-invocation: false"
echo "---"
echo ""
echo "Do the thing."
} > "$HAND_FALSE"
expect_gate "disable-model-invocation: false is NOT the carve-out" \
suggest "$HAND_FALSE" "has no boundary clause"
echo "" echo ""
echo "--- resolvable boundary targets ---" echo "--- resolvable boundary targets ---"
# Resolution is against the AUTHORING SOURCE (plugins/*/.apm/skills/ and # Resolution is against the AUTHORING SOURCE (plugins/*/.apm/skills/ and