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.
- package/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/discovery.md +10 -0
- package/CHANGELOG.md +95 -1
- package/README.md +31 -16
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +21 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +10 -6
- package/docs/ARCHITECTURE.md +40 -17
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +2 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -299
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- 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 +404 -38
- package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
- package/mcp-server/lib/change-workflow.cjs +249 -75
- package/mcp-server/lib/change-workflow.test.cjs +235 -1
- package/mcp-server/lib/context-spine.test.cjs +5 -0
- package/mcp-server/lib/doctor.cjs +14 -1
- package/mcp-server/lib/doctor.test.cjs +42 -4
- package/mcp-server/lib/init-project.cjs +292 -9
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/spec-learning.test.cjs +1 -1
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +154 -32
- package/mcp-server/lib/state-db.test.cjs +136 -4
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +14 -37
- package/mcp-server/lib/task-expander.test.cjs +80 -164
- package/mcp-server/server.cjs +34 -37
- package/mcp-server/tests/init-project.test.cjs +162 -1
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +391 -17
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +18 -10
- package/skills/ultra-deliver/SKILL.md +13 -9
- package/skills/ultra-dev/SKILL.md +6 -4
- package/skills/ultra-doctor/SKILL.md +22 -7
- package/skills/ultra-init/SKILL.md +104 -59
- package/skills/ultra-init/references/brownfield-adoption.md +65 -37
- package/skills/ultra-plan/SKILL.md +20 -7
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +133 -19
- package/spec/schemas/state-db.sql +11 -0
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +18 -9
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +7 -1
- package/ultra-tools/status-command.test.cjs +29 -0
- package/ultra-tools/system-command.test.cjs +252 -1
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- 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 = '
|
|
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,
|
|
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 (
|
|
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,
|
|
65
|
-
VALUES ('migrated-baseline', 'Migrated Ultra project', 'migrated', '
|
|
66
|
-
'
|
|
67
|
-
|
|
68
|
-
|
|
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 && ![
|
|
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
|
-
|
|
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,
|
|
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 &&
|
|
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 ===
|
|
292
|
-
|
|
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(
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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, '
|
|
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: '
|
|
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
|
-
|
|
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,
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
`
|
|
59
|
+
`child task graph failed schema: ${ajv.errorsText(validateChildren.errors)}`,
|
|
85
60
|
);
|
|
86
61
|
}
|
|
87
62
|
|
|
88
63
|
const seen = new Set();
|
|
89
|
-
const
|
|
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
|
-
|
|
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
|
}
|