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.
- package/OPENSPEC-RALPH-BP.md +20 -0
- package/QUICKSTART.md +2 -0
- package/README.md +20 -2
- package/lib/mini-ralph/history.js +77 -2
- package/lib/mini-ralph/index.js +8 -0
- package/lib/mini-ralph/invoker.js +29 -3
- package/lib/mini-ralph/prompt.js +40 -3
- package/lib/mini-ralph/runner-autocommit.js +440 -0
- package/lib/mini-ralph/runner-baseline-gate.js +431 -0
- package/lib/mini-ralph/runner-handoff.js +338 -0
- package/lib/mini-ralph/runner-pending-dirty.js +168 -0
- package/lib/mini-ralph/runner.js +308 -1248
- package/lib/mini-ralph/state.js +35 -3
- package/lib/mini-ralph/status.js +37 -1
- package/lib/mini-ralph/supervisor-rules.js +379 -0
- package/lib/mini-ralph/supervisor-state.js +218 -0
- package/lib/mini-ralph/supervisor.js +1319 -0
- package/package.json +1 -1
- package/scripts/mini-ralph-cli.js +48 -1
- package/scripts/ralph-run.sh +103 -2
- package/scripts/supervisor-prompt.md +134 -0
package/lib/mini-ralph/runner.js
CHANGED
|
@@ -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
|
|
190
|
-
|
|
191
|
-
:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
:
|
|
195
|
-
|
|
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 (
|
|
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:
|
|
215
|
-
reason:
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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({},
|
|
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
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
445
|
+
return recovery;
|
|
352
446
|
}
|
|
353
447
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
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,
|
|
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,
|