ultra-builder-pro-cli 0.14.0 → 0.15.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 (154) hide show
  1. package/.ultra-template/docs/research/README.md +11 -7
  2. package/.ultra-template/specs/architecture.md +36 -36
  3. package/.ultra-template/specs/discovery.md +46 -33
  4. package/.ultra-template/specs/product.md +38 -29
  5. package/.ultra-template/specs/research-distillate.md +48 -0
  6. package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
  7. package/CHANGELOG.md +44 -1
  8. package/README.md +34 -19
  9. package/adapters/_shared/codex-assets.cjs +6 -2
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/claude.js +3 -2
  12. package/adapters/codex.js +1 -0
  13. package/adapters/kimi.js +3 -2
  14. package/adapters/opencode.js +3 -2
  15. package/agents/review-coordinator.md +9 -6
  16. package/docs/AGENT-CONTEXT.md +15 -8
  17. package/docs/ARCHITECTURE.md +33 -12
  18. package/docs/PLAN.zh-CN.md +3 -2
  19. package/docs/ROADMAP.md +8 -4
  20. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  21. package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
  22. package/docs/WORKFLOW-LIFECYCLE.md +266 -0
  23. package/hooks/README.md +6 -1
  24. package/hooks/health_check.py +12 -3
  25. package/hooks/subagent_tracker.py +52 -77
  26. package/mcp-server/hook-event.cjs +34 -0
  27. package/mcp-server/lib/archive-journal.cjs +17 -5
  28. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  29. package/mcp-server/lib/change-workflow.cjs +550 -85
  30. package/mcp-server/lib/context-spine.cjs +242 -24
  31. package/mcp-server/lib/doctor.cjs +33 -3
  32. package/mcp-server/lib/init-project.cjs +210 -16
  33. package/mcp-server/lib/plan-store.cjs +4 -1
  34. package/mcp-server/lib/pricing.cjs +7 -10
  35. package/mcp-server/lib/project-breadcrumb.cjs +6 -0
  36. package/mcp-server/lib/projector.cjs +57 -3
  37. package/mcp-server/lib/spec-learning.cjs +79 -2
  38. package/mcp-server/lib/state-db.cjs +145 -8
  39. package/mcp-server/lib/state-ops.cjs +160 -9
  40. package/mcp-server/lib/workflow-state.cjs +2292 -0
  41. package/mcp-server/server.cjs +161 -15
  42. package/orchestrator/daemon.cjs +1 -2
  43. package/orchestrator/dispatch-rules.cjs +5 -31
  44. package/orchestrator/planner/plan-builder.cjs +21 -12
  45. package/package.json +13 -3
  46. package/skills/ultra-change/SKILL.md +53 -68
  47. package/skills/ultra-change/references/change-contract.md +36 -0
  48. package/skills/ultra-deliver/SKILL.md +69 -79
  49. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  50. package/skills/ultra-dev/SKILL.md +54 -59
  51. package/skills/ultra-doctor/SKILL.md +40 -71
  52. package/skills/ultra-init/SKILL.md +96 -103
  53. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  54. package/skills/ultra-plan/SKILL.md +79 -85
  55. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  56. package/skills/ultra-research/SKILL.md +87 -60
  57. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  58. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  59. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  60. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  61. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  62. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  63. package/skills/ultra-research/references/10-user-personas.md +29 -0
  64. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  65. package/skills/ultra-research/references/20-user-stories.md +30 -0
  66. package/skills/ultra-research/references/21-features-scope.md +29 -0
  67. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  68. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  69. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  70. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  71. package/skills/ultra-research/references/40-deployment.md +29 -0
  72. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  73. package/skills/ultra-research/references/99-synthesis.md +35 -0
  74. package/skills/ultra-review/SKILL.md +57 -73
  75. package/skills/ultra-review/references/review-modes.md +27 -0
  76. package/skills/ultra-review/references/unified-schema.md +25 -0
  77. package/skills/ultra-review/scripts/review_wait.py +40 -3
  78. package/skills/ultra-status/SKILL.md +41 -45
  79. package/skills/ultra-test/SKILL.md +48 -55
  80. package/skills/ultra-verify/SKILL.md +1 -1
  81. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  82. package/spec/cli-protocol.md +7 -2
  83. package/spec/mcp-tools.yaml +384 -63
  84. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  85. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  86. package/spec/schemas/mcp-tools.schema.json +1 -1
  87. package/spec/schemas/state-db.sql +82 -1
  88. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  89. package/spec/schemas/test-report.v1.schema.json +194 -0
  90. package/ultra-tools/cli.cjs +1 -1
  91. package/ultra-tools/commands/migrate.cjs +3 -3
  92. package/ultra-tools/commands/status.cjs +213 -12
  93. package/.ultra-template/delivery-report.json +0 -9
  94. package/.ultra-template/test-report.json +0 -8
  95. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  96. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  97. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  98. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  99. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  100. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  101. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
  102. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  103. package/adapters/_shared/tests/validate.test.cjs +0 -30
  104. package/adapters/tests/claude.test.cjs +0 -138
  105. package/adapters/tests/codex-hook.test.cjs +0 -142
  106. package/adapters/tests/codex.test.cjs +0 -430
  107. package/adapters/tests/kimi-hook.test.cjs +0 -149
  108. package/adapters/tests/kimi.test.cjs +0 -259
  109. package/adapters/tests/opencode.test.cjs +0 -357
  110. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  111. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  112. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  113. package/mcp-server/lib/context-spine.test.cjs +0 -93
  114. package/mcp-server/lib/doctor.test.cjs +0 -148
  115. package/mcp-server/lib/plan-store.test.cjs +0 -132
  116. package/mcp-server/lib/prd-parser.test.cjs +0 -82
  117. package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
  118. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  119. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  120. package/mcp-server/lib/state-db.test.cjs +0 -314
  121. package/mcp-server/lib/task-expander.test.cjs +0 -169
  122. package/mcp-server/lib/topo.test.cjs +0 -158
  123. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  124. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  125. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  126. package/mcp-server/tests/concurrency.test.cjs +0 -112
  127. package/mcp-server/tests/db-ops.test.cjs +0 -121
  128. package/mcp-server/tests/init-project.test.cjs +0 -393
  129. package/mcp-server/tests/migrate.test.cjs +0 -232
  130. package/mcp-server/tests/projector.test.cjs +0 -162
  131. package/mcp-server/tests/server-contract.test.cjs +0 -1467
  132. package/mcp-server/tests/staleness.test.cjs +0 -190
  133. package/mcp-server/tests/state-db.test.cjs +0 -106
  134. package/mcp-server/tests/state-ops.test.cjs +0 -449
  135. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  136. package/mcp-server/tests/telemetry.test.cjs +0 -219
  137. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  138. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  139. package/orchestrator/tests/daemon.test.cjs +0 -311
  140. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  141. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  142. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  143. package/orchestrator/tests/recovery.test.cjs +0 -190
  144. package/orchestrator/tests/session-runner.test.cjs +0 -314
  145. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  146. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  147. package/skills/ultra-research/references/architecture.md +0 -38
  148. package/skills/ultra-research/references/problem-product.md +0 -39
  149. package/skills/ultra-research/references/quality-delivery.md +0 -34
  150. package/skills/ultra-research/references/synthesis.md +0 -38
  151. package/skills/ultra-research/references/users-scope.md +0 -40
  152. package/ultra-tools/legacy-memory.test.cjs +0 -80
  153. package/ultra-tools/status-command.test.cjs +0 -181
  154. package/ultra-tools/system-command.test.cjs +0 -309
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
+ const Ajv = require('ajv/dist/2020');
7
8
 
8
9
  const ops = require('./state-ops.cjs');
9
10
  const contextSpine = require('./context-spine.cjs');
@@ -11,9 +12,17 @@ const specLearning = require('./spec-learning.cjs');
11
12
  const providerRefs = require('./provider-refs.cjs');
12
13
  const baselines = require('./baseline-workflow.cjs');
13
14
  const archiveJournal = require('./archive-journal.cjs');
15
+ const workflows = require('./workflow-state.cjs');
16
+ const reconciliationSchema = require('../../spec/schemas/baseline-reconciliation.v1.schema.json');
17
+
18
+ const reconciliationAjv = new Ajv({ allErrors: true, strict: false });
19
+ const validateReconciliationSchema = reconciliationAjv.compile(reconciliationSchema);
14
20
 
15
21
  const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
16
- const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
22
+ const CHANGE_PATCH_FIELDS = new Set([
23
+ 'title', 'intent', 'status', 'docs_impact', 'provider_refs',
24
+ 'contract', 'classification', 'research_disposition',
25
+ ]);
17
26
  const CHANGE_TRANSITIONS = Object.freeze({
18
27
  active: new Set(['active', 'blocked', 'cancelled']),
19
28
  blocked: new Set(['active', 'blocked', 'cancelled']),
@@ -21,12 +30,14 @@ const CHANGE_TRANSITIONS = Object.freeze({
21
30
  archived: new Set(),
22
31
  cancelled: new Set(),
23
32
  });
24
- const REQUIRED_EVIDENCE = Object.freeze({
25
- quick: ['diff', 'tests', 'spec'],
26
- standard: ['diff', 'tests', 'spec', 'docs', 'review'],
27
- major: ['diff', 'tests', 'spec', 'docs', 'review'],
28
- incident: ['diagnosis', 'diff', 'tests'],
29
- });
33
+ const CHANGE_KINDS = new Set(['quick', 'standard', 'major', 'incident']);
34
+ const CHANGE_RISK_FLAGS = new Set([
35
+ 'public_contract', 'schema_migration', 'data_migration', 'authorization', 'security',
36
+ 'multi_module', 'external_integration', 'release_semantics',
37
+ ]);
38
+ const QUICK_EXCLUSION_FLAGS = new Set(CHANGE_RISK_FLAGS);
39
+ const RESEARCH_DISPOSITIONS = new Set(['none', 'bounded', 'required']);
40
+ const CHANGE_RESEARCH_MODES = new Set(['product', 'feature', 'architecture', 'custom']);
30
41
  const INCIDENT_DIAGNOSIS_SECTIONS = Object.freeze([
31
42
  { heading: 'Reproduction', key: 'reproduction' },
32
43
  { heading: 'Hypotheses', key: 'hypotheses' },
@@ -63,10 +74,18 @@ function rowToChange(row) {
63
74
  provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json'),
64
75
  baseline_bypass: row.baseline_bypass_json
65
76
  ? parseJson(row.baseline_bypass_json, 'baseline_bypass_json') : null,
77
+ contract: parseJson(row.contract_json || '{}', 'contract_json'),
78
+ classification: parseJson(row.classification_json || '{}', 'classification_json'),
79
+ research_disposition: parseJson(
80
+ row.research_disposition_json || '{}', 'research_disposition_json',
81
+ ),
66
82
  };
67
83
  delete change.docs_impact_json;
68
84
  delete change.provider_refs_json;
69
85
  delete change.baseline_bypass_json;
86
+ delete change.contract_json;
87
+ delete change.classification_json;
88
+ delete change.research_disposition_json;
70
89
  return change;
71
90
  }
72
91
 
@@ -88,6 +107,140 @@ function normalizeProviderRefs(value) {
88
107
  return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
89
108
  }
90
109
 
110
+ function requiredText(value, field) {
111
+ const text = typeof value === 'string' ? value.trim() : '';
112
+ if (!text) throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `${field} is required`);
113
+ return text;
114
+ }
115
+
116
+ function stringList(value, field, { allowEmpty = false } = {}) {
117
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0)
118
+ || value.some((item) => typeof item !== 'string' || !item.trim())) {
119
+ throw new ChangeWorkflowError(
120
+ 'CHANGE_CONTRACT_REQUIRED', `${field} must be ${allowEmpty ? 'an' : 'a non-empty'} array of strings`,
121
+ );
122
+ }
123
+ return [...new Set(value.map((item) => item.trim()))];
124
+ }
125
+
126
+ function normalizeChangeContract(value) {
127
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
128
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract is required');
129
+ }
130
+ if (!Array.isArray(value.acceptance) || value.acceptance.length === 0) {
131
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.acceptance is required');
132
+ }
133
+ const ids = new Set();
134
+ const acceptance = value.acceptance.map((item, index) => {
135
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
136
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `contract.acceptance[${index}] is invalid`);
137
+ }
138
+ const id = requiredText(item.id, `contract.acceptance[${index}].id`);
139
+ if (!CHANGE_ID.test(id) || ids.has(id)) {
140
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `acceptance id is invalid or duplicated: ${id}`);
141
+ }
142
+ ids.add(id);
143
+ return {
144
+ id,
145
+ criterion: requiredText(item.criterion, `contract.acceptance[${index}].criterion`),
146
+ verification: requiredText(item.verification, `contract.acceptance[${index}].verification`),
147
+ };
148
+ });
149
+ const recovery = value.recovery;
150
+ if (!recovery || typeof recovery !== 'object' || Array.isArray(recovery)) {
151
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.recovery is required');
152
+ }
153
+ if (!Array.isArray(value.unresolved_decisions)) {
154
+ throw new ChangeWorkflowError(
155
+ 'CHANGE_CONTRACT_REQUIRED', 'contract.unresolved_decisions must be an explicit array',
156
+ );
157
+ }
158
+ const decisions = value.unresolved_decisions.map((item, index) => {
159
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
160
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `unresolved decision ${index} is invalid`);
161
+ }
162
+ return {
163
+ id: requiredText(item.id, `contract.unresolved_decisions[${index}].id`),
164
+ summary: requiredText(item.summary, `contract.unresolved_decisions[${index}].summary`),
165
+ blocking: item.blocking === true,
166
+ owner: item.owner == null ? null : requiredText(item.owner, `contract.unresolved_decisions[${index}].owner`),
167
+ };
168
+ });
169
+ return {
170
+ outcome: requiredText(value.outcome, 'contract.outcome'),
171
+ acceptance,
172
+ non_goals: stringList(value.non_goals, 'contract.non_goals'),
173
+ public_seams: stringList(value.public_seams, 'contract.public_seams'),
174
+ recovery: {
175
+ strategy: requiredText(recovery.strategy, 'contract.recovery.strategy'),
176
+ verification: requiredText(recovery.verification, 'contract.recovery.verification'),
177
+ },
178
+ unresolved_decisions: decisions,
179
+ };
180
+ }
181
+
182
+ function normalizeClassification(value, kind) {
183
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
184
+ throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', 'classification is required');
185
+ }
186
+ const flags = stringList(value.risk_flags, 'classification.risk_flags', { allowEmpty: true });
187
+ const invalid = flags.filter((flag) => !CHANGE_RISK_FLAGS.has(flag));
188
+ if (invalid.length > 0) {
189
+ throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', `unknown risk flags: ${invalid.join(', ')}`);
190
+ }
191
+ if (kind === 'quick') {
192
+ const excluded = flags.filter((flag) => QUICK_EXCLUSION_FLAGS.has(flag));
193
+ if (excluded.length > 0) {
194
+ throw new ChangeWorkflowError(
195
+ 'CHANGE_PROFILE_ESCALATION_REQUIRED',
196
+ `quick changes cannot carry these risk flags: ${excluded.join(', ')}`,
197
+ );
198
+ }
199
+ }
200
+ return {
201
+ rationale: requiredText(value.rationale, 'classification.rationale'),
202
+ risk_flags: flags,
203
+ };
204
+ }
205
+
206
+ function normalizeResearchDisposition(value, kind) {
207
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
208
+ throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research_disposition is required');
209
+ }
210
+ const status = String(value.status || '').trim();
211
+ if (!RESEARCH_DISPOSITIONS.has(status)) {
212
+ throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research disposition status is invalid');
213
+ }
214
+ const selectedSteps = stringList(
215
+ value.selected_steps || [], 'research_disposition.selected_steps', { allowEmpty: true },
216
+ );
217
+ const mode = value.mode == null ? null : String(value.mode).trim();
218
+ if (status === 'none' && (mode !== null || selectedSteps.length > 0)) {
219
+ throw new ChangeWorkflowError(
220
+ 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research status none cannot select a mode or steps',
221
+ );
222
+ }
223
+ if (status !== 'none' && !CHANGE_RESEARCH_MODES.has(mode)) {
224
+ throw new ChangeWorkflowError(
225
+ 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'bounded or required research needs a supported mode',
226
+ );
227
+ }
228
+ if (mode === 'custom' && selectedSteps.length === 0) {
229
+ throw new ChangeWorkflowError(
230
+ 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'custom research needs selected_steps',
231
+ );
232
+ }
233
+ if (kind === 'quick' && status !== 'none') {
234
+ throw new ChangeWorkflowError(
235
+ 'CHANGE_PROFILE_ESCALATION_REQUIRED', 'a change that requires research cannot use the quick profile',
236
+ );
237
+ }
238
+ return {
239
+ status, mode, selected_steps: selectedSteps,
240
+ rationale: requiredText(value.rationale, 'research_disposition.rationale'),
241
+ };
242
+ }
243
+
91
244
  function normalizeBaselineBypass(value) {
92
245
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
93
246
  throw new ChangeWorkflowError(
@@ -130,6 +283,138 @@ function safeRelativePath(rootDir, candidate) {
130
283
  return target;
131
284
  }
132
285
 
286
+ function readReconciliationManifest(db, change, input, rootDir) {
287
+ const relative = typeof input.reconciliation_path === 'string'
288
+ ? input.reconciliation_path.trim() : '';
289
+ if (!relative) {
290
+ throw new ChangeWorkflowError(
291
+ 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation_path is required',
292
+ );
293
+ }
294
+ let file = safeRelativePath(rootDir, relative);
295
+ const artifactRoot = path.resolve(rootDir, change.artifact_root);
296
+ if (!file.startsWith(`${artifactRoot}${path.sep}`)) {
297
+ throw new ChangeWorkflowError(
298
+ 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED',
299
+ 'reconciliation manifest must be a file inside the active change artifact root',
300
+ );
301
+ }
302
+ let manifest;
303
+ if (!fs.existsSync(file)) {
304
+ const pending = archiveJournal.listArchiveIntents(rootDir).find(
305
+ (item) => !item.error && item.intent.change_id === change.id
306
+ && item.intent.reconciliation_path === relative,
307
+ );
308
+ if (pending) {
309
+ const suffix = path.relative(pending.intent.source, relative);
310
+ file = path.join(pending.destination, suffix);
311
+ manifest = pending.intent.reconciliation_manifest;
312
+ }
313
+ }
314
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
315
+ throw new ChangeWorkflowError(
316
+ 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation manifest file is missing',
317
+ );
318
+ }
319
+ try { manifest ||= JSON.parse(fs.readFileSync(file, 'utf8')); }
320
+ catch (error) {
321
+ throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
322
+ }
323
+ if (!validateReconciliationSchema(manifest)) {
324
+ throw new ChangeWorkflowError(
325
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
326
+ `reconciliation manifest does not satisfy its published schema: ${reconciliationAjv.errorsText(validateReconciliationSchema.errors)}`,
327
+ validateReconciliationSchema.errors,
328
+ );
329
+ }
330
+ const baseline = baselines.readBaseline(db);
331
+ const updates = Array.isArray(input.baseline_updates) ? input.baseline_updates : [];
332
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
333
+ || manifest.$schema !== 'ultra-baseline-reconciliation-v1'
334
+ || manifest.change_id !== change.id || manifest.baseline_id !== (baseline?.id || null)
335
+ || JSON.stringify(manifest.baseline_updates) !== JSON.stringify(updates)
336
+ || !Array.isArray(manifest.semantic_changes)
337
+ || !Array.isArray(manifest.resolved_gap_ids)
338
+ || !Array.isArray(manifest.resolved_unknowns)
339
+ || !Array.isArray(manifest.verification) || manifest.verification.length === 0) {
340
+ throw new ChangeWorkflowError(
341
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'reconciliation manifest shape or authority binding is invalid',
342
+ );
343
+ }
344
+ if (manifest.verification.some((item) => (
345
+ !item || typeof item !== 'object' || item.status !== 'pass'
346
+ || !String(item.name || '').trim() || !String(item.command || '').trim()
347
+ || !String(item.evidence || '').trim()
348
+ ))) {
349
+ throw new ChangeWorkflowError(
350
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'every reconciliation verification must be an evidenced pass',
351
+ );
352
+ }
353
+ if (updates.length === 0) {
354
+ if (manifest.semantic_changes.length > 0
355
+ || String(manifest.semantic_no_change_reason || '').trim().length < 3) {
356
+ throw new ChangeWorkflowError(
357
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
358
+ 'no-change reconciliation requires an explicit semantic_no_change_reason and no semantic changes',
359
+ );
360
+ }
361
+ } else if (manifest.semantic_changes.length === 0) {
362
+ throw new ChangeWorkflowError(
363
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'baseline updates require semantic change records',
364
+ );
365
+ }
366
+ const specRefs = new Map((baseline?.spec_refs || []).map((ref) => [ref.path, ref]));
367
+ const semanticPaths = new Set();
368
+ for (const item of manifest.semantic_changes) {
369
+ if (!item || typeof item !== 'object' || !CHANGE_ID.test(String(item.id || ''))
370
+ || !['add', 'update'].includes(item.action)
371
+ || !/^[0-9a-f]{64}$/.test(String(item.after_digest || ''))) {
372
+ throw new ChangeWorkflowError(
373
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'semantic change record is invalid',
374
+ );
375
+ }
376
+ let resolved;
377
+ try { resolved = workflows.resolveProjectSourceRef(rootDir, item.source_ref, 'semantic_change.source_ref'); }
378
+ catch (error) {
379
+ throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
380
+ }
381
+ semanticPaths.add(resolved.relative);
382
+ if (!updates.includes(resolved.relative) || resolved.digest !== item.after_digest) {
383
+ throw new ChangeWorkflowError(
384
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
385
+ `semantic change digest or baseline update binding is invalid: ${item.source_ref}`,
386
+ );
387
+ }
388
+ const before = specRefs.get(resolved.relative)?.digest || null;
389
+ if ((item.before_digest || null) !== before || item.action !== (before ? 'update' : 'add')) {
390
+ throw new ChangeWorkflowError(
391
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
392
+ `semantic change before-state is invalid: ${item.source_ref}`,
393
+ );
394
+ }
395
+ }
396
+ const missingSemanticUpdates = updates.filter((update) => !semanticPaths.has(update));
397
+ if (missingSemanticUpdates.length > 0) {
398
+ throw new ChangeWorkflowError(
399
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
400
+ `baseline updates have no semantic record: ${missingSemanticUpdates.join(', ')}`,
401
+ );
402
+ }
403
+ const gapIds = new Set((baseline?.gaps || []).map((gap) => gap.id));
404
+ const unknowns = new Set((baseline?.unknowns || []).map((unknown) => unknown.summary));
405
+ if (manifest.resolved_gap_ids.some((id) => !gapIds.has(id))
406
+ || manifest.resolved_unknowns.some((summary) => !unknowns.has(summary))) {
407
+ throw new ChangeWorkflowError(
408
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'resolved gap or unknown does not exist in baseline authority',
409
+ );
410
+ }
411
+ const serialized = fs.readFileSync(file);
412
+ return {
413
+ manifest, relative, file,
414
+ digest: crypto.createHash('sha256').update(serialized).digest('hex'),
415
+ };
416
+ }
417
+
133
418
  function readChange(db, id) {
134
419
  return rowToChange(db.prepare('SELECT * FROM changes WHERE id = ?').get(id));
135
420
  }
@@ -151,11 +436,24 @@ function writeIntent(file, change) {
151
436
  `- Kind: \`${change.kind}\``,
152
437
  `- Base commit: \`${change.base_commit || 'unavailable'}\``,
153
438
  `- Documentation impact: \`${impact.status}\``,
439
+ `- Research disposition: \`${change.research_disposition.status}\``,
154
440
  '',
155
441
  '## Intent',
156
442
  '',
157
443
  change.intent,
158
444
  '',
445
+ '## Outcome', '', change.contract.outcome, '',
446
+ '## Acceptance', '',
447
+ ...change.contract.acceptance.flatMap((item) => [
448
+ `### ${item.id}`, '', item.criterion, '', `Verification: \`${item.verification}\``, '',
449
+ ]),
450
+ '## Public seams', '', ...change.contract.public_seams.map((item) => `- ${item}`), '',
451
+ '## Non-goals', '', ...change.contract.non_goals.map((item) => `- ${item}`), '',
452
+ '## Recovery', '', change.contract.recovery.strategy, '',
453
+ `Verification: \`${change.contract.recovery.verification}\``, '',
454
+ '## Classification', '', change.classification.rationale, '',
455
+ `Risk flags: ${change.classification.risk_flags.join(', ') || 'none'}`, '',
456
+ '## Research routing', '', change.research_disposition.rationale, '',
159
457
  ];
160
458
  if (impact.rationale) lines.push('## Documentation rationale', '', impact.rationale, '');
161
459
  if (change.baseline_bypass) {
@@ -240,23 +538,27 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
240
538
  ? change.provider_refs
241
539
  : normalizeProviderRefs(input.provider_refs);
242
540
  const tasks = db.prepare(
243
- `SELECT id, title, type, status, priority, complexity, stale, trace_to,
244
- context_file, files_modified, session_id, created_at, updated_at
245
- FROM tasks WHERE change_id = ? ORDER BY created_at ASC`,
246
- ).all(change.id).map((task) => ({ ...task, stale: Boolean(task.stale) }));
541
+ 'SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
542
+ ).all(change.id).map((task) => ops.readTask(db, task.id));
247
543
  const specs = Array.isArray(input.spec_refs) ? input.spec_refs : [];
248
- const allowedPaths = Array.isArray(input.allowed_paths) ? input.allowed_paths : [];
249
- const head = gitHead(rootDir);
250
544
  const spine = contextSpine.compileRoleContext(db, { input, change, tasks, rootDir });
545
+ const snapshotId = `ctx-${crypto.randomUUID().slice(0, 12)}`;
251
546
  const manifest = {
252
547
  schema_version: '2.0',
548
+ snapshot_id: snapshotId,
253
549
  generated_at: nowIso(),
254
550
  change: {
255
551
  id: change.id, title: change.title, kind: change.kind, status: change.status,
256
552
  intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
257
553
  baseline_bypass: change.baseline_bypass,
554
+ contract: change.contract, classification: change.classification,
555
+ research_disposition: change.research_disposition,
556
+ },
557
+ git: {
558
+ head: spine.resume.git_head,
559
+ worktree_state: spine.resume.worktree_state,
560
+ worktree_digest: spine.resume.worktree_digest,
258
561
  },
259
- git: { head },
260
562
  tasks,
261
563
  selected_task: spine.selected_task,
262
564
  specs,
@@ -270,11 +572,11 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
270
572
  resume: spine.resume,
271
573
  providers,
272
574
  provider_boundary: 'metadata references only; memory and code graph content remain external',
273
- tool_policy: { allowed_paths: allowedPaths },
274
575
  };
275
576
  const artifactDir = path.resolve(rootDir, change.artifact_root);
276
577
  fs.mkdirSync(artifactDir, { recursive: true });
277
- const manifestPath = path.join(artifactDir, 'context-manifest.json');
578
+ const manifestPath = path.join(artifactDir, 'contexts', `${snapshotId}.json`);
579
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
278
580
  const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
279
581
  fs.writeFileSync(manifestPath, serialized);
280
582
  const hash = crypto.createHash('sha256').update(serialized).digest('hex');
@@ -288,8 +590,8 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
288
590
  token_estimate, token_budget)
289
591
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
290
592
  ).run(
291
- `ctx-${crypto.randomUUID().slice(0, 12)}`, change.id, input.task_id || null,
292
- head, JSON.stringify(providers), relative, hash,
593
+ snapshotId, change.id, input.task_id || null,
594
+ spine.resume.git_head, JSON.stringify(providers), relative, hash,
293
595
  spine.role, spine.gate, spine.next_action, spine.readiness.status,
294
596
  JSON.stringify(spine.readiness.blockers), JSON.stringify(spine),
295
597
  spine.context.token_estimate, spine.context.budget.max_tokens,
@@ -298,7 +600,9 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
298
600
  change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
299
601
  artifactPath: relative, contentHash: hash,
300
602
  metadata: {
301
- git_head: head, role: spine.role, gate: spine.gate,
603
+ git_head: spine.resume.git_head,
604
+ worktree_digest: spine.resume.worktree_digest,
605
+ role: spine.role, gate: spine.gate,
302
606
  readiness: spine.readiness.status, token_estimate: spine.context.token_estimate,
303
607
  },
304
608
  });
@@ -314,11 +618,35 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
314
618
  ops.appendEventInTx(db, {
315
619
  type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
316
620
  payload: {
317
- manifest_path: relative, manifest_hash: hash, git_head: head,
621
+ manifest_path: relative, manifest_hash: hash, git_head: spine.resume.git_head,
622
+ worktree_digest: spine.resume.worktree_digest,
318
623
  role: spine.role, gate: spine.gate, readiness: spine.readiness.status,
319
624
  next_action: spine.next_action,
320
625
  },
321
626
  });
627
+ const linkedWorkflow = db.prepare(
628
+ `SELECT id FROM workflow_runs
629
+ WHERE kind = 'change' AND change_id = ? AND current_step = 'compile-context'
630
+ AND status IN ('active', 'blocked')
631
+ ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
632
+ ).get(change.id);
633
+ if (linkedWorkflow) {
634
+ if (spine.readiness.status === 'ready') {
635
+ workflows.recordWorkflowStep(db, {
636
+ id: linkedWorkflow.id, step_id: 'compile-context', status: 'completed',
637
+ evidence: [{
638
+ kind: 'context', ref: relative,
639
+ summary: `Role ${spine.role} context is ready for ${input.task_id || 'change planning'}.`,
640
+ }],
641
+ outputs: [{ path: relative, kind: 'context-manifest' }],
642
+ }, { rootDir });
643
+ } else {
644
+ workflows.recordWorkflowStep(db, {
645
+ id: linkedWorkflow.id, step_id: 'compile-context', status: 'blocked',
646
+ blockers: spine.readiness.blockers,
647
+ }, { rootDir });
648
+ }
649
+ }
322
650
  });
323
651
  return { manifest, context_manifest_path: manifestPath, manifest_hash: hash };
324
652
  }
@@ -332,7 +660,7 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
332
660
  'id plus non-blank title and intent of at least three characters are required',
333
661
  );
334
662
  }
335
- if (!REQUIRED_EVIDENCE[input.kind]) {
663
+ if (!CHANGE_KINDS.has(input.kind)) {
336
664
  throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
337
665
  }
338
666
  if (input.baseline_bypass !== undefined && input.kind !== 'incident') {
@@ -359,6 +687,9 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
359
687
  }
360
688
  const docsImpact = normalizeDocsImpact(input.docs_impact);
361
689
  const providers = normalizeProviderRefs(input.provider_refs);
690
+ const contract = normalizeChangeContract(input.contract);
691
+ const classification = normalizeClassification(input.classification, input.kind);
692
+ const researchDisposition = normalizeResearchDisposition(input.research_disposition, input.kind);
362
693
  const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
363
694
  const artifactDir = path.resolve(rootDir, artifactRoot);
364
695
  if (fs.existsSync(artifactDir)) {
@@ -368,6 +699,7 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
368
699
  const row = {
369
700
  id: input.id, title, kind: input.kind, status: 'active',
370
701
  intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
702
+ contract, classification, research_disposition: researchDisposition,
371
703
  base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
372
704
  created_at: ts, updated_at: ts,
373
705
  };
@@ -376,13 +708,15 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
376
708
  db.prepare(
377
709
  `INSERT INTO changes
378
710
  (id, title, kind, status, intent, docs_impact_json, provider_refs_json,
379
- baseline_bypass_json, base_commit, artifact_root, created_at, updated_at)
380
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
711
+ baseline_bypass_json, contract_json, classification_json,
712
+ research_disposition_json, base_commit, artifact_root, created_at, updated_at)
713
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
381
714
  ).run(
382
715
  row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
383
716
  JSON.stringify(row.provider_refs), row.baseline_bypass
384
717
  ? JSON.stringify(row.baseline_bypass) : null,
385
- row.base_commit, row.artifact_root, ts, ts,
718
+ JSON.stringify(row.contract), JSON.stringify(row.classification),
719
+ JSON.stringify(row.research_disposition), row.base_commit, row.artifact_root, ts, ts,
386
720
  );
387
721
  fs.mkdirSync(artifactDir, { recursive: true });
388
722
  const intentPath = path.join(artifactDir, 'intent.md');
@@ -406,10 +740,41 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
406
740
  type: 'change_created', change_id: row.id,
407
741
  payload: { kind: row.kind, artifact_root: row.artifact_root },
408
742
  });
743
+ let workflow = workflows.startWorkflow(db, {
744
+ kind: 'change', baseline_id: baselineHealth.baseline?.id || null,
745
+ change_id: row.id, subject: `Establish executable readiness for ${row.title}.`,
746
+ metadata: { change_kind: row.kind, base_commit: row.base_commit },
747
+ }, { rootDir });
409
748
  const context = compileContext(db, { id: row.id }, { rootDir });
749
+ workflow = workflows.recordWorkflowStep(db, {
750
+ id: workflow.id, step_id: 'bind-baseline', status: 'completed',
751
+ evidence: [{
752
+ kind: 'baseline', ref: baselineHealth.baseline?.id || 'incident-break-glass',
753
+ summary: baselineHealth.status === 'pass'
754
+ ? `Bound ready baseline at ${baselineHealth.baseline.repository_revision}.`
755
+ : 'Bound the approved incident break-glass authority.',
756
+ }],
757
+ }, { rootDir });
758
+ workflow = workflows.recordWorkflowStep(db, {
759
+ id: workflow.id, step_id: 'classify-change', status: 'completed',
760
+ decisions: [{
761
+ kind: 'change_kind', value: row.kind,
762
+ rationale: row.classification.rationale, risk_flags: row.classification.risk_flags,
763
+ }],
764
+ }, { rootDir });
765
+ workflow = workflows.recordWorkflowStep(db, {
766
+ id: workflow.id, step_id: 'record-intent', status: 'completed',
767
+ evidence: [{ kind: 'intent', ref: path.relative(rootDir, intentPath), summary: row.intent }],
768
+ outputs: [{ path: path.relative(rootDir, intentPath), kind: 'change-intent' }],
769
+ decisions: [{
770
+ kind: 'change_contract', acceptance_ids: row.contract.acceptance.map((item) => item.id),
771
+ research_disposition: row.research_disposition,
772
+ }],
773
+ }, { rootDir });
410
774
  return {
411
775
  change: readChange(db, row.id), intent_path: intentPath,
412
776
  context_manifest_path: context.context_manifest_path,
777
+ workflow,
413
778
  };
414
779
  });
415
780
  } catch (error) {
@@ -428,15 +793,14 @@ function assertTaskCreationAllowed(db, input = {}, { rootDir = process.cwd() } =
428
793
  }
429
794
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
430
795
  if (baselineHealth.status === 'pass') return;
431
- if (change) {
432
- // The change writer gate already proved that this work either started from
433
- // healthy authority or carries an approved incident break-glass record.
434
- return;
435
- }
796
+ const gate = change
797
+ ? contextSpine.baselineGateForChange(db, change, baselineHealth)
798
+ : { blockers: baselineHealth.blockers };
799
+ if (gate.blockers.length === 0) return;
436
800
  throw new ChangeWorkflowError(
437
801
  'BASELINE_NOT_READY',
438
- `ordinary task creation requires a ready baseline or an already-authorized active change: ${baselineHealth.blockers.join(', ')}`,
439
- { blockers: baselineHealth.blockers, change_id: input.change_id || null },
802
+ `task creation requires healthy baseline authority or an approved incident break-glass record: ${gate.blockers.join(', ')}`,
803
+ { blockers: gate.blockers, change_id: input.change_id || null },
440
804
  );
441
805
  }
442
806
 
@@ -451,7 +815,9 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
451
815
  throw new ChangeWorkflowError('VALIDATION_ERROR', `change field ${field} is not patchable`);
452
816
  }
453
817
  }
454
- const syncIntent = ['title', 'intent', 'docs_impact'].some((field) => patch[field] !== undefined);
818
+ const syncIntent = [
819
+ 'title', 'intent', 'docs_impact', 'contract', 'classification', 'research_disposition',
820
+ ].some((field) => patch[field] !== undefined);
455
821
  const intentPath = path.resolve(rootDir, current.artifact_root, 'intent.md');
456
822
  const previousIntent = syncIntent && fs.existsSync(intentPath)
457
823
  ? fs.readFileSync(intentPath)
@@ -476,6 +842,17 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
476
842
  if (patch.provider_refs !== undefined) {
477
843
  sets.push('provider_refs_json = ?'); values.push(JSON.stringify(normalizeProviderRefs(patch.provider_refs)));
478
844
  }
845
+ if (patch.contract !== undefined) {
846
+ sets.push('contract_json = ?'); values.push(JSON.stringify(normalizeChangeContract(patch.contract)));
847
+ }
848
+ if (patch.classification !== undefined) {
849
+ sets.push('classification_json = ?');
850
+ values.push(JSON.stringify(normalizeClassification(patch.classification, current.kind)));
851
+ }
852
+ if (patch.research_disposition !== undefined) {
853
+ sets.push('research_disposition_json = ?');
854
+ values.push(JSON.stringify(normalizeResearchDisposition(patch.research_disposition, current.kind)));
855
+ }
479
856
  if (patch.status !== undefined) {
480
857
  const allowed = CHANGE_TRANSITIONS[current.status] || new Set();
481
858
  if (!allowed.has(patch.status)) {
@@ -522,65 +899,87 @@ function filesUnder(dir) {
522
899
  return out;
523
900
  }
524
901
 
525
- function evidenceBlockers(change, evidence, tasks) {
526
- const blockers = [];
527
- const rows = Array.isArray(evidence) ? evidence : [];
528
- const byCategory = new Map(rows.map((row) => [row.category, row]));
529
- for (const category of REQUIRED_EVIDENCE[change.kind].filter((value) => value !== 'review')) {
530
- const row = byCategory.get(category);
531
- if (!row) blockers.push(`EVIDENCE_MISSING:${category}`);
532
- else if (!['pass', 'not_applicable'].includes(row.status)) blockers.push(`EVIDENCE_FAILED:${category}`);
533
- else if (!row.evidence || String(row.evidence).trim().length < 3) blockers.push(`EVIDENCE_EMPTY:${category}`);
534
- }
535
- if (['standard', 'major'].includes(change.kind)) {
536
- const reviews = rows.filter((row) => row.category === 'review');
537
- for (const axis of ['spec_fidelity', 'engineering_standards']) {
538
- const row = reviews.find((candidate) => candidate.axis === axis);
539
- if (!row) blockers.push(`REVIEW_AXIS_MISSING:${axis}`);
540
- else if (row.status !== 'pass') blockers.push(`REVIEW_AXIS_FAILED:${axis}`);
541
- else if (!row.evidence || String(row.evidence).trim().length < 3) {
542
- blockers.push(`REVIEW_AXIS_EMPTY:${axis}`);
543
- }
544
- }
545
- }
546
- const tests = byCategory.get('tests');
547
- if (tests && ['standard', 'major', 'incident'].includes(change.kind)
548
- && (!tests.seam || String(tests.seam).trim().length < 3)) {
549
- blockers.push('TEST_SEAM_MISSING');
550
- }
551
- if (tests && (change.kind === 'incident' || tasks.some((task) => task.type === 'bugfix'))) {
552
- const signal = tests.signal;
553
- if (!signal) blockers.push('TEST_RED_SIGNAL_MISSING');
554
- else {
555
- if (!signal.command || String(signal.command).trim().length < 3) blockers.push('TEST_SIGNAL_COMMAND_MISSING');
556
- if (!signal.expected_red || String(signal.expected_red).trim().length < 3) blockers.push('TEST_EXPECTED_RED_MISSING');
557
- if (signal.observed_red !== true) blockers.push('TEST_RED_NOT_OBSERVED');
558
- if (signal.observed_green !== true) blockers.push('TEST_GREEN_NOT_OBSERVED');
559
- if (signal.deterministic !== true) blockers.push('TEST_SIGNAL_NOT_DETERMINISTIC');
560
- }
561
- }
562
- return blockers;
902
+ function baselineConvergenceBlockers(db, change, rootDir) {
903
+ const baselineHealth = baselines.inspectBaseline(db, { rootDir });
904
+ return contextSpine.baselineGateForChange(db, change, baselineHealth).blockers;
563
905
  }
564
906
 
565
- function baselineConvergenceBlockers(db, change, rootDir) {
566
- const baseline = baselines.readBaseline(db);
567
- if (baseline && baseline.status === 'ready' && baseline.mode !== 'migrated') return [];
568
- if (change.kind === 'incident' && change.baseline_bypass?.mode === 'incident_break_glass') {
569
- return [];
907
+ function deriveConvergenceEvidence(db, change, tasks, workflowGate, diagnosis, rootDir) {
908
+ const devRuns = db.prepare(
909
+ `SELECT id, task_id, summary_json FROM workflow_runs
910
+ WHERE kind = 'dev' AND change_id = ? AND status = 'completed'
911
+ ORDER BY completed_at ASC, rowid ASC`,
912
+ ).all(change.id).map((row) => ({
913
+ id: row.id, task_id: row.task_id, summary: parseJson(row.summary_json, 'workflow_runs.summary_json'),
914
+ }));
915
+ const deltaPaths = filesUnder(path.join(rootDir, change.artifact_root, 'delta'))
916
+ .map((file) => path.relative(rootDir, file));
917
+ const appliedLearning = specLearning.listSpecLearning(db, change.id)
918
+ .filter((candidate) => candidate.status === 'applied')
919
+ .map((candidate) => candidate.target_ref);
920
+ const rows = [
921
+ {
922
+ category: 'diff', status: 'pass',
923
+ evidence: `Completed dev workflows: ${devRuns.map((run) => `${run.task_id}:${run.id}`).join(', ')}`,
924
+ refs: devRuns.map((run) => run.id),
925
+ },
926
+ {
927
+ category: 'tests', status: 'pass',
928
+ evidence: `DB-derived passing test workflow ${workflowGate.test_workflow_id}.`,
929
+ refs: [workflowGate.test_summary.report_path, workflowGate.test_summary.report_digest].filter(Boolean),
930
+ regression_signal: workflowGate.test_summary.regression_signal || null,
931
+ },
932
+ {
933
+ category: 'spec', status: 'pass',
934
+ evidence: deltaPaths.length > 0
935
+ ? `Current delta artifacts: ${deltaPaths.join(', ')}`
936
+ : `Change intent and resolved specification learning for ${change.id}.`,
937
+ refs: [...deltaPaths, ...appliedLearning],
938
+ },
939
+ {
940
+ category: 'docs', status: 'pass',
941
+ evidence: change.docs_impact.status === 'required'
942
+ ? `Declared documentation files: ${change.docs_impact.files.join(', ')}`
943
+ : `No documentation change: ${change.docs_impact.rationale}`,
944
+ refs: change.docs_impact.files,
945
+ },
946
+ ...['spec_fidelity', 'engineering_standards'].map((axis) => ({
947
+ category: 'review', axis, status: 'pass',
948
+ evidence: `DB-derived ${axis} verdict from ${workflowGate.review_workflow_id}.`,
949
+ refs: workflowGate.review_summary.axes?.[axis]?.evidence_refs || [],
950
+ })),
951
+ ];
952
+ if (diagnosis.artifact) {
953
+ rows.unshift({
954
+ category: 'diagnosis', status: 'pass',
955
+ evidence: `Structured incident diagnosis ${diagnosis.artifact.artifactPath}.`,
956
+ refs: [diagnosis.artifact.artifactPath, diagnosis.artifact.contentHash],
957
+ });
570
958
  }
571
- if (!baseline) return ['BASELINE_MISSING'];
572
- if (baseline.mode === 'migrated') return ['BASELINE_MIGRATION_REVIEW_REQUIRED'];
573
- return [`BASELINE_NOT_READY:${baseline.status}`];
959
+ return rows;
574
960
  }
575
961
 
576
962
  function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
963
+ if (Object.hasOwn(input || {}, 'evidence')) {
964
+ throw new ChangeWorkflowError(
965
+ 'CONVERGENCE_EVIDENCE_AUTHORITY_VIOLATION',
966
+ 'change convergence evidence is derived from DB-backed dev, test, review, specification, and documentation authority',
967
+ );
968
+ }
577
969
  const change = readChange(db, input.id);
578
970
  if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
579
971
  if (!['active', 'blocked'].includes(change.status)) {
580
972
  throw new ChangeWorkflowError('CHANGE_NOT_CONVERGEABLE', `change ${input.id} is ${change.status}`);
581
973
  }
582
- const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
583
- const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
974
+ const tasks = db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
975
+ .all(change.id).map((task) => ops.readTask(db, task.id));
976
+ const blockers = new Set();
977
+ let workflowGate = null;
978
+ try {
979
+ workflowGate = workflows.validateDeliveryPrerequisites(db, { change_id: change.id }, rootDir);
980
+ } catch (error) {
981
+ blockers.add(error.code || 'WORKFLOW_GATE_UNAVAILABLE');
982
+ }
584
983
  for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
585
984
  if (tasks.length === 0) blockers.add('NO_TASKS');
586
985
  if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
@@ -624,11 +1023,24 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
624
1023
  if (filesUnder(path.join(artifactDir, 'delta')).length === 0) blockers.add('SPEC_DELTA_MISSING');
625
1024
  if (!fs.existsSync(path.join(artifactDir, 'plan.md'))) blockers.add('CHANGE_PLAN_MISSING');
626
1025
  }
627
- const manifestPath = path.join(artifactDir, 'context-manifest.json');
628
- if (!fs.existsSync(manifestPath)) blockers.add('CONTEXT_MANIFEST_MISSING');
1026
+ const latestContext = db.prepare(
1027
+ `SELECT manifest_path, manifest_hash FROM context_snapshots
1028
+ WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
1029
+ ).get(change.id);
1030
+ const manifestPath = latestContext?.manifest_path
1031
+ ? safeRelativePath(rootDir, latestContext.manifest_path)
1032
+ : null;
1033
+ if (!manifestPath || !fs.existsSync(manifestPath)) blockers.add('CONTEXT_MANIFEST_MISSING');
629
1034
  else {
630
1035
  const manifest = parseJson(fs.readFileSync(manifestPath, 'utf8'), 'context-manifest.json');
1036
+ const manifestHash = crypto.createHash('sha256').update(fs.readFileSync(manifestPath)).digest('hex');
1037
+ if (latestContext.manifest_hash !== manifestHash) blockers.add('CONTEXT_MANIFEST_STALE');
631
1038
  if (manifest.git && manifest.git.head !== gitHead(rootDir)) blockers.add('CONTEXT_HEAD_STALE');
1039
+ const checkout = baselines.gitWorktreeSnapshot(rootDir, ['.']);
1040
+ if (manifest.git?.worktree_digest && checkout.digest
1041
+ && manifest.git.worktree_digest !== checkout.digest) {
1042
+ blockers.add('CONTEXT_WORKTREE_STALE');
1043
+ }
632
1044
  const manifestTasks = (manifest.tasks || []).map((task) => task.id).sort();
633
1045
  const currentTasks = tasks.map((task) => task.id).sort();
634
1046
  if (JSON.stringify(manifestTasks) !== JSON.stringify(currentTasks)) blockers.add('CONTEXT_TASK_SET_STALE');
@@ -652,10 +1064,13 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
652
1064
  return { ready: false, status: 'blocked', blockers: blockerList };
653
1065
  }
654
1066
 
1067
+ const derivedEvidence = deriveConvergenceEvidence(
1068
+ db, change, tasks, workflowGate, diagnosis, rootDir,
1069
+ );
655
1070
  const verificationPath = path.join(artifactDir, 'verification.md');
656
1071
  const lines = [
657
1072
  `# Verification: ${change.title}`, '',
658
- ...input.evidence.flatMap((row) => [
1073
+ ...derivedEvidence.flatMap((row) => [
659
1074
  `## ${row.category}${row.axis ? `:${row.axis}` : ''}`, '',
660
1075
  `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
661
1076
  ]),
@@ -668,11 +1083,11 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
668
1083
  upsertArtifact(db, {
669
1084
  change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
670
1085
  contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
671
- metadata: { evidence: input.evidence },
1086
+ metadata: { evidence: derivedEvidence },
672
1087
  });
673
1088
  ops.appendEventInTx(db, {
674
1089
  type: 'change_converged', change_id: change.id,
675
- payload: { evidence_categories: input.evidence.map((row) => row.category) },
1090
+ payload: { evidence_categories: derivedEvidence.map((row) => row.category) },
676
1091
  });
677
1092
  });
678
1093
  return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
@@ -711,6 +1126,36 @@ function finalizeArchive(db, intent, { rootDir }) {
711
1126
  db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
712
1127
  .run(archivedPath, nowIso(), artifact.id);
713
1128
  }
1129
+ const workflowOutputs = db.prepare(
1130
+ `SELECT ws.run_id, ws.step_id, ws.outputs_json
1131
+ FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
1132
+ WHERE wr.change_id = ?`,
1133
+ ).all(change.id);
1134
+ for (const step of workflowOutputs) {
1135
+ const outputs = parseJson(step.outputs_json, 'workflow_steps.outputs_json');
1136
+ let changed = false;
1137
+ const moved = outputs.map((output) => {
1138
+ if (output.path !== intent.source && !output.path.startsWith(prefix)) return output;
1139
+ changed = true;
1140
+ const suffix = path.relative(intent.source, output.path);
1141
+ return { ...output, path: suffix ? path.join(relative, suffix) : relative };
1142
+ });
1143
+ if (changed) {
1144
+ db.prepare(
1145
+ 'UPDATE workflow_steps SET outputs_json = ?, updated_at = ? WHERE run_id = ? AND step_id = ?',
1146
+ ).run(JSON.stringify(moved), nowIso(), step.run_id, step.step_id);
1147
+ }
1148
+ }
1149
+ const contextRows = db.prepare(
1150
+ 'SELECT id, manifest_path FROM context_snapshots WHERE change_id = ?',
1151
+ ).all(change.id);
1152
+ for (const snapshot of contextRows) {
1153
+ if (snapshot.manifest_path !== intent.source && !snapshot.manifest_path.startsWith(prefix)) continue;
1154
+ const suffix = path.relative(intent.source, snapshot.manifest_path);
1155
+ const archivedPath = suffix ? path.join(relative, suffix) : relative;
1156
+ db.prepare('UPDATE context_snapshots SET manifest_path = ? WHERE id = ?')
1157
+ .run(archivedPath, snapshot.id);
1158
+ }
714
1159
  const archivedSummary = path.join(destination, 'archive-summary.md');
715
1160
  upsertArtifact(db, {
716
1161
  change_id: change.id, kind: 'archive_summary',
@@ -721,6 +1166,19 @@ function finalizeArchive(db, intent, { rootDir }) {
721
1166
  no_baseline_change_reason: intent.no_baseline_change_reason,
722
1167
  },
723
1168
  });
1169
+ const reconciliationSuffix = path.relative(intent.source, intent.reconciliation_path);
1170
+ const archivedReconciliationPath = path.join(relative, reconciliationSuffix);
1171
+ upsertArtifact(db, {
1172
+ change_id: change.id, kind: 'baseline_reconciliation',
1173
+ artifactPath: archivedReconciliationPath,
1174
+ contentHash: intent.reconciliation_digest,
1175
+ metadata: {
1176
+ schema: intent.reconciliation_manifest.$schema,
1177
+ semantic_changes: intent.reconciliation_manifest.semantic_changes.map((item) => item.id),
1178
+ resolved_gap_ids: intent.reconciliation_manifest.resolved_gap_ids,
1179
+ resolved_unknowns: intent.reconciliation_manifest.resolved_unknowns,
1180
+ },
1181
+ });
724
1182
  db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
725
1183
  .run(nowIso(), change.id);
726
1184
  if (breakGlass) {
@@ -742,6 +1200,7 @@ function finalizeArchive(db, intent, { rootDir }) {
742
1200
  } else {
743
1201
  baselines.reconcileBaseline(db, {
744
1202
  baseline_updates: intent.baseline_updates, change_id: change.id,
1203
+ reconciliation: intent.reconciliation_manifest,
745
1204
  }, { rootDir });
746
1205
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
747
1206
  if (baselineHealth.blockers.length > 0) {
@@ -757,6 +1216,8 @@ function finalizeArchive(db, intent, { rootDir }) {
757
1216
  payload: {
758
1217
  archive_path: relative, baseline_updates: intent.baseline_updates,
759
1218
  no_baseline_change_reason: intent.no_baseline_change_reason,
1219
+ reconciliation_path: archivedReconciliationPath,
1220
+ reconciliation_digest: intent.reconciliation_digest,
760
1221
  baseline_bypass: breakGlass,
761
1222
  },
762
1223
  });
@@ -786,9 +1247,13 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
786
1247
  if (!['ready', 'archived'].includes(change.status)) {
787
1248
  throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
788
1249
  }
1250
+ const reconciliation = readReconciliationManifest(db, change, input, rootDir);
789
1251
  const prepared = archiveJournal.prepareArchiveMove({
790
1252
  rootDir, change, summary: String(input.summary), baselineUpdates: updates,
791
1253
  noBaselineChangeReason: noChangeReason || null,
1254
+ reconciliationPath: reconciliation.relative,
1255
+ reconciliationDigest: reconciliation.digest,
1256
+ reconciliationManifest: reconciliation.manifest,
792
1257
  });
793
1258
  let result;
794
1259
  try {