dual-brain 5.0.0 → 5.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/CLAUDE.md +32 -1
- package/hooks/budget-balancer.mjs +162 -109
- package/hooks/control-panel.mjs +61 -0
- package/hooks/health-check.mjs +6 -2
- package/hooks/wave-orchestrator.mjs +970 -0
- package/install.mjs +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,970 @@
|
|
|
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 } from './budget-balancer.mjs';
|
|
7
|
+
import { recordDecision, recordOutcome } from './decision-ledger.mjs';
|
|
8
|
+
import { spawnSync } from 'child_process';
|
|
9
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ROOT_DIR = join(__dirname, '..');
|
|
15
|
+
const STATE_DIR = join(ROOT_DIR, '.dualbrain');
|
|
16
|
+
const MANIFEST_DIR = join(STATE_DIR, 'manifests');
|
|
17
|
+
const CHECKPOINT_DIR = join(STATE_DIR, 'checkpoints');
|
|
18
|
+
const MAX_WAVE_PARALLELISM = 4;
|
|
19
|
+
const LEVEL_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
20
|
+
const STATUS_ICON = {
|
|
21
|
+
pending: '○',
|
|
22
|
+
running: '◐',
|
|
23
|
+
completed: '✓',
|
|
24
|
+
failed: '✕',
|
|
25
|
+
paused: '⏸',
|
|
26
|
+
};
|
|
27
|
+
const ASCII_STATUS_ICON = {
|
|
28
|
+
pending: 'o',
|
|
29
|
+
running: '>',
|
|
30
|
+
completed: 'v',
|
|
31
|
+
failed: 'x',
|
|
32
|
+
paused: '=',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function ensureStateDirs() {
|
|
36
|
+
mkdirSync(MANIFEST_DIR, { recursive: true });
|
|
37
|
+
mkdirSync(CHECKPOINT_DIR, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isoNow() {
|
|
41
|
+
return new Date().toISOString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeManifestId() {
|
|
45
|
+
return `mf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function safeJsonParse(raw, fallback = null) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
} catch {
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function uniq(items) {
|
|
57
|
+
return [...new Set((items || []).filter(Boolean))];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function trimText(value, max = 120) {
|
|
61
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
62
|
+
if (text.length <= max) return text;
|
|
63
|
+
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function slugify(value) {
|
|
67
|
+
return String(value || 'task')
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
70
|
+
.replace(/^-+|-+$/g, '')
|
|
71
|
+
.slice(0, 32) || 'task';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function highestRisk(levels) {
|
|
75
|
+
return (levels || []).reduce((current, level) => {
|
|
76
|
+
return (LEVEL_ORDER[level] ?? 0) > (LEVEL_ORDER[current] ?? 0) ? level : current;
|
|
77
|
+
}, 'low');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function statusIcon(status) {
|
|
81
|
+
const icons = process.env.NO_COLOR ? ASCII_STATUS_ICON : STATUS_ICON;
|
|
82
|
+
return icons[status] || icons.pending;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function saveManifest(manifest) {
|
|
86
|
+
ensureStateDirs();
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(MANIFEST_DIR, `${manifest.manifestId}.json`),
|
|
89
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
90
|
+
'utf8',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function loadManifest(manifestId) {
|
|
95
|
+
const path = join(MANIFEST_DIR, `${manifestId}.json`);
|
|
96
|
+
if (!existsSync(path)) {
|
|
97
|
+
throw new Error(`Manifest not found: ${manifestId}`);
|
|
98
|
+
}
|
|
99
|
+
const manifest = safeJsonParse(readFileSync(path, 'utf8'), null);
|
|
100
|
+
if (!manifest) {
|
|
101
|
+
throw new Error(`Manifest is unreadable: ${manifestId}`);
|
|
102
|
+
}
|
|
103
|
+
return manifest;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function flattenTasks(manifest) {
|
|
107
|
+
return manifest.waves.flatMap(wave => wave.tasks);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function combinedTaskFiles(task) {
|
|
111
|
+
return uniq([...(task.owns || []), ...(task.reads || [])]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function refreshCounts(manifest) {
|
|
115
|
+
const tasks = flattenTasks(manifest);
|
|
116
|
+
manifest.totalWaves = manifest.waves.length;
|
|
117
|
+
manifest.totalTasks = tasks.length;
|
|
118
|
+
manifest.completedWaves = manifest.waves.filter(w => w.status === 'completed').length;
|
|
119
|
+
manifest.completedTasks = tasks.filter(t => t.status === 'completed').length;
|
|
120
|
+
manifest.failedTasks = tasks.filter(t => t.status === 'failed').length;
|
|
121
|
+
return manifest;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getBalanceSnapshot() {
|
|
125
|
+
const status = getProviderStatus();
|
|
126
|
+
const recommendation = chooseProvider({
|
|
127
|
+
tier: 'execute',
|
|
128
|
+
estimatedDurationMs: 300_000,
|
|
129
|
+
contextCoupling: 'medium',
|
|
130
|
+
isolation: 'medium',
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
claude: {
|
|
134
|
+
think: status.claude?.think || null,
|
|
135
|
+
execute: status.claude?.execute || null,
|
|
136
|
+
},
|
|
137
|
+
openai: {
|
|
138
|
+
think: status.openai?.think || null,
|
|
139
|
+
execute: status.openai?.execute || null,
|
|
140
|
+
},
|
|
141
|
+
recommendation: `${recommendation.provider}:${recommendation.model} (${recommendation.reason})`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function padCell(value, width) {
|
|
146
|
+
const text = String(value ?? '');
|
|
147
|
+
return text + ' '.repeat(Math.max(0, width - text.length));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderTable(headers, rows) {
|
|
151
|
+
const widths = headers.map((header, idx) =>
|
|
152
|
+
Math.max(
|
|
153
|
+
header.length,
|
|
154
|
+
...rows.map(row => String(row[idx] ?? '').length),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
const top = `┌${widths.map(w => '─'.repeat(w + 2)).join('┬')}┐`;
|
|
158
|
+
const mid = `├${widths.map(w => '─'.repeat(w + 2)).join('┼')}┤`;
|
|
159
|
+
const bot = `└${widths.map(w => '─'.repeat(w + 2)).join('┴')}┘`;
|
|
160
|
+
const line = cells => `│ ${cells.map((cell, idx) => padCell(cell, widths[idx])).join(' │ ')} │`;
|
|
161
|
+
return [top, line(headers), mid, ...rows.map(line), bot].join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function gitInsideRepo() {
|
|
165
|
+
const proc = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
166
|
+
cwd: ROOT_DIR,
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
});
|
|
169
|
+
return proc.status === 0 && proc.stdout.trim() === 'true';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function gitHead() {
|
|
173
|
+
const proc = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
174
|
+
cwd: ROOT_DIR,
|
|
175
|
+
encoding: 'utf8',
|
|
176
|
+
});
|
|
177
|
+
return proc.status === 0 ? proc.stdout.trim() : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function runCommand(command, args, cwd = ROOT_DIR) {
|
|
181
|
+
const proc = spawnSync(command, args, {
|
|
182
|
+
cwd,
|
|
183
|
+
encoding: 'utf8',
|
|
184
|
+
});
|
|
185
|
+
return {
|
|
186
|
+
status: proc.status,
|
|
187
|
+
stdout: proc.stdout || '',
|
|
188
|
+
stderr: proc.stderr || '',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function printProgress(message) {
|
|
193
|
+
process.stdout.write(`${message}\n`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeTask(rawTask, index) {
|
|
197
|
+
const description = trimText(rawTask.description || rawTask.title || `Task ${index + 1}`, 220);
|
|
198
|
+
const pathSeed = `${rawTask.title || ''} ${rawTask.description || ''}`;
|
|
199
|
+
const paths = uniq([...(rawTask.files || []), ...extractPaths(pathSeed)]);
|
|
200
|
+
const risk = classifyRisk(paths);
|
|
201
|
+
return {
|
|
202
|
+
taskId: rawTask.taskId || `task-${index + 1}-${slugify(rawTask.title || description)}`,
|
|
203
|
+
description,
|
|
204
|
+
title: rawTask.title || description,
|
|
205
|
+
tier: rawTask.tier || 'execute',
|
|
206
|
+
topic: rawTask.topic || rawTask.title || description,
|
|
207
|
+
dependencies: rawTask.dependencies || [],
|
|
208
|
+
files: paths,
|
|
209
|
+
riskLevel: highestRisk([rawTask.risk, risk.level]),
|
|
210
|
+
riskReason: rawTask.reason || risk.reason || 'no risk rationale',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function decomposeIntent(utterance) {
|
|
215
|
+
if (!utterance || !String(utterance).trim()) {
|
|
216
|
+
throw new Error('Utterance is required unless resuming an existing manifest.');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const proc = spawnSync(
|
|
220
|
+
'node',
|
|
221
|
+
[join(__dirname, 'vibe-router.mjs'), utterance],
|
|
222
|
+
{ cwd: ROOT_DIR, encoding: 'utf8' },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (proc.status !== 0) {
|
|
226
|
+
throw new Error(trimText(proc.stderr || proc.stdout || 'vibe-router failed', 300));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const parsed = safeJsonParse(proc.stdout, null);
|
|
230
|
+
if (!parsed || !Array.isArray(parsed.tasks)) {
|
|
231
|
+
throw new Error('vibe-router returned invalid JSON.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const tasks = parsed.tasks.map(normalizeTask);
|
|
235
|
+
return {
|
|
236
|
+
utterance,
|
|
237
|
+
createdAt: isoNow(),
|
|
238
|
+
status: 'planned',
|
|
239
|
+
riskLevel: highestRisk(tasks.map(task => task.riskLevel)),
|
|
240
|
+
tasks,
|
|
241
|
+
qualityGates: parsed.quality_gates || [],
|
|
242
|
+
waveRecommendation: parsed.wave_recommendation || 'sequential',
|
|
243
|
+
summary: parsed.summary || '',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function pathsConflict(a, b) {
|
|
248
|
+
if (!a || !b) return false;
|
|
249
|
+
if (a === b) return true;
|
|
250
|
+
if (a.startsWith(`${b}/`) || b.startsWith(`${a}/`)) return true;
|
|
251
|
+
const aDir = a.includes('/') ? a.slice(0, a.lastIndexOf('/')) : a;
|
|
252
|
+
const bDir = b.includes('/') ? b.slice(0, b.lastIndexOf('/')) : b;
|
|
253
|
+
return Boolean(aDir && aDir === bDir);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildOwnershipMap(tasks) {
|
|
257
|
+
const byTask = {};
|
|
258
|
+
const fileOwners = {};
|
|
259
|
+
const conflicts = [];
|
|
260
|
+
|
|
261
|
+
for (const task of tasks) {
|
|
262
|
+
const owns = task.tier === 'execute' ? uniq(task.files) : [];
|
|
263
|
+
const reads = uniq(task.files);
|
|
264
|
+
byTask[task.taskId] = {
|
|
265
|
+
owns,
|
|
266
|
+
reads,
|
|
267
|
+
conflicts: [],
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
for (const file of owns) {
|
|
271
|
+
if (!fileOwners[file]) fileOwners[file] = [];
|
|
272
|
+
fileOwners[file].push(task.taskId);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
277
|
+
for (let j = i + 1; j < tasks.length; j++) {
|
|
278
|
+
const left = tasks[i];
|
|
279
|
+
const right = tasks[j];
|
|
280
|
+
const leftOwns = byTask[left.taskId].owns;
|
|
281
|
+
const rightOwns = byTask[right.taskId].owns;
|
|
282
|
+
const overlap = [];
|
|
283
|
+
|
|
284
|
+
for (const leftPath of leftOwns) {
|
|
285
|
+
for (const rightPath of rightOwns) {
|
|
286
|
+
if (pathsConflict(leftPath, rightPath)) {
|
|
287
|
+
overlap.push(leftPath === rightPath ? leftPath : `${leftPath} ~ ${rightPath}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (overlap.length > 0) {
|
|
293
|
+
const conflict = { taskIds: [left.taskId, right.taskId], paths: uniq(overlap) };
|
|
294
|
+
conflicts.push(conflict);
|
|
295
|
+
byTask[left.taskId].conflicts.push(conflict);
|
|
296
|
+
byTask[right.taskId].conflicts.push(conflict);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { byTask, fileOwners, conflicts };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function buildDependencyMap(tasks) {
|
|
305
|
+
const ordered = resolveDependencies(tasks.map(task => ({
|
|
306
|
+
title: task.title,
|
|
307
|
+
description: task.description,
|
|
308
|
+
tier: task.tier,
|
|
309
|
+
risk: task.riskLevel,
|
|
310
|
+
files: task.files,
|
|
311
|
+
dependencies: task.dependencies,
|
|
312
|
+
topic: task.topic,
|
|
313
|
+
})));
|
|
314
|
+
|
|
315
|
+
const orderedTasks = [];
|
|
316
|
+
const dependencies = new Map();
|
|
317
|
+
const usedTaskIds = new Set();
|
|
318
|
+
|
|
319
|
+
for (const item of ordered) {
|
|
320
|
+
const task = tasks.find(candidate =>
|
|
321
|
+
!usedTaskIds.has(candidate.taskId) &&
|
|
322
|
+
candidate.title === item.title &&
|
|
323
|
+
candidate.description === item.description,
|
|
324
|
+
);
|
|
325
|
+
if (!task) continue;
|
|
326
|
+
usedTaskIds.add(task.taskId);
|
|
327
|
+
orderedTasks.push(task);
|
|
328
|
+
|
|
329
|
+
const deps = [];
|
|
330
|
+
if (item.dependencies && item.dependencies !== '—') {
|
|
331
|
+
for (const label of String(item.dependencies).split(',').map(entry => entry.trim())) {
|
|
332
|
+
const match = label.match(/^Task\s+(\d+)$/i);
|
|
333
|
+
if (!match) continue;
|
|
334
|
+
const depIdx = Number(match[1]) - 1;
|
|
335
|
+
if (!ordered[depIdx]) continue;
|
|
336
|
+
const dep = tasks.find(candidate =>
|
|
337
|
+
candidate.title === ordered[depIdx].title &&
|
|
338
|
+
candidate.description === ordered[depIdx].description,
|
|
339
|
+
);
|
|
340
|
+
if (dep) deps.push(dep.taskId);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
dependencies.set(task.taskId, uniq([...deps, ...(task.dependencies || []).filter(Boolean)]));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const task of tasks) {
|
|
347
|
+
if (!usedTaskIds.has(task.taskId)) {
|
|
348
|
+
orderedTasks.push(task);
|
|
349
|
+
dependencies.set(task.taskId, uniq(task.dependencies || []));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { orderedTasks, dependencies };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function initManifestTask(baseTask, waveId, ownership) {
|
|
357
|
+
return {
|
|
358
|
+
taskId: baseTask.taskId,
|
|
359
|
+
description: baseTask.description,
|
|
360
|
+
provider: null,
|
|
361
|
+
model: null,
|
|
362
|
+
tier: baseTask.tier,
|
|
363
|
+
effort: null,
|
|
364
|
+
agentType: null,
|
|
365
|
+
sandbox: null,
|
|
366
|
+
reason: baseTask.riskReason,
|
|
367
|
+
owns: ownership.byTask[baseTask.taskId]?.owns || [],
|
|
368
|
+
reads: ownership.byTask[baseTask.taskId]?.reads || [],
|
|
369
|
+
status: 'pending',
|
|
370
|
+
result: null,
|
|
371
|
+
startedAt: null,
|
|
372
|
+
completedAt: null,
|
|
373
|
+
retryCount: 0,
|
|
374
|
+
durationMs: null,
|
|
375
|
+
riskLevel: baseTask.riskLevel,
|
|
376
|
+
dependencies: baseTask.dependencies || [],
|
|
377
|
+
topic: baseTask.topic,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function canAddTaskToWave(candidate, waveTasks, ownership, dependencies) {
|
|
382
|
+
if (waveTasks.length >= MAX_WAVE_PARALLELISM) return false;
|
|
383
|
+
|
|
384
|
+
const candidateDeps = dependencies.get(candidate.taskId) || [];
|
|
385
|
+
if (candidateDeps.some(depId => waveTasks.some(task => task.taskId === depId))) return false;
|
|
386
|
+
|
|
387
|
+
const candidateOwns = ownership.byTask[candidate.taskId]?.owns || [];
|
|
388
|
+
for (const existing of waveTasks) {
|
|
389
|
+
const existingOwns = ownership.byTask[existing.taskId]?.owns || [];
|
|
390
|
+
for (const left of candidateOwns) {
|
|
391
|
+
for (const right of existingOwns) {
|
|
392
|
+
if (pathsConflict(left, right)) return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function planWaves(tasks, ownership) {
|
|
401
|
+
const { orderedTasks, dependencies } = buildDependencyMap(tasks);
|
|
402
|
+
const taskIndex = new Map(orderedTasks.map((task, idx) => [task.taskId, idx]));
|
|
403
|
+
const pending = [...orderedTasks];
|
|
404
|
+
const completed = new Set();
|
|
405
|
+
const groups = [];
|
|
406
|
+
|
|
407
|
+
while (pending.length > 0) {
|
|
408
|
+
const ready = pending.filter(task =>
|
|
409
|
+
(dependencies.get(task.taskId) || []).every(depId => completed.has(depId) || !taskIndex.has(depId)),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const bucket = [];
|
|
413
|
+
for (const task of ready) {
|
|
414
|
+
if (canAddTaskToWave(task, bucket, ownership, dependencies)) {
|
|
415
|
+
bucket.push(task);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (bucket.length === 0) {
|
|
420
|
+
bucket.push(pending[0]);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
groups.push(bucket);
|
|
424
|
+
for (const task of bucket) {
|
|
425
|
+
completed.add(task.taskId);
|
|
426
|
+
const idx = pending.findIndex(candidate => candidate.taskId === task.taskId);
|
|
427
|
+
if (idx >= 0) pending.splice(idx, 1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const waves = groups.map((group, index) => {
|
|
432
|
+
const waveId = `wave-${index + 1}`;
|
|
433
|
+
return {
|
|
434
|
+
waveId,
|
|
435
|
+
status: 'pending',
|
|
436
|
+
checkpoint: { commitHash: null, createdAt: null },
|
|
437
|
+
tasks: group.map(task => initManifestTask(task, waveId, ownership)),
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
waves.push({
|
|
442
|
+
waveId: `wave-${waves.length + 1}`,
|
|
443
|
+
status: 'pending',
|
|
444
|
+
checkpoint: { commitHash: null, createdAt: null },
|
|
445
|
+
tasks: [{
|
|
446
|
+
taskId: `task-final-review`,
|
|
447
|
+
description: 'Review all completed wave outputs, call out unresolved risks, and verify final coherence.',
|
|
448
|
+
provider: null,
|
|
449
|
+
model: null,
|
|
450
|
+
tier: 'think',
|
|
451
|
+
effort: null,
|
|
452
|
+
agentType: null,
|
|
453
|
+
sandbox: null,
|
|
454
|
+
reason: 'final review wave',
|
|
455
|
+
owns: [],
|
|
456
|
+
reads: uniq(tasks.flatMap(task => ownership.byTask[task.taskId]?.reads || task.files || [])),
|
|
457
|
+
status: 'pending',
|
|
458
|
+
result: null,
|
|
459
|
+
startedAt: null,
|
|
460
|
+
completedAt: null,
|
|
461
|
+
retryCount: 0,
|
|
462
|
+
durationMs: null,
|
|
463
|
+
riskLevel: highestRisk(tasks.map(task => task.riskLevel)),
|
|
464
|
+
dependencies: tasks.map(task => task.taskId),
|
|
465
|
+
topic: 'final-review',
|
|
466
|
+
}],
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return waves;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function inferEffort(task) {
|
|
473
|
+
if (task.tier === 'think') return 'high';
|
|
474
|
+
if (task.riskLevel === 'critical' || task.riskLevel === 'high') return 'high';
|
|
475
|
+
if (task.tier === 'search') return 'low';
|
|
476
|
+
return (combinedTaskFiles(task).length <= 1) ? 'medium' : 'high';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function inferAgentType(task) {
|
|
480
|
+
if (task.tier === 'search') return 'explorer';
|
|
481
|
+
if (task.tier === 'think') return 'reviewer';
|
|
482
|
+
return 'worker';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function inferSandbox(task) {
|
|
486
|
+
return task.tier === 'execute' ? 'danger-full-access' : 'read-only';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function estimateDurationMs(task) {
|
|
490
|
+
const fileCount = Math.max(1, combinedTaskFiles(task).length);
|
|
491
|
+
if (task.tier === 'search') return 90_000 + (fileCount * 15_000);
|
|
492
|
+
if (task.tier === 'think') return 240_000 + (fileCount * 20_000);
|
|
493
|
+
return 180_000 + (fileCount * 30_000);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function routeTasks(tasks) {
|
|
497
|
+
const status = getProviderStatus();
|
|
498
|
+
return tasks.map(task => {
|
|
499
|
+
const tier = ['search', 'execute', 'think'].includes(task.tier) ? task.tier : 'execute';
|
|
500
|
+
const effort = inferEffort(task);
|
|
501
|
+
const agentType = inferAgentType(task);
|
|
502
|
+
const sandbox = inferSandbox(task);
|
|
503
|
+
const files = combinedTaskFiles(task);
|
|
504
|
+
const contextCoupling = task.tier === 'think' || files.length > 3 ? 'high' : files.length > 1 ? 'medium' : 'low';
|
|
505
|
+
const isolation = (task.owns?.length || 0) <= 1 ? 'high' : (task.owns?.length || 0) <= 3 ? 'medium' : 'low';
|
|
506
|
+
const selected = chooseProvider({
|
|
507
|
+
tier,
|
|
508
|
+
estimatedDurationMs: estimateDurationMs(task),
|
|
509
|
+
contextCoupling,
|
|
510
|
+
isolation,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
let provider = selected.provider;
|
|
514
|
+
let model = selected.model;
|
|
515
|
+
let reason = selected.reason;
|
|
516
|
+
|
|
517
|
+
if (provider !== 'openai') {
|
|
518
|
+
provider = 'openai';
|
|
519
|
+
model = tier === 'think' ? 'gpt-5.5' : tier === 'search' ? 'gpt-4.1-mini' : 'gpt-5.4';
|
|
520
|
+
reason = `${selected.provider}:${selected.model} preferred; forced to openai:${model} because dispatchGptTask is GPT-only`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const decisionId = recordDecision({
|
|
524
|
+
tier,
|
|
525
|
+
provider,
|
|
526
|
+
model,
|
|
527
|
+
recommended_model: selected.model,
|
|
528
|
+
followed: provider === selected.provider,
|
|
529
|
+
task_type: agentType,
|
|
530
|
+
estimated_duration_ms: estimateDurationMs(task),
|
|
531
|
+
file_count: files.length,
|
|
532
|
+
context_coupling: contextCoupling,
|
|
533
|
+
isolation,
|
|
534
|
+
claude_pressure: status.claude?.[tier]?.pressure ?? null,
|
|
535
|
+
openai_pressure: status.openai?.[tier]?.pressure ?? null,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
...task,
|
|
540
|
+
provider,
|
|
541
|
+
model,
|
|
542
|
+
tier,
|
|
543
|
+
effort,
|
|
544
|
+
agentType,
|
|
545
|
+
sandbox,
|
|
546
|
+
reason,
|
|
547
|
+
_decisionId: decisionId,
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function printDispatchTable(manifest) {
|
|
553
|
+
const rows = manifest.waves.flatMap(wave => wave.tasks.map(task => [
|
|
554
|
+
`${wave.waveId}:${task.taskId}`,
|
|
555
|
+
task.provider || '-',
|
|
556
|
+
task.model || '-',
|
|
557
|
+
task.effort || '-',
|
|
558
|
+
task.agentType || '-',
|
|
559
|
+
trimText(combinedTaskFiles(task).join(', ') || '-', 42),
|
|
560
|
+
]));
|
|
561
|
+
|
|
562
|
+
console.log(`Manifest: ${manifest.manifestId}`);
|
|
563
|
+
console.log(`State: ${manifest.status} Risk: ${manifest.riskLevel}`);
|
|
564
|
+
console.log(`Balance: ${manifest.balanceSnapshot.recommendation}`);
|
|
565
|
+
console.log(renderTable(
|
|
566
|
+
['Task', 'Provider', 'Model', 'Effort', 'Agent', 'Files'],
|
|
567
|
+
rows,
|
|
568
|
+
));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function printFinalTable(manifest) {
|
|
572
|
+
const rows = manifest.waves.flatMap(wave => wave.tasks.map(task => [
|
|
573
|
+
`${wave.waveId}:${task.taskId}`,
|
|
574
|
+
task.provider || '-',
|
|
575
|
+
task.model || '-',
|
|
576
|
+
task.durationMs != null ? `${(task.durationMs / 1000).toFixed(1)}s` : '-',
|
|
577
|
+
`${statusIcon(task.status)} ${task.status}`,
|
|
578
|
+
trimText(combinedTaskFiles(task).join(', ') || '-', 42),
|
|
579
|
+
]));
|
|
580
|
+
|
|
581
|
+
console.log(renderTable(
|
|
582
|
+
['Task', 'Provider', 'Model', 'Duration', 'Status', 'Files'],
|
|
583
|
+
rows,
|
|
584
|
+
));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function gitCheckpoint(manifest, waveId) {
|
|
588
|
+
ensureStateDirs();
|
|
589
|
+
const checkpoint = {
|
|
590
|
+
manifestId: manifest.manifestId,
|
|
591
|
+
waveId,
|
|
592
|
+
createdAt: isoNow(),
|
|
593
|
+
commitHash: null,
|
|
594
|
+
commitStatus: 'skipped',
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
if (!gitInsideRepo()) {
|
|
598
|
+
writeFileSync(
|
|
599
|
+
join(CHECKPOINT_DIR, `${manifest.manifestId}-${waveId}.json`),
|
|
600
|
+
`${JSON.stringify(checkpoint, null, 2)}\n`,
|
|
601
|
+
'utf8',
|
|
602
|
+
);
|
|
603
|
+
return checkpoint;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
runCommand('git', ['add', '-A']);
|
|
607
|
+
const commit = runCommand('git', ['commit', '-m', `wave-orchestrator checkpoint ${manifest.manifestId} ${waveId}`]);
|
|
608
|
+
if (commit.status === 0) {
|
|
609
|
+
checkpoint.commitStatus = 'created';
|
|
610
|
+
} else if (/nothing to commit|no changes added/i.test(commit.stdout + commit.stderr)) {
|
|
611
|
+
checkpoint.commitStatus = 'noop';
|
|
612
|
+
} else {
|
|
613
|
+
checkpoint.commitStatus = 'failed';
|
|
614
|
+
checkpoint.error = trimText(commit.stderr || commit.stdout, 300);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
checkpoint.commitHash = gitHead();
|
|
618
|
+
|
|
619
|
+
writeFileSync(
|
|
620
|
+
join(CHECKPOINT_DIR, `${manifest.manifestId}-${waveId}.json`),
|
|
621
|
+
`${JSON.stringify(checkpoint, null, 2)}\n`,
|
|
622
|
+
'utf8',
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
return checkpoint;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function loadPackageJson() {
|
|
629
|
+
const path = join(ROOT_DIR, 'package.json');
|
|
630
|
+
if (!existsSync(path)) return null;
|
|
631
|
+
return safeJsonParse(readFileSync(path, 'utf8'), null);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function detectTestCommand(manifest, wave) {
|
|
635
|
+
const hasExecute = wave.tasks.some(task => task.tier === 'execute' && task.status === 'completed');
|
|
636
|
+
if (!hasExecute) return null;
|
|
637
|
+
|
|
638
|
+
const pkg = loadPackageJson();
|
|
639
|
+
if (pkg?.scripts?.test) {
|
|
640
|
+
return { command: 'npm', args: ['test'] };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const changedFiles = wave.tasks.flatMap(task => combinedTaskFiles(task));
|
|
644
|
+
const hasNodeTests = changedFiles.some(file => /\.test\.|\.spec\.|tests?\//i.test(file));
|
|
645
|
+
if (hasNodeTests) {
|
|
646
|
+
return { command: 'node', args: ['--test'] };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function runWaveTests(manifest, wave) {
|
|
653
|
+
const testCommand = detectTestCommand(manifest, wave);
|
|
654
|
+
if (!testCommand) return null;
|
|
655
|
+
|
|
656
|
+
printProgress(`${statusIcon('running')} ${wave.waveId} tests: ${testCommand.command} ${testCommand.args.join(' ')}`);
|
|
657
|
+
const startedAt = Date.now();
|
|
658
|
+
const proc = spawnSync(testCommand.command, testCommand.args, {
|
|
659
|
+
cwd: ROOT_DIR,
|
|
660
|
+
encoding: 'utf8',
|
|
661
|
+
});
|
|
662
|
+
const durationMs = Date.now() - startedAt;
|
|
663
|
+
return {
|
|
664
|
+
command: `${testCommand.command} ${testCommand.args.join(' ')}`,
|
|
665
|
+
status: proc.status === 0 ? 'completed' : 'failed',
|
|
666
|
+
durationMs,
|
|
667
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
668
|
+
completedAt: isoNow(),
|
|
669
|
+
stdout: trimText(proc.stdout, 500),
|
|
670
|
+
stderr: trimText(proc.stderr, 500),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function buildDispatchPayload(manifest, wave, task) {
|
|
675
|
+
const files = combinedTaskFiles(task);
|
|
676
|
+
return {
|
|
677
|
+
task: [
|
|
678
|
+
task.description,
|
|
679
|
+
`Manifest ID: ${manifest.manifestId}`,
|
|
680
|
+
`Wave ID: ${wave.waveId}`,
|
|
681
|
+
`Task ID: ${task.taskId}`,
|
|
682
|
+
`Reason: ${task.reason}`,
|
|
683
|
+
`Provider: ${task.provider}`,
|
|
684
|
+
`Effort: ${task.effort}`,
|
|
685
|
+
`Agent Type: ${task.agentType}`,
|
|
686
|
+
`Sandbox: ${task.sandbox}`,
|
|
687
|
+
].join('\n'),
|
|
688
|
+
model: task.model,
|
|
689
|
+
tier: task.tier,
|
|
690
|
+
files,
|
|
691
|
+
timeoutMs: Math.max(120_000, estimateDurationMs(task)),
|
|
692
|
+
constraints: [
|
|
693
|
+
`Owns: ${task.owns.join(', ') || 'none'}`,
|
|
694
|
+
`Reads: ${task.reads.join(', ') || 'none'}`,
|
|
695
|
+
`Status persistence is handled by the orchestrator; summarize changes clearly.`,
|
|
696
|
+
],
|
|
697
|
+
cwd: ROOT_DIR,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function compressResult(result) {
|
|
702
|
+
const duration = result?.durationMs != null ? `${(result.durationMs / 1000).toFixed(1)}s` : null;
|
|
703
|
+
const core = [
|
|
704
|
+
result?.success ? 'success' : 'failure',
|
|
705
|
+
duration,
|
|
706
|
+
result?.model || null,
|
|
707
|
+
trimText(result?.summary || result?.error || (result?.errors || []).join('; ') || 'no summary', 420),
|
|
708
|
+
].filter(Boolean).join(' | ');
|
|
709
|
+
return trimText(core, 500);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function executeTask(manifest, wave, task) {
|
|
713
|
+
task.status = 'running';
|
|
714
|
+
task.startedAt = isoNow();
|
|
715
|
+
saveManifest(refreshCounts(manifest));
|
|
716
|
+
printProgress(`${statusIcon('running')} ${wave.waveId} ${task.taskId} -> ${task.provider}:${task.model}`);
|
|
717
|
+
|
|
718
|
+
const started = Date.now();
|
|
719
|
+
const result = await dispatchGptTask(buildDispatchPayload(manifest, wave, task));
|
|
720
|
+
const durationMs = Date.now() - started;
|
|
721
|
+
|
|
722
|
+
task.durationMs = durationMs;
|
|
723
|
+
task.retryCount = result.retryCount || 0;
|
|
724
|
+
task.completedAt = isoNow();
|
|
725
|
+
task.result = {
|
|
726
|
+
success: !!result.success,
|
|
727
|
+
summary: compressResult(result),
|
|
728
|
+
rawSummary: trimText(result.summary || '', 2000),
|
|
729
|
+
error: trimText(result.error || (result.errors || []).join('; '), 800),
|
|
730
|
+
usage: result.usage || null,
|
|
731
|
+
exitCode: result.exitCode ?? null,
|
|
732
|
+
failureType: result.failureType || null,
|
|
733
|
+
};
|
|
734
|
+
task.status = result.success ? 'completed' : 'failed';
|
|
735
|
+
|
|
736
|
+
if (task._decisionId) {
|
|
737
|
+
recordOutcome(task._decisionId, {
|
|
738
|
+
actual_duration_ms: durationMs,
|
|
739
|
+
codex_startup_ms: result.startupMs || null,
|
|
740
|
+
success: !!result.success,
|
|
741
|
+
retries: result.retryCount || 0,
|
|
742
|
+
actual_input_tokens: result.usage?.input_tokens || null,
|
|
743
|
+
actual_output_tokens: result.usage?.output_tokens || null,
|
|
744
|
+
files_changed: task.owns || [],
|
|
745
|
+
files_read: task.reads || [],
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
printProgress(`${statusIcon(task.status)} ${wave.waveId} ${task.taskId} ${task.status} ${(durationMs / 1000).toFixed(1)}s`);
|
|
750
|
+
saveManifest(refreshCounts(manifest));
|
|
751
|
+
return task;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function executeWave(manifest, waveIdx) {
|
|
755
|
+
const wave = manifest.waves[waveIdx];
|
|
756
|
+
if (!wave) throw new Error(`Wave not found at index ${waveIdx}`);
|
|
757
|
+
if (wave.status === 'completed') return wave;
|
|
758
|
+
|
|
759
|
+
wave.status = 'running';
|
|
760
|
+
saveManifest(refreshCounts(manifest));
|
|
761
|
+
printProgress(`\n${statusIcon('running')} Starting ${wave.waveId} (${wave.tasks.length} task${wave.tasks.length === 1 ? '' : 's'})`);
|
|
762
|
+
|
|
763
|
+
const runnable = wave.tasks.filter(task => task.status !== 'completed');
|
|
764
|
+
if (runnable.length === 0) {
|
|
765
|
+
wave.status = 'completed';
|
|
766
|
+
saveManifest(refreshCounts(manifest));
|
|
767
|
+
return wave;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const failures = [];
|
|
771
|
+
await Promise.all(runnable.slice(0, MAX_WAVE_PARALLELISM).map(async task => {
|
|
772
|
+
try {
|
|
773
|
+
await executeTask(manifest, wave, task);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
task.status = 'failed';
|
|
776
|
+
task.completedAt = isoNow();
|
|
777
|
+
task.result = {
|
|
778
|
+
success: false,
|
|
779
|
+
summary: trimText(error.message, 500),
|
|
780
|
+
rawSummary: '',
|
|
781
|
+
error: trimText(error.stack || error.message, 800),
|
|
782
|
+
usage: null,
|
|
783
|
+
exitCode: null,
|
|
784
|
+
failureType: 'orchestrator_error',
|
|
785
|
+
};
|
|
786
|
+
failures.push(error);
|
|
787
|
+
saveManifest(refreshCounts(manifest));
|
|
788
|
+
}
|
|
789
|
+
}));
|
|
790
|
+
|
|
791
|
+
const testRun = runWaveTests(manifest, wave);
|
|
792
|
+
if (testRun) {
|
|
793
|
+
wave.testRun = testRun;
|
|
794
|
+
if (testRun.status === 'failed') {
|
|
795
|
+
failures.push(new Error(`Tests failed for ${wave.waveId}`));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
wave.status = failures.length > 0 || wave.tasks.some(task => task.status === 'failed')
|
|
800
|
+
? 'failed'
|
|
801
|
+
: 'completed';
|
|
802
|
+
saveManifest(refreshCounts(manifest));
|
|
803
|
+
return wave;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function buildManifest(plan) {
|
|
807
|
+
const ownership = buildOwnershipMap(plan.tasks);
|
|
808
|
+
const waves = planWaves(plan.tasks, ownership);
|
|
809
|
+
const routedWaves = waves.map(wave => ({
|
|
810
|
+
...wave,
|
|
811
|
+
tasks: routeTasks(wave.tasks),
|
|
812
|
+
}));
|
|
813
|
+
const manifest = refreshCounts({
|
|
814
|
+
manifestId: makeManifestId(),
|
|
815
|
+
utterance: plan.utterance,
|
|
816
|
+
createdAt: plan.createdAt,
|
|
817
|
+
status: 'planned',
|
|
818
|
+
riskLevel: plan.riskLevel,
|
|
819
|
+
balanceSnapshot: getBalanceSnapshot(),
|
|
820
|
+
waves: routedWaves,
|
|
821
|
+
completedWaves: 0,
|
|
822
|
+
totalWaves: routedWaves.length,
|
|
823
|
+
totalTasks: 0,
|
|
824
|
+
completedTasks: 0,
|
|
825
|
+
failedTasks: 0,
|
|
826
|
+
});
|
|
827
|
+
return manifest;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function getResumeWaveIndex(manifest) {
|
|
831
|
+
return manifest.waves.findIndex(wave => wave.status !== 'completed');
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function verifyResumeState(manifest) {
|
|
835
|
+
if (!gitInsideRepo()) return;
|
|
836
|
+
const currentHead = gitHead();
|
|
837
|
+
const lastCompletedWave = [...manifest.waves].reverse().find(wave => wave.status === 'completed' && wave.checkpoint?.commitHash);
|
|
838
|
+
if (!lastCompletedWave) return;
|
|
839
|
+
if (currentHead !== lastCompletedWave.checkpoint.commitHash) {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`Git HEAD ${currentHead || 'unknown'} does not match last completed checkpoint ${lastCompletedWave.checkpoint.commitHash}. Restore that checkpoint before resuming.`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function orchestrate(utterance, opts = {}) {
|
|
847
|
+
ensureStateDirs();
|
|
848
|
+
|
|
849
|
+
if (opts.show) {
|
|
850
|
+
const manifest = refreshCounts(loadManifest(opts.show));
|
|
851
|
+
printDispatchTable(manifest);
|
|
852
|
+
printFinalTable(manifest);
|
|
853
|
+
return manifest;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
let manifest;
|
|
857
|
+
if (opts.resume) {
|
|
858
|
+
manifest = refreshCounts(loadManifest(opts.resume));
|
|
859
|
+
verifyResumeState(manifest);
|
|
860
|
+
manifest.status = 'running';
|
|
861
|
+
manifest.balanceSnapshot = getBalanceSnapshot();
|
|
862
|
+
saveManifest(manifest);
|
|
863
|
+
} else {
|
|
864
|
+
const plan = decomposeIntent(utterance);
|
|
865
|
+
manifest = buildManifest(plan);
|
|
866
|
+
saveManifest(manifest);
|
|
867
|
+
printDispatchTable(manifest);
|
|
868
|
+
if (opts.dryRun) {
|
|
869
|
+
manifest.status = 'dry-run';
|
|
870
|
+
saveManifest(refreshCounts(manifest));
|
|
871
|
+
return manifest;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
const startWaveIdx = Math.max(0, getResumeWaveIndex(manifest));
|
|
877
|
+
if (startWaveIdx >= manifest.waves.length) {
|
|
878
|
+
manifest.status = 'completed';
|
|
879
|
+
saveManifest(refreshCounts(manifest));
|
|
880
|
+
printFinalTable(manifest);
|
|
881
|
+
return manifest;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
for (let i = startWaveIdx; i < manifest.waves.length; i++) {
|
|
885
|
+
const wave = manifest.waves[i];
|
|
886
|
+
if (wave.status === 'completed') continue;
|
|
887
|
+
|
|
888
|
+
wave.checkpoint = gitCheckpoint(manifest, wave.waveId);
|
|
889
|
+
saveManifest(refreshCounts(manifest));
|
|
890
|
+
await executeWave(manifest, i);
|
|
891
|
+
|
|
892
|
+
if (wave.status === 'failed') {
|
|
893
|
+
manifest.status = 'paused';
|
|
894
|
+
saveManifest(refreshCounts(manifest));
|
|
895
|
+
console.error(`Paused after ${wave.waveId}. Resume with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
896
|
+
printFinalTable(manifest);
|
|
897
|
+
return manifest;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
manifest.status = 'completed';
|
|
902
|
+
saveManifest(refreshCounts(manifest));
|
|
903
|
+
printFinalTable(manifest);
|
|
904
|
+
return manifest;
|
|
905
|
+
} catch (error) {
|
|
906
|
+
manifest.status = 'paused';
|
|
907
|
+
manifest.lastError = trimText(error.stack || error.message, 1200);
|
|
908
|
+
saveManifest(refreshCounts(manifest));
|
|
909
|
+
console.error(trimText(error.message, 400));
|
|
910
|
+
console.error(`Resume with: node hooks/wave-orchestrator.mjs --resume ${manifest.manifestId}`);
|
|
911
|
+
return manifest;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function parseCli(argv) {
|
|
916
|
+
const args = [...argv];
|
|
917
|
+
if (args[0] === '--resume') {
|
|
918
|
+
return { resume: args[1] };
|
|
919
|
+
}
|
|
920
|
+
if (args[0] === '--dry-run') {
|
|
921
|
+
return { dryRun: true, utterance: args.slice(1).join(' ') };
|
|
922
|
+
}
|
|
923
|
+
if (args[0] === '--show') {
|
|
924
|
+
return { show: args[1] };
|
|
925
|
+
}
|
|
926
|
+
return { utterance: args.join(' ') };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async function main() {
|
|
930
|
+
const args = parseCli(process.argv.slice(2));
|
|
931
|
+
|
|
932
|
+
if (args.show) {
|
|
933
|
+
await orchestrate(null, { show: args.show });
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (args.resume) {
|
|
937
|
+
await orchestrate(null, { resume: args.resume });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (args.dryRun) {
|
|
941
|
+
if (!args.utterance) {
|
|
942
|
+
console.error('Usage: node hooks/wave-orchestrator.mjs --dry-run "utterance"');
|
|
943
|
+
process.exit(1);
|
|
944
|
+
}
|
|
945
|
+
await orchestrate(args.utterance, { dryRun: true });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (!args.utterance) {
|
|
949
|
+
console.error('Usage: node hooks/wave-orchestrator.mjs "utterance" | --resume ID | --dry-run "utterance" | --show ID');
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
await orchestrate(args.utterance);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export {
|
|
956
|
+
orchestrate,
|
|
957
|
+
decomposeIntent,
|
|
958
|
+
planWaves,
|
|
959
|
+
buildOwnershipMap,
|
|
960
|
+
compressResult,
|
|
961
|
+
printDispatchTable,
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
965
|
+
if (isMain) {
|
|
966
|
+
main().catch(error => {
|
|
967
|
+
console.error(trimText(error.stack || error.message, 1200));
|
|
968
|
+
process.exit(1);
|
|
969
|
+
});
|
|
970
|
+
}
|