brain-dev 0.1.1 → 0.2.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,155 @@
1
+ 'use strict';
2
+
3
+ const { parseArgs } = require('node:util');
4
+ const path = require('node:path');
5
+ const { output, error, success, prefix } = require('../core.cjs');
6
+ const { readState } = require('../state.cjs');
7
+ const { readLock, isLockStale } = require('../lock.cjs');
8
+ const recovery = require('../recovery.cjs');
9
+
10
+ /**
11
+ * Recover command handler.
12
+ * Detects and recovers from crashed/interrupted executions.
13
+ *
14
+ * Flags:
15
+ * --fix Attempt auto-resume from last checkpoint
16
+ * --rollback Revert state to pre-execution
17
+ * --dismiss Clear stale lock without recovery
18
+ * --json Force JSON output
19
+ *
20
+ * Default (no flags): check mode — show recovery briefing
21
+ *
22
+ * @param {string[]} args - CLI arguments
23
+ * @param {object} [opts] - Options (brainDir override)
24
+ * @returns {object} Result object
25
+ */
26
+ async function run(args = [], opts = {}) {
27
+ const { values } = parseArgs({
28
+ args,
29
+ options: {
30
+ fix: { type: 'boolean', default: false },
31
+ rollback: { type: 'boolean', default: false },
32
+ dismiss: { type: 'boolean', default: false },
33
+ json: { type: 'boolean', default: false }
34
+ },
35
+ strict: false
36
+ });
37
+
38
+ const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
39
+
40
+ // Validate brainDir exists
41
+ const state = readState(brainDir);
42
+ if (!state) {
43
+ const result = { action: 'recover', status: 'error', message: 'No .brain directory or brain.json found' };
44
+ if (values.json) {
45
+ console.log(JSON.stringify(result));
46
+ } else {
47
+ error('No .brain directory found. Run `brain init` first.');
48
+ }
49
+ return result;
50
+ }
51
+
52
+ // Mutually exclusive flags check
53
+ const flagCount = [values.fix, values.rollback, values.dismiss].filter(Boolean).length;
54
+ if (flagCount > 1) {
55
+ const result = { action: 'recover', status: 'error', message: 'Use only one of --fix, --rollback, or --dismiss' };
56
+ if (values.json) {
57
+ console.log(JSON.stringify(result));
58
+ } else {
59
+ error('Use only one of --fix, --rollback, or --dismiss.');
60
+ }
61
+ return result;
62
+ }
63
+
64
+ // Dismiss mode: clear stale lock without recovery
65
+ if (values.dismiss) {
66
+ const result = recovery.recover(brainDir, { dismiss: true });
67
+ const resultObj = { action: 'recover', status: result.recovered ? 'dismissed' : 'no-action', ...result };
68
+
69
+ if (values.json) {
70
+ console.log(JSON.stringify(resultObj, null, 2));
71
+ } else if (result.recovered) {
72
+ success('Stale lock dismissed. No state changes made.');
73
+ } else {
74
+ output(resultObj, prefix(result.briefing));
75
+ }
76
+ return resultObj;
77
+ }
78
+
79
+ // Fix mode: auto-resume recovery
80
+ if (values.fix) {
81
+ const result = recovery.recover(brainDir, { fix: true });
82
+ const resultObj = { action: 'recover', status: result.recovered ? 'fixed' : 'no-action', ...result };
83
+
84
+ if (values.json) {
85
+ console.log(JSON.stringify(resultObj, null, 2));
86
+ } else if (result.recovered) {
87
+ // Show briefing then success
88
+ console.log('');
89
+ console.log(result.briefing);
90
+ console.log('');
91
+ success(`Recovery complete. ${result.nextAction}`);
92
+ if (result.result?.changes) {
93
+ for (const change of result.result.changes) {
94
+ console.log(prefix(` - ${change}`));
95
+ }
96
+ }
97
+ } else {
98
+ output(resultObj, prefix(result.briefing));
99
+ }
100
+ return resultObj;
101
+ }
102
+
103
+ // Rollback mode: revert state
104
+ if (values.rollback) {
105
+ const result = recovery.recover(brainDir, { rollback: true });
106
+ const resultObj = { action: 'recover', status: result.recovered ? 'rolled-back' : 'no-action', ...result };
107
+
108
+ if (values.json) {
109
+ console.log(JSON.stringify(resultObj, null, 2));
110
+ } else if (result.recovered) {
111
+ console.log('');
112
+ console.log(result.briefing);
113
+ console.log('');
114
+ success(`Rollback complete. ${result.nextAction}`);
115
+ if (result.result?.changes) {
116
+ for (const change of result.result.changes) {
117
+ console.log(prefix(` - ${change}`));
118
+ }
119
+ }
120
+ } else {
121
+ output(resultObj, prefix(result.briefing));
122
+ }
123
+ return resultObj;
124
+ }
125
+
126
+ // Default: check mode — show briefing without making changes
127
+ const result = recovery.recover(brainDir, {});
128
+ const resultObj = { action: 'recover', status: result.mode, ...result };
129
+
130
+ if (values.json) {
131
+ console.log(JSON.stringify(resultObj, null, 2));
132
+ } else {
133
+ console.log('');
134
+ console.log(result.briefing);
135
+ console.log('');
136
+
137
+ if (result.mode === 'clean' || result.mode === 'active' || result.mode === 'none') {
138
+ success(result.briefing);
139
+ } else {
140
+ // There is something to recover
141
+ const recommendation = result.result?.recommended;
142
+ if (recommendation === 'auto-resume') {
143
+ console.log(prefix('Suggested: brain recover --fix'));
144
+ } else if (recommendation === 'rollback') {
145
+ console.log(prefix('Suggested: brain recover --rollback'));
146
+ } else {
147
+ console.log(prefix('Review the briefing above and choose: --fix, --rollback, or --dismiss'));
148
+ }
149
+ }
150
+ }
151
+
152
+ return resultObj;
153
+ }
154
+
155
+ module.exports = { run };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { readState, writeState, migrateState } = require('../state.cjs');
6
+ const { detectPlatform } = require('../platform.cjs');
7
+ const { packagePath, registerClaudeHooks, registerAgents, registerCommands, cleanupLegacySkills } = require('../init.cjs');
8
+ const { output, error, success, prefix } = require('../core.cjs');
9
+
10
+ /**
11
+ * Copy hook scripts from package to .brain/hooks/.
12
+ * Preserves executable permissions.
13
+ * @param {string} brainDir
14
+ */
15
+ function copyHooks(brainDir) {
16
+ const hooksDir = path.join(brainDir, 'hooks');
17
+ fs.mkdirSync(hooksDir, { recursive: true });
18
+
19
+ const hookFiles = ['bootstrap.sh', 'statusline.sh', 'post-tool-use.sh'];
20
+ for (const file of hookFiles) {
21
+ const src = packagePath('hooks', file);
22
+ if (fs.existsSync(src)) {
23
+ const dest = path.join(hooksDir, file);
24
+ fs.copyFileSync(src, dest);
25
+ try { fs.chmodSync(dest, 0o755); } catch { /* skip on unsupported FS */ }
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Update .brain/.gitignore with current patterns.
32
+ * @param {string} brainDir
33
+ */
34
+ function updateGitignore(brainDir) {
35
+ const content = [
36
+ '*.tmp',
37
+ '*.lock',
38
+ 'storm/fragments/',
39
+ 'storm/events.jsonl',
40
+ ''
41
+ ].join('\n');
42
+ fs.writeFileSync(path.join(brainDir, '.gitignore'), content, 'utf8');
43
+ }
44
+
45
+ /**
46
+ * Run the update command.
47
+ * Updates tool files (hooks, agents, commands) while preserving project state.
48
+ *
49
+ * brain-dev update → Update tool files + migrate state
50
+ * brain-dev update --check → Only check version, don't update
51
+ *
52
+ * @param {string[]} args
53
+ * @param {object} [opts]
54
+ */
55
+ async function run(args = [], opts = {}) {
56
+ const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
57
+ const cwd = opts.cwd || process.cwd();
58
+
59
+ // 1. Validate .brain/ exists
60
+ if (!fs.existsSync(brainDir)) {
61
+ error("No brain project found. Run 'npx brain-dev init' first.");
62
+ return { error: 'no-project' };
63
+ }
64
+
65
+ // 2. Read current state
66
+ const state = readState(brainDir);
67
+ if (!state) {
68
+ error("Corrupted brain.json. Run 'npx brain-dev init --force' to reset.");
69
+ return { error: 'corrupted-state' };
70
+ }
71
+
72
+ // 3. Get versions
73
+ const installedVersion = state.version || '0.0.0';
74
+ const packageVersion = require(path.join(__dirname, '..', '..', '..', 'package.json')).version;
75
+
76
+ // --check mode
77
+ if (args.includes('--check')) {
78
+ const upToDate = installedVersion === packageVersion;
79
+ const msg = upToDate
80
+ ? `brain-dev v${installedVersion} is up to date.`
81
+ : `Update available: v${installedVersion} → v${packageVersion}. Run: npx brain-dev update`;
82
+
83
+ output({
84
+ action: 'version-check',
85
+ installed: installedVersion,
86
+ package: packageVersion,
87
+ upToDate
88
+ }, prefix(msg));
89
+
90
+ return { action: 'version-check', installed: installedVersion, package: packageVersion, upToDate };
91
+ }
92
+
93
+ // 4. Backup state before update
94
+ const backupPath = path.join(brainDir, 'brain.json.pre-update');
95
+ try {
96
+ fs.copyFileSync(path.join(brainDir, 'brain.json'), backupPath);
97
+ } catch (e) {
98
+ error(`Could not backup brain.json: ${e.message}`);
99
+ return { error: 'backup-failed' };
100
+ }
101
+
102
+ // 5. Ensure required directories exist
103
+ const requiredDirs = ['hooks', 'debug', 'specs', 'codebase'];
104
+ for (const dir of requiredDirs) {
105
+ fs.mkdirSync(path.join(brainDir, dir), { recursive: true });
106
+ }
107
+
108
+ // 6. Update hook scripts
109
+ copyHooks(brainDir);
110
+
111
+ // 7. Platform-specific updates
112
+ const platform = detectPlatform({ cwd });
113
+ if (platform === 'claude-code') {
114
+ registerClaudeHooks(cwd);
115
+ cleanupLegacySkills(cwd);
116
+ registerCommands(cwd);
117
+ registerAgents(cwd);
118
+ }
119
+
120
+ // 8. Migrate state (adds new fields, preserves all existing data)
121
+ const migrated = migrateState(state);
122
+ writeState(brainDir, migrated);
123
+
124
+ // 9. Update .gitignore
125
+ updateGitignore(brainDir);
126
+
127
+ // 10. Report
128
+ const isUpgrade = installedVersion !== packageVersion;
129
+ const msg = isUpgrade
130
+ ? `Updated brain-dev v${installedVersion} → v${packageVersion}. State preserved. Backup: brain.json.pre-update`
131
+ : `brain-dev v${packageVersion} tool files refreshed. State preserved.`;
132
+
133
+ success(msg);
134
+
135
+ const result = {
136
+ action: 'updated',
137
+ from: installedVersion,
138
+ to: packageVersion,
139
+ backup: backupPath,
140
+ statePreserved: true,
141
+ nextAction: '/brain:progress'
142
+ };
143
+
144
+ output(result, '');
145
+ return result;
146
+ }
147
+
148
+ module.exports = { run };
@@ -9,6 +9,7 @@ const { logEvent } = require('../logger.cjs');
9
9
  const { output, error, success } = require('../core.cjs');
10
10
  const antiPatterns = require('../anti-patterns.cjs');
11
11
  const { buildDebuggerSpawnInstructions } = require('./execute.cjs');
12
+ const { isPathWithinRoot } = require('../security.cjs');
12
13
 
13
14
  /**
14
15
  * Find a phase directory under .brain/phases/ matching a phase number.
@@ -72,9 +73,10 @@ function extractMustHaves(content) {
72
73
  * Extract phase-modified file paths from PLAN and SUMMARY frontmatter.
73
74
  * @param {string[]} planFiles - Full paths to plan files
74
75
  * @param {string[]} summaryFiles - Full paths to summary files
76
+ * @param {string} [rootDir] - Project root directory for path traversal guard
75
77
  * @returns {string[]} Deduplicated array of file paths
76
78
  */
77
- function extractPhaseFiles(planFiles, summaryFiles) {
79
+ function extractPhaseFiles(planFiles, summaryFiles, rootDir) {
78
80
  const files = new Set();
79
81
 
80
82
  for (const planPath of planFiles) {
@@ -90,7 +92,10 @@ function extractPhaseFiles(planFiles, summaryFiles) {
90
92
  for (const line of lines) {
91
93
  const itemMatch = line.match(/^\s+-\s+(.+)/);
92
94
  if (itemMatch) {
93
- files.add(itemMatch[1].trim());
95
+ const filePath = itemMatch[1].trim();
96
+ // Guard against path traversal
97
+ if (rootDir && !isPathWithinRoot(filePath, rootDir)) continue;
98
+ files.add(filePath);
94
99
  } else if (line.trim() && !line.match(/^\s+-/) && !line.match(/^\s*$/)) {
95
100
  break; // Hit next YAML key
96
101
  }
@@ -117,7 +122,10 @@ function extractPhaseFiles(planFiles, summaryFiles) {
117
122
  for (const line of lines) {
118
123
  const itemMatch = line.match(/^\s+-\s+(.+)/);
119
124
  if (itemMatch) {
120
- files.add(itemMatch[1].trim().replace(/^["']|["']$/g, ''));
125
+ const filePath = itemMatch[1].trim().replace(/^["']|["']$/g, '');
126
+ // Guard against path traversal
127
+ if (rootDir && !isPathWithinRoot(filePath, rootDir)) continue;
128
+ files.add(filePath);
121
129
  } else if (line.trim() && !line.match(/^\s+-/) && !line.match(/^\s*$/) && !line.match(/^\s+\w+:/)) {
122
130
  break;
123
131
  }
@@ -213,11 +221,11 @@ async function run(args = [], opts = {}) {
213
221
  const summaryPaths = summaryFiles.map(f => path.join(phaseDir, f));
214
222
 
215
223
  // Extract phase-modified files for anti-pattern scanning
224
+ const projectDir = path.dirname(brainDir);
216
225
  const planFullPaths = planFiles.map(f => path.join(phaseDir, f));
217
- const phaseFiles = extractPhaseFiles(planFullPaths, summaryPaths);
226
+ const phaseFiles = extractPhaseFiles(planFullPaths, summaryPaths, projectDir);
218
227
 
219
228
  // Run anti-pattern scan on phase files
220
- const projectDir = path.dirname(brainDir);
221
229
  const apResults = antiPatterns.scanFiles(projectDir, { files: phaseFiles });
222
230
 
223
231
  // Build Nyquist section based on state config
@@ -400,6 +408,8 @@ function handleSaveResults(args, saveIdx, brainDir, state) {
400
408
 
401
409
  // Update state
402
410
  state.phase.status = results.passed ? 'verified' : 'verification-failed';
411
+ // Clear execution timer after verification completes
412
+ state.phase.execution_started_at = null;
403
413
  writeState(brainDir, state);
404
414
 
405
415
  const msg = results.passed
@@ -148,6 +148,15 @@ const COMMANDS = [
148
148
  needsState: true,
149
149
  args: ' --session <id> Resume a specific session by timestamp ID'
150
150
  },
151
+ {
152
+ name: 'recover',
153
+ description: 'Detect and recover from crashes',
154
+ usage: 'brain-dev recover [--fix|--rollback|--dismiss]',
155
+ group: 'Session',
156
+ implemented: true,
157
+ needsState: true,
158
+ args: ' --fix Auto-resume from last checkpoint\n --rollback Revert to pre-crash state\n --dismiss Clear stale lock, move on'
159
+ },
151
160
  // Tools
152
161
  {
153
162
  name: 'storm',
@@ -158,6 +167,15 @@ const COMMANDS = [
158
167
  needsState: true,
159
168
  args: ' <topic> Topic to brainstorm\n --stop Stop running server\n --port <n> Custom port number\n --finalize Generate output.md from fragments'
160
169
  },
170
+ {
171
+ name: 'dashboard',
172
+ description: 'Live monitoring dashboard',
173
+ usage: 'brain-dev dashboard [--browser|--stop|--status]',
174
+ group: 'Tools',
175
+ implemented: true,
176
+ needsState: true,
177
+ args: ' --browser Open web dashboard in browser\n --stop Stop running dashboard server\n --status Show dashboard URL if running'
178
+ },
161
179
  {
162
180
  name: 'adr',
163
181
  description: 'Manage architecture decision records',
@@ -180,6 +198,15 @@ const COMMANDS = [
180
198
  },
181
199
 
182
200
  // Meta
201
+ {
202
+ name: 'update',
203
+ description: 'Update tool files while preserving project state',
204
+ usage: 'brain-dev update [--check]',
205
+ group: 'Meta',
206
+ implemented: true,
207
+ needsState: false,
208
+ args: ' --check Only check for new version, don\'t update'
209
+ },
183
210
  {
184
211
  name: 'health',
185
212
  description: 'Run health diagnostics and auto-repair safe issues',
@@ -7,10 +7,10 @@ const { readState, writeState, atomicWriteSync } = require('./state.cjs');
7
7
 
8
8
  // ─── Constants ──────────────────────────────────────────────────────────────
9
9
 
10
- const CATEGORIES = ['Workflow', 'Models', 'Enforcement', 'Monitoring', 'Complexity', 'Storm', 'ADR'];
10
+ const CATEGORIES = ['Workflow', 'Models', 'Enforcement', 'Monitoring', 'Complexity', 'Storm', 'ADR', 'Budget', 'Timeout', 'Dashboard'];
11
11
 
12
12
  const SCHEMA = {
13
- 'mode': { type: 'enum', values: ['interactive', 'yolo'], default: 'interactive', category: 'Workflow', description: 'Execution mode preset (sets multiple flags)' },
13
+ 'mode': { type: 'enum', values: ['interactive', 'yolo', 'auto'], default: 'interactive', category: 'Workflow', description: 'Execution mode preset (sets multiple flags)' },
14
14
  'depth': { type: 'enum', values: ['shallow', 'standard', 'deep'], default: 'deep', category: 'Workflow', description: 'Verification depth (shallow=L1, standard=L1+L2, deep=L1+L2+L3)' },
15
15
  'workflow.parallelization': { type: 'boolean', default: false, category: 'Workflow', description: 'Enable parallel wave execution' },
16
16
  'workflow.mapper_parallelization': { type: 'boolean', default: true, category: 'Workflow', description: 'Enable parallel codebase mapping' },
@@ -29,6 +29,21 @@ const SCHEMA = {
29
29
  'storm.auto_open': { type: 'boolean', default: true, category: 'Storm', description: 'Auto-open browser on storm start', local: true },
30
30
  'adr.auto_create': { type: 'boolean', default: true, category: 'ADR', description: 'Auto-create ADRs for significant decisions' },
31
31
  'adr.status_lifecycle': { type: 'boolean', default: true, category: 'ADR', description: 'Enable ADR status transitions' },
32
+ 'budget.ceiling_usd': { type: 'number', default: null, min: 0, category: 'Budget', description: 'Maximum spend in USD (null = unlimited)' },
33
+ 'budget.warn_at_pct': { type: 'number', default: 80, min: 10, max: 100, category: 'Budget', description: 'Warn when this % of ceiling is reached' },
34
+ 'budget.mode': { type: 'enum', values: ['warn', 'pause', 'hard-stop'], default: 'warn', category: 'Budget', description: 'Action when budget exceeded' },
35
+ 'timeout.enabled': { type: 'boolean', default: true, category: 'Timeout', description: 'Enable stuck detection and timeouts' },
36
+ 'timeout.soft': { type: 'number', default: 20, min: 5, max: 120, category: 'Timeout', description: 'Soft timeout in minutes (warning)' },
37
+ 'timeout.idle': { type: 'number', default: 5, min: 1, max: 30, category: 'Timeout', description: 'Idle timeout in minutes (no progress)' },
38
+ 'timeout.hard': { type: 'number', default: 45, min: 10, max: 180, category: 'Timeout', description: 'Hard timeout in minutes (force wrap-up)' },
39
+ 'timeout.max_retries': { type: 'number', default: 1, min: 0, max: 3, category: 'Timeout', description: 'Max resume attempts after stuck timeout' },
40
+ 'dashboard.port': { type: 'number', default: 3457, min: 1024, max: 65535, category: 'Dashboard', description: 'Dashboard server port' },
41
+ 'dashboard.auto_open': { type: 'boolean', default: true, category: 'Dashboard', description: 'Auto-open browser on dashboard start' },
42
+ 'auto.max_errors': { type: 'number', default: 3, min: 1, max: 10, category: 'Workflow', description: 'Max consecutive errors before auto mode pauses' },
43
+ 'auto.max_phases': { type: 'number', default: 0, min: 0, max: 100, category: 'Workflow', description: 'Max phases to process in auto mode (0 = unlimited)' },
44
+ 'auto.skip_discuss': { type: 'boolean', default: false, category: 'Workflow', description: 'Skip discuss step in auto mode when CONTEXT.md exists' },
45
+ 'auto.max_operations': { type: 'number', default: 100, min: 10, max: 1000, category: 'Workflow', description: 'Max operations (commits/writes) per auto session' },
46
+ 'auto.max_duration_minutes': { type: 'number', default: 120, min: 10, max: 480, category: 'Workflow', description: 'Max auto session duration in minutes' },
32
47
  };
33
48
 
34
49
  const PROFILES = {
@@ -69,6 +84,12 @@ const MODE_PRESETS = {
69
84
  'workflow.auto_recover': false,
70
85
  'workflow.parallelization': false,
71
86
  'enforcement.level': 'hard'
87
+ },
88
+ auto: {
89
+ 'workflow.advocate': false,
90
+ 'workflow.auto_recover': true,
91
+ 'workflow.parallelization': false,
92
+ 'enforcement.level': 'balanced'
72
93
  }
73
94
  };
74
95