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.
- package/.claude/commands/wogi-audit.md +26 -0
- package/.claude/commands/wogi-review.md +29 -0
- package/.claude/commands/wogi-start.md +124 -0
- package/.claude/docs/claude-code-compatibility.md +24 -0
- package/.claude/docs/explore-agents.md +19 -2
- package/.claude/settings.json +11 -0
- package/bin/flow +11 -1
- package/lib/workspace-channel-server.js +364 -0
- package/lib/workspace-contracts.js +599 -0
- package/lib/workspace-intelligence.js +600 -0
- package/lib/workspace-messages.js +441 -0
- package/lib/workspace-routing.js +782 -0
- package/lib/workspace-sync.js +339 -0
- package/lib/workspace.js +1349 -0
- package/package.json +1 -1
- package/scripts/flow-config-defaults.js +28 -0
- package/scripts/flow-eval-calibration.js +257 -0
- package/scripts/flow-eval-judge.js +10 -1
- package/scripts/flow-eval.js +9 -0
- package/scripts/flow-schema-drift.js +837 -0
- package/scripts/hooks/adapters/claude-code.js +29 -0
- package/scripts/hooks/core/task-created.js +83 -0
- package/scripts/hooks/entry/claude-code/task-created.js +15 -0
- package/scripts/postinstall.js +2 -0
|
@@ -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
|
+
};
|