#!/usr/bin/env bash # run-agent-loop.sh — drives Qwen Code one STATUS.md step at a time. # The script (not the model) decides: what's next, whether tests pass, commit vs rollback, when to stop. # # Usage: ./scripts/run-agent-loop.sh # Options (env vars, all optional): # AGENT_CMD agent command; prompt is appended as last arg (default: "qwen --yolo -p") # MAX_STEPS max agent runs in total (default: 60) # MAX_RETRIES attempts per step before giving up (default: 3) # STEP_TIMEOUT per-run time limit, e.g. 30m, 1h (default: 45m) # PAUSE_AT_PHASE 1 = wait for Enter between phases, 0 = don't (default: 1) # STOP_AFTER_PHASE stop once this phase number is finished (default: run to the end) # PROMPT_FILE prompt template (default: PROMPT_ONE_STEP.md) # # WARNING: --yolo lets the agent run ANY shell command without asking. # Run inside this project folder only, ideally in a VM/container, with git initialised. set -u cd "$(dirname "$0")/.." AGENT_CMD="${AGENT_CMD:-qwen --yolo -p}" MAX_STEPS="${MAX_STEPS:-60}" MAX_RETRIES="${MAX_RETRIES:-3}" STEP_TIMEOUT="${STEP_TIMEOUT:-45m}" PAUSE_AT_PHASE="${PAUSE_AT_PHASE:-1}" STOP_AFTER_PHASE="${STOP_AFTER_PHASE:-}" PROMPT_FILE="${PROMPT_FILE:-PROMPT_ONE_STEP.md}" LOOP_DIR=".loop"; LOG_DIR="$LOOP_DIR/logs" FEEDBACK="$LOOP_DIR/feedback.md"; PASS_FILE="$LOOP_DIR/pass_count" mkdir -p "$LOG_DIR" read -ra AGENT <<< "$AGENT_CMD" # --- helpers --------------------------------------------------------------- if command -v timeout >/dev/null 2>&1; then TO=timeout elif command -v gtimeout >/dev/null 2>&1; then TO=gtimeout else TO=""; echo "note: no 'timeout' command found; runs will not be time-limited"; fi with_timeout() { local t="$1"; shift; if [ -n "$TO" ]; then "$TO" "$t" "$@"; else "$@"; fi; } # First unchecked numbered step (e.g. 1.5). Stretch goals (S1...) are ignored on purpose. next_step() { sed -n 's/^- \[ \] \*\*\([0-9][0-9]*\.[0-9][0-9]*\)\*\*.*/\1/p' STATUS.md | head -1; } total_steps() { grep -cE '^- \[[ xX]\] \*\*[0-9]+\.[0-9]+\*\*' STATUS.md; } TEST_OUT=""; TEST_PASS="" run_tests() { # returns 0 = ok/skipped, 1 = failed TEST_OUT=""; TEST_PASS="" if [ -f package.json ] && grep -q '"test"' package.json \ && find tests -name '*.js' 2>/dev/null | grep -q .; then TEST_OUT="$(with_timeout 5m npm test 2>&1)" || { return 1; } TEST_PASS="$(printf '%s\n' "$TEST_OUT" | grep -E '^(ℹ|#) pass ' | tail -1 | awk '{print $3}')" # guard: the number of passing tests must never drop (stops "fix" by deleting tests) if [ -n "$TEST_PASS" ] && [ -f "$PASS_FILE" ]; then local prev; prev="$(cat "$PASS_FILE")" if [ "$TEST_PASS" -lt "$prev" ] 2>/dev/null; then TEST_OUT="Passing test count dropped from $prev to $TEST_PASS. Do not delete or weaken tests. Restore them. $TEST_OUT" return 1 fi fi fi if [ -x scripts/smoke.sh ]; then # optional extra check you can add later TEST_OUT="$TEST_OUT $(with_timeout 2m ./scripts/smoke.sh 2>&1)" || return 1 fi return 0 } rollback() { git reset -q --hard HEAD && git clean -qfd; } commit() { git add -A && git commit -qm "$1" 2>/dev/null || true; } # --- preflight ------------------------------------------------------------- [ -f STATUS.md ] && [ -f "$PROMPT_FILE" ] || { echo "Run from the project; STATUS.md and $PROMPT_FILE needed."; exit 2; } command -v "${AGENT[0]}" >/dev/null 2>&1 || { echo "Agent command '${AGENT[0]}' not found."; exit 2; } [ "$(total_steps)" -ge 1 ] || { echo "No numbered steps found in STATUS.md (format: '- [ ] **1.1** ...')."; exit 2; } git rev-parse --git-dir >/dev/null 2>&1 || git init -q # make sure commits can't silently fail (no identity configured is a common cause) git config user.email >/dev/null 2>&1 || git config user.email "loop@localhost" git config user.name >/dev/null 2>&1 || git config user.name "agent-loop" git add -A && git commit -qm "loop: baseline" 2>/dev/null git rev-parse --verify HEAD >/dev/null 2>&1 || { echo "Could not create a git baseline commit; aborting (rollback needs one)."; exit 2; } trap 'echo; echo "Interrupted. Working tree left as-is (uncommitted changes may exist)."; exit 130' INT # --- main loop ------------------------------------------------------------- last_step=""; attempt=0; prev_phase="" echo "Loop starting. Logs in $LOG_DIR/. Ctrl+C to stop." for ((n = 1; n <= MAX_STEPS; n++)); do step="$(next_step)" if [ -z "$step" ]; then echo "✅ All numbered steps are ticked. Done."; exit 0; fi phase="${step%%.*}" if [ -n "$STOP_AFTER_PHASE" ] && [ "$phase" -gt "$STOP_AFTER_PHASE" ]; then echo "✅ Reached STOP_AFTER_PHASE=$STOP_AFTER_PHASE. Next up would be step $step."; exit 0 fi if [ -n "$prev_phase" ] && [ "$phase" != "$prev_phase" ] && [ "$PAUSE_AT_PHASE" = "1" ]; then echo; echo "── Phase $prev_phase complete (HEAD: $(git log -1 --format=%s)). Playtest / review now." read -r -p "Press Enter to start phase $phase (Ctrl+C to stop)… " _ || true fi prev_phase="$phase" if [ "$step" = "$last_step" ]; then attempt=$((attempt + 1)); else attempt=1; fi last_step="$step" if [ "$attempt" -gt "$MAX_RETRIES" ]; then echo "🛑 Stuck on step $step after $MAX_RETRIES attempts. See $LOG_DIR/step-$step-try-*.log and $FEEDBACK" exit 1 fi prompt="$(cat "$PROMPT_FILE") ## This run Target step: **$step** (attempt $attempt of $MAX_RETRIES)" [ -s "$FEEDBACK" ] && prompt="$prompt ## Feedback from the previous attempt (fix this first) $(cat "$FEEDBACK")" log="$LOG_DIR/step-$step-try-$attempt.log" echo; echo "▶ [$n/$MAX_STEPS] step $step, attempt $attempt → $log" with_timeout "$STEP_TIMEOUT" "${AGENT[@]}" "$prompt" < /dev/null 2>&1 | tee "$log" rc=${PIPESTATUS[0]} note=""; [ "$rc" = "124" ] && note="The previous attempt TIMED OUT after $STEP_TIMEOUT. Do less per run; avoid commands that never exit (e.g. 'npm start' in the foreground)." after="$(next_step)" if run_tests; then [ -n "$TEST_PASS" ] && echo "$TEST_PASS" > "$PASS_FILE" if [ "$after" != "$step" ]; then commit "step $step: done"; : > "$FEEDBACK" echo "✔ step $step verified and committed (tests: ${TEST_PASS:-n/a})" else commit "step $step: wip (not ticked)" printf '%s\nCode exists and tests pass, but step %s is NOT ticked in STATUS.md. Check the Acceptance line; if truly met, tick it (and update Session Log + HANDOFF.md). If not, finish the missing work.\n' "$note" "$step" > "$FEEDBACK" echo "… step $step not ticked; keeping work, retrying" fi else rollback { [ -n "$note" ] && echo "$note"; echo "Your last attempt was rolled back because verification FAILED. Output (tail):"; echo '```'; printf '%s\n' "$TEST_OUT" | tail -40; echo '```'; } > "$FEEDBACK" echo "✘ step $step failed verification; rolled back" fi done echo "Hit MAX_STEPS=$MAX_STEPS. Re-run to continue."; exit 0