diff --git a/build/mcp-servers/task/task-mcp.mjs b/build/mcp-servers/task/task-mcp.mjs index 3b397dc5baf5..01adc58be6f1 100644 --- a/build/mcp-servers/task/task-mcp.mjs +++ b/build/mcp-servers/task/task-mcp.mjs @@ -5,7 +5,7 @@ import {createMcpServer} from '../shared/mcp-rpc.mjs' import {bd, bdJson, bdShowOne} from './bd-client.mjs' import {addSectionComments, buildPendingNotes, parseNotes, prepareSectionUpdates} from './notes.mjs' -// Fetch ready children for an epic (used in task_status and task_epic resume) +// Fetch ready children for an epic (used in task_status and task_start resume) async function getReadyChildren(epicId) { const readyChildren = await bdJson(['ready', '--parent', epicId]) if (readyChildren.length > 0) { @@ -23,6 +23,12 @@ function needsReview(issue) { return priority <= 1 || issue.issue_type === 'bug' || issue.issue_type === 'feature' || issue.issue_type === 'epic' } +async function createEpic(title, description) { + const id = await bd(['create', '--title', title, '--type', 'epic', '--description', description, '--acceptance', 'PENDING', '--design', 'PENDING', '--silent']) + await bd(['update', id, '--status', 'in_progress']) + return id +} + const tools = [ { name: 'task_status', @@ -36,14 +42,13 @@ const tools = [ } }, { - name: 'task_epic', - description: 'Create or resume epic', + name: 'task_start', + description: 'Start task workflow (status + optional epic creation)', inputSchema: { type: 'object', properties: { - title: {type: 'string', description: 'Epic title'}, - description: {type: 'string', description: 'WHAT and WHY'}, - resume: {type: 'string', description: 'Resume by ID'} + id: {type: 'string', description: 'Issue ID for full details'}, + user_request: {type: 'string', description: 'User task description'} } } }, @@ -126,121 +131,114 @@ const tools = [ } ] -const toolHandlers = { - task_status: async (args) => { - // Specific issue query - return full details (replaces task_show) - if (args.id) { - const issue = await bdShowOne(args.id) - if (!issue) { - return {error: `Issue ${args.id} not found`} - } - const parsedNotes = parseNotes(issue.notes, issue['comments']) - if (parsedNotes) { - issue.notes = parsedNotes - } else { - delete issue.notes - } - return issue +async function handleTaskStatus(args, options = {}) { + const shouldResume = options.resume === true + // Specific issue query - return full details (replaces task_show) + if (args.id) { + const issue = await bdShowOne(args.id) + if (!issue) { + return {error: `Issue ${args.id} not found`} } - - // Overview query - selection-oriented responses - const inProgress = await bdJson(['list', '--status', 'in_progress']) - const ready = await bdJson(['ready', '--limit', '5']) - - // No in-progress issues - if (inProgress.length === 0) { - if (args.user_request) { - return {user_request: args.user_request} - } - if (ready.length > 0) { - return { - askUser: { - question: 'Which task would you like to work on?', - header: 'Task', - options: ready.map(r => ({label: r.title, description: r.id})), - multiSelect: false - } - } - } - return {empty: true} - } - - // Single in-progress, no new request - just continue - if (inProgress.length === 1 && !args.user_request) { - const issue = inProgress[0] - const result = {id: issue.id, title: issue.title, status: issue.status, type: issue.issue_type} - - // For epics, include ready children so Claude knows what to work on - if (issue.issue_type === 'epic') { - const readyChildren = await getReadyChildren(issue.id) - if (readyChildren) { - result.ready_children = readyChildren - } - } - return result - } - - // Multiple in-progress OR conflict with user_request - user must select - const questionText = args.user_request - ? 'New request - which task?' - : 'Which task to work on?' - - // Find parent epic - only if unambiguous (single epic context) - const parentIds = [...new Set(inProgress.map(i => i.parent).filter(Boolean))] - const epicIds = inProgress.filter(i => i.issue_type === 'epic').map(i => i.id) - const parentEpic = parentIds.length === 1 ? parentIds[0] - : (parentIds.length === 0 && epicIds.length === 1 ? epicIds[0] : null) - return { - askUser: { - question: questionText, - header: 'Task', - options: [ - ...inProgress.map(i => ({label: i.title, description: i.id})), - ...(parentEpic ? [{label: 'Create sub-task', description: `Under ${parentEpic}`}] : []), - {label: 'Start new task', description: 'Create a new epic'} - ], - multiSelect: false - } - } - }, - - task_epic: async (args) => { - if (args.resume) { - const issue = await bdShowOne(args.resume) - if (!issue) { - return {error: `Issue ${args.resume} not found`} - } - await bd(['update', args.resume, '--status', 'in_progress']) - - // Return full issue details (same as task_status(id)) to avoid redundant follow-up call - const parsedNotes = parseNotes(issue.notes, issue['comments']) - if (parsedNotes) { - issue.notes = parsedNotes - } else { - delete issue.notes - } + if (shouldResume) { + await bd(['update', args.id, '--status', 'in_progress']) issue.is_new = false - - // For epics, include ready children so Claude knows what to work on + issue.status = 'in_progress' if (issue.issue_type === 'epic') { - const readyChildren = await getReadyChildren(args.resume) + const readyChildren = await getReadyChildren(args.id) if (readyChildren) { issue.ready_children = readyChildren } } - - return issue } - - if (!args.title || !args.description) { - throw new Error('title and description required for new epic') + const parsedNotes = parseNotes(issue.notes, issue['comments']) + if (parsedNotes) { + issue.notes = parsedNotes + } else { + delete issue.notes } + return issue + } - const id = await bd(['create', '--title', args.title, '--type', 'epic', '--description', args.description, '--acceptance', 'PENDING', '--design', 'PENDING', '--silent']) - await bd(['update', id, '--status', 'in_progress']) + // Overview query - selection-oriented responses + const inProgress = await bdJson(['list', '--status', 'in_progress']) + const ready = await bdJson(['ready', '--limit', '5']) - return {id, is_new: true} - }, + // No in-progress issues + if (inProgress.length === 0) { + if (args.user_request) { + const title = args.user_request.trim() + if (title) { + const description = `USER REQUEST: ${args.user_request}` + const id = await createEpic(title, description) + const issue = await bdShowOne(id) + if (!issue) { + return {id, is_new: true} + } + const parsedNotes = parseNotes(issue.notes, issue['comments']) + if (parsedNotes) { + issue.notes = parsedNotes + } else { + delete issue.notes + } + issue.is_new = true + return issue + } + } + if (ready.length > 0) { + return { + askUser: { + question: 'Which task would you like to work on?', + header: 'Task', + options: ready.map(r => ({label: r.title, description: r.id})), + multiSelect: false + } + } + } + return {empty: true} + } + + // Single in-progress, no new request - just continue + if (inProgress.length === 1 && !args.user_request) { + const issue = inProgress[0] + const result = {id: issue.id, title: issue.title, status: issue.status, type: issue.issue_type} + + // For epics, include ready children so Claude knows what to work on + if (issue.issue_type === 'epic') { + const readyChildren = await getReadyChildren(issue.id) + if (readyChildren) { + result.ready_children = readyChildren + } + } + return result + } + + // Multiple in-progress OR conflict with user_request - user must select + const questionText = args.user_request + ? 'New request - which task?' + : 'Which task to work on?' + + // Find parent epic - only if unambiguous (single epic context) + const parentIds = [...new Set(inProgress.map(i => i.parent).filter(Boolean))] + const epicIds = inProgress.filter(i => i.issue_type === 'epic').map(i => i.id) + const parentEpic = parentIds.length === 1 ? parentIds[0] + : (parentIds.length === 0 && epicIds.length === 1 ? epicIds[0] : null) + return { + askUser: { + question: questionText, + header: 'Task', + options: [ + ...inProgress.map(i => ({label: i.title, description: i.id})), + ...(parentEpic ? [{label: 'Create sub-task', description: `Under ${parentEpic}`}] : []), + {label: 'Start new task', description: 'Create a new epic'} + ], + multiSelect: false + } + } +} + +const toolHandlers = { + task_status: handleTaskStatus, + task_start: (args) => handleTaskStatus(args, {resume: true}), task_progress: async (args) => { const issue = await bdShowOne(args.id) diff --git a/build/mcp-servers/task/task-mcp.test.mjs b/build/mcp-servers/task/task-mcp.test.mjs index dde53d7628ca..5fa85a969e07 100644 --- a/build/mcp-servers/task/task-mcp.test.mjs +++ b/build/mcp-servers/task/task-mcp.test.mjs @@ -146,45 +146,35 @@ describe('task MCP integration', {timeout: 30000}, () => { assert.deepEqual(result, {empty: true}) }) - it('returns user_request when provided and no in-progress issues', async () => { + it('creates epic when user_request provided and no in-progress issues', async () => { const result = await client.callTool('task_status', {user_request: 'test task'}) - assert.deepEqual(result, {user_request: 'test task'}) + assert.ok(result.id, 'should return id') + assert.ok(result.title === 'test task') + assert.ok(result.status === 'in_progress') + assert.ok(result.is_new === true) }) }) - describe('task_epic', () => { - it('creates new epic', async () => { - const result = await client.callTool('task_epic', { - title: 'Test Epic', - description: 'Test description' - }) - + describe('task_start', () => { + it('creates epic when user_request provided and no in-progress issues', async () => { + const result = await client.callTool('task_start', {user_request: 'start task'}) assert.ok(result.id, 'should return id') - assert.equal(result.is_new, true) + assert.ok(result.title === 'start task') + assert.ok(result.status === 'in_progress') + assert.ok(result.is_new === true) }) - it('resumes existing epic', async () => { - // Create epic first - const created = await client.callTool('task_epic', { - title: 'Resume Test', - description: 'Test' - }) + it('returns issue for explicit id', async () => { + const epic = await client.callTool('task_start', {user_request: 'Start by id'}) - // Close it via bd command - execSync(`bd close ${created.id} --reason "test"`, {cwd: testDir, stdio: 'pipe'}) - - // Resume it - const resumed = await client.callTool('task_epic', {resume: created.id}) - assert.equal(resumed.id, created.id) - assert.equal(resumed.is_new, false) + const result = await client.callTool('task_start', {id: epic.id}) + assert.ok(result.id === epic.id) + assert.ok(result.status === 'in_progress') + assert.ok(result.is_new === false) }) it('resumes epic with ready_children', async () => { - // Create epic with sub-issues - const epic = await client.callTool('task_epic', { - title: 'Resume with Children', - description: 'Test' - }) + const epic = await client.callTool('task_start', {user_request: 'Resume with Children'}) await client.callTool('task_decompose', { epic_id: epic.id, @@ -194,13 +184,11 @@ describe('task MCP integration', {timeout: 30000}, () => { ] }) - // Close epic execSync(`bd close ${epic.id} --reason "test"`, {cwd: testDir, stdio: 'pipe'}) - // Resume it - should include ready_children - const resumed = await client.callTool('task_epic', {resume: epic.id}) - assert.equal(resumed.id, epic.id) - assert.equal(resumed.is_new, false) + const resumed = await client.callTool('task_start', {id: epic.id}) + assert.ok(resumed.id === epic.id) + assert.ok(resumed.is_new === false) assert.ok(resumed.ready_children, 'should have ready_children') assert.equal(resumed.ready_children.length, 2) }) @@ -208,10 +196,7 @@ describe('task MCP integration', {timeout: 30000}, () => { describe('task_status with in-progress epic', () => { it('returns single in-progress issue', async () => { - const epic = await client.callTool('task_epic', { - title: 'In Progress Epic', - description: 'Test' - }) + const epic = await client.callTool('task_start', {user_request: 'In Progress Epic'}) const status = await client.callTool('task_status', {}) assert.equal(status.id, epic.id) @@ -219,10 +204,7 @@ describe('task MCP integration', {timeout: 30000}, () => { }) it('shows Create sub-task option for single epic', async () => { - await client.callTool('task_epic', { - title: 'Parent Epic', - description: 'Test' - }) + await client.callTool('task_start', {user_request: 'Parent Epic'}) const status = await client.callTool('task_status', {user_request: 'new task'}) assert.ok(status.askUser, 'should return askUser') @@ -233,10 +215,7 @@ describe('task MCP integration', {timeout: 30000}, () => { }) it('returns ready_children for epic with decomposed sub-issues', async () => { - const epic = await client.callTool('task_epic', { - title: 'Epic with children', - description: 'Test' - }) + const epic = await client.callTool('task_start', {user_request: 'Epic with children'}) await client.callTool('task_decompose', { epic_id: epic.id, @@ -255,10 +234,7 @@ describe('task MCP integration', {timeout: 30000}, () => { describe('task_decompose', () => { it('creates sub-issues under epic', async () => { - const epic = await client.callTool('task_epic', { - title: 'Decompose Test', - description: 'Epic to decompose' - }) + const epic = await client.callTool('task_start', {user_request: 'Decompose Test'}) const result = await client.callTool('task_decompose', { epic_id: epic.id, @@ -273,10 +249,7 @@ describe('task MCP integration', {timeout: 30000}, () => { }) it('can mark a child in progress on create', async () => { - const epic = await client.callTool('task_epic', { - title: 'Decompose Start Test', - description: 'Epic to decompose' - }) + const epic = await client.callTool('task_start', {user_request: 'Decompose Start Test'}) const result = await client.callTool('task_decompose', { epic_id: epic.id, @@ -297,10 +270,7 @@ describe('task MCP integration', {timeout: 30000}, () => { describe('task_progress', () => { it('adds findings and decisions', async () => { - const epic = await client.callTool('task_epic', { - title: 'Progress Test', - description: 'Test' - }) + const epic = await client.callTool('task_start', {user_request: 'Progress Test'}) const result = await client.callTool('task_progress', { id: epic.id, @@ -320,10 +290,7 @@ describe('task MCP integration', {timeout: 30000}, () => { describe('task_done', () => { it('closes epic with review prompt', async () => { - const epic = await client.callTool('task_epic', { - title: 'Close Test', - description: 'Test' - }) + const epic = await client.callTool('task_start', {user_request: 'Close Test'}) // First call should prompt for review (epics need review) const first = await client.callTool('task_done', { @@ -361,10 +328,7 @@ describe('task MCP integration', {timeout: 30000}, () => { describe('sub-task option ambiguity', () => { it('hides sub-task option when multiple epics in progress', async () => { // Create first epic - await client.callTool('task_epic', { - title: 'Epic 1', - description: 'First epic' - }) + await client.callTool('task_start', {user_request: 'Epic 1'}) // Create second epic (also becomes in_progress) const epic2Id = execSync(