@syntax-syllogism/aloop 0.6.2 → 0.8.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 +32 -0
- package/README.md +25 -9
- package/bin/eval.mjs +58 -0
- package/bin/loop.mjs +32 -3
- package/package.json +10 -3
- package/presets/work-item/README.md +9 -7
- package/prompts/fix-gate.md +17 -0
- package/src/adapters.mjs +131 -5
- package/src/backends/gitlab.mjs +5 -3
- package/src/command.mjs +5 -10
- package/src/config.mjs +7 -2
- package/src/eval.mjs +217 -0
- package/src/index.mjs +7 -0
- package/src/manifest.mjs +4 -0
- package/src/operations.mjs +120 -7
- package/src/pipeline.mjs +62 -19
- package/src/publish.mjs +4 -12
- package/src/reporter.mjs +37 -1
- package/src/runner.mjs +153 -21
- package/src/state.mjs +5 -1
- package/src/tui.mjs +171 -0
- package/src/types.d.ts +238 -0
- package/src/verdict.mjs +9 -1
package/src/publish.mjs
CHANGED
|
@@ -25,7 +25,8 @@ export function parsePullRequestDescription(contents) {
|
|
|
25
25
|
return { title, body };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function githubBackend(
|
|
28
|
+
export function githubBackend(options = {}) {
|
|
29
|
+
const { cwd, git = new GitFacade(cwd), runner = runCommand, env } = /** @type {any} */ (options);
|
|
29
30
|
const repositories = new Map();
|
|
30
31
|
|
|
31
32
|
async function resolveRepository(remote) {
|
|
@@ -128,17 +129,8 @@ function assertVerifiedPullRequest(pullRequest, { base, branch, localSha, draft
|
|
|
128
129
|
* backend is GitHub's `gh` CLI; other remotes can supply the same precheck,
|
|
129
130
|
* view, create, and update operations without changing this use case.
|
|
130
131
|
*/
|
|
131
|
-
export async function publish({
|
|
132
|
-
git,
|
|
133
|
-
remote,
|
|
134
|
-
branch,
|
|
135
|
-
base,
|
|
136
|
-
prBodyPath,
|
|
137
|
-
draft = true,
|
|
138
|
-
backend = 'github',
|
|
139
|
-
approvedSha = null,
|
|
140
|
-
env,
|
|
141
|
-
}) {
|
|
132
|
+
export async function publish(options = {}) {
|
|
133
|
+
const { git, remote, branch, base, prBodyPath, draft = true, backend = 'github', approvedSha = null, env } = /** @type {any} */ (options);
|
|
142
134
|
if (!git || typeof git.push !== 'function' || typeof git.lsRemote !== 'function') {
|
|
143
135
|
throw new PublishError('Publish requires a GitFacade with push and lsRemote operations.');
|
|
144
136
|
}
|
package/src/reporter.mjs
CHANGED
|
@@ -1,14 +1,49 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
|
|
4
|
+
// Renderer seam: `log`/`banner` fan out to whichever renderer is active so a
|
|
5
|
+
// second renderer (e.g. the TUI in tui.mjs) can slot in without runner.mjs or
|
|
6
|
+
// pipeline.mjs knowing which one is live. `report` is deliberately left alone
|
|
7
|
+
// — callers stop the alternate renderer and reset to plain before calling it,
|
|
8
|
+
// so the final summary always prints as normal scrollback text.
|
|
9
|
+
let activeRenderer = null;
|
|
10
|
+
|
|
11
|
+
export function setRenderer(renderer) {
|
|
12
|
+
activeRenderer = renderer;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resetRenderer() {
|
|
16
|
+
activeRenderer = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
export function log(message) {
|
|
20
|
+
if (activeRenderer) {
|
|
21
|
+
activeRenderer.log(message);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
5
24
|
console.log(message);
|
|
6
25
|
}
|
|
7
26
|
|
|
8
27
|
export function banner(text) {
|
|
28
|
+
if (activeRenderer) {
|
|
29
|
+
activeRenderer.banner(text);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
9
32
|
log(`\n── ${text} ${'─'.repeat(Math.max(0, 60 - text.length))}`);
|
|
10
33
|
}
|
|
11
34
|
|
|
35
|
+
// Raw child-process/engine output (gate commands, setup commands, agent
|
|
36
|
+
// streams) goes through this seam too, not straight to process.stdout: a
|
|
37
|
+
// renderer that repaints the screen (the TUI) needs to be the only writer,
|
|
38
|
+
// or its repaints interleave with the raw bytes into a garbled display.
|
|
39
|
+
export function writeOutput(text) {
|
|
40
|
+
if (activeRenderer) {
|
|
41
|
+
activeRenderer.output?.(text);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
process.stdout.write(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
export function describeAgent(agent) {
|
|
13
48
|
return `${agent.name}${agent.model ? ` model=${agent.model}` : ''}${agent.effort ? ` effort=${agent.effort}` : ''}`;
|
|
14
49
|
}
|
|
@@ -18,7 +53,8 @@ export function formatPhase(phase) {
|
|
|
18
53
|
return ` ✓ ${phase.name}${detail}`;
|
|
19
54
|
}
|
|
20
55
|
|
|
21
|
-
export function openTerminalInput(
|
|
56
|
+
export function openTerminalInput(options = {}) {
|
|
57
|
+
const { platform = process.platform, input = process.stdin, createInput = createReadStream } = /** @type {any} */ (options);
|
|
22
58
|
if (platform === 'win32' && input.isTTY) return Promise.resolve(input);
|
|
23
59
|
const terminalPath = platform === 'win32' ? '\\\\.\\CONIN$' : '/dev/tty';
|
|
24
60
|
return new Promise((resolveInput, rejectInput) => {
|
package/src/runner.mjs
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** @typedef {import('./types.js').Config} Config */
|
|
2
|
+
/** @typedef {import('./types.js').Operations} Operations */
|
|
3
|
+
/** @typedef {import('./types.js').RunState} RunState */
|
|
4
|
+
/** @typedef {import('./types.js').Summary} Summary */
|
|
5
|
+
|
|
1
6
|
import { stat, unlink } from 'node:fs/promises';
|
|
2
7
|
import { join } from 'node:path';
|
|
3
8
|
import { codePhasePostcondition, hasPostcondition, phaseIsSkippable, publishingAttestation } from './policy.mjs';
|
|
@@ -60,12 +65,33 @@ async function invalidateGateCompletion(state, reviewedSha) {
|
|
|
60
65
|
await state.record({ completed: state.data.completed.filter((name) => name !== gate.phase) });
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
async function
|
|
68
|
+
async function invalidateFollowingPhaseState(state, phases, phaseName) {
|
|
69
|
+
const phaseIndex = phases.findIndex((phase) => phase.name === phaseName);
|
|
70
|
+
const followingNames = new Set(phases.slice(phaseIndex + 1).map((phase) => phase.name));
|
|
71
|
+
if (!followingNames.size) return;
|
|
72
|
+
|
|
73
|
+
const completed = state.data.completed.filter((name) => !followingNames.has(name));
|
|
74
|
+
const phaseState = Object.fromEntries(
|
|
75
|
+
Object.entries(state.data.phases ?? {}).filter(([name]) => !followingNames.has(name)),
|
|
76
|
+
);
|
|
77
|
+
const pendingRepairs = Object.fromEntries(
|
|
78
|
+
Object.entries(state.data.pendingRepairs ?? {}).filter(([name]) => !followingNames.has(name)),
|
|
79
|
+
);
|
|
80
|
+
const rounds = Object.fromEntries(
|
|
81
|
+
Object.entries(state.data.rounds ?? {}).filter(([name]) => !followingNames.has(name)),
|
|
82
|
+
);
|
|
83
|
+
const reviewedShas = Object.fromEntries(
|
|
84
|
+
Object.entries(state.data.reviewedShas ?? {}).filter(([name]) => !followingNames.has(name)),
|
|
85
|
+
);
|
|
86
|
+
await state.record({ completed, phases: phaseState, pendingRepairs, rounds, reviewedShas });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runRepairs(phase, ctx, baseVariables, pending, operations, { retainCheckpoint = false } = {}) {
|
|
64
90
|
const {
|
|
65
91
|
budgetStatus, manifestEntry, recordBudgetStall, recordManifest, runAgent, runGate, withRetries,
|
|
66
92
|
} = operations;
|
|
67
|
-
let gateOk = true;
|
|
68
|
-
let gateFailure = null;
|
|
93
|
+
let gateOk = pending.gateOk ?? true;
|
|
94
|
+
let gateFailure = pending.gateFailure ?? null;
|
|
69
95
|
for (let index = pending.nextRepair; index < phase.repair.length; index += 1) {
|
|
70
96
|
const repair = phase.repair[index];
|
|
71
97
|
const budget = budgetStatus(ctx);
|
|
@@ -108,12 +134,15 @@ async function runRepairs(phase, ctx, baseVariables, pending, operations) {
|
|
|
108
134
|
const headBefore = !ctx.dryRun && (hasPostcondition(repair, 'head-advanced') || hasPostcondition(repair, 'head-advanced-or-rebuttal'))
|
|
109
135
|
? await ctx.git.revParse()
|
|
110
136
|
: null;
|
|
111
|
-
const
|
|
137
|
+
const repairCtx = ctx.note?.targetPhase === phase.name
|
|
138
|
+
? { ...ctx, note: { ...ctx.note, repairPhase: repair.name } }
|
|
139
|
+
: ctx;
|
|
140
|
+
const result = await withRetries(repair, () => runAgent(repair, repairCtx, {
|
|
112
141
|
...baseVariables,
|
|
113
142
|
ROUND: pending.round,
|
|
114
143
|
MAX_ROUNDS: phase.maxRounds,
|
|
115
144
|
VERDICT_FILE: ctx.state.verdictPath(pending.round),
|
|
116
|
-
FINDINGS: formatFindings(pending.verdict
|
|
145
|
+
FINDINGS: formatFindings(pending.verdict?.blocking ?? []),
|
|
117
146
|
SINCE_SHA: pending.reviewedSha,
|
|
118
147
|
GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
|
|
119
148
|
}));
|
|
@@ -144,10 +173,77 @@ async function runRepairs(phase, ctx, baseVariables, pending, operations) {
|
|
|
144
173
|
},
|
|
145
174
|
});
|
|
146
175
|
}
|
|
147
|
-
await clearPendingRepair(phase, ctx.state);
|
|
176
|
+
if (!retainCheckpoint) await clearPendingRepair(phase, ctx.state);
|
|
148
177
|
return { gateOk, gateFailure };
|
|
149
178
|
}
|
|
150
179
|
|
|
180
|
+
async function runGateAttempt(phase, ctx, operations, options = {}) {
|
|
181
|
+
const { round } = /** @type {any} */ (options);
|
|
182
|
+
const { manifestEntry, recordManifest, runGate, withRetries } = operations;
|
|
183
|
+
const startedAt = new Date().toISOString();
|
|
184
|
+
const inputSha = await ctx.git.revParse();
|
|
185
|
+
const result = await withRetries(phase, () => runGate(phase, ctx), { failed: (attempt) => !attempt.ok });
|
|
186
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, {
|
|
187
|
+
inputSha,
|
|
188
|
+
outputSha: await ctx.git.revParse(),
|
|
189
|
+
artifacts: [`${phase.name}.log`],
|
|
190
|
+
gateReceipts: result.gateReceipts,
|
|
191
|
+
execution: result.execution,
|
|
192
|
+
startedAt,
|
|
193
|
+
completedAt: new Date().toISOString(),
|
|
194
|
+
durationMs: result.durationMs,
|
|
195
|
+
status: result.ok ? 'completed' : 'stalled',
|
|
196
|
+
...(round === undefined ? {} : { round }),
|
|
197
|
+
}));
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runGateRepairLoop(phase, ctx, variables, operations) {
|
|
202
|
+
let result = null;
|
|
203
|
+
let pending = ctx.resume ? ctx.state.data.pendingRepairs?.[phase.name] : null;
|
|
204
|
+
let round = pending?.round ?? 1;
|
|
205
|
+
|
|
206
|
+
while (round <= phase.maxRounds) {
|
|
207
|
+
if (pending) {
|
|
208
|
+
result = {
|
|
209
|
+
ok: false,
|
|
210
|
+
command: phase.name,
|
|
211
|
+
output: pending.gateFailure ?? '',
|
|
212
|
+
};
|
|
213
|
+
const repairResult = await runRepairs(phase, ctx, variables, pending, operations, { retainCheckpoint: true });
|
|
214
|
+
if (repairResult.stalled) {
|
|
215
|
+
return { result, stalled: repairResult.stalled, stalledPhase: repairResult.stalledPhase, budgetStall: repairResult.budgetStall };
|
|
216
|
+
}
|
|
217
|
+
// The checkpoint's repairs have completed, so re-gate deliberately before
|
|
218
|
+
// deciding whether another repair round is needed.
|
|
219
|
+
result = await runGateAttempt(phase, ctx, operations, { round });
|
|
220
|
+
if (result.ok) {
|
|
221
|
+
await clearPendingRepair(phase, ctx.state);
|
|
222
|
+
return { result };
|
|
223
|
+
}
|
|
224
|
+
pending = null;
|
|
225
|
+
round += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
result ??= await runGateAttempt(phase, ctx, operations);
|
|
230
|
+
if (result.ok) return { result };
|
|
231
|
+
|
|
232
|
+
pending = {
|
|
233
|
+
round,
|
|
234
|
+
reviewedSha: await ctx.git.revParse(),
|
|
235
|
+
verdict: { blocking: [] },
|
|
236
|
+
gateOk: false,
|
|
237
|
+
gateFailure: `${result.command}\n${result.output}`,
|
|
238
|
+
nextRepair: 0,
|
|
239
|
+
};
|
|
240
|
+
await ctx.state.record({
|
|
241
|
+
pendingRepairs: { ...ctx.state.data.pendingRepairs, [phase.name]: pending },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return { result };
|
|
245
|
+
}
|
|
246
|
+
|
|
151
247
|
/**
|
|
152
248
|
* Run a verdict phase and its repair phases until the verdict clears.
|
|
153
249
|
*
|
|
@@ -327,7 +423,16 @@ async function runVerdictLoop(phase, ctx, baseVariables, operations) {
|
|
|
327
423
|
}
|
|
328
424
|
}
|
|
329
425
|
|
|
330
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* Drive phase descriptors using operations supplied by the composition root.
|
|
428
|
+
*
|
|
429
|
+
* @param {{
|
|
430
|
+
* args: Record<string, any>, config: Config, state: RunState, ctx: any,
|
|
431
|
+
* variables: Record<string, unknown>, summary: Summary, remote: string,
|
|
432
|
+
* branch: string, baseBranch: string, confirmInput: any,
|
|
433
|
+
* terminalOpener: any, operations: Operations,
|
|
434
|
+
* }} input
|
|
435
|
+
*/
|
|
331
436
|
export async function runPhases({
|
|
332
437
|
args, config, state, ctx, variables, summary, remote, branch, baseBranch,
|
|
333
438
|
confirmInput, terminalOpener, operations,
|
|
@@ -343,9 +448,22 @@ export async function runPhases({
|
|
|
343
448
|
}
|
|
344
449
|
|
|
345
450
|
const phasesToRun = config.resolvedPhases.slice(startIndex);
|
|
451
|
+
const noteTarget = args.from ?? (args.resume
|
|
452
|
+
? phasesToRun.find((phase) => !state.isComplete(phase.name))?.name
|
|
453
|
+
: phasesToRun[0]?.name);
|
|
454
|
+
if (ctx.note) {
|
|
455
|
+
const target = config.resolvedPhases.find((phase) => phase.name === noteTarget);
|
|
456
|
+
if (!target) throw new Error('--note needs a phase to run; use --from to target a completed phase.');
|
|
457
|
+
const hasAgentRepair = target.kind === 'gate' && target.repair?.some((repair) => repair.kind === 'agent');
|
|
458
|
+
if (target.kind !== 'agent' && !hasAgentRepair) {
|
|
459
|
+
throw new Error(`--note cannot target ${target.kind} phase "${target.name}" without an agent repair phase.`);
|
|
460
|
+
}
|
|
461
|
+
ctx.note = { ...ctx.note, targetPhase: target.name };
|
|
462
|
+
}
|
|
346
463
|
for (const [phaseIndex, phase] of phasesToRun.entries()) {
|
|
347
464
|
const isFinalPhase = phaseIndex === phasesToRun.length - 1;
|
|
348
|
-
|
|
465
|
+
const forceNotedPhase = args.resume && args.from === phase.name && ctx.note?.targetPhase === phase.name;
|
|
466
|
+
if (args.resume && state.data.phases?.[phase.name]?.budgetExhausted && !forceNotedPhase) {
|
|
349
467
|
const budget = budgetStatus(ctx);
|
|
350
468
|
if (budget) {
|
|
351
469
|
summary.stalled = await recordBudgetStall(ctx, phase, budget);
|
|
@@ -356,7 +474,7 @@ export async function runPhases({
|
|
|
356
474
|
summary.phases.push({ name: phase.name, ok: true });
|
|
357
475
|
continue;
|
|
358
476
|
}
|
|
359
|
-
if (args.resume && state.isComplete(phase.name)) {
|
|
477
|
+
if (args.resume && state.isComplete(phase.name) && !forceNotedPhase) {
|
|
360
478
|
log(`\n── ${phase.name}: already complete, skipping`);
|
|
361
479
|
await recordManifest(ctx, manifestEntry(phase, ctx, { status: 'skipped' }));
|
|
362
480
|
continue;
|
|
@@ -393,6 +511,9 @@ export async function runPhases({
|
|
|
393
511
|
continue;
|
|
394
512
|
}
|
|
395
513
|
}
|
|
514
|
+
if (forceNotedPhase) {
|
|
515
|
+
await invalidateFollowingPhaseState(state, config.resolvedPhases, phase.name);
|
|
516
|
+
}
|
|
396
517
|
if (!phase.verdict) banner(phase.name);
|
|
397
518
|
if (phase.requiresCleanTree && !ctx.dryRun) {
|
|
398
519
|
const pending = await ctx.git.status();
|
|
@@ -439,18 +560,29 @@ export async function runPhases({
|
|
|
439
560
|
log(` would run: ${(phase.commands ?? config.gate).join(' && ')}`);
|
|
440
561
|
continue;
|
|
441
562
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
563
|
+
if (phase.repair?.length) {
|
|
564
|
+
const repaired = await runGateRepairLoop(phase, ctx, variables, operations);
|
|
565
|
+
if (repaired.stalled) {
|
|
566
|
+
if (!repaired.budgetStall) {
|
|
567
|
+
await markStalledManifest(ctx, phase, repaired, state.manifest.entries.length - 1);
|
|
568
|
+
}
|
|
569
|
+
summary.stalled = {
|
|
570
|
+
phase: repaired.stalledPhase ?? phase.name,
|
|
571
|
+
reason: repaired.stalled,
|
|
572
|
+
output: repaired.result.output,
|
|
573
|
+
};
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
if (!repaired.result.ok) {
|
|
577
|
+
summary.stalled = { phase: phase.name, reason: `gate failed: ${repaired.result.command}`, output: repaired.result.output };
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
const result = await runGateAttempt(phase, ctx, operations);
|
|
582
|
+
if (!result.ok) {
|
|
583
|
+
summary.stalled = { phase: phase.name, reason: `gate failed: ${result.command}`, output: result.output };
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
454
586
|
}
|
|
455
587
|
if (isFinalPhase) {
|
|
456
588
|
const budget = budgetStatus(ctx);
|
package/src/state.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** @typedef {import('./types.js').RunData} RunData */
|
|
2
|
+
/** @typedef {import('./types.js').ManifestEntry} ManifestEntry */
|
|
3
|
+
|
|
1
4
|
import { randomUUID } from 'node:crypto';
|
|
2
5
|
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
6
|
import { readdirSync, renameSync, rmSync } from 'node:fs';
|
|
@@ -145,6 +148,7 @@ export function slugFor(input) {
|
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
export class RunState {
|
|
151
|
+
/** @param {string} dir @param {RunData} data @param {boolean} [readOnly] */
|
|
148
152
|
constructor(dir, data, readOnly = false, manifest = new Manifest(), lockToken = null, manifestMetadata = {}) {
|
|
149
153
|
this.dir = dir;
|
|
150
154
|
this.data = data;
|
|
@@ -210,7 +214,7 @@ export class RunState {
|
|
|
210
214
|
if (create) {
|
|
211
215
|
try {
|
|
212
216
|
const saved = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
|
|
213
|
-
manifest = new Manifest(saved.phases ?? []);
|
|
217
|
+
manifest = new Manifest(/** @type {ManifestEntry[]} */ (saved.phases ?? []));
|
|
214
218
|
manifestMetadata = saved;
|
|
215
219
|
} catch (error) {
|
|
216
220
|
if (error.code !== 'ENOENT') throw error;
|
package/src/tui.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { computeRunMetrics } from './metrics.mjs';
|
|
3
|
+
import { formatFindings } from './verdict.mjs';
|
|
4
|
+
|
|
5
|
+
const ESC = '\x1b';
|
|
6
|
+
const CLEAR_SCREEN = `${ESC}[2J${ESC}[H`;
|
|
7
|
+
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
8
|
+
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
9
|
+
const RECENT_LOG_LIMIT = 8;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A live TUI is only a view over the same run; it never changes what the run
|
|
13
|
+
* does. It is therefore only worth showing where someone can watch it, and
|
|
14
|
+
* must get out of the way the moment that stops being true (piped output/CI, or
|
|
15
|
+
* an explicit `--no-tui`). `--yes` only skips per-phase confirmation, so an
|
|
16
|
+
* unattended run on a real terminal can still be watched live.
|
|
17
|
+
*/
|
|
18
|
+
export function tuiEnabled(options = {}) {
|
|
19
|
+
const { isTTY, noTui } = /** @type {any} */ (options);
|
|
20
|
+
return Boolean(isTTY) && !noTui;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function statusSymbol(status) {
|
|
24
|
+
if (status === 'completed') return '✓';
|
|
25
|
+
if (status === 'stalled') return '✗';
|
|
26
|
+
if (status === 'active') return '▶';
|
|
27
|
+
return ' ';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchPhaseName(resolvedPhases, text) {
|
|
31
|
+
const found = resolvedPhases.find((phase) => text === phase.name || text.startsWith(`${phase.name} (`));
|
|
32
|
+
return found ? found.name : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function phaseRows(resolvedPhases, manifestEntries, completed, activeName) {
|
|
36
|
+
return resolvedPhases.map((phase) => {
|
|
37
|
+
const entries = manifestEntries.filter((entry) => entry.phase === phase.name);
|
|
38
|
+
const last = entries.at(-1);
|
|
39
|
+
let status = 'pending';
|
|
40
|
+
if (last?.status === 'stalled') status = 'stalled';
|
|
41
|
+
else if (completed.includes(phase.name)) status = 'completed';
|
|
42
|
+
else if (phase.name === activeName) status = 'active';
|
|
43
|
+
const rounds = entries.filter((entry) => Number.isInteger(entry.round)).length;
|
|
44
|
+
return { name: phase.name, status, rounds, verdict: last?.verdict?.verdict ?? null };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defaultDiffStat(worktree) {
|
|
49
|
+
if (!worktree) return '';
|
|
50
|
+
try {
|
|
51
|
+
const result = spawnSync('git', ['-C', worktree, 'diff', '--stat', 'HEAD'], { encoding: 'utf8' });
|
|
52
|
+
if (result.status !== 0 || result.error) return '';
|
|
53
|
+
return result.stdout.trim();
|
|
54
|
+
} catch {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatValue(value) {
|
|
60
|
+
return value === null || value === undefined ? '?' : String(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function latestVerdictEntry(manifestEntries) {
|
|
64
|
+
return [...manifestEntries].reverse().find((entry) => entry.verdict);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderFrame({ config, summary, state, activeName, recentLog, diff }) {
|
|
68
|
+
const manifestEntries = state.manifest.entries;
|
|
69
|
+
const completed = state.data.completed ?? [];
|
|
70
|
+
const rows = phaseRows(config.resolvedPhases, manifestEntries, completed, activeName);
|
|
71
|
+
const metrics = computeRunMetrics(state.manifest);
|
|
72
|
+
const artifacts = [...new Set(manifestEntries.flatMap((entry) => entry.artifacts ?? []))];
|
|
73
|
+
const verdictEntry = latestVerdictEntry(manifestEntries);
|
|
74
|
+
|
|
75
|
+
const lines = [];
|
|
76
|
+
lines.push(`aloop · ${summary.task} branch=${summary.branch}`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push('Phases:');
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const roundDetail = row.rounds ? ` (round ${row.rounds}${row.verdict ? `, ${row.verdict}` : ''})` : '';
|
|
81
|
+
lines.push(` ${statusSymbol(row.status)} ${row.name}${roundDetail}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push(
|
|
85
|
+
`Cost: duration=${formatValue(metrics.total.durationMs)}ms tokens=${formatValue(metrics.total.tokens)} cost=${formatValue(metrics.total.cost)}`,
|
|
86
|
+
);
|
|
87
|
+
if (diff) {
|
|
88
|
+
lines.push('');
|
|
89
|
+
lines.push('Diff:');
|
|
90
|
+
lines.push(...diff.split('\n'));
|
|
91
|
+
}
|
|
92
|
+
if (verdictEntry) {
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push(`Findings (${verdictEntry.phase}, ${verdictEntry.verdict.verdict}):`);
|
|
95
|
+
lines.push(formatFindings([...(verdictEntry.verdict.blocking ?? []), ...(verdictEntry.verdict.nits ?? [])]));
|
|
96
|
+
}
|
|
97
|
+
if (artifacts.length) {
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push(`Artifacts: ${artifacts.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
if (recentLog.length) {
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push('Recent:');
|
|
104
|
+
for (const entry of recentLog) lines.push(` ${entry}`);
|
|
105
|
+
}
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A second renderer behind the `banner`/`log` seam in reporter.mjs. It reads
|
|
111
|
+
* the same manifest/state the run already persists after every phase, so it
|
|
112
|
+
* needs no separate event stream — just a repaint on every `banner`/`log`
|
|
113
|
+
* call, which is exactly when the run's visible state can have changed.
|
|
114
|
+
*
|
|
115
|
+
* `git diff --stat` is a subprocess spawn, not free, and the diff it reports
|
|
116
|
+
* only changes between phases (each phase is what edits the worktree) — so
|
|
117
|
+
* it's computed once per `banner` and reused by every repaint until the next
|
|
118
|
+
* one, instead of being re-spawned on every `log`/`output` call.
|
|
119
|
+
*/
|
|
120
|
+
export function createTuiRenderer(options = {}) {
|
|
121
|
+
const { config, state, summary, output = process.stdout, diffStat = defaultDiffStat } = /** @type {any} */ (options);
|
|
122
|
+
let activeName = null;
|
|
123
|
+
const recentLog = [];
|
|
124
|
+
let pendingLine = '';
|
|
125
|
+
let cachedDiff = '';
|
|
126
|
+
|
|
127
|
+
function repaint() {
|
|
128
|
+
const frame = renderFrame({ config, summary, state, activeName, recentLog, diff: cachedDiff });
|
|
129
|
+
output.write(`${HIDE_CURSOR}${CLEAR_SCREEN}${frame}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pushRecent(text) {
|
|
133
|
+
const trimmed = text.trim();
|
|
134
|
+
if (!trimmed) return;
|
|
135
|
+
recentLog.push(trimmed);
|
|
136
|
+
if (recentLog.length > RECENT_LOG_LIMIT) recentLog.shift();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
banner(text) {
|
|
141
|
+
activeName = matchPhaseName(config.resolvedPhases, text) ?? activeName;
|
|
142
|
+
recentLog.length = 0;
|
|
143
|
+
recentLog.push(text.trim());
|
|
144
|
+
cachedDiff = diffStat(summary.worktree);
|
|
145
|
+
repaint();
|
|
146
|
+
},
|
|
147
|
+
log(message) {
|
|
148
|
+
pushRecent(message);
|
|
149
|
+
repaint();
|
|
150
|
+
},
|
|
151
|
+
// Raw engine/gate/setup output arrives in arbitrary chunks, not lines;
|
|
152
|
+
// buffer until a newline so the "Recent" pane shows whole lines instead
|
|
153
|
+
// of chopped fragments, then fold it into the same repaint the other
|
|
154
|
+
// renderer calls use so nothing else writes to the terminal directly.
|
|
155
|
+
// A chunk with no newline yet changes nothing visible (the fragment
|
|
156
|
+
// isn't shown until it completes a line), so it's buffered without
|
|
157
|
+
// triggering a repaint.
|
|
158
|
+
output(text) {
|
|
159
|
+
pendingLine += text;
|
|
160
|
+
const lines = pendingLine.split('\n');
|
|
161
|
+
pendingLine = lines.pop();
|
|
162
|
+
if (lines.length === 0) return;
|
|
163
|
+
for (const line of lines) pushRecent(line);
|
|
164
|
+
repaint();
|
|
165
|
+
},
|
|
166
|
+
stop() {
|
|
167
|
+
repaint();
|
|
168
|
+
output.write(SHOW_CURSOR);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|