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,522 @@
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 { logEvent } = require('../logger.cjs');
8
+ const { output, error, prefix } = require('../core.cjs');
9
+ const { recordInvocation, estimateTokens } = require('../cost.cjs');
10
+
11
+ async function run(args = [], opts = {}) {
12
+ const { values, positionals } = parseArgs({
13
+ args,
14
+ options: {
15
+ research: { type: 'boolean', default: false },
16
+ light: { type: 'boolean', default: false },
17
+ continue: { type: 'boolean', default: false },
18
+ list: { type: 'boolean', default: false },
19
+ promote: { type: 'string' },
20
+ json: { type: 'boolean', default: false }
21
+ },
22
+ strict: false,
23
+ allowPositionals: true
24
+ });
25
+
26
+ const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
27
+ const state = readState(brainDir);
28
+
29
+ if (!state || !state.project?.initialized) {
30
+ error("Project not initialized. Run '/brain:new-project' first.");
31
+ return { error: 'not-initialized' };
32
+ }
33
+
34
+ // Handle --list
35
+ if (values.list) return handleList(brainDir, state);
36
+
37
+ // Handle --promote N
38
+ if (values.promote) {
39
+ const taskNum = parseInt(values.promote, 10);
40
+ if (isNaN(taskNum) || taskNum < 1) {
41
+ error("--promote requires a valid task number. Usage: brain-dev new-task --promote <N>");
42
+ return { error: 'invalid-task-number' };
43
+ }
44
+ return handlePromote(brainDir, state, taskNum);
45
+ }
46
+
47
+ // Handle --continue (resume current task)
48
+ if (values.continue) return handleContinue(brainDir, state);
49
+
50
+ // New task creation
51
+ const description = positionals.join(' ').trim();
52
+ if (!description) {
53
+ error("Usage: brain-dev new-task \"description\" [--research] [--light]");
54
+ return { error: 'no-description' };
55
+ }
56
+
57
+ return handleNewTask(brainDir, state, description, values);
58
+ }
59
+
60
+ function handleNewTask(brainDir, state, description, options) {
61
+ // Create task directory and metadata
62
+ const taskNum = (state.tasks?.count || 0) + 1;
63
+ const slug = description
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9]+/g, '-')
66
+ .replace(/^-|-$/g, '')
67
+ .slice(0, 40) || 'task';
68
+
69
+ const taskDir = path.join(brainDir, 'tasks', `${String(taskNum).padStart(2, '0')}-${slug}`);
70
+ fs.mkdirSync(taskDir, { recursive: true });
71
+
72
+ const mode = options.light ? 'light' : 'standard';
73
+ const taskMeta = {
74
+ num: taskNum,
75
+ slug,
76
+ description,
77
+ created: new Date().toISOString(),
78
+ status: 'initialized',
79
+ mode,
80
+ research: options.research || false,
81
+ parent_phase: state.phase?.current || null,
82
+ promoted_to_phase: null,
83
+ completed: null
84
+ };
85
+
86
+ fs.writeFileSync(path.join(taskDir, 'task.json'), JSON.stringify(taskMeta, null, 2));
87
+
88
+ // Update state
89
+ state.tasks = state.tasks || { current: null, count: 0, active: [], history: [] };
90
+ state.tasks.current = taskNum;
91
+ state.tasks.count = taskNum;
92
+ state.tasks.active.push({ num: taskNum, slug, status: 'initialized', description });
93
+ writeState(brainDir, state);
94
+
95
+ logEvent(brainDir, 0, { type: 'task-init', task: taskNum, slug, description, mode });
96
+
97
+ // Determine first step
98
+ const firstStep = mode === 'light' ? 'plan' : 'discuss';
99
+
100
+ // Build step instructions
101
+ const steps = buildStepInstructions(taskNum, slug, taskDir, brainDir, mode, options.research, description);
102
+
103
+ const humanLines = [
104
+ prefix(`Task #${taskNum} created: ${description}`),
105
+ prefix(`Mode: ${mode}${options.research ? ' + research' : ''}`),
106
+ prefix(`Directory: .brain/tasks/${String(taskNum).padStart(2, '0')}-${slug}/`),
107
+ '',
108
+ prefix(`Pipeline: ${mode === 'light' ? 'plan -> execute -> commit' : 'discuss -> plan -> execute -> verify -> complete'}`),
109
+ '',
110
+ prefix(`Next step: ${firstStep}`),
111
+ '',
112
+ steps[firstStep]
113
+ ].join('\n');
114
+
115
+ const result = {
116
+ action: 'task-created',
117
+ task: taskMeta,
118
+ nextStep: firstStep,
119
+ taskDir,
120
+ steps,
121
+ instructions: steps[firstStep]
122
+ };
123
+
124
+ output(result, humanLines);
125
+ return result;
126
+ }
127
+
128
+ function buildStepInstructions(taskNum, slug, taskDir, brainDir, mode, research, description) {
129
+ const padded = String(taskNum).padStart(2, '0');
130
+ const steps = {};
131
+
132
+ // DISCUSS step
133
+ steps.discuss = [
134
+ `## Step 1: Discuss — Task #${taskNum}`,
135
+ '',
136
+ `Discuss the architectural approach for: "${description}"`,
137
+ '',
138
+ 'Questions to explore:',
139
+ '1. What is the best approach for this task?',
140
+ '2. Are there existing patterns in the codebase to follow?',
141
+ '3. What are the risks or edge cases?',
142
+ '4. What dependencies does this have?',
143
+ '',
144
+ 'After discussion, save decisions to:',
145
+ `\`${taskDir}/CONTEXT.md\``,
146
+ '',
147
+ 'Then run: `npx brain-dev new-task --continue`'
148
+ ].join('\n');
149
+
150
+ // RESEARCH step (optional)
151
+ if (research) {
152
+ steps.research = [
153
+ `## Step 1.5: Research — Task #${taskNum}`,
154
+ '',
155
+ `Research the approach for: "${description}"`,
156
+ '',
157
+ 'Focus on:',
158
+ '1. How does the existing codebase handle similar features?',
159
+ '2. What are the best practices for this type of change?',
160
+ '',
161
+ 'Save findings to:',
162
+ `\`${taskDir}/RESEARCH.md\``,
163
+ '',
164
+ 'Then run: `npx brain-dev new-task --continue`'
165
+ ].join('\n');
166
+ }
167
+
168
+ // PLAN step
169
+ steps.plan = [
170
+ `## Step 2: Plan — Task #${taskNum}`,
171
+ '',
172
+ `Create an execution plan for: "${description}"`,
173
+ '',
174
+ 'Constraints:',
175
+ '- Max 3-4 tasks (this is a focused task, not a full phase)',
176
+ '- Target ~50% context budget',
177
+ '- Each task should be atomic and testable',
178
+ '- Include must_haves for verification',
179
+ '',
180
+ `Read context: \`${taskDir}/CONTEXT.md\` (if exists)`,
181
+ research ? `Read research: \`${taskDir}/RESEARCH.md\` (if exists)` : '',
182
+ '',
183
+ `Write plan to: \`${taskDir}/PLAN-1.md\``,
184
+ 'Use standard Brain plan format with YAML frontmatter.',
185
+ '',
186
+ 'Then run: `npx brain-dev new-task --continue`'
187
+ ].filter(Boolean).join('\n');
188
+
189
+ // EXECUTE step
190
+ steps.execute = [
191
+ `## Step 3: Execute — Task #${taskNum}`,
192
+ '',
193
+ `Execute the plan at: \`${taskDir}/PLAN-1.md\``,
194
+ '',
195
+ 'Instructions:',
196
+ '- Follow the plan tasks sequentially',
197
+ '- Make atomic commits per task',
198
+ '- If a task fails, retry once then escalate',
199
+ '',
200
+ `Write summary to: \`${taskDir}/SUMMARY-1.md\``,
201
+ 'Include: key files modified, decisions made, Self-Check status.',
202
+ '',
203
+ 'Then run: `npx brain-dev new-task --continue`'
204
+ ].join('\n');
205
+
206
+ // VERIFY step
207
+ if (mode !== 'light') {
208
+ steps.verify = [
209
+ `## Step 4: Verify — Task #${taskNum}`,
210
+ '',
211
+ `Verify the execution results against must_haves in: \`${taskDir}/PLAN-1.md\``,
212
+ '',
213
+ 'Verification levels:',
214
+ '1. EXISTS: Files exist and are non-empty',
215
+ '2. SUBSTANTIVE: Contains real implementation (no stubs)',
216
+ '3. WIRED: Properly imported and consumed',
217
+ '',
218
+ `Write results to: \`${taskDir}/VERIFICATION.md\``,
219
+ '',
220
+ 'Then run: `npx brain-dev new-task --continue`'
221
+ ].join('\n');
222
+ }
223
+
224
+ // COMPLETE step
225
+ steps.complete = [
226
+ `## Step 5: Complete — Task #${taskNum}`,
227
+ '',
228
+ 'Task completion options:',
229
+ '',
230
+ '**Option A: Keep as standalone task**',
231
+ '`npx brain-dev new-task --continue` (will auto-complete)',
232
+ '',
233
+ '**Option B: Promote to roadmap phase**',
234
+ `\`npx brain-dev new-task --promote ${taskNum}\``,
235
+ 'This adds the task as a new phase in ROADMAP.md.',
236
+ '',
237
+ 'Commit suggestion:',
238
+ `\`git add -A && git commit -m "feat(task-${padded}): ${slug}"\``
239
+ ].join('\n');
240
+
241
+ return steps;
242
+ }
243
+
244
+ function handleContinue(brainDir, state) {
245
+ const taskNum = state.tasks?.current;
246
+ if (!taskNum) {
247
+ error("No active task. Start one with: brain-dev new-task \"description\"");
248
+ return { error: 'no-active-task' };
249
+ }
250
+
251
+ // Find task directory
252
+ const tasksDir = path.join(brainDir, 'tasks');
253
+ if (!fs.existsSync(tasksDir)) {
254
+ error("No tasks directory found.");
255
+ return { error: 'no-tasks-dir' };
256
+ }
257
+
258
+ const padded = String(taskNum).padStart(2, '0');
259
+ const dirs = fs.readdirSync(tasksDir).filter(d => d.startsWith(padded + '-'));
260
+ if (dirs.length === 0) {
261
+ error(`Task #${taskNum} directory not found.`);
262
+ return { error: 'task-not-found' };
263
+ }
264
+
265
+ const taskDir = path.join(tasksDir, dirs[0]);
266
+ const taskMeta = JSON.parse(fs.readFileSync(path.join(taskDir, 'task.json'), 'utf8'));
267
+
268
+ // Determine current step based on what files exist
269
+ const hasContext = fs.existsSync(path.join(taskDir, 'CONTEXT.md'));
270
+ const hasResearch = fs.existsSync(path.join(taskDir, 'RESEARCH.md'));
271
+ const hasPlan = fs.existsSync(path.join(taskDir, 'PLAN-1.md'));
272
+ const hasSummary = fs.existsSync(path.join(taskDir, 'SUMMARY-1.md'));
273
+ const hasVerification = fs.existsSync(path.join(taskDir, 'VERIFICATION.md'));
274
+
275
+ const isLight = taskMeta.mode === 'light';
276
+ let nextStep;
277
+ let newStatus;
278
+
279
+ if (!hasContext && !isLight) {
280
+ nextStep = 'discuss';
281
+ newStatus = 'discussing';
282
+ } else if (taskMeta.research && !hasResearch) {
283
+ nextStep = 'research';
284
+ newStatus = 'researching';
285
+ } else if (!hasPlan) {
286
+ nextStep = 'plan';
287
+ newStatus = 'planning';
288
+ } else if (!hasSummary) {
289
+ nextStep = 'execute';
290
+ newStatus = 'executing';
291
+ } else if (!hasVerification && !isLight) {
292
+ nextStep = 'verify';
293
+ newStatus = 'verifying';
294
+ } else {
295
+ // All steps done — complete the task
296
+ return handleTaskComplete(brainDir, state, taskNum, taskDir, taskMeta);
297
+ }
298
+
299
+ // Update status
300
+ taskMeta.status = newStatus;
301
+ fs.writeFileSync(path.join(taskDir, 'task.json'), JSON.stringify(taskMeta, null, 2));
302
+
303
+ // Update state
304
+ state.tasks.active = state.tasks.active || [];
305
+ const activeIdx = state.tasks.active.findIndex(t => t.num === taskNum);
306
+ if (activeIdx >= 0) state.tasks.active[activeIdx].status = newStatus;
307
+ writeState(brainDir, state);
308
+
309
+ // Build step instructions
310
+ const steps = buildStepInstructions(
311
+ taskNum, taskMeta.slug, taskDir, brainDir,
312
+ taskMeta.mode, taskMeta.research, taskMeta.description
313
+ );
314
+
315
+ logEvent(brainDir, 0, { type: 'task-continue', task: taskNum, step: nextStep });
316
+
317
+ // Record cost for execute step
318
+ if (nextStep === 'execute' && hasPlan) {
319
+ try {
320
+ const planContent = fs.readFileSync(path.join(taskDir, 'PLAN-1.md'), 'utf8');
321
+ const inputTokens = estimateTokens(planContent);
322
+ recordInvocation(brainDir, {
323
+ agent: 'executor', phase: 0, plan: '01',
324
+ model: 'inherit', input_tokens: inputTokens, output_tokens: 0
325
+ });
326
+ } catch { /* non-fatal */ }
327
+ }
328
+
329
+ const humanLines = [
330
+ prefix(`Task #${taskNum}: ${taskMeta.description}`),
331
+ prefix(`Status: ${newStatus}`),
332
+ prefix(`Next step: ${nextStep}`),
333
+ '',
334
+ steps[nextStep]
335
+ ].join('\n');
336
+
337
+ output({
338
+ action: 'task-continue',
339
+ task: taskMeta,
340
+ nextStep,
341
+ instructions: steps[nextStep]
342
+ }, humanLines);
343
+
344
+ return { action: 'task-continue', task: taskMeta, nextStep };
345
+ }
346
+
347
+ function handleTaskComplete(brainDir, state, taskNum, taskDir, taskMeta) {
348
+ taskMeta.status = 'complete';
349
+ taskMeta.completed = new Date().toISOString();
350
+ fs.writeFileSync(path.join(taskDir, 'task.json'), JSON.stringify(taskMeta, null, 2));
351
+
352
+ // Move from active to history
353
+ state.tasks.active = state.tasks.active.filter(t => t.num !== taskNum);
354
+ state.tasks.history.push({
355
+ num: taskNum,
356
+ slug: taskMeta.slug,
357
+ description: taskMeta.description,
358
+ mode: taskMeta.mode,
359
+ completed: taskMeta.completed,
360
+ promoted_to_phase: null
361
+ });
362
+ state.tasks.current = null;
363
+ writeState(brainDir, state);
364
+
365
+ logEvent(brainDir, 0, { type: 'task-complete', task: taskNum, slug: taskMeta.slug });
366
+
367
+ const padded = String(taskNum).padStart(2, '0');
368
+ const humanLines = [
369
+ prefix(`Task #${taskNum} complete: ${taskMeta.description}`),
370
+ '',
371
+ prefix('Options:'),
372
+ prefix(' 1. Keep as standalone task (done!)'),
373
+ prefix(` 2. Promote to roadmap phase: brain-dev new-task --promote ${taskNum}`),
374
+ '',
375
+ prefix(`Suggested commit: git add -A && git commit -m "feat(task-${padded}): ${taskMeta.slug}"`)
376
+ ].join('\n');
377
+
378
+ output({
379
+ action: 'task-complete',
380
+ task: taskMeta,
381
+ promote_command: `brain-dev new-task --promote ${taskNum}`
382
+ }, humanLines);
383
+
384
+ return { action: 'task-complete', task: taskMeta };
385
+ }
386
+
387
+ function handlePromote(brainDir, state, taskNum) {
388
+ // Guard: phases must exist for promotion
389
+ if (!state.phase || !Array.isArray(state.phase.phases)) {
390
+ error("Project phases not initialized. Run '/brain:new-project' first.");
391
+ return { error: 'no-phases' };
392
+ }
393
+
394
+ // Find the task
395
+ const tasksDir = path.join(brainDir, 'tasks');
396
+ if (!fs.existsSync(tasksDir)) {
397
+ error(`No tasks directory found. Create a task first: brain-dev new-task "description"`);
398
+ return { error: 'no-tasks-dir' };
399
+ }
400
+ const padded = String(taskNum).padStart(2, '0');
401
+ const dirs = fs.readdirSync(tasksDir).filter(d => d.startsWith(padded + '-'));
402
+
403
+ if (dirs.length === 0) {
404
+ error(`Task #${taskNum} not found.`);
405
+ return { error: 'task-not-found' };
406
+ }
407
+
408
+ const taskDir = path.join(tasksDir, dirs[0]);
409
+ const taskMeta = JSON.parse(fs.readFileSync(path.join(taskDir, 'task.json'), 'utf8'));
410
+
411
+ // Insert as a new phase after current phase
412
+ try {
413
+ const { parseRoadmap, insertPhase, writeRoadmap } = require('../roadmap.cjs');
414
+ const roadmapPath = path.join(brainDir, 'ROADMAP.md');
415
+
416
+ if (!fs.existsSync(roadmapPath)) {
417
+ error("No ROADMAP.md found. Cannot promote without a roadmap.");
418
+ return { error: 'no-roadmap' };
419
+ }
420
+
421
+ const roadmapData = parseRoadmap(brainDir);
422
+ const afterPhase = state.phase?.current || roadmapData.phases.length;
423
+
424
+ const updatedRoadmap = insertPhase(roadmapData, afterPhase, {
425
+ name: taskMeta.description,
426
+ goal: `Implement: ${taskMeta.description} (promoted from task #${taskNum})`,
427
+ requirements: taskMeta.requirements || [],
428
+ status: taskMeta.status === 'complete' ? 'Complete' : 'In Progress'
429
+ });
430
+
431
+ writeRoadmap(brainDir, updatedRoadmap);
432
+
433
+ // Find the newly inserted phase (it was not in the original roadmapData)
434
+ const newPhase = updatedRoadmap.phases.find(
435
+ p => !roadmapData.phases.some(op => op.number === p.number)
436
+ );
437
+
438
+ // Update task metadata
439
+ taskMeta.promoted_to_phase = newPhase ? newPhase.number : afterPhase;
440
+ fs.writeFileSync(path.join(taskDir, 'task.json'), JSON.stringify(taskMeta, null, 2));
441
+
442
+ // Update state
443
+ state.phase.total = updatedRoadmap.phases.length;
444
+ state.phase.phases.push({
445
+ number: taskMeta.promoted_to_phase,
446
+ name: taskMeta.description,
447
+ status: taskMeta.status === 'complete' ? 'complete' : 'pending',
448
+ goal: newPhase ? newPhase.goal : taskMeta.description
449
+ });
450
+
451
+ // Update history
452
+ const historyEntry = state.tasks.history.find(t => t.num === taskNum);
453
+ if (historyEntry) historyEntry.promoted_to_phase = taskMeta.promoted_to_phase;
454
+
455
+ writeState(brainDir, state);
456
+
457
+ logEvent(brainDir, 0, { type: 'task-promote', task: taskNum, phase: taskMeta.promoted_to_phase });
458
+
459
+ // Copy artifacts to phase directory
460
+ const phaseSlug = taskMeta.slug;
461
+ const phasePadded = Number.isInteger(taskMeta.promoted_to_phase)
462
+ ? String(taskMeta.promoted_to_phase).padStart(2, '0')
463
+ : String(taskMeta.promoted_to_phase).replace('.', '_');
464
+ const phaseDir = path.join(brainDir, 'phases', `${phasePadded}-${phaseSlug}`);
465
+ fs.mkdirSync(phaseDir, { recursive: true });
466
+
467
+ // Copy plan, summary, verification if they exist
468
+ for (const file of ['PLAN-1.md', 'SUMMARY-1.md', 'VERIFICATION.md', 'CONTEXT.md']) {
469
+ const src = path.join(taskDir, file);
470
+ if (fs.existsSync(src)) {
471
+ fs.copyFileSync(src, path.join(phaseDir, file));
472
+ }
473
+ }
474
+
475
+ const humanLines = [
476
+ prefix(`Task #${taskNum} promoted to Phase ${taskMeta.promoted_to_phase}`),
477
+ prefix(`Phase: ${taskMeta.description}`),
478
+ prefix(`Artifacts copied to: .brain/phases/${phasePadded}-${phaseSlug}/`),
479
+ prefix('ROADMAP.md updated')
480
+ ].join('\n');
481
+
482
+ output({ action: 'task-promoted', task: taskMeta, phase: newPhase }, humanLines);
483
+ return { action: 'task-promoted', task: taskMeta, phase: newPhase };
484
+
485
+ } catch (e) {
486
+ error(`Promotion failed: ${e.message}`);
487
+ return { error: 'promotion-failed', message: e.message };
488
+ }
489
+ }
490
+
491
+ function handleList(brainDir, state) {
492
+ const tasks = state.tasks || { active: [], history: [], count: 0 };
493
+
494
+ const lines = [prefix(`Tasks (${tasks.count} total)`)];
495
+
496
+ if (tasks.active.length > 0) {
497
+ lines.push('');
498
+ lines.push(prefix('Active:'));
499
+ for (const t of tasks.active) {
500
+ lines.push(prefix(` #${t.num} ${t.slug} (${t.status})`));
501
+ }
502
+ }
503
+
504
+ if (tasks.history.length > 0) {
505
+ lines.push('');
506
+ lines.push(prefix('Completed:'));
507
+ for (const t of tasks.history.slice(-10)) {
508
+ const promoted = t.promoted_to_phase ? ` -> Phase ${t.promoted_to_phase}` : '';
509
+ lines.push(prefix(` #${t.num} ${t.slug}${promoted}`));
510
+ }
511
+ }
512
+
513
+ if (tasks.count === 0) {
514
+ lines.push(prefix(' No tasks yet. Create one: brain-dev new-task "description"'));
515
+ }
516
+
517
+ const result = { action: 'task-list', tasks };
518
+ output(result, lines.join('\n'));
519
+ return result;
520
+ }
521
+
522
+ module.exports = { run };
@@ -29,7 +29,7 @@ function nextAction(state) {
29
29
  case 'initialized':
30
30
  return '/brain:new-project';
31
31
  case 'pending':
32
- return '/brain:discuss';
32
+ return '/brain:story';
33
33
  case 'discussing':
34
34
  case 'discussed':
35
35
  return '/brain:plan';