brain-dev 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/agents/brain-checker.md +33 -0
- package/agents/brain-debugger.md +35 -0
- package/agents/brain-executor.md +37 -0
- package/agents/brain-mapper.md +44 -0
- package/agents/brain-planner.md +49 -0
- package/agents/brain-researcher.md +47 -0
- package/agents/brain-synthesizer.md +43 -0
- package/agents/brain-verifier.md +41 -0
- package/bin/brain-tools.cjs +185 -0
- package/bin/lib/adr.cjs +283 -0
- package/bin/lib/agents.cjs +152 -0
- package/bin/lib/anti-patterns.cjs +183 -0
- package/bin/lib/audit.cjs +268 -0
- package/bin/lib/commands/adr.cjs +126 -0
- package/bin/lib/commands/complete.cjs +270 -0
- package/bin/lib/commands/config.cjs +306 -0
- package/bin/lib/commands/discuss.cjs +237 -0
- package/bin/lib/commands/execute.cjs +415 -0
- package/bin/lib/commands/health.cjs +103 -0
- package/bin/lib/commands/map.cjs +101 -0
- package/bin/lib/commands/new-project.cjs +885 -0
- package/bin/lib/commands/pause.cjs +142 -0
- package/bin/lib/commands/phase-manage.cjs +357 -0
- package/bin/lib/commands/plan.cjs +451 -0
- package/bin/lib/commands/progress.cjs +167 -0
- package/bin/lib/commands/quick.cjs +447 -0
- package/bin/lib/commands/resume.cjs +196 -0
- package/bin/lib/commands/storm.cjs +590 -0
- package/bin/lib/commands/verify.cjs +504 -0
- package/bin/lib/commands.cjs +263 -0
- package/bin/lib/complexity.cjs +138 -0
- package/bin/lib/complexity.test.cjs +108 -0
- package/bin/lib/config.cjs +452 -0
- package/bin/lib/core.cjs +62 -0
- package/bin/lib/detect.cjs +603 -0
- package/bin/lib/git.cjs +112 -0
- package/bin/lib/health.cjs +356 -0
- package/bin/lib/init.cjs +310 -0
- package/bin/lib/logger.cjs +100 -0
- package/bin/lib/platform.cjs +58 -0
- package/bin/lib/requirements.cjs +158 -0
- package/bin/lib/roadmap.cjs +228 -0
- package/bin/lib/security.cjs +237 -0
- package/bin/lib/state.cjs +353 -0
- package/bin/lib/templates.cjs +48 -0
- package/bin/templates/advocate.md +182 -0
- package/bin/templates/checkpoint.md +55 -0
- package/bin/templates/debugger.md +148 -0
- package/bin/templates/discuss.md +60 -0
- package/bin/templates/executor.md +201 -0
- package/bin/templates/mapper.md +129 -0
- package/bin/templates/plan-checker.md +134 -0
- package/bin/templates/planner.md +165 -0
- package/bin/templates/researcher.md +78 -0
- package/bin/templates/storm.html +376 -0
- package/bin/templates/synthesis.md +30 -0
- package/bin/templates/verifier.md +181 -0
- package/commands/brain/adr.md +34 -0
- package/commands/brain/complete.md +37 -0
- package/commands/brain/config.md +37 -0
- package/commands/brain/discuss.md +35 -0
- package/commands/brain/execute.md +38 -0
- package/commands/brain/health.md +33 -0
- package/commands/brain/map.md +35 -0
- package/commands/brain/new-project.md +38 -0
- package/commands/brain/pause.md +26 -0
- package/commands/brain/plan.md +38 -0
- package/commands/brain/progress.md +28 -0
- package/commands/brain/quick.md +51 -0
- package/commands/brain/resume.md +28 -0
- package/commands/brain/storm.md +30 -0
- package/commands/brain/verify.md +39 -0
- package/hooks/bootstrap.sh +54 -0
- package/hooks/post-tool-use.sh +45 -0
- package/hooks/statusline.sh +130 -0
- package/package.json +36 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const net = require('node:net');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { exec } = require('node:child_process');
|
|
9
|
+
const { output, error, success } = require('../core.cjs');
|
|
10
|
+
const { readState, writeState } = require('../state.cjs');
|
|
11
|
+
|
|
12
|
+
// RFC 6455 WebSocket magic string
|
|
13
|
+
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-5AB4085B9ABC';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the Sec-WebSocket-Accept value per RFC 6455.
|
|
17
|
+
* @param {string} wsKey - Client's Sec-WebSocket-Key
|
|
18
|
+
* @returns {string} Base64-encoded SHA-1 hash
|
|
19
|
+
*/
|
|
20
|
+
function computeAcceptKey(wsKey) {
|
|
21
|
+
return crypto.createHash('sha1').update(wsKey + WS_MAGIC).digest('base64');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a WebSocket frame from a buffer per RFC 6455 Section 5.2.
|
|
26
|
+
* Handles all three payload length modes (<=125, 126=2-byte, 127=8-byte).
|
|
27
|
+
* Client-to-server frames MUST be masked.
|
|
28
|
+
* @param {Buffer} buffer
|
|
29
|
+
* @returns {{ fin: boolean, opcode: number, payload: Buffer, totalLength: number }|null}
|
|
30
|
+
*/
|
|
31
|
+
function parseFrame(buffer) {
|
|
32
|
+
if (buffer.length < 2) return null;
|
|
33
|
+
|
|
34
|
+
const firstByte = buffer[0];
|
|
35
|
+
const secondByte = buffer[1];
|
|
36
|
+
|
|
37
|
+
const fin = !!(firstByte & 0x80);
|
|
38
|
+
const opcode = firstByte & 0x0F;
|
|
39
|
+
const masked = !!(secondByte & 0x80);
|
|
40
|
+
let payloadLength = secondByte & 0x7F;
|
|
41
|
+
let offset = 2;
|
|
42
|
+
|
|
43
|
+
if (payloadLength === 126) {
|
|
44
|
+
if (buffer.length < 4) return null;
|
|
45
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
46
|
+
offset = 4;
|
|
47
|
+
} else if (payloadLength === 127) {
|
|
48
|
+
if (buffer.length < 10) return null;
|
|
49
|
+
// Read as BigUInt64BE, convert to number (safe for practical payload sizes)
|
|
50
|
+
const high = buffer.readUInt32BE(2);
|
|
51
|
+
const low = buffer.readUInt32BE(6);
|
|
52
|
+
payloadLength = high * 0x100000000 + low;
|
|
53
|
+
offset = 10;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const maskSize = masked ? 4 : 0;
|
|
57
|
+
const totalLength = offset + maskSize + payloadLength;
|
|
58
|
+
if (buffer.length < totalLength) return null;
|
|
59
|
+
|
|
60
|
+
let payload;
|
|
61
|
+
if (masked) {
|
|
62
|
+
const maskKey = buffer.slice(offset, offset + 4);
|
|
63
|
+
payload = Buffer.alloc(payloadLength);
|
|
64
|
+
for (let i = 0; i < payloadLength; i++) {
|
|
65
|
+
payload[i] = buffer[offset + 4 + i] ^ maskKey[i % 4];
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
payload = buffer.slice(offset, offset + payloadLength);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { fin, opcode, payload, totalLength };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a WebSocket frame for server-to-client transmission.
|
|
76
|
+
* Server frames MUST NOT be masked per RFC 6455.
|
|
77
|
+
* Handles all three payload length modes.
|
|
78
|
+
* @param {string|Buffer} data - Data to send
|
|
79
|
+
* @param {number} opcode - Frame opcode (default 0x01 for text)
|
|
80
|
+
* @returns {Buffer}
|
|
81
|
+
*/
|
|
82
|
+
function buildFrame(data, opcode = 0x01) {
|
|
83
|
+
const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
84
|
+
const len = payload.length;
|
|
85
|
+
|
|
86
|
+
let header;
|
|
87
|
+
if (len <= 125) {
|
|
88
|
+
header = Buffer.alloc(2);
|
|
89
|
+
header[0] = 0x80 | opcode; // FIN + opcode
|
|
90
|
+
header[1] = len;
|
|
91
|
+
} else if (len <= 0xFFFF) {
|
|
92
|
+
header = Buffer.alloc(4);
|
|
93
|
+
header[0] = 0x80 | opcode;
|
|
94
|
+
header[1] = 126;
|
|
95
|
+
header.writeUInt16BE(len, 2);
|
|
96
|
+
} else {
|
|
97
|
+
header = Buffer.alloc(10);
|
|
98
|
+
header[0] = 0x80 | opcode;
|
|
99
|
+
header[1] = 127;
|
|
100
|
+
// Write as two 32-bit values for 64-bit length
|
|
101
|
+
header.writeUInt32BE(Math.floor(len / 0x100000000), 2);
|
|
102
|
+
header.writeUInt32BE(len % 0x100000000, 6);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return Buffer.concat([header, payload]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find an available port by attempting to bind.
|
|
110
|
+
* Handles EADDRINUSE by trying the next port. No pre-check race condition.
|
|
111
|
+
* @param {number} startPort
|
|
112
|
+
* @param {number} maxAttempts
|
|
113
|
+
* @returns {Promise<number>}
|
|
114
|
+
*/
|
|
115
|
+
function findAvailablePort(startPort, maxAttempts = 10) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let attempt = 0;
|
|
118
|
+
|
|
119
|
+
function tryPort(port) {
|
|
120
|
+
if (attempt >= maxAttempts) {
|
|
121
|
+
reject(new Error(`No available port found after ${maxAttempts} attempts starting from ${startPort}`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
attempt++;
|
|
125
|
+
|
|
126
|
+
const tester = net.createServer();
|
|
127
|
+
tester.once('error', (err) => {
|
|
128
|
+
if (err.code === 'EADDRINUSE') {
|
|
129
|
+
tryPort(port + 1);
|
|
130
|
+
} else {
|
|
131
|
+
reject(err);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
tester.once('listening', () => {
|
|
135
|
+
tester.close(() => resolve(port));
|
|
136
|
+
});
|
|
137
|
+
tester.listen(port);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
tryPort(startPort);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Slugify a topic string for use as directory name.
|
|
146
|
+
* @param {string} topic
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
function slugify(topic) {
|
|
150
|
+
return topic
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
153
|
+
.replace(/^-|-$/g, '');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ensure a directory exists, creating it recursively if needed.
|
|
158
|
+
* @param {string} dirPath
|
|
159
|
+
*/
|
|
160
|
+
function ensureDir(dirPath) {
|
|
161
|
+
if (!fs.existsSync(dirPath)) {
|
|
162
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Start the brainstorm WebSocket server.
|
|
168
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
169
|
+
* @param {string} topic - Brainstorm topic
|
|
170
|
+
* @param {number} port - Port to listen on
|
|
171
|
+
* @param {boolean} autoOpen - Whether to open browser automatically
|
|
172
|
+
* @returns {Promise<http.Server>}
|
|
173
|
+
*/
|
|
174
|
+
function startServer(brainDir, topic, port, autoOpen) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const slug = slugify(topic);
|
|
177
|
+
const sessionDir = path.join(brainDir, 'storm', slug);
|
|
178
|
+
ensureDir(sessionDir);
|
|
179
|
+
|
|
180
|
+
const fragmentsPath = path.join(sessionDir, 'fragments.md');
|
|
181
|
+
const eventsPath = path.join(sessionDir, 'events.jsonl');
|
|
182
|
+
const portFilePath = path.join(sessionDir, 'port');
|
|
183
|
+
|
|
184
|
+
// Initialize fragments file if it doesn't exist
|
|
185
|
+
if (!fs.existsSync(fragmentsPath)) {
|
|
186
|
+
fs.writeFileSync(fragmentsPath, `# Storm: ${topic}\n\n`, 'utf8');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const clients = [];
|
|
190
|
+
const templatePath = path.join(__dirname, '..', '..', 'templates', 'storm.html');
|
|
191
|
+
|
|
192
|
+
const server = http.createServer((req, res) => {
|
|
193
|
+
if (req.method === 'GET' && (req.url === '/' || req.url === '')) {
|
|
194
|
+
// Serve storm HTML template
|
|
195
|
+
let html;
|
|
196
|
+
try {
|
|
197
|
+
html = fs.readFileSync(templatePath, 'utf8');
|
|
198
|
+
} catch (e) {
|
|
199
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
200
|
+
res.end('Error: storm.html template not found');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Escape HTML entities to prevent XSS
|
|
204
|
+
const safeTopic = topic
|
|
205
|
+
.replace(/&/g, '&')
|
|
206
|
+
.replace(/</g, '<')
|
|
207
|
+
.replace(/>/g, '>')
|
|
208
|
+
.replace(/"/g, '"')
|
|
209
|
+
.replace(/'/g, ''');
|
|
210
|
+
html = html.replace(/\{\{topic\}\}/g, safeTopic);
|
|
211
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
212
|
+
res.end(html);
|
|
213
|
+
} else if (req.method === 'POST' && req.url === '/shutdown') {
|
|
214
|
+
// Graceful shutdown endpoint
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
216
|
+
res.end('Shutting down...');
|
|
217
|
+
cleanup();
|
|
218
|
+
} else {
|
|
219
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
220
|
+
res.end('Not found');
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Handle WebSocket upgrade
|
|
225
|
+
server.on('upgrade', (req, socket) => {
|
|
226
|
+
const wsKey = req.headers['sec-websocket-key'];
|
|
227
|
+
if (!wsKey) {
|
|
228
|
+
socket.destroy();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const acceptKey = computeAcceptKey(wsKey);
|
|
233
|
+
const headers = [
|
|
234
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
235
|
+
'Upgrade: websocket',
|
|
236
|
+
'Connection: Upgrade',
|
|
237
|
+
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
238
|
+
'',
|
|
239
|
+
''
|
|
240
|
+
].join('\r\n');
|
|
241
|
+
|
|
242
|
+
socket.write(headers);
|
|
243
|
+
clients.push(socket);
|
|
244
|
+
|
|
245
|
+
// Send existing fragments on connect
|
|
246
|
+
if (fs.existsSync(fragmentsPath)) {
|
|
247
|
+
const content = fs.readFileSync(fragmentsPath, 'utf8');
|
|
248
|
+
const fragments = parseFragments(content);
|
|
249
|
+
if (fragments.length > 0) {
|
|
250
|
+
const syncMsg = JSON.stringify({ type: 'fragments-sync', fragments });
|
|
251
|
+
socket.write(buildFrame(syncMsg));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let frameBuffer = Buffer.alloc(0);
|
|
256
|
+
|
|
257
|
+
socket.on('data', (data) => {
|
|
258
|
+
frameBuffer = Buffer.concat([frameBuffer, data]);
|
|
259
|
+
|
|
260
|
+
while (frameBuffer.length > 0) {
|
|
261
|
+
const frame = parseFrame(frameBuffer);
|
|
262
|
+
if (!frame) break;
|
|
263
|
+
|
|
264
|
+
frameBuffer = frameBuffer.slice(frame.totalLength);
|
|
265
|
+
|
|
266
|
+
// Handle opcodes
|
|
267
|
+
if (frame.opcode === 0x08) {
|
|
268
|
+
// Close frame - send close back
|
|
269
|
+
socket.write(buildFrame(Buffer.alloc(0), 0x08));
|
|
270
|
+
socket.end();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (frame.opcode === 0x09) {
|
|
275
|
+
// Ping - respond with pong
|
|
276
|
+
socket.write(buildFrame(frame.payload, 0x0A));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (frame.opcode === 0x0A) {
|
|
281
|
+
// Pong - ignore
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (frame.opcode === 0x01) {
|
|
286
|
+
// Text frame
|
|
287
|
+
handleMessage(frame.payload.toString('utf8'), socket);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
socket.on('close', () => {
|
|
293
|
+
const idx = clients.indexOf(socket);
|
|
294
|
+
if (idx !== -1) clients.splice(idx, 1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
socket.on('error', () => {
|
|
298
|
+
const idx = clients.indexOf(socket);
|
|
299
|
+
if (idx !== -1) clients.splice(idx, 1);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Parse fragments from fragments.md content.
|
|
305
|
+
* Each fragment is a ## section.
|
|
306
|
+
*/
|
|
307
|
+
function parseFragments(content) {
|
|
308
|
+
const fragments = [];
|
|
309
|
+
const sections = content.split(/^## /m).slice(1);
|
|
310
|
+
for (const section of sections) {
|
|
311
|
+
const lines = section.split('\n');
|
|
312
|
+
const id = lines[0].trim().replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
313
|
+
const body = lines.slice(1).join('\n').trim();
|
|
314
|
+
fragments.push({ id, title: lines[0].trim(), body });
|
|
315
|
+
}
|
|
316
|
+
return fragments;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handle an incoming WebSocket text message.
|
|
321
|
+
*/
|
|
322
|
+
function handleMessage(text, sender) {
|
|
323
|
+
let msg;
|
|
324
|
+
try {
|
|
325
|
+
msg = JSON.parse(text);
|
|
326
|
+
} catch {
|
|
327
|
+
return; // Ignore non-JSON messages
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (msg.type === 'add-fragment') {
|
|
331
|
+
const fragmentId = `f-${Date.now()}`;
|
|
332
|
+
const title = msg.title || 'Untitled';
|
|
333
|
+
const body = msg.body || '';
|
|
334
|
+
|
|
335
|
+
// Append to fragments.md
|
|
336
|
+
const entry = `## ${title}\n\n${body}\n\n`;
|
|
337
|
+
fs.appendFileSync(fragmentsPath, entry, 'utf8');
|
|
338
|
+
|
|
339
|
+
// Broadcast to all clients
|
|
340
|
+
const broadcastMsg = JSON.stringify({
|
|
341
|
+
type: 'fragment',
|
|
342
|
+
id: fragmentId,
|
|
343
|
+
title,
|
|
344
|
+
body
|
|
345
|
+
});
|
|
346
|
+
const frame = buildFrame(broadcastMsg);
|
|
347
|
+
for (const client of clients) {
|
|
348
|
+
try { client.write(frame); } catch { /* client disconnected */ }
|
|
349
|
+
}
|
|
350
|
+
} else if (msg.type === 'click' || msg.type === 'group' || msg.type === 'drag') {
|
|
351
|
+
// Persist event to events.jsonl
|
|
352
|
+
const event = {
|
|
353
|
+
...msg,
|
|
354
|
+
timestamp: msg.timestamp || Date.now(),
|
|
355
|
+
recorded: new Date().toISOString()
|
|
356
|
+
};
|
|
357
|
+
fs.appendFileSync(eventsPath, JSON.stringify(event) + '\n', 'utf8');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Clean shutdown: send close frames, close server, remove port file.
|
|
363
|
+
*/
|
|
364
|
+
function cleanup() {
|
|
365
|
+
// Send close frames to all connected clients
|
|
366
|
+
const closeFrame = buildFrame(Buffer.alloc(0), 0x08);
|
|
367
|
+
for (const client of clients) {
|
|
368
|
+
try { client.write(closeFrame); client.end(); } catch { /* ignore */ }
|
|
369
|
+
}
|
|
370
|
+
clients.length = 0;
|
|
371
|
+
|
|
372
|
+
// Remove port file
|
|
373
|
+
try { fs.unlinkSync(portFilePath); } catch { /* ignore */ }
|
|
374
|
+
|
|
375
|
+
server.close();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Register signal handlers for clean shutdown
|
|
379
|
+
const sigHandler = () => {
|
|
380
|
+
success('Shutting down storm server...');
|
|
381
|
+
cleanup();
|
|
382
|
+
process.exit(0);
|
|
383
|
+
};
|
|
384
|
+
process.on('SIGINT', sigHandler);
|
|
385
|
+
process.on('SIGTERM', sigHandler);
|
|
386
|
+
|
|
387
|
+
server.on('error', (err) => {
|
|
388
|
+
reject(err);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
server.listen(port, () => {
|
|
392
|
+
// Write port file
|
|
393
|
+
fs.writeFileSync(portFilePath, String(port), 'utf8');
|
|
394
|
+
|
|
395
|
+
if (autoOpen) {
|
|
396
|
+
// macOS: open browser
|
|
397
|
+
exec(`open http://localhost:${port}`, () => { /* ignore errors */ });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
resolve(server);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Stop a running storm server for a given topic.
|
|
407
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
408
|
+
* @param {string} topic - Brainstorm topic
|
|
409
|
+
*/
|
|
410
|
+
function stopServer(brainDir, topic) {
|
|
411
|
+
const slug = slugify(topic);
|
|
412
|
+
const sessionDir = path.join(brainDir, 'storm', slug);
|
|
413
|
+
const portFilePath = path.join(sessionDir, 'port');
|
|
414
|
+
|
|
415
|
+
if (!fs.existsSync(portFilePath)) {
|
|
416
|
+
error(`No running storm server found for topic: ${topic}`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const port = parseInt(fs.readFileSync(portFilePath, 'utf8').trim(), 10);
|
|
421
|
+
|
|
422
|
+
// Send shutdown request to the server
|
|
423
|
+
const req = http.request({ hostname: 'localhost', port, path: '/shutdown', method: 'POST' }, (res) => {
|
|
424
|
+
success(`Storm server for "${topic}" stopped (port ${port}).`);
|
|
425
|
+
// Clean up port file
|
|
426
|
+
try { fs.unlinkSync(portFilePath); } catch { /* ignore */ }
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
req.on('error', () => {
|
|
430
|
+
// Server might already be down, just clean up port file
|
|
431
|
+
try { fs.unlinkSync(portFilePath); } catch { /* ignore */ }
|
|
432
|
+
success(`Storm server port file cleaned up for "${topic}".`);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
req.end();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Generate output.md from fragments for a topic.
|
|
440
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
441
|
+
* @param {string} topic - Brainstorm topic
|
|
442
|
+
*/
|
|
443
|
+
function finalizeSession(brainDir, topic) {
|
|
444
|
+
const slug = slugify(topic);
|
|
445
|
+
const sessionDir = path.join(brainDir, 'storm', slug);
|
|
446
|
+
const fragmentsPath = path.join(sessionDir, 'fragments.md');
|
|
447
|
+
const outputPath = path.join(sessionDir, 'output.md');
|
|
448
|
+
|
|
449
|
+
if (!fs.existsSync(fragmentsPath)) {
|
|
450
|
+
error(`No fragments found for topic: ${topic}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const fragments = fs.readFileSync(fragmentsPath, 'utf8');
|
|
455
|
+
const now = new Date().toISOString();
|
|
456
|
+
|
|
457
|
+
const outputContent = [
|
|
458
|
+
`# Storm Output: ${topic}`,
|
|
459
|
+
'',
|
|
460
|
+
`**Generated:** ${now}`,
|
|
461
|
+
`**Session:** ${slug}`,
|
|
462
|
+
'',
|
|
463
|
+
'## Fragments',
|
|
464
|
+
'',
|
|
465
|
+
fragments,
|
|
466
|
+
'',
|
|
467
|
+
'---',
|
|
468
|
+
'',
|
|
469
|
+
'*Generated by brain-dev storm --finalize*'
|
|
470
|
+
].join('\n');
|
|
471
|
+
|
|
472
|
+
fs.writeFileSync(outputPath, outputContent, 'utf8');
|
|
473
|
+
success(`Output written to .brain/storm/${slug}/output.md`);
|
|
474
|
+
output({ path: outputPath, topic, fragments: fragments.split('## ').length - 1 },
|
|
475
|
+
`Finalized ${fragments.split('## ').length - 1} fragments for "${topic}".`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Parse command-line flags from args array.
|
|
480
|
+
* @param {string[]} args
|
|
481
|
+
* @returns {{ flags: object, positional: string[] }}
|
|
482
|
+
*/
|
|
483
|
+
function parseArgs(args) {
|
|
484
|
+
const flags = {};
|
|
485
|
+
const positional = [];
|
|
486
|
+
|
|
487
|
+
for (let i = 0; i < args.length; i++) {
|
|
488
|
+
if (args[i] === '--stop') {
|
|
489
|
+
flags.stop = true;
|
|
490
|
+
} else if (args[i] === '--finalize') {
|
|
491
|
+
flags.finalize = true;
|
|
492
|
+
} else if (args[i] === '--port' && i + 1 < args.length) {
|
|
493
|
+
flags.port = parseInt(args[i + 1], 10);
|
|
494
|
+
i++;
|
|
495
|
+
} else if (!args[i].startsWith('--')) {
|
|
496
|
+
positional.push(args[i]);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return { flags, positional };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Main command handler for brain-dev storm.
|
|
505
|
+
* @param {string[]} args - CLI arguments
|
|
506
|
+
*/
|
|
507
|
+
async function run(args = []) {
|
|
508
|
+
const { flags, positional } = parseArgs(args);
|
|
509
|
+
const brainDir = path.join(process.cwd(), '.brain');
|
|
510
|
+
const state = readState(brainDir);
|
|
511
|
+
|
|
512
|
+
if (!state) {
|
|
513
|
+
error("No brain project found. Run 'brain-dev init' first.");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const topic = positional.join(' ');
|
|
518
|
+
|
|
519
|
+
if (!topic) {
|
|
520
|
+
error('Usage: brain-dev storm <topic> [--stop] [--port <n>] [--finalize]');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Handle --stop subcommand
|
|
525
|
+
if (flags.stop) {
|
|
526
|
+
stopServer(brainDir, topic);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Handle --finalize subcommand
|
|
531
|
+
if (flags.finalize) {
|
|
532
|
+
finalizeSession(brainDir, topic);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Default: start server
|
|
537
|
+
const stormConfig = state.storm || { port: 3456, auto_open: true };
|
|
538
|
+
const defaultPort = stormConfig.port || 3456;
|
|
539
|
+
const autoOpen = stormConfig.auto_open !== false;
|
|
540
|
+
|
|
541
|
+
let confirmedPort;
|
|
542
|
+
|
|
543
|
+
if (flags.port) {
|
|
544
|
+
// User provided explicit --port, use directly
|
|
545
|
+
confirmedPort = flags.port;
|
|
546
|
+
} else {
|
|
547
|
+
// Find available port starting from default
|
|
548
|
+
try {
|
|
549
|
+
const availablePort = await findAvailablePort(defaultPort);
|
|
550
|
+
|
|
551
|
+
if (availablePort !== defaultPort) {
|
|
552
|
+
// Port was busy, inform user
|
|
553
|
+
output(
|
|
554
|
+
{ defaultPort, availablePort, action: 'port_confirmation' },
|
|
555
|
+
`[brain] Port ${defaultPort} is busy. Found available port: ${availablePort}. Use this port? (Y/n, or enter custom port)`
|
|
556
|
+
);
|
|
557
|
+
// In non-interactive/agent context, use the found port automatically
|
|
558
|
+
confirmedPort = availablePort;
|
|
559
|
+
} else {
|
|
560
|
+
confirmedPort = availablePort;
|
|
561
|
+
}
|
|
562
|
+
} catch (e) {
|
|
563
|
+
error(`Could not find available port: ${e.message}`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Persist confirmed port to brain.json
|
|
569
|
+
state.storm = state.storm || {};
|
|
570
|
+
state.storm.port = confirmedPort;
|
|
571
|
+
writeState(brainDir, state);
|
|
572
|
+
|
|
573
|
+
// Create session directory
|
|
574
|
+
const slug = slugify(topic);
|
|
575
|
+
const sessionDir = path.join(brainDir, 'storm', slug);
|
|
576
|
+
ensureDir(sessionDir);
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const server = await startServer(brainDir, topic, confirmedPort, autoOpen);
|
|
580
|
+
success(`Storm server started at http://localhost:${confirmedPort}`);
|
|
581
|
+
output(
|
|
582
|
+
{ url: `http://localhost:${confirmedPort}`, topic, slug, port: confirmedPort },
|
|
583
|
+
`[brain] Storm: "${topic}" - Open http://localhost:${confirmedPort} in your browser\n[brain] Press Ctrl+C to stop the server`
|
|
584
|
+
);
|
|
585
|
+
} catch (e) {
|
|
586
|
+
error(`Failed to start storm server: ${e.message}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
module.exports = { run };
|