ultra-builder-pro-cli 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +53 -1
  4. package/README.md +25 -9
  5. package/adapters/_shared/handbook.cjs +2 -2
  6. package/adapters/tests/codex.test.cjs +8 -0
  7. package/commands/ultra-init.md +1 -1
  8. package/docs/AGENT-CONTEXT.md +4 -3
  9. package/docs/ARCHITECTURE.md +32 -16
  10. package/docs/PLAN.zh-CN.md +5 -4
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  13. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  14. package/hooks/context_spine.py +37 -4
  15. package/mcp-server/lib/archive-journal.cjs +192 -0
  16. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  17. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  18. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  19. package/mcp-server/lib/change-workflow.cjs +249 -75
  20. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  21. package/mcp-server/lib/context-spine.test.cjs +5 -0
  22. package/mcp-server/lib/doctor.cjs +14 -1
  23. package/mcp-server/lib/doctor.test.cjs +42 -4
  24. package/mcp-server/lib/init-project.cjs +292 -9
  25. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  26. package/mcp-server/lib/state-authority.cjs +9 -3
  27. package/mcp-server/lib/state-db.cjs +154 -32
  28. package/mcp-server/lib/state-db.test.cjs +136 -4
  29. package/mcp-server/lib/state-ops.cjs +68 -6
  30. package/mcp-server/lib/task-expander.cjs +7 -1
  31. package/mcp-server/lib/task-expander.test.cjs +71 -0
  32. package/mcp-server/server.cjs +14 -3
  33. package/mcp-server/tests/init-project.test.cjs +162 -1
  34. package/mcp-server/tests/migrate.test.cjs +33 -0
  35. package/mcp-server/tests/server-contract.test.cjs +384 -6
  36. package/mcp-server/tests/state-db.test.cjs +14 -2
  37. package/mcp-server/tests/state-ops.test.cjs +108 -0
  38. package/package.json +1 -1
  39. package/skills/ultra-change/SKILL.md +18 -10
  40. package/skills/ultra-deliver/SKILL.md +13 -9
  41. package/skills/ultra-dev/SKILL.md +6 -4
  42. package/skills/ultra-doctor/SKILL.md +22 -7
  43. package/skills/ultra-init/SKILL.md +104 -59
  44. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  45. package/skills/ultra-plan/SKILL.md +8 -5
  46. package/skills/ultra-research/SKILL.md +12 -5
  47. package/skills/ultra-status/SKILL.md +6 -4
  48. package/spec/cli-protocol.md +11 -1
  49. package/spec/mcp-tools.yaml +95 -9
  50. package/spec/schemas/state-db.sql +11 -0
  51. package/ultra-tools/cli.cjs +9 -5
  52. package/ultra-tools/commands/migrate.cjs +62 -36
  53. package/ultra-tools/commands/status.cjs +18 -9
  54. package/ultra-tools/commands/system.cjs +340 -6
  55. package/ultra-tools/commands/task.cjs +7 -1
  56. package/ultra-tools/status-command.test.cjs +29 -0
  57. package/ultra-tools/system-command.test.cjs +252 -1
@@ -15,21 +15,26 @@ const fs = require('node:fs');
15
15
  const path = require('node:path');
16
16
 
17
17
  const { initStateDb } = require('./state-db.cjs');
18
+ const { assertStateAuthority, inspectProjection } = require('./state-authority.cjs');
18
19
  const baselines = require('./baseline-workflow.cjs');
19
20
  const runtimeState = require('./runtime-state.cjs');
21
+ const ops = require('./state-ops.cjs');
20
22
 
21
23
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
22
24
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
23
25
  : path.resolve(__dirname, '..', '..');
24
- const DEFAULT_TEMPLATE = path.join(REPO_ROOT, 'templates', '.ultra');
26
+ const SOURCE_TEMPLATE = path.join(REPO_ROOT, 'templates', '.ultra');
27
+ const PACKAGED_TEMPLATE = path.join(REPO_ROOT, '.ultra-template');
28
+ const DEFAULT_TEMPLATE = fs.existsSync(SOURCE_TEMPLATE) ? SOURCE_TEMPLATE : PACKAGED_TEMPLATE;
25
29
 
26
30
  class InitProjectError extends Error {
27
- constructor(code, message, { retriable = false, cause } = {}) {
31
+ constructor(code, message, { retriable = false, cause, details } = {}) {
28
32
  super(message);
29
33
  this.name = 'InitProjectError';
30
34
  this.code = code;
31
35
  this.retriable = retriable;
32
36
  if (cause) this.cause = cause;
37
+ if (details) this.details = details;
33
38
  }
34
39
  }
35
40
 
@@ -97,6 +102,18 @@ function copyTemplate(source, dest) {
97
102
  return files;
98
103
  }
99
104
 
105
+ function copyMissingTemplate(source, dest) {
106
+ const copied = [];
107
+ for (const rel of walkRelative(source)) {
108
+ const to = path.join(dest, rel);
109
+ if (fs.existsSync(to)) continue;
110
+ fs.mkdirSync(path.dirname(to), { recursive: true });
111
+ fs.copyFileSync(path.join(source, rel), to);
112
+ copied.push(rel);
113
+ }
114
+ return copied;
115
+ }
116
+
100
117
  function validateTasksTemplate(ultraDir) {
101
118
  const tasksFile = path.join(ultraDir, 'tasks', 'tasks.json');
102
119
  if (!fs.existsSync(tasksFile)) return;
@@ -112,15 +129,150 @@ const IGNORED_ADOPTION_ENTRIES = new Set([
112
129
  '.DS_Store', '.git', '.gitignore', '.ultra', 'node_modules', 'vendor', '.venv', 'venv',
113
130
  ]);
114
131
 
132
+ const IGNORED_SCAN_DIRS = new Set([
133
+ ...IGNORED_ADOPTION_ENTRIES, 'dist', 'build', 'coverage', '.next', '.turbo', 'target',
134
+ ]);
135
+ const MANIFEST_NAMES = new Set([
136
+ 'package.json', 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod', 'go.work',
137
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json',
138
+ ]);
139
+ const SOURCE_EXTENSIONS = new Set([
140
+ '.c', '.cc', '.cpp', '.cs', '.go', '.java', '.js', '.jsx', '.kt', '.kts', '.mjs',
141
+ '.move', '.php', '.py', '.rb', '.rs', '.sol', '.swift', '.ts', '.tsx', '.vue',
142
+ ]);
143
+ const SOURCE_DIR_SEGMENTS = new Set([
144
+ 'src', 'app', 'apps', 'lib', 'server', 'backend', 'frontend', 'api', 'cmd', 'internal',
145
+ ]);
146
+
147
+ function scanRepositoryFiles(rootDir, { maxFiles = 5000, maxDepth = 8 } = {}) {
148
+ const files = [];
149
+ function walk(dir, depth) {
150
+ if (files.length >= maxFiles || depth > maxDepth) return;
151
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
152
+ if (files.length >= maxFiles) break;
153
+ if (entry.name === '.DS_Store') continue;
154
+ const full = path.join(dir, entry.name);
155
+ const rel = path.relative(rootDir, full).split(path.sep).join('/');
156
+ if (entry.isDirectory()) {
157
+ if (!IGNORED_SCAN_DIRS.has(entry.name) && !entry.name.startsWith('.ultra.backup.')) {
158
+ walk(full, depth + 1);
159
+ }
160
+ } else if (entry.isFile()) files.push(rel);
161
+ }
162
+ }
163
+ walk(rootDir, 0);
164
+ return { files: files.sort(), truncated: files.length >= maxFiles };
165
+ }
166
+
167
+ function expandWorkspacePattern(rootDir, pattern) {
168
+ const normalized = String(pattern || '').trim().replace(/^['"]|['"]$/g, '').replace(/\\/g, '/');
169
+ if (!normalized) return [];
170
+ if (!normalized.includes('*')) {
171
+ const candidate = path.join(rootDir, normalized);
172
+ return fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? [normalized] : [];
173
+ }
174
+ if (!normalized.endsWith('/*') || normalized.slice(0, -2).includes('*')) return [];
175
+ const parentRel = normalized.slice(0, -2);
176
+ const parent = path.join(rootDir, parentRel);
177
+ if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) return [];
178
+ return fs.readdirSync(parent, { withFileTypes: true })
179
+ .filter((entry) => entry.isDirectory())
180
+ .map((entry) => `${parentRel}/${entry.name}`);
181
+ }
182
+
183
+ function packageProfile(rootDir) {
184
+ const file = path.join(rootDir, 'package.json');
185
+ if (!fs.existsSync(file)) return { workspacePatterns: [], verificationCommands: [] };
186
+ try {
187
+ const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
188
+ const rawWorkspaces = Array.isArray(pkg.workspaces)
189
+ ? pkg.workspaces
190
+ : (Array.isArray(pkg.workspaces?.packages) ? pkg.workspaces.packages : []);
191
+ const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
192
+ const verificationCommands = ['build', 'test', 'lint', 'typecheck']
193
+ .filter((name) => typeof scripts[name] === 'string' && scripts[name].trim())
194
+ .map((name) => name === 'test' ? 'npm test' : `npm run ${name}`);
195
+ return { workspacePatterns: rawWorkspaces, verificationCommands };
196
+ } catch {
197
+ return { workspacePatterns: [], verificationCommands: [] };
198
+ }
199
+ }
200
+
201
+ function classifyRepository(absTarget) {
202
+ const { files, truncated } = scanRepositoryFiles(absTarget);
203
+ const packageData = packageProfile(absTarget);
204
+ const manifestSignals = files.filter((file) => MANIFEST_NAMES.has(path.basename(file))).slice(0, 100);
205
+ const testSignals = files.filter((file) => {
206
+ const lower = file.toLowerCase();
207
+ return /(^|\/)(test|tests|__tests__|spec)(\/|$)/.test(lower)
208
+ || /\.(test|spec)\.[^.]+$/.test(lower);
209
+ }).slice(0, 100);
210
+ const deploymentSignals = files.filter((file) => {
211
+ const lower = file.toLowerCase();
212
+ return /(^|\/)(dockerfile|railway\.json|railway\.toml|vercel\.json|fly\.toml)$/.test(lower)
213
+ || lower.startsWith('.github/workflows/')
214
+ || /(^|\/)docker-compose[^/]*\.ya?ml$/.test(lower);
215
+ }).slice(0, 100);
216
+ const databaseSignals = files.filter((file) => {
217
+ const lower = file.toLowerCase();
218
+ return /(^|\/)(migrations?|prisma|schema)(\/|$)/.test(lower)
219
+ || /(^|\/)schema\.(sql|prisma)$/.test(lower);
220
+ }).slice(0, 100);
221
+ const sourceSignals = files.filter((file) => {
222
+ const lower = file.toLowerCase();
223
+ if (testSignals.includes(file) || lower.endsWith('.config.js') || lower.endsWith('.config.ts')) return false;
224
+ const segments = file.split('/');
225
+ const ext = path.extname(file);
226
+ if (!SOURCE_EXTENSIONS.has(ext)) return false;
227
+ return segments.some((segment) => SOURCE_DIR_SEGMENTS.has(segment))
228
+ || /(^|\/)(main|index|server|app)\.[^.]+$/.test(lower);
229
+ }).slice(0, 200);
230
+ const workspaceRoots = [...new Set(packageData.workspacePatterns
231
+ .flatMap((pattern) => expandWorkspacePattern(absTarget, pattern)))]
232
+ .sort();
233
+ const workspaceMarkers = [];
234
+ if (packageData.workspacePatterns.length > 0) workspaceMarkers.push('package.json#workspaces');
235
+ for (const marker of ['pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json', 'go.work']) {
236
+ if (files.includes(marker)) workspaceMarkers.push(marker);
237
+ }
238
+ if (workspaceRoots.length === 0 && files.some((file) => /^packages\/[^/]+\/package\.json$/.test(file))) {
239
+ workspaceRoots.push(...files
240
+ .filter((file) => /^packages\/[^/]+\/package\.json$/.test(file))
241
+ .map((file) => path.posix.dirname(file)));
242
+ workspaceRoots.sort();
243
+ }
244
+ const reasons = [];
245
+ if (sourceSignals.length > 0) reasons.push('SOURCE_PRESENT');
246
+ if (testSignals.length > 0) reasons.push('TESTS_PRESENT');
247
+ if (deploymentSignals.length > 0) reasons.push('DEPLOYMENT_PRESENT');
248
+ if (databaseSignals.length > 0) reasons.push('PERSISTED_STATE_PRESENT');
249
+ if (workspaceMarkers.length > 0 || workspaceRoots.length > 0) reasons.push('MONOREPO_PRESENT');
250
+ if (reasons.length === 0) reasons.push('SKELETON_ONLY');
251
+ const mode = sourceSignals.length > 0 || testSignals.length > 0
252
+ || deploymentSignals.length > 0 || databaseSignals.length > 0
253
+ ? 'brownfield' : 'greenfield';
254
+ return {
255
+ mode,
256
+ reasons,
257
+ repository_kind: workspaceMarkers.length > 0 || workspaceRoots.length > 0 ? 'monorepo' : 'single',
258
+ workspace_markers: workspaceMarkers,
259
+ workspace_roots: [...new Set(workspaceRoots)],
260
+ manifest_signals: manifestSignals,
261
+ source_signals: sourceSignals,
262
+ test_signals: testSignals,
263
+ deployment_signals: deploymentSignals,
264
+ database_signals: databaseSignals,
265
+ verification_commands: packageData.verificationCommands,
266
+ scan_truncated: truncated,
267
+ };
268
+ }
269
+
115
270
  function detectProjectMode(absTarget, requested = 'auto') {
116
271
  if (!['auto', 'greenfield', 'brownfield'].includes(requested)) {
117
272
  throw new InitProjectError('VALIDATION_ERROR', `unsupported initialization mode: ${requested}`);
118
273
  }
119
274
  if (requested !== 'auto') return requested;
120
- const meaningful = fs.readdirSync(absTarget, { withFileTypes: true })
121
- .some((entry) => !IGNORED_ADOPTION_ENTRIES.has(entry.name)
122
- && !entry.name.startsWith('.ultra.backup.'));
123
- return meaningful ? 'brownfield' : 'greenfield';
275
+ return classifyRepository(absTarget).mode;
124
276
  }
125
277
 
126
278
  function rollbackInitialization(ultraDir, backupPath) {
@@ -137,25 +289,144 @@ function rollbackInitialization(ultraDir, backupPath) {
137
289
  return errors;
138
290
  }
139
291
 
292
+ function projectionMigrationError(projection) {
293
+ const targets = { '4.4': '4.5', '4.5': '12.0' };
294
+ const target = targets[projection.version];
295
+ if (target) {
296
+ return new InitProjectError(
297
+ 'LEGACY_STATE_MIGRATION_REQUIRED',
298
+ `state.db has no tasks but ${projection.path} contains ${projection.taskCount} v${projection.version} tasks; `
299
+ + `run ultra-tools migrate --from=${projection.version} --to=${target} --source-dir <project-root> before resuming initialization`,
300
+ { details: { projection_path: projection.path, projection_version: projection.version, legacy_task_count: projection.taskCount } },
301
+ );
302
+ }
303
+ return new InitProjectError(
304
+ 'STATE_AUTHORITY_CONFLICT',
305
+ `state.db has no tasks but ${projection.path} contains ${projection.taskCount} tasks with unsupported projection version ${projection.version}`,
306
+ { details: { projection_path: projection.path, projection_version: projection.version, legacy_task_count: projection.taskCount } },
307
+ );
308
+ }
309
+
310
+ function resumeProject({
311
+ absTarget, ultraDir, source, project_name, project_type, stack, resolvedMode,
312
+ scope, repositoryProfile, projection,
313
+ }) {
314
+ const stateDbPath = path.join(ultraDir, 'state.db');
315
+ const stateExisted = fs.existsSync(stateDbPath);
316
+ let initialized;
317
+ let copiedFiles = [];
318
+ let baselineResult;
319
+ try {
320
+ initialized = initStateDb(stateDbPath);
321
+ const { db } = initialized;
322
+ if (projection && projection.taskCount > 0) assertStateAuthority(db, absTarget);
323
+ copiedFiles = copyMissingTemplate(source, ultraDir);
324
+ validateTasksTemplate(ultraDir);
325
+ baselineResult = baselines.readBaseline(db);
326
+ if (!baselineResult) {
327
+ baselineResult = baselines.startBaseline(db, {
328
+ id: 'project-baseline', project_name, project_type, stack,
329
+ mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
330
+ scope: scope === undefined ? ['.'] : scope,
331
+ classification: repositoryProfile,
332
+ }, { rootDir: absTarget });
333
+ }
334
+ ops.appendEvent(db, {
335
+ type: 'project_resumed',
336
+ payload: {
337
+ project_name, mode: baselineResult.mode, baseline_id: baselineResult.id,
338
+ installed_missing_files: copiedFiles.length,
339
+ },
340
+ });
341
+ const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
342
+ const projectionResult = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
343
+ const projected = projectionResult.jobs.find((item) => item.id === job.id);
344
+ if (!projected || projected.status !== 'completed') {
345
+ throw new Error(projected?.error || 'resume projection did not complete');
346
+ }
347
+ } catch (error) {
348
+ if (initialized?.db?.open) initialized.db.close();
349
+ for (const rel of copiedFiles.reverse()) {
350
+ try { fs.rmSync(path.join(ultraDir, rel), { force: true }); } catch { /* best effort */ }
351
+ }
352
+ if (!stateExisted) {
353
+ for (const suffix of ['', '-wal', '-shm']) {
354
+ try { fs.rmSync(`${stateDbPath}${suffix}`, { force: true }); } catch { /* best effort */ }
355
+ }
356
+ }
357
+ if (error.code && ['LEGACY_STATE_MIGRATION_REQUIRED', 'STATE_AUTHORITY_CONFLICT', 'STATE_PROJECTION_INVALID'].includes(error.code)) {
358
+ throw new InitProjectError(error.code, error.message, { cause: error, details: error.details });
359
+ }
360
+ throw new InitProjectError(
361
+ 'IO_ERROR', `project resume failed: ${error.message}; existing .ultra assets were preserved`,
362
+ { retriable: true, cause: error },
363
+ );
364
+ }
365
+ initialized.db.close();
366
+ const result = {
367
+ created_path: ultraDir,
368
+ state_db_path: stateDbPath,
369
+ status: 'resumed',
370
+ mode: resolvedMode,
371
+ baseline: {
372
+ id: baselineResult.id,
373
+ mode: baselineResult.mode,
374
+ status: baselineResult.status,
375
+ repository_revision: baselineResult.repository_revision,
376
+ scope: baselineResult.scope,
377
+ },
378
+ copied_files: copiedFiles,
379
+ repository_profile: repositoryProfile,
380
+ };
381
+ if (initialized.backup_path) result.migration_backup_path = initialized.backup_path;
382
+ return result;
383
+ }
384
+
140
385
  function initProject({
141
386
  target_dir,
142
387
  project_name,
143
388
  project_type,
144
389
  stack,
145
390
  mode = 'auto',
391
+ scope,
392
+ resume = false,
146
393
  overwrite = false,
147
394
  source_template,
148
395
  } = {}) {
149
396
  if (typeof project_name !== 'string' || project_name.length === 0) {
150
397
  throw new InitProjectError('VALIDATION_ERROR', 'project_name must be non-empty');
151
398
  }
399
+ if (resume && overwrite) {
400
+ throw new InitProjectError('VALIDATION_ERROR', 'resume and overwrite are mutually exclusive');
401
+ }
152
402
 
153
403
  const absTarget = resolveTargetDir(target_dir);
154
404
  ensureTargetDir(absTarget);
155
- const resolvedMode = detectProjectMode(absTarget, mode);
405
+ const repositoryProfile = classifyRepository(absTarget);
406
+ const resolvedMode = mode === 'auto' ? repositoryProfile.mode : detectProjectMode(absTarget, mode);
407
+ repositoryProfile.detected_mode = repositoryProfile.mode;
408
+ repositoryProfile.mode = resolvedMode;
156
409
  const source = ensureTemplate(source_template);
157
410
  const ultraDir = path.join(absTarget, '.ultra');
158
411
 
412
+ if (fs.existsSync(ultraDir) && resume) {
413
+ let projection;
414
+ try { projection = inspectProjection(absTarget); }
415
+ catch (error) {
416
+ throw new InitProjectError(error.code || 'STATE_PROJECTION_INVALID', error.message, {
417
+ cause: error, details: error.details,
418
+ });
419
+ }
420
+ if (projection && projection.taskCount > 0
421
+ && !fs.existsSync(path.join(ultraDir, 'state.db'))) {
422
+ throw projectionMigrationError(projection);
423
+ }
424
+ return resumeProject({
425
+ absTarget, ultraDir, source, project_name, project_type, stack, resolvedMode,
426
+ scope, repositoryProfile, projection,
427
+ });
428
+ }
429
+
159
430
  let status = 'created';
160
431
  let backupPath;
161
432
  if (fs.existsSync(ultraDir)) {
@@ -179,8 +450,16 @@ function initProject({
179
450
  try {
180
451
  baselineResult = baselines.startBaseline(db, {
181
452
  id: 'project-baseline', project_name, project_type, stack,
182
- mode: resolvedMode, repository_revision: baselines.gitHead(absTarget), scope: ['.'],
453
+ mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
454
+ scope: scope === undefined ? ['.'] : scope,
455
+ classification: repositoryProfile,
183
456
  }, { rootDir: absTarget });
457
+ ops.appendEvent(db, {
458
+ type: 'project_initialized',
459
+ payload: {
460
+ project_name, mode: resolvedMode, baseline_id: baselineResult.id,
461
+ },
462
+ });
184
463
  const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
185
464
  const projection = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
186
465
  const projected = projection.jobs.find((item) => item.id === job.id);
@@ -213,11 +492,15 @@ function initProject({
213
492
  mode: baselineResult.mode,
214
493
  status: baselineResult.status,
215
494
  repository_revision: baselineResult.repository_revision,
495
+ scope: baselineResult.scope,
216
496
  },
217
497
  copied_files: copiedFiles,
498
+ repository_profile: repositoryProfile,
218
499
  };
219
500
  if (backupPath) result.backup_path = backupPath;
220
501
  return result;
221
502
  }
222
503
 
223
- module.exports = { initProject, InitProjectError, DEFAULT_TEMPLATE, detectProjectMode };
504
+ module.exports = {
505
+ initProject, InitProjectError, DEFAULT_TEMPLATE, detectProjectMode, classifyRepository,
506
+ };
@@ -23,7 +23,7 @@ function fixture() {
23
23
  db.prepare(
24
24
  `INSERT INTO baselines
25
25
  (id, project_name, mode, status, approved_by, approval_note, converged_at)
26
- VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
26
+ VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
27
27
  ).run(new Date().toISOString());
28
28
  changes.createChange(db, {
29
29
  id: 'learning-change', title: 'Learning change', kind: 'quick',
@@ -51,11 +51,17 @@ function assertStateAuthority(db, rootDir) {
51
51
  projection_path: projection.path,
52
52
  projection_version: projection.version,
53
53
  };
54
- if (projection.version === '4.4') {
54
+ const migrationTargets = {
55
+ '4.4': '4.5',
56
+ '4.5': '12.0',
57
+ };
58
+ const migrationTarget = migrationTargets[projection.version];
59
+ if (migrationTarget) {
55
60
  throw new StateAuthorityError(
56
61
  'LEGACY_STATE_MIGRATION_REQUIRED',
57
- `state.db has no tasks but ${projection.path} contains ${projection.taskCount} v4.4 tasks; `
58
- + 'run ultra-tools migrate --from=4.4 --to=4.5 --source-dir <project-root> before using Ultra MCP tools',
62
+ `state.db has no tasks but ${projection.path} contains ${projection.taskCount} v${projection.version} tasks; `
63
+ + `run ultra-tools migrate --from=${projection.version} --to=${migrationTarget} `
64
+ + '--source-dir <project-root> before using Ultra MCP tools',
59
65
  details,
60
66
  );
61
67
  }