ultra-builder-pro-cli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +41 -228
- package/.ultra-template/specs/product.md +44 -200
- package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
- package/.ultra-template/tasks/tasks.json +6 -0
- package/CHANGELOG.md +94 -1
- package/README.md +37 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +5 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +10 -2
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/opencode.test.cjs +36 -1
- package/commands/ultra-init.md +3 -3
- package/docs/AGENT-CONTEXT.md +17 -8
- package/docs/ARCHITECTURE.md +51 -8
- package/docs/PLAN.zh-CN.md +9 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
- package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
- package/hooks/README.md +4 -3
- package/hooks/adapters/codex.py +1 -1
- package/hooks/adapters/kimi.py +1 -1
- package/hooks/context_spine.py +153 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +781 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
- package/mcp-server/lib/change-workflow.cjs +256 -85
- package/mcp-server/lib/change-workflow.test.cjs +436 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +20 -0
- package/mcp-server/lib/doctor.cjs +28 -1
- package/mcp-server/lib/doctor.test.cjs +63 -1
- package/mcp-server/lib/init-project.cjs +334 -16
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +202 -21
- package/mcp-server/lib/state-db.test.cjs +140 -3
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +62 -6
- package/mcp-server/tests/init-project.test.cjs +216 -9
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +478 -3
- package/mcp-server/tests/state-db.test.cjs +23 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +32 -13
- package/skills/ultra-deliver/SKILL.md +19 -5
- package/skills/ultra-dev/SKILL.md +8 -2
- package/skills/ultra-doctor/SKILL.md +30 -9
- package/skills/ultra-init/SKILL.md +107 -66
- package/skills/ultra-init/references/brownfield-adoption.md +93 -0
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-research/SKILL.md +17 -5
- package/skills/ultra-status/SKILL.md +15 -6
- package/spec/cli-protocol.md +15 -1
- package/spec/mcp-tools.yaml +316 -11
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +44 -2
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +29 -2
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +11 -2
- package/ultra-tools/status-command.test.cjs +49 -0
- package/ultra-tools/system-command.test.cjs +256 -0
|
@@ -14,12 +14,36 @@ const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio
|
|
|
14
14
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
15
15
|
const SERVER = path.join(REPO_ROOT, 'mcp-server', 'server.cjs');
|
|
16
16
|
const PACKAGE_VERSION = require(path.join(REPO_ROOT, 'package.json')).version;
|
|
17
|
+
const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
|
|
18
|
+
const { dispatchTool } = require('../server.cjs');
|
|
19
|
+
const changes = require('../lib/change-workflow.cjs');
|
|
20
|
+
const ops = require('../lib/state-ops.cjs');
|
|
17
21
|
|
|
18
22
|
function tmpProject() {
|
|
19
23
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-mcp-'));
|
|
20
24
|
return { dir, dbPath: path.join(dir, '.ultra', 'state.db') };
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
function seedReadyBaseline(project) {
|
|
28
|
+
const { db } = initStateDb(project.dbPath);
|
|
29
|
+
db.prepare(
|
|
30
|
+
`INSERT OR IGNORE INTO baselines
|
|
31
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
32
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'test fixture', ?)`,
|
|
33
|
+
).run(new Date().toISOString());
|
|
34
|
+
closeStateDb(db);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function seedBaseline(project, { mode, status }) {
|
|
38
|
+
const { db } = initStateDb(project.dbPath);
|
|
39
|
+
db.prepare(
|
|
40
|
+
`INSERT OR REPLACE INTO baselines
|
|
41
|
+
(id, project_name, mode, status)
|
|
42
|
+
VALUES ('test-baseline', 'fixture', ?, ?)`,
|
|
43
|
+
).run(mode, status);
|
|
44
|
+
closeStateDb(db);
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
async function withClient({ dir, dbPath }, fn, extraEnv = {}) {
|
|
24
48
|
const transport = new StdioClientTransport({
|
|
25
49
|
command: process.execPath,
|
|
@@ -67,6 +91,29 @@ function expectError(result) {
|
|
|
67
91
|
return JSON.parse(result.content[0].text).error;
|
|
68
92
|
}
|
|
69
93
|
|
|
94
|
+
function fakePrdClient(taskId) {
|
|
95
|
+
const client = {
|
|
96
|
+
calls: 0,
|
|
97
|
+
provider: 'anthropic',
|
|
98
|
+
model: 'test-model',
|
|
99
|
+
async completeJson() {
|
|
100
|
+
client.calls += 1;
|
|
101
|
+
const json = {
|
|
102
|
+
tasks: [{
|
|
103
|
+
id: taskId,
|
|
104
|
+
title: `Implement ${taskId}`,
|
|
105
|
+
type: 'feature',
|
|
106
|
+
priority: 'P1',
|
|
107
|
+
deps: [],
|
|
108
|
+
files_modified: [`src/${taskId}.js`],
|
|
109
|
+
}],
|
|
110
|
+
};
|
|
111
|
+
return { json, raw: JSON.stringify(json), usage: {}, model: client.model, provider: client.provider };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
return client;
|
|
115
|
+
}
|
|
116
|
+
|
|
70
117
|
test('listTools returns workflow tools and exposes no Ultra memory API', async () => {
|
|
71
118
|
const proj = tmpProject();
|
|
72
119
|
try {
|
|
@@ -74,6 +121,10 @@ test('listTools returns workflow tools and exposes no Ultra memory API', async (
|
|
|
74
121
|
const list = await client.listTools();
|
|
75
122
|
const names = list.tools.map((t) => t.name).sort();
|
|
76
123
|
assert.deepEqual(names, [
|
|
124
|
+
'baseline.converge',
|
|
125
|
+
'baseline.get',
|
|
126
|
+
'baseline.record',
|
|
127
|
+
'baseline.start',
|
|
77
128
|
'change.archive',
|
|
78
129
|
'change.breadcrumb',
|
|
79
130
|
'change.context',
|
|
@@ -118,9 +169,78 @@ test('listTools returns workflow tools and exposes no Ultra memory API', async (
|
|
|
118
169
|
}
|
|
119
170
|
});
|
|
120
171
|
|
|
172
|
+
test('baseline MCP tools adopt and converge an existing checkout without storing provider payloads', async () => {
|
|
173
|
+
const proj = tmpProject();
|
|
174
|
+
try {
|
|
175
|
+
fs.mkdirSync(path.join(proj.dir, '.ultra', 'specs'), { recursive: true });
|
|
176
|
+
fs.mkdirSync(path.join(proj.dir, 'src'), { recursive: true });
|
|
177
|
+
fs.writeFileSync(path.join(proj.dir, '.ultra', 'specs', 'product.md'), '# Product\n\nObserved behavior.\n');
|
|
178
|
+
fs.writeFileSync(path.join(proj.dir, '.ultra', 'specs', 'architecture.md'), '# Architecture\n\nCurrent boundary.\n');
|
|
179
|
+
fs.writeFileSync(path.join(proj.dir, 'src', 'index.js'), 'module.exports = true;\n');
|
|
180
|
+
require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: proj.dir });
|
|
181
|
+
require('node:child_process').execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: proj.dir });
|
|
182
|
+
require('node:child_process').execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: proj.dir });
|
|
183
|
+
require('node:child_process').execFileSync('git', ['add', '.'], { cwd: proj.dir });
|
|
184
|
+
require('node:child_process').execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: proj.dir });
|
|
185
|
+
const revision = require('node:child_process').execFileSync(
|
|
186
|
+
'git', ['rev-parse', 'HEAD'], { cwd: proj.dir, encoding: 'utf8' },
|
|
187
|
+
).trim();
|
|
188
|
+
|
|
189
|
+
await withClient(proj, async (client) => {
|
|
190
|
+
const started = readToolPayload(await client.callTool({
|
|
191
|
+
name: 'baseline.start',
|
|
192
|
+
arguments: {
|
|
193
|
+
id: 'mcp-baseline', project_name: 'fixture', mode: 'brownfield',
|
|
194
|
+
repository_revision: revision, scope: ['.'],
|
|
195
|
+
},
|
|
196
|
+
}));
|
|
197
|
+
assert.equal(started.baseline.status, 'adopting');
|
|
198
|
+
|
|
199
|
+
const recorded = readToolPayload(await client.callTool({
|
|
200
|
+
name: 'baseline.record',
|
|
201
|
+
arguments: {
|
|
202
|
+
id: 'mcp-baseline', repository_revision: revision,
|
|
203
|
+
spec_refs: [
|
|
204
|
+
{ kind: 'product', path: '.ultra/specs/product.md' },
|
|
205
|
+
{ kind: 'architecture', path: '.ultra/specs/architecture.md' },
|
|
206
|
+
],
|
|
207
|
+
evidence: [{ kind: 'source', ref: 'src/index.js', summary: 'Current entry point.' }],
|
|
208
|
+
verification: [{
|
|
209
|
+
name: 'smoke', command: 'node -e "require(\'./src\')"', status: 'pass', evidence: 'exit 0',
|
|
210
|
+
}],
|
|
211
|
+
unknowns: [],
|
|
212
|
+
provider_refs: {
|
|
213
|
+
code_graph: { provider: 'codebase-memory-mcp', project: 'fixture', status: 'fresh' },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
}));
|
|
217
|
+
assert.match(recorded.baseline.spec_refs[0].digest, /^[0-9a-f]{64}$/);
|
|
218
|
+
|
|
219
|
+
const converged = readToolPayload(await client.callTool({
|
|
220
|
+
name: 'baseline.converge',
|
|
221
|
+
arguments: {
|
|
222
|
+
id: 'mcp-baseline', expected_revision: revision, approved_by: 'owner',
|
|
223
|
+
approval_note: 'The baseline matches the current checkout.', accept_known_red: false,
|
|
224
|
+
},
|
|
225
|
+
}));
|
|
226
|
+
assert.equal(converged.ready, true);
|
|
227
|
+
assert.equal(converged.baseline.status, 'ready');
|
|
228
|
+
|
|
229
|
+
const got = readToolPayload(await client.callTool({
|
|
230
|
+
name: 'baseline.get', arguments: {},
|
|
231
|
+
}));
|
|
232
|
+
assert.equal(got.baseline.id, 'mcp-baseline');
|
|
233
|
+
assert.equal(JSON.stringify(got.baseline).includes('captured prompt'), false);
|
|
234
|
+
});
|
|
235
|
+
} finally {
|
|
236
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
121
240
|
test('change.create + change.context expose a continuous change unit with external provider references', async () => {
|
|
122
241
|
const proj = tmpProject();
|
|
123
242
|
try {
|
|
243
|
+
seedReadyBaseline(proj);
|
|
124
244
|
fs.writeFileSync(path.join(proj.dir, 'README.md'), '# project\n');
|
|
125
245
|
await withClient(proj, async (client) => {
|
|
126
246
|
const created = await client.callTool({
|
|
@@ -172,6 +292,7 @@ test('change.create + change.context expose a continuous change unit with extern
|
|
|
172
292
|
test('spec-learning MCP tools persist an approval-gated candidate and projection', async () => {
|
|
173
293
|
const proj = tmpProject();
|
|
174
294
|
try {
|
|
295
|
+
seedReadyBaseline(proj);
|
|
175
296
|
await withClient(proj, async (client) => {
|
|
176
297
|
await client.callTool({
|
|
177
298
|
name: 'change.create',
|
|
@@ -233,6 +354,7 @@ test('spec-learning MCP tools persist an approval-gated candidate and projection
|
|
|
233
354
|
test('system.doctor returns structured provider ownership and projection health', async () => {
|
|
234
355
|
const proj = tmpProject();
|
|
235
356
|
try {
|
|
357
|
+
seedReadyBaseline(proj);
|
|
236
358
|
await withClient(proj, async (client) => {
|
|
237
359
|
await client.callTool({
|
|
238
360
|
name: 'task.create',
|
|
@@ -273,6 +395,7 @@ test('published MCP contract contains exactly the live server tools', () => {
|
|
|
273
395
|
test('task.create + task.get round trip via MCP', async () => {
|
|
274
396
|
const proj = tmpProject();
|
|
275
397
|
try {
|
|
398
|
+
seedReadyBaseline(proj);
|
|
276
399
|
await withClient(proj, async (client) => {
|
|
277
400
|
const created = await client.callTool({
|
|
278
401
|
name: 'task.create',
|
|
@@ -293,6 +416,226 @@ test('task.create + task.get round trip via MCP', async () => {
|
|
|
293
416
|
}
|
|
294
417
|
});
|
|
295
418
|
|
|
419
|
+
test('task.create requires ready baseline authority or an already-authorized active change', async () => {
|
|
420
|
+
for (const baseline of [
|
|
421
|
+
null,
|
|
422
|
+
{ mode: 'greenfield', status: 'draft' },
|
|
423
|
+
{ mode: 'migrated', status: 'adopting' },
|
|
424
|
+
]) {
|
|
425
|
+
const proj = tmpProject();
|
|
426
|
+
try {
|
|
427
|
+
if (baseline) seedBaseline(proj, baseline);
|
|
428
|
+
await withClient(proj, async (client) => {
|
|
429
|
+
const result = await client.callTool({
|
|
430
|
+
name: 'task.create',
|
|
431
|
+
arguments: {
|
|
432
|
+
id: `blocked-${baseline?.mode || 'missing'}`,
|
|
433
|
+
title: 'Must wait for baseline readiness', type: 'feature', priority: 'P1',
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
assert.equal(expectError(result).code, 'BASELINE_NOT_READY');
|
|
437
|
+
});
|
|
438
|
+
} finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const incidentProject = tmpProject();
|
|
442
|
+
try {
|
|
443
|
+
seedBaseline(incidentProject, { mode: 'brownfield', status: 'adopting' });
|
|
444
|
+
await withClient(incidentProject, async (client) => {
|
|
445
|
+
const change = await client.callTool({
|
|
446
|
+
name: 'change.create',
|
|
447
|
+
arguments: {
|
|
448
|
+
id: 'incident-task-bypass', title: 'Restore production', kind: 'incident',
|
|
449
|
+
intent: 'Repair the urgent production path while adoption remains incomplete.',
|
|
450
|
+
baseline_bypass: {
|
|
451
|
+
reason: 'Production recovery cannot wait for baseline convergence.',
|
|
452
|
+
approved_by: 'incident-commander',
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
assert.equal(readToolPayload(change).change.id, 'incident-task-bypass');
|
|
457
|
+
const task = await client.callTool({
|
|
458
|
+
name: 'task.create',
|
|
459
|
+
arguments: {
|
|
460
|
+
id: 'authorized-incident-task', title: 'Repair the production path',
|
|
461
|
+
type: 'bugfix', priority: 'P0', change_id: 'incident-task-bypass',
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
assert.equal(readToolPayload(task).id, 'authorized-incident-task');
|
|
465
|
+
});
|
|
466
|
+
} finally { fs.rmSync(incidentProject.dir, { recursive: true, force: true }); }
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('task.create enforces terminal-change and parent ownership invariants at the MCP boundary', async () => {
|
|
470
|
+
const proj = tmpProject();
|
|
471
|
+
try {
|
|
472
|
+
seedReadyBaseline(proj);
|
|
473
|
+
const initialized = initStateDb(proj.dbPath);
|
|
474
|
+
for (const [id, status] of [
|
|
475
|
+
['parent-change', 'active'],
|
|
476
|
+
['other-change', 'active'],
|
|
477
|
+
['terminal-change', 'archived'],
|
|
478
|
+
]) {
|
|
479
|
+
initialized.db.prepare(
|
|
480
|
+
`INSERT INTO changes (id, title, kind, status, intent, artifact_root)
|
|
481
|
+
VALUES (?, ?, 'quick', ?, ?, ?)`,
|
|
482
|
+
).run(id, id, status, `Intent for ${id}.`, `.ultra/changes/active/${id}`);
|
|
483
|
+
}
|
|
484
|
+
closeStateDb(initialized.db);
|
|
485
|
+
|
|
486
|
+
await withClient(proj, async (client) => {
|
|
487
|
+
const parent = await client.callTool({
|
|
488
|
+
name: 'task.create',
|
|
489
|
+
arguments: {
|
|
490
|
+
id: 'owned-parent', title: 'Owned parent task', type: 'feature', priority: 'P1',
|
|
491
|
+
change_id: 'parent-change',
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
assert.equal(parent.isError, undefined);
|
|
495
|
+
|
|
496
|
+
const child = await client.callTool({
|
|
497
|
+
name: 'task.create',
|
|
498
|
+
arguments: {
|
|
499
|
+
id: 'owned-child', title: 'Owned child task', type: 'feature', priority: 'P1',
|
|
500
|
+
parent_id: 'owned-parent',
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
assert.equal(child.isError, undefined);
|
|
504
|
+
const inherited = readToolPayload(await client.callTool({
|
|
505
|
+
name: 'task.get', arguments: { id: 'owned-child' },
|
|
506
|
+
}));
|
|
507
|
+
assert.equal(inherited.task.change_id, 'parent-change');
|
|
508
|
+
|
|
509
|
+
const mismatch = await client.callTool({
|
|
510
|
+
name: 'task.create',
|
|
511
|
+
arguments: {
|
|
512
|
+
id: 'cross-change-child', title: 'Cross change child', type: 'feature', priority: 'P1',
|
|
513
|
+
parent_id: 'owned-parent', change_id: 'other-change',
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
assert.equal(expectError(mismatch).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
|
|
517
|
+
|
|
518
|
+
const orphan = await client.callTool({
|
|
519
|
+
name: 'task.create',
|
|
520
|
+
arguments: {
|
|
521
|
+
id: 'orphan-child', title: 'Orphan child task', type: 'feature', priority: 'P1',
|
|
522
|
+
parent_id: 'missing-parent',
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
assert.equal(expectError(orphan).code, 'TASK_NOT_FOUND');
|
|
526
|
+
|
|
527
|
+
const terminal = await client.callTool({
|
|
528
|
+
name: 'task.create',
|
|
529
|
+
arguments: {
|
|
530
|
+
id: 'terminal-task', title: 'Terminal task', type: 'feature', priority: 'P1',
|
|
531
|
+
change_id: 'terminal-change',
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
assert.equal(expectError(terminal).code, 'CHANGE_NOT_MUTABLE');
|
|
535
|
+
|
|
536
|
+
const missing = await client.callTool({
|
|
537
|
+
name: 'task.create',
|
|
538
|
+
arguments: {
|
|
539
|
+
id: 'missing-change-task', title: 'Missing change task', type: 'feature', priority: 'P1',
|
|
540
|
+
change_id: 'missing-change',
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
assert.equal(expectError(missing).code, 'CHANGE_NOT_FOUND');
|
|
544
|
+
});
|
|
545
|
+
} finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('task.parse_prd keeps dry runs read-only and blocks persistence before baseline readiness', async () => {
|
|
549
|
+
const dryProject = tmpProject();
|
|
550
|
+
try {
|
|
551
|
+
const { db } = initStateDb(dryProject.dbPath);
|
|
552
|
+
try {
|
|
553
|
+
const client = fakePrdClient('dry-run-task');
|
|
554
|
+
const result = await dispatchTool('task.parse_prd', {
|
|
555
|
+
prd_text: 'Build a dry-run task.', dry_run: true,
|
|
556
|
+
}, db, { rootDir: dryProject.dir, llmClient: client });
|
|
557
|
+
assert.equal(result.tasks[0].id, 'dry-run-task');
|
|
558
|
+
assert.equal(client.calls, 1);
|
|
559
|
+
assert.equal(ops.listTasks(db, {}).length, 0);
|
|
560
|
+
} finally { closeStateDb(db); }
|
|
561
|
+
} finally { fs.rmSync(dryProject.dir, { recursive: true, force: true }); }
|
|
562
|
+
|
|
563
|
+
for (const baseline of [
|
|
564
|
+
null,
|
|
565
|
+
{ mode: 'greenfield', status: 'draft' },
|
|
566
|
+
{ mode: 'migrated', status: 'adopting' },
|
|
567
|
+
]) {
|
|
568
|
+
const proj = tmpProject();
|
|
569
|
+
try {
|
|
570
|
+
if (baseline) seedBaseline(proj, baseline);
|
|
571
|
+
const { db } = initStateDb(proj.dbPath);
|
|
572
|
+
try {
|
|
573
|
+
const client = fakePrdClient(`blocked-prd-${baseline?.mode || 'missing'}`);
|
|
574
|
+
await assert.rejects(
|
|
575
|
+
dispatchTool('task.parse_prd', {
|
|
576
|
+
prd_text: 'Persist a task before adoption.', dry_run: false,
|
|
577
|
+
}, db, { rootDir: proj.dir, llmClient: client }),
|
|
578
|
+
(error) => error.code === 'BASELINE_NOT_READY',
|
|
579
|
+
);
|
|
580
|
+
assert.equal(client.calls, 0, 'baseline gate must run before the LLM call');
|
|
581
|
+
assert.equal(ops.listTasks(db, {}).length, 0);
|
|
582
|
+
} finally { closeStateDb(db); }
|
|
583
|
+
} finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test('task.parse_prd persists only under ready or already-authorized change ownership', async () => {
|
|
588
|
+
const readyProject = tmpProject();
|
|
589
|
+
try {
|
|
590
|
+
seedReadyBaseline(readyProject);
|
|
591
|
+
const { db } = initStateDb(readyProject.dbPath);
|
|
592
|
+
try {
|
|
593
|
+
await dispatchTool('task.parse_prd', {
|
|
594
|
+
prd_text: 'Build a ready task.', dry_run: false,
|
|
595
|
+
}, db, { rootDir: readyProject.dir, llmClient: fakePrdClient('ready-prd-task') });
|
|
596
|
+
assert.equal(ops.readTask(db, 'ready-prd-task').change_id, null);
|
|
597
|
+
} finally { closeStateDb(db); }
|
|
598
|
+
} finally { fs.rmSync(readyProject.dir, { recursive: true, force: true }); }
|
|
599
|
+
|
|
600
|
+
for (const kind of ['quick', 'incident']) {
|
|
601
|
+
const proj = tmpProject();
|
|
602
|
+
try {
|
|
603
|
+
seedReadyBaseline(proj);
|
|
604
|
+
const { db } = initStateDb(proj.dbPath);
|
|
605
|
+
try {
|
|
606
|
+
if (kind === 'incident') {
|
|
607
|
+
db.prepare(
|
|
608
|
+
"UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
|
|
609
|
+
).run();
|
|
610
|
+
}
|
|
611
|
+
const changeId = `parsed-${kind}`;
|
|
612
|
+
changes.createChange(db, {
|
|
613
|
+
id: changeId,
|
|
614
|
+
title: kind === 'incident' ? 'Recover parsed incident work' : 'Continue parsed ordinary work',
|
|
615
|
+
kind,
|
|
616
|
+
intent: 'Keep parsed tasks bound to the already-authorized continuous change.',
|
|
617
|
+
...(kind === 'incident' ? {
|
|
618
|
+
baseline_bypass: {
|
|
619
|
+
approved_by: 'incident-commander',
|
|
620
|
+
reason: 'Production recovery requires an approved task graph before adoption converges.',
|
|
621
|
+
},
|
|
622
|
+
} : {}),
|
|
623
|
+
}, { rootDir: proj.dir });
|
|
624
|
+
if (kind === 'quick') {
|
|
625
|
+
db.prepare(
|
|
626
|
+
"UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
|
|
627
|
+
).run();
|
|
628
|
+
}
|
|
629
|
+
const taskId = `parsed-${kind}-task`;
|
|
630
|
+
await dispatchTool('task.parse_prd', {
|
|
631
|
+
prd_text: 'Build a change-owned task.', dry_run: false, change_id: changeId,
|
|
632
|
+
}, db, { rootDir: proj.dir, llmClient: fakePrdClient(taskId) });
|
|
633
|
+
assert.equal(ops.readTask(db, taskId).change_id, changeId);
|
|
634
|
+
} finally { closeStateDb(db); }
|
|
635
|
+
} finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
296
639
|
test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets an empty state.db', async () => {
|
|
297
640
|
const proj = tmpProject();
|
|
298
641
|
try {
|
|
@@ -325,9 +668,35 @@ test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets
|
|
|
325
668
|
}
|
|
326
669
|
});
|
|
327
670
|
|
|
671
|
+
test('state-backed MCP tools return the supported v4.5 projection migration command', async () => {
|
|
672
|
+
const proj = tmpProject();
|
|
673
|
+
try {
|
|
674
|
+
const tasksDir = path.join(proj.dir, '.ultra', 'tasks');
|
|
675
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
676
|
+
fs.writeFileSync(path.join(tasksDir, 'tasks.json'), JSON.stringify({
|
|
677
|
+
schema_version: '4.5', source: '.ultra/state.db',
|
|
678
|
+
tasks: [{
|
|
679
|
+
id: 'projection-only', title: 'projection-only task', type: 'feature', priority: 'P1',
|
|
680
|
+
status: 'pending', deps: [], estimated_days: 1,
|
|
681
|
+
}],
|
|
682
|
+
}));
|
|
683
|
+
|
|
684
|
+
await withClient(proj, async (client) => {
|
|
685
|
+
const listed = await client.callTool({ name: 'task.list', arguments: {} });
|
|
686
|
+
const error = expectError(listed);
|
|
687
|
+
assert.equal(error.code, 'LEGACY_STATE_MIGRATION_REQUIRED');
|
|
688
|
+
assert.match(error.message, /ultra-tools migrate --from=4\.5 --to=12\.0/);
|
|
689
|
+
assert.equal(error.details.projection_version, '4.5');
|
|
690
|
+
});
|
|
691
|
+
} finally {
|
|
692
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
328
696
|
test('task.update enforces the status state machine', async () => {
|
|
329
697
|
const proj = tmpProject();
|
|
330
698
|
try {
|
|
699
|
+
seedReadyBaseline(proj);
|
|
331
700
|
await withClient(proj, async (client) => {
|
|
332
701
|
await client.callTool({
|
|
333
702
|
name: 'task.create',
|
|
@@ -357,9 +726,63 @@ test('task.update enforces the status state machine', async () => {
|
|
|
357
726
|
}
|
|
358
727
|
});
|
|
359
728
|
|
|
729
|
+
test('task.update preserves established change ownership and freezes terminal change tasks', async () => {
|
|
730
|
+
const proj = tmpProject();
|
|
731
|
+
try {
|
|
732
|
+
seedReadyBaseline(proj);
|
|
733
|
+
const initialized = initStateDb(proj.dbPath);
|
|
734
|
+
try {
|
|
735
|
+
for (const id of ['update-change-a', 'update-change-b', 'update-terminal']) {
|
|
736
|
+
initialized.db.prepare(
|
|
737
|
+
`INSERT INTO changes (id, title, kind, status, intent, artifact_root)
|
|
738
|
+
VALUES (?, ?, 'quick', 'active', ?, ?)`,
|
|
739
|
+
).run(id, id, `Intent for ${id}.`, `.ultra/changes/active/${id}`);
|
|
740
|
+
}
|
|
741
|
+
ops.createTask(initialized.db, {
|
|
742
|
+
id: 'update-owned-root', title: 'Owned root task', type: 'feature', priority: 'P1',
|
|
743
|
+
change_id: 'update-change-a',
|
|
744
|
+
});
|
|
745
|
+
ops.createTask(initialized.db, {
|
|
746
|
+
id: 'update-terminal-task', title: 'Terminal change task', type: 'feature', priority: 'P1',
|
|
747
|
+
change_id: 'update-terminal',
|
|
748
|
+
});
|
|
749
|
+
initialized.db.prepare("UPDATE changes SET status = 'archived' WHERE id = 'update-terminal'").run();
|
|
750
|
+
} finally { closeStateDb(initialized.db); }
|
|
751
|
+
|
|
752
|
+
await withClient(proj, async (client) => {
|
|
753
|
+
const detach = await client.callTool({
|
|
754
|
+
name: 'task.update', arguments: { id: 'update-owned-root', patch: { change_id: null } },
|
|
755
|
+
});
|
|
756
|
+
assert.equal(expectError(detach).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
|
|
757
|
+
|
|
758
|
+
const cross = await client.callTool({
|
|
759
|
+
name: 'task.update',
|
|
760
|
+
arguments: { id: 'update-owned-root', patch: { change_id: 'update-change-b' } },
|
|
761
|
+
});
|
|
762
|
+
assert.equal(expectError(cross).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
|
|
763
|
+
|
|
764
|
+
const terminal = await client.callTool({
|
|
765
|
+
name: 'task.update', arguments: { id: 'update-terminal-task', patch: { priority: 'P0' } },
|
|
766
|
+
});
|
|
767
|
+
assert.equal(expectError(terminal).code, 'CHANGE_NOT_MUTABLE');
|
|
768
|
+
|
|
769
|
+
await client.callTool({
|
|
770
|
+
name: 'task.create',
|
|
771
|
+
arguments: { id: 'update-unowned', title: 'Unowned task', type: 'feature', priority: 'P1' },
|
|
772
|
+
});
|
|
773
|
+
const firstBinding = readToolPayload(await client.callTool({
|
|
774
|
+
name: 'task.update',
|
|
775
|
+
arguments: { id: 'update-unowned', patch: { change_id: 'update-change-b' } },
|
|
776
|
+
}));
|
|
777
|
+
assert.equal(firstBinding.task.change_id, 'update-change-b');
|
|
778
|
+
});
|
|
779
|
+
} finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
|
|
780
|
+
});
|
|
781
|
+
|
|
360
782
|
test('task.append_event + subscribe_events drive a monotonic cursor', async () => {
|
|
361
783
|
const proj = tmpProject();
|
|
362
784
|
try {
|
|
785
|
+
seedReadyBaseline(proj);
|
|
363
786
|
await withClient(proj, async (client) => {
|
|
364
787
|
await client.callTool({
|
|
365
788
|
name: 'task.create',
|
|
@@ -397,6 +820,7 @@ test('task.append_event + subscribe_events drive a monotonic cursor', async () =
|
|
|
397
820
|
test('task.list filters by status and tag', async () => {
|
|
398
821
|
const proj = tmpProject();
|
|
399
822
|
try {
|
|
823
|
+
seedReadyBaseline(proj);
|
|
400
824
|
await withClient(proj, async (client) => {
|
|
401
825
|
await client.callTool({ name: 'task.create', arguments: { id: 'l-1', title: 'list one', type: 'feature', priority: 'P0', tag: 'main' } });
|
|
402
826
|
await client.callTool({ name: 'task.create', arguments: { id: 'l-2', title: 'list two', type: 'feature', priority: 'P1', tag: 'main' } });
|
|
@@ -438,6 +862,7 @@ test('task.create rejects bad input via the JSON Schema validator', async () =>
|
|
|
438
862
|
test('mutating tools trigger the projector — tasks.json appears under .ultra/', async () => {
|
|
439
863
|
const proj = tmpProject();
|
|
440
864
|
try {
|
|
865
|
+
seedReadyBaseline(proj);
|
|
441
866
|
await withClient(proj, async (client) => {
|
|
442
867
|
await client.callTool({
|
|
443
868
|
name: 'task.create',
|
|
@@ -457,6 +882,7 @@ test('mutating tools trigger the projector — tasks.json appears under .ultra/'
|
|
|
457
882
|
test('output schema drift cannot strand a committed mutation without a projection job', async () => {
|
|
458
883
|
const proj = tmpProject();
|
|
459
884
|
try {
|
|
885
|
+
seedReadyBaseline(proj);
|
|
460
886
|
const runtimeRoot = path.join(proj.dir, 'runtime-contract');
|
|
461
887
|
fs.mkdirSync(path.join(runtimeRoot, 'spec', 'schemas'), { recursive: true });
|
|
462
888
|
fs.copyFileSync(path.join(REPO_ROOT, 'package.json'), path.join(runtimeRoot, 'package.json'));
|
|
@@ -515,11 +941,22 @@ test('task.init_project creates .ultra/ skeleton in a fresh target directory', a
|
|
|
515
941
|
assert.equal(payload.status, 'created');
|
|
516
942
|
assert.equal(payload.created_path, path.join(freshTarget, '.ultra'));
|
|
517
943
|
assert.equal(payload.state_db_path, path.join(freshTarget, '.ultra', 'state.db'));
|
|
944
|
+
assert.equal(payload.mode, 'greenfield');
|
|
945
|
+
assert.equal(payload.baseline.status, 'draft');
|
|
518
946
|
assert.ok(fs.existsSync(payload.state_db_path));
|
|
519
947
|
assert.ok(payload.copied_files.includes('tasks/tasks.json'));
|
|
520
948
|
const tasksJson = JSON.parse(fs.readFileSync(path.join(payload.created_path, 'tasks', 'tasks.json'), 'utf8'));
|
|
521
|
-
assert.equal(tasksJson.
|
|
522
|
-
assert.
|
|
949
|
+
assert.equal(tasksJson.source, '.ultra/state.db');
|
|
950
|
+
assert.deepEqual(tasksJson.tasks, []);
|
|
951
|
+
const state = new Database(payload.state_db_path, { readonly: true });
|
|
952
|
+
try {
|
|
953
|
+
assert.deepEqual(
|
|
954
|
+
state.prepare('SELECT project_name, project_type, mode FROM baselines').get(),
|
|
955
|
+
{ project_name: 'mcp-init', project_type: 'cli', mode: 'greenfield' },
|
|
956
|
+
);
|
|
957
|
+
} finally {
|
|
958
|
+
state.close();
|
|
959
|
+
}
|
|
523
960
|
});
|
|
524
961
|
} finally {
|
|
525
962
|
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
@@ -549,6 +986,34 @@ test('task.init_project returns ULTRA_DIR_EXISTS on re-init without overwrite',
|
|
|
549
986
|
}
|
|
550
987
|
});
|
|
551
988
|
|
|
989
|
+
test('task.init_project resume preserves existing files and installs missing current assets', async () => {
|
|
990
|
+
const proj = tmpProject();
|
|
991
|
+
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-resume-'));
|
|
992
|
+
try {
|
|
993
|
+
fs.mkdirSync(path.join(target, '.ultra', 'specs'), { recursive: true });
|
|
994
|
+
fs.writeFileSync(path.join(target, '.ultra', 'specs', 'product.md'), '# Preserved contract\n');
|
|
995
|
+
await withClient(proj, async (client) => {
|
|
996
|
+
const result = await client.callTool({
|
|
997
|
+
name: 'task.init_project',
|
|
998
|
+
arguments: {
|
|
999
|
+
target_dir: target, project_name: 'resume-project', mode: 'brownfield', resume: true,
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
const payload = readToolPayload(result);
|
|
1003
|
+
assert.equal(payload.status, 'resumed');
|
|
1004
|
+
assert.equal(payload.baseline.status, 'adopting');
|
|
1005
|
+
assert.equal(
|
|
1006
|
+
fs.readFileSync(path.join(target, '.ultra', 'specs', 'product.md'), 'utf8'),
|
|
1007
|
+
'# Preserved contract\n',
|
|
1008
|
+
);
|
|
1009
|
+
assert.ok(payload.copied_files.includes('specs/architecture.md'));
|
|
1010
|
+
});
|
|
1011
|
+
} finally {
|
|
1012
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
1013
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
|
|
552
1017
|
async function seedTask(client, id = 's-1') {
|
|
553
1018
|
await client.callTool({
|
|
554
1019
|
name: 'task.create',
|
|
@@ -559,6 +1024,7 @@ async function seedTask(client, id = 's-1') {
|
|
|
559
1024
|
test('session.admission_check + session.spawn: happy path returns sid and paths', async () => {
|
|
560
1025
|
const proj = tmpProject();
|
|
561
1026
|
try {
|
|
1027
|
+
seedReadyBaseline(proj);
|
|
562
1028
|
await withClient(proj, async (client) => {
|
|
563
1029
|
await seedTask(client, 's-happy');
|
|
564
1030
|
|
|
@@ -605,6 +1071,7 @@ test('session.spawn rejects a retired runtime at the MCP schema boundary', async
|
|
|
605
1071
|
test('session.spawn refuses second session for same task without takeover (ADMISSION_DENIED)', async () => {
|
|
606
1072
|
const proj = tmpProject();
|
|
607
1073
|
try {
|
|
1074
|
+
seedReadyBaseline(proj);
|
|
608
1075
|
await withClient(proj, async (client) => {
|
|
609
1076
|
await seedTask(client, 's-conflict');
|
|
610
1077
|
await client.callTool({
|
|
@@ -635,6 +1102,7 @@ test('session.spawn refuses second session for same task without takeover (ADMIS
|
|
|
635
1102
|
test('session.spawn with takeover=true crashes the old session and succeeds', async () => {
|
|
636
1103
|
const proj = tmpProject();
|
|
637
1104
|
try {
|
|
1105
|
+
seedReadyBaseline(proj);
|
|
638
1106
|
await withClient(proj, async (client) => {
|
|
639
1107
|
await seedTask(client, 's-takeover');
|
|
640
1108
|
const first = readToolPayload(await client.callTool({
|
|
@@ -661,6 +1129,7 @@ test('session.spawn with takeover=true crashes the old session and succeeds', as
|
|
|
661
1129
|
test('session.subscribe_events sees task events with ≤1s latency (D31 id cursor)', async () => {
|
|
662
1130
|
const proj = tmpProject();
|
|
663
1131
|
try {
|
|
1132
|
+
seedReadyBaseline(proj);
|
|
664
1133
|
await withClient(proj, async (client) => {
|
|
665
1134
|
await seedTask(client, 's-sub');
|
|
666
1135
|
|
|
@@ -696,6 +1165,7 @@ test('session.subscribe_events sees task events with ≤1s latency (D31 id curso
|
|
|
696
1165
|
test('session.subscribe_events filters events by sid', async () => {
|
|
697
1166
|
const proj = tmpProject();
|
|
698
1167
|
try {
|
|
1168
|
+
seedReadyBaseline(proj);
|
|
699
1169
|
await withClient(proj, async (client) => {
|
|
700
1170
|
await seedTask(client, 's-filter-a');
|
|
701
1171
|
await seedTask(client, 's-filter-b');
|
|
@@ -769,6 +1239,7 @@ test('MCP rejects declared inputs that have no runtime behavior', async () => {
|
|
|
769
1239
|
test('session.heartbeat refreshes lease; session.close marks completed', async () => {
|
|
770
1240
|
const proj = tmpProject();
|
|
771
1241
|
try {
|
|
1242
|
+
seedReadyBaseline(proj);
|
|
772
1243
|
await withClient(proj, async (client) => {
|
|
773
1244
|
await seedTask(client, 's-heart');
|
|
774
1245
|
const spawn = readToolPayload(await client.callTool({
|
|
@@ -820,6 +1291,7 @@ test('retired memory tools are rejected as unknown', async () => {
|
|
|
820
1291
|
test('task.dependency_topo: happy path groups tasks into correct waves', async () => {
|
|
821
1292
|
const proj = tmpProject();
|
|
822
1293
|
try {
|
|
1294
|
+
seedReadyBaseline(proj);
|
|
823
1295
|
await withClient(proj, async (client) => {
|
|
824
1296
|
for (const [id, deps] of [['A', []], ['B', ['A']], ['C', ['A']]]) {
|
|
825
1297
|
await client.callTool({
|
|
@@ -860,7 +1332,7 @@ test('task.parse_prd: missing LLM credentials → NO_LLM_CREDENTIALS', async ()
|
|
|
860
1332
|
await withClientNoLlmKey(proj, async (client) => {
|
|
861
1333
|
const resp = await client.callTool({
|
|
862
1334
|
name: 'task.parse_prd',
|
|
863
|
-
arguments: { prd_text: 'Build a login feature with email and password.' },
|
|
1335
|
+
arguments: { prd_text: 'Build a login feature with email and password.', dry_run: true },
|
|
864
1336
|
});
|
|
865
1337
|
const err = expectError(resp);
|
|
866
1338
|
assert.equal(err.code, 'NO_LLM_CREDENTIALS');
|
|
@@ -889,6 +1361,7 @@ test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', asyn
|
|
|
889
1361
|
test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIALS', async () => {
|
|
890
1362
|
const proj = tmpProject();
|
|
891
1363
|
try {
|
|
1364
|
+
seedReadyBaseline(proj);
|
|
892
1365
|
await withClientNoLlmKey(proj, async (client) => {
|
|
893
1366
|
await client.callTool({
|
|
894
1367
|
name: 'task.create',
|
|
@@ -925,6 +1398,7 @@ test('plan.export: no tasks → NO_TASKS', async () => {
|
|
|
925
1398
|
test('plan.export → plan.get round trip: artifact on disk + retrievable', async () => {
|
|
926
1399
|
const proj = tmpProject();
|
|
927
1400
|
try {
|
|
1401
|
+
seedReadyBaseline(proj);
|
|
928
1402
|
await withClient(proj, async (client) => {
|
|
929
1403
|
for (const [id, deps, files] of [
|
|
930
1404
|
['p-a', [], ['src/a.ts']],
|
|
@@ -971,6 +1445,7 @@ test('plan.get: no plan written yet → NO_PLAN', async () => {
|
|
|
971
1445
|
test('task.dependency_topo: cycle returns CYCLE_DETECTED with cycles in details', async () => {
|
|
972
1446
|
const proj = tmpProject();
|
|
973
1447
|
try {
|
|
1448
|
+
seedReadyBaseline(proj);
|
|
974
1449
|
await withClient(proj, async (client) => {
|
|
975
1450
|
await client.callTool({
|
|
976
1451
|
name: 'task.create',
|