ultra-builder-pro-cli 0.12.1 → 0.14.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 (88) 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 +95 -1
  4. package/README.md +31 -16
  5. package/adapters/_shared/codex-assets.cjs +23 -163
  6. package/adapters/_shared/handbook.cjs +2 -2
  7. package/adapters/_shared/runtime-assets.cjs +1 -0
  8. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  9. package/adapters/opencode.js +30 -120
  10. package/adapters/tests/claude.test.cjs +3 -2
  11. package/adapters/tests/codex-hook.test.cjs +34 -7
  12. package/adapters/tests/codex.test.cjs +21 -6
  13. package/adapters/tests/kimi-hook.test.cjs +34 -7
  14. package/adapters/tests/opencode.test.cjs +70 -84
  15. package/commands/ultra-init.md +1 -1
  16. package/docs/AGENT-CONTEXT.md +10 -6
  17. package/docs/ARCHITECTURE.md +40 -17
  18. package/docs/PLAN.zh-CN.md +5 -4
  19. package/docs/ROADMAP.md +2 -2
  20. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  21. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  22. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  23. package/hooks/active_task_context.py +22 -50
  24. package/hooks/context_spine.py +61 -299
  25. package/hooks/health_check.py +19 -17
  26. package/hooks/pre_stop_check.py +28 -33
  27. package/hooks/subagent_tracker.py +13 -19
  28. package/hooks/workflow_checkpoint.py +39 -47
  29. package/hooks/workflow_context.py +4 -9
  30. package/hooks/workflow_resume.py +48 -128
  31. package/mcp-server/breadcrumb.cjs +22 -0
  32. package/mcp-server/lib/archive-journal.cjs +192 -0
  33. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  34. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  35. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  36. package/mcp-server/lib/change-workflow.cjs +249 -75
  37. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  38. package/mcp-server/lib/context-spine.test.cjs +5 -0
  39. package/mcp-server/lib/doctor.cjs +14 -1
  40. package/mcp-server/lib/doctor.test.cjs +42 -4
  41. package/mcp-server/lib/init-project.cjs +292 -9
  42. package/mcp-server/lib/prd-parser.cjs +14 -41
  43. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  44. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  45. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  46. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  47. package/mcp-server/lib/state-authority.cjs +9 -3
  48. package/mcp-server/lib/state-db.cjs +154 -32
  49. package/mcp-server/lib/state-db.test.cjs +136 -4
  50. package/mcp-server/lib/state-ops.cjs +68 -6
  51. package/mcp-server/lib/task-expander.cjs +14 -37
  52. package/mcp-server/lib/task-expander.test.cjs +80 -164
  53. package/mcp-server/server.cjs +34 -37
  54. package/mcp-server/tests/init-project.test.cjs +162 -1
  55. package/mcp-server/tests/migrate.test.cjs +33 -0
  56. package/mcp-server/tests/server-contract.test.cjs +391 -17
  57. package/mcp-server/tests/state-db.test.cjs +14 -2
  58. package/mcp-server/tests/state-ops.test.cjs +108 -0
  59. package/package.json +2 -4
  60. package/skills/cc-collab/SKILL.md +2 -2
  61. package/skills/codex-collab/SKILL.md +2 -3
  62. package/skills/ultra-change/SKILL.md +18 -10
  63. package/skills/ultra-deliver/SKILL.md +13 -9
  64. package/skills/ultra-dev/SKILL.md +6 -4
  65. package/skills/ultra-doctor/SKILL.md +22 -7
  66. package/skills/ultra-init/SKILL.md +104 -59
  67. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  68. package/skills/ultra-plan/SKILL.md +20 -7
  69. package/skills/ultra-research/SKILL.md +12 -5
  70. package/skills/ultra-status/SKILL.md +6 -4
  71. package/skills/ultra-verify/SKILL.md +21 -30
  72. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  73. package/spec/cli-protocol.md +11 -1
  74. package/spec/mcp-tools.yaml +133 -19
  75. package/spec/schemas/state-db.sql +11 -0
  76. package/ultra-tools/cli.cjs +9 -5
  77. package/ultra-tools/commands/migrate.cjs +62 -36
  78. package/ultra-tools/commands/status.cjs +18 -9
  79. package/ultra-tools/commands/system.cjs +340 -6
  80. package/ultra-tools/commands/task.cjs +7 -1
  81. package/ultra-tools/status-command.test.cjs +29 -0
  82. package/ultra-tools/system-command.test.cjs +252 -1
  83. package/mcp-server/lib/llm-client.cjs +0 -157
  84. package/mcp-server/lib/llm-client.test.cjs +0 -156
  85. package/orchestrator/planner/expand-prompt.cjs +0 -62
  86. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  87. package/orchestrator/planner/prd-prompt.cjs +0 -44
  88. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
@@ -122,6 +122,39 @@ test('migrate forward inserts tasks + events, records migration_history, creates
122
122
  }
123
123
  });
124
124
 
125
+ test('migrate imports a v4.5 projection into schema 12 authority and requires evidence-backed re-adoption', () => {
126
+ const dir = tmpProject();
127
+ try {
128
+ const tasksPath = path.join(dir, '.ultra', 'tasks', 'tasks.json');
129
+ const projection = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
130
+ delete projection.version;
131
+ projection.schema_version = '4.5';
132
+ projection.source = '.ultra/state.db';
133
+ fs.writeFileSync(tasksPath, `${JSON.stringify(projection, null, 2)}\n`);
134
+
135
+ const r = runCli(['migrate', '--from=4.5', '--to=12.0', '--source-dir', dir]);
136
+ assert.equal(r.code, 0);
137
+ assert.equal(r.envelope.data.from, '4.5');
138
+ assert.equal(r.envelope.data.to, '12.0');
139
+ assert.match(path.basename(r.envelope.data.backup_dir), /^backup-v4\.5-/);
140
+
141
+ const db = openStateDb(path.join(dir, '.ultra', 'state.db'));
142
+ try {
143
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count, 3);
144
+ const baseline = db.prepare(
145
+ "SELECT mode, status, gaps_json FROM baselines WHERE id = 'migrated-baseline'",
146
+ ).get();
147
+ assert.deepEqual(
148
+ { mode: baseline.mode, status: baseline.status },
149
+ { mode: 'migrated', status: 'adopting' },
150
+ );
151
+ assert.equal(JSON.parse(baseline.gaps_json)[0].id, 'legacy-rebaseline-required');
152
+ } finally { closeStateDb(db); }
153
+ } finally {
154
+ fs.rmSync(dir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
125
158
  test('migrate --rollback restores .ultra contents and writes a rollback row', () => {
126
159
  const dir = tmpProject();
127
160
  try {
@@ -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,17 @@ function expectError(result) {
78
91
  return JSON.parse(result.content[0].text).error;
79
92
  }
80
93
 
94
+ function prdTasks(taskId) {
95
+ return [{
96
+ id: taskId,
97
+ title: `Implement ${taskId}`,
98
+ type: 'feature',
99
+ priority: 'P1',
100
+ deps: [],
101
+ files_modified: [`src/${taskId}.js`],
102
+ }];
103
+ }
104
+
81
105
  test('listTools returns workflow tools and exposes no Ultra memory API', async () => {
82
106
  const proj = tmpProject();
83
107
  try {
@@ -204,7 +228,7 @@ test('baseline MCP tools adopt and converge an existing checkout without storing
204
228
  test('change.create + change.context expose a continuous change unit with external provider references', async () => {
205
229
  const proj = tmpProject();
206
230
  try {
207
- seedMigratedBaseline(proj);
231
+ seedReadyBaseline(proj);
208
232
  fs.writeFileSync(path.join(proj.dir, 'README.md'), '# project\n');
209
233
  await withClient(proj, async (client) => {
210
234
  const created = await client.callTool({
@@ -256,7 +280,7 @@ test('change.create + change.context expose a continuous change unit with extern
256
280
  test('spec-learning MCP tools persist an approval-gated candidate and projection', async () => {
257
281
  const proj = tmpProject();
258
282
  try {
259
- seedMigratedBaseline(proj);
283
+ seedReadyBaseline(proj);
260
284
  await withClient(proj, async (client) => {
261
285
  await client.callTool({
262
286
  name: 'change.create',
@@ -318,7 +342,7 @@ test('spec-learning MCP tools persist an approval-gated candidate and projection
318
342
  test('system.doctor returns structured provider ownership and projection health', async () => {
319
343
  const proj = tmpProject();
320
344
  try {
321
- seedMigratedBaseline(proj);
345
+ seedReadyBaseline(proj);
322
346
  await withClient(proj, async (client) => {
323
347
  await client.callTool({
324
348
  name: 'task.create',
@@ -359,6 +383,7 @@ test('published MCP contract contains exactly the live server tools', () => {
359
383
  test('task.create + task.get round trip via MCP', async () => {
360
384
  const proj = tmpProject();
361
385
  try {
386
+ seedReadyBaseline(proj);
362
387
  await withClient(proj, async (client) => {
363
388
  const created = await client.callTool({
364
389
  name: 'task.create',
@@ -379,6 +404,222 @@ test('task.create + task.get round trip via MCP', async () => {
379
404
  }
380
405
  });
381
406
 
407
+ test('task.create requires ready baseline authority or an already-authorized active change', async () => {
408
+ for (const baseline of [
409
+ null,
410
+ { mode: 'greenfield', status: 'draft' },
411
+ { mode: 'migrated', status: 'adopting' },
412
+ ]) {
413
+ const proj = tmpProject();
414
+ try {
415
+ if (baseline) seedBaseline(proj, baseline);
416
+ await withClient(proj, async (client) => {
417
+ const result = await client.callTool({
418
+ name: 'task.create',
419
+ arguments: {
420
+ id: `blocked-${baseline?.mode || 'missing'}`,
421
+ title: 'Must wait for baseline readiness', type: 'feature', priority: 'P1',
422
+ },
423
+ });
424
+ assert.equal(expectError(result).code, 'BASELINE_NOT_READY');
425
+ });
426
+ } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
427
+ }
428
+
429
+ const incidentProject = tmpProject();
430
+ try {
431
+ seedBaseline(incidentProject, { mode: 'brownfield', status: 'adopting' });
432
+ await withClient(incidentProject, async (client) => {
433
+ const change = await client.callTool({
434
+ name: 'change.create',
435
+ arguments: {
436
+ id: 'incident-task-bypass', title: 'Restore production', kind: 'incident',
437
+ intent: 'Repair the urgent production path while adoption remains incomplete.',
438
+ baseline_bypass: {
439
+ reason: 'Production recovery cannot wait for baseline convergence.',
440
+ approved_by: 'incident-commander',
441
+ },
442
+ },
443
+ });
444
+ assert.equal(readToolPayload(change).change.id, 'incident-task-bypass');
445
+ const task = await client.callTool({
446
+ name: 'task.create',
447
+ arguments: {
448
+ id: 'authorized-incident-task', title: 'Repair the production path',
449
+ type: 'bugfix', priority: 'P0', change_id: 'incident-task-bypass',
450
+ },
451
+ });
452
+ assert.equal(readToolPayload(task).id, 'authorized-incident-task');
453
+ });
454
+ } finally { fs.rmSync(incidentProject.dir, { recursive: true, force: true }); }
455
+ });
456
+
457
+ test('task.create enforces terminal-change and parent ownership invariants at the MCP boundary', async () => {
458
+ const proj = tmpProject();
459
+ try {
460
+ seedReadyBaseline(proj);
461
+ const initialized = initStateDb(proj.dbPath);
462
+ for (const [id, status] of [
463
+ ['parent-change', 'active'],
464
+ ['other-change', 'active'],
465
+ ['terminal-change', 'archived'],
466
+ ]) {
467
+ initialized.db.prepare(
468
+ `INSERT INTO changes (id, title, kind, status, intent, artifact_root)
469
+ VALUES (?, ?, 'quick', ?, ?, ?)`,
470
+ ).run(id, id, status, `Intent for ${id}.`, `.ultra/changes/active/${id}`);
471
+ }
472
+ closeStateDb(initialized.db);
473
+
474
+ await withClient(proj, async (client) => {
475
+ const parent = await client.callTool({
476
+ name: 'task.create',
477
+ arguments: {
478
+ id: 'owned-parent', title: 'Owned parent task', type: 'feature', priority: 'P1',
479
+ change_id: 'parent-change',
480
+ },
481
+ });
482
+ assert.equal(parent.isError, undefined);
483
+
484
+ const child = await client.callTool({
485
+ name: 'task.create',
486
+ arguments: {
487
+ id: 'owned-child', title: 'Owned child task', type: 'feature', priority: 'P1',
488
+ parent_id: 'owned-parent',
489
+ },
490
+ });
491
+ assert.equal(child.isError, undefined);
492
+ const inherited = readToolPayload(await client.callTool({
493
+ name: 'task.get', arguments: { id: 'owned-child' },
494
+ }));
495
+ assert.equal(inherited.task.change_id, 'parent-change');
496
+
497
+ const mismatch = await client.callTool({
498
+ name: 'task.create',
499
+ arguments: {
500
+ id: 'cross-change-child', title: 'Cross change child', type: 'feature', priority: 'P1',
501
+ parent_id: 'owned-parent', change_id: 'other-change',
502
+ },
503
+ });
504
+ assert.equal(expectError(mismatch).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
505
+
506
+ const orphan = await client.callTool({
507
+ name: 'task.create',
508
+ arguments: {
509
+ id: 'orphan-child', title: 'Orphan child task', type: 'feature', priority: 'P1',
510
+ parent_id: 'missing-parent',
511
+ },
512
+ });
513
+ assert.equal(expectError(orphan).code, 'TASK_NOT_FOUND');
514
+
515
+ const terminal = await client.callTool({
516
+ name: 'task.create',
517
+ arguments: {
518
+ id: 'terminal-task', title: 'Terminal task', type: 'feature', priority: 'P1',
519
+ change_id: 'terminal-change',
520
+ },
521
+ });
522
+ assert.equal(expectError(terminal).code, 'CHANGE_NOT_MUTABLE');
523
+
524
+ const missing = await client.callTool({
525
+ name: 'task.create',
526
+ arguments: {
527
+ id: 'missing-change-task', title: 'Missing change task', type: 'feature', priority: 'P1',
528
+ change_id: 'missing-change',
529
+ },
530
+ });
531
+ assert.equal(expectError(missing).code, 'CHANGE_NOT_FOUND');
532
+ });
533
+ } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
534
+ });
535
+
536
+ test('task.parse_prd keeps dry runs read-only and blocks persistence before baseline readiness', async () => {
537
+ const dryProject = tmpProject();
538
+ try {
539
+ const { db } = initStateDb(dryProject.dbPath);
540
+ try {
541
+ const result = await dispatchTool('task.parse_prd', {
542
+ tasks: prdTasks('dry-run-task'), dry_run: true,
543
+ }, db, { rootDir: dryProject.dir });
544
+ assert.equal(result.tasks[0].id, 'dry-run-task');
545
+ assert.equal(ops.listTasks(db, {}).length, 0);
546
+ } finally { closeStateDb(db); }
547
+ } finally { fs.rmSync(dryProject.dir, { recursive: true, force: true }); }
548
+
549
+ for (const baseline of [
550
+ null,
551
+ { mode: 'greenfield', status: 'draft' },
552
+ { mode: 'migrated', status: 'adopting' },
553
+ ]) {
554
+ const proj = tmpProject();
555
+ try {
556
+ if (baseline) seedBaseline(proj, baseline);
557
+ const { db } = initStateDb(proj.dbPath);
558
+ try {
559
+ await assert.rejects(
560
+ dispatchTool('task.parse_prd', {
561
+ tasks: prdTasks(`blocked-prd-${baseline?.mode || 'missing'}`), dry_run: false,
562
+ }, db, { rootDir: proj.dir }),
563
+ (error) => error.code === 'BASELINE_NOT_READY',
564
+ );
565
+ assert.equal(ops.listTasks(db, {}).length, 0);
566
+ } finally { closeStateDb(db); }
567
+ } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
568
+ }
569
+ });
570
+
571
+ test('task.parse_prd persists only under ready or already-authorized change ownership', async () => {
572
+ const readyProject = tmpProject();
573
+ try {
574
+ seedReadyBaseline(readyProject);
575
+ const { db } = initStateDb(readyProject.dbPath);
576
+ try {
577
+ await dispatchTool('task.parse_prd', {
578
+ tasks: prdTasks('ready-prd-task'), dry_run: false,
579
+ }, db, { rootDir: readyProject.dir });
580
+ assert.equal(ops.readTask(db, 'ready-prd-task').change_id, null);
581
+ } finally { closeStateDb(db); }
582
+ } finally { fs.rmSync(readyProject.dir, { recursive: true, force: true }); }
583
+
584
+ for (const kind of ['quick', 'incident']) {
585
+ const proj = tmpProject();
586
+ try {
587
+ seedReadyBaseline(proj);
588
+ const { db } = initStateDb(proj.dbPath);
589
+ try {
590
+ if (kind === 'incident') {
591
+ db.prepare(
592
+ "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
593
+ ).run();
594
+ }
595
+ const changeId = `parsed-${kind}`;
596
+ changes.createChange(db, {
597
+ id: changeId,
598
+ title: kind === 'incident' ? 'Recover parsed incident work' : 'Continue parsed ordinary work',
599
+ kind,
600
+ intent: 'Keep parsed tasks bound to the already-authorized continuous change.',
601
+ ...(kind === 'incident' ? {
602
+ baseline_bypass: {
603
+ approved_by: 'incident-commander',
604
+ reason: 'Production recovery requires an approved task graph before adoption converges.',
605
+ },
606
+ } : {}),
607
+ }, { rootDir: proj.dir });
608
+ if (kind === 'quick') {
609
+ db.prepare(
610
+ "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
611
+ ).run();
612
+ }
613
+ const taskId = `parsed-${kind}-task`;
614
+ await dispatchTool('task.parse_prd', {
615
+ tasks: prdTasks(taskId), dry_run: false, change_id: changeId,
616
+ }, db, { rootDir: proj.dir });
617
+ assert.equal(ops.readTask(db, taskId).change_id, changeId);
618
+ } finally { closeStateDb(db); }
619
+ } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
620
+ }
621
+ });
622
+
382
623
  test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets an empty state.db', async () => {
383
624
  const proj = tmpProject();
384
625
  try {
@@ -411,9 +652,35 @@ test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets
411
652
  }
412
653
  });
413
654
 
655
+ test('state-backed MCP tools return the supported v4.5 projection migration command', async () => {
656
+ const proj = tmpProject();
657
+ try {
658
+ const tasksDir = path.join(proj.dir, '.ultra', 'tasks');
659
+ fs.mkdirSync(tasksDir, { recursive: true });
660
+ fs.writeFileSync(path.join(tasksDir, 'tasks.json'), JSON.stringify({
661
+ schema_version: '4.5', source: '.ultra/state.db',
662
+ tasks: [{
663
+ id: 'projection-only', title: 'projection-only task', type: 'feature', priority: 'P1',
664
+ status: 'pending', deps: [], estimated_days: 1,
665
+ }],
666
+ }));
667
+
668
+ await withClient(proj, async (client) => {
669
+ const listed = await client.callTool({ name: 'task.list', arguments: {} });
670
+ const error = expectError(listed);
671
+ assert.equal(error.code, 'LEGACY_STATE_MIGRATION_REQUIRED');
672
+ assert.match(error.message, /ultra-tools migrate --from=4\.5 --to=12\.0/);
673
+ assert.equal(error.details.projection_version, '4.5');
674
+ });
675
+ } finally {
676
+ fs.rmSync(proj.dir, { recursive: true, force: true });
677
+ }
678
+ });
679
+
414
680
  test('task.update enforces the status state machine', async () => {
415
681
  const proj = tmpProject();
416
682
  try {
683
+ seedReadyBaseline(proj);
417
684
  await withClient(proj, async (client) => {
418
685
  await client.callTool({
419
686
  name: 'task.create',
@@ -443,9 +710,63 @@ test('task.update enforces the status state machine', async () => {
443
710
  }
444
711
  });
445
712
 
713
+ test('task.update preserves established change ownership and freezes terminal change tasks', async () => {
714
+ const proj = tmpProject();
715
+ try {
716
+ seedReadyBaseline(proj);
717
+ const initialized = initStateDb(proj.dbPath);
718
+ try {
719
+ for (const id of ['update-change-a', 'update-change-b', 'update-terminal']) {
720
+ initialized.db.prepare(
721
+ `INSERT INTO changes (id, title, kind, status, intent, artifact_root)
722
+ VALUES (?, ?, 'quick', 'active', ?, ?)`,
723
+ ).run(id, id, `Intent for ${id}.`, `.ultra/changes/active/${id}`);
724
+ }
725
+ ops.createTask(initialized.db, {
726
+ id: 'update-owned-root', title: 'Owned root task', type: 'feature', priority: 'P1',
727
+ change_id: 'update-change-a',
728
+ });
729
+ ops.createTask(initialized.db, {
730
+ id: 'update-terminal-task', title: 'Terminal change task', type: 'feature', priority: 'P1',
731
+ change_id: 'update-terminal',
732
+ });
733
+ initialized.db.prepare("UPDATE changes SET status = 'archived' WHERE id = 'update-terminal'").run();
734
+ } finally { closeStateDb(initialized.db); }
735
+
736
+ await withClient(proj, async (client) => {
737
+ const detach = await client.callTool({
738
+ name: 'task.update', arguments: { id: 'update-owned-root', patch: { change_id: null } },
739
+ });
740
+ assert.equal(expectError(detach).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
741
+
742
+ const cross = await client.callTool({
743
+ name: 'task.update',
744
+ arguments: { id: 'update-owned-root', patch: { change_id: 'update-change-b' } },
745
+ });
746
+ assert.equal(expectError(cross).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
747
+
748
+ const terminal = await client.callTool({
749
+ name: 'task.update', arguments: { id: 'update-terminal-task', patch: { priority: 'P0' } },
750
+ });
751
+ assert.equal(expectError(terminal).code, 'CHANGE_NOT_MUTABLE');
752
+
753
+ await client.callTool({
754
+ name: 'task.create',
755
+ arguments: { id: 'update-unowned', title: 'Unowned task', type: 'feature', priority: 'P1' },
756
+ });
757
+ const firstBinding = readToolPayload(await client.callTool({
758
+ name: 'task.update',
759
+ arguments: { id: 'update-unowned', patch: { change_id: 'update-change-b' } },
760
+ }));
761
+ assert.equal(firstBinding.task.change_id, 'update-change-b');
762
+ });
763
+ } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
764
+ });
765
+
446
766
  test('task.append_event + subscribe_events drive a monotonic cursor', async () => {
447
767
  const proj = tmpProject();
448
768
  try {
769
+ seedReadyBaseline(proj);
449
770
  await withClient(proj, async (client) => {
450
771
  await client.callTool({
451
772
  name: 'task.create',
@@ -483,6 +804,7 @@ test('task.append_event + subscribe_events drive a monotonic cursor', async () =
483
804
  test('task.list filters by status and tag', async () => {
484
805
  const proj = tmpProject();
485
806
  try {
807
+ seedReadyBaseline(proj);
486
808
  await withClient(proj, async (client) => {
487
809
  await client.callTool({ name: 'task.create', arguments: { id: 'l-1', title: 'list one', type: 'feature', priority: 'P0', tag: 'main' } });
488
810
  await client.callTool({ name: 'task.create', arguments: { id: 'l-2', title: 'list two', type: 'feature', priority: 'P1', tag: 'main' } });
@@ -524,6 +846,7 @@ test('task.create rejects bad input via the JSON Schema validator', async () =>
524
846
  test('mutating tools trigger the projector — tasks.json appears under .ultra/', async () => {
525
847
  const proj = tmpProject();
526
848
  try {
849
+ seedReadyBaseline(proj);
527
850
  await withClient(proj, async (client) => {
528
851
  await client.callTool({
529
852
  name: 'task.create',
@@ -543,6 +866,7 @@ test('mutating tools trigger the projector — tasks.json appears under .ultra/'
543
866
  test('output schema drift cannot strand a committed mutation without a projection job', async () => {
544
867
  const proj = tmpProject();
545
868
  try {
869
+ seedReadyBaseline(proj);
546
870
  const runtimeRoot = path.join(proj.dir, 'runtime-contract');
547
871
  fs.mkdirSync(path.join(runtimeRoot, 'spec', 'schemas'), { recursive: true });
548
872
  fs.copyFileSync(path.join(REPO_ROOT, 'package.json'), path.join(runtimeRoot, 'package.json'));
@@ -646,6 +970,34 @@ test('task.init_project returns ULTRA_DIR_EXISTS on re-init without overwrite',
646
970
  }
647
971
  });
648
972
 
973
+ test('task.init_project resume preserves existing files and installs missing current assets', async () => {
974
+ const proj = tmpProject();
975
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-resume-'));
976
+ try {
977
+ fs.mkdirSync(path.join(target, '.ultra', 'specs'), { recursive: true });
978
+ fs.writeFileSync(path.join(target, '.ultra', 'specs', 'product.md'), '# Preserved contract\n');
979
+ await withClient(proj, async (client) => {
980
+ const result = await client.callTool({
981
+ name: 'task.init_project',
982
+ arguments: {
983
+ target_dir: target, project_name: 'resume-project', mode: 'brownfield', resume: true,
984
+ },
985
+ });
986
+ const payload = readToolPayload(result);
987
+ assert.equal(payload.status, 'resumed');
988
+ assert.equal(payload.baseline.status, 'adopting');
989
+ assert.equal(
990
+ fs.readFileSync(path.join(target, '.ultra', 'specs', 'product.md'), 'utf8'),
991
+ '# Preserved contract\n',
992
+ );
993
+ assert.ok(payload.copied_files.includes('specs/architecture.md'));
994
+ });
995
+ } finally {
996
+ fs.rmSync(proj.dir, { recursive: true, force: true });
997
+ fs.rmSync(target, { recursive: true, force: true });
998
+ }
999
+ });
1000
+
649
1001
  async function seedTask(client, id = 's-1') {
650
1002
  await client.callTool({
651
1003
  name: 'task.create',
@@ -656,6 +1008,7 @@ async function seedTask(client, id = 's-1') {
656
1008
  test('session.admission_check + session.spawn: happy path returns sid and paths', async () => {
657
1009
  const proj = tmpProject();
658
1010
  try {
1011
+ seedReadyBaseline(proj);
659
1012
  await withClient(proj, async (client) => {
660
1013
  await seedTask(client, 's-happy');
661
1014
 
@@ -702,6 +1055,7 @@ test('session.spawn rejects a retired runtime at the MCP schema boundary', async
702
1055
  test('session.spawn refuses second session for same task without takeover (ADMISSION_DENIED)', async () => {
703
1056
  const proj = tmpProject();
704
1057
  try {
1058
+ seedReadyBaseline(proj);
705
1059
  await withClient(proj, async (client) => {
706
1060
  await seedTask(client, 's-conflict');
707
1061
  await client.callTool({
@@ -732,6 +1086,7 @@ test('session.spawn refuses second session for same task without takeover (ADMIS
732
1086
  test('session.spawn with takeover=true crashes the old session and succeeds', async () => {
733
1087
  const proj = tmpProject();
734
1088
  try {
1089
+ seedReadyBaseline(proj);
735
1090
  await withClient(proj, async (client) => {
736
1091
  await seedTask(client, 's-takeover');
737
1092
  const first = readToolPayload(await client.callTool({
@@ -758,6 +1113,7 @@ test('session.spawn with takeover=true crashes the old session and succeeds', as
758
1113
  test('session.subscribe_events sees task events with ≤1s latency (D31 id cursor)', async () => {
759
1114
  const proj = tmpProject();
760
1115
  try {
1116
+ seedReadyBaseline(proj);
761
1117
  await withClient(proj, async (client) => {
762
1118
  await seedTask(client, 's-sub');
763
1119
 
@@ -793,6 +1149,7 @@ test('session.subscribe_events sees task events with ≤1s latency (D31 id curso
793
1149
  test('session.subscribe_events filters events by sid', async () => {
794
1150
  const proj = tmpProject();
795
1151
  try {
1152
+ seedReadyBaseline(proj);
796
1153
  await withClient(proj, async (client) => {
797
1154
  await seedTask(client, 's-filter-a');
798
1155
  await seedTask(client, 's-filter-b');
@@ -866,6 +1223,7 @@ test('MCP rejects declared inputs that have no runtime behavior', async () => {
866
1223
  test('session.heartbeat refreshes lease; session.close marks completed', async () => {
867
1224
  const proj = tmpProject();
868
1225
  try {
1226
+ seedReadyBaseline(proj);
869
1227
  await withClient(proj, async (client) => {
870
1228
  await seedTask(client, 's-heart');
871
1229
  const spawn = readToolPayload(await client.callTool({
@@ -917,6 +1275,7 @@ test('retired memory tools are rejected as unknown', async () => {
917
1275
  test('task.dependency_topo: happy path groups tasks into correct waves', async () => {
918
1276
  const proj = tmpProject();
919
1277
  try {
1278
+ seedReadyBaseline(proj);
920
1279
  await withClient(proj, async (client) => {
921
1280
  for (const [id, deps] of [['A', []], ['B', ['A']], ['C', ['A']]]) {
922
1281
  await client.callTool({
@@ -938,42 +1297,48 @@ test('task.dependency_topo: happy path groups tasks into correct waves', async (
938
1297
  }
939
1298
  });
940
1299
 
941
- test('task.parse_prd: neither prd_path nor prd_text → NO_INPUT', async () => {
1300
+ test('task.parse_prd: missing host-derived tasks → validation error', async () => {
942
1301
  const proj = tmpProject();
943
1302
  try {
944
1303
  await withClient(proj, async (client) => {
945
1304
  const resp = await client.callTool({ name: 'task.parse_prd', arguments: {} });
946
1305
  const err = expectError(resp);
947
- assert.equal(err.code, 'NO_INPUT');
1306
+ assert.equal(err.code, 'VALIDATION_ERROR');
948
1307
  });
949
1308
  } finally {
950
1309
  fs.rmSync(proj.dir, { recursive: true, force: true });
951
1310
  }
952
1311
  });
953
1312
 
954
- test('task.parse_prd: missing LLM credentials → NO_LLM_CREDENTIALS', async () => {
1313
+ test('task.parse_prd works without provider credentials', async () => {
955
1314
  const proj = tmpProject();
956
1315
  try {
957
1316
  await withClientNoLlmKey(proj, async (client) => {
958
1317
  const resp = await client.callTool({
959
1318
  name: 'task.parse_prd',
960
- arguments: { prd_text: 'Build a login feature with email and password.' },
1319
+ arguments: { tasks: prdTasks('no-key-prd'), dry_run: true },
961
1320
  });
962
- const err = expectError(resp);
963
- assert.equal(err.code, 'NO_LLM_CREDENTIALS');
1321
+ const data = readToolPayload(resp);
1322
+ assert.equal(data.tasks[0].id, 'no-key-prd');
964
1323
  });
965
1324
  } finally {
966
1325
  fs.rmSync(proj.dir, { recursive: true, force: true });
967
1326
  }
968
1327
  });
969
1328
 
970
- test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', async () => {
1329
+ test('task.expand: unknown parent → TASK_NOT_FOUND without a provider key', async () => {
971
1330
  const proj = tmpProject();
972
1331
  try {
973
1332
  await withClientNoLlmKey(proj, async (client) => {
974
1333
  const resp = await client.callTool({
975
1334
  name: 'task.expand',
976
- arguments: { id: 'nonexistent-parent' },
1335
+ arguments: {
1336
+ id: 'nonexistent-parent',
1337
+ children: [{
1338
+ id: 'unused-child', title: 'Unused child task',
1339
+ type: 'feature', priority: 'P1',
1340
+ }],
1341
+ },
977
1342
  });
978
1343
  const err = expectError(resp);
979
1344
  assert.equal(err.code, 'TASK_NOT_FOUND');
@@ -983,9 +1348,10 @@ test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', asyn
983
1348
  }
984
1349
  });
985
1350
 
986
- test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIALS', async () => {
1351
+ test('task.expand persists host-derived children without provider credentials', async () => {
987
1352
  const proj = tmpProject();
988
1353
  try {
1354
+ seedReadyBaseline(proj);
989
1355
  await withClientNoLlmKey(proj, async (client) => {
990
1356
  await client.callTool({
991
1357
  name: 'task.create',
@@ -993,10 +1359,16 @@ test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIAL
993
1359
  });
994
1360
  const resp = await client.callTool({
995
1361
  name: 'task.expand',
996
- arguments: { id: 'parent-1' },
1362
+ arguments: {
1363
+ id: 'parent-1',
1364
+ children: [{
1365
+ id: 'child-no-key', title: 'Implement child without provider key',
1366
+ type: 'feature', priority: 'P1', complexity: 4, deps: [], files_modified: [],
1367
+ }],
1368
+ },
997
1369
  });
998
- const err = expectError(resp);
999
- assert.equal(err.code, 'NO_LLM_CREDENTIALS');
1370
+ const data = readToolPayload(resp);
1371
+ assert.equal(data.children[0].id, 'child-no-key');
1000
1372
  });
1001
1373
  } finally {
1002
1374
  fs.rmSync(proj.dir, { recursive: true, force: true });
@@ -1022,6 +1394,7 @@ test('plan.export: no tasks → NO_TASKS', async () => {
1022
1394
  test('plan.export → plan.get round trip: artifact on disk + retrievable', async () => {
1023
1395
  const proj = tmpProject();
1024
1396
  try {
1397
+ seedReadyBaseline(proj);
1025
1398
  await withClient(proj, async (client) => {
1026
1399
  for (const [id, deps, files] of [
1027
1400
  ['p-a', [], ['src/a.ts']],
@@ -1068,6 +1441,7 @@ test('plan.get: no plan written yet → NO_PLAN', async () => {
1068
1441
  test('task.dependency_topo: cycle returns CYCLE_DETECTED with cycles in details', async () => {
1069
1442
  const proj = tmpProject();
1070
1443
  try {
1444
+ seedReadyBaseline(proj);
1071
1445
  await withClient(proj, async (client) => {
1072
1446
  await client.callTool({
1073
1447
  name: 'task.create',