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
@@ -17,7 +17,7 @@ function fixture() {
17
17
  db.prepare(
18
18
  `INSERT INTO baselines
19
19
  (id, project_name, mode, status, approved_by, approval_note, converged_at)
20
- VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
20
+ VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
21
21
  ).run(new Date().toISOString());
22
22
  closeStateDb(db);
23
23
  return rootDir;
@@ -56,3 +56,254 @@ test('system doctor --repair creates a backup before recovery', () => {
56
56
  fs.rmSync(rootDir, { recursive: true, force: true });
57
57
  }
58
58
  });
59
+
60
+ test('system doctor --repair migrates an older schema only after preserving a pre-migration backup', () => {
61
+ const rootDir = fixture();
62
+ try {
63
+ const dbPath = path.join(rootDir, '.ultra', 'state.db');
64
+ const Database = require('better-sqlite3');
65
+ const db = new Database(dbPath);
66
+ db.prepare("DELETE FROM schema_version WHERE version = '12.0'").run();
67
+ db.close();
68
+
69
+ const result = invoke(rootDir, ['doctor', '--repair']);
70
+ assert.equal(result.status, 0, result.stderr);
71
+ const envelope = JSON.parse(result.stdout.trim().split('\n').at(-1));
72
+ assert.equal(envelope.ok, true);
73
+ assert.equal(envelope.data.schema_version, '12.0');
74
+ assert.ok(fs.existsSync(envelope.data.schema_migration_backup_path));
75
+ assert.ok(fs.existsSync(envelope.data.backup_path));
76
+ } finally {
77
+ fs.rmSync(rootDir, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ test('system doctor reports a corrupt database as structured recovery guidance', () => {
82
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-system-corrupt-'));
83
+ try {
84
+ fs.mkdirSync(path.join(rootDir, '.ultra'), { recursive: true });
85
+ fs.writeFileSync(path.join(rootDir, '.ultra', 'state.db'), 'not a sqlite database');
86
+ const result = invoke(rootDir, ['doctor']);
87
+ assert.equal(result.status, 2);
88
+ const envelope = JSON.parse(result.stdout.trim().split('\n').at(-1));
89
+ assert.equal(envelope.ok, false);
90
+ assert.equal(envelope.error.code, 'STATE_DB_CORRUPT');
91
+ assert.match(envelope.error.message, /restore.*backup|rebaseline/i);
92
+ } finally {
93
+ fs.rmSync(rootDir, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test('system restore replaces only a corrupt state database from a verified managed backup', () => {
98
+ const rootDir = fixture();
99
+ try {
100
+ const dbPath = path.join(rootDir, '.ultra', 'state.db');
101
+ const backupsDir = path.join(rootDir, '.ultra', 'backups');
102
+ fs.mkdirSync(backupsDir, { recursive: true });
103
+ const sourceBackup = path.join(backupsDir, 'verified.db');
104
+ fs.copyFileSync(dbPath, sourceBackup);
105
+ fs.writeFileSync(dbPath, 'not a sqlite database');
106
+
107
+ const refused = invoke(rootDir, ['restore', '--backup', sourceBackup]);
108
+ assert.equal(refused.status, 1);
109
+ assert.equal(JSON.parse(refused.stdout.trim()).error.code, 'CONFIRMATION_REQUIRED');
110
+ assert.equal(fs.readFileSync(dbPath, 'utf8'), 'not a sqlite database');
111
+
112
+ const restored = invoke(rootDir, [
113
+ 'restore', '--backup', sourceBackup,
114
+ '--confirm', 'REPLACE_CORRUPT_ULTRA_STATE',
115
+ ]);
116
+ assert.equal(restored.status, 0, restored.stderr);
117
+ const envelope = JSON.parse(restored.stdout.trim());
118
+ assert.equal(envelope.ok, true);
119
+ assert.equal(envelope.data.schema_version, '12.0');
120
+ assert.equal(envelope.data.source_backup, fs.realpathSync(sourceBackup));
121
+ assert.ok(fs.existsSync(envelope.data.quarantined_state_path));
122
+ assert.equal(
123
+ fs.readFileSync(envelope.data.quarantined_state_path, 'utf8'),
124
+ 'not a sqlite database',
125
+ );
126
+ const restoredDb = require('better-sqlite3')(dbPath, { readonly: true });
127
+ try {
128
+ assert.equal(restoredDb.prepare('SELECT COUNT(*) AS count FROM baselines').get().count, 1);
129
+ } finally { restoredDb.close(); }
130
+ } finally {
131
+ fs.rmSync(rootDir, { recursive: true, force: true });
132
+ }
133
+ });
134
+
135
+ test('system rebaseline preserves corrupt authority and legacy projection before starting brownfield adoption', () => {
136
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-system-rebaseline-'));
137
+ try {
138
+ fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true });
139
+ fs.mkdirSync(path.join(rootDir, '.ultra', 'tasks', 'contexts'), { recursive: true });
140
+ fs.writeFileSync(path.join(rootDir, 'src', 'index.js'), 'module.exports = true;\n');
141
+ fs.writeFileSync(path.join(rootDir, '.ultra', 'state.db'), 'broken authority');
142
+ const tasksPath = path.join(rootDir, '.ultra', 'tasks', 'tasks.json');
143
+ fs.writeFileSync(tasksPath, `${JSON.stringify({
144
+ schema_version: '4.5', source: '.ultra/state.db',
145
+ tasks: [{ id: 'legacy-task', title: 'Preserved task', type: 'feature', priority: 'P1', status: 'pending' }],
146
+ }, null, 2)}\n`);
147
+ fs.writeFileSync(
148
+ path.join(rootDir, '.ultra', 'tasks', 'contexts', 'legacy-task.md'),
149
+ '# Preserved task context\n',
150
+ );
151
+
152
+ const result = invoke(rootDir, [
153
+ 'rebaseline', '--project-name', 'legacy-app',
154
+ '--confirm', 'REBASELINE_CORRUPT_ULTRA_STATE',
155
+ ]);
156
+ assert.equal(result.status, 0, result.stderr);
157
+ const envelope = JSON.parse(result.stdout.trim());
158
+ assert.equal(envelope.ok, true);
159
+ assert.equal(envelope.data.baseline.mode, 'brownfield');
160
+ assert.equal(envelope.data.baseline.status, 'adopting');
161
+ assert.ok(fs.existsSync(envelope.data.quarantined_state_path));
162
+ assert.ok(fs.existsSync(envelope.data.legacy_projection_path));
163
+ assert.equal(
164
+ JSON.parse(fs.readFileSync(envelope.data.legacy_projection_path, 'utf8')).tasks[0].id,
165
+ 'legacy-task',
166
+ );
167
+ assert.ok(fs.existsSync(path.join(
168
+ envelope.data.recovery_backup_dir, 'tasks', 'contexts', 'legacy-task.md',
169
+ )));
170
+ assert.equal(
171
+ fs.existsSync(path.join(rootDir, '.ultra', 'tasks', 'contexts', 'legacy-task.md')),
172
+ false,
173
+ );
174
+ assert.deepEqual(JSON.parse(fs.readFileSync(tasksPath, 'utf8')).tasks, []);
175
+ } finally {
176
+ fs.rmSync(rootDir, { recursive: true, force: true });
177
+ }
178
+ });
179
+
180
+ test('system restore rolls back the corrupt database and sidecars when replacement initialization fails', () => {
181
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-system-restore-rollback-'));
182
+ try {
183
+ const ultraDir = path.join(rootDir, '.ultra');
184
+ const backupsDir = path.join(ultraDir, 'backups');
185
+ fs.mkdirSync(backupsDir, { recursive: true });
186
+ const statePath = path.join(ultraDir, 'state.db');
187
+ const originals = {
188
+ '': Buffer.from('broken authority'),
189
+ '-wal': Buffer.from('broken wal'),
190
+ '-shm': Buffer.from('broken shm'),
191
+ };
192
+ for (const [suffix, content] of Object.entries(originals)) {
193
+ fs.writeFileSync(`${statePath}${suffix}`, content);
194
+ }
195
+
196
+ const incompatibleBackup = path.join(backupsDir, 'structurally-incompatible.db');
197
+ const backupDb = require('better-sqlite3')(incompatibleBackup);
198
+ backupDb.exec("CREATE TABLE schema_version (version TEXT NOT NULL); INSERT INTO schema_version VALUES ('11.0');");
199
+ backupDb.close();
200
+
201
+ const result = invoke(rootDir, [
202
+ 'restore', '--backup', incompatibleBackup,
203
+ '--confirm', 'REPLACE_CORRUPT_ULTRA_STATE',
204
+ ]);
205
+ assert.notEqual(result.status, 0, result.stderr);
206
+ const envelope = JSON.parse(result.stdout.trim());
207
+ assert.equal(envelope.ok, false);
208
+ assert.equal(envelope.error.code, 'STATE_RESTORE_FAILED');
209
+ for (const [suffix, content] of Object.entries(originals)) {
210
+ assert.deepEqual(fs.readFileSync(`${statePath}${suffix}`), content);
211
+ }
212
+ } finally {
213
+ fs.rmSync(rootDir, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test('system rebaseline rolls back authority, sidecars, and the full task projection when adoption fails', () => {
218
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-system-rebaseline-rollback-'));
219
+ try {
220
+ const ultraDir = path.join(rootDir, '.ultra');
221
+ const tasksDir = path.join(ultraDir, 'tasks');
222
+ fs.mkdirSync(path.join(tasksDir, 'contexts'), { recursive: true });
223
+ fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true });
224
+ fs.writeFileSync(path.join(rootDir, 'src', 'index.js'), 'module.exports = true;\n');
225
+ const statePath = path.join(ultraDir, 'state.db');
226
+ const originals = {
227
+ '': Buffer.from('broken authority'),
228
+ '-wal': Buffer.from('broken wal'),
229
+ '-shm': Buffer.from('broken shm'),
230
+ };
231
+ for (const [suffix, content] of Object.entries(originals)) {
232
+ fs.writeFileSync(`${statePath}${suffix}`, content);
233
+ }
234
+ const tasksPath = path.join(tasksDir, 'tasks.json');
235
+ const contextPath = path.join(tasksDir, 'contexts', 'legacy.md');
236
+ const tasksContent = Buffer.from('{"schema_version":"4.5","source":".ultra/state.db","tasks":[]}\n');
237
+ const contextContent = Buffer.from('# Legacy context\n');
238
+ fs.writeFileSync(tasksPath, tasksContent);
239
+ fs.writeFileSync(contextPath, contextContent);
240
+
241
+ const result = invoke(rootDir, [
242
+ 'rebaseline', '--project-name', 'legacy-app', '--scope', 'missing-workspace',
243
+ '--confirm', 'REBASELINE_CORRUPT_ULTRA_STATE',
244
+ ]);
245
+ assert.notEqual(result.status, 0, result.stderr);
246
+ const envelope = JSON.parse(result.stdout.trim());
247
+ assert.equal(envelope.ok, false);
248
+ assert.equal(envelope.error.code, 'IO_ERROR');
249
+ for (const [suffix, content] of Object.entries(originals)) {
250
+ assert.deepEqual(fs.readFileSync(`${statePath}${suffix}`), content);
251
+ }
252
+ assert.deepEqual(fs.readFileSync(tasksPath), tasksContent);
253
+ assert.deepEqual(fs.readFileSync(contextPath), contextContent);
254
+ } finally {
255
+ fs.rmSync(rootDir, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test('system restore rejects backups outside the managed project backup directory', () => {
260
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-system-external-backup-'));
261
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-external-backup-'));
262
+ try {
263
+ fs.mkdirSync(path.join(rootDir, '.ultra'), { recursive: true });
264
+ fs.mkdirSync(path.join(rootDir, '.ultra', 'backups'), { recursive: true });
265
+ fs.writeFileSync(path.join(rootDir, '.ultra', 'state.db'), 'broken authority');
266
+ const external = path.join(externalDir, 'state.db');
267
+ fs.writeFileSync(external, 'not trusted');
268
+ const result = invoke(rootDir, [
269
+ 'restore', '--backup', external,
270
+ '--confirm', 'REPLACE_CORRUPT_ULTRA_STATE',
271
+ ]);
272
+ assert.equal(result.status, 1);
273
+ assert.equal(JSON.parse(result.stdout.trim()).error.code, 'BACKUP_OUTSIDE_MANAGED_ROOT');
274
+ } finally {
275
+ fs.rmSync(rootDir, { recursive: true, force: true });
276
+ fs.rmSync(externalDir, { recursive: true, force: true });
277
+ }
278
+ });
279
+
280
+ test('system corrupt-state recovery commands refuse to replace readable authority', () => {
281
+ const rootDir = fixture();
282
+ try {
283
+ const backupsDir = path.join(rootDir, '.ultra', 'backups');
284
+ fs.mkdirSync(backupsDir, { recursive: true });
285
+ const backup = path.join(backupsDir, 'healthy.db');
286
+ fs.copyFileSync(path.join(rootDir, '.ultra', 'state.db'), backup);
287
+
288
+ const restore = invoke(rootDir, [
289
+ 'restore', '--backup', backup,
290
+ '--confirm', 'REPLACE_CORRUPT_ULTRA_STATE',
291
+ ]);
292
+ assert.equal(restore.status, 2);
293
+ assert.equal(JSON.parse(restore.stdout.trim()).error.code, 'STATE_DB_NOT_CORRUPT');
294
+
295
+ const rebaseline = invoke(rootDir, [
296
+ 'rebaseline', '--project-name', 'fixture',
297
+ '--confirm', 'REBASELINE_CORRUPT_ULTRA_STATE',
298
+ ]);
299
+ assert.equal(rebaseline.status, 2);
300
+ assert.equal(JSON.parse(rebaseline.stdout.trim()).error.code, 'STATE_DB_NOT_CORRUPT');
301
+
302
+ const db = require('better-sqlite3')(path.join(rootDir, '.ultra', 'state.db'), { readonly: true });
303
+ try {
304
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM baselines').get().count, 1);
305
+ } finally { db.close(); }
306
+ } finally {
307
+ fs.rmSync(rootDir, { recursive: true, force: true });
308
+ }
309
+ });
@@ -1,157 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 8A.1 — Unified LLM client over @anthropic-ai/sdk and openai.
4
- //
5
- // Provider is inferred from model prefix (claude/opus/sonnet/haiku → Anthropic,
6
- // gpt/o1/o3 → OpenAI). API keys come from env. The single surface the rest of
7
- // the code depends on is `client.completeJson({ system, user, maxTokens })`,
8
- // which returns `{ json, raw, usage, model, provider }` — the model's reply
9
- // already parsed as JSON (caller handles schema validation on `.json`).
10
-
11
- const Anthropic = require('@anthropic-ai/sdk');
12
- const OpenAI = require('openai');
13
-
14
- const PROVIDER_RULES = Object.freeze([
15
- { pattern: /^(claude|opus|sonnet|haiku)/i, provider: 'anthropic' },
16
- { pattern: /^(gpt|o1|o3)/i, provider: 'openai' },
17
- ]);
18
-
19
- const DEFAULT_MODEL = 'claude-sonnet-4-6';
20
- const DEFAULT_MAX_TOKENS = 4096;
21
-
22
- class LlmClientError extends Error {
23
- constructor(code, message, cause) {
24
- super(message);
25
- this.name = 'LlmClientError';
26
- this.code = code;
27
- if (cause) this.cause = cause;
28
- }
29
- }
30
-
31
- function inferProvider(model) {
32
- if (!model || typeof model !== 'string') return null;
33
- for (const rule of PROVIDER_RULES) {
34
- if (rule.pattern.test(model)) return rule.provider;
35
- }
36
- return null;
37
- }
38
-
39
- function resolveProviderKey(provider, apiKey) {
40
- if (apiKey) return apiKey;
41
- return provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
42
- }
43
-
44
- function createLlmClient({ model, apiKey } = {}) {
45
- const resolvedModel = model || process.env.UBP_PLANNER_MODEL || DEFAULT_MODEL;
46
- const provider = inferProvider(resolvedModel);
47
- if (!provider) {
48
- throw new LlmClientError(
49
- 'LLM_UNSUPPORTED_MODEL',
50
- `cannot infer provider from model "${resolvedModel}"`,
51
- );
52
- }
53
- const key = resolveProviderKey(provider, apiKey);
54
- if (!key) {
55
- const envName = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
56
- throw new LlmClientError('NO_LLM_CREDENTIALS', `${envName} not set`);
57
- }
58
- return provider === 'anthropic'
59
- ? buildAnthropicAdapter({ model: resolvedModel, apiKey: key })
60
- : buildOpenAiAdapter({ model: resolvedModel, apiKey: key });
61
- }
62
-
63
- function buildAnthropicAdapter({ model, apiKey }) {
64
- const sdk = new Anthropic({ apiKey });
65
- return {
66
- provider: 'anthropic',
67
- model,
68
- async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
69
- let message;
70
- try {
71
- message = await sdk.messages.create({
72
- model,
73
- max_tokens: maxTokens,
74
- system,
75
- messages: [{ role: 'user', content: user }],
76
- });
77
- } catch (err) {
78
- throw new LlmClientError('LLM_API_ERROR', `Anthropic API error: ${err.message}`, err);
79
- }
80
- const text = extractAnthropicText(message.content);
81
- const usage = {
82
- input_tokens: message.usage?.input_tokens ?? 0,
83
- output_tokens: message.usage?.output_tokens ?? 0,
84
- };
85
- return parseCompletionJson({ raw: text, usage, model, provider: 'anthropic' });
86
- },
87
- };
88
- }
89
-
90
- function buildOpenAiAdapter({ model, apiKey }) {
91
- const sdk = new OpenAI({ apiKey });
92
- return {
93
- provider: 'openai',
94
- model,
95
- async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
96
- let completion;
97
- try {
98
- completion = await sdk.chat.completions.create({
99
- model,
100
- max_tokens: maxTokens,
101
- response_format: { type: 'json_object' },
102
- messages: [
103
- { role: 'system', content: system },
104
- { role: 'user', content: user },
105
- ],
106
- });
107
- } catch (err) {
108
- throw new LlmClientError('LLM_API_ERROR', `OpenAI API error: ${err.message}`, err);
109
- }
110
- const text = completion.choices?.[0]?.message?.content ?? '';
111
- const usage = {
112
- input_tokens: completion.usage?.prompt_tokens ?? 0,
113
- output_tokens: completion.usage?.completion_tokens ?? 0,
114
- };
115
- return parseCompletionJson({ raw: text, usage, model, provider: 'openai' });
116
- },
117
- };
118
- }
119
-
120
- function extractAnthropicText(content) {
121
- if (!Array.isArray(content)) return '';
122
- return content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('');
123
- }
124
-
125
- function parseCompletionJson({ raw, usage, model, provider }) {
126
- const trimmed = stripCodeFence(raw).trim();
127
- if (!trimmed) {
128
- throw new LlmClientError('LLM_INVALID_JSON', 'LLM returned empty content');
129
- }
130
- let json;
131
- try {
132
- json = JSON.parse(trimmed);
133
- } catch (err) {
134
- throw new LlmClientError(
135
- 'LLM_INVALID_JSON',
136
- `failed to parse LLM response as JSON: ${err.message}`,
137
- err,
138
- );
139
- }
140
- return { json, raw, usage, model, provider };
141
- }
142
-
143
- function stripCodeFence(s) {
144
- if (!s || typeof s !== 'string') return '';
145
- const fenced = s.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
146
- return fenced ? fenced[1] : s;
147
- }
148
-
149
- module.exports = {
150
- createLlmClient,
151
- inferProvider,
152
- LlmClientError,
153
- parseCompletionJson,
154
- stripCodeFence,
155
- DEFAULT_MODEL,
156
- DEFAULT_MAX_TOKENS,
157
- };
@@ -1,156 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
-
6
- const {
7
- createLlmClient,
8
- inferProvider,
9
- LlmClientError,
10
- parseCompletionJson,
11
- stripCodeFence,
12
- DEFAULT_MODEL,
13
- } = require('./llm-client.cjs');
14
-
15
- test('inferProvider: claude family → anthropic', () => {
16
- assert.equal(inferProvider('claude-sonnet-4-6'), 'anthropic');
17
- assert.equal(inferProvider('claude-opus-4-7'), 'anthropic');
18
- assert.equal(inferProvider('sonnet-legacy'), 'anthropic');
19
- assert.equal(inferProvider('opus-anything'), 'anthropic');
20
- assert.equal(inferProvider('haiku-4-5'), 'anthropic');
21
- });
22
-
23
- test('inferProvider: GPT / o1 / o3 → openai', () => {
24
- assert.equal(inferProvider('gpt-4o'), 'openai');
25
- assert.equal(inferProvider('gpt-5.4'), 'openai');
26
- assert.equal(inferProvider('o1-preview'), 'openai');
27
- assert.equal(inferProvider('o3-mini'), 'openai');
28
- });
29
-
30
- test('inferProvider: unknown prefix → null', () => {
31
- assert.equal(inferProvider('llama-3'), null);
32
- assert.equal(inferProvider(''), null);
33
- assert.equal(inferProvider(null), null);
34
- assert.equal(inferProvider(undefined), null);
35
- });
36
-
37
- test('stripCodeFence: removes ```json ... ``` fence', () => {
38
- const input = '```json\n{"a":1}\n```';
39
- assert.equal(stripCodeFence(input).trim(), '{"a":1}');
40
- });
41
-
42
- test('stripCodeFence: removes plain ``` ... ``` fence', () => {
43
- const input = '```\n{"a":1}\n```';
44
- assert.equal(stripCodeFence(input).trim(), '{"a":1}');
45
- });
46
-
47
- test('stripCodeFence: leaves unfenced content alone', () => {
48
- assert.equal(stripCodeFence('{"a":1}'), '{"a":1}');
49
- assert.equal(stripCodeFence(' '), ' ');
50
- });
51
-
52
- test('stripCodeFence: empty / nullish → empty string', () => {
53
- assert.equal(stripCodeFence(''), '');
54
- assert.equal(stripCodeFence(null), '');
55
- assert.equal(stripCodeFence(undefined), '');
56
- });
57
-
58
- test('parseCompletionJson: valid JSON → parsed object', () => {
59
- const r = parseCompletionJson({
60
- raw: '{"ok":true,"n":2}',
61
- usage: { input_tokens: 10, output_tokens: 5 },
62
- model: 'claude-sonnet-4-6',
63
- provider: 'anthropic',
64
- });
65
- assert.deepEqual(r.json, { ok: true, n: 2 });
66
- assert.equal(r.usage.input_tokens, 10);
67
- assert.equal(r.model, 'claude-sonnet-4-6');
68
- assert.equal(r.provider, 'anthropic');
69
- });
70
-
71
- test('parseCompletionJson: fenced JSON → parsed object', () => {
72
- const r = parseCompletionJson({
73
- raw: '```json\n{"x":42}\n```',
74
- usage: {},
75
- model: 'gpt-5',
76
- provider: 'openai',
77
- });
78
- assert.deepEqual(r.json, { x: 42 });
79
- });
80
-
81
- test('parseCompletionJson: empty content → LLM_INVALID_JSON', () => {
82
- assert.throws(
83
- () => parseCompletionJson({ raw: '', usage: {}, model: 'x', provider: 'anthropic' }),
84
- (err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
85
- );
86
- });
87
-
88
- test('parseCompletionJson: malformed JSON → LLM_INVALID_JSON', () => {
89
- assert.throws(
90
- () => parseCompletionJson({ raw: '{not json', usage: {}, model: 'x', provider: 'anthropic' }),
91
- (err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
92
- );
93
- });
94
-
95
- test('createLlmClient: unknown model → LLM_UNSUPPORTED_MODEL', () => {
96
- assert.throws(
97
- () => createLlmClient({ model: 'llama-3', apiKey: 'sk-test' }),
98
- (err) => err instanceof LlmClientError && err.code === 'LLM_UNSUPPORTED_MODEL',
99
- );
100
- });
101
-
102
- test('createLlmClient: missing key → NO_LLM_CREDENTIALS', () => {
103
- const savedA = process.env.ANTHROPIC_API_KEY;
104
- const savedO = process.env.OPENAI_API_KEY;
105
- delete process.env.ANTHROPIC_API_KEY;
106
- delete process.env.OPENAI_API_KEY;
107
- try {
108
- assert.throws(
109
- () => createLlmClient({ model: 'claude-sonnet-4-6' }),
110
- (err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
111
- );
112
- assert.throws(
113
- () => createLlmClient({ model: 'gpt-5' }),
114
- (err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
115
- );
116
- } finally {
117
- if (savedA !== undefined) process.env.ANTHROPIC_API_KEY = savedA;
118
- if (savedO !== undefined) process.env.OPENAI_API_KEY = savedO;
119
- }
120
- });
121
-
122
- test('createLlmClient: explicit apiKey bypasses env check + returns adapter shape', () => {
123
- const anthropicClient = createLlmClient({ model: 'claude-sonnet-4-6', apiKey: 'sk-test' });
124
- assert.equal(anthropicClient.provider, 'anthropic');
125
- assert.equal(anthropicClient.model, 'claude-sonnet-4-6');
126
- assert.equal(typeof anthropicClient.completeJson, 'function');
127
-
128
- const openaiClient = createLlmClient({ model: 'gpt-5', apiKey: 'sk-test' });
129
- assert.equal(openaiClient.provider, 'openai');
130
- assert.equal(openaiClient.model, 'gpt-5');
131
- assert.equal(typeof openaiClient.completeJson, 'function');
132
- });
133
-
134
- test('createLlmClient: defaults model from env UBP_PLANNER_MODEL', () => {
135
- const saved = process.env.UBP_PLANNER_MODEL;
136
- process.env.UBP_PLANNER_MODEL = 'gpt-5.4';
137
- try {
138
- const c = createLlmClient({ apiKey: 'sk-test' });
139
- assert.equal(c.model, 'gpt-5.4');
140
- assert.equal(c.provider, 'openai');
141
- } finally {
142
- if (saved === undefined) delete process.env.UBP_PLANNER_MODEL;
143
- else process.env.UBP_PLANNER_MODEL = saved;
144
- }
145
- });
146
-
147
- test('createLlmClient: falls back to DEFAULT_MODEL when nothing supplied', () => {
148
- const savedEnv = process.env.UBP_PLANNER_MODEL;
149
- delete process.env.UBP_PLANNER_MODEL;
150
- try {
151
- const c = createLlmClient({ apiKey: 'sk-test' });
152
- assert.equal(c.model, DEFAULT_MODEL);
153
- } finally {
154
- if (savedEnv !== undefined) process.env.UBP_PLANNER_MODEL = savedEnv;
155
- }
156
- });
@@ -1,62 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 8A.3 — Prompt templates for task → subtasks expansion.
4
- //
5
- // Parent context (title, type, priority, complexity, files_modified) is
6
- // serialized into the user prompt so the LLM can produce children aligned
7
- // with the parent's shape. sub_count defaults to 3 when omitted.
8
-
9
- const DEFAULT_SUB_COUNT = 3;
10
-
11
- function buildExpandSystemPrompt({ sub_count = DEFAULT_SUB_COUNT } = {}) {
12
- return `You are an engineering task decomposer. Break a single complex task into ${sub_count} smaller subtasks that together deliver the parent's scope.
13
-
14
- Output STRICT JSON matching this shape — nothing else, no markdown fences, no prose:
15
-
16
- {
17
- "children": [
18
- {
19
- "id": "child-1",
20
- "title": "Imperative short title (3-80 chars)",
21
- "type": "architecture | feature | bugfix",
22
- "priority": "P0 | P1 | P2 | P3",
23
- "complexity": 1-10,
24
- "deps": ["child-2"],
25
- "files_modified": ["src/path/to/file.ts"]
26
- }
27
- ]
28
- }
29
-
30
- Rules:
31
- - Exactly ${sub_count} children.
32
- - Each id matches /^[a-zA-Z0-9_\\-]+$/ and is unique in the list.
33
- - "deps" references siblings in the same list; omit or use [] when none.
34
- - "files_modified" overlaps with the parent's files_modified where possible.
35
- - Each child's complexity is < parent's complexity; sum should approximate the parent.
36
- - Return JSON only.`;
37
- }
38
-
39
- function buildExpandUserPrompt(parent) {
40
- const files = Array.isArray(parent.files_modified) ? parent.files_modified : [];
41
- const deps = Array.isArray(parent.deps) ? parent.deps : [];
42
- return [
43
- 'Parent task to decompose:',
44
- '',
45
- `- id: ${parent.id}`,
46
- `- title: ${parent.title}`,
47
- `- type: ${parent.type}`,
48
- `- priority: ${parent.priority}`,
49
- parent.complexity != null ? `- complexity: ${parent.complexity}` : null,
50
- files.length ? `- files_modified: ${JSON.stringify(files)}` : null,
51
- deps.length ? `- existing deps: ${JSON.stringify(deps)}` : null,
52
- parent.trace_to ? `- trace_to: ${parent.trace_to}` : null,
53
- '',
54
- 'Return the children list as JSON following the system schema.',
55
- ].filter((l) => l !== null).join('\n');
56
- }
57
-
58
- module.exports = {
59
- buildExpandSystemPrompt,
60
- buildExpandUserPrompt,
61
- DEFAULT_SUB_COUNT,
62
- };
@@ -1,52 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
-
6
- const {
7
- buildExpandSystemPrompt,
8
- buildExpandUserPrompt,
9
- DEFAULT_SUB_COUNT,
10
- } = require('./expand-prompt.cjs');
11
-
12
- test('buildExpandSystemPrompt: mentions sub_count explicitly', () => {
13
- const s = buildExpandSystemPrompt({ sub_count: 4 });
14
- assert.match(s, /Exactly 4 children/);
15
- assert.match(s, /STRICT JSON/);
16
- });
17
-
18
- test('buildExpandSystemPrompt: falls back to DEFAULT_SUB_COUNT when omitted', () => {
19
- const s = buildExpandSystemPrompt({});
20
- assert.match(s, new RegExp(`Exactly ${DEFAULT_SUB_COUNT} children`));
21
- });
22
-
23
- test('buildExpandUserPrompt: serializes parent fields into prompt body', () => {
24
- const parent = {
25
- id: 'parent-1',
26
- title: 'Build search feature',
27
- type: 'feature',
28
- priority: 'P1',
29
- complexity: 8,
30
- files_modified: ['src/search/index.ts', 'src/search/query.ts'],
31
- deps: ['infra-1'],
32
- trace_to: 'spec §3.2',
33
- };
34
- const body = buildExpandUserPrompt(parent);
35
- assert.match(body, /parent-1/);
36
- assert.match(body, /Build search feature/);
37
- assert.match(body, /complexity: 8/);
38
- assert.match(body, /src\/search\/index\.ts/);
39
- assert.match(body, /infra-1/);
40
- assert.match(body, /spec §3\.2/);
41
- });
42
-
43
- test('buildExpandUserPrompt: omits empty optional fields', () => {
44
- const parent = {
45
- id: 'p', title: 'Tiny task', type: 'feature', priority: 'P3',
46
- complexity: null, files_modified: [], deps: [],
47
- };
48
- const body = buildExpandUserPrompt(parent);
49
- assert.doesNotMatch(body, /files_modified/);
50
- assert.doesNotMatch(body, /existing deps/);
51
- assert.doesNotMatch(body, /complexity:/);
52
- });