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.
- package/.claude/rules/documentation-first.md +1 -1
- package/.sinapse-ai/core/config/merge-utils.js +8 -0
- package/.sinapse-ai/core/errors/constants.js +147 -0
- package/.sinapse-ai/core/errors/error-registry.js +176 -0
- package/.sinapse-ai/core/errors/index.js +50 -0
- package/.sinapse-ai/core/errors/serializer.js +147 -0
- package/.sinapse-ai/core/errors/sinapse-error.js +144 -0
- package/.sinapse-ai/core/errors/utils.js +187 -0
- package/.sinapse-ai/core/execution/build-orchestrator.js +43 -48
- package/.sinapse-ai/core/execution/build-state-manager.js +183 -31
- package/.sinapse-ai/core/execution/semantic-merge-engine.js +26 -14
- package/.sinapse-ai/core/execution/subagent-dispatcher.js +86 -43
- package/.sinapse-ai/core/ideation/ideation-engine.js +63 -7
- package/.sinapse-ai/core/memory/gotchas-memory.js +37 -2
- package/.sinapse-ai/core/orchestration/condition-evaluator.js +57 -0
- package/.sinapse-ai/core/orchestration/master-orchestrator.js +45 -3
- package/.sinapse-ai/core/orchestration/recovery-handler.js +81 -8
- package/.sinapse-ai/core/registry/registry-loader.js +71 -5
- package/.sinapse-ai/core/synapse/context/context-tracker.js +104 -9
- package/.sinapse-ai/core/synapse/context/index.js +19 -0
- package/.sinapse-ai/core/synapse/context/semantic-handshake-engine.js +555 -0
- package/.sinapse-ai/core/synapse/diagnostics/collectors/pipeline-collector.js +4 -2
- package/.sinapse-ai/core/synapse/engine.js +43 -3
- package/.sinapse-ai/core/utils/spawn-safe.js +186 -0
- package/.sinapse-ai/core-config.yaml +19 -0
- package/.sinapse-ai/data/entity-registry.yaml +190 -72
- package/.sinapse-ai/data/registry-update-log.jsonl +58 -0
- package/.sinapse-ai/development/scripts/apply-inline-greeting-all-agents.js +7 -1
- package/.sinapse-ai/development/scripts/squad/squad-downloader.js +115 -3
- package/.sinapse-ai/hooks/sinapse-ds-grounding.cjs +1 -1
- package/.sinapse-ai/hooks/sinapse-vault-grounding.cjs +2 -2
- package/.sinapse-ai/infrastructure/integrations/pm-adapters/github-adapter.js +9 -7
- package/.sinapse-ai/install-manifest.yaml +78 -42
- package/.sinapse-ai/product/templates/engine/renderer.js +20 -1
- package/bin/commands/install.js +18 -3
- package/docs/framework/collaboration-autonomy-plan.md +18 -18
- package/docs/guides/parallel-workflow.md +6 -6
- package/package.json +10 -3
- package/packages/installer/src/wizard/index.js +3 -1
- package/packages/installer/tests/unit/doctor/doctor-checks.test.js +44 -22
- package/scripts/regenerate-orqx-stubs.ps1 +6 -5
- package/squads/claude-code-mastery/knowledge-base/memory-systems-reference.md +1 -1
- package/squads/squad-brand/templates/client-delivery-template.md +1 -1
- package/squads/squad-content/knowledge-base/social-compression-framework.md +1 -1
- package/squads/squad-council/knowledge-base/brand-strategy-models.md +1 -1
- package/docs/chrome-brain-upgrade-plan.md +0 -624
- package/docs/constitution-compliance.md +0 -87
- package/docs/mega-upgrade-orchestration-plan.md +0 -71
- package/docs/research-synthesis-for-upgrade.md +0 -511
- 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 {
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
524
|
+
const args = [
|
|
525
|
+
'--print', // Non-interactive mode
|
|
526
|
+
'--dangerously-skip-permissions', // Allow file writes
|
|
527
|
+
];
|
|
523
528
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
let stderr = '';
|
|
534
|
+
this.log(`Running Claude CLI in ${workDir}`, 'debug');
|
|
532
535
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
788
|
-
|
|
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}: ${
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
fs.
|
|
950
|
-
|
|
1051
|
+
try {
|
|
1052
|
+
// Ensure directory exists
|
|
1053
|
+
if (!fs.existsSync(this.planDir)) {
|
|
1054
|
+
fs.mkdirSync(this.planDir, { recursive: true });
|
|
1055
|
+
}
|
|
951
1056
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
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
|
// ─────────────────────────────────────────────────────────────────────────────────
|