ultra-builder-pro-cli 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/discovery.md +10 -0
- package/CHANGELOG.md +53 -1
- package/README.md +25 -9
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/tests/codex.test.cjs +8 -0
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +4 -3
- package/docs/ARCHITECTURE.md +32 -16
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +1 -1
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/context_spine.py +37 -4
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +404 -38
- package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
- package/mcp-server/lib/change-workflow.cjs +249 -75
- package/mcp-server/lib/change-workflow.test.cjs +235 -1
- package/mcp-server/lib/context-spine.test.cjs +5 -0
- package/mcp-server/lib/doctor.cjs +14 -1
- package/mcp-server/lib/doctor.test.cjs +42 -4
- package/mcp-server/lib/init-project.cjs +292 -9
- package/mcp-server/lib/spec-learning.test.cjs +1 -1
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +154 -32
- package/mcp-server/lib/state-db.test.cjs +136 -4
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +14 -3
- package/mcp-server/tests/init-project.test.cjs +162 -1
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +384 -6
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +18 -10
- package/skills/ultra-deliver/SKILL.md +13 -9
- package/skills/ultra-dev/SKILL.md +6 -4
- package/skills/ultra-doctor/SKILL.md +22 -7
- package/skills/ultra-init/SKILL.md +104 -59
- package/skills/ultra-init/references/brownfield-adoption.md +65 -37
- package/skills/ultra-plan/SKILL.md +8 -5
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +95 -9
- package/spec/schemas/state-db.sql +11 -0
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +18 -9
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +7 -1
- package/ultra-tools/status-command.test.cjs +29 -0
- package/ultra-tools/system-command.test.cjs +252 -1
|
@@ -15,22 +15,35 @@ 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
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');
|
|
18
21
|
|
|
19
22
|
function tmpProject() {
|
|
20
23
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-mcp-'));
|
|
21
24
|
return { dir, dbPath: path.join(dir, '.ultra', 'state.db') };
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
function
|
|
27
|
+
function seedReadyBaseline(project) {
|
|
25
28
|
const { db } = initStateDb(project.dbPath);
|
|
26
29
|
db.prepare(
|
|
27
30
|
`INSERT OR IGNORE INTO baselines
|
|
28
31
|
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
29
|
-
VALUES ('test-baseline', 'fixture', '
|
|
32
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'test fixture', ?)`,
|
|
30
33
|
).run(new Date().toISOString());
|
|
31
34
|
closeStateDb(db);
|
|
32
35
|
}
|
|
33
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
|
+
|
|
34
47
|
async function withClient({ dir, dbPath }, fn, extraEnv = {}) {
|
|
35
48
|
const transport = new StdioClientTransport({
|
|
36
49
|
command: process.execPath,
|
|
@@ -78,6 +91,29 @@ function expectError(result) {
|
|
|
78
91
|
return JSON.parse(result.content[0].text).error;
|
|
79
92
|
}
|
|
80
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
|
+
|
|
81
117
|
test('listTools returns workflow tools and exposes no Ultra memory API', async () => {
|
|
82
118
|
const proj = tmpProject();
|
|
83
119
|
try {
|
|
@@ -204,7 +240,7 @@ test('baseline MCP tools adopt and converge an existing checkout without storing
|
|
|
204
240
|
test('change.create + change.context expose a continuous change unit with external provider references', async () => {
|
|
205
241
|
const proj = tmpProject();
|
|
206
242
|
try {
|
|
207
|
-
|
|
243
|
+
seedReadyBaseline(proj);
|
|
208
244
|
fs.writeFileSync(path.join(proj.dir, 'README.md'), '# project\n');
|
|
209
245
|
await withClient(proj, async (client) => {
|
|
210
246
|
const created = await client.callTool({
|
|
@@ -256,7 +292,7 @@ test('change.create + change.context expose a continuous change unit with extern
|
|
|
256
292
|
test('spec-learning MCP tools persist an approval-gated candidate and projection', async () => {
|
|
257
293
|
const proj = tmpProject();
|
|
258
294
|
try {
|
|
259
|
-
|
|
295
|
+
seedReadyBaseline(proj);
|
|
260
296
|
await withClient(proj, async (client) => {
|
|
261
297
|
await client.callTool({
|
|
262
298
|
name: 'change.create',
|
|
@@ -318,7 +354,7 @@ test('spec-learning MCP tools persist an approval-gated candidate and projection
|
|
|
318
354
|
test('system.doctor returns structured provider ownership and projection health', async () => {
|
|
319
355
|
const proj = tmpProject();
|
|
320
356
|
try {
|
|
321
|
-
|
|
357
|
+
seedReadyBaseline(proj);
|
|
322
358
|
await withClient(proj, async (client) => {
|
|
323
359
|
await client.callTool({
|
|
324
360
|
name: 'task.create',
|
|
@@ -359,6 +395,7 @@ test('published MCP contract contains exactly the live server tools', () => {
|
|
|
359
395
|
test('task.create + task.get round trip via MCP', async () => {
|
|
360
396
|
const proj = tmpProject();
|
|
361
397
|
try {
|
|
398
|
+
seedReadyBaseline(proj);
|
|
362
399
|
await withClient(proj, async (client) => {
|
|
363
400
|
const created = await client.callTool({
|
|
364
401
|
name: 'task.create',
|
|
@@ -379,6 +416,226 @@ test('task.create + task.get round trip via MCP', async () => {
|
|
|
379
416
|
}
|
|
380
417
|
});
|
|
381
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
|
+
|
|
382
639
|
test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets an empty state.db', async () => {
|
|
383
640
|
const proj = tmpProject();
|
|
384
641
|
try {
|
|
@@ -411,9 +668,35 @@ test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets
|
|
|
411
668
|
}
|
|
412
669
|
});
|
|
413
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
|
+
|
|
414
696
|
test('task.update enforces the status state machine', async () => {
|
|
415
697
|
const proj = tmpProject();
|
|
416
698
|
try {
|
|
699
|
+
seedReadyBaseline(proj);
|
|
417
700
|
await withClient(proj, async (client) => {
|
|
418
701
|
await client.callTool({
|
|
419
702
|
name: 'task.create',
|
|
@@ -443,9 +726,63 @@ test('task.update enforces the status state machine', async () => {
|
|
|
443
726
|
}
|
|
444
727
|
});
|
|
445
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
|
+
|
|
446
782
|
test('task.append_event + subscribe_events drive a monotonic cursor', async () => {
|
|
447
783
|
const proj = tmpProject();
|
|
448
784
|
try {
|
|
785
|
+
seedReadyBaseline(proj);
|
|
449
786
|
await withClient(proj, async (client) => {
|
|
450
787
|
await client.callTool({
|
|
451
788
|
name: 'task.create',
|
|
@@ -483,6 +820,7 @@ test('task.append_event + subscribe_events drive a monotonic cursor', async () =
|
|
|
483
820
|
test('task.list filters by status and tag', async () => {
|
|
484
821
|
const proj = tmpProject();
|
|
485
822
|
try {
|
|
823
|
+
seedReadyBaseline(proj);
|
|
486
824
|
await withClient(proj, async (client) => {
|
|
487
825
|
await client.callTool({ name: 'task.create', arguments: { id: 'l-1', title: 'list one', type: 'feature', priority: 'P0', tag: 'main' } });
|
|
488
826
|
await client.callTool({ name: 'task.create', arguments: { id: 'l-2', title: 'list two', type: 'feature', priority: 'P1', tag: 'main' } });
|
|
@@ -524,6 +862,7 @@ test('task.create rejects bad input via the JSON Schema validator', async () =>
|
|
|
524
862
|
test('mutating tools trigger the projector — tasks.json appears under .ultra/', async () => {
|
|
525
863
|
const proj = tmpProject();
|
|
526
864
|
try {
|
|
865
|
+
seedReadyBaseline(proj);
|
|
527
866
|
await withClient(proj, async (client) => {
|
|
528
867
|
await client.callTool({
|
|
529
868
|
name: 'task.create',
|
|
@@ -543,6 +882,7 @@ test('mutating tools trigger the projector — tasks.json appears under .ultra/'
|
|
|
543
882
|
test('output schema drift cannot strand a committed mutation without a projection job', async () => {
|
|
544
883
|
const proj = tmpProject();
|
|
545
884
|
try {
|
|
885
|
+
seedReadyBaseline(proj);
|
|
546
886
|
const runtimeRoot = path.join(proj.dir, 'runtime-contract');
|
|
547
887
|
fs.mkdirSync(path.join(runtimeRoot, 'spec', 'schemas'), { recursive: true });
|
|
548
888
|
fs.copyFileSync(path.join(REPO_ROOT, 'package.json'), path.join(runtimeRoot, 'package.json'));
|
|
@@ -646,6 +986,34 @@ test('task.init_project returns ULTRA_DIR_EXISTS on re-init without overwrite',
|
|
|
646
986
|
}
|
|
647
987
|
});
|
|
648
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
|
+
|
|
649
1017
|
async function seedTask(client, id = 's-1') {
|
|
650
1018
|
await client.callTool({
|
|
651
1019
|
name: 'task.create',
|
|
@@ -656,6 +1024,7 @@ async function seedTask(client, id = 's-1') {
|
|
|
656
1024
|
test('session.admission_check + session.spawn: happy path returns sid and paths', async () => {
|
|
657
1025
|
const proj = tmpProject();
|
|
658
1026
|
try {
|
|
1027
|
+
seedReadyBaseline(proj);
|
|
659
1028
|
await withClient(proj, async (client) => {
|
|
660
1029
|
await seedTask(client, 's-happy');
|
|
661
1030
|
|
|
@@ -702,6 +1071,7 @@ test('session.spawn rejects a retired runtime at the MCP schema boundary', async
|
|
|
702
1071
|
test('session.spawn refuses second session for same task without takeover (ADMISSION_DENIED)', async () => {
|
|
703
1072
|
const proj = tmpProject();
|
|
704
1073
|
try {
|
|
1074
|
+
seedReadyBaseline(proj);
|
|
705
1075
|
await withClient(proj, async (client) => {
|
|
706
1076
|
await seedTask(client, 's-conflict');
|
|
707
1077
|
await client.callTool({
|
|
@@ -732,6 +1102,7 @@ test('session.spawn refuses second session for same task without takeover (ADMIS
|
|
|
732
1102
|
test('session.spawn with takeover=true crashes the old session and succeeds', async () => {
|
|
733
1103
|
const proj = tmpProject();
|
|
734
1104
|
try {
|
|
1105
|
+
seedReadyBaseline(proj);
|
|
735
1106
|
await withClient(proj, async (client) => {
|
|
736
1107
|
await seedTask(client, 's-takeover');
|
|
737
1108
|
const first = readToolPayload(await client.callTool({
|
|
@@ -758,6 +1129,7 @@ test('session.spawn with takeover=true crashes the old session and succeeds', as
|
|
|
758
1129
|
test('session.subscribe_events sees task events with ≤1s latency (D31 id cursor)', async () => {
|
|
759
1130
|
const proj = tmpProject();
|
|
760
1131
|
try {
|
|
1132
|
+
seedReadyBaseline(proj);
|
|
761
1133
|
await withClient(proj, async (client) => {
|
|
762
1134
|
await seedTask(client, 's-sub');
|
|
763
1135
|
|
|
@@ -793,6 +1165,7 @@ test('session.subscribe_events sees task events with ≤1s latency (D31 id curso
|
|
|
793
1165
|
test('session.subscribe_events filters events by sid', async () => {
|
|
794
1166
|
const proj = tmpProject();
|
|
795
1167
|
try {
|
|
1168
|
+
seedReadyBaseline(proj);
|
|
796
1169
|
await withClient(proj, async (client) => {
|
|
797
1170
|
await seedTask(client, 's-filter-a');
|
|
798
1171
|
await seedTask(client, 's-filter-b');
|
|
@@ -866,6 +1239,7 @@ test('MCP rejects declared inputs that have no runtime behavior', async () => {
|
|
|
866
1239
|
test('session.heartbeat refreshes lease; session.close marks completed', async () => {
|
|
867
1240
|
const proj = tmpProject();
|
|
868
1241
|
try {
|
|
1242
|
+
seedReadyBaseline(proj);
|
|
869
1243
|
await withClient(proj, async (client) => {
|
|
870
1244
|
await seedTask(client, 's-heart');
|
|
871
1245
|
const spawn = readToolPayload(await client.callTool({
|
|
@@ -917,6 +1291,7 @@ test('retired memory tools are rejected as unknown', async () => {
|
|
|
917
1291
|
test('task.dependency_topo: happy path groups tasks into correct waves', async () => {
|
|
918
1292
|
const proj = tmpProject();
|
|
919
1293
|
try {
|
|
1294
|
+
seedReadyBaseline(proj);
|
|
920
1295
|
await withClient(proj, async (client) => {
|
|
921
1296
|
for (const [id, deps] of [['A', []], ['B', ['A']], ['C', ['A']]]) {
|
|
922
1297
|
await client.callTool({
|
|
@@ -957,7 +1332,7 @@ test('task.parse_prd: missing LLM credentials → NO_LLM_CREDENTIALS', async ()
|
|
|
957
1332
|
await withClientNoLlmKey(proj, async (client) => {
|
|
958
1333
|
const resp = await client.callTool({
|
|
959
1334
|
name: 'task.parse_prd',
|
|
960
|
-
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 },
|
|
961
1336
|
});
|
|
962
1337
|
const err = expectError(resp);
|
|
963
1338
|
assert.equal(err.code, 'NO_LLM_CREDENTIALS');
|
|
@@ -986,6 +1361,7 @@ test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', asyn
|
|
|
986
1361
|
test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIALS', async () => {
|
|
987
1362
|
const proj = tmpProject();
|
|
988
1363
|
try {
|
|
1364
|
+
seedReadyBaseline(proj);
|
|
989
1365
|
await withClientNoLlmKey(proj, async (client) => {
|
|
990
1366
|
await client.callTool({
|
|
991
1367
|
name: 'task.create',
|
|
@@ -1022,6 +1398,7 @@ test('plan.export: no tasks → NO_TASKS', async () => {
|
|
|
1022
1398
|
test('plan.export → plan.get round trip: artifact on disk + retrievable', async () => {
|
|
1023
1399
|
const proj = tmpProject();
|
|
1024
1400
|
try {
|
|
1401
|
+
seedReadyBaseline(proj);
|
|
1025
1402
|
await withClient(proj, async (client) => {
|
|
1026
1403
|
for (const [id, deps, files] of [
|
|
1027
1404
|
['p-a', [], ['src/a.ts']],
|
|
@@ -1068,6 +1445,7 @@ test('plan.get: no plan written yet → NO_PLAN', async () => {
|
|
|
1068
1445
|
test('task.dependency_topo: cycle returns CYCLE_DETECTED with cycles in details', async () => {
|
|
1069
1446
|
const proj = tmpProject();
|
|
1070
1447
|
try {
|
|
1448
|
+
seedReadyBaseline(proj);
|
|
1071
1449
|
await withClient(proj, async (client) => {
|
|
1072
1450
|
await client.callTool({
|
|
1073
1451
|
name: 'task.create',
|
|
@@ -59,7 +59,9 @@ test('initStateDb upgrades a pre-10.0 database through Context Spine into baseli
|
|
|
59
59
|
assert.ok(columns.includes('change_id'));
|
|
60
60
|
const eventColumns = upgraded.db.prepare('PRAGMA table_info(events)').all().map((row) => row.name);
|
|
61
61
|
assert.ok(eventColumns.includes('change_id'));
|
|
62
|
-
assert.equal(upgraded.schema_version, '
|
|
62
|
+
assert.equal(upgraded.schema_version, '12.0');
|
|
63
|
+
assert.ok(upgraded.backup_path);
|
|
64
|
+
assert.ok(fs.existsSync(upgraded.backup_path));
|
|
63
65
|
assert.ok(upgraded.db.prepare("SELECT 1 FROM schema_version WHERE version = '10.0'").get());
|
|
64
66
|
const contextColumns = upgraded.db.prepare('PRAGMA table_info(context_snapshots)').all()
|
|
65
67
|
.map((row) => row.name);
|
|
@@ -72,7 +74,7 @@ test('initStateDb upgrades a pre-10.0 database through Context Spine into baseli
|
|
|
72
74
|
const baseline = upgraded.db.prepare(
|
|
73
75
|
"SELECT mode, status FROM baselines WHERE id = 'migrated-baseline'",
|
|
74
76
|
).get();
|
|
75
|
-
assert.deepEqual(baseline, { mode: 'migrated', status: '
|
|
77
|
+
assert.deepEqual(baseline, { mode: 'migrated', status: 'adopting' });
|
|
76
78
|
const contextMigration = upgraded.db.prepare(
|
|
77
79
|
"SELECT from_version, to_version, notes FROM migration_history WHERE to_version = '10.0' ORDER BY id DESC LIMIT 1",
|
|
78
80
|
).get();
|
|
@@ -84,6 +86,16 @@ test('initStateDb upgrades a pre-10.0 database through Context Spine into baseli
|
|
|
84
86
|
).get();
|
|
85
87
|
assert.equal(baselineMigration.from_version, '10.0');
|
|
86
88
|
assert.match(baselineMigration.notes, /baseline adoption/i);
|
|
89
|
+
const adoptionMigration = upgraded.db.prepare(
|
|
90
|
+
"SELECT from_version, to_version, notes FROM migration_history WHERE to_version = '12.0' ORDER BY id DESC LIMIT 1",
|
|
91
|
+
).get();
|
|
92
|
+
assert.equal(adoptionMigration.from_version, '11.0');
|
|
93
|
+
assert.match(adoptionMigration.notes, /gap ledger|re-adoption/i);
|
|
94
|
+
const migrated = upgraded.db.prepare(
|
|
95
|
+
"SELECT gaps_json, worktree_state FROM baselines WHERE id = 'migrated-baseline'",
|
|
96
|
+
).get();
|
|
97
|
+
assert.equal(migrated.worktree_state, 'unavailable');
|
|
98
|
+
assert.equal(JSON.parse(migrated.gaps_json)[0].category, 'baseline_blocker');
|
|
87
99
|
assert.ok(upgraded.db.prepare(
|
|
88
100
|
"SELECT 1 FROM migration_history WHERE from_version = '8A.1' AND to_version = '9.1' AND notes LIKE '%Kimi%'",
|
|
89
101
|
).get());
|