ultra-builder-pro-cli 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +53 -1
  4. package/README.md +25 -9
  5. package/adapters/_shared/handbook.cjs +2 -2
  6. package/adapters/tests/codex.test.cjs +8 -0
  7. package/commands/ultra-init.md +1 -1
  8. package/docs/AGENT-CONTEXT.md +4 -3
  9. package/docs/ARCHITECTURE.md +32 -16
  10. package/docs/PLAN.zh-CN.md +5 -4
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  13. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  14. package/hooks/context_spine.py +37 -4
  15. package/mcp-server/lib/archive-journal.cjs +192 -0
  16. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  17. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  18. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  19. package/mcp-server/lib/change-workflow.cjs +249 -75
  20. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  21. package/mcp-server/lib/context-spine.test.cjs +5 -0
  22. package/mcp-server/lib/doctor.cjs +14 -1
  23. package/mcp-server/lib/doctor.test.cjs +42 -4
  24. package/mcp-server/lib/init-project.cjs +292 -9
  25. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  26. package/mcp-server/lib/state-authority.cjs +9 -3
  27. package/mcp-server/lib/state-db.cjs +154 -32
  28. package/mcp-server/lib/state-db.test.cjs +136 -4
  29. package/mcp-server/lib/state-ops.cjs +68 -6
  30. package/mcp-server/lib/task-expander.cjs +7 -1
  31. package/mcp-server/lib/task-expander.test.cjs +71 -0
  32. package/mcp-server/server.cjs +14 -3
  33. package/mcp-server/tests/init-project.test.cjs +162 -1
  34. package/mcp-server/tests/migrate.test.cjs +33 -0
  35. package/mcp-server/tests/server-contract.test.cjs +384 -6
  36. package/mcp-server/tests/state-db.test.cjs +14 -2
  37. package/mcp-server/tests/state-ops.test.cjs +108 -0
  38. package/package.json +1 -1
  39. package/skills/ultra-change/SKILL.md +18 -10
  40. package/skills/ultra-deliver/SKILL.md +13 -9
  41. package/skills/ultra-dev/SKILL.md +6 -4
  42. package/skills/ultra-doctor/SKILL.md +22 -7
  43. package/skills/ultra-init/SKILL.md +104 -59
  44. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  45. package/skills/ultra-plan/SKILL.md +8 -5
  46. package/skills/ultra-research/SKILL.md +12 -5
  47. package/skills/ultra-status/SKILL.md +6 -4
  48. package/spec/cli-protocol.md +11 -1
  49. package/spec/mcp-tools.yaml +95 -9
  50. package/spec/schemas/state-db.sql +11 -0
  51. package/ultra-tools/cli.cjs +9 -5
  52. package/ultra-tools/commands/migrate.cjs +62 -36
  53. package/ultra-tools/commands/status.cjs +18 -9
  54. package/ultra-tools/commands/system.cjs +340 -6
  55. package/ultra-tools/commands/task.cjs +7 -1
  56. package/ultra-tools/status-command.test.cjs +29 -0
  57. package/ultra-tools/system-command.test.cjs +252 -1
@@ -10,6 +10,7 @@ const contextSpine = require('./context-spine.cjs');
10
10
  const specLearning = require('./spec-learning.cjs');
11
11
  const providerRefs = require('./provider-refs.cjs');
12
12
  const baselines = require('./baseline-workflow.cjs');
13
+ const archiveJournal = require('./archive-journal.cjs');
13
14
 
14
15
  const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
15
16
  const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
@@ -60,9 +61,12 @@ function rowToChange(row) {
60
61
  ...row,
61
62
  docs_impact: parseJson(row.docs_impact_json, 'docs_impact_json'),
62
63
  provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json'),
64
+ baseline_bypass: row.baseline_bypass_json
65
+ ? parseJson(row.baseline_bypass_json, 'baseline_bypass_json') : null,
63
66
  };
64
67
  delete change.docs_impact_json;
65
68
  delete change.provider_refs_json;
69
+ delete change.baseline_bypass_json;
66
70
  return change;
67
71
  }
68
72
 
@@ -84,6 +88,29 @@ function normalizeProviderRefs(value) {
84
88
  return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
85
89
  }
86
90
 
91
+ function normalizeBaselineBypass(value) {
92
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
93
+ throw new ChangeWorkflowError(
94
+ 'BASELINE_BYPASS_REQUIRED',
95
+ 'incident work without a ready baseline requires an explicit baseline_bypass record',
96
+ );
97
+ }
98
+ const reason = typeof value.reason === 'string' ? value.reason.trim() : '';
99
+ const approvedBy = typeof value.approved_by === 'string' ? value.approved_by.trim() : '';
100
+ if (reason.length < 3 || !approvedBy) {
101
+ throw new ChangeWorkflowError(
102
+ 'BASELINE_BYPASS_REQUIRED',
103
+ 'baseline_bypass requires reason and approved_by',
104
+ );
105
+ }
106
+ return {
107
+ mode: 'incident_break_glass',
108
+ reason,
109
+ approved_by: approvedBy,
110
+ recorded_at: nowIso(),
111
+ };
112
+ }
113
+
87
114
  function gitHead(rootDir) {
88
115
  const result = spawnSync('git', ['rev-parse', 'HEAD'], {
89
116
  cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
@@ -131,6 +158,14 @@ function writeIntent(file, change) {
131
158
  '',
132
159
  ];
133
160
  if (impact.rationale) lines.push('## Documentation rationale', '', impact.rationale, '');
161
+ if (change.baseline_bypass) {
162
+ lines.push(
163
+ '## Baseline break-glass', '',
164
+ `- Approved by: ${change.baseline_bypass.approved_by}`,
165
+ `- Recorded at: ${change.baseline_bypass.recorded_at}`,
166
+ '', change.baseline_bypass.reason, '',
167
+ );
168
+ }
134
169
  fs.writeFileSync(file, `${lines.join('\n')}\n`);
135
170
  }
136
171
 
@@ -219,6 +254,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
219
254
  change: {
220
255
  id: change.id, title: change.title, kind: change.kind, status: change.status,
221
256
  intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
257
+ baseline_bypass: change.baseline_bypass,
222
258
  },
223
259
  git: { head },
224
260
  tasks,
@@ -299,7 +335,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
299
335
  if (!REQUIRED_EVIDENCE[input.kind]) {
300
336
  throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
301
337
  }
338
+ if (input.baseline_bypass !== undefined && input.kind !== 'incident') {
339
+ throw new ChangeWorkflowError(
340
+ 'VALIDATION_ERROR', 'baseline_bypass is valid only for incident changes',
341
+ );
342
+ }
302
343
  if (readChange(db, input.id)) throw new ChangeWorkflowError('DUPLICATE_CHANGE_ID', `change ${input.id} exists`);
344
+ const baselineHealth = baselines.inspectBaseline(db, { rootDir });
345
+ let baselineBypass = null;
346
+ if (baselineHealth.status !== 'pass') {
347
+ if (input.kind !== 'incident') {
348
+ throw new ChangeWorkflowError(
349
+ 'BASELINE_NOT_READY',
350
+ `ordinary changes require a ready baseline: ${baselineHealth.blockers.join(', ')}`,
351
+ { blockers: baselineHealth.blockers },
352
+ );
353
+ }
354
+ baselineBypass = normalizeBaselineBypass(input.baseline_bypass);
355
+ } else if (input.baseline_bypass !== undefined) {
356
+ throw new ChangeWorkflowError(
357
+ 'VALIDATION_ERROR', 'baseline_bypass is not valid while the project baseline is healthy',
358
+ );
359
+ }
303
360
  const docsImpact = normalizeDocsImpact(input.docs_impact);
304
361
  const providers = normalizeProviderRefs(input.provider_refs);
305
362
  const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
@@ -310,7 +367,7 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
310
367
  const ts = nowIso();
311
368
  const row = {
312
369
  id: input.id, title, kind: input.kind, status: 'active',
313
- intent, docs_impact: docsImpact, provider_refs: providers,
370
+ intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
314
371
  base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
315
372
  created_at: ts, updated_at: ts,
316
373
  };
@@ -319,11 +376,13 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
319
376
  db.prepare(
320
377
  `INSERT INTO changes
321
378
  (id, title, kind, status, intent, docs_impact_json, provider_refs_json,
322
- base_commit, artifact_root, created_at, updated_at)
323
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
379
+ baseline_bypass_json, base_commit, artifact_root, created_at, updated_at)
380
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
324
381
  ).run(
325
382
  row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
326
- JSON.stringify(row.provider_refs), row.base_commit, row.artifact_root, ts, ts,
383
+ JSON.stringify(row.provider_refs), row.baseline_bypass
384
+ ? JSON.stringify(row.baseline_bypass) : null,
385
+ row.base_commit, row.artifact_root, ts, ts,
327
386
  );
328
387
  fs.mkdirSync(artifactDir, { recursive: true });
329
388
  const intentPath = path.join(artifactDir, 'intent.md');
@@ -359,6 +418,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
359
418
  }
360
419
  }
361
420
 
421
+ function assertTaskCreationAllowed(db, input = {}, { rootDir = process.cwd() } = {}) {
422
+ const change = input.change_id ? readChange(db, input.change_id) : null;
423
+ if (input.change_id && !change) {
424
+ throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.change_id} not found`);
425
+ }
426
+ if (change && !['active', 'blocked'].includes(change.status)) {
427
+ throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${change.id} is ${change.status}`);
428
+ }
429
+ const baselineHealth = baselines.inspectBaseline(db, { rootDir });
430
+ if (baselineHealth.status === 'pass') return;
431
+ if (change) {
432
+ // The change writer gate already proved that this work either started from
433
+ // healthy authority or carries an approved incident break-glass record.
434
+ return;
435
+ }
436
+ throw new ChangeWorkflowError(
437
+ 'BASELINE_NOT_READY',
438
+ `ordinary task creation requires a ready baseline or an already-authorized active change: ${baselineHealth.blockers.join(', ')}`,
439
+ { blockers: baselineHealth.blockers, change_id: input.change_id || null },
440
+ );
441
+ }
442
+
362
443
  function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
363
444
  const current = readChange(db, id);
364
445
  if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
@@ -481,11 +562,15 @@ function evidenceBlockers(change, evidence, tasks) {
481
562
  return blockers;
482
563
  }
483
564
 
484
- function baselineConvergenceBlockers(db) {
565
+ function baselineConvergenceBlockers(db, change, rootDir) {
485
566
  const baseline = baselines.readBaseline(db);
567
+ if (baseline && baseline.status === 'ready' && baseline.mode !== 'migrated') return [];
568
+ if (change.kind === 'incident' && change.baseline_bypass?.mode === 'incident_break_glass') {
569
+ return [];
570
+ }
486
571
  if (!baseline) return ['BASELINE_MISSING'];
487
- if (baseline.status !== 'ready') return [`BASELINE_NOT_READY:${baseline.status}`];
488
- return [];
572
+ if (baseline.mode === 'migrated') return ['BASELINE_MIGRATION_REVIEW_REQUIRED'];
573
+ return [`BASELINE_NOT_READY:${baseline.status}`];
489
574
  }
490
575
 
491
576
  function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
@@ -496,7 +581,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
496
581
  }
497
582
  const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
498
583
  const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
499
- for (const blocker of baselineConvergenceBlockers(db)) blockers.add(blocker);
584
+ for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
500
585
  if (tasks.length === 0) blockers.add('NO_TASKS');
501
586
  if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
502
587
  if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
@@ -593,12 +678,98 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
593
678
  return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
594
679
  }
595
680
 
681
+ function finalizeArchive(db, intent, { rootDir }) {
682
+ const change = readChange(db, intent.change_id);
683
+ if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${intent.change_id} not found`);
684
+ const destination = safeRelativePath(rootDir, intent.destination);
685
+ const relative = path.relative(rootDir, destination);
686
+ if (change.status === 'archived' && change.artifact_root === relative) {
687
+ return { change, archive_path: destination };
688
+ }
689
+ if (change.status !== 'ready') {
690
+ throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${change.id} must be ready to finish archive`);
691
+ }
692
+ if (!fs.existsSync(destination)) {
693
+ throw new ChangeWorkflowError('ARCHIVE_DESTINATION_MISSING', `archive destination missing: ${relative}`);
694
+ }
695
+ const baselineHealthBefore = baselines.inspectBaseline(db, { rootDir });
696
+ const breakGlass = Boolean(
697
+ baselineHealthBefore.status !== 'pass'
698
+ && change.kind === 'incident'
699
+ && change.baseline_bypass?.mode === 'incident_break_glass',
700
+ );
701
+ ops.tx(db, () => {
702
+ db.prepare(
703
+ "UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
704
+ ).run(relative, nowIso(), nowIso(), change.id);
705
+ const prefix = `${intent.source}${path.sep}`;
706
+ const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?').all(change.id);
707
+ for (const artifact of artifactRows) {
708
+ if (artifact.path !== intent.source && !artifact.path.startsWith(prefix)) continue;
709
+ const suffix = path.relative(intent.source, artifact.path);
710
+ const archivedPath = suffix ? path.join(relative, suffix) : relative;
711
+ db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
712
+ .run(archivedPath, nowIso(), artifact.id);
713
+ }
714
+ const archivedSummary = path.join(destination, 'archive-summary.md');
715
+ upsertArtifact(db, {
716
+ change_id: change.id, kind: 'archive_summary',
717
+ artifactPath: path.relative(rootDir, archivedSummary),
718
+ contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
719
+ metadata: {
720
+ baseline_updates: intent.baseline_updates,
721
+ no_baseline_change_reason: intent.no_baseline_change_reason,
722
+ },
723
+ });
724
+ db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
725
+ .run(nowIso(), change.id);
726
+ if (breakGlass) {
727
+ const currentBaseline = baselines.readBaseline(db);
728
+ if (currentBaseline) {
729
+ baselines.appendGapInTx(db, {
730
+ baseline_id: currentBaseline.id,
731
+ gap: {
732
+ id: `incident-${change.id}-reconciliation`,
733
+ category: 'baseline_blocker',
734
+ status: 'open',
735
+ blocking: true,
736
+ summary: `Incident ${change.id} was archived under break-glass and requires baseline reconciliation.`,
737
+ evidence_refs: [relative],
738
+ owner: change.baseline_bypass.approved_by,
739
+ },
740
+ });
741
+ }
742
+ } else {
743
+ baselines.reconcileBaseline(db, {
744
+ baseline_updates: intent.baseline_updates, change_id: change.id,
745
+ }, { rootDir });
746
+ const baselineHealth = baselines.inspectBaseline(db, { rootDir });
747
+ if (baselineHealth.blockers.length > 0) {
748
+ throw new ChangeWorkflowError(
749
+ 'BASELINE_RECONCILIATION_INCOMPLETE',
750
+ `baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
751
+ { blockers: baselineHealth.blockers },
752
+ );
753
+ }
754
+ }
755
+ ops.appendEventInTx(db, {
756
+ type: 'change_archived', change_id: change.id,
757
+ payload: {
758
+ archive_path: relative, baseline_updates: intent.baseline_updates,
759
+ no_baseline_change_reason: intent.no_baseline_change_reason,
760
+ baseline_bypass: breakGlass,
761
+ },
762
+ });
763
+ });
764
+ return {
765
+ change: readChange(db, change.id), archive_path: destination,
766
+ baseline_bypass: breakGlass,
767
+ };
768
+ }
769
+
596
770
  function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
597
771
  const change = readChange(db, input.id);
598
772
  if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
599
- if (change.status !== 'ready') {
600
- throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
601
- }
602
773
  if (!input.summary || String(input.summary).trim().length < 3) {
603
774
  throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
604
775
  }
@@ -612,74 +783,74 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
612
783
  throw new ChangeWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${file}`);
613
784
  }
614
785
  }
615
- const source = path.resolve(rootDir, change.artifact_root);
616
- const date = nowIso().slice(0, 10);
617
- const destination = path.join(rootDir, '.ultra', 'changes', 'archive', `${date}-${change.id}`);
618
- if (fs.existsSync(destination)) {
619
- throw new ChangeWorkflowError('ARCHIVE_EXISTS', `archive already exists: ${destination}`);
620
- }
621
- fs.mkdirSync(path.dirname(destination), { recursive: true });
622
- const summaryPath = path.join(source, 'archive-summary.md');
623
- const previousSummary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath) : null;
624
- fs.writeFileSync(summaryPath, [
625
- `# Archived change: ${change.title}`, '', String(input.summary).trim(), '',
626
- '## Baseline reconciliation', '',
627
- ...(updates.length > 0 ? updates.map((file) => `- ${file}`) : [noChangeReason]), '',
628
- ].join('\n'));
629
- fs.renameSync(source, destination);
786
+ if (!['ready', 'archived'].includes(change.status)) {
787
+ throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
788
+ }
789
+ const prepared = archiveJournal.prepareArchiveMove({
790
+ rootDir, change, summary: String(input.summary), baselineUpdates: updates,
791
+ noBaselineChangeReason: noChangeReason || null,
792
+ });
793
+ let result;
630
794
  try {
631
- ops.tx(db, () => {
632
- const relative = path.relative(rootDir, destination);
633
- db.prepare(
634
- "UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
635
- ).run(relative, nowIso(), nowIso(), change.id);
636
- const prefix = `${change.artifact_root}${path.sep}`;
637
- const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?')
638
- .all(change.id);
639
- for (const artifact of artifactRows) {
640
- if (artifact.path !== change.artifact_root && !artifact.path.startsWith(prefix)) continue;
641
- const suffix = path.relative(change.artifact_root, artifact.path);
642
- const archivedPath = suffix ? path.join(relative, suffix) : relative;
643
- db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
644
- .run(archivedPath, nowIso(), artifact.id);
645
- }
646
- const archivedSummary = path.join(destination, 'archive-summary.md');
647
- upsertArtifact(db, {
648
- change_id: change.id,
649
- kind: 'archive_summary',
650
- artifactPath: path.relative(rootDir, archivedSummary),
651
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
652
- metadata: {
653
- baseline_updates: updates,
654
- no_baseline_change_reason: noChangeReason || null,
655
- },
656
- });
657
- db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
658
- .run(nowIso(), change.id);
659
- baselines.reconcileBaseline(db, {
660
- baseline_updates: updates,
661
- change_id: change.id,
662
- }, { rootDir });
663
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
664
- if (baselineHealth.blockers.length > 0) {
665
- throw new ChangeWorkflowError(
666
- 'BASELINE_RECONCILIATION_INCOMPLETE',
667
- `baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
668
- { blockers: baselineHealth.blockers },
669
- );
670
- }
671
- ops.appendEventInTx(db, {
672
- type: 'change_archived', change_id: change.id,
673
- payload: { archive_path: relative, baseline_updates: updates, no_baseline_change_reason: noChangeReason || null },
674
- });
675
- });
795
+ result = finalizeArchive(db, prepared.intent, { rootDir });
676
796
  } catch (error) {
677
- fs.renameSync(destination, source);
678
- if (previousSummary === null) fs.rmSync(summaryPath, { force: true });
679
- else fs.writeFileSync(summaryPath, previousSummary);
797
+ try { archiveJournal.rollbackArchiveIntent(rootDir, prepared.intent); }
798
+ catch (rollbackError) {
799
+ throw new ChangeWorkflowError('ARCHIVE_RECOVERY_REQUIRED', error.message, {
800
+ cause: error.code || error.message, rollback: rollbackError.code || rollbackError.message,
801
+ });
802
+ }
680
803
  throw error;
681
804
  }
682
- return { change: readChange(db, change.id), archive_path: destination };
805
+ try { archiveJournal.completeArchiveIntent(rootDir, prepared.intent); }
806
+ catch (error) { result.recovery_warning = `ARCHIVE_JOURNAL_CLEANUP_PENDING:${error.message}`; }
807
+ return result;
808
+ }
809
+
810
+ function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
811
+ const records = archiveJournal.listArchiveIntents(rootDir);
812
+ const result = { found: records.length, resumed: 0, rolled_back: 0, cleaned: 0, failed: 0, items: [] };
813
+ for (const record of records) {
814
+ if (record.error) {
815
+ result.failed += 1;
816
+ result.items.push({ file: record.file, status: 'failed', error: record.error.code || record.error.message });
817
+ continue;
818
+ }
819
+ const change = readChange(db, record.intent.change_id);
820
+ try {
821
+ if (change?.status === 'archived' && change.artifact_root === record.intent.destination) {
822
+ archiveJournal.completeArchiveIntent(rootDir, record.intent);
823
+ result.cleaned += 1;
824
+ result.items.push({ change_id: change.id, status: 'cleaned' });
825
+ } else if (change?.status === 'ready' && fs.existsSync(record.destination)
826
+ && !fs.existsSync(record.source)) {
827
+ finalizeArchive(db, record.intent, { rootDir });
828
+ archiveJournal.completeArchiveIntent(rootDir, record.intent);
829
+ result.resumed += 1;
830
+ result.items.push({ change_id: change.id, status: 'resumed' });
831
+ } else {
832
+ archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
833
+ result.rolled_back += 1;
834
+ result.items.push({ change_id: record.intent.change_id, status: 'rolled_back' });
835
+ }
836
+ } catch (error) {
837
+ try {
838
+ archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
839
+ result.rolled_back += 1;
840
+ result.items.push({
841
+ change_id: record.intent.change_id, status: 'rolled_back',
842
+ error: error.code || error.message,
843
+ });
844
+ } catch (rollbackError) {
845
+ result.failed += 1;
846
+ result.items.push({
847
+ change_id: record.intent.change_id, status: 'failed',
848
+ error: `${error.code || error.message}; ${rollbackError.code || rollbackError.message}`,
849
+ });
850
+ }
851
+ }
852
+ }
853
+ return result;
683
854
  }
684
855
 
685
856
  module.exports = {
@@ -697,4 +868,7 @@ module.exports = {
697
868
  archiveChange,
698
869
  normalizeDocsImpact,
699
870
  normalizeProviderRefs,
871
+ normalizeBaselineBypass,
872
+ assertTaskCreationAllowed,
873
+ recoverInterruptedArchives,
700
874
  };