ultra-builder-pro-cli 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/docs/research/README.md +11 -7
- package/.ultra-template/specs/architecture.md +36 -36
- package/.ultra-template/specs/discovery.md +46 -33
- package/.ultra-template/specs/product.md +38 -29
- package/.ultra-template/specs/research-distillate.md +48 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
- package/CHANGELOG.md +77 -1
- package/README.md +46 -24
- package/adapters/_shared/codex-assets.cjs +6 -2
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/_shared/handbook.cjs +2 -1
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +3 -2
- package/agents/review-coordinator.md +9 -6
- package/commands/ultra-change.md +1 -1
- package/commands/ultra-plan.md +1 -1
- package/commands/ultra-research.md +1 -1
- package/commands/ultra-think.md +2 -2
- package/docs/AGENT-CONTEXT.md +43 -19
- package/docs/ARCHITECTURE.md +54 -13
- package/docs/PLAN.zh-CN.md +4 -2
- package/docs/ROADMAP.md +10 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +33 -29
- package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
- package/docs/WORKFLOW-LIFECYCLE.md +342 -0
- package/hooks/README.md +6 -1
- package/hooks/health_check.py +12 -3
- package/hooks/subagent_tracker.py +52 -77
- package/mcp-server/hook-event.cjs +34 -0
- package/mcp-server/lib/archive-journal.cjs +17 -5
- package/mcp-server/lib/baseline-workflow.cjs +158 -18
- package/mcp-server/lib/change-workflow.cjs +598 -85
- package/mcp-server/lib/context-spine.cjs +305 -25
- package/mcp-server/lib/decision-dialogue.cjs +713 -0
- package/mcp-server/lib/doctor.cjs +42 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +15 -0
- package/mcp-server/lib/projector.cjs +57 -3
- package/mcp-server/lib/spec-learning.cjs +79 -2
- package/mcp-server/lib/state-db.cjs +168 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/workflow-state.cjs +2305 -0
- package/mcp-server/server.cjs +215 -15
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +13 -3
- package/skills/ultra-change/SKILL.md +94 -80
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +76 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +62 -59
- package/skills/ultra-doctor/SKILL.md +61 -82
- package/skills/ultra-init/SKILL.md +101 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +99 -85
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +102 -60
- package/skills/ultra-research/references/00-problem-validation.md +36 -0
- package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
- package/skills/ultra-research/references/02-market-assessment.md +30 -0
- package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
- package/skills/ultra-research/references/04-product-strategy.md +32 -0
- package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
- package/skills/ultra-research/references/10-user-personas.md +29 -0
- package/skills/ultra-research/references/11-user-scenarios.md +28 -0
- package/skills/ultra-research/references/20-user-stories.md +30 -0
- package/skills/ultra-research/references/21-features-scope.md +29 -0
- package/skills/ultra-research/references/22-success-metrics.md +28 -0
- package/skills/ultra-research/references/30-architecture-context.md +30 -0
- package/skills/ultra-research/references/31-solution-strategy.md +28 -0
- package/skills/ultra-research/references/32-building-blocks.md +28 -0
- package/skills/ultra-research/references/40-deployment.md +29 -0
- package/skills/ultra-research/references/41-quality-risks.md +28 -0
- package/skills/ultra-research/references/99-synthesis.md +35 -0
- package/skills/ultra-review/SKILL.md +63 -73
- package/skills/ultra-review/references/review-modes.md +27 -0
- package/skills/ultra-review/references/unified-schema.md +25 -0
- package/skills/ultra-review/scripts/review_wait.py +40 -3
- package/skills/ultra-status/SKILL.md +41 -41
- package/skills/ultra-test/SKILL.md +54 -55
- package/skills/ultra-think/SKILL.md +26 -12
- package/skills/ultra-think/references/decision-dialogue.md +130 -0
- package/skills/ultra-verify/SKILL.md +1 -1
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/spec/cli-protocol.md +17 -3
- package/spec/mcp-tools.yaml +683 -64
- package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
- package/spec/schemas/delivery-report.v1.schema.json +111 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +140 -1
- package/spec/schemas/tasks.v4.5.schema.json +8 -1
- package/spec/schemas/test-report.v1.schema.json +194 -0
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/migrate.cjs +3 -3
- package/ultra-tools/commands/status.cjs +228 -12
- package/.ultra-template/delivery-report.json +0 -9
- package/.ultra-template/test-report.json +0 -8
- package/adapters/_shared/tests/file-ops.test.cjs +0 -54
- package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
- package/adapters/_shared/tests/handbook.test.cjs +0 -101
- package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
- package/adapters/_shared/tests/provenance.test.cjs +0 -133
- package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
- package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
- package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
- package/adapters/_shared/tests/validate.test.cjs +0 -30
- package/adapters/tests/claude.test.cjs +0 -138
- package/adapters/tests/codex-hook.test.cjs +0 -142
- package/adapters/tests/codex.test.cjs +0 -430
- package/adapters/tests/kimi-hook.test.cjs +0 -149
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -357
- package/mcp-server/lib/archive-journal.test.cjs +0 -35
- package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
- package/mcp-server/lib/change-workflow.test.cjs +0 -971
- package/mcp-server/lib/context-spine.test.cjs +0 -93
- package/mcp-server/lib/doctor.test.cjs +0 -148
- package/mcp-server/lib/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -82
- package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
- package/mcp-server/lib/runtime-state.test.cjs +0 -92
- package/mcp-server/lib/spec-learning.test.cjs +0 -67
- package/mcp-server/lib/state-db.test.cjs +0 -314
- package/mcp-server/lib/task-expander.test.cjs +0 -169
- package/mcp-server/lib/topo.test.cjs +0 -158
- package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
- package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
- package/mcp-server/tests/concurrency-update.test.cjs +0 -96
- package/mcp-server/tests/concurrency.test.cjs +0 -112
- package/mcp-server/tests/db-ops.test.cjs +0 -121
- package/mcp-server/tests/init-project.test.cjs +0 -393
- package/mcp-server/tests/migrate.test.cjs +0 -232
- package/mcp-server/tests/projector.test.cjs +0 -162
- package/mcp-server/tests/server-contract.test.cjs +0 -1467
- package/mcp-server/tests/staleness.test.cjs +0 -190
- package/mcp-server/tests/state-db.test.cjs +0 -106
- package/mcp-server/tests/state-ops.test.cjs +0 -449
- package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
- package/mcp-server/tests/telemetry.test.cjs +0 -219
- package/orchestrator/planner/plan-builder.test.cjs +0 -165
- package/orchestrator/tests/auto-merge.test.cjs +0 -216
- package/orchestrator/tests/daemon.test.cjs +0 -311
- package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
- package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
- package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
- package/orchestrator/tests/recovery.test.cjs +0 -190
- package/orchestrator/tests/session-runner.test.cjs +0 -314
- package/orchestrator/tests/skill-miner.test.cjs +0 -147
- package/orchestrator/tests/worktree-manager.test.cjs +0 -148
- package/skills/ultra-research/references/architecture.md +0 -38
- package/skills/ultra-research/references/problem-product.md +0 -39
- package/skills/ultra-research/references/quality-delivery.md +0 -34
- package/skills/ultra-research/references/synthesis.md +0 -38
- package/skills/ultra-research/references/users-scope.md +0 -40
- package/ultra-tools/legacy-memory.test.cjs +0 -80
- package/ultra-tools/status-command.test.cjs +0 -181
- package/ultra-tools/system-command.test.cjs +0 -309
|
@@ -0,0 +1,713 @@
|
|
|
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
|
+
|
|
9
|
+
const ID = /^[a-zA-Z0-9_-]+$/;
|
|
10
|
+
const MODES = new Set(['guided', 'fast', 'autonomous', 'diagnostic']);
|
|
11
|
+
const ACTIVE_STATUSES = new Set(['active', 'checkpoint_ready']);
|
|
12
|
+
|
|
13
|
+
class DecisionDialogueError extends Error {
|
|
14
|
+
constructor(code, message, details) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'DecisionDialogueError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
if (details !== undefined) this.details = details;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nowIso() {
|
|
23
|
+
return new Date().toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requiredText(value, field) {
|
|
27
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
28
|
+
if (!text) throw new DecisionDialogueError('VALIDATION_ERROR', `${field} is required`);
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseJson(value, field, fallback) {
|
|
33
|
+
if (value == null || value === '') return fallback;
|
|
34
|
+
try { return JSON.parse(value); }
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new DecisionDialogueError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringList(value, field) {
|
|
41
|
+
if (value === undefined) return [];
|
|
42
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
43
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', `${field} must be an array of non-empty strings`);
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(value.map((item) => item.trim()))];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function plainObject(value, field, { allowEmpty = true } = {}) {
|
|
49
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
50
|
+
|| (!allowEmpty && Object.keys(value).length === 0)) {
|
|
51
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', `${field} must be an object`);
|
|
52
|
+
}
|
|
53
|
+
return JSON.parse(JSON.stringify(value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeOptions(value) {
|
|
57
|
+
if (value === undefined) return [];
|
|
58
|
+
if (!Array.isArray(value) || value.length > 3) {
|
|
59
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', 'options must contain at most three credible alternatives');
|
|
60
|
+
}
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
return value.map((option, index) => {
|
|
63
|
+
if (!option || typeof option !== 'object' || Array.isArray(option)) {
|
|
64
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', `options[${index}] is invalid`);
|
|
65
|
+
}
|
|
66
|
+
const id = requiredText(option.id, `options[${index}].id`);
|
|
67
|
+
if (!ID.test(id) || seen.has(id)) {
|
|
68
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', `option id is invalid or duplicated: ${id}`);
|
|
69
|
+
}
|
|
70
|
+
seen.add(id);
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
label: requiredText(option.label, `options[${index}].label`),
|
|
74
|
+
tradeoff: requiredText(option.tradeoff, `options[${index}].tradeoff`),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rowToItem(row) {
|
|
80
|
+
if (!row) return null;
|
|
81
|
+
return {
|
|
82
|
+
id: row.id,
|
|
83
|
+
thread_id: row.thread_id,
|
|
84
|
+
sequence: row.sequence,
|
|
85
|
+
phase: row.phase,
|
|
86
|
+
question: row.question,
|
|
87
|
+
why_now: row.why_now,
|
|
88
|
+
recommendation: row.recommendation,
|
|
89
|
+
options: parseJson(row.options_json, 'decision_items.options_json', []),
|
|
90
|
+
evidence_refs: parseJson(row.evidence_refs_json, 'decision_items.evidence_refs_json', []),
|
|
91
|
+
dependency_ids: parseJson(row.dependency_ids_json, 'decision_items.dependency_ids_json', []),
|
|
92
|
+
effects: parseJson(row.effects_json, 'decision_items.effects_json', {}),
|
|
93
|
+
blocking: Boolean(row.blocking),
|
|
94
|
+
status: row.status,
|
|
95
|
+
resolution: parseJson(row.resolution_json, 'decision_items.resolution_json', {}),
|
|
96
|
+
supersedes_id: row.supersedes_id,
|
|
97
|
+
created_at: row.created_at,
|
|
98
|
+
updated_at: row.updated_at,
|
|
99
|
+
resolved_at: row.resolved_at,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readDecisionThread(db, id) {
|
|
104
|
+
const row = db.prepare('SELECT * FROM decision_threads WHERE id = ?').get(id);
|
|
105
|
+
if (!row) return null;
|
|
106
|
+
const items = db.prepare(
|
|
107
|
+
'SELECT * FROM decision_items WHERE thread_id = ? ORDER BY sequence ASC',
|
|
108
|
+
).all(id).map(rowToItem);
|
|
109
|
+
return {
|
|
110
|
+
id: row.id,
|
|
111
|
+
purpose: row.purpose,
|
|
112
|
+
mode: row.mode,
|
|
113
|
+
status: row.status,
|
|
114
|
+
baseline_id: row.baseline_id,
|
|
115
|
+
change_id: row.change_id,
|
|
116
|
+
workflow_run_id: row.workflow_run_id,
|
|
117
|
+
summary: parseJson(row.summary_json, 'decision_threads.summary_json', {}),
|
|
118
|
+
checkpoint: parseJson(row.checkpoint_json, 'decision_threads.checkpoint_json', {}),
|
|
119
|
+
current_decision: items.find((item) => item.status === 'open') || null,
|
|
120
|
+
items,
|
|
121
|
+
started_at: row.started_at,
|
|
122
|
+
updated_at: row.updated_at,
|
|
123
|
+
confirmed_at: row.confirmed_at,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listDecisionThreads(db, filter = {}) {
|
|
128
|
+
const clauses = [];
|
|
129
|
+
const params = {};
|
|
130
|
+
for (const field of ['status', 'mode', 'baseline_id', 'change_id', 'workflow_run_id']) {
|
|
131
|
+
if (filter[field] !== undefined && filter[field] !== null) {
|
|
132
|
+
clauses.push(`${field} = @${field}`);
|
|
133
|
+
params[field] = filter[field];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const limit = Math.min(Math.max(Number(filter.limit || 100), 1), 500);
|
|
137
|
+
const sql = `SELECT id FROM decision_threads${clauses.length ? ` WHERE ${clauses.join(' AND ')}` : ''}
|
|
138
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT @limit`;
|
|
139
|
+
return db.prepare(sql).all({ ...params, limit }).map((row) => readDecisionThread(db, row.id));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function assertReference(db, table, id, field) {
|
|
143
|
+
if (!id) return;
|
|
144
|
+
if (!db.prepare(`SELECT 1 FROM ${table} WHERE id = ?`).get(id)) {
|
|
145
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', `${field} does not exist: ${id}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startDecisionThread(db, input = {}) {
|
|
150
|
+
const id = requiredText(input.id, 'id');
|
|
151
|
+
if (!ID.test(id)) throw new DecisionDialogueError('VALIDATION_ERROR', `invalid decision thread id: ${id}`);
|
|
152
|
+
const purpose = requiredText(input.purpose, 'purpose');
|
|
153
|
+
const mode = input.mode || 'guided';
|
|
154
|
+
if (!MODES.has(mode)) throw new DecisionDialogueError('VALIDATION_ERROR', `unsupported decision mode: ${mode}`);
|
|
155
|
+
if (!input.baseline_id && !input.change_id && !input.workflow_run_id) {
|
|
156
|
+
throw new DecisionDialogueError(
|
|
157
|
+
'DECISION_AUTHORITY_REQUIRED', 'decision thread requires baseline, change, or workflow authority',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return ops.tx(db, () => {
|
|
161
|
+
if (db.prepare('SELECT 1 FROM decision_threads WHERE id = ?').get(id)) {
|
|
162
|
+
throw new DecisionDialogueError('DUPLICATE_DECISION_THREAD', `decision thread ${id} already exists`);
|
|
163
|
+
}
|
|
164
|
+
assertReference(db, 'baselines', input.baseline_id, 'baseline_id');
|
|
165
|
+
assertReference(db, 'changes', input.change_id, 'change_id');
|
|
166
|
+
assertReference(db, 'workflow_runs', input.workflow_run_id, 'workflow_run_id');
|
|
167
|
+
let baselineId = input.baseline_id || null;
|
|
168
|
+
let changeId = input.change_id || null;
|
|
169
|
+
if (input.workflow_run_id) {
|
|
170
|
+
const run = db.prepare(
|
|
171
|
+
'SELECT baseline_id, change_id FROM workflow_runs WHERE id = ?',
|
|
172
|
+
).get(input.workflow_run_id);
|
|
173
|
+
if (input.baseline_id && run.baseline_id && input.baseline_id !== run.baseline_id) {
|
|
174
|
+
throw new DecisionDialogueError('DECISION_AUTHORITY_MISMATCH', 'thread baseline differs from workflow');
|
|
175
|
+
}
|
|
176
|
+
if (input.change_id && run.change_id && input.change_id !== run.change_id) {
|
|
177
|
+
throw new DecisionDialogueError('DECISION_AUTHORITY_MISMATCH', 'thread change differs from workflow');
|
|
178
|
+
}
|
|
179
|
+
baselineId ||= run.baseline_id || null;
|
|
180
|
+
changeId ||= run.change_id || null;
|
|
181
|
+
}
|
|
182
|
+
if (changeId) {
|
|
183
|
+
const changeBaselineId = db.prepare(
|
|
184
|
+
`SELECT baseline_id FROM workflow_runs
|
|
185
|
+
WHERE kind = 'change' AND change_id = ? ORDER BY started_at DESC, rowid DESC LIMIT 1`,
|
|
186
|
+
).get(changeId)?.baseline_id || null;
|
|
187
|
+
if (baselineId && changeBaselineId && baselineId !== changeBaselineId) {
|
|
188
|
+
throw new DecisionDialogueError(
|
|
189
|
+
'DECISION_AUTHORITY_MISMATCH', 'thread baseline differs from the owning change workflow',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
baselineId ||= changeBaselineId;
|
|
193
|
+
}
|
|
194
|
+
const duplicate = db.prepare(
|
|
195
|
+
`SELECT id, baseline_id, change_id, workflow_run_id FROM decision_threads
|
|
196
|
+
WHERE status IN ('active', 'checkpoint_ready') ORDER BY updated_at ASC, rowid ASC`,
|
|
197
|
+
).all().find((thread) => (
|
|
198
|
+
(changeId && thread.change_id === changeId)
|
|
199
|
+
|| (!changeId && baselineId && !thread.change_id && thread.baseline_id === baselineId)
|
|
200
|
+
|| (input.workflow_run_id && thread.workflow_run_id === input.workflow_run_id)
|
|
201
|
+
));
|
|
202
|
+
if (duplicate) {
|
|
203
|
+
throw new DecisionDialogueError(
|
|
204
|
+
'DECISION_THREAD_IN_PROGRESS', `decision thread ${duplicate.id} already owns this alignment purpose`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const ts = nowIso();
|
|
208
|
+
db.prepare(
|
|
209
|
+
`INSERT INTO decision_threads
|
|
210
|
+
(id, purpose, mode, status, baseline_id, change_id, workflow_run_id,
|
|
211
|
+
summary_json, checkpoint_json, started_at, updated_at)
|
|
212
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, '{}', ?, ?)`,
|
|
213
|
+
).run(
|
|
214
|
+
id, purpose, mode, baselineId, changeId,
|
|
215
|
+
input.workflow_run_id || null, '{}', ts, ts,
|
|
216
|
+
);
|
|
217
|
+
ops.appendEventInTx(db, {
|
|
218
|
+
type: 'decision_thread_started', change_id: changeId,
|
|
219
|
+
payload: { thread_id: id, workflow_run_id: input.workflow_run_id || null, mode },
|
|
220
|
+
});
|
|
221
|
+
return readDecisionThread(db, id);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function requireMutableThread(db, id) {
|
|
226
|
+
const thread = readDecisionThread(db, id);
|
|
227
|
+
if (!thread) throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
|
|
228
|
+
if (!ACTIVE_STATUSES.has(thread.status)) {
|
|
229
|
+
throw new DecisionDialogueError('DECISION_THREAD_NOT_MUTABLE', `decision thread ${id} is ${thread.status}`);
|
|
230
|
+
}
|
|
231
|
+
return thread;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeQuestion(input, threadId) {
|
|
235
|
+
const id = requiredText(input.id, 'id');
|
|
236
|
+
if (!ID.test(id)) throw new DecisionDialogueError('VALIDATION_ERROR', `invalid decision id: ${id}`);
|
|
237
|
+
return {
|
|
238
|
+
id,
|
|
239
|
+
thread_id: threadId,
|
|
240
|
+
phase: requiredText(input.phase, 'phase'),
|
|
241
|
+
question: requiredText(input.question, 'question'),
|
|
242
|
+
why_now: requiredText(input.why_now, 'why_now'),
|
|
243
|
+
recommendation: requiredText(input.recommendation, 'recommendation'),
|
|
244
|
+
options: normalizeOptions(input.options),
|
|
245
|
+
evidence_refs: stringList(input.evidence_refs, 'evidence_refs'),
|
|
246
|
+
dependency_ids: stringList(input.dependency_ids, 'dependency_ids'),
|
|
247
|
+
effects: plainObject(input.effects, 'effects', { allowEmpty: false }),
|
|
248
|
+
blocking: input.blocking !== false,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function insertQuestionInTx(db, thread, input, supersedesId = null) {
|
|
253
|
+
if (thread.current_decision) {
|
|
254
|
+
throw new DecisionDialogueError(
|
|
255
|
+
'DECISION_ALREADY_OPEN', `answer ${thread.current_decision.id} before opening another decision`,
|
|
256
|
+
{ current_decision: thread.current_decision },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const question = normalizeQuestion(input, thread.id);
|
|
260
|
+
if (db.prepare('SELECT 1 FROM decision_items WHERE id = ?').get(question.id)) {
|
|
261
|
+
throw new DecisionDialogueError('DUPLICATE_DECISION_ID', `decision ${question.id} already exists`);
|
|
262
|
+
}
|
|
263
|
+
for (const dependencyId of question.dependency_ids) {
|
|
264
|
+
const dependency = db.prepare(
|
|
265
|
+
'SELECT thread_id, status FROM decision_items WHERE id = ?',
|
|
266
|
+
).get(dependencyId);
|
|
267
|
+
if (!dependency || dependency.thread_id !== thread.id || dependency.status === 'open') {
|
|
268
|
+
throw new DecisionDialogueError(
|
|
269
|
+
'DECISION_DEPENDENCY_UNRESOLVED', `decision dependency is missing or unresolved: ${dependencyId}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const sequence = db.prepare(
|
|
274
|
+
'SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM decision_items WHERE thread_id = ?',
|
|
275
|
+
).get(thread.id).sequence;
|
|
276
|
+
const ts = nowIso();
|
|
277
|
+
db.prepare(
|
|
278
|
+
`INSERT INTO decision_items
|
|
279
|
+
(id, thread_id, sequence, phase, question, why_now, recommendation, options_json,
|
|
280
|
+
evidence_refs_json, dependency_ids_json, effects_json, blocking, status,
|
|
281
|
+
resolution_json, supersedes_id, created_at, updated_at)
|
|
282
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', '{}', ?, ?, ?)`,
|
|
283
|
+
).run(
|
|
284
|
+
question.id, thread.id, sequence, question.phase, question.question, question.why_now,
|
|
285
|
+
question.recommendation, JSON.stringify(question.options), JSON.stringify(question.evidence_refs),
|
|
286
|
+
JSON.stringify(question.dependency_ids), JSON.stringify(question.effects), question.blocking ? 1 : 0,
|
|
287
|
+
supersedesId, ts, ts,
|
|
288
|
+
);
|
|
289
|
+
db.prepare(
|
|
290
|
+
`UPDATE decision_threads SET status = 'active', checkpoint_json = CASE
|
|
291
|
+
WHEN status = 'confirmed' THEN json_set(checkpoint_json, '$.invalidated', json('true'), '$.invalidated_at', ?)
|
|
292
|
+
ELSE checkpoint_json END, confirmed_at = NULL, updated_at = ? WHERE id = ?`,
|
|
293
|
+
).run(ts, ts, thread.id);
|
|
294
|
+
ops.appendEventInTx(db, {
|
|
295
|
+
type: 'decision_opened', change_id: thread.change_id,
|
|
296
|
+
payload: { thread_id: thread.id, decision_id: question.id, phase: question.phase },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function openDecision(db, input = {}) {
|
|
301
|
+
const threadId = requiredText(input.thread_id, 'thread_id');
|
|
302
|
+
return ops.tx(db, () => {
|
|
303
|
+
const thread = requireMutableThread(db, threadId);
|
|
304
|
+
if (thread.status === 'checkpoint_ready') {
|
|
305
|
+
throw new DecisionDialogueError(
|
|
306
|
+
'DECISION_CHECKPOINT_PENDING', `confirm or revise checkpoint ${threadId} before opening another decision`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
insertQuestionInTx(db, thread, input);
|
|
310
|
+
return readDecisionThread(db, threadId);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function requireOpenItem(db, id) {
|
|
315
|
+
const row = db.prepare('SELECT * FROM decision_items WHERE id = ?').get(id);
|
|
316
|
+
if (!row) throw new DecisionDialogueError('DECISION_NOT_FOUND', `decision ${id} not found`);
|
|
317
|
+
if (row.status !== 'open') {
|
|
318
|
+
throw new DecisionDialogueError('DECISION_NOT_OPEN', `decision ${id} is ${row.status}`);
|
|
319
|
+
}
|
|
320
|
+
return { row, thread: requireMutableThread(db, row.thread_id) };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function resolveInTx(db, item, status, resolution) {
|
|
324
|
+
const ts = nowIso();
|
|
325
|
+
const checkpointHistory = appendCheckpointHistory(item.thread.checkpoint);
|
|
326
|
+
db.prepare(
|
|
327
|
+
`UPDATE decision_items SET status = ?, resolution_json = ?, resolved_at = ?, updated_at = ?
|
|
328
|
+
WHERE id = ?`,
|
|
329
|
+
).run(status, JSON.stringify(resolution), ts, ts, item.row.id);
|
|
330
|
+
db.prepare(
|
|
331
|
+
"UPDATE decision_threads SET status = 'active', checkpoint_json = ?, updated_at = ? WHERE id = ?",
|
|
332
|
+
).run(
|
|
333
|
+
JSON.stringify(checkpointHistory.length > 0 ? { history: checkpointHistory } : {}),
|
|
334
|
+
ts, item.row.thread_id,
|
|
335
|
+
);
|
|
336
|
+
ops.appendEventInTx(db, {
|
|
337
|
+
type: 'decision_resolved', change_id: item.thread.change_id,
|
|
338
|
+
payload: { thread_id: item.row.thread_id, decision_id: item.row.id, status },
|
|
339
|
+
});
|
|
340
|
+
return readDecisionThread(db, item.row.thread_id);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function resolveDecision(db, input = {}) {
|
|
344
|
+
return ops.tx(db, () => {
|
|
345
|
+
const item = requireOpenItem(db, requiredText(input.id, 'id'));
|
|
346
|
+
return resolveInTx(db, item, 'answered', {
|
|
347
|
+
authority: 'owner',
|
|
348
|
+
decision: requiredText(input.decision, 'decision'),
|
|
349
|
+
rationale: requiredText(input.rationale, 'rationale'),
|
|
350
|
+
decided_by: requiredText(input.decided_by, 'decided_by'),
|
|
351
|
+
resolved_at: nowIso(),
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function delegateDecision(db, input = {}) {
|
|
357
|
+
return ops.tx(db, () => {
|
|
358
|
+
const item = requireOpenItem(db, requiredText(input.id, 'id'));
|
|
359
|
+
const guardrails = stringList(input.guardrails, 'guardrails');
|
|
360
|
+
if (guardrails.length === 0) {
|
|
361
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', 'delegated decisions require at least one guardrail');
|
|
362
|
+
}
|
|
363
|
+
return resolveInTx(db, item, 'delegated', {
|
|
364
|
+
authority: 'delegated',
|
|
365
|
+
delegated_to: requiredText(input.delegated_to, 'delegated_to'),
|
|
366
|
+
decision: requiredText(input.decision, 'decision'),
|
|
367
|
+
rationale: requiredText(input.rationale, 'rationale'),
|
|
368
|
+
guardrails,
|
|
369
|
+
resolved_at: nowIso(),
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function deferDecision(db, input = {}) {
|
|
375
|
+
return ops.tx(db, () => {
|
|
376
|
+
const item = requireOpenItem(db, requiredText(input.id, 'id'));
|
|
377
|
+
return resolveInTx(db, item, 'deferred', {
|
|
378
|
+
authority: 'owner',
|
|
379
|
+
reason: requiredText(input.reason, 'reason'),
|
|
380
|
+
consequences: requiredText(input.consequences, 'consequences'),
|
|
381
|
+
revisit_condition: requiredText(input.revisit_condition, 'revisit_condition'),
|
|
382
|
+
resolved_at: nowIso(),
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function supersedeDecision(db, input = {}) {
|
|
388
|
+
return ops.tx(db, () => {
|
|
389
|
+
const id = requiredText(input.id, 'id');
|
|
390
|
+
const row = db.prepare('SELECT * FROM decision_items WHERE id = ?').get(id);
|
|
391
|
+
if (!row) throw new DecisionDialogueError('DECISION_NOT_FOUND', `decision ${id} not found`);
|
|
392
|
+
if (!['answered', 'delegated', 'deferred'].includes(row.status)) {
|
|
393
|
+
throw new DecisionDialogueError('DECISION_NOT_SUPERSEDEABLE', `decision ${id} is ${row.status}`);
|
|
394
|
+
}
|
|
395
|
+
const thread = readDecisionThread(db, row.thread_id);
|
|
396
|
+
if (thread.current_decision) {
|
|
397
|
+
throw new DecisionDialogueError(
|
|
398
|
+
'DECISION_ALREADY_OPEN', `answer ${thread.current_decision.id} before superseding another decision`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const replacement = plainObject(input.replacement, 'replacement', { allowEmpty: false });
|
|
402
|
+
const reason = requiredText(input.reason, 'reason');
|
|
403
|
+
const ts = nowIso();
|
|
404
|
+
const priorResolution = parseJson(row.resolution_json, 'decision_items.resolution_json', {});
|
|
405
|
+
db.prepare(
|
|
406
|
+
`UPDATE decision_items SET status = 'superseded', resolution_json = ?, updated_at = ? WHERE id = ?`,
|
|
407
|
+
).run(JSON.stringify({ ...priorResolution, superseded_by: replacement.id, supersede_reason: reason }), ts, id);
|
|
408
|
+
if (thread.status === 'confirmed') {
|
|
409
|
+
db.prepare(
|
|
410
|
+
`UPDATE decision_threads SET status = 'active', confirmed_at = NULL,
|
|
411
|
+
checkpoint_json = json_set(checkpoint_json, '$.invalidated', json('true'),
|
|
412
|
+
'$.invalidated_at', ?, '$.invalidation_reason', ?), updated_at = ? WHERE id = ?`,
|
|
413
|
+
).run(ts, reason, ts, thread.id);
|
|
414
|
+
}
|
|
415
|
+
insertQuestionInTx(db, readDecisionThread(db, thread.id), replacement, id);
|
|
416
|
+
return readDecisionThread(db, thread.id);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function decisionDigest(items) {
|
|
421
|
+
const authority = items.filter((item) => item.status !== 'superseded').map((item) => ({
|
|
422
|
+
id: item.id,
|
|
423
|
+
sequence: item.sequence,
|
|
424
|
+
status: item.status,
|
|
425
|
+
resolution: item.resolution,
|
|
426
|
+
effects: item.effects,
|
|
427
|
+
}));
|
|
428
|
+
return crypto.createHash('sha256').update(JSON.stringify(authority)).digest('hex');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function appendCheckpointHistory(checkpoint) {
|
|
432
|
+
const history = Array.isArray(checkpoint?.history) ? [...checkpoint.history] : [];
|
|
433
|
+
if (!checkpoint?.confirmed_at) return history;
|
|
434
|
+
const { history: _priorHistory, ...prior } = checkpoint;
|
|
435
|
+
history.push(prior);
|
|
436
|
+
return history;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function inspectCheckpoint(thread, { rootDir = process.cwd(), requireArtifact = false } = {}) {
|
|
440
|
+
const failures = [];
|
|
441
|
+
if (thread.status !== 'confirmed') {
|
|
442
|
+
failures.push({ code: 'DECISION_CHECKPOINT_NOT_CONFIRMED', thread_id: thread.id });
|
|
443
|
+
return { valid: false, failures };
|
|
444
|
+
}
|
|
445
|
+
if (thread.checkpoint.invalidated
|
|
446
|
+
|| thread.checkpoint.decision_digest !== decisionDigest(thread.items)) {
|
|
447
|
+
failures.push({ code: 'DECISION_CHECKPOINT_STALE', thread_id: thread.id });
|
|
448
|
+
}
|
|
449
|
+
const artifacts = Array.isArray(thread.checkpoint.artifacts) ? thread.checkpoint.artifacts : [];
|
|
450
|
+
if (requireArtifact && artifacts.length === 0) {
|
|
451
|
+
failures.push({ code: 'DECISION_CHECKPOINT_ARTIFACT_REQUIRED', thread_id: thread.id });
|
|
452
|
+
}
|
|
453
|
+
const root = path.resolve(rootDir);
|
|
454
|
+
for (const artifact of artifacts) {
|
|
455
|
+
const relative = typeof artifact?.path === 'string' ? artifact.path : '';
|
|
456
|
+
const file = relative ? path.resolve(root, relative) : null;
|
|
457
|
+
const inside = file && (file === root || file.startsWith(`${root}${path.sep}`));
|
|
458
|
+
const actual = inside && fs.existsSync(file) && fs.statSync(file).isFile()
|
|
459
|
+
? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null;
|
|
460
|
+
if (!inside || actual !== artifact.digest) {
|
|
461
|
+
failures.push({
|
|
462
|
+
code: 'DECISION_CHECKPOINT_ARTIFACT_STALE', thread_id: thread.id,
|
|
463
|
+
path: relative || null, expected: artifact?.digest || null, actual,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { valid: failures.length === 0, failures };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function assertConfirmedDecisionCheckpoint(db, id, options = {}) {
|
|
471
|
+
const thread = readDecisionThread(db, requiredText(id, 'id'));
|
|
472
|
+
if (!thread) {
|
|
473
|
+
throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
|
|
474
|
+
}
|
|
475
|
+
const health = inspectCheckpoint(thread, options);
|
|
476
|
+
if (!health.valid) {
|
|
477
|
+
throw new DecisionDialogueError(
|
|
478
|
+
'DECISION_CHECKPOINT_STALE', `decision checkpoint ${id} is not current`, health,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return thread;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function safeArtifact(rootDir, artifact) {
|
|
485
|
+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
|
486
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', 'checkpoint artifact is invalid');
|
|
487
|
+
}
|
|
488
|
+
const relative = requiredText(artifact.path, 'artifacts.path');
|
|
489
|
+
if (path.isAbsolute(relative)) {
|
|
490
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', 'checkpoint artifact path must be project-relative');
|
|
491
|
+
}
|
|
492
|
+
const root = path.resolve(rootDir);
|
|
493
|
+
const file = path.resolve(root, relative);
|
|
494
|
+
if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
|
|
495
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', `checkpoint artifact escapes project root: ${relative}`);
|
|
496
|
+
}
|
|
497
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
498
|
+
throw new DecisionDialogueError('DECISION_ARTIFACT_MISSING', `checkpoint artifact is missing: ${relative}`);
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
path: relative,
|
|
502
|
+
kind: requiredText(artifact.kind, 'artifacts.kind'),
|
|
503
|
+
digest: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function checkpointDecisionThread(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
508
|
+
const id = requiredText(input.id, 'id');
|
|
509
|
+
const action = input.action;
|
|
510
|
+
if (!['prepare', 'confirm'].includes(action)) {
|
|
511
|
+
throw new DecisionDialogueError('VALIDATION_ERROR', 'checkpoint action must be prepare or confirm');
|
|
512
|
+
}
|
|
513
|
+
return ops.tx(db, () => {
|
|
514
|
+
const thread = readDecisionThread(db, id);
|
|
515
|
+
if (!thread) {
|
|
516
|
+
throw new DecisionDialogueError('DECISION_THREAD_NOT_FOUND', `decision thread ${id} not found`);
|
|
517
|
+
}
|
|
518
|
+
if (thread.status === 'cancelled') {
|
|
519
|
+
throw new DecisionDialogueError('DECISION_THREAD_NOT_MUTABLE', `decision thread ${id} is cancelled`);
|
|
520
|
+
}
|
|
521
|
+
const open = thread.items.find((item) => item.status === 'open');
|
|
522
|
+
const deferredBlocking = thread.items.filter((item) => item.blocking && item.status === 'deferred');
|
|
523
|
+
if (open || deferredBlocking.length > 0 || thread.items.length === 0) {
|
|
524
|
+
const blockers = [
|
|
525
|
+
...(open ? [`DECISION_AWAITING_OWNER:${open.id}`] : []),
|
|
526
|
+
...deferredBlocking.map((item) => `DECISION_DEFERRED_BLOCKING:${item.id}`),
|
|
527
|
+
...(thread.items.length === 0 ? ['DECISION_THREAD_EMPTY'] : []),
|
|
528
|
+
];
|
|
529
|
+
throw new DecisionDialogueError(
|
|
530
|
+
'DECISION_ALIGNMENT_BLOCKING', `decision thread ${id} is not ready for checkpoint`, { blockers },
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const digest = decisionDigest(thread.items);
|
|
534
|
+
const ts = nowIso();
|
|
535
|
+
if (action === 'prepare') {
|
|
536
|
+
const summary = requiredText(input.summary, 'summary');
|
|
537
|
+
const history = appendCheckpointHistory(thread.checkpoint);
|
|
538
|
+
db.prepare(
|
|
539
|
+
`UPDATE decision_threads SET status = 'checkpoint_ready', summary_json = ?,
|
|
540
|
+
checkpoint_json = ?, updated_at = ? WHERE id = ?`,
|
|
541
|
+
).run(
|
|
542
|
+
JSON.stringify({ text: summary }),
|
|
543
|
+
JSON.stringify({ decision_digest: digest, summary, prepared_at: ts, history }), ts, id,
|
|
544
|
+
);
|
|
545
|
+
ops.appendEventInTx(db, {
|
|
546
|
+
type: 'decision_checkpoint_prepared', change_id: thread.change_id,
|
|
547
|
+
payload: { thread_id: id, decision_digest: digest },
|
|
548
|
+
});
|
|
549
|
+
return readDecisionThread(db, id);
|
|
550
|
+
}
|
|
551
|
+
if (thread.status !== 'checkpoint_ready' || thread.checkpoint.decision_digest !== digest) {
|
|
552
|
+
throw new DecisionDialogueError(
|
|
553
|
+
'DECISION_CHECKPOINT_STALE', `decision checkpoint ${id} is missing or stale`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
const artifacts = input.artifacts === undefined
|
|
557
|
+
? [] : input.artifacts.map((artifact) => safeArtifact(rootDir, artifact));
|
|
558
|
+
const noArtifactReason = input.no_artifact_reason == null
|
|
559
|
+
? null : requiredText(input.no_artifact_reason, 'no_artifact_reason');
|
|
560
|
+
if (artifacts.length === 0 && !noArtifactReason) {
|
|
561
|
+
throw new DecisionDialogueError(
|
|
562
|
+
'DECISION_ARTIFACT_REQUIRED', 'checkpoint confirmation requires artifacts or no_artifact_reason',
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (artifacts.length === 0 && (thread.change_id || thread.workflow_run_id)) {
|
|
566
|
+
throw new DecisionDialogueError(
|
|
567
|
+
'DECISION_ARTIFACT_REQUIRED',
|
|
568
|
+
'change- and workflow-bound checkpoints require at least one current artifact',
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
const checkpoint = {
|
|
572
|
+
...thread.checkpoint,
|
|
573
|
+
decision_digest: digest,
|
|
574
|
+
approved_by: requiredText(input.approved_by, 'approved_by'),
|
|
575
|
+
approval_note: requiredText(input.approval_note, 'approval_note'),
|
|
576
|
+
artifacts,
|
|
577
|
+
no_artifact_reason: noArtifactReason,
|
|
578
|
+
confirmed_at: ts,
|
|
579
|
+
};
|
|
580
|
+
db.prepare(
|
|
581
|
+
`UPDATE decision_threads SET status = 'confirmed', checkpoint_json = ?,
|
|
582
|
+
confirmed_at = ?, updated_at = ? WHERE id = ?`,
|
|
583
|
+
).run(JSON.stringify(checkpoint), ts, ts, id);
|
|
584
|
+
ops.appendEventInTx(db, {
|
|
585
|
+
type: 'decision_checkpoint_confirmed', change_id: thread.change_id,
|
|
586
|
+
payload: { thread_id: id, decision_digest: digest, artifact_count: artifacts.length },
|
|
587
|
+
});
|
|
588
|
+
return readDecisionThread(db, id);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function decisionGate(db, bindings = {}, { rootDir = process.cwd() } = {}) {
|
|
593
|
+
const clauses = [];
|
|
594
|
+
const params = {};
|
|
595
|
+
if (bindings.workflow_run_id) {
|
|
596
|
+
clauses.push('workflow_run_id = @workflow_run_id');
|
|
597
|
+
params.workflow_run_id = bindings.workflow_run_id;
|
|
598
|
+
}
|
|
599
|
+
if (bindings.change_id) {
|
|
600
|
+
clauses.push('change_id = @change_id');
|
|
601
|
+
params.change_id = bindings.change_id;
|
|
602
|
+
}
|
|
603
|
+
if (bindings.baseline_id) {
|
|
604
|
+
clauses.push('(baseline_id = @baseline_id AND change_id IS NULL AND workflow_run_id IS NULL)');
|
|
605
|
+
params.baseline_id = bindings.baseline_id;
|
|
606
|
+
}
|
|
607
|
+
if (clauses.length === 0) return { ready: true, blockers: [], thread: null, current_decision: null };
|
|
608
|
+
const rows = db.prepare(
|
|
609
|
+
`SELECT id FROM decision_threads
|
|
610
|
+
WHERE status IN ('active', 'checkpoint_ready', 'confirmed') AND (${clauses.join(' OR ')})
|
|
611
|
+
ORDER BY updated_at ASC, rowid ASC`,
|
|
612
|
+
).all(params);
|
|
613
|
+
const blockers = [];
|
|
614
|
+
let selected = null;
|
|
615
|
+
for (const row of rows) {
|
|
616
|
+
const thread = readDecisionThread(db, row.id);
|
|
617
|
+
if (thread.status === 'confirmed') {
|
|
618
|
+
const checkpointHealth = inspectCheckpoint(thread, { rootDir });
|
|
619
|
+
if (!checkpointHealth.valid) {
|
|
620
|
+
selected ||= thread;
|
|
621
|
+
blockers.push(`DECISION_CHECKPOINT_ARTIFACT_STALE:${thread.id}`);
|
|
622
|
+
}
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
selected ||= thread;
|
|
626
|
+
if (thread.current_decision) blockers.push(`DECISION_AWAITING_OWNER:${thread.current_decision.id}`);
|
|
627
|
+
for (const item of thread.items) {
|
|
628
|
+
if (item.blocking && item.status === 'deferred') {
|
|
629
|
+
blockers.push(`DECISION_DEFERRED_BLOCKING:${item.id}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
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
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
ready: blockers.length === 0,
|
|
639
|
+
blockers: [...new Set(blockers)],
|
|
640
|
+
thread: selected,
|
|
641
|
+
current_decision: selected?.current_decision || null,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function assertDecisionGate(db, bindings = {}, options = {}) {
|
|
646
|
+
const gate = decisionGate(db, bindings, options);
|
|
647
|
+
if (!gate.ready) {
|
|
648
|
+
throw new DecisionDialogueError(
|
|
649
|
+
'DECISION_ALIGNMENT_BLOCKING', `decision alignment blocks workflow advancement: ${gate.blockers.join(', ')}`,
|
|
650
|
+
gate,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
return gate;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function inspectDecisionHealth(db, { rootDir = process.cwd() } = {}) {
|
|
657
|
+
const threads = listDecisionThreads(db, { limit: 500 });
|
|
658
|
+
const active = threads.filter((thread) => thread.status === 'active');
|
|
659
|
+
const checkpointReady = threads.filter((thread) => thread.status === 'checkpoint_ready');
|
|
660
|
+
const awaiting = active.filter((thread) => thread.current_decision);
|
|
661
|
+
const deferredBlocking = threads.flatMap((thread) => thread.items.filter(
|
|
662
|
+
(item) => item.blocking && item.status === 'deferred',
|
|
663
|
+
));
|
|
664
|
+
const staleArtifacts = [];
|
|
665
|
+
const root = path.resolve(rootDir);
|
|
666
|
+
for (const thread of threads.filter((item) => item.status === 'confirmed')) {
|
|
667
|
+
if (thread.change_id) {
|
|
668
|
+
const change = db.prepare('SELECT status FROM changes WHERE id = ?').get(thread.change_id);
|
|
669
|
+
if (!change || !['active', 'blocked', 'ready'].includes(change.status)) continue;
|
|
670
|
+
}
|
|
671
|
+
for (const artifact of thread.checkpoint.artifacts || []) {
|
|
672
|
+
const file = path.resolve(root, artifact.path);
|
|
673
|
+
const inside = file === root || file.startsWith(`${root}${path.sep}`);
|
|
674
|
+
const digest = inside && fs.existsSync(file) && fs.statSync(file).isFile()
|
|
675
|
+
? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null;
|
|
676
|
+
if (!inside || digest !== artifact.digest) {
|
|
677
|
+
staleArtifacts.push({ thread_id: thread.id, path: artifact.path, expected: artifact.digest, actual: digest });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const currentThread = awaiting[0] || checkpointReady[0] || active[0] || null;
|
|
682
|
+
const currentThreadId = currentThread?.id || staleArtifacts[0]?.thread_id || null;
|
|
683
|
+
const status = staleArtifacts.length > 0
|
|
684
|
+
? 'fail'
|
|
685
|
+
: (active.length > 0 || checkpointReady.length > 0 || deferredBlocking.length > 0 ? 'warning' : 'pass');
|
|
686
|
+
return {
|
|
687
|
+
status,
|
|
688
|
+
active: active.length,
|
|
689
|
+
awaiting_owner: awaiting.length,
|
|
690
|
+
checkpoint_ready: checkpointReady.length,
|
|
691
|
+
deferred_blocking: deferredBlocking.length,
|
|
692
|
+
stale_artifacts: staleArtifacts,
|
|
693
|
+
current: currentThread?.current_decision || null,
|
|
694
|
+
current_thread_id: currentThreadId,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
module.exports = {
|
|
699
|
+
DecisionDialogueError,
|
|
700
|
+
startDecisionThread,
|
|
701
|
+
readDecisionThread,
|
|
702
|
+
listDecisionThreads,
|
|
703
|
+
openDecision,
|
|
704
|
+
resolveDecision,
|
|
705
|
+
delegateDecision,
|
|
706
|
+
deferDecision,
|
|
707
|
+
supersedeDecision,
|
|
708
|
+
checkpointDecisionThread,
|
|
709
|
+
decisionGate,
|
|
710
|
+
assertDecisionGate,
|
|
711
|
+
assertConfirmedDecisionCheckpoint,
|
|
712
|
+
inspectDecisionHealth,
|
|
713
|
+
};
|