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,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
+ };
@@ -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.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
  }