@vexdo/cli 0.1.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 (51) hide show
  1. package/.eslintrc.json +23 -0
  2. package/.github/workflows/ci.yml +84 -0
  3. package/.idea/copilot.data.migration.ask2agent.xml +6 -0
  4. package/.idea/go.imports.xml +11 -0
  5. package/.idea/misc.xml +6 -0
  6. package/.idea/modules.xml +8 -0
  7. package/.idea/vcs.xml +7 -0
  8. package/.idea/vexdo-cli.iml +9 -0
  9. package/.prettierrc +5 -0
  10. package/CLAUDE.md +93 -0
  11. package/CONTRIBUTING.md +62 -0
  12. package/LICENSE +21 -0
  13. package/README.md +206 -0
  14. package/bin/vexdo.js +2 -0
  15. package/package.json +35 -0
  16. package/src/commands/abort.ts +66 -0
  17. package/src/commands/fix.ts +106 -0
  18. package/src/commands/init.ts +142 -0
  19. package/src/commands/logs.ts +74 -0
  20. package/src/commands/review.ts +107 -0
  21. package/src/commands/start.ts +197 -0
  22. package/src/commands/status.ts +52 -0
  23. package/src/commands/submit.ts +38 -0
  24. package/src/index.ts +42 -0
  25. package/src/lib/claude.ts +259 -0
  26. package/src/lib/codex.ts +96 -0
  27. package/src/lib/config.ts +157 -0
  28. package/src/lib/gh.ts +78 -0
  29. package/src/lib/git.ts +119 -0
  30. package/src/lib/logger.ts +147 -0
  31. package/src/lib/requirements.ts +18 -0
  32. package/src/lib/review-loop.ts +154 -0
  33. package/src/lib/state.ts +121 -0
  34. package/src/lib/submit-task.ts +43 -0
  35. package/src/lib/tasks.ts +94 -0
  36. package/src/prompts/arbiter.ts +21 -0
  37. package/src/prompts/reviewer.ts +20 -0
  38. package/src/types/index.ts +96 -0
  39. package/test/config.test.ts +124 -0
  40. package/test/state.test.ts +147 -0
  41. package/test/unit/claude.test.ts +117 -0
  42. package/test/unit/codex.test.ts +67 -0
  43. package/test/unit/gh.test.ts +49 -0
  44. package/test/unit/git.test.ts +120 -0
  45. package/test/unit/review-loop.test.ts +198 -0
  46. package/tests/integration/review.test.ts +137 -0
  47. package/tests/integration/start.test.ts +220 -0
  48. package/tests/unit/init.test.ts +91 -0
  49. package/tsconfig.json +15 -0
  50. package/tsup.config.ts +8 -0
  51. package/vitest.config.ts +7 -0
@@ -0,0 +1,106 @@
1
+ import path from 'node:path';
2
+
3
+ import type { Command } from 'commander';
4
+
5
+ import { ClaudeClient } from '../lib/claude.js';
6
+ import * as codex from '../lib/codex.js';
7
+ import { findProjectRoot, loadConfig } from '../lib/config.js';
8
+ import * as logger from '../lib/logger.js';
9
+ import { requireAnthropicApiKey } from '../lib/requirements.js';
10
+ import { runReviewLoop } from '../lib/review-loop.js';
11
+ import { loadState, saveState } from '../lib/state.js';
12
+ import { ensureTaskDirectory, loadAndValidateTask, moveTaskFileAtomically } from '../lib/tasks.js';
13
+
14
+ interface FixOptions { dryRun?: boolean; verbose?: boolean }
15
+
16
+ function fatalAndExit(message: string): never {
17
+ logger.fatal(message);
18
+ process.exit(1);
19
+ }
20
+
21
+ export async function runFix(feedback: string, options: FixOptions): Promise<void> {
22
+ try {
23
+ const projectRoot = findProjectRoot();
24
+ if (!projectRoot) {
25
+ fatalAndExit('Not inside a vexdo project.');
26
+ }
27
+
28
+ const config = loadConfig(projectRoot);
29
+ const state = loadState(projectRoot);
30
+ if (!state) {
31
+ fatalAndExit('No active task.');
32
+ }
33
+
34
+ if (!options.dryRun) {
35
+ requireAnthropicApiKey();
36
+ await codex.checkCodexAvailable();
37
+ }
38
+
39
+ const currentStep = state.steps.find((step) => step.status === 'in_progress' || step.status === 'pending');
40
+ if (!currentStep) {
41
+ fatalAndExit('No in-progress step found in active task.');
42
+ }
43
+
44
+ const task = loadAndValidateTask(state.taskPath, config);
45
+ const step = task.steps.find((item) => item.service === currentStep.service);
46
+ if (!step) {
47
+ fatalAndExit(`Could not locate task step for service '${currentStep.service}'.`);
48
+ }
49
+
50
+ if (!options.dryRun) {
51
+ const serviceConfig = config.services.find((service) => service.name === currentStep.service);
52
+ if (!serviceConfig) {
53
+ fatalAndExit(`Unknown service in step: ${currentStep.service}`);
54
+ }
55
+
56
+ await codex.exec({
57
+ spec: feedback,
58
+ model: config.codex.model,
59
+ cwd: path.resolve(projectRoot, serviceConfig.path),
60
+ verbose: options.verbose,
61
+ });
62
+ }
63
+
64
+ const result = await runReviewLoop({
65
+ taskId: task.id,
66
+ task,
67
+ step,
68
+ stepState: currentStep,
69
+ projectRoot,
70
+ config,
71
+ claude: new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? ''),
72
+ dryRun: options.dryRun,
73
+ verbose: options.verbose,
74
+ });
75
+
76
+ if (result.decision === 'escalate') {
77
+ currentStep.status = 'escalated';
78
+ state.status = 'escalated';
79
+ if (!options.dryRun) {
80
+ saveState(projectRoot, state);
81
+ const blockedDir = ensureTaskDirectory(projectRoot, 'blocked');
82
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
83
+ saveState(projectRoot, state);
84
+ }
85
+ process.exit(1);
86
+ }
87
+
88
+ currentStep.status = 'done';
89
+ if (!options.dryRun) {
90
+ saveState(projectRoot, state);
91
+ }
92
+ } catch (error: unknown) {
93
+ fatalAndExit(error instanceof Error ? error.message : String(error));
94
+ }
95
+ }
96
+
97
+ export function registerFixCommand(program: Command): void {
98
+ program
99
+ .command('fix')
100
+ .description('Provide feedback to codex and rerun review')
101
+ .argument('<feedback>')
102
+ .action(async (feedback: string, options: FixOptions, command: Command) => {
103
+ const merged = command.optsWithGlobals();
104
+ await runFix(feedback, { ...options, ...merged });
105
+ });
106
+ }
@@ -0,0 +1,142 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { stdin as input, stdout as output } from 'node:process';
5
+
6
+ import type { Command } from 'commander';
7
+ import { stringify } from 'yaml';
8
+
9
+ import * as logger from '../lib/logger.js';
10
+ import type { VexdoConfig } from '../types/index.js';
11
+
12
+ const DEFAULT_REVIEW_MODEL = 'claude-haiku-4-5-20251001';
13
+ const DEFAULT_MAX_ITERATIONS = 3;
14
+ const DEFAULT_CODEX_MODEL = 'gpt-4o';
15
+
16
+ const TASK_DIRS = ['backlog', 'in_progress', 'review', 'done', 'blocked'] as const;
17
+
18
+ export type PromptFn = (question: string) => Promise<string>;
19
+
20
+ async function defaultPrompt(question: string): Promise<string> {
21
+ const rl = createInterface({ input, output });
22
+ try {
23
+ return await rl.question(question);
24
+ } finally {
25
+ rl.close();
26
+ }
27
+ }
28
+
29
+ function parseServices(value: string): string[] {
30
+ const parsed = value
31
+ .split(',')
32
+ .map((item) => item.trim())
33
+ .filter((item) => item.length > 0);
34
+
35
+ return Array.from(new Set(parsed));
36
+ }
37
+
38
+ function parseBoolean(value: string): boolean {
39
+ const normalized = value.trim().toLowerCase();
40
+ return normalized === 'y' || normalized === 'yes';
41
+ }
42
+
43
+ function parseMaxIterations(value: string): number {
44
+ const parsed = Number.parseInt(value, 10);
45
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ITERATIONS;
46
+ }
47
+
48
+ function ensureGitignoreEntry(gitignorePath: string, entry: string): boolean {
49
+ if (!fs.existsSync(gitignorePath)) {
50
+ fs.writeFileSync(gitignorePath, `${entry}\n`, 'utf8');
51
+ return true;
52
+ }
53
+
54
+ const content = fs.readFileSync(gitignorePath, 'utf8');
55
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
56
+
57
+ if (lines.includes(entry)) {
58
+ return false;
59
+ }
60
+
61
+ const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
62
+ fs.appendFileSync(gitignorePath, `${suffix}${entry}\n`, 'utf8');
63
+ return true;
64
+ }
65
+
66
+ export async function runInit(projectRoot: string, prompt: PromptFn = defaultPrompt): Promise<void> {
67
+ const configPath = path.join(projectRoot, '.vexdo.yml');
68
+
69
+ if (fs.existsSync(configPath)) {
70
+ logger.warn('Found existing .vexdo.yml.');
71
+ const overwriteAnswer = await prompt('Overwrite existing .vexdo.yml? (y/N): ');
72
+ if (!parseBoolean(overwriteAnswer)) {
73
+ logger.info('Initialization cancelled.');
74
+ return;
75
+ }
76
+ }
77
+
78
+ let services = parseServices(await prompt('Project services (comma-separated names, e.g. api,web): '));
79
+ if (services.length === 0) {
80
+ services = ['api'];
81
+ }
82
+
83
+ const serviceConfigs: VexdoConfig['services'] = [];
84
+ for (const name of services) {
85
+ const answer = await prompt(`Path for ${name} (default: ./${name}): `);
86
+ serviceConfigs.push({
87
+ name,
88
+ path: answer.trim().length > 0 ? answer.trim() : `./${name}`,
89
+ });
90
+ }
91
+
92
+ const reviewModelRaw = await prompt(`Review model (default: ${DEFAULT_REVIEW_MODEL}): `);
93
+ const maxIterationsRaw = await prompt(`Max review iterations (default: ${String(DEFAULT_MAX_ITERATIONS)}): `);
94
+ const autoSubmitRaw = await prompt('Auto-submit PRs? (y/N): ');
95
+ const codexModelRaw = await prompt(`Codex model (default: ${DEFAULT_CODEX_MODEL}): `);
96
+
97
+ const config: VexdoConfig = {
98
+ version: 1,
99
+ services: serviceConfigs,
100
+ review: {
101
+ model: reviewModelRaw.trim() || DEFAULT_REVIEW_MODEL,
102
+ max_iterations: maxIterationsRaw.trim() ? parseMaxIterations(maxIterationsRaw.trim()) : DEFAULT_MAX_ITERATIONS,
103
+ auto_submit: parseBoolean(autoSubmitRaw),
104
+ },
105
+ codex: {
106
+ model: codexModelRaw.trim() || DEFAULT_CODEX_MODEL,
107
+ },
108
+ };
109
+
110
+ fs.writeFileSync(configPath, stringify(config), 'utf8');
111
+
112
+ const createdDirs: string[] = [];
113
+ for (const taskDir of TASK_DIRS) {
114
+ const directory = path.join(projectRoot, 'tasks', taskDir);
115
+ fs.mkdirSync(directory, { recursive: true });
116
+ createdDirs.push(path.relative(projectRoot, directory));
117
+ }
118
+
119
+ const logDir = path.join(projectRoot, '.vexdo', 'logs');
120
+ fs.mkdirSync(logDir, { recursive: true });
121
+ createdDirs.push(path.relative(projectRoot, logDir));
122
+
123
+ const gitignorePath = path.join(projectRoot, '.gitignore');
124
+ const gitignoreUpdated = ensureGitignoreEntry(gitignorePath, '.vexdo/');
125
+
126
+ logger.success('Initialized vexdo project.');
127
+ logger.info(`Created: ${path.relative(projectRoot, configPath)}`);
128
+ logger.info(`Created directories: ${createdDirs.join(', ')}`);
129
+ if (gitignoreUpdated) {
130
+ logger.info('Updated .gitignore with .vexdo/');
131
+ }
132
+ logger.info("Next: create a task file in tasks/backlog/ and run 'vexdo start tasks/backlog/my-task.yml'");
133
+ }
134
+
135
+ export function registerInitCommand(program: Command): void {
136
+ program
137
+ .command('init')
138
+ .description('Initialize vexdo in the current project')
139
+ .action(async () => {
140
+ await runInit(process.cwd());
141
+ });
142
+ }
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { Command } from 'commander';
5
+
6
+ import { findProjectRoot } from '../lib/config.js';
7
+ import * as logger from '../lib/logger.js';
8
+ import { getLogsDir, getStateDir, loadState } from '../lib/state.js';
9
+
10
+ interface LogsOptions { full?: boolean }
11
+
12
+ function fatalAndExit(message: string): never {
13
+ logger.fatal(message);
14
+ process.exit(1);
15
+ }
16
+
17
+ export function runLogs(taskIdArg?: string, options?: LogsOptions): void {
18
+ const projectRoot = findProjectRoot();
19
+ if (!projectRoot) {
20
+ fatalAndExit('Not inside a vexdo project.');
21
+ }
22
+
23
+ const state = loadState(projectRoot);
24
+ const taskId = taskIdArg ?? state?.taskId;
25
+
26
+ if (!taskId) {
27
+ const base = path.join(getStateDir(projectRoot), 'logs');
28
+ if (!fs.existsSync(base)) {
29
+ logger.info('No logs available.');
30
+ return;
31
+ }
32
+
33
+ const tasks = fs.readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory());
34
+ for (const dir of tasks) {
35
+ logger.info(dir.name);
36
+ }
37
+ return;
38
+ }
39
+
40
+ const logsDir = getLogsDir(projectRoot, taskId);
41
+ if (!fs.existsSync(logsDir)) {
42
+ fatalAndExit(`No logs found for task '${taskId}'.`);
43
+ }
44
+
45
+ const files = fs.readdirSync(logsDir).filter((name) => name.endsWith('-arbiter.json'));
46
+ for (const arbiterFile of files) {
47
+ const base = arbiterFile.replace(/-arbiter\.json$/, '');
48
+ const arbiterPath = path.join(logsDir, `${base}-arbiter.json`);
49
+ const reviewPath = path.join(logsDir, `${base}-review.json`);
50
+ const diffPath = path.join(logsDir, `${base}-diff.txt`);
51
+
52
+ const arbiter = JSON.parse(fs.readFileSync(arbiterPath, 'utf8')) as { decision: string; summary: string };
53
+ const review = JSON.parse(fs.readFileSync(reviewPath, 'utf8')) as { comments?: unknown[] };
54
+
55
+ logger.info(`${base}: decision=${arbiter.decision}, comments=${String(review.comments?.length ?? 0)}, summary=${arbiter.summary}`);
56
+
57
+ if (options?.full) {
58
+ console.log(fs.readFileSync(diffPath, 'utf8'));
59
+ console.log(JSON.stringify(review, null, 2));
60
+ console.log(JSON.stringify(arbiter, null, 2));
61
+ }
62
+ }
63
+ }
64
+
65
+ export function registerLogsCommand(program: Command): void {
66
+ program
67
+ .command('logs')
68
+ .description('Show iteration logs')
69
+ .argument('[task-id]')
70
+ .option('--full', 'Print full diff and comments')
71
+ .action((taskId?: string, options?: LogsOptions) => {
72
+ runLogs(taskId, options);
73
+ });
74
+ }
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+
3
+ import type { Command } from 'commander';
4
+
5
+ import { ClaudeClient } from '../lib/claude.js';
6
+ import { findProjectRoot, loadConfig } from '../lib/config.js';
7
+ import * as logger from '../lib/logger.js';
8
+ import { requireAnthropicApiKey } from '../lib/requirements.js';
9
+ import { runReviewLoop } from '../lib/review-loop.js';
10
+ import { loadState, saveState } from '../lib/state.js';
11
+ import { ensureTaskDirectory, loadAndValidateTask, moveTaskFileAtomically } from '../lib/tasks.js';
12
+
13
+ interface ReviewOptions { dryRun?: boolean; verbose?: boolean }
14
+
15
+ function fatalAndExit(message: string): never {
16
+ logger.fatal(message);
17
+ process.exit(1);
18
+ }
19
+
20
+ export async function runReview(options: ReviewOptions): Promise<void> {
21
+ try {
22
+ const projectRoot = findProjectRoot();
23
+ if (!projectRoot) {
24
+ fatalAndExit('Not inside a vexdo project.');
25
+ }
26
+
27
+ const config = loadConfig(projectRoot);
28
+ const state = loadState(projectRoot);
29
+ if (!state) {
30
+ fatalAndExit('No active task.');
31
+ }
32
+
33
+ if (!options.dryRun) {
34
+ requireAnthropicApiKey();
35
+ }
36
+
37
+ const currentStep = state.steps.find((step) => step.status === 'in_progress' || step.status === 'pending');
38
+ if (!currentStep) {
39
+ fatalAndExit('No in-progress step found in active task.');
40
+ }
41
+
42
+ if (!fs.existsSync(state.taskPath)) {
43
+ fatalAndExit(`Task file not found: ${state.taskPath}`);
44
+ }
45
+
46
+ const task = loadAndValidateTask(state.taskPath, config);
47
+ const step = task.steps.find((item) => item.service === currentStep.service);
48
+ if (!step) {
49
+ fatalAndExit(`Could not locate task step for service '${currentStep.service}'.`);
50
+ }
51
+
52
+ const result = await runReviewLoop({
53
+ taskId: task.id,
54
+ task,
55
+ step,
56
+ stepState: currentStep,
57
+ projectRoot,
58
+ config,
59
+ claude: new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? ''),
60
+ dryRun: options.dryRun,
61
+ verbose: options.verbose,
62
+ });
63
+
64
+ if (result.decision === 'escalate') {
65
+ logger.escalation({
66
+ taskId: task.id,
67
+ service: step.service,
68
+ iteration: result.finalIteration,
69
+ spec: step.spec,
70
+ diff: '',
71
+ reviewComments: result.lastReviewComments,
72
+ arbiterReasoning: result.lastArbiterResult.reasoning,
73
+ summary: result.lastArbiterResult.summary,
74
+ });
75
+
76
+ currentStep.status = 'escalated';
77
+ state.status = 'escalated';
78
+
79
+ if (!options.dryRun) {
80
+ saveState(projectRoot, state);
81
+ const blockedDir = ensureTaskDirectory(projectRoot, 'blocked');
82
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
83
+ saveState(projectRoot, state);
84
+ }
85
+
86
+ process.exit(1);
87
+ }
88
+
89
+ currentStep.status = 'done';
90
+ if (!options.dryRun) {
91
+ saveState(projectRoot, state);
92
+ }
93
+ } catch (error: unknown) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ fatalAndExit(message);
96
+ }
97
+ }
98
+
99
+ export function registerReviewCommand(program: Command): void {
100
+ program
101
+ .command('review')
102
+ .description('Run review loop for the current step')
103
+ .action(async (options: ReviewOptions, command: Command) => {
104
+ const merged = command.optsWithGlobals();
105
+ await runReview({ ...options, ...merged });
106
+ });
107
+ }
@@ -0,0 +1,197 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { Command } from 'commander';
5
+
6
+ import { ClaudeClient } from '../lib/claude.js';
7
+ import * as codex from '../lib/codex.js';
8
+ import { findProjectRoot, loadConfig } from '../lib/config.js';
9
+ import * as git from '../lib/git.js';
10
+ import * as logger from '../lib/logger.js';
11
+ import { requireAnthropicApiKey } from '../lib/requirements.js';
12
+ import { runReviewLoop } from '../lib/review-loop.js';
13
+ import { createState, hasActiveTask, loadState, saveState } from '../lib/state.js';
14
+ import { submitActiveTask } from '../lib/submit-task.js';
15
+ import { buildInitialStepState, ensureTaskDirectory, loadAndValidateTask, moveTaskFileAtomically } from '../lib/tasks.js';
16
+
17
+ export interface StartCommandOptions {
18
+ dryRun?: boolean;
19
+ verbose?: boolean;
20
+ resume?: boolean;
21
+ }
22
+
23
+ function fatalAndExit(message: string, hint?: string): never {
24
+ logger.fatal(message, hint);
25
+ process.exit(1);
26
+ }
27
+
28
+ export async function runStart(taskFile: string, options: StartCommandOptions): Promise<void> {
29
+ try {
30
+ const projectRoot = findProjectRoot();
31
+ if (!projectRoot) {
32
+ fatalAndExit('Not inside a vexdo project. Could not find .vexdo.yml.');
33
+ }
34
+
35
+ const config = loadConfig(projectRoot);
36
+ const taskPath = path.resolve(taskFile);
37
+ const task = loadAndValidateTask(taskPath, config);
38
+
39
+ if (hasActiveTask(projectRoot) && !options.resume) {
40
+ fatalAndExit('An active task already exists.', "Use --resume to continue or 'vexdo abort' to cancel.");
41
+ }
42
+
43
+ if (!options.dryRun) {
44
+ requireAnthropicApiKey();
45
+ await codex.checkCodexAvailable();
46
+ }
47
+
48
+ let state = loadState(projectRoot);
49
+
50
+ if (!options.resume) {
51
+ let taskPathInProgress = taskPath;
52
+ if (!options.dryRun) {
53
+ const inProgressDir = ensureTaskDirectory(projectRoot, 'in_progress');
54
+ taskPathInProgress = moveTaskFileAtomically(taskPath, inProgressDir);
55
+ }
56
+
57
+ state = createState(task.id, task.title, taskPathInProgress, buildInitialStepState(task));
58
+ if (!options.dryRun) {
59
+ saveState(projectRoot, state);
60
+ }
61
+ }
62
+
63
+ if (!state) {
64
+ fatalAndExit('No resumable task state found.');
65
+ }
66
+
67
+ const claude = new ClaudeClient(process.env.ANTHROPIC_API_KEY ?? '');
68
+ const total = task.steps.length;
69
+
70
+ for (let i = 0; i < task.steps.length; i += 1) {
71
+ const step = task.steps[i];
72
+ const stepState = state.steps[i];
73
+ if (!step || !stepState) {
74
+ continue;
75
+ }
76
+
77
+ if (stepState.status === 'done') {
78
+ continue;
79
+ }
80
+
81
+ if (step.depends_on && step.depends_on.length > 0) {
82
+ for (const depService of step.depends_on) {
83
+ const depState = state.steps.find((item) => item.service === depService);
84
+ if (depState?.status !== 'done') {
85
+ fatalAndExit(`Step dependency '${depService}' for service '${step.service}' is not done.`);
86
+ }
87
+ }
88
+ }
89
+
90
+ logger.step(i + 1, total, `${step.service}: ${task.title}`);
91
+
92
+ const serviceCfg = config.services.find((service) => service.name === step.service);
93
+ if (!serviceCfg) {
94
+ fatalAndExit(`Unknown service in step: ${step.service}`);
95
+ }
96
+
97
+ const serviceRoot = path.resolve(projectRoot, serviceCfg.path);
98
+ const branch = git.getBranchName(task.id, step.service);
99
+
100
+ if (!options.dryRun) {
101
+ if (options.resume) {
102
+ await git.checkoutBranch(stepState.branch ?? branch, serviceRoot);
103
+ } else {
104
+ await git.createBranch(branch, serviceRoot);
105
+ }
106
+ }
107
+
108
+ stepState.status = 'in_progress';
109
+ stepState.branch = branch;
110
+ if (!options.dryRun) {
111
+ saveState(projectRoot, state);
112
+ }
113
+
114
+ if (!options.resume && !options.dryRun) {
115
+ await codex.exec({
116
+ spec: step.spec,
117
+ model: config.codex.model,
118
+ cwd: serviceRoot,
119
+ verbose: options.verbose,
120
+ });
121
+ } else if (options.dryRun) {
122
+ logger.info(`[dry-run] Would run codex for service ${step.service}`);
123
+ }
124
+
125
+ const result = await runReviewLoop({
126
+ taskId: task.id,
127
+ task,
128
+ step,
129
+ stepState,
130
+ projectRoot,
131
+ config,
132
+ claude,
133
+ dryRun: options.dryRun,
134
+ verbose: options.verbose,
135
+ });
136
+
137
+ if (result.decision === 'escalate') {
138
+ logger.escalation({
139
+ taskId: task.id,
140
+ service: step.service,
141
+ iteration: result.finalIteration,
142
+ spec: step.spec,
143
+ diff: '',
144
+ reviewComments: result.lastReviewComments,
145
+ arbiterReasoning: result.lastArbiterResult.reasoning,
146
+ summary: result.lastArbiterResult.summary,
147
+ });
148
+
149
+ stepState.status = 'escalated';
150
+ state.status = 'escalated';
151
+
152
+ if (!options.dryRun) {
153
+ saveState(projectRoot, state);
154
+ const blockedDir = ensureTaskDirectory(projectRoot, 'blocked');
155
+ state.taskPath = moveTaskFileAtomically(state.taskPath, blockedDir);
156
+ saveState(projectRoot, state);
157
+ }
158
+
159
+ process.exit(1);
160
+ }
161
+
162
+ stepState.status = 'done';
163
+ if (!options.dryRun) {
164
+ saveState(projectRoot, state);
165
+ }
166
+ }
167
+
168
+ state.status = 'review';
169
+ if (!options.dryRun) {
170
+ const reviewDir = ensureTaskDirectory(projectRoot, 'review');
171
+ state.taskPath = moveTaskFileAtomically(state.taskPath, reviewDir);
172
+ saveState(projectRoot, state);
173
+ }
174
+
175
+ if (config.review.auto_submit && !options.dryRun) {
176
+ await submitActiveTask(projectRoot, config, state);
177
+ return;
178
+ }
179
+
180
+ logger.success("Task ready for PR. Run 'vexdo submit' to create PR.");
181
+ } catch (error: unknown) {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ fatalAndExit(message);
184
+ }
185
+ }
186
+
187
+ export function registerStartCommand(program: Command): void {
188
+ program
189
+ .command('start')
190
+ .description('Start a task from a YAML file')
191
+ .argument('<task-file>')
192
+ .option('--resume', 'Resume an existing active task')
193
+ .action(async (taskFile: string, options: StartCommandOptions, command: Command) => {
194
+ const merged = command.optsWithGlobals();
195
+ await runStart(taskFile, { ...options, ...merged });
196
+ });
197
+ }
@@ -0,0 +1,52 @@
1
+ import type { Command } from 'commander';
2
+
3
+ import { findProjectRoot } from '../lib/config.js';
4
+ import * as logger from '../lib/logger.js';
5
+ import { loadState } from '../lib/state.js';
6
+
7
+ function fatalAndExit(message: string): never {
8
+ logger.fatal(message);
9
+ process.exit(1);
10
+ }
11
+
12
+ function formatElapsed(startedAt: string): string {
13
+ const elapsedMs = Date.now() - new Date(startedAt).getTime();
14
+ const minutes = Math.floor(elapsedMs / 1000 / 60);
15
+ const hours = Math.floor(minutes / 60);
16
+ if (hours > 0) {
17
+ return `${String(hours)}h ${String(minutes % 60)}m`;
18
+ }
19
+ return `${String(minutes)}m`;
20
+ }
21
+
22
+ export function runStatus(): void {
23
+ const projectRoot = findProjectRoot();
24
+ if (!projectRoot) {
25
+ fatalAndExit('Not inside a vexdo project.');
26
+ }
27
+
28
+ const state = loadState(projectRoot);
29
+ if (!state) {
30
+ fatalAndExit('No active task.');
31
+ }
32
+
33
+ logger.info(`Task: ${state.taskId} — ${state.taskTitle}`);
34
+ logger.info(`Status: ${state.status}`);
35
+ console.log('service | status | iteration | branch');
36
+ for (const step of state.steps) {
37
+ console.log(`${step.service} | ${step.status} | ${String(step.iteration)} | ${step.branch ?? '-'}`);
38
+ }
39
+
40
+ const inProgress = state.steps.find((step) => step.status === 'in_progress');
41
+ if (inProgress?.lastArbiterResult?.summary) {
42
+ logger.info(`Last arbiter summary: ${inProgress.lastArbiterResult.summary}`);
43
+ }
44
+
45
+ logger.info(`Elapsed: ${formatElapsed(state.startedAt)}`);
46
+ }
47
+
48
+ export function registerStatusCommand(program: Command): void {
49
+ program.command('status').description('Print active task status').action(() => {
50
+ runStatus();
51
+ });
52
+ }