#!/bin/bash # Autonomous AI agent loop orchestrator # Combines generator-evaluator architecture with iterative context-reset pattern. # # Usage: # ./loop.sh [options] # # Options: # --mode Operating mode (default: from config.json) # --max Maximum iterations (default: from config.json) # --skip-eval Skip evaluator pass # --tool AI tool to use (default: from config.json) # --no-hooks Don't install stop hooks # --dry-run Print assembled prompts without running agents # --resume Skip already-passed stories (explicit mode) # --replan (reserved — not yet implemented) # # Each iteration: # 1. Generator: picks highest-priority incomplete story, does the work # 2. Evaluator: verifies the work, can PASS or REJECT # Both get fresh context windows. Loop continues until all stories pass or max iterations. set -euo pipefail # --- Exit codes --- EXIT_OK=0 # All stories complete EXIT_ERROR=1 # Configuration or runtime error EXIT_MAX_ITERATIONS=2 # Max iterations reached, work remains EXIT_ALL_BLOCKED=3 # All remaining stories blocked for human review # --- Resolve paths --- LOOP_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$LOOP_DIR/.." && pwd)" export LOOP_DIR PROJECT_ROOT # --- Lockfile (prevent concurrent runs) --- LOCKFILE="$LOOP_DIR/.loop.lock" acquire_lock() { # mkdir is atomic on POSIX — prevents race between check and create if ! mkdir "$LOCKFILE" 2>/dev/null; then local old_pid old_pid=$(cat "$LOCKFILE/pid" 2>/dev/null) if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then echo "[loop] ERROR: Another loop instance is running (PID $old_pid)." echo "[loop] If this is stale, remove $LOCKFILE and retry." exit 1 fi # Stale lockfile — previous run crashed without cleanup rm -rf "$LOCKFILE" mkdir "$LOCKFILE" fi echo $$ > "$LOCKFILE/pid" } release_lock() { rm -rf "$LOCKFILE" } acquire_lock # --- Source libraries --- source "$LOOP_DIR/lib/hooks.sh" source "$LOOP_DIR/lib/state.sh" source "$LOOP_DIR/lib/archive.sh" source "$LOOP_DIR/lib/prompt.sh" # --- Logging --- log() { echo "[loop] $*"; } log_header() { echo "" echo "═══════════════════════════════════════════════════════" echo " $*" echo "═══════════════════════════════════════════════════════" echo "" } # --- Preflight checks --- if ! command -v jq &>/dev/null && ! command -v python3 &>/dev/null; then log "ERROR: Either jq or python3 is required. Install one and retry." exit 1 fi # --- macOS timeout compatibility --- # macOS doesn't have GNU timeout. Use gtimeout (from coreutils) or a perl fallback. if ! command -v timeout &>/dev/null; then if command -v gtimeout &>/dev/null; then timeout() { gtimeout "$@"; } else # Perl-based fallback: runs command with alarm signal timeout() { local duration="$1"; shift perl -e ' alarm shift @ARGV; exec @ARGV; ' "$duration" "$@" } fi fi # --- Load config defaults --- CONFIG_FILE="$LOOP_DIR/config.json" config_default() { get_config_value "$1" "$2"; } TOOL=$(config_default ".tool" "claude") MODE=$(config_default ".mode" "implement") MAX_ITERATIONS=$(config_default ".maxIterations" "20") SKIP_EVAL=$(config_default ".skipEval" "false") EVAL_RETRIES=$(config_default ".evalRetries" "2") AUTO_HOOKS=$(config_default ".autoHooks" "true") DRY_RUN=false RESUME=false # --- Parse CLI args (override config) --- while [[ $# -gt 0 ]]; do case $1 in --mode) MODE="$2"; shift 2 ;; --mode=*) MODE="${1#*=}"; shift ;; --max) MAX_ITERATIONS="$2"; shift 2 ;; --max=*) MAX_ITERATIONS="${1#*=}"; shift ;; --skip-eval) SKIP_EVAL=true; shift ;; --tool) TOOL="$2"; shift 2 ;; --tool=*) TOOL="${1#*=}"; shift ;; --no-hooks) AUTO_HOOKS=false; shift ;; --dry-run) DRY_RUN=true; shift ;; --headless) export LOOP_HEADLESS=true; shift ;; --resume) RESUME=true; shift ;; --replan) log "ERROR: --replan is not yet implemented. Use /loop-plan interactively."; exit 1 ;; [0-9]*) MAX_ITERATIONS="$1"; shift ;; *) log "Unknown option: $1"; exit 1 ;; esac done export ITERATION=0 MAX_ITERATIONS MODE # --- Validate --- if [[ ! "$MODE" =~ ^(implement|explore|fix)$ ]]; then log "ERROR: Invalid mode '$MODE'. Must be: implement, explore, fix" exit 1 fi if [[ ! "$TOOL" =~ ^(claude|amp)$ ]]; then log "ERROR: Invalid tool '$TOOL'. Must be: claude, amp" exit 1 fi # --- Setup --- cd "$PROJECT_ROOT" cleanup() { [ -n "${LOOP_AGENT_TMPFILE:-}" ] && rm -f "$LOOP_AGENT_TMPFILE" [ "$AUTO_HOOKS" = true ] && remove_hooks release_lock } LOOP_AGENT_TMPFILE="" if [ "$AUTO_HOOKS" = true ]; then install_hooks fi trap cleanup EXIT INT TERM check_archive # Validate prd.json exists (AFTER archive check, which may delete it on branch change) if [ ! -f "$LOOP_DIR/prd.json" ]; then log "ERROR: No prd.json found. Run /loop-plan first to create one." exit 1 fi validate_prd # Run project init script if it exists if [ -f "$LOOP_DIR/init.sh" ]; then log "Running init.sh..." bash "$LOOP_DIR/init.sh" fi # Ensure correct git branch BRANCH=$(prd_branch_name 2>/dev/null || echo "") if [ -n "$BRANCH" ]; then CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then log "Switching to branch: $BRANCH" git checkout "$BRANCH" 2>/dev/null || \ git checkout -b "$BRANCH" "origin/$BRANCH" 2>/dev/null || \ git checkout -b "$BRANCH" fi fi # --- Agent runner --- # Runs a prompt through the selected AI tool. # # Interactive (default): Pipes prompt to claude WITHOUT --print. # This gives the full interactive CC UI — tool calls, file edits, etc. # A Stop hook (installed at startup) sends SIGINT to the loop when claude # finishes, which returns control to the while loop for the next iteration. # State is tracked via files (prd.json, .verdict), not stdout. # # Headless (LOOP_HEADLESS=true): Uses claude --print for CI/background. # Output captured to file for verdict parsing. run_agent() { local prompt="$1" local role="${2:-}" rm -f "$LOOP_DIR/.verdict" local agent_exit=0 if [ "${LOOP_HEADLESS:-false}" != "true" ]; then # --- Interactive mode (Ralph pattern) --- # Pipe prompt to claude without --print — full interactive UI. # The Stop hook handles session exit. ( case "$TOOL" in claude) printf '%s\n' "$prompt" | claude --dangerously-skip-permissions ;; amp) printf '%s\n' "$prompt" | amp --dangerously-allow-all ;; *) log "ERROR: Unknown tool '$TOOL'" exit 1 ;; esac ) || agent_exit=$? sleep 2 # Brief pause between sessions # Read verdict from file if evaluator wrote one if [ "$role" = "evaluator" ] && [ -f "$LOOP_DIR/.verdict" ]; then cat "$LOOP_DIR/.verdict" fi else # --- Headless mode --- local output_file output_file=$(mktemp) LOOP_AGENT_TMPFILE="$output_file" ( case "$TOOL" in claude) printf '%s\n' "$prompt" | timeout "${LOOP_AGENT_TIMEOUT:-600}" \ claude --dangerously-skip-permissions --output-format text \ --print 2>&1 > "$output_file" ;; amp) printf '%s\n' "$prompt" | timeout "${LOOP_AGENT_TIMEOUT:-600}" \ amp --dangerously-allow-all 2>&1 > "$output_file" ;; *) log "ERROR: Unknown tool '$TOOL'" exit 1 ;; esac ) || agent_exit=$? if [ "$agent_exit" -ne 0 ] && [ ! -s "$output_file" ]; then log "WARNING: Agent exited with code $agent_exit and produced no output." fi cat "$output_file" rm -f "$output_file" LOOP_AGENT_TMPFILE="" fi } # --- Parse evaluator verdict --- parse_verdict() { local output="$1" if echo "$output" | grep -q "REJECT"; then # Extract rejection reason (supports multiline) local reason reason=$(echo "$output" | sed -n '//,/<\/rejection_reason>/p' \ | sed '1s/.*//' | sed '$s/<\/rejection_reason>.*//' \ | tr '\n' ' ' | sed 's/ */ /g' | sed 's/^ //;s/ $//') [ -z "$reason" ] && reason="Rejected without specific reason" echo "REJECT:${reason}" elif echo "$output" | grep -q "PASS"; then echo "PASS" else # No explicit verdict — fail-safe: treat as reject so broken evaluators don't silently approve log "WARNING: No verdict tag found in evaluator output. Treating as REJECT (fail-safe)." echo "REJECT:Evaluator produced no verdict tag — output may be malformed" fi } # --- Main loop --- log_header "Loop Starting" log "Mode: $MODE" log "Tool: $TOOL" log "Max iter: $MAX_ITERATIONS" log "Eval: $([[ $SKIP_EVAL == true ]] && echo 'off' || echo 'on')" log "Dry run: $([[ $DRY_RUN == true ]] && echo 'yes' || echo 'no')" log "Project: $PROJECT_ROOT" log "Stories: $(story_counts 2>/dev/null || echo 'N/A')" echo "" while [ "$ITERATION" -lt "$MAX_ITERATIONS" ]; do ITERATION=$((ITERATION + 1)) export ITERATION # Check if all stories already pass if all_stories_pass 2>/dev/null; then log_header "All Stories Complete! ($(story_counts))" snapshot_for_archive exit 0 fi # Capture which story the generator will work on (highest-priority incomplete) CURRENT_STORY_ID=$(next_story_id 2>/dev/null || echo "") export CURRENT_STORY_ID # No actionable story — all remaining are passed or blocked if [ -z "$CURRENT_STORY_ID" ]; then if [ "$RESUME" = true ]; then log "Resume mode: no actionable stories remaining." else log "No actionable stories remaining (all passed or blocked)." fi snapshot_for_archive if any_stories_blocked 2>/dev/null; then log "Some stories are blocked and need human review. Run /loop-triage for details." exit $EXIT_ALL_BLOCKED fi exit $EXIT_OK fi # Capture git state before generator runs (for evaluator diff) PRE_GENERATOR_SHA=$(git rev-parse HEAD 2>/dev/null || echo "") export PRE_GENERATOR_SHA # --- Generator pass --- log_header "Iteration $ITERATION / $MAX_ITERATIONS — GENERATOR${CURRENT_STORY_ID:+ ($CURRENT_STORY_ID)}" GENERATOR_PROMPT=$(build_prompt "generator" "$MODE") # --dry-run: print prompts and exit without running agents if [ "$DRY_RUN" = true ]; then log "=== GENERATOR PROMPT ===" printf '%s\n' "$GENERATOR_PROMPT" echo "" if [ "$SKIP_EVAL" != true ] && [ -n "$CURRENT_STORY_ID" ]; then EVAL_PROMPT=$(build_prompt "evaluator" "$MODE") log "=== EVALUATOR PROMPT ===" printf '%s\n' "$EVAL_PROMPT" fi log "Dry run complete. Showing prompts for story: ${CURRENT_STORY_ID:-unknown}" exit 0 fi GENERATOR_OUTPUT=$(run_agent "$GENERATOR_PROMPT" "generator") # In interactive mode, generator output is empty (displayed in terminal, not captured). # State is tracked via prd.json — the generator updates it directly. if [ "${LOOP_HEADLESS:-false}" = "true" ] && [ -z "$GENERATOR_OUTPUT" ]; then log "WARNING: Generator produced empty output (timeout or crash). Skipping to next iteration." continue fi # --- Scope budget check --- # Verify the generator stayed within configured limits (files modified, lines written). # Advisory in implement/fix modes (log warning), but enforced as rejection reason for evaluator. if [ -n "$PRE_GENERATOR_SHA" ] && [ "$PRE_GENERATOR_SHA" != "" ]; then SCOPE_FILES_MODIFIED=$(git diff --name-only "$PRE_GENERATOR_SHA" HEAD 2>/dev/null | wc -l | tr -d ' ') SCOPE_LINES_WRITTEN=$(git diff --stat "$PRE_GENERATOR_SHA" HEAD 2>/dev/null | tail -1 | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0") MAX_MODIFY=$(config_default ".scopeBudgets.${MODE}.maxFilesToModify" "10") MAX_WRITE=$(config_default ".scopeBudgets.${MODE}.maxLinesToWrite" "500") if [ "${SCOPE_FILES_MODIFIED:-0}" -gt "$MAX_MODIFY" ]; then log "WARNING: Scope budget exceeded — modified $SCOPE_FILES_MODIFIED files (limit: $MAX_MODIFY)" fi if [ "${SCOPE_LINES_WRITTEN:-0}" -gt "$MAX_WRITE" ]; then log "WARNING: Scope budget exceeded — wrote $SCOPE_LINES_WRITTEN lines (limit: $MAX_WRITE)" fi export SCOPE_FILES_MODIFIED SCOPE_LINES_WRITTEN fi # Check for completion sentinel if echo "$GENERATOR_OUTPUT" | grep -q "COMPLETE"; then log_header "Generator signaled COMPLETE ($(story_counts))" snapshot_for_archive exit 0 fi # --- Evaluator pass --- if [ "$SKIP_EVAL" != true ]; then log_header "Iteration $ITERATION / $MAX_ITERATIONS — EVALUATOR${CURRENT_STORY_ID:+ ($CURRENT_STORY_ID)}" if [ -z "$CURRENT_STORY_ID" ]; then log "WARNING: No actionable story ID found. Skipping evaluator." continue fi EVAL_PROMPT=$(build_prompt "evaluator" "$MODE") EVAL_OUTPUT=$(run_agent "$EVAL_PROMPT" "evaluator") if [ -z "$EVAL_OUTPUT" ]; then # In interactive mode, check the verdict file if [ -f "$LOOP_DIR/.verdict" ]; then EVAL_OUTPUT=$(cat "$LOOP_DIR/.verdict") else log "WARNING: Evaluator produced no output and no verdict file. Treating as REJECT." EVAL_OUTPUT="REJECTEvaluator produced no output" fi fi VERDICT=$(parse_verdict "$EVAL_OUTPUT") case "$VERDICT" in PASS) log "Evaluator: PASS" if [ -n "$CURRENT_STORY_ID" ]; then mark_story_pass "$CURRENT_STORY_ID" fi ;; REJECT:*) REASON="${VERDICT#REJECT:}" log "Evaluator: REJECT — $REASON" if [ -n "$CURRENT_STORY_ID" ]; then mark_story_reject "$CURRENT_STORY_ID" "$REASON" # Check retry limit — block story to prevent infinite retries REJECTIONS=$(story_rejections "$CURRENT_STORY_ID") REJECTIONS="${REJECTIONS:-0}" if [ "$REJECTIONS" -ge "$EVAL_RETRIES" ]; then log "WARNING: Story $CURRENT_STORY_ID rejected $REJECTIONS times (limit: $EVAL_RETRIES). Blocking for human review." mark_story_blocked "$CURRENT_STORY_ID" "Rejected $REJECTIONS times. Last: $REASON" append_progress "### BLOCKED: $CURRENT_STORY_ID Rejected $REJECTIONS times. Needs human review. Last reason: $REASON ---" fi fi ;; esac fi done # --- Max iterations reached --- log_header "Max Iterations Reached ($MAX_ITERATIONS)" log "Stories completed: $(story_counts)" log "Run /loop-triage to generate a handoff brief." snapshot_for_archive exit $EXIT_MAX_ITERATIONS