spec-and-loop 3.3.3 → 3.3.4

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.
@@ -22,6 +22,53 @@ const invoker = require('./invoker');
22
22
  const errors = require('./errors');
23
23
  const progress = require('./progress');
24
24
  const lessons = require('./lessons');
25
+ const supervisor = require('./supervisor');
26
+ const handoff = require('./runner-handoff');
27
+ const baselineGate = require('./runner-baseline-gate');
28
+ const autoCommit = require('./runner-autocommit');
29
+ const pendingDirty = require('./runner-pending-dirty');
30
+
31
+ const _extractBlockerNote = handoff._extractBlockerNote;
32
+ const _detectBlockerArtifacts = handoff._detectBlockerArtifacts;
33
+ const _writeHandoff = handoff._writeHandoff;
34
+ const _formatSupervisorHandoffSections = handoff._formatSupervisorHandoffSections;
35
+ const _formatSupervisorList = handoff._formatSupervisorList;
36
+ const _appendFatalIterationFailure = handoff._appendFatalIterationFailure;
37
+ const _summarizeBlockerNote = handoff._summarizeBlockerNote;
38
+
39
+ const _buildBaselineGateFeedback = baselineGate._buildBaselineGateFeedback;
40
+ const _analyzeBaselineGateConflict = baselineGate._analyzeBaselineGateConflict;
41
+ const _formatBaselineGateFeedback = baselineGate._formatBaselineGateFeedback;
42
+ const _extractCurrentTaskBlock = baselineGate._extractCurrentTaskBlock;
43
+ const _detectStrictCleanGates = baselineGate._detectStrictCleanGates;
44
+ const _detectFailingBaselineGates = baselineGate._detectFailingBaselineGates;
45
+ const _detectRecordedBaselineGates = baselineGate._detectRecordedBaselineGates;
46
+ const _detectMissingBaselineGates = baselineGate._detectMissingBaselineGates;
47
+ const _detectAuthorizedBaselineCleanup = baselineGate._detectAuthorizedBaselineCleanup;
48
+ const _baselineGateRepairBudgetUsed = baselineGate._baselineGateRepairBudgetUsed;
49
+ const _baselineGateRepairAttempted = baselineGate._baselineGateRepairAttempted;
50
+
51
+ const _autoCommit = autoCommit._autoCommit;
52
+ const _formatAutoCommitIgnoreBlock = autoCommit._formatAutoCommitIgnoreBlock;
53
+ const _filterGitignored = autoCommit._filterGitignored;
54
+ const _mergePathLists = autoCommit._mergePathLists;
55
+ const _buildAutoCommitAllowlist = autoCommit._buildAutoCommitAllowlist;
56
+ const _completedTaskDelta = autoCommit._completedTaskDelta;
57
+ const _formatAutoCommitMessage = autoCommit._formatAutoCommitMessage;
58
+ const _truncateSubjectSummary = autoCommit._truncateSubjectSummary;
59
+ const _taskIdentity = autoCommit._taskIdentity;
60
+ const _repoRelativePath = autoCommit._repoRelativePath;
61
+ const _detectProtectedCommitArtifacts = autoCommit._detectProtectedCommitArtifacts;
62
+ const _gitErrorMessage = autoCommit._gitErrorMessage;
63
+ const _coerceGitErrorStream = autoCommit._coerceGitErrorStream;
64
+
65
+ const _normalizePendingDirtyPaths = pendingDirty._normalizePendingDirtyPaths;
66
+ const _recordPendingDirtyPaths = pendingDirty._recordPendingDirtyPaths;
67
+ const _remainingPendingDirtyPathsAfterCommit = pendingDirty._remainingPendingDirtyPathsAfterCommit;
68
+ const _refreshPendingDirtyPaths = pendingDirty._refreshPendingDirtyPaths;
69
+ const _samePendingTask = pendingDirty._samePendingTask;
70
+ const _formatPendingDirtyPathsBlock = pendingDirty._formatPendingDirtyPathsBlock;
71
+ const _currentDirtyPathSet = pendingDirty._currentDirtyPathSet;
25
72
 
26
73
  const DEFAULTS = {
27
74
  minIterations: 1,
@@ -54,8 +101,88 @@ const DEFAULTS = {
54
101
  // explicit evidence for a safe, bounded resolution class.
55
102
  autoResolveHandoffs: true,
56
103
  autoResolveHandoffMaxPerRun: 6,
104
+ selfHeal: true,
105
+ selfHealMaxTries: 3,
106
+ selfHealDownstream: true,
107
+ selfHealHints: true,
108
+ selfHealLogAccess: true,
109
+ selfHealVerbose: false,
110
+ ruleCacheEnabled: true,
111
+ validationTimeoutMs: 30000,
57
112
  };
58
113
 
114
+ function _envFlag(value) {
115
+ if (value === undefined) return null;
116
+ return !/^(0|false|no|off)$/i.test(String(value || '').trim());
117
+ }
118
+
119
+ function _envPositiveInteger(value) {
120
+ if (value === undefined) return null;
121
+ const parsed = Number.parseInt(String(value).trim(), 10);
122
+ if (!Number.isInteger(parsed) || parsed <= 0) return null;
123
+ return parsed;
124
+ }
125
+
126
+ function _resolveSupervisorConfig(options) {
127
+ const env = process.env;
128
+ const readBoolean = (cliValue, envName, fallback) => {
129
+ if (typeof cliValue === 'boolean') return cliValue;
130
+ const envValue = _envFlag(env[envName]);
131
+ if (envValue !== null) return envValue;
132
+ return fallback;
133
+ };
134
+ const readPositiveInteger = (cliValue, envName, fallback) => {
135
+ if (Number.isInteger(cliValue) && cliValue > 0) return cliValue;
136
+ const envValue = _envPositiveInteger(env[envName]);
137
+ if (envValue !== null) return envValue;
138
+ return fallback;
139
+ };
140
+
141
+ let selfHealVerbose;
142
+ if (typeof options.selfHealVerbose === 'boolean') {
143
+ selfHealVerbose = options.selfHealVerbose;
144
+ } else if (options.verbose === true) {
145
+ selfHealVerbose = true;
146
+ } else {
147
+ selfHealVerbose = readBoolean(undefined, 'RALPH_SELF_HEAL_VERBOSE', DEFAULTS.selfHealVerbose);
148
+ }
149
+
150
+ return {
151
+ selfHeal: readBoolean(options.selfHeal, 'RALPH_SELF_HEAL', DEFAULTS.selfHeal),
152
+ selfHealMaxTries: readPositiveInteger(
153
+ options.selfHealMaxTries,
154
+ 'RALPH_SELF_HEAL_MAX_TRIES',
155
+ DEFAULTS.selfHealMaxTries,
156
+ ),
157
+ selfHealDownstream: readBoolean(
158
+ options.selfHealDownstream,
159
+ 'RALPH_SELF_HEAL_DOWNSTREAM',
160
+ DEFAULTS.selfHealDownstream,
161
+ ),
162
+ selfHealHints: readBoolean(
163
+ options.selfHealHints,
164
+ 'RALPH_SELF_HEAL_HINTS',
165
+ DEFAULTS.selfHealHints,
166
+ ),
167
+ selfHealLogAccess: readBoolean(
168
+ options.selfHealLogAccess,
169
+ 'RALPH_SELF_HEAL_LOG_ACCESS',
170
+ DEFAULTS.selfHealLogAccess,
171
+ ),
172
+ selfHealVerbose,
173
+ ruleCacheEnabled: readBoolean(
174
+ undefined,
175
+ 'RALPH_SELF_HEAL_RULE_CACHE',
176
+ DEFAULTS.ruleCacheEnabled,
177
+ ),
178
+ validationTimeoutMs: readPositiveInteger(
179
+ undefined,
180
+ 'RALPH_SELF_HEAL_VALIDATION_TIMEOUT_MS',
181
+ DEFAULTS.validationTimeoutMs,
182
+ ),
183
+ };
184
+ }
185
+
59
186
  /**
60
187
  * Determine whether an iteration made any forward progress.
61
188
  *
@@ -186,33 +313,22 @@ function _decideAutoResolveHandoff(config, blockerNote, currentTaskMeta, baselin
186
313
  }
187
314
 
188
315
  const budgetKey = _autoResolveHandoffBudgetKey(currentTaskMeta, classification.className);
189
- const totalAttempts = Number.isInteger(config.state && config.state.totalAttempts)
190
- ? config.state.totalAttempts
191
- : 0;
192
- const maxPerRun = Number.isInteger(config.maxPerRun)
193
- ? config.maxPerRun
194
- : DEFAULTS.autoResolveHandoffMaxPerRun;
195
- const attempts = config.state && config.state.attempts ? config.state.attempts : {};
316
+ const budgetDecision = supervisor._decideBoundedBudget({
317
+ totalAttempts: config.state && config.state.totalAttempts,
318
+ maxTotalAttempts: Number.isInteger(config.maxPerRun)
319
+ ? config.maxPerRun
320
+ : DEFAULTS.autoResolveHandoffMaxPerRun,
321
+ attempts: config.state && config.state.attempts,
322
+ budgetKey,
323
+ });
196
324
 
197
- if (totalAttempts >= maxPerRun) {
198
- return Object.assign({}, classification, {
199
- allowed: false,
200
- reason: 'global_budget_exhausted',
201
- budgetKey,
202
- });
203
- }
204
-
205
- if (attempts[budgetKey]) {
206
- return Object.assign({}, classification, {
207
- allowed: false,
208
- reason: 'task_class_budget_exhausted',
209
- budgetKey,
210
- });
325
+ if (!budgetDecision.allowed) {
326
+ return Object.assign({}, classification, budgetDecision, { budgetKey });
211
327
  }
212
328
 
213
329
  return Object.assign({}, classification, {
214
- allowed: true,
215
- reason: 'authorized',
330
+ allowed: budgetDecision.allowed,
331
+ reason: budgetDecision.reason,
216
332
  budgetKey,
217
333
  });
218
334
  }
@@ -222,20 +338,17 @@ function _consumeAutoResolveHandoffBudget(config, decision, iteration) {
222
338
  return null;
223
339
  }
224
340
 
225
- const attempts = Object.assign({}, config.state.attempts || {});
226
- attempts[decision.budgetKey] = {
227
- className: decision.className,
228
- iteration,
229
- attemptedAt: new Date().toISOString(),
230
- };
231
-
232
- const totalAttempts = (Number.isInteger(config.state.totalAttempts)
233
- ? config.state.totalAttempts
234
- : 0) + 1;
341
+ const nextState = supervisor._consumeBoundedBudget({
342
+ state: config.state,
343
+ budgetKey: decision.budgetKey,
344
+ entry: {
345
+ className: decision.className,
346
+ iteration,
347
+ attemptedAt: new Date().toISOString(),
348
+ },
349
+ });
235
350
 
236
- config.state = Object.assign({}, config.state, {
237
- totalAttempts,
238
- attempts,
351
+ config.state = Object.assign({}, nextState, {
239
352
  lastDecision: {
240
353
  className: decision.className,
241
354
  reason: decision.reason,
@@ -301,285 +414,59 @@ function _errorText(err) {
301
414
  }
302
415
 
303
416
  /**
304
- * Extract the agent's blocker note from iteration output. The convention is:
305
- * the line containing `<promise>BLOCKED_HANDOFF</promise>` MAY be preceded by
306
- * a free-text rationale block (any number of lines up to a sentinel header
307
- * `## Blocker` / `## Blocker Note` / `Blocker:`), and MAY include `## Why:` /
308
- * `## Done-When-Will-Be:` / `## Suggested Next Step:` sections. We capture
309
- * everything from the first sentinel header up to the promise tag, with a
310
- * fallback to the last 40 non-blank lines preceding the tag if no sentinel
311
- * is present, so the operator gets *something* useful even when the agent
312
- * skips the structured format.
313
- *
314
- * @param {string} outputText full iteration stdout
315
- * @param {string} promiseName configured BLOCKED_HANDOFF promise name
316
- * @returns {string} the extracted note (empty string if the tag is absent)
417
+ * Wrapper around `supervisor._recoverSupervisorTmpFiles` that streams a stderr
418
+ * notice for each recovery action and is invoked at runner startup so a
419
+ * crashed prior loop's `tasks.md.supervisor-tmp` / `.supervisor-orig` residue
420
+ * is reconciled before the next iteration runs. This wrapper intentionally
421
+ * lives on the runner side (not in supervisor.js) because the stderr
422
+ * narration is a runner-loop concern; the underlying atomic-rollback semantics
423
+ * are unit-tested in `mini-ralph-supervisor.test.js`.
317
424
  */
318
- function _extractBlockerNote(outputText, promiseName) {
319
- if (!outputText || !promiseName) return '';
320
- const tag = `<promise>${promiseName}</promise>`;
321
- const lines = outputText.split(/\r?\n/);
322
- let tagIdx = -1;
323
- for (let i = 0; i < lines.length; i++) {
324
- if (lines[i].trim() === tag) {
325
- tagIdx = i;
326
- break;
327
- }
328
- }
329
- if (tagIdx === -1) return '';
330
-
331
- // Look backwards for a sentinel header.
332
- const sentinel = /^\s*(##\s*Blocker(\s+Note)?|Blocker:)/i;
333
- let startIdx = tagIdx;
334
- for (let i = tagIdx - 1; i >= 0; i--) {
335
- if (sentinel.test(lines[i])) {
336
- startIdx = i;
337
- break;
338
- }
425
+ function _recoverSupervisorStartupResidue(options = {}) {
426
+ const tasksFile = options.tasksFile ? path.resolve(options.tasksFile) : '';
427
+ const changeDir = options.changeDir
428
+ ? path.resolve(options.changeDir)
429
+ : (tasksFile ? path.dirname(tasksFile) : '');
430
+
431
+ if (!changeDir) {
432
+ return { recovered: false, actions: [] };
339
433
  }
340
434
 
341
- if (startIdx === tagIdx) {
342
- // No sentinel fall back to the last 40 non-blank lines before the tag.
343
- const window = [];
344
- for (let i = tagIdx - 1; i >= 0 && window.length < 40; i--) {
345
- const l = lines[i];
346
- if (l.trim()) window.unshift(l);
435
+ const recovery = supervisor._recoverSupervisorTmpFiles({
436
+ tasksFile: tasksFile || path.join(changeDir, 'tasks.md'),
437
+ });
438
+
439
+ if (recovery.recovered && Array.isArray(recovery.actions)) {
440
+ for (const action of recovery.actions) {
441
+ process.stderr.write(`[mini-ralph] supervisor recovery: ${action}\n`);
347
442
  }
348
- return window.join('\n').trim();
349
443
  }
350
444
 
351
- return lines.slice(startIdx, tagIdx).join('\n').trim();
445
+ return recovery;
352
446
  }
353
447
 
354
- /**
355
- * Scan well-known locations for blocker / diagnostic artifacts the agent
356
- * may have written during the most recent iteration, and return their
357
- * content (truncated) so we can tee it into the next iteration's prompt.
358
- *
359
- * The motivation is the failure mode we observed in the wild: the agent
360
- * writes `<change-baseline>/shared-chrome-invariant-report.txt` with a clear
361
- * `STATUS=BLOCKED REASON=...` diagnosis, then on the next iteration starts
362
- * from a blank slate, re-derives the same diagnosis, and burns another full
363
- * LLM cycle. By auto-detecting and surfacing the artifact, the agent gets
364
- * its own prior diagnosis as input on the next turn, freeing it to either
365
- * (a) act on it, or (b) emit BLOCKED_HANDOFF with a richer note.
366
- *
367
- * Probe paths (relative to ralphDir's parent — i.e. the change root):
368
- * - <ralphDir>/HANDOFF.md
369
- * - <ralphDir>/BLOCKED.md
370
- * - <ralphDir>/blocker.md / blocker-note.md
371
- * - <repoRoot>/.ralph/baselines/<change>/*report*.{txt,md}
372
- * - any file under <ralphDir> matching /(blocker|handoff|invariant-report)\.[a-z]+$/i
373
- *
374
- * We cap the returned text at 1500 chars per artifact and 3 artifacts total
375
- * so the feedback block stays bounded. Freshness is required by default to
376
- * avoid carrying stale diagnostics forever; when a prior run explicitly ended
377
- * with BLOCKED_HANDOFF, the canonical handoff files may be included even when
378
- * stale because they are the persisted operator-facing diagnosis.
379
- *
380
- * @param {string} ralphDir
381
- * @param {object} [options] { repoRoot, maxArtifacts = 3, maxCharsEach = 1500, includeStaleHandoff = false }
382
- * @returns {Array<{ path: string, content: string, truncated: boolean }>}
383
- */
384
- function _detectBlockerArtifacts(ralphDir, options) {
385
- const fs = require('fs');
386
- const fsPath = require('path');
387
- const opts = Object.assign(
388
- {
389
- repoRoot: process.cwd(),
390
- maxArtifacts: 3,
391
- maxCharsEach: 1500,
392
- includeStaleHandoff: false,
393
- },
394
- options || {}
395
- );
396
-
397
- if (!ralphDir || !fs.existsSync(ralphDir)) return [];
398
-
399
- const matches = new Map(); // path -> mtimeMs (dedup by absolute path)
400
- const isHandoffArtifact = (name) =>
401
- /^(handoff|blocked|blocker(-note)?)\.(md|txt)$/i.test(name);
402
- const isInteresting = (name) =>
403
- isHandoffArtifact(name) ||
404
- /(invariant|blocker|handoff).*report\.(md|txt)$/i.test(name) ||
405
- /report\.(md|txt)$/i.test(name);
406
-
407
- const consider = (p) => {
408
- try {
409
- const st = fs.statSync(p);
410
- if (!st.isFile()) return;
411
- // Files larger than 1MB are almost certainly not human-curated blocker
412
- // notes; skip them so we don't load logs or screenshots into the prompt.
413
- if (st.size > 1024 * 1024) return;
414
- // Only surface artifacts touched within the last ~10 minutes — older
415
- // files are almost always stale leftovers from prior runs, and the
416
- // failure mode we care about (repeated diagnosis with no progress)
417
- // produces fresh writes every iteration.
418
- const stale = Date.now() - st.mtimeMs > 10 * 60 * 1000;
419
- if (stale && !(opts.includeStaleHandoff && isHandoffArtifact(fsPath.basename(p)))) {
420
- return;
421
- }
422
- matches.set(fsPath.resolve(p), st.mtimeMs);
423
- } catch (_) {
424
- // ENOENT / permission errors: ignore — this is a best-effort probe.
425
- }
426
- };
448
+ function _resolveChangeDirFromTasksFile(tasksFile) {
449
+ if (!tasksFile) return '';
450
+ return path.dirname(path.resolve(tasksFile));
451
+ }
427
452
 
428
- // 1) Direct ralphDir scan, one level deep. .ralph/ is small, so a flat
429
- // listing is cheap and bounded.
430
- try {
431
- const entries = fs.readdirSync(ralphDir, { withFileTypes: true });
432
- for (const ent of entries) {
433
- if (ent.isFile() && isInteresting(ent.name)) {
434
- consider(fsPath.join(ralphDir, ent.name));
435
- }
436
- }
437
- } catch (_) { /* ignore */ }
453
+ function _resolveOpenspecRootFromTasksFile(tasksFile) {
454
+ if (!tasksFile) return '';
438
455
 
439
- // 2) Convention-based baseline location used by spec-and-loop changes:
440
- // <repoRoot>/.ralph/baselines/<change>/*report*.{txt,md}
441
- // The change name is the parent directory of ralphDir's parent in the
442
- // OpenSpec layout (e.g. .../changes/<name>/.ralph), so we derive it.
443
- try {
444
- const changeDir = fsPath.dirname(ralphDir);
445
- const changeName = fsPath.basename(changeDir);
446
- const baselinesDir = fsPath.join(opts.repoRoot, '.ralph', 'baselines', changeName);
447
- if (fs.existsSync(baselinesDir)) {
448
- const entries = fs.readdirSync(baselinesDir, { withFileTypes: true });
449
- for (const ent of entries) {
450
- if (ent.isFile() && isInteresting(ent.name)) {
451
- consider(fsPath.join(baselinesDir, ent.name));
452
- }
453
- }
456
+ let current = path.dirname(path.resolve(tasksFile));
457
+ while (current) {
458
+ if (path.basename(current) === 'openspec') {
459
+ return current;
454
460
  }
455
- } catch (_) { /* ignore */ }
456
461
 
457
- if (matches.size === 0) return [];
458
-
459
- // Sort by mtime descending so the freshest artifact wins when we cap.
460
- const sorted = Array.from(matches.entries())
461
- .sort((a, b) => b[1] - a[1])
462
- .map(([p]) => p);
463
-
464
- const out = [];
465
- for (const p of sorted.slice(0, opts.maxArtifacts)) {
466
- try {
467
- const raw = fs.readFileSync(p, 'utf8');
468
- const truncated = raw.length > opts.maxCharsEach;
469
- const content = truncated ? raw.slice(0, opts.maxCharsEach) : raw;
470
- out.push({
471
- path: fsPath.relative(opts.repoRoot, p) || p,
472
- content: content.trim(),
473
- truncated,
474
- });
475
- } catch (_) {
476
- // Ignore unreadable artifacts.
462
+ const parent = path.dirname(current);
463
+ if (parent === current) {
464
+ break;
477
465
  }
466
+ current = parent;
478
467
  }
479
468
 
480
- return out;
481
- }
482
-
483
- /**
484
- * Write the agent's blocker note to <ralphDir>/HANDOFF.md with iteration
485
- * metadata so an operator can reproduce the context. Appends rather than
486
- * overwrites: a single change can hit several BLOCKED_HANDOFFs over time
487
- * (operator unblocks, loop resumes, hits a different blocker), and we want
488
- * the full audit trail in one file.
489
- *
490
- * @param {string} ralphDir
491
- * @param {object} entry { iteration, task, note, completionPromise, taskPromise }
492
- * @returns {string} the absolute path to HANDOFF.md
493
- */
494
- function _writeHandoff(ralphDir, entry) {
495
- const fs = require('fs');
496
- const fsPath = require('path');
497
- if (!fs.existsSync(ralphDir)) {
498
- fs.mkdirSync(ralphDir, { recursive: true });
499
- }
500
- const handoffPath = fsPath.join(ralphDir, 'HANDOFF.md');
501
- const ts = new Date().toISOString();
502
- const taskLine = entry.task && entry.task !== 'N/A'
503
- ? entry.task
504
- : '(no task in progress)';
505
- const noteBlock = entry.note && entry.note.trim()
506
- ? entry.note.trim()
507
- : '(agent emitted BLOCKED_HANDOFF without a structured blocker note;\n' +
508
- 'check the iteration stdout log for the rationale)';
509
-
510
- const section = [
511
- '',
512
- `## Iteration ${entry.iteration} — ${ts}`,
513
- '',
514
- `**Task:** ${taskLine}`,
515
- '',
516
- '**Agent blocker note:**',
517
- '',
518
- noteBlock,
519
- '',
520
- '**Operator next step:** investigate the blocker, take one of the actions',
521
- 'the task spec authorizes (revert / isolate / justify / escalate), then',
522
- 'rerun `ralph-run` to resume.',
523
- '',
524
- '---',
525
- '',
526
- ].join('\n');
527
-
528
- let existing = '';
529
- if (fs.existsSync(handoffPath)) {
530
- existing = fs.readFileSync(handoffPath, 'utf8');
531
- } else {
532
- existing = '# Ralph Handoff Log\n\nThis file is appended whenever the loop\n' +
533
- 'exits with `BLOCKED_HANDOFF`. Each section is one blocker the\n' +
534
- 'agent surfaced — review newest first.\n';
535
- }
536
- fs.writeFileSync(handoffPath, existing + section, 'utf8');
537
- return handoffPath;
538
- }
539
-
540
- function _appendFatalIterationFailure(ralphDir, entry) {
541
- errors.append(ralphDir, {
542
- iteration: entry.iteration,
543
- task: entry.task,
544
- exitCode: entry.exitCode,
545
- signal: entry.signal || '',
546
- failureStage: entry.failureStage || '',
547
- stderr: entry.stderr || '',
548
- stdout: entry.stdout || '',
549
- });
550
-
551
- history.append(ralphDir, {
552
- iteration: entry.iteration,
553
- duration: entry.duration,
554
- completionDetected: false,
555
- taskDetected: false,
556
- toolUsage: [],
557
- filesChanged: [],
558
- exitCode: entry.exitCode,
559
- signal: entry.signal || '',
560
- failureStage: entry.failureStage || '',
561
- completedTasks: [],
562
- commitAttempted: false,
563
- commitCreated: false,
564
- commitAnomaly: '',
565
- commitAnomalyType: '',
566
- protectedArtifacts: [],
567
- promptBytes: entry.promptBytes || 0,
568
- promptChars: entry.promptChars || 0,
569
- promptTokens: entry.promptTokens || 0,
570
- responseBytes: entry.responseBytes || 0,
571
- responseChars: entry.responseChars || 0,
572
- responseTokens: entry.responseTokens || 0,
573
- truncated: entry.truncated || false,
574
- });
575
- }
576
-
577
- function _summarizeBlockerNote(note, limit = 500) {
578
- if (!note || typeof note !== 'string') return '';
579
- const oneLine = note.replace(/\s+/g, ' ').trim();
580
- if (!oneLine) return '';
581
- if (oneLine.length <= limit) return oneLine;
582
- return `${oneLine.slice(0, Math.max(0, limit - 1)).replace(/\s+$/, '')}…`;
469
+ return '';
583
470
  }
584
471
 
585
472
  /**
@@ -591,6 +478,13 @@ function _summarizeBlockerNote(note, limit = 500) {
591
478
  async function run(opts) {
592
479
  const options = Object.assign({}, DEFAULTS, opts);
593
480
  _validateOptions(options);
481
+ const supervisorConfig = _resolveSupervisorConfig(options);
482
+ const changeDir = options.changeDir || _resolveChangeDirFromTasksFile(options.tasksFile);
483
+ const openspecRoot = _resolveOpenspecRootFromTasksFile(options.tasksFile);
484
+
485
+ if (options.tasksFile) {
486
+ _recoverSupervisorStartupResidue({ tasksFile: options.tasksFile });
487
+ }
594
488
 
595
489
  const ralphDir = options.ralphDir;
596
490
  const runLock = state.acquireRunLock(ralphDir, {
@@ -617,6 +511,7 @@ async function run(opts) {
617
511
  let iterationCount = 0;
618
512
  let completed = false;
619
513
  let exitReason = 'max_iterations';
514
+ let pendingSupervisorHints = [];
620
515
  // Consecutive iterations that succeeded but produced no progress signal.
621
516
  // Reset whenever any progress is detected (or when the iteration failed, so
622
517
  // transient infra errors don't trip the stall detector).
@@ -736,7 +631,12 @@ async function run(opts) {
736
631
  // Build the prompt for this iteration
737
632
  let renderedPrompt;
738
633
  try {
739
- renderedPrompt = await prompt.render(options, iterationCount);
634
+ renderedPrompt = await prompt.render(Object.assign({}, options, {
635
+ changeDir,
636
+ selfHealHints: supervisorConfig.selfHealHints,
637
+ supervisorHints: pendingSupervisorHints,
638
+ }), iterationCount);
639
+ pendingSupervisorHints = [];
740
640
  } catch (err) {
741
641
  err.failureStage = err.failureStage || 'prompt_render';
742
642
  throw err;
@@ -880,6 +780,9 @@ async function run(opts) {
880
780
  const blockerNote = hasBlockedHandoff
881
781
  ? _extractBlockerNote(outputText, blockedHandoffPromise)
882
782
  : '';
783
+ const autoResolveHandoffClassification = hasBlockedHandoff
784
+ ? _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict)
785
+ : null;
883
786
  const autoResolveHandoffDecision = hasBlockedHandoff
884
787
  ? _decideAutoResolveHandoff(
885
788
  autoResolveHandoffs,
@@ -888,6 +791,38 @@ async function run(opts) {
888
791
  baselineGateConflict,
889
792
  )
890
793
  : null;
794
+ let supervisorResult = null;
795
+ if (
796
+ hasBlockedHandoff &&
797
+ !autoResolveHandoffClassification &&
798
+ supervisorConfig.selfHeal === true &&
799
+ options.tasksFile
800
+ ) {
801
+ const previousTasksFileEnv = process.env.RALPH_TASKS_FILE;
802
+ try {
803
+ process.env.RALPH_TASKS_FILE = options.tasksFile;
804
+ supervisorResult = await supervisor.runSupervisor({
805
+ blockerNote,
806
+ ralphDir,
807
+ changeDir,
808
+ openspecRoot,
809
+ tasksFile: options.tasksFile,
810
+ config: supervisorConfig,
811
+ iteration: iterationCount,
812
+ model: options.model,
813
+ });
814
+ } catch (error) {
815
+ process.stderr.write(
816
+ `[mini-ralph] warning: supervisor invocation failed: ${error.message}\n`
817
+ );
818
+ } finally {
819
+ if (previousTasksFileEnv === undefined) {
820
+ delete process.env.RALPH_TASKS_FILE;
821
+ } else {
822
+ process.env.RALPH_TASKS_FILE = previousTasksFileEnv;
823
+ }
824
+ }
825
+ }
891
826
  if (autoResolveHandoffDecision && autoResolveHandoffDecision.allowed) {
892
827
  const nextAutoResolveState = _consumeAutoResolveHandoffBudget(
893
828
  autoResolveHandoffs,
@@ -902,6 +837,11 @@ async function run(opts) {
902
837
  ? tasks.parseTasks(options.tasksFile)
903
838
  : [];
904
839
  const completedTasks = _completedTaskDelta(tasksBefore, tasksAfter);
840
+ const supervisorState = supervisorResult ? state.read(ralphDir) : null;
841
+ const supervisorTryIndex = supervisorState && supervisorState.supervisor &&
842
+ Number.isInteger(supervisorState.supervisor.totalAttemptsForCurrentBlocker)
843
+ ? supervisorState.supervisor.totalAttemptsForCurrentBlocker
844
+ : undefined;
905
845
 
906
846
  let commitResult = { attempted: false, committed: false, anomaly: null };
907
847
 
@@ -989,6 +929,24 @@ async function run(opts) {
989
929
  autoResolveHandoffAllowedFiles: autoResolveHandoffDecision.allowedFiles || [],
990
930
  }
991
931
  : {}),
932
+ ...(supervisorResult
933
+ ? {
934
+ supervisorInvoked: true,
935
+ ...(supervisorTryIndex !== undefined ? { supervisorTryIndex } : {}),
936
+ supervisorOutcome: supervisorResult.outcome || '',
937
+ supervisorPatchedTasks: supervisorResult.patchedTasks || [],
938
+ supervisorBlockerHash: supervisorResult.blockerHash || '',
939
+ supervisorSoftWarnings: supervisorResult.softWarnings || [],
940
+ supervisorHints: supervisorResult.hints || [],
941
+ supervisorHintsDropped: supervisorResult.hintsDropped || [],
942
+ supervisorReadLogs: Object.prototype.hasOwnProperty.call(supervisorResult, 'readLogs')
943
+ ? supervisorResult.readLogs
944
+ : null,
945
+ supervisorReadLogsBytes: Object.prototype.hasOwnProperty.call(supervisorResult, 'readLogsBytes')
946
+ ? supervisorResult.readLogsBytes
947
+ : null,
948
+ }
949
+ : {}),
992
950
  commitAttempted: commitResult.attempted,
993
951
  commitCreated: commitResult.committed,
994
952
  commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
@@ -1020,6 +978,25 @@ async function run(opts) {
1020
978
  ...(result.lastStdoutBytes !== undefined ? { lastStdoutBytes: result.lastStdoutBytes } : {}),
1021
979
  ...(result.lastStderrBytes !== undefined ? { lastStderrBytes: result.lastStderrBytes } : {}),
1022
980
  });
981
+ if (supervisorResult && supervisorResult.outcome === 'patch_applied') {
982
+ for (const taskNumber of supervisorResult.patchedTasks || []) {
983
+ history.append(ralphDir, {
984
+ type: 'supervisorEdit',
985
+ iteration: iterationCount,
986
+ blockerHash: supervisorResult.blockerHash || '',
987
+ tryIndex: supervisorTryIndex === undefined ? null : supervisorTryIndex,
988
+ taskNumber,
989
+ rationaleSummary: supervisorResult.summary || '',
990
+ validatorOk: true,
991
+ softWarnings: supervisorResult.softWarnings || [],
992
+ });
993
+ }
994
+ }
995
+ if (supervisorResult && supervisorResult.outcome === 'patch_applied') {
996
+ pendingSupervisorHints = Array.isArray(supervisorResult.hints)
997
+ ? supervisorResult.hints.slice()
998
+ : [];
999
+ }
1023
1000
 
1024
1001
  // Stall detection is computed *before* the progress event so the
1025
1002
  // reporter can show the live streak alongside the badge. We still
@@ -1082,6 +1059,18 @@ async function run(opts) {
1082
1059
  iteration: iterationCount,
1083
1060
  task: currentTask,
1084
1061
  note: blockerNote,
1062
+ supervisor: supervisorResult
1063
+ ? {
1064
+ iteration: iterationCount,
1065
+ tryIndex: supervisorTryIndex,
1066
+ blockerHash: supervisorResult.blockerHash || '',
1067
+ patchedTasks: supervisorResult.patchedTasks || [],
1068
+ softWarnings: supervisorResult.softWarnings || [],
1069
+ summary: supervisorResult.summary || '',
1070
+ attempts: supervisorResult.attempts || [],
1071
+ attemptsExhausted: supervisorResult.attemptsExhausted === true,
1072
+ }
1073
+ : null,
1085
1074
  completionPromise,
1086
1075
  taskPromise,
1087
1076
  });
@@ -1111,6 +1100,15 @@ async function run(opts) {
1111
1100
  }
1112
1101
  continue;
1113
1102
  }
1103
+ if (supervisorResult && supervisorResult.outcome === 'patch_applied') {
1104
+ reporter.note('supervisor applied a tasks.md patch; continuing.', 'warn');
1105
+ if (options.verbose) {
1106
+ process.stderr.write(
1107
+ `[mini-ralph] supervisor applied a tasks.md patch at iteration ${iterationCount}; continuing.\n`
1108
+ );
1109
+ }
1110
+ continue;
1111
+ }
1114
1112
  if (options.verbose) {
1115
1113
  process.stderr.write(
1116
1114
  `[mini-ralph] ${blockedHandoffPromise} detected at iteration ${iterationCount}; halting.\n`
@@ -1199,145 +1197,6 @@ function _containsPromise(text, promiseName) {
1199
1197
  .some((line) => line.trim() === expectedTag);
1200
1198
  }
1201
1199
 
1202
- function _normalizePendingDirtyPaths(pending) {
1203
- if (!pending || typeof pending !== 'object') return null;
1204
- const files = _mergePathLists(pending.files || pending.paths || []);
1205
- if (files.length === 0) return null;
1206
-
1207
- return {
1208
- iteration: typeof pending.iteration === 'number' ? pending.iteration : null,
1209
- reason: pending.reason || 'blocked_handoff',
1210
- task: pending.task || '',
1211
- taskNumber: pending.taskNumber || '',
1212
- taskDescription: pending.taskDescription || '',
1213
- files,
1214
- recordedAt: pending.recordedAt || new Date().toISOString(),
1215
- };
1216
- }
1217
-
1218
- function _recordPendingDirtyPaths(existing, update) {
1219
- const normalized = _normalizePendingDirtyPaths({
1220
- iteration: update && typeof update.iteration === 'number' ? update.iteration : null,
1221
- reason: update && update.reason ? update.reason : 'blocked_handoff',
1222
- task: update && update.task ? update.task : '',
1223
- taskNumber: update && update.taskNumber ? update.taskNumber : '',
1224
- taskDescription: update && update.taskDescription ? update.taskDescription : '',
1225
- files: _mergePathLists(
1226
- existing && existing.files ? existing.files : [],
1227
- update && update.files ? update.files : []
1228
- ),
1229
- recordedAt: update && update.recordedAt ? update.recordedAt : new Date().toISOString(),
1230
- });
1231
-
1232
- return normalized;
1233
- }
1234
-
1235
- function _remainingPendingDirtyPathsAfterCommit(pending, anomaly) {
1236
- const normalized = _normalizePendingDirtyPaths(pending);
1237
- if (!normalized) return null;
1238
-
1239
- const ignoredPaths = anomaly && Array.isArray(anomaly.ignoredPaths)
1240
- ? anomaly.ignoredPaths.map(_repoRelativePath).filter(Boolean)
1241
- : [];
1242
- if (ignoredPaths.length === 0) return null;
1243
-
1244
- const ignoredSet = new Set(ignoredPaths);
1245
- const files = normalized.files.filter((file) => ignoredSet.has(file));
1246
- if (files.length === 0) return null;
1247
- return Object.assign({}, normalized, { files });
1248
- }
1249
-
1250
- function _refreshPendingDirtyPaths(pending) {
1251
- const normalized = _normalizePendingDirtyPaths(pending);
1252
- if (!normalized) return null;
1253
-
1254
- const dirtyPaths = _currentDirtyPathSet();
1255
- if (!dirtyPaths) return normalized;
1256
- const files = normalized.files.filter((file) => dirtyPaths.has(file));
1257
- if (files.length === 0) return null;
1258
-
1259
- return Object.assign({}, normalized, { files });
1260
- }
1261
-
1262
- function _samePendingTask(pending, currentTaskMeta, currentTask) {
1263
- if (!pending) return true;
1264
- const currentNumber = currentTaskMeta && currentTaskMeta.number ? currentTaskMeta.number : '';
1265
- const currentDescription = currentTaskMeta && currentTaskMeta.description ? currentTaskMeta.description : '';
1266
- const currentFull = currentTask || '';
1267
-
1268
- if (pending.taskNumber && currentNumber) {
1269
- return pending.taskNumber === currentNumber;
1270
- }
1271
-
1272
- if (pending.taskDescription && currentDescription) {
1273
- return pending.taskDescription === currentDescription;
1274
- }
1275
-
1276
- return Boolean(pending.task && currentFull && pending.task === currentFull);
1277
- }
1278
-
1279
- function _formatPendingDirtyPathsBlock(pending, currentTaskMeta, currentTask) {
1280
- const currentStamp = currentTaskMeta && currentTaskMeta.number
1281
- ? `${currentTaskMeta.number} ${currentTaskMeta.description || ''}`.trim()
1282
- : (currentTask || 'the current task');
1283
- const pendingStamp = pending.taskNumber
1284
- ? `${pending.taskNumber} ${pending.taskDescription || ''}`.trim()
1285
- : (pending.task || 'a prior blocked handoff');
1286
- const files = (pending.files || []).slice(0, 8);
1287
- const extra = (pending.files || []).length - files.length;
1288
- const fileLines = files.map((file) => ` - ${file}`).join('\n');
1289
- const suffix = extra > 0 ? `\n - (+${extra} more)` : '';
1290
-
1291
- return [
1292
- `pending dirty paths from ${pending.reason || 'blocked_handoff'} iteration ${pending.iteration || 'unknown'} remain unresolved.`,
1293
- `Prior task: ${pendingStamp}`,
1294
- `Current task: ${currentStamp}`,
1295
- 'Resolve the prior patch before Ralph can safely continue: commit it with the same task, revert it, or move it to a separate change.',
1296
- 'Pending paths:',
1297
- `${fileLines}${suffix}`,
1298
- ].join('\n');
1299
- }
1300
-
1301
- function _currentDirtyPathSet() {
1302
- try {
1303
- const output = childProcess.execFileSync('git', ['status', '--porcelain'], {
1304
- encoding: 'utf8',
1305
- stdio: ['pipe', 'pipe', 'pipe'],
1306
- });
1307
- const paths = new Set();
1308
- for (const line of output.split('\n')) {
1309
- for (const file of _parseGitStatusPaths(line)) {
1310
- if (file) paths.add(file);
1311
- }
1312
- }
1313
- return paths;
1314
- } catch (_) {
1315
- return null;
1316
- }
1317
- }
1318
-
1319
- function _parseGitStatusPaths(line) {
1320
- if (!line || typeof line !== 'string') return [];
1321
- const rawPath = line.slice(3).trim();
1322
- if (!rawPath) return [];
1323
- if (rawPath.includes(' -> ')) {
1324
- return rawPath.split(' -> ').map(_stripGitStatusQuotes).filter(Boolean);
1325
- }
1326
- return [_stripGitStatusQuotes(rawPath)].filter(Boolean);
1327
- }
1328
-
1329
- function _stripGitStatusQuotes(value) {
1330
- if (!value) return '';
1331
- const trimmed = value.trim();
1332
- if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
1333
- return trimmed;
1334
- }
1335
- return trimmed
1336
- .slice(1, -1)
1337
- .replace(/\\"/g, '"')
1338
- .replace(/\\\\/g, '\\');
1339
- }
1340
-
1341
1200
  /**
1342
1201
  * Validate required options and throw descriptive errors.
1343
1202
  *
@@ -1364,356 +1223,6 @@ function _validateOptions(options) {
1364
1223
  }
1365
1224
  }
1366
1225
 
1367
- /**
1368
- * Format the loud direct stderr block for auto-commit ignore-filter events.
1369
- * Emitted via process.stderr.write (bypassing reporter dedup/buffering) on
1370
- * every iteration where paths_ignored_filtered or all_paths_ignored fires.
1371
- * (task 5.1 — surface-autocommit-ignore-warning-and-watchdog)
1372
- *
1373
- * @param {number} iteration
1374
- * @param {{ type: string, ignoredPaths: string[] }} anomaly
1375
- * @returns {string}
1376
- */
1377
- function _formatAutoCommitIgnoreBlock(iteration, anomaly) {
1378
- const SEP = '================================================================================\n';
1379
- const pathLines = (anomaly.ignoredPaths || []).map(p => ` - ${p}`).join('\n');
1380
- return (
1381
- SEP +
1382
- `⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration ${iteration}, type: ${anomaly.type})\n` +
1383
- `Paths filtered because .gitignore matches:\n` +
1384
- pathLines + '\n' +
1385
- `Consequence: these paths are NOT in the latest commit.\n` +
1386
- `Remediation (pick one):\n` +
1387
- ` 1. git add -f <path> # one-time unblock, if you want it tracked\n` +
1388
- ` 2. edit .gitignore # narrow or remove the matching rule\n` +
1389
- ` 3. pass --no-auto-commit on the ralph-run invocation\n` +
1390
- SEP
1391
- );
1392
- }
1393
-
1394
- /**
1395
- * Auto-commit changed files after a successful iteration.
1396
- * Silently skips if git is unavailable, there is nothing to commit, or the
1397
- * iteration did not complete any tasks.
1398
- *
1399
- * @param {number} iteration
1400
- * @param {object} opts
1401
- * @param {Array<object>} [opts.completedTasks]
1402
- * @param {Array<string>} [opts.filesToStage]
1403
- * @param {boolean} [opts.verbose]
1404
- */
1405
- function _autoCommit(iteration, opts = {}) {
1406
- const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false, reporter = null } = opts;
1407
- const message = _formatAutoCommitMessage(iteration, completedTasks);
1408
-
1409
- if (!message) {
1410
- if (verbose) {
1411
- process.stderr.write('[mini-ralph] auto-commit skipped: no completed tasks detected\n');
1412
- }
1413
- return { attempted: false, committed: false, anomaly: null };
1414
- }
1415
-
1416
- if (!Array.isArray(filesToStage) || filesToStage.length === 0) {
1417
- if (verbose) {
1418
- process.stderr.write('[mini-ralph] auto-commit skipped: no iteration files to stage\n');
1419
- }
1420
- return { attempted: false, committed: false, anomaly: null };
1421
- }
1422
-
1423
- const protectedArtifacts = _detectProtectedCommitArtifacts(filesToStage, tasksFile);
1424
- if (protectedArtifacts.length > 0) {
1425
- const anomaly = {
1426
- type: 'protected_artifacts',
1427
- message:
1428
- 'Auto-commit blocked: loop-managed commits cannot include protected OpenSpec artifacts: ' +
1429
- protectedArtifacts.join(', '),
1430
- protectedArtifacts,
1431
- };
1432
-
1433
- process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
1434
- return { attempted: true, committed: false, anomaly };
1435
- }
1436
-
1437
- const { kept: keptPaths, dropped: droppedPaths } = _filterGitignored(filesToStage, process.cwd());
1438
-
1439
- if (droppedPaths.length > 0) {
1440
- const pathWord = droppedPaths.length === 1 ? 'path' : 'paths';
1441
- const allIgnored = keptPaths.length === 0;
1442
- const warnLines = allIgnored
1443
- ? [
1444
- `auto-commit iter ${iteration} skipped: all ${droppedPaths.length} ${pathWord} are gitignored`,
1445
- ...droppedPaths.map(p => ` - ${p}`),
1446
- ' hint: `git add -f <path>` once, or adjust .gitignore',
1447
- ].join('\n')
1448
- : [
1449
- `auto-commit iter ${iteration}: filtered ${droppedPaths.length} gitignored ${pathWord}, committing ${keptPaths.length} ${keptPaths.length === 1 ? 'other' : 'others'}`,
1450
- ...droppedPaths.map(p => ` - ${p}`),
1451
- ].join('\n');
1452
- if (reporter) {
1453
- reporter.note(warnLines, 'error');
1454
- } else {
1455
- const fallbackMsg = allIgnored
1456
- ? `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`
1457
- : `Auto-commit filtered gitignored paths: ${droppedPaths.join(', ')}`;
1458
- process.stderr.write(`[mini-ralph] warning: ${fallbackMsg}\n`);
1459
- }
1460
- if (allIgnored) {
1461
- const anomaly = {
1462
- type: 'all_paths_ignored',
1463
- message: `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`,
1464
- ignoredPaths: droppedPaths,
1465
- };
1466
- // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
1467
- process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
1468
- return {
1469
- attempted: true,
1470
- committed: false,
1471
- anomaly,
1472
- };
1473
- }
1474
- }
1475
-
1476
- const stagePaths = droppedPaths.length > 0 ? keptPaths : filesToStage;
1477
-
1478
- try {
1479
- // Use `git add -A -- <paths>` (not plain `git add -- <paths>`) so deletions
1480
- // and renames are staged alongside modifications/additions. Tasks that call
1481
- // `git rm` via a shell tool leave the path absent from the working tree but
1482
- // still present in `git status --porcelain`, which means the plain form
1483
- // would error with `fatal: pathspec did not match`. Scoping to the per-path
1484
- // allowlist preserves the protected-artifact guarantee.
1485
- childProcess.execFileSync('git', ['add', '-A', '--', ...stagePaths], {
1486
- stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
1487
- encoding: 'utf8',
1488
- });
1489
-
1490
- const stagedFiles = childProcess.execFileSync('git', ['diff', '--cached', '--name-only'], {
1491
- stdio: ['pipe', 'pipe', 'pipe'],
1492
- encoding: 'utf8',
1493
- });
1494
-
1495
- if (!stagedFiles.trim()) {
1496
- const anomaly = {
1497
- type: 'nothing_staged',
1498
- message: 'Auto-commit failed: nothing was staged after git add',
1499
- };
1500
-
1501
- process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
1502
- if (verbose) {
1503
- process.stderr.write('[mini-ralph] auto-commit skipped: nothing staged\n');
1504
- }
1505
- return { attempted: true, committed: false, anomaly };
1506
- }
1507
-
1508
- childProcess.execFileSync('git', ['commit', '-m', message], {
1509
- stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
1510
- encoding: 'utf8',
1511
- });
1512
-
1513
- if (verbose) {
1514
- process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
1515
- }
1516
- if (droppedPaths.length > 0) {
1517
- const anomaly = {
1518
- type: 'paths_ignored_filtered',
1519
- message: 'Auto-commit succeeded but filtered gitignored paths: ' + droppedPaths.join(', '),
1520
- ignoredPaths: droppedPaths,
1521
- };
1522
- // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
1523
- process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
1524
- return {
1525
- attempted: true,
1526
- committed: true,
1527
- anomaly,
1528
- };
1529
- }
1530
- return { attempted: true, committed: true, anomaly: null };
1531
- } catch (err) {
1532
- const anomaly = {
1533
- type: 'commit_failed',
1534
- message: `Auto-commit failed: ${_gitErrorMessage(err)}`,
1535
- };
1536
-
1537
- process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
1538
- return { attempted: true, committed: false, anomaly };
1539
- }
1540
- }
1541
-
1542
- /**
1543
- * Filter gitignored paths out of a list using `git check-ignore --stdin`.
1544
- *
1545
- * Exit-code semantics of `git check-ignore`:
1546
- * 0 – at least one path is ignored; stdout lists the ignored paths.
1547
- * 1 – no paths are ignored (Node's execFileSync throws; we catch status===1).
1548
- * other / ENOENT / any thrown error – fallback: treat all paths as kept.
1549
- *
1550
- * @param {string[]} paths - Repo-relative paths to test.
1551
- * @param {string} cwd - Working directory for the git command.
1552
- * @returns {{ kept: string[], dropped: string[] }}
1553
- */
1554
- function _filterGitignored(paths, cwd) {
1555
- if (!Array.isArray(paths) || paths.length === 0) {
1556
- return { kept: [], dropped: [] };
1557
- }
1558
-
1559
- try {
1560
- const stdout = childProcess.execFileSync(
1561
- 'git',
1562
- ['check-ignore', '--stdin'],
1563
- {
1564
- input: paths.join('\n'),
1565
- cwd: cwd || process.cwd(),
1566
- stdio: ['pipe', 'pipe', 'pipe'],
1567
- encoding: 'utf8',
1568
- }
1569
- );
1570
-
1571
- // Exit code 0: stdout lists ignored paths (one per line).
1572
- const dropped = stdout
1573
- .split('\n')
1574
- .map((l) => l.trim())
1575
- .filter(Boolean);
1576
- const droppedSet = new Set(dropped);
1577
- const kept = paths.filter((p) => !droppedSet.has(p));
1578
- return { kept, dropped };
1579
- } catch (err) {
1580
- // exit status 1 means "no paths ignored" — treat as success with no drops.
1581
- if (err && err.status === 1) {
1582
- return { kept: paths.slice(), dropped: [] };
1583
- }
1584
- // Any other error (ENOENT, unexpected exit code, etc.) — fallback, never crash.
1585
- return { kept: paths.slice(), dropped: [] };
1586
- }
1587
- }
1588
-
1589
- function _mergePathLists(...lists) {
1590
- const merged = new Set();
1591
- for (const list of lists) {
1592
- for (const file of list || []) {
1593
- const relativeFile = _repoRelativePath(file);
1594
- if (relativeFile) {
1595
- merged.add(relativeFile);
1596
- }
1597
- }
1598
- }
1599
- return Array.from(merged);
1600
- }
1601
-
1602
- /**
1603
- * Build the explicit per-iteration git staging allowlist.
1604
- *
1605
- * @param {Array<string>} filesChanged
1606
- * @param {Array<object>} completedTasks
1607
- * @param {string|null|undefined} tasksFile
1608
- * @returns {Array<string>}
1609
- */
1610
- function _buildAutoCommitAllowlist(filesChanged, completedTasks, tasksFile) {
1611
- const allowlist = new Set();
1612
-
1613
- for (const file of filesChanged || []) {
1614
- const relativeFile = _repoRelativePath(file);
1615
- if (relativeFile) {
1616
- allowlist.add(relativeFile);
1617
- }
1618
- }
1619
-
1620
- if (Array.isArray(completedTasks) && completedTasks.length > 0 && tasksFile) {
1621
- const relativeTasksFile = _repoRelativePath(tasksFile);
1622
- if (relativeTasksFile) {
1623
- allowlist.add(relativeTasksFile);
1624
- }
1625
- }
1626
-
1627
- return Array.from(allowlist);
1628
- }
1629
-
1630
- /**
1631
- * Return tasks that became completed during the current iteration.
1632
- *
1633
- * @param {Array<object>} beforeTasks
1634
- * @param {Array<object>} afterTasks
1635
- * @returns {Array<object>}
1636
- */
1637
- function _completedTaskDelta(beforeTasks, afterTasks) {
1638
- const beforeCompleted = new Set(
1639
- (beforeTasks || [])
1640
- .filter((task) => task.status === 'completed')
1641
- .map(_taskIdentity)
1642
- );
1643
-
1644
- return (afterTasks || []).filter(
1645
- (task) => task.status === 'completed' && !beforeCompleted.has(_taskIdentity(task))
1646
- );
1647
- }
1648
-
1649
- /**
1650
- * Build a task-aware commit message for an iteration.
1651
- *
1652
- * The subject line (first line) is kept short — conventional git tooling
1653
- * assumes ~50–72 characters — so `git log --oneline` stays readable even when
1654
- * the underlying task description is a multi-sentence normative blob. The
1655
- * full, untruncated task descriptions are preserved in the commit body.
1656
- *
1657
- * @param {number} iteration
1658
- * @param {Array<object>} completedTasks
1659
- * @returns {string}
1660
- */
1661
- function _formatAutoCommitMessage(iteration, completedTasks) {
1662
- if (!Array.isArray(completedTasks) || completedTasks.length === 0) {
1663
- return '';
1664
- }
1665
-
1666
- const rawSummary = completedTasks.length === 1
1667
- ? completedTasks[0].description
1668
- : `complete ${completedTasks.length} tasks`;
1669
-
1670
- const prefix = `Ralph iteration ${iteration}: `;
1671
- const subjectBudget = Math.max(20, SUBJECT_MAX_LENGTH - prefix.length);
1672
- const summary = _truncateSubjectSummary(rawSummary, subjectBudget);
1673
-
1674
- const taskLines = completedTasks.map(
1675
- (task) => `- [x] ${task.fullDescription || task.description}`
1676
- );
1677
-
1678
- return `${prefix}${summary}\n\nTasks completed:\n${taskLines.join('\n')}`;
1679
- }
1680
-
1681
- const SUBJECT_MAX_LENGTH = 72;
1682
-
1683
- /**
1684
- * Reduce a task description to a short, single-line commit subject.
1685
- *
1686
- * Strategy:
1687
- * 1. Collapse whitespace onto a single line.
1688
- * 2. Prefer the first sentence (up to `.`, `!`, `?`) when it is not itself
1689
- * longer than the allowed budget.
1690
- * 3. Otherwise hard-truncate at a word boundary and append an ellipsis.
1691
- *
1692
- * @param {string} text
1693
- * @param {number} budget
1694
- * @returns {string}
1695
- */
1696
- function _truncateSubjectSummary(text, budget) {
1697
- const oneLine = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
1698
- if (oneLine.length === 0) return '';
1699
- if (oneLine.length <= budget) return oneLine;
1700
-
1701
- const sentenceMatch = oneLine.match(/^(.+?[.!?])(\s|$)/);
1702
- if (sentenceMatch) {
1703
- const candidate = sentenceMatch[1].trim();
1704
- if (candidate.length > 0 && candidate.length <= budget) {
1705
- return candidate;
1706
- }
1707
- }
1708
-
1709
- const ellipsis = '…';
1710
- const hardBudget = Math.max(1, budget - ellipsis.length);
1711
- const sliced = oneLine.slice(0, hardBudget);
1712
- const lastSpace = sliced.lastIndexOf(' ');
1713
- const cut = lastSpace > Math.floor(hardBudget / 2) ? sliced.slice(0, lastSpace) : sliced;
1714
- return `${cut.replace(/[\s,;:.!?-]+$/, '')}${ellipsis}`;
1715
- }
1716
-
1717
1226
  /**
1718
1227
  * Summarize recent problem signals so the next iteration can avoid repeating
1719
1228
  * the same failed approach.
@@ -2005,8 +1514,14 @@ function _buildAutoResolveHandoffFeedback(recentHistory) {
2005
1514
  if (!entry) return '';
2006
1515
 
2007
1516
  const className = entry.autoResolveHandoffClass || 'unknown';
1517
+ const spentBudget = supervisor._decideBoundedBudget({
1518
+ totalAttempts: 0,
1519
+ maxTotalAttempts: DEFAULTS.autoResolveHandoffMaxPerRun,
1520
+ attempts: { spent: { className } },
1521
+ budgetKey: 'spent',
1522
+ });
2008
1523
  const lines = [
2009
- `The previous iteration emitted BLOCKED_HANDOFF, but auto-resolution is enabled and spent its bounded attempt for ${className}.`,
1524
+ `The previous iteration emitted BLOCKED_HANDOFF, but auto-resolution is enabled and ${spentBudget.allowed ? 'reserved' : 'spent'} its bounded attempt for ${className}.`,
2010
1525
  'You have exactly one continuation attempt for this task/blocker class. Do not broaden task scope, do not repair unrelated snapshots or UI behavior, and do not keep retrying if the evidence does not hold.',
2011
1526
  ];
2012
1527
 
@@ -2030,394 +1545,6 @@ function _buildAutoResolveHandoffFeedback(recentHistory) {
2030
1545
  return lines.join('\n');
2031
1546
  }
2032
1547
 
2033
- function _buildBaselineGateFeedback(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
2034
- return _formatBaselineGateFeedback(
2035
- _analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory)
2036
- );
2037
- }
2038
-
2039
- function _analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
2040
- if (!ralphDir || !tasksFile || !currentTaskMeta || !currentTaskMeta.description) {
2041
- return null;
2042
- }
2043
-
2044
- const taskBlock = _extractCurrentTaskBlock(tasksFile, currentTaskMeta);
2045
- if (!taskBlock) return null;
2046
-
2047
- const strictGates = _detectStrictCleanGates(taskBlock);
2048
- if (strictGates.length === 0) return null;
2049
-
2050
- const recordedBaselines = _detectRecordedBaselineGates(ralphDir);
2051
- const missingBaselines = _detectMissingBaselineGates(
2052
- strictGates,
2053
- recordedBaselines,
2054
- taskBlock,
2055
- tasksFile
2056
- );
2057
-
2058
- if (missingBaselines.length > 0) {
2059
- return {
2060
- mode: 'missing_baseline',
2061
- conflicts: [],
2062
- missingBaselines,
2063
- allowedFiles: [],
2064
- budgetUsed: false,
2065
- };
2066
- }
2067
-
2068
- const failingBaselines = recordedBaselines.filter((gate) => gate.exitCode !== 0);
2069
- if (failingBaselines.length === 0) return null;
2070
-
2071
- const baselineByGate = new Map(failingBaselines.map((gate) => [gate.name, gate]));
2072
- const conflicts = strictGates
2073
- .map((gate) => ({ gate, baseline: baselineByGate.get(gate.name) }))
2074
- .filter((item) => item.baseline);
2075
-
2076
- if (conflicts.length === 0) return null;
2077
-
2078
- const cleanup = _detectAuthorizedBaselineCleanup(taskBlock);
2079
- if (cleanup.allowedFiles.length > 0) {
2080
- return {
2081
- mode: 'authorized_cleanup',
2082
- conflicts,
2083
- allowedFiles: cleanup.allowedFiles,
2084
- budgetUsed: _baselineGateRepairBudgetUsed(recentHistory, currentTaskMeta, cleanup.allowedFiles),
2085
- };
2086
- }
2087
-
2088
- if (_taskExplicitlyHandlesBaselineFailures(taskBlock)) {
2089
- return {
2090
- mode: 'baseline_classification',
2091
- conflicts,
2092
- allowedFiles: [],
2093
- budgetUsed: false,
2094
- };
2095
- }
2096
-
2097
- return {
2098
- mode: 'missing_policy',
2099
- conflicts,
2100
- allowedFiles: [],
2101
- budgetUsed: false,
2102
- };
2103
- }
2104
-
2105
- function _formatBaselineGateFeedback(conflict) {
2106
- const conflicts = Array.isArray(conflict && conflict.conflicts) ? conflict.conflicts : [];
2107
- const missingBaselines = Array.isArray(conflict && conflict.missingBaselines)
2108
- ? conflict.missingBaselines
2109
- : [];
2110
-
2111
- if (!conflict || (conflicts.length === 0 && missingBaselines.length === 0)) {
2112
- return '';
2113
- }
2114
-
2115
- const conflictLines = conflicts.map(({ gate, baseline }) =>
2116
- `- ${gate.command}: baseline ${baseline.file} exits ${baseline.exitCode}.`
2117
- );
2118
- const missingLines = missingBaselines.map((gate) =>
2119
- `- ${gate.command}: no matching baseline artifact found under .ralph/baselines.`
2120
- );
2121
-
2122
- if (conflict.mode === 'missing_baseline') {
2123
- return [
2124
- 'The current task uses a strict clean quality gate and the task plan indicates a pre-flight baseline should exist, but the matching baseline artifact is missing.',
2125
- 'Do not classify failures as pre-existing or spend an implementation iteration trying to satisfy an impossible task contract.',
2126
- 'emit BLOCKED_HANDOFF and ask the operator to rerun or restore the pre-flight baseline artifact, or update the task spec to authorize a different gate policy.',
2127
- '',
2128
- ...missingLines,
2129
- ].join('\n');
2130
- }
2131
-
2132
- if (conflict.mode === 'authorized_cleanup') {
2133
- if (conflict.budgetUsed) {
2134
- return [
2135
- 'The current task explicitly authorized cleanup for baseline gate failures, but its one repair attempt has already been used.',
2136
- 'Do not keep iterating on cleanup or broaden the edit scope.',
2137
- 'If the gate is still failing, emit BLOCKED_HANDOFF with the remaining failing identifiers and ask for either a broader cleanup task or a task-spec change.',
2138
- '',
2139
- `Authorized cleanup files: ${conflict.allowedFiles.join(', ')}`,
2140
- ...conflictLines,
2141
- ].join('\n');
2142
- }
2143
-
2144
- return [
2145
- 'The current task explicitly authorizes cleanup for baseline gate failures in named files.',
2146
- 'You have exactly one repair attempt for this task. Limit edits to compiler/lint-only fixes in the authorized files; do not change behavior or edit other files for this cleanup.',
2147
- 'If this attempt does not clear the gate, emit BLOCKED_HANDOFF instead of continuing to retry.',
2148
- '',
2149
- `Authorized cleanup files: ${conflict.allowedFiles.join(', ')}`,
2150
- ...conflictLines,
2151
- ].join('\n');
2152
- }
2153
-
2154
- if (conflict.mode === 'baseline_classification') {
2155
- return [
2156
- 'The current task has strict quality-gate checks, and matching pre-flight baselines are already failing.',
2157
- 'The task text appears to authorize baseline classification, so do not repair unrelated baseline failures unless the task explicitly names those files.',
2158
- 'Complete the task only if the current run has no new failures beyond the named baseline failures.',
2159
- '',
2160
- ...conflictLines,
2161
- ].join('\n');
2162
- }
2163
-
2164
- return [
2165
- 'The current task requires a clean gate that already has a failing pre-flight baseline, but the task text does not say whether baseline-matching failures may be classified.',
2166
- 'Do not spend iterations repairing unrelated files outside the current task scope.',
2167
- 'If the only remaining gate failures match the baseline, emit BLOCKED_HANDOFF with a task-spec correction request: either allow baseline classification for this gate, or explicitly authorize the named out-of-scope repair.',
2168
- '',
2169
- ...conflictLines,
2170
- ].join('\n');
2171
- }
2172
-
2173
- function _extractCurrentTaskBlock(tasksFile, currentTaskMeta) {
2174
- const fs = require('fs');
2175
- if (!tasksFile || !fs.existsSync(tasksFile)) return '';
2176
-
2177
- const lines = fs.readFileSync(tasksFile, 'utf8').split(/\r?\n/);
2178
- const taskHeader = /^-\s+\[[ x/]\]\s+(.+)$/;
2179
- const targetNumber = currentTaskMeta.number || '';
2180
- const targetDescription = (currentTaskMeta.description || '').trim();
2181
- let start = -1;
2182
-
2183
- for (let i = 0; i < lines.length; i++) {
2184
- const match = lines[i].match(taskHeader);
2185
- if (!match) continue;
2186
-
2187
- const fullDescription = match[1].trim();
2188
- const numMatch = fullDescription.match(/^(\d+\.\d+)\s+(.+)$/);
2189
- const number = numMatch ? numMatch[1] : '';
2190
- const description = (numMatch ? numMatch[2] : fullDescription).trim();
2191
-
2192
- if (
2193
- (targetNumber && number === targetNumber) ||
2194
- (!targetNumber && description === targetDescription) ||
2195
- (targetNumber && description === targetDescription)
2196
- ) {
2197
- start = i;
2198
- break;
2199
- }
2200
- }
2201
-
2202
- if (start === -1) return '';
2203
-
2204
- let end = lines.length;
2205
- for (let i = start + 1; i < lines.length; i++) {
2206
- if (taskHeader.test(lines[i])) {
2207
- end = i;
2208
- break;
2209
- }
2210
- }
2211
-
2212
- return lines.slice(start, end).join('\n');
2213
- }
2214
-
2215
- function _detectStrictCleanGates(taskBlock) {
2216
- if (!taskBlock) return [];
2217
-
2218
- const gates = [
2219
- {
2220
- name: 'typecheck',
2221
- command: 'pnpm typecheck',
2222
- pattern: /`?pnpm\s+typecheck`?[^\n]*(?:exits?|returns?)\s+0/i,
2223
- },
2224
- {
2225
- name: 'lint',
2226
- command: 'pnpm lint',
2227
- pattern: /`?pnpm\s+lint`?[^\n]*(?:exits?|returns?)\s+0/i,
2228
- },
2229
- {
2230
- name: 'test',
2231
- command: 'pnpm test',
2232
- pattern: /`?pnpm\s+test`?[^\n]*(?:exits?|returns?)\s+0/i,
2233
- },
2234
- ];
2235
-
2236
- return gates.filter((gate) => gate.pattern.test(taskBlock));
2237
- }
2238
-
2239
- function _detectFailingBaselineGates(ralphDir) {
2240
- return _detectRecordedBaselineGates(ralphDir).filter((gate) => gate.exitCode !== 0);
2241
- }
2242
-
2243
- function _detectRecordedBaselineGates(ralphDir) {
2244
- const fs = require('fs');
2245
- const fsPath = require('path');
2246
- const baselinesDir = fsPath.join(ralphDir, 'baselines');
2247
- if (!fs.existsSync(baselinesDir) || !fs.statSync(baselinesDir).isDirectory()) {
2248
- return [];
2249
- }
2250
-
2251
- const gates = [];
2252
- for (const name of fs.readdirSync(baselinesDir)) {
2253
- if (!/\.txt$/i.test(name)) continue;
2254
-
2255
- const gateName = _gateNameFromBaselineFile(name);
2256
- if (!gateName) continue;
2257
-
2258
- const file = fsPath.join(baselinesDir, name);
2259
- const tail = _readFileTail(file, 16384);
2260
- const exitMatch = tail.match(/(?:^|\n)EXIT=(\d+)(?:\n|$)/);
2261
- if (!exitMatch) continue;
2262
-
2263
- const exitCode = Number(exitMatch[1]);
2264
- if (!Number.isInteger(exitCode)) continue;
2265
-
2266
- gates.push({ name: gateName, file: fsPath.join('baselines', name), exitCode });
2267
- }
2268
-
2269
- const priority = { typecheck: 1, lint: 2, test: 3 };
2270
- return gates.sort((a, b) =>
2271
- (priority[a.name] || 99) - (priority[b.name] || 99) ||
2272
- a.file.localeCompare(b.file)
2273
- );
2274
- }
2275
-
2276
- function _detectMissingBaselineGates(strictGates, recordedBaselines, taskBlock, tasksFile) {
2277
- if (!Array.isArray(strictGates) || strictGates.length === 0) return [];
2278
-
2279
- const expectsBaseline =
2280
- _taskExplicitlyHandlesBaselineFailures(taskBlock) ||
2281
- _completedPreflightBaselineExists(tasksFile);
2282
-
2283
- if (!expectsBaseline) return [];
2284
-
2285
- const recordedNames = new Set((recordedBaselines || []).map((gate) => gate.name));
2286
- return strictGates.filter((gate) => !recordedNames.has(gate.name));
2287
- }
2288
-
2289
- function _completedPreflightBaselineExists(tasksFile) {
2290
- const fs = require('fs');
2291
- if (!tasksFile || !fs.existsSync(tasksFile)) return false;
2292
-
2293
- const lines = fs.readFileSync(tasksFile, 'utf8').split(/\r?\n/);
2294
- return lines.some((line) =>
2295
- /^-\s+\[x\]\s+.*\bpre-?flight\b.*\bbaselines?\b/i.test(line)
2296
- );
2297
- }
2298
-
2299
- function _gateNameFromBaselineFile(fileName) {
2300
- const normalized = fileName.toLowerCase();
2301
- if (/(^|[-_.])typecheck([-_.]|\.|$)/.test(normalized)) return 'typecheck';
2302
- if (/(^|[-_.])lint([-_.]|\.|$)/.test(normalized)) return 'lint';
2303
- if (/(^|[-_.])test([-_.]|\.|$)/.test(normalized)) return 'test';
2304
- return '';
2305
- }
2306
-
2307
- function _readFileTail(file, maxBytes) {
2308
- const fs = require('fs');
2309
- let fd = null;
2310
- try {
2311
- const stat = fs.statSync(file);
2312
- const length = Math.min(stat.size, maxBytes);
2313
- const offset = Math.max(0, stat.size - length);
2314
- const buffer = Buffer.alloc(length);
2315
- fd = fs.openSync(file, 'r');
2316
- fs.readSync(fd, buffer, 0, length, offset);
2317
- return buffer.toString('utf8');
2318
- } catch {
2319
- return '';
2320
- } finally {
2321
- if (fd !== null) {
2322
- try {
2323
- fs.closeSync(fd);
2324
- } catch {
2325
- // Ignore close failures while building best-effort feedback.
2326
- }
2327
- }
2328
- }
2329
- }
2330
-
2331
- function _taskExplicitlyHandlesBaselineFailures(taskBlock) {
2332
- return /\bbaseline\b/i.test(taskBlock) &&
2333
- /\b(match|matches|matching|classif(?:y|ied|ication)|pre-existing|preexisting|no new failures?)\b/i.test(taskBlock);
2334
- }
2335
-
2336
- function _detectAuthorizedBaselineCleanup(taskBlock) {
2337
- if (!taskBlock || !/\b(authori[sz]ed cleanup|after fixing|fixing the named baseline failures?)\b/i.test(taskBlock)) {
2338
- return { allowedFiles: [] };
2339
- }
2340
-
2341
- const allowedFiles = [];
2342
- const seen = new Set();
2343
- const backtickPattern = /`([^`]+)`/g;
2344
- let match;
2345
-
2346
- while ((match = backtickPattern.exec(taskBlock)) !== null) {
2347
- const candidate = match[1].trim();
2348
- if (!_looksLikeCleanupPath(candidate)) continue;
2349
-
2350
- const normalized = candidate.replace(/\\/g, '/');
2351
- if (seen.has(normalized)) continue;
2352
-
2353
- seen.add(normalized);
2354
- allowedFiles.push(normalized);
2355
- }
2356
-
2357
- return { allowedFiles };
2358
- }
2359
-
2360
- function _looksLikeCleanupPath(value) {
2361
- if (!value || /\s/.test(value)) return false;
2362
- if (/^(pnpm|npm|yarn|node|gtimeout|timeout|rg|git)(\s|$)/i.test(value)) return false;
2363
- if (/^--?/.test(value)) return false;
2364
- if (/[*{}]/.test(value)) return false;
2365
- return value.includes('/') || /\.[A-Za-z0-9]+$/.test(value);
2366
- }
2367
-
2368
- function _baselineGateRepairBudgetUsed(recentHistory, currentTaskMeta, allowedFiles) {
2369
- if (!Array.isArray(recentHistory) || recentHistory.length === 0) return false;
2370
-
2371
- return recentHistory.some((entry) => {
2372
- if (!_historyEntryMatchesTask(entry, currentTaskMeta)) return false;
2373
- if (entry.baselineGateRepairAttempted === true) return true;
2374
-
2375
- return _baselineGateRepairAttempted(
2376
- { mode: 'authorized_cleanup', allowedFiles },
2377
- entry.filesChanged || []
2378
- );
2379
- });
2380
- }
2381
-
2382
- function _baselineGateRepairAttempted(conflict, filesChanged) {
2383
- if (
2384
- !conflict ||
2385
- conflict.mode !== 'authorized_cleanup' ||
2386
- !Array.isArray(conflict.allowedFiles) ||
2387
- conflict.allowedFiles.length === 0 ||
2388
- !Array.isArray(filesChanged) ||
2389
- filesChanged.length === 0
2390
- ) {
2391
- return false;
2392
- }
2393
-
2394
- return _pathsIntersect(conflict.allowedFiles, filesChanged);
2395
- }
2396
-
2397
- function _historyEntryMatchesTask(entry, currentTaskMeta) {
2398
- if (!entry || !currentTaskMeta) return false;
2399
-
2400
- const currentNumber = currentTaskMeta.number || '';
2401
- const currentDescription = currentTaskMeta.description || '';
2402
-
2403
- if (currentNumber && entry.taskNumber === currentNumber) return true;
2404
- if (!currentNumber && currentDescription && entry.taskDescription === currentDescription) return true;
2405
-
2406
- return false;
2407
- }
2408
-
2409
- function _pathsIntersect(left, right) {
2410
- const normalizedLeft = new Set((left || []).map(_normalizeComparablePath));
2411
- return (right || []).some((pathValue) => normalizedLeft.has(_normalizeComparablePath(pathValue)));
2412
- }
2413
-
2414
- function _normalizeComparablePath(pathValue) {
2415
- return String(pathValue || '')
2416
- .replace(/\\/g, '/')
2417
- .replace(/^\.\//, '')
2418
- .replace(/\/+$/, '');
2419
- }
2420
-
2421
1548
  function _extractErrorForIteration(errorEntries, iteration) {
2422
1549
  if (!Array.isArray(errorEntries) || errorEntries.length === 0) return null;
2423
1550
 
@@ -2514,75 +1641,6 @@ function _cleanupCompletedErrors(ralphDir, verbose) {
2514
1641
  }
2515
1642
  }
2516
1643
 
2517
- function _taskIdentity(task) {
2518
- return task.number
2519
- ? `${task.number}|${task.fullDescription || task.description}`
2520
- : (task.fullDescription || task.description);
2521
- }
2522
-
2523
- function _repoRelativePath(filePath) {
2524
- if (!filePath || typeof filePath !== 'string') return '';
2525
- const normalized = path.normalize(filePath);
2526
- if (!normalized || normalized === '.') return '';
2527
- const relative = path.isAbsolute(normalized)
2528
- ? path.relative(process.cwd(), normalized)
2529
- : normalized;
2530
-
2531
- if (!relative || relative.startsWith('..')) {
2532
- return '';
2533
- }
2534
-
2535
- return relative.split(path.sep).join('/');
2536
- }
2537
-
2538
- function _detectProtectedCommitArtifacts(filesToStage, tasksFile) {
2539
- if (!Array.isArray(filesToStage) || filesToStage.length === 0 || !tasksFile) {
2540
- return [];
2541
- }
2542
-
2543
- const relativeTasksFile = _repoRelativePath(tasksFile);
2544
- if (!relativeTasksFile) {
2545
- return [];
2546
- }
2547
-
2548
- const changeRoot = path.posix.dirname(relativeTasksFile);
2549
- const protectedArtifacts = [];
2550
-
2551
- for (const file of filesToStage) {
2552
- const normalized = _repoRelativePath(file);
2553
- if (!normalized) continue;
2554
-
2555
- const isProposal = normalized === `${changeRoot}/proposal.md`;
2556
- const isDesign = normalized === `${changeRoot}/design.md`;
2557
- const isSpec = normalized.startsWith(`${changeRoot}/specs/`) && normalized.endsWith('/spec.md');
2558
-
2559
- if (isProposal || isDesign || isSpec) {
2560
- protectedArtifacts.push(normalized);
2561
- }
2562
- }
2563
-
2564
- return protectedArtifacts;
2565
- }
2566
-
2567
- function _gitErrorMessage(err) {
2568
- if (!err) return 'unknown git error';
2569
-
2570
- const stderr = _coerceGitErrorStream(err.stderr);
2571
- const stdout = _coerceGitErrorStream(err.stdout);
2572
-
2573
- if (stderr) return stderr;
2574
- if (stdout) return stdout;
2575
- if (err.message) return err.message;
2576
- return 'unknown git error';
2577
- }
2578
-
2579
- function _coerceGitErrorStream(stream) {
2580
- if (!stream) return '';
2581
- if (Buffer.isBuffer(stream)) return stream.toString('utf8').trim();
2582
- if (typeof stream === 'string') return stream.trim();
2583
- return '';
2584
- }
2585
-
2586
1644
  /**
2587
1645
  * Determine the starting iteration for a new run.
2588
1646
  *
@@ -2624,6 +1682,7 @@ function _resolveStartIteration(existingState, options) {
2624
1682
 
2625
1683
  module.exports = {
2626
1684
  run,
1685
+ _recoverSupervisorStartupResidue,
2627
1686
  _finalizeRunState,
2628
1687
  _containsPromise,
2629
1688
  _validateOptions,
@@ -2644,6 +1703,7 @@ module.exports = {
2644
1703
  _buildIterationFeedback,
2645
1704
  _buildAutoResolveHandoffFeedback,
2646
1705
  _resolveAutoResolveHandoffConfig,
1706
+ _resolveSupervisorConfig,
2647
1707
  _handoffHasFocusedVerifierEvidence,
2648
1708
  _classifyAutoResolvableHandoff,
2649
1709
  _decideAutoResolveHandoff,