@vexdo/cli 0.1.4 → 0.2.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/README.md +82 -5
- package/dist/index.js +1206 -344
- package/package.json +5 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import fs10 from "fs";
|
|
3
|
+
import path12 from "path";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/commands/abort.ts
|
|
@@ -79,6 +79,15 @@ function parseReview(value) {
|
|
|
79
79
|
auto_submit
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
function parseMaxConcurrent(value) {
|
|
83
|
+
if (value === void 0) {
|
|
84
|
+
return void 0;
|
|
85
|
+
}
|
|
86
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
87
|
+
throw new Error("maxConcurrent must be a positive integer");
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
82
91
|
function parseCodex(value) {
|
|
83
92
|
if (value === void 0) {
|
|
84
93
|
return { model: DEFAULT_CODEX_MODEL };
|
|
@@ -113,8 +122,8 @@ function loadConfig(projectRoot) {
|
|
|
113
122
|
let parsed;
|
|
114
123
|
try {
|
|
115
124
|
parsed = parse(configRaw);
|
|
116
|
-
} catch (
|
|
117
|
-
const message =
|
|
125
|
+
} catch (error2) {
|
|
126
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
118
127
|
throw new Error(`Invalid YAML in .vexdo.yml: ${message}`);
|
|
119
128
|
}
|
|
120
129
|
if (!isRecord(parsed)) {
|
|
@@ -127,11 +136,13 @@ function loadConfig(projectRoot) {
|
|
|
127
136
|
const services = parseServices(readObjectField(parsed, "services"));
|
|
128
137
|
const review = parseReview(readObjectField(parsed, "review"));
|
|
129
138
|
const codex = parseCodex(readObjectField(parsed, "codex"));
|
|
139
|
+
const maxConcurrent = parseMaxConcurrent(readObjectField(parsed, "maxConcurrent"));
|
|
130
140
|
return {
|
|
131
141
|
version: 1,
|
|
132
142
|
services,
|
|
133
143
|
review,
|
|
134
|
-
codex
|
|
144
|
+
codex,
|
|
145
|
+
maxConcurrent
|
|
135
146
|
};
|
|
136
147
|
}
|
|
137
148
|
|
|
@@ -161,15 +172,15 @@ function success(message) {
|
|
|
161
172
|
function warn(message) {
|
|
162
173
|
safeLog("log", `${pc.yellow("\u26A0")} ${message}`);
|
|
163
174
|
}
|
|
175
|
+
function error(message) {
|
|
176
|
+
safeLog("error", `${pc.red("\u2716")} ${message}`);
|
|
177
|
+
}
|
|
164
178
|
function debug(message) {
|
|
165
179
|
if (!verboseEnabled) {
|
|
166
180
|
return;
|
|
167
181
|
}
|
|
168
182
|
safeLog("log", `${pc.gray("\u2022")} ${message}`);
|
|
169
183
|
}
|
|
170
|
-
function step(n, total, title) {
|
|
171
|
-
safeLog("log", `${pc.bold(`Step ${String(n)}/${String(total)}:`)} ${title}`);
|
|
172
|
-
}
|
|
173
184
|
function iteration(n, max) {
|
|
174
185
|
safeLog("log", pc.gray(`Iteration ${String(n)}/${String(max)}`));
|
|
175
186
|
}
|
|
@@ -228,6 +239,56 @@ function reviewSummary(comments) {
|
|
|
228
239
|
safeLog("log", `- ${comment.severity}${location}: ${comment.comment}`);
|
|
229
240
|
}
|
|
230
241
|
}
|
|
242
|
+
function prefixed(prefix, message) {
|
|
243
|
+
return `${prefix} ${message}`;
|
|
244
|
+
}
|
|
245
|
+
function withPrefix(prefix) {
|
|
246
|
+
return {
|
|
247
|
+
info: (message) => {
|
|
248
|
+
info(prefixed(prefix, message));
|
|
249
|
+
},
|
|
250
|
+
success: (message) => {
|
|
251
|
+
success(prefixed(prefix, message));
|
|
252
|
+
},
|
|
253
|
+
warn: (message) => {
|
|
254
|
+
warn(prefixed(prefix, message));
|
|
255
|
+
},
|
|
256
|
+
error: (message) => {
|
|
257
|
+
error(prefixed(prefix, message));
|
|
258
|
+
},
|
|
259
|
+
debug: (message) => {
|
|
260
|
+
debug(prefixed(prefix, message));
|
|
261
|
+
},
|
|
262
|
+
iteration: (n, max) => {
|
|
263
|
+
safeLog("log", prefixed(prefix, pc.gray(`Iteration ${String(n)}/${String(max)}`)));
|
|
264
|
+
},
|
|
265
|
+
reviewSummary: (comments) => {
|
|
266
|
+
const counts = {
|
|
267
|
+
critical: 0,
|
|
268
|
+
important: 0,
|
|
269
|
+
minor: 0,
|
|
270
|
+
noise: 0
|
|
271
|
+
};
|
|
272
|
+
for (const comment of comments) {
|
|
273
|
+
counts[comment.severity] += 1;
|
|
274
|
+
}
|
|
275
|
+
safeLog(
|
|
276
|
+
"log",
|
|
277
|
+
prefixed(
|
|
278
|
+
prefix,
|
|
279
|
+
`${pc.bold("Review:")} ${String(counts.critical)} critical ${String(counts.important)} important ${String(counts.minor)} minor`
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
for (const comment of comments) {
|
|
283
|
+
if (comment.severity === "noise") {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const location = comment.file ? ` (${comment.file}${comment.line ? `:${String(comment.line)}` : ""})` : "";
|
|
287
|
+
safeLog("log", prefixed(prefix, `- ${comment.severity}${location}: ${comment.comment}`));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
231
292
|
|
|
232
293
|
// src/lib/state.ts
|
|
233
294
|
import fs2 from "fs";
|
|
@@ -257,8 +318,8 @@ function loadState(projectRoot) {
|
|
|
257
318
|
const raw = fs2.readFileSync(statePath, "utf8");
|
|
258
319
|
try {
|
|
259
320
|
return JSON.parse(raw);
|
|
260
|
-
} catch (
|
|
261
|
-
const message =
|
|
321
|
+
} catch (error2) {
|
|
322
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
262
323
|
throw new Error(`Corrupt state file at ${statePath}: ${message}`);
|
|
263
324
|
}
|
|
264
325
|
}
|
|
@@ -271,6 +332,34 @@ function saveState(projectRoot, state) {
|
|
|
271
332
|
};
|
|
272
333
|
fs2.writeFileSync(getStatePath(projectRoot), JSON.stringify(nextState, null, 2) + "\n", "utf8");
|
|
273
334
|
}
|
|
335
|
+
async function updateStep(projectRoot, taskId, service, updates) {
|
|
336
|
+
const currentState = loadState(projectRoot);
|
|
337
|
+
if (!currentState) {
|
|
338
|
+
throw new Error("No active state found to update.");
|
|
339
|
+
}
|
|
340
|
+
if (currentState.taskId !== taskId) {
|
|
341
|
+
throw new Error(`State task mismatch. Expected '${taskId}', found '${currentState.taskId}'.`);
|
|
342
|
+
}
|
|
343
|
+
const nextState = {
|
|
344
|
+
...currentState,
|
|
345
|
+
steps: currentState.steps.map((step) => {
|
|
346
|
+
if (step.service !== service) {
|
|
347
|
+
return step;
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
...step,
|
|
351
|
+
...updates
|
|
352
|
+
};
|
|
353
|
+
}),
|
|
354
|
+
updatedAt: nowIso()
|
|
355
|
+
};
|
|
356
|
+
const stateDir = getStateDir(projectRoot);
|
|
357
|
+
fs2.mkdirSync(stateDir, { recursive: true });
|
|
358
|
+
const statePath = getStatePath(projectRoot);
|
|
359
|
+
const tmpPath = `${statePath}.tmp`;
|
|
360
|
+
await fs2.promises.writeFile(tmpPath, JSON.stringify(nextState, null, 2) + "\n", "utf8");
|
|
361
|
+
await fs2.promises.rename(tmpPath, statePath);
|
|
362
|
+
}
|
|
274
363
|
function clearState(projectRoot) {
|
|
275
364
|
const statePath = getStatePath(projectRoot);
|
|
276
365
|
if (fs2.existsSync(statePath)) {
|
|
@@ -341,13 +430,46 @@ function parseTaskStep(value, index, config) {
|
|
|
341
430
|
depends_on
|
|
342
431
|
};
|
|
343
432
|
}
|
|
433
|
+
function validateDependencies(steps) {
|
|
434
|
+
const byService = new Map(steps.map((step) => [step.service, step]));
|
|
435
|
+
for (const step of steps) {
|
|
436
|
+
for (const dep of step.depends_on ?? []) {
|
|
437
|
+
if (!byService.has(dep)) {
|
|
438
|
+
throw new Error(`step '${step.service}' depends on unknown service '${dep}'`);
|
|
439
|
+
}
|
|
440
|
+
if (dep === step.service) {
|
|
441
|
+
throw new Error(`step '${step.service}' cannot depend on itself`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
446
|
+
const visited = /* @__PURE__ */ new Set();
|
|
447
|
+
const visit = (service) => {
|
|
448
|
+
if (visited.has(service)) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (visiting.has(service)) {
|
|
452
|
+
throw new Error(`Dependency cycle detected involving service '${service}'`);
|
|
453
|
+
}
|
|
454
|
+
visiting.add(service);
|
|
455
|
+
const step = byService.get(service);
|
|
456
|
+
for (const dep of step?.depends_on ?? []) {
|
|
457
|
+
visit(dep);
|
|
458
|
+
}
|
|
459
|
+
visiting.delete(service);
|
|
460
|
+
visited.add(service);
|
|
461
|
+
};
|
|
462
|
+
for (const step of steps) {
|
|
463
|
+
visit(step.service);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
344
466
|
function loadAndValidateTask(taskPath, config) {
|
|
345
467
|
const raw = fs3.readFileSync(taskPath, "utf8");
|
|
346
468
|
let parsed;
|
|
347
469
|
try {
|
|
348
470
|
parsed = parseYaml(raw);
|
|
349
|
-
} catch (
|
|
350
|
-
const message =
|
|
471
|
+
} catch (error2) {
|
|
472
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
351
473
|
throw new Error(`Invalid task YAML: ${message}`);
|
|
352
474
|
}
|
|
353
475
|
if (!isRecord2(parsed)) {
|
|
@@ -358,12 +480,13 @@ function loadAndValidateTask(taskPath, config) {
|
|
|
358
480
|
if (!Array.isArray(parsed.steps) || parsed.steps.length === 0) {
|
|
359
481
|
throw new Error("steps must be a non-empty array");
|
|
360
482
|
}
|
|
361
|
-
const steps = parsed.steps.map((
|
|
483
|
+
const steps = parsed.steps.map((step, index) => parseTaskStep(step, index, config));
|
|
484
|
+
validateDependencies(steps);
|
|
362
485
|
return { id, title, steps };
|
|
363
486
|
}
|
|
364
487
|
function buildInitialStepState(task) {
|
|
365
|
-
return task.steps.map((
|
|
366
|
-
service:
|
|
488
|
+
return task.steps.map((step) => ({
|
|
489
|
+
service: step.service,
|
|
367
490
|
status: "pending",
|
|
368
491
|
iteration: 0
|
|
369
492
|
}));
|
|
@@ -423,8 +546,328 @@ function registerAbortCommand(program2) {
|
|
|
423
546
|
});
|
|
424
547
|
}
|
|
425
548
|
|
|
549
|
+
// src/commands/board.tsx
|
|
550
|
+
import { render } from "ink";
|
|
551
|
+
|
|
552
|
+
// src/components/Board.tsx
|
|
553
|
+
import { spawnSync } from "child_process";
|
|
554
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
555
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
556
|
+
|
|
557
|
+
// src/lib/board.ts
|
|
558
|
+
import fs5 from "fs";
|
|
559
|
+
import path5 from "path";
|
|
560
|
+
import { parse as parse2 } from "yaml";
|
|
561
|
+
function parseTaskYaml(raw) {
|
|
562
|
+
try {
|
|
563
|
+
const parsed = parse2(raw);
|
|
564
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
565
|
+
return {};
|
|
566
|
+
}
|
|
567
|
+
const obj = parsed;
|
|
568
|
+
return {
|
|
569
|
+
id: typeof obj.id === "string" ? obj.id : void 0,
|
|
570
|
+
title: typeof obj.title === "string" ? obj.title : void 0
|
|
571
|
+
};
|
|
572
|
+
} catch {
|
|
573
|
+
return {};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async function listTaskFiles(directory) {
|
|
577
|
+
if (!fs5.existsSync(directory)) {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
const entries = await fs5.promises.readdir(directory, { withFileTypes: true });
|
|
581
|
+
return entries.filter((entry) => entry.isFile() && (entry.name.endsWith(".yml") || entry.name.endsWith(".yaml"))).map((entry) => path5.join(directory, entry.name));
|
|
582
|
+
}
|
|
583
|
+
async function readTaskSummary(filePath) {
|
|
584
|
+
const raw = await fs5.promises.readFile(filePath, "utf8");
|
|
585
|
+
const parsed = parseTaskYaml(raw);
|
|
586
|
+
const fallbackId = path5.basename(filePath).replace(/\.ya?ml$/i, "");
|
|
587
|
+
return {
|
|
588
|
+
id: parsed.id ?? fallbackId,
|
|
589
|
+
title: parsed.title ?? fallbackId,
|
|
590
|
+
path: filePath
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async function loadColumn(projectRoot, columnDir) {
|
|
594
|
+
const directory = path5.join(projectRoot, "tasks", columnDir);
|
|
595
|
+
const files = await listTaskFiles(directory);
|
|
596
|
+
const tasks = await Promise.all(files.map((filePath) => readTaskSummary(filePath)));
|
|
597
|
+
return tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
598
|
+
}
|
|
599
|
+
async function loadBoardState(projectRoot) {
|
|
600
|
+
const [backlog, inProgress, review, blocked, doneFiles] = await Promise.all([
|
|
601
|
+
loadColumn(projectRoot, "backlog"),
|
|
602
|
+
loadColumn(projectRoot, "in_progress"),
|
|
603
|
+
loadColumn(projectRoot, "review"),
|
|
604
|
+
loadColumn(projectRoot, "blocked"),
|
|
605
|
+
listTaskFiles(path5.join(projectRoot, "tasks", "done"))
|
|
606
|
+
]);
|
|
607
|
+
const doneWithStats = await Promise.all(
|
|
608
|
+
doneFiles.map(async (filePath) => ({
|
|
609
|
+
filePath,
|
|
610
|
+
stat: await fs5.promises.stat(filePath)
|
|
611
|
+
}))
|
|
612
|
+
);
|
|
613
|
+
doneWithStats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
614
|
+
const done = await Promise.all(doneWithStats.slice(0, 20).map((item) => readTaskSummary(item.filePath)));
|
|
615
|
+
return {
|
|
616
|
+
backlog,
|
|
617
|
+
in_progress: inProgress,
|
|
618
|
+
review,
|
|
619
|
+
done,
|
|
620
|
+
blocked: blocked.map((task) => ({ ...task, blocked: true }))
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/components/Board.tsx
|
|
625
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
626
|
+
function clamp(value, min, max) {
|
|
627
|
+
return Math.max(min, Math.min(value, max));
|
|
628
|
+
}
|
|
629
|
+
function runExternal(command, args) {
|
|
630
|
+
const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
|
|
631
|
+
if (process.stdin.isTTY && wasRaw) {
|
|
632
|
+
process.stdin.setRawMode(false);
|
|
633
|
+
}
|
|
634
|
+
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
635
|
+
if (process.stdin.isTTY && wasRaw) {
|
|
636
|
+
process.stdin.setRawMode(true);
|
|
637
|
+
}
|
|
638
|
+
if (result.error) {
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
message: result.error.message
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
ok: result.status === 0,
|
|
646
|
+
message: result.status === 0 ? "Command completed." : `Command exited with status ${String(result.status)}.`
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function openInEditor(filePath) {
|
|
650
|
+
const editor = process.env.EDITOR;
|
|
651
|
+
if (!editor) {
|
|
652
|
+
return { ok: false, message: "EDITOR is not set." };
|
|
653
|
+
}
|
|
654
|
+
return runExternal(editor, [filePath]);
|
|
655
|
+
}
|
|
656
|
+
function runPrimaryAction(column, task) {
|
|
657
|
+
if (task.blocked) {
|
|
658
|
+
return runExternal("vexdo", ["logs", task.id]);
|
|
659
|
+
}
|
|
660
|
+
if (column === "backlog") {
|
|
661
|
+
return runExternal("vexdo", ["start", task.path]);
|
|
662
|
+
}
|
|
663
|
+
if (column === "in_progress") {
|
|
664
|
+
return runExternal("vexdo", ["status"]);
|
|
665
|
+
}
|
|
666
|
+
if (column === "review") {
|
|
667
|
+
return runExternal("vexdo", ["submit"]);
|
|
668
|
+
}
|
|
669
|
+
return openInEditor(task.path);
|
|
670
|
+
}
|
|
671
|
+
function TaskCard({ task, selected }) {
|
|
672
|
+
const prefix = task.blocked ? "\u26A0 " : "";
|
|
673
|
+
return /* @__PURE__ */ jsx(Text, { children: `${selected ? "> " : " "}${prefix}${task.id}` });
|
|
674
|
+
}
|
|
675
|
+
function Column({
|
|
676
|
+
title,
|
|
677
|
+
count,
|
|
678
|
+
tasks,
|
|
679
|
+
selectedRow,
|
|
680
|
+
active
|
|
681
|
+
}) {
|
|
682
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", paddingX: 1, children: [
|
|
683
|
+
/* @__PURE__ */ jsx(Text, { color: active ? "cyan" : void 0, children: title }),
|
|
684
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: `(${String(count)})` }),
|
|
685
|
+
/* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
|
|
686
|
+
tasks.length === 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u2014" }) : null,
|
|
687
|
+
tasks.map((task, index) => /* @__PURE__ */ jsx(TaskCard, { task, selected: active && index === selectedRow }, `${task.path}-${task.id}`))
|
|
688
|
+
] })
|
|
689
|
+
] });
|
|
690
|
+
}
|
|
691
|
+
function StatusBar({
|
|
692
|
+
task,
|
|
693
|
+
column,
|
|
694
|
+
message,
|
|
695
|
+
confirmAbort
|
|
696
|
+
}) {
|
|
697
|
+
if (!task) {
|
|
698
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [
|
|
699
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "No task selected." }),
|
|
700
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "[\u2190/\u2192/\u2191/\u2193] navigate [r] refresh [q] quit" })
|
|
701
|
+
] });
|
|
702
|
+
}
|
|
703
|
+
const primaryLabel = task.blocked ? "[\u21B5] logs" : column === "backlog" ? "[\u21B5] start" : column === "in_progress" ? "[\u21B5] status" : column === "review" ? "[\u21B5] submit" : "[\u21B5] edit";
|
|
704
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [
|
|
705
|
+
/* @__PURE__ */ jsx(Text, { children: `${task.id} \xB7 ${task.title}` }),
|
|
706
|
+
/* @__PURE__ */ jsx(Text, { children: `${primaryLabel} [e] edit [l] logs [a] abort [r] refresh [q] quit${confirmAbort ? " Confirm abort: press [a] again" : ""}` }),
|
|
707
|
+
message ? /* @__PURE__ */ jsx(Text, { color: "yellow", children: message }) : null
|
|
708
|
+
] });
|
|
709
|
+
}
|
|
710
|
+
function Board({ projectRoot }) {
|
|
711
|
+
const { exit } = useApp();
|
|
712
|
+
const [state, setState] = useState(null);
|
|
713
|
+
const [loading, setLoading] = useState(true);
|
|
714
|
+
const [cursor, setCursor] = useState({ column: 0, row: 0 });
|
|
715
|
+
const [message, setMessage] = useState(null);
|
|
716
|
+
const [confirmAbort, setConfirmAbort] = useState(false);
|
|
717
|
+
const refresh = useCallback(async () => {
|
|
718
|
+
setLoading(true);
|
|
719
|
+
try {
|
|
720
|
+
const next = await loadBoardState(projectRoot);
|
|
721
|
+
setState(next);
|
|
722
|
+
setMessage(null);
|
|
723
|
+
} catch (error2) {
|
|
724
|
+
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
|
725
|
+
setMessage(`Failed to load board: ${errorMessage}`);
|
|
726
|
+
} finally {
|
|
727
|
+
setLoading(false);
|
|
728
|
+
}
|
|
729
|
+
}, [projectRoot]);
|
|
730
|
+
useEffect(() => {
|
|
731
|
+
void refresh();
|
|
732
|
+
}, [refresh]);
|
|
733
|
+
const columns = useMemo(() => {
|
|
734
|
+
if (!state) {
|
|
735
|
+
return [
|
|
736
|
+
{ key: "backlog", title: "BACKLOG", tasks: [] },
|
|
737
|
+
{ key: "in_progress", title: "IN PROGRESS", tasks: [] },
|
|
738
|
+
{ key: "review", title: "REVIEW", tasks: [] },
|
|
739
|
+
{ key: "done", title: "DONE", tasks: [] }
|
|
740
|
+
];
|
|
741
|
+
}
|
|
742
|
+
return [
|
|
743
|
+
{ key: "backlog", title: "BACKLOG", tasks: [...state.blocked, ...state.backlog] },
|
|
744
|
+
{ key: "in_progress", title: "IN PROGRESS", tasks: state.in_progress },
|
|
745
|
+
{ key: "review", title: "REVIEW", tasks: state.review },
|
|
746
|
+
{ key: "done", title: "DONE", tasks: state.done }
|
|
747
|
+
];
|
|
748
|
+
}, [state]);
|
|
749
|
+
const activeColumn = columns[cursor.column] ?? columns[0];
|
|
750
|
+
const selectedTask = activeColumn.tasks.at(cursor.row);
|
|
751
|
+
useEffect(() => {
|
|
752
|
+
const nextColumn = clamp(cursor.column, 0, columns.length - 1);
|
|
753
|
+
const maxRow = Math.max(0, (columns[nextColumn]?.tasks.length ?? 1) - 1);
|
|
754
|
+
const nextRow = clamp(cursor.row, 0, maxRow);
|
|
755
|
+
if (nextColumn !== cursor.column || nextRow !== cursor.row) {
|
|
756
|
+
setCursor({ column: nextColumn, row: nextRow });
|
|
757
|
+
}
|
|
758
|
+
}, [columns, cursor.column, cursor.row]);
|
|
759
|
+
useInput((input2, key) => {
|
|
760
|
+
if (input2 === "q" || key.ctrl && input2 === "c") {
|
|
761
|
+
exit();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (key.leftArrow) {
|
|
765
|
+
setConfirmAbort(false);
|
|
766
|
+
setCursor((prev) => {
|
|
767
|
+
const nextColumn = clamp(prev.column - 1, 0, columns.length - 1);
|
|
768
|
+
const maxRow = Math.max(0, columns[nextColumn].tasks.length - 1);
|
|
769
|
+
return { column: nextColumn, row: clamp(prev.row, 0, maxRow) };
|
|
770
|
+
});
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (key.rightArrow) {
|
|
774
|
+
setConfirmAbort(false);
|
|
775
|
+
setCursor((prev) => {
|
|
776
|
+
const nextColumn = clamp(prev.column + 1, 0, columns.length - 1);
|
|
777
|
+
const maxRow = Math.max(0, columns[nextColumn].tasks.length - 1);
|
|
778
|
+
return { column: nextColumn, row: clamp(prev.row, 0, maxRow) };
|
|
779
|
+
});
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (key.upArrow) {
|
|
783
|
+
setConfirmAbort(false);
|
|
784
|
+
setCursor((prev) => ({ ...prev, row: clamp(prev.row - 1, 0, Math.max(0, activeColumn.tasks.length - 1)) }));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (key.downArrow) {
|
|
788
|
+
setConfirmAbort(false);
|
|
789
|
+
setCursor((prev) => ({ ...prev, row: clamp(prev.row + 1, 0, Math.max(0, activeColumn.tasks.length - 1)) }));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (input2 === "r") {
|
|
793
|
+
setConfirmAbort(false);
|
|
794
|
+
void refresh();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (key.return && selectedTask) {
|
|
798
|
+
const result = runPrimaryAction(activeColumn.key, selectedTask);
|
|
799
|
+
setMessage(result.message);
|
|
800
|
+
setConfirmAbort(false);
|
|
801
|
+
void refresh();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (input2 === "e" && selectedTask) {
|
|
805
|
+
const result = openInEditor(selectedTask.path);
|
|
806
|
+
setMessage(result.message);
|
|
807
|
+
setConfirmAbort(false);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (input2 === "l" && selectedTask) {
|
|
811
|
+
const result = runExternal("vexdo", ["logs", selectedTask.id]);
|
|
812
|
+
setMessage(result.message);
|
|
813
|
+
setConfirmAbort(false);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (input2 === "a" && selectedTask) {
|
|
817
|
+
if (!confirmAbort) {
|
|
818
|
+
setConfirmAbort(true);
|
|
819
|
+
setMessage(`Confirm abort task '${selectedTask.id}' by pressing 'a' again.`);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const result = runExternal("vexdo", ["abort"]);
|
|
823
|
+
setMessage(result.message);
|
|
824
|
+
setConfirmAbort(false);
|
|
825
|
+
void refresh();
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
const terminalRows = typeof process.stdout.rows === "number" ? process.stdout.rows : 24;
|
|
829
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: terminalRows, children: [
|
|
830
|
+
/* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", borderStyle: "single", paddingX: 1, children: [
|
|
831
|
+
/* @__PURE__ */ jsx(Text, { children: "vexdo board" }),
|
|
832
|
+
/* @__PURE__ */ jsx(Text, { children: "[q] quit" })
|
|
833
|
+
] }),
|
|
834
|
+
loading ? /* @__PURE__ */ jsx(Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsx(Text, { children: "Loading tasks\u2026" }) }) : /* @__PURE__ */ jsx(Box, { flexGrow: 1, children: columns.map((column, index) => /* @__PURE__ */ jsx(
|
|
835
|
+
Column,
|
|
836
|
+
{
|
|
837
|
+
title: column.title,
|
|
838
|
+
count: column.tasks.length,
|
|
839
|
+
tasks: column.tasks,
|
|
840
|
+
selectedRow: cursor.row,
|
|
841
|
+
active: index === cursor.column
|
|
842
|
+
},
|
|
843
|
+
column.key
|
|
844
|
+
)) }),
|
|
845
|
+
/* @__PURE__ */ jsx(StatusBar, { task: selectedTask, column: activeColumn.key, message, confirmAbort })
|
|
846
|
+
] });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/commands/board.tsx
|
|
850
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
851
|
+
function fatalAndExit2(message) {
|
|
852
|
+
fatal(message);
|
|
853
|
+
process.exit(1);
|
|
854
|
+
}
|
|
855
|
+
async function runBoard() {
|
|
856
|
+
const projectRoot = findProjectRoot();
|
|
857
|
+
if (!projectRoot) {
|
|
858
|
+
fatalAndExit2("Not inside a vexdo project.");
|
|
859
|
+
}
|
|
860
|
+
const instance = render(/* @__PURE__ */ jsx2(Board, { projectRoot }));
|
|
861
|
+
await instance.waitUntilExit();
|
|
862
|
+
}
|
|
863
|
+
function registerBoardCommand(program2) {
|
|
864
|
+
program2.command("board").description("Open interactive task board").action(() => {
|
|
865
|
+
void runBoard();
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
426
869
|
// src/commands/fix.ts
|
|
427
|
-
import
|
|
870
|
+
import path7 from "path";
|
|
428
871
|
|
|
429
872
|
// src/lib/claude.ts
|
|
430
873
|
import Anthropic from "@anthropic-ai/sdk";
|
|
@@ -547,10 +990,10 @@ ${JSON.stringify(opts.reviewComments)}`
|
|
|
547
990
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
548
991
|
try {
|
|
549
992
|
return await fn();
|
|
550
|
-
} catch (
|
|
551
|
-
lastError =
|
|
552
|
-
if (!isRetryableError(
|
|
553
|
-
throw new ClaudeError(attempt,
|
|
993
|
+
} catch (error2) {
|
|
994
|
+
lastError = error2;
|
|
995
|
+
if (!isRetryableError(error2) || attempt === MAX_ATTEMPTS) {
|
|
996
|
+
throw new ClaudeError(attempt, error2);
|
|
554
997
|
}
|
|
555
998
|
const backoffMs = 1e3 * 2 ** (attempt - 1);
|
|
556
999
|
warn(
|
|
@@ -579,7 +1022,7 @@ function extractJson(text) {
|
|
|
579
1022
|
return trimmed;
|
|
580
1023
|
}
|
|
581
1024
|
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
|
|
582
|
-
if (fenced
|
|
1025
|
+
if (fenced?.[1]) {
|
|
583
1026
|
return fenced[1].trim();
|
|
584
1027
|
}
|
|
585
1028
|
return trimmed;
|
|
@@ -588,8 +1031,8 @@ function parseReviewerResult(raw) {
|
|
|
588
1031
|
let parsed;
|
|
589
1032
|
try {
|
|
590
1033
|
parsed = JSON.parse(extractJson(raw));
|
|
591
|
-
} catch (
|
|
592
|
-
throw new Error(`Failed to parse reviewer JSON: ${
|
|
1034
|
+
} catch (error2) {
|
|
1035
|
+
throw new Error(`Failed to parse reviewer JSON: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
593
1036
|
}
|
|
594
1037
|
if (!isReviewResult(parsed)) {
|
|
595
1038
|
throw new Error("Reviewer JSON does not match schema");
|
|
@@ -600,8 +1043,8 @@ function parseArbiterResult(raw) {
|
|
|
600
1043
|
let parsed;
|
|
601
1044
|
try {
|
|
602
1045
|
parsed = JSON.parse(extractJson(raw));
|
|
603
|
-
} catch (
|
|
604
|
-
throw new Error(`Failed to parse arbiter JSON: ${
|
|
1046
|
+
} catch (error2) {
|
|
1047
|
+
throw new Error(`Failed to parse arbiter JSON: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
605
1048
|
}
|
|
606
1049
|
if (!isArbiterResult(parsed)) {
|
|
607
1050
|
throw new Error("Arbiter JSON does not match schema");
|
|
@@ -650,11 +1093,11 @@ function isArbiterResult(value) {
|
|
|
650
1093
|
}
|
|
651
1094
|
return candidate.feedback_for_codex === void 0;
|
|
652
1095
|
}
|
|
653
|
-
function getStatusCode(
|
|
654
|
-
if (typeof
|
|
1096
|
+
function getStatusCode(error2) {
|
|
1097
|
+
if (typeof error2 !== "object" || error2 === null) {
|
|
655
1098
|
return void 0;
|
|
656
1099
|
}
|
|
657
|
-
const candidate =
|
|
1100
|
+
const candidate = error2;
|
|
658
1101
|
if (typeof candidate.status === "number") {
|
|
659
1102
|
return candidate.status;
|
|
660
1103
|
}
|
|
@@ -663,8 +1106,8 @@ function getStatusCode(error) {
|
|
|
663
1106
|
}
|
|
664
1107
|
return void 0;
|
|
665
1108
|
}
|
|
666
|
-
function isRetryableError(
|
|
667
|
-
const status = getStatusCode(
|
|
1109
|
+
function isRetryableError(error2) {
|
|
1110
|
+
const status = getStatusCode(error2);
|
|
668
1111
|
if (status === 400 || status === 401 || status === 403) {
|
|
669
1112
|
return false;
|
|
670
1113
|
}
|
|
@@ -675,19 +1118,31 @@ function isRetryableError(error) {
|
|
|
675
1118
|
}
|
|
676
1119
|
|
|
677
1120
|
// src/lib/codex.ts
|
|
678
|
-
import {
|
|
1121
|
+
import { spawn } from "child_process";
|
|
679
1122
|
var CODEX_TIMEOUT_MS = 6e5;
|
|
680
|
-
var VERBOSE_HEARTBEAT_MS = 15e3;
|
|
681
1123
|
var CodexError = class extends Error {
|
|
1124
|
+
code;
|
|
682
1125
|
stdout;
|
|
683
1126
|
stderr;
|
|
684
1127
|
exitCode;
|
|
685
|
-
constructor(
|
|
686
|
-
super(
|
|
1128
|
+
constructor(code, message, details) {
|
|
1129
|
+
super(message);
|
|
687
1130
|
this.name = "CodexError";
|
|
688
|
-
this.
|
|
689
|
-
this.
|
|
690
|
-
this.
|
|
1131
|
+
this.code = code;
|
|
1132
|
+
this.stdout = details?.stdout ?? "";
|
|
1133
|
+
this.stderr = details?.stderr ?? "";
|
|
1134
|
+
this.exitCode = details?.exitCode ?? 1;
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
var CodexTimeoutError = class extends CodexError {
|
|
1138
|
+
sessionId;
|
|
1139
|
+
constructor(sessionId, timeoutMs) {
|
|
1140
|
+
super(
|
|
1141
|
+
"poll_timeout",
|
|
1142
|
+
`Timed out waiting for codex cloud session ${sessionId} after ${String(timeoutMs)}ms. Check status with: codex cloud status ${sessionId}`
|
|
1143
|
+
);
|
|
1144
|
+
this.name = "CodexTimeoutError";
|
|
1145
|
+
this.sessionId = sessionId;
|
|
691
1146
|
}
|
|
692
1147
|
};
|
|
693
1148
|
var CodexNotFoundError = class extends Error {
|
|
@@ -696,110 +1151,141 @@ var CodexNotFoundError = class extends Error {
|
|
|
696
1151
|
this.name = "CodexNotFoundError";
|
|
697
1152
|
}
|
|
698
1153
|
};
|
|
699
|
-
function
|
|
700
|
-
|
|
701
|
-
|
|
1154
|
+
function runCodexCommand(args, opts) {
|
|
1155
|
+
return new Promise((resolve, reject) => {
|
|
1156
|
+
const child = spawn("codex", args, {
|
|
1157
|
+
cwd: opts?.cwd,
|
|
1158
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1159
|
+
});
|
|
1160
|
+
let stdout = "";
|
|
1161
|
+
let stderr = "";
|
|
1162
|
+
child.stdout.on("data", (chunk) => {
|
|
1163
|
+
stdout += chunk.toString();
|
|
1164
|
+
});
|
|
1165
|
+
child.stderr.on("data", (chunk) => {
|
|
1166
|
+
stderr += chunk.toString();
|
|
1167
|
+
});
|
|
1168
|
+
const timeout = setTimeout(() => {
|
|
1169
|
+
child.kill("SIGTERM");
|
|
1170
|
+
reject(new Error(`codex command timed out after ${String(opts?.timeoutMs ?? CODEX_TIMEOUT_MS)}ms`));
|
|
1171
|
+
}, opts?.timeoutMs ?? CODEX_TIMEOUT_MS);
|
|
1172
|
+
child.once("error", (error2) => {
|
|
1173
|
+
clearTimeout(timeout);
|
|
1174
|
+
reject(error2);
|
|
1175
|
+
});
|
|
1176
|
+
child.once("close", (code) => {
|
|
1177
|
+
clearTimeout(timeout);
|
|
1178
|
+
resolve({
|
|
1179
|
+
stdout: stdout.trim(),
|
|
1180
|
+
stderr: stderr.trim(),
|
|
1181
|
+
exitCode: code ?? 1
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
702
1185
|
}
|
|
703
|
-
function
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
flush() {
|
|
718
|
-
if (!partialLine) {
|
|
719
|
-
return;
|
|
1186
|
+
function parseSessionId(output2) {
|
|
1187
|
+
const match = /session[_-]?id\s*[:=]\s*([A-Za-z0-9._-]+)/i.exec(output2);
|
|
1188
|
+
if (match?.[1]) {
|
|
1189
|
+
return match[1];
|
|
1190
|
+
}
|
|
1191
|
+
for (const line of output2.split(/\r?\n/)) {
|
|
1192
|
+
const trimmed = line.trim();
|
|
1193
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
const parsed = JSON.parse(trimmed);
|
|
1198
|
+
if (typeof parsed.session_id === "string" && parsed.session_id.length > 0) {
|
|
1199
|
+
return parsed.session_id;
|
|
720
1200
|
}
|
|
721
|
-
|
|
722
|
-
partialLine = "";
|
|
1201
|
+
} catch {
|
|
723
1202
|
}
|
|
724
|
-
}
|
|
1203
|
+
}
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
function parseStatus(output2) {
|
|
1207
|
+
const match = /\b(completed|failed)\b/i.exec(output2);
|
|
1208
|
+
if (!match?.[1]) {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
return match[1].toLowerCase();
|
|
725
1212
|
}
|
|
726
1213
|
async function checkCodexAvailable() {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1214
|
+
try {
|
|
1215
|
+
const result = await runCodexCommand(["--version"]);
|
|
1216
|
+
if (result.exitCode !== 0) {
|
|
1217
|
+
throw new CodexNotFoundError();
|
|
1218
|
+
}
|
|
1219
|
+
} catch {
|
|
1220
|
+
throw new CodexNotFoundError();
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async function submitTask(prompt, options) {
|
|
1224
|
+
const args = ["cloud", "exec", prompt];
|
|
1225
|
+
if (options?.cwd) {
|
|
1226
|
+
args.push("-C", options.cwd);
|
|
1227
|
+
}
|
|
1228
|
+
const result = await runCodexCommand(args, { cwd: options?.cwd });
|
|
1229
|
+
const sessionId = parseSessionId(result.stdout);
|
|
1230
|
+
if (result.exitCode !== 0 || !sessionId) {
|
|
1231
|
+
throw new CodexError("submit_failed", "Failed to submit task to codex cloud.", result);
|
|
1232
|
+
}
|
|
1233
|
+
return sessionId;
|
|
1234
|
+
}
|
|
1235
|
+
async function resumeTask(sessionId, feedback) {
|
|
1236
|
+
const result = await runCodexCommand(["cloud", "exec", "resume", sessionId, feedback]);
|
|
1237
|
+
const nextSessionId = parseSessionId(result.stdout);
|
|
1238
|
+
if (result.exitCode !== 0 || !nextSessionId) {
|
|
1239
|
+
throw new CodexError("resume_failed", `Failed to resume codex cloud session ${sessionId}.`, result);
|
|
1240
|
+
}
|
|
1241
|
+
return nextSessionId;
|
|
1242
|
+
}
|
|
1243
|
+
async function pollStatus(sessionId, opts) {
|
|
1244
|
+
const startedAt = Date.now();
|
|
1245
|
+
for (; ; ) {
|
|
1246
|
+
const result = await runCodexCommand(["cloud", "status", sessionId]);
|
|
1247
|
+
if (result.exitCode !== 0) {
|
|
1248
|
+
throw new CodexError("poll_failed", `Failed to poll codex cloud status for session ${sessionId}.`, result);
|
|
1249
|
+
}
|
|
1250
|
+
const status = parseStatus(result.stdout);
|
|
1251
|
+
if (status === "completed" || status === "failed") {
|
|
1252
|
+
return status;
|
|
1253
|
+
}
|
|
1254
|
+
if (Date.now() - startedAt >= opts.timeoutMs) {
|
|
1255
|
+
throw new CodexTimeoutError(sessionId, opts.timeoutMs);
|
|
1256
|
+
}
|
|
1257
|
+
await new Promise((resolve) => {
|
|
1258
|
+
setTimeout(resolve, opts.intervalMs);
|
|
734
1259
|
});
|
|
735
|
-
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
async function getDiff(sessionId) {
|
|
1263
|
+
const result = await runCodexCommand(["cloud", "diff", sessionId]);
|
|
1264
|
+
if (result.exitCode !== 0) {
|
|
1265
|
+
throw new CodexError("poll_failed", `Failed to get diff for codex cloud session ${sessionId}.`, result);
|
|
1266
|
+
}
|
|
1267
|
+
if (!result.stdout.trim()) {
|
|
1268
|
+
throw new CodexError("diff_empty", `Diff was empty for codex cloud session ${sessionId}.`, result);
|
|
1269
|
+
}
|
|
1270
|
+
return result.stdout;
|
|
1271
|
+
}
|
|
1272
|
+
async function applyDiff(sessionId) {
|
|
1273
|
+
const result = await runCodexCommand(["cloud", "apply", sessionId]);
|
|
1274
|
+
if (result.exitCode !== 0) {
|
|
1275
|
+
throw new CodexError("apply_failed", `Failed to apply diff for codex cloud session ${sessionId}.`, result);
|
|
1276
|
+
}
|
|
736
1277
|
}
|
|
737
1278
|
async function exec(opts) {
|
|
738
|
-
const
|
|
739
|
-
const
|
|
740
|
-
if (
|
|
741
|
-
|
|
1279
|
+
const sessionId = await submitTask(opts.spec, { cwd: opts.cwd });
|
|
1280
|
+
const status = await pollStatus(sessionId, { intervalMs: 2e3, timeoutMs: CODEX_TIMEOUT_MS });
|
|
1281
|
+
if (status !== "completed") {
|
|
1282
|
+
throw new CodexError("submit_failed", `Codex cloud session ${sessionId} ended with status '${status}'.`);
|
|
742
1283
|
}
|
|
743
|
-
|
|
744
|
-
let liveLogsAttached = false;
|
|
745
|
-
const stdoutHandler = buildVerboseStreamHandler("stdout");
|
|
746
|
-
const stderrHandler = buildVerboseStreamHandler("stderr");
|
|
747
|
-
const heartbeat = opts.verbose ? setInterval(() => {
|
|
748
|
-
debug(`[codex] still running (${formatElapsed(startedAt)})`);
|
|
749
|
-
}, VERBOSE_HEARTBEAT_MS) : null;
|
|
750
|
-
const child = execFileCb(
|
|
751
|
-
"codex",
|
|
752
|
-
args,
|
|
753
|
-
{ cwd: opts.cwd, timeout: CODEX_TIMEOUT_MS, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
|
|
754
|
-
(error, stdout, stderr) => {
|
|
755
|
-
if (heartbeat) {
|
|
756
|
-
clearInterval(heartbeat);
|
|
757
|
-
}
|
|
758
|
-
stdoutHandler.flush();
|
|
759
|
-
stderrHandler.flush();
|
|
760
|
-
const normalizedStdout = stdout.trimEnd();
|
|
761
|
-
const normalizedStderr = stderr.trimEnd();
|
|
762
|
-
if (opts.verbose) {
|
|
763
|
-
debug(`[codex] finished in ${formatElapsed(startedAt)}`);
|
|
764
|
-
if (!liveLogsAttached && normalizedStdout) {
|
|
765
|
-
debug(normalizedStdout);
|
|
766
|
-
}
|
|
767
|
-
if (!liveLogsAttached && normalizedStderr) {
|
|
768
|
-
debug(normalizedStderr);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
if (error) {
|
|
772
|
-
const exitCode = typeof error.code === "number" ? error.code : 1;
|
|
773
|
-
if (opts.verbose) {
|
|
774
|
-
debug(`[codex] failed in ${formatElapsed(startedAt)} with exit ${String(exitCode)}`);
|
|
775
|
-
}
|
|
776
|
-
reject(new CodexError(normalizedStdout, normalizedStderr || error.message, exitCode));
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
resolve({
|
|
780
|
-
stdout: normalizedStdout,
|
|
781
|
-
stderr: normalizedStderr,
|
|
782
|
-
exitCode: 0
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
);
|
|
786
|
-
if (opts.verbose) {
|
|
787
|
-
const stdout = child.stdout;
|
|
788
|
-
const stderr = child.stderr;
|
|
789
|
-
if (stdout) {
|
|
790
|
-
liveLogsAttached = true;
|
|
791
|
-
stdout.on("data", stdoutHandler.onData);
|
|
792
|
-
}
|
|
793
|
-
if (stderr) {
|
|
794
|
-
liveLogsAttached = true;
|
|
795
|
-
stderr.on("data", stderrHandler.onData);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
});
|
|
1284
|
+
await applyDiff(sessionId);
|
|
799
1285
|
}
|
|
800
1286
|
|
|
801
1287
|
// src/lib/gh.ts
|
|
802
|
-
import { execFile as
|
|
1288
|
+
import { execFile as execFileCb } from "child_process";
|
|
803
1289
|
var GH_TIMEOUT_MS = 3e4;
|
|
804
1290
|
var GIT_TIMEOUT_MS = 3e4;
|
|
805
1291
|
var GhNotFoundError = class extends Error {
|
|
@@ -820,36 +1306,36 @@ var GitCommandError = class extends Error {
|
|
|
820
1306
|
};
|
|
821
1307
|
async function execGit(args, cwd) {
|
|
822
1308
|
await new Promise((resolve, reject) => {
|
|
823
|
-
|
|
824
|
-
if (
|
|
825
|
-
const exitCode = typeof
|
|
826
|
-
reject(new GitCommandError(args, exitCode, (stderr ||
|
|
1309
|
+
execFileCb("git", args, { cwd, timeout: GIT_TIMEOUT_MS, encoding: "utf8" }, (error2, _stdout, stderr) => {
|
|
1310
|
+
if (error2) {
|
|
1311
|
+
const exitCode = typeof error2.code === "number" ? error2.code : -1;
|
|
1312
|
+
reject(new GitCommandError(args, exitCode, (stderr || error2.message).trim()));
|
|
827
1313
|
return;
|
|
828
1314
|
}
|
|
829
1315
|
resolve();
|
|
830
1316
|
});
|
|
831
1317
|
});
|
|
832
1318
|
}
|
|
833
|
-
function isNoUpstreamError(
|
|
834
|
-
const text =
|
|
1319
|
+
function isNoUpstreamError(error2) {
|
|
1320
|
+
const text = error2.stderr.toLowerCase();
|
|
835
1321
|
return text.includes("no upstream configured for branch") || text.includes("has no upstream branch");
|
|
836
1322
|
}
|
|
837
1323
|
async function pushCurrentBranch(cwd) {
|
|
838
1324
|
try {
|
|
839
1325
|
await execGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd);
|
|
840
1326
|
await execGit(["push"], cwd);
|
|
841
|
-
} catch (
|
|
842
|
-
if (
|
|
1327
|
+
} catch (error2) {
|
|
1328
|
+
if (error2 instanceof GitCommandError && isNoUpstreamError(error2)) {
|
|
843
1329
|
await execGit(["push", "--set-upstream", "origin", "HEAD"], cwd);
|
|
844
1330
|
return;
|
|
845
1331
|
}
|
|
846
|
-
throw
|
|
1332
|
+
throw error2;
|
|
847
1333
|
}
|
|
848
1334
|
}
|
|
849
1335
|
async function checkGhAvailable() {
|
|
850
1336
|
await new Promise((resolve, reject) => {
|
|
851
|
-
|
|
852
|
-
if (
|
|
1337
|
+
execFileCb("gh", ["--version"], { timeout: GH_TIMEOUT_MS, encoding: "utf8" }, (error2) => {
|
|
1338
|
+
if (error2) {
|
|
853
1339
|
reject(new GhNotFoundError());
|
|
854
1340
|
return;
|
|
855
1341
|
}
|
|
@@ -861,17 +1347,17 @@ async function createPr(opts) {
|
|
|
861
1347
|
const base = opts.base ?? "main";
|
|
862
1348
|
try {
|
|
863
1349
|
await pushCurrentBranch(opts.cwd);
|
|
864
|
-
} catch (
|
|
865
|
-
throw new Error(`Failed to push current branch before creating PR: ${
|
|
1350
|
+
} catch (error2) {
|
|
1351
|
+
throw new Error(`Failed to push current branch before creating PR: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
866
1352
|
}
|
|
867
1353
|
return await new Promise((resolve, reject) => {
|
|
868
|
-
|
|
1354
|
+
execFileCb(
|
|
869
1355
|
"gh",
|
|
870
1356
|
["pr", "create", "--title", opts.title, "--body", opts.body, "--base", base],
|
|
871
1357
|
{ cwd: opts.cwd, timeout: GH_TIMEOUT_MS, encoding: "utf8" },
|
|
872
|
-
(
|
|
873
|
-
if (
|
|
874
|
-
reject(new Error((stderr ||
|
|
1358
|
+
(error2, stdout, stderr) => {
|
|
1359
|
+
if (error2) {
|
|
1360
|
+
reject(new Error((stderr || error2.message).trim()));
|
|
875
1361
|
return;
|
|
876
1362
|
}
|
|
877
1363
|
resolve(stdout.trim());
|
|
@@ -893,10 +1379,193 @@ async function requireGhAvailable() {
|
|
|
893
1379
|
}
|
|
894
1380
|
|
|
895
1381
|
// src/lib/review-loop.ts
|
|
896
|
-
import
|
|
1382
|
+
import path6 from "path";
|
|
1383
|
+
|
|
1384
|
+
// src/lib/copilot.ts
|
|
1385
|
+
import { spawn as spawn2 } from "child_process";
|
|
1386
|
+
var COPILOT_TIMEOUT_MS = 12e4;
|
|
1387
|
+
var CopilotReviewError = class extends Error {
|
|
1388
|
+
code;
|
|
1389
|
+
stdout;
|
|
1390
|
+
stderr;
|
|
1391
|
+
exitCode;
|
|
1392
|
+
constructor(code, message, details) {
|
|
1393
|
+
super(message);
|
|
1394
|
+
this.name = "CopilotReviewError";
|
|
1395
|
+
this.code = code;
|
|
1396
|
+
this.stdout = details?.stdout ?? "";
|
|
1397
|
+
this.stderr = details?.stderr ?? "";
|
|
1398
|
+
this.exitCode = details?.exitCode ?? 1;
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
var CopilotNotFoundError = class extends CopilotReviewError {
|
|
1402
|
+
constructor() {
|
|
1403
|
+
super("not_found", "copilot CLI not found. Install and authenticate GitHub Copilot CLI, then retry.");
|
|
1404
|
+
this.name = "CopilotNotFoundError";
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
function runCopilotCommand(args, opts) {
|
|
1408
|
+
return new Promise((resolve, reject) => {
|
|
1409
|
+
const child = spawn2("copilot", args, {
|
|
1410
|
+
cwd: opts?.cwd,
|
|
1411
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1412
|
+
});
|
|
1413
|
+
let stdout = "";
|
|
1414
|
+
let stderr = "";
|
|
1415
|
+
child.stdout.on("data", (chunk) => {
|
|
1416
|
+
stdout += chunk.toString();
|
|
1417
|
+
});
|
|
1418
|
+
child.stderr.on("data", (chunk) => {
|
|
1419
|
+
stderr += chunk.toString();
|
|
1420
|
+
});
|
|
1421
|
+
const timeout = setTimeout(() => {
|
|
1422
|
+
child.kill("SIGTERM");
|
|
1423
|
+
reject(new Error(`copilot command timed out after ${String(opts?.timeoutMs ?? COPILOT_TIMEOUT_MS)}ms`));
|
|
1424
|
+
}, opts?.timeoutMs ?? COPILOT_TIMEOUT_MS);
|
|
1425
|
+
child.once("error", (error2) => {
|
|
1426
|
+
clearTimeout(timeout);
|
|
1427
|
+
reject(error2);
|
|
1428
|
+
});
|
|
1429
|
+
child.once("close", (code) => {
|
|
1430
|
+
clearTimeout(timeout);
|
|
1431
|
+
resolve({
|
|
1432
|
+
stdout: stdout.trim(),
|
|
1433
|
+
stderr: stderr.trim(),
|
|
1434
|
+
exitCode: code ?? 1
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
function normalizeSeverity(raw) {
|
|
1440
|
+
const sev = typeof raw === "string" ? raw.toLowerCase() : "";
|
|
1441
|
+
if (sev === "error" || sev === "high" || sev === "critical") {
|
|
1442
|
+
return "critical";
|
|
1443
|
+
}
|
|
1444
|
+
if (sev === "warning" || sev === "medium") {
|
|
1445
|
+
return "important";
|
|
1446
|
+
}
|
|
1447
|
+
if (sev === "info" || sev === "low") {
|
|
1448
|
+
return "minor";
|
|
1449
|
+
}
|
|
1450
|
+
return "minor";
|
|
1451
|
+
}
|
|
1452
|
+
function maybeNumber(raw) {
|
|
1453
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
1454
|
+
return Math.trunc(raw);
|
|
1455
|
+
}
|
|
1456
|
+
if (typeof raw === "string") {
|
|
1457
|
+
const n = Number.parseInt(raw, 10);
|
|
1458
|
+
if (!Number.isNaN(n)) {
|
|
1459
|
+
return n;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return void 0;
|
|
1463
|
+
}
|
|
1464
|
+
function collectParsedComments(value, comments) {
|
|
1465
|
+
if (Array.isArray(value)) {
|
|
1466
|
+
for (const item of value) {
|
|
1467
|
+
collectParsedComments(item, comments);
|
|
1468
|
+
}
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (!value || typeof value !== "object") {
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const record = value;
|
|
1475
|
+
const message = [record.message, record.body, record.text].find((field) => typeof field === "string");
|
|
1476
|
+
if (typeof message === "string" && message.trim()) {
|
|
1477
|
+
comments.push({
|
|
1478
|
+
message: message.trim(),
|
|
1479
|
+
severity: record.severity ?? record.priority,
|
|
1480
|
+
file: record.file ?? record.path,
|
|
1481
|
+
line: record.line
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
for (const nested of Object.values(record)) {
|
|
1485
|
+
collectParsedComments(nested, comments);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function parseReviewComments(stdout) {
|
|
1489
|
+
const parsedObjects = [];
|
|
1490
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
1491
|
+
const trimmed = line.trim();
|
|
1492
|
+
if (!trimmed) {
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
try {
|
|
1496
|
+
parsedObjects.push(JSON.parse(trimmed));
|
|
1497
|
+
} catch {
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
const parsed = [];
|
|
1501
|
+
for (const entry of parsedObjects) {
|
|
1502
|
+
collectParsedComments(entry, parsed);
|
|
1503
|
+
}
|
|
1504
|
+
const deduped = /* @__PURE__ */ new Set();
|
|
1505
|
+
const output2 = [];
|
|
1506
|
+
for (const item of parsed) {
|
|
1507
|
+
const file = typeof item.file === "string" && item.file.length > 0 ? item.file : void 0;
|
|
1508
|
+
const line = maybeNumber(item.line);
|
|
1509
|
+
const lineKey = line === void 0 ? "" : String(line);
|
|
1510
|
+
const key = `${item.message}::${file ?? ""}::${lineKey}`;
|
|
1511
|
+
if (deduped.has(key)) {
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
deduped.add(key);
|
|
1515
|
+
output2.push({
|
|
1516
|
+
severity: normalizeSeverity(item.severity),
|
|
1517
|
+
file,
|
|
1518
|
+
line,
|
|
1519
|
+
comment: item.message
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
return output2;
|
|
1523
|
+
}
|
|
1524
|
+
async function checkCopilotAvailable() {
|
|
1525
|
+
try {
|
|
1526
|
+
const result = await runCopilotCommand(["--version"]);
|
|
1527
|
+
if (result.exitCode !== 0) {
|
|
1528
|
+
throw new CopilotNotFoundError();
|
|
1529
|
+
}
|
|
1530
|
+
} catch {
|
|
1531
|
+
throw new CopilotNotFoundError();
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
async function runCopilotReview(spec, opts) {
|
|
1535
|
+
const prompt = `Review the staged changes against the following spec.
|
|
1536
|
+
Report bugs, missing requirements, security issues, and logic errors.
|
|
1537
|
+
Ignore style issues.
|
|
1538
|
+
|
|
1539
|
+
Spec:
|
|
1540
|
+
${spec}`;
|
|
1541
|
+
let result;
|
|
1542
|
+
try {
|
|
1543
|
+
result = await runCopilotCommand(["-p", prompt, "--silent", "--output-format=json"], { cwd: opts?.cwd });
|
|
1544
|
+
} catch (error2) {
|
|
1545
|
+
throw new CopilotReviewError(
|
|
1546
|
+
"review_failed",
|
|
1547
|
+
`Failed to execute copilot review: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
if (result.exitCode !== 0) {
|
|
1551
|
+
throw new CopilotReviewError("review_failed", "Copilot review command failed.", result);
|
|
1552
|
+
}
|
|
1553
|
+
if (!result.stdout.trim()) {
|
|
1554
|
+
throw new CopilotReviewError("review_failed", "Copilot review produced no output.", result);
|
|
1555
|
+
}
|
|
1556
|
+
try {
|
|
1557
|
+
return parseReviewComments(result.stdout);
|
|
1558
|
+
} catch (error2) {
|
|
1559
|
+
throw new CopilotReviewError(
|
|
1560
|
+
"parse_failed",
|
|
1561
|
+
`Failed to parse Copilot review output: ${error2 instanceof Error ? error2.message : String(error2)}`,
|
|
1562
|
+
result
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
897
1566
|
|
|
898
1567
|
// src/lib/git.ts
|
|
899
|
-
import { execFile as
|
|
1568
|
+
import { execFile as execFileCb2 } from "child_process";
|
|
900
1569
|
var GIT_TIMEOUT_MS2 = 3e4;
|
|
901
1570
|
var GitError = class extends Error {
|
|
902
1571
|
command;
|
|
@@ -912,10 +1581,10 @@ var GitError = class extends Error {
|
|
|
912
1581
|
};
|
|
913
1582
|
async function exec2(args, cwd) {
|
|
914
1583
|
return new Promise((resolve, reject) => {
|
|
915
|
-
|
|
916
|
-
if (
|
|
917
|
-
const exitCode = typeof
|
|
918
|
-
reject(new GitError(args, exitCode, (stderr ||
|
|
1584
|
+
execFileCb2("git", args, { cwd, timeout: GIT_TIMEOUT_MS2, encoding: "utf8" }, (error2, stdout, stderr) => {
|
|
1585
|
+
if (error2) {
|
|
1586
|
+
const exitCode = typeof error2.code === "number" ? error2.code : -1;
|
|
1587
|
+
reject(new GitError(args, exitCode, (stderr || error2.message).trim()));
|
|
919
1588
|
return;
|
|
920
1589
|
}
|
|
921
1590
|
resolve(stdout.trimEnd());
|
|
@@ -926,11 +1595,11 @@ async function branchExists(name, cwd) {
|
|
|
926
1595
|
try {
|
|
927
1596
|
await exec2(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], cwd);
|
|
928
1597
|
return true;
|
|
929
|
-
} catch (
|
|
930
|
-
if (
|
|
1598
|
+
} catch (error2) {
|
|
1599
|
+
if (error2 instanceof GitError && error2.exitCode === 1) {
|
|
931
1600
|
return false;
|
|
932
1601
|
}
|
|
933
|
-
throw
|
|
1602
|
+
throw error2;
|
|
934
1603
|
}
|
|
935
1604
|
}
|
|
936
1605
|
async function createBranch(name, cwd) {
|
|
@@ -942,7 +1611,7 @@ async function createBranch(name, cwd) {
|
|
|
942
1611
|
async function checkoutBranch(name, cwd) {
|
|
943
1612
|
await exec2(["checkout", name], cwd);
|
|
944
1613
|
}
|
|
945
|
-
async function
|
|
1614
|
+
async function getDiff2(cwd, base) {
|
|
946
1615
|
if (base) {
|
|
947
1616
|
return exec2(["diff", `${base}..HEAD`], cwd);
|
|
948
1617
|
}
|
|
@@ -953,7 +1622,7 @@ function getBranchName(taskId, service) {
|
|
|
953
1622
|
}
|
|
954
1623
|
|
|
955
1624
|
// src/lib/review-loop.ts
|
|
956
|
-
function
|
|
1625
|
+
function formatElapsed(startedAt) {
|
|
957
1626
|
const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1e3));
|
|
958
1627
|
return `${String(seconds)}s`;
|
|
959
1628
|
}
|
|
@@ -975,12 +1644,12 @@ async function runReviewLoop(opts) {
|
|
|
975
1644
|
if (!serviceConfig) {
|
|
976
1645
|
throw new Error(`Unknown service in step: ${opts.step.service}`);
|
|
977
1646
|
}
|
|
978
|
-
const serviceRoot =
|
|
1647
|
+
const serviceRoot = path6.resolve(opts.projectRoot, serviceConfig.path);
|
|
979
1648
|
let iteration2 = opts.stepState.iteration;
|
|
980
1649
|
for (; ; ) {
|
|
981
1650
|
iteration(iteration2 + 1, opts.config.review.max_iterations);
|
|
982
1651
|
info(`Collecting git diff for service ${opts.step.service}`);
|
|
983
|
-
const diff = await
|
|
1652
|
+
const diff = await getDiff2(serviceRoot);
|
|
984
1653
|
if (opts.verbose) {
|
|
985
1654
|
info(`Diff collected (${String(diff.length)} chars)`);
|
|
986
1655
|
}
|
|
@@ -999,23 +1668,20 @@ async function runReviewLoop(opts) {
|
|
|
999
1668
|
info(`Requesting reviewer analysis (model: ${opts.config.review.model})`);
|
|
1000
1669
|
const reviewerStartedAt = Date.now();
|
|
1001
1670
|
const reviewerHeartbeat = opts.verbose ? setInterval(() => {
|
|
1002
|
-
info(`Waiting for reviewer response (${
|
|
1671
|
+
info(`Waiting for reviewer response (${formatElapsed(reviewerStartedAt)})`);
|
|
1003
1672
|
}, 15e3) : null;
|
|
1004
|
-
const
|
|
1005
|
-
spec: opts.step.spec,
|
|
1006
|
-
diff,
|
|
1007
|
-
model: opts.config.review.model
|
|
1008
|
-
}).finally(() => {
|
|
1673
|
+
const comments = await runCopilotReview(opts.step.spec, { cwd: serviceRoot }).finally(() => {
|
|
1009
1674
|
if (reviewerHeartbeat) {
|
|
1010
1675
|
clearInterval(reviewerHeartbeat);
|
|
1011
1676
|
}
|
|
1012
1677
|
});
|
|
1013
|
-
info(`Reviewer response received in ${
|
|
1678
|
+
info(`Reviewer response received in ${formatElapsed(reviewerStartedAt)}`);
|
|
1679
|
+
const review = { comments };
|
|
1014
1680
|
reviewSummary(review.comments);
|
|
1015
1681
|
info(`Requesting arbiter decision (model: ${opts.config.review.model})`);
|
|
1016
1682
|
const arbiterStartedAt = Date.now();
|
|
1017
1683
|
const arbiterHeartbeat = opts.verbose ? setInterval(() => {
|
|
1018
|
-
info(`Waiting for arbiter response (${
|
|
1684
|
+
info(`Waiting for arbiter response (${formatElapsed(arbiterStartedAt)})`);
|
|
1019
1685
|
}, 15e3) : null;
|
|
1020
1686
|
const arbiter = await opts.claude.runArbiter({
|
|
1021
1687
|
spec: opts.step.spec,
|
|
@@ -1027,7 +1693,7 @@ async function runReviewLoop(opts) {
|
|
|
1027
1693
|
clearInterval(arbiterHeartbeat);
|
|
1028
1694
|
}
|
|
1029
1695
|
});
|
|
1030
|
-
info(`Arbiter response received in ${
|
|
1696
|
+
info(`Arbiter response received in ${formatElapsed(arbiterStartedAt)}`);
|
|
1031
1697
|
info(`Arbiter decision: ${arbiter.decision} (${arbiter.summary})`);
|
|
1032
1698
|
saveIterationLog(opts.projectRoot, opts.taskId, opts.step.service, iteration2, {
|
|
1033
1699
|
diff,
|
|
@@ -1089,7 +1755,7 @@ async function runReviewLoop(opts) {
|
|
|
1089
1755
|
}
|
|
1090
1756
|
|
|
1091
1757
|
// src/commands/fix.ts
|
|
1092
|
-
function
|
|
1758
|
+
function fatalAndExit3(message) {
|
|
1093
1759
|
fatal(message);
|
|
1094
1760
|
process.exit(1);
|
|
1095
1761
|
}
|
|
@@ -1097,43 +1763,43 @@ async function runFix(feedback, options) {
|
|
|
1097
1763
|
try {
|
|
1098
1764
|
const projectRoot = findProjectRoot();
|
|
1099
1765
|
if (!projectRoot) {
|
|
1100
|
-
|
|
1766
|
+
fatalAndExit3("Not inside a vexdo project.");
|
|
1101
1767
|
}
|
|
1102
1768
|
const config = loadConfig(projectRoot);
|
|
1103
1769
|
const state = loadState(projectRoot);
|
|
1104
1770
|
if (!state) {
|
|
1105
|
-
|
|
1771
|
+
fatalAndExit3("No active task.");
|
|
1106
1772
|
}
|
|
1107
1773
|
if (!options.dryRun) {
|
|
1108
1774
|
requireAnthropicApiKey();
|
|
1109
1775
|
await checkCodexAvailable();
|
|
1110
1776
|
}
|
|
1111
|
-
const currentStep = state.steps.find((
|
|
1777
|
+
const currentStep = state.steps.find((step2) => step2.status === "in_progress" || step2.status === "pending");
|
|
1112
1778
|
if (!currentStep) {
|
|
1113
|
-
|
|
1779
|
+
fatalAndExit3("No in-progress step found in active task.");
|
|
1114
1780
|
}
|
|
1115
1781
|
const task = loadAndValidateTask(state.taskPath, config);
|
|
1116
|
-
const
|
|
1117
|
-
if (!
|
|
1118
|
-
|
|
1782
|
+
const step = task.steps.find((item) => item.service === currentStep.service);
|
|
1783
|
+
if (!step) {
|
|
1784
|
+
fatalAndExit3(`Could not locate task step for service '${currentStep.service}'.`);
|
|
1119
1785
|
}
|
|
1120
1786
|
if (!options.dryRun) {
|
|
1121
1787
|
const serviceConfig = config.services.find((service) => service.name === currentStep.service);
|
|
1122
1788
|
if (!serviceConfig) {
|
|
1123
|
-
|
|
1789
|
+
fatalAndExit3(`Unknown service in step: ${currentStep.service}`);
|
|
1124
1790
|
}
|
|
1125
1791
|
await exec({
|
|
1126
1792
|
spec: feedback,
|
|
1127
1793
|
model: config.codex.model,
|
|
1128
|
-
cwd:
|
|
1794
|
+
cwd: path7.resolve(projectRoot, serviceConfig.path),
|
|
1129
1795
|
verbose: options.verbose
|
|
1130
1796
|
});
|
|
1131
1797
|
}
|
|
1132
|
-
info(`Running review loop for service ${
|
|
1798
|
+
info(`Running review loop for service ${step.service}`);
|
|
1133
1799
|
const result = await runReviewLoop({
|
|
1134
1800
|
taskId: task.id,
|
|
1135
1801
|
task,
|
|
1136
|
-
step
|
|
1802
|
+
step,
|
|
1137
1803
|
stepState: currentStep,
|
|
1138
1804
|
projectRoot,
|
|
1139
1805
|
config,
|
|
@@ -1156,8 +1822,8 @@ async function runFix(feedback, options) {
|
|
|
1156
1822
|
if (!options.dryRun) {
|
|
1157
1823
|
saveState(projectRoot, state);
|
|
1158
1824
|
}
|
|
1159
|
-
} catch (
|
|
1160
|
-
|
|
1825
|
+
} catch (error2) {
|
|
1826
|
+
fatalAndExit3(error2 instanceof Error ? error2.message : String(error2));
|
|
1161
1827
|
}
|
|
1162
1828
|
}
|
|
1163
1829
|
function registerFixCommand(program2) {
|
|
@@ -1168,8 +1834,8 @@ function registerFixCommand(program2) {
|
|
|
1168
1834
|
}
|
|
1169
1835
|
|
|
1170
1836
|
// src/commands/init.ts
|
|
1171
|
-
import
|
|
1172
|
-
import
|
|
1837
|
+
import fs6 from "fs";
|
|
1838
|
+
import path8 from "path";
|
|
1173
1839
|
import { createInterface } from "readline/promises";
|
|
1174
1840
|
import { stdin as input, stdout as output } from "process";
|
|
1175
1841
|
import { stringify } from "yaml";
|
|
@@ -1198,24 +1864,24 @@ function parseMaxIterations(value) {
|
|
|
1198
1864
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ITERATIONS2;
|
|
1199
1865
|
}
|
|
1200
1866
|
function ensureGitignoreEntry(gitignorePath, entry) {
|
|
1201
|
-
if (!
|
|
1202
|
-
|
|
1867
|
+
if (!fs6.existsSync(gitignorePath)) {
|
|
1868
|
+
fs6.writeFileSync(gitignorePath, `${entry}
|
|
1203
1869
|
`, "utf8");
|
|
1204
1870
|
return true;
|
|
1205
1871
|
}
|
|
1206
|
-
const content =
|
|
1872
|
+
const content = fs6.readFileSync(gitignorePath, "utf8");
|
|
1207
1873
|
const lines = content.split(/\r?\n/).map((line) => line.trim());
|
|
1208
1874
|
if (lines.includes(entry)) {
|
|
1209
1875
|
return false;
|
|
1210
1876
|
}
|
|
1211
1877
|
const suffix = content.endsWith("\n") || content.length === 0 ? "" : "\n";
|
|
1212
|
-
|
|
1878
|
+
fs6.appendFileSync(gitignorePath, `${suffix}${entry}
|
|
1213
1879
|
`, "utf8");
|
|
1214
1880
|
return true;
|
|
1215
1881
|
}
|
|
1216
1882
|
async function runInit(projectRoot, prompt = defaultPrompt) {
|
|
1217
|
-
const configPath =
|
|
1218
|
-
if (
|
|
1883
|
+
const configPath = path8.join(projectRoot, ".vexdo.yml");
|
|
1884
|
+
if (fs6.existsSync(configPath)) {
|
|
1219
1885
|
warn("Found existing .vexdo.yml.");
|
|
1220
1886
|
const overwriteAnswer = await prompt("Overwrite existing .vexdo.yml? (y/N): ");
|
|
1221
1887
|
if (!parseBoolean(overwriteAnswer)) {
|
|
@@ -1251,20 +1917,20 @@ async function runInit(projectRoot, prompt = defaultPrompt) {
|
|
|
1251
1917
|
model: codexModelRaw.trim() || DEFAULT_CODEX_MODEL2
|
|
1252
1918
|
}
|
|
1253
1919
|
};
|
|
1254
|
-
|
|
1920
|
+
fs6.writeFileSync(configPath, stringify(config), "utf8");
|
|
1255
1921
|
const createdDirs = [];
|
|
1256
1922
|
for (const taskDir of TASK_DIRS) {
|
|
1257
|
-
const directory =
|
|
1258
|
-
|
|
1259
|
-
createdDirs.push(
|
|
1260
|
-
}
|
|
1261
|
-
const logDir =
|
|
1262
|
-
|
|
1263
|
-
createdDirs.push(
|
|
1264
|
-
const gitignorePath =
|
|
1923
|
+
const directory = path8.join(projectRoot, "tasks", taskDir);
|
|
1924
|
+
fs6.mkdirSync(directory, { recursive: true });
|
|
1925
|
+
createdDirs.push(path8.relative(projectRoot, directory));
|
|
1926
|
+
}
|
|
1927
|
+
const logDir = path8.join(projectRoot, ".vexdo", "logs");
|
|
1928
|
+
fs6.mkdirSync(logDir, { recursive: true });
|
|
1929
|
+
createdDirs.push(path8.relative(projectRoot, logDir));
|
|
1930
|
+
const gitignorePath = path8.join(projectRoot, ".gitignore");
|
|
1265
1931
|
const gitignoreUpdated = ensureGitignoreEntry(gitignorePath, ".vexdo/");
|
|
1266
1932
|
success("Initialized vexdo project.");
|
|
1267
|
-
info(`Created: ${
|
|
1933
|
+
info(`Created: ${path8.relative(projectRoot, configPath)}`);
|
|
1268
1934
|
info(`Created directories: ${createdDirs.join(", ")}`);
|
|
1269
1935
|
if (gitignoreUpdated) {
|
|
1270
1936
|
info("Updated .gitignore with .vexdo/");
|
|
@@ -1278,46 +1944,46 @@ function registerInitCommand(program2) {
|
|
|
1278
1944
|
}
|
|
1279
1945
|
|
|
1280
1946
|
// src/commands/logs.ts
|
|
1281
|
-
import
|
|
1282
|
-
import
|
|
1283
|
-
function
|
|
1947
|
+
import fs7 from "fs";
|
|
1948
|
+
import path9 from "path";
|
|
1949
|
+
function fatalAndExit4(message) {
|
|
1284
1950
|
fatal(message);
|
|
1285
1951
|
process.exit(1);
|
|
1286
1952
|
}
|
|
1287
1953
|
function runLogs(taskIdArg, options) {
|
|
1288
1954
|
const projectRoot = findProjectRoot();
|
|
1289
1955
|
if (!projectRoot) {
|
|
1290
|
-
|
|
1956
|
+
fatalAndExit4("Not inside a vexdo project.");
|
|
1291
1957
|
}
|
|
1292
1958
|
const state = loadState(projectRoot);
|
|
1293
1959
|
const taskId = taskIdArg ?? state?.taskId;
|
|
1294
1960
|
if (!taskId) {
|
|
1295
|
-
const base =
|
|
1296
|
-
if (!
|
|
1961
|
+
const base = path9.join(getStateDir(projectRoot), "logs");
|
|
1962
|
+
if (!fs7.existsSync(base)) {
|
|
1297
1963
|
info("No logs available.");
|
|
1298
1964
|
return;
|
|
1299
1965
|
}
|
|
1300
|
-
const tasks =
|
|
1966
|
+
const tasks = fs7.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
1301
1967
|
for (const dir of tasks) {
|
|
1302
1968
|
info(dir.name);
|
|
1303
1969
|
}
|
|
1304
1970
|
return;
|
|
1305
1971
|
}
|
|
1306
1972
|
const logsDir = getLogsDir(projectRoot, taskId);
|
|
1307
|
-
if (!
|
|
1308
|
-
|
|
1973
|
+
if (!fs7.existsSync(logsDir)) {
|
|
1974
|
+
fatalAndExit4(`No logs found for task '${taskId}'.`);
|
|
1309
1975
|
}
|
|
1310
|
-
const files =
|
|
1976
|
+
const files = fs7.readdirSync(logsDir).filter((name) => name.endsWith("-arbiter.json"));
|
|
1311
1977
|
for (const arbiterFile of files) {
|
|
1312
1978
|
const base = arbiterFile.replace(/-arbiter\.json$/, "");
|
|
1313
|
-
const arbiterPath =
|
|
1314
|
-
const reviewPath =
|
|
1315
|
-
const diffPath =
|
|
1316
|
-
const arbiter = JSON.parse(
|
|
1317
|
-
const review = JSON.parse(
|
|
1979
|
+
const arbiterPath = path9.join(logsDir, `${base}-arbiter.json`);
|
|
1980
|
+
const reviewPath = path9.join(logsDir, `${base}-review.json`);
|
|
1981
|
+
const diffPath = path9.join(logsDir, `${base}-diff.txt`);
|
|
1982
|
+
const arbiter = JSON.parse(fs7.readFileSync(arbiterPath, "utf8"));
|
|
1983
|
+
const review = JSON.parse(fs7.readFileSync(reviewPath, "utf8"));
|
|
1318
1984
|
info(`${base}: decision=${arbiter.decision}, comments=${String(review.comments?.length ?? 0)}, summary=${arbiter.summary}`);
|
|
1319
1985
|
if (options?.full) {
|
|
1320
|
-
console.log(
|
|
1986
|
+
console.log(fs7.readFileSync(diffPath, "utf8"));
|
|
1321
1987
|
console.log(JSON.stringify(review, null, 2));
|
|
1322
1988
|
console.log(JSON.stringify(arbiter, null, 2));
|
|
1323
1989
|
}
|
|
@@ -1330,8 +1996,8 @@ function registerLogsCommand(program2) {
|
|
|
1330
1996
|
}
|
|
1331
1997
|
|
|
1332
1998
|
// src/commands/review.ts
|
|
1333
|
-
import
|
|
1334
|
-
function
|
|
1999
|
+
import fs8 from "fs";
|
|
2000
|
+
function fatalAndExit5(message) {
|
|
1335
2001
|
fatal(message);
|
|
1336
2002
|
process.exit(1);
|
|
1337
2003
|
}
|
|
@@ -1339,33 +2005,33 @@ async function runReview(options) {
|
|
|
1339
2005
|
try {
|
|
1340
2006
|
const projectRoot = findProjectRoot();
|
|
1341
2007
|
if (!projectRoot) {
|
|
1342
|
-
|
|
2008
|
+
fatalAndExit5("Not inside a vexdo project.");
|
|
1343
2009
|
}
|
|
1344
2010
|
const config = loadConfig(projectRoot);
|
|
1345
2011
|
const state = loadState(projectRoot);
|
|
1346
2012
|
if (!state) {
|
|
1347
|
-
|
|
2013
|
+
fatalAndExit5("No active task.");
|
|
1348
2014
|
}
|
|
1349
2015
|
if (!options.dryRun) {
|
|
1350
2016
|
requireAnthropicApiKey();
|
|
1351
2017
|
}
|
|
1352
|
-
const currentStep = state.steps.find((
|
|
2018
|
+
const currentStep = state.steps.find((step2) => step2.status === "in_progress" || step2.status === "pending");
|
|
1353
2019
|
if (!currentStep) {
|
|
1354
|
-
|
|
2020
|
+
fatalAndExit5("No in-progress step found in active task.");
|
|
1355
2021
|
}
|
|
1356
|
-
if (!
|
|
1357
|
-
|
|
2022
|
+
if (!fs8.existsSync(state.taskPath)) {
|
|
2023
|
+
fatalAndExit5(`Task file not found: ${state.taskPath}`);
|
|
1358
2024
|
}
|
|
1359
2025
|
const task = loadAndValidateTask(state.taskPath, config);
|
|
1360
|
-
const
|
|
1361
|
-
if (!
|
|
1362
|
-
|
|
2026
|
+
const step = task.steps.find((item) => item.service === currentStep.service);
|
|
2027
|
+
if (!step) {
|
|
2028
|
+
fatalAndExit5(`Could not locate task step for service '${currentStep.service}'.`);
|
|
1363
2029
|
}
|
|
1364
|
-
info(`Running review loop for service ${
|
|
2030
|
+
info(`Running review loop for service ${step.service}`);
|
|
1365
2031
|
const result = await runReviewLoop({
|
|
1366
2032
|
taskId: task.id,
|
|
1367
2033
|
task,
|
|
1368
|
-
step
|
|
2034
|
+
step,
|
|
1369
2035
|
stepState: currentStep,
|
|
1370
2036
|
projectRoot,
|
|
1371
2037
|
config,
|
|
@@ -1376,9 +2042,9 @@ async function runReview(options) {
|
|
|
1376
2042
|
if (result.decision === "escalate") {
|
|
1377
2043
|
escalation({
|
|
1378
2044
|
taskId: task.id,
|
|
1379
|
-
service:
|
|
2045
|
+
service: step.service,
|
|
1380
2046
|
iteration: result.finalIteration,
|
|
1381
|
-
spec:
|
|
2047
|
+
spec: step.spec,
|
|
1382
2048
|
diff: "",
|
|
1383
2049
|
reviewComments: result.lastReviewComments,
|
|
1384
2050
|
arbiterReasoning: result.lastArbiterResult.reasoning,
|
|
@@ -1398,9 +2064,9 @@ async function runReview(options) {
|
|
|
1398
2064
|
if (!options.dryRun) {
|
|
1399
2065
|
saveState(projectRoot, state);
|
|
1400
2066
|
}
|
|
1401
|
-
} catch (
|
|
1402
|
-
const message =
|
|
1403
|
-
|
|
2067
|
+
} catch (error2) {
|
|
2068
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2069
|
+
fatalAndExit5(message);
|
|
1404
2070
|
}
|
|
1405
2071
|
}
|
|
1406
2072
|
function registerReviewCommand(program2) {
|
|
@@ -1411,25 +2077,103 @@ function registerReviewCommand(program2) {
|
|
|
1411
2077
|
}
|
|
1412
2078
|
|
|
1413
2079
|
// src/commands/start.ts
|
|
1414
|
-
import
|
|
2080
|
+
import path11 from "path";
|
|
2081
|
+
|
|
2082
|
+
// src/lib/runner.ts
|
|
2083
|
+
async function runStepsConcurrently(steps, config, runStep) {
|
|
2084
|
+
const maxConcurrent = config.maxConcurrent ?? Number.POSITIVE_INFINITY;
|
|
2085
|
+
if (!Number.isFinite(maxConcurrent) && maxConcurrent !== Number.POSITIVE_INFINITY) {
|
|
2086
|
+
throw new Error("maxConcurrent must be a finite number when specified");
|
|
2087
|
+
}
|
|
2088
|
+
if (Number.isFinite(maxConcurrent) && (!Number.isInteger(maxConcurrent) || maxConcurrent <= 0)) {
|
|
2089
|
+
throw new Error("maxConcurrent must be a positive integer when specified");
|
|
2090
|
+
}
|
|
2091
|
+
const stepByService = new Map(steps.map((step) => [step.service, step]));
|
|
2092
|
+
for (const step of steps) {
|
|
2093
|
+
for (const dependency of step.depends_on ?? []) {
|
|
2094
|
+
if (!stepByService.has(dependency)) {
|
|
2095
|
+
throw new Error(`Unknown dependency '${dependency}' for service '${step.service}'`);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
const results = /* @__PURE__ */ new Map();
|
|
2100
|
+
const running = /* @__PURE__ */ new Map();
|
|
2101
|
+
while (results.size < steps.length) {
|
|
2102
|
+
let dispatched = false;
|
|
2103
|
+
for (const step of steps) {
|
|
2104
|
+
if (results.has(step.service) || running.has(step.service)) {
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
const dependencies = step.depends_on ?? [];
|
|
2108
|
+
const depResults = dependencies.map((service) => results.get(service));
|
|
2109
|
+
if (depResults.some((result) => result === void 0)) {
|
|
2110
|
+
continue;
|
|
2111
|
+
}
|
|
2112
|
+
const blockedByDependencyFailure = depResults.some((result) => result?.status !== "done");
|
|
2113
|
+
if (blockedByDependencyFailure) {
|
|
2114
|
+
results.set(step.service, {
|
|
2115
|
+
service: step.service,
|
|
2116
|
+
status: "failed",
|
|
2117
|
+
error: "dependency_failed"
|
|
2118
|
+
});
|
|
2119
|
+
dispatched = true;
|
|
2120
|
+
continue;
|
|
2121
|
+
}
|
|
2122
|
+
if (running.size >= maxConcurrent) {
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
dispatched = true;
|
|
2126
|
+
const runningPromise = runStep(step).then((result) => {
|
|
2127
|
+
results.set(step.service, result);
|
|
2128
|
+
}).catch((error2) => {
|
|
2129
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2130
|
+
results.set(step.service, {
|
|
2131
|
+
service: step.service,
|
|
2132
|
+
status: "failed",
|
|
2133
|
+
error: message
|
|
2134
|
+
});
|
|
2135
|
+
}).finally(() => {
|
|
2136
|
+
running.delete(step.service);
|
|
2137
|
+
});
|
|
2138
|
+
running.set(step.service, runningPromise);
|
|
2139
|
+
}
|
|
2140
|
+
if (!dispatched) {
|
|
2141
|
+
if (running.size === 0) {
|
|
2142
|
+
throw new Error("Unable to schedule all steps; dependency graph may contain a cycle.");
|
|
2143
|
+
}
|
|
2144
|
+
await Promise.race(running.values());
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
return steps.map((step) => {
|
|
2148
|
+
const result = results.get(step.service);
|
|
2149
|
+
if (!result) {
|
|
2150
|
+
return {
|
|
2151
|
+
service: step.service,
|
|
2152
|
+
status: "failed",
|
|
2153
|
+
error: "not_executed"
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
return result;
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
1415
2159
|
|
|
1416
2160
|
// src/lib/submit-task.ts
|
|
1417
|
-
import
|
|
1418
|
-
import
|
|
2161
|
+
import fs9 from "fs";
|
|
2162
|
+
import path10 from "path";
|
|
1419
2163
|
async function submitActiveTask(projectRoot, config, state) {
|
|
1420
|
-
for (const
|
|
1421
|
-
if (
|
|
2164
|
+
for (const step of state.steps) {
|
|
2165
|
+
if (step.status !== "done" && step.status !== "in_progress") {
|
|
1422
2166
|
continue;
|
|
1423
2167
|
}
|
|
1424
|
-
const service = config.services.find((item) => item.name ===
|
|
2168
|
+
const service = config.services.find((item) => item.name === step.service);
|
|
1425
2169
|
if (!service) {
|
|
1426
|
-
throw new Error(`Unknown service in state: ${
|
|
2170
|
+
throw new Error(`Unknown service in state: ${step.service}`);
|
|
1427
2171
|
}
|
|
1428
|
-
const servicePath =
|
|
2172
|
+
const servicePath = path10.resolve(projectRoot, service.path);
|
|
1429
2173
|
const body = `Task: ${state.taskId}
|
|
1430
|
-
Service: ${
|
|
2174
|
+
Service: ${step.service}`;
|
|
1431
2175
|
const url = await createPr({
|
|
1432
|
-
title: `${state.taskTitle} [${
|
|
2176
|
+
title: `${state.taskTitle} [${step.service}]`,
|
|
1433
2177
|
body,
|
|
1434
2178
|
base: "main",
|
|
1435
2179
|
cwd: servicePath
|
|
@@ -1439,7 +2183,7 @@ Service: ${step2.service}`;
|
|
|
1439
2183
|
state.status = "done";
|
|
1440
2184
|
saveState(projectRoot, state);
|
|
1441
2185
|
const doneDir = ensureTaskDirectory(projectRoot, "done");
|
|
1442
|
-
if (
|
|
2186
|
+
if (fs9.existsSync(state.taskPath)) {
|
|
1443
2187
|
state.taskPath = moveTaskFileAtomically(state.taskPath, doneDir);
|
|
1444
2188
|
saveState(projectRoot, state);
|
|
1445
2189
|
}
|
|
@@ -1447,7 +2191,9 @@ Service: ${step2.service}`;
|
|
|
1447
2191
|
}
|
|
1448
2192
|
|
|
1449
2193
|
// src/commands/start.ts
|
|
1450
|
-
|
|
2194
|
+
var POLL_INTERVAL_MS = 5e3;
|
|
2195
|
+
var POLL_TIMEOUT_MS = 10 * 6e4;
|
|
2196
|
+
function fatalAndExit6(message, hint) {
|
|
1451
2197
|
fatal(message, hint);
|
|
1452
2198
|
process.exit(1);
|
|
1453
2199
|
}
|
|
@@ -1455,17 +2201,18 @@ async function runStart(taskFile, options) {
|
|
|
1455
2201
|
try {
|
|
1456
2202
|
const projectRoot = findProjectRoot();
|
|
1457
2203
|
if (!projectRoot) {
|
|
1458
|
-
|
|
2204
|
+
fatalAndExit6("Not inside a vexdo project. Could not find .vexdo.yml.");
|
|
1459
2205
|
}
|
|
1460
2206
|
const config = loadConfig(projectRoot);
|
|
1461
|
-
const taskPath =
|
|
2207
|
+
const taskPath = path11.resolve(taskFile);
|
|
1462
2208
|
const task = loadAndValidateTask(taskPath, config);
|
|
1463
2209
|
if (hasActiveTask(projectRoot) && !options.resume) {
|
|
1464
|
-
|
|
2210
|
+
fatalAndExit6("An active task already exists.", "Use --resume to continue or 'vexdo abort' to cancel.");
|
|
1465
2211
|
}
|
|
1466
2212
|
if (!options.dryRun) {
|
|
1467
2213
|
requireAnthropicApiKey();
|
|
1468
2214
|
await checkCodexAvailable();
|
|
2215
|
+
await checkCopilotAvailable();
|
|
1469
2216
|
}
|
|
1470
2217
|
let state = loadState(projectRoot);
|
|
1471
2218
|
if (!options.resume) {
|
|
@@ -1480,94 +2227,123 @@ async function runStart(taskFile, options) {
|
|
|
1480
2227
|
}
|
|
1481
2228
|
}
|
|
1482
2229
|
if (!state) {
|
|
1483
|
-
|
|
2230
|
+
fatalAndExit6("No resumable task state found.");
|
|
1484
2231
|
}
|
|
1485
2232
|
const claude = new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? "");
|
|
1486
|
-
const
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
2233
|
+
const stepResults = await runStepsConcurrently(
|
|
2234
|
+
task.steps,
|
|
2235
|
+
{ maxConcurrent: config.maxConcurrent },
|
|
2236
|
+
async (step) => {
|
|
2237
|
+
const scopedLogger = withPrefix(`[${step.service}]`);
|
|
2238
|
+
const latestState = options.dryRun ? state : loadState(projectRoot);
|
|
2239
|
+
const stepState = latestState?.steps.find((item) => item.service === step.service);
|
|
2240
|
+
if (!stepState) {
|
|
2241
|
+
return { service: step.service, status: "failed", error: "step_state_missing" };
|
|
2242
|
+
}
|
|
2243
|
+
if (stepState.status === "done") {
|
|
2244
|
+
return { service: step.service, status: "done", sessionId: stepState.session_id };
|
|
2245
|
+
}
|
|
2246
|
+
const serviceCfg = config.services.find((service) => service.name === step.service);
|
|
2247
|
+
if (!serviceCfg) {
|
|
2248
|
+
return { service: step.service, status: "failed", error: `Unknown service in step: ${step.service}` };
|
|
2249
|
+
}
|
|
2250
|
+
const serviceRoot = path11.resolve(projectRoot, serviceCfg.path);
|
|
2251
|
+
const branch = stepState.branch ?? getBranchName(task.id, step.service);
|
|
2252
|
+
try {
|
|
2253
|
+
if (!options.dryRun) {
|
|
2254
|
+
if (options.resume) {
|
|
2255
|
+
if (stepState.branch) {
|
|
2256
|
+
await checkoutBranch(stepState.branch, serviceRoot);
|
|
2257
|
+
} else {
|
|
2258
|
+
await createBranch(branch, serviceRoot);
|
|
2259
|
+
}
|
|
2260
|
+
} else {
|
|
2261
|
+
await createBranch(branch, serviceRoot);
|
|
2262
|
+
}
|
|
2263
|
+
await updateStep(projectRoot, task.id, step.service, {
|
|
2264
|
+
status: "in_progress",
|
|
2265
|
+
branch
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
if (options.dryRun) {
|
|
2269
|
+
scopedLogger.info(`[dry-run] Would run codex cloud implementation for service ${step.service}`);
|
|
2270
|
+
return { service: step.service, status: "done" };
|
|
2271
|
+
}
|
|
2272
|
+
scopedLogger.info("Submitting to Codex Cloud...");
|
|
2273
|
+
const submissionSession = stepState.session_id ?? await submitTask(step.spec, { cwd: serviceRoot });
|
|
2274
|
+
await updateStep(projectRoot, task.id, step.service, { session_id: submissionSession });
|
|
2275
|
+
const execution = await runCloudReviewLoop({
|
|
2276
|
+
taskId: task.id,
|
|
2277
|
+
service: step.service,
|
|
2278
|
+
spec: step.spec,
|
|
2279
|
+
sessionId: submissionSession,
|
|
2280
|
+
stepState: {
|
|
2281
|
+
iteration: stepState.iteration,
|
|
2282
|
+
session_id: submissionSession
|
|
2283
|
+
},
|
|
2284
|
+
projectRoot,
|
|
2285
|
+
config,
|
|
2286
|
+
claude,
|
|
2287
|
+
verbose: options.verbose,
|
|
2288
|
+
log: scopedLogger,
|
|
2289
|
+
serviceRoot
|
|
2290
|
+
});
|
|
2291
|
+
await updateStep(projectRoot, task.id, step.service, {
|
|
2292
|
+
lastReviewComments: execution.lastReviewComments,
|
|
2293
|
+
lastArbiterResult: execution.lastArbiterResult,
|
|
2294
|
+
iteration: execution.finalIteration,
|
|
2295
|
+
session_id: execution.sessionId
|
|
2296
|
+
});
|
|
2297
|
+
if (execution.lastArbiterResult.decision === "escalate") {
|
|
2298
|
+
escalation({
|
|
2299
|
+
taskId: task.id,
|
|
2300
|
+
service: step.service,
|
|
2301
|
+
iteration: execution.finalIteration,
|
|
2302
|
+
spec: step.spec,
|
|
2303
|
+
diff: "",
|
|
2304
|
+
reviewComments: execution.lastReviewComments,
|
|
2305
|
+
arbiterReasoning: execution.lastArbiterResult.reasoning,
|
|
2306
|
+
summary: execution.lastArbiterResult.summary
|
|
2307
|
+
});
|
|
2308
|
+
await updateStep(projectRoot, task.id, step.service, { status: "escalated" });
|
|
2309
|
+
return { service: step.service, status: "escalated", sessionId: execution.sessionId };
|
|
1501
2310
|
}
|
|
2311
|
+
await updateStep(projectRoot, task.id, step.service, { status: "done" });
|
|
2312
|
+
scopedLogger.success("Review passed \u2014 ready for PR");
|
|
2313
|
+
return { service: step.service, status: "done", sessionId: execution.sessionId };
|
|
2314
|
+
} catch (error2) {
|
|
2315
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2316
|
+
if (!options.dryRun) {
|
|
2317
|
+
await updateStep(projectRoot, task.id, step.service, { status: "failed" });
|
|
2318
|
+
}
|
|
2319
|
+
scopedLogger.error(message);
|
|
2320
|
+
if (error2 instanceof CodexError) {
|
|
2321
|
+
if (error2.stderr) scopedLogger.error(`stderr: ${error2.stderr}`);
|
|
2322
|
+
if (error2.stdout) scopedLogger.debug(`stdout: ${error2.stdout}`);
|
|
2323
|
+
}
|
|
2324
|
+
return { service: step.service, status: "failed", error: message };
|
|
1502
2325
|
}
|
|
1503
2326
|
}
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
const serviceRoot = path10.resolve(projectRoot, serviceCfg.path);
|
|
1510
|
-
const branch = getBranchName(task.id, step2.service);
|
|
1511
|
-
if (!options.dryRun) {
|
|
1512
|
-
if (options.resume) {
|
|
1513
|
-
await checkoutBranch(stepState.branch ?? branch, serviceRoot);
|
|
1514
|
-
} else {
|
|
1515
|
-
await createBranch(branch, serviceRoot);
|
|
2327
|
+
);
|
|
2328
|
+
if (!options.dryRun) {
|
|
2329
|
+
for (const result of stepResults) {
|
|
2330
|
+
if (result.status === "failed" && result.error === "dependency_failed") {
|
|
2331
|
+
await updateStep(projectRoot, task.id, result.service, { status: "failed" });
|
|
1516
2332
|
}
|
|
1517
2333
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
2334
|
+
}
|
|
2335
|
+
const hasEscalation = stepResults.some((result) => result.status === "escalated");
|
|
2336
|
+
const hasFailure = stepResults.some((result) => result.status === "failed");
|
|
2337
|
+
state = loadState(projectRoot) ?? state;
|
|
2338
|
+
if (hasEscalation || hasFailure) {
|
|
2339
|
+
state.status = hasEscalation ? "escalated" : "blocked";
|
|
1520
2340
|
if (!options.dryRun) {
|
|
1521
2341
|
saveState(projectRoot, state);
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
info(`Running codex implementation for service ${step2.service}`);
|
|
1525
|
-
await exec({
|
|
1526
|
-
spec: step2.spec,
|
|
1527
|
-
model: config.codex.model,
|
|
1528
|
-
cwd: serviceRoot,
|
|
1529
|
-
verbose: options.verbose
|
|
1530
|
-
});
|
|
1531
|
-
} else if (options.dryRun) {
|
|
1532
|
-
info(`[dry-run] Would run codex for service ${step2.service}`);
|
|
1533
|
-
}
|
|
1534
|
-
info(`Starting review loop for service ${step2.service}`);
|
|
1535
|
-
const result = await runReviewLoop({
|
|
1536
|
-
taskId: task.id,
|
|
1537
|
-
task,
|
|
1538
|
-
step: step2,
|
|
1539
|
-
stepState,
|
|
1540
|
-
projectRoot,
|
|
1541
|
-
config,
|
|
1542
|
-
claude,
|
|
1543
|
-
dryRun: options.dryRun,
|
|
1544
|
-
verbose: options.verbose
|
|
1545
|
-
});
|
|
1546
|
-
if (result.decision === "escalate") {
|
|
1547
|
-
escalation({
|
|
1548
|
-
taskId: task.id,
|
|
1549
|
-
service: step2.service,
|
|
1550
|
-
iteration: result.finalIteration,
|
|
1551
|
-
spec: step2.spec,
|
|
1552
|
-
diff: "",
|
|
1553
|
-
reviewComments: result.lastReviewComments,
|
|
1554
|
-
arbiterReasoning: result.lastArbiterResult.reasoning,
|
|
1555
|
-
summary: result.lastArbiterResult.summary
|
|
1556
|
-
});
|
|
1557
|
-
stepState.status = "escalated";
|
|
1558
|
-
state.status = "escalated";
|
|
1559
|
-
if (!options.dryRun) {
|
|
1560
|
-
saveState(projectRoot, state);
|
|
1561
|
-
const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
|
|
1562
|
-
state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
|
|
1563
|
-
saveState(projectRoot, state);
|
|
1564
|
-
}
|
|
1565
|
-
process.exit(1);
|
|
1566
|
-
}
|
|
1567
|
-
stepState.status = "done";
|
|
1568
|
-
if (!options.dryRun) {
|
|
2342
|
+
const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
|
|
2343
|
+
state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
|
|
1569
2344
|
saveState(projectRoot, state);
|
|
1570
2345
|
}
|
|
2346
|
+
process.exit(1);
|
|
1571
2347
|
}
|
|
1572
2348
|
state.status = "review";
|
|
1573
2349
|
if (!options.dryRun) {
|
|
@@ -1580,9 +2356,94 @@ async function runStart(taskFile, options) {
|
|
|
1580
2356
|
return;
|
|
1581
2357
|
}
|
|
1582
2358
|
success("Task ready for PR. Run 'vexdo submit' to create PR.");
|
|
1583
|
-
} catch (
|
|
1584
|
-
const message =
|
|
1585
|
-
|
|
2359
|
+
} catch (error2) {
|
|
2360
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2361
|
+
fatalAndExit6(message);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
async function runCloudReviewLoop(opts) {
|
|
2365
|
+
let sessionId = opts.sessionId;
|
|
2366
|
+
let iteration2 = opts.stepState.iteration;
|
|
2367
|
+
for (; ; ) {
|
|
2368
|
+
opts.log.iteration(iteration2 + 1, opts.config.review.max_iterations);
|
|
2369
|
+
opts.log.info(`Polling codex cloud session ${sessionId}`);
|
|
2370
|
+
const status = await pollStatus(sessionId, {
|
|
2371
|
+
intervalMs: POLL_INTERVAL_MS,
|
|
2372
|
+
timeoutMs: POLL_TIMEOUT_MS
|
|
2373
|
+
});
|
|
2374
|
+
if (status !== "completed") {
|
|
2375
|
+
return {
|
|
2376
|
+
sessionId,
|
|
2377
|
+
finalIteration: iteration2,
|
|
2378
|
+
lastReviewComments: [],
|
|
2379
|
+
lastArbiterResult: {
|
|
2380
|
+
decision: "escalate",
|
|
2381
|
+
reasoning: `Codex Cloud session ended with status '${status}'.`,
|
|
2382
|
+
summary: "Escalated due to codex cloud execution failure."
|
|
2383
|
+
}
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
opts.log.success("Codex task completed");
|
|
2387
|
+
opts.log.info(`Retrieving diff for session ${sessionId}`);
|
|
2388
|
+
const diff = await getDiff(sessionId);
|
|
2389
|
+
opts.log.info(`Running review (iteration ${String(iteration2 + 1)})...`);
|
|
2390
|
+
await applyDiff(sessionId);
|
|
2391
|
+
const comments = await runCopilotReview(opts.spec, { cwd: opts.serviceRoot }).finally(async () => {
|
|
2392
|
+
await exec2(["checkout", "."], opts.serviceRoot);
|
|
2393
|
+
});
|
|
2394
|
+
const review = { comments };
|
|
2395
|
+
opts.log.reviewSummary(review.comments);
|
|
2396
|
+
opts.log.info(`Requesting arbiter decision (model: ${opts.config.review.model})`);
|
|
2397
|
+
const arbiter = await opts.claude.runArbiter({
|
|
2398
|
+
spec: opts.spec,
|
|
2399
|
+
diff,
|
|
2400
|
+
reviewComments: review.comments,
|
|
2401
|
+
model: opts.config.review.model
|
|
2402
|
+
});
|
|
2403
|
+
saveIterationLog(opts.projectRoot, opts.taskId, opts.service, iteration2, {
|
|
2404
|
+
diff,
|
|
2405
|
+
review,
|
|
2406
|
+
arbiter
|
|
2407
|
+
});
|
|
2408
|
+
opts.stepState.iteration = iteration2;
|
|
2409
|
+
opts.stepState.session_id = sessionId;
|
|
2410
|
+
if (arbiter.decision === "submit" || arbiter.decision === "escalate") {
|
|
2411
|
+
return {
|
|
2412
|
+
sessionId,
|
|
2413
|
+
finalIteration: iteration2,
|
|
2414
|
+
lastReviewComments: review.comments,
|
|
2415
|
+
lastArbiterResult: arbiter
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
if (iteration2 >= opts.config.review.max_iterations) {
|
|
2419
|
+
return {
|
|
2420
|
+
sessionId,
|
|
2421
|
+
finalIteration: iteration2,
|
|
2422
|
+
lastReviewComments: review.comments,
|
|
2423
|
+
lastArbiterResult: {
|
|
2424
|
+
decision: "escalate",
|
|
2425
|
+
reasoning: "Max review iterations reached while arbiter still requested fixes.",
|
|
2426
|
+
summary: "Escalated because maximum iterations were exhausted."
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
if (!arbiter.feedback_for_codex) {
|
|
2431
|
+
return {
|
|
2432
|
+
sessionId,
|
|
2433
|
+
finalIteration: iteration2,
|
|
2434
|
+
lastReviewComments: review.comments,
|
|
2435
|
+
lastArbiterResult: {
|
|
2436
|
+
decision: "escalate",
|
|
2437
|
+
reasoning: "Arbiter returned fix decision without feedback_for_codex.",
|
|
2438
|
+
summary: "Escalated because fix instructions were missing."
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
opts.log.warn(`Review requested fixes (iteration ${String(iteration2 + 1)}/${String(opts.config.review.max_iterations)})`);
|
|
2443
|
+
sessionId = await resumeTask(sessionId, arbiter.feedback_for_codex);
|
|
2444
|
+
opts.stepState.session_id = sessionId;
|
|
2445
|
+
iteration2 += 1;
|
|
2446
|
+
opts.stepState.iteration = iteration2;
|
|
1586
2447
|
}
|
|
1587
2448
|
}
|
|
1588
2449
|
function registerStartCommand(program2) {
|
|
@@ -1593,11 +2454,11 @@ function registerStartCommand(program2) {
|
|
|
1593
2454
|
}
|
|
1594
2455
|
|
|
1595
2456
|
// src/commands/status.ts
|
|
1596
|
-
function
|
|
2457
|
+
function fatalAndExit7(message) {
|
|
1597
2458
|
fatal(message);
|
|
1598
2459
|
process.exit(1);
|
|
1599
2460
|
}
|
|
1600
|
-
function
|
|
2461
|
+
function formatElapsed2(startedAt) {
|
|
1601
2462
|
const elapsedMs = Date.now() - new Date(startedAt).getTime();
|
|
1602
2463
|
const minutes = Math.floor(elapsedMs / 1e3 / 60);
|
|
1603
2464
|
const hours = Math.floor(minutes / 60);
|
|
@@ -1609,23 +2470,23 @@ function formatElapsed3(startedAt) {
|
|
|
1609
2470
|
function runStatus() {
|
|
1610
2471
|
const projectRoot = findProjectRoot();
|
|
1611
2472
|
if (!projectRoot) {
|
|
1612
|
-
|
|
2473
|
+
fatalAndExit7("Not inside a vexdo project.");
|
|
1613
2474
|
}
|
|
1614
2475
|
const state = loadState(projectRoot);
|
|
1615
2476
|
if (!state) {
|
|
1616
|
-
|
|
2477
|
+
fatalAndExit7("No active task.");
|
|
1617
2478
|
}
|
|
1618
2479
|
info(`Task: ${state.taskId} \u2014 ${state.taskTitle}`);
|
|
1619
2480
|
info(`Status: ${state.status}`);
|
|
1620
2481
|
console.log("service | status | iteration | branch");
|
|
1621
|
-
for (const
|
|
1622
|
-
console.log(`${
|
|
2482
|
+
for (const step of state.steps) {
|
|
2483
|
+
console.log(`${step.service} | ${step.status} | ${String(step.iteration)} | ${step.branch ?? "-"}`);
|
|
1623
2484
|
}
|
|
1624
|
-
const inProgress = state.steps.find((
|
|
2485
|
+
const inProgress = state.steps.find((step) => step.status === "in_progress");
|
|
1625
2486
|
if (inProgress?.lastArbiterResult?.summary) {
|
|
1626
2487
|
info(`Last arbiter summary: ${inProgress.lastArbiterResult.summary}`);
|
|
1627
2488
|
}
|
|
1628
|
-
info(`Elapsed: ${
|
|
2489
|
+
info(`Elapsed: ${formatElapsed2(state.startedAt)}`);
|
|
1629
2490
|
}
|
|
1630
2491
|
function registerStatusCommand(program2) {
|
|
1631
2492
|
program2.command("status").description("Print active task status").action(() => {
|
|
@@ -1634,7 +2495,7 @@ function registerStatusCommand(program2) {
|
|
|
1634
2495
|
}
|
|
1635
2496
|
|
|
1636
2497
|
// src/commands/submit.ts
|
|
1637
|
-
function
|
|
2498
|
+
function fatalAndExit8(message) {
|
|
1638
2499
|
fatal(message);
|
|
1639
2500
|
process.exit(1);
|
|
1640
2501
|
}
|
|
@@ -1642,17 +2503,17 @@ async function runSubmit() {
|
|
|
1642
2503
|
try {
|
|
1643
2504
|
const projectRoot = findProjectRoot();
|
|
1644
2505
|
if (!projectRoot) {
|
|
1645
|
-
|
|
2506
|
+
fatalAndExit8("Not inside a vexdo project.");
|
|
1646
2507
|
}
|
|
1647
2508
|
const config = loadConfig(projectRoot);
|
|
1648
2509
|
const state = loadState(projectRoot);
|
|
1649
2510
|
if (!state) {
|
|
1650
|
-
|
|
2511
|
+
fatalAndExit8("No active task.");
|
|
1651
2512
|
}
|
|
1652
2513
|
await requireGhAvailable();
|
|
1653
2514
|
await submitActiveTask(projectRoot, config, state);
|
|
1654
|
-
} catch (
|
|
1655
|
-
|
|
2515
|
+
} catch (error2) {
|
|
2516
|
+
fatalAndExit8(error2 instanceof Error ? error2.message : String(error2));
|
|
1656
2517
|
}
|
|
1657
2518
|
}
|
|
1658
2519
|
function registerSubmitCommand(program2) {
|
|
@@ -1662,8 +2523,8 @@ function registerSubmitCommand(program2) {
|
|
|
1662
2523
|
}
|
|
1663
2524
|
|
|
1664
2525
|
// src/index.ts
|
|
1665
|
-
var packageJsonPath =
|
|
1666
|
-
var packageJson = JSON.parse(
|
|
2526
|
+
var packageJsonPath = path12.resolve(path12.dirname(new URL(import.meta.url).pathname), "..", "package.json");
|
|
2527
|
+
var packageJson = JSON.parse(fs10.readFileSync(packageJsonPath, "utf8"));
|
|
1667
2528
|
var program = new Command();
|
|
1668
2529
|
program.name("vexdo").description("Vexdo CLI").version(packageJson.version).option("--verbose", "Enable verbose logs").option("--dry-run", "Print plan without making changes");
|
|
1669
2530
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
@@ -1678,4 +2539,5 @@ registerSubmitCommand(program);
|
|
|
1678
2539
|
registerStatusCommand(program);
|
|
1679
2540
|
registerAbortCommand(program);
|
|
1680
2541
|
registerLogsCommand(program);
|
|
2542
|
+
registerBoardCommand(program);
|
|
1681
2543
|
program.parse(process.argv);
|