fix(gates): check body-level routing targets, not just descriptions

The ADR-0020 boundary resolver (boundary_targets()/unresolved_targets())
only ever read a SKILL.md's description. A target named in the BODY -- a
dispatch table row, a "run X" step, both routine in a 900-word procedure
-- was checked by nothing. Two real instances shipped before either was
caught by reading rather than by a gate: bin/write-docs routed twice to a
deleted `to-prd` skill, and bin/triage told an agent to run a nonexistent
`/setup-matt-pocock-skills` (both fixed in 03abcff; that fix was the
symptom, this gate is the actual ask per #124).

Added a separate, narrower extractor -- body_targets() /
unresolved_body_targets() in the shared lib-boundary-resolver.sh -- rather
than reusing the description resolver at wider scope. The description
gate's sentence-level heuristics (BOUNDARY_MARKER, the follower test,
in-sentence corroboration) are tuned for a one-to-three-sentence routing
clause and misfire on dispatch-table/procedure prose in both directions,
so the body gate reads only explicit route notation (`/name`,
backticked-or-slash-prefixed `-> name` / `-> name`), already the
description gate's own unconditionally-blocking tier.

Three guards were added after running the extractor over the real
39-skill corpus and reading every hit rather than assuming the design was
correct:

- a target must be hyphenated, even in notation -- single-word citations
  like `/fork` (forge, citing Claude Code's own /fork command) and
  `/name` (skill-author, a placeholder) are not routes.
- a bare hyphenated word after any arrow is not notation -- only
  ARROW_MARKED (backticked/slash-prefixed) is used, not NOTATION_ARROW's
  bare form, so ordinary process-chain prose ("prop -> new ref ->
  re-render", caveman) is not read as a route.
- a name immediately preceded by `<` is a closing tag
  (`</what-to-do>`, grill-with-docs), not /name notation.

Wired into both consumers that must agree by contract: scripts/
skill-size-check.sh (the pre-commit hook) and factory-audit's
lib-checks-skill.sh (the audit). Verified identical findings across both
over the whole corpus.

tests/test-adr0020-targets.sh gains a dedicated section pinning the two
live true positives and all three guards. docs/spec/gates.md and
ADR-0020 get a matching amendment.

Fixes: #124
ADR: 0020

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGHFJextYtVQseaHPDDhxB
This commit is contained in:
2026-09-22 15:09:21 +00:00
parent 3ea057794c
commit c5f754d3ad
7 changed files with 397 additions and 48 deletions

View File

@@ -444,30 +444,42 @@ for path in files:
"\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second))
targets = boundary_targets(desc)
if targets:
# Body-level targets (issue #124): notation only (`/name`, `-> name`), so
# every hit is unconditionally blocking — see body_targets()'s header for
# why the description gate's SUGGESTION tier has no counterpart here.
body_route_names = body_targets(body)
if targets or body_route_names:
known = known_targets(skill_dir)
if known:
blocking, reported = unresolved_targets(desc, known)
for target in blocking:
error("%s: description routes to '%s', which does not resolve to a skill "
"or agent in this monorepo, in this package, or in a package it "
"declares in apm.yml dependencies.apm (ADR-0020). A boundary clause "
"that names a non-existent target sends the router nowhere."
% (path, target))
for target in reported:
suggest("%s: description routes to '%s', which does not resolve to a skill "
"or agent in this monorepo, in this package, or in a package it "
"declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather "
"than a hard failure because nothing else in the sentence resolves, "
"so this is equally likely to be a tool, a file format or an English "
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
"be checked properly." % (path, target, target, target))
if targets:
blocking, reported = unresolved_targets(desc, known)
for target in blocking:
error("%s: description routes to '%s', which does not resolve to a skill "
"or agent in this monorepo, in this package, or in a package it "
"declares in apm.yml dependencies.apm (ADR-0020). A boundary clause "
"that names a non-existent target sends the router nowhere."
% (path, target))
for target in reported:
suggest("%s: description routes to '%s', which does not resolve to a skill "
"or agent in this monorepo, in this package, or in a package it "
"declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather "
"than a hard failure because nothing else in the sentence resolves, "
"so this is equally likely to be a tool, a file format or an English "
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
"be checked properly." % (path, target, target, target))
for target in unresolved_body_targets(body, known):
error("%s: body routes to '%s' (`/%s` or `-> %s` notation), which does not "
"resolve to a skill or agent in this monorepo, in this package, or in a "
"package it declares in apm.yml dependencies.apm (ADR-0020). A dispatch "
"table or \"run X\" step naming a non-existent target sends the agent "
"nowhere." % (path, target, target, target))
else:
unchecked = sorted(set(targets) | set(body_route_names))
info("%s: boundary-target resolution DID NOT RUN — no skill universe "
"could be determined for this path (no authoring root above it, no "
"apm package root, no declared apm dependencies, no deployed "
".claude/ or .agents/ tree). Unchecked target(s): %s"
% (path, ", ".join(targets)))
% (path, ", ".join(unchecked)))
sys.exit(1 if failed else 0)
SSC_CHECKS_PY