ultra-builder-pro-cli 0.11.0 → 0.13.0

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