@vexdo/cli 0.1.0 → 0.1.2

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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +1597 -0
  4. package/package.json +9 -1
  5. package/.eslintrc.json +0 -23
  6. package/.github/workflows/ci.yml +0 -84
  7. package/.idea/copilot.data.migration.ask2agent.xml +0 -6
  8. package/.idea/go.imports.xml +0 -11
  9. package/.idea/misc.xml +0 -6
  10. package/.idea/modules.xml +0 -8
  11. package/.idea/vcs.xml +0 -7
  12. package/.idea/vexdo-cli.iml +0 -9
  13. package/.prettierrc +0 -5
  14. package/CLAUDE.md +0 -93
  15. package/CONTRIBUTING.md +0 -62
  16. package/src/commands/abort.ts +0 -66
  17. package/src/commands/fix.ts +0 -106
  18. package/src/commands/init.ts +0 -142
  19. package/src/commands/logs.ts +0 -74
  20. package/src/commands/review.ts +0 -107
  21. package/src/commands/start.ts +0 -197
  22. package/src/commands/status.ts +0 -52
  23. package/src/commands/submit.ts +0 -38
  24. package/src/index.ts +0 -42
  25. package/src/lib/claude.ts +0 -259
  26. package/src/lib/codex.ts +0 -96
  27. package/src/lib/config.ts +0 -157
  28. package/src/lib/gh.ts +0 -78
  29. package/src/lib/git.ts +0 -119
  30. package/src/lib/logger.ts +0 -147
  31. package/src/lib/requirements.ts +0 -18
  32. package/src/lib/review-loop.ts +0 -154
  33. package/src/lib/state.ts +0 -121
  34. package/src/lib/submit-task.ts +0 -43
  35. package/src/lib/tasks.ts +0 -94
  36. package/src/prompts/arbiter.ts +0 -21
  37. package/src/prompts/reviewer.ts +0 -20
  38. package/src/types/index.ts +0 -96
  39. package/test/config.test.ts +0 -124
  40. package/test/state.test.ts +0 -147
  41. package/test/unit/claude.test.ts +0 -117
  42. package/test/unit/codex.test.ts +0 -67
  43. package/test/unit/gh.test.ts +0 -49
  44. package/test/unit/git.test.ts +0 -120
  45. package/test/unit/review-loop.test.ts +0 -198
  46. package/tests/integration/review.test.ts +0 -137
  47. package/tests/integration/start.test.ts +0 -220
  48. package/tests/unit/init.test.ts +0 -91
  49. package/tsconfig.json +0 -15
  50. package/tsup.config.ts +0 -8
  51. package/vitest.config.ts +0 -7
package/dist/index.js ADDED
@@ -0,0 +1,1597 @@
1
+ // src/index.ts
2
+ import fs9 from "fs";
3
+ import path11 from "path";
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/abort.ts
7
+ import fs4 from "fs";
8
+ import path4 from "path";
9
+ import readline from "readline/promises";
10
+
11
+ // src/lib/config.ts
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import { parse } from "yaml";
15
+ var DEFAULT_REVIEW_MODEL = "claude-haiku-4-5-20251001";
16
+ var DEFAULT_MAX_ITERATIONS = 3;
17
+ var DEFAULT_AUTO_SUBMIT = false;
18
+ var DEFAULT_CODEX_MODEL = "gpt-4o";
19
+ function isRecord(value) {
20
+ return typeof value === "object" && value !== null;
21
+ }
22
+ function readObjectField(obj, fieldPath) {
23
+ return obj[fieldPath];
24
+ }
25
+ function requireString(value, fieldPath) {
26
+ if (typeof value !== "string" || value.length === 0) {
27
+ throw new Error(`${fieldPath} must be a non-empty string`);
28
+ }
29
+ return value;
30
+ }
31
+ function parseServices(value) {
32
+ if (!Array.isArray(value) || value.length === 0) {
33
+ throw new Error("services must be a non-empty array");
34
+ }
35
+ return value.map((service, index) => {
36
+ if (!isRecord(service)) {
37
+ throw new Error(`services[${String(index)}] must be an object`);
38
+ }
39
+ const name = requireString(readObjectField(service, "name"), `services[${String(index)}].name`);
40
+ const servicePath = requireString(readObjectField(service, "path"), `services[${String(index)}].path`);
41
+ return {
42
+ name,
43
+ path: servicePath
44
+ };
45
+ });
46
+ }
47
+ function parseReview(value) {
48
+ if (value === void 0) {
49
+ return {
50
+ model: DEFAULT_REVIEW_MODEL,
51
+ max_iterations: DEFAULT_MAX_ITERATIONS,
52
+ auto_submit: DEFAULT_AUTO_SUBMIT
53
+ };
54
+ }
55
+ if (!isRecord(value)) {
56
+ throw new Error("review must be an object");
57
+ }
58
+ const modelRaw = readObjectField(value, "model");
59
+ const iterationsRaw = readObjectField(value, "max_iterations");
60
+ const autoSubmitRaw = readObjectField(value, "auto_submit");
61
+ const model = modelRaw === void 0 ? DEFAULT_REVIEW_MODEL : requireString(modelRaw, "review.model");
62
+ let max_iterations = DEFAULT_MAX_ITERATIONS;
63
+ if (iterationsRaw !== void 0) {
64
+ if (typeof iterationsRaw !== "number" || !Number.isInteger(iterationsRaw) || iterationsRaw <= 0) {
65
+ throw new Error("review.max_iterations must be a positive integer");
66
+ }
67
+ max_iterations = iterationsRaw;
68
+ }
69
+ let auto_submit = DEFAULT_AUTO_SUBMIT;
70
+ if (autoSubmitRaw !== void 0) {
71
+ if (typeof autoSubmitRaw !== "boolean") {
72
+ throw new Error("review.auto_submit must be a boolean");
73
+ }
74
+ auto_submit = autoSubmitRaw;
75
+ }
76
+ return {
77
+ model,
78
+ max_iterations,
79
+ auto_submit
80
+ };
81
+ }
82
+ function parseCodex(value) {
83
+ if (value === void 0) {
84
+ return { model: DEFAULT_CODEX_MODEL };
85
+ }
86
+ if (!isRecord(value)) {
87
+ throw new Error("codex must be an object");
88
+ }
89
+ const modelRaw = readObjectField(value, "model");
90
+ const model = modelRaw === void 0 ? DEFAULT_CODEX_MODEL : requireString(modelRaw, "codex.model");
91
+ return { model };
92
+ }
93
+ function findProjectRoot(startDir = process.cwd()) {
94
+ let current = path.resolve(startDir);
95
+ let reachedRoot = false;
96
+ while (!reachedRoot) {
97
+ const candidate = path.join(current, ".vexdo.yml");
98
+ if (fs.existsSync(candidate)) {
99
+ return current;
100
+ }
101
+ const parent = path.dirname(current);
102
+ reachedRoot = parent === current;
103
+ current = parent;
104
+ }
105
+ return null;
106
+ }
107
+ function loadConfig(projectRoot) {
108
+ const configPath = path.join(projectRoot, ".vexdo.yml");
109
+ if (!fs.existsSync(configPath)) {
110
+ throw new Error(`Configuration file not found: ${configPath}`);
111
+ }
112
+ const configRaw = fs.readFileSync(configPath, "utf8");
113
+ let parsed;
114
+ try {
115
+ parsed = parse(configRaw);
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new Error(`Invalid YAML in .vexdo.yml: ${message}`);
119
+ }
120
+ if (!isRecord(parsed)) {
121
+ throw new Error("config must be an object");
122
+ }
123
+ const versionRaw = readObjectField(parsed, "version");
124
+ if (versionRaw !== 1) {
125
+ throw new Error("version must be 1");
126
+ }
127
+ const services = parseServices(readObjectField(parsed, "services"));
128
+ const review = parseReview(readObjectField(parsed, "review"));
129
+ const codex = parseCodex(readObjectField(parsed, "codex"));
130
+ return {
131
+ version: 1,
132
+ services,
133
+ review,
134
+ codex
135
+ };
136
+ }
137
+
138
+ // src/lib/logger.ts
139
+ import ora from "ora";
140
+ import pc from "picocolors";
141
+ var verboseEnabled = false;
142
+ function safeLog(method, message) {
143
+ try {
144
+ if (method === "error") {
145
+ console.error(message);
146
+ } else {
147
+ console.log(message);
148
+ }
149
+ } catch {
150
+ }
151
+ }
152
+ function setVerbose(enabled) {
153
+ verboseEnabled = enabled;
154
+ }
155
+ function info(message) {
156
+ safeLog("log", `${pc.cyan("\u2192")} ${message}`);
157
+ }
158
+ function success(message) {
159
+ safeLog("log", `${pc.green("\u2713")} ${message}`);
160
+ }
161
+ function warn(message) {
162
+ safeLog("log", `${pc.yellow("\u26A0")} ${message}`);
163
+ }
164
+ function debug(message) {
165
+ if (!verboseEnabled) {
166
+ return;
167
+ }
168
+ safeLog("log", `${pc.gray("\u2022")} ${message}`);
169
+ }
170
+ function step(n, total, title) {
171
+ safeLog("log", `${pc.bold(`Step ${String(n)}/${String(total)}:`)} ${title}`);
172
+ }
173
+ function iteration(n, max) {
174
+ safeLog("log", pc.gray(`Iteration ${String(n)}/${String(max)}`));
175
+ }
176
+ function fatal(message, hint) {
177
+ safeLog("error", `${pc.bold(pc.red("Error:"))} ${message}`);
178
+ if (hint) {
179
+ safeLog("error", `${pc.gray("Hint:")} ${hint}`);
180
+ }
181
+ }
182
+ function escalation(context) {
183
+ const lines = [
184
+ pc.bold(pc.red("Escalation triggered")),
185
+ `${pc.gray("Task:")} ${context.taskId}`,
186
+ `${pc.gray("Service:")} ${context.service}`,
187
+ `${pc.gray("Iteration:")} ${String(context.iteration)}`,
188
+ `${pc.gray("Summary:")} ${context.summary}`,
189
+ "",
190
+ pc.bold("Spec:"),
191
+ context.spec,
192
+ "",
193
+ pc.bold("Arbiter reasoning:"),
194
+ context.arbiterReasoning,
195
+ "",
196
+ pc.bold("Review comments:")
197
+ ];
198
+ for (const comment of context.reviewComments) {
199
+ const sevColor = comment.severity === "critical" ? pc.red : comment.severity === "important" ? pc.yellow : comment.severity === "minor" ? pc.cyan : pc.gray;
200
+ const location = comment.file ? ` (${comment.file}${comment.line ? `:${String(comment.line)}` : ""})` : "";
201
+ lines.push(`- ${sevColor(comment.severity.toUpperCase())}${location}: ${comment.comment}`);
202
+ if (comment.suggestion) {
203
+ lines.push(` ${pc.gray(`Suggestion: ${comment.suggestion}`)}`);
204
+ }
205
+ }
206
+ lines.push("", pc.bold("Diff:"), context.diff, "", pc.gray("Hint: run `vexdo abort` to clear state."));
207
+ safeLog("error", lines.join("\n"));
208
+ }
209
+ function reviewSummary(comments) {
210
+ const counts = {
211
+ critical: 0,
212
+ important: 0,
213
+ minor: 0,
214
+ noise: 0
215
+ };
216
+ for (const comment of comments) {
217
+ counts[comment.severity] += 1;
218
+ }
219
+ safeLog(
220
+ "log",
221
+ `${pc.bold("Review:")} ${String(counts.critical)} critical ${String(counts.important)} important ${String(counts.minor)} minor`
222
+ );
223
+ for (const comment of comments) {
224
+ if (comment.severity === "noise") {
225
+ continue;
226
+ }
227
+ const location = comment.file ? ` (${comment.file}${comment.line ? `:${String(comment.line)}` : ""})` : "";
228
+ safeLog("log", `- ${comment.severity}${location}: ${comment.comment}`);
229
+ }
230
+ }
231
+
232
+ // src/lib/state.ts
233
+ import fs2 from "fs";
234
+ import path2 from "path";
235
+ function nowIso() {
236
+ return (/* @__PURE__ */ new Date()).toISOString();
237
+ }
238
+ function getStateDir(projectRoot) {
239
+ return path2.join(projectRoot, ".vexdo");
240
+ }
241
+ function getStatePath(projectRoot) {
242
+ return path2.join(getStateDir(projectRoot), "state.json");
243
+ }
244
+ function getLogsDir(projectRoot, taskId) {
245
+ return path2.join(getStateDir(projectRoot), "logs", taskId);
246
+ }
247
+ function ensureLogsDir(projectRoot, taskId) {
248
+ const logsDir = getLogsDir(projectRoot, taskId);
249
+ fs2.mkdirSync(logsDir, { recursive: true });
250
+ return logsDir;
251
+ }
252
+ function loadState(projectRoot) {
253
+ const statePath = getStatePath(projectRoot);
254
+ if (!fs2.existsSync(statePath)) {
255
+ return null;
256
+ }
257
+ const raw = fs2.readFileSync(statePath, "utf8");
258
+ try {
259
+ return JSON.parse(raw);
260
+ } catch (error) {
261
+ const message = error instanceof Error ? error.message : String(error);
262
+ throw new Error(`Corrupt state file at ${statePath}: ${message}`);
263
+ }
264
+ }
265
+ function saveState(projectRoot, state) {
266
+ const stateDir = getStateDir(projectRoot);
267
+ fs2.mkdirSync(stateDir, { recursive: true });
268
+ const nextState = {
269
+ ...state,
270
+ updatedAt: nowIso()
271
+ };
272
+ fs2.writeFileSync(getStatePath(projectRoot), JSON.stringify(nextState, null, 2) + "\n", "utf8");
273
+ }
274
+ function clearState(projectRoot) {
275
+ const statePath = getStatePath(projectRoot);
276
+ if (fs2.existsSync(statePath)) {
277
+ fs2.rmSync(statePath);
278
+ }
279
+ }
280
+ function hasActiveTask(projectRoot) {
281
+ const state = loadState(projectRoot);
282
+ return state?.status === "in_progress" || state?.status === "review";
283
+ }
284
+ function createState(taskId, taskTitle, taskPath, steps) {
285
+ const timestamp = nowIso();
286
+ return {
287
+ taskId,
288
+ taskTitle,
289
+ taskPath,
290
+ status: "in_progress",
291
+ steps: [...steps],
292
+ startedAt: timestamp,
293
+ updatedAt: timestamp
294
+ };
295
+ }
296
+ function saveIterationLog(projectRoot, taskId, service, iteration2, payload) {
297
+ const logsDir = ensureLogsDir(projectRoot, taskId);
298
+ const base = `${service}-iteration-${String(iteration2)}`;
299
+ fs2.writeFileSync(path2.join(logsDir, `${base}-diff.txt`), payload.diff, "utf8");
300
+ fs2.writeFileSync(path2.join(logsDir, `${base}-review.json`), JSON.stringify(payload.review, null, 2) + "\n", "utf8");
301
+ fs2.writeFileSync(
302
+ path2.join(logsDir, `${base}-arbiter.json`),
303
+ JSON.stringify(payload.arbiter, null, 2) + "\n",
304
+ "utf8"
305
+ );
306
+ }
307
+
308
+ // src/lib/tasks.ts
309
+ import fs3 from "fs";
310
+ import path3 from "path";
311
+ import { load as parseYaml } from "js-yaml";
312
+ function isRecord2(value) {
313
+ return typeof value === "object" && value !== null;
314
+ }
315
+ function requireString2(value, field) {
316
+ if (typeof value !== "string" || value.trim().length === 0) {
317
+ throw new Error(`${field} must be a non-empty string`);
318
+ }
319
+ return value;
320
+ }
321
+ function parseTaskStep(value, index, config) {
322
+ if (!isRecord2(value)) {
323
+ throw new Error(`steps[${String(index)}] must be an object`);
324
+ }
325
+ const service = requireString2(value.service, `steps[${String(index)}].service`);
326
+ const spec = requireString2(value.spec, `steps[${String(index)}].spec`);
327
+ if (!config.services.some((item) => item.name === service)) {
328
+ throw new Error(`steps[${String(index)}].service references unknown service '${service}'`);
329
+ }
330
+ const dependsOnRaw = value.depends_on;
331
+ let depends_on;
332
+ if (dependsOnRaw !== void 0) {
333
+ if (!Array.isArray(dependsOnRaw) || !dependsOnRaw.every((dep) => typeof dep === "string" && dep.trim().length > 0)) {
334
+ throw new Error(`steps[${String(index)}].depends_on must be an array of non-empty strings`);
335
+ }
336
+ depends_on = dependsOnRaw;
337
+ }
338
+ return {
339
+ service,
340
+ spec,
341
+ depends_on
342
+ };
343
+ }
344
+ function loadAndValidateTask(taskPath, config) {
345
+ const raw = fs3.readFileSync(taskPath, "utf8");
346
+ let parsed;
347
+ try {
348
+ parsed = parseYaml(raw);
349
+ } catch (error) {
350
+ const message = error instanceof Error ? error.message : String(error);
351
+ throw new Error(`Invalid task YAML: ${message}`);
352
+ }
353
+ if (!isRecord2(parsed)) {
354
+ throw new Error("task must be a YAML object");
355
+ }
356
+ const id = requireString2(parsed.id, "id");
357
+ const title = requireString2(parsed.title, "title");
358
+ if (!Array.isArray(parsed.steps) || parsed.steps.length === 0) {
359
+ throw new Error("steps must be a non-empty array");
360
+ }
361
+ const steps = parsed.steps.map((step2, index) => parseTaskStep(step2, index, config));
362
+ return { id, title, steps };
363
+ }
364
+ function buildInitialStepState(task) {
365
+ return task.steps.map((step2) => ({
366
+ service: step2.service,
367
+ status: "pending",
368
+ iteration: 0
369
+ }));
370
+ }
371
+ function ensureTaskDirectory(projectRoot, taskState) {
372
+ const dir = path3.join(projectRoot, "tasks", taskState);
373
+ fs3.mkdirSync(dir, { recursive: true });
374
+ return dir;
375
+ }
376
+ function moveTaskFileAtomically(taskPath, destinationDir) {
377
+ const destinationPath = path3.join(destinationDir, path3.basename(taskPath));
378
+ fs3.renameSync(taskPath, destinationPath);
379
+ return destinationPath;
380
+ }
381
+
382
+ // src/commands/abort.ts
383
+ function fatalAndExit(message) {
384
+ fatal(message);
385
+ process.exit(1);
386
+ }
387
+ async function promptConfirmation(taskId) {
388
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
389
+ try {
390
+ const answer = await rl.question(`Abort task ${taskId}? Branches will be kept. [y/N] `);
391
+ return answer.trim().toLowerCase() === "y";
392
+ } finally {
393
+ rl.close();
394
+ }
395
+ }
396
+ async function runAbort(options) {
397
+ const projectRoot = findProjectRoot();
398
+ if (!projectRoot) {
399
+ fatalAndExit("Not inside a vexdo project.");
400
+ }
401
+ const state = loadState(projectRoot);
402
+ if (!state) {
403
+ fatalAndExit("No active task.");
404
+ }
405
+ if (!options.force) {
406
+ const confirmed = await promptConfirmation(state.taskId);
407
+ if (!confirmed) {
408
+ info("Abort cancelled.");
409
+ return;
410
+ }
411
+ }
412
+ const inProgressDir = path4.join(projectRoot, "tasks", "in_progress");
413
+ if (state.taskPath.startsWith(inProgressDir) && fs4.existsSync(state.taskPath)) {
414
+ const backlogDir = ensureTaskDirectory(projectRoot, "backlog");
415
+ moveTaskFileAtomically(state.taskPath, backlogDir);
416
+ }
417
+ clearState(projectRoot);
418
+ info("Task aborted. Branches preserved for manual review.");
419
+ }
420
+ function registerAbortCommand(program2) {
421
+ program2.command("abort").description("Abort active task").option("--force", "Skip confirmation prompt").action(async (options) => {
422
+ await runAbort(options);
423
+ });
424
+ }
425
+
426
+ // src/commands/fix.ts
427
+ import path6 from "path";
428
+
429
+ // src/lib/claude.ts
430
+ import Anthropic from "@anthropic-ai/sdk";
431
+
432
+ // src/prompts/arbiter.ts
433
+ var ARBITER_SYSTEM_PROMPT = `You are a technical arbiter receiving: spec, diff, and reviewer comments.
434
+
435
+ Rules:
436
+ - Treat the spec as the single source of truth, not reviewer opinion.
437
+ - Explicitly check each reviewer comment against the spec:
438
+ - If a comment correctly identifies a spec violation, include it in the decision.
439
+ - If a comment conflicts with the spec or invents requirements, flag it and escalate.
440
+ - If comments are ambiguous, escalate.
441
+ - Never resolve architectural decisions autonomously; escalate instead.
442
+ - When decision is fix, feedback_for_codex must be concrete and actionable: what to change, where, and how.
443
+
444
+ Decision rules:
445
+ - submit: no critical/important comments that reflect real spec violations.
446
+ - fix: clear spec violations with actionable fixes; include feedback_for_codex.
447
+ - escalate: any reviewer/spec conflict, architectural ambiguity, or if max-iteration-like uncertainty would require escalation.
448
+
449
+ Output requirements:
450
+ - Output ONLY valid JSON with schema:
451
+ { "decision", "reasoning", "feedback_for_codex", "summary" }
452
+ - feedback_for_codex is required when decision="fix" and must be omitted otherwise.
453
+ - Never output prose outside the JSON.`;
454
+
455
+ // src/prompts/reviewer.ts
456
+ var REVIEWER_SYSTEM_PROMPT = `You are a strict code reviewer evaluating a git diff against a provided spec.
457
+
458
+ Rules:
459
+ - Treat the spec (acceptance criteria + architectural constraints) as the ONLY source of truth.
460
+ - Never invent requirements that are not explicitly present in the spec.
461
+ - Evaluate whether the diff violates the spec; distinguish real violations from stylistic preferences.
462
+ - For each issue, provide exact file and line number when visible in the diff.
463
+ - Severity definitions (strict):
464
+ - critical: breaks an acceptance criterion or architectural constraint.
465
+ - important: likely to cause bugs or maintenance issues directly related to the spec.
466
+ - minor: code quality issue not blocking the spec.
467
+ - noise: style/preference, spec-neutral.
468
+ - If the diff fully satisfies the spec, return no comments.
469
+
470
+ Output requirements:
471
+ - Output ONLY valid JSON.
472
+ - JSON schema:
473
+ { "comments": [ { "severity", "file", "line", "comment", "suggestion" } ] }
474
+ - If no issues: { "comments": [] }
475
+ - Never output prose outside the JSON.`;
476
+
477
+ // src/lib/claude.ts
478
+ var REVIEWER_MAX_TOKENS_DEFAULT = 4096;
479
+ var ARBITER_MAX_TOKENS_DEFAULT = 2048;
480
+ var MAX_ATTEMPTS = 3;
481
+ var ClaudeError = class extends Error {
482
+ attempt;
483
+ cause;
484
+ constructor(attempt, cause) {
485
+ const message = cause instanceof Error ? cause.message : String(cause);
486
+ super(`Claude API failed after ${String(attempt)} attempts: ${message}`);
487
+ this.name = "ClaudeError";
488
+ this.attempt = attempt;
489
+ this.cause = cause;
490
+ }
491
+ };
492
+ async function sleep(ms) {
493
+ await new Promise((resolve) => {
494
+ setTimeout(resolve, ms);
495
+ });
496
+ }
497
+ var ClaudeClient = class {
498
+ client;
499
+ constructor(apiKey) {
500
+ this.client = new Anthropic({ apiKey });
501
+ }
502
+ async runReviewer(opts) {
503
+ return this.runWithRetry(async () => {
504
+ const response = await this.client.messages.create({
505
+ model: opts.model,
506
+ max_tokens: opts.maxTokens ?? REVIEWER_MAX_TOKENS_DEFAULT,
507
+ system: REVIEWER_SYSTEM_PROMPT,
508
+ messages: [
509
+ {
510
+ role: "user",
511
+ content: `SPEC:
512
+ ${opts.spec}
513
+
514
+ DIFF:
515
+ ${opts.diff}`
516
+ }
517
+ ]
518
+ });
519
+ return parseReviewerResult(extractTextFromResponse(response));
520
+ });
521
+ }
522
+ async runArbiter(opts) {
523
+ return this.runWithRetry(async () => {
524
+ const response = await this.client.messages.create({
525
+ model: opts.model,
526
+ max_tokens: opts.maxTokens ?? ARBITER_MAX_TOKENS_DEFAULT,
527
+ system: ARBITER_SYSTEM_PROMPT,
528
+ messages: [
529
+ {
530
+ role: "user",
531
+ content: `SPEC:
532
+ ${opts.spec}
533
+
534
+ DIFF:
535
+ ${opts.diff}
536
+
537
+ REVIEWER COMMENTS:
538
+ ${JSON.stringify(opts.reviewComments)}`
539
+ }
540
+ ]
541
+ });
542
+ return parseArbiterResult(extractTextFromResponse(response));
543
+ });
544
+ }
545
+ async runWithRetry(fn) {
546
+ let lastError = new Error("Unknown Claude failure");
547
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
548
+ try {
549
+ return await fn();
550
+ } catch (error) {
551
+ lastError = error;
552
+ if (!isRetryableError(error) || attempt === MAX_ATTEMPTS) {
553
+ throw new ClaudeError(attempt, error);
554
+ }
555
+ const backoffMs = 1e3 * 2 ** (attempt - 1);
556
+ warn(
557
+ `Claude API error on attempt ${String(attempt)}/${String(MAX_ATTEMPTS)}. Retrying in ${String(Math.round(backoffMs / 1e3))}s...`
558
+ );
559
+ await sleep(backoffMs);
560
+ }
561
+ }
562
+ throw new ClaudeError(MAX_ATTEMPTS, lastError);
563
+ }
564
+ };
565
+ function extractTextFromResponse(response) {
566
+ const content = typeof response === "object" && response !== null && "content" in response ? response.content : null;
567
+ if (!Array.isArray(content)) {
568
+ throw new Error("Claude response missing content array");
569
+ }
570
+ const text = content.filter((block) => typeof block === "object" && block !== null && "type" in block).filter((block) => block.type === "text").map((block) => typeof block.text === "string" ? block.text : "").join("\n").trim();
571
+ if (!text) {
572
+ throw new Error("Claude response had no text content");
573
+ }
574
+ return text;
575
+ }
576
+ function extractJson(text) {
577
+ const trimmed = text.trim();
578
+ if (!trimmed.startsWith("```")) {
579
+ return trimmed;
580
+ }
581
+ const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
582
+ if (fenced && fenced[1]) {
583
+ return fenced[1].trim();
584
+ }
585
+ return trimmed;
586
+ }
587
+ function parseReviewerResult(raw) {
588
+ let parsed;
589
+ try {
590
+ parsed = JSON.parse(extractJson(raw));
591
+ } catch (error) {
592
+ throw new Error(`Failed to parse reviewer JSON: ${error instanceof Error ? error.message : String(error)}`);
593
+ }
594
+ if (!isReviewResult(parsed)) {
595
+ throw new Error("Reviewer JSON does not match schema");
596
+ }
597
+ return parsed;
598
+ }
599
+ function parseArbiterResult(raw) {
600
+ let parsed;
601
+ try {
602
+ parsed = JSON.parse(extractJson(raw));
603
+ } catch (error) {
604
+ throw new Error(`Failed to parse arbiter JSON: ${error instanceof Error ? error.message : String(error)}`);
605
+ }
606
+ if (!isArbiterResult(parsed)) {
607
+ throw new Error("Arbiter JSON does not match schema");
608
+ }
609
+ return parsed;
610
+ }
611
+ function isReviewComment(value) {
612
+ if (typeof value !== "object" || value === null) {
613
+ return false;
614
+ }
615
+ const candidate = value;
616
+ if (!["critical", "important", "minor", "noise"].includes(String(candidate.severity))) {
617
+ return false;
618
+ }
619
+ if (typeof candidate.comment !== "string") {
620
+ return false;
621
+ }
622
+ if (candidate.file !== void 0 && typeof candidate.file !== "string") {
623
+ return false;
624
+ }
625
+ if (candidate.line !== void 0 && typeof candidate.line !== "number") {
626
+ return false;
627
+ }
628
+ return !(candidate.suggestion !== void 0 && typeof candidate.suggestion !== "string");
629
+ }
630
+ function isReviewResult(value) {
631
+ if (typeof value !== "object" || value === null) {
632
+ return false;
633
+ }
634
+ const comments = value.comments;
635
+ return Array.isArray(comments) && comments.every((comment) => isReviewComment(comment));
636
+ }
637
+ function isArbiterResult(value) {
638
+ if (typeof value !== "object" || value === null) {
639
+ return false;
640
+ }
641
+ const candidate = value;
642
+ if (!["fix", "submit", "escalate"].includes(String(candidate.decision))) {
643
+ return false;
644
+ }
645
+ if (typeof candidate.reasoning !== "string" || typeof candidate.summary !== "string") {
646
+ return false;
647
+ }
648
+ if (candidate.decision === "fix") {
649
+ return typeof candidate.feedback_for_codex === "string" && candidate.feedback_for_codex.length > 0;
650
+ }
651
+ return candidate.feedback_for_codex === void 0;
652
+ }
653
+ function getStatusCode(error) {
654
+ if (typeof error !== "object" || error === null) {
655
+ return void 0;
656
+ }
657
+ const candidate = error;
658
+ if (typeof candidate.status === "number") {
659
+ return candidate.status;
660
+ }
661
+ if (typeof candidate.statusCode === "number") {
662
+ return candidate.statusCode;
663
+ }
664
+ return void 0;
665
+ }
666
+ function isRetryableError(error) {
667
+ const status = getStatusCode(error);
668
+ if (status === 400 || status === 401 || status === 403) {
669
+ return false;
670
+ }
671
+ if (status === 429 || status !== void 0 && status >= 500 && status <= 599) {
672
+ return true;
673
+ }
674
+ return true;
675
+ }
676
+
677
+ // src/lib/codex.ts
678
+ import { execFile as execFileCb } from "child_process";
679
+ var CODEX_TIMEOUT_MS = 6e5;
680
+ var VERBOSE_HEARTBEAT_MS = 15e3;
681
+ var CodexError = class extends Error {
682
+ stdout;
683
+ stderr;
684
+ exitCode;
685
+ constructor(stdout, stderr, exitCode) {
686
+ super(`codex exec failed (exit ${String(exitCode)})`);
687
+ this.name = "CodexError";
688
+ this.stdout = stdout;
689
+ this.stderr = stderr;
690
+ this.exitCode = exitCode;
691
+ }
692
+ };
693
+ var CodexNotFoundError = class extends Error {
694
+ constructor() {
695
+ super("codex CLI not found. Install it: npm install -g @openai/codex");
696
+ this.name = "CodexNotFoundError";
697
+ }
698
+ };
699
+ function formatElapsed(startedAt) {
700
+ const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1e3));
701
+ return `${String(seconds)}s`;
702
+ }
703
+ async function checkCodexAvailable() {
704
+ await new Promise((resolve, reject) => {
705
+ execFileCb("codex", ["--version"], { timeout: CODEX_TIMEOUT_MS, encoding: "utf8" }, (error) => {
706
+ if (error) {
707
+ reject(new CodexNotFoundError());
708
+ return;
709
+ }
710
+ resolve();
711
+ });
712
+ });
713
+ }
714
+ async function exec(opts) {
715
+ const args = ["exec", "--model", opts.model, "--full-auto", "--", opts.spec];
716
+ const startedAt = Date.now();
717
+ if (opts.verbose) {
718
+ debug(`[codex] starting (model=${opts.model}, cwd=${opts.cwd})`);
719
+ }
720
+ return await new Promise((resolve, reject) => {
721
+ const heartbeat = opts.verbose ? setInterval(() => {
722
+ debug(`[codex] still running (${formatElapsed(startedAt)})`);
723
+ }, VERBOSE_HEARTBEAT_MS) : null;
724
+ execFileCb(
725
+ "codex",
726
+ args,
727
+ { cwd: opts.cwd, timeout: CODEX_TIMEOUT_MS, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
728
+ (error, stdout, stderr) => {
729
+ if (heartbeat) {
730
+ clearInterval(heartbeat);
731
+ }
732
+ const normalizedStdout = stdout.trimEnd();
733
+ const normalizedStderr = stderr.trimEnd();
734
+ if (opts.verbose) {
735
+ debug(`[codex] finished in ${formatElapsed(startedAt)}`);
736
+ if (normalizedStdout) {
737
+ debug(normalizedStdout);
738
+ }
739
+ if (normalizedStderr) {
740
+ debug(normalizedStderr);
741
+ }
742
+ }
743
+ if (error) {
744
+ const exitCode = typeof error.code === "number" ? error.code : 1;
745
+ if (opts.verbose) {
746
+ debug(`[codex] failed in ${formatElapsed(startedAt)} with exit ${String(exitCode)}`);
747
+ }
748
+ reject(new CodexError(normalizedStdout, normalizedStderr || error.message, exitCode));
749
+ return;
750
+ }
751
+ resolve({
752
+ stdout: normalizedStdout,
753
+ stderr: normalizedStderr,
754
+ exitCode: 0
755
+ });
756
+ }
757
+ );
758
+ });
759
+ }
760
+
761
+ // src/lib/gh.ts
762
+ import { execFile as execFileCb2 } from "child_process";
763
+ var GH_TIMEOUT_MS = 3e4;
764
+ var GhNotFoundError = class extends Error {
765
+ constructor() {
766
+ super("gh CLI not found. Install it: https://cli.github.com");
767
+ this.name = "GhNotFoundError";
768
+ }
769
+ };
770
+ async function checkGhAvailable() {
771
+ await new Promise((resolve, reject) => {
772
+ execFileCb2("gh", ["--version"], { timeout: GH_TIMEOUT_MS, encoding: "utf8" }, (error) => {
773
+ if (error) {
774
+ reject(new GhNotFoundError());
775
+ return;
776
+ }
777
+ resolve();
778
+ });
779
+ });
780
+ }
781
+ async function createPr(opts) {
782
+ const base = opts.base ?? "main";
783
+ return await new Promise((resolve, reject) => {
784
+ execFileCb2(
785
+ "gh",
786
+ ["pr", "create", "--title", opts.title, "--body", opts.body, "--base", base],
787
+ { cwd: opts.cwd, timeout: GH_TIMEOUT_MS, encoding: "utf8" },
788
+ (error, stdout, stderr) => {
789
+ if (error) {
790
+ reject(new Error((stderr || error.message).trim()));
791
+ return;
792
+ }
793
+ resolve(stdout.trim());
794
+ }
795
+ );
796
+ });
797
+ }
798
+
799
+ // src/lib/requirements.ts
800
+ function requireAnthropicApiKey() {
801
+ const apiKey = process.env.ANTHROPIC_API_KEY;
802
+ if (!apiKey) {
803
+ throw new Error("ANTHROPIC_API_KEY is required");
804
+ }
805
+ return apiKey;
806
+ }
807
+ async function requireGhAvailable() {
808
+ await checkGhAvailable();
809
+ }
810
+
811
+ // src/lib/review-loop.ts
812
+ import path5 from "path";
813
+
814
+ // src/lib/git.ts
815
+ import { execFile as execFileCb3 } from "child_process";
816
+ var GIT_TIMEOUT_MS = 3e4;
817
+ var GitError = class extends Error {
818
+ command;
819
+ exitCode;
820
+ stderr;
821
+ constructor(args, exitCode, stderr) {
822
+ super(`git ${args.join(" ")} failed (exit ${String(exitCode)}): ${stderr}`);
823
+ this.name = "GitError";
824
+ this.command = `git ${args.join(" ")}`;
825
+ this.exitCode = exitCode;
826
+ this.stderr = stderr;
827
+ }
828
+ };
829
+ async function exec2(args, cwd) {
830
+ return new Promise((resolve, reject) => {
831
+ execFileCb3("git", args, { cwd, timeout: GIT_TIMEOUT_MS, encoding: "utf8" }, (error, stdout, stderr) => {
832
+ if (error) {
833
+ const exitCode = typeof error.code === "number" ? error.code : -1;
834
+ reject(new GitError(args, exitCode, (stderr || error.message).trim()));
835
+ return;
836
+ }
837
+ resolve(stdout.trimEnd());
838
+ });
839
+ });
840
+ }
841
+ async function branchExists(name, cwd) {
842
+ try {
843
+ await exec2(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], cwd);
844
+ return true;
845
+ } catch (error) {
846
+ if (error instanceof GitError && error.exitCode === 1) {
847
+ return false;
848
+ }
849
+ throw error;
850
+ }
851
+ }
852
+ async function createBranch(name, cwd) {
853
+ if (await branchExists(name, cwd)) {
854
+ throw new GitError(["checkout", "-b", name], 128, `branch '${name}' already exists`);
855
+ }
856
+ await exec2(["checkout", "-b", name], cwd);
857
+ }
858
+ async function checkoutBranch(name, cwd) {
859
+ await exec2(["checkout", name], cwd);
860
+ }
861
+ async function getDiff(cwd, base) {
862
+ if (base) {
863
+ return exec2(["diff", `${base}..HEAD`], cwd);
864
+ }
865
+ return exec2(["diff", "HEAD"], cwd);
866
+ }
867
+ function getBranchName(taskId, service) {
868
+ return `vexdo/${taskId}/${service}`;
869
+ }
870
+
871
+ // src/lib/review-loop.ts
872
+ function formatElapsed2(startedAt) {
873
+ const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1e3));
874
+ return `${String(seconds)}s`;
875
+ }
876
+ async function runReviewLoop(opts) {
877
+ if (opts.dryRun) {
878
+ info(`[dry-run] Would run review loop for service ${opts.step.service}`);
879
+ return {
880
+ decision: "submit",
881
+ finalIteration: opts.stepState.iteration,
882
+ lastReviewComments: [],
883
+ lastArbiterResult: {
884
+ decision: "submit",
885
+ reasoning: "Dry run: skipped reviewer and arbiter calls.",
886
+ summary: "Dry run mode; submitting without external calls."
887
+ }
888
+ };
889
+ }
890
+ const serviceConfig = opts.config.services.find((service) => service.name === opts.step.service);
891
+ if (!serviceConfig) {
892
+ throw new Error(`Unknown service in step: ${opts.step.service}`);
893
+ }
894
+ const serviceRoot = path5.resolve(opts.projectRoot, serviceConfig.path);
895
+ let iteration2 = opts.stepState.iteration;
896
+ for (; ; ) {
897
+ iteration(iteration2 + 1, opts.config.review.max_iterations);
898
+ info(`Collecting git diff for service ${opts.step.service}`);
899
+ const diff = await getDiff(serviceRoot);
900
+ if (opts.verbose) {
901
+ info(`Diff collected (${String(diff.length)} chars)`);
902
+ }
903
+ if (!diff.trim()) {
904
+ return {
905
+ decision: "submit",
906
+ finalIteration: iteration2,
907
+ lastReviewComments: [],
908
+ lastArbiterResult: {
909
+ decision: "submit",
910
+ reasoning: "No changes in git diff for service directory.",
911
+ summary: "No diff detected, nothing to review."
912
+ }
913
+ };
914
+ }
915
+ info(`Requesting reviewer analysis (model: ${opts.config.review.model})`);
916
+ const reviewerStartedAt = Date.now();
917
+ const reviewerHeartbeat = opts.verbose ? setInterval(() => {
918
+ info(`Waiting for reviewer response (${formatElapsed2(reviewerStartedAt)})`);
919
+ }, 15e3) : null;
920
+ const review = await opts.claude.runReviewer({
921
+ spec: opts.step.spec,
922
+ diff,
923
+ model: opts.config.review.model
924
+ }).finally(() => {
925
+ if (reviewerHeartbeat) {
926
+ clearInterval(reviewerHeartbeat);
927
+ }
928
+ });
929
+ info(`Reviewer response received in ${formatElapsed2(reviewerStartedAt)}`);
930
+ reviewSummary(review.comments);
931
+ info(`Requesting arbiter decision (model: ${opts.config.review.model})`);
932
+ const arbiterStartedAt = Date.now();
933
+ const arbiterHeartbeat = opts.verbose ? setInterval(() => {
934
+ info(`Waiting for arbiter response (${formatElapsed2(arbiterStartedAt)})`);
935
+ }, 15e3) : null;
936
+ const arbiter = await opts.claude.runArbiter({
937
+ spec: opts.step.spec,
938
+ diff,
939
+ reviewComments: review.comments,
940
+ model: opts.config.review.model
941
+ }).finally(() => {
942
+ if (arbiterHeartbeat) {
943
+ clearInterval(arbiterHeartbeat);
944
+ }
945
+ });
946
+ info(`Arbiter response received in ${formatElapsed2(arbiterStartedAt)}`);
947
+ info(`Arbiter decision: ${arbiter.decision} (${arbiter.summary})`);
948
+ saveIterationLog(opts.projectRoot, opts.taskId, opts.step.service, iteration2, {
949
+ diff,
950
+ review,
951
+ arbiter
952
+ });
953
+ opts.stepState.lastReviewComments = review.comments;
954
+ opts.stepState.lastArbiterResult = arbiter;
955
+ if (arbiter.decision === "submit") {
956
+ return {
957
+ decision: "submit",
958
+ finalIteration: iteration2,
959
+ lastReviewComments: review.comments,
960
+ lastArbiterResult: arbiter
961
+ };
962
+ }
963
+ if (arbiter.decision === "escalate") {
964
+ return {
965
+ decision: "escalate",
966
+ finalIteration: iteration2,
967
+ lastReviewComments: review.comments,
968
+ lastArbiterResult: arbiter
969
+ };
970
+ }
971
+ if (iteration2 >= opts.config.review.max_iterations) {
972
+ return {
973
+ decision: "escalate",
974
+ finalIteration: iteration2,
975
+ lastReviewComments: review.comments,
976
+ lastArbiterResult: {
977
+ decision: "escalate",
978
+ reasoning: "Max review iterations reached while arbiter still requested fixes.",
979
+ summary: "Escalated because maximum iterations were exhausted."
980
+ }
981
+ };
982
+ }
983
+ if (!arbiter.feedback_for_codex) {
984
+ return {
985
+ decision: "escalate",
986
+ finalIteration: iteration2,
987
+ lastReviewComments: review.comments,
988
+ lastArbiterResult: {
989
+ decision: "escalate",
990
+ reasoning: "Arbiter returned fix decision without feedback_for_codex.",
991
+ summary: "Escalated because fix instructions were missing."
992
+ }
993
+ };
994
+ }
995
+ info("Applying arbiter feedback with codex");
996
+ await exec({
997
+ spec: arbiter.feedback_for_codex,
998
+ model: opts.config.codex.model,
999
+ cwd: serviceRoot,
1000
+ verbose: opts.verbose
1001
+ });
1002
+ iteration2 += 1;
1003
+ opts.stepState.iteration = iteration2;
1004
+ }
1005
+ }
1006
+
1007
+ // src/commands/fix.ts
1008
+ function fatalAndExit2(message) {
1009
+ fatal(message);
1010
+ process.exit(1);
1011
+ }
1012
+ async function runFix(feedback, options) {
1013
+ try {
1014
+ const projectRoot = findProjectRoot();
1015
+ if (!projectRoot) {
1016
+ fatalAndExit2("Not inside a vexdo project.");
1017
+ }
1018
+ const config = loadConfig(projectRoot);
1019
+ const state = loadState(projectRoot);
1020
+ if (!state) {
1021
+ fatalAndExit2("No active task.");
1022
+ }
1023
+ if (!options.dryRun) {
1024
+ requireAnthropicApiKey();
1025
+ await checkCodexAvailable();
1026
+ }
1027
+ const currentStep = state.steps.find((step3) => step3.status === "in_progress" || step3.status === "pending");
1028
+ if (!currentStep) {
1029
+ fatalAndExit2("No in-progress step found in active task.");
1030
+ }
1031
+ const task = loadAndValidateTask(state.taskPath, config);
1032
+ const step2 = task.steps.find((item) => item.service === currentStep.service);
1033
+ if (!step2) {
1034
+ fatalAndExit2(`Could not locate task step for service '${currentStep.service}'.`);
1035
+ }
1036
+ if (!options.dryRun) {
1037
+ const serviceConfig = config.services.find((service) => service.name === currentStep.service);
1038
+ if (!serviceConfig) {
1039
+ fatalAndExit2(`Unknown service in step: ${currentStep.service}`);
1040
+ }
1041
+ await exec({
1042
+ spec: feedback,
1043
+ model: config.codex.model,
1044
+ cwd: path6.resolve(projectRoot, serviceConfig.path),
1045
+ verbose: options.verbose
1046
+ });
1047
+ }
1048
+ info(`Running review loop for service ${step2.service}`);
1049
+ const result = await runReviewLoop({
1050
+ taskId: task.id,
1051
+ task,
1052
+ step: step2,
1053
+ stepState: currentStep,
1054
+ projectRoot,
1055
+ config,
1056
+ claude: new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? ""),
1057
+ dryRun: options.dryRun,
1058
+ verbose: options.verbose
1059
+ });
1060
+ if (result.decision === "escalate") {
1061
+ currentStep.status = "escalated";
1062
+ state.status = "escalated";
1063
+ if (!options.dryRun) {
1064
+ saveState(projectRoot, state);
1065
+ const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
1066
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
1067
+ saveState(projectRoot, state);
1068
+ }
1069
+ process.exit(1);
1070
+ }
1071
+ currentStep.status = "done";
1072
+ if (!options.dryRun) {
1073
+ saveState(projectRoot, state);
1074
+ }
1075
+ } catch (error) {
1076
+ fatalAndExit2(error instanceof Error ? error.message : String(error));
1077
+ }
1078
+ }
1079
+ function registerFixCommand(program2) {
1080
+ program2.command("fix").description("Provide feedback to codex and rerun review").argument("<feedback>").option("--verbose", "Enable verbose logs").option("--dry-run", "Print plan without making changes").action(async (feedback, options, command) => {
1081
+ const merged = command.optsWithGlobals();
1082
+ await runFix(feedback, { ...options, ...merged });
1083
+ });
1084
+ }
1085
+
1086
+ // src/commands/init.ts
1087
+ import fs5 from "fs";
1088
+ import path7 from "path";
1089
+ import { createInterface } from "readline/promises";
1090
+ import { stdin as input, stdout as output } from "process";
1091
+ import { stringify } from "yaml";
1092
+ var DEFAULT_REVIEW_MODEL2 = "claude-haiku-4-5-20251001";
1093
+ var DEFAULT_MAX_ITERATIONS2 = 3;
1094
+ var DEFAULT_CODEX_MODEL2 = "gpt-4o";
1095
+ var TASK_DIRS = ["backlog", "in_progress", "review", "done", "blocked"];
1096
+ async function defaultPrompt(question) {
1097
+ const rl = createInterface({ input, output });
1098
+ try {
1099
+ return await rl.question(question);
1100
+ } finally {
1101
+ rl.close();
1102
+ }
1103
+ }
1104
+ function parseServices2(value) {
1105
+ const parsed = value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
1106
+ return Array.from(new Set(parsed));
1107
+ }
1108
+ function parseBoolean(value) {
1109
+ const normalized = value.trim().toLowerCase();
1110
+ return normalized === "y" || normalized === "yes";
1111
+ }
1112
+ function parseMaxIterations(value) {
1113
+ const parsed = Number.parseInt(value, 10);
1114
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ITERATIONS2;
1115
+ }
1116
+ function ensureGitignoreEntry(gitignorePath, entry) {
1117
+ if (!fs5.existsSync(gitignorePath)) {
1118
+ fs5.writeFileSync(gitignorePath, `${entry}
1119
+ `, "utf8");
1120
+ return true;
1121
+ }
1122
+ const content = fs5.readFileSync(gitignorePath, "utf8");
1123
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
1124
+ if (lines.includes(entry)) {
1125
+ return false;
1126
+ }
1127
+ const suffix = content.endsWith("\n") || content.length === 0 ? "" : "\n";
1128
+ fs5.appendFileSync(gitignorePath, `${suffix}${entry}
1129
+ `, "utf8");
1130
+ return true;
1131
+ }
1132
+ async function runInit(projectRoot, prompt = defaultPrompt) {
1133
+ const configPath = path7.join(projectRoot, ".vexdo.yml");
1134
+ if (fs5.existsSync(configPath)) {
1135
+ warn("Found existing .vexdo.yml.");
1136
+ const overwriteAnswer = await prompt("Overwrite existing .vexdo.yml? (y/N): ");
1137
+ if (!parseBoolean(overwriteAnswer)) {
1138
+ info("Initialization cancelled.");
1139
+ return;
1140
+ }
1141
+ }
1142
+ let services = parseServices2(await prompt("Project services (comma-separated names, e.g. api,web): "));
1143
+ if (services.length === 0) {
1144
+ services = ["api"];
1145
+ }
1146
+ const serviceConfigs = [];
1147
+ for (const name of services) {
1148
+ const answer = await prompt(`Path for ${name} (default: ./${name}): `);
1149
+ serviceConfigs.push({
1150
+ name,
1151
+ path: answer.trim().length > 0 ? answer.trim() : `./${name}`
1152
+ });
1153
+ }
1154
+ const reviewModelRaw = await prompt(`Review model (default: ${DEFAULT_REVIEW_MODEL2}): `);
1155
+ const maxIterationsRaw = await prompt(`Max review iterations (default: ${String(DEFAULT_MAX_ITERATIONS2)}): `);
1156
+ const autoSubmitRaw = await prompt("Auto-submit PRs? (y/N): ");
1157
+ const codexModelRaw = await prompt(`Codex model (default: ${DEFAULT_CODEX_MODEL2}): `);
1158
+ const config = {
1159
+ version: 1,
1160
+ services: serviceConfigs,
1161
+ review: {
1162
+ model: reviewModelRaw.trim() || DEFAULT_REVIEW_MODEL2,
1163
+ max_iterations: maxIterationsRaw.trim() ? parseMaxIterations(maxIterationsRaw.trim()) : DEFAULT_MAX_ITERATIONS2,
1164
+ auto_submit: parseBoolean(autoSubmitRaw)
1165
+ },
1166
+ codex: {
1167
+ model: codexModelRaw.trim() || DEFAULT_CODEX_MODEL2
1168
+ }
1169
+ };
1170
+ fs5.writeFileSync(configPath, stringify(config), "utf8");
1171
+ const createdDirs = [];
1172
+ for (const taskDir of TASK_DIRS) {
1173
+ const directory = path7.join(projectRoot, "tasks", taskDir);
1174
+ fs5.mkdirSync(directory, { recursive: true });
1175
+ createdDirs.push(path7.relative(projectRoot, directory));
1176
+ }
1177
+ const logDir = path7.join(projectRoot, ".vexdo", "logs");
1178
+ fs5.mkdirSync(logDir, { recursive: true });
1179
+ createdDirs.push(path7.relative(projectRoot, logDir));
1180
+ const gitignorePath = path7.join(projectRoot, ".gitignore");
1181
+ const gitignoreUpdated = ensureGitignoreEntry(gitignorePath, ".vexdo/");
1182
+ success("Initialized vexdo project.");
1183
+ info(`Created: ${path7.relative(projectRoot, configPath)}`);
1184
+ info(`Created directories: ${createdDirs.join(", ")}`);
1185
+ if (gitignoreUpdated) {
1186
+ info("Updated .gitignore with .vexdo/");
1187
+ }
1188
+ info("Next: create a task file in tasks/backlog/ and run 'vexdo start tasks/backlog/my-task.yml'");
1189
+ }
1190
+ function registerInitCommand(program2) {
1191
+ program2.command("init").description("Initialize vexdo in the current project").action(async () => {
1192
+ await runInit(process.cwd());
1193
+ });
1194
+ }
1195
+
1196
+ // src/commands/logs.ts
1197
+ import fs6 from "fs";
1198
+ import path8 from "path";
1199
+ function fatalAndExit3(message) {
1200
+ fatal(message);
1201
+ process.exit(1);
1202
+ }
1203
+ function runLogs(taskIdArg, options) {
1204
+ const projectRoot = findProjectRoot();
1205
+ if (!projectRoot) {
1206
+ fatalAndExit3("Not inside a vexdo project.");
1207
+ }
1208
+ const state = loadState(projectRoot);
1209
+ const taskId = taskIdArg ?? state?.taskId;
1210
+ if (!taskId) {
1211
+ const base = path8.join(getStateDir(projectRoot), "logs");
1212
+ if (!fs6.existsSync(base)) {
1213
+ info("No logs available.");
1214
+ return;
1215
+ }
1216
+ const tasks = fs6.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory());
1217
+ for (const dir of tasks) {
1218
+ info(dir.name);
1219
+ }
1220
+ return;
1221
+ }
1222
+ const logsDir = getLogsDir(projectRoot, taskId);
1223
+ if (!fs6.existsSync(logsDir)) {
1224
+ fatalAndExit3(`No logs found for task '${taskId}'.`);
1225
+ }
1226
+ const files = fs6.readdirSync(logsDir).filter((name) => name.endsWith("-arbiter.json"));
1227
+ for (const arbiterFile of files) {
1228
+ const base = arbiterFile.replace(/-arbiter\.json$/, "");
1229
+ const arbiterPath = path8.join(logsDir, `${base}-arbiter.json`);
1230
+ const reviewPath = path8.join(logsDir, `${base}-review.json`);
1231
+ const diffPath = path8.join(logsDir, `${base}-diff.txt`);
1232
+ const arbiter = JSON.parse(fs6.readFileSync(arbiterPath, "utf8"));
1233
+ const review = JSON.parse(fs6.readFileSync(reviewPath, "utf8"));
1234
+ info(`${base}: decision=${arbiter.decision}, comments=${String(review.comments?.length ?? 0)}, summary=${arbiter.summary}`);
1235
+ if (options?.full) {
1236
+ console.log(fs6.readFileSync(diffPath, "utf8"));
1237
+ console.log(JSON.stringify(review, null, 2));
1238
+ console.log(JSON.stringify(arbiter, null, 2));
1239
+ }
1240
+ }
1241
+ }
1242
+ function registerLogsCommand(program2) {
1243
+ program2.command("logs").description("Show iteration logs").argument("[task-id]").option("--full", "Print full diff and comments").action((taskId, options) => {
1244
+ runLogs(taskId, options);
1245
+ });
1246
+ }
1247
+
1248
+ // src/commands/review.ts
1249
+ import fs7 from "fs";
1250
+ function fatalAndExit4(message) {
1251
+ fatal(message);
1252
+ process.exit(1);
1253
+ }
1254
+ async function runReview(options) {
1255
+ try {
1256
+ const projectRoot = findProjectRoot();
1257
+ if (!projectRoot) {
1258
+ fatalAndExit4("Not inside a vexdo project.");
1259
+ }
1260
+ const config = loadConfig(projectRoot);
1261
+ const state = loadState(projectRoot);
1262
+ if (!state) {
1263
+ fatalAndExit4("No active task.");
1264
+ }
1265
+ if (!options.dryRun) {
1266
+ requireAnthropicApiKey();
1267
+ }
1268
+ const currentStep = state.steps.find((step3) => step3.status === "in_progress" || step3.status === "pending");
1269
+ if (!currentStep) {
1270
+ fatalAndExit4("No in-progress step found in active task.");
1271
+ }
1272
+ if (!fs7.existsSync(state.taskPath)) {
1273
+ fatalAndExit4(`Task file not found: ${state.taskPath}`);
1274
+ }
1275
+ const task = loadAndValidateTask(state.taskPath, config);
1276
+ const step2 = task.steps.find((item) => item.service === currentStep.service);
1277
+ if (!step2) {
1278
+ fatalAndExit4(`Could not locate task step for service '${currentStep.service}'.`);
1279
+ }
1280
+ info(`Running review loop for service ${step2.service}`);
1281
+ const result = await runReviewLoop({
1282
+ taskId: task.id,
1283
+ task,
1284
+ step: step2,
1285
+ stepState: currentStep,
1286
+ projectRoot,
1287
+ config,
1288
+ claude: new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? ""),
1289
+ dryRun: options.dryRun,
1290
+ verbose: options.verbose
1291
+ });
1292
+ if (result.decision === "escalate") {
1293
+ escalation({
1294
+ taskId: task.id,
1295
+ service: step2.service,
1296
+ iteration: result.finalIteration,
1297
+ spec: step2.spec,
1298
+ diff: "",
1299
+ reviewComments: result.lastReviewComments,
1300
+ arbiterReasoning: result.lastArbiterResult.reasoning,
1301
+ summary: result.lastArbiterResult.summary
1302
+ });
1303
+ currentStep.status = "escalated";
1304
+ state.status = "escalated";
1305
+ if (!options.dryRun) {
1306
+ saveState(projectRoot, state);
1307
+ const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
1308
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
1309
+ saveState(projectRoot, state);
1310
+ }
1311
+ process.exit(1);
1312
+ }
1313
+ currentStep.status = "done";
1314
+ if (!options.dryRun) {
1315
+ saveState(projectRoot, state);
1316
+ }
1317
+ } catch (error) {
1318
+ const message = error instanceof Error ? error.message : String(error);
1319
+ fatalAndExit4(message);
1320
+ }
1321
+ }
1322
+ function registerReviewCommand(program2) {
1323
+ program2.command("review").description("Run review loop for the current step").option("--verbose", "Enable verbose logs").option("--dry-run", "Print plan without making changes").action(async (options, command) => {
1324
+ const merged = command.optsWithGlobals();
1325
+ await runReview({ ...options, ...merged });
1326
+ });
1327
+ }
1328
+
1329
+ // src/commands/start.ts
1330
+ import path10 from "path";
1331
+
1332
+ // src/lib/submit-task.ts
1333
+ import fs8 from "fs";
1334
+ import path9 from "path";
1335
+ async function submitActiveTask(projectRoot, config, state) {
1336
+ for (const step2 of state.steps) {
1337
+ if (step2.status !== "done" && step2.status !== "in_progress") {
1338
+ continue;
1339
+ }
1340
+ const service = config.services.find((item) => item.name === step2.service);
1341
+ if (!service) {
1342
+ throw new Error(`Unknown service in state: ${step2.service}`);
1343
+ }
1344
+ const servicePath = path9.resolve(projectRoot, service.path);
1345
+ const body = `Task: ${state.taskId}
1346
+ Service: ${step2.service}`;
1347
+ const url = await createPr({
1348
+ title: `${state.taskTitle} [${step2.service}]`,
1349
+ body,
1350
+ base: "main",
1351
+ cwd: servicePath
1352
+ });
1353
+ success(`PR created: ${url}`);
1354
+ }
1355
+ state.status = "done";
1356
+ saveState(projectRoot, state);
1357
+ const doneDir = ensureTaskDirectory(projectRoot, "done");
1358
+ if (fs8.existsSync(state.taskPath)) {
1359
+ state.taskPath = moveTaskFileAtomically(state.taskPath, doneDir);
1360
+ saveState(projectRoot, state);
1361
+ }
1362
+ clearState(projectRoot);
1363
+ }
1364
+
1365
+ // src/commands/start.ts
1366
+ function fatalAndExit5(message, hint) {
1367
+ fatal(message, hint);
1368
+ process.exit(1);
1369
+ }
1370
+ async function runStart(taskFile, options) {
1371
+ try {
1372
+ const projectRoot = findProjectRoot();
1373
+ if (!projectRoot) {
1374
+ fatalAndExit5("Not inside a vexdo project. Could not find .vexdo.yml.");
1375
+ }
1376
+ const config = loadConfig(projectRoot);
1377
+ const taskPath = path10.resolve(taskFile);
1378
+ const task = loadAndValidateTask(taskPath, config);
1379
+ if (hasActiveTask(projectRoot) && !options.resume) {
1380
+ fatalAndExit5("An active task already exists.", "Use --resume to continue or 'vexdo abort' to cancel.");
1381
+ }
1382
+ if (!options.dryRun) {
1383
+ requireAnthropicApiKey();
1384
+ await checkCodexAvailable();
1385
+ }
1386
+ let state = loadState(projectRoot);
1387
+ if (!options.resume) {
1388
+ let taskPathInProgress = taskPath;
1389
+ if (!options.dryRun) {
1390
+ const inProgressDir = ensureTaskDirectory(projectRoot, "in_progress");
1391
+ taskPathInProgress = moveTaskFileAtomically(taskPath, inProgressDir);
1392
+ }
1393
+ state = createState(task.id, task.title, taskPathInProgress, buildInitialStepState(task));
1394
+ if (!options.dryRun) {
1395
+ saveState(projectRoot, state);
1396
+ }
1397
+ }
1398
+ if (!state) {
1399
+ fatalAndExit5("No resumable task state found.");
1400
+ }
1401
+ const claude = new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? "");
1402
+ const total = task.steps.length;
1403
+ for (let i = 0; i < task.steps.length; i += 1) {
1404
+ const step2 = task.steps[i];
1405
+ const stepState = state.steps[i];
1406
+ if (!step2 || !stepState) {
1407
+ continue;
1408
+ }
1409
+ if (stepState.status === "done") {
1410
+ continue;
1411
+ }
1412
+ if (step2.depends_on && step2.depends_on.length > 0) {
1413
+ for (const depService of step2.depends_on) {
1414
+ const depState = state.steps.find((item) => item.service === depService);
1415
+ if (depState?.status !== "done") {
1416
+ fatalAndExit5(`Step dependency '${depService}' for service '${step2.service}' is not done.`);
1417
+ }
1418
+ }
1419
+ }
1420
+ step(i + 1, total, `${step2.service}: ${task.title}`);
1421
+ const serviceCfg = config.services.find((service) => service.name === step2.service);
1422
+ if (!serviceCfg) {
1423
+ fatalAndExit5(`Unknown service in step: ${step2.service}`);
1424
+ }
1425
+ const serviceRoot = path10.resolve(projectRoot, serviceCfg.path);
1426
+ const branch = getBranchName(task.id, step2.service);
1427
+ if (!options.dryRun) {
1428
+ if (options.resume) {
1429
+ await checkoutBranch(stepState.branch ?? branch, serviceRoot);
1430
+ } else {
1431
+ await createBranch(branch, serviceRoot);
1432
+ }
1433
+ }
1434
+ stepState.status = "in_progress";
1435
+ stepState.branch = branch;
1436
+ if (!options.dryRun) {
1437
+ saveState(projectRoot, state);
1438
+ }
1439
+ if (!options.resume && !options.dryRun) {
1440
+ info(`Running codex implementation for service ${step2.service}`);
1441
+ await exec({
1442
+ spec: step2.spec,
1443
+ model: config.codex.model,
1444
+ cwd: serviceRoot,
1445
+ verbose: options.verbose
1446
+ });
1447
+ } else if (options.dryRun) {
1448
+ info(`[dry-run] Would run codex for service ${step2.service}`);
1449
+ }
1450
+ info(`Starting review loop for service ${step2.service}`);
1451
+ const result = await runReviewLoop({
1452
+ taskId: task.id,
1453
+ task,
1454
+ step: step2,
1455
+ stepState,
1456
+ projectRoot,
1457
+ config,
1458
+ claude,
1459
+ dryRun: options.dryRun,
1460
+ verbose: options.verbose
1461
+ });
1462
+ if (result.decision === "escalate") {
1463
+ escalation({
1464
+ taskId: task.id,
1465
+ service: step2.service,
1466
+ iteration: result.finalIteration,
1467
+ spec: step2.spec,
1468
+ diff: "",
1469
+ reviewComments: result.lastReviewComments,
1470
+ arbiterReasoning: result.lastArbiterResult.reasoning,
1471
+ summary: result.lastArbiterResult.summary
1472
+ });
1473
+ stepState.status = "escalated";
1474
+ state.status = "escalated";
1475
+ if (!options.dryRun) {
1476
+ saveState(projectRoot, state);
1477
+ const blockedDir = ensureTaskDirectory(projectRoot, "blocked");
1478
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
1479
+ saveState(projectRoot, state);
1480
+ }
1481
+ process.exit(1);
1482
+ }
1483
+ stepState.status = "done";
1484
+ if (!options.dryRun) {
1485
+ saveState(projectRoot, state);
1486
+ }
1487
+ }
1488
+ state.status = "review";
1489
+ if (!options.dryRun) {
1490
+ const reviewDir = ensureTaskDirectory(projectRoot, "review");
1491
+ state.taskPath = moveTaskFileAtomically(state.taskPath, reviewDir);
1492
+ saveState(projectRoot, state);
1493
+ }
1494
+ if (config.review.auto_submit && !options.dryRun) {
1495
+ await submitActiveTask(projectRoot, config, state);
1496
+ return;
1497
+ }
1498
+ success("Task ready for PR. Run 'vexdo submit' to create PR.");
1499
+ } catch (error) {
1500
+ const message = error instanceof Error ? error.message : String(error);
1501
+ fatalAndExit5(message);
1502
+ }
1503
+ }
1504
+ function registerStartCommand(program2) {
1505
+ program2.command("start").description("Start a task from a YAML file").argument("<task-file>").option("--verbose", "Enable verbose logs").option("--dry-run", "Print plan without making changes").option("--resume", "Resume an existing active task").action(async (taskFile, options, command) => {
1506
+ const merged = command.optsWithGlobals();
1507
+ await runStart(taskFile, { ...options, ...merged });
1508
+ });
1509
+ }
1510
+
1511
+ // src/commands/status.ts
1512
+ function fatalAndExit6(message) {
1513
+ fatal(message);
1514
+ process.exit(1);
1515
+ }
1516
+ function formatElapsed3(startedAt) {
1517
+ const elapsedMs = Date.now() - new Date(startedAt).getTime();
1518
+ const minutes = Math.floor(elapsedMs / 1e3 / 60);
1519
+ const hours = Math.floor(minutes / 60);
1520
+ if (hours > 0) {
1521
+ return `${String(hours)}h ${String(minutes % 60)}m`;
1522
+ }
1523
+ return `${String(minutes)}m`;
1524
+ }
1525
+ function runStatus() {
1526
+ const projectRoot = findProjectRoot();
1527
+ if (!projectRoot) {
1528
+ fatalAndExit6("Not inside a vexdo project.");
1529
+ }
1530
+ const state = loadState(projectRoot);
1531
+ if (!state) {
1532
+ fatalAndExit6("No active task.");
1533
+ }
1534
+ info(`Task: ${state.taskId} \u2014 ${state.taskTitle}`);
1535
+ info(`Status: ${state.status}`);
1536
+ console.log("service | status | iteration | branch");
1537
+ for (const step2 of state.steps) {
1538
+ console.log(`${step2.service} | ${step2.status} | ${String(step2.iteration)} | ${step2.branch ?? "-"}`);
1539
+ }
1540
+ const inProgress = state.steps.find((step2) => step2.status === "in_progress");
1541
+ if (inProgress?.lastArbiterResult?.summary) {
1542
+ info(`Last arbiter summary: ${inProgress.lastArbiterResult.summary}`);
1543
+ }
1544
+ info(`Elapsed: ${formatElapsed3(state.startedAt)}`);
1545
+ }
1546
+ function registerStatusCommand(program2) {
1547
+ program2.command("status").description("Print active task status").action(() => {
1548
+ runStatus();
1549
+ });
1550
+ }
1551
+
1552
+ // src/commands/submit.ts
1553
+ function fatalAndExit7(message) {
1554
+ fatal(message);
1555
+ process.exit(1);
1556
+ }
1557
+ async function runSubmit() {
1558
+ try {
1559
+ const projectRoot = findProjectRoot();
1560
+ if (!projectRoot) {
1561
+ fatalAndExit7("Not inside a vexdo project.");
1562
+ }
1563
+ const config = loadConfig(projectRoot);
1564
+ const state = loadState(projectRoot);
1565
+ if (!state) {
1566
+ fatalAndExit7("No active task.");
1567
+ }
1568
+ await requireGhAvailable();
1569
+ await submitActiveTask(projectRoot, config, state);
1570
+ } catch (error) {
1571
+ fatalAndExit7(error instanceof Error ? error.message : String(error));
1572
+ }
1573
+ }
1574
+ function registerSubmitCommand(program2) {
1575
+ program2.command("submit").description("Create PRs for active task").action(async () => {
1576
+ await runSubmit();
1577
+ });
1578
+ }
1579
+
1580
+ // src/index.ts
1581
+ var packageJsonPath = path11.resolve(path11.dirname(new URL(import.meta.url).pathname), "..", "package.json");
1582
+ var packageJson = JSON.parse(fs9.readFileSync(packageJsonPath, "utf8"));
1583
+ var program = new Command();
1584
+ program.name("vexdo").description("Vexdo CLI").version(packageJson.version).option("--verbose", "Enable verbose logs").option("--dry-run", "Print plan without making changes");
1585
+ program.hook("preAction", (_thisCommand, actionCommand) => {
1586
+ const globalOpts = actionCommand.optsWithGlobals();
1587
+ setVerbose(Boolean(globalOpts.verbose));
1588
+ });
1589
+ registerInitCommand(program);
1590
+ registerStartCommand(program);
1591
+ registerReviewCommand(program);
1592
+ registerFixCommand(program);
1593
+ registerSubmitCommand(program);
1594
+ registerStatusCommand(program);
1595
+ registerAbortCommand(program);
1596
+ registerLogsCommand(program);
1597
+ program.parse(process.argv);