ultra-builder-pro-cli 0.6.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.ultra-template/changes/active/.gitkeep +1 -0
  2. package/.ultra-template/changes/archive/.gitkeep +1 -0
  3. package/CHANGELOG.md +90 -11
  4. package/README.md +39 -19
  5. package/adapters/_shared/handbook.cjs +8 -7
  6. package/adapters/_shared/provenance.cjs +286 -0
  7. package/adapters/_shared/runtime-assets.cjs +2 -0
  8. package/adapters/_shared/tests/handbook.test.cjs +4 -1
  9. package/adapters/_shared/tests/provenance.test.cjs +133 -0
  10. package/adapters/_shared/tests/runtime-assets.test.cjs +2 -0
  11. package/adapters/claude.js +33 -0
  12. package/adapters/codex.js +87 -0
  13. package/adapters/opencode.js +147 -20
  14. package/adapters/tests/codex.test.cjs +42 -3
  15. package/adapters/tests/opencode.test.cjs +16 -9
  16. package/agents/debugger.md +12 -0
  17. package/bin/install.js +63 -27
  18. package/bin/orchestrator.js +1 -26
  19. package/commands/ultra-change.md +48 -0
  20. package/commands/ultra-deliver.md +14 -7
  21. package/commands/ultra-dev.md +6 -3
  22. package/commands/ultra-doctor.md +36 -0
  23. package/commands/ultra-init.md +4 -3
  24. package/commands/ultra-status.md +7 -5
  25. package/commands/ultra-test.md +2 -1
  26. package/docs/AGENT-CONTEXT.md +31 -23
  27. package/docs/ARCHITECTURE.md +80 -17
  28. package/docs/PLAN.zh-CN.md +37 -29
  29. package/docs/ROADMAP.md +14 -14
  30. package/docs/RUNTIME-COMPAT-MATRIX.md +19 -12
  31. package/hooks/README.md +14 -9
  32. package/hooks/active_task_context.py +22 -15
  33. package/hooks/health_check.py +136 -38
  34. package/hooks/workflow_checkpoint.py +15 -1
  35. package/hooks/workflow_context.py +120 -30
  36. package/hooks/workflow_resume.py +96 -7
  37. package/mcp-server/lib/change-workflow.cjs +625 -0
  38. package/mcp-server/lib/change-workflow.test.cjs +346 -0
  39. package/mcp-server/lib/doctor.cjs +112 -0
  40. package/mcp-server/lib/doctor.test.cjs +86 -0
  41. package/mcp-server/lib/init-project.cjs +32 -6
  42. package/mcp-server/lib/runtime-state.cjs +222 -0
  43. package/mcp-server/lib/runtime-state.test.cjs +92 -0
  44. package/mcp-server/lib/state-db.cjs +31 -8
  45. package/mcp-server/lib/state-ops.cjs +17 -3
  46. package/mcp-server/server.cjs +102 -10
  47. package/mcp-server/tests/db-ops.test.cjs +5 -1
  48. package/mcp-server/tests/init-project.test.cjs +45 -0
  49. package/mcp-server/tests/server-contract.test.cjs +123 -1
  50. package/mcp-server/tests/state-db.test.cjs +44 -7
  51. package/mcp-server/tests/state-ops.test.cjs +19 -0
  52. package/orchestrator/daemon.cjs +39 -0
  53. package/orchestrator/tests/daemon.test.cjs +50 -0
  54. package/package.json +3 -3
  55. package/settings.json +1 -2
  56. package/skills/ultra-change/SKILL.md +149 -0
  57. package/skills/ultra-deliver/SKILL.md +83 -15
  58. package/skills/ultra-dev/SKILL.md +32 -12
  59. package/skills/ultra-doctor/SKILL.md +97 -0
  60. package/skills/ultra-init/SKILL.md +8 -5
  61. package/skills/ultra-review/SKILL.md +16 -0
  62. package/skills/ultra-status/SKILL.md +30 -7
  63. package/skills/ultra-test/SKILL.md +14 -2
  64. package/spec/cli-protocol.md +12 -1
  65. package/spec/mcp-tools.yaml +337 -1
  66. package/spec/schemas/mcp-tools.schema.json +1 -1
  67. package/spec/schemas/state-db.sql +117 -0
  68. package/ultra-tools/cli.cjs +3 -0
  69. package/ultra-tools/commands/system.cjs +57 -0
  70. package/ultra-tools/system-command.test.cjs +53 -0
  71. package/adapters/_shared/rtk-detect.cjs +0 -86
  72. package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
  73. package/docs/LEGACY-HERMES.md +0 -1099
  74. package/orchestrator/code-graph-watcher.cjs +0 -121
  75. package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ const { randomUUID } = require('node:crypto');
4
+
5
+ const ops = require('./state-ops.cjs');
6
+ const defaultProjector = require('./projector.cjs');
7
+
8
+ class RuntimeStateError extends Error {
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.name = 'RuntimeStateError';
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ function nowIso() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function parseJson(value) {
21
+ if (value == null) return null;
22
+ try { return JSON.parse(value); }
23
+ catch (error) {
24
+ throw new RuntimeStateError('STATE_CORRUPT', `invalid runtime JSON: ${error.message}`);
25
+ }
26
+ }
27
+
28
+ function rowToIncident(row) {
29
+ if (!row) return null;
30
+ return {
31
+ ...row,
32
+ retryable: Boolean(row.retryable),
33
+ evidence: parseJson(row.evidence_json),
34
+ };
35
+ }
36
+
37
+ function recordIncident(db, input) {
38
+ if (!input || !input.code || !input.message) {
39
+ throw new RuntimeStateError('VALIDATION_ERROR', 'incident code and message required');
40
+ }
41
+ return ops.tx(db, () => {
42
+ const existing = db.prepare(
43
+ `SELECT * FROM incidents
44
+ WHERE status = 'open' AND code = ?
45
+ AND change_id IS ? AND task_id IS ? AND session_id IS ?
46
+ AND source_kind IS ? AND source_id IS ?
47
+ ORDER BY first_seen_at ASC LIMIT 1`,
48
+ ).get(
49
+ input.code, input.change_id ?? null, input.task_id ?? null, input.session_id ?? null,
50
+ input.source_kind ?? null, input.source_id ?? null,
51
+ );
52
+ const ts = nowIso();
53
+ if (existing) {
54
+ db.prepare(
55
+ `UPDATE incidents SET message = ?, severity = ?, retryable = ?, evidence_json = ?,
56
+ occurrence_count = occurrence_count + 1, last_seen_at = ? WHERE id = ?`,
57
+ ).run(
58
+ input.message, input.severity || 'error', input.retryable ? 1 : 0,
59
+ input.evidence === undefined ? existing.evidence_json : JSON.stringify(input.evidence),
60
+ ts, existing.id,
61
+ );
62
+ return rowToIncident(db.prepare('SELECT * FROM incidents WHERE id = ?').get(existing.id));
63
+ }
64
+ const id = `inc-${randomUUID().slice(0, 12)}`;
65
+ db.prepare(
66
+ `INSERT INTO incidents
67
+ (id, code, severity, retryable, message, change_id, task_id, session_id,
68
+ source_kind, source_id, evidence_json, first_seen_at, last_seen_at)
69
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
70
+ ).run(
71
+ id, input.code, input.severity || 'error', input.retryable ? 1 : 0, input.message,
72
+ input.change_id ?? null, input.task_id ?? null, input.session_id ?? null,
73
+ input.source_kind ?? null, input.source_id ?? null,
74
+ input.evidence === undefined ? null : JSON.stringify(input.evidence), ts, ts,
75
+ );
76
+ return rowToIncident(db.prepare('SELECT * FROM incidents WHERE id = ?').get(id));
77
+ });
78
+ }
79
+
80
+ function resolveIncident(db, id, resolution) {
81
+ const result = db.prepare(
82
+ `UPDATE incidents SET status = 'resolved', resolved_at = ?, resolution = ?
83
+ WHERE id = ? AND status = 'open'`,
84
+ ).run(nowIso(), resolution || 'resolved', id);
85
+ return { resolved: result.changes > 0 };
86
+ }
87
+
88
+ const LIST_INCIDENTS_SQL = "SELECT * FROM incidents WHERE (@status IS NULL OR status = @status) AND (@change_id IS NULL OR change_id = @change_id) AND (@task_id IS NULL OR task_id = @task_id) ORDER BY last_seen_at DESC LIMIT @maxn";
89
+
90
+ function listIncidents(db, { status = null, change_id = null, task_id = null, limit = 100 } = {}) {
91
+ return db.prepare(LIST_INCIDENTS_SQL).all({
92
+ status, change_id, task_id, maxn: Math.min(Math.max(limit, 1), 500),
93
+ }).map(rowToIncident);
94
+ }
95
+
96
+ function enqueueProjection(db, { tool_name, event_cursor, max_attempts = 3 } = {}) {
97
+ if (!tool_name) throw new RuntimeStateError('VALIDATION_ERROR', 'projection tool_name required');
98
+ const cursor = Number.isInteger(event_cursor)
99
+ ? event_cursor
100
+ : Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM events').get().id);
101
+ const result = db.prepare(
102
+ `INSERT INTO projection_jobs (tool_name, event_cursor, max_attempts)
103
+ VALUES (?, ?, ?)`,
104
+ ).run(tool_name, cursor, max_attempts);
105
+ return readProjectionJob(db, Number(result.lastInsertRowid));
106
+ }
107
+
108
+ function readProjectionJob(db, id) {
109
+ return db.prepare('SELECT * FROM projection_jobs WHERE id = ?').get(id) || null;
110
+ }
111
+
112
+ const LIST_PROJECTION_JOBS_SQL = "SELECT * FROM projection_jobs WHERE (@status IS NULL OR status = @status) ORDER BY id ASC LIMIT @maxn";
113
+
114
+ function listProjectionJobs(db, { status = null, limit = 100 } = {}) {
115
+ return db.prepare(LIST_PROJECTION_JOBS_SQL).all({
116
+ status, maxn: Math.min(Math.max(limit, 1), 500),
117
+ });
118
+ }
119
+
120
+ function processProjectionJobs(db, {
121
+ rootDir = '.', project = defaultProjector.projectAll, limit = 10,
122
+ } = {}) {
123
+ const pending = listProjectionJobs(db, { status: 'pending', limit });
124
+ const jobs = [];
125
+ for (const row of pending) {
126
+ const claimed = db.prepare(
127
+ "UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ? AND status = 'pending'",
128
+ ).run(nowIso(), row.id);
129
+ if (claimed.changes === 0) continue;
130
+ const active = readProjectionJob(db, row.id);
131
+ try {
132
+ const projection = project(db, { rootDir });
133
+ const ts = nowIso();
134
+ db.prepare(
135
+ `UPDATE projection_jobs SET status = 'completed', attempts = attempts + 1,
136
+ last_error = NULL, updated_at = ?, completed_at = ? WHERE id = ?`,
137
+ ).run(ts, ts, row.id);
138
+ const related = db.prepare(
139
+ "SELECT id FROM incidents WHERE status = 'open' AND source_kind = 'projection_job' AND source_id = ?",
140
+ ).all(String(row.id));
141
+ for (const incident of related) resolveIncident(db, incident.id, 'projection completed on retry');
142
+ jobs.push({ id: row.id, status: 'completed', projection });
143
+ } catch (error) {
144
+ const attempts = active.attempts + 1;
145
+ const status = attempts >= active.max_attempts ? 'failed' : 'pending';
146
+ db.prepare(
147
+ `UPDATE projection_jobs SET status = ?, attempts = ?, last_error = ?, updated_at = ?
148
+ WHERE id = ?`,
149
+ ).run(status, attempts, error.message, nowIso(), row.id);
150
+ const incident = recordIncident(db, {
151
+ code: 'PROJECTION_FAILED', severity: 'error', retryable: true,
152
+ message: error.message, source_kind: 'projection_job', source_id: String(row.id),
153
+ evidence: { tool_name: row.tool_name, event_cursor: row.event_cursor, attempts },
154
+ });
155
+ jobs.push({ id: row.id, status: status === 'pending' ? 'retrying' : 'failed', error: error.message, incident_id: incident.id });
156
+ }
157
+ }
158
+ return { processed: jobs.length, jobs };
159
+ }
160
+
161
+ function requeueInterruptedProjections(db, { staleBefore } = {}) {
162
+ const cutoff = staleBefore || new Date(Date.now() - 5 * 60 * 1000).toISOString();
163
+ if (typeof cutoff !== 'string' || Number.isNaN(Date.parse(cutoff))) {
164
+ throw new RuntimeStateError('VALIDATION_ERROR', 'staleBefore must be an ISO date-time');
165
+ }
166
+ const result = db.prepare(
167
+ `UPDATE projection_jobs
168
+ SET status = 'pending', last_error = 'interrupted projection requeued by doctor',
169
+ updated_at = ?, completed_at = NULL
170
+ WHERE status = 'running' AND updated_at < ?`,
171
+ ).run(nowIso(), cutoff);
172
+ return { requeued: result.changes, stale_before: cutoff };
173
+ }
174
+
175
+ function requeueFailedProjections(db) {
176
+ const result = db.prepare(
177
+ "UPDATE projection_jobs SET status = 'pending', attempts = 0, updated_at = ? WHERE status = 'failed'",
178
+ ).run(nowIso());
179
+ return { requeued: result.changes };
180
+ }
181
+
182
+ function ensureProjectionJob(db, { tool_name = 'runtime.reconcile' } = {}) {
183
+ const eventCursor = Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM events').get().id);
184
+ const projectedCursor = Number(db.prepare(
185
+ "SELECT COALESCE(MAX(event_cursor), 0) AS id FROM projection_jobs WHERE status IN ('pending', 'running', 'completed')",
186
+ ).get().id);
187
+ if (eventCursor <= projectedCursor) return { enqueued: false, event_cursor: eventCursor };
188
+ return { enqueued: true, job: enqueueProjection(db, { tool_name, event_cursor: eventCursor }) };
189
+ }
190
+
191
+ function readConsumerCursor(db, name) {
192
+ const row = db.prepare('SELECT cursor FROM event_consumers WHERE name = ?').get(name);
193
+ return row ? Number(row.cursor) : 0;
194
+ }
195
+
196
+ function writeConsumerCursor(db, name, cursor) {
197
+ if (!name || !Number.isInteger(cursor) || cursor < 0) {
198
+ throw new RuntimeStateError('VALIDATION_ERROR', 'consumer name and non-negative cursor required');
199
+ }
200
+ db.prepare(
201
+ `INSERT INTO event_consumers (name, cursor, updated_at) VALUES (?, ?, ?)
202
+ ON CONFLICT(name) DO UPDATE SET
203
+ cursor = MAX(event_consumers.cursor, excluded.cursor), updated_at = excluded.updated_at`,
204
+ ).run(name, cursor, nowIso());
205
+ return { name, cursor: readConsumerCursor(db, name) };
206
+ }
207
+
208
+ module.exports = {
209
+ RuntimeStateError,
210
+ recordIncident,
211
+ resolveIncident,
212
+ listIncidents,
213
+ enqueueProjection,
214
+ readProjectionJob,
215
+ listProjectionJobs,
216
+ processProjectionJobs,
217
+ requeueInterruptedProjections,
218
+ requeueFailedProjections,
219
+ ensureProjectionJob,
220
+ readConsumerCursor,
221
+ writeConsumerCursor,
222
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const { initStateDb, closeStateDb } = require('./state-db.cjs');
10
+ const runtime = require('./runtime-state.cjs');
11
+
12
+ function fixture() {
13
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-runtime-state-'));
14
+ const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
15
+ return { rootDir, db };
16
+ }
17
+
18
+ function cleanup({ rootDir, db }) {
19
+ closeStateDb(db);
20
+ fs.rmSync(rootDir, { recursive: true, force: true });
21
+ }
22
+
23
+ test('projection jobs complete and retain their event cursor', () => {
24
+ const fx = fixture();
25
+ try {
26
+ const job = runtime.enqueueProjection(fx.db, { tool_name: 'task.create', event_cursor: 7 });
27
+ const result = runtime.processProjectionJobs(fx.db, {
28
+ rootDir: fx.rootDir,
29
+ project: () => ({ tasks_json: { count: 0 }, contexts: [] }),
30
+ });
31
+ assert.equal(result.processed, 1);
32
+ assert.equal(result.jobs[0].status, 'completed');
33
+ const stored = runtime.readProjectionJob(fx.db, job.id);
34
+ assert.equal(stored.status, 'completed');
35
+ assert.equal(stored.event_cursor, 7);
36
+ } finally {
37
+ cleanup(fx);
38
+ }
39
+ });
40
+
41
+ test('projection failures are explicit, retryable incidents rather than swallowed warnings', () => {
42
+ const fx = fixture();
43
+ try {
44
+ runtime.enqueueProjection(fx.db, { tool_name: 'task.update', event_cursor: 4, max_attempts: 1 });
45
+ // Test Double rationale: deterministic projector failure is required; filesystem permission failures vary by host.
46
+ const result = runtime.processProjectionJobs(fx.db, {
47
+ rootDir: fx.rootDir,
48
+ project: () => { throw new Error('projection disk unavailable'); },
49
+ });
50
+ assert.equal(result.jobs[0].status, 'failed');
51
+ assert.match(result.jobs[0].error, /projection disk unavailable/);
52
+ const incidents = runtime.listIncidents(fx.db, { status: 'open' });
53
+ assert.equal(incidents.length, 1);
54
+ assert.equal(incidents[0].code, 'PROJECTION_FAILED');
55
+ assert.equal(incidents[0].retryable, true);
56
+ } finally {
57
+ cleanup(fx);
58
+ }
59
+ });
60
+
61
+ test('event consumer cursors are durable and monotonic', () => {
62
+ const fx = fixture();
63
+ try {
64
+ assert.equal(runtime.readConsumerCursor(fx.db, 'spec-staleness'), 0);
65
+ runtime.writeConsumerCursor(fx.db, 'spec-staleness', 12);
66
+ runtime.writeConsumerCursor(fx.db, 'spec-staleness', 9);
67
+ assert.equal(runtime.readConsumerCursor(fx.db, 'spec-staleness'), 12);
68
+ } finally {
69
+ cleanup(fx);
70
+ }
71
+ });
72
+
73
+ test('interrupted running projections are requeued only after the stale cutoff', () => {
74
+ const fx = fixture();
75
+ try {
76
+ const stale = runtime.enqueueProjection(fx.db, { tool_name: 'task.update', event_cursor: 3 });
77
+ const fresh = runtime.enqueueProjection(fx.db, { tool_name: 'task.create', event_cursor: 4 });
78
+ fx.db.prepare("UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ?")
79
+ .run('2000-01-01T00:00:00.000Z', stale.id);
80
+ fx.db.prepare("UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ?")
81
+ .run('2999-01-01T00:00:00.000Z', fresh.id);
82
+
83
+ const result = runtime.requeueInterruptedProjections(fx.db, {
84
+ staleBefore: '2026-01-01T00:00:00.000Z',
85
+ });
86
+ assert.equal(result.requeued, 1);
87
+ assert.equal(runtime.readProjectionJob(fx.db, stale.id).status, 'pending');
88
+ assert.equal(runtime.readProjectionJob(fx.db, fresh.id).status, 'running');
89
+ } finally {
90
+ cleanup(fx);
91
+ }
92
+ });
@@ -9,7 +9,7 @@ const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
9
9
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
10
10
  : path.resolve(__dirname, '..', '..');
11
11
  const SCHEMA_FILE = path.join(REPO_ROOT, 'spec', 'schemas', 'state-db.sql');
12
- const EXPECTED_VERSION = '8A.2';
12
+ const EXPECTED_VERSION = '9.0';
13
13
 
14
14
  const REQUIRED_TABLES = Object.freeze([
15
15
  'tasks',
@@ -20,6 +20,13 @@ const REQUIRED_TABLES = Object.freeze([
20
20
  'telemetry',
21
21
  'specs_refs',
22
22
  'circuit_breaker',
23
+ 'changes',
24
+ 'artifacts',
25
+ 'context_snapshots',
26
+ 'trace_links',
27
+ 'incidents',
28
+ 'projection_jobs',
29
+ 'event_consumers',
23
30
  ]);
24
31
 
25
32
  function readSchemaSql() {
@@ -64,18 +71,33 @@ function columnNames(db, table) {
64
71
  return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
65
72
  }
66
73
 
67
- function applyCompatibleUpgrades(db) {
74
+ function applyCompatibleColumns(db) {
75
+ const tables = new Set(tableNames(db));
76
+ if (!tables.has('tasks')) return;
68
77
  const taskColumns = columnNames(db, 'tasks');
69
78
  if (!taskColumns.has('estimated_days')) {
70
- db.transaction(() => {
71
- db.exec('ALTER TABLE tasks ADD COLUMN estimated_days REAL CHECK (estimated_days IS NULL OR estimated_days > 0)');
72
- db.prepare(
73
- 'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
74
- ).run(EXPECTED_VERSION, 'Phase 8A.2 — tasks.estimated_days preserved across MCP and v4.4 migration');
75
- })();
79
+ db.exec('ALTER TABLE tasks ADD COLUMN estimated_days REAL CHECK (estimated_days IS NULL OR estimated_days > 0)');
80
+ }
81
+ if (!taskColumns.has('change_id')) {
82
+ db.exec('ALTER TABLE tasks ADD COLUMN change_id TEXT REFERENCES changes(id) ON DELETE SET NULL');
83
+ }
84
+ if (tables.has('events')) {
85
+ const eventColumns = columnNames(db, 'events');
86
+ if (!eventColumns.has('change_id')) db.exec('ALTER TABLE events ADD COLUMN change_id TEXT');
76
87
  }
77
88
  }
78
89
 
90
+ function applyCompatibleUpgrades(db) {
91
+ db.transaction(() => {
92
+ applyCompatibleColumns(db);
93
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
94
+ db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
95
+ db.prepare(
96
+ 'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
97
+ ).run(EXPECTED_VERSION, 'Continuous change units and recoverable runtime state');
98
+ })();
99
+ }
100
+
79
101
  function ensureSchemaVersion(db) {
80
102
  // schema_version is an audit trail — multiple rows across phase upgrades.
81
103
  // Guard by checking whether the expected version row exists rather than
@@ -94,6 +116,7 @@ function ensureSchemaVersion(db) {
94
116
  function initStateDb(dbPath) {
95
117
  const db = openStateDb(dbPath);
96
118
  const existing = new Set(tableNames(db));
119
+ applyCompatibleColumns(db);
97
120
  const missing = REQUIRED_TABLES.filter((t) => !existing.has(t));
98
121
  if (missing.length > 0) {
99
122
  applySchema(db);
@@ -27,6 +27,7 @@ const TASK_FIELDS = Object.freeze([
27
27
  'id', 'title', 'type', 'priority', 'complexity', 'estimated_days', 'status',
28
28
  'deps', 'files_modified', 'session_id', 'stale', 'complexity_hint',
29
29
  'tag', 'trace_to', 'context_file', 'completion_commit', 'parent_id',
30
+ 'change_id',
30
31
  'created_at', 'updated_at',
31
32
  ]);
32
33
 
@@ -34,6 +35,7 @@ const PATCHABLE_FIELDS = Object.freeze([
34
35
  'priority', 'complexity', 'estimated_days', 'deps', 'files_modified',
35
36
  'session_id', 'stale', 'complexity_hint', 'tag', 'trace_to',
36
37
  'context_file', 'completion_commit',
38
+ 'change_id',
37
39
  ]);
38
40
 
39
41
  const SESSION_PATCHABLE = Object.freeze([
@@ -153,10 +155,14 @@ function createTask(db, input) {
153
155
  context_file: input.context_file ?? null,
154
156
  completion_commit: null,
155
157
  parent_id: input.parent_id ?? null,
158
+ change_id: input.change_id ?? null,
156
159
  created_at: ts,
157
160
  updated_at: ts,
158
161
  };
159
162
  return tx(db, () => {
163
+ if (row.change_id && !db.prepare('SELECT id FROM changes WHERE id = ?').get(row.change_id)) {
164
+ throw new StateOpsError('CHANGE_NOT_FOUND', `change ${row.change_id} does not exist`);
165
+ }
160
166
  try {
161
167
  db.prepare(
162
168
  `INSERT INTO tasks (${TASK_FIELDS.join(', ')})
@@ -171,6 +177,7 @@ function createTask(db, input) {
171
177
  appendEventInTx(db, {
172
178
  type: 'task_created',
173
179
  task_id: row.id,
180
+ change_id: row.change_id,
174
181
  payload: { priority: row.priority, type: row.type },
175
182
  });
176
183
  return readTask(db, row.id);
@@ -193,6 +200,10 @@ function patchTask(db, id, patch = {}) {
193
200
  throw new StateOpsError('VALIDATION_ERROR', `field ${key} is not patchable`);
194
201
  }
195
202
  let value = patch[key];
203
+ if (key === 'change_id' && value !== null
204
+ && !db.prepare('SELECT id FROM changes WHERE id = ?').get(value)) {
205
+ throw new StateOpsError('CHANGE_NOT_FOUND', `change ${value} does not exist`);
206
+ }
196
207
  if (key === 'deps' || key === 'files_modified') {
197
208
  if (value !== null && !Array.isArray(value)) {
198
209
  throw new StateOpsError('VALIDATION_ERROR', `${key} must be an array`);
@@ -224,6 +235,7 @@ function patchTask(db, id, patch = {}) {
224
235
  appendEventInTx(db, {
225
236
  type: statusEventType(nextStatus),
226
237
  task_id: id,
238
+ change_id: patch.change_id !== undefined ? patch.change_id : current.change_id,
227
239
  payload: { from: current.status, to: nextStatus },
228
240
  });
229
241
  }
@@ -268,11 +280,12 @@ function appendEventInTx(db, event) {
268
280
  throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${event.runtime}`);
269
281
  }
270
282
  const result = db.prepare(
271
- `INSERT INTO events (type, task_id, session_id, runtime, payload_json)
272
- VALUES (?, ?, ?, ?, ?)`,
283
+ `INSERT INTO events (type, task_id, change_id, session_id, runtime, payload_json)
284
+ VALUES (?, ?, ?, ?, ?, ?)`,
273
285
  ).run(
274
286
  event.type,
275
287
  event.task_id ?? null,
288
+ event.change_id ?? null,
276
289
  event.session_id ?? null,
277
290
  event.runtime ?? null,
278
291
  event.payload === undefined ? null : JSON.stringify(event.payload),
@@ -287,7 +300,7 @@ function appendEvent(db, event) {
287
300
 
288
301
  // Single static SQL using json_each for the optional types IN-list and
289
302
  // NULL-pass-through for task_id/session_id. Frozen literal — no concat, no interpolation.
290
- const SUBSCRIBE_EVENTS_SQL = "SELECT id, ts, type, task_id, session_id, runtime, payload_json FROM events WHERE id > @since_id AND (@types_json IS NULL OR EXISTS (SELECT 1 FROM json_each(@types_json) WHERE value = events.type)) AND (@task_id IS NULL OR task_id = @task_id) AND (@session_id IS NULL OR session_id = @session_id) ORDER BY id ASC LIMIT @maxn";
303
+ const SUBSCRIBE_EVENTS_SQL = "SELECT id, ts, type, task_id, change_id, session_id, runtime, payload_json FROM events WHERE id > @since_id AND (@types_json IS NULL OR EXISTS (SELECT 1 FROM json_each(@types_json) WHERE value = events.type)) AND (@task_id IS NULL OR task_id = @task_id) AND (@session_id IS NULL OR session_id = @session_id) ORDER BY id ASC LIMIT @maxn";
291
304
 
292
305
  function subscribeEventsSince(db, { since_id = 0, types, task_id, session_id, limit = 100 } = {}) {
293
306
  const events = db.prepare(SUBSCRIBE_EVENTS_SQL).all({
@@ -704,6 +717,7 @@ module.exports = {
704
717
  deleteTask,
705
718
  // events
706
719
  appendEvent,
720
+ appendEventInTx,
707
721
  subscribeEventsSince,
708
722
  // sessions
709
723
  createSession,
@@ -28,6 +28,9 @@ const parser = require('./lib/prd-parser.cjs');
28
28
  const expander = require('./lib/task-expander.cjs');
29
29
  const planStore = require('./lib/plan-store.cjs');
30
30
  const { initProject } = require('./lib/init-project.cjs');
31
+ const changes = require('./lib/change-workflow.cjs');
32
+ const runtimeState = require('./lib/runtime-state.cjs');
33
+ const doctor = require('./lib/doctor.cjs');
31
34
 
32
35
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
33
36
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
@@ -64,8 +67,22 @@ const PLAN_TOOLS = Object.freeze([
64
67
  'plan.get',
65
68
  ]);
66
69
 
70
+ const CHANGE_TOOLS = Object.freeze([
71
+ 'change.create',
72
+ 'change.update',
73
+ 'change.get',
74
+ 'change.list',
75
+ 'change.context',
76
+ 'change.converge',
77
+ 'change.archive',
78
+ ]);
79
+
80
+ const SYSTEM_TOOLS = Object.freeze([
81
+ 'system.doctor',
82
+ ]);
83
+
67
84
  const REGISTERED_TOOLS = Object.freeze([
68
- ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS,
85
+ ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...CHANGE_TOOLS, ...SYSTEM_TOOLS,
69
86
  ]);
70
87
 
71
88
  const STATELESS_TOOLS = new Set(['task.init_project']);
@@ -76,6 +93,8 @@ const MUTATING_TOOLS = new Set([
76
93
  'task.create', 'task.update', 'task.delete', 'task.append_event', 'task.switch_tag',
77
94
  'task.parse_prd', 'task.expand',
78
95
  'session.spawn', 'session.close', 'session.heartbeat',
96
+ 'plan.export',
97
+ 'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
79
98
  ]);
80
99
 
81
100
  function loadRegisteredTools() {
@@ -145,6 +164,7 @@ async function dispatchTool(name, input, db, ctx = {}) {
145
164
  const r = ops.appendEvent(db, {
146
165
  type: input.type,
147
166
  task_id: input.task_id,
167
+ change_id: input.change_id,
148
168
  session_id: input.session_id,
149
169
  runtime: input.runtime,
150
170
  payload: input.payload,
@@ -328,6 +348,53 @@ async function dispatchTool(name, input, db, ctx = {}) {
328
348
  }
329
349
  return { plan: planStore.selectSection(loaded, input.section) };
330
350
  }
351
+ case 'change.create': {
352
+ const { randomUUID } = require('node:crypto');
353
+ const id = input.id || `chg-${randomUUID().slice(0, 12)}`;
354
+ return changes.createChange(db, { ...input, id }, { rootDir: ctx.rootDir || process.cwd() });
355
+ }
356
+ case 'change.update': {
357
+ return {
358
+ ok: true,
359
+ change: changes.updateChange(
360
+ db, input.id, input.patch || {}, { rootDir: ctx.rootDir || process.cwd() },
361
+ ),
362
+ };
363
+ }
364
+ case 'change.get': {
365
+ const change = changes.readChange(db, input.id);
366
+ if (!change) {
367
+ const err = new Error(`change ${input.id} not found`);
368
+ err.code = 'CHANGE_NOT_FOUND';
369
+ throw err;
370
+ }
371
+ return { change };
372
+ }
373
+ case 'change.list': {
374
+ const rows = changes.listChanges(db, input || {});
375
+ return { changes: rows, count: rows.length };
376
+ }
377
+ case 'change.context': {
378
+ const out = changes.compileContext(db, input, { rootDir: ctx.rootDir || process.cwd() });
379
+ return {
380
+ manifest_path: out.context_manifest_path,
381
+ manifest_hash: out.manifest_hash,
382
+ manifest: out.manifest,
383
+ };
384
+ }
385
+ case 'change.converge': {
386
+ return changes.convergeChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
387
+ }
388
+ case 'change.archive': {
389
+ return changes.archiveChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
390
+ }
391
+ case 'system.doctor': {
392
+ return doctor.runDoctor(db, {
393
+ rootDir: ctx.rootDir || process.cwd(),
394
+ repair: input.repair === true,
395
+ project: ctx.projector || projector.projectAll,
396
+ });
397
+ }
331
398
  default:
332
399
  throw new Error(`unhandled tool ${name}`);
333
400
  }
@@ -345,7 +412,7 @@ function errorResponse(code, message, retriable = false, details = undefined) {
345
412
  };
346
413
  }
347
414
 
348
- function startServer({ dbPath, rootDir, projectOnWrite = true }) {
415
+ function startServer({ dbPath, rootDir, projectOnWrite = true, project = projector.projectAll }) {
349
416
  let db = null;
350
417
  const getDb = () => {
351
418
  if (!db) db = initStateDb(dbPath).db;
@@ -415,7 +482,7 @@ function startServer({ dbPath, rootDir, projectOnWrite = true }) {
415
482
  try {
416
483
  toolDb = STATELESS_TOOLS.has(name) ? null : getDb();
417
484
  if (toolDb) assertStateAuthority(toolDb, rootDir);
418
- result = await dispatchTool(name, args, toolDb, { rootDir });
485
+ result = await dispatchTool(name, args, toolDb, { rootDir, projector: project });
419
486
  } catch (err) {
420
487
  const code = err.code || (err instanceof ops.StateOpsError ? err.code : 'STATE_DB_ERROR');
421
488
  toolError = code;
@@ -423,22 +490,45 @@ function startServer({ dbPath, rootDir, projectOnWrite = true }) {
423
490
  return errorResponse(code, err.message, !!err.retriable, err.details);
424
491
  }
425
492
 
493
+ let runtimeMeta = null;
494
+ if (toolDb && projectOnWrite && MUTATING_TOOLS.has(name)) {
495
+ const job = runtimeState.enqueueProjection(toolDb, { tool_name: name });
496
+ const processed = runtimeState.processProjectionJobs(toolDb, { rootDir, project, limit: 500 });
497
+ const own = processed.jobs.find((item) => item.id === job.id);
498
+ runtimeMeta = {
499
+ state_commit: 'committed',
500
+ projection_status: own ? own.status : 'failed',
501
+ projection_job_id: job.id,
502
+ };
503
+ if (own && own.incident_id) runtimeMeta.incident_id = own.incident_id;
504
+ } else if (toolDb && name === 'system.doctor' && args.repair === true) {
505
+ runtimeMeta = {
506
+ state_commit: 'committed',
507
+ projection_status: result.checks.projections.status === 'pass' ? 'completed' : 'failed',
508
+ backup_path: result.backup_path,
509
+ };
510
+ }
511
+
426
512
  const validateOutput = outputValidators.get(name);
427
513
  if (!validateOutput(result)) {
428
514
  toolError = 'OUTPUT_SCHEMA_DRIFT';
429
515
  emitTelemetry();
430
- return errorResponse('OUTPUT_SCHEMA_DRIFT', ajv.errorsText(validateOutput.errors));
431
- }
432
-
433
- if (toolDb && projectOnWrite && MUTATING_TOOLS.has(name)) {
434
- try { projector.projectAll(toolDb, { rootDir }); }
435
- catch (err) { process.stderr.write(`projector warning: ${err.message}\n`); }
516
+ const response = errorResponse(
517
+ 'OUTPUT_SCHEMA_DRIFT',
518
+ ajv.errorsText(validateOutput.errors),
519
+ false,
520
+ runtimeMeta ? { _ultra: runtimeMeta } : undefined,
521
+ );
522
+ if (runtimeMeta) response._meta = { ultra: runtimeMeta };
523
+ return response;
436
524
  }
437
525
 
438
526
  emitTelemetry();
527
+ const visible = runtimeMeta ? { ...result, _ultra: runtimeMeta } : result;
439
528
  return {
440
- content: [{ type: 'text', text: JSON.stringify(result) }],
529
+ content: [{ type: 'text', text: JSON.stringify(visible) }],
441
530
  structuredContent: result,
531
+ ...(runtimeMeta ? { _meta: { ultra: runtimeMeta } } : {}),
442
532
  };
443
533
  });
444
534
 
@@ -478,6 +568,8 @@ module.exports = {
478
568
  dispatchTool,
479
569
  TASK_TOOLS,
480
570
  SESSION_TOOLS,
571
+ CHANGE_TOOLS,
572
+ SYSTEM_TOOLS,
481
573
  REGISTERED_TOOLS,
482
574
  STATELESS_TOOLS,
483
575
  MUTATING_TOOLS,
@@ -46,7 +46,11 @@ test('db vacuum reports reclaimed bytes after deleting rows', () => {
46
46
  try {
47
47
  const init = initStateDb(dbPath);
48
48
  for (let i = 0; i < 50; i++) {
49
- ops.appendEvent(init.db, { type: 'task_created', task_id: `t-${i}` });
49
+ ops.appendEvent(init.db, {
50
+ type: 'task_created',
51
+ task_id: `t-${i}`,
52
+ payload: { padding: 'x'.repeat(16 * 1024) },
53
+ });
50
54
  }
51
55
  init.db.prepare('DELETE FROM events').run();
52
56
  closeStateDb(init.db);