From 67e78b132c47aea9474aebce5be23bb906c1ca9e Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sat, 28 Feb 2026 11:40:28 +0800 Subject: [PATCH] feat: add workflow-wave-plan skill and A2UI debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CSV Wave planning and execution skill (explore via wave → conflict resolution → execution CSV with linked exploration context) - Add debug NDJSON logging for A2UI submit-all and multi-answer polling --- .claude/skills/workflow-wave-plan/SKILL.md | 774 +++++++++++++++++++++ ccw/src/core/a2ui/A2UIWebSocketHandler.ts | 125 ++-- ccw/src/tools/ask-question.ts | 75 ++ 3 files changed, 920 insertions(+), 54 deletions(-) create mode 100644 .claude/skills/workflow-wave-plan/SKILL.md diff --git a/.claude/skills/workflow-wave-plan/SKILL.md b/.claude/skills/workflow-wave-plan/SKILL.md new file mode 100644 index 00000000..09b32d75 --- /dev/null +++ b/.claude/skills/workflow-wave-plan/SKILL.md @@ -0,0 +1,774 @@ +--- +name: workflow-wave-plan +description: CSV Wave planning and execution - explore via wave, resolve conflicts, execute from CSV with linked exploration context. Triggers on "workflow:wave-plan". +argument-hint: " [--yes|-y] [--concurrency|-c N]" +allowed-tools: Task, AskUserQuestion, Read, Write, Edit, Bash, Glob, Grep +--- + +# Workflow Wave Plan + +CSV Wave-based planning and execution. Uses structured CSV state for both exploration and execution, with cross-phase context propagation via `context_from` linking. + +## Architecture + +``` +Requirement + ↓ +┌─ Phase 1: Decompose ─────────────────────┐ +│ Analyze requirement → explore.csv │ +│ (1 row per exploration angle) │ +└────────────────────┬──────────────────────┘ + ↓ +┌─ Phase 2: Wave Explore ──────────────────┐ +│ Wave loop: spawn Explore agents │ +│ → findings/key_files → explore.csv │ +└────────────────────┬──────────────────────┘ + ↓ +┌─ Phase 3: Synthesize & Plan ─────────────┐ +│ Read explore findings → cross-reference │ +│ → resolve conflicts → tasks.csv │ +│ (context_from links to E* explore rows) │ +└────────────────────┬──────────────────────┘ + ↓ +┌─ Phase 4: Wave Execute ──────────────────┐ +│ Wave loop: build prev_context from CSV │ +│ → spawn code-developer agents per wave │ +│ → results → tasks.csv │ +└────────────────────┬──────────────────────┘ + ↓ +┌─ Phase 5: Aggregate ─────────────────────┐ +│ results.csv + context.md + summary │ +└───────────────────────────────────────────┘ +``` + +## Context Flow + +``` +explore.csv tasks.csv +┌──────────┐ ┌──────────┐ +│ E1: arch │──────────→│ T1: setup│ context_from: E1;E2 +│ findings │ │ prev_ctx │← E1+E2 findings +├──────────┤ ├──────────┤ +│ E2: deps │──────────→│ T2: impl │ context_from: E1;T1 +│ findings │ │ prev_ctx │← E1+T1 findings +├──────────┤ ├──────────┤ +│ E3: test │──┐ ┌───→│ T3: test │ context_from: E3;T2 +│ findings │ └───┘ │ prev_ctx │← E3+T2 findings +└──────────┘ └──────────┘ + +Two context channels: +1. Directed: context_from → prev_context (from CSV findings) +2. Broadcast: discoveries.ndjson (append-only shared board) +``` + +--- + +## CSV Schemas + +### explore.csv + +| Column | Type | Set By | Description | +|--------|------|--------|-------------| +| `id` | string | Decomposer | E1, E2, ... | +| `angle` | string | Decomposer | Exploration angle name | +| `description` | string | Decomposer | What to explore from this angle | +| `focus` | string | Decomposer | Keywords and focus areas | +| `deps` | string | Decomposer | Semicolon-separated dep IDs (usually empty) | +| `wave` | integer | Wave Engine | Wave number (usually 1) | +| `status` | enum | Agent | pending / completed / failed | +| `findings` | string | Agent | Discoveries (max 800 chars) | +| `key_files` | string | Agent | Relevant files (semicolon-separated) | +| `error` | string | Agent | Error message if failed | + +### tasks.csv + +| Column | Type | Set By | Description | +|--------|------|--------|-------------| +| `id` | string | Planner | T1, T2, ... | +| `title` | string | Planner | Task title | +| `description` | string | Planner | Self-contained task description | +| `deps` | string | Planner | Dependency task IDs: T1;T2 | +| `context_from` | string | Planner | Context source IDs: **E1;E2;T1** | +| `wave` | integer | Wave Engine | Wave number (computed from deps) | +| `status` | enum | Agent | pending / completed / failed / skipped | +| `findings` | string | Agent | Execution findings (max 500 chars) | +| `files_modified` | string | Agent | Files modified (semicolon-separated) | +| `error` | string | Agent | Error if failed | + +**context_from prefix convention**: `E*` → explore.csv lookup, `T*` → tasks.csv lookup. + +--- + +## Session Structure + +``` +.workflow/.wave-plan/{session-id}/ +├── explore.csv # Exploration state +├── tasks.csv # Execution state +├── discoveries.ndjson # Shared discovery board +├── explore-results/ # Detailed per-angle results +│ ├── E1.json +│ └── E2.json +├── task-results/ # Detailed per-task results +│ ├── T1.json +│ └── T2.json +├── results.csv # Final results export +└── context.md # Full context summary +``` + +--- + +## Session Initialization + +```javascript +const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString() + +// Parse flags +const AUTO_YES = $ARGUMENTS.includes('--yes') || $ARGUMENTS.includes('-y') +const concurrencyMatch = $ARGUMENTS.match(/(?:--concurrency|-c)\s+(\d+)/) +const maxConcurrency = concurrencyMatch ? parseInt(concurrencyMatch[1]) : 4 + +const requirement = $ARGUMENTS + .replace(/--yes|-y|--concurrency\s+\d+|-c\s+\d+/g, '') + .trim() + +const slug = requirement.toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .substring(0, 40) +const dateStr = getUtc8ISOString().substring(0, 10).replace(/-/g, '') +const sessionId = `wp-${slug}-${dateStr}` +const sessionFolder = `.workflow/.wave-plan/${sessionId}` + +Bash(`mkdir -p ${sessionFolder}/explore-results ${sessionFolder}/task-results`) +``` + +--- + +## Phase 1: Decompose → explore.csv + +### 1.1 Analyze Requirement + +```javascript +const complexity = analyzeComplexity(requirement) +// Low: 1 angle | Medium: 2-3 angles | High: 3-4 angles + +const ANGLE_PRESETS = { + architecture: ['architecture', 'dependencies', 'integration-points', 'modularity'], + security: ['security', 'auth-patterns', 'dataflow', 'validation'], + performance: ['performance', 'bottlenecks', 'caching', 'data-access'], + bugfix: ['error-handling', 'dataflow', 'state-management', 'edge-cases'], + feature: ['patterns', 'integration-points', 'testing', 'dependencies'] +} + +function selectAngles(text, count) { + let preset = 'feature' + if (/refactor|architect|restructure|modular/.test(text)) preset = 'architecture' + else if (/security|auth|permission|access/.test(text)) preset = 'security' + else if (/performance|slow|optimi|cache/.test(text)) preset = 'performance' + else if (/fix|bug|error|broken/.test(text)) preset = 'bugfix' + return ANGLE_PRESETS[preset].slice(0, count) +} + +const angleCount = complexity === 'High' ? 4 : complexity === 'Medium' ? 3 : 1 +const angles = selectAngles(requirement, angleCount) +``` + +### 1.2 Generate explore.csv + +```javascript +const header = 'id,angle,description,focus,deps,wave,status,findings,key_files,error' +const rows = angles.map((angle, i) => { + const id = `E${i + 1}` + const desc = `Explore codebase from ${angle} perspective for: ${requirement}` + return `"${id}","${angle}","${escCSV(desc)}","${angle}","",1,"pending","","",""` +}) + +Write(`${sessionFolder}/explore.csv`, [header, ...rows].join('\n')) +``` + +All exploration rows default to wave 1 (independent parallel). If angle dependencies exist, compute waves. + +--- + +## Phase 2: Wave Explore + +Execute exploration waves using `Task(Explore)` agents. + +### 2.1 Wave Loop + +```javascript +const exploreCSV = parseCSV(Read(`${sessionFolder}/explore.csv`)) +const maxExploreWave = Math.max(...exploreCSV.map(r => parseInt(r.wave))) + +for (let wave = 1; wave <= maxExploreWave; wave++) { + const waveRows = exploreCSV.filter(r => + parseInt(r.wave) === wave && r.status === 'pending' + ) + if (waveRows.length === 0) continue + + // Skip rows with failed dependencies + const validRows = waveRows.filter(r => { + if (!r.deps) return true + return r.deps.split(';').filter(Boolean).every(depId => { + const dep = exploreCSV.find(d => d.id === depId) + return dep && dep.status === 'completed' + }) + }) + + waveRows.filter(r => !validRows.includes(r)).forEach(r => { + r.status = 'skipped' + r.error = 'Dependency failed/skipped' + }) + + // ★ Spawn ALL explore agents in SINGLE message → parallel execution + const results = validRows.map(row => + Task({ + subagent_type: "Explore", + run_in_background: false, + description: `Explore: ${row.angle}`, + prompt: buildExplorePrompt(row, requirement, sessionFolder) + }) + ) + + // Collect results from JSON files → update explore.csv + validRows.forEach((row, i) => { + const resultPath = `${sessionFolder}/explore-results/${row.id}.json` + if (fileExists(resultPath)) { + const result = JSON.parse(Read(resultPath)) + row.status = result.status || 'completed' + row.findings = truncate(result.findings, 800) + row.key_files = Array.isArray(result.key_files) + ? result.key_files.join(';') + : (result.key_files || '') + row.error = result.error || '' + } else { + // Fallback: parse from agent output text + row.status = 'completed' + row.findings = truncate(results[i], 800) + } + }) + + writeCSV(`${sessionFolder}/explore.csv`, exploreCSV) +} +``` + +### 2.2 Explore Agent Prompt + +```javascript +function buildExplorePrompt(row, requirement, sessionFolder) { + return `## Exploration: ${row.angle} + +**Requirement**: ${requirement} +**Focus**: ${row.focus} + +## Instructions +Explore the codebase from the **${row.angle}** perspective: +1. Discover relevant files, modules, and patterns +2. Identify integration points and dependencies +3. Note constraints, risks, and conventions +4. Find existing patterns to follow + +## Output +Write findings to: ${sessionFolder}/explore-results/${row.id}.json + +JSON format: +{ + "status": "completed", + "findings": "Concise summary of ${row.angle} discoveries (max 800 chars)", + "key_files": ["relevant/file1.ts", "relevant/file2.ts"], + "details": { + "patterns": ["pattern descriptions"], + "integration_points": [{"file": "path", "description": "..."}], + "constraints": ["constraint descriptions"], + "recommendations": ["recommendation descriptions"] + } +} + +Also provide a 2-3 sentence summary.` +} +``` + +--- + +## Phase 3: Synthesize & Plan → tasks.csv + +Read exploration findings, cross-reference, resolve conflicts, generate execution tasks. + +### 3.1 Load Explore Results + +```javascript +const exploreCSV = parseCSV(Read(`${sessionFolder}/explore.csv`)) +const completed = exploreCSV.filter(r => r.status === 'completed') + +// Load detailed result JSONs where available +const detailedResults = {} +completed.forEach(r => { + const path = `${sessionFolder}/explore-results/${r.id}.json` + if (fileExists(path)) detailedResults[r.id] = JSON.parse(Read(path)) +}) +``` + +### 3.2 Conflict Resolution Protocol + +Cross-reference findings across all exploration angles: + +```javascript +// 1. Identify common files referenced by multiple angles +const fileRefs = {} +completed.forEach(r => { + r.key_files.split(';').filter(Boolean).forEach(f => { + if (!fileRefs[f]) fileRefs[f] = [] + fileRefs[f].push({ angle: r.angle, id: r.id }) + }) +}) +const sharedFiles = Object.entries(fileRefs).filter(([_, refs]) => refs.length > 1) + +// 2. Detect conflicting recommendations +// Compare recommendations from different angles for same file/module +// Flag contradictions (angle A says "refactor X" vs angle B says "extend X") + +// 3. Resolution rules: +// a. Safety first — when approaches conflict, choose safer option +// b. Consistency — prefer approaches aligned with existing patterns +// c. Scope — prefer minimal-change approaches +// d. Document — note all resolved conflicts for transparency + +const synthesis = { + sharedFiles, + conflicts: detectConflicts(completed, detailedResults), + resolutions: [], + allKeyFiles: [...new Set(completed.flatMap(r => r.key_files.split(';').filter(Boolean)))] +} +``` + +### 3.3 Generate tasks.csv + +Decompose into execution tasks based on synthesized exploration: + +```javascript +// Task decomposition rules: +// 1. Group by feature/module (not per-file) +// 2. Each description is self-contained (agent sees only its row + prev_context) +// 3. deps only when task B requires task A's output +// 4. context_from links relevant explore rows (E*) and predecessor tasks (T*) +// 5. Prefer parallel (minimize deps) +// 6. Use exploration findings: key_files → target files, patterns → references, +// integration_points → dependency relationships, constraints → included in description + +const tasks = [] +// Claude decomposes requirement using exploration synthesis +// Example: +// tasks.push({ id: 'T1', title: 'Setup types', description: '...', deps: '', context_from: 'E1;E2' }) +// tasks.push({ id: 'T2', title: 'Implement core', description: '...', deps: 'T1', context_from: 'E1;E2;T1' }) +// tasks.push({ id: 'T3', title: 'Add tests', description: '...', deps: 'T2', context_from: 'E3;T2' }) + +// Compute waves +const waves = computeWaves(tasks) +tasks.forEach(t => { t.wave = waves[t.id] }) + +// Write tasks.csv +const header = 'id,title,description,deps,context_from,wave,status,findings,files_modified,error' +const rows = tasks.map(t => + `"${t.id}","${escCSV(t.title)}","${escCSV(t.description)}","${t.deps}","${t.context_from}",${t.wave},"pending","","",""` +) + +Write(`${sessionFolder}/tasks.csv`, [header, ...rows].join('\n')) +``` + +### 3.4 User Confirmation + +```javascript +if (!AUTO_YES) { + const maxWave = Math.max(...tasks.map(t => t.wave)) + + console.log(` +## Execution Plan + +Explore: ${completed.length} angles completed +Conflicts resolved: ${synthesis.conflicts.length} +Tasks: ${tasks.length} across ${maxWave} waves + +${Array.from({length: maxWave}, (_, i) => i + 1).map(w => { + const wt = tasks.filter(t => t.wave === w) + return `### Wave ${w} (${wt.length} tasks, concurrent) +${wt.map(t => ` - [${t.id}] ${t.title} (from: ${t.context_from})`).join('\n')}` +}).join('\n')} + `) + + AskUserQuestion({ + questions: [{ + question: `Proceed with ${tasks.length} tasks across ${maxWave} waves?`, + header: "Confirm", + multiSelect: false, + options: [ + { label: "Execute", description: "Proceed with wave execution" }, + { label: "Modify", description: `Edit ${sessionFolder}/tasks.csv then re-run` }, + { label: "Cancel", description: "Abort" } + ] + }] + }) +} +``` + +--- + +## Phase 4: Wave Execute + +Execute tasks from tasks.csv in wave order, with prev_context built from both explore.csv and tasks.csv. + +### 4.1 Wave Loop + +```javascript +const exploreCSV = parseCSV(Read(`${sessionFolder}/explore.csv`)) +const failedIds = new Set() +const skippedIds = new Set() + +let tasksCSV = parseCSV(Read(`${sessionFolder}/tasks.csv`)) +const maxWave = Math.max(...tasksCSV.map(r => parseInt(r.wave))) + +for (let wave = 1; wave <= maxWave; wave++) { + // Re-read master CSV (updated by previous wave) + tasksCSV = parseCSV(Read(`${sessionFolder}/tasks.csv`)) + + const waveRows = tasksCSV.filter(r => + parseInt(r.wave) === wave && r.status === 'pending' + ) + if (waveRows.length === 0) continue + + // Skip on failed dependencies (cascade) + const validRows = [] + for (const row of waveRows) { + const deps = (row.deps || '').split(';').filter(Boolean) + if (deps.some(d => failedIds.has(d) || skippedIds.has(d))) { + skippedIds.add(row.id) + row.status = 'skipped' + row.error = 'Dependency failed/skipped' + continue + } + validRows.push(row) + } + + if (validRows.length === 0) { + writeCSV(`${sessionFolder}/tasks.csv`, tasksCSV) + continue + } + + // Build prev_context for each row from explore.csv + tasks.csv + validRows.forEach(row => { + row._prev_context = buildPrevContext(row.context_from, exploreCSV, tasksCSV) + }) + + // ★ Spawn ALL task agents in SINGLE message → parallel execution + const results = validRows.map(row => + Task({ + subagent_type: "code-developer", + run_in_background: false, + description: row.title, + prompt: buildExecutePrompt(row, requirement, sessionFolder) + }) + ) + + // Collect results → update tasks.csv + validRows.forEach((row, i) => { + const resultPath = `${sessionFolder}/task-results/${row.id}.json` + if (fileExists(resultPath)) { + const result = JSON.parse(Read(resultPath)) + row.status = result.status || 'completed' + row.findings = truncate(result.findings, 500) + row.files_modified = Array.isArray(result.files_modified) + ? result.files_modified.join(';') + : (result.files_modified || '') + row.error = result.error || '' + } else { + row.status = 'completed' + row.findings = truncate(results[i], 500) + } + + if (row.status === 'failed') failedIds.add(row.id) + delete row._prev_context // runtime-only, don't persist + }) + + writeCSV(`${sessionFolder}/tasks.csv`, tasksCSV) +} +``` + +### 4.2 prev_context Builder + +The key function linking exploration context to execution: + +```javascript +function buildPrevContext(contextFrom, exploreCSV, tasksCSV) { + if (!contextFrom) return 'No previous context available' + + const ids = contextFrom.split(';').filter(Boolean) + const entries = [] + + ids.forEach(id => { + if (id.startsWith('E')) { + // ← Look up in explore.csv (cross-phase link) + const row = exploreCSV.find(r => r.id === id) + if (row && row.status === 'completed' && row.findings) { + entries.push(`[Explore ${row.angle}] ${row.findings}`) + if (row.key_files) entries.push(` Key files: ${row.key_files}`) + } + } else if (id.startsWith('T')) { + // ← Look up in tasks.csv (same-phase link) + const row = tasksCSV.find(r => r.id === id) + if (row && row.status === 'completed' && row.findings) { + entries.push(`[Task ${row.id}: ${row.title}] ${row.findings}`) + if (row.files_modified) entries.push(` Modified: ${row.files_modified}`) + } + } + }) + + return entries.length > 0 ? entries.join('\n') : 'No previous context available' +} +``` + +### 4.3 Execute Agent Prompt + +```javascript +function buildExecutePrompt(row, requirement, sessionFolder) { + return `## Task: ${row.title} + +**ID**: ${row.id} +**Goal**: ${requirement} + +## Description +${row.description} + +## Previous Context (from exploration and predecessor tasks) +${row._prev_context} + +## Discovery Board +Read shared discoveries first: ${sessionFolder}/discoveries.ndjson (if exists) +After execution, append any discoveries: +echo '{"ts":"","worker":"${row.id}","type":"","data":{...}}' >> ${sessionFolder}/discoveries.ndjson + +## Instructions +1. Read the relevant files identified in the context above +2. Implement changes described in the task description +3. Ensure changes are consistent with exploration findings +4. Test changes if applicable + +## Output +Write results to: ${sessionFolder}/task-results/${row.id}.json + +{ + "status": "completed", + "findings": "What was done (max 500 chars)", + "files_modified": ["file1.ts", "file2.ts"], + "error": "" +}` +} +``` + +--- + +## Phase 5: Aggregate + +### 5.1 Generate Results + +```javascript +const finalTasks = parseCSV(Read(`${sessionFolder}/tasks.csv`)) +const exploreCSV = parseCSV(Read(`${sessionFolder}/explore.csv`)) + +Bash(`cp "${sessionFolder}/tasks.csv" "${sessionFolder}/results.csv"`) + +const completed = finalTasks.filter(r => r.status === 'completed') +const failed = finalTasks.filter(r => r.status === 'failed') +const skipped = finalTasks.filter(r => r.status === 'skipped') +const maxWave = Math.max(...finalTasks.map(r => parseInt(r.wave))) +``` + +### 5.2 Generate context.md + +```javascript +const contextMd = `# Wave Plan Results + +**Requirement**: ${requirement} +**Session**: ${sessionId} +**Timestamp**: ${getUtc8ISOString()} + +## Summary + +| Metric | Count | +|--------|-------| +| Explore Angles | ${exploreCSV.length} | +| Total Tasks | ${finalTasks.length} | +| Completed | ${completed.length} | +| Failed | ${failed.length} | +| Skipped | ${skipped.length} | +| Waves | ${maxWave} | + +## Exploration Results + +${exploreCSV.map(e => `### ${e.id}: ${e.angle} (${e.status}) +${e.findings || 'N/A'} +Key files: ${e.key_files || 'none'}`).join('\n\n')} + +## Task Results + +${finalTasks.map(t => `### ${t.id}: ${t.title} (${t.status}) +- Context from: ${t.context_from || 'none'} +- Wave: ${t.wave} +- Findings: ${t.findings || 'N/A'} +- Files: ${t.files_modified || 'none'} +${t.error ? `- Error: ${t.error}` : ''}`).join('\n\n')} + +## All Modified Files + +${[...new Set(finalTasks.flatMap(t => + (t.files_modified || '').split(';')).filter(Boolean) +)].map(f => '- ' + f).join('\n') || 'None'} +` + +Write(`${sessionFolder}/context.md`, contextMd) +``` + +### 5.3 Summary & Next Steps + +```javascript +console.log(` +## Wave Plan Complete + +Session: ${sessionFolder} +Explore: ${exploreCSV.filter(r => r.status === 'completed').length}/${exploreCSV.length} angles +Tasks: ${completed.length}/${finalTasks.length} completed, ${failed.length} failed, ${skipped.length} skipped +Waves: ${maxWave} + +Files: +- explore.csv — exploration state +- tasks.csv — execution state +- results.csv — final results +- context.md — full report +- discoveries.ndjson — shared discoveries +`) + +if (!AUTO_YES && failed.length > 0) { + AskUserQuestion({ + questions: [{ + question: `${failed.length} tasks failed. Next action?`, + header: "Next Step", + multiSelect: false, + options: [ + { label: "Retry Failed", description: "Reset failed + skipped, re-execute Phase 4" }, + { label: "View Report", description: "Display context.md" }, + { label: "Done", description: "Complete session" } + ] + }] + }) + // If Retry: reset failed/skipped status to pending, re-run Phase 4 +} +``` + +--- + +## Utilities + +### Wave Computation (Kahn's BFS) + +```javascript +function computeWaves(tasks) { + const inDegree = {}, adj = {}, depth = {} + tasks.forEach(t => { inDegree[t.id] = 0; adj[t.id] = []; depth[t.id] = 1 }) + + tasks.forEach(t => { + const deps = (t.deps || '').split(';').filter(Boolean) + deps.forEach(dep => { + if (adj[dep]) { adj[dep].push(t.id); inDegree[t.id]++ } + }) + }) + + const queue = Object.keys(inDegree).filter(id => inDegree[id] === 0) + queue.forEach(id => { depth[id] = 1 }) + + while (queue.length > 0) { + const current = queue.shift() + adj[current].forEach(next => { + depth[next] = Math.max(depth[next], depth[current] + 1) + inDegree[next]-- + if (inDegree[next] === 0) queue.push(next) + }) + } + + if (Object.values(inDegree).some(d => d > 0)) { + throw new Error('Circular dependency detected') + } + + return depth // { taskId: waveNumber } +} +``` + +### CSV Helpers + +```javascript +function escCSV(s) { return String(s || '').replace(/"/g, '""') } + +function parseCSV(content) { + const lines = content.trim().split('\n') + const header = lines[0].split(',').map(h => h.replace(/"/g, '').trim()) + return lines.slice(1).filter(l => l.trim()).map(line => { + const values = parseCSVLine(line) + const row = {} + header.forEach((col, i) => { row[col] = (values[i] || '').replace(/^"|"$/g, '') }) + return row + }) +} + +function writeCSV(path, rows) { + if (rows.length === 0) return + // Exclude runtime-only columns (prefixed with _) + const cols = Object.keys(rows[0]).filter(k => !k.startsWith('_')) + const header = cols.join(',') + const lines = rows.map(r => + cols.map(c => `"${escCSV(r[c])}"`).join(',') + ) + Write(path, [header, ...lines].join('\n')) +} + +function truncate(s, max) { + s = String(s || '') + return s.length > max ? s.substring(0, max - 3) + '...' : s +} +``` + +--- + +## Discovery Board Protocol + +Shared `discoveries.ndjson` — append-only NDJSON accessible to all agents across all phases. + +```jsonl +{"ts":"...","worker":"E1","type":"code_pattern","data":{"name":"repo-pattern","file":"src/repos/Base.ts"}} +{"ts":"...","worker":"T2","type":"integration_point","data":{"file":"src/auth/index.ts","exports":["auth"]}} +``` + +**Types**: `code_pattern`, `integration_point`, `convention`, `blocker`, `tech_stack` +**Rules**: Read first → write immediately → deduplicate → append-only + +--- + +## Error Handling + +| Error | Resolution | +|-------|------------| +| Explore agent failure | Mark as failed in explore.csv, exclude from planning | +| Execute agent failure | Mark as failed, skip dependents (cascade) | +| Circular dependency | Abort wave computation, report cycle | +| All explores failed | Fallback: plan directly from requirement | +| CSV parse error | Re-validate format | +| discoveries.ndjson corrupt | Ignore malformed lines | + +--- + +## Core Rules + +1. **Wave Order is Sacred**: Never execute wave N before wave N-1 completes +2. **CSV is Source of Truth**: Read master CSV before each wave, write after +3. **Context via CSV**: prev_context built from CSV findings, not from memory +4. **E* ↔ T* Linking**: tasks.csv `context_from` references explore.csv rows for cross-phase context +5. **Skip on Failure**: Failed dep → skip dependent (cascade) +6. **Discovery Board Append-Only**: Never clear or modify discoveries.ndjson +7. **Explore Before Execute**: Phase 2 completes before Phase 4 starts +8. **DO NOT STOP**: Continuous execution until all waves complete or remaining skipped diff --git a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts index aecb070f..cc31c437 100644 --- a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts +++ b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts @@ -341,6 +341,77 @@ export class A2UIWebSocketHandler { ): boolean { const params = action.parameters ?? {}; const questionId = typeof params.questionId === 'string' ? params.questionId : undefined; + + // Handle submit-all first - it uses compositeId instead of questionId + if (action.actionId === 'submit-all') { + const compositeId = typeof params.compositeId === 'string' ? params.compositeId : undefined; + const questionIds = Array.isArray(params.questionIds) ? params.questionIds as string[] : undefined; + if (!compositeId || !questionIds) { + return false; + } + + // DEBUG: NDJSON log for submit-all received + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H2', + event: 'submit_all_received_in_handleQuestionAction', + compositeId, + questionCount: questionIds.length + })); + + // Collect answers for all sub-questions + const answers: QuestionAnswer[] = []; + for (const qId of questionIds) { + const singleSel = this.singleSelectSelections.get(qId); + const multiSel = this.multiSelectSelections.get(qId); + const inputVal = this.inputValues.get(qId); + const otherText = this.inputValues.get(`__other__:${qId}`); + + if (singleSel !== undefined) { + const value = singleSel === '__other__' && otherText ? otherText : singleSel; + answers.push({ questionId: qId, value, cancelled: false }); + } else if (multiSel !== undefined) { + const values = Array.from(multiSel).map(v => + v === '__other__' && otherText ? otherText : v + ); + answers.push({ questionId: qId, value: values, cancelled: false }); + } else if (inputVal !== undefined) { + answers.push({ questionId: qId, value: inputVal, cancelled: false }); + } else { + answers.push({ questionId: qId, value: '', cancelled: false }); + } + + // Cleanup per-question tracking + this.singleSelectSelections.delete(qId); + this.multiSelectSelections.delete(qId); + this.inputValues.delete(qId); + this.inputValues.delete(`__other__:${qId}`); + } + + // Call multi-answer callback + let handled = false; + if (this.multiAnswerCallback) { + handled = this.multiAnswerCallback(compositeId, answers); + } + if (!handled) { + // Store for HTTP polling retrieval + this.resolvedMultiAnswers.set(compositeId, { compositeId, answers, timestamp: Date.now() }); + + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H2', + event: 'answer_stored_for_polling', + compositeId, + answerCount: answers.length + })); + } + this.activeSurfaces.delete(compositeId); + return true; + } + + // For other actions, questionId is required if (!questionId) { return false; } @@ -446,60 +517,6 @@ export class A2UIWebSocketHandler { return true; } - case 'submit-all': { - // Multi-question composite submit - const compositeId = typeof params.compositeId === 'string' ? params.compositeId : undefined; - const questionIds = Array.isArray(params.questionIds) ? params.questionIds as string[] : undefined; - if (!compositeId || !questionIds) { - return false; - } - - // Collect answers for all sub-questions - const answers: QuestionAnswer[] = []; - for (const qId of questionIds) { - const singleSel = this.singleSelectSelections.get(qId); - const multiSel = this.multiSelectSelections.get(qId); - const inputVal = this.inputValues.get(qId); - const otherText = this.inputValues.get(`__other__:${qId}`); - - if (singleSel !== undefined) { - // Resolve __other__ to actual text input - const value = singleSel === '__other__' && otherText ? otherText : singleSel; - answers.push({ questionId: qId, value, cancelled: false }); - } else if (multiSel !== undefined) { - // Resolve __other__ in multi-select: replace with actual text - const values = Array.from(multiSel).map(v => - v === '__other__' && otherText ? otherText : v - ); - answers.push({ questionId: qId, value: values, cancelled: false }); - } else if (inputVal !== undefined) { - answers.push({ questionId: qId, value: inputVal, cancelled: false }); - } else { - // No value recorded — include empty - answers.push({ questionId: qId, value: '', cancelled: false }); - } - - // Cleanup per-question tracking - this.singleSelectSelections.delete(qId); - this.multiSelectSelections.delete(qId); - this.inputValues.delete(qId); - this.inputValues.delete(`__other__:${qId}`); - } - - // Call multi-answer callback - let handled = false; - if (this.multiAnswerCallback) { - handled = this.multiAnswerCallback(compositeId, answers); - } - if (!handled) { - // Store for HTTP polling retrieval - this.resolvedMultiAnswers.set(compositeId, { compositeId, answers, timestamp: Date.now() }); - } - // Always clean up UI state - this.activeSurfaces.delete(compositeId); - return true; - } - default: return false; } diff --git a/ccw/src/tools/ask-question.ts b/ccw/src/tools/ask-question.ts index 491384ad..783c041b 100644 --- a/ccw/src/tools/ask-question.ts +++ b/ccw/src/tools/ask-question.ts @@ -611,8 +611,29 @@ export function handleAnswer(answer: QuestionAnswer): boolean { * @returns True if answer was processed */ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean { + const handleStartTime = Date.now(); + + // DEBUG: NDJSON log for handleMultiAnswer start + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H3', + event: 'handle_multi_answer_start', + compositeId, + answerCount: answers.length + })); + const pending = getPendingQuestion(compositeId); if (!pending) { + // DEBUG: NDJSON log for missing pending question + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H3', + event: 'handle_multi_answer_no_pending', + compositeId, + elapsedMs: Date.now() - handleStartTime + })); return false; } @@ -625,6 +646,17 @@ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[] }); removePendingQuestion(compositeId); + + // DEBUG: NDJSON log for handleMultiAnswer complete + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H3', + event: 'handle_multi_answer_complete', + compositeId, + elapsedMs: Date.now() - handleStartTime + })); + return true; } @@ -637,9 +669,24 @@ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[] */ function startAnswerPolling(questionId: string, isComposite: boolean = false): void { const pollPath = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`; + const startTime = Date.now(); + + // DEBUG: NDJSON log for polling start + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H1', + event: 'polling_start', + questionId, + isComposite, + port: DASHBOARD_PORT, + firstPollDelayMs: POLL_INTERVAL_MS + })); console.error(`[A2UI-Poll] Starting polling for questionId=${questionId}, composite=${isComposite}, port=${DASHBOARD_PORT}`); + let pollCount = 0; + const poll = () => { // Stop if the question was already resolved or timed out if (!hasPendingQuestion(questionId)) { @@ -647,6 +694,20 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v return; } + pollCount++; + const pollStartTime = Date.now(); + + // DEBUG: NDJSON log for each poll attempt + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H1', + event: 'poll_attempt', + questionId, + pollCount, + elapsedMs: pollStartTime - startTime + })); + const req = http.get({ hostname: '127.0.0.1', port: DASHBOARD_PORT, path: pollPath, timeout: 2000 }, (res) => { let data = ''; res.on('data', (chunk: Buffer) => { data += chunk.toString(); }); @@ -667,6 +728,20 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v console.error(`[A2UI-Poll] Answer received for questionId=${questionId}:`, JSON.stringify(parsed).slice(0, 200)); + // DEBUG: NDJSON log for answer received + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'DEBUG', + hid: 'H1', + event: 'answer_received', + questionId, + pollCount, + elapsedMs: Date.now() - startTime, + pollLatencyMs: Date.now() - pollStartTime, + isComposite, + answerPreview: JSON.stringify(parsed).slice(0, 100) + })); + if (isComposite && Array.isArray(parsed.answers)) { const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]); console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`);