ultra-builder-pro-cli 0.5.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.ultra-template/changes/active/.gitkeep +1 -0
  2. package/.ultra-template/changes/archive/.gitkeep +1 -0
  3. package/CHANGELOG.md +90 -14
  4. package/README.md +38 -32
  5. package/adapters/_shared/codex-assets.cjs +32 -151
  6. package/adapters/_shared/frontmatter.cjs +2 -2
  7. package/adapters/_shared/handbook.cjs +10 -10
  8. package/adapters/_shared/path-rewrite.cjs +0 -3
  9. package/adapters/_shared/runtime-assets.cjs +13 -4
  10. package/adapters/_shared/tests/handbook.test.cjs +4 -1
  11. package/adapters/_shared/tests/path-rewrite.test.cjs +1 -3
  12. package/adapters/_shared/tests/resolve-target.test.cjs +1 -11
  13. package/adapters/_shared/tests/runtime-assets.test.cjs +7 -6
  14. package/adapters/opencode.js +82 -26
  15. package/adapters/tests/claude.test.cjs +1 -4
  16. package/adapters/tests/codex.test.cjs +9 -8
  17. package/adapters/tests/opencode.test.cjs +17 -11
  18. package/bin/install.js +6 -28
  19. package/bin/orchestrator.js +12 -30
  20. package/commands/ultra-change.md +47 -0
  21. package/commands/ultra-deliver.md +14 -7
  22. package/commands/ultra-dev.md +6 -3
  23. package/commands/ultra-doctor.md +36 -0
  24. package/commands/ultra-init.md +4 -3
  25. package/commands/ultra-status.md +7 -5
  26. package/commands/ultra-test.md +2 -1
  27. package/docs/AGENT-CONTEXT.md +35 -28
  28. package/docs/ARCHITECTURE.md +49 -21
  29. package/docs/PLAN.zh-CN.md +67 -68
  30. package/docs/ROADMAP.md +17 -17
  31. package/docs/RUNTIME-COMPAT-MATRIX.md +63 -62
  32. package/hooks/README.md +8 -7
  33. package/hooks/active_task_context.py +22 -15
  34. package/hooks/health_check.py +136 -38
  35. package/hooks/workflow_context.py +120 -30
  36. package/mcp-server/lib/change-workflow.cjs +556 -0
  37. package/mcp-server/lib/change-workflow.test.cjs +259 -0
  38. package/mcp-server/lib/doctor.cjs +112 -0
  39. package/mcp-server/lib/doctor.test.cjs +86 -0
  40. package/mcp-server/lib/init-project.cjs +32 -6
  41. package/mcp-server/lib/pricing.cjs +0 -6
  42. package/mcp-server/lib/runtime-state.cjs +222 -0
  43. package/mcp-server/lib/runtime-state.test.cjs +92 -0
  44. package/mcp-server/lib/state-db.cjs +31 -8
  45. package/mcp-server/lib/state-ops.cjs +24 -3
  46. package/mcp-server/lib/telemetry.cjs +4 -0
  47. package/mcp-server/server.cjs +102 -10
  48. package/mcp-server/tests/db-ops.test.cjs +5 -1
  49. package/mcp-server/tests/init-project.test.cjs +45 -0
  50. package/mcp-server/tests/server-contract.test.cjs +140 -1
  51. package/mcp-server/tests/state-db.test.cjs +44 -7
  52. package/mcp-server/tests/state-ops.test.cjs +42 -0
  53. package/mcp-server/tests/telemetry.test.cjs +14 -0
  54. package/orchestrator/daemon.cjs +39 -0
  55. package/orchestrator/dispatch-rules.cjs +15 -4
  56. package/orchestrator/tests/daemon.test.cjs +63 -12
  57. package/orchestrator/tests/dispatch-rules.test.cjs +12 -10
  58. package/package.json +5 -7
  59. package/settings.json +1 -2
  60. package/skills/ultra-change/SKILL.md +140 -0
  61. package/skills/ultra-deliver/SKILL.md +81 -15
  62. package/skills/ultra-dev/SKILL.md +32 -12
  63. package/skills/ultra-doctor/SKILL.md +90 -0
  64. package/skills/ultra-init/SKILL.md +8 -6
  65. package/skills/ultra-review/SKILL.md +16 -0
  66. package/skills/ultra-status/SKILL.md +30 -7
  67. package/skills/ultra-test/SKILL.md +14 -2
  68. package/skills/ultra-verify/SKILL.md +22 -41
  69. package/skills/ultra-verify/evals/evals.json +22 -24
  70. package/skills/ultra-verify/references/confidence-system.md +15 -32
  71. package/skills/ultra-verify/references/cross-verify-modes.md +14 -59
  72. package/skills/ultra-verify/scripts/verify_wait.py +44 -81
  73. package/spec/cli-protocol.md +12 -1
  74. package/spec/mcp-tools.yaml +340 -4
  75. package/spec/schemas/mcp-tools.schema.json +1 -1
  76. package/spec/schemas/skill-manifest.schema.json +2 -2
  77. package/spec/schemas/state-db.sql +119 -2
  78. package/ultra-tools/cli.cjs +3 -0
  79. package/ultra-tools/commands/system.cjs +57 -0
  80. package/ultra-tools/system-command.test.cjs +53 -0
  81. package/adapters/_shared/md-to-toml.cjs +0 -54
  82. package/adapters/_shared/rtk-detect.cjs +0 -86
  83. package/adapters/_shared/tests/md-to-toml.test.cjs +0 -48
  84. package/adapters/_shared/tests/rtk-detect.test.cjs +0 -116
  85. package/adapters/gemini.js +0 -204
  86. package/adapters/tests/gemini.test.cjs +0 -112
  87. package/docs/LEGACY-HERMES.md +0 -1175
  88. package/orchestrator/code-graph-watcher.cjs +0 -121
  89. package/orchestrator/tests/code-graph-watcher.test.cjs +0 -183
  90. package/skills/gemini-collab/SKILL.md +0 -51
  91. package/skills/gemini-collab/evals/evals.json +0 -42
@@ -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);
@@ -13,6 +13,7 @@ const path = require('node:path');
13
13
  const { spawnSync } = require('node:child_process');
14
14
 
15
15
  const { openStateDb } = require('./state-db.cjs');
16
+ const { isSupportedRuntime } = require('../../adapters/_shared/runtime-assets.cjs');
16
17
 
17
18
  const STATUS_TRANSITIONS = Object.freeze({
18
19
  pending: new Set(['in_progress', 'blocked', 'expanded']),
@@ -26,6 +27,7 @@ const TASK_FIELDS = Object.freeze([
26
27
  'id', 'title', 'type', 'priority', 'complexity', 'estimated_days', 'status',
27
28
  'deps', 'files_modified', 'session_id', 'stale', 'complexity_hint',
28
29
  'tag', 'trace_to', 'context_file', 'completion_commit', 'parent_id',
30
+ 'change_id',
29
31
  'created_at', 'updated_at',
30
32
  ]);
31
33
 
@@ -33,6 +35,7 @@ const PATCHABLE_FIELDS = Object.freeze([
33
35
  'priority', 'complexity', 'estimated_days', 'deps', 'files_modified',
34
36
  'session_id', 'stale', 'complexity_hint', 'tag', 'trace_to',
35
37
  'context_file', 'completion_commit',
38
+ 'change_id',
36
39
  ]);
37
40
 
38
41
  const SESSION_PATCHABLE = Object.freeze([
@@ -152,10 +155,14 @@ function createTask(db, input) {
152
155
  context_file: input.context_file ?? null,
153
156
  completion_commit: null,
154
157
  parent_id: input.parent_id ?? null,
158
+ change_id: input.change_id ?? null,
155
159
  created_at: ts,
156
160
  updated_at: ts,
157
161
  };
158
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
+ }
159
166
  try {
160
167
  db.prepare(
161
168
  `INSERT INTO tasks (${TASK_FIELDS.join(', ')})
@@ -170,6 +177,7 @@ function createTask(db, input) {
170
177
  appendEventInTx(db, {
171
178
  type: 'task_created',
172
179
  task_id: row.id,
180
+ change_id: row.change_id,
173
181
  payload: { priority: row.priority, type: row.type },
174
182
  });
175
183
  return readTask(db, row.id);
@@ -192,6 +200,10 @@ function patchTask(db, id, patch = {}) {
192
200
  throw new StateOpsError('VALIDATION_ERROR', `field ${key} is not patchable`);
193
201
  }
194
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
+ }
195
207
  if (key === 'deps' || key === 'files_modified') {
196
208
  if (value !== null && !Array.isArray(value)) {
197
209
  throw new StateOpsError('VALIDATION_ERROR', `${key} must be an array`);
@@ -223,6 +235,7 @@ function patchTask(db, id, patch = {}) {
223
235
  appendEventInTx(db, {
224
236
  type: statusEventType(nextStatus),
225
237
  task_id: id,
238
+ change_id: patch.change_id !== undefined ? patch.change_id : current.change_id,
226
239
  payload: { from: current.status, to: nextStatus },
227
240
  });
228
241
  }
@@ -263,12 +276,16 @@ function appendEventInTx(db, event) {
263
276
  if (!event || !event.type) {
264
277
  throw new StateOpsError('VALIDATION_ERROR', 'event.type is required');
265
278
  }
279
+ if (event.runtime != null && !isSupportedRuntime(event.runtime)) {
280
+ throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${event.runtime}`);
281
+ }
266
282
  const result = db.prepare(
267
- `INSERT INTO events (type, task_id, session_id, runtime, payload_json)
268
- VALUES (?, ?, ?, ?, ?)`,
283
+ `INSERT INTO events (type, task_id, change_id, session_id, runtime, payload_json)
284
+ VALUES (?, ?, ?, ?, ?, ?)`,
269
285
  ).run(
270
286
  event.type,
271
287
  event.task_id ?? null,
288
+ event.change_id ?? null,
272
289
  event.session_id ?? null,
273
290
  event.runtime ?? null,
274
291
  event.payload === undefined ? null : JSON.stringify(event.payload),
@@ -283,7 +300,7 @@ function appendEvent(db, event) {
283
300
 
284
301
  // Single static SQL using json_each for the optional types IN-list and
285
302
  // NULL-pass-through for task_id/session_id. Frozen literal — no concat, no interpolation.
286
- 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";
287
304
 
288
305
  function subscribeEventsSince(db, { since_id = 0, types, task_id, session_id, limit = 100 } = {}) {
289
306
  const events = db.prepare(SUBSCRIBE_EVENTS_SQL).all({
@@ -311,6 +328,9 @@ function createSession(db, { sid, task_id, runtime, pid = null, worktree_path, a
311
328
  if (!sid || !task_id || !runtime || !worktree_path || !artifact_dir) {
312
329
  throw new StateOpsError('VALIDATION_ERROR', 'sid, task_id, runtime, worktree_path, artifact_dir required');
313
330
  }
331
+ if (!isSupportedRuntime(runtime)) {
332
+ throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${runtime}`);
333
+ }
314
334
  const lease = new Date(Date.now() + lease_seconds * 1000).toISOString();
315
335
  return tx(db, () => {
316
336
  if (!readTask(db, task_id)) {
@@ -697,6 +717,7 @@ module.exports = {
697
717
  deleteTask,
698
718
  // events
699
719
  appendEvent,
720
+ appendEventInTx,
700
721
  subscribeEventsSince,
701
722
  // sessions
702
723
  createSession,
@@ -12,6 +12,7 @@ const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
 
14
14
  const { computeCost } = require('./pricing.cjs');
15
+ const { isSupportedRuntime } = require('../../adapters/_shared/runtime-assets.cjs');
15
16
 
16
17
  function todayStamp() {
17
18
  return new Date().toISOString().slice(0, 10);
@@ -33,6 +34,9 @@ function appendTelemetry(db, {
33
34
  payload = null,
34
35
  rootDir = '.',
35
36
  } = {}) {
37
+ if (runtime != null && !isSupportedRuntime(runtime)) {
38
+ throw new Error(`unsupported runtime: ${runtime}`);
39
+ }
36
40
  if (!event_type) throw new Error('appendTelemetry: event_type required');
37
41
 
38
42
  // Auto-derive cost when tokens + runtime known and caller didn't supply one.