feat: add workflow-wave-plan skill and A2UI debug logging

- 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
This commit is contained in:
catlog22
2026-02-28 11:40:28 +08:00
parent 0a37bc3eaf
commit 67e78b132c
3 changed files with 920 additions and 54 deletions

View File

@@ -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: "<task description> [--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":"<ISO>","worker":"${row.id}","type":"<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

View File

@@ -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;
}

View File

@@ -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}`);