ultra-builder-pro-cli 0.11.0 → 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 (75) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/architecture.md +54 -223
  3. package/.ultra-template/specs/discovery.md +41 -228
  4. package/.ultra-template/specs/product.md +44 -200
  5. package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
  6. package/.ultra-template/tasks/tasks.json +6 -0
  7. package/CHANGELOG.md +94 -1
  8. package/README.md +37 -9
  9. package/adapters/_shared/codex-assets.cjs +1 -1
  10. package/adapters/_shared/handbook.cjs +5 -4
  11. package/adapters/_shared/tests/handbook.test.cjs +4 -0
  12. package/adapters/opencode.js +8 -2
  13. package/adapters/tests/codex.test.cjs +10 -2
  14. package/adapters/tests/kimi-hook.test.cjs +1 -1
  15. package/adapters/tests/opencode.test.cjs +36 -1
  16. package/commands/ultra-init.md +3 -3
  17. package/docs/AGENT-CONTEXT.md +17 -8
  18. package/docs/ARCHITECTURE.md +51 -8
  19. package/docs/PLAN.zh-CN.md +9 -6
  20. package/docs/ROADMAP.md +5 -3
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
  22. package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
  23. package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
  24. package/hooks/README.md +4 -3
  25. package/hooks/adapters/codex.py +1 -1
  26. package/hooks/adapters/kimi.py +1 -1
  27. package/hooks/context_spine.py +153 -16
  28. package/hooks/health_check.py +11 -0
  29. package/hooks/pre_stop_check.py +6 -5
  30. package/mcp-server/lib/archive-journal.cjs +192 -0
  31. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  32. package/mcp-server/lib/baseline-workflow.cjs +781 -0
  33. package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
  34. package/mcp-server/lib/change-workflow.cjs +256 -85
  35. package/mcp-server/lib/change-workflow.test.cjs +436 -6
  36. package/mcp-server/lib/context-spine.cjs +74 -9
  37. package/mcp-server/lib/context-spine.test.cjs +20 -0
  38. package/mcp-server/lib/doctor.cjs +28 -1
  39. package/mcp-server/lib/doctor.test.cjs +63 -1
  40. package/mcp-server/lib/init-project.cjs +334 -16
  41. package/mcp-server/lib/provider-refs.cjs +42 -0
  42. package/mcp-server/lib/spec-learning.test.cjs +5 -0
  43. package/mcp-server/lib/state-authority.cjs +9 -3
  44. package/mcp-server/lib/state-db.cjs +202 -21
  45. package/mcp-server/lib/state-db.test.cjs +140 -3
  46. package/mcp-server/lib/state-ops.cjs +68 -6
  47. package/mcp-server/lib/task-expander.cjs +7 -1
  48. package/mcp-server/lib/task-expander.test.cjs +71 -0
  49. package/mcp-server/server.cjs +62 -6
  50. package/mcp-server/tests/init-project.test.cjs +216 -9
  51. package/mcp-server/tests/migrate.test.cjs +33 -0
  52. package/mcp-server/tests/server-contract.test.cjs +478 -3
  53. package/mcp-server/tests/state-db.test.cjs +23 -2
  54. package/mcp-server/tests/state-ops.test.cjs +108 -0
  55. package/package.json +1 -1
  56. package/skills/ultra-change/SKILL.md +32 -13
  57. package/skills/ultra-deliver/SKILL.md +19 -5
  58. package/skills/ultra-dev/SKILL.md +8 -2
  59. package/skills/ultra-doctor/SKILL.md +30 -9
  60. package/skills/ultra-init/SKILL.md +107 -66
  61. package/skills/ultra-init/references/brownfield-adoption.md +93 -0
  62. package/skills/ultra-plan/SKILL.md +15 -5
  63. package/skills/ultra-research/SKILL.md +17 -5
  64. package/skills/ultra-status/SKILL.md +15 -6
  65. package/spec/cli-protocol.md +15 -1
  66. package/spec/mcp-tools.yaml +316 -11
  67. package/spec/schemas/mcp-tools.schema.json +1 -1
  68. package/spec/schemas/state-db.sql +44 -2
  69. package/ultra-tools/cli.cjs +9 -5
  70. package/ultra-tools/commands/migrate.cjs +62 -36
  71. package/ultra-tools/commands/status.cjs +29 -2
  72. package/ultra-tools/commands/system.cjs +340 -6
  73. package/ultra-tools/commands/task.cjs +11 -2
  74. package/ultra-tools/status-command.test.cjs +49 -0
  75. package/ultra-tools/system-command.test.cjs +256 -0
@@ -8,12 +8,11 @@ const { spawnSync } = require('node:child_process');
8
8
  const ops = require('./state-ops.cjs');
9
9
  const contextSpine = require('./context-spine.cjs');
10
10
  const specLearning = require('./spec-learning.cjs');
11
+ const providerRefs = require('./provider-refs.cjs');
12
+ const baselines = require('./baseline-workflow.cjs');
13
+ const archiveJournal = require('./archive-journal.cjs');
11
14
 
12
15
  const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
13
- const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
14
- const PROVIDER_FIELDS = new Set([
15
- 'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
16
- ]);
17
16
  const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
18
17
  const CHANGE_TRANSITIONS = Object.freeze({
19
18
  active: new Set(['active', 'blocked', 'cancelled']),
@@ -62,9 +61,12 @@ function rowToChange(row) {
62
61
  ...row,
63
62
  docs_impact: parseJson(row.docs_impact_json, 'docs_impact_json'),
64
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,
65
66
  };
66
67
  delete change.docs_impact_json;
67
68
  delete change.provider_refs_json;
69
+ delete change.baseline_bypass_json;
68
70
  return change;
69
71
  }
70
72
 
@@ -83,29 +85,30 @@ function normalizeDocsImpact(value) {
83
85
  }
84
86
 
85
87
  function normalizeProviderRefs(value) {
86
- if (value == null) return {};
87
- if (typeof value !== 'object' || Array.isArray(value)) {
88
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'provider_refs must be an object');
89
- }
90
- const out = {};
91
- for (const [kind, ref] of Object.entries(value)) {
92
- if (!PROVIDER_KINDS.has(kind) || !ref || typeof ref !== 'object' || Array.isArray(ref)) {
93
- throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid external provider reference: ${kind}`);
94
- }
95
- for (const key of Object.keys(ref)) {
96
- if (!PROVIDER_FIELDS.has(key)) {
97
- throw new ChangeWorkflowError(
98
- 'PROVIDER_CONTENT_FORBIDDEN',
99
- `provider_refs.${kind}.${key} is not metadata; provider content remains external`,
100
- );
101
- }
102
- }
103
- if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
104
- throw new ChangeWorkflowError('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
105
- }
106
- out[kind] = { ...ref };
88
+ return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
89
+ }
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
+ );
107
97
  }
108
- return out;
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
+ };
109
112
  }
110
113
 
111
114
  function gitHead(rootDir) {
@@ -155,6 +158,14 @@ function writeIntent(file, change) {
155
158
  '',
156
159
  ];
157
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
+ }
158
169
  fs.writeFileSync(file, `${lines.join('\n')}\n`);
159
170
  }
160
171
 
@@ -243,6 +254,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
243
254
  change: {
244
255
  id: change.id, title: change.title, kind: change.kind, status: change.status,
245
256
  intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
257
+ baseline_bypass: change.baseline_bypass,
246
258
  },
247
259
  git: { head },
248
260
  tasks,
@@ -254,6 +266,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
254
266
  readiness: spine.readiness,
255
267
  context: spine.context,
256
268
  execution_contract: spine.execution_contract,
269
+ baseline: spine.baseline,
257
270
  resume: spine.resume,
258
271
  providers,
259
272
  provider_boundary: 'metadata references only; memory and code graph content remain external',
@@ -322,7 +335,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
322
335
  if (!REQUIRED_EVIDENCE[input.kind]) {
323
336
  throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
324
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
+ }
325
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
+ }
326
360
  const docsImpact = normalizeDocsImpact(input.docs_impact);
327
361
  const providers = normalizeProviderRefs(input.provider_refs);
328
362
  const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
@@ -333,7 +367,7 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
333
367
  const ts = nowIso();
334
368
  const row = {
335
369
  id: input.id, title, kind: input.kind, status: 'active',
336
- intent, docs_impact: docsImpact, provider_refs: providers,
370
+ intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
337
371
  base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
338
372
  created_at: ts, updated_at: ts,
339
373
  };
@@ -342,11 +376,13 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
342
376
  db.prepare(
343
377
  `INSERT INTO changes
344
378
  (id, title, kind, status, intent, docs_impact_json, provider_refs_json,
345
- base_commit, artifact_root, created_at, updated_at)
346
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
379
+ baseline_bypass_json, base_commit, artifact_root, created_at, updated_at)
380
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
347
381
  ).run(
348
382
  row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
349
- 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,
350
386
  );
351
387
  fs.mkdirSync(artifactDir, { recursive: true });
352
388
  const intentPath = path.join(artifactDir, 'intent.md');
@@ -382,6 +418,28 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
382
418
  }
383
419
  }
384
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
+
385
443
  function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
386
444
  const current = readChange(db, id);
387
445
  if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
@@ -504,6 +562,17 @@ function evidenceBlockers(change, evidence, tasks) {
504
562
  return blockers;
505
563
  }
506
564
 
565
+ function baselineConvergenceBlockers(db, change, rootDir) {
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
+ }
571
+ if (!baseline) return ['BASELINE_MISSING'];
572
+ if (baseline.mode === 'migrated') return ['BASELINE_MIGRATION_REVIEW_REQUIRED'];
573
+ return [`BASELINE_NOT_READY:${baseline.status}`];
574
+ }
575
+
507
576
  function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
508
577
  const change = readChange(db, input.id);
509
578
  if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
@@ -512,6 +581,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
512
581
  }
513
582
  const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
514
583
  const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
584
+ for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
515
585
  if (tasks.length === 0) blockers.add('NO_TASKS');
516
586
  if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
517
587
  if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
@@ -608,12 +678,98 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
608
678
  return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
609
679
  }
610
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
+
611
770
  function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
612
771
  const change = readChange(db, input.id);
613
772
  if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
614
- if (change.status !== 'ready') {
615
- throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
616
- }
617
773
  if (!input.summary || String(input.summary).trim().length < 3) {
618
774
  throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
619
775
  }
@@ -627,62 +783,74 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
627
783
  throw new ChangeWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${file}`);
628
784
  }
629
785
  }
630
- const source = path.resolve(rootDir, change.artifact_root);
631
- const date = nowIso().slice(0, 10);
632
- const destination = path.join(rootDir, '.ultra', 'changes', 'archive', `${date}-${change.id}`);
633
- if (fs.existsSync(destination)) {
634
- throw new ChangeWorkflowError('ARCHIVE_EXISTS', `archive already exists: ${destination}`);
635
- }
636
- fs.mkdirSync(path.dirname(destination), { recursive: true });
637
- const summaryPath = path.join(source, 'archive-summary.md');
638
- const previousSummary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath) : null;
639
- fs.writeFileSync(summaryPath, [
640
- `# Archived change: ${change.title}`, '', String(input.summary).trim(), '',
641
- '## Baseline reconciliation', '',
642
- ...(updates.length > 0 ? updates.map((file) => `- ${file}`) : [noChangeReason]), '',
643
- ].join('\n'));
644
- 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;
645
794
  try {
646
- ops.tx(db, () => {
647
- const relative = path.relative(rootDir, destination);
648
- db.prepare(
649
- "UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
650
- ).run(relative, nowIso(), nowIso(), change.id);
651
- const prefix = `${change.artifact_root}${path.sep}`;
652
- const artifactRows = db.prepare('SELECT id, path FROM artifacts WHERE change_id = ?')
653
- .all(change.id);
654
- for (const artifact of artifactRows) {
655
- if (artifact.path !== change.artifact_root && !artifact.path.startsWith(prefix)) continue;
656
- const suffix = path.relative(change.artifact_root, artifact.path);
657
- const archivedPath = suffix ? path.join(relative, suffix) : relative;
658
- db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
659
- .run(archivedPath, nowIso(), artifact.id);
660
- }
661
- const archivedSummary = path.join(destination, 'archive-summary.md');
662
- upsertArtifact(db, {
663
- change_id: change.id,
664
- kind: 'archive_summary',
665
- artifactPath: path.relative(rootDir, archivedSummary),
666
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(archivedSummary)).digest('hex'),
667
- metadata: {
668
- baseline_updates: updates,
669
- no_baseline_change_reason: noChangeReason || null,
670
- },
671
- });
672
- db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
673
- .run(nowIso(), change.id);
674
- ops.appendEventInTx(db, {
675
- type: 'change_archived', change_id: change.id,
676
- payload: { archive_path: relative, baseline_updates: updates, no_baseline_change_reason: noChangeReason || null },
677
- });
678
- });
795
+ result = finalizeArchive(db, prepared.intent, { rootDir });
679
796
  } catch (error) {
680
- fs.renameSync(destination, source);
681
- if (previousSummary === null) fs.rmSync(summaryPath, { force: true });
682
- 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
+ }
683
803
  throw error;
684
804
  }
685
- 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;
686
854
  }
687
855
 
688
856
  module.exports = {
@@ -700,4 +868,7 @@ module.exports = {
700
868
  archiveChange,
701
869
  normalizeDocsImpact,
702
870
  normalizeProviderRefs,
871
+ normalizeBaselineBypass,
872
+ assertTaskCreationAllowed,
873
+ recoverInterruptedArchives,
703
874
  };