aidp 0.10.0 → 0.12.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +194 -25
  3. data/lib/aidp/analyze/kb_inspector.rb +2 -15
  4. data/lib/aidp/analyze/progress.rb +2 -1
  5. data/lib/aidp/analyze/ruby_maat_integration.rb +2 -15
  6. data/lib/aidp/analyze/runner.rb +64 -20
  7. data/lib/aidp/analyze/steps.rb +10 -8
  8. data/lib/aidp/analyze/tree_sitter_grammar_loader.rb +2 -13
  9. data/lib/aidp/analyze/tree_sitter_scan.rb +2 -13
  10. data/lib/aidp/cli/checkpoint_command.rb +98 -0
  11. data/lib/aidp/cli/first_run_wizard.rb +65 -94
  12. data/lib/aidp/cli/jobs_command.rb +249 -34
  13. data/lib/aidp/cli/mcp_dashboard.rb +205 -0
  14. data/lib/aidp/cli.rb +517 -43
  15. data/lib/aidp/config.rb +5 -8
  16. data/lib/aidp/debug_logger.rb +4 -4
  17. data/lib/aidp/debug_mixin.rb +11 -4
  18. data/lib/aidp/execute/checkpoint.rb +282 -0
  19. data/lib/aidp/execute/checkpoint_display.rb +221 -0
  20. data/lib/aidp/execute/progress.rb +2 -1
  21. data/lib/aidp/execute/prompt_manager.rb +62 -0
  22. data/lib/aidp/execute/runner.rb +53 -24
  23. data/lib/aidp/execute/steps.rb +36 -27
  24. data/lib/aidp/execute/work_loop_runner.rb +308 -0
  25. data/lib/aidp/execute/workflow_selector.rb +26 -17
  26. data/lib/aidp/harness/condition_detector.rb +4 -4
  27. data/lib/aidp/harness/config_schema.rb +40 -0
  28. data/lib/aidp/harness/config_validator.rb +3 -6
  29. data/lib/aidp/harness/configuration.rb +35 -1
  30. data/lib/aidp/harness/enhanced_runner.rb +22 -1
  31. data/lib/aidp/harness/error_handler.rb +103 -28
  32. data/lib/aidp/harness/provider_factory.rb +4 -1
  33. data/lib/aidp/harness/provider_info.rb +366 -0
  34. data/lib/aidp/harness/provider_manager.rb +250 -15
  35. data/lib/aidp/harness/runner.rb +3 -14
  36. data/lib/aidp/harness/simple_user_interface.rb +2 -15
  37. data/lib/aidp/harness/status_display.rb +12 -17
  38. data/lib/aidp/harness/test_runner.rb +83 -0
  39. data/lib/aidp/harness/ui/enhanced_tui.rb +2 -0
  40. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +44 -5
  41. data/lib/aidp/harness/ui/error_handler.rb +4 -0
  42. data/lib/aidp/harness/ui/frame_manager.rb +10 -8
  43. data/lib/aidp/harness/ui/job_monitor.rb +2 -0
  44. data/lib/aidp/harness/ui/navigation/main_menu.rb +4 -2
  45. data/lib/aidp/harness/ui/navigation/menu_item.rb +1 -0
  46. data/lib/aidp/harness/ui/navigation/menu_state.rb +1 -0
  47. data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
  48. data/lib/aidp/harness/ui/navigation/workflow_selector.rb +2 -0
  49. data/lib/aidp/harness/ui/progress_display.rb +8 -12
  50. data/lib/aidp/harness/ui/question_collector.rb +2 -0
  51. data/lib/aidp/harness/ui/spinner_group.rb +2 -0
  52. data/lib/aidp/harness/ui/spinner_helper.rb +1 -1
  53. data/lib/aidp/harness/ui/status_manager.rb +4 -2
  54. data/lib/aidp/harness/ui/status_widget.rb +3 -1
  55. data/lib/aidp/harness/ui/workflow_controller.rb +2 -0
  56. data/lib/aidp/harness/user_interface.rb +12 -17
  57. data/lib/aidp/jobs/background_runner.rb +278 -0
  58. data/lib/aidp/message_display.rb +48 -0
  59. data/lib/aidp/provider_manager.rb +3 -1
  60. data/lib/aidp/providers/anthropic.rb +100 -17
  61. data/lib/aidp/providers/base.rb +42 -11
  62. data/lib/aidp/providers/codex.rb +248 -0
  63. data/lib/aidp/providers/cursor.rb +35 -42
  64. data/lib/aidp/providers/gemini.rb +25 -15
  65. data/lib/aidp/providers/github_copilot.rb +41 -42
  66. data/lib/aidp/providers/opencode.rb +34 -41
  67. data/lib/aidp/version.rb +1 -1
  68. data/lib/aidp/workflows/definitions.rb +357 -0
  69. data/lib/aidp/workflows/guided_agent.rb +400 -0
  70. data/lib/aidp/workflows/selector.rb +171 -0
  71. data/lib/aidp.rb +12 -0
  72. data/templates/planning/generate_llm_style_guide.md +119 -0
  73. metadata +41 -26
  74. /data/templates/{ANALYZE/02_ARCHITECTURE_ANALYSIS.md → analysis/analyze_architecture.md} +0 -0
  75. /data/templates/{ANALYZE/05_DOCUMENTATION_ANALYSIS.md → analysis/analyze_documentation.md} +0 -0
  76. /data/templates/{ANALYZE/04_FUNCTIONALITY_ANALYSIS.md → analysis/analyze_functionality.md} +0 -0
  77. /data/templates/{ANALYZE/01_REPOSITORY_ANALYSIS.md → analysis/analyze_repository.md} +0 -0
  78. /data/templates/{ANALYZE/06_STATIC_ANALYSIS.md → analysis/analyze_static_code.md} +0 -0
  79. /data/templates/{ANALYZE/03_TEST_ANALYSIS.md → analysis/analyze_tests.md} +0 -0
  80. /data/templates/{ANALYZE/07_REFACTORING_RECOMMENDATIONS.md → analysis/recommend_refactoring.md} +0 -0
  81. /data/templates/{ANALYZE/06a_tree_sitter_scan.md → analysis/scan_with_tree_sitter.md} +0 -0
  82. /data/templates/{EXECUTE/11_STATIC_ANALYSIS.md → implementation/configure_static_analysis.md} +0 -0
  83. /data/templates/{EXECUTE/14_DOCS_PORTAL.md → implementation/create_documentation_portal.md} +0 -0
  84. /data/templates/{EXECUTE/10_IMPLEMENTATION_AGENT.md → implementation/implement_features.md} +0 -0
  85. /data/templates/{EXECUTE/13_DELIVERY_ROLLOUT.md → implementation/plan_delivery.md} +0 -0
  86. /data/templates/{EXECUTE/15_POST_RELEASE.md → implementation/review_post_release.md} +0 -0
  87. /data/templates/{EXECUTE/09_SCAFFOLDING_DEVEX.md → implementation/setup_scaffolding.md} +0 -0
  88. /data/templates/{EXECUTE/02A_ARCH_GATE_QUESTIONS.md → planning/ask_architecture_questions.md} +0 -0
  89. /data/templates/{EXECUTE/00_PRD.md → planning/create_prd.md} +0 -0
  90. /data/templates/{EXECUTE/08_TASKS.md → planning/create_tasks.md} +0 -0
  91. /data/templates/{EXECUTE/04_DOMAIN_DECOMPOSITION.md → planning/decompose_domain.md} +0 -0
  92. /data/templates/{EXECUTE/01_NFRS.md → planning/define_nfrs.md} +0 -0
  93. /data/templates/{EXECUTE/05_CONTRACTS.md → planning/design_apis.md} +0 -0
  94. /data/templates/{EXECUTE/02_ARCHITECTURE.md → planning/design_architecture.md} +0 -0
  95. /data/templates/{EXECUTE/06_THREAT_MODEL.md → planning/design_data_model.md} +0 -0
  96. /data/templates/{EXECUTE/03_ADR_FACTORY.md → planning/generate_adrs.md} +0 -0
  97. /data/templates/{EXECUTE/12_OBSERVABILITY_SLOS.md → planning/plan_observability.md} +0 -0
  98. /data/templates/{EXECUTE/07_TEST_PLAN.md → planning/plan_testing.md} +0 -0
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "fileutils"
6
+
7
+ module Aidp
8
+ module Harness
9
+ # Stores detailed information about AI providers gathered from their CLI tools
10
+ class ProviderInfo
11
+ attr_reader :provider_name, :info_file_path
12
+
13
+ def initialize(provider_name, root_dir = nil)
14
+ @provider_name = provider_name
15
+ @root_dir = root_dir || Dir.pwd
16
+ @info_file_path = File.join(@root_dir, ".aidp", "providers", "#{provider_name}_info.yml")
17
+ ensure_directory_exists
18
+ end
19
+
20
+ # Gather information about the provider by introspecting its CLI
21
+ def gather_info
22
+ info = {
23
+ provider: @provider_name,
24
+ last_checked: Time.now.iso8601,
25
+ cli_available: false,
26
+ help_output: nil,
27
+ capabilities: {},
28
+ permission_modes: [],
29
+ mcp_support: false,
30
+ mcp_servers: [],
31
+ auth_method: nil,
32
+ flags: {}
33
+ }
34
+
35
+ # Try to get help output from the provider CLI
36
+ help_output = fetch_help_output
37
+ if help_output
38
+ info[:cli_available] = true
39
+ info[:help_output] = help_output
40
+ info.merge!(parse_help_output(help_output))
41
+ end
42
+
43
+ # Try to get MCP server list if supported
44
+ if info[:mcp_support]
45
+ mcp_servers = fetch_mcp_servers
46
+ info[:mcp_servers] = mcp_servers if mcp_servers
47
+ end
48
+
49
+ save_info(info)
50
+ info
51
+ end
52
+
53
+ # Load stored provider info
54
+ def load_info
55
+ return nil unless File.exist?(@info_file_path)
56
+
57
+ YAML.safe_load_file(@info_file_path, permitted_classes: [Time, Symbol])
58
+ rescue => e
59
+ warn "Failed to load provider info for #{@provider_name}: #{e.message}"
60
+ nil
61
+ end
62
+
63
+ # Get provider info, refreshing if needed
64
+ def get_info(force_refresh: false, max_age: 86400)
65
+ existing_info = load_info
66
+
67
+ # Refresh if forced, missing, or stale
68
+ if force_refresh || existing_info.nil? || info_stale?(existing_info, max_age)
69
+ gather_info
70
+ else
71
+ existing_info
72
+ end
73
+ end
74
+
75
+ # Check if provider supports MCP servers
76
+ def supports_mcp?
77
+ info = load_info
78
+ return false unless info
79
+
80
+ info[:mcp_support] == true
81
+ end
82
+
83
+ # Get permission modes available
84
+ def permission_modes
85
+ info = load_info
86
+ return [] unless info
87
+
88
+ info[:permission_modes] || []
89
+ end
90
+
91
+ # Get authentication method
92
+ def auth_method
93
+ info = load_info
94
+ return nil unless info
95
+
96
+ info[:auth_method]
97
+ end
98
+
99
+ # Get available flags/options
100
+ def available_flags
101
+ info = load_info
102
+ return {} unless info
103
+
104
+ info[:flags] || {}
105
+ end
106
+
107
+ # Get configured MCP servers
108
+ def mcp_servers
109
+ info = load_info
110
+ return [] unless info
111
+
112
+ info[:mcp_servers] || []
113
+ end
114
+
115
+ # Check if provider has MCP servers configured
116
+ def has_mcp_servers?
117
+ mcp_servers.any?
118
+ end
119
+
120
+ private
121
+
122
+ def ensure_directory_exists
123
+ dir = File.dirname(@info_file_path)
124
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
125
+ end
126
+
127
+ def save_info(info)
128
+ File.write(@info_file_path, YAML.dump(info))
129
+ end
130
+
131
+ def info_stale?(info, max_age)
132
+ return true unless info[:last_checked]
133
+
134
+ last_checked = Time.parse(info[:last_checked].to_s)
135
+ (Time.now - last_checked) > max_age
136
+ rescue
137
+ true
138
+ end
139
+
140
+ def fetch_help_output
141
+ execute_provider_command("--help")
142
+ end
143
+
144
+ def fetch_mcp_servers
145
+ binary = get_binary_name
146
+ return nil unless binary
147
+
148
+ # Try different MCP list commands based on provider
149
+ mcp_output = case @provider_name
150
+ when "claude", "anthropic"
151
+ execute_provider_command("mcp", "list")
152
+ end
153
+
154
+ return nil unless mcp_output
155
+
156
+ parse_mcp_servers(mcp_output)
157
+ end
158
+
159
+ def execute_provider_command(*args)
160
+ binary = get_binary_name
161
+ return nil unless binary
162
+
163
+ # Try to find the binary
164
+ path = begin
165
+ Aidp::Util.which(binary)
166
+ rescue
167
+ nil
168
+ end
169
+ return nil unless path
170
+
171
+ # Execute command with timeout
172
+ begin
173
+ r, w = IO.pipe
174
+ pid = Process.spawn(binary, *args, out: w, err: w)
175
+ w.close
176
+
177
+ # Wait with timeout
178
+ deadline = Time.now + 5
179
+ status = nil
180
+ while Time.now < deadline
181
+ pid_done, status = Process.waitpid2(pid, Process::WNOHANG)
182
+ break if pid_done
183
+ sleep 0.05
184
+ end
185
+
186
+ # Kill if timed out
187
+ unless status
188
+ begin
189
+ Process.kill("TERM", pid)
190
+ sleep 0.1
191
+ Process.kill("KILL", pid)
192
+ rescue
193
+ nil
194
+ end
195
+ return nil
196
+ end
197
+
198
+ output = r.read
199
+ r.close
200
+ output
201
+ rescue
202
+ nil
203
+ end
204
+ end
205
+
206
+ def parse_mcp_servers(output)
207
+ servers = []
208
+ return servers unless output
209
+
210
+ # Parse MCP server list output
211
+ # Claude format (as of 2025):
212
+ # dash-api: uvx --from git+https://... - ✓ Connected
213
+ # or
214
+ # server-name: command - ✗ Error message
215
+ #
216
+ # Legacy format:
217
+ # Name Status Description
218
+ # filesystem enabled File system access
219
+
220
+ lines = output.lines
221
+
222
+ # Skip header lines
223
+ lines.reject! { |line| /checking mcp server health/i.match?(line) }
224
+
225
+ lines.each do |line|
226
+ line = line.strip
227
+ next if line.empty?
228
+
229
+ # Try to parse new Claude format: "name: command - ✓ Connected"
230
+ if line =~ /^([^:]+):\s*(.+?)\s*-\s*(✓|✗)\s*(.+)$/
231
+ name = Regexp.last_match(1).strip
232
+ command = Regexp.last_match(2).strip
233
+ status_symbol = Regexp.last_match(3)
234
+ status_text = Regexp.last_match(4).strip
235
+
236
+ servers << {
237
+ name: name,
238
+ status: (status_symbol == "✓") ? "connected" : "error",
239
+ description: command,
240
+ enabled: status_symbol == "✓",
241
+ error: (status_symbol == "✗") ? status_text : nil
242
+ }
243
+ next
244
+ end
245
+
246
+ # Try to parse legacy table format
247
+ # Skip header line
248
+ next if /Name.*Status/i.match?(line)
249
+ next if /^[-=]+$/.match?(line) # Skip separator lines
250
+
251
+ # Parse table format: columns separated by multiple spaces
252
+ parts = line.split(/\s{2,}/)
253
+ next if parts.size < 2
254
+
255
+ name = parts[0]&.strip
256
+ status = parts[1]&.strip
257
+ description = parts[2..]&.join(" ")&.strip
258
+
259
+ next unless name && !name.empty?
260
+
261
+ servers << {
262
+ name: name,
263
+ status: status || "unknown",
264
+ description: description,
265
+ enabled: status&.downcase == "enabled" || status&.downcase == "connected"
266
+ }
267
+ end
268
+
269
+ servers
270
+ end
271
+
272
+ def get_binary_name
273
+ case @provider_name
274
+ when "claude", "anthropic"
275
+ "claude"
276
+ when "cursor"
277
+ "cursor"
278
+ when "gemini"
279
+ "gemini"
280
+ when "codex"
281
+ "codex"
282
+ when "github_copilot"
283
+ "gh"
284
+ when "opencode"
285
+ "opencode"
286
+ else
287
+ @provider_name
288
+ end
289
+ end
290
+
291
+ def parse_help_output(help_text)
292
+ parsed = {
293
+ capabilities: {},
294
+ permission_modes: [],
295
+ mcp_support: false,
296
+ auth_method: nil,
297
+ flags: {}
298
+ }
299
+
300
+ # Check for MCP support
301
+ parsed[:mcp_support] = !!(help_text =~ /mcp|MCP|Model Context Protocol/i)
302
+
303
+ # Extract permission modes
304
+ if help_text =~ /--permission-mode\s+<mode>\s+.*?\(choices:\s*([^)]+)\)/m
305
+ modes = Regexp.last_match(1).split(",").map(&:strip).map { |m| m.gsub(/["']/, "") }
306
+ parsed[:permission_modes] = modes
307
+ end
308
+
309
+ # Check for dangerous skip permissions
310
+ parsed[:capabilities][:bypass_permissions] = !!(help_text =~ /--dangerously-skip-permissions/)
311
+
312
+ # Check for API key / subscription patterns
313
+ if /--api-key|API_KEY|setup-token|subscription/i.match?(help_text)
314
+ parsed[:auth_method] = if /setup-token|subscription/i.match?(help_text)
315
+ "subscription"
316
+ else
317
+ "api_key"
318
+ end
319
+ end
320
+
321
+ # Extract model configuration
322
+ parsed[:capabilities][:model_selection] = !!(help_text =~ /--model\s+<model>/)
323
+
324
+ # Extract MCP configuration
325
+ parsed[:capabilities][:mcp_config] = !!(help_text =~ /--mcp-config/)
326
+
327
+ # Extract allowed/disallowed tools
328
+ parsed[:capabilities][:tool_restrictions] = !!(help_text =~ /--allowed-tools|--disallowed-tools/)
329
+
330
+ # Extract session management
331
+ parsed[:capabilities][:session_management] = !!(help_text =~ /--continue|--resume|--fork-session/)
332
+
333
+ # Extract output formats
334
+ if help_text =~ /--output-format\s+.*?\(choices:\s*([^)]+)\)/m
335
+ formats = Regexp.last_match(1).split(",").map(&:strip).map { |f| f.gsub(/["']/, "") }
336
+ parsed[:capabilities][:output_formats] = formats
337
+ end
338
+
339
+ # Extract notable flags
340
+ extract_flags(help_text, parsed[:flags])
341
+
342
+ parsed
343
+ end
344
+
345
+ def extract_flags(help_text, flags_hash)
346
+ # Extract all flags with their descriptions
347
+ help_text.scan(/^\s+(--[\w-]+(?:\s+<\w+>)?)\s+(.+?)(?=^\s+(?:--|\w|$))/m).each do |flag, desc|
348
+ flag_name = flag.split.first.gsub(/^--/, "")
349
+ flags_hash[flag_name] = {
350
+ flag: flag.strip,
351
+ description: desc.strip.gsub(/\s+/, " ")
352
+ }
353
+ end
354
+
355
+ # Also capture short flags
356
+ help_text.scan(/^\s+(-\w),\s+(--[\w-]+(?:\s+<\w+>)?)\s+(.+?)(?=^\s+(?:--|-\w|$))/m).each do |short, long, desc|
357
+ flag_name = long.split.first.gsub(/^--/, "")
358
+ flags_hash[flag_name] ||= {}
359
+ flags_hash[flag_name][:short] = short
360
+ flags_hash[flag_name][:flag] = long.strip
361
+ flags_hash[flag_name][:description] = desc.strip.gsub(/\s+/, " ")
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
@@ -7,6 +7,8 @@ module Aidp
7
7
  module Harness
8
8
  # Manages provider switching and fallback logic
9
9
  class ProviderManager
10
+ include Aidp::MessageDisplay
11
+
10
12
  def initialize(configuration, prompt: TTY::Prompt.new)
11
13
  @configuration = configuration
12
14
  @prompt = prompt
@@ -31,6 +33,9 @@ module Aidp
31
33
  @model_fallback_chains = {}
32
34
  @model_switching_enabled = true
33
35
  @model_weights = {}
36
+ @unavailable_cache = {}
37
+ @binary_check_cache = {}
38
+ @binary_check_ttl = 300 # seconds
34
39
  initialize_fallback_chains
35
40
  initialize_provider_health
36
41
  initialize_model_configs
@@ -562,9 +567,39 @@ module Aidp
562
567
 
563
568
  # Check if provider is available (not rate limited, healthy, circuit breaker closed)
564
569
  def is_provider_available?(provider_name)
565
- !is_rate_limited?(provider_name) &&
566
- is_provider_healthy?(provider_name) &&
567
- !is_provider_circuit_breaker_open?(provider_name)
570
+ cli_ok, _reason = provider_cli_available?(provider_name)
571
+ return false unless cli_ok
572
+ return false if is_rate_limited?(provider_name)
573
+ return false unless is_provider_healthy?(provider_name)
574
+ return false if is_provider_circuit_breaker_open?(provider_name)
575
+ true
576
+ end
577
+
578
+ # Mark provider unhealthy (auth or generic) and optionally open circuit breaker
579
+ def mark_provider_unhealthy(provider_name, reason: "manual", open_circuit: true)
580
+ return unless @provider_health[provider_name]
581
+ health = @provider_health[provider_name]
582
+ health[:status] = (reason == "auth") ? "unhealthy_auth" : "unhealthy"
583
+ health[:last_updated] = Time.now
584
+ health[:unhealthy_reason] = reason
585
+ if open_circuit
586
+ health[:circuit_breaker_open] = true
587
+ health[:circuit_breaker_opened_at] = Time.now
588
+ log_circuit_breaker_event(provider_name, "opened")
589
+ end
590
+ end
591
+
592
+ def mark_provider_auth_failure(provider_name)
593
+ mark_provider_unhealthy(provider_name, reason: "auth", open_circuit: true)
594
+ end
595
+
596
+ # Mark provider unhealthy specifically due to failure exhaustion (non-auth)
597
+ def mark_provider_failure_exhausted(provider_name)
598
+ return unless @provider_health[provider_name]
599
+ health = @provider_health[provider_name]
600
+ # Don't override more critical states (auth or circuit already open)
601
+ return if health[:unhealthy_reason] == "auth"
602
+ mark_provider_unhealthy(provider_name, reason: "fail_exhausted", open_circuit: true)
568
603
  end
569
604
 
570
605
  # Check if model is rate limited
@@ -950,6 +985,198 @@ module Aidp
950
985
  @provider_metrics.dup
951
986
  end
952
987
 
988
+ # Determine whether a provider CLI/binary appears installed
989
+ def provider_installed?(provider_name)
990
+ return @unavailable_cache[provider_name] unless @unavailable_cache[provider_name].nil?
991
+ installed = true
992
+ begin
993
+ case provider_name
994
+ when "anthropic", "claude"
995
+ # Prefer direct binary probe instead of Anthropic.available? (which uses which internally)
996
+ path = begin
997
+ Aidp::Util.which("claude")
998
+ rescue
999
+ nil
1000
+ end
1001
+ installed = !path.nil?
1002
+ when "cursor"
1003
+ require_relative "../providers/cursor"
1004
+ installed = Aidp::Providers::Cursor.available?
1005
+ end
1006
+ rescue LoadError
1007
+ installed = false
1008
+ end
1009
+ @unavailable_cache[provider_name] = installed
1010
+ end
1011
+
1012
+ # Attempt to run a provider's CLI with --version (or no-op) to verify executable health
1013
+ def provider_cli_available?(provider_name)
1014
+ normalized = normalize_provider_name(provider_name)
1015
+
1016
+ # Handle test environment overrides
1017
+ if defined?(RSpec) || ENV["RSPEC_RUNNING"]
1018
+ # Force claude to be missing for testing
1019
+ if ENV["AIDP_FORCE_CLAUDE_MISSING"] == "1" && normalized == "claude"
1020
+ return [false, "binary_missing"]
1021
+ end
1022
+ # Force claude to be available for testing
1023
+ if ENV["AIDP_FORCE_CLAUDE_AVAILABLE"] == "1" && normalized == "claude"
1024
+ return [true, "available"]
1025
+ end
1026
+ end
1027
+
1028
+ cache_key = "#{provider_name}:#{normalized}"
1029
+ cached = @binary_check_cache[cache_key]
1030
+ if cached && (Time.now - cached[:checked_at] < @binary_check_ttl)
1031
+ return [cached[:ok], cached[:reason]]
1032
+ end
1033
+ # Map normalized provider -> binary
1034
+ binary = case normalized
1035
+ when "claude" then "claude"
1036
+ when "cursor" then "cursor"
1037
+ when "gemini" then "gemini"
1038
+ when "macos" then nil # passthrough; no direct binary expected
1039
+ end
1040
+ unless binary
1041
+ @binary_check_cache[cache_key] = {ok: true, reason: nil, checked_at: Time.now}
1042
+ return [true, nil]
1043
+ end
1044
+ path = begin
1045
+ Aidp::Util.which(binary)
1046
+ rescue
1047
+ nil
1048
+ end
1049
+ unless path
1050
+ @binary_check_cache[cache_key] = {ok: false, reason: "binary_missing", checked_at: Time.now}
1051
+ return [false, "binary_missing"]
1052
+ end
1053
+ # Light command execution to ensure it responds quickly
1054
+ ok = true
1055
+ reason = nil
1056
+ begin
1057
+ # Use IO.popen to avoid shell injection and impose a short timeout
1058
+ r, w = IO.pipe
1059
+ pid = Process.spawn(binary, "--version", out: w, err: w)
1060
+ w.close
1061
+ deadline = Time.now + 3
1062
+ status = nil
1063
+ while Time.now < deadline
1064
+ pid_done, status = Process.waitpid2(pid, Process::WNOHANG)
1065
+ break if pid_done
1066
+ sleep 0.05
1067
+ end
1068
+ unless status
1069
+ # Timeout -> kill
1070
+ begin
1071
+ Process.kill("TERM", pid)
1072
+ rescue
1073
+ nil
1074
+ end
1075
+ sleep 0.1
1076
+ begin
1077
+ Process.kill("KILL", pid)
1078
+ rescue
1079
+ nil
1080
+ end
1081
+ ok = false
1082
+ reason = "binary_timeout"
1083
+ end
1084
+ output = r.read.to_s
1085
+ r.close
1086
+ if ok && output.strip.empty?
1087
+ # Some CLIs require just calling without args; treat empty as still OK
1088
+ ok = true
1089
+ end
1090
+ rescue => e
1091
+ ok = false
1092
+ reason = e.class.name.downcase.include?("enoent") ? "binary_missing" : "binary_error"
1093
+ end
1094
+ @binary_check_cache[cache_key] = {ok: ok, reason: reason, checked_at: Time.now}
1095
+ [ok, reason]
1096
+ end
1097
+
1098
+ # Summarize health and metrics for dashboard/CLI display
1099
+ def health_dashboard
1100
+ now = Time.now
1101
+ statuses = get_provider_health_status
1102
+ metrics = all_metrics
1103
+ configured = @configuration.configured_providers
1104
+ # Ensure fresh binary probe results in test mode so stubs of Aidp::Util.which take effect
1105
+ if defined?(RSpec) || ENV["RSPEC_RUNNING"]
1106
+ @binary_check_cache.clear
1107
+ end
1108
+ rows_by_normalized = {}
1109
+ configured.each do |prov|
1110
+ # Temporarily hide macos provider until it's user-configurable
1111
+ next if prov == "macos"
1112
+ normalized = normalize_provider_name(prov)
1113
+ cli_ok_prefetch, cli_reason_prefetch = provider_cli_available?(prov)
1114
+ h = statuses[prov] || {}
1115
+ m = metrics[prov] || {}
1116
+ rl = @rate_limit_info[prov]
1117
+ reset_in = (rl && rl[:reset_time]) ? [(rl[:reset_time] - now).to_i, 0].max : nil
1118
+ cb_remaining = if h[:circuit_breaker_open] && h[:circuit_breaker_opened_at]
1119
+ elapsed = now - h[:circuit_breaker_opened_at]
1120
+ rem = @circuit_breaker_timeout - elapsed
1121
+ rem.positive? ? rem.to_i : 0
1122
+ end
1123
+ row = {
1124
+ provider: normalized,
1125
+ installed: provider_installed?(prov),
1126
+ status: h[:status] || (provider_installed?(prov) ? "unknown" : "uninstalled"),
1127
+ unhealthy_reason: h[:unhealthy_reason],
1128
+ available: false, # will set true below only if all checks pass
1129
+ circuit_breaker: h[:circuit_breaker_open] ? "open" : "closed",
1130
+ circuit_breaker_remaining: cb_remaining,
1131
+ rate_limited: !!rl,
1132
+ rate_limit_reset_in: reset_in,
1133
+ total_requests: m[:total_requests] || 0,
1134
+ failed_requests: m[:failed_requests] || 0,
1135
+ success_requests: m[:successful_requests] || 0,
1136
+ total_tokens: m[:total_tokens] || 0,
1137
+ last_used: m[:last_used]
1138
+ }
1139
+ # Incorporate CLI check outcome into reason/availability if failing
1140
+ unless cli_ok_prefetch
1141
+ row[:available] = false
1142
+ row[:unhealthy_reason] ||= cli_reason_prefetch
1143
+ row[:status] = "unhealthy" if row[:status] == "healthy" || row[:status] == "healthy_auth"
1144
+ end
1145
+ if cli_ok_prefetch && is_provider_available?(prov)
1146
+ row[:available] = true
1147
+ end
1148
+ if (existing = rows_by_normalized[normalized])
1149
+ # Merge metrics: sum counts/tokens, keep most severe status, earliest unhealthy reason if any
1150
+ existing[:total_requests] += row[:total_requests]
1151
+ existing[:failed_requests] += row[:failed_requests]
1152
+ existing[:success_requests] += row[:success_requests]
1153
+ existing[:total_tokens] += row[:total_tokens]
1154
+ # If either unavailable then mark unavailable
1155
+ existing[:available] &&= row[:available]
1156
+ # Prefer an unhealthy or circuit breaker status over healthy
1157
+ existing[:status] = merge_status_priority(existing[:status], row[:status])
1158
+ existing[:unhealthy_reason] ||= row[:unhealthy_reason]
1159
+ # Circuit breaker open takes precedence
1160
+ if row[:circuit_breaker] == "open"
1161
+ existing[:circuit_breaker] = "open"
1162
+ existing[:circuit_breaker_remaining] = [existing[:circuit_breaker_remaining].to_i, row[:circuit_breaker_remaining].to_i].max
1163
+ end
1164
+ # Rate limited if any underlying
1165
+ if row[:rate_limited]
1166
+ existing[:rate_limited] = true
1167
+ existing[:rate_limit_reset_in] = [existing[:rate_limit_reset_in].to_i, row[:rate_limit_reset_in].to_i].max
1168
+ end
1169
+ # Keep most recent last_used
1170
+ if row[:last_used] && (!existing[:last_used] || row[:last_used] > existing[:last_used])
1171
+ existing[:last_used] = row[:last_used]
1172
+ end
1173
+ else
1174
+ rows_by_normalized[normalized] = row
1175
+ end
1176
+ end
1177
+ rows_by_normalized.values
1178
+ end
1179
+
953
1180
  # Get provider history
954
1181
  def provider_history
955
1182
  @provider_history.dup
@@ -1006,7 +1233,9 @@ module Aidp
1006
1233
  circuit_breaker_open: health[:circuit_breaker_open],
1007
1234
  last_updated: health[:last_updated],
1008
1235
  last_used: health[:last_used],
1009
- last_rate_limited: health[:last_rate_limited]
1236
+ last_rate_limited: health[:last_rate_limited],
1237
+ circuit_breaker_opened_at: health[:circuit_breaker_opened_at],
1238
+ unhealthy_reason: health[:unhealthy_reason]
1010
1239
  }
1011
1240
  end
1012
1241
  end
@@ -1203,17 +1432,23 @@ module Aidp
1203
1432
 
1204
1433
  private
1205
1434
 
1206
- def display_message(message, type: :info)
1207
- color = case type
1208
- when :error then :red
1209
- when :success then :green
1210
- when :warning then :yellow
1211
- when :info then :blue
1212
- when :highlight then :cyan
1213
- when :muted then :bright_black
1214
- else :white
1215
- end
1216
- @prompt.say(message, color: color)
1435
+ # Normalize provider naming for display (hide legacy 'anthropic')
1436
+ def normalize_provider_name(name)
1437
+ return "claude" if name == "anthropic"
1438
+ name
1439
+ end
1440
+
1441
+ # Status priority for merging duplicate normalized providers
1442
+ def merge_status_priority(a, b)
1443
+ order = {
1444
+ "circuit_breaker_open" => 5,
1445
+ "unhealthy_auth" => 4,
1446
+ "unhealthy" => 3,
1447
+ "unknown" => 2,
1448
+ "healthy" => 1,
1449
+ nil => 0
1450
+ }
1451
+ ((order[a] || 0) >= (order[b] || 0)) ? a : b
1217
1452
  end
1218
1453
 
1219
1454
  public
@@ -15,6 +15,8 @@ module Aidp
15
15
  module Harness
16
16
  # Main harness runner that orchestrates the execution loop
17
17
  class Runner
18
+ include Aidp::MessageDisplay
19
+
18
20
  # Harness execution states
19
21
  STATES = {
20
22
  idle: "idle",
@@ -38,7 +40,7 @@ module Aidp
38
40
  @start_time = nil
39
41
  @current_step = nil
40
42
  @current_provider = nil
41
- @user_input = options[:user_input] || {} # Include user input from workflow selection
43
+ @user_input = options[:user_input] || {} # Include user input from workflow selection
42
44
  @execution_log = []
43
45
  @prompt = options[:prompt] || TTY::Prompt.new
44
46
 
@@ -412,19 +414,6 @@ module Aidp
412
414
  end
413
415
 
414
416
  private
415
-
416
- def display_message(message, type: :info)
417
- color = case type
418
- when :error then :red
419
- when :success then :green
420
- when :warning then :yellow
421
- when :info then :blue
422
- when :highlight then :cyan
423
- when :muted then :bright_black
424
- else :white
425
- end
426
- @prompt.say(message, color: color)
427
- end
428
417
  end
429
418
  end
430
419
  end