Browse Source

feat(skills): harden loop-ops — pricing-sync verifier + run.md scaffold

Follow-up to the loop-ops skill addressing a SKILL-CREATION-PROTOCOL §7 gap and
closing the scaffold-to-runnable story.

- Add scripts/check-pricing-sync.py — an offline §7 verifier asserting that
  loop-ops' model-pricing.json matches claude-api-ops's authoritative "Current
  Models" table (a copied table drifts silently). Network-free, so it gates PR CI
  via tests/check-resources.sh; live model-id drift stays owned by
  claude-api-ops/check-model-table.py. Exit 0 in sync / 10 drift / 3 missing.
- Wire it into tests/check-resources.sh (offline run + --help + py_compile +
  ASCII-purity + inline-Term checks), matching the other shipped verifiers.
- Add assets/run.template.md and have loop-init scaffold run.md — the headless run
  prompt a scheduler feeds to `claude -p` (referenced by claude-code-loops.md but
  previously never created). loop-init now writes config + STATE + run-log + run.md.
- SKILL.md cites the new verifier, run.template.md, and STATE.template.md
  (resource-citation completeness, §4).

Suite 58 -> 68 assertions; check-resources + doc-drift + validate all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
70f523fdaa

+ 21 - 2
skills/loop-ops/SKILL.md

@@ -162,8 +162,14 @@ estimates spend before you commit to a cadence.
 
 ### `scripts/loop-init.sh` — scaffold a loop's state spine
 
+Writes `<dir>/<name>/` with four files from the bundled templates:
+`loop.config.yaml` ([assets/loop.config.template.yaml](assets/loop.config.template.yaml)),
+`STATE.md` ([assets/STATE.template.md](assets/STATE.template.md)), `run-log.md`, and
+`run.md` — the headless run prompt a scheduler feeds to `claude -p`
+([assets/run.template.md](assets/run.template.md)).
+
 ```bash
-# Create .loops/pr-babysitter/ with config + STATE.md + run-log.md from templates:
+# Create .loops/pr-babysitter/ with config + STATE.md + run-log.md + run.md from templates:
 bash scripts/loop-init.sh --name pr-babysitter --pattern pr-babysitter --tier L1
 
 # Custom dir + cadence, preview without writing:
@@ -209,6 +215,19 @@ Exit `0` ok, `2` usage, `3` pricing file missing, `4` bad cadence/model. Output
 every assumption (runs/day, tokens/run, sub-agent multiplier) — it's an estimate, and it
 says so.
 
+### `scripts/check-pricing-sync.py` — offline drift guard (CI)
+
+`model-pricing.json` is a *copy* of claude-api-ops's authoritative model table, and a copy
+drifts silently. This offline verifier asserts every model in
+[assets/model-pricing.json](assets/model-pricing.json) matches claude-api-ops's "Current
+Models" table (prices included). Both files are in-repo, so it's network-free and gates PR
+CI via `tests/check-resources.sh`; live model-id drift is owned by claude-api-ops's
+`check-model-table.py`.
+
+```bash
+python scripts/check-pricing-sync.py --offline   # exit 0 in sync, 10 drift, 3 a file missing
+```
+
 ---
 
 ## End-to-end workflow
@@ -249,5 +268,5 @@ says so.
 - [references/pattern-catalog.md](references/pattern-catalog.md) — the seven patterns, full skeletons + escalation rules.
 - [references/state-spine.md](references/state-spine.md) — STATE.md / run-log / budget schemas, multi-loop coordination.
 - [references/claude-code-loops.md](references/claude-code-loops.md) — where loops actually live: `/loop`, `/schedule`, hooks, the scheduler pattern.
-- [assets/loop.config.template.yaml](assets/loop.config.template.yaml) — the loop definition starter.
+- [assets/loop.config.template.yaml](assets/loop.config.template.yaml) — the loop definition starter; [assets/STATE.template.md](assets/STATE.template.md) — the state-spine starter; [assets/run.template.md](assets/run.template.md) — the headless run prompt.
 - The lineage: [Ralph loop](https://ghuntley.com/ralph/) (inner brute-force), [loop-engineering](https://github.com/cobusgreyling/loop-engineering) (the methodology this distills).

+ 33 - 0
skills/loop-ops/assets/run.template.md

@@ -0,0 +1,33 @@
+<!--
+run.md — the prompt a scheduler feeds to `claude -p` each tick. It is the SAME
+every run (fresh context each time; state lives in STATE.md + the codebase + git,
+not the conversation — the Ralph property). Fill the <PLACEHOLDERS>; keep it short.
+
+Wire it up (see references/claude-code-loops.md):
+  claude -p "$(cat .loops/<loop-name>/run.md)" \
+    --permission-mode <permission_mode-from-config> \
+    --append-system-prompt "$(cat .loops/<loop-name>/STATE.md)"
+The SCHEDULER (cron / Task Scheduler / CI), not a Claude session, invokes this.
+-->
+
+# Run: <loop-name>  (tier <L1|L2|L3>)
+
+You are one tick of a scheduled loop. Goal: **<one sentence — what to do AND what never to do>**.
+
+## Do these in order
+
+1. **Check the kill switch FIRST.** If <kill_switch — e.g. `.loops/<loop-name>/PAUSED` exists, or the `loop-pause` label is set>, STOP immediately and do nothing else.
+2. **Read `STATE.md`** (appended to your system prompt). It is your memory of prior runs: the Priority / Watch / Noise lists.
+3. **Pick the next unit of work** from the Priority list. Stay strictly within scope: `<scope globs — never *>`.
+4. **Do the tier-appropriate action:**
+   - **L1 (report-only):** investigate and summarize. Write NOTHING but `STATE.md`.
+   - **L2 (assisted):** make the change in a **git worktree**; run the gate `<verify>` and guard `<guard>`; if both pass, hand the branch to `<land_via — e.g. fleet-ops>`; otherwise discard.
+5. **Apply the escalation rule.** If the action would <escalation — e.g. force-push / push to main / deploy / delete pre-existing files / edit .claude>, do NOT do it — escalate to a human with context instead.
+6. **Respect the budget.** Stop this run if you approach `<budget_tokens>` output tokens.
+7. **Rewrite `STATE.md`:** promote/demote items across Priority / Watch / Noise, bump the `_Updated_` line + run number + readiness score.
+8. **Append one line to `run-log.md`:** `<ISO-Z>  run#N  action=<…>  outcome=<…>  tokens=<N>`.
+
+## Hard rules
+- A general goal is NOT authorization for a specific high-blast action it implies — when in doubt, escalate.
+- Never act outside `scope`. Never touch another session's `.claude/worktrees/`.
+- Leave the repo in a clean, reviewable state every tick.

+ 183 - 0
skills/loop-ops/scripts/check-pricing-sync.py

@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Offline verifier: loop-ops pricing must match claude-api-ops's model table.
+
+loop-cost.py reads assets/model-pricing.json. That table is a *copy* of the
+authoritative "Current Models" table in skills/claude-api-ops/SKILL.md — and a
+copy drifts silently (the exact §7 failure mode). This asserts every model in
+loop-ops' pricing exists in the claude-api-ops table with matching input/output
+prices. Both files are in-repo, so this is a pure OFFLINE consistency check and
+safe to gate PR CI (no network). Live model-id drift is owned by
+claude-api-ops/scripts/check-model-table.py.
+
+Usage:   check-pricing-sync.py [--offline] [--pricing FILE] [--table FILE] [--json]
+Input:   argv flags only (no stdin).
+Output:  stdout = drift findings (plain rows, or --json envelope). Data only.
+Stderr:  the verdict panel, notices, errors.
+Exit:    0 in sync, 2 usage, 3 a file missing, 4 unparseable, 10 drift found
+
+--offline is the default and only mode (accepted for parity with the other §7
+verifiers invoked by tests/check-resources.sh).
+
+Examples:
+  check-pricing-sync.py --offline
+  check-pricing-sync.py --json | jq '.data[]'
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from pathlib import Path
+
+EX_OK = 0
+EX_USAGE = 2
+EX_NOTFOUND = 3
+EX_UNPARSEABLE = 4
+EX_DRIFT = 10
+
+HERE = Path(__file__).resolve().parent
+DEFAULT_PRICING = HERE.parent / "assets" / "model-pricing.json"
+DEFAULT_TABLE = HERE.parent.parent / "claude-api-ops" / "SKILL.md"
+
+PRICE_RE = re.compile(r"\$?\s*([0-9]+(?:\.[0-9]+)?)")
+
+
+class Term:
+    """Minimal ANSI helper (term.sh is bash-only; per TERMINAL-DESIGN.md §9 the
+    Python port is inline). Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the
+    bound stream's TTY + encoding so piped data stays plain ASCII."""
+
+    _C = {"green": "\033[32m", "red": "\033[31m", "cyan": "\033[36m",
+          "dim": "\033[2m", "off": "\033[0m"}
+
+    def __init__(self, stream=sys.stderr):
+        enc = (getattr(stream, "encoding", "") or "").lower()
+        self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc
+        if os.environ.get("FORCE_COLOR"):
+            self.color = True
+        elif (os.environ.get("NO_COLOR") is not None
+              or os.environ.get("TERM") == "dumb"
+              or not getattr(stream, "isatty", lambda: False)()):
+            self.color = False
+        else:
+            self.color = True
+
+    def c(self, name, text):
+        return f"{self._C.get(name,'')}{text}{self._C['off']}" if self.color else text
+
+    def mark(self, ok):
+        g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗")
+        return self.c("green" if ok else "red", g)
+
+
+def parse_price(cell: str) -> float | None:
+    m = PRICE_RE.search(cell)
+    return float(m.group(1)) if m else None
+
+
+def load_pricing(path: Path) -> dict:
+    """{model_id: (input_per_mtok, output_per_mtok)} from loop-ops' JSON."""
+    if not path.is_file():
+        print(f"error: pricing file not found: {path}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+        out = {}
+        for mid, pr in data.get("models", {}).items():
+            out[mid] = (float(pr["input_per_mtok"]), float(pr["output_per_mtok"]))
+        if not out:
+            print(f"error: no models in {path}", file=sys.stderr)
+            raise SystemExit(EX_UNPARSEABLE)
+        return out
+    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
+        print(f"error: could not parse pricing file: {exc}", file=sys.stderr)
+        raise SystemExit(EX_UNPARSEABLE)
+
+
+def load_table(path: Path) -> dict:
+    """{model_id: (input_price, output_price)} from the claude-api-ops markdown
+    'Current Models' table. Columns: Model | ID | Context | Max Output | Input | Output."""
+    if not path.is_file():
+        print(f"error: claude-api-ops table not found: {path}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    table: dict = {}
+    in_table = False
+    for line in path.read_text(encoding="utf-8").splitlines():
+        s = line.strip()
+        low = s.lower()
+        if s.startswith("|") and "id" in low and "context" in low and "output" in low:
+            in_table = True
+            continue
+        if in_table:
+            if not s.startswith("|"):
+                if table:  # table ended
+                    break
+                continue
+            if set(s) <= set("|-: "):  # separator row
+                continue
+            cells = [c.strip() for c in s.strip("|").split("|")]
+            if len(cells) < 6:
+                continue
+            mid = cells[1].strip("`").strip()
+            if not mid.startswith("claude-"):
+                continue
+            ip, op = parse_price(cells[4]), parse_price(cells[5])
+            if ip is not None and op is not None:
+                table[mid] = (ip, op)
+    if not table:
+        print(f"error: no model rows parsed from {path}", file=sys.stderr)
+        raise SystemExit(EX_UNPARSEABLE)
+    return table
+
+
+def main(argv: list[str]) -> int:
+    p = argparse.ArgumentParser(
+        prog="check-pricing-sync.py",
+        description="Verify loop-ops pricing matches claude-api-ops's model table (offline).",
+    )
+    p.add_argument("--offline", action="store_true", help="offline consistency check (default/only mode)")
+    p.add_argument("--pricing", default=str(DEFAULT_PRICING), help="loop-ops model-pricing.json")
+    p.add_argument("--table", default=str(DEFAULT_TABLE), help="claude-api-ops SKILL.md with the model table")
+    p.add_argument("--json", action="store_true", help="emit a JSON envelope")
+    try:
+        args = p.parse_args(argv)
+    except SystemExit as exc:
+        return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
+
+    pricing = load_pricing(Path(args.pricing))
+    table = load_table(Path(args.table))
+
+    findings = []
+    for mid, (ip, op) in sorted(pricing.items()):
+        if mid not in table:
+            findings.append({"model": mid, "issue": "absent from claude-api-ops table",
+                             "loop_ops": [ip, op], "authoritative": None})
+            continue
+        tip, top = table[mid]
+        if abs(ip - tip) > 1e-9 or abs(op - top) > 1e-9:
+            findings.append({"model": mid, "issue": "price mismatch",
+                             "loop_ops": [ip, op], "authoritative": [tip, top]})
+
+    if args.json:
+        print(json.dumps({
+            "data": findings,
+            "meta": {"count": len(findings), "models_checked": len(pricing),
+                     "in_sync": not findings, "schema": "claude-mods.loop-ops.pricing-sync/v1"},
+        }, indent=2))
+    else:
+        for f in findings:
+            auth = f"authoritative {f['authoritative']}" if f["authoritative"] else "not in table"
+            print(f"DRIFT  {f['model']}: {f['issue']} (loop-ops {f['loop_ops']} vs {auth})")
+        t = Term(sys.stderr)
+        ok = not findings
+        print(f"{t.mark(ok)} pricing-sync: {len(pricing)} model(s) checked, "
+              f"{len(findings)} drift "
+              f"{t.c('dim', '(authoritative: claude-api-ops/SKILL.md)')}", file=sys.stderr)
+
+    return EX_DRIFT if findings else EX_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 13 - 2
skills/loop-ops/scripts/loop-init.sh

@@ -37,6 +37,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 ASSETS="$HERE/../assets"
 CFG_TPL="$ASSETS/loop.config.template.yaml"
 STATE_TPL="$ASSETS/STATE.template.md"
+RUN_TPL="$ASSETS/run.template.md"
 
 # ── defaults ────────────────────────────────────────────────────────────────
 NAME=""
@@ -105,6 +106,7 @@ case "$TIER" in L1|L2|L3) ;; *) die_usage "--tier must be L1|L2|L3 (got '$TIER')
 
 [[ -f "$CFG_TPL" ]]   || { printf 'error: config template not found at %s\n' "$CFG_TPL" >&2; exit "$EX_NOTFOUND"; }
 [[ -f "$STATE_TPL" ]] || { printf 'error: STATE template not found at %s\n' "$STATE_TPL" >&2; exit "$EX_NOTFOUND"; }
+[[ -f "$RUN_TPL" ]]   || { printf 'error: run template not found at %s\n' "$RUN_TPL" >&2; exit "$EX_NOTFOUND"; }
 
 # Default permission_mode from tier (the workhorse mapping; see references/risk-tiers.md).
 case "$TIER" in
@@ -116,6 +118,7 @@ TARGET_DIR="$DIR/$NAME"
 CFG_OUT="$TARGET_DIR/loop.config.yaml"
 STATE_OUT="$TARGET_DIR/STATE.md"
 LOG_OUT="$TARGET_DIR/run-log.md"
+RUN_OUT="$TARGET_DIR/run.md"
 
 # Refuse a populated target unless --force.
 if [[ -d "$TARGET_DIR" ]] && [[ -n "$(ls -A "$TARGET_DIR" 2>/dev/null)" ]] && [[ "$FORCE" -ne 1 ]]; then
@@ -152,6 +155,13 @@ render_log() {
 EOF
 }
 
+render_run() {
+  sed -E \
+    -e "s|<loop-name>|$NAME|g" \
+    -e "s|tier <L1\\|L2\\|L3>|tier $TIER|g" \
+    "$RUN_TPL"
+}
+
 # ── dry-run: print and stop ─────────────────────────────────────────────────
 if [[ "$DRY_RUN" -eq 1 ]]; then
   printf '%s\n' "$CFG_OUT"
@@ -160,7 +170,7 @@ if [[ "$DRY_RUN" -eq 1 ]]; then
     term_panel_vert
     term_status_row skip "would create  $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
     term_status_row skip "  loop.config.yaml" "permission_mode: $PMODE"
-    term_status_row skip "  STATE.md / run-log.md" ""
+    term_status_row skip "  STATE.md / run-log.md / run.md" ""
     term_panel_vert
     term_panel_close "nothing written" ""
   } >&2
@@ -181,6 +191,7 @@ write_atomic() {  # write_atomic <dest> <content>
 write_atomic "$CFG_OUT"   "$(render_config)"
 write_atomic "$STATE_OUT" "$(render_state)"
 write_atomic "$LOG_OUT"   "$(render_log)"
+write_atomic "$RUN_OUT"   "$(render_run)"
 
 printf '%s\n' "$CFG_OUT"
 
@@ -189,7 +200,7 @@ printf '%s\n' "$CFG_OUT"
   term_panel_vert
   term_status_row ok "created  $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
   term_status_row ok "  loop.config.yaml" "permission_mode: $PMODE"
-  term_status_row ok "  STATE.md / run-log.md" ""
+  term_status_row ok "  STATE.md / run-log.md / run.md" ""
   if [[ "$TIER" != "L1" ]]; then
     term_alert warning "tier $TIER needs a verify gate, guard, worktree, escalation + land_via — fill them before auditing"
   fi

+ 18 - 0
skills/loop-ops/tests/run.sh

@@ -16,6 +16,7 @@ SCRIPTS="$SKILL/scripts"
 INIT="$SCRIPTS/loop-init.sh"
 AUDIT="$SCRIPTS/loop-audit.sh"
 COST="$SCRIPTS/loop-cost.py"
+SYNC="$SCRIPTS/check-pricing-sync.py"
 
 # Pick a python that actually executes — skips the Windows Store python3 stub.
 PYTHON=""
@@ -85,6 +86,10 @@ expect_has  "prints the config path" "pr-watch/loop.config.yaml" "$out"
 [[ -f "$SB/loops/pr-watch/loop.config.yaml" ]] && ok "wrote loop.config.yaml" || no "no loop.config.yaml"
 [[ -f "$SB/loops/pr-watch/STATE.md" ]] && ok "wrote STATE.md" || no "no STATE.md"
 [[ -f "$SB/loops/pr-watch/run-log.md" ]] && ok "wrote run-log.md" || no "no run-log.md"
+[[ -f "$SB/loops/pr-watch/run.md" ]] && ok "wrote run.md" || no "no run.md"
+runmd="$(cat "$SB/loops/pr-watch/run.md")"
+expect_has "run.md substitutes loop name" "Run: pr-watch" "$runmd"
+expect_has "run.md substitutes tier" "tier L1)" "$runmd"
 cfg="$(cat "$SB/loops/pr-watch/loop.config.yaml")"
 expect_has "substituted name" "name: pr-watch" "$cfg"
 expect_has "substituted tier" "tier: L1" "$cfg"
@@ -189,6 +194,18 @@ expect_has  "uses the override" "5 runs/day" "$out"
 "$PYTHON" "$COST" --pattern pr-babysitter --cadence "garbage cron" --model claude-haiku-4-5 >/dev/null 2>&1; expect_exit "bad cadence -> 4" 4 $?
 "$PYTHON" "$COST" --pricing "$SB/no-pricing.json" --pattern custom --cadence 1h --input-tokens 1 --output-tokens 1 --model x >/dev/null 2>&1; expect_exit "missing pricing file -> 3" 3 $?
 
+# ── check-pricing-sync: offline clean -> 0, drift -> 10, --json ────────────
+echo "-- check-pricing-sync --"
+"$PYTHON" "$SYNC" --help >/dev/null 2>&1; expect_exit "pricing-sync --help -> 0" 0 $?
+"$PYTHON" "$SYNC" --offline >/dev/null 2>&1; expect_exit "pricing-sync offline in sync -> 0" 0 $?
+# Tamper a copy: opus input price 5.0 -> 999.0 (sed; argv path is MSYS-converted for python).
+sed 's/"input_per_mtok": 5\.0/"input_per_mtok": 999.0/' "$SKILL/assets/model-pricing.json" > "$SB/badprice.json"
+"$PYTHON" "$SYNC" --pricing "$SB/badprice.json" >/dev/null 2>&1; expect_exit "pricing-sync drift -> 10" 10 $?
+"$PYTHON" "$SYNC" --pricing "$SB/no-such.json" >/dev/null 2>&1; expect_exit "pricing-sync missing file -> 3" 3 $?
+out="$("$PYTHON" "$SYNC" --json 2>/dev/null)"
+expect_has "pricing-sync json schema" "claude-mods.loop-ops.pricing-sync/v1" "$out"
+expect_has "pricing-sync json in_sync" '"in_sync": true' "$out"
+
 # ── terminal design system ─────────────────────────────────────────────────
 echo "-- terminal design system --"
 for s in "$INIT" "$AUDIT"; do
@@ -196,6 +213,7 @@ for s in "$INIT" "$AUDIT"; do
   grep -q '_lib/term.sh' "$s" && ok "$b sources _lib/term.sh" || no "$b does not source _lib/term.sh"
 done
 grep -q 'class Term' "$COST" && ok "loop-cost carries inline Term helper" || no "loop-cost missing inline Term helper"
+grep -q 'class Term' "$SYNC" && ok "check-pricing-sync carries inline Term helper" || no "check-pricing-sync missing inline Term helper"
 grep -q 'BRAND::loop' "$SKILL/../_lib/term.sh" && ok "term.sh registers the loop brand glyph" || no "term.sh missing loop brand glyph"
 # Piped audit findings stay plain (no ANSI in the data stream).
 po="$(bash "$AUDIT" "$SB/l2-nogate.yaml" 2>/dev/null)"

+ 9 - 2
tests/check-resources.sh

@@ -61,11 +61,16 @@ echo "== fleet-worker: doctor (preflight + staleness) verifier"
 run "fleet-doctor --offline consistent" 0 bash skills/fleet-worker/scripts/fleet-doctor.sh --offline
 run "fleet-doctor --help"               0 bash skills/fleet-worker/scripts/fleet-doctor.sh --help
 
+echo "== loop-ops: pricing-sync verifier"
+run "pricing-sync --offline in sync" 0 "$PY" skills/loop-ops/scripts/check-pricing-sync.py --offline
+run "pricing-sync --help"            0 "$PY" skills/loop-ops/scripts/check-pricing-sync.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 \
-         skills/mapbox-ops/scripts/check-mapbox-facts.py; do
+         skills/mapbox-ops/scripts/check-mapbox-facts.py \
+         skills/loop-ops/scripts/check-pricing-sync.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 \
@@ -95,13 +100,15 @@ __tf="$(mktemp)"; printf '{"suites":[]}' > "$__tf"
 purity "flake-triage" "$PY" skills/playwright-ops/scripts/triage-flakes.py "$__tf"
 rm -f "$__tf"
 purity "fleet-doctor"  bash skills/fleet-worker/scripts/fleet-doctor.sh --offline
+purity "pricing-sync"  "$PY" skills/loop-ops/scripts/check-pricing-sync.py --offline
 grep -q '_lib/term.sh' skills/terraform-ops/scripts/check-action-refs.sh \
     && pass "check-action-refs sources term.sh" || bad "check-action-refs missing term.sh"
 grep -q '_lib/term.sh' skills/fleet-worker/scripts/fleet-doctor.sh \
     && pass "fleet-doctor sources term.sh" || bad "fleet-doctor missing term.sh"
 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
+         skills/playwright-ops/scripts/triage-flakes.py \
+         skills/loop-ops/scripts/check-pricing-sync.py; do
     grep -q 'class Term' "$s" && pass "$(basename "$s") carries inline Term" \
         || bad "$(basename "$s") missing inline Term"
 done