Browse Source

feat: Skill Resource Protocol + 4 staleness verifiers + freshness CI

docs/SKILL-RESOURCE-PROTOCOL.md codifies the build standard for skill
scripts/assets/references (stream separation, semantic exit codes,
--help+EXAMPLES, --json envelopes, agent safety, the resource-scaffold
checklist) adapted from Axiom's tool protocol. Its core contribution is
the staleness-verifier pattern: an --offline structural check that gates
PR CI plus a --live drift check that runs scheduled and never blocks a
PR on a network blip (exit 7 = unavailable/advisory, exit 10 = drift).

Four verifiers built to the protocol:
- claude-api-ops/check-model-table.py: model+pricing/cache table drift
  vs the live Models API
- terraform-ops/check-action-refs.sh: every GitHub Action uses: ref
  resolves (the exact trivy-action@0.33.1 bug class from v3.0)
- claude-code-ops/validate-hooks-json.py: lint a hooks.json against the
  30-event catalog (repo's own hooks.json passes)
- playwright-ops/triage-flakes.py: rank flaky tests from a JSON report
Plus assets: agentic-loop.py, output-schema.json, hooks.json.template.

tests/check-resources.sh runs the offline mode in PR CI (validate.yml);
.github/workflows/freshness.yml runs the live mode weekly (advisory).
Folded into the v3.0.0 changelog entry per the one-large-v3 plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 4 weeks ago
parent
commit
4d774f479a

+ 52 - 0
.github/workflows/freshness.yml

@@ -0,0 +1,52 @@
+name: Freshness (live drift checks)
+
+# Live staleness checks for skills that encode fast-moving external facts
+# (SKILL-RESOURCE-PROTOCOL.md §7). These hit the network, so they run on a
+# schedule — NEVER as a PR gate. A network blip / rate-limit exits 7 and is
+# treated as "skip, retry next run"; only a confirmed drift (exit 10) fails
+# the job loudly.
+
+on:
+  schedule:
+    - cron: "0 6 * * 1"   # 06:00 UTC every Monday
+  workflow_dispatch: {}     # manual trigger
+
+permissions:
+  contents: read
+
+jobs:
+  drift:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+
+      - name: Model table vs live Models API
+        env:
+          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+        run: |
+          set +e
+          python skills/claude-api-ops/scripts/check-model-table.py --live
+          rc=$?
+          # 0 = in sync, 7 = unavailable (no key / unreachable) -> advisory skip,
+          # 10 = drift -> fail. Anything else is a real error.
+          if [ "$rc" -eq 10 ]; then echo "::error::model table drifted from the live Models API"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::model-table live check unavailable (no key / unreachable) — skipped"; fi
+          exit 0
+
+      - name: GitHub Action refs still resolve
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set +e
+          # Scan every shipped workflow asset + the repo's own workflows.
+          targets="skills/terraform-ops/assets/github-actions-terraform.yml .github/workflows/*.yml"
+          bash skills/terraform-ops/scripts/check-action-refs.sh --live $targets
+          rc=$?
+          if [ "$rc" -eq 10 ]; then echo "::error::a GitHub Action 'uses:' ref no longer resolves"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::action-ref live check rate-limited / unreachable — skipped"; fi
+          exit 0

+ 3 - 0
.github/workflows/validate.yml

@@ -34,3 +34,6 @@ jobs:
 
       - name: Run skill behavioural test suites
         run: bash tests/run-skill-tests.sh
+
+      - name: Offline resource checks (verifier scripts)
+        run: bash tests/check-resources.sh

+ 1 - 1
AGENTS.md

@@ -38,7 +38,7 @@ cd claude-mods && ./scripts/install.sh  # or .\scripts\install.ps1 on Windows
 | `tools/` | Modern CLI toolkit documentation |
 | `tests/` | Validation scripts + justfile |
 | `scripts/` | Install scripts |
-| `docs/` | ARCHITECTURE.md, WORKFLOWS.md, PLAN.md, SKILL-SUBAGENT-REFERENCE.md, TERMINAL-DESIGN.md |
+| `docs/` | ARCHITECTURE.md, WORKFLOWS.md, PLAN.md, SKILL-SUBAGENT-REFERENCE.md, SKILL-RESOURCE-PROTOCOL.md, TERMINAL-DESIGN.md |
 
 ## Session Init
 

+ 18 - 0
CHANGELOG.md

@@ -9,6 +9,24 @@ feature releases live in the README "Recent Updates" section.
 
 ## [3.0.0] - 2026-06-10
 
+### Added (skill resource protocol)
+- **`docs/SKILL-RESOURCE-PROTOCOL.md`** - the build standard for skill `scripts/`,
+  `assets/`, and `references/`: stream separation, semantic exit codes, `--help`
+  with EXAMPLES, first-comment-block contract, `--json` envelopes, agent safety,
+  the resource-scaffold checklist, and the **staleness-verifier pattern** (an
+  `--offline` structural check that gates PR CI plus a `--live` drift check that
+  runs scheduled, never blocking a PR on a network blip)
+- Four verifier/scanner scripts built to the protocol:
+  `claude-api-ops/check-model-table.py` (model+pricing table drift),
+  `terraform-ops/check-action-refs.sh` (GitHub Action `uses:` refs resolve —
+  catches the exact `trivy-action` tag bug from v3.0),
+  `claude-code-ops/validate-hooks-json.py` (lint a hooks.json against the
+  30-event catalog), `playwright-ops/triage-flakes.py` (rank flaky tests from a
+  JSON report). Plus assets: `agentic-loop.py`, `output-schema.json`,
+  `hooks.json.template`
+- CI: `tests/check-resources.sh` runs the offline verifiers in PR CI;
+  `.github/workflows/freshness.yml` runs the live drift checks weekly (advisory)
+
 ### Removed
 - **11 language/framework expert agents** deprecated in favour of their `-ops`
   skill twins (python, typescript, javascript, go, rust, react, vue, astro,

+ 1 - 0
README.md

@@ -29,6 +29,7 @@ From Python async patterns to Rust ownership models, from AWS Fargate deployment
 - 🛡️ **Live security guards + zero hand-wiring** - new `config-change-guard.sh` scans Claude settings files for worm-persistence IOCs the moment they're edited (ConfigChange hook); `worktree-guard.sh` mechanically enforces the worktree-boundaries rule. A plugin-level `hooks/hooks.json` auto-wires the whole security-advisory set on plugin install - no more manual settings.json surgery.
 - 🚢 **fleet-ops v2** - repositioned as the landing-discipline layer (sequential queue, test-gated merge, pre-land scrub, one-shot revert, new `fleet track`) on top of native agent teams / background agents, which now own session spawning.
 - 📑 **Docs you can trust, enforced** - new `CHANGELOG.md`; CI doc-drift gate fails the build when README/AGENTS/PLAN counts diverge from disk or a doc links a ghost path; CI now runs every skill's behavioural test suite; /save + /sync honestly repositioned vs native auto-memory (their value: portable, git-trackable, team-shareable state).
+- 🧰 **Skill Resource Protocol** - [`docs/SKILL-RESOURCE-PROTOCOL.md`](docs/SKILL-RESOURCE-PROTOCOL.md) sets one build standard for everything a skill ships beyond its prose (stream separation, semantic exit codes, `--help`+EXAMPLES, `--json` envelopes, agent safety). Its headline is the **staleness-verifier pattern**: a skill encoding fast-moving facts ships an `--offline` structural check (gates PR CI) plus a `--live` drift check (scheduled `freshness.yml`, never blocks a PR on a network blip). Four verifiers built to it — model-table drift, GitHub Action `uses:` resolution (the exact bug class that slipped into v3.0), hooks.json linting against the 30-event catalog, and Playwright flake-ranking.
 
 **v2.10.0** (May 2026)
 - 🕵️ **`prompt-injection-defense` skill** - Instruction-integrity sibling to `supply-chain-defense`: defends the agent's context surface against adversarial content where what a reviewer sees differs from what the model reads. `scan-hidden-unicode.py` detects bidi/Trojan-Source reordering, `U+E0000` tag-block ASCII smuggling, zero-width text, and (`--strict`) homoglyphs — emoji-whitelisted so it doesn't false-positive on every README; `sanitize-content.py` strips them from untrusted content before ingest (byte-faithful, idempotent). Deployed as silent guardians at the trust boundaries: a SessionStart hook scans project instruction files at boot, a git pre-commit gate blocks `critical` hidden Unicode from entering the repo, and `rules/prompt-injection.md` drives scan-on-entry / sanitize-on-ingest. Codepoint catalog + 2 references + 18-assertion offline suite.

+ 253 - 0
docs/SKILL-RESOURCE-PROTOCOL.md

@@ -0,0 +1,253 @@
+# Skill Resource Protocol
+
+> The build standard for everything a skill ships **besides** its `SKILL.md` prose:
+> the `scripts/`, `assets/`, and `references/` directories. One contract, so that a
+> script in `mac-ops` behaves like a script in `supply-chain-defense` — predictable
+> streams, predictable exit codes, predictable help.
+
+**Scope.** This document governs skill *resources*. Frontmatter, naming, and body
+structure live elsewhere — see [naming-conventions.md](../rules/naming-conventions.md),
+[SKILL-SUBAGENT-REFERENCE.md](SKILL-SUBAGENT-REFERENCE.md), and the
+[Agent Skills spec](https://agentskills.io/specification). Terminal/TTY output is
+[TERMINAL-DESIGN.md](TERMINAL-DESIGN.md) (`skills/_lib/term.sh`).
+
+**Why it exists.** Claude executes these scripts mid-task and parses their output.
+Inconsistent interfaces mean wasted tokens (the agent re-derives usage), broken pipes
+(status text pollutes `| jq`), and silent failures (exit 0 on bad input). The repo has
+56 skill scripts; the strong ones already follow this — `supply-chain-defense/scripts/preinstall-check.sh`
+is the canonical exemplar.
+
+---
+
+## 1. Directory roles
+
+```
+<skill-name>/
+├── SKILL.md            # prose: routing + 80/20 patterns + pointers
+├── scripts/            # runnable code the agent executes rather than re-derives
+├── references/         # deep docs loaded on demand (one concept per file)
+└── assets/             # templates, reference data, starter files to copy
+```
+
+| Resource | Ship one when… |
+|---|---|
+| `scripts/*` | The agent would re-derive the same logic every task, OR the invocation has >3 flags, OR it's a known-good decoder/validator/verifier |
+| `references/*.md` | A sub-topic is too long for the SKILL.md body (keep body < 500 lines); one concept per file, kebab-case, TOC if > 300 lines |
+| `assets/*` | A task needs a known-good scaffold — a config template, a starter schema, canonical lookup data |
+
+Every reference and asset MUST be cited from `SKILL.md` with enough context that the
+agent knows *when* to load it. An unreferenced resource is dead weight the router never finds.
+
+---
+
+## 2. The script contract (hard rules)
+
+A script under `scripts/` is an **agent-facing tool**. It MUST satisfy all of:
+
+1. **Shebang + first-comment-block contract** (§3) — readable via `head -25`.
+2. **`chmod +x`**, and the right extension (`.sh` bash, `.py` python3 — the agent reads
+   the extension to know the runtime).
+3. **`--help` / `-h`** → usage + options + an **EXAMPLES** section, exit 0, to stdout.
+4. **Stream separation** (§4) — stdout is data only; everything else is stderr.
+5. **Semantic exit codes** (§5) — distinct codes per failure class.
+6. **Bash:** `set -uo pipefail` (use `-e` only when every failure is fatal), all
+   expansions quoted. **Python:** passes `python -m py_compile`, `argparse` for args.
+7. **Agent safety** (§6) — validate inputs; never `shell=True` with agent-supplied data.
+8. **Cited from `SKILL.md`** with a complete worked invocation, not a bare path.
+
+Default to **stdlib + common shell tools** (`jq`, `git`, `curl`). Check optional tools
+with `command -v` and exit `5` (missing-dep) with an install hint, never a stack trace.
+
+---
+
+## 3. First-comment-block contract
+
+The first comment block is the script's machine-readable contract. The agent reads it
+with `head -25` before running.
+
+```bash
+#!/usr/bin/env bash
+# <one-line description, ends with a period.>
+#
+# Usage:   <script> [OPTIONS] <ARG>
+# Input:   <argv + stdin contract>
+# Output:  <stdout contract — name the --json schema if structured>
+# Stderr:  <what goes to stderr; e.g. "headers, progress, errors">
+# Exit:    0 ok, 2 usage, 5 missing-dep, 7 unavailable, 10 <domain signal>
+#
+# Examples:
+#   <script> simple-input
+#   <script> --json input | jq '.data[]'
+set -uo pipefail
+```
+
+Python uses the module docstring identically. The **Examples** section is mandatory —
+it's what makes the tool discoverable when the agent runs `--help`.
+
+---
+
+## 4. Stream separation (the most important rule)
+
+| Stream | Carries | Never carries |
+|---|---|---|
+| **stdout** | The data product only — JSON under `--json`, else plain/TSV | Progress, status, warnings, ANSI (unless TTY and not `--json`) |
+| **stderr** | Everything else — headers, progress, warnings, errors, logs | The data product |
+
+stdout is the agent's input. Pollution breaks `| jq` and downstream parsing. When
+`--json` is set, an error goes to stdout **as structured JSON** (§5) *and* a human line
+goes to stderr.
+
+`--json` success envelope:
+
+```json
+{ "data": [ {"...": "..."} ], "meta": { "count": 2, "schema": "claude-mods.<skill>.<name>/v1" } }
+```
+
+`--json` error envelope (also printed to stdout):
+
+```json
+{ "error": { "code": "VALIDATION", "message": "…", "details": { } } }
+```
+
+Booleans are `true`/`false`; empty lists `[]` not `null`; timestamps ISO-8601 Z.
+
+---
+
+## 5. Exit codes (semantic, not just 0/1)
+
+Distinct codes per failure **class** so the agent (and CI) can branch.
+
+| Code | Name | When |
+|---|---|---|
+| `0` | SUCCESS | Operation completed; for verifiers, "no drift / all checks pass" |
+| `1` | ERROR | Uncategorised failure |
+| `2` | USAGE | Bad/missing arguments, conflicting flags |
+| `3` | NOT_FOUND | Input file/resource absent |
+| `4` | VALIDATION | Input present but invalid (malformed JSON, schema mismatch) |
+| `5` | PRECONDITION | Environment issue — missing dependency, wrong cwd, no permission |
+| `6` | TIMEOUT | Exceeded a time budget |
+| `7` | UNAVAILABLE | External resource down/offline/rate-limited (distinct from a real failure) |
+| `10`+ | DOMAIN SIGNAL | A non-error "finding" the caller branches on — document it in the header |
+
+Codes 0/2 are required. **`10` is the workhorse for verifiers and scanners**: "ran fine,
+found something." `preinstall-check.sh` exits `10` for "a package is inside the cooldown
+window"; a hidden-unicode scan exits `10` on a hit. Reserve `7` for genuine
+external-resource failure so a network blip never looks like a content problem — this is
+what lets a live check stay advisory instead of flaky-blocking (§7).
+
+---
+
+## 6. Agent safety
+
+Agents fabricate plausible inputs. The script is the last line of defence.
+
+| Threat | Defence |
+|---|---|
+| Path traversal | `realpath`/`Path.resolve()`; reject paths outside the expected root |
+| Shell injection | List-form `subprocess.run([...])`; **never** `shell=True` with agent input |
+| Destructive ops | Require explicit `--force`/`--yes`; default to dry-run-equivalent; atomic writes (`tmp` + rename) |
+| Resource exhaustion | Default a sane `--limit`; stream large inputs |
+| Unknown flags / extra positionals | Hard `USAGE` error — never silently ignore |
+
+Never write to a destination directly — write `<dest>.tmp`, then rename. Re-running with
+the same inputs must be idempotent.
+
+---
+
+## 7. The staleness-verifier pattern (claude-mods-specific)
+
+The repo's worst failure mode is **silent doc staleness** — a skill that was correct when
+written and quietly drifts as the external world moves (model IDs, API params, GitHub
+Action versions, hook events). This produced three real bugs in the v3.0 review alone.
+
+**Any skill that encodes fast-moving external facts SHOULD ship a verifier script** with
+two modes:
+
+| Mode | Flag | Checks | Network | Where it runs |
+|---|---|---|---|---|
+| **Structural** | `--offline` (default in CI) | Internal consistency — the table parses, every documented item is well-formed, the shipped template is syntactically valid | No | **PR CI — may block** |
+| **Live** | `--live` | Does the encoded fact still match reality? (fetch the Models API; resolve every `uses:` ref) | Yes | **Scheduled workflow — never blocks a PR** |
+
+The rule that makes this safe: **a network-dependent assertion is never a blocking PR
+gate.** It exits `7` (UNAVAILABLE) on transient failure — which the scheduled job treats
+as "skip, retry next run", not "fail". Only a confirmed drift (reachable source, value
+differs) exits `10`. A blocking check that goes red on a rate-limit teaches everyone to
+ignore red CI — the precise way a gate dies.
+
+Worked shape:
+
+```bash
+check-model-table.py --offline      # exit 0 (table internally consistent) — PR CI
+check-model-table.py --live         # exit 10 if live Models API disagrees with the table
+                                    # exit 7  if the API was unreachable (advisory, no failure)
+```
+
+The scheduled job runs `--live` weekly; on exit `10` it fails loudly (or opens an issue)
+naming the exact drift. The skill stays trustworthy without making honest PRs flaky.
+
+---
+
+## 8. The resource-scaffold checklist
+
+When authoring a skill, ask whether any of these would save the agent re-deriving
+known-good logic. A skill may warrant zero, one, or several.
+
+| Type | Purpose | Signals it's worth it |
+|---|---|---|
+| **Source / input scanner** | Static-check input before work; surface errors as structured output | Untrusted input, config files, formats with footguns (a `hooks.json`, a Terraform module) |
+| **Preflight checker** | Validate environment/deps before a long step | Multi-tool pipelines, anything that crashes late |
+| **Verifier / output checker** | Assert the produced artefact matches expected structure | Templates the skill ships, generated configs, the staleness pattern (§7) |
+| **Calculator / triage** | Compute a domain constraint or rank findings the agent shouldn't redo by hand | Cost/budget math, parsing reports (flaky-test ranking), layout computation |
+
+Gate question: *"Would a senior engineer in this domain reach for a small script to check
+this before or after doing the work?"* If yes, the agent will too — write it once, to this
+protocol.
+
+---
+
+## 9. assets/ taxonomy
+
+| Kind | Example | Discipline |
+|---|---|---|
+| **Template** | `playwright.config.template.ts`, `github-actions-terraform.yml` | Heavily commented; mark adapt-points; a verifier (§7) should confirm it stays valid |
+| **Reference data** | `exposure-catalog.json`, an IOC list | Canonical lookup the agent queries; version/date-stamp if it changes |
+| **Starter code** | a minimal agentic-loop, a starter JSON schema | Smallest thing that runs and is correct to extend |
+
+Use the target file's natural extension. Keep binary assets < 500 KB. Asset edits are part
+of the skill — no separate version field; a skill commit covers SKILL.md + scripts + assets
+atomically.
+
+---
+
+## 10. Compliance checklist
+
+Required (a script that fails these doesn't ship):
+
+- [ ] Shebang + first-comment-block contract with an **Examples** section
+- [ ] `chmod +x`; correct extension
+- [ ] `--help`/`-h` works, exits 0, lists EXAMPLES
+- [ ] stdout is data-only; stderr gets progress/status/errors
+- [ ] Exit codes follow §5; `USAGE`→2 on bad args
+- [ ] Bash `set -uo pipefail` + quoted expansions; Python passes `py_compile`
+- [ ] Input validation per §6; no `shell=True` on agent input
+- [ ] Cited from `SKILL.md` with a worked invocation
+
+Recommended (the bar for a world-class skill):
+
+- [ ] `--json` flag, output matches the §4 envelope
+- [ ] `command -v` checks for optional tools → exit 5 with install hint
+- [ ] Stdlib-only where possible; degrade gracefully on missing optional deps
+- [ ] Idempotent; `--force` to overwrite; atomic writes
+- [ ] If it encodes external facts: the §7 `--offline`/`--live` verifier split
+- [ ] A `tests/` peer suite (see `supply-chain-defense/tests/run.sh`), run by
+      `tests/run-skill-tests.sh`
+
+---
+
+## Reference exemplars in this repo
+
+- `skills/supply-chain-defense/scripts/preinstall-check.sh` — the canonical script:
+  ecosystem flags, `--json`, exit-10 domain signal, `command -v` guards, registry-unavailable→7.
+- `skills/supply-chain-defense/tests/run.sh` — offline self-test peer suite (67 assertions).
+- `skills/_lib/term.sh` + [TERMINAL-DESIGN.md](TERMINAL-DESIGN.md) — for any script that
+  prints a panel to a TTY.

+ 10 - 0
rules/skill-agent-updates.md

@@ -9,6 +9,16 @@
 
 These APIs change frequently. For detailed reference (frontmatter fields, decision frameworks), see `docs/SKILL-SUBAGENT-REFERENCE.md`.
 
+## Skill resources (scripts / assets / references)
+
+Anything a skill ships beyond `SKILL.md` follows `docs/SKILL-RESOURCE-PROTOCOL.md`:
+stream separation (stdout data-only), semantic exit codes, `--help` with EXAMPLES,
+the first-comment-block contract, and `--json` envelopes. A skill that encodes
+fast-moving external facts (model IDs, API params, action versions) SHOULD ship a
+verifier with the `--offline`/`--live` split (§7) so staleness trips a tripwire
+instead of rotting silently. `tests/check-resources.sh` runs the offline mode in CI;
+the scheduled `freshness.yml` workflow runs the live mode.
+
 ## Terminal output
 
 Skills that print to a TTY follow `docs/TERMINAL-DESIGN.md` and source `skills/_lib/term.sh` for glyphs, colors, and layout helpers. Don't roll your own ANSI codes or state icons — `term_init` plus `term_state_icon` / `term_header` / `term_table_row` cover the common cases and keep the toolkit visually coherent.

+ 38 - 0
skills/claude-api-ops/SKILL.md

@@ -254,6 +254,44 @@ Built-in tools (Read/Write/Edit/Bash/Glob/Grep/WebSearch/WebFetch/...), hooks
 | String-matching error messages | Fragile retries | Typed exceptions: `anthropic.RateLimitError` etc. |
 | Raw string-matching tool `input` | Breaks on escaping changes | Always `json.loads()` / use parsed `block.input` |
 
+## Resources & Verification
+
+This skill ships a staleness verifier and two copy-and-adapt starter assets. The
+model table and pricing above are the facts most likely to drift — run the
+verifier when you suspect they're stale.
+
+**`scripts/check-model-table.py`** — guards the Current Models table (this file)
+and the per-model prompt-cache minimum table
+([references/caching-and-cost.md](references/caching-and-cost.md)) against drift.
+Two modes per the [resource protocol §7](../../docs/SKILL-RESOURCE-PROTOCOL.md):
+
+```bash
+# Structural (default, no network): every row well-formed, ids carry no date
+# suffix, prices numeric, the two files agree on the model lineup. Exit 4 on a
+# malformed/contradictory row.
+python skills/claude-api-ops/scripts/check-model-table.py --offline
+python skills/claude-api-ops/scripts/check-model-table.py --offline --json | python -m json.tool
+
+# Live (advisory, needs ANTHROPIC_API_KEY): curls the Models API and compares
+# its id set against the documented ids. Exit 10 if a documented id is gone or a
+# newer alias id is missing from the table; exit 7 (not a failure) if the key is
+# unset or the API is unreachable. Live mode checks model-ID coverage ONLY — the
+# API returns no pricing, so pricing/context drift stays an --offline + docs concern.
+ANTHROPIC_API_KEY=sk-... python skills/claude-api-ops/scripts/check-model-table.py --live
+```
+
+**`assets/agentic-loop.py`** — a minimal, runnable tool-use loop (define a tool,
+call `messages.create`, loop while `stop_reason == "tool_use"`, append
+`tool_result`, re-request until `end_turn`). Copy it as the starting point when
+building a manual agent loop; the `>>> ADAPT` marks show what to change.
+
+**`assets/output-schema.json`** — a known-good structured-outputs request body in
+the canonical `output_config.format` shape (with `additionalProperties: false`
+and a `required` array). Copy and reshape `schema.properties` when adding JSON
+outputs; see [references/structured-outputs.md](references/structured-outputs.md)
+for the rules. (Not supported on Fable 5 — that model uses system-prompt
+instructions instead.)
+
 ## Reference Files
 
 | File | Covers |

+ 112 - 0
skills/claude-api-ops/assets/agentic-loop.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""Minimal, correct tool-use agentic loop on the Anthropic Messages API.
+
+The canonical pattern: define a tool, call messages.create, and keep looping
+while stop_reason == "tool_use" — execute each requested tool, append a
+tool_result, and re-request until stop_reason == "end_turn".
+
+Run:  pip install anthropic   (then: export ANTHROPIC_API_KEY=sk-...)
+      python agentic-loop.py
+
+Copy this file and adapt the >>> ADAPT marks for your own tools.
+Reflects the current API (model claude-opus-4-8, typed content blocks).
+"""
+# The Anthropic SDK accepts plain dict literals for tools/messages at runtime
+# (as the official docs show), but its strict TypedDict stubs over-narrow them.
+# Silence those false positives so this starter stays readable; real apps may
+# prefer the SDK's typed params (anthropic.types.ToolParam, MessageParam).
+# pyright: reportArgumentType=false
+import anthropic
+
+client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment
+
+MODEL = "claude-opus-4-8"  # >>> ADAPT: pick a tier (see the skill's model table)
+
+
+# --- 1. Define your tool(s) ------------------------------------------------
+# The input_schema is JSON Schema. Write a description that says WHEN to call.
+TOOLS = [
+    {
+        "name": "get_weather",  # >>> ADAPT
+        "description": "Get the current weather for a city. "
+                       "Call this whenever the user asks about weather conditions.",
+        "input_schema": {
+            "type": "object",
+            "properties": {
+                "location": {"type": "string", "description": "City, e.g. 'Paris'"},
+            },
+            "required": ["location"],
+        },
+    }
+]
+
+
+# --- 2. Implement each tool ------------------------------------------------
+# Map tool name -> a Python callable. Never trust the arguments blindly: they
+# come from the model. Validate before doing anything with side effects.
+def get_weather(location: str) -> str:  # >>> ADAPT: real implementation
+    return f"It is 21°C and sunny in {location}."
+
+
+TOOL_IMPLS = {"get_weather": get_weather}
+
+
+def run_tool(name: str, tool_input: dict) -> str:
+    """Dispatch a tool call, returning a string result for the model."""
+    impl = TOOL_IMPLS.get(name)
+    if impl is None:
+        return f"ERROR: unknown tool {name!r}"
+    try:
+        return impl(**tool_input)
+    except Exception as exc:  # surface failures back to the model, don't crash
+        return f"ERROR running {name}: {exc}"
+
+
+# --- 3. The loop -----------------------------------------------------------
+def agent(user_prompt: str, max_turns: int = 10) -> str:
+    # The conversation is a growing list of message dicts we own and replay.
+    messages = [{"role": "user", "content": user_prompt}]
+
+    for _ in range(max_turns):
+        response = client.messages.create(
+            model=MODEL,
+            max_tokens=4096,
+            tools=TOOLS,
+            messages=messages,
+        )
+
+        # Append the assistant turn VERBATIM — content is a list of typed
+        # blocks (text and/or tool_use). It must go back as-is next request.
+        messages.append({"role": "assistant", "content": response.content})
+
+        # If the model didn't ask for a tool, we're done — return its text.
+        if response.stop_reason != "tool_use":
+            return "".join(
+                block.text for block in response.content if block.type == "text"
+            )
+
+        # Otherwise: execute EVERY tool_use block and collect tool_result
+        # blocks (the model may request several tools in parallel).
+        tool_results = []
+        for block in response.content:
+            if block.type != "tool_use":
+                continue  # skip text/thinking blocks
+            result_text = run_tool(block.name, block.input)
+            tool_results.append({
+                "type": "tool_result",
+                "tool_use_id": block.id,   # MUST echo the matching id
+                "content": result_text,
+                # "is_error": True,        # set when the tool failed
+            })
+
+        # Feed results back as a single user turn, then loop to re-request.
+        messages.append({"role": "user", "content": tool_results})
+
+    return "Stopped: hit max_turns without an end_turn."
+
+
+if __name__ == "__main__":
+    answer = agent("What's the weather in Tokyo right now?")
+    print(answer)
+    # For debugging, inspect the assembled transcript:
+    # print(json.dumps(..., indent=2, default=str))

+ 39 - 0
skills/claude-api-ops/assets/output-schema.json

@@ -0,0 +1,39 @@
+{
+  "_comment": "Canonical structured-outputs request body for the Anthropic Messages API. Pass the `output_config` object below in client.messages.create(...). This is the current `output_config.format` shape — NOT the deprecated top-level `output_format` param. Adapt `schema.properties` and `required` to your data; keep `additionalProperties: false` so the model can't add stray keys. Supported on Opus 4.8/4.7/4.6/4.5, Sonnet 4.6/4.5, Haiku 4.5 — NOT Fable 5 (use system-prompt instructions or strict tool use there).",
+  "model": "claude-opus-4-8",
+  "max_tokens": 1024,
+  "messages": [
+    {
+      "role": "user",
+      "content": "Extract the contact: Jane Doe (jane@example.com) wants the Enterprise plan and asked for a demo."
+    }
+  ],
+  "output_config": {
+    "format": {
+      "type": "json_schema",
+      "schema": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "description": "Full name of the contact"
+          },
+          "email": {
+            "type": "string",
+            "format": "email"
+          },
+          "plan": {
+            "type": "string",
+            "enum": ["Free", "Pro", "Enterprise"]
+          },
+          "demo_requested": {
+            "type": "boolean",
+            "description": "Whether the contact asked for a demo"
+          }
+        },
+        "required": ["name", "email", "plan", "demo_requested"],
+        "additionalProperties": false
+      }
+    }
+  }
+}

+ 460 - 0
skills/claude-api-ops/scripts/check-model-table.py

@@ -0,0 +1,460 @@
+#!/usr/bin/env python3
+"""Staleness verifier for the claude-api-ops model + cache-minimum tables.
+
+Guards the two fast-moving fact tables in this skill against silent drift:
+  - the "Current Models" table in SKILL.md (ids, pricing, context, output)
+  - the per-model prompt-cache minimum table in references/caching-and-cost.md
+
+Two modes (protocol SKILL-RESOURCE-PROTOCOL.md §7):
+  --offline (default): parse both tables, assert internal consistency. No network.
+                       Exit 4 (VALIDATION) on a malformed/contradictory row.
+  --live:              curl the Anthropic Models API and compare its model-id set
+                       against the documented ids. Advisory only.
+
+Live-mode scope limit: the Models API returns model IDs but NOT pricing, context,
+or output limits. --live therefore verifies model-ID existence/coverage ONLY:
+  - a documented id absent from the live list  -> DRIFT (retired/typo)
+  - a live id newer than anything documented    -> DRIFT (table lacks a new model)
+Pricing/context/output drift is out of scope for --live (the API can't confirm it);
+--offline guards their well-formedness, and the SKILL.md "Live Documentation" links
+remain the human cross-check for pricing.
+
+Usage:   check-model-table.py [--offline | --live] [--json] [--skill-dir DIR] [-q]
+Input:   reads SKILL.md and references/caching-and-cost.md (resolved relative to
+         this script, or --skill-dir)
+Output:  stdout = data only (JSON envelope under --json, else a plain summary)
+Stderr:  headers, progress, notes, errors
+Exit:    0 ok/consistent, 2 usage, 3 not-found, 4 validation (malformed/contradictory),
+         5 missing-dep (curl, --live only), 7 unavailable (no key / API unreachable),
+         10 drift (live id-set disagrees with the table)
+
+Examples:
+  check-model-table.py --offline
+  check-model-table.py --offline --json | python -m json.tool
+  ANTHROPIC_API_KEY=sk-... check-model-table.py --live
+  check-model-table.py --live   # exits 7 (advisory) when the key is unset
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import NoReturn
+
+# Windows consoles default to cp1252; force UTF-8 so em-dashes/§ in notes don't
+# raise UnicodeEncodeError or print mojibake (matches the repo's standard fix).
+for _stream in (sys.stdout, sys.stderr):
+    try:
+        _stream.reconfigure(encoding="utf-8")  # type: ignore[attr-defined]
+    except (AttributeError, ValueError):
+        pass
+
+EXIT_OK = 0
+EXIT_USAGE = 2
+EXIT_NOT_FOUND = 3
+EXIT_VALIDATION = 4
+EXIT_MISSING_DEP = 5
+EXIT_UNAVAILABLE = 7
+EXIT_DRIFT = 10
+
+SCHEMA = "claude-mods.claude-api-ops.model-table/v1"
+MODELS_API = "https://api.anthropic.com/v1/models?limit=1000"
+ANTHROPIC_VERSION = "2023-06-01"
+
+# A well-formed alias id: claude-<word>-<digit>... and NO date suffix.
+# Accepts claude-opus-4-8, claude-fable-5, claude-sonnet-4-6, claude-haiku-4-5.
+ID_RE = re.compile(r"^claude-[a-z]+-\d+(?:-\d+)?$")
+# A date suffix looks like an 8-digit run (e.g. -20251114).
+DATE_SUFFIX_RE = re.compile(r"-\d{8}$")
+
+
+def note(msg: str, quiet: bool) -> None:
+    if not quiet:
+        print(msg, file=sys.stderr)
+
+
+def fail_validation(message: str, details: dict, json_mode: bool) -> NoReturn:
+    if json_mode:
+        print(json.dumps({"error": {"code": "VALIDATION", "message": message,
+                                    "details": details}}))
+    print(f"ERROR: {message}", file=sys.stderr)
+    for k, v in details.items():
+        print(f"  {k}: {v}", file=sys.stderr)
+    sys.exit(EXIT_VALIDATION)
+
+
+# ---------------------------------------------------------------------------
+# Parsing
+# ---------------------------------------------------------------------------
+
+def split_row(line: str) -> list[str]:
+    """Split a markdown table row into trimmed cells (drops outer pipes)."""
+    cells = [c.strip() for c in line.strip().strip("|").split("|")]
+    return cells
+
+
+def is_separator(cells: list[str]) -> bool:
+    return all(re.fullmatch(r":?-{2,}:?", c or "") for c in cells) and bool(cells)
+
+
+def parse_model_table(text: str) -> tuple[list[dict], list[str]]:
+    """Parse the SKILL.md 'Current Models' table.
+
+    Columns: Model | ID | Context | Max Output | Input $/MTok | Output $/MTok
+    Returns one dict per data row.
+    """
+    lines = text.splitlines()
+    # Locate the header row that contains the ID column and a price column.
+    start = None
+    for i, line in enumerate(lines):
+        low = line.lower()
+        if line.lstrip().startswith("|") and "id" in low and "context" in low and "output" in low:
+            start = i
+            break
+    if start is None:
+        return [], []
+
+    header = split_row(lines[start])
+    rows: list[dict] = []
+    # Expect a separator row next, then data rows until a non-table line.
+    j = start + 1
+    if j < len(lines) and is_separator(split_row(lines[j])):
+        j += 1
+    while j < len(lines):
+        line = lines[j]
+        if not line.lstrip().startswith("|"):
+            break
+        cells = split_row(line)
+        if is_separator(cells):
+            j += 1
+            continue
+        if len(cells) >= 6:
+            rows.append({
+                "name": cells[0],
+                "id_cell": cells[1],
+                "context": cells[2],
+                "max_output": cells[3],
+                "input_price": cells[4],
+                "output_price": cells[5],
+            })
+        j += 1
+    return rows, header
+
+
+def parse_cache_min_table(text: str) -> list[dict]:
+    """Parse the caching-and-cost.md 'Minimum prefix tokens' table.
+
+    Columns: Model | Minimum prefix tokens. The Model cell holds friendly names
+    (possibly several comma-separated), not ids.
+    """
+    lines = text.splitlines()
+    start = None
+    for i, line in enumerate(lines):
+        low = line.lower()
+        if line.lstrip().startswith("|") and "model" in low and "minimum" in low and "prefix" in low:
+            start = i
+            break
+    if start is None:
+        return []
+    rows: list[dict] = []
+    j = start + 1
+    if j < len(lines) and is_separator(split_row(lines[j])):
+        j += 1
+    while j < len(lines):
+        line = lines[j]
+        if not line.lstrip().startswith("|"):
+            break
+        cells = split_row(line)
+        if is_separator(cells):
+            j += 1
+            continue
+        if len(cells) >= 2:
+            rows.append({"names": cells[0], "min_tokens": cells[1]})
+        j += 1
+    return rows
+
+
+# ---------------------------------------------------------------------------
+# Offline validation
+# ---------------------------------------------------------------------------
+
+PRICE_RE = re.compile(r"^\$\d+(?:\.\d+)?$")
+SIZE_RE = re.compile(r"^\d+(?:\.\d+)?[KM]$")
+
+
+def clean_id(id_cell: str) -> str:
+    """Strip backtick code fences from an ID cell."""
+    return id_cell.strip().strip("`").strip()
+
+
+def validate_offline(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
+    skill_md = skill_dir / "SKILL.md"
+    cache_md = skill_dir / "references" / "caching-and-cost.md"
+    for p in (skill_md, cache_md):
+        if not p.is_file():
+            if json_mode:
+                print(json.dumps({"error": {"code": "NOT_FOUND",
+                                            "message": f"missing file: {p}",
+                                            "details": {}}}))
+            print(f"ERROR: required file not found: {p}", file=sys.stderr)
+            sys.exit(EXIT_NOT_FOUND)
+
+    note("=== offline model-table consistency check ===", quiet)
+
+    model_rows, _ = parse_model_table(skill_md.read_text(encoding="utf-8"))
+    if not model_rows:
+        fail_validation("could not locate a non-empty Current Models table in SKILL.md",
+                        {"file": str(skill_md)}, json_mode)
+
+    documented_ids: list[str] = []
+    models_out: list[dict] = []
+    for row in model_rows:
+        mid = clean_id(row["id_cell"])
+        problems = []
+        if not ID_RE.match(mid):
+            problems.append("id does not match claude-[a-z]+-<digits>")
+        if DATE_SUFFIX_RE.search(mid):
+            problems.append("id carries a date suffix (should be a bare alias)")
+        if not PRICE_RE.match(row["input_price"]):
+            problems.append(f"input price not numeric: {row['input_price']!r}")
+        if not PRICE_RE.match(row["output_price"]):
+            problems.append(f"output price not numeric: {row['output_price']!r}")
+        if not SIZE_RE.match(row["context"]):
+            problems.append(f"context not a size (e.g. 1M/200K): {row['context']!r}")
+        if not SIZE_RE.match(row["max_output"]):
+            problems.append(f"max output not a size: {row['max_output']!r}")
+        if problems:
+            fail_validation(f"malformed model row: {row['name']!r}",
+                            {"id": mid, "problems": "; ".join(problems)}, json_mode)
+        documented_ids.append(mid)
+        models_out.append({
+            "name": row["name"], "id": mid, "context": row["context"],
+            "max_output": row["max_output"],
+            "input_price": row["input_price"], "output_price": row["output_price"],
+        })
+
+    # No duplicate ids.
+    dupes = {x for x in documented_ids if documented_ids.count(x) > 1}
+    if dupes:
+        fail_validation("duplicate model ids in the table",
+                        {"ids": ", ".join(sorted(dupes))}, json_mode)
+
+    # Cache-minimum table.
+    cache_rows = parse_cache_min_table(cache_md.read_text(encoding="utf-8"))
+    if not cache_rows:
+        fail_validation("could not locate the cache-minimum table in caching-and-cost.md",
+                        {"file": str(cache_md)}, json_mode)
+    for crow in cache_rows:
+        if not re.fullmatch(r"\d+", crow["min_tokens"]):
+            fail_validation("cache-minimum value is not an integer",
+                            {"row": crow["names"], "value": crow["min_tokens"]},
+                            json_mode)
+
+    # Cross-file consistency: every model NAME (e.g. "Opus 4.8", "Fable 5",
+    # "Sonnet 4.6", "Haiku 4.5") in the model table must appear in the cache
+    # table's name set, so the two files agree on the model lineup.
+    cache_blob = " ".join(c["names"] for c in cache_rows).lower()
+    missing_in_cache: list[str] = []
+    for m in models_out:
+        # Derive the short family+version token, e.g. "Claude Opus 4.8" -> "opus 4.8".
+        short = re.sub(r"^claude\s+", "", m["name"], flags=re.I).strip().lower()
+        if short not in cache_blob:
+            missing_in_cache.append(m["name"])
+    if missing_in_cache:
+        fail_validation(
+            "model(s) in SKILL.md absent from the cache-minimum table — files contradict",
+            {"missing": ", ".join(missing_in_cache),
+             "hint": "every documented model needs a prompt-cache minimum row"},
+            json_mode)
+
+    note(f"  {len(models_out)} model rows, all well-formed", quiet)
+    note(f"  {len(cache_rows)} cache-minimum rows, all integer", quiet)
+    note("  cross-file model lineup consistent", quiet)
+    note("OK: tables internally consistent.", quiet)
+
+    return {
+        "mode": "offline",
+        "models": models_out,
+        "documented_ids": documented_ids,
+        "cache_min_rows": cache_rows,
+        "consistent": True,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Live validation
+# ---------------------------------------------------------------------------
+
+def fetch_live_ids(quiet: bool) -> list[str] | None:
+    """Return the live model-id list, or None if unavailable (advisory)."""
+    key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
+    if not key:
+        note("NOTE: ANTHROPIC_API_KEY is unset - skipping live check (advisory).",
+             quiet)
+        return None
+    cmd = [
+        "curl", "-fsS", "--max-time", "20",
+        "-H", f"x-api-key: {key}",
+        "-H", f"anthropic-version: {ANTHROPIC_VERSION}",
+        MODELS_API,
+    ]
+    try:
+        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+    except (subprocess.TimeoutExpired, OSError) as exc:
+        note(f"NOTE: Models API call failed ({exc}) — advisory, not a failure.",
+             quiet)
+        return None
+    if proc.returncode != 0:
+        note(f"NOTE: Models API unreachable (curl exit {proc.returncode}) — advisory.",
+             quiet)
+        if proc.stderr.strip():
+            note(f"  {proc.stderr.strip().splitlines()[-1]}", quiet)
+        return None
+    try:
+        payload = json.loads(proc.stdout)
+    except json.JSONDecodeError:
+        note("NOTE: Models API returned non-JSON — advisory, not a failure.", quiet)
+        return None
+    data = payload.get("data")
+    if not isinstance(data, list):
+        note("NOTE: Models API JSON missing 'data' list — advisory.", quiet)
+        return None
+    return [m.get("id", "") for m in data if isinstance(m, dict) and m.get("id")]
+
+
+def validate_live(skill_dir: Path, json_mode: bool, quiet: bool) -> dict:
+    if not _have("curl"):
+        if json_mode:
+            print(json.dumps({"error": {"code": "PRECONDITION",
+                                         "message": "curl required for --live",
+                                         "details": {}}}))
+        print("ERROR: curl is required for --live", file=sys.stderr)
+        sys.exit(EXIT_MISSING_DEP)
+
+    # Reuse offline parse for the documented id set (also validates well-formedness).
+    note("=== live model-id coverage check ===", quiet)
+    skill_md = skill_dir / "SKILL.md"
+    if not skill_md.is_file():
+        print(f"ERROR: required file not found: {skill_md}", file=sys.stderr)
+        sys.exit(EXIT_NOT_FOUND)
+    parsed = parse_model_table(skill_md.read_text(encoding="utf-8"))
+    if not parsed or not parsed[0]:
+        fail_validation("could not parse the model table for live comparison",
+                        {"file": str(skill_md)}, json_mode)
+    documented = [clean_id(r["id_cell"]) for r in parsed[0]]
+
+    live = fetch_live_ids(quiet)
+    if live is None:
+        # Advisory: not a failure. Exit 7.
+        if json_mode:
+            print(json.dumps({"data": {"mode": "live", "status": "unavailable",
+                                       "documented_ids": documented, "live_ids": None},
+                              "meta": {"schema": SCHEMA, "status": "unavailable"}}))
+        sys.exit(EXIT_UNAVAILABLE)
+
+    live_set = set(live)
+    doc_set = set(documented)
+
+    # A documented id absent from the live list = drift (retired/typo).
+    missing = sorted(doc_set - live_set)
+    # A live id NEWER than anything documented = drift (table lacks a new model).
+    # Restrict "newer" to well-formed alias ids so we ignore date-suffixed and
+    # snapshot variants the docs intentionally don't list.
+    live_alias = {m for m in live_set if ID_RE.match(m) and not DATE_SUFFIX_RE.search(m)}
+    new_models = sorted(live_alias - doc_set)
+
+    drift = bool(missing or new_models)
+    result = {
+        "mode": "live",
+        "status": "drift" if drift else "ok",
+        "documented_ids": documented,
+        "live_ids": sorted(live_set),
+        "missing_from_live": missing,
+        "new_in_live": new_models,
+    }
+
+    if drift:
+        if missing:
+            note("DRIFT: documented id(s) absent from live Models API:", quiet)
+            for m in missing:
+                note(f"  - {m}", quiet)
+        if new_models:
+            note("DRIFT: live Models API has alias id(s) the table lacks:", quiet)
+            for m in new_models:
+                note(f"  + {m}", quiet)
+        if json_mode:
+            print(json.dumps({"data": result, "meta": {"schema": SCHEMA,
+                                                        "status": "drift"}}))
+        else:
+            print("DRIFT: model-id table disagrees with the live Models API "
+                  f"(missing={missing}, new={new_models})")
+        sys.exit(EXIT_DRIFT)
+
+    note("OK: every documented id exists live; no newer alias id missing from the table.",
+         quiet)
+    return result
+
+
+def _have(tool: str) -> bool:
+    from shutil import which
+    return which(tool) is not None
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main(argv: list[str]) -> int:
+    parser = argparse.ArgumentParser(
+        prog="check-model-table.py", add_help=True,
+        description="Staleness verifier for the claude-api-ops model + cache tables.",
+        epilog=(
+            "EXAMPLES:\n"
+            "  check-model-table.py --offline\n"
+            "  check-model-table.py --offline --json | python -m json.tool\n"
+            "  ANTHROPIC_API_KEY=sk-... check-model-table.py --live\n"
+            "  check-model-table.py --live   # exits 7 (advisory) when key unset\n"
+        ),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    mode = parser.add_mutually_exclusive_group()
+    mode.add_argument("--offline", action="store_true",
+                      help="parse + assert internal consistency, no network (default)")
+    mode.add_argument("--live", action="store_true",
+                      help="compare documented ids against the live Models API (advisory)")
+    parser.add_argument("--json", action="store_true",
+                        help="emit the JSON envelope on stdout")
+    parser.add_argument("--skill-dir", default=None,
+                        help="skill root (default: parent of this script's dir)")
+    parser.add_argument("-q", "--quiet", action="store_true",
+                        help="suppress stderr progress/notes")
+    args = parser.parse_args(argv)
+
+    if args.skill_dir:
+        skill_dir = Path(args.skill_dir).resolve()
+    else:
+        skill_dir = Path(__file__).resolve().parent.parent
+    if not skill_dir.is_dir():
+        print(f"ERROR: skill dir not found: {skill_dir}", file=sys.stderr)
+        return EXIT_NOT_FOUND
+
+    if args.live:
+        result = validate_live(skill_dir, args.json, args.quiet)
+    else:
+        result = validate_offline(skill_dir, args.json, args.quiet)
+
+    if args.json:
+        print(json.dumps({"data": result,
+                          "meta": {"schema": SCHEMA, "status": "ok"}}))
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main(sys.argv[1:]))
+    except KeyboardInterrupt:
+        sys.exit(EXIT_USAGE)

+ 20 - 0
skills/claude-code-ops/SKILL.md

@@ -25,6 +25,26 @@ One skill for the machinery of Claude Code itself: the **hook system**, the **sk
 | `claude -p`; CI scripts; output parsing; stream-json; structured output; background agents | [references/headless-reference.md](references/headless-reference.md) |
 | Anything configured isn't taking effect; plugin validation; /doctor | [references/debugging-reference.md](references/debugging-reference.md) |
 
+## Resources
+
+| Resource | Use |
+|---|---|
+| [scripts/validate-hooks-json.py](scripts/validate-hooks-json.py) | Lint a `hooks.json` (or a settings.json `"hooks"` block) against the 30-event contract before trusting it |
+| [assets/hooks.json.template](assets/hooks.json.template) | Starter `hooks.json` — one of each common pattern (PreToolUse `Bash`, PostToolUse `Edit\|Write`, SessionStart), `${CLAUDE_PLUGIN_ROOT}`-rooted |
+
+**Validate a hooks file** (offline, structural — catches the unknown-event and matcher-as-array footguns the docs warn about):
+
+```bash
+# Lint this repo's own plugin hooks file (default target if no path given):
+python skills/claude-code-ops/scripts/validate-hooks-json.py hooks/hooks.json
+# → exit 0 clean, 10 findings (lists them), 4 malformed JSON, 3 not-found.
+# Machine-readable for CI:
+python skills/claude-code-ops/scripts/validate-hooks-json.py --json hooks/hooks.json | jq '.data[]'
+# --strict makes portability warnings (unrooted command paths) count as findings.
+```
+
+**Start a new hooks file** from `assets/hooks.json.template` — copy it, strip the `//` comment lines (the live `hooks.json` must be strict JSON), then validate the result with the script above.
+
 ## Mental Model
 
 - **Skills** = prompt content loaded on demand (descriptions always in context, body on invoke). Guidance, not guarantees.

+ 63 - 0
skills/claude-code-ops/assets/hooks.json.template

@@ -0,0 +1,63 @@
+// ============================================================================
+// hooks.json.template — starter Claude Code plugin hooks file (JSONC)
+// ----------------------------------------------------------------------------
+// COPY THIS, then STRIP EVERY COMMENT: the live `hooks/hooks.json` must be
+// STRICT JSON (no `//` lines, no trailing commas). Claude Code does NOT parse
+// comments. Save the comment-free result as `hooks/hooks.json` in your plugin.
+//
+// Validate after editing:
+//   python skills/claude-code-ops/scripts/validate-hooks-json.py hooks/hooks.json
+//
+// Contract source of truth: skills/claude-code-ops/references/hooks-reference.md
+// ----------------------------------------------------------------------------
+// Rules this template demonstrates:
+//   * Shape is { "hooks": { <Event>: [ <matcher-group>, ... ] } }.
+//   * Event keys must be in the 30-event catalog (PreToolUse, PostToolUse,
+//     SessionStart, ... — see hooks-reference.md).
+//   * "matcher" is a STRING, never an array. Use "Edit|Write", not ["Edit","Write"].
+//     A "*"/""/omitted matcher matches everything.
+//   * Command paths use ${CLAUDE_PLUGIN_ROOT} (plugin install dir) so they
+//     resolve no matter the cwd. Project hooks use ${CLAUDE_PROJECT_DIR}.
+// ============================================================================
+{
+  "hooks": {
+    // Before every Bash tool call — e.g. block/flag dangerous commands.
+    "PreToolUse": [
+      {
+        "matcher": "Bash",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-bash-check.sh\"",
+            "timeout": 10
+          }
+        ]
+      }
+    ],
+    // After a file is written or edited — note the string "Edit|Write" matcher.
+    "PostToolUse": [
+      {
+        "matcher": "Edit|Write",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/post-edit-format.sh\"",
+            "timeout": 30
+          }
+        ]
+      }
+    ],
+    // At session start — no matcher needed (this event takes none).
+    "SessionStart": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\"",
+            "timeout": 30
+          }
+        ]
+      }
+    ]
+  }
+}

+ 303 - 0
skills/claude-code-ops/scripts/validate-hooks-json.py

@@ -0,0 +1,303 @@
+#!/usr/bin/env python3
+# Lint a hooks.json (or the "hooks" block of a settings.json) against the
+# current Claude Code hook contract. Offline / structural only — no network.
+#
+# Usage:   validate-hooks-json.py [--json] [--strict] [PATH]
+# Input:   PATH to a hooks.json or settings.json (positional). Default: the
+#          repo's hooks/hooks.json if present (resolved from cwd or git root).
+# Output:  stdout = findings (plain text, or JSON envelope with --json) — data only
+# Stderr:  headers, progress, per-finding human framing, summary, errors
+# Exit:    0 clean, 2 usage, 3 file-not-found, 4 malformed-JSON,
+#          10 findings present (DOMAIN SIGNAL — "ran fine, found issues")
+#
+# --strict makes warnings count toward exit 10 (default: only errors do).
+#
+# The 30-event catalog and the matcher/hook-type/output rules enforced here
+# are derived from the authoritative reference shipped alongside this script:
+#   ../references/hooks-reference.md  (the Event Catalog + Hook Types tables).
+# Keep KNOWN_EVENTS / HOOK_TYPES in sync with that file when the contract moves.
+#
+# Examples:
+#   validate-hooks-json.py hooks/hooks.json
+#   validate-hooks-json.py --json .claude/settings.json | jq '.data[]'
+#   validate-hooks-json.py --strict ./hooks.json   # warnings also fail
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+
+SCHEMA = "claude-mods.claude-code-ops.hooks-lint/v1"
+
+EXIT_OK = 0
+EXIT_USAGE = 2
+EXIT_NOT_FOUND = 3
+EXIT_MALFORMED = 4
+EXIT_FINDINGS = 10
+
+# --- Source of truth: ../references/hooks-reference.md "Event Catalog" table. ---
+# The 30 hook events Claude Code recognises (June 2026 contract). An event key
+# outside this set is a finding — almost always a typo or a stale name.
+KNOWN_EVENTS = [
+    "SessionStart", "SessionEnd", "Setup",
+    "UserPromptSubmit", "UserPromptExpansion",
+    "PreToolUse", "PermissionRequest", "PermissionDenied",
+    "PostToolUse", "PostToolUseFailure", "PostToolBatch",
+    "Stop", "StopFailure",
+    "SubagentStart", "SubagentStop",
+    "TaskCreated", "TaskCompleted",
+    "TeammateIdle", "Notification", "MessageDisplay",
+    "ConfigChange", "CwdChanged", "FileChanged",
+    "PreCompact", "PostCompact",
+    "InstructionsLoaded",
+    "WorktreeCreate", "WorktreeRemove",
+    "Elicitation", "ElicitationResult",
+]  # len == 30
+
+# Source of truth: ../references/hooks-reference.md "Hook Types" section.
+HOOK_TYPES = ["command", "http", "mcp_tool", "prompt", "agent"]
+
+# Portability-recommended placeholders for command paths.
+ROOTED_PLACEHOLDERS = ("${CLAUDE_PLUGIN_ROOT}", "${CLAUDE_PROJECT_DIR}",
+                       "${CLAUDE_PLUGIN_DATA}", "${CLAUDE_SKILL_DIR}")
+
+
+class Finding:
+    __slots__ = ("pointer", "severity", "message")
+
+    def __init__(self, pointer, severity, message):
+        self.pointer = pointer
+        self.severity = severity  # "error" | "warning"
+        self.message = message
+
+    def as_dict(self):
+        return {"pointer": self.pointer, "severity": self.severity,
+                "message": self.message}
+
+
+def add(findings, pointer, severity, message):
+    findings.append(Finding(pointer, severity, message))
+
+
+def looks_like_permission_rule(s):
+    # Permission-rule syntax: "Tool(args)" e.g. Bash(git *), Edit(*.ts).
+    if not isinstance(s, str) or "(" not in s or not s.endswith(")"):
+        return False
+    head = s.split("(", 1)[0]
+    return bool(head) and head[0].isalpha()
+
+
+def check_hook_entry(findings, entry, ptr):
+    if not isinstance(entry, dict):
+        add(findings, ptr, "error",
+            "hook entry must be an object, got %s" % type(entry).__name__)
+        return
+    htype = entry.get("type")
+    if htype is None:
+        add(findings, ptr, "error", "hook entry missing 'type'")
+    elif htype not in HOOK_TYPES:
+        add(findings, ptr, "error",
+            "unknown hook type %r (expected one of: %s)"
+            % (htype, ", ".join(HOOK_TYPES)))
+
+    if htype == "command":
+        cmd = entry.get("command")
+        if not cmd or not isinstance(cmd, str):
+            add(findings, ptr, "error",
+                "command hook must have a non-empty string 'command'")
+        elif not any(p in cmd for p in ROOTED_PLACEHOLDERS):
+            add(findings, ptr, "warning",
+                "command path is not rooted at ${CLAUDE_PLUGIN_ROOT}/"
+                "${CLAUDE_PROJECT_DIR} — may break when cwd varies")
+    elif htype == "http":
+        if not entry.get("url"):
+            add(findings, ptr, "error", "http hook must have a 'url'")
+    elif htype == "mcp_tool":
+        if not entry.get("server") or not entry.get("tool"):
+            add(findings, ptr, "error",
+                "mcp_tool hook must have 'server' and 'tool'")
+    elif htype in ("prompt", "agent"):
+        if not entry.get("prompt"):
+            add(findings, ptr, "warning",
+                "%s hook usually needs a 'prompt'" % htype)
+
+    iff = entry.get("if")
+    if iff is not None:
+        if not isinstance(iff, str):
+            add(findings, ptr, "error", "'if' filter must be a string")
+        elif not looks_like_permission_rule(iff):
+            add(findings, ptr, "warning",
+                "'if' filter %r does not look like a permission rule "
+                "(e.g. \"Bash(git *)\", \"Edit(*.ts)\")" % iff)
+
+
+def check_matcher_group(findings, group, ptr):
+    if not isinstance(group, dict):
+        add(findings, ptr, "error",
+            "matcher group must be an object, got %s" % type(group).__name__)
+        return
+    if "matcher" in group and not isinstance(group["matcher"], str):
+        if isinstance(group["matcher"], list):
+            add(findings, ptr + "/matcher", "error",
+                "'matcher' must be a STRING (use \"Edit|Write\"), not an array "
+                "— an array is a schema error and the hook is silently dropped")
+        else:
+            add(findings, ptr + "/matcher", "error",
+                "'matcher' must be a string, got %s"
+                % type(group["matcher"]).__name__)
+    hooks = group.get("hooks")
+    if hooks is None:
+        add(findings, ptr, "error", "matcher group missing 'hooks' list")
+    elif not isinstance(hooks, list):
+        add(findings, ptr + "/hooks", "error",
+            "'hooks' must be a list, got %s" % type(hooks).__name__)
+    else:
+        for i, entry in enumerate(hooks):
+            check_hook_entry(findings, entry, "%s/hooks/%d" % (ptr, i))
+
+
+def lint(doc):
+    """Return list[Finding] for a parsed hooks.json / settings.json document."""
+    findings = []
+    if not isinstance(doc, dict):
+        add(findings, "", "error",
+            "top-level value must be an object "
+            '({"hooks": {...}} or a bare event map)')
+        return findings
+
+    # Accept either {"hooks": {<Event>: [...]}} or a bare event map.
+    if "hooks" in doc and isinstance(doc["hooks"], dict):
+        events = doc["hooks"]
+        base = "/hooks"
+    else:
+        # Bare event map only if keys look like events; otherwise flag shape.
+        keys = list(doc.keys())
+        if keys and any(k in KNOWN_EVENTS for k in keys):
+            events = doc
+            base = ""
+        else:
+            add(findings, "", "error",
+                'expected {"hooks": {<Event>: [...]}} or a bare event map; '
+                "found object with keys: %s" % ", ".join(keys) or "(empty)")
+            return findings
+
+    for event, groups in events.items():
+        eptr = "%s/%s" % (base, event)
+        if event not in KNOWN_EVENTS:
+            add(findings, eptr, "error",
+                "unknown hook event %r — not in the 30-event catalog "
+                "(see references/hooks-reference.md)" % event)
+            # still structurally validate its groups below
+        if not isinstance(groups, list):
+            add(findings, eptr, "error",
+                "event value must be a list of matcher groups, got %s"
+                % type(groups).__name__)
+            continue
+        for i, group in enumerate(groups):
+            check_matcher_group(findings, group, "%s/%d" % (eptr, i))
+    return findings
+
+
+def default_path():
+    """Repo's hooks/hooks.json: try cwd, then git toplevel."""
+    cand = os.path.join(os.getcwd(), "hooks", "hooks.json")
+    if os.path.isfile(cand):
+        return cand
+    try:
+        top = subprocess.run(
+            ["git", "rev-parse", "--show-toplevel"],
+            capture_output=True, text=True, timeout=5)
+        if top.returncode == 0:
+            cand = os.path.join(top.stdout.strip(), "hooks", "hooks.json")
+            if os.path.isfile(cand):
+                return cand
+    except (OSError, subprocess.SubprocessError):
+        pass
+    return None
+
+
+def main(argv):
+    p = argparse.ArgumentParser(
+        prog="validate-hooks-json.py",
+        description="Lint a hooks.json / settings.json hooks block against "
+                    "the Claude Code hook contract (offline, structural).",
+        epilog="EXAMPLES:\n"
+               "  validate-hooks-json.py hooks/hooks.json\n"
+               "  validate-hooks-json.py --json .claude/settings.json | jq '.data[]'\n"
+               "  validate-hooks-json.py --strict ./hooks.json\n"
+               "\nEXIT: 0 clean, 2 usage, 3 not-found, 4 malformed-JSON, "
+               "10 findings present.",
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    p.add_argument("path", nargs="?",
+                   help="hooks.json or settings.json (default: repo hooks/hooks.json)")
+    p.add_argument("--json", action="store_true",
+                   help="emit a JSON envelope (schema %s)" % SCHEMA)
+    p.add_argument("--strict", action="store_true",
+                   help="count warnings toward the exit-10 signal")
+    try:
+        args = p.parse_args(argv)
+    except SystemExit as e:
+        # argparse exits 0 for --help (good), 2 for bad args (matches USAGE).
+        return e.code if e.code is not None else EXIT_USAGE
+
+    path = args.path or default_path()
+    if not path:
+        msg = ("no path given and no repo hooks/hooks.json found "
+               "(pass a path explicitly)")
+        if args.json:
+            print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}}))
+        print("ERROR: %s" % msg, file=sys.stderr)
+        return EXIT_NOT_FOUND
+
+    if not os.path.isfile(path):
+        msg = "file not found: %s" % path
+        if args.json:
+            print(json.dumps({"error": {"code": "NOT_FOUND", "message": msg}}))
+        print("ERROR: %s" % msg, file=sys.stderr)
+        return EXIT_NOT_FOUND
+
+    try:
+        with open(path, "r", encoding="utf-8") as fh:
+            doc = json.load(fh)
+    except (json.JSONDecodeError, UnicodeDecodeError) as e:
+        msg = "malformed JSON in %s: %s" % (path, e)
+        if args.json:
+            print(json.dumps({"error": {"code": "VALIDATION", "message": msg}}))
+        print("ERROR: %s" % msg, file=sys.stderr)
+        return EXIT_MALFORMED
+
+    print("=== hooks-lint: %s ===" % path, file=sys.stderr)
+    findings = lint(doc)
+
+    errors = [f for f in findings if f.severity == "error"]
+    warnings = [f for f in findings if f.severity == "warning"]
+
+    if args.json:
+        print(json.dumps({
+            "data": [f.as_dict() for f in findings],
+            "meta": {"count": len(findings),
+                     "errors": len(errors), "warnings": len(warnings),
+                     "path": path, "schema": SCHEMA},
+        }, indent=2))
+    else:
+        for f in findings:
+            print("%s\t%s\t%s" % (f.severity, f.pointer or "/", f.message))
+
+    # Human framing → stderr.
+    for f in findings:
+        tag = "ERROR" if f.severity == "error" else "warn "
+        print("  [%s] %s: %s" % (tag, f.pointer or "/", f.message),
+              file=sys.stderr)
+    if not findings:
+        print("  clean — no findings", file=sys.stderr)
+    print("--- %d error(s), %d warning(s) ---" % (len(errors), len(warnings)),
+          file=sys.stderr)
+
+    if errors or (args.strict and warnings):
+        return EXIT_FINDINGS
+    return EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 15 - 0
skills/playwright-ops/SKILL.md

@@ -230,6 +230,20 @@ that only passes single-worker is broken, not "sensitive".
 network, console per action). Local: `npx playwright test --ui` or `PWDEBUG=1` / `page.pause()`.
 Repro: `--repeat-each=20 --workers=4`. Playbook: [references/flake-hunting.md](references/flake-hunting.md)
 
+**Triage a whole run without eyeballing the report** — generate the JSON reporter output, then
+rank the offenders with the bundled triage tool ([scripts/triage-flakes.py](scripts/triage-flakes.py)):
+
+```bash
+npx playwright test --reporter=json > results.json   # or reporter: [['json', { outputFile: 'results.json' }]]
+scripts/triage-flakes.py results.json                # flaky tests first, then hard fails
+```
+
+It emits a ranked TSV (or `--json` envelope, schema `claude-mods.playwright-ops.flake-triage/v1`):
+flaky tests (passed only on retry) first — ordered by retry count then duration — followed by
+`unexpected` hard failures, each with `file:line`, the status sequence (`failed->passed`), and total
+duration. **Exit 10 means flakes/fails were found** (the triage signal — go fix them); exit 0 means a
+clean suite. `--outcome all` includes the passing tests for context; `-n N` caps rows.
+
 ## CI (GitHub Actions)
 
 ```yaml
@@ -312,4 +326,5 @@ automation — distinct from the test runner; don't conflate browsing automation
 | [references/network-and-api.md](references/network-and-api.md) | route/fulfill/abort, HAR replay, API testing, hybrid seeding, WebSocket |
 | [references/ci-patterns.md](references/ci-patterns.md) | Full GH Actions workflows: basic, sharded+merge, container, caching, reporters |
 | [references/flake-hunting.md](references/flake-hunting.md) | Systematic flake diagnosis: traces, repro loops, common causes + fixes |
+| [scripts/triage-flakes.py](scripts/triage-flakes.py) | Parse a Playwright JSON report and rank flaky/failing tests (exit 10 = findings); see Flake diagnosis above |
 | [assets/playwright.config.template.ts](assets/playwright.config.template.ts) | Commented production config template |

+ 225 - 0
skills/playwright-ops/scripts/triage-flakes.py

@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+# Rank Playwright tests by flakiness from a JSON report so the agent triages, not eyeballs.
+#
+# Parses a Playwright JSON report (`--reporter=json`) and surfaces the tests
+# worth a human's attention: flaky tests (passed only on retry) first, then
+# hard "unexpected" failures. Flaky tests are ranked by retry count desc, then
+# total duration desc, because the most-retried, slowest test is the worst
+# offender in your queue.
+#
+# Usage:   triage-flakes.py [OPTIONS] [REPORT]
+# Input:   REPORT = path to a Playwright JSON report (positional, default ./results.json)
+# Output:  stdout = ranked findings (TSV, or JSON envelope with --json)
+# Stderr:  headers, summary, progress, errors
+# Exit:    0 parsed fine, no flaky/unexpected tests (clean suite)
+#          2 usage, 3 file not found, 4 malformed/not a Playwright report,
+#          10 DOMAIN SIGNAL: flaky/unexpected tests present (the thing being triaged)
+#
+# Examples:
+#   npx playwright test --reporter=json > results.json
+#   triage-flakes.py results.json
+#   triage-flakes.py --outcome all -n 50 results.json
+#   triage-flakes.py --json results.json | jq '.data[] | select(.outcome=="flaky")'
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+SCHEMA = "claude-mods.playwright-ops.flake-triage/v1"
+
+EXIT_OK = 0
+EXIT_USAGE = 2
+EXIT_NOT_FOUND = 3
+EXIT_VALIDATION = 4
+EXIT_FINDINGS = 10
+
+# Rank order for outcomes: flaky always sorts before unexpected.
+OUTCOME_RANK = {"flaky": 0, "unexpected": 1}
+
+
+def err(msg):
+    print(msg, file=sys.stderr)
+
+
+def walk_suites(suites, finds, file_hint=""):
+    """Recursively descend the suites tree collecting spec/test results."""
+    for suite in suites or []:
+        # A suite's file is on the suite node; specs inherit it.
+        sfile = suite.get("file") or file_hint
+        for spec in suite.get("specs", []) or []:
+            collect_spec(spec, finds, sfile)
+        walk_suites(suite.get("suites"), finds, sfile)
+
+
+def collect_spec(spec, finds, sfile):
+    title = spec.get("title", "<untitled>")
+    sline = spec.get("line", 0)
+    sfile = spec.get("file") or sfile
+    for test in spec.get("tests", []) or []:
+        outcome = test.get("status") or test.get("outcome") or "unknown"
+        results = test.get("results", []) or []
+        # status sequence ordered by retry index; duration summed across attempts
+        ordered = sorted(results, key=lambda r: r.get("retry", 0))
+        statuses = [r.get("status", "unknown") for r in ordered]
+        duration = sum(int(r.get("duration", 0) or 0) for r in ordered)
+        retries = max((r.get("retry", 0) for r in ordered), default=0)
+        location = f"{sfile}:{sline}" if sfile else f"?:{sline}"
+        finds.append(
+            {
+                "title": title,
+                "location": location,
+                "outcome": outcome,
+                "retries": retries,
+                "statuses": statuses,
+                "durationMs": duration,
+            }
+        )
+
+
+def load_report(path):
+    """Return parsed Playwright report dict, or raise ValueError if not one."""
+    try:
+        raw = path.read_text(encoding="utf-8")
+    except OSError as e:
+        raise FileNotFoundError(str(e))
+    try:
+        data = json.loads(raw)
+    except json.JSONDecodeError as e:
+        raise ValueError(f"not valid JSON: {e}")
+    if not isinstance(data, dict) or "suites" not in data:
+        raise ValueError("missing top-level 'suites' key - not a Playwright JSON report")
+    if not isinstance(data["suites"], list):
+        raise ValueError("'suites' is not a list — not a Playwright JSON report")
+    return data
+
+
+def main(argv=None):
+    p = argparse.ArgumentParser(
+        prog="triage-flakes.py",
+        description="Rank Playwright tests by flakiness from a JSON report.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=(
+            "EXAMPLES:\n"
+            "  npx playwright test --reporter=json > results.json\n"
+            "  triage-flakes.py results.json\n"
+            "  triage-flakes.py --outcome all -n 50 results.json\n"
+            "  triage-flakes.py --json results.json | jq '.data[] | select(.outcome==\"flaky\")'\n"
+            "\n"
+            "EXIT CODES:\n"
+            "  0  parsed fine, no flaky/unexpected tests (clean suite)\n"
+            "  2  usage   3  file not found   4  malformed report\n"
+            "  10 flaky/unexpected tests present (the triage signal)\n"
+        ),
+    )
+    p.add_argument(
+        "report",
+        nargs="?",
+        default="results.json",
+        help="path to Playwright JSON report (default: ./results.json)",
+    )
+    p.add_argument("--json", action="store_true", help="emit a JSON envelope instead of TSV")
+    p.add_argument(
+        "-n",
+        "--limit",
+        type=int,
+        default=20,
+        metavar="N",
+        help="cap rows printed (default 20)",
+    )
+    p.add_argument(
+        "--outcome",
+        default="flaky,unexpected",
+        help="which outcomes to include: flaky | unexpected | all (default flaky,unexpected)",
+    )
+    args = p.parse_args(argv)
+
+    if args.limit < 0:
+        err("ERROR: --limit must be >= 0")
+        return EXIT_USAGE
+
+    sel = args.outcome.strip().lower()
+    if sel == "all":
+        wanted = None  # all outcomes
+    else:
+        wanted = {x.strip() for x in sel.split(",") if x.strip()}
+        unknown = wanted - {"flaky", "unexpected", "expected", "skipped"}
+        if unknown:
+            err(f"ERROR: unknown outcome(s): {', '.join(sorted(unknown))} (use flaky|unexpected|all)")
+            return EXIT_USAGE
+
+    path = Path(args.report).resolve()
+    if not path.exists():
+        err(f"ERROR: report not found: {path}")
+        if args.json:
+            print(json.dumps({"error": {"code": "NOT_FOUND", "message": f"report not found: {path}"}}))
+        return EXIT_NOT_FOUND
+    if not path.is_file():
+        err(f"ERROR: not a file: {path}")
+        return EXIT_NOT_FOUND
+
+    try:
+        data = load_report(path)
+    except FileNotFoundError as e:
+        err(f"ERROR: cannot read report: {e}")
+        return EXIT_NOT_FOUND
+    except ValueError as e:
+        err(f"ERROR: malformed report: {e}")
+        if args.json:
+            print(json.dumps({"error": {"code": "VALIDATION", "message": str(e)}}))
+        return EXIT_VALIDATION
+
+    finds = []
+    walk_suites(data.get("suites"), finds)
+
+    # The domain signal is computed over ALL findings, regardless of the display
+    # filter — a clean suite means zero flaky AND zero unexpected, full stop.
+    signal_present = any(f["outcome"] in ("flaky", "unexpected") for f in finds)
+
+    if wanted is None:
+        shown = list(finds)
+    else:
+        shown = [f for f in finds if f["outcome"] in wanted]
+
+    # Rank: flaky before unexpected (OUTCOME_RANK), then retries desc, duration desc.
+    shown.sort(
+        key=lambda f: (
+            OUTCOME_RANK.get(f["outcome"], 99),
+            -f["retries"],
+            -f["durationMs"],
+        )
+    )
+
+    capped = shown[: args.limit] if args.limit else shown
+
+    total = len(finds)
+    flaky_n = sum(1 for f in finds if f["outcome"] == "flaky")
+    unexp_n = sum(1 for f in finds if f["outcome"] == "unexpected")
+    err(f"=== Flake triage: {path.name} ===")
+    err(f"  {total} tests | {flaky_n} flaky | {unexp_n} unexpected | showing {len(capped)} of {len(shown)}")
+
+    if args.json:
+        envelope = {
+            "data": capped,
+            "meta": {
+                "count": len(capped),
+                "total_matched": len(shown),
+                "flaky": flaky_n,
+                "unexpected": unexp_n,
+                "schema": SCHEMA,
+            },
+        }
+        print(json.dumps(envelope, indent=2))
+    else:
+        print("outcome\tretries\tstatuses\tduration_ms\tlocation\ttitle")
+        for f in capped:
+            print(
+                f"{f['outcome']}\t{f['retries']}\t{'->'.join(f['statuses'])}\t"
+                f"{f['durationMs']}\t{f['location']}\t{f['title']}"
+            )
+
+    return EXIT_FINDINGS if signal_present else EXIT_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 22 - 0
skills/terraform-ops/SKILL.md

@@ -23,6 +23,9 @@ Terraform / OpenTofu infrastructure-as-code: layout, state, modules, safety, CI/
 | [references/cicd-pipelines.md](references/cicd-pipelines.md) | GitHub Actions plan/apply, OIDC auth, policy gates (tflint/trivy/checkov/OPA), Atlantis/HCP |
 | [references/security-and-secrets.md](references/security-and-secrets.md) | Secrets in state, ephemeral resources, write-only arguments, SOPS/Vault, sensitive limits |
 | [assets/github-actions-terraform.yml](assets/github-actions-terraform.yml) | Ready-to-adapt PR-plan + OIDC-apply workflow |
+| [scripts/check-action-refs.sh](scripts/check-action-refs.sh) | Staleness verifier for any workflow's `uses:` action refs (offline structural / live API resolve) |
+
+> The action versions pinned in `github-actions-terraform.yml` are **point-in-time** (verified 2026-06). Run `scripts/check-action-refs.sh --live` before adopting — a tag that was valid at write time may have been retracted or never existed (e.g. `trivy-action@0.33.1` vs the real `v0.33.1`).
 
 ## Project Layout Decision Tree
 
@@ -183,6 +186,25 @@ Nightly     → plan -detailed-exitcode → exit 2 ⇒ drift alert
 | HCP Terraform / Terraform Cloud | Managed runs, Sentinel policy, state hosting; free ≤500 resources |
 | Spacelift / env0 / Digger / Scalr | Commercial Atlantis-likes; Digger runs inside your Actions |
 
+### Verification — `uses:` ref staleness
+
+GitHub Action versions rot: a tag gets retracted, or a workflow pins one that never existed. [scripts/check-action-refs.sh](scripts/check-action-refs.sh) lints every `uses: owner/repo@ref` line. It's **general** — pass any workflow file(s) as positionals (default: this skill's own `assets/github-actions-terraform.yml`).
+
+```bash
+# Structural only, no network — well-formedness of every uses: ref (CI-safe gate).
+# Floating @main/@master → WARN (exit 0; use --strict to fail). Malformed → exit 4.
+scripts/check-action-refs.sh --offline .github/workflows/ci.yml
+
+# Live — resolve each ref against the GitHub API. A 404 (ref doesn't exist) → exit 10
+# DRIFT; API unreachable/rate-limited → exit 7 (advisory, never fails the build, §7).
+# Set GITHUB_TOKEN to dodge the unauthenticated rate limit.
+GITHUB_TOKEN=$GH_PAT scripts/check-action-refs.sh --live .github/workflows/*.yml
+
+scripts/check-action-refs.sh --json --offline | jq '.data[] | select(.status!="ok")'
+```
+
+`--live` is the check that catches the classic `aquasecurity/trivy-action@0.33.1` mistake — that tag 404s; the real one is `v0.33.1`. Run live on a schedule (never as a blocking PR gate), offline in PR CI.
+
 ## Testing Quick Reference
 
 ```hcl

+ 251 - 0
skills/terraform-ops/scripts/check-action-refs.sh

@@ -0,0 +1,251 @@
+#!/usr/bin/env bash
+# Staleness verifier for GitHub Actions `uses:` references in workflow YAML.
+#
+# Lints every `uses: owner/repo@ref` line in one or more workflow files. General-
+# purpose: not terraform-specific — point it at any GitHub Actions workflow. Two
+# modes per the staleness-verifier pattern (SKILL-RESOURCE-PROTOCOL §7):
+#   --offline (default): structural-only, NO network. Asserts every `uses:` is
+#                        well-formed; floating @main/@master refs are a WARNING.
+#   --live:              resolves every owner/repo@ref against the GitHub API.
+#                        A 404 (ref does not exist) is DRIFT; rate-limit/offline
+#                        is UNAVAILABLE (advisory, never a build failure).
+#
+# Usage:   check-action-refs.sh [--offline|--live] [--strict] [--json] [-q] [FILE ...]
+# Input:   workflow YAML paths as positionals (default: the skill's own
+#          assets/github-actions-terraform.yml). Pure grep/sed extraction — no
+#          YAML library dependency.
+# Output:  stdout = data only (findings list, or JSON envelope with --json)
+# Stderr:  headers, progress, warnings, errors
+# Exit:    0 ok, 2 usage, 3 not-found, 4 malformed-uses, 5 missing-dep,
+#          7 api-unavailable (live), 10 drift (live: a ref does not resolve)
+#
+# Examples:
+#   check-action-refs.sh --offline
+#   check-action-refs.sh --offline .github/workflows/ci.yml
+#   GITHUB_TOKEN=ghp_xxx check-action-refs.sh --live ci.yml deploy.yml
+#   check-action-refs.sh --json --offline | jq '.data[] | select(.status!="ok")'
+
+set -uo pipefail
+
+EXIT_OK=0; EXIT_USAGE=2; EXIT_NOT_FOUND=3; EXIT_MALFORMED=4
+EXIT_MISSING_DEP=5; EXIT_UNAVAILABLE=7; EXIT_DRIFT=10
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+DEFAULT_FILE="${SCRIPT_DIR}/../assets/github-actions-terraform.yml"
+
+MODE="offline"; STRICT=0; JSON=0; QUIET=0; FILES=()
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --offline)   MODE="offline" ;;
+    --live)      MODE="live" ;;
+    --strict)    STRICT=1 ;;
+    --json)      JSON=1 ;;
+    -q|--quiet)  QUIET=1 ;;
+    -h|--help)   sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit "$EXIT_OK" ;;
+    -*)  echo "ERROR: unknown flag: $1 (try --help)" >&2; exit "$EXIT_USAGE" ;;
+    *)   FILES+=("$1") ;;
+  esac
+  shift
+done
+
+[[ ${#FILES[@]} -eq 0 ]] && FILES=("$DEFAULT_FILE")
+
+command -v grep >/dev/null 2>&1 || { echo "ERROR: grep required" >&2; exit "$EXIT_MISSING_DEP"; }
+HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
+[[ "$JSON" -eq 1 && "$HAS_JQ" -eq 0 ]] && {
+  echo '{"error":{"code":"PRECONDITION","message":"jq required for --json"}}'
+  echo "ERROR: jq required for --json" >&2; exit "$EXIT_MISSING_DEP"; }
+if [[ "$MODE" == "live" ]]; then
+  command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required for --live" >&2; exit "$EXIT_MISSING_DEP"; }
+fi
+
+emit() { [[ "$QUIET" -eq 1 ]] && return; printf '%s\n' "$1" >&2; }
+
+# State accumulators
+malformed=0; drift=0; unavailable=0; warned=0
+declare -a JSON_OBJS=()
+declare -a TEXT_ROWS=()
+
+# --- classify a `uses:` value -------------------------------------------------
+# Sets globals: C_STATUS (ok|warn|malformed), C_OWNER, C_REPO, C_REF, C_KIND
+classify_uses() {
+  local v=$1
+  C_OWNER=""; C_REPO=""; C_REF=""; C_KIND=""; C_STATUS="ok"
+  # Local action: ./path  — always valid, no network
+  if [[ "$v" == ./* ]]; then C_KIND="local"; return; fi
+  # Docker action: docker://image[:tag]  — out of scope for ref resolution
+  if [[ "$v" == docker://* ]]; then C_KIND="docker"; return; fi
+  # Must contain an @ separating owner/repo[/path] from ref
+  if [[ "$v" != *"@"* ]]; then C_STATUS="malformed"; C_KIND="action"; return; fi
+  local path="${v%@*}" ref="${v#*@}"
+  C_REF="$ref"
+  # Empty ref, or empty path
+  if [[ -z "$ref" || -z "$path" ]]; then C_STATUS="malformed"; C_KIND="action"; return; fi
+  # path must be owner/repo[/subpath...]
+  if [[ "$path" != */* ]]; then C_STATUS="malformed"; C_KIND="action"; return; fi
+  C_OWNER="${path%%/*}"
+  local rest="${path#*/}"
+  C_REPO="${rest%%/*}"
+  C_KIND="action"
+  if [[ -z "$C_OWNER" || -z "$C_REPO" ]]; then C_STATUS="malformed"; return; fi
+  # Validate ref shape: tag (vN / vN.N.N...), 40-hex SHA, or branch name.
+  if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
+    C_STATUS="ok"          # pinned SHA — best practice
+  elif [[ "$ref" == "main" || "$ref" == "master" ]]; then
+    C_STATUS="warn"        # floating default branch — advisory
+  elif [[ "$ref" =~ ^v[0-9]+([._][0-9]+)*$ ]]; then
+    C_STATUS="ok"          # version tag
+  elif [[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]]; then
+    C_STATUS="ok"          # other tag/branch name — structurally fine
+  else
+    C_STATUS="malformed"
+  fi
+}
+
+# --- live resolution: does owner/repo@ref exist on GitHub? --------------------
+# Echoes one of: resolved | notfound | unavailable
+resolve_ref() {
+  local owner=$1 repo=$2 ref=$3
+  local -a auth=()
+  [[ -n "${GITHUB_TOKEN:-}" ]] && auth=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
+  local base="https://api.github.com/repos/${owner}/${repo}"
+  local code body url
+
+  try() {
+    local u=$1 out
+    out=$(curl -sS -w $'\n%{http_code}' \
+      -H "Accept: application/vnd.github+json" \
+      -H "X-GitHub-Api-Version: 2022-11-28" \
+      "${auth[@]}" "$u" 2>/dev/null)
+    [[ -z "$out" ]] && { echo "000"; return; }
+    code="${out##*$'\n'}"; body="${out%$'\n'*}"
+    echo "$code"
+  }
+
+  if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
+    url="${base}/commits/${ref}"
+  else
+    url="${base}/git/ref/tags/${ref}"
+  fi
+  code=$(try "$url")
+  # Network failure
+  [[ "$code" == "000" ]] && { echo "unavailable"; return; }
+  # Rate limit — advisory, never drift
+  if [[ "$code" == "403" || "$code" == "429" ]]; then echo "unavailable"; return; fi
+  if [[ "$code" == "200" ]]; then echo "resolved"; return; fi
+  # Tag lookup 404'd — fall back to a commit/branch lookup before declaring drift.
+  # A non-SHA ref can still be a branch name; the commits endpoint resolves those.
+  if [[ "$code" == "404" && ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then
+    code=$(try "${base}/commits/${ref}")
+    [[ "$code" == "000" ]] && { echo "unavailable"; return; }
+    if [[ "$code" == "403" || "$code" == "429" ]]; then echo "unavailable"; return; fi
+    [[ "$code" == "200" ]] && { echo "resolved"; return; }
+    # 404 (no such branch) or 422 (unparseable commit-ish) — the ref does not exist
+    [[ "$code" == "404" || "$code" == "422" ]] && { echo "notfound"; return; }
+    echo "unavailable"; return
+  fi
+  # Direct SHA lookup that 404/422'd, or a tag 404 with no branch fallback path
+  [[ "$code" == "404" || "$code" == "422" ]] && { echo "notfound"; return; }
+  echo "unavailable"
+}
+
+add_json() {  # file line ref status
+  [[ "$HAS_JQ" -eq 1 ]] || return
+  JSON_OBJS+=("$(jq -cn --arg f "$1" --argjson l "$2" --arg r "$3" --arg s "$4" \
+    '{file:$f, line:$l, ref:$r, status:$s}')")
+}
+
+emit "=== check-action-refs (${MODE}) ==="
+
+for f in "${FILES[@]}"; do
+  if [[ ! -f "$f" ]]; then
+    emit "ERROR: file not found: $f"
+    # In JSON mode still report a structured error per §5
+    if [[ "$JSON" -eq 1 ]]; then
+      echo "{\"error\":{\"code\":\"NOT_FOUND\",\"message\":\"file not found: $f\"}}"
+    fi
+    exit "$EXIT_NOT_FOUND"
+  fi
+
+  # Extract every `uses:` value with its 1-based line number. Strip inline
+  # comments and surrounding quotes. grep -n gives "LINE:content".
+  while IFS= read -r entry; do
+    [[ -z "$entry" ]] && continue
+    lineno="${entry%%:*}"
+    rawline="${entry#*:}"
+    # value after `uses:` — drop leading `- ` list dash if present
+    val="${rawline#*uses:}"
+    val="${val%%#*}"                       # strip trailing comment
+    # trim whitespace
+    val="${val#"${val%%[![:space:]]*}"}"
+    val="${val%"${val##*[![:space:]]}"}"
+    # strip surrounding quotes
+    val="${val%\"}"; val="${val#\"}"
+    val="${val%\'}"; val="${val#\'}"
+    [[ -z "$val" ]] && continue
+
+    classify_uses "$val"
+
+    case "$C_STATUS" in
+      malformed)
+        malformed=1
+        emit "  [MALFORMED] ${f}:${lineno}  uses: ${val}"
+        TEXT_ROWS+=("${f}:${lineno}	${val}	malformed")
+        add_json "$f" "$lineno" "$val" "malformed"
+        ;;
+      warn)
+        warned=1
+        emit "  [WARN floating] ${f}:${lineno}  ${val}  (prefer SHA pin)"
+        TEXT_ROWS+=("${f}:${lineno}	${val}	warn")
+        add_json "$f" "$lineno" "$val" "warn"
+        ;;
+      ok)
+        if [[ "$MODE" == "live" && "$C_KIND" == "action" ]]; then
+          res=$(resolve_ref "$C_OWNER" "$C_REPO" "$C_REF")
+          case "$res" in
+            resolved)
+              emit "  [ok] ${f}:${lineno}  ${C_OWNER}/${C_REPO}@${C_REF}"
+              TEXT_ROWS+=("${f}:${lineno}	${val}	ok")
+              add_json "$f" "$lineno" "$val" "ok" ;;
+            notfound)
+              drift=1
+              emit "  [DRIFT 404] ${f}:${lineno}  ${C_OWNER}/${C_REPO}@${C_REF}"
+              TEXT_ROWS+=("${f}:${lineno}	${val}	drift")
+              add_json "$f" "$lineno" "$val" "drift" ;;
+            unavailable)
+              unavailable=1
+              emit "  [unavailable] ${f}:${lineno}  ${C_OWNER}/${C_REPO}@${C_REF} (API unreachable/rate-limited)"
+              TEXT_ROWS+=("${f}:${lineno}	${val}	unavailable")
+              add_json "$f" "$lineno" "$val" "unavailable" ;;
+          esac
+        else
+          TEXT_ROWS+=("${f}:${lineno}	${val}	ok")
+          add_json "$f" "$lineno" "$val" "ok"
+        fi
+        ;;
+    esac
+  done < <(grep -nE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*' "$f" 2>/dev/null)
+done
+
+# --- output -------------------------------------------------------------------
+if [[ "$JSON" -eq 1 ]]; then
+  printf '%s\n' "${JSON_OBJS[@]:-}" | jq -s \
+    --arg mode "$MODE" \
+    '{data: map(select(length>0)),
+      meta: {mode:$mode,
+             count:(map(select(length>0))|length),
+             schema:"claude-mods.terraform-ops.action-refs/v1"}}'
+else
+  # plain text: data rows to stdout (only the non-ok findings are interesting,
+  # but emit all rows so the agent sees the full inventory)
+  for row in "${TEXT_ROWS[@]:-}"; do
+    [[ -n "$row" ]] && printf '%s\n' "$row"
+  done
+fi
+
+# --- exit ---------------------------------------------------------------------
+[[ "$malformed" -eq 1 ]] && exit "$EXIT_MALFORMED"
+[[ "$drift" -eq 1 ]] && exit "$EXIT_DRIFT"
+[[ "$unavailable" -eq 1 ]] && exit "$EXIT_UNAVAILABLE"
+if [[ "$warned" -eq 1 && "$STRICT" -eq 1 ]]; then exit "$EXIT_MALFORMED"; fi
+exit "$EXIT_OK"

+ 58 - 0
tests/check-resources.sh

@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Offline resource checks — runs in PR CI, may block.
+#
+# Exercises the skill verifier/scanner scripts in their OFFLINE/structural mode
+# (no network) and asserts basic protocol compliance (SKILL-RESOURCE-PROTOCOL.md):
+# every shipped verifier responds to --help with exit 0 and passes its own
+# offline self-check against the skill's current content.
+#
+# The network-dependent --live drift checks run in the scheduled freshness
+# workflow, never here — a rate-limit must never block an unrelated PR (§7).
+#
+# Exit: 0 all checks pass, 1 a check failed.
+set -uo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT" || exit 1
+
+# Pick a working python (Windows Store python3 stub exits 49 on --version).
+PY="python3"
+if ! "$PY" --version >/dev/null 2>&1; then PY="python"; fi
+
+fail=0
+pass() { echo "  ok   $*"; }
+bad()  { echo "  FAIL $*"; fail=1; }
+
+run() { # description, expected-exit, command...
+    local desc="$1" want="$2"; shift 2
+    "$@" >/dev/null 2>&1; local got=$?
+    if [ "$got" -eq "$want" ]; then pass "$desc (exit $got)"; else bad "$desc (want $want, got $got)"; fi
+}
+
+echo "== claude-api-ops: model-table verifier"
+run "model-table --offline consistent" 0 "$PY" skills/claude-api-ops/scripts/check-model-table.py --offline
+run "model-table --help"               0 "$PY" skills/claude-api-ops/scripts/check-model-table.py --help
+
+echo "== terraform-ops: action-ref verifier"
+run "action-refs --offline well-formed" 0 bash skills/terraform-ops/scripts/check-action-refs.sh --offline
+run "action-refs --help"                0 bash skills/terraform-ops/scripts/check-action-refs.sh --help
+
+echo "== claude-code-ops: hooks.json validator"
+run "hooks-lint clean on repo hooks.json" 0 "$PY" skills/claude-code-ops/scripts/validate-hooks-json.py hooks/hooks.json
+run "hooks-lint --help"                   0 "$PY" skills/claude-code-ops/scripts/validate-hooks-json.py --help
+
+echo "== playwright-ops: flake-triage"
+run "flake-triage --help" 0 "$PY" skills/playwright-ops/scripts/triage-flakes.py --help
+
+echo "== protocol: every new verifier is executable + compiles"
+for s in skills/claude-api-ops/scripts/check-model-table.py \
+         skills/claude-code-ops/scripts/validate-hooks-json.py \
+         skills/playwright-ops/scripts/triage-flakes.py; do
+    "$PY" -m py_compile "$s" 2>/dev/null && pass "py_compile $(basename "$s")" || bad "py_compile $(basename "$s")"
+done
+bash -n skills/terraform-ops/scripts/check-action-refs.sh 2>/dev/null \
+    && pass "bash -n check-action-refs.sh" || bad "bash -n check-action-refs.sh"
+
+echo
+if [ "$fail" -eq 0 ]; then echo "resource checks: clean"; exit 0; fi
+echo "resource checks: failures above"; exit 1