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
package/mcp-server/server.cjs
CHANGED
|
@@ -148,7 +148,10 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
148
148
|
case 'task.create': {
|
|
149
149
|
const { randomUUID } = require('node:crypto');
|
|
150
150
|
const id = input.id || `task-${randomUUID()}`;
|
|
151
|
-
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 });
|
|
152
155
|
return { id: task.id, status: task.status, created_at: task.created_at };
|
|
153
156
|
}
|
|
154
157
|
case 'task.update': {
|
|
@@ -286,20 +289,25 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
286
289
|
err.code = 'NO_INPUT';
|
|
287
290
|
throw err;
|
|
288
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 });
|
|
289
296
|
const client = buildLlmClient(ctx);
|
|
290
297
|
const parsed = await parser.parsePrd(prdText, { llmClient: client, tag: input.tag });
|
|
291
298
|
const shaped = parsed.tasks.map((t) => ({
|
|
292
299
|
id: t.id, title: t.title, type: t.type, priority: t.priority,
|
|
293
300
|
complexity: t.complexity, deps: t.deps, files_modified: t.files_modified,
|
|
294
|
-
tag: t.tag,
|
|
301
|
+
tag: t.tag, change_id: input.change_id,
|
|
295
302
|
}));
|
|
296
|
-
const dryRun = input.dry_run === true;
|
|
297
303
|
if (!dryRun) {
|
|
298
304
|
try {
|
|
299
305
|
ops.tx(db, () => {
|
|
306
|
+
changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
|
|
300
307
|
for (const t of shaped) ops.createTask(db, t);
|
|
301
308
|
});
|
|
302
309
|
} catch (err) {
|
|
310
|
+
if (['BASELINE_NOT_READY', 'CHANGE_NOT_FOUND', 'CHANGE_NOT_MUTABLE'].includes(err.code)) throw err;
|
|
303
311
|
const wrap = new Error(`failed to persist parsed tasks: ${err.message}`);
|
|
304
312
|
wrap.code = 'PARSE_FAILED';
|
|
305
313
|
wrap.cause = err;
|
|
@@ -324,12 +332,15 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
324
332
|
err.code = 'ALREADY_EXPANDED';
|
|
325
333
|
throw err;
|
|
326
334
|
}
|
|
335
|
+
const rootDir = ctx.rootDir || process.cwd();
|
|
336
|
+
changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
|
|
327
337
|
const client = buildLlmClient(ctx);
|
|
328
338
|
const result = await expander.expandTask(db, {
|
|
329
339
|
id: input.id,
|
|
330
340
|
sub_count: input.sub_count,
|
|
331
341
|
strategy: input.strategy,
|
|
332
342
|
llmClient: client,
|
|
343
|
+
rootDir,
|
|
333
344
|
});
|
|
334
345
|
return { parent_id: result.parent_id, children: result.children };
|
|
335
346
|
}
|
|
@@ -8,7 +8,9 @@ const path = require('node:path');
|
|
|
8
8
|
const { execFileSync } = require('node:child_process');
|
|
9
9
|
const Database = require('better-sqlite3');
|
|
10
10
|
|
|
11
|
-
const {
|
|
11
|
+
const {
|
|
12
|
+
initProject, InitProjectError, DEFAULT_TEMPLATE, classifyRepository,
|
|
13
|
+
} = require('../lib/init-project.cjs');
|
|
12
14
|
const { EXPECTED_VERSION } = require('../lib/state-db.cjs');
|
|
13
15
|
|
|
14
16
|
function mkTempDir(prefix = 'ubp-init-test-') {
|
|
@@ -71,6 +73,85 @@ test('initProject auto-detects existing source as brownfield and starts adoption
|
|
|
71
73
|
} finally { cleanup(target); }
|
|
72
74
|
});
|
|
73
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
|
+
|
|
74
155
|
test('maintenance CLI forwards an explicit initialization mode', () => {
|
|
75
156
|
const target = mkTempDir();
|
|
76
157
|
try {
|
|
@@ -110,6 +191,25 @@ test('initProject keeps project metadata in authoritative baseline state and tas
|
|
|
110
191
|
} finally { cleanup(target); }
|
|
111
192
|
});
|
|
112
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
|
+
|
|
113
213
|
test('initProject refuses when .ultra/ already exists and overwrite=false', () => {
|
|
114
214
|
const target = mkTempDir();
|
|
115
215
|
try {
|
|
@@ -121,6 +221,67 @@ test('initProject refuses when .ultra/ already exists and overwrite=false', () =
|
|
|
121
221
|
} finally { cleanup(target); }
|
|
122
222
|
});
|
|
123
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
|
+
|
|
124
285
|
test('initProject with overwrite=true backs up existing .ultra/', () => {
|
|
125
286
|
const target = mkTempDir();
|
|
126
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 {
|