sinapse-ai 1.6.0 → 1.7.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.
Files changed (50) hide show
  1. package/.claude/rules/documentation-first.md +1 -1
  2. package/.sinapse-ai/core/config/merge-utils.js +8 -0
  3. package/.sinapse-ai/core/errors/constants.js +147 -0
  4. package/.sinapse-ai/core/errors/error-registry.js +176 -0
  5. package/.sinapse-ai/core/errors/index.js +50 -0
  6. package/.sinapse-ai/core/errors/serializer.js +147 -0
  7. package/.sinapse-ai/core/errors/sinapse-error.js +144 -0
  8. package/.sinapse-ai/core/errors/utils.js +187 -0
  9. package/.sinapse-ai/core/execution/build-orchestrator.js +43 -48
  10. package/.sinapse-ai/core/execution/build-state-manager.js +183 -31
  11. package/.sinapse-ai/core/execution/semantic-merge-engine.js +26 -14
  12. package/.sinapse-ai/core/execution/subagent-dispatcher.js +86 -43
  13. package/.sinapse-ai/core/ideation/ideation-engine.js +63 -7
  14. package/.sinapse-ai/core/memory/gotchas-memory.js +37 -2
  15. package/.sinapse-ai/core/orchestration/condition-evaluator.js +57 -0
  16. package/.sinapse-ai/core/orchestration/master-orchestrator.js +45 -3
  17. package/.sinapse-ai/core/orchestration/recovery-handler.js +81 -8
  18. package/.sinapse-ai/core/registry/registry-loader.js +71 -5
  19. package/.sinapse-ai/core/synapse/context/context-tracker.js +104 -9
  20. package/.sinapse-ai/core/synapse/context/index.js +19 -0
  21. package/.sinapse-ai/core/synapse/context/semantic-handshake-engine.js +555 -0
  22. package/.sinapse-ai/core/synapse/diagnostics/collectors/pipeline-collector.js +4 -2
  23. package/.sinapse-ai/core/synapse/engine.js +43 -3
  24. package/.sinapse-ai/core/utils/spawn-safe.js +186 -0
  25. package/.sinapse-ai/core-config.yaml +19 -0
  26. package/.sinapse-ai/data/entity-registry.yaml +190 -72
  27. package/.sinapse-ai/data/registry-update-log.jsonl +58 -0
  28. package/.sinapse-ai/development/scripts/apply-inline-greeting-all-agents.js +7 -1
  29. package/.sinapse-ai/development/scripts/squad/squad-downloader.js +115 -3
  30. package/.sinapse-ai/hooks/sinapse-ds-grounding.cjs +1 -1
  31. package/.sinapse-ai/hooks/sinapse-vault-grounding.cjs +2 -2
  32. package/.sinapse-ai/infrastructure/integrations/pm-adapters/github-adapter.js +9 -7
  33. package/.sinapse-ai/install-manifest.yaml +78 -42
  34. package/.sinapse-ai/product/templates/engine/renderer.js +20 -1
  35. package/bin/commands/install.js +18 -3
  36. package/docs/framework/collaboration-autonomy-plan.md +18 -18
  37. package/docs/guides/parallel-workflow.md +6 -6
  38. package/package.json +10 -3
  39. package/packages/installer/src/wizard/index.js +3 -1
  40. package/packages/installer/tests/unit/doctor/doctor-checks.test.js +44 -22
  41. package/scripts/regenerate-orqx-stubs.ps1 +6 -5
  42. package/squads/claude-code-mastery/knowledge-base/memory-systems-reference.md +1 -1
  43. package/squads/squad-brand/templates/client-delivery-template.md +1 -1
  44. package/squads/squad-content/knowledge-base/social-compression-framework.md +1 -1
  45. package/squads/squad-council/knowledge-base/brand-strategy-models.md +1 -1
  46. package/docs/chrome-brain-upgrade-plan.md +0 -624
  47. package/docs/constitution-compliance.md +0 -87
  48. package/docs/mega-upgrade-orchestration-plan.md +0 -71
  49. package/docs/research-synthesis-for-upgrade.md +0 -511
  50. package/docs/security-audit-report.md +0 -306
@@ -0,0 +1,187 @@
1
+ /**
2
+ * core/errors/utils.js — pure helpers for the SINAPSE error module.
3
+ *
4
+ * Pure helpers with no external branding, so the logic stays portable. These
5
+ * helpers are shared by the
6
+ * registry, the SinapseError class, and the serializer — keep them dependency
7
+ * free so every other error file can require them safely.
8
+ */
9
+
10
+ function isPlainObject(value) {
11
+ if (value === null || typeof value !== 'object') {
12
+ return false;
13
+ }
14
+
15
+ const prototype = Object.getPrototypeOf(value);
16
+ return prototype === Object.prototype || prototype === null;
17
+ }
18
+
19
+ function cloneMetadataValue(value, seen = new WeakSet()) {
20
+ if (Array.isArray(value)) {
21
+ if (seen.has(value)) {
22
+ return '[Circular]';
23
+ }
24
+
25
+ seen.add(value);
26
+ try {
27
+ return value.map((entry) => cloneMetadataValue(entry, seen));
28
+ } finally {
29
+ seen.delete(value);
30
+ }
31
+ }
32
+
33
+ if (isPlainObject(value)) {
34
+ if (seen.has(value)) {
35
+ return '[Circular]';
36
+ }
37
+
38
+ seen.add(value);
39
+ try {
40
+ return Object.keys(value).reduce((clone, key) => {
41
+ clone[key] = cloneMetadataValue(value[key], seen);
42
+ return clone;
43
+ }, {});
44
+ } finally {
45
+ seen.delete(value);
46
+ }
47
+ }
48
+
49
+ return value;
50
+ }
51
+
52
+ function deepMerge(...sources) {
53
+ return sources.reduce((merged, source) => {
54
+ if (!isPlainObject(source)) {
55
+ return merged;
56
+ }
57
+
58
+ const sourceSeen = new WeakSet();
59
+ sourceSeen.add(source);
60
+
61
+ for (const key of Object.keys(source)) {
62
+ const current = merged[key];
63
+ const next = source[key];
64
+
65
+ if (isPlainObject(current) && isPlainObject(next)) {
66
+ merged[key] = deepMerge(current, next);
67
+ } else {
68
+ merged[key] = cloneMetadataValue(next, sourceSeen);
69
+ }
70
+ }
71
+
72
+ return merged;
73
+ }, {});
74
+ }
75
+
76
+ function normalizeErrorCode(code) {
77
+ if (typeof code !== 'string') {
78
+ return null;
79
+ }
80
+
81
+ const normalized = code.trim().toUpperCase();
82
+ return normalized.length > 0 ? normalized : null;
83
+ }
84
+
85
+ function normalizeRecovery(value) {
86
+ if (!Array.isArray(value)) {
87
+ return [];
88
+ }
89
+
90
+ return value.filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
91
+ }
92
+
93
+ function hasOwn(value, key) {
94
+ return Object.prototype.hasOwnProperty.call(value, key);
95
+ }
96
+
97
+ /**
98
+ * sanitizeValue — turn an arbitrary value into something JSON-safe.
99
+ *
100
+ * Handles: bigint (→ string), function/symbol (→ String()), Date (→ ISO),
101
+ * RegExp (→ string), Map/Set (→ arrays), circular references (via WeakSet,
102
+ * → '[Circular]'), and nested plain objects/arrays. Never throws on a cycle.
103
+ *
104
+ * Note on Error values: when `serializeErrorFn` is supplied (the serializer
105
+ * injects its own `serializeError`), Error instances are delegated to it so
106
+ * the full typed envelope is produced. When called standalone (no injection),
107
+ * Error instances fall through to plain-object enumeration — still cycle-safe.
108
+ */
109
+ function sanitizeValue(value, seen = new WeakSet(), options = {}, serializeErrorFn = null) {
110
+ if (value === undefined || value === null) {
111
+ return value;
112
+ }
113
+
114
+ const valueType = typeof value;
115
+
116
+ if (valueType === 'bigint') {
117
+ return value.toString();
118
+ }
119
+
120
+ if (valueType === 'function' || valueType === 'symbol') {
121
+ return String(value);
122
+ }
123
+
124
+ if (valueType !== 'object') {
125
+ return value;
126
+ }
127
+
128
+ if (seen.has(value)) {
129
+ return '[Circular]';
130
+ }
131
+
132
+ if (value instanceof Date) {
133
+ return Number.isNaN(value.getTime()) ? value.toString() : value.toISOString();
134
+ }
135
+
136
+ if (value instanceof RegExp) {
137
+ return value.toString();
138
+ }
139
+
140
+ if (value instanceof Error && typeof serializeErrorFn === 'function') {
141
+ return serializeErrorFn(value, options, seen);
142
+ }
143
+
144
+ seen.add(value);
145
+
146
+ try {
147
+ if (value instanceof Map) {
148
+ return Array.from(value.entries()).map(([key, entryValue]) => [
149
+ sanitizeValue(key, seen, options, serializeErrorFn),
150
+ sanitizeValue(entryValue, seen, options, serializeErrorFn),
151
+ ]);
152
+ }
153
+
154
+ if (value instanceof Set) {
155
+ return Array.from(value.values()).map((entryValue) =>
156
+ sanitizeValue(entryValue, seen, options, serializeErrorFn),
157
+ );
158
+ }
159
+
160
+ if (Array.isArray(value)) {
161
+ return value.map((entryValue) => sanitizeValue(entryValue, seen, options, serializeErrorFn));
162
+ }
163
+
164
+ return Object.keys(value).reduce((safeValue, key) => {
165
+ try {
166
+ safeValue[key] = sanitizeValue(value[key], seen, options, serializeErrorFn);
167
+ } catch (error) {
168
+ safeValue[key] = `[Unserializable: ${error.message}]`;
169
+ }
170
+ return safeValue;
171
+ }, {});
172
+ } catch (error) {
173
+ return `[Unserializable: ${error.message}]`;
174
+ } finally {
175
+ seen.delete(value);
176
+ }
177
+ }
178
+
179
+ module.exports = {
180
+ isPlainObject,
181
+ cloneMetadataValue,
182
+ deepMerge,
183
+ normalizeErrorCode,
184
+ normalizeRecovery,
185
+ hasOwn,
186
+ sanitizeValue,
187
+ };
@@ -22,8 +22,9 @@
22
22
 
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
- const { spawn, execSync } = require('child_process');
25
+ const { execSync } = require('child_process');
26
26
  const { EventEmitter } = require('events');
27
+ const { runSafe } = require('../utils/spawn-safe');
27
28
 
28
29
  // Import components
29
30
  const { AutonomousBuildLoop, BuildEvent } = require('./autonomous-build-loop');
@@ -501,61 +502,55 @@ The subtask is complete only when verification passes.
501
502
  }
502
503
 
503
504
  /**
504
- * Run Claude CLI with prompt
505
+ * Run Claude CLI with the prompt delivered through stdin.
506
+ *
507
+ * Hardened: the prompt goes through stdin (never the command line) and the
508
+ * process is spawned by argv via cross-spawn — so the prompt can contain
509
+ * quotes, pipes, `;`, `$()` or any shell metacharacter without ever being
510
+ * interpreted as a command (shell-injection is structurally impossible).
511
+ * `--model` is pushed onto the argv (not interpolated into a string).
512
+ * cross-spawn resolves `claude.cmd` on Windows (native spawn → ENOENT).
513
+ *
514
+ * @param {string} prompt - Prompt to execute
515
+ * @param {string} workDir - Working directory for the CLI
516
+ * @param {Object} [config={}] - { claudeModel, subtaskTimeout, verbose }
517
+ * @returns {Promise<{stdout:string, stderr:string, code:number|null}>}
505
518
  */
506
- async runClaudeCLI(prompt, workDir, config) {
507
- return new Promise((resolve, reject) => {
508
- const args = [
509
- '--print', // Non-interactive mode
510
- '--dangerously-skip-permissions', // Allow file writes
511
- ];
512
-
513
- if (config.claudeModel) {
514
- args.push('--model', config.claudeModel);
515
- }
516
-
517
- // Escape prompt for shell
518
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
519
-
520
- const fullCommand = `echo '${escapedPrompt}' | claude ${args.join(' ')}`;
519
+ async runClaudeCLI(prompt, workDir, config = {}) {
520
+ if (!prompt || typeof prompt !== 'string') {
521
+ throw new Error('runClaudeCLI requires a non-empty string prompt');
522
+ }
521
523
 
522
- this.log(`Running Claude CLI in ${workDir}`, 'debug');
524
+ const args = [
525
+ '--print', // Non-interactive mode
526
+ '--dangerously-skip-permissions', // Allow file writes
527
+ ];
523
528
 
524
- const child = spawn('sh', ['-c', fullCommand], {
525
- cwd: workDir,
526
- env: { ...process.env },
527
- timeout: config.subtaskTimeout,
528
- });
529
+ // Model goes onto the argv, never interpolated into a shell string.
530
+ if (config.claudeModel) {
531
+ args.push('--model', config.claudeModel);
532
+ }
529
533
 
530
- let stdout = '';
531
- let stderr = '';
534
+ this.log(`Running Claude CLI in ${workDir}`, 'debug');
532
535
 
533
- child.stdout.on('data', (data) => {
534
- stdout += data.toString();
535
- if (config.verbose) {
536
- process.stdout.write(data);
537
- }
538
- });
536
+ const result = await runSafe('claude', args, {
537
+ cwd: workDir,
538
+ env: { ...process.env },
539
+ timeout: config.subtaskTimeout,
540
+ input: prompt,
541
+ onStdout: config.verbose ? (chunk) => process.stdout.write(chunk) : undefined,
542
+ onStderr: config.verbose ? (chunk) => process.stderr.write(chunk) : undefined,
543
+ });
539
544
 
540
- child.stderr.on('data', (data) => {
541
- stderr += data.toString();
542
- if (config.verbose) {
543
- process.stderr.write(data);
544
- }
545
- });
545
+ if (result.success) {
546
+ return { stdout: result.stdout, stderr: result.stderr, code: result.code };
547
+ }
546
548
 
547
- child.on('close', (code) => {
548
- if (code === 0) {
549
- resolve({ stdout, stderr, code });
550
- } else {
551
- reject(new Error(`Claude CLI exited with code ${code}: ${stderr}`));
552
- }
553
- });
549
+ if (result.signal) {
550
+ throw new Error(`Claude CLI killed by signal ${result.signal}: ${result.stderr}`);
551
+ }
554
552
 
555
- child.on('error', (error) => {
556
- reject(error);
557
- });
558
- });
553
+ throw new Error(`Claude CLI exited with code ${result.code}: ${result.stderr}`);
559
554
  }
560
555
 
561
556
  /**
@@ -23,6 +23,11 @@
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
25
  const crypto = require('crypto');
26
+ const {
27
+ normalizeError,
28
+ sanitizeValue: sanitizeErrorValue,
29
+ serializeError,
30
+ } = require('../errors');
26
31
 
27
32
  // Optional dependencies with graceful fallback
28
33
  let chalk;
@@ -87,6 +92,66 @@ const NotificationType = {
87
92
  ABANDONED: 'abandoned',
88
93
  };
89
94
 
95
+ /**
96
+ * Converts arbitrary values into stable, JSON-safe log data.
97
+ *
98
+ * @param {*} value - Value to sanitize.
99
+ * @param {WeakSet<object>} [seen=new WeakSet()] - Objects in the current path.
100
+ * @returns {*} Data-only representation that JSON.stringify can serialize.
101
+ */
102
+ function sanitizeLogValue(value, seen = new WeakSet()) {
103
+ return sanitizeErrorValue(value, seen, {
104
+ includeStack: shouldExposeLogErrorStack(),
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Checks whether persisted attempt logs may include raw error stack traces.
110
+ *
111
+ * @returns {boolean} True when stack trace logging is explicitly enabled.
112
+ */
113
+ function shouldExposeLogErrorStack() {
114
+ const stackFlag = process.env.DEBUG_ERROR_STACKS || process.env.DEBUG_STACKS || '';
115
+ return ['1', 'true', 'yes', 'on'].includes(String(stackFlag).toLowerCase());
116
+ }
117
+
118
+ /**
119
+ * Stringifies attempt log details without allowing log formatting to throw.
120
+ *
121
+ * @param {*} value - Value to stringify.
122
+ * @returns {string} JSON string or a fallback marker.
123
+ */
124
+ function stringifyLogDetails(value) {
125
+ try {
126
+ return JSON.stringify(sanitizeLogValue(value));
127
+ } catch (error) {
128
+ return JSON.stringify(`[Unserializable: ${error.message}]`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Normalize a failed build attempt into legacy message plus canonical details.
134
+ *
135
+ * @param {*} error - Raw failure error/value.
136
+ * @param {object} context - Build/subtask context for metadata.
137
+ * @returns {{ message: string, details: object }} Normalized failure payload.
138
+ */
139
+ function normalizeFailureError(error, context = {}) {
140
+ const normalized = normalizeError(error || 'Unknown error', {
141
+ code: 'SNPS_EXECUTION_FAILED',
142
+ metadata: {
143
+ buildState: context,
144
+ },
145
+ });
146
+
147
+ return {
148
+ message: normalized.message,
149
+ details: serializeError(normalized, {
150
+ includeStack: shouldExposeLogErrorStack(),
151
+ }),
152
+ };
153
+ }
154
+
90
155
  // ═══════════════════════════════════════════════════════════════════════════════════
91
156
  // SCHEMA VALIDATION
92
157
  // ═══════════════════════════════════════════════════════════════════════════════════
@@ -185,6 +250,8 @@ class BuildStateManager {
185
250
  // Internal state
186
251
  this._state = null;
187
252
  this._logBuffer = [];
253
+ this._persistenceAvailable = true;
254
+ this._persistenceError = null;
188
255
  }
189
256
 
190
257
  // ─────────────────────────────────────────────────────────────────────────────────
@@ -244,14 +311,24 @@ class BuildStateManager {
244
311
  * @returns {Object|null} Loaded state or null if not exists
245
312
  */
246
313
  loadState() {
314
+ if (!this._persistenceAvailable) {
315
+ return this._state;
316
+ }
317
+
247
318
  if (!fs.existsSync(this.stateFilePath)) {
248
319
  return null;
249
320
  }
250
321
 
322
+ let content;
251
323
  try {
252
- const content = fs.readFileSync(this.stateFilePath, 'utf-8');
253
- const state = JSON.parse(content);
324
+ content = fs.readFileSync(this.stateFilePath, 'utf-8');
325
+ } catch (error) {
326
+ this._markPersistenceUnavailable(error);
327
+ return this._state;
328
+ }
254
329
 
330
+ try {
331
+ const state = JSON.parse(content);
255
332
  // Validate
256
333
  const validation = validateBuildState(state);
257
334
  if (!validation.valid) {
@@ -277,11 +354,6 @@ class BuildStateManager {
277
354
  throw new Error('No state to save. Call createState() or loadState() first.');
278
355
  }
279
356
 
280
- // Ensure directory exists
281
- if (!fs.existsSync(this.planDir)) {
282
- fs.mkdirSync(this.planDir, { recursive: true });
283
- }
284
-
285
357
  // Only update timestamp if explicitly requested (via saveCheckpoint)
286
358
  if (options.updateCheckpoint) {
287
359
  this._state.lastCheckpoint = new Date().toISOString();
@@ -293,11 +365,24 @@ class BuildStateManager {
293
365
  throw new Error(`Invalid state: ${validation.errors.join(', ')}`);
294
366
  }
295
367
 
296
- // Write state file
297
- fs.writeFileSync(this.stateFilePath, JSON.stringify(this._state, null, 2), 'utf-8');
368
+ if (!this._persistenceAvailable) {
369
+ return this._state;
370
+ }
371
+
372
+ try {
373
+ // Ensure directory exists
374
+ if (!fs.existsSync(this.planDir)) {
375
+ fs.mkdirSync(this.planDir, { recursive: true });
376
+ }
377
+
378
+ // Write state file
379
+ fs.writeFileSync(this.stateFilePath, JSON.stringify(this._state, null, 2), 'utf-8');
298
380
 
299
- // Flush log buffer
300
- this._flushLogBuffer();
381
+ // Flush log buffer
382
+ this._flushLogBuffer();
383
+ } catch (error) {
384
+ this._markPersistenceUnavailable(error);
385
+ }
301
386
 
302
387
  return this._state;
303
388
  }
@@ -341,11 +426,6 @@ class BuildStateManager {
341
426
  throw new Error('No state loaded');
342
427
  }
343
428
 
344
- // Ensure checkpoint directory exists
345
- if (!fs.existsSync(this.checkpointDir)) {
346
- fs.mkdirSync(this.checkpointDir, { recursive: true });
347
- }
348
-
349
429
  const checkpointId = this._generateCheckpointId();
350
430
  const now = new Date().toISOString();
351
431
 
@@ -376,8 +456,19 @@ class BuildStateManager {
376
456
  this._updateMetrics(checkpoint);
377
457
 
378
458
  // Save checkpoint file
379
- const checkpointPath = path.join(this.checkpointDir, `${checkpointId}.json`);
380
- fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
459
+ if (this._persistenceAvailable) {
460
+ try {
461
+ // Ensure checkpoint directory exists
462
+ if (!fs.existsSync(this.checkpointDir)) {
463
+ fs.mkdirSync(this.checkpointDir, { recursive: true });
464
+ }
465
+
466
+ const checkpointPath = path.join(this.checkpointDir, `${checkpointId}.json`);
467
+ fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
468
+ } catch (error) {
469
+ this._markPersistenceUnavailable(error);
470
+ }
471
+ }
381
472
 
382
473
  // Save main state with checkpoint timestamp update
383
474
  this.saveState({ updateCheckpoint: true });
@@ -781,12 +872,21 @@ class BuildStateManager {
781
872
  throw new Error('No state loaded');
782
873
  }
783
874
 
875
+ const attempt =
876
+ options.attempt ||
877
+ this._state.failedAttempts.filter((f) => f.subtaskId === subtaskId).length + 1;
878
+
879
+ const normalizedFailure = normalizeFailureError(options.error, {
880
+ storyId: this.storyId,
881
+ subtaskId,
882
+ attempt,
883
+ });
884
+
784
885
  const failure = {
785
886
  subtaskId,
786
- attempt:
787
- options.attempt ||
788
- this._state.failedAttempts.filter((f) => f.subtaskId === subtaskId).length + 1,
789
- error: options.error || 'Unknown error',
887
+ attempt,
888
+ error: normalizedFailure.message,
889
+ errorDetails: normalizedFailure.details,
790
890
  timestamp: new Date().toISOString(),
791
891
  approach: options.approach || null,
792
892
  duration: options.duration || null,
@@ -808,6 +908,7 @@ class BuildStateManager {
808
908
  this._logAttempt(subtaskId, 'failure', {
809
909
  attempt: failure.attempt,
810
910
  error: failure.error,
911
+ errorDetails: failure.errorDetails,
811
912
  isStuck: isStuck.stuck,
812
913
  });
813
914
 
@@ -918,6 +1019,8 @@ class BuildStateManager {
918
1019
  * @private
919
1020
  */
920
1021
  _logAttempt(subtaskId, action, details = {}) {
1022
+ if (!this._persistenceAvailable) return;
1023
+
921
1024
  const entry = {
922
1025
  timestamp: new Date().toISOString(),
923
1026
  storyId: this.storyId,
@@ -927,7 +1030,7 @@ class BuildStateManager {
927
1030
  };
928
1031
 
929
1032
  // Format log line
930
- const logLine = `[${entry.timestamp}] [${this.storyId}] [${subtaskId}] ${action}: ${JSON.stringify(details)}\n`;
1033
+ const logLine = `[${entry.timestamp}] [${this.storyId}] [${subtaskId}] ${action}: ${stringifyLogDetails(details)}\n`;
931
1034
 
932
1035
  this._logBuffer.push(logLine);
933
1036
 
@@ -943,15 +1046,20 @@ class BuildStateManager {
943
1046
  */
944
1047
  _flushLogBuffer() {
945
1048
  if (this._logBuffer.length === 0) return;
1049
+ if (!this._persistenceAvailable) return;
946
1050
 
947
- // Ensure directory exists
948
- if (!fs.existsSync(this.planDir)) {
949
- fs.mkdirSync(this.planDir, { recursive: true });
950
- }
1051
+ try {
1052
+ // Ensure directory exists
1053
+ if (!fs.existsSync(this.planDir)) {
1054
+ fs.mkdirSync(this.planDir, { recursive: true });
1055
+ }
951
1056
 
952
- // Append to log file
953
- fs.appendFileSync(this.logFilePath, this._logBuffer.join(''), 'utf-8');
954
- this._logBuffer = [];
1057
+ // Append to log file
1058
+ fs.appendFileSync(this.logFilePath, this._logBuffer.join(''), 'utf-8');
1059
+ this._logBuffer = [];
1060
+ } catch (error) {
1061
+ this._markPersistenceUnavailable(error);
1062
+ }
955
1063
  }
956
1064
 
957
1065
  /**
@@ -961,11 +1069,22 @@ class BuildStateManager {
961
1069
  * @returns {string[]} Log lines
962
1070
  */
963
1071
  getAttemptLog(options = {}) {
1072
+ if (!this._persistenceAvailable) {
1073
+ return [];
1074
+ }
1075
+
964
1076
  if (!fs.existsSync(this.logFilePath)) {
965
1077
  return [];
966
1078
  }
967
1079
 
968
- const content = fs.readFileSync(this.logFilePath, 'utf-8');
1080
+ let content;
1081
+ try {
1082
+ content = fs.readFileSync(this.logFilePath, 'utf-8');
1083
+ } catch (error) {
1084
+ this._markPersistenceUnavailable(error);
1085
+ return [];
1086
+ }
1087
+
969
1088
  let lines = content.split('\n').filter((l) => l.trim());
970
1089
 
971
1090
  // Filter by subtask if specified
@@ -1143,6 +1262,39 @@ class BuildStateManager {
1143
1262
  // Silent by default - can be overridden
1144
1263
  }
1145
1264
 
1265
+ /**
1266
+ * Check if file-backed persistence is still available.
1267
+ *
1268
+ * @returns {boolean} True when state/checkpoint/log writes are available.
1269
+ */
1270
+ isPersistenceAvailable() {
1271
+ return this._persistenceAvailable;
1272
+ }
1273
+
1274
+ /**
1275
+ * Get the first persistence error that disabled file-backed writes.
1276
+ *
1277
+ * @returns {Error|null} Persistence error or null when persistence is available.
1278
+ */
1279
+ getPersistenceError() {
1280
+ return this._persistenceError;
1281
+ }
1282
+
1283
+ /**
1284
+ * Disable file-backed persistence after an I/O failure.
1285
+ *
1286
+ * @param {Error} error - Underlying persistence error.
1287
+ * @private
1288
+ */
1289
+ _markPersistenceUnavailable(error) {
1290
+ if (!this._persistenceAvailable) return;
1291
+
1292
+ this._persistenceAvailable = false;
1293
+ this._persistenceError = error instanceof Error ? error : new Error(String(error));
1294
+ this._logBuffer = [];
1295
+ this._log(`Persistence unavailable: ${this._persistenceError.message}`);
1296
+ }
1297
+
1146
1298
  // ─────────────────────────────────────────────────────────────────────────────────
1147
1299
  // CLI FORMATTING
1148
1300
  // ─────────────────────────────────────────────────────────────────────────────────