ralph.rb 1.2.435535439
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.
- checksums.yaml +7 -0
- data/.github/workflows/gem-push.yml +47 -0
- data/.gitignore +79 -0
- data/.rubocop.yml +6018 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +113 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +656 -0
- data/bin/rubocop +8 -0
- data/bin/test +5 -0
- data/exe/ralph +8 -0
- data/lib/ralph/agents/base.rb +132 -0
- data/lib/ralph/agents/claude_code.rb +24 -0
- data/lib/ralph/agents/codex.rb +25 -0
- data/lib/ralph/agents/open_code.rb +30 -0
- data/lib/ralph/agents.rb +24 -0
- data/lib/ralph/cli.rb +222 -0
- data/lib/ralph/config.rb +40 -0
- data/lib/ralph/git/file_snapshot.rb +60 -0
- data/lib/ralph/helpers.rb +76 -0
- data/lib/ralph/iteration.rb +220 -0
- data/lib/ralph/loop.rb +196 -0
- data/lib/ralph/output/active_loop_error.rb +13 -0
- data/lib/ralph/output/banner.rb +29 -0
- data/lib/ralph/output/completion_deferred.rb +12 -0
- data/lib/ralph/output/completion_detected.rb +17 -0
- data/lib/ralph/output/config_summary.rb +31 -0
- data/lib/ralph/output/context_consumed.rb +11 -0
- data/lib/ralph/output/iteration.rb +45 -0
- data/lib/ralph/output/max_iterations_reached.rb +16 -0
- data/lib/ralph/output/no_plugin_warning.rb +14 -0
- data/lib/ralph/output/nonzero_exit_warning.rb +11 -0
- data/lib/ralph/output/plugin_error.rb +12 -0
- data/lib/ralph/output/status.rb +176 -0
- data/lib/ralph/output/struggle_warning.rb +18 -0
- data/lib/ralph/output/task_completion.rb +12 -0
- data/lib/ralph/output/tasks_file_created.rb +11 -0
- data/lib/ralph/prompt_template.rb +183 -0
- data/lib/ralph/storage/context.rb +58 -0
- data/lib/ralph/storage/history.rb +117 -0
- data/lib/ralph/storage/state.rb +178 -0
- data/lib/ralph/storage/tasks.rb +244 -0
- data/lib/ralph/threads/heartbeat.rb +44 -0
- data/lib/ralph/threads/stream_reader.rb +50 -0
- data/lib/ralph/version.rb +5 -0
- data/lib/ralph.rb +67 -0
- data/original/bin/ralph.js +13 -0
- data/original/ralph.ts +1706 -0
- data/ralph.gemspec +35 -0
- data/ralph2.gemspec +35 -0
- data/screenshot.webp +0 -0
- data/specs/README.md +46 -0
- data/specs/agents.md +172 -0
- data/specs/cli.md +223 -0
- data/specs/iteration.md +173 -0
- data/specs/output.md +104 -0
- data/specs/storage/local-data-structure.md +246 -0
- data/specs/tasks.md +295 -0
- metadata +150 -0
data/original/ralph.ts
ADDED
|
@@ -0,0 +1,1706 @@
|
|
|
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
|
+
});
|