ai-consultants 2.10.1

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 (62) hide show
  1. package/.claude/commands/ai-consultants:consult.md +94 -0
  2. package/.claude/commands/ai-consultants:debate.md +103 -0
  3. package/.claude/commands/ai-consultants:help.md +30 -0
  4. package/CLAUDE.md +839 -0
  5. package/LICENSE +21 -0
  6. package/README.md +758 -0
  7. package/SKILL.md +156 -0
  8. package/bin/ai-consultants +96 -0
  9. package/docs/COST_RATES.md +172 -0
  10. package/docs/JSON_SCHEMA.md +305 -0
  11. package/docs/SETUP.md +879 -0
  12. package/docs/SMART_ROUTING.md +219 -0
  13. package/docs/cost_rates.json +143 -0
  14. package/package.json +47 -0
  15. package/references/configuration.md +141 -0
  16. package/references/details.md +60 -0
  17. package/scripts/build_context.sh +562 -0
  18. package/scripts/classify_question.sh +148 -0
  19. package/scripts/config.sh +753 -0
  20. package/scripts/configure.sh +1093 -0
  21. package/scripts/consult_all.sh +934 -0
  22. package/scripts/debate_round.sh +421 -0
  23. package/scripts/doctor.sh +642 -0
  24. package/scripts/followup.sh +197 -0
  25. package/scripts/install.sh +319 -0
  26. package/scripts/lib/api.sh +640 -0
  27. package/scripts/lib/api_query.sh +228 -0
  28. package/scripts/lib/cache.sh +338 -0
  29. package/scripts/lib/chunking.sh +880 -0
  30. package/scripts/lib/code_optimizer.sh +671 -0
  31. package/scripts/lib/common.sh +1088 -0
  32. package/scripts/lib/costs.sh +674 -0
  33. package/scripts/lib/personas.sh +372 -0
  34. package/scripts/lib/progress.sh +251 -0
  35. package/scripts/lib/reflection.sh +447 -0
  36. package/scripts/lib/routing.sh +566 -0
  37. package/scripts/lib/schema.json +165 -0
  38. package/scripts/lib/session.sh +327 -0
  39. package/scripts/lib/symbol_map.sh +445 -0
  40. package/scripts/lib/voting.sh +495 -0
  41. package/scripts/peer_review.sh +460 -0
  42. package/scripts/preflight_check.sh +452 -0
  43. package/scripts/query_aider.sh +83 -0
  44. package/scripts/query_amp.sh +67 -0
  45. package/scripts/query_claude.sh +99 -0
  46. package/scripts/query_codex.sh +92 -0
  47. package/scripts/query_cursor.sh +72 -0
  48. package/scripts/query_deepseek.sh +17 -0
  49. package/scripts/query_gemini.sh +85 -0
  50. package/scripts/query_glm.sh +17 -0
  51. package/scripts/query_grok.sh +17 -0
  52. package/scripts/query_kilo.sh +140 -0
  53. package/scripts/query_kimi.sh +67 -0
  54. package/scripts/query_minimax.sh +17 -0
  55. package/scripts/query_mistral.sh +97 -0
  56. package/scripts/query_ollama.sh +328 -0
  57. package/scripts/query_qwen3.sh +93 -0
  58. package/scripts/setup_wizard.sh +367 -0
  59. package/scripts/skillport-install.sh +196 -0
  60. package/scripts/synthesize.sh +332 -0
  61. package/templates/consultation_report.md +323 -0
  62. package/templates/synthesis_prompt.md +120 -0
@@ -0,0 +1,1088 @@
1
+ #!/bin/bash
2
+ # common.sh - Shared functions for consultation scripts
3
+ # Includes: logging, cross-platform timeout, retry logic, validation
4
+
5
+ # Guard against double-sourcing
6
+ if [[ -n "${_COMMON_SH_SOURCED:-}" ]]; then
7
+ return 0 2>/dev/null || exit 0
8
+ fi
9
+ _COMMON_SH_SOURCED=1
10
+
11
+ # Load configuration
12
+ SCRIPT_DIR_COMMON="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ source "$SCRIPT_DIR_COMMON/../config.sh"
14
+
15
+ # =============================================================================
16
+ # LOGGING FUNCTIONS
17
+ # =============================================================================
18
+
19
+ _log() {
20
+ local level=$1
21
+ local color=$2
22
+ local message=$3
23
+
24
+ # Map levels to numbers for comparison (without associative arrays for compatibility)
25
+ local current_level_num=1 # default INFO
26
+ local message_level_num=1
27
+
28
+ case "$LOG_LEVEL" in
29
+ DEBUG) current_level_num=0 ;;
30
+ INFO) current_level_num=1 ;;
31
+ WARN) current_level_num=2 ;;
32
+ ERROR) current_level_num=3 ;;
33
+ esac
34
+
35
+ case "$level" in
36
+ DEBUG) message_level_num=0 ;;
37
+ INFO) message_level_num=1 ;;
38
+ WARN) message_level_num=2 ;;
39
+ ERROR) message_level_num=3 ;;
40
+ esac
41
+
42
+ if [[ "$message_level_num" -ge "$current_level_num" ]]; then
43
+ echo -e "${color}[$(date '+%H:%M:%S')] [$level]${C_RESET} ${message}" >&2
44
+ fi
45
+ }
46
+
47
+ log_debug() { _log "DEBUG" "$C_DEBUG" "$1"; }
48
+ log_info() { _log "INFO" "$C_INFO" "$1"; }
49
+ log_success() { _log "INFO" "$C_SUCCESS" "$1"; }
50
+ log_warn() { _log "WARN" "$C_WARN" "$1"; }
51
+ log_error() { _log "ERROR" "$C_ERROR" "$1"; }
52
+
53
+ # =============================================================================
54
+ # CROSS-PLATFORM TIMEOUT
55
+ # =============================================================================
56
+
57
+ # Random stagger delay to avoid rate-limit bursts on parallel launch.
58
+ # Usage: apply_launch_stagger
59
+ # Sleeps for a random duration between 0 and LAUNCH_STAGGER_MAX_SECONDS.
60
+ # No-op if LAUNCH_STAGGER_MAX_SECONDS is 0 or unset.
61
+ apply_launch_stagger() {
62
+ local max="${LAUNCH_STAGGER_MAX_SECONDS:-0}"
63
+ if [[ "$max" -gt 0 ]] 2>/dev/null; then
64
+ local delay_ms=$(( RANDOM % (max * 1000) ))
65
+ local delay_s
66
+ delay_s=$(awk "BEGIN {printf \"%.2f\", $delay_ms / 1000}")
67
+ log_debug "Stagger delay: ${delay_s}s"
68
+ sleep "$delay_s"
69
+ fi
70
+ }
71
+
72
+ # Timeout function compatible with macOS and Linux
73
+ # Usage: run_with_timeout <seconds> <command> [args...]
74
+ run_with_timeout() {
75
+ local timeout_seconds=$1
76
+ shift
77
+ local cmd=("$@")
78
+
79
+ # Try first with timeout (Linux/GNU coreutils)
80
+ if command -v timeout &> /dev/null; then
81
+ timeout "$timeout_seconds" "${cmd[@]}"
82
+ return $?
83
+ fi
84
+
85
+ # Try with gtimeout (macOS with coreutils installed)
86
+ if command -v gtimeout &> /dev/null; then
87
+ gtimeout "$timeout_seconds" "${cmd[@]}"
88
+ return $?
89
+ fi
90
+
91
+ # Fallback: implementation with background job and kill
92
+ # Works on any POSIX system
93
+ # Note: stdin must be captured before backgrounding
94
+ local stdin_data
95
+ stdin_data=$(cat)
96
+ echo "$stdin_data" | "${cmd[@]}" &
97
+ local pid=$!
98
+
99
+ # Monitor in background
100
+ (
101
+ sleep "$timeout_seconds"
102
+ kill -0 "$pid" 2>/dev/null && kill -TERM "$pid" 2>/dev/null
103
+ ) &
104
+ local watchdog_pid=$!
105
+
106
+ # Wait for the command
107
+ wait "$pid" 2>/dev/null
108
+ local exit_code=$?
109
+
110
+ # Clean up the watchdog if the command finished first
111
+ kill -0 "$watchdog_pid" 2>/dev/null && kill "$watchdog_pid" 2>/dev/null
112
+ wait "$watchdog_pid" 2>/dev/null
113
+
114
+ # If the process was killed by timeout, return 124 (like GNU timeout)
115
+ if [[ $exit_code -eq 143 ]] || [[ $exit_code -eq 137 ]]; then
116
+ return 124
117
+ fi
118
+
119
+ return $exit_code
120
+ }
121
+
122
+ # =============================================================================
123
+ # COMMAND VERIFICATION
124
+ # =============================================================================
125
+
126
+ check_command() {
127
+ local cmd=$1
128
+ local name=$2
129
+ local install_hint=$3
130
+
131
+ if ! command -v "$cmd" &> /dev/null; then
132
+ log_error "$name not found (command: $cmd)"
133
+ if [[ -n "$install_hint" ]]; then
134
+ log_info "Install with: $install_hint"
135
+ fi
136
+ return 1
137
+ fi
138
+ return 0
139
+ }
140
+
141
+ # =============================================================================
142
+ # MAIN QUERY EXECUTION FUNCTION
143
+ # =============================================================================
144
+
145
+ # Executes a query to an AI consultant with retry and timeout
146
+ #
147
+ # Usage: run_query <consultant_name> <output_file> <timeout_sec> <command...>
148
+ #
149
+ # The query is passed via stdin to the command.
150
+ # Example:
151
+ # echo "$QUERY" | run_query "Gemini" "/tmp/out.json" 120 gemini -p - --output-format json
152
+ #
153
+ run_query() {
154
+ local consultant_name="$1"
155
+ local output_file="$2"
156
+ local timeout_seconds="$3"
157
+ shift 3
158
+ local cmd=("$@")
159
+
160
+ # File for stderr
161
+ local error_file="${output_file}.err"
162
+
163
+ # Read stdin into a variable so it can be reused in retries
164
+ local stdin_content
165
+ stdin_content=$(cat)
166
+
167
+ log_info "Consulting $consultant_name (timeout: ${timeout_seconds}s, max retry: $MAX_RETRIES)..."
168
+
169
+ local attempt=1
170
+ while (( attempt <= MAX_RETRIES )); do
171
+ log_debug "[$consultant_name] Attempt $attempt of $MAX_RETRIES..."
172
+
173
+ # Execute the command with timeout, passing stdin
174
+ echo "$stdin_content" | run_with_timeout "$timeout_seconds" "${cmd[@]}" > "$output_file" 2> "$error_file"
175
+ local exit_code=$?
176
+
177
+ if [[ $exit_code -eq 0 ]]; then
178
+ # Verify that the output is not empty
179
+ if [[ -s "$output_file" ]]; then
180
+ log_success "[$consultant_name] Response received ($(wc -c < "$output_file" | tr -d ' ') bytes)"
181
+ rm -f "$error_file"
182
+ return 0
183
+ else
184
+ log_warn "[$consultant_name] Empty response"
185
+ exit_code=1 # Treat empty response as failure
186
+ fi
187
+ fi
188
+
189
+ # Error handling
190
+ local error_msg=""
191
+ [[ -f "$error_file" ]] && error_msg=$(head -5 "$error_file" 2>/dev/null)
192
+
193
+ if [[ $exit_code -eq 124 ]]; then
194
+ log_warn "[$consultant_name] Timeout after ${timeout_seconds}s"
195
+ else
196
+ log_warn "[$consultant_name] Error (code: $exit_code)"
197
+ [[ -n "$error_msg" ]] && log_debug "Details: $error_msg"
198
+ fi
199
+
200
+ ((attempt++))
201
+ if (( attempt <= MAX_RETRIES )); then
202
+ log_info "Waiting ${RETRY_DELAY_SECONDS}s before next attempt..."
203
+ sleep "$RETRY_DELAY_SECONDS"
204
+ fi
205
+ done
206
+
207
+ log_error "[$consultant_name] All $MAX_RETRIES attempts failed"
208
+ return 1
209
+ }
210
+
211
+ # =============================================================================
212
+ # TIMESTAMP UTILITIES
213
+ # =============================================================================
214
+
215
+ # Detect timestamp method once at source time (avoid repeated detection)
216
+ if date +%s%3N 2>/dev/null | grep -qv 'N'; then
217
+ _TIMESTAMP_METHOD="gnu"
218
+ elif command -v python3 &>/dev/null; then
219
+ _TIMESTAMP_METHOD="python"
220
+ else
221
+ _TIMESTAMP_METHOD="posix"
222
+ fi
223
+
224
+ # Get current timestamp in milliseconds (portable - works on macOS and Linux)
225
+ # Usage: get_timestamp_ms
226
+ get_timestamp_ms() {
227
+ case "$_TIMESTAMP_METHOD" in
228
+ gnu) date +%s%3N ;;
229
+ python) python3 -c 'import time; print(int(time.time()*1000))' ;;
230
+ *) echo "$(($(date +%s) * 1000))" ;;
231
+ esac
232
+ }
233
+
234
+ # =============================================================================
235
+ # CASE NORMALIZATION HELPERS
236
+ # =============================================================================
237
+
238
+ # Convert string to uppercase (portable - works on Bash 3.2+)
239
+ # Usage: to_upper "string"
240
+ to_upper() {
241
+ echo "$1" | tr '[:lower:]' '[:upper:]' | tr -d ' -'
242
+ }
243
+
244
+ # Convert string to lowercase (portable - works on Bash 3.2+)
245
+ # Usage: to_lower "string"
246
+ to_lower() {
247
+ echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' -' '_'
248
+ }
249
+
250
+ # Convert to title case (first letter uppercase, rest lowercase)
251
+ # Usage: to_title "STRING" => "String"
252
+ to_title() {
253
+ echo "$1" | awk '{print toupper(substr($0,1,1)) tolower(substr($0,2))}'
254
+ }
255
+
256
+ # =============================================================================
257
+ # KNOWN AGENTS REGISTRY
258
+ # =============================================================================
259
+
260
+ # Central list of known/predefined agents (to distinguish from custom ones)
261
+ # This list is used by discovery functions to identify custom agents
262
+ KNOWN_CLI_AGENTS="GEMINI CODEX MISTRAL KILO CURSOR AIDER AMP KIMI CLAUDE QWEN3"
263
+ KNOWN_API_AGENTS="GLM GROK DEEPSEEK MINIMAX"
264
+ KNOWN_FEATURE_FLAGS="PERSONA SYNTHESIS DEBATE REFLECTION CLASSIFICATION SMART_ROUTING COST_TRACKING PROGRESS_BARS EARLY_TERMINATION PREFLIGHT"
265
+
266
+ # Check if an agent name is a known predefined agent
267
+ # Usage: is_known_agent "AGENTNAME"
268
+ is_known_agent() {
269
+ local agent_upper="$1"
270
+ for known in $KNOWN_CLI_AGENTS $KNOWN_API_AGENTS $KNOWN_FEATURE_FLAGS; do
271
+ [[ "$agent_upper" == "$known" ]] && return 0
272
+ done
273
+ return 1
274
+ }
275
+
276
+ # =============================================================================
277
+ # CLI/API MODE SWITCHING (v2.6)
278
+ # =============================================================================
279
+
280
+ # Check if an agent is configured to use API mode instead of CLI
281
+ # Usage: is_api_mode <agent_name>
282
+ # Returns: 0 (true) if API mode, 1 (false) if CLI mode
283
+ is_api_mode() {
284
+ local agent="$1"
285
+ local agent_upper
286
+ agent_upper=$(to_upper "$agent")
287
+
288
+ local var_name="${agent_upper}_USE_API"
289
+ [[ "${!var_name:-false}" == "true" ]]
290
+ }
291
+
292
+ # Validate API mode configuration for an agent
293
+ # Checks if the required API key is set
294
+ # Usage: validate_api_mode <agent_name>
295
+ # Returns: 0 if valid, 1 if API key missing
296
+ validate_api_mode() {
297
+ local agent="$1"
298
+ local agent_upper
299
+ agent_upper=$(to_upper "$agent")
300
+
301
+ local api_key_var
302
+ case "$agent_upper" in
303
+ GEMINI) api_key_var="GEMINI_API_KEY" ;;
304
+ CODEX) api_key_var="OPENAI_API_KEY" ;;
305
+ CLAUDE) api_key_var="ANTHROPIC_API_KEY" ;;
306
+ MISTRAL) api_key_var="MISTRAL_API_KEY" ;;
307
+ QWEN3) api_key_var="QWEN3_API_KEY" ;;
308
+ *)
309
+ log_error "Unknown agent for API mode: $agent"
310
+ return 1
311
+ ;;
312
+ esac
313
+
314
+ if [[ -z "${!api_key_var:-}" ]]; then
315
+ log_error "[$agent] API mode enabled but $api_key_var is not set"
316
+ return 1
317
+ fi
318
+
319
+ log_debug "[$agent] API mode validated with $api_key_var"
320
+ return 0
321
+ }
322
+
323
+ # Get the API key variable name for an agent
324
+ # Usage: get_api_key_var <agent_name>
325
+ get_api_key_var() {
326
+ local agent="$1"
327
+ local agent_upper
328
+ agent_upper=$(to_upper "$agent")
329
+
330
+ case "$agent_upper" in
331
+ GEMINI) echo "GEMINI_API_KEY" ;;
332
+ CODEX) echo "OPENAI_API_KEY" ;;
333
+ CLAUDE) echo "ANTHROPIC_API_KEY" ;;
334
+ MISTRAL) echo "MISTRAL_API_KEY" ;;
335
+ QWEN3) echo "QWEN3_API_KEY" ;;
336
+ GLM) echo "GLM_API_KEY" ;;
337
+ GROK) echo "GROK_API_KEY" ;;
338
+ DEEPSEEK) echo "DEEPSEEK_API_KEY" ;;
339
+ MINIMAX) echo "MINIMAX_API_KEY" ;;
340
+ *) echo "" ;;
341
+ esac
342
+ }
343
+
344
+ # Get the API URL for an agent
345
+ # Usage: get_api_url <agent_name>
346
+ get_api_url() {
347
+ local agent="$1"
348
+ local agent_upper
349
+ agent_upper=$(to_upper "$agent")
350
+
351
+ case "$agent_upper" in
352
+ GEMINI) echo "${GEMINI_API_URL:-https://generativelanguage.googleapis.com/v1beta/models}" ;;
353
+ CODEX) echo "${CODEX_API_URL:-https://api.openai.com/v1/chat/completions}" ;;
354
+ CLAUDE) echo "${CLAUDE_API_URL:-https://api.anthropic.com/v1/messages}" ;;
355
+ MISTRAL) echo "${MISTRAL_API_URL:-https://api.mistral.ai/v1/chat/completions}" ;;
356
+ QWEN3) echo "${QWEN3_API_URL:-https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation}" ;;
357
+ GLM) echo "${GLM_API_URL:-https://open.bigmodel.cn/api/paas/v4/chat/completions}" ;;
358
+ GROK) echo "${GROK_API_URL:-https://api.x.ai/v1/chat/completions}" ;;
359
+ DEEPSEEK) echo "${DEEPSEEK_API_URL:-https://api.deepseek.com/v1/chat/completions}" ;;
360
+ MINIMAX) echo "${MINIMAX_API_URL:-https://api.minimax.io/v1/chat/completions}" ;;
361
+ *) echo "" ;;
362
+ esac
363
+ }
364
+
365
+ # Get the API response format for an agent
366
+ # Usage: get_api_format <agent_name>
367
+ get_api_format() {
368
+ local agent="$1"
369
+ local agent_upper
370
+ agent_upper=$(to_upper "$agent")
371
+
372
+ case "$agent_upper" in
373
+ GEMINI) echo "google_ai" ;;
374
+ CLAUDE) echo "anthropic" ;;
375
+ CODEX|MISTRAL|GLM|GROK|DEEPSEEK|MINIMAX) echo "openai" ;;
376
+ QWEN3) echo "qwen" ;;
377
+ *) echo "openai" ;;
378
+ esac
379
+ }
380
+
381
+ # Log API mode status for debugging
382
+ log_api_mode_status() {
383
+ local agent="$1"
384
+ if is_api_mode "$agent"; then
385
+ log_debug "[$agent] Mode: API"
386
+ else
387
+ log_debug "[$agent] Mode: CLI"
388
+ fi
389
+ }
390
+
391
+ # =============================================================================
392
+ # SELF-EXCLUSION LOGIC (v2.2)
393
+ # =============================================================================
394
+
395
+ # Maps invoking agent identifier to the consultant name that should be excluded.
396
+ # Prevents an agent from consulting itself (e.g., Claude Code shouldn't query Claude).
397
+ #
398
+ # Usage: excluded=$(get_self_consultant_name)
399
+ # Returns: Uppercase consultant name (e.g., "CLAUDE", "CODEX") or empty string
400
+ get_self_consultant_name() {
401
+ local invoking
402
+ invoking=$(to_lower "${INVOKING_AGENT:-unknown}")
403
+
404
+ # Normalize aliases to canonical names
405
+ case "$invoking" in
406
+ claude|claude_code|claudecode) echo "CLAUDE" ;;
407
+ codex|codex_cli|codexcli) echo "CODEX" ;;
408
+ gemini|gemini_cli|geminicli) echo "GEMINI" ;;
409
+ mistral|vibe|mistral_vibe) echo "MISTRAL" ;;
410
+ kilo|kilocode|kilo_code) echo "KILO" ;;
411
+ amp|amp_code|ampcode) echo "AMP" ;;
412
+ kimi|kimi_code|kimicode) echo "KIMI" ;;
413
+ qwen|qwen3|qwen_code|qwencode) echo "QWEN3" ;;
414
+ cursor|aider) echo "$(to_upper "$invoking")" ;;
415
+ *) echo "" ;;
416
+ esac
417
+ }
418
+
419
+ # Check if a consultant should be skipped due to self-exclusion
420
+ # Usage: should_skip_consultant "CONSULTANTNAME"
421
+ # Returns: 0 (true) if should skip, 1 (false) if should include
422
+ should_skip_consultant() {
423
+ local self_name
424
+ self_name=$(get_self_consultant_name)
425
+ [[ -n "$self_name" && "$(to_upper "$1")" == "$self_name" ]]
426
+ }
427
+
428
+ # Log self-exclusion status for debugging
429
+ log_self_exclusion_status() {
430
+ local self_name
431
+ self_name=$(get_self_consultant_name)
432
+ if [[ -n "$self_name" ]]; then
433
+ log_debug "Self-exclusion: excluding $self_name (invoking: ${INVOKING_AGENT:-unknown})"
434
+ fi
435
+ }
436
+
437
+ # =============================================================================
438
+ # SECURITY: INPUT VALIDATION
439
+ # =============================================================================
440
+
441
+ # Validate a file path to prevent path traversal attacks
442
+ # - Rejects paths containing ".."
443
+ # - Rejects absolute paths starting with / (unless in allowed dirs)
444
+ # - Returns 0 if valid, 1 if invalid
445
+ # Usage: validate_file_path "path" [allow_absolute]
446
+ validate_file_path() {
447
+ local path="$1"
448
+ local allow_absolute="${2:-false}"
449
+
450
+ # Check for empty path
451
+ if [[ -z "$path" ]]; then
452
+ log_error "Empty file path provided"
453
+ return 1
454
+ fi
455
+
456
+ # Check for path traversal attempts
457
+ if [[ "$path" == *".."* ]]; then
458
+ log_error "Path traversal detected in: $path"
459
+ return 1
460
+ fi
461
+
462
+ # Check for null bytes (common injection technique)
463
+ # Bash 3.2 can't reliably detect null bytes in variables, but command-line
464
+ # arguments with null bytes are truncated anyway, so this is a best-effort check
465
+ local path_len=${#path}
466
+ local printf_len
467
+ printf_len=$(printf '%s' "$path" | wc -c | tr -d ' ')
468
+ if [[ "$path_len" != "$printf_len" ]]; then
469
+ log_error "Null byte injection detected in path"
470
+ return 1
471
+ fi
472
+
473
+ # Check absolute paths
474
+ if [[ "$path" == /* ]] && [[ "$allow_absolute" != "true" ]]; then
475
+ log_warn "Absolute path not allowed: $path"
476
+ return 1
477
+ fi
478
+
479
+ # Reject paths to sensitive system directories
480
+ local sensitive_paths="/etc /root /var/log /proc /sys /dev"
481
+ for sensitive in $sensitive_paths; do
482
+ if [[ "$path" == "$sensitive"* ]]; then
483
+ log_error "Access to sensitive path denied: $path"
484
+ return 1
485
+ fi
486
+ done
487
+
488
+ return 0
489
+ }
490
+
491
+ # Sanitize a string for safe use in filenames
492
+ # Removes or replaces dangerous characters
493
+ # Usage: sanitize_filename "string"
494
+ sanitize_filename() {
495
+ local input="$1"
496
+ # Remove null bytes, newlines, and other control characters
497
+ # Replace spaces and special chars with underscores
498
+ echo "$input" | tr -d '\0\n\r' | tr -cs '[:alnum:]._-' '_' | head -c 255
499
+ }
500
+
501
+ # Validate consultant name against known valid names
502
+ # Usage: validate_consultant_name "name"
503
+ validate_consultant_name() {
504
+ local name="$1"
505
+ local upper
506
+ upper=$(to_upper "$name")
507
+
508
+ # Check against known agents
509
+ local valid_agents="GEMINI CODEX MISTRAL KILO CURSOR AIDER AMP KIMI CLAUDE QWEN3 GLM GROK DEEPSEEK MINIMAX OLLAMA"
510
+ for agent in $valid_agents; do
511
+ if [[ "$upper" == "$agent" ]]; then
512
+ return 0
513
+ fi
514
+ done
515
+
516
+ # Check if it looks like a custom agent (alphanumeric only)
517
+ if [[ ! "$upper" =~ ^[A-Z0-9_]+$ ]]; then
518
+ log_error "Invalid consultant name: $name"
519
+ return 1
520
+ fi
521
+
522
+ return 0
523
+ }
524
+
525
+ # =============================================================================
526
+ # UTILITY
527
+ # =============================================================================
528
+
529
+ # Builds the complete query from arguments and context file
530
+ # Usage: build_full_query "query" "context_file"
531
+ build_full_query() {
532
+ local query="$1"
533
+ local context_file="$2"
534
+ local full_query=""
535
+
536
+ # Add context if provided
537
+ if [[ -n "$context_file" && -f "$context_file" ]]; then
538
+ full_query=$(cat "$context_file")
539
+ fi
540
+
541
+ # Add query if provided
542
+ if [[ -n "$query" ]]; then
543
+ if [[ -n "$full_query" ]]; then
544
+ full_query="${full_query}
545
+
546
+ # Additional Question
547
+ ${query}"
548
+ else
549
+ full_query="$query"
550
+ fi
551
+ fi
552
+
553
+ echo "$full_query"
554
+ }
555
+
556
+ # Verifies that there is something to send
557
+ validate_query() {
558
+ local full_query="$1"
559
+ local consultant="$2"
560
+
561
+ if [[ -z "$full_query" ]]; then
562
+ log_error "No query to send to $consultant. Specify a query or a context file."
563
+ return 1
564
+ fi
565
+ return 0
566
+ }
567
+
568
+ # =============================================================================
569
+ # BASH 3.2 COMPATIBLE MAP FUNCTIONS
570
+ # =============================================================================
571
+ # These functions emulate associative arrays for compatibility with macOS
572
+ # default bash (3.2). They use dynamic variable names with eval.
573
+ #
574
+ # IMPORTANT: Keys must be alphanumeric (letters, numbers, underscore).
575
+ # Special characters in keys will be stripped.
576
+ #
577
+ # Usage:
578
+ # map_set "MYMAP" "key" "value"
579
+ # value=$(map_get "MYMAP" "key")
580
+ # map_has "MYMAP" "key" && echo "exists"
581
+ # map_keys "MYMAP" # prints space-separated keys
582
+ # map_clear "MYMAP"
583
+
584
+ # Sanitize key for use as variable name (keep only alphanumeric and underscore)
585
+ _map_sanitize_key() {
586
+ echo "$1" | tr -cd '[:alnum:]_'
587
+ }
588
+
589
+ # Validate map name to prevent eval injection
590
+ # Only allows alphanumeric characters and underscores
591
+ _map_validate_name() {
592
+ local name="$1"
593
+ if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
594
+ log_error "Invalid map name: $name (must be alphanumeric with underscores)"
595
+ return 1
596
+ fi
597
+ echo "$name"
598
+ }
599
+
600
+ # Set a value in a map
601
+ # Usage: map_set "MAPNAME" "key" "value"
602
+ map_set() {
603
+ local map_name
604
+ map_name=$(_map_validate_name "$1") || return 1
605
+ local safe_key
606
+ safe_key=$(_map_sanitize_key "$2")
607
+ local value="$3"
608
+
609
+ eval "_MAP_${map_name}_${safe_key}=\"\$value\""
610
+
611
+ # Track keys for iteration (only add if not already present)
612
+ local keys_var="_MAP_${map_name}__KEYS__"
613
+ local current_keys
614
+ eval "current_keys=\"\${$keys_var:-}\""
615
+ if [[ ! " $current_keys " =~ " $safe_key " ]]; then
616
+ if [[ -z "$current_keys" ]]; then
617
+ eval "$keys_var=\"\$safe_key\""
618
+ else
619
+ eval "$keys_var=\"\$current_keys \$safe_key\""
620
+ fi
621
+ fi
622
+ }
623
+
624
+ # Get a value from a map
625
+ # Usage: value=$(map_get "MAPNAME" "key")
626
+ map_get() {
627
+ local map_name
628
+ map_name=$(_map_validate_name "$1") || return 1
629
+ local safe_key
630
+ safe_key=$(_map_sanitize_key "$2")
631
+ eval "echo \"\${_MAP_${map_name}_${safe_key}:-}\""
632
+ }
633
+
634
+ # Check if a key exists in a map (returns 0 if exists, 1 otherwise)
635
+ # Usage: map_has "MAPNAME" "key" && echo "exists"
636
+ map_has() {
637
+ local map_name
638
+ map_name=$(_map_validate_name "$1") || return 1
639
+ local safe_key
640
+ safe_key=$(_map_sanitize_key "$2")
641
+ eval "[ -n \"\${_MAP_${map_name}_${safe_key}+x}\" ]"
642
+ }
643
+
644
+ # Get all keys from a map (space-separated)
645
+ # Usage: for key in $(map_keys "MAPNAME"); do ...; done
646
+ map_keys() {
647
+ local map_name
648
+ map_name=$(_map_validate_name "$1") || return 1
649
+ eval "echo \"\${_MAP_${map_name}__KEYS__:-}\""
650
+ }
651
+
652
+ # Clear all values from a map
653
+ # Usage: map_clear "MAPNAME"
654
+ map_clear() {
655
+ local map_name
656
+ map_name=$(_map_validate_name "$1") || return 1
657
+ local keys_var="_MAP_${map_name}__KEYS__"
658
+ local keys key
659
+ eval "keys=\"\${$keys_var:-}\""
660
+ for key in $keys; do
661
+ eval "unset _MAP_${map_name}_${key}"
662
+ done
663
+ eval "unset $keys_var"
664
+ }
665
+
666
+ # =============================================================================
667
+ # MODULE SOURCING HELPER
668
+ # =============================================================================
669
+
670
+ # Source common.sh from a library module with automatic path detection
671
+ # This is called by other lib/*.sh files to source common.sh reliably
672
+ # Usage: source_common (call from within the module's directory context)
673
+ # Note: This function exists primarily for documentation - modules should
674
+ # use the pattern below directly since this function may not exist yet.
675
+ #
676
+ # Standard pattern for lib modules:
677
+ # SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
678
+ # source "$SCRIPT_DIR/common.sh" 2>/dev/null || source "${SCRIPT_DIR%/*}/lib/common.sh" 2>/dev/null || true
679
+
680
+ # =============================================================================
681
+ # PANIC MODE DETECTION (v2.2)
682
+ # =============================================================================
683
+
684
+ # Check if panic mode should be triggered based on responses
685
+ # Usage: should_trigger_panic <responses_dir>
686
+ # Returns: 0 if panic mode should trigger, 1 otherwise
687
+ should_trigger_panic() {
688
+ local responses_dir="$1"
689
+ local panic_mode="${ENABLE_PANIC_MODE:-auto}"
690
+
691
+ # Never trigger if disabled
692
+ [[ "$panic_mode" == "never" ]] && return 1
693
+
694
+ # Always trigger if set to always
695
+ [[ "$panic_mode" == "always" ]] && return 0
696
+
697
+ # Auto-detect mode
698
+ local total_confidence=0
699
+ local response_count=0
700
+ local has_uncertainty_keywords=false
701
+
702
+ for f in "$responses_dir"/*.json; do
703
+ if [[ -f "$f" && -s "$f" ]]; then
704
+ # Check confidence score
705
+ local confidence
706
+ confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
707
+ total_confidence=$((total_confidence + confidence))
708
+ ((response_count++))
709
+
710
+ # Check for uncertainty keywords in summary/detailed response
711
+ local keywords="${PANIC_KEYWORDS:-uncertain|maybe|not sure|possibly}"
712
+ local text
713
+ text=$(jq -r '(.response.summary // "") + " " + (.response.detailed // "")' "$f" 2>/dev/null | tr '[:upper:]' '[:lower:]')
714
+
715
+ if echo "$text" | grep -qiE "$keywords"; then
716
+ has_uncertainty_keywords=true
717
+ fi
718
+ fi
719
+ done
720
+
721
+ # Calculate average confidence
722
+ if [[ $response_count -gt 0 ]]; then
723
+ local avg_confidence=$((total_confidence / response_count))
724
+ local threshold="${PANIC_CONFIDENCE_THRESHOLD:-5}"
725
+
726
+ # Trigger if average confidence is below threshold
727
+ if [[ $avg_confidence -lt $threshold ]]; then
728
+ return 0
729
+ fi
730
+ fi
731
+
732
+ # Trigger if uncertainty keywords found
733
+ if [[ "$has_uncertainty_keywords" == "true" ]]; then
734
+ return 0
735
+ fi
736
+
737
+ return 1
738
+ }
739
+
740
+ # Get panic mode diagnosis (for reporting)
741
+ # Usage: get_panic_diagnosis <responses_dir>
742
+ # Output: JSON with diagnosis details
743
+ get_panic_diagnosis() {
744
+ local responses_dir="$1"
745
+
746
+ local total_confidence=0
747
+ local response_count=0
748
+ local uncertainty_found=()
749
+
750
+ for f in "$responses_dir"/*.json; do
751
+ if [[ -f "$f" && -s "$f" ]]; then
752
+ local confidence consultant
753
+ confidence=$(jq -r '.confidence.score // 5' "$f" 2>/dev/null)
754
+ consultant=$(jq -r '.consultant // "unknown"' "$f" 2>/dev/null)
755
+ total_confidence=$((total_confidence + confidence))
756
+ ((response_count++))
757
+
758
+ # Check for keywords
759
+ local keywords="${PANIC_KEYWORDS:-uncertain|maybe|not sure|possibly}"
760
+ local text
761
+ text=$(jq -r '(.response.summary // "") + " " + (.response.detailed // "")' "$f" 2>/dev/null | tr '[:upper:]' '[:lower:]')
762
+
763
+ if echo "$text" | grep -qiE "$keywords"; then
764
+ uncertainty_found+=("$consultant")
765
+ fi
766
+ fi
767
+ done
768
+
769
+ local avg_confidence=5
770
+ if [[ $response_count -gt 0 ]]; then
771
+ avg_confidence=$((total_confidence / response_count))
772
+ fi
773
+
774
+ local threshold="${PANIC_CONFIDENCE_THRESHOLD:-5}"
775
+ local triggers=()
776
+
777
+ [[ $avg_confidence -lt $threshold ]] && triggers+=("low_confidence")
778
+ [[ ${#uncertainty_found[@]} -gt 0 ]] && triggers+=("uncertainty_keywords")
779
+
780
+ jq -n \
781
+ --argjson avg_confidence "$avg_confidence" \
782
+ --argjson threshold "$threshold" \
783
+ --argjson response_count "$response_count" \
784
+ --arg uncertainty_consultants "$(IFS=','; echo "${uncertainty_found[*]:-}")" \
785
+ --arg triggers "$(IFS=','; echo "${triggers[*]:-}")" \
786
+ '{
787
+ average_confidence: $avg_confidence,
788
+ threshold: $threshold,
789
+ responses_analyzed: $response_count,
790
+ consultants_with_uncertainty: ($uncertainty_consultants | split(",") | map(select(. != ""))),
791
+ triggers: ($triggers | split(",") | map(select(. != ""))),
792
+ triggered: (($triggers | length) > 0)
793
+ }'
794
+ }
795
+
796
+ # =============================================================================
797
+ # TOKEN ESTIMATION (v2.1)
798
+ # =============================================================================
799
+
800
+ # Estimate token count from text
801
+ # Uses ~4 characters per token approximation for English text
802
+ # Usage: estimate_tokens "text" or echo "text" | estimate_tokens
803
+ estimate_tokens() {
804
+ local text="${1:-}"
805
+ if [[ -z "$text" ]]; then
806
+ # Read from stdin if no argument
807
+ text=$(cat)
808
+ fi
809
+ local chars
810
+ chars=$(echo -n "$text" | wc -c | tr -d ' ')
811
+ echo $((chars / 4))
812
+ }
813
+
814
+ # Estimate tokens from a file
815
+ # Usage: estimate_tokens_file "/path/to/file"
816
+ estimate_tokens_file() {
817
+ local file_path="$1"
818
+ if [[ -f "$file_path" ]]; then
819
+ local chars
820
+ chars=$(wc -c < "$file_path" | tr -d ' ')
821
+ echo $((chars / 4))
822
+ else
823
+ echo 0
824
+ fi
825
+ }
826
+
827
+ # Log token estimate with label
828
+ # Usage: log_token_estimate "Prompt" "$prompt_text"
829
+ log_token_estimate() {
830
+ local label="$1"
831
+ local text="$2"
832
+ local tokens
833
+ tokens=$(estimate_tokens "$text")
834
+ log_debug "$label: ~$tokens tokens (~$((tokens * 4)) chars)"
835
+ }
836
+
837
+ # =============================================================================
838
+ # RESPONSE BUILDERS (v2.4)
839
+ # =============================================================================
840
+ # Shared helpers for building standardized JSON responses in query scripts
841
+
842
+ # Build metadata JSON for responses
843
+ # Usage: build_response_metadata <latency_ms> <model> [error_msg]
844
+ build_response_metadata() {
845
+ local latency="$1"
846
+ local model="$2"
847
+ local error="${3:-}"
848
+
849
+ jq -n \
850
+ --argjson latency "$latency" \
851
+ --arg model "$model" \
852
+ --arg timestamp "$(date -Iseconds)" \
853
+ --arg error "$error" \
854
+ '{tokens_used: 0, latency_ms: $latency, model_version: $model, timestamp: $timestamp} + (if $error != "" then {error: $error} else {} end)'
855
+ }
856
+
857
+ # Build a complete structured response JSON
858
+ # Usage: build_structured_response <consultant> <model> <persona> <inner_json> <latency_ms>
859
+ build_structured_response() {
860
+ local consultant="$1"
861
+ local model="$2"
862
+ local persona="$3"
863
+ local inner_json="$4"
864
+ local latency="$5"
865
+
866
+ jq -n \
867
+ --arg consultant "$consultant" \
868
+ --arg model "$model" \
869
+ --arg persona "$persona" \
870
+ --argjson inner "$inner_json" \
871
+ --argjson metadata "$(build_response_metadata "$latency" "$model")" \
872
+ '{consultant: $consultant, model: $model, persona: $persona, response: $inner.response, confidence: $inner.confidence, metadata: $metadata}'
873
+ }
874
+
875
+ # Build a fallback response from unstructured text
876
+ # Usage: build_fallback_response <consultant> <model> <persona> <response_text> <latency_ms>
877
+ build_fallback_response() {
878
+ local consultant="$1"
879
+ local model="$2"
880
+ local persona="$3"
881
+ local response_text="$4"
882
+ local latency="$5"
883
+
884
+ jq -n \
885
+ --arg consultant "$consultant" \
886
+ --arg model "$model" \
887
+ --arg persona "$persona" \
888
+ --arg response "$response_text" \
889
+ --argjson metadata "$(build_response_metadata "$latency" "$model")" \
890
+ '{consultant: $consultant, model: $model, persona: $persona,
891
+ response: {summary: "Unstructured response - see detailed", detailed: $response, approach: "unknown", pros: [], cons: [], caveats: ["Unstructured output from consultant"]},
892
+ confidence: {score: 5, reasoning: "Confidence not provided by consultant", uncertainty_factors: ["Non-standard response format"]},
893
+ metadata: $metadata}'
894
+ }
895
+
896
+ # Build an error response
897
+ # Usage: build_error_response <consultant> <model> <persona> <error_msg> <latency_ms>
898
+ build_error_response() {
899
+ local consultant="$1"
900
+ local model="$2"
901
+ local persona="$3"
902
+ local error_msg="$4"
903
+ local latency="$5"
904
+
905
+ jq -n \
906
+ --arg consultant "$consultant" \
907
+ --arg model "$model" \
908
+ --arg persona "$persona" \
909
+ --arg error "$error_msg" \
910
+ --argjson metadata "$(build_response_metadata "$latency" "$model" "$error_msg")" \
911
+ '{consultant: $consultant, model: $model, persona: $persona,
912
+ response: {summary: "ERROR: Consultation failed", detailed: $error, approach: "error", pros: [], cons: [], caveats: []},
913
+ confidence: {score: 0, reasoning: "Consultation failed", uncertainty_factors: ["Execution error"]},
914
+ metadata: $metadata}'
915
+ }
916
+
917
+ # Process consultant response and write to output file
918
+ # This encapsulates the common post-processing pattern found in all query scripts
919
+ # Usage: process_consultant_response <consultant> <model> <persona> <temp_output> <output_file> <exit_code> <latency_ms> [native_json_field]
920
+ # Parameters:
921
+ # consultant - Consultant name (e.g., "Gemini")
922
+ # model - Model used (e.g., "gemini-3.1-pro-preview")
923
+ # persona - Persona name (e.g., "The Architect")
924
+ # temp_output - Path to temporary output file from CLI/API
925
+ # output_file - Path to final output file
926
+ # exit_code - Exit code from CLI/API call
927
+ # latency_ms - Latency in milliseconds
928
+ # native_json_field - Optional field name to extract from native JSON (e.g., "response" for Gemini)
929
+ # Returns: The same exit code passed in
930
+ process_consultant_response() {
931
+ local consultant="$1"
932
+ local model="$2"
933
+ local persona="$3"
934
+ local temp_output="$4"
935
+ local output_file="$5"
936
+ local exit_code="$6"
937
+ local latency_ms="$7"
938
+ local native_json_field="${8:-}"
939
+
940
+ if [[ $exit_code -eq 0 && -f "$temp_output" && -s "$temp_output" ]]; then
941
+ local raw_response inner_response
942
+ raw_response=$(cat "$temp_output")
943
+
944
+ # Try to extract from native JSON format if field specified
945
+ if [[ -n "$native_json_field" ]] && echo "$raw_response" | jq -e ".$native_json_field" > /dev/null 2>&1; then
946
+ inner_response=$(echo "$raw_response" | jq -r ".$native_json_field")
947
+ else
948
+ inner_response="$raw_response"
949
+ fi
950
+
951
+ rm -f "$temp_output"
952
+
953
+ # Use shared helpers for response building
954
+ if echo "$inner_response" | jq -e '.response.summary' > /dev/null 2>&1; then
955
+ build_structured_response "$consultant" "$model" "$persona" "$inner_response" "$latency_ms" > "$output_file"
956
+ else
957
+ build_fallback_response "$consultant" "$model" "$persona" "$inner_response" "$latency_ms" > "$output_file"
958
+ fi
959
+ else
960
+ rm -f "$temp_output"
961
+ build_error_response "$consultant" "$model" "$persona" "Query failed with exit code $exit_code" "$latency_ms" > "$output_file"
962
+ fi
963
+
964
+ return $exit_code
965
+ }
966
+
967
+ # Check if a consultant is enabled
968
+ # Usage: is_consultant_enabled <consultant_name>
969
+ # Returns: 0 if enabled, 1 if disabled
970
+ is_consultant_enabled() {
971
+ local name="$1"
972
+ local name_upper
973
+ name_upper=$(to_upper "$name")
974
+ local var_name="ENABLE_${name_upper}"
975
+
976
+ # Get default based on consultant type
977
+ local default="true"
978
+ case "$name_upper" in
979
+ AIDER|AMP|KIMI|CLAUDE|QWEN3|GLM|GROK|DEEPSEEK|MINIMAX|OLLAMA)
980
+ default="false"
981
+ ;;
982
+ esac
983
+
984
+ [[ "${!var_name:-$default}" == "true" ]]
985
+ }
986
+
987
+ # Get list of enabled consultants
988
+ # Usage: get_enabled_consultants
989
+ # Output: Space-separated list of enabled consultant names
990
+ get_enabled_consultants() {
991
+ local enabled=()
992
+ for consultant in "${ALL_CONSULTANTS[@]}"; do
993
+ if is_consultant_enabled "$consultant"; then
994
+ enabled+=("$consultant")
995
+ fi
996
+ done
997
+ echo "${enabled[@]}"
998
+ }
999
+
1000
+ # =============================================================================
1001
+ # SYNTHESIS CLI RESOLUTION (v2.10.1)
1002
+ # =============================================================================
1003
+
1004
+ # Map a canonical consultant name (from get_self_consultant_name) to its CLI command name.
1005
+ # Usage: cli=$(_consultant_to_cli "CLAUDE")
1006
+ _consultant_to_cli() {
1007
+ case "$1" in
1008
+ CLAUDE) echo "claude" ;;
1009
+ GEMINI) echo "gemini" ;;
1010
+ CODEX) echo "codex" ;;
1011
+ MISTRAL) echo "mistral" ;;
1012
+ KILO) echo "kilo" ;;
1013
+ AMP) echo "amp" ;;
1014
+ KIMI) echo "kimi" ;;
1015
+ QWEN3) echo "qwen" ;;
1016
+ CURSOR) echo "cursor" ;;
1017
+ AIDER) echo "aider" ;;
1018
+ *) echo "" ;;
1019
+ esac
1020
+ }
1021
+
1022
+ # Resolve which CLI to use for synthesis.
1023
+ # Avoids using the invoking agent's CLI (self-consultation prevention).
1024
+ # Walks a fallback chain: configured SYNTHESIS_CMD → gemini → codex → claude → ollama
1025
+ #
1026
+ # Usage: SYNTH_CLI=$(resolve_synthesis_cli)
1027
+ # Returns: CLI type name (gemini, codex, claude, ollama) or empty string on failure
1028
+ resolve_synthesis_cli() {
1029
+ # Reuse get_self_consultant_name for complete alias coverage
1030
+ local avoid_cmd
1031
+ avoid_cmd=$(_consultant_to_cli "$(get_self_consultant_name)")
1032
+
1033
+ # 1. Check if SYNTHESIS_CMD is configured and is not the invoking agent
1034
+ local configured="${SYNTHESIS_CMD:-}"
1035
+ if [[ -n "$configured" && "$configured" != "$avoid_cmd" ]]; then
1036
+ if command -v "$configured" &>/dev/null; then
1037
+ echo "$configured"
1038
+ return 0
1039
+ fi
1040
+ fi
1041
+
1042
+ # 2. Walk fallback chain: pick first available CLI that isn't the invoking agent
1043
+ local candidate cmd
1044
+ for candidate in gemini codex claude ollama; do
1045
+ [[ "$candidate" == "$avoid_cmd" ]] && continue
1046
+ case "$candidate" in
1047
+ gemini) cmd="${GEMINI_CMD:-gemini}" ;;
1048
+ codex) cmd="${CODEX_CMD:-codex}" ;;
1049
+ claude) cmd="${CLAUDE_CMD:-claude}" ;;
1050
+ ollama) cmd="ollama" ;;
1051
+ esac
1052
+ if command -v "$cmd" &>/dev/null; then
1053
+ echo "$candidate"
1054
+ return 0
1055
+ fi
1056
+ done
1057
+
1058
+ echo ""
1059
+ return 1
1060
+ }
1061
+
1062
+ # Build synthesis command arguments for a resolved CLI type.
1063
+ # Sets the global SYNTHESIS_ARGS array directly (no eval needed).
1064
+ # Usage: build_synthesis_args <cli_type>
1065
+ build_synthesis_args() {
1066
+ local cli_type="$1"
1067
+
1068
+ case "$cli_type" in
1069
+ claude)
1070
+ SYNTHESIS_ARGS=("${CLAUDE_CMD:-claude}" "--print")
1071
+ local model="${SYNTHESIS_MODEL:-${CLAUDE_MODEL:-}}"
1072
+ [[ -n "$model" ]] && SYNTHESIS_ARGS+=("--model" "$model")
1073
+ ;;
1074
+ gemini)
1075
+ SYNTHESIS_ARGS=("${GEMINI_CMD:-gemini}")
1076
+ ;;
1077
+ codex)
1078
+ SYNTHESIS_ARGS=("${CODEX_CMD:-codex}" "--quiet" "--full-auto")
1079
+ ;;
1080
+ ollama)
1081
+ SYNTHESIS_ARGS=("ollama" "run" "${OLLAMA_MODEL:-llama3.2}")
1082
+ ;;
1083
+ *)
1084
+ log_warn "Unknown synthesis CLI type: $cli_type"
1085
+ return 1
1086
+ ;;
1087
+ esac
1088
+ }