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
@@ -28,6 +28,7 @@ const parser = require('./lib/prd-parser.cjs');
28
28
  const expander = require('./lib/task-expander.cjs');
29
29
  const planStore = require('./lib/plan-store.cjs');
30
30
  const { initProject } = require('./lib/init-project.cjs');
31
+ const baselines = require('./lib/baseline-workflow.cjs');
31
32
  const changes = require('./lib/change-workflow.cjs');
32
33
  const runtimeState = require('./lib/runtime-state.cjs');
33
34
  const doctor = require('./lib/doctor.cjs');
@@ -67,6 +68,13 @@ const PLAN_TOOLS = Object.freeze([
67
68
  'plan.get',
68
69
  ]);
69
70
 
71
+ const BASELINE_TOOLS = Object.freeze([
72
+ 'baseline.start',
73
+ 'baseline.record',
74
+ 'baseline.get',
75
+ 'baseline.converge',
76
+ ]);
77
+
70
78
  const CHANGE_TOOLS = Object.freeze([
71
79
  'change.create',
72
80
  'change.update',
@@ -85,18 +93,20 @@ const SYSTEM_TOOLS = Object.freeze([
85
93
  ]);
86
94
 
87
95
  const REGISTERED_TOOLS = Object.freeze([
88
- ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...CHANGE_TOOLS, ...SYSTEM_TOOLS,
96
+ ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...BASELINE_TOOLS, ...CHANGE_TOOLS,
97
+ ...SYSTEM_TOOLS,
89
98
  ]);
90
99
 
91
100
  const STATELESS_TOOLS = new Set(['task.init_project']);
92
101
 
93
- // init_project mutates the filesystem of an unrelated project, not state.db —
94
- // do NOT run the projector (it would overwrite the freshly-copied template).
102
+ // init_project owns its target project's schema, baseline seed, and first
103
+ // projection internally. Do not enqueue a second projection on the caller DB.
95
104
  const MUTATING_TOOLS = new Set([
96
105
  'task.create', 'task.update', 'task.delete', 'task.append_event', 'task.switch_tag',
97
106
  'task.parse_prd', 'task.expand',
98
107
  'session.spawn', 'session.close', 'session.heartbeat',
99
108
  'plan.export',
109
+ 'baseline.start', 'baseline.record', 'baseline.converge',
100
110
  'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
101
111
  'change.learning_propose', 'change.learning_resolve',
102
112
  ]);
@@ -138,7 +148,10 @@ async function dispatchTool(name, input, db, ctx = {}) {
138
148
  case 'task.create': {
139
149
  const { randomUUID } = require('node:crypto');
140
150
  const id = input.id || `task-${randomUUID()}`;
141
- const task = ops.createTask(db, { ...input, id });
151
+ const changeId = ops.resolveTaskCreationChangeId(db, input);
152
+ const authorizedInput = { ...input, change_id: changeId };
153
+ changes.assertTaskCreationAllowed(db, authorizedInput, { rootDir: ctx.rootDir || process.cwd() });
154
+ const task = ops.createTask(db, { ...authorizedInput, id });
142
155
  return { id: task.id, status: task.status, created_at: task.created_at };
143
156
  }
144
157
  case 'task.update': {
@@ -276,20 +289,25 @@ async function dispatchTool(name, input, db, ctx = {}) {
276
289
  err.code = 'NO_INPUT';
277
290
  throw err;
278
291
  }
292
+ const dryRun = input.dry_run === true;
293
+ const rootDir = ctx.rootDir || process.cwd();
294
+ const creationAuthority = { change_id: input.change_id };
295
+ if (!dryRun) changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
279
296
  const client = buildLlmClient(ctx);
280
297
  const parsed = await parser.parsePrd(prdText, { llmClient: client, tag: input.tag });
281
298
  const shaped = parsed.tasks.map((t) => ({
282
299
  id: t.id, title: t.title, type: t.type, priority: t.priority,
283
300
  complexity: t.complexity, deps: t.deps, files_modified: t.files_modified,
284
- tag: t.tag,
301
+ tag: t.tag, change_id: input.change_id,
285
302
  }));
286
- const dryRun = input.dry_run === true;
287
303
  if (!dryRun) {
288
304
  try {
289
305
  ops.tx(db, () => {
306
+ changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
290
307
  for (const t of shaped) ops.createTask(db, t);
291
308
  });
292
309
  } catch (err) {
310
+ if (['BASELINE_NOT_READY', 'CHANGE_NOT_FOUND', 'CHANGE_NOT_MUTABLE'].includes(err.code)) throw err;
293
311
  const wrap = new Error(`failed to persist parsed tasks: ${err.message}`);
294
312
  wrap.code = 'PARSE_FAILED';
295
313
  wrap.cause = err;
@@ -314,12 +332,15 @@ async function dispatchTool(name, input, db, ctx = {}) {
314
332
  err.code = 'ALREADY_EXPANDED';
315
333
  throw err;
316
334
  }
335
+ const rootDir = ctx.rootDir || process.cwd();
336
+ changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
317
337
  const client = buildLlmClient(ctx);
318
338
  const result = await expander.expandTask(db, {
319
339
  id: input.id,
320
340
  sub_count: input.sub_count,
321
341
  strategy: input.strategy,
322
342
  llmClient: client,
343
+ rootDir,
323
344
  });
324
345
  return { parent_id: result.parent_id, children: result.children };
325
346
  }
@@ -352,6 +373,39 @@ async function dispatchTool(name, input, db, ctx = {}) {
352
373
  }
353
374
  return { plan: planStore.selectSection(loaded, input.section) };
354
375
  }
376
+ case 'baseline.start': {
377
+ return {
378
+ baseline: baselines.startBaseline(
379
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
380
+ ),
381
+ };
382
+ }
383
+ case 'baseline.record': {
384
+ return {
385
+ baseline: baselines.recordBaseline(
386
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
387
+ ),
388
+ };
389
+ }
390
+ case 'baseline.get': {
391
+ const baseline = baselines.readBaseline(db, input.id);
392
+ if (!baseline) {
393
+ const err = new Error(input.id ? `baseline ${input.id} not found` : 'current baseline not found');
394
+ err.code = 'BASELINE_NOT_FOUND';
395
+ throw err;
396
+ }
397
+ return {
398
+ baseline,
399
+ health: baselines.inspectBaseline(db, {
400
+ rootDir: ctx.rootDir || process.cwd(), id: input.id,
401
+ }),
402
+ };
403
+ }
404
+ case 'baseline.converge': {
405
+ return baselines.convergeBaseline(
406
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
407
+ );
408
+ }
355
409
  case 'change.create': {
356
410
  const { randomUUID } = require('node:crypto');
357
411
  const id = input.id || `chg-${randomUUID().slice(0, 12)}`;
@@ -598,6 +652,8 @@ module.exports = {
598
652
  dispatchTool,
599
653
  TASK_TOOLS,
600
654
  SESSION_TOOLS,
655
+ PLAN_TOOLS,
656
+ BASELINE_TOOLS,
601
657
  CHANGE_TOOLS,
602
658
  SYSTEM_TOOLS,
603
659
  REGISTERED_TOOLS,
@@ -5,9 +5,13 @@ const assert = require('node:assert/strict');
5
5
  const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
+ const { execFileSync } = require('node:child_process');
8
9
  const Database = require('better-sqlite3');
9
10
 
10
- const { initProject, InitProjectError, DEFAULT_TEMPLATE } = require('../lib/init-project.cjs');
11
+ const {
12
+ initProject, InitProjectError, DEFAULT_TEMPLATE, classifyRepository,
13
+ } = require('../lib/init-project.cjs');
14
+ const { EXPECTED_VERSION } = require('../lib/state-db.cjs');
11
15
 
12
16
  function mkTempDir(prefix = 'ubp-init-test-') {
13
17
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -27,6 +31,7 @@ test('initProject copies bundled template into .ultra/', () => {
27
31
  stack: 'node',
28
32
  });
29
33
  assert.equal(r.status, 'created');
34
+ assert.equal(r.mode, 'greenfield');
30
35
  assert.equal(r.created_path, path.join(target, '.ultra'));
31
36
  assert.ok(r.copied_files.includes('tasks/tasks.json'));
32
37
  assert.ok(r.copied_files.includes('specs/product.md'));
@@ -36,9 +41,11 @@ test('initProject copies bundled template into .ultra/', () => {
36
41
  assert.ok(fs.existsSync(r.state_db_path));
37
42
  const db = new Database(r.state_db_path, { readonly: true });
38
43
  try {
39
- const version = db.prepare("SELECT version FROM schema_version WHERE version = '9.1'").get();
40
- assert.equal(version.version, '9.1');
44
+ const version = db.prepare('SELECT version FROM schema_version WHERE version = ?').get(EXPECTED_VERSION);
45
+ assert.equal(version.version, EXPECTED_VERSION);
41
46
  assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE name = 'changes'").get());
47
+ const baseline = db.prepare('SELECT mode, status, project_name FROM baselines').get();
48
+ assert.deepEqual(baseline, { mode: 'greenfield', status: 'draft', project_name: 'demo' });
42
49
  } finally {
43
50
  db.close();
44
51
  }
@@ -46,7 +53,121 @@ test('initProject copies bundled template into .ultra/', () => {
46
53
  } finally { cleanup(target); }
47
54
  });
48
55
 
49
- test('initProject injects project metadata into tasks.json', () => {
56
+ test('initProject auto-detects existing source as brownfield and starts adoption', () => {
57
+ const target = mkTempDir();
58
+ try {
59
+ fs.mkdirSync(path.join(target, 'src'));
60
+ fs.writeFileSync(path.join(target, 'package.json'), '{"name":"legacy-app"}\n');
61
+ fs.writeFileSync(path.join(target, 'src', 'index.js'), 'module.exports = true;\n');
62
+ const result = initProject({ target_dir: target, project_name: 'legacy-app' });
63
+ assert.equal(result.mode, 'brownfield');
64
+ const db = new Database(result.state_db_path, { readonly: true });
65
+ try {
66
+ const baseline = db.prepare('SELECT mode, status, scope_json FROM baselines').get();
67
+ assert.equal(baseline.mode, 'brownfield');
68
+ assert.equal(baseline.status, 'adopting');
69
+ assert.deepEqual(JSON.parse(baseline.scope_json), ['.']);
70
+ } finally {
71
+ db.close();
72
+ }
73
+ } finally { cleanup(target); }
74
+ });
75
+
76
+ test('auto classification keeps a documentation-and-manifest-only skeleton greenfield', () => {
77
+ const target = mkTempDir();
78
+ try {
79
+ fs.writeFileSync(path.join(target, 'README.md'), '# New service skeleton\n');
80
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
81
+ name: 'new-service', private: true, scripts: { test: 'node --test' },
82
+ }));
83
+ const profile = classifyRepository(target);
84
+ assert.equal(profile.mode, 'greenfield');
85
+ assert.equal(profile.repository_kind, 'single');
86
+ assert.deepEqual(profile.source_signals, []);
87
+
88
+ const result = initProject({ target_dir: target, project_name: 'new-service' });
89
+ assert.equal(result.mode, 'greenfield');
90
+ assert.deepEqual(result.repository_profile.verification_commands, ['npm test']);
91
+ } finally { cleanup(target); }
92
+ });
93
+
94
+ test('auto classification records bounded monorepo evidence for brownfield adoption', () => {
95
+ const target = mkTempDir();
96
+ try {
97
+ fs.mkdirSync(path.join(target, 'packages', 'api', 'src'), { recursive: true });
98
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
99
+ name: 'legacy-suite', private: true, workspaces: ['packages/*'],
100
+ scripts: { build: 'turbo build', test: 'turbo test', lint: 'turbo lint' },
101
+ }));
102
+ fs.writeFileSync(path.join(target, 'packages', 'api', 'package.json'), '{"name":"api"}\n');
103
+ fs.writeFileSync(path.join(target, 'packages', 'api', 'src', 'index.js'), 'module.exports = true;\n');
104
+ fs.writeFileSync(path.join(target, 'Dockerfile'), 'FROM node:22\n');
105
+
106
+ const result = initProject({ target_dir: target, project_name: 'legacy-suite' });
107
+ assert.equal(result.mode, 'brownfield');
108
+ assert.equal(result.repository_profile.repository_kind, 'monorepo');
109
+ assert.deepEqual(result.repository_profile.workspace_roots, ['packages/api']);
110
+ assert.ok(result.repository_profile.source_signals.includes('packages/api/src/index.js'));
111
+ assert.ok(result.repository_profile.deployment_signals.includes('Dockerfile'));
112
+ assert.deepEqual(
113
+ result.repository_profile.verification_commands,
114
+ ['npm run build', 'npm test', 'npm run lint'],
115
+ );
116
+
117
+ const db = new Database(result.state_db_path, { readonly: true });
118
+ try {
119
+ const row = db.prepare(
120
+ 'SELECT repository_branch, worktree_state, classification_json FROM baselines',
121
+ ).get();
122
+ assert.equal(row.repository_branch, null);
123
+ assert.equal(row.worktree_state, 'unavailable');
124
+ assert.equal(JSON.parse(row.classification_json).repository_kind, 'monorepo');
125
+ } finally { db.close(); }
126
+ } finally { cleanup(target); }
127
+ });
128
+
129
+ test('brownfield initialization persists an explicitly selected monorepo scope', () => {
130
+ const target = mkTempDir();
131
+ try {
132
+ fs.mkdirSync(path.join(target, 'packages', 'api', 'src'), { recursive: true });
133
+ fs.mkdirSync(path.join(target, 'packages', 'web', 'src'), { recursive: true });
134
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
135
+ name: 'legacy-suite', private: true, workspaces: ['packages/*'],
136
+ }));
137
+ fs.writeFileSync(path.join(target, 'packages', 'api', 'src', 'index.js'), 'module.exports = true;\n');
138
+ fs.writeFileSync(path.join(target, 'packages', 'web', 'src', 'index.js'), 'module.exports = true;\n');
139
+
140
+ const result = initProject({
141
+ target_dir: target,
142
+ project_name: 'legacy-suite',
143
+ scope: ['packages/api'],
144
+ });
145
+
146
+ const db = new Database(result.state_db_path, { readonly: true });
147
+ try {
148
+ const baseline = db.prepare('SELECT scope_json FROM baselines').get();
149
+ assert.deepEqual(JSON.parse(baseline.scope_json), ['packages/api']);
150
+ assert.deepEqual(result.baseline.scope, ['packages/api']);
151
+ } finally { db.close(); }
152
+ } finally { cleanup(target); }
153
+ });
154
+
155
+ test('maintenance CLI forwards an explicit initialization mode', () => {
156
+ const target = mkTempDir();
157
+ try {
158
+ fs.writeFileSync(path.join(target, 'README.md'), '# Intentional greenfield notes\n');
159
+ const output = execFileSync(process.execPath, [
160
+ path.resolve(__dirname, '..', '..', 'ultra-tools', 'cli.cjs'),
161
+ 'task', 'init-project', '--target-dir', target, '--project-name', 'explicit-mode',
162
+ '--mode', 'greenfield',
163
+ ], { encoding: 'utf8' });
164
+ const envelope = JSON.parse(output);
165
+ assert.equal(envelope.ok, true);
166
+ assert.equal(envelope.data.mode, 'greenfield');
167
+ } finally { cleanup(target); }
168
+ });
169
+
170
+ test('initProject keeps project metadata in authoritative baseline state and tasks.json as a projection', () => {
50
171
  const target = mkTempDir();
51
172
  try {
52
173
  initProject({
@@ -56,14 +177,39 @@ test('initProject injects project metadata into tasks.json', () => {
56
177
  stack: 'next',
57
178
  });
58
179
  const tasksJson = JSON.parse(fs.readFileSync(path.join(target, '.ultra', 'tasks', 'tasks.json'), 'utf8'));
59
- assert.equal(tasksJson.project.name, 'meta-demo');
60
- assert.equal(tasksJson.project.type, 'web');
61
- assert.equal(tasksJson.project.stack, 'next');
62
- assert.ok(tasksJson.created.length > 0);
63
- assert.ok(tasksJson.updated.length > 0);
180
+ assert.equal(tasksJson.schema_version, '4.5');
181
+ assert.equal(tasksJson.source, '.ultra/state.db');
182
+ assert.deepEqual(tasksJson.tasks, []);
183
+ assert.equal('project' in tasksJson, false);
184
+ const db = new Database(path.join(target, '.ultra', 'state.db'), { readonly: true });
185
+ try {
186
+ const baseline = db.prepare('SELECT project_name, project_type, stack FROM baselines').get();
187
+ assert.deepEqual(baseline, { project_name: 'meta-demo', project_type: 'web', stack: 'next' });
188
+ } finally {
189
+ db.close();
190
+ }
64
191
  } finally { cleanup(target); }
65
192
  });
66
193
 
194
+ test('initProject records the declared project_initialized lifecycle event', () => {
195
+ const rootDir = mkTempDir();
196
+ try {
197
+ const result = initProject({ target_dir: rootDir, project_name: 'events-fixture' });
198
+ const db = new Database(result.state_db_path, { readonly: true });
199
+ try {
200
+ const events = db.prepare('SELECT type, payload_json FROM events ORDER BY id').all();
201
+ assert.deepEqual(events.map((row) => row.type), ['baseline_started', 'project_initialized']);
202
+ assert.deepEqual(JSON.parse(events[1].payload_json), {
203
+ project_name: 'events-fixture', mode: 'greenfield', baseline_id: 'project-baseline',
204
+ });
205
+ } finally {
206
+ db.close();
207
+ }
208
+ } finally {
209
+ fs.rmSync(rootDir, { recursive: true, force: true });
210
+ }
211
+ });
212
+
67
213
  test('initProject refuses when .ultra/ already exists and overwrite=false', () => {
68
214
  const target = mkTempDir();
69
215
  try {
@@ -75,6 +221,67 @@ test('initProject refuses when .ultra/ already exists and overwrite=false', () =
75
221
  } finally { cleanup(target); }
76
222
  });
77
223
 
224
+ test('initProject resume preserves existing artifacts and installs only missing current scaffold files', () => {
225
+ const target = mkTempDir();
226
+ try {
227
+ fs.mkdirSync(path.join(target, '.ultra', 'specs'), { recursive: true });
228
+ fs.writeFileSync(path.join(target, '.ultra', 'specs', 'product.md'), '# Existing product contract\n');
229
+
230
+ const result = initProject({
231
+ target_dir: target, project_name: 'existing-project', mode: 'brownfield', resume: true,
232
+ });
233
+ assert.equal(result.status, 'resumed');
234
+ assert.equal(
235
+ fs.readFileSync(path.join(target, '.ultra', 'specs', 'product.md'), 'utf8'),
236
+ '# Existing product contract\n',
237
+ );
238
+ assert.ok(fs.existsSync(path.join(target, '.ultra', 'specs', 'architecture.md')));
239
+ assert.ok(result.copied_files.includes('specs/architecture.md'));
240
+ assert.equal(result.copied_files.includes('specs/product.md'), false);
241
+
242
+ const second = initProject({
243
+ target_dir: target, project_name: 'existing-project', resume: true,
244
+ });
245
+ assert.equal(second.status, 'resumed');
246
+ assert.deepEqual(second.copied_files, []);
247
+ const db = new Database(result.state_db_path, { readonly: true });
248
+ try {
249
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM baselines').get().count, 1);
250
+ } finally { db.close(); }
251
+ } finally { cleanup(target); }
252
+ });
253
+
254
+ test('initProject resume refuses projection-only tasks until the supported import runs', () => {
255
+ const target = mkTempDir();
256
+ try {
257
+ fs.mkdirSync(path.join(target, '.ultra', 'tasks'), { recursive: true });
258
+ fs.writeFileSync(path.join(target, '.ultra', 'tasks', 'tasks.json'), JSON.stringify({
259
+ schema_version: '4.5', source: '.ultra/state.db',
260
+ tasks: [{ id: 'legacy', title: 'Legacy task', type: 'feature', priority: 'P1', status: 'pending' }],
261
+ }));
262
+ assert.throws(
263
+ () => initProject({ target_dir: target, project_name: 'legacy', resume: true }),
264
+ (error) => error.code === 'LEGACY_STATE_MIGRATION_REQUIRED'
265
+ && /--from=4\.5 --to=12\.0/.test(error.message),
266
+ );
267
+ assert.equal(fs.existsSync(path.join(target, '.ultra', 'state.db')), false);
268
+ assert.equal(fs.existsSync(path.join(target, '.ultra', 'specs', 'product.md')), false);
269
+ } finally { cleanup(target); }
270
+ });
271
+
272
+ test('initProject rejects conflicting resume and overwrite modes', () => {
273
+ const target = mkTempDir();
274
+ try {
275
+ fs.mkdirSync(path.join(target, '.ultra'));
276
+ assert.throws(
277
+ () => initProject({
278
+ target_dir: target, project_name: 'conflict', resume: true, overwrite: true,
279
+ }),
280
+ (error) => error.code === 'VALIDATION_ERROR' && /resume.*overwrite/i.test(error.message),
281
+ );
282
+ } finally { cleanup(target); }
283
+ });
284
+
78
285
  test('initProject with overwrite=true backs up existing .ultra/', () => {
79
286
  const target = mkTempDir();
80
287
  try {
@@ -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 {