brain-dev 0.2.0 → 1.0.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,972 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { parseArgs } = require('node:util');
6
+ const { readState, writeState } = require('../state.cjs');
7
+ const { output, error, prefix } = require('../core.cjs');
8
+ const { logEvent } = require('../logger.cjs');
9
+ const { loadTemplate, interpolate } = require('../templates.cjs');
10
+ const { getAgent, resolveModel } = require('../agents.cjs');
11
+ const {
12
+ RESEARCH_AREAS, CORE_QUESTIONS, buildBrownfieldQuestions,
13
+ readDetection, buildCodebaseContext, generateProjectMd,
14
+ generateRequirementsMd, extractFeatures, extractSection
15
+ } = require('../story-helpers.cjs');
16
+
17
+ async function run(args = [], opts = {}) {
18
+ const { values, positionals } = parseArgs({
19
+ args,
20
+ options: {
21
+ continue: { type: 'boolean', default: false },
22
+ list: { type: 'boolean', default: false },
23
+ complete: { type: 'boolean', default: false },
24
+ status: { type: 'boolean', default: false },
25
+ research: { type: 'boolean', default: false },
26
+ 'no-research': { type: 'boolean', default: false },
27
+ answers: { type: 'string' },
28
+ json: { type: 'boolean', default: false }
29
+ },
30
+ strict: false,
31
+ allowPositionals: true
32
+ });
33
+
34
+ const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
35
+ const state = readState(brainDir);
36
+
37
+ if (!state || !state.project?.initialized) {
38
+ error("Project not initialized. Run '/brain:new-project' first.");
39
+ return { error: 'not-initialized' };
40
+ }
41
+
42
+ if (values.list) return handleList(brainDir, state);
43
+ if (values.status) return handleStatus(brainDir, state);
44
+ if (values.complete) return handleComplete(brainDir, state);
45
+ if (values.continue) return handleContinue(brainDir, state, values);
46
+
47
+ // Answers for step 1 (after questions asked)
48
+ if (values.answers) return handleAnswers(brainDir, state, values.answers);
49
+
50
+ // New story
51
+ const title = positionals.join(' ').trim();
52
+ if (!title) {
53
+ error('Usage: brain-dev story "v1.1 Feature title" [--research] [--no-research]');
54
+ return { error: 'no-title' };
55
+ }
56
+
57
+ return handleNewStory(brainDir, state, title, values);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // handleNewStory
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function handleNewStory(brainDir, state, title, options) {
65
+ // Guard: no concurrent stories
66
+ state.stories = state.stories || { current: null, count: 0, active: [], history: [] };
67
+ if (state.stories.current) {
68
+ error(`A story is already active: ${state.stories.current}. Complete it first or use --continue.`);
69
+ return { error: 'story-already-active', current: state.stories.current };
70
+ }
71
+
72
+ // Extract version from title (e.g. "v1.1 Add auth" -> version "v1.1", rest = "Add auth")
73
+ const versionMatch = title.match(/^(v\d+\.\d+)\s+(.+)$/i);
74
+ let version, storyTitle;
75
+ if (versionMatch) {
76
+ version = versionMatch[1].toLowerCase();
77
+ storyTitle = versionMatch[2].trim();
78
+ } else {
79
+ version = (state.milestone && state.milestone.current) || 'v1.0';
80
+ storyTitle = title;
81
+ }
82
+
83
+ // Generate slug
84
+ const slug = storyTitle
85
+ .toLowerCase()
86
+ .replace(/[^a-z0-9]+/g, '-')
87
+ .replace(/^-|-$/g, '')
88
+ .slice(0, 40) || 'story';
89
+
90
+ const storyDirName = `${version}-${slug}`;
91
+ const storyDir = path.join(brainDir, 'stories', storyDirName);
92
+ fs.mkdirSync(storyDir, { recursive: true });
93
+
94
+ // Story metadata
95
+ const storyNum = (state.stories.count || 0) + 1;
96
+ const storyMeta = {
97
+ num: storyNum,
98
+ version,
99
+ title: storyTitle,
100
+ slug,
101
+ dirName: storyDirName,
102
+ created: new Date().toISOString(),
103
+ status: 'initialized',
104
+ research: options.research || false,
105
+ noResearch: options['no-research'] || false,
106
+ completed: null
107
+ };
108
+
109
+ fs.writeFileSync(path.join(storyDir, 'story.json'), JSON.stringify(storyMeta, null, 2));
110
+
111
+ // Update state
112
+ state.stories.current = storyDirName;
113
+ state.stories.count = storyNum;
114
+ state.stories.active = state.stories.active || [];
115
+ state.stories.active.push({
116
+ num: storyNum,
117
+ version,
118
+ title: storyTitle,
119
+ slug,
120
+ dirName: storyDirName,
121
+ status: 'initialized'
122
+ });
123
+ writeState(brainDir, state);
124
+
125
+ logEvent(brainDir, 0, { type: 'story-init', story: storyNum, version, title: storyTitle, slug });
126
+
127
+ // Build questions
128
+ const detection = readDetection(brainDir);
129
+ const isBrownfield = detection && detection.type === 'brownfield';
130
+ const questions = isBrownfield
131
+ ? buildBrownfieldQuestions(detection)
132
+ : CORE_QUESTIONS;
133
+
134
+ const humanLines = [
135
+ prefix(`Story #${storyNum} created: ${version} ${storyTitle}`),
136
+ prefix(`Directory: .brain/stories/${storyDirName}/`),
137
+ '',
138
+ prefix('Answer the following questions to define this story:'),
139
+ '',
140
+ ...questions.map((q, i) => {
141
+ const lines = [` ${i + 1}. ${q.text}`];
142
+ if (q.options) {
143
+ for (const opt of q.options) {
144
+ lines.push(` - ${opt.label}: ${opt.description}`);
145
+ }
146
+ }
147
+ return lines.join('\n');
148
+ }),
149
+ '',
150
+ prefix('Provide answers as JSON with --answers flag:'),
151
+ prefix(` brain-dev story --answers '${JSON.stringify({ vision: '...', users: '...', features: '...', constraints: '...' })}'`)
152
+ ].join('\n');
153
+
154
+ const result = {
155
+ action: 'story-ask-questions',
156
+ questions,
157
+ story: storyMeta
158
+ };
159
+
160
+ output(result, humanLines);
161
+ return result;
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // handleAnswers
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function handleAnswers(brainDir, state, answersJson) {
169
+ let answers;
170
+ try {
171
+ answers = JSON.parse(answersJson);
172
+ } catch (e) {
173
+ error(`Invalid JSON for --answers: ${e.message}`);
174
+ return { error: 'invalid-answers-json', message: e.message };
175
+ }
176
+
177
+ state.stories = state.stories || { current: null, count: 0, active: [], history: [] };
178
+ const currentSlug = state.stories.current;
179
+ if (!currentSlug) {
180
+ error('No active story. Start one with: brain-dev story "title"');
181
+ return { error: 'no-active-story' };
182
+ }
183
+
184
+ const storyDir = path.join(brainDir, 'stories', currentSlug);
185
+ if (!fs.existsSync(storyDir)) {
186
+ error(`Story directory not found: ${currentSlug}`);
187
+ return { error: 'story-dir-not-found' };
188
+ }
189
+
190
+ // Read detection for brownfield context
191
+ const detection = readDetection(brainDir);
192
+
193
+ // Generate and write PROJECT.md
194
+ const projectMd = generateProjectMd(answers, detection);
195
+ fs.writeFileSync(path.join(storyDir, 'PROJECT.md'), projectMd, 'utf8');
196
+
197
+ // Update story.json
198
+ const storyMetaPath = path.join(storyDir, 'story.json');
199
+ const storyMeta = JSON.parse(fs.readFileSync(storyMetaPath, 'utf8'));
200
+ storyMeta.status = 'initialized';
201
+ storyMeta.answersReceived = new Date().toISOString();
202
+ fs.writeFileSync(storyMetaPath, JSON.stringify(storyMeta, null, 2));
203
+
204
+ // Update state active entry
205
+ const activeIdx = (state.stories.active || []).findIndex(s => s.dirName === currentSlug);
206
+ if (activeIdx >= 0) state.stories.active[activeIdx].status = 'initialized';
207
+ writeState(brainDir, state);
208
+
209
+ logEvent(brainDir, 0, { type: 'story-answers', story: storyMeta.num, slug: storyMeta.slug });
210
+
211
+ // Determine next step
212
+ const skipResearch = storyMeta.noResearch === true;
213
+ const nextStep = skipResearch ? 'requirements' : 'research';
214
+
215
+ const humanLines = [
216
+ prefix(`Answers saved for story: ${storyMeta.version} ${storyMeta.title}`),
217
+ prefix(`PROJECT.md written to .brain/stories/${currentSlug}/PROJECT.md`),
218
+ '',
219
+ prefix(`Next step: ${nextStep}`),
220
+ prefix('Run: brain-dev story --continue')
221
+ ].join('\n');
222
+
223
+ const result = {
224
+ action: 'story-answers-saved',
225
+ nextStep
226
+ };
227
+
228
+ output(result, humanLines);
229
+ return result;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // handleContinue
234
+ // ---------------------------------------------------------------------------
235
+
236
+ function handleContinue(brainDir, state, values) {
237
+ state.stories = state.stories || { current: null, count: 0, active: [], history: [] };
238
+ const currentSlug = state.stories.current;
239
+ if (!currentSlug) {
240
+ error('No active story. Start one with: brain-dev story "title"');
241
+ return { error: 'no-active-story' };
242
+ }
243
+
244
+ const storyDir = path.join(brainDir, 'stories', currentSlug);
245
+ if (!fs.existsSync(storyDir)) {
246
+ error(`Story directory not found: ${currentSlug}`);
247
+ return { error: 'story-dir-not-found' };
248
+ }
249
+
250
+ const storyMetaPath = path.join(storyDir, 'story.json');
251
+ const storyMeta = JSON.parse(fs.readFileSync(storyMetaPath, 'utf8'));
252
+
253
+ // File-existence based step detection
254
+ const hasProjectMd = fs.existsSync(path.join(storyDir, 'PROJECT.md'));
255
+ const hasResearchDir = fs.existsSync(path.join(storyDir, 'research'));
256
+ const hasSummaryMd = hasResearchDir && fs.existsSync(path.join(storyDir, 'research', 'SUMMARY.md'));
257
+ const hasRequirementsMd = fs.existsSync(path.join(storyDir, 'REQUIREMENTS.md'));
258
+ const hasRoadmapMd = fs.existsSync(path.join(storyDir, 'ROADMAP.md'));
259
+ const skipResearch = storyMeta.noResearch === true || values['no-research'];
260
+
261
+ if (!hasProjectMd) {
262
+ error("Run 'brain-dev story --answers' first to provide project context.");
263
+ return { error: 'no-project-md', instruction: 'Provide answers via --answers flag' };
264
+ }
265
+
266
+ if (!hasResearchDir && !skipResearch) {
267
+ return stepResearch(brainDir, storyDir, storyMeta, state);
268
+ }
269
+
270
+ if (hasResearchDir && !hasSummaryMd && !skipResearch) {
271
+ return stepSynthesize(brainDir, storyDir, storyMeta, state);
272
+ }
273
+
274
+ if (!hasRequirementsMd) {
275
+ return stepRequirements(brainDir, storyDir, storyMeta, state);
276
+ }
277
+
278
+ if (!hasRoadmapMd) {
279
+ return stepRoadmap(brainDir, storyDir, storyMeta, state);
280
+ }
281
+
282
+ if (storyMeta.status !== 'active') {
283
+ return stepActivate(brainDir, state, storyDir, storyMeta);
284
+ }
285
+
286
+ // Already active
287
+ const humanLines = [
288
+ prefix(`Story "${storyMeta.version} ${storyMeta.title}" is already active.`),
289
+ prefix("Use '/brain:discuss' to begin phase work.")
290
+ ].join('\n');
291
+
292
+ output({ action: 'story-already-active', story: storyMeta }, humanLines);
293
+ return { action: 'story-already-active', story: storyMeta };
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // stepResearch
298
+ // ---------------------------------------------------------------------------
299
+
300
+ function stepResearch(brainDir, storyDir, storyMeta, state) {
301
+ // Read PROJECT.md for context
302
+ const projectMd = fs.readFileSync(path.join(storyDir, 'PROJECT.md'), 'utf8');
303
+
304
+ // Create research directory
305
+ const researchDir = path.join(storyDir, 'research');
306
+ fs.mkdirSync(researchDir, { recursive: true });
307
+
308
+ // Build researcher agent prompts
309
+ const agents = RESEARCH_AREAS.map(area => {
310
+ const agentDef = getAgent('researcher');
311
+ const model = resolveModel('researcher', state);
312
+ return {
313
+ name: area.name,
314
+ file: path.join(researchDir, area.file),
315
+ area: area.area,
316
+ description: area.description,
317
+ model,
318
+ prompt: [
319
+ `# Research Focus: ${area.area}`,
320
+ '',
321
+ `You are a ${area.description} researcher.`,
322
+ '',
323
+ '## Project Context',
324
+ projectMd,
325
+ '',
326
+ `## Your Focus Area: ${area.area}`,
327
+ '',
328
+ `Research and document findings about ${area.description} for this project.`,
329
+ 'Be specific, actionable, and concise.',
330
+ '',
331
+ `Write your findings to: ${area.file}`
332
+ ].join('\n')
333
+ };
334
+ });
335
+
336
+ // Check brownfield and build mapper agents if codebase not yet mapped
337
+ const detection = readDetection(brainDir);
338
+ const isBrownfield = detection && detection.type === 'brownfield';
339
+ const codebaseDir = path.join(brainDir, 'codebase');
340
+ const codebaseMapped = fs.existsSync(codebaseDir) &&
341
+ fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md')).length > 0;
342
+
343
+ let mapperAgents = [];
344
+ if (isBrownfield && !codebaseMapped) {
345
+ const mapperDef = getAgent('mapper');
346
+ const mapperModel = resolveModel('mapper', state);
347
+ const codebaseContext = buildCodebaseContext(detection);
348
+
349
+ mapperAgents = (mapperDef.focus || ['tech', 'arch', 'quality', 'concerns']).map(focus => ({
350
+ name: `mapper-${focus}`,
351
+ file: path.join(codebaseDir, `${focus}.md`),
352
+ focus,
353
+ model: mapperModel,
354
+ prompt: [
355
+ `# Codebase Mapping: ${focus}`,
356
+ '',
357
+ codebaseContext,
358
+ '',
359
+ `Map the codebase focusing on: ${focus}`,
360
+ `Write output to: codebase/${focus}.md`
361
+ ].join('\n')
362
+ }));
363
+
364
+ fs.mkdirSync(codebaseDir, { recursive: true });
365
+ }
366
+
367
+ // Update story status
368
+ storyMeta.status = 'researching';
369
+ fs.writeFileSync(path.join(storyDir, 'story.json'), JSON.stringify(storyMeta, null, 2));
370
+
371
+ const activeIdx = (state.stories.active || []).findIndex(s => s.dirName === storyMeta.dirName);
372
+ if (activeIdx >= 0) state.stories.active[activeIdx].status = 'researching';
373
+ writeState(brainDir, state);
374
+
375
+ logEvent(brainDir, 0, { type: 'story-research', story: storyMeta.num, agents: agents.length, mappers: mapperAgents.length });
376
+
377
+ const humanLines = [
378
+ prefix(`Story: ${storyMeta.version} ${storyMeta.title}`),
379
+ prefix('Step: Research'),
380
+ '',
381
+ prefix(`Spawning ${agents.length} research agents:`),
382
+ ...agents.map(a => prefix(` - ${a.name}: ${a.area}`)),
383
+ ...(mapperAgents.length > 0 ? [
384
+ '',
385
+ prefix(`Spawning ${mapperAgents.length} mapper agents:`),
386
+ ...mapperAgents.map(a => prefix(` - ${a.name}: ${a.focus}`))
387
+ ] : []),
388
+ '',
389
+ prefix('After research completes, run: brain-dev story --continue')
390
+ ].join('\n');
391
+
392
+ const result = {
393
+ action: 'spawn-researchers',
394
+ agents,
395
+ mapperAgents
396
+ };
397
+
398
+ output(result, humanLines);
399
+ return result;
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // stepSynthesize
404
+ // ---------------------------------------------------------------------------
405
+
406
+ function stepSynthesize(brainDir, storyDir, storyMeta, state) {
407
+ const researchDir = path.join(storyDir, 'research');
408
+
409
+ // List research files
410
+ const researchFiles = fs.readdirSync(researchDir)
411
+ .filter(f => f.endsWith('.md') && f !== 'SUMMARY.md')
412
+ .map(f => ({
413
+ name: f,
414
+ path: path.join(researchDir, f),
415
+ area: f.replace('.md', '').replace(/-/g, ' ')
416
+ }));
417
+
418
+ // Build synthesis prompt
419
+ const synthAgent = getAgent('synthesizer');
420
+ const model = resolveModel('synthesizer', state);
421
+
422
+ const prompt = [
423
+ '# Research Synthesis',
424
+ '',
425
+ 'Synthesize findings from all research agents into a unified SUMMARY.md.',
426
+ '',
427
+ '## Research Files',
428
+ ...researchFiles.map(f => `- ${f.name} (${f.area})`),
429
+ '',
430
+ '## Instructions',
431
+ '1. Read all research files in the research/ directory',
432
+ '2. Identify common themes, conflicts, and priorities',
433
+ '3. Create a unified SUMMARY.md with:',
434
+ ' - Key recommendations (prioritized)',
435
+ ' - Technology decisions',
436
+ ' - Architecture patterns to follow',
437
+ ' - Risks and mitigations',
438
+ ' - Testing strategy summary',
439
+ '',
440
+ `Write to: ${path.join(researchDir, 'SUMMARY.md')}`
441
+ ].join('\n');
442
+
443
+ // Update status
444
+ storyMeta.status = 'synthesizing';
445
+ fs.writeFileSync(path.join(storyDir, 'story.json'), JSON.stringify(storyMeta, null, 2));
446
+
447
+ const activeIdx = (state.stories.active || []).findIndex(s => s.dirName === storyMeta.dirName);
448
+ if (activeIdx >= 0) state.stories.active[activeIdx].status = 'synthesizing';
449
+ writeState(brainDir, state);
450
+
451
+ logEvent(brainDir, 0, { type: 'story-synthesize', story: storyMeta.num, files: researchFiles.length });
452
+
453
+ const humanLines = [
454
+ prefix(`Story: ${storyMeta.version} ${storyMeta.title}`),
455
+ prefix('Step: Synthesize Research'),
456
+ '',
457
+ prefix(`Found ${researchFiles.length} research files to synthesize:`),
458
+ ...researchFiles.map(f => prefix(` - ${f.name}`)),
459
+ '',
460
+ prefix('After synthesis completes, run: brain-dev story --continue')
461
+ ].join('\n');
462
+
463
+ const result = {
464
+ action: 'spawn-synthesis',
465
+ prompt,
466
+ model,
467
+ researchFiles
468
+ };
469
+
470
+ output(result, humanLines);
471
+ return result;
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // stepRequirements
476
+ // ---------------------------------------------------------------------------
477
+
478
+ function stepRequirements(brainDir, storyDir, storyMeta, state) {
479
+ // Read PROJECT.md for features
480
+ const projectMd = fs.readFileSync(path.join(storyDir, 'PROJECT.md'), 'utf8');
481
+ const features = extractFeatures(projectMd);
482
+
483
+ // Read research summary if available
484
+ const summaryPath = path.join(storyDir, 'research', 'SUMMARY.md');
485
+ let summaryContent = '';
486
+ if (fs.existsSync(summaryPath)) {
487
+ summaryContent = fs.readFileSync(summaryPath, 'utf8');
488
+ }
489
+
490
+ // Generate REQUIREMENTS.md
491
+ const { requirementsMd, categories } = generateRequirementsMd(features, summaryContent);
492
+ fs.writeFileSync(path.join(storyDir, 'REQUIREMENTS.md'), requirementsMd, 'utf8');
493
+
494
+ // Update status
495
+ storyMeta.status = 'requirements';
496
+ fs.writeFileSync(path.join(storyDir, 'story.json'), JSON.stringify(storyMeta, null, 2));
497
+
498
+ const activeIdx = (state.stories.active || []).findIndex(s => s.dirName === storyMeta.dirName);
499
+ if (activeIdx >= 0) state.stories.active[activeIdx].status = 'requirements';
500
+ writeState(brainDir, state);
501
+
502
+ logEvent(brainDir, 0, { type: 'story-requirements', story: storyMeta.num, categories: categories.length });
503
+
504
+ const humanLines = [
505
+ prefix(`Story: ${storyMeta.version} ${storyMeta.title}`),
506
+ prefix('Step: Requirements Generated'),
507
+ '',
508
+ prefix(`REQUIREMENTS.md written with ${categories.length} categories:`),
509
+ ...categories.map(c => prefix(` - ${c.name} (${c.items.length} requirements)`)),
510
+ '',
511
+ prefix('Review REQUIREMENTS.md and confirm.'),
512
+ prefix('Then run: brain-dev story --continue')
513
+ ].join('\n');
514
+
515
+ const result = {
516
+ action: 'requirements-generated',
517
+ content: requirementsMd,
518
+ categories,
519
+ instruction: 'Review and confirm'
520
+ };
521
+
522
+ output(result, humanLines);
523
+ return result;
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // stepRoadmap
528
+ // ---------------------------------------------------------------------------
529
+
530
+ function stepRoadmap(brainDir, storyDir, storyMeta, state) {
531
+ // Read REQUIREMENTS.md
532
+ const reqPath = path.join(storyDir, 'REQUIREMENTS.md');
533
+ const reqContent = fs.readFileSync(reqPath, 'utf8');
534
+
535
+ // Parse categories from REQUIREMENTS.md headings
536
+ const categoryRegex = /## (.+)\n([\s\S]*?)(?=\n## |$)/g;
537
+ const phases = [];
538
+ let catMatch;
539
+ let phaseNum = 1;
540
+
541
+ while ((catMatch = categoryRegex.exec(reqContent)) !== null) {
542
+ const catName = catMatch[1].trim();
543
+ if (catName === 'Requirements' || catName.toLowerCase() === 'requirements') continue;
544
+
545
+ // Extract requirement IDs from this category
546
+ const catBody = catMatch[2];
547
+ const reqIds = [];
548
+ const reqLineRegex = /- \*\*(\d+\.\d+)\*\*/g;
549
+ let reqMatch;
550
+ while ((reqMatch = reqLineRegex.exec(catBody)) !== null) {
551
+ reqIds.push(reqMatch[1]);
552
+ }
553
+
554
+ // Dependencies: each phase depends on the previous one (except phase 1)
555
+ const dependsOn = phaseNum > 1 ? [phaseNum - 1] : [];
556
+
557
+ phases.push({
558
+ number: phaseNum,
559
+ name: catName,
560
+ goal: `Implement ${catName.toLowerCase()} requirements`,
561
+ dependsOn,
562
+ requirements: reqIds,
563
+ plans: '',
564
+ status: 'Pending'
565
+ });
566
+
567
+ phaseNum++;
568
+ }
569
+
570
+ // Fallback: if no phases extracted, create a single phase
571
+ if (phases.length === 0) {
572
+ phases.push({
573
+ number: 1,
574
+ name: storyMeta.title,
575
+ goal: `Implement ${storyMeta.title}`,
576
+ dependsOn: [],
577
+ requirements: [],
578
+ plans: '',
579
+ status: 'Pending'
580
+ });
581
+ }
582
+
583
+ // Generate ROADMAP.md
584
+ const roadmapLines = [
585
+ '# Roadmap',
586
+ '',
587
+ `Story: ${storyMeta.version} ${storyMeta.title}`,
588
+ '',
589
+ '## Phases',
590
+ ''
591
+ ];
592
+
593
+ for (const phase of phases) {
594
+ roadmapLines.push(`### Phase ${phase.number}: ${phase.name}`);
595
+ roadmapLines.push('');
596
+ roadmapLines.push(`- **Goal:** ${phase.goal}`);
597
+ roadmapLines.push(`- **Depends on:** ${phase.dependsOn.length === 0 ? 'None' : phase.dependsOn.join(', ')}`);
598
+ roadmapLines.push(`- **Requirements:** ${phase.requirements.length === 0 ? 'None' : phase.requirements.join(', ')}`);
599
+ roadmapLines.push(`- **Status:** ${phase.status}`);
600
+ roadmapLines.push('');
601
+ }
602
+
603
+ // Progress table
604
+ roadmapLines.push('## Progress');
605
+ roadmapLines.push('');
606
+ roadmapLines.push('| Phase | Status | Plans |');
607
+ roadmapLines.push('|-------|--------|-------|');
608
+ for (const phase of phases) {
609
+ roadmapLines.push(`| ${phase.number} | ${phase.status} | 0/0 |`);
610
+ }
611
+ roadmapLines.push('');
612
+
613
+ const roadmapMd = roadmapLines.join('\n');
614
+ fs.writeFileSync(path.join(storyDir, 'ROADMAP.md'), roadmapMd, 'utf8');
615
+
616
+ // Update status
617
+ storyMeta.status = 'roadmap';
618
+ fs.writeFileSync(path.join(storyDir, 'story.json'), JSON.stringify(storyMeta, null, 2));
619
+
620
+ const activeIdx = (state.stories.active || []).findIndex(s => s.dirName === storyMeta.dirName);
621
+ if (activeIdx >= 0) state.stories.active[activeIdx].status = 'roadmap';
622
+ writeState(brainDir, state);
623
+
624
+ logEvent(brainDir, 0, { type: 'story-roadmap', story: storyMeta.num, phases: phases.length });
625
+
626
+ const humanLines = [
627
+ prefix(`Story: ${storyMeta.version} ${storyMeta.title}`),
628
+ prefix('Step: Roadmap Generated'),
629
+ '',
630
+ prefix(`ROADMAP.md written with ${phases.length} phases:`),
631
+ ...phases.map(p => prefix(` Phase ${p.number}: ${p.name} (depends on: ${p.dependsOn.length === 0 ? 'none' : p.dependsOn.join(', ')})`)),
632
+ '',
633
+ prefix('Review ROADMAP.md and confirm.'),
634
+ prefix('Then run: brain-dev story --continue')
635
+ ].join('\n');
636
+
637
+ const result = {
638
+ action: 'roadmap-generated',
639
+ phases,
640
+ instruction: 'Review and confirm'
641
+ };
642
+
643
+ output(result, humanLines);
644
+ return result;
645
+ }
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // stepActivate
649
+ // ---------------------------------------------------------------------------
650
+
651
+ function stepActivate(brainDir, state, storyDir, storyMeta) {
652
+ const rootRoadmapPath = path.join(brainDir, 'ROADMAP.md');
653
+ const rootRequirementsPath = path.join(brainDir, 'REQUIREMENTS.md');
654
+
655
+ // Archive existing root ROADMAP.md if present
656
+ if (fs.existsSync(rootRoadmapPath)) {
657
+ const prevPath = path.join(brainDir, 'ROADMAP.prev.md');
658
+ fs.copyFileSync(rootRoadmapPath, prevPath);
659
+ }
660
+
661
+ // Copy story REQUIREMENTS.md and ROADMAP.md to .brain/ root
662
+ const storyReqPath = path.join(storyDir, 'REQUIREMENTS.md');
663
+ const storyRoadmapPath = path.join(storyDir, 'ROADMAP.md');
664
+
665
+ if (fs.existsSync(storyReqPath)) {
666
+ fs.copyFileSync(storyReqPath, rootRequirementsPath);
667
+ }
668
+ if (fs.existsSync(storyRoadmapPath)) {
669
+ fs.copyFileSync(storyRoadmapPath, rootRoadmapPath);
670
+ }
671
+
672
+ // Parse ROADMAP.md to get phases
673
+ const { parseRoadmap } = require('../roadmap.cjs');
674
+ const roadmapData = parseRoadmap(brainDir);
675
+
676
+ // Update state phases
677
+ state.phase = state.phase || { current: 0, status: 'initialized', total: 0, phases: [] };
678
+ state.phase.current = 1;
679
+ state.phase.status = 'ready';
680
+ state.phase.total = roadmapData.phases.length;
681
+ state.phase.phases = roadmapData.phases.map(p => ({
682
+ number: p.number,
683
+ name: p.name,
684
+ status: p.status === 'Pending' ? 'pending' : p.status.toLowerCase(),
685
+ goal: p.goal
686
+ }));
687
+ state.phase.execution_started_at = null;
688
+ state.phase.stuck_count = 0;
689
+ state.phase.last_stuck_at = null;
690
+
691
+ // Update milestone
692
+ state.milestone = state.milestone || { current: 'v1.0', name: null, history: [] };
693
+ state.milestone.current = storyMeta.version;
694
+ state.milestone.name = storyMeta.title;
695
+
696
+ // Update story status
697
+ storyMeta.status = 'active';
698
+ fs.writeFileSync(path.join(storyDir, 'story.json'), JSON.stringify(storyMeta, null, 2));
699
+
700
+ // Update state stories
701
+ const activeIdx = (state.stories.active || []).findIndex(s => s.dirName === storyMeta.dirName);
702
+ if (activeIdx >= 0) state.stories.active[activeIdx].status = 'active';
703
+ writeState(brainDir, state);
704
+
705
+ logEvent(brainDir, 0, {
706
+ type: 'story-activated',
707
+ story: storyMeta.num,
708
+ version: storyMeta.version,
709
+ phases: roadmapData.phases.length
710
+ });
711
+
712
+ const humanLines = [
713
+ prefix(`Story activated: ${storyMeta.version} ${storyMeta.title}`),
714
+ '',
715
+ prefix(`Milestone set to: ${storyMeta.version}`),
716
+ prefix(`Phases loaded: ${roadmapData.phases.length}`),
717
+ prefix('ROADMAP.md and REQUIREMENTS.md copied to .brain/ root'),
718
+ ...(fs.existsSync(path.join(brainDir, 'ROADMAP.prev.md'))
719
+ ? [prefix('Previous ROADMAP.md archived as ROADMAP.prev.md')]
720
+ : []),
721
+ '',
722
+ prefix("Next: Use '/brain:discuss' to begin phase work.")
723
+ ].join('\n');
724
+
725
+ const result = {
726
+ action: 'story-activated',
727
+ story: storyMeta,
728
+ phases: roadmapData.phases.length,
729
+ nextAction: '/brain:discuss'
730
+ };
731
+
732
+ output(result, humanLines);
733
+ return result;
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // handleComplete
738
+ // ---------------------------------------------------------------------------
739
+
740
+ function handleComplete(brainDir, state) {
741
+ state.stories = state.stories || { current: null, count: 0, active: [], history: [] };
742
+ const currentSlug = state.stories.current;
743
+ if (!currentSlug) {
744
+ error('No active story to complete.');
745
+ return { error: 'no-active-story' };
746
+ }
747
+
748
+ const storyDir = path.join(brainDir, 'stories', currentSlug);
749
+ if (!fs.existsSync(storyDir)) {
750
+ error(`Story directory not found: ${currentSlug}`);
751
+ return { error: 'story-dir-not-found' };
752
+ }
753
+
754
+ const storyMetaPath = path.join(storyDir, 'story.json');
755
+ const storyMeta = JSON.parse(fs.readFileSync(storyMetaPath, 'utf8'));
756
+
757
+ // Check if all phases are complete
758
+ const allPhasesComplete = state.phase && Array.isArray(state.phase.phases) &&
759
+ state.phase.phases.length > 0 &&
760
+ state.phase.phases.every(p => p.status === 'complete' || p.status === 'Complete');
761
+
762
+ if (!allPhasesComplete && state.phase && state.phase.phases && state.phase.phases.length > 0) {
763
+ const pending = state.phase.phases.filter(p => p.status !== 'complete' && p.status !== 'Complete');
764
+ error(`Cannot complete story: ${pending.length} phase(s) still pending.`);
765
+
766
+ const humanLines = [
767
+ prefix('Incomplete phases:'),
768
+ ...pending.map(p => prefix(` Phase ${p.number}: ${p.name} (${p.status})`))
769
+ ].join('\n');
770
+
771
+ output({ error: 'phases-incomplete', pending }, humanLines);
772
+ return { error: 'phases-incomplete', pending };
773
+ }
774
+
775
+ // Mark story complete
776
+ storyMeta.status = 'complete';
777
+ storyMeta.completed = new Date().toISOString();
778
+ fs.writeFileSync(storyMetaPath, JSON.stringify(storyMeta, null, 2));
779
+
780
+ // Move from active to history
781
+ state.stories.active = (state.stories.active || []).filter(s => s.dirName !== currentSlug);
782
+ state.stories.history = state.stories.history || [];
783
+ state.stories.history.push({
784
+ num: storyMeta.num,
785
+ version: storyMeta.version,
786
+ title: storyMeta.title,
787
+ slug: storyMeta.slug,
788
+ dirName: storyMeta.dirName,
789
+ completed: storyMeta.completed
790
+ });
791
+ state.stories.current = null;
792
+
793
+ // Bump milestone version
794
+ const currentVersion = storyMeta.version || 'v1.0';
795
+ const versionMatch = currentVersion.match(/^v(\d+)\.(\d+)$/);
796
+ let nextVersion = currentVersion;
797
+ if (versionMatch) {
798
+ const major = parseInt(versionMatch[1], 10);
799
+ const minor = parseInt(versionMatch[2], 10) + 1;
800
+ nextVersion = `v${major}.${minor}`;
801
+ }
802
+ state.milestone.current = nextVersion;
803
+ state.milestone.name = null;
804
+
805
+ // Add to milestone history
806
+ state.milestone.history = state.milestone.history || [];
807
+ state.milestone.history.push({
808
+ version: storyMeta.version,
809
+ title: storyMeta.title,
810
+ completed: storyMeta.completed
811
+ });
812
+
813
+ writeState(brainDir, state);
814
+
815
+ logEvent(brainDir, 0, {
816
+ type: 'story-complete',
817
+ story: storyMeta.num,
818
+ version: storyMeta.version,
819
+ title: storyMeta.title
820
+ });
821
+
822
+ const humanLines = [
823
+ prefix(`Story completed: ${storyMeta.version} ${storyMeta.title}`),
824
+ prefix(`Completed at: ${storyMeta.completed}`),
825
+ '',
826
+ prefix(`Milestone bumped to: ${nextVersion}`),
827
+ '',
828
+ prefix('Suggestions:'),
829
+ prefix(` Start next story: brain-dev story "${nextVersion} Next feature"`),
830
+ prefix(' View history: brain-dev story --list')
831
+ ].join('\n');
832
+
833
+ const result = {
834
+ action: 'story-completed',
835
+ version: storyMeta.version,
836
+ nextVersion,
837
+ suggest: `/brain:story "${nextVersion} next"`
838
+ };
839
+
840
+ output(result, humanLines);
841
+ return result;
842
+ }
843
+
844
+ // ---------------------------------------------------------------------------
845
+ // handleList
846
+ // ---------------------------------------------------------------------------
847
+
848
+ function handleList(brainDir, state) {
849
+ const stories = state.stories || { current: null, count: 0, active: [], history: [] };
850
+
851
+ const lines = [prefix(`Stories (${stories.count} total)`)];
852
+
853
+ if ((stories.active || []).length > 0) {
854
+ lines.push('');
855
+ lines.push(prefix('Active:'));
856
+ for (const s of stories.active) {
857
+ const current = s.dirName === stories.current ? ' (current)' : '';
858
+ lines.push(prefix(` ${s.version} ${s.title} [${s.status}]${current}`));
859
+ }
860
+ }
861
+
862
+ if ((stories.history || []).length > 0) {
863
+ lines.push('');
864
+ lines.push(prefix('Completed:'));
865
+ for (const s of stories.history.slice(-10)) {
866
+ lines.push(prefix(` ${s.version} ${s.title} (${s.completed ? s.completed.slice(0, 10) : 'unknown'})`));
867
+ }
868
+ }
869
+
870
+ if (stories.count === 0) {
871
+ lines.push(prefix(' No stories yet. Create one: brain-dev story "v1.1 Feature title"'));
872
+ }
873
+
874
+ const result = { action: 'story-list', stories };
875
+ output(result, lines.join('\n'));
876
+ return result;
877
+ }
878
+
879
+ // ---------------------------------------------------------------------------
880
+ // handleStatus
881
+ // ---------------------------------------------------------------------------
882
+
883
+ function handleStatus(brainDir, state) {
884
+ const stories = state.stories || { current: null, count: 0, active: [], history: [] };
885
+ const currentSlug = stories.current;
886
+
887
+ if (!currentSlug) {
888
+ const humanLines = [
889
+ prefix('No active story.'),
890
+ prefix('Create one: brain-dev story "v1.1 Feature title"')
891
+ ].join('\n');
892
+
893
+ output({ action: 'story-status', active: false }, humanLines);
894
+ return { action: 'story-status', active: false };
895
+ }
896
+
897
+ const storyDir = path.join(brainDir, 'stories', currentSlug);
898
+ if (!fs.existsSync(storyDir)) {
899
+ error(`Story directory not found: ${currentSlug}`);
900
+ return { error: 'story-dir-not-found' };
901
+ }
902
+
903
+ const storyMetaPath = path.join(storyDir, 'story.json');
904
+ const storyMeta = JSON.parse(fs.readFileSync(storyMetaPath, 'utf8'));
905
+
906
+ // Determine step progress
907
+ const steps = [];
908
+ const hasProjectMd = fs.existsSync(path.join(storyDir, 'PROJECT.md'));
909
+ const hasResearchDir = fs.existsSync(path.join(storyDir, 'research'));
910
+ const hasSummaryMd = hasResearchDir && fs.existsSync(path.join(storyDir, 'research', 'SUMMARY.md'));
911
+ const hasRequirementsMd = fs.existsSync(path.join(storyDir, 'REQUIREMENTS.md'));
912
+ const hasRoadmapMd = fs.existsSync(path.join(storyDir, 'ROADMAP.md'));
913
+
914
+ steps.push({ name: 'Questions', done: !!storyMeta.answersReceived });
915
+ steps.push({ name: 'PROJECT.md', done: hasProjectMd });
916
+ steps.push({ name: 'Research', done: hasResearchDir, skipped: storyMeta.noResearch });
917
+ steps.push({ name: 'Synthesis', done: hasSummaryMd, skipped: storyMeta.noResearch });
918
+ steps.push({ name: 'Requirements', done: hasRequirementsMd });
919
+ steps.push({ name: 'Roadmap', done: hasRoadmapMd });
920
+ steps.push({ name: 'Activated', done: storyMeta.status === 'active' });
921
+
922
+ const completedSteps = steps.filter(s => s.done || s.skipped).length;
923
+ const totalSteps = steps.length;
924
+
925
+ // Phase progress (if activated)
926
+ let phaseProgress = null;
927
+ if (state.phase && Array.isArray(state.phase.phases) && state.phase.phases.length > 0) {
928
+ const done = state.phase.phases.filter(p => p.status === 'complete' || p.status === 'Complete').length;
929
+ phaseProgress = { done, total: state.phase.phases.length };
930
+ }
931
+
932
+ const lines = [
933
+ prefix(`Story: ${storyMeta.version} ${storyMeta.title}`),
934
+ prefix(`Status: ${storyMeta.status}`),
935
+ prefix(`Directory: .brain/stories/${currentSlug}/`),
936
+ prefix(`Progress: ${completedSteps}/${totalSteps} steps`),
937
+ ''
938
+ ];
939
+
940
+ lines.push(prefix('Steps:'));
941
+ for (const step of steps) {
942
+ const icon = step.done ? '[x]' : (step.skipped ? '[-]' : '[ ]');
943
+ lines.push(prefix(` ${icon} ${step.name}${step.skipped ? ' (skipped)' : ''}`));
944
+ }
945
+
946
+ if (phaseProgress) {
947
+ lines.push('');
948
+ lines.push(prefix(`Phases: ${phaseProgress.done}/${phaseProgress.total} complete`));
949
+
950
+ if (state.phase.phases) {
951
+ for (const p of state.phase.phases) {
952
+ const pIcon = (p.status === 'complete' || p.status === 'Complete') ? '[x]' : '[ ]';
953
+ lines.push(prefix(` ${pIcon} Phase ${p.number}: ${p.name} (${p.status})`));
954
+ }
955
+ }
956
+ }
957
+
958
+ const result = {
959
+ action: 'story-status',
960
+ active: true,
961
+ story: storyMeta,
962
+ steps,
963
+ completedSteps,
964
+ totalSteps,
965
+ phaseProgress
966
+ };
967
+
968
+ output(result, lines.join('\n'));
969
+ return result;
970
+ }
971
+
972
+ module.exports = { run };