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,7 +253,15 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)):
names.add(os.path.basename(path.rstrip('/')).lower())
# 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())
for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)):
base = os.path.basename(path)
@@ -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_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_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)
# A boundary clause takes two shapes and BOTH count: the prose markers, and
# 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_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
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# 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
# SUGGESTION / 2 dangling before and after) — this protects the descriptions
# 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(
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
# 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):
"""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:
return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name,
_terminal(text, end) if strict is None else strict,
_notation(text, start, arrow)))
notation))
def _scan(text, route_re, cont_re, out):
@@ -706,6 +758,85 @@ def boundary_targets(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):
"""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
universe from known_targets(); passing an empty set is not meaningful —
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()
for sentence in SENTENCE_SPLIT.split(description):
@@ -730,7 +872,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known}
for name, may_dangle, notation in found:
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
if notation or (resolved - {key}):
blocking.add(name)
@@ -810,6 +955,47 @@ def description_value(fm_text):
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) -------
# 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
@@ -977,8 +1163,14 @@ def agent_description(fm, local_fname):
f"not run — {local_fname}")
return None
def check_description_budget(value, local_fname):
"""ADR-0020 description gates — identical for every scope."""
def check_description_budget(value, local_fname, by_hand=False):
"""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:
return
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"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body — {local_fname}")
elif dlen > DESC_SUGGEST_CHARS:
elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character "
f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is "
f"what moves the corpus average; the FAIL tier only stops outliers "
f"— {local_fname}")
def check_boundary(value, fpath, local_fname):
def check_boundary(value, fpath, local_fname, by_hand=False):
"""ADR-0020 boundary clause + resolvable boundary targets.
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
# this particular agent warrants a boundary clause is judgment. All four
# 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 "
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}")
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)
if not targets:
return
@@ -1249,8 +1462,9 @@ def check_apm_agent_file(fpath, allowlist, stem):
else:
if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname)
check_boundary(folded, fpath, local_fname)
by_hand = hand_invoked(fm)
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
# applies since this file compiles verbatim into a real Copilot file downstream.
@@ -1345,8 +1559,9 @@ def check_file(fpath, file_provider):
else:
if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname)
check_boundary(folded, fpath, local_fname)
by_hand = hand_invoked(fm)
check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body
if not body.strip():

View File

@@ -179,7 +179,15 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)):
names.add(os.path.basename(path.rstrip('/')).lower())
# 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())
for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)):
base = os.path.basename(path)
@@ -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_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_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)
# A boundary clause takes two shapes and BOTH count: the prose markers, and
# 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_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
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# 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
# SUGGESTION / 2 dangling before and after) — this protects the descriptions
# 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(
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
# 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):
"""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:
return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name,
_terminal(text, end) if strict is None else strict,
_notation(text, start, arrow)))
notation))
def _scan(text, route_re, cont_re, out):
@@ -632,6 +684,85 @@ def boundary_targets(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):
"""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
universe from known_targets(); passing an empty set is not meaningful —
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()
for sentence in SENTENCE_SPLIT.split(description):
@@ -656,7 +798,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known}
for name, may_dangle, notation in found:
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
if notation or (resolved - {key}):
blocking.add(name)
@@ -736,6 +881,47 @@ def description_value(fm_text):
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) -------
# 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
@@ -909,6 +1095,15 @@ except FrontmatterError as exc:
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 ---
# name present
@@ -1023,10 +1218,13 @@ if desc:
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"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 "
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")
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:
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
# this particular skill warrants a boundary clause is judgment. Both accepted
# 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")
else:
elif status == 'absent':
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\") "
"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 ---------------------------------
# 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.
#
# <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
#
# 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() {
local root="$1" subject="$2"
local apm="$root/plugins/fixture-plugin/.apm"
mkdir -p "$apm/skills/$subject" "$apm/skills/fixture-sibling-skill" "$apm/agents"
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"
}
}
@@ -568,3 +578,168 @@ EOF
assert_output --partial "boundary-target resolution DID NOT RUN"
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,7 +253,15 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)):
names.add(os.path.basename(path.rstrip('/')).lower())
# 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())
for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)):
base = os.path.basename(path)
@@ -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_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_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)
# A boundary clause takes two shapes and BOTH count: the prose markers, and
# 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_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
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# 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
# SUGGESTION / 2 dangling before and after) — this protects the descriptions
# 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(
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
# 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):
"""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:
return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name,
_terminal(text, end) if strict is None else strict,
_notation(text, start, arrow)))
notation))
def _scan(text, route_re, cont_re, out):
@@ -706,6 +758,85 @@ def boundary_targets(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):
"""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
universe from known_targets(); passing an empty set is not meaningful —
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()
for sentence in SENTENCE_SPLIT.split(description):
@@ -730,7 +872,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known}
for name, may_dangle, notation in found:
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
if notation or (resolved - {key}):
blocking.add(name)
@@ -810,6 +955,47 @@ def description_value(fm_text):
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) -------
# 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
@@ -977,8 +1163,14 @@ def agent_description(fm, local_fname):
f"not run — {local_fname}")
return None
def check_description_budget(value, local_fname):
"""ADR-0020 description gates — identical for every scope."""
def check_description_budget(value, local_fname, by_hand=False):
"""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:
return
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"and a boundary clause; move capability enumeration, output-format detail, "
f"composition notes and implementation detail to the body — {local_fname}")
elif dlen > DESC_SUGGEST_CHARS:
elif dlen > DESC_SUGGEST_CHARS and not by_hand:
suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character "
f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is "
f"what moves the corpus average; the FAIL tier only stops outliers "
f"— {local_fname}")
def check_boundary(value, fpath, local_fname):
def check_boundary(value, fpath, local_fname, by_hand=False):
"""ADR-0020 boundary clause + resolvable boundary targets.
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
# this particular agent warrants a boundary clause is judgment. All four
# 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 "
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}")
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)
if not targets:
return
@@ -1249,8 +1462,9 @@ def check_apm_agent_file(fpath, allowlist, stem):
else:
if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname)
check_boundary(folded, fpath, local_fname)
by_hand = hand_invoked(fm)
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
# applies since this file compiles verbatim into a real Copilot file downstream.
@@ -1345,8 +1559,9 @@ def check_file(fpath, file_provider):
else:
if PLACEHOLDER_RE.search(folded):
fail(f"description contains unfilled FILL IN: placeholder — {local_fname}")
check_description_budget(folded, local_fname)
check_boundary(folded, fpath, local_fname)
by_hand = hand_invoked(fm)
check_description_budget(folded, local_fname, by_hand)
check_boundary(folded, fpath, local_fname, by_hand)
# body
if not body.strip():

View File

@@ -179,7 +179,15 @@ def _collect_package(pkg_dir, names):
safe_dir = glob.escape(pkg_dir)
for sub in ('.apm/skills/*/', 'skills/*/'):
for path in glob.glob(os.path.join(safe_dir, sub)):
names.add(os.path.basename(path.rstrip('/')).lower())
# 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())
for sub in ('.apm/agents/*.md', 'agents/*.md'):
for path in glob.glob(os.path.join(safe_dir, sub)):
base = os.path.basename(path)
@@ -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_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_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)
# A boundary clause takes two shapes and BOTH count: the prose markers, and
# 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_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
# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two
# 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
# SUGGESTION / 2 dangling before and after) — this protects the descriptions
# 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(
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
# 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):
"""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:
return
notation = _notation(text, start, arrow)
if strict is None and notation:
strict = True
out.append((name,
_terminal(text, end) if strict is None else strict,
_notation(text, start, arrow)))
notation))
def _scan(text, route_re, cont_re, out):
@@ -632,6 +684,85 @@ def boundary_targets(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):
"""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
universe from known_targets(); passing an empty set is not meaningful —
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()
for sentence in SENTENCE_SPLIT.split(description):
@@ -656,7 +798,10 @@ def unresolved_targets(description, known):
if normalize_target(name) in known}
for name, may_dangle, notation in found:
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
if notation or (resolved - {key}):
blocking.add(name)
@@ -736,6 +881,47 @@ def description_value(fm_text):
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) -------
# 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
@@ -909,6 +1095,15 @@ except FrontmatterError as exc:
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 ---
# name present
@@ -1023,10 +1218,13 @@ if desc:
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"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 "
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")
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:
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
# this particular skill warrants a boundary clause is judgment. Both accepted
# 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")
else:
elif status == 'absent':
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\") "
"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 ---------------------------------
# The resolution universe comes from the SKILL's own location: the authoring