loop-check.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. #!/usr/bin/env bash
  2. # Score an outer-loop config for readiness before it is scheduled.
  3. #
  4. # Usage: loop-check.sh [OPTIONS] <loop.config.yaml>
  5. # Input: argv flags + a config path (no stdin).
  6. # Output: stdout = findings (plain `SEVERITY message` rows, or --json envelope).
  7. # Data only.
  8. # Stderr: the readiness panel (score + verdict), notices, errors.
  9. # Exit: 0 ready (no errors, score >= --min), 2 usage, 3 config not found,
  10. # 4 config unparseable, 10 NOT ready (findings present)
  11. #
  12. # Scores a flat loop.config.yaml against the tier's requirements: a bounded scope,
  13. # a defined escalation rule + kill switch, and — at L2+ — a verify gate, a guard, a
  14. # worktree, and a landing path. The config is parsed without a yq dependency.
  15. # Pair with loop-scaffold.sh (scaffold) and references/risk-tiers.md (the rubric).
  16. #
  17. # Examples:
  18. # loop-check.sh .loops/pr-watch/loop.config.yaml
  19. # loop-check.sh --json .loops/dep-bump/loop.config.yaml | jq '.data[] | select(.severity=="error")'
  20. # loop-check.sh --min 80 --strict .loops/ci-watch/loop.config.yaml
  21. set -uo pipefail
  22. readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 EX_UNPARSEABLE=4 EX_FINDINGS=10
  23. # Terminal design system. stdout = findings (data); the score panel frames on stderr.
  24. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  25. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
  26. else
  27. term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
  28. term_status_row() { shift; printf ' - %s %s\n' "$1" "${2:-}"; }
  29. term_pip_bar() { printf '%s/%s' "$2" "$3"; }
  30. term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|"
  31. fi
  32. CFG=""
  33. MIN=70
  34. STRICT=0
  35. JSON=0
  36. usage() {
  37. cat <<'EOF'
  38. loop-check.sh — score an outer-loop config for readiness.
  39. Usage:
  40. loop-check.sh [OPTIONS] <loop.config.yaml>
  41. Options:
  42. --min N readiness score (0-100) required for a "ready" verdict (default: 70).
  43. --strict count warnings toward the NOT-ready signal (exit 10).
  44. --json emit a JSON envelope instead of plain rows.
  45. -h, --help show this help and exit 0.
  46. Exit codes:
  47. 0 ready 2 usage 3 config not found 4 unparseable 10 NOT ready (findings)
  48. Examples:
  49. loop-check.sh .loops/pr-watch/loop.config.yaml
  50. loop-check.sh --json .loops/dep-bump/loop.config.yaml | jq '.data[] | select(.severity=="error")'
  51. loop-check.sh --min 80 --strict .loops/ci-watch/loop.config.yaml
  52. EOF
  53. }
  54. die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; }
  55. # ── parse args ──────────────────────────────────────────────────────────────
  56. while [[ $# -gt 0 ]]; do
  57. case "$1" in
  58. --min) [[ $# -ge 2 ]] || die_usage "--min needs a value"; MIN="$2"; shift 2 ;;
  59. --strict) STRICT=1; shift ;;
  60. --json) JSON=1; shift ;;
  61. -h|--help) usage; exit "$EX_OK" ;;
  62. -*) die_usage "unknown flag: $1" ;;
  63. *) [[ -z "$CFG" ]] || die_usage "unexpected extra argument: $1"; CFG="$1"; shift ;;
  64. esac
  65. done
  66. [[ -n "$CFG" ]] || die_usage "a loop.config.yaml path is required"
  67. [[ "$MIN" =~ ^[0-9]+$ ]] || die_usage "--min must be an integer (got '$MIN')"
  68. [[ -f "$CFG" ]] || { printf 'error: config not found: %s\n' "$CFG" >&2; exit "$EX_NOTFOUND"; }
  69. # Normalize Windows-authored configs: strip a leading UTF-8 BOM (line 1) and CR
  70. # line-endings so a CRLF/BOM file parses identically to a clean LF one (octal BOM +
  71. # gsub \r are portable across gawk/mawk/BSD awk). Falls back to the original on failure.
  72. __NORM="$(mktemp 2>/dev/null)" && awk 'NR==1{sub(/^\357\273\277/,"")} {gsub(/\r/,""); print}' "$CFG" > "$__NORM" 2>/dev/null && CFG="$__NORM" && trap 'rm -f "$__NORM"' EXIT
  73. # Unparseable: no top-level `key:` lines at all.
  74. if ! grep -Eq '^[a-z_]+:' "$CFG"; then
  75. printf 'error: no parseable top-level keys in %s\n' "$CFG" >&2
  76. exit "$EX_UNPARSEABLE"
  77. fi
  78. # ── flat-YAML readers (no yq) ───────────────────────────────────────────────
  79. cfg_scalar() { # inline scalar value for `^KEY:`; empty if absent or block-list
  80. awk -v k="$1" -v q="'" '
  81. $0 ~ "^"k":" {
  82. sub("^"k":[ \t]*","")
  83. sub(/[ \t]*#.*$/,"")
  84. gsub(/^[ \t]+|[ \t]+$/,"")
  85. gsub(/^"|"$/,""); gsub("^"q"|"q"$","")
  86. print; exit
  87. }' "$CFG"
  88. }
  89. cfg_has_key() { grep -Eq "^$1:" "$CFG"; }
  90. cfg_list_items() { # ` - item` lines under `^KEY:`, until the next top-level key
  91. awk -v k="$1" -v q="'" '
  92. $0 ~ "^"k":" { inlist=1; next }
  93. inlist==1 {
  94. if ($0 ~ /^[ \t]*-[ \t]+/) {
  95. line=$0
  96. sub(/^[ \t]*-[ \t]+/,"",line); sub(/[ \t]*#.*$/,"",line)
  97. gsub(/^[ \t]+|[ \t]+$/,"",line); gsub(/^"|"$/,"",line); gsub("^"q"|"q"$","",line)
  98. if (line != "") print line
  99. } else if ($0 ~ /^[^ \t#]/) { inlist=0 }
  100. }' "$CFG"
  101. }
  102. is_placeholder() { [[ "$1" == *"<"*">"* ]]; } # an unfilled <PLACEHOLDER>
  103. # ── findings + scoring ──────────────────────────────────────────────────────
  104. FIND_SEV=(); FIND_MSG=()
  105. CHECKS_TOTAL=0; CHECKS_PASS=0
  106. add() { FIND_SEV+=("$1"); FIND_MSG+=("$2"); }
  107. pass() { CHECKS_TOTAL=$((CHECKS_TOTAL+1)); CHECKS_PASS=$((CHECKS_PASS+1)); }
  108. fail() { CHECKS_TOTAL=$((CHECKS_TOTAL+1)); add "$1" "$2"; } # $1=severity $2=message
  109. # require <severity> <ok?> <message-on-fail> — a present+valid scalar check.
  110. require() { if [[ "$2" -eq 1 ]]; then pass; else fail "$1" "$3"; fi; }
  111. TIER="$(cfg_scalar tier)"
  112. PMODE="$(cfg_scalar permission_mode)"
  113. NAME="$(cfg_scalar name)"
  114. GOAL="$(cfg_scalar goal)"
  115. ESCAL="$(cfg_scalar escalation)"
  116. KILL="$(cfg_scalar kill_switch)"
  117. BUDGET="$(cfg_scalar budget_tokens)"
  118. VERIFY="$(cfg_scalar verify)"
  119. GUARD="$(cfg_scalar guard)"
  120. WORKTREE="$(cfg_scalar worktree)"
  121. LANDVIA="$(cfg_scalar land_via)"
  122. CADENCE="$(cfg_scalar cadence)"
  123. PATTERN="$(cfg_scalar pattern)"
  124. is_l2plus=0; [[ "$TIER" == "L2" || "$TIER" == "L3" ]] && is_l2plus=1
  125. # present-and-not-placeholder predicate
  126. filled() { [[ -n "$1" ]] && ! is_placeholder "$1"; }
  127. # ── always-applicable checks ────────────────────────────────────────────────
  128. require error "$(filled "$NAME" && echo 1 || echo 0)" "name: missing or placeholder"
  129. require warning "$(filled "$PATTERN" && echo 1 || echo 0)" "pattern: missing"
  130. case "$TIER" in L1|L2|L3) pass ;; *) fail error "tier: must be L1|L2|L3 (got '${TIER:-empty}')" ;; esac
  131. require warning "$(filled "$CADENCE" && echo 1 || echo 0)" "cadence: missing"
  132. require error "$(filled "$GOAL" && echo 1 || echo 0)" "goal: missing or placeholder"
  133. require error "$(filled "$ESCAL" && echo 1 || echo 0)" "escalation: undefined — every loop must declare what it escalates"
  134. require error "$(filled "$KILL" && echo 1 || echo 0)" "kill_switch: undefined — no loop ships without a stop signal"
  135. # budget present + numeric
  136. if [[ -n "$BUDGET" && "$BUDGET" =~ ^[0-9]+$ ]]; then pass; else fail warning "budget_tokens: missing or non-numeric — bound the per-run spend"; fi
  137. # scope present + bounded + not placeholder
  138. mapfile -t SCOPE_ITEMS < <(cfg_list_items scope)
  139. SCOPE_INLINE="$(cfg_scalar scope)"
  140. [[ -n "$SCOPE_INLINE" ]] && SCOPE_ITEMS+=("$SCOPE_INLINE")
  141. if ! cfg_has_key scope || [[ ${#SCOPE_ITEMS[@]} -eq 0 ]]; then
  142. fail error "scope: missing — bound what the loop may touch"
  143. else
  144. scope_bad=0
  145. for it in "${SCOPE_ITEMS[@]}"; do
  146. if is_placeholder "$it"; then fail error "scope: unfilled placeholder ('$it')"; scope_bad=1; break; fi
  147. case "$it" in '*'|'**'|'.'|'./'|'/'|'') fail error "scope: unbounded ('$it') — a loop that may touch anything is not bounded"; scope_bad=1; break ;; esac
  148. done
  149. [[ "$scope_bad" -eq 0 ]] && pass
  150. fi
  151. # permission_mode present + valid
  152. case "$PMODE" in
  153. plan|dontAsk|auto|acceptEdits|bypassPermissions) pass ;;
  154. "") fail error "permission_mode: missing" ;;
  155. *) fail error "permission_mode: invalid ('$PMODE')" ;;
  156. esac
  157. # permission_mode consistent with tier (warning)
  158. case "$TIER" in
  159. L1) case "$PMODE" in plan|dontAsk) pass ;; *) fail warning "permission_mode '$PMODE' is broad for L1 (report-only) — prefer plan or dontAsk" ;; esac ;;
  160. L2) case "$PMODE" in dontAsk|auto|acceptEdits) pass ;; *) fail warning "permission_mode '$PMODE' fits L2 poorly — prefer dontAsk/auto/acceptEdits" ;; esac ;;
  161. L3) case "$PMODE" in bypassPermissions) pass ;; *) fail warning "L3 unattended usually needs bypassPermissions in a container (got '$PMODE')" ;; esac ;;
  162. *) : ;;
  163. esac
  164. # ── L2+ checks (code-changing tiers) ────────────────────────────────────────
  165. if [[ "$is_l2plus" -eq 1 ]]; then
  166. require error "$(filled "$VERIFY" && echo 1 || echo 0)" "verify: no gate command — a code-changing loop with no gate is invalid"
  167. require error "$(filled "$GUARD" && echo 1 || echo 0)" "guard: no must-always-pass command at $TIER"
  168. if [[ "$WORKTREE" == "true" ]]; then pass; else fail error "worktree: must be true at $TIER — isolate code changes"; fi
  169. require warning "$(filled "$LANDVIA" && echo 1 || echo 0)" "land_via: undefined — name who gates+lands (e.g. fleet-ops)"
  170. fi
  171. # ── L3-specific isolation check ─────────────────────────────────────────────
  172. if [[ "$TIER" == "L3" ]]; then
  173. if printf '%s %s' "$ESCAL" "${SCOPE_ITEMS[*]:-}" | grep -Eqi 'container|isolat|sandbox|devcontainer'; then
  174. pass
  175. else
  176. fail warning "L3 declares no isolation boundary — bypassPermissions is only safe in a container/VM; note it in escalation"
  177. fi
  178. fi
  179. # ── verdict ─────────────────────────────────────────────────────────────────
  180. ERRORS=0; WARNINGS=0
  181. for s in "${FIND_SEV[@]:-}"; do
  182. [[ "$s" == "error" ]] && ERRORS=$((ERRORS+1))
  183. [[ "$s" == "warning" ]] && WARNINGS=$((WARNINGS+1))
  184. done
  185. SCORE=0
  186. [[ "$CHECKS_TOTAL" -gt 0 ]] && SCORE=$(( CHECKS_PASS * 100 / CHECKS_TOTAL ))
  187. READY=1
  188. [[ "$ERRORS" -gt 0 ]] && READY=0
  189. [[ "$SCORE" -lt "$MIN" ]] && READY=0
  190. [[ "$STRICT" -eq 1 && "$WARNINGS" -gt 0 ]] && READY=0
  191. # ── output ──────────────────────────────────────────────────────────────────
  192. if [[ "$JSON" -eq 1 ]]; then
  193. printf '{\n "data": [\n'
  194. for i in "${!FIND_SEV[@]}"; do
  195. msg="${FIND_MSG[$i]//\\/\\\\}"; msg="${msg//\"/\\\"}"
  196. sep=","; [[ "$i" -eq $(( ${#FIND_SEV[@]} - 1 )) ]] && sep=""
  197. printf ' {"severity": "%s", "message": "%s"}%s\n' "${FIND_SEV[$i]}" "$msg" "$sep"
  198. done
  199. printf ' ],\n "meta": {"count": %d, "errors": %d, "warnings": %d, "score": %d, "min": %d, "ready": %s, "tier": "%s", "schema": "claude-mods.loop-ops.check/v1"}\n}\n' \
  200. "${#FIND_SEV[@]}" "$ERRORS" "$WARNINGS" "$SCORE" "$MIN" "$([[ "$READY" -eq 1 ]] && echo true || echo false)" "${TIER:-unknown}"
  201. else
  202. if [[ ${#FIND_SEV[@]} -gt 0 ]]; then
  203. for i in "${!FIND_SEV[@]}"; do
  204. printf '%-7s %s\n' "$(printf '%s' "${FIND_SEV[$i]}" | tr '[:lower:]' '[:upper:]')" "${FIND_MSG[$i]}"
  205. done
  206. fi
  207. verdict="$([[ "$READY" -eq 1 ]] && echo READY || echo "NOT READY")"
  208. vstate="$([[ "$READY" -eq 1 ]] && echo ok || echo bad)"
  209. {
  210. term_panel_open loop "loop ${TERM_DOT} audit" "${NAME:-$(basename "$(dirname "$CFG")")}"
  211. term_panel_vert
  212. term_status_row "$vstate" "$verdict $(term_pip_bar score "$SCORE" 100)" "score $SCORE/100 ${TERM_DOT} tier ${TIER:-?}"
  213. term_status_row "$([[ "$ERRORS" -eq 0 ]] && echo ok || echo bad)" "$ERRORS error(s)" "must be 0 to be ready"
  214. term_status_row "$([[ "$WARNINGS" -eq 0 ]] && echo ok || echo warn)" "$WARNINGS warning(s)" "$([[ "$STRICT" -eq 1 ]] && echo 'block under --strict' || echo advisory)"
  215. term_panel_vert
  216. term_panel_close "min $MIN ${TERM_DOT} fix errors before scheduling" ""
  217. } >&2
  218. fi
  219. [[ "$READY" -eq 1 ]] && exit "$EX_OK" || exit "$EX_FINDINGS"