spec-and-loop 1.0.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * runner.js - Core loop orchestrator for mini-ralph.
5
+ *
6
+ * Responsible for iteratively invoking OpenCode, tracking iteration state,
7
+ * detecting completion/task promises, and coordinating state/history writes.
8
+ *
9
+ * Implementation note: This module is intentionally structured to be testable
10
+ * with mocked OpenCode invocations. The actual child_process execution is
11
+ * isolated in a thin invoker that can be swapped in tests.
12
+ */
13
+
14
+ const { execFileSync } = require('child_process');
15
+ const state = require('./state');
16
+ const history = require('./history');
17
+ const context = require('./context');
18
+ const tasks = require('./tasks');
19
+ const prompt = require('./prompt');
20
+ const invoker = require('./invoker');
21
+ const errors = require('./errors');
22
+
23
+ const DEFAULTS = {
24
+ minIterations: 1,
25
+ maxIterations: 50,
26
+ completionPromise: 'COMPLETE',
27
+ taskPromise: 'READY_FOR_NEXT_TASK',
28
+ tasksMode: false,
29
+ noCommit: false,
30
+ verbose: false,
31
+ };
32
+
33
+ /**
34
+ * Run the iteration loop.
35
+ *
36
+ * @param {object} opts - Loop options (see index.js for full schema)
37
+ * @returns {Promise<object>} { completed, iterations, exitReason }
38
+ */
39
+ async function run(opts) {
40
+ const options = Object.assign({}, DEFAULTS, opts);
41
+ _validateOptions(options);
42
+
43
+ const ralphDir = options.ralphDir;
44
+ const maxIterations = options.maxIterations;
45
+ const minIterations = options.minIterations;
46
+ const completionPromise = options.completionPromise;
47
+ const taskPromise = options.taskPromise;
48
+
49
+ // Determine starting iteration — resume from prior state if it exists,
50
+ // otherwise start fresh at 1.
51
+ const existingState = state.read(ralphDir);
52
+ const resumeIteration = _resolveStartIteration(existingState, options);
53
+
54
+ if (options.verbose && resumeIteration > 1) {
55
+ process.stderr.write(
56
+ `[mini-ralph] resuming from iteration ${resumeIteration} ` +
57
+ `(${resumeIteration - 1} prior iteration(s) preserved)\n`
58
+ );
59
+ }
60
+
61
+ // Initialize state file for this run, preserving history count if resuming.
62
+ state.init(ralphDir, {
63
+ active: true,
64
+ iteration: resumeIteration,
65
+ minIterations,
66
+ maxIterations,
67
+ completionPromise,
68
+ taskPromise,
69
+ tasksMode: options.tasksMode,
70
+ tasksFile: options.tasksFile || null,
71
+ promptFile: options.promptFile || null,
72
+ promptTemplate: options.promptTemplate || null,
73
+ noCommit: options.noCommit,
74
+ model: options.model || '',
75
+ startedAt: new Date().toISOString(),
76
+ resumedAt: resumeIteration > 1 ? new Date().toISOString() : null,
77
+ });
78
+
79
+ // Synchronize .ralph/ralph-tasks.md symlink to the OpenSpec tasks.md so the
80
+ // loop engine always operates on the source-of-truth task file.
81
+ if (options.tasksMode && options.tasksFile) {
82
+ tasks.syncLink(ralphDir, options.tasksFile);
83
+ }
84
+
85
+ let iterationCount = resumeIteration - 1;
86
+ let completed = false;
87
+ let exitReason = 'max_iterations';
88
+
89
+ while (iterationCount < maxIterations) {
90
+ iterationCount++;
91
+
92
+ // Update state with current iteration
93
+ state.update(ralphDir, { iteration: iterationCount, active: true });
94
+
95
+ // Build the prompt for this iteration
96
+ const renderedPrompt = await prompt.render(options, iterationCount);
97
+ const errorEntries = errors.readEntries(ralphDir, 3);
98
+ const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorEntries);
99
+
100
+ // Inject any pending context
101
+ const pendingContext = context.consume(ralphDir);
102
+ const promptSections = [renderedPrompt];
103
+
104
+ if (iterationFeedback) {
105
+ promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
106
+ }
107
+
108
+ if (pendingContext) {
109
+ promptSections.push(`## Injected Context\n\n${pendingContext}`);
110
+ }
111
+
112
+ const finalPrompt = promptSections.join('\n\n');
113
+
114
+ const iterStart = Date.now();
115
+ const tasksBefore = options.tasksMode && options.tasksFile
116
+ ? tasks.parseTasks(options.tasksFile)
117
+ : [];
118
+
119
+ // Invoke OpenCode
120
+ const result = await invoker.invoke({
121
+ prompt: finalPrompt,
122
+ model: options.model,
123
+ noCommit: options.noCommit,
124
+ verbose: options.verbose,
125
+ ralphDir,
126
+ });
127
+
128
+ const duration = Date.now() - iterStart;
129
+
130
+ // Detect promises in output
131
+ const outputText = result.stdout || '';
132
+ const hasCompletion = _containsPromise(outputText, completionPromise);
133
+ const hasTask = _containsPromise(outputText, taskPromise);
134
+ const tasksAfter = options.tasksMode && options.tasksFile
135
+ ? tasks.parseTasks(options.tasksFile)
136
+ : [];
137
+ const completedTasks = _completedTaskDelta(tasksBefore, tasksAfter);
138
+
139
+ // Record iteration in history
140
+ history.append(ralphDir, {
141
+ iteration: iterationCount,
142
+ duration,
143
+ completionDetected: hasCompletion,
144
+ taskDetected: hasTask,
145
+ toolUsage: result.toolUsage || [],
146
+ filesChanged: result.filesChanged || [],
147
+ exitCode: result.exitCode,
148
+ completedTasks: completedTasks.map((task) => task.fullDescription || task.description),
149
+ });
150
+
151
+ if (result.exitCode !== 0) {
152
+ const currentTask = _getCurrentTaskDescription(tasksBefore);
153
+ errors.append(ralphDir, {
154
+ iteration: iterationCount,
155
+ task: currentTask,
156
+ exitCode: result.exitCode,
157
+ stderr: result.stderr || '',
158
+ stdout: result.stdout || '',
159
+ });
160
+ }
161
+
162
+ // Auto-commit only for successful task/completion iterations.
163
+ if (
164
+ !options.noCommit &&
165
+ result.exitCode === 0 &&
166
+ result.filesChanged &&
167
+ result.filesChanged.length > 0 &&
168
+ (hasCompletion || (options.tasksMode && hasTask))
169
+ ) {
170
+ _autoCommit(iterationCount, {
171
+ completedTasks,
172
+ verbose: options.verbose,
173
+ });
174
+ }
175
+
176
+ // Check completion condition (must also satisfy minIterations)
177
+ if (hasCompletion && iterationCount >= minIterations) {
178
+ completed = true;
179
+ exitReason = 'completion_promise';
180
+ break;
181
+ }
182
+
183
+ // In tasks mode, task promise just continues the loop
184
+ if (options.tasksMode && hasTask) {
185
+ // Continue to next iteration
186
+ continue;
187
+ }
188
+ }
189
+
190
+ try {
191
+ if (completed) {
192
+ _cleanupCompletedErrors(ralphDir, options.verbose);
193
+ }
194
+ } finally {
195
+ // A completed loop must not remain marked active because post-completion
196
+ // cleanup failed.
197
+ state.update(ralphDir, { active: false, completedAt: new Date().toISOString() });
198
+ }
199
+
200
+ return { completed, iterations: iterationCount, exitReason };
201
+ }
202
+
203
+ /**
204
+ * Check whether a promise tag appears in output text.
205
+ *
206
+ * @param {string} text
207
+ * @param {string} promiseName
208
+ * @returns {boolean}
209
+ */
210
+ function _containsPromise(text, promiseName) {
211
+ if (!text || !promiseName) return false;
212
+ return text.includes(`<promise>${promiseName}</promise>`);
213
+ }
214
+
215
+ /**
216
+ * Validate required options and throw descriptive errors.
217
+ *
218
+ * @param {object} options
219
+ */
220
+ function _validateOptions(options) {
221
+ if (!options.ralphDir) {
222
+ throw new Error('mini-ralph runner: options.ralphDir is required');
223
+ }
224
+ if (!options.promptFile && !options.promptText) {
225
+ throw new Error('mini-ralph runner: either options.promptFile or options.promptText is required');
226
+ }
227
+ if (options.promptFile && options.promptText) {
228
+ throw new Error('mini-ralph runner: provide either options.promptFile or options.promptText, not both');
229
+ }
230
+ if (typeof options.maxIterations !== 'number' || options.maxIterations < 1) {
231
+ throw new Error('mini-ralph runner: options.maxIterations must be a positive integer');
232
+ }
233
+ if (typeof options.minIterations !== 'number' || options.minIterations < 1) {
234
+ throw new Error('mini-ralph runner: options.minIterations must be a positive integer');
235
+ }
236
+ if (options.minIterations > options.maxIterations) {
237
+ throw new Error('mini-ralph runner: options.minIterations must be <= options.maxIterations');
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Auto-commit changed files after a successful iteration.
243
+ * Silently skips if git is unavailable, there is nothing to commit, or the
244
+ * iteration did not complete any tasks.
245
+ *
246
+ * @param {number} iteration
247
+ * @param {object} opts
248
+ * @param {Array<object>} [opts.completedTasks]
249
+ * @param {boolean} [opts.verbose]
250
+ */
251
+ function _autoCommit(iteration, opts = {}) {
252
+ const { completedTasks = [], verbose = false } = opts;
253
+ const message = _formatAutoCommitMessage(iteration, completedTasks);
254
+
255
+ if (!message) {
256
+ if (verbose) {
257
+ process.stderr.write('[mini-ralph] auto-commit skipped: no completed tasks detected\n');
258
+ }
259
+ return;
260
+ }
261
+
262
+ try {
263
+ execFileSync('git', ['add', '-A'], {
264
+ stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
265
+ encoding: 'utf8',
266
+ });
267
+
268
+ const stagedFiles = execFileSync('git', ['diff', '--cached', '--name-only'], {
269
+ stdio: ['pipe', 'pipe', 'pipe'],
270
+ encoding: 'utf8',
271
+ });
272
+
273
+ if (!stagedFiles.trim()) {
274
+ if (verbose) {
275
+ process.stderr.write('[mini-ralph] auto-commit skipped: nothing staged\n');
276
+ }
277
+ return;
278
+ }
279
+
280
+ execFileSync('git', ['commit', '-m', message], {
281
+ stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
282
+ encoding: 'utf8',
283
+ });
284
+
285
+ if (verbose) {
286
+ process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
287
+ }
288
+ } catch (err) {
289
+ // Auto-commit is best-effort; don't fail the loop if commit fails
290
+ if (verbose) {
291
+ process.stderr.write(`[mini-ralph] auto-commit skipped: ${err.message}\n`);
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Return tasks that became completed during the current iteration.
298
+ *
299
+ * @param {Array<object>} beforeTasks
300
+ * @param {Array<object>} afterTasks
301
+ * @returns {Array<object>}
302
+ */
303
+ function _completedTaskDelta(beforeTasks, afterTasks) {
304
+ const beforeCompleted = new Set(
305
+ (beforeTasks || [])
306
+ .filter((task) => task.status === 'completed')
307
+ .map(_taskIdentity)
308
+ );
309
+
310
+ return (afterTasks || []).filter(
311
+ (task) => task.status === 'completed' && !beforeCompleted.has(_taskIdentity(task))
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Build a task-aware commit message for an iteration.
317
+ *
318
+ * @param {number} iteration
319
+ * @param {Array<object>} completedTasks
320
+ * @returns {string}
321
+ */
322
+ function _formatAutoCommitMessage(iteration, completedTasks) {
323
+ if (!Array.isArray(completedTasks) || completedTasks.length === 0) {
324
+ return '';
325
+ }
326
+
327
+ const summary = completedTasks.length === 1
328
+ ? completedTasks[0].description
329
+ : `complete ${completedTasks.length} tasks`;
330
+ const taskLines = completedTasks.map(
331
+ (task) => `- [x] ${task.fullDescription || task.description}`
332
+ );
333
+
334
+ return `Ralph iteration ${iteration}: ${summary}\n\nTasks completed:\n${taskLines.join('\n')}`;
335
+ }
336
+
337
+ /**
338
+ * Summarize recent problem signals so the next iteration can avoid repeating
339
+ * the same failed approach.
340
+ *
341
+ * @param {Array<object>} recentHistory
342
+ * @returns {string}
343
+ */
344
+ function _buildIterationFeedback(recentHistory, errorEntries) {
345
+ if (!Array.isArray(recentHistory) || recentHistory.length === 0) {
346
+ return '';
347
+ }
348
+
349
+ const problemLines = [];
350
+
351
+ for (const entry of recentHistory) {
352
+ const issues = [];
353
+
354
+ if (entry.exitCode !== 0) {
355
+ issues.push(`opencode exited with code ${entry.exitCode}`);
356
+ }
357
+
358
+ if (!entry.filesChanged || entry.filesChanged.length === 0) {
359
+ issues.push('no files changed');
360
+ }
361
+
362
+ if (!entry.completionDetected && !entry.taskDetected) {
363
+ issues.push('no loop promise emitted');
364
+ }
365
+
366
+ if (issues.length > 0) {
367
+ let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`;
368
+
369
+ if (entry.exitCode !== 0 && errorEntries) {
370
+ const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration);
371
+ if (errorDetails) {
372
+ line += '\n Error output:';
373
+ if (errorDetails.stderr) {
374
+ line += `\n ${errorDetails.stderr}`;
375
+ }
376
+ if (errorDetails.stdout) {
377
+ line += `\n stdout: ${errorDetails.stdout}`;
378
+ }
379
+ }
380
+ }
381
+
382
+ problemLines.push(line);
383
+ }
384
+ }
385
+
386
+ if (problemLines.length === 0) {
387
+ return '';
388
+ }
389
+
390
+ return [
391
+ 'Use these signals to avoid repeating the same failed approach:',
392
+ ...problemLines,
393
+ ].join('\n');
394
+ }
395
+
396
+ function _extractErrorForIteration(errorEntries, iteration) {
397
+ if (!Array.isArray(errorEntries) || errorEntries.length === 0) return null;
398
+
399
+ const match = errorEntries.find((entry) => entry.iteration === iteration);
400
+ if (!match) return null;
401
+
402
+ let stderr = match.stderr || '';
403
+ let stdout = match.stdout || '';
404
+
405
+ if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
406
+ if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
407
+
408
+ return { stderr, stdout };
409
+ }
410
+
411
+ function _getCurrentTaskDescription(tasksBefore) {
412
+ if (!Array.isArray(tasksBefore) || tasksBefore.length === 0) return 'N/A';
413
+ const incomplete = tasksBefore.find((t) => t.status !== 'completed');
414
+ if (incomplete) return incomplete.fullDescription || incomplete.description || 'N/A';
415
+ return 'N/A';
416
+ }
417
+
418
+ function _cleanupCompletedErrors(ralphDir, verbose) {
419
+ let archivePath = null;
420
+
421
+ try {
422
+ archivePath = errors.archive(ralphDir);
423
+ if (archivePath && verbose) {
424
+ process.stderr.write(`[mini-ralph] errors archived to ${archivePath}\n`);
425
+ }
426
+ } catch (err) {
427
+ process.stderr.write(`[mini-ralph] warning: failed to archive error history: ${err.message}\n`);
428
+ return;
429
+ }
430
+
431
+ try {
432
+ errors.clear(ralphDir);
433
+ } catch (err) {
434
+ process.stderr.write(`[mini-ralph] warning: failed to clear active error history: ${err.message}\n`);
435
+ }
436
+ }
437
+
438
+ function _taskIdentity(task) {
439
+ return task.number
440
+ ? `${task.number}|${task.fullDescription || task.description}`
441
+ : (task.fullDescription || task.description);
442
+ }
443
+
444
+ /**
445
+ * Determine the starting iteration for a new run.
446
+ *
447
+ * If a previous state exists for the same tasks file (or the same ralphDir when
448
+ * not in tasks mode), resume from the next iteration after the last recorded one.
449
+ * Otherwise start fresh at 1.
450
+ *
451
+ * Resume conditions:
452
+ * - There is a prior state file with a recorded iteration > 0
453
+ * - The prior run used the same tasksFile (when in tasks mode) — this prevents
454
+ * resuming across different changes that happen to share a .ralph/ directory.
455
+ * - The prior run was not marked as active (i.e. it ended cleanly or was interrupted)
456
+ *
457
+ * When resuming, the iteration counter starts at (priorIteration + 1), which
458
+ * preserves the loop budget while aligning the displayed number with task progress.
459
+ *
460
+ * @param {object|null} existingState
461
+ * @param {object} options
462
+ * @returns {number} 1-based starting iteration
463
+ */
464
+ function _resolveStartIteration(existingState, options) {
465
+ if (!existingState) return 1;
466
+
467
+ const priorIteration = existingState.iteration;
468
+ if (typeof priorIteration !== 'number' || priorIteration < 1) return 1;
469
+
470
+ // In tasks mode, only resume if the prior run used the same tasks file.
471
+ if (options.tasksMode && options.tasksFile) {
472
+ const priorTasksFile = existingState.tasksFile || null;
473
+ if (priorTasksFile && priorTasksFile !== options.tasksFile) {
474
+ // Different tasks file — treat as a fresh run.
475
+ return 1;
476
+ }
477
+ }
478
+
479
+ // Resume from the iteration after the last recorded one.
480
+ return priorIteration + 1;
481
+ }
482
+
483
+ module.exports = {
484
+ run,
485
+ _containsPromise,
486
+ _validateOptions,
487
+ _autoCommit,
488
+ _resolveStartIteration,
489
+ _completedTaskDelta,
490
+ _formatAutoCommitMessage,
491
+ _buildIterationFeedback,
492
+ _extractErrorForIteration,
493
+ _getCurrentTaskDescription,
494
+ _cleanupCompletedErrors,
495
+ };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * state.js - Loop state persistence for mini-ralph.
5
+ *
6
+ * Manages reading and writing ralph-loop.state.json under the .ralph/ directory.
7
+ * This file tracks active loop metadata so status commands and resume logic
8
+ * can read the current loop condition without rerunning.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const STATE_FILE = 'ralph-loop.state.json';
15
+
16
+ /**
17
+ * Return the absolute path to the state file.
18
+ *
19
+ * @param {string} ralphDir
20
+ * @returns {string}
21
+ */
22
+ function statePath(ralphDir) {
23
+ return path.join(ralphDir, STATE_FILE);
24
+ }
25
+
26
+ /**
27
+ * Initialize the state file with the provided data.
28
+ * Creates ralphDir if it does not exist.
29
+ *
30
+ * @param {string} ralphDir
31
+ * @param {object} data
32
+ */
33
+ function init(ralphDir, data) {
34
+ _ensureDir(ralphDir);
35
+ _write(ralphDir, data);
36
+ }
37
+
38
+ /**
39
+ * Read the current state. Returns null if the state file does not exist.
40
+ *
41
+ * @param {string} ralphDir
42
+ * @returns {object|null}
43
+ */
44
+ function read(ralphDir) {
45
+ const file = statePath(ralphDir);
46
+ if (!fs.existsSync(file)) return null;
47
+ try {
48
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Merge the provided fields into the existing state.
56
+ * If no state file exists, creates one.
57
+ *
58
+ * @param {string} ralphDir
59
+ * @param {object} updates
60
+ */
61
+ function update(ralphDir, updates) {
62
+ const current = read(ralphDir) || {};
63
+ _write(ralphDir, Object.assign({}, current, updates));
64
+ }
65
+
66
+ /**
67
+ * Delete the state file.
68
+ *
69
+ * @param {string} ralphDir
70
+ */
71
+ function remove(ralphDir) {
72
+ const file = statePath(ralphDir);
73
+ if (fs.existsSync(file)) {
74
+ fs.unlinkSync(file);
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Internal helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function _ensureDir(ralphDir) {
83
+ if (!fs.existsSync(ralphDir)) {
84
+ fs.mkdirSync(ralphDir, { recursive: true });
85
+ }
86
+ }
87
+
88
+ function _write(ralphDir, data) {
89
+ _ensureDir(ralphDir);
90
+ fs.writeFileSync(statePath(ralphDir), JSON.stringify(data, null, 2), 'utf8');
91
+ }
92
+
93
+ module.exports = { init, read, update, remove, statePath };