diff --git a/ccw/src/core/data-aggregator.ts b/ccw/src/core/data-aggregator.ts index 9e1dbad9..6b4d49ed 100644 --- a/ccw/src/core/data-aggregator.ts +++ b/ccw/src/core/data-aggregator.ts @@ -77,6 +77,7 @@ interface DashboardData { liteTasks: { litePlan: unknown[]; liteFix: unknown[]; + multiCliPlan: unknown[]; }; reviewData: ReviewData | null; projectOverview: ProjectOverview | null; @@ -88,6 +89,7 @@ interface DashboardData { reviewFindings: number; litePlanCount: number; liteFixCount: number; + multiCliPlanCount: number; }; } @@ -211,7 +213,8 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s archivedSessions: [], liteTasks: { litePlan: [], - liteFix: [] + liteFix: [], + multiCliPlan: [] }, reviewData: null, projectOverview: null, @@ -222,7 +225,8 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s completedTasks: 0, reviewFindings: 0, litePlanCount: 0, - liteFixCount: 0 + liteFixCount: 0, + multiCliPlanCount: 0 } }; @@ -257,6 +261,7 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s data.liteTasks = liteTasks; data.statistics.litePlanCount = liteTasks.litePlan.length; data.statistics.liteFixCount = liteTasks.liteFix.length; + data.statistics.multiCliPlanCount = liteTasks.multiCliPlan.length; } catch (err) { console.error('Error scanning lite tasks:', (err as Error).message); } diff --git a/ccw/src/core/lite-scanner.ts b/ccw/src/core/lite-scanner.ts index 4c10379e..7fa4b35f 100644 --- a/ccw/src/core/lite-scanner.ts +++ b/ccw/src/core/lite-scanner.ts @@ -60,6 +60,43 @@ interface LiteSession { progress: Progress; } +// Multi-CLI specific session state from session-state.json +interface MultiCliSessionState { + session_id: string; + task_description: string; + status: string; + current_phase: number; + phases: Record; + ace_context?: { relevant_files: string[]; detected_patterns: string[] }; + user_decisions?: Array<{ round: number; decision: string; selected: string }>; + updated_at?: string; +} + +// Discussion topic structure for frontend rendering +interface DiscussionTopic { + title: string; + description: string; + scope: { included: string[]; excluded: string[] }; + keyQuestions: string[]; + status: string; + tags: string[]; +} + +// Extended session interface for multi-cli-plan +interface MultiCliSession extends LiteSession { + roundCount: number; + topicTitle: string; + status: string; + metadata: { + roundId: number; + timestamp: string; + currentPhase: number; + }; + discussionTopic: DiscussionTopic; + rounds: RoundSynthesis[]; + latestSynthesis: RoundSynthesis | null; +} + interface LiteTasks { litePlan: LiteSession[]; liteFix: LiteSession[]; @@ -145,12 +182,53 @@ async function scanLiteDir(dir: string, type: string): Promise { } } +/** + * Load session-state.json from multi-cli session directory + * @param sessionPath - Session directory path + * @returns Session state or null if not found + */ +async function loadSessionState(sessionPath: string): Promise { + const statePath = join(sessionPath, 'session-state.json'); + try { + const content = await readFile(statePath, 'utf8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Build discussion topic structure from session state and synthesis + * @param state - Session state from session-state.json + * @param synthesis - Latest round synthesis + * @returns Discussion topic for frontend rendering + */ +function buildDiscussionTopic( + state: MultiCliSessionState | null, + synthesis: RoundSynthesis | null +): DiscussionTopic { + const keyQuestions = synthesis?.clarification_questions || []; + const solutions = synthesis?.solutions || []; + + return { + title: state?.task_description || 'Discussion Topic', + description: solutions[0]?.summary || '', + scope: { + included: state?.ace_context?.relevant_files || [], + excluded: [], + }, + keyQuestions, + status: state?.status || 'analyzing', + tags: solutions.map((s) => s.name).slice(0, 3), + }; +} + /** * Scan multi-cli-plan directory for sessions * @param dir - Directory path to .multi-cli-plan - * @returns Array of multi-cli sessions + * @returns Array of multi-cli sessions with extended metadata */ -async function scanMultiCliDir(dir: string): Promise { +async function scanMultiCliDir(dir: string): Promise { try { const entries = await readdir(dir, { withFileTypes: true }); @@ -160,18 +238,27 @@ async function scanMultiCliDir(dir: string): Promise { .map(async (entry) => { const sessionPath = join(dir, entry.name); - const [createdAt, syntheses] = await Promise.all([ + const [createdAt, syntheses, sessionState] = await Promise.all([ getCreatedTime(sessionPath), loadRoundSyntheses(sessionPath), + loadSessionState(sessionPath), ]); - // Extract plan from latest synthesis if available + // Extract data from syntheses + const roundCount = syntheses.length; const latestSynthesis = syntheses.length > 0 ? syntheses[syntheses.length - 1] : null; // Calculate progress based on round count and convergence const progress = calculateMultiCliProgress(syntheses); - const session: LiteSession = { + // Build discussion topic for frontend + const discussionTopic = buildDiscussionTopic(sessionState, latestSynthesis); + + // Determine status from session state or synthesis convergence + const status = sessionState?.status || + (latestSynthesis?.convergence?.recommendation === 'converged' ? 'converged' : 'analyzing'); + + const session: MultiCliSession = { id: entry.name, type: 'multi-cli-plan', path: sessionPath, @@ -179,12 +266,24 @@ async function scanMultiCliDir(dir: string): Promise { plan: latestSynthesis, tasks: extractTasksFromSyntheses(syntheses), progress, + // Extended multi-cli specific fields + roundCount, + topicTitle: sessionState?.task_description || 'Discussion Topic', + status, + metadata: { + roundId: roundCount, + timestamp: sessionState?.updated_at || createdAt, + currentPhase: sessionState?.current_phase || 1, + }, + discussionTopic, + rounds: syntheses, + latestSynthesis, }; return session; }), )) - .filter((session): session is LiteSession => session !== null) + .filter((session): session is MultiCliSession => session !== null) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return sessions; diff --git a/ccw/src/templates/dashboard-css/02-session.css b/ccw/src/templates/dashboard-css/02-session.css index f3984522..77becf29 100644 --- a/ccw/src/templates/dashboard-css/02-session.css +++ b/ccw/src/templates/dashboard-css/02-session.css @@ -102,6 +102,87 @@ color: hsl(220 80% 40%); } +/* Session Status Badge (used in detail page header) */ +.session-status-badge { + font-size: 0.7rem; + font-weight: 500; + padding: 0.25rem 0.625rem; + border-radius: 0.25rem; + text-transform: lowercase; +} + +.session-status-badge.plan_generated, +.session-status-badge.converged, +.session-status-badge.completed, +.session-status-badge.decided { + background: hsl(var(--success-light, 142 70% 95%)); + color: hsl(var(--success, 142 70% 45%)); +} + +.session-status-badge.analyzing, +.session-status-badge.debating { + background: hsl(var(--warning-light, 45 90% 95%)); + color: hsl(var(--warning, 45 90% 40%)); +} + +.session-status-badge.initialized, +.session-status-badge.exploring { + background: hsl(var(--info-light, 220 80% 95%)); + color: hsl(var(--info, 220 80% 55%)); +} + +.session-status-badge.blocked, +.session-status-badge.conflict { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} + +.session-status-badge.pending { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +/* Status Badge Colors (used in card list meta) */ +.session-meta-item.status-badge.success { + background: hsl(var(--success-light, 142 70% 95%)); + color: hsl(var(--success, 142 70% 45%)); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 500; +} + +.session-meta-item.status-badge.warning { + background: hsl(var(--warning-light, 45 90% 95%)); + color: hsl(var(--warning, 45 90% 40%)); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 500; +} + +.session-meta-item.status-badge.info { + background: hsl(var(--info-light, 220 80% 95%)); + color: hsl(var(--info, 220 80% 55%)); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 500; +} + +.session-meta-item.status-badge.error { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 500; +} + +.session-meta-item.status-badge.default { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 500; +} + .session-body { display: flex; flex-direction: column; diff --git a/ccw/src/templates/dashboard-css/04-lite-tasks.css b/ccw/src/templates/dashboard-css/04-lite-tasks.css index 5a7cd810..0ba062f3 100644 --- a/ccw/src/templates/dashboard-css/04-lite-tasks.css +++ b/ccw/src/templates/dashboard-css/04-lite-tasks.css @@ -1368,6 +1368,43 @@ white-space: pre-wrap; } +/* Topic Meta Container */ +.topic-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; +} + +/* Tag Badge */ +.tag-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; +} + +/* Additional Multi-CLI Status Variants */ +.multi-cli-status.plan_generated { + background: hsl(var(--success-light, 142 70% 95%)); + color: hsl(var(--success, 142 70% 45%)); +} + +.multi-cli-status.initialized, +.multi-cli-status.exploring { + background: hsl(var(--info-light, 220 80% 95%)); + color: hsl(var(--info, 220 80% 55%)); +} + +.multi-cli-status.completed { + background: hsl(var(--success-light, 142 70% 95%)); + color: hsl(var(--success, 142 70% 45%)); +} + .multi-cli-complexity-badge { display: inline-flex; align-items: center; diff --git a/ccw/src/templates/dashboard-js/components/navigation.js b/ccw/src/templates/dashboard-js/components/navigation.js index bffcde11..61db3fab 100644 --- a/ccw/src/templates/dashboard-js/components/navigation.js +++ b/ccw/src/templates/dashboard-js/components/navigation.js @@ -321,10 +321,10 @@ function updateSidebarCounts(data) { if (archivedCount) archivedCount.textContent = data.archivedSessions?.length || 0; if (allCount) allCount.textContent = (data.activeSessions?.length || 0) + (data.archivedSessions?.length || 0); - // Update lite task counts - const litePlanCount = document.querySelector('.nav-item[data-lite="lite-plan"] .nav-count'); - const liteFixCount = document.querySelector('.nav-item[data-lite="lite-fix"] .nav-count'); - const multiCliPlanCount = document.querySelector('.nav-item[data-lite="multi-cli-plan"] .nav-count'); + // Update lite task counts (using ID selectors to match dashboard.html structure) + const litePlanCount = document.getElementById('badgeLitePlan'); + const liteFixCount = document.getElementById('badgeLiteFix'); + const multiCliPlanCount = document.getElementById('badgeMultiCliPlan'); if (litePlanCount) litePlanCount.textContent = data.liteTasks?.litePlan?.length || 0; if (liteFixCount) liteFixCount.textContent = data.liteTasks?.liteFix?.length || 0; diff --git a/ccw/src/templates/dashboard-js/views/home.js b/ccw/src/templates/dashboard-js/views/home.js index fc4786b7..780beadd 100644 --- a/ccw/src/templates/dashboard-js/views/home.js +++ b/ccw/src/templates/dashboard-js/views/home.js @@ -51,6 +51,7 @@ function updateBadges() { const liteTasks = workflowData.liteTasks || {}; document.getElementById('badgeLitePlan').textContent = liteTasks.litePlan?.length || 0; document.getElementById('badgeLiteFix').textContent = liteTasks.liteFix?.length || 0; + document.getElementById('badgeMultiCliPlan').textContent = liteTasks.multiCliPlan?.length || 0; // MCP badge - load async if needed if (typeof loadMcpConfig === 'function') { diff --git a/ccw/src/templates/dashboard-js/views/lite-tasks.js b/ccw/src/templates/dashboard-js/views/lite-tasks.js index 40dc52c0..01c4aff0 100644 --- a/ccw/src/templates/dashboard-js/views/lite-tasks.js +++ b/ccw/src/templates/dashboard-js/views/lite-tasks.js @@ -105,7 +105,10 @@ function renderMultiCliCard(session) { const statusColors = { 'decided': 'success', 'converged': 'success', + 'plan_generated': 'success', + 'completed': 'success', 'exploring': 'info', + 'initialized': 'info', 'analyzing': 'warning', 'debating': 'warning', 'blocked': 'error', @@ -147,6 +150,203 @@ function getI18nText(label) { return label[lang] || label.en || label.zh || ''; } +// ============================================ +// SYNTHESIS DATA TRANSFORMATION HELPERS +// ============================================ + +/** + * Extract files from synthesis solutions[].implementation_plan.tasks[].files + * Returns object with fileTree and impactSummary arrays + */ +function extractFilesFromSynthesis(synthesis) { + if (!synthesis || !synthesis.solutions) { + return { fileTree: [], impactSummary: [], dependencyGraph: [] }; + } + + const fileSet = new Set(); + const impactMap = new Map(); + + synthesis.solutions.forEach(solution => { + const tasks = solution.implementation_plan?.tasks || []; + tasks.forEach(task => { + const files = task.files || []; + files.forEach(filePath => { + fileSet.add(filePath); + // Build impact summary based on task context + if (!impactMap.has(filePath)) { + impactMap.set(filePath, { + filePath: filePath, + score: 'medium', + reasoning: { en: `Part of ${solution.title?.en || solution.id} implementation`, zh: `${solution.title?.zh || solution.id} 实现的一部分` } + }); + } + }); + }); + }); + + // Convert to fileTree format (flat list with file type) + const fileTree = Array.from(fileSet).map(path => ({ + path: path, + type: 'file', + modificationStatus: 'modified', + impactScore: 'medium' + })); + + return { + fileTree: fileTree, + impactSummary: Array.from(impactMap.values()), + dependencyGraph: [] + }; +} + +/** + * Extract planning data from synthesis solutions[].implementation_plan + * Builds planning object with functional requirements format + */ +function extractPlanningFromSynthesis(synthesis) { + if (!synthesis || !synthesis.solutions) { + return { functional: [], nonFunctional: [], acceptanceCriteria: [] }; + } + + const functional = []; + const acceptanceCriteria = []; + let reqCounter = 1; + let acCounter = 1; + + synthesis.solutions.forEach(solution => { + const plan = solution.implementation_plan; + if (!plan) return; + + // Extract approach as functional requirement + if (plan.approach) { + functional.push({ + id: `FR-${String(reqCounter++).padStart(3, '0')}`, + description: plan.approach, + priority: solution.feasibility?.score >= 0.8 ? 'high' : 'medium', + source: solution.id + }); + } + + // Extract tasks as acceptance criteria + const tasks = plan.tasks || []; + tasks.forEach(task => { + acceptanceCriteria.push({ + id: `AC-${String(acCounter++).padStart(3, '0')}`, + description: task.title || { en: task.id, zh: task.id }, + isMet: false + }); + }); + }); + + return { + functional: functional, + nonFunctional: [], + acceptanceCriteria: acceptanceCriteria + }; +} + +/** + * Extract decision data from synthesis solutions + * Sorts by feasibility score, returns highest as selected, rest as rejected + */ +function extractDecisionFromSynthesis(synthesis) { + if (!synthesis || !synthesis.solutions || synthesis.solutions.length === 0) { + return {}; + } + + // Sort solutions by feasibility score (highest first) + const sortedSolutions = [...synthesis.solutions].sort((a, b) => { + const scoreA = a.feasibility?.score || 0; + const scoreB = b.feasibility?.score || 0; + return scoreB - scoreA; + }); + + const selectedSolution = sortedSolutions[0]; + const rejectedAlternatives = sortedSolutions.slice(1).map(sol => ({ + ...sol, + rejectionReason: sol.cons?.length > 0 ? sol.cons[0] : { en: 'Lower feasibility score', zh: '可行性评分较低' } + })); + + // Calculate confidence from convergence level + let confidenceScore = 0.5; + if (synthesis.convergence) { + const level = synthesis.convergence.level; + if (level === 'high' || level === 'converged') confidenceScore = 0.9; + else if (level === 'medium') confidenceScore = 0.7; + else if (level === 'low') confidenceScore = 0.4; + } + + return { + status: synthesis.convergence?.recommendation || 'pending', + summary: synthesis.convergence?.summary || {}, + selectedSolution: selectedSolution, + rejectedAlternatives: rejectedAlternatives, + confidenceScore: confidenceScore + }; +} + +/** + * Extract timeline data from synthesis convergence and cross_verification + * Builds timeline array with events from discussion process + */ +function extractTimelineFromSynthesis(synthesis) { + if (!synthesis) { + return []; + } + + const timeline = []; + const now = new Date().toISOString(); + + // Add convergence summary as decision event + if (synthesis.convergence?.summary) { + timeline.push({ + type: 'decision', + timestamp: now, + summary: synthesis.convergence.summary, + contributor: { name: 'Synthesis', id: 'synthesis' }, + reversibility: synthesis.convergence.recommendation === 'proceed' ? 'irreversible' : 'reversible' + }); + } + + // Add cross-verification agreements as agreement events + const agreements = synthesis.cross_verification?.agreements || []; + agreements.forEach((agreement, idx) => { + timeline.push({ + type: 'agreement', + timestamp: now, + summary: typeof agreement === 'string' ? { en: agreement, zh: agreement } : agreement, + contributor: { name: 'Cross-Verification', id: 'cross-verify' }, + evidence: [] + }); + }); + + // Add cross-verification disagreements as disagreement events + const disagreements = synthesis.cross_verification?.disagreements || []; + disagreements.forEach((disagreement, idx) => { + timeline.push({ + type: 'disagreement', + timestamp: now, + summary: typeof disagreement === 'string' ? { en: disagreement, zh: disagreement } : disagreement, + contributor: { name: 'Cross-Verification', id: 'cross-verify' }, + evidence: [] + }); + }); + + // Add solutions as proposal events + const solutions = synthesis.solutions || []; + solutions.forEach(solution => { + timeline.push({ + type: 'proposal', + timestamp: now, + summary: solution.description || solution.title || {}, + contributor: { name: solution.id, id: solution.id }, + evidence: solution.pros?.map(p => ({ type: 'pro', description: p })) || [] + }); + }); + + return timeline; +} + /** * Show multi-cli detail page with tabs */ @@ -317,11 +517,11 @@ function renderMultiCliTopicTab(session) { // Title and Description sections.push(` -
-

${escapeHtml(title)}

- ${description ? `

${escapeHtml(description)}

` : ''} +
+

${escapeHtml(title)}

+ ${description ? `

${escapeHtml(description)}

` : ''}
- ${escapeHtml(status)} + ${escapeHtml(status)} ${tags.length ? tags.map(tag => `${escapeHtml(tag)}`).join('') : ''}
@@ -372,9 +572,10 @@ function renderMultiCliTopicTab(session) { * Shows: fileTree, impactSummary */ function renderMultiCliFilesTab(session) { - const relatedFiles = session.relatedFiles || session.latestSynthesis?.relatedFiles || {}; + // Use helper to extract files from synthesis data structure + const relatedFiles = extractFilesFromSynthesis(session.latestSynthesis); - if (!relatedFiles || Object.keys(relatedFiles).length === 0) { + if (!relatedFiles || (!relatedFiles.fileTree?.length && !relatedFiles.impactSummary?.length)) { return `
@@ -498,9 +699,10 @@ function renderFileTreeNodes(nodes, depth = 0) { * Shows: functional, nonFunctional requirements, acceptanceCriteria */ function renderMultiCliPlanningTab(session) { - const planning = session.planning || session.latestSynthesis?.planning || {}; + // Use helper to extract planning from synthesis data structure + const planning = extractPlanningFromSynthesis(session.latestSynthesis); - if (!planning || Object.keys(planning).length === 0) { + if (!planning || (!planning.functional?.length && !planning.acceptanceCriteria?.length)) { return `
@@ -596,9 +798,10 @@ function renderRequirementItem(req) { * Shows: selectedSolution, rejectedAlternatives, confidenceScore */ function renderMultiCliDecisionTab(session) { - const decision = session.decision || session.latestSynthesis?.decision || {}; + // Use helper to extract decision from synthesis data structure + const decision = extractDecisionFromSynthesis(session.latestSynthesis); - if (!decision || Object.keys(decision).length === 0) { + if (!decision || !decision.selectedSolution) { return `
@@ -708,10 +911,10 @@ function renderSolutionCard(solution, isSelected) { * Shows: decisionRecords.timeline */ function renderMultiCliTimelineTab(session) { - const decisionRecords = session.decisionRecords || session.latestSynthesis?.decisionRecords || {}; - const timeline = decisionRecords.timeline || []; + // Use helper to extract timeline from synthesis data structure + const timeline = extractTimelineFromSynthesis(session.latestSynthesis); - if (!timeline.length) { + if (!timeline || !timeline.length) { return `