@sureshsankaran/ralph-wiggum 0.2.0 → 0.2.1

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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Ralph Wiggum Plugin for OpenCode
3
+ * Implements the Ralph Wiggum technique for iterative, self-referential AI development loops.
4
+ *
5
+ * Based on: https://github.com/anthropics/claude-code/tree/main/plugins/ralph-wiggum
6
+ *
7
+ * Usage:
8
+ * ralph-loop "Your task here" --max 8 --promise "DONE"
9
+ * ralph-loop "Your task here" --max 8 --promise "DONE" --state-file /custom/path.json
10
+ * ralph-loop "Your task here" --no-state # Disable state file
11
+ *
12
+ * The loop will:
13
+ * 1. Execute the prompt
14
+ * 2. Continue iterating until max iterations OR completion promise is found
15
+ * 3. Feed the SAME original prompt back each iteration
16
+ * 4. Show iteration count in system message
17
+ * 5. Write state to ~/.config/opencode/state/ralph-wiggum.json (or custom path) for verification
18
+ */
19
+ export default function ralphWiggum(input: {
20
+ client: any;
21
+ project: string;
22
+ worktree: string;
23
+ directory: string;
24
+ serverUrl: string;
25
+ $: any;
26
+ }): Promise<{
27
+ command: {
28
+ "ralph-loop": {
29
+ description: string;
30
+ template: string;
31
+ };
32
+ "cancel-ralph": {
33
+ description: string;
34
+ template: string;
35
+ };
36
+ "ralph-status": {
37
+ description: string;
38
+ template: string;
39
+ };
40
+ };
41
+ tool: {
42
+ "cancel-ralph": {
43
+ description: string;
44
+ args: {};
45
+ execute(_args: {}, ctx: any): Promise<"Ralph loop cancelled" | "No active Ralph loop to cancel">;
46
+ };
47
+ "ralph-status": {
48
+ description: string;
49
+ args: {};
50
+ execute(_args: {}, ctx: any): Promise<string>;
51
+ };
52
+ };
53
+ event(input: {
54
+ event: any;
55
+ }): Promise<void>;
56
+ "session.stop"(hookInput: {
57
+ sessionID: string;
58
+ step: number;
59
+ lastAssistantText?: string;
60
+ }, output: {
61
+ stop: boolean;
62
+ prompt?: string;
63
+ systemMessage?: string;
64
+ }): Promise<void>;
65
+ "experimental.chat.system.transform"(_input: {}, output: {
66
+ system: string[];
67
+ }): Promise<void>;
68
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,620 @@
1
+ /**
2
+ * Ralph Wiggum Plugin for OpenCode
3
+ * Implements the Ralph Wiggum technique for iterative, self-referential AI development loops.
4
+ *
5
+ * Based on: https://github.com/anthropics/claude-code/tree/main/plugins/ralph-wiggum
6
+ *
7
+ * Usage:
8
+ * ralph-loop "Your task here" --max 8 --promise "DONE"
9
+ * ralph-loop "Your task here" --max 8 --promise "DONE" --state-file /custom/path.json
10
+ * ralph-loop "Your task here" --no-state # Disable state file
11
+ *
12
+ * The loop will:
13
+ * 1. Execute the prompt
14
+ * 2. Continue iterating until max iterations OR completion promise is found
15
+ * 3. Feed the SAME original prompt back each iteration
16
+ * 4. Show iteration count in system message
17
+ * 5. Write state to ~/.config/opencode/state/ralph-wiggum.json (or custom path) for verification
18
+ */
19
+ import { writeFileSync, mkdirSync, unlinkSync, existsSync } from "fs";
20
+ import { homedir } from "os";
21
+ import { join, dirname } from "path";
22
+ // Default state file path
23
+ const DEFAULT_STATE_DIR = join(homedir(), ".config", "opencode", "state");
24
+ const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, "ralph-wiggum.json");
25
+ const state = {};
26
+ // Track active review session for system prompt injection
27
+ let activeReviewSession = null;
28
+ /**
29
+ * Ensure directory exists for state file
30
+ */
31
+ function ensureDir(filePath) {
32
+ try {
33
+ mkdirSync(dirname(filePath), { recursive: true });
34
+ }
35
+ catch {
36
+ // Ignore errors
37
+ }
38
+ }
39
+ /**
40
+ * Clean up existing state file on start
41
+ */
42
+ function cleanupExistingStateFile(filePath) {
43
+ try {
44
+ if (existsSync(filePath)) {
45
+ unlinkSync(filePath);
46
+ }
47
+ }
48
+ catch {
49
+ // Ignore errors
50
+ }
51
+ }
52
+ /**
53
+ * Write state to file for external verification
54
+ */
55
+ function writeStateFile(sessionID, s) {
56
+ if (!s.stateFile)
57
+ return;
58
+ try {
59
+ ensureDir(s.stateFile);
60
+ const stateData = {
61
+ sessionID,
62
+ active: s.active,
63
+ prompt: s.prompt,
64
+ promise: s.promise || "DONE",
65
+ iterations: s.iterations,
66
+ max: s.max ?? null,
67
+ remaining: s.max != null ? s.max - s.iterations : null,
68
+ startedAt: s.startedAt,
69
+ lastUpdatedAt: new Date().toISOString(),
70
+ status: s.status,
71
+ // Review system fields
72
+ phase: s.phase,
73
+ reviewCount: s.reviewCount,
74
+ maxReviews: s.maxReviews,
75
+ lastReviewFeedback: s.lastReviewFeedback || null,
76
+ };
77
+ writeFileSync(s.stateFile, JSON.stringify(stateData, null, 2));
78
+ }
79
+ catch {
80
+ // Silently ignore write errors
81
+ }
82
+ }
83
+ /**
84
+ * Write final state when loop ends
85
+ */
86
+ function writeFinalState(sessionID, s) {
87
+ if (!s.stateFile)
88
+ return;
89
+ s.lastUpdatedAt = new Date().toISOString();
90
+ writeStateFile(sessionID, s);
91
+ }
92
+ /**
93
+ * Tokenize a string respecting quoted strings.
94
+ * Handles both single and double quotes, preserving content within quotes as single tokens.
95
+ * Also handles escape sequences like \" and \\
96
+ */
97
+ function tokenize(input) {
98
+ const tokens = [];
99
+ let current = "";
100
+ let inQuote = null;
101
+ for (let i = 0; i < input.length; i++) {
102
+ const char = input[i];
103
+ // Handle escape sequences
104
+ if (char === "\\" && i + 1 < input.length) {
105
+ const nextChar = input[i + 1];
106
+ if (nextChar === '"' || nextChar === "'" || nextChar === "\\") {
107
+ // Escaped quote or backslash - add the escaped character
108
+ current += nextChar;
109
+ i++; // Skip the next character
110
+ continue;
111
+ }
112
+ }
113
+ if (inQuote) {
114
+ if (char === inQuote) {
115
+ // End of quoted section
116
+ inQuote = null;
117
+ }
118
+ else {
119
+ current += char;
120
+ }
121
+ }
122
+ else if (char === '"' || char === "'") {
123
+ // Start of quoted section
124
+ inQuote = char;
125
+ }
126
+ else if (char === " " || char === "\t") {
127
+ // Whitespace outside quotes - token boundary
128
+ if (current) {
129
+ tokens.push(current);
130
+ current = "";
131
+ }
132
+ }
133
+ else {
134
+ current += char;
135
+ }
136
+ }
137
+ // Don't forget the last token
138
+ if (current) {
139
+ tokens.push(current);
140
+ }
141
+ return tokens;
142
+ }
143
+ // Parse arguments from command invocation
144
+ // Supports: ralph-loop "prompt text with spaces" --max 5 --promise "DONE" --state-file /tmp/ralph.json --no-state
145
+ function parseArgs(args) {
146
+ let input = args.trim();
147
+ // Handle CLI double-quoting: if the entire input is wrapped in quotes with escaped inner quotes,
148
+ // strip the outer quotes and unescape the inner ones
149
+ // e.g., "\"say hello\" --max 2" -> "say hello" --max 2
150
+ if (input.startsWith('"') && input.endsWith('"') && input.length > 2) {
151
+ // Check if this looks like double-quoted input (contains escaped quotes inside)
152
+ const inner = input.slice(1, -1);
153
+ if (inner.includes('\\"')) {
154
+ // Unescape the inner quotes and use that as input
155
+ input = inner.replace(/\\"/g, '"');
156
+ }
157
+ }
158
+ const tokens = tokenize(input);
159
+ const promptParts = [];
160
+ let maxIterations = 8;
161
+ let completionPromise;
162
+ let stateFile = DEFAULT_STATE_FILE;
163
+ let noState = false;
164
+ let i = 0;
165
+ while (i < tokens.length) {
166
+ const token = tokens[i];
167
+ if (token === "--max" || token === "--max-iterations") {
168
+ maxIterations = parseInt(tokens[++i] || "8", 10);
169
+ }
170
+ else if (token === "--promise" || token === "--completion-promise") {
171
+ completionPromise = tokens[++i];
172
+ }
173
+ else if (token === "--state-file" || token === "--state") {
174
+ stateFile = tokens[++i] || DEFAULT_STATE_FILE;
175
+ }
176
+ else if (token === "--no-state") {
177
+ noState = true;
178
+ }
179
+ else {
180
+ // Accumulate as prompt
181
+ promptParts.push(token);
182
+ }
183
+ i++;
184
+ }
185
+ return {
186
+ prompt: promptParts.join(" ") || "Continue working on the task",
187
+ maxIterations,
188
+ completionPromise,
189
+ stateFile: noState ? null : stateFile,
190
+ };
191
+ }
192
+ /**
193
+ * Check if the assistant's response contains the completion promise.
194
+ * Looks for <promise>TEXT</promise> pattern where TEXT matches the expected promise.
195
+ */
196
+ function checkCompletionPromise(text, expectedPromise) {
197
+ if (!text || !expectedPromise)
198
+ return false;
199
+ // Look for <promise>TEXT</promise> pattern
200
+ const promiseRegex = /<promise>([\s\S]*?)<\/promise>/gi;
201
+ const matches = text.matchAll(promiseRegex);
202
+ for (const match of matches) {
203
+ const promiseText = match[1].trim();
204
+ if (promiseText === expectedPromise) {
205
+ return true;
206
+ }
207
+ }
208
+ return false;
209
+ }
210
+ // System prompt for REVIEW phase
211
+ const REVIEW_SYSTEM_PROMPT = `
212
+ <ralph-review-mode>
213
+ ## Code Review Mode Active
214
+
215
+ You are in CODE REVIEW mode. Be EXTREMELY THOROUGH and CRITICAL. Do not rubber-stamp changes.
216
+
217
+ ### Review Philosophy
218
+ - Assume there ARE issues until proven otherwise
219
+ - Question every design decision
220
+ - Look for what's missing, not just what's wrong
221
+ - Consider maintainability, scalability, and future implications
222
+ - A good review finds problems; an excellent review prevents future ones
223
+
224
+ ### 1. Retrieve and Inspect Changes
225
+ - Run \`git diff HEAD --stat\` to see which files changed
226
+ - Run \`git diff HEAD\` or \`git diff HEAD -- <file>\` to inspect specific changes
227
+ - Run \`git status\` to see untracked files
228
+ - Read full files if needed for context
229
+ - Do NOT skip any files - review everything
230
+
231
+ ### 2. Design Review (Be Critical)
232
+ - Is this the RIGHT solution, not just A solution?
233
+ - Are there simpler approaches that were overlooked?
234
+ - Does the architecture make sense for the problem?
235
+ - Will this scale? Is it maintainable?
236
+ - Are there unnecessary abstractions or missing abstractions?
237
+
238
+ ### 3. Spec Compliance (Be Thorough)
239
+ - Verify EVERY requirement from the original spec is addressed
240
+ - Check for missing functionality or incomplete implementations
241
+ - Look for requirements that were misunderstood
242
+ - Verify edge cases mentioned in the spec are handled
243
+
244
+ ### 4. Code Quality Review (Be Rigorous)
245
+ - Look for bugs, logic errors, race conditions, edge cases
246
+ - Check error handling - what happens when things fail?
247
+ - Review security implications (injection, auth, data exposure)
248
+ - Check for performance issues (N+1 queries, memory leaks, blocking calls)
249
+ - Verify proper input validation and sanitization
250
+ - Look for code smells and anti-patterns
251
+
252
+ ### 5. Test Recommendations
253
+ Provide SPECIFIC recommendations for:
254
+ - **Unit Tests**: Test individual functions/components in isolation
255
+ - **E2E Tests**: Tests that verify the UX exactly like a user would interact with it
256
+ - **Edge Cases**: Specific scenarios that MUST be tested
257
+
258
+ ### Required Response
259
+ After your review, you MUST respond with ONE of:
260
+ - <promise>APPROVED</promise> - Code meets ALL requirements, no critical/high priority issues, design is sound
261
+ - <promise>NEEDFIX</promise> - Issues found that must be addressed
262
+
263
+ If responding with NEEDFIX, list EVERY issue with priority:
264
+ - **CRITICAL**: Bugs, security issues, data loss risks, spec violations
265
+ - **HIGH**: Design flaws, missing error handling, performance issues
266
+ </ralph-review-mode>
267
+ `;
268
+ // System prompt for FIX phase
269
+ const FIX_SYSTEM_PROMPT = `
270
+ <ralph-fix-mode>
271
+ ## Fix Mode Active
272
+
273
+ You are addressing review comments. Your responsibilities:
274
+
275
+ ### 1. Address Review Issues
276
+ - Fix all CRITICAL and HIGH priority issues identified
277
+ - Refer to the review feedback for specific issues to address
278
+
279
+ ### 2. Implement Tests
280
+ - Add recommended unit tests
281
+ - Add recommended E2E tests that verify UX like a user would
282
+
283
+ ### 3. Verify Fixes
284
+ - Run the test suite to verify fixes work correctly
285
+ - Ensure all tests pass before marking complete
286
+
287
+ ### Required Response
288
+ When all fixes are complete AND tests pass, respond with:
289
+ <promise>DONE</promise>
290
+
291
+ This will trigger a re-review of the updated changes.
292
+ </ralph-fix-mode>
293
+ `;
294
+ function buildReviewUserPrompt(s) {
295
+ return `## Code Review Request
296
+
297
+ The code implementation is complete. Please review the uncommitted changes.
298
+
299
+ ### Original Task/Specification:
300
+ \`\`\`
301
+ ${s.prompt}
302
+ \`\`\`
303
+
304
+ ### Instructions:
305
+ 1. Run \`git diff HEAD --stat\` to see which files were modified
306
+ 2. Run \`git diff HEAD\` or \`git diff HEAD -- <file>\` to inspect the actual changes
307
+ 3. Read full files if you need more context
308
+
309
+ ### Review Checklist:
310
+ 1. **Spec Compliance**: Does this implementation fulfill the original requirements?
311
+ 2. **Code Quality**: Are there bugs, logic errors, edge cases, or security issues?
312
+ 3. **Test Coverage**: What unit tests and E2E tests should be added?
313
+
314
+ ### Required Response:
315
+ - If there are CRITICAL or HIGH priority issues that must be fixed: <promise>NEEDFIX</promise>
316
+ - If the code is acceptable and meets requirements: <promise>APPROVED</promise>`;
317
+ }
318
+ function buildFixUserPrompt(s) {
319
+ return `## Fix Review Comments
320
+
321
+ The code review identified issues that need to be addressed.
322
+
323
+ ### Review Feedback:
324
+ ${s.lastReviewFeedback}
325
+
326
+ ### Original Task/Specification:
327
+ \`\`\`
328
+ ${s.prompt}
329
+ \`\`\`
330
+
331
+ ### Instructions:
332
+ 1. Address all CRITICAL and HIGH priority issues from the review above
333
+ 2. Add the recommended unit tests
334
+ 3. Add the recommended E2E tests (tests that verify UX exactly like a user would)
335
+ 4. **Run the tests** to ensure they pass - do not skip this step
336
+ 5. Verify all fixes are working correctly
337
+
338
+ When all fixes are complete AND tests pass, respond with:
339
+ <promise>DONE</promise>`;
340
+ }
341
+ function buildReReviewUserPrompt(s) {
342
+ return `## Re-Review Request (Review #${s.reviewCount + 1})
343
+
344
+ The previous review comments have been addressed. Please verify the fixes.
345
+
346
+ ### Original Task/Specification:
347
+ \`\`\`
348
+ ${s.prompt}
349
+ \`\`\`
350
+
351
+ ### Instructions:
352
+ 1. Run \`git diff HEAD --stat\` to see current changes
353
+ 2. Run \`git diff HEAD\` to inspect the updated code
354
+ 3. Verify all previous issues are resolved
355
+ 4. Check that new changes don't introduce new issues
356
+ 5. Verify tests are adequate
357
+
358
+ ### Required Response:
359
+ - If more issues remain: <promise>NEEDFIX</promise>
360
+ - If everything is acceptable: <promise>APPROVED</promise>`;
361
+ }
362
+ export default async function ralphWiggum(input) {
363
+ return {
364
+ command: {
365
+ "ralph-loop": {
366
+ description: "Start a self-referential Ralph loop. Usage: ralph-loop <prompt> --max <iterations> --promise <text> --state-file <path>",
367
+ template: `You are now in a Ralph Wiggum iterative development loop.
368
+
369
+ The user wants you to work on the following task iteratively:
370
+ $ARGUMENTS
371
+
372
+ Instructions:
373
+ 1. Work on the task step by step
374
+ 2. After each iteration, the loop will automatically continue
375
+ 3. The loop will stop when max iterations is reached OR you output <promise>TEXT</promise> where TEXT matches the completion promise
376
+ 4. Focus on making progress with each iteration
377
+ 5. When you believe the task is complete, output <promise>COMPLETION_PROMISE_TEXT</promise>
378
+
379
+ Begin working on the task now.`,
380
+ },
381
+ "cancel-ralph": {
382
+ description: "Cancel the active Ralph loop",
383
+ template: "The Ralph loop has been cancelled. Stop the current iteration.",
384
+ },
385
+ "ralph-status": {
386
+ description: "Show the current Ralph loop status",
387
+ template: "Show the current Ralph loop status for this session.",
388
+ },
389
+ },
390
+ tool: {
391
+ "cancel-ralph": {
392
+ description: "Cancel the active Ralph loop for the current session",
393
+ args: {},
394
+ async execute(_args, ctx) {
395
+ const sessionID = ctx.sessionID;
396
+ const s = state[sessionID];
397
+ if (s) {
398
+ s.status = "cancelled";
399
+ s.active = false;
400
+ writeFinalState(sessionID, s);
401
+ delete state[sessionID];
402
+ // Clear active review session if this was it
403
+ if (activeReviewSession === sessionID) {
404
+ activeReviewSession = null;
405
+ }
406
+ return "Ralph loop cancelled";
407
+ }
408
+ return "No active Ralph loop to cancel";
409
+ },
410
+ },
411
+ "ralph-status": {
412
+ description: "Get the current Ralph loop status for the session",
413
+ args: {},
414
+ async execute(_args, ctx) {
415
+ const sessionID = ctx.sessionID;
416
+ const s = state[sessionID];
417
+ if (!s?.active) {
418
+ return "No active Ralph loop";
419
+ }
420
+ const remaining = s.max != null ? s.max - s.iterations : "unlimited";
421
+ return JSON.stringify({
422
+ active: s.active,
423
+ prompt: s.prompt,
424
+ promise: s.promise || "DONE",
425
+ iterations: s.iterations,
426
+ max: s.max ?? "unlimited",
427
+ remaining,
428
+ phase: s.phase,
429
+ reviewCount: s.reviewCount,
430
+ maxReviews: s.maxReviews,
431
+ reviewPromises: s.reviewPromises,
432
+ stateFile: s.stateFile || "none",
433
+ startedAt: s.startedAt,
434
+ status: s.status,
435
+ }, null, 2);
436
+ },
437
+ },
438
+ },
439
+ // Hook: Listen for command execution to set up the loop state
440
+ async ["event"](input) {
441
+ const event = input.event;
442
+ if (event?.type === "command.executed" && event?.properties?.name === "ralph-loop") {
443
+ const sessionID = event.properties.sessionID;
444
+ const args = parseArgs(event.properties.arguments || "");
445
+ const now = new Date().toISOString();
446
+ // Clean up existing state file on start
447
+ if (args.stateFile) {
448
+ cleanupExistingStateFile(args.stateFile);
449
+ }
450
+ state[sessionID] = {
451
+ active: true,
452
+ prompt: args.prompt,
453
+ promise: args.completionPromise || "DONE",
454
+ max: args.maxIterations,
455
+ iterations: 0,
456
+ stateFile: args.stateFile,
457
+ startedAt: now,
458
+ lastUpdatedAt: now,
459
+ status: "running",
460
+ // Review system fields
461
+ phase: "working",
462
+ reviewPromises: {
463
+ needFix: "NEEDFIX",
464
+ approved: "APPROVED",
465
+ },
466
+ reviewCount: 0,
467
+ maxReviews: 5,
468
+ };
469
+ // Write initial state
470
+ writeStateFile(sessionID, state[sessionID]);
471
+ }
472
+ },
473
+ // Hook: session.stop - called just before the session loop exits
474
+ // Modifies output.stop to control whether the loop should continue
475
+ async ["session.stop"](hookInput, output) {
476
+ const s = state[hookInput.sessionID];
477
+ if (!s?.active) {
478
+ return; // No active loop, let it stop
479
+ }
480
+ s.iterations++;
481
+ s.lastUpdatedAt = new Date().toISOString();
482
+ // WORKING PHASE
483
+ if (s.phase === "working") {
484
+ // Check for completion promise (DONE)
485
+ if (checkCompletionPromise(hookInput.lastAssistantText, s.promise)) {
486
+ // Check if this is a git repo, auto-init if not
487
+ const isGitRepo = await input.$ `git rev-parse --is-inside-work-tree`.text().catch(() => "false");
488
+ if (isGitRepo.trim() !== "true") {
489
+ // Auto-initialize git repo
490
+ await input.$ `git init`.quiet().catch(() => { });
491
+ }
492
+ // Check for changes that should trigger review
493
+ // Use git status --porcelain to detect staged, unstaged, and untracked files
494
+ const gitStatus = await input.$ `git status --porcelain`.text().catch(() => "");
495
+ const hasChanges = gitStatus.trim().length > 0;
496
+ if (!hasChanges) {
497
+ // No changes - exit normally
498
+ s.status = "completed";
499
+ s.active = false;
500
+ writeFinalState(hookInput.sessionID, s);
501
+ delete state[hookInput.sessionID];
502
+ output.stop = true;
503
+ return;
504
+ }
505
+ // Transition to REVIEW phase
506
+ s.phase = "review";
507
+ activeReviewSession = hookInput.sessionID;
508
+ output.stop = false;
509
+ output.systemMessage = `[Ralph - CODE REVIEW MODE]`;
510
+ output.prompt = buildReviewUserPrompt(s);
511
+ writeStateFile(hookInput.sessionID, s);
512
+ return;
513
+ }
514
+ // Check max iterations
515
+ if (s.max != null && s.iterations >= s.max) {
516
+ s.status = "max_reached";
517
+ s.active = false;
518
+ activeReviewSession = null;
519
+ writeFinalState(hookInput.sessionID, s);
520
+ delete state[hookInput.sessionID];
521
+ output.stop = true;
522
+ return;
523
+ }
524
+ // Continue working - existing logic
525
+ output.stop = false;
526
+ output.prompt = s.prompt;
527
+ const promiseHint = s.promise ? ` | When complete: <promise>${s.promise}</promise>` : "";
528
+ output.systemMessage = `[Ralph iteration ${s.iterations + 1}/${s.max ?? "∞"}${promiseHint}]`;
529
+ writeStateFile(hookInput.sessionID, s);
530
+ return;
531
+ }
532
+ // REVIEW PHASE
533
+ if (s.phase === "review") {
534
+ // Check for APPROVED
535
+ if (checkCompletionPromise(hookInput.lastAssistantText, s.reviewPromises.approved)) {
536
+ s.status = "approved";
537
+ s.active = false;
538
+ activeReviewSession = null;
539
+ writeFinalState(hookInput.sessionID, s);
540
+ delete state[hookInput.sessionID];
541
+ output.stop = true;
542
+ return;
543
+ }
544
+ // Check for NEEDFIX
545
+ if (checkCompletionPromise(hookInput.lastAssistantText, s.reviewPromises.needFix)) {
546
+ // Check max reviews limit
547
+ if (s.reviewCount >= s.maxReviews) {
548
+ s.status = "max_reviews_reached";
549
+ s.active = false;
550
+ activeReviewSession = null;
551
+ writeFinalState(hookInput.sessionID, s);
552
+ delete state[hookInput.sessionID];
553
+ output.stop = true;
554
+ return;
555
+ }
556
+ // Transition to FIX phase
557
+ s.phase = "fix";
558
+ s.lastReviewFeedback = hookInput.lastAssistantText;
559
+ s.reviewCount++;
560
+ output.stop = false;
561
+ output.systemMessage = `[Ralph - FIX MODE (Review #${s.reviewCount})]`;
562
+ output.prompt = buildFixUserPrompt(s);
563
+ writeStateFile(hookInput.sessionID, s);
564
+ return;
565
+ }
566
+ // Neither promise found - nudge for response
567
+ output.stop = false;
568
+ output.systemMessage = `[Ralph - REVIEW MODE - Awaiting verdict]`;
569
+ output.prompt = `Please complete your review by inspecting the changes with \`git diff HEAD\` and respond with:
570
+ - <promise>APPROVED</promise> if the code meets requirements
571
+ - <promise>NEEDFIX</promise> if there are critical/high priority issues`;
572
+ return;
573
+ }
574
+ // FIX PHASE
575
+ if (s.phase === "fix") {
576
+ // Check for DONE (fixes complete)
577
+ if (checkCompletionPromise(hookInput.lastAssistantText, s.promise)) {
578
+ // Transition back to REVIEW for re-review
579
+ s.phase = "review";
580
+ output.stop = false;
581
+ output.systemMessage = `[Ralph - RE-REVIEW MODE (Review #${s.reviewCount + 1})]`;
582
+ output.prompt = buildReReviewUserPrompt(s);
583
+ writeStateFile(hookInput.sessionID, s);
584
+ return;
585
+ }
586
+ // Check max iterations (safety valve)
587
+ if (s.max != null && s.iterations >= s.max) {
588
+ s.status = "max_reached";
589
+ s.active = false;
590
+ activeReviewSession = null;
591
+ writeFinalState(hookInput.sessionID, s);
592
+ delete state[hookInput.sessionID];
593
+ output.stop = true;
594
+ return;
595
+ }
596
+ // Continue fixing
597
+ output.stop = false;
598
+ output.systemMessage = `[Ralph - FIX MODE - Addressing review comments]`;
599
+ output.prompt = s.prompt;
600
+ return;
601
+ }
602
+ },
603
+ // Hook: experimental.chat.system.transform - inject system prompts for review/fix modes
604
+ async ["experimental.chat.system.transform"](_input, output) {
605
+ if (!activeReviewSession)
606
+ return;
607
+ const s = state[activeReviewSession];
608
+ if (!s?.active) {
609
+ activeReviewSession = null;
610
+ return;
611
+ }
612
+ if (s.phase === "review") {
613
+ output.system.push(REVIEW_SYSTEM_PROMPT);
614
+ }
615
+ else if (s.phase === "fix") {
616
+ output.system.push(FIX_SYSTEM_PROMPT);
617
+ }
618
+ },
619
+ };
620
+ }
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "@sureshsankaran/ralph-wiggum",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Ralph Wiggum plugin for OpenCode - iterative, self-referential AI development loops",
5
5
  "type": "module",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
8
17
  "keywords": [
9
18
  "opencode",
10
19
  "plugin",
@@ -21,11 +30,16 @@
21
30
  "directory": "packages/ralph-wiggum"
22
31
  },
23
32
  "scripts": {
24
- "typecheck": "tsc --noEmit"
33
+ "typecheck": "tsc --noEmit",
34
+ "build": "tsc"
25
35
  },
26
36
  "dependencies": {},
27
37
  "devDependencies": {
28
38
  "typescript": "^5.8.3",
29
- "@types/node": "^20.0.0"
39
+ "@types/node": "^20.0.0",
40
+ "@types/bun": "^1.1.0"
41
+ },
42
+ "peerDependencies": {
43
+ "bun": ">=1.0.0"
30
44
  }
31
45
  }
package/src/index.ts DELETED
@@ -1,361 +0,0 @@
1
- /**
2
- * Ralph Wiggum Plugin for OpenCode
3
- * Implements the Ralph Wiggum technique for iterative, self-referential AI development loops.
4
- *
5
- * Based on: https://github.com/anthropics/claude-code/tree/main/plugins/ralph-wiggum
6
- *
7
- * Usage:
8
- * ralph-loop "Your task here" --max 10 --promise "DONE"
9
- * ralph-loop "Your task here" --max 10 --promise "DONE" --state-file /custom/path.json
10
- * ralph-loop "Your task here" --no-state # Disable state file
11
- *
12
- * The loop will:
13
- * 1. Execute the prompt
14
- * 2. Continue iterating until max iterations OR completion promise is found
15
- * 3. Feed the SAME original prompt back each iteration
16
- * 4. Show iteration count in system message
17
- * 5. Write state to ~/.config/opencode/state/ralph-wiggum.json (or custom path) for verification
18
- */
19
-
20
- import { writeFileSync, mkdirSync, unlinkSync, existsSync } from "fs"
21
- import { homedir } from "os"
22
- import { join, dirname } from "path"
23
-
24
- // Default state file path
25
- const DEFAULT_STATE_DIR = join(homedir(), ".config", "opencode", "state")
26
- const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, "ralph-wiggum.json")
27
-
28
- type RalphState = {
29
- active: boolean
30
- prompt: string
31
- promise?: string
32
- max?: number
33
- iterations: number
34
- stateFile: string | null
35
- startedAt: string
36
- lastUpdatedAt: string
37
- status: "running" | "completed" | "cancelled" | "max_reached"
38
- }
39
-
40
- const state: Record<string, RalphState> = {}
41
-
42
- /**
43
- * Ensure directory exists for state file
44
- */
45
- function ensureDir(filePath: string): void {
46
- try {
47
- mkdirSync(dirname(filePath), { recursive: true })
48
- } catch {
49
- // Ignore errors
50
- }
51
- }
52
-
53
- /**
54
- * Clean up existing state file on start
55
- */
56
- function cleanupExistingStateFile(filePath: string): void {
57
- try {
58
- if (existsSync(filePath)) {
59
- unlinkSync(filePath)
60
- }
61
- } catch {
62
- // Ignore errors
63
- }
64
- }
65
-
66
- /**
67
- * Write state to file for external verification
68
- */
69
- function writeStateFile(sessionID: string, s: RalphState): void {
70
- if (!s.stateFile) return
71
- try {
72
- ensureDir(s.stateFile)
73
- const stateData = {
74
- sessionID,
75
- active: s.active,
76
- prompt: s.prompt,
77
- promise: s.promise || null,
78
- iterations: s.iterations,
79
- max: s.max ?? null,
80
- remaining: s.max != null ? s.max - s.iterations : null,
81
- startedAt: s.startedAt,
82
- lastUpdatedAt: new Date().toISOString(),
83
- status: s.status,
84
- }
85
- writeFileSync(s.stateFile, JSON.stringify(stateData, null, 2))
86
- } catch {
87
- // Silently ignore write errors
88
- }
89
- }
90
-
91
- /**
92
- * Write final state when loop ends
93
- */
94
- function writeFinalState(sessionID: string, s: RalphState): void {
95
- if (!s.stateFile) return
96
- s.lastUpdatedAt = new Date().toISOString()
97
- writeStateFile(sessionID, s)
98
- }
99
-
100
- /**
101
- * Tokenize a string respecting quoted strings.
102
- * Handles both single and double quotes, preserving content within quotes as single tokens.
103
- */
104
- function tokenize(input: string): string[] {
105
- const tokens: string[] = []
106
- let current = ""
107
- let inQuote: '"' | "'" | null = null
108
-
109
- for (let i = 0; i < input.length; i++) {
110
- const char = input[i]
111
-
112
- if (inQuote) {
113
- if (char === inQuote) {
114
- // End of quoted section
115
- inQuote = null
116
- } else {
117
- current += char
118
- }
119
- } else if (char === '"' || char === "'") {
120
- // Start of quoted section
121
- inQuote = char
122
- } else if (char === " " || char === "\t") {
123
- // Whitespace outside quotes - token boundary
124
- if (current) {
125
- tokens.push(current)
126
- current = ""
127
- }
128
- } else {
129
- current += char
130
- }
131
- }
132
-
133
- // Don't forget the last token
134
- if (current) {
135
- tokens.push(current)
136
- }
137
-
138
- return tokens
139
- }
140
-
141
- // Parse arguments from command invocation
142
- // Supports: ralph-loop "prompt text with spaces" --max 5 --promise "DONE" --state-file /tmp/ralph.json --no-state
143
- function parseArgs(args: string): {
144
- prompt: string
145
- maxIterations: number
146
- completionPromise?: string
147
- stateFile: string | null
148
- } {
149
- const tokens = tokenize(args.trim())
150
- const promptParts: string[] = []
151
- let maxIterations = 10
152
- let completionPromise: string | undefined
153
- let stateFile: string | null = DEFAULT_STATE_FILE
154
- let noState = false
155
-
156
- let i = 0
157
- while (i < tokens.length) {
158
- const token = tokens[i]
159
- if (token === "--max" || token === "--max-iterations") {
160
- maxIterations = parseInt(tokens[++i] || "10", 10)
161
- } else if (token === "--promise" || token === "--completion-promise") {
162
- completionPromise = tokens[++i]
163
- } else if (token === "--state-file" || token === "--state") {
164
- stateFile = tokens[++i] || DEFAULT_STATE_FILE
165
- } else if (token === "--no-state") {
166
- noState = true
167
- } else {
168
- // Accumulate as prompt
169
- promptParts.push(token)
170
- }
171
- i++
172
- }
173
-
174
- return {
175
- prompt: promptParts.join(" ") || "Continue working on the task",
176
- maxIterations,
177
- completionPromise,
178
- stateFile: noState ? null : stateFile,
179
- }
180
- }
181
-
182
- /**
183
- * Check if the assistant's response contains the completion promise.
184
- * Looks for <promise>TEXT</promise> pattern where TEXT matches the expected promise.
185
- */
186
- function checkCompletionPromise(text: string | undefined, expectedPromise: string | undefined): boolean {
187
- if (!text || !expectedPromise) return false
188
-
189
- // Look for <promise>TEXT</promise> pattern
190
- const promiseRegex = /<promise>([\s\S]*?)<\/promise>/gi
191
- const matches = text.matchAll(promiseRegex)
192
-
193
- for (const match of matches) {
194
- const promiseText = match[1].trim()
195
- if (promiseText === expectedPromise) {
196
- return true
197
- }
198
- }
199
-
200
- return false
201
- }
202
-
203
- export default async function ralphWiggum(input: {
204
- client: any
205
- project: string
206
- worktree: string
207
- directory: string
208
- serverUrl: string
209
- $: any
210
- }) {
211
- return {
212
- command: {
213
- "ralph-loop": {
214
- description:
215
- "Start a self-referential Ralph loop. Usage: ralph-loop <prompt> --max <iterations> --promise <text> --state-file <path>",
216
- template: `You are now in a Ralph Wiggum iterative development loop.
217
-
218
- The user wants you to work on the following task iteratively:
219
- $ARGUMENTS
220
-
221
- Instructions:
222
- 1. Work on the task step by step
223
- 2. After each iteration, the loop will automatically continue
224
- 3. The loop will stop when max iterations is reached OR you output <promise>TEXT</promise> where TEXT matches the completion promise
225
- 4. Focus on making progress with each iteration
226
- 5. When you believe the task is complete, output <promise>COMPLETION_PROMISE_TEXT</promise>
227
-
228
- Begin working on the task now.`,
229
- },
230
- "cancel-ralph": {
231
- description: "Cancel the active Ralph loop",
232
- template: "The Ralph loop has been cancelled. Stop the current iteration.",
233
- },
234
- "ralph-status": {
235
- description: "Show the current Ralph loop status",
236
- template: "Show the current Ralph loop status for this session.",
237
- },
238
- },
239
-
240
- tool: {
241
- "cancel-ralph": {
242
- description: "Cancel the active Ralph loop for the current session",
243
- args: {},
244
- async execute(_args: {}, ctx: any) {
245
- const sessionID = ctx.sessionID
246
- const s = state[sessionID]
247
- if (s) {
248
- s.status = "cancelled"
249
- s.active = false
250
- writeFinalState(sessionID, s)
251
- delete state[sessionID]
252
- return "Ralph loop cancelled"
253
- }
254
- return "No active Ralph loop to cancel"
255
- },
256
- },
257
- "ralph-status": {
258
- description: "Get the current Ralph loop status for the session",
259
- args: {},
260
- async execute(_args: {}, ctx: any) {
261
- const sessionID = ctx.sessionID
262
- const s = state[sessionID]
263
- if (!s?.active) {
264
- return "No active Ralph loop"
265
- }
266
- const remaining = s.max != null ? s.max - s.iterations : "unlimited"
267
- return JSON.stringify(
268
- {
269
- active: s.active,
270
- prompt: s.prompt,
271
- promise: s.promise || "none",
272
- iterations: s.iterations,
273
- max: s.max ?? "unlimited",
274
- remaining,
275
- stateFile: s.stateFile || "none",
276
- startedAt: s.startedAt,
277
- status: s.status,
278
- },
279
- null,
280
- 2,
281
- )
282
- },
283
- },
284
- },
285
-
286
- // Hook: Listen for command execution to set up the loop state
287
- async ["event"](input: { event: any }): Promise<void> {
288
- const event = input.event
289
- if (event?.type === "command.executed" && event?.properties?.name === "ralph-loop") {
290
- const sessionID = event.properties.sessionID
291
- const args = parseArgs(event.properties.arguments || "")
292
- const now = new Date().toISOString()
293
-
294
- // Clean up existing state file on start
295
- if (args.stateFile) {
296
- cleanupExistingStateFile(args.stateFile)
297
- }
298
-
299
- state[sessionID] = {
300
- active: true,
301
- prompt: args.prompt,
302
- promise: args.completionPromise,
303
- max: args.maxIterations,
304
- iterations: 0,
305
- stateFile: args.stateFile,
306
- startedAt: now,
307
- lastUpdatedAt: now,
308
- status: "running",
309
- }
310
- // Write initial state
311
- writeStateFile(sessionID, state[sessionID])
312
- }
313
- },
314
-
315
- // Hook: session.stop - called just before the session loop exits (SYNCHRONOUS)
316
- // Modifies output.stop to control whether the loop should continue
317
- ["session.stop"](
318
- hookInput: { sessionID: string; step: number; lastAssistantText?: string },
319
- output: { stop: boolean; prompt?: string; systemMessage?: string },
320
- ): void {
321
- const s = state[hookInput.sessionID]
322
- if (!s?.active) {
323
- return // No active loop, let it stop
324
- }
325
-
326
- s.iterations++
327
- s.lastUpdatedAt = new Date().toISOString()
328
-
329
- // Check for completion promise in assistant's response
330
- if (checkCompletionPromise(hookInput.lastAssistantText, s.promise)) {
331
- s.status = "completed"
332
- s.active = false
333
- writeFinalState(hookInput.sessionID, s)
334
- delete state[hookInput.sessionID]
335
- output.stop = true
336
- return
337
- }
338
-
339
- // Check max iterations
340
- if (s.max != null && s.iterations >= s.max) {
341
- s.status = "max_reached"
342
- s.active = false
343
- writeFinalState(hookInput.sessionID, s)
344
- delete state[hookInput.sessionID]
345
- output.stop = true
346
- return
347
- }
348
-
349
- // Continue the loop - feed back the SAME original prompt
350
- output.stop = false
351
- output.prompt = s.prompt
352
-
353
- // Write state update
354
- writeStateFile(hookInput.sessionID, s)
355
-
356
- // Add system message with iteration info
357
- const promiseHint = s.promise ? ` | To complete: output <promise>${s.promise}</promise>` : ""
358
- output.systemMessage = `[Ralph iteration ${s.iterations + 1}/${s.max ?? "∞"}${promiseHint}]`
359
- },
360
- }
361
- }
package/tsconfig.json DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "strict": true,
7
- "esModuleInterop": true,
8
- "skipLibCheck": true,
9
- "outDir": "dist",
10
- "declaration": true,
11
- "types": ["bun"]
12
- },
13
- "include": ["src"]
14
- }