ultra-builder-pro-cli 0.12.1 → 0.14.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 (88) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +95 -1
  4. package/README.md +31 -16
  5. package/adapters/_shared/codex-assets.cjs +23 -163
  6. package/adapters/_shared/handbook.cjs +2 -2
  7. package/adapters/_shared/runtime-assets.cjs +1 -0
  8. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  9. package/adapters/opencode.js +30 -120
  10. package/adapters/tests/claude.test.cjs +3 -2
  11. package/adapters/tests/codex-hook.test.cjs +34 -7
  12. package/adapters/tests/codex.test.cjs +21 -6
  13. package/adapters/tests/kimi-hook.test.cjs +34 -7
  14. package/adapters/tests/opencode.test.cjs +70 -84
  15. package/commands/ultra-init.md +1 -1
  16. package/docs/AGENT-CONTEXT.md +10 -6
  17. package/docs/ARCHITECTURE.md +40 -17
  18. package/docs/PLAN.zh-CN.md +5 -4
  19. package/docs/ROADMAP.md +2 -2
  20. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  21. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  22. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  23. package/hooks/active_task_context.py +22 -50
  24. package/hooks/context_spine.py +61 -299
  25. package/hooks/health_check.py +19 -17
  26. package/hooks/pre_stop_check.py +28 -33
  27. package/hooks/subagent_tracker.py +13 -19
  28. package/hooks/workflow_checkpoint.py +39 -47
  29. package/hooks/workflow_context.py +4 -9
  30. package/hooks/workflow_resume.py +48 -128
  31. package/mcp-server/breadcrumb.cjs +22 -0
  32. package/mcp-server/lib/archive-journal.cjs +192 -0
  33. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  34. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  35. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  36. package/mcp-server/lib/change-workflow.cjs +249 -75
  37. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  38. package/mcp-server/lib/context-spine.test.cjs +5 -0
  39. package/mcp-server/lib/doctor.cjs +14 -1
  40. package/mcp-server/lib/doctor.test.cjs +42 -4
  41. package/mcp-server/lib/init-project.cjs +292 -9
  42. package/mcp-server/lib/prd-parser.cjs +14 -41
  43. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  44. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  45. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  46. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  47. package/mcp-server/lib/state-authority.cjs +9 -3
  48. package/mcp-server/lib/state-db.cjs +154 -32
  49. package/mcp-server/lib/state-db.test.cjs +136 -4
  50. package/mcp-server/lib/state-ops.cjs +68 -6
  51. package/mcp-server/lib/task-expander.cjs +14 -37
  52. package/mcp-server/lib/task-expander.test.cjs +80 -164
  53. package/mcp-server/server.cjs +34 -37
  54. package/mcp-server/tests/init-project.test.cjs +162 -1
  55. package/mcp-server/tests/migrate.test.cjs +33 -0
  56. package/mcp-server/tests/server-contract.test.cjs +391 -17
  57. package/mcp-server/tests/state-db.test.cjs +14 -2
  58. package/mcp-server/tests/state-ops.test.cjs +108 -0
  59. package/package.json +2 -4
  60. package/skills/cc-collab/SKILL.md +2 -2
  61. package/skills/codex-collab/SKILL.md +2 -3
  62. package/skills/ultra-change/SKILL.md +18 -10
  63. package/skills/ultra-deliver/SKILL.md +13 -9
  64. package/skills/ultra-dev/SKILL.md +6 -4
  65. package/skills/ultra-doctor/SKILL.md +22 -7
  66. package/skills/ultra-init/SKILL.md +104 -59
  67. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  68. package/skills/ultra-plan/SKILL.md +20 -7
  69. package/skills/ultra-research/SKILL.md +12 -5
  70. package/skills/ultra-status/SKILL.md +6 -4
  71. package/skills/ultra-verify/SKILL.md +21 -30
  72. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  73. package/spec/cli-protocol.md +11 -1
  74. package/spec/mcp-tools.yaml +133 -19
  75. package/spec/schemas/state-db.sql +11 -0
  76. package/ultra-tools/cli.cjs +9 -5
  77. package/ultra-tools/commands/migrate.cjs +62 -36
  78. package/ultra-tools/commands/status.cjs +18 -9
  79. package/ultra-tools/commands/system.cjs +340 -6
  80. package/ultra-tools/commands/task.cjs +7 -1
  81. package/ultra-tools/status-command.test.cjs +29 -0
  82. package/ultra-tools/system-command.test.cjs +252 -1
  83. package/mcp-server/lib/llm-client.cjs +0 -157
  84. package/mcp-server/lib/llm-client.test.cjs +0 -156
  85. package/orchestrator/planner/expand-prompt.cjs +0 -62
  86. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  87. package/orchestrator/planner/prd-prompt.cjs +0 -44
  88. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
@@ -9,8 +9,20 @@ 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 = '11.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([
16
28
  'baselines',
@@ -32,7 +44,7 @@ const REQUIRED_TABLES = Object.freeze([
32
44
  'event_consumers',
33
45
  ]);
34
46
 
35
- function applyBaselineUpgrade(db, fromVersion) {
47
+ function applyBaselineUpgrade(db, { legacyState = false } = {}) {
36
48
  db.exec(`
37
49
  CREATE TABLE IF NOT EXISTS baselines (
38
50
  id TEXT PRIMARY KEY,
@@ -45,10 +57,18 @@ function applyBaselineUpgrade(db, fromVersion) {
45
57
  repository_root TEXT NOT NULL DEFAULT '.',
46
58
  scope_json TEXT NOT NULL DEFAULT '["."]',
47
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)),
48
66
  spec_refs_json TEXT NOT NULL DEFAULT '[]',
49
67
  evidence_json TEXT NOT NULL DEFAULT '[]',
50
68
  verification_json TEXT NOT NULL DEFAULT '[]',
51
69
  unknowns_json TEXT NOT NULL DEFAULT '[]',
70
+ gaps_json TEXT NOT NULL DEFAULT '[]',
71
+ classification_json TEXT NOT NULL DEFAULT '{}',
52
72
  provider_refs_json TEXT NOT NULL DEFAULT '{}',
53
73
  approved_by TEXT,
54
74
  approval_note TEXT,
@@ -58,15 +78,69 @@ function applyBaselineUpgrade(db, fromVersion) {
58
78
  );
59
79
  CREATE INDEX IF NOT EXISTS baselines_status ON baselines(status, updated_at);
60
80
  `);
61
- if (fromVersion && fromVersion !== EXPECTED_VERSION) {
81
+ if (legacyState && db.prepare('SELECT COUNT(*) AS count FROM baselines').get().count === 0) {
62
82
  db.prepare(
63
83
  `INSERT OR IGNORE INTO baselines
64
- (id, project_name, mode, status, approved_by, approval_note, converged_at)
65
- VALUES ('migrated-baseline', 'Migrated Ultra project', 'migrated', 'ready',
66
- 'schema-migration', 'Legacy project preserved during schema 11.0 migration',
67
- strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
68
- ).run();
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;
69
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;
70
144
  }
71
145
 
72
146
  function readSchemaSql() {
@@ -256,7 +330,7 @@ function upgradeRuntimeConstraints(db, fromVersion = latestSchemaVersion(db)) {
256
330
  return true;
257
331
  }
258
332
 
259
- function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
333
+ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db), { legacyState = false } = {}) {
260
334
  const runtimeChanged = upgradeRuntimeConstraints(db, fromVersion);
261
335
  db.transaction(() => {
262
336
  applyCompatibleColumns(db);
@@ -265,7 +339,8 @@ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
265
339
  "SELECT 1 FROM migration_history WHERE to_version = '10.0' AND status = 'success' LIMIT 1",
266
340
  ).get();
267
341
  const needsContextMigration = Boolean(
268
- fromVersion && !['10.0', EXPECTED_VERSION].includes(fromVersion) && !contextMigration,
342
+ fromVersion && ![CONTEXT_SCHEMA_VERSION, BASELINE_SCHEMA_VERSION, EXPECTED_VERSION].includes(fromVersion)
343
+ && !contextMigration,
269
344
  );
270
345
  if (contextChanged || needsContextMigration) {
271
346
  db.prepare(
@@ -274,30 +349,63 @@ function applyCompatibleUpgrades(db, fromVersion = latestSchemaVersion(db)) {
274
349
  VALUES (?, ?, 'forward', 'success', ?)`,
275
350
  ).run(
276
351
  runtimeChanged ? KIMI_SCHEMA_VERSION : (fromVersion || 'unknown'),
277
- '10.0',
352
+ CONTEXT_SCHEMA_VERSION,
278
353
  'Add Context Spine role/gate readiness, execution contracts, breadcrumbs, and specification learning',
279
354
  );
280
355
  }
281
- applyBaselineUpgrade(db, fromVersion);
356
+ applyBaselineUpgrade(db, { legacyState });
357
+ applyBaselineColumns(db);
358
+ applyChangeColumns(db);
282
359
  const baselineMigration = db.prepare(
283
360
  "SELECT 1 FROM migration_history WHERE to_version = '11.0' AND status = 'success' LIMIT 1",
284
361
  ).get();
285
- if (fromVersion && fromVersion !== EXPECTED_VERSION && !baselineMigration) {
362
+ if (fromVersion && ![BASELINE_SCHEMA_VERSION, EXPECTED_VERSION].includes(fromVersion)
363
+ && !baselineMigration) {
286
364
  db.prepare(
287
365
  `INSERT INTO migration_history
288
366
  (from_version, to_version, direction, status, notes)
289
367
  VALUES (?, ?, 'forward', 'success', ?)`,
290
368
  ).run(
291
- fromVersion === '10.0' ? fromVersion : '10.0',
292
- EXPECTED_VERSION,
369
+ fromVersion === CONTEXT_SCHEMA_VERSION ? fromVersion : CONTEXT_SCHEMA_VERSION,
370
+ BASELINE_SCHEMA_VERSION,
293
371
  'Add authoritative greenfield and brownfield baseline adoption, evidence, convergence, and drift state',
294
372
  );
295
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
+ }
296
401
  db.exec('CREATE INDEX IF NOT EXISTS tasks_change ON tasks(change_id) WHERE change_id IS NOT NULL');
297
402
  db.exec('CREATE INDEX IF NOT EXISTS events_change ON events(change_id, id)');
298
403
  db.prepare(
299
404
  'INSERT OR IGNORE INTO schema_version (version, description) VALUES (?, ?)',
300
- ).run(EXPECTED_VERSION, 'Greenfield and brownfield baseline adoption, convergence, and drift authority');
405
+ ).run(
406
+ EXPECTED_VERSION,
407
+ 'Evidence-backed repository snapshots, gap ledger, safe re-adoption, and incident break-glass governance',
408
+ );
301
409
  })();
302
410
  }
303
411
 
@@ -317,23 +425,36 @@ function ensureSchemaVersion(db) {
317
425
  }
318
426
 
319
427
  function initStateDb(dbPath) {
320
- const db = openStateDb(dbPath);
321
- const existing = new Set(tableNames(db));
322
- const fromVersion = latestSchemaVersion(db);
323
- applyCompatibleColumns(db);
324
- const missing = REQUIRED_TABLES.filter((t) => !existing.has(t));
325
- if (missing.length > 0) {
326
- 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;
327
457
  }
328
- applyCompatibleUpgrades(db, fromVersion);
329
- const version = ensureSchemaVersion(db);
330
- return {
331
- db,
332
- path: dbPath,
333
- schema_version: version,
334
- created: missing.length > 0,
335
- tables: tableNames(db).sort(),
336
- };
337
458
  }
338
459
 
339
460
  function closeStateDb(db) {
@@ -355,4 +476,5 @@ module.exports = {
355
476
  closeStateDb,
356
477
  tableNames,
357
478
  runScript,
479
+ MIGRATED_GAPS,
358
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', '11.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,20 +275,23 @@ 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, '11.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', '11.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)));
159
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)));
160
292
  assert.deepEqual(
161
293
  upgraded.db.prepare("SELECT mode, status FROM baselines WHERE id = 'migrated-baseline'").get(),
162
- { mode: 'migrated', status: 'ready' },
294
+ { mode: 'migrated', status: 'adopting' },
163
295
  );
164
296
 
165
297
  upgraded.db.prepare(
@@ -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,
@@ -1,20 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // Phase 8A.3 — Expand a complex task into subtasks via injected llmClient.
4
- //
5
- // expandTask(db, { id, sub_count, strategy, llmClient }) reads the parent,
6
- // asks the LLM for children, and persists them atomically (children rows
7
- // with parent_id set, parent status → 'expanded'). The status transition
8
- // automatically emits task_expanded via state-ops' status→event map.
9
- // Strategy 'manual' is reserved for future CLI interactive flows.
3
+ // Persist host-derived child tasks atomically. Model judgment stays with the
4
+ // current host; this module validates ownership and the database contract.
10
5
 
11
6
  const Ajv = require('ajv/dist/2020');
12
7
  const ops = require('./state-ops.cjs');
13
- const {
14
- buildExpandSystemPrompt,
15
- buildExpandUserPrompt,
16
- } = require('../../orchestrator/planner/expand-prompt.cjs');
17
-
8
+ const changes = require('./change-workflow.cjs');
18
9
  const CHILDREN_SHAPE = Object.freeze({
19
10
  type: 'object',
20
11
  required: ['children'],
@@ -51,7 +42,9 @@ class TaskExpandError extends Error {
51
42
  }
52
43
  }
53
44
 
54
- async function expandTask(db, { id, sub_count, strategy = 'llm', llmClient } = {}) {
45
+ function expandTask(db, {
46
+ id, children, rootDir = process.cwd(),
47
+ } = {}) {
55
48
  if (!id) throw new TaskExpandError('VALIDATION_ERROR', 'id required');
56
49
 
57
50
  const parent = ops.readTask(db, id);
@@ -59,34 +52,16 @@ async function expandTask(db, { id, sub_count, strategy = 'llm', llmClient } = {
59
52
  if (parent.status === 'expanded') {
60
53
  throw new TaskExpandError('ALREADY_EXPANDED', `task ${id} is already expanded`);
61
54
  }
62
- if (strategy === 'manual') {
63
- throw new TaskExpandError('NOT_IMPLEMENTED', 'strategy "manual" not implemented; use "llm"');
64
- }
65
- if (strategy !== 'llm') {
66
- throw new TaskExpandError('VALIDATION_ERROR', `unknown strategy "${strategy}"`);
67
- }
68
- if (!llmClient || typeof llmClient.completeJson !== 'function') {
69
- throw new TaskExpandError('NO_LLM_CLIENT', 'llmClient.completeJson is required');
70
- }
71
-
72
- const system = buildExpandSystemPrompt({ sub_count });
73
- const user = buildExpandUserPrompt(parent);
74
-
75
- let reply;
76
- try {
77
- reply = await llmClient.completeJson({ system, user });
78
- } catch (err) {
79
- throw new TaskExpandError('LLM_CALL_FAILED', `LLM call failed: ${err.message}`, err);
80
- }
81
- if (!validateChildren(reply.json)) {
55
+ changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
56
+ if (!validateChildren({ children })) {
82
57
  throw new TaskExpandError(
83
58
  'INVALID_OUTPUT',
84
- `LLM output failed schema: ${ajv.errorsText(validateChildren.errors)}`,
59
+ `child task graph failed schema: ${ajv.errorsText(validateChildren.errors)}`,
85
60
  );
86
61
  }
87
62
 
88
63
  const seen = new Set();
89
- const children = reply.json.children.map((c) => {
64
+ const normalized = children.map((c) => {
90
65
  if (seen.has(c.id)) {
91
66
  throw new TaskExpandError('INVALID_OUTPUT', `duplicate child id "${c.id}"`);
92
67
  }
@@ -101,17 +76,19 @@ async function expandTask(db, { id, sub_count, strategy = 'llm', llmClient } = {
101
76
  files_modified: Array.isArray(c.files_modified) ? c.files_modified : [],
102
77
  parent_id: parent.id,
103
78
  tag: parent.tag,
79
+ change_id: parent.change_id,
104
80
  };
105
81
  });
106
82
 
107
83
  return ops.tx(db, () => {
108
- for (const child of children) {
84
+ changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
85
+ for (const child of normalized) {
109
86
  ops.createTask(db, child);
110
87
  }
111
88
  ops.patchTask(db, parent.id, { status: 'expanded' });
112
89
  return {
113
90
  parent_id: parent.id,
114
- children,
91
+ children: normalized,
115
92
  };
116
93
  });
117
94
  }