ultra-builder-pro-cli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +7 -8
  3. package/adapters/_shared/codex-assets.cjs +23 -163
  4. package/adapters/_shared/runtime-assets.cjs +1 -0
  5. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  6. package/adapters/opencode.js +30 -120
  7. package/adapters/tests/claude.test.cjs +3 -2
  8. package/adapters/tests/codex-hook.test.cjs +34 -7
  9. package/adapters/tests/codex.test.cjs +13 -6
  10. package/adapters/tests/kimi-hook.test.cjs +34 -7
  11. package/adapters/tests/opencode.test.cjs +70 -84
  12. package/docs/AGENT-CONTEXT.md +6 -3
  13. package/docs/ARCHITECTURE.md +8 -1
  14. package/docs/ROADMAP.md +1 -1
  15. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  16. package/hooks/active_task_context.py +22 -50
  17. package/hooks/context_spine.py +61 -332
  18. package/hooks/health_check.py +19 -17
  19. package/hooks/pre_stop_check.py +28 -33
  20. package/hooks/subagent_tracker.py +13 -19
  21. package/hooks/workflow_checkpoint.py +39 -47
  22. package/hooks/workflow_context.py +4 -9
  23. package/hooks/workflow_resume.py +48 -128
  24. package/mcp-server/breadcrumb.cjs +22 -0
  25. package/mcp-server/lib/prd-parser.cjs +14 -41
  26. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  27. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  28. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  29. package/mcp-server/lib/task-expander.cjs +9 -38
  30. package/mcp-server/lib/task-expander.test.cjs +54 -209
  31. package/mcp-server/server.cjs +21 -35
  32. package/mcp-server/tests/server-contract.test.cjs +41 -45
  33. package/package.json +2 -4
  34. package/skills/cc-collab/SKILL.md +2 -2
  35. package/skills/codex-collab/SKILL.md +2 -3
  36. package/skills/ultra-plan/SKILL.md +15 -5
  37. package/skills/ultra-verify/SKILL.md +21 -30
  38. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  39. package/spec/mcp-tools.yaml +38 -10
  40. package/mcp-server/lib/llm-client.cjs +0 -157
  41. package/mcp-server/lib/llm-client.test.cjs +0 -156
  42. package/orchestrator/planner/expand-prompt.cjs +0 -62
  43. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  44. package/orchestrator/planner/prd-prompt.cjs +0 -44
  45. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
@@ -1,10 +1,5 @@
1
1
  'use strict';
2
2
 
3
- // Integration tests for task-expander — real SQLite (Testcontainers-style
4
- // in-process) so parent_id column + status transition + task_expanded event
5
- // are exercised end-to-end. The only Test Double is the LLM client, which
6
- // we inject with deterministic child fixtures.
7
-
8
3
  const test = require('node:test');
9
4
  const assert = require('node:assert/strict');
10
5
  const fs = require('node:fs');
@@ -32,23 +27,6 @@ function cleanup({ dir, db }) {
32
27
  fs.rmSync(dir, { recursive: true, force: true });
33
28
  }
34
29
 
35
- function fakeLlm(childrenJson, { shouldFail = false, usage = {} } = {}) {
36
- return {
37
- provider: 'anthropic',
38
- model: 'claude-sonnet-4-6',
39
- async completeJson() {
40
- if (shouldFail) throw new Error('upstream LLM 500');
41
- return {
42
- json: { children: childrenJson },
43
- raw: JSON.stringify({ children: childrenJson }),
44
- usage,
45
- model: 'claude-sonnet-4-6',
46
- provider: 'anthropic',
47
- };
48
- },
49
- };
50
- }
51
-
52
30
  function seedParent(db, overrides = {}) {
53
31
  return ops.createTask(db, {
54
32
  id: 'parent-1',
@@ -62,203 +40,110 @@ function seedParent(db, overrides = {}) {
62
40
  }
63
41
 
64
42
  const HAPPY_CHILDREN = [
65
- { id: 'child-1', title: 'Design auth schema', type: 'architecture', priority: 'P1', complexity: 4, deps: [], files_modified: ['src/auth/schema.ts'] },
66
- { id: 'child-2', title: 'Implement login endpoint', type: 'feature', priority: 'P1', complexity: 5, deps: ['child-1'], files_modified: ['src/auth/login.ts'] },
67
- { id: 'child-3', title: 'Add signup endpoint', type: 'feature', priority: 'P2', complexity: 3, deps: ['child-1'], files_modified: ['src/auth/signup.ts'] },
43
+ { id: 'child-1', title: 'Design auth schema', type: 'architecture', priority: 'P1', complexity: 4, deps: [], files_modified: ['src/auth/schema.ts'] },
44
+ { id: 'child-2', title: 'Implement login endpoint', type: 'feature', priority: 'P1', complexity: 5, deps: ['child-1'], files_modified: ['src/auth/login.ts'] },
45
+ { id: 'child-3', title: 'Add signup endpoint', type: 'feature', priority: 'P2', complexity: 3, deps: ['child-1'], files_modified: ['src/auth/signup.ts'] },
68
46
  ];
69
47
 
70
- test('expandTask happy path: creates children, flips parent status, emits task_expanded', async () => {
48
+ test('expandTask atomically persists host-derived children without a provider client', () => {
71
49
  const ctx = tmpDb();
72
50
  try {
73
51
  seedParent(ctx.db);
74
- const result = await expandTask(ctx.db, {
75
- id: 'parent-1',
76
- strategy: 'llm',
77
- llmClient: fakeLlm(HAPPY_CHILDREN),
78
- });
52
+ const result = expandTask(ctx.db, { id: 'parent-1', children: HAPPY_CHILDREN });
79
53
  assert.equal(result.parent_id, 'parent-1');
80
54
  assert.equal(result.children.length, 3);
81
-
82
- const parentAfter = ops.readTask(ctx.db, 'parent-1');
83
- assert.equal(parentAfter.status, 'expanded');
84
-
55
+ assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'expanded');
85
56
  for (const child of HAPPY_CHILDREN) {
86
57
  const row = ops.readTask(ctx.db, child.id);
87
- assert.ok(row, `child ${child.id} missing`);
88
58
  assert.equal(row.parent_id, 'parent-1');
89
59
  assert.equal(row.status, 'pending');
90
60
  }
91
-
92
- const events = ops.subscribeEventsSince(ctx.db, { since_id: 0, limit: 100 });
93
- const expandedEvents = events.events.filter((e) => e.type === 'task_expanded');
94
- assert.equal(expandedEvents.length, 1);
95
- assert.equal(expandedEvents[0].task_id, 'parent-1');
96
- const createdEvents = events.events.filter((e) => e.type === 'task_created');
97
- assert.ok(createdEvents.length >= 4, 'parent + 3 children task_created events');
98
- } finally {
99
- cleanup(ctx);
100
- }
61
+ const events = ops.subscribeEventsSince(ctx.db, { since_id: 0, limit: 100 }).events;
62
+ assert.equal(events.filter((event) => event.type === 'task_expanded').length, 1);
63
+ } finally { cleanup(ctx); }
101
64
  });
102
65
 
103
- test('expandTask: missing parent → TASK_NOT_FOUND', async () => {
66
+ test('expandTask rejects missing and already expanded parents', () => {
104
67
  const ctx = tmpDb();
105
68
  try {
106
- await assert.rejects(
107
- expandTask(ctx.db, {
108
- id: 'does-not-exist',
109
- llmClient: fakeLlm(HAPPY_CHILDREN),
110
- }),
69
+ assert.throws(
70
+ () => expandTask(ctx.db, { id: 'missing', children: HAPPY_CHILDREN }),
111
71
  (err) => err instanceof TaskExpandError && err.code === 'TASK_NOT_FOUND',
112
72
  );
113
- } finally {
114
- cleanup(ctx);
115
- }
116
- });
117
-
118
- test('expandTask: already-expanded parent → ALREADY_EXPANDED', async () => {
119
- const ctx = tmpDb();
120
- try {
121
73
  seedParent(ctx.db);
122
- // First expansion succeeds
123
- await expandTask(ctx.db, {
124
- id: 'parent-1',
125
- llmClient: fakeLlm(HAPPY_CHILDREN),
126
- });
127
- // Second attempt must refuse
128
- await assert.rejects(
129
- expandTask(ctx.db, {
130
- id: 'parent-1',
131
- llmClient: fakeLlm(HAPPY_CHILDREN.map((c, i) => ({ ...c, id: `other-${i}` }))),
132
- }),
74
+ expandTask(ctx.db, { id: 'parent-1', children: HAPPY_CHILDREN });
75
+ assert.throws(
76
+ () => expandTask(ctx.db, { id: 'parent-1', children: [{ ...HAPPY_CHILDREN[0], id: 'other' }] }),
133
77
  (err) => err instanceof TaskExpandError && err.code === 'ALREADY_EXPANDED',
134
78
  );
135
- } finally {
136
- cleanup(ctx);
137
- }
138
- });
139
-
140
- test('expandTask: strategy="manual" → NOT_IMPLEMENTED', async () => {
141
- const ctx = tmpDb();
142
- try {
143
- seedParent(ctx.db);
144
- await assert.rejects(
145
- expandTask(ctx.db, {
146
- id: 'parent-1',
147
- strategy: 'manual',
148
- llmClient: fakeLlm(HAPPY_CHILDREN),
149
- }),
150
- (err) => err instanceof TaskExpandError && err.code === 'NOT_IMPLEMENTED',
151
- );
152
- } finally {
153
- cleanup(ctx);
154
- }
155
- });
156
-
157
- test('expandTask: missing llmClient → NO_LLM_CLIENT', async () => {
158
- const ctx = tmpDb();
159
- try {
160
- seedParent(ctx.db);
161
- await assert.rejects(
162
- expandTask(ctx.db, { id: 'parent-1' }),
163
- (err) => err instanceof TaskExpandError && err.code === 'NO_LLM_CLIENT',
164
- );
165
- } finally {
166
- cleanup(ctx);
167
- }
79
+ } finally { cleanup(ctx); }
168
80
  });
169
81
 
170
- test('expandTask: LLM throws → LLM_CALL_FAILED', async () => {
82
+ test('expandTask requires valid host-derived children and leaves the parent untouched on failure', () => {
171
83
  const ctx = tmpDb();
172
84
  try {
173
85
  seedParent(ctx.db);
174
- await assert.rejects(
175
- expandTask(ctx.db, {
176
- id: 'parent-1',
177
- llmClient: fakeLlm([], { shouldFail: true }),
178
- }),
179
- (err) => err instanceof TaskExpandError && err.code === 'LLM_CALL_FAILED',
180
- );
181
- // parent status must be unchanged
182
- assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
183
- } finally {
184
- cleanup(ctx);
185
- }
186
- });
187
-
188
- test('expandTask: invalid child shape → INVALID_OUTPUT, parent untouched', async () => {
189
- const ctx = tmpDb();
190
- try {
191
- seedParent(ctx.db);
192
- const bad = [{ id: 'child-1', title: 'x', type: 'feature', priority: 'P1' }]; // title too short
193
- await assert.rejects(
194
- expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(bad) }),
195
- (err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
196
- );
197
- assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
198
- } finally {
199
- cleanup(ctx);
200
- }
86
+ for (const children of [undefined, [], [{
87
+ id: 'child-1', title: 'x', type: 'feature', priority: 'P1',
88
+ }]]) {
89
+ assert.throws(
90
+ () => expandTask(ctx.db, { id: 'parent-1', children }),
91
+ (err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
92
+ );
93
+ assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
94
+ }
95
+ } finally { cleanup(ctx); }
201
96
  });
202
97
 
203
- test('expandTask: duplicate child id → INVALID_OUTPUT', async () => {
98
+ test('expandTask rejects duplicate child ids', () => {
204
99
  const ctx = tmpDb();
205
100
  try {
206
101
  seedParent(ctx.db);
207
- const dup = [
208
- { id: 'same', title: 'First child', type: 'feature', priority: 'P2' },
209
- { id: 'same', title: 'Second child', type: 'feature', priority: 'P2' },
210
- ];
211
- await assert.rejects(
212
- expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(dup) }),
102
+ assert.throws(
103
+ () => expandTask(ctx.db, { id: 'parent-1', children: [
104
+ { id: 'same', title: 'First child', type: 'feature', priority: 'P2' },
105
+ { id: 'same', title: 'Second child', type: 'feature', priority: 'P2' },
106
+ ] }),
213
107
  (err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
214
108
  );
215
- } finally {
216
- cleanup(ctx);
217
- }
109
+ } finally { cleanup(ctx); }
218
110
  });
219
111
 
220
- test('expandTask: tx rollback — child id collides with existing task → parent status unchanged, no children inserted', async () => {
112
+ test('expandTask rolls back every child when one id collides', () => {
221
113
  const ctx = tmpDb();
222
114
  try {
223
115
  seedParent(ctx.db);
224
- // Pre-create a task with the same id one of the children will try
225
116
  ops.createTask(ctx.db, {
226
- id: 'child-1',
227
- title: 'Pre-existing task',
228
- type: 'bugfix',
229
- priority: 'P3',
117
+ id: 'child-1', title: 'Pre-existing task', type: 'bugfix', priority: 'P3',
230
118
  });
231
- await assert.rejects(
232
- expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN) }),
233
- /* any error is acceptable — the point is no partial state */
234
- );
119
+ assert.throws(() => expandTask(ctx.db, { id: 'parent-1', children: HAPPY_CHILDREN }));
235
120
  assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
236
- // child-2 / child-3 must NOT have been inserted
237
121
  assert.equal(ops.readTask(ctx.db, 'child-2'), null);
238
122
  assert.equal(ops.readTask(ctx.db, 'child-3'), null);
239
- } finally {
240
- cleanup(ctx);
241
- }
123
+ } finally { cleanup(ctx); }
242
124
  });
243
125
 
244
- test('expandTask: children inherit parent tag', async () => {
126
+ test('expandTask children inherit parent tag and change ownership', () => {
245
127
  const ctx = tmpDb();
246
128
  try {
247
- seedParent(ctx.db, { tag: 'feat-auth' });
248
- await expandTask(ctx.db, {
249
- id: 'parent-1',
250
- llmClient: fakeLlm(HAPPY_CHILDREN),
129
+ const { change } = changes.createChange(ctx.db, {
130
+ id: 'expand-change', title: 'Expand one task', kind: 'quick',
131
+ intent: 'Keep child ownership inside the active change.',
132
+ docs_impact: { status: 'none', rationale: 'test fixture' },
133
+ }, { rootDir: ctx.dir });
134
+ seedParent(ctx.db, { tag: 'feat-auth', change_id: change.id });
135
+ const result = expandTask(ctx.db, {
136
+ id: 'parent-1', children: HAPPY_CHILDREN, rootDir: ctx.dir,
251
137
  });
252
- for (const child of HAPPY_CHILDREN) {
138
+ for (const child of result.children) {
253
139
  const row = ops.readTask(ctx.db, child.id);
254
140
  assert.equal(row.tag, 'feat-auth');
141
+ assert.equal(row.change_id, change.id);
255
142
  }
256
- } finally {
257
- cleanup(ctx);
258
- }
143
+ } finally { cleanup(ctx); }
259
144
  });
260
145
 
261
- test('expandTask blocks new children when baseline authority is missing, draft, or migrated', async () => {
146
+ test('expandTask enforces baseline authority before writing children', () => {
262
147
  for (const baseline of ['missing', 'draft', 'migrated']) {
263
148
  const ctx = tmpDb();
264
149
  try {
@@ -271,9 +156,9 @@ test('expandTask blocks new children when baseline authority is missing, draft,
271
156
  "UPDATE baselines SET mode = 'migrated', status = 'adopting' WHERE id = 'test-baseline'",
272
157
  ).run();
273
158
  }
274
- await assert.rejects(
275
- expandTask(ctx.db, {
276
- id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN), rootDir: ctx.dir,
159
+ assert.throws(
160
+ () => expandTask(ctx.db, {
161
+ id: 'parent-1', children: HAPPY_CHILDREN, rootDir: ctx.dir,
277
162
  }),
278
163
  (error) => error.code === 'BASELINE_NOT_READY',
279
164
  );
@@ -282,43 +167,3 @@ test('expandTask blocks new children when baseline authority is missing, draft,
282
167
  } finally { cleanup(ctx); }
283
168
  }
284
169
  });
285
-
286
- test('expandTask preserves active ordinary and approved incident change ownership while adoption is incomplete', async () => {
287
- for (const kind of ['quick', 'incident']) {
288
- const ctx = tmpDb();
289
- try {
290
- if (kind === 'incident') {
291
- ctx.db.prepare(
292
- "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
293
- ).run();
294
- }
295
- const changeId = `expand-${kind}`;
296
- changes.createChange(ctx.db, {
297
- id: changeId,
298
- title: kind === 'incident' ? 'Recover production expansion' : 'Continue planned expansion',
299
- kind,
300
- intent: 'Keep every expanded child inside the already-authorized continuous change.',
301
- ...(kind === 'incident' ? {
302
- baseline_bypass: {
303
- approved_by: 'incident-commander',
304
- reason: 'Production recovery requires a bounded task expansion before adoption converges.',
305
- },
306
- } : {}),
307
- }, { rootDir: ctx.dir });
308
- seedParent(ctx.db, { change_id: changeId });
309
- if (kind === 'quick') {
310
- ctx.db.prepare(
311
- "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
312
- ).run();
313
- }
314
-
315
- const result = await expandTask(ctx.db, {
316
- id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN), rootDir: ctx.dir,
317
- });
318
- assert.equal(result.children.length, HAPPY_CHILDREN.length);
319
- for (const child of HAPPY_CHILDREN) {
320
- assert.equal(ops.readTask(ctx.db, child.id).change_id, changeId);
321
- }
322
- } finally { cleanup(ctx); }
323
- }
324
- });
@@ -23,7 +23,6 @@ const ops = require('./lib/state-ops.cjs');
23
23
  const projector = require('./lib/projector.cjs');
24
24
  const telemetry = require('./lib/telemetry.cjs');
25
25
  const topo = require('./lib/topo.cjs');
26
- const llm = require('./lib/llm-client.cjs');
27
26
  const parser = require('./lib/prd-parser.cjs');
28
27
  const expander = require('./lib/task-expander.cjs');
29
28
  const planStore = require('./lib/plan-store.cjs');
@@ -32,6 +31,10 @@ const baselines = require('./lib/baseline-workflow.cjs');
32
31
  const changes = require('./lib/change-workflow.cjs');
33
32
  const runtimeState = require('./lib/runtime-state.cjs');
34
33
  const doctor = require('./lib/doctor.cjs');
34
+ const {
35
+ readProjectBreadcrumb,
36
+ renderProjectBreadcrumb,
37
+ } = require('./lib/project-breadcrumb.cjs');
35
38
 
36
39
  const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
37
40
  ? path.resolve(process.env.UBP_RUNTIME_ROOT)
@@ -127,22 +130,6 @@ function mintSessionId() {
127
130
  return `sess-${randomUUID().slice(0, 8)}`;
128
131
  }
129
132
 
130
- function resolvePrdText(input, ctx) {
131
- if (input.prd_text && String(input.prd_text).trim()) return String(input.prd_text);
132
- if (input.prd_path) {
133
- const abs = path.isAbsolute(input.prd_path)
134
- ? input.prd_path
135
- : path.join(ctx.rootDir || process.cwd(), input.prd_path);
136
- return fs.readFileSync(abs, 'utf8');
137
- }
138
- return null;
139
- }
140
-
141
- function buildLlmClient(ctx) {
142
- if (ctx && ctx.llmClient) return ctx.llmClient;
143
- return llm.createLlmClient({});
144
- }
145
-
146
133
  async function dispatchTool(name, input, db, ctx = {}) {
147
134
  switch (name) {
148
135
  case 'task.create': {
@@ -283,23 +270,27 @@ async function dispatchTool(name, input, db, ctx = {}) {
283
270
  return { waves: result.waves };
284
271
  }
285
272
  case 'task.parse_prd': {
286
- const prdText = resolvePrdText(input, ctx);
287
- if (!prdText || !prdText.trim()) {
288
- const err = new Error('one of prd_path or prd_text required');
289
- err.code = 'NO_INPUT';
290
- throw err;
291
- }
292
273
  const dryRun = input.dry_run === true;
293
274
  const rootDir = ctx.rootDir || process.cwd();
294
275
  const creationAuthority = { change_id: input.change_id };
295
276
  if (!dryRun) changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
296
- const client = buildLlmClient(ctx);
297
- const parsed = await parser.parsePrd(prdText, { llmClient: client, tag: input.tag });
277
+ const parsed = parser.parsePrd(input.tasks, {
278
+ tag: input.tag,
279
+ changeId: input.change_id,
280
+ });
298
281
  const shaped = parsed.tasks.map((t) => ({
299
282
  id: t.id, title: t.title, type: t.type, priority: t.priority,
300
283
  complexity: t.complexity, deps: t.deps, files_modified: t.files_modified,
301
- tag: t.tag, change_id: input.change_id,
284
+ tag: t.tag, change_id: t.change_id,
302
285
  }));
286
+ const graph = shaped.map((t) => ({ id: t.id, deps: t.deps || [] }));
287
+ const topoResult = topo.computeWaves(graph);
288
+ if (topoResult.cycles.length > 0) {
289
+ const err = new Error(`parsed task graph has ${topoResult.cycles.length} cycle(s)`);
290
+ err.code = 'CYCLE_DETECTED';
291
+ err.details = { cycles: topoResult.cycles };
292
+ throw err;
293
+ }
303
294
  if (!dryRun) {
304
295
  try {
305
296
  ops.tx(db, () => {
@@ -314,13 +305,9 @@ async function dispatchTool(name, input, db, ctx = {}) {
314
305
  throw wrap;
315
306
  }
316
307
  }
317
- const graph = shaped.map((t) => ({ id: t.id, deps: t.deps || [] }));
318
- const topoResult = topo.computeWaves(graph);
319
308
  return { tasks: shaped, topo: topoResult.waves };
320
309
  }
321
310
  case 'task.expand': {
322
- // Cheap guards before paying for LLM client construction (which would
323
- // throw NO_LLM_CREDENTIALS when env keys are missing).
324
311
  const parent = ops.readTask(db, input.id);
325
312
  if (!parent) {
326
313
  const err = new Error(`no task ${input.id}`);
@@ -334,12 +321,9 @@ async function dispatchTool(name, input, db, ctx = {}) {
334
321
  }
335
322
  const rootDir = ctx.rootDir || process.cwd();
336
323
  changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
337
- const client = buildLlmClient(ctx);
338
- const result = await expander.expandTask(db, {
324
+ const result = expander.expandTask(db, {
339
325
  id: input.id,
340
- sub_count: input.sub_count,
341
- strategy: input.strategy,
342
- llmClient: client,
326
+ children: input.children,
343
327
  rootDir,
344
328
  });
345
329
  return { parent_id: result.parent_id, children: result.children };
@@ -659,4 +643,6 @@ module.exports = {
659
643
  REGISTERED_TOOLS,
660
644
  STATELESS_TOOLS,
661
645
  MUTATING_TOOLS,
646
+ readProjectBreadcrumb,
647
+ renderProjectBreadcrumb,
662
648
  };
@@ -91,27 +91,15 @@ function expectError(result) {
91
91
  return JSON.parse(result.content[0].text).error;
92
92
  }
93
93
 
94
- function fakePrdClient(taskId) {
95
- const client = {
96
- calls: 0,
97
- provider: 'anthropic',
98
- model: 'test-model',
99
- async completeJson() {
100
- client.calls += 1;
101
- const json = {
102
- tasks: [{
103
- id: taskId,
104
- title: `Implement ${taskId}`,
105
- type: 'feature',
106
- priority: 'P1',
107
- deps: [],
108
- files_modified: [`src/${taskId}.js`],
109
- }],
110
- };
111
- return { json, raw: JSON.stringify(json), usage: {}, model: client.model, provider: client.provider };
112
- },
113
- };
114
- return client;
94
+ function prdTasks(taskId) {
95
+ return [{
96
+ id: taskId,
97
+ title: `Implement ${taskId}`,
98
+ type: 'feature',
99
+ priority: 'P1',
100
+ deps: [],
101
+ files_modified: [`src/${taskId}.js`],
102
+ }];
115
103
  }
116
104
 
117
105
  test('listTools returns workflow tools and exposes no Ultra memory API', async () => {
@@ -550,12 +538,10 @@ test('task.parse_prd keeps dry runs read-only and blocks persistence before base
550
538
  try {
551
539
  const { db } = initStateDb(dryProject.dbPath);
552
540
  try {
553
- const client = fakePrdClient('dry-run-task');
554
541
  const result = await dispatchTool('task.parse_prd', {
555
- prd_text: 'Build a dry-run task.', dry_run: true,
556
- }, db, { rootDir: dryProject.dir, llmClient: client });
542
+ tasks: prdTasks('dry-run-task'), dry_run: true,
543
+ }, db, { rootDir: dryProject.dir });
557
544
  assert.equal(result.tasks[0].id, 'dry-run-task');
558
- assert.equal(client.calls, 1);
559
545
  assert.equal(ops.listTasks(db, {}).length, 0);
560
546
  } finally { closeStateDb(db); }
561
547
  } finally { fs.rmSync(dryProject.dir, { recursive: true, force: true }); }
@@ -570,14 +556,12 @@ test('task.parse_prd keeps dry runs read-only and blocks persistence before base
570
556
  if (baseline) seedBaseline(proj, baseline);
571
557
  const { db } = initStateDb(proj.dbPath);
572
558
  try {
573
- const client = fakePrdClient(`blocked-prd-${baseline?.mode || 'missing'}`);
574
559
  await assert.rejects(
575
560
  dispatchTool('task.parse_prd', {
576
- prd_text: 'Persist a task before adoption.', dry_run: false,
577
- }, db, { rootDir: proj.dir, llmClient: client }),
561
+ tasks: prdTasks(`blocked-prd-${baseline?.mode || 'missing'}`), dry_run: false,
562
+ }, db, { rootDir: proj.dir }),
578
563
  (error) => error.code === 'BASELINE_NOT_READY',
579
564
  );
580
- assert.equal(client.calls, 0, 'baseline gate must run before the LLM call');
581
565
  assert.equal(ops.listTasks(db, {}).length, 0);
582
566
  } finally { closeStateDb(db); }
583
567
  } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
@@ -591,8 +575,8 @@ test('task.parse_prd persists only under ready or already-authorized change owne
591
575
  const { db } = initStateDb(readyProject.dbPath);
592
576
  try {
593
577
  await dispatchTool('task.parse_prd', {
594
- prd_text: 'Build a ready task.', dry_run: false,
595
- }, db, { rootDir: readyProject.dir, llmClient: fakePrdClient('ready-prd-task') });
578
+ tasks: prdTasks('ready-prd-task'), dry_run: false,
579
+ }, db, { rootDir: readyProject.dir });
596
580
  assert.equal(ops.readTask(db, 'ready-prd-task').change_id, null);
597
581
  } finally { closeStateDb(db); }
598
582
  } finally { fs.rmSync(readyProject.dir, { recursive: true, force: true }); }
@@ -628,8 +612,8 @@ test('task.parse_prd persists only under ready or already-authorized change owne
628
612
  }
629
613
  const taskId = `parsed-${kind}-task`;
630
614
  await dispatchTool('task.parse_prd', {
631
- prd_text: 'Build a change-owned task.', dry_run: false, change_id: changeId,
632
- }, db, { rootDir: proj.dir, llmClient: fakePrdClient(taskId) });
615
+ tasks: prdTasks(taskId), dry_run: false, change_id: changeId,
616
+ }, db, { rootDir: proj.dir });
633
617
  assert.equal(ops.readTask(db, taskId).change_id, changeId);
634
618
  } finally { closeStateDb(db); }
635
619
  } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
@@ -1313,42 +1297,48 @@ test('task.dependency_topo: happy path groups tasks into correct waves', async (
1313
1297
  }
1314
1298
  });
1315
1299
 
1316
- test('task.parse_prd: neither prd_path nor prd_text → NO_INPUT', async () => {
1300
+ test('task.parse_prd: missing host-derived tasks → validation error', async () => {
1317
1301
  const proj = tmpProject();
1318
1302
  try {
1319
1303
  await withClient(proj, async (client) => {
1320
1304
  const resp = await client.callTool({ name: 'task.parse_prd', arguments: {} });
1321
1305
  const err = expectError(resp);
1322
- assert.equal(err.code, 'NO_INPUT');
1306
+ assert.equal(err.code, 'VALIDATION_ERROR');
1323
1307
  });
1324
1308
  } finally {
1325
1309
  fs.rmSync(proj.dir, { recursive: true, force: true });
1326
1310
  }
1327
1311
  });
1328
1312
 
1329
- test('task.parse_prd: missing LLM credentials → NO_LLM_CREDENTIALS', async () => {
1313
+ test('task.parse_prd works without provider credentials', async () => {
1330
1314
  const proj = tmpProject();
1331
1315
  try {
1332
1316
  await withClientNoLlmKey(proj, async (client) => {
1333
1317
  const resp = await client.callTool({
1334
1318
  name: 'task.parse_prd',
1335
- arguments: { prd_text: 'Build a login feature with email and password.', dry_run: true },
1319
+ arguments: { tasks: prdTasks('no-key-prd'), dry_run: true },
1336
1320
  });
1337
- const err = expectError(resp);
1338
- assert.equal(err.code, 'NO_LLM_CREDENTIALS');
1321
+ const data = readToolPayload(resp);
1322
+ assert.equal(data.tasks[0].id, 'no-key-prd');
1339
1323
  });
1340
1324
  } finally {
1341
1325
  fs.rmSync(proj.dir, { recursive: true, force: true });
1342
1326
  }
1343
1327
  });
1344
1328
 
1345
- test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', async () => {
1329
+ test('task.expand: unknown parent → TASK_NOT_FOUND without a provider key', async () => {
1346
1330
  const proj = tmpProject();
1347
1331
  try {
1348
1332
  await withClientNoLlmKey(proj, async (client) => {
1349
1333
  const resp = await client.callTool({
1350
1334
  name: 'task.expand',
1351
- arguments: { id: 'nonexistent-parent' },
1335
+ arguments: {
1336
+ id: 'nonexistent-parent',
1337
+ children: [{
1338
+ id: 'unused-child', title: 'Unused child task',
1339
+ type: 'feature', priority: 'P1',
1340
+ }],
1341
+ },
1352
1342
  });
1353
1343
  const err = expectError(resp);
1354
1344
  assert.equal(err.code, 'TASK_NOT_FOUND');
@@ -1358,7 +1348,7 @@ test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', asyn
1358
1348
  }
1359
1349
  });
1360
1350
 
1361
- test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIALS', async () => {
1351
+ test('task.expand persists host-derived children without provider credentials', async () => {
1362
1352
  const proj = tmpProject();
1363
1353
  try {
1364
1354
  seedReadyBaseline(proj);
@@ -1369,10 +1359,16 @@ test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIAL
1369
1359
  });
1370
1360
  const resp = await client.callTool({
1371
1361
  name: 'task.expand',
1372
- arguments: { id: 'parent-1' },
1362
+ arguments: {
1363
+ id: 'parent-1',
1364
+ children: [{
1365
+ id: 'child-no-key', title: 'Implement child without provider key',
1366
+ type: 'feature', priority: 'P1', complexity: 4, deps: [], files_modified: [],
1367
+ }],
1368
+ },
1373
1369
  });
1374
- const err = expectError(resp);
1375
- assert.equal(err.code, 'NO_LLM_CREDENTIALS');
1370
+ const data = readToolPayload(resp);
1371
+ assert.equal(data.children[0].id, 'child-no-key');
1376
1372
  });
1377
1373
  } finally {
1378
1374
  fs.rmSync(proj.dir, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra-builder-pro-cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Multi-runtime installer for the Ultra Builder Pro agent engineering system — deploys commands, agents, skills, hooks, and MCP server to Claude Code, OpenCode, Codex CLI, and Kimi Code.",
5
5
  "bin": {
6
6
  "ultra-builder-pro-cli": "bin/install.js",
@@ -98,13 +98,11 @@
98
98
  "install:all": "node bin/install.js --all --local"
99
99
  },
100
100
  "dependencies": {
101
- "@anthropic-ai/sdk": "^0.111.0",
102
101
  "@modelcontextprotocol/sdk": "^1.29.0",
103
102
  "@vercel/ncc": "0.38.4",
104
103
  "ajv": "^8.17.1",
105
104
  "ajv-formats": "^3.0.1",
106
105
  "better-sqlite3": "^11.5.0",
107
- "js-yaml": "^4.1.0",
108
- "openai": "^6.34.0"
106
+ "js-yaml": "^4.1.0"
109
107
  }
110
108
  }
@@ -5,8 +5,8 @@ description: Ask Claude Code for an independent read-only analysis of a bounded
5
5
 
6
6
  # Consult Claude Code read-only
7
7
 
8
- Treat Claude Code as an untrusted advisor. The current host owns scope, evidence,
9
- decisions, edits, and final verification.
8
+ Treat Claude Code as an untrusted advisor. Keep the current host responsible for
9
+ scope, evidence, decisions, edits, and final verification.
10
10
 
11
11
  ## Preconditions
12
12