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.
- package/.ultra-template/changes/active/.gitkeep +1 -0
- package/.ultra-template/changes/archive/.gitkeep +1 -0
- package/CHANGELOG.md +90 -14
- package/README.md +38 -32
- package/adapters/_shared/codex-assets.cjs +32 -151
- package/adapters/_shared/frontmatter.cjs +2 -2
- package/adapters/_shared/handbook.cjs +10 -10
- package/adapters/_shared/path-rewrite.cjs +0 -3
- package/adapters/_shared/runtime-assets.cjs +13 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -1
- package/adapters/_shared/tests/path-rewrite.test.cjs +1 -3
- package/adapters/_shared/tests/resolve-target.test.cjs +1 -11
- package/adapters/_shared/tests/runtime-assets.test.cjs +7 -6
- package/adapters/opencode.js +82 -26
- package/adapters/tests/claude.test.cjs +1 -4
- package/adapters/tests/codex.test.cjs +9 -8
- package/adapters/tests/opencode.test.cjs +17 -11
- package/bin/install.js +6 -28
- package/bin/orchestrator.js +12 -30
- package/commands/ultra-change.md +47 -0
- package/commands/ultra-deliver.md +14 -7
- package/commands/ultra-dev.md +6 -3
- package/commands/ultra-doctor.md +36 -0
- package/commands/ultra-init.md +4 -3
- package/commands/ultra-status.md +7 -5
- package/commands/ultra-test.md +2 -1
- package/docs/AGENT-CONTEXT.md +35 -28
- package/docs/ARCHITECTURE.md +49 -21
- package/docs/PLAN.zh-CN.md +67 -68
- package/docs/ROADMAP.md +17 -17
- package/docs/RUNTIME-COMPAT-MATRIX.md +63 -62
- package/hooks/README.md +8 -7
- package/hooks/active_task_context.py +22 -15
- package/hooks/health_check.py +136 -38
- package/hooks/workflow_context.py +120 -30
- package/mcp-server/lib/change-workflow.cjs +556 -0
- package/mcp-server/lib/change-workflow.test.cjs +259 -0
- package/mcp-server/lib/doctor.cjs +112 -0
- package/mcp-server/lib/doctor.test.cjs +86 -0
- package/mcp-server/lib/init-project.cjs +32 -6
- package/mcp-server/lib/pricing.cjs +0 -6
- package/mcp-server/lib/runtime-state.cjs +222 -0
- package/mcp-server/lib/runtime-state.test.cjs +92 -0
- package/mcp-server/lib/state-db.cjs +31 -8
- package/mcp-server/lib/state-ops.cjs +24 -3
- package/mcp-server/lib/telemetry.cjs +4 -0
- package/mcp-server/server.cjs +102 -10
- package/mcp-server/tests/db-ops.test.cjs +5 -1
- package/mcp-server/tests/init-project.test.cjs +45 -0
- package/mcp-server/tests/server-contract.test.cjs +140 -1
- package/mcp-server/tests/state-db.test.cjs +44 -7
- package/mcp-server/tests/state-ops.test.cjs +42 -0
- package/mcp-server/tests/telemetry.test.cjs +14 -0
- package/orchestrator/daemon.cjs +39 -0
- package/orchestrator/dispatch-rules.cjs +15 -4
- package/orchestrator/tests/daemon.test.cjs +63 -12
- package/orchestrator/tests/dispatch-rules.test.cjs +12 -10
- package/package.json +5 -7
- package/settings.json +1 -2
- package/skills/ultra-change/SKILL.md +140 -0
- package/skills/ultra-deliver/SKILL.md +81 -15
- package/skills/ultra-dev/SKILL.md +32 -12
- package/skills/ultra-doctor/SKILL.md +90 -0
- package/skills/ultra-init/SKILL.md +8 -6
- package/skills/ultra-review/SKILL.md +16 -0
- package/skills/ultra-status/SKILL.md +30 -7
- package/skills/ultra-test/SKILL.md +14 -2
- package/skills/ultra-verify/SKILL.md +22 -41
- package/skills/ultra-verify/evals/evals.json +22 -24
- package/skills/ultra-verify/references/confidence-system.md +15 -32
- package/skills/ultra-verify/references/cross-verify-modes.md +14 -59
- package/skills/ultra-verify/scripts/verify_wait.py +44 -81
- package/spec/cli-protocol.md +12 -1
- package/spec/mcp-tools.yaml +340 -4
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/skill-manifest.schema.json +2 -2
- package/spec/schemas/state-db.sql +119 -2
- package/ultra-tools/cli.cjs +3 -0
- package/ultra-tools/commands/system.cjs +57 -0
- package/ultra-tools/system-command.test.cjs +53 -0
- package/adapters/_shared/md-to-toml.cjs +0 -54
- package/adapters/_shared/rtk-detect.cjs +0 -86
- package/adapters/_shared/tests/md-to-toml.test.cjs +0 -48
- package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
- package/adapters/gemini.js +0 -204
- package/adapters/tests/gemini.test.cjs +0 -112
- package/docs/LEGACY-HERMES.md +0 -1175
- package/orchestrator/code-graph-watcher.cjs +0 -121
- package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
- package/skills/gemini-collab/SKILL.md +0 -51
- package/skills/gemini-collab/evals/evals.json +0 -42
package/mcp-server/server.cjs
CHANGED
|
@@ -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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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(
|
|
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, {
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
26
|
-
|
|
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', () => {
|