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
package/mcp-server/server.cjs
CHANGED
|
@@ -28,6 +28,7 @@ const parser = require('./lib/prd-parser.cjs');
|
|
|
28
28
|
const expander = require('./lib/task-expander.cjs');
|
|
29
29
|
const planStore = require('./lib/plan-store.cjs');
|
|
30
30
|
const { initProject } = require('./lib/init-project.cjs');
|
|
31
|
+
const baselines = require('./lib/baseline-workflow.cjs');
|
|
31
32
|
const changes = require('./lib/change-workflow.cjs');
|
|
32
33
|
const runtimeState = require('./lib/runtime-state.cjs');
|
|
33
34
|
const doctor = require('./lib/doctor.cjs');
|
|
@@ -67,6 +68,13 @@ const PLAN_TOOLS = Object.freeze([
|
|
|
67
68
|
'plan.get',
|
|
68
69
|
]);
|
|
69
70
|
|
|
71
|
+
const BASELINE_TOOLS = Object.freeze([
|
|
72
|
+
'baseline.start',
|
|
73
|
+
'baseline.record',
|
|
74
|
+
'baseline.get',
|
|
75
|
+
'baseline.converge',
|
|
76
|
+
]);
|
|
77
|
+
|
|
70
78
|
const CHANGE_TOOLS = Object.freeze([
|
|
71
79
|
'change.create',
|
|
72
80
|
'change.update',
|
|
@@ -85,18 +93,20 @@ const SYSTEM_TOOLS = Object.freeze([
|
|
|
85
93
|
]);
|
|
86
94
|
|
|
87
95
|
const REGISTERED_TOOLS = Object.freeze([
|
|
88
|
-
...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...
|
|
96
|
+
...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...BASELINE_TOOLS, ...CHANGE_TOOLS,
|
|
97
|
+
...SYSTEM_TOOLS,
|
|
89
98
|
]);
|
|
90
99
|
|
|
91
100
|
const STATELESS_TOOLS = new Set(['task.init_project']);
|
|
92
101
|
|
|
93
|
-
// init_project
|
|
94
|
-
//
|
|
102
|
+
// init_project owns its target project's schema, baseline seed, and first
|
|
103
|
+
// projection internally. Do not enqueue a second projection on the caller DB.
|
|
95
104
|
const MUTATING_TOOLS = new Set([
|
|
96
105
|
'task.create', 'task.update', 'task.delete', 'task.append_event', 'task.switch_tag',
|
|
97
106
|
'task.parse_prd', 'task.expand',
|
|
98
107
|
'session.spawn', 'session.close', 'session.heartbeat',
|
|
99
108
|
'plan.export',
|
|
109
|
+
'baseline.start', 'baseline.record', 'baseline.converge',
|
|
100
110
|
'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
|
|
101
111
|
'change.learning_propose', 'change.learning_resolve',
|
|
102
112
|
]);
|
|
@@ -138,7 +148,10 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
138
148
|
case 'task.create': {
|
|
139
149
|
const { randomUUID } = require('node:crypto');
|
|
140
150
|
const id = input.id || `task-${randomUUID()}`;
|
|
141
|
-
const
|
|
151
|
+
const changeId = ops.resolveTaskCreationChangeId(db, input);
|
|
152
|
+
const authorizedInput = { ...input, change_id: changeId };
|
|
153
|
+
changes.assertTaskCreationAllowed(db, authorizedInput, { rootDir: ctx.rootDir || process.cwd() });
|
|
154
|
+
const task = ops.createTask(db, { ...authorizedInput, id });
|
|
142
155
|
return { id: task.id, status: task.status, created_at: task.created_at };
|
|
143
156
|
}
|
|
144
157
|
case 'task.update': {
|
|
@@ -276,20 +289,25 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
276
289
|
err.code = 'NO_INPUT';
|
|
277
290
|
throw err;
|
|
278
291
|
}
|
|
292
|
+
const dryRun = input.dry_run === true;
|
|
293
|
+
const rootDir = ctx.rootDir || process.cwd();
|
|
294
|
+
const creationAuthority = { change_id: input.change_id };
|
|
295
|
+
if (!dryRun) changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
|
|
279
296
|
const client = buildLlmClient(ctx);
|
|
280
297
|
const parsed = await parser.parsePrd(prdText, { llmClient: client, tag: input.tag });
|
|
281
298
|
const shaped = parsed.tasks.map((t) => ({
|
|
282
299
|
id: t.id, title: t.title, type: t.type, priority: t.priority,
|
|
283
300
|
complexity: t.complexity, deps: t.deps, files_modified: t.files_modified,
|
|
284
|
-
tag: t.tag,
|
|
301
|
+
tag: t.tag, change_id: input.change_id,
|
|
285
302
|
}));
|
|
286
|
-
const dryRun = input.dry_run === true;
|
|
287
303
|
if (!dryRun) {
|
|
288
304
|
try {
|
|
289
305
|
ops.tx(db, () => {
|
|
306
|
+
changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
|
|
290
307
|
for (const t of shaped) ops.createTask(db, t);
|
|
291
308
|
});
|
|
292
309
|
} catch (err) {
|
|
310
|
+
if (['BASELINE_NOT_READY', 'CHANGE_NOT_FOUND', 'CHANGE_NOT_MUTABLE'].includes(err.code)) throw err;
|
|
293
311
|
const wrap = new Error(`failed to persist parsed tasks: ${err.message}`);
|
|
294
312
|
wrap.code = 'PARSE_FAILED';
|
|
295
313
|
wrap.cause = err;
|
|
@@ -314,12 +332,15 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
314
332
|
err.code = 'ALREADY_EXPANDED';
|
|
315
333
|
throw err;
|
|
316
334
|
}
|
|
335
|
+
const rootDir = ctx.rootDir || process.cwd();
|
|
336
|
+
changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
|
|
317
337
|
const client = buildLlmClient(ctx);
|
|
318
338
|
const result = await expander.expandTask(db, {
|
|
319
339
|
id: input.id,
|
|
320
340
|
sub_count: input.sub_count,
|
|
321
341
|
strategy: input.strategy,
|
|
322
342
|
llmClient: client,
|
|
343
|
+
rootDir,
|
|
323
344
|
});
|
|
324
345
|
return { parent_id: result.parent_id, children: result.children };
|
|
325
346
|
}
|
|
@@ -352,6 +373,39 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
352
373
|
}
|
|
353
374
|
return { plan: planStore.selectSection(loaded, input.section) };
|
|
354
375
|
}
|
|
376
|
+
case 'baseline.start': {
|
|
377
|
+
return {
|
|
378
|
+
baseline: baselines.startBaseline(
|
|
379
|
+
db, input, { rootDir: ctx.rootDir || process.cwd() },
|
|
380
|
+
),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
case 'baseline.record': {
|
|
384
|
+
return {
|
|
385
|
+
baseline: baselines.recordBaseline(
|
|
386
|
+
db, input, { rootDir: ctx.rootDir || process.cwd() },
|
|
387
|
+
),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
case 'baseline.get': {
|
|
391
|
+
const baseline = baselines.readBaseline(db, input.id);
|
|
392
|
+
if (!baseline) {
|
|
393
|
+
const err = new Error(input.id ? `baseline ${input.id} not found` : 'current baseline not found');
|
|
394
|
+
err.code = 'BASELINE_NOT_FOUND';
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
baseline,
|
|
399
|
+
health: baselines.inspectBaseline(db, {
|
|
400
|
+
rootDir: ctx.rootDir || process.cwd(), id: input.id,
|
|
401
|
+
}),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
case 'baseline.converge': {
|
|
405
|
+
return baselines.convergeBaseline(
|
|
406
|
+
db, input, { rootDir: ctx.rootDir || process.cwd() },
|
|
407
|
+
);
|
|
408
|
+
}
|
|
355
409
|
case 'change.create': {
|
|
356
410
|
const { randomUUID } = require('node:crypto');
|
|
357
411
|
const id = input.id || `chg-${randomUUID().slice(0, 12)}`;
|
|
@@ -598,6 +652,8 @@ module.exports = {
|
|
|
598
652
|
dispatchTool,
|
|
599
653
|
TASK_TOOLS,
|
|
600
654
|
SESSION_TOOLS,
|
|
655
|
+
PLAN_TOOLS,
|
|
656
|
+
BASELINE_TOOLS,
|
|
601
657
|
CHANGE_TOOLS,
|
|
602
658
|
SYSTEM_TOOLS,
|
|
603
659
|
REGISTERED_TOOLS,
|
|
@@ -5,9 +5,13 @@ const assert = require('node:assert/strict');
|
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const os = require('node:os');
|
|
7
7
|
const path = require('node:path');
|
|
8
|
+
const { execFileSync } = require('node:child_process');
|
|
8
9
|
const Database = require('better-sqlite3');
|
|
9
10
|
|
|
10
|
-
const {
|
|
11
|
+
const {
|
|
12
|
+
initProject, InitProjectError, DEFAULT_TEMPLATE, classifyRepository,
|
|
13
|
+
} = require('../lib/init-project.cjs');
|
|
14
|
+
const { EXPECTED_VERSION } = require('../lib/state-db.cjs');
|
|
11
15
|
|
|
12
16
|
function mkTempDir(prefix = 'ubp-init-test-') {
|
|
13
17
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
@@ -27,6 +31,7 @@ test('initProject copies bundled template into .ultra/', () => {
|
|
|
27
31
|
stack: 'node',
|
|
28
32
|
});
|
|
29
33
|
assert.equal(r.status, 'created');
|
|
34
|
+
assert.equal(r.mode, 'greenfield');
|
|
30
35
|
assert.equal(r.created_path, path.join(target, '.ultra'));
|
|
31
36
|
assert.ok(r.copied_files.includes('tasks/tasks.json'));
|
|
32
37
|
assert.ok(r.copied_files.includes('specs/product.md'));
|
|
@@ -36,9 +41,11 @@ test('initProject copies bundled template into .ultra/', () => {
|
|
|
36
41
|
assert.ok(fs.existsSync(r.state_db_path));
|
|
37
42
|
const db = new Database(r.state_db_path, { readonly: true });
|
|
38
43
|
try {
|
|
39
|
-
const version = db.prepare(
|
|
40
|
-
assert.equal(version.version,
|
|
44
|
+
const version = db.prepare('SELECT version FROM schema_version WHERE version = ?').get(EXPECTED_VERSION);
|
|
45
|
+
assert.equal(version.version, EXPECTED_VERSION);
|
|
41
46
|
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE name = 'changes'").get());
|
|
47
|
+
const baseline = db.prepare('SELECT mode, status, project_name FROM baselines').get();
|
|
48
|
+
assert.deepEqual(baseline, { mode: 'greenfield', status: 'draft', project_name: 'demo' });
|
|
42
49
|
} finally {
|
|
43
50
|
db.close();
|
|
44
51
|
}
|
|
@@ -46,7 +53,121 @@ test('initProject copies bundled template into .ultra/', () => {
|
|
|
46
53
|
} finally { cleanup(target); }
|
|
47
54
|
});
|
|
48
55
|
|
|
49
|
-
test('initProject
|
|
56
|
+
test('initProject auto-detects existing source as brownfield and starts adoption', () => {
|
|
57
|
+
const target = mkTempDir();
|
|
58
|
+
try {
|
|
59
|
+
fs.mkdirSync(path.join(target, 'src'));
|
|
60
|
+
fs.writeFileSync(path.join(target, 'package.json'), '{"name":"legacy-app"}\n');
|
|
61
|
+
fs.writeFileSync(path.join(target, 'src', 'index.js'), 'module.exports = true;\n');
|
|
62
|
+
const result = initProject({ target_dir: target, project_name: 'legacy-app' });
|
|
63
|
+
assert.equal(result.mode, 'brownfield');
|
|
64
|
+
const db = new Database(result.state_db_path, { readonly: true });
|
|
65
|
+
try {
|
|
66
|
+
const baseline = db.prepare('SELECT mode, status, scope_json FROM baselines').get();
|
|
67
|
+
assert.equal(baseline.mode, 'brownfield');
|
|
68
|
+
assert.equal(baseline.status, 'adopting');
|
|
69
|
+
assert.deepEqual(JSON.parse(baseline.scope_json), ['.']);
|
|
70
|
+
} finally {
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
} finally { cleanup(target); }
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('auto classification keeps a documentation-and-manifest-only skeleton greenfield', () => {
|
|
77
|
+
const target = mkTempDir();
|
|
78
|
+
try {
|
|
79
|
+
fs.writeFileSync(path.join(target, 'README.md'), '# New service skeleton\n');
|
|
80
|
+
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
|
|
81
|
+
name: 'new-service', private: true, scripts: { test: 'node --test' },
|
|
82
|
+
}));
|
|
83
|
+
const profile = classifyRepository(target);
|
|
84
|
+
assert.equal(profile.mode, 'greenfield');
|
|
85
|
+
assert.equal(profile.repository_kind, 'single');
|
|
86
|
+
assert.deepEqual(profile.source_signals, []);
|
|
87
|
+
|
|
88
|
+
const result = initProject({ target_dir: target, project_name: 'new-service' });
|
|
89
|
+
assert.equal(result.mode, 'greenfield');
|
|
90
|
+
assert.deepEqual(result.repository_profile.verification_commands, ['npm test']);
|
|
91
|
+
} finally { cleanup(target); }
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('auto classification records bounded monorepo evidence for brownfield adoption', () => {
|
|
95
|
+
const target = mkTempDir();
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(path.join(target, 'packages', 'api', 'src'), { recursive: true });
|
|
98
|
+
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
|
|
99
|
+
name: 'legacy-suite', private: true, workspaces: ['packages/*'],
|
|
100
|
+
scripts: { build: 'turbo build', test: 'turbo test', lint: 'turbo lint' },
|
|
101
|
+
}));
|
|
102
|
+
fs.writeFileSync(path.join(target, 'packages', 'api', 'package.json'), '{"name":"api"}\n');
|
|
103
|
+
fs.writeFileSync(path.join(target, 'packages', 'api', 'src', 'index.js'), 'module.exports = true;\n');
|
|
104
|
+
fs.writeFileSync(path.join(target, 'Dockerfile'), 'FROM node:22\n');
|
|
105
|
+
|
|
106
|
+
const result = initProject({ target_dir: target, project_name: 'legacy-suite' });
|
|
107
|
+
assert.equal(result.mode, 'brownfield');
|
|
108
|
+
assert.equal(result.repository_profile.repository_kind, 'monorepo');
|
|
109
|
+
assert.deepEqual(result.repository_profile.workspace_roots, ['packages/api']);
|
|
110
|
+
assert.ok(result.repository_profile.source_signals.includes('packages/api/src/index.js'));
|
|
111
|
+
assert.ok(result.repository_profile.deployment_signals.includes('Dockerfile'));
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
result.repository_profile.verification_commands,
|
|
114
|
+
['npm run build', 'npm test', 'npm run lint'],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const db = new Database(result.state_db_path, { readonly: true });
|
|
118
|
+
try {
|
|
119
|
+
const row = db.prepare(
|
|
120
|
+
'SELECT repository_branch, worktree_state, classification_json FROM baselines',
|
|
121
|
+
).get();
|
|
122
|
+
assert.equal(row.repository_branch, null);
|
|
123
|
+
assert.equal(row.worktree_state, 'unavailable');
|
|
124
|
+
assert.equal(JSON.parse(row.classification_json).repository_kind, 'monorepo');
|
|
125
|
+
} finally { db.close(); }
|
|
126
|
+
} finally { cleanup(target); }
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('brownfield initialization persists an explicitly selected monorepo scope', () => {
|
|
130
|
+
const target = mkTempDir();
|
|
131
|
+
try {
|
|
132
|
+
fs.mkdirSync(path.join(target, 'packages', 'api', 'src'), { recursive: true });
|
|
133
|
+
fs.mkdirSync(path.join(target, 'packages', 'web', 'src'), { recursive: true });
|
|
134
|
+
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
|
|
135
|
+
name: 'legacy-suite', private: true, workspaces: ['packages/*'],
|
|
136
|
+
}));
|
|
137
|
+
fs.writeFileSync(path.join(target, 'packages', 'api', 'src', 'index.js'), 'module.exports = true;\n');
|
|
138
|
+
fs.writeFileSync(path.join(target, 'packages', 'web', 'src', 'index.js'), 'module.exports = true;\n');
|
|
139
|
+
|
|
140
|
+
const result = initProject({
|
|
141
|
+
target_dir: target,
|
|
142
|
+
project_name: 'legacy-suite',
|
|
143
|
+
scope: ['packages/api'],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const db = new Database(result.state_db_path, { readonly: true });
|
|
147
|
+
try {
|
|
148
|
+
const baseline = db.prepare('SELECT scope_json FROM baselines').get();
|
|
149
|
+
assert.deepEqual(JSON.parse(baseline.scope_json), ['packages/api']);
|
|
150
|
+
assert.deepEqual(result.baseline.scope, ['packages/api']);
|
|
151
|
+
} finally { db.close(); }
|
|
152
|
+
} finally { cleanup(target); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('maintenance CLI forwards an explicit initialization mode', () => {
|
|
156
|
+
const target = mkTempDir();
|
|
157
|
+
try {
|
|
158
|
+
fs.writeFileSync(path.join(target, 'README.md'), '# Intentional greenfield notes\n');
|
|
159
|
+
const output = execFileSync(process.execPath, [
|
|
160
|
+
path.resolve(__dirname, '..', '..', 'ultra-tools', 'cli.cjs'),
|
|
161
|
+
'task', 'init-project', '--target-dir', target, '--project-name', 'explicit-mode',
|
|
162
|
+
'--mode', 'greenfield',
|
|
163
|
+
], { encoding: 'utf8' });
|
|
164
|
+
const envelope = JSON.parse(output);
|
|
165
|
+
assert.equal(envelope.ok, true);
|
|
166
|
+
assert.equal(envelope.data.mode, 'greenfield');
|
|
167
|
+
} finally { cleanup(target); }
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('initProject keeps project metadata in authoritative baseline state and tasks.json as a projection', () => {
|
|
50
171
|
const target = mkTempDir();
|
|
51
172
|
try {
|
|
52
173
|
initProject({
|
|
@@ -56,14 +177,39 @@ test('initProject injects project metadata into tasks.json', () => {
|
|
|
56
177
|
stack: 'next',
|
|
57
178
|
});
|
|
58
179
|
const tasksJson = JSON.parse(fs.readFileSync(path.join(target, '.ultra', 'tasks', 'tasks.json'), 'utf8'));
|
|
59
|
-
assert.equal(tasksJson.
|
|
60
|
-
assert.equal(tasksJson.
|
|
61
|
-
assert.
|
|
62
|
-
assert.
|
|
63
|
-
|
|
180
|
+
assert.equal(tasksJson.schema_version, '4.5');
|
|
181
|
+
assert.equal(tasksJson.source, '.ultra/state.db');
|
|
182
|
+
assert.deepEqual(tasksJson.tasks, []);
|
|
183
|
+
assert.equal('project' in tasksJson, false);
|
|
184
|
+
const db = new Database(path.join(target, '.ultra', 'state.db'), { readonly: true });
|
|
185
|
+
try {
|
|
186
|
+
const baseline = db.prepare('SELECT project_name, project_type, stack FROM baselines').get();
|
|
187
|
+
assert.deepEqual(baseline, { project_name: 'meta-demo', project_type: 'web', stack: 'next' });
|
|
188
|
+
} finally {
|
|
189
|
+
db.close();
|
|
190
|
+
}
|
|
64
191
|
} finally { cleanup(target); }
|
|
65
192
|
});
|
|
66
193
|
|
|
194
|
+
test('initProject records the declared project_initialized lifecycle event', () => {
|
|
195
|
+
const rootDir = mkTempDir();
|
|
196
|
+
try {
|
|
197
|
+
const result = initProject({ target_dir: rootDir, project_name: 'events-fixture' });
|
|
198
|
+
const db = new Database(result.state_db_path, { readonly: true });
|
|
199
|
+
try {
|
|
200
|
+
const events = db.prepare('SELECT type, payload_json FROM events ORDER BY id').all();
|
|
201
|
+
assert.deepEqual(events.map((row) => row.type), ['baseline_started', 'project_initialized']);
|
|
202
|
+
assert.deepEqual(JSON.parse(events[1].payload_json), {
|
|
203
|
+
project_name: 'events-fixture', mode: 'greenfield', baseline_id: 'project-baseline',
|
|
204
|
+
});
|
|
205
|
+
} finally {
|
|
206
|
+
db.close();
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
67
213
|
test('initProject refuses when .ultra/ already exists and overwrite=false', () => {
|
|
68
214
|
const target = mkTempDir();
|
|
69
215
|
try {
|
|
@@ -75,6 +221,67 @@ test('initProject refuses when .ultra/ already exists and overwrite=false', () =
|
|
|
75
221
|
} finally { cleanup(target); }
|
|
76
222
|
});
|
|
77
223
|
|
|
224
|
+
test('initProject resume preserves existing artifacts and installs only missing current scaffold files', () => {
|
|
225
|
+
const target = mkTempDir();
|
|
226
|
+
try {
|
|
227
|
+
fs.mkdirSync(path.join(target, '.ultra', 'specs'), { recursive: true });
|
|
228
|
+
fs.writeFileSync(path.join(target, '.ultra', 'specs', 'product.md'), '# Existing product contract\n');
|
|
229
|
+
|
|
230
|
+
const result = initProject({
|
|
231
|
+
target_dir: target, project_name: 'existing-project', mode: 'brownfield', resume: true,
|
|
232
|
+
});
|
|
233
|
+
assert.equal(result.status, 'resumed');
|
|
234
|
+
assert.equal(
|
|
235
|
+
fs.readFileSync(path.join(target, '.ultra', 'specs', 'product.md'), 'utf8'),
|
|
236
|
+
'# Existing product contract\n',
|
|
237
|
+
);
|
|
238
|
+
assert.ok(fs.existsSync(path.join(target, '.ultra', 'specs', 'architecture.md')));
|
|
239
|
+
assert.ok(result.copied_files.includes('specs/architecture.md'));
|
|
240
|
+
assert.equal(result.copied_files.includes('specs/product.md'), false);
|
|
241
|
+
|
|
242
|
+
const second = initProject({
|
|
243
|
+
target_dir: target, project_name: 'existing-project', resume: true,
|
|
244
|
+
});
|
|
245
|
+
assert.equal(second.status, 'resumed');
|
|
246
|
+
assert.deepEqual(second.copied_files, []);
|
|
247
|
+
const db = new Database(result.state_db_path, { readonly: true });
|
|
248
|
+
try {
|
|
249
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM baselines').get().count, 1);
|
|
250
|
+
} finally { db.close(); }
|
|
251
|
+
} finally { cleanup(target); }
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('initProject resume refuses projection-only tasks until the supported import runs', () => {
|
|
255
|
+
const target = mkTempDir();
|
|
256
|
+
try {
|
|
257
|
+
fs.mkdirSync(path.join(target, '.ultra', 'tasks'), { recursive: true });
|
|
258
|
+
fs.writeFileSync(path.join(target, '.ultra', 'tasks', 'tasks.json'), JSON.stringify({
|
|
259
|
+
schema_version: '4.5', source: '.ultra/state.db',
|
|
260
|
+
tasks: [{ id: 'legacy', title: 'Legacy task', type: 'feature', priority: 'P1', status: 'pending' }],
|
|
261
|
+
}));
|
|
262
|
+
assert.throws(
|
|
263
|
+
() => initProject({ target_dir: target, project_name: 'legacy', resume: true }),
|
|
264
|
+
(error) => error.code === 'LEGACY_STATE_MIGRATION_REQUIRED'
|
|
265
|
+
&& /--from=4\.5 --to=12\.0/.test(error.message),
|
|
266
|
+
);
|
|
267
|
+
assert.equal(fs.existsSync(path.join(target, '.ultra', 'state.db')), false);
|
|
268
|
+
assert.equal(fs.existsSync(path.join(target, '.ultra', 'specs', 'product.md')), false);
|
|
269
|
+
} finally { cleanup(target); }
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('initProject rejects conflicting resume and overwrite modes', () => {
|
|
273
|
+
const target = mkTempDir();
|
|
274
|
+
try {
|
|
275
|
+
fs.mkdirSync(path.join(target, '.ultra'));
|
|
276
|
+
assert.throws(
|
|
277
|
+
() => initProject({
|
|
278
|
+
target_dir: target, project_name: 'conflict', resume: true, overwrite: true,
|
|
279
|
+
}),
|
|
280
|
+
(error) => error.code === 'VALIDATION_ERROR' && /resume.*overwrite/i.test(error.message),
|
|
281
|
+
);
|
|
282
|
+
} finally { cleanup(target); }
|
|
283
|
+
});
|
|
284
|
+
|
|
78
285
|
test('initProject with overwrite=true backs up existing .ultra/', () => {
|
|
79
286
|
const target = mkTempDir();
|
|
80
287
|
try {
|
|
@@ -122,6 +122,39 @@ test('migrate forward inserts tasks + events, records migration_history, creates
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('migrate imports a v4.5 projection into schema 12 authority and requires evidence-backed re-adoption', () => {
|
|
126
|
+
const dir = tmpProject();
|
|
127
|
+
try {
|
|
128
|
+
const tasksPath = path.join(dir, '.ultra', 'tasks', 'tasks.json');
|
|
129
|
+
const projection = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
|
|
130
|
+
delete projection.version;
|
|
131
|
+
projection.schema_version = '4.5';
|
|
132
|
+
projection.source = '.ultra/state.db';
|
|
133
|
+
fs.writeFileSync(tasksPath, `${JSON.stringify(projection, null, 2)}\n`);
|
|
134
|
+
|
|
135
|
+
const r = runCli(['migrate', '--from=4.5', '--to=12.0', '--source-dir', dir]);
|
|
136
|
+
assert.equal(r.code, 0);
|
|
137
|
+
assert.equal(r.envelope.data.from, '4.5');
|
|
138
|
+
assert.equal(r.envelope.data.to, '12.0');
|
|
139
|
+
assert.match(path.basename(r.envelope.data.backup_dir), /^backup-v4\.5-/);
|
|
140
|
+
|
|
141
|
+
const db = openStateDb(path.join(dir, '.ultra', 'state.db'));
|
|
142
|
+
try {
|
|
143
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count, 3);
|
|
144
|
+
const baseline = db.prepare(
|
|
145
|
+
"SELECT mode, status, gaps_json FROM baselines WHERE id = 'migrated-baseline'",
|
|
146
|
+
).get();
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
{ mode: baseline.mode, status: baseline.status },
|
|
149
|
+
{ mode: 'migrated', status: 'adopting' },
|
|
150
|
+
);
|
|
151
|
+
assert.equal(JSON.parse(baseline.gaps_json)[0].id, 'legacy-rebaseline-required');
|
|
152
|
+
} finally { closeStateDb(db); }
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
125
158
|
test('migrate --rollback restores .ultra contents and writes a rollback row', () => {
|
|
126
159
|
const dir = tmpProject();
|
|
127
160
|
try {
|