@canivel/ralph 0.2.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 (40) hide show
  1. package/.agents/ralph/PROMPT_build.md +126 -0
  2. package/.agents/ralph/agents.sh +15 -0
  3. package/.agents/ralph/config.sh +25 -0
  4. package/.agents/ralph/log-activity.sh +15 -0
  5. package/.agents/ralph/loop.sh +1001 -0
  6. package/.agents/ralph/references/CONTEXT_ENGINEERING.md +126 -0
  7. package/.agents/ralph/references/GUARDRAILS.md +174 -0
  8. package/AGENTS.md +20 -0
  9. package/README.md +266 -0
  10. package/bin/ralph +766 -0
  11. package/diagram.svg +55 -0
  12. package/examples/commands.md +46 -0
  13. package/package.json +39 -0
  14. package/ralph.webp +0 -0
  15. package/skills/commit/SKILL.md +219 -0
  16. package/skills/commit/references/commit_examples.md +292 -0
  17. package/skills/dev-browser/SKILL.md +211 -0
  18. package/skills/dev-browser/bun.lock +443 -0
  19. package/skills/dev-browser/package-lock.json +2988 -0
  20. package/skills/dev-browser/package.json +31 -0
  21. package/skills/dev-browser/references/scraping.md +155 -0
  22. package/skills/dev-browser/scripts/start-relay.ts +32 -0
  23. package/skills/dev-browser/scripts/start-server.ts +117 -0
  24. package/skills/dev-browser/server.sh +24 -0
  25. package/skills/dev-browser/src/client.ts +474 -0
  26. package/skills/dev-browser/src/index.ts +287 -0
  27. package/skills/dev-browser/src/relay.ts +731 -0
  28. package/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +223 -0
  29. package/skills/dev-browser/src/snapshot/browser-script.ts +877 -0
  30. package/skills/dev-browser/src/snapshot/index.ts +14 -0
  31. package/skills/dev-browser/src/snapshot/inject.ts +13 -0
  32. package/skills/dev-browser/src/types.ts +34 -0
  33. package/skills/dev-browser/tsconfig.json +36 -0
  34. package/skills/dev-browser/vitest.config.ts +12 -0
  35. package/skills/prd/SKILL.md +235 -0
  36. package/tests/agent-loops.mjs +79 -0
  37. package/tests/agent-ping.mjs +39 -0
  38. package/tests/audit.md +56 -0
  39. package/tests/cli-smoke.mjs +47 -0
  40. package/tests/real-agents.mjs +127 -0
@@ -0,0 +1,1001 @@
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