@canivel/ralph 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.agents/ralph/PROMPT_build.md +126 -126
  2. package/.agents/ralph/config.sh +25 -25
  3. package/.agents/ralph/log-activity.sh +15 -15
  4. package/.agents/ralph/loop.sh +1265 -1027
  5. package/.agents/ralph/references/CONTEXT_ENGINEERING.md +126 -126
  6. package/.agents/ralph/references/GUARDRAILS.md +174 -174
  7. package/.agents/tasks/prd.json +531 -0
  8. package/.agents/tasks/prd.overview.md +34 -0
  9. package/README.md +431 -270
  10. package/bin/ralph +932 -767
  11. package/dashboard/server/public/assets/index-BsXe-adb.css +1 -0
  12. package/dashboard/server/public/assets/index-baxd-uax.js +453 -0
  13. package/dashboard/server/public/index.html +14 -0
  14. package/package.json +67 -39
  15. package/skills/commit/SKILL.md +219 -219
  16. package/skills/commit/references/commit_examples.md +292 -292
  17. package/skills/dev-browser/SKILL.md +211 -211
  18. package/skills/dev-browser/bun.lock +443 -443
  19. package/skills/dev-browser/package-lock.json +2988 -2988
  20. package/skills/dev-browser/package.json +31 -31
  21. package/skills/dev-browser/references/scraping.md +155 -155
  22. package/skills/dev-browser/scripts/start-relay.ts +32 -32
  23. package/skills/dev-browser/scripts/start-server.ts +117 -117
  24. package/skills/dev-browser/server.sh +24 -24
  25. package/skills/dev-browser/src/client.ts +474 -474
  26. package/skills/dev-browser/src/index.ts +287 -287
  27. package/skills/dev-browser/src/relay.ts +731 -731
  28. package/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +223 -223
  29. package/skills/dev-browser/src/snapshot/browser-script.ts +877 -877
  30. package/skills/dev-browser/src/snapshot/index.ts +14 -14
  31. package/skills/dev-browser/src/snapshot/inject.ts +13 -13
  32. package/skills/dev-browser/src/types.ts +34 -34
  33. package/skills/dev-browser/tsconfig.json +36 -36
  34. package/skills/dev-browser/vitest.config.ts +12 -12
  35. package/skills/prd/SKILL.md +235 -235
  36. package/AGENTS.md +0 -20
  37. package/diagram.svg +0 -55
  38. package/examples/commands.md +0 -46
  39. package/ralph.webp +0 -0
  40. package/tests/agent-loops.mjs +0 -79
  41. package/tests/agent-ping.mjs +0 -39
  42. package/tests/audit.md +0 -56
  43. package/tests/cli-smoke.mjs +0 -47
  44. package/tests/real-agents.mjs +0 -127
@@ -1,1027 +1,1265 @@
1
- #!/bin/bash
2
- # Ralph loop — simple, portable, single-agent
3
- # Usage:
4
- # ./.agents/ralph/loop.sh # build mode, default iterations
5
- # ./.agents/ralph/loop.sh build # build mode
6
- # ./.agents/ralph/loop.sh prd "request" # generate PRD via agent
7
- # ./.agents/ralph/loop.sh 10 # build mode, 10 iterations
8
- # ./.agents/ralph/loop.sh build 1 --no-commit
9
-
10
- set -euo pipefail
11
-
12
- # Cross-platform Python detection (Windows uses 'python', Unix uses 'python3')
13
- # On Windows, 'python' may exist as an App Execution Alias that redirects to Microsoft Store
14
- # We need to actually test if the command works, not just if it exists
15
- PYTHON_CMD=""
16
- for py_cmd in python3 python; do
17
- if command -v "$py_cmd" >/dev/null 2>&1; then
18
- # Test if Python actually runs (catches Windows App Execution Alias issue)
19
- if "$py_cmd" --version >/dev/null 2>&1; then
20
- PYTHON_CMD="$py_cmd"
21
- break
22
- fi
23
- fi
24
- done
25
-
26
- if [ -z "$PYTHON_CMD" ]; then
27
- echo "Error: Python is required but not found or not working."
28
- echo "Install Python 3.x and ensure it's in your PATH."
29
- echo "On Windows, you may need to disable App Execution Aliases for python in Settings."
30
- exit 1
31
- fi
32
-
33
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
- ROOT_DIR="$(cd "${RALPH_ROOT:-${SCRIPT_DIR}/../..}" && pwd)"
35
- CONFIG_FILE="${SCRIPT_DIR}/config.sh"
36
-
37
- DEFAULT_PRD_PATH=".agents/tasks/prd.json"
38
- DEFAULT_PROGRESS_PATH=".ralph/progress.md"
39
- DEFAULT_AGENTS_PATH="AGENTS.md"
40
- DEFAULT_PROMPT_BUILD=".agents/ralph/PROMPT_build.md"
41
- DEFAULT_GUARDRAILS_PATH=".ralph/guardrails.md"
42
- DEFAULT_ERRORS_LOG_PATH=".ralph/errors.log"
43
- DEFAULT_ACTIVITY_LOG_PATH=".ralph/activity.log"
44
- DEFAULT_TMP_DIR=".ralph/.tmp"
45
- DEFAULT_RUNS_DIR=".ralph/runs"
46
- DEFAULT_GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md"
47
- DEFAULT_CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md"
48
- DEFAULT_ACTIVITY_CMD=".agents/ralph/log-activity.sh"
49
- if [[ -n "${RALPH_ROOT:-}" ]]; then
50
- agents_path="$RALPH_ROOT/.agents/ralph/agents.sh"
51
- else
52
- agents_path="$SCRIPT_DIR/agents.sh"
53
- fi
54
- if [[ -f "$agents_path" ]]; then
55
- # shellcheck source=/dev/null
56
- source "$agents_path"
57
- fi
58
-
59
- DEFAULT_MAX_ITERATIONS=25
60
- DEFAULT_NO_COMMIT=false
61
- DEFAULT_STALE_SECONDS=0
62
- PRD_REQUEST_PATH=""
63
- PRD_INLINE=""
64
-
65
- # Optional config overrides (simple shell vars)
66
- if [ -f "$CONFIG_FILE" ]; then
67
- # shellcheck source=/dev/null
68
- . "$CONFIG_FILE"
69
- fi
70
-
71
- DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-codex}"
72
- resolve_agent_cmd() {
73
- local name="$1"
74
- local interactive="${2:-0}"
75
- case "$name" in
76
- claude)
77
- if [ "$interactive" = "1" ]; then
78
- echo "${AGENT_CLAUDE_INTERACTIVE_CMD:-"claude --dangerously-skip-permissions -"}"
79
- else
80
- echo "${AGENT_CLAUDE_CMD:-"claude --dangerously-skip-permissions -p -"}"
81
- fi
82
- ;;
83
- droid)
84
- if [ "$interactive" = "1" ]; then
85
- echo "${AGENT_DROID_INTERACTIVE_CMD:-"droid --skip-permissions-unsafe {prompt}"}"
86
- else
87
- echo "${AGENT_DROID_CMD:-"droid exec --skip-permissions-unsafe -f {prompt}"}"
88
- fi
89
- ;;
90
- codex|"")
91
- if [ "$interactive" = "1" ]; then
92
- echo "${AGENT_CODEX_INTERACTIVE_CMD:-"codex --yolo {prompt}"}"
93
- else
94
- echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
95
- fi
96
- ;;
97
- opencode)
98
- if [ "$interactive" = "1" ]; then
99
- echo "${AGENT_OPENCODE_INTERACTIVE_CMD:-"opencode --prompt {prompt}"}"
100
- else
101
- echo "${AGENT_OPENCODE_CMD:-"opencode run {prompt}"}"
102
- fi
103
- ;;
104
- *)
105
- echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
106
- ;;
107
- esac
108
- }
109
- DEFAULT_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME" 0)"
110
-
111
- PRD_PATH="${PRD_PATH:-$DEFAULT_PRD_PATH}"
112
- PROGRESS_PATH="${PROGRESS_PATH:-$DEFAULT_PROGRESS_PATH}"
113
- AGENTS_PATH="${AGENTS_PATH:-$DEFAULT_AGENTS_PATH}"
114
- PROMPT_BUILD="${PROMPT_BUILD:-$DEFAULT_PROMPT_BUILD}"
115
- GUARDRAILS_PATH="${GUARDRAILS_PATH:-$DEFAULT_GUARDRAILS_PATH}"
116
- ERRORS_LOG_PATH="${ERRORS_LOG_PATH:-$DEFAULT_ERRORS_LOG_PATH}"
117
- ACTIVITY_LOG_PATH="${ACTIVITY_LOG_PATH:-$DEFAULT_ACTIVITY_LOG_PATH}"
118
- TMP_DIR="${TMP_DIR:-$DEFAULT_TMP_DIR}"
119
- RUNS_DIR="${RUNS_DIR:-$DEFAULT_RUNS_DIR}"
120
- GUARDRAILS_REF="${GUARDRAILS_REF:-$DEFAULT_GUARDRAILS_REF}"
121
- CONTEXT_REF="${CONTEXT_REF:-$DEFAULT_CONTEXT_REF}"
122
- ACTIVITY_CMD="${ACTIVITY_CMD:-$DEFAULT_ACTIVITY_CMD}"
123
- AGENT_CMD="${AGENT_CMD:-$DEFAULT_AGENT_CMD}"
124
- MAX_ITERATIONS="${MAX_ITERATIONS:-$DEFAULT_MAX_ITERATIONS}"
125
- NO_COMMIT="${NO_COMMIT:-$DEFAULT_NO_COMMIT}"
126
- STALE_SECONDS="${STALE_SECONDS:-$DEFAULT_STALE_SECONDS}"
127
-
128
- abs_path() {
129
- local p="$1"
130
- # Check for Unix absolute path (starts with /) or Windows absolute path (contains :)
131
- if [[ "$p" = /* ]] || [[ "$p" == *:* ]]; then
132
- echo "$p"
133
- else
134
- echo "$ROOT_DIR/$p"
135
- fi
136
- }
137
-
138
- PRD_PATH="$(abs_path "$PRD_PATH")"
139
- PROGRESS_PATH="$(abs_path "$PROGRESS_PATH")"
140
- AGENTS_PATH="$(abs_path "$AGENTS_PATH")"
141
- PROMPT_BUILD="$(abs_path "$PROMPT_BUILD")"
142
- GUARDRAILS_PATH="$(abs_path "$GUARDRAILS_PATH")"
143
- ERRORS_LOG_PATH="$(abs_path "$ERRORS_LOG_PATH")"
144
- ACTIVITY_LOG_PATH="$(abs_path "$ACTIVITY_LOG_PATH")"
145
- TMP_DIR="$(abs_path "$TMP_DIR")"
146
- RUNS_DIR="$(abs_path "$RUNS_DIR")"
147
- GUARDRAILS_REF="$(abs_path "$GUARDRAILS_REF")"
148
- CONTEXT_REF="$(abs_path "$CONTEXT_REF")"
149
- ACTIVITY_CMD="$(abs_path "$ACTIVITY_CMD")"
150
-
151
- require_agent() {
152
- local agent_cmd="${1:-$AGENT_CMD}"
153
- local agent_bin
154
- agent_bin="${agent_cmd%% *}"
155
- if [ -z "$agent_bin" ]; then
156
- echo "AGENT_CMD is empty. Set it in config.sh."
157
- exit 1
158
- fi
159
- if ! command -v "$agent_bin" >/dev/null 2>&1; then
160
- echo "Agent command not found: $agent_bin"
161
- case "$agent_bin" in
162
- codex)
163
- echo "Install: npm i -g @openai/codex"
164
- ;;
165
- claude)
166
- echo "Install: curl -fsSL https://claude.ai/install.sh | bash"
167
- ;;
168
- droid)
169
- echo "Install: curl -fsSL https://app.factory.ai/cli | sh"
170
- ;;
171
- opencode)
172
- echo "Install: curl -fsSL https://opencode.ai/install.sh | bash"
173
- ;;
174
- esac
175
- echo "Then authenticate per the CLI's instructions."
176
- exit 1
177
- fi
178
- }
179
-
180
- run_agent() {
181
- local prompt_file="$1"
182
- if [[ "$AGENT_CMD" == *"{prompt}"* ]]; then
183
- local escaped
184
- escaped=$(printf '%q' "$prompt_file")
185
- local cmd="${AGENT_CMD//\{prompt\}/$escaped}"
186
- eval "$cmd"
187
- else
188
- cat "$prompt_file" | eval "$AGENT_CMD"
189
- fi
190
- }
191
-
192
- run_agent_inline() {
193
- local prompt_file="$1"
194
- local prompt_content
195
- prompt_content="$(cat "$prompt_file")"
196
- local escaped
197
- escaped=$(printf "%s" "$prompt_content" | sed "s/'/'\\\\''/g")
198
- local cmd="${PRD_AGENT_CMD:-$AGENT_CMD}"
199
- if [[ "$cmd" == *"{prompt}"* ]]; then
200
- cmd="${cmd//\{prompt\}/'$escaped'}"
201
- else
202
- cmd="$cmd '$escaped'"
203
- fi
204
- eval "$cmd"
205
- }
206
-
207
- MODE="build"
208
- while [ $# -gt 0 ]; do
209
- case "$1" in
210
- build|prd)
211
- MODE="$1"
212
- shift
213
- ;;
214
- --prompt)
215
- PRD_REQUEST_PATH="$2"
216
- shift 2
217
- ;;
218
- --no-commit)
219
- NO_COMMIT=true
220
- shift
221
- ;;
222
- *)
223
- if [ "$MODE" = "prd" ]; then
224
- PRD_INLINE="${PRD_INLINE:+$PRD_INLINE }$1"
225
- shift
226
- elif [[ "$1" =~ ^[0-9]+$ ]]; then
227
- MAX_ITERATIONS="$1"
228
- shift
229
- else
230
- echo "Unknown arg: $1"
231
- exit 1
232
- fi
233
- ;;
234
- esac
235
- done
236
-
237
- PROMPT_FILE="$PROMPT_BUILD"
238
-
239
- if [ "$MODE" = "prd" ]; then
240
- # For PRD mode, use interactive agent command (file-based with {prompt} placeholder)
241
- if [ -z "${PRD_AGENT_CMD:-}" ]; then
242
- PRD_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME" 1)"
243
- fi
244
- if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
245
- require_agent "${PRD_AGENT_CMD}"
246
- fi
247
-
248
- if [[ "$PRD_PATH" == *.json ]]; then
249
- mkdir -p "$(dirname "$PRD_PATH")" "$TMP_DIR"
250
- else
251
- mkdir -p "$PRD_PATH" "$TMP_DIR"
252
- fi
253
-
254
- if [ -z "$PRD_REQUEST_PATH" ] && [ -n "$PRD_INLINE" ]; then
255
- PRD_REQUEST_PATH="$TMP_DIR/prd-request-$(date +%Y%m%d-%H%M%S)-$$.txt"
256
- printf '%s\n' "$PRD_INLINE" > "$PRD_REQUEST_PATH"
257
- fi
258
-
259
- if [ -z "$PRD_REQUEST_PATH" ] || [ ! -f "$PRD_REQUEST_PATH" ]; then
260
- echo "PRD request missing. Provide a prompt string or --prompt <file>."
261
- exit 1
262
- fi
263
-
264
- if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
265
- if [[ "$PRD_PATH" == *.json ]]; then
266
- if [ ! -f "$PRD_PATH" ]; then
267
- {
268
- echo '{'
269
- echo ' "version": 1,'
270
- echo ' "project": "ralph",'
271
- echo ' "qualityGates": [],'
272
- echo ' "stories": []'
273
- echo '}'
274
- } > "$PRD_PATH"
275
- fi
276
- fi
277
- exit 0
278
- fi
279
-
280
- PRD_PROMPT_FILE="$TMP_DIR/prd-prompt-$(date +%Y%m%d-%H%M%S)-$$.md"
281
- # Look for prd skill in project skills or ralph bundled skills
282
- PRD_SKILL_FILE=""
283
- for skill_path in "$ROOT_DIR/.claude/skills/prd/SKILL.md" "$ROOT_DIR/skills/prd/SKILL.md" "$SCRIPT_DIR/../skills/prd/SKILL.md"; do
284
- if [[ -f "$skill_path" ]]; then
285
- PRD_SKILL_FILE="$skill_path"
286
- break
287
- fi
288
- done
289
- # Also check ralph's bundled skills directory
290
- if [[ -z "$PRD_SKILL_FILE" ]]; then
291
- ralph_skills_dir="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd)/skills/prd/SKILL.md"
292
- if [[ -f "$ralph_skills_dir" ]]; then
293
- PRD_SKILL_FILE="$ralph_skills_dir"
294
- fi
295
- fi
296
- {
297
- echo "You are an autonomous coding agent."
298
- echo ""
299
- # Include the PRD skill instructions if available
300
- if [[ -n "$PRD_SKILL_FILE" && -f "$PRD_SKILL_FILE" ]]; then
301
- cat "$PRD_SKILL_FILE"
302
- echo ""
303
- echo "---"
304
- echo ""
305
- fi
306
- echo "# Output Requirements"
307
- echo ""
308
- if [[ "$PRD_PATH" == *.json ]]; then
309
- echo "Save the PRD JSON to: $PRD_PATH"
310
- else
311
- echo "Save the PRD as JSON in directory: $PRD_PATH"
312
- echo "Filename rules: prd-<short-slug>.json using 1-3 meaningful words."
313
- echo "Examples: prd-workout-tracker.json, prd-usage-billing.json"
314
- fi
315
- echo ""
316
- echo "Do NOT implement anything. Only generate the JSON PRD file."
317
- echo ""
318
- echo "After saving the PRD, end your response with:"
319
- echo "PRD JSON saved to <path>. Close this chat and run \`ralph build\`."
320
- echo ""
321
- echo "---"
322
- echo ""
323
- echo "# User Request"
324
- echo ""
325
- cat "$PRD_REQUEST_PATH"
326
- } > "$PRD_PROMPT_FILE"
327
-
328
- # Use PRD_AGENT_CMD for the agent call (always file-based for PRD mode)
329
- AGENT_CMD="$PRD_AGENT_CMD"
330
- run_agent "$PRD_PROMPT_FILE"
331
- exit 0
332
- fi
333
-
334
- if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
335
- require_agent
336
- fi
337
-
338
- if [ ! -f "$PROMPT_FILE" ]; then
339
- echo "Prompt not found: $PROMPT_FILE"
340
- exit 1
341
- fi
342
-
343
- if [ "$MODE" != "prd" ] && [ ! -f "$PRD_PATH" ]; then
344
- echo "PRD not found: $PRD_PATH"
345
- exit 1
346
- fi
347
-
348
- mkdir -p "$(dirname "$PROGRESS_PATH")" "$TMP_DIR" "$RUNS_DIR"
349
-
350
- if [ ! -f "$PROGRESS_PATH" ]; then
351
- {
352
- echo "# Progress Log"
353
- echo "Started: $(date)"
354
- echo ""
355
- echo "## Codebase Patterns"
356
- echo "- (add reusable patterns here)"
357
- echo ""
358
- echo "---"
359
- } > "$PROGRESS_PATH"
360
- fi
361
-
362
- if [ ! -f "$GUARDRAILS_PATH" ]; then
363
- {
364
- echo "# Guardrails (Signs)"
365
- echo ""
366
- echo "> Lessons learned from failures. Read before acting."
367
- echo ""
368
- echo "## Core Signs"
369
- echo ""
370
- echo "### Sign: Read Before Writing"
371
- echo "- **Trigger**: Before modifying any file"
372
- echo "- **Instruction**: Read the file first"
373
- echo "- **Added after**: Core principle"
374
- echo ""
375
- echo "### Sign: Test Before Commit"
376
- echo "- **Trigger**: Before committing changes"
377
- echo "- **Instruction**: Run required tests and verify outputs"
378
- echo "- **Added after**: Core principle"
379
- echo ""
380
- echo "---"
381
- echo ""
382
- echo "## Learned Signs"
383
- echo ""
384
- } > "$GUARDRAILS_PATH"
385
- fi
386
-
387
- if [ ! -f "$ERRORS_LOG_PATH" ]; then
388
- {
389
- echo "# Error Log"
390
- echo ""
391
- echo "> Failures and repeated issues. Use this to add guardrails."
392
- echo ""
393
- } > "$ERRORS_LOG_PATH"
394
- fi
395
-
396
- if [ ! -f "$ACTIVITY_LOG_PATH" ]; then
397
- {
398
- echo "# Activity Log"
399
- echo ""
400
- echo "## Run Summary"
401
- echo ""
402
- echo "## Events"
403
- echo ""
404
- } > "$ACTIVITY_LOG_PATH"
405
- fi
406
-
407
- RUN_TAG="$(date +%Y%m%d-%H%M%S)-$$"
408
-
409
- render_prompt() {
410
- local src="$1"
411
- local dst="$2"
412
- local story_meta="$3"
413
- local story_block="$4"
414
- local run_id="$5"
415
- local iter="$6"
416
- local run_log="$7"
417
- local run_meta="$8"
418
- $PYTHON_CMD - "$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'
419
- import sys
420
- from pathlib import Path
421
-
422
- src = Path(sys.argv[1]).read_text()
423
- prd, agents, progress, root = sys.argv[3:7]
424
- guardrails = sys.argv[7]
425
- errors_log = sys.argv[8]
426
- activity_log = sys.argv[9]
427
- guardrails_ref = sys.argv[10]
428
- context_ref = sys.argv[11]
429
- activity_cmd = sys.argv[12]
430
- no_commit = sys.argv[13]
431
- meta_path = sys.argv[14] if len(sys.argv) > 14 else ""
432
- block_path = sys.argv[15] if len(sys.argv) > 15 else ""
433
- run_id = sys.argv[16] if len(sys.argv) > 16 else ""
434
- iteration = sys.argv[17] if len(sys.argv) > 17 else ""
435
- run_log = sys.argv[18] if len(sys.argv) > 18 else ""
436
- run_meta = sys.argv[19] if len(sys.argv) > 19 else ""
437
- repl = {
438
- "PRD_PATH": prd,
439
- "AGENTS_PATH": agents,
440
- "PROGRESS_PATH": progress,
441
- "REPO_ROOT": root,
442
- "GUARDRAILS_PATH": guardrails,
443
- "ERRORS_LOG_PATH": errors_log,
444
- "ACTIVITY_LOG_PATH": activity_log,
445
- "GUARDRAILS_REF": guardrails_ref,
446
- "CONTEXT_REF": context_ref,
447
- "ACTIVITY_CMD": activity_cmd,
448
- "NO_COMMIT": no_commit,
449
- "RUN_ID": run_id,
450
- "ITERATION": iteration,
451
- "RUN_LOG_PATH": run_log,
452
- "RUN_META_PATH": run_meta,
453
- }
454
- story = {"id": "", "title": "", "block": ""}
455
- quality_gates = []
456
- if meta_path:
457
- try:
458
- import json
459
- meta = json.loads(Path(meta_path).read_text())
460
- story["id"] = meta.get("id", "") or ""
461
- story["title"] = meta.get("title", "") or ""
462
- quality_gates = meta.get("quality_gates", []) or []
463
- except Exception:
464
- pass
465
- if block_path and Path(block_path).exists():
466
- story["block"] = Path(block_path).read_text()
467
- repl["STORY_ID"] = story["id"]
468
- repl["STORY_TITLE"] = story["title"]
469
- repl["STORY_BLOCK"] = story["block"]
470
- if quality_gates:
471
- repl["QUALITY_GATES"] = "\n".join([f"- {g}" for g in quality_gates])
472
- else:
473
- repl["QUALITY_GATES"] = "- (none)"
474
- for k, v in repl.items():
475
- src = src.replace("{{" + k + "}}", v)
476
- Path(sys.argv[2]).write_text(src)
477
- PY
478
- }
479
-
480
- select_story() {
481
- local meta_out="$1"
482
- local block_out="$2"
483
- $PYTHON_CMD - "$PRD_PATH" "$meta_out" "$block_out" "$STALE_SECONDS" <<'PY'
484
- import json
485
- import os
486
- import sys
487
- from pathlib import Path
488
- from datetime import datetime, timezone
489
- try:
490
- import fcntl
491
- except Exception:
492
- fcntl = None
493
-
494
- prd_path = Path(sys.argv[1])
495
- meta_out = Path(sys.argv[2])
496
- block_out = Path(sys.argv[3])
497
- stale_seconds = 0
498
- if len(sys.argv) > 4:
499
- try:
500
- stale_seconds = int(sys.argv[4])
501
- except Exception:
502
- stale_seconds = 0
503
-
504
- if not prd_path.exists():
505
- meta_out.write_text(json.dumps({"ok": False, "error": "PRD not found"}, indent=2) + "\n")
506
- block_out.write_text("")
507
- sys.exit(0)
508
-
509
- def normalize_status(value):
510
- if value is None:
511
- return "open"
512
- return str(value).strip().lower()
513
-
514
- def parse_ts(value):
515
- if not value:
516
- return None
517
- text = str(value).strip()
518
- if text.endswith("Z"):
519
- text = text[:-1] + "+00:00"
520
- try:
521
- return datetime.fromisoformat(text)
522
- except Exception:
523
- return None
524
-
525
- def now_iso():
526
- return datetime.now(timezone.utc).isoformat()
527
-
528
- with prd_path.open("r+", encoding="utf-8") as fh:
529
- if fcntl is not None:
530
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
531
- try:
532
- try:
533
- data = json.load(fh)
534
- except Exception as exc:
535
- meta_out.write_text(json.dumps({"ok": False, "error": f"Invalid PRD JSON: {exc}"}, indent=2) + "\n")
536
- block_out.write_text("")
537
- sys.exit(0)
538
-
539
- stories = data.get("stories") if isinstance(data, dict) else None
540
- if not isinstance(stories, list) or not stories:
541
- meta_out.write_text(json.dumps({"ok": False, "error": "No stories found in PRD"}, indent=2) + "\n")
542
- block_out.write_text("")
543
- sys.exit(0)
544
-
545
- story_index = {s.get("id"): s for s in stories if isinstance(s, dict)}
546
-
547
- def is_done(story_id: str) -> bool:
548
- target = story_index.get(story_id)
549
- if not isinstance(target, dict):
550
- return False
551
- return normalize_status(target.get("status")) == "done"
552
-
553
- if stale_seconds > 0:
554
- now = datetime.now(timezone.utc)
555
- for story in stories:
556
- if not isinstance(story, dict):
557
- continue
558
- if normalize_status(story.get("status")) != "in_progress":
559
- continue
560
- started = parse_ts(story.get("startedAt"))
561
- if started is None or (now - started).total_seconds() > stale_seconds:
562
- story["status"] = "open"
563
- story["startedAt"] = None
564
- story["completedAt"] = None
565
- story["updatedAt"] = now_iso()
566
-
567
- candidate = None
568
- for story in stories:
569
- if not isinstance(story, dict):
570
- continue
571
- if normalize_status(story.get("status")) != "open":
572
- continue
573
- deps = story.get("dependsOn") or []
574
- if not isinstance(deps, list):
575
- deps = []
576
- if all(is_done(dep) for dep in deps):
577
- candidate = story
578
- break
579
-
580
- remaining = sum(
581
- 1 for story in stories
582
- if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
583
- )
584
-
585
- meta = {
586
- "ok": True,
587
- "total": len(stories),
588
- "remaining": remaining,
589
- "quality_gates": data.get("qualityGates", []) or [],
590
- }
591
-
592
- if candidate:
593
- candidate["status"] = "in_progress"
594
- if not candidate.get("startedAt"):
595
- candidate["startedAt"] = now_iso()
596
- candidate["completedAt"] = None
597
- candidate["updatedAt"] = now_iso()
598
- meta.update({
599
- "id": candidate.get("id", ""),
600
- "title": candidate.get("title", ""),
601
- })
602
-
603
- depends = candidate.get("dependsOn") or []
604
- if not isinstance(depends, list):
605
- depends = []
606
- acceptance = candidate.get("acceptanceCriteria") or []
607
- if not isinstance(acceptance, list):
608
- acceptance = []
609
-
610
- description = candidate.get("description") or ""
611
- block_lines = []
612
- block_lines.append(f"### {candidate.get('id', '')}: {candidate.get('title', '')}")
613
- block_lines.append(f"Status: {candidate.get('status', 'open')}")
614
- block_lines.append(
615
- f"Depends on: {', '.join(depends) if depends else 'None'}"
616
- )
617
- block_lines.append("")
618
- block_lines.append("Description:")
619
- block_lines.append(description if description else "(none)")
620
- block_lines.append("")
621
- block_lines.append("Acceptance Criteria:")
622
- if acceptance:
623
- block_lines.extend([f"- [ ] {item}" for item in acceptance])
624
- else:
625
- block_lines.append("- (none)")
626
- block_out.write_text("\n".join(block_lines).rstrip() + "\n")
627
- else:
628
- block_out.write_text("")
629
-
630
- fh.seek(0)
631
- fh.truncate()
632
- json.dump(data, fh, indent=2)
633
- fh.write("\n")
634
- fh.flush()
635
- os.fsync(fh.fileno())
636
- finally:
637
- if fcntl is not None:
638
- fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
639
-
640
- meta_out.write_text(json.dumps(meta, indent=2) + "\n")
641
- PY
642
- }
643
-
644
- remaining_stories() {
645
- local meta_file="$1"
646
- $PYTHON_CMD - "$meta_file" <<'PY'
647
- import json
648
- import sys
649
- from pathlib import Path
650
-
651
- data = json.loads(Path(sys.argv[1]).read_text())
652
- print(data.get("remaining", "unknown"))
653
- PY
654
- }
655
-
656
- remaining_from_prd() {
657
- $PYTHON_CMD - "$PRD_PATH" <<'PY'
658
- import json
659
- import sys
660
- from pathlib import Path
661
-
662
- prd_path = Path(sys.argv[1])
663
- if not prd_path.exists():
664
- print("unknown")
665
- sys.exit(0)
666
-
667
- try:
668
- data = json.loads(prd_path.read_text())
669
- except Exception:
670
- print("unknown")
671
- sys.exit(0)
672
-
673
- stories = data.get("stories") if isinstance(data, dict) else None
674
- if not isinstance(stories, list):
675
- print("unknown")
676
- sys.exit(0)
677
-
678
- def normalize_status(value):
679
- if value is None:
680
- return "open"
681
- return str(value).strip().lower()
682
-
683
- remaining = sum(
684
- 1 for story in stories
685
- if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
686
- )
687
- print(remaining)
688
- PY
689
- }
690
-
691
- story_field() {
692
- local meta_file="$1"
693
- local field="$2"
694
- $PYTHON_CMD - "$meta_file" "$field" <<'PY'
695
- import json
696
- import sys
697
- from pathlib import Path
698
-
699
- data = json.loads(Path(sys.argv[1]).read_text())
700
- field = sys.argv[2]
701
- print(data.get(field, ""))
702
- PY
703
- }
704
-
705
- update_story_status() {
706
- local story_id="$1"
707
- local new_status="$2"
708
- $PYTHON_CMD - "$PRD_PATH" "$story_id" "$new_status" <<'PY'
709
- import json
710
- import os
711
- import sys
712
- from pathlib import Path
713
- from datetime import datetime, timezone
714
- try:
715
- import fcntl
716
- except Exception:
717
- fcntl = None
718
-
719
- prd_path = Path(sys.argv[1])
720
- story_id = sys.argv[2]
721
- new_status = sys.argv[3]
722
-
723
- if not story_id:
724
- sys.exit(0)
725
-
726
- if not prd_path.exists():
727
- sys.exit(0)
728
-
729
- def now_iso():
730
- return datetime.now(timezone.utc).isoformat()
731
-
732
- with prd_path.open("r+", encoding="utf-8") as fh:
733
- if fcntl is not None:
734
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
735
- try:
736
- data = json.load(fh)
737
- stories = data.get("stories") if isinstance(data, dict) else None
738
- if not isinstance(stories, list):
739
- sys.exit(0)
740
- for story in stories:
741
- if isinstance(story, dict) and story.get("id") == story_id:
742
- story["status"] = new_status
743
- story["updatedAt"] = now_iso()
744
- if new_status == "in_progress":
745
- if not story.get("startedAt"):
746
- story["startedAt"] = now_iso()
747
- story["completedAt"] = None
748
- elif new_status == "done":
749
- story["completedAt"] = now_iso()
750
- if not story.get("startedAt"):
751
- story["startedAt"] = now_iso()
752
- elif new_status == "open":
753
- story["startedAt"] = None
754
- story["completedAt"] = None
755
- break
756
- fh.seek(0)
757
- fh.truncate()
758
- json.dump(data, fh, indent=2)
759
- fh.write("\n")
760
- fh.flush()
761
- os.fsync(fh.fileno())
762
- finally:
763
- if fcntl is not None:
764
- fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
765
- PY
766
- }
767
-
768
- log_activity() {
769
- local message="$1"
770
- local timestamp
771
- timestamp=$(date '+%Y-%m-%d %H:%M:%S')
772
- echo "[$timestamp] $message" >> "$ACTIVITY_LOG_PATH"
773
- }
774
-
775
- log_error() {
776
- local message="$1"
777
- local timestamp
778
- timestamp=$(date '+%Y-%m-%d %H:%M:%S')
779
- echo "[$timestamp] $message" >> "$ERRORS_LOG_PATH"
780
- }
781
-
782
- append_run_summary() {
783
- local line="$1"
784
- $PYTHON_CMD - "$ACTIVITY_LOG_PATH" "$line" <<'PY'
785
- import sys
786
- from pathlib import Path
787
-
788
- path = Path(sys.argv[1])
789
- line = sys.argv[2]
790
- text = path.read_text().splitlines()
791
- out = []
792
- inserted = False
793
- for l in text:
794
- out.append(l)
795
- if not inserted and l.strip() == "## Run Summary":
796
- out.append(f"- {line}")
797
- inserted = True
798
- if not inserted:
799
- out = [
800
- "# Activity Log",
801
- "",
802
- "## Run Summary",
803
- f"- {line}",
804
- "",
805
- "## Events",
806
- "",
807
- ] + text
808
- Path(path).write_text("\n".join(out).rstrip() + "\n")
809
- PY
810
- }
811
-
812
- write_run_meta() {
813
- local path="$1"
814
- local mode="$2"
815
- local iter="$3"
816
- local run_id="$4"
817
- local story_id="$5"
818
- local story_title="$6"
819
- local started="$7"
820
- local ended="$8"
821
- local duration="$9"
822
- local status="${10}"
823
- local log_file="${11}"
824
- local head_before="${12}"
825
- local head_after="${13}"
826
- local commit_list="${14}"
827
- local changed_files="${15}"
828
- local dirty_files="${16}"
829
- {
830
- echo "# Ralph Run Summary"
831
- echo ""
832
- echo "- Run ID: $run_id"
833
- echo "- Iteration: $iter"
834
- echo "- Mode: $mode"
835
- if [ -n "$story_id" ]; then
836
- echo "- Story: $story_id: $story_title"
837
- fi
838
- echo "- Started: $started"
839
- echo "- Ended: $ended"
840
- echo "- Duration: ${duration}s"
841
- echo "- Status: $status"
842
- echo "- Log: $log_file"
843
- echo ""
844
- echo "## Git"
845
- echo "- Head (before): ${head_before:-unknown}"
846
- echo "- Head (after): ${head_after:-unknown}"
847
- echo ""
848
- echo "### Commits"
849
- if [ -n "$commit_list" ]; then
850
- echo "$commit_list"
851
- else
852
- echo "- (none)"
853
- fi
854
- echo ""
855
- echo "### Changed Files (commits)"
856
- if [ -n "$changed_files" ]; then
857
- echo "$changed_files"
858
- else
859
- echo "- (none)"
860
- fi
861
- echo ""
862
- echo "### Uncommitted Changes"
863
- if [ -n "$dirty_files" ]; then
864
- echo "$dirty_files"
865
- else
866
- echo "- (clean)"
867
- fi
868
- echo ""
869
- } > "$path"
870
- }
871
-
872
- git_head() {
873
- if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
874
- git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true
875
- else
876
- echo ""
877
- fi
878
- }
879
-
880
- git_commit_list() {
881
- local before="$1"
882
- local after="$2"
883
- if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
884
- git -C "$ROOT_DIR" log --oneline "$before..$after" | sed 's/^/- /'
885
- else
886
- echo ""
887
- fi
888
- }
889
-
890
- git_changed_files() {
891
- local before="$1"
892
- local after="$2"
893
- if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
894
- git -C "$ROOT_DIR" diff --name-only "$before" "$after" | sed 's/^/- /'
895
- else
896
- echo ""
897
- fi
898
- }
899
-
900
- git_dirty_files() {
901
- if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
902
- git -C "$ROOT_DIR" status --porcelain | awk '{print "- " $2}'
903
- else
904
- echo ""
905
- fi
906
- }
907
-
908
- echo "Ralph mode: $MODE"
909
- echo "Max iterations: $MAX_ITERATIONS"
910
- echo "PRD: $PRD_PATH"
911
- HAS_ERROR="false"
912
-
913
- for i in $(seq 1 "$MAX_ITERATIONS"); do
914
- echo ""
915
- echo "═══════════════════════════════════════════════════════"
916
- echo " Ralph Iteration $i of $MAX_ITERATIONS"
917
- echo "═══════════════════════════════════════════════════════"
918
-
919
- STORY_META=""
920
- STORY_BLOCK=""
921
- ITER_START=$(date +%s)
922
- ITER_START_FMT=$(date '+%Y-%m-%d %H:%M:%S')
923
- if [ "$MODE" = "build" ]; then
924
- STORY_META="$TMP_DIR/story-$RUN_TAG-$i.json"
925
- STORY_BLOCK="$TMP_DIR/story-$RUN_TAG-$i.md"
926
- select_story "$STORY_META" "$STORY_BLOCK"
927
- REMAINING="$(remaining_stories "$STORY_META")"
928
- if [ "$REMAINING" = "unknown" ]; then
929
- echo "Could not parse stories from PRD: $PRD_PATH"
930
- exit 1
931
- fi
932
- if [ "$REMAINING" = "0" ]; then
933
- echo "No remaining stories."
934
- exit 0
935
- fi
936
- STORY_ID="$(story_field "$STORY_META" "id")"
937
- STORY_TITLE="$(story_field "$STORY_META" "title")"
938
- if [ -z "$STORY_ID" ]; then
939
- echo "No actionable open stories (all blocked or in progress). Remaining: $REMAINING"
940
- exit 0
941
- fi
942
- fi
943
-
944
- HEAD_BEFORE="$(git_head)"
945
- PROMPT_RENDERED="$TMP_DIR/prompt-$RUN_TAG-$i.md"
946
- LOG_FILE="$RUNS_DIR/run-$RUN_TAG-iter-$i.log"
947
- RUN_META="$RUNS_DIR/run-$RUN_TAG-iter-$i.md"
948
- render_prompt "$PROMPT_FILE" "$PROMPT_RENDERED" "$STORY_META" "$STORY_BLOCK" "$RUN_TAG" "$i" "$LOG_FILE" "$RUN_META"
949
-
950
- if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
951
- log_activity "ITERATION $i start (mode=$MODE story=$STORY_ID)"
952
- else
953
- log_activity "ITERATION $i start (mode=$MODE)"
954
- fi
955
- set +e
956
- if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
957
- echo "[RALPH_DRY_RUN] Skipping agent execution." | tee "$LOG_FILE"
958
- CMD_STATUS=0
959
- else
960
- # Run agent - capture output to log file while displaying to terminal
961
- # Note: On some systems, piping through tee can cause buffering issues
962
- # with interactive CLIs like Claude. If you see no output, the agent
963
- # is still running - check the log file for captured output.
964
- run_agent "$PROMPT_RENDERED" 2>&1 | tee "$LOG_FILE"
965
- CMD_STATUS=${PIPESTATUS[0]}
966
- fi
967
- set -e
968
- if [ "$CMD_STATUS" -eq 130 ] || [ "$CMD_STATUS" -eq 143 ]; then
969
- echo "Interrupted."
970
- exit "$CMD_STATUS"
971
- fi
972
- ITER_END=$(date +%s)
973
- ITER_END_FMT=$(date '+%Y-%m-%d %H:%M:%S')
974
- ITER_DURATION=$((ITER_END - ITER_START))
975
- HEAD_AFTER="$(git_head)"
976
- log_activity "ITERATION $i end (duration=${ITER_DURATION}s)"
977
- if [ "$CMD_STATUS" -ne 0 ]; then
978
- log_error "ITERATION $i command failed (status=$CMD_STATUS)"
979
- HAS_ERROR="true"
980
- fi
981
- COMMIT_LIST="$(git_commit_list "$HEAD_BEFORE" "$HEAD_AFTER")"
982
- CHANGED_FILES="$(git_changed_files "$HEAD_BEFORE" "$HEAD_AFTER")"
983
- DIRTY_FILES="$(git_dirty_files)"
984
- STATUS_LABEL="success"
985
- if [ "$CMD_STATUS" -ne 0 ]; then
986
- STATUS_LABEL="error"
987
- fi
988
- if [ "$MODE" = "build" ] && [ "$NO_COMMIT" = "false" ] && [ -n "$DIRTY_FILES" ]; then
989
- log_error "ITERATION $i left uncommitted changes; review run summary at $RUN_META"
990
- fi
991
- 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"
992
- if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
993
- 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"
994
- else
995
- append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | duration=${ITER_DURATION}s | status=$STATUS_LABEL"
996
- fi
997
-
998
- if [ "$MODE" = "build" ]; then
999
- if [ "$CMD_STATUS" -ne 0 ]; then
1000
- log_error "ITERATION $i exited non-zero; review $LOG_FILE"
1001
- update_story_status "$STORY_ID" "open"
1002
- echo "Iteration failed; story reset to open."
1003
- elif grep -q "<promise>COMPLETE</promise>" "$LOG_FILE"; then
1004
- update_story_status "$STORY_ID" "done"
1005
- echo "Completion signal received; story marked done."
1006
- else
1007
- update_story_status "$STORY_ID" "open"
1008
- echo "No completion signal; story reset to open."
1009
- fi
1010
- REMAINING="$(remaining_from_prd)"
1011
- echo "Iteration $i complete. Remaining stories: $REMAINING"
1012
- if [ "$REMAINING" = "0" ]; then
1013
- echo "No remaining stories."
1014
- exit 0
1015
- fi
1016
- else
1017
- echo "Iteration $i complete."
1018
- fi
1019
- sleep 2
1020
-
1021
- done
1022
-
1023
- echo "Reached max iterations ($MAX_ITERATIONS)."
1024
- if [ "$HAS_ERROR" = "true" ]; then
1025
- exit 1
1026
- fi
1027
- exit 0
1
+ #!/bin/bash
2
+ # Ralph loop — simple, portable, single-agent
3
+ # Usage:
4
+ # ./.agents/ralph/loop.sh # build mode, default iterations
5
+ # ./.agents/ralph/loop.sh build # build mode
6
+ # ./.agents/ralph/loop.sh prd "request" # generate PRD via agent
7
+ # ./.agents/ralph/loop.sh 10 # build mode, 10 iterations
8
+ # ./.agents/ralph/loop.sh build 1 --no-commit
9
+
10
+ set -euo pipefail
11
+
12
+ # Cross-platform Python detection (Windows uses 'python', Unix uses 'python3')
13
+ # On Windows, 'python' may exist as an App Execution Alias that redirects to Microsoft Store
14
+ # We need to actually test if the command works, not just if it exists
15
+ PYTHON_CMD=""
16
+ for py_cmd in python3 python; do
17
+ if command -v "$py_cmd" >/dev/null 2>&1; then
18
+ # Test if Python actually runs (catches Windows App Execution Alias issue)
19
+ if "$py_cmd" --version >/dev/null 2>&1; then
20
+ PYTHON_CMD="$py_cmd"
21
+ break
22
+ fi
23
+ fi
24
+ done
25
+
26
+ if [ -z "$PYTHON_CMD" ]; then
27
+ echo "Error: Python is required but not found or not working."
28
+ echo "Install Python 3.x and ensure it's in your PATH."
29
+ echo "On Windows, you may need to disable App Execution Aliases for python in Settings."
30
+ exit 1
31
+ fi
32
+
33
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ ROOT_DIR="$(cd "${RALPH_ROOT:-${SCRIPT_DIR}/../..}" && pwd)"
35
+ CONFIG_FILE="${SCRIPT_DIR}/config.sh"
36
+
37
+ DEFAULT_PRD_PATH=".agents/tasks/prd.json"
38
+ DEFAULT_PROGRESS_PATH=".ralph/progress.md"
39
+ DEFAULT_AGENTS_PATH="AGENTS.md"
40
+ DEFAULT_PROMPT_BUILD=".agents/ralph/PROMPT_build.md"
41
+ DEFAULT_GUARDRAILS_PATH=".ralph/guardrails.md"
42
+ DEFAULT_ERRORS_LOG_PATH=".ralph/errors.log"
43
+ DEFAULT_ACTIVITY_LOG_PATH=".ralph/activity.log"
44
+ DEFAULT_TMP_DIR=".ralph/.tmp"
45
+ DEFAULT_RUNS_DIR=".ralph/runs"
46
+ DEFAULT_GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md"
47
+ DEFAULT_CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md"
48
+ DEFAULT_ACTIVITY_CMD=".agents/ralph/log-activity.sh"
49
+ if [[ -n "${RALPH_ROOT:-}" ]]; then
50
+ agents_path="$RALPH_ROOT/.agents/ralph/agents.sh"
51
+ else
52
+ agents_path="$SCRIPT_DIR/agents.sh"
53
+ fi
54
+ if [[ -f "$agents_path" ]]; then
55
+ # shellcheck source=/dev/null
56
+ source "$agents_path"
57
+ fi
58
+
59
+ DEFAULT_MAX_ITERATIONS=25
60
+ DEFAULT_NO_COMMIT=false
61
+ DEFAULT_STALE_SECONDS=0
62
+ DEFAULT_MAX_RETRIES=3
63
+ DEFAULT_RETRY_DELAY=10
64
+ DEFAULT_MAX_STORY_FAILURES=3
65
+ PRD_REQUEST_PATH=""
66
+ PRD_INLINE=""
67
+
68
+ # Optional config overrides (simple shell vars)
69
+ if [ -f "$CONFIG_FILE" ]; then
70
+ # shellcheck source=/dev/null
71
+ . "$CONFIG_FILE"
72
+ fi
73
+
74
+ DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-codex}"
75
+ resolve_agent_cmd() {
76
+ local name="$1"
77
+ local interactive="${2:-0}"
78
+ case "$name" in
79
+ claude)
80
+ if [ "$interactive" = "1" ]; then
81
+ echo "${AGENT_CLAUDE_INTERACTIVE_CMD:-"claude --dangerously-skip-permissions -"}"
82
+ else
83
+ echo "${AGENT_CLAUDE_CMD:-"claude --dangerously-skip-permissions -p -"}"
84
+ fi
85
+ ;;
86
+ droid)
87
+ if [ "$interactive" = "1" ]; then
88
+ echo "${AGENT_DROID_INTERACTIVE_CMD:-"droid --skip-permissions-unsafe {prompt}"}"
89
+ else
90
+ echo "${AGENT_DROID_CMD:-"droid exec --skip-permissions-unsafe -f {prompt}"}"
91
+ fi
92
+ ;;
93
+ codex|"")
94
+ if [ "$interactive" = "1" ]; then
95
+ echo "${AGENT_CODEX_INTERACTIVE_CMD:-"codex --yolo {prompt}"}"
96
+ else
97
+ echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
98
+ fi
99
+ ;;
100
+ opencode)
101
+ if [ "$interactive" = "1" ]; then
102
+ echo "${AGENT_OPENCODE_INTERACTIVE_CMD:-"opencode --prompt {prompt}"}"
103
+ else
104
+ echo "${AGENT_OPENCODE_CMD:-"opencode run {prompt}"}"
105
+ fi
106
+ ;;
107
+ *)
108
+ echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
109
+ ;;
110
+ esac
111
+ }
112
+ DEFAULT_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME" 0)"
113
+
114
+ PRD_PATH="${PRD_PATH:-$DEFAULT_PRD_PATH}"
115
+ PROGRESS_PATH="${PROGRESS_PATH:-$DEFAULT_PROGRESS_PATH}"
116
+ AGENTS_PATH="${AGENTS_PATH:-$DEFAULT_AGENTS_PATH}"
117
+ PROMPT_BUILD="${PROMPT_BUILD:-$DEFAULT_PROMPT_BUILD}"
118
+ GUARDRAILS_PATH="${GUARDRAILS_PATH:-$DEFAULT_GUARDRAILS_PATH}"
119
+ ERRORS_LOG_PATH="${ERRORS_LOG_PATH:-$DEFAULT_ERRORS_LOG_PATH}"
120
+ ACTIVITY_LOG_PATH="${ACTIVITY_LOG_PATH:-$DEFAULT_ACTIVITY_LOG_PATH}"
121
+ TMP_DIR="${TMP_DIR:-$DEFAULT_TMP_DIR}"
122
+ RUNS_DIR="${RUNS_DIR:-$DEFAULT_RUNS_DIR}"
123
+ GUARDRAILS_REF="${GUARDRAILS_REF:-$DEFAULT_GUARDRAILS_REF}"
124
+ CONTEXT_REF="${CONTEXT_REF:-$DEFAULT_CONTEXT_REF}"
125
+ ACTIVITY_CMD="${ACTIVITY_CMD:-$DEFAULT_ACTIVITY_CMD}"
126
+ AGENT_CMD="${AGENT_CMD:-$DEFAULT_AGENT_CMD}"
127
+ MAX_ITERATIONS="${MAX_ITERATIONS:-$DEFAULT_MAX_ITERATIONS}"
128
+ NO_COMMIT="${NO_COMMIT:-$DEFAULT_NO_COMMIT}"
129
+ STALE_SECONDS="${STALE_SECONDS:-$DEFAULT_STALE_SECONDS}"
130
+ MAX_RETRIES="${MAX_RETRIES:-$DEFAULT_MAX_RETRIES}"
131
+ RETRY_DELAY="${RETRY_DELAY:-$DEFAULT_RETRY_DELAY}"
132
+ MAX_STORY_FAILURES="${MAX_STORY_FAILURES:-$DEFAULT_MAX_STORY_FAILURES}"
133
+
134
+ abs_path() {
135
+ local p="$1"
136
+ # Check for Unix absolute path (starts with /) or Windows absolute path (contains :)
137
+ if [[ "$p" = /* ]] || [[ "$p" == *:* ]]; then
138
+ echo "$p"
139
+ else
140
+ echo "$ROOT_DIR/$p"
141
+ fi
142
+ }
143
+
144
+ PRD_PATH="$(abs_path "$PRD_PATH")"
145
+ PROGRESS_PATH="$(abs_path "$PROGRESS_PATH")"
146
+ AGENTS_PATH="$(abs_path "$AGENTS_PATH")"
147
+ PROMPT_BUILD="$(abs_path "$PROMPT_BUILD")"
148
+ GUARDRAILS_PATH="$(abs_path "$GUARDRAILS_PATH")"
149
+ ERRORS_LOG_PATH="$(abs_path "$ERRORS_LOG_PATH")"
150
+ ACTIVITY_LOG_PATH="$(abs_path "$ACTIVITY_LOG_PATH")"
151
+ TMP_DIR="$(abs_path "$TMP_DIR")"
152
+ RUNS_DIR="$(abs_path "$RUNS_DIR")"
153
+ GUARDRAILS_REF="$(abs_path "$GUARDRAILS_REF")"
154
+ CONTEXT_REF="$(abs_path "$CONTEXT_REF")"
155
+ ACTIVITY_CMD="$(abs_path "$ACTIVITY_CMD")"
156
+
157
+ require_agent() {
158
+ local agent_cmd="${1:-$AGENT_CMD}"
159
+ local agent_bin
160
+ agent_bin="${agent_cmd%% *}"
161
+ if [ -z "$agent_bin" ]; then
162
+ echo "AGENT_CMD is empty. Set it in config.sh."
163
+ exit 1
164
+ fi
165
+ if ! command -v "$agent_bin" >/dev/null 2>&1; then
166
+ echo "Agent command not found: $agent_bin"
167
+ case "$agent_bin" in
168
+ codex)
169
+ echo "Install: npm i -g @openai/codex"
170
+ ;;
171
+ claude)
172
+ echo "Install: curl -fsSL https://claude.ai/install.sh | bash"
173
+ ;;
174
+ droid)
175
+ echo "Install: curl -fsSL https://app.factory.ai/cli | sh"
176
+ ;;
177
+ opencode)
178
+ echo "Install: curl -fsSL https://opencode.ai/install.sh | bash"
179
+ ;;
180
+ esac
181
+ echo "Then authenticate per the CLI's instructions."
182
+ exit 1
183
+ fi
184
+ }
185
+
186
+ run_agent() {
187
+ local prompt_file="$1"
188
+ if [[ "$AGENT_CMD" == *"{prompt}"* ]]; then
189
+ local escaped
190
+ escaped=$(printf '%q' "$prompt_file")
191
+ local cmd="${AGENT_CMD//\{prompt\}/$escaped}"
192
+ eval "$cmd"
193
+ else
194
+ cat "$prompt_file" | eval "$AGENT_CMD"
195
+ fi
196
+ }
197
+
198
+ run_agent_inline() {
199
+ local prompt_file="$1"
200
+ local prompt_content
201
+ prompt_content="$(cat "$prompt_file")"
202
+ local escaped
203
+ escaped=$(printf "%s" "$prompt_content" | sed "s/'/'\\\\''/g")
204
+ local cmd="${PRD_AGENT_CMD:-$AGENT_CMD}"
205
+ if [[ "$cmd" == *"{prompt}"* ]]; then
206
+ cmd="${cmd//\{prompt\}/'$escaped'}"
207
+ else
208
+ cmd="$cmd '$escaped'"
209
+ fi
210
+ eval "$cmd"
211
+ }
212
+
213
+ MODE="build"
214
+ while [ $# -gt 0 ]; do
215
+ case "$1" in
216
+ build|prd)
217
+ MODE="$1"
218
+ shift
219
+ ;;
220
+ --prompt)
221
+ PRD_REQUEST_PATH="$2"
222
+ shift 2
223
+ ;;
224
+ --no-commit)
225
+ NO_COMMIT=true
226
+ shift
227
+ ;;
228
+ *)
229
+ if [ "$MODE" = "prd" ]; then
230
+ PRD_INLINE="${PRD_INLINE:+$PRD_INLINE }$1"
231
+ shift
232
+ elif [[ "$1" =~ ^[0-9]+$ ]]; then
233
+ MAX_ITERATIONS="$1"
234
+ shift
235
+ else
236
+ echo "Unknown arg: $1"
237
+ exit 1
238
+ fi
239
+ ;;
240
+ esac
241
+ done
242
+
243
+ PROMPT_FILE="$PROMPT_BUILD"
244
+
245
+ if [ "$MODE" = "prd" ]; then
246
+ # For PRD mode, use interactive agent command (file-based with {prompt} placeholder)
247
+ if [ -z "${PRD_AGENT_CMD:-}" ]; then
248
+ PRD_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME" 1)"
249
+ fi
250
+ if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
251
+ require_agent "${PRD_AGENT_CMD}"
252
+ fi
253
+
254
+ if [[ "$PRD_PATH" == *.json ]]; then
255
+ mkdir -p "$(dirname "$PRD_PATH")" "$TMP_DIR"
256
+ else
257
+ mkdir -p "$PRD_PATH" "$TMP_DIR"
258
+ fi
259
+
260
+ if [ -z "$PRD_REQUEST_PATH" ] && [ -n "$PRD_INLINE" ]; then
261
+ PRD_REQUEST_PATH="$TMP_DIR/prd-request-$(date +%Y%m%d-%H%M%S)-$$.txt"
262
+ printf '%s\n' "$PRD_INLINE" > "$PRD_REQUEST_PATH"
263
+ fi
264
+
265
+ if [ -z "$PRD_REQUEST_PATH" ] || [ ! -f "$PRD_REQUEST_PATH" ]; then
266
+ echo "PRD request missing. Provide a prompt string or --prompt <file>."
267
+ exit 1
268
+ fi
269
+
270
+ if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
271
+ if [[ "$PRD_PATH" == *.json ]]; then
272
+ if [ ! -f "$PRD_PATH" ]; then
273
+ {
274
+ echo '{'
275
+ echo ' "version": 1,'
276
+ echo ' "project": "ralph",'
277
+ echo ' "qualityGates": [],'
278
+ echo ' "stories": []'
279
+ echo '}'
280
+ } > "$PRD_PATH"
281
+ fi
282
+ fi
283
+ exit 0
284
+ fi
285
+
286
+ PRD_PROMPT_FILE="$TMP_DIR/prd-prompt-$(date +%Y%m%d-%H%M%S)-$$.md"
287
+ # Look for prd skill in project skills or ralph bundled skills
288
+ PRD_SKILL_FILE=""
289
+ for skill_path in "$ROOT_DIR/.claude/skills/prd/SKILL.md" "$ROOT_DIR/skills/prd/SKILL.md" "$SCRIPT_DIR/../skills/prd/SKILL.md"; do
290
+ if [[ -f "$skill_path" ]]; then
291
+ PRD_SKILL_FILE="$skill_path"
292
+ break
293
+ fi
294
+ done
295
+ # Also check ralph's bundled skills directory
296
+ if [[ -z "$PRD_SKILL_FILE" ]]; then
297
+ ralph_skills_dir="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd)/skills/prd/SKILL.md"
298
+ if [[ -f "$ralph_skills_dir" ]]; then
299
+ PRD_SKILL_FILE="$ralph_skills_dir"
300
+ fi
301
+ fi
302
+ {
303
+ echo "You are an autonomous coding agent."
304
+ echo ""
305
+ # Include the PRD skill instructions if available
306
+ if [[ -n "$PRD_SKILL_FILE" && -f "$PRD_SKILL_FILE" ]]; then
307
+ cat "$PRD_SKILL_FILE"
308
+ echo ""
309
+ echo "---"
310
+ echo ""
311
+ fi
312
+ echo "# Output Requirements"
313
+ echo ""
314
+ if [[ "$PRD_PATH" == *.json ]]; then
315
+ echo "Save the PRD JSON to: $PRD_PATH"
316
+ else
317
+ echo "Save the PRD as JSON in directory: $PRD_PATH"
318
+ echo "Filename rules: prd-<short-slug>.json using 1-3 meaningful words."
319
+ echo "Examples: prd-workout-tracker.json, prd-usage-billing.json"
320
+ fi
321
+ echo ""
322
+ echo "Do NOT implement anything. Only generate the JSON PRD file."
323
+ echo ""
324
+ echo "After saving the PRD, end your response with:"
325
+ echo "PRD JSON saved to <path>. Close this chat and run \`ralph build\`."
326
+ echo ""
327
+ echo "---"
328
+ echo ""
329
+ echo "# User Request"
330
+ echo ""
331
+ cat "$PRD_REQUEST_PATH"
332
+ } > "$PRD_PROMPT_FILE"
333
+
334
+ # Use PRD_AGENT_CMD for the agent call (always file-based for PRD mode)
335
+ AGENT_CMD="$PRD_AGENT_CMD"
336
+ run_agent "$PRD_PROMPT_FILE"
337
+ exit 0
338
+ fi
339
+
340
+ if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
341
+ require_agent
342
+ fi
343
+
344
+ if [ ! -f "$PROMPT_FILE" ]; then
345
+ echo "Prompt not found: $PROMPT_FILE"
346
+ exit 1
347
+ fi
348
+
349
+ if [ "$MODE" != "prd" ] && [ ! -f "$PRD_PATH" ]; then
350
+ echo "PRD not found: $PRD_PATH"
351
+ exit 1
352
+ fi
353
+
354
+ mkdir -p "$(dirname "$PROGRESS_PATH")" "$TMP_DIR" "$RUNS_DIR"
355
+
356
+ if [ ! -f "$PROGRESS_PATH" ]; then
357
+ {
358
+ echo "# Progress Log"
359
+ echo "Started: $(date)"
360
+ echo ""
361
+ echo "## Codebase Patterns"
362
+ echo "- (add reusable patterns here)"
363
+ echo ""
364
+ echo "---"
365
+ } > "$PROGRESS_PATH"
366
+ fi
367
+
368
+ if [ ! -f "$GUARDRAILS_PATH" ]; then
369
+ {
370
+ echo "# Guardrails (Signs)"
371
+ echo ""
372
+ echo "> Lessons learned from failures. Read before acting."
373
+ echo ""
374
+ echo "## Core Signs"
375
+ echo ""
376
+ echo "### Sign: Read Before Writing"
377
+ echo "- **Trigger**: Before modifying any file"
378
+ echo "- **Instruction**: Read the file first"
379
+ echo "- **Added after**: Core principle"
380
+ echo ""
381
+ echo "### Sign: Test Before Commit"
382
+ echo "- **Trigger**: Before committing changes"
383
+ echo "- **Instruction**: Run required tests and verify outputs"
384
+ echo "- **Added after**: Core principle"
385
+ echo ""
386
+ echo "---"
387
+ echo ""
388
+ echo "## Learned Signs"
389
+ echo ""
390
+ } > "$GUARDRAILS_PATH"
391
+ fi
392
+
393
+ if [ ! -f "$ERRORS_LOG_PATH" ]; then
394
+ {
395
+ echo "# Error Log"
396
+ echo ""
397
+ echo "> Failures and repeated issues. Use this to add guardrails."
398
+ echo ""
399
+ } > "$ERRORS_LOG_PATH"
400
+ fi
401
+
402
+ if [ ! -f "$ACTIVITY_LOG_PATH" ]; then
403
+ {
404
+ echo "# Activity Log"
405
+ echo ""
406
+ echo "## Run Summary"
407
+ echo ""
408
+ echo "## Events"
409
+ echo ""
410
+ } > "$ACTIVITY_LOG_PATH"
411
+ fi
412
+
413
+ RUN_TAG="$(date +%Y%m%d-%H%M%S)-$$"
414
+
415
+ render_prompt() {
416
+ local src="$1"
417
+ local dst="$2"
418
+ local story_meta="$3"
419
+ local story_block="$4"
420
+ local run_id="$5"
421
+ local iter="$6"
422
+ local run_log="$7"
423
+ local run_meta="$8"
424
+ $PYTHON_CMD - "$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" "$RUNS_DIR" <<'PY'
425
+ import sys
426
+ from pathlib import Path
427
+
428
+ src = Path(sys.argv[1]).read_text()
429
+ prd, agents, progress, root = sys.argv[3:7]
430
+ guardrails = sys.argv[7]
431
+ errors_log = sys.argv[8]
432
+ activity_log = sys.argv[9]
433
+ guardrails_ref = sys.argv[10]
434
+ context_ref = sys.argv[11]
435
+ activity_cmd = sys.argv[12]
436
+ no_commit = sys.argv[13]
437
+ meta_path = sys.argv[14] if len(sys.argv) > 14 else ""
438
+ block_path = sys.argv[15] if len(sys.argv) > 15 else ""
439
+ run_id = sys.argv[16] if len(sys.argv) > 16 else ""
440
+ iteration = sys.argv[17] if len(sys.argv) > 17 else ""
441
+ run_log = sys.argv[18] if len(sys.argv) > 18 else ""
442
+ run_meta = sys.argv[19] if len(sys.argv) > 19 else ""
443
+ runs_dir = sys.argv[20] if len(sys.argv) > 20 else ""
444
+ repl = {
445
+ "PRD_PATH": prd,
446
+ "AGENTS_PATH": agents,
447
+ "PROGRESS_PATH": progress,
448
+ "REPO_ROOT": root,
449
+ "GUARDRAILS_PATH": guardrails,
450
+ "ERRORS_LOG_PATH": errors_log,
451
+ "ACTIVITY_LOG_PATH": activity_log,
452
+ "GUARDRAILS_REF": guardrails_ref,
453
+ "CONTEXT_REF": context_ref,
454
+ "ACTIVITY_CMD": activity_cmd,
455
+ "NO_COMMIT": no_commit,
456
+ "RUN_ID": run_id,
457
+ "ITERATION": iteration,
458
+ "RUN_LOG_PATH": run_log,
459
+ "RUN_META_PATH": run_meta,
460
+ "RUNS_DIR": runs_dir,
461
+ }
462
+ story = {"id": "", "title": "", "block": ""}
463
+ quality_gates = []
464
+ if meta_path:
465
+ try:
466
+ import json
467
+ meta = json.loads(Path(meta_path).read_text())
468
+ story["id"] = meta.get("id", "") or ""
469
+ story["title"] = meta.get("title", "") or ""
470
+ quality_gates = meta.get("quality_gates", []) or []
471
+ except Exception:
472
+ pass
473
+ if block_path and Path(block_path).exists():
474
+ story["block"] = Path(block_path).read_text()
475
+ repl["STORY_ID"] = story["id"]
476
+ repl["STORY_TITLE"] = story["title"]
477
+ repl["STORY_BLOCK"] = story["block"]
478
+ if quality_gates:
479
+ repl["QUALITY_GATES"] = "\n".join([f"- {g}" for g in quality_gates])
480
+ else:
481
+ repl["QUALITY_GATES"] = "- (none)"
482
+ for k, v in repl.items():
483
+ src = src.replace("{{" + k + "}}", v)
484
+ Path(sys.argv[2]).write_text(src)
485
+ PY
486
+ }
487
+
488
+ select_story() {
489
+ local meta_out="$1"
490
+ local block_out="$2"
491
+ $PYTHON_CMD - "$PRD_PATH" "$meta_out" "$block_out" "$STALE_SECONDS" <<'PY'
492
+ import json
493
+ import os
494
+ import sys
495
+ from pathlib import Path
496
+ from datetime import datetime, timezone
497
+ try:
498
+ import fcntl
499
+ except Exception:
500
+ fcntl = None
501
+
502
+ prd_path = Path(sys.argv[1])
503
+ meta_out = Path(sys.argv[2])
504
+ block_out = Path(sys.argv[3])
505
+ stale_seconds = 0
506
+ if len(sys.argv) > 4:
507
+ try:
508
+ stale_seconds = int(sys.argv[4])
509
+ except Exception:
510
+ stale_seconds = 0
511
+
512
+ if not prd_path.exists():
513
+ meta_out.write_text(json.dumps({"ok": False, "error": "PRD not found"}, indent=2) + "\n")
514
+ block_out.write_text("")
515
+ sys.exit(0)
516
+
517
+ def normalize_status(value):
518
+ if value is None:
519
+ return "open"
520
+ return str(value).strip().lower()
521
+
522
+ def parse_ts(value):
523
+ if not value:
524
+ return None
525
+ text = str(value).strip()
526
+ if text.endswith("Z"):
527
+ text = text[:-1] + "+00:00"
528
+ try:
529
+ return datetime.fromisoformat(text)
530
+ except Exception:
531
+ return None
532
+
533
+ def now_iso():
534
+ return datetime.now(timezone.utc).isoformat()
535
+
536
+ with prd_path.open("r+", encoding="utf-8") as fh:
537
+ if fcntl is not None:
538
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
539
+ try:
540
+ try:
541
+ data = json.load(fh)
542
+ except Exception as exc:
543
+ meta_out.write_text(json.dumps({"ok": False, "error": f"Invalid PRD JSON: {exc}"}, indent=2) + "\n")
544
+ block_out.write_text("")
545
+ sys.exit(0)
546
+
547
+ stories = data.get("stories") if isinstance(data, dict) else None
548
+ if not isinstance(stories, list) or not stories:
549
+ meta_out.write_text(json.dumps({"ok": False, "error": "No stories found in PRD"}, indent=2) + "\n")
550
+ block_out.write_text("")
551
+ sys.exit(0)
552
+
553
+ story_index = {s.get("id"): s for s in stories if isinstance(s, dict)}
554
+
555
+ def is_done(story_id: str) -> bool:
556
+ target = story_index.get(story_id)
557
+ if not isinstance(target, dict):
558
+ return False
559
+ return normalize_status(target.get("status")) == "done"
560
+
561
+ if stale_seconds > 0:
562
+ now = datetime.now(timezone.utc)
563
+ for story in stories:
564
+ if not isinstance(story, dict):
565
+ continue
566
+ if normalize_status(story.get("status")) != "in_progress":
567
+ continue
568
+ started = parse_ts(story.get("startedAt"))
569
+ if started is None or (now - started).total_seconds() > stale_seconds:
570
+ story["status"] = "open"
571
+ story["startedAt"] = None
572
+ story["completedAt"] = None
573
+ story["updatedAt"] = now_iso()
574
+
575
+ candidate = None
576
+ for story in stories:
577
+ if not isinstance(story, dict):
578
+ continue
579
+ if normalize_status(story.get("status")) != "open":
580
+ continue
581
+ deps = story.get("dependsOn") or []
582
+ if not isinstance(deps, list):
583
+ deps = []
584
+ if all(is_done(dep) for dep in deps):
585
+ candidate = story
586
+ break
587
+
588
+ # Count remaining stories (excluding done and blocked)
589
+ remaining = sum(
590
+ 1 for story in stories
591
+ if isinstance(story, dict) and normalize_status(story.get("status")) not in ("done", "blocked")
592
+ )
593
+
594
+ meta = {
595
+ "ok": True,
596
+ "total": len(stories),
597
+ "remaining": remaining,
598
+ "quality_gates": data.get("qualityGates", []) or [],
599
+ }
600
+
601
+ if candidate:
602
+ candidate["status"] = "in_progress"
603
+ if not candidate.get("startedAt"):
604
+ candidate["startedAt"] = now_iso()
605
+ candidate["completedAt"] = None
606
+ candidate["updatedAt"] = now_iso()
607
+ meta.update({
608
+ "id": candidate.get("id", ""),
609
+ "title": candidate.get("title", ""),
610
+ })
611
+
612
+ depends = candidate.get("dependsOn") or []
613
+ if not isinstance(depends, list):
614
+ depends = []
615
+ acceptance = candidate.get("acceptanceCriteria") or []
616
+ if not isinstance(acceptance, list):
617
+ acceptance = []
618
+
619
+ description = candidate.get("description") or ""
620
+ block_lines = []
621
+ block_lines.append(f"### {candidate.get('id', '')}: {candidate.get('title', '')}")
622
+ block_lines.append(f"Status: {candidate.get('status', 'open')}")
623
+ block_lines.append(
624
+ f"Depends on: {', '.join(depends) if depends else 'None'}"
625
+ )
626
+ block_lines.append("")
627
+ block_lines.append("Description:")
628
+ block_lines.append(description if description else "(none)")
629
+ block_lines.append("")
630
+ block_lines.append("Acceptance Criteria:")
631
+ if acceptance:
632
+ block_lines.extend([f"- [ ] {item}" for item in acceptance])
633
+ else:
634
+ block_lines.append("- (none)")
635
+ block_out.write_text("\n".join(block_lines).rstrip() + "\n")
636
+ else:
637
+ block_out.write_text("")
638
+
639
+ fh.seek(0)
640
+ fh.truncate()
641
+ json.dump(data, fh, indent=2)
642
+ fh.write("\n")
643
+ fh.flush()
644
+ os.fsync(fh.fileno())
645
+ finally:
646
+ if fcntl is not None:
647
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
648
+
649
+ meta_out.write_text(json.dumps(meta, indent=2) + "\n")
650
+ PY
651
+ }
652
+
653
+ remaining_stories() {
654
+ local meta_file="$1"
655
+ $PYTHON_CMD - "$meta_file" <<'PY'
656
+ import json
657
+ import sys
658
+ from pathlib import Path
659
+
660
+ data = json.loads(Path(sys.argv[1]).read_text())
661
+ print(data.get("remaining", "unknown"))
662
+ PY
663
+ }
664
+
665
+ remaining_from_prd() {
666
+ $PYTHON_CMD - "$PRD_PATH" <<'PY'
667
+ import json
668
+ import sys
669
+ from pathlib import Path
670
+
671
+ prd_path = Path(sys.argv[1])
672
+ if not prd_path.exists():
673
+ print("unknown")
674
+ sys.exit(0)
675
+
676
+ try:
677
+ data = json.loads(prd_path.read_text())
678
+ except Exception:
679
+ print("unknown")
680
+ sys.exit(0)
681
+
682
+ stories = data.get("stories") if isinstance(data, dict) else None
683
+ if not isinstance(stories, list):
684
+ print("unknown")
685
+ sys.exit(0)
686
+
687
+ def normalize_status(value):
688
+ if value is None:
689
+ return "open"
690
+ return str(value).strip().lower()
691
+
692
+ # Count remaining stories (excluding done and blocked)
693
+ remaining = sum(
694
+ 1 for story in stories
695
+ if isinstance(story, dict) and normalize_status(story.get("status")) not in ("done", "blocked")
696
+ )
697
+ print(remaining)
698
+ PY
699
+ }
700
+
701
+ story_field() {
702
+ local meta_file="$1"
703
+ local field="$2"
704
+ $PYTHON_CMD - "$meta_file" "$field" <<'PY'
705
+ import json
706
+ import sys
707
+ from pathlib import Path
708
+
709
+ data = json.loads(Path(sys.argv[1]).read_text())
710
+ field = sys.argv[2]
711
+ print(data.get(field, ""))
712
+ PY
713
+ }
714
+
715
+ update_story_status() {
716
+ local story_id="$1"
717
+ local new_status="$2"
718
+ $PYTHON_CMD - "$PRD_PATH" "$story_id" "$new_status" <<'PY'
719
+ import json
720
+ import os
721
+ import sys
722
+ from pathlib import Path
723
+ from datetime import datetime, timezone
724
+ try:
725
+ import fcntl
726
+ except Exception:
727
+ fcntl = None
728
+
729
+ prd_path = Path(sys.argv[1])
730
+ story_id = sys.argv[2]
731
+ new_status = sys.argv[3]
732
+
733
+ if not story_id:
734
+ sys.exit(0)
735
+
736
+ if not prd_path.exists():
737
+ sys.exit(0)
738
+
739
+ def now_iso():
740
+ return datetime.now(timezone.utc).isoformat()
741
+
742
+ with prd_path.open("r+", encoding="utf-8") as fh:
743
+ if fcntl is not None:
744
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
745
+ try:
746
+ data = json.load(fh)
747
+ stories = data.get("stories") if isinstance(data, dict) else None
748
+ if not isinstance(stories, list):
749
+ sys.exit(0)
750
+ for story in stories:
751
+ if isinstance(story, dict) and story.get("id") == story_id:
752
+ story["status"] = new_status
753
+ story["updatedAt"] = now_iso()
754
+ if new_status == "in_progress":
755
+ if not story.get("startedAt"):
756
+ story["startedAt"] = now_iso()
757
+ story["completedAt"] = None
758
+ elif new_status == "done":
759
+ story["completedAt"] = now_iso()
760
+ if not story.get("startedAt"):
761
+ story["startedAt"] = now_iso()
762
+ elif new_status == "open":
763
+ story["startedAt"] = None
764
+ story["completedAt"] = None
765
+ break
766
+ fh.seek(0)
767
+ fh.truncate()
768
+ json.dump(data, fh, indent=2)
769
+ fh.write("\n")
770
+ fh.flush()
771
+ os.fsync(fh.fileno())
772
+ finally:
773
+ if fcntl is not None:
774
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
775
+ PY
776
+ }
777
+
778
+ log_activity() {
779
+ local message="$1"
780
+ local timestamp
781
+ timestamp=$(date '+%Y-%m-%d %H:%M:%S')
782
+ echo "[$timestamp] $message" >> "$ACTIVITY_LOG_PATH"
783
+ }
784
+
785
+ log_error() {
786
+ local message="$1"
787
+ local timestamp
788
+ timestamp=$(date '+%Y-%m-%d %H:%M:%S')
789
+ echo "[$timestamp] $message" >> "$ERRORS_LOG_PATH"
790
+ }
791
+
792
+ # Check if the log file contains a transient/retryable error
793
+ # Only returns true if the log is small (< 20 lines) AND contains a known error
794
+ # This prevents retrying after a successful run that happens to mention an error
795
+ is_transient_error() {
796
+ local log_file="$1"
797
+ if [ ! -f "$log_file" ]; then
798
+ # No log file at all - treat as transient (agent didn't even start)
799
+ return 0
800
+ fi
801
+
802
+ # Check log file size
803
+ local line_count
804
+ line_count=$(wc -l < "$log_file" | tr -d ' ')
805
+
806
+ # Empty or very small log file (<= 5 lines) with non-zero exit - likely transient
807
+ # The agent didn't produce meaningful output
808
+ if [ "$line_count" -le 5 ]; then
809
+ return 0
810
+ fi
811
+
812
+ # If the log file has substantial content (> 20 lines), don't retry
813
+ # This means the agent actually ran and produced output
814
+ if [ "$line_count" -gt 20 ]; then
815
+ return 1
816
+ fi
817
+
818
+ # For files between 6-20 lines, check for known error patterns
819
+ if grep -q "No messages returned" "$log_file"; then
820
+ return 0
821
+ fi
822
+ if grep -q "rate_limit_error" "$log_file"; then
823
+ return 0
824
+ fi
825
+ if grep -q "overloaded_error" "$log_file"; then
826
+ return 0
827
+ fi
828
+ if grep -q "ECONNRESET" "$log_file"; then
829
+ return 0
830
+ fi
831
+ if grep -q "ETIMEDOUT" "$log_file"; then
832
+ return 0
833
+ fi
834
+ if grep -q "socket hang up" "$log_file"; then
835
+ return 0
836
+ fi
837
+ if grep -q "API error" "$log_file"; then
838
+ return 0
839
+ fi
840
+ return 1
841
+ }
842
+
843
+ # Clean up uncommitted changes after failed iteration
844
+ git_cleanup_dirty() {
845
+ if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
846
+ local dirty
847
+ dirty="$(git -C "$ROOT_DIR" status --porcelain 2>/dev/null || true)"
848
+ if [ -n "$dirty" ]; then
849
+ echo "Cleaning up uncommitted changes from failed iteration..."
850
+ git -C "$ROOT_DIR" checkout -- . 2>/dev/null || true
851
+ git -C "$ROOT_DIR" clean -fd 2>/dev/null || true
852
+ fi
853
+ fi
854
+ }
855
+
856
+ # Track story failure counts (stored in temp file)
857
+ STORY_FAILURES_FILE="$TMP_DIR/story-failures-$$.json"
858
+
859
+ init_story_failures() {
860
+ echo "{}" > "$STORY_FAILURES_FILE"
861
+ }
862
+
863
+ get_story_failure_count() {
864
+ local story_id="$1"
865
+ if [ ! -f "$STORY_FAILURES_FILE" ]; then
866
+ echo "0"
867
+ return
868
+ fi
869
+ $PYTHON_CMD - "$STORY_FAILURES_FILE" "$story_id" <<'PY'
870
+ import json
871
+ import sys
872
+ from pathlib import Path
873
+ try:
874
+ data = json.loads(Path(sys.argv[1]).read_text())
875
+ print(data.get(sys.argv[2], 0))
876
+ except Exception:
877
+ print("0")
878
+ PY
879
+ }
880
+
881
+ increment_story_failure() {
882
+ local story_id="$1"
883
+ $PYTHON_CMD - "$STORY_FAILURES_FILE" "$story_id" <<'PY'
884
+ import json
885
+ import sys
886
+ from pathlib import Path
887
+ path = Path(sys.argv[1])
888
+ story_id = sys.argv[2]
889
+ try:
890
+ data = json.loads(path.read_text())
891
+ except Exception:
892
+ data = {}
893
+ data[story_id] = data.get(story_id, 0) + 1
894
+ path.write_text(json.dumps(data))
895
+ PY
896
+ }
897
+
898
+ # Mark a story as blocked (too many failures) in PRD
899
+ mark_story_blocked() {
900
+ local story_id="$1"
901
+ local reason="$2"
902
+ $PYTHON_CMD - "$PRD_PATH" "$story_id" "$reason" <<'PY'
903
+ import json
904
+ import os
905
+ import sys
906
+ from pathlib import Path
907
+ from datetime import datetime, timezone
908
+ try:
909
+ import fcntl
910
+ except Exception:
911
+ fcntl = None
912
+
913
+ prd_path = Path(sys.argv[1])
914
+ story_id = sys.argv[2]
915
+ reason = sys.argv[3] if len(sys.argv) > 3 else "Too many failures"
916
+
917
+ if not story_id or not prd_path.exists():
918
+ sys.exit(0)
919
+
920
+ def now_iso():
921
+ return datetime.now(timezone.utc).isoformat()
922
+
923
+ with prd_path.open("r+", encoding="utf-8") as fh:
924
+ if fcntl is not None:
925
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
926
+ try:
927
+ data = json.load(fh)
928
+ stories = data.get("stories") if isinstance(data, dict) else None
929
+ if not isinstance(stories, list):
930
+ sys.exit(0)
931
+ for story in stories:
932
+ if isinstance(story, dict) and story.get("id") == story_id:
933
+ story["status"] = "blocked"
934
+ story["blockedReason"] = reason
935
+ story["updatedAt"] = now_iso()
936
+ break
937
+ fh.seek(0)
938
+ fh.truncate()
939
+ json.dump(data, fh, indent=2)
940
+ fh.write("\n")
941
+ fh.flush()
942
+ os.fsync(fh.fileno())
943
+ finally:
944
+ if fcntl is not None:
945
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
946
+ PY
947
+ }
948
+
949
+ append_run_summary() {
950
+ local line="$1"
951
+ $PYTHON_CMD - "$ACTIVITY_LOG_PATH" "$line" <<'PY'
952
+ import sys
953
+ from pathlib import Path
954
+
955
+ path = Path(sys.argv[1])
956
+ line = sys.argv[2]
957
+ text = path.read_text().splitlines()
958
+ out = []
959
+ inserted = False
960
+ for l in text:
961
+ out.append(l)
962
+ if not inserted and l.strip() == "## Run Summary":
963
+ out.append(f"- {line}")
964
+ inserted = True
965
+ if not inserted:
966
+ out = [
967
+ "# Activity Log",
968
+ "",
969
+ "## Run Summary",
970
+ f"- {line}",
971
+ "",
972
+ "## Events",
973
+ "",
974
+ ] + text
975
+ Path(path).write_text("\n".join(out).rstrip() + "\n")
976
+ PY
977
+ }
978
+
979
+ write_run_meta() {
980
+ local path="$1"
981
+ local mode="$2"
982
+ local iter="$3"
983
+ local run_id="$4"
984
+ local story_id="$5"
985
+ local story_title="$6"
986
+ local started="$7"
987
+ local ended="$8"
988
+ local duration="$9"
989
+ local status="${10}"
990
+ local log_file="${11}"
991
+ local head_before="${12}"
992
+ local head_after="${13}"
993
+ local commit_list="${14}"
994
+ local changed_files="${15}"
995
+ local dirty_files="${16}"
996
+ {
997
+ echo "# Ralph Run Summary"
998
+ echo ""
999
+ echo "- Run ID: $run_id"
1000
+ echo "- Iteration: $iter"
1001
+ echo "- Mode: $mode"
1002
+ if [ -n "$story_id" ]; then
1003
+ echo "- Story: $story_id: $story_title"
1004
+ fi
1005
+ echo "- Started: $started"
1006
+ echo "- Ended: $ended"
1007
+ echo "- Duration: ${duration}s"
1008
+ echo "- Status: $status"
1009
+ echo "- Log: $log_file"
1010
+ echo ""
1011
+ echo "## Git"
1012
+ echo "- Head (before): ${head_before:-unknown}"
1013
+ echo "- Head (after): ${head_after:-unknown}"
1014
+ echo ""
1015
+ echo "### Commits"
1016
+ if [ -n "$commit_list" ]; then
1017
+ echo "$commit_list"
1018
+ else
1019
+ echo "- (none)"
1020
+ fi
1021
+ echo ""
1022
+ echo "### Changed Files (commits)"
1023
+ if [ -n "$changed_files" ]; then
1024
+ echo "$changed_files"
1025
+ else
1026
+ echo "- (none)"
1027
+ fi
1028
+ echo ""
1029
+ echo "### Uncommitted Changes"
1030
+ if [ -n "$dirty_files" ]; then
1031
+ echo "$dirty_files"
1032
+ else
1033
+ echo "- (clean)"
1034
+ fi
1035
+ echo ""
1036
+ } > "$path"
1037
+ }
1038
+
1039
+ git_head() {
1040
+ if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
1041
+ git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true
1042
+ else
1043
+ echo ""
1044
+ fi
1045
+ }
1046
+
1047
+ git_commit_list() {
1048
+ local before="$1"
1049
+ local after="$2"
1050
+ if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
1051
+ git -C "$ROOT_DIR" log --oneline "$before..$after" | sed 's/^/- /'
1052
+ else
1053
+ echo ""
1054
+ fi
1055
+ }
1056
+
1057
+ git_changed_files() {
1058
+ local before="$1"
1059
+ local after="$2"
1060
+ if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
1061
+ git -C "$ROOT_DIR" diff --name-only "$before" "$after" | sed 's/^/- /'
1062
+ else
1063
+ echo ""
1064
+ fi
1065
+ }
1066
+
1067
+ git_dirty_files() {
1068
+ if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
1069
+ git -C "$ROOT_DIR" status --porcelain | awk '{print "- " $2}'
1070
+ else
1071
+ echo ""
1072
+ fi
1073
+ }
1074
+
1075
+ echo "Ralph mode: $MODE"
1076
+ echo "Max iterations: $MAX_ITERATIONS"
1077
+ echo "Max retries per iteration: $MAX_RETRIES"
1078
+ echo "Max story failures before blocking: $MAX_STORY_FAILURES"
1079
+ echo "PRD: $PRD_PATH"
1080
+ HAS_ERROR="false"
1081
+
1082
+ # Initialize story failure tracking
1083
+ init_story_failures
1084
+
1085
+ for i in $(seq 1 "$MAX_ITERATIONS"); do
1086
+ echo ""
1087
+ echo "═══════════════════════════════════════════════════════"
1088
+ echo " Ralph Iteration $i of $MAX_ITERATIONS"
1089
+ echo "═══════════════════════════════════════════════════════"
1090
+
1091
+ STORY_META=""
1092
+ STORY_BLOCK=""
1093
+ ITER_START=$(date +%s)
1094
+ ITER_START_FMT=$(date '+%Y-%m-%d %H:%M:%S')
1095
+ if [ "$MODE" = "build" ]; then
1096
+ STORY_META="$TMP_DIR/story-$RUN_TAG-$i.json"
1097
+ STORY_BLOCK="$TMP_DIR/story-$RUN_TAG-$i.md"
1098
+ select_story "$STORY_META" "$STORY_BLOCK"
1099
+ REMAINING="$(remaining_stories "$STORY_META")"
1100
+ if [ "$REMAINING" = "unknown" ]; then
1101
+ echo "Could not parse stories from PRD: $PRD_PATH"
1102
+ exit 1
1103
+ fi
1104
+ if [ "$REMAINING" = "0" ]; then
1105
+ echo "No remaining stories."
1106
+ exit 0
1107
+ fi
1108
+ STORY_ID="$(story_field "$STORY_META" "id")"
1109
+ STORY_TITLE="$(story_field "$STORY_META" "title")"
1110
+ if [ -z "$STORY_ID" ]; then
1111
+ echo "No actionable open stories (all blocked or in progress). Remaining: $REMAINING"
1112
+ exit 0
1113
+ fi
1114
+
1115
+ # Check if this story has failed too many times
1116
+ STORY_FAILURES="$(get_story_failure_count "$STORY_ID")"
1117
+ if [ "$STORY_FAILURES" -ge "$MAX_STORY_FAILURES" ]; then
1118
+ echo "Story $STORY_ID has failed $STORY_FAILURES times, marking as blocked."
1119
+ mark_story_blocked "$STORY_ID" "Failed $STORY_FAILURES times"
1120
+ log_error "Story $STORY_ID blocked after $STORY_FAILURES failures"
1121
+ continue
1122
+ fi
1123
+ fi
1124
+
1125
+ HEAD_BEFORE="$(git_head)"
1126
+ PROMPT_RENDERED="$TMP_DIR/prompt-$RUN_TAG-$i.md"
1127
+ LOG_FILE="$RUNS_DIR/run-$RUN_TAG-iter-$i.log"
1128
+ RUN_META="$RUNS_DIR/run-$RUN_TAG-iter-$i.md"
1129
+ render_prompt "$PROMPT_FILE" "$PROMPT_RENDERED" "$STORY_META" "$STORY_BLOCK" "$RUN_TAG" "$i" "$LOG_FILE" "$RUN_META"
1130
+
1131
+ if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
1132
+ log_activity "ITERATION $i start (mode=$MODE story=$STORY_ID)"
1133
+ else
1134
+ log_activity "ITERATION $i start (mode=$MODE)"
1135
+ fi
1136
+
1137
+ # Retry loop for transient errors
1138
+ RETRY_COUNT=0
1139
+ CMD_STATUS=1
1140
+ while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
1141
+ set +e
1142
+ if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
1143
+ echo "[RALPH_DRY_RUN] Skipping agent execution." | tee "$LOG_FILE"
1144
+ CMD_STATUS=0
1145
+ else
1146
+ # Run agent - capture output to log file while displaying to terminal
1147
+ # Note: On some systems, piping through tee can cause buffering issues
1148
+ # with interactive CLIs like Claude. If you see no output, the agent
1149
+ # is still running - check the log file for captured output.
1150
+ run_agent "$PROMPT_RENDERED" 2>&1 | tee "$LOG_FILE"
1151
+ CMD_STATUS=${PIPESTATUS[0]}
1152
+ fi
1153
+ set -e
1154
+
1155
+ # Check for user interrupt
1156
+ if [ "$CMD_STATUS" -eq 130 ] || [ "$CMD_STATUS" -eq 143 ]; then
1157
+ echo "Interrupted."
1158
+ exit "$CMD_STATUS"
1159
+ fi
1160
+
1161
+ # If command succeeded, break retry loop
1162
+ if [ "$CMD_STATUS" -eq 0 ]; then
1163
+ break
1164
+ fi
1165
+
1166
+ # Check if this is a transient error worth retrying
1167
+ if is_transient_error "$LOG_FILE"; then
1168
+ RETRY_COUNT=$((RETRY_COUNT + 1))
1169
+ if [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; then
1170
+ echo ""
1171
+ echo "╔═══════════════════════════════════════════════════════╗"
1172
+ echo "║ Transient error detected, retrying ($RETRY_COUNT/$MAX_RETRIES)..."
1173
+ echo "╚═══════════════════════════════════════════════════════╝"
1174
+ log_activity "ITERATION $i retry $RETRY_COUNT (transient error)"
1175
+ # Clean up any dirty state before retry
1176
+ git_cleanup_dirty
1177
+ # Exponential backoff: 10s, 20s, 40s, ...
1178
+ local backoff_delay=$((RETRY_DELAY * (1 << (RETRY_COUNT - 1))))
1179
+ echo "Waiting ${backoff_delay}s before retry..."
1180
+ sleep "$backoff_delay"
1181
+ continue
1182
+ fi
1183
+ fi
1184
+
1185
+ # Non-transient error or max retries reached
1186
+ break
1187
+ done
1188
+
1189
+ ITER_END=$(date +%s)
1190
+ ITER_END_FMT=$(date '+%Y-%m-%d %H:%M:%S')
1191
+ ITER_DURATION=$((ITER_END - ITER_START))
1192
+ HEAD_AFTER="$(git_head)"
1193
+ log_activity "ITERATION $i end (duration=${ITER_DURATION}s)"
1194
+ if [ "$CMD_STATUS" -ne 0 ]; then
1195
+ log_error "ITERATION $i command failed (status=$CMD_STATUS) after $((RETRY_COUNT)) retries"
1196
+ HAS_ERROR="true"
1197
+ fi
1198
+ COMMIT_LIST="$(git_commit_list "$HEAD_BEFORE" "$HEAD_AFTER")"
1199
+ CHANGED_FILES="$(git_changed_files "$HEAD_BEFORE" "$HEAD_AFTER")"
1200
+ DIRTY_FILES="$(git_dirty_files)"
1201
+ STATUS_LABEL="success"
1202
+ if [ "$CMD_STATUS" -ne 0 ]; then
1203
+ STATUS_LABEL="error"
1204
+ fi
1205
+ if [ "$MODE" = "build" ] && [ "$NO_COMMIT" = "false" ] && [ -n "$DIRTY_FILES" ]; then
1206
+ log_error "ITERATION $i left uncommitted changes; review run summary at $RUN_META"
1207
+ # Clean up dirty state on failure to prevent polluting next iteration
1208
+ if [ "$CMD_STATUS" -ne 0 ]; then
1209
+ git_cleanup_dirty
1210
+ fi
1211
+ fi
1212
+ 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"
1213
+ if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
1214
+ 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 | retries=$RETRY_COUNT"
1215
+ else
1216
+ append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | duration=${ITER_DURATION}s | status=$STATUS_LABEL"
1217
+ fi
1218
+
1219
+ if [ "$MODE" = "build" ]; then
1220
+ if [ "$CMD_STATUS" -ne 0 ]; then
1221
+ log_error "ITERATION $i exited non-zero; review $LOG_FILE"
1222
+ increment_story_failure "$STORY_ID"
1223
+ STORY_FAILURES="$(get_story_failure_count "$STORY_ID")"
1224
+ if [ "$STORY_FAILURES" -ge "$MAX_STORY_FAILURES" ]; then
1225
+ echo "Story $STORY_ID has now failed $STORY_FAILURES times, marking as blocked."
1226
+ mark_story_blocked "$STORY_ID" "Failed $STORY_FAILURES times"
1227
+ log_error "Story $STORY_ID blocked after $STORY_FAILURES consecutive failures"
1228
+ else
1229
+ update_story_status "$STORY_ID" "open"
1230
+ echo "Iteration failed (attempt $STORY_FAILURES/$MAX_STORY_FAILURES); story reset to open."
1231
+ fi
1232
+ elif grep -q "<promise>COMPLETE</promise>" "$LOG_FILE"; then
1233
+ update_story_status "$STORY_ID" "done"
1234
+ echo "Completion signal received; story marked done."
1235
+ else
1236
+ # No completion signal - could be agent didn't finish or didn't output signal
1237
+ increment_story_failure "$STORY_ID"
1238
+ STORY_FAILURES="$(get_story_failure_count "$STORY_ID")"
1239
+ if [ "$STORY_FAILURES" -ge "$MAX_STORY_FAILURES" ]; then
1240
+ echo "Story $STORY_ID has failed $STORY_FAILURES times (no completion signal), marking as blocked."
1241
+ mark_story_blocked "$STORY_ID" "No completion signal after $STORY_FAILURES attempts"
1242
+ log_error "Story $STORY_ID blocked: no completion signal after $STORY_FAILURES attempts"
1243
+ else
1244
+ update_story_status "$STORY_ID" "open"
1245
+ echo "No completion signal (attempt $STORY_FAILURES/$MAX_STORY_FAILURES); story reset to open."
1246
+ fi
1247
+ fi
1248
+ REMAINING="$(remaining_from_prd)"
1249
+ echo "Iteration $i complete. Remaining stories: $REMAINING"
1250
+ if [ "$REMAINING" = "0" ]; then
1251
+ echo "No remaining stories."
1252
+ exit 0
1253
+ fi
1254
+ else
1255
+ echo "Iteration $i complete."
1256
+ fi
1257
+ sleep 2
1258
+
1259
+ done
1260
+
1261
+ echo "Reached max iterations ($MAX_ITERATIONS)."
1262
+ if [ "$HAS_ERROR" = "true" ]; then
1263
+ exit 1
1264
+ fi
1265
+ exit 0