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,1349 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Multi-Repo Orchestration Layer
5
+ *
6
+ * Creates a workspace that coordinates N member repos through a manager agent.
7
+ * The workspace reads WogiFlow state files (not source code) from each member
8
+ * and generates a unified view for cross-repo task routing.
9
+ *
10
+ * Directory structure created:
11
+ * .workspace/
12
+ * ├── state/ — workspace-level state
13
+ * ├── contracts/ — shared API contracts
14
+ * ├── messages/ — agent-to-agent communication
15
+ * └── specs/ — cross-repo task specifications
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ // ============================================================
22
+ // Constants
23
+ // ============================================================
24
+
25
+ const WORKSPACE_CONFIG_FILE = 'wogi-workspace.json';
26
+ const WORKSPACE_DIR = '.workspace';
27
+ const WORKSPACE_DIRS = [
28
+ 'state',
29
+ 'contracts',
30
+ 'messages',
31
+ 'specs'
32
+ ];
33
+
34
+ const MEMBER_ROLES = ['consumer', 'provider', 'both', 'standalone', 'library'];
35
+
36
+ const STATE_FILES_TO_READ = [
37
+ { file: 'api-map.md', key: 'apiMap', description: 'API endpoints' },
38
+ { file: 'app-map.md', key: 'appMap', description: 'Components/modules' },
39
+ { file: 'schema-map.md', key: 'schemaMap', description: 'Data models' },
40
+ { file: 'function-map.md', key: 'functionMap', description: 'Utility functions' },
41
+ { file: 'decisions.md', key: 'decisions', description: 'Coding rules' },
42
+ { file: 'config.json', key: 'config', description: 'Project config', json: true }
43
+ ];
44
+
45
+ const INDEX_FILES_TO_READ = [
46
+ { file: 'api-index.json', key: 'apiIndex' },
47
+ { file: 'component-index.json', key: 'componentIndex' },
48
+ { file: 'schema-index.json', key: 'schemaIndex' },
49
+ { file: 'service-index.json', key: 'serviceIndex' },
50
+ { file: 'registry-manifest.json', key: 'registryManifest' }
51
+ ];
52
+
53
+ // ============================================================
54
+ // Discovery
55
+ // ============================================================
56
+
57
+ /**
58
+ * Scan for WogiFlow-enabled subdirectories
59
+ * @param {string} workspaceRoot — path to workspace folder
60
+ * @returns {Array<{name: string, path: string, workflowPath: string}>}
61
+ */
62
+ function discoverMembers(workspaceRoot) {
63
+ const members = [];
64
+ const entries = fs.readdirSync(workspaceRoot, { withFileTypes: true });
65
+
66
+ for (const entry of entries) {
67
+ if (!entry.isDirectory()) continue;
68
+ // Skip hidden dirs and node_modules
69
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
70
+
71
+ const memberPath = path.join(workspaceRoot, entry.name);
72
+ const workflowPath = path.join(memberPath, '.workflow');
73
+
74
+ if (fs.existsSync(workflowPath) && fs.statSync(workflowPath).isDirectory()) {
75
+ members.push({
76
+ name: entry.name,
77
+ path: memberPath,
78
+ workflowPath
79
+ });
80
+ }
81
+ }
82
+
83
+ return members;
84
+ }
85
+
86
+ // ============================================================
87
+ // State File Reading (metadata only — no source code)
88
+ // ============================================================
89
+
90
+ /**
91
+ * Read a member repo's WogiFlow state files
92
+ * @param {string} workflowPath — path to member's .workflow/ directory
93
+ * @returns {Object} parsed metadata
94
+ */
95
+ function readMemberMetadata(workflowPath) {
96
+ const statePath = path.join(workflowPath, 'state');
97
+ const metadata = {};
98
+
99
+ // Read markdown and JSON state files
100
+ for (const { file, key, json } of STATE_FILES_TO_READ) {
101
+ const filePath = path.join(json ? workflowPath : statePath, file);
102
+ try {
103
+ if (fs.existsSync(filePath)) {
104
+ const content = fs.readFileSync(filePath, 'utf-8');
105
+ metadata[key] = json ? JSON.parse(content) : content;
106
+ }
107
+ } catch (_err) {
108
+ // Non-critical — skip unreadable files
109
+ }
110
+ }
111
+
112
+ // Read JSON index files (machine-readable)
113
+ for (const { file, key } of INDEX_FILES_TO_READ) {
114
+ const filePath = path.join(statePath, file);
115
+ try {
116
+ if (fs.existsSync(filePath)) {
117
+ metadata[key] = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
118
+ }
119
+ } catch (_err) {
120
+ // Non-critical
121
+ }
122
+ }
123
+
124
+ return metadata;
125
+ }
126
+
127
+ /**
128
+ * Extract capabilities summary from member metadata
129
+ * @param {Object} metadata — parsed member metadata
130
+ * @returns {Object} capabilities summary
131
+ */
132
+ function extractCapabilities(metadata) {
133
+ const caps = {
134
+ endpoints: 0,
135
+ components: 0,
136
+ models: 0,
137
+ functions: 0,
138
+ services: 0
139
+ };
140
+
141
+ // Count from index files (most accurate)
142
+ if (metadata.apiIndex) {
143
+ const idx = metadata.apiIndex;
144
+ caps.endpoints = (idx.endpoints || []).length + (idx.clientFunctions || []).length;
145
+ }
146
+ if (metadata.componentIndex) {
147
+ const idx = metadata.componentIndex;
148
+ caps.components = (idx.components || []).length + (idx.hooks || []).length;
149
+ }
150
+ if (metadata.schemaIndex) {
151
+ const idx = metadata.schemaIndex;
152
+ caps.models = (idx.models || []).length;
153
+ }
154
+ if (metadata.serviceIndex) {
155
+ const idx = metadata.serviceIndex;
156
+ caps.services = (idx.services || []).length;
157
+ }
158
+
159
+ // Fallback: count from markdown tables if index files missing
160
+ if (caps.endpoints === 0 && metadata.apiMap) {
161
+ caps.endpoints = (metadata.apiMap.match(/^\|[^|]+\|/gm) || []).length - countHeaderRows(metadata.apiMap);
162
+ }
163
+ if (caps.components === 0 && metadata.appMap) {
164
+ caps.components = (metadata.appMap.match(/^\|[^|]+\|/gm) || []).length - countHeaderRows(metadata.appMap);
165
+ }
166
+
167
+ return caps;
168
+ }
169
+
170
+ /**
171
+ * Count markdown table header rows (lines starting with |---|)
172
+ */
173
+ function countHeaderRows(md) {
174
+ return (md.match(/^\|[-: |]+\|$/gm) || []).length;
175
+ }
176
+
177
+ /**
178
+ * Extract endpoints provided/consumed from api-map or api-index
179
+ * @param {Object} metadata
180
+ * @returns {{ provides: string[], consumes: string[] }}
181
+ */
182
+ function extractEndpoints(metadata) {
183
+ const provides = [];
184
+ const consumes = [];
185
+
186
+ if (metadata.apiIndex) {
187
+ const idx = metadata.apiIndex;
188
+ // Server endpoints = provides
189
+ for (const ep of (idx.endpoints || [])) {
190
+ const method = (ep.method || 'GET').toUpperCase();
191
+ const route = ep.route || ep.path || ep.endpoint || '';
192
+ if (route) provides.push(`${method} ${route}`);
193
+ }
194
+ // Client functions = consumes
195
+ for (const fn of (idx.clientFunctions || [])) {
196
+ const method = (fn.method || 'GET').toUpperCase();
197
+ const url = fn.url || fn.endpoint || fn.path || '';
198
+ if (url) consumes.push(`${method} ${url}`);
199
+ }
200
+ }
201
+
202
+ return { provides, consumes };
203
+ }
204
+
205
+ /**
206
+ * Detect stack from config or metadata
207
+ * @param {Object} metadata
208
+ * @param {string} memberPath
209
+ * @returns {Object} stack info
210
+ */
211
+ function detectStack(metadata, memberPath) {
212
+ const stack = {
213
+ language: 'unknown',
214
+ framework: 'unknown'
215
+ };
216
+
217
+ // From WogiFlow config
218
+ if (metadata.config) {
219
+ const c = metadata.config;
220
+ if (c.projectType) stack.projectType = c.projectType;
221
+ if (c.strictAdherence?.operational?.packageManager?.tool) {
222
+ stack.packageManager = c.strictAdherence.operational.packageManager.tool;
223
+ }
224
+ }
225
+
226
+ // Detect from package.json
227
+ const pkgPath = path.join(memberPath, 'package.json');
228
+ if (fs.existsSync(pkgPath)) {
229
+ try {
230
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
231
+ if (pkg.dependencies?.typescript || pkg.devDependencies?.typescript) {
232
+ stack.language = 'TypeScript';
233
+ } else {
234
+ stack.language = 'JavaScript';
235
+ }
236
+
237
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
238
+ if (allDeps.react) stack.framework = 'React';
239
+ else if (allDeps.next) stack.framework = 'Next.js';
240
+ else if (allDeps.vue) stack.framework = 'Vue';
241
+ else if (allDeps.svelte) stack.framework = 'Svelte';
242
+ else if (allDeps.express) stack.framework = 'Express';
243
+ else if (allDeps.fastify) stack.framework = 'Fastify';
244
+ else if (allDeps.nestjs || allDeps['@nestjs/core']) stack.framework = 'NestJS';
245
+ else if (allDeps.hono) stack.framework = 'Hono';
246
+ } catch (_err) {
247
+ // Non-critical
248
+ }
249
+ }
250
+
251
+ // Detect Python
252
+ const pyprojectPath = path.join(memberPath, 'pyproject.toml');
253
+ const requirementsPath = path.join(memberPath, 'requirements.txt');
254
+ if (fs.existsSync(pyprojectPath) || fs.existsSync(requirementsPath)) {
255
+ stack.language = 'Python';
256
+ try {
257
+ const content = fs.existsSync(pyprojectPath)
258
+ ? fs.readFileSync(pyprojectPath, 'utf-8')
259
+ : fs.readFileSync(requirementsPath, 'utf-8');
260
+ if (content.includes('fastapi')) stack.framework = 'FastAPI';
261
+ else if (content.includes('django')) stack.framework = 'Django';
262
+ else if (content.includes('flask')) stack.framework = 'Flask';
263
+ } catch (_err) {
264
+ // Non-critical
265
+ }
266
+ }
267
+
268
+ // Detect Go
269
+ if (fs.existsSync(path.join(memberPath, 'go.mod'))) {
270
+ stack.language = 'Go';
271
+ try {
272
+ const goMod = fs.readFileSync(path.join(memberPath, 'go.mod'), 'utf-8');
273
+ if (goMod.includes('gin-gonic')) stack.framework = 'Gin';
274
+ else if (goMod.includes('echo')) stack.framework = 'Echo';
275
+ else if (goMod.includes('fiber')) stack.framework = 'Fiber';
276
+ } catch (_err) {
277
+ // Non-critical
278
+ }
279
+ }
280
+
281
+ return stack;
282
+ }
283
+
284
+ // ============================================================
285
+ // Workspace Config & Manifest Generation
286
+ // ============================================================
287
+
288
+ /**
289
+ * Auto-detect role based on endpoints
290
+ * @param {{ provides: string[], consumes: string[] }} endpoints
291
+ * @returns {string} role
292
+ */
293
+ function autoDetectRole(endpoints) {
294
+ const hasProvides = endpoints.provides.length > 0;
295
+ const hasConsumes = endpoints.consumes.length > 0;
296
+
297
+ if (hasProvides && hasConsumes) return 'both';
298
+ if (hasProvides) return 'provider';
299
+ if (hasConsumes) return 'consumer';
300
+ return 'standalone';
301
+ }
302
+
303
+ /**
304
+ * Generate the workspace config (wogi-workspace.json)
305
+ * @param {string} workspaceName
306
+ * @param {Array} members — array of { name, role, path }
307
+ * @returns {Object} workspace config
308
+ */
309
+ function generateWorkspaceConfig(workspaceName, members) {
310
+ const config = {
311
+ $schema: './workspace-config.schema.json',
312
+ name: workspaceName,
313
+ version: '1.0.0',
314
+ members: {},
315
+ routing: {
316
+ default: 'auto',
317
+ providerFirst: true
318
+ },
319
+ contracts: {
320
+ autoGenerate: true,
321
+ format: 'openapi',
322
+ path: '.workspace/contracts'
323
+ },
324
+ messages: {
325
+ autoNotify: true,
326
+ path: '.workspace/messages'
327
+ },
328
+ sync: {
329
+ autoOnSessionStart: true,
330
+ autoAfterTaskComplete: true
331
+ },
332
+ channels: {
333
+ enabled: true,
334
+ basePort: 8801,
335
+ members: {}
336
+ }
337
+ };
338
+
339
+ let port = config.channels.basePort;
340
+ if (port + members.length - 1 > 65535) {
341
+ throw new Error(`Channel port range ${port}-${port + members.length - 1} exceeds maximum port 65535. Reduce basePort or number of members.`);
342
+ }
343
+ for (const member of members) {
344
+ config.members[member.name] = {
345
+ path: `./${member.name}`,
346
+ role: member.role
347
+ };
348
+ config.channels.members[member.name] = { port: port++ };
349
+ }
350
+
351
+ return config;
352
+ }
353
+
354
+ /**
355
+ * Generate the workspace manifest from member metadata
356
+ * @param {string} workspaceName
357
+ * @param {Array} members — enriched member objects
358
+ * @returns {Object} manifest
359
+ */
360
+ function generateManifest(workspaceName, members) {
361
+ const manifest = {
362
+ workspace: workspaceName,
363
+ version: '1.0.0',
364
+ generatedAt: new Date().toISOString(),
365
+ members: {},
366
+ integrations: {
367
+ matched: [],
368
+ orphanedConsumers: [],
369
+ orphanedProviders: [],
370
+ typeDrift: []
371
+ }
372
+ };
373
+
374
+ for (const member of members) {
375
+ manifest.members[member.name] = {
376
+ path: `./${member.name}`,
377
+ role: member.role,
378
+ stack: member.stack,
379
+ capabilities: member.capabilities,
380
+ provides: member.endpoints.provides,
381
+ consumes: member.endpoints.consumes,
382
+ lastSynced: new Date().toISOString()
383
+ };
384
+ }
385
+
386
+ // Cross-reference endpoints to find integration points
387
+ const allProviders = new Map(); // endpoint → [memberName]
388
+ const allConsumers = new Map(); // endpoint → [memberName]
389
+
390
+ for (const member of members) {
391
+ for (const ep of member.endpoints.provides) {
392
+ if (!allProviders.has(ep)) allProviders.set(ep, []);
393
+ allProviders.get(ep).push(member.name);
394
+ }
395
+ for (const ep of member.endpoints.consumes) {
396
+ // Normalize consumer endpoints for matching (strip base URL, query params)
397
+ const normalized = normalizeEndpoint(ep);
398
+ if (!allConsumers.has(normalized)) allConsumers.set(normalized, []);
399
+ allConsumers.get(normalized).push(member.name);
400
+ }
401
+ }
402
+
403
+ // Find matches and orphans
404
+ for (const [ep, consumers] of allConsumers) {
405
+ // Try to match against providers (fuzzy — same method + similar path)
406
+ let matched = false;
407
+ for (const [providerEp, providers] of allProviders) {
408
+ if (endpointsMatch(ep, providerEp)) {
409
+ manifest.integrations.matched.push({
410
+ endpoint: ep,
411
+ providers,
412
+ consumers
413
+ });
414
+ matched = true;
415
+ break;
416
+ }
417
+ }
418
+ if (!matched) {
419
+ manifest.integrations.orphanedConsumers.push({
420
+ endpoint: ep,
421
+ consumers
422
+ });
423
+ }
424
+ }
425
+
426
+ // Find providers with no consumers
427
+ for (const [ep, providers] of allProviders) {
428
+ let hasConsumer = false;
429
+ for (const [consumerEp] of allConsumers) {
430
+ if (endpointsMatch(consumerEp, ep)) {
431
+ hasConsumer = true;
432
+ break;
433
+ }
434
+ }
435
+ if (!hasConsumer) {
436
+ manifest.integrations.orphanedProviders.push({
437
+ endpoint: ep,
438
+ providers
439
+ });
440
+ }
441
+ }
442
+
443
+ return manifest;
444
+ }
445
+
446
+ /**
447
+ * Normalize an endpoint for matching (strip query params, base URL)
448
+ */
449
+ function normalizeEndpoint(ep) {
450
+ // Remove base URL if present
451
+ let normalized = ep.replace(/https?:\/\/[^/]+/, '');
452
+ // Remove query params
453
+ normalized = normalized.replace(/\?.*$/, '');
454
+ // Normalize path params: /users/123 → /users/:id
455
+ normalized = normalized.replace(/\/\d+/g, '/:id');
456
+ return normalized.trim();
457
+ }
458
+
459
+ /**
460
+ * Check if two endpoints match (same method + similar path)
461
+ */
462
+ function endpointsMatch(ep1, ep2) {
463
+ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
464
+ let [method1, ...pathParts1] = ep1.split(' ');
465
+ let [method2, ...pathParts2] = ep2.split(' ');
466
+
467
+ // If the first token isn't a recognized HTTP method, treat the whole string as a path
468
+ if (!HTTP_METHODS.has(method1.toUpperCase())) {
469
+ pathParts1 = [ep1];
470
+ method1 = 'GET';
471
+ }
472
+ if (!HTTP_METHODS.has(method2.toUpperCase())) {
473
+ pathParts2 = [ep2];
474
+ method2 = 'GET';
475
+ }
476
+
477
+ method1 = method1.toUpperCase();
478
+ method2 = method2.toUpperCase();
479
+
480
+ const path1 = pathParts1.join(' ').trim();
481
+ const path2 = pathParts2.join(' ').trim();
482
+
483
+ if (method1 !== method2) return false;
484
+
485
+ // Exact match
486
+ if (path1 === path2) return true;
487
+
488
+ // Normalize and compare
489
+ const norm1 = normalizeEndpoint(path1);
490
+ const norm2 = normalizeEndpoint(path2);
491
+ return norm1 === norm2;
492
+ }
493
+
494
+ // ============================================================
495
+ // CLAUDE.md Generation
496
+ // ============================================================
497
+
498
+ /**
499
+ * Generate workspace-level CLAUDE.md
500
+ * @param {Object} config — workspace config
501
+ * @param {Object} manifest — workspace manifest
502
+ * @returns {string} CLAUDE.md content
503
+ */
504
+ function generateWorkspaceClaudeMd(config, manifest) {
505
+ const memberLines = Object.entries(manifest.members).map(([name, m]) => {
506
+ return `| ${name} | ${m.role} | ${m.stack.language}/${m.stack.framework} | ${m.provides.length} provided, ${m.consumes.length} consumed |`;
507
+ });
508
+
509
+ const matchedCount = manifest.integrations.matched.length;
510
+ const orphanCount = manifest.integrations.orphanedConsumers.length;
511
+ const memberNames = Object.keys(manifest.members);
512
+ const providers = Object.entries(manifest.members).filter(([_, m]) => m.role === 'provider' || m.role === 'both').map(([n]) => n);
513
+ const consumers = Object.entries(manifest.members).filter(([_, m]) => m.role === 'consumer' || m.role === 'both').map(([n]) => n);
514
+
515
+ return `# Wogi Workspace: ${config.name}
516
+
517
+ You are a **workspace manager** coordinating ${memberNames.length} repositories. You do NOT read source code directly. You read WogiFlow state files to understand each repo, then delegate implementation to repo-scoped sub-agents.
518
+
519
+ ## CRITICAL RULES
520
+
521
+ 1. **NEVER read source code** in member repos directly. Read \`.workflow/state/\` files (api-map.md, app-map.md, decisions.md) for context.
522
+ 2. **ALWAYS delegate implementation** to sub-agents. You plan and coordinate — sub-agents write code.
523
+ 3. **Provider before consumer.** When both need changes, implement the provider side first.
524
+ 4. **Write messages after cross-repo changes.** Every change that affects another repo gets a message in \`.workspace/messages/\`.
525
+
526
+ ## Member Repos
527
+
528
+ | Repo | Role | Stack | Endpoints |
529
+ |------|------|-------|-----------|
530
+ ${memberLines.join('\n')}
531
+
532
+ ## Session Startup Checklist
533
+
534
+ When you start a session, do this FIRST:
535
+ 1. Read \`.workspace/state/workspace-manifest.json\` — understand the current integration map
536
+ 2. Check \`.workspace/messages/\` for unread messages (status: "pending") — show them to the user
537
+ 3. Read each member's \`.workflow/state/ready.json\` — know what tasks are in progress
538
+
539
+ ## How to Route Tasks
540
+
541
+ ### Step 1: Classify the Task
542
+
543
+ Read the user's request and determine:
544
+ - **Single-repo** — only affects one repo (e.g., "add a button to the login page" → ${consumers[0] || 'frontend'})
545
+ - **Cross-repo** — affects multiple repos (e.g., "add avatar upload" → needs endpoint + UI)
546
+ - **Bug investigation** — something is broken, unclear which repo
547
+
548
+ ### Step 2: Routing Keywords
549
+
550
+ ${Object.entries(manifest.members).map(([name, m]) => {
551
+ const keywords = [];
552
+ if (m.role === 'consumer' || m.role === 'both') keywords.push('page', 'component', 'UI', 'style', 'frontend', 'screen', 'form');
553
+ if (m.role === 'provider' || m.role === 'both') keywords.push('endpoint', 'model', 'database', 'migration', 'backend', 'API', 'query');
554
+ if (m.role === 'library') keywords.push('shared', 'utility', 'types', 'common');
555
+ return `- **${name}** (${m.role}): ${keywords.join(', ')}`;
556
+ }).join('\n')}
557
+ - **Both/all repos**: api contract, schema change, integration, full-stack, end-to-end
558
+
559
+ ### Step 3: Dispatch via Channels
560
+
561
+ Each worker repo runs a full Claude Code session with its own CLAUDE.md, hooks, and WogiFlow pipeline. You dispatch tasks by sending HTTP requests to their channel ports. **Do NOT use the Agent tool for implementation** — use channels for full enforcement.
562
+
563
+ **Worker Channel Ports:**
564
+ ${Object.entries(config.channels?.members || {}).map(([name, ch]) => `- **${name}**: \`http://localhost:${ch.port}\``).join('\n')}
565
+
566
+ **Check if workers are running:**
567
+ \`\`\`bash
568
+ ${Object.entries(config.channels?.members || {}).map(([name, ch]) => `curl -s http://localhost:${ch.port}/health`).join('\n')}
569
+ \`\`\`
570
+
571
+ If a worker is down, tell the user: "Start the ${'{'}repo{'}'} worker: \`cd ${'{'}repo{'}'}/ && flow workspace start\`"
572
+
573
+ **Single-repo task:**
574
+ 1. Create task in the repo's ready.json using \`decomposeToRepoTasks()\` or directly
575
+ 2. Dispatch: \`curl -s -X POST http://localhost:${'{'}port{'}'} -d "/wogi-start ${'{'}taskId${'}'}"\`
576
+ 3. The worker's full WogiFlow pipeline handles the rest (explore, spec, implement, verify)
577
+ 4. Monitor: check \`.workspace/messages/\` for a \`task-complete\` message
578
+
579
+ **Cross-repo task:**
580
+ 1. Read the contract from \`.workspace/contracts/\` (if exists)
581
+ 2. If the API contract needs updating, update it first
582
+ 3. Create tasks in each repo's ready.json (provider gets P0 priority)
583
+ 4. Dispatch to provider first: \`curl -s -X POST http://localhost:${providers[0] ? config.channels?.members?.[providers[0]]?.port || '{port}' : '{port}'} -d "/wogi-start ${'{'}providerTaskId${'}'}"\`
584
+ 5. Wait for provider completion message in \`.workspace/messages/\`
585
+ 6. Then dispatch to consumer: \`curl -s -X POST http://localhost:${consumers[0] ? config.channels?.members?.[consumers[0]]?.port || '{port}' : '{port}'} -d "/wogi-start ${'{'}consumerTaskId${'}'}"\`
586
+ 7. Wait for consumer completion, then verify integration
587
+
588
+ **Bug investigation — dispatch to all workers:**
589
+ \`\`\`bash
590
+ # Send investigation request to all workers in parallel
591
+ ${Object.entries(config.channels?.members || {}).map(([name, ch]) =>
592
+ `curl -s -X POST http://localhost:${ch.port} -d "Investigate: <BUG_DESCRIPTION>. Check recent changes, error logs, relevant code. Report back via workspace message."`
593
+ ).join('\n')}
594
+ \`\`\`
595
+ Then read responses from \`.workspace/messages/\` and synthesize findings.
596
+
597
+ ## Reading Member State (What to Read, When)
598
+
599
+ | When | What to Read | Path |
600
+ |------|-------------|------|
601
+ | Before routing a task | api-map.md | \`<repo>/.workflow/state/api-map.md\` |
602
+ | Before spawning an agent | decisions.md | \`<repo>/.workflow/state/decisions.md\` |
603
+ | To understand structure | app-map.md | \`<repo>/.workflow/state/app-map.md\` |
604
+ | To check data models | schema-map.md | \`<repo>/.workflow/state/schema-map.md\` |
605
+ | To check task status | ready.json | \`<repo>/.workflow/state/ready.json\` |
606
+
607
+ **NEVER read**: source files (\`src/\`, \`lib/\`, \`app/\`, etc.) — leave that to sub-agents.
608
+
609
+ ## Integration Map
610
+
611
+ - **${matchedCount}** matched endpoint pair${matchedCount !== 1 ? 's' : ''} (provider ↔ consumer)
612
+ ${orphanCount > 0 ? `- **${orphanCount}** orphaned consumer${orphanCount !== 1 ? 's' : ''} (calling endpoints with no provider) ⚠️` : '- No orphaned consumers ✓'}
613
+
614
+ Full details: \`.workspace/state/workspace-manifest.json\`
615
+ Visual map: \`.workspace/state/integration-map.md\`
616
+
617
+ ## Message Bus
618
+
619
+ After ANY cross-repo change, write a message to \`.workspace/messages/\`:
620
+
621
+ \`\`\`json
622
+ {
623
+ "id": "msg-<random-8-hex>",
624
+ "from": "<repo-that-changed>",
625
+ "to": "<affected-repo>",
626
+ "type": "contract-change",
627
+ "subject": "Changed POST /api/users — added email_verified field",
628
+ "body": "Details of what changed and why...",
629
+ "actionRequired": true,
630
+ "status": "pending",
631
+ "timestamp": "<ISO-8601>"
632
+ }
633
+ \`\`\`
634
+
635
+ **Message types**: \`contract-change\`, \`question\`, \`bug-report\`, \`task-complete\`, \`needs-help\`, \`heads-up\`
636
+
637
+ When spawning a sub-agent, check for pending messages to that repo and include them in the prompt context.
638
+
639
+ ## Contracts
640
+
641
+ Shared API contracts: \`.workspace/contracts/\`
642
+ When a provider changes an endpoint, update the contract BEFORE the consumer implements.
643
+
644
+ ## Peer Communication (Worker ↔ Worker)
645
+
646
+ Workers can talk directly to each other — no manager bottleneck. Each worker knows its peers' channel ports.
647
+
648
+ A worker asking its peer a question:
649
+ \`\`\`bash
650
+ curl -s -X POST http://localhost:{peer_port} -H "X-Wogi-From: {my_repo}" -d "Is the POST /users endpoint ready? What's the expected payload shape?"
651
+ \`\`\`
652
+
653
+ The peer receives this as a channel event, reads its codebase to answer, and can reply back the same way. This is useful when:
654
+ - Frontend needs to know the exact API shape before implementing
655
+ - Backend wants to know which fields the frontend actually uses
656
+ - Any repo needs real-time coordination without going through the manager
657
+
658
+ Workers also have a \`workspace_send_message\` MCP tool that handles this automatically.
659
+
660
+ ## Command Routing (CRITICAL)
661
+
662
+ **You are an orchestrator — you do NOT execute code-level commands locally.** When the user invokes a WogiFlow command, route it based on this table:
663
+
664
+ | Command | Action | Why |
665
+ |---------|--------|-----|
666
+ | \`/wogi-review\` | **Dispatch to ALL workers** → aggregate findings + cross-repo analysis | Workers have the source code and full pipeline |
667
+ | \`/wogi-audit\` | **Dispatch to ALL workers** → aggregate audit results + cross-repo analysis | Same reason |
668
+ | \`/wogi-test\` | **Dispatch to ALL workers** → aggregate test results | Tests run in each repo's environment |
669
+ | \`/wogi-health\` | **Dispatch to ALL workers** → unified health report | Each repo has its own workflow state |
670
+ | \`/wogi-start "task"\` | **Analyze → decompose → dispatch** to correct worker(s) | Already designed for this |
671
+ | \`/wogi-status\` | Run **locally** — workspace-level overview | Reads workspace state files |
672
+ | \`/wogi-ready\` | Run **locally** — cross-repo task queue | Reads all repos' ready.json |
673
+ | \`/wogi-session-end\` | Run **locally** — end workspace session | Workspace-level action |
674
+
675
+ **How to dispatch a command to all workers:**
676
+ \`\`\`bash
677
+ ${Object.entries(config.channels?.members || {}).map(([name, ch]) =>
678
+ `curl -s -X POST http://localhost:${ch.port} -d "/wogi-review" # → ${name}`
679
+ ).join('\n')}
680
+ \`\`\`
681
+
682
+ **After all workers complete:** Run a cross-repo analysis that only you can do:
683
+ 1. Collect findings from \`.workspace/messages/\` (workers write results there)
684
+ 2. Check API contract alignment between provider and consumer
685
+ 3. Check for integration gaps (orphaned consumers, drifted contracts)
686
+ 4. Check shared decisions compliance (\`.workspace/state/decisions.md\`)
687
+ 5. Present unified report: per-repo findings + cross-repo findings
688
+
689
+ ## Workspace Commands
690
+
691
+ | Command | What it does |
692
+ |---------|-------------|
693
+ | \`flow workspace sync\` | Re-read all member state files, update manifest |
694
+ | \`flow workspace status\` | Show all repos, tasks, messages, contracts |
695
+ | \`flow workspace add <path>\` | Add a new member repo |
696
+ | \`flow workspace remove <name>\` | Remove a member repo |
697
+ | \`flow workspace start\` | Start a worker session (run from within a member repo) |
698
+
699
+ ## File Reference
700
+
701
+ | File | Purpose |
702
+ |------|---------|
703
+ | \`wogi-workspace.json\` | Workspace config (members, roles, settings) |
704
+ | \`.workspace/state/workspace-manifest.json\` | Auto-generated member metadata + integration map |
705
+ | \`.workspace/state/integration-map.md\` | Human-readable integration visualization |
706
+ | \`.workspace/state/ready.json\` | Workspace-level cross-repo tasks |
707
+ | \`.workspace/state/decisions.md\` | Shared cross-repo rules |
708
+ | \`.workspace/contracts/\` | Shared API contracts (OpenAPI, TypeScript, etc.) |
709
+ | \`.workspace/messages/\` | Agent-to-agent messages |
710
+
711
+ ---
712
+ Generated by Wogi Workspace v1.0.0
713
+ Last synced: ${new Date().toISOString()}
714
+ `;
715
+ }
716
+
717
+ // ============================================================
718
+ // Settings.json Generation
719
+ // ============================================================
720
+
721
+ /**
722
+ * Generate workspace-level .claude/settings.json
723
+ * Minimal hooks — workspace doesn't need validation/linting hooks.
724
+ * @returns {Object} settings config
725
+ */
726
+ function generateWorkspaceSettings(memberNames) {
727
+ // Build read patterns for member state files
728
+ const readPatterns = [];
729
+ const bashPatterns = [
730
+ 'Bash(git status *)',
731
+ 'Bash(git log *)',
732
+ 'Bash(git diff *)',
733
+ 'Bash(git show *)',
734
+ 'Bash(git branch *)',
735
+ 'Bash(git add *)',
736
+ 'Bash(git commit *)',
737
+ 'Bash(git push *)',
738
+ 'Bash(git pull *)',
739
+ 'Bash(git fetch *)',
740
+ 'Bash(node --check *)',
741
+ 'Bash(node scripts/*)',
742
+ 'Bash(ls *)',
743
+ 'Bash(cat *)',
744
+ 'Bash(curl http://localhost:*)',
745
+ 'Bash(curl http://127.0.0.1:*)',
746
+ 'Bash(curl -s http://localhost:*)',
747
+ 'Bash(curl -s http://127.0.0.1:*)',
748
+ 'Bash(curl -s -X POST http://localhost:*)',
749
+ 'Bash(curl -s -X POST http://127.0.0.1:*)'
750
+ ];
751
+
752
+ for (const name of (memberNames || [])) {
753
+ readPatterns.push(`Read(${name}/.workflow/**)`);
754
+ readPatterns.push(`Read(${name}/package.json)`);
755
+ readPatterns.push(`Read(${name}/pyproject.toml)`);
756
+ readPatterns.push(`Read(${name}/go.mod)`);
757
+ }
758
+
759
+ return {
760
+ permissions: {
761
+ allow: [
762
+ // Allow reading member state files
763
+ ...readPatterns,
764
+ // Allow reading workspace state
765
+ 'Read(.workspace/**)',
766
+ 'Read(wogi-workspace.json)',
767
+ 'Read(CLAUDE.md)',
768
+ // Allow writing workspace state
769
+ 'Write(.workspace/**)',
770
+ 'Edit(.workspace/**)',
771
+ // Allow git and node
772
+ ...bashPatterns
773
+ ]
774
+ },
775
+ hooks: {},
776
+ _wogiWorkspace: true,
777
+ _wogiFlowVersion: require('../package.json').version,
778
+ _comment: 'Workspace-level settings. Member repos have their own settings. Sub-agents spawned for member repos use THEIR settings, not these.'
779
+ };
780
+ }
781
+
782
+ // ============================================================
783
+ // Directory Structure Creation
784
+ // ============================================================
785
+
786
+ /**
787
+ * Create the .workspace/ directory structure
788
+ * @param {string} workspaceRoot
789
+ */
790
+ function createWorkspaceStructure(workspaceRoot) {
791
+ const wsDir = path.join(workspaceRoot, WORKSPACE_DIR);
792
+
793
+ // Create main dirs
794
+ for (const dir of WORKSPACE_DIRS) {
795
+ const dirPath = path.join(wsDir, dir);
796
+ fs.mkdirSync(dirPath, { recursive: true });
797
+ }
798
+
799
+ // Create .claude/ for workspace settings
800
+ const claudeDir = path.join(workspaceRoot, '.claude');
801
+ fs.mkdirSync(claudeDir, { recursive: true });
802
+
803
+ // Create empty ready.json for workspace-level tasks
804
+ const readyPath = path.join(wsDir, 'state', 'ready.json');
805
+ if (!fs.existsSync(readyPath)) {
806
+ fs.writeFileSync(readyPath, JSON.stringify({
807
+ lastUpdated: new Date().toISOString(),
808
+ inProgress: [],
809
+ ready: [],
810
+ blocked: [],
811
+ recentlyCompleted: []
812
+ }, null, 2));
813
+ }
814
+
815
+ // Create workspace decisions.md
816
+ const decisionsPath = path.join(wsDir, 'state', 'decisions.md');
817
+ if (!fs.existsSync(decisionsPath)) {
818
+ fs.writeFileSync(decisionsPath, `# Workspace Decisions
819
+
820
+ Cross-repo rules that apply to all member repositories.
821
+
822
+ ## Shared Conventions
823
+
824
+ <!-- Add shared rules here, e.g.: -->
825
+ <!-- ### Date Format -->
826
+ <!-- All dates use ISO 8601 (YYYY-MM-DDTHH:mm:ssZ) across all repos. -->
827
+ `);
828
+ }
829
+
830
+ // Create .gitignore for workspace transient files
831
+ const gitignorePath = path.join(wsDir, '.gitignore');
832
+ if (!fs.existsSync(gitignorePath)) {
833
+ fs.writeFileSync(gitignorePath, `# Wogi Workspace — transient files
834
+ # Messages are ephemeral (processed and resolved)
835
+ messages/*.json
836
+ # State is auto-generated from member repos
837
+ state/workspace-manifest.json
838
+ state/integration-map.md
839
+ state/contract-versions.json
840
+ `);
841
+ }
842
+ }
843
+
844
+ // ============================================================
845
+ // ============================================================
846
+ // Channel MCP Config Generation
847
+ // ============================================================
848
+
849
+ /**
850
+ * Generate .mcp.json for each member repo with channel server config.
851
+ * This allows workers to receive task dispatches from the manager
852
+ * and communicate with peer repos via channels.
853
+ *
854
+ * @param {string} workspaceRoot
855
+ * @param {Object} config — workspace config (with channels section)
856
+ */
857
+ function generateMemberMcpConfigs(workspaceRoot, config) {
858
+ const channelMembers = config.channels?.members || {};
859
+ const memberNames = Object.keys(channelMembers);
860
+
861
+ const VALID_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
862
+
863
+ for (const [name, channelConfig] of Object.entries(channelMembers)) {
864
+ if (!VALID_NAME.test(name)) {
865
+ console.error(` ✗ ${name}: invalid member name (must match [a-zA-Z0-9_-]) — skipping`);
866
+ continue;
867
+ }
868
+
869
+ const memberPath = path.resolve(workspaceRoot, config.members[name]?.path || `./${name}`);
870
+
871
+ // Path traversal guard — ensure member path is inside workspace root
872
+ if (!memberPath.startsWith(workspaceRoot + path.sep) && memberPath !== workspaceRoot) {
873
+ console.error(` ✗ ${name}: path escapes workspace root (${config.members[name]?.path}) — skipping`);
874
+ continue;
875
+ }
876
+
877
+ const mcpJsonPath = path.join(memberPath, '.mcp.json');
878
+
879
+ // Build peer list (all other members)
880
+ const peers = memberNames
881
+ .filter(n => n !== name)
882
+ .map(n => `${n}:${channelMembers[n].port}`)
883
+ .join(',');
884
+
885
+ // Resolve channel server script path
886
+ // Use require.resolve to find the installed wogiflow package
887
+ let channelServerPath;
888
+ try {
889
+ channelServerPath = require.resolve('wogiflow/lib/workspace-channel-server.js');
890
+ } catch (_err) {
891
+ // Fallback: relative path from workspace root
892
+ channelServerPath = path.join(__dirname, 'workspace-channel-server.js');
893
+ }
894
+
895
+ const mcpConfig = {
896
+ mcpServers: {
897
+ 'wogi-workspace-channel': {
898
+ command: 'node',
899
+ args: [channelServerPath],
900
+ env: {
901
+ WOGI_CHANNEL_PORT: String(channelConfig.port),
902
+ WOGI_REPO_NAME: name,
903
+ WOGI_PEERS: peers,
904
+ WOGI_WORKSPACE_ROOT: workspaceRoot
905
+ }
906
+ }
907
+ }
908
+ };
909
+
910
+ // Merge with existing .mcp.json if present
911
+ let existingConfig = {};
912
+ try {
913
+ if (fs.existsSync(mcpJsonPath)) {
914
+ existingConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8'));
915
+ }
916
+ } catch (_err) {
917
+ // Ignore malformed existing config
918
+ }
919
+
920
+ const merged = {
921
+ ...existingConfig,
922
+ mcpServers: {
923
+ ...(existingConfig.mcpServers || {}),
924
+ ...mcpConfig.mcpServers
925
+ }
926
+ };
927
+
928
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(merged, null, 2));
929
+ console.log(` ✓ ${name}/.mcp.json (channel port ${channelConfig.port}, peers: ${peers || 'none'})`);
930
+ }
931
+ }
932
+
933
+ // ============================================================
934
+ // Main Init Function
935
+ // ============================================================
936
+
937
+ /**
938
+ * Initialize a Wogi Workspace
939
+ * @param {string[]} args — CLI arguments
940
+ */
941
+ async function initWorkspace(args) {
942
+ const workspaceRoot = process.cwd();
943
+ const workspaceName = path.basename(workspaceRoot);
944
+
945
+ // Check if workspace already exists
946
+ if (fs.existsSync(path.join(workspaceRoot, WORKSPACE_CONFIG_FILE))) {
947
+ console.error(`Workspace already initialized in ${workspaceRoot}`);
948
+ console.error('Use `flow workspace sync` to update.');
949
+ process.exit(1);
950
+ }
951
+
952
+ console.log('🔍 Scanning for WogiFlow-enabled projects...\n');
953
+
954
+ // Discover member repos
955
+ const discovered = discoverMembers(workspaceRoot);
956
+
957
+ if (discovered.length === 0) {
958
+ console.error('No WogiFlow-enabled projects found in subdirectories.');
959
+ console.error('Each member repo must have a .workflow/ directory (run `flow init` in each first).');
960
+ process.exit(1);
961
+ }
962
+
963
+ console.log(`Found ${discovered.length} WogiFlow project${discovered.length !== 1 ? 's' : ''}:`);
964
+ for (const m of discovered) {
965
+ console.log(` ✓ ${m.name}/`);
966
+ }
967
+ console.log('');
968
+
969
+ // Read metadata from each member
970
+ console.log('── Reading project metadata ──────────────────\n');
971
+ const members = [];
972
+
973
+ for (const disc of discovered) {
974
+ const metadata = readMemberMetadata(disc.workflowPath);
975
+ const stack = detectStack(metadata, disc.path);
976
+ const capabilities = extractCapabilities(metadata);
977
+ const endpoints = extractEndpoints(metadata);
978
+ const role = autoDetectRole(endpoints);
979
+
980
+ members.push({
981
+ name: disc.name,
982
+ path: disc.path,
983
+ workflowPath: disc.workflowPath,
984
+ metadata,
985
+ stack,
986
+ capabilities,
987
+ endpoints,
988
+ role
989
+ });
990
+
991
+ const capsSummary = Object.entries(capabilities)
992
+ .filter(([_, v]) => v > 0)
993
+ .map(([k, v]) => `${v} ${k}`)
994
+ .join(', ') || 'no data yet';
995
+
996
+ console.log(` ${disc.name}/ (${stack.language}/${stack.framework})`);
997
+ console.log(` Role: ${role} | ${capsSummary}`);
998
+ console.log(` Provides: ${endpoints.provides.length} endpoints | Consumes: ${endpoints.consumes.length} endpoints`);
999
+ console.log('');
1000
+ }
1001
+
1002
+ // Create directory structure
1003
+ console.log('── Creating workspace structure ──────────────\n');
1004
+ createWorkspaceStructure(workspaceRoot);
1005
+ console.log(' ✓ .workspace/state/');
1006
+ console.log(' ✓ .workspace/contracts/');
1007
+ console.log(' ✓ .workspace/messages/');
1008
+ console.log(' ✓ .workspace/specs/');
1009
+ console.log('');
1010
+
1011
+ // Generate workspace config
1012
+ const config = generateWorkspaceConfig(workspaceName, members);
1013
+ fs.writeFileSync(
1014
+ path.join(workspaceRoot, WORKSPACE_CONFIG_FILE),
1015
+ JSON.stringify(config, null, 2)
1016
+ );
1017
+ console.log(` ✓ ${WORKSPACE_CONFIG_FILE}`);
1018
+
1019
+ // Generate manifest
1020
+ const manifest = generateManifest(workspaceName, members);
1021
+ fs.writeFileSync(
1022
+ path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'workspace-manifest.json'),
1023
+ JSON.stringify(manifest, null, 2)
1024
+ );
1025
+ console.log(' ✓ .workspace/state/workspace-manifest.json');
1026
+
1027
+ // Generate integration map (human-readable markdown)
1028
+ const integrationMap = generateIntegrationMap(manifest);
1029
+ fs.writeFileSync(
1030
+ path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'integration-map.md'),
1031
+ integrationMap
1032
+ );
1033
+ console.log(' ✓ .workspace/state/integration-map.md');
1034
+
1035
+ // Generate CLAUDE.md
1036
+ const claudeMd = generateWorkspaceClaudeMd(config, manifest);
1037
+ fs.writeFileSync(path.join(workspaceRoot, 'CLAUDE.md'), claudeMd);
1038
+ console.log(' ✓ CLAUDE.md (workspace manager instructions)');
1039
+
1040
+ // Generate settings.json
1041
+ const settings = generateWorkspaceSettings(members.map(m => m.name));
1042
+ fs.writeFileSync(
1043
+ path.join(workspaceRoot, '.claude', 'settings.json'),
1044
+ JSON.stringify(settings, null, 2)
1045
+ );
1046
+ console.log(' ✓ .claude/settings.json');
1047
+
1048
+ // Generate .mcp.json for each member repo (channel server config)
1049
+ if (config.channels?.enabled) {
1050
+ console.log('');
1051
+ console.log('── Setting up workspace channels ────────────\n');
1052
+ generateMemberMcpConfigs(workspaceRoot, config);
1053
+ }
1054
+ console.log('');
1055
+
1056
+ // Summary
1057
+ const matched = manifest.integrations.matched.length;
1058
+ const orphanedC = manifest.integrations.orphanedConsumers.length;
1059
+ const orphanedP = manifest.integrations.orphanedProviders.length;
1060
+
1061
+ console.log('── Integration Summary ──────────────────────\n');
1062
+ console.log(` ✓ ${matched} matched endpoint pair${matched !== 1 ? 's' : ''}`);
1063
+ if (orphanedC > 0) {
1064
+ console.log(` ⚠️ ${orphanedC} orphaned consumer${orphanedC !== 1 ? 's' : ''} (calling endpoints with no provider)`);
1065
+ for (const orphan of manifest.integrations.orphanedConsumers) {
1066
+ console.log(` → ${orphan.endpoint} (consumed by: ${orphan.consumers.join(', ')})`);
1067
+ }
1068
+ }
1069
+ if (orphanedP > 0) {
1070
+ console.log(` ℹ️ ${orphanedP} endpoint${orphanedP !== 1 ? 's' : ''} with no consumer`);
1071
+ }
1072
+ console.log('');
1073
+
1074
+ console.log(`✅ Workspace "${workspaceName}" initialized with ${members.length} member${members.length !== 1 ? 's' : ''}!`);
1075
+ console.log('');
1076
+ console.log('Next steps:');
1077
+ console.log(" 1. Run 'claude' in this folder to start the workspace manager");
1078
+ console.log(" 2. Give it tasks — it will route them to the right repo(s)");
1079
+ console.log(" 3. Run 'flow workspace sync' after external changes");
1080
+ console.log('');
1081
+ }
1082
+
1083
+ /**
1084
+ * Generate human-readable integration map markdown
1085
+ * @param {Object} manifest
1086
+ * @returns {string}
1087
+ */
1088
+ function generateIntegrationMap(manifest) {
1089
+ const lines = ['# Integration Map\n'];
1090
+ lines.push(`Generated: ${manifest.generatedAt}\n`);
1091
+
1092
+ // Matched endpoints
1093
+ if (manifest.integrations.matched.length > 0) {
1094
+ lines.push('## Matched Endpoints\n');
1095
+ lines.push('| Endpoint | Provider(s) | Consumer(s) |');
1096
+ lines.push('|----------|-------------|-------------|');
1097
+ for (const m of manifest.integrations.matched) {
1098
+ lines.push(`| \`${m.endpoint}\` | ${m.providers.join(', ')} | ${m.consumers.join(', ')} |`);
1099
+ }
1100
+ lines.push('');
1101
+ }
1102
+
1103
+ // Orphaned consumers
1104
+ if (manifest.integrations.orphanedConsumers.length > 0) {
1105
+ lines.push('## ⚠️ Orphaned Consumers\n');
1106
+ lines.push('These repos call endpoints that no provider serves:\n');
1107
+ for (const o of manifest.integrations.orphanedConsumers) {
1108
+ lines.push(`- \`${o.endpoint}\` — consumed by: ${o.consumers.join(', ')}`);
1109
+ }
1110
+ lines.push('');
1111
+ }
1112
+
1113
+ // Orphaned providers
1114
+ if (manifest.integrations.orphanedProviders.length > 0) {
1115
+ lines.push('## ℹ️ Endpoints Without Consumers\n');
1116
+ lines.push('These endpoints are served but no consumer calls them:\n');
1117
+ for (const o of manifest.integrations.orphanedProviders) {
1118
+ lines.push(`- \`${o.endpoint}\` — provided by: ${o.providers.join(', ')}`);
1119
+ }
1120
+ lines.push('');
1121
+ }
1122
+
1123
+ // Member summary
1124
+ lines.push('## Members\n');
1125
+ for (const [name, m] of Object.entries(manifest.members)) {
1126
+ lines.push(`### ${name} (${m.role})`);
1127
+ lines.push(`- **Stack**: ${m.stack.language} / ${m.stack.framework}`);
1128
+ lines.push(`- **Provides**: ${m.provides.length > 0 ? m.provides.join(', ') : 'none'}`);
1129
+ lines.push(`- **Consumes**: ${m.consumes.length > 0 ? m.consumes.join(', ') : 'none'}`);
1130
+ lines.push('');
1131
+ }
1132
+
1133
+ return lines.join('\n');
1134
+ }
1135
+
1136
+ // ============================================================
1137
+ // Worker Session Launcher
1138
+ // ============================================================
1139
+
1140
+ /**
1141
+ * Start a Claude Code worker session with the workspace channel enabled.
1142
+ * Must be run from within a member repo directory.
1143
+ *
1144
+ * @param {string} cwd — current working directory (should be a member repo)
1145
+ */
1146
+ function startWorkerSession(cwd) {
1147
+ const { execSync } = require('node:child_process');
1148
+
1149
+ // Find workspace root by walking up
1150
+ let workspaceRoot = null;
1151
+ let dir = cwd;
1152
+ while (dir !== path.dirname(dir)) {
1153
+ if (fs.existsSync(path.join(dir, WORKSPACE_CONFIG_FILE))) {
1154
+ workspaceRoot = dir;
1155
+ break;
1156
+ }
1157
+ dir = path.dirname(dir);
1158
+ }
1159
+
1160
+ if (!workspaceRoot) {
1161
+ console.error('Error: Not inside a workspace. Could not find wogi-workspace.json in any parent directory.');
1162
+ process.exit(1);
1163
+ }
1164
+
1165
+ // Read workspace config
1166
+ let config;
1167
+ try {
1168
+ config = JSON.parse(fs.readFileSync(path.join(workspaceRoot, WORKSPACE_CONFIG_FILE), 'utf-8'));
1169
+ } catch (err) {
1170
+ console.error(`Error reading workspace config: ${err.message}`);
1171
+ process.exit(1);
1172
+ }
1173
+
1174
+ if (!config.channels?.enabled) {
1175
+ console.error('Error: Channels are not enabled in this workspace. Run "flow workspace init" to set up channels.');
1176
+ process.exit(1);
1177
+ }
1178
+
1179
+ // Determine which member we are based on cwd
1180
+ const relativePath = path.relative(workspaceRoot, cwd);
1181
+ let memberName = null;
1182
+ let memberChannel = null;
1183
+
1184
+ for (const [name, memberConfig] of Object.entries(config.members)) {
1185
+ const memberRelPath = memberConfig.path.replace(/^\.\//, '');
1186
+ if (relativePath === memberRelPath || relativePath.startsWith(memberRelPath + path.sep)) {
1187
+ memberName = name;
1188
+ memberChannel = config.channels.members[name];
1189
+ break;
1190
+ }
1191
+ }
1192
+
1193
+ if (!memberName || !memberChannel) {
1194
+ console.error(`Error: Current directory "${relativePath}" does not match any workspace member.`);
1195
+ console.error('Members:', Object.keys(config.members).join(', '));
1196
+ process.exit(1);
1197
+ }
1198
+
1199
+ // Build peer list
1200
+ const peers = Object.entries(config.channels.members)
1201
+ .filter(([n]) => n !== memberName)
1202
+ .map(([n, ch]) => `${n}:${ch.port}`)
1203
+ .join(',');
1204
+
1205
+ // Verify .mcp.json exists with channel config
1206
+ const mcpJsonPath = path.join(cwd, '.mcp.json');
1207
+ let mcpConfigValid = false;
1208
+ try {
1209
+ if (fs.existsSync(mcpJsonPath)) {
1210
+ const mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8'));
1211
+ mcpConfigValid = !!mcpConfig?.mcpServers?.['wogi-workspace-channel'];
1212
+ }
1213
+ } catch (_err) {
1214
+ // Malformed .mcp.json
1215
+ }
1216
+
1217
+ if (!mcpConfigValid) {
1218
+ console.error(`Error: .mcp.json missing or does not contain wogi-workspace-channel config.`);
1219
+ console.error('Run "flow workspace init" from the workspace root to generate channel configs.');
1220
+ process.exit(1);
1221
+ }
1222
+
1223
+ console.log(`Starting worker session for "${memberName}" (port ${memberChannel.port})`);
1224
+ if (peers) console.log(`Peers: ${peers}`);
1225
+ console.log('');
1226
+
1227
+ // Set up environment for the channel server
1228
+ const env = {
1229
+ ...process.env,
1230
+ WOGI_CHANNEL_PORT: String(memberChannel.port),
1231
+ WOGI_REPO_NAME: memberName,
1232
+ WOGI_PEERS: peers,
1233
+ WOGI_WORKSPACE_ROOT: workspaceRoot
1234
+ };
1235
+
1236
+ // Launch Claude Code with the channel
1237
+ try {
1238
+ execSync('claude --dangerously-load-development-channels server:wogi-workspace-channel', {
1239
+ cwd,
1240
+ env,
1241
+ stdio: 'inherit'
1242
+ });
1243
+ } catch (err) {
1244
+ // Exit code 0 or SIGINT from user quit — that's fine
1245
+ if (err.status && err.status !== 0 && err.signal !== 'SIGINT') {
1246
+ console.error(`Worker session exited with error (code ${err.status}): ${err.message}`);
1247
+ process.exit(err.status);
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ // ============================================================
1253
+ // CLI Router
1254
+ // ============================================================
1255
+
1256
+ /**
1257
+ * Handle workspace subcommands
1258
+ * @param {string[]} args
1259
+ */
1260
+ async function workspace(args) {
1261
+ const subcommand = args[0];
1262
+
1263
+ switch (subcommand) {
1264
+ case 'init':
1265
+ await initWorkspace(args.slice(1));
1266
+ break;
1267
+ case 'sync': {
1268
+ const { syncWorkspace } = require('./workspace-sync');
1269
+ const result = syncWorkspace(process.cwd());
1270
+ console.log(`✓ Synced ${result.membersUpdated} member(s). ${result.changes.length} change(s) detected.`);
1271
+ if (result.warnings.length > 0) {
1272
+ for (const w of result.warnings) console.log(` ⚠️ ${w}`);
1273
+ }
1274
+ break;
1275
+ }
1276
+ case 'status': {
1277
+ const { getWorkspaceStatus } = require('./workspace-sync');
1278
+ console.log(getWorkspaceStatus(process.cwd()));
1279
+ break;
1280
+ }
1281
+ case 'add': {
1282
+ const { addMember } = require('./workspace-sync');
1283
+ const memberPath = args[1];
1284
+ const role = args[2];
1285
+ if (!memberPath) {
1286
+ console.error('Usage: flow workspace add <path> [role]');
1287
+ process.exit(1);
1288
+ }
1289
+ const result = addMember(process.cwd(), memberPath, role);
1290
+ console.log(`✓ Added '${result.name}' as ${result.role}`);
1291
+ break;
1292
+ }
1293
+ case 'remove': {
1294
+ const { removeMember } = require('./workspace-sync');
1295
+ const name = args[1];
1296
+ if (!name) {
1297
+ console.error('Usage: flow workspace remove <name>');
1298
+ process.exit(1);
1299
+ }
1300
+ removeMember(process.cwd(), name);
1301
+ console.log(`✓ Removed '${name}' from workspace`);
1302
+ break;
1303
+ }
1304
+ case 'start': {
1305
+ startWorkerSession(process.cwd());
1306
+ break;
1307
+ }
1308
+ default:
1309
+ console.log(`
1310
+ Wogi Workspace — Multi-Repo Orchestration
1311
+
1312
+ Usage: flow workspace <command>
1313
+
1314
+ Commands:
1315
+ init Initialize a workspace from member repos
1316
+ sync Re-sync workspace manifest from member state files
1317
+ status Show unified workspace status
1318
+ add Add a member repo to the workspace
1319
+ remove Remove a member repo from the workspace
1320
+ start Start a worker session with channel (run from a member repo)
1321
+
1322
+ Examples:
1323
+ flow workspace init # Create workspace from subdirectories
1324
+ flow workspace sync # Refresh after external changes
1325
+ flow workspace status # Show all repos, tasks, contracts
1326
+ cd frontend/ && flow workspace start # Start worker session
1327
+ `);
1328
+ }
1329
+ }
1330
+
1331
+ module.exports = {
1332
+ workspace,
1333
+ initWorkspace,
1334
+ discoverMembers,
1335
+ readMemberMetadata,
1336
+ extractCapabilities,
1337
+ extractEndpoints,
1338
+ detectStack,
1339
+ generateWorkspaceConfig,
1340
+ generateManifest,
1341
+ generateWorkspaceClaudeMd,
1342
+ generateWorkspaceSettings,
1343
+ createWorkspaceStructure,
1344
+ WORKSPACE_CONFIG_FILE,
1345
+ WORKSPACE_DIR,
1346
+ MEMBER_ROLES,
1347
+ generateMemberMcpConfigs,
1348
+ startWorkerSession
1349
+ };