ultra-builder-pro-cli 0.6.0 → 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 +62 -11
- package/README.md +26 -18
- package/adapters/_shared/handbook.cjs +7 -7
- package/adapters/_shared/runtime-assets.cjs +2 -0
- package/adapters/_shared/tests/handbook.test.cjs +4 -1
- package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
- package/adapters/opencode.js +81 -20
- package/adapters/tests/codex.test.cjs +5 -3
- package/adapters/tests/opencode.test.cjs +16 -9
- package/bin/install.js +0 -19
- package/bin/orchestrator.js +1 -26
- 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 +31 -23
- package/docs/ARCHITECTURE.md +46 -17
- package/docs/PLAN.zh-CN.md +35 -29
- package/docs/ROADMAP.md +14 -14
- package/docs/RUNTIME-COMPAT-MATRIX.md +11 -11
- 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/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 +17 -3
- 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 +123 -1
- package/mcp-server/tests/state-db.test.cjs +44 -7
- package/mcp-server/tests/state-ops.test.cjs +19 -0
- package/orchestrator/daemon.cjs +39 -0
- package/orchestrator/tests/daemon.test.cjs +50 -0
- package/package.json +3 -3
- 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 -5
- 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/spec/cli-protocol.md +12 -1
- package/spec/mcp-tools.yaml +337 -1
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +117 -0
- 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/rtk-detect.cjs +0 -86
- package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
- package/docs/LEGACY-HERMES.md +0 -1099
- package/orchestrator/code-graph-watcher.cjs +0 -121
- package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
|
@@ -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');
|
|
@@ -748,6 +869,7 @@ test('plan.export → plan.get round trip: artifact on disk + retrievable', asyn
|
|
|
748
869
|
const expData = readToolPayload(exp);
|
|
749
870
|
assert.equal(expData.wave_count, 2);
|
|
750
871
|
assert.ok(fs.existsSync(expData.plan_path), 'artifact file must exist');
|
|
872
|
+
assert.equal(exp._meta.ultra.projection_status, 'completed');
|
|
751
873
|
|
|
752
874
|
const got = await client.callTool({ name: 'plan.get', arguments: { section: 'topo' } });
|
|
753
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 {
|
package/orchestrator/daemon.cjs
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
// calling runDaemon — the runtime layer itself has no opt-in concept.
|
|
13
13
|
|
|
14
14
|
const ops = require('../mcp-server/lib/state-ops.cjs');
|
|
15
|
+
const runtimeState = require('../mcp-server/lib/runtime-state.cjs');
|
|
16
|
+
const projector = require('../mcp-server/lib/projector.cjs');
|
|
15
17
|
const runner = require('./session-runner.cjs');
|
|
18
|
+
const recovery = require('./recovery.cjs');
|
|
16
19
|
const { evaluate, DEFAULT_RULES, ROUTE_PREFERENCES } = require('./dispatch-rules.cjs');
|
|
17
20
|
|
|
18
21
|
// Phase 8B.1 — routeTask is now a thin wrapper over evaluate() so Phase 5.4
|
|
@@ -29,6 +32,19 @@ function routeTask(task, availableRuntimes) {
|
|
|
29
32
|
return d.action === 'spawn_agent' ? d.runtime : null;
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
function maintainState({ db, repoRoot, project = projector.projectAll } = {}) {
|
|
36
|
+
if (!db) throw new Error('maintainState: db required');
|
|
37
|
+
if (!repoRoot) throw new Error('maintainState: repoRoot required');
|
|
38
|
+
const cursor = runtimeState.readConsumerCursor(db, 'spec-staleness');
|
|
39
|
+
const staleness = ops.consumeSpecChangedEvents(db, { since_id: cursor, limit: 500 });
|
|
40
|
+
runtimeState.writeConsumerCursor(db, 'spec-staleness', staleness.next_since_id);
|
|
41
|
+
const ensured = runtimeState.ensureProjectionJob(db, { tool_name: 'orchestrator.maintenance' });
|
|
42
|
+
const projections = runtimeState.processProjectionJobs(db, {
|
|
43
|
+
rootDir: repoRoot, project, limit: 500,
|
|
44
|
+
});
|
|
45
|
+
return { staleness, ensured, projections };
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
function runDaemon({
|
|
33
49
|
db,
|
|
34
50
|
repoRoot,
|
|
@@ -47,9 +63,30 @@ function runDaemon({
|
|
|
47
63
|
|
|
48
64
|
let stopped = false;
|
|
49
65
|
const children = [];
|
|
66
|
+
let bootRecovery;
|
|
67
|
+
try {
|
|
68
|
+
bootRecovery = recovery.recoverOnBoot(db);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
runtimeState.recordIncident(db, {
|
|
71
|
+
code: 'BOOT_RECOVERY_FAILED', severity: 'critical', retryable: true,
|
|
72
|
+
message: error.message, source_kind: 'orchestrator', source_id: 'boot',
|
|
73
|
+
});
|
|
74
|
+
if (onError) onError(error); else throw error;
|
|
75
|
+
bootRecovery = { recovered: [], count: 0, error: error.message };
|
|
76
|
+
}
|
|
50
77
|
|
|
51
78
|
function tick() {
|
|
52
79
|
if (stopped) return;
|
|
80
|
+
try {
|
|
81
|
+
maintainState({ db, repoRoot });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
runtimeState.recordIncident(db, {
|
|
84
|
+
code: 'RUNTIME_MAINTENANCE_FAILED', severity: 'error', retryable: true,
|
|
85
|
+
message: error.message, source_kind: 'orchestrator', source_id: 'maintenance',
|
|
86
|
+
});
|
|
87
|
+
if (onError) onError(error); else throw error;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
53
90
|
let pending;
|
|
54
91
|
try {
|
|
55
92
|
const filter = { status: 'pending' };
|
|
@@ -102,6 +139,7 @@ function runDaemon({
|
|
|
102
139
|
},
|
|
103
140
|
get running() { return !stopped; },
|
|
104
141
|
get children() { return children.slice(); },
|
|
142
|
+
get bootRecovery() { return bootRecovery; },
|
|
105
143
|
};
|
|
106
144
|
}
|
|
107
145
|
|
|
@@ -109,4 +147,5 @@ module.exports = {
|
|
|
109
147
|
runDaemon,
|
|
110
148
|
routeTask,
|
|
111
149
|
ROUTE_PREFERENCES,
|
|
150
|
+
maintainState,
|
|
112
151
|
};
|
|
@@ -259,3 +259,53 @@ test('runDaemon applies complexity_hint route', async () => {
|
|
|
259
259
|
cleanup(repoRoot, db, handle);
|
|
260
260
|
}
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
test('maintainState consumes spec changes with a durable cursor', () => {
|
|
264
|
+
const repoRoot = mkRepo();
|
|
265
|
+
const db = mkDb(repoRoot);
|
|
266
|
+
try {
|
|
267
|
+
ops.createTask(db, {
|
|
268
|
+
id: 'maintain-stale', title: 'stale target', type: 'feature', priority: 'P1',
|
|
269
|
+
trace_to: '.ultra/specs/product.md#daily-change',
|
|
270
|
+
});
|
|
271
|
+
ops.appendEvent(db, {
|
|
272
|
+
type: 'spec_changed', payload: { sections: ['.ultra/specs/product.md#daily-change'] },
|
|
273
|
+
});
|
|
274
|
+
const first = daemon.maintainState({ db, repoRoot });
|
|
275
|
+
assert.equal(first.staleness.processed, 1);
|
|
276
|
+
assert.equal(ops.readTask(db, 'maintain-stale').stale, true);
|
|
277
|
+
const second = daemon.maintainState({ db, repoRoot });
|
|
278
|
+
assert.equal(second.staleness.processed, 0);
|
|
279
|
+
} finally {
|
|
280
|
+
cleanup(repoRoot, db);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('runDaemon performs crash recovery before dispatch polling', async () => {
|
|
285
|
+
const repoRoot = mkRepo();
|
|
286
|
+
const db = mkDb(repoRoot);
|
|
287
|
+
let handle;
|
|
288
|
+
try {
|
|
289
|
+
ops.createTask(db, { id: 'recover-boot', title: 'recover boot', type: 'bugfix', priority: 'P0' });
|
|
290
|
+
ops.updateTaskStatus(db, 'recover-boot', 'in_progress');
|
|
291
|
+
ops.createSession(db, {
|
|
292
|
+
sid: 'dead-session', task_id: 'recover-boot', runtime: 'claude', pid: 999999,
|
|
293
|
+
worktree_path: '/tmp/dead-worktree', artifact_dir: '/tmp/dead-artifact', lease_seconds: 1,
|
|
294
|
+
});
|
|
295
|
+
db.prepare(
|
|
296
|
+
"UPDATE sessions SET heartbeat_at = '2000-01-01T00:00:00.000Z', lease_expires_at = '2000-01-01T00:00:00.000Z' WHERE sid = 'dead-session'",
|
|
297
|
+
).run();
|
|
298
|
+
|
|
299
|
+
handle = daemon.runDaemon({
|
|
300
|
+
db, repoRoot, runtimes: ['claude'], pollMs: 50,
|
|
301
|
+
command: LONG_SLEEP_CMD, commandArgs: LONG_SLEEP_ARGS,
|
|
302
|
+
});
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
304
|
+
const session = ops.readSession(db, 'dead-session');
|
|
305
|
+
assert.equal(session.status, 'crashed');
|
|
306
|
+
const failure = db.prepare("SELECT * FROM circuit_breaker WHERE task_id = 'recover-boot'").get();
|
|
307
|
+
assert.equal(failure.failure_count, 1);
|
|
308
|
+
} finally {
|
|
309
|
+
cleanup(repoRoot, db, handle);
|
|
310
|
+
}
|
|
311
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra-builder-pro-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Multi-runtime installer for the Ultra Builder Pro agent engineering system — deploys commands, agents, skills, hooks, and MCP server to Claude Code, OpenCode, and Codex CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ultra-builder-pro-cli": "bin/install.js",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"skills/ultra-deliver",
|
|
28
28
|
"skills/ultra-status",
|
|
29
29
|
"skills/ultra-think",
|
|
30
|
+
"skills/ultra-change",
|
|
31
|
+
"skills/ultra-doctor",
|
|
30
32
|
"skills/code-review-expert",
|
|
31
33
|
"skills/security-rules",
|
|
32
34
|
"skills/integration-rules",
|
|
@@ -49,7 +51,6 @@
|
|
|
49
51
|
"docs/ARCHITECTURE.md",
|
|
50
52
|
"docs/AGENT-CONTEXT.md",
|
|
51
53
|
"docs/COMMIT-HASH-BACKFILL.md",
|
|
52
|
-
"docs/LEGACY-HERMES.md",
|
|
53
54
|
"docs/PLAN.zh-CN.md",
|
|
54
55
|
"docs/STATE-DB-ACCESS-POLICY.md",
|
|
55
56
|
"docs/USER-HANDBOOK-CONTRACT.md",
|
|
@@ -100,7 +101,6 @@
|
|
|
100
101
|
"ajv": "^8.17.1",
|
|
101
102
|
"ajv-formats": "^3.0.1",
|
|
102
103
|
"better-sqlite3": "^11.5.0",
|
|
103
|
-
"chokidar": "^3.6.0",
|
|
104
104
|
"js-yaml": "^4.1.0",
|
|
105
105
|
"openai": "^6.34.0"
|
|
106
106
|
}
|
package/settings.json
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ultra-change
|
|
3
|
+
description: "Start or resume a continuous post-delivery change, compile bounded context, declare documentation impact, and route the change through implementation and convergence."
|
|
4
|
+
runtime: all
|
|
5
|
+
mcp_tools_required:
|
|
6
|
+
- change.create
|
|
7
|
+
- change.update
|
|
8
|
+
- change.get
|
|
9
|
+
- change.list
|
|
10
|
+
- change.context
|
|
11
|
+
- task.create
|
|
12
|
+
- task.list
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# ultra-change — Continuous Change Entry
|
|
16
|
+
|
|
17
|
+
Use this workflow for daily fixes, small features, redesigns, and incidents after
|
|
18
|
+
the initial `init → research → plan → dev → test → deliver` baseline exists.
|
|
19
|
+
Each piece of work becomes one durable change unit instead of an informal edit
|
|
20
|
+
that drifts away from specifications and verification evidence.
|
|
21
|
+
|
|
22
|
+
## Authority and provider boundary
|
|
23
|
+
|
|
24
|
+
- `.ultra/state.db` is authoritative for the change, its tasks, events, incidents,
|
|
25
|
+
context snapshots, and projection status.
|
|
26
|
+
- `.ultra/changes/active/<change-id>/` contains inspectable artifacts, not a
|
|
27
|
+
second state authority.
|
|
28
|
+
- Memory and code graph are external providers. Pass only metadata references:
|
|
29
|
+
`provider`, `project`, `reference`, `revision`, `indexed_head`, and `status`.
|
|
30
|
+
- Never paste recalled memories, graph nodes, embeddings, summaries, or provider
|
|
31
|
+
payloads into `provider_refs`. If provider data is unavailable, record
|
|
32
|
+
`status: unavailable` and continue with current-checkout evidence.
|
|
33
|
+
- If any required change/task MCP operation is unavailable, stop. Do not edit
|
|
34
|
+
`state.db`, `tasks.json`, or context projections directly.
|
|
35
|
+
|
|
36
|
+
## Arguments
|
|
37
|
+
|
|
38
|
+
- `$1`: existing change id, or a short title for a new change.
|
|
39
|
+
- Optional flags/concepts: `quick`, `standard`, `major`, `incident` and an
|
|
40
|
+
explicit documentation impact.
|
|
41
|
+
|
|
42
|
+
## 1. Resume before creating
|
|
43
|
+
|
|
44
|
+
Call `change.list` for `active` and `blocked` changes. If `$1` matches an
|
|
45
|
+
existing id, call `change.get` and resume it. If the request clearly belongs to
|
|
46
|
+
an active change, show the match and continue there rather than creating a
|
|
47
|
+
duplicate.
|
|
48
|
+
|
|
49
|
+
Do not resume `ready`, `archived`, or `cancelled` work as a new mutation. Create
|
|
50
|
+
a follow-up change that links to the prior archive in its intent.
|
|
51
|
+
|
|
52
|
+
## 2. Classify the change
|
|
53
|
+
|
|
54
|
+
Choose the smallest honest kind:
|
|
55
|
+
|
|
56
|
+
| Kind | Use when | Required convergence evidence |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `quick` | localized fix or tiny behavior change | diff, tests, spec |
|
|
59
|
+
| `standard` | normal feature or cross-file change | diff, tests, spec, docs, review |
|
|
60
|
+
| `major` | architecture, public contract, migration, or broad redesign | diff, tests, spec, docs, review |
|
|
61
|
+
| `incident` | production/runtime failure requiring diagnosis | diagnosis, diff, tests |
|
|
62
|
+
|
|
63
|
+
When uncertain between two kinds, choose the higher-risk kind. Classification
|
|
64
|
+
changes the proof required; it must not be used to bypass evidence.
|
|
65
|
+
|
|
66
|
+
## 3. Declare intent and documentation impact
|
|
67
|
+
|
|
68
|
+
Before implementation, write a concise intent with:
|
|
69
|
+
|
|
70
|
+
- observed problem or requested outcome;
|
|
71
|
+
- acceptance behavior and non-goals;
|
|
72
|
+
- affected contract/surface;
|
|
73
|
+
- recovery or rollback concern when material.
|
|
74
|
+
|
|
75
|
+
Documentation impact is mandatory:
|
|
76
|
+
|
|
77
|
+
- `required`: list every baseline document expected to change;
|
|
78
|
+
- `none`: provide a concrete rationale;
|
|
79
|
+
- `unknown`: allowed only while investigating and blocks convergence.
|
|
80
|
+
|
|
81
|
+
Create the change with `change.create`. Use a stable kebab-case id. External
|
|
82
|
+
provider references are optional and metadata-only.
|
|
83
|
+
|
|
84
|
+
## 4. Build the delta packet
|
|
85
|
+
|
|
86
|
+
The change directory is the bounded execution packet:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
.ultra/changes/active/<change-id>/
|
|
90
|
+
intent.md
|
|
91
|
+
context-manifest.json
|
|
92
|
+
delta/ # required for standard and major
|
|
93
|
+
plan.md # required for standard and major
|
|
94
|
+
verification.md # generated by convergence
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For `standard` and `major`, write delta documents under `delta/` describing
|
|
98
|
+
what changes relative to the current baseline. Do not copy the whole baseline.
|
|
99
|
+
Write `plan.md` with live entry points, files/contracts, tests, documentation,
|
|
100
|
+
and rollback. For `quick` and `incident`, add only artifacts needed to make the
|
|
101
|
+
decision and evidence inspectable.
|
|
102
|
+
|
|
103
|
+
Create at least one task through `task.create` with `change_id` set. Split only
|
|
104
|
+
when ownership, dependencies, or independent verification justify it. All task
|
|
105
|
+
status changes remain MCP-authoritative.
|
|
106
|
+
|
|
107
|
+
## 5. Compile task context
|
|
108
|
+
|
|
109
|
+
Call `change.context` before implementation and again whenever any of these
|
|
110
|
+
changes: git HEAD, task set, relevant specs, allowed paths, or provider revision.
|
|
111
|
+
Supply only the minimum task/spec/path references needed for the current slice.
|
|
112
|
+
|
|
113
|
+
Inspect `context-manifest.json` before handing work to an agent. A stale git head
|
|
114
|
+
or task set is a hard signal to recompile. Provider `status: stale` is visible
|
|
115
|
+
context, not permission to silently refresh or mutate the external provider.
|
|
116
|
+
|
|
117
|
+
## 6. Route execution
|
|
118
|
+
|
|
119
|
+
- Implement one linked task with `/ultra-dev <task-id>`.
|
|
120
|
+
- Run `/ultra-test` for project-level evidence.
|
|
121
|
+
- Run `/ultra-review` when the change kind requires review.
|
|
122
|
+
- Update baseline docs declared in `docs_impact` during the change, not after
|
|
123
|
+
memory has gone cold.
|
|
124
|
+
- Finish with `/ultra-deliver`; it runs deterministic change convergence and
|
|
125
|
+
archive reconciliation before release actions.
|
|
126
|
+
|
|
127
|
+
If work exposes a new requirement, update the change intent/delta and recompile
|
|
128
|
+
context before continuing. Never let code become the only record of the change.
|
|
129
|
+
|
|
130
|
+
## 7. Report
|
|
131
|
+
|
|
132
|
+
Return the change id, kind, artifact root, linked tasks, documentation impact,
|
|
133
|
+
context freshness, provider-reference status, and exact next workflow. Separate
|
|
134
|
+
provider unavailability from an Ultra runtime incident.
|
|
135
|
+
|
|
136
|
+
## What this skill does not do
|
|
137
|
+
|
|
138
|
+
- It does not implement, test, review, release, or repair runtime state.
|
|
139
|
+
- It does not own or synchronize Memory/code-graph content.
|
|
140
|
+
- It does not bypass user approval for destructive or external release actions.
|