@vexdo/cli 0.1.3 → 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 +1236 -334
  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,121 +1151,191 @@ 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;
1290
+ var GIT_TIMEOUT_MS = 3e4;
804
1291
  var GhNotFoundError = class extends Error {
805
1292
  constructor() {
806
1293
  super("gh CLI not found. Install it: https://cli.github.com");
807
1294
  this.name = "GhNotFoundError";
808
1295
  }
809
1296
  };
1297
+ var GitCommandError = class extends Error {
1298
+ exitCode;
1299
+ stderr;
1300
+ constructor(args, exitCode, stderr) {
1301
+ super(`git ${args.join(" ")} failed (exit ${String(exitCode)}): ${stderr}`);
1302
+ this.name = "GitCommandError";
1303
+ this.exitCode = exitCode;
1304
+ this.stderr = stderr;
1305
+ }
1306
+ };
1307
+ async function execGit(args, cwd) {
1308
+ await new Promise((resolve, reject) => {
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()));
1313
+ return;
1314
+ }
1315
+ resolve();
1316
+ });
1317
+ });
1318
+ }
1319
+ function isNoUpstreamError(error2) {
1320
+ const text = error2.stderr.toLowerCase();
1321
+ return text.includes("no upstream configured for branch") || text.includes("has no upstream branch");
1322
+ }
1323
+ async function pushCurrentBranch(cwd) {
1324
+ try {
1325
+ await execGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd);
1326
+ await execGit(["push"], cwd);
1327
+ } catch (error2) {
1328
+ if (error2 instanceof GitCommandError && isNoUpstreamError(error2)) {
1329
+ await execGit(["push", "--set-upstream", "origin", "HEAD"], cwd);
1330
+ return;
1331
+ }
1332
+ throw error2;
1333
+ }
1334
+ }
810
1335
  async function checkGhAvailable() {
811
1336
  await new Promise((resolve, reject) => {
812
- execFileCb2("gh", ["--version"], { timeout: GH_TIMEOUT_MS, encoding: "utf8" }, (error) => {
813
- if (error) {
1337
+ execFileCb("gh", ["--version"], { timeout: GH_TIMEOUT_MS, encoding: "utf8" }, (error2) => {
1338
+ if (error2) {
814
1339
  reject(new GhNotFoundError());
815
1340
  return;
816
1341
  }
@@ -820,14 +1345,19 @@ async function checkGhAvailable() {
820
1345
  }
821
1346
  async function createPr(opts) {
822
1347
  const base = opts.base ?? "main";
1348
+ try {
1349
+ await pushCurrentBranch(opts.cwd);
1350
+ } catch (error2) {
1351
+ throw new Error(`Failed to push current branch before creating PR: ${error2 instanceof Error ? error2.message : String(error2)}`);
1352
+ }
823
1353
  return await new Promise((resolve, reject) => {
824
- execFileCb2(
1354
+ execFileCb(
825
1355
  "gh",
826
1356
  ["pr", "create", "--title", opts.title, "--body", opts.body, "--base", base],
827
1357
  { cwd: opts.cwd, timeout: GH_TIMEOUT_MS, encoding: "utf8" },
828
- (error, stdout, stderr) => {
829
- if (error) {
830
- reject(new Error((stderr || error.message).trim()));
1358
+ (error2, stdout, stderr) => {
1359
+ if (error2) {
1360
+ reject(new Error((stderr || error2.message).trim()));
831
1361
  return;
832
1362
  }
833
1363
  resolve(stdout.trim());
@@ -849,11 +1379,194 @@ async function requireGhAvailable() {
849
1379
  }
850
1380
 
851
1381
  // src/lib/review-loop.ts
852
- 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
+ }
853
1566
 
854
1567
  // src/lib/git.ts
855
- import { execFile as execFileCb3 } from "child_process";
856
- var GIT_TIMEOUT_MS = 3e4;
1568
+ import { execFile as execFileCb2 } from "child_process";
1569
+ var GIT_TIMEOUT_MS2 = 3e4;
857
1570
  var GitError = class extends Error {
858
1571
  command;
859
1572
  exitCode;
@@ -868,10 +1581,10 @@ var GitError = class extends Error {
868
1581
  };
869
1582
  async function exec2(args, cwd) {
870
1583
  return new Promise((resolve, reject) => {
871
- execFileCb3("git", args, { cwd, timeout: GIT_TIMEOUT_MS, encoding: "utf8" }, (error, stdout, stderr) => {
872
- if (error) {
873
- const exitCode = typeof error.code === "number" ? error.code : -1;
874
- 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()));
875
1588
  return;
876
1589
  }
877
1590
  resolve(stdout.trimEnd());
@@ -882,11 +1595,11 @@ async function branchExists(name, cwd) {
882
1595
  try {
883
1596
  await exec2(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], cwd);
884
1597
  return true;
885
- } catch (error) {
886
- if (error instanceof GitError && error.exitCode === 1) {
1598
+ } catch (error2) {
1599
+ if (error2 instanceof GitError && error2.exitCode === 1) {
887
1600
  return false;
888
1601
  }
889
- throw error;
1602
+ throw error2;
890
1603
  }
891
1604
  }
892
1605
  async function createBranch(name, cwd) {
@@ -898,7 +1611,7 @@ async function createBranch(name, cwd) {
898
1611
  async function checkoutBranch(name, cwd) {
899
1612
  await exec2(["checkout", name], cwd);
900
1613
  }
901
- async function getDiff(cwd, base) {
1614
+ async function getDiff2(cwd, base) {
902
1615
  if (base) {
903
1616
  return exec2(["diff", `${base}..HEAD`], cwd);
904
1617
  }
@@ -909,7 +1622,7 @@ function getBranchName(taskId, service) {
909
1622
  }
910
1623
 
911
1624
  // src/lib/review-loop.ts
912
- function formatElapsed2(startedAt) {
1625
+ function formatElapsed(startedAt) {
913
1626
  const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1e3));
914
1627
  return `${String(seconds)}s`;
915
1628
  }
@@ -931,12 +1644,12 @@ async function runReviewLoop(opts) {
931
1644
  if (!serviceConfig) {
932
1645
  throw new Error(`Unknown service in step: ${opts.step.service}`);
933
1646
  }
934
- const serviceRoot = path5.resolve(opts.projectRoot, serviceConfig.path);
1647
+ const serviceRoot = path6.resolve(opts.projectRoot, serviceConfig.path);
935
1648
  let iteration2 = opts.stepState.iteration;
936
1649
  for (; ; ) {
937
1650
  iteration(iteration2 + 1, opts.config.review.max_iterations);
938
1651
  info(`Collecting git diff for service ${opts.step.service}`);
939
- const diff = await getDiff(serviceRoot);
1652
+ const diff = await getDiff2(serviceRoot);
940
1653
  if (opts.verbose) {
941
1654
  info(`Diff collected (${String(diff.length)} chars)`);
942
1655
  }
@@ -955,23 +1668,20 @@ async function runReviewLoop(opts) {
955
1668
  info(`Requesting reviewer analysis (model: ${opts.config.review.model})`);
956
1669
  const reviewerStartedAt = Date.now();
957
1670
  const reviewerHeartbeat = opts.verbose ? setInterval(() => {
958
- info(`Waiting for reviewer response (${formatElapsed2(reviewerStartedAt)})`);
1671
+ info(`Waiting for reviewer response (${formatElapsed(reviewerStartedAt)})`);
959
1672
  }, 15e3) : null;
960
- const review = await opts.claude.runReviewer({
961
- spec: opts.step.spec,
962
- diff,
963
- model: opts.config.review.model
964
- }).finally(() => {
1673
+ const comments = await runCopilotReview(opts.step.spec, { cwd: serviceRoot }).finally(() => {
965
1674
  if (reviewerHeartbeat) {
966
1675
  clearInterval(reviewerHeartbeat);
967
1676
  }
968
1677
  });
969
- info(`Reviewer response received in ${formatElapsed2(reviewerStartedAt)}`);
1678
+ info(`Reviewer response received in ${formatElapsed(reviewerStartedAt)}`);
1679
+ const review = { comments };
970
1680
  reviewSummary(review.comments);
971
1681
  info(`Requesting arbiter decision (model: ${opts.config.review.model})`);
972
1682
  const arbiterStartedAt = Date.now();
973
1683
  const arbiterHeartbeat = opts.verbose ? setInterval(() => {
974
- info(`Waiting for arbiter response (${formatElapsed2(arbiterStartedAt)})`);
1684
+ info(`Waiting for arbiter response (${formatElapsed(arbiterStartedAt)})`);
975
1685
  }, 15e3) : null;
976
1686
  const arbiter = await opts.claude.runArbiter({
977
1687
  spec: opts.step.spec,
@@ -983,7 +1693,7 @@ async function runReviewLoop(opts) {
983
1693
  clearInterval(arbiterHeartbeat);
984
1694
  }
985
1695
  });
986
- info(`Arbiter response received in ${formatElapsed2(arbiterStartedAt)}`);
1696
+ info(`Arbiter response received in ${formatElapsed(arbiterStartedAt)}`);
987
1697
  info(`Arbiter decision: ${arbiter.decision} (${arbiter.summary})`);
988
1698
  saveIterationLog(opts.projectRoot, opts.taskId, opts.step.service, iteration2, {
989
1699
  diff,
@@ -1045,7 +1755,7 @@ async function runReviewLoop(opts) {
1045
1755
  }
1046
1756
 
1047
1757
  // src/commands/fix.ts
1048
- function fatalAndExit2(message) {
1758
+ function fatalAndExit3(message) {
1049
1759
  fatal(message);
1050
1760
  process.exit(1);
1051
1761
  }
@@ -1053,43 +1763,43 @@ async function runFix(feedback, options) {
1053
1763
  try {
1054
1764
  const projectRoot = findProjectRoot();
1055
1765
  if (!projectRoot) {
1056
- fatalAndExit2("Not inside a vexdo project.");
1766
+ fatalAndExit3("Not inside a vexdo project.");
1057
1767
  }
1058
1768
  const config = loadConfig(projectRoot);
1059
1769
  const state = loadState(projectRoot);
1060
1770
  if (!state) {
1061
- fatalAndExit2("No active task.");
1771
+ fatalAndExit3("No active task.");
1062
1772
  }
1063
1773
  if (!options.dryRun) {
1064
1774
  requireAnthropicApiKey();
1065
1775
  await checkCodexAvailable();
1066
1776
  }
1067
- 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");
1068
1778
  if (!currentStep) {
1069
- fatalAndExit2("No in-progress step found in active task.");
1779
+ fatalAndExit3("No in-progress step found in active task.");
1070
1780
  }
1071
1781
  const task = loadAndValidateTask(state.taskPath, config);
1072
- const step2 = task.steps.find((item) => item.service === currentStep.service);
1073
- if (!step2) {
1074
- 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}'.`);
1075
1785
  }
1076
1786
  if (!options.dryRun) {
1077
1787
  const serviceConfig = config.services.find((service) => service.name === currentStep.service);
1078
1788
  if (!serviceConfig) {
1079
- fatalAndExit2(`Unknown service in step: ${currentStep.service}`);
1789
+ fatalAndExit3(`Unknown service in step: ${currentStep.service}`);
1080
1790
  }
1081
1791
  await exec({
1082
1792
  spec: feedback,
1083
1793
  model: config.codex.model,
1084
- cwd: path6.resolve(projectRoot, serviceConfig.path),
1794
+ cwd: path7.resolve(projectRoot, serviceConfig.path),
1085
1795
  verbose: options.verbose
1086
1796
  });
1087
1797
  }
1088
- info(`Running review loop for service ${step2.service}`);
1798
+ info(`Running review loop for service ${step.service}`);
1089
1799
  const result = await runReviewLoop({
1090
1800
  taskId: task.id,
1091
1801
  task,
1092
- step: step2,
1802
+ step,
1093
1803
  stepState: currentStep,
1094
1804
  projectRoot,
1095
1805
  config,
@@ -1112,8 +1822,8 @@ async function runFix(feedback, options) {
1112
1822
  if (!options.dryRun) {
1113
1823
  saveState(projectRoot, state);
1114
1824
  }
1115
- } catch (error) {
1116
- fatalAndExit2(error instanceof Error ? error.message : String(error));
1825
+ } catch (error2) {
1826
+ fatalAndExit3(error2 instanceof Error ? error2.message : String(error2));
1117
1827
  }
1118
1828
  }
1119
1829
  function registerFixCommand(program2) {
@@ -1124,8 +1834,8 @@ function registerFixCommand(program2) {
1124
1834
  }
1125
1835
 
1126
1836
  // src/commands/init.ts
1127
- import fs5 from "fs";
1128
- import path7 from "path";
1837
+ import fs6 from "fs";
1838
+ import path8 from "path";
1129
1839
  import { createInterface } from "readline/promises";
1130
1840
  import { stdin as input, stdout as output } from "process";
1131
1841
  import { stringify } from "yaml";
@@ -1154,24 +1864,24 @@ function parseMaxIterations(value) {
1154
1864
  return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ITERATIONS2;
1155
1865
  }
1156
1866
  function ensureGitignoreEntry(gitignorePath, entry) {
1157
- if (!fs5.existsSync(gitignorePath)) {
1158
- fs5.writeFileSync(gitignorePath, `${entry}
1867
+ if (!fs6.existsSync(gitignorePath)) {
1868
+ fs6.writeFileSync(gitignorePath, `${entry}
1159
1869
  `, "utf8");
1160
1870
  return true;
1161
1871
  }
1162
- const content = fs5.readFileSync(gitignorePath, "utf8");
1872
+ const content = fs6.readFileSync(gitignorePath, "utf8");
1163
1873
  const lines = content.split(/\r?\n/).map((line) => line.trim());
1164
1874
  if (lines.includes(entry)) {
1165
1875
  return false;
1166
1876
  }
1167
1877
  const suffix = content.endsWith("\n") || content.length === 0 ? "" : "\n";
1168
- fs5.appendFileSync(gitignorePath, `${suffix}${entry}
1878
+ fs6.appendFileSync(gitignorePath, `${suffix}${entry}
1169
1879
  `, "utf8");
1170
1880
  return true;
1171
1881
  }
1172
1882
  async function runInit(projectRoot, prompt = defaultPrompt) {
1173
- const configPath = path7.join(projectRoot, ".vexdo.yml");
1174
- if (fs5.existsSync(configPath)) {
1883
+ const configPath = path8.join(projectRoot, ".vexdo.yml");
1884
+ if (fs6.existsSync(configPath)) {
1175
1885
  warn("Found existing .vexdo.yml.");
1176
1886
  const overwriteAnswer = await prompt("Overwrite existing .vexdo.yml? (y/N): ");
1177
1887
  if (!parseBoolean(overwriteAnswer)) {
@@ -1207,20 +1917,20 @@ async function runInit(projectRoot, prompt = defaultPrompt) {
1207
1917
  model: codexModelRaw.trim() || DEFAULT_CODEX_MODEL2
1208
1918
  }
1209
1919
  };
1210
- fs5.writeFileSync(configPath, stringify(config), "utf8");
1920
+ fs6.writeFileSync(configPath, stringify(config), "utf8");
1211
1921
  const createdDirs = [];
1212
1922
  for (const taskDir of TASK_DIRS) {
1213
- const directory = path7.join(projectRoot, "tasks", taskDir);
1214
- fs5.mkdirSync(directory, { recursive: true });
1215
- createdDirs.push(path7.relative(projectRoot, directory));
1216
- }
1217
- const logDir = path7.join(projectRoot, ".vexdo", "logs");
1218
- fs5.mkdirSync(logDir, { recursive: true });
1219
- createdDirs.push(path7.relative(projectRoot, logDir));
1220
- 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");
1221
1931
  const gitignoreUpdated = ensureGitignoreEntry(gitignorePath, ".vexdo/");
1222
1932
  success("Initialized vexdo project.");
1223
- info(`Created: ${path7.relative(projectRoot, configPath)}`);
1933
+ info(`Created: ${path8.relative(projectRoot, configPath)}`);
1224
1934
  info(`Created directories: ${createdDirs.join(", ")}`);
1225
1935
  if (gitignoreUpdated) {
1226
1936
  info("Updated .gitignore with .vexdo/");
@@ -1234,46 +1944,46 @@ function registerInitCommand(program2) {
1234
1944
  }
1235
1945
 
1236
1946
  // src/commands/logs.ts
1237
- import fs6 from "fs";
1238
- import path8 from "path";
1239
- function fatalAndExit3(message) {
1947
+ import fs7 from "fs";
1948
+ import path9 from "path";
1949
+ function fatalAndExit4(message) {
1240
1950
  fatal(message);
1241
1951
  process.exit(1);
1242
1952
  }
1243
1953
  function runLogs(taskIdArg, options) {
1244
1954
  const projectRoot = findProjectRoot();
1245
1955
  if (!projectRoot) {
1246
- fatalAndExit3("Not inside a vexdo project.");
1956
+ fatalAndExit4("Not inside a vexdo project.");
1247
1957
  }
1248
1958
  const state = loadState(projectRoot);
1249
1959
  const taskId = taskIdArg ?? state?.taskId;
1250
1960
  if (!taskId) {
1251
- const base = path8.join(getStateDir(projectRoot), "logs");
1252
- if (!fs6.existsSync(base)) {
1961
+ const base = path9.join(getStateDir(projectRoot), "logs");
1962
+ if (!fs7.existsSync(base)) {
1253
1963
  info("No logs available.");
1254
1964
  return;
1255
1965
  }
1256
- const tasks = fs6.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory());
1966
+ const tasks = fs7.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory());
1257
1967
  for (const dir of tasks) {
1258
1968
  info(dir.name);
1259
1969
  }
1260
1970
  return;
1261
1971
  }
1262
1972
  const logsDir = getLogsDir(projectRoot, taskId);
1263
- if (!fs6.existsSync(logsDir)) {
1264
- fatalAndExit3(`No logs found for task '${taskId}'.`);
1973
+ if (!fs7.existsSync(logsDir)) {
1974
+ fatalAndExit4(`No logs found for task '${taskId}'.`);
1265
1975
  }
1266
- const files = fs6.readdirSync(logsDir).filter((name) => name.endsWith("-arbiter.json"));
1976
+ const files = fs7.readdirSync(logsDir).filter((name) => name.endsWith("-arbiter.json"));
1267
1977
  for (const arbiterFile of files) {
1268
1978
  const base = arbiterFile.replace(/-arbiter\.json$/, "");
1269
- const arbiterPath = path8.join(logsDir, `${base}-arbiter.json`);
1270
- const reviewPath = path8.join(logsDir, `${base}-review.json`);
1271
- const diffPath = path8.join(logsDir, `${base}-diff.txt`);
1272
- const arbiter = JSON.parse(fs6.readFileSync(arbiterPath, "utf8"));
1273
- 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"));
1274
1984
  info(`${base}: decision=${arbiter.decision}, comments=${String(review.comments?.length ?? 0)}, summary=${arbiter.summary}`);
1275
1985
  if (options?.full) {
1276
- console.log(fs6.readFileSync(diffPath, "utf8"));
1986
+ console.log(fs7.readFileSync(diffPath, "utf8"));
1277
1987
  console.log(JSON.stringify(review, null, 2));
1278
1988
  console.log(JSON.stringify(arbiter, null, 2));
1279
1989
  }
@@ -1286,8 +1996,8 @@ function registerLogsCommand(program2) {
1286
1996
  }
1287
1997
 
1288
1998
  // src/commands/review.ts
1289
- import fs7 from "fs";
1290
- function fatalAndExit4(message) {
1999
+ import fs8 from "fs";
2000
+ function fatalAndExit5(message) {
1291
2001
  fatal(message);
1292
2002
  process.exit(1);
1293
2003
  }
@@ -1295,33 +2005,33 @@ async function runReview(options) {
1295
2005
  try {
1296
2006
  const projectRoot = findProjectRoot();
1297
2007
  if (!projectRoot) {
1298
- fatalAndExit4("Not inside a vexdo project.");
2008
+ fatalAndExit5("Not inside a vexdo project.");
1299
2009
  }
1300
2010
  const config = loadConfig(projectRoot);
1301
2011
  const state = loadState(projectRoot);
1302
2012
  if (!state) {
1303
- fatalAndExit4("No active task.");
2013
+ fatalAndExit5("No active task.");
1304
2014
  }
1305
2015
  if (!options.dryRun) {
1306
2016
  requireAnthropicApiKey();
1307
2017
  }
1308
- 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");
1309
2019
  if (!currentStep) {
1310
- fatalAndExit4("No in-progress step found in active task.");
2020
+ fatalAndExit5("No in-progress step found in active task.");
1311
2021
  }
1312
- if (!fs7.existsSync(state.taskPath)) {
1313
- fatalAndExit4(`Task file not found: ${state.taskPath}`);
2022
+ if (!fs8.existsSync(state.taskPath)) {
2023
+ fatalAndExit5(`Task file not found: ${state.taskPath}`);
1314
2024
  }
1315
2025
  const task = loadAndValidateTask(state.taskPath, config);
1316
- const step2 = task.steps.find((item) => item.service === currentStep.service);
1317
- if (!step2) {
1318
- 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}'.`);
1319
2029
  }
1320
- info(`Running review loop for service ${step2.service}`);
2030
+ info(`Running review loop for service ${step.service}`);
1321
2031
  const result = await runReviewLoop({
1322
2032
  taskId: task.id,
1323
2033
  task,
1324
- step: step2,
2034
+ step,
1325
2035
  stepState: currentStep,
1326
2036
  projectRoot,
1327
2037
  config,
@@ -1332,9 +2042,9 @@ async function runReview(options) {
1332
2042
  if (result.decision === "escalate") {
1333
2043
  escalation({
1334
2044
  taskId: task.id,
1335
- service: step2.service,
2045
+ service: step.service,
1336
2046
  iteration: result.finalIteration,
1337
- spec: step2.spec,
2047
+ spec: step.spec,
1338
2048
  diff: "",
1339
2049
  reviewComments: result.lastReviewComments,
1340
2050
  arbiterReasoning: result.lastArbiterResult.reasoning,
@@ -1354,9 +2064,9 @@ async function runReview(options) {
1354
2064
  if (!options.dryRun) {
1355
2065
  saveState(projectRoot, state);
1356
2066
  }
1357
- } catch (error) {
1358
- const message = error instanceof Error ? error.message : String(error);
1359
- fatalAndExit4(message);
2067
+ } catch (error2) {
2068
+ const message = error2 instanceof Error ? error2.message : String(error2);
2069
+ fatalAndExit5(message);
1360
2070
  }
1361
2071
  }
1362
2072
  function registerReviewCommand(program2) {
@@ -1367,25 +2077,103 @@ function registerReviewCommand(program2) {
1367
2077
  }
1368
2078
 
1369
2079
  // src/commands/start.ts
1370
- 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
+ }
1371
2159
 
1372
2160
  // src/lib/submit-task.ts
1373
- import fs8 from "fs";
1374
- import path9 from "path";
2161
+ import fs9 from "fs";
2162
+ import path10 from "path";
1375
2163
  async function submitActiveTask(projectRoot, config, state) {
1376
- for (const step2 of state.steps) {
1377
- if (step2.status !== "done" && step2.status !== "in_progress") {
2164
+ for (const step of state.steps) {
2165
+ if (step.status !== "done" && step.status !== "in_progress") {
1378
2166
  continue;
1379
2167
  }
1380
- const service = config.services.find((item) => item.name === step2.service);
2168
+ const service = config.services.find((item) => item.name === step.service);
1381
2169
  if (!service) {
1382
- throw new Error(`Unknown service in state: ${step2.service}`);
2170
+ throw new Error(`Unknown service in state: ${step.service}`);
1383
2171
  }
1384
- const servicePath = path9.resolve(projectRoot, service.path);
2172
+ const servicePath = path10.resolve(projectRoot, service.path);
1385
2173
  const body = `Task: ${state.taskId}
1386
- Service: ${step2.service}`;
2174
+ Service: ${step.service}`;
1387
2175
  const url = await createPr({
1388
- title: `${state.taskTitle} [${step2.service}]`,
2176
+ title: `${state.taskTitle} [${step.service}]`,
1389
2177
  body,
1390
2178
  base: "main",
1391
2179
  cwd: servicePath
@@ -1395,7 +2183,7 @@ Service: ${step2.service}`;
1395
2183
  state.status = "done";
1396
2184
  saveState(projectRoot, state);
1397
2185
  const doneDir = ensureTaskDirectory(projectRoot, "done");
1398
- if (fs8.existsSync(state.taskPath)) {
2186
+ if (fs9.existsSync(state.taskPath)) {
1399
2187
  state.taskPath = moveTaskFileAtomically(state.taskPath, doneDir);
1400
2188
  saveState(projectRoot, state);
1401
2189
  }
@@ -1403,7 +2191,9 @@ Service: ${step2.service}`;
1403
2191
  }
1404
2192
 
1405
2193
  // src/commands/start.ts
1406
- function fatalAndExit5(message, hint) {
2194
+ var POLL_INTERVAL_MS = 5e3;
2195
+ var POLL_TIMEOUT_MS = 10 * 6e4;
2196
+ function fatalAndExit6(message, hint) {
1407
2197
  fatal(message, hint);
1408
2198
  process.exit(1);
1409
2199
  }
@@ -1411,17 +2201,18 @@ async function runStart(taskFile, options) {
1411
2201
  try {
1412
2202
  const projectRoot = findProjectRoot();
1413
2203
  if (!projectRoot) {
1414
- fatalAndExit5("Not inside a vexdo project. Could not find .vexdo.yml.");
2204
+ fatalAndExit6("Not inside a vexdo project. Could not find .vexdo.yml.");
1415
2205
  }
1416
2206
  const config = loadConfig(projectRoot);
1417
- const taskPath = path10.resolve(taskFile);
2207
+ const taskPath = path11.resolve(taskFile);
1418
2208
  const task = loadAndValidateTask(taskPath, config);
1419
2209
  if (hasActiveTask(projectRoot) && !options.resume) {
1420
- 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.");
1421
2211
  }
1422
2212
  if (!options.dryRun) {
1423
2213
  requireAnthropicApiKey();
1424
2214
  await checkCodexAvailable();
2215
+ await checkCopilotAvailable();
1425
2216
  }
1426
2217
  let state = loadState(projectRoot);
1427
2218
  if (!options.resume) {
@@ -1436,94 +2227,119 @@ async function runStart(taskFile, options) {
1436
2227
  }
1437
2228
  }
1438
2229
  if (!state) {
1439
- fatalAndExit5("No resumable task state found.");
2230
+ fatalAndExit6("No resumable task state found.");
1440
2231
  }
1441
2232
  const claude = new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? "");
1442
- const total = task.steps.length;
1443
- for (let i = 0; i < task.steps.length; i += 1) {
1444
- const step2 = task.steps[i];
1445
- const stepState = state.steps[i];
1446
- if (!step2 || !stepState) {
1447
- continue;
1448
- }
1449
- if (stepState.status === "done") {
1450
- continue;
1451
- }
1452
- if (step2.depends_on && step2.depends_on.length > 0) {
1453
- for (const depService of step2.depends_on) {
1454
- const depState = state.steps.find((item) => item.service === depService);
1455
- if (depState?.status !== "done") {
1456
- 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
+ });
1457
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 };
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 };
1458
2321
  }
1459
2322
  }
1460
- step(i + 1, total, `${step2.service}: ${task.title}`);
1461
- const serviceCfg = config.services.find((service) => service.name === step2.service);
1462
- if (!serviceCfg) {
1463
- fatalAndExit5(`Unknown service in step: ${step2.service}`);
1464
- }
1465
- const serviceRoot = path10.resolve(projectRoot, serviceCfg.path);
1466
- const branch = getBranchName(task.id, step2.service);
1467
- if (!options.dryRun) {
1468
- if (options.resume) {
1469
- await checkoutBranch(stepState.branch ?? branch, serviceRoot);
1470
- } else {
1471
- 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" });
1472
2328
  }
1473
2329
  }
1474
- stepState.status = "in_progress";
1475
- 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";
1476
2336
  if (!options.dryRun) {
1477
2337
  saveState(projectRoot, state);
1478
- }
1479
- if (!options.resume && !options.dryRun) {
1480
- info(`Running codex implementation for service ${step2.service}`);
1481
- await exec({
1482
- spec: step2.spec,
1483
- model: config.codex.model,
1484
- cwd: serviceRoot,
1485
- verbose: options.verbose
1486
- });
1487
- } else if (options.dryRun) {
1488
- info(`[dry-run] Would run codex for service ${step2.service}`);
1489
- }
1490
- info(`Starting review loop for service ${step2.service}`);
1491
- const result = await runReviewLoop({
1492
- taskId: task.id,
1493
- task,
1494
- step: step2,
1495
- stepState,
1496
- projectRoot,
1497
- config,
1498
- claude,
1499
- dryRun: options.dryRun,
1500
- verbose: options.verbose
1501
- });
1502
- if (result.decision === "escalate") {
1503
- escalation({
1504
- taskId: task.id,
1505
- service: step2.service,
1506
- iteration: result.finalIteration,
1507
- spec: step2.spec,
1508
- diff: "",
1509
- reviewComments: result.lastReviewComments,
1510
- arbiterReasoning: result.lastArbiterResult.reasoning,
1511
- summary: result.lastArbiterResult.summary
1512
- });
1513
- stepState.status = "escalated";
1514
- state.status = "escalated";
1515
- if (!options.dryRun) {
1516
- saveState(projectRoot, state);
1517
- const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
1518
- state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
1519
- saveState(projectRoot, state);
1520
- }
1521
- process.exit(1);
1522
- }
1523
- stepState.status = "done";
1524
- if (!options.dryRun) {
2338
+ const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
2339
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
1525
2340
  saveState(projectRoot, state);
1526
2341
  }
2342
+ process.exit(1);
1527
2343
  }
1528
2344
  state.status = "review";
1529
2345
  if (!options.dryRun) {
@@ -1536,9 +2352,94 @@ async function runStart(taskFile, options) {
1536
2352
  return;
1537
2353
  }
1538
2354
  success("Task ready for PR. Run 'vexdo submit' to create PR.");
1539
- } catch (error) {
1540
- const message = error instanceof Error ? error.message : String(error);
1541
- 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;
1542
2443
  }
1543
2444
  }
1544
2445
  function registerStartCommand(program2) {
@@ -1549,11 +2450,11 @@ function registerStartCommand(program2) {
1549
2450
  }
1550
2451
 
1551
2452
  // src/commands/status.ts
1552
- function fatalAndExit6(message) {
2453
+ function fatalAndExit7(message) {
1553
2454
  fatal(message);
1554
2455
  process.exit(1);
1555
2456
  }
1556
- function formatElapsed3(startedAt) {
2457
+ function formatElapsed2(startedAt) {
1557
2458
  const elapsedMs = Date.now() - new Date(startedAt).getTime();
1558
2459
  const minutes = Math.floor(elapsedMs / 1e3 / 60);
1559
2460
  const hours = Math.floor(minutes / 60);
@@ -1565,23 +2466,23 @@ function formatElapsed3(startedAt) {
1565
2466
  function runStatus() {
1566
2467
  const projectRoot = findProjectRoot();
1567
2468
  if (!projectRoot) {
1568
- fatalAndExit6("Not inside a vexdo project.");
2469
+ fatalAndExit7("Not inside a vexdo project.");
1569
2470
  }
1570
2471
  const state = loadState(projectRoot);
1571
2472
  if (!state) {
1572
- fatalAndExit6("No active task.");
2473
+ fatalAndExit7("No active task.");
1573
2474
  }
1574
2475
  info(`Task: ${state.taskId} \u2014 ${state.taskTitle}`);
1575
2476
  info(`Status: ${state.status}`);
1576
2477
  console.log("service | status | iteration | branch");
1577
- for (const step2 of state.steps) {
1578
- 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 ?? "-"}`);
1579
2480
  }
1580
- const inProgress = state.steps.find((step2) => step2.status === "in_progress");
2481
+ const inProgress = state.steps.find((step) => step.status === "in_progress");
1581
2482
  if (inProgress?.lastArbiterResult?.summary) {
1582
2483
  info(`Last arbiter summary: ${inProgress.lastArbiterResult.summary}`);
1583
2484
  }
1584
- info(`Elapsed: ${formatElapsed3(state.startedAt)}`);
2485
+ info(`Elapsed: ${formatElapsed2(state.startedAt)}`);
1585
2486
  }
1586
2487
  function registerStatusCommand(program2) {
1587
2488
  program2.command("status").description("Print active task status").action(() => {
@@ -1590,7 +2491,7 @@ function registerStatusCommand(program2) {
1590
2491
  }
1591
2492
 
1592
2493
  // src/commands/submit.ts
1593
- function fatalAndExit7(message) {
2494
+ function fatalAndExit8(message) {
1594
2495
  fatal(message);
1595
2496
  process.exit(1);
1596
2497
  }
@@ -1598,17 +2499,17 @@ async function runSubmit() {
1598
2499
  try {
1599
2500
  const projectRoot = findProjectRoot();
1600
2501
  if (!projectRoot) {
1601
- fatalAndExit7("Not inside a vexdo project.");
2502
+ fatalAndExit8("Not inside a vexdo project.");
1602
2503
  }
1603
2504
  const config = loadConfig(projectRoot);
1604
2505
  const state = loadState(projectRoot);
1605
2506
  if (!state) {
1606
- fatalAndExit7("No active task.");
2507
+ fatalAndExit8("No active task.");
1607
2508
  }
1608
2509
  await requireGhAvailable();
1609
2510
  await submitActiveTask(projectRoot, config, state);
1610
- } catch (error) {
1611
- fatalAndExit7(error instanceof Error ? error.message : String(error));
2511
+ } catch (error2) {
2512
+ fatalAndExit8(error2 instanceof Error ? error2.message : String(error2));
1612
2513
  }
1613
2514
  }
1614
2515
  function registerSubmitCommand(program2) {
@@ -1618,8 +2519,8 @@ function registerSubmitCommand(program2) {
1618
2519
  }
1619
2520
 
1620
2521
  // src/index.ts
1621
- var packageJsonPath = path11.resolve(path11.dirname(new URL(import.meta.url).pathname), "..", "package.json");
1622
- 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"));
1623
2524
  var program = new Command();
1624
2525
  program.name("vexdo").description("Vexdo CLI").version(packageJson.version).option("--verbose", "Enable verbose logs").option("--dry-run", "Print plan without making changes");
1625
2526
  program.hook("preAction", (_thisCommand, actionCommand) => {
@@ -1634,4 +2535,5 @@ registerSubmitCommand(program);
1634
2535
  registerStatusCommand(program);
1635
2536
  registerAbortCommand(program);
1636
2537
  registerLogsCommand(program);
2538
+ registerBoardCommand(program);
1637
2539
  program.parse(process.argv);