wogiflow 2.4.3 → 2.5.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.
@@ -0,0 +1,782 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Task Routing & Sub-Agent Delegation
5
+ *
6
+ * Story 3 (wf-824638e4): The manager agent's brain — analyzes tasks,
7
+ * determines which repo(s) to target, determines ordering,
8
+ * and generates sub-agent delegation instructions.
9
+ */
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const crypto = require('node:crypto');
14
+ const http = require('node:http');
15
+
16
+ let _workspaceMessages;
17
+ function getWorkspaceMessages() {
18
+ if (!_workspaceMessages) {
19
+ _workspaceMessages = require('./workspace-messages');
20
+ }
21
+ return _workspaceMessages;
22
+ }
23
+
24
+ // Shared phase ordering — used by both getExecutionOrder() and dispatchCrossRepoPlan()
25
+ const PHASE_ORDER = { library: 0, contract: 0, provider: 1, both: 1, consumer: 2, standalone: 3, verify: 4 };
26
+
27
+ // ============================================================
28
+ // Routing Keywords (Criterion 1)
29
+ // ============================================================
30
+
31
+ const ROLE_KEYWORDS = {
32
+ consumer: [
33
+ 'page', 'component', 'ui', 'style', 'css', 'layout', 'form', 'button',
34
+ 'modal', 'screen', 'view', 'frontend', 'client', 'browser', 'render',
35
+ 'hook', 'state', 'redux', 'zustand', 'store', 'theme', 'responsive',
36
+ 'animation', 'toast', 'notification-ui', 'sidebar', 'navbar', 'header'
37
+ ],
38
+ provider: [
39
+ 'endpoint', 'route', 'controller', 'model', 'database', 'migration',
40
+ 'schema', 'backend', 'server', 'api', 'query', 'mutation', 'resolver',
41
+ 'middleware', 'auth', 'jwt', 'session', 'seed', 'fixture', 'orm',
42
+ 'sql', 'table', 'index', 'relation', 'service-layer'
43
+ ],
44
+ library: [
45
+ 'shared', 'utility', 'types', 'common', 'helper', 'constant', 'enum',
46
+ 'interface', 'typedef', 'lib', 'package', 'module'
47
+ ],
48
+ crossRepo: [
49
+ 'api', 'contract', 'schema', 'integration', 'full-stack', 'end-to-end',
50
+ 'e2e', 'both', 'cross', 'sync', 'together'
51
+ ]
52
+ };
53
+
54
+ // ============================================================
55
+ // Route Analysis (Criterion 1)
56
+ // ============================================================
57
+
58
+ /**
59
+ * Analyze a task description and determine which repo(s) should handle it.
60
+ *
61
+ * @param {string} taskDescription — the user's task description
62
+ * @param {Object} manifest — workspace-manifest.json content
63
+ * @returns {Object} routing decision
64
+ */
65
+ function analyzeTaskRouting(taskDescription, manifest) {
66
+ if (!manifest?.members) {
67
+ return {
68
+ type: 'single-repo',
69
+ target: null,
70
+ scores: {},
71
+ reason: 'No members in manifest'
72
+ };
73
+ }
74
+
75
+ const desc = taskDescription.toLowerCase();
76
+ const scores = {}; // memberName → score
77
+ const crossRepoScore = scoreKeywords(desc, ROLE_KEYWORDS.crossRepo);
78
+
79
+ for (const [name, member] of Object.entries(manifest.members)) {
80
+ let score = 0;
81
+
82
+ // Score based on role keywords
83
+ const roleKeywords = ROLE_KEYWORDS[member.role] || [];
84
+ score += scoreKeywords(desc, roleKeywords);
85
+
86
+ // Score based on member name appearing in description
87
+ if (desc.includes(name.toLowerCase())) score += 3;
88
+
89
+ // Score based on specific endpoint mentions
90
+ for (const ep of (member.provides || [])) {
91
+ const epPath = ep.split(' ').slice(1).join(' ').toLowerCase();
92
+ if (desc.includes(epPath)) score += 2;
93
+ }
94
+ for (const ep of (member.consumes || [])) {
95
+ const epPath = ep.split(' ').slice(1).join(' ').toLowerCase();
96
+ if (desc.includes(epPath)) score += 2;
97
+ }
98
+
99
+ scores[name] = score;
100
+ }
101
+
102
+ // Determine routing
103
+ const sortedMembers = Object.entries(scores)
104
+ .sort((a, b) => b[1] - a[1]);
105
+
106
+ const topScore = sortedMembers[0]?.[1] || 0;
107
+ const secondScore = sortedMembers[1]?.[1] || 0;
108
+
109
+ // Cross-repo if:
110
+ // 1. Cross-repo keywords are dominant
111
+ // 2. Two repos score similarly (within 30%)
112
+ // 3. No repo scored above threshold
113
+ const isCrossRepo =
114
+ crossRepoScore >= 2 ||
115
+ (topScore > 0 && secondScore > 0 && secondScore >= topScore * 0.7) ||
116
+ topScore === 0;
117
+
118
+ if (isCrossRepo && Object.keys(manifest.members).length >= 2) {
119
+ return {
120
+ type: 'cross-repo',
121
+ targets: sortedMembers.filter(([_, s]) => s > 0).map(([name]) => name),
122
+ allMembers: Object.keys(manifest.members),
123
+ scores,
124
+ crossRepoScore,
125
+ reason: crossRepoScore >= 2
126
+ ? 'Cross-repo keywords detected'
127
+ : topScore === 0
128
+ ? 'No clear repo match — routing to all'
129
+ : 'Multiple repos score similarly'
130
+ };
131
+ }
132
+
133
+ return {
134
+ type: 'single-repo',
135
+ target: sortedMembers[0]?.[0] || Object.keys(manifest.members)[0],
136
+ scores,
137
+ reason: `Best match: ${sortedMembers[0]?.[0]} (score: ${topScore})`
138
+ };
139
+ }
140
+
141
+ function scoreKeywords(text, keywords) {
142
+ let score = 0;
143
+ for (const kw of keywords) {
144
+ const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
145
+ if (new RegExp('\\b' + escaped + '\\b', 'i').test(text)) score++;
146
+ }
147
+ return score;
148
+ }
149
+
150
+ // ============================================================
151
+ // Single-Repo Delegation (Criterion 2)
152
+ // ============================================================
153
+
154
+ /**
155
+ * Generate a sub-agent delegation prompt for a single repo.
156
+ *
157
+ * @param {string} workspaceRoot
158
+ * @param {string} repoName
159
+ * @param {string} task — task description
160
+ * @param {Object} manifest
161
+ * @returns {Object} delegation instruction
162
+ */
163
+ function generateSingleRepoDelegation(workspaceRoot, repoName, task, manifest) {
164
+ const member = manifest.members[repoName];
165
+ if (!member) throw new Error(`Unknown repo: ${repoName}`);
166
+
167
+ const memberPath = member.path || `./${repoName}`;
168
+ const repoPath = path.resolve(workspaceRoot, memberPath);
169
+ const decisionsPath = path.join(repoPath, '.workflow', 'state', 'decisions.md');
170
+
171
+ // Read repo's decisions for context injection
172
+ let decisions = '';
173
+ try {
174
+ if (fs.existsSync(decisionsPath)) {
175
+ decisions = fs.readFileSync(decisionsPath, 'utf-8').slice(0, 3000);
176
+ }
177
+ } catch (_err) {
178
+ // Non-critical
179
+ }
180
+
181
+ // Get unread messages for this repo
182
+ let messages = [];
183
+ try {
184
+ const { getUnreadMessages } = require('./workspace-messages');
185
+ messages = getUnreadMessages(workspaceRoot, repoName);
186
+ } catch (_err) {
187
+ // Non-critical
188
+ }
189
+
190
+ const messageContext = messages.length > 0
191
+ ? `\n\nUnread messages for your repo:\n${messages.map(m => `- [${m.type}] from ${m.from}: ${m.subject}`).join('\n')}`
192
+ : '';
193
+
194
+ // Read relevant contracts
195
+ let contractContext = '';
196
+ try {
197
+ const contractsDir = path.join(workspaceRoot, '.workspace', 'contracts');
198
+ if (fs.existsSync(contractsDir)) {
199
+ const files = fs.readdirSync(contractsDir).filter(f => f.endsWith('.json') || f.endsWith('.yaml'));
200
+ if (files.length > 0) {
201
+ contractContext = '\n\nShared contracts available in .workspace/contracts/: ' + files.join(', ');
202
+ }
203
+ }
204
+ } catch (_err) {
205
+ // Non-critical
206
+ }
207
+
208
+ return {
209
+ repoName,
210
+ repoPath: member.path,
211
+ prompt: `You are working in the ${repoName} repo (${member.stack?.language || 'unknown'}/${member.stack?.framework || 'unknown'}).
212
+
213
+ Task: ${task}
214
+
215
+ Your repo's role: ${member.role}
216
+ ${decisions ? `\nProject rules (from decisions.md):\n${decisions}` : ''}${messageContext}${contractContext}
217
+
218
+ After completing the task:
219
+ 1. Commit your changes
220
+ 2. If your changes affect the API contract (endpoints, request/response shapes), write a message to .workspace/messages/ notifying other repos`,
221
+
222
+ agentConfig: {
223
+ description: `${repoName}: ${task.substring(0, 50)}...`,
224
+ // The sub-agent should work within the repo directory
225
+ // The orchestrator (workspace manager) will read results after completion
226
+ }
227
+ };
228
+ }
229
+
230
+ // ============================================================
231
+ // Cross-Repo Delegation (Criterion 3)
232
+ // ============================================================
233
+
234
+ /**
235
+ * Generate a cross-repo execution plan.
236
+ * Order: contract update → provider(s) → consumer(s) → integration verify
237
+ *
238
+ * @param {string} workspaceRoot
239
+ * @param {string} task
240
+ * @param {Object} manifest
241
+ * @param {Object} routing — from analyzeTaskRouting()
242
+ * @returns {Object} execution plan with ordered steps
243
+ */
244
+ function generateCrossRepoPlan(workspaceRoot, task, manifest, routing) {
245
+ const steps = [];
246
+ const members = manifest.members;
247
+
248
+ // Step 1: Contract update (if API changes are involved)
249
+ const apiKeywords = ['endpoint', 'api', 'route', 'schema', 'contract'];
250
+ const needsContractUpdate = apiKeywords.some(kw => task.toLowerCase().includes(kw));
251
+
252
+ if (needsContractUpdate) {
253
+ steps.push({
254
+ phase: 'contract',
255
+ action: 'Update shared contract in .workspace/contracts/',
256
+ executor: 'manager',
257
+ description: 'Define the API contract before implementation'
258
+ });
259
+ }
260
+
261
+ // Step 2: Provider repos first (they create the API)
262
+ const providers = Object.entries(members)
263
+ .filter(([_, m]) => m.role === 'provider' || m.role === 'both')
264
+ .filter(([name]) => routing.targets?.includes(name) || routing.allMembers?.includes(name));
265
+
266
+ for (const [name] of providers) {
267
+ steps.push({
268
+ phase: 'provider',
269
+ action: `Implement provider-side changes in ${name}/`,
270
+ executor: name,
271
+ description: `${name} implements the API/backend side`,
272
+ delegation: generateSingleRepoDelegation(workspaceRoot, name, task, manifest)
273
+ });
274
+ }
275
+
276
+ // Step 3: Consumer repos (they use the API)
277
+ const consumers = Object.entries(members)
278
+ .filter(([_, m]) => m.role === 'consumer' || m.role === 'both')
279
+ .filter(([name]) => routing.targets?.includes(name) || routing.allMembers?.includes(name))
280
+ .filter(([name]) => !providers.some(([pName]) => pName === name)); // Skip if already in providers
281
+
282
+ for (const [name] of consumers) {
283
+ steps.push({
284
+ phase: 'consumer',
285
+ action: `Implement consumer-side changes in ${name}/`,
286
+ executor: name,
287
+ description: `${name} implements the frontend/client side`,
288
+ delegation: generateSingleRepoDelegation(workspaceRoot, name, task, manifest)
289
+ });
290
+ }
291
+
292
+ // Step 4: Library repos (if affected)
293
+ const libraries = Object.entries(members)
294
+ .filter(([_, m]) => m.role === 'library')
295
+ .filter(([name]) => routing.targets?.includes(name));
296
+
297
+ for (const [name] of libraries) {
298
+ // Libraries go first (before providers and consumers)
299
+ steps.unshift({
300
+ phase: 'library',
301
+ action: `Update shared library ${name}/`,
302
+ executor: name,
303
+ description: `${name} updates shared types/utilities`,
304
+ delegation: generateSingleRepoDelegation(workspaceRoot, name, task, manifest)
305
+ });
306
+ }
307
+
308
+ // Step 5: Integration verification
309
+ steps.push({
310
+ phase: 'verify',
311
+ action: 'Verify cross-repo integration',
312
+ executor: 'manager',
313
+ description: 'Check that provider and consumer sides work together'
314
+ });
315
+
316
+ return {
317
+ task,
318
+ type: 'cross-repo',
319
+ totalSteps: steps.length,
320
+ executionOrder: steps,
321
+ providers: providers.map(([n]) => n),
322
+ consumers: consumers.map(([n]) => n),
323
+ libraries: libraries.map(([n]) => n)
324
+ };
325
+ }
326
+
327
+ // ============================================================
328
+ // Parallel Investigation (Criterion 4)
329
+ // ============================================================
330
+
331
+ /**
332
+ * Generate parallel investigation instructions for bug reports.
333
+ * Spawns one investigator per potentially affected repo.
334
+ *
335
+ * @param {string} workspaceRoot
336
+ * @param {string} bugDescription
337
+ * @param {Object} manifest
338
+ * @returns {Object} investigation plan
339
+ */
340
+ function generateParallelInvestigation(workspaceRoot, bugDescription, manifest) {
341
+ const investigators = [];
342
+
343
+ for (const [name, member] of Object.entries(manifest.members)) {
344
+ const repoPath = path.resolve(workspaceRoot, member.path);
345
+
346
+ investigators.push({
347
+ repoName: name,
348
+ repoPath: member.path,
349
+ role: member.role,
350
+ prompt: `You are investigating a bug in the ${name} repo (${member.role}).
351
+
352
+ Bug report: ${bugDescription}
353
+
354
+ Your job:
355
+ 1. Check if the issue originates from YOUR repo
356
+ 2. Check recent changes (git log) that might have caused this
357
+ 3. Check relevant API endpoints, components, or services
358
+ 4. Report your findings clearly:
359
+ - Is the issue on YOUR side? (yes/no/maybe)
360
+ - What did you find?
361
+ - If yes: what's the fix?
362
+ - If no: what should the OTHER repo(s) check?
363
+
364
+ Be specific about file names, line numbers, and error messages.`,
365
+ agentConfig: {
366
+ description: `Investigate: ${name} — ${bugDescription.substring(0, 40)}...`,
367
+ model: 'sonnet' // Use cheaper model for investigation
368
+ }
369
+ });
370
+ }
371
+
372
+ return {
373
+ type: 'parallel-investigation',
374
+ bugDescription,
375
+ investigators,
376
+ synthesisPrompt: `Multiple investigators checked their repos. Synthesize their findings:
377
+ - Which repo is the root cause?
378
+ - What's the fix?
379
+ - Are there improvements needed in other repos?
380
+ - Create tasks in the appropriate repo(s) ready.json`
381
+ };
382
+ }
383
+
384
+ // ============================================================
385
+ // Task Decomposition (Criterion 5)
386
+ // ============================================================
387
+
388
+ /**
389
+ * Decompose a workspace-level task into repo-level tasks.
390
+ * Creates entries in each affected repo's ready.json.
391
+ *
392
+ * @param {string} workspaceRoot
393
+ * @param {Object} workspaceTask — { title, description, criteria }
394
+ * @param {Object} plan — from generateCrossRepoPlan()
395
+ * @returns {Array<Object>} created repo-level tasks
396
+ */
397
+ function decomposeToRepoTasks(workspaceRoot, workspaceTask, plan) {
398
+ const configPath = path.join(workspaceRoot, 'wogi-workspace.json');
399
+ let config;
400
+ try {
401
+ const raw = fs.readFileSync(configPath, 'utf-8');
402
+ config = JSON.parse(raw);
403
+ if (!config || typeof config !== 'object') return [];
404
+ } catch (_err) {
405
+ return []; // Graceful fallback — config missing or malformed
406
+ }
407
+ const createdTasks = [];
408
+
409
+ for (const step of plan.executionOrder) {
410
+ if (step.executor === 'manager') continue; // Manager steps don't create repo tasks
411
+
412
+ const memberConfig = config.members[step.executor];
413
+ if (!memberConfig) continue;
414
+
415
+ const memberPath = path.resolve(workspaceRoot, memberConfig.path);
416
+ const readyPath = path.join(memberPath, '.workflow', 'state', 'ready.json');
417
+
418
+ if (!fs.existsSync(readyPath)) continue;
419
+
420
+ try {
421
+ const ready = JSON.parse(fs.readFileSync(readyPath, 'utf-8'));
422
+ const taskId = 'wf-' + crypto.randomBytes(4).toString('hex');
423
+
424
+ const repoTask = {
425
+ id: taskId,
426
+ title: `[Workspace] ${workspaceTask.title} — ${step.executor} (${step.phase})`,
427
+ type: workspaceTask.type || 'feature',
428
+ level: 'L2',
429
+ priority: 'P0',
430
+ source: `workspace:${workspaceTask.id || 'direct'}`,
431
+ status: 'ready',
432
+ description: step.description + '\n\n' + (workspaceTask.description || ''),
433
+ createdAt: new Date().toISOString()
434
+ };
435
+
436
+ if (!ready.ready) ready.ready = [];
437
+ ready.ready.push(repoTask);
438
+ ready.lastUpdated = new Date().toISOString();
439
+ fs.writeFileSync(readyPath, JSON.stringify(ready, null, 2));
440
+
441
+ createdTasks.push({ repo: step.executor, phase: step.phase, task: repoTask });
442
+ } catch (_err) {
443
+ // Non-critical — log and continue
444
+ }
445
+ }
446
+
447
+ return createdTasks;
448
+ }
449
+
450
+ // ============================================================
451
+ // Dependency-Aware Ordering (Criterion 6)
452
+ // ============================================================
453
+
454
+ /**
455
+ * Determine execution order respecting dependencies:
456
+ * library → provider → consumer
457
+ *
458
+ * @param {Object} manifest
459
+ * @param {string[]} targetRepos — repos involved in the task
460
+ * @returns {Array<{ name: string, phase: string, order: number }>}
461
+ */
462
+ function getExecutionOrder(manifest, targetRepos) {
463
+ const order = [];
464
+
465
+ for (const name of targetRepos) {
466
+ const member = manifest.members[name];
467
+ if (!member) continue;
468
+ order.push({
469
+ name,
470
+ role: member.role,
471
+ phase: member.role,
472
+ order: PHASE_ORDER[member.role] ?? 3
473
+ });
474
+ }
475
+
476
+ // Sort by phase order (library first, then provider, then consumer)
477
+ order.sort((a, b) => a.order - b.order);
478
+
479
+ return order;
480
+ }
481
+
482
+ // ============================================================
483
+ // Channel-Based Dispatch (wf-d4b98f60)
484
+ // ============================================================
485
+
486
+ /**
487
+ * Send an HTTP POST to a worker's channel server.
488
+ *
489
+ * @param {string} host — hostname (default '127.0.0.1')
490
+ * @param {number} port — worker's channel port
491
+ * @param {string} message — message body to send
492
+ * @param {Object} [opts] — options
493
+ * @param {string} [opts.from] — sender identifier
494
+ * @param {number} [opts.timeout] — request timeout in ms (default 5000)
495
+ * @returns {Promise<{ ok: boolean, status: number, body: string }>}
496
+ */
497
+ function httpPost(host, port, message, opts = {}) {
498
+ const timeout = opts.timeout ?? 5000;
499
+ const from = opts.from ?? 'workspace-manager';
500
+ const buf = Buffer.from(message, 'utf-8');
501
+
502
+ return new Promise((resolve) => {
503
+ const req = http.request({
504
+ hostname: host,
505
+ port,
506
+ path: '/',
507
+ method: 'POST',
508
+ headers: {
509
+ 'Content-Type': 'text/plain',
510
+ 'Content-Length': buf.byteLength,
511
+ 'X-Wogi-From': from
512
+ },
513
+ timeout
514
+ }, (res) => {
515
+ const chunks = [];
516
+ res.on('data', chunk => chunks.push(chunk));
517
+ res.on('end', () => resolve({ ok: res.statusCode === 200, status: res.statusCode, body: Buffer.concat(chunks).toString('utf-8') }));
518
+ });
519
+
520
+ req.on('error', (err) => resolve({ ok: false, status: 0, body: err.message }));
521
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, status: 0, body: 'timeout' }); });
522
+ req.write(buf);
523
+ req.end();
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Check if a worker's channel server is running.
529
+ *
530
+ * @param {number} port
531
+ * @returns {Promise<{ up: boolean, repo: string }>}
532
+ */
533
+ function checkWorkerHealth(port) {
534
+ return new Promise((resolve) => {
535
+ const req = http.request({
536
+ hostname: '127.0.0.1',
537
+ port,
538
+ path: '/health',
539
+ method: 'GET',
540
+ timeout: 3000
541
+ }, (res) => {
542
+ let body = '';
543
+ res.on('data', chunk => { body += chunk; });
544
+ res.on('end', () => {
545
+ try {
546
+ const data = JSON.parse(body);
547
+ resolve({ up: data.status === 'ok', repo: data.repo || 'unknown' });
548
+ } catch (_err) {
549
+ resolve({ up: false, repo: 'unknown' });
550
+ }
551
+ });
552
+ });
553
+
554
+ req.on('error', () => resolve({ up: false, repo: 'unknown' }));
555
+ req.on('timeout', () => { req.destroy(); resolve({ up: false, repo: 'unknown' }); });
556
+ req.end();
557
+ });
558
+ }
559
+
560
+ /**
561
+ * Dispatch a task to a single worker via its channel.
562
+ *
563
+ * @param {string} workspaceRoot
564
+ * @param {string} repoName — target repo
565
+ * @param {string} taskId — task ID to start
566
+ * @returns {Promise<{ ok: boolean, message: string }>}
567
+ */
568
+ async function dispatchToChannel(workspaceRoot, repoName, taskId) {
569
+ // Validate taskId format to prevent injection into channel body
570
+ if (!/^wf-[0-9a-f]{8}$/i.test(taskId)) {
571
+ return { ok: false, message: `Invalid task ID format: "${taskId}" — expected wf-XXXXXXXX` };
572
+ }
573
+
574
+ const configPath = path.join(workspaceRoot, 'wogi-workspace.json');
575
+ let config;
576
+ try {
577
+ const raw = fs.readFileSync(configPath, 'utf-8');
578
+ config = JSON.parse(raw);
579
+ if (!config || typeof config !== 'object') throw new Error('Invalid config format');
580
+ } catch (err) {
581
+ return { ok: false, message: `Cannot read workspace config: ${err.message}` };
582
+ }
583
+
584
+ const channelConfig = config.channels?.members?.[repoName];
585
+ if (!channelConfig) {
586
+ return { ok: false, message: `No channel config for repo "${repoName}"` };
587
+ }
588
+
589
+ const port = channelConfig.port;
590
+
591
+ // Health check first
592
+ const health = await checkWorkerHealth(port);
593
+ if (!health.up) {
594
+ return {
595
+ ok: false,
596
+ message: `Worker "${repoName}" is not running on port ${port}. Start it with: cd ${repoName}/ && flow workspace start`
597
+ };
598
+ }
599
+
600
+ // Dispatch the task
601
+ const result = await httpPost('127.0.0.1', port, `/wogi-start ${taskId}`);
602
+ if (result.ok) {
603
+ return { ok: true, message: `Dispatched /wogi-start ${taskId} to ${repoName} (port ${port})` };
604
+ }
605
+
606
+ return { ok: false, message: `Dispatch failed: HTTP ${result.status} — ${result.body}` };
607
+ }
608
+
609
+ /**
610
+ * Dispatch a cross-repo execution plan to workers.
611
+ * Respects phase ordering: library → provider → consumer.
612
+ * Within a phase, dispatches in parallel. Between phases, waits for completion.
613
+ *
614
+ * @param {string} workspaceRoot
615
+ * @param {Array} createdTasks — from decomposeToRepoTasks()
616
+ * @param {Object} [opts]
617
+ * @param {boolean} [opts.parallel] — dispatch all at once ignoring phases (default false)
618
+ * @returns {Promise<{ dispatched: Array, failed: Array }>}
619
+ */
620
+ async function dispatchCrossRepoPlan(workspaceRoot, createdTasks, opts = {}) {
621
+ const dispatched = [];
622
+ const failed = [];
623
+
624
+ if (opts.parallel) {
625
+ // Dispatch all tasks at once
626
+ const results = await Promise.all(
627
+ createdTasks.map(ct =>
628
+ dispatchToChannel(workspaceRoot, ct.repo, ct.task.id)
629
+ .then(r => ({ ...r, repo: ct.repo, taskId: ct.task.id, phase: ct.phase }))
630
+ )
631
+ );
632
+ for (const r of results) {
633
+ (r.ok ? dispatched : failed).push(r);
634
+ }
635
+ } else {
636
+ // Group by phase order: library(0) → provider(1) → consumer(2) → standalone(3)
637
+ const grouped = {};
638
+ for (const ct of createdTasks) {
639
+ const order = PHASE_ORDER[ct.phase] ?? 3;
640
+ if (!grouped[order]) grouped[order] = [];
641
+ grouped[order].push(ct);
642
+ }
643
+
644
+ // Execute phases in order
645
+ const sortedPhases = Object.keys(grouped).map(Number).sort((a, b) => a - b);
646
+ for (const phase of sortedPhases) {
647
+ const tasks = grouped[phase];
648
+ const results = await Promise.all(
649
+ tasks.map(ct =>
650
+ dispatchToChannel(workspaceRoot, ct.repo, ct.task.id)
651
+ .then(r => ({ ...r, repo: ct.repo, taskId: ct.task.id, phase: ct.phase }))
652
+ )
653
+ );
654
+ for (const r of results) {
655
+ (r.ok ? dispatched : failed).push(r);
656
+ }
657
+
658
+ // Wait for this phase to complete before starting the next
659
+ const phaseTaskIds = results.filter(r => r.ok).map(r => r.taskId);
660
+ if (phaseTaskIds.length > 0 && phase !== sortedPhases[sortedPhases.length - 1]) {
661
+ const completion = await waitForCompletion(workspaceRoot, phaseTaskIds, {
662
+ timeoutMs: opts.phaseTimeoutMs ?? 15 * 60 * 1000 // 15 min per phase
663
+ });
664
+ if (completion.timedOut) {
665
+ // Abort further phases — provider didn't finish
666
+ failed.push({ ok: false, message: `Phase timed out waiting for: ${completion.pending.join(', ')}`, phase });
667
+ break;
668
+ }
669
+ }
670
+ }
671
+ }
672
+
673
+ return { dispatched, failed };
674
+ }
675
+
676
+ // ============================================================
677
+ // Completion Monitoring (wf-d4b98f60)
678
+ // ============================================================
679
+
680
+ /**
681
+ * Wait for workspace tasks to complete by polling the message bus.
682
+ *
683
+ * @param {string} workspaceRoot
684
+ * @param {string[]} taskIds — task IDs to wait for
685
+ * @param {Object} [opts]
686
+ * @param {number} [opts.pollIntervalMs] — poll interval (default 5000)
687
+ * @param {number} [opts.timeoutMs] — max wait time (default 1800000 = 30min)
688
+ * @returns {Promise<{ completed: string[], pending: string[], timedOut: boolean }>}
689
+ */
690
+ async function waitForCompletion(workspaceRoot, taskIds, opts = {}) {
691
+ const pollInterval = opts.pollIntervalMs ?? 5000;
692
+ const timeout = opts.timeoutMs ?? 30 * 60 * 1000;
693
+ const startTime = Date.now();
694
+ const startIso = new Date(startTime).toISOString();
695
+ const completed = new Set();
696
+ const taskIdSet = new Set(taskIds);
697
+
698
+ let readMessages, updateMessageStatus;
699
+ try {
700
+ const bus = getWorkspaceMessages();
701
+ readMessages = bus.readMessages;
702
+ updateMessageStatus = bus.updateMessageStatus;
703
+ } catch (_err) {
704
+ return { completed: [], pending: [...taskIds], timedOut: false, error: 'Cannot load workspace-messages module' };
705
+ }
706
+
707
+ while (completed.size < taskIds.length) {
708
+ if (Date.now() - startTime > timeout) {
709
+ return {
710
+ completed: [...completed],
711
+ pending: taskIds.filter(id => !completed.has(id)),
712
+ timedOut: true
713
+ };
714
+ }
715
+
716
+ // Read task-complete messages created AFTER we started waiting
717
+ try {
718
+ const messages = readMessages(workspaceRoot, { type: 'task-complete', status: 'pending' });
719
+ for (const msg of messages) {
720
+ // Only consider messages created after we started waiting
721
+ if (msg.timestamp && msg.timestamp < startIso) continue;
722
+
723
+ // Exact match on structured taskId field, or fallback to subject
724
+ const msgTaskId = msg.taskId || msg.subject;
725
+ if (msgTaskId && taskIdSet.has(msgTaskId) && !completed.has(msgTaskId)) {
726
+ completed.add(msgTaskId);
727
+ // Mark message as acknowledged so it's not re-processed
728
+ try {
729
+ if (updateMessageStatus) {
730
+ updateMessageStatus(workspaceRoot, msg.id, 'acknowledged');
731
+ }
732
+ } catch (_err) {
733
+ // Non-critical
734
+ }
735
+ }
736
+ }
737
+ } catch (_err) {
738
+ // Non-critical — retry on next poll
739
+ }
740
+
741
+ if (completed.size < taskIds.length) {
742
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
743
+ }
744
+ }
745
+
746
+ return {
747
+ completed: [...completed],
748
+ pending: [],
749
+ timedOut: false
750
+ };
751
+ }
752
+
753
+ // ============================================================
754
+ // Exports
755
+ // ============================================================
756
+
757
+ module.exports = {
758
+ // Routing
759
+ analyzeTaskRouting,
760
+ ROLE_KEYWORDS,
761
+
762
+ // Delegation
763
+ generateSingleRepoDelegation,
764
+ generateCrossRepoPlan,
765
+
766
+ // Investigation
767
+ generateParallelInvestigation,
768
+
769
+ // Decomposition
770
+ decomposeToRepoTasks,
771
+
772
+ // Ordering
773
+ getExecutionOrder,
774
+
775
+ // Channel dispatch
776
+ dispatchToChannel,
777
+ dispatchCrossRepoPlan,
778
+ checkWorkerHealth,
779
+
780
+ // Completion monitoring
781
+ waitForCompletion
782
+ };