ultra-builder-pro-cli 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.ultra-template/docs/research/README.md +4 -4
  2. package/CHANGELOG.md +83 -0
  3. package/README.md +68 -35
  4. package/adapters/_shared/codex-assets.cjs +121 -60
  5. package/adapters/_shared/interaction-contract.cjs +20 -7
  6. package/adapters/_shared/provenance.cjs +7 -2
  7. package/adapters/_shared/runtime-assets.cjs +4 -3
  8. package/adapters/_shared/runtime-native.cjs +132 -0
  9. package/adapters/claude.js +61 -31
  10. package/adapters/codex.js +178 -81
  11. package/adapters/grok.js +372 -0
  12. package/adapters/kimi.js +8 -4
  13. package/adapters/opencode.js +211 -33
  14. package/agents/code-reviewer.md +4 -2
  15. package/agents/debugger.md +4 -1
  16. package/agents/review-code.md +7 -3
  17. package/agents/review-comments.md +7 -4
  18. package/agents/review-coordinator.md +11 -7
  19. package/agents/review-design.md +8 -4
  20. package/agents/review-errors.md +8 -4
  21. package/agents/review-spec.md +8 -5
  22. package/agents/review-tests.md +7 -4
  23. package/agents/tdd-runner.md +5 -2
  24. package/bin/install.js +9 -4
  25. package/bin/orchestrator.js +4 -13
  26. package/docs/AGENT-CONTEXT.md +141 -231
  27. package/docs/ARCHITECTURE.md +203 -498
  28. package/docs/ARTIFACT-AUTHORITY.md +38 -35
  29. package/docs/PLUGIN-ISOLATION-CONTRACT.md +12 -9
  30. package/docs/ROADMAP.md +85 -110
  31. package/docs/RUNTIME-COMPAT-MATRIX.md +139 -174
  32. package/docs/SKILL-AUTHORING.md +3 -3
  33. package/docs/STATE-DB-ACCESS-POLICY.md +25 -20
  34. package/docs/WORKFLOW-LIFECYCLE.md +174 -442
  35. package/hooks/README.md +11 -8
  36. package/hooks/active_task_context.py +16 -16
  37. package/hooks/adapters/grok.py +134 -0
  38. package/hooks/{context_spine.py → context_envelope.py} +26 -22
  39. package/hooks/health_check.py +19 -29
  40. package/hooks/pre_stop_check.py +22 -12
  41. package/hooks/subagent_tracker.py +1 -1
  42. package/hooks/workflow_checkpoint.py +14 -13
  43. package/hooks/workflow_context.py +11 -12
  44. package/hooks/workflow_resume.py +14 -13
  45. package/mcp-server/{breadcrumb.cjs → hook-context.cjs} +7 -9
  46. package/mcp-server/lib/artifact-registry.cjs +25 -1
  47. package/mcp-server/lib/baseline-workflow.cjs +36 -38
  48. package/mcp-server/lib/canonical-json.cjs +19 -0
  49. package/mcp-server/lib/change-workflow.cjs +637 -49
  50. package/mcp-server/lib/context-envelope.cjs +508 -0
  51. package/mcp-server/lib/decision-records.cjs +337 -0
  52. package/mcp-server/lib/doctor.cjs +91 -33
  53. package/mcp-server/lib/init-project.cjs +78 -39
  54. package/mcp-server/lib/managed-file-write.cjs +116 -0
  55. package/mcp-server/lib/plan-checkpoint.cjs +177 -0
  56. package/mcp-server/lib/project-context.cjs +178 -0
  57. package/mcp-server/lib/stage-checkpoints.cjs +309 -0
  58. package/mcp-server/lib/state-db.cjs +155 -24
  59. package/mcp-server/lib/task-ledger.cjs +522 -13
  60. package/mcp-server/lib/ultra-facade.cjs +834 -0
  61. package/mcp-server/lib/worker-packet.cjs +358 -0
  62. package/mcp-server/server.cjs +208 -1059
  63. package/orchestrator/daemon.cjs +27 -9
  64. package/orchestrator/parallel-orchestrator.cjs +111 -76
  65. package/orchestrator/session-runner.cjs +135 -34
  66. package/package.json +10 -3
  67. package/skills/ultra-change/SKILL.md +39 -98
  68. package/skills/ultra-deliver/SKILL.md +42 -65
  69. package/skills/ultra-dev/SKILL.md +52 -99
  70. package/skills/ultra-doctor/SKILL.md +35 -57
  71. package/skills/ultra-init/SKILL.md +48 -88
  72. package/skills/ultra-init/references/brownfield-adoption.md +11 -8
  73. package/skills/ultra-plan/SKILL.md +54 -132
  74. package/skills/ultra-research/SKILL.md +58 -112
  75. package/skills/ultra-review/SKILL.md +28 -46
  76. package/skills/ultra-review/references/unified-schema.md +2 -0
  77. package/skills/ultra-review/scripts/review_wait.py +8 -0
  78. package/skills/ultra-status/SKILL.md +22 -45
  79. package/skills/ultra-test/SKILL.md +27 -61
  80. package/skills/ultra-think/SKILL.md +29 -48
  81. package/skills/ultra-think/references/interaction-boundary.md +56 -0
  82. package/spec/cli-protocol.md +80 -187
  83. package/spec/mcp-tools.yaml +130 -2738
  84. package/spec/schemas/mcp-tools.schema.json +1 -1
  85. package/spec/schemas/state-db.sql +107 -4
  86. package/spec/schemas/{task-ledger.v1.schema.json → task-ledger.v2.schema.json} +70 -6
  87. package/ultra-tools/commands/status.cjs +369 -243
  88. package/mcp-server/lib/context-spine.cjs +0 -1208
  89. package/mcp-server/lib/decision-dialogue.cjs +0 -1128
  90. package/mcp-server/lib/project-breadcrumb.cjs +0 -173
  91. package/mcp-server/lib/spec-learning.cjs +0 -284
  92. package/mcp-server/lib/workflow-state.cjs +0 -2965
  93. package/skills/ultra-think/references/decision-dialogue.md +0 -153
@@ -1,1128 +0,0 @@
1
- 'use strict';
2
-
3
- const crypto = require('node:crypto');
4
- const fs = require('node:fs');
5
- const path = require('node:path');
6
-
7
- const ops = require('./state-ops.cjs');
8
- const artifactRegistry = require('./artifact-registry.cjs');
9
- const { readStableProjectFile } = require('./safe-project-file.cjs');
10
-
11
- const ID = /^[a-zA-Z0-9_-]+$/;
12
- const MODES = new Set(['guided', 'fast', 'autonomous', 'diagnostic']);
13
- const ACTIVE_STATUSES = new Set(['active', 'checkpoint_ready']);
14
- const APPLIED_REF_KINDS = new Set([
15
- 'baseline', 'change', 'workflow', 'task', 'spec', 'artifact',
16
- ]);
17
-
18
- class DecisionDialogueError extends Error {
19
- constructor(code, message, details) {
20
- super(message);
21
- this.name = 'DecisionDialogueError';
22
- this.code = code;
23
- if (details !== undefined) this.details = details;
24
- }
25
- }
26
-
27
- function nowIso() {
28
- return new Date().toISOString();
29
- }
30
-
31
- function requiredText(value, field) {
32
- const text = typeof value === 'string' ? value.trim() : '';
33
- if (!text) throw new DecisionDialogueError('VALIDATION_ERROR', `${field} is required`);
34
- return text;
35
- }
36
-
37
- function parseJson(value, field, fallback) {
38
- if (value == null || value === '') return fallback;
39
- try { return JSON.parse(value); }
40
- catch (error) {
41
- throw new DecisionDialogueError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
42
- }
43
- }
44
-
45
- function stringList(value, field) {
46
- if (value === undefined) return [];
47
- if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
48
- throw new DecisionDialogueError('VALIDATION_ERROR', `${field} must be an array of non-empty strings`);
49
- }
50
- return [...new Set(value.map((item) => item.trim()))];
51
- }
52
-
53
- function plainObject(value, field, { allowEmpty = true } = {}) {
54
- if (!value || typeof value !== 'object' || Array.isArray(value)
55
- || (!allowEmpty && Object.keys(value).length === 0)) {
56
- throw new DecisionDialogueError('VALIDATION_ERROR', `${field} must be an object`);
57
- }
58
- return JSON.parse(JSON.stringify(value));
59
- }
60
-
61
- function normalizeAppliedRefs(value) {
62
- if (value === undefined) return [];
63
- if (!Array.isArray(value) || value.length > 20) {
64
- throw new DecisionDialogueError(
65
- 'VALIDATION_ERROR', 'applied_refs must contain at most twenty authority references',
66
- );
67
- }
68
- const seen = new Set();
69
- return value.map((item, index) => {
70
- const ref = plainObject(item, `applied_refs[${index}]`, { allowEmpty: false });
71
- const kind = requiredText(ref.kind, `applied_refs[${index}].kind`);
72
- if (!APPLIED_REF_KINDS.has(kind)) {
73
- throw new DecisionDialogueError(
74
- 'VALIDATION_ERROR', `unsupported applied authority kind: ${kind}`,
75
- );
76
- }
77
- const target = requiredText(ref.ref, `applied_refs[${index}].ref`);
78
- const field = ref.field === undefined
79
- ? undefined
80
- : requiredText(ref.field, `applied_refs[${index}].field`);
81
- const digest = ref.digest === undefined
82
- ? undefined
83
- : requiredText(ref.digest, `applied_refs[${index}].digest`);
84
- const hasValue = Object.hasOwn(ref, 'value');
85
- const expectedValue = hasValue ? JSON.parse(JSON.stringify(ref.value)) : undefined;
86
- if (digest !== undefined && !/^[0-9a-f]{64}$/.test(digest)) {
87
- throw new DecisionDialogueError(
88
- 'VALIDATION_ERROR', `applied_refs[${index}].digest must be a SHA-256 digest`,
89
- );
90
- }
91
- const key = `${kind}\0${target}\0${field || ''}`;
92
- if (seen.has(key)) {
93
- throw new DecisionDialogueError('VALIDATION_ERROR', `duplicate applied authority ref: ${kind}:${target}`);
94
- }
95
- seen.add(key);
96
- return {
97
- kind,
98
- ref: target,
99
- ...(field === undefined ? {} : { field }),
100
- ...(hasValue ? { value: expectedValue } : {}),
101
- ...(digest === undefined ? {} : { digest }),
102
- };
103
- });
104
- }
105
-
106
- function canonicalJson(value) {
107
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
108
- if (value && typeof value === 'object') {
109
- return `{${Object.keys(value).sort().map(
110
- (key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`,
111
- ).join(',')}}`;
112
- }
113
- return JSON.stringify(value);
114
- }
115
-
116
- function valueDigest(value) {
117
- return crypto.createHash('sha256').update(canonicalJson(value)).digest('hex');
118
- }
119
-
120
- function valueAtField(value, field) {
121
- const segments = field.split('.').map((item) => item.trim());
122
- if (segments.some((item) => !item || ['__proto__', 'prototype', 'constructor'].includes(item))) {
123
- throw new DecisionDialogueError('VALIDATION_ERROR', `invalid applied authority field: ${field}`);
124
- }
125
- let current = value;
126
- for (const segment of segments) {
127
- if (current === null || current === undefined || typeof current !== 'object'
128
- || !Object.hasOwn(current, segment)) {
129
- throw new DecisionDialogueError(
130
- 'DECISION_APPLIED_REF_FIELD_NOT_FOUND',
131
- `applied authority field does not exist: ${field}`,
132
- );
133
- }
134
- current = current[segment];
135
- }
136
- return current;
137
- }
138
-
139
- const ROW_FIELD_ALIASES = Object.freeze({
140
- baseline: {
141
- scope: 'scope_json',
142
- worktree_files: 'worktree_files_json',
143
- spec_refs: 'spec_refs_json',
144
- evidence: 'evidence_json',
145
- verification: 'verification_json',
146
- unknowns: 'unknowns_json',
147
- gaps: 'gaps_json',
148
- classification: 'classification_json',
149
- provider_refs: 'provider_refs_json',
150
- },
151
- change: {
152
- docs_impact: 'docs_impact_json',
153
- provider_refs: 'provider_refs_json',
154
- baseline_bypass: 'baseline_bypass_json',
155
- contract: 'contract_json',
156
- classification: 'classification_json',
157
- research_disposition: 'research_disposition_json',
158
- },
159
- workflow: {
160
- metadata: 'metadata_json',
161
- blockers: 'blockers_json',
162
- approval: 'approval_json',
163
- summary: 'summary_json',
164
- },
165
- task: {
166
- acceptance: 'acceptance_json',
167
- context_refs: 'context_refs_json',
168
- docs_impact: 'docs_impact_json',
169
- ownership: 'ownership_json',
170
- },
171
- });
172
-
173
- function rowFieldValue(row, kind, field) {
174
- const [head, ...tail] = field.split('.');
175
- const column = ROW_FIELD_ALIASES[kind]?.[head] || head;
176
- if (!Object.hasOwn(row, column)) {
177
- throw new DecisionDialogueError(
178
- 'DECISION_APPLIED_REF_FIELD_NOT_FOUND',
179
- `applied ${kind} authority field does not exist: ${field}`,
180
- );
181
- }
182
- let value = row[column];
183
- if (column.endsWith('_json') && typeof value === 'string') {
184
- try { value = JSON.parse(value); }
185
- catch (error) {
186
- throw new DecisionDialogueError(
187
- 'STATE_CORRUPT', `invalid ${kind}.${column}: ${error.message}`,
188
- );
189
- }
190
- }
191
- return tail.length === 0 ? value : valueAtField(value, tail.join('.'));
192
- }
193
-
194
- function verifyAppliedValue(applied, actual) {
195
- if (!Object.hasOwn(applied, 'value')) {
196
- throw new DecisionDialogueError(
197
- 'VALIDATION_ERROR',
198
- `applied ${applied.kind} references require the exact field value`,
199
- );
200
- }
201
- if (canonicalJson(actual) !== canonicalJson(applied.value)) {
202
- throw new DecisionDialogueError(
203
- 'DECISION_APPLIED_REF_VALUE_MISMATCH',
204
- `applied ${applied.kind} field value does not match current authority: ${applied.ref}#${applied.field}`,
205
- { expected: applied.value, actual },
206
- );
207
- }
208
- if (applied.digest && applied.digest !== valueDigest(actual)) {
209
- throw new DecisionDialogueError(
210
- 'DECISION_APPLIED_REF_STALE',
211
- `applied ${applied.kind} field digest is stale: ${applied.ref}#${applied.field}`,
212
- { expected: applied.digest, actual: valueDigest(actual) },
213
- );
214
- }
215
- }
216
-
217
- function projectFile(rootDir, relative, field) {
218
- if (path.isAbsolute(relative)) {
219
- throw new DecisionDialogueError('VALIDATION_ERROR', `${field} must be project-relative`);
220
- }
221
- const root = path.resolve(rootDir);
222
- const file = path.resolve(root, relative);
223
- if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
224
- throw new DecisionDialogueError('VALIDATION_ERROR', `${field} escapes project root`);
225
- }
226
- return file;
227
- }
228
-
229
- function readAppliedProjectFile(rootDir, relative, label) {
230
- try {
231
- return readStableProjectFile(rootDir, relative);
232
- } catch (error) {
233
- if (error?.code === 'PROJECT_FILE_MISSING') {
234
- throw new DecisionDialogueError(
235
- 'DECISION_APPLIED_REF_NOT_FOUND',
236
- `${label} is missing: ${relative}`,
237
- );
238
- }
239
- throw new DecisionDialogueError(
240
- 'DECISION_APPLIED_REF_UNSAFE',
241
- `${label} cannot be read as physical project authority: ${relative}`,
242
- { cause: error.code || error.message },
243
- );
244
- }
245
- }
246
-
247
- function registeredArtifactForRef(db, ref) {
248
- const byId = db.prepare('SELECT id FROM artifacts WHERE id = ?').get(ref);
249
- if (byId) return artifactRegistry.getArtifact(db, { id: byId.id });
250
- try {
251
- return artifactRegistry.getArtifact(db, { path: ref });
252
- } catch (error) {
253
- if (error?.code === 'ARTIFACT_NOT_FOUND') return null;
254
- throw new DecisionDialogueError(
255
- 'DECISION_APPLIED_REF_AUTHORITY_CONFLICT',
256
- `applied artifact authority is ambiguous or invalid: ${ref}`,
257
- { cause: error.code || error.message },
258
- );
259
- }
260
- }
261
-
262
- function verifyAppliedFileDigest(applied, read, registered = null) {
263
- if (!applied.digest) {
264
- throw new DecisionDialogueError(
265
- 'VALIDATION_ERROR',
266
- `applied ${applied.kind} references require the exact content digest`,
267
- );
268
- }
269
- if (registered?.status === 'stale'
270
- || (registered?.digest && registered.digest !== read.digest)
271
- || applied.digest !== read.digest) {
272
- throw new DecisionDialogueError(
273
- 'DECISION_APPLIED_REF_STALE',
274
- `applied ${applied.kind} digest is stale: ${registered?.path || applied.ref}`,
275
- {
276
- registered_digest: registered?.digest || null,
277
- expected: applied.digest,
278
- actual: read.digest,
279
- },
280
- );
281
- }
282
- }
283
-
284
- function validateAppliedRefs(db, refs, { rootDir = process.cwd() } = {}) {
285
- const tableByKind = {
286
- baseline: 'baselines',
287
- change: 'changes',
288
- workflow: 'workflow_runs',
289
- task: 'tasks',
290
- };
291
- for (const applied of refs) {
292
- const table = tableByKind[applied.kind];
293
- if (table) {
294
- if (!applied.field) {
295
- throw new DecisionDialogueError(
296
- 'VALIDATION_ERROR',
297
- `applied ${applied.kind} references require an exact field`,
298
- );
299
- }
300
- const row = db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(applied.ref);
301
- if (!row) {
302
- throw new DecisionDialogueError(
303
- 'DECISION_APPLIED_REF_NOT_FOUND',
304
- `applied ${applied.kind} authority does not exist: ${applied.ref}`,
305
- );
306
- }
307
- verifyAppliedValue(applied, rowFieldValue(row, applied.kind, applied.field));
308
- continue;
309
- }
310
-
311
- if (applied.kind === 'artifact') {
312
- const artifact = registeredArtifactForRef(db, applied.ref);
313
- if (!artifact) {
314
- throw new DecisionDialogueError(
315
- 'DECISION_APPLIED_REF_NOT_FOUND',
316
- `applied artifact authority is not registered: ${applied.ref}`,
317
- );
318
- }
319
- const read = readAppliedProjectFile(
320
- rootDir, artifact.path, 'applied artifact file',
321
- );
322
- verifyAppliedFileDigest(applied, read, artifact);
323
- continue;
324
- }
325
-
326
- const registered = registeredArtifactForRef(db, applied.ref);
327
- const read = readAppliedProjectFile(
328
- rootDir,
329
- registered?.path || applied.ref,
330
- 'applied specification file',
331
- );
332
- verifyAppliedFileDigest(applied, read, registered);
333
- }
334
- }
335
-
336
- function normalizeOptions(value) {
337
- if (value === undefined) return [];
338
- if (!Array.isArray(value) || value.length > 3) {
339
- throw new DecisionDialogueError('VALIDATION_ERROR', 'options must contain at most three credible alternatives');
340
- }
341
- const seen = new Set();
342
- return value.map((option, index) => {
343
- if (!option || typeof option !== 'object' || Array.isArray(option)) {
344
- throw new DecisionDialogueError('VALIDATION_ERROR', `options[${index}] is invalid`);
345
- }
346
- const id = requiredText(option.id, `options[${index}].id`);
347
- if (!ID.test(id) || seen.has(id)) {
348
- throw new DecisionDialogueError('VALIDATION_ERROR', `option id is invalid or duplicated: ${id}`);
349
- }
350
- seen.add(id);
351
- return {
352
- id,
353
- label: requiredText(option.label, `options[${index}].label`),
354
- tradeoff: requiredText(option.tradeoff, `options[${index}].tradeoff`),
355
- };
356
- });
357
- }
358
-
359
- function rowToItem(row) {
360
- if (!row) return null;
361
- return {
362
- id: row.id,
363
- thread_id: row.thread_id,
364
- sequence: row.sequence,
365
- phase: row.phase,
366
- question: row.question,
367
- why_now: row.why_now,
368
- recommendation: row.recommendation,
369
- options: parseJson(row.options_json, 'decision_items.options_json', []),
370
- evidence_refs: parseJson(row.evidence_refs_json, 'decision_items.evidence_refs_json', []),
371
- dependency_ids: parseJson(row.dependency_ids_json, 'decision_items.dependency_ids_json', []),
372
- effects: parseJson(row.effects_json, 'decision_items.effects_json', {}),
373
- blocking: Boolean(row.blocking),
374
- status: row.status,
375
- resolution: parseJson(row.resolution_json, 'decision_items.resolution_json', {}),
376
- supersedes_id: row.supersedes_id,
377
- created_at: row.created_at,
378
- updated_at: row.updated_at,
379
- resolved_at: row.resolved_at,
380
- };
381
- }
382
-
383
- function readDecisionThread(db, id) {
384
- const row = db.prepare('SELECT * FROM decision_threads WHERE id = ?').get(id);
385
- if (!row) return null;
386
- const items = db.prepare(
387
- 'SELECT * FROM decision_items WHERE thread_id = ? ORDER BY sequence ASC',
388
- ).all(id).map(rowToItem);
389
- return {
390
- id: row.id,
391
- purpose: row.purpose,
392
- mode: row.mode,
393
- status: row.status,
394
- baseline_id: row.baseline_id,
395
- change_id: row.change_id,
396
- workflow_run_id: row.workflow_run_id,
397
- summary: parseJson(row.summary_json, 'decision_threads.summary_json', {}),
398
- checkpoint: parseJson(row.checkpoint_json, 'decision_threads.checkpoint_json', {}),
399
- current_decision: items.find((item) => item.status === 'open') || null,
400
- items,
401
- started_at: row.started_at,
402
- updated_at: row.updated_at,
403
- completed_at: row.completed_at,
404
- confirmed_at: row.confirmed_at,
405
- };
406
- }
407
-
408
- function listDecisionThreads(db, filter = {}) {
409
- const clauses = [];
410
- const params = {};
411
- for (const field of ['status', 'mode', 'baseline_id', 'change_id', 'workflow_run_id']) {
412
- if (filter[field] !== undefined && filter[field] !== null) {
413
- clauses.push(`${field} = @${field}`);
414
- params[field] = filter[field];
415
- }
416
- }
417
- const limit = Math.min(Math.max(Number(filter.limit || 100), 1), 500);
418
- const sql = `SELECT id FROM decision_threads${clauses.length ? ` WHERE ${clauses.join(' AND ')}` : ''}
419
- ORDER BY updated_at DESC, rowid DESC LIMIT @limit`;
420
- return db.prepare(sql).all({ ...params, limit }).map((row) => readDecisionThread(db, row.id));
421
- }
422
-
423
- function assertReference(db, table, id, field) {
424
- if (!id) return;
425
- if (!db.prepare(`SELECT 1 FROM ${table} WHERE id = ?`).get(id)) {
426
- throw new DecisionDialogueError('VALIDATION_ERROR', `${field} does not exist: ${id}`);
427
- }
428
- }
429
-
430
- function startDecisionThread(db, input = {}) {
431
- const id = requiredText(input.id, 'id');
432
- if (!ID.test(id)) throw new DecisionDialogueError('VALIDATION_ERROR', `invalid decision thread id: ${id}`);
433
- const purpose = requiredText(input.purpose, 'purpose');
434
- const mode = input.mode || 'guided';
435
- if (!MODES.has(mode)) throw new DecisionDialogueError('VALIDATION_ERROR', `unsupported decision mode: ${mode}`);
436
- if (!input.baseline_id && !input.change_id && !input.workflow_run_id) {
437
- throw new DecisionDialogueError(
438
- 'DECISION_AUTHORITY_REQUIRED', 'decision thread requires baseline, change, or workflow authority',
439
- );
440
- }
441
- return ops.tx(db, () => {
442
- if (db.prepare('SELECT 1 FROM decision_threads WHERE id = ?').get(id)) {
443
- throw new DecisionDialogueError('DUPLICATE_DECISION_THREAD', `decision thread ${id} already exists`);
444
- }
445
- assertReference(db, 'baselines', input.baseline_id, 'baseline_id');
446
- assertReference(db, 'changes', input.change_id, 'change_id');
447
- assertReference(db, 'workflow_runs', input.workflow_run_id, 'workflow_run_id');
448
- let baselineId = input.baseline_id || null;
449
- let changeId = input.change_id || null;
450
- if (input.workflow_run_id) {
451
- const run = db.prepare(
452
- 'SELECT baseline_id, change_id FROM workflow_runs WHERE id = ?',
453
- ).get(input.workflow_run_id);
454
- if (input.baseline_id && run.baseline_id && input.baseline_id !== run.baseline_id) {
455
- throw new DecisionDialogueError('DECISION_AUTHORITY_MISMATCH', 'thread baseline differs from workflow');
456
- }
457
- if (input.change_id && run.change_id && input.change_id !== run.change_id) {
458
- throw new DecisionDialogueError('DECISION_AUTHORITY_MISMATCH', 'thread change differs from workflow');
459
- }
460
- baselineId ||= run.baseline_id || null;
461
- changeId ||= run.change_id || null;
462
- }
463
- if (changeId) {
464
- const changeBaselineId = db.prepare(
465
- `SELECT baseline_id FROM workflow_runs
466
- WHERE kind = 'change' AND change_id = ? ORDER BY started_at DESC, rowid DESC LIMIT 1`,
467
- ).get(changeId)?.baseline_id || null;
468
- if (baselineId && changeBaselineId && baselineId !== changeBaselineId) {
469
- throw new DecisionDialogueError(
470
- 'DECISION_AUTHORITY_MISMATCH', 'thread baseline differs from the owning change workflow',
471
- );
472
- }
473
- baselineId ||= changeBaselineId;
474
- }
475
- const duplicate = db.prepare(
476
- `SELECT id, baseline_id, change_id, workflow_run_id FROM decision_threads
477
- WHERE status IN ('active', 'checkpoint_ready') ORDER BY updated_at ASC, rowid ASC`,
478
- ).all().find((thread) => (
479
- (changeId && thread.change_id === changeId)
480
- || (!changeId && baselineId && !thread.change_id && thread.baseline_id === baselineId)
481
- || (input.workflow_run_id && thread.workflow_run_id === input.workflow_run_id)
482
- ));
483
- if (duplicate) {
484
- throw new DecisionDialogueError(
485
- 'DECISION_THREAD_IN_PROGRESS', `decision thread ${duplicate.id} already owns this alignment purpose`,
486
- );
487
- }
488
- const ts = nowIso();
489
- db.prepare(
490
- `INSERT INTO decision_threads
491
- (id, purpose, mode, status, baseline_id, change_id, workflow_run_id,
492
- summary_json, checkpoint_json, started_at, updated_at)
493
- VALUES (?, ?, ?, 'active', ?, ?, ?, ?, '{}', ?, ?)`,
494
- ).run(
495
- id, purpose, mode, baselineId, changeId,
496
- input.workflow_run_id || null, '{}', ts, ts,
497
- );
498
- ops.appendEventInTx(db, {
499
- type: 'decision_thread_started', change_id: changeId,
500
- payload: { thread_id: id, workflow_run_id: input.workflow_run_id || null, mode },
501
- });
502
- return readDecisionThread(db, id);
503
- });
504
- }
505
-
506
- function requireMutableThread(db, id) {
507
- const thread = readDecisionThread(db, id);
508
- if (!thread) throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
509
- if (!ACTIVE_STATUSES.has(thread.status)) {
510
- throw new DecisionDialogueError('DECISION_THREAD_NOT_MUTABLE', `decision thread ${id} is ${thread.status}`);
511
- }
512
- return thread;
513
- }
514
-
515
- function normalizeQuestion(input, threadId) {
516
- const id = requiredText(input.id, 'id');
517
- if (!ID.test(id)) throw new DecisionDialogueError('VALIDATION_ERROR', `invalid decision id: ${id}`);
518
- return {
519
- id,
520
- thread_id: threadId,
521
- phase: requiredText(input.phase, 'phase'),
522
- question: requiredText(input.question, 'question'),
523
- why_now: requiredText(input.why_now, 'why_now'),
524
- recommendation: requiredText(input.recommendation, 'recommendation'),
525
- options: normalizeOptions(input.options),
526
- evidence_refs: stringList(input.evidence_refs, 'evidence_refs'),
527
- dependency_ids: stringList(input.dependency_ids, 'dependency_ids'),
528
- effects: plainObject(input.effects, 'effects', { allowEmpty: false }),
529
- blocking: input.blocking !== false,
530
- };
531
- }
532
-
533
- function insertQuestionInTx(db, thread, input, supersedesId = null) {
534
- if (thread.current_decision) {
535
- throw new DecisionDialogueError(
536
- 'DECISION_ALREADY_OPEN', `answer ${thread.current_decision.id} before opening another decision`,
537
- { current_decision: thread.current_decision },
538
- );
539
- }
540
- const question = normalizeQuestion(input, thread.id);
541
- if (db.prepare('SELECT 1 FROM decision_items WHERE id = ?').get(question.id)) {
542
- throw new DecisionDialogueError('DUPLICATE_DECISION_ID', `decision ${question.id} already exists`);
543
- }
544
- for (const dependencyId of question.dependency_ids) {
545
- const dependency = db.prepare(
546
- 'SELECT thread_id, status FROM decision_items WHERE id = ?',
547
- ).get(dependencyId);
548
- if (!dependency || dependency.thread_id !== thread.id || dependency.status === 'open') {
549
- throw new DecisionDialogueError(
550
- 'DECISION_DEPENDENCY_UNRESOLVED', `decision dependency is missing or unresolved: ${dependencyId}`,
551
- );
552
- }
553
- }
554
- const sequence = db.prepare(
555
- 'SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM decision_items WHERE thread_id = ?',
556
- ).get(thread.id).sequence;
557
- const ts = nowIso();
558
- db.prepare(
559
- `INSERT INTO decision_items
560
- (id, thread_id, sequence, phase, question, why_now, recommendation, options_json,
561
- evidence_refs_json, dependency_ids_json, effects_json, blocking, status,
562
- resolution_json, supersedes_id, created_at, updated_at)
563
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', '{}', ?, ?, ?)`,
564
- ).run(
565
- question.id, thread.id, sequence, question.phase, question.question, question.why_now,
566
- question.recommendation, JSON.stringify(question.options), JSON.stringify(question.evidence_refs),
567
- JSON.stringify(question.dependency_ids), JSON.stringify(question.effects), question.blocking ? 1 : 0,
568
- supersedesId, ts, ts,
569
- );
570
- db.prepare(
571
- `UPDATE decision_threads SET status = 'active', checkpoint_json = CASE
572
- WHEN status = 'confirmed' THEN json_set(checkpoint_json, '$.invalidated', json('true'), '$.invalidated_at', ?)
573
- ELSE checkpoint_json END, confirmed_at = NULL, updated_at = ? WHERE id = ?`,
574
- ).run(ts, ts, thread.id);
575
- ops.appendEventInTx(db, {
576
- type: 'decision_opened', change_id: thread.change_id,
577
- payload: { thread_id: thread.id, decision_id: question.id, phase: question.phase },
578
- });
579
- }
580
-
581
- function openDecision(db, input = {}) {
582
- const threadId = requiredText(input.thread_id, 'thread_id');
583
- return ops.tx(db, () => {
584
- const thread = requireMutableThread(db, threadId);
585
- if (thread.status === 'checkpoint_ready') {
586
- throw new DecisionDialogueError(
587
- 'DECISION_CHECKPOINT_PENDING', `confirm or revise checkpoint ${threadId} before opening another decision`,
588
- );
589
- }
590
- insertQuestionInTx(db, thread, input);
591
- return readDecisionThread(db, threadId);
592
- });
593
- }
594
-
595
- function requireOpenItem(db, id) {
596
- const row = db.prepare('SELECT * FROM decision_items WHERE id = ?').get(id);
597
- if (!row) throw new DecisionDialogueError('DECISION_NOT_FOUND', `decision ${id} not found`);
598
- if (row.status !== 'open') {
599
- throw new DecisionDialogueError('DECISION_NOT_OPEN', `decision ${id} is ${row.status}`);
600
- }
601
- return { row, thread: requireMutableThread(db, row.thread_id) };
602
- }
603
-
604
- function resolveInTx(db, item, status, resolution) {
605
- const ts = nowIso();
606
- const checkpointHistory = appendCheckpointHistory(item.thread.checkpoint);
607
- db.prepare(
608
- `UPDATE decision_items SET status = ?, resolution_json = ?, resolved_at = ?, updated_at = ?
609
- WHERE id = ?`,
610
- ).run(status, JSON.stringify(resolution), ts, ts, item.row.id);
611
- db.prepare(
612
- "UPDATE decision_threads SET status = 'active', checkpoint_json = ?, updated_at = ? WHERE id = ?",
613
- ).run(
614
- JSON.stringify(checkpointHistory.length > 0 ? { history: checkpointHistory } : {}),
615
- ts, item.row.thread_id,
616
- );
617
- ops.appendEventInTx(db, {
618
- type: 'decision_resolved', change_id: item.thread.change_id,
619
- payload: { thread_id: item.row.thread_id, decision_id: item.row.id, status },
620
- });
621
- return readDecisionThread(db, item.row.thread_id);
622
- }
623
-
624
- function resolveDecision(db, input = {}) {
625
- return ops.tx(db, () => {
626
- const item = requireOpenItem(db, requiredText(input.id, 'id'));
627
- return resolveInTx(db, item, 'answered', {
628
- authority: 'owner',
629
- decision: requiredText(input.decision, 'decision'),
630
- rationale: requiredText(input.rationale, 'rationale'),
631
- decided_by: requiredText(input.decided_by, 'decided_by'),
632
- resolved_at: nowIso(),
633
- });
634
- });
635
- }
636
-
637
- function delegateDecision(db, input = {}) {
638
- return ops.tx(db, () => {
639
- const item = requireOpenItem(db, requiredText(input.id, 'id'));
640
- const guardrails = stringList(input.guardrails, 'guardrails');
641
- if (guardrails.length === 0) {
642
- throw new DecisionDialogueError('VALIDATION_ERROR', 'delegated decisions require at least one guardrail');
643
- }
644
- return resolveInTx(db, item, 'delegated', {
645
- authority: 'delegated',
646
- delegated_to: requiredText(input.delegated_to, 'delegated_to'),
647
- decision: requiredText(input.decision, 'decision'),
648
- rationale: requiredText(input.rationale, 'rationale'),
649
- guardrails,
650
- resolved_at: nowIso(),
651
- });
652
- });
653
- }
654
-
655
- function deferDecision(db, input = {}) {
656
- return ops.tx(db, () => {
657
- const item = requireOpenItem(db, requiredText(input.id, 'id'));
658
- return resolveInTx(db, item, 'deferred', {
659
- authority: 'owner',
660
- reason: requiredText(input.reason, 'reason'),
661
- consequences: requiredText(input.consequences, 'consequences'),
662
- revisit_condition: requiredText(input.revisit_condition, 'revisit_condition'),
663
- resolved_at: nowIso(),
664
- });
665
- });
666
- }
667
-
668
- function completeDecisionThread(db, input = {}, options = {}) {
669
- const id = requiredText(input.id, 'id');
670
- const summary = requiredText(input.summary, 'summary');
671
- const appliedRefs = normalizeAppliedRefs(input.applied_refs);
672
- return ops.tx(db, () => {
673
- const thread = readDecisionThread(db, id);
674
- if (!thread) {
675
- throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
676
- }
677
- if (thread.status === 'checkpoint_ready') {
678
- throw new DecisionDialogueError(
679
- 'DECISION_CHECKPOINT_PENDING', `confirm or revise checkpoint ${id} before completing the thread`,
680
- );
681
- }
682
- if (thread.status !== 'active') {
683
- throw new DecisionDialogueError(
684
- 'DECISION_THREAD_NOT_MUTABLE', `decision thread ${id} is ${thread.status}`,
685
- );
686
- }
687
- const open = thread.items.find((item) => item.status === 'open');
688
- const deferredBlocking = thread.items.filter((item) => item.blocking && item.status === 'deferred');
689
- if (open || deferredBlocking.length > 0) {
690
- const blockers = [
691
- ...(open ? [`DECISION_AWAITING_OWNER:${open.id}`] : []),
692
- ...deferredBlocking.map((item) => `DECISION_DEFERRED_BLOCKING:${item.id}`),
693
- ];
694
- throw new DecisionDialogueError(
695
- 'DECISION_ALIGNMENT_BLOCKING',
696
- `decision thread ${id} still contains unresolved blocking state`,
697
- { blockers },
698
- );
699
- }
700
- validateAppliedRefs(db, appliedRefs, options);
701
- const ts = nowIso();
702
- db.prepare(
703
- `UPDATE decision_threads
704
- SET status = 'completed', summary_json = ?, completed_at = ?, updated_at = ?
705
- WHERE id = ?`,
706
- ).run(JSON.stringify({
707
- text: summary,
708
- completion_kind: 'normalized_state',
709
- applied_refs: appliedRefs,
710
- completed_at: ts,
711
- }), ts, ts, id);
712
- ops.appendEventInTx(db, {
713
- type: 'decision_thread_completed',
714
- change_id: thread.change_id,
715
- payload: {
716
- thread_id: id,
717
- item_count: thread.items.length,
718
- applied_ref_count: appliedRefs.length,
719
- },
720
- });
721
- return readDecisionThread(db, id);
722
- });
723
- }
724
-
725
- function acceptedIntent(db, bindings = {}, { limit = 10 } = {}) {
726
- const clauses = [];
727
- const params = {};
728
- if (bindings.workflow_run_id) {
729
- clauses.push('workflow_run_id = @workflow_run_id');
730
- params.workflow_run_id = bindings.workflow_run_id;
731
- }
732
- if (bindings.change_id) {
733
- clauses.push('change_id = @change_id');
734
- params.change_id = bindings.change_id;
735
- }
736
- if (bindings.baseline_id) {
737
- clauses.push('(baseline_id = @baseline_id AND change_id IS NULL)');
738
- params.baseline_id = bindings.baseline_id;
739
- }
740
- if (clauses.length === 0) return [];
741
- const threadRows = db.prepare(
742
- `SELECT id FROM decision_threads
743
- WHERE status IN ('completed', 'confirmed')
744
- AND (${clauses.join(' OR ')})
745
- ORDER BY updated_at DESC, rowid DESC
746
- LIMIT 100`,
747
- ).all(params);
748
- const items = [];
749
- for (const row of threadRows) {
750
- const thread = readDecisionThread(db, row.id);
751
- const appliedRefs = Array.isArray(thread.summary.applied_refs)
752
- ? thread.summary.applied_refs
753
- : [];
754
- for (const item of thread.items) {
755
- if (!['answered', 'delegated', 'deferred'].includes(item.status)) continue;
756
- const resolution = item.resolution || {};
757
- items.push({
758
- thread_id: thread.id,
759
- decision_id: item.id,
760
- status: item.status,
761
- authority: resolution.authority || null,
762
- decision: resolution.decision || null,
763
- rationale: resolution.rationale || null,
764
- decided_by: resolution.decided_by || null,
765
- delegated_to: resolution.delegated_to || null,
766
- consequences: resolution.consequences || null,
767
- revisit_condition: resolution.revisit_condition || null,
768
- effects: item.effects,
769
- applied_refs: appliedRefs,
770
- resolved_at: item.resolved_at,
771
- });
772
- }
773
- }
774
- return items
775
- .sort((left, right) => String(right.resolved_at || '').localeCompare(String(left.resolved_at || '')))
776
- .slice(0, Math.min(Math.max(Number(limit) || 10, 1), 20));
777
- }
778
-
779
- function supersedeDecision(db, input = {}) {
780
- return ops.tx(db, () => {
781
- const id = requiredText(input.id, 'id');
782
- const row = db.prepare('SELECT * FROM decision_items WHERE id = ?').get(id);
783
- if (!row) throw new DecisionDialogueError('DECISION_NOT_FOUND', `decision ${id} not found`);
784
- if (!['answered', 'delegated', 'deferred'].includes(row.status)) {
785
- throw new DecisionDialogueError('DECISION_NOT_SUPERSEDEABLE', `decision ${id} is ${row.status}`);
786
- }
787
- const thread = readDecisionThread(db, row.thread_id);
788
- if (thread.current_decision) {
789
- throw new DecisionDialogueError(
790
- 'DECISION_ALREADY_OPEN', `answer ${thread.current_decision.id} before superseding another decision`,
791
- );
792
- }
793
- const replacement = plainObject(input.replacement, 'replacement', { allowEmpty: false });
794
- const reason = requiredText(input.reason, 'reason');
795
- const ts = nowIso();
796
- const priorResolution = parseJson(row.resolution_json, 'decision_items.resolution_json', {});
797
- db.prepare(
798
- `UPDATE decision_items SET status = 'superseded', resolution_json = ?, updated_at = ? WHERE id = ?`,
799
- ).run(JSON.stringify({ ...priorResolution, superseded_by: replacement.id, supersede_reason: reason }), ts, id);
800
- if (thread.status === 'confirmed') {
801
- db.prepare(
802
- `UPDATE decision_threads SET status = 'active', confirmed_at = NULL,
803
- checkpoint_json = json_set(checkpoint_json, '$.invalidated', json('true'),
804
- '$.invalidated_at', ?, '$.invalidation_reason', ?), updated_at = ? WHERE id = ?`,
805
- ).run(ts, reason, ts, thread.id);
806
- } else if (thread.status === 'completed') {
807
- db.prepare(
808
- `UPDATE decision_threads
809
- SET status = 'active', completed_at = NULL,
810
- summary_json = json_set(summary_json, '$.invalidated', json('true'),
811
- '$.invalidated_at', ?, '$.invalidation_reason', ?),
812
- updated_at = ?
813
- WHERE id = ?`,
814
- ).run(ts, reason, ts, thread.id);
815
- }
816
- insertQuestionInTx(db, readDecisionThread(db, thread.id), replacement, id);
817
- return readDecisionThread(db, thread.id);
818
- });
819
- }
820
-
821
- function decisionDigest(items) {
822
- const authority = items.filter((item) => item.status !== 'superseded').map((item) => ({
823
- id: item.id,
824
- sequence: item.sequence,
825
- status: item.status,
826
- resolution: item.resolution,
827
- effects: item.effects,
828
- }));
829
- return crypto.createHash('sha256').update(JSON.stringify(authority)).digest('hex');
830
- }
831
-
832
- function appendCheckpointHistory(checkpoint) {
833
- const history = Array.isArray(checkpoint?.history) ? [...checkpoint.history] : [];
834
- if (!checkpoint?.confirmed_at) return history;
835
- const { history: _priorHistory, ...prior } = checkpoint;
836
- history.push(prior);
837
- return history;
838
- }
839
-
840
- function inspectCheckpoint(thread, { rootDir = process.cwd(), requireArtifact = false } = {}) {
841
- const failures = [];
842
- if (thread.status !== 'confirmed') {
843
- failures.push({ code: 'DECISION_CHECKPOINT_NOT_CONFIRMED', thread_id: thread.id });
844
- return { valid: false, failures };
845
- }
846
- if (thread.checkpoint.invalidated
847
- || thread.checkpoint.decision_digest !== decisionDigest(thread.items)) {
848
- failures.push({ code: 'DECISION_CHECKPOINT_STALE', thread_id: thread.id });
849
- }
850
- const artifacts = Array.isArray(thread.checkpoint.artifacts) ? thread.checkpoint.artifacts : [];
851
- if (requireArtifact && artifacts.length === 0) {
852
- failures.push({ code: 'DECISION_CHECKPOINT_ARTIFACT_REQUIRED', thread_id: thread.id });
853
- }
854
- const root = path.resolve(rootDir);
855
- for (const artifact of artifacts) {
856
- const relative = typeof artifact?.path === 'string' ? artifact.path : '';
857
- const file = relative ? path.resolve(root, relative) : null;
858
- const inside = file && (file === root || file.startsWith(`${root}${path.sep}`));
859
- const actual = inside && fs.existsSync(file) && fs.statSync(file).isFile()
860
- ? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null;
861
- if (!inside || actual !== artifact.digest) {
862
- failures.push({
863
- code: 'DECISION_CHECKPOINT_ARTIFACT_STALE', thread_id: thread.id,
864
- path: relative || null, expected: artifact?.digest || null, actual,
865
- });
866
- }
867
- }
868
- return { valid: failures.length === 0, failures };
869
- }
870
-
871
- function assertConfirmedDecisionCheckpoint(db, id, options = {}) {
872
- const thread = readDecisionThread(db, requiredText(id, 'id'));
873
- if (!thread) {
874
- throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
875
- }
876
- const health = inspectCheckpoint(thread, options);
877
- if (!health.valid) {
878
- throw new DecisionDialogueError(
879
- 'DECISION_CHECKPOINT_STALE', `decision checkpoint ${id} is not current`, health,
880
- );
881
- }
882
- return thread;
883
- }
884
-
885
- function safeArtifact(rootDir, artifact) {
886
- if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
887
- throw new DecisionDialogueError('VALIDATION_ERROR', 'checkpoint artifact is invalid');
888
- }
889
- const relative = requiredText(artifact.path, 'artifacts.path');
890
- if (path.isAbsolute(relative)) {
891
- throw new DecisionDialogueError('VALIDATION_ERROR', 'checkpoint artifact path must be project-relative');
892
- }
893
- const root = path.resolve(rootDir);
894
- const file = path.resolve(root, relative);
895
- if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
896
- throw new DecisionDialogueError('VALIDATION_ERROR', `checkpoint artifact escapes project root: ${relative}`);
897
- }
898
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
899
- throw new DecisionDialogueError('DECISION_ARTIFACT_MISSING', `checkpoint artifact is missing: ${relative}`);
900
- }
901
- return {
902
- path: relative,
903
- kind: requiredText(artifact.kind, 'artifacts.kind'),
904
- digest: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'),
905
- };
906
- }
907
-
908
- function checkpointDecisionThread(db, input = {}, { rootDir = process.cwd() } = {}) {
909
- const id = requiredText(input.id, 'id');
910
- const action = input.action;
911
- if (!['prepare', 'confirm'].includes(action)) {
912
- throw new DecisionDialogueError('VALIDATION_ERROR', 'checkpoint action must be prepare or confirm');
913
- }
914
- return ops.tx(db, () => {
915
- const thread = readDecisionThread(db, id);
916
- if (!thread) {
917
- throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
918
- }
919
- if (['completed', 'cancelled'].includes(thread.status)) {
920
- throw new DecisionDialogueError(
921
- 'DECISION_THREAD_NOT_MUTABLE', `decision thread ${id} is ${thread.status}`,
922
- );
923
- }
924
- const open = thread.items.find((item) => item.status === 'open');
925
- const deferredBlocking = thread.items.filter((item) => item.blocking && item.status === 'deferred');
926
- if (open || deferredBlocking.length > 0 || thread.items.length === 0) {
927
- const blockers = [
928
- ...(open ? [`DECISION_AWAITING_OWNER:${open.id}`] : []),
929
- ...deferredBlocking.map((item) => `DECISION_DEFERRED_BLOCKING:${item.id}`),
930
- ...(thread.items.length === 0 ? ['DECISION_THREAD_EMPTY'] : []),
931
- ];
932
- throw new DecisionDialogueError(
933
- 'DECISION_ALIGNMENT_BLOCKING', `decision thread ${id} is not ready for checkpoint`, { blockers },
934
- );
935
- }
936
- const digest = decisionDigest(thread.items);
937
- const ts = nowIso();
938
- if (action === 'prepare') {
939
- const summary = requiredText(input.summary, 'summary');
940
- const history = appendCheckpointHistory(thread.checkpoint);
941
- db.prepare(
942
- `UPDATE decision_threads SET status = 'checkpoint_ready', summary_json = ?,
943
- checkpoint_json = ?, updated_at = ? WHERE id = ?`,
944
- ).run(
945
- JSON.stringify({ text: summary }),
946
- JSON.stringify({ decision_digest: digest, summary, prepared_at: ts, history }), ts, id,
947
- );
948
- ops.appendEventInTx(db, {
949
- type: 'decision_checkpoint_prepared', change_id: thread.change_id,
950
- payload: { thread_id: id, decision_digest: digest },
951
- });
952
- return readDecisionThread(db, id);
953
- }
954
- if (thread.status !== 'checkpoint_ready' || thread.checkpoint.decision_digest !== digest) {
955
- throw new DecisionDialogueError(
956
- 'DECISION_CHECKPOINT_STALE', `decision checkpoint ${id} is missing or stale`,
957
- );
958
- }
959
- const artifacts = input.artifacts === undefined
960
- ? [] : input.artifacts.map((artifact) => safeArtifact(rootDir, artifact));
961
- const noArtifactReason = input.no_artifact_reason == null
962
- ? null : requiredText(input.no_artifact_reason, 'no_artifact_reason');
963
- if (artifacts.length === 0 && !noArtifactReason) {
964
- throw new DecisionDialogueError(
965
- 'DECISION_ARTIFACT_REQUIRED', 'checkpoint confirmation requires artifacts or no_artifact_reason',
966
- );
967
- }
968
- if (artifacts.length === 0 && (thread.change_id || thread.workflow_run_id)) {
969
- throw new DecisionDialogueError(
970
- 'DECISION_ARTIFACT_REQUIRED',
971
- 'change- and workflow-bound checkpoints require at least one current artifact',
972
- );
973
- }
974
- const checkpoint = {
975
- ...thread.checkpoint,
976
- decision_digest: digest,
977
- approved_by: requiredText(input.approved_by, 'approved_by'),
978
- approval_note: requiredText(input.approval_note, 'approval_note'),
979
- artifacts,
980
- no_artifact_reason: noArtifactReason,
981
- confirmed_at: ts,
982
- };
983
- db.prepare(
984
- `UPDATE decision_threads SET status = 'confirmed', checkpoint_json = ?,
985
- confirmed_at = ?, updated_at = ? WHERE id = ?`,
986
- ).run(JSON.stringify(checkpoint), ts, ts, id);
987
- ops.appendEventInTx(db, {
988
- type: 'decision_checkpoint_confirmed', change_id: thread.change_id,
989
- payload: { thread_id: id, decision_digest: digest, artifact_count: artifacts.length },
990
- });
991
- return readDecisionThread(db, id);
992
- });
993
- }
994
-
995
- function decisionGate(db, bindings = {}, { rootDir = process.cwd() } = {}) {
996
- const clauses = [];
997
- const params = {};
998
- if (bindings.workflow_run_id) {
999
- clauses.push('workflow_run_id = @workflow_run_id');
1000
- params.workflow_run_id = bindings.workflow_run_id;
1001
- }
1002
- if (bindings.change_id) {
1003
- clauses.push('change_id = @change_id');
1004
- params.change_id = bindings.change_id;
1005
- }
1006
- if (bindings.baseline_id) {
1007
- clauses.push('(baseline_id = @baseline_id AND change_id IS NULL)');
1008
- params.baseline_id = bindings.baseline_id;
1009
- }
1010
- if (clauses.length === 0) return { ready: true, blockers: [], thread: null, current_decision: null };
1011
- const rows = db.prepare(
1012
- `SELECT id FROM decision_threads
1013
- WHERE status IN ('active', 'checkpoint_ready', 'confirmed') AND (${clauses.join(' OR ')})
1014
- ORDER BY updated_at ASC, rowid ASC`,
1015
- ).all(params);
1016
- const blockers = [];
1017
- let selected = null;
1018
- let fallback = null;
1019
- for (const row of rows) {
1020
- const thread = readDecisionThread(db, row.id);
1021
- if (thread.status === 'confirmed') {
1022
- const checkpointHealth = inspectCheckpoint(thread, { rootDir });
1023
- if (!checkpointHealth.valid) {
1024
- selected ||= thread;
1025
- blockers.push(`DECISION_CHECKPOINT_ARTIFACT_STALE:${thread.id}`);
1026
- }
1027
- continue;
1028
- }
1029
- fallback ||= thread;
1030
- const priorBlockerCount = blockers.length;
1031
- if (thread.current_decision?.blocking) {
1032
- blockers.push(`DECISION_AWAITING_OWNER:${thread.current_decision.id}`);
1033
- }
1034
- for (const item of thread.items) {
1035
- if (item.blocking && item.status === 'deferred') {
1036
- blockers.push(`DECISION_DEFERRED_BLOCKING:${item.id}`);
1037
- }
1038
- }
1039
- if (thread.status === 'checkpoint_ready') blockers.push(`DECISION_CHECKPOINT_CONFIRMATION_REQUIRED:${thread.id}`);
1040
- if (blockers.length > priorBlockerCount) selected ||= thread;
1041
- }
1042
- const reported = selected || fallback;
1043
- return {
1044
- ready: blockers.length === 0,
1045
- blockers: [...new Set(blockers)],
1046
- thread: reported,
1047
- current_decision: reported?.current_decision || null,
1048
- };
1049
- }
1050
-
1051
- function assertDecisionGate(db, bindings = {}, options = {}) {
1052
- const gate = decisionGate(db, bindings, options);
1053
- if (!gate.ready) {
1054
- throw new DecisionDialogueError(
1055
- 'DECISION_ALIGNMENT_BLOCKING', `decision alignment blocks workflow advancement: ${gate.blockers.join(', ')}`,
1056
- gate,
1057
- );
1058
- }
1059
- return gate;
1060
- }
1061
-
1062
- function inspectDecisionHealth(db, { rootDir = process.cwd() } = {}) {
1063
- const threads = listDecisionThreads(db, { limit: 500 });
1064
- const active = threads.filter((thread) => thread.status === 'active');
1065
- const completed = threads.filter((thread) => thread.status === 'completed');
1066
- const checkpointReady = threads.filter((thread) => thread.status === 'checkpoint_ready');
1067
- const awaiting = active.filter((thread) => thread.current_decision);
1068
- const awaitingBlocking = awaiting.filter((thread) => thread.current_decision.blocking);
1069
- const deferredBlocking = threads.flatMap((thread) => thread.items.filter(
1070
- (item) => item.blocking && item.status === 'deferred',
1071
- ));
1072
- const staleArtifacts = [];
1073
- const root = path.resolve(rootDir);
1074
- for (const thread of threads.filter((item) => item.status === 'confirmed')) {
1075
- if (thread.change_id) {
1076
- const change = db.prepare('SELECT status FROM changes WHERE id = ?').get(thread.change_id);
1077
- if (!change || !['active', 'blocked', 'ready'].includes(change.status)) continue;
1078
- }
1079
- for (const artifact of thread.checkpoint.artifacts || []) {
1080
- const file = path.resolve(root, artifact.path);
1081
- const inside = file === root || file.startsWith(`${root}${path.sep}`);
1082
- const digest = inside && fs.existsSync(file) && fs.statSync(file).isFile()
1083
- ? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null;
1084
- if (!inside || digest !== artifact.digest) {
1085
- staleArtifacts.push({ thread_id: thread.id, path: artifact.path, expected: artifact.digest, actual: digest });
1086
- }
1087
- }
1088
- }
1089
- let currentThread = awaitingBlocking[0] || checkpointReady[0] || awaiting[0] || active[0] || null;
1090
- if (staleArtifacts.length > 0) {
1091
- currentThread = threads.find((thread) => thread.id === staleArtifacts[0].thread_id) || currentThread;
1092
- }
1093
- const currentThreadId = staleArtifacts[0]?.thread_id || currentThread?.id || null;
1094
- const status = staleArtifacts.length > 0
1095
- ? 'fail'
1096
- : (active.length > 0 || checkpointReady.length > 0 || deferredBlocking.length > 0 ? 'warning' : 'pass');
1097
- return {
1098
- status,
1099
- active: active.length,
1100
- completed: completed.length,
1101
- awaiting_owner: awaiting.length,
1102
- awaiting_blocking: awaitingBlocking.length,
1103
- checkpoint_ready: checkpointReady.length,
1104
- deferred_blocking: deferredBlocking.length,
1105
- stale_artifacts: staleArtifacts,
1106
- current: currentThread?.current_decision || null,
1107
- current_thread_id: currentThreadId,
1108
- };
1109
- }
1110
-
1111
- module.exports = {
1112
- DecisionDialogueError,
1113
- startDecisionThread,
1114
- readDecisionThread,
1115
- listDecisionThreads,
1116
- openDecision,
1117
- resolveDecision,
1118
- delegateDecision,
1119
- deferDecision,
1120
- completeDecisionThread,
1121
- supersedeDecision,
1122
- checkpointDecisionThread,
1123
- acceptedIntent,
1124
- decisionGate,
1125
- assertDecisionGate,
1126
- assertConfirmedDecisionCheckpoint,
1127
- inspectDecisionHealth,
1128
- };