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.
Files changed (88) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +95 -1
  4. package/README.md +31 -16
  5. package/adapters/_shared/codex-assets.cjs +23 -163
  6. package/adapters/_shared/handbook.cjs +2 -2
  7. package/adapters/_shared/runtime-assets.cjs +1 -0
  8. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  9. package/adapters/opencode.js +30 -120
  10. package/adapters/tests/claude.test.cjs +3 -2
  11. package/adapters/tests/codex-hook.test.cjs +34 -7
  12. package/adapters/tests/codex.test.cjs +21 -6
  13. package/adapters/tests/kimi-hook.test.cjs +34 -7
  14. package/adapters/tests/opencode.test.cjs +70 -84
  15. package/commands/ultra-init.md +1 -1
  16. package/docs/AGENT-CONTEXT.md +10 -6
  17. package/docs/ARCHITECTURE.md +40 -17
  18. package/docs/PLAN.zh-CN.md +5 -4
  19. package/docs/ROADMAP.md +2 -2
  20. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  21. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  22. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  23. package/hooks/active_task_context.py +22 -50
  24. package/hooks/context_spine.py +61 -299
  25. package/hooks/health_check.py +19 -17
  26. package/hooks/pre_stop_check.py +28 -33
  27. package/hooks/subagent_tracker.py +13 -19
  28. package/hooks/workflow_checkpoint.py +39 -47
  29. package/hooks/workflow_context.py +4 -9
  30. package/hooks/workflow_resume.py +48 -128
  31. package/mcp-server/breadcrumb.cjs +22 -0
  32. package/mcp-server/lib/archive-journal.cjs +192 -0
  33. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  34. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  35. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  36. package/mcp-server/lib/change-workflow.cjs +249 -75
  37. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  38. package/mcp-server/lib/context-spine.test.cjs +5 -0
  39. package/mcp-server/lib/doctor.cjs +14 -1
  40. package/mcp-server/lib/doctor.test.cjs +42 -4
  41. package/mcp-server/lib/init-project.cjs +292 -9
  42. package/mcp-server/lib/prd-parser.cjs +14 -41
  43. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  44. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  45. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  46. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  47. package/mcp-server/lib/state-authority.cjs +9 -3
  48. package/mcp-server/lib/state-db.cjs +154 -32
  49. package/mcp-server/lib/state-db.test.cjs +136 -4
  50. package/mcp-server/lib/state-ops.cjs +68 -6
  51. package/mcp-server/lib/task-expander.cjs +14 -37
  52. package/mcp-server/lib/task-expander.test.cjs +80 -164
  53. package/mcp-server/server.cjs +34 -37
  54. package/mcp-server/tests/init-project.test.cjs +162 -1
  55. package/mcp-server/tests/migrate.test.cjs +33 -0
  56. package/mcp-server/tests/server-contract.test.cjs +391 -17
  57. package/mcp-server/tests/state-db.test.cjs +14 -2
  58. package/mcp-server/tests/state-ops.test.cjs +108 -0
  59. package/package.json +2 -4
  60. package/skills/cc-collab/SKILL.md +2 -2
  61. package/skills/codex-collab/SKILL.md +2 -3
  62. package/skills/ultra-change/SKILL.md +18 -10
  63. package/skills/ultra-deliver/SKILL.md +13 -9
  64. package/skills/ultra-dev/SKILL.md +6 -4
  65. package/skills/ultra-doctor/SKILL.md +22 -7
  66. package/skills/ultra-init/SKILL.md +104 -59
  67. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  68. package/skills/ultra-plan/SKILL.md +20 -7
  69. package/skills/ultra-research/SKILL.md +12 -5
  70. package/skills/ultra-status/SKILL.md +6 -4
  71. package/skills/ultra-verify/SKILL.md +21 -30
  72. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  73. package/spec/cli-protocol.md +11 -1
  74. package/spec/mcp-tools.yaml +133 -19
  75. package/spec/schemas/state-db.sql +11 -0
  76. package/ultra-tools/cli.cjs +9 -5
  77. package/ultra-tools/commands/migrate.cjs +62 -36
  78. package/ultra-tools/commands/status.cjs +18 -9
  79. package/ultra-tools/commands/system.cjs +340 -6
  80. package/ultra-tools/commands/task.cjs +7 -1
  81. package/ultra-tools/status-command.test.cjs +29 -0
  82. package/ultra-tools/system-command.test.cjs +252 -1
  83. package/mcp-server/lib/llm-client.cjs +0 -157
  84. package/mcp-server/lib/llm-client.test.cjs +0 -156
  85. package/orchestrator/planner/expand-prompt.cjs +0 -62
  86. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  87. package/orchestrator/planner/prd-prompt.cjs +0 -44
  88. 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', type: 'architecture', priority: 'P1', complexity: 4, deps: [], files_modified: ['src/auth/schema.ts'] },
60
- { id: 'child-2', title: 'Implement login endpoint', type: 'feature', priority: 'P1', complexity: 5, deps: ['child-1'], files_modified: ['src/auth/login.ts'] },
61
- { 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'] },
62
46
  ];
63
47
 
64
- 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', () => {
65
49
  const ctx = tmpDb();
66
50
  try {
67
51
  seedParent(ctx.db);
68
- const result = await expandTask(ctx.db, {
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
- const events = ops.subscribeEventsSince(ctx.db, { since_id: 0, limit: 100 });
87
- const expandedEvents = events.events.filter((e) => e.type === 'task_expanded');
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: missing parent → TASK_NOT_FOUND', async () => {
66
+ test('expandTask rejects missing and already expanded parents', () => {
98
67
  const ctx = tmpDb();
99
68
  try {
100
- await assert.rejects(
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
- // First expansion succeeds
117
- await expandTask(ctx.db, {
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: invalid child shape → INVALID_OUTPUT, parent untouched', async () => {
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 bad = [{ id: 'child-1', title: 'x', type: 'feature', priority: 'P1' }]; // title too short
187
- await assert.rejects(
188
- expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(bad) }),
189
- (err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
190
- );
191
- assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
192
- } finally {
193
- cleanup(ctx);
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: duplicate child id → INVALID_OUTPUT', async () => {
98
+ test('expandTask rejects duplicate child ids', () => {
198
99
  const ctx = tmpDb();
199
100
  try {
200
101
  seedParent(ctx.db);
201
- const dup = [
202
- { id: 'same', title: 'First child', type: 'feature', priority: 'P2' },
203
- { id: 'same', title: 'Second child', type: 'feature', priority: 'P2' },
204
- ];
205
- await assert.rejects(
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: 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', () => {
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
- await assert.rejects(
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: children inherit parent tag', async () => {
126
+ test('expandTask children inherit parent tag and change ownership', () => {
239
127
  const ctx = tmpDb();
240
128
  try {
241
- seedParent(ctx.db, { tag: 'feat-auth' });
242
- await expandTask(ctx.db, {
243
- id: 'parent-1',
244
- 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,
245
137
  });
246
- for (const child of HAPPY_CHILDREN) {
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
- cleanup(ctx);
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
  });
@@ -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 task = ops.createTask(db, { ...input, id });
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 prdText = resolvePrdText(input, ctx);
284
- if (!prdText || !prdText.trim()) {
285
- const err = new Error('one of prd_path or prd_text required');
286
- err.code = 'NO_INPUT';
287
- throw err;
288
- }
289
- const client = buildLlmClient(ctx);
290
- const parsed = await parser.parsePrd(prdText, { llmClient: client, tag: input.tag });
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 dryRun = input.dry_run === true;
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 client = buildLlmClient(ctx);
328
- const result = await expander.expandTask(db, {
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
- sub_count: input.sub_count,
331
- strategy: input.strategy,
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 { initProject, InitProjectError, DEFAULT_TEMPLATE } = require('../lib/init-project.cjs');
11
+ const {
12
+ initProject, InitProjectError, DEFAULT_TEMPLATE, classifyRepository,
13
+ } = require('../lib/init-project.cjs');
12
14
  const { EXPECTED_VERSION } = require('../lib/state-db.cjs');
13
15
 
14
16
  function mkTempDir(prefix = 'ubp-init-test-') {
@@ -71,6 +73,85 @@ test('initProject auto-detects existing source as brownfield and starts adoption
71
73
  } finally { cleanup(target); }
72
74
  });
73
75
 
76
+ test('auto classification keeps a documentation-and-manifest-only skeleton greenfield', () => {
77
+ const target = mkTempDir();
78
+ try {
79
+ fs.writeFileSync(path.join(target, 'README.md'), '# New service skeleton\n');
80
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
81
+ name: 'new-service', private: true, scripts: { test: 'node --test' },
82
+ }));
83
+ const profile = classifyRepository(target);
84
+ assert.equal(profile.mode, 'greenfield');
85
+ assert.equal(profile.repository_kind, 'single');
86
+ assert.deepEqual(profile.source_signals, []);
87
+
88
+ const result = initProject({ target_dir: target, project_name: 'new-service' });
89
+ assert.equal(result.mode, 'greenfield');
90
+ assert.deepEqual(result.repository_profile.verification_commands, ['npm test']);
91
+ } finally { cleanup(target); }
92
+ });
93
+
94
+ test('auto classification records bounded monorepo evidence for brownfield adoption', () => {
95
+ const target = mkTempDir();
96
+ try {
97
+ fs.mkdirSync(path.join(target, 'packages', 'api', 'src'), { recursive: true });
98
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
99
+ name: 'legacy-suite', private: true, workspaces: ['packages/*'],
100
+ scripts: { build: 'turbo build', test: 'turbo test', lint: 'turbo lint' },
101
+ }));
102
+ fs.writeFileSync(path.join(target, 'packages', 'api', 'package.json'), '{"name":"api"}\n');
103
+ fs.writeFileSync(path.join(target, 'packages', 'api', 'src', 'index.js'), 'module.exports = true;\n');
104
+ fs.writeFileSync(path.join(target, 'Dockerfile'), 'FROM node:22\n');
105
+
106
+ const result = initProject({ target_dir: target, project_name: 'legacy-suite' });
107
+ assert.equal(result.mode, 'brownfield');
108
+ assert.equal(result.repository_profile.repository_kind, 'monorepo');
109
+ assert.deepEqual(result.repository_profile.workspace_roots, ['packages/api']);
110
+ assert.ok(result.repository_profile.source_signals.includes('packages/api/src/index.js'));
111
+ assert.ok(result.repository_profile.deployment_signals.includes('Dockerfile'));
112
+ assert.deepEqual(
113
+ result.repository_profile.verification_commands,
114
+ ['npm run build', 'npm test', 'npm run lint'],
115
+ );
116
+
117
+ const db = new Database(result.state_db_path, { readonly: true });
118
+ try {
119
+ const row = db.prepare(
120
+ 'SELECT repository_branch, worktree_state, classification_json FROM baselines',
121
+ ).get();
122
+ assert.equal(row.repository_branch, null);
123
+ assert.equal(row.worktree_state, 'unavailable');
124
+ assert.equal(JSON.parse(row.classification_json).repository_kind, 'monorepo');
125
+ } finally { db.close(); }
126
+ } finally { cleanup(target); }
127
+ });
128
+
129
+ test('brownfield initialization persists an explicitly selected monorepo scope', () => {
130
+ const target = mkTempDir();
131
+ try {
132
+ fs.mkdirSync(path.join(target, 'packages', 'api', 'src'), { recursive: true });
133
+ fs.mkdirSync(path.join(target, 'packages', 'web', 'src'), { recursive: true });
134
+ fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({
135
+ name: 'legacy-suite', private: true, workspaces: ['packages/*'],
136
+ }));
137
+ fs.writeFileSync(path.join(target, 'packages', 'api', 'src', 'index.js'), 'module.exports = true;\n');
138
+ fs.writeFileSync(path.join(target, 'packages', 'web', 'src', 'index.js'), 'module.exports = true;\n');
139
+
140
+ const result = initProject({
141
+ target_dir: target,
142
+ project_name: 'legacy-suite',
143
+ scope: ['packages/api'],
144
+ });
145
+
146
+ const db = new Database(result.state_db_path, { readonly: true });
147
+ try {
148
+ const baseline = db.prepare('SELECT scope_json FROM baselines').get();
149
+ assert.deepEqual(JSON.parse(baseline.scope_json), ['packages/api']);
150
+ assert.deepEqual(result.baseline.scope, ['packages/api']);
151
+ } finally { db.close(); }
152
+ } finally { cleanup(target); }
153
+ });
154
+
74
155
  test('maintenance CLI forwards an explicit initialization mode', () => {
75
156
  const target = mkTempDir();
76
157
  try {
@@ -110,6 +191,25 @@ test('initProject keeps project metadata in authoritative baseline state and tas
110
191
  } finally { cleanup(target); }
111
192
  });
112
193
 
194
+ test('initProject records the declared project_initialized lifecycle event', () => {
195
+ const rootDir = mkTempDir();
196
+ try {
197
+ const result = initProject({ target_dir: rootDir, project_name: 'events-fixture' });
198
+ const db = new Database(result.state_db_path, { readonly: true });
199
+ try {
200
+ const events = db.prepare('SELECT type, payload_json FROM events ORDER BY id').all();
201
+ assert.deepEqual(events.map((row) => row.type), ['baseline_started', 'project_initialized']);
202
+ assert.deepEqual(JSON.parse(events[1].payload_json), {
203
+ project_name: 'events-fixture', mode: 'greenfield', baseline_id: 'project-baseline',
204
+ });
205
+ } finally {
206
+ db.close();
207
+ }
208
+ } finally {
209
+ fs.rmSync(rootDir, { recursive: true, force: true });
210
+ }
211
+ });
212
+
113
213
  test('initProject refuses when .ultra/ already exists and overwrite=false', () => {
114
214
  const target = mkTempDir();
115
215
  try {
@@ -121,6 +221,67 @@ test('initProject refuses when .ultra/ already exists and overwrite=false', () =
121
221
  } finally { cleanup(target); }
122
222
  });
123
223
 
224
+ test('initProject resume preserves existing artifacts and installs only missing current scaffold files', () => {
225
+ const target = mkTempDir();
226
+ try {
227
+ fs.mkdirSync(path.join(target, '.ultra', 'specs'), { recursive: true });
228
+ fs.writeFileSync(path.join(target, '.ultra', 'specs', 'product.md'), '# Existing product contract\n');
229
+
230
+ const result = initProject({
231
+ target_dir: target, project_name: 'existing-project', mode: 'brownfield', resume: true,
232
+ });
233
+ assert.equal(result.status, 'resumed');
234
+ assert.equal(
235
+ fs.readFileSync(path.join(target, '.ultra', 'specs', 'product.md'), 'utf8'),
236
+ '# Existing product contract\n',
237
+ );
238
+ assert.ok(fs.existsSync(path.join(target, '.ultra', 'specs', 'architecture.md')));
239
+ assert.ok(result.copied_files.includes('specs/architecture.md'));
240
+ assert.equal(result.copied_files.includes('specs/product.md'), false);
241
+
242
+ const second = initProject({
243
+ target_dir: target, project_name: 'existing-project', resume: true,
244
+ });
245
+ assert.equal(second.status, 'resumed');
246
+ assert.deepEqual(second.copied_files, []);
247
+ const db = new Database(result.state_db_path, { readonly: true });
248
+ try {
249
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM baselines').get().count, 1);
250
+ } finally { db.close(); }
251
+ } finally { cleanup(target); }
252
+ });
253
+
254
+ test('initProject resume refuses projection-only tasks until the supported import runs', () => {
255
+ const target = mkTempDir();
256
+ try {
257
+ fs.mkdirSync(path.join(target, '.ultra', 'tasks'), { recursive: true });
258
+ fs.writeFileSync(path.join(target, '.ultra', 'tasks', 'tasks.json'), JSON.stringify({
259
+ schema_version: '4.5', source: '.ultra/state.db',
260
+ tasks: [{ id: 'legacy', title: 'Legacy task', type: 'feature', priority: 'P1', status: 'pending' }],
261
+ }));
262
+ assert.throws(
263
+ () => initProject({ target_dir: target, project_name: 'legacy', resume: true }),
264
+ (error) => error.code === 'LEGACY_STATE_MIGRATION_REQUIRED'
265
+ && /--from=4\.5 --to=12\.0/.test(error.message),
266
+ );
267
+ assert.equal(fs.existsSync(path.join(target, '.ultra', 'state.db')), false);
268
+ assert.equal(fs.existsSync(path.join(target, '.ultra', 'specs', 'product.md')), false);
269
+ } finally { cleanup(target); }
270
+ });
271
+
272
+ test('initProject rejects conflicting resume and overwrite modes', () => {
273
+ const target = mkTempDir();
274
+ try {
275
+ fs.mkdirSync(path.join(target, '.ultra'));
276
+ assert.throws(
277
+ () => initProject({
278
+ target_dir: target, project_name: 'conflict', resume: true, overwrite: true,
279
+ }),
280
+ (error) => error.code === 'VALIDATION_ERROR' && /resume.*overwrite/i.test(error.message),
281
+ );
282
+ } finally { cleanup(target); }
283
+ });
284
+
124
285
  test('initProject with overwrite=true backs up existing .ultra/', () => {
125
286
  const target = mkTempDir();
126
287
  try {