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.
Files changed (68) hide show
  1. package/.ultra-template/changes/active/.gitkeep +1 -0
  2. package/.ultra-template/changes/archive/.gitkeep +1 -0
  3. package/CHANGELOG.md +62 -11
  4. package/README.md +26 -18
  5. package/adapters/_shared/handbook.cjs +7 -7
  6. package/adapters/_shared/runtime-assets.cjs +2 -0
  7. package/adapters/_shared/tests/handbook.test.cjs +4 -1
  8. package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
  9. package/adapters/opencode.js +81 -20
  10. package/adapters/tests/codex.test.cjs +5 -3
  11. package/adapters/tests/opencode.test.cjs +16 -9
  12. package/bin/install.js +0 -19
  13. package/bin/orchestrator.js +1 -26
  14. package/commands/ultra-change.md +47 -0
  15. package/commands/ultra-deliver.md +14 -7
  16. package/commands/ultra-dev.md +6 -3
  17. package/commands/ultra-doctor.md +36 -0
  18. package/commands/ultra-init.md +4 -3
  19. package/commands/ultra-status.md +7 -5
  20. package/commands/ultra-test.md +2 -1
  21. package/docs/AGENT-CONTEXT.md +31 -23
  22. package/docs/ARCHITECTURE.md +46 -17
  23. package/docs/PLAN.zh-CN.md +35 -29
  24. package/docs/ROADMAP.md +14 -14
  25. package/docs/RUNTIME-COMPAT-MATRIX.md +11 -11
  26. package/hooks/README.md +8 -7
  27. package/hooks/active_task_context.py +22 -15
  28. package/hooks/health_check.py +136 -38
  29. package/hooks/workflow_context.py +120 -30
  30. package/mcp-server/lib/change-workflow.cjs +556 -0
  31. package/mcp-server/lib/change-workflow.test.cjs +259 -0
  32. package/mcp-server/lib/doctor.cjs +112 -0
  33. package/mcp-server/lib/doctor.test.cjs +86 -0
  34. package/mcp-server/lib/init-project.cjs +32 -6
  35. package/mcp-server/lib/runtime-state.cjs +222 -0
  36. package/mcp-server/lib/runtime-state.test.cjs +92 -0
  37. package/mcp-server/lib/state-db.cjs +31 -8
  38. package/mcp-server/lib/state-ops.cjs +17 -3
  39. package/mcp-server/server.cjs +102 -10
  40. package/mcp-server/tests/db-ops.test.cjs +5 -1
  41. package/mcp-server/tests/init-project.test.cjs +45 -0
  42. package/mcp-server/tests/server-contract.test.cjs +123 -1
  43. package/mcp-server/tests/state-db.test.cjs +44 -7
  44. package/mcp-server/tests/state-ops.test.cjs +19 -0
  45. package/orchestrator/daemon.cjs +39 -0
  46. package/orchestrator/tests/daemon.test.cjs +50 -0
  47. package/package.json +3 -3
  48. package/settings.json +1 -2
  49. package/skills/ultra-change/SKILL.md +140 -0
  50. package/skills/ultra-deliver/SKILL.md +81 -15
  51. package/skills/ultra-dev/SKILL.md +32 -12
  52. package/skills/ultra-doctor/SKILL.md +90 -0
  53. package/skills/ultra-init/SKILL.md +8 -5
  54. package/skills/ultra-review/SKILL.md +16 -0
  55. package/skills/ultra-status/SKILL.md +30 -7
  56. package/skills/ultra-test/SKILL.md +14 -2
  57. package/spec/cli-protocol.md +12 -1
  58. package/spec/mcp-tools.yaml +337 -1
  59. package/spec/schemas/mcp-tools.schema.json +1 -1
  60. package/spec/schemas/state-db.sql +117 -0
  61. package/ultra-tools/cli.cjs +3 -0
  62. package/ultra-tools/commands/system.cjs +57 -0
  63. package/ultra-tools/system-command.test.cjs +53 -0
  64. package/adapters/_shared/rtk-detect.cjs +0 -86
  65. package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
  66. package/docs/LEGACY-HERMES.md +0 -1099
  67. package/orchestrator/code-graph-watcher.cjs +0 -121
  68. 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 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 {
@@ -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.6.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
@@ -2,7 +2,6 @@
2
2
  "_comment": "Ultra Builder Pro runtime settings. Host hooks and plugin permissions live in each native plugin manifest.",
3
3
  "cleanupPeriodDays": 30,
4
4
  "orchestrator": {
5
- "auto_dispatch": false,
6
- "graph_watcher": false
5
+ "auto_dispatch": false
7
6
  }
8
7
  }
@@ -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.