ultra-builder-pro-cli 0.25.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.ultra-template/changes/abandoned/.gitkeep +0 -0
  2. package/.ultra-template/contexts/TEMPLATE.md +94 -0
  3. package/.ultra-template/decisions/.gitkeep +1 -0
  4. package/.ultra-template/evidence/.gitkeep +1 -0
  5. package/.ultra-template/north-star.md +46 -0
  6. package/.ultra-template/research/.gitkeep +1 -0
  7. package/.ultra-template/specs/discovery.md +2 -2
  8. package/.ultra-template/tasks.json +3 -0
  9. package/.ultra-template/test-report.json +27 -0
  10. package/CHANGELOG.md +107 -1
  11. package/README.md +217 -579
  12. package/adapters/_shared/delegate-profiles/kimi-read-only.md +15 -0
  13. package/adapters/_shared/delegate-profiles/kimi-write.md +17 -0
  14. package/adapters/_shared/file-ops.cjs +76 -2
  15. package/adapters/_shared/host-profile.cjs +97 -0
  16. package/adapters/_shared/plugin-core.cjs +204 -0
  17. package/adapters/_shared/runtime-assets.cjs +31 -96
  18. package/adapters/claude.js +92 -291
  19. package/adapters/codex.js +309 -842
  20. package/adapters/grok.js +143 -1469
  21. package/adapters/kimi.js +124 -1053
  22. package/adapters/opencode.js +230 -742
  23. package/bin/delegate-worker.cjs +328 -0
  24. package/bin/delegate.cjs +463 -0
  25. package/bin/install.js +38 -11
  26. package/docs/ARCHITECTURE.md +125 -216
  27. package/docs/ARTIFACT-AUTHORITY.md +90 -268
  28. package/docs/DECISIONS.md +77 -87
  29. package/docs/PHILOSOPHY.md +208 -0
  30. package/docs/PLUGIN-ISOLATION-CONTRACT.md +65 -39
  31. package/docs/RUNTIME-COMPAT-MATRIX.md +140 -155
  32. package/docs/SKILL-AUTHORING.md +94 -73
  33. package/docs/WORKFLOW-LIFECYCLE.md +97 -189
  34. package/hooks/README.md +19 -37
  35. package/hooks/_common.py +113 -0
  36. package/hooks/adapters/codex.py +9 -16
  37. package/hooks/adapters/grok.py +5 -5
  38. package/hooks/adapters/kimi.py +8 -14
  39. package/hooks/block_dangerous_commands.py +201 -0
  40. package/hooks/compact_context.py +65 -0
  41. package/hooks/mid_workflow_recall.py +25 -0
  42. package/hooks/post_edit_guard.py +105 -0
  43. package/hooks/session_context.py +30 -0
  44. package/package.json +17 -52
  45. package/skills/ultra-change/SKILL.md +115 -50
  46. package/skills/ultra-change/references/change-contract.md +68 -3
  47. package/skills/ultra-delegate/SKILL.md +89 -0
  48. package/skills/ultra-delegate/references/delegation-contract.md +59 -0
  49. package/skills/ultra-delegate/scripts/delegate_wait.py +86 -0
  50. package/skills/ultra-deliver/SKILL.md +98 -42
  51. package/skills/ultra-deliver/references/baseline-reconciliation.md +39 -20
  52. package/skills/ultra-dev/SKILL.md +115 -62
  53. package/skills/ultra-dev/references/debugging.md +22 -0
  54. package/skills/ultra-domain-modeling/SKILL.md +79 -0
  55. package/skills/ultra-grilling/SKILL.md +118 -0
  56. package/skills/ultra-grilling/references/reframing.md +72 -0
  57. package/skills/ultra-init/SKILL.md +102 -37
  58. package/skills/ultra-init/references/brownfield-adoption.md +33 -83
  59. package/skills/ultra-init/scripts/init_project.cjs +84 -0
  60. package/skills/ultra-plan/SKILL.md +101 -68
  61. package/skills/ultra-research/SKILL.md +68 -48
  62. package/skills/ultra-research/references/00-problem-validation.md +5 -5
  63. package/skills/ultra-research/references/01-opportunity-discovery.md +3 -3
  64. package/skills/ultra-research/references/02-market-assessment.md +5 -4
  65. package/skills/ultra-research/references/03-competitive-landscape.md +3 -3
  66. package/skills/ultra-research/references/04-product-strategy.md +3 -3
  67. package/skills/ultra-research/references/05-assumptions-validation.md +5 -5
  68. package/skills/ultra-research/references/10-user-personas.md +3 -2
  69. package/skills/ultra-research/references/11-user-scenarios.md +3 -3
  70. package/skills/ultra-research/references/20-user-stories.md +4 -3
  71. package/skills/ultra-research/references/21-features-scope.md +3 -2
  72. package/skills/ultra-research/references/22-success-metrics.md +3 -3
  73. package/skills/ultra-research/references/30-architecture-context.md +3 -3
  74. package/skills/ultra-research/references/31-solution-strategy.md +3 -3
  75. package/skills/ultra-research/references/32-building-blocks.md +3 -3
  76. package/skills/ultra-research/references/40-deployment.md +3 -3
  77. package/skills/ultra-research/references/41-quality-risks.md +3 -3
  78. package/skills/ultra-research/references/99-synthesis.md +7 -7
  79. package/skills/ultra-review/SKILL.md +84 -32
  80. package/skills/ultra-review/references/code.md +17 -0
  81. package/skills/ultra-review/references/comments.md +12 -0
  82. package/skills/ultra-review/references/design.md +12 -0
  83. package/skills/ultra-review/references/errors.md +13 -0
  84. package/skills/ultra-review/references/spec.md +13 -0
  85. package/skills/ultra-review/references/tests.md +12 -0
  86. package/skills/ultra-review/references/worker-packet.md +40 -0
  87. package/skills/ultra-review/scripts/review_wait.py +26 -15
  88. package/skills/ultra-status/SKILL.md +61 -36
  89. package/skills/ultra-tdd/SKILL.md +81 -0
  90. package/skills/ultra-tdd/references/templates/README.md +20 -0
  91. package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +79 -0
  92. package/skills/ultra-tdd/references/templates/persistence-real.ts +78 -0
  93. package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +78 -0
  94. package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +73 -0
  95. package/skills/ultra-tdd/references/templates/vertical-slice.ts +66 -0
  96. package/skills/ultra-tdd/references/test-execution.md +18 -0
  97. package/skills/ultra-test/SKILL.md +75 -30
  98. package/skills/ultra-test/references/export-syntax.md +18 -0
  99. package/skills/ultra-test/scripts/worktree_digest.cjs +54 -0
  100. package/skills/ultra-think/SKILL.md +49 -27
  101. package/skills/ultra-think/references/autonomy-boundary.md +48 -0
  102. package/skills/ultra-think/references/interaction-boundary.md +18 -33
  103. package/.ultra-template/docs/research/README.md +0 -55
  104. package/.ultra-template/reports/templates/delivery-report.json +0 -17
  105. package/.ultra-template/reports/templates/test-report.json +0 -18
  106. package/.ultra-template/tasks/tasks.json +0 -15
  107. package/.ultra-template/templates/task-context.md +0 -19
  108. package/adapters/_shared/codex-assets.cjs +0 -725
  109. package/adapters/_shared/codex-ultra-tools-entry.cjs +0 -11
  110. package/adapters/_shared/interaction-contract.cjs +0 -129
  111. package/adapters/_shared/path-rewrite.cjs +0 -79
  112. package/adapters/_shared/runtime-native.cjs +0 -132
  113. package/adapters/_shared/settings-merge.cjs +0 -76
  114. package/agents/code-reviewer.md +0 -42
  115. package/agents/debugger.md +0 -40
  116. package/agents/review-code.md +0 -49
  117. package/agents/review-comments.md +0 -38
  118. package/agents/review-coordinator.md +0 -56
  119. package/agents/review-design.md +0 -40
  120. package/agents/review-errors.md +0 -43
  121. package/agents/review-spec.md +0 -39
  122. package/agents/review-tests.md +0 -40
  123. package/agents/tdd-runner.md +0 -38
  124. package/bin/orchestrator.js +0 -383
  125. package/commands/ultra-change.md +0 -10
  126. package/commands/ultra-deliver.md +0 -10
  127. package/commands/ultra-dev.md +0 -10
  128. package/commands/ultra-doctor.md +0 -10
  129. package/commands/ultra-init.md +0 -10
  130. package/commands/ultra-plan.md +0 -10
  131. package/commands/ultra-research.md +0 -10
  132. package/commands/ultra-review.md +0 -10
  133. package/commands/ultra-status.md +0 -10
  134. package/commands/ultra-test.md +0 -10
  135. package/commands/ultra-think.md +0 -10
  136. package/docs/AGENT-CONTEXT.md +0 -191
  137. package/docs/COMMIT-HASH-BACKFILL.md +0 -51
  138. package/docs/LEGACY-CLI-CROSSWALK.md +0 -175
  139. package/docs/ROADMAP.md +0 -101
  140. package/docs/STATE-DB-ACCESS-POLICY.md +0 -161
  141. package/hooks/active_task_context.py +0 -152
  142. package/hooks/context_envelope.py +0 -100
  143. package/hooks/health_check.py +0 -196
  144. package/hooks/pre_stop_check.py +0 -66
  145. package/hooks/runtime_paths.py +0 -396
  146. package/hooks/subagent_tracker.py +0 -88
  147. package/hooks/workflow_checkpoint.py +0 -76
  148. package/hooks/workflow_context.py +0 -48
  149. package/hooks/workflow_resume.py +0 -84
  150. package/mcp-server/hook-context.cjs +0 -38
  151. package/mcp-server/hook-event.cjs +0 -34
  152. package/mcp-server/lib/archive-journal.cjs +0 -1119
  153. package/mcp-server/lib/archive-mutation-worker.py +0 -365
  154. package/mcp-server/lib/artifact-registry.cjs +0 -1232
  155. package/mcp-server/lib/baseline-workflow.cjs +0 -1193
  156. package/mcp-server/lib/canonical-json.cjs +0 -19
  157. package/mcp-server/lib/change-authority.cjs +0 -26
  158. package/mcp-server/lib/change-packet.cjs +0 -790
  159. package/mcp-server/lib/change-workflow.cjs +0 -65
  160. package/mcp-server/lib/context-envelope.cjs +0 -581
  161. package/mcp-server/lib/context-paths.cjs +0 -142
  162. package/mcp-server/lib/decision-records.cjs +0 -423
  163. package/mcp-server/lib/delivery-transaction.cjs +0 -471
  164. package/mcp-server/lib/doctor-backup-worker.cjs +0 -328
  165. package/mcp-server/lib/doctor.cjs +0 -627
  166. package/mcp-server/lib/git-bootstrap.cjs +0 -651
  167. package/mcp-server/lib/init-project.cjs +0 -1021
  168. package/mcp-server/lib/kernel-change-workflow.cjs +0 -2029
  169. package/mcp-server/lib/legacy-change-workflow.cjs +0 -3032
  170. package/mcp-server/lib/managed-file-write.cjs +0 -172
  171. package/mcp-server/lib/plan-checkpoint.cjs +0 -177
  172. package/mcp-server/lib/plan-publication-worker.cjs +0 -830
  173. package/mcp-server/lib/plan-store.cjs +0 -794
  174. package/mcp-server/lib/prd-parser.cjs +0 -83
  175. package/mcp-server/lib/pricing.cjs +0 -44
  176. package/mcp-server/lib/project-context.cjs +0 -178
  177. package/mcp-server/lib/projector.cjs +0 -386
  178. package/mcp-server/lib/provider-refs.cjs +0 -42
  179. package/mcp-server/lib/runtime-paths.cjs +0 -1714
  180. package/mcp-server/lib/runtime-state.cjs +0 -222
  181. package/mcp-server/lib/safe-project-file.cjs +0 -385
  182. package/mcp-server/lib/stage-checkpoints.cjs +0 -463
  183. package/mcp-server/lib/state-authority.cjs +0 -95
  184. package/mcp-server/lib/state-db.cjs +0 -1580
  185. package/mcp-server/lib/state-ops.cjs +0 -1428
  186. package/mcp-server/lib/task-contract.cjs +0 -404
  187. package/mcp-server/lib/task-expander.cjs +0 -105
  188. package/mcp-server/lib/task-ledger.cjs +0 -1964
  189. package/mcp-server/lib/telemetry.cjs +0 -85
  190. package/mcp-server/lib/topo.cjs +0 -131
  191. package/mcp-server/lib/ultra-facade.cjs +0 -2684
  192. package/mcp-server/lib/worker-packet.cjs +0 -358
  193. package/mcp-server/server.cjs +0 -437
  194. package/orchestrator/auto-merge.cjs +0 -128
  195. package/orchestrator/daemon.cjs +0 -264
  196. package/orchestrator/dispatch-rules.cjs +0 -78
  197. package/orchestrator/parallel-orchestrator.cjs +0 -903
  198. package/orchestrator/planner/plan-builder.cjs +0 -125
  199. package/orchestrator/recovery.cjs +0 -188
  200. package/orchestrator/session-close-journal-worker.cjs +0 -5
  201. package/orchestrator/session-close-journal.cjs +0 -1014
  202. package/orchestrator/session-runner.cjs +0 -1153
  203. package/orchestrator/worktree-manager.cjs +0 -282
  204. package/settings.json +0 -7
  205. package/skills/cc-collab/SKILL.md +0 -42
  206. package/skills/code-review-expert/SKILL.md +0 -43
  207. package/skills/code-review-expert/references/correctness-reliability.md +0 -30
  208. package/skills/code-review-expert/references/design-boundaries.md +0 -26
  209. package/skills/code-review-expert/references/removal-plan.md +0 -24
  210. package/skills/codex-collab/SKILL.md +0 -49
  211. package/skills/codex-collab/evals/evals.json +0 -35
  212. package/skills/integration-rules/SKILL.md +0 -32
  213. package/skills/security-rules/SKILL.md +0 -38
  214. package/skills/testing-rules/SKILL.md +0 -32
  215. package/skills/ultra-doctor/SKILL.md +0 -41
  216. package/skills/ultra-plan/references/semantic-preflight.md +0 -20
  217. package/skills/ultra-verify/SKILL.md +0 -46
  218. package/skills/ultra-verify/evals/evals.json +0 -46
  219. package/skills/ultra-verify/references/cross-verify-modes.md +0 -28
  220. package/skills/ultra-verify/references/evidence-status.md +0 -21
  221. package/skills/ultra-verify/scripts/verify_wait.py +0 -94
  222. package/spec/cli-protocol.md +0 -102
  223. package/spec/mcp-tools.yaml +0 -312
  224. package/spec/schemas/baseline-reconciliation.v1.schema.json +0 -73
  225. package/spec/schemas/change-delta.v1.schema.json +0 -153
  226. package/spec/schemas/command-manifest.schema.json +0 -29
  227. package/spec/schemas/context-file.v4.5.schema.json +0 -54
  228. package/spec/schemas/delivery-report.v1.schema.json +0 -66
  229. package/spec/schemas/documentation-reconciliation.v1.schema.json +0 -106
  230. package/spec/schemas/mcp-tools.schema.json +0 -136
  231. package/spec/schemas/skill-manifest.schema.json +0 -24
  232. package/spec/schemas/state-db.sql +0 -641
  233. package/spec/schemas/task-ledger.v2.schema.json +0 -296
  234. package/spec/schemas/tasks.v4.5.schema.json +0 -96
  235. package/spec/schemas/test-report.v1.schema.json +0 -225
  236. package/ultra-tools/cli.cjs +0 -129
  237. package/ultra-tools/commands/db.cjs +0 -226
  238. package/ultra-tools/commands/legacy-memory.cjs +0 -159
  239. package/ultra-tools/commands/migrate.cjs +0 -1386
  240. package/ultra-tools/commands/session.cjs +0 -213
  241. package/ultra-tools/commands/status.cjs +0 -559
  242. package/ultra-tools/commands/system.cjs +0 -498
  243. package/ultra-tools/commands/task.cjs +0 -105
@@ -1,3032 +0,0 @@
1
- 'use strict';
2
-
3
- // Internal compatibility implementation for pre-v0.24 workflow authority.
4
- // Production/public callers must use change-workflow.cjs, which exposes
5
- // Kernel-only behavior and provides no legacy/current mode switch.
6
-
7
- const crypto = require('node:crypto');
8
- const fs = require('node:fs');
9
- const path = require('node:path');
10
- const { spawnSync } = require('node:child_process');
11
- const Ajv = require('ajv/dist/2020');
12
-
13
- const ops = require('./state-ops.cjs');
14
- const artifacts = require('./artifact-registry.cjs');
15
- const canonical = require('./canonical-json.cjs');
16
- const changeAuthority = require('./change-authority.cjs');
17
- const { writeManagedFile } = require('./managed-file-write.cjs');
18
- const providerRefs = require('./provider-refs.cjs');
19
- const baselines = require('./baseline-workflow.cjs');
20
- const archiveJournal = require('./archive-journal.cjs');
21
- const changePacket = require('./change-packet.cjs');
22
- const deliveryTransaction = require('./delivery-transaction.cjs');
23
- const {
24
- openStableProjectRead,
25
- readStableProjectFile,
26
- walkStableProjectTree,
27
- } = require('./safe-project-file.cjs');
28
- const stageCheckpoints = require('./stage-checkpoints.cjs');
29
- const kernelChangeWorkflow = require('./kernel-change-workflow.cjs');
30
- const reconciliationSchema = require('../../spec/schemas/baseline-reconciliation.v1.schema.json');
31
-
32
- const reconciliationAjv = new Ajv({ allErrors: true, strict: false });
33
- const validateReconciliationSchema = reconciliationAjv.compile(reconciliationSchema);
34
-
35
- function loadLegacyModule(filename) {
36
- return module.require(path.join(__dirname, filename));
37
- }
38
-
39
- function legacySpecLearning() {
40
- return loadLegacyModule('spec-learning.cjs');
41
- }
42
-
43
- function legacyWorkflows() {
44
- return loadLegacyModule('workflow-state.cjs');
45
- }
46
-
47
- function legacyDecisions() {
48
- return loadLegacyModule('decision-dialogue.cjs');
49
- }
50
-
51
- function legacyContextSpine() {
52
- return loadLegacyModule('context-spine.cjs');
53
- }
54
-
55
- const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
56
- const CHANGE_PATCH_FIELDS = new Set([
57
- 'title', 'intent', 'status', 'docs_impact', 'provider_refs',
58
- 'contract', 'classification', 'research_disposition',
59
- ]);
60
- const CHANGE_CREATE_FIELDS = new Set([
61
- 'id', 'title', 'kind', 'intent', 'docs_impact', 'provider_refs',
62
- 'baseline_bypass', 'contract', 'classification', 'research_disposition',
63
- 'base_commit', 'alignment_thread_id', 'supersedes_id',
64
- ]);
65
- const SEMANTIC_AUTHORITY_FIELDS = new Set([
66
- 'intent', 'docs_impact', 'provider_refs', 'contract', 'classification',
67
- 'research_disposition',
68
- ]);
69
- const CHANGE_TRANSITIONS = Object.freeze({
70
- active: new Set(['active', 'blocked', 'cancelled']),
71
- blocked: new Set(['active', 'blocked', 'cancelled']),
72
- ready: new Set(['active', 'ready', 'cancelled']),
73
- archived: new Set(),
74
- cancelled: new Set(),
75
- });
76
- const CHANGE_KINDS = new Set(['quick', 'standard', 'major', 'incident']);
77
- const CHANGE_RISK_FLAGS = new Set([
78
- 'public_contract', 'schema_migration', 'data_migration', 'authorization', 'security',
79
- 'multi_module', 'external_integration', 'release_semantics',
80
- ]);
81
- const QUICK_EXCLUSION_FLAGS = new Set(CHANGE_RISK_FLAGS);
82
- const RESEARCH_DISPOSITIONS = new Set(['none', 'bounded', 'required']);
83
- const CHANGE_RESEARCH_MODES = new Set(['product', 'feature', 'architecture', 'custom']);
84
- const INCIDENT_DIAGNOSIS_SECTIONS = Object.freeze([
85
- { heading: 'Reproduction', key: 'reproduction' },
86
- { heading: 'Hypotheses', key: 'hypotheses' },
87
- { heading: 'Root cause', key: 'root-cause' },
88
- { heading: 'Regression test', key: 'regression-test' },
89
- { heading: 'Recovery', key: 'recovery' },
90
- ]);
91
-
92
- class ChangeWorkflowError extends Error {
93
- constructor(code, message, details) {
94
- super(message);
95
- this.name = 'ChangeWorkflowError';
96
- this.code = code;
97
- if (details !== undefined) this.details = details;
98
- }
99
- }
100
-
101
- function nowIso() {
102
- return new Date().toISOString();
103
- }
104
-
105
- function parseJson(value, field) {
106
- try { return JSON.parse(value); }
107
- catch (error) {
108
- throw new ChangeWorkflowError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
109
- }
110
- }
111
-
112
- function rowToChange(row) {
113
- if (!row) return null;
114
- const change = {
115
- ...row,
116
- docs_impact: parseJson(row.docs_impact_json, 'docs_impact_json'),
117
- provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json'),
118
- baseline_bypass: row.baseline_bypass_json
119
- ? parseJson(row.baseline_bypass_json, 'baseline_bypass_json') : null,
120
- contract: parseJson(row.contract_json || '{}', 'contract_json'),
121
- classification: parseJson(row.classification_json || '{}', 'classification_json'),
122
- research_disposition: parseJson(
123
- row.research_disposition_json || '{}', 'research_disposition_json',
124
- ),
125
- };
126
- delete change.docs_impact_json;
127
- delete change.provider_refs_json;
128
- delete change.baseline_bypass_json;
129
- delete change.contract_json;
130
- delete change.classification_json;
131
- delete change.research_disposition_json;
132
- return change;
133
- }
134
-
135
- function assertExactFields(value, allowed, field) {
136
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
137
- throw new ChangeWorkflowError('VALIDATION_ERROR', `${field} must be an object`);
138
- }
139
- const unknown = Object.keys(value).filter((key) => !allowed.has(key));
140
- if (unknown.length > 0) {
141
- throw new ChangeWorkflowError(
142
- 'VALIDATION_ERROR', `${field}.${unknown[0]} is not allowed`,
143
- );
144
- }
145
- }
146
-
147
- function normalizeDocsImpact(value) {
148
- const input = value === undefined ? {} : value;
149
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
150
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact must be an object');
151
- }
152
- assertExactFields(input, new Set(['status', 'files', 'rationale']), 'docs_impact');
153
- const status = input.status === undefined ? 'unknown' : input.status;
154
- if (typeof status !== 'string') {
155
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact status must be a string');
156
- }
157
- if (!['unknown', 'required', 'none'].includes(status)) {
158
- throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid docs impact status: ${status}`);
159
- }
160
- const files = input.files === undefined ? [] : input.files;
161
- if (!Array.isArray(files) || files.some((file) => typeof file !== 'string' || !file.trim())) {
162
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact files must be non-empty strings');
163
- }
164
- if (input.rationale != null && typeof input.rationale !== 'string') {
165
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'docs impact rationale must be a string or null');
166
- }
167
- const rationale = input.rationale == null ? null : input.rationale.trim();
168
- return { status, files: [...new Set(files.map((file) => file.trim()))], rationale };
169
- }
170
-
171
- function normalizeProviderRefs(value) {
172
- return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
173
- }
174
-
175
- function requiredText(value, field) {
176
- const text = typeof value === 'string' ? value.trim() : '';
177
- if (!text) throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `${field} is required`);
178
- return text;
179
- }
180
-
181
- function stringList(value, field, { allowEmpty = false } = {}) {
182
- if (!Array.isArray(value) || (!allowEmpty && value.length === 0)
183
- || value.some((item) => typeof item !== 'string' || !item.trim())) {
184
- throw new ChangeWorkflowError(
185
- 'CHANGE_CONTRACT_REQUIRED', `${field} must be ${allowEmpty ? 'an' : 'a non-empty'} array of strings`,
186
- );
187
- }
188
- return [...new Set(value.map((item) => item.trim()))];
189
- }
190
-
191
- function optionalTrimmedText(value, field, { maxLength = 4000 } = {}) {
192
- if (value === undefined || value === null) return '';
193
- if (typeof value !== 'string') {
194
- throw new ChangeWorkflowError('VALIDATION_ERROR', `${field} must be a string`);
195
- }
196
- const text = value.trim();
197
- if (text.length > maxLength) {
198
- throw new ChangeWorkflowError(
199
- 'VALIDATION_ERROR',
200
- `${field} must not exceed ${maxLength} characters`,
201
- );
202
- }
203
- return text;
204
- }
205
-
206
- function boundedVocabulary(value, field) {
207
- const text = optionalTrimmedText(value, field, { maxLength: 80 });
208
- if (!text) {
209
- throw new ChangeWorkflowError('VALIDATION_ERROR', `${field} is required`);
210
- }
211
- return text;
212
- }
213
-
214
- function normalizeChangeContract(value, { allowDraft = false } = {}) {
215
- if ((!value || typeof value !== 'object' || Array.isArray(value)) && !allowDraft) {
216
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract is required');
217
- }
218
- const input = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
219
- assertExactFields(
220
- input,
221
- new Set([
222
- 'outcome', 'acceptance', 'non_goals', 'public_seams',
223
- 'recovery', 'unresolved_decisions',
224
- ]),
225
- 'contract',
226
- );
227
- if (!allowDraft && (!Array.isArray(input.acceptance) || input.acceptance.length === 0)) {
228
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.acceptance is required');
229
- }
230
- if (input.acceptance !== undefined && !Array.isArray(input.acceptance)) {
231
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'contract.acceptance must be an array');
232
- }
233
- const ids = new Set();
234
- const acceptance = (input.acceptance || []).map((item, index) => {
235
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
236
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `contract.acceptance[${index}] is invalid`);
237
- }
238
- assertExactFields(
239
- item,
240
- new Set(['id', 'criterion', 'verification']),
241
- `contract.acceptance[${index}]`,
242
- );
243
- const id = requiredText(item.id, `contract.acceptance[${index}].id`);
244
- if (!CHANGE_ID.test(id) || ids.has(id)) {
245
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `acceptance id is invalid or duplicated: ${id}`);
246
- }
247
- ids.add(id);
248
- return {
249
- id,
250
- criterion: requiredText(item.criterion, `contract.acceptance[${index}].criterion`),
251
- verification: requiredText(item.verification, `contract.acceptance[${index}].verification`),
252
- };
253
- });
254
- const recovery = input.recovery;
255
- if (!allowDraft && (!recovery || typeof recovery !== 'object' || Array.isArray(recovery))) {
256
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', 'contract.recovery is required');
257
- }
258
- if (recovery !== undefined && recovery !== null) {
259
- assertExactFields(
260
- recovery,
261
- new Set(['strategy', 'verification']),
262
- 'contract.recovery',
263
- );
264
- }
265
- if (!allowDraft && !Array.isArray(input.unresolved_decisions)) {
266
- throw new ChangeWorkflowError(
267
- 'CHANGE_CONTRACT_REQUIRED', 'contract.unresolved_decisions must be an explicit array',
268
- );
269
- }
270
- if (input.unresolved_decisions !== undefined && !Array.isArray(input.unresolved_decisions)) {
271
- throw new ChangeWorkflowError(
272
- 'VALIDATION_ERROR',
273
- 'contract.unresolved_decisions must be an array',
274
- );
275
- }
276
- const decisions = (input.unresolved_decisions || []).map((item, index) => {
277
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
278
- throw new ChangeWorkflowError('CHANGE_CONTRACT_REQUIRED', `unresolved decision ${index} is invalid`);
279
- }
280
- assertExactFields(
281
- item,
282
- new Set(['id', 'summary', 'blocking', 'owner']),
283
- `contract.unresolved_decisions[${index}]`,
284
- );
285
- if (item.blocking !== undefined && typeof item.blocking !== 'boolean') {
286
- throw new ChangeWorkflowError(
287
- 'VALIDATION_ERROR',
288
- `contract.unresolved_decisions[${index}].blocking must be a boolean`,
289
- );
290
- }
291
- return {
292
- id: requiredText(item.id, `contract.unresolved_decisions[${index}].id`),
293
- summary: requiredText(item.summary, `contract.unresolved_decisions[${index}].summary`),
294
- blocking: item.blocking === true,
295
- owner: item.owner == null ? null : requiredText(item.owner, `contract.unresolved_decisions[${index}].owner`),
296
- };
297
- });
298
- return {
299
- outcome: allowDraft
300
- ? optionalTrimmedText(input.outcome, 'contract.outcome')
301
- : requiredText(input.outcome, 'contract.outcome'),
302
- acceptance,
303
- non_goals: stringList(
304
- input.non_goals === undefined && allowDraft ? [] : input.non_goals,
305
- 'contract.non_goals',
306
- { allowEmpty: allowDraft },
307
- ),
308
- public_seams: stringList(
309
- input.public_seams === undefined && allowDraft ? [] : input.public_seams,
310
- 'contract.public_seams',
311
- { allowEmpty: allowDraft },
312
- ),
313
- recovery: recovery
314
- ? {
315
- strategy: allowDraft
316
- ? optionalTrimmedText(recovery.strategy, 'contract.recovery.strategy')
317
- : requiredText(recovery.strategy, 'contract.recovery.strategy'),
318
- verification: allowDraft
319
- ? optionalTrimmedText(recovery.verification, 'contract.recovery.verification')
320
- : requiredText(recovery.verification, 'contract.recovery.verification'),
321
- }
322
- : null,
323
- unresolved_decisions: decisions,
324
- };
325
- }
326
-
327
- function normalizeClassification(value, kind, { allowDraft = false } = {}) {
328
- if ((!value || typeof value !== 'object' || Array.isArray(value)) && !allowDraft) {
329
- throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', 'classification is required');
330
- }
331
- const input = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
332
- assertExactFields(
333
- input,
334
- new Set(['rationale', 'risk_flags']),
335
- 'classification',
336
- );
337
- const flags = stringList(
338
- input.risk_flags === undefined ? [] : input.risk_flags,
339
- 'classification.risk_flags',
340
- { allowEmpty: true },
341
- );
342
- if (allowDraft) {
343
- return {
344
- rationale: optionalTrimmedText(input.rationale, 'classification.rationale'),
345
- risk_flags: flags,
346
- };
347
- }
348
- const invalid = flags.filter((flag) => !CHANGE_RISK_FLAGS.has(flag));
349
- if (invalid.length > 0) {
350
- throw new ChangeWorkflowError('CHANGE_CLASSIFICATION_REQUIRED', `unknown risk flags: ${invalid.join(', ')}`);
351
- }
352
- if (kind === 'quick') {
353
- const excluded = flags.filter((flag) => QUICK_EXCLUSION_FLAGS.has(flag));
354
- if (excluded.length > 0) {
355
- throw new ChangeWorkflowError(
356
- 'CHANGE_PROFILE_ESCALATION_REQUIRED',
357
- `quick changes cannot carry these risk flags: ${excluded.join(', ')}`,
358
- );
359
- }
360
- }
361
- return {
362
- rationale: requiredText(input.rationale, 'classification.rationale'),
363
- risk_flags: flags,
364
- };
365
- }
366
-
367
- function normalizeResearchDisposition(value, kind, { allowDraft = false } = {}) {
368
- if ((!value || typeof value !== 'object' || Array.isArray(value)) && !allowDraft) {
369
- throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research_disposition is required');
370
- }
371
- const input = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
372
- assertExactFields(
373
- input,
374
- new Set(['status', 'mode', 'selected_steps', 'rationale']),
375
- 'research_disposition',
376
- );
377
- const status = allowDraft
378
- ? (input.status === undefined
379
- ? 'unresolved'
380
- : boundedVocabulary(input.status, 'research_disposition.status'))
381
- : (typeof input.status === 'string' ? input.status.trim() : '');
382
- if (allowDraft) {
383
- const selectedSteps = stringList(
384
- input.selected_steps === undefined ? [] : input.selected_steps,
385
- 'research_disposition.selected_steps',
386
- { allowEmpty: true },
387
- );
388
- if (input.mode !== undefined && input.mode !== null && typeof input.mode !== 'string') {
389
- throw new ChangeWorkflowError(
390
- 'VALIDATION_ERROR',
391
- 'research_disposition.mode must be a string or null',
392
- );
393
- }
394
- const mode = input.mode == null
395
- ? null
396
- : boundedVocabulary(input.mode, 'research_disposition.mode');
397
- return {
398
- status,
399
- mode,
400
- selected_steps: selectedSteps,
401
- rationale: optionalTrimmedText(input.rationale, 'research_disposition.rationale'),
402
- };
403
- }
404
- if (!RESEARCH_DISPOSITIONS.has(status)) {
405
- throw new ChangeWorkflowError('CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research disposition status is invalid');
406
- }
407
- const selectedSteps = stringList(
408
- input.selected_steps === undefined ? [] : input.selected_steps,
409
- 'research_disposition.selected_steps',
410
- { allowEmpty: true },
411
- );
412
- if (input.mode != null && typeof input.mode !== 'string') {
413
- throw new ChangeWorkflowError(
414
- 'CHANGE_RESEARCH_DISPOSITION_REQUIRED',
415
- 'research disposition mode must be a string or null',
416
- );
417
- }
418
- const mode = input.mode == null ? null : input.mode.trim();
419
- if (status === 'none' && (mode !== null || selectedSteps.length > 0)) {
420
- throw new ChangeWorkflowError(
421
- 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'research status none cannot select a mode or steps',
422
- );
423
- }
424
- if (status !== 'none' && !CHANGE_RESEARCH_MODES.has(mode)) {
425
- throw new ChangeWorkflowError(
426
- 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'bounded or required research needs a supported mode',
427
- );
428
- }
429
- if (mode === 'custom' && selectedSteps.length === 0) {
430
- throw new ChangeWorkflowError(
431
- 'CHANGE_RESEARCH_DISPOSITION_REQUIRED', 'custom research needs selected_steps',
432
- );
433
- }
434
- if (kind === 'quick' && status !== 'none') {
435
- throw new ChangeWorkflowError(
436
- 'CHANGE_PROFILE_ESCALATION_REQUIRED', 'a change that requires research cannot use the quick profile',
437
- );
438
- }
439
- return {
440
- status, mode, selected_steps: selectedSteps,
441
- rationale: requiredText(input.rationale, 'research_disposition.rationale'),
442
- };
443
- }
444
-
445
- function normalizeBaselineBypass(value) {
446
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
447
- throw new ChangeWorkflowError(
448
- 'BASELINE_BYPASS_REQUIRED',
449
- 'incident work without a ready baseline requires an explicit baseline_bypass record',
450
- );
451
- }
452
- assertExactFields(
453
- value,
454
- new Set(['reason', 'approved_by']),
455
- 'baseline_bypass',
456
- );
457
- const reason = typeof value.reason === 'string' ? value.reason.trim() : '';
458
- const approvedBy = typeof value.approved_by === 'string' ? value.approved_by.trim() : '';
459
- if (reason.length < 3 || !approvedBy) {
460
- throw new ChangeWorkflowError(
461
- 'BASELINE_BYPASS_REQUIRED',
462
- 'baseline_bypass requires reason and approved_by',
463
- );
464
- }
465
- return {
466
- mode: 'incident_break_glass',
467
- reason,
468
- approved_by: approvedBy,
469
- recorded_at: nowIso(),
470
- };
471
- }
472
-
473
- function gitHead(rootDir) {
474
- const result = spawnSync('git', ['rev-parse', 'HEAD'], {
475
- cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
476
- });
477
- return result.status === 0 ? result.stdout.trim() : null;
478
- }
479
-
480
- function safeRelativePath(rootDir, candidate) {
481
- if (typeof candidate !== 'string' || !candidate.trim() || path.isAbsolute(candidate)) {
482
- throw new ChangeWorkflowError('VALIDATION_ERROR', `path must be project-relative: ${candidate}`);
483
- }
484
- const root = path.resolve(rootDir);
485
- const target = path.resolve(root, candidate);
486
- if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
487
- throw new ChangeWorkflowError('VALIDATION_ERROR', `path escapes project root: ${candidate}`);
488
- }
489
- return target;
490
- }
491
-
492
- function rebindRegistryPath(candidate, source, destination) {
493
- const current = artifacts.normalizeRelativePath(candidate);
494
- const from = artifacts.normalizeRelativePath(source);
495
- const to = artifacts.normalizeRelativePath(destination);
496
- if (current !== from && !current.startsWith(`${from}/`)) return null;
497
- const suffix = current === from ? '' : path.posix.relative(from, current);
498
- return suffix ? path.posix.join(to, suffix) : to;
499
- }
500
-
501
- function assertReconciliationAuthority(db, change, relative, digest) {
502
- const rows = db.prepare(
503
- "SELECT * FROM artifacts WHERE status <> 'archived' ORDER BY id",
504
- ).all().filter((item) => {
505
- try { return artifacts.normalizeRelativePath(item.path) === relative; }
506
- catch { return false; }
507
- });
508
- const row = rows.length === 1 ? rows[0] : null;
509
- const authorityDigest = row?.digest || row?.content_hash || null;
510
- if (!row || row.managed !== 1 || row.owner_type !== 'change'
511
- || row.owner_id !== change.id || row.change_id !== change.id
512
- || row.kind !== 'baseline_reconciliation' || row.status !== 'current'
513
- || authorityDigest !== digest) {
514
- throw new ChangeWorkflowError(
515
- 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
516
- 'reconciliation manifest must match one current managed Change artifact authority',
517
- {
518
- path: relative,
519
- artifact_ids: rows.map((item) => item.id),
520
- expected_owner: `change:${change.id}`,
521
- expected_kind: 'baseline_reconciliation',
522
- expected_digest: digest,
523
- actual_digest: authorityDigest,
524
- },
525
- );
526
- }
527
- return row;
528
- }
529
-
530
- function openReconciliationReader(rootDir, relative) {
531
- try {
532
- return openStableProjectRead(rootDir, relative);
533
- } catch (error) {
534
- if (error?.code === 'PROJECT_FILE_MISSING') return null;
535
- throw new ChangeWorkflowError(
536
- 'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
537
- `reconciliation manifest cannot be read safely: ${relative}`,
538
- { cause: error.code || error.message },
539
- );
540
- }
541
- }
542
-
543
- function readReconciliationManifest(db, change, input, rootDir) {
544
- const requested = typeof input.reconciliation_path === 'string'
545
- ? input.reconciliation_path.trim() : '';
546
- if (!requested) {
547
- throw new ChangeWorkflowError(
548
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation_path is required',
549
- );
550
- }
551
- let relative;
552
- try { relative = artifacts.normalizeRelativePath(requested); }
553
- catch (error) {
554
- throw new ChangeWorkflowError(
555
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', error.message,
556
- );
557
- }
558
- let file = safeRelativePath(rootDir, relative);
559
- const artifactRoot = path.resolve(rootDir, change.artifact_root);
560
- if (!file.startsWith(`${artifactRoot}${path.sep}`)) {
561
- throw new ChangeWorkflowError(
562
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED',
563
- 'reconciliation manifest must be a file inside the active change artifact root',
564
- );
565
- }
566
- let reader = openReconciliationReader(rootDir, relative);
567
- let manifest;
568
- let digest;
569
- let expectedDigest = null;
570
- let authorityDigest = null;
571
- let journalIntent = null;
572
- if (!reader) {
573
- const pending = archiveJournal.listArchiveIntents(rootDir).find(
574
- (item) => !item.error && item.intent.change_id === change.id
575
- && item.intent.reconciliation_path === relative,
576
- );
577
- if (pending) {
578
- journalIntent = pending.intent;
579
- const suffix = path.posix.relative(pending.intent.source, relative);
580
- const archivedRelative = path.posix.join(pending.intent.destination, suffix);
581
- file = safeRelativePath(rootDir, archivedRelative);
582
- reader = openReconciliationReader(rootDir, archivedRelative);
583
- expectedDigest = pending.intent.reconciliation_digest;
584
- authorityDigest = pending.intent.reconciliation_digest;
585
- const rebind = archiveJournal.readArchiveRebind(rootDir, pending.intent);
586
- const rebound = rebind?.entries.find(
587
- (entry) => entry.relative_path
588
- === path.posix.relative(pending.intent.destination, archivedRelative),
589
- );
590
- if (rebound) {
591
- if (rebound.before_digest !== pending.intent.reconciliation_digest) {
592
- throw new ChangeWorkflowError(
593
- 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
594
- 'archive rebind journal does not begin from the prepared reconciliation authority',
595
- {
596
- expected_digest: pending.intent.reconciliation_digest,
597
- actual_digest: rebound.before_digest,
598
- },
599
- );
600
- }
601
- expectedDigest = rebound.after_digest;
602
- authorityDigest = rebound.before_digest;
603
- }
604
- }
605
- }
606
- if (!reader) {
607
- throw new ChangeWorkflowError(
608
- 'BASELINE_RECONCILIATION_MANIFEST_REQUIRED', 'reconciliation manifest file is missing',
609
- );
610
- }
611
- try {
612
- digest = reader.digest;
613
- if (expectedDigest && digest !== expectedDigest) {
614
- throw new ChangeWorkflowError(
615
- 'BASELINE_RECONCILIATION_AUTHORITY_INVALID',
616
- 'archived reconciliation bytes do not match the prepared journal digest',
617
- { expected_digest: expectedDigest, actual_digest: digest },
618
- );
619
- }
620
- assertReconciliationAuthority(db, change, relative, authorityDigest || digest);
621
- manifest = JSON.parse(reader.bytes.toString('utf8'));
622
- reader.verify();
623
- } catch (error) {
624
- if (error instanceof ChangeWorkflowError) throw error;
625
- if (error?.code === 'PROJECT_FILE_CHANGED' || error?.code === 'PROJECT_FILE_UNSAFE') {
626
- throw new ChangeWorkflowError(
627
- 'BASELINE_RECONCILIATION_MANIFEST_UNSAFE',
628
- `reconciliation manifest changed while it was read: ${relative}`,
629
- { cause: error.code },
630
- );
631
- }
632
- throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
633
- } finally {
634
- reader.close();
635
- }
636
- if (!validateReconciliationSchema(manifest)) {
637
- throw new ChangeWorkflowError(
638
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
639
- `reconciliation manifest does not satisfy its published schema: ${reconciliationAjv.errorsText(validateReconciliationSchema.errors)}`,
640
- validateReconciliationSchema.errors,
641
- );
642
- }
643
- const baseline = baselines.readBaseline(db);
644
- const updates = Array.isArray(input.baseline_updates) ? input.baseline_updates : [];
645
- if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
646
- || manifest.$schema !== 'ultra-baseline-reconciliation-v1'
647
- || manifest.change_id !== change.id || manifest.baseline_id !== (baseline?.id || null)
648
- || JSON.stringify(manifest.baseline_updates) !== JSON.stringify(updates)
649
- || !Array.isArray(manifest.semantic_changes)
650
- || !Array.isArray(manifest.resolved_gap_ids)
651
- || !Array.isArray(manifest.resolved_unknowns)
652
- || !Array.isArray(manifest.verification) || manifest.verification.length === 0) {
653
- throw new ChangeWorkflowError(
654
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'reconciliation manifest shape or authority binding is invalid',
655
- );
656
- }
657
- if (manifest.verification.some((item) => (
658
- !item || typeof item !== 'object' || item.status !== 'pass'
659
- || !String(item.name || '').trim() || !String(item.command || '').trim()
660
- || !String(item.evidence || '').trim()
661
- ))) {
662
- throw new ChangeWorkflowError(
663
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'every reconciliation verification must be an evidenced pass',
664
- );
665
- }
666
- if (updates.length === 0) {
667
- if (manifest.semantic_changes.length > 0
668
- || String(manifest.semantic_no_change_reason || '').trim().length < 3) {
669
- throw new ChangeWorkflowError(
670
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
671
- 'no-change reconciliation requires an explicit semantic_no_change_reason and no semantic changes',
672
- );
673
- }
674
- } else if (manifest.semantic_changes.length === 0) {
675
- throw new ChangeWorkflowError(
676
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'baseline updates require semantic change records',
677
- );
678
- }
679
- const specRefs = new Map((baseline?.spec_refs || []).map((ref) => [ref.path, ref]));
680
- const semanticPaths = new Set();
681
- for (const item of manifest.semantic_changes) {
682
- if (!item || typeof item !== 'object' || !CHANGE_ID.test(String(item.id || ''))
683
- || !['add', 'update'].includes(item.action)
684
- || !/^[0-9a-f]{64}$/.test(String(item.after_digest || ''))) {
685
- throw new ChangeWorkflowError(
686
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'semantic change record is invalid',
687
- );
688
- }
689
- let resolved;
690
- try {
691
- resolved = legacyWorkflows().resolveProjectSourceRef(
692
- rootDir,
693
- item.source_ref,
694
- 'semantic_change.source_ref',
695
- );
696
- }
697
- catch (error) {
698
- throw new ChangeWorkflowError('BASELINE_RECONCILIATION_MANIFEST_INVALID', error.message);
699
- }
700
- semanticPaths.add(resolved.relative);
701
- if (!updates.includes(resolved.relative) || resolved.digest !== item.after_digest) {
702
- throw new ChangeWorkflowError(
703
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
704
- `semantic change digest or baseline update binding is invalid: ${item.source_ref}`,
705
- );
706
- }
707
- const before = specRefs.get(resolved.relative)?.digest || null;
708
- if ((item.before_digest || null) !== before || item.action !== (before ? 'update' : 'add')) {
709
- throw new ChangeWorkflowError(
710
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
711
- `semantic change before-state is invalid: ${item.source_ref}`,
712
- );
713
- }
714
- }
715
- const missingSemanticUpdates = updates.filter((update) => !semanticPaths.has(update));
716
- if (missingSemanticUpdates.length > 0) {
717
- throw new ChangeWorkflowError(
718
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
719
- `baseline updates have no semantic record: ${missingSemanticUpdates.join(', ')}`,
720
- );
721
- }
722
- const gapIds = new Set((baseline?.gaps || []).map((gap) => gap.id));
723
- const unknowns = new Set((baseline?.unknowns || []).map((unknown) => unknown.summary));
724
- if (manifest.resolved_gap_ids.some((id) => !gapIds.has(id))
725
- || manifest.resolved_unknowns.some((summary) => !unknowns.has(summary))) {
726
- throw new ChangeWorkflowError(
727
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID', 'resolved gap or unknown does not exist in baseline authority',
728
- );
729
- }
730
- return {
731
- manifest, relative, file,
732
- digest,
733
- journal_intent: journalIntent,
734
- };
735
- }
736
-
737
- function readChange(db, id) {
738
- return rowToChange(db.prepare('SELECT * FROM changes WHERE id = ?').get(id));
739
- }
740
-
741
- const LIST_CHANGES_SQL = "SELECT * FROM changes WHERE (@status IS NULL OR status = @status) AND (@kind IS NULL OR kind = @kind) ORDER BY created_at DESC LIMIT @maxn";
742
-
743
- function listChanges(db, { status = null, kind = null, limit = 100 } = {}) {
744
- return db.prepare(LIST_CHANGES_SQL).all({
745
- status, kind, maxn: Math.min(Math.max(limit, 1), 500),
746
- }).map(rowToChange);
747
- }
748
-
749
- function writeIntent(file, change) {
750
- const impact = change.docs_impact;
751
- const recovery = change.contract.recovery;
752
- const lines = [
753
- `# ${change.title}`,
754
- '',
755
- `- Change: \`${change.id}\``,
756
- `- Kind: \`${change.kind}\``,
757
- `- Base commit: \`${change.base_commit || 'unavailable'}\``,
758
- `- Documentation impact: \`${impact.status}\``,
759
- `- Research disposition: \`${change.research_disposition.status}\``,
760
- `- Alignment checkpoint: \`${change.alignment_thread_id || 'not-required'}\``,
761
- '',
762
- '## Intent',
763
- '',
764
- change.intent,
765
- '',
766
- '## Outcome', '', change.contract.outcome, '',
767
- '## Acceptance', '',
768
- ...change.contract.acceptance.flatMap((item) => [
769
- `### ${item.id}`, '', item.criterion, '', `Verification: \`${item.verification}\``, '',
770
- ]),
771
- '## Public seams', '', ...change.contract.public_seams.map((item) => `- ${item}`), '',
772
- '## Non-goals', '', ...change.contract.non_goals.map((item) => `- ${item}`), '',
773
- '## Recovery', '', recovery?.strategy || 'Not yet decided.', '',
774
- `Verification: \`${recovery?.verification || 'not yet decided'}\``, '',
775
- '## Classification', '', change.classification.rationale, '',
776
- `Risk flags: ${change.classification.risk_flags.join(', ') || 'none'}`, '',
777
- '## Research routing', '', change.research_disposition.rationale, '',
778
- ];
779
- if (impact.rationale) lines.push('## Documentation rationale', '', impact.rationale, '');
780
- if (change.baseline_bypass) {
781
- lines.push(
782
- '## Baseline break-glass', '',
783
- `- Approved by: ${change.baseline_bypass.approved_by}`,
784
- `- Recorded at: ${change.baseline_bypass.recorded_at}`,
785
- '', change.baseline_bypass.reason, '',
786
- );
787
- }
788
- fs.writeFileSync(file, `${lines.join('\n')}\n`);
789
- }
790
-
791
- function writeIncidentDiagnosis(file, change) {
792
- const lines = [
793
- `# Incident diagnosis: ${change.title}`,
794
- '',
795
- ...INCIDENT_DIAGNOSIS_SECTIONS.flatMap(({ heading }) => [`## ${heading}`, '', '']),
796
- ];
797
- fs.writeFileSync(file, `${lines.join('\n')}\n`);
798
- }
799
-
800
- function readMarkdownSections(text) {
801
- const sections = new Map();
802
- let current = null;
803
- for (const line of String(text).split(/\r?\n/)) {
804
- const heading = /^##\s+(.+?)\s*$/.exec(line);
805
- if (heading) {
806
- current = heading[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
807
- if (!sections.has(current)) sections.set(current, []);
808
- continue;
809
- }
810
- if (current) sections.get(current).push(line);
811
- }
812
- return new Map([...sections].map(([key, lines]) => [key, lines.join('\n').trim()]));
813
- }
814
-
815
- function inspectIncidentDiagnosis(change, artifactDir, rootDir) {
816
- if (change.kind !== 'incident') return { blockers: [], artifact: null };
817
- const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
818
- if (!fs.existsSync(diagnosisPath)) {
819
- return { blockers: ['DIAGNOSIS_ARTIFACT_MISSING'], artifact: null };
820
- }
821
- const content = fs.readFileSync(diagnosisPath);
822
- const sections = readMarkdownSections(content.toString('utf8'));
823
- const blockers = INCIDENT_DIAGNOSIS_SECTIONS
824
- .filter(({ key }) => !sections.get(key) || sections.get(key).length < 3)
825
- .map(({ key }) => `DIAGNOSIS_SECTION_MISSING:${key}`);
826
- return {
827
- blockers,
828
- artifact: {
829
- change_id: change.id,
830
- kind: 'diagnosis',
831
- artifactPath: path.relative(rootDir, diagnosisPath),
832
- contentHash: crypto.createHash('sha256').update(content).digest('hex'),
833
- metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
834
- },
835
- };
836
- }
837
-
838
- function latestWorkflowConsumer(db, changeId) {
839
- const workflow = db.prepare(
840
- `SELECT id FROM workflow_runs WHERE change_id = ?
841
- ORDER BY started_at DESC, rowid DESC LIMIT 1`,
842
- ).get(changeId);
843
- if (!workflow) {
844
- throw new ChangeWorkflowError(
845
- 'ARTIFACT_CONSUMER_MISSING',
846
- `change ${changeId} has no workflow consumer for its durable artifacts`,
847
- );
848
- }
849
- return { type: 'workflow', id: workflow.id, relation: 'consumed_by' };
850
- }
851
-
852
- function upsertArtifact(
853
- db,
854
- {
855
- change_id, task_id = null, kind, artifactPath, contentHash, metadata,
856
- workflow_id = null, consumer_ref = null, terminal = false,
857
- writer = 'change-workflow', rootDir = process.cwd(),
858
- },
859
- ) {
860
- const ownerType = task_id ? 'task' : 'change';
861
- const ownerId = task_id || change_id;
862
- const consumer = terminal
863
- ? null
864
- : consumer_ref
865
- ? consumer_ref
866
- : task_id
867
- ? { type: 'task', id: task_id, relation: 'consumed_by' }
868
- : workflow_id
869
- ? { type: 'workflow', id: workflow_id, relation: 'consumed_by' }
870
- : latestWorkflowConsumer(db, change_id);
871
- return artifacts.recordArtifactInTx(db, {
872
- owner_type: ownerType,
873
- owner_id: ownerId,
874
- kind,
875
- path: artifactPath,
876
- content_digest: contentHash || undefined,
877
- source_refs: [{ type: 'change', id: change_id, relation: 'produced_for' }],
878
- consumer_refs: consumer ? [consumer] : [],
879
- provenance: {
880
- writer,
881
- workflow_run_id: consumer?.type === 'workflow' ? consumer.id : null,
882
- },
883
- metadata: { ...(metadata || {}), ...(terminal ? { terminal_role: true } : {}) },
884
- }, { rootDir });
885
- }
886
-
887
- function compileContext(db, input, { rootDir = process.cwd() } = {}) {
888
- const change = readChange(db, input.id);
889
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
890
- if (['archived', 'cancelled'].includes(change.status)) {
891
- throw new ChangeWorkflowError('CHANGE_TERMINAL', `change ${input.id} is ${change.status}`);
892
- }
893
- if (Object.hasOwn(input, 'provider_refs')) {
894
- throw new ChangeWorkflowError(
895
- 'CONTEXT_PROVIDER_REFS_MUTATION_UNSUPPORTED',
896
- 'provider refs are Change authority; update them through change.update before compiling context',
897
- );
898
- }
899
- const providers = change.provider_refs;
900
- const tasks = db.prepare(
901
- 'SELECT id FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
902
- ).all(change.id).map((task) => ops.readTask(db, task.id));
903
- const specs = Array.isArray(input.spec_refs) ? input.spec_refs : [];
904
- const spine = legacyContextSpine().compileRoleContext(
905
- db,
906
- { input, change, tasks, rootDir },
907
- );
908
- const recommendation = spine.recommendation;
909
- const durableSpine = { ...spine };
910
- delete durableSpine.recommendation;
911
- const semanticManifest = {
912
- schema_version: '3.0',
913
- change: {
914
- id: change.id, title: change.title, kind: change.kind, status: change.status,
915
- intent: change.intent, docs_impact: change.docs_impact, base_commit: change.base_commit,
916
- baseline_bypass: change.baseline_bypass,
917
- contract: change.contract, classification: change.classification,
918
- research_disposition: change.research_disposition,
919
- },
920
- git: {
921
- head: spine.resume.git_head,
922
- worktree_state: spine.resume.worktree_state,
923
- worktree_digest: spine.resume.worktree_digest,
924
- },
925
- tasks: spine.task_scope,
926
- selected_task: spine.selected_task,
927
- specs,
928
- role: spine.role,
929
- gate: spine.gate,
930
- control: spine.control,
931
- readiness: spine.readiness,
932
- context: spine.context,
933
- execution_contract: spine.execution_contract,
934
- task_context_contract: spine.task_context_contract,
935
- baseline: spine.baseline,
936
- resume: spine.resume,
937
- providers,
938
- provider_boundary: 'metadata references only; memory and code graph content remain external',
939
- };
940
- const identityHash = crypto.createHash('sha256')
941
- .update(JSON.stringify(semanticManifest))
942
- .digest('hex');
943
- const snapshotId = `ctx-${identityHash.slice(0, 12)}`;
944
- const existing = db.prepare(
945
- `SELECT manifest_path, manifest_hash
946
- FROM context_snapshots WHERE id = ?`,
947
- ).get(snapshotId);
948
- if (existing) {
949
- const existingPath = path.resolve(rootDir, existing.manifest_path);
950
- if (!fs.existsSync(existingPath)) {
951
- throw new ChangeWorkflowError(
952
- 'CONTEXT_SNAPSHOT_CORRUPT',
953
- `content-addressed Context snapshot is missing: ${existing.manifest_path}`,
954
- );
955
- }
956
- const bytes = fs.readFileSync(existingPath);
957
- const existingHash = crypto.createHash('sha256').update(bytes).digest('hex');
958
- if (existingHash !== existing.manifest_hash) {
959
- throw new ChangeWorkflowError(
960
- 'CONTEXT_SNAPSHOT_CORRUPT',
961
- `content-addressed Context snapshot digest changed: ${existing.manifest_path}`,
962
- );
963
- }
964
- return {
965
- manifest: JSON.parse(bytes.toString('utf8')),
966
- recommendation,
967
- context_manifest_path: existingPath,
968
- manifest_hash: existingHash,
969
- };
970
- }
971
- const manifest = {
972
- schema_version: semanticManifest.schema_version,
973
- snapshot_id: snapshotId,
974
- generated_at: nowIso(),
975
- ...Object.fromEntries(Object.entries(semanticManifest).filter(([key]) => key !== 'schema_version')),
976
- };
977
- const artifactDir = path.resolve(rootDir, change.artifact_root);
978
- fs.mkdirSync(artifactDir, { recursive: true });
979
- const manifestPath = path.join(artifactDir, 'contexts', `${snapshotId}.json`);
980
- fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
981
- const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
982
- fs.writeFileSync(manifestPath, serialized);
983
- const hash = crypto.createHash('sha256').update(serialized).digest('hex');
984
- const relative = path.relative(rootDir, manifestPath);
985
-
986
- ops.tx(db, () => {
987
- db.prepare(
988
- `INSERT INTO context_snapshots
989
- (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash,
990
- role, gate, next_action, allowed_transitions_json, required_transition,
991
- readiness, blockers_json, context_json, token_estimate, token_budget)
992
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
993
- ).run(
994
- snapshotId, change.id, input.task_id || null,
995
- spine.resume.git_head, JSON.stringify(providers), relative, hash,
996
- spine.role, spine.gate, '',
997
- JSON.stringify(spine.control.allowed_transitions), spine.control.required_transition,
998
- spine.readiness.status,
999
- JSON.stringify(spine.readiness.blockers), JSON.stringify(durableSpine),
1000
- spine.context.token_estimate, spine.context.budget.max_tokens,
1001
- );
1002
- upsertArtifact(db, {
1003
- change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
1004
- artifactPath: relative, contentHash: hash,
1005
- rootDir,
1006
- metadata: {
1007
- git_head: spine.resume.git_head,
1008
- worktree_digest: spine.resume.worktree_digest,
1009
- role: spine.role, gate: spine.gate,
1010
- readiness: spine.readiness.status, token_estimate: spine.context.token_estimate,
1011
- },
1012
- });
1013
- for (const task of tasks) {
1014
- if (!task.trace_to) continue;
1015
- db.prepare(
1016
- `INSERT OR IGNORE INTO trace_links
1017
- (change_id, task_id, source_ref, target_ref, relation) VALUES (?, ?, ?, ?, ?)`,
1018
- ).run(change.id, task.id, task.trace_to, `task:${task.id}`, 'specified_by');
1019
- }
1020
- ops.appendEventInTx(db, {
1021
- type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
1022
- payload: {
1023
- manifest_path: relative, manifest_hash: hash, git_head: spine.resume.git_head,
1024
- worktree_digest: spine.resume.worktree_digest,
1025
- role: spine.role, gate: spine.gate, readiness: spine.readiness.status,
1026
- allowed_transitions: spine.control.allowed_transitions,
1027
- required_transition: spine.control.required_transition,
1028
- },
1029
- });
1030
- });
1031
- return {
1032
- manifest,
1033
- recommendation,
1034
- context_manifest_path: manifestPath,
1035
- manifest_hash: hash,
1036
- };
1037
- }
1038
-
1039
- function createChange(db, input, {
1040
- rootDir = process.cwd(),
1041
- kernelMode = false,
1042
- } = {}) {
1043
- assertExactFields(input, CHANGE_CREATE_FIELDS, 'change');
1044
- const id = typeof input?.id === 'string' ? input.id.trim() : '';
1045
- const title = typeof input?.title === 'string' ? input.title.trim() : '';
1046
- const intent = typeof input?.intent === 'string' ? input.intent.trim() : '';
1047
- if (!input || !CHANGE_ID.test(id) || title.length < 3 || intent.length < 3) {
1048
- throw new ChangeWorkflowError(
1049
- 'VALIDATION_ERROR',
1050
- 'id plus non-blank title and intent of at least three characters are required',
1051
- );
1052
- }
1053
- if (!kernelMode && !CHANGE_KINDS.has(input.kind)) {
1054
- throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid change kind: ${input.kind}`);
1055
- }
1056
- const kind = kernelMode
1057
- ? boundedVocabulary(input.kind, 'change.kind')
1058
- : input.kind;
1059
- if (input.base_commit !== undefined && input.base_commit !== null
1060
- && typeof input.base_commit !== 'string') {
1061
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'base_commit must be a string or null');
1062
- }
1063
- if (input.alignment_thread_id !== undefined
1064
- && (typeof input.alignment_thread_id !== 'string'
1065
- || input.alignment_thread_id.trim() === '')) {
1066
- throw new ChangeWorkflowError(
1067
- 'VALIDATION_ERROR', 'alignment_thread_id must be a non-empty string',
1068
- );
1069
- }
1070
- if (input.supersedes_id !== undefined && input.supersedes_id !== null
1071
- && (typeof input.supersedes_id !== 'string'
1072
- || !CHANGE_ID.test(input.supersedes_id.trim()))) {
1073
- throw new ChangeWorkflowError(
1074
- 'VALIDATION_ERROR',
1075
- 'supersedes_id must be a valid Change id',
1076
- );
1077
- }
1078
- const baseCommit = typeof input.base_commit === 'string'
1079
- ? input.base_commit.trim()
1080
- : null;
1081
- if (input.baseline_bypass !== undefined && kind !== 'incident') {
1082
- throw new ChangeWorkflowError(
1083
- 'VALIDATION_ERROR', 'baseline_bypass is valid only for incident changes',
1084
- );
1085
- }
1086
- if (readChange(db, id)) throw new ChangeWorkflowError('DUPLICATE_CHANGE_ID', `change ${id} exists`);
1087
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
1088
- let baselineBypass = null;
1089
- if (baselineHealth.status !== 'pass' && !kernelMode) {
1090
- if (kind !== 'incident') {
1091
- throw new ChangeWorkflowError(
1092
- 'BASELINE_NOT_READY',
1093
- `ordinary changes require a ready baseline: ${baselineHealth.blockers.join(', ')}`,
1094
- { blockers: baselineHealth.blockers },
1095
- );
1096
- }
1097
- baselineBypass = normalizeBaselineBypass(input.baseline_bypass);
1098
- } else if (input.baseline_bypass !== undefined) {
1099
- throw new ChangeWorkflowError(
1100
- 'VALIDATION_ERROR', 'baseline_bypass is not valid while the project baseline is healthy',
1101
- );
1102
- }
1103
- const alignmentGate = kernelMode ? { ready: true, blockers: [] } : legacyDecisions().decisionGate(
1104
- db, { baseline_id: baselineHealth.baseline?.id || null }, { rootDir },
1105
- );
1106
- if (!alignmentGate.ready) {
1107
- throw new ChangeWorkflowError(
1108
- 'CHANGE_ALIGNMENT_REQUIRED',
1109
- `resolve and confirm the active baseline alignment before creating a change: ${alignmentGate.blockers.join(', ')}`,
1110
- { blockers: alignmentGate.blockers, thread_id: alignmentGate.thread?.id || null },
1111
- );
1112
- }
1113
- const docsImpact = normalizeDocsImpact(input.docs_impact);
1114
- const providers = normalizeProviderRefs(input.provider_refs);
1115
- const contract = normalizeChangeContract(input.contract, { allowDraft: kernelMode });
1116
- const classification = normalizeClassification(input.classification, kind, {
1117
- allowDraft: kernelMode,
1118
- });
1119
- const researchDisposition = normalizeResearchDisposition(input.research_disposition, kind, {
1120
- allowDraft: kernelMode,
1121
- });
1122
- let alignmentThread = null;
1123
- if (input.alignment_thread_id !== undefined) {
1124
- try {
1125
- alignmentThread = legacyDecisions().assertConfirmedDecisionCheckpoint(
1126
- db, input.alignment_thread_id, { rootDir, requireArtifact: true },
1127
- );
1128
- } catch (error) {
1129
- throw new ChangeWorkflowError(
1130
- 'CHANGE_ALIGNMENT_REQUIRED',
1131
- `alignment_thread_id must name a current artifact-bound checkpoint: ${error.message}`,
1132
- );
1133
- }
1134
- if (alignmentThread.change_id) {
1135
- throw new ChangeWorkflowError(
1136
- 'CHANGE_ALIGNMENT_REQUIRED', `alignment thread already belongs to ${alignmentThread.change_id}`,
1137
- );
1138
- }
1139
- if (!baselineHealth.baseline?.id || alignmentThread.baseline_id !== baselineHealth.baseline.id
1140
- || alignmentThread.workflow_run_id) {
1141
- throw new ChangeWorkflowError(
1142
- 'CHANGE_ALIGNMENT_REQUIRED',
1143
- 'change alignment must be bound only to the current baseline before change creation',
1144
- );
1145
- }
1146
- }
1147
- const artifactRoot = path.join('.ultra', 'changes', 'active', id);
1148
- const artifactDir = path.resolve(rootDir, artifactRoot);
1149
- if (fs.existsSync(artifactDir)) {
1150
- throw new ChangeWorkflowError('CHANGE_ARTIFACT_EXISTS', `change artifact already exists: ${artifactDir}`);
1151
- }
1152
- const ts = nowIso();
1153
- const row = {
1154
- id, title, kind, status: 'active',
1155
- intent, docs_impact: docsImpact, provider_refs: providers, baseline_bypass: baselineBypass,
1156
- contract, classification, research_disposition: researchDisposition,
1157
- alignment_thread_id: alignmentThread?.id || null,
1158
- supersedes_id: input.supersedes_id?.trim() || null,
1159
- base_commit: baseCommit || gitHead(rootDir), artifact_root: artifactRoot,
1160
- created_at: ts, updated_at: ts,
1161
- };
1162
- try {
1163
- return ops.tx(db, () => {
1164
- db.prepare(
1165
- `INSERT INTO changes
1166
- (id, title, kind, status, intent, docs_impact_json, provider_refs_json,
1167
- baseline_bypass_json, contract_json, classification_json,
1168
- research_disposition_json, alignment_thread_id, base_commit, supersedes_id, artifact_root,
1169
- created_at, updated_at)
1170
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1171
- ).run(
1172
- row.id, row.title, row.kind, row.status, row.intent, JSON.stringify(row.docs_impact),
1173
- JSON.stringify(row.provider_refs), row.baseline_bypass
1174
- ? JSON.stringify(row.baseline_bypass) : null,
1175
- JSON.stringify(row.contract), JSON.stringify(row.classification),
1176
- JSON.stringify(row.research_disposition), row.alignment_thread_id,
1177
- row.base_commit, row.supersedes_id, row.artifact_root, ts, ts,
1178
- );
1179
- if (row.alignment_thread_id) {
1180
- db.prepare(
1181
- 'UPDATE decision_threads SET change_id = ?, updated_at = ? WHERE id = ?',
1182
- ).run(row.id, ts, row.alignment_thread_id);
1183
- }
1184
- fs.mkdirSync(artifactDir, { recursive: true });
1185
- const intentPath = path.join(artifactDir, 'intent.md');
1186
- writeIntent(intentPath, row);
1187
- if (row.kind === 'incident') {
1188
- const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
1189
- writeIncidentDiagnosis(diagnosisPath, row);
1190
- }
1191
- ops.appendEventInTx(db, {
1192
- type: 'change_created', change_id: row.id,
1193
- payload: {
1194
- kind: row.kind, artifact_root: row.artifact_root,
1195
- alignment_thread_id: row.alignment_thread_id,
1196
- supersedes_id: row.supersedes_id,
1197
- },
1198
- });
1199
- let workflow = kernelMode ? null : legacyWorkflows().startWorkflow(db, {
1200
- kind: 'change', baseline_id: baselineHealth.baseline?.id || null,
1201
- change_id: row.id, subject: `Establish executable readiness for ${row.title}.`,
1202
- metadata: { change_kind: row.kind, base_commit: row.base_commit },
1203
- }, { rootDir });
1204
- upsertArtifact(db, {
1205
- change_id: row.id, kind: 'intent', artifactPath: path.relative(rootDir, intentPath),
1206
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
1207
- workflow_id: workflow?.id || null,
1208
- consumer_ref: kernelMode
1209
- ? { type: 'external', id: 'ultra-plan', relation: 'consumed_by' }
1210
- : null,
1211
- writer: kernelMode ? 'ultra.record' : 'change-workflow',
1212
- rootDir,
1213
- });
1214
- if (row.kind === 'incident') {
1215
- const diagnosisPath = path.join(artifactDir, 'diagnosis.md');
1216
- upsertArtifact(db, {
1217
- change_id: row.id,
1218
- kind: 'diagnosis',
1219
- artifactPath: path.relative(rootDir, diagnosisPath),
1220
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(diagnosisPath)).digest('hex'),
1221
- metadata: { required_sections: INCIDENT_DIAGNOSIS_SECTIONS.map(({ key }) => key) },
1222
- workflow_id: workflow?.id || null,
1223
- consumer_ref: kernelMode
1224
- ? { type: 'external', id: 'ultra-dev', relation: 'consumed_by' }
1225
- : null,
1226
- writer: kernelMode ? 'ultra.record' : 'change-workflow',
1227
- rootDir,
1228
- });
1229
- }
1230
- if (kernelMode) {
1231
- return {
1232
- change: readChange(db, row.id),
1233
- intent_path: intentPath,
1234
- context_manifest_path: null,
1235
- workflow: null,
1236
- diagnostics: baselineHealth.status === 'pass'
1237
- ? []
1238
- : (baselineHealth.blockers || []).map((code) => ({
1239
- code,
1240
- severity: 'needs_attention',
1241
- })),
1242
- };
1243
- }
1244
- workflow = legacyWorkflows().recordWorkflowStep(db, {
1245
- id: workflow.id, step_id: 'bind-baseline', status: 'completed',
1246
- evidence: [{
1247
- kind: 'baseline', ref: baselineHealth.baseline?.id || 'incident-break-glass',
1248
- summary: baselineHealth.status === 'pass'
1249
- ? `Bound ready baseline at ${baselineHealth.baseline.repository_revision}.`
1250
- : 'Bound the approved incident break-glass authority.',
1251
- }],
1252
- }, { rootDir });
1253
- workflow = legacyWorkflows().recordWorkflowStep(db, {
1254
- id: workflow.id, step_id: 'classify-change', status: 'completed',
1255
- decisions: [{
1256
- kind: 'change_kind', value: row.kind,
1257
- rationale: row.classification.rationale, risk_flags: row.classification.risk_flags,
1258
- }],
1259
- }, { rootDir });
1260
- workflow = legacyWorkflows().recordWorkflowStep(db, {
1261
- id: workflow.id, step_id: 'record-intent', status: 'completed',
1262
- evidence: [{ kind: 'intent', ref: path.relative(rootDir, intentPath), summary: row.intent }],
1263
- outputs: [{ path: path.relative(rootDir, intentPath), kind: 'change-intent' }],
1264
- decisions: [{
1265
- kind: 'change_contract', acceptance_ids: row.contract.acceptance.map((item) => item.id),
1266
- research_disposition: row.research_disposition,
1267
- }],
1268
- }, { rootDir });
1269
- workflow = legacyWorkflows().completeWorkflow(db, { id: workflow.id }, { rootDir });
1270
- return {
1271
- change: readChange(db, row.id), intent_path: intentPath,
1272
- context_manifest_path: null,
1273
- workflow,
1274
- };
1275
- });
1276
- } catch (error) {
1277
- fs.rmSync(artifactDir, { recursive: true, force: true });
1278
- throw error;
1279
- }
1280
- }
1281
-
1282
- function assertTaskCreationAllowed(db, input = {}, { rootDir = process.cwd() } = {}) {
1283
- const change = input.change_id ? readChange(db, input.change_id) : null;
1284
- if (input.change_id && !change) {
1285
- throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.change_id} not found`);
1286
- }
1287
- if (change && !['active', 'blocked'].includes(change.status)) {
1288
- throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${change.id} is ${change.status}`);
1289
- }
1290
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
1291
- if (baselineHealth.status === 'pass') return;
1292
- const gate = change
1293
- ? legacyContextSpine().baselineGateForChange(db, change, baselineHealth)
1294
- : { blockers: baselineHealth.blockers };
1295
- if (gate.blockers.length === 0) return;
1296
- throw new ChangeWorkflowError(
1297
- 'BASELINE_NOT_READY',
1298
- `task creation requires healthy baseline authority or an approved incident break-glass record: ${gate.blockers.join(', ')}`,
1299
- { blockers: gate.blockers, change_id: input.change_id || null },
1300
- );
1301
- }
1302
-
1303
- function updateChange(db, id, patch = {}, {
1304
- rootDir = process.cwd(),
1305
- kernelMode = false,
1306
- } = {}) {
1307
- const normalizedId = typeof id === 'string' ? id.trim() : '';
1308
- if (!CHANGE_ID.test(normalizedId)) {
1309
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'change id is invalid');
1310
- }
1311
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1312
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'change patch must be an object');
1313
- }
1314
- const current = readChange(db, normalizedId);
1315
- if (!current) {
1316
- throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${normalizedId} not found`);
1317
- }
1318
- if (['archived', 'cancelled'].includes(current.status)) {
1319
- throw new ChangeWorkflowError('CHANGE_NOT_MUTABLE', `change ${id} is ${current.status}`);
1320
- }
1321
- for (const field of Object.keys(patch)) {
1322
- if (!CHANGE_PATCH_FIELDS.has(field)) {
1323
- throw new ChangeWorkflowError('VALIDATION_ERROR', `change field ${field} is not patchable`);
1324
- }
1325
- }
1326
- if (kernelMode && patch.status !== undefined
1327
- && !['active', 'cancelled'].includes(patch.status)) {
1328
- throw new ChangeWorkflowError(
1329
- 'VALIDATION_ERROR',
1330
- 'kernel Change status may only be active or cancelled; readiness is derived from checkpoints',
1331
- );
1332
- }
1333
- const syncIntent = [
1334
- 'title', 'intent', 'docs_impact', 'contract', 'classification', 'research_disposition',
1335
- ].some((field) => patch[field] !== undefined);
1336
- const intentPath = path.resolve(rootDir, current.artifact_root, 'intent.md');
1337
- const previousIntent = syncIntent && fs.existsSync(intentPath)
1338
- ? fs.readFileSync(intentPath)
1339
- : null;
1340
- try {
1341
- return ops.tx(db, () => {
1342
- const sets = [];
1343
- const values = [];
1344
- if (patch.title !== undefined) {
1345
- const title = typeof patch.title === 'string' ? patch.title.trim() : '';
1346
- if (!title) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change title cannot be empty');
1347
- sets.push('title = ?'); values.push(title);
1348
- }
1349
- if (patch.intent !== undefined) {
1350
- const intent = typeof patch.intent === 'string' ? patch.intent.trim() : '';
1351
- if (!intent) throw new ChangeWorkflowError('VALIDATION_ERROR', 'change intent cannot be empty');
1352
- sets.push('intent = ?'); values.push(intent);
1353
- }
1354
- if (patch.docs_impact !== undefined) {
1355
- sets.push('docs_impact_json = ?'); values.push(JSON.stringify(normalizeDocsImpact(patch.docs_impact)));
1356
- }
1357
- if (patch.provider_refs !== undefined) {
1358
- sets.push('provider_refs_json = ?'); values.push(JSON.stringify(normalizeProviderRefs(patch.provider_refs)));
1359
- }
1360
- if (patch.contract !== undefined) {
1361
- sets.push('contract_json = ?');
1362
- values.push(JSON.stringify(normalizeChangeContract(patch.contract, {
1363
- allowDraft: kernelMode,
1364
- })));
1365
- }
1366
- if (patch.classification !== undefined) {
1367
- sets.push('classification_json = ?');
1368
- values.push(JSON.stringify(normalizeClassification(patch.classification, current.kind, {
1369
- allowDraft: kernelMode,
1370
- })));
1371
- }
1372
- if (patch.research_disposition !== undefined) {
1373
- sets.push('research_disposition_json = ?');
1374
- values.push(JSON.stringify(normalizeResearchDisposition(
1375
- patch.research_disposition,
1376
- current.kind,
1377
- { allowDraft: kernelMode },
1378
- )));
1379
- }
1380
- if (patch.status !== undefined) {
1381
- const allowed = kernelMode
1382
- ? new Set(['active', 'cancelled'])
1383
- : (CHANGE_TRANSITIONS[current.status] || new Set());
1384
- if (!allowed.has(patch.status)) {
1385
- throw new ChangeWorkflowError(
1386
- 'ILLEGAL_CHANGE_TRANSITION', `cannot transition change ${id} from ${current.status} to ${patch.status}`,
1387
- );
1388
- }
1389
- sets.push('status = ?'); values.push(patch.status);
1390
- } else if (['ready', 'blocked'].includes(current.status)
1391
- && Object.keys(patch).length > 0) {
1392
- sets.push('status = ?'); values.push('active');
1393
- }
1394
- if (sets.length === 0) return current;
1395
- sets.push('updated_at = ?'); values.push(nowIso(), normalizedId);
1396
- db.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE id = ?`).run(...values);
1397
- const updated = readChange(db, normalizedId);
1398
- const invalidated = [];
1399
- let intentArtifactId = null;
1400
- if (syncIntent) {
1401
- fs.mkdirSync(path.dirname(intentPath), { recursive: true });
1402
- writeIntent(intentPath, updated);
1403
- const recorded = upsertArtifact(db, {
1404
- change_id: normalizedId,
1405
- kind: 'intent',
1406
- artifactPath: path.relative(rootDir, intentPath),
1407
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
1408
- consumer_ref: kernelMode
1409
- ? { type: 'external', id: 'ultra-plan', relation: 'consumed_by' }
1410
- : null,
1411
- writer: kernelMode ? 'ultra.record' : 'change-workflow',
1412
- rootDir,
1413
- });
1414
- intentArtifactId = recorded.artifact.id;
1415
- invalidated.push(...recorded.invalidated);
1416
- }
1417
- const semanticFields = Object.keys(patch)
1418
- .filter((field) => SEMANTIC_AUTHORITY_FIELDS.has(field));
1419
- if (semanticFields.length > 0
1420
- && changeAuthority.changeStateDigest(current)
1421
- !== changeAuthority.changeStateDigest(updated)) {
1422
- invalidated.push(...artifacts.invalidateConsumersFromEndpointInTx(
1423
- db,
1424
- { type: 'change', id: normalizedId },
1425
- {
1426
- reason: 'change_semantic_authority_updated',
1427
- exclude: intentArtifactId ? [{ type: 'artifact', id: intentArtifactId }] : [],
1428
- },
1429
- ));
1430
- }
1431
- const invalidatedTasks = [...new Set(
1432
- invalidated.filter((item) => item.type === 'task').map((item) => item.id),
1433
- )].sort();
1434
- ops.appendEventInTx(db, {
1435
- type: 'change_updated',
1436
- change_id: normalizedId,
1437
- payload: {
1438
- fields: Object.keys(patch),
1439
- invalidated_tasks: invalidatedTasks,
1440
- invalidated_authorities: invalidated,
1441
- },
1442
- });
1443
- return updated;
1444
- });
1445
- } catch (error) {
1446
- if (syncIntent) {
1447
- if (previousIntent === null) fs.rmSync(intentPath, { force: true });
1448
- else fs.writeFileSync(intentPath, previousIntent);
1449
- }
1450
- throw error;
1451
- }
1452
- }
1453
-
1454
- function recordDelta(db, input, { rootDir = process.cwd() } = {}) {
1455
- const change = readChange(db, input?.id);
1456
- if (!change) {
1457
- throw new ChangeWorkflowError(
1458
- 'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
1459
- );
1460
- }
1461
- return changePacket.recordDelta(db, change, input, { rootDir });
1462
- }
1463
-
1464
- function recordDocumentationReconciliation(
1465
- db,
1466
- input,
1467
- { rootDir = process.cwd() } = {},
1468
- ) {
1469
- const change = readChange(db, input?.id);
1470
- if (!change) {
1471
- throw new ChangeWorkflowError(
1472
- 'CHANGE_NOT_FOUND', `change ${input?.id || '(missing)'} not found`,
1473
- );
1474
- }
1475
- return changePacket.recordDocumentationReconciliation(
1476
- db,
1477
- change,
1478
- input,
1479
- { rootDir },
1480
- );
1481
- }
1482
-
1483
- function filesUnder(dir) {
1484
- if (!fs.existsSync(dir)) return [];
1485
- const out = [];
1486
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1487
- const full = path.join(dir, entry.name);
1488
- if (entry.isDirectory()) out.push(...filesUnder(full));
1489
- else if (entry.isFile()) out.push(full);
1490
- }
1491
- return out;
1492
- }
1493
-
1494
- function markdownAnchor(text, fallback) {
1495
- const heading = String(text).match(/^#{1,6}\s+(.+?)\s*#*\s*$/m)?.[1] || fallback;
1496
- return String(heading || 'change')
1497
- .trim()
1498
- .toLowerCase()
1499
- .replace(/[^\p{L}\p{N}\s-]/gu, '')
1500
- .replace(/\s+/g, '-')
1501
- .replace(/-+/g, '-');
1502
- }
1503
-
1504
- function generatePacketReconciliation(db, change, packet, rootDir) {
1505
- const baseline = baselines.readBaseline(db);
1506
- const semanticChanges = packet.delta.value.mutations.map((mutation) => {
1507
- const overlay = safeRelativePath(rootDir, mutation.overlay_path);
1508
- const anchor = markdownAnchor(
1509
- fs.readFileSync(overlay, 'utf8'),
1510
- path.basename(mutation.target_path, path.extname(mutation.target_path)),
1511
- );
1512
- return {
1513
- id: `delta-${mutation.id}`,
1514
- action: mutation.action,
1515
- source_ref: `${mutation.target_path}#${anchor}`,
1516
- before_digest: mutation.before_digest,
1517
- after_digest: mutation.after_digest,
1518
- };
1519
- });
1520
- const verification = [{
1521
- name: 'typed Change packet read-back',
1522
- command: 'change.delta',
1523
- status: 'pass',
1524
- evidence: `Change delta ${packet.delta.artifact.id} @ ${packet.delta.artifact.digest} and documentation reconciliation ${packet.documentation?.artifact.id || 'none-required'} were read back before Deliver.`,
1525
- }];
1526
- const manifest = {
1527
- $schema: 'ultra-baseline-reconciliation-v1',
1528
- change_id: change.id,
1529
- baseline_id: baseline?.id || null,
1530
- baseline_updates: packet.baseline_updates,
1531
- semantic_changes: semanticChanges,
1532
- resolved_gap_ids: [],
1533
- resolved_unknowns: [],
1534
- verification,
1535
- ...(semanticChanges.length === 0
1536
- ? {
1537
- semantic_no_change_reason: packet.delta.value.no_semantic_change_reason
1538
- || 'The accepted Change has no baseline specification mutation.',
1539
- }
1540
- : {}),
1541
- };
1542
- const relative = `${change.artifact_root}/baseline-reconciliation.json`;
1543
- const file = safeRelativePath(rootDir, relative);
1544
- const previous = fs.existsSync(file) ? fs.readFileSync(file) : null;
1545
- const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
1546
- fs.writeFileSync(file, serialized);
1547
- try {
1548
- const recorded = artifacts.recordArtifact(db, {
1549
- id: `${change.id}-baseline-reconciliation`,
1550
- owner_type: 'change',
1551
- owner_id: change.id,
1552
- kind: 'baseline_reconciliation',
1553
- path: relative,
1554
- content_digest: crypto.createHash('sha256').update(serialized).digest('hex'),
1555
- source_refs: [
1556
- { type: 'artifact', id: packet.delta.artifact.id, relation: 'reconciles' },
1557
- ...(packet.documentation
1558
- ? [{
1559
- type: 'artifact',
1560
- id: packet.documentation.artifact.id,
1561
- relation: 'reconciles_documentation',
1562
- }]
1563
- : []),
1564
- ],
1565
- consumer_refs: [],
1566
- provenance: { writer: 'change.deliver', generated: true },
1567
- metadata: { terminal_role: true, schema: manifest.$schema },
1568
- }, { rootDir }).artifact;
1569
- return {
1570
- manifest,
1571
- relative,
1572
- file,
1573
- digest: recorded.digest,
1574
- artifact: recorded,
1575
- };
1576
- } catch (error) {
1577
- if (previous === null) fs.rmSync(file, { force: true });
1578
- else fs.writeFileSync(file, previous);
1579
- throw error;
1580
- }
1581
- }
1582
-
1583
- function generateCallerNoChangeReconciliation(db, change, input, rootDir) {
1584
- const baseline = baselines.readBaseline(db);
1585
- const manifest = {
1586
- $schema: 'ultra-baseline-reconciliation-v1',
1587
- change_id: change.id,
1588
- baseline_id: baseline?.id || null,
1589
- baseline_updates: [],
1590
- semantic_changes: [],
1591
- semantic_no_change_reason: String(input.no_baseline_change_reason).trim(),
1592
- resolved_gap_ids: [],
1593
- resolved_unknowns: [],
1594
- verification: [{
1595
- name: 'explicit archive handoff',
1596
- command: 'ultra.archive',
1597
- status: 'pass',
1598
- evidence: String(input.summary).trim(),
1599
- }],
1600
- };
1601
- const relative = `${change.artifact_root}/baseline-reconciliation.json`;
1602
- const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
1603
- const file = safeRelativePath(rootDir, relative);
1604
- const previous = fs.existsSync(file) ? fs.readFileSync(file) : null;
1605
- const published = writeManagedFile(rootDir, relative, Buffer.from(serialized));
1606
- try {
1607
- const recorded = artifacts.recordArtifact(db, {
1608
- id: `${change.id}-baseline-reconciliation`,
1609
- owner_type: 'change',
1610
- owner_id: change.id,
1611
- kind: 'baseline_reconciliation',
1612
- path: relative,
1613
- content_digest: published.digest,
1614
- source_refs: [],
1615
- consumer_refs: [],
1616
- provenance: { writer: 'ultra.archive', generated: true },
1617
- metadata: {
1618
- terminal_role: true,
1619
- schema: manifest.$schema,
1620
- caller_declared: true,
1621
- },
1622
- }, { rootDir }).artifact;
1623
- return {
1624
- manifest,
1625
- relative,
1626
- file,
1627
- digest: recorded.digest,
1628
- artifact: recorded,
1629
- };
1630
- } catch (error) {
1631
- if (previous === null) fs.rmSync(file, { force: true });
1632
- else fs.writeFileSync(file, previous);
1633
- throw error;
1634
- }
1635
- }
1636
-
1637
- function baselineConvergenceBlockers(db, change, rootDir) {
1638
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
1639
- return legacyContextSpine().baselineGateForChange(db, change, baselineHealth).blockers;
1640
- }
1641
-
1642
- function deriveConvergenceEvidence(db, change, tasks, workflowGate, diagnosis, rootDir) {
1643
- const devRuns = db.prepare(
1644
- `SELECT id, task_id, summary_json, metadata_json FROM workflow_runs
1645
- WHERE kind = 'dev' AND change_id = ? AND status = 'completed'
1646
- ORDER BY completed_at ASC, rowid ASC`,
1647
- ).all(change.id)
1648
- .filter((row) => legacyWorkflows().isConsumableWorkflowAuthority({
1649
- metadata: parseJson(row.metadata_json, 'workflow_runs.metadata_json'),
1650
- }))
1651
- .map((row) => ({
1652
- id: row.id, task_id: row.task_id,
1653
- summary: parseJson(row.summary_json, 'workflow_runs.summary_json'),
1654
- }));
1655
- const deltaAuthority = changePacket.readDeltaAuthority(db, change.id);
1656
- const deltaPaths = deltaAuthority
1657
- ? [deltaAuthority.path, deltaAuthority.digest]
1658
- : [];
1659
- const appliedLearning = legacySpecLearning().listSpecLearning(db, change.id)
1660
- .filter((candidate) => candidate.status === 'applied')
1661
- .map((candidate) => candidate.target_ref);
1662
- const rows = [
1663
- {
1664
- category: 'diff', status: 'pass',
1665
- evidence: `Completed dev workflows: ${devRuns.map((run) => `${run.task_id}:${run.id}`).join(', ')}`,
1666
- refs: devRuns.map((run) => run.id),
1667
- },
1668
- {
1669
- category: 'tests', status: 'pass',
1670
- evidence: `DB-derived passing test workflow ${workflowGate.test_workflow_id}.`,
1671
- refs: [workflowGate.test_summary.report_path, workflowGate.test_summary.report_digest].filter(Boolean),
1672
- regression_signal: workflowGate.test_summary.regression_signal || null,
1673
- },
1674
- {
1675
- category: 'spec', status: 'pass',
1676
- evidence: deltaPaths.length > 0
1677
- ? `Current delta artifacts: ${deltaPaths.join(', ')}`
1678
- : `Change intent and resolved specification learning for ${change.id}.`,
1679
- refs: [...deltaPaths, ...appliedLearning],
1680
- },
1681
- {
1682
- category: 'docs', status: 'pass',
1683
- evidence: change.docs_impact.status === 'required'
1684
- ? `Declared documentation files: ${change.docs_impact.files.join(', ')}`
1685
- : `No documentation change: ${change.docs_impact.rationale}`,
1686
- refs: change.docs_impact.files,
1687
- },
1688
- ...['spec_fidelity', 'engineering_standards'].map((axis) => ({
1689
- category: 'review', axis, status: 'pass',
1690
- evidence: `DB-derived ${axis} verdict from ${workflowGate.review_workflow_id}.`,
1691
- refs: workflowGate.review_summary.axes?.[axis]?.evidence_refs || [],
1692
- })),
1693
- ];
1694
- if (diagnosis.artifact) {
1695
- rows.unshift({
1696
- category: 'diagnosis', status: 'pass',
1697
- evidence: `Structured incident diagnosis ${diagnosis.artifact.artifactPath}.`,
1698
- refs: [diagnosis.artifact.artifactPath, diagnosis.artifact.contentHash],
1699
- });
1700
- }
1701
- return rows;
1702
- }
1703
-
1704
- function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
1705
- if (Object.hasOwn(input || {}, 'evidence')) {
1706
- throw new ChangeWorkflowError(
1707
- 'CONVERGENCE_EVIDENCE_AUTHORITY_VIOLATION',
1708
- 'change convergence evidence is derived from DB-backed dev, test, review, specification, and documentation authority',
1709
- );
1710
- }
1711
- const change = readChange(db, input.id);
1712
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
1713
- if (!['active', 'blocked'].includes(change.status)) {
1714
- throw new ChangeWorkflowError('CHANGE_NOT_CONVERGEABLE', `change ${input.id} is ${change.status}`);
1715
- }
1716
- const tasks = db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
1717
- .all(change.id).map((task) => ops.readTask(db, task.id));
1718
- const blockers = new Set();
1719
- let workflowGate = null;
1720
- try {
1721
- workflowGate = legacyWorkflows().validateDeliveryPrerequisites(
1722
- db,
1723
- { change_id: change.id },
1724
- rootDir,
1725
- );
1726
- } catch (error) {
1727
- blockers.add(error.code || 'WORKFLOW_GATE_UNAVAILABLE');
1728
- }
1729
- for (const blocker of baselineConvergenceBlockers(db, change, rootDir)) blockers.add(blocker);
1730
- if (tasks.length === 0) blockers.add('NO_TASKS');
1731
- if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
1732
- if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
1733
- for (const task of tasks.filter((item) => item.status !== 'expanded')) {
1734
- const validation = legacyContextSpine().validateContextSnapshot(db, {
1735
- change_id: change.id,
1736
- task_id: task.id,
1737
- role: 'implement',
1738
- gate: 'implementation',
1739
- }, { rootDir });
1740
- if (!validation.snapshot) {
1741
- blockers.add(`TASK_CONTEXT_MISSING:${task.id}`);
1742
- continue;
1743
- }
1744
- for (const blocker of validation.blockers) {
1745
- blockers.add(`TASK_CONTEXT_INVALID:${task.id}:${blocker}`);
1746
- }
1747
- if (!validation.manifest?.execution_contract?.public_seam) {
1748
- blockers.add(`TASK_SEAM_MISSING:${task.id}`);
1749
- }
1750
- if (!validation.manifest?.execution_contract?.verification_command) {
1751
- blockers.add(`TASK_VERIFICATION_MISSING:${task.id}`);
1752
- }
1753
- }
1754
-
1755
- for (const candidate of legacySpecLearning().listSpecLearning(db, change.id)) {
1756
- if (candidate.status === 'proposed') blockers.add(`SPEC_LEARNING_UNRESOLVED:${candidate.id}`);
1757
- if (candidate.status === 'approved') blockers.add(`SPEC_LEARNING_NOT_APPLIED:${candidate.id}`);
1758
- }
1759
-
1760
- if (change.docs_impact.status === 'unknown') blockers.add('DOCS_IMPACT_UNKNOWN');
1761
- if (change.docs_impact.status === 'none' && (!change.docs_impact.rationale || change.docs_impact.rationale.length < 3)) {
1762
- blockers.add('DOCS_IMPACT_RATIONALE_MISSING');
1763
- }
1764
- if (change.docs_impact.status === 'required') {
1765
- if (change.docs_impact.files.length === 0) blockers.add('DOCS_FILES_UNDECLARED');
1766
- for (const file of change.docs_impact.files) {
1767
- if (!fs.existsSync(safeRelativePath(rootDir, file))) blockers.add(`DOCS_FILE_MISSING:${file}`);
1768
- }
1769
- }
1770
-
1771
- const artifactDir = path.resolve(rootDir, change.artifact_root);
1772
- const diagnosis = inspectIncidentDiagnosis(change, artifactDir, rootDir);
1773
- for (const blocker of diagnosis.blockers) blockers.add(blocker);
1774
- if (['standard', 'major'].includes(change.kind)) {
1775
- let delta = null;
1776
- try {
1777
- delta = changePacket.loadDelta(db, change.id, { rootDir });
1778
- } catch (error) {
1779
- blockers.add(error.code || 'CHANGE_DELTA_AUTHORITY_MISSING');
1780
- }
1781
- if (delta) {
1782
- try {
1783
- const documentation = changePacket.loadDocumentationReconciliation(
1784
- db, change.id, { rootDir },
1785
- );
1786
- if (documentation.value.delta_artifact_id !== delta.artifact.id
1787
- || documentation.value.delta_digest !== delta.artifact.digest) {
1788
- blockers.add('DOCUMENTATION_DELTA_AUTHORITY_INVALID');
1789
- }
1790
- } catch (error) {
1791
- blockers.add(error.code || 'DOCUMENTATION_RECONCILIATION_MISSING');
1792
- }
1793
- }
1794
- const planArtifact = db.prepare(
1795
- `SELECT id FROM artifacts
1796
- WHERE owner_type = 'change' AND owner_id = ? AND kind = 'execution_plan'
1797
- AND status = 'current'
1798
- ORDER BY updated_at DESC, rowid DESC`,
1799
- ).all(change.id);
1800
- if (planArtifact.length !== 1) blockers.add('CHANGE_PLAN_AUTHORITY_MISSING');
1801
- }
1802
- const openIncidents = db.prepare(
1803
- `SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'
1804
- AND (change_id = ? OR task_id IN (SELECT id FROM tasks WHERE change_id = ?))`,
1805
- ).get(change.id, change.id).count;
1806
- if (openIncidents > 0) blockers.add('OPEN_INCIDENTS');
1807
-
1808
- const blockerList = [...blockers].sort();
1809
- if (blockerList.length > 0) {
1810
- ops.tx(db, () => {
1811
- db.prepare("UPDATE changes SET status = 'blocked', updated_at = ? WHERE id = ?")
1812
- .run(nowIso(), change.id);
1813
- if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
1814
- ops.appendEventInTx(db, {
1815
- type: 'change_blocked', change_id: change.id, payload: { blockers: blockerList },
1816
- });
1817
- });
1818
- return { ready: false, status: 'blocked', blockers: blockerList };
1819
- }
1820
-
1821
- const derivedEvidence = deriveConvergenceEvidence(
1822
- db, change, tasks, workflowGate, diagnosis, rootDir,
1823
- );
1824
- const verificationPath = path.join(artifactDir, 'verification.md');
1825
- const lines = [
1826
- `# Verification: ${change.title}`, '',
1827
- ...derivedEvidence.flatMap((row) => [
1828
- `## ${row.category}${row.axis ? `:${row.axis}` : ''}`, '',
1829
- `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
1830
- ]),
1831
- ];
1832
- fs.writeFileSync(verificationPath, `${lines.join('\n')}\n`);
1833
- ops.tx(db, () => {
1834
- db.prepare("UPDATE changes SET status = 'ready', updated_at = ? WHERE id = ?")
1835
- .run(nowIso(), change.id);
1836
- if (diagnosis.artifact) upsertArtifact(db, { ...diagnosis.artifact, rootDir });
1837
- upsertArtifact(db, {
1838
- change_id: change.id, kind: 'verification', artifactPath: path.relative(rootDir, verificationPath),
1839
- contentHash: crypto.createHash('sha256').update(fs.readFileSync(verificationPath)).digest('hex'),
1840
- metadata: { evidence: derivedEvidence },
1841
- rootDir,
1842
- });
1843
- ops.appendEventInTx(db, {
1844
- type: 'change_converged', change_id: change.id,
1845
- payload: { evidence_categories: derivedEvidence.map((row) => row.category) },
1846
- });
1847
- });
1848
- return { ready: true, status: 'ready', blockers: [], verification_path: verificationPath };
1849
- }
1850
-
1851
- function replaceArchiveAuthorityText(
1852
- text,
1853
- source,
1854
- destination,
1855
- digestMap = new Map(),
1856
- { normalizePath = false } = {},
1857
- ) {
1858
- const original = String(text);
1859
- const windowsSource = source.replaceAll('/', '\\');
1860
- let result = original
1861
- .replaceAll(source, destination)
1862
- .replaceAll(windowsSource, destination);
1863
- if (normalizePath && original.includes(windowsSource)) {
1864
- result = result.replaceAll('\\', '/');
1865
- }
1866
- for (const [before, after] of digestMap) {
1867
- if (before !== after) result = result.replaceAll(before, after);
1868
- }
1869
- return result;
1870
- }
1871
-
1872
- function kernelArchiveContextPath(change, id) {
1873
- return `${change.artifact_root}/authority/context-envelopes/${id}.json`;
1874
- }
1875
-
1876
- function kernelArchiveWorkerPath(change, id) {
1877
- return `${change.artifact_root}/authority/worker-packets/${id}.json`;
1878
- }
1879
-
1880
- function materializeKernelArchiveAuthority(db, change, rootDir) {
1881
- const taskIds = db.prepare(
1882
- 'SELECT id FROM tasks WHERE change_id = ? ORDER BY id',
1883
- ).all(change.id).map((row) => row.id);
1884
- const contexts = db.prepare(
1885
- `SELECT * FROM context_envelopes
1886
- WHERE (scope_type = 'change' AND scope_id = ?)
1887
- OR (scope_type = 'task' AND scope_id IN (
1888
- SELECT id FROM tasks WHERE change_id = ?
1889
- ))
1890
- ORDER BY id`,
1891
- ).all(change.id, change.id);
1892
- for (const row of contexts) {
1893
- const read = readStableProjectFile(rootDir, row.artifact_path);
1894
- if (!row.file_digest || read.digest !== row.file_digest) {
1895
- throw new ChangeWorkflowError(
1896
- 'CONTEXT_ENVELOPE_FILE_DRIFT',
1897
- `cannot archive unbound Context Envelope ${row.id}`,
1898
- {
1899
- path: row.artifact_path,
1900
- expected: row.file_digest || null,
1901
- actual: read.digest,
1902
- },
1903
- );
1904
- }
1905
- const target = kernelArchiveContextPath(change, row.id);
1906
- const published = writeManagedFile(rootDir, target, read.bytes);
1907
- upsertArtifact(db, {
1908
- change_id: change.id,
1909
- task_id: row.scope_type === 'task' ? row.scope_id : null,
1910
- kind: 'archive_context_envelope',
1911
- artifactPath: target,
1912
- contentHash: published.digest,
1913
- consumer_ref: {
1914
- type: 'external',
1915
- id: 'ultra.archive',
1916
- relation: 'archived_by',
1917
- },
1918
- writer: 'ultra.archive',
1919
- rootDir,
1920
- });
1921
- }
1922
- if (taskIds.length === 0) return;
1923
- const packets = db.prepare(
1924
- `SELECT * FROM worker_packets
1925
- WHERE scope_type = 'task' AND status = 'assigned'
1926
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)
1927
- ORDER BY id`,
1928
- ).all(change.id);
1929
- for (const row of packets) {
1930
- const read = readStableProjectFile(rootDir, row.packet_path);
1931
- if (!row.file_digest || read.digest !== row.file_digest) {
1932
- throw new ChangeWorkflowError(
1933
- 'WORKER_PACKET_FILE_DRIFT',
1934
- `cannot archive unbound Worker Packet ${row.id}`,
1935
- {
1936
- path: row.packet_path,
1937
- expected: row.file_digest || null,
1938
- actual: read.digest,
1939
- },
1940
- );
1941
- }
1942
- const target = kernelArchiveWorkerPath(change, row.id);
1943
- const published = writeManagedFile(rootDir, target, read.bytes);
1944
- upsertArtifact(db, {
1945
- change_id: change.id,
1946
- task_id: row.scope_id,
1947
- kind: 'archive_worker_packet',
1948
- artifactPath: target,
1949
- contentHash: published.digest,
1950
- consumer_ref: {
1951
- type: 'external',
1952
- id: 'ultra.archive',
1953
- relation: 'archived_by',
1954
- },
1955
- writer: 'ultra.archive',
1956
- rootDir,
1957
- });
1958
- }
1959
- }
1960
-
1961
- function normalizedArchiveDocument(bytes, intent) {
1962
- const text = bytes.toString('utf8');
1963
- if (!Buffer.from(text, 'utf8').equals(bytes)) return { bytes, semanticDigest: null };
1964
- let document;
1965
- try { document = JSON.parse(text); }
1966
- catch { return { bytes, semanticDigest: null }; }
1967
- let semanticDigest = null;
1968
- if (document?.schema_version === '1.0'
1969
- && document.detail === 'full'
1970
- && document.envelope
1971
- && document.digest) {
1972
- if (document.envelope.change?.id === intent.change_id) {
1973
- document.envelope.change.status = 'archived';
1974
- }
1975
- semanticDigest = canonical.digest(document.envelope);
1976
- document.digest = semanticDigest;
1977
- } else if (document?.packet_version && document.packet_digest) {
1978
- const { packet_digest: _prior, ...value } = document;
1979
- semanticDigest = canonical.digest(value);
1980
- document.packet_digest = semanticDigest;
1981
- } else if (document?.schema_version === '1.0'
1982
- && document.question
1983
- && document.selection
1984
- && document.digest) {
1985
- const { digest: _prior, ...value } = document;
1986
- semanticDigest = canonical.digest(value);
1987
- document.digest = semanticDigest;
1988
- } else if (document?.change?.id === intent.change_id) {
1989
- document.change.status = 'archived';
1990
- }
1991
- return {
1992
- bytes: Buffer.from(`${JSON.stringify(document, null, 2)}\n`),
1993
- semanticDigest,
1994
- };
1995
- }
1996
-
1997
- function archiveRebindAuthorityValue(value, source, destination, digestMap) {
1998
- if (typeof value === 'string') {
1999
- return replaceArchiveAuthorityText(
2000
- value, source, destination, digestMap, { normalizePath: true },
2001
- );
2002
- }
2003
- if (Array.isArray(value)) {
2004
- return value.map(
2005
- (item) => archiveRebindAuthorityValue(item, source, destination, digestMap),
2006
- );
2007
- }
2008
- if (value && typeof value === 'object') {
2009
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
2010
- key,
2011
- archiveRebindAuthorityValue(item, source, destination, digestMap),
2012
- ]));
2013
- }
2014
- return value;
2015
- }
2016
-
2017
- function buildArchiveRebindEntries(db, intent, rootDir) {
2018
- const tree = walkStableProjectTree(rootDir, intent.destination, {
2019
- ignore: (relative) => (
2020
- relative === `${intent.destination}/${archiveJournal.INTENT_FILE}`
2021
- || relative === `${intent.destination}/${archiveJournal.REBIND_FILE}`
2022
- ),
2023
- });
2024
- if (tree.unsafe.length > 0) {
2025
- throw new ChangeWorkflowError(
2026
- 'ARCHIVE_PATH_UNSAFE',
2027
- `archive packet contains unsafe entries: ${tree.unsafe.map((item) => item.path).join(', ')}`,
2028
- { unsafe: tree.unsafe },
2029
- );
2030
- }
2031
- const originalByPath = new Map();
2032
- const semanticByPath = new Map();
2033
- for (const artifact of db.prepare(
2034
- 'SELECT path, digest, content_hash FROM artifacts WHERE change_id = ?',
2035
- ).all(intent.change_id)) {
2036
- const archivedPath = rebindRegistryPath(
2037
- artifact.path, intent.source, intent.destination,
2038
- );
2039
- const digest = artifact.digest || artifact.content_hash;
2040
- if (archivedPath && digest) originalByPath.set(archivedPath, digest);
2041
- }
2042
- const contextPaths = new Set();
2043
- for (const snapshot of db.prepare(
2044
- 'SELECT manifest_path, manifest_hash FROM context_snapshots WHERE change_id = ?',
2045
- ).all(intent.change_id)) {
2046
- const archivedPath = rebindRegistryPath(
2047
- snapshot.manifest_path, intent.source, intent.destination,
2048
- );
2049
- if (archivedPath) {
2050
- contextPaths.add(archivedPath);
2051
- originalByPath.set(archivedPath, snapshot.manifest_hash);
2052
- }
2053
- }
2054
- for (const row of db.prepare(
2055
- `SELECT id, digest FROM decision_records
2056
- WHERE scope_type = 'change' AND scope_id = ?`,
2057
- ).all(intent.change_id)) {
2058
- const archivedPath = `${intent.destination}/decisions/${row.id}.json`;
2059
- semanticByPath.set(archivedPath, row.digest);
2060
- }
2061
- for (const row of db.prepare(
2062
- `SELECT id, digest FROM context_envelopes
2063
- WHERE (scope_type = 'change' AND scope_id = ?)
2064
- OR (scope_type = 'task' AND scope_id IN (
2065
- SELECT id FROM tasks WHERE change_id = ?
2066
- ))`,
2067
- ).all(intent.change_id, intent.change_id)) {
2068
- semanticByPath.set(
2069
- `${intent.destination}/authority/context-envelopes/${row.id}.json`,
2070
- row.digest,
2071
- );
2072
- }
2073
- for (const row of db.prepare(
2074
- `SELECT id, packet_digest FROM worker_packets
2075
- WHERE scope_type = 'task' AND status = 'assigned'
2076
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
2077
- ).all(intent.change_id)) {
2078
- semanticByPath.set(
2079
- `${intent.destination}/authority/worker-packets/${row.id}.json`,
2080
- row.packet_digest,
2081
- );
2082
- }
2083
- const original = new Map();
2084
- const pathRebound = new Map();
2085
- for (const relative of tree.files) {
2086
- const read = readStableProjectFile(rootDir, relative);
2087
- original.set(relative, read.bytes);
2088
- const text = read.bytes.toString('utf8');
2089
- if (!Buffer.from(text, 'utf8').equals(read.bytes)) {
2090
- if (read.bytes.includes(Buffer.from(intent.source))) {
2091
- throw new ChangeWorkflowError(
2092
- 'ARCHIVE_REBIND_UNSUPPORTED',
2093
- `archive packet contains a binary active-root reference: ${relative}`,
2094
- );
2095
- }
2096
- pathRebound.set(relative, read.bytes);
2097
- continue;
2098
- }
2099
- let next = replaceArchiveAuthorityText(
2100
- text, intent.source, intent.destination,
2101
- );
2102
- if (contextPaths.has(relative)) {
2103
- let manifest;
2104
- try { manifest = JSON.parse(next); } catch (error) {
2105
- throw new ChangeWorkflowError(
2106
- 'ARCHIVE_CONTEXT_INVALID',
2107
- `archived Context Manifest cannot be parsed: ${relative}: ${error.message}`,
2108
- );
2109
- }
2110
- if (manifest.change?.id !== intent.change_id) {
2111
- throw new ChangeWorkflowError(
2112
- 'ARCHIVE_CONTEXT_INVALID',
2113
- `archived Context Manifest belongs to another change: ${relative}`,
2114
- );
2115
- }
2116
- manifest.change.status = 'archived';
2117
- next = `${JSON.stringify(manifest, null, 2)}\n`;
2118
- }
2119
- pathRebound.set(
2120
- relative,
2121
- normalizedArchiveDocument(Buffer.from(next), intent).bytes,
2122
- );
2123
- }
2124
-
2125
- let current = new Map(pathRebound);
2126
- for (let iteration = 0; iteration < 20; iteration += 1) {
2127
- const digestMap = new Map();
2128
- for (const [relative, beforeDigest] of originalByPath) {
2129
- const bytes = current.get(relative);
2130
- if (bytes) {
2131
- digestMap.set(
2132
- beforeDigest,
2133
- crypto.createHash('sha256').update(bytes).digest('hex'),
2134
- );
2135
- }
2136
- }
2137
- for (const [relative, beforeDigest] of semanticByPath) {
2138
- const bytes = current.get(relative);
2139
- if (!bytes) continue;
2140
- const normalized = normalizedArchiveDocument(bytes, intent);
2141
- if (normalized.semanticDigest) {
2142
- digestMap.set(beforeDigest, normalized.semanticDigest);
2143
- }
2144
- }
2145
- const next = new Map();
2146
- let changed = false;
2147
- for (const [relative, baseBytes] of pathRebound) {
2148
- const text = baseBytes.toString('utf8');
2149
- if (!Buffer.from(text, 'utf8').equals(baseBytes)) {
2150
- next.set(relative, baseBytes);
2151
- continue;
2152
- }
2153
- const replaced = Buffer.from(
2154
- replaceArchiveAuthorityText(
2155
- text, intent.source, intent.destination, digestMap,
2156
- ),
2157
- );
2158
- const bytes = normalizedArchiveDocument(replaced, intent).bytes;
2159
- next.set(relative, bytes);
2160
- if (!bytes.equals(current.get(relative))) changed = true;
2161
- }
2162
- current = next;
2163
- if (!changed) break;
2164
- if (iteration === 19) {
2165
- throw new ChangeWorkflowError(
2166
- 'ARCHIVE_REBIND_CYCLE',
2167
- 'archive packet digest references did not converge',
2168
- );
2169
- }
2170
- }
2171
- return [...original.entries()].flatMap(([relative, before]) => {
2172
- const after = current.get(relative);
2173
- if (after.equals(before)) return [];
2174
- return [{
2175
- relative_path: path.posix.relative(intent.destination, relative),
2176
- before_digest: crypto.createHash('sha256').update(before).digest('hex'),
2177
- after_digest: crypto.createHash('sha256').update(after).digest('hex'),
2178
- before_base64: before.toString('base64'),
2179
- after_base64: after.toString('base64'),
2180
- }];
2181
- }).sort((left, right) => left.relative_path.localeCompare(right.relative_path));
2182
- }
2183
-
2184
- function archiveRebindState(db, intent, rootDir) {
2185
- const existing = archiveJournal.readArchiveRebind(rootDir, intent);
2186
- const entries = existing?.entries || buildArchiveRebindEntries(db, intent, rootDir);
2187
- const journal = archiveJournal.prepareArchiveRebind(
2188
- rootDir, intent, existing ? null : entries,
2189
- );
2190
- const byPath = new Map(journal.entries.map((entry) => [
2191
- `${intent.destination}/${entry.relative_path}`,
2192
- entry,
2193
- ]));
2194
- const digestMap = new Map(journal.entries.map(
2195
- (entry) => [entry.before_digest, entry.after_digest],
2196
- ));
2197
- const semanticEntry = (relativePath) => byPath.get(relativePath);
2198
- for (const row of db.prepare(
2199
- `SELECT id, digest FROM decision_records
2200
- WHERE scope_type = 'change' AND scope_id = ?`,
2201
- ).all(intent.change_id)) {
2202
- const entry = semanticEntry(`${intent.destination}/decisions/${row.id}.json`);
2203
- if (!entry) continue;
2204
- const document = JSON.parse(Buffer.from(entry.after_base64, 'base64').toString('utf8'));
2205
- if (document.digest) digestMap.set(row.digest, document.digest);
2206
- }
2207
- for (const row of db.prepare(
2208
- `SELECT id, digest FROM context_envelopes
2209
- WHERE (scope_type = 'change' AND scope_id = ?)
2210
- OR (scope_type = 'task' AND scope_id IN (
2211
- SELECT id FROM tasks WHERE change_id = ?
2212
- ))`,
2213
- ).all(intent.change_id, intent.change_id)) {
2214
- const entry = semanticEntry(
2215
- `${intent.destination}/authority/context-envelopes/${row.id}.json`,
2216
- );
2217
- if (!entry) continue;
2218
- const document = JSON.parse(Buffer.from(entry.after_base64, 'base64').toString('utf8'));
2219
- if (document.digest) digestMap.set(row.digest, document.digest);
2220
- }
2221
- for (const row of db.prepare(
2222
- `SELECT id, packet_digest FROM worker_packets
2223
- WHERE scope_type = 'task' AND status = 'assigned'
2224
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
2225
- ).all(intent.change_id)) {
2226
- const entry = semanticEntry(
2227
- `${intent.destination}/authority/worker-packets/${row.id}.json`,
2228
- );
2229
- if (!entry) continue;
2230
- const document = JSON.parse(Buffer.from(entry.after_base64, 'base64').toString('utf8'));
2231
- if (document.packet_digest) {
2232
- digestMap.set(row.packet_digest, document.packet_digest);
2233
- }
2234
- }
2235
- return { journal, byPath, digestMap };
2236
- }
2237
-
2238
- function finalizeArchive(db, intent, {
2239
- rootDir,
2240
- journalIntent = intent,
2241
- kernelMode = false,
2242
- }) {
2243
- const change = readChange(db, intent.change_id);
2244
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${intent.change_id} not found`);
2245
- try {
2246
- archiveJournal.verifyArchiveIntent(rootDir, journalIntent, { location: 'destination' });
2247
- } catch (error) {
2248
- throw new ChangeWorkflowError(
2249
- 'ARCHIVE_PATH_UNSAFE',
2250
- `archive transition cannot trust its physical destination: ${error.message}`,
2251
- { cause: error.code || error.message },
2252
- );
2253
- }
2254
- const relative = artifacts.normalizeRelativePath(intent.destination);
2255
- const destination = safeRelativePath(rootDir, relative);
2256
- if (change.status === 'archived'
2257
- && artifacts.normalizeRelativePath(change.artifact_root) === relative) {
2258
- return { change, archive_path: destination };
2259
- }
2260
- const acceptedStatuses = kernelMode ? ['active', 'ready'] : ['ready'];
2261
- if (!acceptedStatuses.includes(change.status)) {
2262
- throw new ChangeWorkflowError(
2263
- 'CHANGE_NOT_READY',
2264
- `change ${change.id} must have complete accepted checkpoints to finish archive`,
2265
- );
2266
- }
2267
- const rebind = archiveRebindState(db, journalIntent, rootDir);
2268
- const archivedReconciliationPath = rebindRegistryPath(
2269
- intent.reconciliation_path, intent.source, relative,
2270
- );
2271
- const reconciliationRead = readStableProjectFile(
2272
- rootDir, archivedReconciliationPath, { encoding: 'utf8' },
2273
- );
2274
- let reconciliationManifest;
2275
- try { reconciliationManifest = JSON.parse(reconciliationRead.text); } catch (error) {
2276
- throw new ChangeWorkflowError(
2277
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
2278
- `archived reconciliation manifest is invalid JSON: ${error.message}`,
2279
- );
2280
- }
2281
- if (!validateReconciliationSchema(reconciliationManifest)
2282
- || reconciliationManifest.change_id !== change.id) {
2283
- throw new ChangeWorkflowError(
2284
- 'BASELINE_RECONCILIATION_MANIFEST_INVALID',
2285
- 'archived reconciliation manifest no longer matches its accepted schema and Change',
2286
- { errors: validateReconciliationSchema.errors || [] },
2287
- );
2288
- }
2289
- const reconciliationDigest = reconciliationRead.digest;
2290
- let summaryReader;
2291
- try {
2292
- summaryReader = openStableProjectRead(rootDir, `${relative}/archive-summary.md`);
2293
- } catch (error) {
2294
- throw new ChangeWorkflowError(
2295
- 'ARCHIVE_PATH_UNSAFE', 'archive summary cannot be read safely',
2296
- { cause: error.code || error.message },
2297
- );
2298
- }
2299
- const baselineHealthBefore = baselines.inspectBaseline(db, { rootDir });
2300
- const breakGlass = Boolean(
2301
- baselineHealthBefore.status !== 'pass'
2302
- && change.kind === 'incident'
2303
- && change.baseline_bypass?.mode === 'incident_break_glass',
2304
- );
2305
- try {
2306
- ops.tx(db, () => {
2307
- db.prepare(
2308
- "UPDATE changes SET status = 'archived', artifact_root = ?, updated_at = ?, closed_at = ? WHERE id = ?",
2309
- ).run(relative, nowIso(), nowIso(), change.id);
2310
- const artifactRows = db.prepare(
2311
- `SELECT id, path, digest, content_hash, before_digest, metadata_json, provenance_json
2312
- FROM artifacts WHERE change_id = ?`,
2313
- ).all(change.id);
2314
- for (const artifact of artifactRows) {
2315
- const archivedPath = rebindRegistryPath(
2316
- artifact.path, intent.source, relative,
2317
- );
2318
- if (!archivedPath) continue;
2319
- const rebound = rebind.byPath.get(archivedPath);
2320
- if (rebound) {
2321
- db.prepare(
2322
- `UPDATE artifacts
2323
- SET digest = ?, content_hash = ?, before_digest = COALESCE(before_digest, ?),
2324
- after_digest = ?, metadata_json = ?, provenance_json = ?
2325
- WHERE id = ?`,
2326
- ).run(
2327
- rebound.after_digest,
2328
- rebound.after_digest,
2329
- artifact.digest || artifact.content_hash || rebound.before_digest,
2330
- rebound.after_digest,
2331
- JSON.stringify(archiveRebindAuthorityValue(
2332
- parseJson(artifact.metadata_json || '{}', 'artifacts.metadata_json'),
2333
- intent.source,
2334
- relative,
2335
- rebind.digestMap,
2336
- )),
2337
- JSON.stringify(archiveRebindAuthorityValue(
2338
- parseJson(artifact.provenance_json || '{}', 'artifacts.provenance_json'),
2339
- intent.source,
2340
- relative,
2341
- rebind.digestMap,
2342
- )),
2343
- artifact.id,
2344
- );
2345
- }
2346
- artifacts.moveArtifactInTx(db, artifact.id, archivedPath, { rootDir });
2347
- }
2348
- const workflowOutputs = db.prepare(
2349
- `SELECT ws.run_id, ws.step_id, ws.outputs_json, ws.evidence_json,
2350
- ws.decisions_json, ws.semantic_records_json, ws.blockers_json
2351
- FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
2352
- WHERE wr.change_id = ?`,
2353
- ).all(change.id);
2354
- for (const step of workflowOutputs) {
2355
- const rebound = Object.fromEntries([
2356
- ['outputs_json', parseJson(step.outputs_json, 'workflow_steps.outputs_json')],
2357
- ['evidence_json', parseJson(step.evidence_json, 'workflow_steps.evidence_json')],
2358
- ['decisions_json', parseJson(step.decisions_json, 'workflow_steps.decisions_json')],
2359
- ['semantic_records_json', parseJson(
2360
- step.semantic_records_json, 'workflow_steps.semantic_records_json',
2361
- )],
2362
- ['blockers_json', parseJson(step.blockers_json, 'workflow_steps.blockers_json')],
2363
- ].map(([field, value]) => [
2364
- field,
2365
- JSON.stringify(archiveRebindAuthorityValue(
2366
- value, intent.source, relative, rebind.digestMap,
2367
- )),
2368
- ]));
2369
- db.prepare(
2370
- `UPDATE workflow_steps
2371
- SET outputs_json = ?, evidence_json = ?, decisions_json = ?,
2372
- semantic_records_json = ?, blockers_json = ?, updated_at = ?
2373
- WHERE run_id = ? AND step_id = ?`,
2374
- ).run(
2375
- rebound.outputs_json,
2376
- rebound.evidence_json,
2377
- rebound.decisions_json,
2378
- rebound.semantic_records_json,
2379
- rebound.blockers_json,
2380
- nowIso(),
2381
- step.run_id,
2382
- step.step_id,
2383
- );
2384
- }
2385
- const contextRows = db.prepare(
2386
- `SELECT id, manifest_path, manifest_hash, context_json
2387
- FROM context_snapshots WHERE change_id = ?`,
2388
- ).all(change.id);
2389
- for (const snapshot of contextRows) {
2390
- const archivedPath = rebindRegistryPath(
2391
- snapshot.manifest_path, intent.source, relative,
2392
- );
2393
- if (!archivedPath) continue;
2394
- const rebound = rebind.byPath.get(archivedPath);
2395
- db.prepare(
2396
- `UPDATE context_snapshots
2397
- SET manifest_path = ?, manifest_hash = ?, context_json = ? WHERE id = ?`,
2398
- ).run(
2399
- archivedPath,
2400
- rebound?.after_digest || snapshot.manifest_hash,
2401
- JSON.stringify(archiveRebindAuthorityValue(
2402
- parseJson(snapshot.context_json || '{}', 'context_snapshots.context_json'),
2403
- intent.source,
2404
- relative,
2405
- rebind.digestMap,
2406
- )),
2407
- snapshot.id,
2408
- );
2409
- }
2410
- for (const decision of db.prepare(
2411
- `SELECT id FROM decision_records
2412
- WHERE scope_type = 'change' AND scope_id = ?`,
2413
- ).all(change.id)) {
2414
- const archivedPath = `${relative}/decisions/${decision.id}.json`;
2415
- const rebound = rebind.byPath.get(archivedPath);
2416
- if (!rebound) {
2417
- throw new ChangeWorkflowError(
2418
- 'ARCHIVE_DECISION_MISSING',
2419
- `archive is missing Decision Record ${decision.id}`,
2420
- );
2421
- }
2422
- const document = JSON.parse(
2423
- Buffer.from(rebound.after_base64, 'base64').toString('utf8'),
2424
- );
2425
- db.prepare(
2426
- `UPDATE decision_records
2427
- SET question = ?, recommendation = ?, selection = ?,
2428
- effects_json = ?, non_goals_json = ?, owner = ?, source = ?,
2429
- provenance_json = ?, applied_refs_json = ?, digest = ?,
2430
- artifact_path = ?, updated_at = ?
2431
- WHERE id = ?`,
2432
- ).run(
2433
- document.question,
2434
- document.recommendation,
2435
- document.selection,
2436
- JSON.stringify(document.effects || {}),
2437
- JSON.stringify(document.non_goals || []),
2438
- document.owner,
2439
- document.source,
2440
- JSON.stringify(document.provenance || {}),
2441
- JSON.stringify(document.applied_refs || []),
2442
- document.digest,
2443
- archivedPath,
2444
- nowIso(),
2445
- decision.id,
2446
- );
2447
- }
2448
- for (const envelope of db.prepare(
2449
- `SELECT id FROM context_envelopes
2450
- WHERE (scope_type = 'change' AND scope_id = ?)
2451
- OR (scope_type = 'task' AND scope_id IN (
2452
- SELECT id FROM tasks WHERE change_id = ?
2453
- ))`,
2454
- ).all(change.id, change.id)) {
2455
- const archivedPath = `${relative}/authority/context-envelopes/${envelope.id}.json`;
2456
- const rebound = rebind.byPath.get(archivedPath);
2457
- if (!rebound) {
2458
- throw new ChangeWorkflowError(
2459
- 'ARCHIVE_CONTEXT_MISSING',
2460
- `archive is missing Context Envelope ${envelope.id}`,
2461
- );
2462
- }
2463
- const document = JSON.parse(
2464
- Buffer.from(rebound.after_base64, 'base64').toString('utf8'),
2465
- );
2466
- db.prepare(
2467
- `UPDATE context_envelopes
2468
- SET digest = ?, file_digest = ?, payload_json = ?, artifact_path = ?
2469
- WHERE id = ?`,
2470
- ).run(
2471
- document.digest,
2472
- rebound.after_digest,
2473
- JSON.stringify(document.envelope),
2474
- archivedPath,
2475
- envelope.id,
2476
- );
2477
- }
2478
- for (const packet of db.prepare(
2479
- `SELECT id FROM worker_packets
2480
- WHERE scope_type = 'task' AND status = 'assigned'
2481
- AND scope_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
2482
- ).all(change.id)) {
2483
- const archivedPath = `${relative}/authority/worker-packets/${packet.id}.json`;
2484
- const rebound = rebind.byPath.get(archivedPath);
2485
- if (!rebound) {
2486
- throw new ChangeWorkflowError(
2487
- 'ARCHIVE_WORKER_PACKET_MISSING',
2488
- `archive is missing Worker Packet ${packet.id}`,
2489
- );
2490
- }
2491
- const document = JSON.parse(
2492
- Buffer.from(rebound.after_base64, 'base64').toString('utf8'),
2493
- );
2494
- const decisionDigest = canonical.digest(
2495
- (document.accepted_decisions || []).map((decision) => ({
2496
- id: decision.id,
2497
- digest: decision.digest,
2498
- status: 'accepted',
2499
- })),
2500
- );
2501
- db.prepare(
2502
- `UPDATE worker_packets
2503
- SET context_digest = ?, task_digest = ?, decision_digest = ?,
2504
- packet_digest = ?, file_digest = ?, packet_path = ?, output_path = ?
2505
- WHERE id = ?`,
2506
- ).run(
2507
- document.context_envelope?.digest || null,
2508
- document.task?.digest || null,
2509
- decisionDigest,
2510
- document.packet_digest,
2511
- rebound.after_digest,
2512
- archivedPath,
2513
- document.output?.path,
2514
- packet.id,
2515
- );
2516
- }
2517
- const checkpointRows = db.prepare(
2518
- `SELECT *
2519
- FROM stage_checkpoints
2520
- WHERE (scope_type = 'change' AND scope_id = ?)
2521
- OR (scope_type = 'task' AND scope_id IN (
2522
- SELECT id FROM tasks WHERE change_id = ?
2523
- ))
2524
- ORDER BY revision`,
2525
- ).all(change.id, change.id);
2526
- for (const row of checkpointRows) {
2527
- const value = {
2528
- stage: row.stage,
2529
- scope_type: row.scope_type,
2530
- scope_id: row.scope_id,
2531
- revision: row.revision,
2532
- payload: archiveRebindAuthorityValue(
2533
- parseJson(row.payload_json || '{}', 'stage_checkpoints.payload_json'),
2534
- intent.source,
2535
- relative,
2536
- rebind.digestMap,
2537
- ),
2538
- evidence: archiveRebindAuthorityValue(
2539
- parseJson(row.evidence_json || '[]', 'stage_checkpoints.evidence_json'),
2540
- intent.source,
2541
- relative,
2542
- rebind.digestMap,
2543
- ),
2544
- diagnostics: archiveRebindAuthorityValue(
2545
- parseJson(row.diagnostics_json || '[]', 'stage_checkpoints.diagnostics_json'),
2546
- intent.source,
2547
- relative,
2548
- rebind.digestMap,
2549
- ),
2550
- context_envelope_id: row.context_envelope_id,
2551
- supersedes_id: row.supersedes_id,
2552
- };
2553
- db.prepare(
2554
- `UPDATE stage_checkpoints
2555
- SET payload_json = ?, evidence_json = ?, diagnostics_json = ?, digest = ?,
2556
- updated_at = ?
2557
- WHERE id = ?`,
2558
- ).run(
2559
- JSON.stringify(value.payload),
2560
- JSON.stringify(value.evidence),
2561
- JSON.stringify(value.diagnostics),
2562
- stageCheckpoints.checkpointDigest(value),
2563
- nowIso(),
2564
- row.id,
2565
- );
2566
- }
2567
- for (const task of db.prepare(
2568
- `SELECT id, context_refs_json, docs_impact_json, ownership_json
2569
- FROM tasks WHERE change_id = ?`,
2570
- ).all(change.id)) {
2571
- db.prepare(
2572
- `UPDATE tasks
2573
- SET context_refs_json = ?, docs_impact_json = ?, ownership_json = ?,
2574
- updated_at = ?
2575
- WHERE id = ?`,
2576
- ).run(
2577
- JSON.stringify(archiveRebindAuthorityValue(
2578
- parseJson(task.context_refs_json || '[]', 'tasks.context_refs_json'),
2579
- intent.source,
2580
- relative,
2581
- rebind.digestMap,
2582
- )),
2583
- JSON.stringify(archiveRebindAuthorityValue(
2584
- parseJson(task.docs_impact_json || '{}', 'tasks.docs_impact_json'),
2585
- intent.source,
2586
- relative,
2587
- rebind.digestMap,
2588
- )),
2589
- JSON.stringify(archiveRebindAuthorityValue(
2590
- parseJson(task.ownership_json || '{}', 'tasks.ownership_json'),
2591
- intent.source,
2592
- relative,
2593
- rebind.digestMap,
2594
- )),
2595
- nowIso(),
2596
- task.id,
2597
- );
2598
- }
2599
- for (const event of db.prepare(
2600
- `SELECT id, payload_json FROM events
2601
- WHERE change_id = ?
2602
- OR task_id IN (SELECT id FROM tasks WHERE change_id = ?)`,
2603
- ).all(change.id, change.id)) {
2604
- db.prepare('UPDATE events SET payload_json = ? WHERE id = ?').run(
2605
- JSON.stringify(archiveRebindAuthorityValue(
2606
- parseJson(event.payload_json || '{}', 'events.payload_json'),
2607
- intent.source,
2608
- relative,
2609
- rebind.digestMap,
2610
- )),
2611
- event.id,
2612
- );
2613
- }
2614
- for (const run of db.prepare(
2615
- `SELECT id, metadata_json, blockers_json, summary_json, approval_json
2616
- FROM workflow_runs WHERE change_id = ?`,
2617
- ).all(change.id)) {
2618
- db.prepare(
2619
- `UPDATE workflow_runs
2620
- SET metadata_json = ?, blockers_json = ?, summary_json = ?, approval_json = ?
2621
- WHERE id = ?`,
2622
- ).run(
2623
- JSON.stringify(archiveRebindAuthorityValue(
2624
- parseJson(run.metadata_json || '{}', 'workflow_runs.metadata_json'),
2625
- intent.source, relative, rebind.digestMap,
2626
- )),
2627
- JSON.stringify(archiveRebindAuthorityValue(
2628
- parseJson(run.blockers_json || '[]', 'workflow_runs.blockers_json'),
2629
- intent.source, relative, rebind.digestMap,
2630
- )),
2631
- JSON.stringify(archiveRebindAuthorityValue(
2632
- parseJson(run.summary_json || '{}', 'workflow_runs.summary_json'),
2633
- intent.source, relative, rebind.digestMap,
2634
- )),
2635
- run.approval_json === null
2636
- ? null
2637
- : JSON.stringify(archiveRebindAuthorityValue(
2638
- parseJson(run.approval_json, 'workflow_runs.approval_json'),
2639
- intent.source, relative, rebind.digestMap,
2640
- )),
2641
- run.id,
2642
- );
2643
- }
2644
- db.prepare(
2645
- `UPDATE trace_links
2646
- SET source_ref = REPLACE(source_ref, ?, ?),
2647
- target_ref = REPLACE(target_ref, ?, ?)
2648
- WHERE change_id = ?`,
2649
- ).run(intent.source, relative, intent.source, relative, change.id);
2650
- upsertArtifact(db, {
2651
- change_id: change.id, kind: 'archive_summary',
2652
- artifactPath: `${relative}/archive-summary.md`,
2653
- contentHash: summaryReader.digest,
2654
- metadata: {
2655
- baseline_updates: intent.baseline_updates,
2656
- no_baseline_change_reason: intent.no_baseline_change_reason,
2657
- },
2658
- terminal: true,
2659
- rootDir,
2660
- });
2661
- upsertArtifact(db, {
2662
- change_id: change.id, kind: 'baseline_reconciliation',
2663
- artifactPath: archivedReconciliationPath,
2664
- contentHash: reconciliationDigest,
2665
- metadata: {
2666
- schema: reconciliationManifest.$schema,
2667
- semantic_changes: reconciliationManifest.semantic_changes.map((item) => item.id),
2668
- resolved_gap_ids: reconciliationManifest.resolved_gap_ids,
2669
- resolved_unknowns: reconciliationManifest.resolved_unknowns,
2670
- },
2671
- terminal: true,
2672
- rootDir,
2673
- });
2674
- for (const artifact of db.prepare(
2675
- 'SELECT id FROM artifacts WHERE change_id = ? ORDER BY id',
2676
- ).all(change.id)) {
2677
- artifacts.setArtifactStatusInTx(db, artifact.id, 'archived');
2678
- }
2679
- if (breakGlass) {
2680
- const currentBaseline = baselines.readBaseline(db);
2681
- if (currentBaseline) {
2682
- baselines.appendGapInTx(db, {
2683
- baseline_id: currentBaseline.id,
2684
- gap: {
2685
- id: `incident-${change.id}-reconciliation`,
2686
- category: 'baseline_blocker',
2687
- status: 'open',
2688
- blocking: true,
2689
- summary: `Incident ${change.id} was archived under break-glass and requires baseline reconciliation.`,
2690
- evidence_refs: [relative],
2691
- owner: change.baseline_bypass.approved_by,
2692
- },
2693
- });
2694
- }
2695
- } else {
2696
- baselines.reconcileBaseline(db, {
2697
- baseline_updates: intent.baseline_updates, change_id: change.id,
2698
- reconciliation: reconciliationManifest,
2699
- accept_delivery_worktree: true,
2700
- }, { rootDir });
2701
- const baselineHealth = baselines.inspectBaseline(db, { rootDir });
2702
- if (baselineHealth.blockers.length > 0) {
2703
- throw new ChangeWorkflowError(
2704
- 'BASELINE_RECONCILIATION_INCOMPLETE',
2705
- `baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
2706
- { blockers: baselineHealth.blockers },
2707
- );
2708
- }
2709
- }
2710
- ops.appendEventInTx(db, {
2711
- type: 'change_archived', change_id: change.id,
2712
- payload: {
2713
- archive_path: relative, baseline_updates: intent.baseline_updates,
2714
- no_baseline_change_reason: intent.no_baseline_change_reason,
2715
- reconciliation_path: archivedReconciliationPath,
2716
- reconciliation_digest: reconciliationDigest,
2717
- baseline_bypass: breakGlass,
2718
- },
2719
- });
2720
- summaryReader.verify();
2721
- try {
2722
- archiveJournal.verifyArchiveIntent(
2723
- rootDir, journalIntent, { location: 'destination' },
2724
- );
2725
- } catch (error) {
2726
- throw new ChangeWorkflowError(
2727
- 'ARCHIVE_PATH_UNSAFE',
2728
- `archive transition changed physical identity before commit: ${error.message}`,
2729
- { cause: error.code || error.message },
2730
- );
2731
- }
2732
- });
2733
- } finally {
2734
- summaryReader.close();
2735
- }
2736
- return {
2737
- change: readChange(db, change.id), archive_path: destination,
2738
- baseline_bypass: breakGlass,
2739
- };
2740
- }
2741
-
2742
- function archiveChange(db, input, {
2743
- rootDir = process.cwd(),
2744
- kernelMode = false,
2745
- deferDeliveryCleanup = false,
2746
- } = {}) {
2747
- const change = readChange(db, input.id);
2748
- if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
2749
- if (!input.summary || String(input.summary).trim().length < 3) {
2750
- throw new ChangeWorkflowError('VALIDATION_ERROR', 'archive summary required');
2751
- }
2752
- const allowedArchiveStatuses = kernelMode
2753
- ? ['active', 'ready', 'archived']
2754
- : ['ready', 'archived'];
2755
- if (!allowedArchiveStatuses.includes(change.status)) {
2756
- throw new ChangeWorkflowError('CHANGE_NOT_READY', `change ${input.id} must converge before archive`);
2757
- }
2758
- if (change.status === 'archived') {
2759
- if (!deferDeliveryCleanup) {
2760
- deliveryTransaction.completeDeliveryTransaction({
2761
- rootDir,
2762
- changeId: change.id,
2763
- });
2764
- }
2765
- return {
2766
- change,
2767
- archive_path: safeRelativePath(rootDir, change.artifact_root),
2768
- idempotent: true,
2769
- };
2770
- }
2771
-
2772
- let packet = null;
2773
- if (changePacket.readDeltaAuthority(db, change.id)) {
2774
- if (!kernelMode) {
2775
- legacyWorkflows().validateDeliveryPrerequisites(
2776
- db,
2777
- { change_id: change.id },
2778
- rootDir,
2779
- );
2780
- }
2781
- packet = changePacket.deliveryEntries(db, change, { rootDir });
2782
- }
2783
- const requestedUpdates = Array.isArray(input.baseline_updates)
2784
- ? input.baseline_updates.map((item) => artifacts.normalizeRelativePath(item))
2785
- : null;
2786
- const updates = packet
2787
- ? packet.baseline_updates
2788
- : (requestedUpdates || []);
2789
- if (packet && requestedUpdates
2790
- && JSON.stringify([...requestedUpdates].sort()) !== JSON.stringify([...updates].sort())) {
2791
- throw new ChangeWorkflowError(
2792
- 'BASELINE_RECONCILIATION_CONFLICT',
2793
- 'baseline_updates must match the typed Change delta exactly',
2794
- );
2795
- }
2796
- const requestedNoChange = input.no_baseline_change_reason
2797
- && String(input.no_baseline_change_reason).trim();
2798
- const noChangeReason = packet
2799
- ? (packet.delta.value.no_semantic_change_reason || requestedNoChange || null)
2800
- : requestedNoChange;
2801
- if (updates.length === 0 && !noChangeReason) {
2802
- throw new ChangeWorkflowError(
2803
- 'BASELINE_RECONCILIATION_REQUIRED',
2804
- 'baseline updates or no-change reason required',
2805
- );
2806
- }
2807
-
2808
- const reconciliation = packet
2809
- ? generatePacketReconciliation(db, change, packet, rootDir)
2810
- : (
2811
- kernelMode && updates.length === 0 && !String(input.reconciliation_path || '').trim()
2812
- ? generateCallerNoChangeReconciliation(db, change, {
2813
- summary: input.summary,
2814
- no_baseline_change_reason: noChangeReason,
2815
- }, rootDir)
2816
- : readReconciliationManifest(db, change, input, rootDir)
2817
- );
2818
- if (kernelMode) {
2819
- materializeKernelArchiveAuthority(db, change, rootDir);
2820
- }
2821
- let delivery = null;
2822
- let prepared = null;
2823
- try {
2824
- if (packet) {
2825
- delivery = deliveryTransaction.beginDeliveryTransaction({
2826
- rootDir,
2827
- changeId: change.id,
2828
- entries: packet.entries,
2829
- });
2830
- }
2831
- for (const file of updates) {
2832
- if (!fs.existsSync(safeRelativePath(rootDir, file))) {
2833
- throw new ChangeWorkflowError(
2834
- 'BASELINE_FILE_MISSING', `baseline update missing: ${file}`,
2835
- );
2836
- }
2837
- }
2838
- prepared = archiveJournal.prepareArchiveMove({
2839
- rootDir, change, summary: String(input.summary), baselineUpdates: updates,
2840
- noBaselineChangeReason: noChangeReason || null,
2841
- reconciliationPath: reconciliation.relative,
2842
- reconciliationDigest: reconciliation.journal_intent?.reconciliation_digest
2843
- || reconciliation.digest,
2844
- reconciliationManifest: reconciliation.journal_intent?.reconciliation_manifest
2845
- || reconciliation.manifest,
2846
- });
2847
- } catch (error) {
2848
- if (delivery) {
2849
- try {
2850
- deliveryTransaction.rollbackDeliveryTransaction({
2851
- rootDir,
2852
- changeId: change.id,
2853
- });
2854
- } catch (rollbackError) {
2855
- throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
2856
- cause: error.code || error.message,
2857
- rollback: rollbackError.code || rollbackError.message,
2858
- });
2859
- }
2860
- }
2861
- throw error;
2862
- }
2863
- const authoritativeIntent = {
2864
- ...prepared.intent,
2865
- reconciliation_digest: reconciliation.digest,
2866
- reconciliation_manifest: reconciliation.manifest,
2867
- };
2868
- let result;
2869
- try {
2870
- result = finalizeArchive(db, authoritativeIntent, {
2871
- rootDir,
2872
- journalIntent: prepared.intent,
2873
- kernelMode,
2874
- });
2875
- } catch (error) {
2876
- try { archiveJournal.rollbackArchiveIntent(rootDir, prepared.intent); }
2877
- catch (rollbackError) {
2878
- throw new ChangeWorkflowError('ARCHIVE_RECOVERY_REQUIRED', error.message, {
2879
- cause: error.code || error.message, rollback: rollbackError.code || rollbackError.message,
2880
- });
2881
- }
2882
- if (delivery) {
2883
- try {
2884
- deliveryTransaction.rollbackDeliveryTransaction({
2885
- rootDir,
2886
- changeId: change.id,
2887
- });
2888
- } catch (rollbackError) {
2889
- throw new ChangeWorkflowError('DELIVERY_RECOVERY_REQUIRED', error.message, {
2890
- cause: error.code || error.message,
2891
- rollback: rollbackError.code || rollbackError.message,
2892
- });
2893
- }
2894
- }
2895
- throw error;
2896
- }
2897
- try { archiveJournal.completeArchiveIntent(rootDir, prepared.intent); }
2898
- catch (error) { result.recovery_warning = `ARCHIVE_JOURNAL_CLEANUP_PENDING:${error.message}`; }
2899
- if (delivery && !deferDeliveryCleanup) {
2900
- try {
2901
- deliveryTransaction.completeDeliveryTransaction({
2902
- rootDir,
2903
- changeId: change.id,
2904
- });
2905
- } catch (error) {
2906
- result.recovery_warning = [
2907
- result.recovery_warning,
2908
- `DELIVERY_JOURNAL_CLEANUP_PENDING:${error.message}`,
2909
- ].filter(Boolean).join(';');
2910
- }
2911
- }
2912
- return result;
2913
- }
2914
-
2915
- function recoverInterruptedArchives(db, { rootDir = process.cwd() } = {}) {
2916
- const records = archiveJournal.listArchiveIntents(rootDir);
2917
- const result = { found: records.length, resumed: 0, rolled_back: 0, cleaned: 0, failed: 0, items: [] };
2918
- for (const record of records) {
2919
- if (record.error) {
2920
- result.failed += 1;
2921
- result.items.push({ file: record.file, status: 'failed', error: record.error.code || record.error.message });
2922
- continue;
2923
- }
2924
- const change = readChange(db, record.intent.change_id);
2925
- try {
2926
- if (change?.status === 'archived' && change.artifact_root === record.intent.destination) {
2927
- archiveJournal.completeArchiveIntent(rootDir, record.intent);
2928
- result.cleaned += 1;
2929
- result.items.push({ change_id: change.id, status: 'cleaned' });
2930
- } else if (['active', 'ready'].includes(change?.status) && record.location === 'destination') {
2931
- const reconciliation = readReconciliationManifest(db, change, {
2932
- reconciliation_path: record.intent.reconciliation_path,
2933
- baseline_updates: record.intent.baseline_updates,
2934
- }, rootDir);
2935
- const authoritativeIntent = {
2936
- ...record.intent,
2937
- reconciliation_digest: reconciliation.digest,
2938
- reconciliation_manifest: reconciliation.manifest,
2939
- };
2940
- finalizeArchive(db, authoritativeIntent, {
2941
- rootDir,
2942
- journalIntent: record.intent,
2943
- // Recovery is a mechanical continuation of an already prepared
2944
- // archive intent. It must never re-enter the retired semantic
2945
- // workflow supervisor, including for a v0.23 `ready` Change.
2946
- kernelMode: true,
2947
- });
2948
- archiveJournal.completeArchiveIntent(rootDir, record.intent);
2949
- result.resumed += 1;
2950
- result.items.push({ change_id: change.id, status: 'resumed' });
2951
- } else {
2952
- archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
2953
- result.rolled_back += 1;
2954
- result.items.push({ change_id: record.intent.change_id, status: 'rolled_back' });
2955
- }
2956
- } catch (error) {
2957
- try {
2958
- archiveJournal.rollbackArchiveIntent(rootDir, record.intent);
2959
- result.rolled_back += 1;
2960
- result.items.push({
2961
- change_id: record.intent.change_id, status: 'rolled_back',
2962
- error: error.code || error.message,
2963
- });
2964
- } catch (rollbackError) {
2965
- result.failed += 1;
2966
- result.items.push({
2967
- change_id: record.intent.change_id, status: 'failed',
2968
- error: `${error.code || error.message}; ${rollbackError.code || rollbackError.message}`,
2969
- });
2970
- }
2971
- }
2972
- }
2973
- const archivedChangeIds = new Set(
2974
- db.prepare("SELECT id FROM changes WHERE status = 'archived'").all().map((row) => row.id),
2975
- );
2976
- result.delivery = deliveryTransaction.recoverDeliveryTransactions({
2977
- rootDir,
2978
- archivedChangeIds,
2979
- });
2980
- result.failed += result.delivery.failed;
2981
- return result;
2982
- }
2983
-
2984
- function createKernelChange(db, input, options = {}) {
2985
- return kernelChangeWorkflow.createChange(db, input, options);
2986
- }
2987
-
2988
- function supersedeKernelChange(db, input = {}, options = {}) {
2989
- return kernelChangeWorkflow.supersedeChange(db, input, options);
2990
- }
2991
-
2992
- function updateKernelChange(db, id, patch = {}, options = {}) {
2993
- return kernelChangeWorkflow.updateChange(db, id, patch, options);
2994
- }
2995
-
2996
- function archiveKernelChange(db, input, options = {}) {
2997
- return kernelChangeWorkflow.archiveChange(db, input, options);
2998
- }
2999
-
3000
- module.exports = {
3001
- ChangeWorkflowError,
3002
- createKernelChange,
3003
- supersedeKernelChange,
3004
- updateKernelChange,
3005
- archiveKernelChange,
3006
- createChange,
3007
- readChange,
3008
- listChanges,
3009
- updateChange,
3010
- recordDelta,
3011
- recordDocumentationReconciliation,
3012
- compileContext,
3013
- readBreadcrumb(...args) {
3014
- return legacyContextSpine().readBreadcrumb(...args);
3015
- },
3016
- proposeSpecLearning(...args) {
3017
- return legacySpecLearning().proposeSpecLearning(...args);
3018
- },
3019
- resolveSpecLearning(...args) {
3020
- return legacySpecLearning().resolveSpecLearning(...args);
3021
- },
3022
- listSpecLearning(...args) {
3023
- return legacySpecLearning().listSpecLearning(...args);
3024
- },
3025
- convergeChange,
3026
- archiveChange,
3027
- normalizeDocsImpact,
3028
- normalizeProviderRefs,
3029
- normalizeBaselineBypass,
3030
- assertTaskCreationAllowed,
3031
- recoverInterruptedArchives,
3032
- };