claude-all-hands 1.0.7 → 1.0.8

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.
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Gemini API commands.
3
- * Phase 8: Consolidated Gemini integration with audit, review, and retry behavior.
2
+ * Oracle: Multi-provider LLM commands.
3
+ * Supports Gemini and OpenAI with unified interface.
4
4
  */
5
5
 
6
- import { GoogleGenAI } from "@google/genai";
7
6
  import { Command } from "commander";
8
- import { readFileSync, existsSync, readdirSync } from "fs";
7
+ import { readFileSync, existsSync } from "fs";
9
8
  import { join, extname } from "path";
10
9
  import { spawnSync } from "child_process";
11
10
  import { getBaseBranch, getBranch, getDiff, getPlanDir, isDirectModeBranch } from "../lib/git.js";
@@ -28,19 +27,19 @@ import {
28
27
  planExists,
29
28
  getPlanPaths,
30
29
  getPromptId,
31
- parsePromptId,
32
30
  } from "../lib/index.js";
33
31
  import { watchForDone } from "../lib/watcher.js";
34
- import { withRetry, GEMINI_FALLBACKS } from "../lib/retry.js";
35
- import { recordGeminiCall } from "../lib/observability.js";
32
+ import { withRetry, ORACLE_FALLBACKS } from "../lib/retry.js";
33
+ import { recordOracleCall } from "../lib/observability.js";
34
+ import {
35
+ createProvider,
36
+ getDefaultProvider,
37
+ type LLMProvider,
38
+ type ProviderName,
39
+ type ContentPart,
40
+ } from "../lib/providers.js";
36
41
  import { BaseCommand, type CommandResult } from "./base.js";
37
42
 
38
- // Default model for most operations
39
- const DEFAULT_MODEL = "gemini-2.0-flash";
40
- // Pro model for complex audit/review operations
41
- const PRO_MODEL = "gemini-3-pro-preview";
42
-
43
- // Blocking gate timeout (12 hours default)
44
43
  const DEFAULT_BLOCKING_GATE_TIMEOUT_MS = 12 * 60 * 60 * 1000;
45
44
 
46
45
  function getBlockingGateTimeout(): number {
@@ -54,9 +53,6 @@ function getBlockingGateTimeout(): number {
54
53
  return DEFAULT_BLOCKING_GATE_TIMEOUT_MS;
55
54
  }
56
55
 
57
- /**
58
- * Parse JSON from a response that may contain markdown code blocks.
59
- */
60
56
  function parseJsonResponse(response: string): Record<string, unknown> {
61
57
  const jsonMatch = response.match(/\{[\s\S]*\}/);
62
58
  if (jsonMatch) {
@@ -69,9 +65,6 @@ function parseJsonResponse(response: string): Record<string, unknown> {
69
65
  return { raw_response: response };
70
66
  }
71
67
 
72
- /**
73
- * Read an image file and return base64 data with mime type.
74
- */
75
68
  function readImageAsBase64(filePath: string): { data: string; mimeType: string } | null {
76
69
  if (!existsSync(filePath)) return null;
77
70
 
@@ -95,46 +88,140 @@ function readImageAsBase64(filePath: string): { data: string; mimeType: string }
95
88
  }
96
89
  }
97
90
 
98
- /**
99
- * Get commit summaries for a branch since divergence from base.
100
- */
101
91
  function getCommitSummaries(baseRef: string): string {
102
92
  try {
103
- const result = spawnSync(
104
- "git",
105
- ["log", "--oneline", `${baseRef}..HEAD`],
106
- { encoding: "utf-8" }
107
- );
93
+ const result = spawnSync("git", ["log", "--oneline", `${baseRef}..HEAD`], {
94
+ encoding: "utf-8",
95
+ });
108
96
  return result.status === 0 ? result.stdout.trim() : "(No commits)";
109
97
  } catch {
110
98
  return "(Unable to get commits)";
111
99
  }
112
100
  }
113
101
 
102
+ function addProviderOption(cmd: Command): Command {
103
+ return cmd.option(
104
+ "--provider <provider>",
105
+ "LLM provider (gemini | openai)",
106
+ getDefaultProvider()
107
+ );
108
+ }
109
+
110
+ // ============================================================================
111
+ // Base Oracle Command
112
+ // ============================================================================
113
+
114
+ abstract class OracleCommand extends BaseCommand {
115
+ protected provider!: LLMProvider;
116
+
117
+ protected async initProvider(args: Record<string, unknown>): Promise<CommandResult | null> {
118
+ const providerName = (args.provider as ProviderName) ?? getDefaultProvider();
119
+ this.provider = await createProvider(providerName);
120
+
121
+ const apiKey = this.provider.getApiKey();
122
+ if (!apiKey) {
123
+ return this.error("auth_error", `${this.provider.config.apiKeyEnvVar} not set`);
124
+ }
125
+ return null;
126
+ }
127
+
128
+ protected async callProvider(
129
+ contents: string | ContentPart[],
130
+ endpoint: string,
131
+ usePro: boolean = false
132
+ ): Promise<{ result: Awaited<ReturnType<typeof withRetry<{ text: string; model: string }>>>; durationMs: number }> {
133
+ const start = performance.now();
134
+ const result = await withRetry(
135
+ () => this.provider.generate(contents, { usePro }),
136
+ `oracle.${this.provider.config.name}.${endpoint}`,
137
+ {},
138
+ ORACLE_FALLBACKS[endpoint]
139
+ );
140
+ const durationMs = Math.round(performance.now() - start);
141
+
142
+ recordOracleCall({
143
+ provider: this.provider.config.name,
144
+ endpoint,
145
+ duration_ms: durationMs,
146
+ success: result.success,
147
+ retries: result.retries,
148
+ });
149
+
150
+ return { result, durationMs };
151
+ }
152
+
153
+ protected handleError(
154
+ result: { success: false; error: string; retries: number; fallback_suggestion?: string },
155
+ durationMs: number
156
+ ): CommandResult {
157
+ return {
158
+ status: "error",
159
+ error: {
160
+ type: result.error,
161
+ message: "LLM API unavailable after retries",
162
+ suggestion: result.fallback_suggestion,
163
+ },
164
+ metadata: { retries: result.retries, duration_ms: durationMs },
165
+ };
166
+ }
167
+
168
+ protected async callAndParse(
169
+ endpoint: string,
170
+ contents: string | ContentPart[],
171
+ usePro: boolean = false
172
+ ): Promise<
173
+ | { error: CommandResult; parsed?: never; metadata?: never }
174
+ | { error?: never; parsed: Record<string, unknown>; metadata: Record<string, unknown> }
175
+ > {
176
+ const { result, durationMs } = await this.callProvider(contents, endpoint, usePro);
177
+
178
+ if (!result.success) {
179
+ const failedResult = result as { success: false; error: string; retries: number; fallback_suggestion?: string };
180
+ return {
181
+ error: {
182
+ status: "error",
183
+ error: {
184
+ type: failedResult.error,
185
+ message: "LLM API unavailable after retries",
186
+ suggestion: failedResult.fallback_suggestion,
187
+ },
188
+ metadata: { retries: failedResult.retries, duration_ms: durationMs },
189
+ },
190
+ };
191
+ }
192
+
193
+ const parsed = parseJsonResponse(result.data.text);
194
+ const metadata = {
195
+ provider: this.provider.config.name,
196
+ command: `oracle ${endpoint}`,
197
+ duration_ms: durationMs,
198
+ retries: result.retries,
199
+ };
200
+
201
+ return { parsed, metadata };
202
+ }
203
+ }
204
+
114
205
  // ============================================================================
115
206
  // Ask Command
116
207
  // ============================================================================
117
208
 
118
- class GeminiAskCommand extends BaseCommand {
209
+ class OracleAskCommand extends OracleCommand {
119
210
  readonly name = "ask";
120
- readonly description = "Raw Gemini inference with retry";
211
+ readonly description = "Raw LLM inference with retry";
121
212
 
122
213
  defineArguments(cmd: Command): void {
123
- cmd
124
- .argument("<query>", "Query for Gemini")
214
+ addProviderOption(cmd)
215
+ .argument("<query>", "Query for LLM")
125
216
  .option("--files <files...>", "Files to include as context")
126
- .option("--context <context>", "Additional context")
127
- .option("--model <model>", "Model to use", DEFAULT_MODEL);
217
+ .option("--context <context>", "Additional context");
128
218
  }
129
219
 
130
220
  async execute(args: Record<string, unknown>): Promise<CommandResult> {
131
- const apiKey = process.env.VERTEX_API_KEY;
132
- if (!apiKey) {
133
- return this.error("auth_error", "VERTEX_API_KEY not set");
134
- }
221
+ const authError = await this.initProvider(args);
222
+ if (authError) return authError;
135
223
 
136
224
  const query = args.query as string;
137
- const model = (args.model as string) ?? DEFAULT_MODEL;
138
225
  const files = args.files as string[] | undefined;
139
226
  const context = args.context as string | undefined;
140
227
 
@@ -154,39 +241,22 @@ class GeminiAskCommand extends BaseCommand {
154
241
  parts.push(query);
155
242
  const prompt = parts.join("\n\n");
156
243
 
157
- const start = performance.now();
158
- const result = await withRetry(
159
- async () => {
160
- const client = new GoogleGenAI({ vertexai: true, apiKey });
161
- const genResult = await client.models.generateContent({
162
- model,
163
- contents: prompt,
164
- });
165
- return genResult.text ?? "";
166
- },
167
- "gemini.ask",
168
- {},
169
- GEMINI_FALLBACKS.ask
170
- );
171
-
172
- const durationMs = Math.round(performance.now() - start);
173
- recordGeminiCall({ endpoint: "ask", duration_ms: durationMs, success: result.success, retries: result.retries });
244
+ const { result, durationMs } = await this.callProvider(prompt, "ask");
174
245
 
175
246
  if (!result.success) {
176
- return {
177
- status: "error",
178
- error: {
179
- type: result.error,
180
- message: "Gemini API unavailable after retries",
181
- suggestion: result.fallback_suggestion,
182
- },
183
- metadata: { retries: result.retries, duration_ms: durationMs },
184
- };
247
+ const failedResult = result as { success: false; error: string; retries: number; fallback_suggestion?: string };
248
+ return this.handleError(failedResult, durationMs);
185
249
  }
186
250
 
187
251
  return this.success(
188
- { content: result.data },
189
- { model, command: "gemini ask", duration_ms: durationMs, retries: result.retries }
252
+ { content: result.data.text },
253
+ {
254
+ model: result.data.model,
255
+ provider: this.provider.config.name,
256
+ command: "oracle ask",
257
+ duration_ms: durationMs,
258
+ retries: result.retries,
259
+ }
190
260
  );
191
261
  }
192
262
  }
@@ -195,7 +265,7 @@ class GeminiAskCommand extends BaseCommand {
195
265
  // Validate Command
196
266
  // ============================================================================
197
267
 
198
- class GeminiValidateCommand extends BaseCommand {
268
+ class OracleValidateCommand extends OracleCommand {
199
269
  readonly name = "validate";
200
270
  readonly description = "Validate plan against requirements (anti-overengineering)";
201
271
 
@@ -228,16 +298,14 @@ Output JSON:
228
298
  }`;
229
299
 
230
300
  defineArguments(cmd: Command): void {
231
- cmd
301
+ addProviderOption(cmd)
232
302
  .option("--queries <path>", "Queries file path (optional)")
233
303
  .option("--context <context>", "Additional context");
234
304
  }
235
305
 
236
306
  async execute(args: Record<string, unknown>): Promise<CommandResult> {
237
- const apiKey = process.env.VERTEX_API_KEY;
238
- if (!apiKey) {
239
- return this.error("auth_error", "VERTEX_API_KEY not set");
240
- }
307
+ const authError = await this.initProvider(args);
308
+ if (authError) return authError;
241
309
 
242
310
  if (isDirectModeBranch(getBranch())) {
243
311
  return this.error("direct_mode", "No plan in direct mode");
@@ -282,41 +350,10 @@ ${additional}
282
350
 
283
351
  Respond with JSON only.`;
284
352
 
285
- const start = performance.now();
286
- const result = await withRetry(
287
- async () => {
288
- const client = new GoogleGenAI({ vertexai: true, apiKey });
289
- const genResult = await client.models.generateContent({
290
- model: DEFAULT_MODEL,
291
- contents: fullPrompt,
292
- });
293
- return genResult.text ?? "";
294
- },
295
- "gemini.validate",
296
- {},
297
- "Skip validation and proceed with user review only"
298
- );
299
-
300
- const durationMs = Math.round(performance.now() - start);
353
+ const { error, parsed, metadata } = await this.callAndParse("validate", fullPrompt);
354
+ if (error) return error;
301
355
 
302
- if (!result.success) {
303
- return {
304
- status: "error",
305
- error: {
306
- type: result.error,
307
- message: "Gemini API unavailable after retries",
308
- suggestion: result.fallback_suggestion,
309
- },
310
- metadata: { retries: result.retries, duration_ms: durationMs },
311
- };
312
- }
313
-
314
- const parsed = parseJsonResponse(result.data);
315
- return this.success(parsed, {
316
- command: "gemini validate",
317
- duration_ms: durationMs,
318
- retries: result.retries,
319
- });
356
+ return this.success(parsed, metadata);
320
357
  }
321
358
  }
322
359
 
@@ -324,7 +361,7 @@ Respond with JSON only.`;
324
361
  // Architect Command
325
362
  // ============================================================================
326
363
 
327
- class GeminiArchitectCommand extends BaseCommand {
364
+ class OracleArchitectCommand extends OracleCommand {
328
365
  readonly name = "architect";
329
366
  readonly description = "Solutions architecture for complex features";
330
367
 
@@ -355,17 +392,15 @@ Output JSON:
355
392
  }`;
356
393
 
357
394
  defineArguments(cmd: Command): void {
358
- cmd
395
+ addProviderOption(cmd)
359
396
  .argument("<query>", "Feature/system description")
360
397
  .option("--files <files...>", "Relevant code files")
361
398
  .option("--context <context>", "Additional context or constraints");
362
399
  }
363
400
 
364
401
  async execute(args: Record<string, unknown>): Promise<CommandResult> {
365
- const apiKey = process.env.VERTEX_API_KEY;
366
- if (!apiKey) {
367
- return this.error("auth_error", "VERTEX_API_KEY not set");
368
- }
402
+ const authError = await this.initProvider(args);
403
+ if (authError) return authError;
369
404
 
370
405
  const query = args.query as string;
371
406
  const files = args.files as string[] | undefined;
@@ -394,49 +429,18 @@ ${additional}
394
429
 
395
430
  Respond with JSON only.`;
396
431
 
397
- const start = performance.now();
398
- const result = await withRetry(
399
- async () => {
400
- const client = new GoogleGenAI({ vertexai: true, apiKey });
401
- const genResult = await client.models.generateContent({
402
- model: DEFAULT_MODEL,
403
- contents: fullPrompt,
404
- });
405
- return genResult.text ?? "";
406
- },
407
- "gemini.architect",
408
- {},
409
- "Proceed without architect analysis"
410
- );
411
-
412
- const durationMs = Math.round(performance.now() - start);
432
+ const { error, parsed, metadata } = await this.callAndParse("architect", fullPrompt);
433
+ if (error) return error;
413
434
 
414
- if (!result.success) {
415
- return {
416
- status: "error",
417
- error: {
418
- type: result.error,
419
- message: "Gemini API unavailable after retries",
420
- suggestion: result.fallback_suggestion,
421
- },
422
- metadata: { retries: result.retries, duration_ms: durationMs },
423
- };
424
- }
425
-
426
- const parsed = parseJsonResponse(result.data);
427
- return this.success(parsed, {
428
- command: "gemini architect",
429
- duration_ms: durationMs,
430
- retries: result.retries,
431
- });
435
+ return this.success(parsed, metadata);
432
436
  }
433
437
  }
434
438
 
435
439
  // ============================================================================
436
- // Audit Command (Phase 8)
440
+ // Audit Command
437
441
  // ============================================================================
438
442
 
439
- class GeminiAuditCommand extends BaseCommand {
443
+ class OracleAuditCommand extends OracleCommand {
440
444
  readonly name = "audit";
441
445
  readonly description = "Audit plan for completeness and coherence";
442
446
 
@@ -461,14 +465,12 @@ Output JSON:
461
465
  }`;
462
466
 
463
467
  defineArguments(cmd: Command): void {
464
- // No arguments - reads from plan directory
468
+ addProviderOption(cmd);
465
469
  }
466
470
 
467
- async execute(_args: Record<string, unknown>): Promise<CommandResult> {
468
- const apiKey = process.env.VERTEX_API_KEY;
469
- if (!apiKey) {
470
- return this.error("auth_error", "VERTEX_API_KEY not set");
471
- }
471
+ async execute(args: Record<string, unknown>): Promise<CommandResult> {
472
+ const authError = await this.initProvider(args);
473
+ if (authError) return authError;
472
474
 
473
475
  if (isDirectModeBranch(getBranch())) {
474
476
  return this.error("direct_mode", "No plan in direct mode");
@@ -478,7 +480,6 @@ Output JSON:
478
480
  return this.error("no_plan", "No plan directory exists for this branch");
479
481
  }
480
482
 
481
- // Gather all plan context
482
483
  const plan = readPlan();
483
484
  if (!plan) {
484
485
  return this.error("file_not_found", "Plan file not found");
@@ -489,9 +490,8 @@ Output JSON:
489
490
  const designManifest = readDesignManifest();
490
491
  const paths = getPlanPaths();
491
492
 
492
- // Build prompts section
493
493
  const promptsSection = allPrompts
494
- .map((p, i) => {
494
+ .map((p) => {
495
495
  const id = getPromptId(p.number, p.variant);
496
496
  return `### Prompt ${id}
497
497
  **Description:** ${p.frontMatter.description}
@@ -503,15 +503,13 @@ ${p.content}`;
503
503
  })
504
504
  .join("\n\n");
505
505
 
506
- // Build design assets section with images
507
506
  let designSection = "";
508
- const contentParts: Array<string | { inlineData: { mimeType: string; data: string } }> = [];
507
+ const contentParts: ContentPart[] = [];
509
508
 
510
509
  if (designManifest && designManifest.designs.length > 0) {
511
510
  designSection = "## Design Assets\n\n";
512
511
  for (const design of designManifest.designs) {
513
512
  designSection += `- **${design.screenshot_file_name}**: ${design.description}\n`;
514
- // Try to read the image
515
513
  const imagePath = join(paths.design, design.screenshot_file_name);
516
514
  const imageData = readImageAsBase64(imagePath);
517
515
  if (imageData) {
@@ -535,47 +533,15 @@ ${designSection}
535
533
 
536
534
  Review the plan and respond with JSON only.`;
537
535
 
538
- // Add text prompt to content parts
539
536
  contentParts.unshift(fullPrompt);
540
537
 
541
- const start = performance.now();
542
- const result = await withRetry(
543
- async () => {
544
- const client = new GoogleGenAI({ vertexai: true, apiKey });
545
- const genResult = await client.models.generateContent({
546
- model: PRO_MODEL,
547
- contents: contentParts,
548
- });
549
- return genResult.text ?? "";
550
- },
551
- "gemini.audit",
552
- {},
553
- GEMINI_FALLBACKS.audit
554
- );
555
-
556
- const durationMs = Math.round(performance.now() - start);
557
- recordGeminiCall({ endpoint: "audit", duration_ms: durationMs, success: result.success, retries: result.retries });
538
+ const { error, parsed, metadata } = await this.callAndParse("audit", contentParts, true);
539
+ if (error) return error;
558
540
 
559
- if (!result.success) {
560
- return {
561
- status: "error",
562
- error: {
563
- type: result.error,
564
- message: "Gemini API unavailable after retries",
565
- suggestion: result.fallback_suggestion,
566
- },
567
- metadata: { retries: result.retries, duration_ms: durationMs },
568
- };
569
- }
570
-
571
- const parsed = parseJsonResponse(result.data);
572
541
  const questions = (parsed.clarifying_questions as string[]) ?? [];
573
542
 
574
- // If there are clarifying questions, block for user answers
575
543
  if (questions.length > 0) {
576
544
  const feedbackPath = writeAuditQuestionsFeedback(questions);
577
-
578
- // Block until done: true
579
545
  await watchForDone(feedbackPath, getBlockingGateTimeout());
580
546
  const feedback = readAuditQuestionsFeedback();
581
547
 
@@ -583,62 +549,57 @@ Review the plan and respond with JSON only.`;
583
549
  return this.error("invalid_feedback", feedback.error);
584
550
  }
585
551
 
586
- // Append thoughts and Q&A to user_input.md
587
552
  const qaContent = feedback.data.questions
588
553
  .map((q) => `**Q:** ${q.question}\n**A:** ${q.answer}`)
589
554
  .join("\n\n");
590
- const userThoughts = feedback.data.thoughts ? `**User Thoughts:** ${feedback.data.thoughts}\n\n` : "";
555
+ const userThoughts = feedback.data.thoughts
556
+ ? `**User Thoughts:** ${feedback.data.thoughts}\n\n`
557
+ : "";
591
558
  appendUserInput(`## Audit Clarifications\n\n${userThoughts}${qaContent}`);
592
-
593
- // Delete feedback file
594
559
  deleteFeedbackFile("audit_questions");
595
560
 
596
- // Record audit entry
597
561
  appendPlanAudit({
598
- review_context: parsed.thoughts as string ?? "",
562
+ review_context: (parsed.thoughts as string) ?? "",
599
563
  decision: "needs_clarification",
600
564
  total_questions: questions.length,
601
565
  were_changes_suggested: ((parsed.suggested_edits as unknown[]) ?? []).length > 0,
602
566
  });
603
567
 
604
- return this.success({
605
- verdict: "needs_clarification",
606
- thoughts: parsed.thoughts,
607
- answered_questions: feedback.data.questions,
608
- suggested_edits: parsed.suggested_edits,
609
- }, {
610
- command: "gemini audit",
611
- duration_ms: durationMs,
612
- retries: result.retries,
613
- });
568
+ return this.success(
569
+ {
570
+ verdict: "needs_clarification",
571
+ thoughts: parsed.thoughts,
572
+ answered_questions: feedback.data.questions,
573
+ suggested_edits: parsed.suggested_edits,
574
+ },
575
+ metadata
576
+ );
614
577
  }
615
578
 
616
- // No questions - record audit and return
617
- const verdict = parsed.verdict as string ?? "passed";
579
+ const verdict = (parsed.verdict as string) ?? "passed";
618
580
  appendPlanAudit({
619
- review_context: parsed.thoughts as string ?? "",
581
+ review_context: (parsed.thoughts as string) ?? "",
620
582
  decision: verdict === "passed" ? "approved" : "rejected",
621
583
  total_questions: 0,
622
584
  were_changes_suggested: ((parsed.suggested_edits as unknown[]) ?? []).length > 0,
623
585
  });
624
586
 
625
- return this.success({
626
- verdict,
627
- thoughts: parsed.thoughts,
628
- suggested_edits: parsed.suggested_edits,
629
- }, {
630
- command: "gemini audit",
631
- duration_ms: durationMs,
632
- retries: result.retries,
633
- });
587
+ return this.success(
588
+ {
589
+ verdict,
590
+ thoughts: parsed.thoughts,
591
+ suggested_edits: parsed.suggested_edits,
592
+ },
593
+ metadata
594
+ );
634
595
  }
635
596
  }
636
597
 
637
598
  // ============================================================================
638
- // Review Command (Phase 8)
599
+ // Review Command
639
600
  // ============================================================================
640
601
 
641
- class GeminiReviewCommand extends BaseCommand {
602
+ class OracleReviewCommand extends OracleCommand {
642
603
  readonly name = "review";
643
604
  readonly description = "Review implementation against requirements";
644
605
 
@@ -673,17 +634,15 @@ Output JSON:
673
634
  }`;
674
635
 
675
636
  defineArguments(cmd: Command): void {
676
- cmd
637
+ addProviderOption(cmd)
677
638
  .argument("[prompt_num]", "Prompt number to review")
678
639
  .argument("[variant]", "Optional variant letter (A, B, etc.)")
679
640
  .option("--full", "Review entire plan implementation");
680
641
  }
681
642
 
682
643
  async execute(args: Record<string, unknown>): Promise<CommandResult> {
683
- const apiKey = process.env.VERTEX_API_KEY;
684
- if (!apiKey) {
685
- return this.error("auth_error", "VERTEX_API_KEY not set");
686
- }
644
+ const authError = await this.initProvider(args);
645
+ if (authError) return authError;
687
646
 
688
647
  if (isDirectModeBranch(getBranch())) {
689
648
  return this.error("direct_mode", "No plan in direct mode");
@@ -698,18 +657,17 @@ Output JSON:
698
657
  const variant = args.variant as string | undefined;
699
658
 
700
659
  if (isFull) {
701
- return this.executeFullReview(apiKey);
660
+ return this.executeFullReview();
702
661
  }
703
662
 
704
663
  if (!promptNum) {
705
664
  return this.error("missing_argument", "Either --full or prompt_num is required");
706
665
  }
707
666
 
708
- return this.executePromptReview(apiKey, parseInt(promptNum, 10), variant ?? null);
667
+ return this.executePromptReview(parseInt(promptNum, 10), variant ?? null);
709
668
  }
710
669
 
711
670
  private async executePromptReview(
712
- apiKey: string,
713
671
  promptNum: number,
714
672
  variant: string | null
715
673
  ): Promise<CommandResult> {
@@ -721,8 +679,6 @@ Output JSON:
721
679
 
722
680
  const userInput = readUserInput() ?? "";
723
681
  const promptId = getPromptId(promptNum, variant);
724
-
725
- // Get diff for worktree branch if available, otherwise current branch
726
682
  const baseBranch = getBaseBranch();
727
683
  const diffContent = getDiff(baseBranch);
728
684
  const commits = getCommitSummaries(baseBranch);
@@ -746,43 +702,14 @@ ${commits}
746
702
 
747
703
  Review and respond with JSON only.`;
748
704
 
749
- const start = performance.now();
750
- const result = await withRetry(
751
- async () => {
752
- const client = new GoogleGenAI({ vertexai: true, apiKey });
753
- const genResult = await client.models.generateContent({
754
- model: PRO_MODEL,
755
- contents: fullPrompt,
756
- });
757
- return genResult.text ?? "";
758
- },
759
- "gemini.review",
760
- {},
761
- GEMINI_FALLBACKS.review
762
- );
763
-
764
- const durationMs = Math.round(performance.now() - start);
765
- recordGeminiCall({ endpoint: "review", duration_ms: durationMs, success: result.success, retries: result.retries });
705
+ const { error, parsed, metadata } = await this.callAndParse("review", fullPrompt, true);
706
+ if (error) return error;
766
707
 
767
- if (!result.success) {
768
- return {
769
- status: "error",
770
- error: {
771
- type: result.error,
772
- message: "Gemini API unavailable after retries",
773
- suggestion: result.fallback_suggestion,
774
- },
775
- metadata: { retries: result.retries, duration_ms: durationMs },
776
- };
777
- }
778
-
779
- const parsed = parseJsonResponse(result.data);
708
+ const promptMeta = { ...metadata, prompt_id: promptId };
780
709
  const questions = (parsed.clarifying_questions as string[]) ?? [];
781
710
 
782
- // If there are clarifying questions, block for user answers
783
711
  if (questions.length > 0) {
784
712
  const feedbackPath = writeReviewQuestionsFeedback(promptId, questions);
785
-
786
713
  await watchForDone(feedbackPath, getBlockingGateTimeout());
787
714
  const feedback = readReviewQuestionsFeedback(promptId);
788
715
 
@@ -790,61 +717,58 @@ Review and respond with JSON only.`;
790
717
  return this.error("invalid_feedback", feedback.error);
791
718
  }
792
719
 
793
- // Append to user_input.md
794
720
  const qaContent = feedback.data.questions
795
721
  .map((q) => `**Q:** ${q.question}\n**A:** ${q.answer}`)
796
722
  .join("\n\n");
797
- const userThoughts = feedback.data.thoughts ? `**User Thoughts:** ${feedback.data.thoughts}\n\n` : "";
798
- appendUserInput(`## Review Clarifications (Prompt ${promptId})\n\n${userThoughts}${qaContent}`);
799
-
723
+ const userThoughts = feedback.data.thoughts
724
+ ? `**User Thoughts:** ${feedback.data.thoughts}\n\n`
725
+ : "";
726
+ appendUserInput(
727
+ `## Review Clarifications (Prompt ${promptId})\n\n${userThoughts}${qaContent}`
728
+ );
800
729
  deleteFeedbackFile(`${promptId}_review_questions`);
801
730
 
802
731
  appendPromptReview(promptNum, variant, {
803
- review_context: parsed.thoughts as string ?? "",
732
+ review_context: (parsed.thoughts as string) ?? "",
804
733
  decision: "needs_changes",
805
734
  total_questions: questions.length,
806
735
  were_changes_suggested: !!(parsed.suggested_changes as string),
807
736
  });
808
737
 
809
- return this.success({
810
- verdict: "needs_clarification",
811
- thoughts: parsed.thoughts,
812
- answered_questions: feedback.data.questions,
813
- suggested_changes: parsed.suggested_changes,
814
- }, {
815
- command: "gemini review",
816
- duration_ms: durationMs,
817
- retries: result.retries,
818
- prompt_id: promptId,
819
- });
738
+ return this.success(
739
+ {
740
+ verdict: "needs_clarification",
741
+ thoughts: parsed.thoughts,
742
+ answered_questions: feedback.data.questions,
743
+ suggested_changes: parsed.suggested_changes,
744
+ },
745
+ promptMeta
746
+ );
820
747
  }
821
748
 
822
- // No questions - update status and return
823
- const verdict = parsed.verdict as string ?? "passed";
749
+ const verdict = (parsed.verdict as string) ?? "passed";
824
750
  if (verdict === "passed") {
825
751
  updatePromptStatus(promptNum, variant, "reviewed");
826
752
  }
827
753
 
828
754
  appendPromptReview(promptNum, variant, {
829
- review_context: parsed.thoughts as string ?? "",
755
+ review_context: (parsed.thoughts as string) ?? "",
830
756
  decision: verdict === "passed" ? "approved" : "needs_changes",
831
757
  total_questions: 0,
832
758
  were_changes_suggested: !!(parsed.suggested_changes as string),
833
759
  });
834
760
 
835
- return this.success({
836
- verdict,
837
- thoughts: parsed.thoughts,
838
- suggested_changes: parsed.suggested_changes,
839
- }, {
840
- command: "gemini review",
841
- duration_ms: durationMs,
842
- retries: result.retries,
843
- prompt_id: promptId,
844
- });
761
+ return this.success(
762
+ {
763
+ verdict,
764
+ thoughts: parsed.thoughts,
765
+ suggested_changes: parsed.suggested_changes,
766
+ },
767
+ promptMeta
768
+ );
845
769
  }
846
770
 
847
- private async executeFullReview(apiKey: string): Promise<CommandResult> {
771
+ private async executeFullReview(): Promise<CommandResult> {
848
772
  const plan = readPlan();
849
773
  if (!plan) {
850
774
  return this.error("file_not_found", "Plan file not found");
@@ -854,7 +778,6 @@ Review and respond with JSON only.`;
854
778
  const allPrompts = readAllPrompts();
855
779
  const paths = getPlanPaths();
856
780
 
857
- // Read curator.md if exists
858
781
  const curatorPath = paths.curator;
859
782
  const curatorContent = existsSync(curatorPath)
860
783
  ? readFileSync(curatorPath, "utf-8")
@@ -899,42 +822,14 @@ ${commits}
899
822
 
900
823
  Review and respond with JSON only.`;
901
824
 
902
- const start = performance.now();
903
- const result = await withRetry(
904
- async () => {
905
- const client = new GoogleGenAI({ vertexai: true, apiKey });
906
- const genResult = await client.models.generateContent({
907
- model: PRO_MODEL,
908
- contents: fullPrompt,
909
- });
910
- return genResult.text ?? "";
911
- },
912
- "gemini.review",
913
- {},
914
- GEMINI_FALLBACKS.review
915
- );
916
-
917
- const durationMs = Math.round(performance.now() - start);
918
- recordGeminiCall({ endpoint: "review", duration_ms: durationMs, success: result.success, retries: result.retries });
825
+ const { error, parsed, metadata } = await this.callAndParse("review", fullPrompt, true);
826
+ if (error) return error;
919
827
 
920
- if (!result.success) {
921
- return {
922
- status: "error",
923
- error: {
924
- type: result.error,
925
- message: "Gemini API unavailable after retries",
926
- suggestion: result.fallback_suggestion,
927
- },
928
- metadata: { retries: result.retries, duration_ms: durationMs },
929
- };
930
- }
931
-
932
- const parsed = parseJsonResponse(result.data);
828
+ const fullMeta = { ...metadata, command: "oracle review --full" };
933
829
  const questions = (parsed.clarifying_questions as string[]) ?? [];
934
830
 
935
831
  if (questions.length > 0) {
936
832
  const feedbackPath = writeReviewQuestionsFeedback("full", questions);
937
-
938
833
  await watchForDone(feedbackPath, getBlockingGateTimeout());
939
834
  const feedback = readReviewQuestionsFeedback("full");
940
835
 
@@ -945,55 +840,53 @@ Review and respond with JSON only.`;
945
840
  const qaContent = feedback.data.questions
946
841
  .map((q) => `**Q:** ${q.question}\n**A:** ${q.answer}`)
947
842
  .join("\n\n");
948
- const userThoughts = feedback.data.thoughts ? `**User Thoughts:** ${feedback.data.thoughts}\n\n` : "";
843
+ const userThoughts = feedback.data.thoughts
844
+ ? `**User Thoughts:** ${feedback.data.thoughts}\n\n`
845
+ : "";
949
846
  appendUserInput(`## Full Review Clarifications\n\n${userThoughts}${qaContent}`);
950
-
951
847
  deleteFeedbackFile("full_review_questions");
952
848
 
953
849
  appendPlanReview({
954
- review_context: parsed.thoughts as string ?? "",
850
+ review_context: (parsed.thoughts as string) ?? "",
955
851
  decision: "needs_changes",
956
852
  total_questions: questions.length,
957
853
  were_changes_suggested: !!(parsed.suggested_changes as string),
958
854
  });
959
855
 
960
- return this.success({
961
- verdict: "needs_clarification",
962
- thoughts: parsed.thoughts,
963
- answered_questions: feedback.data.questions,
964
- suggested_changes: parsed.suggested_changes,
965
- }, {
966
- command: "gemini review --full",
967
- duration_ms: durationMs,
968
- retries: result.retries,
969
- });
856
+ return this.success(
857
+ {
858
+ verdict: "needs_clarification",
859
+ thoughts: parsed.thoughts,
860
+ answered_questions: feedback.data.questions,
861
+ suggested_changes: parsed.suggested_changes,
862
+ },
863
+ fullMeta
864
+ );
970
865
  }
971
866
 
972
- const verdict = parsed.verdict as string ?? "passed";
867
+ const verdict = (parsed.verdict as string) ?? "passed";
973
868
  appendPlanReview({
974
- review_context: parsed.thoughts as string ?? "",
869
+ review_context: (parsed.thoughts as string) ?? "",
975
870
  decision: verdict === "passed" ? "approved" : "needs_changes",
976
871
  total_questions: 0,
977
872
  were_changes_suggested: !!(parsed.suggested_changes as string),
978
873
  });
979
874
 
980
- return this.success({
981
- verdict,
982
- thoughts: parsed.thoughts,
983
- suggested_changes: parsed.suggested_changes,
984
- }, {
985
- command: "gemini review --full",
986
- duration_ms: durationMs,
987
- retries: result.retries,
988
- });
875
+ return this.success(
876
+ {
877
+ verdict,
878
+ thoughts: parsed.thoughts,
879
+ suggested_changes: parsed.suggested_changes,
880
+ },
881
+ fullMeta
882
+ );
989
883
  }
990
884
  }
991
885
 
992
- // Auto-discovered by cli.ts
993
886
  export const COMMANDS = {
994
- ask: GeminiAskCommand,
995
- validate: GeminiValidateCommand,
996
- architect: GeminiArchitectCommand,
997
- audit: GeminiAuditCommand,
998
- review: GeminiReviewCommand,
887
+ ask: OracleAskCommand,
888
+ validate: OracleValidateCommand,
889
+ architect: OracleArchitectCommand,
890
+ audit: OracleAuditCommand,
891
+ review: OracleReviewCommand,
999
892
  };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Gemini provider implementation using @google/genai SDK.
3
+ */
4
+
5
+ import { GoogleGenAI } from "@google/genai";
6
+ import type {
7
+ LLMProvider,
8
+ GenerateOptions,
9
+ GenerateResult,
10
+ ProviderConfig,
11
+ ContentPart,
12
+ } from "./providers.js";
13
+ import { PROVIDER_CONFIGS } from "./providers.js";
14
+
15
+ export class GeminiProvider implements LLMProvider {
16
+ readonly config: ProviderConfig = PROVIDER_CONFIGS.gemini;
17
+
18
+ getApiKey(): string | undefined {
19
+ return process.env.VERTEX_API_KEY;
20
+ }
21
+
22
+ async generate(
23
+ contents: string | ContentPart[],
24
+ options?: GenerateOptions
25
+ ): Promise<GenerateResult> {
26
+ const apiKey = this.getApiKey();
27
+ if (!apiKey) {
28
+ throw new Error(`${this.config.apiKeyEnvVar} not set`);
29
+ }
30
+
31
+ const model =
32
+ options?.model ?? (options?.usePro ? this.config.proModel : this.config.defaultModel);
33
+ const client = new GoogleGenAI({ vertexai: true, apiKey });
34
+
35
+ const result = await client.models.generateContent({ model, contents });
36
+ return { text: result.text ?? "", model };
37
+ }
38
+ }
@@ -29,6 +29,7 @@ export {
29
29
  recordPromptCompleted,
30
30
  recordGateCompleted,
31
31
  recordGeminiCall,
32
+ recordOracleCall,
32
33
  recordDiscoveryCompleted,
33
34
  recordDocumentationExtracted,
34
35
  } from "./observability.js";
@@ -167,9 +168,24 @@ export {
167
168
  export type { TreeEntry, RepomixResult } from "./repomix.js";
168
169
 
169
170
  // Retry utilities
170
- export { withRetry, GEMINI_FALLBACKS } from "./retry.js";
171
+ export { withRetry, ORACLE_FALLBACKS, GEMINI_FALLBACKS } from "./retry.js";
171
172
  export type { RetryOptions, RetryResult } from "./retry.js";
172
173
 
174
+ // Provider utilities
175
+ export {
176
+ createProvider,
177
+ getDefaultProvider,
178
+ PROVIDER_CONFIGS,
179
+ } from "./providers.js";
180
+ export type {
181
+ ProviderName,
182
+ ProviderConfig,
183
+ GenerateOptions,
184
+ GenerateResult,
185
+ ContentPart,
186
+ LLMProvider,
187
+ } from "./providers.js";
188
+
173
189
  // Protocol utilities (Phase 9)
174
190
  export {
175
191
  getProtocolsDir,
@@ -242,6 +242,18 @@ export function recordGeminiCall(data: {
242
242
  recordMetric({ type: "gemini_call", ...data });
243
243
  }
244
244
 
245
+ export function recordOracleCall(data: {
246
+ provider: string;
247
+ endpoint: string;
248
+ duration_ms: number;
249
+ success: boolean;
250
+ retries: number;
251
+ verdict?: string;
252
+ plan_name?: string;
253
+ }): void {
254
+ recordMetric({ type: "oracle_call", ...data });
255
+ }
256
+
245
257
  export function recordDiscoveryCompleted(data: {
246
258
  specialist: string;
247
259
  approach_count: number;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * OpenAI provider implementation using direct fetch (no SDK dependency).
3
+ */
4
+
5
+ import type {
6
+ LLMProvider,
7
+ GenerateOptions,
8
+ GenerateResult,
9
+ ProviderConfig,
10
+ ContentPart,
11
+ } from "./providers.js";
12
+ import { PROVIDER_CONFIGS } from "./providers.js";
13
+
14
+ interface OpenAIResponse {
15
+ choices?: Array<{ message?: { content?: string } }>;
16
+ model?: string;
17
+ error?: { message?: string };
18
+ }
19
+
20
+ interface OpenAIMessage {
21
+ role: string;
22
+ content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
23
+ }
24
+
25
+ export class OpenAIProvider implements LLMProvider {
26
+ readonly config: ProviderConfig = PROVIDER_CONFIGS.openai;
27
+
28
+ getApiKey(): string | undefined {
29
+ return process.env.OPENAI_API_KEY;
30
+ }
31
+
32
+ async generate(
33
+ contents: string | ContentPart[],
34
+ options?: GenerateOptions
35
+ ): Promise<GenerateResult> {
36
+ const apiKey = this.getApiKey();
37
+ if (!apiKey) {
38
+ throw new Error(`${this.config.apiKeyEnvVar} not set`);
39
+ }
40
+
41
+ const model =
42
+ options?.model ?? (options?.usePro ? this.config.proModel : this.config.defaultModel);
43
+ const messages = this.formatMessages(contents);
44
+
45
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
46
+ method: "POST",
47
+ headers: {
48
+ Authorization: `Bearer ${apiKey}`,
49
+ "Content-Type": "application/json",
50
+ },
51
+ body: JSON.stringify({ model, messages }),
52
+ });
53
+
54
+ if (!response.ok) {
55
+ const text = await response.text();
56
+ throw new Error(`HTTP ${response.status}: ${text}`);
57
+ }
58
+
59
+ const data = (await response.json()) as OpenAIResponse;
60
+ if (data.error) {
61
+ throw new Error(data.error.message ?? "OpenAI API error");
62
+ }
63
+
64
+ return {
65
+ text: data.choices?.[0]?.message?.content ?? "",
66
+ model: data.model ?? model,
67
+ };
68
+ }
69
+
70
+ private formatMessages(contents: string | ContentPart[]): OpenAIMessage[] {
71
+ if (typeof contents === "string") {
72
+ return [{ role: "user", content: contents }];
73
+ }
74
+
75
+ const parts: Array<{ type: string; text?: string; image_url?: { url: string } }> = [];
76
+ for (const item of contents) {
77
+ if (typeof item === "string") {
78
+ parts.push({ type: "text", text: item });
79
+ } else if (item.inlineData) {
80
+ parts.push({
81
+ type: "image_url",
82
+ image_url: {
83
+ url: `data:${item.inlineData.mimeType};base64,${item.inlineData.data}`,
84
+ },
85
+ });
86
+ }
87
+ }
88
+ return [{ role: "user", content: parts }];
89
+ }
90
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Provider abstraction layer for multi-LLM oracle commands.
3
+ * Supports Gemini and OpenAI with standardized interface.
4
+ */
5
+
6
+ export type ProviderName = "gemini" | "openai";
7
+
8
+ export interface ProviderConfig {
9
+ name: ProviderName;
10
+ apiKeyEnvVar: string;
11
+ defaultModel: string;
12
+ proModel: string;
13
+ }
14
+
15
+ export interface GenerateOptions {
16
+ model?: string;
17
+ usePro?: boolean;
18
+ }
19
+
20
+ export interface GenerateResult {
21
+ text: string;
22
+ model: string;
23
+ }
24
+
25
+ export type ContentPart = string | { inlineData: { mimeType: string; data: string } };
26
+
27
+ export interface LLMProvider {
28
+ readonly config: ProviderConfig;
29
+ getApiKey(): string | undefined;
30
+ generate(
31
+ contents: string | ContentPart[],
32
+ options?: GenerateOptions
33
+ ): Promise<GenerateResult>;
34
+ }
35
+
36
+ export const PROVIDER_CONFIGS: Record<ProviderName, ProviderConfig> = {
37
+ gemini: {
38
+ name: "gemini",
39
+ apiKeyEnvVar: "VERTEX_API_KEY",
40
+ defaultModel: "gemini-2.0-flash",
41
+ proModel: "gemini-3-pro-preview",
42
+ },
43
+ openai: {
44
+ name: "openai",
45
+ apiKeyEnvVar: "OPENAI_API_KEY",
46
+ defaultModel: "gpt-5.2",
47
+ proModel: "gpt-5.2",
48
+ },
49
+ };
50
+
51
+ export function getDefaultProvider(): ProviderName {
52
+ const envProvider = process.env.ORACLE_DEFAULT_PROVIDER;
53
+ if (envProvider === "openai" || envProvider === "gemini") {
54
+ return envProvider;
55
+ }
56
+ return "gemini";
57
+ }
58
+
59
+ // Lazy imports to avoid loading unused SDKs
60
+ export async function createProvider(name: ProviderName): Promise<LLMProvider> {
61
+ switch (name) {
62
+ case "gemini": {
63
+ const { GeminiProvider } = await import("./gemini-provider.js");
64
+ return new GeminiProvider();
65
+ }
66
+ case "openai": {
67
+ const { OpenAIProvider } = await import("./openai-provider.js");
68
+ return new OpenAIProvider();
69
+ }
70
+ }
71
+ }
@@ -129,10 +129,17 @@ export async function withRetry<T>(
129
129
  }
130
130
 
131
131
  /**
132
- * Pre-defined fallback suggestions for Gemini endpoints.
132
+ * Pre-defined fallback suggestions for oracle endpoints (provider-agnostic).
133
133
  */
134
- export const GEMINI_FALLBACKS: Record<string, string> = {
135
- audit: "Skip audit and proceed with user review only via block-plan-gate",
134
+ export const ORACLE_FALLBACKS: Record<string, string> = {
135
+ audit: "Skip audit and proceed with user review only",
136
136
  review: "Mark prompt as needs_manual_review for user verification",
137
- ask: "Proceed without Gemini response, use agent judgment",
137
+ ask: "Proceed without LLM response, use agent judgment",
138
+ validate: "Skip validation and proceed with user review only",
139
+ architect: "Proceed without architect analysis",
138
140
  };
141
+
142
+ /**
143
+ * @deprecated Use ORACLE_FALLBACKS instead
144
+ */
145
+ export const GEMINI_FALLBACKS = ORACLE_FALLBACKS;
@@ -8,7 +8,8 @@
8
8
  "SEARCH_CONTEXT_TOKEN_LIMIT": "5000",
9
9
  "MAX_LOGS_TOKENS": "10000",
10
10
  "BASH_MAX_TIMEOUT_MS": "3600000",
11
- "BASE_BRANCH": "main"
11
+ "BASE_BRANCH": "main",
12
+ "ORACLE_DEFAULT_PROVIDER": "openai"
12
13
  },
13
14
  "permissions": {
14
15
  "allow": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-all-hands",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "CLI for syncing Claude agent configurations to all-hands repository",
5
5
  "type": "module",
6
6
  "bin": {