fix(gates): check body-level routing targets, not just descriptions #140
@@ -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
|
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.
|
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
|
## Consequences
|
||||||
|
|
||||||
**Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39
|
**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
|
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.
|
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
|
### SUGGESTION-only checks
|
||||||
|
|
||||||
Deterministic to measure, judgment to act on:
|
Deterministic to measure, judgment to act on:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: >
|
|||||||
fixes -> agent-author.
|
fixes -> agent-author.
|
||||||
allowed-tools: Bash Read
|
allowed-tools: Bash Read
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.4"
|
version: "1.0.5"
|
||||||
category: factory
|
category: factory
|
||||||
source_keys:
|
source_keys:
|
||||||
- agentskills-home
|
- agentskills-home
|
||||||
|
|||||||
@@ -898,6 +898,103 @@ def unresolved_targets(description, known):
|
|||||||
reported.add(name)
|
reported.add(name)
|
||||||
return sorted(blocking), sorted(reported - blocking)
|
return sorted(blocking), sorted(reported - blocking)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Body-level routing targets (issue #124) -------------------------------
|
||||||
|
# boundary_targets()/unresolved_targets() above are tuned for a description:
|
||||||
|
# one to three sentences, where BOUNDARY_MARKER, the follower test and
|
||||||
|
# in-sentence corroboration all exist to tell a routing sentence apart from
|
||||||
|
# ordinary prose about a hyphenated tool. A SKILL.md body is a different
|
||||||
|
# genre — up to 900 words of procedure and dispatch tables — where those same
|
||||||
|
# heuristics would misfire in both directions: a dispatch table rarely reads
|
||||||
|
# as a "boundary sentence" (under-fire), and a procedure step naming a file, a
|
||||||
|
# CLI verb or a config key looks exactly like a route (over-fire). Retuning
|
||||||
|
# the sentence-level heuristics for that genre is the hard half of this gate
|
||||||
|
# and is deliberately NOT attempted here — see the issue for why.
|
||||||
|
#
|
||||||
|
# So the body extractor takes the narrow route instead: only two EXPLICIT
|
||||||
|
# ROUTE NOTATION forms count, and each is measured against the real corpus
|
||||||
|
# (39 SKILL.md bodies) rather than assumed correct from the description gate's
|
||||||
|
# behaviour — a body is dense with prose that LOOKS like this notation and
|
||||||
|
# genuinely is not, in ways a one-to-three-sentence description never is:
|
||||||
|
#
|
||||||
|
# * ARROW_MARKED — `-> name` / `→ name` where the target is BACKTICKED or
|
||||||
|
# slash-prefixed (MARKED_TARGET). NOT NOTATION_ARROW, which matches a bare
|
||||||
|
# hyphenated word after any arrow: the corpus's own process-chain prose
|
||||||
|
# ("Inline obj prop -> new ref -> re-render.", caveman/SKILL.md) reads as
|
||||||
|
# a route under that pattern and does not under this one, because a
|
||||||
|
# process chain is never itself backticked or slash-prefixed. The one
|
||||||
|
# live true positive this was filed over, write-docs' "-> `to-prd`", IS
|
||||||
|
# backticked (03abcff's diff shows the original), so ARROW_MARKED still
|
||||||
|
# catches it losslessly.
|
||||||
|
# * NOTATION_SLASH — free-standing `/name`, unconditionally, the same
|
||||||
|
# pattern the description gate sweeps with. Two guards narrow it for body
|
||||||
|
# text specifically, each one measured against a real corpus false
|
||||||
|
# positive rather than hypothesised:
|
||||||
|
# - a name with NO hyphen is discarded. A real dispatch entry in this
|
||||||
|
# corpus always names a multi-word skill (`to-prd`,
|
||||||
|
# `setup-matt-pocock-skills`); a single bare or backticked word after
|
||||||
|
# a `/` is prose citing a CLI command, a Claude Code built-in or a
|
||||||
|
# placeholder — `` `/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`" —
|
||||||
|
# `name` is a placeholder for the skill's OWN name, not a route) are
|
||||||
|
# both real corpus hits this guard removes. This is a real recall
|
||||||
|
# loss — `/forge`, `/triage` and other single-word skill names are
|
||||||
|
# unreachable through this extractor — accepted deliberately, the
|
||||||
|
# same "start narrow" trade the issue itself recommends.
|
||||||
|
# - a name immediately preceded by `<` is discarded. An XML/HTML-style
|
||||||
|
# closing tag used as a prompt section delimiter — `</what-to-do>`,
|
||||||
|
# `</supporting-info>` (grill-with-docs/SKILL.md) — is indistinguishable
|
||||||
|
# from `/what-to-do` notation by every other rule in this pattern; no
|
||||||
|
# route is ever written directly after `<` in this corpus, so the
|
||||||
|
# guard costs nothing else.
|
||||||
|
#
|
||||||
|
# Every surviving hit is unconditionally blocking: both forms are explicit
|
||||||
|
# notation with the ambiguous single-word and closing-tag readings already
|
||||||
|
# removed, so there is no SUGGESTION tier here — that tier exists to soften
|
||||||
|
# an ambiguous prose form, and none is admitted at this point.
|
||||||
|
#
|
||||||
|
# No conjunction continuation (CONT_*) either: `-> \`to-prd\` or \`grill-me\``
|
||||||
|
# resolves only `to-prd`, the same one-arrow-one-target convention
|
||||||
|
# multi_target_arrow_clauses() already enforces on descriptions (issue #107),
|
||||||
|
# applied here by construction instead of by a second SUGGESTION.
|
||||||
|
def body_targets(body):
|
||||||
|
"""Every /name or -> `name` routing target named in a SKILL.md body.
|
||||||
|
|
||||||
|
Fenced code blocks are masked first, the same way gotcha_stats() and
|
||||||
|
missing_reference_pointers() mask them: a ```-fenced example quoting
|
||||||
|
`/some-skill` or `-> \`some-skill\`` as illustration is not a live
|
||||||
|
dispatch entry, and skill-author/factory-audit — which document this
|
||||||
|
very notation — are exactly the skills most likely to carry one.
|
||||||
|
"""
|
||||||
|
masked = mask_fenced(body)
|
||||||
|
names = set()
|
||||||
|
for match in NOTATION_SLASH.finditer(masked):
|
||||||
|
if match.start() > 0 and masked[match.start() - 1] == '<':
|
||||||
|
continue # </closing-tag>, not /route-notation
|
||||||
|
name = match.group(1)
|
||||||
|
if '-' in name:
|
||||||
|
names.add(name)
|
||||||
|
for match in ARROW_MARKED.finditer(masked):
|
||||||
|
name, _, _ = _first(match)
|
||||||
|
if name and '-' in name:
|
||||||
|
names.add(name)
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def unresolved_body_targets(body, known):
|
||||||
|
"""Body routing targets (notation only) that resolve to nothing.
|
||||||
|
|
||||||
|
Unlike unresolved_targets(), this has one outcome, not two: every name
|
||||||
|
body_targets() finds is already route notation, and notation always
|
||||||
|
blocks. `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, exactly as they do for the description gate.
|
||||||
|
"""
|
||||||
|
return sorted(name for name in body_targets(body)
|
||||||
|
if normalize_target(name) not in known)
|
||||||
|
|
||||||
|
|
||||||
# --- Frontmatter ----------------------------------------------------------
|
# --- Frontmatter ----------------------------------------------------------
|
||||||
# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading
|
# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading
|
||||||
# blank line, trailing whitespace after either `---`, or CRLF line endings all
|
# blank line, trailing whitespace after either `---`, or CRLF line endings all
|
||||||
|
|||||||
@@ -443,39 +443,56 @@ elif desc:
|
|||||||
# derived from this script's own path, and — when an authoring root exists — it
|
# derived from this script's own path, and — when an authoring root exists — it
|
||||||
# never reads a deployed .claude/ tree, so a fresh clone and a machine that has
|
# never reads a deployed .claude/ tree, so a fresh clone and a machine that has
|
||||||
# run `apm install` return the same verdict. See the shared resolver's header.
|
# run `apm install` return the same verdict. See the shared resolver's header.
|
||||||
if desc:
|
routing_targets = boundary_targets(desc) if desc else []
|
||||||
routing_targets = boundary_targets(desc)
|
# Body-level targets (issue #124): notation only (`/name`, `-> name`), so
|
||||||
known = known_targets(skill_dir) if routing_targets else set()
|
# every hit is unconditionally blocking — see the shared resolver's
|
||||||
if routing_targets and not known:
|
# body_targets() header for why the description gate's SUGGESTION tier has
|
||||||
|
# no counterpart here. Read regardless of `desc`: a body dispatch table can
|
||||||
|
# carry a broken route even when the description carries none.
|
||||||
|
body_routing_targets = body_targets(body)
|
||||||
|
if routing_targets or body_routing_targets:
|
||||||
|
known = known_targets(skill_dir)
|
||||||
|
if not known:
|
||||||
|
unchecked = sorted(set(routing_targets) | set(body_routing_targets))
|
||||||
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
|
info(f"boundary-target resolution DID NOT RUN — no skill universe could be "
|
||||||
f"determined for this path (no authoring root above it, no apm package "
|
f"determined for this path (no authoring root above it, no apm package "
|
||||||
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
|
f"root, no declared apm dependencies, no deployed .claude/ or .agents/ "
|
||||||
f"tree). Unchecked target(s): {', '.join(routing_targets)}")
|
f"tree). Unchecked target(s): {', '.join(unchecked)}")
|
||||||
elif routing_targets:
|
else:
|
||||||
# blocking vs reported: a target only earns a FAIL when it is written in
|
if routing_targets:
|
||||||
# route notation or its own sentence corroborates it by naming another
|
# blocking vs reported: a target only earns a FAIL when it is written in
|
||||||
# target that resolves. See the shared resolver's CORROBORATION note.
|
# route notation or its own sentence corroborates it by naming another
|
||||||
unresolved, soft = unresolved_targets(desc, known)
|
# target that resolves. See the shared resolver's CORROBORATION note.
|
||||||
for target in unresolved:
|
unresolved, soft = unresolved_targets(desc, known)
|
||||||
fail(f"description routes to '{target}', which resolves to no skill or agent "
|
for target in unresolved:
|
||||||
f"in this monorepo, in this package, or in a package it declares in "
|
fail(f"description routes to '{target}', which resolves to no skill or agent "
|
||||||
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
|
f"in this monorepo, in this package, or in a package it declares in "
|
||||||
f"target sends the router nowhere")
|
f"apm.yml dependencies.apm — a boundary clause naming a non-existent "
|
||||||
for target in soft:
|
f"target sends the router nowhere")
|
||||||
suggest(f"description routes to '{target}', which resolves to no skill or agent "
|
for target in soft:
|
||||||
f"in this monorepo, in this package, or in a package it declares in "
|
suggest(f"description routes to '{target}', which resolves to no skill or agent "
|
||||||
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
|
f"in this monorepo, in this package, or in a package it declares in "
|
||||||
f"else in that sentence resolves, so it is equally likely to be a tool, a "
|
f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing "
|
||||||
f"file format or an English compound. If it IS a route, write it as "
|
f"else in that sentence resolves, so it is equally likely to be a tool, a "
|
||||||
f"`/{target}` or `-> {target}` and it will be checked properly")
|
f"file format or an English compound. If it IS a route, write it as "
|
||||||
if not unresolved:
|
f"`/{target}` or `-> {target}` and it will be checked properly")
|
||||||
# Counts the targets that ACTUALLY resolve, not every target found:
|
if not unresolved:
|
||||||
# a confirm-only target (one used attributively — see the resolver's
|
# Counts the targets that ACTUALLY resolve, not every target found:
|
||||||
# ATTRIBUTIVE USE note) is exempt from the failure above, so
|
# a confirm-only target (one used attributively — see the resolver's
|
||||||
# reporting it as resolved would be a false claim.
|
# ATTRIBUTIVE USE note) is exempt from the failure above, so
|
||||||
resolved = [t for t in routing_targets if normalize_target(t) in known]
|
# reporting it as resolved would be a false claim.
|
||||||
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
|
resolved = [t for t in routing_targets if normalize_target(t) in known]
|
||||||
f"{', '.join(resolved) if resolved else '(none)'}")
|
ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: "
|
||||||
|
f"{', '.join(resolved) if resolved else '(none)'}")
|
||||||
|
unresolved_body = unresolved_body_targets(body, known)
|
||||||
|
for target in unresolved_body:
|
||||||
|
fail(f"body routes to '{target}' (`/{target}` or `-> {target}` notation), which "
|
||||||
|
f"resolves to no skill or agent in this monorepo, in this package, or in a "
|
||||||
|
f"package it declares in apm.yml dependencies.apm — a dispatch table or "
|
||||||
|
f"\"run X\" step naming a non-existent target sends the agent nowhere")
|
||||||
|
if body_routing_targets and not unresolved_body:
|
||||||
|
ok(f"{len(body_routing_targets)} of {len(body_routing_targets)} body routing "
|
||||||
|
f"target(s) resolve: {', '.join(body_routing_targets)}")
|
||||||
|
|
||||||
# Body unfilled placeholders
|
# Body unfilled placeholders
|
||||||
fill_matches = PLACEHOLDER_RE.findall(body)
|
fill_matches = PLACEHOLDER_RE.findall(body)
|
||||||
|
|||||||
@@ -444,30 +444,42 @@ for path in files:
|
|||||||
"\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second))
|
"\"Not X -> %s. Not Y -> %s.\"" % (path, first, second, first, second))
|
||||||
|
|
||||||
targets = boundary_targets(desc)
|
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)
|
known = known_targets(skill_dir)
|
||||||
if known:
|
if known:
|
||||||
blocking, reported = unresolved_targets(desc, known)
|
if targets:
|
||||||
for target in blocking:
|
blocking, reported = unresolved_targets(desc, known)
|
||||||
error("%s: description routes to '%s', which does not resolve to a skill "
|
for target in blocking:
|
||||||
"or agent in this monorepo, in this package, or in a package it "
|
error("%s: description routes to '%s', which does not resolve to a skill "
|
||||||
"declares in apm.yml dependencies.apm (ADR-0020). A boundary clause "
|
"or agent in this monorepo, in this package, or in a package it "
|
||||||
"that names a non-existent target sends the router nowhere."
|
"declares in apm.yml dependencies.apm (ADR-0020). A boundary clause "
|
||||||
% (path, target))
|
"that names a non-existent target sends the router nowhere."
|
||||||
for target in reported:
|
% (path, target))
|
||||||
suggest("%s: description routes to '%s', which does not resolve to a skill "
|
for target in reported:
|
||||||
"or agent in this monorepo, in this package, or in a package it "
|
suggest("%s: description routes to '%s', which does not resolve to a skill "
|
||||||
"declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather "
|
"or agent in this monorepo, in this package, or in a package it "
|
||||||
"than a hard failure because nothing else in the sentence resolves, "
|
"declares in apm.yml dependencies.apm (ADR-0020). SUGGESTION rather "
|
||||||
"so this is equally likely to be a tool, a file format or an English "
|
"than a hard failure because nothing else in the sentence resolves, "
|
||||||
"compound. If it IS a route, write it as `/%s` or `-> %s` and it will "
|
"so this is equally likely to be a tool, a file format or an English "
|
||||||
"be checked properly." % (path, target, target, target))
|
"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:
|
else:
|
||||||
|
unchecked = sorted(set(targets) | set(body_route_names))
|
||||||
info("%s: boundary-target resolution DID NOT RUN — no skill universe "
|
info("%s: boundary-target resolution DID NOT RUN — no skill universe "
|
||||||
"could be determined for this path (no authoring root above it, no "
|
"could be determined for this path (no authoring root above it, no "
|
||||||
"apm package root, no declared apm dependencies, no deployed "
|
"apm package root, no declared apm dependencies, no deployed "
|
||||||
".claude/ or .agents/ tree). Unchecked target(s): %s"
|
".claude/ or .agents/ tree). Unchecked target(s): %s"
|
||||||
% (path, ", ".join(targets)))
|
% (path, ", ".join(unchecked)))
|
||||||
|
|
||||||
sys.exit(1 if failed else 0)
|
sys.exit(1 if failed else 0)
|
||||||
SSC_CHECKS_PY
|
SSC_CHECKS_PY
|
||||||
|
|||||||
@@ -961,6 +961,117 @@ else
|
|||||||
fail "an attributive target naming a REAL skill produced output (exit $ATTR_RC): $ATTR_OUT"
|
fail "an attributive target naming a REAL skill produced output (exit $ATTR_RC): $ATTR_OUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Body-level routing targets (issue #124)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# boundary_targets()/unresolved_targets() are the DESCRIPTION gate, exercised
|
||||||
|
# above. body_targets()/unresolved_body_targets() are the separate, narrower
|
||||||
|
# extractor added for issue #124: a SKILL.md body is dispatch-table and
|
||||||
|
# procedure prose, not a one-to-three-sentence routing clause, so the body
|
||||||
|
# extractor takes only /name and -> `name` NOTATION (never the bare-prose
|
||||||
|
# forms the description gate also reads), and even within notation, a target
|
||||||
|
# must be hyphenated and must not be a `<tag` immediately before the `/`.
|
||||||
|
# Every fixture is built inside a real plugin tree (BODY_ROOT), same as
|
||||||
|
# section 2 above, so the resolver actually runs instead of declining.
|
||||||
|
echo ""
|
||||||
|
echo "--- body-level routing targets (issue #124) ---"
|
||||||
|
BODY_ROOT="$TMPDIR_T/body"
|
||||||
|
write_skill "$BODY_ROOT/plugins/p/.apm/skills/sibling-skill" sibling-skill \
|
||||||
|
"Use when doing the other thing. Do not use for anything else."
|
||||||
|
|
||||||
|
# write_skill_body <skill-dir> <name> <body>
|
||||||
|
write_skill_body() {
|
||||||
|
mkdir -p "$1"
|
||||||
|
{
|
||||||
|
echo "---"
|
||||||
|
echo "name: $2"
|
||||||
|
echo "description: Use when doing the thing. Do not use for anything else."
|
||||||
|
echo "metadata:"
|
||||||
|
echo " version: \"1.0.0\""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
printf '%s\n' "$3"
|
||||||
|
} > "$1/SKILL.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# body_case <slug> <expect: silent|errors> <needle> <body>
|
||||||
|
body_case() {
|
||||||
|
local slug="$1" mode="$2" needle="$3" body="$4" out status=0
|
||||||
|
write_skill_body "$BODY_ROOT/plugins/p/.apm/skills/$slug" "$slug" "$body"
|
||||||
|
set +e
|
||||||
|
out="$(bash "$HOOK" "$BODY_ROOT/plugins/p/.apm/skills/$slug/SKILL.md" 2>&1)"
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$out" == *"DID NOT RUN"* ]]; then
|
||||||
|
fail "body \"$body\" — the resolver declined, so this case asserts nothing about extraction: $out"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
case "$mode" in
|
||||||
|
silent)
|
||||||
|
if [[ $status -eq 0 && -z "$out" ]]; then
|
||||||
|
pass "not a dangling body target: \"$body\""
|
||||||
|
else
|
||||||
|
fail "body \"$body\" (exit $status, output: ${out:-<empty>})"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
errors)
|
||||||
|
if [[ $status -ne 0 && "$out" == *"$needle"* ]]; then
|
||||||
|
pass "dangling body target caught: \"$body\""
|
||||||
|
else
|
||||||
|
fail "body \"$body\" should have ERRORed with $needle (exit $status, output: ${out:-<empty>})"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The two live true positives the issue was filed over, at fixture scale:
|
||||||
|
# a bare/backticked `/name` and a backticked `-> \`name\``.
|
||||||
|
body_case body-slash-dangling errors "body routes to 'no-such-body-skill'" \
|
||||||
|
"Run \`/no-such-body-skill\` if the config is missing."
|
||||||
|
body_case body-slash-resolves silent "" \
|
||||||
|
"Run \`/sibling-skill\` if the config is missing."
|
||||||
|
body_case body-arrow-dangling errors "body routes to 'no-such-arrow-body'" \
|
||||||
|
"- User wants X -> \`no-such-arrow-body\`"
|
||||||
|
body_case body-arrow-resolves silent "" \
|
||||||
|
"- User wants X -> \`sibling-skill\`"
|
||||||
|
|
||||||
|
# No conjunction continuation: only the FIRST target after an arrow is ever
|
||||||
|
# read, so a dangling SECOND name is silently uncounted rather than reported
|
||||||
|
# — the same one-arrow-one-target convention issue #107 enforces on
|
||||||
|
# descriptions (there, at SUGGESTION tier; here, by construction, since the
|
||||||
|
# body gate has no SUGGESTION tier at all).
|
||||||
|
body_case body-arrow-no-continuation silent "" \
|
||||||
|
"- User wants X -> \`sibling-skill\` or \`no-such-uncounted-target\`"
|
||||||
|
|
||||||
|
# The single-word guard: a real corpus false positive removed by requiring a
|
||||||
|
# hyphen. `` `/fork` `` (forge/SKILL.md) and `` `/name` `` (skill-author/SKILL.md)
|
||||||
|
# are both single-word citations of a tool or a placeholder, not routes, and
|
||||||
|
# both would otherwise have hard-FAILed with no escape hatch.
|
||||||
|
body_case body-slash-single-word-guard silent "" \
|
||||||
|
"See \`/fork\` for how the two differ."
|
||||||
|
|
||||||
|
# The closing-tag guard: an XML/HTML-style section delimiter used as a prompt
|
||||||
|
# marker (grill-with-docs/SKILL.md's <what-to-do>...</what-to-do>) is
|
||||||
|
# indistinguishable from /route notation by every other rule in the pattern —
|
||||||
|
# a `<` immediately before the `/` is the one signal that tells them apart.
|
||||||
|
body_case body-closing-tag-guard silent "" \
|
||||||
|
$'<what-to-do>\nDo the thing.\n</what-to-do>'
|
||||||
|
|
||||||
|
# The bare-arrow guard: NOTATION_ARROW (bare hyphenated word after any arrow)
|
||||||
|
# is deliberately NOT used here, only ARROW_MARKED (backticked or
|
||||||
|
# slash-prefixed). caveman/SKILL.md's own process chain, "Inline obj prop ->
|
||||||
|
# new ref -> re-render.", is real corpus prose this guard exists for — an
|
||||||
|
# unbacked, unresolvable name after an arrow must stay silent, not become a
|
||||||
|
# hard-blocking dangling-target FAIL with no suppression mechanism.
|
||||||
|
body_case body-arrow-bare-not-notation silent "" \
|
||||||
|
"Reproduce -> minimise -> no-such-bare-chain-target."
|
||||||
|
|
||||||
|
# Fenced code blocks are masked, same as gotcha_stats() and
|
||||||
|
# missing_reference_pointers() mask them: an illustrative example is not a
|
||||||
|
# live dispatch entry.
|
||||||
|
body_case body-fenced-example silent "" \
|
||||||
|
$'```\nRun /no-such-fenced-skill instead.\n```'
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
[[ $FAIL -eq 0 ]]
|
[[ $FAIL -eq 0 ]]
|
||||||
|
|||||||
Reference in New Issue
Block a user