ralph.rb 1.2.435535439 → 2.0.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/gem-push.yml +2 -2
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +53 -0
  5. data/lib/ralph/cli.rb +67 -186
  6. data/lib/ralph/display.rb +105 -0
  7. data/lib/ralph/events.rb +117 -0
  8. data/lib/ralph/loop.rb +113 -170
  9. data/lib/ralph/metrics.rb +88 -0
  10. data/lib/ralph/opencode.rb +66 -0
  11. data/lib/ralph/version.rb +1 -1
  12. data/lib/ralph.rb +0 -3
  13. data/plans/00-complete-implementation.md +120 -0
  14. data/plans/01-cli-implementation.md +53 -0
  15. data/plans/02-loop-implementation.md +78 -0
  16. data/plans/03-agents-implementation.md +76 -0
  17. data/plans/04-metrics-implementation.md +98 -0
  18. data/plans/README.md +63 -0
  19. data/specs/README.md +4 -15
  20. data/specs/__templates__/API_TEMPLATE.md +0 -0
  21. data/specs/__templates__/AUTOMATION_ACTION_TEMPLATE.md +0 -0
  22. data/specs/__templates__/AUTOMATION_TRIGGER_TEMPLATE.md +0 -0
  23. data/specs/__templates__/CONTROLLER_TEMPLATE.md +32 -0
  24. data/specs/__templates__/INTEGRATION_TEMPLATE.md +0 -0
  25. data/specs/__templates__/MODEL_TEMPLATE.md +0 -0
  26. data/specs/agents.md +426 -120
  27. data/specs/cli.md +11 -218
  28. data/specs/lib/todo_item.rb +144 -0
  29. data/specs/log +15 -0
  30. data/specs/loop.md +42 -0
  31. data/specs/metrics.md +51 -0
  32. metadata +23 -39
  33. data/lib/ralph/agents/base.rb +0 -132
  34. data/lib/ralph/agents/claude_code.rb +0 -24
  35. data/lib/ralph/agents/codex.rb +0 -25
  36. data/lib/ralph/agents/open_code.rb +0 -30
  37. data/lib/ralph/agents.rb +0 -24
  38. data/lib/ralph/config.rb +0 -40
  39. data/lib/ralph/git/file_snapshot.rb +0 -60
  40. data/lib/ralph/helpers.rb +0 -76
  41. data/lib/ralph/iteration.rb +0 -220
  42. data/lib/ralph/output/active_loop_error.rb +0 -13
  43. data/lib/ralph/output/banner.rb +0 -29
  44. data/lib/ralph/output/completion_deferred.rb +0 -12
  45. data/lib/ralph/output/completion_detected.rb +0 -17
  46. data/lib/ralph/output/config_summary.rb +0 -31
  47. data/lib/ralph/output/context_consumed.rb +0 -11
  48. data/lib/ralph/output/iteration.rb +0 -45
  49. data/lib/ralph/output/max_iterations_reached.rb +0 -16
  50. data/lib/ralph/output/no_plugin_warning.rb +0 -14
  51. data/lib/ralph/output/nonzero_exit_warning.rb +0 -11
  52. data/lib/ralph/output/plugin_error.rb +0 -12
  53. data/lib/ralph/output/status.rb +0 -176
  54. data/lib/ralph/output/struggle_warning.rb +0 -18
  55. data/lib/ralph/output/task_completion.rb +0 -12
  56. data/lib/ralph/output/tasks_file_created.rb +0 -11
  57. data/lib/ralph/prompt_template.rb +0 -183
  58. data/lib/ralph/storage/context.rb +0 -58
  59. data/lib/ralph/storage/history.rb +0 -117
  60. data/lib/ralph/storage/state.rb +0 -178
  61. data/lib/ralph/storage/tasks.rb +0 -244
  62. data/lib/ralph/threads/heartbeat.rb +0 -44
  63. data/lib/ralph/threads/stream_reader.rb +0 -50
  64. data/original/bin/ralph.js +0 -13
  65. data/original/ralph.ts +0 -1706
  66. data/specs/iteration.md +0 -173
  67. data/specs/output.md +0 -104
  68. data/specs/storage/local-data-structure.md +0 -246
  69. data/specs/tasks.md +0 -295
data/original/ralph.ts DELETED
@@ -1,1706 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Ralph Wiggum Loop for AI agents
4
- *
5
- * Implementation of the Ralph Wiggum technique - continuous self-referential
6
- * AI loops for iterative development. Based on ghuntley.com/ralph/
7
- */
8
-
9
- import { $ } from "bun";
10
- import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
11
- import { join } from "path";
12
-
13
- const VERSION = "1.1.0";
14
-
15
- // Context file path for mid-loop injection
16
- const stateDir = join(process.cwd(), ".ralph");
17
- const statePath = join(stateDir, "ralph-loop.state.json");
18
- const contextPath = join(stateDir, "ralph-context.md");
19
- const historyPath = join(stateDir, "ralph-history.json");
20
- const tasksPath = join(stateDir, "ralph-tasks.md");
21
-
22
- type AgentType = "opencode" | "claude-code" | "codex";
23
-
24
- type AgentEnvOptions = { filterPlugins?: boolean; allowAllPermissions?: boolean };
25
-
26
- type AgentBuildArgsOptions = { allowAllPermissions?: boolean };
27
-
28
- interface AgentConfig {
29
- type: AgentType;
30
- command: string;
31
- buildArgs: (prompt: string, model: string, options?: AgentBuildArgsOptions) => string[];
32
- buildEnv: (options: AgentEnvOptions) => Record<string, string>;
33
- parseToolOutput: (line: string) => string | null;
34
- configName: string;
35
- }
36
-
37
- const AGENTS: Record<AgentType, AgentConfig> = {
38
- opencode: {
39
- type: "opencode",
40
- command: "opencode",
41
- buildArgs: (promptText, modelName, _options) => {
42
- const cmdArgs = ["run"];
43
- if (modelName) {
44
- cmdArgs.push("-m", modelName);
45
- }
46
- cmdArgs.push(promptText);
47
- return cmdArgs;
48
- },
49
- buildEnv: options => {
50
- const env = { ...process.env };
51
- if (options.filterPlugins || options.allowAllPermissions) {
52
- env.OPENCODE_CONFIG = ensureRalphConfig({
53
- filterPlugins: options.filterPlugins,
54
- allowAllPermissions: options.allowAllPermissions,
55
- });
56
- }
57
- return env;
58
- },
59
- parseToolOutput: line => {
60
- const match = stripAnsi(line).match(/^\|\s{2}([A-Za-z0-9_-]+)/);
61
- return match ? match[1] : null;
62
- },
63
- configName: "OpenCode",
64
- },
65
- "claude-code": {
66
- type: "claude-code",
67
- command: "claude",
68
- buildArgs: (promptText, modelName, options) => {
69
- const cmdArgs = ["-p", promptText];
70
- if (modelName) {
71
- cmdArgs.push("--model", modelName);
72
- }
73
- if (options?.allowAllPermissions) {
74
- cmdArgs.push("--dangerously-skip-permissions");
75
- }
76
- return cmdArgs;
77
- },
78
- buildEnv: () => ({ ...process.env }),
79
- parseToolOutput: line => {
80
- const match = stripAnsi(line).match(/(?:Using|Called|Tool:)\s+([A-Za-z0-9_-]+)/i);
81
- return match ? match[1] : null;
82
- },
83
- configName: "Claude Code",
84
- },
85
- codex: {
86
- type: "codex",
87
- command: "codex",
88
- buildArgs: (promptText, modelName, options) => {
89
- const cmdArgs = ["exec"];
90
- if (modelName) {
91
- cmdArgs.push("--model", modelName);
92
- }
93
- if (options?.allowAllPermissions) {
94
- cmdArgs.push("--full-auto");
95
- }
96
- cmdArgs.push(promptText);
97
- return cmdArgs;
98
- },
99
- buildEnv: () => ({ ...process.env }),
100
- parseToolOutput: line => {
101
- const match = stripAnsi(line).match(/(?:Tool:|Using|Calling|Running)\s+([A-Za-z0-9_-]+)/i);
102
- return match ? match[1] : null;
103
- },
104
- configName: "Codex",
105
- },
106
- };
107
- // Parse arguments
108
- const args = process.argv.slice(2);
109
-
110
- if (args.includes("--help") || args.includes("-h")) {
111
- console.log(`
112
- Ralph Wiggum Loop - Iterative AI development with AI agents
113
-
114
- Usage:
115
- ralph "<prompt>" [options]
116
- ralph --prompt-file <path> [options]
117
-
118
- Arguments:
119
- prompt Task description for the AI to work on
120
-
121
- Options:
122
- --agent AGENT AI agent to use: opencode (default), claude-code, codex
123
- --min-iterations N Minimum iterations before completion allowed (default: 1)
124
- --max-iterations N Maximum iterations before stopping (default: unlimited)
125
- --completion-promise TEXT Phrase that signals completion (default: COMPLETE)
126
- --tasks, -t Enable Tasks Mode for structured task tracking
127
- --task-promise TEXT Phrase that signals task completion (default: READY_FOR_NEXT_TASK)
128
- --model MODEL Model to use (agent-specific, e.g., anthropic/claude-sonnet)
129
- --prompt-file, --file, -f Read prompt content from a file
130
- --no-stream Buffer agent output and print at the end
131
- --verbose-tools Print every tool line (disable compact tool summary)
132
- --no-plugins Disable non-auth OpenCode plugins for this run (opencode only)
133
- --no-commit Don't auto-commit after each iteration
134
- --allow-all Auto-approve all tool permissions (default: on)
135
- --no-allow-all Require interactive permission prompts
136
- --version, -v Show version
137
- --help, -h Show this help
138
-
139
- Commands:
140
- --status Show current Ralph loop status and history
141
- --status --tasks Show status including current task list
142
- --add-context TEXT Add context for the next iteration (or edit .ralph/ralph-context.md)
143
- --clear-context Clear any pending context
144
- --list-tasks Display the current task list with indices
145
- --add-task "desc" Add a new task to the list
146
- --remove-task N Remove task at index N (including subtasks)
147
-
148
- Examples:
149
- ralph "Build a REST API for todos"
150
- ralph "Fix the auth bug" --max-iterations 10
151
- ralph "Add tests" --completion-promise "ALL TESTS PASS" --model openai/gpt-5.1
152
- ralph "Fix the bug" --agent codex --model gpt-5-codex
153
- ralph --prompt-file ./prompt.md --max-iterations 5
154
- ralph --status # Check loop status
155
- ralph --add-context "Focus on the auth module first" # Add hint for next iteration
156
-
157
- How it works:
158
- 1. Sends your prompt to the selected AI agent
159
- 2. AI agent works on the task
160
- 3. Checks output for completion promise
161
- 4. If not complete, repeats with same prompt
162
- 5. AI sees its previous work in files
163
- 6. Continues until promise detected or max iterations
164
-
165
- To stop manually: Ctrl+C
166
-
167
- Learn more: https://ghuntley.com/ralph/
168
- `);
169
- process.exit(0);
170
- }
171
-
172
- if (args.includes("--version") || args.includes("-v")) {
173
- console.log(`ralph ${VERSION}`);
174
- process.exit(0);
175
- }
176
-
177
- // History tracking interface
178
- interface IterationHistory {
179
- iteration: number;
180
- startedAt: string;
181
- endedAt: string;
182
- durationMs: number;
183
- toolsUsed: Record<string, number>;
184
- filesModified: string[];
185
- exitCode: number;
186
- completionDetected: boolean;
187
- errors: string[];
188
- }
189
-
190
- interface RalphHistory {
191
- iterations: IterationHistory[];
192
- totalDurationMs: number;
193
- struggleIndicators: {
194
- repeatedErrors: Record<string, number>;
195
- noProgressIterations: number;
196
- shortIterations: number;
197
- };
198
- }
199
-
200
- // Load history
201
- function loadHistory(): RalphHistory {
202
- if (!existsSync(historyPath)) {
203
- return {
204
- iterations: [],
205
- totalDurationMs: 0,
206
- struggleIndicators: { repeatedErrors: {}, noProgressIterations: 0, shortIterations: 0 }
207
- };
208
- }
209
- try {
210
- return JSON.parse(readFileSync(historyPath, "utf-8"));
211
- } catch {
212
- return {
213
- iterations: [],
214
- totalDurationMs: 0,
215
- struggleIndicators: { repeatedErrors: {}, noProgressIterations: 0, shortIterations: 0 }
216
- };
217
- }
218
- }
219
-
220
- function saveHistory(history: RalphHistory): void {
221
- if (!existsSync(stateDir)) {
222
- mkdirSync(stateDir, { recursive: true });
223
- }
224
- writeFileSync(historyPath, JSON.stringify(history, null, 2));
225
- }
226
-
227
- function clearHistory(): void {
228
- if (existsSync(historyPath)) {
229
- try {
230
- require("fs").unlinkSync(historyPath);
231
- } catch {}
232
- }
233
- }
234
-
235
- // Status command
236
- if (args.includes("--status")) {
237
- const state = loadState();
238
- const history = loadHistory();
239
- const context = existsSync(contextPath) ? readFileSync(contextPath, "utf-8").trim() : null;
240
- // Show tasks if explicitly requested OR if active loop has tasks mode enabled
241
- const showTasks = args.includes("--tasks") || args.includes("-t") || state?.tasksMode;
242
-
243
- console.log(`
244
- ╔══════════════════════════════════════════════════════════════════╗
245
- ║ Ralph Wiggum Status ║
246
- ╚══════════════════════════════════════════════════════════════════╝
247
- `);
248
-
249
- if (state?.active) {
250
- const elapsed = Date.now() - new Date(state.startedAt).getTime();
251
- const elapsedStr = formatDurationLong(elapsed);
252
- console.log(`🔄 ACTIVE LOOP`);
253
- console.log(` Iteration: ${state.iteration}${state.maxIterations > 0 ? ` / ${state.maxIterations}` : " (unlimited)"}`);
254
- console.log(` Started: ${state.startedAt}`);
255
- console.log(` Elapsed: ${elapsedStr}`);
256
- console.log(` Promise: ${state.completionPromise}`);
257
- const agentLabel = state.agent ? (AGENTS[state.agent]?.configName ?? state.agent) : "OpenCode";
258
- console.log(` Agent: ${agentLabel}`);
259
- if (state.model) console.log(` Model: ${state.model}`);
260
- if (state.tasksMode) {
261
- console.log(` Tasks Mode: ENABLED`);
262
- console.log(` Task Promise: ${state.taskPromise}`);
263
- }
264
- console.log(` Prompt: ${state.prompt.substring(0, 60)}${state.prompt.length > 60 ? "..." : ""}`);
265
- } else {
266
- console.log(`⏹️ No active loop`);
267
- }
268
-
269
- if (context) {
270
- console.log(`\n📝 PENDING CONTEXT (will be injected next iteration):`);
271
- console.log(` ${context.split("\n").join("\n ")}`);
272
- }
273
-
274
- // Show tasks if requested
275
- if (showTasks) {
276
- if (existsSync(tasksPath)) {
277
- try {
278
- const tasksContent = readFileSync(tasksPath, "utf-8");
279
- const tasks = parseTasks(tasksContent);
280
- if (tasks.length > 0) {
281
- console.log(`\n📋 CURRENT TASKS:`);
282
- for (let i = 0; i < tasks.length; i++) {
283
- const task = tasks[i];
284
- const statusIcon = task.status === "complete" ? "✅" : task.status === "in-progress" ? "🔄" : "⏸️";
285
- console.log(` ${i + 1}. ${statusIcon} ${task.text}`);
286
-
287
- for (const subtask of task.subtasks) {
288
- const subStatusIcon = subtask.status === "complete" ? "✅" : subtask.status === "in-progress" ? "🔄" : "⏸️";
289
- console.log(` ${subStatusIcon} ${subtask.text}`);
290
- }
291
- }
292
- const complete = tasks.filter(t => t.status === "complete").length;
293
- const inProgress = tasks.filter(t => t.status === "in-progress").length;
294
- console.log(`\n Progress: ${complete}/${tasks.length} complete, ${inProgress} in progress`);
295
- } else {
296
- console.log(`\n📋 CURRENT TASKS: (no tasks found)`);
297
- }
298
- } catch {
299
- console.log(`\n📋 CURRENT TASKS: (error reading tasks)`);
300
- }
301
- } else {
302
- console.log(`\n📋 CURRENT TASKS: (no tasks file found)`);
303
- }
304
- }
305
-
306
- if (history.iterations.length > 0) {
307
- console.log(`\n📊 HISTORY (${history.iterations.length} iterations)`);
308
- console.log(` Total time: ${formatDurationLong(history.totalDurationMs)}`);
309
-
310
- // Show last 5 iterations
311
- const recent = history.iterations.slice(-5);
312
- console.log(`\n Recent iterations:`);
313
- for (const iter of recent) {
314
- const tools = Object.entries(iter.toolsUsed)
315
- .sort((a, b) => b[1] - a[1])
316
- .slice(0, 3)
317
- .map(([k, v]) => `${k}:${v}`)
318
- .join(" ");
319
- const status = iter.completionDetected ? "✅" : iter.exitCode !== 0 ? "❌" : "🔄";
320
- console.log(` ${status} #${iter.iteration}: ${formatDurationLong(iter.durationMs)} | ${tools || "no tools"}`);
321
- }
322
-
323
- // Struggle detection
324
- const struggle = history.struggleIndicators;
325
- const hasRepeatedErrors = Object.values(struggle.repeatedErrors).some(count => count >= 2);
326
- if (struggle.noProgressIterations >= 3 || struggle.shortIterations >= 3 || hasRepeatedErrors) {
327
- console.log(`\n⚠️ STRUGGLE INDICATORS:`);
328
- if (struggle.noProgressIterations >= 3) {
329
- console.log(` - No file changes in ${struggle.noProgressIterations} iterations`);
330
- }
331
- if (struggle.shortIterations >= 3) {
332
- console.log(` - ${struggle.shortIterations} very short iterations (< 30s)`);
333
- }
334
- const topErrors = Object.entries(struggle.repeatedErrors)
335
- .filter(([_, count]) => count >= 2)
336
- .sort((a, b) => b[1] - a[1])
337
- .slice(0, 3);
338
- for (const [error, count] of topErrors) {
339
- console.log(` - Same error ${count}x: "${error.substring(0, 50)}..."`);
340
- }
341
- console.log(`\n 💡 Consider using: ralph --add-context "your hint here"`);
342
- }
343
- }
344
-
345
- console.log("");
346
- process.exit(0);
347
- }
348
-
349
- // Add context command
350
- const addContextIdx = args.indexOf("--add-context");
351
- if (addContextIdx !== -1) {
352
- const contextText = args[addContextIdx + 1];
353
- if (!contextText) {
354
- console.error("Error: --add-context requires a text argument");
355
- console.error("Usage: ralph --add-context \"Your context or hint here\"");
356
- process.exit(1);
357
- }
358
-
359
- if (!existsSync(stateDir)) {
360
- mkdirSync(stateDir, { recursive: true });
361
- }
362
-
363
- // Append to existing context or create new
364
- const timestamp = new Date().toISOString();
365
- const newEntry = `\n## Context added at ${timestamp}\n${contextText}\n`;
366
-
367
- if (existsSync(contextPath)) {
368
- const existing = readFileSync(contextPath, "utf-8");
369
- writeFileSync(contextPath, existing + newEntry);
370
- } else {
371
- writeFileSync(contextPath, `# Ralph Loop Context\n${newEntry}`);
372
- }
373
-
374
- console.log(`✅ Context added for next iteration`);
375
- console.log(` File: ${contextPath}`);
376
-
377
- const state = loadState();
378
- if (state?.active) {
379
- console.log(` Will be picked up in iteration ${state.iteration + 1}`);
380
- } else {
381
- console.log(` Will be used when loop starts`);
382
- }
383
- process.exit(0);
384
- }
385
-
386
- // Clear context command
387
- if (args.includes("--clear-context")) {
388
- if (existsSync(contextPath)) {
389
- require("fs").unlinkSync(contextPath);
390
- console.log(`✅ Context cleared`);
391
- } else {
392
- console.log(`ℹ️ No pending context to clear`);
393
- }
394
- process.exit(0);
395
- }
396
-
397
- // List tasks command
398
- if (args.includes("--list-tasks")) {
399
- if (!existsSync(tasksPath)) {
400
- console.log("No tasks file found. Use --add-task to create your first task.");
401
- process.exit(0);
402
- }
403
-
404
- try {
405
- const tasksContent = readFileSync(tasksPath, "utf-8");
406
- const tasks = parseTasks(tasksContent);
407
- displayTasksWithIndices(tasks);
408
- } catch (error) {
409
- console.error("Error reading tasks file:", error);
410
- process.exit(1);
411
- }
412
- process.exit(0);
413
- }
414
-
415
- // Add task command
416
- const addTaskIdx = args.indexOf("--add-task");
417
- if (addTaskIdx !== -1) {
418
- const taskDescription = args[addTaskIdx + 1];
419
- if (!taskDescription) {
420
- console.error("Error: --add-task requires a description");
421
- console.error("Usage: ralph --add-task \"Task description\"");
422
- process.exit(1);
423
- }
424
-
425
- if (!existsSync(stateDir)) {
426
- mkdirSync(stateDir, { recursive: true });
427
- }
428
-
429
- try {
430
- let tasksContent = "";
431
- if (existsSync(tasksPath)) {
432
- tasksContent = readFileSync(tasksPath, "utf-8");
433
- } else {
434
- tasksContent = "# Ralph Tasks\n\n";
435
- }
436
-
437
- const newTaskContent = tasksContent.trimEnd() + "\n" + `- [ ] ${taskDescription}\n`;
438
- writeFileSync(tasksPath, newTaskContent);
439
- console.log(`✅ Task added: "${taskDescription}"`);
440
- } catch (error) {
441
- console.error("Error adding task:", error);
442
- process.exit(1);
443
- }
444
- process.exit(0);
445
- }
446
-
447
- // Remove task command
448
- const removeTaskIdx = args.indexOf("--remove-task");
449
- if (removeTaskIdx !== -1) {
450
- const taskIndexStr = args[removeTaskIdx + 1];
451
- if (!taskIndexStr || isNaN(parseInt(taskIndexStr))) {
452
- console.error("Error: --remove-task requires a valid number");
453
- console.error("Usage: ralph --remove-task 3");
454
- process.exit(1);
455
- }
456
-
457
- const taskIndex = parseInt(taskIndexStr);
458
-
459
- if (!existsSync(tasksPath)) {
460
- console.error("Error: No tasks file found");
461
- process.exit(1);
462
- }
463
-
464
- try {
465
- const tasksContent = readFileSync(tasksPath, "utf-8");
466
- const tasks = parseTasks(tasksContent);
467
-
468
- if (taskIndex < 1 || taskIndex > tasks.length) {
469
- console.error(`Error: Task index ${taskIndex} is out of range (1-${tasks.length})`);
470
- process.exit(1);
471
- }
472
-
473
- // Remove the task and its subtasks
474
- const lines = tasksContent.split("\n");
475
- const newLines: string[] = [];
476
- let inRemovedTask = false;
477
- let currentTaskLine = 0;
478
-
479
- for (const line of lines) {
480
- // Check if this is a top-level task (starts with "- [" at beginning of line)
481
- if (line.match(/^- \[/)) {
482
- currentTaskLine++;
483
- if (currentTaskLine === taskIndex) {
484
- inRemovedTask = true;
485
- continue; // Skip this task line
486
- } else {
487
- inRemovedTask = false;
488
- }
489
- }
490
-
491
- // Skip all indented content under the removed task (subtasks, notes, etc.)
492
- if (inRemovedTask && line.match(/^\s+/) && line.trim() !== "") {
493
- continue;
494
- }
495
-
496
- newLines.push(line);
497
- }
498
-
499
- writeFileSync(tasksPath, newLines.join("\n"));
500
- console.log(`✅ Removed task ${taskIndex} and its subtasks`);
501
- } catch (error) {
502
- console.error("Error removing task:", error);
503
- process.exit(1);
504
- }
505
- process.exit(0);
506
- }
507
-
508
- function formatDurationLong(ms: number): string {
509
- const totalSeconds = Math.max(0, Math.floor(ms / 1000));
510
- const hours = Math.floor(totalSeconds / 3600);
511
- const minutes = Math.floor((totalSeconds % 3600) / 60);
512
- const seconds = totalSeconds % 60;
513
- if (hours > 0) {
514
- return `${hours}h ${minutes}m ${seconds}s`;
515
- }
516
- if (minutes > 0) {
517
- return `${minutes}m ${seconds}s`;
518
- }
519
- return `${seconds}s`;
520
- }
521
-
522
- // Task tracking types and functions
523
- interface Task {
524
- text: string;
525
- status: "todo" | "in-progress" | "complete";
526
- subtasks: Task[];
527
- originalLine: string;
528
- }
529
-
530
- // Parse markdown tasks into structured data
531
- function parseTasks(content: string): Task[] {
532
- const tasks: Task[] = [];
533
- const lines = content.split("\n");
534
- let currentTask: Task | null = null;
535
-
536
- for (const line of lines) {
537
- // Top-level task: starts with "- [" at beginning (no leading whitespace)
538
- const topLevelMatch = line.match(/^- \[([ x\/])\]\s*(.+)/);
539
- if (topLevelMatch) {
540
- if (currentTask) {
541
- tasks.push(currentTask);
542
- }
543
- const [, statusChar, text] = topLevelMatch;
544
- let status: Task["status"] = "todo";
545
- if (statusChar === "x") status = "complete";
546
- else if (statusChar === "/") status = "in-progress";
547
-
548
- currentTask = { text, status, subtasks: [], originalLine: line };
549
- continue;
550
- }
551
-
552
- // Subtask: starts with whitespace followed by "- ["
553
- const subtaskMatch = line.match(/^\s+- \[([ x\/])\]\s*(.+)/);
554
- if (subtaskMatch && currentTask) {
555
- const [, statusChar, text] = subtaskMatch;
556
- let status: Task["status"] = "todo";
557
- if (statusChar === "x") status = "complete";
558
- else if (statusChar === "/") status = "in-progress";
559
-
560
- currentTask.subtasks.push({ text, status, subtasks: [], originalLine: line });
561
- }
562
- }
563
-
564
- if (currentTask) {
565
- tasks.push(currentTask);
566
- }
567
-
568
- return tasks;
569
- }
570
-
571
- // Display tasks with numbering for CLI
572
- function displayTasksWithIndices(tasks: Task[]): void {
573
- if (tasks.length === 0) {
574
- console.log("No tasks found.");
575
- return;
576
- }
577
-
578
- console.log("Current tasks:");
579
- for (let i = 0; i < tasks.length; i++) {
580
- const task = tasks[i];
581
- const statusIcon = task.status === "complete" ? "✅" : task.status === "in-progress" ? "🔄" : "⏸️";
582
- console.log(`${i + 1}. ${statusIcon} ${task.text}`);
583
-
584
- for (const subtask of task.subtasks) {
585
- const subStatusIcon = subtask.status === "complete" ? "✅" : subtask.status === "in-progress" ? "🔄" : "⏸️";
586
- console.log(` ${subStatusIcon} ${subtask.text}`);
587
- }
588
- }
589
- }
590
-
591
- // Find the current in-progress task (marked with [/])
592
- function findCurrentTask(tasks: Task[]): Task | null {
593
- for (const task of tasks) {
594
- if (task.status === "in-progress") {
595
- return task;
596
- }
597
- }
598
- return null;
599
- }
600
-
601
- // Find the next incomplete task
602
- function findNextTask(tasks: Task[]): Task | null {
603
- for (const task of tasks) {
604
- if (task.status === "todo") {
605
- return task;
606
- }
607
- }
608
- return null;
609
- }
610
-
611
- // Check if all tasks are complete
612
- function allTasksComplete(tasks: Task[]): boolean {
613
- return tasks.length > 0 && tasks.every(t => t.status === "complete");
614
- }
615
-
616
- // Parse options
617
- let prompt = "";
618
- let minIterations = 1; // default: 1 iteration minimum
619
- let maxIterations = 0; // 0 = unlimited
620
- let completionPromise = "COMPLETE";
621
- let tasksMode = false;
622
- let taskPromise = "READY_FOR_NEXT_TASK";
623
- let model = "";
624
- let agentType: AgentType = "opencode";
625
- let autoCommit = true;
626
- let disablePlugins = false;
627
- let allowAllPermissions = true;
628
- let promptFile = "";
629
- let streamOutput = true;
630
- let verboseTools = false;
631
- let promptSource = "";
632
-
633
- const promptParts: string[] = [];
634
-
635
- for (let i = 0; i < args.length; i++) {
636
- const arg = args[i];
637
-
638
- if (arg === "--agent") {
639
- const val = args[++i];
640
- if (!val || !["opencode", "claude-code", "codex"].includes(val)) {
641
- console.error("Error: --agent requires: 'opencode', 'claude-code', or 'codex'");
642
- process.exit(1);
643
- }
644
- agentType = val as AgentType;
645
- } else if (arg === "--min-iterations") {
646
- const val = args[++i];
647
- if (!val || isNaN(parseInt(val))) {
648
- console.error("Error: --min-iterations requires a number");
649
- process.exit(1);
650
- }
651
- minIterations = parseInt(val);
652
- } else if (arg === "--max-iterations") {
653
- const val = args[++i];
654
- if (!val || isNaN(parseInt(val))) {
655
- console.error("Error: --max-iterations requires a number");
656
- process.exit(1);
657
- }
658
- maxIterations = parseInt(val);
659
- } else if (arg === "--completion-promise") {
660
- const val = args[++i];
661
- if (!val) {
662
- console.error("Error: --completion-promise requires a value");
663
- process.exit(1);
664
- }
665
- completionPromise = val;
666
- } else if (arg === "--tasks" || arg === "-t") {
667
- tasksMode = true;
668
- } else if (arg === "--task-promise") {
669
- const val = args[++i];
670
- if (!val) {
671
- console.error("Error: --task-promise requires a value");
672
- process.exit(1);
673
- }
674
- taskPromise = val;
675
- } else if (arg === "--model") {
676
- const val = args[++i];
677
- if (!val) {
678
- console.error("Error: --model requires a value");
679
- process.exit(1);
680
- }
681
- model = val;
682
- } else if (arg === "--prompt-file" || arg === "--file" || arg === "-f") {
683
- const val = args[++i];
684
- if (!val) {
685
- console.error("Error: --prompt-file requires a file path");
686
- process.exit(1);
687
- }
688
- promptFile = val;
689
- } else if (arg === "--no-stream") {
690
- streamOutput = false;
691
- } else if (arg === "--stream") {
692
- streamOutput = true;
693
- } else if (arg === "--verbose-tools") {
694
- verboseTools = true;
695
- } else if (arg === "--no-commit") {
696
- autoCommit = false;
697
- } else if (arg === "--no-plugins") {
698
- disablePlugins = true;
699
- } else if (arg === "--allow-all") {
700
- allowAllPermissions = true;
701
- } else if (arg === "--no-allow-all") {
702
- allowAllPermissions = false;
703
- } else if (arg.startsWith("-")) {
704
- console.error(`Error: Unknown option: ${arg}`);
705
- console.error("Run 'ralph --help' for available options");
706
- process.exit(1);
707
- } else {
708
- promptParts.push(arg);
709
- }
710
- }
711
-
712
- function readPromptFile(path: string): string {
713
- if (!existsSync(path)) {
714
- console.error(`Error: Prompt file not found: ${path}`);
715
- process.exit(1);
716
- }
717
- try {
718
- const stat = statSync(path);
719
- if (!stat.isFile()) {
720
- console.error(`Error: Prompt path is not a file: ${path}`);
721
- process.exit(1);
722
- }
723
- } catch {
724
- console.error(`Error: Unable to stat prompt file: ${path}`);
725
- process.exit(1);
726
- }
727
- try {
728
- const content = readFileSync(path, "utf-8");
729
- if (!content.trim()) {
730
- console.error(`Error: Prompt file is empty: ${path}`);
731
- process.exit(1);
732
- }
733
- return content;
734
- } catch {
735
- console.error(`Error: Unable to read prompt file: ${path}`);
736
- process.exit(1);
737
- }
738
- }
739
-
740
- if (promptFile) {
741
- promptSource = promptFile;
742
- prompt = readPromptFile(promptFile);
743
- } else if (promptParts.length === 1 && existsSync(promptParts[0])) {
744
- promptSource = promptParts[0];
745
- prompt = readPromptFile(promptParts[0]);
746
- } else {
747
- prompt = promptParts.join(" ");
748
- }
749
-
750
- if (!prompt) {
751
- console.error("Error: No prompt provided");
752
- console.error("Usage: ralph \"Your task description\" [options]");
753
- console.error("Run 'ralph --help' for more information");
754
- process.exit(1);
755
- }
756
-
757
- // Validate min/max iterations
758
- if (maxIterations > 0 && minIterations > maxIterations) {
759
- console.error(`Error: --min-iterations (${minIterations}) cannot be greater than --max-iterations (${maxIterations})`);
760
- process.exit(1);
761
- }
762
-
763
- interface RalphState {
764
- active: boolean;
765
- iteration: number;
766
- minIterations: number;
767
- maxIterations: number;
768
- completionPromise: string;
769
- tasksMode: boolean;
770
- taskPromise: string;
771
- prompt: string;
772
- startedAt: string;
773
- model: string;
774
- agent: AgentType;
775
- }
776
-
777
- // Create or update state
778
- function saveState(state: RalphState): void {
779
- if (!existsSync(stateDir)) {
780
- mkdirSync(stateDir, { recursive: true });
781
- }
782
- writeFileSync(statePath, JSON.stringify(state, null, 2));
783
- }
784
-
785
- function loadState(): RalphState | null {
786
- if (!existsSync(statePath)) {
787
- return null;
788
- }
789
- try {
790
- return JSON.parse(readFileSync(statePath, "utf-8"));
791
- } catch {
792
- return null;
793
- }
794
- }
795
-
796
- function clearState(): void {
797
- if (existsSync(statePath)) {
798
- try {
799
- require("fs").unlinkSync(statePath);
800
- } catch {}
801
- }
802
- }
803
-
804
- function loadPluginsFromConfig(configPath: string): string[] {
805
- if (!existsSync(configPath)) {
806
- return [];
807
- }
808
- try {
809
- const raw = readFileSync(configPath, "utf-8");
810
- // Basic JSONC support: strip // and /* */ comments.
811
- const withoutBlock = raw.replace(/\/\*[\s\S]*?\*\//g, "");
812
- const withoutLine = withoutBlock.replace(/^\s*\/\/.*$/gm, "");
813
- const parsed = JSON.parse(withoutLine);
814
- const plugins = parsed?.plugin;
815
- return Array.isArray(plugins) ? plugins.filter(p => typeof p === "string") : [];
816
- } catch {
817
- return [];
818
- }
819
- }
820
-
821
- function ensureRalphConfig(options: { filterPlugins?: boolean; allowAllPermissions?: boolean }): string {
822
- if (!existsSync(stateDir)) {
823
- mkdirSync(stateDir, { recursive: true });
824
- }
825
- const configPath = join(stateDir, "ralph-opencode.config.json");
826
- const userConfigPath = join(process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? "", ".config"), "opencode", "opencode.json");
827
- const projectConfigPath = join(process.cwd(), ".ralph", "opencode.json");
828
- const legacyProjectConfigPath = join(process.cwd(), ".opencode", "opencode.json");
829
-
830
- const config: Record<string, unknown> = {
831
- $schema: "https://opencode.ai/config.json",
832
- };
833
-
834
- // Filter plugins if requested (only keep auth plugins)
835
- if (options.filterPlugins) {
836
- const plugins = [
837
- ...loadPluginsFromConfig(userConfigPath),
838
- ...loadPluginsFromConfig(projectConfigPath),
839
- ...loadPluginsFromConfig(legacyProjectConfigPath),
840
- ];
841
- config.plugin = Array.from(new Set(plugins)).filter(p => /auth/i.test(p));
842
- }
843
-
844
- // Auto-allow all permissions for non-interactive use
845
- if (options.allowAllPermissions) {
846
- config.permission = {
847
- read: "allow",
848
- edit: "allow",
849
- glob: "allow",
850
- grep: "allow",
851
- list: "allow",
852
- bash: "allow",
853
- task: "allow",
854
- webfetch: "allow",
855
- websearch: "allow",
856
- codesearch: "allow",
857
- todowrite: "allow",
858
- todoread: "allow",
859
- question: "allow",
860
- lsp: "allow",
861
- external_directory: "allow",
862
- };
863
- }
864
-
865
- writeFileSync(configPath, JSON.stringify(config, null, 2));
866
- return configPath;
867
- }
868
-
869
- async function validateAgent(agent: AgentConfig): Promise<void> {
870
- // Use Bun.which() for cross-platform executable detection (works on Windows, macOS, Linux)
871
- const path = Bun.which(agent.command);
872
- if (!path) {
873
- console.error(`Error: ${agent.configName} CLI ('${agent.command}') not found.`);
874
- process.exit(1);
875
- }
876
- }
877
-
878
- // Build the full prompt with iteration context
879
- function loadContext(): string | null {
880
- if (!existsSync(contextPath)) {
881
- return null;
882
- }
883
- try {
884
- const content = readFileSync(contextPath, "utf-8").trim();
885
- return content || null;
886
- } catch {
887
- return null;
888
- }
889
- }
890
-
891
- function clearContext(): void {
892
- if (existsSync(contextPath)) {
893
- try {
894
- require("fs").unlinkSync(contextPath);
895
- } catch {}
896
- }
897
- }
898
-
899
- /**
900
- * Build the prompt for the current iteration.
901
- * @param state - Current loop state
902
- * @param _agent - Agent config (reserved for future agent-specific prompt customization)
903
- */
904
- function buildPrompt(state: RalphState, _agent: AgentConfig): string {
905
- const context = loadContext();
906
- const contextSection = context
907
- ? `
908
- ## Additional Context (added by user mid-loop)
909
-
910
- ${context}
911
-
912
- ---
913
- `
914
- : "";
915
-
916
- // Tasks mode: use task-specific instructions
917
- if (state.tasksMode) {
918
- const tasksSection = getTasksModeSection(state);
919
- return `
920
- # Ralph Wiggum Loop - Iteration ${state.iteration}
921
-
922
- You are in an iterative development loop working through a task list.
923
- ${contextSection}${tasksSection}
924
- ## Your Main Goal
925
-
926
- ${state.prompt}
927
-
928
- ## Critical Rules
929
-
930
- - Work on ONE task at a time from .ralph/ralph-tasks.md
931
- - ONLY output <promise>${state.taskPromise}</promise> when the current task is complete and marked in ralph-tasks.md
932
- - ONLY output <promise>${state.completionPromise}</promise> when ALL tasks are truly done
933
- - Do NOT lie or output false promises to exit the loop
934
- - If stuck, try a different approach
935
- - Check your work before claiming completion
936
-
937
- ## Current Iteration: ${state.iteration}${state.maxIterations > 0 ? ` / ${state.maxIterations}` : " (unlimited)"} (min: ${state.minIterations ?? 1})
938
-
939
- Tasks Mode: ENABLED - Work on one task at a time from ralph-tasks.md
940
-
941
- Now, work on the current task. Good luck!
942
- `.trim();
943
- }
944
-
945
- // Default mode: simple instructions without tool-specific mentions
946
- return `
947
- # Ralph Wiggum Loop - Iteration ${state.iteration}
948
-
949
- You are in an iterative development loop. Work on the task below until you can genuinely complete it.
950
- ${contextSection}
951
- ## Your Task
952
-
953
- ${state.prompt}
954
-
955
- ## Instructions
956
-
957
- 1. Read the current state of files to understand what's been done
958
- 2. Track your progress and plan remaining work
959
- 3. Make progress on the task
960
- 4. Run tests/verification if applicable
961
- 5. When the task is GENUINELY COMPLETE, output:
962
- <promise>${state.completionPromise}</promise>
963
-
964
- ## Critical Rules
965
-
966
- - ONLY output <promise>${state.completionPromise}</promise> when the task is truly done
967
- - Do NOT lie or output false promises to exit the loop
968
- - If stuck, try a different approach
969
- - Check your work before claiming completion
970
- - The loop will continue until you succeed
971
-
972
- ## Current Iteration: ${state.iteration}${state.maxIterations > 0 ? ` / ${state.maxIterations}` : " (unlimited)"} (min: ${state.minIterations ?? 1})
973
-
974
- Now, work on the task. Good luck!
975
- `.trim();
976
- }
977
-
978
- // Generate the tasks mode section for the prompt
979
- function getTasksModeSection(state: RalphState): string {
980
- if (!existsSync(tasksPath)) {
981
- return `
982
- ## TASKS MODE: Enabled (no tasks file found)
983
-
984
- Create .ralph/ralph-tasks.md with your task list, or use \`ralph --add-task "description"\` to add tasks.
985
- `;
986
- }
987
-
988
- try {
989
- const tasksContent = readFileSync(tasksPath, "utf-8");
990
- const tasks = parseTasks(tasksContent);
991
- const currentTask = findCurrentTask(tasks);
992
- const nextTask = findNextTask(tasks);
993
-
994
- let taskInstructions = "";
995
- if (currentTask) {
996
- taskInstructions = `
997
- 🔄 CURRENT TASK: "${currentTask.text}"
998
- Focus on completing this specific task.
999
- When done: Mark as [x] in .ralph/ralph-tasks.md and output <promise>${state.taskPromise}</promise>`;
1000
- } else if (nextTask) {
1001
- taskInstructions = `
1002
- 📍 NEXT TASK: "${nextTask.text}"
1003
- Mark as [/] in .ralph/ralph-tasks.md before starting.
1004
- When done: Mark as [x] and output <promise>${state.taskPromise}</promise>`;
1005
- } else if (allTasksComplete(tasks)) {
1006
- taskInstructions = `
1007
- ✅ ALL TASKS COMPLETE!
1008
- Output <promise>${state.completionPromise}</promise> to finish.`;
1009
- } else {
1010
- taskInstructions = `
1011
- 📋 No tasks found. Add tasks to .ralph/ralph-tasks.md or use \`ralph --add-task\``;
1012
- }
1013
-
1014
- return `
1015
- ## TASKS MODE: Working through task list
1016
-
1017
- Current tasks from .ralph/ralph-tasks.md:
1018
- \`\`\`markdown
1019
- ${tasksContent.trim()}
1020
- \`\`\`
1021
- ${taskInstructions}
1022
-
1023
- ### Task Workflow
1024
- 1. Find any task marked [/] (in progress). If none, pick the first [ ] task.
1025
- 2. Mark the task as [/] in ralph-tasks.md before starting.
1026
- 3. Complete the task.
1027
- 4. Mark as [x] when verified complete.
1028
- 5. Output <promise>${state.taskPromise}</promise> to move to the next task.
1029
- 6. Only output <promise>${state.completionPromise}</promise> when ALL tasks are [x].
1030
-
1031
- ---
1032
- `;
1033
- } catch {
1034
- return `
1035
- ## TASKS MODE: Error reading tasks file
1036
-
1037
- Unable to read .ralph/ralph-tasks.md
1038
- `;
1039
- }
1040
- }
1041
-
1042
- // Check if output contains the completion promise
1043
- function checkCompletion(output: string, promise: string): boolean {
1044
- const promisePattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "i");
1045
- return promisePattern.test(output);
1046
- }
1047
-
1048
- function escapeRegex(str: string): string {
1049
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1050
- }
1051
-
1052
- function detectPlaceholderPluginError(output: string): boolean {
1053
- return output.includes("ralph-wiggum is not yet ready for use. This is a placeholder package.");
1054
- }
1055
-
1056
- function stripAnsi(input: string): string {
1057
- return input.replace(/\x1B\[[0-9;]*m/g, "");
1058
- }
1059
-
1060
- function formatDuration(ms: number): string {
1061
- const totalSeconds = Math.max(0, Math.floor(ms / 1000));
1062
- const hours = Math.floor(totalSeconds / 3600);
1063
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1064
- const seconds = totalSeconds % 60;
1065
- if (hours > 0) {
1066
- return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
1067
- }
1068
- return `${minutes}:${String(seconds).padStart(2, "0")}`;
1069
- }
1070
-
1071
- function formatToolSummary(toolCounts: Map<string, number>, maxItems = 6): string {
1072
- if (!toolCounts.size) return "";
1073
- const entries = Array.from(toolCounts.entries()).sort((a, b) => b[1] - a[1]);
1074
- const shown = entries.slice(0, maxItems);
1075
- const remaining = entries.length - shown.length;
1076
- const parts = shown.map(([name, count]) => `${name} ${count}`);
1077
- if (remaining > 0) {
1078
- parts.push(`+${remaining} more`);
1079
- }
1080
- return parts.join(" • ");
1081
- }
1082
-
1083
- function collectToolSummaryFromText(text: string, agent: AgentConfig): Map<string, number> {
1084
- const counts = new Map<string, number>();
1085
- const lines = text.split(/\r?\n/);
1086
- for (const line of lines) {
1087
- const tool = agent.parseToolOutput(line);
1088
- if (tool) {
1089
- counts.set(tool, (counts.get(tool) ?? 0) + 1);
1090
- }
1091
- }
1092
- return counts;
1093
- }
1094
-
1095
- function printIterationSummary(params: {
1096
- iteration: number;
1097
- elapsedMs: number;
1098
- toolCounts: Map<string, number>;
1099
- exitCode: number;
1100
- completionDetected: boolean;
1101
- }): void {
1102
- const toolSummary = formatToolSummary(params.toolCounts);
1103
- console.log("\nIteration Summary");
1104
- console.log("────────────────────────────────────────────────────────────────────");
1105
- console.log(`Iteration: ${params.iteration}`);
1106
- console.log(`Elapsed: ${formatDuration(params.elapsedMs)}`);
1107
- if (toolSummary) {
1108
- console.log(`Tools: ${toolSummary}`);
1109
- } else {
1110
- console.log("Tools: none");
1111
- }
1112
- console.log(`Exit code: ${params.exitCode}`);
1113
- console.log(`Completion promise: ${params.completionDetected ? "detected" : "not detected"}`);
1114
- }
1115
-
1116
- async function streamProcessOutput(
1117
- proc: ReturnType<typeof Bun.spawn>,
1118
- options: {
1119
- compactTools: boolean;
1120
- toolSummaryIntervalMs: number;
1121
- heartbeatIntervalMs: number;
1122
- iterationStart: number;
1123
- agent: AgentConfig;
1124
- },
1125
- ): Promise<{ stdoutText: string; stderrText: string; toolCounts: Map<string, number> }> {
1126
- const toolCounts = new Map<string, number>();
1127
- let stdoutText = "";
1128
- let stderrText = "";
1129
- let lastPrintedAt = Date.now();
1130
- let lastActivityAt = Date.now();
1131
- let lastToolSummaryAt = 0;
1132
-
1133
- const compactTools = options.compactTools;
1134
- const parseToolOutput = options.agent.parseToolOutput;
1135
-
1136
- const maybePrintToolSummary = (force = false) => {
1137
- if (!compactTools || toolCounts.size === 0) return;
1138
- const now = Date.now();
1139
- if (!force && now - lastToolSummaryAt < options.toolSummaryIntervalMs) {
1140
- return;
1141
- }
1142
- const summary = formatToolSummary(toolCounts);
1143
- if (summary) {
1144
- console.log(`| Tools ${summary}`);
1145
- lastPrintedAt = Date.now();
1146
- lastToolSummaryAt = Date.now();
1147
- }
1148
- };
1149
-
1150
- const handleLine = (line: string, isError: boolean) => {
1151
- lastActivityAt = Date.now();
1152
- const tool = parseToolOutput(line);
1153
- if (tool) {
1154
- toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + 1);
1155
- if (compactTools) {
1156
- maybePrintToolSummary();
1157
- return;
1158
- }
1159
- }
1160
- if (line.length === 0) {
1161
- console.log("");
1162
- lastPrintedAt = Date.now();
1163
- return;
1164
- }
1165
- if (isError) {
1166
- console.error(line);
1167
- } else {
1168
- console.log(line);
1169
- }
1170
- lastPrintedAt = Date.now();
1171
- };
1172
-
1173
- const streamText = async (
1174
- stream: ReadableStream<Uint8Array> | null,
1175
- onText: (chunk: string) => void,
1176
- isError: boolean,
1177
- ) => {
1178
- if (!stream) return;
1179
- const reader = stream.getReader();
1180
- const decoder = new TextDecoder();
1181
- let buffer = "";
1182
- while (true) {
1183
- const { value, done } = await reader.read();
1184
- if (done) break;
1185
- const text = decoder.decode(value, { stream: true });
1186
- if (text.length > 0) {
1187
- onText(text);
1188
- buffer += text;
1189
- const lines = buffer.split(/\r?\n/);
1190
- buffer = lines.pop() ?? "";
1191
- for (const line of lines) {
1192
- handleLine(line, isError);
1193
- }
1194
- }
1195
- }
1196
- const flushed = decoder.decode();
1197
- if (flushed.length > 0) {
1198
- onText(flushed);
1199
- buffer += flushed;
1200
- }
1201
- if (buffer.length > 0) {
1202
- handleLine(buffer, isError);
1203
- }
1204
- };
1205
-
1206
- const heartbeatTimer = setInterval(() => {
1207
- const now = Date.now();
1208
- if (now - lastPrintedAt >= options.heartbeatIntervalMs) {
1209
- const elapsed = formatDuration(now - options.iterationStart);
1210
- const sinceActivity = formatDuration(now - lastActivityAt);
1211
- console.log(`⏳ working... elapsed ${elapsed} · last activity ${sinceActivity} ago`);
1212
- lastPrintedAt = now;
1213
- }
1214
- }, options.heartbeatIntervalMs);
1215
-
1216
- try {
1217
- await Promise.all([
1218
- streamText(
1219
- proc.stdout,
1220
- chunk => {
1221
- stdoutText += chunk;
1222
- },
1223
- false,
1224
- ),
1225
- streamText(
1226
- proc.stderr,
1227
- chunk => {
1228
- stderrText += chunk;
1229
- },
1230
- true,
1231
- ),
1232
- ]);
1233
- } finally {
1234
- clearInterval(heartbeatTimer);
1235
- }
1236
-
1237
- if (compactTools) {
1238
- maybePrintToolSummary(true);
1239
- }
1240
-
1241
- return { stdoutText, stderrText, toolCounts };
1242
- }
1243
- // Main loop
1244
- // Helper to detect per-iteration file changes using content hashes
1245
- // Works correctly with --no-commit by comparing file content hashes
1246
-
1247
- interface FileSnapshot {
1248
- files: Map<string, string>; // filename -> hash/mtime
1249
- }
1250
-
1251
- async function captureFileSnapshot(): Promise<FileSnapshot> {
1252
- const files = new Map<string, string>();
1253
- try {
1254
- // Get list of all tracked and modified files
1255
- const status = await $`git status --porcelain`.text();
1256
- const trackedFiles = await $`git ls-files`.text();
1257
-
1258
- // Combine modified and tracked files
1259
- const allFiles = new Set<string>();
1260
- for (const line of status.split("\n")) {
1261
- if (line.trim()) {
1262
- allFiles.add(line.substring(3).trim());
1263
- }
1264
- }
1265
- for (const file of trackedFiles.split("\n")) {
1266
- if (file.trim()) {
1267
- allFiles.add(file.trim());
1268
- }
1269
- }
1270
-
1271
- // Get hash for each file (using git hash-object for content comparison)
1272
- for (const file of allFiles) {
1273
- try {
1274
- const hash = await $`git hash-object ${file} 2>/dev/null || stat -f '%m' ${file} 2>/dev/null || echo ''`.text();
1275
- files.set(file, hash.trim());
1276
- } catch {
1277
- // File may not exist, skip
1278
- }
1279
- }
1280
- } catch {
1281
- // Git not available or error
1282
- }
1283
- return { files };
1284
- }
1285
-
1286
- function getModifiedFilesSinceSnapshot(before: FileSnapshot, after: FileSnapshot): string[] {
1287
- const changedFiles: string[] = [];
1288
-
1289
- // Check for new or modified files
1290
- for (const [file, hash] of after.files) {
1291
- const prevHash = before.files.get(file);
1292
- if (prevHash !== hash) {
1293
- changedFiles.push(file);
1294
- }
1295
- }
1296
-
1297
- // Check for deleted files
1298
- for (const [file] of before.files) {
1299
- if (!after.files.has(file)) {
1300
- changedFiles.push(file);
1301
- }
1302
- }
1303
-
1304
- return changedFiles;
1305
- }
1306
-
1307
- // Helper to extract error patterns from output
1308
- function extractErrors(output: string): string[] {
1309
- const errors: string[] = [];
1310
- const lines = output.split("\n");
1311
-
1312
- for (const line of lines) {
1313
- const lower = line.toLowerCase();
1314
- // Match common error patterns
1315
- if (
1316
- lower.includes("error:") ||
1317
- lower.includes("failed:") ||
1318
- lower.includes("exception:") ||
1319
- lower.includes("typeerror") ||
1320
- lower.includes("syntaxerror") ||
1321
- lower.includes("referenceerror") ||
1322
- (lower.includes("test") && lower.includes("fail"))
1323
- ) {
1324
- const cleaned = line.trim().substring(0, 200);
1325
- if (cleaned && !errors.includes(cleaned)) {
1326
- errors.push(cleaned);
1327
- }
1328
- }
1329
- }
1330
-
1331
- return errors.slice(0, 10); // Cap at 10 errors per iteration
1332
- }
1333
-
1334
- async function runRalphLoop(): Promise<void> {
1335
- // Check if a loop is already running
1336
- const existingState = loadState();
1337
- if (existingState?.active) {
1338
- console.error(`Error: A Ralph loop is already active (iteration ${existingState.iteration})`);
1339
- console.error(`Started at: ${existingState.startedAt}`);
1340
- console.error(`To cancel it, press Ctrl+C in its terminal or delete ${statePath}`);
1341
- process.exit(1);
1342
- }
1343
-
1344
- const agentConfig = AGENTS[agentType];
1345
- await validateAgent(agentConfig);
1346
- if (disablePlugins && agentConfig.type === "claude-code") {
1347
- console.warn("Warning: --no-plugins has no effect with Claude Code agent");
1348
- }
1349
- if (disablePlugins && agentConfig.type === "codex") {
1350
- console.warn("Warning: --no-plugins has no effect with Codex agent");
1351
- }
1352
-
1353
- console.log(`
1354
- ╔══════════════════════════════════════════════════════════════════╗
1355
- ║ Ralph Wiggum Loop ║
1356
- ║ Iterative AI Development with ${agentConfig.configName.padEnd(20, " ")} ║
1357
- ╚══════════════════════════════════════════════════════════════════╝
1358
- `);
1359
-
1360
- // Initialize state
1361
- const state: RalphState = {
1362
- active: true,
1363
- iteration: 1,
1364
- minIterations,
1365
- maxIterations,
1366
- completionPromise,
1367
- tasksMode,
1368
- taskPromise,
1369
- prompt,
1370
- startedAt: new Date().toISOString(),
1371
- model,
1372
- agent: agentType,
1373
- };
1374
-
1375
- saveState(state);
1376
-
1377
- // Create tasks file if tasks mode is enabled and file doesn't exist
1378
- if (tasksMode && !existsSync(tasksPath)) {
1379
- if (!existsSync(stateDir)) {
1380
- mkdirSync(stateDir, { recursive: true });
1381
- }
1382
- writeFileSync(tasksPath, "# Ralph Tasks\n\nAdd your tasks below using: `ralph --add-task \"description\"`\n");
1383
- console.log(`📋 Created tasks file: ${tasksPath}`);
1384
- }
1385
-
1386
- // Initialize history tracking
1387
- const history: RalphHistory = {
1388
- iterations: [],
1389
- totalDurationMs: 0,
1390
- struggleIndicators: { repeatedErrors: {}, noProgressIterations: 0, shortIterations: 0 }
1391
- };
1392
- saveHistory(history);
1393
-
1394
- const promptPreview = prompt.replace(/\s+/g, " ").substring(0, 80) + (prompt.length > 80 ? "..." : "");
1395
- if (promptSource) {
1396
- console.log(`Task: ${promptSource}`);
1397
- console.log(`Preview: ${promptPreview}`);
1398
- } else {
1399
- console.log(`Task: ${promptPreview}`);
1400
- }
1401
- console.log(`Completion promise: ${completionPromise}`);
1402
- if (tasksMode) {
1403
- console.log(`Tasks mode: ENABLED`);
1404
- console.log(`Task promise: ${taskPromise}`);
1405
- }
1406
- console.log(`Min iterations: ${minIterations}`);
1407
- console.log(`Max iterations: ${maxIterations > 0 ? maxIterations : "unlimited"}`);
1408
- console.log(`Agent: ${agentConfig.configName}`);
1409
- if (model) console.log(`Model: ${model}`);
1410
- if (disablePlugins && agentConfig.type === "opencode") {
1411
- console.log("OpenCode plugins: non-auth plugins disabled");
1412
- }
1413
- if (allowAllPermissions) console.log("Permissions: auto-approve all tools");
1414
- console.log("");
1415
- console.log("Starting loop... (Ctrl+C to stop)");
1416
- console.log("═".repeat(68));
1417
-
1418
- // Track current subprocess for cleanup on SIGINT
1419
- let currentProc: ReturnType<typeof Bun.spawn> | null = null;
1420
-
1421
- // Set up signal handler for graceful shutdown
1422
- let stopping = false;
1423
- process.on("SIGINT", () => {
1424
- if (stopping) {
1425
- console.log("\nForce stopping...");
1426
- process.exit(1);
1427
- }
1428
- stopping = true;
1429
- console.log("\nGracefully stopping Ralph loop...");
1430
-
1431
- // Kill the subprocess if it's running
1432
- if (currentProc) {
1433
- try {
1434
- currentProc.kill();
1435
- } catch {
1436
- // Process may have already exited
1437
- }
1438
- }
1439
-
1440
- clearState();
1441
- console.log("Loop cancelled.");
1442
- process.exit(0);
1443
- });
1444
-
1445
- // Main loop
1446
- while (true) {
1447
- // Check max iterations
1448
- if (maxIterations > 0 && state.iteration > maxIterations) {
1449
- console.log(`\n╔══════════════════════════════════════════════════════════════════╗`);
1450
- console.log(`║ Max iterations (${maxIterations}) reached. Loop stopped.`);
1451
- console.log(`║ Total time: ${formatDurationLong(history.totalDurationMs)}`);
1452
- console.log(`╚══════════════════════════════════════════════════════════════════╝`);
1453
- clearState();
1454
- // Keep history for analysis via --status
1455
- break;
1456
- }
1457
-
1458
- const iterInfo = maxIterations > 0 ? ` / ${maxIterations}` : "";
1459
- const minInfo = minIterations > 1 && state.iteration < minIterations ? ` (min: ${minIterations})` : "";
1460
- console.log(`\n🔄 Iteration ${state.iteration}${iterInfo}${minInfo}`);
1461
- console.log("─".repeat(68));
1462
-
1463
- // Capture context at start of iteration (to only clear what was consumed)
1464
- const contextAtStart = loadContext();
1465
-
1466
- // Capture git state before iteration to detect per-iteration changes
1467
- const snapshotBefore = await captureFileSnapshot();
1468
-
1469
- // Build the prompt
1470
- const fullPrompt = buildPrompt(state, agentConfig);
1471
- const iterationStart = Date.now();
1472
-
1473
- try {
1474
- // Build command arguments (permission flags are handled inside buildArgs)
1475
- const cmdArgs = agentConfig.buildArgs(fullPrompt, model, { allowAllPermissions });
1476
-
1477
- const env = agentConfig.buildEnv({
1478
- filterPlugins: disablePlugins,
1479
- allowAllPermissions: allowAllPermissions,
1480
- });
1481
-
1482
- // Run agent using spawn for better argument handling
1483
- // stdin is inherited so users can respond to permission prompts if needed
1484
- currentProc = Bun.spawn([agentConfig.command, ...cmdArgs], {
1485
- env,
1486
- stdin: "inherit",
1487
- stdout: "pipe",
1488
- stderr: "pipe",
1489
- });
1490
- const proc = currentProc;
1491
- const exitCodePromise = proc.exited;
1492
- let result = "";
1493
- let stderr = "";
1494
- let toolCounts = new Map<string, number>();
1495
-
1496
- if (streamOutput) {
1497
- const streamed = await streamProcessOutput(proc, {
1498
- compactTools: !verboseTools,
1499
- toolSummaryIntervalMs: 3000,
1500
- heartbeatIntervalMs: 10000,
1501
- iterationStart,
1502
- agent: agentConfig,
1503
- });
1504
- result = streamed.stdoutText;
1505
- stderr = streamed.stderrText;
1506
- toolCounts = streamed.toolCounts;
1507
- } else {
1508
- const stdoutPromise = new Response(proc.stdout).text();
1509
- const stderrPromise = new Response(proc.stderr).text();
1510
- [result, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
1511
- toolCounts = collectToolSummaryFromText(`${result}\n${stderr}`, agentConfig);
1512
- }
1513
-
1514
- const exitCode = await exitCodePromise;
1515
- currentProc = null; // Clear reference after subprocess completes
1516
-
1517
- if (!streamOutput) {
1518
- if (stderr) {
1519
- console.error(stderr);
1520
- }
1521
- console.log(result);
1522
- }
1523
-
1524
- const combinedOutput = `${result}\n${stderr}`;
1525
- const completionDetected = checkCompletion(combinedOutput, completionPromise);
1526
- const taskCompletionDetected = tasksMode ? checkCompletion(combinedOutput, taskPromise) : false;
1527
-
1528
- const iterationDuration = Date.now() - iterationStart;
1529
-
1530
- printIterationSummary({
1531
- iteration: state.iteration,
1532
- elapsedMs: iterationDuration,
1533
- toolCounts,
1534
- exitCode,
1535
- completionDetected,
1536
- });
1537
-
1538
- // Track iteration history - compare against pre-iteration snapshot
1539
- const snapshotAfter = await captureFileSnapshot();
1540
- const filesModified = getModifiedFilesSinceSnapshot(snapshotBefore, snapshotAfter);
1541
- const errors = extractErrors(combinedOutput);
1542
-
1543
- const iterationRecord: IterationHistory = {
1544
- iteration: state.iteration,
1545
- startedAt: new Date(iterationStart).toISOString(),
1546
- endedAt: new Date().toISOString(),
1547
- durationMs: iterationDuration,
1548
- toolsUsed: Object.fromEntries(toolCounts),
1549
- filesModified,
1550
- exitCode,
1551
- completionDetected,
1552
- errors,
1553
- };
1554
-
1555
- history.iterations.push(iterationRecord);
1556
- history.totalDurationMs += iterationDuration;
1557
-
1558
- // Update struggle indicators
1559
- if (filesModified.length === 0) {
1560
- history.struggleIndicators.noProgressIterations++;
1561
- } else {
1562
- history.struggleIndicators.noProgressIterations = 0; // Reset on progress
1563
- }
1564
-
1565
- if (iterationDuration < 30000) { // Less than 30 seconds
1566
- history.struggleIndicators.shortIterations++;
1567
- } else {
1568
- history.struggleIndicators.shortIterations = 0; // Reset on normal-length iteration
1569
- }
1570
-
1571
- if (errors.length === 0) {
1572
- // Reset error tracking when iteration has no errors (issue resolved)
1573
- history.struggleIndicators.repeatedErrors = {};
1574
- } else {
1575
- for (const error of errors) {
1576
- const key = error.substring(0, 100);
1577
- history.struggleIndicators.repeatedErrors[key] = (history.struggleIndicators.repeatedErrors[key] || 0) + 1;
1578
- }
1579
- }
1580
-
1581
- saveHistory(history);
1582
-
1583
- // Show struggle warning if detected
1584
- const struggle = history.struggleIndicators;
1585
- if (state.iteration > 2 && (struggle.noProgressIterations >= 3 || struggle.shortIterations >= 3)) {
1586
- console.log(`\n⚠️ Potential struggle detected:`);
1587
- if (struggle.noProgressIterations >= 3) {
1588
- console.log(` - No file changes in ${struggle.noProgressIterations} iterations`);
1589
- }
1590
- if (struggle.shortIterations >= 3) {
1591
- console.log(` - ${struggle.shortIterations} very short iterations`);
1592
- }
1593
- console.log(` 💡 Tip: Use 'ralph --add-context "hint"' in another terminal to guide the agent`);
1594
- }
1595
-
1596
- if (agentType === "opencode" && detectPlaceholderPluginError(combinedOutput)) {
1597
- console.error(
1598
- "\n❌ OpenCode tried to load the legacy 'ralph-wiggum' plugin. This package is CLI-only.",
1599
- );
1600
- console.error(
1601
- "Remove 'ralph-wiggum' from your opencode.json plugin list, or re-run with --no-plugins.",
1602
- );
1603
- clearState();
1604
- process.exit(1);
1605
- }
1606
-
1607
- if (exitCode !== 0) {
1608
- console.warn(`\n⚠️ ${agentConfig.configName} exited with code ${exitCode}. Continuing to next iteration.`);
1609
- }
1610
-
1611
- // Check for task completion (tasks mode only)
1612
- if (taskCompletionDetected && !completionDetected) {
1613
- console.log(`\n🔄 Task completion detected: <promise>${taskPromise}</promise>`);
1614
- console.log(` Moving to next task in iteration ${state.iteration + 1}...`);
1615
- }
1616
-
1617
- // Check for full completion
1618
- if (completionDetected) {
1619
- if (state.iteration < minIterations) {
1620
- // Completion detected but minimum iterations not reached
1621
- console.log(`\n⏳ Completion promise detected, but minimum iterations (${minIterations}) not yet reached.`);
1622
- console.log(` Continuing to iteration ${state.iteration + 1}...`);
1623
- } else {
1624
- console.log(`\n╔══════════════════════════════════════════════════════════════════╗`);
1625
- console.log(`║ ✅ Completion promise detected: <promise>${completionPromise}</promise>`);
1626
- console.log(`║ Task completed in ${state.iteration} iteration(s)`);
1627
- console.log(`║ Total time: ${formatDurationLong(history.totalDurationMs)}`);
1628
- console.log(`╚══════════════════════════════════════════════════════════════════╝`);
1629
- clearState();
1630
- clearHistory();
1631
- clearContext();
1632
- break;
1633
- }
1634
- }
1635
-
1636
- // Clear context only if it was present at iteration start (preserve mid-iteration additions)
1637
- if (contextAtStart) {
1638
- console.log(`📝 Context was consumed this iteration`);
1639
- clearContext();
1640
- }
1641
-
1642
- // Auto-commit if enabled
1643
- if (autoCommit) {
1644
- try {
1645
- // Check if there are changes to commit
1646
- const status = await $`git status --porcelain`.text();
1647
- if (status.trim()) {
1648
- await $`git add -A`;
1649
- await $`git commit -m "Ralph iteration ${state.iteration}: work in progress"`.quiet();
1650
- console.log(`📝 Auto-committed changes`);
1651
- }
1652
- } catch {
1653
- // Git commit failed, that's okay
1654
- }
1655
- }
1656
-
1657
- // Update state for next iteration
1658
- state.iteration++;
1659
- saveState(state);
1660
-
1661
- // Small delay between iterations
1662
- await new Promise(r => setTimeout(r, 1000));
1663
-
1664
- } catch (error) {
1665
- // Kill subprocess if still running to prevent orphaned processes
1666
- if (currentProc) {
1667
- try {
1668
- currentProc.kill();
1669
- } catch {
1670
- // Process may have already exited
1671
- }
1672
- currentProc = null;
1673
- }
1674
- console.error(`\n❌ Error in iteration ${state.iteration}:`, error);
1675
- console.log("Continuing to next iteration...");
1676
-
1677
- // Track failed iteration in history to keep state/history in sync
1678
- const iterationDuration = Date.now() - iterationStart;
1679
- const errorRecord: IterationHistory = {
1680
- iteration: state.iteration,
1681
- startedAt: new Date(iterationStart).toISOString(),
1682
- endedAt: new Date().toISOString(),
1683
- durationMs: iterationDuration,
1684
- toolsUsed: {},
1685
- filesModified: [],
1686
- exitCode: -1,
1687
- completionDetected: false,
1688
- errors: [String(error).substring(0, 200)],
1689
- };
1690
- history.iterations.push(errorRecord);
1691
- history.totalDurationMs += iterationDuration;
1692
- saveHistory(history);
1693
-
1694
- state.iteration++;
1695
- saveState(state);
1696
- await new Promise(r => setTimeout(r, 2000));
1697
- }
1698
- }
1699
- }
1700
-
1701
- // Run the loop
1702
- runRalphLoop().catch(error => {
1703
- console.error("Fatal error:", error);
1704
- clearState();
1705
- process.exit(1);
1706
- });