ultra-builder-pro-cli 0.11.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 +42 -1
- package/README.md +21 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +4 -3
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +2 -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 +16 -8
- package/docs/ARCHITECTURE.md +32 -5
- package/docs/PLAN.zh-CN.md +7 -5
- package/docs/ROADMAP.md +4 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- 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 +1 -1
- package/skills/ultra-change/SKILL.md +24 -13
- package/skills/ultra-deliver/SKILL.md +15 -5
- package/skills/ultra-dev/SKILL.md +4 -0
- package/skills/ultra-doctor/SKILL.md +8 -2
- package/skills/ultra-init/SKILL.md +46 -50
- package/skills/ultra-init/references/brownfield-adoption.md +65 -0
- package/skills/ultra-plan/SKILL.md +9 -2
- package/skills/ultra-research/SKILL.md +10 -5
- package/skills/ultra-status/SKILL.md +13 -6
- package/spec/cli-protocol.md +4 -0
- package/spec/mcp-tools.yaml +223 -4
- package/spec/schemas/mcp-tools.schema.json +1 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -8,12 +8,10 @@ const { spawnSync } = require('node:child_process');
|
|
|
8
8
|
const ops = require('./state-ops.cjs');
|
|
9
9
|
const contextSpine = require('./context-spine.cjs');
|
|
10
10
|
const specLearning = require('./spec-learning.cjs');
|
|
11
|
+
const providerRefs = require('./provider-refs.cjs');
|
|
12
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
11
13
|
|
|
12
14
|
const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
13
|
-
const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
|
|
14
|
-
const PROVIDER_FIELDS = new Set([
|
|
15
|
-
'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
|
|
16
|
-
]);
|
|
17
15
|
const CHANGE_PATCH_FIELDS = new Set(['title', 'intent', 'status', 'docs_impact', 'provider_refs']);
|
|
18
16
|
const CHANGE_TRANSITIONS = Object.freeze({
|
|
19
17
|
active: new Set(['active', 'blocked', 'cancelled']),
|
|
@@ -83,29 +81,7 @@ function normalizeDocsImpact(value) {
|
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
function normalizeProviderRefs(value) {
|
|
86
|
-
|
|
87
|
-
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
88
|
-
throw new ChangeWorkflowError('VALIDATION_ERROR', 'provider_refs must be an object');
|
|
89
|
-
}
|
|
90
|
-
const out = {};
|
|
91
|
-
for (const [kind, ref] of Object.entries(value)) {
|
|
92
|
-
if (!PROVIDER_KINDS.has(kind) || !ref || typeof ref !== 'object' || Array.isArray(ref)) {
|
|
93
|
-
throw new ChangeWorkflowError('VALIDATION_ERROR', `invalid external provider reference: ${kind}`);
|
|
94
|
-
}
|
|
95
|
-
for (const key of Object.keys(ref)) {
|
|
96
|
-
if (!PROVIDER_FIELDS.has(key)) {
|
|
97
|
-
throw new ChangeWorkflowError(
|
|
98
|
-
'PROVIDER_CONTENT_FORBIDDEN',
|
|
99
|
-
`provider_refs.${kind}.${key} is not metadata; provider content remains external`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
|
|
104
|
-
throw new ChangeWorkflowError('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
|
|
105
|
-
}
|
|
106
|
-
out[kind] = { ...ref };
|
|
107
|
-
}
|
|
108
|
-
return out;
|
|
84
|
+
return providerRefs.normalizeProviderRefs(value, ChangeWorkflowError);
|
|
109
85
|
}
|
|
110
86
|
|
|
111
87
|
function gitHead(rootDir) {
|
|
@@ -254,6 +230,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
254
230
|
readiness: spine.readiness,
|
|
255
231
|
context: spine.context,
|
|
256
232
|
execution_contract: spine.execution_contract,
|
|
233
|
+
baseline: spine.baseline,
|
|
257
234
|
resume: spine.resume,
|
|
258
235
|
providers,
|
|
259
236
|
provider_boundary: 'metadata references only; memory and code graph content remain external',
|
|
@@ -504,6 +481,13 @@ function evidenceBlockers(change, evidence, tasks) {
|
|
|
504
481
|
return blockers;
|
|
505
482
|
}
|
|
506
483
|
|
|
484
|
+
function baselineConvergenceBlockers(db) {
|
|
485
|
+
const baseline = baselines.readBaseline(db);
|
|
486
|
+
if (!baseline) return ['BASELINE_MISSING'];
|
|
487
|
+
if (baseline.status !== 'ready') return [`BASELINE_NOT_READY:${baseline.status}`];
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
|
|
507
491
|
function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
508
492
|
const change = readChange(db, input.id);
|
|
509
493
|
if (!change) throw new ChangeWorkflowError('CHANGE_NOT_FOUND', `change ${input.id} not found`);
|
|
@@ -512,6 +496,7 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
512
496
|
}
|
|
513
497
|
const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
|
|
514
498
|
const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
|
|
499
|
+
for (const blocker of baselineConvergenceBlockers(db)) blockers.add(blocker);
|
|
515
500
|
if (tasks.length === 0) blockers.add('NO_TASKS');
|
|
516
501
|
if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
|
|
517
502
|
if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
|
|
@@ -671,6 +656,18 @@ function archiveChange(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
671
656
|
});
|
|
672
657
|
db.prepare("UPDATE artifacts SET status = 'archived', updated_at = ? WHERE change_id = ?")
|
|
673
658
|
.run(nowIso(), change.id);
|
|
659
|
+
baselines.reconcileBaseline(db, {
|
|
660
|
+
baseline_updates: updates,
|
|
661
|
+
change_id: change.id,
|
|
662
|
+
}, { rootDir });
|
|
663
|
+
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
664
|
+
if (baselineHealth.blockers.length > 0) {
|
|
665
|
+
throw new ChangeWorkflowError(
|
|
666
|
+
'BASELINE_RECONCILIATION_INCOMPLETE',
|
|
667
|
+
`baseline reconciliation remains incomplete: ${baselineHealth.blockers.join(', ')}`,
|
|
668
|
+
{ blockers: baselineHealth.blockers },
|
|
669
|
+
);
|
|
670
|
+
}
|
|
674
671
|
ops.appendEventInTx(db, {
|
|
675
672
|
type: 'change_archived', change_id: change.id,
|
|
676
673
|
payload: { archive_path: relative, baseline_updates: updates, no_baseline_change_reason: noChangeReason || null },
|
|
@@ -10,6 +10,7 @@ const { execFileSync } = require('node:child_process');
|
|
|
10
10
|
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
11
11
|
const ops = require('./state-ops.cjs');
|
|
12
12
|
const changes = require('./change-workflow.cjs');
|
|
13
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
13
14
|
|
|
14
15
|
function fixture() {
|
|
15
16
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-change-'));
|
|
@@ -20,6 +21,11 @@ function fixture() {
|
|
|
20
21
|
execFileSync('git', ['add', 'README.md'], { cwd: rootDir });
|
|
21
22
|
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
|
|
22
23
|
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
24
|
+
db.prepare(
|
|
25
|
+
`INSERT INTO baselines
|
|
26
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
27
|
+
VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
|
|
28
|
+
).run(new Date().toISOString());
|
|
23
29
|
return { rootDir, db };
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -179,14 +185,82 @@ test('convergeChange blocks incomplete work and marks a fully evidenced standard
|
|
|
179
185
|
}
|
|
180
186
|
});
|
|
181
187
|
|
|
188
|
+
test('convergeChange requires an approved baseline after active work has remained executable', () => {
|
|
189
|
+
const fx = fixture();
|
|
190
|
+
try {
|
|
191
|
+
changes.createChange(fx.db, {
|
|
192
|
+
id: 'chg-baseline-gate', title: 'Keep runtime work moving', kind: 'quick',
|
|
193
|
+
intent: 'Defer baseline closure to convergence without losing implementation progress.',
|
|
194
|
+
docs_impact: { status: 'none', files: [], rationale: 'Internal workflow behavior only.' },
|
|
195
|
+
}, { rootDir: fx.rootDir });
|
|
196
|
+
ops.createTask(fx.db, {
|
|
197
|
+
id: 'baseline-gate-task', title: 'Exercise convergence boundary', type: 'feature', priority: 'P0',
|
|
198
|
+
change_id: 'chg-baseline-gate',
|
|
199
|
+
});
|
|
200
|
+
ops.updateTaskStatus(fx.db, 'baseline-gate-task', 'in_progress');
|
|
201
|
+
ops.updateTaskStatus(fx.db, 'baseline-gate-task', 'completed');
|
|
202
|
+
changes.compileContext(fx.db, {
|
|
203
|
+
id: 'chg-baseline-gate', ...executionContext('baseline-gate-task'),
|
|
204
|
+
}, { rootDir: fx.rootDir });
|
|
205
|
+
fx.db.prepare('DELETE FROM baselines').run();
|
|
206
|
+
|
|
207
|
+
const result = changes.convergeChange(fx.db, {
|
|
208
|
+
id: 'chg-baseline-gate',
|
|
209
|
+
evidence: [
|
|
210
|
+
{ category: 'diff', status: 'pass', evidence: 'diff reviewed' },
|
|
211
|
+
{ category: 'tests', status: 'pass', evidence: 'workflow regression passed', seam: 'convergence gate' },
|
|
212
|
+
{ category: 'spec', status: 'pass', evidence: 'change remains aligned' },
|
|
213
|
+
],
|
|
214
|
+
}, { rootDir: fx.rootDir });
|
|
215
|
+
|
|
216
|
+
assert.equal(result.ready, false);
|
|
217
|
+
assert.ok(result.blockers.includes('BASELINE_MISSING'));
|
|
218
|
+
} finally {
|
|
219
|
+
cleanup(fx);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
182
223
|
test('archiveChange moves a ready change into immutable history and records baseline reconciliation', () => {
|
|
183
224
|
const fx = fixture();
|
|
184
225
|
try {
|
|
226
|
+
fs.mkdirSync(path.join(fx.rootDir, '.ultra', 'specs'), { recursive: true });
|
|
227
|
+
fs.writeFileSync(path.join(fx.rootDir, '.ultra', 'specs', 'product.md'), '# Product\n\nCurrent behavior.\n');
|
|
228
|
+
fs.writeFileSync(path.join(fx.rootDir, '.ultra', 'specs', 'architecture.md'), '# Architecture\n\nCurrent boundary.\n');
|
|
229
|
+
const fileDigest = (file) => require('node:crypto').createHash('sha256')
|
|
230
|
+
.update(fs.readFileSync(file)).digest('hex');
|
|
231
|
+
fx.db.prepare(
|
|
232
|
+
`UPDATE baselines SET mode = 'brownfield', repository_revision = ?, spec_refs_json = ?
|
|
233
|
+
WHERE id = 'test-baseline'`,
|
|
234
|
+
).run(
|
|
235
|
+
execFileSync('git', ['rev-parse', 'HEAD'], { cwd: fx.rootDir, encoding: 'utf8' }).trim(),
|
|
236
|
+
JSON.stringify([
|
|
237
|
+
{
|
|
238
|
+
kind: 'product', path: '.ultra/specs/product.md',
|
|
239
|
+
digest: fileDigest(path.join(fx.rootDir, '.ultra', 'specs', 'product.md')),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
kind: 'architecture', path: '.ultra/specs/architecture.md',
|
|
243
|
+
digest: fileDigest(path.join(fx.rootDir, '.ultra', 'specs', 'architecture.md')),
|
|
244
|
+
},
|
|
245
|
+
]),
|
|
246
|
+
);
|
|
185
247
|
changes.createChange(fx.db, {
|
|
186
248
|
id: 'chg-archive', title: 'Archive verified change', kind: 'quick',
|
|
187
249
|
intent: 'Preserve the completed change as project history.',
|
|
188
250
|
docs_impact: { status: 'none', files: [], rationale: 'Internal test-only behavior.' },
|
|
189
251
|
}, { rootDir: fx.rootDir });
|
|
252
|
+
fs.appendFileSync(
|
|
253
|
+
path.join(fx.rootDir, '.ultra', 'specs', 'product.md'),
|
|
254
|
+
'\nBehavior learned and approved by this change.\n',
|
|
255
|
+
);
|
|
256
|
+
execFileSync('git', ['add', '.ultra/specs/product.md'], { cwd: fx.rootDir });
|
|
257
|
+
execFileSync('git', ['commit', '-q', '-m', 'update baseline behavior'], { cwd: fx.rootDir });
|
|
258
|
+
const changedHead = execFileSync(
|
|
259
|
+
'git', ['rev-parse', 'HEAD'], { cwd: fx.rootDir, encoding: 'utf8' },
|
|
260
|
+
).trim();
|
|
261
|
+
const drift = baselines.inspectBaseline(fx.db, { rootDir: fx.rootDir });
|
|
262
|
+
assert.ok(drift.blockers.includes('BASELINE_HEAD_STALE'));
|
|
263
|
+
assert.ok(drift.blockers.includes('BASELINE_SPEC_STALE:.ultra/specs/product.md'));
|
|
190
264
|
ops.createTask(fx.db, {
|
|
191
265
|
id: 'archive-task', title: 'Complete archive path', type: 'bugfix', priority: 'P1',
|
|
192
266
|
change_id: 'chg-archive',
|
|
@@ -214,7 +288,7 @@ test('archiveChange moves a ready change into immutable history and records base
|
|
|
214
288
|
|
|
215
289
|
const archived = changes.archiveChange(fx.db, {
|
|
216
290
|
id: 'chg-archive', summary: 'Verified quick fix archived.',
|
|
217
|
-
|
|
291
|
+
baseline_updates: ['.ultra/specs/product.md'],
|
|
218
292
|
}, { rootDir: fx.rootDir });
|
|
219
293
|
assert.equal(archived.change.status, 'archived');
|
|
220
294
|
assert.ok(fs.existsSync(archived.archive_path));
|
|
@@ -228,6 +302,84 @@ test('archiveChange moves a ready change into immutable history and records base
|
|
|
228
302
|
assert.doesNotMatch(artifact.path, /changes\/active/);
|
|
229
303
|
assert.ok(fs.existsSync(path.join(fx.rootDir, artifact.path)), artifact.path);
|
|
230
304
|
}
|
|
305
|
+
const reconciliation = fx.db.prepare(
|
|
306
|
+
"SELECT type, payload_json FROM events WHERE type = 'baseline_reconciled' ORDER BY id DESC LIMIT 1",
|
|
307
|
+
).get();
|
|
308
|
+
assert.equal(reconciliation.type, 'baseline_reconciled');
|
|
309
|
+
assert.deepEqual(JSON.parse(reconciliation.payload_json).baseline_updates, ['.ultra/specs/product.md']);
|
|
310
|
+
const reconciled = baselines.inspectBaseline(fx.db, { rootDir: fx.rootDir });
|
|
311
|
+
assert.equal(reconciled.status, 'pass');
|
|
312
|
+
assert.equal(reconciled.baseline.repository_revision, changedHead);
|
|
313
|
+
} finally {
|
|
314
|
+
cleanup(fx);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('archiveChange rolls back when declared reconciliation leaves a tracked specification stale', () => {
|
|
319
|
+
const fx = fixture();
|
|
320
|
+
try {
|
|
321
|
+
const specPath = path.join(fx.rootDir, '.ultra', 'specs', 'product.md');
|
|
322
|
+
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
|
323
|
+
fs.writeFileSync(specPath, '# Product\n\nOriginal behavior.\n');
|
|
324
|
+
const originalHead = execFileSync(
|
|
325
|
+
'git', ['rev-parse', 'HEAD'], { cwd: fx.rootDir, encoding: 'utf8' },
|
|
326
|
+
).trim();
|
|
327
|
+
const originalDigest = require('node:crypto').createHash('sha256')
|
|
328
|
+
.update(fs.readFileSync(specPath)).digest('hex');
|
|
329
|
+
fx.db.prepare(
|
|
330
|
+
`UPDATE baselines SET mode = 'brownfield', repository_revision = ?, spec_refs_json = ?
|
|
331
|
+
WHERE id = 'test-baseline'`,
|
|
332
|
+
).run(originalHead, JSON.stringify([
|
|
333
|
+
{ kind: 'product', path: '.ultra/specs/product.md', digest: originalDigest },
|
|
334
|
+
]));
|
|
335
|
+
changes.createChange(fx.db, {
|
|
336
|
+
id: 'chg-reconcile-rollback', title: 'Reject incomplete reconciliation', kind: 'quick',
|
|
337
|
+
intent: 'Keep the active change recoverable until every tracked specification is reconciled.',
|
|
338
|
+
docs_impact: { status: 'none', files: [], rationale: 'Workflow integrity only.' },
|
|
339
|
+
}, { rootDir: fx.rootDir });
|
|
340
|
+
fs.appendFileSync(specPath, '\nChanged behavior that must be declared.\n');
|
|
341
|
+
execFileSync('git', ['add', '.ultra/specs/product.md'], { cwd: fx.rootDir });
|
|
342
|
+
execFileSync('git', ['commit', '-q', '-m', 'change tracked product spec'], { cwd: fx.rootDir });
|
|
343
|
+
ops.createTask(fx.db, {
|
|
344
|
+
id: 'reconcile-rollback-task', title: 'Exercise archive rollback', type: 'feature', priority: 'P0',
|
|
345
|
+
change_id: 'chg-reconcile-rollback',
|
|
346
|
+
});
|
|
347
|
+
ops.updateTaskStatus(fx.db, 'reconcile-rollback-task', 'in_progress');
|
|
348
|
+
ops.updateTaskStatus(fx.db, 'reconcile-rollback-task', 'completed');
|
|
349
|
+
changes.compileContext(fx.db, {
|
|
350
|
+
id: 'chg-reconcile-rollback', ...executionContext('reconcile-rollback-task'),
|
|
351
|
+
}, { rootDir: fx.rootDir });
|
|
352
|
+
const converged = changes.convergeChange(fx.db, {
|
|
353
|
+
id: 'chg-reconcile-rollback',
|
|
354
|
+
evidence: [
|
|
355
|
+
{ category: 'diff', status: 'pass', evidence: 'archive diff reviewed' },
|
|
356
|
+
{
|
|
357
|
+
category: 'tests', status: 'pass', evidence: 'archive rollback regression passed',
|
|
358
|
+
seam: 'archive reconciliation boundary',
|
|
359
|
+
},
|
|
360
|
+
{ category: 'spec', status: 'pass', evidence: 'tracked specification change identified' },
|
|
361
|
+
],
|
|
362
|
+
}, { rootDir: fx.rootDir });
|
|
363
|
+
assert.equal(converged.ready, true);
|
|
364
|
+
|
|
365
|
+
assert.throws(
|
|
366
|
+
() => changes.archiveChange(fx.db, {
|
|
367
|
+
id: 'chg-reconcile-rollback', summary: 'Attempt an incomplete archive.',
|
|
368
|
+
no_baseline_change_reason: 'No baseline update declared.',
|
|
369
|
+
}, { rootDir: fx.rootDir }),
|
|
370
|
+
(error) => error.code === 'BASELINE_RECONCILIATION_INCOMPLETE'
|
|
371
|
+
&& error.details.blockers.includes('BASELINE_SPEC_STALE:.ultra/specs/product.md'),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const active = changes.readChange(fx.db, 'chg-reconcile-rollback');
|
|
375
|
+
assert.equal(active.status, 'ready');
|
|
376
|
+
assert.ok(fs.existsSync(path.join(fx.rootDir, active.artifact_root)));
|
|
377
|
+
assert.equal(fs.existsSync(path.join(fx.rootDir, active.artifact_root, 'archive-summary.md')), false);
|
|
378
|
+
assert.equal(baselines.readBaseline(fx.db).repository_revision, originalHead);
|
|
379
|
+
const reconciliationEvents = fx.db.prepare(
|
|
380
|
+
"SELECT COUNT(*) AS count FROM events WHERE type = 'baseline_reconciled' AND change_id = ?",
|
|
381
|
+
).get('chg-reconcile-rollback');
|
|
382
|
+
assert.equal(reconciliationEvents.count, 0);
|
|
231
383
|
} finally {
|
|
232
384
|
cleanup(fx);
|
|
233
385
|
}
|
|
@@ -447,12 +599,12 @@ test('compileContext v2 persists role-scoped context, budget, execution contract
|
|
|
447
599
|
}
|
|
448
600
|
});
|
|
449
601
|
|
|
450
|
-
test('compileContext blocks a required missing ref
|
|
602
|
+
test('compileContext blocks a required missing ref', () => {
|
|
451
603
|
const fx = fixture();
|
|
452
604
|
try {
|
|
453
605
|
changes.createChange(fx.db, {
|
|
454
606
|
id: 'chg-context-budget', title: 'Bound context packet', kind: 'quick',
|
|
455
|
-
intent: 'Fail closed when required context is missing
|
|
607
|
+
intent: 'Fail closed when required context is missing while reporting size pressure.',
|
|
456
608
|
}, { rootDir: fx.rootDir });
|
|
457
609
|
ops.createTask(fx.db, {
|
|
458
610
|
id: 'budget-task', title: 'Compile bounded packet', type: 'bugfix', priority: 'P0',
|
|
@@ -466,14 +618,58 @@ test('compileContext blocks a required missing ref or an over-budget role packet
|
|
|
466
618
|
{ ref: 'missing-contract.md', kind: 'spec', reason: 'Required contract', required: true },
|
|
467
619
|
{ ref: 'README.md', kind: 'source', reason: 'Current implementation', required: true },
|
|
468
620
|
],
|
|
469
|
-
budget: { max_tokens:
|
|
621
|
+
budget: { max_tokens: 2_000, max_files: 4 },
|
|
470
622
|
}),
|
|
471
623
|
}, { rootDir: fx.rootDir });
|
|
472
624
|
|
|
473
625
|
assert.equal(compiled.manifest.readiness.status, 'blocked');
|
|
474
626
|
assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_REQUIRED_REF_MISSING:missing-contract.md'));
|
|
475
|
-
|
|
476
|
-
|
|
627
|
+
} finally {
|
|
628
|
+
cleanup(fx);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('an incident can proceed with four necessary large files while context budgets remain advisory', () => {
|
|
633
|
+
const fx = fixture();
|
|
634
|
+
try {
|
|
635
|
+
const refs = [];
|
|
636
|
+
for (let index = 1; index <= 4; index += 1) {
|
|
637
|
+
const ref = `incident-runtime-${index}.cjs`;
|
|
638
|
+
fs.writeFileSync(path.join(fx.rootDir, ref), 'x'.repeat(8_000));
|
|
639
|
+
refs.push({
|
|
640
|
+
ref, kind: 'source', reason: `Required incident runtime boundary ${index}`, required: true,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
changes.createChange(fx.db, {
|
|
644
|
+
id: 'chg-incident-context', title: 'Recover IM runtime', kind: 'incident',
|
|
645
|
+
intent: 'Keep an urgent root-cause fix executable when its required files exceed guidance.',
|
|
646
|
+
}, { rootDir: fx.rootDir });
|
|
647
|
+
ops.createTask(fx.db, {
|
|
648
|
+
id: 'incident-context-task', title: 'Repair IM terminal transition', type: 'bugfix', priority: 'P0',
|
|
649
|
+
change_id: 'chg-incident-context',
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const compiled = changes.compileContext(fx.db, {
|
|
653
|
+
id: 'chg-incident-context',
|
|
654
|
+
...executionContext('incident-context-task', {
|
|
655
|
+
context_refs: refs,
|
|
656
|
+
budget: { max_tokens: 100, max_files: 2 },
|
|
657
|
+
execution_contract: {
|
|
658
|
+
slice_kind: 'tracer_bullet',
|
|
659
|
+
public_seam: 'IM terminal transition',
|
|
660
|
+
verification_command: 'node --test im-terminal.test.cjs',
|
|
661
|
+
context_budget_percent: 80,
|
|
662
|
+
},
|
|
663
|
+
}),
|
|
664
|
+
}, { rootDir: fx.rootDir });
|
|
665
|
+
|
|
666
|
+
assert.equal(compiled.manifest.readiness.status, 'ready');
|
|
667
|
+
assert.deepEqual(compiled.manifest.readiness.blockers, []);
|
|
668
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('CONTEXT_FILE_BUDGET_EXCEEDED'));
|
|
669
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('CONTEXT_TOKEN_BUDGET_EXCEEDED'));
|
|
670
|
+
assert.ok(compiled.manifest.readiness.warnings.includes('EXECUTION_CONTEXT_BUDGET_ADVISORY'));
|
|
671
|
+
assert.equal(compiled.manifest.context.file_count, 4);
|
|
672
|
+
assert.ok(compiled.manifest.context.token_estimate > 100);
|
|
477
673
|
} finally {
|
|
478
674
|
cleanup(fx);
|
|
479
675
|
}
|