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.
- 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 +518 -1202
- 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 +75 -2
- package/scripts/ralph-run.sh +121 -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,
|
|
@@ -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
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
445
|
+
return recovery;
|
|
184
446
|
}
|
|
185
447
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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,
|
|
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};
|
|
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
|
|
1784
|
-
return
|
|
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
|
-
|
|
1963
|
-
|
|
1509
|
+
const entry = recentHistory
|
|
1510
|
+
.slice()
|
|
1511
|
+
.reverse()
|
|
1512
|
+
.find((item) => item && item.autoResolveHandoffAttempted === true);
|
|
1964
1513
|
|
|
1965
|
-
|
|
1966
|
-
if (!taskBlock) return [];
|
|
1514
|
+
if (!entry) return '';
|
|
1967
1515
|
|
|
1968
|
-
const
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
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
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
|
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,
|