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.
- package/README.md +85 -23
- package/bin/brain-tools.cjs +8 -0
- package/bin/lib/commands/new-project.cjs +72 -782
- package/bin/lib/commands/new-task.cjs +522 -0
- package/bin/lib/commands/progress.cjs +1 -1
- package/bin/lib/commands/story.cjs +972 -0
- package/bin/lib/commands.cjs +21 -3
- package/bin/lib/state.cjs +47 -1
- package/bin/lib/story-helpers.cjs +439 -0
- package/commands/brain/new-task.md +31 -0
- package/commands/brain/story.md +35 -0
- package/hooks/bootstrap.sh +1 -1
- package/package.json +1 -1
|
@@ -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 };
|