ultra-builder-pro-cli 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/adapters/claude.js +104 -0
  3. package/adapters/codex.js +363 -40
  4. package/adapters/grok.js +1254 -58
  5. package/adapters/kimi.js +682 -13
  6. package/adapters/opencode.js +132 -11
  7. package/docs/AGENT-CONTEXT.md +14 -0
  8. package/docs/ARTIFACT-AUTHORITY.md +13 -0
  9. package/docs/COMMIT-HASH-BACKFILL.md +25 -18
  10. package/docs/RUNTIME-COMPAT-MATRIX.md +18 -2
  11. package/mcp-server/lib/artifact-registry.cjs +181 -51
  12. package/mcp-server/lib/baseline-workflow.cjs +160 -23
  13. package/mcp-server/lib/change-workflow.cjs +21 -16
  14. package/mcp-server/lib/context-envelope.cjs +124 -51
  15. package/mcp-server/lib/decision-records.cjs +164 -78
  16. package/mcp-server/lib/kernel-change-workflow.cjs +2029 -0
  17. package/mcp-server/lib/legacy-change-workflow.cjs +294 -71
  18. package/mcp-server/lib/managed-file-write.cjs +56 -0
  19. package/mcp-server/lib/prd-parser.cjs +2 -2
  20. package/mcp-server/lib/stage-checkpoints.cjs +163 -9
  21. package/mcp-server/lib/state-db.cjs +186 -8
  22. package/mcp-server/lib/state-ops.cjs +214 -31
  23. package/mcp-server/lib/task-contract.cjs +404 -0
  24. package/mcp-server/lib/task-expander.cjs +2 -2
  25. package/mcp-server/lib/task-ledger.cjs +175 -62
  26. package/mcp-server/lib/ultra-facade.cjs +1786 -169
  27. package/mcp-server/lib/worker-packet.cjs +2 -2
  28. package/mcp-server/server.cjs +23 -4
  29. package/orchestrator/parallel-orchestrator.cjs +114 -27
  30. package/orchestrator/session-runner.cjs +25 -136
  31. package/package.json +1 -1
  32. package/skills/ultra-change/SKILL.md +6 -1
  33. package/skills/ultra-dev/SKILL.md +9 -4
  34. package/skills/ultra-plan/SKILL.md +13 -4
  35. package/skills/ultra-plan/references/semantic-preflight.md +6 -4
  36. package/spec/mcp-tools.yaml +114 -9
  37. package/spec/schemas/context-file.v4.5.schema.json +2 -2
  38. package/spec/schemas/state-db.sql +10 -6
  39. package/spec/schemas/task-ledger.v2.schema.json +23 -8
  40. package/spec/schemas/tasks.v4.5.schema.json +34 -12
@@ -26,6 +26,7 @@ const {
26
26
  walkStableProjectTree,
27
27
  } = require('./safe-project-file.cjs');
28
28
  const stageCheckpoints = require('./stage-checkpoints.cjs');
29
+ const kernelChangeWorkflow = require('./kernel-change-workflow.cjs');
29
30
  const reconciliationSchema = require('../../spec/schemas/baseline-reconciliation.v1.schema.json');
30
31
 
31
32
  const reconciliationAjv = new Ajv({ allErrors: true, strict: false });
@@ -56,6 +57,11 @@ const CHANGE_PATCH_FIELDS = new Set([
56
57
  'title', 'intent', 'status', 'docs_impact', 'provider_refs',
57
58
  'contract', 'classification', 'research_disposition',
58
59
  ]);
60
+ const CHANGE_CREATE_FIELDS = new Set([
61
+ 'id', 'title', 'kind', 'intent', 'docs_impact', 'provider_refs',
62
+ 'baseline_bypass', 'contract', 'classification', 'research_disposition',
63
+ 'base_commit', 'alignment_thread_id', 'supersedes_id',
64
+ ]);
59
65
  const SEMANTIC_AUTHORITY_FIELDS = new Set([
60
66
  'intent', 'docs_impact', 'provider_refs', 'contract', 'classification',
61
67
  'research_disposition',
@@ -126,9 +132,28 @@ function rowToChange(row) {
126
132
  return change;
127
133
  }
128
134
 
135
+ function assertExactFields(value, allowed, field) {
136
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
137
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `${field} must be an object`);
138
+ }
139
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
140
+ if (unknown.length > 0) {
141
+ throw new ChangeWorkflowError(
142
+ 'VALIDATION_ERROR', `${field}.${unknown[0]} is not allowed`,
143
+ );
144
+ }
145
+ }
146
+
129
147
  function normalizeDocsImpact(value) {
130
- const input = value || {};
131
- const status = input.status || 'unknown';
148
+ const input = value === undefined ? {} : value;
149
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
150
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact must be an object');
151
+ }
152
+ assertExactFields(input, new Set(['status', 'files', 'rationale']), 'docs_impact');
153
+ const status = input.status === undefined ? 'unknown' : input.status;
154
+ if (typeof status !== 'string') {
155
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact status must be a string');
156
+ }
132
157
  if (!['unknown', 'required', 'none'].includes(status)) {
133
158
  throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid docs impact status: ${status}`);
134
159
  }
@@ -136,8 +161,11 @@ function normalizeDocsImpact(value) {
136
161
  if (!Array.isArray(files) || files.some((file) => typeof file !== 'string' || !file.trim())) {
137
162
  throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact files must be non-empty strings');
138
163
  }
139
- const rationale = input.rationale == null ? null : String(input.rationale).trim();
140
- return { status, files: [...new Set(files)], rationale };
164
+ if (input.rationale != null && typeof input.rationale !== 'string') {
165
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact rationale must be a string or null');
166
+ }
167
+ const rationale = input.rationale == null ? null : input.rationale.trim();
168
+ return { status, files: [...new Set(files.map((file) => file.trim()))], rationale };
141
169
  }
142
170
 
143
171
  function normalizeProviderRefs(value) {
@@ -160,18 +188,58 @@ function stringList(value, field, { allowEmpty = false } = {}) {
160
188
  return [...new Set(value.map((item) => item.trim()))];
161
189
  }
162
190
 
163
- function normalizeChangeContract(value) {
164
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
191
+ function optionalTrimmedText(value, field, { maxLength = 4000 } = {}) {
192
+ if (value === undefined || value === null) return '';
193
+ if (typeof value !== 'string') {
194
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `${field} must be a string`);
195
+ }
196
+ const text = value.trim();
197
+ if (text.length > maxLength) {
198
+ throw new ChangeWorkflowError(
199
+ 'VALIDATION_ERROR',
200
+ `${field} must not exceed ${maxLength} characters`,
201
+ );
202
+ }
203
+ return text;
204
+ }
205
+
206
+ function boundedVocabulary(value, field) {
207
+ const text = optionalTrimmedText(value, field, { maxLength: 80 });
208
+ if (!text) {
209
+ throw new ChangeWorkflowError('VALIDATION_ERROR', `${field} is required`);
210
+ }
211
+ return text;
212
+ }
213
+
214
+ function normalizeChangeContract(value, { allowDraft = false } = {}) {
215
+ if ((!value || typeof value !== 'object' || Array.isArray(value)) && !allowDraft) {
165
216
  throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract is required');
166
217
  }
167
- if (!Array.isArray(value.acceptance) || value.acceptance.length === 0) {
218
+ const input = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
219
+ assertExactFields(
220
+ input,
221
+ new Set([
222
+ 'outcome', 'acceptance', 'non_goals', 'public_seams',
223
+ 'recovery', 'unresolved_decisions',
224
+ ]),
225
+ 'contract',
226
+ );
227
+ if (!allowDraft && (!Array.isArray(input.acceptance) || input.acceptance.length === 0)) {
168
228
  throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.acceptance is required');
169
229
  }
230
+ if (input.acceptance !== undefined && !Array.isArray(input.acceptance)) {
231
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'contract.acceptance must be an array');
232
+ }
170
233
  const ids = new Set();
171
- const acceptance = value.acceptance.map((item, index) => {
234
+ const acceptance = (input.acceptance || []).map((item, index) => {
172
235
  if (!item || typeof item !== 'object' || Array.isArray(item)) {
173
236
  throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `contract.acceptance[${index}] is invalid`);
174
237
  }
238
+ assertExactFields(
239
+ item,
240
+ new Set(['id', 'criterion', 'verification']),
241
+ `contract.acceptance[${index}]`,
242
+ );
175
243
  const id = requiredText(item.id, `contract.acceptance[${index}].id`);
176
244
  if (!CHANGE_ID.test(id) || ids.has(id)) {
177
245
  throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `acceptance id is invalid or duplicated: ${id}`);
@@ -183,19 +251,43 @@ function normalizeChangeContract(value) {
183
251
  verification: requiredText(item.verification, `contract.acceptance[${index}].verification`),
184
252
  };
185
253
  });
186
- const recovery = value.recovery;
187
- if (!recovery || typeof recovery !== 'object' || Array.isArray(recovery)) {
254
+ const recovery = input.recovery;
255
+ if (!allowDraft && (!recovery || typeof recovery !== 'object' || Array.isArray(recovery))) {
188
256
  throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.recovery is required');
189
257
  }
190
- if (!Array.isArray(value.unresolved_decisions)) {
258
+ if (recovery !== undefined && recovery !== null) {
259
+ assertExactFields(
260
+ recovery,
261
+ new Set(['strategy', 'verification']),
262
+ 'contract.recovery',
263
+ );
264
+ }
265
+ if (!allowDraft && !Array.isArray(input.unresolved_decisions)) {
191
266
  throw new ChangeWorkflowError(
192
267
  'CHANGE_CONTRACT_REQUIRED', 'contract.unresolved_decisions must be an explicit array',
193
268
  );
194
269
  }
195
- const decisions = value.unresolved_decisions.map((item, index) => {
270
+ if (input.unresolved_decisions !== undefined && !Array.isArray(input.unresolved_decisions)) {
271
+ throw new ChangeWorkflowError(
272
+ 'VALIDATION_ERROR',
273
+ 'contract.unresolved_decisions must be an array',
274
+ );
275
+ }
276
+ const decisions = (input.unresolved_decisions || []).map((item, index) => {
196
277
  if (!item || typeof item !== 'object' || Array.isArray(item)) {
197
278
  throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `unresolved decision ${index} is invalid`);
198
279
  }
280
+ assertExactFields(
281
+ item,
282
+ new Set(['id', 'summary', 'blocking', 'owner']),
283
+ `contract.unresolved_decisions[${index}]`,
284
+ );
285
+ if (item.blocking !== undefined && typeof item.blocking !== 'boolean') {
286
+ throw new ChangeWorkflowError(
287
+ 'VALIDATION_ERROR',
288
+ `contract.unresolved_decisions[${index}].blocking must be a boolean`,
289
+ );
290
+ }
199
291
  return {
200
292
  id: requiredText(item.id, `contract.unresolved_decisions[${index}].id`),
201
293
  summary: requiredText(item.summary, `contract.unresolved_decisions[${index}].summary`),
@@ -204,23 +296,55 @@ function normalizeChangeContract(value) {
204
296
  };
205
297
  });
206
298
  return {
207
- outcome: requiredText(value.outcome, 'contract.outcome'),
299
+ outcome: allowDraft
300
+ ? optionalTrimmedText(input.outcome, 'contract.outcome')
301
+ : requiredText(input.outcome, 'contract.outcome'),
208
302
  acceptance,
209
- non_goals: stringList(value.non_goals, 'contract.non_goals'),
210
- public_seams: stringList(value.public_seams, 'contract.public_seams'),
211
- recovery: {
212
- strategy: requiredText(recovery.strategy, 'contract.recovery.strategy'),
213
- verification: requiredText(recovery.verification, 'contract.recovery.verification'),
214
- },
303
+ non_goals: stringList(
304
+ input.non_goals === undefined && allowDraft ? [] : input.non_goals,
305
+ 'contract.non_goals',
306
+ { allowEmpty: allowDraft },
307
+ ),
308
+ public_seams: stringList(
309
+ input.public_seams === undefined && allowDraft ? [] : input.public_seams,
310
+ 'contract.public_seams',
311
+ { allowEmpty: allowDraft },
312
+ ),
313
+ recovery: recovery
314
+ ? {
315
+ strategy: allowDraft
316
+ ? optionalTrimmedText(recovery.strategy, 'contract.recovery.strategy')
317
+ : requiredText(recovery.strategy, 'contract.recovery.strategy'),
318
+ verification: allowDraft
319
+ ? optionalTrimmedText(recovery.verification, 'contract.recovery.verification')
320
+ : requiredText(recovery.verification, 'contract.recovery.verification'),
321
+ }
322
+ : null,
215
323
  unresolved_decisions: decisions,
216
324
  };
217
325
  }
218
326
 
219
- function normalizeClassification(value, kind) {
220
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
327
+ function normalizeClassification(value, kind, { allowDraft = false } = {}) {
328
+ if ((!value || typeof value !== 'object' || Array.isArray(value)) && !allowDraft) {
221
329
  throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', 'classification is required');
222
330
  }
223
- const flags = stringList(value.risk_flags, 'classification.risk_flags', { allowEmpty: true });
331
+ const input = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
332
+ assertExactFields(
333
+ input,
334
+ new Set(['rationale', 'risk_flags']),
335
+ 'classification',
336
+ );
337
+ const flags = stringList(
338
+ input.risk_flags === undefined ? [] : input.risk_flags,
339
+ 'classification.risk_flags',
340
+ { allowEmpty: true },
341
+ );
342
+ if (allowDraft) {
343
+ return {
344
+ rationale: optionalTrimmedText(input.rationale, 'classification.rationale'),
345
+ risk_flags: flags,
346
+ };
347
+ }
224
348
  const invalid = flags.filter((flag) => !CHANGE_RISK_FLAGS.has(flag));
225
349
  if (invalid.length > 0) {
226
350
  throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', `unknown risk flags: ${invalid.join(', ')}`);
@@ -235,23 +359,63 @@ function normalizeClassification(value, kind) {
235
359
  }
236
360
  }
237
361
  return {
238
- rationale: requiredText(value.rationale, 'classification.rationale'),
362
+ rationale: requiredText(input.rationale, 'classification.rationale'),
239
363
  risk_flags: flags,
240
364
  };
241
365
  }
242
366
 
243
- function normalizeResearchDisposition(value, kind) {
244
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
367
+ function normalizeResearchDisposition(value, kind, { allowDraft = false } = {}) {
368
+ if ((!value || typeof value !== 'object' || Array.isArray(value)) && !allowDraft) {
245
369
  throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research_disposition is required');
246
370
  }
247
- const status = String(value.status || '').trim();
371
+ const input = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
372
+ assertExactFields(
373
+ input,
374
+ new Set(['status', 'mode', 'selected_steps', 'rationale']),
375
+ 'research_disposition',
376
+ );
377
+ const status = allowDraft
378
+ ? (input.status === undefined
379
+ ? 'unresolved'
380
+ : boundedVocabulary(input.status, 'research_disposition.status'))
381
+ : (typeof input.status === 'string' ? input.status.trim() : '');
382
+ if (allowDraft) {
383
+ const selectedSteps = stringList(
384
+ input.selected_steps === undefined ? [] : input.selected_steps,
385
+ 'research_disposition.selected_steps',
386
+ { allowEmpty: true },
387
+ );
388
+ if (input.mode !== undefined && input.mode !== null && typeof input.mode !== 'string') {
389
+ throw new ChangeWorkflowError(
390
+ 'VALIDATION_ERROR',
391
+ 'research_disposition.mode must be a string or null',
392
+ );
393
+ }
394
+ const mode = input.mode == null
395
+ ? null
396
+ : boundedVocabulary(input.mode, 'research_disposition.mode');
397
+ return {
398
+ status,
399
+ mode,
400
+ selected_steps: selectedSteps,
401
+ rationale: optionalTrimmedText(input.rationale, 'research_disposition.rationale'),
402
+ };
403
+ }
248
404
  if (!RESEARCH_DISPOSITIONS.has(status)) {
249
405
  throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research disposition status is invalid');
250
406
  }
251
407
  const selectedSteps = stringList(
252
- value.selected_steps || [], 'research_disposition.selected_steps', { allowEmpty: true },
408
+ input.selected_steps === undefined ? [] : input.selected_steps,
409
+ 'research_disposition.selected_steps',
410
+ { allowEmpty: true },
253
411
  );
254
- const mode = value.mode == null ? null : String(value.mode).trim();
412
+ if (input.mode != null && typeof input.mode !== 'string') {
413
+ throw new ChangeWorkflowError(
414
+ 'CHANGE_RESEARCH_DISPOSITION_REQUIRED',
415
+ 'research disposition mode must be a string or null',
416
+ );
417
+ }
418
+ const mode = input.mode == null ? null : input.mode.trim();
255
419
  if (status === 'none' && (mode !== null || selectedSteps.length > 0)) {
256
420
  throw new ChangeWorkflowError(
257
421
  'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research status none cannot select a mode or steps',
@@ -274,7 +438,7 @@ function normalizeResearchDisposition(value, kind) {
274
438
  }
275
439
  return {
276
440
  status, mode, selected_steps: selectedSteps,
277
- rationale: requiredText(value.rationale, 'research_disposition.rationale'),
441
+ rationale: requiredText(input.rationale, 'research_disposition.rationale'),
278
442
  };
279
443
  }
280
444
 
@@ -285,6 +449,11 @@ function normalizeBaselineBypass(value) {
285
449
  'incident work without a ready baseline requires an explicit baseline_bypass record',
286
450
  );
287
451
  }
452
+ assertExactFields(
453
+ value,
454
+ new Set(['reason', 'approved_by']),
455
+ 'baseline_bypass',
456
+ );
288
457
  const reason = typeof value.reason === 'string' ? value.reason.trim() : '';
289
458
  const approvedBy = typeof value.approved_by === 'string' ? value.approved_by.trim() : '';
290
459
  if (reason.length < 3 || !approvedBy) {
@@ -579,6 +748,7 @@ function listChanges(db, { status = null, kind = null, limit = 100 } = {}) {
579
748
 
580
749
  function writeIntent(file, change) {
581
750
  const impact = change.docs_impact;
751
+ const recovery = change.contract.recovery;
582
752
  const lines = [
583
753
  `# ${change.title}`,
584
754
  '',
@@ -600,8 +770,8 @@ function writeIntent(file, change) {
600
770
  ]),
601
771
  '## Public seams', '', ...change.contract.public_seams.map((item) => `- ${item}`), '',
602
772
  '## Non-goals', '', ...change.contract.non_goals.map((item) => `- ${item}`), '',
603
- '## Recovery', '', change.contract.recovery.strategy, '',
604
- `Verification: \`${change.contract.recovery.verification}\``, '',
773
+ '## Recovery', '', recovery?.strategy || 'Not yet decided.', '',
774
+ `Verification: \`${recovery?.verification || 'not yet decided'}\``, '',
605
775
  '## Classification', '', change.classification.rationale, '',
606
776
  `Risk flags: ${change.classification.risk_flags.join(', ') || 'none'}`, '',
607
777
  '## Research routing', '', change.research_disposition.rationale, '',
@@ -870,27 +1040,54 @@ function createChange(db, input, {
870
1040
  rootDir = process.cwd(),
871
1041
  kernelMode = false,
872
1042
  } = {}) {
1043
+ assertExactFields(input, CHANGE_CREATE_FIELDS, 'change');
1044
+ const id = typeof input?.id === 'string' ? input.id.trim() : '';
873
1045
  const title = typeof input?.title === 'string' ? input.title.trim() : '';
874
1046
  const intent = typeof input?.intent === 'string' ? input.intent.trim() : '';
875
- if (!input || !CHANGE_ID.test(input.id || '') || title.length < 3 || intent.length < 3) {
1047
+ if (!input || !CHANGE_ID.test(id) || title.length < 3 || intent.length < 3) {
876
1048
  throw new ChangeWorkflowError(
877
1049
  'VALIDATION_ERROR',
878
1050
  'id plus non-blank title and intent of at least three characters are required',
879
1051
  );
880
1052
  }
881
- if (!CHANGE_KINDS.has(input.kind)) {
1053
+ if (!kernelMode && !CHANGE_KINDS.has(input.kind)) {
882
1054
  throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
883
1055
  }
884
- if (input.baseline_bypass !== undefined && input.kind !== 'incident') {
1056
+ const kind = kernelMode
1057
+ ? boundedVocabulary(input.kind, 'change.kind')
1058
+ : input.kind;
1059
+ if (input.base_commit !== undefined && input.base_commit !== null
1060
+ && typeof input.base_commit !== 'string') {
1061
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'base_commit must be a string or null');
1062
+ }
1063
+ if (input.alignment_thread_id !== undefined
1064
+ && (typeof input.alignment_thread_id !== 'string'
1065
+ || input.alignment_thread_id.trim() === '')) {
1066
+ throw new ChangeWorkflowError(
1067
+ 'VALIDATION_ERROR', 'alignment_thread_id must be a non-empty string',
1068
+ );
1069
+ }
1070
+ if (input.supersedes_id !== undefined && input.supersedes_id !== null
1071
+ && (typeof input.supersedes_id !== 'string'
1072
+ || !CHANGE_ID.test(input.supersedes_id.trim()))) {
1073
+ throw new ChangeWorkflowError(
1074
+ 'VALIDATION_ERROR',
1075
+ 'supersedes_id must be a valid Change id',
1076
+ );
1077
+ }
1078
+ const baseCommit = typeof input.base_commit === 'string'
1079
+ ? input.base_commit.trim()
1080
+ : null;
1081
+ if (input.baseline_bypass !== undefined && kind !== 'incident') {
885
1082
  throw new ChangeWorkflowError(
886
1083
  'VALIDATION_ERROR', 'baseline_bypass is valid only for incident changes',
887
1084
  );
888
1085
  }
889
- if (readChange(db, input.id)) throw new ChangeWorkflowError('DUPLICATE_CHANGE_ID', `change ${input.id} exists`);
1086
+ if (readChange(db, id)) throw new ChangeWorkflowError('DUPLICATE_CHANGE_ID', `change ${id} exists`);
890
1087
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
891
1088
  let baselineBypass = null;
892
1089
  if (baselineHealth.status !== 'pass' && !kernelMode) {
893
- if (input.kind !== 'incident') {
1090
+ if (kind !== 'incident') {
894
1091
  throw new ChangeWorkflowError(
895
1092
  'BASELINE_NOT_READY',
896
1093
  `ordinary changes require a ready baseline: ${baselineHealth.blockers.join(', ')}`,
@@ -915,9 +1112,13 @@ function createChange(db, input, {
915
1112
  }
916
1113
  const docsImpact = normalizeDocsImpact(input.docs_impact);
917
1114
  const providers = normalizeProviderRefs(input.provider_refs);
918
- const contract = normalizeChangeContract(input.contract);
919
- const classification = normalizeClassification(input.classification, input.kind);
920
- const researchDisposition = normalizeResearchDisposition(input.research_disposition, input.kind);
1115
+ const contract = normalizeChangeContract(input.contract, { allowDraft: kernelMode });
1116
+ const classification = normalizeClassification(input.classification, kind, {
1117
+ allowDraft: kernelMode,
1118
+ });
1119
+ const researchDisposition = normalizeResearchDisposition(input.research_disposition, kind, {
1120
+ allowDraft: kernelMode,
1121
+ });
921
1122
  let alignmentThread = null;
922
1123
  if (input.alignment_thread_id !== undefined) {
923
1124
  try {
@@ -943,18 +1144,19 @@ function createChange(db, input, {
943
1144
  );
944
1145
  }
945
1146
  }
946
- const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
1147
+ const artifactRoot = path.join('.ultra', 'changes', 'active', id);
947
1148
  const artifactDir = path.resolve(rootDir, artifactRoot);
948
1149
  if (fs.existsSync(artifactDir)) {
949
1150
  throw new ChangeWorkflowError('CHANGE_ARTIFACT_EXISTS', `change artifact already exists: ${artifactDir}`);
950
1151
  }
951
1152
  const ts = nowIso();
952
1153
  const row = {
953
- id: input.id, title, kind: input.kind, status: 'active',
1154
+ id, title, kind, status: 'active',
954
1155
  intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
955
1156
  contract, classification, research_disposition: researchDisposition,
956
1157
  alignment_thread_id: alignmentThread?.id || null,
957
- base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
1158
+ supersedes_id: input.supersedes_id?.trim() || null,
1159
+ base_commit: baseCommit || gitHead(rootDir), artifact_root: artifactRoot,
958
1160
  created_at: ts, updated_at: ts,
959
1161
  };
960
1162
  try {
@@ -963,16 +1165,16 @@ function createChange(db, input, {
963
1165
  `INSERT INTO changes
964
1166
  (id, title, kind, status, intent, docs_impact_json, provider_refs_json,
965
1167
  baseline_bypass_json, contract_json, classification_json,
966
- research_disposition_json, alignment_thread_id, base_commit, artifact_root,
1168
+ research_disposition_json, alignment_thread_id, base_commit, supersedes_id, artifact_root,
967
1169
  created_at, updated_at)
968
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1170
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
969
1171
  ).run(
970
1172
  row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
971
1173
  JSON.stringify(row.provider_refs), row.baseline_bypass
972
1174
  ? JSON.stringify(row.baseline_bypass) : null,
973
1175
  JSON.stringify(row.contract), JSON.stringify(row.classification),
974
1176
  JSON.stringify(row.research_disposition), row.alignment_thread_id,
975
- row.base_commit, row.artifact_root, ts, ts,
1177
+ row.base_commit, row.supersedes_id, row.artifact_root, ts, ts,
976
1178
  );
977
1179
  if (row.alignment_thread_id) {
978
1180
  db.prepare(
@@ -991,6 +1193,7 @@ function createChange(db, input, {
991
1193
  payload: {
992
1194
  kind: row.kind, artifact_root: row.artifact_root,
993
1195
  alignment_thread_id: row.alignment_thread_id,
1196
+ supersedes_id: row.supersedes_id,
994
1197
  },
995
1198
  });
996
1199
  let workflow = kernelMode ? null : legacyWorkflows().startWorkflow(db, {
@@ -1101,8 +1304,17 @@ function updateChange(db, id, patch = {}, {
1101
1304
  rootDir = process.cwd(),
1102
1305
  kernelMode = false,
1103
1306
  } = {}) {
1104
- const current = readChange(db, id);
1105
- if (!current) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${id} not found`);
1307
+ const normalizedId = typeof id === 'string' ? id.trim() : '';
1308
+ if (!CHANGE_ID.test(normalizedId)) {
1309
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'change id is invalid');
1310
+ }
1311
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1312
+ throw new ChangeWorkflowError('VALIDATION_ERROR', 'change patch must be an object');
1313
+ }
1314
+ const current = readChange(db, normalizedId);
1315
+ if (!current) {
1316
+ throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${normalizedId} not found`);
1317
+ }
1106
1318
  if (['archived', 'cancelled'].includes(current.status)) {
1107
1319
  throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${id} is ${current.status}`);
1108
1320
  }
@@ -1130,12 +1342,12 @@ function updateChange(db, id, patch = {}, {
1130
1342
  const sets = [];
1131
1343
  const values = [];
1132
1344
  if (patch.title !== undefined) {
1133
- const title = String(patch.title).trim();
1345
+ const title = typeof patch.title === 'string' ? patch.title.trim() : '';
1134
1346
  if (!title) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change title cannot be empty');
1135
1347
  sets.push('title = ?'); values.push(title);
1136
1348
  }
1137
1349
  if (patch.intent !== undefined) {
1138
- const intent = String(patch.intent).trim();
1350
+ const intent = typeof patch.intent === 'string' ? patch.intent.trim() : '';
1139
1351
  if (!intent) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change intent cannot be empty');
1140
1352
  sets.push('intent = ?'); values.push(intent);
1141
1353
  }
@@ -1146,15 +1358,24 @@ function updateChange(db, id, patch = {}, {
1146
1358
  sets.push('provider_refs_json = ?'); values.push(JSON.stringify(normalizeProviderRefs(patch.provider_refs)));
1147
1359
  }
1148
1360
  if (patch.contract !== undefined) {
1149
- sets.push('contract_json = ?'); values.push(JSON.stringify(normalizeChangeContract(patch.contract)));
1361
+ sets.push('contract_json = ?');
1362
+ values.push(JSON.stringify(normalizeChangeContract(patch.contract, {
1363
+ allowDraft: kernelMode,
1364
+ })));
1150
1365
  }
1151
1366
  if (patch.classification !== undefined) {
1152
1367
  sets.push('classification_json = ?');
1153
- values.push(JSON.stringify(normalizeClassification(patch.classification, current.kind)));
1368
+ values.push(JSON.stringify(normalizeClassification(patch.classification, current.kind, {
1369
+ allowDraft: kernelMode,
1370
+ })));
1154
1371
  }
1155
1372
  if (patch.research_disposition !== undefined) {
1156
1373
  sets.push('research_disposition_json = ?');
1157
- values.push(JSON.stringify(normalizeResearchDisposition(patch.research_disposition, current.kind)));
1374
+ values.push(JSON.stringify(normalizeResearchDisposition(
1375
+ patch.research_disposition,
1376
+ current.kind,
1377
+ { allowDraft: kernelMode },
1378
+ )));
1158
1379
  }
1159
1380
  if (patch.status !== undefined) {
1160
1381
  const allowed = kernelMode
@@ -1171,16 +1392,16 @@ function updateChange(db, id, patch = {}, {
1171
1392
  sets.push('status = ?'); values.push('active');
1172
1393
  }
1173
1394
  if (sets.length === 0) return current;
1174
- sets.push('updated_at = ?'); values.push(nowIso(), id);
1395
+ sets.push('updated_at = ?'); values.push(nowIso(), normalizedId);
1175
1396
  db.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE id = ?`).run(...values);
1176
- const updated = readChange(db, id);
1397
+ const updated = readChange(db, normalizedId);
1177
1398
  const invalidated = [];
1178
1399
  let intentArtifactId = null;
1179
1400
  if (syncIntent) {
1180
1401
  fs.mkdirSync(path.dirname(intentPath), { recursive: true });
1181
1402
  writeIntent(intentPath, updated);
1182
1403
  const recorded = upsertArtifact(db, {
1183
- change_id: id,
1404
+ change_id: normalizedId,
1184
1405
  kind: 'intent',
1185
1406
  artifactPath: path.relative(rootDir, intentPath),
1186
1407
  contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
@@ -1200,7 +1421,7 @@ function updateChange(db, id, patch = {}, {
1200
1421
  !== changeAuthority.changeStateDigest(updated)) {
1201
1422
  invalidated.push(...artifacts.invalidateConsumersFromEndpointInTx(
1202
1423
  db,
1203
- { type: 'change', id },
1424
+ { type: 'change', id: normalizedId },
1204
1425
  {
1205
1426
  reason: 'change_semantic_authority_updated',
1206
1427
  exclude: intentArtifactId ? [{ type: 'artifact', id: intentArtifactId }] : [],
@@ -1212,7 +1433,7 @@ function updateChange(db, id, patch = {}, {
1212
1433
  )].sort();
1213
1434
  ops.appendEventInTx(db, {
1214
1435
  type: 'change_updated',
1215
- change_id: id,
1436
+ change_id: normalizedId,
1216
1437
  payload: {
1217
1438
  fields: Object.keys(patch),
1218
1439
  invalidated_tasks: invalidatedTasks,
@@ -2521,6 +2742,7 @@ function finalizeArchive(db, intent, {
2521
2742
  function archiveChange(db, input, {
2522
2743
  rootDir = process.cwd(),
2523
2744
  kernelMode = false,
2745
+ deferDeliveryCleanup = false,
2524
2746
  } = {}) {
2525
2747
  const change = readChange(db, input.id);
2526
2748
  if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
@@ -2534,10 +2756,12 @@ function archiveChange(db, input, {
2534
2756
  throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
2535
2757
  }
2536
2758
  if (change.status === 'archived') {
2537
- deliveryTransaction.completeDeliveryTransaction({
2538
- rootDir,
2539
- changeId: change.id,
2540
- });
2759
+ if (!deferDeliveryCleanup) {
2760
+ deliveryTransaction.completeDeliveryTransaction({
2761
+ rootDir,
2762
+ changeId: change.id,
2763
+ });
2764
+ }
2541
2765
  return {
2542
2766
  change,
2543
2767
  archive_path: safeRelativePath(rootDir, change.artifact_root),
@@ -2672,7 +2896,7 @@ function archiveChange(db, input, {
2672
2896
  }
2673
2897
  try { archiveJournal.completeArchiveIntent(rootDir, prepared.intent); }
2674
2898
  catch (error) { result.recovery_warning = `ARCHIVE_JOURNAL_CLEANUP_PENDING:${error.message}`; }
2675
- if (delivery) {
2899
+ if (delivery && !deferDeliveryCleanup) {
2676
2900
  try {
2677
2901
  deliveryTransaction.completeDeliveryTransaction({
2678
2902
  rootDir,
@@ -2758,26 +2982,25 @@ function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
2758
2982
  }
2759
2983
 
2760
2984
  function createKernelChange(db, input, options = {}) {
2761
- if (input?.alignment_thread_id !== undefined) {
2762
- throw new ChangeWorkflowError(
2763
- 'VALIDATION_ERROR',
2764
- 'alignment_thread_id belongs to the retired workflow supervisor; record a Decision instead',
2765
- );
2766
- }
2767
- return createChange(db, input, { ...options, kernelMode: true });
2985
+ return kernelChangeWorkflow.createChange(db, input, options);
2986
+ }
2987
+
2988
+ function supersedeKernelChange(db, input = {}, options = {}) {
2989
+ return kernelChangeWorkflow.supersedeChange(db, input, options);
2768
2990
  }
2769
2991
 
2770
2992
  function updateKernelChange(db, id, patch = {}, options = {}) {
2771
- return updateChange(db, id, patch, { ...options, kernelMode: true });
2993
+ return kernelChangeWorkflow.updateChange(db, id, patch, options);
2772
2994
  }
2773
2995
 
2774
2996
  function archiveKernelChange(db, input, options = {}) {
2775
- return archiveChange(db, input, { ...options, kernelMode: true });
2997
+ return kernelChangeWorkflow.archiveChange(db, input, options);
2776
2998
  }
2777
2999
 
2778
3000
  module.exports = {
2779
3001
  ChangeWorkflowError,
2780
3002
  createKernelChange,
3003
+ supersedeKernelChange,
2781
3004
  updateKernelChange,
2782
3005
  archiveKernelChange,
2783
3006
  createChange,