spec-and-loop 2.1.0 → 2.1.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/QUICKSTART.md +29 -16
- package/README.md +40 -38
- package/lib/mini-ralph/errors.js +118 -25
- package/lib/mini-ralph/invoker.js +131 -26
- package/lib/mini-ralph/prompt.js +9 -0
- package/lib/mini-ralph/runner.js +448 -141
- package/lib/mini-ralph/state.js +138 -1
- package/lib/mini-ralph/status.js +142 -10
- package/package.json +5 -5
- 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,163 +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), errorEntries);
|
|
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
|
+
}
|
|
175
240
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
}
|
|
182
282
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
187
319
|
}
|
|
188
|
-
}
|
|
189
320
|
|
|
190
|
-
try {
|
|
191
321
|
if (completed) {
|
|
192
322
|
_cleanupCompletedErrors(ralphDir, options.verbose);
|
|
193
323
|
}
|
|
324
|
+
|
|
325
|
+
return { completed, iterations: iterationCount, exitReason };
|
|
194
326
|
} finally {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
327
|
+
if (stateInitialized) {
|
|
328
|
+
_finalizeRunState(ralphDir, { completed, exitReason });
|
|
329
|
+
}
|
|
330
|
+
state.releaseRunLock(ralphDir, runLock);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
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;
|
|
198
345
|
}
|
|
199
346
|
|
|
200
|
-
|
|
347
|
+
state.update(ralphDir, {
|
|
348
|
+
active: false,
|
|
349
|
+
completedAt: null,
|
|
350
|
+
stoppedAt: now,
|
|
351
|
+
exitReason: outcome && outcome.exitReason ? outcome.exitReason : 'stopped',
|
|
352
|
+
});
|
|
201
353
|
}
|
|
202
354
|
|
|
203
355
|
/**
|
|
@@ -209,7 +361,11 @@ async function run(opts) {
|
|
|
209
361
|
*/
|
|
210
362
|
function _containsPromise(text, promiseName) {
|
|
211
363
|
if (!text || !promiseName) return false;
|
|
212
|
-
|
|
364
|
+
|
|
365
|
+
const expectedTag = `<promise>${promiseName}</promise>`;
|
|
366
|
+
return text
|
|
367
|
+
.split(/\r?\n/)
|
|
368
|
+
.some((line) => line.trim() === expectedTag);
|
|
213
369
|
}
|
|
214
370
|
|
|
215
371
|
/**
|
|
@@ -246,38 +402,66 @@ function _validateOptions(options) {
|
|
|
246
402
|
* @param {number} iteration
|
|
247
403
|
* @param {object} opts
|
|
248
404
|
* @param {Array<object>} [opts.completedTasks]
|
|
405
|
+
* @param {Array<string>} [opts.filesToStage]
|
|
249
406
|
* @param {boolean} [opts.verbose]
|
|
250
407
|
*/
|
|
251
408
|
function _autoCommit(iteration, opts = {}) {
|
|
252
|
-
const { completedTasks = [], verbose = false } = opts;
|
|
409
|
+
const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false } = opts;
|
|
253
410
|
const message = _formatAutoCommitMessage(iteration, completedTasks);
|
|
254
411
|
|
|
255
412
|
if (!message) {
|
|
256
413
|
if (verbose) {
|
|
257
414
|
process.stderr.write('[mini-ralph] auto-commit skipped: no completed tasks detected\n');
|
|
258
415
|
}
|
|
259
|
-
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 };
|
|
260
438
|
}
|
|
261
439
|
|
|
262
440
|
try {
|
|
263
|
-
execFileSync('git', ['add', '
|
|
441
|
+
childProcess.execFileSync('git', ['add', '--', ...filesToStage], {
|
|
264
442
|
stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
|
|
265
443
|
encoding: 'utf8',
|
|
266
444
|
});
|
|
267
445
|
|
|
268
|
-
const stagedFiles = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
446
|
+
const stagedFiles = childProcess.execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
269
447
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
270
448
|
encoding: 'utf8',
|
|
271
449
|
});
|
|
272
450
|
|
|
273
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`);
|
|
274
458
|
if (verbose) {
|
|
275
459
|
process.stderr.write('[mini-ralph] auto-commit skipped: nothing staged\n');
|
|
276
460
|
}
|
|
277
|
-
return;
|
|
461
|
+
return { attempted: true, committed: false, anomaly };
|
|
278
462
|
}
|
|
279
463
|
|
|
280
|
-
execFileSync('git', ['commit', '-m', message], {
|
|
464
|
+
childProcess.execFileSync('git', ['commit', '-m', message], {
|
|
281
465
|
stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
|
|
282
466
|
encoding: 'utf8',
|
|
283
467
|
});
|
|
@@ -285,12 +469,44 @@ function _autoCommit(iteration, opts = {}) {
|
|
|
285
469
|
if (verbose) {
|
|
286
470
|
process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
|
|
287
471
|
}
|
|
472
|
+
return { attempted: true, committed: true, anomaly: null };
|
|
288
473
|
} catch (err) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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);
|
|
292
506
|
}
|
|
293
507
|
}
|
|
508
|
+
|
|
509
|
+
return Array.from(allowlist);
|
|
294
510
|
}
|
|
295
511
|
|
|
296
512
|
/**
|
|
@@ -351,10 +567,18 @@ function _buildIterationFeedback(recentHistory, errorEntries) {
|
|
|
351
567
|
for (const entry of recentHistory) {
|
|
352
568
|
const issues = [];
|
|
353
569
|
|
|
354
|
-
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) {
|
|
355
575
|
issues.push(`opencode exited with code ${entry.exitCode}`);
|
|
356
576
|
}
|
|
357
577
|
|
|
578
|
+
if (entry.commitAnomaly) {
|
|
579
|
+
issues.push(`commit anomaly: ${entry.commitAnomaly}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
358
582
|
if (!entry.filesChanged || entry.filesChanged.length === 0) {
|
|
359
583
|
issues.push('no files changed');
|
|
360
584
|
}
|
|
@@ -366,10 +590,16 @@ function _buildIterationFeedback(recentHistory, errorEntries) {
|
|
|
366
590
|
if (issues.length > 0) {
|
|
367
591
|
let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`;
|
|
368
592
|
|
|
369
|
-
if (entry
|
|
593
|
+
if (_isFailedIteration(entry) && errorEntries) {
|
|
370
594
|
const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration);
|
|
371
595
|
if (errorDetails) {
|
|
372
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
|
+
}
|
|
373
603
|
if (errorDetails.stderr) {
|
|
374
604
|
line += `\n ${errorDetails.stderr}`;
|
|
375
605
|
}
|
|
@@ -396,7 +626,7 @@ function _buildIterationFeedback(recentHistory, errorEntries) {
|
|
|
396
626
|
function _extractErrorForIteration(errorEntries, iteration) {
|
|
397
627
|
if (!Array.isArray(errorEntries) || errorEntries.length === 0) return null;
|
|
398
628
|
|
|
399
|
-
const match =
|
|
629
|
+
const match = errors.matchIteration(errorEntries, iteration);
|
|
400
630
|
if (!match) return null;
|
|
401
631
|
|
|
402
632
|
let stderr = match.stderr || '';
|
|
@@ -405,7 +635,12 @@ function _extractErrorForIteration(errorEntries, iteration) {
|
|
|
405
635
|
if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
|
|
406
636
|
if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
|
|
407
637
|
|
|
408
|
-
return {
|
|
638
|
+
return {
|
|
639
|
+
stderr,
|
|
640
|
+
stdout,
|
|
641
|
+
signal: match.signal || '',
|
|
642
|
+
failureStage: match.failureStage || '',
|
|
643
|
+
};
|
|
409
644
|
}
|
|
410
645
|
|
|
411
646
|
function _getCurrentTaskDescription(tasksBefore) {
|
|
@@ -441,6 +676,69 @@ function _taskIdentity(task) {
|
|
|
441
676
|
: (task.fullDescription || task.description);
|
|
442
677
|
}
|
|
443
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
|
+
|
|
444
742
|
/**
|
|
445
743
|
* Determine the starting iteration for a new run.
|
|
446
744
|
*
|
|
@@ -482,9 +780,11 @@ function _resolveStartIteration(existingState, options) {
|
|
|
482
780
|
|
|
483
781
|
module.exports = {
|
|
484
782
|
run,
|
|
783
|
+
_finalizeRunState,
|
|
485
784
|
_containsPromise,
|
|
486
785
|
_validateOptions,
|
|
487
786
|
_autoCommit,
|
|
787
|
+
_buildAutoCommitAllowlist,
|
|
488
788
|
_resolveStartIteration,
|
|
489
789
|
_completedTaskDelta,
|
|
490
790
|
_formatAutoCommitMessage,
|
|
@@ -492,4 +792,11 @@ module.exports = {
|
|
|
492
792
|
_extractErrorForIteration,
|
|
493
793
|
_getCurrentTaskDescription,
|
|
494
794
|
_cleanupCompletedErrors,
|
|
795
|
+
_detectProtectedCommitArtifacts,
|
|
796
|
+
_gitErrorMessage,
|
|
797
|
+
_isFailedIteration,
|
|
798
|
+
_wasSuccessfulIteration,
|
|
799
|
+
_failureStageForError,
|
|
800
|
+
_errorText,
|
|
801
|
+
_appendFatalIterationFailure,
|
|
495
802
|
};
|