ultra-builder-pro-cli 0.12.1 → 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/discovery.md +10 -0
- package/CHANGELOG.md +53 -1
- package/README.md +25 -9
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/tests/codex.test.cjs +8 -0
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +4 -3
- package/docs/ARCHITECTURE.md +32 -16
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +1 -1
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/context_spine.py +37 -4
- 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 +404 -38
- package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
- package/mcp-server/lib/change-workflow.cjs +249 -75
- package/mcp-server/lib/change-workflow.test.cjs +235 -1
- package/mcp-server/lib/context-spine.test.cjs +5 -0
- package/mcp-server/lib/doctor.cjs +14 -1
- package/mcp-server/lib/doctor.test.cjs +42 -4
- package/mcp-server/lib/init-project.cjs +292 -9
- package/mcp-server/lib/spec-learning.test.cjs +1 -1
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +154 -32
- package/mcp-server/lib/state-db.test.cjs +136 -4
- 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 +14 -3
- package/mcp-server/tests/init-project.test.cjs +162 -1
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +384 -6
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +18 -10
- package/skills/ultra-deliver/SKILL.md +13 -9
- package/skills/ultra-dev/SKILL.md +6 -4
- package/skills/ultra-doctor/SKILL.md +22 -7
- package/skills/ultra-init/SKILL.md +104 -59
- package/skills/ultra-init/references/brownfield-adoption.md +65 -37
- package/skills/ultra-plan/SKILL.md +8 -5
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +95 -9
- package/spec/schemas/state-db.sql +11 -0
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +18 -9
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +7 -1
- package/ultra-tools/status-command.test.cjs +29 -0
- package/ultra-tools/system-command.test.cjs +252 -1
|
@@ -13,6 +13,11 @@ const MODES = new Set(['greenfield', 'brownfield']);
|
|
|
13
13
|
const SPEC_KINDS = new Set(['discovery', 'product', 'architecture', 'quality', 'delivery', 'other']);
|
|
14
14
|
const EVIDENCE_KINDS = new Set(['source', 'docs', 'runtime', 'test', 'deploy', 'external']);
|
|
15
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']);
|
|
16
21
|
|
|
17
22
|
class BaselineWorkflowError extends Error {
|
|
18
23
|
constructor(code, message, details) {
|
|
@@ -34,6 +39,70 @@ function gitHead(rootDir) {
|
|
|
34
39
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
35
40
|
}
|
|
36
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
|
+
|
|
37
106
|
function parseJson(value, field, fallback) {
|
|
38
107
|
if (value == null || value === '') return fallback;
|
|
39
108
|
try { return JSON.parse(value); }
|
|
@@ -51,16 +120,27 @@ function rowToBaseline(row) {
|
|
|
51
120
|
evidence: parseJson(row.evidence_json, 'evidence_json', []),
|
|
52
121
|
verification: parseJson(row.verification_json, 'verification_json', []),
|
|
53
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),
|
|
54
127
|
provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json', {}),
|
|
55
128
|
};
|
|
56
129
|
for (const key of [
|
|
57
130
|
'scope_json', 'spec_refs_json', 'evidence_json', 'verification_json', 'unknowns_json',
|
|
58
|
-
'provider_refs_json',
|
|
131
|
+
'gaps_json', 'classification_json', 'worktree_files_json', 'provider_refs_json',
|
|
59
132
|
]) delete baseline[key];
|
|
60
133
|
return baseline;
|
|
61
134
|
}
|
|
62
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
|
+
|
|
63
142
|
function readBaseline(db, id) {
|
|
143
|
+
if (!hasTable(db, 'baselines')) return null;
|
|
64
144
|
const row = id
|
|
65
145
|
? db.prepare('SELECT * FROM baselines WHERE id = ?').get(id)
|
|
66
146
|
: db.prepare(
|
|
@@ -75,6 +155,33 @@ function nonEmpty(value, field) {
|
|
|
75
155
|
return text;
|
|
76
156
|
}
|
|
77
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
|
+
|
|
78
185
|
function safeRelativePath(rootDir, candidate, field = 'path') {
|
|
79
186
|
const value = nonEmpty(candidate, field);
|
|
80
187
|
if (path.isAbsolute(value)) {
|
|
@@ -94,11 +201,27 @@ function normalizeScope(value, rootDir) {
|
|
|
94
201
|
throw new BaselineWorkflowError('VALIDATION_ERROR', 'scope must be a non-empty array');
|
|
95
202
|
}
|
|
96
203
|
return [...new Set(scope.map((item, index) => {
|
|
97
|
-
safeRelativePath(rootDir, item, `scope[${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
|
+
}
|
|
98
210
|
return String(item).trim();
|
|
99
211
|
}))];
|
|
100
212
|
}
|
|
101
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
|
+
|
|
102
225
|
function digestFile(file) {
|
|
103
226
|
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
104
227
|
}
|
|
@@ -133,17 +256,27 @@ function normalizeSpecRefs(value, rootDir) {
|
|
|
133
256
|
});
|
|
134
257
|
}
|
|
135
258
|
|
|
136
|
-
function normalizeEvidence(value) {
|
|
259
|
+
function normalizeEvidence(value, rootDir) {
|
|
137
260
|
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'evidence must be an array');
|
|
138
261
|
return value.map((item, index) => {
|
|
139
262
|
if (!item || typeof item !== 'object' || Array.isArray(item) || !EVIDENCE_KINDS.has(item.kind)) {
|
|
140
263
|
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid evidence[${index}]`);
|
|
141
264
|
}
|
|
142
|
-
|
|
265
|
+
const normalized = {
|
|
143
266
|
kind: item.kind,
|
|
144
267
|
ref: nonEmpty(item.ref, `evidence[${index}].ref`),
|
|
145
268
|
summary: nonEmpty(item.summary, `evidence[${index}].summary`),
|
|
146
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;
|
|
147
280
|
});
|
|
148
281
|
}
|
|
149
282
|
|
|
@@ -179,6 +312,67 @@ function normalizeUnknowns(value) {
|
|
|
179
312
|
});
|
|
180
313
|
}
|
|
181
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
|
+
|
|
182
376
|
function startBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
|
|
183
377
|
const id = input.id || 'project-baseline';
|
|
184
378
|
if (!BASELINE_ID.test(id)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'baseline id is invalid');
|
|
@@ -186,36 +380,82 @@ function startBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = tr
|
|
|
186
380
|
if (!MODES.has(input.mode)) throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid baseline mode: ${input.mode}`);
|
|
187
381
|
if (readBaseline(db, id)) throw new BaselineWorkflowError('DUPLICATE_BASELINE_ID', `baseline ${id} exists`);
|
|
188
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
|
+
}
|
|
189
409
|
if (current && current.status !== 'superseded') {
|
|
190
|
-
if (
|
|
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) {
|
|
191
414
|
throw new BaselineWorkflowError('BASELINE_EXISTS', `ready baseline ${current.id} requires replace_ready=true`);
|
|
192
|
-
}
|
|
193
|
-
if (current.status !== 'ready') {
|
|
415
|
+
} else if (current.status !== 'ready') {
|
|
194
416
|
throw new BaselineWorkflowError('BASELINE_IN_PROGRESS', `baseline ${current.id} is ${current.status}`);
|
|
195
417
|
}
|
|
196
418
|
}
|
|
197
419
|
const scope = normalizeScope(input.scope, rootDir);
|
|
198
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
|
+
}
|
|
199
428
|
const status = input.mode === 'brownfield' ? 'adopting' : 'draft';
|
|
200
429
|
const ts = nowIso();
|
|
201
430
|
return ops.tx(db, () => {
|
|
202
|
-
if (current
|
|
431
|
+
if (current && (current.status === 'ready' || replacingMigrated)) {
|
|
203
432
|
db.prepare("UPDATE baselines SET status = 'superseded', updated_at = ? WHERE id = ?")
|
|
204
433
|
.run(ts, current.id);
|
|
205
434
|
}
|
|
206
435
|
db.prepare(
|
|
207
436
|
`INSERT INTO baselines
|
|
208
437
|
(id, project_name, project_type, stack, mode, status, repository_root, scope_json,
|
|
209
|
-
repository_revision,
|
|
210
|
-
|
|
438
|
+
repository_revision, repository_branch, worktree_state, worktree_digest,
|
|
439
|
+
worktree_files_json, classification_json, provider_refs_json, started_at, updated_at)
|
|
440
|
+
VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
211
441
|
).run(
|
|
212
442
|
id, projectName, input.project_type || null, input.stack || null, input.mode, status,
|
|
213
|
-
JSON.stringify(scope), input.repository_revision ||
|
|
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,
|
|
214
446
|
);
|
|
215
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
|
+
}
|
|
216
456
|
ops.appendEventInTx(db, {
|
|
217
457
|
type: 'baseline_started',
|
|
218
|
-
payload
|
|
458
|
+
payload,
|
|
219
459
|
});
|
|
220
460
|
}
|
|
221
461
|
return readBaseline(db, id);
|
|
@@ -228,33 +468,50 @@ function recordBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = t
|
|
|
228
468
|
if (['ready', 'superseded'].includes(current.status)) {
|
|
229
469
|
throw new BaselineWorkflowError('BASELINE_NOT_MUTABLE', `baseline ${current.id} is ${current.status}`);
|
|
230
470
|
}
|
|
231
|
-
const scope = input.scope === undefined ? current.scope :
|
|
471
|
+
const scope = normalizeScope(input.scope === undefined ? current.scope : input.scope, rootDir);
|
|
232
472
|
const specs = input.spec_refs === undefined ? current.spec_refs : normalizeSpecRefs(input.spec_refs, rootDir);
|
|
233
|
-
const evidence = input.evidence === undefined
|
|
473
|
+
const evidence = input.evidence === undefined
|
|
474
|
+
? current.evidence : normalizeEvidence(input.evidence, rootDir);
|
|
234
475
|
const verification = input.verification === undefined
|
|
235
476
|
? current.verification : normalizeVerification(input.verification);
|
|
236
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);
|
|
237
481
|
const providers = input.provider_refs === undefined
|
|
238
482
|
? current.provider_refs
|
|
239
483
|
: providerRefs.normalizeProviderRefs(input.provider_refs, BaselineWorkflowError);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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'));
|
|
244
493
|
const status = current.mode === 'brownfield' ? 'adopting' : 'draft';
|
|
245
494
|
const ts = nowIso();
|
|
246
495
|
return ops.tx(db, () => {
|
|
247
496
|
db.prepare(
|
|
248
|
-
`UPDATE baselines SET status = ?, scope_json = ?, repository_revision = ?,
|
|
249
|
-
|
|
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 = ?,
|
|
250
501
|
approved_by = NULL, approval_note = NULL, converged_at = NULL, updated_at = ? WHERE id = ?`,
|
|
251
502
|
).run(
|
|
252
|
-
status, JSON.stringify(scope), revision,
|
|
253
|
-
JSON.stringify(
|
|
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,
|
|
254
507
|
);
|
|
255
508
|
if (emitEvent) {
|
|
256
509
|
ops.appendEventInTx(db, {
|
|
257
|
-
type: 'baseline_recorded',
|
|
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
|
+
},
|
|
258
515
|
});
|
|
259
516
|
}
|
|
260
517
|
return readBaseline(db, current.id);
|
|
@@ -276,18 +533,47 @@ function storedSpecBlockers(baseline, rootDir) {
|
|
|
276
533
|
return blockers;
|
|
277
534
|
}
|
|
278
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
|
+
|
|
279
554
|
function convergenceBlockers(baseline, input, rootDir) {
|
|
280
555
|
const blockers = new Set();
|
|
281
556
|
const expected = typeof input.expected_revision === 'string' ? input.expected_revision.trim() : '';
|
|
282
557
|
if (!expected) blockers.add('BASELINE_REVISION_REQUIRED');
|
|
283
558
|
else if (baseline.repository_revision !== expected) blockers.add('BASELINE_REVISION_MISMATCH');
|
|
284
|
-
const
|
|
285
|
-
if (head && expected && head !== expected) blockers.add('BASELINE_HEAD_STALE');
|
|
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');
|
|
286
563
|
if (!Array.isArray(baseline.scope) || baseline.scope.length === 0) blockers.add('BASELINE_SCOPE_MISSING');
|
|
287
564
|
for (const kind of ['product', 'architecture']) {
|
|
288
565
|
if (!baseline.spec_refs.some((ref) => ref.kind === kind)) blockers.add(`BASELINE_SPEC_MISSING:${kind}`);
|
|
289
566
|
}
|
|
290
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
|
+
}
|
|
291
577
|
if (baseline.evidence.length === 0) blockers.add('BASELINE_EVIDENCE_MISSING');
|
|
292
578
|
if (baseline.mode === 'brownfield' && !baseline.evidence.some((item) => item.kind === 'source')) {
|
|
293
579
|
blockers.add('BASELINE_SOURCE_EVIDENCE_MISSING');
|
|
@@ -303,6 +589,9 @@ function convergenceBlockers(baseline, input, rootDir) {
|
|
|
303
589
|
for (const item of baseline.unknowns.filter((unknown) => unknown.blocking)) {
|
|
304
590
|
blockers.add(`BASELINE_UNKNOWN_BLOCKING:${item.summary}`);
|
|
305
591
|
}
|
|
592
|
+
for (const gap of baseline.gaps.filter((item) => item.status === 'open' && item.blocking)) {
|
|
593
|
+
blockers.add(`BASELINE_GAP_BLOCKING:${gap.id}`);
|
|
594
|
+
}
|
|
306
595
|
if (!String(input.approved_by || '').trim()) blockers.add('BASELINE_APPROVER_REQUIRED');
|
|
307
596
|
if (String(input.approval_note || '').trim().length < 3) blockers.add('BASELINE_APPROVAL_NOTE_REQUIRED');
|
|
308
597
|
return [...blockers].sort();
|
|
@@ -331,8 +620,11 @@ function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent =
|
|
|
331
620
|
ops.tx(db, () => {
|
|
332
621
|
db.prepare(
|
|
333
622
|
`UPDATE baselines SET status = 'ready', approved_by = ?, approval_note = ?,
|
|
334
|
-
converged_at = ?, updated_at = ? WHERE id = ?`,
|
|
335
|
-
).run(
|
|
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
|
+
);
|
|
336
628
|
if (emitEvent) {
|
|
337
629
|
ops.appendEventInTx(db, {
|
|
338
630
|
type: 'baseline_converged', payload: { baseline_id: current.id, revision: current.repository_revision },
|
|
@@ -343,24 +635,50 @@ function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent =
|
|
|
343
635
|
}
|
|
344
636
|
|
|
345
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
|
+
}
|
|
346
643
|
const baseline = readBaseline(db, id);
|
|
347
644
|
if (!baseline) {
|
|
348
645
|
return { status: 'fail', blockers: ['BASELINE_MISSING'], warnings: [], baseline: null };
|
|
349
646
|
}
|
|
350
|
-
if (baseline.
|
|
647
|
+
if (baseline.mode === 'migrated') {
|
|
351
648
|
return {
|
|
352
|
-
status: 'fail', blockers: [
|
|
649
|
+
status: 'fail', blockers: ['BASELINE_MIGRATION_REVIEW_REQUIRED'], warnings: [], baseline,
|
|
353
650
|
};
|
|
354
651
|
}
|
|
355
|
-
if (baseline.
|
|
652
|
+
if (baseline.status !== 'ready') {
|
|
356
653
|
return {
|
|
357
|
-
status: '
|
|
654
|
+
status: 'fail', blockers: [`BASELINE_NOT_READY:${baseline.status}`], warnings: [], baseline,
|
|
358
655
|
};
|
|
359
656
|
}
|
|
360
|
-
const blockers =
|
|
361
|
-
|
|
362
|
-
|
|
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) {
|
|
363
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}`);
|
|
364
682
|
}
|
|
365
683
|
return { status: blockers.length === 0 ? 'pass' : 'fail', blockers, warnings: [], baseline };
|
|
366
684
|
}
|
|
@@ -379,19 +697,40 @@ function reconcileBaseline(db, { baseline_updates = [], change_id = null } = {},
|
|
|
379
697
|
}
|
|
380
698
|
if (current.mode === 'migrated' && current.spec_refs.length === 0) return current;
|
|
381
699
|
const refs = new Map(current.spec_refs.map((ref) => [ref.path, ref]));
|
|
700
|
+
const evidence = current.evidence.map((item) => ({ ...item }));
|
|
382
701
|
for (const update of baseline_updates) {
|
|
383
702
|
const file = safeRelativePath(rootDir, update, 'baseline update');
|
|
384
703
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
385
704
|
throw new BaselineWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${update}`);
|
|
386
705
|
}
|
|
387
|
-
|
|
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
|
+
}
|
|
388
718
|
}
|
|
389
|
-
const
|
|
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);
|
|
390
724
|
const ts = nowIso();
|
|
391
725
|
ops.tx(db, () => {
|
|
392
726
|
db.prepare(
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
);
|
|
395
734
|
if (emitEvent) {
|
|
396
735
|
ops.appendEventInTx(db, {
|
|
397
736
|
type: 'baseline_reconciled',
|
|
@@ -403,6 +742,26 @@ function reconcileBaseline(db, { baseline_updates = [], change_id = null } = {},
|
|
|
403
742
|
return readBaseline(db, current.id);
|
|
404
743
|
}
|
|
405
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
|
+
|
|
406
765
|
module.exports = {
|
|
407
766
|
BaselineWorkflowError,
|
|
408
767
|
startBaseline,
|
|
@@ -412,4 +771,11 @@ module.exports = {
|
|
|
412
771
|
inspectBaseline,
|
|
413
772
|
reconcileBaseline,
|
|
414
773
|
gitHead,
|
|
774
|
+
gitBranch,
|
|
775
|
+
gitWorktreeSnapshot,
|
|
776
|
+
gitWorktreeChanges,
|
|
777
|
+
appendGap,
|
|
778
|
+
appendGapInTx,
|
|
779
|
+
summarizeGaps,
|
|
780
|
+
normalizeGaps,
|
|
415
781
|
};
|