ultra-builder-pro-cli 0.19.1 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +90 -1
  5. package/README.md +62 -29
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +60 -5
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/commands/ultra-research.md +1 -1
  15. package/docs/AGENT-CONTEXT.md +79 -45
  16. package/docs/ARCHITECTURE.md +75 -38
  17. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  18. package/docs/DECISIONS.md +20 -10
  19. package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
  20. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  21. package/docs/ROADMAP.md +21 -6
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
  23. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +105 -54
  25. package/hooks/README.md +6 -5
  26. package/hooks/active_task_context.py +97 -12
  27. package/hooks/context_spine.py +16 -4
  28. package/hooks/health_check.py +5 -4
  29. package/hooks/pre_stop_check.py +5 -2
  30. package/hooks/runtime_paths.py +396 -0
  31. package/hooks/subagent_tracker.py +5 -2
  32. package/hooks/workflow_checkpoint.py +16 -3
  33. package/hooks/workflow_context.py +11 -3
  34. package/hooks/workflow_resume.py +7 -4
  35. package/mcp-server/breadcrumb.cjs +20 -2
  36. package/mcp-server/lib/archive-journal.cjs +1026 -111
  37. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  38. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  39. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  40. package/mcp-server/lib/change-packet.cjs +818 -0
  41. package/mcp-server/lib/change-workflow.cjs +932 -176
  42. package/mcp-server/lib/context-paths.cjs +111 -0
  43. package/mcp-server/lib/context-spine.cjs +511 -45
  44. package/mcp-server/lib/decision-dialogue.cjs +427 -12
  45. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  46. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  47. package/mcp-server/lib/doctor.cjs +375 -12
  48. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  49. package/mcp-server/lib/init-project.cjs +190 -95
  50. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  51. package/mcp-server/lib/plan-store.cjs +700 -5
  52. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  53. package/mcp-server/lib/projector.cjs +174 -40
  54. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  55. package/mcp-server/lib/safe-project-file.cjs +385 -0
  56. package/mcp-server/lib/spec-learning.cjs +28 -12
  57. package/mcp-server/lib/state-db.cjs +397 -13
  58. package/mcp-server/lib/state-ops.cjs +248 -49
  59. package/mcp-server/lib/telemetry.cjs +12 -3
  60. package/mcp-server/lib/workflow-state.cjs +614 -62
  61. package/mcp-server/server.cjs +282 -33
  62. package/orchestrator/daemon.cjs +2 -10
  63. package/orchestrator/parallel-orchestrator.cjs +314 -50
  64. package/orchestrator/planner/plan-builder.cjs +1 -1
  65. package/orchestrator/recovery.cjs +125 -7
  66. package/orchestrator/session-close-journal-worker.cjs +5 -0
  67. package/orchestrator/session-close-journal.cjs +1014 -0
  68. package/orchestrator/session-runner.cjs +679 -69
  69. package/orchestrator/worktree-manager.cjs +186 -35
  70. package/package.json +2 -3
  71. package/skills/cc-collab/SKILL.md +4 -1
  72. package/skills/codex-collab/SKILL.md +5 -2
  73. package/skills/ultra-change/SKILL.md +38 -13
  74. package/skills/ultra-deliver/SKILL.md +23 -11
  75. package/skills/ultra-dev/SKILL.md +33 -6
  76. package/skills/ultra-doctor/SKILL.md +6 -2
  77. package/skills/ultra-init/SKILL.md +4 -2
  78. package/skills/ultra-plan/SKILL.md +68 -18
  79. package/skills/ultra-research/SKILL.md +45 -32
  80. package/skills/ultra-review/SKILL.md +9 -5
  81. package/skills/ultra-status/SKILL.md +9 -4
  82. package/skills/ultra-test/SKILL.md +15 -7
  83. package/skills/ultra-think/SKILL.md +6 -0
  84. package/skills/ultra-think/references/decision-dialogue.md +61 -14
  85. package/skills/ultra-verify/SKILL.md +4 -1
  86. package/spec/cli-protocol.md +19 -7
  87. package/spec/mcp-tools.yaml +531 -46
  88. package/spec/schemas/change-delta.v1.schema.json +153 -0
  89. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  90. package/spec/schemas/mcp-tools.schema.json +1 -1
  91. package/spec/schemas/state-db.sql +57 -17
  92. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  93. package/ultra-tools/cli.cjs +3 -2
  94. package/ultra-tools/commands/db.cjs +76 -25
  95. package/ultra-tools/commands/migrate.cjs +995 -80
  96. package/ultra-tools/commands/session.cjs +34 -8
  97. package/ultra-tools/commands/status.cjs +34 -7
  98. package/ultra-tools/commands/system.cjs +129 -22
  99. package/commands/learn.md +0 -10
  100. package/orchestrator/skill-miner.cjs +0 -133
  101. package/output-styles/coding-vibes.md +0 -67
  102. package/output-styles/structural-thinking.md +0 -61
  103. package/skills/learn/SKILL.md +0 -58
@@ -5,10 +5,15 @@ const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
 
7
7
  const ops = require('./state-ops.cjs');
8
+ const artifactRegistry = require('./artifact-registry.cjs');
9
+ const { readStableProjectFile } = require('./safe-project-file.cjs');
8
10
 
9
11
  const ID = /^[a-zA-Z0-9_-]+$/;
10
12
  const MODES = new Set(['guided', 'fast', 'autonomous', 'diagnostic']);
11
13
  const ACTIVE_STATUSES = new Set(['active', 'checkpoint_ready']);
14
+ const APPLIED_REF_KINDS = new Set([
15
+ 'baseline', 'change', 'workflow', 'task', 'spec', 'artifact',
16
+ ]);
12
17
 
13
18
  class DecisionDialogueError extends Error {
14
19
  constructor(code, message, details) {
@@ -53,6 +58,281 @@ function plainObject(value, field, { allowEmpty = true } = {}) {
53
58
  return JSON.parse(JSON.stringify(value));
54
59
  }
55
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
+
56
336
  function normalizeOptions(value) {
57
337
  if (value === undefined) return [];
58
338
  if (!Array.isArray(value) || value.length > 3) {
@@ -120,6 +400,7 @@ function readDecisionThread(db, id) {
120
400
  items,
121
401
  started_at: row.started_at,
122
402
  updated_at: row.updated_at,
403
+ completed_at: row.completed_at,
123
404
  confirmed_at: row.confirmed_at,
124
405
  };
125
406
  }
@@ -384,6 +665,117 @@ function deferDecision(db, input = {}) {
384
665
  });
385
666
  }
386
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
+
387
779
  function supersedeDecision(db, input = {}) {
388
780
  return ops.tx(db, () => {
389
781
  const id = requiredText(input.id, 'id');
@@ -411,6 +803,15 @@ function supersedeDecision(db, input = {}) {
411
803
  checkpoint_json = json_set(checkpoint_json, '$.invalidated', json('true'),
412
804
  '$.invalidated_at', ?, '$.invalidation_reason', ?), updated_at = ? WHERE id = ?`,
413
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);
414
815
  }
415
816
  insertQuestionInTx(db, readDecisionThread(db, thread.id), replacement, id);
416
817
  return readDecisionThread(db, thread.id);
@@ -515,8 +916,10 @@ function checkpointDecisionThread(db, input = {}, { rootDir = process.cwd() } =
515
916
  if (!thread) {
516
917
  throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
517
918
  }
518
- if (thread.status === 'cancelled') {
519
- throw new DecisionDialogueError('DECISION_THREAD_NOT_MUTABLE', `decision thread ${id} is cancelled`);
919
+ if (['completed', 'cancelled'].includes(thread.status)) {
920
+ throw new DecisionDialogueError(
921
+ 'DECISION_THREAD_NOT_MUTABLE', `decision thread ${id} is ${thread.status}`,
922
+ );
520
923
  }
521
924
  const open = thread.items.find((item) => item.status === 'open');
522
925
  const deferredBlocking = thread.items.filter((item) => item.blocking && item.status === 'deferred');
@@ -601,7 +1004,7 @@ function decisionGate(db, bindings = {}, { rootDir = process.cwd() } = {}) {
601
1004
  params.change_id = bindings.change_id;
602
1005
  }
603
1006
  if (bindings.baseline_id) {
604
- clauses.push('(baseline_id = @baseline_id AND change_id IS NULL AND workflow_run_id IS NULL)');
1007
+ clauses.push('(baseline_id = @baseline_id AND change_id IS NULL)');
605
1008
  params.baseline_id = bindings.baseline_id;
606
1009
  }
607
1010
  if (clauses.length === 0) return { ready: true, blockers: [], thread: null, current_decision: null };
@@ -612,6 +1015,7 @@ function decisionGate(db, bindings = {}, { rootDir = process.cwd() } = {}) {
612
1015
  ).all(params);
613
1016
  const blockers = [];
614
1017
  let selected = null;
1018
+ let fallback = null;
615
1019
  for (const row of rows) {
616
1020
  const thread = readDecisionThread(db, row.id);
617
1021
  if (thread.status === 'confirmed') {
@@ -622,23 +1026,25 @@ function decisionGate(db, bindings = {}, { rootDir = process.cwd() } = {}) {
622
1026
  }
623
1027
  continue;
624
1028
  }
625
- selected ||= thread;
626
- if (thread.current_decision) blockers.push(`DECISION_AWAITING_OWNER:${thread.current_decision.id}`);
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
+ }
627
1034
  for (const item of thread.items) {
628
1035
  if (item.blocking && item.status === 'deferred') {
629
1036
  blockers.push(`DECISION_DEFERRED_BLOCKING:${item.id}`);
630
1037
  }
631
1038
  }
632
1039
  if (thread.status === 'checkpoint_ready') blockers.push(`DECISION_CHECKPOINT_CONFIRMATION_REQUIRED:${thread.id}`);
633
- else if (!thread.current_decision && !thread.items.some((item) => item.blocking && item.status === 'deferred')) {
634
- blockers.push(`DECISION_CHECKPOINT_REQUIRED:${thread.id}`);
635
- }
1040
+ if (blockers.length > priorBlockerCount) selected ||= thread;
636
1041
  }
1042
+ const reported = selected || fallback;
637
1043
  return {
638
1044
  ready: blockers.length === 0,
639
1045
  blockers: [...new Set(blockers)],
640
- thread: selected,
641
- current_decision: selected?.current_decision || null,
1046
+ thread: reported,
1047
+ current_decision: reported?.current_decision || null,
642
1048
  };
643
1049
  }
644
1050
 
@@ -656,8 +1062,10 @@ function assertDecisionGate(db, bindings = {}, options = {}) {
656
1062
  function inspectDecisionHealth(db, { rootDir = process.cwd() } = {}) {
657
1063
  const threads = listDecisionThreads(db, { limit: 500 });
658
1064
  const active = threads.filter((thread) => thread.status === 'active');
1065
+ const completed = threads.filter((thread) => thread.status === 'completed');
659
1066
  const checkpointReady = threads.filter((thread) => thread.status === 'checkpoint_ready');
660
1067
  const awaiting = active.filter((thread) => thread.current_decision);
1068
+ const awaitingBlocking = awaiting.filter((thread) => thread.current_decision.blocking);
661
1069
  const deferredBlocking = threads.flatMap((thread) => thread.items.filter(
662
1070
  (item) => item.blocking && item.status === 'deferred',
663
1071
  ));
@@ -678,15 +1086,20 @@ function inspectDecisionHealth(db, { rootDir = process.cwd() } = {}) {
678
1086
  }
679
1087
  }
680
1088
  }
681
- const currentThread = awaiting[0] || checkpointReady[0] || active[0] || null;
682
- const currentThreadId = currentThread?.id || staleArtifacts[0]?.thread_id || null;
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;
683
1094
  const status = staleArtifacts.length > 0
684
1095
  ? 'fail'
685
1096
  : (active.length > 0 || checkpointReady.length > 0 || deferredBlocking.length > 0 ? 'warning' : 'pass');
686
1097
  return {
687
1098
  status,
688
1099
  active: active.length,
1100
+ completed: completed.length,
689
1101
  awaiting_owner: awaiting.length,
1102
+ awaiting_blocking: awaitingBlocking.length,
690
1103
  checkpoint_ready: checkpointReady.length,
691
1104
  deferred_blocking: deferredBlocking.length,
692
1105
  stale_artifacts: staleArtifacts,
@@ -704,8 +1117,10 @@ module.exports = {
704
1117
  resolveDecision,
705
1118
  delegateDecision,
706
1119
  deferDecision,
1120
+ completeDecisionThread,
707
1121
  supersedeDecision,
708
1122
  checkpointDecisionThread,
1123
+ acceptedIntent,
709
1124
  decisionGate,
710
1125
  assertDecisionGate,
711
1126
  assertConfirmedDecisionCheckpoint,