brain-dev 0.1.2 → 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,98 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { readState } = require('./state.cjs');
6
+ const { readMetrics, checkBudget } = require('./cost.cjs');
7
+ const { readBridge } = require('./bridge.cjs');
8
+ const { readLock, isLockStale } = require('./lock.cjs');
9
+ const { tailLog } = require('./logger.cjs');
10
+ const { checkTimeouts, detectProgress } = require('./stuck.cjs');
11
+
12
+ /**
13
+ * Collect a full DashboardState snapshot from all data sources.
14
+ * Safe: all reads are wrapped so a single source failure does not
15
+ * break the entire snapshot.
16
+ * @param {string} brainDir - Path to .brain/ directory
17
+ * @returns {object} DashboardState snapshot
18
+ */
19
+ function collectState(brainDir) {
20
+ const state = safeRead(() => readState(brainDir), {});
21
+ const metrics = safeRead(() => readMetrics(brainDir), { totals: {} });
22
+ const contextBridge = readBridge(brainDir, 'context');
23
+ const stuckBridge = readBridge(brainDir, 'stuck');
24
+ const lock = readLock(brainDir);
25
+ const currentPhase = state.phase?.current || 1;
26
+ const events = safeRead(() => tailLog(brainDir, currentPhase, null), []);
27
+ const budgetStatus = safeRead(() => checkBudget(metrics), { status: 'ok', spent: 0 });
28
+ const timeoutStatus = safeRead(() => checkTimeouts(brainDir, state), { tier: 'none' });
29
+
30
+ return {
31
+ timestamp: new Date().toISOString(),
32
+ project: {
33
+ name: state.project?.name || 'Unknown',
34
+ mode: state.mode || 'interactive',
35
+ depth: state.depth || 'standard'
36
+ },
37
+ milestone: state.milestone || { current: 'v1.0' },
38
+ phases: {
39
+ current: currentPhase,
40
+ total: state.phase?.total || 0,
41
+ status: state.phase?.status || 'initialized',
42
+ list: (state.phase?.phases || []).map(p => ({
43
+ number: p.number || p,
44
+ name: p.name || `Phase ${p.number || p}`,
45
+ status: p.status || 'pending'
46
+ }))
47
+ },
48
+ auto: {
49
+ active: state.auto?.active || false,
50
+ started_at: state.auto?.started_at || null,
51
+ current_step: state.auto?.current_step || null,
52
+ phases_completed: state.auto?.phases_completed || 0
53
+ },
54
+ timing: {
55
+ phaseStart: state.phase?.execution_started_at || null,
56
+ phaseElapsed: state.phase?.execution_started_at
57
+ ? Math.round((Date.now() - new Date(state.phase.execution_started_at).getTime()) / 1000)
58
+ : 0
59
+ },
60
+ context: {
61
+ remainingPct: contextBridge?.remaining_pct || null,
62
+ level: contextBridge?.level || 'unknown'
63
+ },
64
+ cost: {
65
+ spent: metrics.totals?.estimated_cost_usd || 0,
66
+ ceiling: state.budget?.ceiling_usd || null,
67
+ pctUsed: budgetStatus.pct_used || 0,
68
+ status: budgetStatus.status || 'ok'
69
+ },
70
+ stuck: {
71
+ detected: stuckBridge?.timeout_tier === 'soft' ||
72
+ stuckBridge?.timeout_tier === 'idle' ||
73
+ stuckBridge?.timeout_tier === 'hard',
74
+ tier: stuckBridge?.timeout_tier || timeoutStatus.tier || 'none',
75
+ message: stuckBridge?.message || timeoutStatus.message || null,
76
+ idleMinutes: stuckBridge?.idle_minutes || 0
77
+ },
78
+ lock: {
79
+ active: !!lock,
80
+ operation: lock?.operation || null,
81
+ stale: lock ? isLockStale(lock).stale : false
82
+ },
83
+ events: events.slice(-50),
84
+ health: { stateValid: !!state.$schema }
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Safely invoke a reader function, returning fallback on any error.
90
+ * @param {Function} fn - Reader function
91
+ * @param {*} fallback - Value to return on failure
92
+ * @returns {*}
93
+ */
94
+ function safeRead(fn, fallback) {
95
+ try { return fn(); } catch { return fallback; }
96
+ }
97
+
98
+ module.exports = { collectState };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ // Dashboard web server - WebSocket-based real-time dashboard
4
+ // Full implementation deferred - TUI mode (dashboard.cjs) is the primary interface
5
+
6
+ const http = require('node:http');
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ /**
11
+ * Start a WebSocket-based dashboard server.
12
+ * TODO: Implement following storm.cjs pattern (parseFrame, buildFrame, findAvailablePort).
13
+ * @param {string} brainDir - Path to .brain/ directory
14
+ * @param {number} port - Port to listen on
15
+ * @param {boolean} autoOpen - Whether to open browser automatically
16
+ * @throws {Error} Always — not yet implemented
17
+ */
18
+ async function startDashboardServer(brainDir, port, autoOpen) {
19
+ // TODO: Implement WebSocket server following storm.cjs pattern
20
+ // For now, return a stub
21
+ throw new Error('Web dashboard not yet implemented. Use brain-dev dashboard (TUI mode) instead.');
22
+ }
23
+
24
+ /**
25
+ * Stop a running dashboard server by removing its port file.
26
+ * @param {string} brainDir - Path to .brain/ directory
27
+ */
28
+ function stopDashboardServer(brainDir) {
29
+ const portFile = path.join(brainDir, '.dashboard-port');
30
+ try { fs.unlinkSync(portFile); } catch {}
31
+ }
32
+
33
+ module.exports = { startDashboardServer, stopDashboardServer };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ const { readState } = require('./state.cjs');
4
+ const { readBridge, writeBridge } = require('./bridge.cjs');
5
+ const { checkTimeouts, updateStuckBridge } = require('./stuck.cjs');
6
+ const { readLock } = require('./lock.cjs');
7
+ const { readMetrics } = require('./cost.cjs');
8
+
9
+ /**
10
+ * Handle StatusLine hook — aggregate state into a compact status string.
11
+ * Called on every status line refresh by the hook system.
12
+ * @param {object|null} inputJson - Parsed input from the hook (may contain context_window)
13
+ * @param {string} brainDir - Path to .brain/ directory
14
+ * @returns {string} Formatted status line text
15
+ */
16
+ function onStatusLine(inputJson, brainDir) {
17
+ const state = readState(brainDir);
18
+ const results = {};
19
+
20
+ // 1. Context monitoring — determine level and persist to bridge
21
+ results.context = {
22
+ remaining_pct: inputJson?.context_window?.remaining_percentage || null,
23
+ level: determineContextLevel(inputJson?.context_window?.remaining_percentage)
24
+ };
25
+ writeBridge(brainDir, 'context', results.context);
26
+
27
+ // 2. Stuck detection — update bridge when phase is active
28
+ if (state.phase?.status === 'executing' || state.phase?.status === 'verifying') {
29
+ updateStuckBridge(brainDir, state);
30
+ }
31
+
32
+ // 3. Cost summary for statusline
33
+ try {
34
+ const metrics = readMetrics(brainDir);
35
+ results.cost = {
36
+ spent: metrics.totals?.estimated_cost_usd || 0,
37
+ ceiling: state.budget?.ceiling_usd || null
38
+ };
39
+ } catch {
40
+ results.cost = null;
41
+ }
42
+
43
+ // 4. Lock status
44
+ const lock = readLock(brainDir);
45
+ results.lock = lock
46
+ ? { active: true, operation: lock.operation }
47
+ : { active: false };
48
+
49
+ // Build statusline text
50
+ const parts = [];
51
+ parts.push('brain');
52
+ if (state.phase) parts.push(`P${state.phase.current}: ${state.phase.status}`);
53
+ if (results.cost?.spent > 0) {
54
+ let costStr = `$${results.cost.spent.toFixed(2)}`;
55
+ if (results.cost.ceiling) costStr += `/$${results.cost.ceiling}`;
56
+ parts.push(costStr);
57
+ }
58
+ if (lock) parts.push('auto');
59
+
60
+ return parts.join(' | ');
61
+ }
62
+
63
+ /**
64
+ * Handle PostToolUse hook — return warnings that should be injected.
65
+ * Called after each tool use by the hook system.
66
+ * @param {string} brainDir - Path to .brain/ directory
67
+ * @returns {string[]} Array of warning messages (may be empty)
68
+ */
69
+ function onPostToolUse(brainDir) {
70
+ const warnings = [];
71
+
72
+ // Check stuck detection bridge
73
+ const stuckBridge = readBridge(brainDir, 'stuck');
74
+ if (stuckBridge?.message && stuckBridge.timeout_tier !== 'none') {
75
+ warnings.push(stuckBridge.message);
76
+ }
77
+
78
+ // Check context bridge
79
+ const contextBridge = readBridge(brainDir, 'context');
80
+ if (contextBridge?.level === 'critical') {
81
+ warnings.push('[brain] Context window critically low. Consider /brain:pause to save progress.');
82
+ }
83
+
84
+ return warnings;
85
+ }
86
+
87
+ /**
88
+ * Determine context window health level from remaining percentage.
89
+ * @param {number|null|undefined} remainingPct
90
+ * @returns {'normal'|'warning'|'critical'|'unknown'}
91
+ */
92
+ function determineContextLevel(remainingPct) {
93
+ if (remainingPct === null || remainingPct === undefined) return 'unknown';
94
+ if (remainingPct <= 25) return 'critical';
95
+ if (remainingPct <= 50) return 'warning';
96
+ return 'normal';
97
+ }
98
+
99
+ module.exports = { onStatusLine, onPostToolUse, determineContextLevel };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { atomicWriteSync } = require('./state.cjs');
6
+
7
+ const LOCK_FILE = '.auto.lock';
8
+ const DEFAULT_STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
9
+
10
+ /**
11
+ * Acquire the auto lock. Creates .brain/.auto.lock atomically.
12
+ * If a stale lock exists, it is cleared and re-acquired.
13
+ * @param {string} brainDir - Path to .brain/
14
+ * @param {object} context - { phase, plan, operation, agent }
15
+ * @returns {{ acquired: boolean, staleRecovered: boolean, previousLock: object|null }}
16
+ */
17
+ function acquireLock(brainDir, context) {
18
+ const lockPath = path.join(brainDir, LOCK_FILE);
19
+ const existing = readLock(brainDir);
20
+
21
+ if (existing) {
22
+ const { stale } = isLockStale(existing);
23
+ if (!stale) {
24
+ return { acquired: false, staleRecovered: false, previousLock: existing };
25
+ }
26
+ // Stale lock — back it up and clear
27
+ clearStaleLock(brainDir);
28
+ }
29
+
30
+ const lockData = {
31
+ pid: process.pid,
32
+ sessionId: new Date().toISOString().replace(/:/g, '-').slice(0, 19),
33
+ phase: context.phase || null,
34
+ plan: context.plan || null,
35
+ operation: context.operation || null,
36
+ agent: context.agent || null,
37
+ acquiredAt: new Date().toISOString(),
38
+ heartbeat: new Date().toISOString()
39
+ };
40
+
41
+ atomicWriteSync(lockPath, JSON.stringify(lockData, null, 2));
42
+ return { acquired: true, staleRecovered: !!existing, previousLock: existing || null };
43
+ }
44
+
45
+ /**
46
+ * Release the lock. Only releases if PID matches current process.
47
+ * @param {string} brainDir
48
+ * @returns {boolean} true if released
49
+ */
50
+ function releaseLock(brainDir) {
51
+ const lockPath = path.join(brainDir, LOCK_FILE);
52
+ const existing = readLock(brainDir);
53
+ if (!existing) return false;
54
+ if (existing.pid !== process.pid) return false;
55
+ try {
56
+ fs.unlinkSync(lockPath);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Read the current lock file without modifying it.
65
+ * @param {string} brainDir
66
+ * @returns {object|null} Lock data or null
67
+ */
68
+ function readLock(brainDir) {
69
+ try {
70
+ const lockPath = path.join(brainDir, LOCK_FILE);
71
+ return JSON.parse(fs.readFileSync(lockPath, 'utf8'));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Check if a lock is stale. Uses dual check: PID alive AND heartbeat fresh.
79
+ * @param {object} lockData - Parsed lock contents
80
+ * @param {number} [thresholdMs] - Staleness threshold (default 5 min)
81
+ * @returns {{ stale: boolean, reason: string }}
82
+ */
83
+ function isLockStale(lockData, thresholdMs = DEFAULT_STALE_THRESHOLD_MS) {
84
+ if (!lockData || !lockData.pid) return { stale: true, reason: 'Invalid lock data' };
85
+
86
+ // Check 1: Is the PID still alive?
87
+ try {
88
+ process.kill(lockData.pid, 0); // Signal 0 = existence check
89
+ } catch (e) {
90
+ if (e.code === 'ESRCH') {
91
+ return { stale: true, reason: `PID ${lockData.pid} is not running` };
92
+ }
93
+ // EPERM means process exists but we can't signal it — treat as alive
94
+ }
95
+
96
+ // Check 2: Is the heartbeat fresh?
97
+ if (lockData.heartbeat) {
98
+ const heartbeatAge = Date.now() - new Date(lockData.heartbeat).getTime();
99
+ if (heartbeatAge > thresholdMs) {
100
+ return { stale: true, reason: `Heartbeat ${Math.round(heartbeatAge / 1000)}s old (threshold: ${Math.round(thresholdMs / 1000)}s)` };
101
+ }
102
+ }
103
+
104
+ return { stale: false, reason: '' };
105
+ }
106
+
107
+ /**
108
+ * Update heartbeat timestamp in the lock file.
109
+ * Only updates if lock is owned by current process.
110
+ * @param {string} brainDir
111
+ * @returns {boolean} true if updated
112
+ */
113
+ function heartbeat(brainDir) {
114
+ const lockPath = path.join(brainDir, LOCK_FILE);
115
+ const existing = readLock(brainDir);
116
+ if (!existing || existing.pid !== process.pid) return false;
117
+ existing.heartbeat = new Date().toISOString();
118
+ try {
119
+ atomicWriteSync(lockPath, JSON.stringify(existing, null, 2));
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Force-remove a stale lock. Backs up to .auto.lock.stale first.
128
+ * @param {string} brainDir
129
+ * @returns {object|null} The stale lock data that was removed
130
+ */
131
+ function clearStaleLock(brainDir) {
132
+ const lockPath = path.join(brainDir, LOCK_FILE);
133
+ const stalePath = lockPath + '.stale';
134
+ const existing = readLock(brainDir);
135
+ if (!existing) return null;
136
+ try {
137
+ // Backup stale lock for forensics
138
+ fs.copyFileSync(lockPath, stalePath);
139
+ fs.unlinkSync(lockPath);
140
+ } catch { /* best effort */ }
141
+ return existing;
142
+ }
143
+
144
+ /**
145
+ * Check if a lock currently exists (any state).
146
+ * @param {string} brainDir
147
+ * @returns {boolean}
148
+ */
149
+ function isLocked(brainDir) {
150
+ return fs.existsSync(path.join(brainDir, LOCK_FILE));
151
+ }
152
+
153
+ module.exports = {
154
+ acquireLock,
155
+ releaseLock,
156
+ readLock,
157
+ isLockStale,
158
+ heartbeat,
159
+ clearStaleLock,
160
+ isLocked,
161
+ LOCK_FILE,
162
+ DEFAULT_STALE_THRESHOLD_MS
163
+ };
@@ -57,6 +57,23 @@ function readLog(brainDir, phaseNumber) {
57
57
  }, []);
58
58
  }
59
59
 
60
+ /**
61
+ * Read log events newer than a given timestamp.
62
+ * Efficient: reads from end of file to avoid full-file scans.
63
+ * @param {string} brainDir
64
+ * @param {number} phaseNumber
65
+ * @param {string} [afterTimestamp] - ISO timestamp; returns events after this time. If null, returns last 20 events.
66
+ * @returns {Array<object>}
67
+ */
68
+ function tailLog(brainDir, phaseNumber, afterTimestamp) {
69
+ const events = readLog(brainDir, phaseNumber);
70
+ if (!afterTimestamp) {
71
+ return events.slice(-20);
72
+ }
73
+ const afterTime = new Date(afterTimestamp).getTime();
74
+ return events.filter(e => e.timestamp && new Date(e.timestamp).getTime() > afterTime);
75
+ }
76
+
60
77
  /**
61
78
  * Archive a phase log and clean up old archives beyond keepLast.
62
79
  *
@@ -96,5 +113,6 @@ function archiveLogs(brainDir, phaseNumber, keepLast = 3) {
96
113
  module.exports = {
97
114
  logEvent,
98
115
  readLog,
116
+ tailLog,
99
117
  archiveLogs
100
118
  };