diff --git a/.github/check-skill-refs.py b/.github/check-skill-refs.py new file mode 100644 index 0000000..5985d39 --- /dev/null +++ b/.github/check-skill-refs.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Mohamed Hammad +# SPDX-License-Identifier: GPL-3.0-or-later +"""Catch cross-reference drift between the catalogue and the Standard. + +Two checks, both for failures that no existing gate sees. The description cap +and `reuse lint` verify a skill in isolation; nothing verified that a skill +still *agrees* with the catalogue and the Standard around it. + +1. **Skill references resolve.** A skill id named in prose (`` `spacecraft-foo` `` + in Markdown, `@code{spacecraft-foo}` in Texinfo) must exist as a skill + directory. The Standard shipped for months pointing at + `spacecraft-accessibility` when the skill had always been + `spacecraft-accessibility-support`. Nothing complained, because the failure + is silent: a missing skill does not announce itself, it simply never loads, + so work directed at it proceeds without it. + +2. **No document-version pins.** A skill that says "The Steelbore Standard + v1.42 (§11, §12)" is stale the moment any unrelated section changes — and + ambiguous besides, since a reader cannot tell whether the version refers to + the document or to the clause. Three skills carried such pins; two had gone + stale within a single release of being written. Cite the section, and if a + version is genuinely useful, say when that *section* last changed + ("§11 — last amended in v1.39"), which only moves when the section moves. + +Usage: + check-skill-refs.py --catalogue [--no-version-check] ... + +`--catalogue` is the directory holding skill directories (the Construct clone). +`--no-version-check` runs only check 1, for the Standard, whose own masthead +version is legitimate. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +# Namespaces that are unambiguously skill ids. `steelbore-*` is deliberately +# excluded: it is the palette and theme slug namespace (`steelbore-mono`, +# `steelbore-high-contrast`, `tokyonight-color-palette`), and those are values, +# not directories. The one real skill in that namespace, +# `steelbore-color-palette`, resolves anyway if it is ever referenced. +SKILL_NAMESPACES = ("spacecraft", "microsoft", "gnu") +_NS = "|".join(SKILL_NAMESPACES) + +MARKDOWN_REF = re.compile(rf"`((?:{_NS})-[a-z0-9][a-z0-9-]*)`") +TEXINFO_REF = re.compile(rf"@code\{{((?:{_NS})-[a-z0-9][a-z0-9-]*)\}}") + +# "Standard v1.42", "Standard (§11, §12), v1.39", "current as of Standard v1.42". +# Bounded to one clause so it cannot leap across sentences into an unrelated +# version number. The negative lookahead excludes three-component versions: +# The Steelbore Standard numbers itself v1.NN, while the Dual-Mode CLI Standard +# is a different document versioned v1.0.0 and legitimately pinned as such. +VERSION_PIN = re.compile(r"Standard\b[^.\n]{0,60}?\bv1\.\d+(?!\.\d)", re.IGNORECASE) + +# Phrases that make a version a *clause* pin — when this section last changed — +# rather than a document pin. These are correct and must not be flagged. +CLAUSE_PIN = re.compile( + r"last amended|introduced (in|at)|added (in|at)|retired (at|in)|reinstated" + r"|un-retired|predat|superseded (in|at)|since v1\.|grandfathered|shipped v1\.", + re.IGNORECASE, +) + +# Opt out on a single line when neither rule fits. Keep it rare and explain why. +SUPPRESS = "skill-refs: allow" + +# Never walked: vendored upstream (§4.2 forbids local edits), the holding pen, +# and generated trees that mirror a source elsewhere. +SKIP_DIRS = {".git", ".claude", "android-skills", "Excluded", "node_modules", "target"} + +# Names inside a skill namespace that are not skills. `spacecraft-software` is +# the umbrella workspace and org, and appears in paths and prose. +NON_SKILL_NAMES = {"spacecraft-software"} + +# Paths whose document-version pin is legitimate: the skill that encodes the +# Standard carries its version by design, and changelogs are history. +VERSION_EXEMPT_PARTS = ("spacecraft-standard-constitution",) +VERSION_EXEMPT_NAMES = ("CHANGELOG.md",) + +# Changelogs also name things that have since been renamed or removed — that is +# what a history is for — so reference resolution is skipped there as well. +REF_EXEMPT_NAMES = ("CHANGELOG.md",) + + +def catalogue_skills(root: pathlib.Path) -> set[str]: + """Every directory that is a skill, i.e. contains a SKILL.md. + + Covers top-level skills and the flat Grok variants one level down, which is + where the two bundle layouts diverge. + """ + found: set[str] = set() + for skill_md in root.glob("*/SKILL.md"): + found.add(skill_md.parent.name) + for skill_md in root.glob("*/*/SKILL.md"): + if skill_md.parent.parent.name not in SKIP_DIRS: + found.add(skill_md.parent.name) + return found + + +def iter_files(paths: list[str]): + for raw in paths: + p = pathlib.Path(raw) + if p.is_dir(): + for child in sorted(p.rglob("*")): + if child.is_file() and child.suffix in {".md", ".texi"}: + if not set(child.parts) & SKIP_DIRS: + yield child + elif p.is_file(): + yield p + + +def version_exempt(path: pathlib.Path) -> bool: + return path.name in VERSION_EXEMPT_NAMES or any( + part in VERSION_EXEMPT_PARTS for part in path.parts + ) + + +def check(path: pathlib.Path, skills: set[str], check_versions: bool) -> list[str]: + problems: list[str] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError) as exc: + return [f"{path}: unreadable ({exc})"] + + pattern = TEXINFO_REF if path.suffix == ".texi" else MARKDOWN_REF + exempt = version_exempt(path) + + for n, line in enumerate(lines, 1): + if SUPPRESS in line: + continue + + for name in pattern.findall(line): + if path.name in REF_EXEMPT_NAMES or name in NON_SKILL_NAMES: + continue + if name not in skills: + near = min( + (s for s in skills if s.startswith(name) or name.startswith(s)), + key=len, + default=None, + ) + hint = f" (did you mean `{near}`?)" if near else "" + problems.append( + f"{path}:{n}: references `{name}`, which is not a skill{hint}" + ) + + if check_versions and not exempt: + hit = VERSION_PIN.search(line) + if hit and not CLAUSE_PIN.search(line): + problems.append( + f"{path}:{n}: pins a document version ({hit.group(0).strip()!r}) " + f"— cite the section instead, or say when that section last changed" + ) + + return problems + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--catalogue", default=".", help="directory holding skill dirs") + ap.add_argument( + "--no-version-check", + action="store_true", + help="only verify that skill references resolve", + ) + ap.add_argument("paths", nargs="+") + args = ap.parse_args(argv) + + root = pathlib.Path(args.catalogue) + skills = catalogue_skills(root) + if not skills: + print( + f"error: no skills found under {root} — is --catalogue pointing at " + f"the Construct clone?", + file=sys.stderr, + ) + return 2 + + problems: list[str] = [] + for path in iter_files(args.paths): + problems.extend(check(path, skills, not args.no_version_check)) + + if problems: + for p in problems: + print(p, file=sys.stderr) + print( + f"\n{len(problems)} cross-reference problem(s). " + f"Catalogue has {len(skills)} skills.", + file=sys.stderr, + ) + return 1 + + print(f"skill references OK ({len(skills)} skills in catalogue)") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4590e2..d0af27a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,14 @@ jobs: find . -name SKILL.md -not -path './.git/*' -print0 \ | xargs -0 python3 .githooks/check-description-length.py + # The gates above verify each skill in isolation. This one verifies that a + # skill still agrees with the catalogue around it: that every skill id it + # names resolves to a real directory, and that it does not pin a Standard + # *document* version (which rots on any unrelated release — cite the + # section, or say when that section last changed). + - name: Skill cross-references + version pins + run: python3 .github/check-skill-refs.py --catalogue . . + # construct-cli/ is the one real build surface in this repo — everything else # is markdown. It went ungated until now, which is how a clippy break # (items_after_test_module in src/sources/skillmd.rs) sat unnoticed on main: diff --git a/README.md b/README.md index 4bf9423..b6a7143 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ the rules re-attached to every prompt. | [`spacecraft-theme-factory`](spacecraft-theme-factory/) | Generates Spacecraft Software-compliant themes for IDEs and terminals. | | [`spacecraft-typescript-guidelines`](spacecraft-typescript-guidelines/) | Type-safe highly-concurrent TypeScript guidance (targeting TypeScript 7.0+) — Go native compiler optimizations, Project References (`composite`/`incremental`), strict type checking, non-blocking asynchronous event loops, CPU-parallel worker pools (`Piscina`), V8 engine tuning (hidden classes), and Zod data validation boundaries. | | [`spacecraft-zig-guidelines`](spacecraft-zig-guidelines/) | Memory-safe high-performance concurrent Zig guidance — `std.Thread.Pool` / `std.Io.Threaded`, atomics, allocator discipline, comptime safety, and CPU-bound scaling patterns. | -| [`steelbore-color-palette`](steelbore-color-palette/) | Single source of truth for the Steelbore palette family (Standard §11 v1.35) — six palettes (Modern the default, Classic, Blue, BlackPinkPanther, MatrixGreen, NavyWhite), 13 themes with per-background WCAG matrices, the §11.1 role-token contract, the §11.1.1 accessibility variants, and the shipped `assets/steelbore.toml` + `assets/spacecraft.css`. Consumer skills defer here for color values. | +| [`steelbore-color-palette`](steelbore-color-palette/) | Single source of truth for the Steelbore palette family (Standard §11, last amended v1.39) — nine palettes (Modern the default, plus Classic, Blue, BlackPinkPanther, MatrixGreen, NavyWhite, Tokyo Night, and the two §11.5 fidelity palettes Solarized Dark/Light), 17 themes with per-background WCAG matrices, the §11.1 role-token contract, the §11.1.1 accessibility variants, and the shipped `assets/steelbore.toml` + `assets/spacecraft.css`. Consumer skills defer here for color values. | ## Directory layout diff --git a/spacecraft-agentic-cli.skill b/spacecraft-agentic-cli.skill index 6a4ac91..ba6a00f 100644 Binary files a/spacecraft-agentic-cli.skill and b/spacecraft-agentic-cli.skill differ diff --git a/spacecraft-agentic-cli.zip b/spacecraft-agentic-cli.zip index 732aa97..70d65e1 100644 Binary files a/spacecraft-agentic-cli.zip and b/spacecraft-agentic-cli.zip differ diff --git a/spacecraft-agentic-cli/SKILL.md b/spacecraft-agentic-cli/SKILL.md index 6f11518..89b74f5 100644 --- a/spacecraft-agentic-cli/SKILL.md +++ b/spacecraft-agentic-cli/SKILL.md @@ -380,7 +380,7 @@ This skill is the **agent-UX layer** in the Spacecraft Software CLI skill stack: - **`microsoft-rust-guidelines`** — Microsoft Pragmatic Rust Guidelines. Consult when writing Rust. - **`spacecraft-brand-guidelines`** — application rules for the nine-token - Steelbore 2 palette (Standard §11 v1.34); hex values are read from the + Steelbore 2 palette (Standard §11); hex values are read from the `steelbore-color-palette` skill. The three CLI/shell/provisioning siblings were all retargeted at the local diff --git a/steelbore-color-palette.skill b/steelbore-color-palette.skill index fe6d371..0b71fbb 100644 Binary files a/steelbore-color-palette.skill and b/steelbore-color-palette.skill differ diff --git a/steelbore-color-palette.zip b/steelbore-color-palette.zip index f08f3cf..b75172d 100644 Binary files a/steelbore-color-palette.zip and b/steelbore-color-palette.zip differ diff --git a/steelbore-color-palette/assets/steelbore.toml b/steelbore-color-palette/assets/steelbore.toml index c3f6f35..2bd82e3 100644 --- a/steelbore-color-palette/assets/steelbore.toml +++ b/steelbore-color-palette/assets/steelbore.toml @@ -1,10 +1,13 @@ # SPDX-FileCopyrightText: 2026 Mohamed Hammad # SPDX-License-Identifier: GPL-3.0-or-later # -# Steelbore palette family — canonical color contract (Standard §11, v1.35) -# Six palettes: steelbore (Modern, DEFAULT), steelbore-classic, and four -# alternates. Every palette binds the same eleven §11.1 role tokens except -# Classic, which keeps its legacy six-role contract (§11.2). +# Steelbore palette family — canonical color contract (Standard §11, last +# amended v1.39). Nine palettes and 17 themes: steelbore (Modern, DEFAULT), +# steelbore-classic, five alternates (blue, blackpinkpanther, matrixgreen, +# navywhite, tokyonight), and the two §11.5 fidelity palettes (solarized-dark, +# solarized-light) which are registered but non-conforming. Every palette binds +# the same eleven §11.1 role tokens except Classic, which keeps its legacy +# six-role contract (§11.2). # All contrast ratios computed with the WCAG 2.2 relative-luminance formula. # Normative targets: WCAG 2.2 Level AA; EN 301 549 V4.1.1 clause 11 (non-web software). # A project uses ONE palette (§11.4); tokens are never mixed across palettes.