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.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/architecture.md +54 -223
- package/.ultra-template/specs/discovery.md +41 -228
- package/.ultra-template/specs/product.md +44 -200
- package/.ultra-template/tasks/contexts/TEMPLATE.md +36 -0
- package/.ultra-template/tasks/tasks.json +6 -0
- package/CHANGELOG.md +94 -1
- package/README.md +37 -9
- package/adapters/_shared/codex-assets.cjs +1 -1
- package/adapters/_shared/handbook.cjs +5 -4
- package/adapters/_shared/tests/handbook.test.cjs +4 -0
- package/adapters/opencode.js +8 -2
- package/adapters/tests/codex.test.cjs +10 -2
- package/adapters/tests/kimi-hook.test.cjs +1 -1
- package/adapters/tests/opencode.test.cjs +36 -1
- package/commands/ultra-init.md +3 -3
- package/docs/AGENT-CONTEXT.md +17 -8
- package/docs/ARCHITECTURE.md +51 -8
- package/docs/PLAN.zh-CN.md +9 -6
- package/docs/ROADMAP.md +5 -3
- package/docs/RUNTIME-COMPAT-MATRIX.md +8 -5
- package/docs/STATE-DB-ACCESS-POLICY.md +19 -13
- package/docs/USER-HANDBOOK-CONTRACT.md +9 -5
- package/hooks/README.md +4 -3
- package/hooks/adapters/codex.py +1 -1
- package/hooks/adapters/kimi.py +1 -1
- package/hooks/context_spine.py +153 -16
- package/hooks/health_check.py +11 -0
- package/hooks/pre_stop_check.py +6 -5
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +781 -0
- package/mcp-server/lib/baseline-workflow.test.cjs +477 -0
- package/mcp-server/lib/change-workflow.cjs +256 -85
- package/mcp-server/lib/change-workflow.test.cjs +436 -6
- package/mcp-server/lib/context-spine.cjs +74 -9
- package/mcp-server/lib/context-spine.test.cjs +20 -0
- package/mcp-server/lib/doctor.cjs +28 -1
- package/mcp-server/lib/doctor.test.cjs +63 -1
- package/mcp-server/lib/init-project.cjs +334 -16
- package/mcp-server/lib/provider-refs.cjs +42 -0
- package/mcp-server/lib/spec-learning.test.cjs +5 -0
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +202 -21
- package/mcp-server/lib/state-db.test.cjs +140 -3
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +62 -6
- package/mcp-server/tests/init-project.test.cjs +216 -9
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +478 -3
- package/mcp-server/tests/state-db.test.cjs +23 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +32 -13
- package/skills/ultra-deliver/SKILL.md +19 -5
- package/skills/ultra-dev/SKILL.md +8 -2
- package/skills/ultra-doctor/SKILL.md +30 -9
- package/skills/ultra-init/SKILL.md +107 -66
- package/skills/ultra-init/references/brownfield-adoption.md +93 -0
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-research/SKILL.md +17 -5
- package/skills/ultra-status/SKILL.md +15 -6
- package/spec/cli-protocol.md +15 -1
- package/spec/mcp-tools.yaml +316 -11
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +44 -2
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +29 -2
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +11 -2
- package/ultra-tools/status-command.test.cjs +49 -0
- 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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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, '
|
|
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
|
-
|
|
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'
|
|
204
|
-
|
|
205
|
-
|
|
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, {
|
|
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
|
+
});
|