spec-and-loop 2.1.2 → 3.0.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.
@@ -20,6 +20,8 @@ const tasks = require('./tasks');
20
20
  const prompt = require('./prompt');
21
21
  const invoker = require('./invoker');
22
22
  const errors = require('./errors');
23
+ const progress = require('./progress');
24
+ const lessons = require('./lessons');
23
25
 
24
26
  const DEFAULTS = {
25
27
  minIterations: 1,
@@ -29,8 +31,41 @@ const DEFAULTS = {
29
31
  tasksMode: false,
30
32
  noCommit: false,
31
33
  verbose: false,
34
+ // Emits a per-iteration runtime status line (task #, ok/fail/stall badge,
35
+ // duration, rolling counters, cumulative + average time) to stderr. Enabled
36
+ // by default because "what is the loop doing right now?" is the single most
37
+ // common operator question. Pass `quiet: true` to suppress.
38
+ quiet: false,
39
+ // Stall detector: break the loop after N *consecutive* iterations that
40
+ // succeeded but produced no progress (no promise, no completed tasks, no
41
+ // files changed). 0 disables the detector. Failed iterations do not count
42
+ // toward the streak because their signal is already surfaced via the
43
+ // `Recent Loop Signals` feedback block.
44
+ stallThreshold: 3,
32
45
  };
33
46
 
47
+ /**
48
+ * Determine whether an iteration made any forward progress.
49
+ *
50
+ * An iteration is considered productive if any of the following are true:
51
+ * - OpenCode emitted the task or completion promise
52
+ * - One or more tasks transitioned to "completed" during the iteration
53
+ * - At least one repo-tracked file was observed to have changed
54
+ * - The iteration failed outright (its signal is handled separately)
55
+ *
56
+ * @param {object} iterationSignals
57
+ * @returns {boolean}
58
+ */
59
+ function _iterationIsStalled(iterationSignals) {
60
+ if (!iterationSignals) return false;
61
+ if (iterationSignals.iterationFailed) return false;
62
+ if (iterationSignals.hasCompletion) return false;
63
+ if (iterationSignals.hasTask) return false;
64
+ if (iterationSignals.completedTasksCount > 0) return false;
65
+ if (iterationSignals.filesChangedCount > 0) return false;
66
+ return true;
67
+ }
68
+
34
69
  function _isFailedIteration(result) {
35
70
  if (!result || typeof result !== 'object') return false;
36
71
  if (result.signal !== null && result.signal !== undefined && result.signal !== '') {
@@ -43,6 +78,20 @@ function _wasSuccessfulIteration(result) {
43
78
  return !_isFailedIteration(result);
44
79
  }
45
80
 
81
+ /**
82
+ * Measure the size of a text string for telemetry purposes.
83
+ *
84
+ * @param {string} str
85
+ * @returns {{ bytes: number, chars: number, tokens: number }}
86
+ */
87
+ function _measureText(str) {
88
+ if (typeof str !== 'string') str = '';
89
+ const bytes = Buffer.byteLength(str, 'utf8');
90
+ const chars = str.length;
91
+ const tokens = Math.round(chars / 4);
92
+ return { bytes, chars, tokens };
93
+ }
94
+
46
95
  function _failureStageForError(err) {
47
96
  if (!err || typeof err !== 'object') {
48
97
  return 'invoke_contract';
@@ -96,6 +145,13 @@ function _appendFatalIterationFailure(ralphDir, entry) {
96
145
  commitAnomaly: '',
97
146
  commitAnomalyType: '',
98
147
  protectedArtifacts: [],
148
+ promptBytes: entry.promptBytes || 0,
149
+ promptChars: entry.promptChars || 0,
150
+ promptTokens: entry.promptTokens || 0,
151
+ responseBytes: entry.responseBytes || 0,
152
+ responseChars: entry.responseChars || 0,
153
+ responseTokens: entry.responseTokens || 0,
154
+ truncated: entry.truncated || false,
99
155
  });
100
156
  }
101
157
 
@@ -119,11 +175,24 @@ async function run(opts) {
119
175
  const minIterations = options.minIterations;
120
176
  const completionPromise = options.completionPromise;
121
177
  const taskPromise = options.taskPromise;
178
+ const stallThreshold =
179
+ typeof options.stallThreshold === 'number' && options.stallThreshold >= 0
180
+ ? Math.floor(options.stallThreshold)
181
+ : DEFAULTS.stallThreshold;
182
+
183
+ const reporter = options.reporter || progress.create({
184
+ enabled: !options.quiet,
185
+ maxIterations,
186
+ });
122
187
 
123
188
  let stateInitialized = false;
124
189
  let iterationCount = 0;
125
190
  let completed = false;
126
191
  let exitReason = 'max_iterations';
192
+ // Consecutive iterations that succeeded but produced no progress signal.
193
+ // Reset whenever any progress is detected (or when the iteration failed, so
194
+ // transient infra errors don't trip the stall detector).
195
+ let stallStreak = 0;
127
196
 
128
197
  try {
129
198
 
@@ -139,7 +208,25 @@ async function run(opts) {
139
208
  );
140
209
  }
141
210
 
211
+ reporter.runStarted({
212
+ tasksMode: Boolean(options.tasksMode),
213
+ model: options.model || '',
214
+ resumed: resumeIteration > 1 ? resumeIteration - 1 : null,
215
+ });
216
+
142
217
  // Initialize state file for this run, preserving history count if resuming.
218
+ //
219
+ // `startedAt` semantics: this field marks the first time *this change* was
220
+ // put through a Ralph loop. On a resume we must preserve the original
221
+ // timestamp, not overwrite it with the current time -- previously, every
222
+ // resume reset `startedAt` and the status dashboard lost the true wall-
223
+ // clock duration. `resumedAt` tracks the most recent resume.
224
+ const nowIso = new Date().toISOString();
225
+ const preservedStartedAt =
226
+ resumeIteration > 1 && existingState && existingState.startedAt
227
+ ? existingState.startedAt
228
+ : nowIso;
229
+
143
230
  state.init(ralphDir, {
144
231
  active: true,
145
232
  iteration: resumeIteration,
@@ -153,8 +240,8 @@ async function run(opts) {
153
240
  promptTemplate: options.promptTemplate || null,
154
241
  noCommit: options.noCommit,
155
242
  model: options.model || '',
156
- startedAt: new Date().toISOString(),
157
- resumedAt: resumeIteration > 1 ? new Date().toISOString() : null,
243
+ startedAt: preservedStartedAt,
244
+ resumedAt: resumeIteration > 1 ? nowIso : null,
158
245
  completedAt: null,
159
246
  stoppedAt: null,
160
247
  exitReason: null,
@@ -181,8 +268,17 @@ async function run(opts) {
181
268
  ? tasks.parseTasks(options.tasksFile)
182
269
  : [];
183
270
  const currentTask = _getCurrentTaskDescription(tasksBefore);
271
+ const currentTaskMeta = _getCurrentTaskMeta(tasksBefore);
272
+
273
+ reporter.iterationStarted({
274
+ iteration: iterationCount,
275
+ taskNumber: currentTaskMeta.number,
276
+ taskDescription: currentTaskMeta.description,
277
+ });
184
278
 
185
279
  let result;
280
+ let promptSize = null;
281
+ let responseSize = { bytes: 0, chars: 0, tokens: 0 };
186
282
 
187
283
  try {
188
284
  // Build the prompt for this iteration
@@ -194,23 +290,42 @@ async function run(opts) {
194
290
  throw err;
195
291
  }
196
292
 
293
+ // Emit 3 iterations of Recent Loop Signals — the `_failureFingerprint`
294
+ // dedup collapses identical entries into a single "same failure as
295
+ // iteration N" line, so the 3-entry window is sufficient to surface
296
+ // recurring patterns without bloating the prompt.
197
297
  const errorEntries = errors.readEntries(ralphDir, 3);
198
298
  const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorEntries);
199
299
 
200
300
  // Inject any pending context
201
301
  const pendingContext = context.consume(ralphDir);
302
+ lessons.rotate(ralphDir, 100);
303
+ const lessonsSection = lessons.inject(ralphDir, { limit: 15 });
202
304
  const promptSections = [renderedPrompt];
203
305
 
204
306
  if (iterationFeedback) {
205
307
  promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
206
308
  }
207
309
 
310
+ if (lessonsSection) {
311
+ promptSections.push(lessonsSection);
312
+ }
313
+
208
314
  if (pendingContext) {
209
315
  promptSections.push(`## Injected Context\n\n${pendingContext}`);
210
316
  }
211
317
 
212
318
  const finalPrompt = promptSections.join('\n\n');
213
319
 
320
+ // Measure and report prompt size
321
+ promptSize = _measureText(finalPrompt);
322
+ reporter.iterationPromptReady({
323
+ iteration: iterationCount,
324
+ promptBytes: promptSize.bytes,
325
+ promptChars: promptSize.chars,
326
+ promptTokens: promptSize.tokens,
327
+ });
328
+
214
329
  // Invoke OpenCode
215
330
  try {
216
331
  result = await invoker.invoke({
@@ -220,20 +335,52 @@ async function run(opts) {
220
335
  verbose: options.verbose,
221
336
  ralphDir,
222
337
  });
338
+ // Measure and report response size
339
+ const rawOutput = result.rawOutput || result.output || result.stdout || '';
340
+ responseSize = _measureText(rawOutput);
341
+ reporter.iterationResponseReceived({
342
+ iteration: iterationCount,
343
+ responseBytes: responseSize.bytes,
344
+ responseChars: responseSize.chars,
345
+ responseTokens: responseSize.tokens,
346
+ truncated: result.truncated || false,
347
+ });
223
348
  } catch (err) {
224
349
  err.failureStage = err.failureStage || 'invoke_start';
225
350
  throw err;
226
351
  }
227
352
  } catch (err) {
353
+ const fatalDuration = Date.now() - iterStart;
228
354
  _appendFatalIterationFailure(ralphDir, {
229
355
  iteration: iterationCount,
230
356
  task: currentTask,
231
- duration: Date.now() - iterStart,
357
+ duration: fatalDuration,
232
358
  exitCode: null,
233
359
  signal: '',
234
360
  failureStage: _failureStageForError(err),
235
361
  stderr: _errorText(err),
236
362
  stdout: '',
363
+ promptBytes: promptSize ? promptSize.bytes : 0,
364
+ promptChars: promptSize ? promptSize.chars : 0,
365
+ promptTokens: promptSize ? promptSize.tokens : 0,
366
+ responseBytes: 0,
367
+ responseChars: 0,
368
+ responseTokens: 0,
369
+ truncated: false,
370
+ });
371
+ reporter.iterationFinished({
372
+ iteration: iterationCount,
373
+ durationMs: fatalDuration,
374
+ outcome: 'failure',
375
+ committed: false,
376
+ hasCompletion: false,
377
+ hasTask: false,
378
+ completedTasksCount: 0,
379
+ filesChangedCount: 0,
380
+ stallStreak,
381
+ failureReason: `${_failureStageForError(err)}: ${_firstNonEmptyLine(_errorText(err), 120)}`,
382
+ taskNumber: currentTaskMeta.number,
383
+ taskDescription: currentTaskMeta.description,
237
384
  });
238
385
  throw err;
239
386
  }
@@ -277,6 +424,7 @@ async function run(opts) {
277
424
  filesToStage: _buildAutoCommitAllowlist(result.filesChanged, completedTasks, options.tasksFile),
278
425
  tasksFile: options.tasksFile,
279
426
  verbose: options.verbose,
427
+ reporter,
280
428
  });
281
429
  }
282
430
 
@@ -298,6 +446,59 @@ async function run(opts) {
298
446
  commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
299
447
  commitAnomalyType: commitResult.anomaly ? commitResult.anomaly.type : '',
300
448
  protectedArtifacts: commitResult.anomaly ? commitResult.anomaly.protectedArtifacts || [] : [],
449
+ ...(commitResult.anomaly && commitResult.anomaly.ignoredPaths && commitResult.anomaly.ignoredPaths.length > 0
450
+ ? { ignoredPaths: commitResult.anomaly.ignoredPaths }
451
+ : {}),
452
+ promptBytes: promptSize ? promptSize.bytes : 0,
453
+ promptChars: promptSize ? promptSize.chars : 0,
454
+ promptTokens: promptSize ? promptSize.tokens : 0,
455
+ responseBytes: responseSize.bytes,
456
+ responseChars: responseSize.chars,
457
+ responseTokens: responseSize.tokens,
458
+ truncated: result.truncated || false,
459
+ // Pass through watchdog failure fields when the invoker returns them (task 3.1).
460
+ ...(result.failureReason !== undefined ? { failureReason: result.failureReason } : {}),
461
+ ...(result.idleMs !== undefined ? { idleMs: result.idleMs } : {}),
462
+ ...(result.lastStdoutBytes !== undefined ? { lastStdoutBytes: result.lastStdoutBytes } : {}),
463
+ ...(result.lastStderrBytes !== undefined ? { lastStderrBytes: result.lastStderrBytes } : {}),
464
+ });
465
+
466
+ // Stall detection is computed *before* the progress event so the
467
+ // reporter can show the live streak alongside the badge. We still
468
+ // enforce the stall halt after the event so the operator sees the
469
+ // final (stalled) iteration line before the "halting" note.
470
+ const iterationFailed = _isFailedIteration(result);
471
+ const stalledThisIteration = _iterationIsStalled({
472
+ iterationFailed,
473
+ hasCompletion,
474
+ hasTask,
475
+ completedTasksCount: completedTasks.length,
476
+ filesChangedCount: Array.isArray(result.filesChanged) ? result.filesChanged.length : 0,
477
+ });
478
+
479
+ if (stalledThisIteration) {
480
+ stallStreak++;
481
+ } else {
482
+ stallStreak = 0;
483
+ }
484
+
485
+ reporter.iterationFinished({
486
+ iteration: iterationCount,
487
+ durationMs: duration,
488
+ outcome: iterationFailed
489
+ ? 'failure'
490
+ : stalledThisIteration
491
+ ? 'stalled'
492
+ : 'success',
493
+ committed: commitResult.committed === true,
494
+ hasCompletion,
495
+ hasTask,
496
+ completedTasksCount: completedTasks.length,
497
+ filesChangedCount: Array.isArray(result.filesChanged) ? result.filesChanged.length : 0,
498
+ stallStreak,
499
+ failureReason: iterationFailed ? _summarizeFailure(result) : '',
500
+ taskNumber: currentTaskMeta.number,
501
+ taskDescription: currentTaskMeta.description,
301
502
  });
302
503
 
303
504
  // Check completion condition (must also satisfy minIterations)
@@ -307,6 +508,20 @@ async function run(opts) {
307
508
  break;
308
509
  }
309
510
 
511
+ if (stallThreshold > 0 && stallStreak >= stallThreshold) {
512
+ reporter.note(
513
+ `stall detector: ${stallStreak} consecutive no-op iteration(s); halting.`,
514
+ 'warn'
515
+ );
516
+ if (options.verbose) {
517
+ process.stderr.write(
518
+ `[mini-ralph] stall detector: ${stallStreak} consecutive no-op iteration(s); halting.\n`
519
+ );
520
+ }
521
+ exitReason = 'stalled';
522
+ break;
523
+ }
524
+
310
525
  // In tasks mode, task promise just continues the loop
311
526
  if (options.tasksMode && hasTask) {
312
527
  // Continue to next iteration
@@ -315,6 +530,8 @@ async function run(opts) {
315
530
  }
316
531
  } catch (err) {
317
532
  exitReason = 'fatal_error';
533
+ reporter.note(`fatal error: ${_firstNonEmptyLine(err && err.message, 120) || 'unknown'}`, 'error');
534
+ reporter.runFinished({ completed: false, exitReason, iterations: iterationCount });
318
535
  throw err;
319
536
  }
320
537
 
@@ -322,6 +539,8 @@ async function run(opts) {
322
539
  _cleanupCompletedErrors(ralphDir, options.verbose);
323
540
  }
324
541
 
542
+ reporter.runFinished({ completed, exitReason, iterations: iterationCount });
543
+
325
544
  return { completed, iterations: iterationCount, exitReason };
326
545
  } finally {
327
546
  if (stateInitialized) {
@@ -377,8 +596,8 @@ function _validateOptions(options) {
377
596
  if (!options.ralphDir) {
378
597
  throw new Error('mini-ralph runner: options.ralphDir is required');
379
598
  }
380
- if (!options.promptFile && !options.promptText) {
381
- throw new Error('mini-ralph runner: either options.promptFile or options.promptText is required');
599
+ if (!options.promptFile && !options.promptText && !options.promptTemplate) {
600
+ throw new Error('mini-ralph runner: at least one of options.promptFile, options.promptText, or options.promptTemplate is required');
382
601
  }
383
602
  if (options.promptFile && options.promptText) {
384
603
  throw new Error('mini-ralph runner: provide either options.promptFile or options.promptText, not both');
@@ -394,6 +613,33 @@ function _validateOptions(options) {
394
613
  }
395
614
  }
396
615
 
616
+ /**
617
+ * Format the loud direct stderr block for auto-commit ignore-filter events.
618
+ * Emitted via process.stderr.write (bypassing reporter dedup/buffering) on
619
+ * every iteration where paths_ignored_filtered or all_paths_ignored fires.
620
+ * (task 5.1 — surface-autocommit-ignore-warning-and-watchdog)
621
+ *
622
+ * @param {number} iteration
623
+ * @param {{ type: string, ignoredPaths: string[] }} anomaly
624
+ * @returns {string}
625
+ */
626
+ function _formatAutoCommitIgnoreBlock(iteration, anomaly) {
627
+ const SEP = '================================================================================\n';
628
+ const pathLines = (anomaly.ignoredPaths || []).map(p => ` - ${p}`).join('\n');
629
+ return (
630
+ SEP +
631
+ `⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration ${iteration}, type: ${anomaly.type})\n` +
632
+ `Paths filtered because .gitignore matches:\n` +
633
+ pathLines + '\n' +
634
+ `Consequence: these paths are NOT in the latest commit.\n` +
635
+ `Remediation (pick one):\n` +
636
+ ` 1. git add -f <path> # one-time unblock, if you want it tracked\n` +
637
+ ` 2. edit .gitignore # narrow or remove the matching rule\n` +
638
+ ` 3. pass --no-auto-commit on the ralph-run invocation\n` +
639
+ SEP
640
+ );
641
+ }
642
+
397
643
  /**
398
644
  * Auto-commit changed files after a successful iteration.
399
645
  * Silently skips if git is unavailable, there is nothing to commit, or the
@@ -406,7 +652,7 @@ function _validateOptions(options) {
406
652
  * @param {boolean} [opts.verbose]
407
653
  */
408
654
  function _autoCommit(iteration, opts = {}) {
409
- const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false } = opts;
655
+ const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false, reporter = null } = opts;
410
656
  const message = _formatAutoCommitMessage(iteration, completedTasks);
411
657
 
412
658
  if (!message) {
@@ -437,8 +683,55 @@ function _autoCommit(iteration, opts = {}) {
437
683
  return { attempted: true, committed: false, anomaly };
438
684
  }
439
685
 
686
+ const { kept: keptPaths, dropped: droppedPaths } = _filterGitignored(filesToStage, process.cwd());
687
+
688
+ if (droppedPaths.length > 0) {
689
+ const pathWord = droppedPaths.length === 1 ? 'path' : 'paths';
690
+ const allIgnored = keptPaths.length === 0;
691
+ const warnLines = allIgnored
692
+ ? [
693
+ `auto-commit iter ${iteration} skipped: all ${droppedPaths.length} ${pathWord} are gitignored`,
694
+ ...droppedPaths.map(p => ` - ${p}`),
695
+ ' hint: `git add -f <path>` once, or adjust .gitignore',
696
+ ].join('\n')
697
+ : [
698
+ `auto-commit iter ${iteration}: filtered ${droppedPaths.length} gitignored ${pathWord}, committing ${keptPaths.length} ${keptPaths.length === 1 ? 'other' : 'others'}`,
699
+ ...droppedPaths.map(p => ` - ${p}`),
700
+ ].join('\n');
701
+ if (reporter) {
702
+ reporter.note(warnLines, 'error');
703
+ } else {
704
+ const fallbackMsg = allIgnored
705
+ ? `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`
706
+ : `Auto-commit filtered gitignored paths: ${droppedPaths.join(', ')}`;
707
+ process.stderr.write(`[mini-ralph] warning: ${fallbackMsg}\n`);
708
+ }
709
+ if (allIgnored) {
710
+ const anomaly = {
711
+ type: 'all_paths_ignored',
712
+ message: `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`,
713
+ ignoredPaths: droppedPaths,
714
+ };
715
+ // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
716
+ process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
717
+ return {
718
+ attempted: true,
719
+ committed: false,
720
+ anomaly,
721
+ };
722
+ }
723
+ }
724
+
725
+ const stagePaths = droppedPaths.length > 0 ? keptPaths : filesToStage;
726
+
440
727
  try {
441
- childProcess.execFileSync('git', ['add', '--', ...filesToStage], {
728
+ // Use `git add -A -- <paths>` (not plain `git add -- <paths>`) so deletions
729
+ // and renames are staged alongside modifications/additions. Tasks that call
730
+ // `git rm` via a shell tool leave the path absent from the working tree but
731
+ // still present in `git status --porcelain`, which means the plain form
732
+ // would error with `fatal: pathspec did not match`. Scoping to the per-path
733
+ // allowlist preserves the protected-artifact guarantee.
734
+ childProcess.execFileSync('git', ['add', '-A', '--', ...stagePaths], {
442
735
  stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
443
736
  encoding: 'utf8',
444
737
  });
@@ -469,6 +762,20 @@ function _autoCommit(iteration, opts = {}) {
469
762
  if (verbose) {
470
763
  process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
471
764
  }
765
+ if (droppedPaths.length > 0) {
766
+ const anomaly = {
767
+ type: 'paths_ignored_filtered',
768
+ message: 'Auto-commit succeeded but filtered gitignored paths: ' + droppedPaths.join(', '),
769
+ ignoredPaths: droppedPaths,
770
+ };
771
+ // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
772
+ process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
773
+ return {
774
+ attempted: true,
775
+ committed: true,
776
+ anomaly,
777
+ };
778
+ }
472
779
  return { attempted: true, committed: true, anomaly: null };
473
780
  } catch (err) {
474
781
  const anomaly = {
@@ -481,6 +788,53 @@ function _autoCommit(iteration, opts = {}) {
481
788
  }
482
789
  }
483
790
 
791
+ /**
792
+ * Filter gitignored paths out of a list using `git check-ignore --stdin`.
793
+ *
794
+ * Exit-code semantics of `git check-ignore`:
795
+ * 0 – at least one path is ignored; stdout lists the ignored paths.
796
+ * 1 – no paths are ignored (Node's execFileSync throws; we catch status===1).
797
+ * other / ENOENT / any thrown error – fallback: treat all paths as kept.
798
+ *
799
+ * @param {string[]} paths - Repo-relative paths to test.
800
+ * @param {string} cwd - Working directory for the git command.
801
+ * @returns {{ kept: string[], dropped: string[] }}
802
+ */
803
+ function _filterGitignored(paths, cwd) {
804
+ if (!Array.isArray(paths) || paths.length === 0) {
805
+ return { kept: [], dropped: [] };
806
+ }
807
+
808
+ try {
809
+ const stdout = childProcess.execFileSync(
810
+ 'git',
811
+ ['check-ignore', '--stdin'],
812
+ {
813
+ input: paths.join('\n'),
814
+ cwd: cwd || process.cwd(),
815
+ stdio: ['pipe', 'pipe', 'pipe'],
816
+ encoding: 'utf8',
817
+ }
818
+ );
819
+
820
+ // Exit code 0: stdout lists ignored paths (one per line).
821
+ const dropped = stdout
822
+ .split('\n')
823
+ .map((l) => l.trim())
824
+ .filter(Boolean);
825
+ const droppedSet = new Set(dropped);
826
+ const kept = paths.filter((p) => !droppedSet.has(p));
827
+ return { kept, dropped };
828
+ } catch (err) {
829
+ // exit status 1 means "no paths ignored" — treat as success with no drops.
830
+ if (err && err.status === 1) {
831
+ return { kept: paths.slice(), dropped: [] };
832
+ }
833
+ // Any other error (ENOENT, unexpected exit code, etc.) — fallback, never crash.
834
+ return { kept: paths.slice(), dropped: [] };
835
+ }
836
+ }
837
+
484
838
  /**
485
839
  * Build the explicit per-iteration git staging allowlist.
486
840
  *
@@ -531,6 +885,11 @@ function _completedTaskDelta(beforeTasks, afterTasks) {
531
885
  /**
532
886
  * Build a task-aware commit message for an iteration.
533
887
  *
888
+ * The subject line (first line) is kept short — conventional git tooling
889
+ * assumes ~50–72 characters — so `git log --oneline` stays readable even when
890
+ * the underlying task description is a multi-sentence normative blob. The
891
+ * full, untruncated task descriptions are preserved in the commit body.
892
+ *
534
893
  * @param {number} iteration
535
894
  * @param {Array<object>} completedTasks
536
895
  * @returns {string}
@@ -540,14 +899,55 @@ function _formatAutoCommitMessage(iteration, completedTasks) {
540
899
  return '';
541
900
  }
542
901
 
543
- const summary = completedTasks.length === 1
902
+ const rawSummary = completedTasks.length === 1
544
903
  ? completedTasks[0].description
545
904
  : `complete ${completedTasks.length} tasks`;
905
+
906
+ const prefix = `Ralph iteration ${iteration}: `;
907
+ const subjectBudget = Math.max(20, SUBJECT_MAX_LENGTH - prefix.length);
908
+ const summary = _truncateSubjectSummary(rawSummary, subjectBudget);
909
+
546
910
  const taskLines = completedTasks.map(
547
911
  (task) => `- [x] ${task.fullDescription || task.description}`
548
912
  );
549
913
 
550
- return `Ralph iteration ${iteration}: ${summary}\n\nTasks completed:\n${taskLines.join('\n')}`;
914
+ return `${prefix}${summary}\n\nTasks completed:\n${taskLines.join('\n')}`;
915
+ }
916
+
917
+ const SUBJECT_MAX_LENGTH = 72;
918
+
919
+ /**
920
+ * Reduce a task description to a short, single-line commit subject.
921
+ *
922
+ * Strategy:
923
+ * 1. Collapse whitespace onto a single line.
924
+ * 2. Prefer the first sentence (up to `.`, `!`, `?`) when it is not itself
925
+ * longer than the allowed budget.
926
+ * 3. Otherwise hard-truncate at a word boundary and append an ellipsis.
927
+ *
928
+ * @param {string} text
929
+ * @param {number} budget
930
+ * @returns {string}
931
+ */
932
+ function _truncateSubjectSummary(text, budget) {
933
+ const oneLine = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
934
+ if (oneLine.length === 0) return '';
935
+ if (oneLine.length <= budget) return oneLine;
936
+
937
+ const sentenceMatch = oneLine.match(/^(.+?[.!?])(\s|$)/);
938
+ if (sentenceMatch) {
939
+ const candidate = sentenceMatch[1].trim();
940
+ if (candidate.length > 0 && candidate.length <= budget) {
941
+ return candidate;
942
+ }
943
+ }
944
+
945
+ const ellipsis = '…';
946
+ const hardBudget = Math.max(1, budget - ellipsis.length);
947
+ const sliced = oneLine.slice(0, hardBudget);
948
+ const lastSpace = sliced.lastIndexOf(' ');
949
+ const cut = lastSpace > Math.floor(hardBudget / 2) ? sliced.slice(0, lastSpace) : sliced;
950
+ return `${cut.replace(/[\s,;:.!?-]+$/, '')}${ellipsis}`;
551
951
  }
552
952
 
553
953
  /**
@@ -557,12 +957,62 @@ function _formatAutoCommitMessage(iteration, completedTasks) {
557
957
  * @param {Array<object>} recentHistory
558
958
  * @returns {string}
559
959
  */
960
+ function _firstNonEmptyLine(text, limit) {
961
+ if (!text) return '';
962
+ const lines = text.split('\n');
963
+ for (const line of lines) {
964
+ const trimmed = line.trim();
965
+ if (trimmed.length > 0) {
966
+ return trimmed.slice(0, limit);
967
+ }
968
+ }
969
+ return '';
970
+ }
971
+
972
+ function _failureFingerprint(entry, errorEntries) {
973
+ let stderrHead = '';
974
+ if (errorEntries) {
975
+ const match = errors.matchIteration(errorEntries, entry.iteration);
976
+ stderrHead = _firstNonEmptyLine(match && match.stderr, 120);
977
+ }
978
+ // A "no promise emitted" iteration is also a distinguishable failure mode
979
+ // even when exitCode===0 and there's no stderr (e.g. the agent explicitly
980
+ // refuses to continue). Encoding it in the fingerprint lets the dedup
981
+ // collapse repeated hand-off iterations into a single actionable line
982
+ // instead of N identical bullets.
983
+ const noPromise = !entry.completionDetected && !entry.taskDetected;
984
+ return JSON.stringify({
985
+ failureStage: entry.failureStage || '',
986
+ exitCode: entry.exitCode,
987
+ stderrHead,
988
+ noPromise,
989
+ commitAnomalyType: entry.commitAnomalyType || '',
990
+ });
991
+ }
992
+
993
+ function _isEmptyFingerprint(fingerprint) {
994
+ try {
995
+ const obj = JSON.parse(fingerprint);
996
+ return (
997
+ !obj.failureStage &&
998
+ obj.exitCode === 0 &&
999
+ !obj.stderrHead &&
1000
+ !obj.noPromise &&
1001
+ !obj.commitAnomalyType
1002
+ );
1003
+ } catch {
1004
+ return false;
1005
+ }
1006
+ }
1007
+
560
1008
  function _buildIterationFeedback(recentHistory, errorEntries) {
561
1009
  if (!Array.isArray(recentHistory) || recentHistory.length === 0) {
562
1010
  return '';
563
1011
  }
564
1012
 
565
1013
  const problemLines = [];
1014
+ // Track fingerprint -> first iteration number for dedup
1015
+ const fingerprintSeen = new Map();
566
1016
 
567
1017
  for (const entry of recentHistory) {
568
1018
  const issues = [];
@@ -579,37 +1029,90 @@ function _buildIterationFeedback(recentHistory, errorEntries) {
579
1029
  issues.push(`commit anomaly: ${entry.commitAnomaly}`);
580
1030
  }
581
1031
 
582
- if (!entry.filesChanged || entry.filesChanged.length === 0) {
583
- issues.push('no files changed');
584
- }
585
-
586
1032
  if (!entry.completionDetected && !entry.taskDetected) {
587
1033
  issues.push('no loop promise emitted');
588
1034
  }
589
1035
 
590
1036
  if (issues.length > 0) {
591
- let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`;
592
-
593
- if (_isFailedIteration(entry) && errorEntries) {
594
- const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration);
595
- if (errorDetails) {
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
- }
603
- if (errorDetails.stderr) {
604
- line += `\n ${errorDetails.stderr}`;
1037
+ // Compute fingerprint for dedup
1038
+ const fp = _failureFingerprint(entry, errorEntries);
1039
+ const isRealFailure = !_isEmptyFingerprint(fp);
1040
+
1041
+ // paths_ignored_filtered and all_paths_ignored are exempt from dedup:
1042
+ // every occurrence must produce its own distinct line so the agent
1043
+ // sees the full per-iteration history of gitignore filtering events.
1044
+ const isIgnoreFilterAnomaly =
1045
+ entry.commitAnomalyType === 'paths_ignored_filtered' ||
1046
+ entry.commitAnomalyType === 'all_paths_ignored';
1047
+
1048
+ if (isRealFailure && fingerprintSeen.has(fp) && !isIgnoreFilterAnomaly) {
1049
+ const firstIteration = fingerprintSeen.get(fp);
1050
+ problemLines.push(
1051
+ `- Iteration ${entry.iteration}: same failure as iteration ${firstIteration} (see above).`
1052
+ );
1053
+ } else {
1054
+ if (isRealFailure && !isIgnoreFilterAnomaly) fingerprintSeen.set(fp, entry.iteration);
1055
+
1056
+ let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`;
1057
+
1058
+ // For paths_ignored_filtered / all_paths_ignored, append the first two
1059
+ // ignored paths inline (with a (+N more) suffix) so the agent can see
1060
+ // the exact files without diving into history. This replaces the
1061
+ // generic commit-anomaly text with a richer per-iteration signal.
1062
+ if (isIgnoreFilterAnomaly && Array.isArray(entry.ignoredPaths) && entry.ignoredPaths.length > 0) {
1063
+ const paths = entry.ignoredPaths;
1064
+ const shown = paths.slice(0, 2);
1065
+ const remaining = paths.length - shown.length;
1066
+ const pathStr = shown.join(', ') + (remaining > 0 ? ` (+${remaining} more)` : '');
1067
+ line += ` Ignored paths: ${pathStr}.`;
1068
+ }
1069
+
1070
+ // When the only issue is "no loop promise emitted" (no signal, no
1071
+ // failureStage, exitCode 0, no commit anomaly), append a compact
1072
+ // suffix with tool-usage and duration to give the agent more context.
1073
+ const isNoPromiseOnly =
1074
+ issues.length === 1 &&
1075
+ issues[0] === 'no loop promise emitted' &&
1076
+ !entry.signal &&
1077
+ !entry.failureStage &&
1078
+ entry.exitCode === 0 &&
1079
+ !entry.commitAnomaly;
1080
+
1081
+ if (isNoPromiseOnly) {
1082
+ const toolParts = Array.isArray(entry.toolUsage) && entry.toolUsage.length > 0
1083
+ ? entry.toolUsage.map(t => `${t.tool}\u00d7${t.count}`).join(', ')
1084
+ : null;
1085
+ const durationMs = entry.duration != null ? entry.duration : 0;
1086
+ const durationStr = durationMs > 0 ? progress._formatDuration(durationMs) : null;
1087
+ if (toolParts || durationStr) {
1088
+ const suffixParts = [];
1089
+ if (toolParts) suffixParts.push(`Tools used: ${toolParts}.`);
1090
+ if (durationStr) suffixParts.push(`Duration: ${durationStr}.`);
1091
+ line += ` ${suffixParts.join(' ')}`;
605
1092
  }
606
- if (errorDetails.stdout) {
607
- line += `\n stdout: ${errorDetails.stdout}`;
1093
+ }
1094
+
1095
+ if (_isFailedIteration(entry) && errorEntries) {
1096
+ const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration);
1097
+ if (errorDetails) {
1098
+ line += '\n Error output:';
1099
+ if (errorDetails.signal) {
1100
+ line += `\n signal: ${errorDetails.signal}`;
1101
+ }
1102
+ if (errorDetails.failureStage) {
1103
+ line += `\n failure stage: ${errorDetails.failureStage}`;
1104
+ }
1105
+ if (errorDetails.stderr) {
1106
+ line += `\n ${errorDetails.stderr}`;
1107
+ }
1108
+ if (errorDetails.stdout) {
1109
+ line += `\n stdout: ${errorDetails.stdout}`;
1110
+ }
608
1111
  }
609
1112
  }
610
- }
611
1113
 
612
- problemLines.push(line);
1114
+ problemLines.push(line);
1115
+ }
613
1116
  }
614
1117
  }
615
1118
 
@@ -632,8 +1135,8 @@ function _extractErrorForIteration(errorEntries, iteration) {
632
1135
  let stderr = match.stderr || '';
633
1136
  let stdout = match.stdout || '';
634
1137
 
635
- if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
636
- if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
1138
+ if (stderr.length > 500) stderr = stderr.substring(0, 500) + '...';
1139
+ if (stdout.length > 200) stdout = stdout.substring(0, 200) + '...';
637
1140
 
638
1141
  return {
639
1142
  stderr,
@@ -650,6 +1153,55 @@ function _getCurrentTaskDescription(tasksBefore) {
650
1153
  return 'N/A';
651
1154
  }
652
1155
 
1156
+ /**
1157
+ * Return the structured { number, description } of the current task for the
1158
+ * progress reporter. Unlike `_getCurrentTaskDescription` this preserves the
1159
+ * task number separately so the status line can render "task 4.7" even when
1160
+ * the description is later truncated.
1161
+ *
1162
+ * @param {Array<object>} tasksBefore
1163
+ * @returns {{ number: string, description: string }}
1164
+ */
1165
+ /**
1166
+ * Produce a short one-line reason for a failed iteration, suitable for the
1167
+ * progress reporter. Prefers the stderr first line; falls back to the exit
1168
+ * code / signal / failure stage so the operator always sees *something*.
1169
+ *
1170
+ * @param {object} result
1171
+ * @returns {string}
1172
+ */
1173
+ function _summarizeFailure(result) {
1174
+ if (!result || typeof result !== 'object') return 'unknown failure';
1175
+
1176
+ const stderrHead = _firstNonEmptyLine(result.stderr, 120);
1177
+ if (stderrHead) return stderrHead;
1178
+
1179
+ const parts = [];
1180
+ if (result.failureStage) parts.push(result.failureStage);
1181
+ if (result.signal) parts.push(`signal=${result.signal}`);
1182
+ if (
1183
+ typeof result.exitCode === 'number' &&
1184
+ result.exitCode !== 0
1185
+ ) {
1186
+ parts.push(`exit=${result.exitCode}`);
1187
+ }
1188
+ return parts.length > 0 ? parts.join(' ') : 'iteration failed';
1189
+ }
1190
+
1191
+ function _getCurrentTaskMeta(tasksBefore) {
1192
+ if (!Array.isArray(tasksBefore) || tasksBefore.length === 0) {
1193
+ return { number: '', description: '' };
1194
+ }
1195
+ const incomplete = tasksBefore.find(
1196
+ (task) => task && task.status !== 'completed'
1197
+ );
1198
+ if (!incomplete) return { number: '', description: '' };
1199
+ return {
1200
+ number: incomplete.number ? String(incomplete.number) : '',
1201
+ description: incomplete.description || incomplete.fullDescription || '',
1202
+ };
1203
+ }
1204
+
653
1205
  function _cleanupCompletedErrors(ralphDir, verbose) {
654
1206
  let archivePath = null;
655
1207
 
@@ -785,12 +1337,16 @@ module.exports = {
785
1337
  _validateOptions,
786
1338
  _autoCommit,
787
1339
  _buildAutoCommitAllowlist,
1340
+ _filterGitignored,
788
1341
  _resolveStartIteration,
789
1342
  _completedTaskDelta,
790
1343
  _formatAutoCommitMessage,
1344
+ _truncateSubjectSummary,
791
1345
  _buildIterationFeedback,
792
1346
  _extractErrorForIteration,
793
1347
  _getCurrentTaskDescription,
1348
+ _getCurrentTaskMeta,
1349
+ _summarizeFailure,
794
1350
  _cleanupCompletedErrors,
795
1351
  _detectProtectedCommitArtifacts,
796
1352
  _gitErrorMessage,
@@ -799,4 +1355,7 @@ module.exports = {
799
1355
  _failureStageForError,
800
1356
  _errorText,
801
1357
  _appendFatalIterationFailure,
1358
+ _failureFingerprint,
1359
+ _firstNonEmptyLine,
1360
+ _iterationIsStalled,
802
1361
  };