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:
@@ -414,6 +414,56 @@ and rises to a blocking ERROR the moment a resolving sibling joins it. The reaso
|
||||
the point of enforcement in `_add()`'s docstring in `scripts/skill-size-check.sh` and its two
|
||||
mirrored copies, and the verdict table in `docs/spec/gates.md` states the corrected shape.
|
||||
|
||||
## Amendment (2026-09-22): body-level routing targets are resolved too
|
||||
|
||||
The Decision section's routing-target resolver (`boundary_targets()` / `unresolved_targets()`) reads
|
||||
the **description** only. 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: `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 were found by reading, not by a gate, during
|
||||
the #99 retrofit and its follow-up audit; both were fixed in `03abcff`. **The fix this amendment
|
||||
records is the gate, not those two edits** (issue #124).
|
||||
|
||||
The body gate is a **separate, narrower** extractor (`body_targets()` /
|
||||
`unresolved_body_targets()`), not the description resolver reused at wider scope. The description
|
||||
resolver'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
|
||||
and procedure prose in both directions: under-firing on a table row that carries no "do not" /
|
||||
"instead", over-firing on a procedure step naming a file, a CLI verb or a config key exactly the way
|
||||
a route names a skill. Retuning those heuristics for the body genre was considered and rejected as
|
||||
the harder half of the problem, with a materially worse cost of getting it wrong (a body is loaded
|
||||
on every invocation, so a false-positive-prone body gate is felt far more often than a
|
||||
false-positive-prone description gate).
|
||||
|
||||
So the body gate reads **only** explicit route notation — `/name` and backticked-or-slash-prefixed
|
||||
`-> name` / `→ name` — already the description gate's own unconditionally-blocking tier, and nothing
|
||||
softer: no SUGGESTION tier, no bare-word forms, no corroboration. Two further restrictions, both
|
||||
earned by a real corpus false positive rather than assumed up front:
|
||||
|
||||
- **the target must be hyphenated**, even in notation. `` `/fork` `` (`forge/SKILL.md`, citing
|
||||
Claude Code's own `/fork` subagent command) and `` `/name` `` (`skill-author/SKILL.md`, a
|
||||
placeholder for the skill's own name) are real corpus citations of a tool or a placeholder, not
|
||||
routes, and both hard-FAILed with no escape hatch before this restriction. This is the same
|
||||
"single-word targets are ordinary English" trade the Decision section already makes for the bare
|
||||
form, extended to notation because the body genre has no boundary-sentence signal to fall back on;
|
||||
- **a bare hyphenated word after any arrow is not notation.** The description gate's own bare-arrow
|
||||
sweep (`NOTATION_ARROW`) reads ordinary process-chain prose as a route: `caveman`'s "Inline obj
|
||||
prop -> new ref -> re-render." dangled to `re-render` under it. The body gate uses `ARROW_MARKED`
|
||||
instead, which requires the target to be backticked or slash-prefixed — true of the one real
|
||||
historical target (`` -> `to-prd` ``, confirmed against `03abcff`'s diff), so this costs no real
|
||||
coverage;
|
||||
- a target immediately preceded by `<` is a closing tag (`</what-to-do>`, `<supporting-info>` — this
|
||||
repo's own `grill-with-docs/SKILL.md` uses these as prompt section delimiters), not `/name`
|
||||
notation, and is discarded on that basis alone.
|
||||
|
||||
Both consumers — `scripts/skill-size-check.sh` and `factory-audit/scripts/lib-checks-skill.sh` —
|
||||
call the shared functions independently over the same `known_targets()` universe the description
|
||||
check already computed, so a body target folds into the existing "DID NOT RUN" INFO tier rather than
|
||||
adding a second one. `tests/test-adr0020-targets.sh` pins the two live true positives, all three
|
||||
guards above, and the fenced-code-block mask; the corpus-wide dangling assertion now covers body
|
||||
targets the same way it already covered description ones. `docs/spec/gates.md`'s "Body-level routing
|
||||
targets" section states the enforced shape in full.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39
|
||||
|
||||
@@ -360,6 +360,68 @@ at a real sentence end. **Read the second bullet forward as well as back:** a ba
|
||||
after a dotted filename is now extracted, resolved, and a blocking ERROR when it dangles, where the
|
||||
same clause used to pass unchecked in silence.
|
||||
|
||||
### Body-level routing targets (issue #124)
|
||||
|
||||
Everything above resolves targets named in the **description** — the one field `boundary_targets()`
|
||||
and `unresolved_targets()` read. Until issue #124, a target named in the **body** — a dispatch table
|
||||
or a "run X" step, both routine in a 900-word procedure — was checked by nothing: `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`, and both were found by reading, not by any gate (fixed in `03abcff`;
|
||||
the gate itself is the ask this section documents).
|
||||
|
||||
`body_targets()` / `unresolved_body_targets()` (`lib-boundary-resolver.sh`) are a **separate,
|
||||
narrower** extractor, not a reuse of the description one at wider scope. A body is dispatch-table
|
||||
and procedure prose, not a one-to-three-sentence routing clause, so `BOUNDARY_MARKER`, the follower
|
||||
test and in-sentence corroboration all misfire on it in both directions — under-firing on a table
|
||||
row that carries no "do not"/"instead", over-firing on a procedure step that names a file, a CLI verb
|
||||
or a config key exactly the way a route names a skill. So the body gate reads only **notation**,
|
||||
already the description gate's own "always blocks" tier, and nothing softer:
|
||||
|
||||
| Form | Pattern | Requires |
|
||||
|---|---|---|
|
||||
| `/name` | `NOTATION_SLASH` | a hyphen in `name`; not preceded by `<` |
|
||||
| `-> name` / `→ name` | `ARROW_MARKED` | the name **backticked or slash-prefixed** — `NOTATION_ARROW`'s bare form is not used here |
|
||||
|
||||
Both constraints exist because the corpus, not intuition, said so — each is a real false positive
|
||||
this gate produced once and was narrowed to remove:
|
||||
|
||||
- **No SUGGESTION tier, no continuation, one arrow per target.** Both forms are notation, and
|
||||
notation is unconditionally blocking — there is no ambiguous prose reading left to soften, so
|
||||
there is nothing to report at a softer tier. `CONT_MARKED`/`CONT_ANY` are not run either, so
|
||||
`-> \`a\` or \`b\`` resolves only `a`, same as the one-arrow-one-target convention **#107** already
|
||||
states for descriptions — enforced here by construction instead of by a second SUGGESTION.
|
||||
- **A bare hyphenated word after any arrow is not notation here.** `NOTATION_ARROW` (used for the
|
||||
description gate's own `Not X -> name` sweep) matches a bare `-> name` unconditionally, and a body
|
||||
is full of ordinary arrow prose that is not a route: `caveman`'s own `Inline obj prop -> new ref ->
|
||||
re-render.` read as a dangling route to `re-render` under that pattern. `ARROW_MARKED` requires the
|
||||
target to be backticked or slash-prefixed, which the one real historical target (`` -> `to-prd` ``,
|
||||
per `03abcff`'s diff) already was, so the narrowing costs no real coverage.
|
||||
- **A single-word target is discarded, even in notation.** `` `/fork` `` (`forge/SKILL.md`,
|
||||
contrasting `context: fork` with Claude Code's own `/fork` subagent command) and `` `/name` ``
|
||||
(`skill-author/SKILL.md`, "the user types `/name`" — a placeholder for the skill's *own* name, not
|
||||
a route) are both real corpus citations of a tool or a placeholder, not routes, and both hard-FAILed
|
||||
with no escape hatch before the hyphen requirement was added. This is a real, accepted recall loss:
|
||||
a body dispatch entry to a genuinely single-word skill (`forge`, `research`, `triage`, `tdd`,
|
||||
`prototype`) cannot be checked through this extractor. Same trade the description gate already
|
||||
makes for the *bare* form (the known gap above), extended here to notation as well because the body
|
||||
genre has no boundary-sentence signal to lean on instead.
|
||||
- **A name immediately preceded by `<` is a closing tag, not a route.** `grill-with-docs/SKILL.md`
|
||||
uses XML-style prompt delimiters (`<what-to-do>...</what-to-do>`, `<supporting-info>...`), and
|
||||
`</what-to-do>` is indistinguishable from `/what-to-do` notation by every other rule above. No route
|
||||
is ever written directly after `<` in this corpus, so the guard costs nothing else.
|
||||
|
||||
Fenced code blocks are masked first (`mask_fenced()`, the same masking `gotcha_stats()` and the
|
||||
references/-pointer check already use): an illustrative ` ```/some-skill``` ` in `skill-author` or
|
||||
`factory-audit` — which document this very notation — is not a live dispatch entry.
|
||||
|
||||
Both consumers agree by construction: `scripts/skill-size-check.sh` and
|
||||
`factory-audit/scripts/lib-checks-skill.sh` each call `body_targets()`/`unresolved_body_targets()`
|
||||
independently, over the same `known_targets()` universe the description check already computed, so
|
||||
the "DID NOT RUN" INFO tier covers both description and body targets in one message rather than
|
||||
firing twice. `tests/test-adr0020-targets.sh`'s "body-level routing targets (issue #124)" section
|
||||
pins both the two live true positives and every guard above; the corpus-wide dangling assertion
|
||||
(`EXPECTED_DANGLING`) covers body targets the same way it already covered description ones.
|
||||
|
||||
### SUGGESTION-only checks
|
||||
|
||||
Deterministic to measure, judgment to act on:
|
||||
|
||||
Reference in New Issue
Block a user