From 691a77ddd686d9a087b803574a58307f70476862 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:23:27 -0400 Subject: [PATCH 1/3] feat(skill-quality): enforce frontmatter name matches skill directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLUGIN-PHILOSOPHY has always required the frontmatter `name` to match the skill directory name, but nothing verified it — check 1 asserted only that `name:` was present and non-empty. The mismatch is not cosmetic. The directory name is what Claude Code namespaces the skill by, so a divergent frontmatter name silently relocates the invocation the doctrine says the skill has. And because the slash-command picker labels rows by the resolved leaf name, the drift never surfaced in the listing either. Lands blocking rather than as a warning: all 144 skills already conform, so there is no debt to grandfather and no baseline file to stale-guard. Closes #712 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011ogVV7z2Heg9ojJ88nqNxh --- .../skill-quality/.claude-plugin/plugin.json | 2 +- plugins/skill-quality/CHANGELOG.md | 14 ++++++ plugins/skill-quality/scripts/check-skill.sh | 13 ++++- .../skill-quality/scripts/check-skill.test.sh | 47 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/plugins/skill-quality/.claude-plugin/plugin.json b/plugins/skill-quality/.claude-plugin/plugin.json index e73dfb713e..6b9ebf25b9 100644 --- a/plugins/skill-quality/.claude-plugin/plugin.json +++ b/plugins/skill-quality/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "skill-quality", - "version": "0.5.0", + "version": "0.6.0", "description": "Skill-authoring QA tooling: a static contract checker that runs seventeen deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout.", "author": { "name": "Melodic Software", diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index f087f59d2b..51b06a3a0a 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the `skill-quality` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.0] + +### Added + +- **Check 1 now enforces that frontmatter `name` matches the skill directory name.** + `docs/PLUGIN-PHILOSOPHY.md` has always required it, but nothing verified it — check 1 asserted + only that `name:` was present and non-empty. The directory name is what Claude Code namespaces the + skill by, so a divergent frontmatter `name` silently relocates the invocation the doctrine says + the skill has, and because the slash-command picker labels rows by the resolved leaf name the + drift never surfaced in the listing either. Lands as a deterministic FAIL rather than a warning: + the whole catalog (144 skills) already conforms, so there is no debt to grandfather and no + baseline file. A quoted value is unquoted before comparison, and an absent `name` still reports + only the existing missing-`name` failure rather than a spurious second one. + ## [0.5.0] ### Changed diff --git a/plugins/skill-quality/scripts/check-skill.sh b/plugins/skill-quality/scripts/check-skill.sh index 56108bc941..a0f85f7f9d 100755 --- a/plugins/skill-quality/scripts/check-skill.sh +++ b/plugins/skill-quality/scripts/check-skill.sh @@ -25,7 +25,7 @@ # (e.g. HEAD^ or a merge-base). # # Checks: -# 1. Frontmatter parses; name + description present +# 1. Frontmatter parses; name matches dir; description present # 2. description + when_to_use <= 1536 chars (listing-truncation guard) # 3. Trigger-keyword preservation vs the base ref (skipped for new skills) # 4. SKILL.md < 500 lines (hard cap) @@ -150,7 +150,7 @@ if [[ ! -f "$SKILL_MD" ]]; then exit 1 fi -# --- Check 1: frontmatter parses; name + description present --------------- +# --- Check 1: frontmatter parses; name matches dir; description present ---- FRONTMATTER="$(skill_frontmatter::extract <"$SKILL_MD")" if [[ -z "$FRONTMATTER" ]]; then @@ -158,6 +158,15 @@ if [[ -z "$FRONTMATTER" ]]; then else grep -qE '^name:[[:space:]]*\S' <<<"$FRONTMATTER" || err "frontmatter missing 'name:'" grep -qE '^description:[[:space:]]*\S' <<<"$FRONTMATTER" || err "frontmatter missing 'description:'" + + # The directory name is what Claude Code namespaces the skill by, so a + # divergent frontmatter name silently relocates the invocation the doctrine + # says the skill has — and the picker labels rows by the resolved leaf name, + # so the drift never surfaces in the listing either. + CUR_NAME="$(skill_frontmatter::strip_quotes "$(skill_frontmatter::field name <<<"$FRONTMATTER")")" + if [[ -n "$CUR_NAME" && "$CUR_NAME" != "$SKILL_NAME" ]]; then + err "frontmatter name '$CUR_NAME' does not match skill directory '$SKILL_NAME'" + fi fi # --- Check 2: description + when_to_use <= DESC_CHAR_CAP chars -------------- diff --git a/plugins/skill-quality/scripts/check-skill.test.sh b/plugins/skill-quality/scripts/check-skill.test.sh index 9b75c173a0..ea5bc7c271 100755 --- a/plugins/skill-quality/scripts/check-skill.test.sh +++ b/plugins/skill-quality/scripts/check-skill.test.sh @@ -347,6 +347,53 @@ else fail "trigger on a less-indented block line should be tracked (rc=$rc): $out" fi +# 14. Frontmatter name diverging from the skill directory fails (check 1). The +# directory name is what the skill is namespaced by, so a mismatch silently +# relocates the invocation the doctrine says the skill has. +make_skill misnamed-skill '--- +name: some-other-name +description: "Name does not match its directory. Use when: '"'"'checking the name gate'"'"'." +--- + +## Purpose + +Fixture whose frontmatter name diverges from its directory name. + +## Gotchas + +None known. +' +out="$(run misnamed-skill 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && grep -q "does not match skill directory 'misnamed-skill'" <<<"$out"; then + pass "frontmatter name mismatching the directory fails" +else + fail "name/directory mismatch should fail (rc=$rc): $out" +fi + +# 15. A matching name does not trip the gate — guards the false-positive +# direction, and proves a quoted value reaches the comparison unquoted. +make_skill quoted-name '--- +name: "quoted-name" +description: "Quoted name matching its directory. Use when: '"'"'checking quoted names'"'"'." +--- + +## Purpose + +Fixture whose frontmatter name is quoted but matches its directory. + +## Gotchas + +None known. +' +out="$(run quoted-name 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && ! grep -q 'does not match skill directory' <<<"$out"; then + pass "quoted name matching the directory does not trip the gate" +else + fail "quoted matching name should not trip the gate (rc=$rc): $out" +fi + if [[ $fails -ne 0 ]]; then printf '%d assertion(s) failed\n' "$fails" >&2 exit 1 From b97bd362d15151b2466894a25c7fa50450577696 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:37:46 -0400 Subject: [PATCH 2/3] fix(skill-quality): parse the YAML name scalar before comparing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailing `# comment` is legal on a plain YAML scalar and is not part of the value, but skill_frontmatter::field returns the raw suffix — so `name: my-skill # migration note` compared as `my-skill # migration note` and failed an otherwise correctly named skill. Verified against the helper directly before fixing. Strips from the first whitespace-then-hash ahead of unquoting, so a quoted name carrying a comment resolves too. Skill names are kebab-case per the Agent Skills spec, so a '#' can never belong to the value. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011ogVV7z2Heg9ojJ88nqNxh --- plugins/skill-quality/scripts/check-skill.sh | 9 +++- .../skill-quality/scripts/check-skill.test.sh | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/plugins/skill-quality/scripts/check-skill.sh b/plugins/skill-quality/scripts/check-skill.sh index a0f85f7f9d..4a183c7a60 100755 --- a/plugins/skill-quality/scripts/check-skill.sh +++ b/plugins/skill-quality/scripts/check-skill.sh @@ -163,7 +163,14 @@ else # divergent frontmatter name silently relocates the invocation the doctrine # says the skill has — and the picker labels rows by the resolved leaf name, # so the drift never surfaces in the listing either. - CUR_NAME="$(skill_frontmatter::strip_quotes "$(skill_frontmatter::field name <<<"$FRONTMATTER")")" + RAW_NAME="$(skill_frontmatter::field name <<<"$FRONTMATTER")" + # A trailing `# comment` is legal on a YAML scalar and is not part of the + # value. Skill names are kebab-case per the Agent Skills spec, so a '#' can + # never belong to the name itself — strip from the first whitespace-then-hash, + # before unquoting, so a quoted name with a trailing comment also resolves. + RAW_NAME="${RAW_NAME%%[[:space:]]#*}" + RAW_NAME="${RAW_NAME%"${RAW_NAME##*[![:space:]]}"}" + CUR_NAME="$(skill_frontmatter::strip_quotes "$RAW_NAME")" if [[ -n "$CUR_NAME" && "$CUR_NAME" != "$SKILL_NAME" ]]; then err "frontmatter name '$CUR_NAME' does not match skill directory '$SKILL_NAME'" fi diff --git a/plugins/skill-quality/scripts/check-skill.test.sh b/plugins/skill-quality/scripts/check-skill.test.sh index ea5bc7c271..07ae425483 100755 --- a/plugins/skill-quality/scripts/check-skill.test.sh +++ b/plugins/skill-quality/scripts/check-skill.test.sh @@ -394,6 +394,51 @@ else fail "quoted matching name should not trip the gate (rc=$rc): $out" fi +# 16. A trailing YAML comment is not part of the scalar, so a correctly named +# skill carrying one must not trip the gate (false-positive direction). +make_skill commented-name '--- +name: commented-name # kept for the migration note +description: "Name carries a trailing YAML comment. Use when: '"'"'checking comment stripping'"'"'." +--- + +## Purpose + +Fixture whose frontmatter name carries a legal trailing comment. + +## Gotchas + +None known. +' +out="$(run commented-name 2>&1)" +rc=$? +if [[ $rc -eq 0 ]] && ! grep -q 'does not match skill directory' <<<"$out"; then + pass "trailing YAML comment on name does not trip the gate" +else + fail "commented name should not trip the gate (rc=$rc): $out" +fi + +# 17. Comment stripping must not mask a real mismatch. +make_skill commented-bad '--- +name: wrong-name # with a comment too +description: "Wrong name plus a comment. Use when: '"'"'checking comment stripping'"'"'." +--- + +## Purpose + +Fixture that is both misnamed and commented. + +## Gotchas + +None known. +' +out="$(run commented-bad 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && grep -q "frontmatter name 'wrong-name' does not match" <<<"$out"; then + pass "comment stripping does not mask a real mismatch" +else + fail "commented misnamed skill should still fail (rc=$rc): $out" +fi + if [[ $fails -ne 0 ]]; then printf '%d assertion(s) failed\n' "$fails" >&2 exit 1 From 7870b2d04154401568358688719b4124bf09704d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:45:57 -0400 Subject: [PATCH 3/3] fix(skill-quality): constrain the accepted name syntax to kebab-case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double-quoted YAML scalar may carry an escape — `"escaped\x2dname"` decodes to `escaped-name` — which strip_quotes leaves literal, so a correctly named skill would fail as a directory mismatch. Decoding YAML in bash to fix that would be the wrong trade. The Agent Skills spec already restricts a name to lowercase alphanumerics and hyphens, and PLUGIN-PHILOSOPHY defers to that charset in the same sentence that mandates the directory match — a half nothing enforced either. Validating it reports an escape sequence as the name defect it is, keeps the directory comparison working on literal text, and closes the other half of the doctrine's rule. All 144 skills conform, so this stays green alongside the match gate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011ogVV7z2Heg9ojJ88nqNxh --- plugins/skill-quality/scripts/check-skill.sh | 10 +++++++- .../skill-quality/scripts/check-skill.test.sh | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/plugins/skill-quality/scripts/check-skill.sh b/plugins/skill-quality/scripts/check-skill.sh index 4a183c7a60..e776981c7d 100755 --- a/plugins/skill-quality/scripts/check-skill.sh +++ b/plugins/skill-quality/scripts/check-skill.sh @@ -171,7 +171,15 @@ else RAW_NAME="${RAW_NAME%%[[:space:]]#*}" RAW_NAME="${RAW_NAME%"${RAW_NAME##*[![:space:]]}"}" CUR_NAME="$(skill_frontmatter::strip_quotes "$RAW_NAME")" - if [[ -n "$CUR_NAME" && "$CUR_NAME" != "$SKILL_NAME" ]]; then + # Constrain the accepted syntax rather than reimplementing a YAML decoder in + # bash: the Agent Skills spec restricts a name to lowercase alphanumerics and + # hyphens, so anything else (an escape sequence like "\x2d", whitespace, an + # unresolved quote) is a name defect in its own right. Reporting it as one + # keeps the directory comparison below working on literal text, and stops a + # decodable-but-undecoded scalar from surfacing as a confusing mismatch. + if [[ -n "$CUR_NAME" && ! "$CUR_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + err "frontmatter name '$CUR_NAME' is not kebab-case ([a-z0-9] and hyphens, per the Agent Skills spec)" + elif [[ -n "$CUR_NAME" && "$CUR_NAME" != "$SKILL_NAME" ]]; then err "frontmatter name '$CUR_NAME' does not match skill directory '$SKILL_NAME'" fi fi diff --git a/plugins/skill-quality/scripts/check-skill.test.sh b/plugins/skill-quality/scripts/check-skill.test.sh index 07ae425483..6d857e0950 100755 --- a/plugins/skill-quality/scripts/check-skill.test.sh +++ b/plugins/skill-quality/scripts/check-skill.test.sh @@ -417,6 +417,30 @@ else fail "commented name should not trip the gate (rc=$rc): $out" fi +# 16b. A YAML escape sequence is reported as the name defect it is, rather than +# surfacing as a confusing directory mismatch. Constraining the accepted +# syntax is the alternative to decoding YAML in bash. +make_skill escaped-name '--- +name: "escaped\x2dname" +description: "Name carries a YAML escape. Use when: '"'"'checking the charset gate'"'"'." +--- + +## Purpose + +Fixture whose frontmatter name uses a YAML escape sequence. + +## Gotchas + +None known. +' +out="$(run escaped-name 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && grep -q 'is not kebab-case' <<<"$out"; then + pass "a YAML escape in the name reports as a charset defect, not a mismatch" +else + fail "escaped name should fail the charset gate (rc=$rc): $out" +fi + # 17. Comment stripping must not mask a real mismatch. make_skill commented-bad '--- name: wrong-name # with a comment too