ultra-builder-pro-cli 0.12.1 → 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/discovery.md +10 -0
- package/CHANGELOG.md +53 -1
- package/README.md +25 -9
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/tests/codex.test.cjs +8 -0
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +4 -3
- package/docs/ARCHITECTURE.md +32 -16
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +1 -1
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/context_spine.py +37 -4
- 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/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 +7 -1
- package/mcp-server/lib/task-expander.test.cjs +71 -0
- package/mcp-server/server.cjs +14 -3
- 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 +384 -6
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +1 -1
- 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 +8 -5
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +95 -9
- 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
|
@@ -3,12 +3,17 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
initStateDb, openStateDb, closeStateDb, MIGRATED_GAPS,
|
|
8
|
+
} = require('../../mcp-server/lib/state-db.cjs');
|
|
7
9
|
const ops = require('../../mcp-server/lib/state-ops.cjs');
|
|
8
10
|
const projector = require('../../mcp-server/lib/projector.cjs');
|
|
9
11
|
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
+
const DEFAULT_FROM = '4.4';
|
|
13
|
+
const SUPPORTED_TRANSITIONS = Object.freeze({
|
|
14
|
+
'4.4': '4.5',
|
|
15
|
+
'4.5': '12.0',
|
|
16
|
+
});
|
|
12
17
|
|
|
13
18
|
// Frozen SQL — values flow through parameter bindings.
|
|
14
19
|
const RECORD_MIGRATION_SQL = "INSERT INTO migration_history (from_version, to_version, direction, status, notes) VALUES (@from, @to, @direction, @status, @notes)";
|
|
@@ -73,7 +78,7 @@ function copyDirSync(src, dst) {
|
|
|
73
78
|
const s = path.join(src, entry.name);
|
|
74
79
|
const d = path.join(dst, entry.name);
|
|
75
80
|
if (entry.name === 'state.db' || entry.name === 'state.db-wal' || entry.name === 'state.db-shm') continue;
|
|
76
|
-
if (entry.name
|
|
81
|
+
if (/^backup-v[^/]+-/.test(entry.name)) continue;
|
|
77
82
|
if (entry.isDirectory()) copyDirSync(s, d);
|
|
78
83
|
else fs.copyFileSync(s, d);
|
|
79
84
|
}
|
|
@@ -148,15 +153,15 @@ function normalizeLegacyTask(task, tasksJson, contextHeaders, sourceDir) {
|
|
|
148
153
|
};
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
function planForward(sourceDir) {
|
|
156
|
+
function planForward(sourceDir, expectedFrom = DEFAULT_FROM) {
|
|
152
157
|
const tasksPath = path.join(sourceDir, '.ultra', 'tasks', 'tasks.json');
|
|
153
158
|
const tasksJson = readJsonOptional(tasksPath);
|
|
154
159
|
if (!tasksJson || !Array.isArray(tasksJson.tasks)) {
|
|
155
160
|
throw new Error(`migrate: tasks.json missing or malformed at ${tasksPath}`);
|
|
156
161
|
}
|
|
157
162
|
const version = String(tasksJson.version || tasksJson.schema_version || '');
|
|
158
|
-
if (version !==
|
|
159
|
-
throw new Error(`migrate: expected v${
|
|
163
|
+
if (version !== expectedFrom) {
|
|
164
|
+
throw new Error(`migrate: expected v${expectedFrom} tasks.json, found ${version || '(missing version)'}`);
|
|
160
165
|
}
|
|
161
166
|
|
|
162
167
|
const contextHeaders = {};
|
|
@@ -173,7 +178,7 @@ function planForward(sourceDir) {
|
|
|
173
178
|
task_id: task.id,
|
|
174
179
|
json_status: task.status,
|
|
175
180
|
context_status: ctx.status,
|
|
176
|
-
resolution:
|
|
181
|
+
resolution: `tasks.json wins (v${expectedFrom} projection migration rule)`,
|
|
177
182
|
});
|
|
178
183
|
}
|
|
179
184
|
}
|
|
@@ -190,6 +195,7 @@ function planForward(sourceDir) {
|
|
|
190
195
|
events: eventList,
|
|
191
196
|
contextHeaders,
|
|
192
197
|
warnings,
|
|
198
|
+
projectName: tasksJson.project?.name || path.basename(sourceDir),
|
|
193
199
|
};
|
|
194
200
|
}
|
|
195
201
|
|
|
@@ -241,16 +247,17 @@ function recordMigration(db, { from, to, direction, status, notes }) {
|
|
|
241
247
|
db.prepare(RECORD_MIGRATION_SQL).run({ from, to, direction, status, notes });
|
|
242
248
|
}
|
|
243
249
|
|
|
244
|
-
function ensureBackupName(sourceDir) {
|
|
250
|
+
function ensureBackupName(sourceDir, fromVersion = DEFAULT_FROM) {
|
|
245
251
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
246
|
-
return path.join(sourceDir, '.ultra', `backup-
|
|
252
|
+
return path.join(sourceDir, '.ultra', `backup-v${fromVersion}-${ts}`);
|
|
247
253
|
}
|
|
248
254
|
|
|
249
|
-
function findLatestBackup(sourceDir) {
|
|
255
|
+
function findLatestBackup(sourceDir, fromVersion = DEFAULT_FROM) {
|
|
250
256
|
const ultra = path.join(sourceDir, '.ultra');
|
|
251
257
|
if (!fs.existsSync(ultra)) return null;
|
|
258
|
+
const prefix = `backup-v${fromVersion}-`;
|
|
252
259
|
const candidates = fs.readdirSync(ultra)
|
|
253
|
-
.filter((n) => n.startsWith(
|
|
260
|
+
.filter((n) => n.startsWith(prefix))
|
|
254
261
|
.sort();
|
|
255
262
|
return candidates.length === 0 ? null : path.join(ultra, candidates[candidates.length - 1]);
|
|
256
263
|
}
|
|
@@ -258,9 +265,11 @@ function findLatestBackup(sourceDir) {
|
|
|
258
265
|
function cmdForward(flags) {
|
|
259
266
|
const sourceDir = path.resolve(flags.sourceDir || '.');
|
|
260
267
|
const dbPath = path.resolve(flags.dbPath || path.join(sourceDir, '.ultra', 'state.db'));
|
|
268
|
+
const fromVersion = flags.from || DEFAULT_FROM;
|
|
269
|
+
const toVersion = flags.to || SUPPORTED_TRANSITIONS[fromVersion];
|
|
261
270
|
let plan;
|
|
262
271
|
try {
|
|
263
|
-
plan = planForward(sourceDir);
|
|
272
|
+
plan = planForward(sourceDir, fromVersion);
|
|
264
273
|
} catch (err) {
|
|
265
274
|
emit({ ok: false, error: { code: 'MIGRATE_FAILED', message: err.message, retriable: false } });
|
|
266
275
|
return 2;
|
|
@@ -271,8 +280,8 @@ function cmdForward(flags) {
|
|
|
271
280
|
ok: true,
|
|
272
281
|
data: {
|
|
273
282
|
mode: 'dry',
|
|
274
|
-
from:
|
|
275
|
-
to:
|
|
283
|
+
from: fromVersion,
|
|
284
|
+
to: toVersion,
|
|
276
285
|
source_dir: sourceDir,
|
|
277
286
|
db_path: dbPath,
|
|
278
287
|
tasks_to_insert: plan.tasks.length,
|
|
@@ -284,7 +293,7 @@ function cmdForward(flags) {
|
|
|
284
293
|
}
|
|
285
294
|
|
|
286
295
|
// Real run: backup the entire .ultra subtree first.
|
|
287
|
-
const backupDir = ensureBackupName(sourceDir);
|
|
296
|
+
const backupDir = ensureBackupName(sourceDir, fromVersion);
|
|
288
297
|
const ultraSource = path.join(sourceDir, '.ultra');
|
|
289
298
|
copyDirSync(ultraSource, backupDir);
|
|
290
299
|
|
|
@@ -297,9 +306,18 @@ function cmdForward(flags) {
|
|
|
297
306
|
throw new Error(`migrate: refusing to merge into non-empty state.db (tasks=${existing})`);
|
|
298
307
|
}
|
|
299
308
|
const inserted = applyForward(db, plan);
|
|
309
|
+
db.prepare(
|
|
310
|
+
`INSERT INTO baselines
|
|
311
|
+
(id, project_name, mode, status, gaps_json, approval_note)
|
|
312
|
+
VALUES ('migrated-baseline', ?, 'migrated', 'adopting', ?, ?)`,
|
|
313
|
+
).run(
|
|
314
|
+
plan.projectName,
|
|
315
|
+
JSON.stringify(MIGRATED_GAPS),
|
|
316
|
+
`Legacy v${fromVersion} projection imported; evidence-backed owner re-adoption is required`,
|
|
317
|
+
);
|
|
300
318
|
recordMigration(db, {
|
|
301
|
-
from:
|
|
302
|
-
to:
|
|
319
|
+
from: fromVersion,
|
|
320
|
+
to: toVersion,
|
|
303
321
|
direction: 'forward',
|
|
304
322
|
status: 'success',
|
|
305
323
|
notes: `tasks=${inserted.taskInserted} events=${inserted.eventsInserted} warnings=${plan.warnings.length}`,
|
|
@@ -312,8 +330,8 @@ function cmdForward(flags) {
|
|
|
312
330
|
ok: true,
|
|
313
331
|
data: {
|
|
314
332
|
mode: 'apply',
|
|
315
|
-
from:
|
|
316
|
-
to:
|
|
333
|
+
from: fromVersion,
|
|
334
|
+
to: toVersion,
|
|
317
335
|
source_dir: sourceDir,
|
|
318
336
|
db_path: dbPath,
|
|
319
337
|
backup_dir: backupDir,
|
|
@@ -328,8 +346,8 @@ function cmdForward(flags) {
|
|
|
328
346
|
if (db) {
|
|
329
347
|
try {
|
|
330
348
|
recordMigration(db, {
|
|
331
|
-
from:
|
|
332
|
-
to:
|
|
349
|
+
from: fromVersion,
|
|
350
|
+
to: toVersion,
|
|
333
351
|
direction: 'forward',
|
|
334
352
|
status: 'failed',
|
|
335
353
|
notes: err.message,
|
|
@@ -346,9 +364,11 @@ function cmdForward(flags) {
|
|
|
346
364
|
function cmdRollback(flags) {
|
|
347
365
|
const sourceDir = path.resolve(flags.sourceDir || '.');
|
|
348
366
|
const dbPath = path.resolve(flags.dbPath || path.join(sourceDir, '.ultra', 'state.db'));
|
|
349
|
-
const
|
|
367
|
+
const fromVersion = flags.from || DEFAULT_FROM;
|
|
368
|
+
const toVersion = flags.to || SUPPORTED_TRANSITIONS[fromVersion];
|
|
369
|
+
const backupDir = findLatestBackup(sourceDir, fromVersion);
|
|
350
370
|
if (!backupDir) {
|
|
351
|
-
emit({ ok: false, error: { code: 'NO_BACKUP', message:
|
|
371
|
+
emit({ ok: false, error: { code: 'NO_BACKUP', message: `no backup-v${fromVersion}-* directory found` } });
|
|
352
372
|
return 2;
|
|
353
373
|
}
|
|
354
374
|
|
|
@@ -358,8 +378,8 @@ function cmdRollback(flags) {
|
|
|
358
378
|
if (fs.existsSync(dbPath)) {
|
|
359
379
|
db = openStateDb(dbPath);
|
|
360
380
|
recordMigration(db, {
|
|
361
|
-
from:
|
|
362
|
-
to:
|
|
381
|
+
from: toVersion,
|
|
382
|
+
to: fromVersion,
|
|
363
383
|
direction: 'rollback',
|
|
364
384
|
status: 'success',
|
|
365
385
|
notes: `restored from ${backupDir}`,
|
|
@@ -376,7 +396,7 @@ function cmdRollback(flags) {
|
|
|
376
396
|
// Restore .ultra contents from backup, leaving the backup itself in place.
|
|
377
397
|
const ultraDir = path.join(sourceDir, '.ultra');
|
|
378
398
|
for (const entry of fs.readdirSync(ultraDir, { withFileTypes: true })) {
|
|
379
|
-
if (entry.name
|
|
399
|
+
if (/^backup-v[^/]+-/.test(entry.name)) continue;
|
|
380
400
|
const target = path.join(ultraDir, entry.name);
|
|
381
401
|
fs.rmSync(target, { recursive: true, force: true });
|
|
382
402
|
}
|
|
@@ -386,8 +406,8 @@ function cmdRollback(flags) {
|
|
|
386
406
|
ok: true,
|
|
387
407
|
data: {
|
|
388
408
|
mode: 'rollback',
|
|
389
|
-
from:
|
|
390
|
-
to:
|
|
409
|
+
from: toVersion,
|
|
410
|
+
to: fromVersion,
|
|
391
411
|
backup_dir: backupDir,
|
|
392
412
|
source_dir: sourceDir,
|
|
393
413
|
},
|
|
@@ -406,29 +426,35 @@ function dispatch(args) {
|
|
|
406
426
|
process.stdout.write(USAGE);
|
|
407
427
|
return 0;
|
|
408
428
|
}
|
|
409
|
-
|
|
410
|
-
|
|
429
|
+
const fromVersion = flags.from || DEFAULT_FROM;
|
|
430
|
+
const expectedTo = SUPPORTED_TRANSITIONS[fromVersion];
|
|
431
|
+
if (!expectedTo) {
|
|
432
|
+
emit({ ok: false, error: { code: 'UNSUPPORTED_VERSION', message: `--from ${fromVersion} unsupported (supported: ${Object.keys(SUPPORTED_TRANSITIONS).join(', ')})` } });
|
|
411
433
|
return 1;
|
|
412
434
|
}
|
|
413
|
-
if (flags.to && flags.to !==
|
|
414
|
-
emit({ ok: false, error: { code: 'UNSUPPORTED_VERSION', message: `--to ${flags.to} unsupported (
|
|
435
|
+
if (flags.to && flags.to !== expectedTo) {
|
|
436
|
+
emit({ ok: false, error: { code: 'UNSUPPORTED_VERSION', message: `--to ${flags.to} unsupported for --from ${fromVersion} (expected ${expectedTo})` } });
|
|
415
437
|
return 1;
|
|
416
438
|
}
|
|
417
439
|
return flags.rollback ? cmdRollback(flags) : cmdForward(flags);
|
|
418
440
|
}
|
|
419
441
|
|
|
420
|
-
const USAGE = `ultra-tools migrate --from
|
|
442
|
+
const USAGE = `ultra-tools migrate --from=<version> --to=<version> [flags]
|
|
443
|
+
|
|
444
|
+
Supported transitions:
|
|
445
|
+
4.4 -> 4.5 import the legacy task projection into authoritative state
|
|
446
|
+
4.5 -> 12.0 import a projection-only project into current authoritative state
|
|
421
447
|
|
|
422
448
|
Flags:
|
|
423
449
|
--source-dir <dir> project root containing .ultra/ (default: .)
|
|
424
450
|
--db-path <path> state.db destination (default: <source-dir>/.ultra/state.db)
|
|
425
451
|
--dry print the migration plan without writing
|
|
426
|
-
--rollback restore the most recent backup-
|
|
452
|
+
--rollback restore the most recent matching backup-v<from>-* and drop state.db
|
|
427
453
|
|
|
428
454
|
The forward flow: backup .ultra/ → init state.db → insert tasks from
|
|
429
455
|
tasks.json → merge context md status (tasks.json wins on conflict, warnings
|
|
430
456
|
recorded) → insert activity-log events → record migration_history.
|
|
431
|
-
Rollback restores from the latest backup-
|
|
457
|
+
Rollback restores from the latest matching backup-v<from>-* directory and writes a
|
|
432
458
|
matching migration_history rollback row before dropping state.db.
|
|
433
459
|
`;
|
|
434
460
|
|
|
@@ -71,17 +71,26 @@ function buildCostPanel(db, { since = null, limit = 3, rootDir = process.cwd() }
|
|
|
71
71
|
const total_cost_usd = by_runtime.reduce((acc, r) => acc + (r.cost_usd || 0), 0);
|
|
72
72
|
const baselineHealth = baselines.inspectBaseline(db, { rootDir });
|
|
73
73
|
const current = baselineHealth.baseline;
|
|
74
|
+
const baselineStatus = current?.status
|
|
75
|
+
|| (baselineHealth.blockers.includes('BASELINE_SCHEMA_MIGRATION_REQUIRED')
|
|
76
|
+
? 'migration_required' : 'missing');
|
|
77
|
+
const baseline = {
|
|
78
|
+
id: current?.id || null,
|
|
79
|
+
mode: current?.mode || null,
|
|
80
|
+
status: baselineStatus,
|
|
81
|
+
health: baselineHealth.status,
|
|
82
|
+
repository_revision: current?.repository_revision || null,
|
|
83
|
+
blockers: baselineHealth.blockers,
|
|
84
|
+
warnings: baselineHealth.warnings,
|
|
85
|
+
};
|
|
86
|
+
if (current) {
|
|
87
|
+
baseline.repository_branch = current.repository_branch || null;
|
|
88
|
+
baseline.worktree_state = current.worktree_state || 'unavailable';
|
|
89
|
+
baseline.gaps = baselines.summarizeGaps(current.gaps);
|
|
90
|
+
}
|
|
74
91
|
return {
|
|
75
92
|
period: { since: since || 'all-time' },
|
|
76
|
-
baseline
|
|
77
|
-
id: current?.id || null,
|
|
78
|
-
mode: current?.mode || null,
|
|
79
|
-
status: current?.status || 'missing',
|
|
80
|
-
health: baselineHealth.status,
|
|
81
|
-
repository_revision: current?.repository_revision || null,
|
|
82
|
-
blockers: baselineHealth.blockers,
|
|
83
|
-
warnings: baselineHealth.warnings,
|
|
84
|
-
},
|
|
93
|
+
baseline,
|
|
85
94
|
by_runtime,
|
|
86
95
|
top_tasks,
|
|
87
96
|
total_cost_usd,
|
|
@@ -2,16 +2,323 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
+
const Database = require('better-sqlite3');
|
|
5
6
|
|
|
6
|
-
const { openStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
|
|
7
|
+
const { initStateDb, openStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
|
|
8
|
+
const { initProject } = require('../../mcp-server/lib/init-project.cjs');
|
|
7
9
|
const doctor = require('../../mcp-server/lib/doctor.cjs');
|
|
8
10
|
|
|
9
|
-
const
|
|
11
|
+
const RESTORE_CONFIRMATION = 'REPLACE_CORRUPT_ULTRA_STATE';
|
|
12
|
+
const REBASELINE_CONFIRMATION = 'REBASELINE_CORRUPT_ULTRA_STATE';
|
|
13
|
+
const USAGE = `ultra-tools system <verb> [options]
|
|
14
|
+
|
|
15
|
+
VERBS:
|
|
16
|
+
doctor [--repair]
|
|
17
|
+
restore --backup <.ultra/backups/...db> --confirm ${RESTORE_CONFIRMATION}
|
|
18
|
+
rebaseline --project-name <name> [--scope <path>] --confirm ${REBASELINE_CONFIRMATION}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
class SystemCommandError extends Error {
|
|
22
|
+
constructor(code, message, exitCode = 1) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'SystemCommandError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.exitCode = exitCode;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
function emit(envelope) {
|
|
12
31
|
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
13
32
|
}
|
|
14
33
|
|
|
34
|
+
function stamp() {
|
|
35
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseOptions(args, allowed) {
|
|
39
|
+
const options = { scope: [] };
|
|
40
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
41
|
+
const arg = args[index];
|
|
42
|
+
if (arg === '-h' || arg === '--help') {
|
|
43
|
+
options.help = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const [flag, inline] = arg.startsWith('--') && arg.includes('=')
|
|
47
|
+
? arg.split(/=(.*)/s, 2)
|
|
48
|
+
: [arg, null];
|
|
49
|
+
const name = flag.startsWith('--') ? flag.slice(2) : null;
|
|
50
|
+
if (!name || !allowed.has(name)) {
|
|
51
|
+
throw new SystemCommandError('VALIDATION_ERROR', `unknown system option: ${arg}`);
|
|
52
|
+
}
|
|
53
|
+
const value = inline === null ? args[index += 1] : inline;
|
|
54
|
+
if (value === undefined || String(value).length === 0 || String(value).startsWith('--')) {
|
|
55
|
+
throw new SystemCommandError('VALIDATION_ERROR', `${flag} requires a value`);
|
|
56
|
+
}
|
|
57
|
+
if (name === 'scope') options.scope.push(value);
|
|
58
|
+
else options[name.replaceAll('-', '_')] = value;
|
|
59
|
+
}
|
|
60
|
+
return options;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function inspectSqlite(file) {
|
|
64
|
+
if (!fs.existsSync(file)) return { exists: false, valid: false, corrupt: false };
|
|
65
|
+
let db;
|
|
66
|
+
try {
|
|
67
|
+
db = new Database(file, { readonly: true, fileMustExist: true });
|
|
68
|
+
const integrity = db.pragma('integrity_check', { simple: true });
|
|
69
|
+
const hasSchema = Boolean(db.prepare(
|
|
70
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'",
|
|
71
|
+
).get());
|
|
72
|
+
return { exists: true, valid: integrity === 'ok' && hasSchema, corrupt: false, integrity, hasSchema };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const corrupt = /not a database|database disk image is malformed|file is encrypted|malformed/i
|
|
75
|
+
.test(String(error.message || ''));
|
|
76
|
+
if (!corrupt) throw error;
|
|
77
|
+
return { exists: true, valid: false, corrupt: true, error: error.message };
|
|
78
|
+
} finally {
|
|
79
|
+
closeStateDb(db);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function inspectSqliteSnapshot(file) {
|
|
84
|
+
if (!fs.existsSync(file)) return { exists: false, valid: false, corrupt: false };
|
|
85
|
+
const snapshotDir = fs.mkdtempSync(path.join(path.dirname(file), '.state-inspect-'));
|
|
86
|
+
const snapshotPath = path.join(snapshotDir, 'state.db');
|
|
87
|
+
try {
|
|
88
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
89
|
+
const source = `${file}${suffix}`;
|
|
90
|
+
if (fs.existsSync(source)) fs.copyFileSync(source, `${snapshotPath}${suffix}`);
|
|
91
|
+
}
|
|
92
|
+
return inspectSqlite(snapshotPath);
|
|
93
|
+
} finally {
|
|
94
|
+
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ensureCorruptState(rootDir) {
|
|
99
|
+
const statePath = path.join(rootDir, '.ultra', 'state.db');
|
|
100
|
+
const state = inspectSqliteSnapshot(statePath);
|
|
101
|
+
if (!state.exists) throw new SystemCommandError('STATE_DB_MISSING', `.ultra/state.db not found under ${rootDir}`, 2);
|
|
102
|
+
if (!state.corrupt) {
|
|
103
|
+
throw new SystemCommandError(
|
|
104
|
+
'STATE_DB_NOT_CORRUPT',
|
|
105
|
+
'state.db is readable; use system doctor --repair for supported recovery instead of replacing authority',
|
|
106
|
+
2,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return statePath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function managedBackup(rootDir, candidate) {
|
|
113
|
+
if (!candidate) throw new SystemCommandError('VALIDATION_ERROR', '--backup is required');
|
|
114
|
+
const backupRoot = path.join(rootDir, '.ultra', 'backups');
|
|
115
|
+
if (!fs.existsSync(backupRoot)) {
|
|
116
|
+
throw new SystemCommandError('BACKUP_NOT_FOUND', `managed backup directory does not exist: ${backupRoot}`);
|
|
117
|
+
}
|
|
118
|
+
const resolvedRoot = fs.realpathSync(backupRoot);
|
|
119
|
+
const requested = path.isAbsolute(candidate) ? candidate : path.resolve(rootDir, candidate);
|
|
120
|
+
let resolved;
|
|
121
|
+
try { resolved = fs.realpathSync(requested); }
|
|
122
|
+
catch {
|
|
123
|
+
throw new SystemCommandError('BACKUP_NOT_FOUND', `backup does not exist: ${requested}`);
|
|
124
|
+
}
|
|
125
|
+
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
126
|
+
throw new SystemCommandError(
|
|
127
|
+
'BACKUP_OUTSIDE_MANAGED_ROOT',
|
|
128
|
+
`backup must be inside ${backupRoot}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (!fs.statSync(resolved).isFile()) {
|
|
132
|
+
throw new SystemCommandError('BACKUP_NOT_FOUND', `backup is not a file: ${resolved}`);
|
|
133
|
+
}
|
|
134
|
+
const inspection = inspectSqlite(resolved);
|
|
135
|
+
if (!inspection.valid) {
|
|
136
|
+
throw new SystemCommandError('BACKUP_INVALID', `backup is not a verified Ultra SQLite database: ${resolved}`);
|
|
137
|
+
}
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createRecoveryDir(rootDir, prefix) {
|
|
142
|
+
const dir = path.join(rootDir, '.ultra', 'backups', `${prefix}-${stamp()}`);
|
|
143
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
144
|
+
fs.mkdirSync(dir, { recursive: false });
|
|
145
|
+
return dir;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function moveStateFiles(statePath, recoveryDir, moved = []) {
|
|
149
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
150
|
+
const source = `${statePath}${suffix}`;
|
|
151
|
+
if (!fs.existsSync(source)) continue;
|
|
152
|
+
const target = path.join(recoveryDir, `state.db${suffix}`);
|
|
153
|
+
fs.renameSync(source, target);
|
|
154
|
+
moved.push({ source, target });
|
|
155
|
+
}
|
|
156
|
+
return moved;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function restoreMovedFiles(moved) {
|
|
160
|
+
const errors = [];
|
|
161
|
+
for (const item of moved) {
|
|
162
|
+
try {
|
|
163
|
+
if (fs.existsSync(item.source)) fs.rmSync(item.source, { force: true });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
errors.push(`replacement cleanup failed for ${item.source}: ${error.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const item of moved) {
|
|
169
|
+
try {
|
|
170
|
+
if (fs.existsSync(item.target)) fs.renameSync(item.target, item.source);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
errors.push(`evidence restore failed for ${item.source}: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return errors;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function dispatchRestore(args) {
|
|
179
|
+
let options;
|
|
180
|
+
try { options = parseOptions(args, new Set(['backup', 'confirm'])); }
|
|
181
|
+
catch (error) { emit({ ok: false, error: { code: error.code, message: error.message } }); return error.exitCode || 1; }
|
|
182
|
+
if (options.help) { process.stdout.write(USAGE); return 0; }
|
|
183
|
+
if (options.confirm !== RESTORE_CONFIRMATION) {
|
|
184
|
+
emit({
|
|
185
|
+
ok: false,
|
|
186
|
+
error: {
|
|
187
|
+
code: 'CONFIRMATION_REQUIRED',
|
|
188
|
+
message: `restore requires --confirm ${RESTORE_CONFIRMATION}`,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
const rootDir = process.cwd();
|
|
194
|
+
let moved = [];
|
|
195
|
+
let activeState;
|
|
196
|
+
let staging;
|
|
197
|
+
try {
|
|
198
|
+
activeState = ensureCorruptState(rootDir);
|
|
199
|
+
const sourceBackup = managedBackup(rootDir, options.backup);
|
|
200
|
+
const recoveryDir = createRecoveryDir(rootDir, 'restore');
|
|
201
|
+
staging = path.join(rootDir, '.ultra', `.state.restore-${stamp()}.db`);
|
|
202
|
+
fs.copyFileSync(sourceBackup, staging, fs.constants.COPYFILE_EXCL);
|
|
203
|
+
moveStateFiles(activeState, recoveryDir, moved);
|
|
204
|
+
fs.renameSync(staging, activeState);
|
|
205
|
+
const initialized = initStateDb(activeState);
|
|
206
|
+
closeStateDb(initialized.db);
|
|
207
|
+
emit({
|
|
208
|
+
ok: true,
|
|
209
|
+
data: {
|
|
210
|
+
status: 'restored',
|
|
211
|
+
source_backup: sourceBackup,
|
|
212
|
+
recovery_backup_dir: recoveryDir,
|
|
213
|
+
quarantined_state_path: path.join(recoveryDir, 'state.db'),
|
|
214
|
+
schema_version: initialized.schema_version,
|
|
215
|
+
schema_migration_backup_path: initialized.backup_path || null,
|
|
216
|
+
next_action: 'Run ultra-tools system doctor, then ultra-init to inspect baseline readiness.',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
return 0;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (staging) {
|
|
222
|
+
try { fs.rmSync(staging, { force: true }); } catch { /* rollback continues */ }
|
|
223
|
+
}
|
|
224
|
+
const rollbackErrors = activeState ? restoreMovedFiles(moved) : [];
|
|
225
|
+
emit({
|
|
226
|
+
ok: false,
|
|
227
|
+
error: {
|
|
228
|
+
code: error instanceof SystemCommandError ? error.code : 'STATE_RESTORE_FAILED',
|
|
229
|
+
message: `${error.message}${rollbackErrors.length ? `; rollback errors: ${rollbackErrors.join('; ')}` : ''}`,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
return error.exitCode || 2;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function dispatchRebaseline(args) {
|
|
237
|
+
let options;
|
|
238
|
+
try { options = parseOptions(args, new Set(['project-name', 'scope', 'confirm'])); }
|
|
239
|
+
catch (error) { emit({ ok: false, error: { code: error.code, message: error.message } }); return error.exitCode || 1; }
|
|
240
|
+
if (options.help) { process.stdout.write(USAGE); return 0; }
|
|
241
|
+
if (options.confirm !== REBASELINE_CONFIRMATION) {
|
|
242
|
+
emit({
|
|
243
|
+
ok: false,
|
|
244
|
+
error: {
|
|
245
|
+
code: 'CONFIRMATION_REQUIRED',
|
|
246
|
+
message: `rebaseline requires --confirm ${REBASELINE_CONFIRMATION}`,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
if (!options.project_name) {
|
|
252
|
+
emit({ ok: false, error: { code: 'VALIDATION_ERROR', message: '--project-name is required' } });
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
const rootDir = process.cwd();
|
|
256
|
+
let moved = [];
|
|
257
|
+
let projectionMove = null;
|
|
258
|
+
let activeState;
|
|
259
|
+
try {
|
|
260
|
+
activeState = ensureCorruptState(rootDir);
|
|
261
|
+
const recoveryDir = createRecoveryDir(rootDir, 'rebaseline');
|
|
262
|
+
moveStateFiles(activeState, recoveryDir, moved);
|
|
263
|
+
const tasksDir = path.join(rootDir, '.ultra', 'tasks');
|
|
264
|
+
if (fs.existsSync(tasksDir)) {
|
|
265
|
+
const legacyTasksDir = path.join(recoveryDir, 'tasks');
|
|
266
|
+
projectionMove = { source: tasksDir, target: legacyTasksDir };
|
|
267
|
+
fs.renameSync(tasksDir, legacyTasksDir);
|
|
268
|
+
}
|
|
269
|
+
const initialized = initProject({
|
|
270
|
+
target_dir: rootDir,
|
|
271
|
+
project_name: options.project_name,
|
|
272
|
+
mode: 'brownfield',
|
|
273
|
+
scope: options.scope.length > 0 ? options.scope : undefined,
|
|
274
|
+
resume: true,
|
|
275
|
+
});
|
|
276
|
+
emit({
|
|
277
|
+
ok: true,
|
|
278
|
+
data: {
|
|
279
|
+
status: 'rebaseline_started',
|
|
280
|
+
recovery_backup_dir: recoveryDir,
|
|
281
|
+
quarantined_state_path: path.join(recoveryDir, 'state.db'),
|
|
282
|
+
legacy_projection_path: projectionMove
|
|
283
|
+
? path.join(projectionMove.target, 'tasks.json')
|
|
284
|
+
: null,
|
|
285
|
+
baseline: initialized.baseline,
|
|
286
|
+
copied_files: initialized.copied_files,
|
|
287
|
+
next_action: 'Inspect the preserved evidence, record the brownfield baseline, and obtain owner approval before convergence.',
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
return 0;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
const rollbackErrors = [];
|
|
293
|
+
if (moved.length > 0) {
|
|
294
|
+
const generatedState = activeState || path.join(rootDir, '.ultra', 'state.db');
|
|
295
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
296
|
+
try { fs.rmSync(`${generatedState}${suffix}`, { force: true }); }
|
|
297
|
+
catch (rollbackError) { rollbackErrors.push(`generated state cleanup failed: ${rollbackError.message}`); }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (projectionMove) {
|
|
301
|
+
try {
|
|
302
|
+
if (fs.existsSync(projectionMove.source)) {
|
|
303
|
+
fs.rmSync(projectionMove.source, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
if (fs.existsSync(projectionMove.target)) fs.renameSync(projectionMove.target, projectionMove.source);
|
|
306
|
+
} catch (rollbackError) {
|
|
307
|
+
rollbackErrors.push(`task projection restore failed: ${rollbackError.message}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
rollbackErrors.push(...restoreMovedFiles(moved));
|
|
311
|
+
emit({
|
|
312
|
+
ok: false,
|
|
313
|
+
error: {
|
|
314
|
+
code: error.code || 'STATE_REBASELINE_FAILED',
|
|
315
|
+
message: `${error.message}${rollbackErrors.length ? `; rollback errors: ${rollbackErrors.join('; ')}` : ''}`,
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
return error.exitCode || 2;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
15
322
|
async function dispatchDoctor(args) {
|
|
16
323
|
const unknown = args.filter((arg) => !['--repair', '-h', '--help'].includes(arg));
|
|
17
324
|
if (args.includes('-h') || args.includes('--help')) {
|
|
@@ -28,16 +335,33 @@ async function dispatchDoctor(args) {
|
|
|
28
335
|
emit({ ok: false, error: { code: 'STATE_DB_MISSING', message: `.ultra/state.db not found under ${rootDir}` } });
|
|
29
336
|
return 2;
|
|
30
337
|
}
|
|
31
|
-
|
|
338
|
+
let db;
|
|
32
339
|
try {
|
|
340
|
+
const repair = args.includes('--repair');
|
|
341
|
+
const initialized = repair ? initStateDb(dbPath) : null;
|
|
342
|
+
db = initialized ? initialized.db : openStateDb(dbPath);
|
|
33
343
|
const data = await doctor.runDoctor(db, { rootDir, repair: args.includes('--repair') });
|
|
344
|
+
if (initialized) {
|
|
345
|
+
data.schema_version = initialized.schema_version;
|
|
346
|
+
data.schema_migration_backup_path = initialized.backup_path || null;
|
|
347
|
+
}
|
|
34
348
|
emit({ ok: true, data });
|
|
35
349
|
return data.status === 'healthy' ? 0 : 2;
|
|
36
350
|
} catch (error) {
|
|
37
|
-
|
|
351
|
+
const corrupt = /not a database|database disk image is malformed|file is encrypted or is not a database|malformed/i
|
|
352
|
+
.test(String(error.message || ''));
|
|
353
|
+
emit({
|
|
354
|
+
ok: false,
|
|
355
|
+
error: {
|
|
356
|
+
code: corrupt ? 'STATE_DB_CORRUPT' : (error.code || 'STATE_DB_ERROR'),
|
|
357
|
+
message: corrupt
|
|
358
|
+
? `state.db is corrupt; after owner approval use system restore with a verified managed backup or system rebaseline to quarantine and rebuild authority: ${error.message}`
|
|
359
|
+
: error.message,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
38
362
|
return 2;
|
|
39
363
|
} finally {
|
|
40
|
-
closeStateDb(db);
|
|
364
|
+
if (db) closeStateDb(db);
|
|
41
365
|
}
|
|
42
366
|
}
|
|
43
367
|
|
|
@@ -47,6 +371,8 @@ async function dispatch(args) {
|
|
|
47
371
|
process.stdout.write(USAGE);
|
|
48
372
|
return 0;
|
|
49
373
|
}
|
|
374
|
+
if (verb === 'restore') return dispatchRestore(rest);
|
|
375
|
+
if (verb === 'rebaseline') return dispatchRebaseline(rest);
|
|
50
376
|
if (verb !== 'doctor') {
|
|
51
377
|
emit({ ok: false, error: { code: 'UNKNOWN_VERB', message: `unknown system verb: ${verb}` } });
|
|
52
378
|
return 1;
|
|
@@ -54,4 +380,12 @@ async function dispatch(args) {
|
|
|
54
380
|
return dispatchDoctor(rest);
|
|
55
381
|
}
|
|
56
382
|
|
|
57
|
-
module.exports = {
|
|
383
|
+
module.exports = {
|
|
384
|
+
USAGE,
|
|
385
|
+
RESTORE_CONFIRMATION,
|
|
386
|
+
REBASELINE_CONFIRMATION,
|
|
387
|
+
dispatch,
|
|
388
|
+
dispatchDoctor,
|
|
389
|
+
dispatchRestore,
|
|
390
|
+
dispatchRebaseline,
|
|
391
|
+
};
|