spec-and-loop 3.3.2 → 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,
@@ -50,8 +97,92 @@ const DEFAULTS = {
50
97
  // toward the streak because their signal is already surfaced via the
51
98
  // `Recent Loop Signals` feedback block.
52
99
  stallThreshold: 3,
100
+ // Opt-in continuation after a BLOCKED_HANDOFF only when the handoff note has
101
+ // explicit evidence for a safe, bounded resolution class.
102
+ autoResolveHandoffs: true,
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,
53
112
  };
54
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
+
55
186
  /**
56
187
  * Determine whether an iteration made any forward progress.
57
188
  *
@@ -80,6 +211,156 @@ function _iterationIsStalled(iterationSignals) {
80
211
  return true;
81
212
  }
82
213
 
214
+ function _resolveAutoResolveHandoffConfig(options, existingState) {
215
+ const enabled = options.autoResolveHandoffs === true;
216
+ const maxPerRun =
217
+ Number.isInteger(options.autoResolveHandoffMaxPerRun) &&
218
+ options.autoResolveHandoffMaxPerRun > 0
219
+ ? options.autoResolveHandoffMaxPerRun
220
+ : DEFAULTS.autoResolveHandoffMaxPerRun;
221
+ const previous =
222
+ existingState &&
223
+ existingState.autoResolveHandoffs &&
224
+ typeof existingState.autoResolveHandoffs === 'object'
225
+ ? existingState.autoResolveHandoffs
226
+ : {};
227
+ const previousAttempts =
228
+ previous.attempts && typeof previous.attempts === 'object'
229
+ ? previous.attempts
230
+ : {};
231
+ const previousTotal = Number.isInteger(previous.totalAttempts)
232
+ ? previous.totalAttempts
233
+ : 0;
234
+
235
+ return {
236
+ enabled,
237
+ maxPerRun,
238
+ state: {
239
+ enabled,
240
+ maxPerRun,
241
+ totalAttempts: previousTotal,
242
+ attempts: Object.assign({}, previousAttempts),
243
+ lastDecision: previous.lastDecision || null,
244
+ },
245
+ };
246
+ }
247
+
248
+ function _handoffHasFocusedVerifierEvidence(note) {
249
+ if (!note) return false;
250
+ const text = String(note);
251
+ const mentionsFocusedVerifier =
252
+ /\bfocused\b[\s\S]{0,500}\b(verifier|command|test|spec|vitest)\b/i.test(text) ||
253
+ /\b(verifier|command|test|spec|vitest)\b[\s\S]{0,500}\bfocused\b/i.test(text);
254
+ const saysFocusedPasses =
255
+ /\b(passes?|passed|exits?\s+0|exit(?:ed)?\s+0|green)\b/i.test(text);
256
+ const saysBroadFails =
257
+ /\b(broad|full|required|suite|repo-wide)\b[\s\S]{0,500}\b(fails?|failed|red|non[-\s]?zero)\b/i.test(text) ||
258
+ /\b(fails?|failed|red|non[-\s]?zero)\b[\s\S]{0,500}\b(broad|full|required|suite|repo-wide)\b/i.test(text);
259
+ const saysFailuresAreUnrelated =
260
+ /\b(unrelated|pre[-\s]?existing|out[-\s]?of[-\s]?scope|known failures?|not introduced|baseline)\b/i.test(text);
261
+
262
+ return mentionsFocusedVerifier && saysFocusedPasses && saysBroadFails && saysFailuresAreUnrelated;
263
+ }
264
+
265
+ function _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict) {
266
+ if (_handoffHasFocusedVerifierEvidence(blockerNote)) {
267
+ return {
268
+ className: 'verifier_narrowing',
269
+ summary: 'focused verifier passes while the broad verifier fails on unrelated/pre-existing failures',
270
+ allowedFiles: [],
271
+ };
272
+ }
273
+
274
+ if (
275
+ baselineGateConflict &&
276
+ baselineGateConflict.mode === 'authorized_cleanup' &&
277
+ baselineGateConflict.budgetUsed !== true &&
278
+ Array.isArray(baselineGateConflict.allowedFiles) &&
279
+ baselineGateConflict.allowedFiles.length > 0
280
+ ) {
281
+ return {
282
+ className: 'authorized_cleanup',
283
+ summary: 'task text explicitly authorizes one cleanup attempt for named files',
284
+ allowedFiles: baselineGateConflict.allowedFiles.slice(),
285
+ };
286
+ }
287
+
288
+ return null;
289
+ }
290
+
291
+ function _autoResolveHandoffBudgetKey(currentTaskMeta, className) {
292
+ const taskId =
293
+ currentTaskMeta && currentTaskMeta.number
294
+ ? currentTaskMeta.number
295
+ : currentTaskMeta && currentTaskMeta.description
296
+ ? currentTaskMeta.description
297
+ : 'unknown-task';
298
+ return `${taskId}:${className || 'unknown'}`;
299
+ }
300
+
301
+ function _decideAutoResolveHandoff(config, blockerNote, currentTaskMeta, baselineGateConflict) {
302
+ const disabledDecision = { allowed: false, reason: 'disabled', className: '', budgetKey: '' };
303
+ if (!config || config.enabled !== true) return disabledDecision;
304
+
305
+ const classification = _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict);
306
+ if (!classification) {
307
+ return {
308
+ allowed: false,
309
+ reason: 'ambiguous_or_unsupported_handoff',
310
+ className: '',
311
+ budgetKey: '',
312
+ };
313
+ }
314
+
315
+ const budgetKey = _autoResolveHandoffBudgetKey(currentTaskMeta, classification.className);
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
+ });
324
+
325
+ if (!budgetDecision.allowed) {
326
+ return Object.assign({}, classification, budgetDecision, { budgetKey });
327
+ }
328
+
329
+ return Object.assign({}, classification, {
330
+ allowed: budgetDecision.allowed,
331
+ reason: budgetDecision.reason,
332
+ budgetKey,
333
+ });
334
+ }
335
+
336
+ function _consumeAutoResolveHandoffBudget(config, decision, iteration) {
337
+ if (!config || !config.state || !decision || decision.allowed !== true || !decision.budgetKey) {
338
+ return null;
339
+ }
340
+
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
+ });
350
+
351
+ config.state = Object.assign({}, nextState, {
352
+ lastDecision: {
353
+ className: decision.className,
354
+ reason: decision.reason,
355
+ budgetKey: decision.budgetKey,
356
+ iteration,
357
+ allowedFiles: decision.allowedFiles || [],
358
+ },
359
+ });
360
+
361
+ return config.state;
362
+ }
363
+
83
364
  function _isFailedIteration(result) {
84
365
  if (!result || typeof result !== 'object') return false;
85
366
  if (result.signal !== null && result.signal !== undefined && result.signal !== '') {
@@ -133,285 +414,59 @@ function _errorText(err) {
133
414
  }
134
415
 
135
416
  /**
136
- * Extract the agent's blocker note from iteration output. The convention is:
137
- * the line containing `<promise>BLOCKED_HANDOFF</promise>` MAY be preceded by
138
- * a free-text rationale block (any number of lines up to a sentinel header
139
- * `## Blocker` / `## Blocker Note` / `Blocker:`), and MAY include `## Why:` /
140
- * `## Done-When-Will-Be:` / `## Suggested Next Step:` sections. We capture
141
- * everything from the first sentinel header up to the promise tag, with a
142
- * fallback to the last 40 non-blank lines preceding the tag if no sentinel
143
- * is present, so the operator gets *something* useful even when the agent
144
- * skips the structured format.
145
- *
146
- * @param {string} outputText full iteration stdout
147
- * @param {string} promiseName configured BLOCKED_HANDOFF promise name
148
- * @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`.
149
424
  */
150
- function _extractBlockerNote(outputText, promiseName) {
151
- if (!outputText || !promiseName) return '';
152
- const tag = `<promise>${promiseName}</promise>`;
153
- const lines = outputText.split(/\r?\n/);
154
- let tagIdx = -1;
155
- for (let i = 0; i < lines.length; i++) {
156
- if (lines[i].trim() === tag) {
157
- tagIdx = i;
158
- break;
159
- }
160
- }
161
- if (tagIdx === -1) return '';
162
-
163
- // Look backwards for a sentinel header.
164
- const sentinel = /^\s*(##\s*Blocker(\s+Note)?|Blocker:)/i;
165
- let startIdx = tagIdx;
166
- for (let i = tagIdx - 1; i >= 0; i--) {
167
- if (sentinel.test(lines[i])) {
168
- startIdx = i;
169
- break;
170
- }
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: [] };
171
433
  }
172
434
 
173
- if (startIdx === tagIdx) {
174
- // No sentinel fall back to the last 40 non-blank lines before the tag.
175
- const window = [];
176
- for (let i = tagIdx - 1; i >= 0 && window.length < 40; i--) {
177
- const l = lines[i];
178
- 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`);
179
442
  }
180
- return window.join('\n').trim();
181
443
  }
182
444
 
183
- return lines.slice(startIdx, tagIdx).join('\n').trim();
445
+ return recovery;
184
446
  }
185
447
 
186
- /**
187
- * Scan well-known locations for blocker / diagnostic artifacts the agent
188
- * may have written during the most recent iteration, and return their
189
- * content (truncated) so we can tee it into the next iteration's prompt.
190
- *
191
- * The motivation is the failure mode we observed in the wild: the agent
192
- * writes `<change-baseline>/shared-chrome-invariant-report.txt` with a clear
193
- * `STATUS=BLOCKED REASON=...` diagnosis, then on the next iteration starts
194
- * from a blank slate, re-derives the same diagnosis, and burns another full
195
- * LLM cycle. By auto-detecting and surfacing the artifact, the agent gets
196
- * its own prior diagnosis as input on the next turn, freeing it to either
197
- * (a) act on it, or (b) emit BLOCKED_HANDOFF with a richer note.
198
- *
199
- * Probe paths (relative to ralphDir's parent — i.e. the change root):
200
- * - <ralphDir>/HANDOFF.md
201
- * - <ralphDir>/BLOCKED.md
202
- * - <ralphDir>/blocker.md / blocker-note.md
203
- * - <repoRoot>/.ralph/baselines/<change>/*report*.{txt,md}
204
- * - any file under <ralphDir> matching /(blocker|handoff|invariant-report)\.[a-z]+$/i
205
- *
206
- * We cap the returned text at 1500 chars per artifact and 3 artifacts total
207
- * so the feedback block stays bounded. Freshness is required by default to
208
- * avoid carrying stale diagnostics forever; when a prior run explicitly ended
209
- * with BLOCKED_HANDOFF, the canonical handoff files may be included even when
210
- * stale because they are the persisted operator-facing diagnosis.
211
- *
212
- * @param {string} ralphDir
213
- * @param {object} [options] { repoRoot, maxArtifacts = 3, maxCharsEach = 1500, includeStaleHandoff = false }
214
- * @returns {Array<{ path: string, content: string, truncated: boolean }>}
215
- */
216
- function _detectBlockerArtifacts(ralphDir, options) {
217
- const fs = require('fs');
218
- const fsPath = require('path');
219
- const opts = Object.assign(
220
- {
221
- repoRoot: process.cwd(),
222
- maxArtifacts: 3,
223
- maxCharsEach: 1500,
224
- includeStaleHandoff: false,
225
- },
226
- options || {}
227
- );
228
-
229
- if (!ralphDir || !fs.existsSync(ralphDir)) return [];
230
-
231
- const matches = new Map(); // path -> mtimeMs (dedup by absolute path)
232
- const isHandoffArtifact = (name) =>
233
- /^(handoff|blocked|blocker(-note)?)\.(md|txt)$/i.test(name);
234
- const isInteresting = (name) =>
235
- isHandoffArtifact(name) ||
236
- /(invariant|blocker|handoff).*report\.(md|txt)$/i.test(name) ||
237
- /report\.(md|txt)$/i.test(name);
238
-
239
- const consider = (p) => {
240
- try {
241
- const st = fs.statSync(p);
242
- if (!st.isFile()) return;
243
- // Files larger than 1MB are almost certainly not human-curated blocker
244
- // notes; skip them so we don't load logs or screenshots into the prompt.
245
- if (st.size > 1024 * 1024) return;
246
- // Only surface artifacts touched within the last ~10 minutes — older
247
- // files are almost always stale leftovers from prior runs, and the
248
- // failure mode we care about (repeated diagnosis with no progress)
249
- // produces fresh writes every iteration.
250
- const stale = Date.now() - st.mtimeMs > 10 * 60 * 1000;
251
- if (stale && !(opts.includeStaleHandoff && isHandoffArtifact(fsPath.basename(p)))) {
252
- return;
253
- }
254
- matches.set(fsPath.resolve(p), st.mtimeMs);
255
- } catch (_) {
256
- // ENOENT / permission errors: ignore — this is a best-effort probe.
257
- }
258
- };
448
+ function _resolveChangeDirFromTasksFile(tasksFile) {
449
+ if (!tasksFile) return '';
450
+ return path.dirname(path.resolve(tasksFile));
451
+ }
259
452
 
260
- // 1) Direct ralphDir scan, one level deep. .ralph/ is small, so a flat
261
- // listing is cheap and bounded.
262
- try {
263
- const entries = fs.readdirSync(ralphDir, { withFileTypes: true });
264
- for (const ent of entries) {
265
- if (ent.isFile() && isInteresting(ent.name)) {
266
- consider(fsPath.join(ralphDir, ent.name));
267
- }
268
- }
269
- } catch (_) { /* ignore */ }
453
+ function _resolveOpenspecRootFromTasksFile(tasksFile) {
454
+ if (!tasksFile) return '';
270
455
 
271
- // 2) Convention-based baseline location used by spec-and-loop changes:
272
- // <repoRoot>/.ralph/baselines/<change>/*report*.{txt,md}
273
- // The change name is the parent directory of ralphDir's parent in the
274
- // OpenSpec layout (e.g. .../changes/<name>/.ralph), so we derive it.
275
- try {
276
- const changeDir = fsPath.dirname(ralphDir);
277
- const changeName = fsPath.basename(changeDir);
278
- const baselinesDir = fsPath.join(opts.repoRoot, '.ralph', 'baselines', changeName);
279
- if (fs.existsSync(baselinesDir)) {
280
- const entries = fs.readdirSync(baselinesDir, { withFileTypes: true });
281
- for (const ent of entries) {
282
- if (ent.isFile() && isInteresting(ent.name)) {
283
- consider(fsPath.join(baselinesDir, ent.name));
284
- }
285
- }
456
+ let current = path.dirname(path.resolve(tasksFile));
457
+ while (current) {
458
+ if (path.basename(current) === 'openspec') {
459
+ return current;
286
460
  }
287
- } catch (_) { /* ignore */ }
288
-
289
- if (matches.size === 0) return [];
290
461
 
291
- // Sort by mtime descending so the freshest artifact wins when we cap.
292
- const sorted = Array.from(matches.entries())
293
- .sort((a, b) => b[1] - a[1])
294
- .map(([p]) => p);
295
-
296
- const out = [];
297
- for (const p of sorted.slice(0, opts.maxArtifacts)) {
298
- try {
299
- const raw = fs.readFileSync(p, 'utf8');
300
- const truncated = raw.length > opts.maxCharsEach;
301
- const content = truncated ? raw.slice(0, opts.maxCharsEach) : raw;
302
- out.push({
303
- path: fsPath.relative(opts.repoRoot, p) || p,
304
- content: content.trim(),
305
- truncated,
306
- });
307
- } catch (_) {
308
- // Ignore unreadable artifacts.
462
+ const parent = path.dirname(current);
463
+ if (parent === current) {
464
+ break;
309
465
  }
466
+ current = parent;
310
467
  }
311
468
 
312
- return out;
313
- }
314
-
315
- /**
316
- * Write the agent's blocker note to <ralphDir>/HANDOFF.md with iteration
317
- * metadata so an operator can reproduce the context. Appends rather than
318
- * overwrites: a single change can hit several BLOCKED_HANDOFFs over time
319
- * (operator unblocks, loop resumes, hits a different blocker), and we want
320
- * the full audit trail in one file.
321
- *
322
- * @param {string} ralphDir
323
- * @param {object} entry { iteration, task, note, completionPromise, taskPromise }
324
- * @returns {string} the absolute path to HANDOFF.md
325
- */
326
- function _writeHandoff(ralphDir, entry) {
327
- const fs = require('fs');
328
- const fsPath = require('path');
329
- if (!fs.existsSync(ralphDir)) {
330
- fs.mkdirSync(ralphDir, { recursive: true });
331
- }
332
- const handoffPath = fsPath.join(ralphDir, 'HANDOFF.md');
333
- const ts = new Date().toISOString();
334
- const taskLine = entry.task && entry.task !== 'N/A'
335
- ? entry.task
336
- : '(no task in progress)';
337
- const noteBlock = entry.note && entry.note.trim()
338
- ? entry.note.trim()
339
- : '(agent emitted BLOCKED_HANDOFF without a structured blocker note;\n' +
340
- 'check the iteration stdout log for the rationale)';
341
-
342
- const section = [
343
- '',
344
- `## Iteration ${entry.iteration} — ${ts}`,
345
- '',
346
- `**Task:** ${taskLine}`,
347
- '',
348
- '**Agent blocker note:**',
349
- '',
350
- noteBlock,
351
- '',
352
- '**Operator next step:** investigate the blocker, take one of the actions',
353
- 'the task spec authorizes (revert / isolate / justify / escalate), then',
354
- 'rerun `ralph-run` to resume.',
355
- '',
356
- '---',
357
- '',
358
- ].join('\n');
359
-
360
- let existing = '';
361
- if (fs.existsSync(handoffPath)) {
362
- existing = fs.readFileSync(handoffPath, 'utf8');
363
- } else {
364
- existing = '# Ralph Handoff Log\n\nThis file is appended whenever the loop\n' +
365
- 'exits with `BLOCKED_HANDOFF`. Each section is one blocker the\n' +
366
- 'agent surfaced — review newest first.\n';
367
- }
368
- fs.writeFileSync(handoffPath, existing + section, 'utf8');
369
- return handoffPath;
370
- }
371
-
372
- function _appendFatalIterationFailure(ralphDir, entry) {
373
- errors.append(ralphDir, {
374
- iteration: entry.iteration,
375
- task: entry.task,
376
- exitCode: entry.exitCode,
377
- signal: entry.signal || '',
378
- failureStage: entry.failureStage || '',
379
- stderr: entry.stderr || '',
380
- stdout: entry.stdout || '',
381
- });
382
-
383
- history.append(ralphDir, {
384
- iteration: entry.iteration,
385
- duration: entry.duration,
386
- completionDetected: false,
387
- taskDetected: false,
388
- toolUsage: [],
389
- filesChanged: [],
390
- exitCode: entry.exitCode,
391
- signal: entry.signal || '',
392
- failureStage: entry.failureStage || '',
393
- completedTasks: [],
394
- commitAttempted: false,
395
- commitCreated: false,
396
- commitAnomaly: '',
397
- commitAnomalyType: '',
398
- protectedArtifacts: [],
399
- promptBytes: entry.promptBytes || 0,
400
- promptChars: entry.promptChars || 0,
401
- promptTokens: entry.promptTokens || 0,
402
- responseBytes: entry.responseBytes || 0,
403
- responseChars: entry.responseChars || 0,
404
- responseTokens: entry.responseTokens || 0,
405
- truncated: entry.truncated || false,
406
- });
407
- }
408
-
409
- function _summarizeBlockerNote(note, limit = 500) {
410
- if (!note || typeof note !== 'string') return '';
411
- const oneLine = note.replace(/\s+/g, ' ').trim();
412
- if (!oneLine) return '';
413
- if (oneLine.length <= limit) return oneLine;
414
- return `${oneLine.slice(0, Math.max(0, limit - 1)).replace(/\s+$/, '')}…`;
469
+ return '';
415
470
  }
416
471
 
417
472
  /**
@@ -423,6 +478,13 @@ function _summarizeBlockerNote(note, limit = 500) {
423
478
  async function run(opts) {
424
479
  const options = Object.assign({}, DEFAULTS, opts);
425
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
+ }
426
488
 
427
489
  const ralphDir = options.ralphDir;
428
490
  const runLock = state.acquireRunLock(ralphDir, {
@@ -449,6 +511,7 @@ async function run(opts) {
449
511
  let iterationCount = 0;
450
512
  let completed = false;
451
513
  let exitReason = 'max_iterations';
514
+ let pendingSupervisorHints = [];
452
515
  // Consecutive iterations that succeeded but produced no progress signal.
453
516
  // Reset whenever any progress is detected (or when the iteration failed, so
454
517
  // transient infra errors don't trip the stall detector).
@@ -462,6 +525,7 @@ async function run(opts) {
462
525
  const resumeIteration = _resolveStartIteration(existingState, options);
463
526
  const priorRunWasBlockedHandoff =
464
527
  existingState && existingState.exitReason === 'blocked_handoff';
528
+ const autoResolveHandoffs = _resolveAutoResolveHandoffConfig(options, existingState);
465
529
 
466
530
  if (options.verbose && resumeIteration > 1) {
467
531
  process.stderr.write(
@@ -512,6 +576,7 @@ async function run(opts) {
512
576
  stoppedAt: null,
513
577
  exitReason: null,
514
578
  pendingDirtyPaths,
579
+ autoResolveHandoffs: autoResolveHandoffs.state,
515
580
  });
516
581
  stateInitialized = true;
517
582
 
@@ -566,7 +631,12 @@ async function run(opts) {
566
631
  // Build the prompt for this iteration
567
632
  let renderedPrompt;
568
633
  try {
569
- 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 = [];
570
640
  } catch (err) {
571
641
  err.failureStage = err.failureStage || 'prompt_render';
572
642
  throw err;
@@ -597,6 +667,7 @@ async function run(opts) {
597
667
  fullHistory,
598
668
  );
599
669
  const baselineGateFeedback = _formatBaselineGateFeedback(baselineGateConflict);
670
+ const autoResolveHandoffFeedback = _buildAutoResolveHandoffFeedback(recentHistory);
600
671
 
601
672
  // Inject any pending context
602
673
  const pendingContext = context.consume(ralphDir);
@@ -612,6 +683,10 @@ async function run(opts) {
612
683
  promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`);
613
684
  }
614
685
 
686
+ if (autoResolveHandoffFeedback) {
687
+ promptSections.push(`## Auto-Resolve Handoff\n\n${autoResolveHandoffFeedback}`);
688
+ }
689
+
615
690
  if (lessonsSection) {
616
691
  promptSections.push(lessonsSection);
617
692
  }
@@ -705,10 +780,68 @@ async function run(opts) {
705
780
  const blockerNote = hasBlockedHandoff
706
781
  ? _extractBlockerNote(outputText, blockedHandoffPromise)
707
782
  : '';
783
+ const autoResolveHandoffClassification = hasBlockedHandoff
784
+ ? _classifyAutoResolvableHandoff(blockerNote, baselineGateConflict)
785
+ : null;
786
+ const autoResolveHandoffDecision = hasBlockedHandoff
787
+ ? _decideAutoResolveHandoff(
788
+ autoResolveHandoffs,
789
+ blockerNote,
790
+ currentTaskMeta,
791
+ baselineGateConflict,
792
+ )
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
+ }
826
+ if (autoResolveHandoffDecision && autoResolveHandoffDecision.allowed) {
827
+ const nextAutoResolveState = _consumeAutoResolveHandoffBudget(
828
+ autoResolveHandoffs,
829
+ autoResolveHandoffDecision,
830
+ iterationCount,
831
+ );
832
+ if (nextAutoResolveState) {
833
+ state.update(ralphDir, { autoResolveHandoffs: nextAutoResolveState });
834
+ }
835
+ }
708
836
  const tasksAfter = options.tasksMode && options.tasksFile
709
837
  ? tasks.parseTasks(options.tasksFile)
710
838
  : [];
711
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;
712
845
 
713
846
  let commitResult = { attempted: false, committed: false, anomaly: null };
714
847
 
@@ -787,6 +920,33 @@ async function run(opts) {
787
920
  signal: result.signal || '',
788
921
  failureStage: result.failureStage || '',
789
922
  completedTasks: completedTasks.map((task) => task.fullDescription || task.description),
923
+ ...(autoResolveHandoffDecision
924
+ ? {
925
+ autoResolveHandoffAttempted: autoResolveHandoffDecision.allowed === true,
926
+ autoResolveHandoffClass: autoResolveHandoffDecision.className || '',
927
+ autoResolveHandoffReason: autoResolveHandoffDecision.reason || '',
928
+ autoResolveHandoffBudgetKey: autoResolveHandoffDecision.budgetKey || '',
929
+ autoResolveHandoffAllowedFiles: autoResolveHandoffDecision.allowedFiles || [],
930
+ }
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
+ : {}),
790
950
  commitAttempted: commitResult.attempted,
791
951
  commitCreated: commitResult.committed,
792
952
  commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '',
@@ -818,6 +978,25 @@ async function run(opts) {
818
978
  ...(result.lastStdoutBytes !== undefined ? { lastStdoutBytes: result.lastStdoutBytes } : {}),
819
979
  ...(result.lastStderrBytes !== undefined ? { lastStderrBytes: result.lastStderrBytes } : {}),
820
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
+ }
821
1000
 
822
1001
  // Stall detection is computed *before* the progress event so the
823
1002
  // reporter can show the live streak alongside the badge. We still
@@ -880,6 +1059,18 @@ async function run(opts) {
880
1059
  iteration: iterationCount,
881
1060
  task: currentTask,
882
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,
883
1074
  completionPromise,
884
1075
  taskPromise,
885
1076
  });
@@ -894,9 +1085,30 @@ async function run(opts) {
894
1085
  reporter.note(
895
1086
  handoffPath
896
1087
  ? `agent emitted ${blockedHandoffPromise}; blocker note saved to ${handoffPath}.`
897
- : `agent emitted ${blockedHandoffPromise}; halting (HANDOFF.md write failed; see stderr).`,
1088
+ : `agent emitted ${blockedHandoffPromise}; HANDOFF.md write failed (see stderr).`,
898
1089
  'warn'
899
1090
  );
1091
+ if (autoResolveHandoffDecision && autoResolveHandoffDecision.allowed) {
1092
+ reporter.note(
1093
+ `auto-resolve handoffs: continuing once for ${autoResolveHandoffDecision.className} (${autoResolveHandoffDecision.budgetKey}).`,
1094
+ 'warn'
1095
+ );
1096
+ if (options.verbose) {
1097
+ process.stderr.write(
1098
+ `[mini-ralph] auto-resolve handoff consumed budget key ${autoResolveHandoffDecision.budgetKey}; continuing.\n`
1099
+ );
1100
+ }
1101
+ continue;
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
+ }
900
1112
  if (options.verbose) {
901
1113
  process.stderr.write(
902
1114
  `[mini-ralph] ${blockedHandoffPromise} detected at iteration ${iterationCount}; halting.\n`
@@ -985,145 +1197,6 @@ function _containsPromise(text, promiseName) {
985
1197
  .some((line) => line.trim() === expectedTag);
986
1198
  }
987
1199
 
988
- function _normalizePendingDirtyPaths(pending) {
989
- if (!pending || typeof pending !== 'object') return null;
990
- const files = _mergePathLists(pending.files || pending.paths || []);
991
- if (files.length === 0) return null;
992
-
993
- return {
994
- iteration: typeof pending.iteration === 'number' ? pending.iteration : null,
995
- reason: pending.reason || 'blocked_handoff',
996
- task: pending.task || '',
997
- taskNumber: pending.taskNumber || '',
998
- taskDescription: pending.taskDescription || '',
999
- files,
1000
- recordedAt: pending.recordedAt || new Date().toISOString(),
1001
- };
1002
- }
1003
-
1004
- function _recordPendingDirtyPaths(existing, update) {
1005
- const normalized = _normalizePendingDirtyPaths({
1006
- iteration: update && typeof update.iteration === 'number' ? update.iteration : null,
1007
- reason: update && update.reason ? update.reason : 'blocked_handoff',
1008
- task: update && update.task ? update.task : '',
1009
- taskNumber: update && update.taskNumber ? update.taskNumber : '',
1010
- taskDescription: update && update.taskDescription ? update.taskDescription : '',
1011
- files: _mergePathLists(
1012
- existing && existing.files ? existing.files : [],
1013
- update && update.files ? update.files : []
1014
- ),
1015
- recordedAt: update && update.recordedAt ? update.recordedAt : new Date().toISOString(),
1016
- });
1017
-
1018
- return normalized;
1019
- }
1020
-
1021
- function _remainingPendingDirtyPathsAfterCommit(pending, anomaly) {
1022
- const normalized = _normalizePendingDirtyPaths(pending);
1023
- if (!normalized) return null;
1024
-
1025
- const ignoredPaths = anomaly && Array.isArray(anomaly.ignoredPaths)
1026
- ? anomaly.ignoredPaths.map(_repoRelativePath).filter(Boolean)
1027
- : [];
1028
- if (ignoredPaths.length === 0) return null;
1029
-
1030
- const ignoredSet = new Set(ignoredPaths);
1031
- const files = normalized.files.filter((file) => ignoredSet.has(file));
1032
- if (files.length === 0) return null;
1033
- return Object.assign({}, normalized, { files });
1034
- }
1035
-
1036
- function _refreshPendingDirtyPaths(pending) {
1037
- const normalized = _normalizePendingDirtyPaths(pending);
1038
- if (!normalized) return null;
1039
-
1040
- const dirtyPaths = _currentDirtyPathSet();
1041
- if (!dirtyPaths) return normalized;
1042
- const files = normalized.files.filter((file) => dirtyPaths.has(file));
1043
- if (files.length === 0) return null;
1044
-
1045
- return Object.assign({}, normalized, { files });
1046
- }
1047
-
1048
- function _samePendingTask(pending, currentTaskMeta, currentTask) {
1049
- if (!pending) return true;
1050
- const currentNumber = currentTaskMeta && currentTaskMeta.number ? currentTaskMeta.number : '';
1051
- const currentDescription = currentTaskMeta && currentTaskMeta.description ? currentTaskMeta.description : '';
1052
- const currentFull = currentTask || '';
1053
-
1054
- if (pending.taskNumber && currentNumber) {
1055
- return pending.taskNumber === currentNumber;
1056
- }
1057
-
1058
- if (pending.taskDescription && currentDescription) {
1059
- return pending.taskDescription === currentDescription;
1060
- }
1061
-
1062
- return Boolean(pending.task && currentFull && pending.task === currentFull);
1063
- }
1064
-
1065
- function _formatPendingDirtyPathsBlock(pending, currentTaskMeta, currentTask) {
1066
- const currentStamp = currentTaskMeta && currentTaskMeta.number
1067
- ? `${currentTaskMeta.number} ${currentTaskMeta.description || ''}`.trim()
1068
- : (currentTask || 'the current task');
1069
- const pendingStamp = pending.taskNumber
1070
- ? `${pending.taskNumber} ${pending.taskDescription || ''}`.trim()
1071
- : (pending.task || 'a prior blocked handoff');
1072
- const files = (pending.files || []).slice(0, 8);
1073
- const extra = (pending.files || []).length - files.length;
1074
- const fileLines = files.map((file) => ` - ${file}`).join('\n');
1075
- const suffix = extra > 0 ? `\n - (+${extra} more)` : '';
1076
-
1077
- return [
1078
- `pending dirty paths from ${pending.reason || 'blocked_handoff'} iteration ${pending.iteration || 'unknown'} remain unresolved.`,
1079
- `Prior task: ${pendingStamp}`,
1080
- `Current task: ${currentStamp}`,
1081
- 'Resolve the prior patch before Ralph can safely continue: commit it with the same task, revert it, or move it to a separate change.',
1082
- 'Pending paths:',
1083
- `${fileLines}${suffix}`,
1084
- ].join('\n');
1085
- }
1086
-
1087
- function _currentDirtyPathSet() {
1088
- try {
1089
- const output = childProcess.execFileSync('git', ['status', '--porcelain'], {
1090
- encoding: 'utf8',
1091
- stdio: ['pipe', 'pipe', 'pipe'],
1092
- });
1093
- const paths = new Set();
1094
- for (const line of output.split('\n')) {
1095
- for (const file of _parseGitStatusPaths(line)) {
1096
- if (file) paths.add(file);
1097
- }
1098
- }
1099
- return paths;
1100
- } catch (_) {
1101
- return null;
1102
- }
1103
- }
1104
-
1105
- function _parseGitStatusPaths(line) {
1106
- if (!line || typeof line !== 'string') return [];
1107
- const rawPath = line.slice(3).trim();
1108
- if (!rawPath) return [];
1109
- if (rawPath.includes(' -> ')) {
1110
- return rawPath.split(' -> ').map(_stripGitStatusQuotes).filter(Boolean);
1111
- }
1112
- return [_stripGitStatusQuotes(rawPath)].filter(Boolean);
1113
- }
1114
-
1115
- function _stripGitStatusQuotes(value) {
1116
- if (!value) return '';
1117
- const trimmed = value.trim();
1118
- if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
1119
- return trimmed;
1120
- }
1121
- return trimmed
1122
- .slice(1, -1)
1123
- .replace(/\\"/g, '"')
1124
- .replace(/\\\\/g, '\\');
1125
- }
1126
-
1127
1200
  /**
1128
1201
  * Validate required options and throw descriptive errors.
1129
1202
  *
@@ -1150,356 +1223,6 @@ function _validateOptions(options) {
1150
1223
  }
1151
1224
  }
1152
1225
 
1153
- /**
1154
- * Format the loud direct stderr block for auto-commit ignore-filter events.
1155
- * Emitted via process.stderr.write (bypassing reporter dedup/buffering) on
1156
- * every iteration where paths_ignored_filtered or all_paths_ignored fires.
1157
- * (task 5.1 — surface-autocommit-ignore-warning-and-watchdog)
1158
- *
1159
- * @param {number} iteration
1160
- * @param {{ type: string, ignoredPaths: string[] }} anomaly
1161
- * @returns {string}
1162
- */
1163
- function _formatAutoCommitIgnoreBlock(iteration, anomaly) {
1164
- const SEP = '================================================================================\n';
1165
- const pathLines = (anomaly.ignoredPaths || []).map(p => ` - ${p}`).join('\n');
1166
- return (
1167
- SEP +
1168
- `⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration ${iteration}, type: ${anomaly.type})\n` +
1169
- `Paths filtered because .gitignore matches:\n` +
1170
- pathLines + '\n' +
1171
- `Consequence: these paths are NOT in the latest commit.\n` +
1172
- `Remediation (pick one):\n` +
1173
- ` 1. git add -f <path> # one-time unblock, if you want it tracked\n` +
1174
- ` 2. edit .gitignore # narrow or remove the matching rule\n` +
1175
- ` 3. pass --no-auto-commit on the ralph-run invocation\n` +
1176
- SEP
1177
- );
1178
- }
1179
-
1180
- /**
1181
- * Auto-commit changed files after a successful iteration.
1182
- * Silently skips if git is unavailable, there is nothing to commit, or the
1183
- * iteration did not complete any tasks.
1184
- *
1185
- * @param {number} iteration
1186
- * @param {object} opts
1187
- * @param {Array<object>} [opts.completedTasks]
1188
- * @param {Array<string>} [opts.filesToStage]
1189
- * @param {boolean} [opts.verbose]
1190
- */
1191
- function _autoCommit(iteration, opts = {}) {
1192
- const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false, reporter = null } = opts;
1193
- const message = _formatAutoCommitMessage(iteration, completedTasks);
1194
-
1195
- if (!message) {
1196
- if (verbose) {
1197
- process.stderr.write('[mini-ralph] auto-commit skipped: no completed tasks detected\n');
1198
- }
1199
- return { attempted: false, committed: false, anomaly: null };
1200
- }
1201
-
1202
- if (!Array.isArray(filesToStage) || filesToStage.length === 0) {
1203
- if (verbose) {
1204
- process.stderr.write('[mini-ralph] auto-commit skipped: no iteration files to stage\n');
1205
- }
1206
- return { attempted: false, committed: false, anomaly: null };
1207
- }
1208
-
1209
- const protectedArtifacts = _detectProtectedCommitArtifacts(filesToStage, tasksFile);
1210
- if (protectedArtifacts.length > 0) {
1211
- const anomaly = {
1212
- type: 'protected_artifacts',
1213
- message:
1214
- 'Auto-commit blocked: loop-managed commits cannot include protected OpenSpec artifacts: ' +
1215
- protectedArtifacts.join(', '),
1216
- protectedArtifacts,
1217
- };
1218
-
1219
- process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
1220
- return { attempted: true, committed: false, anomaly };
1221
- }
1222
-
1223
- const { kept: keptPaths, dropped: droppedPaths } = _filterGitignored(filesToStage, process.cwd());
1224
-
1225
- if (droppedPaths.length > 0) {
1226
- const pathWord = droppedPaths.length === 1 ? 'path' : 'paths';
1227
- const allIgnored = keptPaths.length === 0;
1228
- const warnLines = allIgnored
1229
- ? [
1230
- `auto-commit iter ${iteration} skipped: all ${droppedPaths.length} ${pathWord} are gitignored`,
1231
- ...droppedPaths.map(p => ` - ${p}`),
1232
- ' hint: `git add -f <path>` once, or adjust .gitignore',
1233
- ].join('\n')
1234
- : [
1235
- `auto-commit iter ${iteration}: filtered ${droppedPaths.length} gitignored ${pathWord}, committing ${keptPaths.length} ${keptPaths.length === 1 ? 'other' : 'others'}`,
1236
- ...droppedPaths.map(p => ` - ${p}`),
1237
- ].join('\n');
1238
- if (reporter) {
1239
- reporter.note(warnLines, 'error');
1240
- } else {
1241
- const fallbackMsg = allIgnored
1242
- ? `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`
1243
- : `Auto-commit filtered gitignored paths: ${droppedPaths.join(', ')}`;
1244
- process.stderr.write(`[mini-ralph] warning: ${fallbackMsg}\n`);
1245
- }
1246
- if (allIgnored) {
1247
- const anomaly = {
1248
- type: 'all_paths_ignored',
1249
- message: `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`,
1250
- ignoredPaths: droppedPaths,
1251
- };
1252
- // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
1253
- process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
1254
- return {
1255
- attempted: true,
1256
- committed: false,
1257
- anomaly,
1258
- };
1259
- }
1260
- }
1261
-
1262
- const stagePaths = droppedPaths.length > 0 ? keptPaths : filesToStage;
1263
-
1264
- try {
1265
- // Use `git add -A -- <paths>` (not plain `git add -- <paths>`) so deletions
1266
- // and renames are staged alongside modifications/additions. Tasks that call
1267
- // `git rm` via a shell tool leave the path absent from the working tree but
1268
- // still present in `git status --porcelain`, which means the plain form
1269
- // would error with `fatal: pathspec did not match`. Scoping to the per-path
1270
- // allowlist preserves the protected-artifact guarantee.
1271
- childProcess.execFileSync('git', ['add', '-A', '--', ...stagePaths], {
1272
- stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
1273
- encoding: 'utf8',
1274
- });
1275
-
1276
- const stagedFiles = childProcess.execFileSync('git', ['diff', '--cached', '--name-only'], {
1277
- stdio: ['pipe', 'pipe', 'pipe'],
1278
- encoding: 'utf8',
1279
- });
1280
-
1281
- if (!stagedFiles.trim()) {
1282
- const anomaly = {
1283
- type: 'nothing_staged',
1284
- message: 'Auto-commit failed: nothing was staged after git add',
1285
- };
1286
-
1287
- process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
1288
- if (verbose) {
1289
- process.stderr.write('[mini-ralph] auto-commit skipped: nothing staged\n');
1290
- }
1291
- return { attempted: true, committed: false, anomaly };
1292
- }
1293
-
1294
- childProcess.execFileSync('git', ['commit', '-m', message], {
1295
- stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
1296
- encoding: 'utf8',
1297
- });
1298
-
1299
- if (verbose) {
1300
- process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
1301
- }
1302
- if (droppedPaths.length > 0) {
1303
- const anomaly = {
1304
- type: 'paths_ignored_filtered',
1305
- message: 'Auto-commit succeeded but filtered gitignored paths: ' + droppedPaths.join(', '),
1306
- ignoredPaths: droppedPaths,
1307
- };
1308
- // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
1309
- process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
1310
- return {
1311
- attempted: true,
1312
- committed: true,
1313
- anomaly,
1314
- };
1315
- }
1316
- return { attempted: true, committed: true, anomaly: null };
1317
- } catch (err) {
1318
- const anomaly = {
1319
- type: 'commit_failed',
1320
- message: `Auto-commit failed: ${_gitErrorMessage(err)}`,
1321
- };
1322
-
1323
- process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
1324
- return { attempted: true, committed: false, anomaly };
1325
- }
1326
- }
1327
-
1328
- /**
1329
- * Filter gitignored paths out of a list using `git check-ignore --stdin`.
1330
- *
1331
- * Exit-code semantics of `git check-ignore`:
1332
- * 0 – at least one path is ignored; stdout lists the ignored paths.
1333
- * 1 – no paths are ignored (Node's execFileSync throws; we catch status===1).
1334
- * other / ENOENT / any thrown error – fallback: treat all paths as kept.
1335
- *
1336
- * @param {string[]} paths - Repo-relative paths to test.
1337
- * @param {string} cwd - Working directory for the git command.
1338
- * @returns {{ kept: string[], dropped: string[] }}
1339
- */
1340
- function _filterGitignored(paths, cwd) {
1341
- if (!Array.isArray(paths) || paths.length === 0) {
1342
- return { kept: [], dropped: [] };
1343
- }
1344
-
1345
- try {
1346
- const stdout = childProcess.execFileSync(
1347
- 'git',
1348
- ['check-ignore', '--stdin'],
1349
- {
1350
- input: paths.join('\n'),
1351
- cwd: cwd || process.cwd(),
1352
- stdio: ['pipe', 'pipe', 'pipe'],
1353
- encoding: 'utf8',
1354
- }
1355
- );
1356
-
1357
- // Exit code 0: stdout lists ignored paths (one per line).
1358
- const dropped = stdout
1359
- .split('\n')
1360
- .map((l) => l.trim())
1361
- .filter(Boolean);
1362
- const droppedSet = new Set(dropped);
1363
- const kept = paths.filter((p) => !droppedSet.has(p));
1364
- return { kept, dropped };
1365
- } catch (err) {
1366
- // exit status 1 means "no paths ignored" — treat as success with no drops.
1367
- if (err && err.status === 1) {
1368
- return { kept: paths.slice(), dropped: [] };
1369
- }
1370
- // Any other error (ENOENT, unexpected exit code, etc.) — fallback, never crash.
1371
- return { kept: paths.slice(), dropped: [] };
1372
- }
1373
- }
1374
-
1375
- function _mergePathLists(...lists) {
1376
- const merged = new Set();
1377
- for (const list of lists) {
1378
- for (const file of list || []) {
1379
- const relativeFile = _repoRelativePath(file);
1380
- if (relativeFile) {
1381
- merged.add(relativeFile);
1382
- }
1383
- }
1384
- }
1385
- return Array.from(merged);
1386
- }
1387
-
1388
- /**
1389
- * Build the explicit per-iteration git staging allowlist.
1390
- *
1391
- * @param {Array<string>} filesChanged
1392
- * @param {Array<object>} completedTasks
1393
- * @param {string|null|undefined} tasksFile
1394
- * @returns {Array<string>}
1395
- */
1396
- function _buildAutoCommitAllowlist(filesChanged, completedTasks, tasksFile) {
1397
- const allowlist = new Set();
1398
-
1399
- for (const file of filesChanged || []) {
1400
- const relativeFile = _repoRelativePath(file);
1401
- if (relativeFile) {
1402
- allowlist.add(relativeFile);
1403
- }
1404
- }
1405
-
1406
- if (Array.isArray(completedTasks) && completedTasks.length > 0 && tasksFile) {
1407
- const relativeTasksFile = _repoRelativePath(tasksFile);
1408
- if (relativeTasksFile) {
1409
- allowlist.add(relativeTasksFile);
1410
- }
1411
- }
1412
-
1413
- return Array.from(allowlist);
1414
- }
1415
-
1416
- /**
1417
- * Return tasks that became completed during the current iteration.
1418
- *
1419
- * @param {Array<object>} beforeTasks
1420
- * @param {Array<object>} afterTasks
1421
- * @returns {Array<object>}
1422
- */
1423
- function _completedTaskDelta(beforeTasks, afterTasks) {
1424
- const beforeCompleted = new Set(
1425
- (beforeTasks || [])
1426
- .filter((task) => task.status === 'completed')
1427
- .map(_taskIdentity)
1428
- );
1429
-
1430
- return (afterTasks || []).filter(
1431
- (task) => task.status === 'completed' && !beforeCompleted.has(_taskIdentity(task))
1432
- );
1433
- }
1434
-
1435
- /**
1436
- * Build a task-aware commit message for an iteration.
1437
- *
1438
- * The subject line (first line) is kept short — conventional git tooling
1439
- * assumes ~50–72 characters — so `git log --oneline` stays readable even when
1440
- * the underlying task description is a multi-sentence normative blob. The
1441
- * full, untruncated task descriptions are preserved in the commit body.
1442
- *
1443
- * @param {number} iteration
1444
- * @param {Array<object>} completedTasks
1445
- * @returns {string}
1446
- */
1447
- function _formatAutoCommitMessage(iteration, completedTasks) {
1448
- if (!Array.isArray(completedTasks) || completedTasks.length === 0) {
1449
- return '';
1450
- }
1451
-
1452
- const rawSummary = completedTasks.length === 1
1453
- ? completedTasks[0].description
1454
- : `complete ${completedTasks.length} tasks`;
1455
-
1456
- const prefix = `Ralph iteration ${iteration}: `;
1457
- const subjectBudget = Math.max(20, SUBJECT_MAX_LENGTH - prefix.length);
1458
- const summary = _truncateSubjectSummary(rawSummary, subjectBudget);
1459
-
1460
- const taskLines = completedTasks.map(
1461
- (task) => `- [x] ${task.fullDescription || task.description}`
1462
- );
1463
-
1464
- return `${prefix}${summary}\n\nTasks completed:\n${taskLines.join('\n')}`;
1465
- }
1466
-
1467
- const SUBJECT_MAX_LENGTH = 72;
1468
-
1469
- /**
1470
- * Reduce a task description to a short, single-line commit subject.
1471
- *
1472
- * Strategy:
1473
- * 1. Collapse whitespace onto a single line.
1474
- * 2. Prefer the first sentence (up to `.`, `!`, `?`) when it is not itself
1475
- * longer than the allowed budget.
1476
- * 3. Otherwise hard-truncate at a word boundary and append an ellipsis.
1477
- *
1478
- * @param {string} text
1479
- * @param {number} budget
1480
- * @returns {string}
1481
- */
1482
- function _truncateSubjectSummary(text, budget) {
1483
- const oneLine = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
1484
- if (oneLine.length === 0) return '';
1485
- if (oneLine.length <= budget) return oneLine;
1486
-
1487
- const sentenceMatch = oneLine.match(/^(.+?[.!?])(\s|$)/);
1488
- if (sentenceMatch) {
1489
- const candidate = sentenceMatch[1].trim();
1490
- if (candidate.length > 0 && candidate.length <= budget) {
1491
- return candidate;
1492
- }
1493
- }
1494
-
1495
- const ellipsis = '…';
1496
- const hardBudget = Math.max(1, budget - ellipsis.length);
1497
- const sliced = oneLine.slice(0, hardBudget);
1498
- const lastSpace = sliced.lastIndexOf(' ');
1499
- const cut = lastSpace > Math.floor(hardBudget / 2) ? sliced.slice(0, lastSpace) : sliced;
1500
- return `${cut.replace(/[\s,;:.!?-]+$/, '')}${ellipsis}`;
1501
- }
1502
-
1503
1226
  /**
1504
1227
  * Summarize recent problem signals so the next iteration can avoid repeating
1505
1228
  * the same failed approach.
@@ -1780,392 +1503,46 @@ function _buildIterationFeedback(recentHistory, errorEntries, blockerArtifacts)
1780
1503
  return sections.join('\n');
1781
1504
  }
1782
1505
 
1783
- function _buildBaselineGateFeedback(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
1784
- return _formatBaselineGateFeedback(
1785
- _analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory)
1786
- );
1787
- }
1788
-
1789
- function _analyzeBaselineGateConflict(ralphDir, tasksFile, currentTaskMeta, recentHistory) {
1790
- if (!ralphDir || !tasksFile || !currentTaskMeta || !currentTaskMeta.description) {
1791
- return null;
1792
- }
1793
-
1794
- const taskBlock = _extractCurrentTaskBlock(tasksFile, currentTaskMeta);
1795
- if (!taskBlock) return null;
1796
-
1797
- const strictGates = _detectStrictCleanGates(taskBlock);
1798
- if (strictGates.length === 0) return null;
1799
-
1800
- const recordedBaselines = _detectRecordedBaselineGates(ralphDir);
1801
- const missingBaselines = _detectMissingBaselineGates(
1802
- strictGates,
1803
- recordedBaselines,
1804
- taskBlock,
1805
- tasksFile
1806
- );
1807
-
1808
- if (missingBaselines.length > 0) {
1809
- return {
1810
- mode: 'missing_baseline',
1811
- conflicts: [],
1812
- missingBaselines,
1813
- allowedFiles: [],
1814
- budgetUsed: false,
1815
- };
1816
- }
1817
-
1818
- const failingBaselines = recordedBaselines.filter((gate) => gate.exitCode !== 0);
1819
- if (failingBaselines.length === 0) return null;
1820
-
1821
- const baselineByGate = new Map(failingBaselines.map((gate) => [gate.name, gate]));
1822
- const conflicts = strictGates
1823
- .map((gate) => ({ gate, baseline: baselineByGate.get(gate.name) }))
1824
- .filter((item) => item.baseline);
1825
-
1826
- if (conflicts.length === 0) return null;
1827
-
1828
- const cleanup = _detectAuthorizedBaselineCleanup(taskBlock);
1829
- if (cleanup.allowedFiles.length > 0) {
1830
- return {
1831
- mode: 'authorized_cleanup',
1832
- conflicts,
1833
- allowedFiles: cleanup.allowedFiles,
1834
- budgetUsed: _baselineGateRepairBudgetUsed(recentHistory, currentTaskMeta, cleanup.allowedFiles),
1835
- };
1836
- }
1837
-
1838
- if (_taskExplicitlyHandlesBaselineFailures(taskBlock)) {
1839
- return {
1840
- mode: 'baseline_classification',
1841
- conflicts,
1842
- allowedFiles: [],
1843
- budgetUsed: false,
1844
- };
1845
- }
1846
-
1847
- return {
1848
- mode: 'missing_policy',
1849
- conflicts,
1850
- allowedFiles: [],
1851
- budgetUsed: false,
1852
- };
1853
- }
1854
-
1855
- function _formatBaselineGateFeedback(conflict) {
1856
- const conflicts = Array.isArray(conflict && conflict.conflicts) ? conflict.conflicts : [];
1857
- const missingBaselines = Array.isArray(conflict && conflict.missingBaselines)
1858
- ? conflict.missingBaselines
1859
- : [];
1860
-
1861
- if (!conflict || (conflicts.length === 0 && missingBaselines.length === 0)) {
1862
- return '';
1863
- }
1864
-
1865
- const conflictLines = conflicts.map(({ gate, baseline }) =>
1866
- `- ${gate.command}: baseline ${baseline.file} exits ${baseline.exitCode}.`
1867
- );
1868
- const missingLines = missingBaselines.map((gate) =>
1869
- `- ${gate.command}: no matching baseline artifact found under .ralph/baselines.`
1870
- );
1871
-
1872
- if (conflict.mode === 'missing_baseline') {
1873
- return [
1874
- '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.',
1875
- 'Do not classify failures as pre-existing or spend an implementation iteration trying to satisfy an impossible task contract.',
1876
- '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.',
1877
- '',
1878
- ...missingLines,
1879
- ].join('\n');
1880
- }
1881
-
1882
- if (conflict.mode === 'authorized_cleanup') {
1883
- if (conflict.budgetUsed) {
1884
- return [
1885
- 'The current task explicitly authorized cleanup for baseline gate failures, but its one repair attempt has already been used.',
1886
- 'Do not keep iterating on cleanup or broaden the edit scope.',
1887
- '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.',
1888
- '',
1889
- `Authorized cleanup files: ${conflict.allowedFiles.join(', ')}`,
1890
- ...conflictLines,
1891
- ].join('\n');
1892
- }
1893
-
1894
- return [
1895
- 'The current task explicitly authorizes cleanup for baseline gate failures in named files.',
1896
- '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.',
1897
- 'If this attempt does not clear the gate, emit BLOCKED_HANDOFF instead of continuing to retry.',
1898
- '',
1899
- `Authorized cleanup files: ${conflict.allowedFiles.join(', ')}`,
1900
- ...conflictLines,
1901
- ].join('\n');
1902
- }
1903
-
1904
- if (conflict.mode === 'baseline_classification') {
1905
- return [
1906
- 'The current task has strict quality-gate checks, and matching pre-flight baselines are already failing.',
1907
- 'The task text appears to authorize baseline classification, so do not repair unrelated baseline failures unless the task explicitly names those files.',
1908
- 'Complete the task only if the current run has no new failures beyond the named baseline failures.',
1909
- '',
1910
- ...conflictLines,
1911
- ].join('\n');
1912
- }
1913
-
1914
- return [
1915
- '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.',
1916
- 'Do not spend iterations repairing unrelated files outside the current task scope.',
1917
- '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.',
1918
- '',
1919
- ...conflictLines,
1920
- ].join('\n');
1921
- }
1922
-
1923
- function _extractCurrentTaskBlock(tasksFile, currentTaskMeta) {
1924
- const fs = require('fs');
1925
- if (!tasksFile || !fs.existsSync(tasksFile)) return '';
1926
-
1927
- const lines = fs.readFileSync(tasksFile, 'utf8').split(/\r?\n/);
1928
- const taskHeader = /^-\s+\[[ x/]\]\s+(.+)$/;
1929
- const targetNumber = currentTaskMeta.number || '';
1930
- const targetDescription = (currentTaskMeta.description || '').trim();
1931
- let start = -1;
1932
-
1933
- for (let i = 0; i < lines.length; i++) {
1934
- const match = lines[i].match(taskHeader);
1935
- if (!match) continue;
1936
-
1937
- const fullDescription = match[1].trim();
1938
- const numMatch = fullDescription.match(/^(\d+\.\d+)\s+(.+)$/);
1939
- const number = numMatch ? numMatch[1] : '';
1940
- const description = (numMatch ? numMatch[2] : fullDescription).trim();
1941
-
1942
- if (
1943
- (targetNumber && number === targetNumber) ||
1944
- (!targetNumber && description === targetDescription) ||
1945
- (targetNumber && description === targetDescription)
1946
- ) {
1947
- start = i;
1948
- break;
1949
- }
1950
- }
1951
-
1952
- if (start === -1) return '';
1953
-
1954
- let end = lines.length;
1955
- for (let i = start + 1; i < lines.length; i++) {
1956
- if (taskHeader.test(lines[i])) {
1957
- end = i;
1958
- break;
1959
- }
1960
- }
1506
+ function _buildAutoResolveHandoffFeedback(recentHistory) {
1507
+ if (!Array.isArray(recentHistory) || recentHistory.length === 0) return '';
1961
1508
 
1962
- return lines.slice(start, end).join('\n');
1963
- }
1509
+ const entry = recentHistory
1510
+ .slice()
1511
+ .reverse()
1512
+ .find((item) => item && item.autoResolveHandoffAttempted === true);
1964
1513
 
1965
- function _detectStrictCleanGates(taskBlock) {
1966
- if (!taskBlock) return [];
1514
+ if (!entry) return '';
1967
1515
 
1968
- const gates = [
1969
- {
1970
- name: 'typecheck',
1971
- command: 'pnpm typecheck',
1972
- pattern: /`?pnpm\s+typecheck`?[^\n]*(?:exits?|returns?)\s+0/i,
1973
- },
1974
- {
1975
- name: 'lint',
1976
- command: 'pnpm lint',
1977
- pattern: /`?pnpm\s+lint`?[^\n]*(?:exits?|returns?)\s+0/i,
1978
- },
1979
- {
1980
- name: 'test',
1981
- command: 'pnpm test',
1982
- pattern: /`?pnpm\s+test`?[^\n]*(?:exits?|returns?)\s+0/i,
1983
- },
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
+ });
1523
+ const lines = [
1524
+ `The previous iteration emitted BLOCKED_HANDOFF, but auto-resolution is enabled and ${spentBudget.allowed ? 'reserved' : 'spent'} its bounded attempt for ${className}.`,
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.',
1984
1526
  ];
1985
1527
 
1986
- return gates.filter((gate) => gate.pattern.test(taskBlock));
1987
- }
1988
-
1989
- function _detectFailingBaselineGates(ralphDir) {
1990
- return _detectRecordedBaselineGates(ralphDir).filter((gate) => gate.exitCode !== 0);
1991
- }
1992
-
1993
- function _detectRecordedBaselineGates(ralphDir) {
1994
- const fs = require('fs');
1995
- const fsPath = require('path');
1996
- const baselinesDir = fsPath.join(ralphDir, 'baselines');
1997
- if (!fs.existsSync(baselinesDir) || !fs.statSync(baselinesDir).isDirectory()) {
1998
- return [];
1999
- }
2000
-
2001
- const gates = [];
2002
- for (const name of fs.readdirSync(baselinesDir)) {
2003
- if (!/\.txt$/i.test(name)) continue;
2004
-
2005
- const gateName = _gateNameFromBaselineFile(name);
2006
- if (!gateName) continue;
2007
-
2008
- const file = fsPath.join(baselinesDir, name);
2009
- const tail = _readFileTail(file, 16384);
2010
- const exitMatch = tail.match(/(?:^|\n)EXIT=(\d+)(?:\n|$)/);
2011
- if (!exitMatch) continue;
2012
-
2013
- const exitCode = Number(exitMatch[1]);
2014
- if (!Number.isInteger(exitCode)) continue;
2015
-
2016
- gates.push({ name: gateName, file: fsPath.join('baselines', name), exitCode });
2017
- }
2018
-
2019
- const priority = { typecheck: 1, lint: 2, test: 3 };
2020
- return gates.sort((a, b) =>
2021
- (priority[a.name] || 99) - (priority[b.name] || 99) ||
2022
- a.file.localeCompare(b.file)
2023
- );
2024
- }
2025
-
2026
- function _detectMissingBaselineGates(strictGates, recordedBaselines, taskBlock, tasksFile) {
2027
- if (!Array.isArray(strictGates) || strictGates.length === 0) return [];
2028
-
2029
- const expectsBaseline =
2030
- _taskExplicitlyHandlesBaselineFailures(taskBlock) ||
2031
- _completedPreflightBaselineExists(tasksFile);
2032
-
2033
- if (!expectsBaseline) return [];
2034
-
2035
- const recordedNames = new Set((recordedBaselines || []).map((gate) => gate.name));
2036
- return strictGates.filter((gate) => !recordedNames.has(gate.name));
2037
- }
2038
-
2039
- function _completedPreflightBaselineExists(tasksFile) {
2040
- const fs = require('fs');
2041
- if (!tasksFile || !fs.existsSync(tasksFile)) return false;
2042
-
2043
- const lines = fs.readFileSync(tasksFile, 'utf8').split(/\r?\n/);
2044
- return lines.some((line) =>
2045
- /^-\s+\[x\]\s+.*\bpre-?flight\b.*\bbaselines?\b/i.test(line)
2046
- );
2047
- }
2048
-
2049
- function _gateNameFromBaselineFile(fileName) {
2050
- const normalized = fileName.toLowerCase();
2051
- if (/(^|[-_.])typecheck([-_.]|\.|$)/.test(normalized)) return 'typecheck';
2052
- if (/(^|[-_.])lint([-_.]|\.|$)/.test(normalized)) return 'lint';
2053
- if (/(^|[-_.])test([-_.]|\.|$)/.test(normalized)) return 'test';
2054
- return '';
2055
- }
2056
-
2057
- function _readFileTail(file, maxBytes) {
2058
- const fs = require('fs');
2059
- let fd = null;
2060
- try {
2061
- const stat = fs.statSync(file);
2062
- const length = Math.min(stat.size, maxBytes);
2063
- const offset = Math.max(0, stat.size - length);
2064
- const buffer = Buffer.alloc(length);
2065
- fd = fs.openSync(file, 'r');
2066
- fs.readSync(fd, buffer, 0, length, offset);
2067
- return buffer.toString('utf8');
2068
- } catch {
2069
- return '';
2070
- } finally {
2071
- if (fd !== null) {
2072
- try {
2073
- fs.closeSync(fd);
2074
- } catch {
2075
- // Ignore close failures while building best-effort feedback.
2076
- }
2077
- }
2078
- }
2079
- }
2080
-
2081
- function _taskExplicitlyHandlesBaselineFailures(taskBlock) {
2082
- return /\bbaseline\b/i.test(taskBlock) &&
2083
- /\b(match|matches|matching|classif(?:y|ied|ication)|pre-existing|preexisting|no new failures?)\b/i.test(taskBlock);
2084
- }
2085
-
2086
- function _detectAuthorizedBaselineCleanup(taskBlock) {
2087
- if (!taskBlock || !/\b(authori[sz]ed cleanup|after fixing|fixing the named baseline failures?)\b/i.test(taskBlock)) {
2088
- return { allowedFiles: [] };
2089
- }
2090
-
2091
- const allowedFiles = [];
2092
- const seen = new Set();
2093
- const backtickPattern = /`([^`]+)`/g;
2094
- let match;
2095
-
2096
- while ((match = backtickPattern.exec(taskBlock)) !== null) {
2097
- const candidate = match[1].trim();
2098
- if (!_looksLikeCleanupPath(candidate)) continue;
2099
-
2100
- const normalized = candidate.replace(/\\/g, '/');
2101
- if (seen.has(normalized)) continue;
2102
-
2103
- seen.add(normalized);
2104
- allowedFiles.push(normalized);
2105
- }
2106
-
2107
- return { allowedFiles };
2108
- }
2109
-
2110
- function _looksLikeCleanupPath(value) {
2111
- if (!value || /\s/.test(value)) return false;
2112
- if (/^(pnpm|npm|yarn|node|gtimeout|timeout|rg|git)(\s|$)/i.test(value)) return false;
2113
- if (/^--?/.test(value)) return false;
2114
- if (/[*{}]/.test(value)) return false;
2115
- return value.includes('/') || /\.[A-Za-z0-9]+$/.test(value);
2116
- }
2117
-
2118
- function _baselineGateRepairBudgetUsed(recentHistory, currentTaskMeta, allowedFiles) {
2119
- if (!Array.isArray(recentHistory) || recentHistory.length === 0) return false;
2120
-
2121
- return recentHistory.some((entry) => {
2122
- if (!_historyEntryMatchesTask(entry, currentTaskMeta)) return false;
2123
- if (entry.baselineGateRepairAttempted === true) return true;
2124
-
2125
- return _baselineGateRepairAttempted(
2126
- { mode: 'authorized_cleanup', allowedFiles },
2127
- entry.filesChanged || []
1528
+ if (className === 'verifier_narrowing') {
1529
+ lines.push(
1530
+ 'Allowed action: if the handoff explicitly names a focused verifier that passes and a broad verifier that fails only on unrelated/pre-existing failures, update only the current task verifier from the broad command to that focused command, run the focused command once, and complete the task only if it passes. If the focused command is absent, ambiguous, or fails, emit BLOCKED_HANDOFF instead of retrying.'
1531
+ );
1532
+ } else if (className === 'authorized_cleanup') {
1533
+ const files = Array.isArray(entry.autoResolveHandoffAllowedFiles)
1534
+ ? entry.autoResolveHandoffAllowedFiles.filter(Boolean)
1535
+ : [];
1536
+ lines.push(
1537
+ `Allowed action: make one cleanup attempt only in the task-authorized file list${files.length > 0 ? ` (${files.join(', ')})` : ''}. If the gate still fails, emit BLOCKED_HANDOFF instead of continuing.`
1538
+ );
1539
+ } else {
1540
+ lines.push(
1541
+ 'Allowed action: continue only if the blocker evidence remains explicit and within the runner-approved safe class; otherwise emit BLOCKED_HANDOFF.'
2128
1542
  );
2129
- });
2130
- }
2131
-
2132
- function _baselineGateRepairAttempted(conflict, filesChanged) {
2133
- if (
2134
- !conflict ||
2135
- conflict.mode !== 'authorized_cleanup' ||
2136
- !Array.isArray(conflict.allowedFiles) ||
2137
- conflict.allowedFiles.length === 0 ||
2138
- !Array.isArray(filesChanged) ||
2139
- filesChanged.length === 0
2140
- ) {
2141
- return false;
2142
1543
  }
2143
1544
 
2144
- return _pathsIntersect(conflict.allowedFiles, filesChanged);
2145
- }
2146
-
2147
- function _historyEntryMatchesTask(entry, currentTaskMeta) {
2148
- if (!entry || !currentTaskMeta) return false;
2149
-
2150
- const currentNumber = currentTaskMeta.number || '';
2151
- const currentDescription = currentTaskMeta.description || '';
2152
-
2153
- if (currentNumber && entry.taskNumber === currentNumber) return true;
2154
- if (!currentNumber && currentDescription && entry.taskDescription === currentDescription) return true;
2155
-
2156
- return false;
2157
- }
2158
-
2159
- function _pathsIntersect(left, right) {
2160
- const normalizedLeft = new Set((left || []).map(_normalizeComparablePath));
2161
- return (right || []).some((pathValue) => normalizedLeft.has(_normalizeComparablePath(pathValue)));
2162
- }
2163
-
2164
- function _normalizeComparablePath(pathValue) {
2165
- return String(pathValue || '')
2166
- .replace(/\\/g, '/')
2167
- .replace(/^\.\//, '')
2168
- .replace(/\/+$/, '');
1545
+ return lines.join('\n');
2169
1546
  }
2170
1547
 
2171
1548
  function _extractErrorForIteration(errorEntries, iteration) {
@@ -2264,75 +1641,6 @@ function _cleanupCompletedErrors(ralphDir, verbose) {
2264
1641
  }
2265
1642
  }
2266
1643
 
2267
- function _taskIdentity(task) {
2268
- return task.number
2269
- ? `${task.number}|${task.fullDescription || task.description}`
2270
- : (task.fullDescription || task.description);
2271
- }
2272
-
2273
- function _repoRelativePath(filePath) {
2274
- if (!filePath || typeof filePath !== 'string') return '';
2275
- const normalized = path.normalize(filePath);
2276
- if (!normalized || normalized === '.') return '';
2277
- const relative = path.isAbsolute(normalized)
2278
- ? path.relative(process.cwd(), normalized)
2279
- : normalized;
2280
-
2281
- if (!relative || relative.startsWith('..')) {
2282
- return '';
2283
- }
2284
-
2285
- return relative.split(path.sep).join('/');
2286
- }
2287
-
2288
- function _detectProtectedCommitArtifacts(filesToStage, tasksFile) {
2289
- if (!Array.isArray(filesToStage) || filesToStage.length === 0 || !tasksFile) {
2290
- return [];
2291
- }
2292
-
2293
- const relativeTasksFile = _repoRelativePath(tasksFile);
2294
- if (!relativeTasksFile) {
2295
- return [];
2296
- }
2297
-
2298
- const changeRoot = path.posix.dirname(relativeTasksFile);
2299
- const protectedArtifacts = [];
2300
-
2301
- for (const file of filesToStage) {
2302
- const normalized = _repoRelativePath(file);
2303
- if (!normalized) continue;
2304
-
2305
- const isProposal = normalized === `${changeRoot}/proposal.md`;
2306
- const isDesign = normalized === `${changeRoot}/design.md`;
2307
- const isSpec = normalized.startsWith(`${changeRoot}/specs/`) && normalized.endsWith('/spec.md');
2308
-
2309
- if (isProposal || isDesign || isSpec) {
2310
- protectedArtifacts.push(normalized);
2311
- }
2312
- }
2313
-
2314
- return protectedArtifacts;
2315
- }
2316
-
2317
- function _gitErrorMessage(err) {
2318
- if (!err) return 'unknown git error';
2319
-
2320
- const stderr = _coerceGitErrorStream(err.stderr);
2321
- const stdout = _coerceGitErrorStream(err.stdout);
2322
-
2323
- if (stderr) return stderr;
2324
- if (stdout) return stdout;
2325
- if (err.message) return err.message;
2326
- return 'unknown git error';
2327
- }
2328
-
2329
- function _coerceGitErrorStream(stream) {
2330
- if (!stream) return '';
2331
- if (Buffer.isBuffer(stream)) return stream.toString('utf8').trim();
2332
- if (typeof stream === 'string') return stream.trim();
2333
- return '';
2334
- }
2335
-
2336
1644
  /**
2337
1645
  * Determine the starting iteration for a new run.
2338
1646
  *
@@ -2374,6 +1682,7 @@ function _resolveStartIteration(existingState, options) {
2374
1682
 
2375
1683
  module.exports = {
2376
1684
  run,
1685
+ _recoverSupervisorStartupResidue,
2377
1686
  _finalizeRunState,
2378
1687
  _containsPromise,
2379
1688
  _validateOptions,
@@ -2392,6 +1701,13 @@ module.exports = {
2392
1701
  _formatAutoCommitMessage,
2393
1702
  _truncateSubjectSummary,
2394
1703
  _buildIterationFeedback,
1704
+ _buildAutoResolveHandoffFeedback,
1705
+ _resolveAutoResolveHandoffConfig,
1706
+ _resolveSupervisorConfig,
1707
+ _handoffHasFocusedVerifierEvidence,
1708
+ _classifyAutoResolvableHandoff,
1709
+ _decideAutoResolveHandoff,
1710
+ _consumeAutoResolveHandoffBudget,
2395
1711
  _buildBaselineGateFeedback,
2396
1712
  _analyzeBaselineGateConflict,
2397
1713
  _formatBaselineGateFeedback,