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