diff --git a/src/core/index.ts b/src/core/index.ts index b0da6f0..d60669a 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -15,6 +15,11 @@ export interface IndexEntry { role?: string; assignedTo?: string; tags?: string; + // Handoff-specific routing fields + fromRole?: string; + toRole?: string; + fromAgent?: string; + toAgent?: string; } export class Index { @@ -36,10 +41,24 @@ export class Index { status TEXT, role TEXT, assignedTo TEXT, - tags TEXT + tags TEXT, + fromRole TEXT, + toRole TEXT, + fromAgent TEXT, + toAgent TEXT ); CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(id, title, content); `); + + // Migration: add handoff-routing columns to existing DBs that pre-date this schema. + const existingCols = new Set( + (this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name), + ); + for (const col of ['fromRole', 'toRole', 'fromAgent', 'toAgent']) { + if (!existingCols.has(col)) { + this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`); + } + } } upsert(entry: IndexEntry): void { @@ -48,16 +67,21 @@ export class Index { role: null, assignedTo: null, tags: null, + fromRole: null, + toRole: null, + fromAgent: null, + toAgent: null, ...entry, }; const insert = this.db.prepare(` - INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags) - VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags) + INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent) + VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent) ON CONFLICT(id) DO UPDATE SET type=@type, title=@title, content=@content, filePath=@filePath, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, - assignedTo=@assignedTo, tags=@tags + assignedTo=@assignedTo, tags=@tags, + fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent `); insert.run(params); diff --git a/src/core/services/handoffService.ts b/src/core/services/handoffService.ts index 1c8a641..8eb8c86 100644 --- a/src/core/services/handoffService.ts +++ b/src/core/services/handoffService.ts @@ -34,6 +34,10 @@ export function createHandoff(cwd: string, options: Partial = {}): Hand filePath, createdAt: handoff.createdAt, updatedAt: handoff.createdAt, + fromRole: handoff.fromRole, + toRole: handoff.toRole, + fromAgent: handoff.fromAgent, + toAgent: handoff.toAgent, }); index.close(); diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts index 7be8f8d..38ad63a 100644 --- a/src/core/services/taskService.ts +++ b/src/core/services/taskService.ts @@ -63,6 +63,18 @@ export function doneTask(cwd: string, id: string): Task { return updateTask(cwd, id, { status: 'done' }); } +export function reviewTask(cwd: string, id: string): Task { + return updateTask(cwd, id, { status: 'review' }); +} + +export function cancelTask(cwd: string, id: string): Task { + return updateTask(cwd, id, { status: 'cancelled' }); +} + +export function reopenTask(cwd: string, id: string): Task { + return updateTask(cwd, id, { status: 'open' }); +} + function toIndexEntry(task: Task, filePath: string) { return { id: task.id, diff --git a/src/server/board.ts b/src/server/board.ts index 9ae1e07..dc2a9a0 100644 --- a/src/server/board.ts +++ b/src/server/board.ts @@ -153,6 +153,8 @@ export function renderBoardHtml(): string { .row:first-of-type { border-top: 0; } .row .id { color: var(--muted); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; } .row .what { flex: 1; } + .row .who-cell { font-size: 12px; white-space: nowrap; display: flex; align-items: baseline; gap: 4px; } + .row .who { display: flex; align-items: baseline; gap: 4px; color: var(--accent); font-size: 11px; white-space: nowrap; } .row .when { color: var(--muted); font-size: 11px; white-space: nowrap; } @@ -230,6 +232,14 @@ ${columnSkeleton()} : '
'; }); } + function handoffRoute(h) { + // Build "fromRole[@fromAgent] → toRole[@toAgent]" label + var from = esc(h.fromRole || '?'); + var to = esc(h.toRole || '?'); + if (h.fromAgent) from += '@' + esc(h.fromAgent) + ''; + if (h.toAgent) to += '@' + esc(h.toAgent) + ''; + return '' + from + ' → ' + to + ''; + } function renderHandoffs(items) { var el = document.getElementById('handoffs'); if (!items || !items.length) { el.innerHTML = '
none
'; return; } @@ -237,6 +247,7 @@ ${columnSkeleton()} return '
' + '' + esc(h.id) + '' + '' + esc(h.title) + '' + + '' + handoffRoute(h) + '' + '' + esc(ago(h.createdAt)) + '' + '
'; }).join(''); diff --git a/src/server/routes.ts b/src/server/routes.ts index b8b98dc..0b31d54 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,5 +1,5 @@ import { FastifyInstance, FastifyReply } from 'fastify'; -import { createTask, listTasks, getTask, claimTask, doneTask } from '../core/services/taskService.js'; +import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask } from '../core/services/taskService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; @@ -53,13 +53,22 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise app.patch('/tasks/:id', async (request, reply) => { const { id } = request.params as { id: string }; const patch = request.body as Partial; - if (patch.status === 'in_progress' && patch.assignedTo) { - return claimTask(cwd, id, patch.assignedTo); + + switch (patch.status) { + case 'in_progress': + if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)'); + return claimTask(cwd, id, patch.assignedTo); + case 'done': + return doneTask(cwd, id); + case 'review': + return reviewTask(cwd, id); + case 'cancelled': + return cancelTask(cwd, id); + case 'open': + return reopenTask(cwd, id); + default: + return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled'); } - if (patch.status === 'done') { - return doneTask(cwd, id); - } - return badRequest(reply, 'Unsupported patch'); }); app.get('/handoffs', async () => listHandoffs(cwd)); diff --git a/tests/handoffService.test.ts b/tests/handoffService.test.ts index 24706cd..4eb1b5a 100644 --- a/tests/handoffService.test.ts +++ b/tests/handoffService.test.ts @@ -16,4 +16,34 @@ describe('handoffService', () => { expect(getHandoff(cwd, h.id).handoff.summary).toBe('s'); expect(listHandoffs(cwd)).toHaveLength(1); }); + + it('listHandoffs carries fromRole and toRole in the index entry', () => { + createHandoff(cwd, { + fromRole: 'architect', + toRole: 'implementer', + summary: 'Hand over design', + context: 'done', + }); + const items = listHandoffs(cwd); + expect(items).toHaveLength(1); + expect(items[0].fromRole).toBe('architect'); + expect(items[0].toRole).toBe('implementer'); + // agent names default to undefined when not supplied + expect(items[0].fromAgent == null).toBe(true); + expect(items[0].toAgent == null).toBe(true); + }); + + it('listHandoffs carries fromAgent and toAgent when supplied', () => { + createHandoff(cwd, { + fromRole: 'reviewer', + toRole: 'implementer', + fromAgent: 'claude', + toAgent: 'codex', + summary: 'Review complete', + context: 'lgtm', + }); + const items = listHandoffs(cwd); + expect(items[0].fromAgent).toBe('claude'); + expect(items[0].toAgent).toBe('codex'); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 634294e..a567190 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -47,6 +47,48 @@ describe('server routes', () => { expect(res.statusCode).toBe(400); }); + it('returns 400 when claiming without assignedTo', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); + expect(res.statusCode).toBe(400); + }); + + it('PATCH /tasks/:id → review', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).status).toBe('review'); + }); + + it('PATCH /tasks/:id → cancelled', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).status).toBe('cancelled'); + }); + + it('PATCH /tasks/:id → open (reopen)', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + // First cancel it, then reopen + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } }); + const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).status).toBe('open'); + }); + + it('review and cancelled tasks appear in GET /tasks list', async () => { + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); + await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); + await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } }); + + const allRes = await app.inject({ method: 'GET', url: '/tasks' }); + const all = JSON.parse(allRes.payload) as Array<{ status: string }>; + const statuses = all.map((t) => t.status); + expect(statuses).toContain('review'); + expect(statuses).toContain('cancelled'); + }); + it('updates status via POST /status/update', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); const res = await app.inject({ method: 'POST', url: '/status/update' }); @@ -78,4 +120,37 @@ describe('server routes', () => { expect(html).toContain("getJSON('/tasks')"); expect(html).toContain('setInterval(refresh'); }); + + it('board HTML contains the who-arrow rendering logic', async () => { + const res = await app.inject({ method: 'GET', url: '/board' }); + const html = res.payload; + // The handoffRoute helper and the → arrow must be present + expect(html).toContain('handoffRoute'); + expect(html).toContain('→'); + // The who-cell class must be used in renderHandoffs + expect(html).toContain('who-cell'); + }); + + it('GET /handoffs returns fromRole and toRole fields', async () => { + await app.inject({ + method: 'POST', + url: '/handoffs', + payload: { + fromRole: 'architect', + toRole: 'implementer', + fromAgent: 'claude', + toAgent: 'codex', + summary: 'Design done', + context: 'See decisions', + }, + }); + const res = await app.inject({ method: 'GET', url: '/handoffs' }); + expect(res.statusCode).toBe(200); + const items = JSON.parse(res.payload) as Array>; + expect(items).toHaveLength(1); + expect(items[0].fromRole).toBe('architect'); + expect(items[0].toRole).toBe('implementer'); + expect(items[0].fromAgent).toBe('claude'); + expect(items[0].toAgent).toBe('codex'); + }); }); diff --git a/tests/taskService.test.ts b/tests/taskService.test.ts index fd13673..714c9ac 100644 --- a/tests/taskService.test.ts +++ b/tests/taskService.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { createTask, listTasks, getTask, claimTask, doneTask } from '../src/core/services/taskService.js'; +import { + createTask, listTasks, getTask, + claimTask, doneTask, reviewTask, cancelTask, reopenTask, +} from '../src/core/services/taskService.js'; describe('taskService', () => { let cwd: string; @@ -35,4 +38,38 @@ describe('taskService', () => { const done = doneTask(cwd, task.id); expect(done.status).toBe('done'); }); + + it('transitions a task to review', () => { + const task = createTask(cwd, { title: 'C', role: 'implementer' }); + const inReview = reviewTask(cwd, task.id); + expect(inReview.status).toBe('review'); + // Verify index is updated + const listed = listTasks(cwd, { status: 'review' }); + expect(listed).toHaveLength(1); + expect(listed[0].id).toBe(task.id); + }); + + it('cancels a task', () => { + const task = createTask(cwd, { title: 'D', role: 'implementer' }); + const cancelled = cancelTask(cwd, task.id); + expect(cancelled.status).toBe('cancelled'); + const listed = listTasks(cwd, { status: 'cancelled' }); + expect(listed).toHaveLength(1); + }); + + it('reopens a task (any status → open)', () => { + const task = createTask(cwd, { title: 'E', role: 'implementer' }); + cancelTask(cwd, task.id); + const reopened = reopenTask(cwd, task.id); + expect(reopened.status).toBe('open'); + const listed = listTasks(cwd, { status: 'open' }); + expect(listed).toHaveLength(1); + }); + + it('getTask reads back the correct task', () => { + const task = createTask(cwd, { title: 'F', role: 'architect' }); + const { task: read } = getTask(cwd, task.id); + expect(read.title).toBe('F'); + expect(read.role).toBe('architect'); + }); });