brain-dev 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/agents/brain-checker.md +33 -0
- package/agents/brain-debugger.md +35 -0
- package/agents/brain-executor.md +37 -0
- package/agents/brain-mapper.md +44 -0
- package/agents/brain-planner.md +49 -0
- package/agents/brain-researcher.md +47 -0
- package/agents/brain-synthesizer.md +43 -0
- package/agents/brain-verifier.md +41 -0
- package/bin/brain-tools.cjs +185 -0
- package/bin/lib/adr.cjs +283 -0
- package/bin/lib/agents.cjs +152 -0
- package/bin/lib/anti-patterns.cjs +183 -0
- package/bin/lib/audit.cjs +268 -0
- package/bin/lib/commands/adr.cjs +126 -0
- package/bin/lib/commands/complete.cjs +270 -0
- package/bin/lib/commands/config.cjs +306 -0
- package/bin/lib/commands/discuss.cjs +237 -0
- package/bin/lib/commands/execute.cjs +415 -0
- package/bin/lib/commands/health.cjs +103 -0
- package/bin/lib/commands/map.cjs +101 -0
- package/bin/lib/commands/new-project.cjs +885 -0
- package/bin/lib/commands/pause.cjs +142 -0
- package/bin/lib/commands/phase-manage.cjs +357 -0
- package/bin/lib/commands/plan.cjs +451 -0
- package/bin/lib/commands/progress.cjs +167 -0
- package/bin/lib/commands/quick.cjs +447 -0
- package/bin/lib/commands/resume.cjs +196 -0
- package/bin/lib/commands/storm.cjs +590 -0
- package/bin/lib/commands/verify.cjs +504 -0
- package/bin/lib/commands.cjs +263 -0
- package/bin/lib/complexity.cjs +138 -0
- package/bin/lib/complexity.test.cjs +108 -0
- package/bin/lib/config.cjs +452 -0
- package/bin/lib/core.cjs +62 -0
- package/bin/lib/detect.cjs +603 -0
- package/bin/lib/git.cjs +112 -0
- package/bin/lib/health.cjs +356 -0
- package/bin/lib/init.cjs +310 -0
- package/bin/lib/logger.cjs +100 -0
- package/bin/lib/platform.cjs +58 -0
- package/bin/lib/requirements.cjs +158 -0
- package/bin/lib/roadmap.cjs +228 -0
- package/bin/lib/security.cjs +237 -0
- package/bin/lib/state.cjs +353 -0
- package/bin/lib/templates.cjs +48 -0
- package/bin/templates/advocate.md +182 -0
- package/bin/templates/checkpoint.md +55 -0
- package/bin/templates/debugger.md +148 -0
- package/bin/templates/discuss.md +60 -0
- package/bin/templates/executor.md +201 -0
- package/bin/templates/mapper.md +129 -0
- package/bin/templates/plan-checker.md +134 -0
- package/bin/templates/planner.md +165 -0
- package/bin/templates/researcher.md +78 -0
- package/bin/templates/storm.html +376 -0
- package/bin/templates/synthesis.md +30 -0
- package/bin/templates/verifier.md +181 -0
- package/commands/brain/adr.md +34 -0
- package/commands/brain/complete.md +37 -0
- package/commands/brain/config.md +37 -0
- package/commands/brain/discuss.md +35 -0
- package/commands/brain/execute.md +38 -0
- package/commands/brain/health.md +33 -0
- package/commands/brain/map.md +35 -0
- package/commands/brain/new-project.md +38 -0
- package/commands/brain/pause.md +26 -0
- package/commands/brain/plan.md +38 -0
- package/commands/brain/progress.md +28 -0
- package/commands/brain/quick.md +51 -0
- package/commands/brain/resume.md +28 -0
- package/commands/brain/storm.md +30 -0
- package/commands/brain/verify.md +39 -0
- package/hooks/bootstrap.sh +54 -0
- package/hooks/post-tool-use.sh +45 -0
- package/hooks/statusline.sh +130 -0
- package/package.json +36 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Log an execution event as a JSONL line.
|
|
8
|
+
* Auto-adds timestamp and creates logs/ directory if missing.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
11
|
+
* @param {number} phaseNumber - Phase number (padded to 2 digits)
|
|
12
|
+
* @param {object} event - Event data to log
|
|
13
|
+
*/
|
|
14
|
+
function logEvent(brainDir, phaseNumber, event) {
|
|
15
|
+
const logsDir = path.join(brainDir, 'logs');
|
|
16
|
+
if (!fs.existsSync(logsDir)) {
|
|
17
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const padded = String(phaseNumber).padStart(2, '0');
|
|
21
|
+
const logPath = path.join(logsDir, `phase-${padded}-execution.jsonl`);
|
|
22
|
+
|
|
23
|
+
const entry = {
|
|
24
|
+
...event,
|
|
25
|
+
timestamp: new Date().toISOString()
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read all events from a phase execution log.
|
|
33
|
+
* Returns empty array if log file does not exist.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
36
|
+
* @param {number} phaseNumber - Phase number
|
|
37
|
+
* @returns {object[]} Array of parsed event objects
|
|
38
|
+
*/
|
|
39
|
+
function readLog(brainDir, phaseNumber) {
|
|
40
|
+
const padded = String(phaseNumber).padStart(2, '0');
|
|
41
|
+
const logPath = path.join(brainDir, 'logs', `phase-${padded}-execution.jsonl`);
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(logPath)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const content = fs.readFileSync(logPath, 'utf8').trim();
|
|
48
|
+
if (!content) return [];
|
|
49
|
+
|
|
50
|
+
return content.split('\n').reduce((entries, line) => {
|
|
51
|
+
try {
|
|
52
|
+
entries.push(JSON.parse(line));
|
|
53
|
+
} catch {
|
|
54
|
+
// Skip corrupted/incomplete log lines
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}, []);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Archive a phase log and clean up old archives beyond keepLast.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
64
|
+
* @param {number} phaseNumber - Phase number to archive
|
|
65
|
+
* @param {number} [keepLast=3] - Maximum number of archived logs to keep
|
|
66
|
+
*/
|
|
67
|
+
function archiveLogs(brainDir, phaseNumber, keepLast = 3) {
|
|
68
|
+
const padded = String(phaseNumber).padStart(2, '0');
|
|
69
|
+
const logFileName = `phase-${padded}-execution.jsonl`;
|
|
70
|
+
const logPath = path.join(brainDir, 'logs', logFileName);
|
|
71
|
+
|
|
72
|
+
if (!fs.existsSync(logPath)) return;
|
|
73
|
+
|
|
74
|
+
const archiveDir = path.join(brainDir, 'logs', 'archive');
|
|
75
|
+
if (!fs.existsSync(archiveDir)) {
|
|
76
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Move log to archive
|
|
80
|
+
const archivePath = path.join(archiveDir, logFileName);
|
|
81
|
+
fs.renameSync(logPath, archivePath);
|
|
82
|
+
|
|
83
|
+
// Clean up old archives beyond keepLast
|
|
84
|
+
const archived = fs.readdirSync(archiveDir)
|
|
85
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
86
|
+
.sort();
|
|
87
|
+
|
|
88
|
+
if (archived.length > keepLast) {
|
|
89
|
+
const toRemove = archived.slice(0, archived.length - keepLast);
|
|
90
|
+
for (const file of toRemove) {
|
|
91
|
+
fs.unlinkSync(path.join(archiveDir, file));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
logEvent,
|
|
98
|
+
readLog,
|
|
99
|
+
archiveLogs
|
|
100
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect which AI IDE platform is running.
|
|
8
|
+
* Checks environment variables and directory existence.
|
|
9
|
+
* @param {object} [opts]
|
|
10
|
+
* @param {string} [opts.cwd] - Working directory to check for platform indicators
|
|
11
|
+
* @returns {string} Platform identifier
|
|
12
|
+
*/
|
|
13
|
+
function detectPlatform(opts = {}) {
|
|
14
|
+
const cwd = opts.cwd || process.cwd();
|
|
15
|
+
|
|
16
|
+
// Claude Code: CLAUDE_CODE env var or .claude/ directory
|
|
17
|
+
if (process.env.CLAUDE_CODE || safeExists(path.join(cwd, '.claude'))) {
|
|
18
|
+
return 'claude-code';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Codex CLI: CODEX_HOME env var
|
|
22
|
+
if (process.env.CODEX_HOME) {
|
|
23
|
+
return 'codex';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// OpenCode: opencode.json in project root
|
|
27
|
+
if (safeExists(path.join(cwd, 'opencode.json'))) {
|
|
28
|
+
return 'opencode';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Gemini CLI: GEMINI_API_KEY env var
|
|
32
|
+
if (process.env.GEMINI_API_KEY) {
|
|
33
|
+
return 'gemini';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Cursor: .cursor/ directory
|
|
37
|
+
if (safeExists(path.join(cwd, '.cursor'))) {
|
|
38
|
+
return 'cursor';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default to claude-code (primary platform)
|
|
42
|
+
return 'claude-code';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Safe existence check that does not throw on permission errors.
|
|
47
|
+
* @param {string} p - Path to check
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function safeExists(p) {
|
|
51
|
+
try {
|
|
52
|
+
return fs.existsSync(p);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { detectPlatform };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { atomicWriteSync } = require('./state.cjs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse REQUIREMENTS.md from a directory into structured data.
|
|
9
|
+
* @param {string} brainDir - Path to directory containing REQUIREMENTS.md
|
|
10
|
+
* @returns {{ requirements: Array<{id: string, completed: boolean, description: string}>, traceability: Array<{id: string, phase: string, status: string}> }}
|
|
11
|
+
*/
|
|
12
|
+
function parseRequirements(brainDir) {
|
|
13
|
+
const mdPath = path.join(brainDir, 'REQUIREMENTS.md');
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(mdPath)) {
|
|
16
|
+
return { requirements: [], traceability: [] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
content = fs.readFileSync(mdPath, 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
return { requirements: [], traceability: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Parse checkbox items: - [x] **ID**: Description or - [ ] **ID**: Description
|
|
27
|
+
const requirements = [];
|
|
28
|
+
const checkboxRegex = /^- \[([ x])\] \*\*(\S+)\*\*:?\s*(.+)$/gm;
|
|
29
|
+
let match;
|
|
30
|
+
|
|
31
|
+
while ((match = checkboxRegex.exec(content)) !== null) {
|
|
32
|
+
requirements.push({
|
|
33
|
+
id: match[2],
|
|
34
|
+
completed: match[1] === 'x',
|
|
35
|
+
description: match[3].trim()
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse traceability table
|
|
40
|
+
const traceability = [];
|
|
41
|
+
const traceIdx = content.indexOf('## Traceability');
|
|
42
|
+
if (traceIdx >= 0) {
|
|
43
|
+
const traceSection = content.slice(traceIdx);
|
|
44
|
+
const lines = traceSection.split('\n');
|
|
45
|
+
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
// Skip header row and separator row
|
|
48
|
+
if (!line.startsWith('|') || line.includes('---') || line.includes('Requirement')) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const cells = line.split('|').map(c => c.trim()).filter(c => c.length > 0);
|
|
52
|
+
if (cells.length >= 3) {
|
|
53
|
+
traceability.push({
|
|
54
|
+
id: cells[0],
|
|
55
|
+
phase: cells[1],
|
|
56
|
+
status: cells[2]
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { requirements, traceability };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Toggle a requirement's checkbox status.
|
|
67
|
+
* @param {string} brainDir - Path to directory containing REQUIREMENTS.md
|
|
68
|
+
* @param {string} reqId - Requirement ID (e.g., 'FOUND-01')
|
|
69
|
+
* @param {boolean} completed - true for [x], false for [ ]
|
|
70
|
+
*/
|
|
71
|
+
function updateStatus(brainDir, reqId, completed) {
|
|
72
|
+
const mdPath = path.join(brainDir, 'REQUIREMENTS.md');
|
|
73
|
+
let content = fs.readFileSync(mdPath, 'utf8');
|
|
74
|
+
|
|
75
|
+
const oldChecked = `- [x] **${reqId}**`;
|
|
76
|
+
const oldUnchecked = `- [ ] **${reqId}**`;
|
|
77
|
+
const newMark = completed ? '- [x]' : '- [ ]';
|
|
78
|
+
|
|
79
|
+
if (completed && content.includes(oldUnchecked)) {
|
|
80
|
+
content = content.replace(oldUnchecked, `${newMark} **${reqId}**`);
|
|
81
|
+
} else if (!completed && content.includes(oldChecked)) {
|
|
82
|
+
content = content.replace(oldChecked, `${newMark} **${reqId}**`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
atomicWriteSync(mdPath, content);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Update a row in the traceability table.
|
|
90
|
+
* @param {string} brainDir - Path to directory containing REQUIREMENTS.md
|
|
91
|
+
* @param {string} reqId - Requirement ID
|
|
92
|
+
* @param {object} updates - Fields to update (e.g., { status: 'Complete', phase: 'Phase 2' })
|
|
93
|
+
*/
|
|
94
|
+
function updateTraceability(brainDir, reqId, updates) {
|
|
95
|
+
const mdPath = path.join(brainDir, 'REQUIREMENTS.md');
|
|
96
|
+
let content = fs.readFileSync(mdPath, 'utf8');
|
|
97
|
+
const lines = content.split('\n');
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
if (!line.startsWith('|') || !line.includes(reqId)) continue;
|
|
102
|
+
// Skip header/separator
|
|
103
|
+
if (line.includes('---') || line.includes('Requirement')) continue;
|
|
104
|
+
|
|
105
|
+
const cells = line.split('|').map(c => c.trim()).filter(c => c.length > 0);
|
|
106
|
+
// cells: [id, phase, status]
|
|
107
|
+
if (cells.length >= 3 && cells[0] === reqId) {
|
|
108
|
+
if (updates.phase !== undefined) cells[1] = updates.phase;
|
|
109
|
+
if (updates.status !== undefined) cells[2] = updates.status;
|
|
110
|
+
lines[i] = `| ${cells[0]} | ${cells[1]} | ${cells[2]} |`;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
atomicWriteSync(mdPath, lines.join('\n'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get requirements for a specific phase number.
|
|
120
|
+
* @param {string} brainDir - Path to directory containing REQUIREMENTS.md
|
|
121
|
+
* @param {number} phaseNumber - Phase number to filter by
|
|
122
|
+
* @returns {Array<{id: string, completed: boolean, description: string}>}
|
|
123
|
+
*/
|
|
124
|
+
function getPhaseRequirements(brainDir, phaseNumber) {
|
|
125
|
+
const { requirements, traceability } = parseRequirements(brainDir);
|
|
126
|
+
|
|
127
|
+
// Find requirement IDs belonging to this phase
|
|
128
|
+
const phaseReqIds = new Set(
|
|
129
|
+
traceability
|
|
130
|
+
.filter(t => t.phase === `Phase ${phaseNumber}`)
|
|
131
|
+
.map(t => t.id)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return requirements.filter(r => phaseReqIds.has(r.id));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get completion statistics for all requirements.
|
|
139
|
+
* @param {string} brainDir - Path to directory containing REQUIREMENTS.md
|
|
140
|
+
* @returns {{ total: number, completed: number, pending: number, percent: number }}
|
|
141
|
+
*/
|
|
142
|
+
function getCompletionStats(brainDir) {
|
|
143
|
+
const { requirements } = parseRequirements(brainDir);
|
|
144
|
+
const total = requirements.length;
|
|
145
|
+
const completed = requirements.filter(r => r.completed).length;
|
|
146
|
+
const pending = total - completed;
|
|
147
|
+
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
|
|
148
|
+
|
|
149
|
+
return { total, completed, pending, percent };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
parseRequirements,
|
|
154
|
+
updateStatus,
|
|
155
|
+
updateTraceability,
|
|
156
|
+
getPhaseRequirements,
|
|
157
|
+
getCompletionStats
|
|
158
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { atomicWriteSync } = require('./state.cjs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse ROADMAP.md from a .brain directory into a structured object.
|
|
9
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
10
|
+
* @returns {{ overview: string, phases: Array<{number: number, name: string, goal: string, dependsOn: number[], requirements: string[], plans: string, status: string}> }}
|
|
11
|
+
*/
|
|
12
|
+
function parseRoadmap(brainDir) {
|
|
13
|
+
const mdPath = path.join(brainDir, 'ROADMAP.md');
|
|
14
|
+
let content;
|
|
15
|
+
try {
|
|
16
|
+
content = fs.readFileSync(mdPath, 'utf8');
|
|
17
|
+
} catch {
|
|
18
|
+
return { overview: '', phases: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Extract overview: everything between first heading and first ## or ### Phase
|
|
22
|
+
const overviewMatch = content.match(/^# .+\n([\s\S]*?)(?=\n## |\n### Phase )/m);
|
|
23
|
+
const overview = overviewMatch ? overviewMatch[1].trim() : '';
|
|
24
|
+
|
|
25
|
+
// Extract phases section: try multiple heading patterns
|
|
26
|
+
// Supports: "## Phases", "## Milestone:", or fallback to whole content
|
|
27
|
+
let phasesIdx = content.indexOf('## Phases');
|
|
28
|
+
if (phasesIdx < 0) {
|
|
29
|
+
// Try to find first ### Phase heading directly
|
|
30
|
+
phasesIdx = content.search(/### Phase [\d.]+/);
|
|
31
|
+
}
|
|
32
|
+
const progressIdx = content.indexOf('\n## Progress');
|
|
33
|
+
const summaryIdx = content.indexOf('\n## Phase Summary');
|
|
34
|
+
const endIdx = progressIdx >= 0 ? progressIdx : (summaryIdx >= 0 ? summaryIdx : undefined);
|
|
35
|
+
const phasesSection = phasesIdx >= 0
|
|
36
|
+
? content.slice(phasesIdx, endIdx)
|
|
37
|
+
: content; // fallback: parse entire content for ### Phase patterns
|
|
38
|
+
|
|
39
|
+
// Parse individual phases: ## Phase N: Name or ### Phase N: Name (both formats)
|
|
40
|
+
const phaseRegex = /#{2,3} Phase ([\d.]+):?\s+(.+)\n([\s\S]*?)(?=\n#{2,3} Phase |\n## (?!Phase)|$)/g;
|
|
41
|
+
const phases = [];
|
|
42
|
+
let match;
|
|
43
|
+
|
|
44
|
+
while ((match = phaseRegex.exec(phasesSection)) !== null) {
|
|
45
|
+
const number = parseFloat(match[1]);
|
|
46
|
+
const name = match[2].trim();
|
|
47
|
+
const body = match[3];
|
|
48
|
+
|
|
49
|
+
// Flexible matching: accept both "- **Goal:**" and "**Goal:**" formats
|
|
50
|
+
const goalMatch = body.match(/-?\s*\*\*Goal:\*\*\s*(.+)/);
|
|
51
|
+
const dependsMatch = body.match(/-?\s*\*\*Depends on:\*\*\s*(.+)/);
|
|
52
|
+
const reqMatch = body.match(/-?\s*\*\*Requirements:\*\*\s*(.+)/);
|
|
53
|
+
const plansMatch = body.match(/-?\s*\*\*Plans:\*\*\s*(.+)/);
|
|
54
|
+
const statusMatch = body.match(/-?\s*\*\*Status:\*\*\s*(.+)/);
|
|
55
|
+
|
|
56
|
+
const dependsStr = dependsMatch ? dependsMatch[1].trim() : 'None';
|
|
57
|
+
const dependsOn = (!dependsStr || dependsStr === 'None' || dependsStr === 'none')
|
|
58
|
+
? []
|
|
59
|
+
: dependsStr.split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
|
|
60
|
+
|
|
61
|
+
const reqStr = reqMatch ? reqMatch[1].trim() : '';
|
|
62
|
+
const requirements = (!reqStr || reqStr === 'None' || reqStr === 'none')
|
|
63
|
+
? []
|
|
64
|
+
: reqStr.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
65
|
+
|
|
66
|
+
phases.push({
|
|
67
|
+
number,
|
|
68
|
+
name,
|
|
69
|
+
goal: goalMatch ? goalMatch[1].trim() : '',
|
|
70
|
+
dependsOn,
|
|
71
|
+
requirements,
|
|
72
|
+
plans: plansMatch ? plansMatch[1].trim() : '',
|
|
73
|
+
status: statusMatch ? statusMatch[1].trim() : 'Pending'
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Sort numerically
|
|
78
|
+
phases.sort((a, b) => a.number - b.number);
|
|
79
|
+
|
|
80
|
+
return { overview, phases };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Write a structured roadmap object back to ROADMAP.md.
|
|
85
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
86
|
+
* @param {{ overview: string, phases: Array }} roadmapData - Structured roadmap
|
|
87
|
+
*/
|
|
88
|
+
function writeRoadmap(brainDir, roadmapData) {
|
|
89
|
+
const lines = ['# Roadmap', '', roadmapData.overview, '', '## Phases', ''];
|
|
90
|
+
|
|
91
|
+
for (const phase of roadmapData.phases) {
|
|
92
|
+
lines.push(`### Phase ${formatPhaseNumber(phase.number)}: ${phase.name}`);
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push(`- **Goal:** ${phase.goal}`);
|
|
95
|
+
lines.push(`- **Depends on:** ${phase.dependsOn.length === 0 ? 'None' : phase.dependsOn.map(n => formatPhaseNumber(n)).join(', ')}`);
|
|
96
|
+
lines.push(`- **Requirements:** ${phase.requirements.length === 0 ? 'None' : phase.requirements.join(', ')}`);
|
|
97
|
+
if (phase.plans) {
|
|
98
|
+
lines.push(`- **Plans:** ${phase.plans}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(`- **Status:** ${phase.status}`);
|
|
101
|
+
lines.push('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Progress table
|
|
105
|
+
lines.push('## Progress');
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push('| Phase | Status | Plans |');
|
|
108
|
+
lines.push('|-------|--------|-------|');
|
|
109
|
+
for (const phase of roadmapData.phases) {
|
|
110
|
+
lines.push(`| ${formatPhaseNumber(phase.number)} | ${phase.status} | ${phase.plans || '0/0'} |`);
|
|
111
|
+
}
|
|
112
|
+
lines.push('');
|
|
113
|
+
|
|
114
|
+
const mdPath = path.join(brainDir, 'ROADMAP.md');
|
|
115
|
+
atomicWriteSync(mdPath, lines.join('\n'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Format phase number: integers without decimal, decimals with one decimal place.
|
|
120
|
+
* @param {number} n
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function formatPhaseNumber(n) {
|
|
124
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Insert a new phase after a given phase number.
|
|
129
|
+
* Assigns next decimal number (e.g., after 2 creates 2.1, if 2.1 exists creates 2.2).
|
|
130
|
+
* @param {{ overview: string, phases: Array }} roadmapData
|
|
131
|
+
* @param {number} afterNumber - Phase number to insert after
|
|
132
|
+
* @param {{ name: string, goal: string, requirements?: string[], status?: string }} phaseDef
|
|
133
|
+
* @returns {{ overview: string, phases: Array }}
|
|
134
|
+
*/
|
|
135
|
+
function insertPhase(roadmapData, afterNumber, phaseDef) {
|
|
136
|
+
const base = Math.floor(afterNumber);
|
|
137
|
+
|
|
138
|
+
// Find existing decimal phases for this base
|
|
139
|
+
const existing = roadmapData.phases
|
|
140
|
+
.filter(p => Math.floor(p.number) === base && p.number !== base)
|
|
141
|
+
.map(p => p.number);
|
|
142
|
+
|
|
143
|
+
// Calculate next decimal
|
|
144
|
+
let nextDecimal;
|
|
145
|
+
if (existing.length === 0) {
|
|
146
|
+
nextDecimal = base + 0.1;
|
|
147
|
+
} else {
|
|
148
|
+
const maxExisting = Math.max(...existing);
|
|
149
|
+
nextDecimal = Math.round((maxExisting + 0.1) * 10) / 10;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const newPhase = {
|
|
153
|
+
number: nextDecimal,
|
|
154
|
+
name: phaseDef.name,
|
|
155
|
+
goal: phaseDef.goal,
|
|
156
|
+
dependsOn: [afterNumber],
|
|
157
|
+
requirements: phaseDef.requirements || [],
|
|
158
|
+
plans: phaseDef.plans || '',
|
|
159
|
+
status: phaseDef.status || 'Pending'
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const phases = [...roadmapData.phases, newPhase];
|
|
163
|
+
phases.sort((a, b) => a.number - b.number);
|
|
164
|
+
|
|
165
|
+
return { overview: roadmapData.overview, phases };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Remove a phase from the roadmap.
|
|
170
|
+
* @param {{ overview: string, phases: Array }} roadmapData
|
|
171
|
+
* @param {number} number - Phase number to remove
|
|
172
|
+
* @returns {{ roadmap: { overview: string, phases: Array }, orphanedRequirements: string[] }}
|
|
173
|
+
*/
|
|
174
|
+
function removePhase(roadmapData, number) {
|
|
175
|
+
const removed = roadmapData.phases.find(p => p.number === number);
|
|
176
|
+
const orphanedRequirements = removed ? removed.requirements : [];
|
|
177
|
+
|
|
178
|
+
const phases = roadmapData.phases
|
|
179
|
+
.filter(p => p.number !== number)
|
|
180
|
+
.map(p => {
|
|
181
|
+
// Update depends_on: remove references to deleted phase
|
|
182
|
+
const dependsOn = p.dependsOn.filter(d => d !== number);
|
|
183
|
+
return { ...p, dependsOn };
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return { roadmap: { overview: roadmapData.overview, phases }, orphanedRequirements };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reorder phases by providing the new order of original phase numbers.
|
|
191
|
+
* Renumbers all phases sequentially and updates depends_on references.
|
|
192
|
+
* @param {{ overview: string, phases: Array }} roadmapData
|
|
193
|
+
* @param {number[]} newOrder - Array of original phase numbers in desired order
|
|
194
|
+
* @returns {{ overview: string, phases: Array }}
|
|
195
|
+
*/
|
|
196
|
+
function reorderPhases(roadmapData, newOrder) {
|
|
197
|
+
// Build mapping: old number -> new number (1-based sequential)
|
|
198
|
+
const numberMap = {};
|
|
199
|
+
for (let i = 0; i < newOrder.length; i++) {
|
|
200
|
+
numberMap[newOrder[i]] = i + 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Reorder and renumber
|
|
204
|
+
const phases = newOrder.map((oldNum, i) => {
|
|
205
|
+
const phase = roadmapData.phases.find(p => p.number === oldNum);
|
|
206
|
+
if (!phase) return null;
|
|
207
|
+
|
|
208
|
+
const dependsOn = phase.dependsOn
|
|
209
|
+
.map(d => numberMap[d])
|
|
210
|
+
.filter(d => d !== undefined);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
...phase,
|
|
214
|
+
number: i + 1,
|
|
215
|
+
dependsOn
|
|
216
|
+
};
|
|
217
|
+
}).filter(Boolean);
|
|
218
|
+
|
|
219
|
+
return { overview: roadmapData.overview, phases };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
parseRoadmap,
|
|
224
|
+
writeRoadmap,
|
|
225
|
+
insertPhase,
|
|
226
|
+
removePhase,
|
|
227
|
+
reorderPhases
|
|
228
|
+
};
|