diff --git a/build/mcp-servers/task/bd-client.mjs b/build/mcp-servers/task/bd-client.mjs index 93bff3a18e06..63b66ac9cec8 100644 --- a/build/mcp-servers/task/bd-client.mjs +++ b/build/mcp-servers/task/bd-client.mjs @@ -26,6 +26,10 @@ export function bd(args) { }) } +export async function bdAddComment(id, text) { + await bd(['comments', 'add', id, text]) +} + export async function bdJson(args) { const result = await bd([...args, '--json']) return JSON.parse(result) diff --git a/build/mcp-servers/task/notes.mjs b/build/mcp-servers/task/notes.mjs new file mode 100644 index 000000000000..ed0494645859 --- /dev/null +++ b/build/mcp-servers/task/notes.mjs @@ -0,0 +1,172 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {bdAddComment} from './bd-client.mjs' + +export const FINDING_PREFIX = 'FINDING:' +export const DECISION_PREFIX = 'KEY DECISION:' + +function normalizeEntry(entry) { + if (typeof entry !== 'string') return null + const normalized = entry.trim() + return normalized ? normalized : null +} + +function normalizePrefixedEntry(entry, prefix) { + const normalized = normalizeEntry(entry) + if (!normalized) return null + if (prefix && normalized.startsWith(prefix)) { + const stripped = normalized.slice(prefix.length).trim() + return stripped || null + } + return normalized +} + +function normalizeEntries(entries, prefix) { + if (!Array.isArray(entries)) return [] + const normalized = [] + for (const entry of entries) { + const value = normalizePrefixedEntry(entry, prefix) + if (value) normalized.push(value) + } + return normalized +} + +function mergeEntries(primary, secondary) { + const result = [] + const seen = new Set() + for (const entry of [...primary, ...secondary]) { + const normalized = normalizeEntry(entry) + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + result.push(normalized) + } + return result +} + +// Parse notes into structured sections (legacy JSON + text) for backward compatibility. +function parseNotesOnly(notes) { + const sections = {findings: [], decisions: []} + if (!notes) return sections + + const trimmed = notes.trim() + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) + sections.findings = normalizeEntries(parsed.findings, FINDING_PREFIX) + sections.decisions = normalizeEntries(parsed.decisions, DECISION_PREFIX) + if (parsed.pending_close) { + sections.pending_close = parsed.pending_close + } + return sections + } catch (e) { + // Fall through to text parsing + } + } + + for (const line of notes.split('\n')) { + const trimmedLine = line.trim() + if (trimmedLine.startsWith(FINDING_PREFIX)) { + const value = normalizePrefixedEntry(trimmedLine, FINDING_PREFIX) + if (value) sections.findings.push(value) + } else if (trimmedLine.startsWith(DECISION_PREFIX)) { + const value = normalizePrefixedEntry(trimmedLine, DECISION_PREFIX) + if (value) sections.decisions.push(value) + } + } + return sections +} + +function parseComments(comments) { + const sections = {findings: [], decisions: []} + if (!Array.isArray(comments)) return sections + + for (const comment of comments) { + const text = typeof comment?.text === 'string' ? comment.text.trim() : '' + if (!text) continue + if (text.startsWith(FINDING_PREFIX)) { + const value = normalizePrefixedEntry(text, FINDING_PREFIX) + if (value) sections.findings.push(value) + } else if (text.startsWith(DECISION_PREFIX)) { + const value = normalizePrefixedEntry(text, DECISION_PREFIX) + if (value) sections.decisions.push(value) + } + } + return sections +} + +// Build notes from comments + legacy notes for API compatibility. +export function parseNotes(notes, comments) { + const hasNotes = typeof notes === 'string' && notes.length > 0 + const notesSections = parseNotesOnly(notes) + const commentSections = parseComments(comments) + const findings = mergeEntries(commentSections.findings, notesSections.findings) + const decisions = mergeEntries(commentSections.decisions, notesSections.decisions) + const result = {findings, decisions} + if (notesSections.pending_close) result.pending_close = notesSections.pending_close + + if (findings.length > 0 || decisions.length > 0 || result.pending_close || hasNotes) { + return result + } + return null +} + +export function buildPendingNotes(pendingClose) { + if (!pendingClose) return '' + return JSON.stringify({pending_close: pendingClose}, null, 2) +} + +function filterNewEntries(entries, existingSet) { + const result = [] + if (!Array.isArray(entries)) return result + + for (const entry of entries) { + const normalized = normalizeEntry(entry) + if (!normalized || existingSet.has(normalized)) continue + existingSet.add(normalized) + result.push(normalized) + } + return result +} + +export async function addSectionComments(issueId, findings, decisions) { + for (const finding of findings) { + await bdAddComment(issueId, `${FINDING_PREFIX} ${finding}`) + } + for (const decision of decisions) { + await bdAddComment(issueId, `${DECISION_PREFIX} ${decision}`) + } +} + +export function prepareSectionUpdates(issue, incomingFindings, incomingDecisions) { + const notesSections = parseNotesOnly(issue.notes) + const commentSections = parseComments(issue['comments']) + + const existingFindings = mergeEntries(commentSections.findings, notesSections.findings) + const existingDecisions = mergeEntries(commentSections.decisions, notesSections.decisions) + + const normalizedFindings = normalizeEntries(incomingFindings, FINDING_PREFIX) + const normalizedDecisions = normalizeEntries(incomingDecisions, DECISION_PREFIX) + + const newFindings = filterNewEntries(normalizedFindings, new Set(existingFindings)) + const newDecisions = filterNewEntries(normalizedDecisions, new Set(existingDecisions)) + + const migrateFindings = filterNewEntries(notesSections.findings, new Set(commentSections.findings)) + const migrateDecisions = filterNewEntries(notesSections.decisions, new Set(commentSections.decisions)) + + const findingsToAdd = mergeEntries(migrateFindings, newFindings) + const decisionsToAdd = mergeEntries(migrateDecisions, newDecisions) + + const finalFindings = mergeEntries(existingFindings, newFindings) + const finalDecisions = mergeEntries(existingDecisions, newDecisions) + + const shouldStripNotes = notesSections.findings.length > 0 || notesSections.decisions.length > 0 + + return { + notesSections, + finalFindings, + finalDecisions, + findingsToAdd, + decisionsToAdd, + shouldStripNotes + } +} diff --git a/build/mcp-servers/task/task-mcp.mjs b/build/mcp-servers/task/task-mcp.mjs index a276a31e7bf7..3b397dc5baf5 100644 --- a/build/mcp-servers/task/task-mcp.mjs +++ b/build/mcp-servers/task/task-mcp.mjs @@ -3,47 +3,7 @@ import {createMcpServer} from '../shared/mcp-rpc.mjs' import {bd, bdJson, bdShowOne} from './bd-client.mjs' - -// Parse notes into structured sections -// Tries JSON format first, falls back to legacy text format for backward compatibility -function parseNotes(notes) { - if (!notes) return {findings: [], decisions: []} - - // Try JSON first - if (notes.trim().startsWith('{')) { - try { - const parsed = JSON.parse(notes) - const result = { - findings: parsed.findings || [], - decisions: parsed.decisions || [] - } - // Preserve pending_close if present - if (parsed.pending_close) { - result.pending_close = parsed.pending_close - } - return result - } catch (e) { - // Fall through to text parsing - } - } - - // Legacy text format (backward compat) - const sections = {findings: [], decisions: []} - for (const line of notes.split('\n')) { - if (line.startsWith('FINDING:')) { - sections.findings.push(line.replace('FINDING:', '').trim()) - } else if (line.startsWith('KEY DECISION:')) { - sections.decisions.push(line.replace('KEY DECISION:', '').trim()) - } - } - return sections -} - -// Build notes string from sections -// Stores as pretty-printed JSON for human readability -function buildNotes(sections) { - return JSON.stringify(sections, null, 2) -} +import {addSectionComments, buildPendingNotes, parseNotes, prepareSectionUpdates} from './notes.mjs' // Fetch ready children for an epic (used in task_status and task_epic resume) async function getReadyChildren(epicId) { @@ -174,9 +134,11 @@ const toolHandlers = { if (!issue) { return {error: `Issue ${args.id} not found`} } - // Parse notes if present - if (issue.notes) { - issue.notes = parseNotes(issue.notes) + const parsedNotes = parseNotes(issue.notes, issue['comments']) + if (parsedNotes) { + issue.notes = parsedNotes + } else { + delete issue.notes } return issue } @@ -251,8 +213,11 @@ const toolHandlers = { await bd(['update', args.resume, '--status', 'in_progress']) // Return full issue details (same as task_status(id)) to avoid redundant follow-up call - if (issue.notes) { - issue.notes = parseNotes(issue.notes) + const parsedNotes = parseNotes(issue.notes, issue['comments']) + if (parsedNotes) { + issue.notes = parsedNotes + } else { + delete issue.notes } issue.is_new = false @@ -282,20 +247,30 @@ const toolHandlers = { if (!issue) { return {error: `Issue ${args.id} not found`} } - const sections = parseNotes(issue.notes) - if (args.findings) sections.findings.push(...args.findings) - if (args.decisions) sections.decisions.push(...args.decisions) + const update = prepareSectionUpdates(issue, args.findings, args.decisions) + if (update.findingsToAdd.length > 0 || update.decisionsToAdd.length > 0) { + await addSectionComments(args.id, update.findingsToAdd, update.decisionsToAdd) + } - const newNotes = buildNotes(sections) - const updateArgs = ['update', args.id, '--notes', newNotes] + const updateArgs = ['update', args.id] if (args.status) updateArgs.push('--status', args.status) - await bd(updateArgs) + if (update.shouldStripNotes) { + updateArgs.push('--notes', buildPendingNotes(update.notesSections.pending_close)) + } + if (updateArgs.length > 2) { + await bd(updateArgs) + } + + const responseNotes = {findings: update.finalFindings, decisions: update.finalDecisions} + if (update.notesSections.pending_close) { + responseNotes.pending_close = update.notesSections.pending_close + } if (args.completed && needsReview(issue)) { return { success: true, - notes: sections, + notes: responseNotes, status: args.status || issue.status, askUser: { question: `Review completed work (${issue.type}/P${issue.priority ?? 2}). What next?`, @@ -310,7 +285,7 @@ const toolHandlers = { } } - return {success: true, notes: sections, status: args.status || issue.status} + return {success: true, notes: responseNotes, status: args.status || issue.status} }, task_decompose: async (args) => { @@ -388,15 +363,25 @@ const toolHandlers = { return {error: `Issue ${args.id} not found`} } - const sections = parseNotes(issue.notes) + const update = prepareSectionUpdates(issue, args.findings, args.decisions) + + const applyComments = async () => { + if (update.findingsToAdd.length > 0 || update.decisionsToAdd.length > 0) { + await addSectionComments(args.id, update.findingsToAdd, update.decisionsToAdd) + } + } + + const updateNotes = async (pendingClose) => { + const pendingCloseChanged = JSON.stringify(pendingClose ?? null) + !== JSON.stringify(update.notesSections.pending_close ?? null) + if (update.shouldStripNotes || pendingCloseChanged) { + await bd(['update', args.id, '--notes', buildPendingNotes(pendingClose)]) + } + } // Helper to perform actual close and return result const doClose = async (summary) => { - // Clean up pending_close if present - if (sections.pending_close) { - delete sections.pending_close - await bd(['update', args.id, '--notes', buildNotes(sections)]) - } + await updateNotes(null) await bd(['close', args.id, '--reason', summary]) @@ -428,16 +413,12 @@ const toolHandlers = { // If confirmed, retrieve stored pending_close data and close if (args.confirmed) { - const pending = sections.pending_close + const pending = update.notesSections.pending_close if (!pending) { return {error: 'No pending close found. Call task_done with summary first.'} } - // Merge any final findings/decisions - if (args.findings) sections.findings.push(...args.findings) - if (args.decisions) sections.decisions.push(...args.decisions) - await bd(['update', args.id, '--notes', buildNotes(sections)]) - + await applyComments() return await doClose(pending.summary) } @@ -447,11 +428,8 @@ const toolHandlers = { return {error: 'summary required for issues that need review'} } - // Store pending close data and any findings/decisions - if (args.findings) sections.findings.push(...args.findings) - if (args.decisions) sections.decisions.push(...args.decisions) - sections.pending_close = {summary: args.summary} - await bd(['update', args.id, '--notes', buildNotes(sections)]) + await applyComments() + await updateNotes({summary: args.summary}) return { askUser: { @@ -471,13 +449,7 @@ const toolHandlers = { return {error: 'summary required'} } - // Record final findings/decisions before closing - if (args.findings) sections.findings.push(...args.findings) - if (args.decisions) sections.decisions.push(...args.decisions) - if (args.findings || args.decisions) { - await bd(['update', args.id, '--notes', buildNotes(sections)]) - } - + await applyComments() return await doClose(args.summary) } } diff --git a/build/mcp-servers/task/task-mcp.test.mjs b/build/mcp-servers/task/task-mcp.test.mjs index d89d37337702..dde53d7628ca 100644 --- a/build/mcp-servers/task/task-mcp.test.mjs +++ b/build/mcp-servers/task/task-mcp.test.mjs @@ -6,10 +6,13 @@ import {execSync, spawn} from 'node:child_process' import {mkdtempSync, rmSync} from 'node:fs' import {tmpdir} from 'node:os' import {dirname, join} from 'node:path' +import process from 'node:process' import {fileURLToPath} from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) +process['env'].BD_NO_DAEMON = '1' + // MCP client for testing class McpTestClient { constructor(serverProcess) { @@ -308,6 +311,10 @@ describe('task MCP integration', {timeout: 30000}, () => { assert.ok(result.success) assert.deepEqual(result.notes.findings, ['Found pattern X']) assert.deepEqual(result.notes.decisions, ['Use approach Y']) + + const comments = JSON.parse(execSync(`bd comments ${epic.id} --json`, {cwd: testDir, encoding: 'utf-8'})) + assert.ok(comments.some(comment => comment.text === 'FINDING: Found pattern X')) + assert.ok(comments.some(comment => comment.text === 'KEY DECISION: Use approach Y')) }) })