@canivel/ralph 0.2.0 → 0.2.3

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 (39) hide show
  1. package/.agents/ralph/PROMPT_build.md +126 -126
  2. package/.agents/ralph/agents.sh +17 -15
  3. package/.agents/ralph/config.sh +25 -25
  4. package/.agents/ralph/log-activity.sh +15 -15
  5. package/.agents/ralph/loop.sh +1027 -1001
  6. package/.agents/ralph/references/CONTEXT_ENGINEERING.md +126 -126
  7. package/.agents/ralph/references/GUARDRAILS.md +174 -174
  8. package/AGENTS.md +20 -20
  9. package/README.md +270 -266
  10. package/bin/ralph +766 -765
  11. package/diagram.svg +55 -55
  12. package/examples/commands.md +46 -46
  13. package/package.json +39 -39
  14. package/skills/commit/SKILL.md +219 -219
  15. package/skills/commit/references/commit_examples.md +292 -292
  16. package/skills/dev-browser/SKILL.md +211 -211
  17. package/skills/dev-browser/bun.lock +443 -443
  18. package/skills/dev-browser/package-lock.json +2988 -2988
  19. package/skills/dev-browser/package.json +31 -31
  20. package/skills/dev-browser/references/scraping.md +155 -155
  21. package/skills/dev-browser/scripts/start-relay.ts +32 -32
  22. package/skills/dev-browser/scripts/start-server.ts +117 -117
  23. package/skills/dev-browser/server.sh +24 -24
  24. package/skills/dev-browser/src/client.ts +474 -474
  25. package/skills/dev-browser/src/index.ts +287 -287
  26. package/skills/dev-browser/src/relay.ts +731 -731
  27. package/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +223 -223
  28. package/skills/dev-browser/src/snapshot/browser-script.ts +877 -877
  29. package/skills/dev-browser/src/snapshot/index.ts +14 -14
  30. package/skills/dev-browser/src/snapshot/inject.ts +13 -13
  31. package/skills/dev-browser/src/types.ts +34 -34
  32. package/skills/dev-browser/tsconfig.json +36 -36
  33. package/skills/dev-browser/vitest.config.ts +12 -12
  34. package/skills/prd/SKILL.md +235 -235
  35. package/tests/agent-loops.mjs +79 -79
  36. package/tests/agent-ping.mjs +39 -39
  37. package/tests/audit.md +56 -56
  38. package/tests/cli-smoke.mjs +47 -47
  39. package/tests/real-agents.mjs +127 -127
@@ -1,1001 +1,1027 @@
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
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
- ROOT_DIR="$(cd "${RALPH_ROOT:-${SCRIPT_DIR}/../..}" && pwd)"
14
- CONFIG_FILE="${SCRIPT_DIR}/config.sh"
15
-
16
- DEFAULT_PRD_PATH=".agents/tasks/prd.json"
17
- DEFAULT_PROGRESS_PATH=".ralph/progress.md"
18
- DEFAULT_AGENTS_PATH="AGENTS.md"
19
- DEFAULT_PROMPT_BUILD=".agents/ralph/PROMPT_build.md"
20
- DEFAULT_GUARDRAILS_PATH=".ralph/guardrails.md"
21
- DEFAULT_ERRORS_LOG_PATH=".ralph/errors.log"
22
- DEFAULT_ACTIVITY_LOG_PATH=".ralph/activity.log"
23
- DEFAULT_TMP_DIR=".ralph/.tmp"
24
- DEFAULT_RUNS_DIR=".ralph/runs"
25
- DEFAULT_GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md"
26
- DEFAULT_CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md"
27
- DEFAULT_ACTIVITY_CMD=".agents/ralph/log-activity.sh"
28
- if [[ -n "${RALPH_ROOT:-}" ]]; then
29
- agents_path="$RALPH_ROOT/.agents/ralph/agents.sh"
30
- else
31
- agents_path="$SCRIPT_DIR/agents.sh"
32
- fi
33
- if [[ -f "$agents_path" ]]; then
34
- # shellcheck source=/dev/null
35
- source "$agents_path"
36
- fi
37
-
38
- DEFAULT_MAX_ITERATIONS=25
39
- DEFAULT_NO_COMMIT=false
40
- DEFAULT_STALE_SECONDS=0
41
- PRD_REQUEST_PATH=""
42
- PRD_INLINE=""
43
-
44
- # Optional config overrides (simple shell vars)
45
- if [ -f "$CONFIG_FILE" ]; then
46
- # shellcheck source=/dev/null
47
- . "$CONFIG_FILE"
48
- fi
49
-
50
- DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-codex}"
51
- resolve_agent_cmd() {
52
- local name="$1"
53
- local interactive="${2:-0}"
54
- case "$name" in
55
- claude)
56
- if [ "$interactive" = "1" ]; then
57
- echo "${AGENT_CLAUDE_INTERACTIVE_CMD:-claude --dangerously-skip-permissions {prompt}}"
58
- else
59
- echo "${AGENT_CLAUDE_CMD:-claude -p --dangerously-skip-permissions {prompt}}"
60
- fi
61
- ;;
62
- droid)
63
- if [ "$interactive" = "1" ]; then
64
- echo "${AGENT_DROID_INTERACTIVE_CMD:-droid --skip-permissions-unsafe {prompt}}"
65
- else
66
- echo "${AGENT_DROID_CMD:-droid exec --skip-permissions-unsafe -f {prompt}}"
67
- fi
68
- ;;
69
- codex|"")
70
- if [ "$interactive" = "1" ]; then
71
- echo "${AGENT_CODEX_INTERACTIVE_CMD:-codex --yolo {prompt}}"
72
- else
73
- echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
74
- fi
75
- ;;
76
- opencode)
77
- if [ "$interactive" = "1" ]; then
78
- echo "${AGENT_OPENCODE_INTERACTIVE_CMD:-opencode --prompt {prompt}}"
79
- else
80
- echo "${AGENT_OPENCODE_CMD:-opencode run {prompt}}"
81
- fi
82
- ;;
83
- *)
84
- echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
85
- ;;
86
- esac
87
- }
88
- DEFAULT_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME" 0)"
89
-
90
- PRD_PATH="${PRD_PATH:-$DEFAULT_PRD_PATH}"
91
- PROGRESS_PATH="${PROGRESS_PATH:-$DEFAULT_PROGRESS_PATH}"
92
- AGENTS_PATH="${AGENTS_PATH:-$DEFAULT_AGENTS_PATH}"
93
- PROMPT_BUILD="${PROMPT_BUILD:-$DEFAULT_PROMPT_BUILD}"
94
- GUARDRAILS_PATH="${GUARDRAILS_PATH:-$DEFAULT_GUARDRAILS_PATH}"
95
- ERRORS_LOG_PATH="${ERRORS_LOG_PATH:-$DEFAULT_ERRORS_LOG_PATH}"
96
- ACTIVITY_LOG_PATH="${ACTIVITY_LOG_PATH:-$DEFAULT_ACTIVITY_LOG_PATH}"
97
- TMP_DIR="${TMP_DIR:-$DEFAULT_TMP_DIR}"
98
- RUNS_DIR="${RUNS_DIR:-$DEFAULT_RUNS_DIR}"
99
- GUARDRAILS_REF="${GUARDRAILS_REF:-$DEFAULT_GUARDRAILS_REF}"
100
- CONTEXT_REF="${CONTEXT_REF:-$DEFAULT_CONTEXT_REF}"
101
- ACTIVITY_CMD="${ACTIVITY_CMD:-$DEFAULT_ACTIVITY_CMD}"
102
- AGENT_CMD="${AGENT_CMD:-$DEFAULT_AGENT_CMD}"
103
- MAX_ITERATIONS="${MAX_ITERATIONS:-$DEFAULT_MAX_ITERATIONS}"
104
- NO_COMMIT="${NO_COMMIT:-$DEFAULT_NO_COMMIT}"
105
- STALE_SECONDS="${STALE_SECONDS:-$DEFAULT_STALE_SECONDS}"
106
-
107
- abs_path() {
108
- local p="$1"
109
- if [[ "$p" = /* ]]; then
110
- echo "$p"
111
- else
112
- echo "$ROOT_DIR/$p"
113
- fi
114
- }
115
-
116
- PRD_PATH="$(abs_path "$PRD_PATH")"
117
- PROGRESS_PATH="$(abs_path "$PROGRESS_PATH")"
118
- AGENTS_PATH="$(abs_path "$AGENTS_PATH")"
119
- PROMPT_BUILD="$(abs_path "$PROMPT_BUILD")"
120
- GUARDRAILS_PATH="$(abs_path "$GUARDRAILS_PATH")"
121
- ERRORS_LOG_PATH="$(abs_path "$ERRORS_LOG_PATH")"
122
- ACTIVITY_LOG_PATH="$(abs_path "$ACTIVITY_LOG_PATH")"
123
- TMP_DIR="$(abs_path "$TMP_DIR")"
124
- RUNS_DIR="$(abs_path "$RUNS_DIR")"
125
- GUARDRAILS_REF="$(abs_path "$GUARDRAILS_REF")"
126
- CONTEXT_REF="$(abs_path "$CONTEXT_REF")"
127
- ACTIVITY_CMD="$(abs_path "$ACTIVITY_CMD")"
128
-
129
- require_agent() {
130
- local agent_cmd="${1:-$AGENT_CMD}"
131
- local agent_bin
132
- agent_bin="${agent_cmd%% *}"
133
- if [ -z "$agent_bin" ]; then
134
- echo "AGENT_CMD is empty. Set it in config.sh."
135
- exit 1
136
- fi
137
- if ! command -v "$agent_bin" >/dev/null 2>&1; then
138
- echo "Agent command not found: $agent_bin"
139
- case "$agent_bin" in
140
- codex)
141
- echo "Install: npm i -g @openai/codex"
142
- ;;
143
- claude)
144
- echo "Install: curl -fsSL https://claude.ai/install.sh | bash"
145
- ;;
146
- droid)
147
- echo "Install: curl -fsSL https://app.factory.ai/cli | sh"
148
- ;;
149
- opencode)
150
- echo "Install: curl -fsSL https://opencode.ai/install.sh | bash"
151
- ;;
152
- esac
153
- echo "Then authenticate per the CLI's instructions."
154
- exit 1
155
- fi
156
- }
157
-
158
- run_agent() {
159
- local prompt_file="$1"
160
- if [[ "$AGENT_CMD" == *"{prompt}"* ]]; then
161
- local escaped
162
- escaped=$(printf '%q' "$prompt_file")
163
- local cmd="${AGENT_CMD//\{prompt\}/$escaped}"
164
- eval "$cmd"
165
- else
166
- cat "$prompt_file" | eval "$AGENT_CMD"
167
- fi
168
- }
169
-
170
- run_agent_inline() {
171
- local prompt_file="$1"
172
- local prompt_content
173
- prompt_content="$(cat "$prompt_file")"
174
- local escaped
175
- escaped=$(printf "%s" "$prompt_content" | sed "s/'/'\\\\''/g")
176
- local cmd="${PRD_AGENT_CMD:-$AGENT_CMD}"
177
- if [[ "$cmd" == *"{prompt}"* ]]; then
178
- cmd="${cmd//\{prompt\}/'$escaped'}"
179
- else
180
- cmd="$cmd '$escaped'"
181
- fi
182
- eval "$cmd"
183
- }
184
-
185
- MODE="build"
186
- while [ $# -gt 0 ]; do
187
- case "$1" in
188
- build|prd)
189
- MODE="$1"
190
- shift
191
- ;;
192
- --prompt)
193
- PRD_REQUEST_PATH="$2"
194
- shift 2
195
- ;;
196
- --no-commit)
197
- NO_COMMIT=true
198
- shift
199
- ;;
200
- *)
201
- if [ "$MODE" = "prd" ]; then
202
- PRD_INLINE="${PRD_INLINE:+$PRD_INLINE }$1"
203
- shift
204
- elif [[ "$1" =~ ^[0-9]+$ ]]; then
205
- MAX_ITERATIONS="$1"
206
- shift
207
- else
208
- echo "Unknown arg: $1"
209
- exit 1
210
- fi
211
- ;;
212
- esac
213
- done
214
-
215
- PROMPT_FILE="$PROMPT_BUILD"
216
-
217
- if [ "$MODE" = "prd" ]; then
218
- # For PRD mode, use interactive agent command (file-based with {prompt} placeholder)
219
- if [ -z "${PRD_AGENT_CMD:-}" ]; then
220
- PRD_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME" 1)"
221
- fi
222
- if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
223
- require_agent "${PRD_AGENT_CMD}"
224
- fi
225
-
226
- if [[ "$PRD_PATH" == *.json ]]; then
227
- mkdir -p "$(dirname "$PRD_PATH")" "$TMP_DIR"
228
- else
229
- mkdir -p "$PRD_PATH" "$TMP_DIR"
230
- fi
231
-
232
- if [ -z "$PRD_REQUEST_PATH" ] && [ -n "$PRD_INLINE" ]; then
233
- PRD_REQUEST_PATH="$TMP_DIR/prd-request-$(date +%Y%m%d-%H%M%S)-$$.txt"
234
- printf '%s\n' "$PRD_INLINE" > "$PRD_REQUEST_PATH"
235
- fi
236
-
237
- if [ -z "$PRD_REQUEST_PATH" ] || [ ! -f "$PRD_REQUEST_PATH" ]; then
238
- echo "PRD request missing. Provide a prompt string or --prompt <file>."
239
- exit 1
240
- fi
241
-
242
- if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
243
- if [[ "$PRD_PATH" == *.json ]]; then
244
- if [ ! -f "$PRD_PATH" ]; then
245
- {
246
- echo '{'
247
- echo ' "version": 1,'
248
- echo ' "project": "ralph",'
249
- echo ' "qualityGates": [],'
250
- echo ' "stories": []'
251
- echo '}'
252
- } > "$PRD_PATH"
253
- fi
254
- fi
255
- exit 0
256
- fi
257
-
258
- PRD_PROMPT_FILE="$TMP_DIR/prd-prompt-$(date +%Y%m%d-%H%M%S)-$$.md"
259
- # Look for prd skill in project skills or ralph bundled skills
260
- PRD_SKILL_FILE=""
261
- for skill_path in "$ROOT_DIR/.claude/skills/prd/SKILL.md" "$ROOT_DIR/skills/prd/SKILL.md" "$SCRIPT_DIR/../skills/prd/SKILL.md"; do
262
- if [[ -f "$skill_path" ]]; then
263
- PRD_SKILL_FILE="$skill_path"
264
- break
265
- fi
266
- done
267
- # Also check ralph's bundled skills directory
268
- if [[ -z "$PRD_SKILL_FILE" ]]; then
269
- ralph_skills_dir="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd)/skills/prd/SKILL.md"
270
- if [[ -f "$ralph_skills_dir" ]]; then
271
- PRD_SKILL_FILE="$ralph_skills_dir"
272
- fi
273
- fi
274
- {
275
- echo "You are an autonomous coding agent."
276
- echo ""
277
- # Include the PRD skill instructions if available
278
- if [[ -n "$PRD_SKILL_FILE" && -f "$PRD_SKILL_FILE" ]]; then
279
- cat "$PRD_SKILL_FILE"
280
- echo ""
281
- echo "---"
282
- echo ""
283
- fi
284
- echo "# Output Requirements"
285
- echo ""
286
- if [[ "$PRD_PATH" == *.json ]]; then
287
- echo "Save the PRD JSON to: $PRD_PATH"
288
- else
289
- echo "Save the PRD as JSON in directory: $PRD_PATH"
290
- echo "Filename rules: prd-<short-slug>.json using 1-3 meaningful words."
291
- echo "Examples: prd-workout-tracker.json, prd-usage-billing.json"
292
- fi
293
- echo ""
294
- echo "Do NOT implement anything. Only generate the JSON PRD file."
295
- echo ""
296
- echo "After saving the PRD, end your response with:"
297
- echo "PRD JSON saved to <path>. Close this chat and run \`ralph build\`."
298
- echo ""
299
- echo "---"
300
- echo ""
301
- echo "# User Request"
302
- echo ""
303
- cat "$PRD_REQUEST_PATH"
304
- } > "$PRD_PROMPT_FILE"
305
-
306
- # Use PRD_AGENT_CMD for the agent call (always file-based for PRD mode)
307
- AGENT_CMD="$PRD_AGENT_CMD"
308
- run_agent "$PRD_PROMPT_FILE"
309
- exit 0
310
- fi
311
-
312
- if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
313
- require_agent
314
- fi
315
-
316
- if [ ! -f "$PROMPT_FILE" ]; then
317
- echo "Prompt not found: $PROMPT_FILE"
318
- exit 1
319
- fi
320
-
321
- if [ "$MODE" != "prd" ] && [ ! -f "$PRD_PATH" ]; then
322
- echo "PRD not found: $PRD_PATH"
323
- exit 1
324
- fi
325
-
326
- mkdir -p "$(dirname "$PROGRESS_PATH")" "$TMP_DIR" "$RUNS_DIR"
327
-
328
- if [ ! -f "$PROGRESS_PATH" ]; then
329
- {
330
- echo "# Progress Log"
331
- echo "Started: $(date)"
332
- echo ""
333
- echo "## Codebase Patterns"
334
- echo "- (add reusable patterns here)"
335
- echo ""
336
- echo "---"
337
- } > "$PROGRESS_PATH"
338
- fi
339
-
340
- if [ ! -f "$GUARDRAILS_PATH" ]; then
341
- {
342
- echo "# Guardrails (Signs)"
343
- echo ""
344
- echo "> Lessons learned from failures. Read before acting."
345
- echo ""
346
- echo "## Core Signs"
347
- echo ""
348
- echo "### Sign: Read Before Writing"
349
- echo "- **Trigger**: Before modifying any file"
350
- echo "- **Instruction**: Read the file first"
351
- echo "- **Added after**: Core principle"
352
- echo ""
353
- echo "### Sign: Test Before Commit"
354
- echo "- **Trigger**: Before committing changes"
355
- echo "- **Instruction**: Run required tests and verify outputs"
356
- echo "- **Added after**: Core principle"
357
- echo ""
358
- echo "---"
359
- echo ""
360
- echo "## Learned Signs"
361
- echo ""
362
- } > "$GUARDRAILS_PATH"
363
- fi
364
-
365
- if [ ! -f "$ERRORS_LOG_PATH" ]; then
366
- {
367
- echo "# Error Log"
368
- echo ""
369
- echo "> Failures and repeated issues. Use this to add guardrails."
370
- echo ""
371
- } > "$ERRORS_LOG_PATH"
372
- fi
373
-
374
- if [ ! -f "$ACTIVITY_LOG_PATH" ]; then
375
- {
376
- echo "# Activity Log"
377
- echo ""
378
- echo "## Run Summary"
379
- echo ""
380
- echo "## Events"
381
- echo ""
382
- } > "$ACTIVITY_LOG_PATH"
383
- fi
384
-
385
- RUN_TAG="$(date +%Y%m%d-%H%M%S)-$$"
386
-
387
- render_prompt() {
388
- local src="$1"
389
- local dst="$2"
390
- local story_meta="$3"
391
- local story_block="$4"
392
- local run_id="$5"
393
- local iter="$6"
394
- local run_log="$7"
395
- local run_meta="$8"
396
- 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'
397
- import sys
398
- from pathlib import Path
399
-
400
- src = Path(sys.argv[1]).read_text()
401
- prd, agents, progress, root = sys.argv[3:7]
402
- guardrails = sys.argv[7]
403
- errors_log = sys.argv[8]
404
- activity_log = sys.argv[9]
405
- guardrails_ref = sys.argv[10]
406
- context_ref = sys.argv[11]
407
- activity_cmd = sys.argv[12]
408
- no_commit = sys.argv[13]
409
- meta_path = sys.argv[14] if len(sys.argv) > 14 else ""
410
- block_path = sys.argv[15] if len(sys.argv) > 15 else ""
411
- run_id = sys.argv[16] if len(sys.argv) > 16 else ""
412
- iteration = sys.argv[17] if len(sys.argv) > 17 else ""
413
- run_log = sys.argv[18] if len(sys.argv) > 18 else ""
414
- run_meta = sys.argv[19] if len(sys.argv) > 19 else ""
415
- repl = {
416
- "PRD_PATH": prd,
417
- "AGENTS_PATH": agents,
418
- "PROGRESS_PATH": progress,
419
- "REPO_ROOT": root,
420
- "GUARDRAILS_PATH": guardrails,
421
- "ERRORS_LOG_PATH": errors_log,
422
- "ACTIVITY_LOG_PATH": activity_log,
423
- "GUARDRAILS_REF": guardrails_ref,
424
- "CONTEXT_REF": context_ref,
425
- "ACTIVITY_CMD": activity_cmd,
426
- "NO_COMMIT": no_commit,
427
- "RUN_ID": run_id,
428
- "ITERATION": iteration,
429
- "RUN_LOG_PATH": run_log,
430
- "RUN_META_PATH": run_meta,
431
- }
432
- story = {"id": "", "title": "", "block": ""}
433
- quality_gates = []
434
- if meta_path:
435
- try:
436
- import json
437
- meta = json.loads(Path(meta_path).read_text())
438
- story["id"] = meta.get("id", "") or ""
439
- story["title"] = meta.get("title", "") or ""
440
- quality_gates = meta.get("quality_gates", []) or []
441
- except Exception:
442
- pass
443
- if block_path and Path(block_path).exists():
444
- story["block"] = Path(block_path).read_text()
445
- repl["STORY_ID"] = story["id"]
446
- repl["STORY_TITLE"] = story["title"]
447
- repl["STORY_BLOCK"] = story["block"]
448
- if quality_gates:
449
- repl["QUALITY_GATES"] = "\n".join([f"- {g}" for g in quality_gates])
450
- else:
451
- repl["QUALITY_GATES"] = "- (none)"
452
- for k, v in repl.items():
453
- src = src.replace("{{" + k + "}}", v)
454
- Path(sys.argv[2]).write_text(src)
455
- PY
456
- }
457
-
458
- select_story() {
459
- local meta_out="$1"
460
- local block_out="$2"
461
- python3 - "$PRD_PATH" "$meta_out" "$block_out" "$STALE_SECONDS" <<'PY'
462
- import json
463
- import os
464
- import sys
465
- from pathlib import Path
466
- from datetime import datetime, timezone
467
- try:
468
- import fcntl
469
- except Exception:
470
- fcntl = None
471
-
472
- prd_path = Path(sys.argv[1])
473
- meta_out = Path(sys.argv[2])
474
- block_out = Path(sys.argv[3])
475
- stale_seconds = 0
476
- if len(sys.argv) > 4:
477
- try:
478
- stale_seconds = int(sys.argv[4])
479
- except Exception:
480
- stale_seconds = 0
481
-
482
- if not prd_path.exists():
483
- meta_out.write_text(json.dumps({"ok": False, "error": "PRD not found"}, indent=2) + "\n")
484
- block_out.write_text("")
485
- sys.exit(0)
486
-
487
- def normalize_status(value):
488
- if value is None:
489
- return "open"
490
- return str(value).strip().lower()
491
-
492
- def parse_ts(value):
493
- if not value:
494
- return None
495
- text = str(value).strip()
496
- if text.endswith("Z"):
497
- text = text[:-1] + "+00:00"
498
- try:
499
- return datetime.fromisoformat(text)
500
- except Exception:
501
- return None
502
-
503
- def now_iso():
504
- return datetime.now(timezone.utc).isoformat()
505
-
506
- with prd_path.open("r+", encoding="utf-8") as fh:
507
- if fcntl is not None:
508
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
509
- try:
510
- try:
511
- data = json.load(fh)
512
- except Exception as exc:
513
- meta_out.write_text(json.dumps({"ok": False, "error": f"Invalid PRD JSON: {exc}"}, indent=2) + "\n")
514
- block_out.write_text("")
515
- sys.exit(0)
516
-
517
- stories = data.get("stories") if isinstance(data, dict) else None
518
- if not isinstance(stories, list) or not stories:
519
- meta_out.write_text(json.dumps({"ok": False, "error": "No stories found in PRD"}, indent=2) + "\n")
520
- block_out.write_text("")
521
- sys.exit(0)
522
-
523
- story_index = {s.get("id"): s for s in stories if isinstance(s, dict)}
524
-
525
- def is_done(story_id: str) -> bool:
526
- target = story_index.get(story_id)
527
- if not isinstance(target, dict):
528
- return False
529
- return normalize_status(target.get("status")) == "done"
530
-
531
- if stale_seconds > 0:
532
- now = datetime.now(timezone.utc)
533
- for story in stories:
534
- if not isinstance(story, dict):
535
- continue
536
- if normalize_status(story.get("status")) != "in_progress":
537
- continue
538
- started = parse_ts(story.get("startedAt"))
539
- if started is None or (now - started).total_seconds() > stale_seconds:
540
- story["status"] = "open"
541
- story["startedAt"] = None
542
- story["completedAt"] = None
543
- story["updatedAt"] = now_iso()
544
-
545
- candidate = None
546
- for story in stories:
547
- if not isinstance(story, dict):
548
- continue
549
- if normalize_status(story.get("status")) != "open":
550
- continue
551
- deps = story.get("dependsOn") or []
552
- if not isinstance(deps, list):
553
- deps = []
554
- if all(is_done(dep) for dep in deps):
555
- candidate = story
556
- break
557
-
558
- remaining = sum(
559
- 1 for story in stories
560
- if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
561
- )
562
-
563
- meta = {
564
- "ok": True,
565
- "total": len(stories),
566
- "remaining": remaining,
567
- "quality_gates": data.get("qualityGates", []) or [],
568
- }
569
-
570
- if candidate:
571
- candidate["status"] = "in_progress"
572
- if not candidate.get("startedAt"):
573
- candidate["startedAt"] = now_iso()
574
- candidate["completedAt"] = None
575
- candidate["updatedAt"] = now_iso()
576
- meta.update({
577
- "id": candidate.get("id", ""),
578
- "title": candidate.get("title", ""),
579
- })
580
-
581
- depends = candidate.get("dependsOn") or []
582
- if not isinstance(depends, list):
583
- depends = []
584
- acceptance = candidate.get("acceptanceCriteria") or []
585
- if not isinstance(acceptance, list):
586
- acceptance = []
587
-
588
- description = candidate.get("description") or ""
589
- block_lines = []
590
- block_lines.append(f"### {candidate.get('id', '')}: {candidate.get('title', '')}")
591
- block_lines.append(f"Status: {candidate.get('status', 'open')}")
592
- block_lines.append(
593
- f"Depends on: {', '.join(depends) if depends else 'None'}"
594
- )
595
- block_lines.append("")
596
- block_lines.append("Description:")
597
- block_lines.append(description if description else "(none)")
598
- block_lines.append("")
599
- block_lines.append("Acceptance Criteria:")
600
- if acceptance:
601
- block_lines.extend([f"- [ ] {item}" for item in acceptance])
602
- else:
603
- block_lines.append("- (none)")
604
- block_out.write_text("\n".join(block_lines).rstrip() + "\n")
605
- else:
606
- block_out.write_text("")
607
-
608
- fh.seek(0)
609
- fh.truncate()
610
- json.dump(data, fh, indent=2)
611
- fh.write("\n")
612
- fh.flush()
613
- os.fsync(fh.fileno())
614
- finally:
615
- if fcntl is not None:
616
- fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
617
-
618
- meta_out.write_text(json.dumps(meta, indent=2) + "\n")
619
- PY
620
- }
621
-
622
- remaining_stories() {
623
- local meta_file="$1"
624
- python3 - "$meta_file" <<'PY'
625
- import json
626
- import sys
627
- from pathlib import Path
628
-
629
- data = json.loads(Path(sys.argv[1]).read_text())
630
- print(data.get("remaining", "unknown"))
631
- PY
632
- }
633
-
634
- remaining_from_prd() {
635
- python3 - "$PRD_PATH" <<'PY'
636
- import json
637
- import sys
638
- from pathlib import Path
639
-
640
- prd_path = Path(sys.argv[1])
641
- if not prd_path.exists():
642
- print("unknown")
643
- sys.exit(0)
644
-
645
- try:
646
- data = json.loads(prd_path.read_text())
647
- except Exception:
648
- print("unknown")
649
- sys.exit(0)
650
-
651
- stories = data.get("stories") if isinstance(data, dict) else None
652
- if not isinstance(stories, list):
653
- print("unknown")
654
- sys.exit(0)
655
-
656
- def normalize_status(value):
657
- if value is None:
658
- return "open"
659
- return str(value).strip().lower()
660
-
661
- remaining = sum(
662
- 1 for story in stories
663
- if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
664
- )
665
- print(remaining)
666
- PY
667
- }
668
-
669
- story_field() {
670
- local meta_file="$1"
671
- local field="$2"
672
- python3 - "$meta_file" "$field" <<'PY'
673
- import json
674
- import sys
675
- from pathlib import Path
676
-
677
- data = json.loads(Path(sys.argv[1]).read_text())
678
- field = sys.argv[2]
679
- print(data.get(field, ""))
680
- PY
681
- }
682
-
683
- update_story_status() {
684
- local story_id="$1"
685
- local new_status="$2"
686
- python3 - "$PRD_PATH" "$story_id" "$new_status" <<'PY'
687
- import json
688
- import os
689
- import sys
690
- from pathlib import Path
691
- from datetime import datetime, timezone
692
- try:
693
- import fcntl
694
- except Exception:
695
- fcntl = None
696
-
697
- prd_path = Path(sys.argv[1])
698
- story_id = sys.argv[2]
699
- new_status = sys.argv[3]
700
-
701
- if not story_id:
702
- sys.exit(0)
703
-
704
- if not prd_path.exists():
705
- sys.exit(0)
706
-
707
- def now_iso():
708
- return datetime.now(timezone.utc).isoformat()
709
-
710
- with prd_path.open("r+", encoding="utf-8") as fh:
711
- if fcntl is not None:
712
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
713
- try:
714
- data = json.load(fh)
715
- stories = data.get("stories") if isinstance(data, dict) else None
716
- if not isinstance(stories, list):
717
- sys.exit(0)
718
- for story in stories:
719
- if isinstance(story, dict) and story.get("id") == story_id:
720
- story["status"] = new_status
721
- story["updatedAt"] = now_iso()
722
- if new_status == "in_progress":
723
- if not story.get("startedAt"):
724
- story["startedAt"] = now_iso()
725
- story["completedAt"] = None
726
- elif new_status == "done":
727
- story["completedAt"] = now_iso()
728
- if not story.get("startedAt"):
729
- story["startedAt"] = now_iso()
730
- elif new_status == "open":
731
- story["startedAt"] = None
732
- story["completedAt"] = None
733
- break
734
- fh.seek(0)
735
- fh.truncate()
736
- json.dump(data, fh, indent=2)
737
- fh.write("\n")
738
- fh.flush()
739
- os.fsync(fh.fileno())
740
- finally:
741
- if fcntl is not None:
742
- fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
743
- PY
744
- }
745
-
746
- log_activity() {
747
- local message="$1"
748
- local timestamp
749
- timestamp=$(date '+%Y-%m-%d %H:%M:%S')
750
- echo "[$timestamp] $message" >> "$ACTIVITY_LOG_PATH"
751
- }
752
-
753
- log_error() {
754
- local message="$1"
755
- local timestamp
756
- timestamp=$(date '+%Y-%m-%d %H:%M:%S')
757
- echo "[$timestamp] $message" >> "$ERRORS_LOG_PATH"
758
- }
759
-
760
- append_run_summary() {
761
- local line="$1"
762
- python3 - "$ACTIVITY_LOG_PATH" "$line" <<'PY'
763
- import sys
764
- from pathlib import Path
765
-
766
- path = Path(sys.argv[1])
767
- line = sys.argv[2]
768
- text = path.read_text().splitlines()
769
- out = []
770
- inserted = False
771
- for l in text:
772
- out.append(l)
773
- if not inserted and l.strip() == "## Run Summary":
774
- out.append(f"- {line}")
775
- inserted = True
776
- if not inserted:
777
- out = [
778
- "# Activity Log",
779
- "",
780
- "## Run Summary",
781
- f"- {line}",
782
- "",
783
- "## Events",
784
- "",
785
- ] + text
786
- Path(path).write_text("\n".join(out).rstrip() + "\n")
787
- PY
788
- }
789
-
790
- write_run_meta() {
791
- local path="$1"
792
- local mode="$2"
793
- local iter="$3"
794
- local run_id="$4"
795
- local story_id="$5"
796
- local story_title="$6"
797
- local started="$7"
798
- local ended="$8"
799
- local duration="$9"
800
- local status="${10}"
801
- local log_file="${11}"
802
- local head_before="${12}"
803
- local head_after="${13}"
804
- local commit_list="${14}"
805
- local changed_files="${15}"
806
- local dirty_files="${16}"
807
- {
808
- echo "# Ralph Run Summary"
809
- echo ""
810
- echo "- Run ID: $run_id"
811
- echo "- Iteration: $iter"
812
- echo "- Mode: $mode"
813
- if [ -n "$story_id" ]; then
814
- echo "- Story: $story_id: $story_title"
815
- fi
816
- echo "- Started: $started"
817
- echo "- Ended: $ended"
818
- echo "- Duration: ${duration}s"
819
- echo "- Status: $status"
820
- echo "- Log: $log_file"
821
- echo ""
822
- echo "## Git"
823
- echo "- Head (before): ${head_before:-unknown}"
824
- echo "- Head (after): ${head_after:-unknown}"
825
- echo ""
826
- echo "### Commits"
827
- if [ -n "$commit_list" ]; then
828
- echo "$commit_list"
829
- else
830
- echo "- (none)"
831
- fi
832
- echo ""
833
- echo "### Changed Files (commits)"
834
- if [ -n "$changed_files" ]; then
835
- echo "$changed_files"
836
- else
837
- echo "- (none)"
838
- fi
839
- echo ""
840
- echo "### Uncommitted Changes"
841
- if [ -n "$dirty_files" ]; then
842
- echo "$dirty_files"
843
- else
844
- echo "- (clean)"
845
- fi
846
- echo ""
847
- } > "$path"
848
- }
849
-
850
- git_head() {
851
- if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
852
- git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true
853
- else
854
- echo ""
855
- fi
856
- }
857
-
858
- git_commit_list() {
859
- local before="$1"
860
- local after="$2"
861
- if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
862
- git -C "$ROOT_DIR" log --oneline "$before..$after" | sed 's/^/- /'
863
- else
864
- echo ""
865
- fi
866
- }
867
-
868
- git_changed_files() {
869
- local before="$1"
870
- local after="$2"
871
- if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
872
- git -C "$ROOT_DIR" diff --name-only "$before" "$after" | sed 's/^/- /'
873
- else
874
- echo ""
875
- fi
876
- }
877
-
878
- git_dirty_files() {
879
- if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
880
- git -C "$ROOT_DIR" status --porcelain | awk '{print "- " $2}'
881
- else
882
- echo ""
883
- fi
884
- }
885
-
886
- echo "Ralph mode: $MODE"
887
- echo "Max iterations: $MAX_ITERATIONS"
888
- echo "PRD: $PRD_PATH"
889
- HAS_ERROR="false"
890
-
891
- for i in $(seq 1 "$MAX_ITERATIONS"); do
892
- echo ""
893
- echo "═══════════════════════════════════════════════════════"
894
- echo " Ralph Iteration $i of $MAX_ITERATIONS"
895
- echo "═══════════════════════════════════════════════════════"
896
-
897
- STORY_META=""
898
- STORY_BLOCK=""
899
- ITER_START=$(date +%s)
900
- ITER_START_FMT=$(date '+%Y-%m-%d %H:%M:%S')
901
- if [ "$MODE" = "build" ]; then
902
- STORY_META="$TMP_DIR/story-$RUN_TAG-$i.json"
903
- STORY_BLOCK="$TMP_DIR/story-$RUN_TAG-$i.md"
904
- select_story "$STORY_META" "$STORY_BLOCK"
905
- REMAINING="$(remaining_stories "$STORY_META")"
906
- if [ "$REMAINING" = "unknown" ]; then
907
- echo "Could not parse stories from PRD: $PRD_PATH"
908
- exit 1
909
- fi
910
- if [ "$REMAINING" = "0" ]; then
911
- echo "No remaining stories."
912
- exit 0
913
- fi
914
- STORY_ID="$(story_field "$STORY_META" "id")"
915
- STORY_TITLE="$(story_field "$STORY_META" "title")"
916
- if [ -z "$STORY_ID" ]; then
917
- echo "No actionable open stories (all blocked or in progress). Remaining: $REMAINING"
918
- exit 0
919
- fi
920
- fi
921
-
922
- HEAD_BEFORE="$(git_head)"
923
- PROMPT_RENDERED="$TMP_DIR/prompt-$RUN_TAG-$i.md"
924
- LOG_FILE="$RUNS_DIR/run-$RUN_TAG-iter-$i.log"
925
- RUN_META="$RUNS_DIR/run-$RUN_TAG-iter-$i.md"
926
- render_prompt "$PROMPT_FILE" "$PROMPT_RENDERED" "$STORY_META" "$STORY_BLOCK" "$RUN_TAG" "$i" "$LOG_FILE" "$RUN_META"
927
-
928
- if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
929
- log_activity "ITERATION $i start (mode=$MODE story=$STORY_ID)"
930
- else
931
- log_activity "ITERATION $i start (mode=$MODE)"
932
- fi
933
- set +e
934
- if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
935
- echo "[RALPH_DRY_RUN] Skipping agent execution." | tee "$LOG_FILE"
936
- CMD_STATUS=0
937
- else
938
- run_agent "$PROMPT_RENDERED" 2>&1 | tee "$LOG_FILE"
939
- CMD_STATUS=$?
940
- fi
941
- set -e
942
- if [ "$CMD_STATUS" -eq 130 ] || [ "$CMD_STATUS" -eq 143 ]; then
943
- echo "Interrupted."
944
- exit "$CMD_STATUS"
945
- fi
946
- ITER_END=$(date +%s)
947
- ITER_END_FMT=$(date '+%Y-%m-%d %H:%M:%S')
948
- ITER_DURATION=$((ITER_END - ITER_START))
949
- HEAD_AFTER="$(git_head)"
950
- log_activity "ITERATION $i end (duration=${ITER_DURATION}s)"
951
- if [ "$CMD_STATUS" -ne 0 ]; then
952
- log_error "ITERATION $i command failed (status=$CMD_STATUS)"
953
- HAS_ERROR="true"
954
- fi
955
- COMMIT_LIST="$(git_commit_list "$HEAD_BEFORE" "$HEAD_AFTER")"
956
- CHANGED_FILES="$(git_changed_files "$HEAD_BEFORE" "$HEAD_AFTER")"
957
- DIRTY_FILES="$(git_dirty_files)"
958
- STATUS_LABEL="success"
959
- if [ "$CMD_STATUS" -ne 0 ]; then
960
- STATUS_LABEL="error"
961
- fi
962
- if [ "$MODE" = "build" ] && [ "$NO_COMMIT" = "false" ] && [ -n "$DIRTY_FILES" ]; then
963
- log_error "ITERATION $i left uncommitted changes; review run summary at $RUN_META"
964
- fi
965
- 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"
966
- if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
967
- 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"
968
- else
969
- append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | duration=${ITER_DURATION}s | status=$STATUS_LABEL"
970
- fi
971
-
972
- if [ "$MODE" = "build" ]; then
973
- if [ "$CMD_STATUS" -ne 0 ]; then
974
- log_error "ITERATION $i exited non-zero; review $LOG_FILE"
975
- update_story_status "$STORY_ID" "open"
976
- echo "Iteration failed; story reset to open."
977
- elif grep -q "<promise>COMPLETE</promise>" "$LOG_FILE"; then
978
- update_story_status "$STORY_ID" "done"
979
- echo "Completion signal received; story marked done."
980
- else
981
- update_story_status "$STORY_ID" "open"
982
- echo "No completion signal; story reset to open."
983
- fi
984
- REMAINING="$(remaining_from_prd)"
985
- echo "Iteration $i complete. Remaining stories: $REMAINING"
986
- if [ "$REMAINING" = "0" ]; then
987
- echo "No remaining stories."
988
- exit 0
989
- fi
990
- else
991
- echo "Iteration $i complete."
992
- fi
993
- sleep 2
994
-
995
- done
996
-
997
- echo "Reached max iterations ($MAX_ITERATIONS)."
998
- if [ "$HAS_ERROR" = "true" ]; then
999
- exit 1
1000
- fi
1001
- 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
+ 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