ultra-builder-pro-cli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +7 -8
  3. package/adapters/_shared/codex-assets.cjs +23 -163
  4. package/adapters/_shared/runtime-assets.cjs +1 -0
  5. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  6. package/adapters/opencode.js +30 -120
  7. package/adapters/tests/claude.test.cjs +3 -2
  8. package/adapters/tests/codex-hook.test.cjs +34 -7
  9. package/adapters/tests/codex.test.cjs +13 -6
  10. package/adapters/tests/kimi-hook.test.cjs +34 -7
  11. package/adapters/tests/opencode.test.cjs +70 -84
  12. package/docs/AGENT-CONTEXT.md +6 -3
  13. package/docs/ARCHITECTURE.md +8 -1
  14. package/docs/ROADMAP.md +1 -1
  15. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  16. package/hooks/active_task_context.py +22 -50
  17. package/hooks/context_spine.py +61 -332
  18. package/hooks/health_check.py +19 -17
  19. package/hooks/pre_stop_check.py +28 -33
  20. package/hooks/subagent_tracker.py +13 -19
  21. package/hooks/workflow_checkpoint.py +39 -47
  22. package/hooks/workflow_context.py +4 -9
  23. package/hooks/workflow_resume.py +48 -128
  24. package/mcp-server/breadcrumb.cjs +22 -0
  25. package/mcp-server/lib/prd-parser.cjs +14 -41
  26. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  27. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  28. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  29. package/mcp-server/lib/task-expander.cjs +9 -38
  30. package/mcp-server/lib/task-expander.test.cjs +54 -209
  31. package/mcp-server/server.cjs +21 -35
  32. package/mcp-server/tests/server-contract.test.cjs +41 -45
  33. package/package.json +2 -4
  34. package/skills/cc-collab/SKILL.md +2 -2
  35. package/skills/codex-collab/SKILL.md +2 -3
  36. package/skills/ultra-plan/SKILL.md +15 -5
  37. package/skills/ultra-verify/SKILL.md +21 -30
  38. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  39. package/spec/mcp-tools.yaml +38 -10
  40. package/mcp-server/lib/llm-client.cjs +0 -157
  41. package/mcp-server/lib/llm-client.test.cjs +0 -156
  42. package/orchestrator/planner/expand-prompt.cjs +0 -62
  43. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  44. package/orchestrator/planner/prd-prompt.cjs +0 -44
  45. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
@@ -5,8 +5,8 @@ description: Ask Codex CLI for an independent read-only analysis of a bounded ar
5
5
 
6
6
  # Consult Codex read-only
7
7
 
8
- Treat Codex as an untrusted advisor. The current host owns scope, evidence, edits,
9
- verification, and the final answer.
8
+ Treat Codex as an untrusted advisor. Keep the current host responsible for scope,
9
+ evidence, edits, verification, and the final answer.
10
10
 
11
11
  ## Preconditions
12
12
 
@@ -23,7 +23,6 @@ Create a session directory under `.ultra/collab/`, then run in a read-only sandb
23
23
  SESSION_PATH=".ultra/collab/$(date +%Y%m%d-%H%M%S)-codex"
24
24
  mkdir -p "${SESSION_PATH}"
25
25
  codex exec -s read-only \
26
- -a never \
27
26
  --ephemeral \
28
27
  --ignore-user-config \
29
28
  --ignore-rules \
@@ -65,13 +65,23 @@ id when applicable.
65
65
 
66
66
  ## PRD mode
67
67
 
68
- 1. Call `task.parse_prd` with `dry_run: true` and the active `change_id` when
69
- applicable.
70
- 2. Show the proposed slices, topology, conflict surface, and cost estimate.
71
- 3. Require explicit approval; rejection performs no state write.
72
- 4. On approval, repeat with `dry_run: false` and the same change ownership, export
68
+ 1. Read the approved PRD with the current host model and derive the proposed task
69
+ objects using the `task.parse_prd` input schema. Do not invoke a second model or
70
+ require a provider API key inside Ultra MCP.
71
+ 2. Call `task.parse_prd` with those `tasks`, `dry_run: true`, and the active
72
+ `change_id` when applicable. The MCP validates the graph but does not supply
73
+ product judgment.
74
+ 3. Show the proposed slices, topology, conflict surface, and cost estimate.
75
+ 4. Require explicit approval; rejection performs no state write.
76
+ 5. On approval, repeat with `dry_run: false`, the identical task objects, and the
77
+ same change ownership; export
73
78
  the plan, and verify that the dry-run and persisted task identities agree.
74
79
 
80
+ When one approved task is still too broad, derive its complete child graph with the
81
+ current host model and call `task.expand` with the parent `id` and validated
82
+ `children`. The MCP assigns parent, tag, and change ownership atomically. Never ask
83
+ the MCP runtime to generate child content.
84
+
75
85
  ## Completion gate
76
86
 
77
87
  Planning is complete only when the DAG is valid, each task reaches a public seam, each
@@ -1,12 +1,13 @@
1
1
  ---
2
2
  name: ultra-verify
3
- description: Run independent two-model verification with the current host as primary and a read-only external advisor, then verify and synthesize both evidence sets. Use only when the user explicitly requests cross-model verification.
3
+ description: Run independent two-model verification with the current host as primary and one installed read-only advisor, then verify and synthesize both evidence sets. Use only when the user explicitly requests cross-model verification.
4
4
  ---
5
5
 
6
6
  # Run independent cross-model verification
7
7
 
8
- Claude Code writes the first analysis, verifies consequential claims, and owns the
9
- final answer. Codex is an untrusted read-only advisor.
8
+ Keep the current host responsible for scope, evidence, edits, verification, and the
9
+ final answer. Use one installed collaboration companion that invokes a different CLI;
10
+ never recurse into the current host or treat advisor output as authority.
10
11
 
11
12
  ## Modes
12
13
 
@@ -17,36 +18,26 @@ final answer. Codex is an untrusted read-only advisor.
17
18
 
18
19
  ## Workflow
19
20
 
20
- 1. Confirm the advisor CLI and authentication. Define the exact workspace, question,
21
- evidence standard, and response shape. Exclude secrets and unrelated files.
22
- 2. Create `.ultra/collab/<session-id>/` and write the primary host's evidence-backed
23
- analysis to `claude-analysis.md` before reading advisor output.
24
- 3. Launch Codex with the raw bounded question and evidence in a read-only sandbox. Do
25
- not include the primary conclusion when independence matters:
26
-
27
- ```bash
28
- codex exec -s read-only \
29
- -a never \
30
- --ephemeral \
31
- --ignore-user-config \
32
- --ignore-rules \
33
- -o "${SESSION_PATH}/codex-output.md" \
34
- "<bounded prompt>" \
35
- 2> "${SESSION_PATH}/codex-error.log"
36
- ```
37
- 4. Run `scripts/verify_wait.py` resolved relative to this skill directory. Use a
38
- yielded or asynchronous shell session and poll at bounded intervals; do not hold a
39
- blocking tool call longer than one minute.
21
+ 1. Define the workspace, question, evidence standard, and response shape. Confirm the
22
+ selected advisor CLI and authentication. Exclude secrets and unrelated files.
23
+ 2. Create `.ultra/collab/<session-id>/` and write the current host's independent,
24
+ evidence-backed analysis to `host-analysis.md` before reading advisor output.
25
+ 3. Follow the installed collaboration companion's read-only invocation contract. Give
26
+ the advisor the same bounded question and evidence, without the primary conclusion
27
+ when independence matters. Store its result as `advisor-output.md` and diagnostics
28
+ as `advisor-error.log`.
29
+ 4. Run `scripts/verify_wait.py <session-path> --advisor advisor --output
30
+ advisor-output.md` from this skill directory. Use a yielded shell session and poll
31
+ in bounded intervals; do not hold one blocking tool call longer than one minute.
40
32
  5. Read advisor output only after the waiter reports a complete, non-empty file.
41
- 6. Verify both analyses against the current checkout, tests, runtime, or primary
42
- documentation. Explain scope, version, evidence, and assumption differences before
43
- judging disagreement.
44
- 7. Write session metadata and a synthesis artifact, then return one host-owned
45
- conclusion.
33
+ 6. Verify consequential claims against the current checkout, tests, runtime, or
34
+ primary documentation. Explain scope, version, evidence, and assumption differences
35
+ before resolving disagreement.
36
+ 7. Write `metadata.json` and `synthesis.md`, then return one host-owned conclusion.
46
37
 
47
38
  Read `references/cross-verify-modes.md` for mode-specific evidence and
48
39
  `references/confidence-system.md` when confidence language is useful. Model agreement
49
40
  never overrides failing tests or authoritative runtime evidence.
50
41
 
51
- Advisor failure degrades to the primary analysis with a single-source warning. It does
52
- not block the user's underlying task.
42
+ Advisor failure degrades to the primary analysis with a single-source warning. It
43
+ never blocks the user's underlying task.
@@ -1,19 +1,16 @@
1
1
  #!/usr/bin/env python3
2
- """File-based completion waiter for the Ultra Verify advisor.
3
-
4
- Poll the session directory until the expected output is non-empty and stable, or until timeout.
5
- The JSON result carries the operational status; timeout is not a process error.
6
- """
2
+ """Wait for one named advisor output file to become non-empty and stable."""
7
3
 
4
+ import argparse
8
5
  import json
6
+ import re
9
7
  import sys
10
8
  import time
11
9
  from pathlib import Path
12
10
 
13
11
  POLL_INTERVAL = 3
14
12
  DEFAULT_TIMEOUT = 1200
15
- ADVISOR = "codex"
16
- OUTPUT_FILE = "codex-output.md"
13
+ SAFE_ADVISOR = re.compile(r"^[a-zA-Z0-9_-]+$")
17
14
 
18
15
 
19
16
  def _file_size(path: Path) -> int:
@@ -23,71 +20,74 @@ def _file_size(path: Path) -> int:
23
20
  return -1
24
21
 
25
22
 
26
- def _check_output(session_path: Path) -> dict:
27
- output = session_path / OUTPUT_FILE
23
+ def _safe_output(value: str) -> Path:
24
+ result = Path(value)
25
+ if result.is_absolute() or len(result.parts) != 1 or result.name in {"", ".", ".."}:
26
+ raise argparse.ArgumentTypeError("--output must be one file name inside the session directory")
27
+ return result
28
+
29
+
30
+ def _arguments():
31
+ parser = argparse.ArgumentParser(description=__doc__)
32
+ parser.add_argument("session_path", type=Path)
33
+ parser.add_argument("--advisor", default="advisor")
34
+ parser.add_argument("--output", type=_safe_output, default=Path("advisor-output.md"))
35
+ parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
36
+ args = parser.parse_args()
37
+ if not SAFE_ADVISOR.fullmatch(args.advisor):
38
+ parser.error("--advisor must use letters, numbers, underscore, or hyphen")
39
+ if args.timeout < 0:
40
+ parser.error("--timeout must be non-negative")
41
+ if not args.session_path.is_dir():
42
+ parser.error(f"session directory not found: {args.session_path}")
43
+ return args
44
+
45
+
46
+ def _status(session_path: Path, advisor: str, output_name: Path) -> dict:
47
+ output = session_path / output_name
28
48
  if _file_size(output) > 0:
29
- return {"name": ADVISOR, "status": "complete", "file": str(output)}
30
- return {"name": ADVISOR, "status": "pending", "file": None}
49
+ return {"name": advisor, "status": "complete", "file": str(output)}
50
+ return {"name": advisor, "status": "pending", "file": None}
31
51
 
32
52
 
33
- def _timeout_status(session_path: Path) -> dict:
34
- output = session_path / OUTPUT_FILE
35
- error = session_path / f"{ADVISOR}-error.log"
53
+ def _timeout_status(session_path: Path, advisor: str, output_name: Path) -> dict:
54
+ output = session_path / output_name
55
+ error = session_path / f"{advisor}-error.log"
36
56
  output_size = _file_size(output)
37
57
  if output_size > 0:
38
- return {"name": ADVISOR, "status": "complete", "file": str(output)}
58
+ return {"name": advisor, "status": "complete", "file": str(output)}
39
59
  if _file_size(error) > 0:
40
- return {"name": ADVISOR, "status": "failed", "file": str(error)}
60
+ return {"name": advisor, "status": "failed", "file": str(error)}
41
61
  if output_size == 0:
42
- return {"name": ADVISOR, "status": "empty", "file": str(output)}
43
- return {"name": ADVISOR, "status": "pending", "file": None}
62
+ return {"name": advisor, "status": "empty", "file": str(output)}
63
+ return {"name": advisor, "status": "pending", "file": None}
44
64
 
45
65
 
46
66
  def main() -> None:
47
- if len(sys.argv) < 2:
48
- print(__doc__, file=sys.stderr)
49
- sys.exit(2)
50
-
51
- session_path = Path(sys.argv[1])
52
- timeout = DEFAULT_TIMEOUT
53
- if "--timeout" in sys.argv:
54
- idx = sys.argv.index("--timeout")
55
- if idx + 1 >= len(sys.argv):
56
- print("Error: --timeout requires an integer", file=sys.stderr)
57
- sys.exit(2)
58
- try:
59
- timeout = int(sys.argv[idx + 1])
60
- except ValueError:
61
- print(f"Error: --timeout must be integer: {sys.argv[idx + 1]}", file=sys.stderr)
62
- sys.exit(2)
63
- if timeout < 0:
64
- print("Error: --timeout must be non-negative", file=sys.stderr)
65
- sys.exit(2)
66
-
67
- if not session_path.is_dir():
68
- print(f"Error: session directory not found: {session_path}", file=sys.stderr)
69
- sys.exit(2)
70
-
71
- deadline = time.monotonic() + timeout
72
- previous_size = _file_size(session_path / OUTPUT_FILE)
67
+ args = _arguments()
68
+ output = args.session_path / args.output
69
+ deadline = time.monotonic() + args.timeout
70
+ previous_size = _file_size(output)
73
71
  while time.monotonic() < deadline:
74
- current_size = _file_size(session_path / OUTPUT_FILE)
75
- advisor = _check_output(session_path)
76
- stable = advisor["status"] == "complete" and current_size == previous_size
77
- if stable:
78
- elapsed = int(timeout - (deadline - time.monotonic()))
79
- print(json.dumps({"status": "complete", ADVISOR: advisor, "elapsed_seconds": elapsed}))
72
+ current_size = _file_size(output)
73
+ advisor = _status(args.session_path, args.advisor, args.output)
74
+ if advisor["status"] == "complete" and current_size == previous_size:
75
+ elapsed = int(args.timeout - (deadline - time.monotonic()))
76
+ print(json.dumps({
77
+ "status": "complete", "advisor": advisor, "elapsed_seconds": elapsed,
78
+ }))
80
79
  return
81
-
82
80
  state = "stabilizing" if advisor["status"] == "complete" else "waiting"
83
81
  remaining = max(0, int(deadline - time.monotonic()))
84
- sys.stderr.write(f"\r [0/1] {ADVISOR}:{state} ({remaining}s remaining) ")
82
+ sys.stderr.write(f"\r [0/1] {args.advisor}:{state} ({remaining}s remaining) ")
85
83
  sys.stderr.flush()
86
84
  previous_size = current_size
87
85
  time.sleep(POLL_INTERVAL)
88
86
 
89
- advisor = _timeout_status(session_path)
90
- print(json.dumps({"status": "timeout", ADVISOR: advisor, "elapsed_seconds": timeout}))
87
+ advisor = _timeout_status(args.session_path, args.advisor, args.output)
88
+ print(json.dumps({
89
+ "status": "timeout", "advisor": advisor, "elapsed_seconds": args.timeout,
90
+ }))
91
91
 
92
92
 
93
93
  if __name__ == "__main__":
@@ -321,17 +321,30 @@ tools:
321
321
 
322
322
  - name: task.expand
323
323
  family: task
324
- description: Expand a parent task into N child sub-tasks (Phase 8A).
324
+ description: Validate and atomically persist child tasks derived by the current host from one parent task.
325
325
  phase: '8a'
326
326
  writer_role: mcp
327
327
  input_schema:
328
328
  type: object
329
- required: [id]
329
+ required: [id, children]
330
330
  additionalProperties: false
331
331
  properties:
332
- id: { type: string }
333
- sub_count: { type: integer, minimum: 1, maximum: 16 }
334
- strategy: { type: string, enum: [llm], default: llm }
332
+ id: { type: string }
333
+ children:
334
+ type: array
335
+ minItems: 1
336
+ items:
337
+ type: object
338
+ required: [id, title, type, priority]
339
+ additionalProperties: false
340
+ properties:
341
+ id: { type: string, pattern: '^[a-zA-Z0-9_\-]+$' }
342
+ title: { type: string, minLength: 3, maxLength: 200 }
343
+ type: { type: string, enum: [architecture, feature, bugfix] }
344
+ priority: { type: string, enum: [P0, P1, P2, P3] }
345
+ complexity: { type: integer, minimum: 1, maximum: 10 }
346
+ deps: { type: array, items: { type: string } }
347
+ files_modified: { type: array, items: { type: string } }
335
348
  output_schema:
336
349
  type: object
337
350
  required: [parent_id, children]
@@ -345,21 +358,36 @@ tools:
345
358
  - { code: CHANGE_NOT_FOUND, description: 'the parent task change does not exist' }
346
359
  - { code: CHANGE_NOT_MUTABLE, description: 'the parent task change no longer accepts children' }
347
360
  - { code: BASELINE_NOT_READY, description: 'child creation requires a ready baseline or the parent task to belong to an already-authorized active change' }
361
+ - { code: INVALID_OUTPUT, description: 'the host-derived child task graph is invalid' }
348
362
  cli_subcommand: task expand
349
363
  emits_events: [task_expanded]
350
364
  trace_to: '§7.3, D34'
351
365
 
352
366
  - name: task.parse_prd
353
367
  family: task
354
- description: Parse a PRD document into a task graph (Phase 8A). When dry_run=true the tasks are returned without writing to state.db (supports ultra-plan human gate workflow).
368
+ description: Validate a host-derived PRD task graph and optionally persist it after the Ultra planning approval gate.
355
369
  phase: '8a'
356
370
  writer_role: mcp
357
371
  input_schema:
358
372
  type: object
373
+ required: [tasks]
359
374
  additionalProperties: false
360
375
  properties:
361
- prd_path: { type: string }
362
- prd_text: { type: string }
376
+ tasks:
377
+ type: array
378
+ minItems: 1
379
+ items:
380
+ type: object
381
+ required: [id, title, type, priority]
382
+ additionalProperties: false
383
+ properties:
384
+ id: { type: string, pattern: '^[a-zA-Z0-9_\-]+$' }
385
+ title: { type: string, minLength: 3, maxLength: 200 }
386
+ type: { type: string, enum: [architecture, feature, bugfix] }
387
+ priority: { type: string, enum: [P0, P1, P2, P3] }
388
+ complexity: { type: integer, minimum: 1, maximum: 10 }
389
+ deps: { type: array, items: { type: string } }
390
+ files_modified: { type: array, items: { type: string } }
363
391
  tag: { type: string }
364
392
  change_id: { type: string, pattern: '^[a-zA-Z0-9_\-]+$', description: 'continuous change unit that will own every persisted parsed task' }
365
393
  dry_run: { type: boolean, default: false, description: 'when true, do not persist to state.db — used by ultra-plan gate to preview before approve' }
@@ -371,8 +399,8 @@ tools:
371
399
  tasks: { type: array, items: { type: object } }
372
400
  topo: { type: array, items: { type: array, items: { type: string } } }
373
401
  errors:
374
- - { code: NO_INPUT, description: 'one of prd_path or prd_text required' }
375
- - { code: PARSE_FAILED, description: 'PRD could not be parsed into tasks' }
402
+ - { code: PARSE_FAILED, description: 'the host-derived PRD task graph is invalid' }
403
+ - { code: CYCLE_DETECTED, description: 'the proposed task graph contains a dependency cycle' }
376
404
  - { code: CHANGE_NOT_FOUND, description: 'assigned change does not exist' }
377
405
  - { code: CHANGE_NOT_MUTABLE, description: 'assigned change no longer accepts tasks' }
378
406
  - { code: BASELINE_NOT_READY, description: 'persisting parsed tasks requires a ready baseline or an already-authorized active change' }
@@ -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
- });