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.
- package/bin/brain-tools.cjs +21 -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/update.cjs +148 -0
- package/bin/lib/commands/verify.cjs +15 -5
- package/bin/lib/commands.cjs +27 -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/init.cjs +1 -1
- 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/commands/brain/update.md +22 -0
- package/hooks/bootstrap.sh +15 -1
- package/package.json +1 -1
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readLock, isLockStale, clearStaleLock } = require('./lock.cjs');
|
|
6
|
+
const { readLog } = require('./logger.cjs');
|
|
7
|
+
const { readState, writeState } = require('./state.cjs');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Analyze the JSONL execution log for a crashed phase.
|
|
11
|
+
* Finds the last successful checkpoint and what was in progress.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
14
|
+
* @param {object} staleLock - The stale lock data (contains phase info)
|
|
15
|
+
* @returns {{ lastEvent: object|null, completedTasks: string[], inProgressTask: string|null, events: object[], phaseNumber: number }}
|
|
16
|
+
*/
|
|
17
|
+
function analyzeLog(brainDir, staleLock) {
|
|
18
|
+
const phaseNumber = staleLock?.phase || 0;
|
|
19
|
+
if (!phaseNumber) {
|
|
20
|
+
return { lastEvent: null, completedTasks: [], inProgressTask: null, events: [], phaseNumber: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const events = readLog(brainDir, phaseNumber);
|
|
24
|
+
if (events.length === 0) {
|
|
25
|
+
return { lastEvent: null, completedTasks: [], inProgressTask: null, events, phaseNumber };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const completedTasks = [];
|
|
29
|
+
let inProgressTask = null;
|
|
30
|
+
let lastEvent = null;
|
|
31
|
+
|
|
32
|
+
for (const event of events) {
|
|
33
|
+
lastEvent = event;
|
|
34
|
+
|
|
35
|
+
// A passed spot-check means that plan was completed successfully
|
|
36
|
+
if (event.type === 'spot-check' && event.passed && event.plan) {
|
|
37
|
+
completedTasks.push(event.plan);
|
|
38
|
+
if (inProgressTask === event.plan) {
|
|
39
|
+
inProgressTask = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A spawn marks a task as in-progress
|
|
44
|
+
if (event.type === 'spawn' && event.agent === 'executor' && event.plan) {
|
|
45
|
+
inProgressTask = event.plan;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A completed event also counts
|
|
49
|
+
if (event.type === 'complete' && event.plan) {
|
|
50
|
+
completedTasks.push(event.plan);
|
|
51
|
+
if (inProgressTask === event.plan) {
|
|
52
|
+
inProgressTask = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { lastEvent, completedTasks, inProgressTask, events, phaseNumber };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Verify consistency between brain.json state and actual disk artifacts.
|
|
62
|
+
* Cross-references phase status with PLAN and SUMMARY files on disk.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
65
|
+
* @param {object} state - Parsed brain.json state
|
|
66
|
+
* @returns {{ consistent: boolean, issues: Array<{ type: string, description: string, fix: string }> }}
|
|
67
|
+
*/
|
|
68
|
+
function verifyStateConsistency(brainDir, state) {
|
|
69
|
+
const issues = [];
|
|
70
|
+
const phase = state.phase || {};
|
|
71
|
+
const phaseNumber = phase.current || 0;
|
|
72
|
+
|
|
73
|
+
if (phaseNumber === 0) {
|
|
74
|
+
return { consistent: true, issues };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check: phases directory exists
|
|
78
|
+
const phasesDir = path.join(brainDir, 'phases');
|
|
79
|
+
if (!fs.existsSync(phasesDir)) {
|
|
80
|
+
if (phase.status === 'executing' || phase.status === 'planned') {
|
|
81
|
+
issues.push({
|
|
82
|
+
type: 'missing-dir',
|
|
83
|
+
description: `State says phase ${phaseNumber} is "${phase.status}" but phases/ directory does not exist`,
|
|
84
|
+
fix: 'Reset phase status to "initialized"'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return { consistent: issues.length === 0, issues };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Find the current phase directory
|
|
91
|
+
const padded = String(phaseNumber).padStart(2, '0');
|
|
92
|
+
const phaseDirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(padded + '-'));
|
|
93
|
+
const phaseDir = phaseDirs.length > 0 ? path.join(phasesDir, phaseDirs[0]) : null;
|
|
94
|
+
|
|
95
|
+
// Check: phase dir exists when status implies it should
|
|
96
|
+
if (!phaseDir && (phase.status === 'executing' || phase.status === 'planned')) {
|
|
97
|
+
issues.push({
|
|
98
|
+
type: 'missing-phase-dir',
|
|
99
|
+
description: `State says phase ${phaseNumber} is "${phase.status}" but no phase directory found`,
|
|
100
|
+
fix: 'Reset phase status to "initialized"'
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (phaseDir) {
|
|
105
|
+
// Check: PLAN files exist when status is "executing"
|
|
106
|
+
if (phase.status === 'executing') {
|
|
107
|
+
const planFiles = fs.existsSync(phaseDir)
|
|
108
|
+
? fs.readdirSync(phaseDir).filter(f => f.startsWith('PLAN'))
|
|
109
|
+
: [];
|
|
110
|
+
if (planFiles.length === 0) {
|
|
111
|
+
issues.push({
|
|
112
|
+
type: 'missing-plans',
|
|
113
|
+
description: `Phase ${phaseNumber} is "executing" but no PLAN files found in ${phaseDirs[0]}`,
|
|
114
|
+
fix: 'Revert status to "planned" to re-generate execution plans'
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check: SUMMARY file when status is "completed"
|
|
120
|
+
if (phase.status === 'completed') {
|
|
121
|
+
const summaryFiles = fs.existsSync(phaseDir)
|
|
122
|
+
? fs.readdirSync(phaseDir).filter(f => f.toUpperCase().startsWith('SUMMARY'))
|
|
123
|
+
: [];
|
|
124
|
+
if (summaryFiles.length === 0) {
|
|
125
|
+
issues.push({
|
|
126
|
+
type: 'missing-summary',
|
|
127
|
+
description: `Phase ${phaseNumber} is "completed" but no SUMMARY file found`,
|
|
128
|
+
fix: 'Revert status to "executing" to regenerate summary'
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check: phase list consistency
|
|
135
|
+
if (Array.isArray(phase.phases) && phaseNumber > phase.phases.length && phase.phases.length > 0) {
|
|
136
|
+
issues.push({
|
|
137
|
+
type: 'phase-overflow',
|
|
138
|
+
description: `Current phase ${phaseNumber} exceeds total phases defined (${phase.phases.length})`,
|
|
139
|
+
fix: 'Set current phase to last valid phase number'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check: status is a known value
|
|
144
|
+
const validStatuses = ['initialized', 'mapped', 'planned', 'executing', 'completed', 'paused', 'failed', 'partial'];
|
|
145
|
+
if (phase.status && !validStatuses.includes(phase.status)) {
|
|
146
|
+
issues.push({
|
|
147
|
+
type: 'invalid-status',
|
|
148
|
+
description: `Phase status "${phase.status}" is not a recognized value`,
|
|
149
|
+
fix: 'Reset to last known valid status'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { consistent: issues.length === 0, issues };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Determine the appropriate recovery mode based on analysis results.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} logAnalysis - Output from analyzeLog()
|
|
160
|
+
* @param {object} consistency - Output from verifyStateConsistency()
|
|
161
|
+
* @param {object} staleLock - The stale lock data
|
|
162
|
+
* @param {object} state - Current brain.json state
|
|
163
|
+
* @returns {{ mode: 'auto-resume'|'manual-review'|'rollback', reason: string, details: object }}
|
|
164
|
+
*/
|
|
165
|
+
function determineRecoveryMode(logAnalysis, consistency, staleLock, state) {
|
|
166
|
+
const autoRecover = state.workflow?.auto_recover === true;
|
|
167
|
+
const hasProgress = logAnalysis.completedTasks.length > 0;
|
|
168
|
+
|
|
169
|
+
// Rollback: no progress at all
|
|
170
|
+
if (!hasProgress && logAnalysis.events.length === 0) {
|
|
171
|
+
return {
|
|
172
|
+
mode: 'rollback',
|
|
173
|
+
reason: 'No execution progress found; nothing to resume',
|
|
174
|
+
details: { completedTasks: 0, events: 0 }
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Manual review: state is inconsistent
|
|
179
|
+
if (!consistency.consistent) {
|
|
180
|
+
return {
|
|
181
|
+
mode: 'manual-review',
|
|
182
|
+
reason: `State inconsistency detected: ${consistency.issues.length} issue(s)`,
|
|
183
|
+
details: { issues: consistency.issues }
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Manual review: auto_recover is disabled
|
|
188
|
+
if (!autoRecover) {
|
|
189
|
+
return {
|
|
190
|
+
mode: 'manual-review',
|
|
191
|
+
reason: 'Auto-recovery is disabled (workflow.auto_recover=false)',
|
|
192
|
+
details: { completedTasks: logAnalysis.completedTasks.length, inProgress: logAnalysis.inProgressTask }
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Auto-resume: consistent state + auto_recover + progress exists
|
|
197
|
+
if (consistency.consistent && autoRecover && hasProgress) {
|
|
198
|
+
return {
|
|
199
|
+
mode: 'auto-resume',
|
|
200
|
+
reason: `State is consistent with ${logAnalysis.completedTasks.length} completed task(s); auto-recovery enabled`,
|
|
201
|
+
details: { completedTasks: logAnalysis.completedTasks, inProgress: logAnalysis.inProgressTask }
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fallback to manual review
|
|
206
|
+
return {
|
|
207
|
+
mode: 'manual-review',
|
|
208
|
+
reason: 'Recovery conditions partially met; manual review recommended',
|
|
209
|
+
details: { autoRecover, consistent: consistency.consistent, hasProgress }
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Generate a human-readable recovery briefing report.
|
|
215
|
+
*
|
|
216
|
+
* @param {object} analysis - { logAnalysis, consistency, recoveryMode, staleLock, state }
|
|
217
|
+
* @returns {string} Markdown-formatted briefing
|
|
218
|
+
*/
|
|
219
|
+
function generateRecoveryBriefing(analysis) {
|
|
220
|
+
const { logAnalysis, consistency, recoveryMode, staleLock } = analysis;
|
|
221
|
+
const lines = [
|
|
222
|
+
'# Crash Recovery Briefing',
|
|
223
|
+
'',
|
|
224
|
+
'## Stale Lock',
|
|
225
|
+
`- PID: ${staleLock?.pid || 'unknown'}`,
|
|
226
|
+
`- Phase: ${staleLock?.phase || 'unknown'}`,
|
|
227
|
+
`- Operation: ${staleLock?.operation || 'unknown'}`,
|
|
228
|
+
`- Acquired: ${staleLock?.acquiredAt || 'unknown'}`,
|
|
229
|
+
`- Last heartbeat: ${staleLock?.heartbeat || 'unknown'}`,
|
|
230
|
+
''
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
lines.push('## Log Analysis');
|
|
234
|
+
lines.push(`- Phase: ${logAnalysis.phaseNumber}`);
|
|
235
|
+
lines.push(`- Total events: ${logAnalysis.events.length}`);
|
|
236
|
+
lines.push(`- Completed tasks: ${logAnalysis.completedTasks.length > 0 ? logAnalysis.completedTasks.join(', ') : 'none'}`);
|
|
237
|
+
lines.push(`- In-progress task: ${logAnalysis.inProgressTask || 'none'}`);
|
|
238
|
+
if (logAnalysis.lastEvent) {
|
|
239
|
+
lines.push(`- Last event: [${logAnalysis.lastEvent.type || '?'}] at ${logAnalysis.lastEvent.timestamp || '?'}`);
|
|
240
|
+
}
|
|
241
|
+
lines.push('');
|
|
242
|
+
|
|
243
|
+
lines.push('## State Consistency');
|
|
244
|
+
if (consistency.consistent) {
|
|
245
|
+
lines.push('- Status: consistent');
|
|
246
|
+
} else {
|
|
247
|
+
lines.push('- Status: INCONSISTENT');
|
|
248
|
+
for (const issue of consistency.issues) {
|
|
249
|
+
lines.push(` - [${issue.type}] ${issue.description}`);
|
|
250
|
+
lines.push(` Fix: ${issue.fix}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
lines.push('');
|
|
254
|
+
|
|
255
|
+
lines.push('## Recovery Recommendation');
|
|
256
|
+
lines.push(`- Mode: ${recoveryMode.mode}`);
|
|
257
|
+
lines.push(`- Reason: ${recoveryMode.reason}`);
|
|
258
|
+
lines.push('');
|
|
259
|
+
|
|
260
|
+
if (recoveryMode.mode === 'auto-resume') {
|
|
261
|
+
lines.push('## Next Action');
|
|
262
|
+
lines.push('Run `brain recover --fix` to auto-resume from last checkpoint.');
|
|
263
|
+
} else if (recoveryMode.mode === 'rollback') {
|
|
264
|
+
lines.push('## Next Action');
|
|
265
|
+
lines.push('Run `brain recover --rollback` to revert to pre-execution state.');
|
|
266
|
+
} else {
|
|
267
|
+
lines.push('## Next Action');
|
|
268
|
+
lines.push('Review the issues above, then run:');
|
|
269
|
+
lines.push('- `brain recover --fix` to attempt auto-repair and resume');
|
|
270
|
+
lines.push('- `brain recover --rollback` to revert to pre-execution state');
|
|
271
|
+
lines.push('- `brain recover --dismiss` to clear the stale lock without recovery');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return lines.join('\n');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Auto-resume: repair state to last checkpoint and prepare for re-execution.
|
|
279
|
+
* Clears the stale lock and updates state to reflect recovered position.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
282
|
+
* @param {object} logAnalysis - Output from analyzeLog()
|
|
283
|
+
* @param {object} state - Current brain.json state
|
|
284
|
+
* @returns {{ repaired: boolean, changes: string[], nextAction: string }}
|
|
285
|
+
*/
|
|
286
|
+
function autoResume(brainDir, logAnalysis, state) {
|
|
287
|
+
const changes = [];
|
|
288
|
+
|
|
289
|
+
// Clear the stale lock
|
|
290
|
+
clearStaleLock(brainDir);
|
|
291
|
+
changes.push('Cleared stale lock');
|
|
292
|
+
|
|
293
|
+
// Update phase status to indicate recovery
|
|
294
|
+
if (state.phase) {
|
|
295
|
+
if (state.phase.status === 'executing' && logAnalysis.inProgressTask) {
|
|
296
|
+
// There was work in progress - set to planned so execution can retry
|
|
297
|
+
state.phase.status = 'planned';
|
|
298
|
+
changes.push(`Reset phase status from "executing" to "planned"`);
|
|
299
|
+
} else if (state.phase.status === 'executing' && !logAnalysis.inProgressTask) {
|
|
300
|
+
// All tasks were completed or none in progress
|
|
301
|
+
state.phase.status = 'planned';
|
|
302
|
+
changes.push(`Reset phase status to "planned" for re-verification`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Record recovery in stuck tracking
|
|
306
|
+
state.phase.stuck_count = (state.phase.stuck_count || 0) + 1;
|
|
307
|
+
state.phase.last_stuck_at = new Date().toISOString();
|
|
308
|
+
changes.push(`Incremented stuck_count to ${state.phase.stuck_count}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Write repaired state
|
|
312
|
+
writeState(brainDir, state);
|
|
313
|
+
changes.push('Wrote repaired state to brain.json and STATE.md');
|
|
314
|
+
|
|
315
|
+
const nextAction = logAnalysis.inProgressTask
|
|
316
|
+
? `Re-execute from task "${logAnalysis.inProgressTask}" (${logAnalysis.completedTasks.length} task(s) already completed)`
|
|
317
|
+
: `Resume execution for phase ${logAnalysis.phaseNumber}`;
|
|
318
|
+
|
|
319
|
+
return { repaired: true, changes, nextAction };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Rollback: revert state to pre-execution and clear partial artifacts.
|
|
324
|
+
* Preserves logs for forensics but resets phase status.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
327
|
+
* @param {object} staleLock - The stale lock data
|
|
328
|
+
* @param {object} state - Current brain.json state
|
|
329
|
+
* @returns {{ rolledBack: boolean, changes: string[] }}
|
|
330
|
+
*/
|
|
331
|
+
function rollback(brainDir, staleLock, state) {
|
|
332
|
+
const changes = [];
|
|
333
|
+
|
|
334
|
+
// Clear the stale lock
|
|
335
|
+
clearStaleLock(brainDir);
|
|
336
|
+
changes.push('Cleared stale lock');
|
|
337
|
+
|
|
338
|
+
// Reset phase status
|
|
339
|
+
if (state.phase) {
|
|
340
|
+
const previousStatus = state.phase.status;
|
|
341
|
+
state.phase.status = 'planned';
|
|
342
|
+
state.phase.execution_started_at = null;
|
|
343
|
+
changes.push(`Reset phase status from "${previousStatus}" to "planned"`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Clear auto mode if it was active
|
|
347
|
+
if (state.auto?.active) {
|
|
348
|
+
state.auto.active = false;
|
|
349
|
+
state.auto.current_step = null;
|
|
350
|
+
changes.push('Deactivated auto mode');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Write rolled-back state
|
|
354
|
+
writeState(brainDir, state);
|
|
355
|
+
changes.push('Wrote rolled-back state to brain.json and STATE.md');
|
|
356
|
+
|
|
357
|
+
return { rolledBack: true, changes };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Master recovery entry point.
|
|
362
|
+
* Detects stale lock, analyzes state, determines recovery mode, and acts.
|
|
363
|
+
*
|
|
364
|
+
* @param {string} brainDir - Path to .brain/ directory
|
|
365
|
+
* @param {object} [opts] - Options
|
|
366
|
+
* @param {boolean} [opts.fix=false] - Attempt auto-resume
|
|
367
|
+
* @param {boolean} [opts.rollback=false] - Force rollback
|
|
368
|
+
* @param {boolean} [opts.dismiss=false] - Clear lock without recovery
|
|
369
|
+
* @returns {{ recovered: boolean, mode: string, briefing: string, nextAction: string|null, result: object|null }}
|
|
370
|
+
*/
|
|
371
|
+
function recover(brainDir, opts = {}) {
|
|
372
|
+
// Step 1: Read current state
|
|
373
|
+
const state = readState(brainDir);
|
|
374
|
+
if (!state) {
|
|
375
|
+
return {
|
|
376
|
+
recovered: false,
|
|
377
|
+
mode: 'none',
|
|
378
|
+
briefing: 'No brain.json found. Nothing to recover.',
|
|
379
|
+
nextAction: null,
|
|
380
|
+
result: null
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Step 2: Check for stale lock
|
|
385
|
+
const lock = readLock(brainDir);
|
|
386
|
+
let staleLock = null;
|
|
387
|
+
|
|
388
|
+
if (lock) {
|
|
389
|
+
const { stale, reason } = isLockStale(lock);
|
|
390
|
+
if (stale) {
|
|
391
|
+
staleLock = { ...lock, staleReason: reason };
|
|
392
|
+
} else {
|
|
393
|
+
return {
|
|
394
|
+
recovered: false,
|
|
395
|
+
mode: 'active',
|
|
396
|
+
briefing: `Lock is active (PID ${lock.pid}). No recovery needed.`,
|
|
397
|
+
nextAction: null,
|
|
398
|
+
result: null
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// No lock at all — check if state looks like it needs recovery
|
|
404
|
+
if (!staleLock) {
|
|
405
|
+
// Check for signs of interrupted execution without a lock
|
|
406
|
+
if (state.phase?.status === 'executing' && !lock) {
|
|
407
|
+
staleLock = { phase: state.phase.current, operation: 'unknown', pid: null, staleReason: 'No lock file but state is "executing"' };
|
|
408
|
+
} else {
|
|
409
|
+
return {
|
|
410
|
+
recovered: false,
|
|
411
|
+
mode: 'clean',
|
|
412
|
+
briefing: 'No stale lock detected. System appears healthy.',
|
|
413
|
+
nextAction: null,
|
|
414
|
+
result: null
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Step 3: Dismiss mode — just clear the lock
|
|
420
|
+
if (opts.dismiss) {
|
|
421
|
+
clearStaleLock(brainDir);
|
|
422
|
+
return {
|
|
423
|
+
recovered: true,
|
|
424
|
+
mode: 'dismiss',
|
|
425
|
+
briefing: 'Stale lock cleared. No state changes made.',
|
|
426
|
+
nextAction: 'Run your next command normally.',
|
|
427
|
+
result: { dismissed: true }
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Step 4: Analyze
|
|
432
|
+
const logAnalysis = analyzeLog(brainDir, staleLock);
|
|
433
|
+
const consistency = verifyStateConsistency(brainDir, state);
|
|
434
|
+
const recoveryMode = determineRecoveryMode(logAnalysis, consistency, staleLock, state);
|
|
435
|
+
|
|
436
|
+
const analysis = { logAnalysis, consistency, recoveryMode, staleLock, state };
|
|
437
|
+
const briefing = generateRecoveryBriefing(analysis);
|
|
438
|
+
|
|
439
|
+
// Step 5: Act based on mode or user request
|
|
440
|
+
if (opts.rollback) {
|
|
441
|
+
const result = rollback(brainDir, staleLock, state);
|
|
442
|
+
return { recovered: true, mode: 'rollback', briefing, nextAction: 'State rolled back. Re-run planning or execution.', result };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (opts.fix) {
|
|
446
|
+
const result = autoResume(brainDir, logAnalysis, state);
|
|
447
|
+
return { recovered: true, mode: 'auto-resume', briefing, nextAction: result.nextAction, result };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Default: check mode — return briefing only
|
|
451
|
+
return {
|
|
452
|
+
recovered: false,
|
|
453
|
+
mode: recoveryMode.mode,
|
|
454
|
+
briefing,
|
|
455
|
+
nextAction: null,
|
|
456
|
+
result: { recommended: recoveryMode.mode, reason: recoveryMode.reason }
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
module.exports = {
|
|
461
|
+
analyzeLog,
|
|
462
|
+
verifyStateConsistency,
|
|
463
|
+
determineRecoveryMode,
|
|
464
|
+
generateRecoveryBriefing,
|
|
465
|
+
autoResume,
|
|
466
|
+
rollback,
|
|
467
|
+
recover
|
|
468
|
+
};
|
package/bin/lib/security.cjs
CHANGED
|
@@ -70,7 +70,7 @@ function scanContent(content, filePath) {
|
|
|
70
70
|
const finding = {
|
|
71
71
|
name: pattern.name,
|
|
72
72
|
pattern: pattern.regex.source,
|
|
73
|
-
match: match[0]
|
|
73
|
+
match: match[0].slice(0, 4) + '***REDACTED***'
|
|
74
74
|
};
|
|
75
75
|
if (filePath) {
|
|
76
76
|
finding.file = filePath;
|
|
@@ -228,10 +228,24 @@ function scanFiles(rootDir, options = {}) {
|
|
|
228
228
|
return { findings: allFindings, blockers, warnings };
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Check if a file path resolves within the allowed root directory.
|
|
233
|
+
* Prevents path traversal attacks via ../../ etc.
|
|
234
|
+
* @param {string} filePath - Relative or absolute path to check
|
|
235
|
+
* @param {string} rootDir - Allowed root directory
|
|
236
|
+
* @returns {boolean} true if path is within root
|
|
237
|
+
*/
|
|
238
|
+
function isPathWithinRoot(filePath, rootDir) {
|
|
239
|
+
const resolved = path.resolve(rootDir, filePath);
|
|
240
|
+
const normalizedRoot = path.resolve(rootDir);
|
|
241
|
+
return resolved.startsWith(normalizedRoot + path.sep) || resolved === normalizedRoot;
|
|
242
|
+
}
|
|
243
|
+
|
|
231
244
|
module.exports = {
|
|
232
245
|
PATTERNS,
|
|
233
246
|
scanContent,
|
|
234
247
|
parseGitignore,
|
|
235
248
|
scanFiles,
|
|
236
|
-
isIgnored
|
|
249
|
+
isIgnored,
|
|
250
|
+
isPathWithinRoot
|
|
237
251
|
};
|
package/bin/lib/state.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
6
|
const CURRENT_SCHEMA = 'brain/v1';
|
|
7
|
-
const CURRENT_VERSION = '0.
|
|
7
|
+
const CURRENT_VERSION = '0.8.0';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Atomic write: write to temp file, then rename.
|
|
@@ -13,12 +13,6 @@ const CURRENT_VERSION = '0.7.0';
|
|
|
13
13
|
* @param {string} content - Content to write
|
|
14
14
|
*/
|
|
15
15
|
function atomicWriteSync(filePath, content) {
|
|
16
|
-
// Guard: if target is a directory, remove it first
|
|
17
|
-
try {
|
|
18
|
-
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
19
|
-
fs.rmSync(filePath, { recursive: true, force: true });
|
|
20
|
-
}
|
|
21
|
-
} catch { /* ignore stat errors */ }
|
|
22
16
|
const dir = path.dirname(filePath);
|
|
23
17
|
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
|
|
24
18
|
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
@@ -160,6 +154,28 @@ function migrateState(data) {
|
|
|
160
154
|
migrated.quick = { count: 0 };
|
|
161
155
|
}
|
|
162
156
|
|
|
157
|
+
// v0.8.0 fields (auto mode, budget, stuck, recovery, dashboard, context)
|
|
158
|
+
if (!migrated.auto) {
|
|
159
|
+
migrated.auto = { active: false, started_at: null, current_step: null, phases_completed: 0, max_phases: null, error_count: 0, max_errors: 3, max_operations: 100, max_duration_minutes: 120, history: [] };
|
|
160
|
+
}
|
|
161
|
+
if (!migrated.budget) {
|
|
162
|
+
migrated.budget = { ceiling_usd: null, warn_at_pct: 80, mode: 'warn' };
|
|
163
|
+
}
|
|
164
|
+
if (!migrated.timeout) {
|
|
165
|
+
migrated.timeout = { soft: 20, idle: 5, hard: 45, max_retries: 1, enabled: true };
|
|
166
|
+
}
|
|
167
|
+
if (!migrated.dashboard) {
|
|
168
|
+
migrated.dashboard = { port: 3457, auto_open: true };
|
|
169
|
+
}
|
|
170
|
+
if (!migrated.context) {
|
|
171
|
+
migrated.context = { preinline: true, budgets: {}, condensed_summaries: true };
|
|
172
|
+
}
|
|
173
|
+
if (migrated.phase && !('execution_started_at' in migrated.phase)) {
|
|
174
|
+
migrated.phase.execution_started_at = null;
|
|
175
|
+
migrated.phase.stuck_count = 0;
|
|
176
|
+
migrated.phase.last_stuck_at = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
return migrated;
|
|
164
180
|
}
|
|
165
181
|
|
|
@@ -184,6 +200,18 @@ function writeState(brainDir, state) {
|
|
|
184
200
|
// Generate and write STATE.md atomically
|
|
185
201
|
const mdPath = path.join(brainDir, 'STATE.md');
|
|
186
202
|
atomicWriteSync(mdPath, generateStateMd(state));
|
|
203
|
+
|
|
204
|
+
// Heartbeat: update lock if one exists for this process
|
|
205
|
+
try {
|
|
206
|
+
const lockPath = path.join(brainDir, '.auto.lock');
|
|
207
|
+
if (fs.existsSync(lockPath)) {
|
|
208
|
+
const lockData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
209
|
+
if (lockData.pid === process.pid) {
|
|
210
|
+
lockData.heartbeat = new Date().toISOString();
|
|
211
|
+
atomicWriteSync(lockPath, JSON.stringify(lockData, null, 2));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch { /* heartbeat failure is non-fatal */ }
|
|
187
215
|
}
|
|
188
216
|
|
|
189
217
|
/**
|
|
@@ -246,6 +274,52 @@ function generateStateMd(state) {
|
|
|
246
274
|
}
|
|
247
275
|
lines.push('');
|
|
248
276
|
|
|
277
|
+
// Auto Mode section (v0.8.0)
|
|
278
|
+
const auto = state.auto;
|
|
279
|
+
if (auto && auto.active) {
|
|
280
|
+
lines.push('## Auto Mode');
|
|
281
|
+
lines.push(`- Active: ${auto.active}`);
|
|
282
|
+
if (auto.started_at) lines.push(`- Started: ${auto.started_at}`);
|
|
283
|
+
if (auto.current_step) lines.push(`- Current step: ${auto.current_step}`);
|
|
284
|
+
lines.push(`- Phases completed: ${auto.phases_completed}${auto.max_phases ? '/' + auto.max_phases : ''}`);
|
|
285
|
+
lines.push(`- Errors: ${auto.error_count}/${auto.max_errors}`);
|
|
286
|
+
lines.push('');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Budget section (v0.8.0)
|
|
290
|
+
const budget = state.budget;
|
|
291
|
+
if (budget && budget.ceiling_usd !== null) {
|
|
292
|
+
lines.push('## Budget');
|
|
293
|
+
lines.push(`- Ceiling: $${budget.ceiling_usd}`);
|
|
294
|
+
lines.push(`- Warn at: ${budget.warn_at_pct}%`);
|
|
295
|
+
lines.push(`- Mode: ${budget.mode}`);
|
|
296
|
+
lines.push('');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Timeout section (v0.8.0)
|
|
300
|
+
const timeout = state.timeout;
|
|
301
|
+
if (timeout && !timeout.enabled) {
|
|
302
|
+
lines.push('## Timeout');
|
|
303
|
+
lines.push('- Enabled: false');
|
|
304
|
+
lines.push('');
|
|
305
|
+
} else if (timeout && (timeout.soft !== 20 || timeout.idle !== 5 || timeout.hard !== 45)) {
|
|
306
|
+
lines.push('## Timeout');
|
|
307
|
+
lines.push(`- Soft: ${timeout.soft}min`);
|
|
308
|
+
lines.push(`- Idle: ${timeout.idle}min`);
|
|
309
|
+
lines.push(`- Hard: ${timeout.hard}min`);
|
|
310
|
+
lines.push(`- Max retries: ${timeout.max_retries}`);
|
|
311
|
+
lines.push('');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Recovery section (v0.8.0)
|
|
315
|
+
if (phase.stuck_count > 0) {
|
|
316
|
+
lines.push('## Recovery');
|
|
317
|
+
lines.push(`- Stuck count: ${phase.stuck_count}`);
|
|
318
|
+
if (phase.last_stuck_at) lines.push(`- Last stuck at: ${phase.last_stuck_at}`);
|
|
319
|
+
if (phase.execution_started_at) lines.push(`- Execution started: ${phase.execution_started_at}`);
|
|
320
|
+
lines.push('');
|
|
321
|
+
}
|
|
322
|
+
|
|
249
323
|
lines.push('## Blockers');
|
|
250
324
|
|
|
251
325
|
if (Array.isArray(blockers) && blockers.length > 0) {
|
|
@@ -280,7 +354,10 @@ function createDefaultState(platform) {
|
|
|
280
354
|
current: 0,
|
|
281
355
|
status: 'initialized',
|
|
282
356
|
total: 0,
|
|
283
|
-
phases: []
|
|
357
|
+
phases: [],
|
|
358
|
+
execution_started_at: null,
|
|
359
|
+
stuck_count: 0,
|
|
360
|
+
last_stuck_at: null
|
|
284
361
|
},
|
|
285
362
|
milestone: {
|
|
286
363
|
current: 'v1.0',
|
|
@@ -331,6 +408,39 @@ function createDefaultState(platform) {
|
|
|
331
408
|
},
|
|
332
409
|
quick: {
|
|
333
410
|
count: 0
|
|
411
|
+
},
|
|
412
|
+
auto: {
|
|
413
|
+
active: false,
|
|
414
|
+
started_at: null,
|
|
415
|
+
current_step: null,
|
|
416
|
+
phases_completed: 0,
|
|
417
|
+
max_phases: null,
|
|
418
|
+
error_count: 0,
|
|
419
|
+
max_errors: 3,
|
|
420
|
+
max_operations: 100,
|
|
421
|
+
max_duration_minutes: 120,
|
|
422
|
+
history: []
|
|
423
|
+
},
|
|
424
|
+
budget: {
|
|
425
|
+
ceiling_usd: null,
|
|
426
|
+
warn_at_pct: 80,
|
|
427
|
+
mode: 'warn'
|
|
428
|
+
},
|
|
429
|
+
timeout: {
|
|
430
|
+
soft: 20,
|
|
431
|
+
idle: 5,
|
|
432
|
+
hard: 45,
|
|
433
|
+
max_retries: 1,
|
|
434
|
+
enabled: true
|
|
435
|
+
},
|
|
436
|
+
dashboard: {
|
|
437
|
+
port: 3457,
|
|
438
|
+
auto_open: true
|
|
439
|
+
},
|
|
440
|
+
context: {
|
|
441
|
+
preinline: true,
|
|
442
|
+
budgets: {},
|
|
443
|
+
condensed_summaries: true
|
|
334
444
|
}
|
|
335
445
|
};
|
|
336
446
|
}
|