@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
package/src/lib/git.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+
3
+ const GIT_TIMEOUT_MS = 30_000;
4
+
5
+ /** Error thrown when a git command fails. */
6
+ export class GitError extends Error {
7
+ command: string;
8
+ exitCode: number;
9
+ stderr: string;
10
+
11
+ constructor(args: string[], exitCode: number, stderr: string) {
12
+ super(`git ${args.join(' ')} failed (exit ${String(exitCode)}): ${stderr}`);
13
+ this.name = 'GitError';
14
+ this.command = `git ${args.join(' ')}`;
15
+ this.exitCode = exitCode;
16
+ this.stderr = stderr;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Run a git command in a specific working directory.
22
+ */
23
+ export async function exec(args: string[], cwd: string): Promise<string> {
24
+ return new Promise((resolve, reject) => {
25
+ execFileCb('git', args, { cwd, timeout: GIT_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout, stderr) => {
26
+ if (error) {
27
+ const exitCode = typeof error.code === 'number' ? error.code : -1;
28
+ reject(new GitError(args, exitCode, (stderr || error.message).trim()));
29
+ return;
30
+ }
31
+ resolve(stdout.trimEnd());
32
+ });
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Get the current branch name.
38
+ */
39
+ export async function getCurrentBranch(cwd: string): Promise<string> {
40
+ return exec(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
41
+ }
42
+
43
+ /**
44
+ * Check whether a local branch exists.
45
+ */
46
+ export async function branchExists(name: string, cwd: string): Promise<boolean> {
47
+ try {
48
+ await exec(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], cwd);
49
+ return true;
50
+ } catch (error) {
51
+ if (error instanceof GitError && error.exitCode === 1) {
52
+ return false;
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Create and checkout a new branch.
60
+ */
61
+ export async function createBranch(name: string, cwd: string): Promise<void> {
62
+ if (await branchExists(name, cwd)) {
63
+ throw new GitError(['checkout', '-b', name], 128, `branch '${name}' already exists`);
64
+ }
65
+ await exec(['checkout', '-b', name], cwd);
66
+ }
67
+
68
+ /**
69
+ * Checkout an existing branch.
70
+ */
71
+ export async function checkoutBranch(name: string, cwd: string): Promise<void> {
72
+ await exec(['checkout', name], cwd);
73
+ }
74
+
75
+ /**
76
+ * Get the git diff for the working directory.
77
+ */
78
+ export async function getDiff(cwd: string, base?: string): Promise<string> {
79
+ if (base) {
80
+ return exec(['diff', `${base}..HEAD`], cwd);
81
+ }
82
+ return exec(['diff', 'HEAD'], cwd);
83
+ }
84
+
85
+ /**
86
+ * Get porcelain status output.
87
+ */
88
+ export async function getStatus(cwd: string): Promise<string> {
89
+ return exec(['status', '--porcelain'], cwd);
90
+ }
91
+
92
+ /**
93
+ * Return whether the repository has uncommitted changes.
94
+ */
95
+ export async function hasUncommittedChanges(cwd: string): Promise<boolean> {
96
+ const status = await getStatus(cwd);
97
+ return status.length > 0;
98
+ }
99
+
100
+ /**
101
+ * Stage all changes.
102
+ */
103
+ export async function stageAll(cwd: string): Promise<void> {
104
+ await exec(['add', '-A'], cwd);
105
+ }
106
+
107
+ /**
108
+ * Commit staged changes.
109
+ */
110
+ export async function commit(message: string, cwd: string): Promise<void> {
111
+ await exec(['commit', '-m', message], cwd);
112
+ }
113
+
114
+ /**
115
+ * Build the task branch name for a service.
116
+ */
117
+ export function getBranchName(taskId: string, service: string): string {
118
+ return `vexdo/${taskId}/${service}`;
119
+ }
@@ -0,0 +1,147 @@
1
+ import ora from 'ora';
2
+ import type { Ora } from 'ora';
3
+ import pc from 'picocolors';
4
+
5
+ import type { ReviewComment } from '../types/index.js';
6
+
7
+ let verboseEnabled = false;
8
+
9
+ function safeLog(method: 'log' | 'error', message: string): void {
10
+ try {
11
+ if (method === 'error') {
12
+ console.error(message);
13
+ } else {
14
+ console.log(message);
15
+ }
16
+ } catch {
17
+ // never throw
18
+ }
19
+ }
20
+
21
+ export function setVerbose(enabled: boolean): void {
22
+ verboseEnabled = enabled;
23
+ }
24
+
25
+ export function info(message: string): void {
26
+ safeLog('log', `${pc.cyan('→')} ${message}`);
27
+ }
28
+
29
+ export function success(message: string): void {
30
+ safeLog('log', `${pc.green('✓')} ${message}`);
31
+ }
32
+
33
+ export function warn(message: string): void {
34
+ safeLog('log', `${pc.yellow('⚠')} ${message}`);
35
+ }
36
+
37
+ export function error(message: string): void {
38
+ safeLog('error', `${pc.red('✖')} ${message}`);
39
+ }
40
+
41
+ export function debug(message: string): void {
42
+ if (!verboseEnabled) {
43
+ return;
44
+ }
45
+ safeLog('log', `${pc.gray('•')} ${message}`);
46
+ }
47
+
48
+ export function header(title: string): void {
49
+ safeLog('log', `${pc.bold(pc.white(title))}\n${pc.gray('─'.repeat(title.length))}`);
50
+ }
51
+
52
+ export function step(n: number, total: number, title: string): void {
53
+ safeLog('log', `${pc.bold(`Step ${String(n)}/${String(total)}:`)} ${title}`);
54
+ }
55
+
56
+ export function iteration(n: number, max: number): void {
57
+ safeLog('log', pc.gray(`Iteration ${String(n)}/${String(max)}`));
58
+ }
59
+
60
+ export function fatal(message: string, hint?: string): void {
61
+ safeLog('error', `${pc.bold(pc.red('Error:'))} ${message}`);
62
+ if (hint) {
63
+ safeLog('error', `${pc.gray('Hint:')} ${hint}`);
64
+ }
65
+ }
66
+
67
+ export function spinner(text: string): Ora {
68
+ try {
69
+ return ora({ text });
70
+ } catch {
71
+ return ora({ text: '' });
72
+ }
73
+ }
74
+
75
+ export function escalation(context: {
76
+ taskId: string;
77
+ service: string;
78
+ iteration: number;
79
+ spec: string;
80
+ diff: string;
81
+ reviewComments: ReviewComment[];
82
+ arbiterReasoning: string;
83
+ summary: string;
84
+ }): void {
85
+ const lines = [
86
+ pc.bold(pc.red('Escalation triggered')),
87
+ `${pc.gray('Task:')} ${context.taskId}`,
88
+ `${pc.gray('Service:')} ${context.service}`,
89
+ `${pc.gray('Iteration:')} ${String(context.iteration)}`,
90
+ `${pc.gray('Summary:')} ${context.summary}`,
91
+ '',
92
+ pc.bold('Spec:'),
93
+ context.spec,
94
+ '',
95
+ pc.bold('Arbiter reasoning:'),
96
+ context.arbiterReasoning,
97
+ '',
98
+ pc.bold('Review comments:'),
99
+ ];
100
+
101
+ for (const comment of context.reviewComments) {
102
+ const sevColor =
103
+ comment.severity === 'critical'
104
+ ? pc.red
105
+ : comment.severity === 'important'
106
+ ? pc.yellow
107
+ : comment.severity === 'minor'
108
+ ? pc.cyan
109
+ : pc.gray;
110
+ const location = comment.file ? ` (${comment.file}${comment.line ? `:${String(comment.line)}` : ''})` : '';
111
+ lines.push(`- ${sevColor(comment.severity.toUpperCase())}${location}: ${comment.comment}`);
112
+ if (comment.suggestion) {
113
+ lines.push(` ${pc.gray(`Suggestion: ${comment.suggestion}`)}`);
114
+ }
115
+ }
116
+
117
+ lines.push('', pc.bold('Diff:'), context.diff, '', pc.gray('Hint: run `vexdo abort` to clear state.'));
118
+
119
+ safeLog('error', lines.join('\n'));
120
+ }
121
+
122
+ export function reviewSummary(comments: ReviewComment[]): void {
123
+ const counts = {
124
+ critical: 0,
125
+ important: 0,
126
+ minor: 0,
127
+ noise: 0,
128
+ };
129
+
130
+ for (const comment of comments) {
131
+ counts[comment.severity] += 1;
132
+ }
133
+
134
+ safeLog(
135
+ 'log',
136
+ `${pc.bold('Review:')} ${String(counts.critical)} critical ${String(counts.important)} important ${String(counts.minor)} minor`,
137
+ );
138
+
139
+ for (const comment of comments) {
140
+ if (comment.severity === 'noise') {
141
+ continue;
142
+ }
143
+
144
+ const location = comment.file ? ` (${comment.file}${comment.line ? `:${String(comment.line)}` : ''})` : '';
145
+ safeLog('log', `- ${comment.severity}${location}: ${comment.comment}`);
146
+ }
147
+ }
@@ -0,0 +1,18 @@
1
+ import * as codex from './codex.js';
2
+ import * as gh from './gh.js';
3
+
4
+ export function requireAnthropicApiKey(): string {
5
+ const apiKey = process.env.ANTHROPIC_API_KEY;
6
+ if (!apiKey) {
7
+ throw new Error('ANTHROPIC_API_KEY is required');
8
+ }
9
+ return apiKey;
10
+ }
11
+
12
+ export async function requireCodexAvailable(): Promise<void> {
13
+ await codex.checkCodexAvailable();
14
+ }
15
+
16
+ export async function requireGhAvailable(): Promise<void> {
17
+ await gh.checkGhAvailable();
18
+ }
@@ -0,0 +1,154 @@
1
+ import path from 'node:path';
2
+
3
+ import * as codex from './codex.js';
4
+ import type { ClaudeClient } from './claude.js';
5
+ import * as git from './git.js';
6
+ import * as logger from './logger.js';
7
+ import * as state from './state.js';
8
+ import type {
9
+ ArbiterResult,
10
+ ReviewComment,
11
+ StepState,
12
+ Task,
13
+ TaskStep,
14
+ VexdoConfig,
15
+ } from '../types/index.js';
16
+
17
+ export interface ReviewLoopOptions {
18
+ taskId: string;
19
+ task: Task;
20
+ step: TaskStep;
21
+ stepState: StepState;
22
+ projectRoot: string;
23
+ config: VexdoConfig;
24
+ claude: ClaudeClient;
25
+ dryRun?: boolean;
26
+ verbose?: boolean;
27
+ }
28
+
29
+ export interface ReviewLoopResult {
30
+ decision: 'submit' | 'escalate';
31
+ finalIteration: number;
32
+ lastReviewComments: ReviewComment[];
33
+ lastArbiterResult: ArbiterResult;
34
+ }
35
+
36
+ export async function runReviewLoop(opts: ReviewLoopOptions): Promise<ReviewLoopResult> {
37
+ if (opts.dryRun) {
38
+ logger.info(`[dry-run] Would run review loop for service ${opts.step.service}`);
39
+ return {
40
+ decision: 'submit',
41
+ finalIteration: opts.stepState.iteration,
42
+ lastReviewComments: [],
43
+ lastArbiterResult: {
44
+ decision: 'submit',
45
+ reasoning: 'Dry run: skipped reviewer and arbiter calls.',
46
+ summary: 'Dry run mode; submitting without external calls.',
47
+ },
48
+ };
49
+ }
50
+
51
+ const serviceConfig = opts.config.services.find((service) => service.name === opts.step.service);
52
+ if (!serviceConfig) {
53
+ throw new Error(`Unknown service in step: ${opts.step.service}`);
54
+ }
55
+
56
+ const serviceRoot = path.resolve(opts.projectRoot, serviceConfig.path);
57
+ let iteration = opts.stepState.iteration;
58
+
59
+ for (;;) {
60
+ logger.iteration(iteration + 1, opts.config.review.max_iterations);
61
+
62
+ const diff = await git.getDiff(serviceRoot);
63
+ if (!diff.trim()) {
64
+ return {
65
+ decision: 'submit',
66
+ finalIteration: iteration,
67
+ lastReviewComments: [],
68
+ lastArbiterResult: {
69
+ decision: 'submit',
70
+ reasoning: 'No changes in git diff for service directory.',
71
+ summary: 'No diff detected, nothing to review.',
72
+ },
73
+ };
74
+ }
75
+
76
+ const review = await opts.claude.runReviewer({
77
+ spec: opts.step.spec,
78
+ diff,
79
+ model: opts.config.review.model,
80
+ });
81
+
82
+ logger.reviewSummary(review.comments);
83
+
84
+ const arbiter = await opts.claude.runArbiter({
85
+ spec: opts.step.spec,
86
+ diff,
87
+ reviewComments: review.comments,
88
+ model: opts.config.review.model,
89
+ });
90
+
91
+ state.saveIterationLog(opts.projectRoot, opts.taskId, opts.step.service, iteration, {
92
+ diff,
93
+ review,
94
+ arbiter,
95
+ });
96
+
97
+ opts.stepState.lastReviewComments = review.comments;
98
+ opts.stepState.lastArbiterResult = arbiter;
99
+
100
+ if (arbiter.decision === 'submit') {
101
+ return {
102
+ decision: 'submit',
103
+ finalIteration: iteration,
104
+ lastReviewComments: review.comments,
105
+ lastArbiterResult: arbiter,
106
+ };
107
+ }
108
+
109
+ if (arbiter.decision === 'escalate') {
110
+ return {
111
+ decision: 'escalate',
112
+ finalIteration: iteration,
113
+ lastReviewComments: review.comments,
114
+ lastArbiterResult: arbiter,
115
+ };
116
+ }
117
+
118
+ if (iteration >= opts.config.review.max_iterations) {
119
+ return {
120
+ decision: 'escalate',
121
+ finalIteration: iteration,
122
+ lastReviewComments: review.comments,
123
+ lastArbiterResult: {
124
+ decision: 'escalate',
125
+ reasoning: 'Max review iterations reached while arbiter still requested fixes.',
126
+ summary: 'Escalated because maximum iterations were exhausted.',
127
+ },
128
+ };
129
+ }
130
+
131
+ if (!arbiter.feedback_for_codex) {
132
+ return {
133
+ decision: 'escalate',
134
+ finalIteration: iteration,
135
+ lastReviewComments: review.comments,
136
+ lastArbiterResult: {
137
+ decision: 'escalate',
138
+ reasoning: 'Arbiter returned fix decision without feedback_for_codex.',
139
+ summary: 'Escalated because fix instructions were missing.',
140
+ },
141
+ };
142
+ }
143
+
144
+ await codex.exec({
145
+ spec: arbiter.feedback_for_codex,
146
+ model: opts.config.codex.model,
147
+ cwd: serviceRoot,
148
+ verbose: opts.verbose,
149
+ });
150
+
151
+ iteration += 1;
152
+ opts.stepState.iteration = iteration;
153
+ }
154
+ }
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { ArbiterResult, ReviewResult, StepState, VexdoState } from '../types/index.js';
5
+
6
+ function nowIso(): string {
7
+ return new Date().toISOString();
8
+ }
9
+
10
+ export function getStateDir(projectRoot: string): string {
11
+ return path.join(projectRoot, '.vexdo');
12
+ }
13
+
14
+ export function getStatePath(projectRoot: string): string {
15
+ return path.join(getStateDir(projectRoot), 'state.json');
16
+ }
17
+
18
+ export function getLogsDir(projectRoot: string, taskId: string): string {
19
+ return path.join(getStateDir(projectRoot), 'logs', taskId);
20
+ }
21
+
22
+ export function ensureLogsDir(projectRoot: string, taskId: string): string {
23
+ const logsDir = getLogsDir(projectRoot, taskId);
24
+ fs.mkdirSync(logsDir, { recursive: true });
25
+ return logsDir;
26
+ }
27
+
28
+ export function loadState(projectRoot: string): VexdoState | null {
29
+ const statePath = getStatePath(projectRoot);
30
+ if (!fs.existsSync(statePath)) {
31
+ return null;
32
+ }
33
+
34
+ const raw = fs.readFileSync(statePath, 'utf8');
35
+ try {
36
+ return JSON.parse(raw) as VexdoState;
37
+ } catch (error: unknown) {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ throw new Error(`Corrupt state file at ${statePath}: ${message}`);
40
+ }
41
+ }
42
+
43
+ export function saveState(projectRoot: string, state: VexdoState): void {
44
+ const stateDir = getStateDir(projectRoot);
45
+ fs.mkdirSync(stateDir, { recursive: true });
46
+
47
+ const nextState: VexdoState = {
48
+ ...state,
49
+ updatedAt: nowIso(),
50
+ };
51
+
52
+ fs.writeFileSync(getStatePath(projectRoot), JSON.stringify(nextState, null, 2) + '\n', 'utf8');
53
+ }
54
+
55
+ export function clearState(projectRoot: string): void {
56
+ const statePath = getStatePath(projectRoot);
57
+ if (fs.existsSync(statePath)) {
58
+ fs.rmSync(statePath);
59
+ }
60
+ }
61
+
62
+ export function hasActiveTask(projectRoot: string): boolean {
63
+ const state = loadState(projectRoot);
64
+ return state?.status === 'in_progress' || state?.status === 'review';
65
+ }
66
+
67
+ export function createState(
68
+ taskId: string,
69
+ taskTitle: string,
70
+ taskPath: string,
71
+ steps: StepState[],
72
+ ): VexdoState {
73
+ const timestamp = nowIso();
74
+ return {
75
+ taskId,
76
+ taskTitle,
77
+ taskPath,
78
+ status: 'in_progress',
79
+ steps: [...steps],
80
+ startedAt: timestamp,
81
+ updatedAt: timestamp,
82
+ };
83
+ }
84
+
85
+ export function updateStep(
86
+ state: VexdoState,
87
+ service: string,
88
+ updates: Partial<Omit<StepState, 'service'>>,
89
+ ): VexdoState {
90
+ return {
91
+ ...state,
92
+ steps: state.steps.map((step) => {
93
+ if (step.service !== service) {
94
+ return step;
95
+ }
96
+ return {
97
+ ...step,
98
+ ...updates,
99
+ };
100
+ }),
101
+ updatedAt: nowIso(),
102
+ };
103
+ }
104
+
105
+ export function saveIterationLog(
106
+ projectRoot: string,
107
+ taskId: string,
108
+ service: string,
109
+ iteration: number,
110
+ payload: { diff: string; review: ReviewResult; arbiter: ArbiterResult },
111
+ ): void {
112
+ const logsDir = ensureLogsDir(projectRoot, taskId);
113
+ const base = `${service}-iteration-${String(iteration)}`;
114
+ fs.writeFileSync(path.join(logsDir, `${base}-diff.txt`), payload.diff, 'utf8');
115
+ fs.writeFileSync(path.join(logsDir, `${base}-review.json`), JSON.stringify(payload.review, null, 2) + '\n', 'utf8');
116
+ fs.writeFileSync(
117
+ path.join(logsDir, `${base}-arbiter.json`),
118
+ JSON.stringify(payload.arbiter, null, 2) + '\n',
119
+ 'utf8',
120
+ );
121
+ }
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import * as gh from './gh.js';
5
+ import * as logger from './logger.js';
6
+ import { clearState, saveState } from './state.js';
7
+ import { ensureTaskDirectory, moveTaskFileAtomically } from './tasks.js';
8
+ import type { VexdoConfig, VexdoState } from '../types/index.js';
9
+
10
+ export async function submitActiveTask(projectRoot: string, config: VexdoConfig, state: VexdoState): Promise<void> {
11
+ for (const step of state.steps) {
12
+ if (step.status !== 'done' && step.status !== 'in_progress') {
13
+ continue;
14
+ }
15
+
16
+ const service = config.services.find((item) => item.name === step.service);
17
+ if (!service) {
18
+ throw new Error(`Unknown service in state: ${step.service}`);
19
+ }
20
+
21
+ const servicePath = path.resolve(projectRoot, service.path);
22
+ const body = `Task: ${state.taskId}\nService: ${step.service}`;
23
+ const url = await gh.createPr({
24
+ title: `${state.taskTitle} [${step.service}]`,
25
+ body,
26
+ base: 'main',
27
+ cwd: servicePath,
28
+ });
29
+
30
+ logger.success(`PR created: ${url}`);
31
+ }
32
+
33
+ state.status = 'done';
34
+ saveState(projectRoot, state);
35
+
36
+ const doneDir = ensureTaskDirectory(projectRoot, 'done');
37
+ if (fs.existsSync(state.taskPath)) {
38
+ state.taskPath = moveTaskFileAtomically(state.taskPath, doneDir);
39
+ saveState(projectRoot, state);
40
+ }
41
+
42
+ clearState(projectRoot);
43
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { load as parseYaml } from 'js-yaml';
5
+
6
+ import type { StepState, Task, TaskStep, VexdoConfig } from '../types/index.js';
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === 'object' && value !== null;
10
+ }
11
+
12
+ function requireString(value: unknown, field: string): string {
13
+ if (typeof value !== 'string' || value.trim().length === 0) {
14
+ throw new Error(`${field} must be a non-empty string`);
15
+ }
16
+
17
+ return value;
18
+ }
19
+
20
+ function parseTaskStep(value: unknown, index: number, config: VexdoConfig): TaskStep {
21
+ if (!isRecord(value)) {
22
+ throw new Error(`steps[${String(index)}] must be an object`);
23
+ }
24
+
25
+ const service = requireString(value.service, `steps[${String(index)}].service`);
26
+ const spec = requireString(value.spec, `steps[${String(index)}].spec`);
27
+
28
+ if (!config.services.some((item) => item.name === service)) {
29
+ throw new Error(`steps[${String(index)}].service references unknown service '${service}'`);
30
+ }
31
+
32
+ const dependsOnRaw = value.depends_on;
33
+ let depends_on: string[] | undefined;
34
+
35
+ if (dependsOnRaw !== undefined) {
36
+ if (!Array.isArray(dependsOnRaw) || !dependsOnRaw.every((dep) => typeof dep === 'string' && dep.trim().length > 0)) {
37
+ throw new Error(`steps[${String(index)}].depends_on must be an array of non-empty strings`);
38
+ }
39
+ depends_on = dependsOnRaw;
40
+ }
41
+
42
+ return {
43
+ service,
44
+ spec,
45
+ depends_on,
46
+ };
47
+ }
48
+
49
+ export function loadAndValidateTask(taskPath: string, config: VexdoConfig): Task {
50
+ const raw = fs.readFileSync(taskPath, 'utf8');
51
+
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = parseYaml(raw);
55
+ } catch (error: unknown) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ throw new Error(`Invalid task YAML: ${message}`);
58
+ }
59
+
60
+ if (!isRecord(parsed)) {
61
+ throw new Error('task must be a YAML object');
62
+ }
63
+
64
+ const id = requireString(parsed.id, 'id');
65
+ const title = requireString(parsed.title, 'title');
66
+
67
+ if (!Array.isArray(parsed.steps) || parsed.steps.length === 0) {
68
+ throw new Error('steps must be a non-empty array');
69
+ }
70
+
71
+ const steps = parsed.steps.map((step, index) => parseTaskStep(step, index, config));
72
+
73
+ return { id, title, steps };
74
+ }
75
+
76
+ export function buildInitialStepState(task: Task): StepState[] {
77
+ return task.steps.map((step) => ({
78
+ service: step.service,
79
+ status: 'pending',
80
+ iteration: 0,
81
+ }));
82
+ }
83
+
84
+ export function ensureTaskDirectory(projectRoot: string, taskState: 'backlog' | 'in_progress' | 'review' | 'done' | 'blocked'): string {
85
+ const dir = path.join(projectRoot, 'tasks', taskState);
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ return dir;
88
+ }
89
+
90
+ export function moveTaskFileAtomically(taskPath: string, destinationDir: string): string {
91
+ const destinationPath = path.join(destinationDir, path.basename(taskPath));
92
+ fs.renameSync(taskPath, destinationPath);
93
+ return destinationPath;
94
+ }