ultra-builder-pro-cli 0.11.0 → 0.12.1

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 (63) hide show
  1. package/.ultra-template/specs/architecture.md +54 -223
  2. package/.ultra-template/specs/discovery.md +34 -231
  3. package/.ultra-template/specs/product.md +44 -200
  4. package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
  5. package/.ultra-template/tasks/tasks.json +6 -0
  6. package/CHANGELOG.md +42 -1
  7. package/README.md +21 -9
  8. package/adapters/_shared/codex-assets.cjs +1 -1
  9. package/adapters/_shared/handbook.cjs +4 -3
  10. package/adapters/_shared/tests/handbook.test.cjs +4 -0
  11. package/adapters/opencode.js +8 -2
  12. package/adapters/tests/codex.test.cjs +2 -2
  13. package/adapters/tests/kimi-hook.test.cjs +1 -1
  14. package/adapters/tests/opencode.test.cjs +36 -1
  15. package/commands/ultra-init.md +3 -3
  16. package/docs/AGENT-CONTEXT.md +16 -8
  17. package/docs/ARCHITECTURE.md +32 -5
  18. package/docs/PLAN.zh-CN.md +7 -5
  19. package/docs/ROADMAP.md +4 -2
  20. package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
  21. package/docs/STATE-DB-ACCESS-POLICY.md +17 -11
  22. package/docs/USER-HANDBOOK-CONTRACT.md +8 -5
  23. package/hooks/README.md +4 -3
  24. package/hooks/adapters/codex.py +1 -1
  25. package/hooks/adapters/kimi.py +1 -1
  26. package/hooks/context_spine.py +120 -16
  27. package/hooks/health_check.py +11 -0
  28. package/hooks/pre_stop_check.py +6 -5
  29. package/mcp-server/lib/baseline-workflow.cjs +415 -0
  30. package/mcp-server/lib/baseline-workflow.test.cjs +225 -0
  31. package/mcp-server/lib/change-workflow.cjs +24 -27
  32. package/mcp-server/lib/change-workflow.test.cjs +202 -6
  33. package/mcp-server/lib/context-spine.cjs +74 -9
  34. package/mcp-server/lib/context-spine.test.cjs +15 -0
  35. package/mcp-server/lib/doctor.cjs +14 -0
  36. package/mcp-server/lib/doctor.test.cjs +25 -1
  37. package/mcp-server/lib/init-project.cjs +49 -14
  38. package/mcp-server/lib/provider-refs.cjs +42 -0
  39. package/mcp-server/lib/spec-learning.test.cjs +5 -0
  40. package/mcp-server/lib/state-db.cjs +63 -4
  41. package/mcp-server/lib/state-db.test.cjs +8 -3
  42. package/mcp-server/server.cjs +48 -3
  43. package/mcp-server/tests/init-project.test.cjs +54 -8
  44. package/mcp-server/tests/server-contract.test.cjs +99 -2
  45. package/mcp-server/tests/state-db.test.cjs +11 -2
  46. package/package.json +1 -1
  47. package/skills/ultra-change/SKILL.md +24 -13
  48. package/skills/ultra-deliver/SKILL.md +15 -5
  49. package/skills/ultra-dev/SKILL.md +4 -0
  50. package/skills/ultra-doctor/SKILL.md +8 -2
  51. package/skills/ultra-init/SKILL.md +46 -50
  52. package/skills/ultra-init/references/brownfield-adoption.md +65 -0
  53. package/skills/ultra-plan/SKILL.md +9 -2
  54. package/skills/ultra-research/SKILL.md +10 -5
  55. package/skills/ultra-status/SKILL.md +13 -6
  56. package/spec/cli-protocol.md +4 -0
  57. package/spec/mcp-tools.yaml +223 -4
  58. package/spec/schemas/mcp-tools.schema.json +1 -1
  59. package/spec/schemas/state-db.sql +33 -2
  60. package/ultra-tools/commands/status.cjs +20 -2
  61. package/ultra-tools/commands/task.cjs +4 -1
  62. package/ultra-tools/status-command.test.cjs +20 -0
  63. package/ultra-tools/system-command.test.cjs +5 -0
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
+ const baselines = require('./baseline-workflow.cjs');
7
8
 
8
9
  const ROLES = new Set(['plan', 'implement', 'check', 'review']);
9
10
  const GATES = new Set([
@@ -12,6 +13,12 @@ const GATES = new Set([
12
13
  const CONTEXT_KINDS = new Set(['spec', 'source', 'test', 'docs', 'external']);
13
14
  const SLICE_KINDS = new Set(['tracer_bullet', 'expand_contract', 'integration_checkpoint']);
14
15
  const DEFAULT_BUDGET = Object.freeze({ max_tokens: 12_000, max_files: 12 });
16
+ const ADVISORY_CONTEXT_CODES = new Set([
17
+ 'CONTEXT_FILE_BUDGET_EXCEEDED',
18
+ 'CONTEXT_TOKEN_BUDGET_EXCEEDED',
19
+ 'EXECUTION_CONTEXT_BUDGET_EXCEEDED',
20
+ 'EXECUTION_CONTEXT_BUDGET_ADVISORY',
21
+ ]);
15
22
 
16
23
  class ContextSpineError extends Error {
17
24
  constructor(code, message) {
@@ -149,8 +156,13 @@ function defaultGate(role) {
149
156
  }[role];
150
157
  }
151
158
 
152
- function deriveNextAction({ change, tasks, task, role, readiness, explicit }) {
153
- if (readiness === 'blocked') return 'Resolve the context readiness blockers, then recompile change.context.';
159
+ function deriveNextAction({ change, tasks, task, role, readiness, blockers = [], explicit }) {
160
+ if (readiness === 'blocked') {
161
+ if (blockers.some((blocker) => blocker.startsWith('BASELINE_'))) {
162
+ return 'Complete or refresh the Ultra project baseline, then recompile change.context.';
163
+ }
164
+ return 'Resolve the context readiness blockers, then recompile change.context.';
165
+ }
154
166
  if (explicit && String(explicit).trim()) return String(explicit).trim();
155
167
  if (change.status === 'ready') return `Run ultra-deliver for change ${change.id}.`;
156
168
  if (change.status === 'blocked') return `Resolve the blockers for change ${change.id}, then recompile context.`;
@@ -199,12 +211,19 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
199
211
  input, inherited.execution_contract, { taskId: input.task_id },
200
212
  );
201
213
  const blockers = [];
214
+ const warnings = [];
215
+ const baselineHealth = baselines.inspectBaseline(db, { rootDir });
216
+ // An active change must remain executable even when adoption is incomplete or
217
+ // the repository has moved. Approved adoption is required at convergence and
218
+ // full revision/spec health is required at archive; here both are recovery
219
+ // guidance, not a refusal.
220
+ warnings.push(...baselineHealth.blockers, ...baselineHealth.warnings);
202
221
  for (const item of items) {
203
222
  if (item.required && item.status === 'missing') blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
204
223
  if (item.required && item.status === 'stale') blockers.push(`CONTEXT_REQUIRED_REF_STALE:${item.ref}`);
205
224
  }
206
- if (fileCount > budget.max_files) blockers.push('CONTEXT_FILE_BUDGET_EXCEEDED');
207
- if (tokenEstimate > budget.max_tokens) blockers.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
225
+ if (fileCount > budget.max_files) warnings.push('CONTEXT_FILE_BUDGET_EXCEEDED');
226
+ if (tokenEstimate > budget.max_tokens) warnings.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
208
227
  if (input.task_id) {
209
228
  if (!executionContract) blockers.push('EXECUTION_CONTRACT_MISSING');
210
229
  else {
@@ -212,21 +231,29 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
212
231
  if (!executionContract.verification_command) blockers.push('EXECUTION_VERIFICATION_COMMAND_MISSING');
213
232
  if (executionContract.slice_kind !== 'expand_contract'
214
233
  && executionContract.context_budget_percent > 40) {
215
- blockers.push('EXECUTION_CONTEXT_BUDGET_EXCEEDED');
234
+ warnings.push('EXECUTION_CONTEXT_BUDGET_ADVISORY');
216
235
  }
217
236
  }
218
237
  }
219
238
  const readiness = blockers.length === 0 ? 'ready' : 'blocked';
220
239
  const nextAction = deriveNextAction({
221
- change, tasks, task, role, readiness, explicit: input.next_action,
240
+ change, tasks, task, role, readiness, blockers, explicit: input.next_action,
222
241
  });
223
242
  return {
224
243
  role,
225
244
  gate,
226
245
  next_action: nextAction,
227
- readiness: { status: readiness, blockers },
246
+ readiness: { status: readiness, blockers, warnings: [...new Set(warnings)] },
228
247
  context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
229
248
  execution_contract: executionContract,
249
+ baseline: baselineHealth.baseline ? {
250
+ id: baselineHealth.baseline.id,
251
+ mode: baselineHealth.baseline.mode,
252
+ status: baselineHealth.baseline.status,
253
+ repository_revision: baselineHealth.baseline.repository_revision,
254
+ health: baselineHealth.status,
255
+ warnings: [...new Set([...baselineHealth.blockers, ...baselineHealth.warnings])],
256
+ } : null,
230
257
  selected_task: task,
231
258
  resume: {
232
259
  change_id: change.id,
@@ -250,6 +277,7 @@ function recommendedWorkflow(change, task, tasks, readiness) {
250
277
  }
251
278
 
252
279
  function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
280
+ const baselineHealth = baselines.inspectBaseline(db, { rootDir });
253
281
  const change = id
254
282
  ? db.prepare('SELECT * FROM changes WHERE id = ?').get(id)
255
283
  : db.prepare(
@@ -259,10 +287,32 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
259
287
  throw new ContextSpineError('CHANGE_NOT_FOUND', `change ${id} not found`);
260
288
  }
261
289
  if (!change) {
290
+ const baseline = baselineHealth.baseline;
291
+ if (baselineHealth.status !== 'pass') {
292
+ const nextAction = baseline
293
+ ? `Complete ${baseline.mode} baseline adoption ${baseline.id} through ultra-init.`
294
+ : 'Initialize the project and start baseline adoption with ultra-init.';
295
+ return {
296
+ change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
297
+ blockers: baselineHealth.blockers, warnings: baselineHealth.warnings, next_action: nextAction,
298
+ recommended_workflow: 'ultra-init', context_manifest_path: null,
299
+ context_manifest_hash: null, git_head: currentHead(rootDir),
300
+ baseline: baseline ? {
301
+ id: baseline.id, mode: baseline.mode, status: baseline.status,
302
+ repository_revision: baseline.repository_revision,
303
+ } : null,
304
+ };
305
+ }
262
306
  return {
263
307
  change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
264
308
  next_action: 'Start daily work with ultra-change.', recommended_workflow: 'ultra-change',
265
309
  context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
310
+ blockers: [], warnings: baselineHealth.warnings,
311
+ baseline: {
312
+ id: baselineHealth.baseline.id, mode: baselineHealth.baseline.mode,
313
+ status: baselineHealth.baseline.status,
314
+ repository_revision: baselineHealth.baseline.repository_revision,
315
+ },
266
316
  };
267
317
  }
268
318
  const tasks = db.prepare('SELECT * FROM tasks WHERE change_id = ? ORDER BY created_at ASC').all(change.id);
@@ -275,18 +325,26 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
275
325
  const task = tasks.find((row) => row.id === taskId) || null;
276
326
  const head = currentHead(rootDir);
277
327
  const snapshotMissing = !snapshot;
278
- const legacyContext = Boolean(snapshot && (!context.resume || !context.context || !context.readiness));
328
+ const legacyContext = Boolean(
329
+ snapshot && (!context.resume || !context.context || !context.readiness || !('baseline' in context)),
330
+ );
279
331
  const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
280
332
  const stateChanged = context.resume?.task_status !== undefined
281
333
  && context.resume.task_status !== (task?.status || null);
282
334
  const blockers = [];
335
+ const warnings = [...baselineHealth.blockers, ...baselineHealth.warnings];
283
336
  if (snapshotMissing) blockers.push('CONTEXT_NOT_COMPILED');
284
337
  else {
285
338
  if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
286
339
  if (headStale) blockers.push('CONTEXT_HEAD_STALE');
287
340
  if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
288
341
  for (const blocker of parseJson(snapshot.blockers_json, [])) {
289
- if (!blockers.includes(blocker)) blockers.push(blocker);
342
+ if (ADVISORY_CONTEXT_CODES.has(blocker) || blocker.startsWith('BASELINE_')) {
343
+ if (!warnings.includes(blocker)) warnings.push(blocker);
344
+ } else if (!blockers.includes(blocker)) blockers.push(blocker);
345
+ }
346
+ for (const warning of context.readiness?.warnings || []) {
347
+ if (!warnings.includes(warning)) warnings.push(warning);
290
348
  }
291
349
  }
292
350
  const readiness = blockers.length > 0 ? 'blocked' : snapshot.readiness;
@@ -302,6 +360,7 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
302
360
  } else {
303
361
  nextAction = deriveNextAction({
304
362
  change, tasks, task, role: snapshot?.role || 'plan', readiness,
363
+ blockers,
305
364
  explicit: snapshot?.next_action,
306
365
  });
307
366
  }
@@ -315,11 +374,17 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
315
374
  gate: snapshot?.gate || 'alignment',
316
375
  readiness,
317
376
  blockers,
377
+ warnings: [...new Set(warnings)],
318
378
  next_action: nextAction,
319
379
  recommended_workflow: recommendedWorkflow(change, task, tasks, readiness),
320
380
  context_manifest_path: snapshot?.manifest_path || null,
321
381
  context_manifest_hash: snapshot?.manifest_hash || null,
322
382
  git_head: head,
383
+ baseline: baselineHealth.baseline ? {
384
+ id: baselineHealth.baseline.id, mode: baselineHealth.baseline.mode,
385
+ status: baselineHealth.baseline.status,
386
+ repository_revision: baselineHealth.baseline.repository_revision,
387
+ } : null,
323
388
  };
324
389
  }
325
390
 
@@ -11,6 +11,21 @@ const { initStateDb } = require('./state-db.cjs');
11
11
  const { createChange } = require('./change-workflow.cjs');
12
12
  const { createTask } = require('./state-ops.cjs');
13
13
 
14
+ test('breadcrumb routes a project without a converged baseline back to adoption', () => {
15
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-baseline-'));
16
+ const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
17
+ try {
18
+ const breadcrumb = readBreadcrumb(db, {}, { rootDir });
19
+ assert.equal(breadcrumb.readiness, 'blocked');
20
+ assert.ok(breadcrumb.blockers.includes('BASELINE_MISSING'));
21
+ assert.equal(breadcrumb.recommended_workflow, 'ultra-init');
22
+ assert.match(breadcrumb.next_action, /baseline|adoption/i);
23
+ } finally {
24
+ db.close();
25
+ fs.rmSync(rootDir, { recursive: true, force: true });
26
+ }
27
+ });
28
+
14
29
  test('deriveNextAction gives one state-specific action instead of a workflow dump', () => {
15
30
  const change = { id: 'context-spine', status: 'active' };
16
31
  const task = { id: 'task-1', status: 'in_progress' };
@@ -8,6 +8,7 @@ const ops = require('./state-ops.cjs');
8
8
  const runtime = require('./runtime-state.cjs');
9
9
  const projector = require('./projector.cjs');
10
10
  const recovery = require('../../orchestrator/recovery.cjs');
11
+ const baselines = require('./baseline-workflow.cjs');
11
12
 
12
13
  const SPEC_CONSUMER = 'spec-staleness';
13
14
 
@@ -48,6 +49,9 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
48
49
  WHERE status IN ('active', 'blocked', 'ready') AND artifact_root IS NOT NULL`,
49
50
  ).all().filter((row) => row.artifact_root && !fs.existsSync(path.resolve(rootDir, row.artifact_root))).length
50
51
  : 0;
52
+ const baseline = missing.length === 0
53
+ ? baselines.inspectBaseline(db, { rootDir })
54
+ : { status: 'fail', blockers: ['BASELINE_STATE_UNAVAILABLE'], warnings: [], baseline: null };
51
55
  const degraded = integrity !== 'ok' || missing.length > 0 || incidents.length > 0
52
56
  || pending.length > 0 || running.length > 0 || failed.length > 0 || orphanSessions > 0 || activeMissing > 0
53
57
  || eventCursor > projectedCursor;
@@ -63,6 +67,16 @@ function inspectSystem(db, { rootDir = process.cwd() } = {}) {
63
67
  },
64
68
  sessions: { status: orphanSessions === 0 ? 'pass' : 'fail', orphan: orphanSessions },
65
69
  change_artifacts: { status: activeMissing === 0 ? 'pass' : 'fail', missing: activeMissing },
70
+ baseline: {
71
+ status: baseline.status === 'pass' ? 'pass' : 'warning',
72
+ readiness: baseline.status,
73
+ mode: baseline.baseline?.mode || null,
74
+ baseline_status: baseline.baseline?.status || null,
75
+ id: baseline.baseline?.id || null,
76
+ repository_revision: baseline.baseline?.repository_revision || null,
77
+ blockers: baseline.blockers,
78
+ warnings: baseline.warnings,
79
+ },
66
80
  external_providers: {
67
81
  status: 'pass', ownership: 'external',
68
82
  note: 'Ultra stores provider metadata references only and never owns memory or code-graph content.',
@@ -10,10 +10,18 @@ const { initStateDb, closeStateDb } = require('./state-db.cjs');
10
10
  const ops = require('./state-ops.cjs');
11
11
  const runtime = require('./runtime-state.cjs');
12
12
  const doctor = require('./doctor.cjs');
13
+ const baselines = require('./baseline-workflow.cjs');
13
14
 
14
- function fixture() {
15
+ function fixture({ migratedBaseline = true } = {}) {
15
16
  const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-doctor-'));
16
17
  const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
18
+ if (migratedBaseline) {
19
+ db.prepare(
20
+ `INSERT INTO baselines
21
+ (id, project_name, mode, status, approved_by, approval_note, converged_at)
22
+ VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
23
+ ).run(new Date().toISOString());
24
+ }
17
25
  return { rootDir, db };
18
26
  }
19
27
 
@@ -35,6 +43,22 @@ test('doctor reports structured health for an initialized Ultra project', async
35
43
  }
36
44
  });
37
45
 
46
+ test('doctor reports incomplete brownfield adoption as advisory rather than authority failure', async () => {
47
+ const fx = fixture({ migratedBaseline: false });
48
+ try {
49
+ baselines.startBaseline(fx.db, {
50
+ id: 'adoption', project_name: 'legacy', mode: 'brownfield', scope: ['.'],
51
+ }, { rootDir: fx.rootDir, emitEvent: false });
52
+ const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
53
+ assert.equal(report.status, 'healthy');
54
+ assert.equal(report.checks.baseline.status, 'warning');
55
+ assert.equal(report.checks.baseline.mode, 'brownfield');
56
+ assert.ok(report.checks.baseline.blockers.includes('BASELINE_NOT_READY:adopting'));
57
+ } finally {
58
+ cleanup(fx);
59
+ }
60
+ });
61
+
38
62
  test('doctor repair is backup-first and drains projection/spec-event recovery work', async () => {
39
63
  const fx = fixture();
40
64
  try {
@@ -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,6 +15,8 @@ const fs = require('node:fs');
15
15
  const path = require('node:path');
16
16
 
17
17
  const { initStateDb } = require('./state-db.cjs');
18
+ const baselines = require('./baseline-workflow.cjs');
19
+ const runtimeState = require('./runtime-state.cjs');
18
20
 
19
21
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
20
22
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
@@ -95,20 +97,30 @@ function copyTemplate(source, dest) {
95
97
  return files;
96
98
  }
97
99
 
98
- function injectTasksJson(ultraDir, { project_name, project_type, stack }) {
100
+ function validateTasksTemplate(ultraDir) {
99
101
  const tasksFile = path.join(ultraDir, 'tasks', 'tasks.json');
100
102
  if (!fs.existsSync(tasksFile)) return;
101
103
  let data;
102
104
  try { data = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); }
103
105
  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');
106
+ if (!data || typeof data !== 'object' || !Array.isArray(data.tasks)) {
107
+ throw new InitProjectError('IO_ERROR', 'tasks.json template must contain a tasks array');
108
+ }
109
+ }
110
+
111
+ const IGNORED_ADOPTION_ENTRIES = new Set([
112
+ '.DS_Store', '.git', '.gitignore', '.ultra', 'node_modules', 'vendor', '.venv', 'venv',
113
+ ]);
114
+
115
+ function detectProjectMode(absTarget, requested = 'auto') {
116
+ if (!['auto', 'greenfield', 'brownfield'].includes(requested)) {
117
+ throw new InitProjectError('VALIDATION_ERROR', `unsupported initialization mode: ${requested}`);
118
+ }
119
+ 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';
112
124
  }
113
125
 
114
126
  function rollbackInitialization(ultraDir, backupPath) {
@@ -130,6 +142,7 @@ function initProject({
130
142
  project_name,
131
143
  project_type,
132
144
  stack,
145
+ mode = 'auto',
133
146
  overwrite = false,
134
147
  source_template,
135
148
  } = {}) {
@@ -139,6 +152,7 @@ function initProject({
139
152
 
140
153
  const absTarget = resolveTargetDir(target_dir);
141
154
  ensureTargetDir(absTarget);
155
+ const resolvedMode = detectProjectMode(absTarget, mode);
142
156
  const source = ensureTemplate(source_template);
143
157
  const ultraDir = path.join(absTarget, '.ultra');
144
158
 
@@ -155,13 +169,27 @@ function initProject({
155
169
  }
156
170
 
157
171
  let copiedFiles;
172
+ let baselineResult;
158
173
  try {
159
174
  fs.mkdirSync(ultraDir, { recursive: true });
160
175
  copiedFiles = copyTemplate(source, ultraDir);
161
- injectTasksJson(ultraDir, { project_name, project_type, stack });
176
+ validateTasksTemplate(ultraDir);
162
177
  const stateDbPath = path.join(ultraDir, 'state.db');
163
178
  const { db } = initStateDb(stateDbPath);
164
- db.close();
179
+ try {
180
+ baselineResult = baselines.startBaseline(db, {
181
+ id: 'project-baseline', project_name, project_type, stack,
182
+ mode: resolvedMode, repository_revision: baselines.gitHead(absTarget), scope: ['.'],
183
+ }, { rootDir: absTarget });
184
+ const job = runtimeState.enqueueProjection(db, { tool_name: 'task.init_project' });
185
+ const projection = runtimeState.processProjectionJobs(db, { rootDir: absTarget, limit: 1 });
186
+ const projected = projection.jobs.find((item) => item.id === job.id);
187
+ if (!projected || projected.status !== 'completed') {
188
+ throw new Error(projected?.error || 'initial projection did not complete');
189
+ }
190
+ } finally {
191
+ db.close();
192
+ }
165
193
  } catch (err) {
166
194
  const rollbackErrors = rollbackInitialization(ultraDir, backupPath);
167
195
  const rollbackNote = rollbackErrors.length > 0
@@ -179,10 +207,17 @@ function initProject({
179
207
  created_path: ultraDir,
180
208
  state_db_path: stateDbPath,
181
209
  status,
210
+ mode: resolvedMode,
211
+ baseline: {
212
+ id: baselineResult.id,
213
+ mode: baselineResult.mode,
214
+ status: baselineResult.status,
215
+ repository_revision: baselineResult.repository_revision,
216
+ },
182
217
  copied_files: copiedFiles,
183
218
  };
184
219
  if (backupPath) result.backup_path = backupPath;
185
220
  return result;
186
221
  }
187
222
 
188
- module.exports = { initProject, InitProjectError, DEFAULT_TEMPLATE };
223
+ module.exports = { initProject, InitProjectError, DEFAULT_TEMPLATE, detectProjectMode };
@@ -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', 'migrated', 'ready', 'test', 'legacy 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.',
@@ -9,10 +9,11 @@ const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
9
9
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
10
10
  : path.resolve(__dirname, '..', '..');
11
11
  const SCHEMA_FILE = path.join(REPO_ROOT, 'spec', 'schemas', 'state-db.sql');
12
- const EXPECTED_VERSION = '10.0';
12
+ const EXPECTED_VERSION = '11.0';
13
13
  const KIMI_SCHEMA_VERSION = '9.1';
14
14
 
15
15
  const REQUIRED_TABLES = Object.freeze([
16
+ 'baselines',
16
17
  'tasks',
17
18
  'events',
18
19
  'sessions',
@@ -31,6 +32,43 @@ const REQUIRED_TABLES = Object.freeze([
31
32
  'event_consumers',
32
33
  ]);
33
34
 
35
+ function applyBaselineUpgrade(db, fromVersion) {
36
+ db.exec(`
37
+ CREATE TABLE IF NOT EXISTS baselines (
38
+ id TEXT PRIMARY KEY,
39
+ project_name TEXT NOT NULL,
40
+ project_type TEXT,
41
+ stack TEXT,
42
+ mode TEXT NOT NULL CHECK (mode IN ('greenfield', 'brownfield', 'migrated')),
43
+ status TEXT NOT NULL DEFAULT 'draft'
44
+ CHECK (status IN ('draft', 'adopting', 'blocked', 'ready', 'superseded')),
45
+ repository_root TEXT NOT NULL DEFAULT '.',
46
+ scope_json TEXT NOT NULL DEFAULT '["."]',
47
+ repository_revision TEXT,
48
+ spec_refs_json TEXT NOT NULL DEFAULT '[]',
49
+ evidence_json TEXT NOT NULL DEFAULT '[]',
50
+ verification_json TEXT NOT NULL DEFAULT '[]',
51
+ unknowns_json TEXT NOT NULL DEFAULT '[]',
52
+ provider_refs_json TEXT NOT NULL DEFAULT '{}',
53
+ approved_by TEXT,
54
+ approval_note TEXT,
55
+ started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
56
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
57
+ converged_at TEXT
58
+ );
59
+ CREATE INDEX IF NOT EXISTS baselines_status ON baselines(status, updated_at);
60
+ `);
61
+ if (fromVersion && fromVersion !== EXPECTED_VERSION) {
62
+ db.prepare(
63
+ `INSERT OR IGNORE INTO baselines
64
+ (id, project_name, mode, status, approved_by, approval_note, converged_at)
65
+ VALUES ('migrated-baseline', 'Migrated Ultra project', 'migrated', 'ready',
66
+ 'schema-migration', 'Legacy project preserved during schema 11.0 migration',
67
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
68
+ ).run();
69
+ }
70
+ }
71
+
34
72
  function readSchemaSql() {
35
73
  if (!fs.existsSync(SCHEMA_FILE)) {
36
74
  throw new Error(`state-db schema missing at ${SCHEMA_FILE}`);
@@ -223,22 +261,43 @@ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
223
261
  db.transaction(() => {
224
262
  applyCompatibleColumns(db);
225
263
  const contextChanged = applyContextSpineUpgrade(db);
226
- if (contextChanged || (fromVersion && fromVersion !== EXPECTED_VERSION)) {
264
+ const contextMigration = db.prepare(
265
+ "SELECT 1 FROM migration_history WHERE to_version = '10.0' AND status = 'success' LIMIT 1",
266
+ ).get();
267
+ const needsContextMigration = Boolean(
268
+ fromVersion && !['10.0', EXPECTED_VERSION].includes(fromVersion) && !contextMigration,
269
+ );
270
+ if (contextChanged || needsContextMigration) {
227
271
  db.prepare(
228
272
  `INSERT INTO migration_history
229
273
  (from_version, to_version, direction, status, notes)
230
274
  VALUES (?, ?, 'forward', 'success', ?)`,
231
275
  ).run(
232
276
  runtimeChanged ? KIMI_SCHEMA_VERSION : (fromVersion || 'unknown'),
233
- EXPECTED_VERSION,
277
+ '10.0',
234
278
  'Add Context Spine role/gate readiness, execution contracts, breadcrumbs, and specification learning',
235
279
  );
236
280
  }
281
+ applyBaselineUpgrade(db, fromVersion);
282
+ const baselineMigration = db.prepare(
283
+ "SELECT 1 FROM migration_history WHERE to_version = '11.0' AND status = 'success' LIMIT 1",
284
+ ).get();
285
+ if (fromVersion && fromVersion !== EXPECTED_VERSION && !baselineMigration) {
286
+ db.prepare(
287
+ `INSERT INTO migration_history
288
+ (from_version, to_version, direction, status, notes)
289
+ VALUES (?, ?, 'forward', 'success', ?)`,
290
+ ).run(
291
+ fromVersion === '10.0' ? fromVersion : '10.0',
292
+ EXPECTED_VERSION,
293
+ 'Add authoritative greenfield and brownfield baseline adoption, evidence, convergence, and drift state',
294
+ );
295
+ }
237
296
  db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
238
297
  db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
239
298
  db.prepare(
240
299
  'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
241
- ).run(EXPECTED_VERSION, 'Role-scoped context snapshots, deterministic breadcrumbs, and approval-gated specification learning');
300
+ ).run(EXPECTED_VERSION, 'Greenfield and brownfield baseline adoption, convergence, and drift authority');
242
301
  })();
243
302
  }
244
303
 
@@ -123,7 +123,7 @@ test('initStateDb migrates existing runtime constraints to Kimi without losing r
123
123
  const legacy = openStateDb(file);
124
124
  const legacySchema = fs.readFileSync(SCHEMA_FILE, 'utf8').replaceAll(", 'kimi'", '');
125
125
  legacy.exec(legacySchema);
126
- legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.0')").run();
126
+ legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.0', '11.0')").run();
127
127
  legacy.prepare(
128
128
  "INSERT INTO tasks (id, title, type, priority) VALUES ('task-old', 'Old', 'feature', 'P1')",
129
129
  ).run();
@@ -146,16 +146,21 @@ test('initStateDb migrates existing runtime constraints to Kimi without losing r
146
146
  closeStateDb(legacy);
147
147
 
148
148
  const upgraded = initStateDb(file);
149
- assert.equal(upgraded.schema_version, '10.0');
149
+ assert.equal(upgraded.schema_version, '11.0');
150
150
  assert.equal(upgraded.db.prepare("SELECT runtime FROM sessions WHERE sid = 'session-old'").get().runtime, 'codex');
151
151
  assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE session_id = 'session-old'").get().n, 1);
152
152
  assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM incidents WHERE session_id = 'session-old'").get().n, 1);
153
153
  assert.deepEqual(upgraded.db.pragma('foreign_key_check'), []);
154
154
  const migrations = upgraded.db.prepare(
155
- "SELECT to_version, notes FROM migration_history WHERE to_version IN ('9.1', '10.0') ORDER BY id",
155
+ "SELECT to_version, notes FROM migration_history WHERE to_version IN ('9.1', '10.0', '11.0') ORDER BY id",
156
156
  ).all();
157
157
  assert.ok(migrations.some((row) => row.to_version === '9.1' && /Kimi/.test(row.notes)));
158
158
  assert.ok(migrations.some((row) => row.to_version === '10.0' && /Context Spine/.test(row.notes)));
159
+ assert.ok(migrations.some((row) => row.to_version === '11.0' && /baseline adoption/i.test(row.notes)));
160
+ assert.deepEqual(
161
+ upgraded.db.prepare("SELECT mode, status FROM baselines WHERE id = 'migrated-baseline'").get(),
162
+ { mode: 'migrated', status: 'ready' },
163
+ );
159
164
 
160
165
  upgraded.db.prepare(
161
166
  "INSERT INTO tasks (id, title, type, priority) VALUES ('task-kimi', 'Kimi', 'feature', 'P1')",