ultra-builder-pro-cli 0.14.0 → 0.16.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 (163) 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 +77 -1
  8. package/README.md +46 -24
  9. package/adapters/_shared/codex-assets.cjs +6 -2
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/_shared/handbook.cjs +2 -1
  12. package/adapters/claude.js +3 -2
  13. package/adapters/codex.js +1 -0
  14. package/adapters/kimi.js +3 -2
  15. package/adapters/opencode.js +3 -2
  16. package/agents/review-coordinator.md +9 -6
  17. package/commands/ultra-change.md +1 -1
  18. package/commands/ultra-plan.md +1 -1
  19. package/commands/ultra-research.md +1 -1
  20. package/commands/ultra-think.md +2 -2
  21. package/docs/AGENT-CONTEXT.md +43 -19
  22. package/docs/ARCHITECTURE.md +54 -13
  23. package/docs/PLAN.zh-CN.md +4 -2
  24. package/docs/ROADMAP.md +10 -4
  25. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  26. package/docs/STATE-DB-ACCESS-POLICY.md +33 -29
  27. package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
  28. package/docs/WORKFLOW-LIFECYCLE.md +342 -0
  29. package/hooks/README.md +6 -1
  30. package/hooks/health_check.py +12 -3
  31. package/hooks/subagent_tracker.py +52 -77
  32. package/mcp-server/hook-event.cjs +34 -0
  33. package/mcp-server/lib/archive-journal.cjs +17 -5
  34. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  35. package/mcp-server/lib/change-workflow.cjs +598 -85
  36. package/mcp-server/lib/context-spine.cjs +305 -25
  37. package/mcp-server/lib/decision-dialogue.cjs +713 -0
  38. package/mcp-server/lib/doctor.cjs +42 -3
  39. package/mcp-server/lib/init-project.cjs +210 -16
  40. package/mcp-server/lib/plan-store.cjs +4 -1
  41. package/mcp-server/lib/pricing.cjs +7 -10
  42. package/mcp-server/lib/project-breadcrumb.cjs +15 -0
  43. package/mcp-server/lib/projector.cjs +57 -3
  44. package/mcp-server/lib/spec-learning.cjs +79 -2
  45. package/mcp-server/lib/state-db.cjs +168 -8
  46. package/mcp-server/lib/state-ops.cjs +160 -9
  47. package/mcp-server/lib/workflow-state.cjs +2305 -0
  48. package/mcp-server/server.cjs +215 -15
  49. package/orchestrator/daemon.cjs +1 -2
  50. package/orchestrator/dispatch-rules.cjs +5 -31
  51. package/orchestrator/planner/plan-builder.cjs +21 -12
  52. package/package.json +13 -3
  53. package/skills/ultra-change/SKILL.md +94 -80
  54. package/skills/ultra-change/references/change-contract.md +36 -0
  55. package/skills/ultra-deliver/SKILL.md +76 -79
  56. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  57. package/skills/ultra-dev/SKILL.md +62 -59
  58. package/skills/ultra-doctor/SKILL.md +61 -82
  59. package/skills/ultra-init/SKILL.md +101 -103
  60. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  61. package/skills/ultra-plan/SKILL.md +99 -85
  62. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  63. package/skills/ultra-research/SKILL.md +102 -60
  64. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  65. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  66. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  67. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  68. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  69. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  70. package/skills/ultra-research/references/10-user-personas.md +29 -0
  71. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  72. package/skills/ultra-research/references/20-user-stories.md +30 -0
  73. package/skills/ultra-research/references/21-features-scope.md +29 -0
  74. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  75. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  76. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  77. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  78. package/skills/ultra-research/references/40-deployment.md +29 -0
  79. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  80. package/skills/ultra-research/references/99-synthesis.md +35 -0
  81. package/skills/ultra-review/SKILL.md +63 -73
  82. package/skills/ultra-review/references/review-modes.md +27 -0
  83. package/skills/ultra-review/references/unified-schema.md +25 -0
  84. package/skills/ultra-review/scripts/review_wait.py +40 -3
  85. package/skills/ultra-status/SKILL.md +41 -41
  86. package/skills/ultra-test/SKILL.md +54 -55
  87. package/skills/ultra-think/SKILL.md +26 -12
  88. package/skills/ultra-think/references/decision-dialogue.md +130 -0
  89. package/skills/ultra-verify/SKILL.md +1 -1
  90. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  91. package/spec/cli-protocol.md +17 -3
  92. package/spec/mcp-tools.yaml +683 -64
  93. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  94. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  95. package/spec/schemas/mcp-tools.schema.json +1 -1
  96. package/spec/schemas/state-db.sql +140 -1
  97. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  98. package/spec/schemas/test-report.v1.schema.json +194 -0
  99. package/ultra-tools/cli.cjs +1 -1
  100. package/ultra-tools/commands/migrate.cjs +3 -3
  101. package/ultra-tools/commands/status.cjs +228 -12
  102. package/.ultra-template/delivery-report.json +0 -9
  103. package/.ultra-template/test-report.json +0 -8
  104. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  105. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  106. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  107. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  108. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  109. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  110. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
  111. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  112. package/adapters/_shared/tests/validate.test.cjs +0 -30
  113. package/adapters/tests/claude.test.cjs +0 -138
  114. package/adapters/tests/codex-hook.test.cjs +0 -142
  115. package/adapters/tests/codex.test.cjs +0 -430
  116. package/adapters/tests/kimi-hook.test.cjs +0 -149
  117. package/adapters/tests/kimi.test.cjs +0 -259
  118. package/adapters/tests/opencode.test.cjs +0 -357
  119. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  120. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  121. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  122. package/mcp-server/lib/context-spine.test.cjs +0 -93
  123. package/mcp-server/lib/doctor.test.cjs +0 -148
  124. package/mcp-server/lib/plan-store.test.cjs +0 -132
  125. package/mcp-server/lib/prd-parser.test.cjs +0 -82
  126. package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
  127. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  128. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  129. package/mcp-server/lib/state-db.test.cjs +0 -314
  130. package/mcp-server/lib/task-expander.test.cjs +0 -169
  131. package/mcp-server/lib/topo.test.cjs +0 -158
  132. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  133. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  134. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  135. package/mcp-server/tests/concurrency.test.cjs +0 -112
  136. package/mcp-server/tests/db-ops.test.cjs +0 -121
  137. package/mcp-server/tests/init-project.test.cjs +0 -393
  138. package/mcp-server/tests/migrate.test.cjs +0 -232
  139. package/mcp-server/tests/projector.test.cjs +0 -162
  140. package/mcp-server/tests/server-contract.test.cjs +0 -1467
  141. package/mcp-server/tests/staleness.test.cjs +0 -190
  142. package/mcp-server/tests/state-db.test.cjs +0 -106
  143. package/mcp-server/tests/state-ops.test.cjs +0 -449
  144. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  145. package/mcp-server/tests/telemetry.test.cjs +0 -219
  146. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  147. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  148. package/orchestrator/tests/daemon.test.cjs +0 -311
  149. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  150. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  151. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  152. package/orchestrator/tests/recovery.test.cjs +0 -190
  153. package/orchestrator/tests/session-runner.test.cjs +0 -314
  154. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  155. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  156. package/skills/ultra-research/references/architecture.md +0 -38
  157. package/skills/ultra-research/references/problem-product.md +0 -39
  158. package/skills/ultra-research/references/quality-delivery.md +0 -34
  159. package/skills/ultra-research/references/synthesis.md +0 -38
  160. package/skills/ultra-research/references/users-scope.md +0 -40
  161. package/ultra-tools/legacy-memory.test.cjs +0 -80
  162. package/ultra-tools/status-command.test.cjs +0 -181
  163. 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,18 @@ 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 decisions = require('./decision-dialogue.cjs');
17
+ const reconciliationSchema = require('../../spec/schemas/baseline-reconciliation.v1.schema.json');
18
+
19
+ const reconciliationAjv = new Ajv({ allErrors: true, strict: false });
20
+ const validateReconciliationSchema = reconciliationAjv.compile(reconciliationSchema);
14
21
 
15
22
  const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
16
- const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
23
+ const CHANGE_PATCH_FIELDS = new Set([
24
+ 'title', 'intent', 'status', 'docs_impact', 'provider_refs',
25
+ 'contract', 'classification', 'research_disposition',
26
+ ]);
17
27
  const CHANGE_TRANSITIONS = Object.freeze({
18
28
  active: new Set(['active', 'blocked', 'cancelled']),
19
29
  blocked: new Set(['active', 'blocked', 'cancelled']),
@@ -21,12 +31,14 @@ const CHANGE_TRANSITIONS = Object.freeze({
21
31
  archived: new Set(),
22
32
  cancelled: new Set(),
23
33
  });
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
- });
34
+ const CHANGE_KINDS = new Set(['quick', 'standard', 'major', 'incident']);
35
+ const CHANGE_RISK_FLAGS = new Set([
36
+ 'public_contract', 'schema_migration', 'data_migration', 'authorization', 'security',
37
+ 'multi_module', 'external_integration', 'release_semantics',
38
+ ]);
39
+ const QUICK_EXCLUSION_FLAGS = new Set(CHANGE_RISK_FLAGS);
40
+ const RESEARCH_DISPOSITIONS = new Set(['none', 'bounded', 'required']);
41
+ const CHANGE_RESEARCH_MODES = new Set(['product', 'feature', 'architecture', 'custom']);
30
42
  const INCIDENT_DIAGNOSIS_SECTIONS = Object.freeze([
31
43
  { heading: 'Reproduction', key: 'reproduction' },
32
44
  { heading: 'Hypotheses', key: 'hypotheses' },
@@ -63,10 +75,18 @@ function rowToChange(row) {
63
75
  provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json'),
64
76
  baseline_bypass: row.baseline_bypass_json
65
77
  ? parseJson(row.baseline_bypass_json, 'baseline_bypass_json') : null,
78
+ contract: parseJson(row.contract_json || '{}', 'contract_json'),
79
+ classification: parseJson(row.classification_json || '{}', 'classification_json'),
80
+ research_disposition: parseJson(
81
+ row.research_disposition_json || '{}', 'research_disposition_json',
82
+ ),
66
83
  };
67
84
  delete change.docs_impact_json;
68
85
  delete change.provider_refs_json;
69
86
  delete change.baseline_bypass_json;
87
+ delete change.contract_json;
88
+ delete change.classification_json;
89
+ delete change.research_disposition_json;
70
90
  return change;
71
91
  }
72
92
 
@@ -88,6 +108,140 @@ function normalizeProviderRefs(value) {
88
108
  return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
89
109
  }
90
110
 
111
+ function requiredText(value, field) {
112
+ const text = typeof value === 'string' ? value.trim() : '';
113
+ if (!text) throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `${field} is required`);
114
+ return text;
115
+ }
116
+
117
+ function stringList(value, field, { allowEmpty = false } = {}) {
118
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0)
119
+ || value.some((item) => typeof item !== 'string' || !item.trim())) {
120
+ throw new ChangeWorkflowError(
121
+ 'CHANGE_CONTRACT_REQUIRED', `${field} must be ${allowEmpty ? 'an' : 'a non-empty'} array of strings`,
122
+ );
123
+ }
124
+ return [...new Set(value.map((item) => item.trim()))];
125
+ }
126
+
127
+ function normalizeChangeContract(value) {
128
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
129
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract is required');
130
+ }
131
+ if (!Array.isArray(value.acceptance) || value.acceptance.length === 0) {
132
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.acceptance is required');
133
+ }
134
+ const ids = new Set();
135
+ const acceptance = value.acceptance.map((item, index) => {
136
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
137
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `contract.acceptance[${index}] is invalid`);
138
+ }
139
+ const id = requiredText(item.id, `contract.acceptance[${index}].id`);
140
+ if (!CHANGE_ID.test(id) || ids.has(id)) {
141
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `acceptance id is invalid or duplicated: ${id}`);
142
+ }
143
+ ids.add(id);
144
+ return {
145
+ id,
146
+ criterion: requiredText(item.criterion, `contract.acceptance[${index}].criterion`),
147
+ verification: requiredText(item.verification, `contract.acceptance[${index}].verification`),
148
+ };
149
+ });
150
+ const recovery = value.recovery;
151
+ if (!recovery || typeof recovery !== 'object' || Array.isArray(recovery)) {
152
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.recovery is required');
153
+ }
154
+ if (!Array.isArray(value.unresolved_decisions)) {
155
+ throw new ChangeWorkflowError(
156
+ 'CHANGE_CONTRACT_REQUIRED', 'contract.unresolved_decisions must be an explicit array',
157
+ );
158
+ }
159
+ const decisions = value.unresolved_decisions.map((item, index) => {
160
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
161
+ throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `unresolved decision ${index} is invalid`);
162
+ }
163
+ return {
164
+ id: requiredText(item.id, `contract.unresolved_decisions[${index}].id`),
165
+ summary: requiredText(item.summary, `contract.unresolved_decisions[${index}].summary`),
166
+ blocking: item.blocking === true,
167
+ owner: item.owner == null ? null : requiredText(item.owner, `contract.unresolved_decisions[${index}].owner`),
168
+ };
169
+ });
170
+ return {
171
+ outcome: requiredText(value.outcome, 'contract.outcome'),
172
+ acceptance,
173
+ non_goals: stringList(value.non_goals, 'contract.non_goals'),
174
+ public_seams: stringList(value.public_seams, 'contract.public_seams'),
175
+ recovery: {
176
+ strategy: requiredText(recovery.strategy, 'contract.recovery.strategy'),
177
+ verification: requiredText(recovery.verification, 'contract.recovery.verification'),
178
+ },
179
+ unresolved_decisions: decisions,
180
+ };
181
+ }
182
+
183
+ function normalizeClassification(value, kind) {
184
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
185
+ throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', 'classification is required');
186
+ }
187
+ const flags = stringList(value.risk_flags, 'classification.risk_flags', { allowEmpty: true });
188
+ const invalid = flags.filter((flag) => !CHANGE_RISK_FLAGS.has(flag));
189
+ if (invalid.length > 0) {
190
+ throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', `unknown risk flags: ${invalid.join(', ')}`);
191
+ }
192
+ if (kind === 'quick') {
193
+ const excluded = flags.filter((flag) => QUICK_EXCLUSION_FLAGS.has(flag));
194
+ if (excluded.length > 0) {
195
+ throw new ChangeWorkflowError(
196
+ 'CHANGE_PROFILE_ESCALATION_REQUIRED',
197
+ `quick changes cannot carry these risk flags: ${excluded.join(', ')}`,
198
+ );
199
+ }
200
+ }
201
+ return {
202
+ rationale: requiredText(value.rationale, 'classification.rationale'),
203
+ risk_flags: flags,
204
+ };
205
+ }
206
+
207
+ function normalizeResearchDisposition(value, kind) {
208
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
209
+ throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research_disposition is required');
210
+ }
211
+ const status = String(value.status || '').trim();
212
+ if (!RESEARCH_DISPOSITIONS.has(status)) {
213
+ throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research disposition status is invalid');
214
+ }
215
+ const selectedSteps = stringList(
216
+ value.selected_steps || [], 'research_disposition.selected_steps', { allowEmpty: true },
217
+ );
218
+ const mode = value.mode == null ? null : String(value.mode).trim();
219
+ if (status === 'none' && (mode !== null || selectedSteps.length > 0)) {
220
+ throw new ChangeWorkflowError(
221
+ 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research status none cannot select a mode or steps',
222
+ );
223
+ }
224
+ if (status !== 'none' && !CHANGE_RESEARCH_MODES.has(mode)) {
225
+ throw new ChangeWorkflowError(
226
+ 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'bounded or required research needs a supported mode',
227
+ );
228
+ }
229
+ if (mode === 'custom' && selectedSteps.length === 0) {
230
+ throw new ChangeWorkflowError(
231
+ 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'custom research needs selected_steps',
232
+ );
233
+ }
234
+ if (kind === 'quick' && status !== 'none') {
235
+ throw new ChangeWorkflowError(
236
+ 'CHANGE_PROFILE_ESCALATION_REQUIRED', 'a change that requires research cannot use the quick profile',
237
+ );
238
+ }
239
+ return {
240
+ status, mode, selected_steps: selectedSteps,
241
+ rationale: requiredText(value.rationale, 'research_disposition.rationale'),
242
+ };
243
+ }
244
+
91
245
  function normalizeBaselineBypass(value) {
92
246
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
93
247
  throw new ChangeWorkflowError(
@@ -130,6 +284,138 @@ function safeRelativePath(rootDir, candidate) {
130
284
  return target;
131
285
  }
132
286
 
287
+ function readReconciliationManifest(db, change, input, rootDir) {
288
+ const relative = typeof input.reconciliation_path === 'string'
289
+ ? input.reconciliation_path.trim() : '';
290
+ if (!relative) {
291
+ throw new ChangeWorkflowError(
292
+ 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation_path is required',
293
+ );
294
+ }
295
+ let file = safeRelativePath(rootDir, relative);
296
+ const artifactRoot = path.resolve(rootDir, change.artifact_root);
297
+ if (!file.startsWith(`${artifactRoot}${path.sep}`)) {
298
+ throw new ChangeWorkflowError(
299
+ 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED',
300
+ 'reconciliation manifest must be a file inside the active change artifact root',
301
+ );
302
+ }
303
+ let manifest;
304
+ if (!fs.existsSync(file)) {
305
+ const pending = archiveJournal.listArchiveIntents(rootDir).find(
306
+ (item) => !item.error && item.intent.change_id === change.id
307
+ && item.intent.reconciliation_path === relative,
308
+ );
309
+ if (pending) {
310
+ const suffix = path.relative(pending.intent.source, relative);
311
+ file = path.join(pending.destination, suffix);
312
+ manifest = pending.intent.reconciliation_manifest;
313
+ }
314
+ }
315
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
316
+ throw new ChangeWorkflowError(
317
+ 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation manifest file is missing',
318
+ );
319
+ }
320
+ try { manifest ||= JSON.parse(fs.readFileSync(file, 'utf8')); }
321
+ catch (error) {
322
+ throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
323
+ }
324
+ if (!validateReconciliationSchema(manifest)) {
325
+ throw new ChangeWorkflowError(
326
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
327
+ `reconciliation manifest does not satisfy its published schema: ${reconciliationAjv.errorsText(validateReconciliationSchema.errors)}`,
328
+ validateReconciliationSchema.errors,
329
+ );
330
+ }
331
+ const baseline = baselines.readBaseline(db);
332
+ const updates = Array.isArray(input.baseline_updates) ? input.baseline_updates : [];
333
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
334
+ || manifest.$schema !== 'ultra-baseline-reconciliation-v1'
335
+ || manifest.change_id !== change.id || manifest.baseline_id !== (baseline?.id || null)
336
+ || JSON.stringify(manifest.baseline_updates) !== JSON.stringify(updates)
337
+ || !Array.isArray(manifest.semantic_changes)
338
+ || !Array.isArray(manifest.resolved_gap_ids)
339
+ || !Array.isArray(manifest.resolved_unknowns)
340
+ || !Array.isArray(manifest.verification) || manifest.verification.length === 0) {
341
+ throw new ChangeWorkflowError(
342
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'reconciliation manifest shape or authority binding is invalid',
343
+ );
344
+ }
345
+ if (manifest.verification.some((item) => (
346
+ !item || typeof item !== 'object' || item.status !== 'pass'
347
+ || !String(item.name || '').trim() || !String(item.command || '').trim()
348
+ || !String(item.evidence || '').trim()
349
+ ))) {
350
+ throw new ChangeWorkflowError(
351
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'every reconciliation verification must be an evidenced pass',
352
+ );
353
+ }
354
+ if (updates.length === 0) {
355
+ if (manifest.semantic_changes.length > 0
356
+ || String(manifest.semantic_no_change_reason || '').trim().length < 3) {
357
+ throw new ChangeWorkflowError(
358
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
359
+ 'no-change reconciliation requires an explicit semantic_no_change_reason and no semantic changes',
360
+ );
361
+ }
362
+ } else if (manifest.semantic_changes.length === 0) {
363
+ throw new ChangeWorkflowError(
364
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'baseline updates require semantic change records',
365
+ );
366
+ }
367
+ const specRefs = new Map((baseline?.spec_refs || []).map((ref) => [ref.path, ref]));
368
+ const semanticPaths = new Set();
369
+ for (const item of manifest.semantic_changes) {
370
+ if (!item || typeof item !== 'object' || !CHANGE_ID.test(String(item.id || ''))
371
+ || !['add', 'update'].includes(item.action)
372
+ || !/^[0-9a-f]{64}$/.test(String(item.after_digest || ''))) {
373
+ throw new ChangeWorkflowError(
374
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'semantic change record is invalid',
375
+ );
376
+ }
377
+ let resolved;
378
+ try { resolved = workflows.resolveProjectSourceRef(rootDir, item.source_ref, 'semantic_change.source_ref'); }
379
+ catch (error) {
380
+ throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
381
+ }
382
+ semanticPaths.add(resolved.relative);
383
+ if (!updates.includes(resolved.relative) || resolved.digest !== item.after_digest) {
384
+ throw new ChangeWorkflowError(
385
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
386
+ `semantic change digest or baseline update binding is invalid: ${item.source_ref}`,
387
+ );
388
+ }
389
+ const before = specRefs.get(resolved.relative)?.digest || null;
390
+ if ((item.before_digest || null) !== before || item.action !== (before ? 'update' : 'add')) {
391
+ throw new ChangeWorkflowError(
392
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
393
+ `semantic change before-state is invalid: ${item.source_ref}`,
394
+ );
395
+ }
396
+ }
397
+ const missingSemanticUpdates = updates.filter((update) => !semanticPaths.has(update));
398
+ if (missingSemanticUpdates.length > 0) {
399
+ throw new ChangeWorkflowError(
400
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
401
+ `baseline updates have no semantic record: ${missingSemanticUpdates.join(', ')}`,
402
+ );
403
+ }
404
+ const gapIds = new Set((baseline?.gaps || []).map((gap) => gap.id));
405
+ const unknowns = new Set((baseline?.unknowns || []).map((unknown) => unknown.summary));
406
+ if (manifest.resolved_gap_ids.some((id) => !gapIds.has(id))
407
+ || manifest.resolved_unknowns.some((summary) => !unknowns.has(summary))) {
408
+ throw new ChangeWorkflowError(
409
+ 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'resolved gap or unknown does not exist in baseline authority',
410
+ );
411
+ }
412
+ const serialized = fs.readFileSync(file);
413
+ return {
414
+ manifest, relative, file,
415
+ digest: crypto.createHash('sha256').update(serialized).digest('hex'),
416
+ };
417
+ }
418
+
133
419
  function readChange(db, id) {
134
420
  return rowToChange(db.prepare('SELECT * FROM changes WHERE id = ?').get(id));
135
421
  }
@@ -151,11 +437,25 @@ function writeIntent(file, change) {
151
437
  `- Kind: \`${change.kind}\``,
152
438
  `- Base commit: \`${change.base_commit || 'unavailable'}\``,
153
439
  `- Documentation impact: \`${impact.status}\``,
440
+ `- Research disposition: \`${change.research_disposition.status}\``,
441
+ `- Alignment checkpoint: \`${change.alignment_thread_id || 'not-required'}\``,
154
442
  '',
155
443
  '## Intent',
156
444
  '',
157
445
  change.intent,
158
446
  '',
447
+ '## Outcome', '', change.contract.outcome, '',
448
+ '## Acceptance', '',
449
+ ...change.contract.acceptance.flatMap((item) => [
450
+ `### ${item.id}`, '', item.criterion, '', `Verification: \`${item.verification}\``, '',
451
+ ]),
452
+ '## Public seams', '', ...change.contract.public_seams.map((item) => `- ${item}`), '',
453
+ '## Non-goals', '', ...change.contract.non_goals.map((item) => `- ${item}`), '',
454
+ '## Recovery', '', change.contract.recovery.strategy, '',
455
+ `Verification: \`${change.contract.recovery.verification}\``, '',
456
+ '## Classification', '', change.classification.rationale, '',
457
+ `Risk flags: ${change.classification.risk_flags.join(', ') || 'none'}`, '',
458
+ '## Research routing', '', change.research_disposition.rationale, '',
159
459
  ];
160
460
  if (impact.rationale) lines.push('## Documentation rationale', '', impact.rationale, '');
161
461
  if (change.baseline_bypass) {
@@ -240,23 +540,27 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
240
540
  ? change.provider_refs
241
541
  : normalizeProviderRefs(input.provider_refs);
242
542
  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) }));
543
+ 'SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
544
+ ).all(change.id).map((task) => ops.readTask(db, task.id));
247
545
  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
546
  const spine = contextSpine.compileRoleContext(db, { input, change, tasks, rootDir });
547
+ const snapshotId = `ctx-${crypto.randomUUID().slice(0, 12)}`;
251
548
  const manifest = {
252
549
  schema_version: '2.0',
550
+ snapshot_id: snapshotId,
253
551
  generated_at: nowIso(),
254
552
  change: {
255
553
  id: change.id, title: change.title, kind: change.kind, status: change.status,
256
554
  intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
257
555
  baseline_bypass: change.baseline_bypass,
556
+ contract: change.contract, classification: change.classification,
557
+ research_disposition: change.research_disposition,
558
+ },
559
+ git: {
560
+ head: spine.resume.git_head,
561
+ worktree_state: spine.resume.worktree_state,
562
+ worktree_digest: spine.resume.worktree_digest,
258
563
  },
259
- git: { head },
260
564
  tasks,
261
565
  selected_task: spine.selected_task,
262
566
  specs,
@@ -270,11 +574,11 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
270
574
  resume: spine.resume,
271
575
  providers,
272
576
  provider_boundary: 'metadata references only; memory and code graph content remain external',
273
- tool_policy: { allowed_paths: allowedPaths },
274
577
  };
275
578
  const artifactDir = path.resolve(rootDir, change.artifact_root);
276
579
  fs.mkdirSync(artifactDir, { recursive: true });
277
- const manifestPath = path.join(artifactDir, 'context-manifest.json');
580
+ const manifestPath = path.join(artifactDir, 'contexts', `${snapshotId}.json`);
581
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
278
582
  const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
279
583
  fs.writeFileSync(manifestPath, serialized);
280
584
  const hash = crypto.createHash('sha256').update(serialized).digest('hex');
@@ -288,8 +592,8 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
288
592
  token_estimate, token_budget)
289
593
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
290
594
  ).run(
291
- `ctx-${crypto.randomUUID().slice(0, 12)}`, change.id, input.task_id || null,
292
- head, JSON.stringify(providers), relative, hash,
595
+ snapshotId, change.id, input.task_id || null,
596
+ spine.resume.git_head, JSON.stringify(providers), relative, hash,
293
597
  spine.role, spine.gate, spine.next_action, spine.readiness.status,
294
598
  JSON.stringify(spine.readiness.blockers), JSON.stringify(spine),
295
599
  spine.context.token_estimate, spine.context.budget.max_tokens,
@@ -298,7 +602,9 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
298
602
  change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
299
603
  artifactPath: relative, contentHash: hash,
300
604
  metadata: {
301
- git_head: head, role: spine.role, gate: spine.gate,
605
+ git_head: spine.resume.git_head,
606
+ worktree_digest: spine.resume.worktree_digest,
607
+ role: spine.role, gate: spine.gate,
302
608
  readiness: spine.readiness.status, token_estimate: spine.context.token_estimate,
303
609
  },
304
610
  });
@@ -314,11 +620,35 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
314
620
  ops.appendEventInTx(db, {
315
621
  type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
316
622
  payload: {
317
- manifest_path: relative, manifest_hash: hash, git_head: head,
623
+ manifest_path: relative, manifest_hash: hash, git_head: spine.resume.git_head,
624
+ worktree_digest: spine.resume.worktree_digest,
318
625
  role: spine.role, gate: spine.gate, readiness: spine.readiness.status,
319
626
  next_action: spine.next_action,
320
627
  },
321
628
  });
629
+ const linkedWorkflow = db.prepare(
630
+ `SELECT id FROM workflow_runs
631
+ WHERE kind = 'change' AND change_id = ? AND current_step = 'compile-context'
632
+ AND status IN ('active', 'blocked')
633
+ ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
634
+ ).get(change.id);
635
+ if (linkedWorkflow) {
636
+ if (spine.readiness.status === 'ready') {
637
+ workflows.recordWorkflowStep(db, {
638
+ id: linkedWorkflow.id, step_id: 'compile-context', status: 'completed',
639
+ evidence: [{
640
+ kind: 'context', ref: relative,
641
+ summary: `Role ${spine.role} context is ready for ${input.task_id || 'change planning'}.`,
642
+ }],
643
+ outputs: [{ path: relative, kind: 'context-manifest' }],
644
+ }, { rootDir });
645
+ } else {
646
+ workflows.recordWorkflowStep(db, {
647
+ id: linkedWorkflow.id, step_id: 'compile-context', status: 'blocked',
648
+ blockers: spine.readiness.blockers,
649
+ }, { rootDir });
650
+ }
651
+ }
322
652
  });
323
653
  return { manifest, context_manifest_path: manifestPath, manifest_hash: hash };
324
654
  }
@@ -332,7 +662,7 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
332
662
  'id plus non-blank title and intent of at least three characters are required',
333
663
  );
334
664
  }
335
- if (!REQUIRED_EVIDENCE[input.kind]) {
665
+ if (!CHANGE_KINDS.has(input.kind)) {
336
666
  throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
337
667
  }
338
668
  if (input.baseline_bypass !== undefined && input.kind !== 'incident') {
@@ -357,8 +687,46 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
357
687
  'VALIDATION_ERROR', 'baseline_bypass is not valid while the project baseline is healthy',
358
688
  );
359
689
  }
690
+ const alignmentGate = decisions.decisionGate(
691
+ db, { baseline_id: baselineHealth.baseline?.id || null }, { rootDir },
692
+ );
693
+ if (!alignmentGate.ready) {
694
+ throw new ChangeWorkflowError(
695
+ 'CHANGE_ALIGNMENT_REQUIRED',
696
+ `resolve and confirm the active baseline alignment before creating a change: ${alignmentGate.blockers.join(', ')}`,
697
+ { blockers: alignmentGate.blockers, thread_id: alignmentGate.thread?.id || null },
698
+ );
699
+ }
360
700
  const docsImpact = normalizeDocsImpact(input.docs_impact);
361
701
  const providers = normalizeProviderRefs(input.provider_refs);
702
+ const contract = normalizeChangeContract(input.contract);
703
+ const classification = normalizeClassification(input.classification, input.kind);
704
+ const researchDisposition = normalizeResearchDisposition(input.research_disposition, input.kind);
705
+ let alignmentThread = null;
706
+ if (input.alignment_thread_id !== undefined) {
707
+ try {
708
+ alignmentThread = decisions.assertConfirmedDecisionCheckpoint(
709
+ db, input.alignment_thread_id, { rootDir, requireArtifact: true },
710
+ );
711
+ } catch (error) {
712
+ throw new ChangeWorkflowError(
713
+ 'CHANGE_ALIGNMENT_REQUIRED',
714
+ `alignment_thread_id must name a current artifact-bound checkpoint: ${error.message}`,
715
+ );
716
+ }
717
+ if (alignmentThread.change_id) {
718
+ throw new ChangeWorkflowError(
719
+ 'CHANGE_ALIGNMENT_REQUIRED', `alignment thread already belongs to ${alignmentThread.change_id}`,
720
+ );
721
+ }
722
+ if (!baselineHealth.baseline?.id || alignmentThread.baseline_id !== baselineHealth.baseline.id
723
+ || alignmentThread.workflow_run_id) {
724
+ throw new ChangeWorkflowError(
725
+ 'CHANGE_ALIGNMENT_REQUIRED',
726
+ 'change alignment must be bound only to the current baseline before change creation',
727
+ );
728
+ }
729
+ }
362
730
  const artifactRoot = path.join('.ultra', 'changes', 'active', input.id);
363
731
  const artifactDir = path.resolve(rootDir, artifactRoot);
364
732
  if (fs.existsSync(artifactDir)) {
@@ -368,6 +736,8 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
368
736
  const row = {
369
737
  id: input.id, title, kind: input.kind, status: 'active',
370
738
  intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
739
+ contract, classification, research_disposition: researchDisposition,
740
+ alignment_thread_id: alignmentThread?.id || null,
371
741
  base_commit: input.base_commit || gitHead(rootDir), artifact_root: artifactRoot,
372
742
  created_at: ts, updated_at: ts,
373
743
  };
@@ -376,14 +746,23 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
376
746
  db.prepare(
377
747
  `INSERT INTO changes
378
748
  (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
749
+ baseline_bypass_json, contract_json, classification_json,
750
+ research_disposition_json, alignment_thread_id, base_commit, artifact_root,
751
+ created_at, updated_at)
752
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
381
753
  ).run(
382
754
  row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
383
755
  JSON.stringify(row.provider_refs), row.baseline_bypass
384
756
  ? JSON.stringify(row.baseline_bypass) : null,
757
+ JSON.stringify(row.contract), JSON.stringify(row.classification),
758
+ JSON.stringify(row.research_disposition), row.alignment_thread_id,
385
759
  row.base_commit, row.artifact_root, ts, ts,
386
760
  );
761
+ if (row.alignment_thread_id) {
762
+ db.prepare(
763
+ 'UPDATE decision_threads SET change_id = ?, updated_at = ? WHERE id = ?',
764
+ ).run(row.id, ts, row.alignment_thread_id);
765
+ }
387
766
  fs.mkdirSync(artifactDir, { recursive: true });
388
767
  const intentPath = path.join(artifactDir, 'intent.md');
389
768
  writeIntent(intentPath, row);
@@ -404,12 +783,46 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
404
783
  }
405
784
  ops.appendEventInTx(db, {
406
785
  type: 'change_created', change_id: row.id,
407
- payload: { kind: row.kind, artifact_root: row.artifact_root },
786
+ payload: {
787
+ kind: row.kind, artifact_root: row.artifact_root,
788
+ alignment_thread_id: row.alignment_thread_id,
789
+ },
408
790
  });
791
+ let workflow = workflows.startWorkflow(db, {
792
+ kind: 'change', baseline_id: baselineHealth.baseline?.id || null,
793
+ change_id: row.id, subject: `Establish executable readiness for ${row.title}.`,
794
+ metadata: { change_kind: row.kind, base_commit: row.base_commit },
795
+ }, { rootDir });
409
796
  const context = compileContext(db, { id: row.id }, { rootDir });
797
+ workflow = workflows.recordWorkflowStep(db, {
798
+ id: workflow.id, step_id: 'bind-baseline', status: 'completed',
799
+ evidence: [{
800
+ kind: 'baseline', ref: baselineHealth.baseline?.id || 'incident-break-glass',
801
+ summary: baselineHealth.status === 'pass'
802
+ ? `Bound ready baseline at ${baselineHealth.baseline.repository_revision}.`
803
+ : 'Bound the approved incident break-glass authority.',
804
+ }],
805
+ }, { rootDir });
806
+ workflow = workflows.recordWorkflowStep(db, {
807
+ id: workflow.id, step_id: 'classify-change', status: 'completed',
808
+ decisions: [{
809
+ kind: 'change_kind', value: row.kind,
810
+ rationale: row.classification.rationale, risk_flags: row.classification.risk_flags,
811
+ }],
812
+ }, { rootDir });
813
+ workflow = workflows.recordWorkflowStep(db, {
814
+ id: workflow.id, step_id: 'record-intent', status: 'completed',
815
+ evidence: [{ kind: 'intent', ref: path.relative(rootDir, intentPath), summary: row.intent }],
816
+ outputs: [{ path: path.relative(rootDir, intentPath), kind: 'change-intent' }],
817
+ decisions: [{
818
+ kind: 'change_contract', acceptance_ids: row.contract.acceptance.map((item) => item.id),
819
+ research_disposition: row.research_disposition,
820
+ }],
821
+ }, { rootDir });
410
822
  return {
411
823
  change: readChange(db, row.id), intent_path: intentPath,
412
824
  context_manifest_path: context.context_manifest_path,
825
+ workflow,
413
826
  };
414
827
  });
415
828
  } catch (error) {
@@ -428,15 +841,14 @@ function assertTaskCreationAllowed(db, input = {}, { rootDir = process.cwd() } =
428
841
  }
429
842
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
430
843
  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
- }
844
+ const gate = change
845
+ ? contextSpine.baselineGateForChange(db, change, baselineHealth)
846
+ : { blockers: baselineHealth.blockers };
847
+ if (gate.blockers.length === 0) return;
436
848
  throw new ChangeWorkflowError(
437
849
  '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 },
850
+ `task creation requires healthy baseline authority or an approved incident break-glass record: ${gate.blockers.join(', ')}`,
851
+ { blockers: gate.blockers, change_id: input.change_id || null },
440
852
  );
441
853
  }
442
854
 
@@ -451,7 +863,9 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
451
863
  throw new ChangeWorkflowError('VALIDATION_ERROR', `change field ${field} is not patchable`);
452
864
  }
453
865
  }
454
- const syncIntent = ['title', 'intent', 'docs_impact'].some((field) => patch[field] !== undefined);
866
+ const syncIntent = [
867
+ 'title', 'intent', 'docs_impact', 'contract', 'classification', 'research_disposition',
868
+ ].some((field) => patch[field] !== undefined);
455
869
  const intentPath = path.resolve(rootDir, current.artifact_root, 'intent.md');
456
870
  const previousIntent = syncIntent && fs.existsSync(intentPath)
457
871
  ? fs.readFileSync(intentPath)
@@ -476,6 +890,17 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
476
890
  if (patch.provider_refs !== undefined) {
477
891
  sets.push('provider_refs_json = ?'); values.push(JSON.stringify(normalizeProviderRefs(patch.provider_refs)));
478
892
  }
893
+ if (patch.contract !== undefined) {
894
+ sets.push('contract_json = ?'); values.push(JSON.stringify(normalizeChangeContract(patch.contract)));
895
+ }
896
+ if (patch.classification !== undefined) {
897
+ sets.push('classification_json = ?');
898
+ values.push(JSON.stringify(normalizeClassification(patch.classification, current.kind)));
899
+ }
900
+ if (patch.research_disposition !== undefined) {
901
+ sets.push('research_disposition_json = ?');
902
+ values.push(JSON.stringify(normalizeResearchDisposition(patch.research_disposition, current.kind)));
903
+ }
479
904
  if (patch.status !== undefined) {
480
905
  const allowed = CHANGE_TRANSITIONS[current.status] || new Set();
481
906
  if (!allowed.has(patch.status)) {
@@ -522,65 +947,87 @@ function filesUnder(dir) {
522
947
  return out;
523
948
  }
524
949
 
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;
950
+ function baselineConvergenceBlockers(db, change, rootDir) {
951
+ const baselineHealth = baselines.inspectBaseline(db, { rootDir });
952
+ return contextSpine.baselineGateForChange(db, change, baselineHealth).blockers;
563
953
  }
564
954
 
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 [];
955
+ function deriveConvergenceEvidence(db, change, tasks, workflowGate, diagnosis, rootDir) {
956
+ const devRuns = db.prepare(
957
+ `SELECT id, task_id, summary_json FROM workflow_runs
958
+ WHERE kind = 'dev' AND change_id = ? AND status = 'completed'
959
+ ORDER BY completed_at ASC, rowid ASC`,
960
+ ).all(change.id).map((row) => ({
961
+ id: row.id, task_id: row.task_id, summary: parseJson(row.summary_json, 'workflow_runs.summary_json'),
962
+ }));
963
+ const deltaPaths = filesUnder(path.join(rootDir, change.artifact_root, 'delta'))
964
+ .map((file) => path.relative(rootDir, file));
965
+ const appliedLearning = specLearning.listSpecLearning(db, change.id)
966
+ .filter((candidate) => candidate.status === 'applied')
967
+ .map((candidate) => candidate.target_ref);
968
+ const rows = [
969
+ {
970
+ category: 'diff', status: 'pass',
971
+ evidence: `Completed dev workflows: ${devRuns.map((run) => `${run.task_id}:${run.id}`).join(', ')}`,
972
+ refs: devRuns.map((run) => run.id),
973
+ },
974
+ {
975
+ category: 'tests', status: 'pass',
976
+ evidence: `DB-derived passing test workflow ${workflowGate.test_workflow_id}.`,
977
+ refs: [workflowGate.test_summary.report_path, workflowGate.test_summary.report_digest].filter(Boolean),
978
+ regression_signal: workflowGate.test_summary.regression_signal || null,
979
+ },
980
+ {
981
+ category: 'spec', status: 'pass',
982
+ evidence: deltaPaths.length > 0
983
+ ? `Current delta artifacts: ${deltaPaths.join(', ')}`
984
+ : `Change intent and resolved specification learning for ${change.id}.`,
985
+ refs: [...deltaPaths, ...appliedLearning],
986
+ },
987
+ {
988
+ category: 'docs', status: 'pass',
989
+ evidence: change.docs_impact.status === 'required'
990
+ ? `Declared documentation files: ${change.docs_impact.files.join(', ')}`
991
+ : `No documentation change: ${change.docs_impact.rationale}`,
992
+ refs: change.docs_impact.files,
993
+ },
994
+ ...['spec_fidelity', 'engineering_standards'].map((axis) => ({
995
+ category: 'review', axis, status: 'pass',
996
+ evidence: `DB-derived ${axis} verdict from ${workflowGate.review_workflow_id}.`,
997
+ refs: workflowGate.review_summary.axes?.[axis]?.evidence_refs || [],
998
+ })),
999
+ ];
1000
+ if (diagnosis.artifact) {
1001
+ rows.unshift({
1002
+ category: 'diagnosis', status: 'pass',
1003
+ evidence: `Structured incident diagnosis ${diagnosis.artifact.artifactPath}.`,
1004
+ refs: [diagnosis.artifact.artifactPath, diagnosis.artifact.contentHash],
1005
+ });
570
1006
  }
571
- if (!baseline) return ['BASELINE_MISSING'];
572
- if (baseline.mode === 'migrated') return ['BASELINE_MIGRATION_REVIEW_REQUIRED'];
573
- return [`BASELINE_NOT_READY:${baseline.status}`];
1007
+ return rows;
574
1008
  }
575
1009
 
576
1010
  function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1011
+ if (Object.hasOwn(input || {}, 'evidence')) {
1012
+ throw new ChangeWorkflowError(
1013
+ 'CONVERGENCE_EVIDENCE_AUTHORITY_VIOLATION',
1014
+ 'change convergence evidence is derived from DB-backed dev, test, review, specification, and documentation authority',
1015
+ );
1016
+ }
577
1017
  const change = readChange(db, input.id);
578
1018
  if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
579
1019
  if (!['active', 'blocked'].includes(change.status)) {
580
1020
  throw new ChangeWorkflowError('CHANGE_NOT_CONVERGEABLE', `change ${input.id} is ${change.status}`);
581
1021
  }
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));
1022
+ const tasks = db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
1023
+ .all(change.id).map((task) => ops.readTask(db, task.id));
1024
+ const blockers = new Set();
1025
+ let workflowGate = null;
1026
+ try {
1027
+ workflowGate = workflows.validateDeliveryPrerequisites(db, { change_id: change.id }, rootDir);
1028
+ } catch (error) {
1029
+ blockers.add(error.code || 'WORKFLOW_GATE_UNAVAILABLE');
1030
+ }
584
1031
  for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
585
1032
  if (tasks.length === 0) blockers.add('NO_TASKS');
586
1033
  if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
@@ -624,11 +1071,24 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
624
1071
  if (filesUnder(path.join(artifactDir, 'delta')).length === 0) blockers.add('SPEC_DELTA_MISSING');
625
1072
  if (!fs.existsSync(path.join(artifactDir, 'plan.md'))) blockers.add('CHANGE_PLAN_MISSING');
626
1073
  }
627
- const manifestPath = path.join(artifactDir, 'context-manifest.json');
628
- if (!fs.existsSync(manifestPath)) blockers.add('CONTEXT_MANIFEST_MISSING');
1074
+ const latestContext = db.prepare(
1075
+ `SELECT manifest_path, manifest_hash FROM context_snapshots
1076
+ WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
1077
+ ).get(change.id);
1078
+ const manifestPath = latestContext?.manifest_path
1079
+ ? safeRelativePath(rootDir, latestContext.manifest_path)
1080
+ : null;
1081
+ if (!manifestPath || !fs.existsSync(manifestPath)) blockers.add('CONTEXT_MANIFEST_MISSING');
629
1082
  else {
630
1083
  const manifest = parseJson(fs.readFileSync(manifestPath, 'utf8'), 'context-manifest.json');
1084
+ const manifestHash = crypto.createHash('sha256').update(fs.readFileSync(manifestPath)).digest('hex');
1085
+ if (latestContext.manifest_hash !== manifestHash) blockers.add('CONTEXT_MANIFEST_STALE');
631
1086
  if (manifest.git && manifest.git.head !== gitHead(rootDir)) blockers.add('CONTEXT_HEAD_STALE');
1087
+ const checkout = baselines.gitWorktreeSnapshot(rootDir, ['.']);
1088
+ if (manifest.git?.worktree_digest && checkout.digest
1089
+ && manifest.git.worktree_digest !== checkout.digest) {
1090
+ blockers.add('CONTEXT_WORKTREE_STALE');
1091
+ }
632
1092
  const manifestTasks = (manifest.tasks || []).map((task) => task.id).sort();
633
1093
  const currentTasks = tasks.map((task) => task.id).sort();
634
1094
  if (JSON.stringify(manifestTasks) !== JSON.stringify(currentTasks)) blockers.add('CONTEXT_TASK_SET_STALE');
@@ -652,10 +1112,13 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
652
1112
  return { ready: false, status: 'blocked', blockers: blockerList };
653
1113
  }
654
1114
 
1115
+ const derivedEvidence = deriveConvergenceEvidence(
1116
+ db, change, tasks, workflowGate, diagnosis, rootDir,
1117
+ );
655
1118
  const verificationPath = path.join(artifactDir, 'verification.md');
656
1119
  const lines = [
657
1120
  `# Verification: ${change.title}`, '',
658
- ...input.evidence.flatMap((row) => [
1121
+ ...derivedEvidence.flatMap((row) => [
659
1122
  `## ${row.category}${row.axis ? `:${row.axis}` : ''}`, '',
660
1123
  `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
661
1124
  ]),
@@ -668,11 +1131,11 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
668
1131
  upsertArtifact(db, {
669
1132
  change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
670
1133
  contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
671
- metadata: { evidence: input.evidence },
1134
+ metadata: { evidence: derivedEvidence },
672
1135
  });
673
1136
  ops.appendEventInTx(db, {
674
1137
  type: 'change_converged', change_id: change.id,
675
- payload: { evidence_categories: input.evidence.map((row) => row.category) },
1138
+ payload: { evidence_categories: derivedEvidence.map((row) => row.category) },
676
1139
  });
677
1140
  });
678
1141
  return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
@@ -711,6 +1174,36 @@ function finalizeArchive(db, intent, { rootDir }) {
711
1174
  db.prepare('UPDATE artifacts SET path = ?, updated_at = ? WHERE id = ?')
712
1175
  .run(archivedPath, nowIso(), artifact.id);
713
1176
  }
1177
+ const workflowOutputs = db.prepare(
1178
+ `SELECT ws.run_id, ws.step_id, ws.outputs_json
1179
+ FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
1180
+ WHERE wr.change_id = ?`,
1181
+ ).all(change.id);
1182
+ for (const step of workflowOutputs) {
1183
+ const outputs = parseJson(step.outputs_json, 'workflow_steps.outputs_json');
1184
+ let changed = false;
1185
+ const moved = outputs.map((output) => {
1186
+ if (output.path !== intent.source && !output.path.startsWith(prefix)) return output;
1187
+ changed = true;
1188
+ const suffix = path.relative(intent.source, output.path);
1189
+ return { ...output, path: suffix ? path.join(relative, suffix) : relative };
1190
+ });
1191
+ if (changed) {
1192
+ db.prepare(
1193
+ 'UPDATE workflow_steps SET outputs_json = ?, updated_at = ? WHERE run_id = ? AND step_id = ?',
1194
+ ).run(JSON.stringify(moved), nowIso(), step.run_id, step.step_id);
1195
+ }
1196
+ }
1197
+ const contextRows = db.prepare(
1198
+ 'SELECT id, manifest_path FROM context_snapshots WHERE change_id = ?',
1199
+ ).all(change.id);
1200
+ for (const snapshot of contextRows) {
1201
+ if (snapshot.manifest_path !== intent.source && !snapshot.manifest_path.startsWith(prefix)) continue;
1202
+ const suffix = path.relative(intent.source, snapshot.manifest_path);
1203
+ const archivedPath = suffix ? path.join(relative, suffix) : relative;
1204
+ db.prepare('UPDATE context_snapshots SET manifest_path = ? WHERE id = ?')
1205
+ .run(archivedPath, snapshot.id);
1206
+ }
714
1207
  const archivedSummary = path.join(destination, 'archive-summary.md');
715
1208
  upsertArtifact(db, {
716
1209
  change_id: change.id, kind: 'archive_summary',
@@ -721,6 +1214,19 @@ function finalizeArchive(db, intent, { rootDir }) {
721
1214
  no_baseline_change_reason: intent.no_baseline_change_reason,
722
1215
  },
723
1216
  });
1217
+ const reconciliationSuffix = path.relative(intent.source, intent.reconciliation_path);
1218
+ const archivedReconciliationPath = path.join(relative, reconciliationSuffix);
1219
+ upsertArtifact(db, {
1220
+ change_id: change.id, kind: 'baseline_reconciliation',
1221
+ artifactPath: archivedReconciliationPath,
1222
+ contentHash: intent.reconciliation_digest,
1223
+ metadata: {
1224
+ schema: intent.reconciliation_manifest.$schema,
1225
+ semantic_changes: intent.reconciliation_manifest.semantic_changes.map((item) => item.id),
1226
+ resolved_gap_ids: intent.reconciliation_manifest.resolved_gap_ids,
1227
+ resolved_unknowns: intent.reconciliation_manifest.resolved_unknowns,
1228
+ },
1229
+ });
724
1230
  db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
725
1231
  .run(nowIso(), change.id);
726
1232
  if (breakGlass) {
@@ -742,6 +1248,7 @@ function finalizeArchive(db, intent, { rootDir }) {
742
1248
  } else {
743
1249
  baselines.reconcileBaseline(db, {
744
1250
  baseline_updates: intent.baseline_updates, change_id: change.id,
1251
+ reconciliation: intent.reconciliation_manifest,
745
1252
  }, { rootDir });
746
1253
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
747
1254
  if (baselineHealth.blockers.length > 0) {
@@ -757,6 +1264,8 @@ function finalizeArchive(db, intent, { rootDir }) {
757
1264
  payload: {
758
1265
  archive_path: relative, baseline_updates: intent.baseline_updates,
759
1266
  no_baseline_change_reason: intent.no_baseline_change_reason,
1267
+ reconciliation_path: archivedReconciliationPath,
1268
+ reconciliation_digest: intent.reconciliation_digest,
760
1269
  baseline_bypass: breakGlass,
761
1270
  },
762
1271
  });
@@ -786,9 +1295,13 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
786
1295
  if (!['ready', 'archived'].includes(change.status)) {
787
1296
  throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
788
1297
  }
1298
+ const reconciliation = readReconciliationManifest(db, change, input, rootDir);
789
1299
  const prepared = archiveJournal.prepareArchiveMove({
790
1300
  rootDir, change, summary: String(input.summary), baselineUpdates: updates,
791
1301
  noBaselineChangeReason: noChangeReason || null,
1302
+ reconciliationPath: reconciliation.relative,
1303
+ reconciliationDigest: reconciliation.digest,
1304
+ reconciliationManifest: reconciliation.manifest,
792
1305
  });
793
1306
  let result;
794
1307
  try {