dual-brain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- package/src/update-check.mjs +35 -0
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { classifyRisk, extractPaths } from './risk-classifier.mjs';
|
|
4
|
+
import { resolveDependencies } from './plan-generator.mjs';
|
|
5
|
+
import { dispatchGptTask } from './gpt-work-dispatcher.mjs';
|
|
6
|
+
import { getProviderStatus, chooseProvider, estimateWaveCost, estimateTokensForTask } from './budget-balancer.mjs';
|
|
7
|
+
import { recordDecision, recordOutcome } from './decision-ledger.mjs';
|
|
8
|
+
import { classifyTask, selectModelEffort } from './task-classifier.mjs';
|
|
9
|
+
import { getCapabilities, getDispatchConfig, recommendEffort } from './model-registry.mjs';
|
|
10
|
+
import { dualThink } from './dual-brain-think.mjs';
|
|
11
|
+
import { spawnSync, spawn } from 'child_process';
|
|
12
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
|
13
|
+
import { dirname, join } from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const ROOT_DIR = join(__dirname, '..');
|
|
18
|
+
const STATE_DIR = join(ROOT_DIR, '.dualbrain');
|
|
19
|
+
const MANIFEST_DIR = join(STATE_DIR, 'manifests');
|
|
20
|
+
const CHECKPOINT_DIR = join(STATE_DIR, 'checkpoints');
|
|
21
|
+
const MAX_WAVE_PARALLELISM = 4;
|
|
22
|
+
const LEVEL_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
23
|
+
const STATUS_ICON = {
|
|
24
|
+
pending: '○',
|
|
25
|
+
running: '◐',
|
|
26
|
+
completed: '✓',
|
|
27
|
+
failed: '✕',
|
|
28
|
+
paused: '⏸',
|
|
29
|
+
};
|
|
30
|
+
const ASCII_STATUS_ICON = {
|
|
31
|
+
pending: 'o',
|
|
32
|
+
running: '>',
|
|
33
|
+
completed: 'v',
|
|
34
|
+
failed: 'x',
|
|
35
|
+
paused: '=',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function ensureStateDirs() {
|
|
39
|
+
mkdirSync(MANIFEST_DIR, { recursive: true });
|
|
40
|
+
mkdirSync(CHECKPOINT_DIR, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isoNow() {
|
|
44
|
+
return new Date().toISOString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeManifestId() {
|
|
48
|
+
return `mf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeJsonParse(raw, fallback = null) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function uniq(items) {
|
|
60
|
+
return [...new Set((items || []).filter(Boolean))];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function trimText(value, max = 120) {
|
|
64
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
65
|
+
if (text.length <= max) return text;
|
|
66
|
+
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slugify(value) {
|
|
70
|
+
return String(value || 'task')
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
73
|
+
.replace(/^-+|-+$/g, '')
|
|
74
|
+
.slice(0, 32) || 'task';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function highestRisk(levels) {
|
|
78
|
+
return (levels || []).reduce((current, level) => {
|
|
79
|
+
return (LEVEL_ORDER[level] ?? 0) > (LEVEL_ORDER[current] ?? 0) ? level : current;
|
|
80
|
+
}, 'low');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function statusIcon(status) {
|
|
84
|
+
const icons = process.env.NO_COLOR ? ASCII_STATUS_ICON : STATUS_ICON;
|
|
85
|
+
return icons[status] || icons.pending;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveManifest(manifest) {
|
|
89
|
+
ensureStateDirs();
|
|
90
|
+
writeFileSync(
|
|
91
|
+
join(MANIFEST_DIR, `${manifest.manifestId}.json`),
|
|
92
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
93
|
+
'utf8',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadManifest(manifestId) {
|
|
98
|
+
const path = join(MANIFEST_DIR, `${manifestId}.json`);
|
|
99
|
+
if (!existsSync(path)) {
|
|
100
|
+
throw new Error(`Manifest not found: ${manifestId}`);
|
|
101
|
+
}
|
|
102
|
+
const manifest = safeJsonParse(readFileSync(path, 'utf8'), null);
|
|
103
|
+
if (!manifest) {
|
|
104
|
+
throw new Error(`Manifest is unreadable: ${manifestId}`);
|
|
105
|
+
}
|
|
106
|
+
return manifest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function flattenTasks(manifest) {
|
|
110
|
+
return manifest.waves.flatMap(wave => wave.tasks);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function combinedTaskFiles(task) {
|
|
114
|
+
return uniq([...(task.owns || []), ...(task.reads || [])]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function refreshCounts(manifest) {
|
|
118
|
+
const tasks = flattenTasks(manifest);
|
|
119
|
+
manifest.totalWaves = manifest.waves.length;
|
|
120
|
+
manifest.totalTasks = tasks.length;
|
|
121
|
+
manifest.completedWaves = manifest.waves.filter(w => w.status === 'completed').length;
|
|
122
|
+
manifest.completedTasks = tasks.filter(t => t.status === 'completed').length;
|
|
123
|
+
manifest.failedTasks = tasks.filter(t => t.status === 'failed').length;
|
|
124
|
+
return manifest;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getBalanceSnapshot() {
|
|
128
|
+
const status = getProviderStatus();
|
|
129
|
+
const recommendation = chooseProvider({
|
|
130
|
+
tier: 'execute',
|
|
131
|
+
estimatedDurationMs: 300_000,
|
|
132
|
+
contextCoupling: 'medium',
|
|
133
|
+
isolation: 'medium',
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
claude: {
|
|
137
|
+
think: status.claude?.think || null,
|
|
138
|
+
execute: status.claude?.execute || null,
|
|
139
|
+
},
|
|
140
|
+
openai: {
|
|
141
|
+
think: status.openai?.think || null,
|
|
142
|
+
execute: status.openai?.execute || null,
|
|
143
|
+
},
|
|
144
|
+
recommendation: `${recommendation.provider}:${recommendation.model} (${recommendation.reason})`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function padCell(value, width) {
|
|
149
|
+
const text = String(value ?? '');
|
|
150
|
+
return text + ' '.repeat(Math.max(0, width - text.length));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderTable(headers, rows) {
|
|
154
|
+
const widths = headers.map((header, idx) =>
|
|
155
|
+
Math.max(
|
|
156
|
+
header.length,
|
|
157
|
+
...rows.map(row => String(row[idx] ?? '').length),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
const top = `┌${widths.map(w => '─'.repeat(w + 2)).join('┬')}┐`;
|
|
161
|
+
const mid = `├${widths.map(w => '─'.repeat(w + 2)).join('┼')}┤`;
|
|
162
|
+
const bot = `└${widths.map(w => '─'.repeat(w + 2)).join('┴')}┘`;
|
|
163
|
+
const line = cells => `│ ${cells.map((cell, idx) => padCell(cell, widths[idx])).join(' │ ')} │`;
|
|
164
|
+
return [top, line(headers), mid, ...rows.map(line), bot].join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function gitInsideRepo() {
|
|
168
|
+
const proc = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
169
|
+
cwd: ROOT_DIR,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
});
|
|
172
|
+
return proc.status === 0 && proc.stdout.trim() === 'true';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function gitHead() {
|
|
176
|
+
const proc = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
177
|
+
cwd: ROOT_DIR,
|
|
178
|
+
encoding: 'utf8',
|
|
179
|
+
});
|
|
180
|
+
return proc.status === 0 ? proc.stdout.trim() : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function runCommand(command, args, cwd = ROOT_DIR) {
|
|
184
|
+
const proc = spawnSync(command, args, {
|
|
185
|
+
cwd,
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
status: proc.status,
|
|
190
|
+
stdout: proc.stdout || '',
|
|
191
|
+
stderr: proc.stderr || '',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function printProgress(message) {
|
|
196
|
+
process.stdout.write(`${message}\n`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizeTask(rawTask, index) {
|
|
200
|
+
const description = trimText(rawTask.description || rawTask.title || `Task ${index + 1}`, 220);
|
|
201
|
+
const pathSeed = `${rawTask.title || ''} ${rawTask.description || ''}`;
|
|
202
|
+
const paths = uniq([...(rawTask.files || []), ...extractPaths(pathSeed)]);
|
|
203
|
+
const risk = classifyRisk(paths);
|
|
204
|
+
return {
|
|
205
|
+
taskId: rawTask.taskId || `task-${index + 1}-${slugify(rawTask.title || description)}`,
|
|
206
|
+
description,
|
|
207
|
+
title: rawTask.title || description,
|
|
208
|
+
tier: rawTask.tier || 'execute',
|
|
209
|
+
topic: rawTask.topic || rawTask.title || description,
|
|
210
|
+
dependencies: rawTask.dependencies || [],
|
|
211
|
+
files: paths,
|
|
212
|
+
riskLevel: highestRisk([rawTask.risk, risk.level]),
|
|
213
|
+
riskReason: rawTask.reason || risk.reason || 'no risk rationale',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function decomposeIntent(utterance) {
|
|
218
|
+
if (!utterance || !String(utterance).trim()) {
|
|
219
|
+
throw new Error('Utterance is required unless resuming an existing manifest.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const proc = spawnSync(
|
|
223
|
+
'node',
|
|
224
|
+
[join(__dirname, 'vibe-router.mjs'), utterance],
|
|
225
|
+
{ cwd: ROOT_DIR, encoding: 'utf8' },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (proc.status !== 0) {
|
|
229
|
+
throw new Error(trimText(proc.stderr || proc.stdout || 'vibe-router failed', 300));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const parsed = safeJsonParse(proc.stdout, null);
|
|
233
|
+
if (!parsed || !Array.isArray(parsed.tasks)) {
|
|
234
|
+
throw new Error('vibe-router returned invalid JSON.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const tasks = parsed.tasks.map(normalizeTask);
|
|
238
|
+
return {
|
|
239
|
+
utterance,
|
|
240
|
+
createdAt: isoNow(),
|
|
241
|
+
status: 'planned',
|
|
242
|
+
riskLevel: highestRisk(tasks.map(task => task.riskLevel)),
|
|
243
|
+
tasks,
|
|
244
|
+
qualityGates: parsed.quality_gates || [],
|
|
245
|
+
waveRecommendation: parsed.wave_recommendation || 'sequential',
|
|
246
|
+
summary: parsed.summary || '',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function pathsConflict(a, b) {
|
|
251
|
+
if (!a || !b) return false;
|
|
252
|
+
if (a === b) return true;
|
|
253
|
+
if (a.startsWith(`${b}/`) || b.startsWith(`${a}/`)) return true;
|
|
254
|
+
const aDir = a.includes('/') ? a.slice(0, a.lastIndexOf('/')) : a;
|
|
255
|
+
const bDir = b.includes('/') ? b.slice(0, b.lastIndexOf('/')) : b;
|
|
256
|
+
return Boolean(aDir && aDir === bDir);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildOwnershipMap(tasks) {
|
|
260
|
+
const byTask = {};
|
|
261
|
+
const fileOwners = {};
|
|
262
|
+
const conflicts = [];
|
|
263
|
+
|
|
264
|
+
for (const task of tasks) {
|
|
265
|
+
const owns = task.tier === 'execute' ? uniq(task.files) : [];
|
|
266
|
+
const reads = uniq(task.files);
|
|
267
|
+
byTask[task.taskId] = {
|
|
268
|
+
owns,
|
|
269
|
+
reads,
|
|
270
|
+
conflicts: [],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
for (const file of owns) {
|
|
274
|
+
if (!fileOwners[file]) fileOwners[file] = [];
|
|
275
|
+
fileOwners[file].push(task.taskId);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
280
|
+
for (let j = i + 1; j < tasks.length; j++) {
|
|
281
|
+
const left = tasks[i];
|
|
282
|
+
const right = tasks[j];
|
|
283
|
+
const leftOwns = byTask[left.taskId].owns;
|
|
284
|
+
const rightOwns = byTask[right.taskId].owns;
|
|
285
|
+
const overlap = [];
|
|
286
|
+
|
|
287
|
+
for (const leftPath of leftOwns) {
|
|
288
|
+
for (const rightPath of rightOwns) {
|
|
289
|
+
if (pathsConflict(leftPath, rightPath)) {
|
|
290
|
+
overlap.push(leftPath === rightPath ? leftPath : `${leftPath} ~ ${rightPath}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (overlap.length > 0) {
|
|
296
|
+
const conflict = { taskIds: [left.taskId, right.taskId], paths: uniq(overlap) };
|
|
297
|
+
conflicts.push(conflict);
|
|
298
|
+
byTask[left.taskId].conflicts.push(conflict);
|
|
299
|
+
byTask[right.taskId].conflicts.push(conflict);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { byTask, fileOwners, conflicts };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildDependencyMap(tasks) {
|
|
308
|
+
const ordered = resolveDependencies(tasks.map(task => ({
|
|
309
|
+
title: task.title,
|
|
310
|
+
description: task.description,
|
|
311
|
+
tier: task.tier,
|
|
312
|
+
risk: task.riskLevel,
|
|
313
|
+
files: task.files,
|
|
314
|
+
dependencies: task.dependencies,
|
|
315
|
+
topic: task.topic,
|
|
316
|
+
})));
|
|
317
|
+
|
|
318
|
+
const orderedTasks = [];
|
|
319
|
+
const dependencies = new Map();
|
|
320
|
+
const usedTaskIds = new Set();
|
|
321
|
+
|
|
322
|
+
for (const item of ordered) {
|
|
323
|
+
const task = tasks.find(candidate =>
|
|
324
|
+
!usedTaskIds.has(candidate.taskId) &&
|
|
325
|
+
candidate.title === item.title &&
|
|
326
|
+
candidate.description === item.description,
|
|
327
|
+
);
|
|
328
|
+
if (!task) continue;
|
|
329
|
+
usedTaskIds.add(task.taskId);
|
|
330
|
+
orderedTasks.push(task);
|
|
331
|
+
|
|
332
|
+
const deps = [];
|
|
333
|
+
if (item.dependencies && item.dependencies !== '—') {
|
|
334
|
+
for (const label of String(item.dependencies).split(',').map(entry => entry.trim())) {
|
|
335
|
+
const match = label.match(/^Task\s+(\d+)$/i);
|
|
336
|
+
if (!match) continue;
|
|
337
|
+
const depIdx = Number(match[1]) - 1;
|
|
338
|
+
if (!ordered[depIdx]) continue;
|
|
339
|
+
const dep = tasks.find(candidate =>
|
|
340
|
+
candidate.title === ordered[depIdx].title &&
|
|
341
|
+
candidate.description === ordered[depIdx].description,
|
|
342
|
+
);
|
|
343
|
+
if (dep) deps.push(dep.taskId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
dependencies.set(task.taskId, uniq([...deps, ...(task.dependencies || []).filter(Boolean)]));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const task of tasks) {
|
|
350
|
+
if (!usedTaskIds.has(task.taskId)) {
|
|
351
|
+
orderedTasks.push(task);
|
|
352
|
+
dependencies.set(task.taskId, uniq(task.dependencies || []));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { orderedTasks, dependencies };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function initManifestTask(baseTask, waveId, ownership) {
|
|
360
|
+
return {
|
|
361
|
+
taskId: baseTask.taskId,
|
|
362
|
+
description: baseTask.description,
|
|
363
|
+
provider: null,
|
|
364
|
+
model: null,
|
|
365
|
+
tier: baseTask.tier,
|
|
366
|
+
effort: null,
|
|
367
|
+
agentType: null,
|
|
368
|
+
sandbox: null,
|
|
369
|
+
reason: baseTask.riskReason,
|
|
370
|
+
owns: ownership.byTask[baseTask.taskId]?.owns || [],
|
|
371
|
+
reads: ownership.byTask[baseTask.taskId]?.reads || [],
|
|
372
|
+
status: 'pending',
|
|
373
|
+
result: null,
|
|
374
|
+
startedAt: null,
|
|
375
|
+
completedAt: null,
|
|
376
|
+
retryCount: 0,
|
|
377
|
+
durationMs: null,
|
|
378
|
+
riskLevel: baseTask.riskLevel,
|
|
379
|
+
dependencies: baseTask.dependencies || [],
|
|
380
|
+
topic: baseTask.topic,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function canAddTaskToWave(candidate, waveTasks, ownership, dependencies) {
|
|
385
|
+
if (waveTasks.length >= MAX_WAVE_PARALLELISM) return false;
|
|
386
|
+
|
|
387
|
+
const candidateDeps = dependencies.get(candidate.taskId) || [];
|
|
388
|
+
if (candidateDeps.some(depId => waveTasks.some(task => task.taskId === depId))) return false;
|
|
389
|
+
|
|
390
|
+
const candidateOwns = ownership.byTask[candidate.taskId]?.owns || [];
|
|
391
|
+
for (const existing of waveTasks) {
|
|
392
|
+
const existingOwns = ownership.byTask[existing.taskId]?.owns || [];
|
|
393
|
+
for (const left of candidateOwns) {
|
|
394
|
+
for (const right of existingOwns) {
|
|
395
|
+
if (pathsConflict(left, right)) return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function planWaves(tasks, ownership) {
|
|
404
|
+
const { orderedTasks, dependencies } = buildDependencyMap(tasks);
|
|
405
|
+
const taskIndex = new Map(orderedTasks.map((task, idx) => [task.taskId, idx]));
|
|
406
|
+
const pending = [...orderedTasks];
|
|
407
|
+
const completed = new Set();
|
|
408
|
+
const groups = [];
|
|
409
|
+
|
|
410
|
+
while (pending.length > 0) {
|
|
411
|
+
const ready = pending.filter(task =>
|
|
412
|
+
(dependencies.get(task.taskId) || []).every(depId => completed.has(depId) || !taskIndex.has(depId)),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const bucket = [];
|
|
416
|
+
for (const task of ready) {
|
|
417
|
+
if (canAddTaskToWave(task, bucket, ownership, dependencies)) {
|
|
418
|
+
bucket.push(task);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (bucket.length === 0) {
|
|
423
|
+
bucket.push(pending[0]);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
groups.push(bucket);
|
|
427
|
+
for (const task of bucket) {
|
|
428
|
+
completed.add(task.taskId);
|
|
429
|
+
const idx = pending.findIndex(candidate => candidate.taskId === task.taskId);
|
|
430
|
+
if (idx >= 0) pending.splice(idx, 1);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const waves = groups.map((group, index) => {
|
|
435
|
+
const waveId = `wave-${index + 1}`;
|
|
436
|
+
return {
|
|
437
|
+
waveId,
|
|
438
|
+
status: 'pending',
|
|
439
|
+
checkpoint: { commitHash: null, createdAt: null },
|
|
440
|
+
tasks: group.map(task => initManifestTask(task, waveId, ownership)),
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
waves.push({
|
|
445
|
+
waveId: `wave-${waves.length + 1}`,
|
|
446
|
+
status: 'pending',
|
|
447
|
+
checkpoint: { commitHash: null, createdAt: null },
|
|
448
|
+
tasks: [{
|
|
449
|
+
taskId: `task-final-review`,
|
|
450
|
+
description: 'Review all completed wave outputs, call out unresolved risks, and verify final coherence.',
|
|
451
|
+
provider: null,
|
|
452
|
+
model: null,
|
|
453
|
+
tier: 'think',
|
|
454
|
+
effort: null,
|
|
455
|
+
agentType: null,
|
|
456
|
+
sandbox: null,
|
|
457
|
+
reason: 'final review wave',
|
|
458
|
+
owns: [],
|
|
459
|
+
reads: uniq(tasks.flatMap(task => ownership.byTask[task.taskId]?.reads || task.files || [])),
|
|
460
|
+
status: 'pending',
|
|
461
|
+
result: null,
|
|
462
|
+
startedAt: null,
|
|
463
|
+
completedAt: null,
|
|
464
|
+
retryCount: 0,
|
|
465
|
+
durationMs: null,
|
|
466
|
+
riskLevel: highestRisk(tasks.map(task => task.riskLevel)),
|
|
467
|
+
dependencies: tasks.map(task => task.taskId),
|
|
468
|
+
topic: 'final-review',
|
|
469
|
+
}],
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
return waves;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function inferEffort(task) {
|
|
476
|
+
if (task.tier === 'think') return 'high';
|
|
477
|
+
if (task.riskLevel === 'critical' || task.riskLevel === 'high') return 'high';
|
|
478
|
+
if (task.tier === 'search') return 'low';
|
|
479
|
+
return (combinedTaskFiles(task).length <= 1) ? 'medium' : 'high';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function inferAgentType(task) {
|
|
483
|
+
if (task.tier === 'search') return 'explorer';
|
|
484
|
+
if (task.tier === 'think') return 'reviewer';
|
|
485
|
+
return 'worker';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function inferSandbox(task) {
|
|
489
|
+
return task.tier === 'execute' ? 'danger-full-access' : 'read-only';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function estimateDurationMs(task) {
|
|
493
|
+
const fileCount = Math.max(1, combinedTaskFiles(task).length);
|
|
494
|
+
if (task.tier === 'search') return 90_000 + (fileCount * 15_000);
|
|
495
|
+
if (task.tier === 'think') return 240_000 + (fileCount * 20_000);
|
|
496
|
+
return 180_000 + (fileCount * 30_000);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function routeTasks(tasks) {
|
|
500
|
+
const status = getProviderStatus();
|
|
501
|
+
return tasks.map(task => {
|
|
502
|
+
const tier = ['search', 'execute', 'think'].includes(task.tier) ? task.tier : 'execute';
|
|
503
|
+
const files = combinedTaskFiles(task);
|
|
504
|
+
const agentType = inferAgentType(task);
|
|
505
|
+
const sandbox = inferSandbox(task);
|
|
506
|
+
|
|
507
|
+
// Use task-classifier for capability-aware model+effort selection
|
|
508
|
+
const profile = classifyTask(task.description, { files });
|
|
509
|
+
const estimatedTokens = estimateTokensForTask({ tier, effort: profile.effort, fileCount: files.length });
|
|
510
|
+
const selection = selectModelEffort(profile, {
|
|
511
|
+
budgetPressure: Math.max(
|
|
512
|
+
status.claude?.[tier]?.effectivePressure ?? 0,
|
|
513
|
+
status.openai?.[tier]?.effectivePressure ?? 0,
|
|
514
|
+
) / 100,
|
|
515
|
+
estimatedTokens,
|
|
516
|
+
isIterating: (task.retryCount || 0) > 0,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Choose provider via budget balancer
|
|
520
|
+
const contextCoupling = tier === 'think' || files.length > 3 ? 'high' : files.length > 1 ? 'medium' : 'low';
|
|
521
|
+
const isolation = (task.owns?.length || 0) <= 1 ? 'high' : (task.owns?.length || 0) <= 3 ? 'medium' : 'low';
|
|
522
|
+
const selected = chooseProvider({
|
|
523
|
+
tier,
|
|
524
|
+
estimatedDurationMs: estimateDurationMs(task),
|
|
525
|
+
contextCoupling,
|
|
526
|
+
isolation,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
let provider = selected.provider;
|
|
530
|
+
let model, effort, modes;
|
|
531
|
+
|
|
532
|
+
if (provider === 'claude') {
|
|
533
|
+
model = selection.claude.model;
|
|
534
|
+
effort = selection.claude.effort;
|
|
535
|
+
modes = selection.claude.modes;
|
|
536
|
+
} else {
|
|
537
|
+
model = selection.openai.model;
|
|
538
|
+
effort = selection.openai.effort;
|
|
539
|
+
modes = selection.openai.modes;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let reason = selected.reason;
|
|
543
|
+
|
|
544
|
+
const decisionId = recordDecision({
|
|
545
|
+
tier,
|
|
546
|
+
provider,
|
|
547
|
+
model,
|
|
548
|
+
effort,
|
|
549
|
+
recommended_model: selected.model,
|
|
550
|
+
followed: provider === selected.provider,
|
|
551
|
+
task_type: agentType,
|
|
552
|
+
estimated_duration_ms: estimateDurationMs(task),
|
|
553
|
+
file_count: files.length,
|
|
554
|
+
context_coupling: contextCoupling,
|
|
555
|
+
isolation,
|
|
556
|
+
claude_pressure: status.claude?.[tier]?.pressure ?? null,
|
|
557
|
+
openai_pressure: status.openai?.[tier]?.pressure ?? null,
|
|
558
|
+
dualBrain: selection.dualBrain,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Enforce: think/search tiers are read-only — they must not own files
|
|
562
|
+
if (tier !== 'execute') {
|
|
563
|
+
task.owns = [];
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
...task,
|
|
568
|
+
provider,
|
|
569
|
+
model,
|
|
570
|
+
tier,
|
|
571
|
+
effort,
|
|
572
|
+
agentType,
|
|
573
|
+
sandbox,
|
|
574
|
+
modes: modes || {},
|
|
575
|
+
dualBrain: selection.dualBrain,
|
|
576
|
+
reason,
|
|
577
|
+
_decisionId: decisionId,
|
|
578
|
+
_profile: profile,
|
|
579
|
+
};
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function printDispatchTable(manifest) {
|
|
584
|
+
const rows = manifest.waves.flatMap(wave => wave.tasks.map(task => {
|
|
585
|
+
const flags = [];
|
|
586
|
+
if (task.dualBrain) flags.push('DB');
|
|
587
|
+
if (task.modes?.extendedThinking) flags.push('ET');
|
|
588
|
+
if (task.modes?.ultrathink) flags.push('UT');
|
|
589
|
+
if (task.modes?.fastMode) flags.push('FM');
|
|
590
|
+
if (task.modes?.extendedContext) flags.push('1M');
|
|
591
|
+
const modeStr = flags.length ? flags.join(',') : '-';
|
|
592
|
+
|
|
593
|
+
return [
|
|
594
|
+
`${wave.waveId}:${task.taskId}`,
|
|
595
|
+
task.provider || '-',
|
|
596
|
+
task.model || '-',
|
|
597
|
+
task.effort || '-',
|
|
598
|
+
modeStr,
|
|
599
|
+
task.agentType || '-',
|
|
600
|
+
trimText(combinedTaskFiles(task).join(', ') || '-', 36),
|
|
601
|
+
];
|
|
602
|
+
}));
|
|
603
|
+
|
|
604
|
+
console.log(`Manifest: ${manifest.manifestId}`);
|
|
605
|
+
console.log(`State: ${manifest.status} Risk: ${manifest.riskLevel}`);
|
|
606
|
+
console.log(`Balance: ${manifest.balanceSnapshot.recommendation}`);
|
|
607
|
+
console.log(renderTable(
|
|
608
|
+
['Task', 'Provider', 'Model', 'Effort', 'Modes', 'Agent', 'Files'],
|
|
609
|
+
rows,
|
|
610
|
+
));
|
|
611
|
+
console.log('Modes: DB=dual-brain ET=extended-thinking UT=ultrathink FM=fast-mode 1M=extended-context');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function printFinalTable(manifest) {
|
|
615
|
+
const rows = manifest.waves.flatMap(wave => wave.tasks.map(task => [
|
|
616
|
+
`${wave.waveId}:${task.taskId}`,
|
|
617
|
+
task.provider || '-',
|
|
618
|
+
task.model || '-',
|
|
619
|
+
task.durationMs != null ? `${(task.durationMs / 1000).toFixed(1)}s` : '-',
|
|
620
|
+
`${statusIcon(task.status)} ${task.status}`,
|
|
621
|
+
trimText(combinedTaskFiles(task).join(', ') || '-', 42),
|
|
622
|
+
]));
|
|
623
|
+
|
|
624
|
+
console.log(renderTable(
|
|
625
|
+
['Task', 'Provider', 'Model', 'Duration', 'Status', 'Files'],
|
|
626
|
+
rows,
|
|
627
|
+
));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function gitCheckpoint(manifest, waveId) {
|
|
631
|
+
ensureStateDirs();
|
|
632
|
+
const checkpoint = {
|
|
633
|
+
manifestId: manifest.manifestId,
|
|
634
|
+
waveId,
|
|
635
|
+
createdAt: isoNow(),
|
|
636
|
+
commitHash: null,
|
|
637
|
+
commitStatus: 'skipped',
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
if (!gitInsideRepo()) {
|
|
641
|
+
writeFileSync(
|
|
642
|
+
join(CHECKPOINT_DIR, `${manifest.manifestId}-${waveId}.json`),
|
|
643
|
+
`${JSON.stringify(checkpoint, null, 2)}\n`,
|
|
644
|
+
'utf8',
|
|
645
|
+
);
|
|
646
|
+
return checkpoint;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const wave = manifest.waves.find(w => w.waveId === waveId);
|
|
650
|
+
const completedFiles = wave
|
|
651
|
+
? wave.tasks
|
|
652
|
+
.filter(t => t.status === 'completed')
|
|
653
|
+
.flatMap(t => t.owns || [])
|
|
654
|
+
.filter(Boolean)
|
|
655
|
+
: [];
|
|
656
|
+
if (completedFiles.length > 0) {
|
|
657
|
+
runCommand('git', ['add', ...completedFiles]);
|
|
658
|
+
} else {
|
|
659
|
+
runCommand('git', ['add', '-A']);
|
|
660
|
+
}
|
|
661
|
+
const commit = runCommand('git', ['commit', '-m', `wave-orchestrator checkpoint ${manifest.manifestId} ${waveId}`]);
|
|
662
|
+
if (commit.status === 0) {
|
|
663
|
+
checkpoint.commitStatus = 'created';
|
|
664
|
+
} else if (/nothing to commit|no changes added/i.test(commit.stdout + commit.stderr)) {
|
|
665
|
+
checkpoint.commitStatus = 'noop';
|
|
666
|
+
} else {
|
|
667
|
+
checkpoint.commitStatus = 'failed';
|
|
668
|
+
checkpoint.error = trimText(commit.stderr || commit.stdout, 300);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
checkpoint.commitHash = gitHead();
|
|
672
|
+
|
|
673
|
+
writeFileSync(
|
|
674
|
+
join(CHECKPOINT_DIR, `${manifest.manifestId}-${waveId}.json`),
|
|
675
|
+
`${JSON.stringify(checkpoint, null, 2)}\n`,
|
|
676
|
+
'utf8',
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
return checkpoint;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function loadPackageJson() {
|
|
683
|
+
const path = join(ROOT_DIR, 'package.json');
|
|
684
|
+
if (!existsSync(path)) return null;
|
|
685
|
+
return safeJsonParse(readFileSync(path, 'utf8'), null);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function detectTestCommand(manifest, wave) {
|
|
689
|
+
const hasExecute = wave.tasks.some(task => task.tier === 'execute' && task.status === 'completed');
|
|
690
|
+
if (!hasExecute) return null;
|
|
691
|
+
|
|
692
|
+
const pkg = loadPackageJson();
|
|
693
|
+
if (pkg?.scripts?.test) {
|
|
694
|
+
return { command: 'npm', args: ['test'] };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const changedFiles = wave.tasks.flatMap(task => combinedTaskFiles(task));
|
|
698
|
+
const hasNodeTests = changedFiles.some(file => /\.test\.|\.spec\.|tests?\//i.test(file));
|
|
699
|
+
if (hasNodeTests) {
|
|
700
|
+
return { command: 'node', args: ['--test'] };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function runWaveTests(manifest, wave) {
|
|
707
|
+
const testCommand = detectTestCommand(manifest, wave);
|
|
708
|
+
if (!testCommand) return null;
|
|
709
|
+
|
|
710
|
+
printProgress(`${statusIcon('running')} ${wave.waveId} tests: ${testCommand.command} ${testCommand.args.join(' ')}`);
|
|
711
|
+
const startedAt = Date.now();
|
|
712
|
+
const proc = spawnSync(testCommand.command, testCommand.args, {
|
|
713
|
+
cwd: ROOT_DIR,
|
|
714
|
+
encoding: 'utf8',
|
|
715
|
+
});
|
|
716
|
+
const durationMs = Date.now() - startedAt;
|
|
717
|
+
return {
|
|
718
|
+
command: `${testCommand.command} ${testCommand.args.join(' ')}`,
|
|
719
|
+
status: proc.status === 0 ? 'completed' : 'failed',
|
|
720
|
+
durationMs,
|
|
721
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
722
|
+
completedAt: isoNow(),
|
|
723
|
+
stdout: trimText(proc.stdout, 500),
|
|
724
|
+
stderr: trimText(proc.stderr, 500),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function buildDispatchPayload(manifest, wave, task) {
|
|
729
|
+
const files = combinedTaskFiles(task);
|
|
730
|
+
return {
|
|
731
|
+
task: [
|
|
732
|
+
task.description,
|
|
733
|
+
`Manifest ID: ${manifest.manifestId}`,
|
|
734
|
+
`Wave ID: ${wave.waveId}`,
|
|
735
|
+
`Task ID: ${task.taskId}`,
|
|
736
|
+
`Reason: ${task.reason}`,
|
|
737
|
+
`Provider: ${task.provider}`,
|
|
738
|
+
`Effort: ${task.effort}`,
|
|
739
|
+
`Agent Type: ${task.agentType}`,
|
|
740
|
+
`Sandbox: ${task.sandbox}`,
|
|
741
|
+
].join('\n'),
|
|
742
|
+
model: task.model,
|
|
743
|
+
tier: task.tier,
|
|
744
|
+
effort: task.effort,
|
|
745
|
+
files,
|
|
746
|
+
timeoutMs: Math.max(120_000, estimateDurationMs(task)),
|
|
747
|
+
constraints: [
|
|
748
|
+
`Owns: ${task.owns.join(', ') || 'none'}`,
|
|
749
|
+
`Reads: ${task.reads.join(', ') || 'none'}`,
|
|
750
|
+
`Status persistence is handled by the orchestrator; summarize changes clearly.`,
|
|
751
|
+
`CRITICAL: You may ONLY edit files listed in "Owns". Do NOT modify any other files. If you need to edit a file not in "Owns", report it as a blocker instead of editing it.`,
|
|
752
|
+
],
|
|
753
|
+
cwd: ROOT_DIR,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function compressResult(result) {
|
|
758
|
+
const duration = result?.durationMs != null ? `${(result.durationMs / 1000).toFixed(1)}s` : null;
|
|
759
|
+
const core = [
|
|
760
|
+
result?.success ? 'success' : 'failure',
|
|
761
|
+
duration,
|
|
762
|
+
result?.model || null,
|
|
763
|
+
trimText(result?.summary || result?.error || (result?.errors || []).join('; ') || 'no summary', 420),
|
|
764
|
+
].filter(Boolean).join(' | ');
|
|
765
|
+
return trimText(core, 500);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function executeClaudeAgent(task) {
|
|
769
|
+
const model = task.model || 'sonnet';
|
|
770
|
+
const files = combinedTaskFiles(task);
|
|
771
|
+
let prompt = [
|
|
772
|
+
task.description,
|
|
773
|
+
files.length ? `\nRelevant files:\n${files.map(f => `- ${f}`).join('\n')}` : '',
|
|
774
|
+
`\nOwns: ${(task.owns || []).join(', ') || 'none'}`,
|
|
775
|
+
`Reads: ${(task.reads || []).join(', ') || 'none'}`,
|
|
776
|
+
'\nCRITICAL: You may ONLY edit files listed in "Owns". Do NOT modify any other files. If you need to edit a file not in "Owns", report it as a blocker instead of editing it.',
|
|
777
|
+
'\nWhen done, summarize: files changed, tests run, edge cases, assumptions.',
|
|
778
|
+
].filter(Boolean).join('\n');
|
|
779
|
+
|
|
780
|
+
if (task.tier !== 'execute') {
|
|
781
|
+
// Think/search agents: analysis only, no edits
|
|
782
|
+
prompt = `YOU ARE A READ-ONLY ANALYST. Do NOT use Edit, Write, or any file-modification tools. Only use Read, Bash (for grep/find/git commands), and analysis.\n\n${prompt}`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const timeoutMs = Math.max(120_000, estimateDurationMs(task));
|
|
786
|
+
const started = Date.now();
|
|
787
|
+
|
|
788
|
+
const args = ['--model', model, '--print', '--output-format', 'json'];
|
|
789
|
+
if (task.effort && task.effort !== 'null') {
|
|
790
|
+
args.push('--effort', task.effort);
|
|
791
|
+
}
|
|
792
|
+
args.push('-p', prompt);
|
|
793
|
+
|
|
794
|
+
return new Promise((resolve) => {
|
|
795
|
+
let proc;
|
|
796
|
+
try {
|
|
797
|
+
proc = spawn('claude', args, {
|
|
798
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
799
|
+
cwd: ROOT_DIR,
|
|
800
|
+
});
|
|
801
|
+
} catch (spawnErr) {
|
|
802
|
+
const durationMs = Date.now() - started;
|
|
803
|
+
resolve({
|
|
804
|
+
success: false,
|
|
805
|
+
summary: '',
|
|
806
|
+
durationMs,
|
|
807
|
+
model,
|
|
808
|
+
usage: null,
|
|
809
|
+
errors: ['Claude CLI not found. Install claude or set PATH.'],
|
|
810
|
+
exitCode: null,
|
|
811
|
+
failureType: 'not_found',
|
|
812
|
+
retryCount: 0,
|
|
813
|
+
});
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
let stdout = '';
|
|
818
|
+
let stderr = '';
|
|
819
|
+
let timedOut = false;
|
|
820
|
+
|
|
821
|
+
const timer = setTimeout(() => {
|
|
822
|
+
timedOut = true;
|
|
823
|
+
proc.kill('SIGTERM');
|
|
824
|
+
setTimeout(() => {
|
|
825
|
+
try { proc.kill('SIGKILL'); } catch (_) {}
|
|
826
|
+
}, 5000);
|
|
827
|
+
}, timeoutMs);
|
|
828
|
+
|
|
829
|
+
proc.stdout.on('data', chunk => { stdout += chunk; });
|
|
830
|
+
proc.stderr.on('data', chunk => { stderr += chunk; });
|
|
831
|
+
|
|
832
|
+
proc.on('error', (err) => {
|
|
833
|
+
clearTimeout(timer);
|
|
834
|
+
const durationMs = Date.now() - started;
|
|
835
|
+
if (err.code === 'ENOENT') {
|
|
836
|
+
resolve({
|
|
837
|
+
success: false,
|
|
838
|
+
summary: '',
|
|
839
|
+
durationMs,
|
|
840
|
+
model,
|
|
841
|
+
usage: null,
|
|
842
|
+
errors: ['Claude CLI not found. Install claude or set PATH.'],
|
|
843
|
+
exitCode: null,
|
|
844
|
+
failureType: 'not_found',
|
|
845
|
+
retryCount: 0,
|
|
846
|
+
});
|
|
847
|
+
} else {
|
|
848
|
+
resolve({
|
|
849
|
+
success: false,
|
|
850
|
+
summary: '',
|
|
851
|
+
durationMs,
|
|
852
|
+
model,
|
|
853
|
+
usage: null,
|
|
854
|
+
errors: [err.message || 'spawn error'],
|
|
855
|
+
exitCode: null,
|
|
856
|
+
failureType: 'spawn_error',
|
|
857
|
+
retryCount: 0,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
proc.on('close', (code) => {
|
|
863
|
+
clearTimeout(timer);
|
|
864
|
+
const durationMs = Date.now() - started;
|
|
865
|
+
const success = !timedOut && code === 0;
|
|
866
|
+
let summary = '';
|
|
867
|
+
let usage = null;
|
|
868
|
+
|
|
869
|
+
if (stdout) {
|
|
870
|
+
const parsed = safeJsonParse(stdout, null);
|
|
871
|
+
if (parsed) {
|
|
872
|
+
summary = parsed.result || parsed.text || stdout.slice(0, 2000);
|
|
873
|
+
usage = parsed.usage || null;
|
|
874
|
+
} else {
|
|
875
|
+
summary = stdout.slice(0, 2000);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
resolve({
|
|
880
|
+
success,
|
|
881
|
+
summary,
|
|
882
|
+
durationMs,
|
|
883
|
+
model,
|
|
884
|
+
usage,
|
|
885
|
+
errors: success ? [] : [stderr?.slice(0, 500) || (timedOut ? 'timeout' : 'claude agent failed')],
|
|
886
|
+
exitCode: code,
|
|
887
|
+
failureType: timedOut ? 'timeout' : code !== 0 ? 'agent_error' : null,
|
|
888
|
+
retryCount: 0,
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function runClaudeAnalysis(question, files, riskLevel) {
|
|
895
|
+
const prompt = [
|
|
896
|
+
`Analyze this independently — give your own recommendation, rationale, alternatives, risks, and confidence level.`,
|
|
897
|
+
'',
|
|
898
|
+
`Question: ${question}`,
|
|
899
|
+
files?.length ? `\nRelevant files: ${files.join(', ')}` : '',
|
|
900
|
+
riskLevel ? `Risk level: ${riskLevel}` : '',
|
|
901
|
+
'',
|
|
902
|
+
'Structure your response as:',
|
|
903
|
+
'1. Recommendation (what to do)',
|
|
904
|
+
'2. Rationale (why)',
|
|
905
|
+
'3. Alternatives considered',
|
|
906
|
+
'4. Risks and edge cases',
|
|
907
|
+
'5. Confidence level (low/medium/high)',
|
|
908
|
+
].filter(Boolean).join('\n');
|
|
909
|
+
|
|
910
|
+
const timeoutMs = 180_000;
|
|
911
|
+
|
|
912
|
+
return new Promise((resolve) => {
|
|
913
|
+
let proc;
|
|
914
|
+
try {
|
|
915
|
+
proc = spawn('claude', ['--model', 'opus', '--print', '-p', prompt], {
|
|
916
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
917
|
+
cwd: ROOT_DIR,
|
|
918
|
+
});
|
|
919
|
+
} catch (_) {
|
|
920
|
+
resolve(null);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
let stdout = '';
|
|
925
|
+
let timedOut = false;
|
|
926
|
+
|
|
927
|
+
const timer = setTimeout(() => {
|
|
928
|
+
timedOut = true;
|
|
929
|
+
proc.kill('SIGTERM');
|
|
930
|
+
setTimeout(() => {
|
|
931
|
+
try { proc.kill('SIGKILL'); } catch (_) {}
|
|
932
|
+
}, 5000);
|
|
933
|
+
}, timeoutMs);
|
|
934
|
+
|
|
935
|
+
proc.stdout.on('data', chunk => { stdout += chunk; });
|
|
936
|
+
proc.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
937
|
+
|
|
938
|
+
proc.on('close', (code) => {
|
|
939
|
+
clearTimeout(timer);
|
|
940
|
+
if (timedOut || code !== 0) {
|
|
941
|
+
resolve(null);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
resolve(stdout?.slice(0, 3000) || null);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function executeDualBrainThink(task) {
|
|
950
|
+
const files = combinedTaskFiles(task);
|
|
951
|
+
const started = Date.now();
|
|
952
|
+
|
|
953
|
+
// Round 1: GPT independent analysis
|
|
954
|
+
printProgress(`${statusIcon('running')} dual-brain R1: GPT analyzing "${trimText(task.description, 60)}"`);
|
|
955
|
+
const r1 = await dualThink({
|
|
956
|
+
question: task.description,
|
|
957
|
+
files,
|
|
958
|
+
round: 1,
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
if (r1.error || !r1.gpt) {
|
|
962
|
+
return {
|
|
963
|
+
success: false,
|
|
964
|
+
summary: `Dual-brain R1 failed: ${r1.error || 'no GPT response'}. Fallback: ${r1.fallback || 'single-brain'}`,
|
|
965
|
+
durationMs: Date.now() - started,
|
|
966
|
+
model: 'dual-brain',
|
|
967
|
+
usage: null,
|
|
968
|
+
errors: [r1.error || 'dual-brain-think R1 failed'],
|
|
969
|
+
exitCode: 1,
|
|
970
|
+
failureType: 'dual_brain_r1_failed',
|
|
971
|
+
retryCount: 0,
|
|
972
|
+
dualBrainResult: { round1: r1, round2: null },
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const gptAnalysis = r1.gpt.recommendation || r1.gpt.rebuttal || '';
|
|
977
|
+
|
|
978
|
+
// Round 1b: Claude's REAL independent analysis (not fabricated)
|
|
979
|
+
printProgress(`${statusIcon('running')} dual-brain R1b: Claude (Opus) analyzing independently`);
|
|
980
|
+
const claudeAnalysis = runClaudeAnalysis(task.description, files, task.riskLevel);
|
|
981
|
+
|
|
982
|
+
if (!claudeAnalysis) {
|
|
983
|
+
printProgress(`${statusIcon('failed')} Claude analysis unavailable — falling back to GPT-only`);
|
|
984
|
+
return {
|
|
985
|
+
success: true,
|
|
986
|
+
summary: `[single-brain fallback] GPT analysis:\n${trimText(gptAnalysis, 1200)}`,
|
|
987
|
+
durationMs: Date.now() - started,
|
|
988
|
+
model: 'gpt-5.5',
|
|
989
|
+
usage: r1.gpt?.tokens || null,
|
|
990
|
+
errors: ['Claude analysis unavailable — single-brain result'],
|
|
991
|
+
exitCode: 0,
|
|
992
|
+
failureType: null,
|
|
993
|
+
retryCount: 0,
|
|
994
|
+
dualBrainResult: { round1: r1, round2: null },
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Round 2: GPT responds to Claude's REAL perspective
|
|
999
|
+
printProgress(`${statusIcon('running')} dual-brain R2: GPT rebutting Claude's analysis`);
|
|
1000
|
+
const r2 = await dualThink({
|
|
1001
|
+
question: task.description,
|
|
1002
|
+
files,
|
|
1003
|
+
round: 2,
|
|
1004
|
+
claudePerspective: claudeAnalysis,
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
const durationMs = Date.now() - started;
|
|
1008
|
+
|
|
1009
|
+
const gptR1Text = (gptAnalysis || '').toLowerCase();
|
|
1010
|
+
const claudeText = (claudeAnalysis || '').toLowerCase();
|
|
1011
|
+
const gptR2Text = (r2.gpt?.rebuttal || '').toLowerCase();
|
|
1012
|
+
|
|
1013
|
+
const disagreementSignals = [
|
|
1014
|
+
/disagree/i, /however/i, /on the other hand/i, /I would push back/i,
|
|
1015
|
+
/alternative approach/i, /concern/i, /risk/i, /not recommended/i,
|
|
1016
|
+
];
|
|
1017
|
+
const hasDisagreement = disagreementSignals.some(re => re.test(gptR2Text));
|
|
1018
|
+
|
|
1019
|
+
const combinedSummary = [
|
|
1020
|
+
'=== DUAL-BRAIN THINK RESULT ===',
|
|
1021
|
+
`Question: ${task.description}`,
|
|
1022
|
+
'',
|
|
1023
|
+
'--- GPT Round 1 (independent) ---',
|
|
1024
|
+
trimText(gptAnalysis, 800),
|
|
1025
|
+
'',
|
|
1026
|
+
'--- Claude Round 1 (independent) ---',
|
|
1027
|
+
trimText(claudeAnalysis, 800),
|
|
1028
|
+
'',
|
|
1029
|
+
'--- GPT Round 2 (rebuttal to Claude) ---',
|
|
1030
|
+
r2.gpt ? trimText(r2.gpt.rebuttal || '', 800) : '(R2 unavailable)',
|
|
1031
|
+
'',
|
|
1032
|
+
'--- Synthesis ---',
|
|
1033
|
+
'Both models analyzed independently. Agreements are high-confidence.',
|
|
1034
|
+
hasDisagreement
|
|
1035
|
+
? 'DISAGREEMENT DETECTED — models diverged on approach. Review both perspectives before proceeding.'
|
|
1036
|
+
: 'Models aligned — high confidence in shared recommendation.',
|
|
1037
|
+
].join('\n');
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
success: true,
|
|
1041
|
+
summary: combinedSummary,
|
|
1042
|
+
durationMs,
|
|
1043
|
+
model: 'dual-brain',
|
|
1044
|
+
usage: {
|
|
1045
|
+
input_tokens: (r1.gpt?.tokens?.input_tokens || 0) + (r2.gpt?.tokens?.input_tokens || 0),
|
|
1046
|
+
output_tokens: (r1.gpt?.tokens?.output_tokens || 0) + (r2.gpt?.tokens?.output_tokens || 0),
|
|
1047
|
+
},
|
|
1048
|
+
errors: [],
|
|
1049
|
+
exitCode: 0,
|
|
1050
|
+
failureType: null,
|
|
1051
|
+
retryCount: 0,
|
|
1052
|
+
dualBrainResult: { round1: r1, claudeAnalysis: trimText(claudeAnalysis, 2000), round2: r2 },
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function executeTask(manifest, wave, task) {
|
|
1057
|
+
task.status = 'running';
|
|
1058
|
+
task.startedAt = isoNow();
|
|
1059
|
+
saveManifest(refreshCounts(manifest));
|
|
1060
|
+
|
|
1061
|
+
const dualBrainLabel = task.dualBrain ? ' [dual-brain]' : '';
|
|
1062
|
+
const providerLabel = `${task.provider}:${task.model}`;
|
|
1063
|
+
printProgress(`${statusIcon('running')} ${wave.waveId} ${task.taskId} -> ${providerLabel}${dualBrainLabel}`);
|
|
1064
|
+
|
|
1065
|
+
const started = Date.now();
|
|
1066
|
+
|
|
1067
|
+
let result;
|
|
1068
|
+
if (task.dualBrain && task.tier === 'think') {
|
|
1069
|
+
result = await executeDualBrainThink(task);
|
|
1070
|
+
} else if (task.provider === 'claude') {
|
|
1071
|
+
result = await executeClaudeAgent(task);
|
|
1072
|
+
} else {
|
|
1073
|
+
result = await dispatchGptTask(buildDispatchPayload(manifest, wave, task));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const durationMs = Date.now() - started;
|
|
1077
|
+
|
|
1078
|
+
task.durationMs = durationMs;
|
|
1079
|
+
task.retryCount = result.retryCount || 0;
|
|
1080
|
+
task.completedAt = isoNow();
|
|
1081
|
+
task.result = {
|
|
1082
|
+
success: !!result.success,
|
|
1083
|
+
summary: compressResult(result),
|
|
1084
|
+
rawSummary: trimText(result.summary || '', 2000),
|
|
1085
|
+
error: trimText(result.error || (result.errors || []).join('; '), 800),
|
|
1086
|
+
usage: result.usage || null,
|
|
1087
|
+
exitCode: result.exitCode ?? null,
|
|
1088
|
+
failureType: result.failureType || null,
|
|
1089
|
+
};
|
|
1090
|
+
task.status = result.success ? 'completed' : 'failed';
|
|
1091
|
+
|
|
1092
|
+
if (task.tier !== 'execute' && task.owns?.length > 0) {
|
|
1093
|
+
printProgress(`WARNING: think/search task ${task.taskId} had owned files — this should not happen. Clearing owns.`);
|
|
1094
|
+
task.owns = [];
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (task._decisionId) {
|
|
1098
|
+
recordOutcome(task._decisionId, {
|
|
1099
|
+
actual_duration_ms: durationMs,
|
|
1100
|
+
codex_startup_ms: result.startupMs || null,
|
|
1101
|
+
success: !!result.success,
|
|
1102
|
+
retries: result.retryCount || 0,
|
|
1103
|
+
actual_input_tokens: result.usage?.input_tokens || null,
|
|
1104
|
+
actual_output_tokens: result.usage?.output_tokens || null,
|
|
1105
|
+
files_changed: task.owns || [],
|
|
1106
|
+
files_read: task.reads || [],
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
printProgress(`${statusIcon(task.status)} ${wave.waveId} ${task.taskId} ${task.status} ${(durationMs / 1000).toFixed(1)}s`);
|
|
1111
|
+
saveManifest(refreshCounts(manifest));
|
|
1112
|
+
return task;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function executeWave(manifest, waveIdx) {
|
|
1116
|
+
const wave = manifest.waves[waveIdx];
|
|
1117
|
+
if (!wave) throw new Error(`Wave not found at index ${waveIdx}`);
|
|
1118
|
+
if (wave.status === 'completed') return wave;
|
|
1119
|
+
|
|
1120
|
+
wave.status = 'running';
|
|
1121
|
+
saveManifest(refreshCounts(manifest));
|
|
1122
|
+
printProgress(`\n${statusIcon('running')} Starting ${wave.waveId} (${wave.tasks.length} task${wave.tasks.length === 1 ? '' : 's'})`);
|
|
1123
|
+
|
|
1124
|
+
const runnable = wave.tasks.filter(task => task.status !== 'completed');
|
|
1125
|
+
if (runnable.length === 0) {
|
|
1126
|
+
wave.status = 'completed';
|
|
1127
|
+
saveManifest(refreshCounts(manifest));
|
|
1128
|
+
return wave;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const failures = [];
|
|
1132
|
+
await Promise.all(runnable.slice(0, MAX_WAVE_PARALLELISM).map(async task => {
|
|
1133
|
+
try {
|
|
1134
|
+
await executeTask(manifest, wave, task);
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
task.status = 'failed';
|
|
1137
|
+
task.completedAt = isoNow();
|
|
1138
|
+
task.result = {
|
|
1139
|
+
success: false,
|
|
1140
|
+
summary: trimText(error.message, 500),
|
|
1141
|
+
rawSummary: '',
|
|
1142
|
+
error: trimText(error.stack || error.message, 800),
|
|
1143
|
+
usage: null,
|
|
1144
|
+
exitCode: null,
|
|
1145
|
+
failureType: 'orchestrator_error',
|
|
1146
|
+
};
|
|
1147
|
+
failures.push(error);
|
|
1148
|
+
saveManifest(refreshCounts(manifest));
|
|
1149
|
+
}
|
|
1150
|
+
}));
|
|
1151
|
+
|
|
1152
|
+
const testRun = runWaveTests(manifest, wave);
|
|
1153
|
+
if (testRun) {
|
|
1154
|
+
wave.testRun = testRun;
|
|
1155
|
+
if (testRun.status === 'failed') {
|
|
1156
|
+
failures.push(new Error(`Tests failed for ${wave.waveId}`));
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
wave.status = failures.length > 0 || wave.tasks.some(task => task.status === 'failed')
|
|
1161
|
+
? 'failed'
|
|
1162
|
+
: 'completed';
|
|
1163
|
+
saveManifest(refreshCounts(manifest));
|
|
1164
|
+
return wave;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function buildManifest(plan) {
|
|
1168
|
+
const ownership = buildOwnershipMap(plan.tasks);
|
|
1169
|
+
const waves = planWaves(plan.tasks, ownership);
|
|
1170
|
+
const routedWaves = waves.map(wave => ({
|
|
1171
|
+
...wave,
|
|
1172
|
+
tasks: routeTasks(wave.tasks),
|
|
1173
|
+
}));
|
|
1174
|
+
const manifest = refreshCounts({
|
|
1175
|
+
manifestId: makeManifestId(),
|
|
1176
|
+
utterance: plan.utterance,
|
|
1177
|
+
createdAt: plan.createdAt,
|
|
1178
|
+
status: 'planned',
|
|
1179
|
+
riskLevel: plan.riskLevel,
|
|
1180
|
+
balanceSnapshot: getBalanceSnapshot(),
|
|
1181
|
+
waves: routedWaves,
|
|
1182
|
+
completedWaves: 0,
|
|
1183
|
+
totalWaves: routedWaves.length,
|
|
1184
|
+
totalTasks: 0,
|
|
1185
|
+
completedTasks: 0,
|
|
1186
|
+
failedTasks: 0,
|
|
1187
|
+
});
|
|
1188
|
+
return manifest;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function getResumeWaveIndex(manifest) {
|
|
1192
|
+
return manifest.waves.findIndex(wave => wave.status !== 'completed');
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function verifyResumeState(manifest) {
|
|
1196
|
+
if (!gitInsideRepo()) return;
|
|
1197
|
+
const currentHead = gitHead();
|
|
1198
|
+
const lastCompletedWave = [...manifest.waves].reverse().find(wave => wave.status === 'completed' && wave.checkpoint?.commitHash);
|
|
1199
|
+
if (!lastCompletedWave) return;
|
|
1200
|
+
if (currentHead !== lastCompletedWave.checkpoint.commitHash) {
|
|
1201
|
+
throw new Error(
|
|
1202
|
+
`Git HEAD ${currentHead || 'unknown'} does not match last completed checkpoint ${lastCompletedWave.checkpoint.commitHash}. Restore that checkpoint before resuming.`,
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
async function orchestrate(utterance, opts = {}) {
|
|
1208
|
+
ensureStateDirs();
|
|
1209
|
+
|
|
1210
|
+
if (opts.show) {
|
|
1211
|
+
const manifest = refreshCounts(loadManifest(opts.show));
|
|
1212
|
+
printDispatchTable(manifest);
|
|
1213
|
+
printFinalTable(manifest);
|
|
1214
|
+
return manifest;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
let manifest;
|
|
1218
|
+
if (opts.resume) {
|
|
1219
|
+
manifest = refreshCounts(loadManifest(opts.resume));
|
|
1220
|
+
verifyResumeState(manifest);
|
|
1221
|
+
manifest.status = 'running';
|
|
1222
|
+
manifest.balanceSnapshot = getBalanceSnapshot();
|
|
1223
|
+
saveManifest(manifest);
|
|
1224
|
+
} else {
|
|
1225
|
+
const plan = decomposeIntent(utterance);
|
|
1226
|
+
manifest = buildManifest(plan);
|
|
1227
|
+
saveManifest(manifest);
|
|
1228
|
+
printDispatchTable(manifest);
|
|
1229
|
+
|
|
1230
|
+
// Pre-dispatch spend estimate
|
|
1231
|
+
const allTasks = manifest.waves.flatMap(w => w.tasks);
|
|
1232
|
+
const costEstimate = estimateWaveCost(allTasks);
|
|
1233
|
+
if (costEstimate.impact.claude || costEstimate.impact.openai) {
|
|
1234
|
+
console.log('\n--- Pre-dispatch cost estimate ---');
|
|
1235
|
+
for (const [prov, est] of Object.entries(costEstimate.impact)) {
|
|
1236
|
+
const label = prov === 'claude' ? 'Claude' : 'OpenAI';
|
|
1237
|
+
console.log(` ${label}: ~${(est.estimatedTokens / 1000).toFixed(1)}K tokens (${est.pctOfBudget}% of 5hr budget, ${(est.remaining / 1000).toFixed(1)}K remaining)`);
|
|
1238
|
+
if (est.wouldExceed) {
|
|
1239
|
+
console.log(` WARNING: Estimated usage EXCEEDS remaining ${label} budget!`);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
console.log('');
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (opts.dryRun) {
|
|
1246
|
+
manifest.status = 'dry-run';
|
|
1247
|
+
saveManifest(refreshCounts(manifest));
|
|
1248
|
+
return manifest;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Auto-confirm in non-interactive mode (--yes flag or piped input)
|
|
1252
|
+
if (!opts.confirmed && !opts.yes) {
|
|
1253
|
+
manifest.status = 'awaiting-confirmation';
|
|
1254
|
+
manifest.costEstimate = costEstimate;
|
|
1255
|
+
saveManifest(refreshCounts(manifest));
|
|
1256
|
+
console.log(`Dispatch plan ready. Execute with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
1257
|
+
console.log('Or pass --yes to skip confirmation.');
|
|
1258
|
+
return manifest;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
try {
|
|
1263
|
+
manifest.status = 'running';
|
|
1264
|
+
saveManifest(refreshCounts(manifest));
|
|
1265
|
+
|
|
1266
|
+
const startWaveIdx = Math.max(0, getResumeWaveIndex(manifest));
|
|
1267
|
+
if (startWaveIdx >= manifest.waves.length) {
|
|
1268
|
+
manifest.status = 'completed';
|
|
1269
|
+
saveManifest(refreshCounts(manifest));
|
|
1270
|
+
printFinalTable(manifest);
|
|
1271
|
+
return manifest;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
for (let i = startWaveIdx; i < manifest.waves.length; i++) {
|
|
1275
|
+
const wave = manifest.waves[i];
|
|
1276
|
+
if (wave.status === 'completed') continue;
|
|
1277
|
+
|
|
1278
|
+
// Per-wave spend check
|
|
1279
|
+
const waveCost = estimateWaveCost(wave.tasks);
|
|
1280
|
+
for (const [prov, est] of Object.entries(waveCost.impact)) {
|
|
1281
|
+
if (est.wouldExceed) {
|
|
1282
|
+
console.error(`[SPEND CAP] Wave ${wave.waveId} would exceed ${prov} budget (${(est.estimatedTokens / 1000).toFixed(1)}K estimated, ${(est.remaining / 1000).toFixed(1)}K remaining). Pausing.`);
|
|
1283
|
+
manifest.status = 'paused';
|
|
1284
|
+
manifest.pauseReason = `spend_cap:${prov}`;
|
|
1285
|
+
saveManifest(refreshCounts(manifest));
|
|
1286
|
+
console.error(`Resume with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
1287
|
+
return manifest;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
wave.checkpoint = gitCheckpoint(manifest, wave.waveId);
|
|
1292
|
+
saveManifest(refreshCounts(manifest));
|
|
1293
|
+
await executeWave(manifest, i);
|
|
1294
|
+
|
|
1295
|
+
if (wave.status === 'failed') {
|
|
1296
|
+
manifest.status = 'paused';
|
|
1297
|
+
saveManifest(refreshCounts(manifest));
|
|
1298
|
+
console.error(`Paused after ${wave.waveId}. Resume with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
1299
|
+
printFinalTable(manifest);
|
|
1300
|
+
return manifest;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
manifest.status = 'completed';
|
|
1305
|
+
saveManifest(refreshCounts(manifest));
|
|
1306
|
+
|
|
1307
|
+
const totalDuration = manifest.waves.reduce((sum, w) =>
|
|
1308
|
+
sum + w.tasks.reduce((ts, t) => ts + (t.durationMs || 0), 0), 0);
|
|
1309
|
+
const totalTokens = manifest.waves.reduce((sum, w) =>
|
|
1310
|
+
sum + w.tasks.reduce((ts, t) => ts + (t.result?.usage?.input_tokens || 0) + (t.result?.usage?.output_tokens || 0), 0), 0);
|
|
1311
|
+
const providerBreakdown = {};
|
|
1312
|
+
for (const w of manifest.waves) {
|
|
1313
|
+
for (const t of w.tasks) {
|
|
1314
|
+
const p = t.provider || 'unknown';
|
|
1315
|
+
if (!providerBreakdown[p]) providerBreakdown[p] = { tasks: 0, tokens: 0, durationMs: 0 };
|
|
1316
|
+
providerBreakdown[p].tasks++;
|
|
1317
|
+
providerBreakdown[p].tokens += (t.result?.usage?.input_tokens || 0) + (t.result?.usage?.output_tokens || 0);
|
|
1318
|
+
providerBreakdown[p].durationMs += t.durationMs || 0;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
console.log('\n--- Session Cost Summary ---');
|
|
1322
|
+
console.log(`Total: ${(totalDuration / 1000).toFixed(1)}s wall time, ${(totalTokens / 1000).toFixed(1)}K tokens`);
|
|
1323
|
+
for (const [prov, stats] of Object.entries(providerBreakdown)) {
|
|
1324
|
+
console.log(` ${prov}: ${stats.tasks} tasks, ${(stats.tokens / 1000).toFixed(1)}K tokens, ${(stats.durationMs / 1000).toFixed(1)}s`);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
printFinalTable(manifest);
|
|
1328
|
+
return manifest;
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
manifest.status = 'paused';
|
|
1331
|
+
manifest.lastError = trimText(error.stack || error.message, 1200);
|
|
1332
|
+
saveManifest(refreshCounts(manifest));
|
|
1333
|
+
console.error(trimText(error.message, 400));
|
|
1334
|
+
console.error(`Resume with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
1335
|
+
return manifest;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function parseCli(argv) {
|
|
1340
|
+
const args = [...argv];
|
|
1341
|
+
const hasYes = args.includes('--yes') || args.includes('-y');
|
|
1342
|
+
const filtered = args.filter(a => a !== '--yes' && a !== '-y');
|
|
1343
|
+
|
|
1344
|
+
if (filtered[0] === '--resume') {
|
|
1345
|
+
return { resume: filtered[1], confirmed: true };
|
|
1346
|
+
}
|
|
1347
|
+
if (filtered[0] === '--dry-run') {
|
|
1348
|
+
return { dryRun: true, utterance: filtered.slice(1).join(' ') };
|
|
1349
|
+
}
|
|
1350
|
+
if (filtered[0] === '--show') {
|
|
1351
|
+
return { show: filtered[1] };
|
|
1352
|
+
}
|
|
1353
|
+
return { utterance: filtered.join(' '), yes: hasYes };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async function main() {
|
|
1357
|
+
const args = parseCli(process.argv.slice(2));
|
|
1358
|
+
|
|
1359
|
+
if (args.show) {
|
|
1360
|
+
await orchestrate(null, { show: args.show });
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
if (args.resume) {
|
|
1364
|
+
await orchestrate(null, { resume: args.resume });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (args.dryRun) {
|
|
1368
|
+
if (!args.utterance) {
|
|
1369
|
+
console.error('Usage: node hooks/wave-orchestrator.mjs --dry-run "utterance"');
|
|
1370
|
+
process.exit(1);
|
|
1371
|
+
}
|
|
1372
|
+
await orchestrate(args.utterance, { dryRun: true });
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (!args.utterance) {
|
|
1376
|
+
console.error('Usage: node hooks/wave-orchestrator.mjs "utterance" | --resume ID | --dry-run "utterance" | --show ID');
|
|
1377
|
+
process.exit(1);
|
|
1378
|
+
}
|
|
1379
|
+
await orchestrate(args.utterance);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
export {
|
|
1383
|
+
orchestrate,
|
|
1384
|
+
decomposeIntent,
|
|
1385
|
+
planWaves,
|
|
1386
|
+
buildOwnershipMap,
|
|
1387
|
+
compressResult,
|
|
1388
|
+
printDispatchTable,
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
1392
|
+
if (isMain) {
|
|
1393
|
+
main().catch(error => {
|
|
1394
|
+
console.error(trimText(error.stack || error.message, 1200));
|
|
1395
|
+
process.exit(1);
|
|
1396
|
+
});
|
|
1397
|
+
}
|