@syntax-syllogism/aloop 0.5.0
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/CHANGELOG.md +141 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/bin/loop.mjs +90 -0
- package/package.json +46 -0
- package/presets/work-item/README.md +31 -0
- package/presets/work-item/loop.config.mjs +13 -0
- package/presets/work-item/prompts/address.md +57 -0
- package/presets/work-item/prompts/docs.md +31 -0
- package/presets/work-item/prompts/git.md +44 -0
- package/presets/work-item/prompts/implement.md +50 -0
- package/presets/work-item/prompts/review.md +77 -0
- package/prompts/address.md +59 -0
- package/prompts/docs.md +23 -0
- package/prompts/git.md +29 -0
- package/prompts/implement.md +45 -0
- package/prompts/review.md +74 -0
- package/src/adapters.mjs +281 -0
- package/src/command.mjs +65 -0
- package/src/config.mjs +175 -0
- package/src/entrypoint.mjs +22 -0
- package/src/git.mjs +91 -0
- package/src/index.mjs +6 -0
- package/src/pipeline.mjs +813 -0
- package/src/prompts.mjs +54 -0
- package/src/state.mjs +88 -0
- package/src/verdict.mjs +69 -0
package/src/pipeline.mjs
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { adapterFor, agentForPhase, passthroughRenderer, validateAgent } from './adapters.mjs';
|
|
6
|
+
import { runCommand } from './command.mjs';
|
|
7
|
+
import { loadConfig } from './config.mjs';
|
|
8
|
+
import { GitFacade } from './git.mjs';
|
|
9
|
+
import { renderPrompt } from './prompts.mjs';
|
|
10
|
+
import { RunState, slugFor } from './state.mjs';
|
|
11
|
+
import { APPROVED, CHANGES_REQUESTED, formatFindings, readVerdict } from './verdict.mjs';
|
|
12
|
+
|
|
13
|
+
const CODE_PHASES = new Set(['implement', 'address']);
|
|
14
|
+
|
|
15
|
+
function log(message) {
|
|
16
|
+
console.log(message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readStdin() {
|
|
20
|
+
if (process.stdin.isTTY) return '';
|
|
21
|
+
const chunks = [];
|
|
22
|
+
for await (const chunk of process.stdin) {
|
|
23
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
24
|
+
}
|
|
25
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildTaskContext({ task, taskFile }) {
|
|
29
|
+
if (task && taskFile) {
|
|
30
|
+
return `Your task:\n\n${task}\n\nFull details in \`${taskFile}\`.`;
|
|
31
|
+
}
|
|
32
|
+
if (task) {
|
|
33
|
+
return `Your task:\n\n${task}`;
|
|
34
|
+
}
|
|
35
|
+
if (taskFile) {
|
|
36
|
+
return `Your task is described in \`${taskFile}\`.`;
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function banner(text) {
|
|
42
|
+
log(`\n── ${text} ${'─'.repeat(Math.max(0, 60 - text.length))}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function describeAgent(agent) {
|
|
46
|
+
return `${agent.name}${agent.model ? ` model=${agent.model}` : ''}${agent.effort ? ` effort=${agent.effort}` : ''}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function overrideSavedEngines(agentSettings, engine, customAdapters) {
|
|
50
|
+
const overrides = {};
|
|
51
|
+
const overriddenSettings = Object.fromEntries(
|
|
52
|
+
Object.entries(agentSettings).map(([phaseName, agent]) => {
|
|
53
|
+
if (agent.name === engine) return [phaseName, agent];
|
|
54
|
+
const overridden = validateAgent({ ...agent, name: engine }, customAdapters);
|
|
55
|
+
overrides[phaseName] = { from: agent, to: overridden };
|
|
56
|
+
return [phaseName, overridden];
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
return { agentSettings: overriddenSettings, overrides };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function configOverrideRecord(config, path) {
|
|
63
|
+
const { resolvedPhases, ...values } = config;
|
|
64
|
+
return {
|
|
65
|
+
at: new Date().toISOString(),
|
|
66
|
+
path,
|
|
67
|
+
values,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function directoryExists(path) {
|
|
72
|
+
try {
|
|
73
|
+
return (await stat(path)).isDirectory();
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fileExists(path) {
|
|
80
|
+
try {
|
|
81
|
+
return (await stat(path)).isFile();
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resumedWorktree(git, repoRoot, branch, savedPath) {
|
|
88
|
+
if (!savedPath) return null;
|
|
89
|
+
if (resolve(savedPath) === resolve(repoRoot)) {
|
|
90
|
+
if (await directoryExists(savedPath)) return savedPath;
|
|
91
|
+
} else {
|
|
92
|
+
const registeredPath = await git.worktreePath(branch);
|
|
93
|
+
if (registeredPath && await directoryExists(registeredPath)) return registeredPath;
|
|
94
|
+
}
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Saved worktree "${savedPath}" for branch "${branch}" is unavailable. Restore it or re-register it with git worktree before resuming.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function openTerminalInput() {
|
|
101
|
+
return new Promise((resolveInput, rejectInput) => {
|
|
102
|
+
const input = createReadStream('/dev/tty');
|
|
103
|
+
const onOpen = () => {
|
|
104
|
+
input.off('error', onError);
|
|
105
|
+
resolveInput(input);
|
|
106
|
+
};
|
|
107
|
+
const onError = (error) => {
|
|
108
|
+
input.off('open', onOpen);
|
|
109
|
+
input.destroy();
|
|
110
|
+
rejectInput(error);
|
|
111
|
+
};
|
|
112
|
+
input.once('open', onOpen);
|
|
113
|
+
input.once('error', onError);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function ensureConfirmationAvailable(input, terminalOpener) {
|
|
118
|
+
if (input || process.stdin.isTTY) return;
|
|
119
|
+
let terminalInput;
|
|
120
|
+
try {
|
|
121
|
+
terminalInput = await terminalOpener();
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error('no terminal available to confirm phases; re-run with --yes to run unattended');
|
|
124
|
+
}
|
|
125
|
+
terminalInput.destroy();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function confirm(question, { input = null, terminalOpener = openTerminalInput } = {}) {
|
|
129
|
+
let confirmationInput = input;
|
|
130
|
+
let closeInput = false;
|
|
131
|
+
if (!confirmationInput) {
|
|
132
|
+
if (process.stdin.isTTY) {
|
|
133
|
+
confirmationInput = process.stdin;
|
|
134
|
+
} else {
|
|
135
|
+
try {
|
|
136
|
+
confirmationInput = await terminalOpener();
|
|
137
|
+
} catch {
|
|
138
|
+
throw new Error('no terminal available to confirm phases; re-run with --yes to run unattended');
|
|
139
|
+
}
|
|
140
|
+
closeInput = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const rl = createInterface({ input: confirmationInput, output: process.stdout });
|
|
144
|
+
try {
|
|
145
|
+
const answer = await rl.question(`${question} [Y/n/q] `);
|
|
146
|
+
const normalized = answer.trim().toLowerCase();
|
|
147
|
+
if (normalized === 'q') return 'quit';
|
|
148
|
+
return normalized === '' || normalized === 'y' ? 'yes' : 'skip';
|
|
149
|
+
} finally {
|
|
150
|
+
rl.close();
|
|
151
|
+
if (closeInput) confirmationInput.destroy();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function tee(path, text) {
|
|
156
|
+
await appendFile(path, text);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runGate(phase, ctx) {
|
|
160
|
+
const commands = phase.commands ?? ctx.config.gate;
|
|
161
|
+
const logFile = ctx.state.logPath(phase.name);
|
|
162
|
+
for (const command of commands) {
|
|
163
|
+
log(` $ ${command}`);
|
|
164
|
+
try {
|
|
165
|
+
// Gate commands run through a shell on purpose: they are user-authored
|
|
166
|
+
// strings that routinely need quoting, pipes, `&&`, and env vars, and
|
|
167
|
+
// naive whitespace splitting mangles all four without complaining.
|
|
168
|
+
await runCommand('sh', ['-c', command], {
|
|
169
|
+
cwd: ctx.worktree,
|
|
170
|
+
timeoutMs: ctx.config.timeoutMs,
|
|
171
|
+
onOutput: (text) => {
|
|
172
|
+
process.stdout.write(text);
|
|
173
|
+
void tee(logFile, text);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
await tee(logFile, `\n${error.output ?? error.message}\n`);
|
|
178
|
+
return { ok: false, command, output: error.output ?? error.message };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runSetup(commands, ctx) {
|
|
185
|
+
const logFile = ctx.state.logPath('setup');
|
|
186
|
+
for (const command of commands) {
|
|
187
|
+
log(` $ ${command}`);
|
|
188
|
+
try {
|
|
189
|
+
await runCommand('sh', ['-c', command], {
|
|
190
|
+
cwd: ctx.worktree,
|
|
191
|
+
timeoutMs: ctx.config.timeoutMs,
|
|
192
|
+
onOutput: (text) => {
|
|
193
|
+
process.stdout.write(text);
|
|
194
|
+
void tee(logFile, text);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const detail = error.output ?? error.message;
|
|
199
|
+
throw new Error(`Worktree setup failed for \`${command}\`: ${detail}`, { cause: error });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isCodePhase(phase) {
|
|
205
|
+
return CODE_PHASES.has(phase.name);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function noOpReason(phase, result, ctx, { hasFindings = true, headBefore = null } = {}) {
|
|
209
|
+
if (ctx.dryRun || !isCodePhase(phase) || (phase.name === 'address' && !hasFindings)) return null;
|
|
210
|
+
const pending = await ctx.git.status();
|
|
211
|
+
const headChanged = headBefore !== null && headBefore !== (await ctx.git.revParse());
|
|
212
|
+
if (pending || headChanged || (result.bytesEmitted ?? 0) > 0) return null;
|
|
213
|
+
return `${phase.name} produced no changes and no output — the engine likely did nothing. Check ${ctx.state.logPath(phase.name)}.`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function runAgent(phase, ctx, variables) {
|
|
217
|
+
const agent = ctx.agentSettings[phase.name] ?? agentForPhase(ctx.config, phase.name);
|
|
218
|
+
const engineOverride = ctx.engineOverrides[phase.name];
|
|
219
|
+
const adapter = adapterFor(agent.name, ctx.config.adapters);
|
|
220
|
+
const { prompt, path } = await renderPrompt(phase.prompt, variables, {
|
|
221
|
+
projectPromptDir: ctx.promptDir,
|
|
222
|
+
});
|
|
223
|
+
log(` engine: ${agent.name}${agent.model ? ` model: ${agent.model}` : ''}${agent.effort ? ` effort: ${agent.effort}` : ''} prompt: ${path}`);
|
|
224
|
+
if (engineOverride) log(` resumed override: ${describeAgent(engineOverride.from)} → ${describeAgent(engineOverride.to)}`);
|
|
225
|
+
|
|
226
|
+
const { command, args } = adapter.command({
|
|
227
|
+
prompt,
|
|
228
|
+
cwd: ctx.worktree,
|
|
229
|
+
addDirs: ctx.addDirs,
|
|
230
|
+
timeoutMs: ctx.config.timeoutMs,
|
|
231
|
+
agent,
|
|
232
|
+
});
|
|
233
|
+
if (ctx.dryRun) {
|
|
234
|
+
log(` would run: ${[command, ...args].map((value) => JSON.stringify(String(value))).join(' ')}`);
|
|
235
|
+
log(`\n${prompt}\n`);
|
|
236
|
+
return { agent, command, args, dryRun: true };
|
|
237
|
+
}
|
|
238
|
+
const logFile = ctx.state.logPath(phase.name);
|
|
239
|
+
const overrideNote = engineOverride ? ` (resumed override: ${describeAgent(engineOverride.from)} → ${describeAgent(engineOverride.to)})` : '';
|
|
240
|
+
await tee(logFile, `\n=== ${new Date().toISOString()} ${describeAgent(agent)} ${phase.name}${overrideNote} ===\n`);
|
|
241
|
+
// Only stdout carries the engine's structured stream; stderr is already
|
|
242
|
+
// human text and passes through, so a vendor warning is never swallowed.
|
|
243
|
+
const renderer = adapter.createRenderer?.() ?? passthroughRenderer();
|
|
244
|
+
let bytesEmitted = 0;
|
|
245
|
+
const emit = (text) => {
|
|
246
|
+
if (!text) return;
|
|
247
|
+
bytesEmitted += Buffer.byteLength(text);
|
|
248
|
+
process.stdout.write(text);
|
|
249
|
+
void tee(logFile, text);
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
await runCommand(command, args, {
|
|
253
|
+
cwd: ctx.worktree,
|
|
254
|
+
timeoutMs: ctx.config.timeoutMs,
|
|
255
|
+
onOutput: (text, stream) => emit(stream === 'stderr' ? text : renderer.write(text)),
|
|
256
|
+
});
|
|
257
|
+
} finally {
|
|
258
|
+
// A killed or failed run still has a partial line worth reading.
|
|
259
|
+
emit(renderer.end());
|
|
260
|
+
}
|
|
261
|
+
return { agent, bytesEmitted };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function reviewResumePoint(state, phase, resume) {
|
|
265
|
+
if (!resume) return { round: 1, sinceSha: null };
|
|
266
|
+
|
|
267
|
+
const pending = state.data.pendingRepairs?.[phase.name];
|
|
268
|
+
if (pending) {
|
|
269
|
+
return {
|
|
270
|
+
atCap: pending.round >= phase.maxRounds,
|
|
271
|
+
pending,
|
|
272
|
+
resumed: true,
|
|
273
|
+
round: pending.round,
|
|
274
|
+
sinceSha: pending.reviewedSha,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const previousRound = state.data.rounds?.[phase.name] ?? 0;
|
|
279
|
+
const sinceSha = state.data.reviewedShas?.[phase.name]?.[previousRound] ?? null;
|
|
280
|
+
if (!sinceSha) return { round: 1, sinceSha: null };
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
atCap: previousRound >= phase.maxRounds,
|
|
284
|
+
resumed: true,
|
|
285
|
+
round: previousRound + 1,
|
|
286
|
+
sinceSha,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function clearPendingRepair(phase, state) {
|
|
291
|
+
const pendingRepairs = { ...state.data.pendingRepairs };
|
|
292
|
+
delete pendingRepairs[phase.name];
|
|
293
|
+
await state.record({ pendingRepairs });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function runRepairs(phase, ctx, baseVariables, pending) {
|
|
297
|
+
let gateOk = true;
|
|
298
|
+
let gateFailure = null;
|
|
299
|
+
for (let index = pending.nextRepair; index < phase.repair.length; index += 1) {
|
|
300
|
+
const repair = phase.repair[index];
|
|
301
|
+
banner(`${repair.name} (round ${pending.round}${ctx.resume ? ' resume' : ''})`);
|
|
302
|
+
if (repair.kind === 'gate') {
|
|
303
|
+
if (ctx.dryRun) {
|
|
304
|
+
log(` would run: ${(repair.commands ?? ctx.config.gate).join(' && ')}`);
|
|
305
|
+
gateOk = true;
|
|
306
|
+
} else {
|
|
307
|
+
const result = await runGate(repair, ctx);
|
|
308
|
+
gateOk = result.ok;
|
|
309
|
+
gateFailure = result.ok ? null : `${result.command}\n${result.output}`;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
const headBefore = isCodePhase(repair) && !ctx.dryRun ? await ctx.git.revParse() : null;
|
|
313
|
+
const result = await runAgent(repair, ctx, {
|
|
314
|
+
...baseVariables,
|
|
315
|
+
ROUND: pending.round,
|
|
316
|
+
MAX_ROUNDS: phase.maxRounds,
|
|
317
|
+
VERDICT_FILE: ctx.state.verdictPath(pending.round),
|
|
318
|
+
FINDINGS: formatFindings(pending.verdict.blocking),
|
|
319
|
+
SINCE_SHA: pending.reviewedSha,
|
|
320
|
+
GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
|
|
321
|
+
});
|
|
322
|
+
const stalledReason = await noOpReason(repair, result, ctx, {
|
|
323
|
+
hasFindings: pending.verdict?.blocking?.length > 0,
|
|
324
|
+
headBefore,
|
|
325
|
+
});
|
|
326
|
+
if (stalledReason) return { gateOk, gateFailure, stalled: stalledReason, stalledPhase: repair.name };
|
|
327
|
+
}
|
|
328
|
+
await ctx.state.record({
|
|
329
|
+
pendingRepairs: {
|
|
330
|
+
...ctx.state.data.pendingRepairs,
|
|
331
|
+
[phase.name]: { ...pending, nextRepair: index + 1 },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
await clearPendingRepair(phase, ctx.state);
|
|
336
|
+
return { gateOk, gateFailure };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Run a verdict phase and its repair phases until the verdict clears.
|
|
341
|
+
*
|
|
342
|
+
* Two guards keep this from spinning: a hard round cap, and a refusal to exit
|
|
343
|
+
* on approval while the last gate re-check was red. Without the second one an
|
|
344
|
+
* agreeable reviewer can wave through a tree that does not build.
|
|
345
|
+
*/
|
|
346
|
+
async function runVerdictLoop(phase, ctx, baseVariables) {
|
|
347
|
+
const resumePoint = reviewResumePoint(ctx.state, phase, ctx.resume);
|
|
348
|
+
if (resumePoint.atCap) {
|
|
349
|
+
return { verdict: CHANGES_REQUESTED, rounds: phase.maxRounds, stalled: 'round cap reached' };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let round = resumePoint.round;
|
|
353
|
+
let gateOk = true;
|
|
354
|
+
let gateFailure = null;
|
|
355
|
+
let sinceSha = resumePoint.sinceSha;
|
|
356
|
+
|
|
357
|
+
if (resumePoint.pending) {
|
|
358
|
+
const result = await runRepairs(phase, ctx, baseVariables, resumePoint.pending);
|
|
359
|
+
gateOk = result.gateOk;
|
|
360
|
+
gateFailure = result.gateFailure;
|
|
361
|
+
if (result.stalled) {
|
|
362
|
+
return { verdict: CHANGES_REQUESTED, rounds: resumePoint.pending.round, stalled: result.stalled, stalledPhase: result.stalledPhase };
|
|
363
|
+
}
|
|
364
|
+
round += 1;
|
|
365
|
+
} else if (resumePoint.resumed) {
|
|
366
|
+
// Runs created before repair checkpoints existed cannot tell whether address
|
|
367
|
+
// completed, so fail closed by rechecking before asking for another verdict.
|
|
368
|
+
const legacyGate = phase.repair.find((repair) => repair.kind === 'gate' && repair.recheck);
|
|
369
|
+
if (legacyGate) {
|
|
370
|
+
const result = await runRepairs(phase, ctx, baseVariables, {
|
|
371
|
+
round: round - 1,
|
|
372
|
+
reviewedSha: sinceSha,
|
|
373
|
+
verdict: { blocking: [] },
|
|
374
|
+
nextRepair: phase.repair.indexOf(legacyGate),
|
|
375
|
+
});
|
|
376
|
+
gateOk = result.gateOk;
|
|
377
|
+
gateFailure = result.gateFailure;
|
|
378
|
+
if (result.stalled) {
|
|
379
|
+
return { verdict: CHANGES_REQUESTED, rounds: round, stalled: result.stalled, stalledPhase: result.stalledPhase };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (;;) {
|
|
385
|
+
const verdictPath = ctx.state.verdictPath(round);
|
|
386
|
+
banner(`${phase.name} (round ${round}/${phase.maxRounds})`);
|
|
387
|
+
await runAgent(phase, ctx, {
|
|
388
|
+
...baseVariables,
|
|
389
|
+
ROUND: round,
|
|
390
|
+
MAX_ROUNDS: phase.maxRounds,
|
|
391
|
+
VERDICT_FILE: verdictPath,
|
|
392
|
+
SINCE_SHA: sinceSha ?? '(none — review the cumulative branch diff)',
|
|
393
|
+
GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (ctx.dryRun) return { verdict: APPROVED, rounds: round, dryRun: true };
|
|
397
|
+
|
|
398
|
+
const reviewedSha = await ctx.git.revParse();
|
|
399
|
+
const verdict = await readVerdict(verdictPath);
|
|
400
|
+
const reviewState = {
|
|
401
|
+
rounds: { ...ctx.state.data.rounds, [phase.name]: round },
|
|
402
|
+
reviewedShas: {
|
|
403
|
+
...ctx.state.data.reviewedShas,
|
|
404
|
+
[phase.name]: {
|
|
405
|
+
...(ctx.state.data.reviewedShas?.[phase.name] ?? {}),
|
|
406
|
+
[round]: reviewedSha,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
// Checkpoint the owed repair even when this is the final allowed round.
|
|
411
|
+
// The cap stops *this* process below, but the last review's findings are
|
|
412
|
+
// still unaddressed; recording them lets a resume (with a raised cap) run
|
|
413
|
+
// the address phase before re-reviewing, instead of burning a fresh review
|
|
414
|
+
// round on the identical tree. Resuming without raising the cap stays a
|
|
415
|
+
// no-op: reviewResumePoint reports atCap while pending.round >= maxRounds.
|
|
416
|
+
if (verdict.verdict === CHANGES_REQUESTED && phase.repair.length) {
|
|
417
|
+
reviewState.pendingRepairs = {
|
|
418
|
+
...ctx.state.data.pendingRepairs,
|
|
419
|
+
[phase.name]: { round, reviewedSha, verdict, nextRepair: 0 },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
await ctx.state.record(reviewState);
|
|
423
|
+
sinceSha = reviewedSha;
|
|
424
|
+
log(`\n verdict: ${verdict.verdict}${verdict.summary ? ` — ${verdict.summary}` : ''}`);
|
|
425
|
+
|
|
426
|
+
if (verdict.verdict === APPROVED && gateOk) return { verdict: APPROVED, rounds: round };
|
|
427
|
+
if (verdict.verdict === APPROVED && !gateOk) {
|
|
428
|
+
log(' approval withheld: the gate is still failing.');
|
|
429
|
+
return {
|
|
430
|
+
verdict: CHANGES_REQUESTED,
|
|
431
|
+
rounds: round,
|
|
432
|
+
stalled: 'approval withheld: repair gate remains failing',
|
|
433
|
+
output: gateFailure,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (!phase.repair.length) {
|
|
437
|
+
return { verdict: verdict.verdict, rounds: round, stalled: 'no repair phase configured' };
|
|
438
|
+
}
|
|
439
|
+
if (round >= phase.maxRounds) {
|
|
440
|
+
return { verdict: verdict.verdict, rounds: round, stalled: 'round cap reached', findings: verdict.blocking };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const result = await runRepairs(phase, ctx, baseVariables, ctx.state.data.pendingRepairs[phase.name]);
|
|
444
|
+
gateOk = result.gateOk;
|
|
445
|
+
gateFailure = result.gateFailure;
|
|
446
|
+
if (result.stalled) {
|
|
447
|
+
return { verdict: CHANGES_REQUESTED, rounds: round, stalled: result.stalled, stalledPhase: result.stalledPhase };
|
|
448
|
+
}
|
|
449
|
+
round += 1;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function setupWorktree(git, config, slug, branch, repoRoot, baseBranch) {
|
|
454
|
+
if (!config.worktrees) return { worktree: repoRoot, created: false, worktreeCreated: false };
|
|
455
|
+
const root = config.worktreeRoot
|
|
456
|
+
? resolve(repoRoot, config.worktreeRoot)
|
|
457
|
+
: join(dirname(repoRoot), '.loop-worktrees');
|
|
458
|
+
await mkdir(root, { recursive: true });
|
|
459
|
+
const existing = await git.worktreePath(branch);
|
|
460
|
+
if (existing) return { worktree: existing, created: false, worktreeCreated: false };
|
|
461
|
+
const path = join(root, slug);
|
|
462
|
+
const result = await git.addWorktree(path, branch, baseBranch);
|
|
463
|
+
return { worktree: path, created: result.created, worktreeCreated: true };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function runLoop(options = {}) {
|
|
467
|
+
const { args, confirmInput = null, terminalOpener = openTerminalInput } = options;
|
|
468
|
+
const cwd = args.cwd ?? process.cwd();
|
|
469
|
+
const rootGit = new GitFacade(cwd);
|
|
470
|
+
const repoRoot = await rootGit.toplevel();
|
|
471
|
+
|
|
472
|
+
if (args.config && args.overrideEngine) {
|
|
473
|
+
throw new Error('--config and --override-engine cannot be used together; configure engines in the supplied config file.');
|
|
474
|
+
}
|
|
475
|
+
const config = await loadConfig(repoRoot, {
|
|
476
|
+
...(args.maxRounds ? { maxRounds: args.maxRounds } : {}),
|
|
477
|
+
...(args.phases ? { phases: args.phases } : {}),
|
|
478
|
+
...(args.noWorktree ? { worktrees: false } : {}),
|
|
479
|
+
}, args.config ? resolve(cwd, args.config) : undefined);
|
|
480
|
+
if (args.engine && !args.overrideEngine) {
|
|
481
|
+
config.engines.default = validateAgent({ ...config.engines.default, name: args.engine }, config.adapters);
|
|
482
|
+
}
|
|
483
|
+
if (args.overrideEngine && !args.resume) {
|
|
484
|
+
throw new Error('--override-engine requires --resume.');
|
|
485
|
+
}
|
|
486
|
+
if (args.overrideEngine && !args.engine) {
|
|
487
|
+
throw new Error('--override-engine requires --engine <name>.');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let task = args.task ?? null;
|
|
491
|
+
let rawTaskFile = args.taskFile ?? null;
|
|
492
|
+
let taskFile = rawTaskFile
|
|
493
|
+
? (rawTaskFile === '-' ? '-' : (isAbsolute(rawTaskFile) ? rawTaskFile : resolve(cwd, rawTaskFile)))
|
|
494
|
+
: null;
|
|
495
|
+
|
|
496
|
+
const name = args.name
|
|
497
|
+
? slugFor(args.name)
|
|
498
|
+
: (taskFile && taskFile !== '-' ? slugFor(taskFile) : null);
|
|
499
|
+
|
|
500
|
+
if (!name) {
|
|
501
|
+
throw new Error('A run needs an identity: pass --name <slug>, or --task-file <path> to derive one.');
|
|
502
|
+
}
|
|
503
|
+
const slug = name;
|
|
504
|
+
const configuredBranch = args.branch ?? `${config.branchPrefix}${slug}`;
|
|
505
|
+
const runsDir = join(repoRoot, config.runsDir);
|
|
506
|
+
const state = await RunState.open(
|
|
507
|
+
runsDir,
|
|
508
|
+
slug,
|
|
509
|
+
{ task, taskFile: taskFile === '-' ? null : taskFile, name, branch: configuredBranch, repoRoot },
|
|
510
|
+
{ readOnly: Boolean(args.dryRun) },
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const persistedName = args.resume ? state.data.name : null;
|
|
514
|
+
if (persistedName && args.name && slugFor(args.name) !== persistedName) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`Saved run used identity "${persistedName}"; cannot resume with --name "${args.name}".`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const persistedTaskFile = args.resume ? state.data.taskFile : null;
|
|
520
|
+
const taskPathInRunDir = rawTaskFile === '-' ? join(state.dir, 'task.md') : null;
|
|
521
|
+
const intendedTaskFile = taskPathInRunDir ?? taskFile;
|
|
522
|
+
if (persistedTaskFile && intendedTaskFile && intendedTaskFile !== persistedTaskFile) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`Saved run used task file "${persistedTaskFile}"; cannot resume with --task-file "${intendedTaskFile}".`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const persistedTask = args.resume ? state.data.task ?? null : null;
|
|
528
|
+
if (args.resume && args.task != null && args.task !== persistedTask) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Saved run used task "${persistedTask ?? ''}"; cannot resume with --task "${args.task}".`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (args.resume) {
|
|
535
|
+
if (!taskFile && persistedTaskFile) taskFile = persistedTaskFile;
|
|
536
|
+
if (task == null && persistedTask != null) task = persistedTask;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const persistedBranch = args.resume ? state.data.branch : null;
|
|
540
|
+
if (persistedBranch && args.branch && args.branch !== persistedBranch) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`Saved run used branch "${persistedBranch}"; cannot resume with --branch "${args.branch}". Start a new run instead of resuming with a different branch.`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
const branch = persistedBranch ?? configuredBranch;
|
|
546
|
+
const persistedBaseBranch = args.resume ? state.data.baseBranch : null;
|
|
547
|
+
if (persistedBaseBranch && args.baseBranch && args.baseBranch !== persistedBaseBranch) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`Saved run used base branch "${persistedBaseBranch}"; cannot resume with --base-branch "${args.baseBranch}". Start a new run instead of resuming with a different base.`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
const baseBranch = persistedBaseBranch ?? args.baseBranch ?? config.baseBranch;
|
|
553
|
+
if (args.baseBranch && !(await rootGit.localBranchExists(baseBranch))) {
|
|
554
|
+
throw new Error(`--base-branch "${baseBranch}" does not exist locally. Fetch or check it out first.`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!args.yes && !args.dryRun) {
|
|
558
|
+
await ensureConfirmationAvailable(confirmInput, terminalOpener);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (rawTaskFile === '-') {
|
|
562
|
+
const canReuseMaterializedTask = args.resume
|
|
563
|
+
&& state.data.taskFile === taskPathInRunDir
|
|
564
|
+
&& await fileExists(taskPathInRunDir);
|
|
565
|
+
if (canReuseMaterializedTask) {
|
|
566
|
+
taskFile = taskPathInRunDir;
|
|
567
|
+
} else {
|
|
568
|
+
const content = await readStdin();
|
|
569
|
+
if (!content || !content.trim()) {
|
|
570
|
+
throw new Error('no data on stdin for --task-file -');
|
|
571
|
+
}
|
|
572
|
+
taskFile = taskPathInRunDir;
|
|
573
|
+
if (!args.dryRun) {
|
|
574
|
+
await writeFile(taskFile, content, 'utf8');
|
|
575
|
+
}
|
|
576
|
+
await state.record({ taskFile });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const configuredAgentSettings = Object.fromEntries(
|
|
580
|
+
config.resolvedPhases
|
|
581
|
+
.flatMap((phase) => [phase, ...(phase.repair ?? [])])
|
|
582
|
+
.filter((phase) => phase.kind === 'agent')
|
|
583
|
+
.map((phase) => [phase.name, agentForPhase(config, phase.name)]),
|
|
584
|
+
);
|
|
585
|
+
const savedAgentSettings = args.resume && !args.config ? state.data.agentSettings : null;
|
|
586
|
+
if (savedAgentSettings) {
|
|
587
|
+
for (const phaseName of Object.keys(configuredAgentSettings)) {
|
|
588
|
+
if (!savedAgentSettings[phaseName]) {
|
|
589
|
+
throw new Error(`Saved run settings do not include agent phase "${phaseName}"; start a new run instead of resuming with a different phase list.`);
|
|
590
|
+
}
|
|
591
|
+
validateAgent(savedAgentSettings[phaseName], config.adapters);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
let agentSettings = savedAgentSettings ?? configuredAgentSettings;
|
|
595
|
+
let engineOverrides = {};
|
|
596
|
+
if (args.overrideEngine) {
|
|
597
|
+
if (!savedAgentSettings) {
|
|
598
|
+
throw new Error('--override-engine requires saved agent settings from an earlier run.');
|
|
599
|
+
}
|
|
600
|
+
({ agentSettings, overrides: engineOverrides } = overrideSavedEngines(savedAgentSettings, args.engine, config.adapters));
|
|
601
|
+
if (Object.keys(engineOverrides).length > 0) {
|
|
602
|
+
const engineOverride = {
|
|
603
|
+
at: new Date().toISOString(),
|
|
604
|
+
engine: args.engine,
|
|
605
|
+
phases: engineOverrides,
|
|
606
|
+
};
|
|
607
|
+
await state.record({
|
|
608
|
+
agentSettings,
|
|
609
|
+
engineOverrides: [...(state.data.engineOverrides ?? []), engineOverride],
|
|
610
|
+
});
|
|
611
|
+
log(' resumed engine override:');
|
|
612
|
+
for (const [phaseName, override] of Object.entries(engineOverrides)) {
|
|
613
|
+
log(` ${phaseName}: ${describeAgent(override.from)} → ${describeAgent(override.to)}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
} else if (!savedAgentSettings) {
|
|
617
|
+
const configOverrides = args.resume && args.config
|
|
618
|
+
? [...(state.data.configOverrides ?? []), configOverrideRecord(config, resolve(cwd, args.config))]
|
|
619
|
+
: state.data.configOverrides;
|
|
620
|
+
await state.record({ agentSettings, ...(configOverrides ? { configOverrides } : {}) });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const savedWorktree = args.resume
|
|
624
|
+
? await resumedWorktree(rootGit, repoRoot, branch, state.data.worktree)
|
|
625
|
+
: null;
|
|
626
|
+
const { worktree, created, worktreeCreated } = savedWorktree
|
|
627
|
+
? { worktree: savedWorktree, created: false, worktreeCreated: false }
|
|
628
|
+
: await setupWorktree(rootGit, config, slug, branch, repoRoot, baseBranch);
|
|
629
|
+
const worktreeGit = new GitFacade(worktree);
|
|
630
|
+
await state.record({ worktree, branch, baseBranch });
|
|
631
|
+
if (args.baseBranch && !created) {
|
|
632
|
+
log(` note: branch "${branch}" already has a worktree or exists; --base-branch has no effect on this run.`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The agent's writable set: its checkout, the run directory it must write the
|
|
636
|
+
// verdict into, and the task-file directory when the file is outside the
|
|
637
|
+
// agent's checkout. Same-repository task files retain their source path, so
|
|
638
|
+
// their containing directory must be granted explicitly in worktree mode.
|
|
639
|
+
const taskFileGit = taskFile ? new GitFacade(dirname(taskFile)) : null;
|
|
640
|
+
const taskFileRepo = taskFileGit ? await taskFileGit.toplevel().catch(() => null) : null;
|
|
641
|
+
const taskFileAccessDir = taskFileRepo === repoRoot ? dirname(taskFile) : taskFileRepo;
|
|
642
|
+
|
|
643
|
+
const addDirs = [state.dir];
|
|
644
|
+
if (taskFileAccessDir && !addDirs.includes(taskFileAccessDir)) addDirs.push(taskFileAccessDir);
|
|
645
|
+
|
|
646
|
+
const remote = config.remote ?? (await rootGit.defaultRemote()) ?? 'origin';
|
|
647
|
+
if (config.remote && !(await rootGit.remoteExists(config.remote))) {
|
|
648
|
+
throw new Error(`Configured remote "${config.remote}" does not exist in ${repoRoot}.`);
|
|
649
|
+
}
|
|
650
|
+
const ctx = {
|
|
651
|
+
config,
|
|
652
|
+
worktree,
|
|
653
|
+
addDirs,
|
|
654
|
+
state,
|
|
655
|
+
git: worktreeGit,
|
|
656
|
+
agentSettings,
|
|
657
|
+
engineOverrides,
|
|
658
|
+
resume: Boolean(args.resume),
|
|
659
|
+
dryRun: args.dryRun,
|
|
660
|
+
promptDir: join(repoRoot, config.promptDir),
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
if (worktreeCreated && !args.dryRun && config.setup.length) {
|
|
664
|
+
log(`setup : ${config.setup.join(' && ')}`);
|
|
665
|
+
await runSetup(config.setup, ctx);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const variables = {
|
|
669
|
+
TASK: task ?? '',
|
|
670
|
+
TASK_FILE: taskFile ?? '',
|
|
671
|
+
TASK_NAME: name,
|
|
672
|
+
TASK_DIR: taskFile ? dirname(taskFile) : cwd,
|
|
673
|
+
TASK_CONTEXT: buildTaskContext({ task, taskFile }),
|
|
674
|
+
BRANCH: branch,
|
|
675
|
+
BASE_BRANCH: baseBranch,
|
|
676
|
+
REPO: worktree,
|
|
677
|
+
RUN_DIR: state.dir,
|
|
678
|
+
REMOTE: remote,
|
|
679
|
+
GATE_COMMANDS: config.gate.join('\n') || '(none configured)',
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
log(`task : ${name}`);
|
|
683
|
+
if (taskFile) log(`task file : ${taskFile}`);
|
|
684
|
+
log(`branch : ${branch}${created ? ' (created)' : ''}`);
|
|
685
|
+
log(`base : ${baseBranch}`);
|
|
686
|
+
log(`worktree : ${worktree}`);
|
|
687
|
+
log(`run dir : ${state.dir}`);
|
|
688
|
+
log(`phases : ${config.resolvedPhases.map((phase) => phase.name).join(' → ')}`);
|
|
689
|
+
|
|
690
|
+
const summary = { phases: [], stalled: null, branch, worktree, runDir: state.dir, task: name, taskFile };
|
|
691
|
+
|
|
692
|
+
let startIndex = 0;
|
|
693
|
+
if (args.from) {
|
|
694
|
+
startIndex = config.resolvedPhases.findIndex((phase) => phase.name === args.from);
|
|
695
|
+
if (startIndex < 0) throw new Error(`--from names a phase that is not in the pipeline: ${args.from}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
for (const phase of config.resolvedPhases.slice(startIndex)) {
|
|
699
|
+
if (args.resume && state.isComplete(phase.name)) {
|
|
700
|
+
log(`\n── ${phase.name}: already complete, skipping`);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (!args.yes && !args.dryRun) {
|
|
704
|
+
const answer = await confirm(`\nRun phase "${phase.name}"?`, {
|
|
705
|
+
input: confirmInput,
|
|
706
|
+
terminalOpener,
|
|
707
|
+
});
|
|
708
|
+
if (answer === 'quit') {
|
|
709
|
+
summary.stalled = { phase: phase.name, reason: 'stopped by user' };
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
if (answer === 'skip') {
|
|
713
|
+
log(` skipped ${phase.name}`);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// A verdict phase prints its own per-round banner.
|
|
719
|
+
if (!phase.verdict) banner(phase.name);
|
|
720
|
+
|
|
721
|
+
// A publishing phase must run on a clean tree. This is a deterministic
|
|
722
|
+
// guard, not the agent's job: an earlier phase failing to commit its work
|
|
723
|
+
// leaves the tree dirty, and without this stop the git agent — told the
|
|
724
|
+
// tree is already clean but forbidden from committing — improvises an
|
|
725
|
+
// uncommittable "fix" and spins until its timeout. Unresolved review
|
|
726
|
+
// findings deliberately do NOT block here; a clean tree with open findings
|
|
727
|
+
// is a valid state to resume through docs and git.
|
|
728
|
+
if (phase.requiresCleanTree && !ctx.dryRun) {
|
|
729
|
+
const pending = await ctx.git.status();
|
|
730
|
+
if (pending) {
|
|
731
|
+
log(' worktree is not clean; refusing to run this phase over uncommitted changes.');
|
|
732
|
+
summary.stalled = {
|
|
733
|
+
phase: phase.name,
|
|
734
|
+
reason: 'worktree has uncommitted changes; a publishing phase must run on a clean tree',
|
|
735
|
+
output: pending,
|
|
736
|
+
};
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (phase.kind === 'gate') {
|
|
742
|
+
if (ctx.dryRun) {
|
|
743
|
+
log(` would run: ${(phase.commands ?? config.gate).join(' && ')}`);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const result = await runGate(phase, ctx);
|
|
747
|
+
if (!result.ok) {
|
|
748
|
+
summary.stalled = { phase: phase.name, reason: `gate failed: ${result.command}`, output: result.output };
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
await state.markComplete(phase.name);
|
|
752
|
+
summary.phases.push({ name: phase.name, ok: true });
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (phase.verdict) {
|
|
757
|
+
const result = await runVerdictLoop(phase, ctx, variables);
|
|
758
|
+
summary.phases.push({ name: phase.name, rounds: result.rounds, verdict: result.verdict });
|
|
759
|
+
if (result.stalled) {
|
|
760
|
+
summary.stalled = {
|
|
761
|
+
phase: result.stalledPhase ?? phase.name,
|
|
762
|
+
reason: result.stalled,
|
|
763
|
+
findings: result.findings,
|
|
764
|
+
rounds: result.rounds,
|
|
765
|
+
output: result.output,
|
|
766
|
+
};
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
await state.markComplete(phase.name, { rounds: result.rounds });
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const headBefore = isCodePhase(phase) && !ctx.dryRun ? await ctx.git.revParse() : null;
|
|
774
|
+
const result = await runAgent(phase, ctx, variables);
|
|
775
|
+
const stalledReason = await noOpReason(phase, result, ctx, { headBefore });
|
|
776
|
+
if (stalledReason) {
|
|
777
|
+
log(` ${stalledReason}`);
|
|
778
|
+
summary.stalled = { phase: phase.name, reason: stalledReason };
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
await state.markComplete(phase.name);
|
|
782
|
+
summary.phases.push({ name: phase.name, ok: true });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
report(summary);
|
|
786
|
+
return summary;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function report(summary) {
|
|
790
|
+
banner('summary');
|
|
791
|
+
for (const phase of summary.phases) {
|
|
792
|
+
const detail = phase.rounds ? ` (${phase.rounds} round${phase.rounds === 1 ? '' : 's'}, ${phase.verdict})` : '';
|
|
793
|
+
log(` ✓ ${phase.name}${detail}`);
|
|
794
|
+
}
|
|
795
|
+
if (!summary.stalled) {
|
|
796
|
+
log(`\nPipeline finished. Branch ${summary.branch} is ready.`);
|
|
797
|
+
log(`Logs: ${summary.runDir}`);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
log(`\n ✗ stalled in "${summary.stalled.phase}": ${summary.stalled.reason}`);
|
|
801
|
+
if (summary.stalled.findings?.length) {
|
|
802
|
+
log(`\nOutstanding blocking findings:\n${formatFindings(summary.stalled.findings)}`);
|
|
803
|
+
}
|
|
804
|
+
if (summary.stalled.output) log(`\n${summary.stalled.output.trim().split('\n').slice(-20).join('\n')}`);
|
|
805
|
+
log(`\nWorktree: ${summary.worktree}`);
|
|
806
|
+
log(`Logs: ${summary.runDir}`);
|
|
807
|
+
const resumeArgs = [`--name ${summary.task}`];
|
|
808
|
+
if (summary.taskFile && summary.taskFile !== join(summary.runDir, 'task.md')) {
|
|
809
|
+
resumeArgs.push(`--task-file ${summary.taskFile}`);
|
|
810
|
+
}
|
|
811
|
+
log(`Resume: aloop ${resumeArgs.join(' ')} --resume`);
|
|
812
|
+
process.exitCode = 1;
|
|
813
|
+
}
|