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.
Files changed (57) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +53 -1
  4. package/README.md +25 -9
  5. package/adapters/_shared/handbook.cjs +2 -2
  6. package/adapters/tests/codex.test.cjs +8 -0
  7. package/commands/ultra-init.md +1 -1
  8. package/docs/AGENT-CONTEXT.md +4 -3
  9. package/docs/ARCHITECTURE.md +32 -16
  10. package/docs/PLAN.zh-CN.md +5 -4
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  13. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  14. package/hooks/context_spine.py +37 -4
  15. package/mcp-server/lib/archive-journal.cjs +192 -0
  16. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  17. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  18. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  19. package/mcp-server/lib/change-workflow.cjs +249 -75
  20. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  21. package/mcp-server/lib/context-spine.test.cjs +5 -0
  22. package/mcp-server/lib/doctor.cjs +14 -1
  23. package/mcp-server/lib/doctor.test.cjs +42 -4
  24. package/mcp-server/lib/init-project.cjs +292 -9
  25. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  26. package/mcp-server/lib/state-authority.cjs +9 -3
  27. package/mcp-server/lib/state-db.cjs +154 -32
  28. package/mcp-server/lib/state-db.test.cjs +136 -4
  29. package/mcp-server/lib/state-ops.cjs +68 -6
  30. package/mcp-server/lib/task-expander.cjs +7 -1
  31. package/mcp-server/lib/task-expander.test.cjs +71 -0
  32. package/mcp-server/server.cjs +14 -3
  33. package/mcp-server/tests/init-project.test.cjs +162 -1
  34. package/mcp-server/tests/migrate.test.cjs +33 -0
  35. package/mcp-server/tests/server-contract.test.cjs +384 -6
  36. package/mcp-server/tests/state-db.test.cjs +14 -2
  37. package/mcp-server/tests/state-ops.test.cjs +108 -0
  38. package/package.json +1 -1
  39. package/skills/ultra-change/SKILL.md +18 -10
  40. package/skills/ultra-deliver/SKILL.md +13 -9
  41. package/skills/ultra-dev/SKILL.md +6 -4
  42. package/skills/ultra-doctor/SKILL.md +22 -7
  43. package/skills/ultra-init/SKILL.md +104 -59
  44. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  45. package/skills/ultra-plan/SKILL.md +8 -5
  46. package/skills/ultra-research/SKILL.md +12 -5
  47. package/skills/ultra-status/SKILL.md +6 -4
  48. package/spec/cli-protocol.md +11 -1
  49. package/spec/mcp-tools.yaml +95 -9
  50. package/spec/schemas/state-db.sql +11 -0
  51. package/ultra-tools/cli.cjs +9 -5
  52. package/ultra-tools/commands/migrate.cjs +62 -36
  53. package/ultra-tools/commands/status.cjs +18 -9
  54. package/ultra-tools/commands/system.cjs +340 -6
  55. package/ultra-tools/commands/task.cjs +7 -1
  56. package/ultra-tools/status-command.test.cjs +29 -0
  57. 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 seedMigratedBaseline(project) {
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', 'migrated', 'ready', 'test', 'legacy 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
- seedMigratedBaseline(proj);
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
- seedMigratedBaseline(proj);
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
- seedMigratedBaseline(proj);
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, '11.0');
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: 'ready' });
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());