spec-and-loop 2.0.1 → 2.1.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.
- package/QUICKSTART.md +29 -16
- package/README.md +40 -38
- package/lib/mini-ralph/errors.js +144 -9
- package/lib/mini-ralph/invoker.js +154 -32
- package/lib/mini-ralph/prompt.js +9 -0
- package/lib/mini-ralph/runner.js +479 -163
- package/lib/mini-ralph/state.js +138 -1
- package/lib/mini-ralph/status.js +159 -14
- package/package.json +1 -1
- package/scripts/ralph-run.sh +9 -38
- package/scripts/setup.js +4 -3
package/lib/mini-ralph/runner.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* isolated in a thin invoker that can be swapped in tests.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const childProcess = require('child_process');
|
|
15
|
+
const path = require('path');
|
|
15
16
|
const state = require('./state');
|
|
16
17
|
const history = require('./history');
|
|
17
18
|
const context = require('./context');
|
|
@@ -30,6 +31,74 @@ const DEFAULTS = {
|
|
|
30
31
|
verbose: false,
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
function _isFailedIteration(result) {
|
|
35
|
+
if (!result || typeof result !== 'object') return false;
|
|
36
|
+
if (result.signal !== null && result.signal !== undefined && result.signal !== '') {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return result.exitCode !== 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _wasSuccessfulIteration(result) {
|
|
43
|
+
return !_isFailedIteration(result);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _failureStageForError(err) {
|
|
47
|
+
if (!err || typeof err !== 'object') {
|
|
48
|
+
return 'invoke_contract';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (err.failureStage) {
|
|
52
|
+
return err.failureStage;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return 'invoke_contract';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _errorText(err) {
|
|
59
|
+
if (!err) return 'Unknown fatal iteration failure';
|
|
60
|
+
|
|
61
|
+
if (err.stack && typeof err.stack === 'string' && err.stack.trim()) {
|
|
62
|
+
return err.stack;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (err.message && typeof err.message === 'string' && err.message.trim()) {
|
|
66
|
+
return err.message;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return String(err);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _appendFatalIterationFailure(ralphDir, entry) {
|
|
73
|
+
errors.append(ralphDir, {
|
|
74
|
+
iteration: entry.iteration,
|
|
75
|
+
task: entry.task,
|
|
76
|
+
exitCode: entry.exitCode,
|
|
77
|
+
signal: entry.signal || '',
|
|
78
|
+
failureStage: entry.failureStage || '',
|
|
79
|
+
stderr: entry.stderr || '',
|
|
80
|
+
stdout: entry.stdout || '',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
history.append(ralphDir, {
|
|
84
|
+
iteration: entry.iteration,
|
|
85
|
+
duration: entry.duration,
|
|
86
|
+
completionDetected: false,
|
|
87
|
+
taskDetected: false,
|
|
88
|
+
toolUsage: [],
|
|
89
|
+
filesChanged: [],
|
|
90
|
+
exitCode: entry.exitCode,
|
|
91
|
+
signal: entry.signal || '',
|
|
92
|
+
failureStage: entry.failureStage || '',
|
|
93
|
+
completedTasks: [],
|
|
94
|
+
commitAttempted: false,
|
|
95
|
+
commitCreated: false,
|
|
96
|
+
commitAnomaly: '',
|
|
97
|
+
commitAnomalyType: '',
|
|
98
|
+
protectedArtifacts: [],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
33
102
|
/**
|
|
34
103
|
* Run the iteration loop.
|
|
35
104
|
*
|
|
@@ -41,164 +110,246 @@ async function run(opts) {
|
|
|
41
110
|
_validateOptions(options);
|
|
42
111
|
|
|
43
112
|
const ralphDir = options.ralphDir;
|
|
113
|
+
const runLock = state.acquireRunLock(ralphDir, {
|
|
114
|
+
tasksFile: options.tasksFile || null,
|
|
115
|
+
promptFile: options.promptFile || null,
|
|
116
|
+
tasksMode: options.tasksMode,
|
|
117
|
+
});
|
|
44
118
|
const maxIterations = options.maxIterations;
|
|
45
119
|
const minIterations = options.minIterations;
|
|
46
120
|
const completionPromise = options.completionPromise;
|
|
47
121
|
const taskPromise = options.taskPromise;
|
|
48
122
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
let stateInitialized = false;
|
|
124
|
+
let iterationCount = 0;
|
|
125
|
+
let completed = false;
|
|
126
|
+
let exitReason = 'max_iterations';
|
|
53
127
|
|
|
54
|
-
|
|
55
|
-
process.stderr.write(
|
|
56
|
-
`[mini-ralph] resuming from iteration ${resumeIteration} ` +
|
|
57
|
-
`(${resumeIteration - 1} prior iteration(s) preserved)\n`
|
|
58
|
-
);
|
|
59
|
-
}
|
|
128
|
+
try {
|
|
60
129
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
});
|
|
130
|
+
// Determine starting iteration — resume from prior state if it exists,
|
|
131
|
+
// otherwise start fresh at 1.
|
|
132
|
+
const existingState = state.read(ralphDir);
|
|
133
|
+
const resumeIteration = _resolveStartIteration(existingState, options);
|
|
78
134
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
if (options.verbose && resumeIteration > 1) {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`[mini-ralph] resuming from iteration ${resumeIteration} ` +
|
|
138
|
+
`(${resumeIteration - 1} prior iteration(s) preserved)\n`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
84
141
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
142
|
+
// Initialize state file for this run, preserving history count if resuming.
|
|
143
|
+
state.init(ralphDir, {
|
|
144
|
+
active: true,
|
|
145
|
+
iteration: resumeIteration,
|
|
146
|
+
minIterations,
|
|
147
|
+
maxIterations,
|
|
148
|
+
completionPromise,
|
|
149
|
+
taskPromise,
|
|
150
|
+
tasksMode: options.tasksMode,
|
|
151
|
+
tasksFile: options.tasksFile || null,
|
|
152
|
+
promptFile: options.promptFile || null,
|
|
153
|
+
promptTemplate: options.promptTemplate || null,
|
|
154
|
+
noCommit: options.noCommit,
|
|
155
|
+
model: options.model || '',
|
|
156
|
+
startedAt: new Date().toISOString(),
|
|
157
|
+
resumedAt: resumeIteration > 1 ? new Date().toISOString() : null,
|
|
158
|
+
completedAt: null,
|
|
159
|
+
stoppedAt: null,
|
|
160
|
+
exitReason: null,
|
|
161
|
+
});
|
|
162
|
+
stateInitialized = true;
|
|
88
163
|
|
|
89
|
-
|
|
90
|
-
|
|
164
|
+
// Synchronize .ralph/ralph-tasks.md symlink to the OpenSpec tasks.md so the
|
|
165
|
+
// loop engine always operates on the source-of-truth task file.
|
|
166
|
+
if (options.tasksMode && options.tasksFile) {
|
|
167
|
+
tasks.syncLink(ralphDir, options.tasksFile);
|
|
168
|
+
}
|
|
91
169
|
|
|
92
|
-
|
|
93
|
-
state.update(ralphDir, { iteration: iterationCount, active: true });
|
|
170
|
+
iterationCount = resumeIteration - 1;
|
|
94
171
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorContent);
|
|
172
|
+
try {
|
|
173
|
+
while (iterationCount < maxIterations) {
|
|
174
|
+
iterationCount++;
|
|
99
175
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const promptSections = [renderedPrompt];
|
|
176
|
+
// Update state with current iteration
|
|
177
|
+
state.update(ralphDir, { iteration: iterationCount, active: true });
|
|
103
178
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
179
|
+
const iterStart = Date.now();
|
|
180
|
+
const tasksBefore = options.tasksMode && options.tasksFile
|
|
181
|
+
? tasks.parseTasks(options.tasksFile)
|
|
182
|
+
: [];
|
|
183
|
+
const currentTask = _getCurrentTaskDescription(tasksBefore);
|
|
107
184
|
|
|
108
|
-
|
|
109
|
-
promptSections.push(`## Injected Context\n\n${pendingContext}`);
|
|
110
|
-
}
|
|
185
|
+
let result;
|
|
111
186
|
|
|
112
|
-
|
|
187
|
+
try {
|
|
188
|
+
// Build the prompt for this iteration
|
|
189
|
+
let renderedPrompt;
|
|
190
|
+
try {
|
|
191
|
+
renderedPrompt = await prompt.render(options, iterationCount);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
err.failureStage = err.failureStage || 'prompt_render';
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
113
196
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
? tasks.parseTasks(options.tasksFile)
|
|
117
|
-
: [];
|
|
197
|
+
const errorEntries = errors.readEntries(ralphDir, 3);
|
|
198
|
+
const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorEntries);
|
|
118
199
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
model: options.model,
|
|
123
|
-
noCommit: options.noCommit,
|
|
124
|
-
verbose: options.verbose,
|
|
125
|
-
ralphDir,
|
|
126
|
-
});
|
|
200
|
+
// Inject any pending context
|
|
201
|
+
const pendingContext = context.consume(ralphDir);
|
|
202
|
+
const promptSections = [renderedPrompt];
|
|
127
203
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
});
|
|
204
|
+
if (iterationFeedback) {
|
|
205
|
+
promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
|
|
206
|
+
}
|
|
150
207
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
iteration: iterationCount,
|
|
155
|
-
task: currentTask,
|
|
156
|
-
exitCode: result.exitCode,
|
|
157
|
-
stderr: result.stderr || '',
|
|
158
|
-
stdout: result.stdout || '',
|
|
159
|
-
});
|
|
160
|
-
}
|
|
208
|
+
if (pendingContext) {
|
|
209
|
+
promptSections.push(`## Injected Context\n\n${pendingContext}`);
|
|
210
|
+
}
|
|
161
211
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
212
|
+
const finalPrompt = promptSections.join('\n\n');
|
|
213
|
+
|
|
214
|
+
// Invoke OpenCode
|
|
215
|
+
try {
|
|
216
|
+
result = await invoker.invoke({
|
|
217
|
+
prompt: finalPrompt,
|
|
218
|
+
model: options.model,
|
|
219
|
+
noCommit: options.noCommit,
|
|
220
|
+
verbose: options.verbose,
|
|
221
|
+
ralphDir,
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
err.failureStage = err.failureStage || 'invoke_start';
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
_appendFatalIterationFailure(ralphDir, {
|
|
229
|
+
iteration: iterationCount,
|
|
230
|
+
task: currentTask,
|
|
231
|
+
duration: Date.now() - iterStart,
|
|
232
|
+
exitCode: null,
|
|
233
|
+
signal: '',
|
|
234
|
+
failureStage: _failureStageForError(err),
|
|
235
|
+
stderr: _errorText(err),
|
|
236
|
+
stdout: '',
|
|
237
|
+
});
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const duration = Date.now() - iterStart;
|
|
242
|
+
|
|
243
|
+
// Detect promises in output
|
|
244
|
+
const outputText = result.stdout || '';
|
|
245
|
+
const iterationSucceeded = _wasSuccessfulIteration(result);
|
|
246
|
+
const hasCompletion = iterationSucceeded && _containsPromise(outputText, completionPromise);
|
|
247
|
+
const hasTask = iterationSucceeded && _containsPromise(outputText, taskPromise);
|
|
248
|
+
const tasksAfter = options.tasksMode && options.tasksFile
|
|
249
|
+
? tasks.parseTasks(options.tasksFile)
|
|
250
|
+
: [];
|
|
251
|
+
const completedTasks = _completedTaskDelta(tasksBefore, tasksAfter);
|
|
252
|
+
|
|
253
|
+
let commitResult = { attempted: false, committed: false, anomaly: null };
|
|
254
|
+
|
|
255
|
+
if (_isFailedIteration(result)) {
|
|
256
|
+
errors.append(ralphDir, {
|
|
257
|
+
iteration: iterationCount,
|
|
258
|
+
task: currentTask,
|
|
259
|
+
exitCode: result.exitCode,
|
|
260
|
+
signal: result.signal || '',
|
|
261
|
+
failureStage: result.failureStage || '',
|
|
262
|
+
stderr: result.stderr || '',
|
|
263
|
+
stdout: result.stdout || '',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Auto-commit only for successful task/completion iterations.
|
|
268
|
+
if (
|
|
269
|
+
!options.noCommit &&
|
|
270
|
+
_wasSuccessfulIteration(result) &&
|
|
271
|
+
result.filesChanged &&
|
|
272
|
+
result.filesChanged.length > 0 &&
|
|
273
|
+
(hasCompletion || (options.tasksMode && hasTask))
|
|
274
|
+
) {
|
|
275
|
+
commitResult = _autoCommit(iterationCount, {
|
|
276
|
+
completedTasks,
|
|
277
|
+
filesToStage: _buildAutoCommitAllowlist(result.filesChanged, completedTasks, options.tasksFile),
|
|
278
|
+
tasksFile: options.tasksFile,
|
|
279
|
+
verbose: options.verbose,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
175
282
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
283
|
+
// Record iteration in history after commit handling so operator-visible
|
|
284
|
+
// anomalies are captured alongside the task/completion signal.
|
|
285
|
+
history.append(ralphDir, {
|
|
286
|
+
iteration: iterationCount,
|
|
287
|
+
duration,
|
|
288
|
+
completionDetected: hasCompletion,
|
|
289
|
+
taskDetected: hasTask,
|
|
290
|
+
toolUsage: result.toolUsage || [],
|
|
291
|
+
filesChanged: result.filesChanged || [],
|
|
292
|
+
exitCode: result.exitCode,
|
|
293
|
+
signal: result.signal || '',
|
|
294
|
+
failureStage: result.failureStage || '',
|
|
295
|
+
completedTasks: completedTasks.map((task) => task.fullDescription || task.description),
|
|
296
|
+
commitAttempted: commitResult.attempted,
|
|
297
|
+
commitCreated: commitResult.committed,
|
|
298
|
+
commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
|
|
299
|
+
commitAnomalyType: commitResult.anomaly ? commitResult.anomaly.type : '',
|
|
300
|
+
protectedArtifacts: commitResult.anomaly ? commitResult.anomaly.protectedArtifacts || [] : [],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Check completion condition (must also satisfy minIterations)
|
|
304
|
+
if (hasCompletion && iterationCount >= minIterations) {
|
|
305
|
+
completed = true;
|
|
306
|
+
exitReason = 'completion_promise';
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// In tasks mode, task promise just continues the loop
|
|
311
|
+
if (options.tasksMode && hasTask) {
|
|
312
|
+
// Continue to next iteration
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch (err) {
|
|
317
|
+
exitReason = 'fatal_error';
|
|
318
|
+
throw err;
|
|
181
319
|
}
|
|
182
320
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// Continue to next iteration
|
|
186
|
-
continue;
|
|
321
|
+
if (completed) {
|
|
322
|
+
_cleanupCompletedErrors(ralphDir, options.verbose);
|
|
187
323
|
}
|
|
188
|
-
}
|
|
189
324
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
325
|
+
return { completed, iterations: iterationCount, exitReason };
|
|
326
|
+
} finally {
|
|
327
|
+
if (stateInitialized) {
|
|
328
|
+
_finalizeRunState(ralphDir, { completed, exitReason });
|
|
194
329
|
}
|
|
195
|
-
|
|
330
|
+
state.releaseRunLock(ralphDir, runLock);
|
|
196
331
|
}
|
|
332
|
+
}
|
|
197
333
|
|
|
198
|
-
|
|
199
|
-
|
|
334
|
+
function _finalizeRunState(ralphDir, outcome) {
|
|
335
|
+
const now = new Date().toISOString();
|
|
336
|
+
|
|
337
|
+
if (outcome && outcome.completed) {
|
|
338
|
+
state.update(ralphDir, {
|
|
339
|
+
active: false,
|
|
340
|
+
completedAt: now,
|
|
341
|
+
stoppedAt: null,
|
|
342
|
+
exitReason: outcome.exitReason || 'completion_promise',
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
200
346
|
|
|
201
|
-
|
|
347
|
+
state.update(ralphDir, {
|
|
348
|
+
active: false,
|
|
349
|
+
completedAt: null,
|
|
350
|
+
stoppedAt: now,
|
|
351
|
+
exitReason: outcome && outcome.exitReason ? outcome.exitReason : 'stopped',
|
|
352
|
+
});
|
|
202
353
|
}
|
|
203
354
|
|
|
204
355
|
/**
|
|
@@ -210,7 +361,11 @@ async function run(opts) {
|
|
|
210
361
|
*/
|
|
211
362
|
function _containsPromise(text, promiseName) {
|
|
212
363
|
if (!text || !promiseName) return false;
|
|
213
|
-
|
|
364
|
+
|
|
365
|
+
const expectedTag = `<promise>${promiseName}</promise>`;
|
|
366
|
+
return text
|
|
367
|
+
.split(/\r?\n/)
|
|
368
|
+
.some((line) => line.trim() === expectedTag);
|
|
214
369
|
}
|
|
215
370
|
|
|
216
371
|
/**
|
|
@@ -247,38 +402,66 @@ function _validateOptions(options) {
|
|
|
247
402
|
* @param {number} iteration
|
|
248
403
|
* @param {object} opts
|
|
249
404
|
* @param {Array<object>} [opts.completedTasks]
|
|
405
|
+
* @param {Array<string>} [opts.filesToStage]
|
|
250
406
|
* @param {boolean} [opts.verbose]
|
|
251
407
|
*/
|
|
252
408
|
function _autoCommit(iteration, opts = {}) {
|
|
253
|
-
const { completedTasks = [], verbose = false } = opts;
|
|
409
|
+
const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false } = opts;
|
|
254
410
|
const message = _formatAutoCommitMessage(iteration, completedTasks);
|
|
255
411
|
|
|
256
412
|
if (!message) {
|
|
257
413
|
if (verbose) {
|
|
258
414
|
process.stderr.write('[mini-ralph] auto-commit skipped: no completed tasks detected\n');
|
|
259
415
|
}
|
|
260
|
-
return;
|
|
416
|
+
return { attempted: false, committed: false, anomaly: null };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!Array.isArray(filesToStage) || filesToStage.length === 0) {
|
|
420
|
+
if (verbose) {
|
|
421
|
+
process.stderr.write('[mini-ralph] auto-commit skipped: no iteration files to stage\n');
|
|
422
|
+
}
|
|
423
|
+
return { attempted: false, committed: false, anomaly: null };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const protectedArtifacts = _detectProtectedCommitArtifacts(filesToStage, tasksFile);
|
|
427
|
+
if (protectedArtifacts.length > 0) {
|
|
428
|
+
const anomaly = {
|
|
429
|
+
type: 'protected_artifacts',
|
|
430
|
+
message:
|
|
431
|
+
'Auto-commit blocked: loop-managed commits cannot include protected OpenSpec artifacts: ' +
|
|
432
|
+
protectedArtifacts.join(', '),
|
|
433
|
+
protectedArtifacts,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
|
|
437
|
+
return { attempted: true, committed: false, anomaly };
|
|
261
438
|
}
|
|
262
439
|
|
|
263
440
|
try {
|
|
264
|
-
execFileSync('git', ['add', '
|
|
441
|
+
childProcess.execFileSync('git', ['add', '--', ...filesToStage], {
|
|
265
442
|
stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
|
|
266
443
|
encoding: 'utf8',
|
|
267
444
|
});
|
|
268
445
|
|
|
269
|
-
const stagedFiles = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
446
|
+
const stagedFiles = childProcess.execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
270
447
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
271
448
|
encoding: 'utf8',
|
|
272
449
|
});
|
|
273
450
|
|
|
274
451
|
if (!stagedFiles.trim()) {
|
|
452
|
+
const anomaly = {
|
|
453
|
+
type: 'nothing_staged',
|
|
454
|
+
message: 'Auto-commit failed: nothing was staged after git add',
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
|
|
275
458
|
if (verbose) {
|
|
276
459
|
process.stderr.write('[mini-ralph] auto-commit skipped: nothing staged\n');
|
|
277
460
|
}
|
|
278
|
-
return;
|
|
461
|
+
return { attempted: true, committed: false, anomaly };
|
|
279
462
|
}
|
|
280
463
|
|
|
281
|
-
execFileSync('git', ['commit', '-m', message], {
|
|
464
|
+
childProcess.execFileSync('git', ['commit', '-m', message], {
|
|
282
465
|
stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
|
|
283
466
|
encoding: 'utf8',
|
|
284
467
|
});
|
|
@@ -286,12 +469,44 @@ function _autoCommit(iteration, opts = {}) {
|
|
|
286
469
|
if (verbose) {
|
|
287
470
|
process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
|
|
288
471
|
}
|
|
472
|
+
return { attempted: true, committed: true, anomaly: null };
|
|
289
473
|
} catch (err) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
474
|
+
const anomaly = {
|
|
475
|
+
type: 'commit_failed',
|
|
476
|
+
message: `Auto-commit failed: ${_gitErrorMessage(err)}`,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
|
|
480
|
+
return { attempted: true, committed: false, anomaly };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Build the explicit per-iteration git staging allowlist.
|
|
486
|
+
*
|
|
487
|
+
* @param {Array<string>} filesChanged
|
|
488
|
+
* @param {Array<object>} completedTasks
|
|
489
|
+
* @param {string|null|undefined} tasksFile
|
|
490
|
+
* @returns {Array<string>}
|
|
491
|
+
*/
|
|
492
|
+
function _buildAutoCommitAllowlist(filesChanged, completedTasks, tasksFile) {
|
|
493
|
+
const allowlist = new Set();
|
|
494
|
+
|
|
495
|
+
for (const file of filesChanged || []) {
|
|
496
|
+
const relativeFile = _repoRelativePath(file);
|
|
497
|
+
if (relativeFile) {
|
|
498
|
+
allowlist.add(relativeFile);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (Array.isArray(completedTasks) && completedTasks.length > 0 && tasksFile) {
|
|
503
|
+
const relativeTasksFile = _repoRelativePath(tasksFile);
|
|
504
|
+
if (relativeTasksFile) {
|
|
505
|
+
allowlist.add(relativeTasksFile);
|
|
293
506
|
}
|
|
294
507
|
}
|
|
508
|
+
|
|
509
|
+
return Array.from(allowlist);
|
|
295
510
|
}
|
|
296
511
|
|
|
297
512
|
/**
|
|
@@ -342,7 +557,7 @@ function _formatAutoCommitMessage(iteration, completedTasks) {
|
|
|
342
557
|
* @param {Array<object>} recentHistory
|
|
343
558
|
* @returns {string}
|
|
344
559
|
*/
|
|
345
|
-
function _buildIterationFeedback(recentHistory,
|
|
560
|
+
function _buildIterationFeedback(recentHistory, errorEntries) {
|
|
346
561
|
if (!Array.isArray(recentHistory) || recentHistory.length === 0) {
|
|
347
562
|
return '';
|
|
348
563
|
}
|
|
@@ -352,10 +567,18 @@ function _buildIterationFeedback(recentHistory, errorContent) {
|
|
|
352
567
|
for (const entry of recentHistory) {
|
|
353
568
|
const issues = [];
|
|
354
569
|
|
|
355
|
-
if (entry.
|
|
570
|
+
if (entry.signal) {
|
|
571
|
+
issues.push(`opencode exited via signal ${entry.signal}`);
|
|
572
|
+
} else if (entry.failureStage) {
|
|
573
|
+
issues.push(`iteration aborted during ${entry.failureStage}`);
|
|
574
|
+
} else if (entry.exitCode !== 0) {
|
|
356
575
|
issues.push(`opencode exited with code ${entry.exitCode}`);
|
|
357
576
|
}
|
|
358
577
|
|
|
578
|
+
if (entry.commitAnomaly) {
|
|
579
|
+
issues.push(`commit anomaly: ${entry.commitAnomaly}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
359
582
|
if (!entry.filesChanged || entry.filesChanged.length === 0) {
|
|
360
583
|
issues.push('no files changed');
|
|
361
584
|
}
|
|
@@ -367,10 +590,16 @@ function _buildIterationFeedback(recentHistory, errorContent) {
|
|
|
367
590
|
if (issues.length > 0) {
|
|
368
591
|
let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`;
|
|
369
592
|
|
|
370
|
-
if (entry
|
|
371
|
-
const errorDetails = _extractErrorForIteration(
|
|
593
|
+
if (_isFailedIteration(entry) && errorEntries) {
|
|
594
|
+
const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration);
|
|
372
595
|
if (errorDetails) {
|
|
373
596
|
line += '\n Error output:';
|
|
597
|
+
if (errorDetails.signal) {
|
|
598
|
+
line += `\n signal: ${errorDetails.signal}`;
|
|
599
|
+
}
|
|
600
|
+
if (errorDetails.failureStage) {
|
|
601
|
+
line += `\n failure stage: ${errorDetails.failureStage}`;
|
|
602
|
+
}
|
|
374
603
|
if (errorDetails.stderr) {
|
|
375
604
|
line += `\n ${errorDetails.stderr}`;
|
|
376
605
|
}
|
|
@@ -394,30 +623,24 @@ function _buildIterationFeedback(recentHistory, errorContent) {
|
|
|
394
623
|
].join('\n');
|
|
395
624
|
}
|
|
396
625
|
|
|
397
|
-
function _extractErrorForIteration(
|
|
398
|
-
if (!
|
|
399
|
-
|
|
400
|
-
const entries = errorContent.split(/^---$/m).filter((e) => e.trim());
|
|
401
|
-
|
|
402
|
-
for (const entry of entries) {
|
|
403
|
-
if (!entry.includes(`Iteration: ${iteration}`)) continue;
|
|
626
|
+
function _extractErrorForIteration(errorEntries, iteration) {
|
|
627
|
+
if (!Array.isArray(errorEntries) || errorEntries.length === 0) return null;
|
|
404
628
|
|
|
405
|
-
|
|
406
|
-
|
|
629
|
+
const match = errors.matchIteration(errorEntries, iteration);
|
|
630
|
+
if (!match) return null;
|
|
407
631
|
|
|
408
|
-
|
|
409
|
-
|
|
632
|
+
let stderr = match.stderr || '';
|
|
633
|
+
let stdout = match.stdout || '';
|
|
410
634
|
|
|
411
|
-
|
|
412
|
-
|
|
635
|
+
if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
|
|
636
|
+
if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
|
|
413
637
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return null;
|
|
638
|
+
return {
|
|
639
|
+
stderr,
|
|
640
|
+
stdout,
|
|
641
|
+
signal: match.signal || '',
|
|
642
|
+
failureStage: match.failureStage || '',
|
|
643
|
+
};
|
|
421
644
|
}
|
|
422
645
|
|
|
423
646
|
function _getCurrentTaskDescription(tasksBefore) {
|
|
@@ -427,12 +650,95 @@ function _getCurrentTaskDescription(tasksBefore) {
|
|
|
427
650
|
return 'N/A';
|
|
428
651
|
}
|
|
429
652
|
|
|
653
|
+
function _cleanupCompletedErrors(ralphDir, verbose) {
|
|
654
|
+
let archivePath = null;
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
archivePath = errors.archive(ralphDir);
|
|
658
|
+
if (archivePath && verbose) {
|
|
659
|
+
process.stderr.write(`[mini-ralph] errors archived to ${archivePath}\n`);
|
|
660
|
+
}
|
|
661
|
+
} catch (err) {
|
|
662
|
+
process.stderr.write(`[mini-ralph] warning: failed to archive error history: ${err.message}\n`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
errors.clear(ralphDir);
|
|
668
|
+
} catch (err) {
|
|
669
|
+
process.stderr.write(`[mini-ralph] warning: failed to clear active error history: ${err.message}\n`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
430
673
|
function _taskIdentity(task) {
|
|
431
674
|
return task.number
|
|
432
675
|
? `${task.number}|${task.fullDescription || task.description}`
|
|
433
676
|
: (task.fullDescription || task.description);
|
|
434
677
|
}
|
|
435
678
|
|
|
679
|
+
function _repoRelativePath(filePath) {
|
|
680
|
+
if (!filePath || typeof filePath !== 'string') return '';
|
|
681
|
+
const normalized = path.normalize(filePath);
|
|
682
|
+
if (!normalized || normalized === '.') return '';
|
|
683
|
+
const relative = path.isAbsolute(normalized)
|
|
684
|
+
? path.relative(process.cwd(), normalized)
|
|
685
|
+
: normalized;
|
|
686
|
+
|
|
687
|
+
if (!relative || relative.startsWith('..')) {
|
|
688
|
+
return '';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return relative.split(path.sep).join('/');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function _detectProtectedCommitArtifacts(filesToStage, tasksFile) {
|
|
695
|
+
if (!Array.isArray(filesToStage) || filesToStage.length === 0 || !tasksFile) {
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const relativeTasksFile = _repoRelativePath(tasksFile);
|
|
700
|
+
if (!relativeTasksFile) {
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const changeRoot = path.posix.dirname(relativeTasksFile);
|
|
705
|
+
const protectedArtifacts = [];
|
|
706
|
+
|
|
707
|
+
for (const file of filesToStage) {
|
|
708
|
+
const normalized = _repoRelativePath(file);
|
|
709
|
+
if (!normalized) continue;
|
|
710
|
+
|
|
711
|
+
const isProposal = normalized === `${changeRoot}/proposal.md`;
|
|
712
|
+
const isDesign = normalized === `${changeRoot}/design.md`;
|
|
713
|
+
const isSpec = normalized.startsWith(`${changeRoot}/specs/`) && normalized.endsWith('/spec.md');
|
|
714
|
+
|
|
715
|
+
if (isProposal || isDesign || isSpec) {
|
|
716
|
+
protectedArtifacts.push(normalized);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return protectedArtifacts;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function _gitErrorMessage(err) {
|
|
724
|
+
if (!err) return 'unknown git error';
|
|
725
|
+
|
|
726
|
+
const stderr = _coerceGitErrorStream(err.stderr);
|
|
727
|
+
const stdout = _coerceGitErrorStream(err.stdout);
|
|
728
|
+
|
|
729
|
+
if (stderr) return stderr;
|
|
730
|
+
if (stdout) return stdout;
|
|
731
|
+
if (err.message) return err.message;
|
|
732
|
+
return 'unknown git error';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function _coerceGitErrorStream(stream) {
|
|
736
|
+
if (!stream) return '';
|
|
737
|
+
if (Buffer.isBuffer(stream)) return stream.toString('utf8').trim();
|
|
738
|
+
if (typeof stream === 'string') return stream.trim();
|
|
739
|
+
return '';
|
|
740
|
+
}
|
|
741
|
+
|
|
436
742
|
/**
|
|
437
743
|
* Determine the starting iteration for a new run.
|
|
438
744
|
*
|
|
@@ -474,13 +780,23 @@ function _resolveStartIteration(existingState, options) {
|
|
|
474
780
|
|
|
475
781
|
module.exports = {
|
|
476
782
|
run,
|
|
783
|
+
_finalizeRunState,
|
|
477
784
|
_containsPromise,
|
|
478
785
|
_validateOptions,
|
|
479
786
|
_autoCommit,
|
|
787
|
+
_buildAutoCommitAllowlist,
|
|
480
788
|
_resolveStartIteration,
|
|
481
789
|
_completedTaskDelta,
|
|
482
790
|
_formatAutoCommitMessage,
|
|
483
791
|
_buildIterationFeedback,
|
|
484
792
|
_extractErrorForIteration,
|
|
485
793
|
_getCurrentTaskDescription,
|
|
794
|
+
_cleanupCompletedErrors,
|
|
795
|
+
_detectProtectedCommitArtifacts,
|
|
796
|
+
_gitErrorMessage,
|
|
797
|
+
_isFailedIteration,
|
|
798
|
+
_wasSuccessfulIteration,
|
|
799
|
+
_failureStageForError,
|
|
800
|
+
_errorText,
|
|
801
|
+
_appendFatalIterationFailure,
|
|
486
802
|
};
|