ultra-builder-pro-cli 0.20.0 → 0.21.1

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 (102) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +66 -0
  5. package/README.md +54 -24
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +36 -3
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/docs/AGENT-CONTEXT.md +74 -43
  15. package/docs/ARCHITECTURE.md +63 -31
  16. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  17. package/docs/DECISIONS.md +10 -4
  18. package/docs/LEGACY-CLI-CROSSWALK.md +6 -4
  19. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  20. package/docs/ROADMAP.md +19 -5
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +24 -13
  22. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  23. package/docs/WORKFLOW-LIFECYCLE.md +70 -39
  24. package/hooks/README.md +6 -5
  25. package/hooks/active_task_context.py +97 -12
  26. package/hooks/context_spine.py +16 -4
  27. package/hooks/health_check.py +5 -4
  28. package/hooks/pre_stop_check.py +5 -2
  29. package/hooks/runtime_paths.py +396 -0
  30. package/hooks/subagent_tracker.py +5 -2
  31. package/hooks/workflow_checkpoint.py +16 -3
  32. package/hooks/workflow_context.py +11 -3
  33. package/hooks/workflow_resume.py +7 -4
  34. package/mcp-server/breadcrumb.cjs +20 -2
  35. package/mcp-server/lib/archive-journal.cjs +1026 -111
  36. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  37. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  38. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  39. package/mcp-server/lib/change-packet.cjs +818 -0
  40. package/mcp-server/lib/change-workflow.cjs +932 -176
  41. package/mcp-server/lib/context-paths.cjs +111 -0
  42. package/mcp-server/lib/context-spine.cjs +511 -45
  43. package/mcp-server/lib/decision-dialogue.cjs +345 -3
  44. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  45. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  46. package/mcp-server/lib/doctor.cjs +373 -11
  47. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  48. package/mcp-server/lib/init-project.cjs +190 -95
  49. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  50. package/mcp-server/lib/plan-store.cjs +700 -5
  51. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  52. package/mcp-server/lib/projector.cjs +174 -40
  53. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  54. package/mcp-server/lib/safe-project-file.cjs +385 -0
  55. package/mcp-server/lib/spec-learning.cjs +28 -12
  56. package/mcp-server/lib/state-db.cjs +263 -9
  57. package/mcp-server/lib/state-ops.cjs +248 -49
  58. package/mcp-server/lib/telemetry.cjs +12 -3
  59. package/mcp-server/lib/workflow-state.cjs +610 -58
  60. package/mcp-server/server.cjs +278 -33
  61. package/orchestrator/daemon.cjs +2 -10
  62. package/orchestrator/parallel-orchestrator.cjs +314 -50
  63. package/orchestrator/planner/plan-builder.cjs +1 -1
  64. package/orchestrator/recovery.cjs +125 -7
  65. package/orchestrator/session-close-journal-worker.cjs +5 -0
  66. package/orchestrator/session-close-journal.cjs +1014 -0
  67. package/orchestrator/session-runner.cjs +679 -69
  68. package/orchestrator/worktree-manager.cjs +186 -35
  69. package/package.json +2 -3
  70. package/skills/cc-collab/SKILL.md +4 -1
  71. package/skills/codex-collab/SKILL.md +5 -2
  72. package/skills/ultra-change/SKILL.md +26 -8
  73. package/skills/ultra-deliver/SKILL.md +23 -11
  74. package/skills/ultra-dev/SKILL.md +33 -6
  75. package/skills/ultra-doctor/SKILL.md +6 -2
  76. package/skills/ultra-init/SKILL.md +4 -2
  77. package/skills/ultra-plan/SKILL.md +49 -16
  78. package/skills/ultra-research/SKILL.md +30 -27
  79. package/skills/ultra-review/SKILL.md +9 -5
  80. package/skills/ultra-status/SKILL.md +9 -4
  81. package/skills/ultra-test/SKILL.md +10 -5
  82. package/skills/ultra-think/SKILL.md +6 -2
  83. package/skills/ultra-think/references/decision-dialogue.md +17 -6
  84. package/skills/ultra-verify/SKILL.md +4 -1
  85. package/spec/cli-protocol.md +18 -7
  86. package/spec/mcp-tools.yaml +502 -44
  87. package/spec/schemas/change-delta.v1.schema.json +153 -0
  88. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  89. package/spec/schemas/mcp-tools.schema.json +1 -1
  90. package/spec/schemas/state-db.sql +50 -13
  91. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  92. package/ultra-tools/cli.cjs +3 -2
  93. package/ultra-tools/commands/db.cjs +76 -25
  94. package/ultra-tools/commands/migrate.cjs +995 -80
  95. package/ultra-tools/commands/session.cjs +34 -8
  96. package/ultra-tools/commands/status.cjs +32 -6
  97. package/ultra-tools/commands/system.cjs +129 -22
  98. package/commands/learn.md +0 -10
  99. package/orchestrator/skill-miner.cjs +0 -133
  100. package/output-styles/coding-vibes.md +0 -67
  101. package/output-styles/structural-thinking.md +0 -61
  102. package/skills/learn/SKILL.md +0 -58
@@ -2,14 +2,20 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { createHash } = require('node:crypto');
6
+ const Database = require('better-sqlite3');
5
7
 
6
8
  const {
7
9
  EXPECTED_VERSION, initStateDb, openStateDb, closeStateDb, MIGRATED_GAPS,
8
10
  } = require('../../mcp-server/lib/state-db.cjs');
9
11
  const ops = require('../../mcp-server/lib/state-ops.cjs');
10
12
  const projector = require('../../mcp-server/lib/projector.cjs');
13
+ const runtimePaths = require('../../mcp-server/lib/runtime-paths.cjs');
14
+ const contextPaths = require('../../mcp-server/lib/context-paths.cjs');
11
15
 
12
16
  const DEFAULT_FROM = '4.4';
17
+ const BACKUP_MANIFEST = 'backup-manifest.json';
18
+ const BACKUP_MANIFEST_KIND = 'ultra-projection-backup';
13
19
  const SUPPORTED_TRANSITIONS = Object.freeze({
14
20
  '4.4': '4.5',
15
21
  '4.5': EXPECTED_VERSION,
@@ -64,23 +70,148 @@ function readJsonOptional(p) {
64
70
  return JSON.parse(fs.readFileSync(p, 'utf8'));
65
71
  }
66
72
 
67
- function findContexts(rootDir) {
68
- const dir = path.join(rootDir, '.ultra', 'tasks', 'contexts');
73
+ function findContexts(rootDir, ultraDir = path.join(rootDir, '.ultra')) {
74
+ const dir = path.join(ultraDir, 'tasks', 'contexts');
69
75
  if (!fs.existsSync(dir)) return [];
70
76
  return fs.readdirSync(dir)
71
77
  .filter((f) => f.endsWith('.md'))
72
78
  .map((f) => path.join(dir, f));
73
79
  }
74
80
 
75
- function copyDirSync(src, dst) {
81
+ function lstatOrNull(candidate) {
82
+ try {
83
+ return fs.lstatSync(candidate);
84
+ } catch (error) {
85
+ if (error.code === 'ENOENT') return null;
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ function assertRealDirectory(candidate, label) {
91
+ const stat = lstatOrNull(candidate);
92
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
93
+ throw new Error(`migrate: ${label} must be a real directory: ${candidate}`);
94
+ }
95
+ return stat;
96
+ }
97
+
98
+ function assertRegularTree(root, label) {
99
+ assertRealDirectory(root, label);
100
+ const pending = fs.readdirSync(root).map((entry) => path.join(root, entry));
101
+ while (pending.length > 0) {
102
+ const candidate = pending.pop();
103
+ const stat = fs.lstatSync(candidate);
104
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
105
+ throw new Error(
106
+ `migrate: ${label} contains a symlink or non-regular entry: ${candidate}`,
107
+ );
108
+ }
109
+ if (stat.isDirectory()) {
110
+ for (const entry of fs.readdirSync(candidate)) {
111
+ pending.push(path.join(candidate, entry));
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ function copyDirSync(src, dst, {
118
+ copyFileSync = fs.copyFileSync,
119
+ excludeAuthorityRoot = true,
120
+ } = {}) {
121
+ assertRealDirectory(src, 'copy source');
76
122
  fs.mkdirSync(dst, { recursive: true });
77
123
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
78
124
  const s = path.join(src, entry.name);
79
125
  const d = path.join(dst, entry.name);
80
- if (entry.name === 'state.db' || entry.name === 'state.db-wal' || entry.name === 'state.db-shm') continue;
81
- if (/^backup-v[^/]+-/.test(entry.name)) continue;
82
- if (entry.isDirectory()) copyDirSync(s, d);
83
- else fs.copyFileSync(s, d);
126
+ if (excludeAuthorityRoot && entry.name === '.runtime') continue;
127
+ if (excludeAuthorityRoot
128
+ && ['state.db', 'state.db-wal', 'state.db-shm'].includes(entry.name)) continue;
129
+ if (excludeAuthorityRoot && /^backup-v[^/]+-/.test(entry.name)) continue;
130
+ if (entry.isDirectory()) {
131
+ copyDirSync(s, d, { copyFileSync, excludeAuthorityRoot: false });
132
+ }
133
+ else if (entry.isFile()) copyFileSync(s, d);
134
+ else {
135
+ throw new Error(
136
+ `migrate: copy source contains a symlink or non-regular entry: ${s}`,
137
+ );
138
+ }
139
+ }
140
+ }
141
+
142
+ function fileDigest(candidate) {
143
+ return createHash('sha256').update(fs.readFileSync(candidate)).digest('hex');
144
+ }
145
+
146
+ function regularTreeInventory(root, { excludeRootEntries = [] } = {}) {
147
+ assertRegularTree(root, 'inventory tree');
148
+ const inventory = [];
149
+ const excluded = new Set(excludeRootEntries);
150
+ const pending = fs.readdirSync(root)
151
+ .filter((entry) => !excluded.has(entry))
152
+ .map((entry) => path.join(root, entry));
153
+ while (pending.length > 0) {
154
+ const candidate = pending.pop();
155
+ const stat = fs.lstatSync(candidate);
156
+ const relative = path.relative(root, candidate).split(path.sep).join('/');
157
+ if (stat.isDirectory()) {
158
+ inventory.push({ path: `${relative}/`, kind: 'directory' });
159
+ for (const entry of fs.readdirSync(candidate)) pending.push(path.join(candidate, entry));
160
+ } else {
161
+ inventory.push({
162
+ path: relative,
163
+ kind: 'file',
164
+ size: stat.size,
165
+ sha256: fileDigest(candidate),
166
+ });
167
+ }
168
+ }
169
+ return inventory.sort((left, right) => left.path.localeCompare(right.path));
170
+ }
171
+
172
+ function jsonDigest(value) {
173
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
174
+ }
175
+
176
+ function backupInventory(root) {
177
+ return regularTreeInventory(root, { excludeRootEntries: [BACKUP_MANIFEST] });
178
+ }
179
+
180
+ function backupManifest(inventory, fromVersion, createdAt) {
181
+ return {
182
+ version: 1,
183
+ kind: BACKUP_MANIFEST_KIND,
184
+ source_version: String(fromVersion),
185
+ created_at: createdAt,
186
+ inventory,
187
+ inventory_sha256: jsonDigest(inventory),
188
+ };
189
+ }
190
+
191
+ function validateBackupManifestDocument(manifest, fromVersion, label) {
192
+ if (!manifest
193
+ || manifest.version !== 1
194
+ || manifest.kind !== BACKUP_MANIFEST_KIND
195
+ || manifest.source_version !== String(fromVersion)
196
+ || !Array.isArray(manifest.inventory)
197
+ || !/^[a-f0-9]{64}$/.test(manifest.inventory_sha256 || '')
198
+ || manifest.inventory_sha256 !== jsonDigest(manifest.inventory)) {
199
+ throw new Error(`migrate: ${label} has an invalid backup manifest`);
200
+ }
201
+ return {
202
+ manifest,
203
+ manifestDigest: jsonDigest(manifest),
204
+ inventoryDigest: manifest.inventory_sha256,
205
+ };
206
+ }
207
+
208
+ function projectionInventory(root) {
209
+ const stage = fs.mkdtempSync(path.join(path.dirname(root), '.inventory-stage-'));
210
+ try {
211
+ copyDirSync(root, stage);
212
+ return regularTreeInventory(stage);
213
+ } finally {
214
+ fs.rmSync(stage, { recursive: true, force: true });
84
215
  }
85
216
  }
86
217
 
@@ -95,17 +226,14 @@ function isoTimestamp(value, fallback) {
95
226
 
96
227
  function projectRelativeContextPath(sourceDir, value) {
97
228
  if (!value) return null;
98
- const normalized = String(value).replaceAll('\\', '/');
99
- if (normalized.startsWith('.ultra/')) return normalized;
100
- if (normalized.startsWith('contexts/')) return `.ultra/tasks/${normalized}`;
101
- if (path.isAbsolute(value)) {
102
- const relative = path.relative(sourceDir, value).replaceAll(path.sep, '/');
103
- if (relative.startsWith('../') || relative === '..') {
104
- throw new Error(`migrate: context_file escapes project root: ${value}`);
105
- }
106
- return relative;
229
+ try {
230
+ return contextPaths.resolveContextPath(sourceDir, value, {
231
+ allowLegacyAliases: true,
232
+ }).relative;
233
+ } catch (error) {
234
+ if (error.code !== 'CONTEXT_PATH_INVALID') throw error;
235
+ throw new Error(`migrate: context_file rejected: ${error.message}`, { cause: error });
107
236
  }
108
- return `.ultra/tasks/contexts/${path.basename(normalized)}`;
109
237
  }
110
238
 
111
239
  function sanitizeLegacyContextTemplate(sourceDir) {
@@ -153,8 +281,10 @@ function normalizeLegacyTask(task, tasksJson, contextHeaders, sourceDir) {
153
281
  };
154
282
  }
155
283
 
156
- function planForward(sourceDir, expectedFrom = DEFAULT_FROM) {
157
- const tasksPath = path.join(sourceDir, '.ultra', 'tasks', 'tasks.json');
284
+ function planForward(sourceDir, expectedFrom = DEFAULT_FROM, {
285
+ ultraDir = path.join(sourceDir, '.ultra'),
286
+ } = {}) {
287
+ const tasksPath = path.join(ultraDir, 'tasks', 'tasks.json');
158
288
  const tasksJson = readJsonOptional(tasksPath);
159
289
  if (!tasksJson || !Array.isArray(tasksJson.tasks)) {
160
290
  throw new Error(`migrate: tasks.json missing or malformed at ${tasksPath}`);
@@ -165,9 +295,18 @@ function planForward(sourceDir, expectedFrom = DEFAULT_FROM) {
165
295
  }
166
296
 
167
297
  const contextHeaders = {};
168
- for (const file of findContexts(sourceDir)) {
298
+ for (const file of findContexts(sourceDir, ultraDir)) {
169
299
  const fm = parseFrontmatter(fs.readFileSync(file, 'utf8'));
170
- if (fm && fm.task_id) contextHeaders[fm.task_id] = { ...fm, _file: file };
300
+ if (fm && fm.task_id) {
301
+ contextHeaders[fm.task_id] = {
302
+ ...fm,
303
+ _file: path.join(
304
+ sourceDir,
305
+ '.ultra',
306
+ path.relative(ultraDir, file),
307
+ ),
308
+ };
309
+ }
171
310
  }
172
311
 
173
312
  const warnings = [];
@@ -183,7 +322,7 @@ function planForward(sourceDir, expectedFrom = DEFAULT_FROM) {
183
322
  }
184
323
  }
185
324
 
186
- const eventsPath = path.join(sourceDir, '.ultra', 'activity-log.json');
325
+ const eventsPath = path.join(ultraDir, 'activity-log.json');
187
326
  const events = readJsonOptional(eventsPath);
188
327
  const eventList = Array.isArray(events) ? events : [];
189
328
  const tasks = tasksJson.tasks.map((task) => (
@@ -249,26 +388,800 @@ function recordMigration(db, { from, to, direction, status, notes }) {
249
388
 
250
389
  function ensureBackupName(sourceDir, fromVersion = DEFAULT_FROM) {
251
390
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
252
- return path.join(sourceDir, '.ultra', `backup-v${fromVersion}-${ts}`);
391
+ const parent = runtimePaths.pathsFor(sourceDir).backupsDir;
392
+ const prefix = `projection-v${fromVersion}-${ts}`;
393
+ let candidate = path.join(parent, prefix);
394
+ let attempt = 0;
395
+ while (lstatOrNull(candidate)) {
396
+ attempt += 1;
397
+ candidate = path.join(parent, `${prefix}-${attempt}`);
398
+ }
399
+ return candidate;
400
+ }
401
+
402
+ function createProjectionBackup(sourceDir, fromVersion, {
403
+ copyFileSync = fs.copyFileSync,
404
+ renameSync = fs.renameSync,
405
+ } = {}) {
406
+ const paths = runtimePaths.pathsFor(sourceDir);
407
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
408
+ const stage = path.join(sourceDir, `.ultra-migrate-stage-${token}`);
409
+ const finalPath = ensureBackupName(sourceDir, fromVersion);
410
+ try {
411
+ copyDirSync(paths.ultraDir, stage, { copyFileSync });
412
+ const expected = projectionInventory(paths.ultraDir);
413
+ const actual = regularTreeInventory(stage);
414
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
415
+ throw new Error('migrate: staged projection backup inventory does not match source');
416
+ }
417
+ writeJsonAtomic(
418
+ path.join(stage, BACKUP_MANIFEST),
419
+ backupManifest(actual, fromVersion, new Date().toISOString()),
420
+ );
421
+ assertRollbackBackup(sourceDir, stage, fromVersion, { staged: true });
422
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
423
+ renameSync(stage, finalPath);
424
+ return finalPath;
425
+ } catch (error) {
426
+ try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* recovery path remains */ }
427
+ throw error;
428
+ }
429
+ }
430
+
431
+ function prepareForwardSnapshot(sourceDir, fromVersion, {
432
+ afterBackup = null,
433
+ ...backupOptions
434
+ } = {}) {
435
+ const backupDir = createProjectionBackup(sourceDir, fromVersion, backupOptions);
436
+ if (afterBackup) afterBackup({ sourceDir, backupDir });
437
+ const bound = assertRollbackBackup(sourceDir, backupDir, fromVersion);
438
+ const current = projectionInventory(runtimePaths.pathsFor(sourceDir).ultraDir);
439
+ if (JSON.stringify(current) !== JSON.stringify(bound.manifest.inventory)) {
440
+ throw new Error(
441
+ 'migrate: semantic source changed after the immutable backup snapshot; replan required',
442
+ );
443
+ }
444
+ const plan = planForward(sourceDir, fromVersion, { ultraDir: backupDir });
445
+ return { backupDir, plan, manifest: bound.manifest };
446
+ }
447
+
448
+ function backupTimestampFromName(candidate) {
449
+ const match = path.basename(candidate).match(
450
+ /(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z(?:-\d+)?$/,
451
+ );
452
+ if (!match) return null;
453
+ const value = `${match[1]}:${match[2]}:${match[3]}.${match[4]}Z`;
454
+ const timestamp = Date.parse(value);
455
+ return Number.isFinite(timestamp) ? timestamp : null;
456
+ }
457
+
458
+ function backupTimestampFromMetadata(candidate) {
459
+ for (const name of ['.backup-metadata.json', 'backup-metadata.json']) {
460
+ const metadataPath = path.join(candidate, name);
461
+ const stat = lstatOrNull(metadataPath);
462
+ if (!stat) continue;
463
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
464
+ try {
465
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
466
+ const timestamp = Date.parse(metadata.created_at || metadata.createdAt || '');
467
+ if (Number.isFinite(timestamp)) return timestamp;
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+ return null;
473
+ }
474
+
475
+ function backupSortKey(candidate) {
476
+ const stat = fs.lstatSync(candidate);
477
+ return [
478
+ backupTimestampFromMetadata(candidate)
479
+ ?? backupTimestampFromName(candidate)
480
+ ?? stat.mtimeMs,
481
+ stat.mtimeMs,
482
+ candidate,
483
+ ];
253
484
  }
254
485
 
255
486
  function findLatestBackup(sourceDir, fromVersion = DEFAULT_FROM) {
256
- const ultra = path.join(sourceDir, '.ultra');
257
- if (!fs.existsSync(ultra)) return null;
258
- const prefix = `backup-v${fromVersion}-`;
259
- const candidates = fs.readdirSync(ultra)
260
- .filter((n) => n.startsWith(prefix))
261
- .sort();
262
- return candidates.length === 0 ? null : path.join(ultra, candidates[candidates.length - 1]);
487
+ runtimePaths.validateProjectLayout(sourceDir, {
488
+ env: {},
489
+ validateRuntimeTree: true,
490
+ });
491
+ const roots = [
492
+ {
493
+ dir: runtimePaths.pathsFor(sourceDir).backupsDir,
494
+ prefix: `projection-v${fromVersion}-`,
495
+ },
496
+ {
497
+ dir: path.join(sourceDir, '.ultra'),
498
+ prefix: `backup-v${fromVersion}-`,
499
+ },
500
+ ];
501
+ const candidates = roots.flatMap(({ dir, prefix }) => (
502
+ fs.existsSync(dir)
503
+ ? fs.readdirSync(dir)
504
+ .filter((name) => name.startsWith(prefix))
505
+ .map((name) => path.join(dir, name))
506
+ : []
507
+ ));
508
+ const valid = [];
509
+ const invalid = [];
510
+ for (const candidate of candidates) {
511
+ try {
512
+ assertRollbackBackup(sourceDir, candidate, fromVersion);
513
+ valid.push(candidate);
514
+ } catch (error) {
515
+ invalid.push(error);
516
+ }
517
+ }
518
+ if (valid.length === 0) {
519
+ if (invalid.length > 0) throw invalid[0];
520
+ return null;
521
+ }
522
+ valid.sort((left, right) => {
523
+ const a = backupSortKey(left);
524
+ const b = backupSortKey(right);
525
+ return a[0] - b[0] || a[1] - b[1] || a[2].localeCompare(b[2]);
526
+ });
527
+ return valid.at(-1);
528
+ }
529
+
530
+ function assertRollbackBackup(sourceDir, backupDir, fromVersion, { staged = false } = {}) {
531
+ const paths = runtimePaths.validateProjectLayout(sourceDir, { env: {} });
532
+ if (!staged) {
533
+ const allowedParents = [
534
+ paths.backupsDir,
535
+ paths.ultraDir,
536
+ ];
537
+ const parent = path.dirname(path.resolve(backupDir));
538
+ if (!allowedParents.includes(parent)) {
539
+ throw new Error(`migrate: rollback backup is outside a managed backup root: ${backupDir}`);
540
+ }
541
+ if (parent === paths.backupsDir) {
542
+ assertRealDirectory(paths.backupsDir, 'runtime backup root');
543
+ }
544
+ }
545
+ assertRegularTree(backupDir, 'rollback backup');
546
+ for (const entry of fs.readdirSync(backupDir, { withFileTypes: true })) {
547
+ const candidate = path.join(backupDir, entry.name);
548
+ if (entry.name === '.runtime'
549
+ || entry.name === 'state.db'
550
+ || entry.name === 'state.db-wal'
551
+ || entry.name === 'state.db-shm') {
552
+ throw new Error(`migrate: rollback backup contains a runtime authority entry: ${candidate}`);
553
+ }
554
+ }
555
+ // Traversing the source with the same copier used for staging proves every
556
+ // nested entry is a real directory or regular file before current state moves.
557
+ const tasksPath = path.join(backupDir, 'tasks', 'tasks.json');
558
+ const tasksStat = lstatOrNull(tasksPath);
559
+ if (!tasksStat || tasksStat.isSymbolicLink() || !tasksStat.isFile()) {
560
+ throw new Error(`migrate: rollback backup has no regular tasks/tasks.json: ${tasksPath}`);
561
+ }
562
+ const tasks = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
563
+ const version = String(tasks.version || tasks.schema_version || '');
564
+ if (version !== String(fromVersion) || !Array.isArray(tasks.tasks)) {
565
+ throw new Error(
566
+ `migrate: rollback backup is not a valid v${fromVersion} projection snapshot`,
567
+ );
568
+ }
569
+ const inventory = backupInventory(backupDir);
570
+ const manifestPath = path.join(backupDir, BACKUP_MANIFEST);
571
+ const manifestStat = lstatOrNull(manifestPath);
572
+ const requiresManifest = path.basename(backupDir).startsWith(`projection-v${fromVersion}-`);
573
+ let manifest;
574
+ if (manifestStat) {
575
+ if (manifestStat.isSymbolicLink() || !manifestStat.isFile()) {
576
+ throw new Error(`migrate: rollback backup manifest is unsafe: ${manifestPath}`);
577
+ }
578
+ try {
579
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
580
+ } catch (error) {
581
+ throw new Error(`migrate: rollback backup manifest is malformed: ${manifestPath}`, {
582
+ cause: error,
583
+ });
584
+ }
585
+ } else if (requiresManifest) {
586
+ throw new Error(`migrate: projection backup manifest is missing: ${manifestPath}`);
587
+ } else {
588
+ // Legacy backup-v snapshots predate cryptographic manifests. Adopt their
589
+ // complete current image once, before mutation, and bind that inventory to
590
+ // the new durable recovery journal.
591
+ manifest = backupManifest(
592
+ inventory,
593
+ fromVersion,
594
+ new Date(fs.lstatSync(backupDir).mtimeMs).toISOString(),
595
+ );
596
+ }
597
+ const admitted = validateBackupManifestDocument(
598
+ manifest,
599
+ fromVersion,
600
+ 'rollback backup',
601
+ );
602
+ if (JSON.stringify(inventory) !== JSON.stringify(manifest.inventory)) {
603
+ throw new Error(
604
+ `migrate: rollback backup inventory does not match its manifest: ${backupDir}`,
605
+ );
606
+ }
607
+ return {
608
+ paths,
609
+ manifest: admitted.manifest,
610
+ manifestDigest: admitted.manifestDigest,
611
+ inventoryDigest: admitted.inventoryDigest,
612
+ manifestPath: manifestStat ? manifestPath : null,
613
+ };
614
+ }
615
+
616
+ function assertCurrentSemanticTree(ultraDir) {
617
+ assertRealDirectory(ultraDir, '.ultra');
618
+ const pending = fs.readdirSync(ultraDir, { withFileTypes: true })
619
+ .filter((entry) => entry.name !== '.runtime'
620
+ && entry.name !== 'state.db'
621
+ && entry.name !== 'state.db-wal'
622
+ && entry.name !== 'state.db-shm')
623
+ .map((entry) => path.join(ultraDir, entry.name));
624
+ while (pending.length > 0) {
625
+ const candidate = pending.pop();
626
+ const stat = fs.lstatSync(candidate);
627
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
628
+ throw new Error(
629
+ `migrate: current semantic tree contains a symlink or non-regular entry: ${candidate}`,
630
+ );
631
+ }
632
+ if (stat.isDirectory()) {
633
+ for (const entry of fs.readdirSync(candidate)) {
634
+ pending.push(path.join(candidate, entry));
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ function writeJsonAtomic(file, value) {
641
+ fs.mkdirSync(path.dirname(file), { recursive: true });
642
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
643
+ try {
644
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
645
+ fs.renameSync(tmp, file);
646
+ } catch (error) {
647
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
648
+ throw error;
649
+ }
650
+ }
651
+
652
+ function updateRollbackJournal(journalPath, patch) {
653
+ const current = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
654
+ const next = {
655
+ ...current,
656
+ ...patch,
657
+ updated_at: new Date().toISOString(),
658
+ };
659
+ writeJsonAtomic(journalPath, next);
660
+ return next;
661
+ }
662
+
663
+ function rollbackRecoveryBase(paths) {
664
+ return path.join(paths.runtimeDir, 'recovery', 'migrate-rollback');
665
+ }
666
+
667
+ function assertRollbackRecoveryJournal(sourceDir, paths, recoveryDir, journal) {
668
+ const resolvedRecovery = path.resolve(recoveryDir);
669
+ const recoveryBase = path.resolve(rollbackRecoveryBase(paths));
670
+ if (path.dirname(resolvedRecovery) !== recoveryBase) {
671
+ throw new Error(`migrate: rollback recovery is outside its managed root: ${recoveryDir}`);
672
+ }
673
+ if (!journal || journal.version !== 1
674
+ || path.resolve(journal.source_dir || '') !== path.resolve(sourceDir)
675
+ || path.resolve(journal.recovery_dir || '') !== resolvedRecovery
676
+ || path.dirname(path.resolve(journal.stage_dir || '')) !== path.resolve(paths.runtimeDir)
677
+ || !path.basename(journal.stage_dir || '').startsWith('.rollback-stage-')
678
+ || path.resolve(journal.backup_manifest_path || '')
679
+ !== path.resolve(recoveryDir, BACKUP_MANIFEST)
680
+ || !/^[a-f0-9]{64}$/.test(journal.backup_manifest_sha256 || '')
681
+ || !/^[a-f0-9]{64}$/.test(journal.backup_inventory_sha256 || '')
682
+ || ![
683
+ 'prepared',
684
+ 'installed',
685
+ 'cleanup_pending',
686
+ 'complete',
687
+ 'rolled_back',
688
+ 'recovery_failed',
689
+ ].includes(journal.phase)) {
690
+ throw new Error(`migrate: invalid rollback recovery journal: ${recoveryDir}`);
691
+ }
692
+ const hasStateInventory = journal.previous_state_inventory !== undefined
693
+ || journal.previous_state_inventory_sha256 !== undefined;
694
+ if (hasStateInventory) {
695
+ if (!Array.isArray(journal.previous_state_inventory)
696
+ || !/^[a-f0-9]{64}$/.test(journal.previous_state_inventory_sha256 || '')
697
+ || jsonDigest(journal.previous_state_inventory)
698
+ !== journal.previous_state_inventory_sha256) {
699
+ throw new Error(`migrate: invalid rollback state inventory: ${recoveryDir}`);
700
+ }
701
+ const actual = regularTreeInventory(path.join(recoveryDir, 'previous-state'));
702
+ if (JSON.stringify(actual) !== JSON.stringify(journal.previous_state_inventory)) {
703
+ throw new Error(`migrate: rollback state snapshot changed after publication: ${recoveryDir}`);
704
+ }
705
+ }
706
+ return journal;
707
+ }
708
+
709
+ function boundRecoveryManifest(recoveryDir, journal) {
710
+ const manifestPath = path.join(recoveryDir, BACKUP_MANIFEST);
711
+ const stat = lstatOrNull(manifestPath);
712
+ if (!stat || stat.isSymbolicLink() || !stat.isFile()) {
713
+ throw new Error(`migrate: bound recovery manifest is missing or unsafe: ${manifestPath}`);
714
+ }
715
+ let manifest;
716
+ try {
717
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
718
+ } catch (error) {
719
+ throw new Error(`migrate: bound recovery manifest is malformed: ${manifestPath}`, {
720
+ cause: error,
721
+ });
722
+ }
723
+ const admitted = validateBackupManifestDocument(
724
+ manifest,
725
+ journal.from_version,
726
+ 'bound recovery',
727
+ );
728
+ if (admitted.manifestDigest !== journal.backup_manifest_sha256
729
+ || admitted.inventoryDigest !== journal.backup_inventory_sha256) {
730
+ throw new Error('migrate: recovery manifest digest does not match its journal binding');
731
+ }
732
+ return admitted;
733
+ }
734
+
735
+ function assertInstalledRollback(sourceDir, paths, journal) {
736
+ const bound = boundRecoveryManifest(journal.recovery_dir, journal);
737
+ assertCurrentSemanticTree(paths.ultraDir);
738
+ const installedInventory = projectionInventory(paths.ultraDir);
739
+ if (JSON.stringify(installedInventory) !== JSON.stringify(bound.manifest.inventory)) {
740
+ throw new Error('migrate: installed rollback inventory does not match its bound manifest');
741
+ }
742
+ const tasksPath = path.join(paths.ultraDir, 'tasks', 'tasks.json');
743
+ const tasks = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
744
+ const version = String(tasks.version || tasks.schema_version || '');
745
+ if (version !== String(journal.from_version)) {
746
+ throw new Error(
747
+ `migrate: installed rollback does not match v${journal.from_version}: ${tasksPath}`,
748
+ );
749
+ }
750
+ for (const candidate of [
751
+ paths.stateDbPath,
752
+ `${paths.stateDbPath}-wal`,
753
+ `${paths.stateDbPath}-shm`,
754
+ paths.legacyStateDbPath,
755
+ `${paths.legacyStateDbPath}-wal`,
756
+ `${paths.legacyStateDbPath}-shm`,
757
+ ]) {
758
+ if (lstatOrNull(candidate)) {
759
+ throw new Error(`migrate: installed rollback still exposes state authority: ${candidate}`);
760
+ }
761
+ }
762
+ return sourceDir;
763
+ }
764
+
765
+ function resumeRollbackRecovery(sourceDir, io = {}) {
766
+ const rmSync = io.rmSync || fs.rmSync;
767
+ const paths = runtimePaths.validateProjectLayout(sourceDir, {
768
+ env: {},
769
+ validateRuntimeTree: true,
770
+ });
771
+ const base = rollbackRecoveryBase(paths);
772
+ const stat = lstatOrNull(base);
773
+ if (!stat) return { resumed: 0, pending: 0, recoveries: [] };
774
+ assertRealDirectory(base, 'rollback recovery root');
775
+ const recoveries = [];
776
+ let pending = 0;
777
+ for (const name of fs.readdirSync(base).sort()) {
778
+ const recoveryDir = path.join(base, name);
779
+ assertRealDirectory(recoveryDir, 'rollback recovery');
780
+ const journalPath = path.join(recoveryDir, 'journal.json');
781
+ const journalStat = lstatOrNull(journalPath);
782
+ if (!journalStat || journalStat.isSymbolicLink() || !journalStat.isFile()) {
783
+ throw new Error(`migrate: rollback recovery journal is missing or unsafe: ${journalPath}`);
784
+ }
785
+ const journal = assertRollbackRecoveryJournal(
786
+ sourceDir,
787
+ paths,
788
+ recoveryDir,
789
+ JSON.parse(fs.readFileSync(journalPath, 'utf8')),
790
+ );
791
+ if (journal.phase === 'complete' || journal.phase === 'rolled_back') continue;
792
+ if (!['installed', 'cleanup_pending'].includes(journal.phase)) {
793
+ pending += 1;
794
+ recoveries.push({
795
+ recovery_dir: recoveryDir,
796
+ phase: journal.phase,
797
+ resumed: false,
798
+ });
799
+ continue;
800
+ }
801
+ assertInstalledRollback(sourceDir, paths, journal);
802
+ if (lstatOrNull(journal.stage_dir)) {
803
+ rmSync(journal.stage_dir, { recursive: true, force: true });
804
+ }
805
+ updateRollbackJournal(journalPath, {
806
+ phase: 'complete',
807
+ cleanup_error: null,
808
+ completed_at: new Date().toISOString(),
809
+ });
810
+ recoveries.push({
811
+ recovery_dir: recoveryDir,
812
+ phase: 'complete',
813
+ resumed: true,
814
+ });
815
+ }
816
+ return {
817
+ resumed: recoveries.filter((entry) => entry.resumed).length,
818
+ pending,
819
+ recoveries,
820
+ };
821
+ }
822
+
823
+ function sqliteFile(candidate) {
824
+ const stat = lstatOrNull(candidate);
825
+ if (!stat || stat.isSymbolicLink() || !stat.isFile()) return false;
826
+ const descriptor = fs.openSync(candidate, 'r');
827
+ try {
828
+ const header = Buffer.alloc(16);
829
+ return fs.readSync(descriptor, header, 0, header.length, 0) === header.length
830
+ && header.equals(Buffer.from('SQLite format 3\0'));
831
+ } finally {
832
+ fs.closeSync(descriptor);
833
+ }
834
+ }
835
+
836
+ function quoteSqlString(value) {
837
+ return `'${String(value).replaceAll("'", "''")}'`;
838
+ }
839
+
840
+ function immutableStateSnapshot(resolvedDb, previousState, {
841
+ onSnapshot = () => {},
842
+ afterVacuumBeforeFence = () => {},
843
+ maxAttempts = 5,
844
+ } = {}) {
845
+ const copied = [];
846
+ const main = lstatOrNull(resolvedDb);
847
+ if (!main) {
848
+ onSnapshot([], { source_data_version: null, snapshot_attempts: 0 });
849
+ return copied;
850
+ }
851
+ if (main.isSymbolicLink() || !main.isFile()) {
852
+ throw new Error(`migrate: current state entry is unsafe: ${resolvedDb}`);
853
+ }
854
+ let liveDb = null;
855
+ let exclusive = false;
856
+ let sourceDataVersion = null;
857
+ let snapshotAttempts = 0;
858
+ try {
859
+ const snapshot = path.join(previousState, 'state.db');
860
+ if (sqliteFile(resolvedDb)) {
861
+ liveDb = new Database(resolvedDb, { fileMustExist: true });
862
+ liveDb.pragma('busy_timeout = 0');
863
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
864
+ snapshotAttempts = attempt;
865
+ const before = Number(liveDb.pragma('data_version', { simple: true }));
866
+ fs.rmSync(snapshot, { force: true });
867
+ // VACUUM cannot run inside the exclusive transaction. Bind its read
868
+ // image to the later writer fence with data_version: if any other
869
+ // connection commits in this gap, discard the candidate and retry.
870
+ liveDb.exec(`VACUUM INTO ${quoteSqlString(snapshot)}`);
871
+ afterVacuumBeforeFence({ attempt, source_data_version: before });
872
+ liveDb.exec('BEGIN EXCLUSIVE');
873
+ exclusive = true;
874
+ const fenced = Number(liveDb.pragma('data_version', { simple: true }));
875
+ if (fenced === before) {
876
+ sourceDataVersion = fenced;
877
+ break;
878
+ }
879
+ liveDb.exec('ROLLBACK');
880
+ exclusive = false;
881
+ fs.rmSync(snapshot, { force: true });
882
+ }
883
+ if (!exclusive) {
884
+ throw new Error(
885
+ `migrate: current state changed during ${maxAttempts} snapshot attempts`,
886
+ );
887
+ }
888
+ } else {
889
+ throw new Error(`migrate: current state is not a SQLite database: ${resolvedDb}`);
890
+ }
891
+ const verification = new Database(snapshot, { readonly: true, fileMustExist: true });
892
+ try {
893
+ const integrity = verification.pragma('integrity_check');
894
+ if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok') {
895
+ throw new Error('migrate: current state recovery snapshot failed integrity_check');
896
+ }
897
+ } finally {
898
+ verification.close();
899
+ }
900
+ fs.chmodSync(snapshot, 0o444);
901
+ copied.push({ current: resolvedDb, prior: snapshot, kind: 'sqlite' });
902
+ const inventory = regularTreeInventory(previousState);
903
+ onSnapshot(inventory, {
904
+ source_data_version: sourceDataVersion,
905
+ snapshot_attempts: snapshotAttempts,
906
+ });
907
+ for (const suffix of ['-wal', '-shm', '']) {
908
+ fs.rmSync(`${resolvedDb}${suffix}`, { force: true });
909
+ }
910
+ if (exclusive) {
911
+ try { liveDb.exec('ROLLBACK'); } catch { /* the unlinked inode is already fenced */ }
912
+ exclusive = false;
913
+ }
914
+ } finally {
915
+ if (liveDb) {
916
+ if (exclusive) {
917
+ try { liveDb.exec('ROLLBACK'); } catch { /* best effort */ }
918
+ }
919
+ liveDb.close();
920
+ }
921
+ }
922
+ return copied;
923
+ }
924
+
925
+ function rollbackFromBackupLocked({
926
+ sourceDir,
927
+ dbPath,
928
+ backupDir,
929
+ fromVersion,
930
+ toVersion,
931
+ }, io = {}) {
932
+ const copyFileSync = io.copyFileSync || fs.copyFileSync;
933
+ const renameSync = io.renameSync || fs.renameSync;
934
+ const rmSync = io.rmSync || fs.rmSync;
935
+ const afterPublish = io.afterPublish || (() => {});
936
+ const backupAdmission = assertRollbackBackup(sourceDir, backupDir, fromVersion);
937
+ const paths = backupAdmission.paths;
938
+ const resolvedDb = path.resolve(dbPath);
939
+ if (![paths.stateDbPath, paths.legacyStateDbPath].includes(resolvedDb)) {
940
+ throw new Error(
941
+ `migrate: rollback state.db must be project-owned: ${resolvedDb}`,
942
+ );
943
+ }
944
+ assertCurrentSemanticTree(paths.ultraDir);
945
+
946
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
947
+ const stageRoot = path.join(paths.runtimeDir, `.rollback-stage-${token}`);
948
+ const restoreRoot = path.join(stageRoot, 'restore');
949
+ const recoveryDir = path.join(rollbackRecoveryBase(paths), token);
950
+ const previousSemantic = path.join(recoveryDir, 'previous-semantic');
951
+ const previousState = path.join(recoveryDir, 'previous-state');
952
+ const journalPath = path.join(recoveryDir, 'journal.json');
953
+ const movedCurrent = [];
954
+ const installed = [];
955
+ const movedState = [];
956
+ const cleanupStage = () => {
957
+ rmSync(stageRoot, { recursive: true, force: true });
958
+ };
959
+ let published = false;
960
+ try {
961
+ if (!lstatOrNull(paths.runtimeDir)) {
962
+ fs.mkdirSync(paths.runtimeDir);
963
+ }
964
+ fs.mkdirSync(stageRoot, { recursive: false });
965
+
966
+ // Complete and validate the restore image before moving any current file.
967
+ copyDirSync(backupDir, restoreRoot, { copyFileSync });
968
+ if (!lstatOrNull(path.join(restoreRoot, BACKUP_MANIFEST))) {
969
+ writeJsonAtomic(path.join(restoreRoot, BACKUP_MANIFEST), backupAdmission.manifest);
970
+ }
971
+ const stagedAdmission = assertRollbackBackup(
972
+ sourceDir,
973
+ restoreRoot,
974
+ fromVersion,
975
+ { staged: true },
976
+ );
977
+ if (stagedAdmission.manifestDigest !== backupAdmission.manifestDigest
978
+ || stagedAdmission.inventoryDigest !== backupAdmission.inventoryDigest) {
979
+ throw new Error('migrate: staged rollback image does not match the admitted backup manifest');
980
+ }
981
+ fs.mkdirSync(recoveryDir, { recursive: true });
982
+ fs.mkdirSync(previousSemantic);
983
+ fs.mkdirSync(previousState);
984
+ const recoveryManifestPath = path.join(recoveryDir, BACKUP_MANIFEST);
985
+ writeJsonAtomic(recoveryManifestPath, stagedAdmission.manifest);
986
+ const now = new Date().toISOString();
987
+ let preparedJournal = {
988
+ version: 1,
989
+ source_dir: path.resolve(sourceDir),
990
+ from_version: fromVersion,
991
+ to_version: toVersion,
992
+ backup_dir: path.resolve(backupDir),
993
+ backup_manifest_path: recoveryManifestPath,
994
+ backup_manifest_sha256: stagedAdmission.manifestDigest,
995
+ backup_inventory_sha256: stagedAdmission.inventoryDigest,
996
+ stage_dir: stageRoot,
997
+ recovery_dir: recoveryDir,
998
+ phase: 'prepared',
999
+ cleanup_error: null,
1000
+ created_at: now,
1001
+ updated_at: now,
1002
+ };
1003
+ writeJsonAtomic(journalPath, preparedJournal);
1004
+
1005
+ if (runtimePaths.isManagedLegacyStateTombstone?.(paths.legacyStateDbPath)) {
1006
+ const prior = path.join(previousState, 'legacy-state-tombstone');
1007
+ copyFileSync(paths.legacyStateDbPath, prior);
1008
+ fs.chmodSync(prior, 0o444);
1009
+ fs.rmSync(paths.legacyStateDbPath);
1010
+ movedState.push({
1011
+ current: paths.legacyStateDbPath,
1012
+ prior,
1013
+ kind: 'tombstone',
1014
+ });
1015
+ }
1016
+
1017
+ movedState.push(...immutableStateSnapshot(resolvedDb, previousState, {
1018
+ afterVacuumBeforeFence: io.afterStateSnapshotBeforeFence,
1019
+ onSnapshot(inventory, snapshotEvidence) {
1020
+ preparedJournal = updateRollbackJournal(journalPath, {
1021
+ previous_state_inventory: inventory,
1022
+ previous_state_inventory_sha256: jsonDigest(inventory),
1023
+ previous_state_data_version: snapshotEvidence.source_data_version,
1024
+ previous_state_snapshot_attempts: snapshotEvidence.snapshot_attempts,
1025
+ });
1026
+ },
1027
+ }));
1028
+
1029
+ for (const entry of fs.readdirSync(paths.ultraDir, { withFileTypes: true })) {
1030
+ if (entry.name === '.runtime'
1031
+ || entry.name === 'state.db'
1032
+ || entry.name === 'state.db-wal'
1033
+ || entry.name === 'state.db-shm'
1034
+ || path.resolve(paths.ultraDir, entry.name) === path.resolve(backupDir)) {
1035
+ continue;
1036
+ }
1037
+ const current = path.join(paths.ultraDir, entry.name);
1038
+ const prior = path.join(previousSemantic, entry.name);
1039
+ renameSync(current, prior);
1040
+ movedCurrent.push({ current, prior });
1041
+ }
1042
+
1043
+ for (const entry of fs.readdirSync(restoreRoot)) {
1044
+ if (entry === BACKUP_MANIFEST) continue;
1045
+ const staged = path.join(restoreRoot, entry);
1046
+ const target = path.join(paths.ultraDir, entry);
1047
+ renameSync(staged, target);
1048
+ installed.push({ staged, target });
1049
+ }
1050
+
1051
+ assertInstalledRollback(sourceDir, paths, preparedJournal);
1052
+ updateRollbackJournal(journalPath, {
1053
+ phase: 'installed',
1054
+ installed_at: new Date().toISOString(),
1055
+ });
1056
+ published = true;
1057
+ // This seam also models a process interruption after the rollback is
1058
+ // authoritative but before disposable staging cleanup begins. Recovery
1059
+ // must trust the installed tree plus the independent journal, never the
1060
+ // potentially partial staging directory.
1061
+ afterPublish();
1062
+ } catch (error) {
1063
+ if (published) throw error;
1064
+ const rollbackErrors = [];
1065
+ for (const item of [...installed].reverse()) {
1066
+ try {
1067
+ fs.mkdirSync(path.dirname(item.staged), { recursive: true });
1068
+ renameSync(item.target, item.staged);
1069
+ } catch (rollbackError) {
1070
+ rollbackErrors.push(`${item.target}: ${rollbackError.message}`);
1071
+ }
1072
+ }
1073
+ for (const item of [...movedCurrent].reverse()) {
1074
+ try {
1075
+ fs.mkdirSync(path.dirname(item.current), { recursive: true });
1076
+ renameSync(item.prior, item.current);
1077
+ } catch (rollbackError) {
1078
+ rollbackErrors.push(`${item.prior}: ${rollbackError.message}`);
1079
+ }
1080
+ }
1081
+ for (const item of [...movedState].reverse()) {
1082
+ try {
1083
+ fs.mkdirSync(path.dirname(item.current), { recursive: true });
1084
+ copyFileSync(item.prior, item.current);
1085
+ fs.chmodSync(item.current, item.kind === 'tombstone' ? 0o444 : 0o600);
1086
+ } catch (rollbackError) {
1087
+ rollbackErrors.push(`${item.prior}: ${rollbackError.message}`);
1088
+ }
1089
+ }
1090
+ if (rollbackErrors.length === 0) {
1091
+ try { cleanupStage(); }
1092
+ catch (cleanupError) {
1093
+ rollbackErrors.push(`${stageRoot}: ${cleanupError.message}`);
1094
+ }
1095
+ }
1096
+ if (rollbackErrors.length > 0) {
1097
+ if (lstatOrNull(journalPath)) {
1098
+ try {
1099
+ updateRollbackJournal(journalPath, {
1100
+ phase: 'recovery_failed',
1101
+ rollback_error: rollbackErrors.join('; '),
1102
+ });
1103
+ } catch { /* preserve original rollback evidence */ }
1104
+ }
1105
+ throw new Error(
1106
+ `${error.message}; rollback restore failed: ${rollbackErrors.join('; ')}`,
1107
+ { cause: error },
1108
+ );
1109
+ }
1110
+ if (lstatOrNull(journalPath)) {
1111
+ updateRollbackJournal(journalPath, {
1112
+ phase: 'rolled_back',
1113
+ rollback_error: null,
1114
+ });
1115
+ }
1116
+ throw error;
1117
+ }
1118
+
1119
+ let cleanupError = null;
1120
+ try {
1121
+ cleanupStage();
1122
+ } catch (error) {
1123
+ cleanupError = error;
1124
+ updateRollbackJournal(journalPath, {
1125
+ phase: 'cleanup_pending',
1126
+ cleanup_error: error.message,
1127
+ });
1128
+ }
1129
+ if (!cleanupError) {
1130
+ updateRollbackJournal(journalPath, {
1131
+ phase: 'complete',
1132
+ cleanup_error: null,
1133
+ completed_at: new Date().toISOString(),
1134
+ });
1135
+ }
1136
+ return {
1137
+ mode: 'rollback',
1138
+ from: toVersion,
1139
+ to: fromVersion,
1140
+ backup_dir: backupDir,
1141
+ source_dir: sourceDir,
1142
+ recovery_dir: recoveryDir,
1143
+ journal_path: journalPath,
1144
+ stage_dir: stageRoot,
1145
+ cleanup_pending: Boolean(cleanupError),
1146
+ ...(cleanupError ? { cleanup_error: cleanupError.message } : {}),
1147
+ };
1148
+ }
1149
+
1150
+ function rollbackFromBackup(input, io = {}) {
1151
+ const paths = runtimePaths.validateProjectLayout(input.sourceDir, {
1152
+ env: {},
1153
+ forMutation: true,
1154
+ validateRuntimeTree: true,
1155
+ });
1156
+ fs.mkdirSync(paths.runtimeDir, { recursive: true });
1157
+ const releaseGate = runtimePaths._internal.acquireStateMigrationGate(paths);
1158
+ try {
1159
+ return rollbackFromBackupLocked(input, io);
1160
+ } finally {
1161
+ releaseGate();
1162
+ }
263
1163
  }
264
1164
 
265
1165
  function cmdForward(flags) {
266
1166
  const sourceDir = path.resolve(flags.sourceDir || '.');
267
- const dbPath = path.resolve(flags.dbPath || path.join(sourceDir, '.ultra', 'state.db'));
268
1167
  const fromVersion = flags.from || DEFAULT_FROM;
269
1168
  const toVersion = flags.to || SUPPORTED_TRANSITIONS[fromVersion];
270
1169
  let plan;
1170
+ let dbPath;
271
1171
  try {
1172
+ const paths = runtimePaths.validateProjectLayout(sourceDir, {
1173
+ env: {},
1174
+ validateRuntimeTree: true,
1175
+ });
1176
+ dbPath = flags.dbPath ? path.resolve(flags.dbPath) : paths.stateDbPath;
1177
+ if (dbPath !== paths.stateDbPath) {
1178
+ throw new Error(
1179
+ `migrate: state.db destination must be the project canonical authority: ${paths.stateDbPath}`,
1180
+ );
1181
+ }
1182
+ assertCurrentSemanticTree(paths.ultraDir);
1183
+ // Validate the live source before creating any backup. Apply mode discards
1184
+ // this provisional plan and rebuilds it from the immutable backup image.
272
1185
  plan = planForward(sourceDir, fromVersion);
273
1186
  } catch (err) {
274
1187
  emit({ ok: false, error: { code: 'MIGRATE_FAILED', message: err.message, retriable: false } });
@@ -292,13 +1205,15 @@ function cmdForward(flags) {
292
1205
  return 0;
293
1206
  }
294
1207
 
295
- // Real run: backup the entire .ultra subtree first.
296
- const backupDir = ensureBackupName(sourceDir, fromVersion);
297
- const ultraSource = path.join(sourceDir, '.ultra');
298
- copyDirSync(ultraSource, backupDir);
299
-
300
1208
  let db;
1209
+ let backupDir;
301
1210
  try {
1211
+ // Validate and publish the complete projection snapshot before authority
1212
+ // migration can relocate any legacy DB or mutable runtime artifact.
1213
+ const prepared = prepareForwardSnapshot(sourceDir, fromVersion);
1214
+ backupDir = prepared.backupDir;
1215
+ plan = prepared.plan;
1216
+ dbPath = runtimePaths.ensureRuntimeState(sourceDir).stateDbPath;
302
1217
  db = initStateDb(dbPath).db;
303
1218
  const counts = ops.tx(db, () => {
304
1219
  const existing = db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count;
@@ -363,58 +1278,54 @@ function cmdForward(flags) {
363
1278
 
364
1279
  function cmdRollback(flags) {
365
1280
  const sourceDir = path.resolve(flags.sourceDir || '.');
366
- const dbPath = path.resolve(flags.dbPath || path.join(sourceDir, '.ultra', 'state.db'));
367
1281
  const fromVersion = flags.from || DEFAULT_FROM;
368
1282
  const toVersion = flags.to || SUPPORTED_TRANSITIONS[fromVersion];
369
- const backupDir = findLatestBackup(sourceDir, fromVersion);
370
- if (!backupDir) {
371
- emit({ ok: false, error: { code: 'NO_BACKUP', message: `no backup-v${fromVersion}-* directory found` } });
372
- return 2;
373
- }
374
-
375
- // Record rollback BEFORE removing the db; if the file is gone we still want an audit row.
376
- let db;
377
1283
  try {
378
- if (fs.existsSync(dbPath)) {
379
- db = openStateDb(dbPath);
380
- recordMigration(db, {
381
- from: toVersion,
382
- to: fromVersion,
383
- direction: 'rollback',
384
- status: 'success',
385
- notes: `restored from ${backupDir}`,
1284
+ const resumed = resumeRollbackRecovery(sourceDir);
1285
+ if (resumed.pending > 0) {
1286
+ throw new Error(
1287
+ `migrate: ${resumed.pending} rollback recovery transaction(s) require manual recovery`,
1288
+ );
1289
+ }
1290
+ if (resumed.resumed > 0) {
1291
+ emit({
1292
+ ok: true,
1293
+ data: {
1294
+ mode: 'rollback-resumed',
1295
+ from: toVersion,
1296
+ to: fromVersion,
1297
+ source_dir: sourceDir,
1298
+ ...resumed,
1299
+ },
386
1300
  });
387
- closeStateDb(db);
388
- db = null;
389
- fs.unlinkSync(dbPath);
390
- const wal = `${dbPath}-wal`;
391
- const shm = `${dbPath}-shm`;
392
- if (fs.existsSync(wal)) fs.unlinkSync(wal);
393
- if (fs.existsSync(shm)) fs.unlinkSync(shm);
1301
+ return 0;
394
1302
  }
395
-
396
- // Restore .ultra contents from backup, leaving the backup itself in place.
397
- const ultraDir = path.join(sourceDir, '.ultra');
398
- for (const entry of fs.readdirSync(ultraDir, { withFileTypes: true })) {
399
- if (/^backup-v[^/]+-/.test(entry.name)) continue;
400
- const target = path.join(ultraDir, entry.name);
401
- fs.rmSync(target, { recursive: true, force: true });
1303
+ const dbPath = flags.dbPath
1304
+ ? path.resolve(flags.dbPath)
1305
+ : runtimePaths.locateStateDb(sourceDir, { env: {} });
1306
+ const backupDir = findLatestBackup(sourceDir, fromVersion);
1307
+ if (!backupDir) {
1308
+ emit({
1309
+ ok: false,
1310
+ error: {
1311
+ code: 'NO_BACKUP',
1312
+ message: `no projection-v${fromVersion}-* or backup-v${fromVersion}-* directory found`,
1313
+ },
1314
+ });
1315
+ return 2;
402
1316
  }
403
- copyDirSync(backupDir, ultraDir);
404
-
405
1317
  emit({
406
1318
  ok: true,
407
- data: {
408
- mode: 'rollback',
409
- from: toVersion,
410
- to: fromVersion,
411
- backup_dir: backupDir,
412
- source_dir: sourceDir,
413
- },
1319
+ data: rollbackFromBackup({
1320
+ sourceDir,
1321
+ dbPath,
1322
+ backupDir,
1323
+ fromVersion,
1324
+ toVersion,
1325
+ }),
414
1326
  });
415
1327
  return 0;
416
1328
  } catch (err) {
417
- if (db) closeStateDb(db);
418
1329
  emit({ ok: false, error: { code: 'ROLLBACK_FAILED', message: err.message, retriable: false } });
419
1330
  return 2;
420
1331
  }
@@ -447,15 +1358,16 @@ Supported transitions:
447
1358
 
448
1359
  Flags:
449
1360
  --source-dir <dir> project root containing .ultra/ (default: .)
450
- --db-path <path> state.db destination (default: <source-dir>/.ultra/state.db)
1361
+ --db-path <path> state.db destination (default: <source-dir>/.ultra/.runtime/state.db)
451
1362
  --dry print the migration plan without writing
452
1363
  --rollback restore the most recent matching backup-v<from>-* and drop state.db
453
1364
 
454
1365
  The forward flow: backup .ultra/ → init state.db → insert tasks from
455
1366
  tasks.json → merge context md status (tasks.json wins on conflict, warnings
456
1367
  recorded) → insert activity-log events → record migration_history.
457
- Rollback restores from the latest matching backup-v<from>-* directory and writes a
458
- matching migration_history rollback row before dropping state.db.
1368
+ Rollback restores from the latest matching backup-v<from>-* directory through a
1369
+ prevalidated staged image, then removes the newer state.db only as part of the
1370
+ rollback transaction.
459
1371
  `;
460
1372
 
461
1373
  module.exports = {
@@ -463,6 +1375,9 @@ module.exports = {
463
1375
  USAGE,
464
1376
  parseFlags,
465
1377
  planForward,
1378
+ prepareForwardSnapshot,
466
1379
  parseFrontmatter,
467
1380
  sanitizeLegacyContextTemplate,
1381
+ rollbackFromBackup,
1382
+ resumeRollbackRecovery,
468
1383
  };