ultra-builder-pro-cli 0.5.3 → 0.7.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 (91) hide show
  1. package/.ultra-template/changes/active/.gitkeep +1 -0
  2. package/.ultra-template/changes/archive/.gitkeep +1 -0
  3. package/CHANGELOG.md +90 -14
  4. package/README.md +38 -32
  5. package/adapters/_shared/codex-assets.cjs +32 -151
  6. package/adapters/_shared/frontmatter.cjs +2 -2
  7. package/adapters/_shared/handbook.cjs +10 -10
  8. package/adapters/_shared/path-rewrite.cjs +0 -3
  9. package/adapters/_shared/runtime-assets.cjs +13 -4
  10. package/adapters/_shared/tests/handbook.test.cjs +4 -1
  11. package/adapters/_shared/tests/path-rewrite.test.cjs +1 -3
  12. package/adapters/_shared/tests/resolve-target.test.cjs +1 -11
  13. package/adapters/_shared/tests/runtime-assets.test.cjs +7 -6
  14. package/adapters/opencode.js +82 -26
  15. package/adapters/tests/claude.test.cjs +1 -4
  16. package/adapters/tests/codex.test.cjs +9 -8
  17. package/adapters/tests/opencode.test.cjs +17 -11
  18. package/bin/install.js +6 -28
  19. package/bin/orchestrator.js +12 -30
  20. package/commands/ultra-change.md +47 -0
  21. package/commands/ultra-deliver.md +14 -7
  22. package/commands/ultra-dev.md +6 -3
  23. package/commands/ultra-doctor.md +36 -0
  24. package/commands/ultra-init.md +4 -3
  25. package/commands/ultra-status.md +7 -5
  26. package/commands/ultra-test.md +2 -1
  27. package/docs/AGENT-CONTEXT.md +35 -28
  28. package/docs/ARCHITECTURE.md +49 -21
  29. package/docs/PLAN.zh-CN.md +67 -68
  30. package/docs/ROADMAP.md +17 -17
  31. package/docs/RUNTIME-COMPAT-MATRIX.md +63 -62
  32. package/hooks/README.md +8 -7
  33. package/hooks/active_task_context.py +22 -15
  34. package/hooks/health_check.py +136 -38
  35. package/hooks/workflow_context.py +120 -30
  36. package/mcp-server/lib/change-workflow.cjs +556 -0
  37. package/mcp-server/lib/change-workflow.test.cjs +259 -0
  38. package/mcp-server/lib/doctor.cjs +112 -0
  39. package/mcp-server/lib/doctor.test.cjs +86 -0
  40. package/mcp-server/lib/init-project.cjs +32 -6
  41. package/mcp-server/lib/pricing.cjs +0 -6
  42. package/mcp-server/lib/runtime-state.cjs +222 -0
  43. package/mcp-server/lib/runtime-state.test.cjs +92 -0
  44. package/mcp-server/lib/state-db.cjs +31 -8
  45. package/mcp-server/lib/state-ops.cjs +24 -3
  46. package/mcp-server/lib/telemetry.cjs +4 -0
  47. package/mcp-server/server.cjs +102 -10
  48. package/mcp-server/tests/db-ops.test.cjs +5 -1
  49. package/mcp-server/tests/init-project.test.cjs +45 -0
  50. package/mcp-server/tests/server-contract.test.cjs +140 -1
  51. package/mcp-server/tests/state-db.test.cjs +44 -7
  52. package/mcp-server/tests/state-ops.test.cjs +42 -0
  53. package/mcp-server/tests/telemetry.test.cjs +14 -0
  54. package/orchestrator/daemon.cjs +39 -0
  55. package/orchestrator/dispatch-rules.cjs +15 -4
  56. package/orchestrator/tests/daemon.test.cjs +63 -12
  57. package/orchestrator/tests/dispatch-rules.test.cjs +12 -10
  58. package/package.json +5 -7
  59. package/settings.json +1 -2
  60. package/skills/ultra-change/SKILL.md +140 -0
  61. package/skills/ultra-deliver/SKILL.md +81 -15
  62. package/skills/ultra-dev/SKILL.md +32 -12
  63. package/skills/ultra-doctor/SKILL.md +90 -0
  64. package/skills/ultra-init/SKILL.md +8 -6
  65. package/skills/ultra-review/SKILL.md +16 -0
  66. package/skills/ultra-status/SKILL.md +30 -7
  67. package/skills/ultra-test/SKILL.md +14 -2
  68. package/skills/ultra-verify/SKILL.md +22 -41
  69. package/skills/ultra-verify/evals/evals.json +22 -24
  70. package/skills/ultra-verify/references/confidence-system.md +15 -32
  71. package/skills/ultra-verify/references/cross-verify-modes.md +14 -59
  72. package/skills/ultra-verify/scripts/verify_wait.py +44 -81
  73. package/spec/cli-protocol.md +12 -1
  74. package/spec/mcp-tools.yaml +340 -4
  75. package/spec/schemas/mcp-tools.schema.json +1 -1
  76. package/spec/schemas/skill-manifest.schema.json +2 -2
  77. package/spec/schemas/state-db.sql +119 -2
  78. package/ultra-tools/cli.cjs +3 -0
  79. package/ultra-tools/commands/system.cjs +57 -0
  80. package/ultra-tools/system-command.test.cjs +53 -0
  81. package/adapters/_shared/md-to-toml.cjs +0 -54
  82. package/adapters/_shared/rtk-detect.cjs +0 -86
  83. package/adapters/_shared/tests/md-to-toml.test.cjs +0 -48
  84. package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
  85. package/adapters/gemini.js +0 -204
  86. package/adapters/tests/gemini.test.cjs +0 -112
  87. package/docs/LEGACY-HERMES.md +0 -1175
  88. package/orchestrator/code-graph-watcher.cjs +0 -121
  89. package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
  90. package/skills/gemini-collab/SKILL.md +0 -51
  91. package/skills/gemini-collab/evals/evals.json +0 -42
@@ -28,6 +28,9 @@ 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 changes = require('./lib/change-workflow.cjs');
32
+ const runtimeState = require('./lib/runtime-state.cjs');
33
+ const doctor = require('./lib/doctor.cjs');
31
34
 
32
35
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
33
36
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
@@ -64,8 +67,22 @@ const PLAN_TOOLS = Object.freeze([
64
67
  'plan.get',
65
68
  ]);
66
69
 
70
+ const CHANGE_TOOLS = Object.freeze([
71
+ 'change.create',
72
+ 'change.update',
73
+ 'change.get',
74
+ 'change.list',
75
+ 'change.context',
76
+ 'change.converge',
77
+ 'change.archive',
78
+ ]);
79
+
80
+ const SYSTEM_TOOLS = Object.freeze([
81
+ 'system.doctor',
82
+ ]);
83
+
67
84
  const REGISTERED_TOOLS = Object.freeze([
68
- ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS,
85
+ ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...CHANGE_TOOLS, ...SYSTEM_TOOLS,
69
86
  ]);
70
87
 
71
88
  const STATELESS_TOOLS = new Set(['task.init_project']);
@@ -76,6 +93,8 @@ const MUTATING_TOOLS = new Set([
76
93
  'task.create', 'task.update', 'task.delete', 'task.append_event', 'task.switch_tag',
77
94
  'task.parse_prd', 'task.expand',
78
95
  'session.spawn', 'session.close', 'session.heartbeat',
96
+ 'plan.export',
97
+ 'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
79
98
  ]);
80
99
 
81
100
  function loadRegisteredTools() {
@@ -145,6 +164,7 @@ async function dispatchTool(name, input, db, ctx = {}) {
145
164
  const r = ops.appendEvent(db, {
146
165
  type: input.type,
147
166
  task_id: input.task_id,
167
+ change_id: input.change_id,
148
168
  session_id: input.session_id,
149
169
  runtime: input.runtime,
150
170
  payload: input.payload,
@@ -328,6 +348,53 @@ async function dispatchTool(name, input, db, ctx = {}) {
328
348
  }
329
349
  return { plan: planStore.selectSection(loaded, input.section) };
330
350
  }
351
+ case 'change.create': {
352
+ const { randomUUID } = require('node:crypto');
353
+ const id = input.id || `chg-${randomUUID().slice(0, 12)}`;
354
+ return changes.createChange(db, { ...input, id }, { rootDir: ctx.rootDir || process.cwd() });
355
+ }
356
+ case 'change.update': {
357
+ return {
358
+ ok: true,
359
+ change: changes.updateChange(
360
+ db, input.id, input.patch || {}, { rootDir: ctx.rootDir || process.cwd() },
361
+ ),
362
+ };
363
+ }
364
+ case 'change.get': {
365
+ const change = changes.readChange(db, input.id);
366
+ if (!change) {
367
+ const err = new Error(`change ${input.id} not found`);
368
+ err.code = 'CHANGE_NOT_FOUND';
369
+ throw err;
370
+ }
371
+ return { change };
372
+ }
373
+ case 'change.list': {
374
+ const rows = changes.listChanges(db, input || {});
375
+ return { changes: rows, count: rows.length };
376
+ }
377
+ case 'change.context': {
378
+ const out = changes.compileContext(db, input, { rootDir: ctx.rootDir || process.cwd() });
379
+ return {
380
+ manifest_path: out.context_manifest_path,
381
+ manifest_hash: out.manifest_hash,
382
+ manifest: out.manifest,
383
+ };
384
+ }
385
+ case 'change.converge': {
386
+ return changes.convergeChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
387
+ }
388
+ case 'change.archive': {
389
+ return changes.archiveChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
390
+ }
391
+ case 'system.doctor': {
392
+ return doctor.runDoctor(db, {
393
+ rootDir: ctx.rootDir || process.cwd(),
394
+ repair: input.repair === true,
395
+ project: ctx.projector || projector.projectAll,
396
+ });
397
+ }
331
398
  default:
332
399
  throw new Error(`unhandled tool ${name}`);
333
400
  }
@@ -345,7 +412,7 @@ function errorResponse(code, message, retriable = false, details = undefined) {
345
412
  };
346
413
  }
347
414
 
348
- function startServer({ dbPath, rootDir, projectOnWrite = true }) {
415
+ function startServer({ dbPath, rootDir, projectOnWrite = true, project = projector.projectAll }) {
349
416
  let db = null;
350
417
  const getDb = () => {
351
418
  if (!db) db = initStateDb(dbPath).db;
@@ -415,7 +482,7 @@ function startServer({ dbPath, rootDir, projectOnWrite = true }) {
415
482
  try {
416
483
  toolDb = STATELESS_TOOLS.has(name) ? null : getDb();
417
484
  if (toolDb) assertStateAuthority(toolDb, rootDir);
418
- result = await dispatchTool(name, args, toolDb, { rootDir });
485
+ result = await dispatchTool(name, args, toolDb, { rootDir, projector: project });
419
486
  } catch (err) {
420
487
  const code = err.code || (err instanceof ops.StateOpsError ? err.code : 'STATE_DB_ERROR');
421
488
  toolError = code;
@@ -423,22 +490,45 @@ function startServer({ dbPath, rootDir, projectOnWrite = true }) {
423
490
  return errorResponse(code, err.message, !!err.retriable, err.details);
424
491
  }
425
492
 
493
+ let runtimeMeta = null;
494
+ if (toolDb && projectOnWrite && MUTATING_TOOLS.has(name)) {
495
+ const job = runtimeState.enqueueProjection(toolDb, { tool_name: name });
496
+ const processed = runtimeState.processProjectionJobs(toolDb, { rootDir, project, limit: 500 });
497
+ const own = processed.jobs.find((item) => item.id === job.id);
498
+ runtimeMeta = {
499
+ state_commit: 'committed',
500
+ projection_status: own ? own.status : 'failed',
501
+ projection_job_id: job.id,
502
+ };
503
+ if (own && own.incident_id) runtimeMeta.incident_id = own.incident_id;
504
+ } else if (toolDb && name === 'system.doctor' && args.repair === true) {
505
+ runtimeMeta = {
506
+ state_commit: 'committed',
507
+ projection_status: result.checks.projections.status === 'pass' ? 'completed' : 'failed',
508
+ backup_path: result.backup_path,
509
+ };
510
+ }
511
+
426
512
  const validateOutput = outputValidators.get(name);
427
513
  if (!validateOutput(result)) {
428
514
  toolError = 'OUTPUT_SCHEMA_DRIFT';
429
515
  emitTelemetry();
430
- return errorResponse('OUTPUT_SCHEMA_DRIFT', ajv.errorsText(validateOutput.errors));
431
- }
432
-
433
- if (toolDb && projectOnWrite && MUTATING_TOOLS.has(name)) {
434
- try { projector.projectAll(toolDb, { rootDir }); }
435
- catch (err) { process.stderr.write(`projector warning: ${err.message}\n`); }
516
+ const response = errorResponse(
517
+ 'OUTPUT_SCHEMA_DRIFT',
518
+ ajv.errorsText(validateOutput.errors),
519
+ false,
520
+ runtimeMeta ? { _ultra: runtimeMeta } : undefined,
521
+ );
522
+ if (runtimeMeta) response._meta = { ultra: runtimeMeta };
523
+ return response;
436
524
  }
437
525
 
438
526
  emitTelemetry();
527
+ const visible = runtimeMeta ? { ...result, _ultra: runtimeMeta } : result;
439
528
  return {
440
- content: [{ type: 'text', text: JSON.stringify(result) }],
529
+ content: [{ type: 'text', text: JSON.stringify(visible) }],
441
530
  structuredContent: result,
531
+ ...(runtimeMeta ? { _meta: { ultra: runtimeMeta } } : {}),
442
532
  };
443
533
  });
444
534
 
@@ -478,6 +568,8 @@ module.exports = {
478
568
  dispatchTool,
479
569
  TASK_TOOLS,
480
570
  SESSION_TOOLS,
571
+ CHANGE_TOOLS,
572
+ SYSTEM_TOOLS,
481
573
  REGISTERED_TOOLS,
482
574
  STATELESS_TOOLS,
483
575
  MUTATING_TOOLS,
@@ -46,7 +46,11 @@ test('db vacuum reports reclaimed bytes after deleting rows', () => {
46
46
  try {
47
47
  const init = initStateDb(dbPath);
48
48
  for (let i = 0; i < 50; i++) {
49
- ops.appendEvent(init.db, { type: 'task_created', task_id: `t-${i}` });
49
+ ops.appendEvent(init.db, {
50
+ type: 'task_created',
51
+ task_id: `t-${i}`,
52
+ payload: { padding: 'x'.repeat(16 * 1024) },
53
+ });
50
54
  }
51
55
  init.db.prepare('DELETE FROM events').run();
52
56
  closeStateDb(init.db);
@@ -5,6 +5,7 @@ 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 Database = require('better-sqlite3');
8
9
 
9
10
  const { initProject, InitProjectError, DEFAULT_TEMPLATE } = require('../lib/init-project.cjs');
10
11
 
@@ -29,6 +30,18 @@ test('initProject copies bundled template into .ultra/', () => {
29
30
  assert.equal(r.created_path, path.join(target, '.ultra'));
30
31
  assert.ok(r.copied_files.includes('tasks/tasks.json'));
31
32
  assert.ok(r.copied_files.includes('specs/product.md'));
33
+ assert.ok(r.copied_files.includes('changes/active/.gitkeep'));
34
+ assert.ok(r.copied_files.includes('changes/archive/.gitkeep'));
35
+ assert.equal(r.state_db_path, path.join(target, '.ultra', 'state.db'));
36
+ assert.ok(fs.existsSync(r.state_db_path));
37
+ const db = new Database(r.state_db_path, { readonly: true });
38
+ try {
39
+ const version = db.prepare("SELECT version FROM schema_version WHERE version = '9.0'").get();
40
+ assert.equal(version.version, '9.0');
41
+ assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE name = 'changes'").get());
42
+ } finally {
43
+ db.close();
44
+ }
32
45
  assert.ok(!r.copied_files.some((f) => f.endsWith('.DS_Store')));
33
46
  } finally { cleanup(target); }
34
47
  });
@@ -81,6 +94,38 @@ test('initProject with overwrite=true backs up existing .ultra/', () => {
81
94
  } finally { cleanup(target); }
82
95
  });
83
96
 
97
+ test('initProject restores the prior .ultra when overwrite initialization fails', () => {
98
+ const target = mkTempDir();
99
+ const badTemplate = mkTempDir('ubp-bad-template-');
100
+ try {
101
+ initProject({ target_dir: target, project_name: 'first' });
102
+ fs.writeFileSync(path.join(target, '.ultra', 'sentinel.txt'), 'restore-me');
103
+ fs.cpSync(DEFAULT_TEMPLATE, badTemplate, { recursive: true });
104
+ fs.writeFileSync(path.join(badTemplate, 'tasks', 'tasks.json'), '{not-json\n');
105
+
106
+ assert.throws(
107
+ () => initProject({
108
+ target_dir: target,
109
+ project_name: 'broken-overwrite',
110
+ overwrite: true,
111
+ source_template: badTemplate,
112
+ }),
113
+ (error) => error instanceof InitProjectError && error.code === 'IO_ERROR',
114
+ );
115
+ assert.equal(
116
+ fs.readFileSync(path.join(target, '.ultra', 'sentinel.txt'), 'utf8'),
117
+ 'restore-me',
118
+ );
119
+ assert.equal(
120
+ fs.readdirSync(target).some((name) => name.startsWith('.ultra.backup.')),
121
+ false,
122
+ );
123
+ } finally {
124
+ cleanup(target);
125
+ cleanup(badTemplate);
126
+ }
127
+ });
128
+
84
129
  test('initProject rejects empty project_name', () => {
85
130
  const target = mkTempDir();
86
131
  try {
@@ -6,6 +6,7 @@ const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
8
  const yaml = require('js-yaml');
9
+ const Database = require('better-sqlite3');
9
10
 
10
11
  const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
11
12
  const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
@@ -19,7 +20,7 @@ function tmpProject() {
19
20
  return { dir, dbPath: path.join(dir, '.ultra', 'state.db') };
20
21
  }
21
22
 
22
- async function withClient({ dir, dbPath }, fn) {
23
+ async function withClient({ dir, dbPath }, fn, extraEnv = {}) {
23
24
  const transport = new StdioClientTransport({
24
25
  command: process.execPath,
25
26
  args: [SERVER],
@@ -27,6 +28,7 @@ async function withClient({ dir, dbPath }, fn) {
27
28
  ...process.env,
28
29
  UBP_DB_PATH: dbPath,
29
30
  UBP_ROOT_DIR: dir,
31
+ ...extraEnv,
30
32
  },
31
33
  stderr: 'pipe',
32
34
  });
@@ -72,6 +74,13 @@ test('listTools returns workflow tools and exposes no Ultra memory API', async (
72
74
  const list = await client.listTools();
73
75
  const names = list.tools.map((t) => t.name).sort();
74
76
  assert.deepEqual(names, [
77
+ 'change.archive',
78
+ 'change.context',
79
+ 'change.converge',
80
+ 'change.create',
81
+ 'change.get',
82
+ 'change.list',
83
+ 'change.update',
75
84
  'plan.export',
76
85
  'plan.get',
77
86
  'session.admission_check',
@@ -81,6 +90,7 @@ test('listTools returns workflow tools and exposes no Ultra memory API', async (
81
90
  'session.list',
82
91
  'session.spawn',
83
92
  'session.subscribe_events',
93
+ 'system.doctor',
84
94
  'task.append_event',
85
95
  'task.create',
86
96
  'task.delete',
@@ -105,6 +115,67 @@ test('listTools returns workflow tools and exposes no Ultra memory API', async (
105
115
  }
106
116
  });
107
117
 
118
+ test('change.create + change.context expose a continuous change unit with external provider references', async () => {
119
+ const proj = tmpProject();
120
+ try {
121
+ fs.writeFileSync(path.join(proj.dir, 'README.md'), '# project\n');
122
+ await withClient(proj, async (client) => {
123
+ const created = await client.callTool({
124
+ name: 'change.create',
125
+ arguments: {
126
+ id: 'mcp-change', title: 'Continuous maintenance', kind: 'quick',
127
+ intent: 'Keep daily modifications synchronized after initial delivery.',
128
+ docs_impact: { status: 'none', files: [], rationale: 'Contract-only fixture.' },
129
+ provider_refs: {
130
+ memory: { provider: 'cloud-mem', status: 'available', reference: 'cmem://fixture' },
131
+ code_graph: { provider: 'codebase-memory-mcp', status: 'stale', project: 'fixture' },
132
+ },
133
+ },
134
+ });
135
+ const payload = readToolPayload(created);
136
+ assert.equal(payload.change.id, 'mcp-change');
137
+ assert.equal(payload.change.status, 'active');
138
+ assert.equal(created._meta.ultra.state_commit, 'committed');
139
+ assert.equal(created._meta.ultra.projection_status, 'completed');
140
+
141
+ const context = readToolPayload(await client.callTool({
142
+ name: 'change.context', arguments: { id: 'mcp-change' },
143
+ }));
144
+ assert.equal(context.manifest.change.id, 'mcp-change');
145
+ assert.equal(context.manifest.providers.memory.provider, 'cloud-mem');
146
+ assert.equal(context.manifest.provider_boundary.includes('content remain external'), true);
147
+
148
+ const listed = readToolPayload(await client.callTool({
149
+ name: 'change.list', arguments: { status: 'active' },
150
+ }));
151
+ assert.equal(listed.count, 1);
152
+ assert.equal(listed.changes[0].id, 'mcp-change');
153
+ });
154
+ } finally {
155
+ fs.rmSync(proj.dir, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ test('system.doctor returns structured provider ownership and projection health', async () => {
160
+ const proj = tmpProject();
161
+ try {
162
+ await withClient(proj, async (client) => {
163
+ await client.callTool({
164
+ name: 'task.create',
165
+ arguments: { id: 'doctor-seed', title: 'doctor seed', type: 'feature', priority: 'P1' },
166
+ });
167
+ const result = readToolPayload(await client.callTool({
168
+ name: 'system.doctor', arguments: { repair: false },
169
+ }));
170
+ assert.equal(result.status, 'healthy');
171
+ assert.equal(result.checks.external_providers.ownership, 'external');
172
+ assert.equal(result.repair_performed, false);
173
+ });
174
+ } finally {
175
+ fs.rmSync(proj.dir, { recursive: true, force: true });
176
+ }
177
+ });
178
+
108
179
  test('MCP metadata matches the package and tools/list does not create project state', async () => {
109
180
  const proj = tmpProject();
110
181
  try {
@@ -309,6 +380,54 @@ test('mutating tools trigger the projector — tasks.json appears under .ultra/'
309
380
  }
310
381
  });
311
382
 
383
+ test('output schema drift cannot strand a committed mutation without a projection job', async () => {
384
+ const proj = tmpProject();
385
+ try {
386
+ const runtimeRoot = path.join(proj.dir, 'runtime-contract');
387
+ fs.mkdirSync(path.join(runtimeRoot, 'spec', 'schemas'), { recursive: true });
388
+ fs.copyFileSync(path.join(REPO_ROOT, 'package.json'), path.join(runtimeRoot, 'package.json'));
389
+ fs.copyFileSync(
390
+ path.join(REPO_ROOT, 'spec', 'schemas', 'state-db.sql'),
391
+ path.join(runtimeRoot, 'spec', 'schemas', 'state-db.sql'),
392
+ );
393
+ const manifest = yaml.load(
394
+ fs.readFileSync(path.join(REPO_ROOT, 'spec', 'mcp-tools.yaml'), 'utf8'),
395
+ );
396
+ const createTool = manifest.tools.find((tool) => tool.name === 'task.create');
397
+ createTool.output_schema.required = ['impossible_output_field'];
398
+ createTool.output_schema.properties.impossible_output_field = { type: 'string' };
399
+ fs.writeFileSync(
400
+ path.join(runtimeRoot, 'spec', 'mcp-tools.yaml'),
401
+ yaml.dump(manifest, { noRefs: true, lineWidth: 120 }),
402
+ );
403
+
404
+ await withClient(proj, async (client) => {
405
+ const result = await client.callTool({
406
+ name: 'task.create',
407
+ arguments: { id: 'schema-drift', title: 'schema drift', type: 'feature', priority: 'P1' },
408
+ });
409
+ const error = expectError(result);
410
+ assert.equal(error.code, 'OUTPUT_SCHEMA_DRIFT');
411
+ assert.equal(error.details._ultra.state_commit, 'committed');
412
+ assert.equal(error.details._ultra.projection_status, 'completed');
413
+ }, { UBP_RUNTIME_ROOT: runtimeRoot });
414
+
415
+ const db = new Database(proj.dbPath, { readonly: true, fileMustExist: true });
416
+ try {
417
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM tasks WHERE id = ?').get('schema-drift').count, 1);
418
+ const job = db.prepare(
419
+ "SELECT status FROM projection_jobs WHERE tool_name = 'task.create' ORDER BY id DESC LIMIT 1",
420
+ ).get();
421
+ assert.deepEqual(job, { status: 'completed' });
422
+ } finally {
423
+ db.close();
424
+ }
425
+ assert.ok(fs.existsSync(path.join(proj.dir, '.ultra', 'tasks', 'tasks.json')));
426
+ } finally {
427
+ fs.rmSync(proj.dir, { recursive: true, force: true });
428
+ }
429
+ });
430
+
312
431
  test('task.init_project creates .ultra/ skeleton in a fresh target directory', async () => {
313
432
  const proj = tmpProject();
314
433
  const freshTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-target-'));
@@ -321,6 +440,8 @@ test('task.init_project creates .ultra/ skeleton in a fresh target directory', a
321
440
  const payload = readToolPayload(res);
322
441
  assert.equal(payload.status, 'created');
323
442
  assert.equal(payload.created_path, path.join(freshTarget, '.ultra'));
443
+ assert.equal(payload.state_db_path, path.join(freshTarget, '.ultra', 'state.db'));
444
+ assert.ok(fs.existsSync(payload.state_db_path));
324
445
  assert.ok(payload.copied_files.includes('tasks/tasks.json'));
325
446
  const tasksJson = JSON.parse(fs.readFileSync(path.join(payload.created_path, 'tasks', 'tasks.json'), 'utf8'));
326
447
  assert.equal(tasksJson.project.name, 'mcp-init');
@@ -390,6 +511,23 @@ test('session.admission_check + session.spawn: happy path returns sid and paths'
390
511
  }
391
512
  });
392
513
 
514
+ test('session.spawn rejects a retired runtime at the MCP schema boundary', async () => {
515
+ const proj = tmpProject();
516
+ const retiredRuntime = ['gem', 'ini'].join('');
517
+ try {
518
+ await withClient(proj, async (client) => {
519
+ await seedTask(client, 's-runtime-guard');
520
+ const spawn = await client.callTool({
521
+ name: 'session.spawn',
522
+ arguments: { task_id: 's-runtime-guard', runtime: retiredRuntime },
523
+ });
524
+ assert.equal(expectError(spawn).code, 'VALIDATION_ERROR');
525
+ });
526
+ } finally {
527
+ fs.rmSync(proj.dir, { recursive: true, force: true });
528
+ }
529
+ });
530
+
393
531
  test('session.spawn refuses second session for same task without takeover (ADMISSION_DENIED)', async () => {
394
532
  const proj = tmpProject();
395
533
  try {
@@ -731,6 +869,7 @@ test('plan.export → plan.get round trip: artifact on disk + retrievable', asyn
731
869
  const expData = readToolPayload(exp);
732
870
  assert.equal(expData.wave_count, 2);
733
871
  assert.ok(fs.existsSync(expData.plan_path), 'artifact file must exist');
872
+ assert.equal(exp._meta.ultra.projection_status, 'completed');
734
873
 
735
874
  const got = await client.callTool({ name: 'plan.get', arguments: { section: 'topo' } });
736
875
  const gotData = readToolPayload(got);
@@ -8,22 +8,59 @@ const path = require('node:path');
8
8
 
9
9
  const stateDb = require('../lib/state-db.cjs');
10
10
 
11
- test('initStateDb upgrades a v8A.1 database in place with estimated_days', () => {
11
+ test('initStateDb upgrades a pre-9.0 database in place with task/event change linkage', () => {
12
12
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-db-upgrade-'));
13
13
  const file = path.join(dir, 'state.db');
14
14
  try {
15
15
  const db = stateDb.openStateDb(file);
16
- const oldSql = fs.readFileSync(stateDb.SCHEMA_FILE, 'utf8')
17
- .replace(/^\s*estimated_days.*\n/m, '')
18
- .replace(/^INSERT OR IGNORE INTO schema_version \(version, description\)\nVALUES \('8A\.2'.*\n/m, '');
19
- db.exec(oldSql);
16
+ db.exec(`
17
+ CREATE TABLE tasks (
18
+ id TEXT PRIMARY KEY,
19
+ title TEXT NOT NULL,
20
+ type TEXT NOT NULL,
21
+ priority TEXT NOT NULL,
22
+ complexity INTEGER,
23
+ status TEXT NOT NULL DEFAULT 'pending',
24
+ deps TEXT,
25
+ files_modified TEXT,
26
+ session_id TEXT,
27
+ stale INTEGER NOT NULL DEFAULT 0,
28
+ complexity_hint TEXT,
29
+ tag TEXT,
30
+ trace_to TEXT,
31
+ context_file TEXT,
32
+ completion_commit TEXT,
33
+ parent_id TEXT,
34
+ created_at TEXT NOT NULL,
35
+ updated_at TEXT NOT NULL
36
+ );
37
+ CREATE TABLE events (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ ts TEXT NOT NULL,
40
+ type TEXT NOT NULL,
41
+ task_id TEXT,
42
+ session_id TEXT,
43
+ runtime TEXT,
44
+ payload_json TEXT
45
+ );
46
+ CREATE TABLE schema_version (
47
+ version TEXT PRIMARY KEY,
48
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
49
+ description TEXT
50
+ );
51
+ INSERT INTO schema_version (version, applied_at, description)
52
+ VALUES ('8A.1', '2026-01-01T00:00:00.000Z', 'legacy fixture');
53
+ `);
20
54
  stateDb.closeStateDb(db);
21
55
 
22
56
  const upgraded = stateDb.initStateDb(file);
23
57
  const columns = upgraded.db.prepare('PRAGMA table_info(tasks)').all().map((row) => row.name);
24
58
  assert.ok(columns.includes('estimated_days'));
25
- assert.equal(upgraded.schema_version, '8A.2');
26
- assert.ok(upgraded.db.prepare("SELECT 1 FROM schema_version WHERE version = '8A.2'").get());
59
+ assert.ok(columns.includes('change_id'));
60
+ const eventColumns = upgraded.db.prepare('PRAGMA table_info(events)').all().map((row) => row.name);
61
+ assert.ok(eventColumns.includes('change_id'));
62
+ assert.equal(upgraded.schema_version, '9.0');
63
+ assert.ok(upgraded.db.prepare("SELECT 1 FROM schema_version WHERE version = '9.0'").get());
27
64
  stateDb.closeStateDb(upgraded.db);
28
65
  } finally {
29
66
  fs.rmSync(dir, { recursive: true, force: true });
@@ -112,6 +112,25 @@ test('patchTask updates JSON arrays + flags + status atomically', () => {
112
112
  }
113
113
  });
114
114
 
115
+ test('patchTask status event follows the newly assigned continuous change', () => {
116
+ const { dir, db } = freshDb();
117
+ try {
118
+ db.prepare(
119
+ `INSERT INTO changes (id, title, kind, intent, artifact_root)
120
+ VALUES ('chg-link', 'Link task', 'quick', 'Link task to change.', '.ultra/changes/active/chg-link')`,
121
+ ).run();
122
+ ops.createTask(db, { id: 'link-task', title: 'link me', type: 'feature', priority: 'P1' });
123
+ ops.patchTask(db, 'link-task', { change_id: 'chg-link', status: 'in_progress' });
124
+ const event = db.prepare(
125
+ "SELECT change_id FROM events WHERE task_id = 'link-task' AND type = 'task_started'",
126
+ ).get();
127
+ assert.equal(event.change_id, 'chg-link');
128
+ closeStateDb(db);
129
+ } finally {
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
133
+
115
134
  test('patchTask rejects unknown fields', () => {
116
135
  const { dir, db } = freshDb();
117
136
  try {
@@ -194,6 +213,29 @@ test('createSession requires existing task and emits session_spawned', () => {
194
213
  }
195
214
  });
196
215
 
216
+ test('runtime-bearing mutations reject unsupported runtimes before touching state', () => {
217
+ const { dir, db } = freshDb();
218
+ const retiredRuntime = ['gem', 'ini'].join('');
219
+ try {
220
+ ops.createTask(db, { id: 'runtime-guard', title: 'guard', type: 'feature', priority: 'P1' });
221
+ assert.throws(
222
+ () => ops.createSession(db, {
223
+ sid: 'unsupported', task_id: 'runtime-guard', runtime: retiredRuntime,
224
+ worktree_path: '/tmp/wt', artifact_dir: '/tmp/art',
225
+ }),
226
+ (error) => error.code === 'VALIDATION_ERROR' && /unsupported runtime/.test(error.message),
227
+ );
228
+ assert.throws(
229
+ () => ops.appendEvent(db, { type: 'task_started', task_id: 'runtime-guard', runtime: retiredRuntime }),
230
+ (error) => error.code === 'VALIDATION_ERROR' && /unsupported runtime/.test(error.message),
231
+ );
232
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions").get().n, 0);
233
+ closeStateDb(db);
234
+ } finally {
235
+ fs.rmSync(dir, { recursive: true, force: true });
236
+ }
237
+ });
238
+
197
239
  test('updateSession status=completed emits session_closed', () => {
198
240
  const { dir, db } = freshDb();
199
241
  try {
@@ -140,6 +140,20 @@ test('appendTelemetry event_type constraint: invalid value rejected', () => {
140
140
  } finally { teardown(dir, db); }
141
141
  });
142
142
 
143
+ test('appendTelemetry rejects unsupported runtime labels', () => {
144
+ const { dir, db } = freshFixture();
145
+ const retiredRuntime = ['gem', 'ini'].join('');
146
+ try {
147
+ assert.throws(
148
+ () => telemetry.appendTelemetry(db, {
149
+ event_type: 'tool_call', runtime: retiredRuntime, rootDir: dir,
150
+ }),
151
+ /unsupported runtime/,
152
+ );
153
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM telemetry').get().n, 0);
154
+ } finally { teardown(dir, db); }
155
+ });
156
+
143
157
  // ─── aggregation ─────────────────────────────────────────────────────────
144
158
 
145
159
  test('aggregateTelemetryByRuntime: groups calls + sums tokens/cost', () => {