Merge branch 'cw/small-change-flow-phase-cli-errand-commands' into cw-merge-1772810965256

This commit is contained in:
Lukas May
2026-03-06 16:29:25 +01:00
7 changed files with 655 additions and 7 deletions

View File

@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCli } from './index.js';
const mockClient = {
errand: {
create: { mutate: vi.fn() },
list: { query: vi.fn() },
get: { query: vi.fn() },
diff: { query: vi.fn() },
complete: { mutate: vi.fn() },
merge: { mutate: vi.fn() },
delete: { mutate: vi.fn() },
sendMessage: { mutate: vi.fn() },
abandon: { mutate: vi.fn() },
},
};
vi.mock('./trpc-client.js', () => ({
createDefaultTrpcClient: () => mockClient,
}));
beforeEach(() => {
vi.clearAllMocks();
});
async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const stdoutLines: string[] = [];
const stderrLines: string[] = [];
let exitCode = 0;
vi.spyOn(process.stdout, 'write').mockImplementation((s: any) => { stdoutLines.push(String(s)); return true; });
vi.spyOn(process.stderr, 'write').mockImplementation((s: any) => { stderrLines.push(String(s)); return true; });
vi.spyOn(console, 'log').mockImplementation((...a: any[]) => { stdoutLines.push(a.join(' ')); });
vi.spyOn(console, 'error').mockImplementation((...a: any[]) => { stderrLines.push(a.join(' ')); });
vi.spyOn(process, 'exit').mockImplementation((code?: any) => { exitCode = code ?? 0; throw new Error(`process.exit(${code})`); });
const program = createCli();
try {
await program.parseAsync(['node', 'cw', ...args]);
} catch (e: any) {
if (!e.message?.startsWith('process.exit')) throw e;
}
vi.restoreAllMocks();
return {
stdout: stdoutLines.join('\n'),
stderr: stderrLines.join('\n'),
exitCode,
};
}
describe('cw errand start', () => {
it('calls errand.create.mutate with correct args and prints output', async () => {
mockClient.errand.create.mutate.mockResolvedValueOnce({
id: 'errand-abc123',
branch: 'cw/errand/fix-typo-errand-ab',
agentId: 'agent-xyz',
});
const { stdout, exitCode } = await runCli(['errand', 'start', 'fix typo', '--project', 'proj-1']);
expect(mockClient.errand.create.mutate).toHaveBeenCalledWith({
description: 'fix typo',
projectId: 'proj-1',
baseBranch: undefined,
});
expect(stdout).toContain('Errand started');
expect(stdout).toContain('errand-abc123');
expect(exitCode).toBe(0);
});
it('exits 1 and prints length error without calling tRPC when description > 200 chars', async () => {
const longDesc = 'x'.repeat(201);
const { stderr, exitCode } = await runCli(['errand', 'start', longDesc, '--project', 'proj-1']);
expect(mockClient.errand.create.mutate).not.toHaveBeenCalled();
expect(stderr).toContain('description must be ≤200 characters (201 given)');
expect(exitCode).toBe(1);
});
it('passes --base option as baseBranch', async () => {
mockClient.errand.create.mutate.mockResolvedValueOnce({ id: 'e1', branch: 'b', agentId: 'a' });
await runCli(['errand', 'start', 'fix thing', '--project', 'p1', '--base', 'develop']);
expect(mockClient.errand.create.mutate).toHaveBeenCalledWith(
expect.objectContaining({ baseBranch: 'develop' })
);
});
});
describe('cw errand list', () => {
it('prints tab-separated rows for errands', async () => {
mockClient.errand.list.query.mockResolvedValueOnce([
{ id: 'errand-abc123full', description: 'fix the bug', branch: 'cw/errand/fix-bug-errand-ab', status: 'active', agentAlias: 'my-agent' },
]);
const { stdout } = await runCli(['errand', 'list']);
expect(stdout).toContain('errand-a'); // id.slice(0,8)
expect(stdout).toContain('fix the bug');
expect(stdout).toContain('active');
expect(stdout).toContain('my-agent');
});
it('prints "No errands found" on empty result', async () => {
mockClient.errand.list.query.mockResolvedValueOnce([]);
const { stdout } = await runCli(['errand', 'list']);
expect(stdout).toContain('No errands found');
});
it('truncates description at 60 chars with ellipsis', async () => {
const longDesc = 'a'.repeat(65);
mockClient.errand.list.query.mockResolvedValueOnce([
{ id: 'x'.repeat(16), description: longDesc, branch: 'b', status: 'active', agentAlias: null },
]);
const { stdout } = await runCli(['errand', 'list']);
expect(stdout).toContain('a'.repeat(57) + '...');
});
it('passes --status filter to query', async () => {
mockClient.errand.list.query.mockResolvedValueOnce([]);
await runCli(['errand', 'list', '--status', 'active']);
expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
});
it('passes --project filter to query', async () => {
mockClient.errand.list.query.mockResolvedValueOnce([]);
await runCli(['errand', 'list', '--project', 'proj-99']);
expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'proj-99' }));
});
it('shows "-" for null agentAlias', async () => {
mockClient.errand.list.query.mockResolvedValueOnce([
{ id: 'x'.repeat(16), description: 'test', branch: 'b', status: 'active', agentAlias: null },
]);
const { stdout } = await runCli(['errand', 'list']);
expect(stdout).toContain('-');
});
});
describe('cw errand chat', () => {
it('calls sendMessage.mutate with no stdout on success', async () => {
mockClient.errand.sendMessage.mutate.mockResolvedValueOnce({ success: true });
const { stdout, exitCode } = await runCli(['errand', 'chat', 'e1', 'hello there']);
expect(mockClient.errand.sendMessage.mutate).toHaveBeenCalledWith({ id: 'e1', message: 'hello there' });
expect(stdout.trim()).toBe('');
expect(exitCode).toBe(0);
});
it('exits 1 and prints error when tRPC throws (agent not running)', async () => {
mockClient.errand.sendMessage.mutate.mockRejectedValueOnce(new Error('Agent is not running (status: stopped)'));
const { stderr, exitCode } = await runCli(['errand', 'chat', 'e1', 'msg']);
expect(stderr).toContain('Agent is not running');
expect(exitCode).toBe(1);
});
});
describe('cw errand diff', () => {
it('writes raw diff to stdout and exits 0', async () => {
mockClient.errand.diff.query.mockResolvedValueOnce({ diff: 'diff --git a/foo.ts b/foo.ts\n+++ change' });
const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']);
expect(stdout).toContain('diff --git');
expect(exitCode).toBe(0);
});
it('produces no output on empty diff and exits 0', async () => {
mockClient.errand.diff.query.mockResolvedValueOnce({ diff: '' });
const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']);
expect(stdout.trim()).toBe('');
expect(exitCode).toBe(0);
});
it('exits 1 with "Errand <id> not found" on NOT_FOUND error', async () => {
mockClient.errand.diff.query.mockRejectedValueOnce(new Error('NOT_FOUND: errand not found'));
const { stderr, exitCode } = await runCli(['errand', 'diff', 'missing-id']);
expect(stderr).toContain('Errand missing-id not found');
expect(exitCode).toBe(1);
});
});
describe('cw errand complete', () => {
it('prints "Errand <id> marked as ready for review"', async () => {
mockClient.errand.complete.mutate.mockResolvedValueOnce({});
const { stdout, exitCode } = await runCli(['errand', 'complete', 'errand-1']);
expect(stdout).toContain('Errand errand-1 marked as ready for review');
expect(exitCode).toBe(0);
});
});
describe('cw errand merge', () => {
it('prints "Merged <branch> into <baseBranch>" on clean merge', async () => {
mockClient.errand.get.query.mockResolvedValueOnce({
id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review',
conflictFiles: [], projectPath: '/path/to/repo',
});
mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' });
const { stdout, exitCode } = await runCli(['errand', 'merge', 'e1']);
expect(stdout).toContain('Merged cw/errand/fix-bug-e1 into main');
expect(exitCode).toBe(0);
});
it('exits 1 and prints conflicting files on conflict', async () => {
mockClient.errand.get.query.mockResolvedValueOnce({
id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review',
conflictFiles: [], projectPath: '/repo',
});
const conflictError = Object.assign(new Error('Merge conflict'), {
data: { conflictFiles: ['src/a.ts', 'src/b.ts'] },
});
mockClient.errand.merge.mutate.mockRejectedValueOnce(conflictError);
const { stderr, exitCode } = await runCli(['errand', 'merge', 'e1']);
expect(stderr).toContain('Merge conflict in 2 file(s)');
expect(stderr).toContain('src/a.ts');
expect(stderr).toContain('src/b.ts');
expect(stderr).toContain('Run: cw errand resolve e1');
expect(exitCode).toBe(1);
});
it('uses --target override instead of baseBranch', async () => {
mockClient.errand.get.query.mockResolvedValueOnce({
id: 'e1', branch: 'cw/errand/fix-e1', baseBranch: 'main', status: 'pending_review',
conflictFiles: [], projectPath: '/repo',
});
mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' });
const { stdout } = await runCli(['errand', 'merge', 'e1', '--target', 'develop']);
expect(stdout).toContain('Merged cw/errand/fix-e1 into develop');
expect(mockClient.errand.merge.mutate).toHaveBeenCalledWith({ id: 'e1', target: 'develop' });
});
});
describe('cw errand resolve', () => {
it('prints worktree path and conflicting files when status is conflict', async () => {
mockClient.errand.get.query.mockResolvedValueOnce({
id: 'e1', status: 'conflict', conflictFiles: ['src/a.ts', 'src/b.ts'],
projectPath: '/home/user/project', branch: 'cw/errand/fix-e1', baseBranch: 'main',
});
const { stdout, exitCode } = await runCli(['errand', 'resolve', 'e1']);
expect(stdout).toContain('/home/user/project/.cw-worktrees/e1');
expect(stdout).toContain('src/a.ts');
expect(stdout).toContain('src/b.ts');
expect(stdout).toContain('cw errand merge e1');
expect(exitCode).toBe(0);
});
it('exits 1 with status message when errand is not in conflict', async () => {
mockClient.errand.get.query.mockResolvedValueOnce({
id: 'e1', status: 'pending_review', conflictFiles: [], projectPath: '/repo',
});
const { stderr, exitCode } = await runCli(['errand', 'resolve', 'e1']);
expect(stderr).toContain('is not in conflict');
expect(stderr).toContain('pending_review');
expect(exitCode).toBe(1);
});
});
describe('cw errand abandon', () => {
it('prints "Errand <id> abandoned"', async () => {
mockClient.errand.abandon.mutate.mockResolvedValueOnce({});
const { stdout, exitCode } = await runCli(['errand', 'abandon', 'errand-1']);
expect(stdout).toContain('Errand errand-1 abandoned');
expect(exitCode).toBe(0);
});
});
describe('cw errand delete', () => {
it('prints "Errand <id> deleted"', async () => {
mockClient.errand.delete.mutate.mockResolvedValueOnce({ success: true });
const { stdout, exitCode } = await runCli(['errand', 'delete', 'errand-1']);
expect(stdout).toContain('Errand errand-1 deleted');
expect(exitCode).toBe(0);
});
});

View File

@@ -1728,6 +1728,195 @@ See the Codewalkers documentation for .cw-preview.yml format and options.`;
} }
}); });
// ── Errand commands ────────────────────────────────────────────────
const errandCommand = program
.command('errand')
.description('Manage lightweight interactive agent sessions for small changes');
errandCommand
.command('start <description>')
.description('Start a new errand session')
.requiredOption('--project <id>', 'Project ID')
.option('--base <branch>', 'Base branch to create errand from (default: main)')
.action(async (description: string, options: { project: string; base?: string }) => {
if (description.length > 200) {
console.error(`Error: description must be ≤200 characters (${description.length} given)`);
process.exit(1);
}
try {
const client = createDefaultTrpcClient();
const errand = await client.errand.create.mutate({
description,
projectId: options.project,
baseBranch: options.base,
});
console.log('Errand started');
console.log(` ID: ${errand.id}`);
console.log(` Branch: ${errand.branch}`);
console.log(` Agent: ${errand.agentId}`);
} catch (error) {
console.error('Failed to start errand:', (error as Error).message);
process.exit(1);
}
});
errandCommand
.command('list')
.description('List errands')
.option('--project <id>', 'Filter by project')
.option('--status <status>', 'Filter by status: active|pending_review|conflict|merged|abandoned')
.action(async (options: { project?: string; status?: string }) => {
try {
const client = createDefaultTrpcClient();
const errands = await client.errand.list.query({
projectId: options.project,
status: options.status as any,
});
if (errands.length === 0) {
console.log('No errands found');
return;
}
for (const e of errands) {
const desc = e.description.length > 60 ? e.description.slice(0, 57) + '...' : e.description;
console.log([e.id.slice(0, 8), desc, e.branch, e.status, e.agentAlias ?? '-'].join('\t'));
}
} catch (error) {
console.error('Failed to list errands:', (error as Error).message);
process.exit(1);
}
});
errandCommand
.command('chat <id> <message>')
.description('Deliver a message to the running errand agent')
.action(async (id: string, message: string) => {
try {
const client = createDefaultTrpcClient();
await client.errand.sendMessage.mutate({ id, message });
// No stdout on success — agent response appears in UI log stream
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
});
errandCommand
.command('diff <id>')
.description('Print unified git diff between base branch and errand branch')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
const { diff } = await client.errand.diff.query({ id });
if (diff) process.stdout.write(diff);
// Empty diff: no output, exit 0 — not an error
} catch (error) {
const msg = (error as Error).message;
if (msg.includes('not found') || msg.includes('NOT_FOUND')) {
console.error(`Errand ${id} not found`);
} else {
console.error(msg);
}
process.exit(1);
}
});
errandCommand
.command('complete <id>')
.description('Mark errand as done and ready for review')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
await client.errand.complete.mutate({ id });
console.log(`Errand ${id} marked as ready for review`);
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
});
errandCommand
.command('merge <id>')
.description('Merge errand branch into target branch')
.option('--target <branch>', 'Target branch (default: baseBranch stored in DB)')
.action(async (id: string, options: { target?: string }) => {
try {
const client = createDefaultTrpcClient();
const errand = await client.errand.get.query({ id });
await client.errand.merge.mutate({ id, target: options.target });
const target = options.target ?? errand.baseBranch;
console.log(`Merged ${errand.branch} into ${target}`);
} catch (error) {
const err = error as any;
const conflictFiles: string[] | undefined =
err?.data?.conflictFiles ?? err?.shape?.data?.conflictFiles;
if (conflictFiles) {
console.error(`Merge conflict in ${conflictFiles.length} file(s):`);
for (const f of conflictFiles) console.error(` ${f}`);
console.error(`Run: cw errand resolve ${id}`);
} else {
console.error((error as Error).message);
}
process.exit(1);
}
});
errandCommand
.command('resolve <id>')
.description('Print worktree path and conflicting files for manual resolution')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
const errand = await client.errand.get.query({ id });
if (errand.status !== 'conflict') {
console.error(`Errand ${id} is not in conflict (status: ${errand.status})`);
process.exit(1);
}
// projectPath is added to errand.get by Task 1; cast until type is updated
const projectPath = (errand as any).projectPath as string | null | undefined;
const worktreePath = projectPath
? `${projectPath}/.cw-worktrees/${id}`
: `.cw-worktrees/${id}`;
console.log(`Resolve conflicts in worktree: ${worktreePath}`);
console.log('Conflicting files:');
for (const f of errand.conflictFiles ?? []) {
console.log(` ${f}`);
}
console.log('After resolving: stage and commit changes in the worktree, then run:');
console.log(` cw errand merge ${id}`);
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
});
errandCommand
.command('abandon <id>')
.description('Stop agent, remove worktree and branch, keep DB record as abandoned')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
await client.errand.abandon.mutate({ id });
console.log(`Errand ${id} abandoned`);
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
});
errandCommand
.command('delete <id>')
.description('Stop agent, remove worktree, delete branch, and delete DB record')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
await client.errand.delete.mutate({ id });
console.log(`Errand ${id} deleted`);
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
});
return program; return program;
} }

View File

@@ -0,0 +1,161 @@
/**
* DrizzleErrandRepository Tests
*
* Tests for the Errand repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleErrandRepository } from './errand.js';
import { DrizzleProjectRepository } from './project.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
import type { Project } from '../../schema.js';
describe('DrizzleErrandRepository', () => {
let db: DrizzleDatabase;
let repo: DrizzleErrandRepository;
let projectRepo: DrizzleProjectRepository;
const createProject = async (): Promise<Project> => {
const suffix = Math.random().toString(36).slice(2, 8);
return projectRepo.create({
name: `test-project-${suffix}`,
url: `https://github.com/test/repo-${suffix}`,
});
};
beforeEach(() => {
db = createTestDatabase();
repo = new DrizzleErrandRepository(db);
projectRepo = new DrizzleProjectRepository(db);
});
describe('create', () => {
it('creates an errand with generated id and timestamps', async () => {
const project = await createProject();
const errand = await repo.create({
description: 'fix typo',
branch: 'cw/errand/fix-typo-abc12345',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
expect(errand.id).toBeDefined();
expect(errand.id.length).toBeGreaterThan(0);
expect(errand.description).toBe('fix typo');
expect(errand.branch).toBe('cw/errand/fix-typo-abc12345');
expect(errand.baseBranch).toBe('main');
expect(errand.agentId).toBeNull();
expect(errand.projectId).toBe(project.id);
expect(errand.status).toBe('active');
expect(errand.conflictFiles).toBeNull();
expect(errand.createdAt).toBeInstanceOf(Date);
expect(errand.updatedAt).toBeInstanceOf(Date);
});
});
describe('findById', () => {
it('returns null for non-existent errand', async () => {
const result = await repo.findById('does-not-exist');
expect(result).toBeNull();
});
it('returns errand with agentAlias null when no agent', async () => {
const project = await createProject();
const created = await repo.create({
description: 'test',
branch: 'cw/errand/test-xyz',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
const found = await repo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.agentAlias).toBeNull();
});
});
describe('findAll', () => {
it('returns empty array when no errands', async () => {
const results = await repo.findAll();
expect(results).toEqual([]);
});
it('filters by projectId', async () => {
const projectA = await createProject();
const projectB = await createProject();
await repo.create({ description: 'a', branch: 'cw/errand/a', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active' });
await repo.create({ description: 'b', branch: 'cw/errand/b', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active' });
const results = await repo.findAll({ projectId: projectA.id });
expect(results).toHaveLength(1);
expect(results[0].description).toBe('a');
});
});
describe('update', () => {
it('updates errand status', async () => {
const project = await createProject();
const created = await repo.create({
description: 'upd test',
branch: 'cw/errand/upd',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
const updated = await repo.update(created.id, { status: 'pending_review' });
expect(updated!.status).toBe('pending_review');
});
});
describe('conflictFiles column', () => {
it('stores and retrieves conflictFiles via update + findById', async () => {
const project = await createProject();
const created = await repo.create({
description: 'x',
branch: 'cw/errand/x',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
await repo.update(created.id, { status: 'conflict', conflictFiles: '["src/a.ts","src/b.ts"]' });
const found = await repo.findById(created.id);
expect(found!.conflictFiles).toBe('["src/a.ts","src/b.ts"]');
expect(found!.status).toBe('conflict');
});
it('returns null conflictFiles for non-conflict errands', async () => {
const project = await createProject();
const created = await repo.create({
description: 'y',
branch: 'cw/errand/y',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
const found = await repo.findById(created.id);
expect(found!.conflictFiles).toBeNull();
});
it('findAll includes conflictFiles in results', async () => {
const project = await createProject();
const created = await repo.create({
description: 'z',
branch: 'cw/errand/z',
baseBranch: 'main',
agentId: null,
projectId: project.id,
status: 'active',
});
await repo.update(created.id, { conflictFiles: '["x.ts"]' });
const all = await repo.findAll({ projectId: project.id });
expect(all[0].conflictFiles).toBe('["x.ts"]');
});
});
});

View File

@@ -35,6 +35,7 @@ vi.mock('../../git/manager.js', () => ({
vi.mock('../../git/project-clones.js', () => ({ vi.mock('../../git/project-clones.js', () => ({
ensureProjectClone: mockEnsureProjectClone, ensureProjectClone: mockEnsureProjectClone,
getProjectCloneDir: vi.fn().mockReturnValue('repos/test-project-abc123'),
})); }));
vi.mock('../../agent/file-io.js', async (importOriginal) => { vi.mock('../../agent/file-io.js', async (importOriginal) => {
@@ -393,7 +394,7 @@ describe('errand procedures', () => {
expect(result.id).toBe(errand.id); expect(result.id).toBe(errand.id);
expect(result).toHaveProperty('agentAlias'); expect(result).toHaveProperty('agentAlias');
expect(result.conflictFiles).toBeNull(); expect(result.conflictFiles).toEqual([]);
}); });
it('parses conflictFiles JSON when present', async () => { it('parses conflictFiles JSON when present', async () => {

View File

@@ -18,8 +18,9 @@ import {
} from './_helpers.js'; } from './_helpers.js';
import { writeErrandManifest } from '../../agent/file-io.js'; import { writeErrandManifest } from '../../agent/file-io.js';
import { buildErrandPrompt } from '../../agent/prompts/index.js'; import { buildErrandPrompt } from '../../agent/prompts/index.js';
import { join } from 'node:path';
import { SimpleGitWorktreeManager } from '../../git/manager.js'; import { SimpleGitWorktreeManager } from '../../git/manager.js';
import { ensureProjectClone } from '../../git/project-clones.js'; import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js';
import type { TRPCContext } from '../context.js'; import type { TRPCContext } from '../context.js';
// ErrandStatus values for input validation // ErrandStatus values for input validation
@@ -200,10 +201,27 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) {
if (!errand) { if (!errand) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' });
} }
return {
...errand, // Parse conflictFiles; return [] on null or malformed JSON
conflictFiles: errand.conflictFiles ? (JSON.parse(errand.conflictFiles) as string[]) : null, let conflictFiles: string[] = [];
}; if (errand.conflictFiles) {
try {
conflictFiles = JSON.parse(errand.conflictFiles) as string[];
} catch {
conflictFiles = [];
}
}
// Compute project clone path for cw errand resolve
let projectPath: string | null = null;
if (errand.projectId && ctx.workspaceRoot) {
const project = await requireProjectRepository(ctx).findById(errand.projectId);
if (project) {
projectPath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id));
}
}
return { ...errand, conflictFiles, projectPath };
}), }),
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -116,6 +116,19 @@ Uses **Commander.js** for command parsing.
All three commands output JSON for programmatic agent consumption. All three commands output JSON for programmatic agent consumption.
### Errand Sessions (`cw errand`)
| Command | Description |
|---------|-------------|
| `start <description> --project <id> [--base <branch>]` | Start a new errand session (description ≤200 chars) |
| `list [--project <id>] [--status <status>]` | List errands; status: active\|pending_review\|conflict\|merged\|abandoned |
| `chat <id> <message>` | Deliver a message to the running errand agent |
| `diff <id>` | Print unified git diff between base branch and errand branch |
| `complete <id>` | Mark errand as done and ready for review |
| `merge <id> [--target <branch>]` | Merge errand branch into target branch |
| `resolve <id>` | Print worktree path and conflicting files for manual resolution |
| `abandon <id>` | Stop agent, remove worktree and branch, keep DB record as abandoned |
| `delete <id>` | Stop agent, remove worktree, delete branch, and delete DB record |
### Accounts (`cw account`) ### Accounts (`cw account`)
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|

View File

@@ -278,7 +278,7 @@ Small isolated changes that spawn a dedicated agent in a git worktree. Errands a
|-----------|------|-------------| |-----------|------|-------------|
| `errand.create` | mutation | Create errand: `{description, projectId, baseBranch?}``{id, branch, agentId}`. Creates branch, worktree, DB record, spawns agent. | | `errand.create` | mutation | Create errand: `{description, projectId, baseBranch?}``{id, branch, agentId}`. Creates branch, worktree, DB record, spawns agent. |
| `errand.list` | query | List errands: `{projectId?, status?}` → ErrandWithAlias[] (ordered newest-first) | | `errand.list` | query | List errands: `{projectId?, status?}` → ErrandWithAlias[] (ordered newest-first) |
| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles` | | `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles: string[]` (never null) and `projectPath: string \| null` (computed from workspaceRoot) |
| `errand.diff` | query | Get branch diff: `{id}``{diff: string}` | | `errand.diff` | query | Get branch diff: `{id}``{diff: string}` |
| `errand.complete` | mutation | Mark active errand ready for review (stops agent): `{id}` → Errand | | `errand.complete` | mutation | Mark active errand ready for review (stops agent): `{id}` → Errand |
| `errand.merge` | mutation | Merge errand branch: `{id, target?}``{status: 'merged'}` or throws conflict | | `errand.merge` | mutation | Merge errand branch: `{id, target?}``{status: 'merged'}` or throws conflict |