@vexdo/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +1540 -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
@@ -1,120 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
-
3
- const { execFileMock } = vi.hoisted(() => ({
4
- execFileMock: vi.fn(),
5
- }));
6
-
7
- vi.mock('node:child_process', () => ({
8
- execFile: execFileMock,
9
- }));
10
-
11
- import {
12
- GitError,
13
- branchExists,
14
- checkoutBranch,
15
- commit,
16
- createBranch,
17
- exec,
18
- getBranchName,
19
- getCurrentBranch,
20
- getDiff,
21
- getStatus,
22
- hasUncommittedChanges,
23
- stageAll,
24
- } from '../../src/lib/git.js';
25
-
26
- beforeEach(() => {
27
- execFileMock.mockReset();
28
- });
29
-
30
- describe('git.exec', () => {
31
- it('resolves stdout on success', async () => {
32
- execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(null, 'hello\n', ''));
33
-
34
- await expect(exec(['status'], '/repo')).resolves.toBe('hello');
35
- });
36
-
37
- it('throws GitError on non-zero exit with fields', async () => {
38
- const error = Object.assign(new Error('failed'), { code: 2 });
39
- execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(error, '', 'bad things'));
40
-
41
- await expect(exec(['status'], '/repo')).rejects.toMatchObject({
42
- name: 'GitError',
43
- command: 'git status',
44
- exitCode: 2,
45
- stderr: 'bad things',
46
- message: 'git status failed (exit 2): bad things',
47
- });
48
- });
49
- });
50
-
51
- describe('git helpers', () => {
52
- it('getBranchName returns expected format', () => {
53
- expect(getBranchName('task-1', 'api')).toBe('vexdo/task-1/api');
54
- });
55
-
56
- it('getDiff returns empty string when no changes', async () => {
57
- execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(null, '', ''));
58
-
59
- await expect(getDiff('/repo')).resolves.toBe('');
60
- });
61
-
62
- it('hasUncommittedChanges returns true/false from porcelain status', async () => {
63
- execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, ' M file.ts\n', ''));
64
- await expect(hasUncommittedChanges('/repo')).resolves.toBe(true);
65
-
66
- execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, '', ''));
67
- await expect(hasUncommittedChanges('/repo')).resolves.toBe(false);
68
- });
69
-
70
- it('runs all helper functions with git args', async () => {
71
- execFileMock.mockImplementation((_cmd, args, _opts, cb) => {
72
- if (args[0] === 'rev-parse' && args[3] === 'refs/heads/feature') {
73
- const error = Object.assign(new Error('missing'), { code: 1 });
74
- cb(error, '', '');
75
- return;
76
- }
77
- cb(null, 'main\n', '');
78
- });
79
-
80
- await expect(getCurrentBranch('/repo')).resolves.toBe('main');
81
- await expect(branchExists('main', '/repo')).resolves.toBe(true);
82
- await expect(createBranch('feature', '/repo')).resolves.toBeUndefined();
83
- await expect(checkoutBranch('main', '/repo')).resolves.toBeUndefined();
84
- await expect(getStatus('/repo')).resolves.toBe('main');
85
- await expect(stageAll('/repo')).resolves.toBeUndefined();
86
- await expect(commit('msg', '/repo')).resolves.toBeUndefined();
87
-
88
- expect(execFileMock).toHaveBeenCalledWith('git', ['rev-parse', '--abbrev-ref', 'HEAD'], expect.any(Object), expect.any(Function));
89
- expect(execFileMock).toHaveBeenCalledWith(
90
- 'git',
91
- ['rev-parse', '--verify', '--quiet', 'refs/heads/main'],
92
- expect.any(Object),
93
- expect.any(Function),
94
- );
95
- expect(execFileMock).toHaveBeenCalledWith('git', ['checkout', '-b', 'feature'], expect.any(Object), expect.any(Function));
96
- expect(execFileMock).toHaveBeenCalledWith('git', ['checkout', 'main'], expect.any(Object), expect.any(Function));
97
- expect(execFileMock).toHaveBeenCalledWith('git', ['status', '--porcelain'], expect.any(Object), expect.any(Function));
98
- expect(execFileMock).toHaveBeenCalledWith('git', ['add', '-A'], expect.any(Object), expect.any(Function));
99
- expect(execFileMock).toHaveBeenCalledWith('git', ['commit', '-m', 'msg'], expect.any(Object), expect.any(Function));
100
- });
101
-
102
- it('branchExists returns false on exit code 1', async () => {
103
- const error = Object.assign(new Error('not found'), { code: 1 });
104
- execFileMock.mockImplementation((_cmd, _args, _opts, cb) => cb(error, '', ''));
105
-
106
- await expect(branchExists('missing', '/repo')).resolves.toBe(false);
107
- });
108
-
109
- it('createBranch throws if branch exists', async () => {
110
- execFileMock.mockImplementation((_cmd, args, _opts, cb) => {
111
- if (args[0] === 'rev-parse') {
112
- cb(null, 'hash', '');
113
- return;
114
- }
115
- cb(null, '', '');
116
- });
117
-
118
- await expect(createBranch('existing', '/repo')).rejects.toBeInstanceOf(GitError);
119
- });
120
- });
@@ -1,198 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
-
3
- const { getDiffMock, codexExecMock, saveIterationLogMock, reviewSummaryMock, infoMock, iterationMock } = vi.hoisted(() => ({
4
- getDiffMock: vi.fn(),
5
- codexExecMock: vi.fn(),
6
- saveIterationLogMock: vi.fn(),
7
- reviewSummaryMock: vi.fn(),
8
- infoMock: vi.fn(),
9
- iterationMock: vi.fn(),
10
- }));
11
-
12
- vi.mock('../../src/lib/git.js', () => ({
13
- getDiff: getDiffMock,
14
- }));
15
-
16
- vi.mock('../../src/lib/codex.js', () => ({
17
- exec: codexExecMock,
18
- }));
19
-
20
- vi.mock('../../src/lib/state.js', () => ({
21
- saveIterationLog: saveIterationLogMock,
22
- }));
23
-
24
- vi.mock('../../src/lib/logger.js', () => ({
25
- reviewSummary: reviewSummaryMock,
26
- info: infoMock,
27
- iteration: iterationMock,
28
- }));
29
-
30
- import { runReviewLoop } from '../../src/lib/review-loop.js';
31
- import type { ArbiterResult, ReviewResult, StepState, Task, TaskStep, VexdoConfig } from '../../src/types/index.js';
32
-
33
- describe('runReviewLoop', () => {
34
- const task: Task = { id: 't1', title: 'Task', steps: [] };
35
- const step: TaskStep = { service: 'svc', spec: 'spec text' };
36
- const config: VexdoConfig = {
37
- version: 1,
38
- services: [{ name: 'svc', path: '.' }],
39
- review: { model: 'claude-sonnet', max_iterations: 2, auto_submit: false },
40
- codex: { model: 'gpt-5' },
41
- };
42
- let stepState: StepState;
43
-
44
- beforeEach(() => {
45
- getDiffMock.mockReset();
46
- codexExecMock.mockReset();
47
- saveIterationLogMock.mockReset();
48
- reviewSummaryMock.mockReset();
49
- infoMock.mockReset();
50
- iterationMock.mockReset();
51
-
52
- stepState = { service: 'svc', status: 'in_progress', iteration: 0 };
53
- });
54
-
55
- it('Empty diff returns submit immediately without calling Claude', async () => {
56
- getDiffMock.mockResolvedValue('');
57
-
58
- const claude = {
59
- runReviewer: vi.fn(),
60
- runArbiter: vi.fn(),
61
- };
62
-
63
- const result = await runReviewLoop({
64
- taskId: 'task-1',
65
- task,
66
- step,
67
- stepState,
68
- projectRoot: '/repo',
69
- config,
70
- claude: claude as never,
71
- });
72
-
73
- expect(result.decision).toBe('submit');
74
- expect(claude.runReviewer).not.toHaveBeenCalled();
75
- expect(claude.runArbiter).not.toHaveBeenCalled();
76
- });
77
-
78
- it('submit decision returns correct ReviewLoopResult', async () => {
79
- getDiffMock.mockResolvedValue('diff');
80
- const review: ReviewResult = { comments: [{ severity: 'minor', comment: 'small' }] };
81
- const arbiter: ArbiterResult = { decision: 'submit', reasoning: 'ok', summary: 'ok' };
82
- const claude = {
83
- runReviewer: vi.fn().mockResolvedValue(review),
84
- runArbiter: vi.fn().mockResolvedValue(arbiter),
85
- };
86
-
87
- const result = await runReviewLoop({
88
- taskId: 'task-1',
89
- task,
90
- step,
91
- stepState,
92
- projectRoot: '/repo',
93
- config,
94
- claude: claude as never,
95
- });
96
-
97
- expect(result).toEqual({
98
- decision: 'submit',
99
- finalIteration: 0,
100
- lastReviewComments: review.comments,
101
- lastArbiterResult: arbiter,
102
- });
103
- expect(saveIterationLogMock).toHaveBeenCalledTimes(1);
104
- });
105
-
106
- it('escalate decision returns correct ReviewLoopResult', async () => {
107
- getDiffMock.mockResolvedValue('diff');
108
- const review: ReviewResult = { comments: [{ severity: 'critical', comment: 'broken' }] };
109
- const arbiter: ArbiterResult = { decision: 'escalate', reasoning: 'conflict', summary: 'escalate' };
110
- const claude = {
111
- runReviewer: vi.fn().mockResolvedValue(review),
112
- runArbiter: vi.fn().mockResolvedValue(arbiter),
113
- };
114
-
115
- const result = await runReviewLoop({ taskId: 'task-1', task, step, stepState, projectRoot: '/repo', config, claude: claude as never });
116
-
117
- expect(result.decision).toBe('escalate');
118
- expect(result.lastArbiterResult).toEqual(arbiter);
119
- });
120
-
121
- it('fix decision calls codex then loops', async () => {
122
- getDiffMock.mockResolvedValueOnce('diff-1').mockResolvedValueOnce('diff-2');
123
- const claude = {
124
- runReviewer: vi
125
- .fn()
126
- .mockResolvedValueOnce({ comments: [{ severity: 'important', comment: 'fix me' }] })
127
- .mockResolvedValueOnce({ comments: [] }),
128
- runArbiter: vi
129
- .fn()
130
- .mockResolvedValueOnce({ decision: 'fix', reasoning: 'needs fix', summary: 'fix', feedback_for_codex: 'edit file' })
131
- .mockResolvedValueOnce({ decision: 'submit', reasoning: 'done', summary: 'submit' }),
132
- };
133
-
134
- const result = await runReviewLoop({ taskId: 'task-1', task, step, stepState, projectRoot: '/repo', config, claude: claude as never });
135
-
136
- expect(codexExecMock).toHaveBeenCalledTimes(1);
137
- expect(result.decision).toBe('submit');
138
- expect(result.finalIteration).toBe(1);
139
- });
140
-
141
- it('After max_iterations with fix: escalates', async () => {
142
- stepState.iteration = 2;
143
- getDiffMock.mockResolvedValue('diff');
144
- const claude = {
145
- runReviewer: vi.fn().mockResolvedValue({ comments: [{ severity: 'important', comment: 'still bad' }] }),
146
- runArbiter: vi.fn().mockResolvedValue({ decision: 'fix', reasoning: 'still broken', summary: 'fix again', feedback_for_codex: 'more' }),
147
- };
148
-
149
- const result = await runReviewLoop({ taskId: 'task-1', task, step, stepState, projectRoot: '/repo', config, claude: claude as never });
150
-
151
- expect(result.decision).toBe('escalate');
152
- expect(codexExecMock).not.toHaveBeenCalled();
153
- });
154
-
155
- it('dryRun skips all external calls', async () => {
156
- const claude = {
157
- runReviewer: vi.fn(),
158
- runArbiter: vi.fn(),
159
- };
160
-
161
- const result = await runReviewLoop({
162
- taskId: 'task-1',
163
- task,
164
- step,
165
- stepState,
166
- projectRoot: '/repo',
167
- config,
168
- claude: claude as never,
169
- dryRun: true,
170
- });
171
-
172
- expect(result.decision).toBe('submit');
173
- expect(getDiffMock).not.toHaveBeenCalled();
174
- expect(codexExecMock).not.toHaveBeenCalled();
175
- expect(claude.runReviewer).not.toHaveBeenCalled();
176
- expect(claude.runArbiter).not.toHaveBeenCalled();
177
- });
178
-
179
- it('Iteration logs are saved on each iteration', async () => {
180
- getDiffMock.mockResolvedValueOnce('d1').mockResolvedValueOnce('d2').mockResolvedValueOnce('d3');
181
- const claude = {
182
- runReviewer: vi
183
- .fn()
184
- .mockResolvedValueOnce({ comments: [{ severity: 'important', comment: '1' }] })
185
- .mockResolvedValueOnce({ comments: [{ severity: 'important', comment: '2' }] })
186
- .mockResolvedValueOnce({ comments: [] }),
187
- runArbiter: vi
188
- .fn()
189
- .mockResolvedValueOnce({ decision: 'fix', reasoning: '1', summary: '1', feedback_for_codex: 'a' })
190
- .mockResolvedValueOnce({ decision: 'fix', reasoning: '2', summary: '2', feedback_for_codex: 'b' })
191
- .mockResolvedValueOnce({ decision: 'submit', reasoning: '3', summary: '3' }),
192
- };
193
-
194
- await runReviewLoop({ taskId: 'task-1', task, step, stepState, projectRoot: '/repo', config: { ...config, review: { ...config.review, max_iterations: 5 } }, claude: claude as never });
195
-
196
- expect(saveIterationLogMock).toHaveBeenCalledTimes(3);
197
- });
198
- });
@@ -1,137 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
-
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
-
7
- const mocks = vi.hoisted(() => ({
8
- runReviewLoop: vi.fn(),
9
- ClaudeClient: vi.fn(() => ({})),
10
- codexCheck: vi.fn(),
11
- codexExec: vi.fn(),
12
- ghCheck: vi.fn(),
13
- ghCreate: vi.fn(),
14
- gitCreateBranch: vi.fn(),
15
- gitCheckoutBranch: vi.fn(),
16
- gitBranchName: vi.fn(),
17
- }));
18
-
19
- vi.mock('../../src/lib/review-loop.js', () => ({ runReviewLoop: mocks.runReviewLoop }));
20
- vi.mock('../../src/lib/claude.js', () => ({ ClaudeClient: mocks.ClaudeClient }));
21
- vi.mock('../../src/lib/codex.js', () => ({ checkCodexAvailable: mocks.codexCheck, exec: mocks.codexExec }));
22
- vi.mock('../../src/lib/gh.js', () => ({ checkGhAvailable: mocks.ghCheck, createPr: mocks.ghCreate }));
23
- vi.mock('../../src/lib/git.js', () => ({ createBranch: mocks.gitCreateBranch, checkoutBranch: mocks.gitCheckoutBranch, getBranchName: mocks.gitBranchName }));
24
-
25
- import { runReview } from '../../src/commands/review.js';
26
-
27
- const tempDirs: string[] = [];
28
-
29
- function tmpDir(): string {
30
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vexdo-review-'));
31
- tempDirs.push(dir);
32
- return dir;
33
- }
34
-
35
- function setupProject(root: string): void {
36
- fs.mkdirSync(path.join(root, 'tasks', 'in_progress'), { recursive: true });
37
- fs.writeFileSync(
38
- path.join(root, '.vexdo.yml'),
39
- `version: 1
40
- services:
41
- - name: api
42
- path: ./services/api
43
- review:
44
- model: m
45
- max_iterations: 3
46
- auto_submit: false
47
- codex:
48
- model: c
49
- `,
50
- );
51
- const taskPath = path.join(root, 'tasks', 'in_progress', 'task.yml');
52
- fs.writeFileSync(taskPath, 'id: t1\ntitle: Demo\nsteps:\n - service: api\n spec: do work\n');
53
- fs.mkdirSync(path.join(root, '.vexdo'), { recursive: true });
54
- fs.writeFileSync(
55
- path.join(root, '.vexdo', 'state.json'),
56
- JSON.stringify({
57
- taskId: 't1',
58
- taskTitle: 'Demo',
59
- taskPath,
60
- status: 'in_progress',
61
- steps: [{ service: 'api', status: 'in_progress', iteration: 0, branch: 'vexdo/t1/api' }],
62
- startedAt: 'a',
63
- updatedAt: 'a',
64
- }),
65
- );
66
- }
67
-
68
- beforeEach(() => {
69
- process.env.ANTHROPIC_API_KEY = 'k';
70
- mocks.runReviewLoop.mockReset();
71
- mocks.runReviewLoop.mockResolvedValue({
72
- decision: 'submit',
73
- finalIteration: 0,
74
- lastReviewComments: [],
75
- lastArbiterResult: { decision: 'submit', reasoning: 'ok', summary: 'ok' },
76
- });
77
- });
78
-
79
- afterEach(() => {
80
- delete process.env.ANTHROPIC_API_KEY;
81
- for (const dir of tempDirs.splice(0)) {
82
- fs.rmSync(dir, { recursive: true, force: true });
83
- }
84
- });
85
-
86
- describe('review integration', () => {
87
- it('No active task -> fatal', async () => {
88
- const root = tmpDir();
89
- fs.writeFileSync(path.join(root, '.vexdo.yml'), 'version: 1\nservices:\n - name: api\n path: ./services/api\n');
90
- process.chdir(root);
91
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
92
- throw new Error('exit');
93
- }) as never);
94
- await expect(runReview({})).rejects.toThrow('exit');
95
- exitSpy.mockRestore();
96
- });
97
-
98
- it('Runs review loop on current step', async () => {
99
- const root = tmpDir();
100
- setupProject(root);
101
- process.chdir(root);
102
-
103
- await runReview({});
104
-
105
- expect(mocks.runReviewLoop).toHaveBeenCalledTimes(1);
106
- });
107
-
108
- it('Handles submit', async () => {
109
- const root = tmpDir();
110
- setupProject(root);
111
- process.chdir(root);
112
-
113
- await runReview({});
114
-
115
- const state = JSON.parse(fs.readFileSync(path.join(root, '.vexdo', 'state.json'), 'utf8')) as { steps: Array<{ status: string }> };
116
- expect(state.steps[0]?.status).toBe('done');
117
- });
118
-
119
- it('Handles escalate', async () => {
120
- const root = tmpDir();
121
- setupProject(root);
122
- process.chdir(root);
123
- mocks.runReviewLoop.mockResolvedValueOnce({
124
- decision: 'escalate',
125
- finalIteration: 0,
126
- lastReviewComments: [],
127
- lastArbiterResult: { decision: 'escalate', reasoning: 'x', summary: 'x' },
128
- });
129
-
130
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
131
- throw new Error('exit');
132
- }) as never);
133
- await expect(runReview({})).rejects.toThrow('exit');
134
- expect(fs.existsSync(path.join(root, 'tasks', 'blocked', 'task.yml'))).toBe(true);
135
- exitSpy.mockRestore();
136
- });
137
- });
@@ -1,220 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
-
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
-
7
- const mocks = vi.hoisted(() => ({
8
- checkCodexAvailable: vi.fn(),
9
- codexExec: vi.fn(),
10
- createBranch: vi.fn(),
11
- checkoutBranch: vi.fn(),
12
- getBranchName: vi.fn((taskId: string, service: string) => `vexdo/${taskId}/${service}`),
13
- runReviewLoop: vi.fn(),
14
- checkGhAvailable: vi.fn(),
15
- createPr: vi.fn(),
16
- ClaudeClient: vi.fn(() => ({})),
17
- }));
18
-
19
- vi.mock('../../src/lib/codex.js', () => ({
20
- checkCodexAvailable: mocks.checkCodexAvailable,
21
- exec: mocks.codexExec,
22
- }));
23
- vi.mock('../../src/lib/git.js', () => ({
24
- createBranch: mocks.createBranch,
25
- checkoutBranch: mocks.checkoutBranch,
26
- getBranchName: mocks.getBranchName,
27
- }));
28
- vi.mock('../../src/lib/review-loop.js', () => ({
29
- runReviewLoop: mocks.runReviewLoop,
30
- }));
31
- vi.mock('../../src/lib/gh.js', () => ({
32
- checkGhAvailable: mocks.checkGhAvailable,
33
- createPr: mocks.createPr,
34
- }));
35
- vi.mock('../../src/lib/claude.js', () => ({
36
- ClaudeClient: mocks.ClaudeClient,
37
- }));
38
-
39
- import { runStart } from '../../src/commands/start.js';
40
- import { loadState } from '../../src/lib/state.js';
41
-
42
- const tempDirs: string[] = [];
43
-
44
- function tmpDir(): string {
45
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vexdo-start-'));
46
- tempDirs.push(dir);
47
- return dir;
48
- }
49
-
50
- function setupProject(root: string): void {
51
- fs.mkdirSync(path.join(root, 'services', 'api'), { recursive: true });
52
- fs.writeFileSync(
53
- path.join(root, '.vexdo.yml'),
54
- `version: 1
55
- services:
56
- - name: api
57
- path: ./services/api
58
- review:
59
- model: m
60
- max_iterations: 3
61
- auto_submit: false
62
- codex:
63
- model: c
64
- `,
65
- );
66
- }
67
-
68
- beforeEach(() => {
69
- process.env.ANTHROPIC_API_KEY = 'test-key';
70
- for (const fn of Object.values(mocks)) {
71
- if (typeof fn === 'function' && 'mockReset' in fn) {
72
- (fn as unknown as { mockReset: () => void }).mockReset();
73
- }
74
- }
75
- mocks.getBranchName.mockImplementation((taskId: string, service: string) => `vexdo/${taskId}/${service}`);
76
- mocks.runReviewLoop.mockResolvedValue({
77
- decision: 'submit',
78
- finalIteration: 0,
79
- lastReviewComments: [],
80
- lastArbiterResult: { decision: 'submit', reasoning: 'ok', summary: 'ok' },
81
- });
82
- });
83
-
84
- afterEach(() => {
85
- delete process.env.ANTHROPIC_API_KEY;
86
- for (const dir of tempDirs.splice(0)) {
87
- fs.rmSync(dir, { recursive: true, force: true });
88
- }
89
- });
90
-
91
- describe('start integration', () => {
92
- it('Creates branch, moves task to in_progress then review on success', async () => {
93
- const root = tmpDir();
94
- setupProject(root);
95
- const taskPath = path.join(root, 'task.yml');
96
- fs.writeFileSync(taskPath, 'id: t1\ntitle: Demo\nsteps:\n - service: api\n spec: do work\n');
97
- process.chdir(root);
98
-
99
- await runStart(taskPath, {});
100
-
101
- expect(mocks.createBranch).toHaveBeenCalledWith('vexdo/t1/api', path.join(root, 'services', 'api'));
102
- expect(fs.existsSync(path.join(root, 'tasks', 'in_progress', 'task.yml'))).toBe(false);
103
- expect(fs.existsSync(path.join(root, 'tasks', 'review', 'task.yml'))).toBe(true);
104
- });
105
-
106
- it('Stops and moves to blocked on escalation', async () => {
107
- const root = tmpDir();
108
- setupProject(root);
109
- const taskPath = path.join(root, 'task.yml');
110
- fs.writeFileSync(taskPath, 'id: t1\ntitle: Demo\nsteps:\n - service: api\n spec: do work\n');
111
- process.chdir(root);
112
- mocks.runReviewLoop.mockResolvedValueOnce({
113
- decision: 'escalate',
114
- finalIteration: 0,
115
- lastReviewComments: [],
116
- lastArbiterResult: { decision: 'escalate', reasoning: 'no', summary: 'blocked' },
117
- });
118
-
119
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
120
- throw new Error('exit');
121
- }) as never);
122
-
123
- await expect(runStart(taskPath, {})).rejects.toThrow('exit');
124
- expect(fs.existsSync(path.join(root, 'tasks', 'blocked', 'task.yml'))).toBe(true);
125
- exitSpy.mockRestore();
126
- });
127
-
128
- it('Fatal with hint if active task exists and no --resume', async () => {
129
- const root = tmpDir();
130
- setupProject(root);
131
- fs.mkdirSync(path.join(root, '.vexdo'), { recursive: true });
132
- fs.writeFileSync(
133
- path.join(root, '.vexdo', 'state.json'),
134
- JSON.stringify({ taskId: 'x', taskTitle: 'X', taskPath: 'x', status: 'in_progress', steps: [], startedAt: 'a', updatedAt: 'a' }),
135
- );
136
- const taskPath = path.join(root, 'task.yml');
137
- fs.writeFileSync(taskPath, 'id: t1\ntitle: Demo\nsteps:\n - service: api\n spec: do work\n');
138
- process.chdir(root);
139
-
140
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
141
- throw new Error('exit');
142
- }) as never);
143
-
144
- await expect(runStart(taskPath, {})).rejects.toThrow('exit');
145
- exitSpy.mockRestore();
146
- });
147
-
148
- it('--resume skips codex for done steps', async () => {
149
- const root = tmpDir();
150
- setupProject(root);
151
- const inProgressDir = path.join(root, 'tasks', 'in_progress');
152
- fs.mkdirSync(inProgressDir, { recursive: true });
153
- const taskPath = path.join(inProgressDir, 'task.yml');
154
- fs.writeFileSync(taskPath, 'id: t1\ntitle: Demo\nsteps:\n - service: api\n spec: do work\n');
155
- fs.mkdirSync(path.join(root, '.vexdo'), { recursive: true });
156
- fs.writeFileSync(
157
- path.join(root, '.vexdo', 'state.json'),
158
- JSON.stringify({
159
- taskId: 't1',
160
- taskTitle: 'Demo',
161
- taskPath,
162
- status: 'in_progress',
163
- steps: [{ service: 'api', status: 'done', iteration: 0, branch: 'vexdo/t1/api' }],
164
- startedAt: 'a',
165
- updatedAt: 'a',
166
- }),
167
- );
168
- process.chdir(root);
169
-
170
- await runStart(taskPath, { resume: true });
171
-
172
- expect(mocks.codexExec).not.toHaveBeenCalled();
173
- });
174
-
175
- it('--dry-run logs plan without codex/review calls and no file moves', async () => {
176
- const root = tmpDir();
177
- setupProject(root);
178
- const taskPath = path.join(root, 'task.yml');
179
- fs.writeFileSync(taskPath, 'id: t1\ntitle: Demo\nsteps:\n - service: api\n spec: do work\n');
180
- process.chdir(root);
181
-
182
- await runStart(taskPath, { dryRun: true });
183
-
184
- expect(mocks.codexExec).not.toHaveBeenCalled();
185
- expect(mocks.runReviewLoop).toHaveBeenCalled();
186
- expect(fs.existsSync(taskPath)).toBe(true);
187
- expect(loadState(root)).toBeNull();
188
- });
189
-
190
- it('Invalid task YAML (missing id) fatals and no state created', async () => {
191
- const root = tmpDir();
192
- setupProject(root);
193
- const taskPath = path.join(root, 'task.yml');
194
- fs.writeFileSync(taskPath, 'title: Demo\nsteps:\n - service: api\n spec: do work\n');
195
- process.chdir(root);
196
-
197
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
198
- throw new Error('exit');
199
- }) as never);
200
-
201
- await expect(runStart(taskPath, {})).rejects.toThrow('exit');
202
- expect(loadState(root)).toBeNull();
203
- exitSpy.mockRestore();
204
- });
205
-
206
- it('Service not in config fatals', async () => {
207
- const root = tmpDir();
208
- setupProject(root);
209
- const taskPath = path.join(root, 'task.yml');
210
- fs.writeFileSync(taskPath, 'id: t1\ntitle: Demo\nsteps:\n - service: web\n spec: do work\n');
211
- process.chdir(root);
212
-
213
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
214
- throw new Error('exit');
215
- }) as never);
216
-
217
- await expect(runStart(taskPath, {})).rejects.toThrow('exit');
218
- exitSpy.mockRestore();
219
- });
220
- });