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,28 +2,335 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ const { readStableProjectFile } = require('./safe-project-file.cjs');
5
8
 
6
9
  const INTENT_FILE = '.archive-intent.json';
7
- const INTENT_VERSION = '1.0';
10
+ const REBIND_FILE = '.archive-rebind.json';
11
+ const INTENT_VERSION = '2.0';
12
+ const REBIND_VERSION = '1.0';
13
+ const MUTATION_WORKER = path.join(__dirname, 'archive-mutation-worker.py');
14
+ const MAX_MUTATION_REQUEST_BYTES = 16 * 1024 * 1024;
15
+ const MUTATION_PIN_FIELDS = Object.freeze({
16
+ mkdir_dir: Object.freeze(['directory_fd']),
17
+ write_atomic: Object.freeze(['directory_fd']),
18
+ write_rebind_atomic: Object.freeze(['directory_fd']),
19
+ unlink_regular: Object.freeze(['directory_fd']),
20
+ rename_dir: Object.freeze([
21
+ 'source_parent_fd',
22
+ 'destination_parent_fd',
23
+ 'guard_parent_fd',
24
+ ]),
25
+ });
26
+ const ACTIVE_ROOT = '.ultra/changes/active';
27
+ const ARCHIVE_ROOT = '.ultra/changes/archive';
28
+ const CORE_DIRECTORIES = Object.freeze([
29
+ ['project', '.'],
30
+ ['ultra', '.ultra'],
31
+ ['changes', '.ultra/changes'],
32
+ ['active', ACTIVE_ROOT],
33
+ ['archive', ARCHIVE_ROOT],
34
+ ]);
8
35
 
9
36
  class ArchiveJournalError extends Error {
10
- constructor(code, message) {
37
+ constructor(code, message, details = undefined) {
11
38
  super(message);
12
39
  this.name = 'ArchiveJournalError';
13
40
  this.code = code;
41
+ if (details !== undefined) this.details = details;
14
42
  }
15
43
  }
16
44
 
17
- function resolveInside(rootDir, relative, field) {
18
- if (typeof relative !== 'string' || !relative.trim() || path.isAbsolute(relative)) {
45
+ function unsafe(message, details = undefined) {
46
+ throw new ArchiveJournalError('ARCHIVE_PATH_UNSAFE', message, details);
47
+ }
48
+
49
+ function identity(stat) {
50
+ return {
51
+ dev: String(stat.dev),
52
+ ino: String(stat.ino),
53
+ mode: String(stat.mode),
54
+ };
55
+ }
56
+
57
+ function sameIdentity(left, right) {
58
+ return Boolean(left && right)
59
+ && left.dev === right.dev
60
+ && left.ino === right.ino
61
+ && left.mode === right.mode;
62
+ }
63
+
64
+ function normalizeRelative(value, field) {
65
+ if (typeof value !== 'string' || !value.trim()) {
19
66
  throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} must be project-relative`);
20
67
  }
21
- const root = path.resolve(rootDir);
22
- const resolved = path.resolve(root, relative);
23
- if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
68
+ const raw = value.trim().replaceAll('\\', '/');
69
+ if (path.posix.isAbsolute(raw) || path.win32.isAbsolute(raw)) {
70
+ throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} must be project-relative`);
71
+ }
72
+ const relative = path.posix.normalize(raw).replace(/^\.\//, '');
73
+ if (relative === '.' || relative === '..' || relative.startsWith('../')) {
24
74
  throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', `${field} escapes project root`);
25
75
  }
26
- return resolved;
76
+ return relative;
77
+ }
78
+
79
+ function inspectProject(rootDir) {
80
+ const root = path.resolve(rootDir);
81
+ let stat;
82
+ try { stat = fs.lstatSync(root); } catch (cause) {
83
+ unsafe(`project root is unavailable: ${root}`, { cause: cause.message });
84
+ }
85
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
86
+ unsafe(`project root must be a real directory: ${root}`);
87
+ }
88
+ let physicalRoot;
89
+ try { physicalRoot = fs.realpathSync.native(root); } catch (cause) {
90
+ unsafe(`project root cannot be resolved: ${root}`, { cause: cause.message });
91
+ }
92
+ return { root, physicalRoot, rootIdentity: identity(stat) };
93
+ }
94
+
95
+ function absoluteInside(project, relative) {
96
+ const normalized = relative === '.' ? '.' : normalizeRelative(relative, 'path');
97
+ if (normalized === '.') return project.physicalRoot;
98
+ return path.join(project.physicalRoot, ...normalized.split('/'));
99
+ }
100
+
101
+ function inspectDirectory(project, relative, { missing = false } = {}) {
102
+ const normalized = relative === '.' ? '.' : normalizeRelative(relative, 'directory');
103
+ const components = normalized === '.' ? [] : normalized.split('/');
104
+ let current = project.physicalRoot;
105
+ let currentIdentity = project.rootIdentity;
106
+ for (let index = 0; index < components.length; index += 1) {
107
+ current = path.join(current, components[index]);
108
+ let stat;
109
+ try { stat = fs.lstatSync(current); } catch (cause) {
110
+ if (missing && cause?.code === 'ENOENT') return null;
111
+ unsafe(`archive directory is unavailable: ${normalized}`, {
112
+ component: components.slice(0, index + 1).join('/'),
113
+ cause: cause.message,
114
+ });
115
+ }
116
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
117
+ unsafe(`archive directory must not contain links or non-directories: ${normalized}`, {
118
+ component: components.slice(0, index + 1).join('/'),
119
+ });
120
+ }
121
+ currentIdentity = identity(stat);
122
+ }
123
+ return { relative: normalized, path: current, identity: currentIdentity };
124
+ }
125
+
126
+ function openDirectoryPin(project, relative, { missing = false } = {}) {
127
+ const inspected = inspectDirectory(project, relative, { missing });
128
+ if (!inspected) return null;
129
+ let fd;
130
+ try {
131
+ fd = fs.openSync(
132
+ inspected.path,
133
+ fs.constants.O_RDONLY
134
+ | (fs.constants.O_DIRECTORY || 0)
135
+ | (fs.constants.O_NOFOLLOW || 0),
136
+ );
137
+ const opened = fs.fstatSync(fd);
138
+ if (!opened.isDirectory() || !sameIdentity(identity(opened), inspected.identity)) {
139
+ unsafe(`archive directory changed while it was pinned: ${inspected.relative}`);
140
+ }
141
+ } catch (error) {
142
+ if (fd !== undefined) fs.closeSync(fd);
143
+ if (error instanceof ArchiveJournalError) throw error;
144
+ unsafe(`archive directory cannot be pinned: ${inspected.relative}`, {
145
+ cause: error.message,
146
+ });
147
+ }
148
+ return {
149
+ ...inspected,
150
+ fd,
151
+ verify() {
152
+ let named;
153
+ let opened;
154
+ try {
155
+ named = fs.lstatSync(inspected.path);
156
+ opened = fs.fstatSync(fd);
157
+ } catch (cause) {
158
+ unsafe(`archive directory pin is no longer valid: ${inspected.relative}`, {
159
+ cause: cause.message,
160
+ });
161
+ }
162
+ if (named.isSymbolicLink() || !named.isDirectory() || !opened.isDirectory()
163
+ || !sameIdentity(identity(named), inspected.identity)
164
+ || !sameIdentity(identity(opened), inspected.identity)) {
165
+ unsafe(`archive directory changed identity: ${inspected.relative}`);
166
+ }
167
+ },
168
+ verifyFd() {
169
+ let opened;
170
+ try { opened = fs.fstatSync(fd); } catch (cause) {
171
+ unsafe(`archive directory descriptor is unavailable: ${inspected.relative}`, {
172
+ cause: cause.message,
173
+ });
174
+ }
175
+ if (!opened.isDirectory() || !sameIdentity(identity(opened), inspected.identity)) {
176
+ unsafe(`archive directory descriptor changed identity: ${inspected.relative}`);
177
+ }
178
+ },
179
+ close() {
180
+ if (fd !== undefined) {
181
+ fs.closeSync(fd);
182
+ fd = undefined;
183
+ }
184
+ },
185
+ };
186
+ }
187
+
188
+ function inspectChild(parent, name, { allowMissing = false } = {}) {
189
+ parent.verify();
190
+ const child = path.join(parent.path, name);
191
+ let stat;
192
+ try { stat = fs.lstatSync(child); } catch (cause) {
193
+ parent.verify();
194
+ if (allowMissing && cause?.code === 'ENOENT') return null;
195
+ unsafe(`archive entry is unavailable: ${child}`, { cause: cause.message });
196
+ }
197
+ parent.verify();
198
+ if (stat.isSymbolicLink()) unsafe(`archive entry is a symbolic link: ${child}`);
199
+ return { path: child, stat, identity: identity(stat) };
200
+ }
201
+
202
+ function pinChildDirectory(parent, name, { allowMissing = false } = {}) {
203
+ const entry = inspectChild(parent, name, { allowMissing });
204
+ if (!entry) return null;
205
+ if (!entry.stat.isDirectory()) unsafe(`archive entry is not a directory: ${entry.path}`);
206
+ const pin = openDirectoryPin(
207
+ { physicalRoot: parent.path, rootIdentity: parent.identity },
208
+ name,
209
+ );
210
+ parent.verify();
211
+ return pin;
212
+ }
213
+
214
+ function runPinnedMutation(request, pins) {
215
+ const expectedPinFields = MUTATION_PIN_FIELDS[request.operation];
216
+ if (!expectedPinFields
217
+ || Object.keys(pins).length !== expectedPinFields.length
218
+ || expectedPinFields.some((field) => !Object.hasOwn(pins, field))) {
219
+ throw new ArchiveJournalError(
220
+ 'ARCHIVE_MUTATION_INVALID',
221
+ `archive mutation has an invalid operation or descriptor contract: ${request.operation}`,
222
+ );
223
+ }
224
+ const stdio = ['pipe', 'pipe', 'pipe'];
225
+ const payload = { ...request };
226
+ for (const field of expectedPinFields) {
227
+ const pin = pins[field];
228
+ if (!pin || !Number.isInteger(pin.fd)) {
229
+ unsafe(`archive mutation is missing a pinned directory: ${field}`);
230
+ }
231
+ payload[field] = stdio.length;
232
+ stdio.push(pin.fd);
233
+ }
234
+ const serialized = JSON.stringify(payload);
235
+ if (Buffer.byteLength(serialized) > MAX_MUTATION_REQUEST_BYTES) {
236
+ throw new ArchiveJournalError(
237
+ 'ARCHIVE_MUTATION_INVALID',
238
+ 'archive mutation request exceeds the bounded worker protocol',
239
+ );
240
+ }
241
+ const result = require('node:child_process').spawnSync(
242
+ 'python3',
243
+ [MUTATION_WORKER],
244
+ {
245
+ input: serialized,
246
+ encoding: 'utf8',
247
+ maxBuffer: 1024 * 1024,
248
+ stdio,
249
+ },
250
+ );
251
+ if (result.error || result.status !== 0) {
252
+ let workerPayload = null;
253
+ const workerStderr = String(result.stderr || '').trim();
254
+ try {
255
+ workerPayload = JSON.parse(workerStderr);
256
+ } catch { /* preserve the worker's exact stderr */ }
257
+ const workerError = workerPayload?.message || workerStderr;
258
+ if (result.error?.code === 'ENOENT'
259
+ || result.error?.code === 'EACCES'
260
+ || result.error?.code === 'ENOEXEC'
261
+ || workerPayload?.code === 'ARCHIVE_RUNTIME_UNAVAILABLE') {
262
+ throw new ArchiveJournalError(
263
+ 'ARCHIVE_RUNTIME_UNAVAILABLE',
264
+ 'inode-pinned archive mutation requires Python 3 with POSIX dir_fd support',
265
+ {
266
+ operation: request.operation,
267
+ cause: result.error?.message || workerError || 'archive runtime unavailable',
268
+ },
269
+ );
270
+ }
271
+ unsafe('archive pinned mutation failed', {
272
+ operation: request.operation,
273
+ cause: result.error?.message || workerError || `worker exited ${result.status}`,
274
+ signal: result.signal || null,
275
+ });
276
+ }
277
+ }
278
+
279
+ function ensureArchiveRoot(project) {
280
+ const changes = openDirectoryPin(project, '.ultra/changes');
281
+ try {
282
+ const existing = inspectChild(changes, 'archive', { allowMissing: true });
283
+ if (existing) {
284
+ if (!existing.stat.isDirectory()) unsafe(`archive root is not a directory: ${existing.path}`);
285
+ return;
286
+ }
287
+ changes.verify();
288
+ runPinnedMutation({
289
+ operation: 'mkdir_dir',
290
+ directory_identity: changes.identity,
291
+ name: 'archive',
292
+ }, { directory_fd: changes });
293
+ changes.verify();
294
+ const created = inspectChild(changes, 'archive');
295
+ if (!created.stat.isDirectory()) unsafe('created archive root is not a directory');
296
+ } finally {
297
+ changes.close();
298
+ }
299
+ }
300
+
301
+ function openCoreLayout(rootDir, { ensureArchive = false } = {}) {
302
+ const project = inspectProject(rootDir);
303
+ if (ensureArchive) ensureArchiveRoot(project);
304
+ const pins = {};
305
+ try {
306
+ for (const [name, relative] of CORE_DIRECTORIES) {
307
+ pins[name] = openDirectoryPin(project, relative);
308
+ }
309
+ } catch (error) {
310
+ for (const pin of Object.values(pins)) pin?.close();
311
+ throw error;
312
+ }
313
+ return {
314
+ project,
315
+ pins,
316
+ identities: Object.fromEntries(
317
+ Object.entries(pins).map(([name, pin]) => [name, pin.identity]),
318
+ ),
319
+ verify() {
320
+ for (const pin of Object.values(pins)) pin.verify();
321
+ },
322
+ close() {
323
+ for (const pin of Object.values(pins).reverse()) pin.close();
324
+ },
325
+ };
326
+ }
327
+
328
+ function verifyStoredLayout(intent, layout) {
329
+ for (const [name] of CORE_DIRECTORIES) {
330
+ if (!sameIdentity(intent.path_identities?.[name], layout.identities[name])) {
331
+ unsafe(`archive ${name} directory no longer matches the prepared intent`);
332
+ }
333
+ }
27
334
  }
28
335
 
29
336
  function assertIntent(intent, rootDir) {
@@ -34,41 +341,346 @@ function assertIntent(intent, rootDir) {
34
341
  || typeof intent.reconciliation_path !== 'string' || !intent.reconciliation_path
35
342
  || !/^[0-9a-f]{64}$/.test(String(intent.reconciliation_digest || ''))
36
343
  || !intent.reconciliation_manifest || typeof intent.reconciliation_manifest !== 'object'
37
- || typeof intent.summary !== 'string' || intent.summary.trim().length < 3) {
344
+ || typeof intent.summary !== 'string' || intent.summary.trim().length < 3
345
+ || !intent.path_identities || typeof intent.path_identities !== 'object'
346
+ || !intent.source_identity || typeof intent.source_identity !== 'object') {
38
347
  throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent shape is invalid');
39
348
  }
40
- const source = resolveInside(rootDir, intent.source, 'source');
41
- const destination = resolveInside(rootDir, intent.destination, 'destination');
42
- const activeRoot = path.resolve(rootDir, '.ultra', 'changes', 'active');
43
- const archiveRoot = path.resolve(rootDir, '.ultra', 'changes', 'archive');
44
- if (path.dirname(source) !== activeRoot || path.basename(source) !== intent.change_id) {
45
- throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent source is not the active change root');
349
+ const sourceRelative = normalizeRelative(intent.source, 'source');
350
+ const destinationRelative = normalizeRelative(intent.destination, 'destination');
351
+ if (path.posix.dirname(sourceRelative) !== ACTIVE_ROOT
352
+ || path.posix.basename(sourceRelative) !== intent.change_id) {
353
+ throw new ArchiveJournalError(
354
+ 'ARCHIVE_INTENT_INVALID', 'archive intent source is not the active change root',
355
+ );
46
356
  }
47
- if (path.dirname(destination) !== archiveRoot
48
- || !path.basename(destination).endsWith(`-${intent.change_id}`)) {
49
- throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'archive intent destination is not the change archive root');
357
+ if (path.posix.dirname(destinationRelative) !== ARCHIVE_ROOT
358
+ || !path.posix.basename(destinationRelative).endsWith(`-${intent.change_id}`)) {
359
+ throw new ArchiveJournalError(
360
+ 'ARCHIVE_INTENT_INVALID', 'archive intent destination is not the change archive root',
361
+ );
50
362
  }
51
- for (const update of intent.baseline_updates) resolveInside(rootDir, update, 'baseline update');
52
- resolveInside(rootDir, intent.reconciliation_path, 'reconciliation path');
363
+ for (const update of intent.baseline_updates) normalizeRelative(update, 'baseline update');
364
+ normalizeRelative(intent.reconciliation_path, 'reconciliation path');
53
365
  if (intent.previous_summary !== null && typeof intent.previous_summary !== 'string') {
54
366
  throw new ArchiveJournalError('ARCHIVE_INTENT_INVALID', 'previous_summary must be string or null');
55
367
  }
56
- return { intent, source, destination };
368
+ const project = inspectProject(rootDir);
369
+ return {
370
+ intent,
371
+ sourceRelative,
372
+ destinationRelative,
373
+ source: absoluteInside(project, sourceRelative),
374
+ destination: absoluteInside(project, destinationRelative),
375
+ };
376
+ }
377
+
378
+ function readStableText(rootDir, relative, code) {
379
+ try {
380
+ return readStableProjectFile(rootDir, relative, { encoding: 'utf8' });
381
+ } catch (error) {
382
+ throw new ArchiveJournalError(code, `cannot read ${relative} safely: ${error.message}`, {
383
+ cause: error.code || error.message,
384
+ });
385
+ }
386
+ }
387
+
388
+ function rebindJournalRelative(intent, location = 'destination') {
389
+ const root = location === 'source' ? intent.source : intent.destination;
390
+ return `${root}/${REBIND_FILE}`;
391
+ }
392
+
393
+ function normalizeRebindEntry(entry, index) {
394
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
395
+ throw new ArchiveJournalError(
396
+ 'ARCHIVE_REBIND_INVALID', `archive rebind entry ${index} is invalid`,
397
+ );
398
+ }
399
+ const relativePath = normalizeRelative(entry.relative_path, `rebind entry ${index} path`);
400
+ if (relativePath === INTENT_FILE || relativePath === REBIND_FILE
401
+ || relativePath.startsWith(`${INTENT_FILE}/`)
402
+ || relativePath.startsWith(`${REBIND_FILE}/`)
403
+ || !/^[0-9a-f]{64}$/.test(String(entry.before_digest || ''))
404
+ || !/^[0-9a-f]{64}$/.test(String(entry.after_digest || ''))
405
+ || typeof entry.before_base64 !== 'string'
406
+ || typeof entry.after_base64 !== 'string') {
407
+ throw new ArchiveJournalError(
408
+ 'ARCHIVE_REBIND_INVALID', `archive rebind entry ${index} is invalid`,
409
+ );
410
+ }
411
+ const before = Buffer.from(entry.before_base64, 'base64');
412
+ const after = Buffer.from(entry.after_base64, 'base64');
413
+ if (before.toString('base64') !== entry.before_base64
414
+ || after.toString('base64') !== entry.after_base64
415
+ || crypto.createHash('sha256').update(before).digest('hex') !== entry.before_digest
416
+ || crypto.createHash('sha256').update(after).digest('hex') !== entry.after_digest) {
417
+ throw new ArchiveJournalError(
418
+ 'ARCHIVE_REBIND_INVALID', `archive rebind entry ${index} payload is invalid`,
419
+ );
420
+ }
421
+ return {
422
+ relative_path: relativePath,
423
+ before_digest: entry.before_digest,
424
+ after_digest: entry.after_digest,
425
+ before_base64: entry.before_base64,
426
+ after_base64: entry.after_base64,
427
+ };
428
+ }
429
+
430
+ function normalizeRebindJournal(value, intent) {
431
+ if (!value || typeof value !== 'object' || Array.isArray(value)
432
+ || value.schema_version !== REBIND_VERSION
433
+ || value.change_id !== intent.change_id
434
+ || value.source !== intent.source
435
+ || value.destination !== intent.destination
436
+ || !Array.isArray(value.entries)) {
437
+ throw new ArchiveJournalError(
438
+ 'ARCHIVE_REBIND_INVALID', 'archive rebind journal shape is invalid',
439
+ );
440
+ }
441
+ const entries = value.entries.map(normalizeRebindEntry);
442
+ if (new Set(entries.map((entry) => entry.relative_path)).size !== entries.length) {
443
+ throw new ArchiveJournalError(
444
+ 'ARCHIVE_REBIND_INVALID', 'archive rebind journal contains duplicate paths',
445
+ );
446
+ }
447
+ return { ...value, entries };
57
448
  }
58
449
 
59
- function readIntentFile(file, rootDir) {
450
+ function readArchiveRebind(rootDir, intent, { location = 'destination' } = {}) {
451
+ const relative = rebindJournalRelative(intent, location);
452
+ const file = path.join(path.resolve(rootDir), ...relative.split('/'));
453
+ if (!fs.existsSync(file)) return null;
454
+ const stable = readStableText(rootDir, relative, 'ARCHIVE_REBIND_INVALID');
455
+ let value;
456
+ try { value = JSON.parse(stable.text); } catch (error) {
457
+ throw new ArchiveJournalError(
458
+ 'ARCHIVE_REBIND_INVALID', `cannot parse ${relative}: ${error.message}`,
459
+ );
460
+ }
461
+ return normalizeRebindJournal(value, intent);
462
+ }
463
+
464
+ function writeRebindTarget(rootDir, rootRelative, entry, bytes) {
465
+ const parentRelative = path.posix.dirname(`${rootRelative}/${entry.relative_path}`);
466
+ const name = path.posix.basename(entry.relative_path);
467
+ const project = inspectProject(rootDir);
468
+ const parent = openDirectoryPin(project, parentRelative);
469
+ try {
470
+ const current = inspectChild(parent, name);
471
+ if (!current.stat.isFile()) {
472
+ unsafe(`archive rebind target is not a regular file: ${entry.relative_path}`);
473
+ }
474
+ writeAtomicRebindEntry(parent, name, bytes);
475
+ } finally {
476
+ parent.close();
477
+ }
478
+ }
479
+
480
+ function verifyRebindTarget(rootDir, rootRelative, entry, expectedDigest) {
481
+ const stable = readStableText(
482
+ rootDir,
483
+ `${rootRelative}/${entry.relative_path}`,
484
+ 'ARCHIVE_REBIND_INVALID',
485
+ );
486
+ if (stable.digest !== expectedDigest) {
487
+ throw new ArchiveJournalError(
488
+ 'ARCHIVE_REBIND_CONFLICT',
489
+ `archive rebind target has unexpected bytes: ${entry.relative_path}`,
490
+ { expected: expectedDigest, actual: stable.digest },
491
+ );
492
+ }
493
+ }
494
+
495
+ function prepareArchiveRebind(rootDir, intent, entries = null) {
496
+ verifyArchiveIntent(rootDir, intent, { location: 'destination' });
497
+ let journal = readArchiveRebind(rootDir, intent);
498
+ if (!journal) {
499
+ if (!Array.isArray(entries)) {
500
+ throw new ArchiveJournalError(
501
+ 'ARCHIVE_REBIND_INVALID', 'archive rebind entries are required for a new journal',
502
+ );
503
+ }
504
+ const normalized = entries.map(normalizeRebindEntry);
505
+ journal = normalizeRebindJournal({
506
+ schema_version: REBIND_VERSION,
507
+ change_id: intent.change_id,
508
+ source: intent.source,
509
+ destination: intent.destination,
510
+ entries: normalized,
511
+ created_at: new Date().toISOString(),
512
+ }, intent);
513
+ for (const entry of journal.entries) {
514
+ verifyRebindTarget(rootDir, intent.destination, entry, entry.before_digest);
515
+ }
516
+ const project = inspectProject(rootDir);
517
+ const destination = openDirectoryPin(project, intent.destination);
518
+ try {
519
+ writeAtomicEntry(
520
+ destination,
521
+ REBIND_FILE,
522
+ `${JSON.stringify(journal, null, 2)}\n`,
523
+ );
524
+ } finally {
525
+ destination.close();
526
+ }
527
+ } else if (entries) {
528
+ const requested = entries.map(normalizeRebindEntry);
529
+ const comparable = (values) => values.map((entry) => ({
530
+ relative_path: entry.relative_path,
531
+ before_digest: entry.before_digest,
532
+ after_digest: entry.after_digest,
533
+ }));
534
+ if (JSON.stringify(comparable(journal.entries))
535
+ !== JSON.stringify(comparable(requested))) {
536
+ throw new ArchiveJournalError(
537
+ 'ARCHIVE_REBIND_CONFLICT', 'existing archive rebind journal differs from retry',
538
+ );
539
+ }
540
+ }
541
+ for (const entry of journal.entries) {
542
+ const relative = `${intent.destination}/${entry.relative_path}`;
543
+ const current = readStableText(rootDir, relative, 'ARCHIVE_REBIND_INVALID');
544
+ if (current.digest === entry.after_digest) continue;
545
+ if (current.digest !== entry.before_digest) {
546
+ throw new ArchiveJournalError(
547
+ 'ARCHIVE_REBIND_CONFLICT',
548
+ `archive rebind target changed outside the transaction: ${entry.relative_path}`,
549
+ );
550
+ }
551
+ writeRebindTarget(
552
+ rootDir,
553
+ intent.destination,
554
+ entry,
555
+ Buffer.from(entry.after_base64, 'base64'),
556
+ );
557
+ verifyRebindTarget(rootDir, intent.destination, entry, entry.after_digest);
558
+ }
559
+ return journal;
560
+ }
561
+
562
+ function rollbackArchiveRebind(rootDir, intent, { location = 'destination' } = {}) {
563
+ const journal = readArchiveRebind(rootDir, intent, { location });
564
+ if (!journal) return;
565
+ const rootRelative = location === 'source' ? intent.source : intent.destination;
566
+ for (const entry of [...journal.entries].reverse()) {
567
+ const current = readStableText(
568
+ rootDir,
569
+ `${rootRelative}/${entry.relative_path}`,
570
+ 'ARCHIVE_REBIND_INVALID',
571
+ );
572
+ if (current.digest !== entry.before_digest) {
573
+ if (current.digest !== entry.after_digest) {
574
+ throw new ArchiveJournalError(
575
+ 'ARCHIVE_REBIND_CONFLICT',
576
+ `archive rebind rollback found unknown bytes: ${entry.relative_path}`,
577
+ );
578
+ }
579
+ writeRebindTarget(
580
+ rootDir,
581
+ rootRelative,
582
+ entry,
583
+ Buffer.from(entry.before_base64, 'base64'),
584
+ );
585
+ }
586
+ verifyRebindTarget(rootDir, rootRelative, entry, entry.before_digest);
587
+ }
588
+ const project = inspectProject(rootDir);
589
+ const parent = openDirectoryPin(project, rootRelative);
590
+ try { removePinnedRegularFile(parent, REBIND_FILE); } finally { parent.close(); }
591
+ }
592
+
593
+ function readIntentAt(rootDir, relative, expectedLocation = null) {
594
+ const stable = readStableText(rootDir, relative, 'ARCHIVE_INTENT_CORRUPT');
60
595
  let intent;
61
- try { intent = JSON.parse(fs.readFileSync(file, 'utf8')); }
62
- catch (error) {
63
- throw new ArchiveJournalError('ARCHIVE_INTENT_CORRUPT', `cannot read ${file}: ${error.message}`);
596
+ try { intent = JSON.parse(stable.text); } catch (error) {
597
+ throw new ArchiveJournalError(
598
+ 'ARCHIVE_INTENT_CORRUPT', `cannot parse ${relative}: ${error.message}`,
599
+ );
600
+ }
601
+ const asserted = assertIntent(intent, rootDir);
602
+ const layout = openCoreLayout(rootDir);
603
+ let child;
604
+ try {
605
+ verifyStoredLayout(intent, layout);
606
+ const activeName = path.posix.basename(asserted.sourceRelative);
607
+ const archiveName = path.posix.basename(asserted.destinationRelative);
608
+ const sourceEntry = inspectChild(layout.pins.active, activeName, { allowMissing: true });
609
+ const destinationEntry = inspectChild(
610
+ layout.pins.archive, archiveName, { allowMissing: true },
611
+ );
612
+ const sourceExists = Boolean(sourceEntry);
613
+ const destinationExists = Boolean(destinationEntry);
614
+ if (sourceEntry && !sourceEntry.stat.isDirectory()) {
615
+ unsafe(`active archive source is not a directory: ${asserted.sourceRelative}`);
616
+ }
617
+ if (destinationEntry && !destinationEntry.stat.isDirectory()) {
618
+ unsafe(`archive destination is not a directory: ${asserted.destinationRelative}`);
619
+ }
620
+ if ((sourceEntry && !sameIdentity(sourceEntry.identity, intent.source_identity))
621
+ || (destinationEntry && !sameIdentity(destinationEntry.identity, intent.source_identity))) {
622
+ unsafe(`archive change directory does not match the prepared inode: ${intent.change_id}`);
623
+ }
624
+ const location = sourceExists && !destinationExists
625
+ ? 'source'
626
+ : destinationExists && !sourceExists
627
+ ? 'destination'
628
+ : 'conflict';
629
+ if (expectedLocation && location !== expectedLocation) {
630
+ unsafe(`archive intent is not at the expected ${expectedLocation} location`);
631
+ }
632
+ child = {
633
+ ...asserted,
634
+ source_exists: sourceExists,
635
+ destination_exists: destinationExists,
636
+ location,
637
+ };
638
+ } finally {
639
+ layout.close();
640
+ }
641
+ return child;
642
+ }
643
+
644
+ function writeAtomicEntry(parent, name, contents, { replace = false } = {}) {
645
+ const current = inspectChild(parent, name, { allowMissing: true });
646
+ if (current && (!replace || !current.stat.isFile())) {
647
+ unsafe(`archive file cannot be replaced safely: ${current.path}`);
648
+ }
649
+ parent.verify();
650
+ runPinnedMutation({
651
+ operation: 'write_atomic',
652
+ directory_identity: parent.identity,
653
+ name,
654
+ temp_name: `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`,
655
+ replace,
656
+ data_base64: Buffer.from(contents).toString('base64'),
657
+ }, { directory_fd: parent });
658
+ parent.verify();
659
+ const published = inspectChild(parent, name);
660
+ if (!published.stat.isFile()) {
661
+ unsafe(`archive file is not regular after publication: ${published.path}`);
64
662
  }
65
- return assertIntent(intent, rootDir);
66
663
  }
67
664
 
68
- function writeJsonAtomic(file, value) {
69
- const temp = `${file}.${process.pid}.tmp`;
70
- fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
71
- fs.renameSync(temp, file);
665
+ function writeAtomicRebindEntry(parent, name, contents) {
666
+ const current = inspectChild(parent, name);
667
+ if (!current.stat.isFile()) {
668
+ unsafe(`archive rebind target is not a regular file: ${current.path}`);
669
+ }
670
+ parent.verify();
671
+ runPinnedMutation({
672
+ operation: 'write_rebind_atomic',
673
+ directory_identity: parent.identity,
674
+ name,
675
+ temp_name: `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`,
676
+ replace: true,
677
+ data_base64: Buffer.from(contents).toString('base64'),
678
+ }, { directory_fd: parent });
679
+ parent.verify();
680
+ const published = inspectChild(parent, name);
681
+ if (!published.stat.isFile()) {
682
+ unsafe(`archive rebind target is not regular after publication: ${published.path}`);
683
+ }
72
684
  }
73
685
 
74
686
  function renderSummary(change, summary, baselineUpdates, noBaselineChangeReason) {
@@ -82,14 +694,15 @@ function renderSummary(change, summary, baselineUpdates, noBaselineChangeReason)
82
694
  }
83
695
 
84
696
  function sameOperation(intent, {
85
- change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath, reconciliationDigest,
697
+ change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath,
698
+ reconciliationDigest,
86
699
  }) {
87
700
  return intent.change_id === change.id
88
- && intent.source === change.artifact_root
701
+ && intent.source === normalizeRelative(change.artifact_root, 'source')
89
702
  && intent.summary === summary.trim()
90
703
  && JSON.stringify(intent.baseline_updates) === JSON.stringify(baselineUpdates)
91
704
  && (intent.no_baseline_change_reason || null) === (noBaselineChangeReason || null)
92
- && intent.reconciliation_path === reconciliationPath
705
+ && intent.reconciliation_path === normalizeRelative(reconciliationPath, 'reconciliation path')
93
706
  && intent.reconciliation_digest === reconciliationDigest;
94
707
  }
95
708
 
@@ -97,108 +710,410 @@ function prepareArchiveMove({
97
710
  rootDir, change, summary, baselineUpdates, noBaselineChangeReason,
98
711
  reconciliationPath, reconciliationDigest, reconciliationManifest, now = new Date(),
99
712
  }) {
713
+ const sourceRelative = normalizeRelative(change.artifact_root, 'source');
100
714
  const date = now.toISOString().slice(0, 10);
101
- const source = resolveInside(rootDir, change.artifact_root, 'source');
102
- const destinationRelative = path.join('.ultra', 'changes', 'archive', `${date}-${change.id}`);
103
- const destination = resolveInside(rootDir, destinationRelative, 'destination');
104
- const sourceIntentFile = path.join(source, INTENT_FILE);
105
- const destinationIntentFile = path.join(destination, INTENT_FILE);
106
-
107
- if (!fs.existsSync(source) && fs.existsSync(destinationIntentFile)) {
108
- const resumed = readIntentFile(destinationIntentFile, rootDir);
109
- if (!sameOperation(resumed.intent, {
110
- change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath, reconciliationDigest,
111
- })) {
112
- throw new ArchiveJournalError('ARCHIVE_INTENT_CONFLICT', 'existing archive intent differs from retry input');
113
- }
114
- return { ...resumed, resumed: true };
115
- }
116
- if (!fs.existsSync(source)) {
117
- throw new ArchiveJournalError('ARCHIVE_SOURCE_MISSING', `active change root missing: ${change.artifact_root}`);
118
- }
119
- if (fs.existsSync(destination)) {
120
- throw new ArchiveJournalError('ARCHIVE_EXISTS', `archive already exists: ${destination}`);
121
- }
122
-
123
- let prepared;
124
- if (fs.existsSync(sourceIntentFile)) {
125
- prepared = readIntentFile(sourceIntentFile, rootDir);
126
- if (!sameOperation(prepared.intent, {
127
- change, summary, baselineUpdates, noBaselineChangeReason, reconciliationPath, reconciliationDigest,
128
- })) {
129
- throw new ArchiveJournalError('ARCHIVE_INTENT_CONFLICT', 'prepared archive intent differs from retry input');
130
- }
131
- } else {
132
- const summaryPath = path.join(source, 'archive-summary.md');
133
- const previousSummary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf8') : null;
134
- const intent = {
135
- schema_version: INTENT_VERSION,
136
- change_id: change.id,
137
- source: change.artifact_root,
138
- destination: destinationRelative,
139
- summary: summary.trim(),
140
- baseline_updates: baselineUpdates,
141
- no_baseline_change_reason: noBaselineChangeReason || null,
142
- reconciliation_path: reconciliationPath,
143
- reconciliation_digest: reconciliationDigest,
144
- reconciliation_manifest: reconciliationManifest,
145
- previous_summary: previousSummary,
146
- created_at: now.toISOString(),
715
+ const destinationRelative = `${ARCHIVE_ROOT}/${date}-${change.id}`;
716
+ const sourceName = path.posix.basename(sourceRelative);
717
+ const destinationName = path.posix.basename(destinationRelative);
718
+ const layout = openCoreLayout(rootDir, { ensureArchive: true });
719
+ let sourcePin;
720
+ try {
721
+ if (path.posix.dirname(sourceRelative) !== ACTIVE_ROOT || sourceName !== change.id) {
722
+ throw new ArchiveJournalError(
723
+ 'ARCHIVE_INTENT_INVALID', 'change artifact root is not the active change root',
724
+ );
725
+ }
726
+ const sourceEntry = inspectChild(layout.pins.active, sourceName, { allowMissing: true });
727
+ const destinationEntry = inspectChild(
728
+ layout.pins.archive, destinationName, { allowMissing: true },
729
+ );
730
+ if (!sourceEntry) {
731
+ if (!destinationEntry) {
732
+ throw new ArchiveJournalError(
733
+ 'ARCHIVE_SOURCE_MISSING', `active change root missing: ${sourceRelative}`,
734
+ );
735
+ }
736
+ const resumed = readIntentAt(
737
+ rootDir, `${destinationRelative}/${INTENT_FILE}`, 'destination',
738
+ );
739
+ if (!sameOperation(resumed.intent, {
740
+ change, summary, baselineUpdates, noBaselineChangeReason,
741
+ reconciliationPath, reconciliationDigest,
742
+ })) {
743
+ throw new ArchiveJournalError(
744
+ 'ARCHIVE_INTENT_CONFLICT', 'existing archive intent differs from retry input',
745
+ );
746
+ }
747
+ return {
748
+ ...resumed,
749
+ source: path.resolve(rootDir, sourceRelative),
750
+ destination: path.resolve(rootDir, destinationRelative),
751
+ resumed: true,
752
+ };
753
+ }
754
+ if (!sourceEntry.stat.isDirectory()) unsafe(`active change root is not a directory: ${sourceRelative}`);
755
+ if (destinationEntry) {
756
+ if (!destinationEntry.stat.isDirectory()) {
757
+ unsafe(`archive destination is not a directory: ${destinationRelative}`);
758
+ }
759
+ throw new ArchiveJournalError('ARCHIVE_EXISTS', `archive already exists: ${destinationRelative}`);
760
+ }
761
+ sourcePin = pinChildDirectory(layout.pins.active, sourceName);
762
+ const sourceIdentity = sourcePin.identity;
763
+ const sourceIntentRelative = `${sourceRelative}/${INTENT_FILE}`;
764
+ const existingIntent = inspectChild(sourcePin, INTENT_FILE, { allowMissing: true });
765
+ let prepared;
766
+ if (existingIntent) {
767
+ if (!existingIntent.stat.isFile()) unsafe(`archive intent is not a regular file: ${sourceIntentRelative}`);
768
+ prepared = readIntentAt(rootDir, sourceIntentRelative, 'source');
769
+ if (!sameOperation(prepared.intent, {
770
+ change, summary, baselineUpdates, noBaselineChangeReason,
771
+ reconciliationPath, reconciliationDigest,
772
+ })) {
773
+ throw new ArchiveJournalError(
774
+ 'ARCHIVE_INTENT_CONFLICT', 'prepared archive intent differs from retry input',
775
+ );
776
+ }
777
+ } else {
778
+ const summaryEntry = inspectChild(sourcePin, 'archive-summary.md', { allowMissing: true });
779
+ if (summaryEntry && !summaryEntry.stat.isFile()) {
780
+ unsafe('archive summary is not a regular file');
781
+ }
782
+ const previousSummary = summaryEntry
783
+ ? readStableText(
784
+ rootDir, `${sourceRelative}/archive-summary.md`, 'ARCHIVE_PATH_UNSAFE',
785
+ ).text
786
+ : null;
787
+ const intent = {
788
+ schema_version: INTENT_VERSION,
789
+ change_id: change.id,
790
+ source: sourceRelative,
791
+ destination: destinationRelative,
792
+ summary: summary.trim(),
793
+ baseline_updates: baselineUpdates,
794
+ no_baseline_change_reason: noBaselineChangeReason || null,
795
+ reconciliation_path: normalizeRelative(reconciliationPath, 'reconciliation path'),
796
+ reconciliation_digest: reconciliationDigest,
797
+ reconciliation_manifest: reconciliationManifest,
798
+ previous_summary: previousSummary,
799
+ path_identities: layout.identities,
800
+ source_identity: sourceIdentity,
801
+ created_at: now.toISOString(),
802
+ };
803
+ writeAtomicEntry(sourcePin, INTENT_FILE, `${JSON.stringify(intent, null, 2)}\n`);
804
+ layout.verify();
805
+ sourcePin.verify();
806
+ prepared = { ...assertIntent(intent, rootDir), location: 'source', intent };
807
+ }
808
+ writeAtomicEntry(
809
+ sourcePin,
810
+ 'archive-summary.md',
811
+ renderSummary(change, summary, baselineUpdates, noBaselineChangeReason),
812
+ { replace: true },
813
+ );
814
+ layout.verify();
815
+ sourcePin.verify();
816
+ if (inspectChild(layout.pins.archive, destinationName, { allowMissing: true })) {
817
+ throw new ArchiveJournalError(
818
+ 'ARCHIVE_EXISTS', `archive appeared before rename: ${destinationRelative}`,
819
+ );
820
+ }
821
+ runPinnedMutation({
822
+ operation: 'rename_dir',
823
+ source_parent_identity: layout.pins.active.identity,
824
+ destination_parent_identity: layout.pins.archive.identity,
825
+ guard_parent_identity: layout.pins.changes.identity,
826
+ source_parent_name: 'active',
827
+ destination_parent_name: 'archive',
828
+ source_name: sourceName,
829
+ destination_name: destinationName,
830
+ source_identity: sourceIdentity,
831
+ }, {
832
+ source_parent_fd: layout.pins.active,
833
+ destination_parent_fd: layout.pins.archive,
834
+ guard_parent_fd: layout.pins.changes,
835
+ });
836
+ layout.verify();
837
+ sourcePin.verifyFd();
838
+ if (inspectChild(layout.pins.active, sourceName, { allowMissing: true })) {
839
+ unsafe(`active source still exists after archive rename: ${sourceRelative}`);
840
+ }
841
+ const moved = inspectChild(layout.pins.archive, destinationName);
842
+ if (!moved.stat.isDirectory() || !sameIdentity(moved.identity, sourceIdentity)) {
843
+ unsafe(`archive destination does not match the prepared source: ${destinationRelative}`);
844
+ }
845
+ return {
846
+ ...prepared,
847
+ source: path.resolve(rootDir, sourceRelative),
848
+ destination: path.resolve(rootDir, destinationRelative),
849
+ sourceRelative,
850
+ destinationRelative,
851
+ location: 'destination',
852
+ resumed: false,
147
853
  };
148
- writeJsonAtomic(sourceIntentFile, intent);
149
- prepared = assertIntent(intent, rootDir);
854
+ } finally {
855
+ sourcePin?.close();
856
+ layout.close();
150
857
  }
151
-
152
- fs.writeFileSync(
153
- path.join(source, 'archive-summary.md'),
154
- renderSummary(change, summary, baselineUpdates, noBaselineChangeReason),
155
- );
156
- fs.mkdirSync(path.dirname(destination), { recursive: true });
157
- fs.renameSync(source, destination);
158
- return { ...prepared, source, destination, resumed: false };
159
858
  }
160
859
 
161
860
  function listArchiveIntents(rootDir) {
162
- const roots = [
163
- path.join(rootDir, '.ultra', 'changes', 'active'),
164
- path.join(rootDir, '.ultra', 'changes', 'archive'),
165
- ];
861
+ let project;
862
+ try { project = inspectProject(rootDir); } catch (error) {
863
+ return [{ file: path.resolve(rootDir), error }];
864
+ }
166
865
  const found = [];
167
- for (const parent of roots) {
168
- if (!fs.existsSync(parent)) continue;
169
- for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
170
- if (!entry.isDirectory()) continue;
171
- const file = path.join(parent, entry.name, INTENT_FILE);
172
- if (!fs.existsSync(file)) continue;
173
- try { found.push({ ...readIntentFile(file, rootDir), file, error: null }); }
174
- catch (error) { found.push({ file, error }); }
866
+ for (const relative of [ACTIVE_ROOT, ARCHIVE_ROOT]) {
867
+ let parent;
868
+ try { parent = openDirectoryPin(project, relative, { missing: true }); } catch (error) {
869
+ found.push({ file: absoluteInside(project, relative), error });
870
+ continue;
871
+ }
872
+ if (!parent) continue;
873
+ try {
874
+ parent.verify();
875
+ const entries = fs.readdirSync(parent.path, { withFileTypes: true });
876
+ parent.verify();
877
+ for (const entry of entries) {
878
+ if (['.gitkeep', '.DS_Store'].includes(entry.name)) continue;
879
+ let child;
880
+ try {
881
+ child = inspectChild(parent, entry.name);
882
+ if (!child.stat.isDirectory()) {
883
+ found.push({
884
+ file: child.path,
885
+ error: new ArchiveJournalError(
886
+ 'ARCHIVE_PATH_UNSAFE', `archive recovery residue is not a directory: ${child.path}`,
887
+ ),
888
+ });
889
+ continue;
890
+ }
891
+ } catch (error) {
892
+ found.push({ file: path.join(parent.path, entry.name), error });
893
+ continue;
894
+ }
895
+ const intentRelative = `${relative}/${entry.name}/${INTENT_FILE}`;
896
+ let intentEntry;
897
+ const childPin = pinChildDirectory(parent, entry.name);
898
+ try {
899
+ intentEntry = inspectChild(childPin, INTENT_FILE, { allowMissing: true });
900
+ } catch (error) {
901
+ found.push({ file: path.join(child.path, INTENT_FILE), error });
902
+ childPin.close();
903
+ continue;
904
+ }
905
+ childPin.close();
906
+ if (!intentEntry) continue;
907
+ if (!intentEntry.stat.isFile()) {
908
+ found.push({
909
+ file: intentEntry.path,
910
+ error: new ArchiveJournalError(
911
+ 'ARCHIVE_PATH_UNSAFE', `archive intent residue is not a regular file: ${intentEntry.path}`,
912
+ ),
913
+ });
914
+ continue;
915
+ }
916
+ try {
917
+ const record = readIntentAt(rootDir, intentRelative);
918
+ found.push({ ...record, file: intentEntry.path, error: null });
919
+ } catch (error) {
920
+ found.push({ file: intentEntry.path, error });
921
+ }
922
+ }
923
+ } catch (error) {
924
+ found.push({ file: parent.path, error });
925
+ } finally {
926
+ parent.close();
175
927
  }
176
928
  }
177
929
  return found;
178
930
  }
179
931
 
932
+ function inspectArchiveIntentState(rootDir, intent) {
933
+ const asserted = assertIntent(intent, rootDir);
934
+ const layout = openCoreLayout(rootDir);
935
+ try {
936
+ verifyStoredLayout(intent, layout);
937
+ const source = inspectChild(
938
+ layout.pins.active, path.posix.basename(asserted.sourceRelative), { allowMissing: true },
939
+ );
940
+ const destination = inspectChild(
941
+ layout.pins.archive,
942
+ path.posix.basename(asserted.destinationRelative),
943
+ { allowMissing: true },
944
+ );
945
+ for (const entry of [source, destination].filter(Boolean)) {
946
+ if (!entry.stat.isDirectory() || !sameIdentity(entry.identity, intent.source_identity)) {
947
+ unsafe(`archive change directory has an unexpected identity: ${entry.path}`);
948
+ }
949
+ }
950
+ return {
951
+ ...asserted,
952
+ source_exists: Boolean(source),
953
+ destination_exists: Boolean(destination),
954
+ location: source && !destination ? 'source' : destination && !source ? 'destination' : 'conflict',
955
+ };
956
+ } finally {
957
+ layout.close();
958
+ }
959
+ }
960
+
961
+ function verifyArchiveIntent(rootDir, intent, { location = 'destination' } = {}) {
962
+ const state = inspectArchiveIntentState(rootDir, intent);
963
+ if (state.location !== location) {
964
+ unsafe(`archive intent is not at the expected ${location} location`);
965
+ }
966
+ const relative = location === 'source' ? state.sourceRelative : state.destinationRelative;
967
+ const record = readIntentAt(rootDir, `${relative}/${INTENT_FILE}`, location);
968
+ if (JSON.stringify(record.intent) !== JSON.stringify(intent)) {
969
+ throw new ArchiveJournalError(
970
+ 'ARCHIVE_INTENT_CONFLICT', 'archive intent bytes differ from the supplied operation',
971
+ );
972
+ }
973
+ return state;
974
+ }
975
+
976
+ function removePinnedRegularFile(parent, name, { allowMissing = false } = {}) {
977
+ const entry = inspectChild(parent, name, { allowMissing });
978
+ if (!entry) return;
979
+ if (!entry.stat.isFile()) unsafe(`archive cleanup target is not a regular file: ${entry.path}`);
980
+ parent.verify();
981
+ runPinnedMutation({
982
+ operation: 'unlink_regular',
983
+ directory_identity: parent.identity,
984
+ name,
985
+ allow_missing: allowMissing,
986
+ }, { directory_fd: parent });
987
+ parent.verify();
988
+ if (inspectChild(parent, name, { allowMissing: true })) {
989
+ unsafe(`archive cleanup target still exists: ${entry.path}`);
990
+ }
991
+ }
992
+
180
993
  function completeArchiveIntent(rootDir, intent) {
181
- const { destination } = assertIntent(intent, rootDir);
182
- fs.rmSync(path.join(destination, INTENT_FILE), { force: true });
994
+ const state = verifyArchiveIntent(rootDir, intent, { location: 'destination' });
995
+ const rebind = readArchiveRebind(rootDir, intent);
996
+ if (rebind) {
997
+ for (const entry of rebind.entries) {
998
+ verifyRebindTarget(
999
+ rootDir, intent.destination, entry, entry.after_digest,
1000
+ );
1001
+ }
1002
+ }
1003
+ const layout = openCoreLayout(rootDir);
1004
+ let destination;
1005
+ try {
1006
+ verifyStoredLayout(intent, layout);
1007
+ destination = pinChildDirectory(
1008
+ layout.pins.archive, path.posix.basename(state.destinationRelative),
1009
+ );
1010
+ if (!sameIdentity(destination.identity, intent.source_identity)) {
1011
+ unsafe('archive destination changed before journal cleanup');
1012
+ }
1013
+ removePinnedRegularFile(destination, REBIND_FILE, { allowMissing: true });
1014
+ removePinnedRegularFile(destination, INTENT_FILE);
1015
+ layout.verify();
1016
+ } finally {
1017
+ destination?.close();
1018
+ layout.close();
1019
+ }
183
1020
  }
184
1021
 
185
1022
  function rollbackArchiveIntent(rootDir, intent) {
186
- const { source, destination } = assertIntent(intent, rootDir);
187
- if (fs.existsSync(destination) && !fs.existsSync(source)) fs.renameSync(destination, source);
188
- if (!fs.existsSync(source) || fs.existsSync(destination)) {
189
- throw new ArchiveJournalError('ARCHIVE_ROLLBACK_FAILED', 'archive roots are not in a recoverable state');
1023
+ const asserted = assertIntent(intent, rootDir);
1024
+ let state = inspectArchiveIntentState(rootDir, intent);
1025
+ if (state.location === 'destination') {
1026
+ rollbackArchiveRebind(rootDir, intent, { location: 'destination' });
1027
+ const layout = openCoreLayout(rootDir);
1028
+ let destination;
1029
+ try {
1030
+ verifyStoredLayout(intent, layout);
1031
+ destination = pinChildDirectory(
1032
+ layout.pins.archive, path.posix.basename(asserted.destinationRelative),
1033
+ );
1034
+ if (!sameIdentity(destination.identity, intent.source_identity)) {
1035
+ unsafe('archive destination changed before rollback');
1036
+ }
1037
+ if (inspectChild(
1038
+ layout.pins.active, path.posix.basename(asserted.sourceRelative), { allowMissing: true },
1039
+ )) {
1040
+ throw new ArchiveJournalError(
1041
+ 'ARCHIVE_ROLLBACK_FAILED', 'archive source already exists during rollback',
1042
+ );
1043
+ }
1044
+ layout.verify();
1045
+ destination.verify();
1046
+ runPinnedMutation({
1047
+ operation: 'rename_dir',
1048
+ source_parent_identity: layout.pins.archive.identity,
1049
+ destination_parent_identity: layout.pins.active.identity,
1050
+ guard_parent_identity: layout.pins.changes.identity,
1051
+ source_parent_name: 'archive',
1052
+ destination_parent_name: 'active',
1053
+ source_name: path.posix.basename(asserted.destinationRelative),
1054
+ destination_name: path.posix.basename(asserted.sourceRelative),
1055
+ source_identity: intent.source_identity,
1056
+ }, {
1057
+ source_parent_fd: layout.pins.archive,
1058
+ destination_parent_fd: layout.pins.active,
1059
+ guard_parent_fd: layout.pins.changes,
1060
+ });
1061
+ layout.verify();
1062
+ destination.verifyFd();
1063
+ const restored = inspectChild(
1064
+ layout.pins.active, path.posix.basename(asserted.sourceRelative),
1065
+ );
1066
+ if (!restored.stat.isDirectory()
1067
+ || !sameIdentity(restored.identity, intent.source_identity)) {
1068
+ unsafe('restored archive source does not match the prepared inode');
1069
+ }
1070
+ } finally {
1071
+ destination?.close();
1072
+ layout.close();
1073
+ }
1074
+ state = inspectArchiveIntentState(rootDir, intent);
1075
+ }
1076
+ if (state.location !== 'source') {
1077
+ throw new ArchiveJournalError(
1078
+ 'ARCHIVE_ROLLBACK_FAILED', 'archive roots are not in a recoverable state',
1079
+ );
1080
+ }
1081
+ rollbackArchiveRebind(rootDir, intent, { location: 'source' });
1082
+ const layout = openCoreLayout(rootDir);
1083
+ let source;
1084
+ try {
1085
+ verifyStoredLayout(intent, layout);
1086
+ source = pinChildDirectory(
1087
+ layout.pins.active, path.posix.basename(asserted.sourceRelative),
1088
+ );
1089
+ if (!sameIdentity(source.identity, intent.source_identity)) {
1090
+ unsafe('archive source changed before rollback cleanup');
1091
+ }
1092
+ if (intent.previous_summary === null) {
1093
+ removePinnedRegularFile(source, 'archive-summary.md', { allowMissing: true });
1094
+ } else {
1095
+ writeAtomicEntry(
1096
+ source, 'archive-summary.md', intent.previous_summary, { replace: true },
1097
+ );
1098
+ }
1099
+ removePinnedRegularFile(source, INTENT_FILE);
1100
+ layout.verify();
1101
+ } finally {
1102
+ source?.close();
1103
+ layout.close();
190
1104
  }
191
- const summaryPath = path.join(source, 'archive-summary.md');
192
- if (intent.previous_summary === null) fs.rmSync(summaryPath, { force: true });
193
- else fs.writeFileSync(summaryPath, intent.previous_summary);
194
- fs.rmSync(path.join(source, INTENT_FILE), { force: true });
195
1105
  }
196
1106
 
197
1107
  module.exports = {
198
1108
  ArchiveJournalError,
199
1109
  INTENT_FILE,
1110
+ REBIND_FILE,
200
1111
  prepareArchiveMove,
1112
+ prepareArchiveRebind,
1113
+ readArchiveRebind,
201
1114
  listArchiveIntents,
1115
+ inspectArchiveIntentState,
1116
+ verifyArchiveIntent,
202
1117
  completeArchiveIntent,
203
1118
  rollbackArchiveIntent,
204
1119
  };