From 7e60197baa28ff32387429a98a3b9b3d7c2e1ebf Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:49:54 -0400 Subject: [PATCH 1/2] fix(skill-quality): name the sibling skill when a cross-skill citation misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check 5 resolves every bare `context/…`-shaped path against the CITING skill's own directory. A skill citing a sibling skill's supporting file therefore failed with "no such file under the skill dir" while the file plainly existed one directory over, and the message pointed at the citing skill — so the author's natural next move was to look for the file where it could never be. That misdirection cost two round trips in a single PR (#2161), once in each direction. The bare form really is wrong, so this stays a FAIL; only the diagnosis changes. When the unresolved path resolves under a sibling skill of the same skills root, the finding names that sibling and the citation form that works. Which form is layout-dependent, so the message follows check 1's existing plugin detection: `${CLAUDE_PLUGIN_ROOT}/skills//…` in a plugin-shaped root, `..//…` outside one, where that variable is undefined and advertising it would be wrong guidance. Extraction is deliberately untouched — prose and inline-code refs stay in scope, since narrowing to link targets would drop coverage the check is presumed to have. PLUGIN-PHILOSOPHY.md gains the rule where the anchoring rules already live, so an author meets the form before the gate teaches it to them. Closes #2173 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FdBsorqGjCU4Mg5nrmgz4m --- docs/PLUGIN-PHILOSOPHY.md | 8 ++ .../skill-quality/.claude-plugin/plugin.json | 2 +- plugins/skill-quality/CHANGELOG.md | 16 +++ plugins/skill-quality/README.md | 4 +- plugins/skill-quality/scripts/check-skill.sh | 35 +++++- .../skill-quality/scripts/check-skill.test.sh | 110 ++++++++++++++++++ 6 files changed, 171 insertions(+), 4 deletions(-) diff --git a/docs/PLUGIN-PHILOSOPHY.md b/docs/PLUGIN-PHILOSOPHY.md index e4488acbb7..31f59bd518 100644 --- a/docs/PLUGIN-PHILOSOPHY.md +++ b/docs/PLUGIN-PHILOSOPHY.md @@ -261,6 +261,14 @@ For project configuration, use neutral repository-relative paths anchored at when the contract requires containment, and document precedence. Do not add an environment variable merely to create a second configuration channel. +Apply the same anchoring rule to bundled assets: one skill citing another skill's supporting file +writes the full `${CLAUDE_PLUGIN_ROOT}/skills//` form, optionally paired with a +relative markdown link target for browsing on GitHub — for example +``[`${CLAUDE_PLUGIN_ROOT}/skills/audit/context/suppression.md`](../audit/context/suppression.md)``. +A bare `context/…`-style path is reserved for a skill's OWN supporting files; it resolves against +the citing skill's directory, so a cross-skill citation written that way points at a file that is +not there. + ## Setup is explicit and repeatable A plugin requires a `setup` skill iff it has (a) a consumer-project configuration surface, (b) an diff --git a/plugins/skill-quality/.claude-plugin/plugin.json b/plugins/skill-quality/.claude-plugin/plugin.json index 6122d6a936..ec8c1c2510 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.15.0", + "version": "0.15.1", "description": "Skill-authoring QA tooling: a static contract checker that runs twenty-two deterministic checks over a Claude Code skill (frontmatter, per-skill listing-entry cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, 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 — no baked layout.", "author": { "name": "Melodic Software", diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index fdc2c2941b..1829d85853 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -3,6 +3,22 @@ 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.15.1] + +### Fixed + +- **A cross-skill citation reported as a broken internal ref pointed at the wrong directory.** + Check 5 resolves every bare `context/…`-shaped path against the CITING skill's own directory, so + a skill citing a sibling skill's supporting file failed with "no such file under the skill dir" + while the file plainly existed one directory over. The message sent the author looking for the + file where it could never be. When the unresolved path does resolve under a sibling skill of the + same skills root, the finding now names that sibling and the citation form that works — + `${CLAUDE_PLUGIN_ROOT}/skills//` in a plugin-shaped root, `..//` + outside one, where that variable is undefined. Still a FAIL: the bare form really does resolve + against the citing skill, so it is wrong regardless of where the file lives. A path no sibling + hosts keeps the original hand-verify wording. Extraction is unchanged — prose and inline-code + refs are still in scope, deliberately. + ## [0.15.0] ### Changed diff --git a/plugins/skill-quality/README.md b/plugins/skill-quality/README.md index cfd888fc92..d2678babf4 100644 --- a/plugins/skill-quality/README.md +++ b/plugins/skill-quality/README.md @@ -24,7 +24,9 @@ phrase, which quietly degrades a skill's auto-invocation. Check 3 compares the t truncates that entry) — a different, narrower limit from the shared budget below. - Trigger-keyword preservation vs `HEAD` (skipped for a new, uncommitted skill). - `SKILL.md` under 500 lines (hard) / 200 lines (soft, advisory). -- Backtick- and link-cited skill-internal supporting files resolve. +- Backtick- and link-cited skill-internal supporting files resolve — a path that instead resolves + under a sibling skill is reported as a cross-skill citation, naming the + `${CLAUDE_PLUGIN_ROOT}/skills//...` form that works. - `markdownlint-cli2` clean (advisory-skips when `npx` is absent). - `scripts/*.test.sh` pass where present. - Vendored `vendor/` byte-identical vs `HEAD`; stale-tracking metadata keys preserved; sync age. diff --git a/plugins/skill-quality/scripts/check-skill.sh b/plugins/skill-quality/scripts/check-skill.sh index f28fca161d..f9780322d3 100755 --- a/plugins/skill-quality/scripts/check-skill.sh +++ b/plugins/skill-quality/scripts/check-skill.sh @@ -40,7 +40,9 @@ # sibling did not carry at the base ref — WARNs, since the marketplace # listing still routes it; lost phrases and coincidental overlap FAIL) # 4. SKILL.md < 500 lines (hard cap) -# 5. Backtick-cited skill-internal supporting files resolve +# 5. Backtick-cited skill-internal supporting files resolve (a path that misses +# here but resolves under a SIBLING skill is reported as a cross-skill +# citation naming the `${CLAUDE_PLUGIN_ROOT}/skills//…` form) # 6. markdownlint clean (markdownlint-cli2; WARN-skip if npx absent) # 7. scripts/*.test.sh pass where present # 8. vendor/ byte-identical vs HEAD, unless paired with an upstream-version @@ -363,7 +365,36 @@ while IFS= read -r ref; do fi if [[ ! -e "$SKILL_DIR/$ref" ]]; then ref_line="$(grep -nF "$ref" "$SKILL_MD" 2>/dev/null | head -1 | cut -d: -f1)" - err "broken skill-internal ref: $ref (no such file under the skill dir; cited at SKILL.md:${ref_line:-?} — hand-verify the line before fixing, may be an illustrative example)" + # A path that misses here but DOES resolve under a SIBLING skill of the same + # skills root is a cross-skill citation written in the skill-internal form — + # not a missing file. Every bare path in this check resolves against the + # CITING skill's dir, so the bare form is genuinely wrong and still FAILs; + # what changes is the message. The default wording sends the author looking + # for the file under their own skill, where it will never be — naming the + # host sibling and the anchored form that works is the difference between a + # dead end and a one-line fix. Glob order is sorted, so a path present under + # more than one sibling names the first deterministically. + REF_HOST="" + for other_md in "$SKILLS_ROOT"/*/SKILL.md; do + [[ -f "$other_md" ]] || continue + [[ "$other_md" == "$SKILL_MD" ]] && continue + if [[ -e "${other_md%/SKILL.md}/$ref" ]]; then + REF_HOST="${other_md%/SKILL.md}" + REF_HOST="${REF_HOST##*/}" + break + fi + done + if [[ -z "$REF_HOST" ]]; then + err "broken skill-internal ref: $ref (no such file under the skill dir; cited at SKILL.md:${ref_line:-?} — hand-verify the line before fixing, may be an illustrative example)" + elif [[ -f "$SKILL_DIR/../../.claude-plugin/plugin.json" ]]; then + # Plugin-shaped skills root (same detection as check 1): bundled plugin + # assets are anchored at the plugin root, so that is the form to name. + err "cross-skill ref written in skill-internal form: $ref (cited at SKILL.md:${ref_line:-?}) — the file is not under this skill, it is under sibling skill '$REF_HOST'; a bare path always resolves against the CITING skill's dir. Anchor the citation at the plugin root: \${CLAUDE_PLUGIN_ROOT}/skills/$REF_HOST/$ref" + else + # Not a plugin: \${CLAUDE_PLUGIN_ROOT} is undefined here, so name the + # layout-free sibling-relative form instead of inventing a skills-root var. + err "cross-skill ref written in skill-internal form: $ref (cited at SKILL.md:${ref_line:-?}) — the file is not under this skill, it is under sibling skill '$REF_HOST'; a bare path always resolves against the CITING skill's dir. Cite the sibling explicitly: ../$REF_HOST/$ref" + fi fi done < <( { diff --git a/plugins/skill-quality/scripts/check-skill.test.sh b/plugins/skill-quality/scripts/check-skill.test.sh index 8155123ccb..bc30633296 100755 --- a/plugins/skill-quality/scripts/check-skill.test.sh +++ b/plugins/skill-quality/scripts/check-skill.test.sh @@ -2285,6 +2285,116 @@ else fail "redundancy warning should be plugin-scoped" fi +# 36a. Check 5: a bare path that misses under the citing skill but resolves under +# a SIBLING skill is a cross-skill citation, not a missing file. It still +# FAILs (the bare form really does resolve against the citing skill), but in +# a plugin-shaped skills root the message must name the host sibling and the +# `${CLAUDE_PLUGIN_ROOT}` form — the default wording sends the author hunting +# under their own skill, where the file will never be. +mkdir -p "$PLUGIN_SKILLS/xref-host/context" "$PLUGIN_SKILLS/xref-citer" +printf 'Sibling-owned supporting file.\n' >"$PLUGIN_SKILLS/xref-host/context/suppression.md" +printf '%s' '--- +description: "Hosts a supporting file. Use when: '"'"'checking cross-skill citations'"'"'." +--- + +## Purpose + +Fixture sibling that owns context/suppression.md. + +## Gotchas + +None known. +' >"$PLUGIN_SKILLS/xref-host/SKILL.md" +printf '%s' '--- +description: "Cites a sibling file. Use when: '"'"'checking cross-skill citations'"'"'." +--- + +## Purpose + +Cites the sibling-owned `context/suppression.md` in the skill-internal form. + +## Gotchas + +None known. +' >"$PLUGIN_SKILLS/xref-citer/SKILL.md" +out="$( (cd "$TMP" && + CHECK_SKILL_SKILLS_ROOT="$PLUGIN_SKILLS" CHECK_SKILL_SKIP_MARKDOWNLINT=1 \ + bash "$SUT" xref-citer) 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && + grep -q "cross-skill ref written in skill-internal form: context/suppression.md" <<<"$out" && + grep -q "sibling skill 'xref-host'" <<<"$out" && + grep -qF '${CLAUDE_PLUGIN_ROOT}/skills/xref-host/context/suppression.md' <<<"$out" && + ! grep -q 'broken skill-internal ref' <<<"$out"; then + pass "a sibling-hosted ref fails as a cross-skill citation naming the plugin-root form" +else + fail "sibling-hosted ref should name the sibling and the plugin-root form (rc=$rc): $out" +fi + +# 36b. Same detection outside a plugin: `${CLAUDE_PLUGIN_ROOT}` is undefined +# there, so the message must name the layout-free sibling-relative form +# instead of advertising a variable the consumer does not have. +mkdir -p "$SKILLS/plain-host/reference" +printf 'Sibling-owned supporting file.\n' >"$SKILLS/plain-host/reference/scaling.md" +make_skill plain-host '--- +description: "Hosts a supporting file. Use when: '"'"'checking non-plugin cross-skill citations'"'"'." +--- + +## Purpose + +Fixture sibling that owns reference/scaling.md. + +## Gotchas + +None known. +' +make_skill plain-citer '--- +description: "Cites a sibling file. Use when: '"'"'checking non-plugin cross-skill citations'"'"'." +--- + +## Purpose + +Cites the sibling-owned `reference/scaling.md` in the skill-internal form. + +## Gotchas + +None known. +' +out="$(run plain-citer 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && + grep -q "sibling skill 'plain-host'" <<<"$out" && + grep -q '\.\./plain-host/reference/scaling\.md' <<<"$out" && + ! grep -q 'Anchor the citation at the plugin root' <<<"$out"; then + pass "outside a plugin the sibling message names the relative form, not the plugin root" +else + fail "non-plugin sibling message should name ../plain-host/... (rc=$rc): $out" +fi + +# 36c. False-positive guard: a ref no sibling hosts is still a plain broken +# internal ref, with the original hand-verify wording intact. +make_skill orphan-citer '--- +description: "Cites a file nobody owns. Use when: '"'"'checking the broken-ref message'"'"'." +--- + +## Purpose + +Cites `context/nowhere-at-all.md`, which no skill in this root owns. + +## Gotchas + +None known. +' +out="$(run orphan-citer 2>&1)" +rc=$? +if [[ $rc -eq 1 ]] && + grep -q 'broken skill-internal ref: context/nowhere-at-all.md' <<<"$out" && + ! grep -q 'cross-skill ref' <<<"$out"; then + pass "a ref no sibling hosts keeps the plain broken-internal-ref message" +else + fail "unhosted ref should keep the broken-internal-ref message (rc=$rc): $out" +fi + if [[ $fails -ne 0 ]]; then printf '%d assertion(s) failed\n' "$fails" >&2 exit 1 From 3f58e20fb435a1d7fd170d1244df1235f38bd986 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:17:56 -0400 Subject: [PATCH 2/2] fix(skill-quality): keep the sibling hit a hypothesis, and hoist plugin detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the check-5 message. The sibling existence hit is evidence, not proof. Check 5 deliberately extracts prose and inline-code refs, so a generic path (`scripts/run.sh`) can collide with an unrelated same-named file under a sibling. The first wording asserted the cross-skill reading and dropped the hand-verify caveat, which would misdirect exactly the illustrative-example case the caveat exists for. The finding now keeps the original message verbatim and APPENDS the sibling evidence conditionally, so both readings — real cross-skill citation, coincidental name collision — stay live and the author still gets the anchored form to paste. The plugin-shaped-root test was also restated verbatim at two call sites (check 1's bare-alias warning, check 5's suggested form). One layout convention asserted twice drifts the moment it changes, so it is hoisted to a single IS_PLUGIN_SKILL computed once after the skill dir resolves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FdBsorqGjCU4Mg5nrmgz4m --- plugins/skill-quality/CHANGELOG.md | 10 ++-- plugins/skill-quality/README.md | 7 ++- plugins/skill-quality/scripts/check-skill.sh | 57 +++++++++++++------ .../skill-quality/scripts/check-skill.test.sh | 21 +++---- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index 1829d85853..2a8bf47fa1 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -12,12 +12,14 @@ All notable changes to the `skill-quality` plugin are documented here. Format fo a skill citing a sibling skill's supporting file failed with "no such file under the skill dir" while the file plainly existed one directory over. The message sent the author looking for the file where it could never be. When the unresolved path does resolve under a sibling skill of the - same skills root, the finding now names that sibling and the citation form that works — + same skills root, the finding now also names that sibling and the citation form that works — `${CLAUDE_PLUGIN_ROOT}/skills//` in a plugin-shaped root, `..//` outside one, where that variable is undefined. Still a FAIL: the bare form really does resolve - against the citing skill, so it is wrong regardless of where the file lives. A path no sibling - hosts keeps the original hand-verify wording. Extraction is unchanged — prose and inline-code - refs are still in scope, deliberately. + against the citing skill, so it is wrong regardless of where the file lives. The sibling hit is + evidence, not proof — this check deliberately extracts prose and inline-code refs, so a generic + path can collide with an unrelated same-named sibling file — so the original hand-verify wording + is kept and the suggestion is phrased conditionally. A path no sibling hosts is unchanged. + Extraction is unchanged too: prose and inline-code refs are still in scope, deliberately. ## [0.15.0] diff --git a/plugins/skill-quality/README.md b/plugins/skill-quality/README.md index d2678babf4..714a35e3c1 100644 --- a/plugins/skill-quality/README.md +++ b/plugins/skill-quality/README.md @@ -24,9 +24,10 @@ phrase, which quietly degrades a skill's auto-invocation. Check 3 compares the t truncates that entry) — a different, narrower limit from the shared budget below. - Trigger-keyword preservation vs `HEAD` (skipped for a new, uncommitted skill). - `SKILL.md` under 500 lines (hard) / 200 lines (soft, advisory). -- Backtick- and link-cited skill-internal supporting files resolve — a path that instead resolves - under a sibling skill is reported as a cross-skill citation, naming the - `${CLAUDE_PLUGIN_ROOT}/skills//...` form that works. +- Backtick- and link-cited skill-internal supporting files resolve — when a path that misses instead + resolves under a sibling skill, the finding names that sibling and the + `${CLAUDE_PLUGIN_ROOT}/skills//...` cross-skill form, while keeping the hand-verify + caveat (the sibling hit is evidence, not proof: paths can collide). - `markdownlint-cli2` clean (advisory-skips when `npx` is absent). - `scripts/*.test.sh` pass where present. - Vendored `vendor/` byte-identical vs `HEAD`; stale-tracking metadata keys preserved; sync age. diff --git a/plugins/skill-quality/scripts/check-skill.sh b/plugins/skill-quality/scripts/check-skill.sh index f9780322d3..67c37235a9 100755 --- a/plugins/skill-quality/scripts/check-skill.sh +++ b/plugins/skill-quality/scripts/check-skill.sh @@ -41,8 +41,9 @@ # listing still routes it; lost phrases and coincidental overlap FAIL) # 4. SKILL.md < 500 lines (hard cap) # 5. Backtick-cited skill-internal supporting files resolve (a path that misses -# here but resolves under a SIBLING skill is reported as a cross-skill -# citation naming the `${CLAUDE_PLUGIN_ROOT}/skills//…` form) +# here but resolves under a SIBLING skill also names that sibling and the +# `${CLAUDE_PLUGIN_ROOT}/skills//…` cross-skill form, without +# dropping the hand-verify caveat — the hit may be a name collision) # 6. markdownlint clean (markdownlint-cli2; WARN-skip if npx absent) # 7. scripts/*.test.sh pass where present # 8. vendor/ byte-identical vs HEAD, unless paired with an upstream-version @@ -199,6 +200,15 @@ if [[ ! -f "$SKILL_MD" ]]; then exit 1 fi +# Is this skill bundled in a PLUGIN, or a loose skill under some skills root? +# A plugin manifest two levels up (/skills//) is the marker. +# Several checks branch on it — check 1 (a declared `name` also registers a bare +# alias there) and check 5 (a cross-skill citation anchors at +# ${CLAUDE_PLUGIN_ROOT}, which is undefined outside a plugin) — so the layout +# convention is asserted in ONE place rather than restated per call site. +IS_PLUGIN_SKILL=0 +[[ -f "$SKILL_DIR/../../.claude-plugin/plugin.json" ]] && IS_PLUGIN_SKILL=1 + # --- Check 1: frontmatter parses; description present; declared name matches -- FRONTMATTER="$(skill_frontmatter::extract <"$SKILL_MD")" @@ -231,7 +241,7 @@ else 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'" - elif [[ -n "$CUR_NAME" && -f "$SKILL_DIR/../../.claude-plugin/plugin.json" ]]; then + elif [[ -n "$CUR_NAME" && "$IS_PLUGIN_SKILL" == 1 ]]; then # In a PLUGIN skill a matching `name` is not inert: a declared name also # answers to the bare `/` unless another command owns that token # (https://code.claude.com/docs/en/skills#how-a-skill-gets-its-command-name), @@ -366,14 +376,21 @@ while IFS= read -r ref; do if [[ ! -e "$SKILL_DIR/$ref" ]]; then ref_line="$(grep -nF "$ref" "$SKILL_MD" 2>/dev/null | head -1 | cut -d: -f1)" # A path that misses here but DOES resolve under a SIBLING skill of the same - # skills root is a cross-skill citation written in the skill-internal form — - # not a missing file. Every bare path in this check resolves against the - # CITING skill's dir, so the bare form is genuinely wrong and still FAILs; - # what changes is the message. The default wording sends the author looking - # for the file under their own skill, where it will never be — naming the - # host sibling and the anchored form that works is the difference between a - # dead end and a one-line fix. Glob order is sorted, so a path present under - # more than one sibling names the first deterministically. + # skills root is most often a cross-skill citation written in the + # skill-internal form — not a missing file. Every bare path in this check + # resolves against the CITING skill's dir, so the bare form is wrong either + # way and still FAILs; what changes is the message. The default wording + # sends the author looking for the file under their own skill, where it will + # never be — naming the host sibling and the anchored form that works is the + # difference between a dead end and a one-line fix. + # + # The sibling hit is EVIDENCE, not proof: this check deliberately extracts + # prose and inline-code refs, so a generic path (`scripts/run.sh`) can + # collide with an unrelated same-named sibling file. The wording therefore + # stays conditional and keeps the hand-verify instruction the default + # message carries — a coincidental name match and an illustrative example + # are both still live readings. Glob order is sorted, so a path present + # under more than one sibling names the first deterministically. REF_HOST="" for other_md in "$SKILLS_ROOT"/*/SKILL.md; do [[ -f "$other_md" ]] || continue @@ -386,14 +403,18 @@ while IFS= read -r ref; do done if [[ -z "$REF_HOST" ]]; then err "broken skill-internal ref: $ref (no such file under the skill dir; cited at SKILL.md:${ref_line:-?} — hand-verify the line before fixing, may be an illustrative example)" - elif [[ -f "$SKILL_DIR/../../.claude-plugin/plugin.json" ]]; then - # Plugin-shaped skills root (same detection as check 1): bundled plugin - # assets are anchored at the plugin root, so that is the form to name. - err "cross-skill ref written in skill-internal form: $ref (cited at SKILL.md:${ref_line:-?}) — the file is not under this skill, it is under sibling skill '$REF_HOST'; a bare path always resolves against the CITING skill's dir. Anchor the citation at the plugin root: \${CLAUDE_PLUGIN_ROOT}/skills/$REF_HOST/$ref" else - # Not a plugin: \${CLAUDE_PLUGIN_ROOT} is undefined here, so name the - # layout-free sibling-relative form instead of inventing a skills-root var. - err "cross-skill ref written in skill-internal form: $ref (cited at SKILL.md:${ref_line:-?}) — the file is not under this skill, it is under sibling skill '$REF_HOST'; a bare path always resolves against the CITING skill's dir. Cite the sibling explicitly: ../$REF_HOST/$ref" + # Plugin-shaped root: bundled plugin assets are anchored at the plugin + # root, so that is the form to name. Outside a plugin + # ${CLAUDE_PLUGIN_ROOT} is undefined, so name the layout-free + # sibling-relative form rather than advertise a variable that resolves to + # nothing. + if [[ "$IS_PLUGIN_SKILL" == 1 ]]; then + ref_fix="\${CLAUDE_PLUGIN_ROOT}/skills/$REF_HOST/$ref" + else + ref_fix="../$REF_HOST/$ref" + fi + err "broken skill-internal ref: $ref (no such file under the skill dir; cited at SKILL.md:${ref_line:-?} — hand-verify the line before fixing, may be an illustrative example). A file with that path DOES exist under sibling skill '$REF_HOST': if that is the file meant, this is a cross-skill citation, and a bare path always resolves against the CITING skill's dir — write it as $ref_fix. If the names merely collide, the ref is unrelated to that sibling." fi fi done < <( diff --git a/plugins/skill-quality/scripts/check-skill.test.sh b/plugins/skill-quality/scripts/check-skill.test.sh index bc30633296..b69117000b 100755 --- a/plugins/skill-quality/scripts/check-skill.test.sh +++ b/plugins/skill-quality/scripts/check-skill.test.sh @@ -2286,11 +2286,12 @@ else fi # 36a. Check 5: a bare path that misses under the citing skill but resolves under -# a SIBLING skill is a cross-skill citation, not a missing file. It still -# FAILs (the bare form really does resolve against the citing skill), but in -# a plugin-shaped skills root the message must name the host sibling and the -# `${CLAUDE_PLUGIN_ROOT}` form — the default wording sends the author hunting -# under their own skill, where the file will never be. +# a SIBLING skill is most likely a cross-skill citation, not a missing file. +# It still FAILs (the bare form really does resolve against the citing +# skill), but in a plugin-shaped skills root the message must name the host +# sibling and the `${CLAUDE_PLUGIN_ROOT}` form — the default wording alone +# sends the author hunting under their own skill, where the file will never +# be. The hand-verify caveat stays: the hit is evidence, not proof. mkdir -p "$PLUGIN_SKILLS/xref-host/context" "$PLUGIN_SKILLS/xref-citer" printf 'Sibling-owned supporting file.\n' >"$PLUGIN_SKILLS/xref-host/context/suppression.md" printf '%s' '--- @@ -2322,11 +2323,11 @@ out="$( (cd "$TMP" && bash "$SUT" xref-citer) 2>&1)" rc=$? if [[ $rc -eq 1 ]] && - grep -q "cross-skill ref written in skill-internal form: context/suppression.md" <<<"$out" && + grep -q 'broken skill-internal ref: context/suppression.md' <<<"$out" && grep -q "sibling skill 'xref-host'" <<<"$out" && grep -qF '${CLAUDE_PLUGIN_ROOT}/skills/xref-host/context/suppression.md' <<<"$out" && - ! grep -q 'broken skill-internal ref' <<<"$out"; then - pass "a sibling-hosted ref fails as a cross-skill citation naming the plugin-root form" + grep -q 'hand-verify the line' <<<"$out"; then + pass "a sibling-hosted ref names the sibling and the plugin-root form" else fail "sibling-hosted ref should name the sibling and the plugin-root form (rc=$rc): $out" fi @@ -2365,7 +2366,7 @@ rc=$? if [[ $rc -eq 1 ]] && grep -q "sibling skill 'plain-host'" <<<"$out" && grep -q '\.\./plain-host/reference/scaling\.md' <<<"$out" && - ! grep -q 'Anchor the citation at the plugin root' <<<"$out"; then + ! grep -q 'skills/plain-host/reference/scaling\.md' <<<"$out"; then pass "outside a plugin the sibling message names the relative form, not the plugin root" else fail "non-plugin sibling message should name ../plain-host/... (rc=$rc): $out" @@ -2389,7 +2390,7 @@ out="$(run orphan-citer 2>&1)" rc=$? if [[ $rc -eq 1 ]] && grep -q 'broken skill-internal ref: context/nowhere-at-all.md' <<<"$out" && - ! grep -q 'cross-skill ref' <<<"$out"; then + ! grep -q 'sibling skill' <<<"$out"; then pass "a ref no sibling hosts keeps the plain broken-internal-ref message" else fail "unhosted ref should keep the broken-internal-ref message (rc=$rc): $out"