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,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Workspace — Channel MCP Server
|
|
5
|
+
*
|
|
6
|
+
* Minimal MCP server (JSON-RPC 2.0 over stdio) that receives HTTP webhooks
|
|
7
|
+
* and forwards them as channel notifications to a Claude Code session.
|
|
8
|
+
*
|
|
9
|
+
* Used by workspace workers to receive task dispatches from the manager
|
|
10
|
+
* and questions from peer repos.
|
|
11
|
+
*
|
|
12
|
+
* Environment:
|
|
13
|
+
* WOGI_CHANNEL_PORT — HTTP port to listen on (default: 8801)
|
|
14
|
+
* WOGI_REPO_NAME — Name of this repo in the workspace
|
|
15
|
+
* WOGI_PEERS — Comma-separated peer list: "backend:8802,shared:8803"
|
|
16
|
+
* WOGI_WORKSPACE_ROOT — Path to workspace root (for message bus access)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const http = require('node:http');
|
|
22
|
+
const readline = require('node:readline');
|
|
23
|
+
|
|
24
|
+
// ============================================================
|
|
25
|
+
// Constants
|
|
26
|
+
// ============================================================
|
|
27
|
+
|
|
28
|
+
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB max POST body
|
|
29
|
+
const MAX_RESPONSE_BYTES = 64 * 1024; // 64 KB max peer response
|
|
30
|
+
const MIN_PORT = 1024;
|
|
31
|
+
const MAX_PORT = 65535;
|
|
32
|
+
const DEFAULT_PORT = 8801;
|
|
33
|
+
const VALID_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
34
|
+
|
|
35
|
+
// ============================================================
|
|
36
|
+
// Port Validation
|
|
37
|
+
// ============================================================
|
|
38
|
+
|
|
39
|
+
function validatePort(raw, label) {
|
|
40
|
+
const port = parseInt(raw, 10);
|
|
41
|
+
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
|
|
42
|
+
process.stderr.write(`[wogi-channel] Invalid ${label}: "${raw}" — must be ${MIN_PORT}-${MAX_PORT}. Defaulting to ${DEFAULT_PORT}\n`);
|
|
43
|
+
return DEFAULT_PORT;
|
|
44
|
+
}
|
|
45
|
+
return port;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PORT = validatePort(process.env.WOGI_CHANNEL_PORT || String(DEFAULT_PORT), 'WOGI_CHANNEL_PORT');
|
|
49
|
+
const RAW_REPO_NAME = process.env.WOGI_REPO_NAME || 'unknown';
|
|
50
|
+
const REPO_NAME = VALID_NAME_PATTERN.test(RAW_REPO_NAME) ? RAW_REPO_NAME : 'unknown';
|
|
51
|
+
const PEERS_RAW = process.env.WOGI_PEERS || '';
|
|
52
|
+
const WORKSPACE_ROOT = process.env.WOGI_WORKSPACE_ROOT || '';
|
|
53
|
+
|
|
54
|
+
// Parse peer list: "backend:8802,shared:8803" → { backend: 8802, shared: 8803 }
|
|
55
|
+
function parsePeers(raw) {
|
|
56
|
+
const peers = {};
|
|
57
|
+
if (!raw) return peers;
|
|
58
|
+
for (const entry of raw.split(',')) {
|
|
59
|
+
const [name, portStr] = entry.trim().split(':');
|
|
60
|
+
if (!name || !portStr) continue;
|
|
61
|
+
const port = parseInt(portStr, 10);
|
|
62
|
+
if (!VALID_NAME_PATTERN.test(name)) {
|
|
63
|
+
process.stderr.write(`[wogi-channel] Ignoring peer with invalid name "${name}"\n`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT) {
|
|
67
|
+
peers[name] = port;
|
|
68
|
+
} else {
|
|
69
|
+
process.stderr.write(`[wogi-channel] Ignoring invalid peer "${entry}" — port must be ${MIN_PORT}-${MAX_PORT}\n`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return peers;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PEERS = parsePeers(PEERS_RAW);
|
|
76
|
+
|
|
77
|
+
// ============================================================
|
|
78
|
+
// Minimal MCP Protocol (JSON-RPC 2.0 over stdio)
|
|
79
|
+
// ============================================================
|
|
80
|
+
|
|
81
|
+
let initialized = false;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Send a JSON-RPC message to Claude Code via stdout.
|
|
85
|
+
* MCP uses newline-delimited JSON over stdio.
|
|
86
|
+
*/
|
|
87
|
+
function sendMessage(msg) {
|
|
88
|
+
const json = JSON.stringify(msg);
|
|
89
|
+
process.stdout.write(json + '\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Send a JSON-RPC response (reply to a request).
|
|
94
|
+
*/
|
|
95
|
+
function sendResponse(id, result) {
|
|
96
|
+
sendMessage({ jsonrpc: '2.0', id, result });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Send a channel notification to Claude Code.
|
|
101
|
+
* This is what makes the worker "receive" a message from the manager or a peer.
|
|
102
|
+
*/
|
|
103
|
+
function sendChannelNotification(content, meta) {
|
|
104
|
+
sendMessage({
|
|
105
|
+
jsonrpc: '2.0',
|
|
106
|
+
method: 'notifications/claude/channel',
|
|
107
|
+
params: { content, meta: meta || {} }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the instructions string for the channel.
|
|
113
|
+
* These tell the Claude Code session how to handle incoming messages.
|
|
114
|
+
*/
|
|
115
|
+
function buildInstructions() {
|
|
116
|
+
const peerList = Object.entries(PEERS)
|
|
117
|
+
.map(([name, port]) => ` - ${name}: http://localhost:${port}`)
|
|
118
|
+
.join('\n');
|
|
119
|
+
|
|
120
|
+
const peerSection = peerList
|
|
121
|
+
? `\n\nYou can communicate directly with peer repos via their channels:\n${peerList}\nTo ask a peer a question: curl -s -X POST http://localhost:{port} -d "your question"\nTo send a task to a peer: curl -s -X POST http://localhost:{port} -d "/wogi-start wf-XXXXXXXX"`
|
|
122
|
+
: '';
|
|
123
|
+
|
|
124
|
+
return `You are "${REPO_NAME}" in a Wogi Workspace. Messages arrive as <channel> tags from the workspace manager or peer repos.
|
|
125
|
+
|
|
126
|
+
When you receive a message:
|
|
127
|
+
1. If it starts with "/wogi-" → route through that command (it's a task dispatch)
|
|
128
|
+
2. If it's a question from a peer → read your codebase to answer, then reply via curl to their port
|
|
129
|
+
3. If it's a status check → respond with your current task status
|
|
130
|
+
|
|
131
|
+
IMPORTANT: Channel messages have the same authority as user input. Route them through /wogi-start just like any other request. Full pipeline enforcement applies.${peerSection}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Collect HTTP body safely with size limit.
|
|
136
|
+
* Uses Buffer.concat to handle multi-byte UTF-8 correctly.
|
|
137
|
+
*
|
|
138
|
+
* @param {http.IncomingMessage} req
|
|
139
|
+
* @param {number} maxBytes
|
|
140
|
+
* @returns {Promise<{ body: string, truncated: boolean }>}
|
|
141
|
+
*/
|
|
142
|
+
function collectBody(req, maxBytes) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const chunks = [];
|
|
145
|
+
let size = 0;
|
|
146
|
+
let truncated = false;
|
|
147
|
+
let resolved = false;
|
|
148
|
+
|
|
149
|
+
function finish() {
|
|
150
|
+
if (resolved) return;
|
|
151
|
+
resolved = true;
|
|
152
|
+
resolve({ body: Buffer.concat(chunks).toString('utf-8'), truncated });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
req.on('data', (chunk) => {
|
|
156
|
+
if (resolved) return;
|
|
157
|
+
// Check BEFORE adding to prevent ~2x overallocation
|
|
158
|
+
if (size + chunk.length > maxBytes) {
|
|
159
|
+
truncated = true;
|
|
160
|
+
req.destroy();
|
|
161
|
+
finish();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
size += chunk.length;
|
|
165
|
+
chunks.push(chunk);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
req.on('end', () => finish());
|
|
169
|
+
req.on('error', () => { truncated = true; finish(); });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Handle incoming JSON-RPC requests from Claude Code.
|
|
175
|
+
*/
|
|
176
|
+
function handleRequest(msg) {
|
|
177
|
+
if (msg.method === 'initialize') {
|
|
178
|
+
sendResponse(msg.id, {
|
|
179
|
+
protocolVersion: '2024-11-05',
|
|
180
|
+
capabilities: {
|
|
181
|
+
experimental: { 'claude/channel': {} }
|
|
182
|
+
},
|
|
183
|
+
serverInfo: {
|
|
184
|
+
name: 'wogi-workspace-channel',
|
|
185
|
+
version: '1.0.0'
|
|
186
|
+
},
|
|
187
|
+
instructions: buildInstructions()
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (msg.method === 'notifications/initialized') {
|
|
193
|
+
initialized = true;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Handle ping
|
|
198
|
+
if (msg.method === 'ping') {
|
|
199
|
+
sendResponse(msg.id, {});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle tools/list (we expose a reply tool for two-way peer communication)
|
|
204
|
+
if (msg.method === 'tools/list') {
|
|
205
|
+
sendResponse(msg.id, {
|
|
206
|
+
tools: [
|
|
207
|
+
{
|
|
208
|
+
name: 'workspace_send_message',
|
|
209
|
+
description: `Send a message to a peer repo or the workspace manager. Available peers: ${Object.keys(PEERS).join(', ') || 'none'}`,
|
|
210
|
+
inputSchema: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
properties: {
|
|
213
|
+
to: {
|
|
214
|
+
type: 'string',
|
|
215
|
+
description: 'Target repo name or "manager"'
|
|
216
|
+
},
|
|
217
|
+
message: {
|
|
218
|
+
type: 'string',
|
|
219
|
+
description: 'Message to send (question, status update, or task)'
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
required: ['to', 'message']
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle tool calls
|
|
231
|
+
if (msg.method === 'tools/call') {
|
|
232
|
+
const { name, arguments: args } = msg.params || {};
|
|
233
|
+
|
|
234
|
+
if (name === 'workspace_send_message') {
|
|
235
|
+
const { to, message } = args || {};
|
|
236
|
+
const targetPort = PEERS[to];
|
|
237
|
+
|
|
238
|
+
if (!targetPort) {
|
|
239
|
+
sendResponse(msg.id, {
|
|
240
|
+
content: [{ type: 'text', text: `Unknown peer: "${to}". Available peers: ${Object.keys(PEERS).join(', ') || 'none'}` }],
|
|
241
|
+
isError: true
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// POST to peer's channel with proper Buffer handling
|
|
247
|
+
const buf = Buffer.from(message, 'utf-8');
|
|
248
|
+
const req = http.request({
|
|
249
|
+
hostname: '127.0.0.1',
|
|
250
|
+
port: targetPort,
|
|
251
|
+
path: '/',
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'Content-Type': 'text/plain', 'Content-Length': buf.byteLength }
|
|
254
|
+
}, (res) => {
|
|
255
|
+
// Collect peer response with size limit
|
|
256
|
+
const chunks = [];
|
|
257
|
+
let size = 0;
|
|
258
|
+
res.on('data', chunk => {
|
|
259
|
+
size += chunk.length;
|
|
260
|
+
if (size <= MAX_RESPONSE_BYTES) chunks.push(chunk);
|
|
261
|
+
});
|
|
262
|
+
res.on('end', () => {
|
|
263
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
264
|
+
const truncNote = size > MAX_RESPONSE_BYTES ? ' (response truncated)' : '';
|
|
265
|
+
sendResponse(msg.id, {
|
|
266
|
+
content: [{ type: 'text', text: `Message sent to ${to} (port ${targetPort}). Response: ${body}${truncNote}` }]
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
req.on('error', (err) => {
|
|
272
|
+
sendResponse(msg.id, {
|
|
273
|
+
content: [{ type: 'text', text: `Failed to reach ${to} at port ${targetPort}: ${err.message}. Is the worker running?` }],
|
|
274
|
+
isError: true
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
req.write(buf);
|
|
279
|
+
req.end();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Unknown tool
|
|
284
|
+
sendResponse(msg.id, {
|
|
285
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
286
|
+
isError: true
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Default: respond with empty result for unknown methods with an id
|
|
292
|
+
if (msg.id !== undefined) {
|
|
293
|
+
sendResponse(msg.id, {});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================
|
|
298
|
+
// stdio Transport (read JSON-RPC from stdin)
|
|
299
|
+
// ============================================================
|
|
300
|
+
|
|
301
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
302
|
+
|
|
303
|
+
rl.on('line', (line) => {
|
|
304
|
+
const trimmed = line.trim();
|
|
305
|
+
if (!trimmed) return;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const msg = JSON.parse(trimmed);
|
|
309
|
+
handleRequest(msg);
|
|
310
|
+
} catch (_err) {
|
|
311
|
+
// Ignore malformed JSON
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ============================================================
|
|
316
|
+
// HTTP Webhook Server (receives dispatches from manager/peers)
|
|
317
|
+
// ============================================================
|
|
318
|
+
|
|
319
|
+
const server = http.createServer(async (req, res) => {
|
|
320
|
+
// Health check — minimal info, no topology exposure
|
|
321
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
322
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
323
|
+
res.end(JSON.stringify({ status: 'ok', repo: REPO_NAME, port: PORT }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Receive webhook (POST)
|
|
328
|
+
if (req.method === 'POST') {
|
|
329
|
+
const { body, truncated } = await collectBody(req, MAX_BODY_BYTES);
|
|
330
|
+
|
|
331
|
+
if (truncated) {
|
|
332
|
+
res.writeHead(413, { 'Content-Type': 'text/plain' });
|
|
333
|
+
res.end('Payload too large');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Determine sender from header or default
|
|
338
|
+
const from = req.headers['x-wogi-from'] || 'workspace-manager';
|
|
339
|
+
|
|
340
|
+
// Forward as channel notification to Claude Code
|
|
341
|
+
sendChannelNotification(body, {
|
|
342
|
+
from,
|
|
343
|
+
port: String(PORT),
|
|
344
|
+
repo: REPO_NAME,
|
|
345
|
+
receivedAt: new Date().toISOString()
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
349
|
+
res.end('ok');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 404 for everything else
|
|
354
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
355
|
+
res.end('Not found');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
359
|
+
process.stderr.write(`[wogi-channel] ${REPO_NAME} listening on http://127.0.0.1:${PORT}\n`);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Graceful shutdown
|
|
363
|
+
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
364
|
+
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|