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,269 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { readLog, tailLog } = require('./logger.cjs');
6
+ const { writeBridge, readBridge } = require('./bridge.cjs');
7
+
8
+ /**
9
+ * Detect progress by checking multiple signals.
10
+ * @param {string} brainDir
11
+ * @param {number} phaseNumber
12
+ * @returns {{ lastActivity: string|null, idleSeconds: number, signals: object, isIdle: boolean }}
13
+ */
14
+ function detectProgress(brainDir, phaseNumber) {
15
+ const now = Date.now();
16
+ let lastActivity = null;
17
+ const signals = { events: null, files: null, commits: null };
18
+
19
+ // Signal 1: Last JSONL event timestamp
20
+ const lastEventTs = getLastEventTimestamp(brainDir, phaseNumber);
21
+ if (lastEventTs) {
22
+ signals.events = lastEventTs;
23
+ if (!lastActivity || new Date(lastEventTs) > new Date(lastActivity)) {
24
+ lastActivity = lastEventTs;
25
+ }
26
+ }
27
+
28
+ // Signal 2: Phase directory file modification times
29
+ const phaseDir = findPhaseDir(brainDir, phaseNumber);
30
+ if (phaseDir) {
31
+ try {
32
+ const files = fs.readdirSync(phaseDir);
33
+ for (const f of files) {
34
+ const stat = fs.statSync(path.join(phaseDir, f));
35
+ const mtime = stat.mtime.toISOString();
36
+ if (!signals.files || new Date(mtime) > new Date(signals.files)) {
37
+ signals.files = mtime;
38
+ }
39
+ if (!lastActivity || new Date(mtime) > new Date(lastActivity)) {
40
+ lastActivity = mtime;
41
+ }
42
+ }
43
+ } catch { /* phase dir may not exist */ }
44
+ }
45
+
46
+ const idleSeconds = lastActivity ? Math.round((now - new Date(lastActivity).getTime()) / 1000) : Infinity;
47
+ const isIdle = idleSeconds > 300; // default 5 minutes
48
+
49
+ return { lastActivity, idleSeconds, signals, isIdle };
50
+ }
51
+
52
+ /**
53
+ * Check timeout tiers based on elapsed time and configuration.
54
+ * @param {string} brainDir
55
+ * @param {object} state - brain.json state
56
+ * @returns {{ tier: 'none'|'soft'|'idle'|'hard', elapsedMinutes: number, idleMinutes: number, message: string|null }}
57
+ */
58
+ function checkTimeouts(brainDir, state) {
59
+ const timeoutConfig = state.timeout || { soft: 20, idle: 5, hard: 45, enabled: true };
60
+
61
+ if (!timeoutConfig.enabled) {
62
+ return { tier: 'none', elapsedMinutes: 0, idleMinutes: 0, message: null };
63
+ }
64
+
65
+ const startedAt = state.phase?.execution_started_at;
66
+ if (!startedAt) {
67
+ return { tier: 'none', elapsedMinutes: 0, idleMinutes: 0, message: null };
68
+ }
69
+
70
+ const elapsedMs = Date.now() - new Date(startedAt).getTime();
71
+ const elapsedMinutes = Math.round(elapsedMs / 60000);
72
+
73
+ // Check progress for idle detection
74
+ const progress = detectProgress(brainDir, state.phase?.current || 1);
75
+ const idleMinutes = Math.round(progress.idleSeconds / 60);
76
+
77
+ // Hard timeout takes precedence
78
+ if (elapsedMinutes >= timeoutConfig.hard) {
79
+ return {
80
+ tier: 'hard',
81
+ elapsedMinutes,
82
+ idleMinutes,
83
+ message: `TIME LIMIT REACHED (${elapsedMinutes}m). Execute recovery protocol: commit all WIP, write SUMMARY with status "partial", output ## EXECUTION PARTIAL`
84
+ };
85
+ }
86
+
87
+ // Idle timeout
88
+ if (idleMinutes >= timeoutConfig.idle) {
89
+ return {
90
+ tier: 'idle',
91
+ elapsedMinutes,
92
+ idleMinutes,
93
+ message: `No progress detected for ${idleMinutes}m. Agent may be stuck. Check output or extend timeout: brain-dev config set timeout.idle ${timeoutConfig.idle * 2}`
94
+ };
95
+ }
96
+
97
+ // Soft timeout
98
+ if (elapsedMinutes >= timeoutConfig.soft) {
99
+ return {
100
+ tier: 'soft',
101
+ elapsedMinutes,
102
+ idleMinutes,
103
+ message: `Agent running for ${elapsedMinutes}m (soft timeout: ${timeoutConfig.soft}m). Consider wrapping up current task.`
104
+ };
105
+ }
106
+
107
+ return { tier: 'none', elapsedMinutes, idleMinutes, message: null };
108
+ }
109
+
110
+ /**
111
+ * Check for execution non-convergence.
112
+ * Detects: same plan attempted 3+ times, repeated spawns without spot-check pass.
113
+ * @param {string} brainDir
114
+ * @param {number} phaseNumber
115
+ * @returns {{ converging: boolean, reason: string|null, repeatCount: number }}
116
+ */
117
+ function checkExecutionConvergence(brainDir, phaseNumber) {
118
+ const events = readLog(brainDir, phaseNumber);
119
+
120
+ // Count spawn events per plan
121
+ const spawnsByPlan = {};
122
+ const passedPlans = new Set();
123
+
124
+ for (const event of events) {
125
+ if (event.type === 'spawn' && event.agent === 'executor' && event.plan) {
126
+ spawnsByPlan[event.plan] = (spawnsByPlan[event.plan] || 0) + 1;
127
+ }
128
+ if (event.type === 'spot-check' && event.passed && event.plan) {
129
+ passedPlans.add(event.plan);
130
+ }
131
+ }
132
+
133
+ // Check for repeated attempts on the same plan that haven't passed
134
+ for (const [plan, count] of Object.entries(spawnsByPlan)) {
135
+ if (count >= 3 && !passedPlans.has(plan)) {
136
+ return {
137
+ converging: false,
138
+ reason: `Plan ${plan} attempted ${count} times without passing spot-check`,
139
+ repeatCount: count
140
+ };
141
+ }
142
+ }
143
+
144
+ return { converging: true, reason: null, repeatCount: 0 };
145
+ }
146
+
147
+ /**
148
+ * Capture diagnostic information when stuck is detected.
149
+ * Writes to .brain/debug/stuck-{phase}-{timestamp}.md
150
+ * @param {string} brainDir
151
+ * @param {number} phaseNumber
152
+ * @param {object} stuckInfo - { tier, elapsedMinutes, idleMinutes, message }
153
+ * @returns {string} Path to diagnostic file
154
+ */
155
+ function captureDiagnostics(brainDir, phaseNumber, stuckInfo) {
156
+ const debugDir = path.join(brainDir, 'debug');
157
+ try { fs.mkdirSync(debugDir, { recursive: true }); } catch {}
158
+
159
+ const timestamp = new Date().toISOString().replace(/:/g, '-').slice(0, 19);
160
+ const filename = `stuck-${String(phaseNumber).padStart(2, '0')}-${timestamp}.md`;
161
+ const filePath = path.join(debugDir, filename);
162
+
163
+ const events = tailLog(brainDir, phaseNumber, null);
164
+ const convergence = checkExecutionConvergence(brainDir, phaseNumber);
165
+
166
+ const content = `---
167
+ phase: ${phaseNumber}
168
+ triggered_at: ${new Date().toISOString()}
169
+ elapsed_minutes: ${stuckInfo.elapsedMinutes}
170
+ idle_minutes: ${stuckInfo.idleMinutes}
171
+ timeout_tier: ${stuckInfo.tier}
172
+ ---
173
+
174
+ # Stuck Diagnostic
175
+
176
+ ## Timeout Info
177
+ - Tier: ${stuckInfo.tier}
178
+ - Elapsed: ${stuckInfo.elapsedMinutes} minutes
179
+ - Idle: ${stuckInfo.idleMinutes} minutes
180
+ - Message: ${stuckInfo.message || 'none'}
181
+
182
+ ## Convergence
183
+ - Converging: ${convergence.converging}
184
+ ${convergence.reason ? `- Issue: ${convergence.reason}` : ''}
185
+
186
+ ## Last ${events.length} Events
187
+ ${events.map(e => `- ${e.timestamp || '?'} [${e.type || '?'}] ${JSON.stringify(e)}`).join('\n')}
188
+
189
+ ## Recommendation
190
+ ${stuckInfo.tier === 'hard' ? 'Agent should commit partial work and output EXECUTION PARTIAL.' : ''}
191
+ ${stuckInfo.tier === 'idle' ? 'Check if agent is waiting for user input or stuck on an error.' : ''}
192
+ ${!convergence.converging ? `Non-convergent: ${convergence.reason}. Consider manual intervention.` : ''}
193
+ `;
194
+
195
+ fs.writeFileSync(filePath, content);
196
+ return filePath;
197
+ }
198
+
199
+ /**
200
+ * Build wrap-up instructions for injection via PostToolUse hook.
201
+ * @param {number} phase
202
+ * @param {string|null} plan
203
+ * @returns {string}
204
+ */
205
+ function buildWrapUpInstructions(phase, plan) {
206
+ const planId = plan ? `P${phase}-${plan}` : `P${phase}`;
207
+ return `[brain] TIME LIMIT REACHED. Execute the following recovery protocol:
208
+ 1. Stop current task implementation
209
+ 2. Commit all work in progress with message "wip(${planId}): partial - stuck timeout"
210
+ 3. Write SUMMARY.md with status "partial" and list completed vs remaining tasks
211
+ 4. Output ## EXECUTION PARTIAL with the same structure as EXECUTION FAILED`;
212
+ }
213
+
214
+ /**
215
+ * Get the timestamp of the most recent JSONL event.
216
+ * @param {string} brainDir
217
+ * @param {number} phaseNumber
218
+ * @returns {string|null} ISO timestamp or null
219
+ */
220
+ function getLastEventTimestamp(brainDir, phaseNumber) {
221
+ const events = readLog(brainDir, phaseNumber);
222
+ if (events.length === 0) return null;
223
+ const last = events[events.length - 1];
224
+ return last.timestamp || null;
225
+ }
226
+
227
+ /**
228
+ * Find the phase directory for a given phase number.
229
+ * @param {string} brainDir
230
+ * @param {number} phaseNumber
231
+ * @returns {string|null}
232
+ */
233
+ function findPhaseDir(brainDir, phaseNumber) {
234
+ const phasesDir = path.join(brainDir, 'phases');
235
+ if (!fs.existsSync(phasesDir)) return null;
236
+ const padded = String(phaseNumber).padStart(2, '0');
237
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(padded + '-'));
238
+ return dirs.length > 0 ? path.join(phasesDir, dirs[0]) : null;
239
+ }
240
+
241
+ /**
242
+ * Update the stuck bridge file with current stuck detection state.
243
+ * Called from hook-dispatcher during StatusLine.
244
+ * @param {string} brainDir
245
+ * @param {object} state
246
+ */
247
+ function updateStuckBridge(brainDir, state) {
248
+ const timeoutResult = checkTimeouts(brainDir, state);
249
+ writeBridge(brainDir, 'stuck', {
250
+ phase_started_at: state.phase?.execution_started_at || null,
251
+ last_activity_at: detectProgress(brainDir, state.phase?.current || 1).lastActivity,
252
+ elapsed_minutes: timeoutResult.elapsedMinutes,
253
+ idle_minutes: timeoutResult.idleMinutes,
254
+ timeout_tier: timeoutResult.tier,
255
+ message: timeoutResult.message,
256
+ wrap_up: timeoutResult.tier === 'hard'
257
+ });
258
+ }
259
+
260
+ module.exports = {
261
+ detectProgress,
262
+ checkTimeouts,
263
+ checkExecutionConvergence,
264
+ captureDiagnostics,
265
+ buildWrapUpInstructions,
266
+ getLastEventTimestamp,
267
+ findPhaseDir,
268
+ updateStuckBridge
269
+ };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+
5
+ const CHARS_PER_TOKEN = 4;
6
+
7
+ /**
8
+ * Estimate token count from text content.
9
+ * Uses chars/4 heuristic (~15-20% accuracy for English + code).
10
+ * @param {string} text
11
+ * @returns {number}
12
+ */
13
+ function estimateTokens(text) {
14
+ if (!text) return 0;
15
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
16
+ }
17
+
18
+ /**
19
+ * Estimate token count from a file on disk.
20
+ * @param {string} filePath - Absolute path to file
21
+ * @returns {number} Estimated tokens, 0 if file missing
22
+ */
23
+ function estimateFileTokens(filePath) {
24
+ try {
25
+ const content = fs.readFileSync(filePath, 'utf8');
26
+ return estimateTokens(content);
27
+ } catch {
28
+ return 0;
29
+ }
30
+ }
31
+
32
+ module.exports = { estimateTokens, estimateFileTokens, CHARS_PER_TOKEN };
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: brain:auto
3
+ description: Run autonomous phase execution (discuss -> plan -> execute -> verify -> complete loop)
4
+ allowed-tools:
5
+ - Read
6
+ - Write
7
+ - Edit
8
+ - Bash
9
+ - Grep
10
+ - Glob
11
+ - Agent
12
+ - AskUserQuestion
13
+ ---
14
+ <objective>
15
+ Execute multiple phases automatically without human intervention.
16
+ Chains: discuss -> plan -> execute -> verify -> complete -> next phase.
17
+ </objective>
18
+
19
+ <context>
20
+ Auto mode generates a runbook of steps. Follow each step's instructions sequentially.
21
+ Each step tells you which agent to spawn and what prompt to use.
22
+ </context>
23
+
24
+ <process>
25
+ 1. Run: `npx brain-dev execute --auto` (or `--dry-run` to preview)
26
+ 2. Follow the generated runbook instructions step by step
27
+ 3. Each step outputs the exact `npx brain-dev` command to run next
28
+ 4. On error: retry once, then run `npx brain-dev execute --auto --stop`
29
+ 5. Check progress: `npx brain-dev progress --cost`
30
+ 6. When done or stopped: `npx brain-dev execute --auto --stop` to release the lock
31
+ </process>
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: brain:dashboard
3
+ description: Live monitoring dashboard showing phase progress, costs, and stuck detection
4
+ allowed-tools:
5
+ - Read
6
+ - Bash
7
+ ---
8
+ <objective>
9
+ Display a live dashboard with phase progress, cost tracking, stuck detection, and recent events.
10
+ Default mode shows a terminal (TUI) dashboard. Use --browser for web view.
11
+ </objective>
12
+
13
+ <process>
14
+ 1. Run: `npx brain-dev dashboard`
15
+ 2. Review the dashboard output
16
+ 3. Use `npx brain-dev dashboard --browser` for the web dashboard
17
+ 4. Use `npx brain-dev dashboard --stop` to stop a running web server
18
+ </process>
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: brain:recover
3
+ description: Detect and recover from crashes or interrupted sessions
4
+ allowed-tools:
5
+ - Read
6
+ - Write
7
+ - Bash
8
+ - AskUserQuestion
9
+ ---
10
+ <objective>
11
+ Check for interrupted sessions and recover safely.
12
+ Default mode shows what happened. Use --fix to auto-resume or --rollback to revert.
13
+ </objective>
14
+
15
+ <process>
16
+ 1. Run: `npx brain-dev recover`
17
+ 2. Review the recovery briefing
18
+ 3. Choose action: `npx brain-dev recover --fix` or `npx brain-dev recover --rollback` or `npx brain-dev recover --dismiss`
19
+ </process>
@@ -13,6 +13,20 @@ if [ ! -f "$BRAIN_DIR/brain.json" ]; then
13
13
  exit 0
14
14
  fi
15
15
 
16
+ # Crash recovery check
17
+ RECOVERY=$(npx brain-dev recover --json 2>/dev/null)
18
+ if [ $? -eq 0 ] && echo "$RECOVERY" | node -e "
19
+ try {
20
+ let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{
21
+ const r=JSON.parse(d);
22
+ if(r.action==='recovery-detected') { console.log('[brain] WARNING: Previous session ended unexpectedly. Run /brain:recover to check state.'); process.exit(0); }
23
+ process.exit(1);
24
+ });
25
+ } catch { process.exit(1); }
26
+ " 2>/dev/null; then
27
+ true # Warning was printed by the inline script
28
+ fi
29
+
16
30
  # Get status and format for context injection
17
31
  # Use npx to ensure brain-dev is found regardless of install method
18
32
  STATUS=$(npx brain-dev status --json 2>/dev/null)
@@ -34,7 +48,7 @@ try {
34
48
  'Phase: ' + (data.phase && data.phase.current || 0) + ' (' + (data.phase && data.phase.status || 'initialized') + ')',
35
49
  'Next: ' + (data.nextAction || '/brain:new-project'),
36
50
  '',
37
- 'Commands: /brain:new-project, /brain:discuss, /brain:plan, /brain:execute, /brain:verify, /brain:complete, /brain:quick, /brain:progress, /brain:pause, /brain:resume, /brain:help, /brain:health, /brain:update, /brain:storm, /brain:adr, /brain:phase, /brain:config, /brain:map',
51
+ 'Commands: /brain:new-project, /brain:discuss, /brain:plan, /brain:execute, /brain:verify, /brain:complete, /brain:quick, /brain:progress, /brain:pause, /brain:resume, /brain:help, /brain:health, /brain:update, /brain:storm, /brain:adr, /brain:phase, /brain:config, /brain:map, /brain:recover, /brain:dashboard, /brain:auto (or execute --auto)',
38
52
  '',
39
53
  'Instructions for Claude:',
40
54
  '- When user types /brain:<command>, run: npx brain-dev <command> [args]',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brain-dev",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "AI-powered development workflow orchestrator",
5
5
  "author": "halilcosdu",
6
6
  "license": "MIT",