Browse Source

feat(skills): Add staleness verifier to tailwind-ops

Adds the §7 staleness-verifier split (offline structural / live npm) to the
tailwind-ops skill, cloned from the threejs-ops / r-ops house pattern.

Files:
- assets/tailwind-facts.json — catalogs the Tailwind v4 facts the skill encodes:
  tailwindcss at major 4, the v4 CSS-first directive gates (@theme, @plugin,
  @config, @import "tailwindcss", the "v4" marker), and the named package set
  (@tailwindcss/postcss, @tailwindcss/vite, @tailwindcss/upgrade,
  @tailwindcss/typography, @tailwindcss/forms, @tailwindcss/container-queries).
- scripts/check-tailwind-facts.py — stdlib-only Python 3.
    --offline: every catalogued package + v4 directive gate is still named in the
               skill prose, and SKILL.md carries a dated 'as of <year>' note.
               Exit 0 clean / 10 drift.
    --live:    queries registry.npmjs.org/<pkg>/latest; exit 10 if the live
               major is newer than the documented major (or the package is gone),
               7 if the registry is unreachable. Never modifies files.
- SKILL.md — adds the "> Tailwind v4 ecosystem facts verified as of 2026-07."
  currency note near the top and a Staleness Verifier section citing the script.
- tests/run.sh — offline self-test; exits nonzero if --offline fails.

Results:
- --offline: PASS (exit 0; 7 packages + 5 gates, 0 inconsistencies)
- --live: PASS (exit 0; all packages at or below documented major)
- tests/run.sh: 15/15 pass

No drift: tailwindcss (and the @tailwindcss/* v4 packages) are still on major 4
as of 2026-07, so the prose's v4 stance is current. The typography/forms/
container-queries plugins remain on 0.x as catalogued.

Co-Authored-By: Claude <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
346c25f0c5

+ 21 - 0
skills/tailwind-ops/SKILL.md

@@ -12,6 +12,8 @@ metadata:
 
 Comprehensive Tailwind CSS patterns covering layout, responsive design, components, dark mode, animations, and v4 migration.
 
+> Tailwind v4 ecosystem facts verified as of 2026-07.
+
 ## Layout Decision Tree
 
 ```
@@ -467,6 +469,25 @@ dialog[open] {
 | `references/v4-migration.md` | CSS-first config, @theme, @plugin, removed utilities, container queries, @starting-style, migration steps, breaking changes | ~500 |
 | `references/configuration.md` | Theme config (v3+v4), colors, spacing, typography, plugins, @layer, @apply, custom variants, dark mode, container queries | ~500 |
 
+## Staleness Verifier
+
+This skill encodes fast-moving facts (the Tailwind v4 CSS-first directives, the
+`@tailwindcss/*` package set, the v3→v4 migration). [`scripts/check-tailwind-facts.py`](scripts/check-tailwind-facts.py)
+guards them against silent drift — internal consistency in PR CI, live
+major-version drift in the scheduled freshness job:
+
+```bash
+# Structural (PR CI, no network): every catalogued package + v4 directive gate
+# is still named in this skill's prose, and the currency note carries a year.
+python3 skills/tailwind-ops/scripts/check-tailwind-facts.py --offline        # exit 0 consistent, 10 drift
+
+# Live (weekly freshness job, never blocks a PR): is any documented major now
+# behind npm's latest dist-tag? (e.g. tailwindcss 5 while the prose says v4.)
+python3 skills/tailwind-ops/scripts/check-tailwind-facts.py --live           # exit 10 a major moved ahead, 7 npm unreachable
+```
+
+The canonical fact list lives in [`assets/tailwind-facts.json`](assets/tailwind-facts.json); when you add or drop a package or the prose stops naming one, update it to match or `--offline` fails CI.
+
 ## See Also
 
 - `react-ops` - React component patterns using Tailwind

+ 23 - 0
skills/tailwind-ops/assets/tailwind-facts.json

@@ -0,0 +1,23 @@
+{
+  "_comment": "Canonical fast-moving facts the tailwind-ops skill encodes. scripts/check-tailwind-facts.py asserts SKILL.md + references name these consistently (--offline) and probes the npm registry for major-version drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping. documented_major is the major the skill's prose commits to (tailwindcss 4) or the current tracked major for ecosystem packages the prose names without pinning a version.",
+  "schema": "claude-mods.tailwind-ops.facts/v1",
+  "as_of": "2026-07-05",
+  "tailwind_major": 4,
+  "version_gates": {
+    "_comment": "Tailwind v4 CSS-first directives the skill centers on. --offline asserts each token is named in SKILL.md/references prose; a missing token means the prose stopped teaching a v4 feature it claims.",
+    "css_first_theme": "@theme",
+    "plugin_directive": "@plugin",
+    "config_directive": "@config",
+    "v4_import": "@import \"tailwindcss\"",
+    "v4_major": "v4"
+  },
+  "packages": {
+    "tailwindcss":                     { "documented_major": 4, "prose": ["tailwindcss"],                    "role": "core (v4 CSS-first engine)" },
+    "@tailwindcss/postcss":            { "documented_major": 4, "prose": ["@tailwindcss/postcss"],           "role": "PostCSS plugin (v4)" },
+    "@tailwindcss/vite":               { "documented_major": 4, "prose": ["@tailwindcss/vite"],              "role": "Vite plugin (v4, faster)" },
+    "@tailwindcss/upgrade":            { "documented_major": 4, "prose": ["@tailwindcss/upgrade"],           "role": "v3->v4 codemod CLI" },
+    "@tailwindcss/typography":         { "documented_major": 0, "prose": ["@tailwindcss/typography"],        "role": "prose content plugin" },
+    "@tailwindcss/forms":              { "documented_major": 0, "prose": ["@tailwindcss/forms"],             "role": "form reset plugin" },
+    "@tailwindcss/container-queries":  { "documented_major": 0, "prose": ["@tailwindcss/container-queries"], "role": "container-queries plugin (v3; native in v4)" }
+  }
+}

+ 250 - 0
skills/tailwind-ops/scripts/check-tailwind-facts.py

@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+"""Staleness verifier for tailwind-ops: the Tailwind v4 facts the skill encodes
+must stay real and named in the prose.
+
+tailwind-ops centers on Tailwind CSS v4 (CSS-first config: @theme, @plugin,
+@config, @import "tailwindcss"; @tailwindcss/postcss + @tailwindcss/vite) and
+the v3->v4 migration. That is exactly the fact that drifts silently
+(SKILL-RESOURCE-PROTOCOL.md §7): Tailwind ships a new major, a v4 directive is
+renamed, or the prose stops mentioning a package the catalog lists, and nobody
+notices for months. Two modes guard it:
+
+  --offline (default, safe for PR CI): structural consistency, no network.
+    * assets/tailwind-facts.json parses and every package + v4 directive gate is
+      named somewhere in the skill prose (SKILL.md / references/*.md) — the
+      catalog can't drift from the docs
+    * SKILL.md still carries a dated "as of <year>" currency note
+  --live (scheduled freshness.yml, never a PR gate): query the npm registry for
+    each package's latest dist-tag; flag DRIFT when the live major is newer than
+    the documented major (the skill is now behind — e.g. tailwindcss 5 while the
+    prose still says v4), or when a package is gone (404). Transient registry
+    failure is UNAVAILABLE (exit 7), never a failure.
+
+Usage:   check-tailwind-facts.py [--offline | --live] [--facts FILE] [--skill DIR] [--json] [--timeout S]
+Input:   argv flags only (no stdin).
+Output:  stdout = findings (plain rows, or a --json envelope). Data only.
+Stderr:  the verdict line, notices, errors.
+Exit:    0 ok, 2 usage, 3 facts/skill missing, 4 facts unparseable,
+         7 npm registry unreachable (live, advisory — never a real failure),
+         10 drift (offline: uncited/undocumented/missing note; live: major ahead or gone)
+
+Examples:
+  check-tailwind-facts.py --offline                 # PR CI: catalog ⇆ prose consistency
+  check-tailwind-facts.py --live                    # weekly: is any documented major behind npm?
+  check-tailwind-facts.py --offline --json | jq '.data[]'
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+EX_OK = 0
+EX_USAGE = 2
+EX_NOTFOUND = 3
+EX_UNPARSEABLE = 4
+EX_UNAVAILABLE = 7
+EX_DRIFT = 10
+
+SCHEMA = "claude-mods.tailwind-ops.facts/v1"
+HERE = Path(__file__).resolve().parent
+DEFAULT_FACTS = HERE.parent / "assets" / "tailwind-facts.json"
+DEFAULT_SKILL = HERE.parent
+REGISTRY = "https://registry.npmjs.org"
+CURRENCY_RE = re.compile(r"as of 20\d\d")
+
+
+class Term:
+    """Minimal ANSI helper. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the
+    bound stream's TTY + encoding so piped data stays plain ASCII."""
+
+    _C = {"green": "\033[32m", "red": "\033[31m", "dim": "\033[2m", "off": "\033[0m"}
+
+    def __init__(self, stream=sys.stderr):
+        enc = (getattr(stream, "encoding", "") or "").lower()
+        self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc
+        if os.environ.get("FORCE_COLOR"):
+            self.color = True
+        elif (os.environ.get("NO_COLOR") is not None
+              or os.environ.get("TERM") == "dumb"
+              or not getattr(stream, "isatty", lambda: False)()):
+            self.color = False
+        else:
+            self.color = True
+
+    def c(self, name, text):
+        return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text
+
+    def mark(self, ok):
+        g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
+        return self.c("green" if ok else "red", g)
+
+
+def load_facts(path: Path) -> dict:
+    if not path.is_file():
+        print(f"error: facts catalog not found: {path}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+        if data.get("schema") != SCHEMA:
+            raise ValueError(f"schema {data.get('schema')!r} != {SCHEMA!r}")
+        if not isinstance(data.get("packages"), dict) or not data["packages"]:
+            raise ValueError("'packages' must be a non-empty object")
+        for name, info in data["packages"].items():
+            if not isinstance(info, dict) or "documented_major" not in info:
+                raise ValueError(f"package {name!r} missing documented_major")
+            if not isinstance(info.get("prose"), list) or not info["prose"]:
+                raise ValueError(f"package {name!r} missing prose tokens")
+        return data
+    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
+        print(f"error: could not parse facts {path}: {exc}", file=sys.stderr)
+        raise SystemExit(EX_UNPARSEABLE)
+
+
+def read_corpus(skill_dir: Path) -> tuple[str, str]:
+    """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md."""
+    doc = skill_dir / "SKILL.md"
+    if not doc.is_file():
+        print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    skill_md = doc.read_text(encoding="utf-8", errors="replace")
+    parts = [skill_md]
+    for ref in sorted((skill_dir / "references").glob("*.md")):
+        parts.append(ref.read_text(encoding="utf-8", errors="replace"))
+    return skill_md, "\n".join(parts)
+
+
+def check_offline(facts: dict, skill_dir: Path) -> list[dict]:
+    skill_md, corpus = read_corpus(skill_dir)
+    findings: list[dict] = []
+    for name, info in facts["packages"].items():
+        for token in info["prose"]:
+            if token not in corpus:
+                findings.append({"package": name, "issue": f"prose token {token!r} not named in skill"})
+    for key, token in facts.get("version_gates", {}).items():
+        if key == "_comment":
+            continue
+        if str(token) not in corpus:
+            findings.append({"package": "(gate)", "issue": f"version gate {key}={token!r} not stated in skill prose"})
+    if not CURRENCY_RE.search(skill_md):
+        findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of <year>' currency note"})
+    return findings
+
+
+def npm_latest(name: str, timeout: float) -> tuple[str, object]:
+    """Return (resolved|notfound|unavailable, version-string-or-status)."""
+    url = f"{REGISTRY}/{urllib.parse.quote(name, safe='')}/latest"
+    req = urllib.request.Request(url, method="GET",
+                                 headers={"User-Agent": "claude-mods-tailwind-ops-check/1",
+                                          "Accept": "application/json"})
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            manifest = json.loads(resp.read().decode("utf-8"))
+            return ("resolved", manifest.get("version", ""))
+    except urllib.error.HTTPError as exc:
+        if exc.code in (404, 410):
+            return ("notfound", exc.code)
+        return ("unavailable", exc.code)
+    except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
+        return ("unavailable", str(getattr(exc, "reason", exc)))
+
+
+def major_of(version: str) -> int | None:
+    m = re.match(r"\d+", version.strip())
+    return int(m.group(0)) if m else None
+
+
+def check_live(facts: dict, timeout: float) -> tuple[list[dict], list[dict]]:
+    drift: list[dict] = []
+    unreachable: list[dict] = []
+    for name, info in facts["packages"].items():
+        documented = info["documented_major"]
+        status, info2 = npm_latest(name, timeout)
+        if status == "notfound":
+            drift.append({"package": name, "issue": "no longer resolves on npm (404) — renamed/removed"})
+        elif status == "unavailable":
+            unreachable.append({"package": name, "issue": f"registry unreachable: {info2}"})
+        else:
+            live = major_of(str(info2))
+            if live is None:
+                unreachable.append({"package": name, "issue": f"could not parse version {info2!r}"})
+            elif live > documented:
+                drift.append({"package": name,
+                              "issue": f"live major {live} ({info2}) ahead of documented major {documented}"})
+    return drift, unreachable
+
+
+def main(argv: list[str]) -> int:
+    p = argparse.ArgumentParser(
+        prog="check-tailwind-facts.py",
+        description="Verify tailwind-ops' Tailwind v4 facts stay named (offline) and current on npm (live).",
+    )
+    mode = p.add_mutually_exclusive_group()
+    mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
+    mode.add_argument("--live", action="store_true", help="check each package's npm major vs documented")
+    p.add_argument("--facts", default=str(DEFAULT_FACTS), help="facts catalog JSON")
+    p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
+    p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
+    p.add_argument("--json", action="store_true", help="emit a JSON envelope")
+    try:
+        args = p.parse_args(argv)
+    except SystemExit as exc:
+        return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
+
+    facts = load_facts(Path(args.facts))
+    live = args.live and not args.offline
+    t = Term(sys.stderr)
+
+    if live:
+        drift, unreachable = check_live(facts, args.timeout)
+        findings = drift + unreachable
+        if args.json:
+            print(json.dumps({
+                "data": findings,
+                "meta": {"mode": "live", "packages_checked": len(facts["packages"]),
+                         "drift": len(drift), "unreachable": len(unreachable),
+                         "registry": REGISTRY, "schema": SCHEMA},
+            }, indent=2))
+        else:
+            for f in findings:
+                kind = "DRIFT" if f in drift else "UNREACH"
+                print(f"{kind}  {f['package']}: {f['issue']}")
+        if drift:
+            print(f"{t.mark(False)} tailwind-facts/live: {len(drift)} package(s) drifted "
+                  f"{t.c('dim', '(' + REGISTRY + ')')}", file=sys.stderr)
+            return EX_DRIFT
+        if unreachable:
+            print(f"{t.mark(False)} tailwind-facts/live: npm unreachable for "
+                  f"{len(unreachable)}/{len(facts['packages'])} {t.c('dim', '(advisory - retry next run)')}",
+                  file=sys.stderr)
+            return EX_UNAVAILABLE
+        print(f"{t.mark(True)} tailwind-facts/live: all {len(facts['packages'])} package(s) "
+              f"at or below documented major", file=sys.stderr)
+        return EX_OK
+
+    # offline (default)
+    findings = check_offline(facts, Path(args.skill))
+    if args.json:
+        print(json.dumps({
+            "data": findings,
+            "meta": {"mode": "offline", "packages_checked": len(facts["packages"]),
+                     "drift": len(findings), "consistent": not findings, "schema": SCHEMA},
+        }, indent=2))
+    else:
+        for f in findings:
+            print(f"DRIFT  {f['package']}: {f['issue']}")
+    ok = not findings
+    print(f"{t.mark(ok)} tailwind-facts/offline: {len(facts['packages'])} package(s) + "
+          f"{sum(1 for k in facts.get('version_gates', {}) if k != '_comment')} gate(s) checked, "
+          f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}", file=sys.stderr)
+    return EX_DRIFT if findings else EX_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 66 - 0
skills/tailwind-ops/tests/run.sh

@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Offline self-test for the tailwind-ops skill — structure, frontmatter, and the
+# staleness-verifier contract (SKILL-RESOURCE-PROTOCOL.md §7, §10).
+#
+# Offline-deterministic (no network, no Tailwind install). Resolves paths
+# relative to itself so it works in the repo and once installed to ~/.claude/.
+#
+# Usage:   bash tests/run.sh
+# Input:   none (self-contained; no network)
+# Output:  TAP-ish progress on stderr; final PASS/FAIL line.
+# Exit:    0 all pass, 1 any failure
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+DOC="$SKILL/SKILL.md"
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1" >&2; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1" >&2; }
+
+# Resolve a *working* python (python3, else python). The bare `command -v` is
+# not enough on Windows, where `python3` is a Microsoft Store stub that exits
+# nonzero. Skip the whole verifier block if none works.
+PY=""
+for c in python3 python py; do
+  if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PY="$c"; break; fi
+done
+
+echo "=== tailwind-ops self-test ===" >&2
+
+# ── SKILL.md frontmatter ───────────────────────────────────────────────────
+[[ -f "$DOC" ]] && ok "SKILL.md present" || { no "SKILL.md missing"; echo "=== $PASS passed, $FAIL failed ===" >&2; exit 1; }
+doc="$(cat "$DOC")"
+case "$doc" in *"name: tailwind-ops"*) ok "frontmatter declares name: tailwind-ops";; *) no "frontmatter name != tailwind-ops";; esac
+case "$doc" in *"license: MIT"*) ok "frontmatter declares license: MIT";; *) no "missing license: MIT";; esac
+case "$doc" in *"as of 20"*) ok "currency note carries a year";; *) no "no dated 'as of <year>' currency note";; esac
+
+# ── resources present + cited ──────────────────────────────────────────────
+for res in assets/tailwind-facts.json scripts/check-tailwind-facts.py; do
+  [[ -f "$SKILL/$res" ]] && ok "resource present: $res" || no "missing resource: $res"
+done
+case "$doc" in *"scripts/check-tailwind-facts.py"*) ok "verifier cited from SKILL.md";; *) no "verifier uncited";; esac
+
+# ── staleness verifier: offline contract (§7) ───────────────────────────────
+if [[ -n "$PY" ]]; then
+  V="$SKILL/scripts/check-tailwind-facts.py"
+  ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$?
+         [[ "$got" == "$want" ]] && ok "$lbl (exit $got)" || no "$lbl (want $want got $got)"; }
+  TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+  ec 0 "py_compile"            "$PY" -m py_compile "$V"
+  ec 0 "--help"                "$PY" "$V" --help
+  ec 0 "--offline consistent"  "$PY" "$V" --offline
+  ec 2 "bad flag -> 2"         "$PY" "$V" --bogus
+  ec 2 "conflicting modes -> 2" "$PY" "$V" --offline --live
+  jout="$("$PY" "$V" --offline --json 2>/dev/null)"
+  case "$jout" in *"claude-mods.tailwind-ops.facts/v1"*) ok "--json envelope schema";; *) no "--json envelope schema missing";; esac
+  ec 3 "missing facts -> 3"    "$PY" "$V" --offline --facts "$TMP/nope.json"
+  printf '{"schema":"claude-mods.tailwind-ops.facts/v1","packages":{"zzz":{"documented_major":1,"prose":["zzznotreal"]}}}' > "$TMP/drift.json"
+  ec 10 "uncited package -> 10" "$PY" "$V" --offline --facts "$TMP/drift.json"
+else
+  no "no working python to exercise the verifier"
+fi
+
+echo "=== $PASS passed, $FAIL failed ===" >&2
+[[ "$FAIL" -eq 0 ]] || exit 1