@syntax-syllogism/aloop 0.5.3 → 0.6.1
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 +43 -0
- package/README.md +49 -15
- package/bin/loop.mjs +197 -7
- package/package.json +7 -1
- package/presets/work-item/loop.config.mjs +1 -1
- package/presets/work-item/prompts/docs.md +6 -4
- package/presets/work-item/prompts/pr-description.md +27 -0
- package/presets/work-item/prompts/review.md +6 -1
- package/prompts/address.md +2 -1
- package/prompts/docs.md +5 -3
- package/prompts/{git.md → pr-description.md} +11 -8
- package/prompts/review.md +9 -4
- package/src/adapters.mjs +84 -9
- package/src/backends/gitlab.mjs +231 -0
- package/src/command.mjs +156 -6
- package/src/config.mjs +278 -53
- package/src/git.mjs +26 -0
- package/src/hermetic.mjs +160 -0
- package/src/index.mjs +22 -1
- package/src/manifest.mjs +67 -0
- package/src/metrics.mjs +251 -0
- package/src/operations.mjs +651 -0
- package/src/pipeline.mjs +505 -427
- package/src/policy.mjs +63 -0
- package/src/publish.mjs +183 -0
- package/src/reporter.mjs +102 -0
- package/src/runner.mjs +509 -0
- package/src/state.mjs +338 -16
- package/src/verdict.mjs +36 -2
- package/src/worktree.mjs +33 -0
- package/presets/work-item/prompts/git.md +0 -44
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { stat, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { codePhasePostcondition, hasPostcondition, phaseIsSkippable, publishingAttestation } from './policy.mjs';
|
|
4
|
+
import { banner, confirm, log } from './reporter.mjs';
|
|
5
|
+
import { APPROVED, CHANGES_REQUESTED, formatFindings, readVerdict } from './verdict.mjs';
|
|
6
|
+
|
|
7
|
+
async function fileExists(path) {
|
|
8
|
+
try {
|
|
9
|
+
return (await stat(path)).isFile();
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function clearVerdict(path) {
|
|
16
|
+
try {
|
|
17
|
+
await unlink(path);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== 'ENOENT') throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function reviewResumePoint(state, phase, resume) {
|
|
24
|
+
if (!resume) return { round: 1, sinceSha: null };
|
|
25
|
+
|
|
26
|
+
const pending = state.data.pendingRepairs?.[phase.name];
|
|
27
|
+
if (pending) {
|
|
28
|
+
return {
|
|
29
|
+
atCap: pending.round >= phase.maxRounds,
|
|
30
|
+
pending,
|
|
31
|
+
resumed: true,
|
|
32
|
+
round: pending.round,
|
|
33
|
+
sinceSha: pending.reviewedSha,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const previousRound = state.data.rounds?.[phase.name] ?? 0;
|
|
38
|
+
const sinceSha = state.data.reviewedShas?.[phase.name]?.[previousRound] ?? null;
|
|
39
|
+
if (!sinceSha) return { round: 1, sinceSha: null };
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
atCap: previousRound >= phase.maxRounds,
|
|
43
|
+
resumed: true,
|
|
44
|
+
round: previousRound + 1,
|
|
45
|
+
sinceSha,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function clearPendingRepair(phase, state) {
|
|
50
|
+
const pendingRepairs = { ...state.data.pendingRepairs };
|
|
51
|
+
delete pendingRepairs[phase.name];
|
|
52
|
+
await state.record({ pendingRepairs });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function invalidateGateCompletion(state, reviewedSha) {
|
|
56
|
+
const gate = state.manifest.entries.findLast((entry) => entry.role === 'gate'
|
|
57
|
+
&& entry.status === 'completed'
|
|
58
|
+
&& entry.outputSha === reviewedSha);
|
|
59
|
+
if (!gate || !state.data.completed.includes(gate.phase)) return;
|
|
60
|
+
await state.record({ completed: state.data.completed.filter((name) => name !== gate.phase) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runRepairs(phase, ctx, baseVariables, pending, operations) {
|
|
64
|
+
const {
|
|
65
|
+
budgetStatus, manifestEntry, recordBudgetStall, recordManifest, runAgent, runGate, withRetries,
|
|
66
|
+
} = operations;
|
|
67
|
+
let gateOk = true;
|
|
68
|
+
let gateFailure = null;
|
|
69
|
+
for (let index = pending.nextRepair; index < phase.repair.length; index += 1) {
|
|
70
|
+
const repair = phase.repair[index];
|
|
71
|
+
const budget = budgetStatus(ctx);
|
|
72
|
+
if (budget) {
|
|
73
|
+
await recordBudgetStall(ctx, repair, budget);
|
|
74
|
+
return {
|
|
75
|
+
gateOk,
|
|
76
|
+
gateFailure,
|
|
77
|
+
stalled: budget.reason,
|
|
78
|
+
stalledPhase: repair.name,
|
|
79
|
+
output: budget.output,
|
|
80
|
+
budgetStall: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
banner(`${repair.name} (round ${pending.round}${ctx.resume ? ' resume' : ''})`);
|
|
84
|
+
if (repair.kind === 'gate') {
|
|
85
|
+
if (ctx.dryRun) {
|
|
86
|
+
log(` would run: ${(repair.commands ?? ctx.config.gate).join(' && ')}`);
|
|
87
|
+
gateOk = true;
|
|
88
|
+
} else {
|
|
89
|
+
const startedAt = new Date().toISOString();
|
|
90
|
+
const inputSha = await ctx.git.revParse();
|
|
91
|
+
const result = await withRetries(repair, () => runGate(repair, ctx), { failed: (attempt) => !attempt.ok });
|
|
92
|
+
gateOk = result.ok;
|
|
93
|
+
gateFailure = result.ok ? null : `${result.command}\n${result.output}`;
|
|
94
|
+
await recordManifest(ctx, manifestEntry(repair, ctx, {
|
|
95
|
+
inputSha,
|
|
96
|
+
outputSha: await ctx.git.revParse(),
|
|
97
|
+
round: pending.round,
|
|
98
|
+
artifacts: [`${repair.name}.log`],
|
|
99
|
+
gateReceipts: result.gateReceipts,
|
|
100
|
+
execution: result.execution,
|
|
101
|
+
startedAt,
|
|
102
|
+
completedAt: new Date().toISOString(),
|
|
103
|
+
durationMs: result.durationMs,
|
|
104
|
+
status: result.ok ? 'completed' : 'stalled',
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
const headBefore = !ctx.dryRun && (hasPostcondition(repair, 'head-advanced') || hasPostcondition(repair, 'head-advanced-or-rebuttal'))
|
|
109
|
+
? await ctx.git.revParse()
|
|
110
|
+
: null;
|
|
111
|
+
const result = await withRetries(repair, () => runAgent(repair, ctx, {
|
|
112
|
+
...baseVariables,
|
|
113
|
+
ROUND: pending.round,
|
|
114
|
+
MAX_ROUNDS: phase.maxRounds,
|
|
115
|
+
VERDICT_FILE: ctx.state.verdictPath(pending.round),
|
|
116
|
+
FINDINGS: formatFindings(pending.verdict.blocking),
|
|
117
|
+
SINCE_SHA: pending.reviewedSha,
|
|
118
|
+
GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
|
|
119
|
+
}));
|
|
120
|
+
const stalledReason = await codePhasePostcondition(repair, ctx, {
|
|
121
|
+
hasFindings: pending.verdict?.blocking?.length > 0,
|
|
122
|
+
headBefore,
|
|
123
|
+
round: pending.round,
|
|
124
|
+
});
|
|
125
|
+
if (!ctx.dryRun) {
|
|
126
|
+
const responsePath = join(ctx.state.dir, `response-round-${pending.round}.md`);
|
|
127
|
+
const artifacts = [
|
|
128
|
+
...result.manifest.artifacts,
|
|
129
|
+
...(await fileExists(responsePath) ? [`response-round-${pending.round}.md`] : []),
|
|
130
|
+
];
|
|
131
|
+
await recordManifest(ctx, {
|
|
132
|
+
...result.manifest,
|
|
133
|
+
round: pending.round,
|
|
134
|
+
artifacts,
|
|
135
|
+
status: stalledReason ? 'stalled' : 'completed',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (stalledReason) return { gateOk, gateFailure, stalled: stalledReason, stalledPhase: repair.name };
|
|
139
|
+
}
|
|
140
|
+
await ctx.state.record({
|
|
141
|
+
pendingRepairs: {
|
|
142
|
+
...ctx.state.data.pendingRepairs,
|
|
143
|
+
[phase.name]: { ...pending, nextRepair: index + 1 },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
await clearPendingRepair(phase, ctx.state);
|
|
148
|
+
return { gateOk, gateFailure };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Run a verdict phase and its repair phases until the verdict clears.
|
|
153
|
+
*
|
|
154
|
+
* Two guards keep this from spinning: a hard round cap, and a refusal to exit
|
|
155
|
+
* on approval while the last gate re-check was red. Without the second one an
|
|
156
|
+
* agreeable reviewer can wave through a tree that does not build.
|
|
157
|
+
*/
|
|
158
|
+
async function runVerdictLoop(phase, ctx, baseVariables, operations) {
|
|
159
|
+
const { budgetStatus, recordBudgetStall, recordManifest, runAgent, withRetries } = operations;
|
|
160
|
+
const resumePoint = reviewResumePoint(ctx.state, phase, ctx.resume);
|
|
161
|
+
if (resumePoint.atCap) {
|
|
162
|
+
return { verdict: CHANGES_REQUESTED, rounds: phase.maxRounds, stalled: 'round cap reached' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let round = resumePoint.round;
|
|
166
|
+
let gateOk = true;
|
|
167
|
+
let gateFailure = null;
|
|
168
|
+
let sinceSha = resumePoint.sinceSha;
|
|
169
|
+
|
|
170
|
+
if (resumePoint.pending) {
|
|
171
|
+
const result = await runRepairs(phase, ctx, baseVariables, resumePoint.pending, operations);
|
|
172
|
+
gateOk = result.gateOk;
|
|
173
|
+
gateFailure = result.gateFailure;
|
|
174
|
+
if (result.stalled) {
|
|
175
|
+
return {
|
|
176
|
+
verdict: CHANGES_REQUESTED,
|
|
177
|
+
rounds: resumePoint.pending.round,
|
|
178
|
+
stalled: result.stalled,
|
|
179
|
+
stalledPhase: result.stalledPhase,
|
|
180
|
+
budgetStall: result.budgetStall,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
round += 1;
|
|
184
|
+
} else if (resumePoint.resumed) {
|
|
185
|
+
const legacyGate = phase.repair.find((repair) => repair.kind === 'gate' && repair.recheck);
|
|
186
|
+
if (legacyGate) {
|
|
187
|
+
const result = await runRepairs(phase, ctx, baseVariables, {
|
|
188
|
+
round: round - 1,
|
|
189
|
+
reviewedSha: sinceSha,
|
|
190
|
+
verdict: { blocking: [] },
|
|
191
|
+
nextRepair: phase.repair.indexOf(legacyGate),
|
|
192
|
+
}, operations);
|
|
193
|
+
gateOk = result.gateOk;
|
|
194
|
+
gateFailure = result.gateFailure;
|
|
195
|
+
if (result.stalled) {
|
|
196
|
+
return {
|
|
197
|
+
verdict: CHANGES_REQUESTED,
|
|
198
|
+
rounds: round,
|
|
199
|
+
stalled: result.stalled,
|
|
200
|
+
stalledPhase: result.stalledPhase,
|
|
201
|
+
budgetStall: result.budgetStall,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (;;) {
|
|
208
|
+
const budget = budgetStatus(ctx);
|
|
209
|
+
if (budget) {
|
|
210
|
+
const stalled = await recordBudgetStall(ctx, phase, budget);
|
|
211
|
+
return {
|
|
212
|
+
verdict: CHANGES_REQUESTED,
|
|
213
|
+
rounds: Math.max(0, round - 1),
|
|
214
|
+
stalled: stalled.reason,
|
|
215
|
+
stalledPhase: stalled.phase,
|
|
216
|
+
output: stalled.output,
|
|
217
|
+
budgetStall: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const verdictPath = ctx.state.verdictPath(round);
|
|
221
|
+
banner(`${phase.name} (round ${round}/${phase.maxRounds})`);
|
|
222
|
+
const reviewStatusBefore = ctx.dryRun ? null : await ctx.git.status();
|
|
223
|
+
const attempt = await withRetries(phase, async () => {
|
|
224
|
+
if (!ctx.dryRun) await clearVerdict(verdictPath);
|
|
225
|
+
const agentResult = await runAgent(phase, ctx, {
|
|
226
|
+
...baseVariables,
|
|
227
|
+
ROUND: round,
|
|
228
|
+
MAX_ROUNDS: phase.maxRounds,
|
|
229
|
+
VERDICT_FILE: verdictPath,
|
|
230
|
+
SINCE_SHA: sinceSha ?? '(none — review the cumulative branch diff)',
|
|
231
|
+
GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
|
|
232
|
+
});
|
|
233
|
+
if (ctx.dryRun) return { verdict: { verdict: APPROVED, blocking: [] }, agentResult };
|
|
234
|
+
return { verdict: await readVerdict(verdictPath), agentResult };
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (ctx.dryRun) return { verdict: APPROVED, rounds: round, dryRun: true };
|
|
238
|
+
|
|
239
|
+
const { verdict, agentResult } = attempt;
|
|
240
|
+
const reviewInputSha = agentResult.manifest.inputSha;
|
|
241
|
+
const reviewedSha = agentResult.manifest.outputSha;
|
|
242
|
+
const reviewStatusAfter = await ctx.git.status();
|
|
243
|
+
if (reviewInputSha !== reviewedSha || reviewStatusBefore !== reviewStatusAfter) {
|
|
244
|
+
const reason = reviewInputSha !== reviewedSha
|
|
245
|
+
? `review phase advanced HEAD from ${reviewInputSha} to ${reviewedSha}; review changes must be gated and reviewed in a later round`
|
|
246
|
+
: 'review phase changed files in the worktree; review must leave the worktree unchanged';
|
|
247
|
+
if (reviewInputSha !== reviewedSha) await invalidateGateCompletion(ctx.state, reviewInputSha);
|
|
248
|
+
await recordManifest(ctx, {
|
|
249
|
+
...agentResult.manifest,
|
|
250
|
+
status: 'stalled',
|
|
251
|
+
failure: { reason },
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
verdict: CHANGES_REQUESTED,
|
|
255
|
+
rounds: round,
|
|
256
|
+
stalled: reason,
|
|
257
|
+
output: `review input SHA: ${reviewInputSha}\nreview output SHA: ${reviewedSha}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const reviewPath = join(ctx.state.dir, `review-round-${round}.md`);
|
|
261
|
+
await recordManifest(ctx, {
|
|
262
|
+
...agentResult.manifest,
|
|
263
|
+
round,
|
|
264
|
+
artifacts: [
|
|
265
|
+
...agentResult.manifest.artifacts,
|
|
266
|
+
...(await fileExists(reviewPath) ? [`review-round-${round}.md`] : []),
|
|
267
|
+
`verdict-round-${round}.json`,
|
|
268
|
+
],
|
|
269
|
+
verdict: { ...verdict, sha: reviewedSha },
|
|
270
|
+
});
|
|
271
|
+
const reviewState = {
|
|
272
|
+
rounds: { ...ctx.state.data.rounds, [phase.name]: round },
|
|
273
|
+
reviewedShas: {
|
|
274
|
+
...ctx.state.data.reviewedShas,
|
|
275
|
+
[phase.name]: {
|
|
276
|
+
...(ctx.state.data.reviewedShas?.[phase.name] ?? {}),
|
|
277
|
+
[round]: reviewedSha,
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
// Checkpoint the owed repair even when this is the final allowed round.
|
|
282
|
+
// The cap stops *this* process below, but the last review's findings are
|
|
283
|
+
// still unaddressed; recording them lets a resume (with a raised cap) run
|
|
284
|
+
// the address phase before re-reviewing, instead of burning a fresh review
|
|
285
|
+
// round on the identical tree. Resuming without raising the cap stays a
|
|
286
|
+
// no-op: reviewResumePoint reports atCap while pending.round >= maxRounds.
|
|
287
|
+
if (verdict.verdict === CHANGES_REQUESTED && phase.repair.length) {
|
|
288
|
+
reviewState.pendingRepairs = {
|
|
289
|
+
...ctx.state.data.pendingRepairs,
|
|
290
|
+
[phase.name]: { round, reviewedSha, verdict, nextRepair: 0 },
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
await ctx.state.record(reviewState);
|
|
294
|
+
sinceSha = reviewedSha;
|
|
295
|
+
log(`\n verdict: ${verdict.verdict}${verdict.summary ? ` — ${verdict.summary}` : ''}`);
|
|
296
|
+
|
|
297
|
+
if (verdict.verdict === APPROVED && gateOk) return { verdict: APPROVED, rounds: round };
|
|
298
|
+
if (verdict.verdict === APPROVED && !gateOk) {
|
|
299
|
+
log(' approval withheld: the gate is still failing.');
|
|
300
|
+
return {
|
|
301
|
+
verdict: CHANGES_REQUESTED,
|
|
302
|
+
rounds: round,
|
|
303
|
+
stalled: 'approval withheld: repair gate remains failing',
|
|
304
|
+
output: gateFailure,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (!phase.repair.length) {
|
|
308
|
+
return { verdict: verdict.verdict, rounds: round, stalled: 'no repair phase configured' };
|
|
309
|
+
}
|
|
310
|
+
if (round >= phase.maxRounds) {
|
|
311
|
+
return { verdict: verdict.verdict, rounds: round, stalled: 'round cap reached', findings: verdict.blocking };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const result = await runRepairs(phase, ctx, baseVariables, ctx.state.data.pendingRepairs[phase.name], operations);
|
|
315
|
+
gateOk = result.gateOk;
|
|
316
|
+
gateFailure = result.gateFailure;
|
|
317
|
+
if (result.stalled) {
|
|
318
|
+
return {
|
|
319
|
+
verdict: CHANGES_REQUESTED,
|
|
320
|
+
rounds: round,
|
|
321
|
+
stalled: result.stalled,
|
|
322
|
+
stalledPhase: result.stalledPhase,
|
|
323
|
+
budgetStall: result.budgetStall,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
round += 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Drive phase descriptors using operations supplied by the composition root. */
|
|
331
|
+
export async function runPhases({
|
|
332
|
+
args, config, state, ctx, variables, summary, remote, branch, baseBranch,
|
|
333
|
+
confirmInput, terminalOpener, operations,
|
|
334
|
+
}) {
|
|
335
|
+
const {
|
|
336
|
+
budgetStatus, markBudgetExhaustedComplete, manifestEntry, markStalledManifest,
|
|
337
|
+
recordBudgetStall, recordManifest, runAgent, runGate, runPublish, withRetries,
|
|
338
|
+
} = operations;
|
|
339
|
+
let startIndex = 0;
|
|
340
|
+
if (args.from) {
|
|
341
|
+
startIndex = config.resolvedPhases.findIndex((phase) => phase.name === args.from);
|
|
342
|
+
if (startIndex < 0) throw new Error(`--from names a phase that is not in the pipeline: ${args.from}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const phasesToRun = config.resolvedPhases.slice(startIndex);
|
|
346
|
+
for (const [phaseIndex, phase] of phasesToRun.entries()) {
|
|
347
|
+
const isFinalPhase = phaseIndex === phasesToRun.length - 1;
|
|
348
|
+
if (args.resume && state.data.phases?.[phase.name]?.budgetExhausted) {
|
|
349
|
+
const budget = budgetStatus(ctx);
|
|
350
|
+
if (budget) {
|
|
351
|
+
summary.stalled = await recordBudgetStall(ctx, phase, budget);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
log(`\n── ${phase.name}: completed before budget exhaustion, finalizing`);
|
|
355
|
+
await state.markComplete(phase.name, { ...state.data.phases[phase.name], budgetExhausted: false });
|
|
356
|
+
summary.phases.push({ name: phase.name, ok: true });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (args.resume && state.isComplete(phase.name)) {
|
|
360
|
+
log(`\n── ${phase.name}: already complete, skipping`);
|
|
361
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, { status: 'skipped' }));
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (!ctx.dryRun) {
|
|
365
|
+
const budget = budgetStatus(ctx);
|
|
366
|
+
if (budget) {
|
|
367
|
+
summary.stalled = await recordBudgetStall(ctx, phase, budget);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!args.yes && !args.dryRun) {
|
|
372
|
+
let refusedRequiredSkip = false;
|
|
373
|
+
let skipPhase = false;
|
|
374
|
+
while (true) {
|
|
375
|
+
const answer = await confirm(`\nRun phase "${phase.name}"?`, { input: confirmInput, terminalOpener });
|
|
376
|
+
if (answer === 'yes') break;
|
|
377
|
+
if (answer === 'quit') {
|
|
378
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, { status: 'stalled' }));
|
|
379
|
+
summary.stalled = { phase: phase.name, reason: refusedRequiredSkip ? 'required phase not run (skipped by user)' : 'stopped by user' };
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
if (phaseIsSkippable(phase)) {
|
|
383
|
+
log(` skipped ${phase.name}`);
|
|
384
|
+
skipPhase = true;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
refusedRequiredSkip = true;
|
|
388
|
+
log(` ${phase.name} is required and cannot be skipped; run it or quit.`);
|
|
389
|
+
}
|
|
390
|
+
if (summary.stalled) break;
|
|
391
|
+
if (skipPhase) {
|
|
392
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, { status: 'skipped' }));
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (!phase.verdict) banner(phase.name);
|
|
397
|
+
if (phase.requiresCleanTree && !ctx.dryRun) {
|
|
398
|
+
const pending = await ctx.git.status();
|
|
399
|
+
if (pending) {
|
|
400
|
+
log(' worktree is not clean; refusing to run this phase over uncommitted changes.');
|
|
401
|
+
const sha = await ctx.git.revParse();
|
|
402
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, { inputSha: sha, outputSha: sha, status: 'stalled' }));
|
|
403
|
+
summary.stalled = { phase: phase.name, reason: 'worktree has uncommitted changes; a publishing phase must run on a clean tree', output: pending };
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
if (phase.kind === 'publish') {
|
|
407
|
+
const currentSha = await ctx.git.revParse();
|
|
408
|
+
const attestation = publishingAttestation(state, currentSha);
|
|
409
|
+
if (attestation) {
|
|
410
|
+
log(` ${attestation.reason}; refusing to publish.`);
|
|
411
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, { inputSha: currentSha, outputSha: currentSha, approvedSha: attestation.approvedSha, status: 'stalled' }));
|
|
412
|
+
summary.stalled = { phase: phase.name, reason: `${attestation.reason}; publishing requires the approved SHA`, output: `approved SHA: ${attestation.approvedSha ?? '(none)'}\ncurrent HEAD: ${attestation.currentSha}` };
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (phase.kind === 'publish') {
|
|
418
|
+
if (ctx.dryRun) {
|
|
419
|
+
log(` would push ${remote}/${branch} and create a ${ctx.config.publish.draft ? 'draft ' : ''}PR to ${baseBranch}`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const approvedSha = await ctx.git.revParse();
|
|
423
|
+
const result = await withRetries(phase, () => runPublish(phase, ctx, { remote, branch, base: baseBranch, approvedSha }), { failed: (attempt) => !attempt.ok });
|
|
424
|
+
await recordManifest(ctx, result.manifest);
|
|
425
|
+
if (!result.ok) {
|
|
426
|
+
summary.stalled = { phase: phase.name, reason: `publish failed: ${result.failure?.message ?? 'unverified publish result'}` };
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
summary.prUrl = result.result.pullRequest.url;
|
|
430
|
+
summary.pullRequest = result.result.pullRequest;
|
|
431
|
+
await state.record({ prUrl: summary.prUrl, pullRequest: summary.pullRequest });
|
|
432
|
+
await state.saveManifest({ prUrl: summary.prUrl, pullRequest: summary.pullRequest });
|
|
433
|
+
await state.markComplete(phase.name);
|
|
434
|
+
summary.phases.push({ name: phase.name, ok: true, prUrl: summary.prUrl, number: summary.pullRequest.number });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (phase.kind === 'gate') {
|
|
438
|
+
if (ctx.dryRun) {
|
|
439
|
+
log(` would run: ${(phase.commands ?? config.gate).join(' && ')}`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const startedAt = new Date().toISOString();
|
|
443
|
+
const inputSha = await ctx.git.revParse();
|
|
444
|
+
const result = await withRetries(phase, () => runGate(phase, ctx), { failed: (attempt) => !attempt.ok });
|
|
445
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, {
|
|
446
|
+
inputSha, outputSha: await ctx.git.revParse(), artifacts: [`${phase.name}.log`],
|
|
447
|
+
gateReceipts: result.gateReceipts, startedAt, completedAt: new Date().toISOString(),
|
|
448
|
+
execution: result.execution,
|
|
449
|
+
durationMs: result.durationMs, status: result.ok ? 'completed' : 'stalled',
|
|
450
|
+
}));
|
|
451
|
+
if (!result.ok) {
|
|
452
|
+
summary.stalled = { phase: phase.name, reason: `gate failed: ${result.command}`, output: result.output };
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
if (isFinalPhase) {
|
|
456
|
+
const budget = budgetStatus(ctx);
|
|
457
|
+
if (budget) {
|
|
458
|
+
summary.stalled = await recordBudgetStall(ctx, phase, budget);
|
|
459
|
+
await markBudgetExhaustedComplete(state, phase);
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
await state.markComplete(phase.name);
|
|
464
|
+
summary.phases.push({ name: phase.name, ok: true });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (phase.verdict) {
|
|
468
|
+
const manifestStart = state.manifest.entries.length;
|
|
469
|
+
const result = await runVerdictLoop(phase, ctx, variables, operations);
|
|
470
|
+
summary.phases.push({ name: phase.name, rounds: result.rounds, verdict: result.verdict });
|
|
471
|
+
if (result.stalled) {
|
|
472
|
+
if (!result.budgetStall) await markStalledManifest(ctx, phase, result, manifestStart);
|
|
473
|
+
summary.stalled = { phase: result.stalledPhase ?? phase.name, reason: result.stalled, findings: result.findings, rounds: result.rounds, output: result.output };
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
if (isFinalPhase) {
|
|
477
|
+
const budget = budgetStatus(ctx);
|
|
478
|
+
if (budget) {
|
|
479
|
+
summary.stalled = await recordBudgetStall(ctx, phase, budget);
|
|
480
|
+
await markBudgetExhaustedComplete(state, phase, { rounds: result.rounds });
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
await state.markComplete(phase.name, { rounds: result.rounds });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const headBefore = !ctx.dryRun && (hasPostcondition(phase, 'head-advanced') || hasPostcondition(phase, 'head-advanced-or-rebuttal') || hasPostcondition(phase, 'head-unchanged'))
|
|
488
|
+
? await ctx.git.revParse() : null;
|
|
489
|
+
const result = await withRetries(phase, () => runAgent(phase, ctx, variables));
|
|
490
|
+
const stalledReason = await codePhasePostcondition(phase, ctx, { headBefore });
|
|
491
|
+
if (stalledReason) {
|
|
492
|
+
log(` ${stalledReason}`);
|
|
493
|
+
await recordManifest(ctx, { ...result.manifest, status: 'stalled' });
|
|
494
|
+
summary.stalled = { phase: phase.name, reason: stalledReason };
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
await recordManifest(ctx, result.manifest);
|
|
498
|
+
if (isFinalPhase) {
|
|
499
|
+
const budget = budgetStatus(ctx);
|
|
500
|
+
if (budget) {
|
|
501
|
+
summary.stalled = await recordBudgetStall(ctx, phase, budget);
|
|
502
|
+
await markBudgetExhaustedComplete(state, phase);
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await state.markComplete(phase.name);
|
|
507
|
+
summary.phases.push({ name: phase.name, ok: true });
|
|
508
|
+
}
|
|
509
|
+
}
|