From b5659637d447216ef8d459852afd49c783efdc50 Mon Sep 17 00:00:00 2001 From: ubcent Date: Tue, 10 Mar 2026 18:12:27 +0100 Subject: [PATCH] feat: add init command and polish project docs/ci --- .github/workflows/ci.yml | 84 ++++++++++++++++ CLAUDE.md | 93 ++++++++++++++++++ CONTRIBUTING.md | 62 ++++++++++++ README.md | 207 ++++++++++++++++++++++++++++++++++++++- package.json | 3 +- src/commands/init.ts | 142 +++++++++++++++++++++++++++ src/index.ts | 2 + tests/unit/init.test.ts | 91 +++++++++++++++++ 8 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 src/commands/init.ts create mode 100644 tests/unit/init.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2bbc41 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: + - '**' + tags: + - 'v*' + pull_request: + +jobs: + lint-typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}- + - run: npm ci + - run: npm run typecheck + - run: npm run lint + + test: + runs-on: ubuntu-latest + needs: lint-typecheck + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}- + - run: npm ci + - run: npm run test:coverage + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}- + - run: npm ci + - run: npm run build + - run: test -n "$(find dist -mindepth 1 -maxdepth 1 -print -quit)" + + release: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + needs: [lint-typecheck, test, build] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + registry-url: https://registry.npmjs.org + - uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}- + - run: npm ci + - run: npm run build + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c757518 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +## 1) Project overview + +`vexdo` is a task orchestrator CLI for multi-service repositories. The core flow is: load task + config, run Codex for each step, run Claude reviewer + arbiter loop, then submit PRs or escalate. + +## 2) Architecture + +Dependency direction should stay: + +```text +types ← lib ← commands ← index.ts +``` + +Key invariants: +- `commands/` may exit process; `lib/` should generally return errors. +- `lib/` is reusable orchestration/integration logic. +- `types/` stays free of runtime dependencies. +- Reviewer and arbiter must run with isolated contexts/data. + +## 3) Key files and roles + +```text +src/ + index.ts # commander bootstrap, global flags, command wiring + commands/ + init.ts # interactive bootstrap (`vexdo init`) + start.ts # primary task execution flow + review.ts # rerun review loop on active step + fix.ts # feed corrective prompt to Codex then review + submit.ts # PR creation and state completion + abort.ts # cancel task, preserve branches + status.ts # print active state + logs.ts # print iteration logs + lib/ + config.ts # .vexdo.yml discovery + validation + tasks.ts # task loading/validation and lane moves + state.ts # active state persistence + review-loop.ts # reviewer/arbiter loop control + claude.ts # Anthropic SDK wrapper + codex.ts # codex CLI wrapper + gh.ts # gh CLI wrapper for PRs + git.ts # git operations + logger.ts # output formatting + requirements.ts # env/runtime checks + prompts/ + reviewer.ts # reviewer prompt construction + arbiter.ts # arbiter prompt construction + types/index.ts # shared type contracts +``` + +## 4) Common tasks + +### Add a command +1. Add `src/commands/.ts` with `registerCommand`. +2. Keep parsing and CLI concerns in command file. +3. Put reusable logic in `lib/`. +4. Register in `src/index.ts`. +5. Add tests and README docs. + +### Add a lib function +1. Add function to the nearest `lib/*` module. +2. Keep signature typed and avoid `any`. +3. Return errors; avoid process termination. +4. Add focused unit tests. + +### Add a test +1. Unit tests in `test/unit` or `tests/unit` for isolated behavior. +2. Integration tests in `test/integration` or `tests/integration` for flow. +3. Use `vi.mock` for `child_process`, SDKs, and external CLIs. + +## 5) Constraints to always enforce + +- No `any` in new code. +- Keep ESM `.js` import specifiers in TS source. +- No `process.exit` inside `lib/` modules. +- Do not add `chalk`; use `picocolors` for output styling. +- Reviewer and arbiter contexts must remain isolated. + +## 6) Test conventions + +- Prefer mocking at module boundary. +- `child_process` calls should be mocked with `vi.mock('node:child_process', ...)`. +- Anthropic SDK behavior should be mocked through `lib/claude.ts` dependencies. +- Use temp directories for filesystem side effects. + +## 7) What not to do + +- Do not embed orchestration logic directly in `index.ts`. +- Do not tightly couple command handlers to specific prompt text. +- Do not bypass task/config validation. +- Do not mutate state without persisting through `state.ts` helpers. +- Do not mix reviewer and arbiter outputs in a single decision context. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a2f6703 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing to vexdo + +## 1) Development setup + +```bash +git clone +cd vexdo-cli +npm install +npm link +npm test +``` + +Useful checks: +- `npm run typecheck` +- `npm run lint` +- `npm run build` + +## 2) Project structure + +```text +src/ + index.ts # CLI entrypoint and command registration + commands/ # User-facing command handlers + lib/ # Core orchestration, integrations, and helpers + prompts/ # Claude reviewer/arbiter prompt templates + types/ # Shared TypeScript types +test/ + tests/ # Unit and integration tests +``` + +## 3) Adding a new command + +1. Create `src/commands/.ts`. +2. Export `registerCommand(program: Command)`. +3. Implement `run` function and keep side effects inside command layer. +4. Add registration in `src/index.ts`. +5. Add unit tests for parsing/behavior and integration tests if command touches git/fs/process. +6. Update README command docs. + +## 4) Testing conventions + +- **Unit tests**: isolated logic, heavy mocking (`child_process`, SDK clients, filesystem helpers). +- **Integration tests**: flow-level behavior and state transitions. +- Prefer deterministic fixtures and temporary directories. +- External dependencies (Anthropic, gh, codex) must be mocked in unit tests. + +## 5) Commit conventions + +- Follow Conventional Commits, e.g.: + - `feat: add init command` + - `fix: prevent duplicate gitignore entry` + - `docs: expand README` +- PRs should include: + - clear summary + - tests run + - any breaking changes + +## 6) Release process + +1. Bump version and update changelog/release notes. +2. Push tag `vX.Y.Z`. +3. GitHub Actions CI runs lint/typecheck/test/build. +4. Release job publishes to npm using `NPM_TOKEN`. diff --git a/README.md b/README.md index 157bc5f..a11b0f7 100644 --- a/README.md +++ b/README.md @@ -1 +1,206 @@ -# vexdo-cli \ No newline at end of file +# vexdo + +Automated implementation + review loop for multi-service tasks, powered by Codex and Claude. + +![CI](https://github.com/your-org/vexdo/actions/workflows/ci.yml/badge.svg) +![npm](https://img.shields.io/npm/v/vexdo) +![license](https://img.shields.io/npm/l/vexdo) +![node](https://img.shields.io/node/v/vexdo) + +## 1) What is vexdo + +`vexdo` is a CLI that turns a task spec into a controlled execution pipeline across one or more services. It applies changes with Codex, reviews with Claude, loops until quality gates are met, and then opens PRs (or escalates when needed). + +Real-world example: you need to add a new billing field in API, web, and worker repos. Instead of running separate ad-hoc sessions, you define one `task.yml` with ordered steps and let `vexdo` orchestrate execution + review with traceable state. + +## 2) How it works + +```text +task.yml + ↓ +vexdo start + ↓ +codex exec + ↓ +git diff + ↓ +claude reviewer + ↓ +claude arbiter + ↓ +fix loop (max N) + ↓ +PR or escalate +``` + +## 3) Requirements + +- Node.js >= 18 +- `ANTHROPIC_API_KEY` environment variable +- Codex CLI installed globally: + - `npm install -g @openai/codex` +- GitHub CLI installed: + - https://cli.github.com + +## 4) Quick start (5 minutes) + +```bash +npm install -g vexdo +cd my-project +vexdo init +# create tasks/backlog/my-task.yml +vexdo start tasks/backlog/my-task.yml +``` + +## 5) Commands + +| Command | Description | +|---|---| +| `vexdo init` | Initialize `.vexdo.yml`, folders, and `.gitignore` entry. | +| `vexdo start [--resume]` | Start a task and run implementation/review orchestration. | +| `vexdo review` | Re-run review loop for the current step. | +| `vexdo fix ` | Send targeted feedback to Codex, then review again. | +| `vexdo submit` | Create PRs for active task branches. | +| `vexdo status` | Show current task state. | +| `vexdo logs [task-id] [--full]` | Inspect iteration logs. | +| `vexdo abort [--force]` | Abort active task and move task file back to backlog. | + +### Global flags + +- `--verbose`: Print debug logs. +- `--dry-run`: Show actions without making changes. + +### Command details + +#### `vexdo init` +Interactive bootstrap wizard: +- asks service names/paths +- asks review and model defaults +- writes `.vexdo.yml` +- creates `tasks/*` lanes and `.vexdo/logs/` +- adds `.vexdo/` to `.gitignore` once + +#### `vexdo start ` +Flags: +- `--resume`: resume from existing state if present + +Behavior: +- validates config and task +- creates/checks service branches +- runs Codex then review loop per step +- moves task files across lanes (`backlog → in_progress → review/done/blocked`) + +#### `vexdo review` +Runs review + arbiter flow against current step without restarting full task. + +#### `vexdo fix ` +Runs Codex with your corrective feedback, then re-enters review loop. + +#### `vexdo submit` +Creates PRs for each service branch in the active task and marks task as done. + +#### `vexdo status` +Prints a concise summary of active task id/title/step statuses. + +#### `vexdo logs [task-id]` +Flags: +- `--full`: include full diffs and complete comment payloads + +#### `vexdo abort` +Flags: +- `--force`: skip confirmation prompt + +## 6) Task YAML format + +```yaml +id: billing-vat-001 +title: "Add VAT ID support" +depends_on: [] # optional task-level dependencies + +steps: + - service: api + spec: | + Add vat_id to organization model, migration, and create/update APIs. + Ensure validation rules and API docs are updated. + + - service: web + depends_on: [api] # optional per-step ordering dependency + spec: | + Add VAT ID input to organization settings screen. + Integrate with updated API and show validation errors. +``` + +Field reference: +- `id` *(string, required)*: stable slug used for branches and state. +- `title` *(string, required)*: human-readable name. +- `depends_on` *(string[], optional)*: coarse dependency marker at task level. +- `steps` *(array, required)*: ordered units of work. + - `service` *(string, required)*: must match `.vexdo.yml` service name. + - `spec` *(string, required)*: concrete implementation request for Codex. + - `depends_on` *(string[], optional)*: service dependencies before this step starts. + +## 7) `.vexdo.yml` format + +```yaml +version: 1 +services: + - name: api + path: ./api + - name: web + path: ./web + +review: + model: claude-haiku-4-5-20251001 + max_iterations: 3 + auto_submit: false + +codex: + model: gpt-4o +``` + +Field reference: +- `version`: config schema version (currently `1`). +- `services`: list of service roots used by task steps. +- `review.model`: Claude model for reviewer + arbiter. +- `review.max_iterations`: hard cap for fix/review loop. +- `review.auto_submit`: auto-run `submit` after successful review. +- `codex.model`: model passed to Codex execution. + +## 8) Spec format guide + +Good specs are: +- **specific**: list exact files, behaviors, and edge cases. +- **testable**: include acceptance checks. +- **constrained**: mention prohibited changes and compatibility limits. + +Recommended template: + +```text +Goal: +Constraints: +Acceptance criteria: +- ... +- ... +Non-goals: +``` + +## 9) Troubleshooting + +- **"Not inside a vexdo project"** + - Run from a directory containing `.vexdo.yml` (or use `vexdo init`). +- **Anthropic key errors** + - Ensure `ANTHROPIC_API_KEY` is exported in shell/CI. +- **Codex not found** + - Install with `npm install -g @openai/codex` and verify PATH. +- **`vexdo submit` fails with GitHub auth issues** + - Run `gh auth login` and confirm repo access. +- **Task escalated** + - Review `.vexdo/logs/*` and rerun with `vexdo fix "..."`. + +## 10) Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md). + +## 11) License + +MIT. diff --git a/package.json b/package.json index 8f369ae..eda813e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "tsup", "typecheck": "tsc --noEmit", "lint": "eslint .", - "test": "vitest run" + "test": "vitest run", + "test:coverage": "vitest run" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..9a189f1 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,142 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; + +import { Command } from 'commander'; +import { stringify } from 'yaml'; + +import * as logger from '../lib/logger.js'; +import type { VexdoConfig } from '../types/index.js'; + +const DEFAULT_REVIEW_MODEL = 'claude-haiku-4-5-20251001'; +const DEFAULT_MAX_ITERATIONS = 3; +const DEFAULT_CODEX_MODEL = 'gpt-4o'; + +const TASK_DIRS = ['backlog', 'in_progress', 'review', 'done', 'blocked'] as const; + +export type PromptFn = (question: string) => Promise; + +async function defaultPrompt(question: string): Promise { + const rl = createInterface({ input, output }); + try { + return await rl.question(question); + } finally { + rl.close(); + } +} + +function parseServices(value: string): string[] { + const parsed = value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + + return Array.from(new Set(parsed)); +} + +function parseBoolean(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; +} + +function parseMaxIterations(value: string): number { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ITERATIONS; +} + +function ensureGitignoreEntry(gitignorePath: string, entry: string): boolean { + if (!fs.existsSync(gitignorePath)) { + fs.writeFileSync(gitignorePath, `${entry}\n`, 'utf8'); + return true; + } + + const content = fs.readFileSync(gitignorePath, 'utf8'); + const lines = content.split(/\r?\n/).map((line) => line.trim()); + + if (lines.includes(entry)) { + return false; + } + + const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n'; + fs.appendFileSync(gitignorePath, `${suffix}${entry}\n`, 'utf8'); + return true; +} + +export async function runInit(projectRoot: string, prompt: PromptFn = defaultPrompt): Promise { + const configPath = path.join(projectRoot, '.vexdo.yml'); + + if (fs.existsSync(configPath)) { + logger.warn('Found existing .vexdo.yml.'); + const overwriteAnswer = await prompt('Overwrite existing .vexdo.yml? (y/N): '); + if (!parseBoolean(overwriteAnswer)) { + logger.info('Initialization cancelled.'); + return; + } + } + + let services = parseServices(await prompt('Project services (comma-separated names, e.g. api,web): ')); + if (services.length === 0) { + services = ['api']; + } + + const serviceConfigs: VexdoConfig['services'] = []; + for (const name of services) { + const answer = await prompt(`Path for ${name} (default: ./${name}): `); + serviceConfigs.push({ + name, + path: answer.trim().length > 0 ? answer.trim() : `./${name}`, + }); + } + + const reviewModelRaw = await prompt(`Review model (default: ${DEFAULT_REVIEW_MODEL}): `); + const maxIterationsRaw = await prompt(`Max review iterations (default: ${String(DEFAULT_MAX_ITERATIONS)}): `); + const autoSubmitRaw = await prompt('Auto-submit PRs? (y/N): '); + const codexModelRaw = await prompt(`Codex model (default: ${DEFAULT_CODEX_MODEL}): `); + + const config: VexdoConfig = { + version: 1, + services: serviceConfigs, + review: { + model: reviewModelRaw.trim() || DEFAULT_REVIEW_MODEL, + max_iterations: maxIterationsRaw.trim() ? parseMaxIterations(maxIterationsRaw.trim()) : DEFAULT_MAX_ITERATIONS, + auto_submit: parseBoolean(autoSubmitRaw), + }, + codex: { + model: codexModelRaw.trim() || DEFAULT_CODEX_MODEL, + }, + }; + + fs.writeFileSync(configPath, stringify(config), 'utf8'); + + const createdDirs: string[] = []; + for (const taskDir of TASK_DIRS) { + const directory = path.join(projectRoot, 'tasks', taskDir); + fs.mkdirSync(directory, { recursive: true }); + createdDirs.push(path.relative(projectRoot, directory)); + } + + const logDir = path.join(projectRoot, '.vexdo', 'logs'); + fs.mkdirSync(logDir, { recursive: true }); + createdDirs.push(path.relative(projectRoot, logDir)); + + const gitignorePath = path.join(projectRoot, '.gitignore'); + const gitignoreUpdated = ensureGitignoreEntry(gitignorePath, '.vexdo/'); + + logger.success('Initialized vexdo project.'); + logger.info(`Created: ${path.relative(projectRoot, configPath)}`); + logger.info(`Created directories: ${createdDirs.join(', ')}`); + if (gitignoreUpdated) { + logger.info('Updated .gitignore with .vexdo/'); + } + logger.info("Next: create a task file in tasks/backlog/ and run 'vexdo start tasks/backlog/my-task.yml'"); +} + +export function registerInitCommand(program: Command): void { + program + .command('init') + .description('Initialize vexdo in the current project') + .action(async () => { + await runInit(process.cwd()); + }); +} diff --git a/src/index.ts b/src/index.ts index d3e4286..80c78f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { Command } from 'commander'; import { registerAbortCommand } from './commands/abort.js'; import { registerFixCommand } from './commands/fix.js'; +import { registerInitCommand } from './commands/init.js'; import { registerLogsCommand } from './commands/logs.js'; import { registerReviewCommand } from './commands/review.js'; import { registerStartCommand } from './commands/start.js'; @@ -29,6 +30,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => { logger.setVerbose(Boolean(globalOpts.verbose)); }); +registerInitCommand(program); registerStartCommand(program); registerReviewCommand(program); registerFixCommand(program); diff --git a/tests/unit/init.test.ts b/tests/unit/init.test.ts new file mode 100644 index 0000000..3463cb3 --- /dev/null +++ b/tests/unit/init.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parse } from 'yaml'; + +import { runInit } from '../../src/commands/init.js'; +import * as logger from '../../src/lib/logger.js'; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'vexdo-init-')); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('runInit', () => { + it('creates .vexdo.yml with answers and all expected directories', async () => { + const root = makeTempDir(); + + const answers = [ + 'api,web', + '', + './apps/web', + 'claude-sonnet-4-5', + '5', + 'y', + 'gpt-4.1', + ]; + + await runInit(root, async () => answers.shift() ?? ''); + + const configPath = path.join(root, '.vexdo.yml'); + expect(fs.existsSync(configPath)).toBe(true); + + const config = parse(fs.readFileSync(configPath, 'utf8')) as { + services: Array<{ name: string; path: string }>; + review: { model: string; max_iterations: number; auto_submit: boolean }; + codex: { model: string }; + }; + + expect(config.services).toEqual([ + { name: 'api', path: './api' }, + { name: 'web', path: './apps/web' }, + ]); + expect(config.review).toEqual({ + model: 'claude-sonnet-4-5', + max_iterations: 5, + auto_submit: true, + }); + expect(config.codex).toEqual({ model: 'gpt-4.1' }); + + for (const taskDir of ['backlog', 'in_progress', 'review', 'done', 'blocked']) { + expect(fs.existsSync(path.join(root, 'tasks', taskDir))).toBe(true); + } + expect(fs.existsSync(path.join(root, '.vexdo', 'logs'))).toBe(true); + }); + + it('appends .vexdo/ to .gitignore once and does not duplicate on second run', async () => { + const root = makeTempDir(); + + const answersFirst = ['api', '', '', '', 'n', '']; + await runInit(root, async () => answersFirst.shift() ?? ''); + + let gitignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); + expect((gitignore.match(/\.vexdo\//g) ?? []).length).toBe(1); + + const answersSecond = ['y', 'api', '', '', '', 'n', '']; + await runInit(root, async () => answersSecond.shift() ?? ''); + + gitignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); + expect((gitignore.match(/\.vexdo\//g) ?? []).length).toBe(1); + }); + + it('warns and asks before overwrite when .vexdo.yml already exists', async () => { + const root = makeTempDir(); + fs.writeFileSync(path.join(root, '.vexdo.yml'), 'version: 1\nservices: []\n', 'utf8'); + + const warnSpy = vi.spyOn(logger, 'warn'); + const promptSpy = vi.fn(async () => 'n'); + + await runInit(root, promptSpy); + + expect(warnSpy).toHaveBeenCalledWith('Found existing .vexdo.yml.'); + expect(promptSpy).toHaveBeenCalledWith('Overwrite existing .vexdo.yml? (y/N): '); + const content = fs.readFileSync(path.join(root, '.vexdo.yml'), 'utf8'); + expect(content).toContain('services: []'); + }); +});