ultra-builder-pro-cli 0.12.1 → 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/.ultra-template/docs/research/README.md +21 -44
- package/.ultra-template/specs/discovery.md +10 -0
- package/CHANGELOG.md +95 -1
- package/README.md +31 -16
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/handbook.cjs +2 -2
- 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 +21 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +10 -6
- package/docs/ARCHITECTURE.md +40 -17
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +2 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -299
- 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/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/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/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 +14 -37
- package/mcp-server/lib/task-expander.test.cjs +80 -164
- package/mcp-server/server.cjs +34 -37
- 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 +391 -17
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- 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 +20 -7
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +133 -19
- 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/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');
|
|
@@ -13,11 +8,17 @@ const path = require('node:path');
|
|
|
13
8
|
|
|
14
9
|
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
15
10
|
const ops = require('./state-ops.cjs');
|
|
11
|
+
const changes = require('./change-workflow.cjs');
|
|
16
12
|
const { expandTask, TaskExpandError } = require('./task-expander.cjs');
|
|
17
13
|
|
|
18
14
|
function tmpDb() {
|
|
19
15
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-expand-'));
|
|
20
16
|
const { db } = initStateDb(path.join(dir, 'state.db'));
|
|
17
|
+
db.prepare(
|
|
18
|
+
`INSERT INTO baselines
|
|
19
|
+
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
20
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'fixture', ?)`,
|
|
21
|
+
).run(new Date().toISOString());
|
|
21
22
|
return { dir, db };
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -26,23 +27,6 @@ function cleanup({ dir, db }) {
|
|
|
26
27
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function fakeLlm(childrenJson, { shouldFail = false, usage = {} } = {}) {
|
|
30
|
-
return {
|
|
31
|
-
provider: 'anthropic',
|
|
32
|
-
model: 'claude-sonnet-4-6',
|
|
33
|
-
async completeJson() {
|
|
34
|
-
if (shouldFail) throw new Error('upstream LLM 500');
|
|
35
|
-
return {
|
|
36
|
-
json: { children: childrenJson },
|
|
37
|
-
raw: JSON.stringify({ children: childrenJson }),
|
|
38
|
-
usage,
|
|
39
|
-
model: 'claude-sonnet-4-6',
|
|
40
|
-
provider: 'anthropic',
|
|
41
|
-
};
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
30
|
function seedParent(db, overrides = {}) {
|
|
47
31
|
return ops.createTask(db, {
|
|
48
32
|
id: 'parent-1',
|
|
@@ -56,198 +40,130 @@ function seedParent(db, overrides = {}) {
|
|
|
56
40
|
}
|
|
57
41
|
|
|
58
42
|
const HAPPY_CHILDREN = [
|
|
59
|
-
{ id: 'child-1', title: 'Design auth schema',
|
|
60
|
-
{ id: 'child-2', title: 'Implement login endpoint', type: 'feature',
|
|
61
|
-
{ 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'] },
|
|
62
46
|
];
|
|
63
47
|
|
|
64
|
-
test('expandTask
|
|
48
|
+
test('expandTask atomically persists host-derived children without a provider client', () => {
|
|
65
49
|
const ctx = tmpDb();
|
|
66
50
|
try {
|
|
67
51
|
seedParent(ctx.db);
|
|
68
|
-
const result =
|
|
69
|
-
id: 'parent-1',
|
|
70
|
-
strategy: 'llm',
|
|
71
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
72
|
-
});
|
|
52
|
+
const result = expandTask(ctx.db, { id: 'parent-1', children: HAPPY_CHILDREN });
|
|
73
53
|
assert.equal(result.parent_id, 'parent-1');
|
|
74
54
|
assert.equal(result.children.length, 3);
|
|
75
|
-
|
|
76
|
-
const parentAfter = ops.readTask(ctx.db, 'parent-1');
|
|
77
|
-
assert.equal(parentAfter.status, 'expanded');
|
|
78
|
-
|
|
55
|
+
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'expanded');
|
|
79
56
|
for (const child of HAPPY_CHILDREN) {
|
|
80
57
|
const row = ops.readTask(ctx.db, child.id);
|
|
81
|
-
assert.ok(row, `child ${child.id} missing`);
|
|
82
58
|
assert.equal(row.parent_id, 'parent-1');
|
|
83
59
|
assert.equal(row.status, 'pending');
|
|
84
60
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
assert.equal(expandedEvents.length, 1);
|
|
89
|
-
assert.equal(expandedEvents[0].task_id, 'parent-1');
|
|
90
|
-
const createdEvents = events.events.filter((e) => e.type === 'task_created');
|
|
91
|
-
assert.ok(createdEvents.length >= 4, 'parent + 3 children task_created events');
|
|
92
|
-
} finally {
|
|
93
|
-
cleanup(ctx);
|
|
94
|
-
}
|
|
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); }
|
|
95
64
|
});
|
|
96
65
|
|
|
97
|
-
test('expandTask
|
|
66
|
+
test('expandTask rejects missing and already expanded parents', () => {
|
|
98
67
|
const ctx = tmpDb();
|
|
99
68
|
try {
|
|
100
|
-
|
|
101
|
-
expandTask(ctx.db, {
|
|
102
|
-
id: 'does-not-exist',
|
|
103
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
104
|
-
}),
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => expandTask(ctx.db, { id: 'missing', children: HAPPY_CHILDREN }),
|
|
105
71
|
(err) => err instanceof TaskExpandError && err.code === 'TASK_NOT_FOUND',
|
|
106
72
|
);
|
|
107
|
-
} finally {
|
|
108
|
-
cleanup(ctx);
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('expandTask: already-expanded parent → ALREADY_EXPANDED', async () => {
|
|
113
|
-
const ctx = tmpDb();
|
|
114
|
-
try {
|
|
115
73
|
seedParent(ctx.db);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
id: 'parent-1',
|
|
119
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
120
|
-
});
|
|
121
|
-
// Second attempt must refuse
|
|
122
|
-
await assert.rejects(
|
|
123
|
-
expandTask(ctx.db, {
|
|
124
|
-
id: 'parent-1',
|
|
125
|
-
llmClient: fakeLlm(HAPPY_CHILDREN.map((c, i) => ({ ...c, id: `other-${i}` }))),
|
|
126
|
-
}),
|
|
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' }] }),
|
|
127
77
|
(err) => err instanceof TaskExpandError && err.code === 'ALREADY_EXPANDED',
|
|
128
78
|
);
|
|
129
|
-
} finally {
|
|
130
|
-
cleanup(ctx);
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('expandTask: strategy="manual" → NOT_IMPLEMENTED', async () => {
|
|
135
|
-
const ctx = tmpDb();
|
|
136
|
-
try {
|
|
137
|
-
seedParent(ctx.db);
|
|
138
|
-
await assert.rejects(
|
|
139
|
-
expandTask(ctx.db, {
|
|
140
|
-
id: 'parent-1',
|
|
141
|
-
strategy: 'manual',
|
|
142
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
143
|
-
}),
|
|
144
|
-
(err) => err instanceof TaskExpandError && err.code === 'NOT_IMPLEMENTED',
|
|
145
|
-
);
|
|
146
|
-
} finally {
|
|
147
|
-
cleanup(ctx);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('expandTask: missing llmClient → NO_LLM_CLIENT', async () => {
|
|
152
|
-
const ctx = tmpDb();
|
|
153
|
-
try {
|
|
154
|
-
seedParent(ctx.db);
|
|
155
|
-
await assert.rejects(
|
|
156
|
-
expandTask(ctx.db, { id: 'parent-1' }),
|
|
157
|
-
(err) => err instanceof TaskExpandError && err.code === 'NO_LLM_CLIENT',
|
|
158
|
-
);
|
|
159
|
-
} finally {
|
|
160
|
-
cleanup(ctx);
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test('expandTask: LLM throws → LLM_CALL_FAILED', async () => {
|
|
165
|
-
const ctx = tmpDb();
|
|
166
|
-
try {
|
|
167
|
-
seedParent(ctx.db);
|
|
168
|
-
await assert.rejects(
|
|
169
|
-
expandTask(ctx.db, {
|
|
170
|
-
id: 'parent-1',
|
|
171
|
-
llmClient: fakeLlm([], { shouldFail: true }),
|
|
172
|
-
}),
|
|
173
|
-
(err) => err instanceof TaskExpandError && err.code === 'LLM_CALL_FAILED',
|
|
174
|
-
);
|
|
175
|
-
// parent status must be unchanged
|
|
176
|
-
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
|
|
177
|
-
} finally {
|
|
178
|
-
cleanup(ctx);
|
|
179
|
-
}
|
|
79
|
+
} finally { cleanup(ctx); }
|
|
180
80
|
});
|
|
181
81
|
|
|
182
|
-
test('expandTask
|
|
82
|
+
test('expandTask requires valid host-derived children and leaves the parent untouched on failure', () => {
|
|
183
83
|
const ctx = tmpDb();
|
|
184
84
|
try {
|
|
185
85
|
seedParent(ctx.db);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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); }
|
|
195
96
|
});
|
|
196
97
|
|
|
197
|
-
test('expandTask
|
|
98
|
+
test('expandTask rejects duplicate child ids', () => {
|
|
198
99
|
const ctx = tmpDb();
|
|
199
100
|
try {
|
|
200
101
|
seedParent(ctx.db);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
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
|
+
] }),
|
|
207
107
|
(err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
|
|
208
108
|
);
|
|
209
|
-
} finally {
|
|
210
|
-
cleanup(ctx);
|
|
211
|
-
}
|
|
109
|
+
} finally { cleanup(ctx); }
|
|
212
110
|
});
|
|
213
111
|
|
|
214
|
-
test('expandTask
|
|
112
|
+
test('expandTask rolls back every child when one id collides', () => {
|
|
215
113
|
const ctx = tmpDb();
|
|
216
114
|
try {
|
|
217
115
|
seedParent(ctx.db);
|
|
218
|
-
// Pre-create a task with the same id one of the children will try
|
|
219
116
|
ops.createTask(ctx.db, {
|
|
220
|
-
id: 'child-1',
|
|
221
|
-
title: 'Pre-existing task',
|
|
222
|
-
type: 'bugfix',
|
|
223
|
-
priority: 'P3',
|
|
117
|
+
id: 'child-1', title: 'Pre-existing task', type: 'bugfix', priority: 'P3',
|
|
224
118
|
});
|
|
225
|
-
|
|
226
|
-
expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN) }),
|
|
227
|
-
/* any error is acceptable — the point is no partial state */
|
|
228
|
-
);
|
|
119
|
+
assert.throws(() => expandTask(ctx.db, { id: 'parent-1', children: HAPPY_CHILDREN }));
|
|
229
120
|
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
|
|
230
|
-
// child-2 / child-3 must NOT have been inserted
|
|
231
121
|
assert.equal(ops.readTask(ctx.db, 'child-2'), null);
|
|
232
122
|
assert.equal(ops.readTask(ctx.db, 'child-3'), null);
|
|
233
|
-
} finally {
|
|
234
|
-
cleanup(ctx);
|
|
235
|
-
}
|
|
123
|
+
} finally { cleanup(ctx); }
|
|
236
124
|
});
|
|
237
125
|
|
|
238
|
-
test('expandTask
|
|
126
|
+
test('expandTask children inherit parent tag and change ownership', () => {
|
|
239
127
|
const ctx = tmpDb();
|
|
240
128
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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,
|
|
245
137
|
});
|
|
246
|
-
for (const child of
|
|
138
|
+
for (const child of result.children) {
|
|
247
139
|
const row = ops.readTask(ctx.db, child.id);
|
|
248
140
|
assert.equal(row.tag, 'feat-auth');
|
|
141
|
+
assert.equal(row.change_id, change.id);
|
|
249
142
|
}
|
|
250
|
-
} finally {
|
|
251
|
-
|
|
143
|
+
} finally { cleanup(ctx); }
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('expandTask enforces baseline authority before writing children', () => {
|
|
147
|
+
for (const baseline of ['missing', 'draft', 'migrated']) {
|
|
148
|
+
const ctx = tmpDb();
|
|
149
|
+
try {
|
|
150
|
+
seedParent(ctx.db);
|
|
151
|
+
if (baseline === 'missing') ctx.db.prepare('DELETE FROM baselines').run();
|
|
152
|
+
else if (baseline === 'draft') {
|
|
153
|
+
ctx.db.prepare("UPDATE baselines SET status = 'draft' WHERE id = 'test-baseline'").run();
|
|
154
|
+
} else {
|
|
155
|
+
ctx.db.prepare(
|
|
156
|
+
"UPDATE baselines SET mode = 'migrated', status = 'adopting' WHERE id = 'test-baseline'",
|
|
157
|
+
).run();
|
|
158
|
+
}
|
|
159
|
+
assert.throws(
|
|
160
|
+
() => expandTask(ctx.db, {
|
|
161
|
+
id: 'parent-1', children: HAPPY_CHILDREN, rootDir: ctx.dir,
|
|
162
|
+
}),
|
|
163
|
+
(error) => error.code === 'BASELINE_NOT_READY',
|
|
164
|
+
);
|
|
165
|
+
assert.equal(ops.readTask(ctx.db, 'child-1'), null);
|
|
166
|
+
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
|
|
167
|
+
} finally { cleanup(ctx); }
|
|
252
168
|
}
|
|
253
169
|
});
|
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,28 +130,15 @@ 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': {
|
|
149
136
|
const { randomUUID } = require('node:crypto');
|
|
150
137
|
const id = input.id || `task-${randomUUID()}`;
|
|
151
|
-
const
|
|
138
|
+
const changeId = ops.resolveTaskCreationChangeId(db, input);
|
|
139
|
+
const authorizedInput = { ...input, change_id: changeId };
|
|
140
|
+
changes.assertTaskCreationAllowed(db, authorizedInput, { rootDir: ctx.rootDir || process.cwd() });
|
|
141
|
+
const task = ops.createTask(db, { ...authorizedInput, id });
|
|
152
142
|
return { id: task.id, status: task.status, created_at: task.created_at };
|
|
153
143
|
}
|
|
154
144
|
case 'task.update': {
|
|
@@ -280,39 +270,44 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
280
270
|
return { waves: result.waves };
|
|
281
271
|
}
|
|
282
272
|
case 'task.parse_prd': {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
273
|
+
const dryRun = input.dry_run === true;
|
|
274
|
+
const rootDir = ctx.rootDir || process.cwd();
|
|
275
|
+
const creationAuthority = { change_id: input.change_id };
|
|
276
|
+
if (!dryRun) changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
|
|
277
|
+
const parsed = parser.parsePrd(input.tasks, {
|
|
278
|
+
tag: input.tag,
|
|
279
|
+
changeId: input.change_id,
|
|
280
|
+
});
|
|
291
281
|
const shaped = parsed.tasks.map((t) => ({
|
|
292
282
|
id: t.id, title: t.title, type: t.type, priority: t.priority,
|
|
293
283
|
complexity: t.complexity, deps: t.deps, files_modified: t.files_modified,
|
|
294
|
-
tag: t.tag,
|
|
284
|
+
tag: t.tag, change_id: t.change_id,
|
|
295
285
|
}));
|
|
296
|
-
const
|
|
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
|
+
}
|
|
297
294
|
if (!dryRun) {
|
|
298
295
|
try {
|
|
299
296
|
ops.tx(db, () => {
|
|
297
|
+
changes.assertTaskCreationAllowed(db, creationAuthority, { rootDir });
|
|
300
298
|
for (const t of shaped) ops.createTask(db, t);
|
|
301
299
|
});
|
|
302
300
|
} catch (err) {
|
|
301
|
+
if (['BASELINE_NOT_READY', 'CHANGE_NOT_FOUND', 'CHANGE_NOT_MUTABLE'].includes(err.code)) throw err;
|
|
303
302
|
const wrap = new Error(`failed to persist parsed tasks: ${err.message}`);
|
|
304
303
|
wrap.code = 'PARSE_FAILED';
|
|
305
304
|
wrap.cause = err;
|
|
306
305
|
throw wrap;
|
|
307
306
|
}
|
|
308
307
|
}
|
|
309
|
-
const graph = shaped.map((t) => ({ id: t.id, deps: t.deps || [] }));
|
|
310
|
-
const topoResult = topo.computeWaves(graph);
|
|
311
308
|
return { tasks: shaped, topo: topoResult.waves };
|
|
312
309
|
}
|
|
313
310
|
case 'task.expand': {
|
|
314
|
-
// Cheap guards before paying for LLM client construction (which would
|
|
315
|
-
// throw NO_LLM_CREDENTIALS when env keys are missing).
|
|
316
311
|
const parent = ops.readTask(db, input.id);
|
|
317
312
|
if (!parent) {
|
|
318
313
|
const err = new Error(`no task ${input.id}`);
|
|
@@ -324,12 +319,12 @@ async function dispatchTool(name, input, db, ctx = {}) {
|
|
|
324
319
|
err.code = 'ALREADY_EXPANDED';
|
|
325
320
|
throw err;
|
|
326
321
|
}
|
|
327
|
-
const
|
|
328
|
-
|
|
322
|
+
const rootDir = ctx.rootDir || process.cwd();
|
|
323
|
+
changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
|
|
324
|
+
const result = expander.expandTask(db, {
|
|
329
325
|
id: input.id,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
llmClient: client,
|
|
326
|
+
children: input.children,
|
|
327
|
+
rootDir,
|
|
333
328
|
});
|
|
334
329
|
return { parent_id: result.parent_id, children: result.children };
|
|
335
330
|
}
|
|
@@ -648,4 +643,6 @@ module.exports = {
|
|
|
648
643
|
REGISTERED_TOOLS,
|
|
649
644
|
STATELESS_TOOLS,
|
|
650
645
|
MUTATING_TOOLS,
|
|
646
|
+
readProjectBreadcrumb,
|
|
647
|
+
renderProjectBreadcrumb,
|
|
651
648
|
};
|
|
@@ -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 {
|