ultra-builder-pro-cli 0.11.0 → 0.13.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 +21 -44
- package/.ultra-template/specs/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +41 -228
- package/.ultra-template/specs/product.md +44 -200
- package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
- package/.ultra-template/tasks/tasks.json +6 -0
- package/CHANGELOG.md +94 -1
- package/README.md +37 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +5 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +10 -2
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/opencode.test.cjs +36 -1
- package/commands/ultra-init.md +3 -3
- package/docs/AGENT-CONTEXT.md +17 -8
- package/docs/ARCHITECTURE.md +51 -8
- package/docs/PLAN.zh-CN.md +9 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
- package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
- package/hooks/README.md +4 -3
- package/hooks/adapters/codex.py +1 -1
- package/hooks/adapters/kimi.py +1 -1
- package/hooks/context_spine.py +153 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +781 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
- package/mcp-server/lib/change-workflow.cjs +256 -85
- package/mcp-server/lib/change-workflow.test.cjs +436 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +20 -0
- package/mcp-server/lib/doctor.cjs +28 -1
- package/mcp-server/lib/doctor.test.cjs +63 -1
- package/mcp-server/lib/init-project.cjs +334 -16
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +202 -21
- package/mcp-server/lib/state-db.test.cjs +140 -3
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +62 -6
- package/mcp-server/tests/init-project.test.cjs +216 -9
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +478 -3
- package/mcp-server/tests/state-db.test.cjs +23 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +32 -13
- package/skills/ultra-deliver/SKILL.md +19 -5
- package/skills/ultra-dev/SKILL.md +8 -2
- package/skills/ultra-doctor/SKILL.md +30 -9
- package/skills/ultra-init/SKILL.md +107 -66
- package/skills/ultra-init/references/brownfield-adoption.md +93 -0
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-research/SKILL.md +17 -5
- package/skills/ultra-status/SKILL.md +15 -6
- package/spec/cli-protocol.md +15 -1
- package/spec/mcp-tools.yaml +316 -11
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +44 -2
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +29 -2
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +11 -2
- package/ultra-tools/status-command.test.cjs +49 -0
- package/ultra-tools/system-command.test.cjs +256 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const ops = require('./state-ops.cjs');
|
|
9
|
+
const providerRefs = require('./provider-refs.cjs');
|
|
10
|
+
|
|
11
|
+
const BASELINE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
12
|
+
const MODES = new Set(['greenfield', 'brownfield']);
|
|
13
|
+
const SPEC_KINDS = new Set(['discovery', 'product', 'architecture', 'quality', 'delivery', 'other']);
|
|
14
|
+
const EVIDENCE_KINDS = new Set(['source', 'docs', 'runtime', 'test', 'deploy', 'external']);
|
|
15
|
+
const VERIFICATION_STATUSES = new Set(['pass', 'known_red', 'not_run']);
|
|
16
|
+
const GAP_CATEGORIES = new Set([
|
|
17
|
+
'baseline_blocker', 'documentation_drift', 'known_defect',
|
|
18
|
+
'technical_debt', 'unknown', 'future_change',
|
|
19
|
+
]);
|
|
20
|
+
const GAP_STATUSES = new Set(['open', 'accepted', 'resolved', 'deferred']);
|
|
21
|
+
|
|
22
|
+
class BaselineWorkflowError extends Error {
|
|
23
|
+
constructor(code, message, details) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'BaselineWorkflowError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
if (details !== undefined) this.details = details;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nowIso() {
|
|
32
|
+
return new Date().toISOString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gitHead(rootDir) {
|
|
36
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
37
|
+
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
|
+
});
|
|
39
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gitBranch(rootDir) {
|
|
43
|
+
const result = spawnSync('git', ['branch', '--show-current'], {
|
|
44
|
+
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
45
|
+
});
|
|
46
|
+
return result.status === 0 ? (result.stdout.trim() || null) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function gitWorktreeSnapshot(rootDir, scope = ['.']) {
|
|
50
|
+
const head = gitHead(rootDir);
|
|
51
|
+
if (!head) {
|
|
52
|
+
return {
|
|
53
|
+
head: null, branch: null, state: 'unavailable', digest: null, files: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const selectedScope = Array.isArray(scope) && scope.length > 0 ? scope : ['.'];
|
|
57
|
+
const pathspecs = selectedScope.map((item) => (
|
|
58
|
+
item === '.' ? '.' : `:(literal)${item}`
|
|
59
|
+
));
|
|
60
|
+
const args = ['--', ...pathspecs, ':(exclude).ultra', ':(exclude).ultra/**'];
|
|
61
|
+
const status = spawnSync(
|
|
62
|
+
'git', ['status', '--porcelain=v1', '-z', '--untracked-files=all', ...args],
|
|
63
|
+
{ cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
|
|
64
|
+
);
|
|
65
|
+
const diff = spawnSync(
|
|
66
|
+
'git', ['diff', '--binary', '--no-ext-diff', 'HEAD', ...args],
|
|
67
|
+
{ cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
|
|
68
|
+
);
|
|
69
|
+
const untracked = spawnSync(
|
|
70
|
+
'git', ['ls-files', '--others', '--exclude-standard', '-z', ...args],
|
|
71
|
+
{ cwd: rootDir, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 },
|
|
72
|
+
);
|
|
73
|
+
if (status.status !== 0 || diff.status !== 0 || untracked.status !== 0) {
|
|
74
|
+
return {
|
|
75
|
+
head, branch: gitBranch(rootDir), state: 'unavailable', digest: null, files: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const statusText = status.stdout.toString('utf8');
|
|
79
|
+
const files = statusText.split('\0').filter(Boolean);
|
|
80
|
+
const digest = crypto.createHash('sha256');
|
|
81
|
+
digest.update(head).update('\0').update(gitBranch(rootDir) || '').update('\0');
|
|
82
|
+
digest.update(status.stdout).update(diff.stdout);
|
|
83
|
+
for (const relative of untracked.stdout.toString('utf8').split('\0').filter(Boolean).sort()) {
|
|
84
|
+
digest.update(relative).update('\0');
|
|
85
|
+
const file = path.resolve(rootDir, relative);
|
|
86
|
+
if (file.startsWith(`${path.resolve(rootDir)}${path.sep}`)
|
|
87
|
+
&& fs.existsSync(file) && fs.statSync(file).isFile()) {
|
|
88
|
+
digest.update(fs.readFileSync(file));
|
|
89
|
+
}
|
|
90
|
+
digest.update('\0');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
head,
|
|
94
|
+
branch: gitBranch(rootDir),
|
|
95
|
+
state: files.length > 0 ? 'dirty' : 'clean',
|
|
96
|
+
digest: digest.digest('hex'),
|
|
97
|
+
files,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function gitWorktreeChanges(rootDir, scope = ['.']) {
|
|
102
|
+
const snapshot = gitWorktreeSnapshot(rootDir, scope);
|
|
103
|
+
return snapshot.state === 'unavailable' ? null : snapshot.files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseJson(value, field, fallback) {
|
|
107
|
+
if (value == null || value === '') return fallback;
|
|
108
|
+
try { return JSON.parse(value); }
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new BaselineWorkflowError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rowToBaseline(row) {
|
|
115
|
+
if (!row) return null;
|
|
116
|
+
const baseline = {
|
|
117
|
+
...row,
|
|
118
|
+
scope: parseJson(row.scope_json, 'scope_json', []),
|
|
119
|
+
spec_refs: parseJson(row.spec_refs_json, 'spec_refs_json', []),
|
|
120
|
+
evidence: parseJson(row.evidence_json, 'evidence_json', []),
|
|
121
|
+
verification: parseJson(row.verification_json, 'verification_json', []),
|
|
122
|
+
unknowns: parseJson(row.unknowns_json, 'unknowns_json', []),
|
|
123
|
+
gaps: parseJson(row.gaps_json, 'gaps_json', []),
|
|
124
|
+
classification: parseJson(row.classification_json, 'classification_json', {}),
|
|
125
|
+
worktree_files: parseJson(row.worktree_files_json, 'worktree_files_json', []),
|
|
126
|
+
worktree_accepted: Boolean(row.worktree_accepted),
|
|
127
|
+
provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json', {}),
|
|
128
|
+
};
|
|
129
|
+
for (const key of [
|
|
130
|
+
'scope_json', 'spec_refs_json', 'evidence_json', 'verification_json', 'unknowns_json',
|
|
131
|
+
'gaps_json', 'classification_json', 'worktree_files_json', 'provider_refs_json',
|
|
132
|
+
]) delete baseline[key];
|
|
133
|
+
return baseline;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function hasTable(db, table) {
|
|
137
|
+
return Boolean(db.prepare(
|
|
138
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
139
|
+
).get(table));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readBaseline(db, id) {
|
|
143
|
+
if (!hasTable(db, 'baselines')) return null;
|
|
144
|
+
const row = id
|
|
145
|
+
? db.prepare('SELECT * FROM baselines WHERE id = ?').get(id)
|
|
146
|
+
: db.prepare(
|
|
147
|
+
"SELECT * FROM baselines WHERE status != 'superseded' ORDER BY updated_at DESC, rowid DESC LIMIT 1",
|
|
148
|
+
).get();
|
|
149
|
+
return rowToBaseline(row);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function nonEmpty(value, field) {
|
|
153
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
154
|
+
if (!text) throw new BaselineWorkflowError('VALIDATION_ERROR', `${field} must be a non-empty string`);
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeReplacementAuthorization(value) {
|
|
159
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
160
|
+
throw new BaselineWorkflowError(
|
|
161
|
+
'BASELINE_REPLACEMENT_AUTHORIZATION_REQUIRED',
|
|
162
|
+
'replacing a ready baseline requires approved_by and reason',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
let approvedBy;
|
|
166
|
+
let reason;
|
|
167
|
+
try {
|
|
168
|
+
approvedBy = nonEmpty(value.approved_by, 'replacement_authorization.approved_by');
|
|
169
|
+
reason = nonEmpty(value.reason, 'replacement_authorization.reason');
|
|
170
|
+
} catch {
|
|
171
|
+
throw new BaselineWorkflowError(
|
|
172
|
+
'BASELINE_REPLACEMENT_AUTHORIZATION_REQUIRED',
|
|
173
|
+
'replacing a ready baseline requires approved_by and reason',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (reason.length < 3) {
|
|
177
|
+
throw new BaselineWorkflowError(
|
|
178
|
+
'BASELINE_REPLACEMENT_AUTHORIZATION_REQUIRED',
|
|
179
|
+
'replacement_authorization.reason must explain the owner-approved replacement',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { approved_by: approvedBy, reason };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function safeRelativePath(rootDir, candidate, field = 'path') {
|
|
186
|
+
const value = nonEmpty(candidate, field);
|
|
187
|
+
if (path.isAbsolute(value)) {
|
|
188
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `${field} must be project-relative: ${value}`);
|
|
189
|
+
}
|
|
190
|
+
const root = path.resolve(rootDir);
|
|
191
|
+
const resolved = path.resolve(root, value);
|
|
192
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
193
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `${field} escapes project root: ${value}`);
|
|
194
|
+
}
|
|
195
|
+
return resolved;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeScope(value, rootDir) {
|
|
199
|
+
const scope = value === undefined ? ['.'] : value;
|
|
200
|
+
if (!Array.isArray(scope) || scope.length === 0) {
|
|
201
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', 'scope must be a non-empty array');
|
|
202
|
+
}
|
|
203
|
+
return [...new Set(scope.map((item, index) => {
|
|
204
|
+
const resolved = safeRelativePath(rootDir, item, `scope[${index}]`);
|
|
205
|
+
if (!fs.existsSync(resolved)) {
|
|
206
|
+
throw new BaselineWorkflowError(
|
|
207
|
+
'BASELINE_SCOPE_MISSING', `baseline scope does not exist: ${String(item).trim()}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return String(item).trim();
|
|
211
|
+
}))];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function storedScopeBlockers(baseline, rootDir) {
|
|
215
|
+
const blockers = [];
|
|
216
|
+
for (const item of baseline.scope) {
|
|
217
|
+
let target;
|
|
218
|
+
try { target = safeRelativePath(rootDir, item, 'scope path'); }
|
|
219
|
+
catch { blockers.push(`BASELINE_SCOPE_INVALID:${item}`); continue; }
|
|
220
|
+
if (!fs.existsSync(target)) blockers.push(`BASELINE_SCOPE_MISSING:${item}`);
|
|
221
|
+
}
|
|
222
|
+
return blockers;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function digestFile(file) {
|
|
226
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function workspaceRevision({ scope, specs, evidence }, rootDir) {
|
|
230
|
+
const evidenceRefs = evidence.map((item) => {
|
|
231
|
+
try {
|
|
232
|
+
const file = safeRelativePath(rootDir, item.ref, 'evidence ref');
|
|
233
|
+
return fs.existsSync(file) && fs.statSync(file).isFile()
|
|
234
|
+
? { kind: item.kind, ref: item.ref, digest: digestFile(file) }
|
|
235
|
+
: { kind: item.kind, ref: item.ref };
|
|
236
|
+
} catch {
|
|
237
|
+
return { kind: item.kind, ref: item.ref };
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const payload = JSON.stringify({ scope, specs, evidence: evidenceRefs });
|
|
241
|
+
return `workspace:${crypto.createHash('sha256').update(payload).digest('hex')}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normalizeSpecRefs(value, rootDir) {
|
|
245
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'spec_refs must be an array');
|
|
246
|
+
return value.map((item, index) => {
|
|
247
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) || !SPEC_KINDS.has(item.kind)) {
|
|
248
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid spec_refs[${index}]`);
|
|
249
|
+
}
|
|
250
|
+
const specPath = nonEmpty(item.path, `spec_refs[${index}].path`);
|
|
251
|
+
const file = safeRelativePath(rootDir, specPath, `spec_refs[${index}].path`);
|
|
252
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
253
|
+
throw new BaselineWorkflowError('BASELINE_SPEC_MISSING', `baseline specification missing: ${specPath}`);
|
|
254
|
+
}
|
|
255
|
+
return { kind: item.kind, path: specPath, digest: digestFile(file) };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function normalizeEvidence(value, rootDir) {
|
|
260
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'evidence must be an array');
|
|
261
|
+
return value.map((item, index) => {
|
|
262
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) || !EVIDENCE_KINDS.has(item.kind)) {
|
|
263
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid evidence[${index}]`);
|
|
264
|
+
}
|
|
265
|
+
const normalized = {
|
|
266
|
+
kind: item.kind,
|
|
267
|
+
ref: nonEmpty(item.ref, `evidence[${index}].ref`),
|
|
268
|
+
summary: nonEmpty(item.summary, `evidence[${index}].summary`),
|
|
269
|
+
};
|
|
270
|
+
if (item.kind === 'source') {
|
|
271
|
+
const file = safeRelativePath(rootDir, normalized.ref, `evidence[${index}].ref`);
|
|
272
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
273
|
+
throw new BaselineWorkflowError(
|
|
274
|
+
'BASELINE_EVIDENCE_MISSING', `baseline source evidence missing: ${normalized.ref}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
normalized.digest = digestFile(file);
|
|
278
|
+
}
|
|
279
|
+
return normalized;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeVerification(value) {
|
|
284
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'verification must be an array');
|
|
285
|
+
return value.map((item, index) => {
|
|
286
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) || !VERIFICATION_STATUSES.has(item.status)) {
|
|
287
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid verification[${index}]`);
|
|
288
|
+
}
|
|
289
|
+
const normalized = {
|
|
290
|
+
name: nonEmpty(item.name, `verification[${index}].name`),
|
|
291
|
+
command: nonEmpty(item.command, `verification[${index}].command`),
|
|
292
|
+
status: item.status,
|
|
293
|
+
evidence: nonEmpty(item.evidence, `verification[${index}].evidence`),
|
|
294
|
+
};
|
|
295
|
+
if (item.rationale !== undefined) normalized.rationale = nonEmpty(item.rationale, `verification[${index}].rationale`);
|
|
296
|
+
return normalized;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function normalizeUnknowns(value) {
|
|
301
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'unknowns must be an array');
|
|
302
|
+
return value.map((item, index) => {
|
|
303
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
304
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid unknowns[${index}]`);
|
|
305
|
+
}
|
|
306
|
+
const normalized = {
|
|
307
|
+
summary: nonEmpty(item.summary, `unknowns[${index}].summary`),
|
|
308
|
+
blocking: item.blocking === true,
|
|
309
|
+
};
|
|
310
|
+
if (item.owner !== undefined) normalized.owner = nonEmpty(item.owner, `unknowns[${index}].owner`);
|
|
311
|
+
return normalized;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function normalizeGaps(value) {
|
|
316
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'gaps must be an array');
|
|
317
|
+
const ids = new Set();
|
|
318
|
+
return value.map((item, index) => {
|
|
319
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
320
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid gaps[${index}]`);
|
|
321
|
+
}
|
|
322
|
+
const id = nonEmpty(item.id, `gaps[${index}].id`);
|
|
323
|
+
if (!BASELINE_ID.test(id) || ids.has(id)) {
|
|
324
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid or duplicate gap id: ${id}`);
|
|
325
|
+
}
|
|
326
|
+
ids.add(id);
|
|
327
|
+
if (!GAP_CATEGORIES.has(item.category)) {
|
|
328
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid gap category: ${item.category}`);
|
|
329
|
+
}
|
|
330
|
+
const status = item.status || 'open';
|
|
331
|
+
if (!GAP_STATUSES.has(status)) {
|
|
332
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid gap status: ${status}`);
|
|
333
|
+
}
|
|
334
|
+
const evidenceRefs = item.evidence_refs === undefined ? [] : item.evidence_refs;
|
|
335
|
+
if (!Array.isArray(evidenceRefs) || evidenceRefs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
|
|
336
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `gaps[${index}].evidence_refs must be strings`);
|
|
337
|
+
}
|
|
338
|
+
const normalized = {
|
|
339
|
+
id,
|
|
340
|
+
category: item.category,
|
|
341
|
+
status,
|
|
342
|
+
blocking: item.category === 'baseline_blocker' ? true : item.blocking === true,
|
|
343
|
+
summary: nonEmpty(item.summary, `gaps[${index}].summary`),
|
|
344
|
+
evidence_refs: [...new Set(evidenceRefs.map((ref) => ref.trim()))],
|
|
345
|
+
owner: item.owner == null ? null : nonEmpty(item.owner, `gaps[${index}].owner`),
|
|
346
|
+
};
|
|
347
|
+
if (item.resolution != null) {
|
|
348
|
+
normalized.resolution = nonEmpty(item.resolution, `gaps[${index}].resolution`);
|
|
349
|
+
}
|
|
350
|
+
return normalized;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function normalizeClassification(value) {
|
|
355
|
+
if (value === undefined || value === null) return {};
|
|
356
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
357
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', 'classification must be an object');
|
|
358
|
+
}
|
|
359
|
+
const serialized = JSON.stringify(value);
|
|
360
|
+
if (Buffer.byteLength(serialized) > 32 * 1024) {
|
|
361
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', 'classification exceeds 32 KiB');
|
|
362
|
+
}
|
|
363
|
+
return JSON.parse(serialized);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function summarizeGaps(gaps = []) {
|
|
367
|
+
const summary = { total: gaps.length, open: 0, blocking: 0, by_category: {} };
|
|
368
|
+
for (const gap of gaps) {
|
|
369
|
+
summary.by_category[gap.category] = (summary.by_category[gap.category] || 0) + 1;
|
|
370
|
+
if (gap.status === 'open') summary.open += 1;
|
|
371
|
+
if (gap.status === 'open' && gap.blocking) summary.blocking += 1;
|
|
372
|
+
}
|
|
373
|
+
return summary;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function startBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
|
|
377
|
+
const id = input.id || 'project-baseline';
|
|
378
|
+
if (!BASELINE_ID.test(id)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'baseline id is invalid');
|
|
379
|
+
const projectName = nonEmpty(input.project_name, 'project_name');
|
|
380
|
+
if (!MODES.has(input.mode)) throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid baseline mode: ${input.mode}`);
|
|
381
|
+
if (readBaseline(db, id)) throw new BaselineWorkflowError('DUPLICATE_BASELINE_ID', `baseline ${id} exists`);
|
|
382
|
+
const current = readBaseline(db);
|
|
383
|
+
if (input.replace_migrated === true
|
|
384
|
+
&& (!current || current.mode !== 'migrated' || input.mode !== 'brownfield')) {
|
|
385
|
+
throw new BaselineWorkflowError(
|
|
386
|
+
'BASELINE_REPLACEMENT_INVALID',
|
|
387
|
+
'replace_migrated requires a current migrated baseline and brownfield mode',
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const replacingMigrated = Boolean(
|
|
391
|
+
current && current.mode === 'migrated' && current.status !== 'superseded'
|
|
392
|
+
&& input.replace_migrated === true,
|
|
393
|
+
);
|
|
394
|
+
let replacementAuthorization = null;
|
|
395
|
+
if (input.replace_ready === true) {
|
|
396
|
+
if (!current || current.status !== 'ready' || replacingMigrated) {
|
|
397
|
+
throw new BaselineWorkflowError(
|
|
398
|
+
'BASELINE_REPLACEMENT_INVALID',
|
|
399
|
+
'replace_ready requires a current ready baseline',
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
replacementAuthorization = normalizeReplacementAuthorization(input.replacement_authorization);
|
|
403
|
+
} else if (input.replacement_authorization !== undefined) {
|
|
404
|
+
throw new BaselineWorkflowError(
|
|
405
|
+
'BASELINE_REPLACEMENT_INVALID',
|
|
406
|
+
'replacement_authorization is valid only with replace_ready=true',
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (current && current.status !== 'superseded') {
|
|
410
|
+
if (replacingMigrated) {
|
|
411
|
+
// Compatibility state is never an accepted product baseline. Explicit
|
|
412
|
+
// replacement preserves its audit row while starting real adoption.
|
|
413
|
+
} else if (current.status === 'ready' && input.replace_ready !== true) {
|
|
414
|
+
throw new BaselineWorkflowError('BASELINE_EXISTS', `ready baseline ${current.id} requires replace_ready=true`);
|
|
415
|
+
} else if (current.status !== 'ready') {
|
|
416
|
+
throw new BaselineWorkflowError('BASELINE_IN_PROGRESS', `baseline ${current.id} is ${current.status}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const scope = normalizeScope(input.scope, rootDir);
|
|
420
|
+
const providers = providerRefs.normalizeProviderRefs(input.provider_refs, BaselineWorkflowError);
|
|
421
|
+
const classification = normalizeClassification(input.classification);
|
|
422
|
+
const snapshot = gitWorktreeSnapshot(rootDir, scope);
|
|
423
|
+
if (snapshot.head && input.repository_revision && input.repository_revision !== snapshot.head) {
|
|
424
|
+
throw new BaselineWorkflowError(
|
|
425
|
+
'BASELINE_REVISION_MISMATCH', 'repository_revision does not match current Git HEAD',
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
const status = input.mode === 'brownfield' ? 'adopting' : 'draft';
|
|
429
|
+
const ts = nowIso();
|
|
430
|
+
return ops.tx(db, () => {
|
|
431
|
+
if (current && (current.status === 'ready' || replacingMigrated)) {
|
|
432
|
+
db.prepare("UPDATE baselines SET status = 'superseded', updated_at = ? WHERE id = ?")
|
|
433
|
+
.run(ts, current.id);
|
|
434
|
+
}
|
|
435
|
+
db.prepare(
|
|
436
|
+
`INSERT INTO baselines
|
|
437
|
+
(id, project_name, project_type, stack, mode, status, repository_root, scope_json,
|
|
438
|
+
repository_revision, repository_branch, worktree_state, worktree_digest,
|
|
439
|
+
worktree_files_json, classification_json, provider_refs_json, started_at, updated_at)
|
|
440
|
+
VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
441
|
+
).run(
|
|
442
|
+
id, projectName, input.project_type || null, input.stack || null, input.mode, status,
|
|
443
|
+
JSON.stringify(scope), snapshot.head || input.repository_revision || null, snapshot.branch,
|
|
444
|
+
snapshot.state, snapshot.digest, JSON.stringify(snapshot.files), JSON.stringify(classification),
|
|
445
|
+
JSON.stringify(providers), ts, ts,
|
|
446
|
+
);
|
|
447
|
+
if (emitEvent) {
|
|
448
|
+
const payload = {
|
|
449
|
+
baseline_id: id,
|
|
450
|
+
mode: input.mode,
|
|
451
|
+
replaced_baseline_id: current?.id || null,
|
|
452
|
+
};
|
|
453
|
+
if (replacementAuthorization) {
|
|
454
|
+
payload.replacement_authorization = { ...replacementAuthorization, recorded_at: ts };
|
|
455
|
+
}
|
|
456
|
+
ops.appendEventInTx(db, {
|
|
457
|
+
type: 'baseline_started',
|
|
458
|
+
payload,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return readBaseline(db, id);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function recordBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
|
|
466
|
+
const current = readBaseline(db, input.id);
|
|
467
|
+
if (!current) throw new BaselineWorkflowError('BASELINE_NOT_FOUND', `baseline ${input.id} not found`);
|
|
468
|
+
if (['ready', 'superseded'].includes(current.status)) {
|
|
469
|
+
throw new BaselineWorkflowError('BASELINE_NOT_MUTABLE', `baseline ${current.id} is ${current.status}`);
|
|
470
|
+
}
|
|
471
|
+
const scope = normalizeScope(input.scope === undefined ? current.scope : input.scope, rootDir);
|
|
472
|
+
const specs = input.spec_refs === undefined ? current.spec_refs : normalizeSpecRefs(input.spec_refs, rootDir);
|
|
473
|
+
const evidence = input.evidence === undefined
|
|
474
|
+
? current.evidence : normalizeEvidence(input.evidence, rootDir);
|
|
475
|
+
const verification = input.verification === undefined
|
|
476
|
+
? current.verification : normalizeVerification(input.verification);
|
|
477
|
+
const unknowns = input.unknowns === undefined ? current.unknowns : normalizeUnknowns(input.unknowns);
|
|
478
|
+
const gaps = input.gaps === undefined ? current.gaps : normalizeGaps(input.gaps);
|
|
479
|
+
const classification = input.classification === undefined
|
|
480
|
+
? current.classification : normalizeClassification(input.classification);
|
|
481
|
+
const providers = input.provider_refs === undefined
|
|
482
|
+
? current.provider_refs
|
|
483
|
+
: providerRefs.normalizeProviderRefs(input.provider_refs, BaselineWorkflowError);
|
|
484
|
+
const snapshot = gitWorktreeSnapshot(rootDir, scope);
|
|
485
|
+
if (snapshot.head && input.repository_revision && input.repository_revision !== snapshot.head) {
|
|
486
|
+
throw new BaselineWorkflowError(
|
|
487
|
+
'BASELINE_REVISION_MISMATCH', 'repository_revision does not match current Git HEAD',
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const revision = snapshot.head || (input.repository_revision === undefined
|
|
491
|
+
? (current.repository_revision || workspaceRevision({ scope, specs, evidence }, rootDir))
|
|
492
|
+
: nonEmpty(input.repository_revision, 'repository_revision'));
|
|
493
|
+
const status = current.mode === 'brownfield' ? 'adopting' : 'draft';
|
|
494
|
+
const ts = nowIso();
|
|
495
|
+
return ops.tx(db, () => {
|
|
496
|
+
db.prepare(
|
|
497
|
+
`UPDATE baselines SET status = ?, scope_json = ?, repository_revision = ?,
|
|
498
|
+
repository_branch = ?, worktree_state = ?, worktree_digest = ?, worktree_files_json = ?,
|
|
499
|
+
worktree_accepted = 0, spec_refs_json = ?, evidence_json = ?, verification_json = ?,
|
|
500
|
+
unknowns_json = ?, gaps_json = ?, classification_json = ?, provider_refs_json = ?,
|
|
501
|
+
approved_by = NULL, approval_note = NULL, converged_at = NULL, updated_at = ? WHERE id = ?`,
|
|
502
|
+
).run(
|
|
503
|
+
status, JSON.stringify(scope), revision, snapshot.branch, snapshot.state, snapshot.digest,
|
|
504
|
+
JSON.stringify(snapshot.files), JSON.stringify(specs), JSON.stringify(evidence),
|
|
505
|
+
JSON.stringify(verification), JSON.stringify(unknowns), JSON.stringify(gaps),
|
|
506
|
+
JSON.stringify(classification), JSON.stringify(providers), ts, current.id,
|
|
507
|
+
);
|
|
508
|
+
if (emitEvent) {
|
|
509
|
+
ops.appendEventInTx(db, {
|
|
510
|
+
type: 'baseline_recorded',
|
|
511
|
+
payload: {
|
|
512
|
+
baseline_id: current.id, spec_count: specs.length,
|
|
513
|
+
gap_count: gaps.length, worktree_state: snapshot.state,
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return readBaseline(db, current.id);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function storedSpecBlockers(baseline, rootDir) {
|
|
522
|
+
const blockers = [];
|
|
523
|
+
for (const spec of baseline.spec_refs) {
|
|
524
|
+
let file;
|
|
525
|
+
try { file = safeRelativePath(rootDir, spec.path, 'spec path'); }
|
|
526
|
+
catch { blockers.push(`BASELINE_SPEC_INVALID:${spec.path}`); continue; }
|
|
527
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
528
|
+
blockers.push(`BASELINE_SPEC_MISSING:${spec.path}`);
|
|
529
|
+
} else if (!spec.digest || digestFile(file) !== spec.digest) {
|
|
530
|
+
blockers.push(`BASELINE_SPEC_STALE:${spec.path}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return blockers;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function storedEvidenceBlockers(baseline, rootDir) {
|
|
537
|
+
const blockers = [];
|
|
538
|
+
for (const item of baseline.evidence) {
|
|
539
|
+
if (item.kind !== 'source' && !item.digest) continue;
|
|
540
|
+
let file;
|
|
541
|
+
try { file = safeRelativePath(rootDir, item.ref, 'evidence ref'); }
|
|
542
|
+
catch { blockers.push(`BASELINE_EVIDENCE_INVALID:${item.ref}`); continue; }
|
|
543
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
544
|
+
blockers.push(`BASELINE_EVIDENCE_MISSING:${item.ref}`);
|
|
545
|
+
} else if (!item.digest) {
|
|
546
|
+
blockers.push(`BASELINE_EVIDENCE_UNVERIFIED:${item.ref}`);
|
|
547
|
+
} else if (digestFile(file) !== item.digest) {
|
|
548
|
+
blockers.push(`BASELINE_EVIDENCE_STALE:${item.ref}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return blockers;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function convergenceBlockers(baseline, input, rootDir) {
|
|
555
|
+
const blockers = new Set();
|
|
556
|
+
const expected = typeof input.expected_revision === 'string' ? input.expected_revision.trim() : '';
|
|
557
|
+
if (!expected) blockers.add('BASELINE_REVISION_REQUIRED');
|
|
558
|
+
else if (baseline.repository_revision !== expected) blockers.add('BASELINE_REVISION_MISMATCH');
|
|
559
|
+
const snapshot = gitWorktreeSnapshot(rootDir, baseline.scope);
|
|
560
|
+
if (snapshot.head && expected && snapshot.head !== expected) blockers.add('BASELINE_HEAD_STALE');
|
|
561
|
+
if (baseline.repository_branch && snapshot.branch
|
|
562
|
+
&& baseline.repository_branch !== snapshot.branch) blockers.add('BASELINE_BRANCH_STALE');
|
|
563
|
+
if (!Array.isArray(baseline.scope) || baseline.scope.length === 0) blockers.add('BASELINE_SCOPE_MISSING');
|
|
564
|
+
for (const kind of ['product', 'architecture']) {
|
|
565
|
+
if (!baseline.spec_refs.some((ref) => ref.kind === kind)) blockers.add(`BASELINE_SPEC_MISSING:${kind}`);
|
|
566
|
+
}
|
|
567
|
+
for (const blocker of storedSpecBlockers(baseline, rootDir)) blockers.add(blocker);
|
|
568
|
+
for (const blocker of storedEvidenceBlockers(baseline, rootDir)) blockers.add(blocker);
|
|
569
|
+
for (const blocker of storedScopeBlockers(baseline, rootDir)) blockers.add(blocker);
|
|
570
|
+
if (baseline.worktree_digest && snapshot.digest && baseline.worktree_digest !== snapshot.digest) {
|
|
571
|
+
blockers.add('BASELINE_WORKTREE_STALE');
|
|
572
|
+
if (snapshot.state === 'dirty') blockers.add('BASELINE_WORKTREE_DIRTY');
|
|
573
|
+
}
|
|
574
|
+
if (baseline.worktree_state === 'dirty' && input.accept_dirty_worktree !== true) {
|
|
575
|
+
blockers.add('BASELINE_DIRTY_WORKTREE_NOT_ACCEPTED');
|
|
576
|
+
}
|
|
577
|
+
if (baseline.evidence.length === 0) blockers.add('BASELINE_EVIDENCE_MISSING');
|
|
578
|
+
if (baseline.mode === 'brownfield' && !baseline.evidence.some((item) => item.kind === 'source')) {
|
|
579
|
+
blockers.add('BASELINE_SOURCE_EVIDENCE_MISSING');
|
|
580
|
+
}
|
|
581
|
+
if (baseline.verification.length === 0) blockers.add('BASELINE_VERIFICATION_MISSING');
|
|
582
|
+
for (const item of baseline.verification) {
|
|
583
|
+
if (item.status === 'not_run') blockers.add(`BASELINE_VERIFICATION_NOT_RUN:${item.name}`);
|
|
584
|
+
if (item.status === 'known_red') {
|
|
585
|
+
if (input.accept_known_red !== true) blockers.add(`BASELINE_KNOWN_RED_NOT_ACCEPTED:${item.name}`);
|
|
586
|
+
if (!item.rationale) blockers.add(`BASELINE_KNOWN_RED_RATIONALE_MISSING:${item.name}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
for (const item of baseline.unknowns.filter((unknown) => unknown.blocking)) {
|
|
590
|
+
blockers.add(`BASELINE_UNKNOWN_BLOCKING:${item.summary}`);
|
|
591
|
+
}
|
|
592
|
+
for (const gap of baseline.gaps.filter((item) => item.status === 'open' && item.blocking)) {
|
|
593
|
+
blockers.add(`BASELINE_GAP_BLOCKING:${gap.id}`);
|
|
594
|
+
}
|
|
595
|
+
if (!String(input.approved_by || '').trim()) blockers.add('BASELINE_APPROVER_REQUIRED');
|
|
596
|
+
if (String(input.approval_note || '').trim().length < 3) blockers.add('BASELINE_APPROVAL_NOTE_REQUIRED');
|
|
597
|
+
return [...blockers].sort();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
|
|
601
|
+
const current = readBaseline(db, input.id);
|
|
602
|
+
if (!current) throw new BaselineWorkflowError('BASELINE_NOT_FOUND', `baseline ${input.id} not found`);
|
|
603
|
+
if (!['draft', 'adopting', 'blocked'].includes(current.status)) {
|
|
604
|
+
throw new BaselineWorkflowError('BASELINE_NOT_CONVERGEABLE', `baseline ${current.id} is ${current.status}`);
|
|
605
|
+
}
|
|
606
|
+
const blockers = convergenceBlockers(current, input, rootDir);
|
|
607
|
+
const ts = nowIso();
|
|
608
|
+
if (blockers.length > 0) {
|
|
609
|
+
ops.tx(db, () => {
|
|
610
|
+
db.prepare("UPDATE baselines SET status = 'blocked', updated_at = ? WHERE id = ?")
|
|
611
|
+
.run(ts, current.id);
|
|
612
|
+
if (emitEvent) {
|
|
613
|
+
ops.appendEventInTx(db, {
|
|
614
|
+
type: 'baseline_blocked', payload: { baseline_id: current.id, blockers },
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
return { ready: false, status: 'blocked', blockers, baseline: readBaseline(db, current.id) };
|
|
619
|
+
}
|
|
620
|
+
ops.tx(db, () => {
|
|
621
|
+
db.prepare(
|
|
622
|
+
`UPDATE baselines SET status = 'ready', approved_by = ?, approval_note = ?,
|
|
623
|
+
worktree_accepted = ?, converged_at = ?, updated_at = ? WHERE id = ?`,
|
|
624
|
+
).run(
|
|
625
|
+
String(input.approved_by).trim(), String(input.approval_note).trim(),
|
|
626
|
+
input.accept_dirty_worktree === true ? 1 : 0, ts, ts, current.id,
|
|
627
|
+
);
|
|
628
|
+
if (emitEvent) {
|
|
629
|
+
ops.appendEventInTx(db, {
|
|
630
|
+
type: 'baseline_converged', payload: { baseline_id: current.id, revision: current.repository_revision },
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
return { ready: true, status: 'ready', blockers: [], baseline: readBaseline(db, current.id) };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
|
|
638
|
+
if (!hasTable(db, 'baselines')) {
|
|
639
|
+
return {
|
|
640
|
+
status: 'fail', blockers: ['BASELINE_SCHEMA_MIGRATION_REQUIRED'], warnings: [], baseline: null,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const baseline = readBaseline(db, id);
|
|
644
|
+
if (!baseline) {
|
|
645
|
+
return { status: 'fail', blockers: ['BASELINE_MISSING'], warnings: [], baseline: null };
|
|
646
|
+
}
|
|
647
|
+
if (baseline.mode === 'migrated') {
|
|
648
|
+
return {
|
|
649
|
+
status: 'fail', blockers: ['BASELINE_MIGRATION_REVIEW_REQUIRED'], warnings: [], baseline,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (baseline.status !== 'ready') {
|
|
653
|
+
return {
|
|
654
|
+
status: 'fail', blockers: [`BASELINE_NOT_READY:${baseline.status}`], warnings: [], baseline,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
const blockers = [
|
|
658
|
+
...storedScopeBlockers(baseline, rootDir),
|
|
659
|
+
...storedSpecBlockers(baseline, rootDir),
|
|
660
|
+
...storedEvidenceBlockers(baseline, rootDir),
|
|
661
|
+
];
|
|
662
|
+
const snapshot = gitWorktreeSnapshot(rootDir, baseline.scope);
|
|
663
|
+
if (snapshot.head && baseline.repository_revision && snapshot.head !== baseline.repository_revision) {
|
|
664
|
+
blockers.push('BASELINE_HEAD_STALE');
|
|
665
|
+
} else if (!snapshot.head && baseline.repository_revision?.startsWith('workspace:')) {
|
|
666
|
+
const revision = workspaceRevision({
|
|
667
|
+
scope: baseline.scope, specs: baseline.spec_refs, evidence: baseline.evidence,
|
|
668
|
+
}, rootDir);
|
|
669
|
+
if (revision !== baseline.repository_revision) blockers.push('BASELINE_WORKSPACE_STALE');
|
|
670
|
+
}
|
|
671
|
+
if (baseline.repository_branch && snapshot.branch
|
|
672
|
+
&& baseline.repository_branch !== snapshot.branch) blockers.push('BASELINE_BRANCH_STALE');
|
|
673
|
+
if (baseline.worktree_digest && snapshot.digest && baseline.worktree_digest !== snapshot.digest) {
|
|
674
|
+
blockers.push('BASELINE_WORKTREE_STALE');
|
|
675
|
+
if (snapshot.state === 'dirty') blockers.push('BASELINE_WORKTREE_DIRTY');
|
|
676
|
+
}
|
|
677
|
+
if (baseline.worktree_state === 'dirty' && !baseline.worktree_accepted) {
|
|
678
|
+
blockers.push('BASELINE_DIRTY_WORKTREE_NOT_ACCEPTED');
|
|
679
|
+
}
|
|
680
|
+
for (const gap of baseline.gaps.filter((item) => item.status === 'open' && item.blocking)) {
|
|
681
|
+
blockers.push(`BASELINE_GAP_BLOCKING:${gap.id}`);
|
|
682
|
+
}
|
|
683
|
+
return { status: blockers.length === 0 ? 'pass' : 'fail', blockers, warnings: [], baseline };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function inferSpecKind(file) {
|
|
687
|
+
const name = path.basename(file, path.extname(file)).toLowerCase();
|
|
688
|
+
return SPEC_KINDS.has(name) ? name : 'other';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function reconcileBaseline(db, { baseline_updates = [], change_id = null } = {}, {
|
|
692
|
+
rootDir = process.cwd(), emitEvent = true,
|
|
693
|
+
} = {}) {
|
|
694
|
+
const current = readBaseline(db);
|
|
695
|
+
if (!current || current.status !== 'ready') {
|
|
696
|
+
throw new BaselineWorkflowError('BASELINE_NOT_READY', 'a ready baseline is required for reconciliation');
|
|
697
|
+
}
|
|
698
|
+
if (current.mode === 'migrated' && current.spec_refs.length === 0) return current;
|
|
699
|
+
const refs = new Map(current.spec_refs.map((ref) => [ref.path, ref]));
|
|
700
|
+
const evidence = current.evidence.map((item) => ({ ...item }));
|
|
701
|
+
for (const update of baseline_updates) {
|
|
702
|
+
const file = safeRelativePath(rootDir, update, 'baseline update');
|
|
703
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
704
|
+
throw new BaselineWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${update}`);
|
|
705
|
+
}
|
|
706
|
+
const digest = digestFile(file);
|
|
707
|
+
const existingRef = refs.get(update);
|
|
708
|
+
if (existingRef) refs.set(update, { ...existingRef, digest });
|
|
709
|
+
let evidenceMatched = false;
|
|
710
|
+
for (const item of evidence) {
|
|
711
|
+
if (item.ref !== update || (item.kind !== 'source' && !item.digest)) continue;
|
|
712
|
+
item.digest = digest;
|
|
713
|
+
evidenceMatched = true;
|
|
714
|
+
}
|
|
715
|
+
if (!existingRef && !evidenceMatched) {
|
|
716
|
+
refs.set(update, { kind: inferSpecKind(update), path: update, digest });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
const specs = [...refs.values()];
|
|
720
|
+
const snapshot = gitWorktreeSnapshot(rootDir, current.scope);
|
|
721
|
+
const revision = snapshot.head || workspaceRevision({
|
|
722
|
+
scope: current.scope, specs, evidence,
|
|
723
|
+
}, rootDir);
|
|
724
|
+
const ts = nowIso();
|
|
725
|
+
ops.tx(db, () => {
|
|
726
|
+
db.prepare(
|
|
727
|
+
`UPDATE baselines SET repository_revision = ?, repository_branch = ?, worktree_state = ?,
|
|
728
|
+
worktree_digest = ?, worktree_files_json = ?, worktree_accepted = 0,
|
|
729
|
+
spec_refs_json = ?, evidence_json = ?, updated_at = ? WHERE id = ?`,
|
|
730
|
+
).run(
|
|
731
|
+
revision, snapshot.branch, snapshot.state, snapshot.digest, JSON.stringify(snapshot.files),
|
|
732
|
+
JSON.stringify(specs), JSON.stringify(evidence), ts, current.id,
|
|
733
|
+
);
|
|
734
|
+
if (emitEvent) {
|
|
735
|
+
ops.appendEventInTx(db, {
|
|
736
|
+
type: 'baseline_reconciled',
|
|
737
|
+
change_id,
|
|
738
|
+
payload: { baseline_id: current.id, baseline_updates, repository_revision: revision },
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
return readBaseline(db, current.id);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function appendGapInTx(db, input = {}, { emitEvent = true } = {}) {
|
|
746
|
+
const current = readBaseline(db, input.baseline_id);
|
|
747
|
+
if (!current) throw new BaselineWorkflowError('BASELINE_NOT_FOUND', 'current baseline not found');
|
|
748
|
+
const next = normalizeGaps([...current.gaps.filter((gap) => gap.id !== input.gap?.id), input.gap]);
|
|
749
|
+
const ts = nowIso();
|
|
750
|
+
db.prepare('UPDATE baselines SET gaps_json = ?, updated_at = ? WHERE id = ?')
|
|
751
|
+
.run(JSON.stringify(next), ts, current.id);
|
|
752
|
+
if (emitEvent) {
|
|
753
|
+
ops.appendEventInTx(db, {
|
|
754
|
+
type: 'baseline_gap_recorded',
|
|
755
|
+
payload: { baseline_id: current.id, gap_id: input.gap.id, category: input.gap.category },
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return readBaseline(db, current.id);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function appendGap(db, input = {}, options = {}) {
|
|
762
|
+
return ops.tx(db, () => appendGapInTx(db, input, options));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
module.exports = {
|
|
766
|
+
BaselineWorkflowError,
|
|
767
|
+
startBaseline,
|
|
768
|
+
recordBaseline,
|
|
769
|
+
convergeBaseline,
|
|
770
|
+
readBaseline,
|
|
771
|
+
inspectBaseline,
|
|
772
|
+
reconcileBaseline,
|
|
773
|
+
gitHead,
|
|
774
|
+
gitBranch,
|
|
775
|
+
gitWorktreeSnapshot,
|
|
776
|
+
gitWorktreeChanges,
|
|
777
|
+
appendGap,
|
|
778
|
+
appendGapInTx,
|
|
779
|
+
summarizeGaps,
|
|
780
|
+
normalizeGaps,
|
|
781
|
+
};
|