Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ jobs:
# skill needs a row in the catalogue's skill-license table.
run: bash scripts/check-skill-licenses.sh

- name: ✍️ Check first-party skill authoring rules
# A first-party skill's references sit one level deep and are linked from its
# entry file, and its description stays inside the recorded limit. Exceptions
# carry a reason in scripts/skill-authoring-exceptions.json and can only shrink.
run: bash scripts/check-skill-authoring.sh

lint-scripts:
name: Lint scripts
runs-on: ubuntu-latest
Expand Down Expand Up @@ -310,6 +316,9 @@ jobs:
- name: 🧪 Self-test the skill license check
run: bash scripts/check-skill-licenses.test.sh

- name: 🧪 Self-test the skill authoring check
run: bash scripts/check-skill-authoring.test.sh

- name: 🧪 Self-test the desired-state digest generator
# Proves the generator writes exactly what validate-manifests.sh demands — a
# generator that disagreed with the gate would leave a sync branch just as
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ complete inventory; read a script's header for its arguments and safeguards.
| Propose or publish a marketplace release | [Release guide](marketplace-releases.md), which explains the separate opt-in steps and recovery |
| Recheck open pull requests after changing CI | [recheck-open-prs.sh](../scripts/recheck-open-prs.sh) |

The directory also contains checks for package discovery, skill provenance and licensing,
The directory also contains checks for package discovery, skill provenance, licensing and authoring rules,
version changes, changelogs, generated writes, release observations and instruction size.
The [validation guide](validation.md) explains which checks to run and what they establish.

Expand Down
24 changes: 21 additions & 3 deletions docs/skill-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,27 @@ catalogued, external skills keep their provenance and are not edited, every bund
licence record, each `SKILL.md` validates against the open skill format, and a changed plugin
has a changed version. [`validation.md`](validation.md) has the commands.

Not checked by CI: whether a skill's examples are correct, and whether it loads for the tasks
it is meant for. The first is the correctness gate in the scorecard. The second is what the
activation suite in #651 will measure; until then it is the trial in each pull request.
Checked for first-party skills only, because external skills are not edited here:

- **Entry file size.** `SKILL.md` is at most 500 lines and 20,000 bytes.
- **References.** Every file under `references/` sits directly in that folder. Each Markdown
reference is linked from `SKILL.md`. A worked example or data file is linked from `SKILL.md`
or from the Markdown reference that explains it.
- **Description length.** At most 600 characters. The limit was set on 2026-11-10 from the 18
first-party skills: 16 were at or under it, and the format itself allows 1,024. CI prints
every first-party description's length on each run.

A skill that cannot meet a reference or description rule yet is recorded in
[`skill-authoring-exceptions.json`](../scripts/skill-authoring-exceptions.json) with its
reason. The record can only shrink: CI fails when an exception is no longer needed, when a
recorded description grows, and when its recorded ceiling is higher than the measured length.
The five first exceptions are tracked in #680.

Not checked by CI: whether a skill's examples are correct, whether it loads for the tasks it
is meant for, and whether its acceptance scenarios exist. The first is the correctness gate in
the scorecard. The other two belong to the activation suite in #651, which decides where
scenarios live; until then they are the trial in each pull request and the scenarios recorded
on the skill's issue.

## Keeping sources current

Expand Down
5 changes: 5 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ bash scripts/plugin-scorecard.test.sh
# plugins/<plugin>/resources/THIRD-PARTY-NOTICES.md; every skill needs a row in docs/plugins.md.
bash scripts/check-skill-licenses.sh
bash scripts/check-skill-licenses.test.sh
# A first-party skill's references sit one level deep and are linked from its entry file, and its
# description stays inside the limit (CI's "Check first-party skill authoring rules" step). Needs
# jq and the Go yq. Exceptions, each with its reason: scripts/skill-authoring-exceptions.json.
bash scripts/check-skill-authoring.sh
bash scripts/check-skill-authoring.test.sh
# Remote MCP URL syntax uses the repository-local Go helper (Go ≥1.22, standard library only).
GOENV=off GOWORK=off GO111MODULE=off GOTOOLCHAIN=local GOFLAGS='' CGO_ENABLED=0 \
go test scripts/mcp-url-go/main.go scripts/mcp-url-go/main_test.go
Expand Down
237 changes: 237 additions & 0 deletions scripts/check-skill-authoring.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# Check the authoring rules for first-party skills that no other check enforces (#678).
#
# A first-party skill is one whose SKILL.md records no install provenance: the same test
# validate-manifests.sh applies. An external skill is owned upstream, is never edited here
# and is not judged by this check.
#
# RULES (the skill-lifecycle skill's references/authoring.md states them)
# 1. Everything under a skill's references/ folder is a regular file directly inside it.
# 2. Every Markdown file in references/ is linked from SKILL.md. Any other file there (a
# worked example, a data file) is linked from SKILL.md or from a Markdown file in
# references/.
# 3. The description is no longer than the recorded limit, in characters.
# The entry file's own 500-line / 20000-byte budget is checked by validate-manifests.sh.
#
# EXCEPTIONS are recorded, with a reason each, in scripts/skill-authoring-exceptions.json
# (read from the working directory). They can only shrink: an exception whose rule now
# holds, whose file is gone, or whose recorded description ceiling no longer equals the
# measured length fails the check until the record is corrected.
#
# LINKS are read from Markdown outside fenced code blocks: inline `[text](target)` links
# and `[label]: target` definitions. A `#fragment` is ignored. A target in angle brackets
# or with spaces is not recognised and is reported as a missing link.
#
# Operates on the current working directory (run from the repository root, as CI does).
#
# USAGE check-skill-authoring.sh
# EXIT 0 every first-party skill follows the rules
# 1 a rule is broken, an exception is stale, or a skill cannot be classified
# 2 usage error, a required tool is missing, or the exception record is unusable
set -euo pipefail

here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=scripts/frontmatter.lib.sh
. "$here/frontmatter.lib.sh"

unusable() {
printf 'check-skill-authoring: %s\n' "$*" >&2
exit 2
}

[ "$#" -eq 0 ] || unusable 'usage: check-skill-authoring.sh (no arguments; run from the repository root)'
for tool in jq yq awk find sort; do
command -v "$tool" > /dev/null 2>&1 || unusable "$tool is required"
done
# The description is decoded by a YAML parser, never by pattern matching. Only the Go yq
# (mikefarah) reads Markdown front matter; another yq must not be trusted to mean the same.
case $(yq --version 2>&1) in
*mikefarah*) ;;
*) unusable 'the Go yq (github.com/mikefarah/yq) is required' ;;
esac
[ -d plugins ] || unusable 'no plugins directory here: run from the repository root'

exceptions=scripts/skill-authoring-exceptions.json
[ -f "$exceptions" ] || unusable "$exceptions is missing"
# One exact shape, so a typo cannot silently drop a rule or an exception.
jq -e '
def text: type == "string" and (gsub("\\s"; "") | length) > 0;
type == "object" and (keys == ["description", "references"]) and
(.description | type == "object" and (keys == ["exceptions", "limit"]) and
(.limit | type == "number" and . == floor and . >= 1 and . <= 1024)) and
(.description.limit as $limit | .description.exceptions | type == "array" and all(.[];
type == "object" and (keys == ["ceiling", "reason", "skill"]) and
(.skill | text) and (.reason | text) and
(.ceiling | type == "number" and . == floor and . > $limit and . <= 1024))) and
((.description.exceptions | map(.skill)) as $skills | ($skills | unique | length) == ($skills | length)) and
(.references | type == "object" and (keys == ["exceptions"]) and
(.exceptions | type == "array" and all(.[];
type == "object" and (keys == ["file", "reason"]) and (.file | text) and (.reason | text)))) and
((.references.exceptions | map(.file)) as $files | ($files | unique | length) == ($files | length))
' "$exceptions" > /dev/null 2>&1 || unusable "$exceptions does not have the expected shape"

work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
limit=$(jq -r '.description.limit' "$exceptions")
jq -r '.description.exceptions[] | [.skill, (.ceiling | tostring)] | @tsv' "$exceptions" > "$work/description-exceptions"
jq -r '.references.exceptions[].file' "$exceptions" > "$work/reference-exceptions"
: > "$work/description-exceptions-used"
: > "$work/reference-exceptions-used"

failed=0
fail() { # file message
echo "::error file=$1::$2"
failed=1
}

# Print every link target in a Markdown file, one per line, without its #fragment.
link_targets() {
awk '
/^[ \t]*(```|~~~)/ { fence = !fence; next }
fence { next }
{
line = $0
if (match(line, /^ ? ? ?\[[^]]+\]:[ \t]*[^ \t]+/)) {
target = substr(line, RSTART, RLENGTH)
sub(/^ ? ? ?\[[^]]+\]:[ \t]*/, "", target)
sub(/#.*$/, "", target)
if (target != "") print target
}
while (match(line, /\]\([^) \t]+/)) {
target = substr(line, RSTART + 2, RLENGTH - 2)
line = substr(line, RSTART + RLENGTH)
sub(/#.*$/, "", target)
if (target != "") print target
}
}
' "$1"
}

# Count characters, not bytes: every UTF-8 character has exactly one non-continuation byte.
character_count() {
printf '%s' "$1" | LC_ALL=C tr -d '\200-\277' | wc -c | tr -d '[:space:]'
}

check_description() { # skill-dir
local dir=$1 skill=$1/SKILL.md tag description length ceiling
tag=$(yq --front-matter=extract '.description | tag' "$skill" 2> /dev/null) || tag=''
if [ "$tag" != '!!str' ]; then
fail "$skill" "the description could not be read as text, so its length is unknown"
return 0
fi
description=$(yq --front-matter=extract '.description' "$skill")
length=$(character_count "$description")
printf ' %4s characters %s\n' "$length" "$dir"
ceiling=$(awk -F '\t' -v skill="$dir" '$1 == skill { print $2 }' "$work/description-exceptions")
if [ -n "$ceiling" ]; then
printf '%s\n' "$dir" >> "$work/description-exceptions-used"
if [ "$length" -le "$limit" ]; then
fail "$exceptions" "$dir: the description is now $length characters, within the limit of $limit; remove its exception"
elif [ "$length" -gt "$ceiling" ]; then
fail "$skill" "the description is $length characters; its recorded exception allows $ceiling. Shorten it"
elif [ "$length" -lt "$ceiling" ]; then
fail "$exceptions" "$dir: the description is now $length characters; lower its recorded ceiling from $ceiling to $length"
fi
elif [ "$length" -gt "$limit" ]; then
fail "$skill" "the description is $length characters; the limit for a first-party skill is $limit. Shorten it, or record an exception with its reason in $exceptions"
fi
}

check_references() { # skill-dir
local dir=$1 refs=$1/references entry rel name document
if [ ! -e "$refs" ] && [ ! -L "$refs" ]; then return 0; fi
if [ -L "$refs" ] || [ ! -d "$refs" ]; then
fail "$refs" 'references must be a real directory'
return 0
fi
link_targets "$dir/SKILL.md" | sed 's|^\./||' > "$work/entry-links"
: > "$work/reference-links"
if ! find "$refs" -mindepth 1 -maxdepth 1 -type f -name '*.md' -print0 > "$work/documents" ||
! find "$refs" -mindepth 1 -print0 > "$work/entries-unsorted" ||
! LC_ALL=C sort -z "$work/entries-unsorted" > "$work/entries"; then
fail "$refs" 'the references folder could not be read, so nothing in it was checked'
return 0
fi
while IFS= read -r -d '' document; do
link_targets "$document" | sed 's|^\./||' >> "$work/reference-links"
done < "$work/documents"
while IFS= read -r -d '' entry; do
rel=${entry#"$dir"/}
if [ -L "$entry" ]; then
fail "$entry" 'a reference must be a regular file, not a link'
continue
fi
[ ! -d "$entry" ] || continue
if [ ! -f "$entry" ]; then
fail "$entry" 'a reference must be a regular file'
continue
fi
name=${rel#references/}
case $name in
*/*)
fail "$entry" "references sit one level deep: move this file directly into $refs"
continue
;;
esac
if grep -Fxq -- "$rel" "$work/entry-links"; then
if grep -Fxq -- "$entry" "$work/reference-exceptions"; then
printf '%s\n' "$entry" >> "$work/reference-exceptions-used"
fail "$exceptions" "$entry is now linked from SKILL.md; remove its exception"
fi
continue
fi
case $name in
*.md) ;;
*)
# A worked example belongs to the document that explains it.
if grep -Fxq -- "$name" "$work/reference-links"; then continue; fi
fail "$entry" "this file is not linked from SKILL.md or from a Markdown file in $refs"
continue
;;
esac
if grep -Fxq -- "$entry" "$work/reference-exceptions"; then
printf '%s\n' "$entry" >> "$work/reference-exceptions-used"
continue
fi
fail "$entry" "this reference is not linked from SKILL.md. Link it there, so a reader of the entry file can find it"
done < "$work/entries"
}

if ! find plugins -type f -path '*/skills/*/SKILL.md' -print0 > "$work/skills-unsorted" ||
! LC_ALL=C sort -z "$work/skills-unsorted" > "$work/skills"; then
unusable 'the skills under plugins could not be listed'
fi
[ -s "$work/skills" ] || unusable 'no skills found under plugins: run from the repository root'

echo "First-party skill descriptions (limit: $limit characters):"
first_party=0
while IFS= read -r -d '' skill; do
if owner=$(frontmatter_repository < "$skill") && ! frontmatter_has_value "$skill" github-repo; then
[ -z "$owner" ] || continue
else
fail "$skill" 'the skill cannot be classified as first-party or external (validate-manifests.sh reports why)'
continue
fi
first_party=$((first_party + 1))
dir=${skill%/SKILL.md}
check_description "$dir"
check_references "$dir"
done < "$work/skills"

# An exception that matched nothing protects nothing: it names a skill or file that is gone.
while IFS=$'\t' read -r skill _; do
[ -n "$skill" ] || continue
grep -Fxq -- "$skill" "$work/description-exceptions-used" ||
fail "$exceptions" "$skill: the description exception names no first-party skill; remove it"
done < "$work/description-exceptions"
while IFS= read -r file; do
[ -n "$file" ] || continue
grep -Fxq -- "$file" "$work/reference-exceptions-used" ||
fail "$exceptions" "$file: the reference exception names no reference of a first-party skill; remove it"
done < "$work/reference-exceptions"

if [ "$failed" -ne 0 ]; then
echo "✗ first-party skill authoring rules: see the errors above" >&2
exit 1
fi
echo "✓ $first_party first-party skills follow the authoring rules"
Loading
Loading