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.
- package/CHANGELOG.md +43 -1
- package/README.md +7 -8
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +13 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/docs/AGENT-CONTEXT.md +6 -3
- package/docs/ARCHITECTURE.md +8 -1
- package/docs/ROADMAP.md +1 -1
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -332
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/task-expander.cjs +9 -38
- package/mcp-server/lib/task-expander.test.cjs +54 -209
- package/mcp-server/server.cjs +21 -35
- package/mcp-server/tests/server-contract.test.cjs +41 -45
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/mcp-tools.yaml +38 -10
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- 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',
|
|
66
|
-
{ id: 'child-2', title: 'Implement login endpoint', type: 'feature',
|
|
67
|
-
{ id: 'child-3', title: 'Add signup endpoint',
|
|
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
|
|
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 =
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
66
|
+
test('expandTask rejects missing and already expanded parents', () => {
|
|
104
67
|
const ctx = tmpDb();
|
|
105
68
|
try {
|
|
106
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
98
|
+
test('expandTask rejects duplicate child ids', () => {
|
|
204
99
|
const ctx = tmpDb();
|
|
205
100
|
try {
|
|
206
101
|
seedParent(ctx.db);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
126
|
+
test('expandTask children inherit parent tag and change ownership', () => {
|
|
245
127
|
const ctx = tmpDb();
|
|
246
128
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
275
|
-
expandTask(ctx.db, {
|
|
276
|
-
id: 'parent-1',
|
|
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
|
-
});
|
package/mcp-server/server.cjs
CHANGED
|
@@ -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
|
|
297
|
-
|
|
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:
|
|
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
|
|
338
|
-
const result = await expander.expandTask(db, {
|
|
324
|
+
const result = expander.expandTask(db, {
|
|
339
325
|
id: input.id,
|
|
340
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
556
|
-
}, db, { rootDir: dryProject.dir
|
|
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
|
-
|
|
577
|
-
}, db, { rootDir: proj.dir
|
|
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
|
-
|
|
595
|
-
}, db, { rootDir: readyProject.dir
|
|
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
|
-
|
|
632
|
-
}, db, { rootDir: proj.dir
|
|
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:
|
|
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, '
|
|
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
|
|
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: {
|
|
1319
|
+
arguments: { tasks: prdTasks('no-key-prd'), dry_run: true },
|
|
1336
1320
|
});
|
|
1337
|
-
const
|
|
1338
|
-
assert.equal(
|
|
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
|
|
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: {
|
|
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
|
|
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: {
|
|
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
|
|
1375
|
-
assert.equal(
|
|
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.
|
|
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.
|
|
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
|
|