@vexdo/cli 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +79 -2
  2. package/dist/index.js +1202 -344
  3. package/package.json +5 -1
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
- import fs9 from "fs";
3
- import path11 from "path";
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 (error) {
117
- const message = error instanceof Error ? error.message : String(error);
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 (error) {
261
- const message = error instanceof Error ? error.message : String(error);
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 (error) {
350
- const message = error instanceof Error ? error.message : String(error);
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((step2, index) => parseTaskStep(step2, index, config));
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((step2) => ({
366
- service: step2.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 path6 from "path";
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 (error) {
551
- lastError = error;
552
- if (!isRetryableError(error) || attempt === MAX_ATTEMPTS) {
553
- throw new ClaudeError(attempt, error);
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 && fenced[1]) {
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 (error) {
592
- throw new Error(`Failed to parse reviewer JSON: ${error instanceof Error ? error.message : String(error)}`);
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 (error) {
604
- throw new Error(`Failed to parse arbiter JSON: ${error instanceof Error ? error.message : String(error)}`);
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(error) {
654
- if (typeof error !== "object" || error === null) {
1096
+ function getStatusCode(error2) {
1097
+ if (typeof error2 !== "object" || error2 === null) {
655
1098
  return void 0;
656
1099
  }
657
- const candidate = error;
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(error) {
667
- const status = getStatusCode(error);
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 { execFile as execFileCb } from "child_process";
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(stdout, stderr, exitCode) {
686
- super(`codex exec failed (exit ${String(exitCode)})`);
1128
+ constructor(code, message, details) {
1129
+ super(message);
687
1130
  this.name = "CodexError";
688
- this.stdout = stdout;
689
- this.stderr = stderr;
690
- this.exitCode = exitCode;
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 formatElapsed(startedAt) {
700
- const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1e3));
701
- return `${String(seconds)}s`;
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 buildVerboseStreamHandler(label) {
704
- let partialLine = "";
705
- return {
706
- onData(chunk) {
707
- partialLine += chunk.toString();
708
- const lines = partialLine.split(/\r?\n/);
709
- partialLine = lines.pop() ?? "";
710
- for (const line of lines) {
711
- if (!line) {
712
- continue;
713
- }
714
- debug(`[codex:${label}] ${line}`);
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
- debug(`[codex:${label}] ${partialLine}`);
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
- await new Promise((resolve, reject) => {
728
- execFileCb("codex", ["--version"], { timeout: CODEX_TIMEOUT_MS, encoding: "utf8" }, (error) => {
729
- if (error) {
730
- reject(new CodexNotFoundError());
731
- return;
732
- }
733
- resolve();
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 args = ["exec", "--model", opts.model, "--full-auto", "--", opts.spec];
739
- const startedAt = Date.now();
740
- if (opts.verbose) {
741
- debug(`[codex] starting (model=${opts.model}, cwd=${opts.cwd})`);
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
- return await new Promise((resolve, reject) => {
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 execFileCb2 } from "child_process";
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
- execFileCb2("git", args, { cwd, timeout: GIT_TIMEOUT_MS, encoding: "utf8" }, (error, _stdout, stderr) => {
824
- if (error) {
825
- const exitCode = typeof error.code === "number" ? error.code : -1;
826
- reject(new GitCommandError(args, exitCode, (stderr || error.message).trim()));
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(error) {
834
- const text = error.stderr.toLowerCase();
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 (error) {
842
- if (error instanceof GitCommandError && isNoUpstreamError(error)) {
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 error;
1332
+ throw error2;
847
1333
  }
848
1334
  }
849
1335
  async function checkGhAvailable() {
850
1336
  await new Promise((resolve, reject) => {
851
- execFileCb2("gh", ["--version"], { timeout: GH_TIMEOUT_MS, encoding: "utf8" }, (error) => {
852
- if (error) {
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 (error) {
865
- throw new Error(`Failed to push current branch before creating PR: ${error instanceof Error ? error.message : String(error)}`);
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
- execFileCb2(
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
- (error, stdout, stderr) => {
873
- if (error) {
874
- reject(new Error((stderr || error.message).trim()));
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 path5 from "path";
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 execFileCb3 } from "child_process";
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
- execFileCb3("git", args, { cwd, timeout: GIT_TIMEOUT_MS2, encoding: "utf8" }, (error, stdout, stderr) => {
916
- if (error) {
917
- const exitCode = typeof error.code === "number" ? error.code : -1;
918
- reject(new GitError(args, exitCode, (stderr || error.message).trim()));
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 (error) {
930
- if (error instanceof GitError && error.exitCode === 1) {
1598
+ } catch (error2) {
1599
+ if (error2 instanceof GitError && error2.exitCode === 1) {
931
1600
  return false;
932
1601
  }
933
- throw error;
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 getDiff(cwd, base) {
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 formatElapsed2(startedAt) {
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 = path5.resolve(opts.projectRoot, serviceConfig.path);
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 getDiff(serviceRoot);
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 (${formatElapsed2(reviewerStartedAt)})`);
1671
+ info(`Waiting for reviewer response (${formatElapsed(reviewerStartedAt)})`);
1003
1672
  }, 15e3) : null;
1004
- const review = await opts.claude.runReviewer({
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 ${formatElapsed2(reviewerStartedAt)}`);
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 (${formatElapsed2(arbiterStartedAt)})`);
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 ${formatElapsed2(arbiterStartedAt)}`);
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 fatalAndExit2(message) {
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
- fatalAndExit2("Not inside a vexdo project.");
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
- fatalAndExit2("No active task.");
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((step3) => step3.status === "in_progress" || step3.status === "pending");
1777
+ const currentStep = state.steps.find((step2) => step2.status === "in_progress" || step2.status === "pending");
1112
1778
  if (!currentStep) {
1113
- fatalAndExit2("No in-progress step found in active task.");
1779
+ fatalAndExit3("No in-progress step found in active task.");
1114
1780
  }
1115
1781
  const task = loadAndValidateTask(state.taskPath, config);
1116
- const step2 = task.steps.find((item) => item.service === currentStep.service);
1117
- if (!step2) {
1118
- fatalAndExit2(`Could not locate task step for service '${currentStep.service}'.`);
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
- fatalAndExit2(`Unknown service in step: ${currentStep.service}`);
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: path6.resolve(projectRoot, serviceConfig.path),
1794
+ cwd: path7.resolve(projectRoot, serviceConfig.path),
1129
1795
  verbose: options.verbose
1130
1796
  });
1131
1797
  }
1132
- info(`Running review loop for service ${step2.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: step2,
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 (error) {
1160
- fatalAndExit2(error instanceof Error ? error.message : String(error));
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 fs5 from "fs";
1172
- import path7 from "path";
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 (!fs5.existsSync(gitignorePath)) {
1202
- fs5.writeFileSync(gitignorePath, `${entry}
1867
+ if (!fs6.existsSync(gitignorePath)) {
1868
+ fs6.writeFileSync(gitignorePath, `${entry}
1203
1869
  `, "utf8");
1204
1870
  return true;
1205
1871
  }
1206
- const content = fs5.readFileSync(gitignorePath, "utf8");
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
- fs5.appendFileSync(gitignorePath, `${suffix}${entry}
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 = path7.join(projectRoot, ".vexdo.yml");
1218
- if (fs5.existsSync(configPath)) {
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
- fs5.writeFileSync(configPath, stringify(config), "utf8");
1920
+ fs6.writeFileSync(configPath, stringify(config), "utf8");
1255
1921
  const createdDirs = [];
1256
1922
  for (const taskDir of TASK_DIRS) {
1257
- const directory = path7.join(projectRoot, "tasks", taskDir);
1258
- fs5.mkdirSync(directory, { recursive: true });
1259
- createdDirs.push(path7.relative(projectRoot, directory));
1260
- }
1261
- const logDir = path7.join(projectRoot, ".vexdo", "logs");
1262
- fs5.mkdirSync(logDir, { recursive: true });
1263
- createdDirs.push(path7.relative(projectRoot, logDir));
1264
- const gitignorePath = path7.join(projectRoot, ".gitignore");
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: ${path7.relative(projectRoot, configPath)}`);
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 fs6 from "fs";
1282
- import path8 from "path";
1283
- function fatalAndExit3(message) {
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
- fatalAndExit3("Not inside a vexdo project.");
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 = path8.join(getStateDir(projectRoot), "logs");
1296
- if (!fs6.existsSync(base)) {
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 = fs6.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory());
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 (!fs6.existsSync(logsDir)) {
1308
- fatalAndExit3(`No logs found for task '${taskId}'.`);
1973
+ if (!fs7.existsSync(logsDir)) {
1974
+ fatalAndExit4(`No logs found for task '${taskId}'.`);
1309
1975
  }
1310
- const files = fs6.readdirSync(logsDir).filter((name) => name.endsWith("-arbiter.json"));
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 = path8.join(logsDir, `${base}-arbiter.json`);
1314
- const reviewPath = path8.join(logsDir, `${base}-review.json`);
1315
- const diffPath = path8.join(logsDir, `${base}-diff.txt`);
1316
- const arbiter = JSON.parse(fs6.readFileSync(arbiterPath, "utf8"));
1317
- const review = JSON.parse(fs6.readFileSync(reviewPath, "utf8"));
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(fs6.readFileSync(diffPath, "utf8"));
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 fs7 from "fs";
1334
- function fatalAndExit4(message) {
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
- fatalAndExit4("Not inside a vexdo project.");
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
- fatalAndExit4("No active task.");
2013
+ fatalAndExit5("No active task.");
1348
2014
  }
1349
2015
  if (!options.dryRun) {
1350
2016
  requireAnthropicApiKey();
1351
2017
  }
1352
- const currentStep = state.steps.find((step3) => step3.status === "in_progress" || step3.status === "pending");
2018
+ const currentStep = state.steps.find((step2) => step2.status === "in_progress" || step2.status === "pending");
1353
2019
  if (!currentStep) {
1354
- fatalAndExit4("No in-progress step found in active task.");
2020
+ fatalAndExit5("No in-progress step found in active task.");
1355
2021
  }
1356
- if (!fs7.existsSync(state.taskPath)) {
1357
- fatalAndExit4(`Task file not found: ${state.taskPath}`);
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 step2 = task.steps.find((item) => item.service === currentStep.service);
1361
- if (!step2) {
1362
- fatalAndExit4(`Could not locate task step for service '${currentStep.service}'.`);
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 ${step2.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: step2,
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: step2.service,
2045
+ service: step.service,
1380
2046
  iteration: result.finalIteration,
1381
- spec: step2.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 (error) {
1402
- const message = error instanceof Error ? error.message : String(error);
1403
- fatalAndExit4(message);
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 path10 from "path";
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 fs8 from "fs";
1418
- import path9 from "path";
2161
+ import fs9 from "fs";
2162
+ import path10 from "path";
1419
2163
  async function submitActiveTask(projectRoot, config, state) {
1420
- for (const step2 of state.steps) {
1421
- if (step2.status !== "done" && step2.status !== "in_progress") {
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 === step2.service);
2168
+ const service = config.services.find((item) => item.name === step.service);
1425
2169
  if (!service) {
1426
- throw new Error(`Unknown service in state: ${step2.service}`);
2170
+ throw new Error(`Unknown service in state: ${step.service}`);
1427
2171
  }
1428
- const servicePath = path9.resolve(projectRoot, service.path);
2172
+ const servicePath = path10.resolve(projectRoot, service.path);
1429
2173
  const body = `Task: ${state.taskId}
1430
- Service: ${step2.service}`;
2174
+ Service: ${step.service}`;
1431
2175
  const url = await createPr({
1432
- title: `${state.taskTitle} [${step2.service}]`,
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 (fs8.existsSync(state.taskPath)) {
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
- function fatalAndExit5(message, hint) {
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
- fatalAndExit5("Not inside a vexdo project. Could not find .vexdo.yml.");
2204
+ fatalAndExit6("Not inside a vexdo project. Could not find .vexdo.yml.");
1459
2205
  }
1460
2206
  const config = loadConfig(projectRoot);
1461
- const taskPath = path10.resolve(taskFile);
2207
+ const taskPath = path11.resolve(taskFile);
1462
2208
  const task = loadAndValidateTask(taskPath, config);
1463
2209
  if (hasActiveTask(projectRoot) && !options.resume) {
1464
- fatalAndExit5("An active task already exists.", "Use --resume to continue or 'vexdo abort' to cancel.");
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,119 @@ async function runStart(taskFile, options) {
1480
2227
  }
1481
2228
  }
1482
2229
  if (!state) {
1483
- fatalAndExit5("No resumable task state found.");
2230
+ fatalAndExit6("No resumable task state found.");
1484
2231
  }
1485
2232
  const claude = new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? "");
1486
- const total = task.steps.length;
1487
- for (let i = 0; i < task.steps.length; i += 1) {
1488
- const step2 = task.steps[i];
1489
- const stepState = state.steps[i];
1490
- if (!step2 || !stepState) {
1491
- continue;
1492
- }
1493
- if (stepState.status === "done") {
1494
- continue;
1495
- }
1496
- if (step2.depends_on && step2.depends_on.length > 0) {
1497
- for (const depService of step2.depends_on) {
1498
- const depState = state.steps.find((item) => item.service === depService);
1499
- if (depState?.status !== "done") {
1500
- fatalAndExit5(`Step dependency '${depService}' for service '${step2.service}' is not done.`);
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" };
1501
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 };
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
+ return { service: step.service, status: "failed", error: message };
1502
2321
  }
1503
2322
  }
1504
- step(i + 1, total, `${step2.service}: ${task.title}`);
1505
- const serviceCfg = config.services.find((service) => service.name === step2.service);
1506
- if (!serviceCfg) {
1507
- fatalAndExit5(`Unknown service in step: ${step2.service}`);
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);
2323
+ );
2324
+ if (!options.dryRun) {
2325
+ for (const result of stepResults) {
2326
+ if (result.status === "failed" && result.error === "dependency_failed") {
2327
+ await updateStep(projectRoot, task.id, result.service, { status: "failed" });
1516
2328
  }
1517
2329
  }
1518
- stepState.status = "in_progress";
1519
- stepState.branch = branch;
2330
+ }
2331
+ const hasEscalation = stepResults.some((result) => result.status === "escalated");
2332
+ const hasFailure = stepResults.some((result) => result.status === "failed");
2333
+ state = loadState(projectRoot) ?? state;
2334
+ if (hasEscalation || hasFailure) {
2335
+ state.status = hasEscalation ? "escalated" : "blocked";
1520
2336
  if (!options.dryRun) {
1521
2337
  saveState(projectRoot, state);
1522
- }
1523
- if (!options.resume && !options.dryRun) {
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) {
2338
+ const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
2339
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
1569
2340
  saveState(projectRoot, state);
1570
2341
  }
2342
+ process.exit(1);
1571
2343
  }
1572
2344
  state.status = "review";
1573
2345
  if (!options.dryRun) {
@@ -1580,9 +2352,94 @@ async function runStart(taskFile, options) {
1580
2352
  return;
1581
2353
  }
1582
2354
  success("Task ready for PR. Run 'vexdo submit' to create PR.");
1583
- } catch (error) {
1584
- const message = error instanceof Error ? error.message : String(error);
1585
- fatalAndExit5(message);
2355
+ } catch (error2) {
2356
+ const message = error2 instanceof Error ? error2.message : String(error2);
2357
+ fatalAndExit6(message);
2358
+ }
2359
+ }
2360
+ async function runCloudReviewLoop(opts) {
2361
+ let sessionId = opts.sessionId;
2362
+ let iteration2 = opts.stepState.iteration;
2363
+ for (; ; ) {
2364
+ opts.log.iteration(iteration2 + 1, opts.config.review.max_iterations);
2365
+ opts.log.info(`Polling codex cloud session ${sessionId}`);
2366
+ const status = await pollStatus(sessionId, {
2367
+ intervalMs: POLL_INTERVAL_MS,
2368
+ timeoutMs: POLL_TIMEOUT_MS
2369
+ });
2370
+ if (status !== "completed") {
2371
+ return {
2372
+ sessionId,
2373
+ finalIteration: iteration2,
2374
+ lastReviewComments: [],
2375
+ lastArbiterResult: {
2376
+ decision: "escalate",
2377
+ reasoning: `Codex Cloud session ended with status '${status}'.`,
2378
+ summary: "Escalated due to codex cloud execution failure."
2379
+ }
2380
+ };
2381
+ }
2382
+ opts.log.success("Codex task completed");
2383
+ opts.log.info(`Retrieving diff for session ${sessionId}`);
2384
+ const diff = await getDiff(sessionId);
2385
+ opts.log.info(`Running review (iteration ${String(iteration2 + 1)})...`);
2386
+ await applyDiff(sessionId);
2387
+ const comments = await runCopilotReview(opts.spec, { cwd: opts.serviceRoot }).finally(async () => {
2388
+ await exec2(["checkout", "."], opts.serviceRoot);
2389
+ });
2390
+ const review = { comments };
2391
+ opts.log.reviewSummary(review.comments);
2392
+ opts.log.info(`Requesting arbiter decision (model: ${opts.config.review.model})`);
2393
+ const arbiter = await opts.claude.runArbiter({
2394
+ spec: opts.spec,
2395
+ diff,
2396
+ reviewComments: review.comments,
2397
+ model: opts.config.review.model
2398
+ });
2399
+ saveIterationLog(opts.projectRoot, opts.taskId, opts.service, iteration2, {
2400
+ diff,
2401
+ review,
2402
+ arbiter
2403
+ });
2404
+ opts.stepState.iteration = iteration2;
2405
+ opts.stepState.session_id = sessionId;
2406
+ if (arbiter.decision === "submit" || arbiter.decision === "escalate") {
2407
+ return {
2408
+ sessionId,
2409
+ finalIteration: iteration2,
2410
+ lastReviewComments: review.comments,
2411
+ lastArbiterResult: arbiter
2412
+ };
2413
+ }
2414
+ if (iteration2 >= opts.config.review.max_iterations) {
2415
+ return {
2416
+ sessionId,
2417
+ finalIteration: iteration2,
2418
+ lastReviewComments: review.comments,
2419
+ lastArbiterResult: {
2420
+ decision: "escalate",
2421
+ reasoning: "Max review iterations reached while arbiter still requested fixes.",
2422
+ summary: "Escalated because maximum iterations were exhausted."
2423
+ }
2424
+ };
2425
+ }
2426
+ if (!arbiter.feedback_for_codex) {
2427
+ return {
2428
+ sessionId,
2429
+ finalIteration: iteration2,
2430
+ lastReviewComments: review.comments,
2431
+ lastArbiterResult: {
2432
+ decision: "escalate",
2433
+ reasoning: "Arbiter returned fix decision without feedback_for_codex.",
2434
+ summary: "Escalated because fix instructions were missing."
2435
+ }
2436
+ };
2437
+ }
2438
+ opts.log.warn(`Review requested fixes (iteration ${String(iteration2 + 1)}/${String(opts.config.review.max_iterations)})`);
2439
+ sessionId = await resumeTask(sessionId, arbiter.feedback_for_codex);
2440
+ opts.stepState.session_id = sessionId;
2441
+ iteration2 += 1;
2442
+ opts.stepState.iteration = iteration2;
1586
2443
  }
1587
2444
  }
1588
2445
  function registerStartCommand(program2) {
@@ -1593,11 +2450,11 @@ function registerStartCommand(program2) {
1593
2450
  }
1594
2451
 
1595
2452
  // src/commands/status.ts
1596
- function fatalAndExit6(message) {
2453
+ function fatalAndExit7(message) {
1597
2454
  fatal(message);
1598
2455
  process.exit(1);
1599
2456
  }
1600
- function formatElapsed3(startedAt) {
2457
+ function formatElapsed2(startedAt) {
1601
2458
  const elapsedMs = Date.now() - new Date(startedAt).getTime();
1602
2459
  const minutes = Math.floor(elapsedMs / 1e3 / 60);
1603
2460
  const hours = Math.floor(minutes / 60);
@@ -1609,23 +2466,23 @@ function formatElapsed3(startedAt) {
1609
2466
  function runStatus() {
1610
2467
  const projectRoot = findProjectRoot();
1611
2468
  if (!projectRoot) {
1612
- fatalAndExit6("Not inside a vexdo project.");
2469
+ fatalAndExit7("Not inside a vexdo project.");
1613
2470
  }
1614
2471
  const state = loadState(projectRoot);
1615
2472
  if (!state) {
1616
- fatalAndExit6("No active task.");
2473
+ fatalAndExit7("No active task.");
1617
2474
  }
1618
2475
  info(`Task: ${state.taskId} \u2014 ${state.taskTitle}`);
1619
2476
  info(`Status: ${state.status}`);
1620
2477
  console.log("service | status | iteration | branch");
1621
- for (const step2 of state.steps) {
1622
- console.log(`${step2.service} | ${step2.status} | ${String(step2.iteration)} | ${step2.branch ?? "-"}`);
2478
+ for (const step of state.steps) {
2479
+ console.log(`${step.service} | ${step.status} | ${String(step.iteration)} | ${step.branch ?? "-"}`);
1623
2480
  }
1624
- const inProgress = state.steps.find((step2) => step2.status === "in_progress");
2481
+ const inProgress = state.steps.find((step) => step.status === "in_progress");
1625
2482
  if (inProgress?.lastArbiterResult?.summary) {
1626
2483
  info(`Last arbiter summary: ${inProgress.lastArbiterResult.summary}`);
1627
2484
  }
1628
- info(`Elapsed: ${formatElapsed3(state.startedAt)}`);
2485
+ info(`Elapsed: ${formatElapsed2(state.startedAt)}`);
1629
2486
  }
1630
2487
  function registerStatusCommand(program2) {
1631
2488
  program2.command("status").description("Print active task status").action(() => {
@@ -1634,7 +2491,7 @@ function registerStatusCommand(program2) {
1634
2491
  }
1635
2492
 
1636
2493
  // src/commands/submit.ts
1637
- function fatalAndExit7(message) {
2494
+ function fatalAndExit8(message) {
1638
2495
  fatal(message);
1639
2496
  process.exit(1);
1640
2497
  }
@@ -1642,17 +2499,17 @@ async function runSubmit() {
1642
2499
  try {
1643
2500
  const projectRoot = findProjectRoot();
1644
2501
  if (!projectRoot) {
1645
- fatalAndExit7("Not inside a vexdo project.");
2502
+ fatalAndExit8("Not inside a vexdo project.");
1646
2503
  }
1647
2504
  const config = loadConfig(projectRoot);
1648
2505
  const state = loadState(projectRoot);
1649
2506
  if (!state) {
1650
- fatalAndExit7("No active task.");
2507
+ fatalAndExit8("No active task.");
1651
2508
  }
1652
2509
  await requireGhAvailable();
1653
2510
  await submitActiveTask(projectRoot, config, state);
1654
- } catch (error) {
1655
- fatalAndExit7(error instanceof Error ? error.message : String(error));
2511
+ } catch (error2) {
2512
+ fatalAndExit8(error2 instanceof Error ? error2.message : String(error2));
1656
2513
  }
1657
2514
  }
1658
2515
  function registerSubmitCommand(program2) {
@@ -1662,8 +2519,8 @@ function registerSubmitCommand(program2) {
1662
2519
  }
1663
2520
 
1664
2521
  // src/index.ts
1665
- var packageJsonPath = path11.resolve(path11.dirname(new URL(import.meta.url).pathname), "..", "package.json");
1666
- var packageJson = JSON.parse(fs9.readFileSync(packageJsonPath, "utf8"));
2522
+ var packageJsonPath = path12.resolve(path12.dirname(new URL(import.meta.url).pathname), "..", "package.json");
2523
+ var packageJson = JSON.parse(fs10.readFileSync(packageJsonPath, "utf8"));
1667
2524
  var program = new Command();
1668
2525
  program.name("vexdo").description("Vexdo CLI").version(packageJson.version).option("--verbose", "Enable verbose logs").option("--dry-run", "Print plan without making changes");
1669
2526
  program.hook("preAction", (_thisCommand, actionCommand) => {
@@ -1678,4 +2535,5 @@ registerSubmitCommand(program);
1678
2535
  registerStatusCommand(program);
1679
2536
  registerAbortCommand(program);
1680
2537
  registerLogsCommand(program);
2538
+ registerBoardCommand(program);
1681
2539
  program.parse(process.argv);