ultra-builder-pro-cli 0.20.0 → 0.21.1

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 (102) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +66 -0
  5. package/README.md +54 -24
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +36 -3
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/docs/AGENT-CONTEXT.md +74 -43
  15. package/docs/ARCHITECTURE.md +63 -31
  16. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  17. package/docs/DECISIONS.md +10 -4
  18. package/docs/LEGACY-CLI-CROSSWALK.md +6 -4
  19. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  20. package/docs/ROADMAP.md +19 -5
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +24 -13
  22. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  23. package/docs/WORKFLOW-LIFECYCLE.md +70 -39
  24. package/hooks/README.md +6 -5
  25. package/hooks/active_task_context.py +97 -12
  26. package/hooks/context_spine.py +16 -4
  27. package/hooks/health_check.py +5 -4
  28. package/hooks/pre_stop_check.py +5 -2
  29. package/hooks/runtime_paths.py +396 -0
  30. package/hooks/subagent_tracker.py +5 -2
  31. package/hooks/workflow_checkpoint.py +16 -3
  32. package/hooks/workflow_context.py +11 -3
  33. package/hooks/workflow_resume.py +7 -4
  34. package/mcp-server/breadcrumb.cjs +20 -2
  35. package/mcp-server/lib/archive-journal.cjs +1026 -111
  36. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  37. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  38. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  39. package/mcp-server/lib/change-packet.cjs +818 -0
  40. package/mcp-server/lib/change-workflow.cjs +932 -176
  41. package/mcp-server/lib/context-paths.cjs +111 -0
  42. package/mcp-server/lib/context-spine.cjs +511 -45
  43. package/mcp-server/lib/decision-dialogue.cjs +345 -3
  44. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  45. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  46. package/mcp-server/lib/doctor.cjs +373 -11
  47. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  48. package/mcp-server/lib/init-project.cjs +190 -95
  49. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  50. package/mcp-server/lib/plan-store.cjs +700 -5
  51. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  52. package/mcp-server/lib/projector.cjs +174 -40
  53. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  54. package/mcp-server/lib/safe-project-file.cjs +385 -0
  55. package/mcp-server/lib/spec-learning.cjs +28 -12
  56. package/mcp-server/lib/state-db.cjs +263 -9
  57. package/mcp-server/lib/state-ops.cjs +248 -49
  58. package/mcp-server/lib/telemetry.cjs +12 -3
  59. package/mcp-server/lib/workflow-state.cjs +610 -58
  60. package/mcp-server/server.cjs +278 -33
  61. package/orchestrator/daemon.cjs +2 -10
  62. package/orchestrator/parallel-orchestrator.cjs +314 -50
  63. package/orchestrator/planner/plan-builder.cjs +1 -1
  64. package/orchestrator/recovery.cjs +125 -7
  65. package/orchestrator/session-close-journal-worker.cjs +5 -0
  66. package/orchestrator/session-close-journal.cjs +1014 -0
  67. package/orchestrator/session-runner.cjs +679 -69
  68. package/orchestrator/worktree-manager.cjs +186 -35
  69. package/package.json +2 -3
  70. package/skills/cc-collab/SKILL.md +4 -1
  71. package/skills/codex-collab/SKILL.md +5 -2
  72. package/skills/ultra-change/SKILL.md +26 -8
  73. package/skills/ultra-deliver/SKILL.md +23 -11
  74. package/skills/ultra-dev/SKILL.md +33 -6
  75. package/skills/ultra-doctor/SKILL.md +6 -2
  76. package/skills/ultra-init/SKILL.md +4 -2
  77. package/skills/ultra-plan/SKILL.md +49 -16
  78. package/skills/ultra-research/SKILL.md +30 -27
  79. package/skills/ultra-review/SKILL.md +9 -5
  80. package/skills/ultra-status/SKILL.md +9 -4
  81. package/skills/ultra-test/SKILL.md +10 -5
  82. package/skills/ultra-think/SKILL.md +6 -2
  83. package/skills/ultra-think/references/decision-dialogue.md +17 -6
  84. package/skills/ultra-verify/SKILL.md +4 -1
  85. package/spec/cli-protocol.md +18 -7
  86. package/spec/mcp-tools.yaml +502 -44
  87. package/spec/schemas/change-delta.v1.schema.json +153 -0
  88. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  89. package/spec/schemas/mcp-tools.schema.json +1 -1
  90. package/spec/schemas/state-db.sql +50 -13
  91. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  92. package/ultra-tools/cli.cjs +3 -2
  93. package/ultra-tools/commands/db.cjs +76 -25
  94. package/ultra-tools/commands/migrate.cjs +995 -80
  95. package/ultra-tools/commands/session.cjs +34 -8
  96. package/ultra-tools/commands/status.cjs +32 -6
  97. package/ultra-tools/commands/system.cjs +129 -22
  98. package/commands/learn.md +0 -10
  99. package/orchestrator/skill-miner.cjs +0 -133
  100. package/output-styles/coding-vibes.md +0 -67
  101. package/output-styles/structural-thinking.md +0 -61
  102. package/skills/learn/SKILL.md +0 -58
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('node:crypto');
4
- const fs = require('node:fs');
5
4
  const path = require('node:path');
6
5
  const { spawnSync } = require('node:child_process');
7
6
  const Ajv = require('ajv/dist/2020');
@@ -9,6 +8,8 @@ const addFormats = require('ajv-formats');
9
8
 
10
9
  const ops = require('./state-ops.cjs');
11
10
  const decisionDialogue = require('./decision-dialogue.cjs');
11
+ const artifactRegistry = require('./artifact-registry.cjs');
12
+ const { readStableProjectFile } = require('./safe-project-file.cjs');
12
13
  const testReportSchema = require('../../spec/schemas/test-report.v1.schema.json');
13
14
  const deliveryReportSchema = require('../../spec/schemas/delivery-report.v1.schema.json');
14
15
 
@@ -56,6 +57,9 @@ const WORKFLOW_DEFINITIONS = Object.freeze({
56
57
  ]),
57
58
  plan: Object.freeze([
58
59
  step('validate-baseline', 'Validate baseline and research coverage', { evidence_required: true }),
60
+ step('compile-context', 'Compile the accepted planning context', {
61
+ evidence_required: true, output_required: true,
62
+ }),
59
63
  step('analyze-requirements', 'Trace requirements and acceptance'),
60
64
  step('analyze-codebase', 'Inspect current codebase patterns and boundaries', { evidence_required: true }),
61
65
  step('design-slices', 'Design walking skeleton and vertical slices'),
@@ -195,18 +199,89 @@ function nonEmpty(value, field) {
195
199
 
196
200
  function safeProjectFile(rootDir, candidate, field) {
197
201
  const relative = nonEmpty(candidate, field);
198
- if (path.isAbsolute(relative)) {
199
- throw new WorkflowStateError('VALIDATION_ERROR', `${field} must be project-relative`);
202
+ try {
203
+ return readStableProjectFile(rootDir, relative, { encoding: 'utf8' });
204
+ } catch (error) {
205
+ if (error?.code === 'PROJECT_FILE_MISSING') {
206
+ throw new WorkflowStateError(
207
+ 'WORKFLOW_OUTPUT_MISSING',
208
+ `workflow output does not exist: ${relative}`,
209
+ );
210
+ }
211
+ throw new WorkflowStateError(
212
+ 'VALIDATION_ERROR',
213
+ `${field} is not a safe project-relative regular file: ${error.message}`,
214
+ );
200
215
  }
201
- const root = path.resolve(rootDir);
202
- const file = path.resolve(root, relative);
203
- if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
204
- throw new WorkflowStateError('VALIDATION_ERROR', `${field} escapes project root`);
216
+ }
217
+
218
+ function requireManagedArtifact(db, {
219
+ owner_type, owner_id, kind, artifactPath, digest, error_code, field,
220
+ statuses = ['current'],
221
+ }) {
222
+ let relative;
223
+ try {
224
+ relative = artifactRegistry.normalizeRelativePath(artifactPath);
225
+ } catch (error) {
226
+ throw new WorkflowStateError(
227
+ error_code,
228
+ `${field} has an invalid registry path: ${error.message}`,
229
+ );
205
230
  }
206
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
207
- throw new WorkflowStateError('WORKFLOW_OUTPUT_MISSING', `workflow output does not exist: ${relative}`);
231
+ const rows = db.prepare(
232
+ `SELECT id, owner_type, owner_id, kind, path, digest, content_hash, managed, status
233
+ FROM artifacts
234
+ WHERE path = ?
235
+ ORDER BY updated_at DESC, rowid DESC`,
236
+ ).all(relative);
237
+ const eligible = rows.filter((item) => statuses.includes(item.status));
238
+ const row = eligible.length === 1 ? eligible[0] : null;
239
+ const expectedDigest = row?.digest || row?.content_hash || null;
240
+ if (!row || row.owner_type !== owner_type || row.owner_id !== owner_id
241
+ || row.kind !== kind || row.path !== relative || row.managed !== 1
242
+ || !statuses.includes(row.status) || !expectedDigest || expectedDigest !== digest) {
243
+ throw new WorkflowStateError(
244
+ error_code,
245
+ `${field} does not match one exact active managed Artifact Registry authority`,
246
+ {
247
+ path: relative,
248
+ expected: {
249
+ owner_type, owner_id, kind, digest, statuses,
250
+ },
251
+ actual: rows.map((item) => ({
252
+ id: item.id,
253
+ owner_type: item.owner_type,
254
+ owner_id: item.owner_id,
255
+ kind: item.kind,
256
+ digest: item.digest || item.content_hash || null,
257
+ managed: Boolean(item.managed),
258
+ status: item.status,
259
+ })),
260
+ },
261
+ );
208
262
  }
209
- return { relative, file };
263
+ return row;
264
+ }
265
+
266
+ function assertChangeArtifactPath(db, run, artifactPath, field) {
267
+ if (!run.change_id) return artifactRegistry.normalizeRelativePath(artifactPath);
268
+ const change = db.prepare(
269
+ 'SELECT artifact_root FROM changes WHERE id = ?',
270
+ ).get(run.change_id);
271
+ if (!change) {
272
+ throw new WorkflowStateError(
273
+ 'WORKFLOW_CHANGE_NOT_FOUND', `change ${run.change_id} does not exist`,
274
+ );
275
+ }
276
+ const relative = artifactRegistry.normalizeRelativePath(artifactPath);
277
+ const root = artifactRegistry.normalizeRelativePath(change.artifact_root);
278
+ if (relative !== root && !relative.startsWith(`${root}/`)) {
279
+ throw new WorkflowStateError(
280
+ 'WORKFLOW_ARTIFACT_SCOPE_INVALID',
281
+ `${field} must stay inside the owning Change root ${root}: ${relative}`,
282
+ );
283
+ }
284
+ return relative;
210
285
  }
211
286
 
212
287
  function normalizeObjects(value, field) {
@@ -233,7 +308,7 @@ function normalizeOutputs(value, rootDir) {
233
308
  return {
234
309
  path: resolved.relative,
235
310
  kind: nonEmpty(item.kind, `outputs[${index}].kind`),
236
- digest: crypto.createHash('sha256').update(fs.readFileSync(resolved.file)).digest('hex'),
311
+ digest: resolved.digest,
237
312
  };
238
313
  });
239
314
  }
@@ -255,7 +330,7 @@ function sourceRefFile(rootDir, sourceRef, field) {
255
330
  const relative = ref.slice(0, separator);
256
331
  const anchor = ref.slice(separator + 1).trim().toLowerCase();
257
332
  const resolved = safeProjectFile(rootDir, relative, field);
258
- const content = fs.readFileSync(resolved.file, 'utf8');
333
+ const content = resolved.text;
259
334
  const anchors = new Set();
260
335
  for (const match of content.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) anchors.add(markdownSlug(match[1]));
261
336
  for (const match of content.matchAll(/<a\s+(?:name|id)=["']([^"']+)["'][^>]*>/gi)) {
@@ -268,7 +343,7 @@ function sourceRefFile(rootDir, sourceRef, field) {
268
343
  }
269
344
  return {
270
345
  ref, relative, anchor,
271
- digest: crypto.createHash('sha256').update(content).digest('hex'),
346
+ digest: resolved.digest,
272
347
  };
273
348
  }
274
349
 
@@ -371,10 +446,15 @@ function validateSynthesisTrace(db, runRow, semanticRecords) {
371
446
  }
372
447
  }
373
448
 
374
- function validateResearchStepReport(run, stepId, outputs, rootDir) {
375
- const expected = path.join(
376
- '.ultra', 'docs', 'research', run.id, `${stepId}.md`,
377
- );
449
+ function validateResearchStepReport(db, run, stepId, outputs, rootDir) {
450
+ const change = run.change_id
451
+ ? db.prepare('SELECT artifact_root FROM changes WHERE id = ?').get(run.change_id)
452
+ : null;
453
+ const expected = change
454
+ ? path.join(
455
+ change.artifact_root, 'research', run.id, `${stepId}.md`,
456
+ )
457
+ : path.join('.ultra', 'docs', 'research', run.id, `${stepId}.md`);
378
458
  const report = outputs.find((output) => path.normalize(output.path) === path.normalize(expected));
379
459
  if (!report) {
380
460
  throw new WorkflowStateError(
@@ -383,7 +463,7 @@ function validateResearchStepReport(run, stepId, outputs, rootDir) {
383
463
  );
384
464
  }
385
465
  const resolved = safeProjectFile(rootDir, report.path, `${stepId}.research_report`);
386
- const content = fs.readFileSync(resolved.file, 'utf8');
466
+ const content = resolved.text;
387
467
  const requiredSections = ['Evidence', 'Specification updates', 'Decisions and unknowns'];
388
468
  const missing = requiredSections.filter(
389
469
  (heading) => !new RegExp(`^##\\s+${heading}\\s*$`, 'im').test(content),
@@ -412,6 +492,56 @@ function validateResearchStepReport(run, stepId, outputs, rootDir) {
412
492
  }
413
493
  }
414
494
 
495
+ function registerChangeResearchOutputsInTx(db, run, stepId, outputs, rootDir) {
496
+ if (!run.change_id) return [];
497
+ const change = db.prepare(
498
+ 'SELECT artifact_root FROM changes WHERE id = ?',
499
+ ).get(run.change_id);
500
+ if (!change) {
501
+ throw new WorkflowStateError(
502
+ 'WORKFLOW_CHANGE_NOT_FOUND',
503
+ `research change ${run.change_id} does not exist`,
504
+ );
505
+ }
506
+ const prefix = artifactRegistry.normalizeRelativePath(change.artifact_root);
507
+ return outputs.map((output) => {
508
+ const relative = artifactRegistry.normalizeRelativePath(output.path);
509
+ if (relative !== prefix && !relative.startsWith(`${prefix}/`)) {
510
+ throw new WorkflowStateError(
511
+ 'WORKFLOW_RESEARCH_REPORT_REQUIRED',
512
+ `Change research output must stay inside ${prefix}: ${relative}`,
513
+ );
514
+ }
515
+ const kind = relative.endsWith(`/research/${run.id}/${stepId}.md`)
516
+ ? 'research_step_report'
517
+ : String(output.kind || 'research_semantic_artifact')
518
+ .trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_');
519
+ return artifactRegistry.recordArtifactInTx(db, {
520
+ id: `workflow-${run.id}-${stepId}-${crypto.createHash('sha256')
521
+ .update(relative).digest('hex').slice(0, 12)}`,
522
+ owner_type: 'workflow',
523
+ owner_id: run.id,
524
+ kind,
525
+ path: relative,
526
+ content_digest: output.digest,
527
+ source_refs: [
528
+ { type: 'workflow', id: run.id, relation: 'produced_by' },
529
+ { type: 'change', id: run.change_id, relation: 'researched_for' },
530
+ ...(run.baseline_id
531
+ ? [{ type: 'baseline', id: run.baseline_id, relation: 'deltas_from' }]
532
+ : []),
533
+ ],
534
+ consumer_refs: [],
535
+ provenance: {
536
+ writer: 'workflow.step',
537
+ workflow_run_id: run.id,
538
+ step_id: stepId,
539
+ },
540
+ metadata: { terminal_role: true, semantic: true },
541
+ }, { rootDir }).artifact;
542
+ });
543
+ }
544
+
415
545
  function rowToStep(row) {
416
546
  if (!row) return null;
417
547
  const value = {
@@ -466,6 +596,10 @@ function definitionForRun(run) {
466
596
 
467
597
  function inspectRunHealth(run, steps, rootDir) {
468
598
  const blockers = [...inspectOutputHealth(steps, rootDir).blockers];
599
+ const runMetadata = parseJson(run.metadata_json, 'workflow_runs.metadata_json', {});
600
+ if (runMetadata.authority_invalidation?.invalidated === true) {
601
+ blockers.push('WORKFLOW_AUTHORITY_STALE');
602
+ }
469
603
  const definition = definitionForRun(run);
470
604
  const expectedIds = new Set(definition.map((item) => item.id));
471
605
  const actualIds = new Set(steps.map((item) => item.step_id));
@@ -544,8 +678,7 @@ function inspectOutputHealth(steps, rootDir) {
544
678
  for (const output of latestByPath.values()) {
545
679
  try {
546
680
  const resolved = safeProjectFile(rootDir, output.path, 'output.path');
547
- const current = crypto.createHash('sha256').update(fs.readFileSync(resolved.file)).digest('hex');
548
- if (current !== output.digest) blockers.push(`WORKFLOW_OUTPUT_STALE:${output.path}`);
681
+ if (resolved.digest !== output.digest) blockers.push(`WORKFLOW_OUTPUT_STALE:${output.path}`);
549
682
  } catch (error) {
550
683
  blockers.push(error.code === 'WORKFLOW_OUTPUT_MISSING'
551
684
  ? `WORKFLOW_OUTPUT_MISSING:${output.path}`
@@ -720,6 +853,19 @@ function assertWorkflowAuthority(db, input, mode) {
720
853
  );
721
854
  }
722
855
  };
856
+ const changeWideKinds = new Set(['research', 'change', 'plan', 'deliver']);
857
+ if ((input.kind === 'init' || changeWideKinds.has(input.kind)) && input.task_id) {
858
+ throw new WorkflowStateError(
859
+ 'WORKFLOW_SCOPE_INVALID',
860
+ `${input.kind} workflow is not task-scoped and must not provide task_id`,
861
+ );
862
+ }
863
+ if (input.kind === 'init' && input.change_id) {
864
+ throw new WorkflowStateError(
865
+ 'WORKFLOW_SCOPE_INVALID',
866
+ 'init workflow is baseline-scoped and must not provide change_id',
867
+ );
868
+ }
723
869
  if (input.kind !== 'research'
724
870
  && (input.mode !== undefined || input.selected_steps !== undefined || input.coverage !== undefined)) {
725
871
  throw new WorkflowStateError(
@@ -807,11 +953,17 @@ function assertPlanWorkflowAuthority(db, input) {
807
953
  change.research_disposition_json || '{}', 'changes.research_disposition_json', {},
808
954
  );
809
955
  if (['bounded', 'required'].includes(disposition.status)) {
810
- const row = db.prepare(
956
+ const rows = db.prepare(
811
957
  `SELECT id FROM workflow_runs
812
958
  WHERE kind = 'research' AND change_id = ? AND mode = ? AND status = 'completed'
813
- ORDER BY completed_at DESC, rowid DESC LIMIT 1`,
814
- ).get(change.id, disposition.mode);
959
+ ORDER BY completed_at DESC, rowid DESC`,
960
+ ).all(change.id, disposition.mode);
961
+ const row = rows.find((candidate) => {
962
+ const run = readWorkflow(db, candidate.id, {
963
+ rootDir: input.root_dir || process.cwd(),
964
+ });
965
+ return isConsumableWorkflowAuthority(run);
966
+ });
815
967
  const research = row ? readWorkflow(db, row.id, { rootDir: input.root_dir || process.cwd() }) : null;
816
968
  const expectedSteps = new Set([
817
969
  ...(Array.isArray(disposition.selected_steps) ? disposition.selected_steps : []),
@@ -1044,6 +1196,188 @@ function startWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
1044
1196
  return readWorkflow(db, id, { rootDir });
1045
1197
  }
1046
1198
 
1199
+ function reviseWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
1200
+ const id = nonEmpty(input.id, 'id');
1201
+ const replacementId = nonEmpty(input.replacement_id, 'replacement_id');
1202
+ const reason = nonEmpty(input.reason, 'reason');
1203
+ const subject = nonEmpty(input.subject, 'subject');
1204
+ return ops.tx(db, () => {
1205
+ const current = readWorkflow(db, id, { rootDir });
1206
+ if (!current) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
1207
+ if (current.status !== 'completed') {
1208
+ throw new WorkflowStateError(
1209
+ 'WORKFLOW_REVISION_SOURCE_NOT_ACCEPTED',
1210
+ `workflow ${id} must be completed before a revision can be proposed`,
1211
+ );
1212
+ }
1213
+ if (current.metadata.revision?.superseded_by) {
1214
+ throw new WorkflowStateError(
1215
+ 'WORKFLOW_ALREADY_SUPERSEDED',
1216
+ `workflow ${id} was superseded by ${current.metadata.revision.superseded_by}`,
1217
+ );
1218
+ }
1219
+ const generation = Number(current.metadata.revision?.generation || 1) + 1;
1220
+ const metadata = {
1221
+ ...current.metadata,
1222
+ revision: {
1223
+ revision_of: current.id,
1224
+ generation,
1225
+ reason,
1226
+ candidate_created_at: nowIso(),
1227
+ authority_status: 'candidate',
1228
+ },
1229
+ };
1230
+ delete metadata.authority_invalidation;
1231
+ const nextInput = {
1232
+ id: replacementId,
1233
+ kind: current.kind,
1234
+ mode: current.kind === 'research' ? current.mode : undefined,
1235
+ subject,
1236
+ baseline_id: current.baseline_id,
1237
+ change_id: current.change_id,
1238
+ task_id: current.task_id,
1239
+ metadata,
1240
+ ...(current.kind === 'research'
1241
+ ? { coverage: current.metadata.coverage }
1242
+ : {}),
1243
+ };
1244
+ const inserted = insertWorkflowInTx(db, nextInput, { rootDir });
1245
+ ops.appendEventInTx(db, {
1246
+ type: 'workflow_revision_started',
1247
+ task_id: current.task_id,
1248
+ change_id: current.change_id,
1249
+ payload: {
1250
+ workflow_id: inserted,
1251
+ revision_of: current.id,
1252
+ generation,
1253
+ reason,
1254
+ },
1255
+ });
1256
+ return readWorkflow(db, inserted, { rootDir });
1257
+ });
1258
+ }
1259
+
1260
+ function sameWorkflowAuthority(left, right) {
1261
+ return left.kind === right.kind
1262
+ && (left.baseline_id || null) === (right.baseline_id || null)
1263
+ && (left.change_id || null) === (right.change_id || null)
1264
+ && (left.task_id || null) === (right.task_id || null);
1265
+ }
1266
+
1267
+ function isConsumableWorkflowAuthority(run) {
1268
+ const metadata = run?.metadata || {};
1269
+ const authorityStatus = metadata.revision?.authority_status;
1270
+ return !metadata.revision?.superseded_by
1271
+ && metadata.authority_invalidation?.invalidated !== true
1272
+ && !['candidate', 'superseded'].includes(authorityStatus);
1273
+ }
1274
+
1275
+ function supersedeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
1276
+ const id = nonEmpty(input.id, 'id');
1277
+ const replacementId = nonEmpty(input.replacement_id, 'replacement_id');
1278
+ const reason = nonEmpty(input.reason, 'reason');
1279
+ if (id === replacementId) {
1280
+ throw new WorkflowStateError('VALIDATION_ERROR', 'a workflow cannot supersede itself');
1281
+ }
1282
+ return ops.tx(db, () => {
1283
+ const prior = readWorkflow(db, id, { rootDir });
1284
+ const replacement = readWorkflow(db, replacementId, { rootDir });
1285
+ if (!prior) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
1286
+ if (!replacement) {
1287
+ throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${replacementId} not found`);
1288
+ }
1289
+ if (prior.status !== 'completed' || replacement.status !== 'completed') {
1290
+ throw new WorkflowStateError(
1291
+ 'WORKFLOW_SUPERSESSION_NOT_ACCEPTED',
1292
+ 'both the prior workflow and its replacement must be completed',
1293
+ );
1294
+ }
1295
+ if (!sameWorkflowAuthority(prior, replacement)) {
1296
+ throw new WorkflowStateError(
1297
+ 'WORKFLOW_SUPERSESSION_AUTHORITY_MISMATCH',
1298
+ 'replacement workflow kind and authority bindings must match the prior workflow',
1299
+ );
1300
+ }
1301
+ if (replacement.metadata.revision?.revision_of !== prior.id) {
1302
+ throw new WorkflowStateError(
1303
+ 'WORKFLOW_SUPERSESSION_AUTHORITY_MISMATCH',
1304
+ `replacement ${replacement.id} is not a revision of ${prior.id}`,
1305
+ );
1306
+ }
1307
+ if (replacement.artifact_health.status !== 'pass') {
1308
+ throw new WorkflowStateError(
1309
+ 'WORKFLOW_OUTPUT_STALE',
1310
+ `replacement workflow ${replacement.id} is not current`,
1311
+ replacement.artifact_health,
1312
+ );
1313
+ }
1314
+ if (prior.metadata.revision?.superseded_by) {
1315
+ if (prior.metadata.revision.superseded_by === replacement.id) {
1316
+ return {
1317
+ superseded: prior,
1318
+ workflow: replacement,
1319
+ invalidated: [],
1320
+ idempotent: true,
1321
+ };
1322
+ }
1323
+ throw new WorkflowStateError(
1324
+ 'WORKFLOW_ALREADY_SUPERSEDED',
1325
+ `workflow ${prior.id} was superseded by ${prior.metadata.revision.superseded_by}`,
1326
+ );
1327
+ }
1328
+ const ts = nowIso();
1329
+ const priorMetadata = {
1330
+ ...prior.metadata,
1331
+ revision: {
1332
+ ...(prior.metadata.revision || { generation: 1 }),
1333
+ superseded_by: replacement.id,
1334
+ superseded_at: ts,
1335
+ supersession_reason: reason,
1336
+ authority_status: 'superseded',
1337
+ },
1338
+ };
1339
+ const replacementMetadata = {
1340
+ ...replacement.metadata,
1341
+ revision: {
1342
+ ...replacement.metadata.revision,
1343
+ authority_status: 'current',
1344
+ accepted_at: ts,
1345
+ },
1346
+ };
1347
+ db.prepare(
1348
+ 'UPDATE workflow_runs SET metadata_json = ?, updated_at = ? WHERE id = ?',
1349
+ ).run(JSON.stringify(priorMetadata), ts, prior.id);
1350
+ db.prepare(
1351
+ 'UPDATE workflow_runs SET metadata_json = ?, updated_at = ? WHERE id = ?',
1352
+ ).run(JSON.stringify(replacementMetadata), ts, replacement.id);
1353
+ const invalidated = artifactRegistry.invalidateConsumersFromEndpointInTx(
1354
+ db,
1355
+ { type: 'workflow', id: prior.id },
1356
+ {
1357
+ reason: 'workflow_superseded',
1358
+ exclude: [{ type: 'workflow', id: replacement.id }],
1359
+ },
1360
+ );
1361
+ ops.appendEventInTx(db, {
1362
+ type: 'workflow_superseded',
1363
+ task_id: prior.task_id,
1364
+ change_id: prior.change_id,
1365
+ payload: {
1366
+ workflow_id: prior.id,
1367
+ replacement_id: replacement.id,
1368
+ reason,
1369
+ invalidated,
1370
+ },
1371
+ });
1372
+ return {
1373
+ superseded: readWorkflow(db, prior.id, { rootDir }),
1374
+ workflow: readWorkflow(db, replacement.id, { rootDir }),
1375
+ invalidated,
1376
+ idempotent: false,
1377
+ };
1378
+ });
1379
+ }
1380
+
1047
1381
  function allowedStepTransition(from, to, required) {
1048
1382
  if (from === to) return true;
1049
1383
  if (from === 'pending') return ['in_progress', 'completed', 'blocked'].includes(to)
@@ -1065,6 +1399,13 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
1065
1399
  if (['completed', 'cancelled'].includes(runRow.status)) {
1066
1400
  throw new WorkflowStateError('WORKFLOW_NOT_MUTABLE', `workflow ${id} is ${runRow.status}`);
1067
1401
  }
1402
+ const runMetadata = parseJson(runRow.metadata_json, 'workflow_runs.metadata_json', {});
1403
+ if (runMetadata.authority_invalidation?.invalidated === true
1404
+ || runMetadata.revision?.superseded_by) {
1405
+ throw new WorkflowStateError(
1406
+ 'WORKFLOW_NOT_MUTABLE', `workflow ${id} no longer owns current authority`,
1407
+ );
1408
+ }
1068
1409
  const stepRow = db.prepare('SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ?').get(id, stepId);
1069
1410
  if (!stepRow) throw new WorkflowStateError('WORKFLOW_STEP_NOT_FOUND', `workflow step ${stepId} not found`);
1070
1411
  if (!allowedStepTransition(stepRow.status, input.status, Boolean(stepRow.required))) {
@@ -1118,7 +1459,7 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
1118
1459
  throw new WorkflowStateError('WORKFLOW_OUTPUT_REQUIRED', `${stepId} requires a durable output`);
1119
1460
  }
1120
1461
  if (input.status === 'completed' && runRow.kind === 'research') {
1121
- validateResearchStepReport(runRow, stepId, outputs, rootDir);
1462
+ validateResearchStepReport(db, runRow, stepId, outputs, rootDir);
1122
1463
  if (semanticRecords.length === 0) {
1123
1464
  throw new WorkflowStateError(
1124
1465
  'WORKFLOW_SEMANTIC_RECORDS_REQUIRED', `${stepId} requires at least one typed semantic record`,
@@ -1154,6 +1495,9 @@ function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
1154
1495
  JSON.stringify(semanticRecords), JSON.stringify(blockers),
1155
1496
  startedAt, completedAt, ts, id, stepId,
1156
1497
  );
1498
+ if (input.status === 'completed' && runRow.kind === 'research') {
1499
+ registerChangeResearchOutputsInTx(db, runRow, stepId, outputs, rootDir);
1500
+ }
1157
1501
 
1158
1502
  const next = db.prepare(
1159
1503
  `SELECT step_id FROM workflow_steps
@@ -1192,19 +1536,44 @@ function assertIsoTimestamp(value, field) {
1192
1536
  }
1193
1537
  }
1194
1538
 
1195
- function readOutputJson(run, stepId, rootDir, schema) {
1539
+ function readOutputJson(db, run, stepId, rootDir, schema) {
1196
1540
  const workflowStep = run.steps.find((item) => item.step_id === stepId);
1197
1541
  const candidates = workflowStep?.outputs || [];
1542
+ const authorityKind = {
1543
+ 'ultra-test-report-v1': 'test_report',
1544
+ 'ultra-review-summary-v2': 'review_summary',
1545
+ 'ultra-delivery-report-v1': 'delivery_report',
1546
+ }[schema];
1198
1547
  for (const output of candidates) {
1548
+ if (authorityKind) {
1549
+ assertChangeArtifactPath(db, run, output.path, `${stepId}.output`);
1550
+ }
1199
1551
  const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
1200
1552
  let value;
1201
- try { value = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
1553
+ try { value = JSON.parse(resolved.text); }
1202
1554
  catch (error) {
1203
1555
  throw new WorkflowStateError(
1204
1556
  'WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`,
1205
1557
  );
1206
1558
  }
1207
1559
  if (value && typeof value === 'object' && !Array.isArray(value) && value.$schema === schema) {
1560
+ if (resolved.digest !== output.digest) {
1561
+ throw new WorkflowStateError(
1562
+ 'WORKFLOW_REPORT_INVALID',
1563
+ `${output.path} no longer matches its workflow output digest`,
1564
+ );
1565
+ }
1566
+ if (authorityKind) {
1567
+ requireManagedArtifact(db, {
1568
+ owner_type: 'workflow',
1569
+ owner_id: run.id,
1570
+ kind: authorityKind,
1571
+ artifactPath: output.path,
1572
+ digest: resolved.digest,
1573
+ error_code: 'WORKFLOW_REPORT_INVALID',
1574
+ field: `${stepId}.output`,
1575
+ });
1576
+ }
1208
1577
  return { value, output };
1209
1578
  }
1210
1579
  }
@@ -1215,6 +1584,7 @@ function readOutputJson(run, stepId, rootDir, schema) {
1215
1584
 
1216
1585
  function contextStepForKind(kind) {
1217
1586
  return {
1587
+ plan: 'compile-context',
1218
1588
  change: 'compile-context',
1219
1589
  dev: 'compile-context',
1220
1590
  test: 'compile-context',
@@ -1225,6 +1595,7 @@ function contextStepForKind(kind) {
1225
1595
 
1226
1596
  function contextExpectation(kind) {
1227
1597
  return {
1598
+ plan: { roles: ['plan'], gates: ['planning'] },
1228
1599
  change: { roles: ['plan', 'implement'], gates: ['planning', 'implementation'] },
1229
1600
  dev: { roles: ['implement'], gates: ['implementation'] },
1230
1601
  test: { roles: ['check'], gates: ['verification'] },
@@ -1238,14 +1609,16 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
1238
1609
  if (!expectation) return null;
1239
1610
  let candidate = null;
1240
1611
  let manifest = null;
1612
+ let candidateDigest = null;
1241
1613
  for (const output of outputs || []) {
1242
1614
  const resolved = safeProjectFile(rootDir, output.path, 'context output');
1243
1615
  let value;
1244
- try { value = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
1616
+ try { value = JSON.parse(resolved.text); }
1245
1617
  catch { continue; }
1246
1618
  if (value?.schema_version === '3.0' && value?.snapshot_id) {
1247
1619
  candidate = output;
1248
1620
  manifest = value;
1621
+ candidateDigest = resolved.digest;
1249
1622
  break;
1250
1623
  }
1251
1624
  }
@@ -1262,9 +1635,13 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
1262
1635
  'WORKFLOW_CONTEXT_MISMATCH', `${run.kind} context is not bound to the expected change, role, gate, or readiness`,
1263
1636
  );
1264
1637
  }
1265
- if (run.task_id && manifest.resume?.task_id !== run.task_id) {
1638
+ const expectedTaskId = run.task_id || null;
1639
+ if ((manifest.resume?.task_id || null) !== expectedTaskId) {
1266
1640
  throw new WorkflowStateError(
1267
- 'WORKFLOW_CONTEXT_MISMATCH', `${run.kind} context is not bound to task ${run.task_id}`,
1641
+ 'WORKFLOW_CONTEXT_MISMATCH',
1642
+ expectedTaskId
1643
+ ? `${run.kind} context is not bound to task ${expectedTaskId}`
1644
+ : `${run.kind} change-wide context must not be task-scoped`,
1268
1645
  );
1269
1646
  }
1270
1647
  const stored = db.prepare(
@@ -1272,23 +1649,52 @@ function validateContextManifestOutput(db, run, outputs, rootDir, { current = tr
1272
1649
  FROM context_snapshots WHERE id = ?`,
1273
1650
  ).get(manifest.snapshot_id);
1274
1651
  if (!stored || stored.manifest_path !== candidate.path || stored.manifest_hash !== candidate.digest
1652
+ || candidateDigest !== candidate.digest
1275
1653
  || stored.change_id !== run.change_id || stored.role !== manifest.role
1276
1654
  || stored.gate !== manifest.gate || stored.readiness !== 'ready'
1277
- || (run.task_id && stored.task_id !== run.task_id)) {
1655
+ || (stored.task_id || null) !== expectedTaskId) {
1278
1656
  throw new WorkflowStateError(
1279
1657
  'WORKFLOW_CONTEXT_AUTHORITY_MISMATCH', 'context output does not match its authoritative snapshot row',
1280
1658
  );
1281
1659
  }
1282
- if (current) {
1283
- const checkout = currentCheckout(rootDir);
1284
- if ((checkout.head && manifest.git?.head !== checkout.head)
1285
- || (checkout.digest && manifest.git?.worktree_digest !== checkout.digest)) {
1286
- throw new WorkflowStateError(
1287
- 'WORKFLOW_CONTEXT_STALE', `${run.kind} context does not match the current checkout`,
1288
- );
1289
- }
1660
+ requireManagedArtifact(db, {
1661
+ owner_type: expectedTaskId ? 'task' : 'change',
1662
+ owner_id: expectedTaskId || run.change_id,
1663
+ kind: 'context_manifest',
1664
+ artifactPath: candidate.path,
1665
+ digest: candidateDigest,
1666
+ error_code: 'WORKFLOW_CONTEXT_AUTHORITY_MISMATCH',
1667
+ field: 'context output',
1668
+ statuses: run.kind === 'deliver'
1669
+ && db.prepare('SELECT status FROM changes WHERE id = ?').get(run.change_id)?.status === 'archived'
1670
+ ? ['archived']
1671
+ : ['current'],
1672
+ });
1673
+ const validation = require('./context-spine.cjs').validateContextSnapshot(db, {
1674
+ change_id: run.change_id,
1675
+ task_id: expectedTaskId,
1676
+ role: manifest.role,
1677
+ gate: manifest.gate,
1678
+ }, {
1679
+ rootDir,
1680
+ require_current_checkout: current,
1681
+ });
1682
+ const blockers = [...validation.blockers];
1683
+ if (validation.snapshot?.id !== manifest.snapshot_id) {
1684
+ blockers.push('CONTEXT_SNAPSHOT_SUPERSEDED');
1290
1685
  }
1291
- return { manifest, output: candidate };
1686
+ if (blockers.length > 0) {
1687
+ throw new WorkflowStateError(
1688
+ 'WORKFLOW_CONTEXT_STALE',
1689
+ `${run.kind} context no longer matches current task, change, reference, or checkout authority`,
1690
+ { blockers: [...new Set(blockers)] },
1691
+ );
1692
+ }
1693
+ return {
1694
+ manifest,
1695
+ output: candidate,
1696
+ warnings: validation.warnings || [],
1697
+ };
1292
1698
  }
1293
1699
 
1294
1700
  function recordedContext(db, run, rootDir, options) {
@@ -1335,11 +1741,15 @@ function taskPlanDigest(task) {
1335
1741
  }
1336
1742
 
1337
1743
  function latestCompletedWorkflow(db, { kind, changeId, taskId = null }, rootDir) {
1338
- const row = db.prepare(
1744
+ const rows = db.prepare(
1339
1745
  `SELECT id FROM workflow_runs
1340
1746
  WHERE kind = ? AND change_id = ? AND task_id IS ? AND status = 'completed'
1341
- ORDER BY completed_at DESC, rowid DESC LIMIT 1`,
1342
- ).get(kind, changeId, taskId);
1747
+ ORDER BY completed_at DESC, rowid DESC`,
1748
+ ).all(kind, changeId, taskId);
1749
+ const row = rows.find((candidate) => {
1750
+ const run = readWorkflow(db, candidate.id, { rootDir });
1751
+ return isConsumableWorkflowAuthority(run);
1752
+ });
1343
1753
  return row ? readWorkflow(db, row.id, { rootDir }) : null;
1344
1754
  }
1345
1755
 
@@ -1518,6 +1928,7 @@ function validateDevCompletion(db, run, rootDir) {
1518
1928
  verification_command: task.verification_command,
1519
1929
  context_path: context.output.path,
1520
1930
  context_digest: context.output.digest,
1931
+ context_warnings: context.warnings,
1521
1932
  git_commit: checkout.head,
1522
1933
  worktree_digest: checkout.digest,
1523
1934
  review_workflow_id: review.id,
@@ -1554,6 +1965,14 @@ function validateChangeCompletion(db, run, rootDir) {
1554
1965
  function validateDeliveryPrerequisites(db, run, rootDir) {
1555
1966
  const tasks = assertDevelopmentComplete(db, run.change_id, rootDir);
1556
1967
  const taskIds = tasks.map((task) => task.id).sort();
1968
+ const change = db.prepare(
1969
+ 'SELECT id, kind, contract_json FROM changes WHERE id = ?',
1970
+ ).get(run.change_id);
1971
+ if (!change) {
1972
+ throw new WorkflowStateError(
1973
+ 'WORKFLOW_CHANGE_NOT_FOUND', `change ${run.change_id || '(missing)'} does not exist`,
1974
+ );
1975
+ }
1557
1976
  const test = latestCompletedWorkflow(
1558
1977
  db, { kind: 'test', changeId: run.change_id, taskId: null }, rootDir,
1559
1978
  );
@@ -1566,6 +1985,38 @@ function validateDeliveryPrerequisites(db, run, rootDir) {
1566
1985
  'WORKFLOW_GATE_STALE', `test gate for ${run.change_id} does not cover the current task set`,
1567
1986
  );
1568
1987
  }
1988
+ const contract = parseJson(change.contract_json, 'changes.contract_json', {});
1989
+ const acceptanceIds = (contract.acceptance || []).map((item) => item.id).sort();
1990
+ if (JSON.stringify([...(test.summary.acceptance_ids || [])].sort())
1991
+ !== JSON.stringify(acceptanceIds)) {
1992
+ throw new WorkflowStateError(
1993
+ 'WORKFLOW_ACCEPTANCE_EVIDENCE_STALE',
1994
+ `test gate for ${run.change_id} does not cover the current accepted intent`,
1995
+ );
1996
+ }
1997
+ if (['standard', 'major'].includes(change.kind)) {
1998
+ try {
1999
+ // Lazy loading avoids the baseline-workflow <-> workflow-state module
2000
+ // cycle while keeping packet validation on the live Deliver boundary.
2001
+ const changePacket = require('./change-packet.cjs');
2002
+ changePacket.deliveryEntries(db, {
2003
+ ...change,
2004
+ contract,
2005
+ docs_impact: parseJson(
2006
+ db.prepare('SELECT docs_impact_json FROM changes WHERE id = ?').get(run.change_id)
2007
+ .docs_impact_json,
2008
+ 'changes.docs_impact_json',
2009
+ {},
2010
+ ),
2011
+ }, { rootDir });
2012
+ } catch (error) {
2013
+ throw new WorkflowStateError(
2014
+ error.code || 'CHANGE_PACKET_INVALID',
2015
+ `delivery packet for ${run.change_id} is not current: ${error.message}`,
2016
+ error.details,
2017
+ );
2018
+ }
2019
+ }
1569
2020
  const review = assertApprovedReview(db, run.change_id, null, rootDir);
1570
2021
  if (JSON.stringify([...(review.summary.task_ids || [])].sort()) !== JSON.stringify(taskIds)) {
1571
2022
  throw new WorkflowStateError(
@@ -1578,6 +2029,7 @@ function validateDeliveryPrerequisites(db, run, rootDir) {
1578
2029
  review_workflow_id: review.id,
1579
2030
  test_summary: test.summary,
1580
2031
  review_summary: review.summary,
2032
+ acceptance_ids: acceptanceIds,
1581
2033
  };
1582
2034
  }
1583
2035
 
@@ -1657,7 +2109,7 @@ function validateTestCompletion(db, run, rootDir) {
1657
2109
  assertDevelopmentComplete(db, run.change_id, rootDir, run.task_id);
1658
2110
  const context = recordedContext(db, run, rootDir, { current: true });
1659
2111
  const { value: report, output } = readOutputJson(
1660
- run, 'write-report', rootDir, 'ultra-test-report-v1',
2112
+ db, run, 'write-report', rootDir, 'ultra-test-report-v1',
1661
2113
  );
1662
2114
  assertReportSchema(report, 'test');
1663
2115
  const verificationProfile = validateVerificationProfile(report);
@@ -1717,6 +2169,7 @@ function validateTestCompletion(db, run, rootDir) {
1717
2169
  return {
1718
2170
  change_id: run.change_id,
1719
2171
  task_ids: report.task_ids,
2172
+ acceptance_ids: report.acceptance.map((item) => item.id).sort(),
1720
2173
  passed: report.passed,
1721
2174
  git_commit: checkout.head,
1722
2175
  worktree_digest: checkout.digest,
@@ -1724,6 +2177,7 @@ function validateTestCompletion(db, run, rootDir) {
1724
2177
  report_digest: output.digest,
1725
2178
  context_path: context.output.path,
1726
2179
  context_digest: context.output.digest,
2180
+ context_warnings: context.warnings,
1727
2181
  regression_signal: report.regression_signal,
1728
2182
  verification_profile: verificationProfile,
1729
2183
  verification_dimensions: report.verification_dimensions,
@@ -1775,7 +2229,7 @@ function validateReviewFinding(finding, axis, seenIds, label) {
1775
2229
  seenIds.add(finding.id);
1776
2230
  }
1777
2231
 
1778
- function readReviewSpecialists(run, rootDir) {
2232
+ function readReviewSpecialists(db, run, rootDir) {
1779
2233
  const axes = [
1780
2234
  ['review-specification', 'spec_fidelity'],
1781
2235
  ['review-engineering', 'engineering_standards'],
@@ -1791,12 +2245,28 @@ function readReviewSpecialists(run, rootDir) {
1791
2245
  throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${stepId} has no specialist artifact`);
1792
2246
  }
1793
2247
  for (const output of outputs) {
2248
+ assertChangeArtifactPath(db, run, output.path, `${stepId}.output`);
1794
2249
  const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
1795
2250
  let artifact;
1796
- try { artifact = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
2251
+ try { artifact = JSON.parse(resolved.text); }
1797
2252
  catch (error) {
1798
2253
  throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`);
1799
2254
  }
2255
+ if (resolved.digest !== output.digest) {
2256
+ throw new WorkflowStateError(
2257
+ 'WORKFLOW_REPORT_INVALID',
2258
+ `${output.path} no longer matches its workflow output digest`,
2259
+ );
2260
+ }
2261
+ requireManagedArtifact(db, {
2262
+ owner_type: 'workflow',
2263
+ owner_id: run.id,
2264
+ kind: 'review_findings',
2265
+ artifactPath: output.path,
2266
+ digest: resolved.digest,
2267
+ error_code: 'WORKFLOW_REPORT_INVALID',
2268
+ field: `${stepId}.output`,
2269
+ });
1800
2270
  if (artifact?.$schema !== 'ultra-review-findings-v2'
1801
2271
  || artifact.axis !== expectedAxis || artifact.status !== 'complete'
1802
2272
  || !String(artifact.agent || '').trim() || !String(artifact.session || '').trim()
@@ -1925,9 +2395,9 @@ function assertCoordinatedReviewEvidence(report, specialists) {
1925
2395
 
1926
2396
  function validateReviewCompletion(db, run, rootDir) {
1927
2397
  const context = recordedContext(db, run, rootDir, { current: true });
1928
- const specialists = readReviewSpecialists(run, rootDir);
2398
+ const specialists = readReviewSpecialists(db, run, rootDir);
1929
2399
  const { value: report, output } = readOutputJson(
1930
- run, 'coordinate-findings', rootDir, 'ultra-review-summary-v2',
2400
+ db, run, 'coordinate-findings', rootDir, 'ultra-review-summary-v2',
1931
2401
  );
1932
2402
  if (report.change_id !== run.change_id) {
1933
2403
  throw new WorkflowStateError('WORKFLOW_REPORT_CHANGE_MISMATCH', 'review summary change_id is not bound to the workflow');
@@ -2000,6 +2470,7 @@ function validateReviewCompletion(db, run, rootDir) {
2000
2470
  report_digest: output.digest,
2001
2471
  context_path: context.output.path,
2002
2472
  context_digest: context.output.digest,
2473
+ context_warnings: context.warnings,
2003
2474
  };
2004
2475
  }
2005
2476
 
@@ -2013,7 +2484,7 @@ function validateDeliveryCompletion(db, run, rootDir) {
2013
2484
  );
2014
2485
  }
2015
2486
  const { value: report, output } = readOutputJson(
2016
- run, 'verify-delivery', rootDir, 'ultra-delivery-report-v1',
2487
+ db, run, 'verify-delivery', rootDir, 'ultra-delivery-report-v1',
2017
2488
  );
2018
2489
  if (report.release !== undefined) {
2019
2490
  throw new WorkflowStateError(
@@ -2058,6 +2529,7 @@ function validateDeliveryCompletion(db, run, rootDir) {
2058
2529
  report_digest: output.digest,
2059
2530
  context_path: context.output.path,
2060
2531
  context_digest: context.output.digest,
2532
+ context_warnings: context.warnings,
2061
2533
  ...gates,
2062
2534
  };
2063
2535
  }
@@ -2073,6 +2545,7 @@ function validateStageCompletion(db, run, rootDir) {
2073
2545
  }
2074
2546
 
2075
2547
  function validatePlanArtifact(db, run, rootDir) {
2548
+ const context = recordedContext(db, run, rootDir, { current: false });
2076
2549
  const workflowStep = run.steps.find((item) => item.step_id === 'verify-plan');
2077
2550
  const outputs = workflowStep?.outputs || [];
2078
2551
  if (outputs.length !== 1) {
@@ -2082,19 +2555,39 @@ function validatePlanArtifact(db, run, rootDir) {
2082
2555
  );
2083
2556
  }
2084
2557
  const output = outputs[0];
2085
- const resolved = safeProjectFile(rootDir, output.path, 'verify-plan.output');
2558
+ const change = db.prepare('SELECT id, artifact_root FROM changes WHERE id = ?').get(run.change_id);
2559
+ const planStore = require('./plan-store.cjs');
2086
2560
  let actual;
2087
- try { actual = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
2561
+ try {
2562
+ actual = planStore.loadChangePlanArtifact(rootDir, change, { db, strict: true });
2563
+ }
2088
2564
  catch (error) {
2089
2565
  throw new WorkflowStateError(
2090
2566
  'WORKFLOW_PLAN_ARTIFACT_INVALID',
2091
- `${output.path} is not valid JSON: ${error.message}`,
2567
+ `${output.path} is not a current canonical Change plan: ${error.message}`,
2568
+ );
2569
+ }
2570
+ const expectedPaths = planStore.changePlanPaths(rootDir, change);
2571
+ const expectedJsonRelative = path.relative(rootDir, expectedPaths.json).split(path.sep).join('/');
2572
+ if (output.path !== expectedJsonRelative) {
2573
+ throw new WorkflowStateError(
2574
+ 'WORKFLOW_PLAN_ARTIFACT_INVALID',
2575
+ `verify-plan must use the owning Change artifact ${expectedJsonRelative}`,
2092
2576
  );
2093
2577
  }
2578
+ requireManagedArtifact(db, {
2579
+ owner_type: 'change',
2580
+ owner_id: run.change_id,
2581
+ kind: 'execution_plan',
2582
+ artifactPath: output.path,
2583
+ digest: output.digest,
2584
+ error_code: 'WORKFLOW_PLAN_ARTIFACT_INVALID',
2585
+ field: 'verify-plan.output',
2586
+ });
2094
2587
  const tasks = changeTasks(db, run.change_id);
2095
- const planStore = require('./plan-store.cjs');
2096
2588
  const expected = planStore.buildPlan(tasks, { changeId: run.change_id });
2097
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
2589
+ const { context: actualContext, ...actualPlan } = actual || {};
2590
+ if (JSON.stringify(actualPlan) !== JSON.stringify(expected)) {
2098
2591
  throw new WorkflowStateError(
2099
2592
  'WORKFLOW_PLAN_ARTIFACT_STALE',
2100
2593
  'execution-plan artifact does not match the current change task graph and contracts',
@@ -2105,9 +2598,51 @@ function validatePlanArtifact(db, run, rootDir) {
2105
2598
  },
2106
2599
  );
2107
2600
  }
2601
+ if (!actualContext
2602
+ || actualContext.snapshot_id !== context.manifest.snapshot_id
2603
+ || actualContext.manifest_path !== context.output.path
2604
+ || actualContext.manifest_digest !== context.output.digest) {
2605
+ throw new WorkflowStateError(
2606
+ 'WORKFLOW_PLAN_CONTEXT_MISMATCH',
2607
+ 'execution-plan artifact is not bound to the compile-context output',
2608
+ );
2609
+ }
2610
+ const expectedMdRelative = path.relative(rootDir, expectedPaths.md).split(path.sep).join('/');
2611
+ let planMd;
2612
+ try {
2613
+ planMd = safeProjectFile(rootDir, expectedMdRelative, 'verify-plan.markdown');
2614
+ requireManagedArtifact(db, {
2615
+ owner_type: 'change',
2616
+ owner_id: run.change_id,
2617
+ kind: 'execution_plan_markdown',
2618
+ artifactPath: expectedMdRelative,
2619
+ digest: planMd.digest,
2620
+ error_code: 'WORKFLOW_PLAN_ARTIFACT_INVALID',
2621
+ field: 'verify-plan.markdown',
2622
+ });
2623
+ } catch (error) {
2624
+ if (error instanceof WorkflowStateError && error.code === 'WORKFLOW_PLAN_ARTIFACT_INVALID') {
2625
+ throw error;
2626
+ }
2627
+ throw new WorkflowStateError(
2628
+ 'WORKFLOW_PLAN_ARTIFACT_STALE',
2629
+ `change-scoped plan.md is missing or unsafe: ${error.message}`,
2630
+ );
2631
+ }
2632
+ if (planMd.text !== planStore.renderPlanMd(actual, { tasks })) {
2633
+ throw new WorkflowStateError(
2634
+ 'WORKFLOW_PLAN_ARTIFACT_STALE',
2635
+ 'change-scoped plan.md is missing or does not match plan.json and current task contracts',
2636
+ );
2637
+ }
2108
2638
  return {
2109
2639
  plan_path: output.path,
2110
2640
  plan_digest: output.digest,
2641
+ plan_md_path: expectedMdRelative,
2642
+ plan_context_path: context.output.path,
2643
+ plan_context_digest: context.output.digest,
2644
+ context_warnings: context.warnings,
2645
+ plan_context_snapshot_id: context.manifest.snapshot_id,
2111
2646
  plan_schema_version: actual.schema_version,
2112
2647
  plan_wave_count: actual.waves.length,
2113
2648
  };
@@ -2122,6 +2657,12 @@ function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
2122
2657
  if (run.status !== 'ready') {
2123
2658
  throw new WorkflowStateError('WORKFLOW_NOT_READY', `workflow ${id} is ${run.status}`);
2124
2659
  }
2660
+ if (run.metadata.authority_invalidation?.invalidated === true
2661
+ || run.metadata.revision?.superseded_by) {
2662
+ throw new WorkflowStateError(
2663
+ 'WORKFLOW_NOT_MUTABLE', `workflow ${id} no longer owns current authority`,
2664
+ );
2665
+ }
2125
2666
  decisionDialogue.assertDecisionGate(db, {
2126
2667
  workflow_run_id: run.id,
2127
2668
  change_id: run.change_id,
@@ -2211,12 +2752,20 @@ function validatePlanApproval(approval) {
2211
2752
  }
2212
2753
 
2213
2754
  function completedResearchSemantics(db, changeId) {
2755
+ const runs = db.prepare(
2756
+ `SELECT id, metadata_json FROM workflow_runs
2757
+ WHERE kind = 'research' AND change_id = ? AND status = 'completed'
2758
+ ORDER BY completed_at DESC, rowid DESC`,
2759
+ ).all(changeId).filter((row) => {
2760
+ const metadata = parseJson(row.metadata_json, 'workflow_runs.metadata_json', {});
2761
+ return isConsumableWorkflowAuthority({ metadata });
2762
+ });
2763
+ if (runs.length === 0) return [];
2764
+ const placeholders = runs.map(() => '?').join(', ');
2214
2765
  return db.prepare(
2215
- `SELECT ws.semantic_records_json
2216
- FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
2217
- WHERE wr.kind = 'research' AND wr.change_id = ? AND wr.status = 'completed'
2218
- AND ws.required = 1 AND ws.status = 'completed'`,
2219
- ).all(changeId).flatMap((row) => (
2766
+ `SELECT semantic_records_json FROM workflow_steps
2767
+ WHERE run_id IN (${placeholders}) AND required = 1 AND status = 'completed'`,
2768
+ ).all(...runs.map((row) => row.id)).flatMap((row) => (
2220
2769
  parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', [])
2221
2770
  ));
2222
2771
  }
@@ -2402,6 +2951,8 @@ module.exports = {
2402
2951
  readWorkflow,
2403
2952
  listWorkflows,
2404
2953
  recordWorkflowStep,
2954
+ reviseWorkflow,
2955
+ supersedeWorkflow,
2405
2956
  completeWorkflow,
2406
2957
  inspectWorkflowHealth,
2407
2958
  recoverUntrackedChangeWorkflows,
@@ -2409,5 +2960,6 @@ module.exports = {
2409
2960
  validatePlanContract,
2410
2961
  assertCurrentPlan,
2411
2962
  assertApprovedReview,
2963
+ isConsumableWorkflowAuthority,
2412
2964
  resolveProjectSourceRef: sourceRefFile,
2413
2965
  };