#!/usr/bin/env bash # check-skill-frontmatter.sh — fail when an image-baked skill's frontmatter # violates the Agent Skills spec that pi's own loader measures against. # # THE DEFECT THIS EXISTS TO CATCH, measured 2026-10-23 while adding the # pi-devbox-firstboot skill. Its `description` was drafted at 1056 characters. # pi's shipped loader (dist/core/skills.js) defines MAX_DESCRIPTION_LENGTH = # 1024 and pushes a diagnostic when a description is longer — and then says, in # a comment directly above the return: # # // Still load the skill even with warnings, unless description is # // missing or empty. # # So an over-long description does NOT break the skill. That is exactly why it # needs a gate rather than trust: the skill works, the violation is real, and # the only thing that would ever tell you is a diagnostic emitted inside a pi # session. Nothing in CI starts a pi session and reads skill diagnostics, so the # warning is invisible here by construction. A spec violation that still works # is the most durable kind of rot — nothing ever forces the fix. # # One failure in this family is NOT a warning but a silent drop: a missing or # empty description returns `{ skill: null }`, so the skill is never offered to # the model at all. The skill file ships, `test -f` passes in smoke-test.sh, the # symlink resolves, `pi-devbox-version` lists it — and the skill does not exist # as far as any agent is concerned. This gate separates the two cases in its # output, because they need different urgency even though both are red. # # WHY THE BAKED TREE SPECIFICALLY. These skills are the FALLBACK layer (see # skills/VENDORED.md): they are what a container resolves with no skillset repo # mounted, which is also the case of a brand-new device that has nothing else. # A skillset-owned skill has its own upstream review; a baked one has only this. # # MEASURED HEADROOM when this gate was added, which is why it is worth having: # credential-incident-response 1022 (2 chars spare) # pi-extensions 1003 (21) # pi-devbox-firstboot 952 (72) # pi-devbox-environment 923 (101) # mempalace 254 (770) # Two of five are within one sentence of the limit, so "nobody would write a # description that long" is already false here. # # A KNOWN COUPLING, stated rather than discovered later: `pi-extensions` and # `mempalace` are VENDORED copies, so an upstream edit that pushes one over the # limit turns this gate red on a file this repo does not author. That is # deliberate and precedented — check-skill-floor.sh already couples to the # pi-extensions package — because the image SHIPS those bytes, so the violation # is ours to carry regardless of who typed it. The fix in that case is to # refresh the snapshot after upstream trims, not to exempt the file. # # WHY NAME IS CHECKED TOO, including against the directory. The loader falls # back to the PARENT DIRECTORY NAME when frontmatter has no usable `name` # (`frontmatterName || parentDirName`), so a mismatch does not fail — it # silently renames the skill, and every pointer written as # `~/.agents/skills//SKILL.md` then refers to a skill the model knows under # a different name. The managed AGENTS.md block points at skills by path, so # that divergence is load-bearing here. # # EXIT CODES (same contract as lint-shell.sh / check-skill-floor.sh / # check-doc-drift.sh — a gate that cannot run must not pass): # 0 every baked skill's frontmatter is valid # 1 VIOLATION — at least one skill breaks the spec # 2 cannot run — no skills directory, or no python3 with a YAML module # # WHY PYTHON AND NOT grep/awk. `description: >-` is a YAML folded block scalar: # its value spans lines and is joined with spaces, so a line-oriented length # check measures the wrong string. python3-yaml is already installed in the # actionlint job and already used by check-doc-drift.sh, so this adds no new # dependency. NB the heredoc form below is `python3 - "$arg"` (script on stdin, # argument on the command line); `echo x | python3 <<'EOF'` cannot work, which # is the SC2259 defect the shellcheck gate was added for. set -euo pipefail REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) SKILLS_DIR="${REPO_ROOT}/rootfs/usr/local/share/pi-devbox/skills" usage() { cat <<'USAGE' Usage: scripts/check-skill-frontmatter.sh [options] Options: --skills-dir DIR Validate DIR instead of the repo's baked skills tree (used by the truth-table self-test). -h, --help This text. Exit: 0 valid | 1 violation | 2 cannot run USAGE } while [ $# -gt 0 ]; do case "$1" in --skills-dir) shift; [ $# -gt 0 ] || { echo "::error::--skills-dir needs a value" >&2; exit 2; }; SKILLS_DIR="$1" ;; -h|--help) usage; exit 0 ;; *) echo "::error::unknown option: $1" >&2; usage >&2; exit 2 ;; esac shift done if [ ! -d "$SKILLS_DIR" ]; then echo "::error::skills directory not found: ${SKILLS_DIR}" echo "A gate that cannot run must not pass — this is a red 2, not a green tick." exit 2 fi if ! command -v python3 >/dev/null 2>&1; then echo "::error::python3 not found; cannot parse YAML frontmatter" exit 2 fi if ! python3 -c 'import yaml' >/dev/null 2>&1; then echo "::error::python3 has no yaml module (apt: python3-yaml); cannot parse frontmatter" exit 2 fi python3 - "$SKILLS_DIR" <<'PY' import os, re, sys import yaml # Mirrors pi's dist/core/skills.js. If pi changes these, change them here. MAX_NAME_LENGTH = 64 MAX_DESCRIPTION_LENGTH = 1024 skills_dir = sys.argv[1] fatal, violations, checked = [], [], 0 for name in sorted(os.listdir(skills_dir)): d = os.path.join(skills_dir, name) if not os.path.isdir(d): continue path = os.path.join(d, "SKILL.md") rel = os.path.relpath(path, skills_dir) if not os.path.isfile(path): fatal.append(f"{rel}: directory has no SKILL.md — the loader cannot see it") continue checked += 1 raw = open(path, encoding="utf-8").read() m = re.match(r"^---\n(.*?)\n---\n", raw, re.S) if not m: fatal.append(f"{rel}: no YAML frontmatter block (must open on line 1 with ---)") continue try: fm = yaml.safe_load(m.group(1)) or {} except Exception as e: fatal.append(f"{rel}: frontmatter is not valid YAML: {e}") continue if not isinstance(fm, dict): fatal.append(f"{rel}: frontmatter is not a mapping") continue desc = fm.get("description") if not isinstance(desc, str) or desc.strip() == "": # The one hard drop: the loader returns skill: null for this. fatal.append(f"{rel}: description missing or empty — THE SKILL IS NEVER LOADED") elif len(desc) > MAX_DESCRIPTION_LENGTH: violations.append( f"{rel}: description is {len(desc)} chars, exceeds {MAX_DESCRIPTION_LENGTH} " f"(loader warns and still loads, so nothing else will tell you)" ) fm_name = fm.get("name") if isinstance(fm.get("name"), str) else None eff = fm_name or name if len(eff) > MAX_NAME_LENGTH: violations.append(f"{rel}: name is {len(eff)} chars, exceeds {MAX_NAME_LENGTH}") if not re.fullmatch(r"[a-z0-9-]+", eff): violations.append(f"{rel}: name {eff!r} must be lowercase a-z, 0-9 and hyphens only") if eff.startswith("-") or eff.endswith("-"): violations.append(f"{rel}: name {eff!r} must not start or end with a hyphen") if "--" in eff: violations.append(f"{rel}: name {eff!r} must not contain a double hyphen") if fm_name is not None and fm_name != name: violations.append( f"{rel}: frontmatter name {fm_name!r} != directory {name!r} — the loader " f"would use {fm_name!r} while every path-based pointer says {name!r}" ) print(f"checked {checked} baked skill(s) in {skills_dir}") if not fatal and not violations: print("OK: every baked skill's frontmatter is valid.") sys.exit(0) for f in fatal: print(f"::error::{f}") for v in violations: print(f"::error::{v}") print(f"FAIL: {len(fatal)} fatal, {len(violations)} spec violation(s).") sys.exit(1) PY