Skip to content

Commit dc52b19

Browse files
authored
fix(skill-quality): check the description field cap separately from the listing cap (#3150)
Adds check 2b: the Agent Skills spec `description` FIELD maximum (1024 codepoints), distinct from check 2's 1536-char listing-entry cap. A description could sit under 1536 combined, breach 1024 alone, and pass clean; nineteen skills in this marketplace do. WARN, not FAIL, on measured evidence: `claude plugin validate --strict` (Claude Code 2.1.241) passes a 1248-char description clean, so the breach is latent locally and hard only on Skills API upload. Counted in codepoints via the same UTF-8 -> UTF-32BE iconv form check 22 uses; `${#var}` degrades to byte counting under a byte-oriented locale and would false-warn on multilingual descriptions (600 'é' reports as 1200 under LC_ALL=C). Caught in review by the Codex reviewer. DESC_LEN stays a byte count for check 2, so check 2 behavior is unchanged. Closes #3119
1 parent 9a6c649 commit dc52b19

4 files changed

Lines changed: 183 additions & 2 deletions

File tree

‎plugins/skill-quality/.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
33
"name": "skill-quality",
4-
"version": "0.18.1",
4+
"version": "0.19.0",
55
"description": "Skill-authoring QA tooling: a static contract checker that runs twenty-five deterministic checks over a Claude Code skill (frontmatter, explicit invocation mode, description/verb-contract polarity, per-skill listing-entry cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, completion-criteria signal, injection shell-declaration, fresh-eyes declaration conformance), a shared skill-listing budget reporter across a set of skills, and a bundled evals.json schema plus a deterministic eval-quality lint (duplicate case identities, missing fixtures, empty or vague grading criteria, set-coverage warnings). Runs against any repo's skills directory via the convention-resolution ladder \u2014 no baked layout.",
66
"author": {
77
"name": "Melodic Software",

‎plugins/skill-quality/CHANGELOG.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,45 @@
33
All notable changes to the `skill-quality` plugin are documented here. Format follows
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
55

6+
## [0.19.0]
7+
8+
### Added
9+
10+
- **`check`: Check 2b — description field cap (1024; WARN; #3119).** The gate
11+
carried one description limit, `DESC_CHAR_CAP=1536`, and treated it as the only
12+
one. That value is Claude Code's in-context listing truncation for the assembled
13+
entry (`description` + `" - "` + `when_to_use`). The Agent Skills spec states a
14+
separate, smaller maximum for the `description` **field alone**: "Must be
15+
non-empty / Maximum 1024 characters / Cannot contain XML tags"
16+
(platform.claude.com Agent Skills overview, fetched 2026-08-23). Two limits at
17+
two layers, and only the looser one was checked — so a description could sit
18+
under 1536 combined, breach 1024 on its own, and pass clean. Nineteen skills in
19+
this marketplace did (lower bound; measured with an independent extractor that
20+
reads slightly short of the gate's own).
21+
22+
Check 2b reports the field breach separately from check 2, with its own message.
23+
24+
Counted in **codepoints**, not bytes — the spec says "Maximum 1024 characters",
25+
and `${#var}` degrades to byte counting under a byte-oriented locale, so 600
26+
`é` characters report as 1200 under `LC_ALL=C` and a valid multilingual
27+
description would false-warn. Uses the same UTF-8 → UTF-32BE `iconv` form as
28+
check 22, with the same UTF-8-locale fallback where `iconv` is absent.
29+
`DESC_LEN` stays a byte count for check 2, whose 1536 listing cap is a separate
30+
measure. Caught in review by the Codex reviewer on this PR.
31+
32+
**WARN, not FAIL, on measured evidence.** No local validator enforces the field
33+
maximum: `claude plugin validate --strict` (Claude Code 2.1.241) passes a
34+
1248-char description clean — verified against a throwaway fixture plugin on
35+
2026-08-23, the only warning raised being an unrelated missing `author`. The
36+
breach is latent for filesystem and plugin skills, and hard only for a skill
37+
uploaded through the Skills API. Failing the build on it would block a fleet
38+
over a limit nothing in the local toolchain applies.
39+
40+
Numbered `2b` rather than `26`: it is the same concern as check 2 at a second
41+
layer, and renumbering would break the identity of checks 3–25, which are cited
42+
by number across this repo and in tracker items. The plugin's "twenty-five
43+
deterministic checks" claim is left standing on that reading.
44+
645
## [0.18.1]
746

847
### Fixed

‎plugins/skill-quality/scripts/check-skill.sh‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
# 2. description + when_to_use <= 1536 chars (per-skill listing-entry cap;
4545
# counts the literal " - " joiner the harness inserts when when_to_use is
4646
# populated)
47+
# 2b. description alone <= 1024 Unicode codepoints (Agent Skills spec FIELD
48+
# maximum — a separate limit at a separate layer from check 2's listing cap;
49+
# WARN, since no local validator enforces it and the Skills API rejects it
50+
# at upload; counted locale-independently via iconv, as check 22 does)
4751
# 3. Trigger-keyword preservation vs the base ref (skipped for new skills;
4852
# a phrase moved verbatim to a sibling skill's listing text — one the
4953
# sibling did not carry at the base ref — WARNs, since the marketplace
@@ -203,8 +207,18 @@ if [[ "$HAVE_GIT" == 1 ]]; then
203207
SKILL_REL="${SKILL_REL%/}"
204208
fi
205209

206-
# Tunables (listing description cap; SKILL.md line caps; vendor sync age).
210+
# Tunables (listing description cap; description field cap; SKILL.md line caps;
211+
# vendor sync age).
207212
DESC_CHAR_CAP=1536
213+
# Agent Skills spec field maximum for `description` ALONE — a different limit at a
214+
# different layer from DESC_CHAR_CAP above, which bounds the assembled listing entry
215+
# (description + " - " + when_to_use). The two do not unify and are checked separately.
216+
# Enforced by the Skills API at package/upload; NOT enforced locally — measured
217+
# 2026-08-23, `claude plugin validate --strict` (Claude Code 2.1.241) passes a
218+
# 1248-char description clean. A breach is therefore latent for filesystem/plugin
219+
# skills and hard for any skill uploaded through the Skills API, which is why this
220+
# is a WARN and DESC_CHAR_CAP stays a FAIL.
221+
DESC_FIELD_CAP=1024
208222
LINE_HARD_CAP=500
209223
LINE_SOFT_CAP=200
210224
SYNCED_MAX_AGE_DAYS=180
@@ -350,6 +364,34 @@ else
350364
note "description length $DESC_LEN/$DESC_CHAR_CAP chars"
351365
fi
352366

367+
# --- Check 2b: description field alone <= DESC_FIELD_CAP codepoints ----------
368+
# The spec's per-FIELD maximum, distinct from check 2's listing-entry cap: a
369+
# description can sit under 1536 combined and still breach 1024 on its own. The
370+
# Skills API rejects that at upload; nothing local does (see DESC_FIELD_CAP above),
371+
# so this warns rather than failing — it reports a real spec breach without
372+
# blocking a fleet that carries pre-existing offenders.
373+
#
374+
# Counted in CODEPOINTS, not bytes: the spec says "Maximum 1024 characters", and
375+
# a byte count would false-positive on any non-ASCII description under a
376+
# byte-oriented locale — measured, 600 'é' characters report as 1200 under
377+
# LC_ALL=C. Same UTF-8 -> UTF-32BE iconv form check 22 uses (every codepoint
378+
# becomes exactly 4 bytes, so byte-count/4 is the codepoint count on any host),
379+
# with the same UTF-8-locale fallback where iconv is absent. DESC_LEN stays a
380+
# byte count for check 2, whose 1536 listing cap is a separate measure.
381+
if command -v iconv >/dev/null 2>&1; then
382+
DESC_CP_LEN=$(($(printf '%s' "$CUR_DESC" | iconv -f UTF-8 -t UTF-32BE | wc -c) / 4))
383+
else
384+
DESC_CP_LEN="$(
385+
LC_ALL=C.UTF-8
386+
printf '%s' "${#CUR_DESC}"
387+
)"
388+
fi
389+
if ((DESC_CP_LEN > DESC_FIELD_CAP)); then
390+
warn "description alone is $DESC_CP_LEN codepoints (Agent Skills spec field maximum $DESC_FIELD_CAP) — accepted locally, rejected on Skills API upload"
391+
else
392+
note "description field $DESC_CP_LEN/$DESC_FIELD_CAP codepoints (spec field maximum)"
393+
fi
394+
353395
# --- Check 3: trigger-keyword preservation vs HEAD -------------------------
354396

355397
if [[ "$HAVE_GIT" != 1 ]]; then

‎plugins/skill-quality/scripts/check-skill.test.sh‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3301,6 +3301,106 @@ else
33013301
pass "awk regexes are free of ERE interval expressions (mawk-portable)"
33023302
fi
33033303

3304+
# Check 2b: description FIELD cap (1024, Agent Skills spec) is a separate limit
3305+
# from check 2's listing-entry cap (1536). The discriminating case is the middle
3306+
# band — a description over 1024 but whose assembled entry is under 1536 — which
3307+
# check 2 passes and only check 2b catches. Without that case a single cap could
3308+
# satisfy both assertions, so it is the one that proves the layers are distinct.
3309+
3310+
# 2b-i. A description one char over the field cap WARNs, and does NOT fail: no
3311+
# local validator enforces this limit (measured — `claude plugin validate
3312+
# --strict` 2.1.241 passes an oversized description clean), so failing the
3313+
# build on it would block a fleet over a breach only the Skills API sees.
3314+
desc_1025="$(printf 'd%.0s' $(seq 1 1025))"
3315+
make_skill field-cap-over "---
3316+
name: field-cap-over
3317+
description: \"$desc_1025\"
3318+
---
3319+
3320+
## Purpose
3321+
3322+
Field-cap fixture: description alone is 1025 chars, one over the 1024 spec
3323+
field maximum, while the assembled listing entry stays well under 1536.
3324+
"
3325+
out="$(run field-cap-over 2>&1)"
3326+
rc=$?
3327+
if [[ $rc -eq 0 ]] && grep -q 'description alone is 1025 codepoints' <<<"$out"; then
3328+
pass "check 2b warns at 1025 chars without failing the run"
3329+
else
3330+
fail "check 2b should WARN (not FAIL) at 1025/1024 (rc=$rc): $out"
3331+
fi
3332+
3333+
# 2b-ii. Boundary guard: exactly 1024 is legal — the cap is >1024, not >=1024.
3334+
desc_1024="$(printf 'd%.0s' $(seq 1 1024))"
3335+
make_skill field-cap-exact "---
3336+
name: field-cap-exact
3337+
description: \"$desc_1024\"
3338+
---
3339+
3340+
## Purpose
3341+
3342+
Field-cap fixture: description alone is exactly 1024 chars, the spec maximum,
3343+
which is legal.
3344+
"
3345+
out="$(run field-cap-exact 2>&1)"
3346+
rc=$?
3347+
if [[ $rc -eq 0 ]] && ! grep -q 'description alone is' <<<"$out"; then
3348+
pass "check 2b stays silent at exactly 1024 chars (cap is >1024, not >=)"
3349+
else
3350+
fail "check 2b should not warn at exactly 1024 (rc=$rc): $out"
3351+
fi
3352+
3353+
# 2b-iii. The discriminating case: desc(1100) + joiner(3) + wtu(40) = 1143 —
3354+
# comfortably under check 2's 1536 listing cap, so check 2 passes and
3355+
# reports no overflow, while the description alone breaches 1024. Proves
3356+
# the two caps are independent layers rather than one limit checked twice.
3357+
desc_1100="$(printf 'd%.0s' $(seq 1 1100))"
3358+
wtu_40="$(printf 'w%.0s' $(seq 1 40))"
3359+
make_skill field-cap-independent "---
3360+
name: field-cap-independent
3361+
description: \"$desc_1100\"
3362+
when_to_use: \"$wtu_40\"
3363+
---
3364+
3365+
## Purpose
3366+
3367+
Independence fixture: the assembled entry is 1143 chars (passes check 2) while
3368+
the description field alone is 1100 (breaches check 2b).
3369+
"
3370+
out="$(run field-cap-independent 2>&1)"
3371+
rc=$?
3372+
if [[ $rc -eq 0 ]] &&
3373+
grep -q 'description alone is 1100 codepoints' <<<"$out" &&
3374+
! grep -q 'description+when_to_use is .* chars (cap 1536' <<<"$out"; then
3375+
pass "check 2b fires on a field breach that check 2's listing cap does not see"
3376+
else
3377+
fail "check 2b should catch desc 1100 while check 2 passes the 1143 entry (rc=$rc): $out"
3378+
fi
3379+
3380+
# 2b-iv. Codepoints, not bytes. 600 'é' is 600 characters and 1200 UTF-8 bytes;
3381+
# the spec's limit is "Maximum 1024 characters", so this must stay silent.
3382+
# Run under LC_ALL=C, where `${#var}` degrades to byte counting and would
3383+
# report 1200 — so the assertion fails on the byte-counting form rather
3384+
# than passing incidentally on a UTF-8 host.
3385+
desc_600_multibyte="$(printf 'é%.0s' $(seq 1 600))"
3386+
make_skill field-cap-multibyte "---
3387+
name: field-cap-multibyte
3388+
description: \"$desc_600_multibyte\"
3389+
---
3390+
3391+
## Purpose
3392+
3393+
Multibyte fixture: 600 codepoints, 1200 UTF-8 bytes. Legal against a 1024
3394+
character cap; a byte count would report 1200 and warn.
3395+
"
3396+
out="$(LC_ALL=C run field-cap-multibyte 2>&1)"
3397+
rc=$?
3398+
if [[ $rc -eq 0 ]] && ! grep -q 'description alone is' <<<"$out"; then
3399+
pass "check 2b counts codepoints, not bytes (600 multibyte chars stay silent under LC_ALL=C)"
3400+
else
3401+
fail "check 2b must count codepoints: 600 'é' is 600 chars, not 1200 bytes (rc=$rc): $out"
3402+
fi
3403+
33043404
if [[ $fails -ne 0 ]]; then
33053405
printf '%d assertion(s) failed\n' "$fails" >&2
33063406
exit 1

0 commit comments

Comments
 (0)