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.
Files changed (57) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +53 -1
  4. package/README.md +25 -9
  5. package/adapters/_shared/handbook.cjs +2 -2
  6. package/adapters/tests/codex.test.cjs +8 -0
  7. package/commands/ultra-init.md +1 -1
  8. package/docs/AGENT-CONTEXT.md +4 -3
  9. package/docs/ARCHITECTURE.md +32 -16
  10. package/docs/PLAN.zh-CN.md +5 -4
  11. package/docs/ROADMAP.md +1 -1
  12. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  13. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  14. package/hooks/context_spine.py +37 -4
  15. package/mcp-server/lib/archive-journal.cjs +192 -0
  16. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  17. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  18. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  19. package/mcp-server/lib/change-workflow.cjs +249 -75
  20. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  21. package/mcp-server/lib/context-spine.test.cjs +5 -0
  22. package/mcp-server/lib/doctor.cjs +14 -1
  23. package/mcp-server/lib/doctor.test.cjs +42 -4
  24. package/mcp-server/lib/init-project.cjs +292 -9
  25. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  26. package/mcp-server/lib/state-authority.cjs +9 -3
  27. package/mcp-server/lib/state-db.cjs +154 -32
  28. package/mcp-server/lib/state-db.test.cjs +136 -4
  29. package/mcp-server/lib/state-ops.cjs +68 -6
  30. package/mcp-server/lib/task-expander.cjs +7 -1
  31. package/mcp-server/lib/task-expander.test.cjs +71 -0
  32. package/mcp-server/server.cjs +14 -3
  33. package/mcp-server/tests/init-project.test.cjs +162 -1
  34. package/mcp-server/tests/migrate.test.cjs +33 -0
  35. package/mcp-server/tests/server-contract.test.cjs +384 -6
  36. package/mcp-server/tests/state-db.test.cjs +14 -2
  37. package/mcp-server/tests/state-ops.test.cjs +108 -0
  38. package/package.json +1 -1
  39. package/skills/ultra-change/SKILL.md +18 -10
  40. package/skills/ultra-deliver/SKILL.md +13 -9
  41. package/skills/ultra-dev/SKILL.md +6 -4
  42. package/skills/ultra-doctor/SKILL.md +22 -7
  43. package/skills/ultra-init/SKILL.md +104 -59
  44. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  45. package/skills/ultra-plan/SKILL.md +8 -5
  46. package/skills/ultra-research/SKILL.md +12 -5
  47. package/skills/ultra-status/SKILL.md +6 -4
  48. package/spec/cli-protocol.md +11 -1
  49. package/spec/mcp-tools.yaml +95 -9
  50. package/spec/schemas/state-db.sql +11 -0
  51. package/ultra-tools/cli.cjs +9 -5
  52. package/ultra-tools/commands/migrate.cjs +62 -36
  53. package/ultra-tools/commands/status.cjs +18 -9
  54. package/ultra-tools/commands/system.cjs +340 -6
  55. package/ultra-tools/commands/task.cjs +7 -1
  56. package/ultra-tools/status-command.test.cjs +29 -0
  57. package/ultra-tools/system-command.test.cjs +252 -1
@@ -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 task = ops.createTask(db, { ...input, id });
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 { initProject, InitProjectError, DEFAULT_TEMPLATE } = require('../lib/init-project.cjs');
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 {