ultra-builder-pro-cli 0.11.0 → 0.13.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 (75) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/architecture.md +54 -223
  3. package/.ultra-template/specs/discovery.md +41 -228
  4. package/.ultra-template/specs/product.md +44 -200
  5. package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
  6. package/.ultra-template/tasks/tasks.json +6 -0
  7. package/CHANGELOG.md +94 -1
  8. package/README.md +37 -9
  9. package/adapters/_shared/codex-assets.cjs +1 -1
  10. package/adapters/_shared/handbook.cjs +5 -4
  11. package/adapters/_shared/tests/handbook.test.cjs +4 -0
  12. package/adapters/opencode.js +8 -2
  13. package/adapters/tests/codex.test.cjs +10 -2
  14. package/adapters/tests/kimi-hook.test.cjs +1 -1
  15. package/adapters/tests/opencode.test.cjs +36 -1
  16. package/commands/ultra-init.md +3 -3
  17. package/docs/AGENT-CONTEXT.md +17 -8
  18. package/docs/ARCHITECTURE.md +51 -8
  19. package/docs/PLAN.zh-CN.md +9 -6
  20. package/docs/ROADMAP.md +5 -3
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
  22. package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
  23. package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
  24. package/hooks/README.md +4 -3
  25. package/hooks/adapters/codex.py +1 -1
  26. package/hooks/adapters/kimi.py +1 -1
  27. package/hooks/context_spine.py +153 -16
  28. package/hooks/health_check.py +11 -0
  29. package/hooks/pre_stop_check.py +6 -5
  30. package/mcp-server/lib/archive-journal.cjs +192 -0
  31. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  32. package/mcp-server/lib/baseline-workflow.cjs +781 -0
  33. package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
  34. package/mcp-server/lib/change-workflow.cjs +256 -85
  35. package/mcp-server/lib/change-workflow.test.cjs +436 -6
  36. package/mcp-server/lib/context-spine.cjs +74 -9
  37. package/mcp-server/lib/context-spine.test.cjs +20 -0
  38. package/mcp-server/lib/doctor.cjs +28 -1
  39. package/mcp-server/lib/doctor.test.cjs +63 -1
  40. package/mcp-server/lib/init-project.cjs +334 -16
  41. package/mcp-server/lib/provider-refs.cjs +42 -0
  42. package/mcp-server/lib/spec-learning.test.cjs +5 -0
  43. package/mcp-server/lib/state-authority.cjs +9 -3
  44. package/mcp-server/lib/state-db.cjs +202 -21
  45. package/mcp-server/lib/state-db.test.cjs +140 -3
  46. package/mcp-server/lib/state-ops.cjs +68 -6
  47. package/mcp-server/lib/task-expander.cjs +7 -1
  48. package/mcp-server/lib/task-expander.test.cjs +71 -0
  49. package/mcp-server/server.cjs +62 -6
  50. package/mcp-server/tests/init-project.test.cjs +216 -9
  51. package/mcp-server/tests/migrate.test.cjs +33 -0
  52. package/mcp-server/tests/server-contract.test.cjs +478 -3
  53. package/mcp-server/tests/state-db.test.cjs +23 -2
  54. package/mcp-server/tests/state-ops.test.cjs +108 -0
  55. package/package.json +1 -1
  56. package/skills/ultra-change/SKILL.md +32 -13
  57. package/skills/ultra-deliver/SKILL.md +19 -5
  58. package/skills/ultra-dev/SKILL.md +8 -2
  59. package/skills/ultra-doctor/SKILL.md +30 -9
  60. package/skills/ultra-init/SKILL.md +107 -66
  61. package/skills/ultra-init/references/brownfield-adoption.md +93 -0
  62. package/skills/ultra-plan/SKILL.md +15 -5
  63. package/skills/ultra-research/SKILL.md +17 -5
  64. package/skills/ultra-status/SKILL.md +15 -6
  65. package/spec/cli-protocol.md +15 -1
  66. package/spec/mcp-tools.yaml +316 -11
  67. package/spec/schemas/mcp-tools.schema.json +1 -1
  68. package/spec/schemas/state-db.sql +44 -2
  69. package/ultra-tools/cli.cjs +9 -5
  70. package/ultra-tools/commands/migrate.cjs +62 -36
  71. package/ultra-tools/commands/status.cjs +29 -2
  72. package/ultra-tools/commands/system.cjs +340 -6
  73. package/ultra-tools/commands/task.cjs +11 -2
  74. package/ultra-tools/status-command.test.cjs +49 -0
  75. package/ultra-tools/system-command.test.cjs +256 -0
@@ -9,10 +9,23 @@ 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 = '10.0';
12
+ const EXPECTED_VERSION = '12.0';
13
13
  const KIMI_SCHEMA_VERSION = '9.1';
14
+ const CONTEXT_SCHEMA_VERSION = '10.0';
15
+ const BASELINE_SCHEMA_VERSION = '11.0';
16
+
17
+ const MIGRATED_GAPS = Object.freeze([{
18
+ id: 'legacy-rebaseline-required',
19
+ category: 'baseline_blocker',
20
+ status: 'open',
21
+ blocking: true,
22
+ summary: 'Legacy Ultra state requires evidence-backed brownfield re-adoption.',
23
+ evidence_refs: [],
24
+ owner: null,
25
+ }]);
14
26
 
15
27
  const REQUIRED_TABLES = Object.freeze([
28
+ 'baselines',
16
29
  'tasks',
17
30
  'events',
18
31
  'sessions',
@@ -31,6 +44,105 @@ const REQUIRED_TABLES = Object.freeze([
31
44
  'event_consumers',
32
45
  ]);
33
46
 
47
+ function applyBaselineUpgrade(db, { legacyState = false } = {}) {
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS baselines (
50
+ id TEXT PRIMARY KEY,
51
+ project_name TEXT NOT NULL,
52
+ project_type TEXT,
53
+ stack TEXT,
54
+ mode TEXT NOT NULL CHECK (mode IN ('greenfield', 'brownfield', 'migrated')),
55
+ status TEXT NOT NULL DEFAULT 'draft'
56
+ CHECK (status IN ('draft', 'adopting', 'blocked', 'ready', 'superseded')),
57
+ repository_root TEXT NOT NULL DEFAULT '.',
58
+ scope_json TEXT NOT NULL DEFAULT '["."]',
59
+ repository_revision TEXT,
60
+ repository_branch TEXT,
61
+ worktree_state TEXT NOT NULL DEFAULT 'unavailable'
62
+ CHECK (worktree_state IN ('clean', 'dirty', 'unavailable')),
63
+ worktree_digest TEXT,
64
+ worktree_files_json TEXT NOT NULL DEFAULT '[]',
65
+ worktree_accepted INTEGER NOT NULL DEFAULT 0 CHECK (worktree_accepted IN (0, 1)),
66
+ spec_refs_json TEXT NOT NULL DEFAULT '[]',
67
+ evidence_json TEXT NOT NULL DEFAULT '[]',
68
+ verification_json TEXT NOT NULL DEFAULT '[]',
69
+ unknowns_json TEXT NOT NULL DEFAULT '[]',
70
+ gaps_json TEXT NOT NULL DEFAULT '[]',
71
+ classification_json TEXT NOT NULL DEFAULT '{}',
72
+ provider_refs_json TEXT NOT NULL DEFAULT '{}',
73
+ approved_by TEXT,
74
+ approval_note TEXT,
75
+ started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
76
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
77
+ converged_at TEXT
78
+ );
79
+ CREATE INDEX IF NOT EXISTS baselines_status ON baselines(status, updated_at);
80
+ `);
81
+ if (legacyState && db.prepare('SELECT COUNT(*) AS count FROM baselines').get().count === 0) {
82
+ db.prepare(
83
+ `INSERT OR IGNORE INTO baselines
84
+ (id, project_name, mode, status, gaps_json, approval_note)
85
+ VALUES ('migrated-baseline', 'Migrated Ultra project', 'migrated', 'adopting', ?,
86
+ 'Legacy project preserved; owner re-adoption is required')`,
87
+ ).run(JSON.stringify(MIGRATED_GAPS));
88
+ }
89
+ }
90
+
91
+ function addColumnIfMissing(db, table, columns, name, definition) {
92
+ if (!columns.has(name)) {
93
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
94
+ columns.add(name);
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ function applyBaselineColumns(db) {
101
+ if (!tableNames(db).includes('baselines')) return false;
102
+ const columns = columnNames(db, 'baselines');
103
+ let changed = false;
104
+ changed = addColumnIfMissing(db, 'baselines', columns, 'repository_branch', 'TEXT') || changed;
105
+ changed = addColumnIfMissing(
106
+ db, 'baselines', columns, 'worktree_state',
107
+ "TEXT NOT NULL DEFAULT 'unavailable' CHECK (worktree_state IN ('clean', 'dirty', 'unavailable'))",
108
+ ) || changed;
109
+ changed = addColumnIfMissing(db, 'baselines', columns, 'worktree_digest', 'TEXT') || changed;
110
+ changed = addColumnIfMissing(
111
+ db, 'baselines', columns, 'worktree_files_json', "TEXT NOT NULL DEFAULT '[]'",
112
+ ) || changed;
113
+ changed = addColumnIfMissing(
114
+ db, 'baselines', columns, 'worktree_accepted',
115
+ 'INTEGER NOT NULL DEFAULT 0 CHECK (worktree_accepted IN (0, 1))',
116
+ ) || changed;
117
+ changed = addColumnIfMissing(db, 'baselines', columns, 'gaps_json', "TEXT NOT NULL DEFAULT '[]'") || changed;
118
+ changed = addColumnIfMissing(
119
+ db, 'baselines', columns, 'classification_json', "TEXT NOT NULL DEFAULT '{}'",
120
+ ) || changed;
121
+ return changed;
122
+ }
123
+
124
+ function applyChangeColumns(db) {
125
+ if (!tableNames(db).includes('changes')) return false;
126
+ const columns = columnNames(db, 'changes');
127
+ return addColumnIfMissing(db, 'changes', columns, 'baseline_bypass_json', 'TEXT');
128
+ }
129
+
130
+ function quoteSqlString(value) {
131
+ return `'${String(value).replaceAll("'", "''")}'`;
132
+ }
133
+
134
+ function migrationBackup(db, dbPath, fromVersion, existingTables) {
135
+ if (existingTables.size === 0 || fromVersion === EXPECTED_VERSION) return null;
136
+ const parent = path.dirname(dbPath);
137
+ const backupDir = path.basename(parent) === '.ultra' ? path.join(parent, 'backups') : path.join(parent, 'backups');
138
+ fs.mkdirSync(backupDir, { recursive: true });
139
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
140
+ const version = String(fromVersion || 'unknown').replace(/[^a-zA-Z0-9_.-]/g, '_');
141
+ const backupPath = path.join(backupDir, `state-pre-${version}-to-${EXPECTED_VERSION}-${stamp}.db`);
142
+ db.exec(`VACUUM INTO ${quoteSqlString(backupPath)}`);
143
+ return backupPath;
144
+ }
145
+
34
146
  function readSchemaSql() {
35
147
  if (!fs.existsSync(SCHEMA_FILE)) {
36
148
  throw new Error(`state-db schema missing at ${SCHEMA_FILE}`);
@@ -218,27 +330,82 @@ function upgradeRuntimeConstraints(db, fromVersion = latestSchemaVersion(db)) {
218
330
  return true;
219
331
  }
220
332
 
221
- function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
333
+ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db), { legacyState = false } = {}) {
222
334
  const runtimeChanged = upgradeRuntimeConstraints(db, fromVersion);
223
335
  db.transaction(() => {
224
336
  applyCompatibleColumns(db);
225
337
  const contextChanged = applyContextSpineUpgrade(db);
226
- if (contextChanged || (fromVersion && fromVersion !== EXPECTED_VERSION)) {
338
+ const contextMigration = db.prepare(
339
+ "SELECT 1 FROM migration_history WHERE to_version = '10.0' AND status = 'success' LIMIT 1",
340
+ ).get();
341
+ const needsContextMigration = Boolean(
342
+ fromVersion && ![CONTEXT_SCHEMA_VERSION, BASELINE_SCHEMA_VERSION, EXPECTED_VERSION].includes(fromVersion)
343
+ && !contextMigration,
344
+ );
345
+ if (contextChanged || needsContextMigration) {
227
346
  db.prepare(
228
347
  `INSERT INTO migration_history
229
348
  (from_version, to_version, direction, status, notes)
230
349
  VALUES (?, ?, 'forward', 'success', ?)`,
231
350
  ).run(
232
351
  runtimeChanged ? KIMI_SCHEMA_VERSION : (fromVersion || 'unknown'),
233
- EXPECTED_VERSION,
352
+ CONTEXT_SCHEMA_VERSION,
234
353
  'Add Context Spine role/gate readiness, execution contracts, breadcrumbs, and specification learning',
235
354
  );
236
355
  }
356
+ applyBaselineUpgrade(db, { legacyState });
357
+ applyBaselineColumns(db);
358
+ applyChangeColumns(db);
359
+ const baselineMigration = db.prepare(
360
+ "SELECT 1 FROM migration_history WHERE to_version = '11.0' AND status = 'success' LIMIT 1",
361
+ ).get();
362
+ if (fromVersion && ![BASELINE_SCHEMA_VERSION, EXPECTED_VERSION].includes(fromVersion)
363
+ && !baselineMigration) {
364
+ db.prepare(
365
+ `INSERT INTO migration_history
366
+ (from_version, to_version, direction, status, notes)
367
+ VALUES (?, ?, 'forward', 'success', ?)`,
368
+ ).run(
369
+ fromVersion === CONTEXT_SCHEMA_VERSION ? fromVersion : CONTEXT_SCHEMA_VERSION,
370
+ BASELINE_SCHEMA_VERSION,
371
+ 'Add authoritative greenfield and brownfield baseline adoption, evidence, convergence, and drift state',
372
+ );
373
+ }
374
+ if (legacyState) {
375
+ db.prepare(
376
+ `UPDATE baselines SET status = 'adopting', approved_by = NULL, approval_note = ?,
377
+ converged_at = NULL, worktree_state = 'unavailable', worktree_digest = NULL,
378
+ worktree_files_json = '[]', worktree_accepted = 0,
379
+ gaps_json = CASE WHEN gaps_json IS NULL OR gaps_json = '[]' THEN ? ELSE gaps_json END,
380
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
381
+ WHERE mode = 'migrated' AND status != 'superseded'`,
382
+ ).run(
383
+ 'Legacy project preserved; owner re-adoption is required',
384
+ JSON.stringify(MIGRATED_GAPS),
385
+ );
386
+ const adoptionMigration = db.prepare(
387
+ "SELECT 1 FROM migration_history WHERE to_version = '12.0' AND status = 'success' LIMIT 1",
388
+ ).get();
389
+ if (!adoptionMigration) {
390
+ db.prepare(
391
+ `INSERT INTO migration_history
392
+ (from_version, to_version, direction, status, notes)
393
+ VALUES (?, ?, 'forward', 'success', ?)`,
394
+ ).run(
395
+ BASELINE_SCHEMA_VERSION,
396
+ EXPECTED_VERSION,
397
+ 'Add repository snapshots, authoritative gap ledger, incident governance, and required re-adoption',
398
+ );
399
+ }
400
+ }
237
401
  db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
238
402
  db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
239
403
  db.prepare(
240
404
  'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
241
- ).run(EXPECTED_VERSION, 'Role-scoped context snapshots, deterministic breadcrumbs, and approval-gated specification learning');
405
+ ).run(
406
+ EXPECTED_VERSION,
407
+ 'Evidence-backed repository snapshots, gap ledger, safe re-adoption, and incident break-glass governance',
408
+ );
242
409
  })();
243
410
  }
244
411
 
@@ -258,23 +425,36 @@ function ensureSchemaVersion(db) {
258
425
  }
259
426
 
260
427
  function initStateDb(dbPath) {
261
- const db = openStateDb(dbPath);
262
- const existing = new Set(tableNames(db));
263
- const fromVersion = latestSchemaVersion(db);
264
- applyCompatibleColumns(db);
265
- const missing = REQUIRED_TABLES.filter((t) => !existing.has(t));
266
- if (missing.length > 0) {
267
- applySchema(db);
428
+ let db;
429
+ let backupPath = null;
430
+ try {
431
+ db = openStateDb(dbPath);
432
+ const existing = new Set(tableNames(db));
433
+ const fromVersion = latestSchemaVersion(db);
434
+ const legacyState = existing.size > 0 && fromVersion !== EXPECTED_VERSION;
435
+ backupPath = migrationBackup(db, dbPath, fromVersion, existing);
436
+ applyCompatibleColumns(db);
437
+ const missing = REQUIRED_TABLES.filter((t) => !existing.has(t));
438
+ if (missing.length > 0) {
439
+ applySchema(db);
440
+ }
441
+ applyCompatibleUpgrades(db, fromVersion, { legacyState });
442
+ const version = ensureSchemaVersion(db);
443
+ return {
444
+ db,
445
+ path: dbPath,
446
+ schema_version: version,
447
+ created: missing.length > 0,
448
+ backup_path: backupPath,
449
+ tables: tableNames(db).sort(),
450
+ };
451
+ } catch (error) {
452
+ if (backupPath && error && typeof error === 'object') {
453
+ error.migration_backup_path = backupPath;
454
+ }
455
+ closeStateDb(db);
456
+ throw error;
268
457
  }
269
- applyCompatibleUpgrades(db, fromVersion);
270
- const version = ensureSchemaVersion(db);
271
- return {
272
- db,
273
- path: dbPath,
274
- schema_version: version,
275
- created: missing.length > 0,
276
- tables: tableNames(db).sort(),
277
- };
278
458
  }
279
459
 
280
460
  function closeStateDb(db) {
@@ -296,4 +476,5 @@ module.exports = {
296
476
  closeStateDb,
297
477
  tableNames,
298
478
  runScript,
479
+ MIGRATED_GAPS,
299
480
  };
@@ -82,6 +82,135 @@ test('openStateDb on an empty file produces no tables until schema is applied',
82
82
  }
83
83
  });
84
84
 
85
+ test('initStateDb upgrades an exact schema 11 project without replacing its approved baseline', () => {
86
+ const { dir, file } = tmpDbPath('ubp-schema-11-upgrade');
87
+ try {
88
+ const legacy = openStateDb(file);
89
+ legacy.exec(`
90
+ CREATE TABLE schema_version (
91
+ version TEXT PRIMARY KEY,
92
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
93
+ description TEXT
94
+ );
95
+ CREATE TABLE migration_history (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ from_version TEXT NOT NULL,
98
+ to_version TEXT NOT NULL,
99
+ direction TEXT NOT NULL CHECK (direction IN ('forward', 'rollback')),
100
+ ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
101
+ status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'dry_run')),
102
+ notes TEXT
103
+ );
104
+ CREATE TABLE baselines (
105
+ id TEXT PRIMARY KEY,
106
+ project_name TEXT NOT NULL,
107
+ project_type TEXT,
108
+ stack TEXT,
109
+ mode TEXT NOT NULL CHECK (mode IN ('greenfield', 'brownfield', 'migrated')),
110
+ status TEXT NOT NULL CHECK (status IN ('draft', 'adopting', 'blocked', 'ready', 'superseded')),
111
+ repository_root TEXT NOT NULL DEFAULT '.',
112
+ scope_json TEXT NOT NULL DEFAULT '["."]',
113
+ repository_revision TEXT,
114
+ spec_refs_json TEXT NOT NULL DEFAULT '[]',
115
+ evidence_json TEXT NOT NULL DEFAULT '[]',
116
+ verification_json TEXT NOT NULL DEFAULT '[]',
117
+ unknowns_json TEXT NOT NULL DEFAULT '[]',
118
+ provider_refs_json TEXT NOT NULL DEFAULT '{}',
119
+ approved_by TEXT,
120
+ approval_note TEXT,
121
+ started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
122
+ converged_at TEXT,
123
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
124
+ );
125
+ CREATE TABLE changes (
126
+ id TEXT PRIMARY KEY,
127
+ title TEXT NOT NULL,
128
+ kind TEXT NOT NULL CHECK (kind IN ('quick', 'standard', 'major', 'incident')),
129
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'blocked', 'ready', 'archived', 'cancelled')),
130
+ intent TEXT NOT NULL,
131
+ docs_impact_json TEXT NOT NULL DEFAULT '{}',
132
+ provider_refs_json TEXT NOT NULL DEFAULT '{}',
133
+ base_commit TEXT,
134
+ artifact_root TEXT,
135
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
136
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
137
+ closed_at TEXT
138
+ );
139
+ INSERT INTO schema_version (version, description) VALUES ('11.0', 'baseline authority');
140
+ INSERT INTO baselines
141
+ (id, project_name, mode, status, approved_by, approval_note, converged_at)
142
+ VALUES
143
+ ('approved-baseline', 'legacy-project', 'brownfield', 'ready', 'owner', 'approved',
144
+ '2026-07-01T00:00:00.000Z');
145
+ `);
146
+ closeStateDb(legacy);
147
+
148
+ let upgraded;
149
+ try {
150
+ upgraded = initStateDb(file);
151
+ } catch (error) {
152
+ assert.fail(`schema 11 upgrade failed: ${error.message}\n${error.stack || ''}`);
153
+ }
154
+ try {
155
+ assert.equal(upgraded.schema_version, '12.0');
156
+ assert.ok(fs.existsSync(upgraded.backup_path));
157
+ const baseline = upgraded.db.prepare(
158
+ 'SELECT id, mode, status, worktree_state, gaps_json FROM baselines',
159
+ ).get();
160
+ assert.deepEqual(baseline, {
161
+ id: 'approved-baseline', mode: 'brownfield', status: 'ready',
162
+ worktree_state: 'unavailable', gaps_json: '[]',
163
+ });
164
+ assert.equal(
165
+ upgraded.db.prepare("SELECT COUNT(*) AS count FROM baselines WHERE mode = 'migrated'").get().count,
166
+ 0,
167
+ );
168
+ const changeColumns = upgraded.db.prepare('PRAGMA table_info(changes)').all().map((row) => row.name);
169
+ assert.ok(changeColumns.includes('baseline_bypass_json'));
170
+ } finally { closeStateDb(upgraded.db); }
171
+ } finally {
172
+ fs.rmSync(dir, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ test('initStateDb exposes its pre-migration backup when an incompatible legacy schema fails', () => {
177
+ const { dir, file } = tmpDbPath('ubp-schema-upgrade-failure');
178
+ try {
179
+ const legacy = openStateDb(file);
180
+ legacy.exec(`
181
+ CREATE TABLE schema_version (
182
+ version TEXT PRIMARY KEY,
183
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
184
+ description TEXT
185
+ );
186
+ CREATE TABLE migration_history (
187
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188
+ from_version TEXT NOT NULL,
189
+ to_version TEXT NOT NULL,
190
+ direction TEXT NOT NULL,
191
+ applied_at TEXT NOT NULL,
192
+ status TEXT NOT NULL,
193
+ notes TEXT
194
+ );
195
+ INSERT INTO schema_version (version, description) VALUES ('11.0', 'incompatible fixture');
196
+ `);
197
+ closeStateDb(legacy);
198
+
199
+ let failure;
200
+ try {
201
+ initStateDb(file);
202
+ } catch (error) {
203
+ failure = error;
204
+ }
205
+ assert.ok(failure, 'the incompatible migration_history table must fail schema application');
206
+ assert.match(failure.message, /no such column: ts/);
207
+ assert.ok(failure.migration_backup_path, 'failure must retain the backup location for recovery');
208
+ assert.ok(fs.existsSync(failure.migration_backup_path));
209
+ } finally {
210
+ fs.rmSync(dir, { recursive: true, force: true });
211
+ }
212
+ });
213
+
85
214
  test('Phase 8A.1 schema: tasks.parent_id column + tasks_parent partial index + seed row', () => {
86
215
  const { dir, file } = tmpDbPath();
87
216
  try {
@@ -123,7 +252,7 @@ test('initStateDb migrates existing runtime constraints to Kimi without losing r
123
252
  const legacy = openStateDb(file);
124
253
  const legacySchema = fs.readFileSync(SCHEMA_FILE, 'utf8').replaceAll(", 'kimi'", '');
125
254
  legacy.exec(legacySchema);
126
- legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.0')").run();
255
+ legacy.prepare("DELETE FROM schema_version WHERE version IN ('9.1', '10.0', '11.0', '12.0')").run();
127
256
  legacy.prepare(
128
257
  "INSERT INTO tasks (id, title, type, priority) VALUES ('task-old', 'Old', 'feature', 'P1')",
129
258
  ).run();
@@ -146,16 +275,24 @@ test('initStateDb migrates existing runtime constraints to Kimi without losing r
146
275
  closeStateDb(legacy);
147
276
 
148
277
  const upgraded = initStateDb(file);
149
- assert.equal(upgraded.schema_version, '10.0');
278
+ assert.equal(upgraded.schema_version, '12.0');
279
+ assert.ok(upgraded.backup_path);
280
+ assert.ok(fs.existsSync(upgraded.backup_path));
150
281
  assert.equal(upgraded.db.prepare("SELECT runtime FROM sessions WHERE sid = 'session-old'").get().runtime, 'codex');
151
282
  assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM telemetry WHERE session_id = 'session-old'").get().n, 1);
152
283
  assert.equal(upgraded.db.prepare("SELECT COUNT(*) AS n FROM incidents WHERE session_id = 'session-old'").get().n, 1);
153
284
  assert.deepEqual(upgraded.db.pragma('foreign_key_check'), []);
154
285
  const migrations = upgraded.db.prepare(
155
- "SELECT to_version, notes FROM migration_history WHERE to_version IN ('9.1', '10.0') ORDER BY id",
286
+ "SELECT to_version, notes FROM migration_history WHERE to_version IN ('9.1', '10.0', '11.0', '12.0') ORDER BY id",
156
287
  ).all();
157
288
  assert.ok(migrations.some((row) => row.to_version === '9.1' && /Kimi/.test(row.notes)));
158
289
  assert.ok(migrations.some((row) => row.to_version === '10.0' && /Context Spine/.test(row.notes)));
290
+ assert.ok(migrations.some((row) => row.to_version === '11.0' && /baseline adoption/i.test(row.notes)));
291
+ assert.ok(migrations.some((row) => row.to_version === '12.0' && /gap ledger|re-adoption/i.test(row.notes)));
292
+ assert.deepEqual(
293
+ upgraded.db.prepare("SELECT mode, status FROM baselines WHERE id = 'migrated-baseline'").get(),
294
+ { mode: 'migrated', status: 'adopting' },
295
+ );
159
296
 
160
297
  upgraded.db.prepare(
161
298
  "INSERT INTO tasks (id, title, type, priority) VALUES ('task-kimi', 'Kimi', 'feature', 'P1')",
@@ -128,6 +128,60 @@ function listTasks(db, filter = {}) {
128
128
  }).map(rowToTask);
129
129
  }
130
130
 
131
+ function assertChangeAcceptsTasks(db, changeId) {
132
+ if (changeId === null || changeId === undefined) return null;
133
+ const change = db.prepare('SELECT id, status FROM changes WHERE id = ?').get(changeId);
134
+ if (!change) throw new StateOpsError('CHANGE_NOT_FOUND', `change ${changeId} does not exist`);
135
+ if (!['active', 'blocked'].includes(change.status)) {
136
+ throw new StateOpsError('CHANGE_NOT_MUTABLE', `change ${changeId} is ${change.status}`);
137
+ }
138
+ return change;
139
+ }
140
+
141
+ function resolveTaskCreationChangeId(db, input = {}) {
142
+ const explicitlyAssigned = input.change_id !== undefined;
143
+ const requestedChangeId = explicitlyAssigned ? input.change_id : null;
144
+ if (!input.parent_id) {
145
+ assertChangeAcceptsTasks(db, requestedChangeId);
146
+ return requestedChangeId;
147
+ }
148
+ const parent = readTask(db, input.parent_id);
149
+ if (!parent) {
150
+ throw new StateOpsError('TASK_NOT_FOUND', `parent task ${input.parent_id} does not exist`);
151
+ }
152
+ if (explicitlyAssigned && requestedChangeId !== parent.change_id) {
153
+ throw new StateOpsError(
154
+ 'TASK_CHANGE_OWNERSHIP_MISMATCH',
155
+ `child task change ${requestedChangeId || '(none)'} does not match parent ${parent.id} change ${parent.change_id || '(none)'}`,
156
+ );
157
+ }
158
+ assertChangeAcceptsTasks(db, parent.change_id);
159
+ return parent.change_id;
160
+ }
161
+
162
+ function assertTaskChangeAssignment(db, task, changeId) {
163
+ if (task.parent_id) {
164
+ const parent = readTask(db, task.parent_id);
165
+ if (!parent) throw new StateOpsError('TASK_NOT_FOUND', `parent task ${task.parent_id} does not exist`);
166
+ if (changeId !== parent.change_id) {
167
+ throw new StateOpsError(
168
+ 'TASK_CHANGE_OWNERSHIP_MISMATCH',
169
+ `task ${task.id} must retain parent ${parent.id} change ${parent.change_id || '(none)'}`,
170
+ );
171
+ }
172
+ }
173
+ const mismatchedChild = db.prepare(
174
+ 'SELECT id, change_id FROM tasks WHERE parent_id = ? AND change_id IS NOT ? LIMIT 1',
175
+ ).get(task.id, changeId);
176
+ if (mismatchedChild) {
177
+ throw new StateOpsError(
178
+ 'TASK_CHANGE_OWNERSHIP_MISMATCH',
179
+ `task ${task.id} change must continue to match child ${mismatchedChild.id}`,
180
+ );
181
+ }
182
+ assertChangeAcceptsTasks(db, changeId);
183
+ }
184
+
131
185
  function createTask(db, input) {
132
186
  if (!input || !input.id || !input.title || !input.type || !input.priority) {
133
187
  throw new StateOpsError('VALIDATION_ERROR', 'id, title, type, priority required');
@@ -160,9 +214,7 @@ function createTask(db, input) {
160
214
  updated_at: ts,
161
215
  };
162
216
  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
- }
217
+ row.change_id = resolveTaskCreationChangeId(db, input);
166
218
  try {
167
219
  db.prepare(
168
220
  `INSERT INTO tasks (${TASK_FIELDS.join(', ')})
@@ -188,6 +240,10 @@ function patchTask(db, id, patch = {}) {
188
240
  return tx(db, () => {
189
241
  const current = readTask(db, id);
190
242
  if (!current) throw new StateOpsError('TASK_NOT_FOUND', `no task ${id}`);
243
+ // A task owned by a change remains writable only while that change is an
244
+ // active execution authority. Ownership is durable once assigned: it may
245
+ // not be detached or moved to another change by a generic task patch.
246
+ assertChangeAcceptsTasks(db, current.change_id);
191
247
  const sets = [];
192
248
  const params = [];
193
249
  let nextStatus = null;
@@ -200,9 +256,14 @@ function patchTask(db, id, patch = {}) {
200
256
  throw new StateOpsError('VALIDATION_ERROR', `field ${key} is not patchable`);
201
257
  }
202
258
  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`);
259
+ if (key === 'change_id') {
260
+ if (current.change_id !== null && value !== current.change_id) {
261
+ throw new StateOpsError(
262
+ 'TASK_CHANGE_OWNERSHIP_MISMATCH',
263
+ `task ${current.id} already belongs to change ${current.change_id}`,
264
+ );
265
+ }
266
+ assertTaskChangeAssignment(db, current, value);
206
267
  }
207
268
  if (key === 'deps' || key === 'files_modified') {
208
269
  if (value !== null && !Array.isArray(value)) {
@@ -711,6 +772,7 @@ module.exports = {
711
772
  // tasks
712
773
  readTask,
713
774
  listTasks,
775
+ resolveTaskCreationChangeId,
714
776
  createTask,
715
777
  patchTask,
716
778
  updateTaskStatus,
@@ -10,6 +10,7 @@
10
10
 
11
11
  const Ajv = require('ajv/dist/2020');
12
12
  const ops = require('./state-ops.cjs');
13
+ const changes = require('./change-workflow.cjs');
13
14
  const {
14
15
  buildExpandSystemPrompt,
15
16
  buildExpandUserPrompt,
@@ -51,7 +52,9 @@ class TaskExpandError extends Error {
51
52
  }
52
53
  }
53
54
 
54
- async function expandTask(db, { id, sub_count, strategy = 'llm', llmClient } = {}) {
55
+ async function expandTask(db, {
56
+ id, sub_count, strategy = 'llm', llmClient, rootDir = process.cwd(),
57
+ } = {}) {
55
58
  if (!id) throw new TaskExpandError('VALIDATION_ERROR', 'id required');
56
59
 
57
60
  const parent = ops.readTask(db, id);
@@ -65,6 +68,7 @@ async function expandTask(db, { id, sub_count, strategy = 'llm', llmClient } = {
65
68
  if (strategy !== 'llm') {
66
69
  throw new TaskExpandError('VALIDATION_ERROR', `unknown strategy "${strategy}"`);
67
70
  }
71
+ changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
68
72
  if (!llmClient || typeof llmClient.completeJson !== 'function') {
69
73
  throw new TaskExpandError('NO_LLM_CLIENT', 'llmClient.completeJson is required');
70
74
  }
@@ -101,10 +105,12 @@ async function expandTask(db, { id, sub_count, strategy = 'llm', llmClient } = {
101
105
  files_modified: Array.isArray(c.files_modified) ? c.files_modified : [],
102
106
  parent_id: parent.id,
103
107
  tag: parent.tag,
108
+ change_id: parent.change_id,
104
109
  };
105
110
  });
106
111
 
107
112
  return ops.tx(db, () => {
113
+ changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
108
114
  for (const child of children) {
109
115
  ops.createTask(db, child);
110
116
  }
@@ -13,11 +13,17 @@ const path = require('node:path');
13
13
 
14
14
  const { initStateDb, closeStateDb } = require('./state-db.cjs');
15
15
  const ops = require('./state-ops.cjs');
16
+ const changes = require('./change-workflow.cjs');
16
17
  const { expandTask, TaskExpandError } = require('./task-expander.cjs');
17
18
 
18
19
  function tmpDb() {
19
20
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-expand-'));
20
21
  const { db } = initStateDb(path.join(dir, 'state.db'));
22
+ db.prepare(
23
+ `INSERT INTO baselines
24
+ (id, project_name, mode, status, approved_by, approval_note, converged_at)
25
+ VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'fixture', ?)`,
26
+ ).run(new Date().toISOString());
21
27
  return { dir, db };
22
28
  }
23
29
 
@@ -251,3 +257,68 @@ test('expandTask: children inherit parent tag', async () => {
251
257
  cleanup(ctx);
252
258
  }
253
259
  });
260
+
261
+ test('expandTask blocks new children when baseline authority is missing, draft, or migrated', async () => {
262
+ for (const baseline of ['missing', 'draft', 'migrated']) {
263
+ const ctx = tmpDb();
264
+ try {
265
+ seedParent(ctx.db);
266
+ if (baseline === 'missing') ctx.db.prepare('DELETE FROM baselines').run();
267
+ else if (baseline === 'draft') {
268
+ ctx.db.prepare("UPDATE baselines SET status = 'draft' WHERE id = 'test-baseline'").run();
269
+ } else {
270
+ ctx.db.prepare(
271
+ "UPDATE baselines SET mode = 'migrated', status = 'adopting' WHERE id = 'test-baseline'",
272
+ ).run();
273
+ }
274
+ await assert.rejects(
275
+ expandTask(ctx.db, {
276
+ id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN), rootDir: ctx.dir,
277
+ }),
278
+ (error) => error.code === 'BASELINE_NOT_READY',
279
+ );
280
+ assert.equal(ops.readTask(ctx.db, 'child-1'), null);
281
+ assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
282
+ } finally { cleanup(ctx); }
283
+ }
284
+ });
285
+
286
+ test('expandTask preserves active ordinary and approved incident change ownership while adoption is incomplete', async () => {
287
+ for (const kind of ['quick', 'incident']) {
288
+ const ctx = tmpDb();
289
+ try {
290
+ if (kind === 'incident') {
291
+ ctx.db.prepare(
292
+ "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
293
+ ).run();
294
+ }
295
+ const changeId = `expand-${kind}`;
296
+ changes.createChange(ctx.db, {
297
+ id: changeId,
298
+ title: kind === 'incident' ? 'Recover production expansion' : 'Continue planned expansion',
299
+ kind,
300
+ intent: 'Keep every expanded child inside the already-authorized continuous change.',
301
+ ...(kind === 'incident' ? {
302
+ baseline_bypass: {
303
+ approved_by: 'incident-commander',
304
+ reason: 'Production recovery requires a bounded task expansion before adoption converges.',
305
+ },
306
+ } : {}),
307
+ }, { rootDir: ctx.dir });
308
+ seedParent(ctx.db, { change_id: changeId });
309
+ if (kind === 'quick') {
310
+ ctx.db.prepare(
311
+ "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
312
+ ).run();
313
+ }
314
+
315
+ const result = await expandTask(ctx.db, {
316
+ id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN), rootDir: ctx.dir,
317
+ });
318
+ assert.equal(result.children.length, HAPPY_CHILDREN.length);
319
+ for (const child of HAPPY_CHILDREN) {
320
+ assert.equal(ops.readTask(ctx.db, child.id).change_id, changeId);
321
+ }
322
+ } finally { cleanup(ctx); }
323
+ }
324
+ });