@th0rgal/ralph-wiggum 1.0.1 → 1.0.2
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.
- package/README.md +12 -0
- package/cli/ralph.js +14 -0
- package/package.json +17 -2
- package/ralph.ts +310 -16
- package/.context/attachments/PR instructions.md +0 -15
- package/.context/notes.md +0 -0
- package/.context/todos.md +0 -0
- package/bun.lock +0 -21
- package/tests/ralph.test.ts +0 -260
package/README.md
CHANGED
|
@@ -142,6 +142,9 @@ Options:
|
|
|
142
142
|
--max-iterations N Stop after N iterations (default: unlimited)
|
|
143
143
|
--completion-promise T Text that signals completion (default: COMPLETE)
|
|
144
144
|
--model MODEL OpenCode model to use
|
|
145
|
+
--prompt-file, --file, -f Read prompt content from a file
|
|
146
|
+
--no-stream Buffer OpenCode output and print at the end
|
|
147
|
+
--verbose-tools Print every tool line (disable compact tool summary)
|
|
145
148
|
--no-plugins Disable non-auth OpenCode plugins for this run
|
|
146
149
|
--no-commit Don't auto-commit after iterations
|
|
147
150
|
--help Show help
|
|
@@ -176,6 +179,15 @@ Remove `ralph-wiggum` from your OpenCode `plugin` list (opencode.json), or run:
|
|
|
176
179
|
ralph "Your task" --no-plugins
|
|
177
180
|
```
|
|
178
181
|
|
|
182
|
+
### "Cannot find module '@opencode-ai/plugin'"
|
|
183
|
+
|
|
184
|
+
OpenCode is loading the `@th0rgal/ralph-wiggum` npm package, but its dependencies aren't installed.
|
|
185
|
+
Reinstall/upgrade the package so dependencies are present, or temporarily disable non-auth plugins:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
ralph "Your task" --no-plugins
|
|
189
|
+
```
|
|
190
|
+
|
|
179
191
|
## Writing Good Prompts
|
|
180
192
|
|
|
181
193
|
### Include Clear Success Criteria
|
package/cli/ralph.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const scriptPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "ralph.ts");
|
|
7
|
+
const result = spawnSync("bun", [scriptPath, ...process.argv.slice(2)], { stdio: "inherit" });
|
|
8
|
+
|
|
9
|
+
if (result.error) {
|
|
10
|
+
console.error("Error: Bun is required to run ralph. Install Bun: https://bun.sh");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
process.exit(result.status ?? 1);
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@th0rgal/ralph-wiggum",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Ralph Wiggum technique for OpenCode - iterative AI development loops with self-correcting agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": ".opencode/plugin/ralph-wiggum.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"ralph": "./ralph.
|
|
8
|
+
"ralph": "./cli/ralph.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "bun ralph.ts",
|
|
@@ -13,6 +13,21 @@
|
|
|
13
13
|
"test": "bun test ./tests/ralph.test.ts",
|
|
14
14
|
"install-local": "bun link"
|
|
15
15
|
},
|
|
16
|
+
"files": [
|
|
17
|
+
"cli/",
|
|
18
|
+
"ralph.ts",
|
|
19
|
+
".opencode/command/",
|
|
20
|
+
".opencode/plugin/",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"install.sh",
|
|
24
|
+
"install.ps1",
|
|
25
|
+
"uninstall.sh",
|
|
26
|
+
"uninstall.ps1"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@opencode-ai/plugin": "1.0.223"
|
|
30
|
+
},
|
|
16
31
|
"keywords": [
|
|
17
32
|
"opencode",
|
|
18
33
|
"ai",
|
package/ralph.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { $ } from "bun";
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
|
|
13
|
-
const VERSION = "1.0.
|
|
13
|
+
const VERSION = "1.0.2";
|
|
14
14
|
|
|
15
15
|
// Parse arguments
|
|
16
16
|
const args = process.argv.slice(2);
|
|
@@ -21,6 +21,7 @@ Ralph Wiggum Loop - Iterative AI development with OpenCode
|
|
|
21
21
|
|
|
22
22
|
Usage:
|
|
23
23
|
ralph "<prompt>" [options]
|
|
24
|
+
ralph --prompt-file <path> [options]
|
|
24
25
|
|
|
25
26
|
Arguments:
|
|
26
27
|
prompt Task description for the AI to work on
|
|
@@ -29,6 +30,9 @@ Options:
|
|
|
29
30
|
--max-iterations N Maximum iterations before stopping (default: unlimited)
|
|
30
31
|
--completion-promise TEXT Phrase that signals completion (default: COMPLETE)
|
|
31
32
|
--model MODEL Model to use (e.g., anthropic/claude-sonnet)
|
|
33
|
+
--prompt-file, --file, -f Read prompt content from a file
|
|
34
|
+
--no-stream Buffer OpenCode output and print at the end
|
|
35
|
+
--verbose-tools Print every tool line (disable compact tool summary)
|
|
32
36
|
--no-plugins Disable non-auth OpenCode plugins for this run
|
|
33
37
|
--no-commit Don't auto-commit after each iteration
|
|
34
38
|
--version, -v Show version
|
|
@@ -38,6 +42,7 @@ Examples:
|
|
|
38
42
|
ralph "Build a REST API for todos"
|
|
39
43
|
ralph "Fix the auth bug" --max-iterations 10
|
|
40
44
|
ralph "Add tests" --completion-promise "ALL TESTS PASS" --model openai/gpt-5.1
|
|
45
|
+
ralph --prompt-file ./prompt.md --max-iterations 5
|
|
41
46
|
|
|
42
47
|
How it works:
|
|
43
48
|
1. Sends your prompt to OpenCode
|
|
@@ -66,6 +71,10 @@ let completionPromise = "COMPLETE";
|
|
|
66
71
|
let model = "";
|
|
67
72
|
let autoCommit = true;
|
|
68
73
|
let disablePlugins = false;
|
|
74
|
+
let promptFile = "";
|
|
75
|
+
let streamOutput = true;
|
|
76
|
+
let verboseTools = false;
|
|
77
|
+
let promptSource = "";
|
|
69
78
|
|
|
70
79
|
const promptParts: string[] = [];
|
|
71
80
|
|
|
@@ -93,6 +102,19 @@ for (let i = 0; i < args.length; i++) {
|
|
|
93
102
|
process.exit(1);
|
|
94
103
|
}
|
|
95
104
|
model = val;
|
|
105
|
+
} else if (arg === "--prompt-file" || arg === "--file" || arg === "-f") {
|
|
106
|
+
const val = args[++i];
|
|
107
|
+
if (!val) {
|
|
108
|
+
console.error("Error: --prompt-file requires a file path");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
promptFile = val;
|
|
112
|
+
} else if (arg === "--no-stream") {
|
|
113
|
+
streamOutput = false;
|
|
114
|
+
} else if (arg === "--stream") {
|
|
115
|
+
streamOutput = true;
|
|
116
|
+
} else if (arg === "--verbose-tools") {
|
|
117
|
+
verboseTools = true;
|
|
96
118
|
} else if (arg === "--no-commit") {
|
|
97
119
|
autoCommit = false;
|
|
98
120
|
} else if (arg === "--no-plugins") {
|
|
@@ -106,7 +128,43 @@ for (let i = 0; i < args.length; i++) {
|
|
|
106
128
|
}
|
|
107
129
|
}
|
|
108
130
|
|
|
109
|
-
|
|
131
|
+
function readPromptFile(path: string): string {
|
|
132
|
+
if (!existsSync(path)) {
|
|
133
|
+
console.error(`Error: Prompt file not found: ${path}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const stat = statSync(path);
|
|
138
|
+
if (!stat.isFile()) {
|
|
139
|
+
console.error(`Error: Prompt path is not a file: ${path}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
console.error(`Error: Unable to stat prompt file: ${path}`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const content = readFileSync(path, "utf-8");
|
|
148
|
+
if (!content.trim()) {
|
|
149
|
+
console.error(`Error: Prompt file is empty: ${path}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
return content;
|
|
153
|
+
} catch {
|
|
154
|
+
console.error(`Error: Unable to read prompt file: ${path}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (promptFile) {
|
|
160
|
+
promptSource = promptFile;
|
|
161
|
+
prompt = readPromptFile(promptFile);
|
|
162
|
+
} else if (promptParts.length === 1 && existsSync(promptParts[0])) {
|
|
163
|
+
promptSource = promptParts[0];
|
|
164
|
+
prompt = readPromptFile(promptParts[0]);
|
|
165
|
+
} else {
|
|
166
|
+
prompt = promptParts.join(" ");
|
|
167
|
+
}
|
|
110
168
|
|
|
111
169
|
if (!prompt) {
|
|
112
170
|
console.error("Error: No prompt provided");
|
|
@@ -184,7 +242,7 @@ function ensureFilteredPluginsConfig(): string {
|
|
|
184
242
|
...loadPluginsFromConfig(userConfigPath),
|
|
185
243
|
...loadPluginsFromConfig(projectConfigPath),
|
|
186
244
|
];
|
|
187
|
-
const filtered = Array.from(new Set(plugins)).filter(p => p
|
|
245
|
+
const filtered = Array.from(new Set(plugins)).filter(p => /auth/i.test(p));
|
|
188
246
|
writeFileSync(
|
|
189
247
|
configPath,
|
|
190
248
|
JSON.stringify(
|
|
@@ -246,6 +304,191 @@ function detectPlaceholderPluginError(output: string): boolean {
|
|
|
246
304
|
return output.includes("ralph-wiggum is not yet ready for use. This is a placeholder package.");
|
|
247
305
|
}
|
|
248
306
|
|
|
307
|
+
function stripAnsi(input: string): string {
|
|
308
|
+
return input.replace(/\x1B\[[0-9;]*m/g, "");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function formatDuration(ms: number): string {
|
|
312
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
313
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
314
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
315
|
+
const seconds = totalSeconds % 60;
|
|
316
|
+
if (hours > 0) {
|
|
317
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
318
|
+
}
|
|
319
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function formatToolSummary(toolCounts: Map<string, number>, maxItems = 6): string {
|
|
323
|
+
if (!toolCounts.size) return "";
|
|
324
|
+
const entries = Array.from(toolCounts.entries()).sort((a, b) => b[1] - a[1]);
|
|
325
|
+
const shown = entries.slice(0, maxItems);
|
|
326
|
+
const remaining = entries.length - shown.length;
|
|
327
|
+
const parts = shown.map(([name, count]) => `${name} ${count}`);
|
|
328
|
+
if (remaining > 0) {
|
|
329
|
+
parts.push(`+${remaining} more`);
|
|
330
|
+
}
|
|
331
|
+
return parts.join(" • ");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function collectToolSummaryFromText(text: string): Map<string, number> {
|
|
335
|
+
const counts = new Map<string, number>();
|
|
336
|
+
const lines = text.split(/\r?\n/);
|
|
337
|
+
for (const line of lines) {
|
|
338
|
+
const match = stripAnsi(line).match(/^\|\s{2}([A-Za-z0-9_-]+)/);
|
|
339
|
+
if (match) {
|
|
340
|
+
const tool = match[1];
|
|
341
|
+
counts.set(tool, (counts.get(tool) ?? 0) + 1);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return counts;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function printIterationSummary(params: {
|
|
348
|
+
iteration: number;
|
|
349
|
+
elapsedMs: number;
|
|
350
|
+
toolCounts: Map<string, number>;
|
|
351
|
+
exitCode: number;
|
|
352
|
+
completionDetected: boolean;
|
|
353
|
+
}): void {
|
|
354
|
+
const toolSummary = formatToolSummary(params.toolCounts);
|
|
355
|
+
console.log("\nIteration Summary");
|
|
356
|
+
console.log("────────────────────────────────────────────────────────────────────");
|
|
357
|
+
console.log(`Iteration: ${params.iteration}`);
|
|
358
|
+
console.log(`Elapsed: ${formatDuration(params.elapsedMs)}`);
|
|
359
|
+
if (toolSummary) {
|
|
360
|
+
console.log(`Tools: ${toolSummary}`);
|
|
361
|
+
} else {
|
|
362
|
+
console.log("Tools: none");
|
|
363
|
+
}
|
|
364
|
+
console.log(`Exit code: ${params.exitCode}`);
|
|
365
|
+
console.log(`Completion promise: ${params.completionDetected ? "detected" : "not detected"}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function streamProcessOutput(
|
|
369
|
+
proc: ReturnType<typeof Bun.spawn>,
|
|
370
|
+
options: {
|
|
371
|
+
compactTools: boolean;
|
|
372
|
+
toolSummaryIntervalMs: number;
|
|
373
|
+
heartbeatIntervalMs: number;
|
|
374
|
+
iterationStart: number;
|
|
375
|
+
},
|
|
376
|
+
): Promise<{ stdoutText: string; stderrText: string; toolCounts: Map<string, number> }> {
|
|
377
|
+
const toolCounts = new Map<string, number>();
|
|
378
|
+
let stdoutText = "";
|
|
379
|
+
let stderrText = "";
|
|
380
|
+
let lastPrintedAt = Date.now();
|
|
381
|
+
let lastActivityAt = Date.now();
|
|
382
|
+
let lastToolSummaryAt = 0;
|
|
383
|
+
|
|
384
|
+
const compactTools = options.compactTools;
|
|
385
|
+
|
|
386
|
+
const maybePrintToolSummary = (force = false) => {
|
|
387
|
+
if (!compactTools || toolCounts.size === 0) return;
|
|
388
|
+
const now = Date.now();
|
|
389
|
+
if (!force && now - lastToolSummaryAt < options.toolSummaryIntervalMs) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const summary = formatToolSummary(toolCounts);
|
|
393
|
+
if (summary) {
|
|
394
|
+
console.log(`| Tools ${summary}`);
|
|
395
|
+
lastPrintedAt = Date.now();
|
|
396
|
+
lastToolSummaryAt = Date.now();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const handleLine = (line: string, isError: boolean) => {
|
|
401
|
+
lastActivityAt = Date.now();
|
|
402
|
+
const match = stripAnsi(line).match(/^\|\s{2}([A-Za-z0-9_-]+)/);
|
|
403
|
+
if (compactTools && match) {
|
|
404
|
+
const tool = match[1];
|
|
405
|
+
toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + 1);
|
|
406
|
+
maybePrintToolSummary();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (line.length === 0) {
|
|
410
|
+
console.log("");
|
|
411
|
+
lastPrintedAt = Date.now();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (isError) {
|
|
415
|
+
console.error(line);
|
|
416
|
+
} else {
|
|
417
|
+
console.log(line);
|
|
418
|
+
}
|
|
419
|
+
lastPrintedAt = Date.now();
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const streamText = async (
|
|
423
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
424
|
+
onText: (chunk: string) => void,
|
|
425
|
+
isError: boolean,
|
|
426
|
+
) => {
|
|
427
|
+
if (!stream) return;
|
|
428
|
+
const reader = stream.getReader();
|
|
429
|
+
const decoder = new TextDecoder();
|
|
430
|
+
let buffer = "";
|
|
431
|
+
while (true) {
|
|
432
|
+
const { value, done } = await reader.read();
|
|
433
|
+
if (done) break;
|
|
434
|
+
const text = decoder.decode(value, { stream: true });
|
|
435
|
+
if (text.length > 0) {
|
|
436
|
+
onText(text);
|
|
437
|
+
buffer += text;
|
|
438
|
+
const lines = buffer.split(/\r?\n/);
|
|
439
|
+
buffer = lines.pop() ?? "";
|
|
440
|
+
for (const line of lines) {
|
|
441
|
+
handleLine(line, isError);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const flushed = decoder.decode();
|
|
446
|
+
if (flushed.length > 0) {
|
|
447
|
+
onText(flushed);
|
|
448
|
+
buffer += flushed;
|
|
449
|
+
}
|
|
450
|
+
if (buffer.length > 0) {
|
|
451
|
+
handleLine(buffer, isError);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const heartbeatTimer = setInterval(() => {
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
if (now - lastPrintedAt >= options.heartbeatIntervalMs) {
|
|
458
|
+
const elapsed = formatDuration(now - options.iterationStart);
|
|
459
|
+
const sinceActivity = formatDuration(now - lastActivityAt);
|
|
460
|
+
console.log(`⏳ working... elapsed ${elapsed} · last activity ${sinceActivity} ago`);
|
|
461
|
+
lastPrintedAt = now;
|
|
462
|
+
}
|
|
463
|
+
}, options.heartbeatIntervalMs);
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
await Promise.all([
|
|
467
|
+
streamText(
|
|
468
|
+
proc.stdout,
|
|
469
|
+
chunk => {
|
|
470
|
+
stdoutText += chunk;
|
|
471
|
+
},
|
|
472
|
+
false,
|
|
473
|
+
),
|
|
474
|
+
streamText(
|
|
475
|
+
proc.stderr,
|
|
476
|
+
chunk => {
|
|
477
|
+
stderrText += chunk;
|
|
478
|
+
},
|
|
479
|
+
true,
|
|
480
|
+
),
|
|
481
|
+
]);
|
|
482
|
+
} finally {
|
|
483
|
+
clearInterval(heartbeatTimer);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (compactTools) {
|
|
487
|
+
maybePrintToolSummary(true);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { stdoutText, stderrText, toolCounts };
|
|
491
|
+
}
|
|
249
492
|
// Main loop
|
|
250
493
|
async function runRalphLoop(): Promise<void> {
|
|
251
494
|
// Check if a loop is already running
|
|
@@ -277,7 +520,13 @@ async function runRalphLoop(): Promise<void> {
|
|
|
277
520
|
|
|
278
521
|
saveState(state);
|
|
279
522
|
|
|
280
|
-
|
|
523
|
+
const promptPreview = prompt.replace(/\s+/g, " ").substring(0, 80) + (prompt.length > 80 ? "..." : "");
|
|
524
|
+
if (promptSource) {
|
|
525
|
+
console.log(`Task: ${promptSource}`);
|
|
526
|
+
console.log(`Preview: ${promptPreview}`);
|
|
527
|
+
} else {
|
|
528
|
+
console.log(`Task: ${promptPreview}`);
|
|
529
|
+
}
|
|
281
530
|
console.log(`Completion promise: ${completionPromise}`);
|
|
282
531
|
console.log(`Max iterations: ${maxIterations > 0 ? maxIterations : "unlimited"}`);
|
|
283
532
|
if (model) console.log(`Model: ${model}`);
|
|
@@ -329,6 +578,7 @@ async function runRalphLoop(): Promise<void> {
|
|
|
329
578
|
|
|
330
579
|
// Build the prompt
|
|
331
580
|
const fullPrompt = buildPrompt(state);
|
|
581
|
+
const iterationStart = Date.now();
|
|
332
582
|
|
|
333
583
|
try {
|
|
334
584
|
// Build command arguments
|
|
@@ -350,21 +600,65 @@ async function runRalphLoop(): Promise<void> {
|
|
|
350
600
|
stderr: "pipe",
|
|
351
601
|
});
|
|
352
602
|
const proc = currentProc;
|
|
603
|
+
const exitCodePromise = proc.exited;
|
|
604
|
+
let result = "";
|
|
605
|
+
let stderr = "";
|
|
606
|
+
let toolCounts = new Map<string, number>();
|
|
607
|
+
|
|
608
|
+
if (streamOutput) {
|
|
609
|
+
const streamed = await streamProcessOutput(proc, {
|
|
610
|
+
compactTools: !verboseTools,
|
|
611
|
+
toolSummaryIntervalMs: 3000,
|
|
612
|
+
heartbeatIntervalMs: 10000,
|
|
613
|
+
iterationStart,
|
|
614
|
+
});
|
|
615
|
+
result = streamed.stdoutText;
|
|
616
|
+
stderr = streamed.stderrText;
|
|
617
|
+
toolCounts = streamed.toolCounts;
|
|
618
|
+
} else {
|
|
619
|
+
const stdoutPromise = new Response(proc.stdout).text();
|
|
620
|
+
const stderrPromise = new Response(proc.stderr).text();
|
|
621
|
+
[result, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
622
|
+
toolCounts = collectToolSummaryFromText(`${result}\n${stderr}`);
|
|
623
|
+
}
|
|
353
624
|
|
|
354
|
-
const
|
|
355
|
-
const stderrPromise = new Response(proc.stderr).text();
|
|
356
|
-
const [result, stderr, exitCode] = await Promise.all([
|
|
357
|
-
stdoutPromise,
|
|
358
|
-
stderrPromise,
|
|
359
|
-
proc.exited,
|
|
360
|
-
]);
|
|
625
|
+
const exitCode = await exitCodePromise;
|
|
361
626
|
currentProc = null; // Clear reference after subprocess completes
|
|
362
627
|
|
|
363
|
-
if (
|
|
364
|
-
|
|
628
|
+
if (!streamOutput) {
|
|
629
|
+
if (stderr) {
|
|
630
|
+
console.error(stderr);
|
|
631
|
+
}
|
|
632
|
+
console.log(result);
|
|
365
633
|
}
|
|
366
634
|
|
|
367
|
-
|
|
635
|
+
const combinedOutput = `${result}\n${stderr}`;
|
|
636
|
+
const completionDetected = checkCompletion(combinedOutput, completionPromise);
|
|
637
|
+
|
|
638
|
+
printIterationSummary({
|
|
639
|
+
iteration: state.iteration,
|
|
640
|
+
elapsedMs: Date.now() - iterationStart,
|
|
641
|
+
toolCounts,
|
|
642
|
+
exitCode,
|
|
643
|
+
completionDetected,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
if (detectPlaceholderPluginError(combinedOutput)) {
|
|
647
|
+
console.error(
|
|
648
|
+
"\n❌ OpenCode tried to load the npm plugin 'ralph-wiggum', which is a placeholder package.",
|
|
649
|
+
);
|
|
650
|
+
console.error(
|
|
651
|
+
"Remove 'ralph-wiggum' from your opencode.json plugin list, or re-run with --no-plugins.",
|
|
652
|
+
);
|
|
653
|
+
clearState();
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (exitCode !== 0) {
|
|
658
|
+
console.error(`\n❌ OpenCode exited with code ${exitCode}. Stopping the loop.`);
|
|
659
|
+
clearState();
|
|
660
|
+
process.exit(exitCode);
|
|
661
|
+
}
|
|
368
662
|
|
|
369
663
|
if (detectPlaceholderPluginError(stderr) || detectPlaceholderPluginError(result)) {
|
|
370
664
|
console.error(
|
|
@@ -384,7 +678,7 @@ async function runRalphLoop(): Promise<void> {
|
|
|
384
678
|
}
|
|
385
679
|
|
|
386
680
|
// Check for completion
|
|
387
|
-
if (
|
|
681
|
+
if (completionDetected) {
|
|
388
682
|
console.log(`\n╔══════════════════════════════════════════════════════════════════╗`);
|
|
389
683
|
console.log(`║ ✅ Completion promise detected: <promise>${completionPromise}</promise>`);
|
|
390
684
|
console.log(`║ Task completed in ${state.iteration} iteration(s)`);
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
The user likes the state of the code.
|
|
2
|
-
|
|
3
|
-
There are 0 uncommitted changes.
|
|
4
|
-
The current branch is Th0rgal/fix-issue2.
|
|
5
|
-
The target branch is origin/master.
|
|
6
|
-
An upstream branch exists.
|
|
7
|
-
|
|
8
|
-
The user requested a PR.
|
|
9
|
-
|
|
10
|
-
Follow these **exact steps** to create a PR:
|
|
11
|
-
|
|
12
|
-
- Use `git diff origin/master...` to review the PR diff
|
|
13
|
-
- Use `gh pr create --base master` to create a PR onto the target branch. Keep the title under 80 characters and the description under five sentences (unless the user has given you other instructions).
|
|
14
|
-
|
|
15
|
-
If any of these steps fail, ask the user for help.
|
package/.context/notes.md
DELETED
|
File without changes
|
package/.context/todos.md
DELETED
|
File without changes
|
package/bun.lock
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"lockfileVersion": 1,
|
|
3
|
-
"configVersion": 1,
|
|
4
|
-
"workspaces": {
|
|
5
|
-
"": {
|
|
6
|
-
"name": "opencode-ralph-wiggum",
|
|
7
|
-
"devDependencies": {
|
|
8
|
-
"@types/bun": "^1.3.5",
|
|
9
|
-
},
|
|
10
|
-
},
|
|
11
|
-
},
|
|
12
|
-
"packages": {
|
|
13
|
-
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
|
14
|
-
|
|
15
|
-
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
|
16
|
-
|
|
17
|
-
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
|
18
|
-
|
|
19
|
-
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
20
|
-
}
|
|
21
|
-
}
|
package/tests/ralph.test.ts
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
|
|
5
|
-
const testDir = join(import.meta.dir, "test-workspace");
|
|
6
|
-
const stateFile = join(testDir, ".opencode", "ralph-loop.state.json");
|
|
7
|
-
|
|
8
|
-
// Helper to set up test workspace
|
|
9
|
-
function setupTestWorkspace() {
|
|
10
|
-
if (!existsSync(testDir)) {
|
|
11
|
-
mkdirSync(testDir, { recursive: true });
|
|
12
|
-
}
|
|
13
|
-
if (!existsSync(join(testDir, ".opencode"))) {
|
|
14
|
-
mkdirSync(join(testDir, ".opencode"), { recursive: true });
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Helper to clean up test workspace
|
|
19
|
-
function cleanupTestWorkspace() {
|
|
20
|
-
try {
|
|
21
|
-
if (existsSync(stateFile)) {
|
|
22
|
-
unlinkSync(stateFile);
|
|
23
|
-
}
|
|
24
|
-
} catch {}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("Ralph Wiggum State Management", () => {
|
|
28
|
-
test("should create state file with correct structure", () => {
|
|
29
|
-
setupTestWorkspace();
|
|
30
|
-
cleanupTestWorkspace();
|
|
31
|
-
|
|
32
|
-
const state = {
|
|
33
|
-
active: true,
|
|
34
|
-
iteration: 1,
|
|
35
|
-
maxIterations: 10,
|
|
36
|
-
completionPromise: "COMPLETE",
|
|
37
|
-
prompt: "Test task",
|
|
38
|
-
startedAt: new Date().toISOString(),
|
|
39
|
-
model: "",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
43
|
-
|
|
44
|
-
expect(existsSync(stateFile)).toBe(true);
|
|
45
|
-
|
|
46
|
-
const loaded = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
47
|
-
expect(loaded.active).toBe(true);
|
|
48
|
-
expect(loaded.iteration).toBe(1);
|
|
49
|
-
expect(loaded.maxIterations).toBe(10);
|
|
50
|
-
expect(loaded.completionPromise).toBe("COMPLETE");
|
|
51
|
-
expect(loaded.prompt).toBe("Test task");
|
|
52
|
-
|
|
53
|
-
cleanupTestWorkspace();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("should detect completion promise in output", () => {
|
|
57
|
-
const testCases = [
|
|
58
|
-
{ output: "<promise>COMPLETE</promise>", promise: "COMPLETE", expected: true },
|
|
59
|
-
{ output: "Some text <promise>DONE</promise> more text", promise: "DONE", expected: true },
|
|
60
|
-
{ output: "<promise> COMPLETE </promise>", promise: "COMPLETE", expected: true },
|
|
61
|
-
{ output: "No promise here", promise: "COMPLETE", expected: false },
|
|
62
|
-
{ output: "<promise>WRONG</promise>", promise: "COMPLETE", expected: false },
|
|
63
|
-
{ output: "promise>COMPLETE</promise", promise: "COMPLETE", expected: false },
|
|
64
|
-
{ output: "<promise>ALL TESTS PASS</promise>", promise: "ALL TESTS PASS", expected: true },
|
|
65
|
-
];
|
|
66
|
-
|
|
67
|
-
for (const { output, promise, expected } of testCases) {
|
|
68
|
-
const escaped = promise.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
-
const pattern = new RegExp(`<promise>\\s*${escaped}\\s*</promise>`, "i");
|
|
70
|
-
const result = pattern.test(output);
|
|
71
|
-
expect(result).toBe(expected);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("should increment iteration correctly", () => {
|
|
76
|
-
setupTestWorkspace();
|
|
77
|
-
cleanupTestWorkspace();
|
|
78
|
-
|
|
79
|
-
const state = {
|
|
80
|
-
active: true,
|
|
81
|
-
iteration: 1,
|
|
82
|
-
maxIterations: 0,
|
|
83
|
-
completionPromise: "COMPLETE",
|
|
84
|
-
prompt: "Test task",
|
|
85
|
-
startedAt: new Date().toISOString(),
|
|
86
|
-
model: "",
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
90
|
-
|
|
91
|
-
// Simulate iteration increment
|
|
92
|
-
const loaded = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
93
|
-
loaded.iteration++;
|
|
94
|
-
writeFileSync(stateFile, JSON.stringify(loaded, null, 2));
|
|
95
|
-
|
|
96
|
-
const updated = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
97
|
-
expect(updated.iteration).toBe(2);
|
|
98
|
-
|
|
99
|
-
cleanupTestWorkspace();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("should handle max iterations limit", () => {
|
|
103
|
-
const maxIterations = 5;
|
|
104
|
-
let currentIteration = 1;
|
|
105
|
-
|
|
106
|
-
while (currentIteration <= maxIterations) {
|
|
107
|
-
currentIteration++;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
expect(currentIteration).toBe(6);
|
|
111
|
-
expect(currentIteration > maxIterations).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("should build prompt with iteration context", () => {
|
|
115
|
-
const state = {
|
|
116
|
-
active: true,
|
|
117
|
-
iteration: 3,
|
|
118
|
-
maxIterations: 10,
|
|
119
|
-
completionPromise: "DONE",
|
|
120
|
-
prompt: "Build a feature",
|
|
121
|
-
startedAt: new Date().toISOString(),
|
|
122
|
-
model: "",
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const prompt = `
|
|
126
|
-
# Ralph Wiggum Loop - Iteration ${state.iteration}
|
|
127
|
-
|
|
128
|
-
## Your Task
|
|
129
|
-
|
|
130
|
-
${state.prompt}
|
|
131
|
-
|
|
132
|
-
## Instructions
|
|
133
|
-
|
|
134
|
-
When complete, output:
|
|
135
|
-
<promise>${state.completionPromise}</promise>
|
|
136
|
-
|
|
137
|
-
## Current Iteration: ${state.iteration}${state.maxIterations > 0 ? ` / ${state.maxIterations}` : " (unlimited)"}
|
|
138
|
-
`.trim();
|
|
139
|
-
|
|
140
|
-
expect(prompt).toContain("Iteration 3");
|
|
141
|
-
expect(prompt).toContain("Build a feature");
|
|
142
|
-
expect(prompt).toContain("<promise>DONE</promise>");
|
|
143
|
-
expect(prompt).toContain("3 / 10");
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe("Ralph Wiggum CLI Arguments", () => {
|
|
148
|
-
test("should parse simple prompt", () => {
|
|
149
|
-
const args = ["Build a todo app"];
|
|
150
|
-
const promptParts: string[] = [];
|
|
151
|
-
|
|
152
|
-
for (const arg of args) {
|
|
153
|
-
if (!arg.startsWith("-")) {
|
|
154
|
-
promptParts.push(arg);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
expect(promptParts.join(" ")).toBe("Build a todo app");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("should parse prompt with options", () => {
|
|
162
|
-
const args = [
|
|
163
|
-
"Build",
|
|
164
|
-
"a",
|
|
165
|
-
"todo",
|
|
166
|
-
"app",
|
|
167
|
-
"--max-iterations",
|
|
168
|
-
"10",
|
|
169
|
-
"--completion-promise",
|
|
170
|
-
"DONE",
|
|
171
|
-
];
|
|
172
|
-
|
|
173
|
-
let maxIterations = 0;
|
|
174
|
-
let completionPromise = "COMPLETE";
|
|
175
|
-
const promptParts: string[] = [];
|
|
176
|
-
|
|
177
|
-
for (let i = 0; i < args.length; i++) {
|
|
178
|
-
const arg = args[i];
|
|
179
|
-
if (arg === "--max-iterations") {
|
|
180
|
-
maxIterations = parseInt(args[++i]);
|
|
181
|
-
} else if (arg === "--completion-promise") {
|
|
182
|
-
completionPromise = args[++i];
|
|
183
|
-
} else if (!arg.startsWith("-")) {
|
|
184
|
-
promptParts.push(arg);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
expect(promptParts.join(" ")).toBe("Build a todo app");
|
|
189
|
-
expect(maxIterations).toBe(10);
|
|
190
|
-
expect(completionPromise).toBe("DONE");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("should handle empty prompt", () => {
|
|
194
|
-
const args: string[] = [];
|
|
195
|
-
const promptParts: string[] = [];
|
|
196
|
-
|
|
197
|
-
for (const arg of args) {
|
|
198
|
-
if (!arg.startsWith("-")) {
|
|
199
|
-
promptParts.push(arg);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const prompt = promptParts.join(" ");
|
|
204
|
-
expect(prompt).toBe("");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("should parse model option", () => {
|
|
208
|
-
const args = ["Test task", "--model", "anthropic/claude-sonnet"];
|
|
209
|
-
|
|
210
|
-
let model = "";
|
|
211
|
-
const promptParts: string[] = [];
|
|
212
|
-
|
|
213
|
-
for (let i = 0; i < args.length; i++) {
|
|
214
|
-
const arg = args[i];
|
|
215
|
-
if (arg === "--model") {
|
|
216
|
-
model = args[++i];
|
|
217
|
-
} else if (!arg.startsWith("-")) {
|
|
218
|
-
promptParts.push(arg);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
expect(model).toBe("anthropic/claude-sonnet");
|
|
223
|
-
expect(promptParts.join(" ")).toBe("Test task");
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
describe("OpenCode Command Files", () => {
|
|
228
|
-
const commandDir = join(import.meta.dir, "..", ".opencode", "command");
|
|
229
|
-
|
|
230
|
-
test("ralph-loop.md should exist and have correct frontmatter", () => {
|
|
231
|
-
const filePath = join(commandDir, "ralph-loop.md");
|
|
232
|
-
expect(existsSync(filePath)).toBe(true);
|
|
233
|
-
|
|
234
|
-
const content = readFileSync(filePath, "utf-8");
|
|
235
|
-
expect(content).toContain("---");
|
|
236
|
-
expect(content).toContain("description:");
|
|
237
|
-
expect(content).toContain("$ARGUMENTS");
|
|
238
|
-
expect(content).toContain("<promise>COMPLETE</promise>");
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
test("cancel-ralph.md should exist", () => {
|
|
242
|
-
const filePath = join(commandDir, "cancel-ralph.md");
|
|
243
|
-
expect(existsSync(filePath)).toBe(true);
|
|
244
|
-
|
|
245
|
-
const content = readFileSync(filePath, "utf-8");
|
|
246
|
-
expect(content).toContain("---");
|
|
247
|
-
expect(content).toContain("Cancel");
|
|
248
|
-
expect(content).toContain("ralph-loop.state.json");
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("help.md should exist and explain Ralph Wiggum", () => {
|
|
252
|
-
const filePath = join(commandDir, "help.md");
|
|
253
|
-
expect(existsSync(filePath)).toBe(true);
|
|
254
|
-
|
|
255
|
-
const content = readFileSync(filePath, "utf-8");
|
|
256
|
-
expect(content).toContain("Ralph Wiggum");
|
|
257
|
-
expect(content).toContain("Geoffrey Huntley");
|
|
258
|
-
expect(content).toContain("ghuntley.com/ralph");
|
|
259
|
-
});
|
|
260
|
-
});
|