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
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  // Implementation for MCP tool `task.init_project` and the CLI fallback
4
- // `ultra-tools init`. Copies the bundled templates/.ultra/ skeleton to the
5
- // target project root, injecting project metadata into tasks.json.
4
+ // `ultra-tools task init-project`. Copies the bundled templates/.ultra/ skeleton
5
+ // to the target project root and records project metadata in authoritative state.
6
6
  //
7
7
  // Error contract matches spec/mcp-tools.yaml#task.init_project:
8
8
  // ULTRA_DIR_EXISTS | TEMPLATE_MISSING | TARGET_NOT_DIR | IO_ERROR
@@ -15,19 +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');
19
+ const baselines = require('./baseline-workflow.cjs');
20
+ const runtimeState = require('./runtime-state.cjs');
21
+ const ops = require('./state-ops.cjs');
18
22
 
19
23
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
20
24
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
21
25
  : path.resolve(__dirname, '..', '..');
22
- 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;
23
29
 
24
30
  class InitProjectError extends Error {
25
- constructor(code, message, { retriable = false, cause } = {}) {
31
+ constructor(code, message, { retriable = false, cause, details } = {}) {
26
32
  super(message);
27
33
  this.name = 'InitProjectError';
28
34
  this.code = code;
29
35
  this.retriable = retriable;
30
36
  if (cause) this.cause = cause;
37
+ if (details) this.details = details;
31
38
  }
32
39
  }
33
40
 
@@ -95,20 +102,177 @@ function copyTemplate(source, dest) {
95
102
  return files;
96
103
  }
97
104
 
98
- function injectTasksJson(ultraDir, { project_name, project_type, stack }) {
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
+
117
+ function validateTasksTemplate(ultraDir) {
99
118
  const tasksFile = path.join(ultraDir, 'tasks', 'tasks.json');
100
119
  if (!fs.existsSync(tasksFile)) return;
101
120
  let data;
102
121
  try { data = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); }
103
122
  catch (err) { throw new InitProjectError('IO_ERROR', `tasks.json malformed: ${err.message}`, { cause: err }); }
104
- const nowIso = new Date().toISOString();
105
- data.created = nowIso;
106
- data.updated = nowIso;
107
- data.project = data.project || {};
108
- data.project.name = project_name;
109
- if (project_type) data.project.type = project_type;
110
- if (stack) data.project.stack = stack;
111
- fs.writeFileSync(tasksFile, JSON.stringify(data, null, 2) + '\n');
123
+ if (!data || typeof data !== 'object' || !Array.isArray(data.tasks)) {
124
+ throw new InitProjectError('IO_ERROR', 'tasks.json template must contain a tasks array');
125
+ }
126
+ }
127
+
128
+ const IGNORED_ADOPTION_ENTRIES = new Set([
129
+ '.DS_Store', '.git', '.gitignore', '.ultra', 'node_modules', 'vendor', '.venv', 'venv',
130
+ ]);
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
+
270
+ function detectProjectMode(absTarget, requested = 'auto') {
271
+ if (!['auto', 'greenfield', 'brownfield'].includes(requested)) {
272
+ throw new InitProjectError('VALIDATION_ERROR', `unsupported initialization mode: ${requested}`);
273
+ }
274
+ if (requested !== 'auto') return requested;
275
+ return classifyRepository(absTarget).mode;
112
276
  }
113
277
 
114
278
  function rollbackInitialization(ultraDir, backupPath) {
@@ -125,23 +289,144 @@ function rollbackInitialization(ultraDir, backupPath) {
125
289
  return errors;
126
290
  }
127
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
+
128
385
  function initProject({
129
386
  target_dir,
130
387
  project_name,
131
388
  project_type,
132
389
  stack,
390
+ mode = 'auto',
391
+ scope,
392
+ resume = false,
133
393
  overwrite = false,
134
394
  source_template,
135
395
  } = {}) {
136
396
  if (typeof project_name !== 'string' || project_name.length === 0) {
137
397
  throw new InitProjectError('VALIDATION_ERROR', 'project_name must be non-empty');
138
398
  }
399
+ if (resume && overwrite) {
400
+ throw new InitProjectError('VALIDATION_ERROR', 'resume and overwrite are mutually exclusive');
401
+ }
139
402
 
140
403
  const absTarget = resolveTargetDir(target_dir);
141
404
  ensureTargetDir(absTarget);
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;
142
409
  const source = ensureTemplate(source_template);
143
410
  const ultraDir = path.join(absTarget, '.ultra');
144
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
+
145
430
  let status = 'created';
146
431
  let backupPath;
147
432
  if (fs.existsSync(ultraDir)) {
@@ -155,13 +440,35 @@ function initProject({
155
440
  }
156
441
 
157
442
  let copiedFiles;
443
+ let baselineResult;
158
444
  try {
159
445
  fs.mkdirSync(ultraDir, { recursive: true });
160
446
  copiedFiles = copyTemplate(source, ultraDir);
161
- injectTasksJson(ultraDir, { project_name, project_type, stack });
447
+ validateTasksTemplate(ultraDir);
162
448
  const stateDbPath = path.join(ultraDir, 'state.db');
163
449
  const { db } = initStateDb(stateDbPath);
164
- db.close();
450
+ try {
451
+ baselineResult = baselines.startBaseline(db, {
452
+ id: 'project-baseline', project_name, project_type, stack,
453
+ mode: resolvedMode, repository_revision: baselines.gitHead(absTarget),
454
+ scope: scope === undefined ? ['.'] : scope,
455
+ classification: repositoryProfile,
456
+ }, { rootDir: absTarget });
457
+ ops.appendEvent(db, {
458
+ type: 'project_initialized',
459
+ payload: {
460
+ project_name, mode: resolvedMode, baseline_id: baselineResult.id,
461
+ },
462
+ });
463
+ const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
464
+ const projection = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
465
+ const projected = projection.jobs.find((item) => item.id === job.id);
466
+ if (!projected || projected.status !== 'completed') {
467
+ throw new Error(projected?.error || 'initial projection did not complete');
468
+ }
469
+ } finally {
470
+ db.close();
471
+ }
165
472
  } catch (err) {
166
473
  const rollbackErrors = rollbackInitialization(ultraDir, backupPath);
167
474
  const rollbackNote = rollbackErrors.length > 0
@@ -179,10 +486,21 @@ function initProject({
179
486
  created_path: ultraDir,
180
487
  state_db_path: stateDbPath,
181
488
  status,
489
+ mode: resolvedMode,
490
+ baseline: {
491
+ id: baselineResult.id,
492
+ mode: baselineResult.mode,
493
+ status: baselineResult.status,
494
+ repository_revision: baselineResult.repository_revision,
495
+ scope: baselineResult.scope,
496
+ },
182
497
  copied_files: copiedFiles,
498
+ repository_profile: repositoryProfile,
183
499
  };
184
500
  if (backupPath) result.backup_path = backupPath;
185
501
  return result;
186
502
  }
187
503
 
188
- module.exports = { initProject, InitProjectError, DEFAULT_TEMPLATE };
504
+ module.exports = {
505
+ initProject, InitProjectError, DEFAULT_TEMPLATE, detectProjectMode, classifyRepository,
506
+ };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
4
+ const PROVIDER_FIELDS = new Set([
5
+ 'provider', 'project', 'reference', 'revision', 'indexed_head', 'status',
6
+ ]);
7
+
8
+ class ProviderRefsError extends Error {
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.name = 'ProviderRefsError';
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ function normalizeProviderRefs(value, ErrorClass = ProviderRefsError) {
17
+ if (value == null) return {};
18
+ if (typeof value !== 'object' || Array.isArray(value)) {
19
+ throw new ErrorClass('VALIDATION_ERROR', 'provider_refs must be an object');
20
+ }
21
+ const out = {};
22
+ for (const [kind, ref] of Object.entries(value)) {
23
+ if (!PROVIDER_KINDS.has(kind) || !ref || typeof ref !== 'object' || Array.isArray(ref)) {
24
+ throw new ErrorClass('VALIDATION_ERROR', `invalid external provider reference: ${kind}`);
25
+ }
26
+ for (const key of Object.keys(ref)) {
27
+ if (!PROVIDER_FIELDS.has(key)) {
28
+ throw new ErrorClass(
29
+ 'PROVIDER_CONTENT_FORBIDDEN',
30
+ `provider_refs.${kind}.${key} is not metadata; provider content remains external`,
31
+ );
32
+ }
33
+ }
34
+ if (typeof ref.provider !== 'string' || !ref.provider.trim()) {
35
+ throw new ErrorClass('VALIDATION_ERROR', `provider_refs.${kind}.provider required`);
36
+ }
37
+ out[kind] = { ...ref, provider: ref.provider.trim() };
38
+ }
39
+ return out;
40
+ }
41
+
42
+ module.exports = { ProviderRefsError, normalizeProviderRefs };
@@ -20,6 +20,11 @@ function fixture() {
20
20
  execFileSync('git', ['add', 'README.md'], { cwd: rootDir });
21
21
  execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: rootDir });
22
22
  const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
23
+ db.prepare(
24
+ `INSERT INTO baselines
25
+ (id, project_name, mode, status, approved_by, approval_note, converged_at)
26
+ VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
27
+ ).run(new Date().toISOString());
23
28
  changes.createChange(db, {
24
29
  id: 'learning-change', title: 'Learning change', kind: 'quick',
25
30
  intent: 'Exercise guarded specification-learning transitions.',
@@ -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
  }