loop-audit.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. #!/usr/bin/env bash
  2. # Score an outer-loop config for readiness before it is scheduled.
  3. #
  4. # Usage: loop-audit.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-init.sh (scaffold) and references/risk-tiers.md (the rubric).
  16. #
  17. # Examples:
  18. # loop-audit.sh .loops/pr-babysitter/loop.config.yaml
  19. # loop-audit.sh --json .loops/dep-sweeper/loop.config.yaml | jq '.data[] | select(.severity=="error")'
  20. # loop-audit.sh --min 80 --strict .loops/ci-sweeper/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-audit.sh — score an outer-loop config for readiness.
  39. Usage:
  40. loop-audit.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-audit.sh .loops/pr-babysitter/loop.config.yaml
  50. loop-audit.sh --json .loops/dep-sweeper/loop.config.yaml | jq '.data[] | select(.severity=="error")'
  51. loop-audit.sh --min 80 --strict .loops/ci-sweeper/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. # Unparseable: no top-level `key:` lines at all.
  70. if ! grep -Eq '^[a-z_]+:' "$CFG"; then
  71. printf 'error: no parseable top-level keys in %s\n' "$CFG" >&2
  72. exit "$EX_UNPARSEABLE"
  73. fi
  74. # ── flat-YAML readers (no yq) ───────────────────────────────────────────────
  75. cfg_scalar() { # inline scalar value for `^KEY:`; empty if absent or block-list
  76. awk -v k="$1" -v q="'" '
  77. $0 ~ "^"k":" {
  78. sub("^"k":[ \t]*","")
  79. sub(/[ \t]*#.*$/,"")
  80. gsub(/^[ \t]+|[ \t]+$/,"")
  81. gsub(/^"|"$/,""); gsub("^"q"|"q"$","")
  82. print; exit
  83. }' "$CFG"
  84. }
  85. cfg_has_key() { grep -Eq "^$1:" "$CFG"; }
  86. cfg_list_items() { # ` - item` lines under `^KEY:`, until the next top-level key
  87. awk -v k="$1" -v q="'" '
  88. $0 ~ "^"k":" { inlist=1; next }
  89. inlist==1 {
  90. if ($0 ~ /^[ \t]*-[ \t]+/) {
  91. line=$0
  92. sub(/^[ \t]*-[ \t]+/,"",line); sub(/[ \t]*#.*$/,"",line)
  93. gsub(/^[ \t]+|[ \t]+$/,"",line); gsub(/^"|"$/,"",line); gsub("^"q"|"q"$","",line)
  94. if (line != "") print line
  95. } else if ($0 ~ /^[^ \t#]/) { inlist=0 }
  96. }' "$CFG"
  97. }
  98. is_placeholder() { [[ "$1" == *"<"*">"* ]]; } # an unfilled <PLACEHOLDER>
  99. # ── findings + scoring ──────────────────────────────────────────────────────
  100. FIND_SEV=(); FIND_MSG=()
  101. CHECKS_TOTAL=0; CHECKS_PASS=0
  102. add() { FIND_SEV+=("$1"); FIND_MSG+=("$2"); }
  103. pass() { CHECKS_TOTAL=$((CHECKS_TOTAL+1)); CHECKS_PASS=$((CHECKS_PASS+1)); }
  104. fail() { CHECKS_TOTAL=$((CHECKS_TOTAL+1)); add "$1" "$2"; } # $1=severity $2=message
  105. # require <severity> <ok?> <message-on-fail> — a present+valid scalar check.
  106. require() { if [[ "$2" -eq 1 ]]; then pass; else fail "$1" "$3"; fi; }
  107. TIER="$(cfg_scalar tier)"
  108. PMODE="$(cfg_scalar permission_mode)"
  109. NAME="$(cfg_scalar name)"
  110. GOAL="$(cfg_scalar goal)"
  111. ESCAL="$(cfg_scalar escalation)"
  112. KILL="$(cfg_scalar kill_switch)"
  113. BUDGET="$(cfg_scalar budget_tokens)"
  114. VERIFY="$(cfg_scalar verify)"
  115. GUARD="$(cfg_scalar guard)"
  116. WORKTREE="$(cfg_scalar worktree)"
  117. LANDVIA="$(cfg_scalar land_via)"
  118. CADENCE="$(cfg_scalar cadence)"
  119. PATTERN="$(cfg_scalar pattern)"
  120. is_l2plus=0; [[ "$TIER" == "L2" || "$TIER" == "L3" ]] && is_l2plus=1
  121. # present-and-not-placeholder predicate
  122. filled() { [[ -n "$1" ]] && ! is_placeholder "$1"; }
  123. # ── always-applicable checks ────────────────────────────────────────────────
  124. require error "$(filled "$NAME" && echo 1 || echo 0)" "name: missing or placeholder"
  125. require warning "$(filled "$PATTERN" && echo 1 || echo 0)" "pattern: missing"
  126. case "$TIER" in L1|L2|L3) pass ;; *) fail error "tier: must be L1|L2|L3 (got '${TIER:-empty}')" ;; esac
  127. require warning "$(filled "$CADENCE" && echo 1 || echo 0)" "cadence: missing"
  128. require error "$(filled "$GOAL" && echo 1 || echo 0)" "goal: missing or placeholder"
  129. require error "$(filled "$ESCAL" && echo 1 || echo 0)" "escalation: undefined — every loop must declare what it escalates"
  130. require error "$(filled "$KILL" && echo 1 || echo 0)" "kill_switch: undefined — no loop ships without a stop signal"
  131. # budget present + numeric
  132. if [[ -n "$BUDGET" && "$BUDGET" =~ ^[0-9]+$ ]]; then pass; else fail warning "budget_tokens: missing or non-numeric — bound the per-run spend"; fi
  133. # scope present + bounded + not placeholder
  134. mapfile -t SCOPE_ITEMS < <(cfg_list_items scope)
  135. SCOPE_INLINE="$(cfg_scalar scope)"
  136. [[ -n "$SCOPE_INLINE" ]] && SCOPE_ITEMS+=("$SCOPE_INLINE")
  137. if ! cfg_has_key scope || [[ ${#SCOPE_ITEMS[@]} -eq 0 ]]; then
  138. fail error "scope: missing — bound what the loop may touch"
  139. else
  140. scope_bad=0
  141. for it in "${SCOPE_ITEMS[@]}"; do
  142. if is_placeholder "$it"; then fail error "scope: unfilled placeholder ('$it')"; scope_bad=1; break; fi
  143. case "$it" in '*'|'**'|'.'|'./'|'/'|'') fail error "scope: unbounded ('$it') — a loop that may touch anything is not bounded"; scope_bad=1; break ;; esac
  144. done
  145. [[ "$scope_bad" -eq 0 ]] && pass
  146. fi
  147. # permission_mode present + valid
  148. case "$PMODE" in
  149. plan|dontAsk|auto|acceptEdits|bypassPermissions) pass ;;
  150. "") fail error "permission_mode: missing" ;;
  151. *) fail error "permission_mode: invalid ('$PMODE')" ;;
  152. esac
  153. # permission_mode consistent with tier (warning)
  154. case "$TIER" in
  155. L1) case "$PMODE" in plan|dontAsk) pass ;; *) fail warning "permission_mode '$PMODE' is broad for L1 (report-only) — prefer plan or dontAsk" ;; esac ;;
  156. L2) case "$PMODE" in dontAsk|auto|acceptEdits) pass ;; *) fail warning "permission_mode '$PMODE' fits L2 poorly — prefer dontAsk/auto/acceptEdits" ;; esac ;;
  157. L3) case "$PMODE" in bypassPermissions) pass ;; *) fail warning "L3 unattended usually needs bypassPermissions in a container (got '$PMODE')" ;; esac ;;
  158. *) : ;;
  159. esac
  160. # ── L2+ checks (code-changing tiers) ────────────────────────────────────────
  161. if [[ "$is_l2plus" -eq 1 ]]; then
  162. require error "$(filled "$VERIFY" && echo 1 || echo 0)" "verify: no gate command — a code-changing loop with no gate is invalid"
  163. require error "$(filled "$GUARD" && echo 1 || echo 0)" "guard: no must-always-pass command at $TIER"
  164. if [[ "$WORKTREE" == "true" ]]; then pass; else fail error "worktree: must be true at $TIER — isolate code changes"; fi
  165. require warning "$(filled "$LANDVIA" && echo 1 || echo 0)" "land_via: undefined — name who gates+lands (e.g. fleet-ops)"
  166. fi
  167. # ── L3-specific isolation check ─────────────────────────────────────────────
  168. if [[ "$TIER" == "L3" ]]; then
  169. if printf '%s %s' "$ESCAL" "${SCOPE_ITEMS[*]:-}" | grep -Eqi 'container|isolat|sandbox|devcontainer'; then
  170. pass
  171. else
  172. fail warning "L3 declares no isolation boundary — bypassPermissions is only safe in a container/VM; note it in escalation"
  173. fi
  174. fi
  175. # ── verdict ─────────────────────────────────────────────────────────────────
  176. ERRORS=0; WARNINGS=0
  177. for s in "${FIND_SEV[@]:-}"; do
  178. [[ "$s" == "error" ]] && ERRORS=$((ERRORS+1))
  179. [[ "$s" == "warning" ]] && WARNINGS=$((WARNINGS+1))
  180. done
  181. SCORE=0
  182. [[ "$CHECKS_TOTAL" -gt 0 ]] && SCORE=$(( CHECKS_PASS * 100 / CHECKS_TOTAL ))
  183. READY=1
  184. [[ "$ERRORS" -gt 0 ]] && READY=0
  185. [[ "$SCORE" -lt "$MIN" ]] && READY=0
  186. [[ "$STRICT" -eq 1 && "$WARNINGS" -gt 0 ]] && READY=0
  187. # ── output ──────────────────────────────────────────────────────────────────
  188. if [[ "$JSON" -eq 1 ]]; then
  189. printf '{\n "data": [\n'
  190. for i in "${!FIND_SEV[@]}"; do
  191. msg="${FIND_MSG[$i]//\\/\\\\}"; msg="${msg//\"/\\\"}"
  192. sep=","; [[ "$i" -eq $(( ${#FIND_SEV[@]} - 1 )) ]] && sep=""
  193. printf ' {"severity": "%s", "message": "%s"}%s\n' "${FIND_SEV[$i]}" "$msg" "$sep"
  194. done
  195. printf ' ],\n "meta": {"count": %d, "errors": %d, "warnings": %d, "score": %d, "min": %d, "ready": %s, "tier": "%s", "schema": "claude-mods.loop-ops.audit/v1"}\n}\n' \
  196. "${#FIND_SEV[@]}" "$ERRORS" "$WARNINGS" "$SCORE" "$MIN" "$([[ "$READY" -eq 1 ]] && echo true || echo false)" "${TIER:-unknown}"
  197. else
  198. if [[ ${#FIND_SEV[@]} -gt 0 ]]; then
  199. for i in "${!FIND_SEV[@]}"; do
  200. printf '%-7s %s\n' "$(printf '%s' "${FIND_SEV[$i]}" | tr '[:lower:]' '[:upper:]')" "${FIND_MSG[$i]}"
  201. done
  202. fi
  203. verdict="$([[ "$READY" -eq 1 ]] && echo READY || echo "NOT READY")"
  204. vstate="$([[ "$READY" -eq 1 ]] && echo ok || echo bad)"
  205. {
  206. term_panel_open loop "loop ${TERM_DOT} audit" "${NAME:-$(basename "$(dirname "$CFG")")}"
  207. term_panel_vert
  208. term_status_row "$vstate" "$verdict $(term_pip_bar score "$SCORE" 100)" "score $SCORE/100 ${TERM_DOT} tier ${TIER:-?}"
  209. term_status_row "$([[ "$ERRORS" -eq 0 ]] && echo ok || echo bad)" "$ERRORS error(s)" "must be 0 to be ready"
  210. term_status_row "$([[ "$WARNINGS" -eq 0 ]] && echo ok || echo warn)" "$WARNINGS warning(s)" "$([[ "$STRICT" -eq 1 ]] && echo 'block under --strict' || echo advisory)"
  211. term_panel_vert
  212. term_panel_close "min $MIN ${TERM_DOT} fix errors before scheduling" ""
  213. } >&2
  214. fi
  215. [[ "$READY" -eq 1 ]] && exit "$EX_OK" || exit "$EX_FINDINGS"