diff --git a/src/domain/tasks.ts b/src/domain/tasks.ts index 150ee66..366839d 100644 --- a/src/domain/tasks.ts +++ b/src/domain/tasks.ts @@ -426,6 +426,47 @@ export class TaskService { }); } + // Like `claim` but never advances the stage. For pipelines whose first + // stage is itself a do-work stage (read-context, pull-from-queue, etc.) + // where assignment and "begin spec" are distinct actions. + assign(taskId: number, assigneeName: string): Task { + validateAssignee(assigneeName); + + return this.db.transaction(() => { + const task = this.requireTask(taskId); + if (task.status !== 'pending') { + throw new ConflictError(`Task ${taskId} is not pending (status: ${task.status}).`); + } + + const gate = this.getGateConfig(task.project ?? undefined); + const minConfidence = gate?.min_confidence_for_claim; + if (typeof minConfidence === 'number' && minConfidence > 0) { + const { score, reasons } = scoreTaskConfidence({ + title: task.title, + description: task.description, + }); + if (score < minConfidence) { + const detail = reasons.length ? ` Issues: ${reasons.join('; ')}` : ''; + throw new ValidationError( + `Task ${taskId} confidence ${score}/100 below required ${minConfidence}.${detail}`, + ); + } + } + + // Stage stays put; status flips to in_progress because an agent now + // actively owns the task. Status no longer follows stage strictly here, + // but a "pending + assigned_to set" state is incoherent for queue + // semantics (next-task picker would otherwise hand it out again). + this.db.run( + `UPDATE tasks SET status = 'in_progress', assigned_to = ?, updated_at = datetime('now') WHERE id = ?`, + [assigneeName, taskId], + ); + const assigned = this.getById(taskId)!; + this.events.emit('task:claimed', { task: assigned, claimer: assigneeName }); + return assigned; + }); + } + // ---- Learnings ---- learn( diff --git a/src/transport/mcp-handlers.ts b/src/transport/mcp-handlers.ts index 87121fc..44bc53b 100644 --- a/src/transport/mcp-handlers.ts +++ b/src/transport/mcp-handlers.ts @@ -300,7 +300,7 @@ export function handleStage( ): unknown { const action = validateEnum( optString(args, 'action'), - ['claim', 'advance', 'regress', 'complete', 'fail', 'cancel'] as const, + ['claim', 'assign', 'advance', 'regress', 'complete', 'fail', 'cancel'] as const, 'action', ); const taskId = requireNumber(args, 'task_id'); @@ -311,6 +311,12 @@ export function handleStage( return withStageInstructions(ctx, claimed); } + if (action === 'assign') { + const assignee = optString(args, 'claimer') ?? sessionName(session); + const assigned = ctx.tasks.assign(taskId, assignee); + return withStageInstructions(ctx, assigned); + } + if (action === 'advance') { const advanceComment = optString(args, 'comment'); const advanced = ctx.tasks.advance(taskId, optString(args, 'stage'), advanceComment); diff --git a/src/transport/mcp.ts b/src/transport/mcp.ts index 0faaca5..35dc9a3 100644 --- a/src/transport/mcp.ts +++ b/src/transport/mcp.ts @@ -163,13 +163,13 @@ export const tools: ToolDefinition[] = [ { name: 'task_stage', description: - 'Move a task through its lifecycle. Actions: "claim" → assign to you and advance from backlog to spec, "advance" → next stage (or jump to a specific one), "regress" → earlier stage (requires reason), "complete" → marks done with result, "fail" → marks failed with error, "cancel" → cancels with reason.', + 'Move a task through its lifecycle. Actions: "claim" → assign to you and advance from backlog to spec, "assign" → assign to you WITHOUT advancing the stage (use for custom pipelines where the first stage is itself a do-work stage), "advance" → next stage (or jump to a specific one), "regress" → earlier stage (requires reason), "complete" → marks done with result, "fail" → marks failed with error, "cancel" → cancels with reason.', inputSchema: { type: 'object', properties: { action: { type: 'string', - enum: ['claim', 'advance', 'regress', 'complete', 'fail', 'cancel'], + enum: ['claim', 'assign', 'advance', 'regress', 'complete', 'fail', 'cancel'], description: 'Lifecycle action to perform', }, task_id: { type: 'number', description: 'Task ID' }, diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 5e547dd..8c2e1c0 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -269,6 +269,46 @@ describe('task_stage claim', () => { }); }); +// ============================================================================= +// task_stage: assign (claim minus stage advance) +// ============================================================================= + +describe('task_stage assign', () => { + it('assigns without advancing the stage', () => { + const task = handle('task_create', { title: 'Assign me' }) as { + id: number; + stage: string; + }; + expect(task.stage).toBe('backlog'); + const assigned = handle('task_stage', { action: 'assign', task_id: task.id }) as { + stage: string; + status: string; + assigned_to: string; + }; + expect(assigned.stage).toBe('backlog'); + expect(assigned.assigned_to).toBe('test-agent'); + expect(assigned.status).toBe('in_progress'); + }); + + it('uses custom assignee name via claimer arg', () => { + const task = handle('task_create', { title: 'Assign me' }) as { id: number }; + const assigned = handle('task_stage', { + action: 'assign', + task_id: task.id, + claimer: 'head-influencer', + }) as { assigned_to: string }; + expect(assigned.assigned_to).toBe('head-influencer'); + }); + + it('rejects assign on already-claimed task', () => { + const task = handle('task_create', { title: 'Locked' }) as { id: number }; + handle('task_stage', { action: 'claim', task_id: task.id }); + expect(() => + handle('task_stage', { action: 'assign', task_id: task.id, claimer: 'other' }), + ).toThrow('not pending'); + }); +}); + // ============================================================================= // task_stage: advance // ============================================================================= diff --git a/tests/tasks.test.ts b/tests/tasks.test.ts index fcf6908..09bc326 100644 --- a/tests/tasks.test.ts +++ b/tests/tasks.test.ts @@ -129,6 +129,54 @@ describe('claiming', () => { }); }); +describe('assigning (no stage advance)', () => { + it('assigns a task without advancing the stage', () => { + const task = ctx.tasks.create({ title: 'Assign me' }, 'agent-1'); + expect(task.stage).toBe('backlog'); + const assigned = ctx.tasks.assign(task.id, 'agent-2'); + expect(assigned.assigned_to).toBe('agent-2'); + expect(assigned.stage).toBe('backlog'); + expect(assigned.status).toBe('in_progress'); + }); + + it('respects custom pipelines (stays at the first stage)', () => { + ctx.tasks.setPipelineConfig('proj', ['I-0-pull', 'I-1-plan', 'I-2-exec', 'I-3-closeout']); + const task = ctx.tasks.create({ title: 'Custom' }, 'agent-1'); + ctx.tasks.update(task.id, { project: 'proj' }); + // re-create at first stage of custom pipeline + const wave = ctx.tasks.create({ title: 'Wave', project: 'proj', stage: 'I-0-pull' }, 'agent-1'); + const assigned = ctx.tasks.assign(wave.id, 'head-influencer'); + expect(assigned.stage).toBe('I-0-pull'); + expect(assigned.assigned_to).toBe('head-influencer'); + expect(assigned.status).toBe('in_progress'); + }); + + it('rejects assigning non-pending task', () => { + const task = ctx.tasks.create({ title: 'Already claimed' }, 'agent-1'); + ctx.tasks.claim(task.id, 'agent-1'); + expect(() => ctx.tasks.assign(task.id, 'agent-2')).toThrow('not pending'); + }); + + it('emits task:claimed event (same channel as claim)', () => { + const task = ctx.tasks.create({ title: 'Listen' }, 'agent-1'); + let captured: { task: { id: number }; claimer: string } | null = null; + ctx.events.on('task:claimed', (e) => { + captured = e.data as { task: { id: number }; claimer: string }; + }); + ctx.tasks.assign(task.id, 'agent-2'); + expect(captured).not.toBeNull(); + expect(captured!.claimer).toBe('agent-2'); + expect(captured!.task.id).toBe(task.id); + }); + + it('respects min_confidence_for_claim gate (parity with claim)', () => { + ctx.tasks.setPipelineConfig('strict', ['I-0-pull', 'I-1-plan', 'done']); + ctx.tasks.setGateConfig('strict', { min_confidence_for_claim: 80 }); + const vague = ctx.tasks.create({ title: 'x', project: 'strict', stage: 'I-0-pull' }, 'agent-1'); + expect(() => ctx.tasks.assign(vague.id, 'agent-2')).toThrow('confidence'); + }); +}); + describe('advancement', () => { it('advances through stages sequentially', () => { const task = ctx.tasks.create({ title: 'Flow' }, 'agent-1');