loop-doctor.sh 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. #!/usr/bin/env bash
  2. # Preflight a loop config - will this loop actually RUN, or die at 3am?
  3. #
  4. # loop-audit checks the config is well-formed; loop-doctor checks the loop will
  5. # execute: the gate command's binary resolves, claude/git are on PATH, the budget
  6. # can fit a tick, and the permission mode is achievable from where it launches.
  7. # Modeled on fleet-worker/scripts/fleet-doctor.sh.
  8. #
  9. # Usage: loop-doctor.sh [--offline|--live] [--json] [-q] <loop.config.yaml>
  10. # Input: argv flags + a config path (no stdin).
  11. # Output: stdout = check rows (TSV: state<TAB>check<TAB>detail), or a --json envelope.
  12. # Stderr: the preflight panel, notices, errors.
  13. # Exit: 0 ok, 2 usage, 3 config not found, 4 unparseable, 5 missing core dep,
  14. # 10 a check predicts a runtime failure (a gate binary missing, bypass on
  15. # host without isolation, budget too small for a tick)
  16. #
  17. # --offline (default): no PATH/exec - config-shape + budget-vs-cost + permission/
  18. # isolation coherence. Safe for PR CI.
  19. # --live: adds runtime preflight - claude/git on PATH, the verify/guard
  20. # leading binary resolvable, the kill-switch path's parent exists.
  21. #
  22. # Examples:
  23. # loop-doctor.sh --offline .loops/pr-babysitter/loop.config.yaml
  24. # loop-doctor.sh --live .loops/ci-sweeper/loop.config.yaml
  25. # loop-doctor.sh --live --json .loops/dep-sweeper/loop.config.yaml | jq '.data[] | select(.state=="bad")'
  26. set -uo pipefail
  27. readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 EX_UNPARSEABLE=4 EX_MISSING_DEP=5 EX_FINDINGS=10
  28. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  29. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
  30. else
  31. term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
  32. term_status_row() { shift; printf ' - %s %s\n' "$1" "${2:-}"; }
  33. term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|"
  34. fi
  35. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  36. PRICING="$HERE/../assets/model-pricing.json"
  37. CFG=""; MODE="offline"; JSON=0; QUIET=0
  38. usage() {
  39. cat <<'EOF'
  40. loop-doctor.sh - preflight a loop config (will it actually run?).
  41. Usage:
  42. loop-doctor.sh [--offline|--live] [--json] [-q] <loop.config.yaml>
  43. Options:
  44. --offline config-shape + budget-vs-cost + permission coherence (default; no PATH/exec).
  45. --live adds runtime preflight: claude/git on PATH, verify/guard binary resolvable.
  46. --json emit a JSON envelope.
  47. -q, --quiet suppress the stderr panel.
  48. -h, --help show this help and exit 0.
  49. Exit codes:
  50. 0 ok 2 usage 3 not found 4 unparseable 5 missing dep 10 predicted runtime failure
  51. Examples:
  52. loop-doctor.sh --offline .loops/pr-babysitter/loop.config.yaml
  53. loop-doctor.sh --live .loops/ci-sweeper/loop.config.yaml
  54. loop-doctor.sh --live --json .loops/dep-sweeper/loop.config.yaml | jq '.data[] | select(.state=="bad")'
  55. EOF
  56. }
  57. die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; }
  58. while [[ $# -gt 0 ]]; do
  59. case "$1" in
  60. --offline) MODE="offline"; shift ;;
  61. --live) MODE="live"; shift ;;
  62. --json) JSON=1; shift ;;
  63. -q|--quiet) QUIET=1; shift ;;
  64. -h|--help) usage; exit "$EX_OK" ;;
  65. -*) die_usage "unknown flag: $1" ;;
  66. *) [[ -z "$CFG" ]] || die_usage "unexpected extra argument: $1"; CFG="$1"; shift ;;
  67. esac
  68. done
  69. command -v awk >/dev/null 2>&1 || { echo "loop-doctor: awk required" >&2; exit "$EX_MISSING_DEP"; }
  70. command -v grep >/dev/null 2>&1 || { echo "loop-doctor: grep required" >&2; exit "$EX_MISSING_DEP"; }
  71. [[ -n "$CFG" ]] || die_usage "a loop.config.yaml path is required"
  72. [[ -f "$CFG" ]] || { printf 'error: config not found: %s\n' "$CFG" >&2; exit "$EX_NOTFOUND"; }
  73. grep -Eq '^[a-z_]+:' "$CFG" || { printf 'error: no parseable keys in %s\n' "$CFG" >&2; exit "$EX_UNPARSEABLE"; }
  74. # Pick a working python for the budget-vs-cost check (skipped gracefully if none).
  75. PY=""
  76. for c in python python3 py; do
  77. if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PY="$c"; break; fi
  78. done
  79. # ── flat-YAML readers (no yq), same contract as loop-audit.sh ────────────────
  80. cfg_scalar() {
  81. awk -v k="$1" -v q="'" '
  82. $0 ~ "^"k":" { sub("^"k":[ \t]*",""); sub(/[ \t]*#.*$/,""); gsub(/^[ \t]+|[ \t]+$/,"");
  83. gsub(/^"|"$/,""); gsub("^"q"|"q"$",""); print; exit }' "$CFG"
  84. }
  85. cfg_list_items() {
  86. awk -v k="$1" -v q="'" '
  87. $0 ~ "^"k":" { inlist=1; next }
  88. inlist==1 { if ($0 ~ /^[ \t]*-[ \t]+/) { line=$0; sub(/^[ \t]*-[ \t]+/,"",line); sub(/[ \t]*#.*$/,"",line);
  89. gsub(/^[ \t]+|[ \t]+$/,"",line); gsub(/^"|"$/,"",line); gsub("^"q"|"q"$","",line); if (line!="") print line }
  90. else if ($0 ~ /^[^ \t#]/) { inlist=0 } }' "$CFG"
  91. }
  92. TIER="$(cfg_scalar tier)"; PMODE="$(cfg_scalar permission_mode)"; PATTERN="$(cfg_scalar pattern)"
  93. VERIFY="$(cfg_scalar verify)"; GUARD="$(cfg_scalar guard)"; BUDGET="$(cfg_scalar budget_tokens)"
  94. KILL="$(cfg_scalar kill_switch)"; ESCAL="$(cfg_scalar escalation)"
  95. is_l2plus=0; [[ "$TIER" == "L2" || "$TIER" == "L3" ]] && is_l2plus=1
  96. # ── findings ─────────────────────────────────────────────────────────────
  97. ROWS=() # "state\tcheck\tdetail"
  98. FINDING=0
  99. row() { ROWS+=("$1"$'\t'"$2"$'\t'"$3"); [[ "$1" == "bad" ]] && FINDING=1; }
  100. # leading binary of a command string (first whitespace token; strips a leading VAR= prefix)
  101. lead_bin() { awk '{ for(i=1;i<=NF;i++){ if($i !~ /=/){print $i; exit} } }' <<<"$1"; }
  102. # ── OFFLINE checks ───────────────────────────────────────────────────────
  103. # Permission mode achievability.
  104. case "$PMODE" in
  105. default) row bad "permission_mode" "default is interactive - a headless 'claude -p' tick can't answer prompts; use dontAsk/auto/bypassPermissions" ;;
  106. "") row bad "permission_mode" "missing" ;;
  107. *) row ok "permission_mode" "$PMODE" ;;
  108. esac
  109. # L3 bypass needs an isolation boundary.
  110. if [[ "$TIER" == "L3" && "$PMODE" == "bypassPermissions" ]]; then
  111. if printf '%s %s' "$ESCAL" "$(cfg_list_items scope | tr '\n' ' ')" | grep -Eqi 'container|isolat|sandbox|devcontainer'; then
  112. row ok "isolation" "L3 bypass declares an isolation boundary"
  113. else
  114. row bad "isolation" "L3 + bypassPermissions with no container/sandbox note - only safe in an isolated VM/container"
  115. fi
  116. fi
  117. # Budget vs estimated tokens/run.
  118. if [[ -n "$BUDGET" && "$BUDGET" =~ ^[0-9]+$ && -n "$PY" && -n "$PATTERN" && -f "$PRICING" ]]; then
  119. TPR="$(PR="$PRICING" PAT="$PATTERN" "$PY" -c "import json,os
  120. try:
  121. d=json.load(open(os.environ['PR']))['_pattern_defaults'].get(os.environ['PAT'])
  122. print((int(d['input'])+int(d['output']))*int(d.get('subagents',1)) if d else '')
  123. except Exception: print('')" 2>/dev/null)"
  124. if [[ -n "$TPR" && "$TPR" =~ ^[0-9]+$ ]]; then
  125. if [[ "$BUDGET" -lt "$TPR" ]]; then
  126. row bad "budget" "budget_tokens $BUDGET < ~$TPR est. tokens/run for $PATTERN - a tick can't complete"
  127. else
  128. row ok "budget" "budget_tokens $BUDGET >= ~$TPR est. tokens/run"
  129. fi
  130. fi
  131. fi
  132. # ── LIVE checks ──────────────────────────────────────────────────────────
  133. if [[ "$MODE" == "live" ]]; then
  134. if command -v claude >/dev/null 2>&1; then row ok "claude" "on PATH"; else row warn "claude" "not on PATH - the scheduler that runs 'claude -p' must have it"; fi
  135. if command -v git >/dev/null 2>&1; then
  136. row ok "git" "on PATH"
  137. if [[ "$is_l2plus" -eq 1 ]] && ! git worktree list >/dev/null 2>&1; then
  138. row warn "worktree" "'git worktree' unavailable here - L2+ isolates changes in a worktree"
  139. fi
  140. elif [[ "$is_l2plus" -eq 1 ]]; then
  141. row bad "git" "git not on PATH - L2+ needs it for worktree isolation + landing"
  142. else
  143. row warn "git" "git not on PATH"
  144. fi
  145. # verify / guard leading binary resolvable
  146. for pair in "verify:$VERIFY" "guard:$GUARD"; do
  147. label="${pair%%:*}"; cmd="${pair#*:}"
  148. [[ -z "$cmd" ]] && continue
  149. case "$cmd" in *"<"*">"*) continue ;; esac # unfilled placeholder - audit's job
  150. bin="$(lead_bin "$cmd")"
  151. [[ -z "$bin" ]] && continue
  152. if [[ "$bin" == */* ]]; then
  153. [[ -x "$bin" ]] && row ok "$label" "$bin executable" || row bad "$label" "$bin not executable - the gate can't run"
  154. elif command -v "$bin" >/dev/null 2>&1; then
  155. row ok "$label" "$bin resolves"
  156. else
  157. row bad "$label" "'$bin' not on PATH - the gate command can't run at tick time"
  158. fi
  159. done
  160. # kill-switch path parent exists (only when it clearly names a path)
  161. ks_path="$(grep -oE '[^ "'"'"']*/[^ "'"'"']*' <<<"$KILL" | head -1)"
  162. if [[ -n "$ks_path" ]]; then
  163. parent="$(dirname "$ks_path")"
  164. [[ -d "$parent" || "$parent" == "." ]] && row ok "kill_switch" "sentinel path parent exists ($parent)" \
  165. || row warn "kill_switch" "sentinel parent dir missing ($parent) - create it so the switch works"
  166. fi
  167. fi
  168. # ── output ───────────────────────────────────────────────────────────────
  169. n_bad=0; n_warn=0; n_ok=0
  170. for r in "${ROWS[@]:-}"; do
  171. case "${r%%$'\t'*}" in bad) n_bad=$((n_bad+1));; warn) n_warn=$((n_warn+1));; ok) n_ok=$((n_ok+1));; esac
  172. done
  173. if [[ "$JSON" -eq 1 ]]; then
  174. printf '{\n "data": [\n'
  175. if [[ ${#ROWS[@]} -gt 0 ]]; then
  176. for i in "${!ROWS[@]}"; do
  177. IFS=$'\t' read -r st ck dt <<<"${ROWS[$i]}"
  178. dt="${dt//\\/\\\\}"; dt="${dt//\"/\\\"}"
  179. sep=","; [[ "$i" -eq $(( ${#ROWS[@]} - 1 )) ]] && sep=""
  180. printf ' {"state": "%s", "check": "%s", "detail": "%s"}%s\n' "$st" "$ck" "$dt" "$sep"
  181. done
  182. fi
  183. printf ' ],\n "meta": {"mode": "%s", "ok": %d, "warn": %d, "bad": %d, "will_run": %s, "tier": "%s", "schema": "claude-mods.loop-ops.doctor/v1"}\n}\n' \
  184. "$MODE" "$n_ok" "$n_warn" "$n_bad" "$([[ "$FINDING" -eq 0 ]] && echo true || echo false)" "${TIER:-unknown}"
  185. else
  186. if [[ ${#ROWS[@]} -gt 0 ]]; then
  187. for r in "${ROWS[@]}"; do
  188. IFS=$'\t' read -r st ck dt <<<"$r"
  189. printf '%-5s %-14s %s\n' "$st" "$ck" "$dt"
  190. done
  191. fi
  192. if [[ "$QUIET" -eq 0 ]]; then
  193. verdict="$([[ "$FINDING" -eq 0 ]] && echo "WILL RUN" || echo "WILL FAIL")"
  194. vstate="$([[ "$FINDING" -eq 0 ]] && echo ok || echo bad)"
  195. {
  196. term_panel_open loop "loop ${TERM_DOT} doctor ($MODE)" "$(basename "$(dirname "$CFG")")"
  197. term_panel_vert
  198. term_status_row "$vstate" "$verdict" "$n_bad blocking ${TERM_DOT} $n_warn advisory ${TERM_DOT} $n_ok ok"
  199. [[ "$MODE" == "offline" ]] && term_status_row skip "run --live before scheduling" "checks gate binaries + PATH"
  200. term_panel_vert
  201. term_panel_close "audit = well-formed ${TERM_DOT} doctor = will-run" ""
  202. } >&2
  203. fi
  204. fi
  205. [[ "$FINDING" -eq 0 ]] && exit "$EX_OK" || exit "$EX_FINDINGS"