Files
Sauci/.agents/ralph/loop.sh
T
jjbrunton 8d4d9d8750 refactor(swipe): split swipe screen modules
Move swipe screen logic into hooks/services and extract
state-specific UI components to keep files small while
preserving existing behavior.

Reference: US-002
2026-01-25 21:05:54 +00:00

956 lines
27 KiB
Bash
Executable File

#!/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 <file>."
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-<short-slug>.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 <path>. 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 "<promise>COMPLETE</promise>" "$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