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.
@@ -11,7 +11,8 @@
11
11
  * isolated in a thin invoker that can be swapped in tests.
12
12
  */
13
13
 
14
- const { execFileSync } = require('child_process');
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
- // 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);
123
+ let stateInitialized = false;
124
+ let iterationCount = 0;
125
+ let completed = false;
126
+ let exitReason = 'max_iterations';
53
127
 
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
- }
128
+ try {
60
129
 
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
- });
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
- // 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
- }
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
- let iterationCount = resumeIteration - 1;
86
- let completed = false;
87
- let exitReason = 'max_iterations';
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
- while (iterationCount < maxIterations) {
90
- iterationCount++;
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
- // Update state with current iteration
93
- state.update(ralphDir, { iteration: iterationCount, active: true });
170
+ iterationCount = resumeIteration - 1;
94
171
 
95
- // Build the prompt for this iteration
96
- const renderedPrompt = await prompt.render(options, iterationCount);
97
- const errorContent = errors.read(ralphDir, 3);
98
- const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorContent);
172
+ try {
173
+ while (iterationCount < maxIterations) {
174
+ iterationCount++;
99
175
 
100
- // Inject any pending context
101
- const pendingContext = context.consume(ralphDir);
102
- const promptSections = [renderedPrompt];
176
+ // Update state with current iteration
177
+ state.update(ralphDir, { iteration: iterationCount, active: true });
103
178
 
104
- if (iterationFeedback) {
105
- promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
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
- if (pendingContext) {
109
- promptSections.push(`## Injected Context\n\n${pendingContext}`);
110
- }
185
+ let result;
111
186
 
112
- const finalPrompt = promptSections.join('\n\n');
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
- const iterStart = Date.now();
115
- const tasksBefore = options.tasksMode && options.tasksFile
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
- // 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
- });
200
+ // Inject any pending context
201
+ const pendingContext = context.consume(ralphDir);
202
+ const promptSections = [renderedPrompt];
127
203
 
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
- });
204
+ if (iterationFeedback) {
205
+ promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
206
+ }
150
207
 
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
- }
208
+ if (pendingContext) {
209
+ promptSections.push(`## Injected Context\n\n${pendingContext}`);
210
+ }
161
211
 
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
- }
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
- // Check completion condition (must also satisfy minIterations)
177
- if (hasCompletion && iterationCount >= minIterations) {
178
- completed = true;
179
- exitReason = 'completion_promise';
180
- break;
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
- // In tasks mode, task promise just continues the loop
184
- if (options.tasksMode && hasTask) {
185
- // Continue to next iteration
186
- continue;
321
+ if (completed) {
322
+ _cleanupCompletedErrors(ralphDir, options.verbose);
187
323
  }
188
- }
189
324
 
190
- if (completed) {
191
- const archivePath = errors.archive(ralphDir);
192
- if (archivePath && options.verbose) {
193
- process.stderr.write(`[mini-ralph] errors archived to ${archivePath}\n`);
325
+ return { completed, iterations: iterationCount, exitReason };
326
+ } finally {
327
+ if (stateInitialized) {
328
+ _finalizeRunState(ralphDir, { completed, exitReason });
194
329
  }
195
- errors.clear(ralphDir);
330
+ state.releaseRunLock(ralphDir, runLock);
196
331
  }
332
+ }
197
333
 
198
- // Mark loop as inactive
199
- state.update(ralphDir, { active: false, completedAt: new Date().toISOString() });
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
- return { completed, iterations: iterationCount, exitReason };
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
- return text.includes(`<promise>${promiseName}</promise>`);
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', '-A'], {
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
- // Auto-commit is best-effort; don't fail the loop if commit fails
291
- if (verbose) {
292
- process.stderr.write(`[mini-ralph] auto-commit skipped: ${err.message}\n`);
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, errorContent) {
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.exitCode !== 0) {
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.exitCode !== 0 && errorContent) {
371
- const errorDetails = _extractErrorForIteration(errorContent, entry.iteration);
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(errorContent, iteration) {
398
- if (!errorContent) return null;
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
- let stderr = '';
406
- let stdout = '';
629
+ const match = errors.matchIteration(errorEntries, iteration);
630
+ if (!match) return null;
407
631
 
408
- const stderrMatch = entry.match(/### stderr\n([\s\S]*?)(?=\n### stdout|$)/);
409
- const stdoutMatch = entry.match(/### stdout\n([\s\S]*?)$/);
632
+ let stderr = match.stderr || '';
633
+ let stdout = match.stdout || '';
410
634
 
411
- if (stderrMatch) stderr = stderrMatch[1].trim();
412
- if (stdoutMatch) stdout = stdoutMatch[1].trim();
635
+ if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
636
+ if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
413
637
 
414
- if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
415
- if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
416
-
417
- return { stderr, stdout };
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
  };