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.
@@ -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,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
- // 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 errorEntries = errors.readEntries(ralphDir, 3);
98
- const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorEntries);
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
+ }
175
240
 
176
- // Check completion condition (must also satisfy minIterations)
177
- if (hasCompletion && iterationCount >= minIterations) {
178
- completed = true;
179
- exitReason = 'completion_promise';
180
- break;
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
- // In tasks mode, task promise just continues the loop
184
- if (options.tasksMode && hasTask) {
185
- // Continue to next iteration
186
- continue;
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
- // A completed loop must not remain marked active because post-completion
196
- // cleanup failed.
197
- state.update(ralphDir, { active: false, completedAt: new Date().toISOString() });
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
- 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
+ });
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
- 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);
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', '-A'], {
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
- // 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`);
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.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) {
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.exitCode !== 0 && errorEntries) {
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 = errorEntries.find((entry) => entry.iteration === iteration);
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 { stderr, stdout };
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
  };