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
@@ -7,11 +7,19 @@ const { spawnSync } = require('node:child_process');
7
7
  const Ajv = require('ajv/dist/2020');
8
8
 
9
9
  const ops = require('./state-ops.cjs');
10
+ const artifacts = require('./artifact-registry.cjs');
10
11
  const contextSpine = require('./context-spine.cjs');
11
12
  const specLearning = require('./spec-learning.cjs');
12
13
  const providerRefs = require('./provider-refs.cjs');
13
14
  const baselines = require('./baseline-workflow.cjs');
14
15
  const archiveJournal = require('./archive-journal.cjs');
16
+ const changePacket = require('./change-packet.cjs');
17
+ const deliveryTransaction = require('./delivery-transaction.cjs');
18
+ const {
19
+ openStableProjectRead,
20
+ readStableProjectFile,
21
+ walkStableProjectTree,
22
+ } = require('./safe-project-file.cjs');
15
23
  const workflows = require('./workflow-state.cjs');
16
24
  const decisions = require('./decision-dialogue.cjs');
17
25
  const reconciliationSchema = require('../../spec/schemas/baseline-reconciliation.v1.schema.json');
@@ -288,14 +296,72 @@ function safeRelativePath(rootDir, candidate) {
288
296
  return target;
289
297
  }
290
298
 
299
+ function rebindRegistryPath(candidate, source, destination) {
300
+ const current = artifacts.normalizeRelativePath(candidate);
301
+ const from = artifacts.normalizeRelativePath(source);
302
+ const to = artifacts.normalizeRelativePath(destination);
303
+ if (current !== from && !current.startsWith(`${from}/`)) return null;
304
+ const suffix = current === from ? '' : path.posix.relative(from, current);
305
+ return suffix ? path.posix.join(to, suffix) : to;
306
+ }
307
+
308
+ function assertReconciliationAuthority(db, change, relative, digest) {
309
+ const rows = db.prepare(
310
+ "SELECT * FROM artifacts WHERE status <> 'archived' ORDER BY id",
311
+ ).all().filter((item) => {
312
+ try { return artifacts.normalizeRelativePath(item.path) === relative; }
313
+ catch { return false; }
314
+ });
315
+ const row = rows.length === 1 ? rows[0] : null;
316
+ const authorityDigest = row?.digest || row?.content_hash || null;
317
+ if (!row || row.managed !== 1 || row.owner_type !== 'change'
318
+ || row.owner_id !== change.id || row.change_id !== change.id
319
+ || row.kind !== 'baseline_reconciliation' || row.status !== 'current'
320
+ || authorityDigest !== digest) {
321
+ throw new ChangeWorkflowError(
322
+ 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
323
+ 'reconciliation manifest must match one current managed Change artifact authority',
324
+ {
325
+ path: relative,
326
+ artifact_ids: rows.map((item) => item.id),
327
+ expected_owner: `change:${change.id}`,
328
+ expected_kind: 'baseline_reconciliation',
329
+ expected_digest: digest,
330
+ actual_digest: authorityDigest,
331
+ },
332
+ );
333
+ }
334
+ return row;
335
+ }
336
+
337
+ function openReconciliationReader(rootDir, relative) {
338
+ try {
339
+ return openStableProjectRead(rootDir, relative);
340
+ } catch (error) {
341
+ if (error?.code === 'PROJECT_FILE_MISSING') return null;
342
+ throw new ChangeWorkflowError(
343
+ 'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
344
+ `reconciliation manifest cannot be read safely: ${relative}`,
345
+ { cause: error.code || error.message },
346
+ );
347
+ }
348
+ }
349
+
291
350
  function readReconciliationManifest(db, change, input, rootDir) {
292
- const relative = typeof input.reconciliation_path === 'string'
351
+ const requested = typeof input.reconciliation_path === 'string'
293
352
  ? input.reconciliation_path.trim() : '';
294
- if (!relative) {
353
+ if (!requested) {
295
354
  throw new ChangeWorkflowError(
296
355
  'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation_path is required',
297
356
  );
298
357
  }
358
+ let relative;
359
+ try { relative = artifacts.normalizeRelativePath(requested); }
360
+ catch (error) {
361
+ throw new ChangeWorkflowError(
362
+ 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', error.message,
363
+ );
364
+ }
299
365
  let file = safeRelativePath(rootDir, relative);
300
366
  const artifactRoot = path.resolve(rootDir, change.artifact_root);
301
367
  if (!file.startsWith(`${artifactRoot}${path.sep}`)) {
@@ -304,26 +370,75 @@ function readReconciliationManifest(db, change, input, rootDir) {
304
370
  'reconciliation manifest must be a file inside the active change artifact root',
305
371
  );
306
372
  }
373
+ let reader = openReconciliationReader(rootDir, relative);
307
374
  let manifest;
308
- if (!fs.existsSync(file)) {
375
+ let digest;
376
+ let expectedDigest = null;
377
+ let authorityDigest = null;
378
+ let journalIntent = null;
379
+ if (!reader) {
309
380
  const pending = archiveJournal.listArchiveIntents(rootDir).find(
310
381
  (item) => !item.error && item.intent.change_id === change.id
311
382
  && item.intent.reconciliation_path === relative,
312
383
  );
313
384
  if (pending) {
314
- const suffix = path.relative(pending.intent.source, relative);
315
- file = path.join(pending.destination, suffix);
316
- manifest = pending.intent.reconciliation_manifest;
385
+ journalIntent = pending.intent;
386
+ const suffix = path.posix.relative(pending.intent.source, relative);
387
+ const archivedRelative = path.posix.join(pending.intent.destination, suffix);
388
+ file = safeRelativePath(rootDir, archivedRelative);
389
+ reader = openReconciliationReader(rootDir, archivedRelative);
390
+ expectedDigest = pending.intent.reconciliation_digest;
391
+ authorityDigest = pending.intent.reconciliation_digest;
392
+ const rebind = archiveJournal.readArchiveRebind(rootDir, pending.intent);
393
+ const rebound = rebind?.entries.find(
394
+ (entry) => entry.relative_path
395
+ === path.posix.relative(pending.intent.destination, archivedRelative),
396
+ );
397
+ if (rebound) {
398
+ if (rebound.before_digest !== pending.intent.reconciliation_digest) {
399
+ throw new ChangeWorkflowError(
400
+ 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
401
+ 'archive rebind journal does not begin from the prepared reconciliation authority',
402
+ {
403
+ expected_digest: pending.intent.reconciliation_digest,
404
+ actual_digest: rebound.before_digest,
405
+ },
406
+ );
407
+ }
408
+ expectedDigest = rebound.after_digest;
409
+ authorityDigest = rebound.before_digest;
410
+ }
317
411
  }
318
412
  }
319
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
413
+ if (!reader) {
320
414
  throw new ChangeWorkflowError(
321
415
  'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation manifest file is missing',
322
416
  );
323
417
  }
324
- try { manifest ||= JSON.parse(fs.readFileSync(file, 'utf8')); }
325
- catch (error) {
418
+ try {
419
+ digest = reader.digest;
420
+ if (expectedDigest && digest !== expectedDigest) {
421
+ throw new ChangeWorkflowError(
422
+ 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
423
+ 'archived reconciliation bytes do not match the prepared journal digest',
424
+ { expected_digest: expectedDigest, actual_digest: digest },
425
+ );
426
+ }
427
+ assertReconciliationAuthority(db, change, relative, authorityDigest || digest);
428
+ manifest = JSON.parse(reader.bytes.toString('utf8'));
429
+ reader.verify();
430
+ } catch (error) {
431
+ if (error instanceof ChangeWorkflowError) throw error;
432
+ if (error?.code === 'PROJECT_FILE_CHANGED' || error?.code === 'PROJECT_FILE_UNSAFE') {
433
+ throw new ChangeWorkflowError(
434
+ 'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
435
+ `reconciliation manifest changed while it was read: ${relative}`,
436
+ { cause: error.code },
437
+ );
438
+ }
326
439
  throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
440
+ } finally {
441
+ reader.close();
327
442
  }
328
443
  if (!validateReconciliationSchema(manifest)) {
329
444
  throw new ChangeWorkflowError(
@@ -413,10 +528,10 @@ function readReconciliationManifest(db, change, input, rootDir) {
413
528
  'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'resolved gap or unknown does not exist in baseline authority',
414
529
  );
415
530
  }
416
- const serialized = fs.readFileSync(file);
417
531
  return {
418
532
  manifest, relative, file,
419
- digest: crypto.createHash('sha256').update(serialized).digest('hex'),
533
+ digest,
534
+ journal_intent: journalIntent,
420
535
  };
421
536
  }
422
537
 
@@ -520,18 +635,50 @@ function inspectIncidentDiagnosis(change, artifactDir, rootDir) {
520
635
  };
521
636
  }
522
637
 
523
- function upsertArtifact(db, { change_id, task_id = null, kind, artifactPath, contentHash, metadata }) {
524
- const id = `art-${crypto.randomUUID().slice(0, 12)}`;
525
- db.prepare(
526
- `INSERT INTO artifacts (id, change_id, task_id, kind, path, content_hash, metadata_json)
527
- VALUES (?, ?, ?, ?, ?, ?, ?)
528
- ON CONFLICT(change_id, kind, path) DO UPDATE SET
529
- task_id = excluded.task_id, content_hash = excluded.content_hash,
530
- metadata_json = excluded.metadata_json, updated_at = excluded.updated_at`,
531
- ).run(
532
- id, change_id, task_id, kind, artifactPath, contentHash || null,
533
- metadata === undefined ? null : JSON.stringify(metadata),
534
- );
638
+ function latestWorkflowConsumer(db, changeId) {
639
+ const workflow = db.prepare(
640
+ `SELECT id FROM workflow_runs WHERE change_id = ?
641
+ ORDER BY started_at DESC, rowid DESC LIMIT 1`,
642
+ ).get(changeId);
643
+ if (!workflow) {
644
+ throw new ChangeWorkflowError(
645
+ 'ARTIFACT_CONSUMER_MISSING',
646
+ `change ${changeId} has no workflow consumer for its durable artifacts`,
647
+ );
648
+ }
649
+ return { type: 'workflow', id: workflow.id, relation: 'consumed_by' };
650
+ }
651
+
652
+ function upsertArtifact(
653
+ db,
654
+ {
655
+ change_id, task_id = null, kind, artifactPath, contentHash, metadata,
656
+ workflow_id = null, terminal = false, rootDir = process.cwd(),
657
+ },
658
+ ) {
659
+ const ownerType = task_id ? 'task' : 'change';
660
+ const ownerId = task_id || change_id;
661
+ const consumer = terminal
662
+ ? null
663
+ : task_id
664
+ ? { type: 'task', id: task_id, relation: 'consumed_by' }
665
+ : workflow_id
666
+ ? { type: 'workflow', id: workflow_id, relation: 'consumed_by' }
667
+ : latestWorkflowConsumer(db, change_id);
668
+ return artifacts.recordArtifactInTx(db, {
669
+ owner_type: ownerType,
670
+ owner_id: ownerId,
671
+ kind,
672
+ path: artifactPath,
673
+ content_digest: contentHash || undefined,
674
+ source_refs: [{ type: 'change', id: change_id, relation: 'produced_for' }],
675
+ consumer_refs: consumer ? [consumer] : [],
676
+ provenance: {
677
+ writer: 'change-workflow',
678
+ workflow_run_id: consumer?.type === 'workflow' ? consumer.id : null,
679
+ },
680
+ metadata: { ...(metadata || {}), ...(terminal ? { terminal_role: true } : {}) },
681
+ }, { rootDir });
535
682
  }
536
683
 
537
684
  function compileContext(db, input, { rootDir = process.cwd() } = {}) {
@@ -540,9 +687,13 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
540
687
  if (['archived', 'cancelled'].includes(change.status)) {
541
688
  throw new ChangeWorkflowError('CHANGE_TERMINAL', `change ${input.id} is ${change.status}`);
542
689
  }
543
- const providers = input.provider_refs === undefined
544
- ? change.provider_refs
545
- : normalizeProviderRefs(input.provider_refs);
690
+ if (Object.hasOwn(input, 'provider_refs')) {
691
+ throw new ChangeWorkflowError(
692
+ 'CONTEXT_PROVIDER_REFS_MUTATION_UNSUPPORTED',
693
+ 'provider refs are Change authority; update them through change.update before compiling context',
694
+ );
695
+ }
696
+ const providers = change.provider_refs;
546
697
  const tasks = db.prepare(
547
698
  'SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
548
699
  ).all(change.id).map((task) => ops.readTask(db, task.id));
@@ -568,7 +719,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
568
719
  worktree_state: spine.resume.worktree_state,
569
720
  worktree_digest: spine.resume.worktree_digest,
570
721
  },
571
- tasks,
722
+ tasks: spine.task_scope,
572
723
  selected_task: spine.selected_task,
573
724
  specs,
574
725
  role: spine.role,
@@ -577,6 +728,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
577
728
  readiness: spine.readiness,
578
729
  context: spine.context,
579
730
  execution_contract: spine.execution_contract,
731
+ task_context_contract: spine.task_context_contract,
580
732
  baseline: spine.baseline,
581
733
  resume: spine.resume,
582
734
  providers,
@@ -610,6 +762,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
610
762
  upsertArtifact(db, {
611
763
  change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
612
764
  artifactPath: relative, contentHash: hash,
765
+ rootDir,
613
766
  metadata: {
614
767
  git_head: spine.resume.git_head,
615
768
  worktree_digest: spine.resume.worktree_digest,
@@ -624,8 +777,6 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
624
777
  (change_id, task_id, source_ref, target_ref, relation) VALUES (?, ?, ?, ?, ?)`,
625
778
  ).run(change.id, task.id, task.trace_to, `task:${task.id}`, 'specified_by');
626
779
  }
627
- db.prepare('UPDATE changes SET provider_refs_json = ?, updated_at = ? WHERE id = ?')
628
- .run(JSON.stringify(providers), nowIso(), change.id);
629
780
  ops.appendEventInTx(db, {
630
781
  type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
631
782
  payload: {
@@ -758,20 +909,9 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
758
909
  fs.mkdirSync(artifactDir, { recursive: true });
759
910
  const intentPath = path.join(artifactDir, 'intent.md');
760
911
  writeIntent(intentPath, row);
761
- upsertArtifact(db, {
762
- change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
763
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
764
- });
765
912
  if (row.kind === 'incident') {
766
913
  const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
767
914
  writeIncidentDiagnosis(diagnosisPath, row);
768
- upsertArtifact(db, {
769
- change_id: row.id,
770
- kind: 'diagnosis',
771
- artifactPath: path.relative(rootDir, diagnosisPath),
772
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
773
- metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
774
- });
775
915
  }
776
916
  ops.appendEventInTx(db, {
777
917
  type: 'change_created', change_id: row.id,
@@ -785,6 +925,24 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
785
925
  change_id: row.id, subject: `Establish executable readiness for ${row.title}.`,
786
926
  metadata: { change_kind: row.kind, base_commit: row.base_commit },
787
927
  }, { rootDir });
928
+ upsertArtifact(db, {
929
+ change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
930
+ contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
931
+ workflow_id: workflow.id,
932
+ rootDir,
933
+ });
934
+ if (row.kind === 'incident') {
935
+ const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
936
+ upsertArtifact(db, {
937
+ change_id: row.id,
938
+ kind: 'diagnosis',
939
+ artifactPath: path.relative(rootDir, diagnosisPath),
940
+ contentHash: crypto.createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
941
+ metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
942
+ workflow_id: workflow.id,
943
+ rootDir,
944
+ });
945
+ }
788
946
  workflow = workflows.recordWorkflowStep(db, {
789
947
  id: workflow.id, step_id: 'bind-baseline', status: 'completed',
790
948
  evidence: [{
@@ -906,44 +1064,45 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
906
1064
  sets.push('updated_at = ?'); values.push(nowIso(), id);
907
1065
  db.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE id = ?`).run(...values);
908
1066
  const updated = readChange(db, id);
909
- const invalidatedTasks = [];
910
- if (contextSpine.changeStateDigest(current) !== contextSpine.changeStateDigest(updated)) {
911
- const tasks = db.prepare(
912
- 'SELECT id, stale FROM tasks WHERE change_id = ? ORDER BY created_at ASC, id ASC',
913
- ).all(id);
914
- const markStale = db.prepare(
915
- 'UPDATE tasks SET stale = 1, updated_at = ? WHERE id = ?',
916
- );
917
- const invalidatedAt = nowIso();
918
- for (const task of tasks) {
919
- if (task.stale) continue;
920
- markStale.run(invalidatedAt, task.id);
921
- invalidatedTasks.push(task.id);
922
- ops.appendEventInTx(db, {
923
- type: 'task_stale_marked',
924
- task_id: task.id,
925
- change_id: id,
926
- payload: {
927
- reason: 'change_semantic_authority_updated',
928
- fields: Object.keys(patch).filter((field) => SEMANTIC_AUTHORITY_FIELDS.has(field)),
929
- },
930
- });
931
- }
932
- }
1067
+ const invalidated = [];
1068
+ let intentArtifactId = null;
933
1069
  if (syncIntent) {
934
1070
  fs.mkdirSync(path.dirname(intentPath), { recursive: true });
935
1071
  writeIntent(intentPath, updated);
936
- upsertArtifact(db, {
1072
+ const recorded = upsertArtifact(db, {
937
1073
  change_id: id,
938
1074
  kind: 'intent',
939
1075
  artifactPath: path.relative(rootDir, intentPath),
940
1076
  contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
1077
+ rootDir,
941
1078
  });
1079
+ intentArtifactId = recorded.artifact.id;
1080
+ invalidated.push(...recorded.invalidated);
1081
+ }
1082
+ const semanticFields = Object.keys(patch)
1083
+ .filter((field) => SEMANTIC_AUTHORITY_FIELDS.has(field));
1084
+ if (semanticFields.length > 0
1085
+ && contextSpine.changeStateDigest(current) !== contextSpine.changeStateDigest(updated)) {
1086
+ invalidated.push(...artifacts.invalidateConsumersFromEndpointInTx(
1087
+ db,
1088
+ { type: 'change', id },
1089
+ {
1090
+ reason: 'change_semantic_authority_updated',
1091
+ exclude: intentArtifactId ? [{ type: 'artifact', id: intentArtifactId }] : [],
1092
+ },
1093
+ ));
942
1094
  }
1095
+ const invalidatedTasks = [...new Set(
1096
+ invalidated.filter((item) => item.type === 'task').map((item) => item.id),
1097
+ )].sort();
943
1098
  ops.appendEventInTx(db, {
944
1099
  type: 'change_updated',
945
1100
  change_id: id,
946
- payload: { fields: Object.keys(patch), invalidated_tasks: invalidatedTasks },
1101
+ payload: {
1102
+ fields: Object.keys(patch),
1103
+ invalidated_tasks: invalidatedTasks,
1104
+ invalidated_authorities: invalidated,
1105
+ },
947
1106
  });
948
1107
  return updated;
949
1108
  });
@@ -956,6 +1115,35 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
956
1115
  }
957
1116
  }
958
1117
 
1118
+ function recordDelta(db, input, { rootDir = process.cwd() } = {}) {
1119
+ const change = readChange(db, input?.id);
1120
+ if (!change) {
1121
+ throw new ChangeWorkflowError(
1122
+ 'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
1123
+ );
1124
+ }
1125
+ return changePacket.recordDelta(db, change, input, { rootDir });
1126
+ }
1127
+
1128
+ function recordDocumentationReconciliation(
1129
+ db,
1130
+ input,
1131
+ { rootDir = process.cwd() } = {},
1132
+ ) {
1133
+ const change = readChange(db, input?.id);
1134
+ if (!change) {
1135
+ throw new ChangeWorkflowError(
1136
+ 'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
1137
+ );
1138
+ }
1139
+ return changePacket.recordDocumentationReconciliation(
1140
+ db,
1141
+ change,
1142
+ input,
1143
+ { rootDir },
1144
+ );
1145
+ }
1146
+
959
1147
  function filesUnder(dir) {
960
1148
  if (!fs.existsSync(dir)) return [];
961
1149
  const out = [];
@@ -967,6 +1155,95 @@ function filesUnder(dir) {
967
1155
  return out;
968
1156
  }
969
1157
 
1158
+ function markdownAnchor(text, fallback) {
1159
+ const heading = String(text).match(/^#{1,6}\s+(.+?)\s*#*\s*$/m)?.[1] || fallback;
1160
+ return String(heading || 'change')
1161
+ .trim()
1162
+ .toLowerCase()
1163
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
1164
+ .replace(/\s+/g, '-')
1165
+ .replace(/-+/g, '-');
1166
+ }
1167
+
1168
+ function generatePacketReconciliation(db, change, packet, rootDir) {
1169
+ const baseline = baselines.readBaseline(db);
1170
+ const semanticChanges = packet.delta.value.mutations.map((mutation) => {
1171
+ const overlay = safeRelativePath(rootDir, mutation.overlay_path);
1172
+ const anchor = markdownAnchor(
1173
+ fs.readFileSync(overlay, 'utf8'),
1174
+ path.basename(mutation.target_path, path.extname(mutation.target_path)),
1175
+ );
1176
+ return {
1177
+ id: `delta-${mutation.id}`,
1178
+ action: mutation.action,
1179
+ source_ref: `${mutation.target_path}#${anchor}`,
1180
+ before_digest: mutation.before_digest,
1181
+ after_digest: mutation.after_digest,
1182
+ };
1183
+ });
1184
+ const verification = [{
1185
+ name: 'typed Change packet read-back',
1186
+ command: 'change.delta',
1187
+ status: 'pass',
1188
+ evidence: `Change delta ${packet.delta.artifact.id} @ ${packet.delta.artifact.digest} and documentation reconciliation ${packet.documentation?.artifact.id || 'none-required'} were read back before Deliver.`,
1189
+ }];
1190
+ const manifest = {
1191
+ $schema: 'ultra-baseline-reconciliation-v1',
1192
+ change_id: change.id,
1193
+ baseline_id: baseline?.id || null,
1194
+ baseline_updates: packet.baseline_updates,
1195
+ semantic_changes: semanticChanges,
1196
+ resolved_gap_ids: [],
1197
+ resolved_unknowns: [],
1198
+ verification,
1199
+ ...(semanticChanges.length === 0
1200
+ ? {
1201
+ semantic_no_change_reason: packet.delta.value.no_semantic_change_reason
1202
+ || 'The accepted Change has no baseline specification mutation.',
1203
+ }
1204
+ : {}),
1205
+ };
1206
+ const relative = `${change.artifact_root}/baseline-reconciliation.json`;
1207
+ const file = safeRelativePath(rootDir, relative);
1208
+ const previous = fs.existsSync(file) ? fs.readFileSync(file) : null;
1209
+ const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
1210
+ fs.writeFileSync(file, serialized);
1211
+ try {
1212
+ const recorded = artifacts.recordArtifact(db, {
1213
+ id: `${change.id}-baseline-reconciliation`,
1214
+ owner_type: 'change',
1215
+ owner_id: change.id,
1216
+ kind: 'baseline_reconciliation',
1217
+ path: relative,
1218
+ content_digest: crypto.createHash('sha256').update(serialized).digest('hex'),
1219
+ source_refs: [
1220
+ { type: 'artifact', id: packet.delta.artifact.id, relation: 'reconciles' },
1221
+ ...(packet.documentation
1222
+ ? [{
1223
+ type: 'artifact',
1224
+ id: packet.documentation.artifact.id,
1225
+ relation: 'reconciles_documentation',
1226
+ }]
1227
+ : []),
1228
+ ],
1229
+ consumer_refs: [],
1230
+ provenance: { writer: 'change.deliver', generated: true },
1231
+ metadata: { terminal_role: true, schema: manifest.$schema },
1232
+ }, { rootDir }).artifact;
1233
+ return {
1234
+ manifest,
1235
+ relative,
1236
+ file,
1237
+ digest: recorded.digest,
1238
+ artifact: recorded,
1239
+ };
1240
+ } catch (error) {
1241
+ if (previous === null) fs.rmSync(file, { force: true });
1242
+ else fs.writeFileSync(file, previous);
1243
+ throw error;
1244
+ }
1245
+ }
1246
+
970
1247
  function baselineConvergenceBlockers(db, change, rootDir) {
971
1248
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
972
1249
  return contextSpine.baselineGateForChange(db, change, baselineHealth).blockers;
@@ -974,14 +1251,21 @@ function baselineConvergenceBlockers(db, change, rootDir) {
974
1251
 
975
1252
  function deriveConvergenceEvidence(db, change, tasks, workflowGate, diagnosis, rootDir) {
976
1253
  const devRuns = db.prepare(
977
- `SELECT id, task_id, summary_json FROM workflow_runs
1254
+ `SELECT id, task_id, summary_json, metadata_json FROM workflow_runs
978
1255
  WHERE kind = 'dev' AND change_id = ? AND status = 'completed'
979
1256
  ORDER BY completed_at ASC, rowid ASC`,
980
- ).all(change.id).map((row) => ({
981
- id: row.id, task_id: row.task_id, summary: parseJson(row.summary_json, 'workflow_runs.summary_json'),
982
- }));
983
- const deltaPaths = filesUnder(path.join(rootDir, change.artifact_root, 'delta'))
984
- .map((file) => path.relative(rootDir, file));
1257
+ ).all(change.id)
1258
+ .filter((row) => workflows.isConsumableWorkflowAuthority({
1259
+ metadata: parseJson(row.metadata_json, 'workflow_runs.metadata_json'),
1260
+ }))
1261
+ .map((row) => ({
1262
+ id: row.id, task_id: row.task_id,
1263
+ summary: parseJson(row.summary_json, 'workflow_runs.summary_json'),
1264
+ }));
1265
+ const deltaAuthority = changePacket.readDeltaAuthority(db, change.id);
1266
+ const deltaPaths = deltaAuthority
1267
+ ? [deltaAuthority.path, deltaAuthority.digest]
1268
+ : [];
985
1269
  const appliedLearning = specLearning.listSpecLearning(db, change.id)
986
1270
  .filter((candidate) => candidate.status === 'applied')
987
1271
  .map((candidate) => candidate.target_ref);
@@ -1052,20 +1336,26 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1052
1336
  if (tasks.length === 0) blockers.add('NO_TASKS');
1053
1337
  if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
1054
1338
  if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
1055
- for (const task of tasks) {
1056
- const snapshot = db.prepare(
1057
- `SELECT readiness, context_json FROM context_snapshots
1058
- WHERE change_id = ? AND task_id = ?
1059
- ORDER BY created_at DESC, rowid DESC LIMIT 1`,
1060
- ).get(change.id, task.id);
1061
- if (!snapshot) {
1339
+ for (const task of tasks.filter((item) => item.status !== 'expanded')) {
1340
+ const validation = contextSpine.validateContextSnapshot(db, {
1341
+ change_id: change.id,
1342
+ task_id: task.id,
1343
+ role: 'implement',
1344
+ gate: 'implementation',
1345
+ }, { rootDir });
1346
+ if (!validation.snapshot) {
1062
1347
  blockers.add(`TASK_CONTEXT_MISSING:${task.id}`);
1063
1348
  continue;
1064
1349
  }
1065
- const context = parseJson(snapshot.context_json, 'context_snapshots.context_json');
1066
- if (snapshot.readiness !== 'ready') blockers.add(`TASK_CONTEXT_NOT_READY:${task.id}`);
1067
- if (!context.execution_contract?.public_seam) blockers.add(`TASK_SEAM_MISSING:${task.id}`);
1068
- if (!context.execution_contract?.verification_command) blockers.add(`TASK_VERIFICATION_MISSING:${task.id}`);
1350
+ for (const blocker of validation.blockers) {
1351
+ blockers.add(`TASK_CONTEXT_INVALID:${task.id}:${blocker}`);
1352
+ }
1353
+ if (!validation.manifest?.execution_contract?.public_seam) {
1354
+ blockers.add(`TASK_SEAM_MISSING:${task.id}`);
1355
+ }
1356
+ if (!validation.manifest?.execution_contract?.verification_command) {
1357
+ blockers.add(`TASK_VERIFICATION_MISSING:${task.id}`);
1358
+ }
1069
1359
  }
1070
1360
 
1071
1361
  for (const candidate of specLearning.listSpecLearning(db, change.id)) {
@@ -1088,30 +1378,32 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1088
1378
  const diagnosis = inspectIncidentDiagnosis(change, artifactDir, rootDir);
1089
1379
  for (const blocker of diagnosis.blockers) blockers.add(blocker);
1090
1380
  if (['standard', 'major'].includes(change.kind)) {
1091
- if (filesUnder(path.join(artifactDir, 'delta')).length === 0) blockers.add('SPEC_DELTA_MISSING');
1092
- if (!fs.existsSync(path.join(artifactDir, 'plan.md'))) blockers.add('CHANGE_PLAN_MISSING');
1093
- }
1094
- const latestContext = db.prepare(
1095
- `SELECT manifest_path, manifest_hash FROM context_snapshots
1096
- WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
1097
- ).get(change.id);
1098
- const manifestPath = latestContext?.manifest_path
1099
- ? safeRelativePath(rootDir, latestContext.manifest_path)
1100
- : null;
1101
- if (!manifestPath || !fs.existsSync(manifestPath)) blockers.add('CONTEXT_MANIFEST_MISSING');
1102
- else {
1103
- const manifest = parseJson(fs.readFileSync(manifestPath, 'utf8'), 'context-manifest.json');
1104
- const manifestHash = crypto.createHash('sha256').update(fs.readFileSync(manifestPath)).digest('hex');
1105
- if (latestContext.manifest_hash !== manifestHash) blockers.add('CONTEXT_MANIFEST_STALE');
1106
- if (manifest.git && manifest.git.head !== gitHead(rootDir)) blockers.add('CONTEXT_HEAD_STALE');
1107
- const checkout = baselines.gitWorktreeSnapshot(rootDir, ['.']);
1108
- if (manifest.git?.worktree_digest && checkout.digest
1109
- && manifest.git.worktree_digest !== checkout.digest) {
1110
- blockers.add('CONTEXT_WORKTREE_STALE');
1381
+ let delta = null;
1382
+ try {
1383
+ delta = changePacket.loadDelta(db, change.id, { rootDir });
1384
+ } catch (error) {
1385
+ blockers.add(error.code || 'CHANGE_DELTA_AUTHORITY_MISSING');
1386
+ }
1387
+ if (delta) {
1388
+ try {
1389
+ const documentation = changePacket.loadDocumentationReconciliation(
1390
+ db, change.id, { rootDir },
1391
+ );
1392
+ if (documentation.value.delta_artifact_id !== delta.artifact.id
1393
+ || documentation.value.delta_digest !== delta.artifact.digest) {
1394
+ blockers.add('DOCUMENTATION_DELTA_AUTHORITY_INVALID');
1395
+ }
1396
+ } catch (error) {
1397
+ blockers.add(error.code || 'DOCUMENTATION_RECONCILIATION_MISSING');
1398
+ }
1111
1399
  }
1112
- const manifestTasks = (manifest.tasks || []).map((task) => task.id).sort();
1113
- const currentTasks = tasks.map((task) => task.id).sort();
1114
- if (JSON.stringify(manifestTasks) !== JSON.stringify(currentTasks)) blockers.add('CONTEXT_TASK_SET_STALE');
1400
+ const planArtifact = db.prepare(
1401
+ `SELECT id FROM artifacts
1402
+ WHERE owner_type = 'change' AND owner_id = ? AND kind = 'execution_plan'
1403
+ AND status = 'current'
1404
+ ORDER BY updated_at DESC, rowid DESC`,
1405
+ ).all(change.id);
1406
+ if (planArtifact.length !== 1) blockers.add('CHANGE_PLAN_AUTHORITY_MISSING');
1115
1407
  }
1116
1408
  const openIncidents = db.prepare(
1117
1409
  `SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'
@@ -1124,7 +1416,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1124
1416
  ops.tx(db, () => {
1125
1417
  db.prepare("UPDATE changes SET status = 'blocked', updated_at = ? WHERE id = ?")
1126
1418
  .run(nowIso(), change.id);
1127
- if (diagnosis.artifact) upsertArtifact(db, diagnosis.artifact);
1419
+ if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
1128
1420
  ops.appendEventInTx(db, {
1129
1421
  type: 'change_blocked', change_id: change.id, payload: { blockers: blockerList },
1130
1422
  });
@@ -1147,11 +1439,12 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1147
1439
  ops.tx(db, () => {
1148
1440
  db.prepare("UPDATE changes SET status = 'ready', updated_at = ? WHERE id = ?")
1149
1441
  .run(nowIso(), change.id);
1150
- if (diagnosis.artifact) upsertArtifact(db, diagnosis.artifact);
1442
+ if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
1151
1443
  upsertArtifact(db, {
1152
1444
  change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
1153
1445
  contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
1154
1446
  metadata: { evidence: derivedEvidence },
1447
+ rootDir,
1155
1448
  });
1156
1449
  ops.appendEventInTx(db, {
1157
1450
  type: 'change_converged', change_id: change.id,
@@ -1161,19 +1454,240 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1161
1454
  return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
1162
1455
  }
1163
1456
 
1164
- function finalizeArchive(db, intent, { rootDir }) {
1457
+ function replaceArchiveAuthorityText(
1458
+ text,
1459
+ source,
1460
+ destination,
1461
+ digestMap = new Map(),
1462
+ { normalizePath = false } = {},
1463
+ ) {
1464
+ const original = String(text);
1465
+ const windowsSource = source.replaceAll('/', '\\');
1466
+ let result = original
1467
+ .replaceAll(source, destination)
1468
+ .replaceAll(windowsSource, destination);
1469
+ if (normalizePath && original.includes(windowsSource)) {
1470
+ result = result.replaceAll('\\', '/');
1471
+ }
1472
+ for (const [before, after] of digestMap) {
1473
+ if (before !== after) result = result.replaceAll(before, after);
1474
+ }
1475
+ return result;
1476
+ }
1477
+
1478
+ function archiveRebindAuthorityValue(value, source, destination, digestMap) {
1479
+ if (typeof value === 'string') {
1480
+ return replaceArchiveAuthorityText(
1481
+ value, source, destination, digestMap, { normalizePath: true },
1482
+ );
1483
+ }
1484
+ if (Array.isArray(value)) {
1485
+ return value.map(
1486
+ (item) => archiveRebindAuthorityValue(item, source, destination, digestMap),
1487
+ );
1488
+ }
1489
+ if (value && typeof value === 'object') {
1490
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1491
+ key,
1492
+ archiveRebindAuthorityValue(item, source, destination, digestMap),
1493
+ ]));
1494
+ }
1495
+ return value;
1496
+ }
1497
+
1498
+ function buildArchiveRebindEntries(db, intent, rootDir) {
1499
+ const tree = walkStableProjectTree(rootDir, intent.destination, {
1500
+ ignore: (relative) => (
1501
+ relative === `${intent.destination}/${archiveJournal.INTENT_FILE}`
1502
+ || relative === `${intent.destination}/${archiveJournal.REBIND_FILE}`
1503
+ ),
1504
+ });
1505
+ if (tree.unsafe.length > 0) {
1506
+ throw new ChangeWorkflowError(
1507
+ 'ARCHIVE_PATH_UNSAFE',
1508
+ `archive packet contains unsafe entries: ${tree.unsafe.map((item) => item.path).join(', ')}`,
1509
+ { unsafe: tree.unsafe },
1510
+ );
1511
+ }
1512
+ const originalByPath = new Map();
1513
+ for (const artifact of db.prepare(
1514
+ 'SELECT path, digest, content_hash FROM artifacts WHERE change_id = ?',
1515
+ ).all(intent.change_id)) {
1516
+ const archivedPath = rebindRegistryPath(
1517
+ artifact.path, intent.source, intent.destination,
1518
+ );
1519
+ const digest = artifact.digest || artifact.content_hash;
1520
+ if (archivedPath && digest) originalByPath.set(archivedPath, digest);
1521
+ }
1522
+ const contextPaths = new Set();
1523
+ for (const snapshot of db.prepare(
1524
+ 'SELECT manifest_path, manifest_hash FROM context_snapshots WHERE change_id = ?',
1525
+ ).all(intent.change_id)) {
1526
+ const archivedPath = rebindRegistryPath(
1527
+ snapshot.manifest_path, intent.source, intent.destination,
1528
+ );
1529
+ if (archivedPath) {
1530
+ contextPaths.add(archivedPath);
1531
+ originalByPath.set(archivedPath, snapshot.manifest_hash);
1532
+ }
1533
+ }
1534
+ const original = new Map();
1535
+ const pathRebound = new Map();
1536
+ for (const relative of tree.files) {
1537
+ const read = readStableProjectFile(rootDir, relative);
1538
+ original.set(relative, read.bytes);
1539
+ const text = read.bytes.toString('utf8');
1540
+ if (!Buffer.from(text, 'utf8').equals(read.bytes)) {
1541
+ if (read.bytes.includes(Buffer.from(intent.source))) {
1542
+ throw new ChangeWorkflowError(
1543
+ 'ARCHIVE_REBIND_UNSUPPORTED',
1544
+ `archive packet contains a binary active-root reference: ${relative}`,
1545
+ );
1546
+ }
1547
+ pathRebound.set(relative, read.bytes);
1548
+ continue;
1549
+ }
1550
+ let next = replaceArchiveAuthorityText(
1551
+ text, intent.source, intent.destination,
1552
+ );
1553
+ if (contextPaths.has(relative)) {
1554
+ let manifest;
1555
+ try { manifest = JSON.parse(next); } catch (error) {
1556
+ throw new ChangeWorkflowError(
1557
+ 'ARCHIVE_CONTEXT_INVALID',
1558
+ `archived Context Manifest cannot be parsed: ${relative}: ${error.message}`,
1559
+ );
1560
+ }
1561
+ if (manifest.change?.id !== intent.change_id) {
1562
+ throw new ChangeWorkflowError(
1563
+ 'ARCHIVE_CONTEXT_INVALID',
1564
+ `archived Context Manifest belongs to another change: ${relative}`,
1565
+ );
1566
+ }
1567
+ manifest.change.status = 'archived';
1568
+ next = `${JSON.stringify(manifest, null, 2)}\n`;
1569
+ }
1570
+ pathRebound.set(relative, Buffer.from(next));
1571
+ }
1572
+
1573
+ let current = new Map(pathRebound);
1574
+ for (let iteration = 0; iteration < 20; iteration += 1) {
1575
+ const digestMap = new Map();
1576
+ for (const [relative, beforeDigest] of originalByPath) {
1577
+ const bytes = current.get(relative);
1578
+ if (bytes) {
1579
+ digestMap.set(
1580
+ beforeDigest,
1581
+ crypto.createHash('sha256').update(bytes).digest('hex'),
1582
+ );
1583
+ }
1584
+ }
1585
+ const next = new Map();
1586
+ let changed = false;
1587
+ for (const [relative, baseBytes] of pathRebound) {
1588
+ const text = baseBytes.toString('utf8');
1589
+ if (!Buffer.from(text, 'utf8').equals(baseBytes)) {
1590
+ next.set(relative, baseBytes);
1591
+ continue;
1592
+ }
1593
+ const bytes = Buffer.from(
1594
+ replaceArchiveAuthorityText(
1595
+ text, intent.source, intent.destination, digestMap,
1596
+ ),
1597
+ );
1598
+ next.set(relative, bytes);
1599
+ if (!bytes.equals(current.get(relative))) changed = true;
1600
+ }
1601
+ current = next;
1602
+ if (!changed) break;
1603
+ if (iteration === 19) {
1604
+ throw new ChangeWorkflowError(
1605
+ 'ARCHIVE_REBIND_CYCLE',
1606
+ 'archive packet digest references did not converge',
1607
+ );
1608
+ }
1609
+ }
1610
+ return [...original.entries()].flatMap(([relative, before]) => {
1611
+ const after = current.get(relative);
1612
+ if (after.equals(before)) return [];
1613
+ return [{
1614
+ relative_path: path.posix.relative(intent.destination, relative),
1615
+ before_digest: crypto.createHash('sha256').update(before).digest('hex'),
1616
+ after_digest: crypto.createHash('sha256').update(after).digest('hex'),
1617
+ before_base64: before.toString('base64'),
1618
+ after_base64: after.toString('base64'),
1619
+ }];
1620
+ }).sort((left, right) => left.relative_path.localeCompare(right.relative_path));
1621
+ }
1622
+
1623
+ function archiveRebindState(db, intent, rootDir) {
1624
+ const existing = archiveJournal.readArchiveRebind(rootDir, intent);
1625
+ const entries = existing?.entries || buildArchiveRebindEntries(db, intent, rootDir);
1626
+ const journal = archiveJournal.prepareArchiveRebind(
1627
+ rootDir, intent, existing ? null : entries,
1628
+ );
1629
+ const byPath = new Map(journal.entries.map((entry) => [
1630
+ `${intent.destination}/${entry.relative_path}`,
1631
+ entry,
1632
+ ]));
1633
+ const digestMap = new Map(journal.entries.map(
1634
+ (entry) => [entry.before_digest, entry.after_digest],
1635
+ ));
1636
+ return { journal, byPath, digestMap };
1637
+ }
1638
+
1639
+ function finalizeArchive(db, intent, { rootDir, journalIntent = intent }) {
1165
1640
  const change = readChange(db, intent.change_id);
1166
1641
  if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${intent.change_id} not found`);
1167
- const destination = safeRelativePath(rootDir, intent.destination);
1168
- const relative = path.relative(rootDir, destination);
1169
- if (change.status === 'archived' && change.artifact_root === relative) {
1642
+ try {
1643
+ archiveJournal.verifyArchiveIntent(rootDir, journalIntent, { location: 'destination' });
1644
+ } catch (error) {
1645
+ throw new ChangeWorkflowError(
1646
+ 'ARCHIVE_PATH_UNSAFE',
1647
+ `archive transition cannot trust its physical destination: ${error.message}`,
1648
+ { cause: error.code || error.message },
1649
+ );
1650
+ }
1651
+ const relative = artifacts.normalizeRelativePath(intent.destination);
1652
+ const destination = safeRelativePath(rootDir, relative);
1653
+ if (change.status === 'archived'
1654
+ && artifacts.normalizeRelativePath(change.artifact_root) === relative) {
1170
1655
  return { change, archive_path: destination };
1171
1656
  }
1172
1657
  if (change.status !== 'ready') {
1173
1658
  throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${change.id} must be ready to finish archive`);
1174
1659
  }
1175
- if (!fs.existsSync(destination)) {
1176
- throw new ChangeWorkflowError('ARCHIVE_DESTINATION_MISSING', `archive destination missing: ${relative}`);
1660
+ const rebind = archiveRebindState(db, journalIntent, rootDir);
1661
+ const archivedReconciliationPath = rebindRegistryPath(
1662
+ intent.reconciliation_path, intent.source, relative,
1663
+ );
1664
+ const reconciliationRead = readStableProjectFile(
1665
+ rootDir, archivedReconciliationPath, { encoding: 'utf8' },
1666
+ );
1667
+ let reconciliationManifest;
1668
+ try { reconciliationManifest = JSON.parse(reconciliationRead.text); } catch (error) {
1669
+ throw new ChangeWorkflowError(
1670
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
1671
+ `archived reconciliation manifest is invalid JSON: ${error.message}`,
1672
+ );
1673
+ }
1674
+ if (!validateReconciliationSchema(reconciliationManifest)
1675
+ || reconciliationManifest.change_id !== change.id) {
1676
+ throw new ChangeWorkflowError(
1677
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
1678
+ 'archived reconciliation manifest no longer matches its accepted schema and Change',
1679
+ { errors: validateReconciliationSchema.errors || [] },
1680
+ );
1681
+ }
1682
+ const reconciliationDigest = reconciliationRead.digest;
1683
+ let summaryReader;
1684
+ try {
1685
+ summaryReader = openStableProjectRead(rootDir, `${relative}/archive-summary.md`);
1686
+ } catch (error) {
1687
+ throw new ChangeWorkflowError(
1688
+ 'ARCHIVE_PATH_UNSAFE', 'archive summary cannot be read safely',
1689
+ { cause: error.code || error.message },
1690
+ );
1177
1691
  }
1178
1692
  const baselineHealthBefore = baselines.inspectBaseline(db, { rootDir });
1179
1693
  const breakGlass = Boolean(
@@ -1181,74 +1695,176 @@ function finalizeArchive(db, intent, { rootDir }) {
1181
1695
  && change.kind === 'incident'
1182
1696
  && change.baseline_bypass?.mode === 'incident_break_glass',
1183
1697
  );
1698
+ try {
1184
1699
  ops.tx(db, () => {
1185
1700
  db.prepare(
1186
1701
  "UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
1187
1702
  ).run(relative, nowIso(), nowIso(), change.id);
1188
- const prefix = `${intent.source}${path.sep}`;
1189
- const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?').all(change.id);
1703
+ const artifactRows = db.prepare(
1704
+ `SELECT id, path, digest, content_hash, before_digest, metadata_json, provenance_json
1705
+ FROM artifacts WHERE change_id = ?`,
1706
+ ).all(change.id);
1190
1707
  for (const artifact of artifactRows) {
1191
- if (artifact.path !== intent.source && !artifact.path.startsWith(prefix)) continue;
1192
- const suffix = path.relative(intent.source, artifact.path);
1193
- const archivedPath = suffix ? path.join(relative, suffix) : relative;
1194
- db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
1195
- .run(archivedPath, nowIso(), artifact.id);
1708
+ const archivedPath = rebindRegistryPath(
1709
+ artifact.path, intent.source, relative,
1710
+ );
1711
+ if (!archivedPath) continue;
1712
+ const rebound = rebind.byPath.get(archivedPath);
1713
+ if (rebound) {
1714
+ db.prepare(
1715
+ `UPDATE artifacts
1716
+ SET digest = ?, content_hash = ?, before_digest = COALESCE(before_digest, ?),
1717
+ after_digest = ?, metadata_json = ?, provenance_json = ?
1718
+ WHERE id = ?`,
1719
+ ).run(
1720
+ rebound.after_digest,
1721
+ rebound.after_digest,
1722
+ artifact.digest || artifact.content_hash || rebound.before_digest,
1723
+ rebound.after_digest,
1724
+ JSON.stringify(archiveRebindAuthorityValue(
1725
+ parseJson(artifact.metadata_json || '{}', 'artifacts.metadata_json'),
1726
+ intent.source,
1727
+ relative,
1728
+ rebind.digestMap,
1729
+ )),
1730
+ JSON.stringify(archiveRebindAuthorityValue(
1731
+ parseJson(artifact.provenance_json || '{}', 'artifacts.provenance_json'),
1732
+ intent.source,
1733
+ relative,
1734
+ rebind.digestMap,
1735
+ )),
1736
+ artifact.id,
1737
+ );
1738
+ }
1739
+ artifacts.moveArtifactInTx(db, artifact.id, archivedPath, { rootDir });
1196
1740
  }
1197
1741
  const workflowOutputs = db.prepare(
1198
- `SELECT ws.run_id, ws.step_id, ws.outputs_json
1742
+ `SELECT ws.run_id, ws.step_id, ws.outputs_json, ws.evidence_json,
1743
+ ws.decisions_json, ws.semantic_records_json, ws.blockers_json
1199
1744
  FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
1200
1745
  WHERE wr.change_id = ?`,
1201
1746
  ).all(change.id);
1202
1747
  for (const step of workflowOutputs) {
1203
- const outputs = parseJson(step.outputs_json, 'workflow_steps.outputs_json');
1204
- let changed = false;
1205
- const moved = outputs.map((output) => {
1206
- if (output.path !== intent.source && !output.path.startsWith(prefix)) return output;
1207
- changed = true;
1208
- const suffix = path.relative(intent.source, output.path);
1209
- return { ...output, path: suffix ? path.join(relative, suffix) : relative };
1210
- });
1211
- if (changed) {
1212
- db.prepare(
1213
- 'UPDATE workflow_steps SET outputs_json = ?, updated_at = ? WHERE run_id = ? AND step_id = ?',
1214
- ).run(JSON.stringify(moved), nowIso(), step.run_id, step.step_id);
1215
- }
1748
+ const rebound = Object.fromEntries([
1749
+ ['outputs_json', parseJson(step.outputs_json, 'workflow_steps.outputs_json')],
1750
+ ['evidence_json', parseJson(step.evidence_json, 'workflow_steps.evidence_json')],
1751
+ ['decisions_json', parseJson(step.decisions_json, 'workflow_steps.decisions_json')],
1752
+ ['semantic_records_json', parseJson(
1753
+ step.semantic_records_json, 'workflow_steps.semantic_records_json',
1754
+ )],
1755
+ ['blockers_json', parseJson(step.blockers_json, 'workflow_steps.blockers_json')],
1756
+ ].map(([field, value]) => [
1757
+ field,
1758
+ JSON.stringify(archiveRebindAuthorityValue(
1759
+ value, intent.source, relative, rebind.digestMap,
1760
+ )),
1761
+ ]));
1762
+ db.prepare(
1763
+ `UPDATE workflow_steps
1764
+ SET outputs_json = ?, evidence_json = ?, decisions_json = ?,
1765
+ semantic_records_json = ?, blockers_json = ?, updated_at = ?
1766
+ WHERE run_id = ? AND step_id = ?`,
1767
+ ).run(
1768
+ rebound.outputs_json,
1769
+ rebound.evidence_json,
1770
+ rebound.decisions_json,
1771
+ rebound.semantic_records_json,
1772
+ rebound.blockers_json,
1773
+ nowIso(),
1774
+ step.run_id,
1775
+ step.step_id,
1776
+ );
1216
1777
  }
1217
1778
  const contextRows = db.prepare(
1218
- 'SELECT id, manifest_path FROM context_snapshots WHERE change_id = ?',
1779
+ `SELECT id, manifest_path, manifest_hash, context_json
1780
+ FROM context_snapshots WHERE change_id = ?`,
1219
1781
  ).all(change.id);
1220
1782
  for (const snapshot of contextRows) {
1221
- if (snapshot.manifest_path !== intent.source && !snapshot.manifest_path.startsWith(prefix)) continue;
1222
- const suffix = path.relative(intent.source, snapshot.manifest_path);
1223
- const archivedPath = suffix ? path.join(relative, suffix) : relative;
1224
- db.prepare('UPDATE context_snapshots SET manifest_path = ? WHERE id = ?')
1225
- .run(archivedPath, snapshot.id);
1783
+ const archivedPath = rebindRegistryPath(
1784
+ snapshot.manifest_path, intent.source, relative,
1785
+ );
1786
+ if (!archivedPath) continue;
1787
+ const rebound = rebind.byPath.get(archivedPath);
1788
+ db.prepare(
1789
+ `UPDATE context_snapshots
1790
+ SET manifest_path = ?, manifest_hash = ?, context_json = ? WHERE id = ?`,
1791
+ ).run(
1792
+ archivedPath,
1793
+ rebound?.after_digest || snapshot.manifest_hash,
1794
+ JSON.stringify(archiveRebindAuthorityValue(
1795
+ parseJson(snapshot.context_json || '{}', 'context_snapshots.context_json'),
1796
+ intent.source,
1797
+ relative,
1798
+ rebind.digestMap,
1799
+ )),
1800
+ snapshot.id,
1801
+ );
1226
1802
  }
1227
- const archivedSummary = path.join(destination, 'archive-summary.md');
1803
+ for (const run of db.prepare(
1804
+ `SELECT id, metadata_json, blockers_json, summary_json, approval_json
1805
+ FROM workflow_runs WHERE change_id = ?`,
1806
+ ).all(change.id)) {
1807
+ db.prepare(
1808
+ `UPDATE workflow_runs
1809
+ SET metadata_json = ?, blockers_json = ?, summary_json = ?, approval_json = ?
1810
+ WHERE id = ?`,
1811
+ ).run(
1812
+ JSON.stringify(archiveRebindAuthorityValue(
1813
+ parseJson(run.metadata_json || '{}', 'workflow_runs.metadata_json'),
1814
+ intent.source, relative, rebind.digestMap,
1815
+ )),
1816
+ JSON.stringify(archiveRebindAuthorityValue(
1817
+ parseJson(run.blockers_json || '[]', 'workflow_runs.blockers_json'),
1818
+ intent.source, relative, rebind.digestMap,
1819
+ )),
1820
+ JSON.stringify(archiveRebindAuthorityValue(
1821
+ parseJson(run.summary_json || '{}', 'workflow_runs.summary_json'),
1822
+ intent.source, relative, rebind.digestMap,
1823
+ )),
1824
+ run.approval_json === null
1825
+ ? null
1826
+ : JSON.stringify(archiveRebindAuthorityValue(
1827
+ parseJson(run.approval_json, 'workflow_runs.approval_json'),
1828
+ intent.source, relative, rebind.digestMap,
1829
+ )),
1830
+ run.id,
1831
+ );
1832
+ }
1833
+ db.prepare(
1834
+ `UPDATE trace_links
1835
+ SET source_ref = REPLACE(source_ref, ?, ?),
1836
+ target_ref = REPLACE(target_ref, ?, ?)
1837
+ WHERE change_id = ?`,
1838
+ ).run(intent.source, relative, intent.source, relative, change.id);
1228
1839
  upsertArtifact(db, {
1229
1840
  change_id: change.id, kind: 'archive_summary',
1230
- artifactPath: path.relative(rootDir, archivedSummary),
1231
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
1841
+ artifactPath: `${relative}/archive-summary.md`,
1842
+ contentHash: summaryReader.digest,
1232
1843
  metadata: {
1233
1844
  baseline_updates: intent.baseline_updates,
1234
1845
  no_baseline_change_reason: intent.no_baseline_change_reason,
1235
1846
  },
1847
+ terminal: true,
1848
+ rootDir,
1236
1849
  });
1237
- const reconciliationSuffix = path.relative(intent.source, intent.reconciliation_path);
1238
- const archivedReconciliationPath = path.join(relative, reconciliationSuffix);
1239
1850
  upsertArtifact(db, {
1240
1851
  change_id: change.id, kind: 'baseline_reconciliation',
1241
1852
  artifactPath: archivedReconciliationPath,
1242
- contentHash: intent.reconciliation_digest,
1853
+ contentHash: reconciliationDigest,
1243
1854
  metadata: {
1244
- schema: intent.reconciliation_manifest.$schema,
1245
- semantic_changes: intent.reconciliation_manifest.semantic_changes.map((item) => item.id),
1246
- resolved_gap_ids: intent.reconciliation_manifest.resolved_gap_ids,
1247
- resolved_unknowns: intent.reconciliation_manifest.resolved_unknowns,
1855
+ schema: reconciliationManifest.$schema,
1856
+ semantic_changes: reconciliationManifest.semantic_changes.map((item) => item.id),
1857
+ resolved_gap_ids: reconciliationManifest.resolved_gap_ids,
1858
+ resolved_unknowns: reconciliationManifest.resolved_unknowns,
1248
1859
  },
1860
+ terminal: true,
1861
+ rootDir,
1249
1862
  });
1250
- db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
1251
- .run(nowIso(), change.id);
1863
+ for (const artifact of db.prepare(
1864
+ 'SELECT id FROM artifacts WHERE change_id = ? ORDER BY id',
1865
+ ).all(change.id)) {
1866
+ artifacts.setArtifactStatusInTx(db, artifact.id, 'archived');
1867
+ }
1252
1868
  if (breakGlass) {
1253
1869
  const currentBaseline = baselines.readBaseline(db);
1254
1870
  if (currentBaseline) {
@@ -1268,7 +1884,8 @@ function finalizeArchive(db, intent, { rootDir }) {
1268
1884
  } else {
1269
1885
  baselines.reconcileBaseline(db, {
1270
1886
  baseline_updates: intent.baseline_updates, change_id: change.id,
1271
- reconciliation: intent.reconciliation_manifest,
1887
+ reconciliation: reconciliationManifest,
1888
+ accept_delivery_worktree: true,
1272
1889
  }, { rootDir });
1273
1890
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
1274
1891
  if (baselineHealth.blockers.length > 0) {
@@ -1285,11 +1902,26 @@ function finalizeArchive(db, intent, { rootDir }) {
1285
1902
  archive_path: relative, baseline_updates: intent.baseline_updates,
1286
1903
  no_baseline_change_reason: intent.no_baseline_change_reason,
1287
1904
  reconciliation_path: archivedReconciliationPath,
1288
- reconciliation_digest: intent.reconciliation_digest,
1905
+ reconciliation_digest: reconciliationDigest,
1289
1906
  baseline_bypass: breakGlass,
1290
1907
  },
1291
1908
  });
1909
+ summaryReader.verify();
1910
+ try {
1911
+ archiveJournal.verifyArchiveIntent(
1912
+ rootDir, journalIntent, { location: 'destination' },
1913
+ );
1914
+ } catch (error) {
1915
+ throw new ChangeWorkflowError(
1916
+ 'ARCHIVE_PATH_UNSAFE',
1917
+ `archive transition changed physical identity before commit: ${error.message}`,
1918
+ { cause: error.code || error.message },
1919
+ );
1920
+ }
1292
1921
  });
1922
+ } finally {
1923
+ summaryReader.close();
1924
+ }
1293
1925
  return {
1294
1926
  change: readChange(db, change.id), archive_path: destination,
1295
1927
  baseline_bypass: breakGlass,
@@ -1302,30 +1934,107 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
1302
1934
  if (!input.summary || String(input.summary).trim().length < 3) {
1303
1935
  throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
1304
1936
  }
1305
- const updates = Array.isArray(input.baseline_updates) ? input.baseline_updates : [];
1306
- const noChangeReason = input.no_baseline_change_reason && String(input.no_baseline_change_reason).trim();
1937
+ if (!['ready', 'archived'].includes(change.status)) {
1938
+ throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
1939
+ }
1940
+ if (change.status === 'archived') {
1941
+ deliveryTransaction.completeDeliveryTransaction({
1942
+ rootDir,
1943
+ changeId: change.id,
1944
+ });
1945
+ return {
1946
+ change,
1947
+ archive_path: safeRelativePath(rootDir, change.artifact_root),
1948
+ idempotent: true,
1949
+ };
1950
+ }
1951
+
1952
+ let packet = null;
1953
+ if (changePacket.readDeltaAuthority(db, change.id)) {
1954
+ workflows.validateDeliveryPrerequisites(db, { change_id: change.id }, rootDir);
1955
+ packet = changePacket.deliveryEntries(db, change, { rootDir });
1956
+ }
1957
+ const requestedUpdates = Array.isArray(input.baseline_updates)
1958
+ ? input.baseline_updates.map((item) => artifacts.normalizeRelativePath(item))
1959
+ : null;
1960
+ const updates = packet
1961
+ ? packet.baseline_updates
1962
+ : (requestedUpdates || []);
1963
+ if (packet && requestedUpdates
1964
+ && JSON.stringify([...requestedUpdates].sort()) !== JSON.stringify([...updates].sort())) {
1965
+ throw new ChangeWorkflowError(
1966
+ 'BASELINE_RECONCILIATION_CONFLICT',
1967
+ 'baseline_updates must match the typed Change delta exactly',
1968
+ );
1969
+ }
1970
+ const requestedNoChange = input.no_baseline_change_reason
1971
+ && String(input.no_baseline_change_reason).trim();
1972
+ const noChangeReason = packet
1973
+ ? (packet.delta.value.no_semantic_change_reason || requestedNoChange || null)
1974
+ : requestedNoChange;
1307
1975
  if (updates.length === 0 && !noChangeReason) {
1308
- throw new ChangeWorkflowError('BASELINE_RECONCILIATION_REQUIRED', 'baseline updates or no-change reason required');
1976
+ throw new ChangeWorkflowError(
1977
+ 'BASELINE_RECONCILIATION_REQUIRED',
1978
+ 'baseline updates or no-change reason required',
1979
+ );
1309
1980
  }
1310
- for (const file of updates) {
1311
- if (!fs.existsSync(safeRelativePath(rootDir, file))) {
1312
- throw new ChangeWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${file}`);
1981
+
1982
+ const reconciliation = packet
1983
+ ? generatePacketReconciliation(db, change, packet, rootDir)
1984
+ : readReconciliationManifest(db, change, input, rootDir);
1985
+ let delivery = null;
1986
+ let prepared = null;
1987
+ try {
1988
+ if (packet) {
1989
+ delivery = deliveryTransaction.beginDeliveryTransaction({
1990
+ rootDir,
1991
+ changeId: change.id,
1992
+ entries: packet.entries,
1993
+ });
1313
1994
  }
1995
+ for (const file of updates) {
1996
+ if (!fs.existsSync(safeRelativePath(rootDir, file))) {
1997
+ throw new ChangeWorkflowError(
1998
+ 'BASELINE_FILE_MISSING', `baseline update missing: ${file}`,
1999
+ );
2000
+ }
2001
+ }
2002
+ prepared = archiveJournal.prepareArchiveMove({
2003
+ rootDir, change, summary: String(input.summary), baselineUpdates: updates,
2004
+ noBaselineChangeReason: noChangeReason || null,
2005
+ reconciliationPath: reconciliation.relative,
2006
+ reconciliationDigest: reconciliation.journal_intent?.reconciliation_digest
2007
+ || reconciliation.digest,
2008
+ reconciliationManifest: reconciliation.journal_intent?.reconciliation_manifest
2009
+ || reconciliation.manifest,
2010
+ });
2011
+ } catch (error) {
2012
+ if (delivery) {
2013
+ try {
2014
+ deliveryTransaction.rollbackDeliveryTransaction({
2015
+ rootDir,
2016
+ changeId: change.id,
2017
+ });
2018
+ } catch (rollbackError) {
2019
+ throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
2020
+ cause: error.code || error.message,
2021
+ rollback: rollbackError.code || rollbackError.message,
2022
+ });
2023
+ }
2024
+ }
2025
+ throw error;
1314
2026
  }
1315
- if (!['ready', 'archived'].includes(change.status)) {
1316
- throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
1317
- }
1318
- const reconciliation = readReconciliationManifest(db, change, input, rootDir);
1319
- const prepared = archiveJournal.prepareArchiveMove({
1320
- rootDir, change, summary: String(input.summary), baselineUpdates: updates,
1321
- noBaselineChangeReason: noChangeReason || null,
1322
- reconciliationPath: reconciliation.relative,
1323
- reconciliationDigest: reconciliation.digest,
1324
- reconciliationManifest: reconciliation.manifest,
1325
- });
2027
+ const authoritativeIntent = {
2028
+ ...prepared.intent,
2029
+ reconciliation_digest: reconciliation.digest,
2030
+ reconciliation_manifest: reconciliation.manifest,
2031
+ };
1326
2032
  let result;
1327
2033
  try {
1328
- result = finalizeArchive(db, prepared.intent, { rootDir });
2034
+ result = finalizeArchive(db, authoritativeIntent, {
2035
+ rootDir,
2036
+ journalIntent: prepared.intent,
2037
+ });
1329
2038
  } catch (error) {
1330
2039
  try { archiveJournal.rollbackArchiveIntent(rootDir, prepared.intent); }
1331
2040
  catch (rollbackError) {
@@ -1333,10 +2042,36 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
1333
2042
  cause: error.code || error.message, rollback: rollbackError.code || rollbackError.message,
1334
2043
  });
1335
2044
  }
2045
+ if (delivery) {
2046
+ try {
2047
+ deliveryTransaction.rollbackDeliveryTransaction({
2048
+ rootDir,
2049
+ changeId: change.id,
2050
+ });
2051
+ } catch (rollbackError) {
2052
+ throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
2053
+ cause: error.code || error.message,
2054
+ rollback: rollbackError.code || rollbackError.message,
2055
+ });
2056
+ }
2057
+ }
1336
2058
  throw error;
1337
2059
  }
1338
2060
  try { archiveJournal.completeArchiveIntent(rootDir, prepared.intent); }
1339
2061
  catch (error) { result.recovery_warning = `ARCHIVE_JOURNAL_CLEANUP_PENDING:${error.message}`; }
2062
+ if (delivery) {
2063
+ try {
2064
+ deliveryTransaction.completeDeliveryTransaction({
2065
+ rootDir,
2066
+ changeId: change.id,
2067
+ });
2068
+ } catch (error) {
2069
+ result.recovery_warning = [
2070
+ result.recovery_warning,
2071
+ `DELIVERY_JOURNAL_CLEANUP_PENDING:${error.message}`,
2072
+ ].filter(Boolean).join(';');
2073
+ }
2074
+ }
1340
2075
  return result;
1341
2076
  }
1342
2077
 
@@ -1355,9 +2090,20 @@ function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
1355
2090
  archiveJournal.completeArchiveIntent(rootDir, record.intent);
1356
2091
  result.cleaned += 1;
1357
2092
  result.items.push({ change_id: change.id, status: 'cleaned' });
1358
- } else if (change?.status === 'ready' && fs.existsSync(record.destination)
1359
- && !fs.existsSync(record.source)) {
1360
- finalizeArchive(db, record.intent, { rootDir });
2093
+ } else if (change?.status === 'ready' && record.location === 'destination') {
2094
+ const reconciliation = readReconciliationManifest(db, change, {
2095
+ reconciliation_path: record.intent.reconciliation_path,
2096
+ baseline_updates: record.intent.baseline_updates,
2097
+ }, rootDir);
2098
+ const authoritativeIntent = {
2099
+ ...record.intent,
2100
+ reconciliation_digest: reconciliation.digest,
2101
+ reconciliation_manifest: reconciliation.manifest,
2102
+ };
2103
+ finalizeArchive(db, authoritativeIntent, {
2104
+ rootDir,
2105
+ journalIntent: record.intent,
2106
+ });
1361
2107
  archiveJournal.completeArchiveIntent(rootDir, record.intent);
1362
2108
  result.resumed += 1;
1363
2109
  result.items.push({ change_id: change.id, status: 'resumed' });
@@ -1383,6 +2129,14 @@ function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
1383
2129
  }
1384
2130
  }
1385
2131
  }
2132
+ const archivedChangeIds = new Set(
2133
+ db.prepare("SELECT id FROM changes WHERE status = 'archived'").all().map((row) => row.id),
2134
+ );
2135
+ result.delivery = deliveryTransaction.recoverDeliveryTransactions({
2136
+ rootDir,
2137
+ archivedChangeIds,
2138
+ });
2139
+ result.failed += result.delivery.failed;
1386
2140
  return result;
1387
2141
  }
1388
2142
 
@@ -1392,6 +2146,8 @@ module.exports = {
1392
2146
  readChange,
1393
2147
  listChanges,
1394
2148
  updateChange,
2149
+ recordDelta,
2150
+ recordDocumentationReconciliation,
1395
2151
  compileContext,
1396
2152
  readBreadcrumb: contextSpine.readBreadcrumb,
1397
2153
  proposeSpecLearning: specLearning.proposeSpecLearning,