#!/bin/bash # Ralph loop — simple, portable, single-agent # Usage: # ./.agents/ralph/loop.sh # build mode, default iterations # ./.agents/ralph/loop.sh build # build mode # ./.agents/ralph/loop.sh prd "request" # generate PRD via agent # ./.agents/ralph/loop.sh 10 # build mode, 10 iterations # ./.agents/ralph/loop.sh build 1 --no-commit set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${RALPH_ROOT:-${SCRIPT_DIR}/../..}" && pwd)" CONFIG_FILE="${SCRIPT_DIR}/config.sh" DEFAULT_PRD_PATH=".agents/tasks/prd.json" DEFAULT_PROGRESS_PATH=".ralph/progress.md" DEFAULT_AGENTS_PATH="AGENTS.md" DEFAULT_PROMPT_BUILD=".agents/ralph/PROMPT_build.md" DEFAULT_GUARDRAILS_PATH=".ralph/guardrails.md" DEFAULT_ERRORS_LOG_PATH=".ralph/errors.log" DEFAULT_ACTIVITY_LOG_PATH=".ralph/activity.log" DEFAULT_TMP_DIR=".ralph/.tmp" DEFAULT_RUNS_DIR=".ralph/runs" DEFAULT_GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md" DEFAULT_CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md" DEFAULT_ACTIVITY_CMD=".agents/ralph/log-activity.sh" if [[ -n "${RALPH_ROOT:-}" ]]; then agents_path="$RALPH_ROOT/.agents/ralph/agents.sh" else agents_path="$SCRIPT_DIR/agents.sh" fi if [[ -f "$agents_path" ]]; then # shellcheck source=/dev/null source "$agents_path" fi DEFAULT_MAX_ITERATIONS=25 DEFAULT_NO_COMMIT=false DEFAULT_STALE_SECONDS=0 PRD_REQUEST_PATH="" PRD_INLINE="" # Optional config overrides (simple shell vars) if [ -f "$CONFIG_FILE" ]; then # shellcheck source=/dev/null . "$CONFIG_FILE" fi DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-codex}" resolve_agent_cmd() { local name="$1" case "$name" in claude) echo "${AGENT_CLAUDE_CMD:-claude -p --dangerously-skip-permissions \"\$(cat {prompt})\"}" ;; droid) echo "${AGENT_DROID_CMD:-droid exec --skip-permissions-unsafe -f {prompt}}" ;; codex|"") echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}" ;; *) echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}" ;; esac } DEFAULT_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME")" PRD_PATH="${PRD_PATH:-$DEFAULT_PRD_PATH}" PROGRESS_PATH="${PROGRESS_PATH:-$DEFAULT_PROGRESS_PATH}" AGENTS_PATH="${AGENTS_PATH:-$DEFAULT_AGENTS_PATH}" PROMPT_BUILD="${PROMPT_BUILD:-$DEFAULT_PROMPT_BUILD}" GUARDRAILS_PATH="${GUARDRAILS_PATH:-$DEFAULT_GUARDRAILS_PATH}" ERRORS_LOG_PATH="${ERRORS_LOG_PATH:-$DEFAULT_ERRORS_LOG_PATH}" ACTIVITY_LOG_PATH="${ACTIVITY_LOG_PATH:-$DEFAULT_ACTIVITY_LOG_PATH}" TMP_DIR="${TMP_DIR:-$DEFAULT_TMP_DIR}" RUNS_DIR="${RUNS_DIR:-$DEFAULT_RUNS_DIR}" GUARDRAILS_REF="${GUARDRAILS_REF:-$DEFAULT_GUARDRAILS_REF}" CONTEXT_REF="${CONTEXT_REF:-$DEFAULT_CONTEXT_REF}" ACTIVITY_CMD="${ACTIVITY_CMD:-$DEFAULT_ACTIVITY_CMD}" AGENT_CMD="${AGENT_CMD:-$DEFAULT_AGENT_CMD}" MAX_ITERATIONS="${MAX_ITERATIONS:-$DEFAULT_MAX_ITERATIONS}" NO_COMMIT="${NO_COMMIT:-$DEFAULT_NO_COMMIT}" STALE_SECONDS="${STALE_SECONDS:-$DEFAULT_STALE_SECONDS}" abs_path() { local p="$1" if [[ "$p" = /* ]]; then echo "$p" else echo "$ROOT_DIR/$p" fi } PRD_PATH="$(abs_path "$PRD_PATH")" PROGRESS_PATH="$(abs_path "$PROGRESS_PATH")" AGENTS_PATH="$(abs_path "$AGENTS_PATH")" PROMPT_BUILD="$(abs_path "$PROMPT_BUILD")" GUARDRAILS_PATH="$(abs_path "$GUARDRAILS_PATH")" ERRORS_LOG_PATH="$(abs_path "$ERRORS_LOG_PATH")" ACTIVITY_LOG_PATH="$(abs_path "$ACTIVITY_LOG_PATH")" TMP_DIR="$(abs_path "$TMP_DIR")" RUNS_DIR="$(abs_path "$RUNS_DIR")" GUARDRAILS_REF="$(abs_path "$GUARDRAILS_REF")" CONTEXT_REF="$(abs_path "$CONTEXT_REF")" ACTIVITY_CMD="$(abs_path "$ACTIVITY_CMD")" require_agent() { local agent_cmd="${1:-$AGENT_CMD}" local agent_bin agent_bin="${agent_cmd%% *}" if [ -z "$agent_bin" ]; then echo "AGENT_CMD is empty. Set it in config.sh." exit 1 fi if ! command -v "$agent_bin" >/dev/null 2>&1; then echo "Agent command not found: $agent_bin" case "$agent_bin" in codex) echo "Install: npm i -g @openai/codex" ;; claude) echo "Install: curl -fsSL https://claude.ai/install.sh | bash" ;; droid) echo "Install: curl -fsSL https://app.factory.ai/cli | sh" ;; opencode) echo "Install: curl -fsSL https://opencode.ai/install.sh | bash" ;; esac echo "Then authenticate per the CLI's instructions." exit 1 fi } run_agent() { local prompt_file="$1" if [[ "$AGENT_CMD" == *"{prompt}"* ]]; then local escaped escaped=$(printf '%q' "$prompt_file") local cmd="${AGENT_CMD//\{prompt\}/$escaped}" eval "$cmd" else cat "$prompt_file" | eval "$AGENT_CMD" fi } run_agent_inline() { local prompt_file="$1" local prompt_content prompt_content="$(cat "$prompt_file")" local escaped escaped=$(printf "%s" "$prompt_content" | sed "s/'/'\\\\''/g") local cmd="${PRD_AGENT_CMD:-$AGENT_CMD}" if [[ "$cmd" == *"{prompt}"* ]]; then cmd="${cmd//\{prompt\}/'$escaped'}" else cmd="$cmd '$escaped'" fi eval "$cmd" } MODE="build" while [ $# -gt 0 ]; do case "$1" in build|prd) MODE="$1" shift ;; --prompt) PRD_REQUEST_PATH="$2" shift 2 ;; --no-commit) NO_COMMIT=true shift ;; *) if [ "$MODE" = "prd" ]; then PRD_INLINE="${PRD_INLINE:+$PRD_INLINE }$1" shift elif [[ "$1" =~ ^[0-9]+$ ]]; then MAX_ITERATIONS="$1" shift else echo "Unknown arg: $1" exit 1 fi ;; esac done PROMPT_FILE="$PROMPT_BUILD" if [ "$MODE" = "prd" ]; then PRD_USE_INLINE=1 if [ -z "${PRD_AGENT_CMD:-}" ]; then PRD_AGENT_CMD="$AGENT_CMD" PRD_USE_INLINE=0 fi if [ "${RALPH_DRY_RUN:-}" != "1" ]; then require_agent "${PRD_AGENT_CMD:-$AGENT_CMD}" fi if [[ "$PRD_PATH" == *.json ]]; then mkdir -p "$(dirname "$PRD_PATH")" "$TMP_DIR" else mkdir -p "$PRD_PATH" "$TMP_DIR" fi if [ -z "$PRD_REQUEST_PATH" ] && [ -n "$PRD_INLINE" ]; then PRD_REQUEST_PATH="$TMP_DIR/prd-request-$(date +%Y%m%d-%H%M%S)-$$.txt" printf '%s\n' "$PRD_INLINE" > "$PRD_REQUEST_PATH" fi if [ -z "$PRD_REQUEST_PATH" ] || [ ! -f "$PRD_REQUEST_PATH" ]; then echo "PRD request missing. Provide a prompt string or --prompt ." exit 1 fi if [ "${RALPH_DRY_RUN:-}" = "1" ]; then if [[ "$PRD_PATH" == *.json ]]; then if [ ! -f "$PRD_PATH" ]; then { echo '{' echo ' "version": 1,' echo ' "project": "ralph",' echo ' "qualityGates": [],' echo ' "stories": []' echo '}' } > "$PRD_PATH" fi fi exit 0 fi PRD_PROMPT_FILE="$TMP_DIR/prd-prompt-$(date +%Y%m%d-%H%M%S)-$$.md" { echo "You are an autonomous coding agent." echo "Use the \$prd skill to create a Product Requirements Document in JSON." if [[ "$PRD_PATH" == *.json ]]; then echo "Save the PRD to: $PRD_PATH" else echo "Save the PRD as JSON in directory: $PRD_PATH" echo "Filename rules: prd-.json using 1-3 meaningful words." echo "Examples: prd-workout-tracker.json, prd-usage-billing.json" fi echo "Do NOT implement anything." echo "After creating the PRD, end with:" echo "PRD JSON saved to . Close this chat and run \`ralph build\`." echo "" echo "User request:" cat "$PRD_REQUEST_PATH" } > "$PRD_PROMPT_FILE" if [ "$PRD_USE_INLINE" -eq 1 ]; then run_agent_inline "$PRD_PROMPT_FILE" else run_agent "$PRD_PROMPT_FILE" fi exit 0 fi if [ "${RALPH_DRY_RUN:-}" != "1" ]; then require_agent fi if [ ! -f "$PROMPT_FILE" ]; then echo "Prompt not found: $PROMPT_FILE" exit 1 fi if [ "$MODE" != "prd" ] && [ ! -f "$PRD_PATH" ]; then echo "PRD not found: $PRD_PATH" exit 1 fi mkdir -p "$(dirname "$PROGRESS_PATH")" "$TMP_DIR" "$RUNS_DIR" if [ ! -f "$PROGRESS_PATH" ]; then { echo "# Progress Log" echo "Started: $(date)" echo "" echo "## Codebase Patterns" echo "- (add reusable patterns here)" echo "" echo "---" } > "$PROGRESS_PATH" fi if [ ! -f "$GUARDRAILS_PATH" ]; then { echo "# Guardrails (Signs)" echo "" echo "> Lessons learned from failures. Read before acting." echo "" echo "## Core Signs" echo "" echo "### Sign: Read Before Writing" echo "- **Trigger**: Before modifying any file" echo "- **Instruction**: Read the file first" echo "- **Added after**: Core principle" echo "" echo "### Sign: Test Before Commit" echo "- **Trigger**: Before committing changes" echo "- **Instruction**: Run required tests and verify outputs" echo "- **Added after**: Core principle" echo "" echo "---" echo "" echo "## Learned Signs" echo "" } > "$GUARDRAILS_PATH" fi if [ ! -f "$ERRORS_LOG_PATH" ]; then { echo "# Error Log" echo "" echo "> Failures and repeated issues. Use this to add guardrails." echo "" } > "$ERRORS_LOG_PATH" fi if [ ! -f "$ACTIVITY_LOG_PATH" ]; then { echo "# Activity Log" echo "" echo "## Run Summary" echo "" echo "## Events" echo "" } > "$ACTIVITY_LOG_PATH" fi RUN_TAG="$(date +%Y%m%d-%H%M%S)-$$" render_prompt() { local src="$1" local dst="$2" local story_meta="$3" local story_block="$4" local run_id="$5" local iter="$6" local run_log="$7" local run_meta="$8" python3 - "$src" "$dst" "$PRD_PATH" "$AGENTS_PATH" "$PROGRESS_PATH" "$ROOT_DIR" "$GUARDRAILS_PATH" "$ERRORS_LOG_PATH" "$ACTIVITY_LOG_PATH" "$GUARDRAILS_REF" "$CONTEXT_REF" "$ACTIVITY_CMD" "$NO_COMMIT" "$story_meta" "$story_block" "$run_id" "$iter" "$run_log" "$run_meta" <<'PY' import sys from pathlib import Path src = Path(sys.argv[1]).read_text() prd, agents, progress, root = sys.argv[3:7] guardrails = sys.argv[7] errors_log = sys.argv[8] activity_log = sys.argv[9] guardrails_ref = sys.argv[10] context_ref = sys.argv[11] activity_cmd = sys.argv[12] no_commit = sys.argv[13] meta_path = sys.argv[14] if len(sys.argv) > 14 else "" block_path = sys.argv[15] if len(sys.argv) > 15 else "" run_id = sys.argv[16] if len(sys.argv) > 16 else "" iteration = sys.argv[17] if len(sys.argv) > 17 else "" run_log = sys.argv[18] if len(sys.argv) > 18 else "" run_meta = sys.argv[19] if len(sys.argv) > 19 else "" repl = { "PRD_PATH": prd, "AGENTS_PATH": agents, "PROGRESS_PATH": progress, "REPO_ROOT": root, "GUARDRAILS_PATH": guardrails, "ERRORS_LOG_PATH": errors_log, "ACTIVITY_LOG_PATH": activity_log, "GUARDRAILS_REF": guardrails_ref, "CONTEXT_REF": context_ref, "ACTIVITY_CMD": activity_cmd, "NO_COMMIT": no_commit, "RUN_ID": run_id, "ITERATION": iteration, "RUN_LOG_PATH": run_log, "RUN_META_PATH": run_meta, } story = {"id": "", "title": "", "block": ""} quality_gates = [] if meta_path: try: import json meta = json.loads(Path(meta_path).read_text()) story["id"] = meta.get("id", "") or "" story["title"] = meta.get("title", "") or "" quality_gates = meta.get("quality_gates", []) or [] except Exception: pass if block_path and Path(block_path).exists(): story["block"] = Path(block_path).read_text() repl["STORY_ID"] = story["id"] repl["STORY_TITLE"] = story["title"] repl["STORY_BLOCK"] = story["block"] if quality_gates: repl["QUALITY_GATES"] = "\n".join([f"- {g}" for g in quality_gates]) else: repl["QUALITY_GATES"] = "- (none)" for k, v in repl.items(): src = src.replace("{{" + k + "}}", v) Path(sys.argv[2]).write_text(src) PY } select_story() { local meta_out="$1" local block_out="$2" python3 - "$PRD_PATH" "$meta_out" "$block_out" "$STALE_SECONDS" <<'PY' import json import os import sys from pathlib import Path from datetime import datetime, timezone try: import fcntl except Exception: fcntl = None prd_path = Path(sys.argv[1]) meta_out = Path(sys.argv[2]) block_out = Path(sys.argv[3]) stale_seconds = 0 if len(sys.argv) > 4: try: stale_seconds = int(sys.argv[4]) except Exception: stale_seconds = 0 if not prd_path.exists(): meta_out.write_text(json.dumps({"ok": False, "error": "PRD not found"}, indent=2) + "\n") block_out.write_text("") sys.exit(0) def normalize_status(value): if value is None: return "open" return str(value).strip().lower() def parse_ts(value): if not value: return None text = str(value).strip() if text.endswith("Z"): text = text[:-1] + "+00:00" try: return datetime.fromisoformat(text) except Exception: return None def now_iso(): return datetime.now(timezone.utc).isoformat() with prd_path.open("r+", encoding="utf-8") as fh: if fcntl is not None: fcntl.flock(fh.fileno(), fcntl.LOCK_EX) try: try: data = json.load(fh) except Exception as exc: meta_out.write_text(json.dumps({"ok": False, "error": f"Invalid PRD JSON: {exc}"}, indent=2) + "\n") block_out.write_text("") sys.exit(0) stories = data.get("stories") if isinstance(data, dict) else None if not isinstance(stories, list) or not stories: meta_out.write_text(json.dumps({"ok": False, "error": "No stories found in PRD"}, indent=2) + "\n") block_out.write_text("") sys.exit(0) story_index = {s.get("id"): s for s in stories if isinstance(s, dict)} def is_done(story_id: str) -> bool: target = story_index.get(story_id) if not isinstance(target, dict): return False return normalize_status(target.get("status")) == "done" if stale_seconds > 0: now = datetime.now(timezone.utc) for story in stories: if not isinstance(story, dict): continue if normalize_status(story.get("status")) != "in_progress": continue started = parse_ts(story.get("startedAt")) if started is None or (now - started).total_seconds() > stale_seconds: story["status"] = "open" story["startedAt"] = None story["completedAt"] = None story["updatedAt"] = now_iso() candidate = None for story in stories: if not isinstance(story, dict): continue if normalize_status(story.get("status")) != "open": continue deps = story.get("dependsOn") or [] if not isinstance(deps, list): deps = [] if all(is_done(dep) for dep in deps): candidate = story break remaining = sum( 1 for story in stories if isinstance(story, dict) and normalize_status(story.get("status")) != "done" ) meta = { "ok": True, "total": len(stories), "remaining": remaining, "quality_gates": data.get("qualityGates", []) or [], } if candidate: candidate["status"] = "in_progress" if not candidate.get("startedAt"): candidate["startedAt"] = now_iso() candidate["completedAt"] = None candidate["updatedAt"] = now_iso() meta.update({ "id": candidate.get("id", ""), "title": candidate.get("title", ""), }) depends = candidate.get("dependsOn") or [] if not isinstance(depends, list): depends = [] acceptance = candidate.get("acceptanceCriteria") or [] if not isinstance(acceptance, list): acceptance = [] description = candidate.get("description") or "" block_lines = [] block_lines.append(f"### {candidate.get('id', '')}: {candidate.get('title', '')}") block_lines.append(f"Status: {candidate.get('status', 'open')}") block_lines.append( f"Depends on: {', '.join(depends) if depends else 'None'}" ) block_lines.append("") block_lines.append("Description:") block_lines.append(description if description else "(none)") block_lines.append("") block_lines.append("Acceptance Criteria:") if acceptance: block_lines.extend([f"- [ ] {item}" for item in acceptance]) else: block_lines.append("- (none)") block_out.write_text("\n".join(block_lines).rstrip() + "\n") else: block_out.write_text("") fh.seek(0) fh.truncate() json.dump(data, fh, indent=2) fh.write("\n") fh.flush() os.fsync(fh.fileno()) finally: if fcntl is not None: fcntl.flock(fh.fileno(), fcntl.LOCK_UN) meta_out.write_text(json.dumps(meta, indent=2) + "\n") PY } remaining_stories() { local meta_file="$1" python3 - "$meta_file" <<'PY' import json import sys from pathlib import Path data = json.loads(Path(sys.argv[1]).read_text()) print(data.get("remaining", "unknown")) PY } remaining_from_prd() { python3 - "$PRD_PATH" <<'PY' import json import sys from pathlib import Path prd_path = Path(sys.argv[1]) if not prd_path.exists(): print("unknown") sys.exit(0) try: data = json.loads(prd_path.read_text()) except Exception: print("unknown") sys.exit(0) stories = data.get("stories") if isinstance(data, dict) else None if not isinstance(stories, list): print("unknown") sys.exit(0) def normalize_status(value): if value is None: return "open" return str(value).strip().lower() remaining = sum( 1 for story in stories if isinstance(story, dict) and normalize_status(story.get("status")) != "done" ) print(remaining) PY } story_field() { local meta_file="$1" local field="$2" python3 - "$meta_file" "$field" <<'PY' import json import sys from pathlib import Path data = json.loads(Path(sys.argv[1]).read_text()) field = sys.argv[2] print(data.get(field, "")) PY } update_story_status() { local story_id="$1" local new_status="$2" python3 - "$PRD_PATH" "$story_id" "$new_status" <<'PY' import json import os import sys from pathlib import Path from datetime import datetime, timezone try: import fcntl except Exception: fcntl = None prd_path = Path(sys.argv[1]) story_id = sys.argv[2] new_status = sys.argv[3] if not story_id: sys.exit(0) if not prd_path.exists(): sys.exit(0) def now_iso(): return datetime.now(timezone.utc).isoformat() with prd_path.open("r+", encoding="utf-8") as fh: if fcntl is not None: fcntl.flock(fh.fileno(), fcntl.LOCK_EX) try: data = json.load(fh) stories = data.get("stories") if isinstance(data, dict) else None if not isinstance(stories, list): sys.exit(0) for story in stories: if isinstance(story, dict) and story.get("id") == story_id: story["status"] = new_status story["updatedAt"] = now_iso() if new_status == "in_progress": if not story.get("startedAt"): story["startedAt"] = now_iso() story["completedAt"] = None elif new_status == "done": story["completedAt"] = now_iso() if not story.get("startedAt"): story["startedAt"] = now_iso() elif new_status == "open": story["startedAt"] = None story["completedAt"] = None break fh.seek(0) fh.truncate() json.dump(data, fh, indent=2) fh.write("\n") fh.flush() os.fsync(fh.fileno()) finally: if fcntl is not None: fcntl.flock(fh.fileno(), fcntl.LOCK_UN) PY } log_activity() { local message="$1" local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] $message" >> "$ACTIVITY_LOG_PATH" } log_error() { local message="$1" local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] $message" >> "$ERRORS_LOG_PATH" } append_run_summary() { local line="$1" python3 - "$ACTIVITY_LOG_PATH" "$line" <<'PY' import sys from pathlib import Path path = Path(sys.argv[1]) line = sys.argv[2] text = path.read_text().splitlines() out = [] inserted = False for l in text: out.append(l) if not inserted and l.strip() == "## Run Summary": out.append(f"- {line}") inserted = True if not inserted: out = [ "# Activity Log", "", "## Run Summary", f"- {line}", "", "## Events", "", ] + text Path(path).write_text("\n".join(out).rstrip() + "\n") PY } write_run_meta() { local path="$1" local mode="$2" local iter="$3" local run_id="$4" local story_id="$5" local story_title="$6" local started="$7" local ended="$8" local duration="$9" local status="${10}" local log_file="${11}" local head_before="${12}" local head_after="${13}" local commit_list="${14}" local changed_files="${15}" local dirty_files="${16}" { echo "# Ralph Run Summary" echo "" echo "- Run ID: $run_id" echo "- Iteration: $iter" echo "- Mode: $mode" if [ -n "$story_id" ]; then echo "- Story: $story_id: $story_title" fi echo "- Started: $started" echo "- Ended: $ended" echo "- Duration: ${duration}s" echo "- Status: $status" echo "- Log: $log_file" echo "" echo "## Git" echo "- Head (before): ${head_before:-unknown}" echo "- Head (after): ${head_after:-unknown}" echo "" echo "### Commits" if [ -n "$commit_list" ]; then echo "$commit_list" else echo "- (none)" fi echo "" echo "### Changed Files (commits)" if [ -n "$changed_files" ]; then echo "$changed_files" else echo "- (none)" fi echo "" echo "### Uncommitted Changes" if [ -n "$dirty_files" ]; then echo "$dirty_files" else echo "- (clean)" fi echo "" } > "$path" } git_head() { if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true else echo "" fi } git_commit_list() { local before="$1" local after="$2" if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then git -C "$ROOT_DIR" log --oneline "$before..$after" | sed 's/^/- /' else echo "" fi } git_changed_files() { local before="$1" local after="$2" if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then git -C "$ROOT_DIR" diff --name-only "$before" "$after" | sed 's/^/- /' else echo "" fi } git_dirty_files() { if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then git -C "$ROOT_DIR" status --porcelain | awk '{print "- " $2}' else echo "" fi } echo "Ralph mode: $MODE" echo "Max iterations: $MAX_ITERATIONS" echo "PRD: $PRD_PATH" HAS_ERROR="false" for i in $(seq 1 "$MAX_ITERATIONS"); do echo "" echo "═══════════════════════════════════════════════════════" echo " Ralph Iteration $i of $MAX_ITERATIONS" echo "═══════════════════════════════════════════════════════" STORY_META="" STORY_BLOCK="" ITER_START=$(date +%s) ITER_START_FMT=$(date '+%Y-%m-%d %H:%M:%S') if [ "$MODE" = "build" ]; then STORY_META="$TMP_DIR/story-$RUN_TAG-$i.json" STORY_BLOCK="$TMP_DIR/story-$RUN_TAG-$i.md" select_story "$STORY_META" "$STORY_BLOCK" REMAINING="$(remaining_stories "$STORY_META")" if [ "$REMAINING" = "unknown" ]; then echo "Could not parse stories from PRD: $PRD_PATH" exit 1 fi if [ "$REMAINING" = "0" ]; then echo "No remaining stories." exit 0 fi STORY_ID="$(story_field "$STORY_META" "id")" STORY_TITLE="$(story_field "$STORY_META" "title")" if [ -z "$STORY_ID" ]; then echo "No actionable open stories (all blocked or in progress). Remaining: $REMAINING" exit 0 fi fi HEAD_BEFORE="$(git_head)" PROMPT_RENDERED="$TMP_DIR/prompt-$RUN_TAG-$i.md" LOG_FILE="$RUNS_DIR/run-$RUN_TAG-iter-$i.log" RUN_META="$RUNS_DIR/run-$RUN_TAG-iter-$i.md" render_prompt "$PROMPT_FILE" "$PROMPT_RENDERED" "$STORY_META" "$STORY_BLOCK" "$RUN_TAG" "$i" "$LOG_FILE" "$RUN_META" if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then log_activity "ITERATION $i start (mode=$MODE story=$STORY_ID)" else log_activity "ITERATION $i start (mode=$MODE)" fi set +e if [ "${RALPH_DRY_RUN:-}" = "1" ]; then echo "[RALPH_DRY_RUN] Skipping agent execution." | tee "$LOG_FILE" CMD_STATUS=0 else run_agent "$PROMPT_RENDERED" 2>&1 | tee "$LOG_FILE" CMD_STATUS=$? fi set -e if [ "$CMD_STATUS" -eq 130 ] || [ "$CMD_STATUS" -eq 143 ]; then echo "Interrupted." exit "$CMD_STATUS" fi ITER_END=$(date +%s) ITER_END_FMT=$(date '+%Y-%m-%d %H:%M:%S') ITER_DURATION=$((ITER_END - ITER_START)) HEAD_AFTER="$(git_head)" log_activity "ITERATION $i end (duration=${ITER_DURATION}s)" if [ "$CMD_STATUS" -ne 0 ]; then log_error "ITERATION $i command failed (status=$CMD_STATUS)" HAS_ERROR="true" fi COMMIT_LIST="$(git_commit_list "$HEAD_BEFORE" "$HEAD_AFTER")" CHANGED_FILES="$(git_changed_files "$HEAD_BEFORE" "$HEAD_AFTER")" DIRTY_FILES="$(git_dirty_files)" STATUS_LABEL="success" if [ "$CMD_STATUS" -ne 0 ]; then STATUS_LABEL="error" fi if [ "$MODE" = "build" ] && [ "$NO_COMMIT" = "false" ] && [ -n "$DIRTY_FILES" ]; then log_error "ITERATION $i left uncommitted changes; review run summary at $RUN_META" fi write_run_meta "$RUN_META" "$MODE" "$i" "$RUN_TAG" "${STORY_ID:-}" "${STORY_TITLE:-}" "$ITER_START_FMT" "$ITER_END_FMT" "$ITER_DURATION" "$STATUS_LABEL" "$LOG_FILE" "$HEAD_BEFORE" "$HEAD_AFTER" "$COMMIT_LIST" "$CHANGED_FILES" "$DIRTY_FILES" if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | story=$STORY_ID | duration=${ITER_DURATION}s | status=$STATUS_LABEL" else append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | duration=${ITER_DURATION}s | status=$STATUS_LABEL" fi if [ "$MODE" = "build" ]; then if [ "$CMD_STATUS" -ne 0 ]; then log_error "ITERATION $i exited non-zero; review $LOG_FILE" update_story_status "$STORY_ID" "open" echo "Iteration failed; story reset to open." elif grep -q "COMPLETE" "$LOG_FILE"; then update_story_status "$STORY_ID" "done" echo "Completion signal received; story marked done." else update_story_status "$STORY_ID" "open" echo "No completion signal; story reset to open." fi REMAINING="$(remaining_from_prd)" echo "Iteration $i complete. Remaining stories: $REMAINING" if [ "$REMAINING" = "0" ]; then echo "No remaining stories." exit 0 fi else echo "Iteration $i complete." fi sleep 2 done echo "Reached max iterations ($MAX_ITERATIONS)." if [ "$HAS_ERROR" = "true" ]; then exit 1 fi exit 0