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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/agents/brain-checker.md +33 -0
  4. package/agents/brain-debugger.md +35 -0
  5. package/agents/brain-executor.md +37 -0
  6. package/agents/brain-mapper.md +44 -0
  7. package/agents/brain-planner.md +49 -0
  8. package/agents/brain-researcher.md +47 -0
  9. package/agents/brain-synthesizer.md +43 -0
  10. package/agents/brain-verifier.md +41 -0
  11. package/bin/brain-tools.cjs +185 -0
  12. package/bin/lib/adr.cjs +283 -0
  13. package/bin/lib/agents.cjs +152 -0
  14. package/bin/lib/anti-patterns.cjs +183 -0
  15. package/bin/lib/audit.cjs +268 -0
  16. package/bin/lib/commands/adr.cjs +126 -0
  17. package/bin/lib/commands/complete.cjs +270 -0
  18. package/bin/lib/commands/config.cjs +306 -0
  19. package/bin/lib/commands/discuss.cjs +237 -0
  20. package/bin/lib/commands/execute.cjs +415 -0
  21. package/bin/lib/commands/health.cjs +103 -0
  22. package/bin/lib/commands/map.cjs +101 -0
  23. package/bin/lib/commands/new-project.cjs +885 -0
  24. package/bin/lib/commands/pause.cjs +142 -0
  25. package/bin/lib/commands/phase-manage.cjs +357 -0
  26. package/bin/lib/commands/plan.cjs +451 -0
  27. package/bin/lib/commands/progress.cjs +167 -0
  28. package/bin/lib/commands/quick.cjs +447 -0
  29. package/bin/lib/commands/resume.cjs +196 -0
  30. package/bin/lib/commands/storm.cjs +590 -0
  31. package/bin/lib/commands/verify.cjs +504 -0
  32. package/bin/lib/commands.cjs +263 -0
  33. package/bin/lib/complexity.cjs +138 -0
  34. package/bin/lib/complexity.test.cjs +108 -0
  35. package/bin/lib/config.cjs +452 -0
  36. package/bin/lib/core.cjs +62 -0
  37. package/bin/lib/detect.cjs +603 -0
  38. package/bin/lib/git.cjs +112 -0
  39. package/bin/lib/health.cjs +356 -0
  40. package/bin/lib/init.cjs +310 -0
  41. package/bin/lib/logger.cjs +100 -0
  42. package/bin/lib/platform.cjs +58 -0
  43. package/bin/lib/requirements.cjs +158 -0
  44. package/bin/lib/roadmap.cjs +228 -0
  45. package/bin/lib/security.cjs +237 -0
  46. package/bin/lib/state.cjs +353 -0
  47. package/bin/lib/templates.cjs +48 -0
  48. package/bin/templates/advocate.md +182 -0
  49. package/bin/templates/checkpoint.md +55 -0
  50. package/bin/templates/debugger.md +148 -0
  51. package/bin/templates/discuss.md +60 -0
  52. package/bin/templates/executor.md +201 -0
  53. package/bin/templates/mapper.md +129 -0
  54. package/bin/templates/plan-checker.md +134 -0
  55. package/bin/templates/planner.md +165 -0
  56. package/bin/templates/researcher.md +78 -0
  57. package/bin/templates/storm.html +376 -0
  58. package/bin/templates/synthesis.md +30 -0
  59. package/bin/templates/verifier.md +181 -0
  60. package/commands/brain/adr.md +34 -0
  61. package/commands/brain/complete.md +37 -0
  62. package/commands/brain/config.md +37 -0
  63. package/commands/brain/discuss.md +35 -0
  64. package/commands/brain/execute.md +38 -0
  65. package/commands/brain/health.md +33 -0
  66. package/commands/brain/map.md +35 -0
  67. package/commands/brain/new-project.md +38 -0
  68. package/commands/brain/pause.md +26 -0
  69. package/commands/brain/plan.md +38 -0
  70. package/commands/brain/progress.md +28 -0
  71. package/commands/brain/quick.md +51 -0
  72. package/commands/brain/resume.md +28 -0
  73. package/commands/brain/storm.md +30 -0
  74. package/commands/brain/verify.md +39 -0
  75. package/hooks/bootstrap.sh +54 -0
  76. package/hooks/post-tool-use.sh +45 -0
  77. package/hooks/statusline.sh +130 -0
  78. 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, '&amp;')
206
+ .replace(/</g, '&lt;')
207
+ .replace(/>/g, '&gt;')
208
+ .replace(/"/g, '&quot;')
209
+ .replace(/'/g, '&#039;');
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 };