ultra-builder-pro-cli 0.24.0 → 0.24.2

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.
@@ -1,2751 +1,60 @@
1
1
  'use strict';
2
2
 
3
- const crypto = require('node:crypto');
4
- const fs = require('node:fs');
5
- const path = require('node:path');
6
- const { spawnSync } = require('node:child_process');
7
- const Ajv = require('ajv/dist/2020');
8
-
9
- const ops = require('./state-ops.cjs');
10
- const artifacts = require('./artifact-registry.cjs');
11
- const canonical = require('./canonical-json.cjs');
12
- const { writeManagedFile } = require('./managed-file-write.cjs');
13
- const providerRefs = require('./provider-refs.cjs');
14
3
  const baselines = require('./baseline-workflow.cjs');
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');
23
- const stageCheckpoints = require('./stage-checkpoints.cjs');
24
- const reconciliationSchema = require('../../spec/schemas/baseline-reconciliation.v1.schema.json');
25
-
26
- const reconciliationAjv = new Ajv({ allErrors: true, strict: false });
27
- const validateReconciliationSchema = reconciliationAjv.compile(reconciliationSchema);
28
-
29
- function loadLegacyModule(filename) {
30
- return module.require(path.join(__dirname, filename));
31
- }
32
-
33
- function legacyContextSpine() {
34
- return loadLegacyModule('context-spine.cjs');
35
- }
36
-
37
- function legacySpecLearning() {
38
- return loadLegacyModule('spec-learning.cjs');
39
- }
40
-
41
- function legacyWorkflows() {
42
- return loadLegacyModule('workflow-state.cjs');
43
- }
44
-
45
- function legacyDecisions() {
46
- return loadLegacyModule('decision-dialogue.cjs');
47
- }
48
-
49
- const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
50
- const CHANGE_PATCH_FIELDS = new Set([
51
- 'title', 'intent', 'status', 'docs_impact', 'provider_refs',
52
- 'contract', 'classification', 'research_disposition',
53
- ]);
54
- const SEMANTIC_AUTHORITY_FIELDS = new Set([
55
- 'intent', 'docs_impact', 'provider_refs', 'contract', 'classification',
56
- 'research_disposition',
57
- ]);
58
- const CHANGE_TRANSITIONS = Object.freeze({
59
- active: new Set(['active', 'blocked', 'cancelled']),
60
- blocked: new Set(['active', 'blocked', 'cancelled']),
61
- ready: new Set(['active', 'ready', 'cancelled']),
62
- archived: new Set(),
63
- cancelled: new Set(),
64
- });
65
- const CHANGE_KINDS = new Set(['quick', 'standard', 'major', 'incident']);
66
- const CHANGE_RISK_FLAGS = new Set([
67
- 'public_contract', 'schema_migration', 'data_migration', 'authorization', 'security',
68
- 'multi_module', 'external_integration', 'release_semantics',
69
- ]);
70
- const QUICK_EXCLUSION_FLAGS = new Set(CHANGE_RISK_FLAGS);
71
- const RESEARCH_DISPOSITIONS = new Set(['none', 'bounded', 'required']);
72
- const CHANGE_RESEARCH_MODES = new Set(['product', 'feature', 'architecture', 'custom']);
73
- const INCIDENT_DIAGNOSIS_SECTIONS = Object.freeze([
74
- { heading: 'Reproduction', key: 'reproduction' },
75
- { heading: 'Hypotheses', key: 'hypotheses' },
76
- { heading: 'Root cause', key: 'root-cause' },
77
- { heading: 'Regression test', key: 'regression-test' },
78
- { heading: 'Recovery', key: 'recovery' },
79
- ]);
80
-
81
- class ChangeWorkflowError extends Error {
82
- constructor(code, message, details) {
83
- super(message);
84
- this.name = 'ChangeWorkflowError';
85
- this.code = code;
86
- if (details !== undefined) this.details = details;
87
- }
88
- }
89
-
90
- function nowIso() {
91
- return new Date().toISOString();
92
- }
93
-
94
- function parseJson(value, field) {
95
- try { return JSON.parse(value); }
96
- catch (error) {
97
- throw new ChangeWorkflowError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
98
- }
99
- }
100
-
101
- function rowToChange(row) {
102
- if (!row) return null;
103
- const change = {
104
- ...row,
105
- docs_impact: parseJson(row.docs_impact_json, 'docs_impact_json'),
106
- provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json'),
107
- baseline_bypass: row.baseline_bypass_json
108
- ? parseJson(row.baseline_bypass_json, 'baseline_bypass_json') : null,
109
- contract: parseJson(row.contract_json || '{}', 'contract_json'),
110
- classification: parseJson(row.classification_json || '{}', 'classification_json'),
111
- research_disposition: parseJson(
112
- row.research_disposition_json || '{}', 'research_disposition_json',
113
- ),
114
- };
115
- delete change.docs_impact_json;
116
- delete change.provider_refs_json;
117
- delete change.baseline_bypass_json;
118
- delete change.contract_json;
119
- delete change.classification_json;
120
- delete change.research_disposition_json;
121
- return change;
122
- }
123
-
124
- function normalizeDocsImpact(value) {
125
- const input = value || {};
126
- const status = input.status || 'unknown';
127
- if (!['unknown', 'required', 'none'].includes(status)) {
128
- throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid docs impact status: ${status}`);
129
- }
130
- const files = input.files === undefined ? [] : input.files;
131
- if (!Array.isArray(files) || files.some((file) => typeof file !== 'string' || !file.trim())) {
132
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact files must be non-empty strings');
133
- }
134
- const rationale = input.rationale == null ? null : String(input.rationale).trim();
135
- return { status, files: [...new Set(files)], rationale };
136
- }
137
-
138
- function normalizeProviderRefs(value) {
139
- return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
140
- }
141
-
142
- function requiredText(value, field) {
143
- const text = typeof value === 'string' ? value.trim() : '';
144
- if (!text) throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `${field} is required`);
145
- return text;
146
- }
147
-
148
- function stringList(value, field, { allowEmpty = false } = {}) {
149
- if (!Array.isArray(value) || (!allowEmpty && value.length === 0)
150
- || value.some((item) => typeof item !== 'string' || !item.trim())) {
151
- throw new ChangeWorkflowError(
152
- 'CHANGE_CONTRACT_REQUIRED', `${field} must be ${allowEmpty ? 'an' : 'a non-empty'} array of strings`,
153
- );
154
- }
155
- return [...new Set(value.map((item) => item.trim()))];
156
- }
157
-
158
- function normalizeChangeContract(value) {
159
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
160
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract is required');
161
- }
162
- if (!Array.isArray(value.acceptance) || value.acceptance.length === 0) {
163
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.acceptance is required');
164
- }
165
- const ids = new Set();
166
- const acceptance = value.acceptance.map((item, index) => {
167
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
168
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `contract.acceptance[${index}] is invalid`);
169
- }
170
- const id = requiredText(item.id, `contract.acceptance[${index}].id`);
171
- if (!CHANGE_ID.test(id) || ids.has(id)) {
172
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `acceptance id is invalid or duplicated: ${id}`);
173
- }
174
- ids.add(id);
175
- return {
176
- id,
177
- criterion: requiredText(item.criterion, `contract.acceptance[${index}].criterion`),
178
- verification: requiredText(item.verification, `contract.acceptance[${index}].verification`),
179
- };
180
- });
181
- const recovery = value.recovery;
182
- if (!recovery || typeof recovery !== 'object' || Array.isArray(recovery)) {
183
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.recovery is required');
184
- }
185
- if (!Array.isArray(value.unresolved_decisions)) {
186
- throw new ChangeWorkflowError(
187
- 'CHANGE_CONTRACT_REQUIRED', 'contract.unresolved_decisions must be an explicit array',
188
- );
189
- }
190
- const decisions = value.unresolved_decisions.map((item, index) => {
191
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
192
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `unresolved decision ${index} is invalid`);
193
- }
194
- return {
195
- id: requiredText(item.id, `contract.unresolved_decisions[${index}].id`),
196
- summary: requiredText(item.summary, `contract.unresolved_decisions[${index}].summary`),
197
- blocking: item.blocking === true,
198
- owner: item.owner == null ? null : requiredText(item.owner, `contract.unresolved_decisions[${index}].owner`),
199
- };
200
- });
201
- return {
202
- outcome: requiredText(value.outcome, 'contract.outcome'),
203
- acceptance,
204
- non_goals: stringList(value.non_goals, 'contract.non_goals'),
205
- public_seams: stringList(value.public_seams, 'contract.public_seams'),
206
- recovery: {
207
- strategy: requiredText(recovery.strategy, 'contract.recovery.strategy'),
208
- verification: requiredText(recovery.verification, 'contract.recovery.verification'),
209
- },
210
- unresolved_decisions: decisions,
211
- };
212
- }
213
-
214
- function normalizeClassification(value, kind) {
215
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
216
- throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', 'classification is required');
217
- }
218
- const flags = stringList(value.risk_flags, 'classification.risk_flags', { allowEmpty: true });
219
- const invalid = flags.filter((flag) => !CHANGE_RISK_FLAGS.has(flag));
220
- if (invalid.length > 0) {
221
- throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', `unknown risk flags: ${invalid.join(', ')}`);
222
- }
223
- if (kind === 'quick') {
224
- const excluded = flags.filter((flag) => QUICK_EXCLUSION_FLAGS.has(flag));
225
- if (excluded.length > 0) {
226
- throw new ChangeWorkflowError(
227
- 'CHANGE_PROFILE_ESCALATION_REQUIRED',
228
- `quick changes cannot carry these risk flags: ${excluded.join(', ')}`,
229
- );
230
- }
231
- }
232
- return {
233
- rationale: requiredText(value.rationale, 'classification.rationale'),
234
- risk_flags: flags,
235
- };
236
- }
237
-
238
- function normalizeResearchDisposition(value, kind) {
239
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
240
- throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research_disposition is required');
241
- }
242
- const status = String(value.status || '').trim();
243
- if (!RESEARCH_DISPOSITIONS.has(status)) {
244
- throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research disposition status is invalid');
245
- }
246
- const selectedSteps = stringList(
247
- value.selected_steps || [], 'research_disposition.selected_steps', { allowEmpty: true },
248
- );
249
- const mode = value.mode == null ? null : String(value.mode).trim();
250
- if (status === 'none' && (mode !== null || selectedSteps.length > 0)) {
251
- throw new ChangeWorkflowError(
252
- 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research status none cannot select a mode or steps',
253
- );
254
- }
255
- if (status !== 'none' && !CHANGE_RESEARCH_MODES.has(mode)) {
256
- throw new ChangeWorkflowError(
257
- 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'bounded or required research needs a supported mode',
258
- );
259
- }
260
- if (mode === 'custom' && selectedSteps.length === 0) {
261
- throw new ChangeWorkflowError(
262
- 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'custom research needs selected_steps',
263
- );
264
- }
265
- if (kind === 'quick' && status !== 'none') {
266
- throw new ChangeWorkflowError(
267
- 'CHANGE_PROFILE_ESCALATION_REQUIRED', 'a change that requires research cannot use the quick profile',
268
- );
269
- }
270
- return {
271
- status, mode, selected_steps: selectedSteps,
272
- rationale: requiredText(value.rationale, 'research_disposition.rationale'),
273
- };
274
- }
275
-
276
- function normalizeBaselineBypass(value) {
277
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
278
- throw new ChangeWorkflowError(
279
- 'BASELINE_BYPASS_REQUIRED',
280
- 'incident work without a ready baseline requires an explicit baseline_bypass record',
281
- );
282
- }
283
- const reason = typeof value.reason === 'string' ? value.reason.trim() : '';
284
- const approvedBy = typeof value.approved_by === 'string' ? value.approved_by.trim() : '';
285
- if (reason.length < 3 || !approvedBy) {
286
- throw new ChangeWorkflowError(
287
- 'BASELINE_BYPASS_REQUIRED',
288
- 'baseline_bypass requires reason and approved_by',
289
- );
290
- }
291
- return {
292
- mode: 'incident_break_glass',
293
- reason,
294
- approved_by: approvedBy,
295
- recorded_at: nowIso(),
296
- };
297
- }
298
-
299
- function gitHead(rootDir) {
300
- const result = spawnSync('git', ['rev-parse', 'HEAD'], {
301
- cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
302
- });
303
- return result.status === 0 ? result.stdout.trim() : null;
304
- }
305
-
306
- function safeRelativePath(rootDir, candidate) {
307
- if (typeof candidate !== 'string' || !candidate.trim() || path.isAbsolute(candidate)) {
308
- throw new ChangeWorkflowError('VALIDATION_ERROR', `path must be project-relative: ${candidate}`);
309
- }
310
- const root = path.resolve(rootDir);
311
- const target = path.resolve(root, candidate);
312
- if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
313
- throw new ChangeWorkflowError('VALIDATION_ERROR', `path escapes project root: ${candidate}`);
314
- }
315
- return target;
316
- }
4
+ const compatibility = require('./legacy-change-workflow.cjs');
317
5
 
318
- function rebindRegistryPath(candidate, source, destination) {
319
- const current = artifacts.normalizeRelativePath(candidate);
320
- const from = artifacts.normalizeRelativePath(source);
321
- const to = artifacts.normalizeRelativePath(destination);
322
- if (current !== from && !current.startsWith(`${from}/`)) return null;
323
- const suffix = current === from ? '' : path.posix.relative(from, current);
324
- return suffix ? path.posix.join(to, suffix) : to;
6
+ function createChange(db, input, options = {}) {
7
+ return compatibility.createKernelChange(db, input, options);
325
8
  }
326
9
 
327
- function assertReconciliationAuthority(db, change, relative, digest) {
328
- const rows = db.prepare(
329
- "SELECT * FROM artifacts WHERE status <> 'archived' ORDER BY id",
330
- ).all().filter((item) => {
331
- try { return artifacts.normalizeRelativePath(item.path) === relative; }
332
- catch { return false; }
333
- });
334
- const row = rows.length === 1 ? rows[0] : null;
335
- const authorityDigest = row?.digest || row?.content_hash || null;
336
- if (!row || row.managed !== 1 || row.owner_type !== 'change'
337
- || row.owner_id !== change.id || row.change_id !== change.id
338
- || row.kind !== 'baseline_reconciliation' || row.status !== 'current'
339
- || authorityDigest !== digest) {
340
- throw new ChangeWorkflowError(
341
- 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
342
- 'reconciliation manifest must match one current managed Change artifact authority',
343
- {
344
- path: relative,
345
- artifact_ids: rows.map((item) => item.id),
346
- expected_owner: `change:${change.id}`,
347
- expected_kind: 'baseline_reconciliation',
348
- expected_digest: digest,
349
- actual_digest: authorityDigest,
350
- },
351
- );
352
- }
353
- return row;
354
- }
355
-
356
- function openReconciliationReader(rootDir, relative) {
357
- try {
358
- return openStableProjectRead(rootDir, relative);
359
- } catch (error) {
360
- if (error?.code === 'PROJECT_FILE_MISSING') return null;
361
- throw new ChangeWorkflowError(
362
- 'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
363
- `reconciliation manifest cannot be read safely: ${relative}`,
364
- { cause: error.code || error.message },
365
- );
366
- }
367
- }
368
-
369
- function readReconciliationManifest(db, change, input, rootDir) {
370
- const requested = typeof input.reconciliation_path === 'string'
371
- ? input.reconciliation_path.trim() : '';
372
- if (!requested) {
373
- throw new ChangeWorkflowError(
374
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation_path is required',
375
- );
376
- }
377
- let relative;
378
- try { relative = artifacts.normalizeRelativePath(requested); }
379
- catch (error) {
380
- throw new ChangeWorkflowError(
381
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', error.message,
382
- );
383
- }
384
- let file = safeRelativePath(rootDir, relative);
385
- const artifactRoot = path.resolve(rootDir, change.artifact_root);
386
- if (!file.startsWith(`${artifactRoot}${path.sep}`)) {
387
- throw new ChangeWorkflowError(
388
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED',
389
- 'reconciliation manifest must be a file inside the active change artifact root',
390
- );
391
- }
392
- let reader = openReconciliationReader(rootDir, relative);
393
- let manifest;
394
- let digest;
395
- let expectedDigest = null;
396
- let authorityDigest = null;
397
- let journalIntent = null;
398
- if (!reader) {
399
- const pending = archiveJournal.listArchiveIntents(rootDir).find(
400
- (item) => !item.error && item.intent.change_id === change.id
401
- && item.intent.reconciliation_path === relative,
402
- );
403
- if (pending) {
404
- journalIntent = pending.intent;
405
- const suffix = path.posix.relative(pending.intent.source, relative);
406
- const archivedRelative = path.posix.join(pending.intent.destination, suffix);
407
- file = safeRelativePath(rootDir, archivedRelative);
408
- reader = openReconciliationReader(rootDir, archivedRelative);
409
- expectedDigest = pending.intent.reconciliation_digest;
410
- authorityDigest = pending.intent.reconciliation_digest;
411
- const rebind = archiveJournal.readArchiveRebind(rootDir, pending.intent);
412
- const rebound = rebind?.entries.find(
413
- (entry) => entry.relative_path
414
- === path.posix.relative(pending.intent.destination, archivedRelative),
415
- );
416
- if (rebound) {
417
- if (rebound.before_digest !== pending.intent.reconciliation_digest) {
418
- throw new ChangeWorkflowError(
419
- 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
420
- 'archive rebind journal does not begin from the prepared reconciliation authority',
421
- {
422
- expected_digest: pending.intent.reconciliation_digest,
423
- actual_digest: rebound.before_digest,
424
- },
425
- );
426
- }
427
- expectedDigest = rebound.after_digest;
428
- authorityDigest = rebound.before_digest;
429
- }
430
- }
431
- }
432
- if (!reader) {
433
- throw new ChangeWorkflowError(
434
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation manifest file is missing',
435
- );
436
- }
437
- try {
438
- digest = reader.digest;
439
- if (expectedDigest && digest !== expectedDigest) {
440
- throw new ChangeWorkflowError(
441
- 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
442
- 'archived reconciliation bytes do not match the prepared journal digest',
443
- { expected_digest: expectedDigest, actual_digest: digest },
444
- );
445
- }
446
- assertReconciliationAuthority(db, change, relative, authorityDigest || digest);
447
- manifest = JSON.parse(reader.bytes.toString('utf8'));
448
- reader.verify();
449
- } catch (error) {
450
- if (error instanceof ChangeWorkflowError) throw error;
451
- if (error?.code === 'PROJECT_FILE_CHANGED' || error?.code === 'PROJECT_FILE_UNSAFE') {
452
- throw new ChangeWorkflowError(
453
- 'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
454
- `reconciliation manifest changed while it was read: ${relative}`,
455
- { cause: error.code },
456
- );
457
- }
458
- throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
459
- } finally {
460
- reader.close();
461
- }
462
- if (!validateReconciliationSchema(manifest)) {
463
- throw new ChangeWorkflowError(
464
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
465
- `reconciliation manifest does not satisfy its published schema: ${reconciliationAjv.errorsText(validateReconciliationSchema.errors)}`,
466
- validateReconciliationSchema.errors,
467
- );
468
- }
469
- const baseline = baselines.readBaseline(db);
470
- const updates = Array.isArray(input.baseline_updates) ? input.baseline_updates : [];
471
- if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
472
- || manifest.$schema !== 'ultra-baseline-reconciliation-v1'
473
- || manifest.change_id !== change.id || manifest.baseline_id !== (baseline?.id || null)
474
- || JSON.stringify(manifest.baseline_updates) !== JSON.stringify(updates)
475
- || !Array.isArray(manifest.semantic_changes)
476
- || !Array.isArray(manifest.resolved_gap_ids)
477
- || !Array.isArray(manifest.resolved_unknowns)
478
- || !Array.isArray(manifest.verification) || manifest.verification.length === 0) {
479
- throw new ChangeWorkflowError(
480
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'reconciliation manifest shape or authority binding is invalid',
481
- );
482
- }
483
- if (manifest.verification.some((item) => (
484
- !item || typeof item !== 'object' || item.status !== 'pass'
485
- || !String(item.name || '').trim() || !String(item.command || '').trim()
486
- || !String(item.evidence || '').trim()
487
- ))) {
488
- throw new ChangeWorkflowError(
489
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'every reconciliation verification must be an evidenced pass',
490
- );
491
- }
492
- if (updates.length === 0) {
493
- if (manifest.semantic_changes.length > 0
494
- || String(manifest.semantic_no_change_reason || '').trim().length < 3) {
495
- throw new ChangeWorkflowError(
496
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
497
- 'no-change reconciliation requires an explicit semantic_no_change_reason and no semantic changes',
498
- );
499
- }
500
- } else if (manifest.semantic_changes.length === 0) {
501
- throw new ChangeWorkflowError(
502
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'baseline updates require semantic change records',
503
- );
504
- }
505
- const specRefs = new Map((baseline?.spec_refs || []).map((ref) => [ref.path, ref]));
506
- const semanticPaths = new Set();
507
- for (const item of manifest.semantic_changes) {
508
- if (!item || typeof item !== 'object' || !CHANGE_ID.test(String(item.id || ''))
509
- || !['add', 'update'].includes(item.action)
510
- || !/^[0-9a-f]{64}$/.test(String(item.after_digest || ''))) {
511
- throw new ChangeWorkflowError(
512
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'semantic change record is invalid',
513
- );
514
- }
515
- let resolved;
516
- try {
517
- resolved = legacyWorkflows().resolveProjectSourceRef(
518
- rootDir,
519
- item.source_ref,
520
- 'semantic_change.source_ref',
521
- );
522
- }
523
- catch (error) {
524
- throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
525
- }
526
- semanticPaths.add(resolved.relative);
527
- if (!updates.includes(resolved.relative) || resolved.digest !== item.after_digest) {
528
- throw new ChangeWorkflowError(
529
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
530
- `semantic change digest or baseline update binding is invalid: ${item.source_ref}`,
531
- );
532
- }
533
- const before = specRefs.get(resolved.relative)?.digest || null;
534
- if ((item.before_digest || null) !== before || item.action !== (before ? 'update' : 'add')) {
535
- throw new ChangeWorkflowError(
536
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
537
- `semantic change before-state is invalid: ${item.source_ref}`,
538
- );
539
- }
540
- }
541
- const missingSemanticUpdates = updates.filter((update) => !semanticPaths.has(update));
542
- if (missingSemanticUpdates.length > 0) {
543
- throw new ChangeWorkflowError(
544
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
545
- `baseline updates have no semantic record: ${missingSemanticUpdates.join(', ')}`,
546
- );
547
- }
548
- const gapIds = new Set((baseline?.gaps || []).map((gap) => gap.id));
549
- const unknowns = new Set((baseline?.unknowns || []).map((unknown) => unknown.summary));
550
- if (manifest.resolved_gap_ids.some((id) => !gapIds.has(id))
551
- || manifest.resolved_unknowns.some((summary) => !unknowns.has(summary))) {
552
- throw new ChangeWorkflowError(
553
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'resolved gap or unknown does not exist in baseline authority',
554
- );
555
- }
556
- return {
557
- manifest, relative, file,
558
- digest,
559
- journal_intent: journalIntent,
560
- };
561
- }
562
-
563
- function readChange(db, id) {
564
- return rowToChange(db.prepare('SELECT * FROM changes WHERE id = ?').get(id));
565
- }
566
-
567
- const LIST_CHANGES_SQL = "SELECT * FROM changes WHERE (@status IS NULL OR status = @status) AND (@kind IS NULL OR kind = @kind) ORDER BY created_at DESC LIMIT @maxn";
568
-
569
- function listChanges(db, { status = null, kind = null, limit = 100 } = {}) {
570
- return db.prepare(LIST_CHANGES_SQL).all({
571
- status, kind, maxn: Math.min(Math.max(limit, 1), 500),
572
- }).map(rowToChange);
573
- }
574
-
575
- function writeIntent(file, change) {
576
- const impact = change.docs_impact;
577
- const lines = [
578
- `# ${change.title}`,
579
- '',
580
- `- Change: \`${change.id}\``,
581
- `- Kind: \`${change.kind}\``,
582
- `- Base commit: \`${change.base_commit || 'unavailable'}\``,
583
- `- Documentation impact: \`${impact.status}\``,
584
- `- Research disposition: \`${change.research_disposition.status}\``,
585
- `- Alignment checkpoint: \`${change.alignment_thread_id || 'not-required'}\``,
586
- '',
587
- '## Intent',
588
- '',
589
- change.intent,
590
- '',
591
- '## Outcome', '', change.contract.outcome, '',
592
- '## Acceptance', '',
593
- ...change.contract.acceptance.flatMap((item) => [
594
- `### ${item.id}`, '', item.criterion, '', `Verification: \`${item.verification}\``, '',
595
- ]),
596
- '## Public seams', '', ...change.contract.public_seams.map((item) => `- ${item}`), '',
597
- '## Non-goals', '', ...change.contract.non_goals.map((item) => `- ${item}`), '',
598
- '## Recovery', '', change.contract.recovery.strategy, '',
599
- `Verification: \`${change.contract.recovery.verification}\``, '',
600
- '## Classification', '', change.classification.rationale, '',
601
- `Risk flags: ${change.classification.risk_flags.join(', ') || 'none'}`, '',
602
- '## Research routing', '', change.research_disposition.rationale, '',
603
- ];
604
- if (impact.rationale) lines.push('## Documentation rationale', '', impact.rationale, '');
605
- if (change.baseline_bypass) {
606
- lines.push(
607
- '## Baseline break-glass', '',
608
- `- Approved by: ${change.baseline_bypass.approved_by}`,
609
- `- Recorded at: ${change.baseline_bypass.recorded_at}`,
610
- '', change.baseline_bypass.reason, '',
611
- );
612
- }
613
- fs.writeFileSync(file, `${lines.join('\n')}\n`);
614
- }
615
-
616
- function writeIncidentDiagnosis(file, change) {
617
- const lines = [
618
- `# Incident diagnosis: ${change.title}`,
619
- '',
620
- ...INCIDENT_DIAGNOSIS_SECTIONS.flatMap(({ heading }) => [`## ${heading}`, '', '']),
621
- ];
622
- fs.writeFileSync(file, `${lines.join('\n')}\n`);
623
- }
624
-
625
- function readMarkdownSections(text) {
626
- const sections = new Map();
627
- let current = null;
628
- for (const line of String(text).split(/\r?\n/)) {
629
- const heading = /^##\s+(.+?)\s*$/.exec(line);
630
- if (heading) {
631
- current = heading[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
632
- if (!sections.has(current)) sections.set(current, []);
633
- continue;
634
- }
635
- if (current) sections.get(current).push(line);
636
- }
637
- return new Map([...sections].map(([key, lines]) => [key, lines.join('\n').trim()]));
638
- }
639
-
640
- function inspectIncidentDiagnosis(change, artifactDir, rootDir) {
641
- if (change.kind !== 'incident') return { blockers: [], artifact: null };
642
- const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
643
- if (!fs.existsSync(diagnosisPath)) {
644
- return { blockers: ['DIAGNOSIS_ARTIFACT_MISSING'], artifact: null };
645
- }
646
- const content = fs.readFileSync(diagnosisPath);
647
- const sections = readMarkdownSections(content.toString('utf8'));
648
- const blockers = INCIDENT_DIAGNOSIS_SECTIONS
649
- .filter(({ key }) => !sections.get(key) || sections.get(key).length < 3)
650
- .map(({ key }) => `DIAGNOSIS_SECTION_MISSING:${key}`);
651
- return {
652
- blockers,
653
- artifact: {
654
- change_id: change.id,
655
- kind: 'diagnosis',
656
- artifactPath: path.relative(rootDir, diagnosisPath),
657
- contentHash: crypto.createHash('sha256').update(content).digest('hex'),
658
- metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
659
- },
660
- };
661
- }
662
-
663
- function latestWorkflowConsumer(db, changeId) {
664
- const workflow = db.prepare(
665
- `SELECT id FROM workflow_runs WHERE change_id = ?
666
- ORDER BY started_at DESC, rowid DESC LIMIT 1`,
667
- ).get(changeId);
668
- if (!workflow) {
669
- throw new ChangeWorkflowError(
670
- 'ARTIFACT_CONSUMER_MISSING',
671
- `change ${changeId} has no workflow consumer for its durable artifacts`,
672
- );
673
- }
674
- return { type: 'workflow', id: workflow.id, relation: 'consumed_by' };
675
- }
676
-
677
- function upsertArtifact(
678
- db,
679
- {
680
- change_id, task_id = null, kind, artifactPath, contentHash, metadata,
681
- workflow_id = null, consumer_ref = null, terminal = false,
682
- writer = 'change-workflow', rootDir = process.cwd(),
683
- },
684
- ) {
685
- const ownerType = task_id ? 'task' : 'change';
686
- const ownerId = task_id || change_id;
687
- const consumer = terminal
688
- ? null
689
- : consumer_ref
690
- ? consumer_ref
691
- : task_id
692
- ? { type: 'task', id: task_id, relation: 'consumed_by' }
693
- : workflow_id
694
- ? { type: 'workflow', id: workflow_id, relation: 'consumed_by' }
695
- : latestWorkflowConsumer(db, change_id);
696
- return artifacts.recordArtifactInTx(db, {
697
- owner_type: ownerType,
698
- owner_id: ownerId,
699
- kind,
700
- path: artifactPath,
701
- content_digest: contentHash || undefined,
702
- source_refs: [{ type: 'change', id: change_id, relation: 'produced_for' }],
703
- consumer_refs: consumer ? [consumer] : [],
704
- provenance: {
705
- writer,
706
- workflow_run_id: consumer?.type === 'workflow' ? consumer.id : null,
707
- },
708
- metadata: { ...(metadata || {}), ...(terminal ? { terminal_role: true } : {}) },
709
- }, { rootDir });
710
- }
711
-
712
- function compileContext(db, input, { rootDir = process.cwd() } = {}) {
713
- const change = readChange(db, input.id);
714
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
715
- if (['archived', 'cancelled'].includes(change.status)) {
716
- throw new ChangeWorkflowError('CHANGE_TERMINAL', `change ${input.id} is ${change.status}`);
717
- }
718
- if (Object.hasOwn(input, 'provider_refs')) {
719
- throw new ChangeWorkflowError(
720
- 'CONTEXT_PROVIDER_REFS_MUTATION_UNSUPPORTED',
721
- 'provider refs are Change authority; update them through change.update before compiling context',
722
- );
723
- }
724
- const providers = change.provider_refs;
725
- const tasks = db.prepare(
726
- 'SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
727
- ).all(change.id).map((task) => ops.readTask(db, task.id));
728
- const specs = Array.isArray(input.spec_refs) ? input.spec_refs : [];
729
- const spine = legacyContextSpine().compileRoleContext(
730
- db,
731
- { input, change, tasks, rootDir },
732
- );
733
- const recommendation = spine.recommendation;
734
- const durableSpine = { ...spine };
735
- delete durableSpine.recommendation;
736
- const semanticManifest = {
737
- schema_version: '3.0',
738
- change: {
739
- id: change.id, title: change.title, kind: change.kind, status: change.status,
740
- intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
741
- baseline_bypass: change.baseline_bypass,
742
- contract: change.contract, classification: change.classification,
743
- research_disposition: change.research_disposition,
744
- },
745
- git: {
746
- head: spine.resume.git_head,
747
- worktree_state: spine.resume.worktree_state,
748
- worktree_digest: spine.resume.worktree_digest,
749
- },
750
- tasks: spine.task_scope,
751
- selected_task: spine.selected_task,
752
- specs,
753
- role: spine.role,
754
- gate: spine.gate,
755
- control: spine.control,
756
- readiness: spine.readiness,
757
- context: spine.context,
758
- execution_contract: spine.execution_contract,
759
- task_context_contract: spine.task_context_contract,
760
- baseline: spine.baseline,
761
- resume: spine.resume,
762
- providers,
763
- provider_boundary: 'metadata references only; memory and code graph content remain external',
764
- };
765
- const identityHash = crypto.createHash('sha256')
766
- .update(JSON.stringify(semanticManifest))
767
- .digest('hex');
768
- const snapshotId = `ctx-${identityHash.slice(0, 12)}`;
769
- const existing = db.prepare(
770
- `SELECT manifest_path, manifest_hash
771
- FROM context_snapshots WHERE id = ?`,
772
- ).get(snapshotId);
773
- if (existing) {
774
- const existingPath = path.resolve(rootDir, existing.manifest_path);
775
- if (!fs.existsSync(existingPath)) {
776
- throw new ChangeWorkflowError(
777
- 'CONTEXT_SNAPSHOT_CORRUPT',
778
- `content-addressed Context snapshot is missing: ${existing.manifest_path}`,
779
- );
780
- }
781
- const bytes = fs.readFileSync(existingPath);
782
- const existingHash = crypto.createHash('sha256').update(bytes).digest('hex');
783
- if (existingHash !== existing.manifest_hash) {
784
- throw new ChangeWorkflowError(
785
- 'CONTEXT_SNAPSHOT_CORRUPT',
786
- `content-addressed Context snapshot digest changed: ${existing.manifest_path}`,
787
- );
788
- }
789
- return {
790
- manifest: JSON.parse(bytes.toString('utf8')),
791
- recommendation,
792
- context_manifest_path: existingPath,
793
- manifest_hash: existingHash,
794
- };
795
- }
796
- const manifest = {
797
- schema_version: semanticManifest.schema_version,
798
- snapshot_id: snapshotId,
799
- generated_at: nowIso(),
800
- ...Object.fromEntries(Object.entries(semanticManifest).filter(([key]) => key !== 'schema_version')),
801
- };
802
- const artifactDir = path.resolve(rootDir, change.artifact_root);
803
- fs.mkdirSync(artifactDir, { recursive: true });
804
- const manifestPath = path.join(artifactDir, 'contexts', `${snapshotId}.json`);
805
- fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
806
- const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
807
- fs.writeFileSync(manifestPath, serialized);
808
- const hash = crypto.createHash('sha256').update(serialized).digest('hex');
809
- const relative = path.relative(rootDir, manifestPath);
810
-
811
- ops.tx(db, () => {
812
- db.prepare(
813
- `INSERT INTO context_snapshots
814
- (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash,
815
- role, gate, next_action, allowed_transitions_json, required_transition,
816
- readiness, blockers_json, context_json, token_estimate, token_budget)
817
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
818
- ).run(
819
- snapshotId, change.id, input.task_id || null,
820
- spine.resume.git_head, JSON.stringify(providers), relative, hash,
821
- spine.role, spine.gate, '',
822
- JSON.stringify(spine.control.allowed_transitions), spine.control.required_transition,
823
- spine.readiness.status,
824
- JSON.stringify(spine.readiness.blockers), JSON.stringify(durableSpine),
825
- spine.context.token_estimate, spine.context.budget.max_tokens,
826
- );
827
- upsertArtifact(db, {
828
- change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
829
- artifactPath: relative, contentHash: hash,
830
- rootDir,
831
- metadata: {
832
- git_head: spine.resume.git_head,
833
- worktree_digest: spine.resume.worktree_digest,
834
- role: spine.role, gate: spine.gate,
835
- readiness: spine.readiness.status, token_estimate: spine.context.token_estimate,
836
- },
837
- });
838
- for (const task of tasks) {
839
- if (!task.trace_to) continue;
840
- db.prepare(
841
- `INSERT OR IGNORE INTO trace_links
842
- (change_id, task_id, source_ref, target_ref, relation) VALUES (?, ?, ?, ?, ?)`,
843
- ).run(change.id, task.id, task.trace_to, `task:${task.id}`, 'specified_by');
844
- }
845
- ops.appendEventInTx(db, {
846
- type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
847
- payload: {
848
- manifest_path: relative, manifest_hash: hash, git_head: spine.resume.git_head,
849
- worktree_digest: spine.resume.worktree_digest,
850
- role: spine.role, gate: spine.gate, readiness: spine.readiness.status,
851
- allowed_transitions: spine.control.allowed_transitions,
852
- required_transition: spine.control.required_transition,
853
- },
854
- });
855
- });
856
- return {
857
- manifest,
858
- recommendation,
859
- context_manifest_path: manifestPath,
860
- manifest_hash: hash,
861
- };
10
+ function updateChange(db, id, patch = {}, options = {}) {
11
+ return compatibility.updateKernelChange(db, id, patch, options);
862
12
  }
863
13
 
864
- function createChange(db, input, {
14
+ function assertTaskCreationAllowed(db, input = {}, {
865
15
  rootDir = process.cwd(),
866
- kernelMode = false,
867
16
  } = {}) {
868
- const title = typeof input?.title === 'string' ? input.title.trim() : '';
869
- const intent = typeof input?.intent === 'string' ? input.intent.trim() : '';
870
- if (!input || !CHANGE_ID.test(input.id || '') || title.length < 3 || intent.length < 3) {
871
- throw new ChangeWorkflowError(
872
- 'VALIDATION_ERROR',
873
- 'id plus non-blank title and intent of at least three characters are required',
874
- );
875
- }
876
- if (!CHANGE_KINDS.has(input.kind)) {
877
- throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
878
- }
879
- if (input.baseline_bypass !== undefined && input.kind !== 'incident') {
880
- throw new ChangeWorkflowError(
881
- 'VALIDATION_ERROR', 'baseline_bypass is valid only for incident changes',
882
- );
883
- }
884
- if (readChange(db, input.id)) throw new ChangeWorkflowError('DUPLICATE_CHANGE_ID', `change ${input.id} exists`);
885
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
886
- let baselineBypass = null;
887
- if (baselineHealth.status !== 'pass' && !kernelMode) {
888
- if (input.kind !== 'incident') {
889
- throw new ChangeWorkflowError(
890
- 'BASELINE_NOT_READY',
891
- `ordinary changes require a ready baseline: ${baselineHealth.blockers.join(', ')}`,
892
- { blockers: baselineHealth.blockers },
893
- );
894
- }
895
- baselineBypass = normalizeBaselineBypass(input.baseline_bypass);
896
- } else if (input.baseline_bypass !== undefined) {
897
- throw new ChangeWorkflowError(
898
- 'VALIDATION_ERROR', 'baseline_bypass is not valid while the project baseline is healthy',
899
- );
900
- }
901
- const alignmentGate = kernelMode ? { ready: true, blockers: [] } : legacyDecisions().decisionGate(
902
- db, { baseline_id: baselineHealth.baseline?.id || null }, { rootDir },
903
- );
904
- if (!alignmentGate.ready) {
905
- throw new ChangeWorkflowError(
906
- 'CHANGE_ALIGNMENT_REQUIRED',
907
- `resolve and confirm the active baseline alignment before creating a change: ${alignmentGate.blockers.join(', ')}`,
908
- { blockers: alignmentGate.blockers, thread_id: alignmentGate.thread?.id || null },
909
- );
910
- }
911
- const docsImpact = normalizeDocsImpact(input.docs_impact);
912
- const providers = normalizeProviderRefs(input.provider_refs);
913
- const contract = normalizeChangeContract(input.contract);
914
- const classification = normalizeClassification(input.classification, input.kind);
915
- const researchDisposition = normalizeResearchDisposition(input.research_disposition, input.kind);
916
- let alignmentThread = null;
917
- if (input.alignment_thread_id !== undefined) {
918
- try {
919
- alignmentThread = legacyDecisions().assertConfirmedDecisionCheckpoint(
920
- db, input.alignment_thread_id, { rootDir, requireArtifact: true },
921
- );
922
- } catch (error) {
923
- throw new ChangeWorkflowError(
924
- 'CHANGE_ALIGNMENT_REQUIRED',
925
- `alignment_thread_id must name a current artifact-bound checkpoint: ${error.message}`,
926
- );
927
- }
928
- if (alignmentThread.change_id) {
929
- throw new ChangeWorkflowError(
930
- 'CHANGE_ALIGNMENT_REQUIRED', `alignment thread already belongs to ${alignmentThread.change_id}`,
931
- );
932
- }
933
- if (!baselineHealth.baseline?.id || alignmentThread.baseline_id !== baselineHealth.baseline.id
934
- || alignmentThread.workflow_run_id) {
935
- throw new ChangeWorkflowError(
936
- 'CHANGE_ALIGNMENT_REQUIRED',
937
- 'change alignment must be bound only to the current baseline before change creation',
938
- );
939
- }
940
- }
941
- const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
942
- const artifactDir = path.resolve(rootDir, artifactRoot);
943
- if (fs.existsSync(artifactDir)) {
944
- throw new ChangeWorkflowError('CHANGE_ARTIFACT_EXISTS', `change artifact already exists: ${artifactDir}`);
945
- }
946
- const ts = nowIso();
947
- const row = {
948
- id: input.id, title, kind: input.kind, status: 'active',
949
- intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
950
- contract, classification, research_disposition: researchDisposition,
951
- alignment_thread_id: alignmentThread?.id || null,
952
- base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
953
- created_at: ts, updated_at: ts,
954
- };
955
- try {
956
- return ops.tx(db, () => {
957
- db.prepare(
958
- `INSERT INTO changes
959
- (id, title, kind, status, intent, docs_impact_json, provider_refs_json,
960
- baseline_bypass_json, contract_json, classification_json,
961
- research_disposition_json, alignment_thread_id, base_commit, artifact_root,
962
- created_at, updated_at)
963
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
964
- ).run(
965
- row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
966
- JSON.stringify(row.provider_refs), row.baseline_bypass
967
- ? JSON.stringify(row.baseline_bypass) : null,
968
- JSON.stringify(row.contract), JSON.stringify(row.classification),
969
- JSON.stringify(row.research_disposition), row.alignment_thread_id,
970
- row.base_commit, row.artifact_root, ts, ts,
971
- );
972
- if (row.alignment_thread_id) {
973
- db.prepare(
974
- 'UPDATE decision_threads SET change_id = ?, updated_at = ? WHERE id = ?',
975
- ).run(row.id, ts, row.alignment_thread_id);
976
- }
977
- fs.mkdirSync(artifactDir, { recursive: true });
978
- const intentPath = path.join(artifactDir, 'intent.md');
979
- writeIntent(intentPath, row);
980
- if (row.kind === 'incident') {
981
- const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
982
- writeIncidentDiagnosis(diagnosisPath, row);
983
- }
984
- ops.appendEventInTx(db, {
985
- type: 'change_created', change_id: row.id,
986
- payload: {
987
- kind: row.kind, artifact_root: row.artifact_root,
988
- alignment_thread_id: row.alignment_thread_id,
989
- },
990
- });
991
- let workflow = kernelMode ? null : legacyWorkflows().startWorkflow(db, {
992
- kind: 'change', baseline_id: baselineHealth.baseline?.id || null,
993
- change_id: row.id, subject: `Establish executable readiness for ${row.title}.`,
994
- metadata: { change_kind: row.kind, base_commit: row.base_commit },
995
- }, { rootDir });
996
- upsertArtifact(db, {
997
- change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
998
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
999
- workflow_id: workflow?.id || null,
1000
- consumer_ref: kernelMode
1001
- ? { type: 'external', id: 'ultra-plan', relation: 'consumed_by' }
1002
- : null,
1003
- writer: kernelMode ? 'ultra.record' : 'change-workflow',
1004
- rootDir,
1005
- });
1006
- if (row.kind === 'incident') {
1007
- const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
1008
- upsertArtifact(db, {
1009
- change_id: row.id,
1010
- kind: 'diagnosis',
1011
- artifactPath: path.relative(rootDir, diagnosisPath),
1012
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
1013
- metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
1014
- workflow_id: workflow?.id || null,
1015
- consumer_ref: kernelMode
1016
- ? { type: 'external', id: 'ultra-dev', relation: 'consumed_by' }
1017
- : null,
1018
- writer: kernelMode ? 'ultra.record' : 'change-workflow',
1019
- rootDir,
1020
- });
1021
- }
1022
- if (kernelMode) {
1023
- return {
1024
- change: readChange(db, row.id),
1025
- intent_path: intentPath,
1026
- context_manifest_path: null,
1027
- workflow: null,
1028
- diagnostics: baselineHealth.status === 'pass'
1029
- ? []
1030
- : (baselineHealth.blockers || []).map((code) => ({
1031
- code,
1032
- severity: 'needs_attention',
1033
- })),
1034
- };
1035
- }
1036
- workflow = legacyWorkflows().recordWorkflowStep(db, {
1037
- id: workflow.id, step_id: 'bind-baseline', status: 'completed',
1038
- evidence: [{
1039
- kind: 'baseline', ref: baselineHealth.baseline?.id || 'incident-break-glass',
1040
- summary: baselineHealth.status === 'pass'
1041
- ? `Bound ready baseline at ${baselineHealth.baseline.repository_revision}.`
1042
- : 'Bound the approved incident break-glass authority.',
1043
- }],
1044
- }, { rootDir });
1045
- workflow = legacyWorkflows().recordWorkflowStep(db, {
1046
- id: workflow.id, step_id: 'classify-change', status: 'completed',
1047
- decisions: [{
1048
- kind: 'change_kind', value: row.kind,
1049
- rationale: row.classification.rationale, risk_flags: row.classification.risk_flags,
1050
- }],
1051
- }, { rootDir });
1052
- workflow = legacyWorkflows().recordWorkflowStep(db, {
1053
- id: workflow.id, step_id: 'record-intent', status: 'completed',
1054
- evidence: [{ kind: 'intent', ref: path.relative(rootDir, intentPath), summary: row.intent }],
1055
- outputs: [{ path: path.relative(rootDir, intentPath), kind: 'change-intent' }],
1056
- decisions: [{
1057
- kind: 'change_contract', acceptance_ids: row.contract.acceptance.map((item) => item.id),
1058
- research_disposition: row.research_disposition,
1059
- }],
1060
- }, { rootDir });
1061
- workflow = legacyWorkflows().completeWorkflow(db, { id: workflow.id }, { rootDir });
1062
- return {
1063
- change: readChange(db, row.id), intent_path: intentPath,
1064
- context_manifest_path: null,
1065
- workflow,
1066
- };
1067
- });
1068
- } catch (error) {
1069
- fs.rmSync(artifactDir, { recursive: true, force: true });
1070
- throw error;
1071
- }
1072
- }
1073
-
1074
- function assertTaskCreationAllowed(db, input = {}, { rootDir = process.cwd() } = {}) {
1075
- const change = input.change_id ? readChange(db, input.change_id) : null;
17
+ const change = input.change_id ? compatibility.readChange(db, input.change_id) : null;
1076
18
  if (input.change_id && !change) {
1077
- throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.change_id} not found`);
1078
- }
1079
- if (change && !['active', 'blocked'].includes(change.status)) {
1080
- throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${change.id} is ${change.status}`);
1081
- }
1082
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
1083
- if (baselineHealth.status === 'pass') return;
1084
- const gate = change
1085
- ? legacyContextSpine().baselineGateForChange(db, change, baselineHealth)
1086
- : { blockers: baselineHealth.blockers };
1087
- if (gate.blockers.length === 0) return;
1088
- throw new ChangeWorkflowError(
1089
- 'BASELINE_NOT_READY',
1090
- `task creation requires healthy baseline authority or an approved incident break-glass record: ${gate.blockers.join(', ')}`,
1091
- { blockers: gate.blockers, change_id: input.change_id || null },
1092
- );
1093
- }
1094
-
1095
- function updateChange(db, id, patch = {}, {
1096
- rootDir = process.cwd(),
1097
- kernelMode = false,
1098
- } = {}) {
1099
- const current = readChange(db, id);
1100
- if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
1101
- if (['archived', 'cancelled'].includes(current.status)) {
1102
- throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${id} is ${current.status}`);
1103
- }
1104
- for (const field of Object.keys(patch)) {
1105
- if (!CHANGE_PATCH_FIELDS.has(field)) {
1106
- throw new ChangeWorkflowError('VALIDATION_ERROR', `change field ${field} is not patchable`);
1107
- }
1108
- }
1109
- if (kernelMode && patch.status !== undefined
1110
- && !['active', 'cancelled'].includes(patch.status)) {
1111
- throw new ChangeWorkflowError(
1112
- 'VALIDATION_ERROR',
1113
- 'kernel Change status may only be active or cancelled; readiness is derived from checkpoints',
1114
- );
1115
- }
1116
- const syncIntent = [
1117
- 'title', 'intent', 'docs_impact', 'contract', 'classification', 'research_disposition',
1118
- ].some((field) => patch[field] !== undefined);
1119
- const intentPath = path.resolve(rootDir, current.artifact_root, 'intent.md');
1120
- const previousIntent = syncIntent && fs.existsSync(intentPath)
1121
- ? fs.readFileSync(intentPath)
1122
- : null;
1123
- try {
1124
- return ops.tx(db, () => {
1125
- const sets = [];
1126
- const values = [];
1127
- if (patch.title !== undefined) {
1128
- const title = String(patch.title).trim();
1129
- if (!title) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change title cannot be empty');
1130
- sets.push('title = ?'); values.push(title);
1131
- }
1132
- if (patch.intent !== undefined) {
1133
- const intent = String(patch.intent).trim();
1134
- if (!intent) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change intent cannot be empty');
1135
- sets.push('intent = ?'); values.push(intent);
1136
- }
1137
- if (patch.docs_impact !== undefined) {
1138
- sets.push('docs_impact_json = ?'); values.push(JSON.stringify(normalizeDocsImpact(patch.docs_impact)));
1139
- }
1140
- if (patch.provider_refs !== undefined) {
1141
- sets.push('provider_refs_json = ?'); values.push(JSON.stringify(normalizeProviderRefs(patch.provider_refs)));
1142
- }
1143
- if (patch.contract !== undefined) {
1144
- sets.push('contract_json = ?'); values.push(JSON.stringify(normalizeChangeContract(patch.contract)));
1145
- }
1146
- if (patch.classification !== undefined) {
1147
- sets.push('classification_json = ?');
1148
- values.push(JSON.stringify(normalizeClassification(patch.classification, current.kind)));
1149
- }
1150
- if (patch.research_disposition !== undefined) {
1151
- sets.push('research_disposition_json = ?');
1152
- values.push(JSON.stringify(normalizeResearchDisposition(patch.research_disposition, current.kind)));
1153
- }
1154
- if (patch.status !== undefined) {
1155
- const allowed = kernelMode
1156
- ? new Set(['active', 'cancelled'])
1157
- : (CHANGE_TRANSITIONS[current.status] || new Set());
1158
- if (!allowed.has(patch.status)) {
1159
- throw new ChangeWorkflowError(
1160
- 'ILLEGAL_CHANGE_TRANSITION', `cannot transition change ${id} from ${current.status} to ${patch.status}`,
1161
- );
1162
- }
1163
- sets.push('status = ?'); values.push(patch.status);
1164
- } else if (['ready', 'blocked'].includes(current.status)
1165
- && Object.keys(patch).length > 0) {
1166
- sets.push('status = ?'); values.push('active');
1167
- }
1168
- if (sets.length === 0) return current;
1169
- sets.push('updated_at = ?'); values.push(nowIso(), id);
1170
- db.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE id = ?`).run(...values);
1171
- const updated = readChange(db, id);
1172
- const invalidated = [];
1173
- let intentArtifactId = null;
1174
- if (syncIntent) {
1175
- fs.mkdirSync(path.dirname(intentPath), { recursive: true });
1176
- writeIntent(intentPath, updated);
1177
- const recorded = upsertArtifact(db, {
1178
- change_id: id,
1179
- kind: 'intent',
1180
- artifactPath: path.relative(rootDir, intentPath),
1181
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
1182
- consumer_ref: kernelMode
1183
- ? { type: 'external', id: 'ultra-plan', relation: 'consumed_by' }
1184
- : null,
1185
- writer: kernelMode ? 'ultra.record' : 'change-workflow',
1186
- rootDir,
1187
- });
1188
- intentArtifactId = recorded.artifact.id;
1189
- invalidated.push(...recorded.invalidated);
1190
- }
1191
- const semanticFields = Object.keys(patch)
1192
- .filter((field) => SEMANTIC_AUTHORITY_FIELDS.has(field));
1193
- if (semanticFields.length > 0
1194
- && legacyContextSpine().changeStateDigest(current)
1195
- !== legacyContextSpine().changeStateDigest(updated)) {
1196
- invalidated.push(...artifacts.invalidateConsumersFromEndpointInTx(
1197
- db,
1198
- { type: 'change', id },
1199
- {
1200
- reason: 'change_semantic_authority_updated',
1201
- exclude: intentArtifactId ? [{ type: 'artifact', id: intentArtifactId }] : [],
1202
- },
1203
- ));
1204
- }
1205
- const invalidatedTasks = [...new Set(
1206
- invalidated.filter((item) => item.type === 'task').map((item) => item.id),
1207
- )].sort();
1208
- ops.appendEventInTx(db, {
1209
- type: 'change_updated',
1210
- change_id: id,
1211
- payload: {
1212
- fields: Object.keys(patch),
1213
- invalidated_tasks: invalidatedTasks,
1214
- invalidated_authorities: invalidated,
1215
- },
1216
- });
1217
- return updated;
1218
- });
1219
- } catch (error) {
1220
- if (syncIntent) {
1221
- if (previousIntent === null) fs.rmSync(intentPath, { force: true });
1222
- else fs.writeFileSync(intentPath, previousIntent);
1223
- }
1224
- throw error;
1225
- }
1226
- }
1227
-
1228
- function recordDelta(db, input, { rootDir = process.cwd() } = {}) {
1229
- const change = readChange(db, input?.id);
1230
- if (!change) {
1231
- throw new ChangeWorkflowError(
1232
- 'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
1233
- );
1234
- }
1235
- return changePacket.recordDelta(db, change, input, { rootDir });
1236
- }
1237
-
1238
- function recordDocumentationReconciliation(
1239
- db,
1240
- input,
1241
- { rootDir = process.cwd() } = {},
1242
- ) {
1243
- const change = readChange(db, input?.id);
1244
- if (!change) {
1245
- throw new ChangeWorkflowError(
1246
- 'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
1247
- );
1248
- }
1249
- return changePacket.recordDocumentationReconciliation(
1250
- db,
1251
- change,
1252
- input,
1253
- { rootDir },
1254
- );
1255
- }
1256
-
1257
- function filesUnder(dir) {
1258
- if (!fs.existsSync(dir)) return [];
1259
- const out = [];
1260
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1261
- const full = path.join(dir, entry.name);
1262
- if (entry.isDirectory()) out.push(...filesUnder(full));
1263
- else if (entry.isFile()) out.push(full);
1264
- }
1265
- return out;
1266
- }
1267
-
1268
- function markdownAnchor(text, fallback) {
1269
- const heading = String(text).match(/^#{1,6}\s+(.+?)\s*#*\s*$/m)?.[1] || fallback;
1270
- return String(heading || 'change')
1271
- .trim()
1272
- .toLowerCase()
1273
- .replace(/[^\p{L}\p{N}\s-]/gu, '')
1274
- .replace(/\s+/g, '-')
1275
- .replace(/-+/g, '-');
1276
- }
1277
-
1278
- function generatePacketReconciliation(db, change, packet, rootDir) {
1279
- const baseline = baselines.readBaseline(db);
1280
- const semanticChanges = packet.delta.value.mutations.map((mutation) => {
1281
- const overlay = safeRelativePath(rootDir, mutation.overlay_path);
1282
- const anchor = markdownAnchor(
1283
- fs.readFileSync(overlay, 'utf8'),
1284
- path.basename(mutation.target_path, path.extname(mutation.target_path)),
1285
- );
1286
- return {
1287
- id: `delta-${mutation.id}`,
1288
- action: mutation.action,
1289
- source_ref: `${mutation.target_path}#${anchor}`,
1290
- before_digest: mutation.before_digest,
1291
- after_digest: mutation.after_digest,
1292
- };
1293
- });
1294
- const verification = [{
1295
- name: 'typed Change packet read-back',
1296
- command: 'change.delta',
1297
- status: 'pass',
1298
- 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.`,
1299
- }];
1300
- const manifest = {
1301
- $schema: 'ultra-baseline-reconciliation-v1',
1302
- change_id: change.id,
1303
- baseline_id: baseline?.id || null,
1304
- baseline_updates: packet.baseline_updates,
1305
- semantic_changes: semanticChanges,
1306
- resolved_gap_ids: [],
1307
- resolved_unknowns: [],
1308
- verification,
1309
- ...(semanticChanges.length === 0
1310
- ? {
1311
- semantic_no_change_reason: packet.delta.value.no_semantic_change_reason
1312
- || 'The accepted Change has no baseline specification mutation.',
1313
- }
1314
- : {}),
1315
- };
1316
- const relative = `${change.artifact_root}/baseline-reconciliation.json`;
1317
- const file = safeRelativePath(rootDir, relative);
1318
- const previous = fs.existsSync(file) ? fs.readFileSync(file) : null;
1319
- const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
1320
- fs.writeFileSync(file, serialized);
1321
- try {
1322
- const recorded = artifacts.recordArtifact(db, {
1323
- id: `${change.id}-baseline-reconciliation`,
1324
- owner_type: 'change',
1325
- owner_id: change.id,
1326
- kind: 'baseline_reconciliation',
1327
- path: relative,
1328
- content_digest: crypto.createHash('sha256').update(serialized).digest('hex'),
1329
- source_refs: [
1330
- { type: 'artifact', id: packet.delta.artifact.id, relation: 'reconciles' },
1331
- ...(packet.documentation
1332
- ? [{
1333
- type: 'artifact',
1334
- id: packet.documentation.artifact.id,
1335
- relation: 'reconciles_documentation',
1336
- }]
1337
- : []),
1338
- ],
1339
- consumer_refs: [],
1340
- provenance: { writer: 'change.deliver', generated: true },
1341
- metadata: { terminal_role: true, schema: manifest.$schema },
1342
- }, { rootDir }).artifact;
1343
- return {
1344
- manifest,
1345
- relative,
1346
- file,
1347
- digest: recorded.digest,
1348
- artifact: recorded,
1349
- };
1350
- } catch (error) {
1351
- if (previous === null) fs.rmSync(file, { force: true });
1352
- else fs.writeFileSync(file, previous);
1353
- throw error;
1354
- }
1355
- }
1356
-
1357
- function baselineConvergenceBlockers(db, change, rootDir) {
1358
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
1359
- return legacyContextSpine().baselineGateForChange(db, change, baselineHealth).blockers;
1360
- }
1361
-
1362
- function deriveConvergenceEvidence(db, change, tasks, workflowGate, diagnosis, rootDir) {
1363
- const devRuns = db.prepare(
1364
- `SELECT id, task_id, summary_json, metadata_json FROM workflow_runs
1365
- WHERE kind = 'dev' AND change_id = ? AND status = 'completed'
1366
- ORDER BY completed_at ASC, rowid ASC`,
1367
- ).all(change.id)
1368
- .filter((row) => legacyWorkflows().isConsumableWorkflowAuthority({
1369
- metadata: parseJson(row.metadata_json, 'workflow_runs.metadata_json'),
1370
- }))
1371
- .map((row) => ({
1372
- id: row.id, task_id: row.task_id,
1373
- summary: parseJson(row.summary_json, 'workflow_runs.summary_json'),
1374
- }));
1375
- const deltaAuthority = changePacket.readDeltaAuthority(db, change.id);
1376
- const deltaPaths = deltaAuthority
1377
- ? [deltaAuthority.path, deltaAuthority.digest]
1378
- : [];
1379
- const appliedLearning = legacySpecLearning().listSpecLearning(db, change.id)
1380
- .filter((candidate) => candidate.status === 'applied')
1381
- .map((candidate) => candidate.target_ref);
1382
- const rows = [
1383
- {
1384
- category: 'diff', status: 'pass',
1385
- evidence: `Completed dev workflows: ${devRuns.map((run) => `${run.task_id}:${run.id}`).join(', ')}`,
1386
- refs: devRuns.map((run) => run.id),
1387
- },
1388
- {
1389
- category: 'tests', status: 'pass',
1390
- evidence: `DB-derived passing test workflow ${workflowGate.test_workflow_id}.`,
1391
- refs: [workflowGate.test_summary.report_path, workflowGate.test_summary.report_digest].filter(Boolean),
1392
- regression_signal: workflowGate.test_summary.regression_signal || null,
1393
- },
1394
- {
1395
- category: 'spec', status: 'pass',
1396
- evidence: deltaPaths.length > 0
1397
- ? `Current delta artifacts: ${deltaPaths.join(', ')}`
1398
- : `Change intent and resolved specification learning for ${change.id}.`,
1399
- refs: [...deltaPaths, ...appliedLearning],
1400
- },
1401
- {
1402
- category: 'docs', status: 'pass',
1403
- evidence: change.docs_impact.status === 'required'
1404
- ? `Declared documentation files: ${change.docs_impact.files.join(', ')}`
1405
- : `No documentation change: ${change.docs_impact.rationale}`,
1406
- refs: change.docs_impact.files,
1407
- },
1408
- ...['spec_fidelity', 'engineering_standards'].map((axis) => ({
1409
- category: 'review', axis, status: 'pass',
1410
- evidence: `DB-derived ${axis} verdict from ${workflowGate.review_workflow_id}.`,
1411
- refs: workflowGate.review_summary.axes?.[axis]?.evidence_refs || [],
1412
- })),
1413
- ];
1414
- if (diagnosis.artifact) {
1415
- rows.unshift({
1416
- category: 'diagnosis', status: 'pass',
1417
- evidence: `Structured incident diagnosis ${diagnosis.artifact.artifactPath}.`,
1418
- refs: [diagnosis.artifact.artifactPath, diagnosis.artifact.contentHash],
1419
- });
1420
- }
1421
- return rows;
1422
- }
1423
-
1424
- function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1425
- if (Object.hasOwn(input || {}, 'evidence')) {
1426
- throw new ChangeWorkflowError(
1427
- 'CONVERGENCE_EVIDENCE_AUTHORITY_VIOLATION',
1428
- 'change convergence evidence is derived from DB-backed dev, test, review, specification, and documentation authority',
1429
- );
1430
- }
1431
- const change = readChange(db, input.id);
1432
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
1433
- if (!['active', 'blocked'].includes(change.status)) {
1434
- throw new ChangeWorkflowError('CHANGE_NOT_CONVERGEABLE', `change ${input.id} is ${change.status}`);
1435
- }
1436
- const tasks = db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
1437
- .all(change.id).map((task) => ops.readTask(db, task.id));
1438
- const blockers = new Set();
1439
- let workflowGate = null;
1440
- try {
1441
- workflowGate = legacyWorkflows().validateDeliveryPrerequisites(
1442
- db,
1443
- { change_id: change.id },
1444
- rootDir,
1445
- );
1446
- } catch (error) {
1447
- blockers.add(error.code || 'WORKFLOW_GATE_UNAVAILABLE');
1448
- }
1449
- for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
1450
- if (tasks.length === 0) blockers.add('NO_TASKS');
1451
- if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
1452
- if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
1453
- for (const task of tasks.filter((item) => item.status !== 'expanded')) {
1454
- const validation = legacyContextSpine().validateContextSnapshot(db, {
1455
- change_id: change.id,
1456
- task_id: task.id,
1457
- role: 'implement',
1458
- gate: 'implementation',
1459
- }, { rootDir });
1460
- if (!validation.snapshot) {
1461
- blockers.add(`TASK_CONTEXT_MISSING:${task.id}`);
1462
- continue;
1463
- }
1464
- for (const blocker of validation.blockers) {
1465
- blockers.add(`TASK_CONTEXT_INVALID:${task.id}:${blocker}`);
1466
- }
1467
- if (!validation.manifest?.execution_contract?.public_seam) {
1468
- blockers.add(`TASK_SEAM_MISSING:${task.id}`);
1469
- }
1470
- if (!validation.manifest?.execution_contract?.verification_command) {
1471
- blockers.add(`TASK_VERIFICATION_MISSING:${task.id}`);
1472
- }
1473
- }
1474
-
1475
- for (const candidate of legacySpecLearning().listSpecLearning(db, change.id)) {
1476
- if (candidate.status === 'proposed') blockers.add(`SPEC_LEARNING_UNRESOLVED:${candidate.id}`);
1477
- if (candidate.status === 'approved') blockers.add(`SPEC_LEARNING_NOT_APPLIED:${candidate.id}`);
1478
- }
1479
-
1480
- if (change.docs_impact.status === 'unknown') blockers.add('DOCS_IMPACT_UNKNOWN');
1481
- if (change.docs_impact.status === 'none' && (!change.docs_impact.rationale || change.docs_impact.rationale.length < 3)) {
1482
- blockers.add('DOCS_IMPACT_RATIONALE_MISSING');
1483
- }
1484
- if (change.docs_impact.status === 'required') {
1485
- if (change.docs_impact.files.length === 0) blockers.add('DOCS_FILES_UNDECLARED');
1486
- for (const file of change.docs_impact.files) {
1487
- if (!fs.existsSync(safeRelativePath(rootDir, file))) blockers.add(`DOCS_FILE_MISSING:${file}`);
1488
- }
1489
- }
1490
-
1491
- const artifactDir = path.resolve(rootDir, change.artifact_root);
1492
- const diagnosis = inspectIncidentDiagnosis(change, artifactDir, rootDir);
1493
- for (const blocker of diagnosis.blockers) blockers.add(blocker);
1494
- if (['standard', 'major'].includes(change.kind)) {
1495
- let delta = null;
1496
- try {
1497
- delta = changePacket.loadDelta(db, change.id, { rootDir });
1498
- } catch (error) {
1499
- blockers.add(error.code || 'CHANGE_DELTA_AUTHORITY_MISSING');
1500
- }
1501
- if (delta) {
1502
- try {
1503
- const documentation = changePacket.loadDocumentationReconciliation(
1504
- db, change.id, { rootDir },
1505
- );
1506
- if (documentation.value.delta_artifact_id !== delta.artifact.id
1507
- || documentation.value.delta_digest !== delta.artifact.digest) {
1508
- blockers.add('DOCUMENTATION_DELTA_AUTHORITY_INVALID');
1509
- }
1510
- } catch (error) {
1511
- blockers.add(error.code || 'DOCUMENTATION_RECONCILIATION_MISSING');
1512
- }
1513
- }
1514
- const planArtifact = db.prepare(
1515
- `SELECT id FROM artifacts
1516
- WHERE owner_type = 'change' AND owner_id = ? AND kind = 'execution_plan'
1517
- AND status = 'current'
1518
- ORDER BY updated_at DESC, rowid DESC`,
1519
- ).all(change.id);
1520
- if (planArtifact.length !== 1) blockers.add('CHANGE_PLAN_AUTHORITY_MISSING');
1521
- }
1522
- const openIncidents = db.prepare(
1523
- `SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'
1524
- AND (change_id = ? OR task_id IN (SELECT id FROM tasks WHERE change_id = ?))`,
1525
- ).get(change.id, change.id).count;
1526
- if (openIncidents > 0) blockers.add('OPEN_INCIDENTS');
1527
-
1528
- const blockerList = [...blockers].sort();
1529
- if (blockerList.length > 0) {
1530
- ops.tx(db, () => {
1531
- db.prepare("UPDATE changes SET status = 'blocked', updated_at = ? WHERE id = ?")
1532
- .run(nowIso(), change.id);
1533
- if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
1534
- ops.appendEventInTx(db, {
1535
- type: 'change_blocked', change_id: change.id, payload: { blockers: blockerList },
1536
- });
1537
- });
1538
- return { ready: false, status: 'blocked', blockers: blockerList };
1539
- }
1540
-
1541
- const derivedEvidence = deriveConvergenceEvidence(
1542
- db, change, tasks, workflowGate, diagnosis, rootDir,
1543
- );
1544
- const verificationPath = path.join(artifactDir, 'verification.md');
1545
- const lines = [
1546
- `# Verification: ${change.title}`, '',
1547
- ...derivedEvidence.flatMap((row) => [
1548
- `## ${row.category}${row.axis ? `:${row.axis}` : ''}`, '',
1549
- `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
1550
- ]),
1551
- ];
1552
- fs.writeFileSync(verificationPath, `${lines.join('\n')}\n`);
1553
- ops.tx(db, () => {
1554
- db.prepare("UPDATE changes SET status = 'ready', updated_at = ? WHERE id = ?")
1555
- .run(nowIso(), change.id);
1556
- if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
1557
- upsertArtifact(db, {
1558
- change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
1559
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
1560
- metadata: { evidence: derivedEvidence },
1561
- rootDir,
1562
- });
1563
- ops.appendEventInTx(db, {
1564
- type: 'change_converged', change_id: change.id,
1565
- payload: { evidence_categories: derivedEvidence.map((row) => row.category) },
1566
- });
1567
- });
1568
- return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
1569
- }
1570
-
1571
- function replaceArchiveAuthorityText(
1572
- text,
1573
- source,
1574
- destination,
1575
- digestMap = new Map(),
1576
- { normalizePath = false } = {},
1577
- ) {
1578
- const original = String(text);
1579
- const windowsSource = source.replaceAll('/', '\\');
1580
- let result = original
1581
- .replaceAll(source, destination)
1582
- .replaceAll(windowsSource, destination);
1583
- if (normalizePath && original.includes(windowsSource)) {
1584
- result = result.replaceAll('\\', '/');
1585
- }
1586
- for (const [before, after] of digestMap) {
1587
- if (before !== after) result = result.replaceAll(before, after);
1588
- }
1589
- return result;
1590
- }
1591
-
1592
- function kernelArchiveContextPath(change, id) {
1593
- return `${change.artifact_root}/authority/context-envelopes/${id}.json`;
1594
- }
1595
-
1596
- function kernelArchiveWorkerPath(change, id) {
1597
- return `${change.artifact_root}/authority/worker-packets/${id}.json`;
1598
- }
1599
-
1600
- function materializeKernelArchiveAuthority(db, change, rootDir) {
1601
- const taskIds = db.prepare(
1602
- 'SELECT id FROM tasks WHERE change_id = ? ORDER BY id',
1603
- ).all(change.id).map((row) => row.id);
1604
- const contexts = db.prepare(
1605
- `SELECT * FROM context_envelopes
1606
- WHERE (scope_type = 'change' AND scope_id = ?)
1607
- OR (scope_type = 'task' AND scope_id IN (
1608
- SELECT id FROM tasks WHERE change_id = ?
1609
- ))
1610
- ORDER BY id`,
1611
- ).all(change.id, change.id);
1612
- for (const row of contexts) {
1613
- const read = readStableProjectFile(rootDir, row.artifact_path);
1614
- if (!row.file_digest || read.digest !== row.file_digest) {
1615
- throw new ChangeWorkflowError(
1616
- 'CONTEXT_ENVELOPE_FILE_DRIFT',
1617
- `cannot archive unbound Context Envelope ${row.id}`,
1618
- {
1619
- path: row.artifact_path,
1620
- expected: row.file_digest || null,
1621
- actual: read.digest,
1622
- },
1623
- );
1624
- }
1625
- const target = kernelArchiveContextPath(change, row.id);
1626
- const published = writeManagedFile(rootDir, target, read.bytes);
1627
- upsertArtifact(db, {
1628
- change_id: change.id,
1629
- task_id: row.scope_type === 'task' ? row.scope_id : null,
1630
- kind: 'archive_context_envelope',
1631
- artifactPath: target,
1632
- contentHash: published.digest,
1633
- consumer_ref: {
1634
- type: 'external',
1635
- id: 'ultra.archive',
1636
- relation: 'archived_by',
1637
- },
1638
- writer: 'ultra.archive',
1639
- rootDir,
1640
- });
1641
- }
1642
- if (taskIds.length === 0) return;
1643
- const packets = db.prepare(
1644
- `SELECT * FROM worker_packets
1645
- WHERE scope_type = 'task' AND status = 'assigned'
1646
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)
1647
- ORDER BY id`,
1648
- ).all(change.id);
1649
- for (const row of packets) {
1650
- const read = readStableProjectFile(rootDir, row.packet_path);
1651
- if (!row.file_digest || read.digest !== row.file_digest) {
1652
- throw new ChangeWorkflowError(
1653
- 'WORKER_PACKET_FILE_DRIFT',
1654
- `cannot archive unbound Worker Packet ${row.id}`,
1655
- {
1656
- path: row.packet_path,
1657
- expected: row.file_digest || null,
1658
- actual: read.digest,
1659
- },
1660
- );
1661
- }
1662
- const target = kernelArchiveWorkerPath(change, row.id);
1663
- const published = writeManagedFile(rootDir, target, read.bytes);
1664
- upsertArtifact(db, {
1665
- change_id: change.id,
1666
- task_id: row.scope_id,
1667
- kind: 'archive_worker_packet',
1668
- artifactPath: target,
1669
- contentHash: published.digest,
1670
- consumer_ref: {
1671
- type: 'external',
1672
- id: 'ultra.archive',
1673
- relation: 'archived_by',
1674
- },
1675
- writer: 'ultra.archive',
1676
- rootDir,
1677
- });
1678
- }
1679
- }
1680
-
1681
- function normalizedArchiveDocument(bytes, intent) {
1682
- const text = bytes.toString('utf8');
1683
- if (!Buffer.from(text, 'utf8').equals(bytes)) return { bytes, semanticDigest: null };
1684
- let document;
1685
- try { document = JSON.parse(text); }
1686
- catch { return { bytes, semanticDigest: null }; }
1687
- let semanticDigest = null;
1688
- if (document?.schema_version === '1.0'
1689
- && document.detail === 'full'
1690
- && document.envelope
1691
- && document.digest) {
1692
- if (document.envelope.change?.id === intent.change_id) {
1693
- document.envelope.change.status = 'archived';
1694
- }
1695
- semanticDigest = canonical.digest(document.envelope);
1696
- document.digest = semanticDigest;
1697
- } else if (document?.packet_version && document.packet_digest) {
1698
- const { packet_digest: _prior, ...value } = document;
1699
- semanticDigest = canonical.digest(value);
1700
- document.packet_digest = semanticDigest;
1701
- } else if (document?.schema_version === '1.0'
1702
- && document.question
1703
- && document.selection
1704
- && document.digest) {
1705
- const { digest: _prior, ...value } = document;
1706
- semanticDigest = canonical.digest(value);
1707
- document.digest = semanticDigest;
1708
- } else if (document?.change?.id === intent.change_id) {
1709
- document.change.status = 'archived';
1710
- }
1711
- return {
1712
- bytes: Buffer.from(`${JSON.stringify(document, null, 2)}\n`),
1713
- semanticDigest,
1714
- };
1715
- }
1716
-
1717
- function archiveRebindAuthorityValue(value, source, destination, digestMap) {
1718
- if (typeof value === 'string') {
1719
- return replaceArchiveAuthorityText(
1720
- value, source, destination, digestMap, { normalizePath: true },
1721
- );
1722
- }
1723
- if (Array.isArray(value)) {
1724
- return value.map(
1725
- (item) => archiveRebindAuthorityValue(item, source, destination, digestMap),
1726
- );
1727
- }
1728
- if (value && typeof value === 'object') {
1729
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1730
- key,
1731
- archiveRebindAuthorityValue(item, source, destination, digestMap),
1732
- ]));
1733
- }
1734
- return value;
1735
- }
1736
-
1737
- function buildArchiveRebindEntries(db, intent, rootDir) {
1738
- const tree = walkStableProjectTree(rootDir, intent.destination, {
1739
- ignore: (relative) => (
1740
- relative === `${intent.destination}/${archiveJournal.INTENT_FILE}`
1741
- || relative === `${intent.destination}/${archiveJournal.REBIND_FILE}`
1742
- ),
1743
- });
1744
- if (tree.unsafe.length > 0) {
1745
- throw new ChangeWorkflowError(
1746
- 'ARCHIVE_PATH_UNSAFE',
1747
- `archive packet contains unsafe entries: ${tree.unsafe.map((item) => item.path).join(', ')}`,
1748
- { unsafe: tree.unsafe },
1749
- );
1750
- }
1751
- const originalByPath = new Map();
1752
- const semanticByPath = new Map();
1753
- for (const artifact of db.prepare(
1754
- 'SELECT path, digest, content_hash FROM artifacts WHERE change_id = ?',
1755
- ).all(intent.change_id)) {
1756
- const archivedPath = rebindRegistryPath(
1757
- artifact.path, intent.source, intent.destination,
1758
- );
1759
- const digest = artifact.digest || artifact.content_hash;
1760
- if (archivedPath && digest) originalByPath.set(archivedPath, digest);
1761
- }
1762
- const contextPaths = new Set();
1763
- for (const snapshot of db.prepare(
1764
- 'SELECT manifest_path, manifest_hash FROM context_snapshots WHERE change_id = ?',
1765
- ).all(intent.change_id)) {
1766
- const archivedPath = rebindRegistryPath(
1767
- snapshot.manifest_path, intent.source, intent.destination,
1768
- );
1769
- if (archivedPath) {
1770
- contextPaths.add(archivedPath);
1771
- originalByPath.set(archivedPath, snapshot.manifest_hash);
1772
- }
1773
- }
1774
- for (const row of db.prepare(
1775
- `SELECT id, digest FROM decision_records
1776
- WHERE scope_type = 'change' AND scope_id = ?`,
1777
- ).all(intent.change_id)) {
1778
- const archivedPath = `${intent.destination}/decisions/${row.id}.json`;
1779
- semanticByPath.set(archivedPath, row.digest);
1780
- }
1781
- for (const row of db.prepare(
1782
- `SELECT id, digest FROM context_envelopes
1783
- WHERE (scope_type = 'change' AND scope_id = ?)
1784
- OR (scope_type = 'task' AND scope_id IN (
1785
- SELECT id FROM tasks WHERE change_id = ?
1786
- ))`,
1787
- ).all(intent.change_id, intent.change_id)) {
1788
- semanticByPath.set(
1789
- `${intent.destination}/authority/context-envelopes/${row.id}.json`,
1790
- row.digest,
1791
- );
1792
- }
1793
- for (const row of db.prepare(
1794
- `SELECT id, packet_digest FROM worker_packets
1795
- WHERE scope_type = 'task' AND status = 'assigned'
1796
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
1797
- ).all(intent.change_id)) {
1798
- semanticByPath.set(
1799
- `${intent.destination}/authority/worker-packets/${row.id}.json`,
1800
- row.packet_digest,
19
+ throw new compatibility.ChangeWorkflowError(
20
+ 'CHANGE_NOT_FOUND',
21
+ `change ${input.change_id} not found`,
1801
22
  );
1802
23
  }
1803
- const original = new Map();
1804
- const pathRebound = new Map();
1805
- for (const relative of tree.files) {
1806
- const read = readStableProjectFile(rootDir, relative);
1807
- original.set(relative, read.bytes);
1808
- const text = read.bytes.toString('utf8');
1809
- if (!Buffer.from(text, 'utf8').equals(read.bytes)) {
1810
- if (read.bytes.includes(Buffer.from(intent.source))) {
1811
- throw new ChangeWorkflowError(
1812
- 'ARCHIVE_REBIND_UNSUPPORTED',
1813
- `archive packet contains a binary active-root reference: ${relative}`,
1814
- );
1815
- }
1816
- pathRebound.set(relative, read.bytes);
1817
- continue;
1818
- }
1819
- let next = replaceArchiveAuthorityText(
1820
- text, intent.source, intent.destination,
1821
- );
1822
- if (contextPaths.has(relative)) {
1823
- let manifest;
1824
- try { manifest = JSON.parse(next); } catch (error) {
1825
- throw new ChangeWorkflowError(
1826
- 'ARCHIVE_CONTEXT_INVALID',
1827
- `archived Context Manifest cannot be parsed: ${relative}: ${error.message}`,
1828
- );
1829
- }
1830
- if (manifest.change?.id !== intent.change_id) {
1831
- throw new ChangeWorkflowError(
1832
- 'ARCHIVE_CONTEXT_INVALID',
1833
- `archived Context Manifest belongs to another change: ${relative}`,
1834
- );
1835
- }
1836
- manifest.change.status = 'archived';
1837
- next = `${JSON.stringify(manifest, null, 2)}\n`;
1838
- }
1839
- pathRebound.set(
1840
- relative,
1841
- normalizedArchiveDocument(Buffer.from(next), intent).bytes,
1842
- );
1843
- }
1844
-
1845
- let current = new Map(pathRebound);
1846
- for (let iteration = 0; iteration < 20; iteration += 1) {
1847
- const digestMap = new Map();
1848
- for (const [relative, beforeDigest] of originalByPath) {
1849
- const bytes = current.get(relative);
1850
- if (bytes) {
1851
- digestMap.set(
1852
- beforeDigest,
1853
- crypto.createHash('sha256').update(bytes).digest('hex'),
1854
- );
1855
- }
1856
- }
1857
- for (const [relative, beforeDigest] of semanticByPath) {
1858
- const bytes = current.get(relative);
1859
- if (!bytes) continue;
1860
- const normalized = normalizedArchiveDocument(bytes, intent);
1861
- if (normalized.semanticDigest) {
1862
- digestMap.set(beforeDigest, normalized.semanticDigest);
1863
- }
1864
- }
1865
- const next = new Map();
1866
- let changed = false;
1867
- for (const [relative, baseBytes] of pathRebound) {
1868
- const text = baseBytes.toString('utf8');
1869
- if (!Buffer.from(text, 'utf8').equals(baseBytes)) {
1870
- next.set(relative, baseBytes);
1871
- continue;
1872
- }
1873
- const replaced = Buffer.from(
1874
- replaceArchiveAuthorityText(
1875
- text, intent.source, intent.destination, digestMap,
1876
- ),
1877
- );
1878
- const bytes = normalizedArchiveDocument(replaced, intent).bytes;
1879
- next.set(relative, bytes);
1880
- if (!bytes.equals(current.get(relative))) changed = true;
1881
- }
1882
- current = next;
1883
- if (!changed) break;
1884
- if (iteration === 19) {
1885
- throw new ChangeWorkflowError(
1886
- 'ARCHIVE_REBIND_CYCLE',
1887
- 'archive packet digest references did not converge',
1888
- );
1889
- }
1890
- }
1891
- return [...original.entries()].flatMap(([relative, before]) => {
1892
- const after = current.get(relative);
1893
- if (after.equals(before)) return [];
1894
- return [{
1895
- relative_path: path.posix.relative(intent.destination, relative),
1896
- before_digest: crypto.createHash('sha256').update(before).digest('hex'),
1897
- after_digest: crypto.createHash('sha256').update(after).digest('hex'),
1898
- before_base64: before.toString('base64'),
1899
- after_base64: after.toString('base64'),
1900
- }];
1901
- }).sort((left, right) => left.relative_path.localeCompare(right.relative_path));
1902
- }
1903
-
1904
- function archiveRebindState(db, intent, rootDir) {
1905
- const existing = archiveJournal.readArchiveRebind(rootDir, intent);
1906
- const entries = existing?.entries || buildArchiveRebindEntries(db, intent, rootDir);
1907
- const journal = archiveJournal.prepareArchiveRebind(
1908
- rootDir, intent, existing ? null : entries,
1909
- );
1910
- const byPath = new Map(journal.entries.map((entry) => [
1911
- `${intent.destination}/${entry.relative_path}`,
1912
- entry,
1913
- ]));
1914
- const digestMap = new Map(journal.entries.map(
1915
- (entry) => [entry.before_digest, entry.after_digest],
1916
- ));
1917
- const semanticEntry = (relativePath) => byPath.get(relativePath);
1918
- for (const row of db.prepare(
1919
- `SELECT id, digest FROM decision_records
1920
- WHERE scope_type = 'change' AND scope_id = ?`,
1921
- ).all(intent.change_id)) {
1922
- const entry = semanticEntry(`${intent.destination}/decisions/${row.id}.json`);
1923
- if (!entry) continue;
1924
- const document = JSON.parse(Buffer.from(entry.after_base64, 'base64').toString('utf8'));
1925
- if (document.digest) digestMap.set(row.digest, document.digest);
1926
- }
1927
- for (const row of db.prepare(
1928
- `SELECT id, digest FROM context_envelopes
1929
- WHERE (scope_type = 'change' AND scope_id = ?)
1930
- OR (scope_type = 'task' AND scope_id IN (
1931
- SELECT id FROM tasks WHERE change_id = ?
1932
- ))`,
1933
- ).all(intent.change_id, intent.change_id)) {
1934
- const entry = semanticEntry(
1935
- `${intent.destination}/authority/context-envelopes/${row.id}.json`,
1936
- );
1937
- if (!entry) continue;
1938
- const document = JSON.parse(Buffer.from(entry.after_base64, 'base64').toString('utf8'));
1939
- if (document.digest) digestMap.set(row.digest, document.digest);
1940
- }
1941
- for (const row of db.prepare(
1942
- `SELECT id, packet_digest FROM worker_packets
1943
- WHERE scope_type = 'task' AND status = 'assigned'
1944
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
1945
- ).all(intent.change_id)) {
1946
- const entry = semanticEntry(
1947
- `${intent.destination}/authority/worker-packets/${row.id}.json`,
1948
- );
1949
- if (!entry) continue;
1950
- const document = JSON.parse(Buffer.from(entry.after_base64, 'base64').toString('utf8'));
1951
- if (document.packet_digest) {
1952
- digestMap.set(row.packet_digest, document.packet_digest);
1953
- }
1954
- }
1955
- return { journal, byPath, digestMap };
1956
- }
1957
-
1958
- function finalizeArchive(db, intent, {
1959
- rootDir,
1960
- journalIntent = intent,
1961
- kernelMode = false,
1962
- }) {
1963
- const change = readChange(db, intent.change_id);
1964
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${intent.change_id} not found`);
1965
- try {
1966
- archiveJournal.verifyArchiveIntent(rootDir, journalIntent, { location: 'destination' });
1967
- } catch (error) {
1968
- throw new ChangeWorkflowError(
1969
- 'ARCHIVE_PATH_UNSAFE',
1970
- `archive transition cannot trust its physical destination: ${error.message}`,
1971
- { cause: error.code || error.message },
1972
- );
1973
- }
1974
- const relative = artifacts.normalizeRelativePath(intent.destination);
1975
- const destination = safeRelativePath(rootDir, relative);
1976
- if (change.status === 'archived'
1977
- && artifacts.normalizeRelativePath(change.artifact_root) === relative) {
1978
- return { change, archive_path: destination };
1979
- }
1980
- const acceptedStatuses = kernelMode ? ['active', 'ready'] : ['ready'];
1981
- if (!acceptedStatuses.includes(change.status)) {
1982
- throw new ChangeWorkflowError(
1983
- 'CHANGE_NOT_READY',
1984
- `change ${change.id} must have complete accepted checkpoints to finish archive`,
1985
- );
1986
- }
1987
- const rebind = archiveRebindState(db, journalIntent, rootDir);
1988
- const archivedReconciliationPath = rebindRegistryPath(
1989
- intent.reconciliation_path, intent.source, relative,
1990
- );
1991
- const reconciliationRead = readStableProjectFile(
1992
- rootDir, archivedReconciliationPath, { encoding: 'utf8' },
1993
- );
1994
- let reconciliationManifest;
1995
- try { reconciliationManifest = JSON.parse(reconciliationRead.text); } catch (error) {
1996
- throw new ChangeWorkflowError(
1997
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
1998
- `archived reconciliation manifest is invalid JSON: ${error.message}`,
1999
- );
2000
- }
2001
- if (!validateReconciliationSchema(reconciliationManifest)
2002
- || reconciliationManifest.change_id !== change.id) {
2003
- throw new ChangeWorkflowError(
2004
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
2005
- 'archived reconciliation manifest no longer matches its accepted schema and Change',
2006
- { errors: validateReconciliationSchema.errors || [] },
2007
- );
2008
- }
2009
- const reconciliationDigest = reconciliationRead.digest;
2010
- let summaryReader;
2011
- try {
2012
- summaryReader = openStableProjectRead(rootDir, `${relative}/archive-summary.md`);
2013
- } catch (error) {
2014
- throw new ChangeWorkflowError(
2015
- 'ARCHIVE_PATH_UNSAFE', 'archive summary cannot be read safely',
2016
- { cause: error.code || error.message },
24
+ if (change && !['active', 'blocked'].includes(change.status)) {
25
+ throw new compatibility.ChangeWorkflowError(
26
+ 'CHANGE_NOT_MUTABLE',
27
+ `change ${change.id} is ${change.status}`,
2017
28
  );
2018
29
  }
2019
- const baselineHealthBefore = baselines.inspectBaseline(db, { rootDir });
2020
- const breakGlass = Boolean(
2021
- baselineHealthBefore.status !== 'pass'
2022
- && change.kind === 'incident'
2023
- && change.baseline_bypass?.mode === 'incident_break_glass',
2024
- );
2025
- try {
2026
- ops.tx(db, () => {
2027
- db.prepare(
2028
- "UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
2029
- ).run(relative, nowIso(), nowIso(), change.id);
2030
- const artifactRows = db.prepare(
2031
- `SELECT id, path, digest, content_hash, before_digest, metadata_json, provenance_json
2032
- FROM artifacts WHERE change_id = ?`,
2033
- ).all(change.id);
2034
- for (const artifact of artifactRows) {
2035
- const archivedPath = rebindRegistryPath(
2036
- artifact.path, intent.source, relative,
2037
- );
2038
- if (!archivedPath) continue;
2039
- const rebound = rebind.byPath.get(archivedPath);
2040
- if (rebound) {
2041
- db.prepare(
2042
- `UPDATE artifacts
2043
- SET digest = ?, content_hash = ?, before_digest = COALESCE(before_digest, ?),
2044
- after_digest = ?, metadata_json = ?, provenance_json = ?
2045
- WHERE id = ?`,
2046
- ).run(
2047
- rebound.after_digest,
2048
- rebound.after_digest,
2049
- artifact.digest || artifact.content_hash || rebound.before_digest,
2050
- rebound.after_digest,
2051
- JSON.stringify(archiveRebindAuthorityValue(
2052
- parseJson(artifact.metadata_json || '{}', 'artifacts.metadata_json'),
2053
- intent.source,
2054
- relative,
2055
- rebind.digestMap,
2056
- )),
2057
- JSON.stringify(archiveRebindAuthorityValue(
2058
- parseJson(artifact.provenance_json || '{}', 'artifacts.provenance_json'),
2059
- intent.source,
2060
- relative,
2061
- rebind.digestMap,
2062
- )),
2063
- artifact.id,
2064
- );
2065
- }
2066
- artifacts.moveArtifactInTx(db, artifact.id, archivedPath, { rootDir });
2067
- }
2068
- const workflowOutputs = db.prepare(
2069
- `SELECT ws.run_id, ws.step_id, ws.outputs_json, ws.evidence_json,
2070
- ws.decisions_json, ws.semantic_records_json, ws.blockers_json
2071
- FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
2072
- WHERE wr.change_id = ?`,
2073
- ).all(change.id);
2074
- for (const step of workflowOutputs) {
2075
- const rebound = Object.fromEntries([
2076
- ['outputs_json', parseJson(step.outputs_json, 'workflow_steps.outputs_json')],
2077
- ['evidence_json', parseJson(step.evidence_json, 'workflow_steps.evidence_json')],
2078
- ['decisions_json', parseJson(step.decisions_json, 'workflow_steps.decisions_json')],
2079
- ['semantic_records_json', parseJson(
2080
- step.semantic_records_json, 'workflow_steps.semantic_records_json',
2081
- )],
2082
- ['blockers_json', parseJson(step.blockers_json, 'workflow_steps.blockers_json')],
2083
- ].map(([field, value]) => [
2084
- field,
2085
- JSON.stringify(archiveRebindAuthorityValue(
2086
- value, intent.source, relative, rebind.digestMap,
2087
- )),
2088
- ]));
2089
- db.prepare(
2090
- `UPDATE workflow_steps
2091
- SET outputs_json = ?, evidence_json = ?, decisions_json = ?,
2092
- semantic_records_json = ?, blockers_json = ?, updated_at = ?
2093
- WHERE run_id = ? AND step_id = ?`,
2094
- ).run(
2095
- rebound.outputs_json,
2096
- rebound.evidence_json,
2097
- rebound.decisions_json,
2098
- rebound.semantic_records_json,
2099
- rebound.blockers_json,
2100
- nowIso(),
2101
- step.run_id,
2102
- step.step_id,
2103
- );
2104
- }
2105
- const contextRows = db.prepare(
2106
- `SELECT id, manifest_path, manifest_hash, context_json
2107
- FROM context_snapshots WHERE change_id = ?`,
2108
- ).all(change.id);
2109
- for (const snapshot of contextRows) {
2110
- const archivedPath = rebindRegistryPath(
2111
- snapshot.manifest_path, intent.source, relative,
2112
- );
2113
- if (!archivedPath) continue;
2114
- const rebound = rebind.byPath.get(archivedPath);
2115
- db.prepare(
2116
- `UPDATE context_snapshots
2117
- SET manifest_path = ?, manifest_hash = ?, context_json = ? WHERE id = ?`,
2118
- ).run(
2119
- archivedPath,
2120
- rebound?.after_digest || snapshot.manifest_hash,
2121
- JSON.stringify(archiveRebindAuthorityValue(
2122
- parseJson(snapshot.context_json || '{}', 'context_snapshots.context_json'),
2123
- intent.source,
2124
- relative,
2125
- rebind.digestMap,
2126
- )),
2127
- snapshot.id,
2128
- );
2129
- }
2130
- for (const decision of db.prepare(
2131
- `SELECT id FROM decision_records
2132
- WHERE scope_type = 'change' AND scope_id = ?`,
2133
- ).all(change.id)) {
2134
- const archivedPath = `${relative}/decisions/${decision.id}.json`;
2135
- const rebound = rebind.byPath.get(archivedPath);
2136
- if (!rebound) {
2137
- throw new ChangeWorkflowError(
2138
- 'ARCHIVE_DECISION_MISSING',
2139
- `archive is missing Decision Record ${decision.id}`,
2140
- );
2141
- }
2142
- const document = JSON.parse(
2143
- Buffer.from(rebound.after_base64, 'base64').toString('utf8'),
2144
- );
2145
- db.prepare(
2146
- `UPDATE decision_records
2147
- SET question = ?, recommendation = ?, selection = ?,
2148
- effects_json = ?, non_goals_json = ?, owner = ?, source = ?,
2149
- provenance_json = ?, applied_refs_json = ?, digest = ?,
2150
- artifact_path = ?, updated_at = ?
2151
- WHERE id = ?`,
2152
- ).run(
2153
- document.question,
2154
- document.recommendation,
2155
- document.selection,
2156
- JSON.stringify(document.effects || {}),
2157
- JSON.stringify(document.non_goals || []),
2158
- document.owner,
2159
- document.source,
2160
- JSON.stringify(document.provenance || {}),
2161
- JSON.stringify(document.applied_refs || []),
2162
- document.digest,
2163
- archivedPath,
2164
- nowIso(),
2165
- decision.id,
2166
- );
2167
- }
2168
- for (const envelope of db.prepare(
2169
- `SELECT id FROM context_envelopes
2170
- WHERE (scope_type = 'change' AND scope_id = ?)
2171
- OR (scope_type = 'task' AND scope_id IN (
2172
- SELECT id FROM tasks WHERE change_id = ?
2173
- ))`,
2174
- ).all(change.id, change.id)) {
2175
- const archivedPath = `${relative}/authority/context-envelopes/${envelope.id}.json`;
2176
- const rebound = rebind.byPath.get(archivedPath);
2177
- if (!rebound) {
2178
- throw new ChangeWorkflowError(
2179
- 'ARCHIVE_CONTEXT_MISSING',
2180
- `archive is missing Context Envelope ${envelope.id}`,
2181
- );
2182
- }
2183
- const document = JSON.parse(
2184
- Buffer.from(rebound.after_base64, 'base64').toString('utf8'),
2185
- );
2186
- db.prepare(
2187
- `UPDATE context_envelopes
2188
- SET digest = ?, file_digest = ?, payload_json = ?, artifact_path = ?
2189
- WHERE id = ?`,
2190
- ).run(
2191
- document.digest,
2192
- rebound.after_digest,
2193
- JSON.stringify(document.envelope),
2194
- archivedPath,
2195
- envelope.id,
2196
- );
2197
- }
2198
- for (const packet of db.prepare(
2199
- `SELECT id FROM worker_packets
2200
- WHERE scope_type = 'task' AND status = 'assigned'
2201
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
2202
- ).all(change.id)) {
2203
- const archivedPath = `${relative}/authority/worker-packets/${packet.id}.json`;
2204
- const rebound = rebind.byPath.get(archivedPath);
2205
- if (!rebound) {
2206
- throw new ChangeWorkflowError(
2207
- 'ARCHIVE_WORKER_PACKET_MISSING',
2208
- `archive is missing Worker Packet ${packet.id}`,
2209
- );
2210
- }
2211
- const document = JSON.parse(
2212
- Buffer.from(rebound.after_base64, 'base64').toString('utf8'),
2213
- );
2214
- const decisionDigest = canonical.digest(
2215
- (document.accepted_decisions || []).map((decision) => ({
2216
- id: decision.id,
2217
- digest: decision.digest,
2218
- status: 'accepted',
2219
- })),
2220
- );
2221
- db.prepare(
2222
- `UPDATE worker_packets
2223
- SET context_digest = ?, task_digest = ?, decision_digest = ?,
2224
- packet_digest = ?, file_digest = ?, packet_path = ?, output_path = ?
2225
- WHERE id = ?`,
2226
- ).run(
2227
- document.context_envelope?.digest || null,
2228
- document.task?.digest || null,
2229
- decisionDigest,
2230
- document.packet_digest,
2231
- rebound.after_digest,
2232
- archivedPath,
2233
- document.output?.path,
2234
- packet.id,
2235
- );
2236
- }
2237
- const checkpointRows = db.prepare(
2238
- `SELECT *
2239
- FROM stage_checkpoints
2240
- WHERE (scope_type = 'change' AND scope_id = ?)
2241
- OR (scope_type = 'task' AND scope_id IN (
2242
- SELECT id FROM tasks WHERE change_id = ?
2243
- ))
2244
- ORDER BY revision`,
2245
- ).all(change.id, change.id);
2246
- for (const row of checkpointRows) {
2247
- const value = {
2248
- stage: row.stage,
2249
- scope_type: row.scope_type,
2250
- scope_id: row.scope_id,
2251
- revision: row.revision,
2252
- payload: archiveRebindAuthorityValue(
2253
- parseJson(row.payload_json || '{}', 'stage_checkpoints.payload_json'),
2254
- intent.source,
2255
- relative,
2256
- rebind.digestMap,
2257
- ),
2258
- evidence: archiveRebindAuthorityValue(
2259
- parseJson(row.evidence_json || '[]', 'stage_checkpoints.evidence_json'),
2260
- intent.source,
2261
- relative,
2262
- rebind.digestMap,
2263
- ),
2264
- diagnostics: archiveRebindAuthorityValue(
2265
- parseJson(row.diagnostics_json || '[]', 'stage_checkpoints.diagnostics_json'),
2266
- intent.source,
2267
- relative,
2268
- rebind.digestMap,
2269
- ),
2270
- context_envelope_id: row.context_envelope_id,
2271
- supersedes_id: row.supersedes_id,
2272
- };
2273
- db.prepare(
2274
- `UPDATE stage_checkpoints
2275
- SET payload_json = ?, evidence_json = ?, diagnostics_json = ?, digest = ?,
2276
- updated_at = ?
2277
- WHERE id = ?`,
2278
- ).run(
2279
- JSON.stringify(value.payload),
2280
- JSON.stringify(value.evidence),
2281
- JSON.stringify(value.diagnostics),
2282
- stageCheckpoints.checkpointDigest(value),
2283
- nowIso(),
2284
- row.id,
2285
- );
2286
- }
2287
- for (const task of db.prepare(
2288
- `SELECT id, context_refs_json, docs_impact_json, ownership_json
2289
- FROM tasks WHERE change_id = ?`,
2290
- ).all(change.id)) {
2291
- db.prepare(
2292
- `UPDATE tasks
2293
- SET context_refs_json = ?, docs_impact_json = ?, ownership_json = ?,
2294
- updated_at = ?
2295
- WHERE id = ?`,
2296
- ).run(
2297
- JSON.stringify(archiveRebindAuthorityValue(
2298
- parseJson(task.context_refs_json || '[]', 'tasks.context_refs_json'),
2299
- intent.source,
2300
- relative,
2301
- rebind.digestMap,
2302
- )),
2303
- JSON.stringify(archiveRebindAuthorityValue(
2304
- parseJson(task.docs_impact_json || '{}', 'tasks.docs_impact_json'),
2305
- intent.source,
2306
- relative,
2307
- rebind.digestMap,
2308
- )),
2309
- JSON.stringify(archiveRebindAuthorityValue(
2310
- parseJson(task.ownership_json || '{}', 'tasks.ownership_json'),
2311
- intent.source,
2312
- relative,
2313
- rebind.digestMap,
2314
- )),
2315
- nowIso(),
2316
- task.id,
2317
- );
2318
- }
2319
- for (const event of db.prepare(
2320
- `SELECT id, payload_json FROM events
2321
- WHERE change_id = ?
2322
- OR task_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
2323
- ).all(change.id, change.id)) {
2324
- db.prepare('UPDATE events SET payload_json = ? WHERE id = ?').run(
2325
- JSON.stringify(archiveRebindAuthorityValue(
2326
- parseJson(event.payload_json || '{}', 'events.payload_json'),
2327
- intent.source,
2328
- relative,
2329
- rebind.digestMap,
2330
- )),
2331
- event.id,
2332
- );
2333
- }
2334
- for (const run of db.prepare(
2335
- `SELECT id, metadata_json, blockers_json, summary_json, approval_json
2336
- FROM workflow_runs WHERE change_id = ?`,
2337
- ).all(change.id)) {
2338
- db.prepare(
2339
- `UPDATE workflow_runs
2340
- SET metadata_json = ?, blockers_json = ?, summary_json = ?, approval_json = ?
2341
- WHERE id = ?`,
2342
- ).run(
2343
- JSON.stringify(archiveRebindAuthorityValue(
2344
- parseJson(run.metadata_json || '{}', 'workflow_runs.metadata_json'),
2345
- intent.source, relative, rebind.digestMap,
2346
- )),
2347
- JSON.stringify(archiveRebindAuthorityValue(
2348
- parseJson(run.blockers_json || '[]', 'workflow_runs.blockers_json'),
2349
- intent.source, relative, rebind.digestMap,
2350
- )),
2351
- JSON.stringify(archiveRebindAuthorityValue(
2352
- parseJson(run.summary_json || '{}', 'workflow_runs.summary_json'),
2353
- intent.source, relative, rebind.digestMap,
2354
- )),
2355
- run.approval_json === null
2356
- ? null
2357
- : JSON.stringify(archiveRebindAuthorityValue(
2358
- parseJson(run.approval_json, 'workflow_runs.approval_json'),
2359
- intent.source, relative, rebind.digestMap,
2360
- )),
2361
- run.id,
2362
- );
2363
- }
2364
- db.prepare(
2365
- `UPDATE trace_links
2366
- SET source_ref = REPLACE(source_ref, ?, ?),
2367
- target_ref = REPLACE(target_ref, ?, ?)
2368
- WHERE change_id = ?`,
2369
- ).run(intent.source, relative, intent.source, relative, change.id);
2370
- upsertArtifact(db, {
2371
- change_id: change.id, kind: 'archive_summary',
2372
- artifactPath: `${relative}/archive-summary.md`,
2373
- contentHash: summaryReader.digest,
2374
- metadata: {
2375
- baseline_updates: intent.baseline_updates,
2376
- no_baseline_change_reason: intent.no_baseline_change_reason,
2377
- },
2378
- terminal: true,
2379
- rootDir,
2380
- });
2381
- upsertArtifact(db, {
2382
- change_id: change.id, kind: 'baseline_reconciliation',
2383
- artifactPath: archivedReconciliationPath,
2384
- contentHash: reconciliationDigest,
2385
- metadata: {
2386
- schema: reconciliationManifest.$schema,
2387
- semantic_changes: reconciliationManifest.semantic_changes.map((item) => item.id),
2388
- resolved_gap_ids: reconciliationManifest.resolved_gap_ids,
2389
- resolved_unknowns: reconciliationManifest.resolved_unknowns,
2390
- },
2391
- terminal: true,
2392
- rootDir,
2393
- });
2394
- for (const artifact of db.prepare(
2395
- 'SELECT id FROM artifacts WHERE change_id = ? ORDER BY id',
2396
- ).all(change.id)) {
2397
- artifacts.setArtifactStatusInTx(db, artifact.id, 'archived');
2398
- }
2399
- if (breakGlass) {
2400
- const currentBaseline = baselines.readBaseline(db);
2401
- if (currentBaseline) {
2402
- baselines.appendGapInTx(db, {
2403
- baseline_id: currentBaseline.id,
2404
- gap: {
2405
- id: `incident-${change.id}-reconciliation`,
2406
- category: 'baseline_blocker',
2407
- status: 'open',
2408
- blocking: true,
2409
- summary: `Incident ${change.id} was archived under break-glass and requires baseline reconciliation.`,
2410
- evidence_refs: [relative],
2411
- owner: change.baseline_bypass.approved_by,
2412
- },
2413
- });
2414
- }
2415
- } else {
2416
- baselines.reconcileBaseline(db, {
2417
- baseline_updates: intent.baseline_updates, change_id: change.id,
2418
- reconciliation: reconciliationManifest,
2419
- accept_delivery_worktree: true,
2420
- }, { rootDir });
2421
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
2422
- if (baselineHealth.blockers.length > 0) {
2423
- throw new ChangeWorkflowError(
2424
- 'BASELINE_RECONCILIATION_INCOMPLETE',
2425
- `baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
2426
- { blockers: baselineHealth.blockers },
2427
- );
2428
- }
2429
- }
2430
- ops.appendEventInTx(db, {
2431
- type: 'change_archived', change_id: change.id,
2432
- payload: {
2433
- archive_path: relative, baseline_updates: intent.baseline_updates,
2434
- no_baseline_change_reason: intent.no_baseline_change_reason,
2435
- reconciliation_path: archivedReconciliationPath,
2436
- reconciliation_digest: reconciliationDigest,
2437
- baseline_bypass: breakGlass,
2438
- },
2439
- });
2440
- summaryReader.verify();
2441
- try {
2442
- archiveJournal.verifyArchiveIntent(
2443
- rootDir, journalIntent, { location: 'destination' },
2444
- );
2445
- } catch (error) {
2446
- throw new ChangeWorkflowError(
2447
- 'ARCHIVE_PATH_UNSAFE',
2448
- `archive transition changed physical identity before commit: ${error.message}`,
2449
- { cause: error.code || error.message },
2450
- );
2451
- }
2452
- });
2453
- } finally {
2454
- summaryReader.close();
2455
- }
30
+ const health = baselines.inspectBaseline(db, { rootDir });
2456
31
  return {
2457
- change: readChange(db, change.id), archive_path: destination,
2458
- baseline_bypass: breakGlass,
32
+ allowed: true,
33
+ diagnostics: health.status === 'pass'
34
+ ? []
35
+ : (health.blockers || []).map((code) => ({
36
+ code,
37
+ severity: 'needs_attention',
38
+ })),
2459
39
  };
2460
40
  }
2461
41
 
2462
- function archiveChange(db, input, {
2463
- rootDir = process.cwd(),
2464
- kernelMode = false,
2465
- } = {}) {
2466
- const change = readChange(db, input.id);
2467
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
2468
- if (!input.summary || String(input.summary).trim().length < 3) {
2469
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
2470
- }
2471
- const allowedArchiveStatuses = kernelMode
2472
- ? ['active', 'archived']
2473
- : ['ready', 'archived'];
2474
- if (!allowedArchiveStatuses.includes(change.status)) {
2475
- throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
2476
- }
2477
- if (change.status === 'archived') {
2478
- deliveryTransaction.completeDeliveryTransaction({
2479
- rootDir,
2480
- changeId: change.id,
2481
- });
2482
- return {
2483
- change,
2484
- archive_path: safeRelativePath(rootDir, change.artifact_root),
2485
- idempotent: true,
2486
- };
2487
- }
2488
-
2489
- let packet = null;
2490
- if (kernelMode) {
2491
- const requiredStages = ['plan', 'test', 'review', 'deliver'];
2492
- const missing = requiredStages.filter((stage) => !stageCheckpoints.currentCheckpoint(
2493
- db,
2494
- stage,
2495
- { change_id: change.id },
2496
- { includeDraft: false },
2497
- ));
2498
- const tasks = ops.listTasks(db, { change_id: change.id });
2499
- if (tasks.length === 0) missing.push('tasks');
2500
- for (const task of tasks.filter((item) => item.status !== 'expanded')) {
2501
- if (task.status !== 'completed') missing.push(`task:${task.id}:completed`);
2502
- if (!stageCheckpoints.currentCheckpoint(
2503
- db,
2504
- 'dev',
2505
- { task_id: task.id },
2506
- { includeDraft: false },
2507
- )) {
2508
- missing.push(`task:${task.id}:dev`);
2509
- }
2510
- }
2511
- if (missing.length > 0) {
2512
- throw new ChangeWorkflowError(
2513
- 'CHANGE_EVIDENCE_INCOMPLETE',
2514
- `change ${change.id} lacks accepted delivery checkpoints`,
2515
- { missing },
2516
- );
2517
- }
2518
- }
2519
- if (changePacket.readDeltaAuthority(db, change.id)) {
2520
- if (!kernelMode) {
2521
- legacyWorkflows().validateDeliveryPrerequisites(
2522
- db,
2523
- { change_id: change.id },
2524
- rootDir,
2525
- );
2526
- }
2527
- packet = changePacket.deliveryEntries(db, change, { rootDir });
2528
- }
2529
- const requestedUpdates = Array.isArray(input.baseline_updates)
2530
- ? input.baseline_updates.map((item) => artifacts.normalizeRelativePath(item))
2531
- : null;
2532
- const updates = packet
2533
- ? packet.baseline_updates
2534
- : (requestedUpdates || []);
2535
- if (packet && requestedUpdates
2536
- && JSON.stringify([...requestedUpdates].sort()) !== JSON.stringify([...updates].sort())) {
2537
- throw new ChangeWorkflowError(
2538
- 'BASELINE_RECONCILIATION_CONFLICT',
2539
- 'baseline_updates must match the typed Change delta exactly',
2540
- );
2541
- }
2542
- const requestedNoChange = input.no_baseline_change_reason
2543
- && String(input.no_baseline_change_reason).trim();
2544
- const noChangeReason = packet
2545
- ? (packet.delta.value.no_semantic_change_reason || requestedNoChange || null)
2546
- : requestedNoChange;
2547
- if (updates.length === 0 && !noChangeReason) {
2548
- throw new ChangeWorkflowError(
2549
- 'BASELINE_RECONCILIATION_REQUIRED',
2550
- 'baseline updates or no-change reason required',
2551
- );
2552
- }
2553
-
2554
- const reconciliation = packet
2555
- ? generatePacketReconciliation(db, change, packet, rootDir)
2556
- : readReconciliationManifest(db, change, input, rootDir);
2557
- if (kernelMode) {
2558
- materializeKernelArchiveAuthority(db, change, rootDir);
2559
- }
2560
- let delivery = null;
2561
- let prepared = null;
2562
- try {
2563
- if (packet) {
2564
- delivery = deliveryTransaction.beginDeliveryTransaction({
2565
- rootDir,
2566
- changeId: change.id,
2567
- entries: packet.entries,
2568
- });
2569
- }
2570
- for (const file of updates) {
2571
- if (!fs.existsSync(safeRelativePath(rootDir, file))) {
2572
- throw new ChangeWorkflowError(
2573
- 'BASELINE_FILE_MISSING', `baseline update missing: ${file}`,
2574
- );
2575
- }
2576
- }
2577
- prepared = archiveJournal.prepareArchiveMove({
2578
- rootDir, change, summary: String(input.summary), baselineUpdates: updates,
2579
- noBaselineChangeReason: noChangeReason || null,
2580
- reconciliationPath: reconciliation.relative,
2581
- reconciliationDigest: reconciliation.journal_intent?.reconciliation_digest
2582
- || reconciliation.digest,
2583
- reconciliationManifest: reconciliation.journal_intent?.reconciliation_manifest
2584
- || reconciliation.manifest,
2585
- });
2586
- } catch (error) {
2587
- if (delivery) {
2588
- try {
2589
- deliveryTransaction.rollbackDeliveryTransaction({
2590
- rootDir,
2591
- changeId: change.id,
2592
- });
2593
- } catch (rollbackError) {
2594
- throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
2595
- cause: error.code || error.message,
2596
- rollback: rollbackError.code || rollbackError.message,
2597
- });
2598
- }
2599
- }
2600
- throw error;
2601
- }
2602
- const authoritativeIntent = {
2603
- ...prepared.intent,
2604
- reconciliation_digest: reconciliation.digest,
2605
- reconciliation_manifest: reconciliation.manifest,
2606
- };
2607
- let result;
2608
- try {
2609
- result = finalizeArchive(db, authoritativeIntent, {
2610
- rootDir,
2611
- journalIntent: prepared.intent,
2612
- kernelMode,
2613
- });
2614
- } catch (error) {
2615
- try { archiveJournal.rollbackArchiveIntent(rootDir, prepared.intent); }
2616
- catch (rollbackError) {
2617
- throw new ChangeWorkflowError('ARCHIVE_RECOVERY_REQUIRED', error.message, {
2618
- cause: error.code || error.message, rollback: rollbackError.code || rollbackError.message,
2619
- });
2620
- }
2621
- if (delivery) {
2622
- try {
2623
- deliveryTransaction.rollbackDeliveryTransaction({
2624
- rootDir,
2625
- changeId: change.id,
2626
- });
2627
- } catch (rollbackError) {
2628
- throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
2629
- cause: error.code || error.message,
2630
- rollback: rollbackError.code || rollbackError.message,
2631
- });
2632
- }
2633
- }
2634
- throw error;
2635
- }
2636
- try { archiveJournal.completeArchiveIntent(rootDir, prepared.intent); }
2637
- catch (error) { result.recovery_warning = `ARCHIVE_JOURNAL_CLEANUP_PENDING:${error.message}`; }
2638
- if (delivery) {
2639
- try {
2640
- deliveryTransaction.completeDeliveryTransaction({
2641
- rootDir,
2642
- changeId: change.id,
2643
- });
2644
- } catch (error) {
2645
- result.recovery_warning = [
2646
- result.recovery_warning,
2647
- `DELIVERY_JOURNAL_CLEANUP_PENDING:${error.message}`,
2648
- ].filter(Boolean).join(';');
2649
- }
2650
- }
2651
- return result;
2652
- }
2653
-
2654
- function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
2655
- const records = archiveJournal.listArchiveIntents(rootDir);
2656
- const result = { found: records.length, resumed: 0, rolled_back: 0, cleaned: 0, failed: 0, items: [] };
2657
- for (const record of records) {
2658
- if (record.error) {
2659
- result.failed += 1;
2660
- result.items.push({ file: record.file, status: 'failed', error: record.error.code || record.error.message });
2661
- continue;
2662
- }
2663
- const change = readChange(db, record.intent.change_id);
2664
- try {
2665
- if (change?.status === 'archived' && change.artifact_root === record.intent.destination) {
2666
- archiveJournal.completeArchiveIntent(rootDir, record.intent);
2667
- result.cleaned += 1;
2668
- result.items.push({ change_id: change.id, status: 'cleaned' });
2669
- } else if (['active', 'ready'].includes(change?.status) && record.location === 'destination') {
2670
- const reconciliation = readReconciliationManifest(db, change, {
2671
- reconciliation_path: record.intent.reconciliation_path,
2672
- baseline_updates: record.intent.baseline_updates,
2673
- }, rootDir);
2674
- const authoritativeIntent = {
2675
- ...record.intent,
2676
- reconciliation_digest: reconciliation.digest,
2677
- reconciliation_manifest: reconciliation.manifest,
2678
- };
2679
- finalizeArchive(db, authoritativeIntent, {
2680
- rootDir,
2681
- journalIntent: record.intent,
2682
- // Recovery is a mechanical continuation of an already prepared
2683
- // archive intent. It must never re-enter the retired semantic
2684
- // workflow supervisor, including for a v0.23 `ready` Change.
2685
- kernelMode: true,
2686
- });
2687
- archiveJournal.completeArchiveIntent(rootDir, record.intent);
2688
- result.resumed += 1;
2689
- result.items.push({ change_id: change.id, status: 'resumed' });
2690
- } else {
2691
- archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
2692
- result.rolled_back += 1;
2693
- result.items.push({ change_id: record.intent.change_id, status: 'rolled_back' });
2694
- }
2695
- } catch (error) {
2696
- try {
2697
- archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
2698
- result.rolled_back += 1;
2699
- result.items.push({
2700
- change_id: record.intent.change_id, status: 'rolled_back',
2701
- error: error.code || error.message,
2702
- });
2703
- } catch (rollbackError) {
2704
- result.failed += 1;
2705
- result.items.push({
2706
- change_id: record.intent.change_id, status: 'failed',
2707
- error: `${error.code || error.message}; ${rollbackError.code || rollbackError.message}`,
2708
- });
2709
- }
2710
- }
2711
- }
2712
- const archivedChangeIds = new Set(
2713
- db.prepare("SELECT id FROM changes WHERE status = 'archived'").all().map((row) => row.id),
2714
- );
2715
- result.delivery = deliveryTransaction.recoverDeliveryTransactions({
2716
- rootDir,
2717
- archivedChangeIds,
2718
- });
2719
- result.failed += result.delivery.failed;
2720
- return result;
42
+ function archiveChange(db, input, options = {}) {
43
+ return compatibility.archiveKernelChange(db, input, options);
2721
44
  }
2722
45
 
2723
46
  module.exports = {
2724
- ChangeWorkflowError,
47
+ ChangeWorkflowError: compatibility.ChangeWorkflowError,
2725
48
  createChange,
2726
- readChange,
2727
- listChanges,
49
+ readChange: compatibility.readChange,
50
+ listChanges: compatibility.listChanges,
2728
51
  updateChange,
2729
- recordDelta,
2730
- recordDocumentationReconciliation,
2731
- compileContext,
2732
- readBreadcrumb(...args) {
2733
- return legacyContextSpine().readBreadcrumb(...args);
2734
- },
2735
- proposeSpecLearning(...args) {
2736
- return legacySpecLearning().proposeSpecLearning(...args);
2737
- },
2738
- resolveSpecLearning(...args) {
2739
- return legacySpecLearning().resolveSpecLearning(...args);
2740
- },
2741
- listSpecLearning(...args) {
2742
- return legacySpecLearning().listSpecLearning(...args);
2743
- },
2744
- convergeChange,
52
+ recordDelta: compatibility.recordDelta,
53
+ recordDocumentationReconciliation: compatibility.recordDocumentationReconciliation,
2745
54
  archiveChange,
2746
- normalizeDocsImpact,
2747
- normalizeProviderRefs,
2748
- normalizeBaselineBypass,
55
+ normalizeDocsImpact: compatibility.normalizeDocsImpact,
56
+ normalizeProviderRefs: compatibility.normalizeProviderRefs,
57
+ normalizeBaselineBypass: compatibility.normalizeBaselineBypass,
2749
58
  assertTaskCreationAllowed,
2750
- recoverInterruptedArchives,
59
+ recoverInterruptedArchives: compatibility.recoverInterruptedArchives,
2751
60
  };