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.
- package/bin/brain-tools.cjs +17 -1
- package/bin/lib/bridge.cjs +47 -0
- package/bin/lib/commands/auto.cjs +337 -0
- package/bin/lib/commands/dashboard.cjs +177 -0
- package/bin/lib/commands/execute.cjs +18 -0
- package/bin/lib/commands/progress.cjs +37 -1
- package/bin/lib/commands/recover.cjs +155 -0
- package/bin/lib/commands/verify.cjs +15 -5
- package/bin/lib/commands.cjs +18 -0
- package/bin/lib/config.cjs +23 -2
- package/bin/lib/context.cjs +397 -0
- package/bin/lib/cost.cjs +273 -0
- package/bin/lib/dashboard-collector.cjs +98 -0
- package/bin/lib/dashboard-server.cjs +33 -0
- package/bin/lib/hook-dispatcher.cjs +99 -0
- package/bin/lib/lock.cjs +163 -0
- package/bin/lib/logger.cjs +18 -0
- package/bin/lib/recovery.cjs +468 -0
- package/bin/lib/security.cjs +16 -2
- package/bin/lib/state.cjs +118 -8
- package/bin/lib/stuck.cjs +269 -0
- package/bin/lib/tokens.cjs +32 -0
- package/commands/brain/auto.md +31 -0
- package/commands/brain/dashboard.md +18 -0
- package/commands/brain/recover.md +19 -0
- package/hooks/bootstrap.sh +15 -1
- package/package.json +1 -1
package/bin/brain-tools.cjs
CHANGED
|
@@ -133,7 +133,11 @@ async function main() {
|
|
|
133
133
|
break;
|
|
134
134
|
|
|
135
135
|
case 'execute':
|
|
136
|
-
|
|
136
|
+
if (args.includes('--auto') || args.includes('--dry-run') || args.includes('--stop')) {
|
|
137
|
+
await require('./lib/commands/auto.cjs').run(args.slice(1));
|
|
138
|
+
} else {
|
|
139
|
+
await require('./lib/commands/execute.cjs').run(args.slice(1));
|
|
140
|
+
}
|
|
137
141
|
break;
|
|
138
142
|
|
|
139
143
|
case 'verify':
|
|
@@ -174,6 +178,18 @@ async function main() {
|
|
|
174
178
|
await require('./lib/commands/map.cjs').run(args.slice(1));
|
|
175
179
|
break;
|
|
176
180
|
|
|
181
|
+
case 'recover':
|
|
182
|
+
await require('./lib/commands/recover.cjs').run(args.slice(1));
|
|
183
|
+
break;
|
|
184
|
+
|
|
185
|
+
case 'dashboard':
|
|
186
|
+
await require('./lib/commands/dashboard.cjs').run(args.slice(1));
|
|
187
|
+
break;
|
|
188
|
+
|
|
189
|
+
case 'auto':
|
|
190
|
+
await require('./lib/commands/auto.cjs').run(args.slice(1));
|
|
191
|
+
break;
|
|
192
|
+
|
|
177
193
|
default:
|
|
178
194
|
// Unimplemented command: show full help text
|
|
179
195
|
if (!cmdEntry.implemented) {
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
* Write a bridge file atomically.
|
|
9
|
+
* Bridge files are ephemeral JSON files for inter-process communication.
|
|
10
|
+
* Convention: .brain/.<name>-bridge.json
|
|
11
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
12
|
+
* @param {string} name - Bridge name (e.g., 'context', 'stuck', 'cost')
|
|
13
|
+
* @param {object} data - JSON-serializable data
|
|
14
|
+
*/
|
|
15
|
+
function writeBridge(brainDir, name, data) {
|
|
16
|
+
const filePath = path.join(brainDir, `.${name}-bridge.json`);
|
|
17
|
+
atomicWriteSync(filePath, JSON.stringify(data, null, 2));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read a bridge file. Returns null on any error (missing, corrupt, etc.).
|
|
22
|
+
* @param {string} brainDir
|
|
23
|
+
* @param {string} name
|
|
24
|
+
* @returns {object|null}
|
|
25
|
+
*/
|
|
26
|
+
function readBridge(brainDir, name) {
|
|
27
|
+
try {
|
|
28
|
+
const filePath = path.join(brainDir, `.${name}-bridge.json`);
|
|
29
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Remove a bridge file.
|
|
37
|
+
* @param {string} brainDir
|
|
38
|
+
* @param {string} name
|
|
39
|
+
*/
|
|
40
|
+
function cleanBridge(brainDir, name) {
|
|
41
|
+
try {
|
|
42
|
+
const filePath = path.join(brainDir, `.${name}-bridge.json`);
|
|
43
|
+
fs.unlinkSync(filePath);
|
|
44
|
+
} catch { /* ignore if already missing */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { writeBridge, readBridge, cleanBridge };
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseArgs } = require('node:util');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const { output, error, success, prefix } = require('../core.cjs');
|
|
7
|
+
const { readState, writeState } = require('../state.cjs');
|
|
8
|
+
const { acquireLock, releaseLock } = require('../lock.cjs');
|
|
9
|
+
const { canSpend } = require('../cost.cjs');
|
|
10
|
+
const { checkTimeouts } = require('../stuck.cjs');
|
|
11
|
+
const { logEvent } = require('../logger.cjs');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run the auto command — the autonomous execution engine.
|
|
15
|
+
*
|
|
16
|
+
* Generates a "runbook" — a sequence of steps that tells Claude Code
|
|
17
|
+
* how to chain the agent calls. Auto does NOT directly execute agents.
|
|
18
|
+
* It calls the existing command modules (plan, execute, verify, complete)
|
|
19
|
+
* which generate prompts. Auto chains these prompts into a single
|
|
20
|
+
* compound instruction.
|
|
21
|
+
*
|
|
22
|
+
* Flags:
|
|
23
|
+
* --dry-run Show the runbook without acquiring a lock or modifying state
|
|
24
|
+
* --stop Stop auto mode, release lock, record history
|
|
25
|
+
* --phase N Start from a specific phase number
|
|
26
|
+
* --json Force JSON output
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} args - CLI arguments
|
|
29
|
+
* @param {object} [opts] - Options (brainDir for testing)
|
|
30
|
+
* @returns {object} Structured result
|
|
31
|
+
*/
|
|
32
|
+
async function run(args = [], opts = {}) {
|
|
33
|
+
const { values } = parseArgs({
|
|
34
|
+
args,
|
|
35
|
+
options: {
|
|
36
|
+
'dry-run': { type: 'boolean', default: false },
|
|
37
|
+
stop: { type: 'boolean', default: false },
|
|
38
|
+
phase: { type: 'string' },
|
|
39
|
+
json: { type: 'boolean', default: false }
|
|
40
|
+
},
|
|
41
|
+
strict: false
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
|
|
45
|
+
const state = readState(brainDir);
|
|
46
|
+
|
|
47
|
+
if (!state) {
|
|
48
|
+
error("No brain state found. Run 'brain-dev init' first.");
|
|
49
|
+
return { error: 'no-state' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Handle --stop
|
|
53
|
+
if (values.stop) return handleStop(brainDir, state);
|
|
54
|
+
|
|
55
|
+
// Handle --dry-run
|
|
56
|
+
if (values['dry-run']) return handleDryRun(brainDir, state, values);
|
|
57
|
+
|
|
58
|
+
// Pre-flight checks
|
|
59
|
+
if (!state.project || !state.project.initialized) {
|
|
60
|
+
error("Project not initialized. Run '/brain:new-project' first.");
|
|
61
|
+
return { error: 'not-initialized' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Budget pre-flight: estimate a conservative cost for the auto run
|
|
65
|
+
const budgetCheck = canSpend(brainDir, 0);
|
|
66
|
+
if (!budgetCheck.allowed) {
|
|
67
|
+
error(`Budget exceeded: ${budgetCheck.reason}`);
|
|
68
|
+
return { error: 'budget-exceeded', reason: budgetCheck.reason, budget_status: budgetCheck.budget_status };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Timeout pre-flight: check if already stuck
|
|
72
|
+
const timeoutCheck = checkTimeouts(brainDir, state);
|
|
73
|
+
if (timeoutCheck.tier === 'hard') {
|
|
74
|
+
error(`Hard timeout active: ${timeoutCheck.message}`);
|
|
75
|
+
return { error: 'hard-timeout', message: timeoutCheck.message };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Acquire lock
|
|
79
|
+
const lockResult = acquireLock(brainDir, {
|
|
80
|
+
phase: state.phase.current,
|
|
81
|
+
operation: 'auto',
|
|
82
|
+
agent: 'orchestrator'
|
|
83
|
+
});
|
|
84
|
+
if (!lockResult.acquired) {
|
|
85
|
+
error('Another operation is in progress. Use /brain:recover if this is a stale lock.');
|
|
86
|
+
return { error: 'lock-held', previousLock: lockResult.previousLock };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Update state for auto mode
|
|
90
|
+
state.auto.active = true;
|
|
91
|
+
state.auto.started_at = new Date().toISOString();
|
|
92
|
+
state.auto.current_step = 'starting';
|
|
93
|
+
state.auto.error_count = 0;
|
|
94
|
+
state.auto.phases_completed = 0;
|
|
95
|
+
writeState(brainDir, state);
|
|
96
|
+
|
|
97
|
+
logEvent(brainDir, state.phase.current, {
|
|
98
|
+
type: 'auto_start',
|
|
99
|
+
phase: state.phase.current
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Build runbook
|
|
103
|
+
const runbook = buildRunbook(brainDir, state, values);
|
|
104
|
+
|
|
105
|
+
// Build result
|
|
106
|
+
const result = {
|
|
107
|
+
action: 'auto-run',
|
|
108
|
+
...runbook,
|
|
109
|
+
lockHeld: true,
|
|
110
|
+
instructions: buildInstructions(runbook, brainDir)
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const humanLines = [
|
|
114
|
+
prefix('Auto mode started'),
|
|
115
|
+
prefix(`Phases: ${runbook.startPhase} → ${runbook.endPhase} (${runbook.phases.length} phases, ${runbook.totalSteps} steps)`),
|
|
116
|
+
prefix(`Guardrails: max ${runbook.config.max_errors} errors, ${runbook.config.max_operations} ops, ${runbook.config.max_duration_minutes}m`),
|
|
117
|
+
'',
|
|
118
|
+
result.instructions
|
|
119
|
+
];
|
|
120
|
+
output(result, humanLines.join('\n'));
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the runbook — a structured plan of phases and steps to execute.
|
|
127
|
+
* @param {string} brainDir
|
|
128
|
+
* @param {object} state
|
|
129
|
+
* @param {object} options - Parsed CLI values
|
|
130
|
+
* @returns {object} Runbook with phases, steps, and config
|
|
131
|
+
*/
|
|
132
|
+
function buildRunbook(brainDir, state, options) {
|
|
133
|
+
const startPhase = options.phase ? parseInt(options.phase, 10) : state.phase.current;
|
|
134
|
+
const maxPhases = state.auto.max_phases || state.phase.total;
|
|
135
|
+
const endPhase = Math.min(startPhase + maxPhases - 1, state.phase.total);
|
|
136
|
+
|
|
137
|
+
const phases = [];
|
|
138
|
+
for (let p = startPhase; p <= endPhase; p++) {
|
|
139
|
+
const phaseInfo = Array.isArray(state.phase.phases)
|
|
140
|
+
? state.phase.phases.find(ph => typeof ph === 'object' && ph.number === p)
|
|
141
|
+
: null;
|
|
142
|
+
|
|
143
|
+
if (!phaseInfo) continue;
|
|
144
|
+
if (phaseInfo.status === 'complete') continue;
|
|
145
|
+
|
|
146
|
+
phases.push({
|
|
147
|
+
number: p,
|
|
148
|
+
name: phaseInfo.name || `Phase ${p}`,
|
|
149
|
+
status: phaseInfo.status || 'pending',
|
|
150
|
+
steps: determineSteps(phaseInfo.status || 'pending', state)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
startPhase,
|
|
156
|
+
endPhase,
|
|
157
|
+
phases,
|
|
158
|
+
totalSteps: phases.reduce((sum, p) => sum + p.steps.length, 0),
|
|
159
|
+
config: {
|
|
160
|
+
max_errors: state.auto.max_errors || 3,
|
|
161
|
+
max_operations: state.auto.max_operations || 100,
|
|
162
|
+
max_duration_minutes: state.auto.max_duration_minutes || 120,
|
|
163
|
+
skip_discuss: state.auto.skip_discuss || false
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Determine which steps remain for a phase based on its current status.
|
|
170
|
+
* The full pipeline is: discuss → plan → execute → verify → complete.
|
|
171
|
+
* @param {string} currentStatus - Phase status string
|
|
172
|
+
* @param {object} state - Brain state (for config flags)
|
|
173
|
+
* @returns {string[]} Remaining step names
|
|
174
|
+
*/
|
|
175
|
+
function determineSteps(currentStatus, state) {
|
|
176
|
+
const allSteps = ['discuss', 'plan', 'execute', 'verify', 'complete'];
|
|
177
|
+
const statusToStep = {
|
|
178
|
+
'initialized': 0,
|
|
179
|
+
'pending': 0,
|
|
180
|
+
'discussing': 0,
|
|
181
|
+
'discussed': 1,
|
|
182
|
+
'planning': 1,
|
|
183
|
+
'planned': 2,
|
|
184
|
+
'executing': 2,
|
|
185
|
+
'executed': 3,
|
|
186
|
+
'verifying': 3,
|
|
187
|
+
'verified': 4,
|
|
188
|
+
'complete': 5
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const startIdx = statusToStep[currentStatus] || 0;
|
|
192
|
+
let steps = allSteps.slice(startIdx);
|
|
193
|
+
|
|
194
|
+
// Skip discuss if configured or if auto mode has skip_discuss
|
|
195
|
+
if (state.auto && state.auto.skip_discuss && steps[0] === 'discuss') {
|
|
196
|
+
steps = steps.slice(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return steps;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build human-readable instructions for Claude Code to follow.
|
|
204
|
+
* This is the core output — a markdown runbook that tells the agent
|
|
205
|
+
* exactly which commands to run in sequence.
|
|
206
|
+
* @param {object} runbook - Built runbook
|
|
207
|
+
* @param {string} brainDir - Path to .brain/
|
|
208
|
+
* @returns {string} Markdown instruction text
|
|
209
|
+
*/
|
|
210
|
+
function buildInstructions(runbook, brainDir) {
|
|
211
|
+
const lines = [];
|
|
212
|
+
lines.push(`## Auto Mode — ${runbook.phases.length} phase${runbook.phases.length !== 1 ? 's' : ''}, ${runbook.totalSteps} steps`);
|
|
213
|
+
lines.push(`Guardrails: max ${runbook.config.max_errors} errors, ${runbook.config.max_operations} operations, ${runbook.config.max_duration_minutes}m duration`);
|
|
214
|
+
lines.push('');
|
|
215
|
+
|
|
216
|
+
for (const phase of runbook.phases) {
|
|
217
|
+
lines.push(`### Phase ${phase.number}: ${phase.name}`);
|
|
218
|
+
lines.push(`Current status: ${phase.status}`);
|
|
219
|
+
lines.push('');
|
|
220
|
+
|
|
221
|
+
let stepNum = 1;
|
|
222
|
+
for (const step of phase.steps) {
|
|
223
|
+
switch (step) {
|
|
224
|
+
case 'discuss':
|
|
225
|
+
lines.push(`${stepNum}. Run: \`npx brain-dev discuss --phase ${phase.number}\` → follow the discuss agent prompt`);
|
|
226
|
+
break;
|
|
227
|
+
case 'plan':
|
|
228
|
+
lines.push(`${stepNum}. Run: \`npx brain-dev plan --phase ${phase.number}\` → follow the planner agent prompt, then plan-checker loop`);
|
|
229
|
+
break;
|
|
230
|
+
case 'execute':
|
|
231
|
+
lines.push(`${stepNum}. Run: \`npx brain-dev execute --phase ${phase.number}\` → repeat until all plans executed`);
|
|
232
|
+
break;
|
|
233
|
+
case 'verify':
|
|
234
|
+
lines.push(`${stepNum}. Run: \`npx brain-dev verify --phase ${phase.number}\` → follow the verifier agent prompt`);
|
|
235
|
+
break;
|
|
236
|
+
case 'complete':
|
|
237
|
+
lines.push(`${stepNum}. Run: \`npx brain-dev complete --phase ${phase.number}\` → advance to next phase`);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
stepNum++;
|
|
241
|
+
}
|
|
242
|
+
lines.push('');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
lines.push('### Error Handling');
|
|
246
|
+
lines.push('- If any step fails, retry once. If it fails again, run: `npx brain-dev auto --stop`');
|
|
247
|
+
lines.push('- Budget check: run `npx brain-dev progress --cost` between phases');
|
|
248
|
+
lines.push('- Timeout check: run `npx brain-dev progress --stuck` if a step takes too long');
|
|
249
|
+
lines.push('');
|
|
250
|
+
lines.push('### Completion');
|
|
251
|
+
lines.push('- When all phases complete or auto stops, run: `npx brain-dev auto --stop` to release the lock');
|
|
252
|
+
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Handle --stop: deactivate auto mode, release lock, record history.
|
|
258
|
+
* @param {string} brainDir
|
|
259
|
+
* @param {object} state
|
|
260
|
+
* @returns {object} Result
|
|
261
|
+
*/
|
|
262
|
+
function handleStop(brainDir, state) {
|
|
263
|
+
const wasActive = state.auto.active;
|
|
264
|
+
|
|
265
|
+
state.auto.active = false;
|
|
266
|
+
state.auto.current_step = 'stopped';
|
|
267
|
+
|
|
268
|
+
// Record history entry
|
|
269
|
+
if (state.auto.started_at) {
|
|
270
|
+
if (!Array.isArray(state.auto.history)) {
|
|
271
|
+
state.auto.history = [];
|
|
272
|
+
}
|
|
273
|
+
state.auto.history.push({
|
|
274
|
+
started: state.auto.started_at,
|
|
275
|
+
ended: new Date().toISOString(),
|
|
276
|
+
phases_completed: state.auto.phases_completed || 0,
|
|
277
|
+
result: 'stopped',
|
|
278
|
+
errors: state.auto.error_count || 0
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
writeState(brainDir, state);
|
|
283
|
+
releaseLock(brainDir);
|
|
284
|
+
|
|
285
|
+
logEvent(brainDir, state.phase.current, {
|
|
286
|
+
type: 'auto_stop',
|
|
287
|
+
phases_completed: state.auto.phases_completed || 0
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const result = {
|
|
291
|
+
action: 'auto-stopped',
|
|
292
|
+
phases_completed: state.auto.phases_completed || 0,
|
|
293
|
+
was_active: wasActive
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const msg = wasActive
|
|
297
|
+
? `Auto mode stopped. ${result.phases_completed} phase(s) completed.`
|
|
298
|
+
: 'Auto mode was not active. Lock released.';
|
|
299
|
+
output(result, prefix(msg));
|
|
300
|
+
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Handle --dry-run: build and display the runbook without modifying state.
|
|
306
|
+
* @param {string} brainDir
|
|
307
|
+
* @param {object} state
|
|
308
|
+
* @param {object} options - Parsed CLI values
|
|
309
|
+
* @returns {object} Result
|
|
310
|
+
*/
|
|
311
|
+
function handleDryRun(brainDir, state, options) {
|
|
312
|
+
if (!state.project || !state.project.initialized) {
|
|
313
|
+
error("Project not initialized. Run '/brain:new-project' first.");
|
|
314
|
+
return { error: 'not-initialized' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const runbook = buildRunbook(brainDir, state, options);
|
|
318
|
+
const instructions = buildInstructions(runbook, brainDir);
|
|
319
|
+
|
|
320
|
+
const result = {
|
|
321
|
+
action: 'auto-dry-run',
|
|
322
|
+
...runbook,
|
|
323
|
+
instructions
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const humanLines = [
|
|
327
|
+
prefix('Auto mode dry run (no state changes)'),
|
|
328
|
+
prefix(`Phases: ${runbook.startPhase} → ${runbook.endPhase} (${runbook.phases.length} phases, ${runbook.totalSteps} steps)`),
|
|
329
|
+
'',
|
|
330
|
+
instructions
|
|
331
|
+
];
|
|
332
|
+
output(result, humanLines.join('\n'));
|
|
333
|
+
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = { run };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseArgs } = require('node:util');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const { output, error, success, prefix } = require('../core.cjs');
|
|
7
|
+
const { readState } = require('../state.cjs');
|
|
8
|
+
const { collectState } = require('../dashboard-collector.cjs');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* brain-dev dashboard command.
|
|
12
|
+
* Modes: TUI (default), --browser (deferred), --json, --stop, --status.
|
|
13
|
+
* @param {string[]} args - CLI arguments
|
|
14
|
+
* @param {object} [opts] - Options (brainDir override)
|
|
15
|
+
* @returns {object} Action result
|
|
16
|
+
*/
|
|
17
|
+
async function run(args = [], opts = {}) {
|
|
18
|
+
const { values } = parseArgs({
|
|
19
|
+
args,
|
|
20
|
+
options: {
|
|
21
|
+
browser: { type: 'boolean', default: false },
|
|
22
|
+
stop: { type: 'boolean', default: false },
|
|
23
|
+
status: { type: 'boolean', default: false },
|
|
24
|
+
port: { type: 'string' },
|
|
25
|
+
json: { type: 'boolean', default: false }
|
|
26
|
+
},
|
|
27
|
+
strict: false
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
|
|
31
|
+
const state = readState(brainDir);
|
|
32
|
+
|
|
33
|
+
if (values.stop) return handleStop(brainDir);
|
|
34
|
+
if (values.status) return handleStatus(brainDir);
|
|
35
|
+
|
|
36
|
+
if (values.browser) {
|
|
37
|
+
// Web dashboard — defer to server module
|
|
38
|
+
return { action: 'dashboard-browser', message: 'Web dashboard coming in next iteration. Use TUI mode (default).' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default: TUI mode
|
|
42
|
+
return renderTUI(brainDir, state, values);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Render a text-based dashboard to stdout.
|
|
47
|
+
* @param {string} brainDir
|
|
48
|
+
* @param {object} state
|
|
49
|
+
* @param {object} options - Parsed CLI flags
|
|
50
|
+
* @returns {object} Action result with snapshot data
|
|
51
|
+
*/
|
|
52
|
+
function renderTUI(brainDir, state, options) {
|
|
53
|
+
const snapshot = collectState(brainDir);
|
|
54
|
+
|
|
55
|
+
if (options.json) {
|
|
56
|
+
return output(snapshot);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lines = [];
|
|
60
|
+
const { styleText } = require('node:util');
|
|
61
|
+
|
|
62
|
+
lines.push(prefix('') + styleText('bold', ' Dashboard') + ' ' + new Date().toLocaleString());
|
|
63
|
+
lines.push('\u2500'.repeat(72));
|
|
64
|
+
|
|
65
|
+
// Project info
|
|
66
|
+
lines.push(`Project: ${styleText('bold', snapshot.project.name)} (${snapshot.project.mode}, ${snapshot.project.depth})` +
|
|
67
|
+
(snapshot.milestone?.current ? ` Milestone: ${snapshot.milestone.current}` : ''));
|
|
68
|
+
lines.push('');
|
|
69
|
+
|
|
70
|
+
// Phase progress
|
|
71
|
+
lines.push(`Phase Progress [${snapshot.phases.current}/${snapshot.phases.total}]`);
|
|
72
|
+
for (const phase of snapshot.phases.list) {
|
|
73
|
+
const isCurrent = phase.number === snapshot.phases.current;
|
|
74
|
+
const bar = renderProgressBar(phase.status);
|
|
75
|
+
const marker = isCurrent ? ' <--' : '';
|
|
76
|
+
const statusColor = phase.status === 'complete' ? 'green' : (isCurrent ? 'yellow' : 'dim');
|
|
77
|
+
lines.push(` ${String(phase.number).padStart(2, ' ')}. ${phase.name.padEnd(16)} ${bar} ${styleText(statusColor, phase.status)}${marker}`);
|
|
78
|
+
}
|
|
79
|
+
lines.push('');
|
|
80
|
+
|
|
81
|
+
// Timing
|
|
82
|
+
if (snapshot.timing.phaseElapsed > 0) {
|
|
83
|
+
const elapsed = formatDuration(snapshot.timing.phaseElapsed);
|
|
84
|
+
lines.push(`Timing: ${elapsed} elapsed`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Context + Cost
|
|
88
|
+
const contextStr = snapshot.context.remainingPct !== null
|
|
89
|
+
? `Context: ${snapshot.context.remainingPct}% (${snapshot.context.level})`
|
|
90
|
+
: 'Context: unknown';
|
|
91
|
+
let costStr = '';
|
|
92
|
+
if (snapshot.cost.spent > 0) {
|
|
93
|
+
costStr = ` | Cost: $${snapshot.cost.spent.toFixed(2)}`;
|
|
94
|
+
if (snapshot.cost.ceiling) costStr += `/$${snapshot.cost.ceiling}`;
|
|
95
|
+
costStr += ` (${snapshot.cost.status})`;
|
|
96
|
+
}
|
|
97
|
+
lines.push(contextStr + costStr);
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
// Recent events
|
|
101
|
+
if (snapshot.events.length > 0) {
|
|
102
|
+
lines.push('Recent Events:');
|
|
103
|
+
const recent = snapshot.events.slice(-5);
|
|
104
|
+
for (const evt of recent) {
|
|
105
|
+
const time = evt.timestamp ? new Date(evt.timestamp).toLocaleTimeString().slice(0, 5) : '??:??';
|
|
106
|
+
lines.push(` ${time} ${(evt.type || '?').padEnd(12)} ${evt.plan ? 'plan-' + evt.plan : ''} ${evt.agent || ''}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push('');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Health / Stuck / Lock
|
|
112
|
+
const healthStr = snapshot.health.stateValid ? 'OK' : 'ERROR';
|
|
113
|
+
const stuckStr = snapshot.stuck.detected ? styleText('red', `YES (${snapshot.stuck.tier})`) : 'No';
|
|
114
|
+
const lockStr = snapshot.lock.active ? (snapshot.lock.stale ? styleText('red', 'stale') : 'active (auto)') : 'none';
|
|
115
|
+
lines.push(`Health: ${healthStr} | Stuck: ${stuckStr} | Lock: ${lockStr}`);
|
|
116
|
+
|
|
117
|
+
const text = lines.join('\n');
|
|
118
|
+
console.log(text);
|
|
119
|
+
return { action: 'dashboard-tui', data: snapshot };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render an ASCII progress bar based on phase status.
|
|
124
|
+
* @param {string} status
|
|
125
|
+
* @returns {string} 22-char progress bar like [==== ]
|
|
126
|
+
*/
|
|
127
|
+
function renderProgressBar(status) {
|
|
128
|
+
const pct = status === 'complete' ? 100 :
|
|
129
|
+
status === 'verified' ? 90 :
|
|
130
|
+
status === 'executing' ? 55 :
|
|
131
|
+
status === 'planning' ? 25 :
|
|
132
|
+
status === 'discussing' ? 10 : 0;
|
|
133
|
+
const filled = Math.round(pct / 5);
|
|
134
|
+
const empty = 20 - filled;
|
|
135
|
+
return '[' + '='.repeat(filled) + ' '.repeat(empty) + ']';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format seconds into human-readable "Xm Ys" string.
|
|
140
|
+
* @param {number} seconds
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function formatDuration(seconds) {
|
|
144
|
+
const m = Math.floor(seconds / 60);
|
|
145
|
+
const s = seconds % 60;
|
|
146
|
+
return `${m}m ${s}s`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Handle --stop: remove dashboard server port file.
|
|
151
|
+
* @param {string} brainDir
|
|
152
|
+
* @returns {object}
|
|
153
|
+
*/
|
|
154
|
+
function handleStop(brainDir) {
|
|
155
|
+
const portFile = path.join(brainDir, '.dashboard-port');
|
|
156
|
+
if (!fs.existsSync(portFile)) {
|
|
157
|
+
return { action: 'dashboard-not-running', message: 'No dashboard server running.' };
|
|
158
|
+
}
|
|
159
|
+
try { fs.unlinkSync(portFile); } catch {}
|
|
160
|
+
return { action: 'dashboard-stopped' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Handle --status: check if a dashboard server is running.
|
|
165
|
+
* @param {string} brainDir
|
|
166
|
+
* @returns {object}
|
|
167
|
+
*/
|
|
168
|
+
function handleStatus(brainDir) {
|
|
169
|
+
const portFile = path.join(brainDir, '.dashboard-port');
|
|
170
|
+
if (fs.existsSync(portFile)) {
|
|
171
|
+
const port = fs.readFileSync(portFile, 'utf8').trim();
|
|
172
|
+
return { action: 'dashboard-status', running: true, url: `http://localhost:${port}` };
|
|
173
|
+
}
|
|
174
|
+
return { action: 'dashboard-status', running: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { run };
|
|
@@ -7,6 +7,8 @@ const { loadTemplate, interpolate } = require('../templates.cjs');
|
|
|
7
7
|
const { getAgent, resolveModel } = require('../agents.cjs');
|
|
8
8
|
const { logEvent } = require('../logger.cjs');
|
|
9
9
|
const { output, error } = require('../core.cjs');
|
|
10
|
+
const { recordInvocation, estimateTokens } = require('../cost.cjs');
|
|
11
|
+
const { acquireLock, releaseLock } = require('../lock.cjs');
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Find a phase directory under .brain/phases/ matching a phase number.
|
|
@@ -366,8 +368,24 @@ async function run(args = [], opts = {}) {
|
|
|
366
368
|
`> ${debuggerSpawnInstruction}`
|
|
367
369
|
].join('\n');
|
|
368
370
|
|
|
371
|
+
// Record cost estimate
|
|
372
|
+
try {
|
|
373
|
+
const inputTokens = estimateTokens(fullPrompt || prompt || '');
|
|
374
|
+
recordInvocation(brainDir, {
|
|
375
|
+
agent: 'executor',
|
|
376
|
+
phase: phaseNumber,
|
|
377
|
+
plan: padded,
|
|
378
|
+
model: model || 'inherit',
|
|
379
|
+
input_tokens: inputTokens,
|
|
380
|
+
output_tokens: 0 // Will be updated when SUMMARY is written
|
|
381
|
+
});
|
|
382
|
+
} catch { /* cost tracking failure is non-fatal */ }
|
|
383
|
+
|
|
369
384
|
// Update state to executing
|
|
370
385
|
state.phase.status = 'executing';
|
|
386
|
+
if (!state.phase.execution_started_at) {
|
|
387
|
+
state.phase.execution_started_at = new Date().toISOString();
|
|
388
|
+
}
|
|
371
389
|
writeState(brainDir, state);
|
|
372
390
|
|
|
373
391
|
// Check auto-recover config
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('node:path');
|
|
4
|
+
const { parseArgs } = require('node:util');
|
|
4
5
|
const { readState } = require('../state.cjs');
|
|
5
6
|
const { output, prefix, error } = require('../core.cjs');
|
|
6
7
|
|
|
@@ -58,6 +59,17 @@ function nextAction(state) {
|
|
|
58
59
|
* @returns {object} Structured result for piping/testing
|
|
59
60
|
*/
|
|
60
61
|
async function run(args = [], opts = {}) {
|
|
62
|
+
const { values } = parseArgs({
|
|
63
|
+
args,
|
|
64
|
+
options: {
|
|
65
|
+
verbose: { type: 'boolean', default: false },
|
|
66
|
+
cost: { type: 'boolean', default: false },
|
|
67
|
+
breakdown: { type: 'string' },
|
|
68
|
+
json: { type: 'boolean', default: false }
|
|
69
|
+
},
|
|
70
|
+
strict: false
|
|
71
|
+
});
|
|
72
|
+
|
|
61
73
|
const brainDir = opts.brainDir || path.join(process.cwd(), '.brain');
|
|
62
74
|
const state = readState(brainDir);
|
|
63
75
|
|
|
@@ -66,7 +78,7 @@ async function run(args = [], opts = {}) {
|
|
|
66
78
|
return { error: 'no-state' };
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
const verbose = args.includes('--verbose');
|
|
81
|
+
const verbose = values.verbose || args.includes('--verbose');
|
|
70
82
|
const phase = state.phase || {};
|
|
71
83
|
const phases = phase.phases || [];
|
|
72
84
|
const session = state.session || {};
|
|
@@ -160,6 +172,30 @@ async function run(args = [], opts = {}) {
|
|
|
160
172
|
contextWarning
|
|
161
173
|
};
|
|
162
174
|
|
|
175
|
+
// Cost display (--cost flag)
|
|
176
|
+
if (values.cost) {
|
|
177
|
+
try {
|
|
178
|
+
const { readMetrics, checkBudget, getAgentBreakdown, getPhaseBreakdown } = require('../cost.cjs');
|
|
179
|
+
const metrics = readMetrics(brainDir);
|
|
180
|
+
const budgetStatus = checkBudget(metrics);
|
|
181
|
+
result.cost = {
|
|
182
|
+
spent: metrics.totals?.estimated_cost_usd || 0,
|
|
183
|
+
ceiling: state.budget?.ceiling_usd || null,
|
|
184
|
+
budget_status: budgetStatus.status,
|
|
185
|
+
invocations: metrics.totals?.agent_invocations || 0
|
|
186
|
+
};
|
|
187
|
+
if (values.breakdown === 'agent') {
|
|
188
|
+
result.cost.breakdown = getAgentBreakdown(metrics);
|
|
189
|
+
} else if (values.breakdown === 'phase') {
|
|
190
|
+
result.cost.breakdown = getPhaseBreakdown(metrics);
|
|
191
|
+
}
|
|
192
|
+
let costLine = prefix(`Cost: $${result.cost.spent.toFixed(2)}`);
|
|
193
|
+
if (result.cost.ceiling) costLine += ` / $${result.cost.ceiling} (${budgetStatus.status})`;
|
|
194
|
+
costLine += ` | ${result.cost.invocations} invocations`;
|
|
195
|
+
lines.push(costLine);
|
|
196
|
+
} catch { /* cost not available */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
output(result, lines.join('\n'));
|
|
164
200
|
return result;
|
|
165
201
|
}
|