ultra-builder-pro-cli 0.10.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/specs/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +34 -231
- 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 +86 -1
- package/README.md +21 -9
- package/adapters/_shared/codex-assets.cjs +24 -342
- package/adapters/_shared/handbook.cjs +4 -3
- package/adapters/_shared/runtime-assets.cjs +24 -0
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +11 -4
- package/adapters/claude.js +28 -5
- package/adapters/kimi.js +15 -138
- package/adapters/opencode.js +22 -49
- package/adapters/tests/claude.test.cjs +22 -8
- package/adapters/tests/codex.test.cjs +21 -8
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/kimi.test.cjs +11 -11
- package/adapters/tests/opencode.test.cjs +47 -8
- package/agents/code-reviewer.md +24 -85
- package/agents/debugger.md +30 -132
- package/agents/review-code.md +24 -104
- package/agents/review-comments.md +20 -83
- package/agents/review-coordinator.md +40 -137
- package/agents/review-design.md +21 -118
- package/agents/review-errors.md +22 -70
- package/agents/review-spec.md +36 -0
- package/agents/review-tests.md +20 -76
- package/agents/tdd-runner.md +26 -84
- package/commands/learn.md +5 -36
- package/commands/ultra-change.md +5 -43
- package/commands/ultra-deliver.md +4 -46
- package/commands/ultra-dev.md +4 -48
- package/commands/ultra-doctor.md +4 -30
- package/commands/ultra-init.md +5 -47
- package/commands/ultra-plan.md +5 -48
- package/commands/ultra-research.md +5 -54
- package/commands/ultra-status.md +5 -44
- package/commands/ultra-test.md +5 -45
- package/commands/ultra-think.md +5 -40
- package/docs/AGENT-CONTEXT.md +16 -8
- package/docs/ARCHITECTURE.md +32 -5
- package/docs/PLAN.zh-CN.md +8 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/SKILL-AUTHORING.md +75 -0
- package/docs/STATE-DB-ACCESS-POLICY.md +17 -11
- package/docs/USER-HANDBOOK-CONTRACT.md +8 -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 +120 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- package/mcp-server/lib/baseline-workflow.cjs +415 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +225 -0
- package/mcp-server/lib/change-workflow.cjs +24 -27
- package/mcp-server/lib/change-workflow.test.cjs +202 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +15 -0
- package/mcp-server/lib/doctor.cjs +14 -0
- package/mcp-server/lib/doctor.test.cjs +25 -1
- package/mcp-server/lib/init-project.cjs +49 -14
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-db.cjs +63 -4
- package/mcp-server/lib/state-db.test.cjs +8 -3
- package/mcp-server/server.cjs +48 -3
- package/mcp-server/tests/init-project.test.cjs +54 -8
- package/mcp-server/tests/server-contract.test.cjs +99 -2
- package/mcp-server/tests/state-db.test.cjs +11 -2
- package/package.json +3 -2
- package/skills/cc-collab/SKILL.md +16 -18
- package/skills/code-review-expert/SKILL.md +39 -114
- package/skills/code-review-expert/references/correctness-reliability.md +30 -0
- package/skills/code-review-expert/references/design-boundaries.md +26 -0
- package/skills/code-review-expert/references/removal-plan.md +17 -45
- package/skills/codex-collab/SKILL.md +23 -28
- package/skills/codex-collab/evals/evals.json +18 -23
- package/skills/integration-rules/SKILL.md +28 -67
- package/skills/learn/SKILL.md +46 -106
- package/skills/security-rules/SKILL.md +34 -56
- package/skills/testing-rules/SKILL.md +28 -52
- package/skills/ultra-change/SKILL.md +68 -114
- package/skills/ultra-deliver/SKILL.md +58 -86
- package/skills/ultra-dev/SKILL.md +46 -105
- package/skills/ultra-doctor/SKILL.md +45 -72
- package/skills/ultra-init/SKILL.md +62 -147
- package/skills/ultra-init/references/brownfield-adoption.md +65 -0
- package/skills/ultra-plan/SKILL.md +56 -89
- package/skills/ultra-research/SKILL.md +64 -152
- package/skills/ultra-research/references/architecture.md +38 -0
- package/skills/ultra-research/references/problem-product.md +39 -0
- package/skills/ultra-research/references/quality-delivery.md +34 -0
- package/skills/ultra-research/references/synthesis.md +38 -0
- package/skills/ultra-research/references/users-scope.md +40 -0
- package/skills/ultra-review/SKILL.md +54 -98
- package/skills/ultra-review/references/unified-schema.md +76 -101
- package/skills/ultra-review/scripts/review_wait.py +242 -77
- package/skills/ultra-status/SKILL.md +41 -56
- package/skills/ultra-test/SKILL.md +37 -72
- package/skills/ultra-think/SKILL.md +43 -102
- package/skills/ultra-verify/SKILL.md +44 -84
- package/skills/ultra-verify/evals/evals.json +24 -27
- package/spec/cli-protocol.md +4 -0
- package/spec/mcp-tools.yaml +223 -4
- package/spec/schemas/command-manifest.schema.json +7 -25
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/skill-manifest.schema.json +9 -79
- package/spec/schemas/state-db.sql +33 -2
- package/ultra-tools/commands/status.cjs +20 -2
- package/ultra-tools/commands/task.cjs +4 -1
- package/ultra-tools/status-command.test.cjs +20 -0
- package/ultra-tools/system-command.test.cjs +5 -0
- package/skills/code-review-expert/references/code-quality-checklist.md +0 -112
- package/skills/code-review-expert/references/integration-checklist.md +0 -46
- package/skills/code-review-expert/references/security-checklist.md +0 -100
- package/skills/code-review-expert/references/solid-checklist.md +0 -65
- package/skills/ultra-research/steps/step-00-problem-validation.md +0 -154
- package/skills/ultra-research/steps/step-01-opportunity-discovery.md +0 -172
- package/skills/ultra-research/steps/step-02-market-assessment.md +0 -195
- package/skills/ultra-research/steps/step-03-competitive-landscape.md +0 -197
- package/skills/ultra-research/steps/step-04-product-strategy.md +0 -196
- package/skills/ultra-research/steps/step-05-assumptions-validation.md +0 -234
- package/skills/ultra-research/steps/step-10-user-personas.md +0 -169
- package/skills/ultra-research/steps/step-11-user-scenarios.md +0 -181
- package/skills/ultra-research/steps/step-20-user-stories.md +0 -156
- package/skills/ultra-research/steps/step-21-features-scope.md +0 -157
- package/skills/ultra-research/steps/step-22-success-metrics.md +0 -195
- package/skills/ultra-research/steps/step-30-architecture-context.md +0 -154
- package/skills/ultra-research/steps/step-31-solution-strategy.md +0 -172
- package/skills/ultra-research/steps/step-32-building-blocks.md +0 -188
- package/skills/ultra-research/steps/step-40-deployment.md +0 -123
- package/skills/ultra-research/steps/step-41-quality-risks.md +0 -135
- package/skills/ultra-research/steps/step-99-synthesis.md +0 -188
- package/skills/ultra-review/scripts/review_verdict_update.py +0 -99
|
@@ -0,0 +1,415 @@
|
|
|
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
|
+
|
|
17
|
+
class BaselineWorkflowError extends Error {
|
|
18
|
+
constructor(code, message, details) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'BaselineWorkflowError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
if (details !== undefined) this.details = details;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nowIso() {
|
|
27
|
+
return new Date().toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function gitHead(rootDir) {
|
|
31
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
32
|
+
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseJson(value, field, fallback) {
|
|
38
|
+
if (value == null || value === '') return fallback;
|
|
39
|
+
try { return JSON.parse(value); }
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new BaselineWorkflowError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rowToBaseline(row) {
|
|
46
|
+
if (!row) return null;
|
|
47
|
+
const baseline = {
|
|
48
|
+
...row,
|
|
49
|
+
scope: parseJson(row.scope_json, 'scope_json', []),
|
|
50
|
+
spec_refs: parseJson(row.spec_refs_json, 'spec_refs_json', []),
|
|
51
|
+
evidence: parseJson(row.evidence_json, 'evidence_json', []),
|
|
52
|
+
verification: parseJson(row.verification_json, 'verification_json', []),
|
|
53
|
+
unknowns: parseJson(row.unknowns_json, 'unknowns_json', []),
|
|
54
|
+
provider_refs: parseJson(row.provider_refs_json, 'provider_refs_json', {}),
|
|
55
|
+
};
|
|
56
|
+
for (const key of [
|
|
57
|
+
'scope_json', 'spec_refs_json', 'evidence_json', 'verification_json', 'unknowns_json',
|
|
58
|
+
'provider_refs_json',
|
|
59
|
+
]) delete baseline[key];
|
|
60
|
+
return baseline;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readBaseline(db, id) {
|
|
64
|
+
const row = id
|
|
65
|
+
? db.prepare('SELECT * FROM baselines WHERE id = ?').get(id)
|
|
66
|
+
: db.prepare(
|
|
67
|
+
"SELECT * FROM baselines WHERE status != 'superseded' ORDER BY updated_at DESC, rowid DESC LIMIT 1",
|
|
68
|
+
).get();
|
|
69
|
+
return rowToBaseline(row);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nonEmpty(value, field) {
|
|
73
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
74
|
+
if (!text) throw new BaselineWorkflowError('VALIDATION_ERROR', `${field} must be a non-empty string`);
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeRelativePath(rootDir, candidate, field = 'path') {
|
|
79
|
+
const value = nonEmpty(candidate, field);
|
|
80
|
+
if (path.isAbsolute(value)) {
|
|
81
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `${field} must be project-relative: ${value}`);
|
|
82
|
+
}
|
|
83
|
+
const root = path.resolve(rootDir);
|
|
84
|
+
const resolved = path.resolve(root, value);
|
|
85
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
86
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `${field} escapes project root: ${value}`);
|
|
87
|
+
}
|
|
88
|
+
return resolved;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeScope(value, rootDir) {
|
|
92
|
+
const scope = value === undefined ? ['.'] : value;
|
|
93
|
+
if (!Array.isArray(scope) || scope.length === 0) {
|
|
94
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', 'scope must be a non-empty array');
|
|
95
|
+
}
|
|
96
|
+
return [...new Set(scope.map((item, index) => {
|
|
97
|
+
safeRelativePath(rootDir, item, `scope[${index}]`);
|
|
98
|
+
return String(item).trim();
|
|
99
|
+
}))];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function digestFile(file) {
|
|
103
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function workspaceRevision({ scope, specs, evidence }, rootDir) {
|
|
107
|
+
const evidenceRefs = evidence.map((item) => {
|
|
108
|
+
try {
|
|
109
|
+
const file = safeRelativePath(rootDir, item.ref, 'evidence ref');
|
|
110
|
+
return fs.existsSync(file) && fs.statSync(file).isFile()
|
|
111
|
+
? { kind: item.kind, ref: item.ref, digest: digestFile(file) }
|
|
112
|
+
: { kind: item.kind, ref: item.ref };
|
|
113
|
+
} catch {
|
|
114
|
+
return { kind: item.kind, ref: item.ref };
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const payload = JSON.stringify({ scope, specs, evidence: evidenceRefs });
|
|
118
|
+
return `workspace:${crypto.createHash('sha256').update(payload).digest('hex')}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeSpecRefs(value, rootDir) {
|
|
122
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'spec_refs must be an array');
|
|
123
|
+
return value.map((item, index) => {
|
|
124
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) || !SPEC_KINDS.has(item.kind)) {
|
|
125
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid spec_refs[${index}]`);
|
|
126
|
+
}
|
|
127
|
+
const specPath = nonEmpty(item.path, `spec_refs[${index}].path`);
|
|
128
|
+
const file = safeRelativePath(rootDir, specPath, `spec_refs[${index}].path`);
|
|
129
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
130
|
+
throw new BaselineWorkflowError('BASELINE_SPEC_MISSING', `baseline specification missing: ${specPath}`);
|
|
131
|
+
}
|
|
132
|
+
return { kind: item.kind, path: specPath, digest: digestFile(file) };
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeEvidence(value) {
|
|
137
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'evidence must be an array');
|
|
138
|
+
return value.map((item, index) => {
|
|
139
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) || !EVIDENCE_KINDS.has(item.kind)) {
|
|
140
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid evidence[${index}]`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
kind: item.kind,
|
|
144
|
+
ref: nonEmpty(item.ref, `evidence[${index}].ref`),
|
|
145
|
+
summary: nonEmpty(item.summary, `evidence[${index}].summary`),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeVerification(value) {
|
|
151
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'verification must be an array');
|
|
152
|
+
return value.map((item, index) => {
|
|
153
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) || !VERIFICATION_STATUSES.has(item.status)) {
|
|
154
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid verification[${index}]`);
|
|
155
|
+
}
|
|
156
|
+
const normalized = {
|
|
157
|
+
name: nonEmpty(item.name, `verification[${index}].name`),
|
|
158
|
+
command: nonEmpty(item.command, `verification[${index}].command`),
|
|
159
|
+
status: item.status,
|
|
160
|
+
evidence: nonEmpty(item.evidence, `verification[${index}].evidence`),
|
|
161
|
+
};
|
|
162
|
+
if (item.rationale !== undefined) normalized.rationale = nonEmpty(item.rationale, `verification[${index}].rationale`);
|
|
163
|
+
return normalized;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeUnknowns(value) {
|
|
168
|
+
if (!Array.isArray(value)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'unknowns must be an array');
|
|
169
|
+
return value.map((item, index) => {
|
|
170
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
171
|
+
throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid unknowns[${index}]`);
|
|
172
|
+
}
|
|
173
|
+
const normalized = {
|
|
174
|
+
summary: nonEmpty(item.summary, `unknowns[${index}].summary`),
|
|
175
|
+
blocking: item.blocking === true,
|
|
176
|
+
};
|
|
177
|
+
if (item.owner !== undefined) normalized.owner = nonEmpty(item.owner, `unknowns[${index}].owner`);
|
|
178
|
+
return normalized;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function startBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
|
|
183
|
+
const id = input.id || 'project-baseline';
|
|
184
|
+
if (!BASELINE_ID.test(id)) throw new BaselineWorkflowError('VALIDATION_ERROR', 'baseline id is invalid');
|
|
185
|
+
const projectName = nonEmpty(input.project_name, 'project_name');
|
|
186
|
+
if (!MODES.has(input.mode)) throw new BaselineWorkflowError('VALIDATION_ERROR', `invalid baseline mode: ${input.mode}`);
|
|
187
|
+
if (readBaseline(db, id)) throw new BaselineWorkflowError('DUPLICATE_BASELINE_ID', `baseline ${id} exists`);
|
|
188
|
+
const current = readBaseline(db);
|
|
189
|
+
if (current && current.status !== 'superseded') {
|
|
190
|
+
if (current.status === 'ready' && input.replace_ready !== true) {
|
|
191
|
+
throw new BaselineWorkflowError('BASELINE_EXISTS', `ready baseline ${current.id} requires replace_ready=true`);
|
|
192
|
+
}
|
|
193
|
+
if (current.status !== 'ready') {
|
|
194
|
+
throw new BaselineWorkflowError('BASELINE_IN_PROGRESS', `baseline ${current.id} is ${current.status}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const scope = normalizeScope(input.scope, rootDir);
|
|
198
|
+
const providers = providerRefs.normalizeProviderRefs(input.provider_refs, BaselineWorkflowError);
|
|
199
|
+
const status = input.mode === 'brownfield' ? 'adopting' : 'draft';
|
|
200
|
+
const ts = nowIso();
|
|
201
|
+
return ops.tx(db, () => {
|
|
202
|
+
if (current?.status === 'ready') {
|
|
203
|
+
db.prepare("UPDATE baselines SET status = 'superseded', updated_at = ? WHERE id = ?")
|
|
204
|
+
.run(ts, current.id);
|
|
205
|
+
}
|
|
206
|
+
db.prepare(
|
|
207
|
+
`INSERT INTO baselines
|
|
208
|
+
(id, project_name, project_type, stack, mode, status, repository_root, scope_json,
|
|
209
|
+
repository_revision, provider_refs_json, started_at, updated_at)
|
|
210
|
+
VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?)`,
|
|
211
|
+
).run(
|
|
212
|
+
id, projectName, input.project_type || null, input.stack || null, input.mode, status,
|
|
213
|
+
JSON.stringify(scope), input.repository_revision || gitHead(rootDir), JSON.stringify(providers), ts, ts,
|
|
214
|
+
);
|
|
215
|
+
if (emitEvent) {
|
|
216
|
+
ops.appendEventInTx(db, {
|
|
217
|
+
type: 'baseline_started',
|
|
218
|
+
payload: { baseline_id: id, mode: input.mode, replaced_baseline_id: current?.id || null },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return readBaseline(db, id);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function recordBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
|
|
226
|
+
const current = readBaseline(db, input.id);
|
|
227
|
+
if (!current) throw new BaselineWorkflowError('BASELINE_NOT_FOUND', `baseline ${input.id} not found`);
|
|
228
|
+
if (['ready', 'superseded'].includes(current.status)) {
|
|
229
|
+
throw new BaselineWorkflowError('BASELINE_NOT_MUTABLE', `baseline ${current.id} is ${current.status}`);
|
|
230
|
+
}
|
|
231
|
+
const scope = input.scope === undefined ? current.scope : normalizeScope(input.scope, rootDir);
|
|
232
|
+
const specs = input.spec_refs === undefined ? current.spec_refs : normalizeSpecRefs(input.spec_refs, rootDir);
|
|
233
|
+
const evidence = input.evidence === undefined ? current.evidence : normalizeEvidence(input.evidence);
|
|
234
|
+
const verification = input.verification === undefined
|
|
235
|
+
? current.verification : normalizeVerification(input.verification);
|
|
236
|
+
const unknowns = input.unknowns === undefined ? current.unknowns : normalizeUnknowns(input.unknowns);
|
|
237
|
+
const providers = input.provider_refs === undefined
|
|
238
|
+
? current.provider_refs
|
|
239
|
+
: providerRefs.normalizeProviderRefs(input.provider_refs, BaselineWorkflowError);
|
|
240
|
+
const revision = input.repository_revision === undefined
|
|
241
|
+
? (current.repository_revision || gitHead(rootDir)
|
|
242
|
+
|| workspaceRevision({ scope, specs, evidence }, rootDir))
|
|
243
|
+
: nonEmpty(input.repository_revision, 'repository_revision');
|
|
244
|
+
const status = current.mode === 'brownfield' ? 'adopting' : 'draft';
|
|
245
|
+
const ts = nowIso();
|
|
246
|
+
return ops.tx(db, () => {
|
|
247
|
+
db.prepare(
|
|
248
|
+
`UPDATE baselines SET status = ?, scope_json = ?, repository_revision = ?, spec_refs_json = ?,
|
|
249
|
+
evidence_json = ?, verification_json = ?, unknowns_json = ?, provider_refs_json = ?,
|
|
250
|
+
approved_by = NULL, approval_note = NULL, converged_at = NULL, updated_at = ? WHERE id = ?`,
|
|
251
|
+
).run(
|
|
252
|
+
status, JSON.stringify(scope), revision, JSON.stringify(specs), JSON.stringify(evidence),
|
|
253
|
+
JSON.stringify(verification), JSON.stringify(unknowns), JSON.stringify(providers), ts, current.id,
|
|
254
|
+
);
|
|
255
|
+
if (emitEvent) {
|
|
256
|
+
ops.appendEventInTx(db, {
|
|
257
|
+
type: 'baseline_recorded', payload: { baseline_id: current.id, spec_count: specs.length },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return readBaseline(db, current.id);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function storedSpecBlockers(baseline, rootDir) {
|
|
265
|
+
const blockers = [];
|
|
266
|
+
for (const spec of baseline.spec_refs) {
|
|
267
|
+
let file;
|
|
268
|
+
try { file = safeRelativePath(rootDir, spec.path, 'spec path'); }
|
|
269
|
+
catch { blockers.push(`BASELINE_SPEC_INVALID:${spec.path}`); continue; }
|
|
270
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
271
|
+
blockers.push(`BASELINE_SPEC_MISSING:${spec.path}`);
|
|
272
|
+
} else if (!spec.digest || digestFile(file) !== spec.digest) {
|
|
273
|
+
blockers.push(`BASELINE_SPEC_STALE:${spec.path}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return blockers;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function convergenceBlockers(baseline, input, rootDir) {
|
|
280
|
+
const blockers = new Set();
|
|
281
|
+
const expected = typeof input.expected_revision === 'string' ? input.expected_revision.trim() : '';
|
|
282
|
+
if (!expected) blockers.add('BASELINE_REVISION_REQUIRED');
|
|
283
|
+
else if (baseline.repository_revision !== expected) blockers.add('BASELINE_REVISION_MISMATCH');
|
|
284
|
+
const head = gitHead(rootDir);
|
|
285
|
+
if (head && expected && head !== expected) blockers.add('BASELINE_HEAD_STALE');
|
|
286
|
+
if (!Array.isArray(baseline.scope) || baseline.scope.length === 0) blockers.add('BASELINE_SCOPE_MISSING');
|
|
287
|
+
for (const kind of ['product', 'architecture']) {
|
|
288
|
+
if (!baseline.spec_refs.some((ref) => ref.kind === kind)) blockers.add(`BASELINE_SPEC_MISSING:${kind}`);
|
|
289
|
+
}
|
|
290
|
+
for (const blocker of storedSpecBlockers(baseline, rootDir)) blockers.add(blocker);
|
|
291
|
+
if (baseline.evidence.length === 0) blockers.add('BASELINE_EVIDENCE_MISSING');
|
|
292
|
+
if (baseline.mode === 'brownfield' && !baseline.evidence.some((item) => item.kind === 'source')) {
|
|
293
|
+
blockers.add('BASELINE_SOURCE_EVIDENCE_MISSING');
|
|
294
|
+
}
|
|
295
|
+
if (baseline.verification.length === 0) blockers.add('BASELINE_VERIFICATION_MISSING');
|
|
296
|
+
for (const item of baseline.verification) {
|
|
297
|
+
if (item.status === 'not_run') blockers.add(`BASELINE_VERIFICATION_NOT_RUN:${item.name}`);
|
|
298
|
+
if (item.status === 'known_red') {
|
|
299
|
+
if (input.accept_known_red !== true) blockers.add(`BASELINE_KNOWN_RED_NOT_ACCEPTED:${item.name}`);
|
|
300
|
+
if (!item.rationale) blockers.add(`BASELINE_KNOWN_RED_RATIONALE_MISSING:${item.name}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const item of baseline.unknowns.filter((unknown) => unknown.blocking)) {
|
|
304
|
+
blockers.add(`BASELINE_UNKNOWN_BLOCKING:${item.summary}`);
|
|
305
|
+
}
|
|
306
|
+
if (!String(input.approved_by || '').trim()) blockers.add('BASELINE_APPROVER_REQUIRED');
|
|
307
|
+
if (String(input.approval_note || '').trim().length < 3) blockers.add('BASELINE_APPROVAL_NOTE_REQUIRED');
|
|
308
|
+
return [...blockers].sort();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
|
|
312
|
+
const current = readBaseline(db, input.id);
|
|
313
|
+
if (!current) throw new BaselineWorkflowError('BASELINE_NOT_FOUND', `baseline ${input.id} not found`);
|
|
314
|
+
if (!['draft', 'adopting', 'blocked'].includes(current.status)) {
|
|
315
|
+
throw new BaselineWorkflowError('BASELINE_NOT_CONVERGEABLE', `baseline ${current.id} is ${current.status}`);
|
|
316
|
+
}
|
|
317
|
+
const blockers = convergenceBlockers(current, input, rootDir);
|
|
318
|
+
const ts = nowIso();
|
|
319
|
+
if (blockers.length > 0) {
|
|
320
|
+
ops.tx(db, () => {
|
|
321
|
+
db.prepare("UPDATE baselines SET status = 'blocked', updated_at = ? WHERE id = ?")
|
|
322
|
+
.run(ts, current.id);
|
|
323
|
+
if (emitEvent) {
|
|
324
|
+
ops.appendEventInTx(db, {
|
|
325
|
+
type: 'baseline_blocked', payload: { baseline_id: current.id, blockers },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
return { ready: false, status: 'blocked', blockers, baseline: readBaseline(db, current.id) };
|
|
330
|
+
}
|
|
331
|
+
ops.tx(db, () => {
|
|
332
|
+
db.prepare(
|
|
333
|
+
`UPDATE baselines SET status = 'ready', approved_by = ?, approval_note = ?,
|
|
334
|
+
converged_at = ?, updated_at = ? WHERE id = ?`,
|
|
335
|
+
).run(String(input.approved_by).trim(), String(input.approval_note).trim(), ts, ts, current.id);
|
|
336
|
+
if (emitEvent) {
|
|
337
|
+
ops.appendEventInTx(db, {
|
|
338
|
+
type: 'baseline_converged', payload: { baseline_id: current.id, revision: current.repository_revision },
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
return { ready: true, status: 'ready', blockers: [], baseline: readBaseline(db, current.id) };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
|
|
346
|
+
const baseline = readBaseline(db, id);
|
|
347
|
+
if (!baseline) {
|
|
348
|
+
return { status: 'fail', blockers: ['BASELINE_MISSING'], warnings: [], baseline: null };
|
|
349
|
+
}
|
|
350
|
+
if (baseline.status !== 'ready') {
|
|
351
|
+
return {
|
|
352
|
+
status: 'fail', blockers: [`BASELINE_NOT_READY:${baseline.status}`], warnings: [], baseline,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (baseline.mode === 'migrated') {
|
|
356
|
+
return {
|
|
357
|
+
status: 'pass', blockers: [], warnings: ['BASELINE_MIGRATION_REVIEW_RECOMMENDED'], baseline,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const blockers = storedSpecBlockers(baseline, rootDir);
|
|
361
|
+
const head = gitHead(rootDir);
|
|
362
|
+
if (head && baseline.repository_revision && head !== baseline.repository_revision) {
|
|
363
|
+
blockers.push('BASELINE_HEAD_STALE');
|
|
364
|
+
}
|
|
365
|
+
return { status: blockers.length === 0 ? 'pass' : 'fail', blockers, warnings: [], baseline };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function inferSpecKind(file) {
|
|
369
|
+
const name = path.basename(file, path.extname(file)).toLowerCase();
|
|
370
|
+
return SPEC_KINDS.has(name) ? name : 'other';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function reconcileBaseline(db, { baseline_updates = [], change_id = null } = {}, {
|
|
374
|
+
rootDir = process.cwd(), emitEvent = true,
|
|
375
|
+
} = {}) {
|
|
376
|
+
const current = readBaseline(db);
|
|
377
|
+
if (!current || current.status !== 'ready') {
|
|
378
|
+
throw new BaselineWorkflowError('BASELINE_NOT_READY', 'a ready baseline is required for reconciliation');
|
|
379
|
+
}
|
|
380
|
+
if (current.mode === 'migrated' && current.spec_refs.length === 0) return current;
|
|
381
|
+
const refs = new Map(current.spec_refs.map((ref) => [ref.path, ref]));
|
|
382
|
+
for (const update of baseline_updates) {
|
|
383
|
+
const file = safeRelativePath(rootDir, update, 'baseline update');
|
|
384
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
385
|
+
throw new BaselineWorkflowError('BASELINE_FILE_MISSING', `baseline update missing: ${update}`);
|
|
386
|
+
}
|
|
387
|
+
refs.set(update, { kind: refs.get(update)?.kind || inferSpecKind(update), path: update, digest: digestFile(file) });
|
|
388
|
+
}
|
|
389
|
+
const revision = gitHead(rootDir) || current.repository_revision;
|
|
390
|
+
const ts = nowIso();
|
|
391
|
+
ops.tx(db, () => {
|
|
392
|
+
db.prepare(
|
|
393
|
+
'UPDATE baselines SET repository_revision = ?, spec_refs_json = ?, updated_at = ? WHERE id = ?',
|
|
394
|
+
).run(revision, JSON.stringify([...refs.values()]), ts, current.id);
|
|
395
|
+
if (emitEvent) {
|
|
396
|
+
ops.appendEventInTx(db, {
|
|
397
|
+
type: 'baseline_reconciled',
|
|
398
|
+
change_id,
|
|
399
|
+
payload: { baseline_id: current.id, baseline_updates, repository_revision: revision },
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
return readBaseline(db, current.id);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
module.exports = {
|
|
407
|
+
BaselineWorkflowError,
|
|
408
|
+
startBaseline,
|
|
409
|
+
recordBaseline,
|
|
410
|
+
convergeBaseline,
|
|
411
|
+
readBaseline,
|
|
412
|
+
inspectBaseline,
|
|
413
|
+
reconcileBaseline,
|
|
414
|
+
gitHead,
|
|
415
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { execFileSync } = require('node:child_process');
|
|
10
|
+
|
|
11
|
+
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
12
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-baseline-'));
|
|
16
|
+
execFileSync('git', ['init', '-q'], { cwd: rootDir });
|
|
17
|
+
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: rootDir });
|
|
18
|
+
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: rootDir });
|
|
19
|
+
fs.mkdirSync(path.join(rootDir, '.ultra', 'specs'), { recursive: true });
|
|
20
|
+
fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true });
|
|
21
|
+
fs.writeFileSync(path.join(rootDir, '.ultra', 'specs', 'product.md'), '# Product\n\nObserved behavior.\n');
|
|
22
|
+
fs.writeFileSync(path.join(rootDir, '.ultra', 'specs', 'architecture.md'), '# Architecture\n\nCurrent boundary.\n');
|
|
23
|
+
fs.writeFileSync(path.join(rootDir, 'src', 'index.js'), 'module.exports = true;\n');
|
|
24
|
+
execFileSync('git', ['add', '.'], { cwd: rootDir });
|
|
25
|
+
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
|
|
26
|
+
const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: rootDir, encoding: 'utf8' }).trim();
|
|
27
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
28
|
+
return { rootDir, db, revision };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanup(fx) {
|
|
32
|
+
closeStateDb(fx.db);
|
|
33
|
+
fs.rmSync(fx.rootDir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function adoptionEvidence() {
|
|
37
|
+
return {
|
|
38
|
+
spec_refs: [
|
|
39
|
+
{ kind: 'product', path: '.ultra/specs/product.md' },
|
|
40
|
+
{ kind: 'architecture', path: '.ultra/specs/architecture.md' },
|
|
41
|
+
],
|
|
42
|
+
evidence: [
|
|
43
|
+
{ kind: 'source', ref: 'src/index.js', summary: 'Current public module entry point.' },
|
|
44
|
+
{ kind: 'docs', ref: '.ultra/specs/product.md', summary: 'Observed behavior baseline.' },
|
|
45
|
+
],
|
|
46
|
+
verification: [
|
|
47
|
+
{ name: 'module smoke', command: 'node -e "require(\'./src\')"', status: 'pass', evidence: 'exit 0' },
|
|
48
|
+
],
|
|
49
|
+
unknowns: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('brownfield baseline converges only after current specs, source evidence, verification, and approval', () => {
|
|
54
|
+
const fx = fixture();
|
|
55
|
+
try {
|
|
56
|
+
const started = baselines.startBaseline(fx.db, {
|
|
57
|
+
id: 'project-baseline', project_name: 'fixture', project_type: 'cli', stack: 'node',
|
|
58
|
+
mode: 'brownfield', repository_revision: fx.revision, scope: ['.'],
|
|
59
|
+
}, { rootDir: fx.rootDir });
|
|
60
|
+
assert.equal(started.status, 'adopting');
|
|
61
|
+
|
|
62
|
+
const recorded = baselines.recordBaseline(fx.db, {
|
|
63
|
+
id: started.id, repository_revision: fx.revision, ...adoptionEvidence(),
|
|
64
|
+
}, { rootDir: fx.rootDir });
|
|
65
|
+
assert.match(recorded.spec_refs[0].digest, /^[0-9a-f]{64}$/);
|
|
66
|
+
assert.equal(recorded.evidence[0].summary, 'Current public module entry point.');
|
|
67
|
+
|
|
68
|
+
const converged = baselines.convergeBaseline(fx.db, {
|
|
69
|
+
id: started.id,
|
|
70
|
+
expected_revision: fx.revision,
|
|
71
|
+
approved_by: 'project-owner',
|
|
72
|
+
approval_note: 'The captured baseline matches the current checkout.',
|
|
73
|
+
accept_known_red: false,
|
|
74
|
+
}, { rootDir: fx.rootDir });
|
|
75
|
+
assert.deepEqual(converged.blockers, []);
|
|
76
|
+
assert.equal(converged.ready, true);
|
|
77
|
+
assert.equal(converged.baseline.status, 'ready');
|
|
78
|
+
assert.equal(converged.baseline.approved_by, 'project-owner');
|
|
79
|
+
} finally {
|
|
80
|
+
cleanup(fx);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('baseline convergence records deterministic blockers for missing evidence and unaccepted known-red verification', () => {
|
|
85
|
+
const fx = fixture();
|
|
86
|
+
try {
|
|
87
|
+
baselines.startBaseline(fx.db, {
|
|
88
|
+
id: 'blocked-baseline', project_name: 'fixture', mode: 'brownfield',
|
|
89
|
+
repository_revision: fx.revision,
|
|
90
|
+
}, { rootDir: fx.rootDir });
|
|
91
|
+
baselines.recordBaseline(fx.db, {
|
|
92
|
+
id: 'blocked-baseline',
|
|
93
|
+
repository_revision: fx.revision,
|
|
94
|
+
spec_refs: [{ kind: 'product', path: '.ultra/specs/product.md' }],
|
|
95
|
+
evidence: [],
|
|
96
|
+
verification: [{
|
|
97
|
+
name: 'legacy suite', command: 'npm test', status: 'known_red',
|
|
98
|
+
evidence: 'Two failures predate adoption.', rationale: 'Tracked as existing debt.',
|
|
99
|
+
}],
|
|
100
|
+
unknowns: [{ summary: 'Production authorization behavior is not verified.', blocking: true }],
|
|
101
|
+
}, { rootDir: fx.rootDir });
|
|
102
|
+
|
|
103
|
+
const result = baselines.convergeBaseline(fx.db, {
|
|
104
|
+
id: 'blocked-baseline', expected_revision: fx.revision,
|
|
105
|
+
approved_by: 'project-owner', approval_note: 'Review attempted.', accept_known_red: false,
|
|
106
|
+
}, { rootDir: fx.rootDir });
|
|
107
|
+
assert.equal(result.ready, false);
|
|
108
|
+
assert.equal(result.status, 'blocked');
|
|
109
|
+
assert.ok(result.blockers.includes('BASELINE_SPEC_MISSING:architecture'));
|
|
110
|
+
assert.ok(result.blockers.includes('BASELINE_SOURCE_EVIDENCE_MISSING'));
|
|
111
|
+
assert.ok(result.blockers.includes('BASELINE_KNOWN_RED_NOT_ACCEPTED:legacy suite'));
|
|
112
|
+
assert.ok(result.blockers.includes('BASELINE_UNKNOWN_BLOCKING:Production authorization behavior is not verified.'));
|
|
113
|
+
} finally {
|
|
114
|
+
cleanup(fx);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('baseline stores provider metadata only and detects specification drift after convergence', () => {
|
|
119
|
+
const fx = fixture();
|
|
120
|
+
try {
|
|
121
|
+
baselines.startBaseline(fx.db, {
|
|
122
|
+
id: 'provider-baseline', project_name: 'fixture', mode: 'brownfield',
|
|
123
|
+
repository_revision: fx.revision,
|
|
124
|
+
}, { rootDir: fx.rootDir });
|
|
125
|
+
assert.throws(
|
|
126
|
+
() => baselines.recordBaseline(fx.db, {
|
|
127
|
+
id: 'provider-baseline',
|
|
128
|
+
provider_refs: { memory: { provider: 'cloud-mem', content: 'captured prompt' } },
|
|
129
|
+
}, { rootDir: fx.rootDir }),
|
|
130
|
+
(error) => error.code === 'PROVIDER_CONTENT_FORBIDDEN',
|
|
131
|
+
);
|
|
132
|
+
baselines.recordBaseline(fx.db, {
|
|
133
|
+
id: 'provider-baseline', repository_revision: fx.revision,
|
|
134
|
+
provider_refs: { memory: { provider: 'cloud-mem', reference: 'cmem://fixture', status: 'fresh' } },
|
|
135
|
+
...adoptionEvidence(),
|
|
136
|
+
}, { rootDir: fx.rootDir });
|
|
137
|
+
baselines.convergeBaseline(fx.db, {
|
|
138
|
+
id: 'provider-baseline', expected_revision: fx.revision,
|
|
139
|
+
approved_by: 'project-owner', approval_note: 'Approved current behavior.',
|
|
140
|
+
}, { rootDir: fx.rootDir });
|
|
141
|
+
|
|
142
|
+
fs.appendFileSync(path.join(fx.rootDir, '.ultra', 'specs', 'product.md'), '\nChanged outside convergence.\n');
|
|
143
|
+
const health = baselines.inspectBaseline(fx.db, { rootDir: fx.rootDir });
|
|
144
|
+
assert.equal(health.status, 'fail');
|
|
145
|
+
assert.ok(health.blockers.includes('BASELINE_SPEC_STALE:.ultra/specs/product.md'));
|
|
146
|
+
} finally {
|
|
147
|
+
cleanup(fx);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('re-adoption requires explicit replacement and supersedes the prior ready baseline', () => {
|
|
152
|
+
const fx = fixture();
|
|
153
|
+
try {
|
|
154
|
+
const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
155
|
+
fx.db.prepare(
|
|
156
|
+
`INSERT INTO baselines
|
|
157
|
+
(id, project_name, mode, status, repository_revision, spec_refs_json, evidence_json, verification_json,
|
|
158
|
+
approved_by, approval_note, converged_at)
|
|
159
|
+
VALUES (?, ?, 'brownfield', 'ready', ?, ?, ?, ?, ?, ?, ?)`,
|
|
160
|
+
).run(
|
|
161
|
+
'ready-baseline', 'fixture', fx.revision,
|
|
162
|
+
JSON.stringify([
|
|
163
|
+
{ kind: 'product', path: '.ultra/specs/product.md', digest: digest(path.join(fx.rootDir, '.ultra/specs/product.md')) },
|
|
164
|
+
{ kind: 'architecture', path: '.ultra/specs/architecture.md', digest: digest(path.join(fx.rootDir, '.ultra/specs/architecture.md')) },
|
|
165
|
+
]),
|
|
166
|
+
JSON.stringify([{ kind: 'source', ref: 'src/index.js', summary: 'Current entry point.' }]),
|
|
167
|
+
JSON.stringify([{ name: 'smoke', command: 'node -e "require(\'./src\')"', status: 'pass', evidence: 'exit 0' }]),
|
|
168
|
+
'owner', 'approved', new Date().toISOString(),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
assert.throws(
|
|
172
|
+
() => baselines.startBaseline(fx.db, {
|
|
173
|
+
id: 'replacement', project_name: 'fixture', mode: 'brownfield', repository_revision: fx.revision,
|
|
174
|
+
}, { rootDir: fx.rootDir }),
|
|
175
|
+
(error) => error.code === 'BASELINE_EXISTS',
|
|
176
|
+
);
|
|
177
|
+
const replacement = baselines.startBaseline(fx.db, {
|
|
178
|
+
id: 'replacement', project_name: 'fixture', mode: 'brownfield',
|
|
179
|
+
repository_revision: fx.revision, replace_ready: true,
|
|
180
|
+
}, { rootDir: fx.rootDir });
|
|
181
|
+
assert.equal(replacement.status, 'adopting');
|
|
182
|
+
assert.equal(baselines.readBaseline(fx.db, 'ready-baseline').status, 'superseded');
|
|
183
|
+
const supersededHealth = baselines.inspectBaseline(fx.db, {
|
|
184
|
+
rootDir: fx.rootDir, id: 'ready-baseline',
|
|
185
|
+
});
|
|
186
|
+
assert.ok(supersededHealth.blockers.includes('BASELINE_NOT_READY:superseded'));
|
|
187
|
+
} finally {
|
|
188
|
+
cleanup(fx);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('greenfield baseline derives a stable workspace revision when Git is not initialized', () => {
|
|
193
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-greenfield-baseline-'));
|
|
194
|
+
fs.mkdirSync(path.join(rootDir, '.ultra', 'specs'), { recursive: true });
|
|
195
|
+
fs.writeFileSync(path.join(rootDir, '.ultra', 'specs', 'product.md'), '# Product\n\nAccepted intent.\n');
|
|
196
|
+
fs.writeFileSync(path.join(rootDir, '.ultra', 'specs', 'architecture.md'), '# Architecture\n\nAccepted constraints.\n');
|
|
197
|
+
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
198
|
+
try {
|
|
199
|
+
baselines.startBaseline(db, {
|
|
200
|
+
id: 'greenfield', project_name: 'new-project', mode: 'greenfield', scope: ['.'],
|
|
201
|
+
}, { rootDir });
|
|
202
|
+
const recorded = baselines.recordBaseline(db, {
|
|
203
|
+
id: 'greenfield',
|
|
204
|
+
spec_refs: [
|
|
205
|
+
{ kind: 'product', path: '.ultra/specs/product.md' },
|
|
206
|
+
{ kind: 'architecture', path: '.ultra/specs/architecture.md' },
|
|
207
|
+
],
|
|
208
|
+
evidence: [{ kind: 'docs', ref: '.ultra/specs/product.md', summary: 'Owner-approved product intent.' }],
|
|
209
|
+
verification: [{
|
|
210
|
+
name: 'spec review', command: 'review baseline specifications', status: 'pass',
|
|
211
|
+
evidence: 'Product and architecture contracts agree.',
|
|
212
|
+
}],
|
|
213
|
+
unknowns: [],
|
|
214
|
+
}, { rootDir });
|
|
215
|
+
assert.match(recorded.repository_revision, /^workspace:[0-9a-f]{64}$/);
|
|
216
|
+
const result = baselines.convergeBaseline(db, {
|
|
217
|
+
id: 'greenfield', expected_revision: recorded.repository_revision,
|
|
218
|
+
approved_by: 'owner', approval_note: 'Approved before planning.',
|
|
219
|
+
}, { rootDir });
|
|
220
|
+
assert.equal(result.ready, true);
|
|
221
|
+
} finally {
|
|
222
|
+
closeStateDb(db);
|
|
223
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|