ultra-builder-pro-cli 0.24.1 → 0.25.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 (46) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +10 -0
  3. package/adapters/claude.js +104 -0
  4. package/adapters/codex.js +363 -40
  5. package/adapters/grok.js +1254 -58
  6. package/adapters/kimi.js +682 -13
  7. package/adapters/opencode.js +132 -11
  8. package/agents/code-reviewer.md +4 -5
  9. package/agents/debugger.md +8 -8
  10. package/docs/AGENT-CONTEXT.md +30 -4
  11. package/docs/ARCHITECTURE.md +24 -5
  12. package/docs/ARTIFACT-AUTHORITY.md +13 -0
  13. package/docs/COMMIT-HASH-BACKFILL.md +25 -18
  14. package/docs/RUNTIME-COMPAT-MATRIX.md +18 -2
  15. package/docs/STATE-DB-ACCESS-POLICY.md +8 -1
  16. package/mcp-server/lib/artifact-registry.cjs +188 -107
  17. package/mcp-server/lib/baseline-workflow.cjs +160 -23
  18. package/mcp-server/lib/change-packet.cjs +4 -32
  19. package/mcp-server/lib/change-workflow.cjs +36 -2755
  20. package/mcp-server/lib/context-envelope.cjs +124 -51
  21. package/mcp-server/lib/decision-records.cjs +164 -78
  22. package/mcp-server/lib/kernel-change-workflow.cjs +2029 -0
  23. package/mcp-server/lib/legacy-change-workflow.cjs +3032 -0
  24. package/mcp-server/lib/managed-file-write.cjs +56 -0
  25. package/mcp-server/lib/prd-parser.cjs +2 -2
  26. package/mcp-server/lib/stage-checkpoints.cjs +163 -9
  27. package/mcp-server/lib/state-db.cjs +186 -8
  28. package/mcp-server/lib/state-ops.cjs +214 -31
  29. package/mcp-server/lib/task-contract.cjs +404 -0
  30. package/mcp-server/lib/task-expander.cjs +8 -3
  31. package/mcp-server/lib/task-ledger.cjs +175 -62
  32. package/mcp-server/lib/ultra-facade.cjs +1895 -172
  33. package/mcp-server/lib/worker-packet.cjs +2 -2
  34. package/mcp-server/server.cjs +24 -5
  35. package/orchestrator/parallel-orchestrator.cjs +114 -27
  36. package/orchestrator/session-runner.cjs +25 -136
  37. package/package.json +1 -1
  38. package/skills/ultra-change/SKILL.md +6 -1
  39. package/skills/ultra-dev/SKILL.md +9 -4
  40. package/skills/ultra-plan/SKILL.md +13 -4
  41. package/skills/ultra-plan/references/semantic-preflight.md +6 -4
  42. package/spec/mcp-tools.yaml +114 -9
  43. package/spec/schemas/context-file.v4.5.schema.json +2 -2
  44. package/spec/schemas/state-db.sql +10 -6
  45. package/spec/schemas/task-ledger.v2.schema.json +23 -8
  46. package/spec/schemas/tasks.v4.5.schema.json +34 -12
@@ -8,7 +8,11 @@ const baselines = require('./baseline-workflow.cjs');
8
8
  const canonical = require('./canonical-json.cjs');
9
9
  const changes = require('./change-workflow.cjs');
10
10
  const decisions = require('./decision-records.cjs');
11
- const { writeManagedJson } = require('./managed-file-write.cjs');
11
+ const {
12
+ restoreManagedFile,
13
+ snapshotManagedFile,
14
+ writeManagedJson,
15
+ } = require('./managed-file-write.cjs');
12
16
  const ops = require('./state-ops.cjs');
13
17
  const { readStableProjectFile } = require('./safe-project-file.cjs');
14
18
  const checkpoints = require('./stage-checkpoints.cjs');
@@ -70,8 +74,33 @@ function selectedTask(db, taskId) {
70
74
  function resolvedScope(db, input = {}) {
71
75
  const requested = input.scope || {};
72
76
  const task = selectedTask(db, requested.task_id);
77
+ if (requested.task_id && !task) {
78
+ throw new ContextEnvelopeError(
79
+ 'CONTEXT_SCOPE_NOT_FOUND',
80
+ `Context task scope does not exist: ${requested.task_id}`,
81
+ { scope_type: 'task', scope_id: requested.task_id },
82
+ );
83
+ }
73
84
  const changeId = requested.change_id || task?.change_id || null;
74
85
  const change = selectedChange(db, changeId);
86
+ if (requested.change_id && !change) {
87
+ throw new ContextEnvelopeError(
88
+ 'CONTEXT_SCOPE_NOT_FOUND',
89
+ `Context Change scope does not exist: ${requested.change_id}`,
90
+ { scope_type: 'change', scope_id: requested.change_id },
91
+ );
92
+ }
93
+ if (task && requested.change_id && task.change_id !== requested.change_id) {
94
+ throw new ContextEnvelopeError(
95
+ 'CONTEXT_SCOPE_MISMATCH',
96
+ `Context task ${task.id} belongs to Change ${task.change_id || '(none)'}, not ${requested.change_id}`,
97
+ {
98
+ task_id: task.id,
99
+ task_change_id: task.change_id || null,
100
+ requested_change_id: requested.change_id,
101
+ },
102
+ );
103
+ }
75
104
  const baseline = baselines.readBaseline(db);
76
105
  if (task) return { type: 'task', id: task.id, baseline, change, task };
77
106
  if (change) return { type: 'change', id: change.id, baseline, change, task: null };
@@ -254,6 +283,31 @@ function authorityPayload(db, input, options) {
254
283
  item.diagnostics.filter((diagnostic) => diagnostic.severity === 'hard_conflict')
255
284
  )),
256
285
  };
286
+ if (scope.task) {
287
+ diagnostics.warnings.push(...ops.taskContractBlockers(scope.task).map((code) => ({
288
+ code,
289
+ severity: 'warning',
290
+ message: 'Task semantics are incomplete; the active Skill and model own the decision to proceed.',
291
+ })));
292
+ }
293
+ if (scope.change) {
294
+ const advisory = [
295
+ [!String(scope.change.contract?.outcome || '').trim(), 'CHANGE_OUTCOME_UNRESOLVED'],
296
+ [!Array.isArray(scope.change.contract?.acceptance)
297
+ || scope.change.contract.acceptance.length === 0, 'CHANGE_ACCEPTANCE_UNRESOLVED'],
298
+ [!scope.change.contract?.recovery, 'CHANGE_RECOVERY_UNRESOLVED'],
299
+ [!String(scope.change.classification?.rationale || '').trim(), 'CHANGE_CLASSIFICATION_UNRESOLVED'],
300
+ [scope.change.research_disposition?.status === 'unresolved',
301
+ 'CHANGE_RESEARCH_DISPOSITION_UNRESOLVED'],
302
+ ];
303
+ diagnostics.warnings.push(...advisory
304
+ .filter(([missing]) => missing)
305
+ .map(([, code]) => ({
306
+ code,
307
+ severity: 'warning',
308
+ message: 'Change semantics remain mutable; the active Skill and model own completion.',
309
+ })));
310
+ }
257
311
  for (const code of baselineHealth.blockers || []) {
258
312
  diagnostics.needs_attention.push({ code, severity: 'needs_attention' });
259
313
  }
@@ -435,58 +489,77 @@ function persistEnvelope(db, input = {}, options = {}) {
435
489
  const scope = resolvedScope(db, input);
436
490
  const id = `context-${full.digest.slice(0, 24)}`;
437
491
  const artifactPath = contextArtifactPath(scope, id);
438
- const published = writeManagedJson(options.rootDir || process.cwd(), artifactPath, full);
439
- ops.tx(db, () => {
440
- db.prepare(
441
- `INSERT INTO context_envelopes
442
- (id, stage, scope_type, scope_id, digest, file_digest, payload_json, artifact_path)
443
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
444
- ).run(
445
- id,
446
- input.stage || 'project',
447
- scope.type,
448
- scope.id,
449
- full.digest,
450
- published.digest,
451
- JSON.stringify(full.envelope),
452
- artifactPath,
453
- );
454
- if (!artifactPath.startsWith('.ultra/.runtime/')) {
455
- artifactRegistry.recordArtifactInTx(db, {
456
- id: `artifact-${id}`,
457
- owner_type: scope.change ? 'change' : (scope.baseline ? 'baseline' : 'project'),
458
- owner_id: scope.change?.id || scope.baseline?.id || 'project',
459
- kind: 'context_envelope',
460
- path: artifactPath,
461
- content_digest: published.digest,
462
- source_refs: [
463
- ...(scope.change
464
- ? [{ type: 'change', id: scope.change.id, relation: 'compiled_from' }]
465
- : []),
466
- ...(scope.task
467
- ? [{ type: 'task', id: scope.task.id, relation: 'compiled_from_task_contract' }]
468
- : []),
469
- ...(scope.baseline && !scope.change
470
- ? [{ type: 'baseline', id: scope.baseline.id, relation: 'compiled_from' }]
471
- : []),
472
- ],
473
- consumer_refs: [{
474
- type: 'external',
475
- id: 'ultra-context-consumer',
476
- relation: 'consumed_by',
477
- }],
478
- provenance: {
479
- writer: 'context-envelope',
480
- envelope_digest: full.digest,
481
- stage: input.stage || 'project',
482
- },
483
- metadata: {
484
- envelope_id: id,
485
- semantic_digest: full.digest,
492
+ const rootDir = options.rootDir || process.cwd();
493
+ const snapshot = snapshotManagedFile(rootDir, artifactPath);
494
+ const published = writeManagedJson(rootDir, artifactPath, full);
495
+ try {
496
+ ops.tx(db, () => {
497
+ db.prepare(
498
+ `INSERT INTO context_envelopes
499
+ (id, stage, scope_type, scope_id, digest, file_digest, payload_json, artifact_path)
500
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
501
+ ).run(
502
+ id,
503
+ input.stage || 'project',
504
+ scope.type,
505
+ scope.id,
506
+ full.digest,
507
+ published.digest,
508
+ JSON.stringify(full.envelope),
509
+ artifactPath,
510
+ );
511
+ if (!artifactPath.startsWith('.ultra/.runtime/')) {
512
+ artifactRegistry.recordArtifactInTx(db, {
513
+ id: `artifact-${id}`,
514
+ owner_type: scope.change ? 'change' : (scope.baseline ? 'baseline' : 'project'),
515
+ owner_id: scope.change?.id || scope.baseline?.id || 'project',
516
+ kind: 'context_envelope',
517
+ path: artifactPath,
518
+ content_digest: published.digest,
519
+ source_refs: [
520
+ ...(scope.change
521
+ ? [{ type: 'change', id: scope.change.id, relation: 'compiled_from' }]
522
+ : []),
523
+ ...(scope.task
524
+ ? [{ type: 'task', id: scope.task.id, relation: 'compiled_from_task_contract' }]
525
+ : []),
526
+ ...(scope.baseline && !scope.change
527
+ ? [{ type: 'baseline', id: scope.baseline.id, relation: 'compiled_from' }]
528
+ : []),
529
+ ],
530
+ consumer_refs: [{
531
+ type: 'external',
532
+ id: 'ultra-context-consumer',
533
+ relation: 'consumed_by',
534
+ }],
535
+ provenance: {
536
+ writer: 'context-envelope',
537
+ envelope_digest: full.digest,
538
+ stage: input.stage || 'project',
539
+ },
540
+ metadata: {
541
+ envelope_id: id,
542
+ semantic_digest: full.digest,
543
+ },
544
+ }, { rootDir });
545
+ }
546
+ });
547
+ } catch (error) {
548
+ try {
549
+ restoreManagedFile(rootDir, snapshot);
550
+ } catch (rollbackError) {
551
+ throw new ContextEnvelopeError(
552
+ 'STATE_CORRUPT',
553
+ 'Context Envelope publication failed and its managed file could not be restored',
554
+ {
555
+ cause: error.code || error.message,
556
+ rollback: rollbackError.code || rollbackError.message,
557
+ path: artifactPath,
486
558
  },
487
- }, { rootDir: options.rootDir || process.cwd() });
559
+ );
488
560
  }
489
- });
561
+ throw error;
562
+ }
490
563
  return {
491
564
  id,
492
565
  digest: full.digest,
@@ -3,9 +3,18 @@
3
3
  const canonical = require('./canonical-json.cjs');
4
4
  const artifactRegistry = require('./artifact-registry.cjs');
5
5
  const ops = require('./state-ops.cjs');
6
- const { writeManagedJson } = require('./managed-file-write.cjs');
6
+ const {
7
+ restoreManagedFile,
8
+ snapshotManagedFile,
9
+ writeManagedJson,
10
+ } = require('./managed-file-write.cjs');
7
11
  const { readStableProjectFile } = require('./safe-project-file.cjs');
8
12
 
13
+ const DECISION_FIELDS = new Set([
14
+ 'id', 'scope', 'question', 'recommendation', 'selection', 'effects',
15
+ 'non_goals', 'owner', 'source', 'provenance', 'applied_refs', 'supersedes_id',
16
+ ]);
17
+
9
18
  class DecisionRecordError extends Error {
10
19
  constructor(code, message, details) {
11
20
  super(message);
@@ -21,11 +30,55 @@ function requiredText(value, field) {
21
30
  return normalized;
22
31
  }
23
32
 
33
+ function assertExactFields(value, allowed, field) {
34
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
35
+ throw new DecisionRecordError('VALIDATION_ERROR', `${field} must be an object`);
36
+ }
37
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
38
+ if (unknown.length > 0) {
39
+ throw new DecisionRecordError(
40
+ 'VALIDATION_ERROR', `${field}.${unknown[0]} is not allowed`,
41
+ );
42
+ }
43
+ }
44
+
45
+ function assertJsonValue(value, field, seen = new Set()) {
46
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
47
+ if (typeof value === 'number') {
48
+ if (Number.isFinite(value)) return;
49
+ throw new DecisionRecordError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
50
+ }
51
+ if (typeof value !== 'object') {
52
+ throw new DecisionRecordError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
53
+ }
54
+ if (seen.has(value)) {
55
+ throw new DecisionRecordError('VALIDATION_ERROR', `${field} must not contain circular references`);
56
+ }
57
+ if (!Array.isArray(value)) {
58
+ const prototype = Object.getPrototypeOf(value);
59
+ if (prototype !== Object.prototype && prototype !== null) {
60
+ throw new DecisionRecordError(
61
+ 'VALIDATION_ERROR', `${field} must contain only JSON objects`,
62
+ );
63
+ }
64
+ }
65
+ seen.add(value);
66
+ if (Array.isArray(value)) {
67
+ value.forEach((item, index) => assertJsonValue(item, `${field}[${index}]`, seen));
68
+ } else {
69
+ Object.entries(value).forEach(([key, item]) => (
70
+ assertJsonValue(item, `${field}.${key}`, seen)
71
+ ));
72
+ }
73
+ seen.delete(value);
74
+ }
75
+
24
76
  function plainObject(value, field) {
25
77
  if (value === undefined) return {};
26
78
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
27
79
  throw new DecisionRecordError('VALIDATION_ERROR', `${field} must be an object`);
28
80
  }
81
+ assertJsonValue(value, field);
29
82
  return JSON.parse(JSON.stringify(value));
30
83
  }
31
84
 
@@ -38,6 +91,7 @@ function stringArray(value, field) {
38
91
  }
39
92
 
40
93
  function normalizeScope(scope = {}) {
94
+ assertExactFields(scope, new Set(['baseline_id', 'change_id']), 'scope');
41
95
  const baseline = typeof scope.baseline_id === 'string' ? scope.baseline_id.trim() : '';
42
96
  const change = typeof scope.change_id === 'string' ? scope.change_id.trim() : '';
43
97
  if (Boolean(baseline) === Boolean(change)) {
@@ -186,10 +240,19 @@ function normalizeAppliedRefs(value) {
186
240
  if (!item || typeof item !== 'object' || Array.isArray(item)) {
187
241
  throw new DecisionRecordError('VALIDATION_ERROR', `applied_refs[${index}] must be an object`);
188
242
  }
243
+ assertExactFields(
244
+ item,
245
+ new Set(['ref', 'field', 'digest']),
246
+ `applied_refs[${index}]`,
247
+ );
189
248
  return {
190
249
  ref: requiredText(item.ref, `applied_refs[${index}].ref`),
191
- ...(item.field ? { field: requiredText(item.field, `applied_refs[${index}].field`) } : {}),
192
- ...(item.digest ? { digest: requiredText(item.digest, `applied_refs[${index}].digest`) } : {}),
250
+ ...(item.field !== undefined
251
+ ? { field: requiredText(item.field, `applied_refs[${index}].field`) }
252
+ : {}),
253
+ ...(item.digest !== undefined
254
+ ? { digest: requiredText(item.digest, `applied_refs[${index}].digest`) }
255
+ : {}),
193
256
  };
194
257
  });
195
258
  }
@@ -201,7 +264,11 @@ function artifactPath(scope, id) {
201
264
  }
202
265
 
203
266
  function acceptDecision(db, input = {}, { rootDir = process.cwd() } = {}) {
267
+ assertExactFields(input, DECISION_FIELDS, 'decision');
204
268
  const scope = normalizeScope(input.scope);
269
+ const supersedesId = input.supersedes_id === undefined || input.supersedes_id === null
270
+ ? null
271
+ : requiredText(input.supersedes_id, 'supersedes_id');
205
272
  const value = {
206
273
  schema_version: '1.0',
207
274
  id: requiredText(input.id, 'id'),
@@ -216,9 +283,7 @@ function acceptDecision(db, input = {}, { rootDir = process.cwd() } = {}) {
216
283
  provenance: plainObject(input.provenance, 'provenance'),
217
284
  applied_refs: normalizeAppliedRefs(input.applied_refs),
218
285
  status: 'accepted',
219
- supersedes_id: input.supersedes_id
220
- ? requiredText(input.supersedes_id, 'supersedes_id')
221
- : null,
286
+ supersedes_id: supersedesId,
222
287
  };
223
288
  const digest = canonical.digest(value);
224
289
  const document = { ...value, digest };
@@ -247,83 +312,104 @@ function acceptDecision(db, input = {}, { rootDir = process.cwd() } = {}) {
247
312
  'a decision can only supersede another decision in the same scope',
248
313
  );
249
314
  }
315
+ if (prior.status !== 'accepted') {
316
+ throw new DecisionRecordError(
317
+ 'DECISION_NOT_SUPERSEDEABLE',
318
+ `decision ${value.supersedes_id} is ${prior.status}`,
319
+ );
320
+ }
250
321
  }
322
+ artifactRegistry.assertArtifactOwner(db, scope.type, scope.id);
323
+ const snapshot = snapshotManagedFile(rootDir, relative);
251
324
  const published = writeManagedJson(rootDir, relative, document);
252
- return ops.tx(db, () => {
253
- if (value.supersedes_id) {
325
+ try {
326
+ return ops.tx(db, () => {
327
+ if (value.supersedes_id) {
328
+ db.prepare(
329
+ `UPDATE decision_records SET status = 'superseded',
330
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
331
+ WHERE id = ? AND status = 'accepted'`,
332
+ ).run(value.supersedes_id);
333
+ }
254
334
  db.prepare(
255
- `UPDATE decision_records SET status = 'superseded',
256
- updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
257
- WHERE id = ? AND status = 'accepted'`,
258
- ).run(value.supersedes_id);
259
- }
260
- db.prepare(
261
- `INSERT INTO decision_records
262
- (id, scope_type, scope_id, question, recommendation, selection,
263
- effects_json, non_goals_json, owner, source, provenance_json,
264
- applied_refs_json, status, digest, artifact_path, supersedes_id)
265
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?, ?)`,
266
- ).run(
267
- value.id,
268
- scope.type,
269
- scope.id,
270
- value.question,
271
- value.recommendation,
272
- value.selection,
273
- JSON.stringify(value.effects),
274
- JSON.stringify(value.non_goals),
275
- value.owner,
276
- value.source,
277
- JSON.stringify(value.provenance),
278
- JSON.stringify(value.applied_refs),
279
- digest,
280
- relative,
281
- value.supersedes_id,
282
- );
283
- artifactRegistry.recordArtifactInTx(db, {
284
- id: `artifact-decision-${value.id}`,
285
- owner_type: scope.type,
286
- owner_id: scope.id,
287
- change_id: scope.type === 'change' ? scope.id : null,
288
- kind: 'decision_record',
289
- path: relative,
290
- content_digest: published.digest,
291
- source_refs: [{
292
- type: scope.type,
293
- id: scope.id,
294
- relation: 'decided_for',
295
- }],
296
- consumer_refs: [{
297
- type: 'external',
298
- id: 'ultra-context-consumer',
299
- relation: 'consumed_by',
300
- }],
301
- provenance: {
302
- writer: 'decision-records',
303
- decision_digest: digest,
304
- owner: value.owner,
305
- source: value.source,
306
- },
307
- metadata: {
308
- decision_id: value.id,
309
- status: value.status,
310
- supersedes_id: value.supersedes_id,
311
- },
312
- }, { rootDir });
313
- ops.appendEventInTx(db, {
314
- type: 'decision_recorded',
315
- change_id: scope.type === 'change' ? scope.id : null,
316
- payload: {
317
- decision_id: value.id,
318
- scope_type: scope.type,
319
- scope_id: scope.id,
335
+ `INSERT INTO decision_records
336
+ (id, scope_type, scope_id, question, recommendation, selection,
337
+ effects_json, non_goals_json, owner, source, provenance_json,
338
+ applied_refs_json, status, digest, artifact_path, supersedes_id)
339
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?, ?)`,
340
+ ).run(
341
+ value.id,
342
+ scope.type,
343
+ scope.id,
344
+ value.question,
345
+ value.recommendation,
346
+ value.selection,
347
+ JSON.stringify(value.effects),
348
+ JSON.stringify(value.non_goals),
349
+ value.owner,
350
+ value.source,
351
+ JSON.stringify(value.provenance),
352
+ JSON.stringify(value.applied_refs),
320
353
  digest,
321
- artifact_path: relative,
322
- supersedes_id: value.supersedes_id,
323
- },
354
+ relative,
355
+ value.supersedes_id,
356
+ );
357
+ artifactRegistry.recordArtifactInTx(db, {
358
+ id: `artifact-decision-${value.id}`,
359
+ owner_type: scope.type,
360
+ owner_id: scope.id,
361
+ change_id: scope.type === 'change' ? scope.id : null,
362
+ kind: 'decision_record',
363
+ path: relative,
364
+ content_digest: published.digest,
365
+ source_refs: [{
366
+ type: scope.type,
367
+ id: scope.id,
368
+ relation: 'decided_for',
369
+ }],
370
+ consumer_refs: [{
371
+ type: 'external',
372
+ id: 'ultra-context-consumer',
373
+ relation: 'consumed_by',
374
+ }],
375
+ provenance: {
376
+ writer: 'decision-records',
377
+ decision_digest: digest,
378
+ owner: value.owner,
379
+ source: value.source,
380
+ },
381
+ metadata: {
382
+ decision_id: value.id,
383
+ status: value.status,
384
+ supersedes_id: value.supersedes_id,
385
+ },
386
+ }, { rootDir });
387
+ ops.appendEventInTx(db, {
388
+ type: 'decision_recorded',
389
+ change_id: scope.type === 'change' ? scope.id : null,
390
+ payload: {
391
+ decision_id: value.id,
392
+ scope_type: scope.type,
393
+ scope_id: scope.id,
394
+ digest,
395
+ artifact_path: relative,
396
+ supersedes_id: value.supersedes_id,
397
+ },
398
+ });
399
+ return readDecision(db, value.id);
324
400
  });
325
- return readDecision(db, value.id);
326
- });
401
+ } catch (error) {
402
+ try {
403
+ restoreManagedFile(rootDir, snapshot);
404
+ } catch (rollbackError) {
405
+ throw new DecisionRecordError(
406
+ 'RECORD_FILE_ROLLBACK_REQUIRED',
407
+ `decision database mutation failed and managed file recovery also failed: ${relative}`,
408
+ { cause: error.message, rollback_error: rollbackError.message },
409
+ );
410
+ }
411
+ throw error;
412
+ }
327
413
  }
328
414
 
329
415
  module.exports = {