IJPL-224042 store findings/decisions in comments

GitOrigin-RevId: 461ac0a97f6b312c67ab6693d3e74a08c168b477
This commit is contained in:
Vladimir Krivosheev
2026-01-15 20:43:22 +00:00
committed by intellij-monorepo-bot
parent 082afa9b94
commit 1345781da8
4 changed files with 233 additions and 78 deletions
+4
View File
@@ -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)
+172
View File
@@ -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
}
}
+50 -78
View File
@@ -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)
}
}
+7
View File
@@ -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'))
})
})