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.
- package/CHANGELOG.md +42 -0
- package/adapters/claude.js +104 -0
- package/adapters/codex.js +363 -40
- package/adapters/grok.js +1254 -58
- package/adapters/kimi.js +682 -13
- package/adapters/opencode.js +132 -11
- package/docs/AGENT-CONTEXT.md +14 -0
- package/docs/ARTIFACT-AUTHORITY.md +13 -0
- package/docs/COMMIT-HASH-BACKFILL.md +25 -18
- package/docs/RUNTIME-COMPAT-MATRIX.md +18 -2
- package/mcp-server/lib/artifact-registry.cjs +181 -51
- package/mcp-server/lib/baseline-workflow.cjs +160 -23
- package/mcp-server/lib/change-workflow.cjs +21 -16
- package/mcp-server/lib/context-envelope.cjs +124 -51
- package/mcp-server/lib/decision-records.cjs +164 -78
- package/mcp-server/lib/kernel-change-workflow.cjs +2029 -0
- package/mcp-server/lib/legacy-change-workflow.cjs +294 -71
- package/mcp-server/lib/managed-file-write.cjs +56 -0
- package/mcp-server/lib/prd-parser.cjs +2 -2
- package/mcp-server/lib/stage-checkpoints.cjs +163 -9
- package/mcp-server/lib/state-db.cjs +186 -8
- package/mcp-server/lib/state-ops.cjs +214 -31
- package/mcp-server/lib/task-contract.cjs +404 -0
- package/mcp-server/lib/task-expander.cjs +2 -2
- package/mcp-server/lib/task-ledger.cjs +175 -62
- package/mcp-server/lib/ultra-facade.cjs +1786 -169
- package/mcp-server/lib/worker-packet.cjs +2 -2
- package/mcp-server/server.cjs +23 -4
- package/orchestrator/parallel-orchestrator.cjs +114 -27
- package/orchestrator/session-runner.cjs +25 -136
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +6 -1
- package/skills/ultra-dev/SKILL.md +9 -4
- package/skills/ultra-plan/SKILL.md +13 -4
- package/skills/ultra-plan/references/semantic-preflight.md +6 -4
- package/spec/mcp-tools.yaml +114 -9
- package/spec/schemas/context-file.v4.5.schema.json +2 -2
- package/spec/schemas/state-db.sql +10 -6
- package/spec/schemas/task-ledger.v2.schema.json +23 -8
- 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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
164
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
299
|
+
outcome: allowDraft
|
|
300
|
+
? optionalTrimmedText(input.outcome, 'contract.outcome')
|
|
301
|
+
: requiredText(input.outcome, 'contract.outcome'),
|
|
208
302
|
acceptance,
|
|
209
|
-
non_goals: stringList(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
408
|
+
input.selected_steps === undefined ? [] : input.selected_steps,
|
|
409
|
+
'research_disposition.selected_steps',
|
|
410
|
+
{ allowEmpty: true },
|
|
253
411
|
);
|
|
254
|
-
|
|
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(
|
|
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', '',
|
|
604
|
-
`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(
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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,
|
|
920
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
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
|
|
1105
|
-
if (!
|
|
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 =
|
|
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 =
|
|
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 = ?');
|
|
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(
|
|
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(),
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
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
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
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,
|
|
2993
|
+
return kernelChangeWorkflow.updateChange(db, id, patch, options);
|
|
2772
2994
|
}
|
|
2773
2995
|
|
|
2774
2996
|
function archiveKernelChange(db, input, options = {}) {
|
|
2775
|
-
return archiveChange(db, input,
|
|
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,
|