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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* runner-handoff.js — Handoff and blocker-artifact helpers split out of
|
|
5
|
+
* runner.js to keep that module digestible.
|
|
6
|
+
*
|
|
7
|
+
* Responsible for:
|
|
8
|
+
* - extracting the agent's `BLOCKED_HANDOFF` blocker note from stdout
|
|
9
|
+
* - probing for diagnostic artifacts written into `.ralph/` so the next
|
|
10
|
+
* iteration sees the prior diagnosis without re-deriving it
|
|
11
|
+
* - appending blocker entries (and supervisor sub-sections) to HANDOFF.md
|
|
12
|
+
* - persisting fatal-iteration history rows
|
|
13
|
+
*
|
|
14
|
+
* These helpers were moved verbatim — no behavior change. Implementation
|
|
15
|
+
* details are unchanged so existing tests in
|
|
16
|
+
* `tests/unit/javascript/mini-ralph-runner.test.js` keep passing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const fsPath = require('path');
|
|
21
|
+
|
|
22
|
+
const errors = require('./errors');
|
|
23
|
+
const history = require('./history');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pull the agent's structured blocker note out of an iteration's stdout.
|
|
27
|
+
*
|
|
28
|
+
* Looks for the explicit `<promise>BLOCKED_HANDOFF</promise>` tag emitted by
|
|
29
|
+
* the agent and walks back up to the nearest `## Blocker Note` (or
|
|
30
|
+
* `Blocker:`) sentinel. Falls back to the last 40 non-blank lines when the
|
|
31
|
+
* agent emitted the promise without a sentinel header.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} outputText full iteration stdout
|
|
34
|
+
* @param {string} promiseName configured BLOCKED_HANDOFF promise name
|
|
35
|
+
* @returns {string} the extracted note (empty string if the tag is absent)
|
|
36
|
+
*/
|
|
37
|
+
function _extractBlockerNote(outputText, promiseName) {
|
|
38
|
+
if (!outputText || !promiseName) return '';
|
|
39
|
+
const tag = `<promise>${promiseName}</promise>`;
|
|
40
|
+
const lines = outputText.split(/\r?\n/);
|
|
41
|
+
let tagIdx = -1;
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
if (lines[i].trim() === tag) {
|
|
44
|
+
tagIdx = i;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (tagIdx === -1) return '';
|
|
49
|
+
|
|
50
|
+
const sentinel = /^\s*(##\s*Blocker(\s+Note)?|Blocker:)/i;
|
|
51
|
+
let startIdx = tagIdx;
|
|
52
|
+
for (let i = tagIdx - 1; i >= 0; i--) {
|
|
53
|
+
if (sentinel.test(lines[i])) {
|
|
54
|
+
startIdx = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (startIdx === tagIdx) {
|
|
60
|
+
const window = [];
|
|
61
|
+
for (let i = tagIdx - 1; i >= 0 && window.length < 40; i--) {
|
|
62
|
+
const l = lines[i];
|
|
63
|
+
if (l.trim()) window.unshift(l);
|
|
64
|
+
}
|
|
65
|
+
return window.join('\n').trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines.slice(startIdx, tagIdx).join('\n').trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Scan well-known locations for blocker / diagnostic artifacts the agent
|
|
73
|
+
* may have written during the most recent iteration, and return their
|
|
74
|
+
* content (truncated) so we can tee it into the next iteration's prompt.
|
|
75
|
+
*
|
|
76
|
+
* The motivation is the failure mode we observed in the wild: the agent
|
|
77
|
+
* writes `<change-baseline>/shared-chrome-invariant-report.txt` with a clear
|
|
78
|
+
* `STATUS=BLOCKED REASON=...` diagnosis, then on the next iteration starts
|
|
79
|
+
* from a blank slate, re-derives the same diagnosis, and burns another full
|
|
80
|
+
* LLM cycle. By auto-detecting and surfacing the artifact, the agent gets
|
|
81
|
+
* its own prior diagnosis as input on the next turn, freeing it to either
|
|
82
|
+
* (a) act on it, or (b) emit BLOCKED_HANDOFF with a richer note.
|
|
83
|
+
*
|
|
84
|
+
* Probe paths (relative to ralphDir's parent — i.e. the change root):
|
|
85
|
+
* - <ralphDir>/HANDOFF.md
|
|
86
|
+
* - <ralphDir>/BLOCKED.md
|
|
87
|
+
* - <ralphDir>/blocker.md / blocker-note.md
|
|
88
|
+
* - <repoRoot>/.ralph/baselines/<change>/*report*.{txt,md}
|
|
89
|
+
* - any file under <ralphDir> matching /(blocker|handoff|invariant-report)\.[a-z]+$/i
|
|
90
|
+
*
|
|
91
|
+
* We cap the returned text at 1500 chars per artifact and 3 artifacts total
|
|
92
|
+
* so the feedback block stays bounded. Freshness is required by default to
|
|
93
|
+
* avoid carrying stale diagnostics forever; when a prior run explicitly ended
|
|
94
|
+
* with BLOCKED_HANDOFF, the canonical handoff files may be included even when
|
|
95
|
+
* stale because they are the persisted operator-facing diagnosis.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} ralphDir
|
|
98
|
+
* @param {object} [options] { repoRoot, maxArtifacts = 3, maxCharsEach = 1500, includeStaleHandoff = false }
|
|
99
|
+
* @returns {Array<{ path: string, content: string, truncated: boolean }>}
|
|
100
|
+
*/
|
|
101
|
+
function _detectBlockerArtifacts(ralphDir, options) {
|
|
102
|
+
const opts = Object.assign(
|
|
103
|
+
{
|
|
104
|
+
repoRoot: process.cwd(),
|
|
105
|
+
maxArtifacts: 3,
|
|
106
|
+
maxCharsEach: 1500,
|
|
107
|
+
includeStaleHandoff: false,
|
|
108
|
+
},
|
|
109
|
+
options || {}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!ralphDir || !fs.existsSync(ralphDir)) return [];
|
|
113
|
+
|
|
114
|
+
const matches = new Map();
|
|
115
|
+
const isHandoffArtifact = (name) =>
|
|
116
|
+
/^(handoff|blocked|blocker(-note)?)\.(md|txt)$/i.test(name);
|
|
117
|
+
const isInteresting = (name) =>
|
|
118
|
+
isHandoffArtifact(name) ||
|
|
119
|
+
/(invariant|blocker|handoff).*report\.(md|txt)$/i.test(name) ||
|
|
120
|
+
/report\.(md|txt)$/i.test(name);
|
|
121
|
+
|
|
122
|
+
const consider = (p) => {
|
|
123
|
+
try {
|
|
124
|
+
const st = fs.statSync(p);
|
|
125
|
+
if (!st.isFile()) return;
|
|
126
|
+
if (st.size > 1024 * 1024) return;
|
|
127
|
+
const stale = Date.now() - st.mtimeMs > 10 * 60 * 1000;
|
|
128
|
+
if (stale && !(opts.includeStaleHandoff && isHandoffArtifact(fsPath.basename(p)))) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
matches.set(fsPath.resolve(p), st.mtimeMs);
|
|
132
|
+
} catch (_) {
|
|
133
|
+
// ENOENT / permission errors: ignore — this is a best-effort probe.
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const entries = fs.readdirSync(ralphDir, { withFileTypes: true });
|
|
139
|
+
for (const ent of entries) {
|
|
140
|
+
if (ent.isFile() && isInteresting(ent.name)) {
|
|
141
|
+
consider(fsPath.join(ralphDir, ent.name));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch (_) { /* ignore */ }
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const changeDir = fsPath.dirname(ralphDir);
|
|
148
|
+
const changeName = fsPath.basename(changeDir);
|
|
149
|
+
const baselinesDir = fsPath.join(opts.repoRoot, '.ralph', 'baselines', changeName);
|
|
150
|
+
if (fs.existsSync(baselinesDir)) {
|
|
151
|
+
const entries = fs.readdirSync(baselinesDir, { withFileTypes: true });
|
|
152
|
+
for (const ent of entries) {
|
|
153
|
+
if (ent.isFile() && isInteresting(ent.name)) {
|
|
154
|
+
consider(fsPath.join(baselinesDir, ent.name));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (_) { /* ignore */ }
|
|
159
|
+
|
|
160
|
+
if (matches.size === 0) return [];
|
|
161
|
+
|
|
162
|
+
const sorted = Array.from(matches.entries())
|
|
163
|
+
.sort((a, b) => b[1] - a[1])
|
|
164
|
+
.map(([p]) => p);
|
|
165
|
+
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const p of sorted.slice(0, opts.maxArtifacts)) {
|
|
168
|
+
try {
|
|
169
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
170
|
+
const truncated = raw.length > opts.maxCharsEach;
|
|
171
|
+
const content = truncated ? raw.slice(0, opts.maxCharsEach) : raw;
|
|
172
|
+
out.push({
|
|
173
|
+
path: fsPath.relative(opts.repoRoot, p) || p,
|
|
174
|
+
content: content.trim(),
|
|
175
|
+
truncated,
|
|
176
|
+
});
|
|
177
|
+
} catch (_) {
|
|
178
|
+
// Ignore unreadable artifacts.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Write the agent's blocker note to <ralphDir>/HANDOFF.md with iteration
|
|
187
|
+
* metadata so an operator can reproduce the context. Appends rather than
|
|
188
|
+
* overwrites: a single change can hit several BLOCKED_HANDOFFs over time
|
|
189
|
+
* (operator unblocks, loop resumes, hits a different blocker), and we want
|
|
190
|
+
* the full audit trail in one file.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} ralphDir
|
|
193
|
+
* @param {object} entry { iteration, task, note, completionPromise, taskPromise, supervisor }
|
|
194
|
+
* @returns {string} the absolute path to HANDOFF.md
|
|
195
|
+
*/
|
|
196
|
+
function _writeHandoff(ralphDir, entry) {
|
|
197
|
+
if (!fs.existsSync(ralphDir)) {
|
|
198
|
+
fs.mkdirSync(ralphDir, { recursive: true });
|
|
199
|
+
}
|
|
200
|
+
const handoffPath = fsPath.join(ralphDir, 'HANDOFF.md');
|
|
201
|
+
const ts = new Date().toISOString();
|
|
202
|
+
const taskLine = entry.task && entry.task !== 'N/A'
|
|
203
|
+
? entry.task
|
|
204
|
+
: '(no task in progress)';
|
|
205
|
+
const noteBlock = entry.note && entry.note.trim()
|
|
206
|
+
? entry.note.trim()
|
|
207
|
+
: '(agent emitted BLOCKED_HANDOFF without a structured blocker note;\n' +
|
|
208
|
+
'check the iteration stdout log for the rationale)';
|
|
209
|
+
const supervisorBlock = _formatSupervisorHandoffSections(entry.supervisor);
|
|
210
|
+
|
|
211
|
+
const section = [
|
|
212
|
+
'',
|
|
213
|
+
`## Iteration ${entry.iteration} — ${ts}`,
|
|
214
|
+
'',
|
|
215
|
+
`**Task:** ${taskLine}`,
|
|
216
|
+
'',
|
|
217
|
+
'**Agent blocker note:**',
|
|
218
|
+
'',
|
|
219
|
+
noteBlock,
|
|
220
|
+
'',
|
|
221
|
+
'**Operator next step:** investigate the blocker, take one of the actions',
|
|
222
|
+
'the task spec authorizes (revert / isolate / justify / escalate), then',
|
|
223
|
+
'rerun `ralph-run` to resume.',
|
|
224
|
+
supervisorBlock,
|
|
225
|
+
'',
|
|
226
|
+
'---',
|
|
227
|
+
'',
|
|
228
|
+
].join('\n');
|
|
229
|
+
|
|
230
|
+
let existing = '';
|
|
231
|
+
if (fs.existsSync(handoffPath)) {
|
|
232
|
+
existing = fs.readFileSync(handoffPath, 'utf8');
|
|
233
|
+
} else {
|
|
234
|
+
existing = '# Ralph Handoff Log\n\nThis file is appended whenever the loop\n' +
|
|
235
|
+
'exits with `BLOCKED_HANDOFF`. Each section is one blocker the\n' +
|
|
236
|
+
'agent surfaced — review newest first.\n';
|
|
237
|
+
}
|
|
238
|
+
fs.writeFileSync(handoffPath, existing + section, 'utf8');
|
|
239
|
+
return handoffPath;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _formatSupervisorHandoffSections(supervisorEntry) {
|
|
243
|
+
if (!supervisorEntry || typeof supervisorEntry !== 'object') {
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const sections = [];
|
|
248
|
+
const patchedTasks = Array.isArray(supervisorEntry.patchedTasks)
|
|
249
|
+
? supervisorEntry.patchedTasks.filter((taskNumber) => String(taskNumber || '').trim())
|
|
250
|
+
: [];
|
|
251
|
+
if (patchedTasks.length > 0) {
|
|
252
|
+
sections.push(
|
|
253
|
+
'## Supervisor edits',
|
|
254
|
+
'',
|
|
255
|
+
`- Iteration: ${supervisorEntry.iteration || 'N/A'}`,
|
|
256
|
+
`- Try index: ${supervisorEntry.tryIndex || 'N/A'}`,
|
|
257
|
+
`- Blocker hash: ${supervisorEntry.blockerHash || 'N/A'}`,
|
|
258
|
+
`- Patched tasks: ${patchedTasks.join(', ')}`,
|
|
259
|
+
`- Soft warnings: ${_formatSupervisorList(supervisorEntry.softWarnings, 'none')}`,
|
|
260
|
+
`- Summary: ${String(supervisorEntry.summary || '').trim() || 'none'}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const attempts = Array.isArray(supervisorEntry.attempts)
|
|
265
|
+
? supervisorEntry.attempts.filter((attempt) => String(attempt || '').trim())
|
|
266
|
+
: [];
|
|
267
|
+
if (supervisorEntry.attemptsExhausted === true && attempts.length > 0) {
|
|
268
|
+
sections.push(
|
|
269
|
+
'### Supervisor attempts',
|
|
270
|
+
'',
|
|
271
|
+
...attempts.map((attempt) => `- ${attempt}`),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return sections.length > 0 ? ['', ...sections].join('\n') : '';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function _formatSupervisorList(values, fallback) {
|
|
279
|
+
const items = Array.isArray(values)
|
|
280
|
+
? values.map((value) => String(value || '').trim()).filter(Boolean)
|
|
281
|
+
: [];
|
|
282
|
+
return items.length > 0 ? items.join('; ') : fallback;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function _appendFatalIterationFailure(ralphDir, entry) {
|
|
286
|
+
errors.append(ralphDir, {
|
|
287
|
+
iteration: entry.iteration,
|
|
288
|
+
task: entry.task,
|
|
289
|
+
exitCode: entry.exitCode,
|
|
290
|
+
signal: entry.signal || '',
|
|
291
|
+
failureStage: entry.failureStage || '',
|
|
292
|
+
stderr: entry.stderr || '',
|
|
293
|
+
stdout: entry.stdout || '',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
history.append(ralphDir, {
|
|
297
|
+
iteration: entry.iteration,
|
|
298
|
+
duration: entry.duration,
|
|
299
|
+
completionDetected: false,
|
|
300
|
+
taskDetected: false,
|
|
301
|
+
toolUsage: [],
|
|
302
|
+
filesChanged: [],
|
|
303
|
+
exitCode: entry.exitCode,
|
|
304
|
+
signal: entry.signal || '',
|
|
305
|
+
failureStage: entry.failureStage || '',
|
|
306
|
+
completedTasks: [],
|
|
307
|
+
commitAttempted: false,
|
|
308
|
+
commitCreated: false,
|
|
309
|
+
commitAnomaly: '',
|
|
310
|
+
commitAnomalyType: '',
|
|
311
|
+
protectedArtifacts: [],
|
|
312
|
+
promptBytes: entry.promptBytes || 0,
|
|
313
|
+
promptChars: entry.promptChars || 0,
|
|
314
|
+
promptTokens: entry.promptTokens || 0,
|
|
315
|
+
responseBytes: entry.responseBytes || 0,
|
|
316
|
+
responseChars: entry.responseChars || 0,
|
|
317
|
+
responseTokens: entry.responseTokens || 0,
|
|
318
|
+
truncated: entry.truncated || false,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function _summarizeBlockerNote(note, limit = 500) {
|
|
323
|
+
if (!note || typeof note !== 'string') return '';
|
|
324
|
+
const oneLine = note.replace(/\s+/g, ' ').trim();
|
|
325
|
+
if (!oneLine) return '';
|
|
326
|
+
if (oneLine.length <= limit) return oneLine;
|
|
327
|
+
return `${oneLine.slice(0, Math.max(0, limit - 1)).replace(/\s+$/, '')}…`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = {
|
|
331
|
+
_extractBlockerNote,
|
|
332
|
+
_detectBlockerArtifacts,
|
|
333
|
+
_writeHandoff,
|
|
334
|
+
_formatSupervisorHandoffSections,
|
|
335
|
+
_formatSupervisorList,
|
|
336
|
+
_appendFatalIterationFailure,
|
|
337
|
+
_summarizeBlockerNote,
|
|
338
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* runner-pending-dirty.js — Pending-dirty-paths bookkeeping split out of
|
|
5
|
+
* runner.js.
|
|
6
|
+
*
|
|
7
|
+
* When the agent emits BLOCKED_HANDOFF (or pending_dirty_paths) without
|
|
8
|
+
* cleaning up its working-tree edits, the runner persists the affected
|
|
9
|
+
* paths into ralph-loop.state.json so a subsequent iteration can warn the
|
|
10
|
+
* agent rather than silently letting it stack changes onto a different
|
|
11
|
+
* task. This module owns the normalization, merging, refresh-from-git, and
|
|
12
|
+
* task-comparison helpers — all moved verbatim from runner.js.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const childProcess = require('child_process');
|
|
16
|
+
|
|
17
|
+
const { _mergePathLists, _repoRelativePath } = require('./runner-autocommit');
|
|
18
|
+
|
|
19
|
+
function _normalizePendingDirtyPaths(pending) {
|
|
20
|
+
if (!pending || typeof pending !== 'object') return null;
|
|
21
|
+
const files = _mergePathLists(pending.files || pending.paths || []);
|
|
22
|
+
if (files.length === 0) return null;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
iteration: typeof pending.iteration === 'number' ? pending.iteration : null,
|
|
26
|
+
reason: pending.reason || 'blocked_handoff',
|
|
27
|
+
task: pending.task || '',
|
|
28
|
+
taskNumber: pending.taskNumber || '',
|
|
29
|
+
taskDescription: pending.taskDescription || '',
|
|
30
|
+
files,
|
|
31
|
+
recordedAt: pending.recordedAt || new Date().toISOString(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _recordPendingDirtyPaths(existing, update) {
|
|
36
|
+
const normalized = _normalizePendingDirtyPaths({
|
|
37
|
+
iteration: update && typeof update.iteration === 'number' ? update.iteration : null,
|
|
38
|
+
reason: update && update.reason ? update.reason : 'blocked_handoff',
|
|
39
|
+
task: update && update.task ? update.task : '',
|
|
40
|
+
taskNumber: update && update.taskNumber ? update.taskNumber : '',
|
|
41
|
+
taskDescription: update && update.taskDescription ? update.taskDescription : '',
|
|
42
|
+
files: _mergePathLists(
|
|
43
|
+
existing && existing.files ? existing.files : [],
|
|
44
|
+
update && update.files ? update.files : []
|
|
45
|
+
),
|
|
46
|
+
recordedAt: update && update.recordedAt ? update.recordedAt : new Date().toISOString(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _remainingPendingDirtyPathsAfterCommit(pending, anomaly) {
|
|
53
|
+
const normalized = _normalizePendingDirtyPaths(pending);
|
|
54
|
+
if (!normalized) return null;
|
|
55
|
+
|
|
56
|
+
const ignoredPaths = anomaly && Array.isArray(anomaly.ignoredPaths)
|
|
57
|
+
? anomaly.ignoredPaths.map(_repoRelativePath).filter(Boolean)
|
|
58
|
+
: [];
|
|
59
|
+
if (ignoredPaths.length === 0) return null;
|
|
60
|
+
|
|
61
|
+
const ignoredSet = new Set(ignoredPaths);
|
|
62
|
+
const files = normalized.files.filter((file) => ignoredSet.has(file));
|
|
63
|
+
if (files.length === 0) return null;
|
|
64
|
+
return Object.assign({}, normalized, { files });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _refreshPendingDirtyPaths(pending) {
|
|
68
|
+
const normalized = _normalizePendingDirtyPaths(pending);
|
|
69
|
+
if (!normalized) return null;
|
|
70
|
+
|
|
71
|
+
const dirtyPaths = _currentDirtyPathSet();
|
|
72
|
+
if (!dirtyPaths) return normalized;
|
|
73
|
+
const files = normalized.files.filter((file) => dirtyPaths.has(file));
|
|
74
|
+
if (files.length === 0) return null;
|
|
75
|
+
|
|
76
|
+
return Object.assign({}, normalized, { files });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _samePendingTask(pending, currentTaskMeta, currentTask) {
|
|
80
|
+
if (!pending) return true;
|
|
81
|
+
const currentNumber = currentTaskMeta && currentTaskMeta.number ? currentTaskMeta.number : '';
|
|
82
|
+
const currentDescription = currentTaskMeta && currentTaskMeta.description ? currentTaskMeta.description : '';
|
|
83
|
+
const currentFull = currentTask || '';
|
|
84
|
+
|
|
85
|
+
if (pending.taskNumber && currentNumber) {
|
|
86
|
+
return pending.taskNumber === currentNumber;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (pending.taskDescription && currentDescription) {
|
|
90
|
+
return pending.taskDescription === currentDescription;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Boolean(pending.task && currentFull && pending.task === currentFull);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _formatPendingDirtyPathsBlock(pending, currentTaskMeta, currentTask) {
|
|
97
|
+
const currentStamp = currentTaskMeta && currentTaskMeta.number
|
|
98
|
+
? `${currentTaskMeta.number} ${currentTaskMeta.description || ''}`.trim()
|
|
99
|
+
: (currentTask || 'the current task');
|
|
100
|
+
const pendingStamp = pending.taskNumber
|
|
101
|
+
? `${pending.taskNumber} ${pending.taskDescription || ''}`.trim()
|
|
102
|
+
: (pending.task || 'a prior blocked handoff');
|
|
103
|
+
const files = (pending.files || []).slice(0, 8);
|
|
104
|
+
const extra = (pending.files || []).length - files.length;
|
|
105
|
+
const fileLines = files.map((file) => ` - ${file}`).join('\n');
|
|
106
|
+
const suffix = extra > 0 ? `\n - (+${extra} more)` : '';
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
`pending dirty paths from ${pending.reason || 'blocked_handoff'} iteration ${pending.iteration || 'unknown'} remain unresolved.`,
|
|
110
|
+
`Prior task: ${pendingStamp}`,
|
|
111
|
+
`Current task: ${currentStamp}`,
|
|
112
|
+
'Resolve the prior patch before Ralph can safely continue: commit it with the same task, revert it, or move it to a separate change.',
|
|
113
|
+
'Pending paths:',
|
|
114
|
+
`${fileLines}${suffix}`,
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _currentDirtyPathSet() {
|
|
119
|
+
try {
|
|
120
|
+
const output = childProcess.execFileSync('git', ['status', '--porcelain'], {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
123
|
+
});
|
|
124
|
+
const paths = new Set();
|
|
125
|
+
for (const line of output.split('\n')) {
|
|
126
|
+
for (const file of _parseGitStatusPaths(line)) {
|
|
127
|
+
if (file) paths.add(file);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return paths;
|
|
131
|
+
} catch (_) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _parseGitStatusPaths(line) {
|
|
137
|
+
if (!line || typeof line !== 'string') return [];
|
|
138
|
+
const rawPath = line.slice(3).trim();
|
|
139
|
+
if (!rawPath) return [];
|
|
140
|
+
if (rawPath.includes(' -> ')) {
|
|
141
|
+
return rawPath.split(' -> ').map(_stripGitStatusQuotes).filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
return [_stripGitStatusQuotes(rawPath)].filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _stripGitStatusQuotes(value) {
|
|
147
|
+
if (!value) return '';
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
|
150
|
+
return trimmed;
|
|
151
|
+
}
|
|
152
|
+
return trimmed
|
|
153
|
+
.slice(1, -1)
|
|
154
|
+
.replace(/\\"/g, '"')
|
|
155
|
+
.replace(/\\\\/g, '\\');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
_normalizePendingDirtyPaths,
|
|
160
|
+
_recordPendingDirtyPaths,
|
|
161
|
+
_remainingPendingDirtyPathsAfterCommit,
|
|
162
|
+
_refreshPendingDirtyPaths,
|
|
163
|
+
_samePendingTask,
|
|
164
|
+
_formatPendingDirtyPathsBlock,
|
|
165
|
+
_currentDirtyPathSet,
|
|
166
|
+
_parseGitStatusPaths,
|
|
167
|
+
_stripGitStatusQuotes,
|
|
168
|
+
};
|