run.sh 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. #!/usr/bin/env bash
  2. # Self-test for loop-ops scripts (loop-scaffold.sh, loop-check.sh, loop-estimate.py).
  3. #
  4. # Offline-deterministic (no network). Scaffolds throwaway loop fixtures, asserts the
  5. # documented exit codes + key output of each script, then cleans up. Resolves paths
  6. # relative to itself so it works both in the repo and installed to ~/.claude/.
  7. #
  8. # Usage: bash tests/run.sh
  9. # Exit: 0 all pass, 1 one or more failures
  10. set -uo pipefail
  11. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  12. SKILL="$(dirname "$HERE")"
  13. SCRIPTS="$SKILL/scripts"
  14. INIT="$SCRIPTS/loop-scaffold.sh"
  15. AUDIT="$SCRIPTS/loop-check.sh"
  16. COST="$SCRIPTS/loop-estimate.py"
  17. SYNC="$SCRIPTS/check-pricing-sync.py"
  18. DOCTOR="$SCRIPTS/loop-doctor.sh"
  19. # Pick a python that actually executes — skips the Windows Store python3 stub.
  20. PYTHON=""
  21. for c in python python3 py; do
  22. if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
  23. done
  24. [[ -z "$PYTHON" ]] && { echo "no working python found — skipping" >&2; exit 0; }
  25. SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
  26. PASS=0; FAIL=0
  27. ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; }
  28. no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; }
  29. expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
  30. expect_has() { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
  31. # Write a filled, READY L1 report-only config.
  32. good_l1() { cat > "$1" <<'EOF'
  33. name: test-l1
  34. pattern: pr-watch
  35. tier: L1
  36. permission_mode: dontAsk
  37. cadence: 10m
  38. goal: "Watch open PRs and report; never merge."
  39. scope:
  40. - "src/**"
  41. escalation: "comment on the PR; never merge to main"
  42. budget_tokens: 200000
  43. kill_switch: ".loops/test-l1/PAUSED exists or loop-pause label"
  44. EOF
  45. }
  46. # Write a filled, READY L2 assisted config.
  47. good_l2() { cat > "$1" <<'EOF'
  48. name: dep-bump
  49. pattern: dep-bump
  50. tier: L2
  51. permission_mode: dontAsk
  52. cadence: 1d
  53. goal: "Patch-only dependency bumps behind cooldown; open a PR."
  54. scope:
  55. - "package.json"
  56. - "package-lock.json"
  57. verify: "npm test"
  58. guard: "npm run typecheck"
  59. worktree: true
  60. land_via: fleet-ops
  61. escalation: "minor/major bumps escalate; never merge to main"
  62. budget_tokens: 300000
  63. kill_switch: ".loops/dep-bump/PAUSED"
  64. EOF
  65. }
  66. echo "=== loop-ops self-test (python: $PYTHON) ==="
  67. # ── --help contracts (exit 0) ──────────────────────────────────────────────
  68. echo "-- --help --"
  69. bash "$INIT" --help >/dev/null 2>&1; expect_exit "loop-scaffold --help" 0 $?
  70. bash "$AUDIT" --help >/dev/null 2>&1; expect_exit "loop-check --help" 0 $?
  71. "$PYTHON" "$COST" --help >/dev/null 2>&1; expect_exit "loop-estimate --help" 0 $?
  72. # ── loop-scaffold: scaffolds dir + 3 files, substitutes fields ─────────────────
  73. echo "-- loop-scaffold --"
  74. out="$(bash "$INIT" --name pr-watch --pattern pr-watch --tier L1 --cadence 5m --dir "$SB/loops" 2>/dev/null)"; rc=$?
  75. expect_exit "loop-scaffold -> 0" 0 "$rc"
  76. expect_has "prints the config path" "pr-watch/loop.config.yaml" "$out"
  77. [[ -f "$SB/loops/pr-watch/loop.config.yaml" ]] && ok "wrote loop.config.yaml" || no "no loop.config.yaml"
  78. [[ -f "$SB/loops/pr-watch/STATE.md" ]] && ok "wrote STATE.md" || no "no STATE.md"
  79. [[ -f "$SB/loops/pr-watch/run-log.md" ]] && ok "wrote run-log.md" || no "no run-log.md"
  80. [[ -f "$SB/loops/pr-watch/run.md" ]] && ok "wrote run.md" || no "no run.md"
  81. runmd="$(cat "$SB/loops/pr-watch/run.md")"
  82. expect_has "run.md substitutes loop name" "Run: pr-watch" "$runmd"
  83. expect_has "run.md substitutes tier" "tier L1)" "$runmd"
  84. # runner-agnostic wrapper: emitted, executable, fully substituted, no GH Actions dep
  85. [[ -f "$SB/loops/pr-watch/loop-run.sh" ]] && ok "wrote loop-run.sh" || no "no loop-run.sh"
  86. runsh="$(cat "$SB/loops/pr-watch/loop-run.sh")"
  87. case "$runsh" in *"<loop-name>"*|*"<permission-mode>"*) no "loop-run.sh left a placeholder";; *) ok "loop-run.sh fully substituted";; esac
  88. expect_has "loop-run.sh wires the gated mode" "--permission-mode dontAsk" "$runsh"
  89. cfg="$(cat "$SB/loops/pr-watch/loop.config.yaml")"
  90. expect_has "substituted name" "name: pr-watch" "$cfg"
  91. expect_has "substituted tier" "tier: L1" "$cfg"
  92. expect_has "substituted cadence" "cadence: 5m" "$cfg"
  93. expect_has "L1 default permission_mode" "permission_mode: dontAsk" "$cfg"
  94. # L3 default permission_mode is bypassPermissions
  95. bash "$INIT" --name big-job --tier L3 --dir "$SB/loops" >/dev/null 2>&1
  96. expect_has "L3 default permission_mode" "permission_mode: bypassPermissions" "$(cat "$SB/loops/big-job/loop.config.yaml")"
  97. # ── loop-scaffold: refuses a populated dir -> 5, --force overwrites ─────────────
  98. bash "$INIT" --name pr-watch --dir "$SB/loops" >/dev/null 2>&1; expect_exit "refuse populated dir -> 5" 5 $?
  99. bash "$INIT" --name pr-watch --dir "$SB/loops" --force >/dev/null 2>&1; expect_exit "--force overwrites -> 0" 0 $?
  100. # ── loop-scaffold: --dry-run writes nothing ────────────────────────────────────
  101. out="$(bash "$INIT" --name ghost --dir "$SB/dryloops" --dry-run 2>/dev/null)"; rc=$?
  102. expect_exit "dry-run -> 0" 0 "$rc"
  103. [[ -e "$SB/dryloops" ]] && no "dry-run created files" || ok "dry-run wrote nothing"
  104. expect_has "dry-run prints config path" "ghost/loop.config.yaml" "$out"
  105. # ── loop-scaffold: usage errors ────────────────────────────────────────────────
  106. bash "$INIT" --dir "$SB/loops" >/dev/null 2>&1; expect_exit "missing --name -> 2" 2 $?
  107. bash "$INIT" --name BadName --dir "$SB/loops" >/dev/null 2>&1; expect_exit "non-kebab name -> 2" 2 $?
  108. bash "$INIT" --name x --tier L9 --dir "$SB/loops" >/dev/null 2>&1; expect_exit "bad tier -> 2" 2 $?
  109. # pattern-seeding: a known pattern seeds a near-ready, audit-clean config
  110. bash "$INIT" --name seed-l1 --pattern ci-watch --tier L1 --cadence 15m --dir "$SB/seed" >/dev/null 2>&1
  111. seedcfg="$(cat "$SB/seed/seed-l1/loop.config.yaml")"
  112. expect_has "seeded config carries the pattern goal" "Detect red CI" "$seedcfg"
  113. expect_has "seeded L1 leaves a graduation block" "graduate to L2" "$seedcfg"
  114. bash "$AUDIT" "$SB/seed/seed-l1/loop.config.yaml" >/dev/null 2>&1; expect_exit "seeded L1 audits clean -> 0" 0 $?
  115. # at L2 the pattern's gate is filled (not commented) and audits clean
  116. bash "$INIT" --name seed-l2 --pattern ci-watch --tier L2 --cadence 15m --dir "$SB/seed" >/dev/null 2>&1
  117. l2cfg="$(cat "$SB/seed/seed-l2/loop.config.yaml")"
  118. case "$l2cfg" in *$'\nverify: "npm test"'*) ok "seeded L2 fills the gate";; *) no "seeded L2 did not fill the gate";; esac
  119. bash "$AUDIT" "$SB/seed/seed-l2/loop.config.yaml" >/dev/null 2>&1; expect_exit "seeded L2 audits clean -> 0" 0 $?
  120. # an unknown pattern falls back to the generic placeholder template (not ready)
  121. bash "$INIT" --name seed-x --pattern custom --tier L1 --dir "$SB/seed" >/dev/null 2>&1
  122. case "$(cat "$SB/seed/seed-x/loop.config.yaml")" in *"<one sentence"*) ok "unknown pattern uses generic template";; *) no "unknown pattern did not use template";; esac
  123. # v2 archetypes: scaffold must be audit-clean AND doctor-clean at L1 + known to the cost model
  124. # (doctor-clean catches budget < tokens/run — the metric-chase trap: it seeds a bigger budget)
  125. for p in metric-chase regression-watch digest backfill monitor freshness; do
  126. bash "$INIT" --name "a-$p" --pattern "$p" --tier L1 --dir "$SB/arch" >/dev/null 2>&1
  127. bash "$AUDIT" "$SB/arch/a-$p/loop.config.yaml" >/dev/null 2>&1; expect_exit "archetype $p seeds audit-clean (L1)" 0 $?
  128. bash "$DOCTOR" --offline "$SB/arch/a-$p/loop.config.yaml" >/dev/null 2>&1; expect_exit "archetype $p doctors clean (L1)" 0 $?
  129. "$PYTHON" "$COST" --pattern "$p" --cadence 1h --model claude-haiku-4-5 >/dev/null 2>&1; expect_exit "cost model knows $p" 0 $?
  130. done
  131. # the most expensive archetype at L2: gate filled, budget fits the tick (audit + doctor clean)
  132. bash "$INIT" --name a-mc --pattern metric-chase --tier L2 --cadence 1h --dir "$SB/arch" >/dev/null 2>&1
  133. bash "$AUDIT" "$SB/arch/a-mc/loop.config.yaml" >/dev/null 2>&1; expect_exit "metric-chase L2 audits clean -> 0" 0 $?
  134. bash "$DOCTOR" --offline "$SB/arch/a-mc/loop.config.yaml" >/dev/null 2>&1; expect_exit "metric-chase L2 doctors clean (budget fits) -> 0" 0 $?
  135. # ── loop-check: a freshly-init'd config is NOT ready (placeholders) -> 10 ───
  136. echo "-- loop-check --"
  137. bash "$INIT" --name raw --pattern custom --tier L1 --dir "$SB/loops" >/dev/null 2>&1
  138. out="$(bash "$AUDIT" "$SB/loops/raw/loop.config.yaml" 2>/dev/null)"; rc=$?
  139. expect_exit "raw scaffold not ready -> 10" 10 "$rc"
  140. expect_has "flags the goal placeholder" "goal:" "$out"
  141. # ── loop-check: filled L1 config is READY -> 0 ─────────────────────────────
  142. good_l1 "$SB/l1.yaml"
  143. out="$(bash "$AUDIT" "$SB/l1.yaml" 2>/dev/null)"; rc=$?
  144. expect_exit "filled L1 ready -> 0" 0 "$rc"
  145. # ── loop-check: filled L2 config is READY -> 0 ─────────────────────────────
  146. good_l2 "$SB/l2.yaml"
  147. bash "$AUDIT" "$SB/l2.yaml" >/dev/null 2>&1; expect_exit "filled L2 ready -> 0" 0 $?
  148. # ── loop-check: L2 missing the gate -> 10, names verify ────────────────────
  149. grep -v '^verify:' "$SB/l2.yaml" > "$SB/l2-nogate.yaml"
  150. out="$(bash "$AUDIT" "$SB/l2-nogate.yaml" 2>/dev/null)"; rc=$?
  151. expect_exit "L2 missing gate -> 10" 10 "$rc"
  152. expect_has "names the missing gate" "verify:" "$out"
  153. # ── loop-check: unbounded scope -> 10 ──────────────────────────────────────
  154. sed 's| - "src/\*\*"| - "*"|' "$SB/l1.yaml" > "$SB/l1-unbounded.yaml"
  155. out="$(bash "$AUDIT" "$SB/l1-unbounded.yaml" 2>/dev/null)"; rc=$?
  156. expect_exit "unbounded scope -> 10" 10 "$rc"
  157. expect_has "names unbounded scope" "unbounded" "$out"
  158. # ── loop-check: missing escalation -> 10 ───────────────────────────────────
  159. grep -v '^escalation:' "$SB/l1.yaml" > "$SB/l1-noescal.yaml"
  160. out="$(bash "$AUDIT" "$SB/l1-noescal.yaml" 2>/dev/null)"; rc=$?
  161. expect_exit "missing escalation -> 10" 10 "$rc"
  162. expect_has "names escalation" "escalation:" "$out"
  163. # ── loop-check: missing file -> 3, unparseable -> 4, bad --min -> 2 ────────
  164. bash "$AUDIT" "$SB/no-such.yaml" >/dev/null 2>&1; expect_exit "missing config -> 3" 3 $?
  165. printf 'just some prose, no keys\n' > "$SB/garbage.yaml"
  166. bash "$AUDIT" "$SB/garbage.yaml" >/dev/null 2>&1; expect_exit "unparseable -> 4" 4 $?
  167. bash "$AUDIT" --min abc "$SB/l1.yaml" >/dev/null 2>&1; expect_exit "bad --min -> 2" 2 $?
  168. # ── loop-check: --json envelope schema + ready flag ────────────────────────
  169. out="$(bash "$AUDIT" --json "$SB/l1.yaml" 2>/dev/null)"
  170. expect_has "audit json schema" "claude-mods.loop-ops.check/v1" "$out"
  171. expect_has "audit json ready true" '"ready": true' "$out"
  172. out="$(bash "$AUDIT" --json "$SB/l2-nogate.yaml" 2>/dev/null)"
  173. expect_has "audit json ready false" '"ready": false' "$out"
  174. # ── loop-check: --strict turns a warning into NOT ready ────────────────────
  175. # An L1 with permission_mode: auto is consistent-enough to pass errors but warns
  176. # (broad for L1). Normally ready; --strict flips it.
  177. sed 's|permission_mode: dontAsk|permission_mode: auto|' "$SB/l1.yaml" > "$SB/l1-warn.yaml"
  178. bash "$AUDIT" "$SB/l1-warn.yaml" >/dev/null 2>&1; expect_exit "warning, normally ready -> 0" 0 $?
  179. bash "$AUDIT" --strict "$SB/l1-warn.yaml" >/dev/null 2>&1; expect_exit "warning, --strict not ready -> 10" 10 $?
  180. # ── loop-estimate: basic run, --json, --list-models, cadence forms ─────────────
  181. echo "-- loop-estimate --"
  182. out="$("$PYTHON" "$COST" --pattern pr-watch --cadence 10m --model claude-haiku-4-5 2>/dev/null)"; rc=$?
  183. expect_exit "loop-estimate -> 0" 0 "$rc"
  184. expect_has "prints a daily cost" "cost/day:" "$out"
  185. expect_has "derives runs/day from 10m" "144 runs/day" "$out"
  186. out="$("$PYTHON" "$COST" --pattern ci-watch --cadence 15m --model claude-sonnet-4-6 --json 2>/dev/null)"
  187. expect_has "cost json schema" "claude-mods.loop-ops.estimate/v1" "$out"
  188. expect_has "cost json carries runs_per_day" "runs_per_day" "$out"
  189. out="$("$PYTHON" "$COST" --list-models 2>/dev/null)"; rc=$?
  190. expect_exit "list-models -> 0" 0 "$rc"
  191. expect_has "list-models shows a model" "claude-opus-4-8" "$out"
  192. # cron cadence parses
  193. "$PYTHON" "$COST" --pattern daily-scan --cadence '*/10 * * * *' --model claude-haiku-4-5 >/dev/null 2>&1
  194. expect_exit "cron cadence -> 0" 0 $?
  195. # --runs-per-day override
  196. out="$("$PYTHON" "$COST" --pattern custom --cadence weird --runs-per-day 5 --model claude-haiku-4-5 2>/dev/null)"; rc=$?
  197. expect_exit "runs-per-day override -> 0" 0 "$rc"
  198. expect_has "uses the override" "5 runs/day" "$out"
  199. # caching: a fast loop (10m -> 1h TTL) projects a cached saving
  200. out="$("$PYTHON" "$COST" --pattern ci-watch --cadence 10m --model claude-sonnet-4-6 2>&1)"
  201. expect_has "fast loop shows a cached projection" "cached/" "$out"
  202. # caching: a slow loop (6h > 1h TTL) is not cache-beneficial
  203. out="$("$PYTHON" "$COST" --pattern daily-scan --cadence 6h --model claude-opus-4-8 2>&1)"
  204. expect_has "slow loop: caching not beneficial" "not beneficial" "$out"
  205. # --no-cache suppresses the cached projection
  206. out="$("$PYTHON" "$COST" --pattern ci-watch --cadence 10m --model claude-sonnet-4-6 --no-cache 2>&1)"
  207. case "$out" in *"cached/"*) no "--no-cache still showed caching";; *) ok "--no-cache suppresses caching";; esac
  208. # json caching block present for a cacheable loop
  209. out="$("$PYTHON" "$COST" --pattern ci-watch --cadence 5m --model claude-sonnet-4-6 --json 2>/dev/null)"
  210. expect_has "cost json carries caching block" '"caching"' "$out"
  211. # ── loop-doctor: preflight (offline budget, live binary), json ─────────────
  212. echo "-- loop-doctor --"
  213. bash "$DOCTOR" --help >/dev/null 2>&1; expect_exit "loop-doctor --help -> 0" 0 $?
  214. bash "$DOCTOR" --offline "$SB/l1.yaml" >/dev/null 2>&1; expect_exit "doctor offline healthy L1 -> 0" 0 $?
  215. bash "$DOCTOR" --live "$SB/l1.yaml" >/dev/null 2>&1; expect_exit "doctor live healthy L1 -> 0" 0 $?
  216. # budget too small for the pattern -> bad -> 10
  217. sed 's/^budget_tokens: 300000/budget_tokens: 100/' "$SB/l2.yaml" > "$SB/l2-poor.yaml"
  218. out="$(bash "$DOCTOR" --offline "$SB/l2-poor.yaml" 2>/dev/null)"; rc=$?
  219. expect_exit "doctor budget-too-small -> 10" 10 "$rc"
  220. expect_has "doctor names the budget gap" "tokens/run" "$out"
  221. # live: a verify gate whose binary is missing -> bad -> 10
  222. sed 's/^verify: "npm test"/verify: "totally-missing-binary-zzz run"/' "$SB/l2.yaml" > "$SB/l2-nobin.yaml"
  223. bash "$DOCTOR" --live "$SB/l2-nobin.yaml" >/dev/null 2>&1; expect_exit "doctor missing gate binary -> 10" 10 $?
  224. # missing config -> 3, json schema
  225. bash "$DOCTOR" --offline "$SB/no-such.yaml" >/dev/null 2>&1; expect_exit "doctor missing config -> 3" 3 $?
  226. out="$(bash "$DOCTOR" --offline --json "$SB/l1.yaml" 2>/dev/null)"
  227. expect_has "doctor json schema" "claude-mods.loop-ops.doctor/v1" "$out"
  228. # ── loop-estimate: validation errors ───────────────────────────────────────────
  229. "$PYTHON" "$COST" --pattern pr-watch --cadence 10m --model claude-nope >/dev/null 2>&1; expect_exit "unknown model -> 4" 4 $?
  230. "$PYTHON" "$COST" --pattern not-a-pattern --cadence 10m --model claude-haiku-4-5 >/dev/null 2>&1; expect_exit "unknown pattern -> 4" 4 $?
  231. "$PYTHON" "$COST" --pattern pr-watch --cadence "garbage cron" --model claude-haiku-4-5 >/dev/null 2>&1; expect_exit "bad cadence -> 4" 4 $?
  232. "$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 $?
  233. # ── check-pricing-sync: offline clean -> 0, drift -> 10, --json ────────────
  234. echo "-- check-pricing-sync --"
  235. "$PYTHON" "$SYNC" --help >/dev/null 2>&1; expect_exit "pricing-sync --help -> 0" 0 $?
  236. "$PYTHON" "$SYNC" --offline >/dev/null 2>&1; expect_exit "pricing-sync offline in sync -> 0" 0 $?
  237. # Tamper a copy: opus input price 5.0 -> 999.0 (sed; argv path is MSYS-converted for python).
  238. sed 's/"input_per_mtok": 5\.0/"input_per_mtok": 999.0/' "$SKILL/assets/model-pricing.json" > "$SB/badprice.json"
  239. "$PYTHON" "$SYNC" --pricing "$SB/badprice.json" >/dev/null 2>&1; expect_exit "pricing-sync drift -> 10" 10 $?
  240. "$PYTHON" "$SYNC" --pricing "$SB/no-such.json" >/dev/null 2>&1; expect_exit "pricing-sync missing file -> 3" 3 $?
  241. out="$("$PYTHON" "$SYNC" --json 2>/dev/null)"
  242. expect_has "pricing-sync json schema" "claude-mods.loop-ops.pricing-sync/v1" "$out"
  243. expect_has "pricing-sync json in_sync" '"in_sync": true' "$out"
  244. # ── Windows-authored configs: CRLF + UTF-8 BOM must parse like clean LF ─────
  245. echo "-- windows-authored configs (CRLF / BOM) --"
  246. good_l1 "$SB/win.yaml"
  247. sed 's/$/\r/' "$SB/win.yaml" > "$SB/win-crlf.yaml" # LF -> CRLF
  248. bash "$AUDIT" "$SB/win-crlf.yaml" >/dev/null 2>&1; expect_exit "CRLF config audits clean -> 0" 0 $?
  249. bash "$DOCTOR" --offline "$SB/win-crlf.yaml" >/dev/null 2>&1; expect_exit "CRLF config doctors clean -> 0" 0 $?
  250. printf '\xEF\xBB\xBF' > "$SB/win-bom.yaml"; cat "$SB/win.yaml" >> "$SB/win-bom.yaml" # prepend BOM
  251. bash "$AUDIT" "$SB/win-bom.yaml" >/dev/null 2>&1; expect_exit "BOM config audits clean -> 0" 0 $?
  252. bash "$DOCTOR" --offline "$SB/win-bom.yaml" >/dev/null 2>&1; expect_exit "BOM config doctors clean -> 0" 0 $?
  253. # ── worked example: the shipped example stays gate-clean ───────────────────
  254. echo "-- worked example --"
  255. EX="$SKILL/assets/examples/pr-watch/loop.config.yaml"
  256. [[ -f "$EX" ]] && ok "worked example present" || no "worked example missing"
  257. bash "$AUDIT" "$EX" >/dev/null 2>&1; expect_exit "shipped example audits clean -> 0" 0 $?
  258. bash "$DOCTOR" --offline "$EX" >/dev/null 2>&1; expect_exit "shipped example doctors clean -> 0" 0 $?
  259. [[ -f "$SKILL/assets/examples/pr-watch/loop-run.sh" ]] && ok "example ships loop-run.sh (runner-agnostic)" || no "example missing loop-run.sh"
  260. [[ -f "$SKILL/assets/examples/pr-watch/github-actions.yml" ]] && ok "example ships an optional GH Actions scheduler" || no "example missing GH Actions option"
  261. [[ -f "$SKILL/assets/examples/pr-watch/run.md" ]] && ok "example ships a run prompt" || no "example missing run.md"
  262. # ── terminal design system ─────────────────────────────────────────────────
  263. echo "-- terminal design system --"
  264. for s in "$INIT" "$AUDIT" "$DOCTOR"; do
  265. b="$(basename "$s")"
  266. grep -q '_lib/term.sh' "$s" && ok "$b sources _lib/term.sh" || no "$b does not source _lib/term.sh"
  267. done
  268. grep -q 'class Term' "$COST" && ok "loop-estimate carries inline Term helper" || no "loop-estimate missing inline Term helper"
  269. grep -q 'class Term' "$SYNC" && ok "check-pricing-sync carries inline Term helper" || no "check-pricing-sync missing inline Term helper"
  270. grep -q 'BRAND::loop' "$SKILL/../_lib/term.sh" && ok "term.sh registers the loop brand glyph" || no "term.sh missing loop brand glyph"
  271. # Piped audit findings stay plain (no ANSI in the data stream).
  272. po="$(bash "$AUDIT" "$SB/l2-nogate.yaml" 2>/dev/null)"
  273. case "$po" in *$'\033'*) no "piped audit leaked ANSI into data";; *) ok "piped audit stays plain data";; esac
  274. # ── summary ────────────────────────────────────────────────────────────────
  275. echo "=== $PASS passed, $FAIL failed ==="
  276. [[ "$FAIL" -eq 0 ]] || exit 1