aidp 0.25.0 → 0.27.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -6
  3. data/lib/aidp/analyze/error_handler.rb +11 -0
  4. data/lib/aidp/cli/checkpoint_command.rb +198 -0
  5. data/lib/aidp/cli/config_command.rb +71 -0
  6. data/lib/aidp/cli/enhanced_input.rb +2 -0
  7. data/lib/aidp/cli/first_run_wizard.rb +8 -7
  8. data/lib/aidp/cli/harness_command.rb +102 -0
  9. data/lib/aidp/cli/jobs_command.rb +3 -3
  10. data/lib/aidp/cli/mcp_dashboard.rb +4 -3
  11. data/lib/aidp/cli/models_command.rb +662 -0
  12. data/lib/aidp/cli/providers_command.rb +223 -0
  13. data/lib/aidp/cli.rb +35 -456
  14. data/lib/aidp/daemon/runner.rb +2 -2
  15. data/lib/aidp/debug_mixin.rb +2 -9
  16. data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
  17. data/lib/aidp/execute/checkpoint_display.rb +38 -37
  18. data/lib/aidp/execute/interactive_repl.rb +2 -1
  19. data/lib/aidp/execute/prompt_manager.rb +4 -4
  20. data/lib/aidp/execute/work_loop_runner.rb +253 -56
  21. data/lib/aidp/execute/workflow_selector.rb +2 -2
  22. data/lib/aidp/harness/config_loader.rb +20 -11
  23. data/lib/aidp/harness/config_manager.rb +5 -5
  24. data/lib/aidp/harness/config_schema.rb +30 -8
  25. data/lib/aidp/harness/configuration.rb +105 -4
  26. data/lib/aidp/harness/enhanced_runner.rb +24 -15
  27. data/lib/aidp/harness/error_handler.rb +26 -5
  28. data/lib/aidp/harness/filter_strategy.rb +45 -0
  29. data/lib/aidp/harness/generic_filter_strategy.rb +63 -0
  30. data/lib/aidp/harness/model_cache.rb +269 -0
  31. data/lib/aidp/harness/model_discovery_service.rb +259 -0
  32. data/lib/aidp/harness/model_registry.rb +201 -0
  33. data/lib/aidp/harness/output_filter.rb +136 -0
  34. data/lib/aidp/harness/provider_manager.rb +18 -3
  35. data/lib/aidp/harness/rspec_filter_strategy.rb +82 -0
  36. data/lib/aidp/harness/runner.rb +5 -0
  37. data/lib/aidp/harness/test_runner.rb +165 -27
  38. data/lib/aidp/harness/thinking_depth_manager.rb +223 -7
  39. data/lib/aidp/harness/ui/enhanced_tui.rb +4 -1
  40. data/lib/aidp/logger.rb +35 -5
  41. data/lib/aidp/providers/adapter.rb +2 -4
  42. data/lib/aidp/providers/anthropic.rb +141 -128
  43. data/lib/aidp/providers/base.rb +98 -2
  44. data/lib/aidp/providers/capability_registry.rb +0 -1
  45. data/lib/aidp/providers/codex.rb +49 -67
  46. data/lib/aidp/providers/cursor.rb +71 -59
  47. data/lib/aidp/providers/gemini.rb +44 -60
  48. data/lib/aidp/providers/github_copilot.rb +2 -66
  49. data/lib/aidp/providers/kilocode.rb +24 -80
  50. data/lib/aidp/providers/opencode.rb +24 -80
  51. data/lib/aidp/safe_directory.rb +10 -3
  52. data/lib/aidp/setup/wizard.rb +345 -8
  53. data/lib/aidp/storage/csv_storage.rb +9 -3
  54. data/lib/aidp/storage/file_manager.rb +8 -2
  55. data/lib/aidp/storage/json_storage.rb +9 -3
  56. data/lib/aidp/version.rb +1 -1
  57. data/lib/aidp/watch/build_processor.rb +40 -1
  58. data/lib/aidp/watch/change_request_processor.rb +659 -0
  59. data/lib/aidp/watch/plan_generator.rb +93 -14
  60. data/lib/aidp/watch/plan_processor.rb +71 -8
  61. data/lib/aidp/watch/repository_client.rb +85 -20
  62. data/lib/aidp/watch/review_processor.rb +3 -3
  63. data/lib/aidp/watch/runner.rb +37 -0
  64. data/lib/aidp/watch/state_store.rb +46 -1
  65. data/lib/aidp/workflows/guided_agent.rb +3 -3
  66. data/lib/aidp/workstream_executor.rb +5 -2
  67. data/lib/aidp.rb +4 -0
  68. data/templates/aidp-development.yml.example +2 -2
  69. data/templates/aidp-production.yml.example +3 -3
  70. data/templates/aidp.yml.example +53 -0
  71. metadata +14 -1
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filter_strategy"
4
+
5
+ module Aidp
6
+ module Harness
7
+ # RSpec-specific output filtering
8
+ class RSpecFilterStrategy < FilterStrategy
9
+ def filter(output, filter_instance)
10
+ case filter_instance.mode
11
+ when :failures_only
12
+ extract_failures_only(output, filter_instance)
13
+ when :minimal
14
+ extract_minimal(output, filter_instance)
15
+ else
16
+ output
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def extract_failures_only(output, filter_instance)
23
+ lines = output.lines
24
+ parts = []
25
+
26
+ # Extract summary line
27
+ if (summary = lines.find { |l| l.match?(/^\d+ examples?, \d+ failures?/) })
28
+ parts << "RSpec Summary:"
29
+ parts << summary
30
+ parts << ""
31
+ end
32
+
33
+ # Extract failed examples
34
+ in_failure = false
35
+ failure_lines = []
36
+
37
+ lines.each_with_index do |line, index|
38
+ # Start of failure section
39
+ if line.match?(/^Failures:/)
40
+ in_failure = true
41
+ failure_lines << line
42
+ next
43
+ end
44
+
45
+ # End of failure section (start of pending/seed info)
46
+ if in_failure && (line.match?(/^Finished in/) || line.match?(/^Pending:/))
47
+ in_failure = false
48
+ break
49
+ end
50
+
51
+ failure_lines << line if in_failure
52
+ end
53
+
54
+ if failure_lines.any?
55
+ parts << failure_lines.join
56
+ end
57
+
58
+ parts.join("\n")
59
+ end
60
+
61
+ def extract_minimal(output, filter_instance)
62
+ lines = output.lines
63
+ parts = []
64
+
65
+ # Extract only summary and failure locations
66
+ if (summary = lines.find { |l| l.match?(/^\d+ examples?, \d+ failures?/) })
67
+ parts << summary
68
+ end
69
+
70
+ # Extract failure locations (file:line references)
71
+ failure_locations = lines.select { |l| l.match?(/# \.\/\S+:\d+/) }
72
+ if failure_locations.any?
73
+ parts << ""
74
+ parts << "Failed examples:"
75
+ parts.concat(failure_locations.map(&:strip))
76
+ end
77
+
78
+ parts.join("\n")
79
+ end
80
+ end
81
+ end
82
+ end
@@ -11,6 +11,7 @@ require_relative "error_handler"
11
11
  require_relative "status_display"
12
12
  require_relative "completion_checker"
13
13
  require_relative "../concurrency"
14
+ require_relative "../errors"
14
15
 
15
16
  module Aidp
16
17
  module Harness
@@ -135,6 +136,10 @@ module Aidp
135
136
  end
136
137
  end
137
138
  end
139
+ rescue Aidp::Errors::ConfigurationError
140
+ # Configuration errors should crash immediately (crash-early principle)
141
+ # Re-raise without catching
142
+ raise
138
143
  rescue => e
139
144
  @state = STATES[:error]
140
145
  @last_error = e
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require_relative "../tooling_detector"
5
+ require_relative "output_filter"
5
6
 
6
7
  module Aidp
7
8
  module Harness
@@ -11,30 +12,68 @@ module Aidp
11
12
  def initialize(project_dir, config)
12
13
  @project_dir = project_dir
13
14
  @config = config
15
+ @iteration_count = 0
14
16
  end
15
17
 
16
18
  # Run all configured tests
17
- # Returns: { success: boolean, output: string, failures: array }
19
+ # Returns: { success: boolean, output: string, failures: array, required_failures: array }
18
20
  def run_tests
19
- test_commands = resolved_test_commands
20
- return {success: true, output: "", failures: []} if test_commands.empty?
21
-
22
- results = test_commands.map { |cmd| execute_command(cmd, "test") }
23
- aggregate_results(results, "Tests")
21
+ @iteration_count += 1
22
+ run_command_category(:test, "Tests")
24
23
  end
25
24
 
26
25
  # Run all configured linters
27
- # Returns: { success: boolean, output: string, failures: array }
26
+ # Returns: { success: boolean, output: string, failures: array, required_failures: array }
28
27
  def run_linters
29
- lint_commands = resolved_lint_commands
30
- return {success: true, output: "", failures: []} if lint_commands.empty?
28
+ @iteration_count += 1
29
+ run_command_category(:lint, "Linters")
30
+ end
31
31
 
32
- results = lint_commands.map { |cmd| execute_command(cmd, "linter") }
33
- aggregate_results(results, "Linters")
32
+ # Run all configured formatters
33
+ # Returns: { success: boolean, output: string, failures: array, required_failures: array }
34
+ def run_formatters
35
+ run_command_category(:formatter, "Formatters")
36
+ end
37
+
38
+ # Run all configured build commands
39
+ # Returns: { success: boolean, output: string, failures: array, required_failures: array }
40
+ def run_builds
41
+ run_command_category(:build, "Build")
42
+ end
43
+
44
+ # Run all configured documentation commands
45
+ # Returns: { success: boolean, output: string, failures: array, required_failures: array }
46
+ def run_documentation
47
+ run_command_category(:documentation, "Documentation")
34
48
  end
35
49
 
36
50
  private
37
51
 
52
+ # Run commands for a specific category (test, lint, formatter, build, documentation)
53
+ def run_command_category(category, display_name)
54
+ commands = resolved_commands(category)
55
+
56
+ # If no commands configured, return success (empty check passes)
57
+ return {success: true, output: "", failures: [], required_failures: []} if commands.empty?
58
+
59
+ # Determine output mode based on category
60
+ mode = determine_output_mode(category)
61
+
62
+ # Execute all commands
63
+ results = commands.map do |cmd_config|
64
+ # Handle both string commands (legacy) and hash format (new)
65
+ if cmd_config.is_a?(String)
66
+ result = execute_command(cmd_config, category.to_s)
67
+ result.merge(required: true)
68
+ else
69
+ result = execute_command(cmd_config[:command], category.to_s)
70
+ result.merge(required: cmd_config[:required])
71
+ end
72
+ end
73
+
74
+ aggregate_results(results, display_name, mode: mode)
75
+ end
76
+
38
77
  def execute_command(command, type)
39
78
  stdout, stderr, status = Open3.capture3(command, chdir: @project_dir)
40
79
 
@@ -48,53 +87,152 @@ module Aidp
48
87
  }
49
88
  end
50
89
 
51
- def aggregate_results(results, category)
52
- failures = results.reject { |r| r[:success] }
53
- success = failures.empty?
90
+ def aggregate_results(results, category, mode: :full)
91
+ # Separate required and optional command failures
92
+ all_failures = results.reject { |r| r[:success] }
93
+ required_failures = all_failures.select { |r| r[:required] }
94
+ optional_failures = all_failures.reject { |r| r[:required] }
95
+
96
+ # Success only if all REQUIRED commands pass
97
+ # Optional command failures don't block completion
98
+ success = required_failures.empty?
54
99
 
55
- output = if success
56
- "#{category}: All passed"
100
+ output = if all_failures.empty?
101
+ "#{category}: All passed (#{results.length} commands)"
102
+ elsif required_failures.empty?
103
+ "#{category}: Required checks passed (#{optional_failures.length} optional warnings)\n" +
104
+ format_failures(optional_failures, "#{category} - Optional", mode: mode)
57
105
  else
58
- format_failures(failures, category)
106
+ format_failures(required_failures, "#{category} - Required", mode: mode) +
107
+ (optional_failures.any? ? "\n" + format_failures(optional_failures, "#{category} - Optional", mode: mode) : "")
59
108
  end
60
109
 
61
110
  {
62
111
  success: success,
63
112
  output: output,
64
- failures: failures
113
+ failures: all_failures,
114
+ required_failures: required_failures,
115
+ optional_failures: optional_failures
65
116
  }
66
117
  end
67
118
 
68
- def format_failures(failures, category)
119
+ def format_failures(failures, category, mode: :full)
69
120
  output = ["#{category} Failures:", ""]
70
121
 
71
122
  failures.each do |failure|
72
123
  output << "Command: #{failure[:command]}"
73
124
  output << "Exit Code: #{failure[:exit_code]}"
74
125
  output << "--- Output ---"
75
- output << failure[:stdout] unless failure[:stdout].strip.empty?
76
- output << failure[:stderr] unless failure[:stderr].strip.empty?
126
+
127
+ # Apply filtering based on mode and framework
128
+ filtered_stdout = filter_output(failure[:stdout], mode, detect_framework_from_command(failure[:command]))
129
+ filtered_stderr = filter_output(failure[:stderr], mode, :unknown)
130
+
131
+ output << filtered_stdout unless filtered_stdout.strip.empty?
132
+ output << filtered_stderr unless filtered_stderr.strip.empty?
77
133
  output << ""
78
134
  end
79
135
 
80
136
  output.join("\n")
81
137
  end
82
138
 
139
+ def filter_output(raw_output, mode, framework)
140
+ return raw_output if mode == :full || raw_output.nil? || raw_output.empty?
141
+
142
+ filter_config = {
143
+ mode: mode,
144
+ include_context: true,
145
+ context_lines: 3,
146
+ max_lines: 500
147
+ }
148
+
149
+ filter = OutputFilter.new(filter_config)
150
+ filter.filter(raw_output, framework: framework)
151
+ rescue NameError
152
+ # Logging infrastructure not available
153
+ raw_output
154
+ rescue => e
155
+ Aidp.log_warn("test_runner", "filter_failed",
156
+ error: e.message,
157
+ framework: framework)
158
+ raw_output # Fallback to unfiltered on error
159
+ end
160
+
161
+ def detect_framework_from_command(command)
162
+ case command
163
+ when /rspec/
164
+ :rspec
165
+ when /minitest/
166
+ :minitest
167
+ when /jest/
168
+ :jest
169
+ when /pytest/
170
+ :pytest
171
+ else
172
+ :unknown
173
+ end
174
+ end
175
+
176
+ def determine_output_mode(category)
177
+ # Check config for category-specific mode
178
+ case category
179
+ when :test
180
+ if @config.respond_to?(:test_output_mode)
181
+ @config.test_output_mode
182
+ elsif @iteration_count > 1
183
+ :failures_only
184
+ else
185
+ :full
186
+ end
187
+ when :lint
188
+ if @config.respond_to?(:lint_output_mode)
189
+ @config.lint_output_mode
190
+ elsif @iteration_count > 1
191
+ :failures_only
192
+ else
193
+ :full
194
+ end
195
+ else
196
+ :full
197
+ end
198
+ end
199
+
200
+ # Resolve commands for a specific category
201
+ # Returns normalized command configs (array of {command:, required:} hashes)
202
+ def resolved_commands(category)
203
+ case category
204
+ when :test
205
+ resolved_test_commands
206
+ when :lint
207
+ resolved_lint_commands
208
+ when :formatter
209
+ @config.formatter_commands
210
+ when :build
211
+ @config.build_commands
212
+ when :documentation
213
+ @config.documentation_commands
214
+ else
215
+ []
216
+ end
217
+ end
218
+
83
219
  def resolved_test_commands
84
- explicit = Array(@config.test_commands).compact.map(&:strip).reject(&:empty?)
220
+ explicit = @config.test_commands
85
221
  return explicit unless explicit.empty?
86
222
 
87
- detected = detected_tooling.test_commands
88
- log_fallback(:tests, detected) unless detected.empty?
223
+ # Auto-detect test commands if none explicitly configured
224
+ detected = detected_tooling.test_commands.map { |cmd| {command: cmd, required: true} }
225
+ log_fallback(:tests, detected.map { |c| c[:command] }) unless detected.empty?
89
226
  detected
90
227
  end
91
228
 
92
229
  def resolved_lint_commands
93
- explicit = Array(@config.lint_commands).compact.map(&:strip).reject(&:empty?)
230
+ explicit = @config.lint_commands
94
231
  return explicit unless explicit.empty?
95
232
 
96
- detected = detected_tooling.lint_commands
97
- log_fallback(:linters, detected) unless detected.empty?
233
+ # Auto-detect lint commands if none explicitly configured
234
+ detected = detected_tooling.lint_commands.map { |cmd| {command: cmd, required: true} }
235
+ log_fallback(:linters, detected.map { |c| c[:command] }) unless detected.empty?
98
236
  detected
99
237
  end
100
238
 
@@ -2,12 +2,15 @@
2
2
 
3
3
  require_relative "capability_registry"
4
4
  require_relative "configuration"
5
+ require_relative "../message_display"
5
6
 
6
7
  module Aidp
7
8
  module Harness
8
9
  # Manages thinking depth tier selection and escalation
9
10
  # Integrates with CapabilityRegistry and Configuration to select appropriate models
10
11
  class ThinkingDepthManager
12
+ include Aidp::MessageDisplay
13
+
11
14
  attr_reader :configuration, :registry
12
15
 
13
16
  def initialize(configuration, registry: nil, root_dir: nil)
@@ -150,11 +153,48 @@ module Aidp
150
153
  tier ||= current_tier
151
154
  validate_tier!(tier)
152
155
 
153
- # If provider specified, try to find model for that provider
156
+ # First, try to get models from user's configuration for this tier
157
+ configured_models = configuration.models_for_tier(tier)
158
+
159
+ if configured_models.any?
160
+ # If provider specified, try to find model for that provider in config
161
+ if provider
162
+ matching_model = configured_models.find { |m| m[:provider] == provider }
163
+ if matching_model
164
+ Aidp.log_debug("thinking_depth_manager", "Selected model from user config",
165
+ tier: tier,
166
+ provider: provider,
167
+ model: matching_model[:model])
168
+ return [matching_model[:provider], matching_model[:model], {}]
169
+ end
170
+
171
+ # If provider doesn't support tier and switching allowed, try other providers in config
172
+ unless configuration.allow_provider_switch_for_tier?
173
+ Aidp.log_warn("thinking_depth_manager", "Provider lacks tier in config, switching disabled",
174
+ tier: tier,
175
+ provider: provider)
176
+ return nil
177
+ end
178
+ end
179
+
180
+ # Try any configured model for this tier (prioritize first in list)
181
+ first_model = configured_models.first
182
+ if first_model
183
+ Aidp.log_info("thinking_depth_manager", "Selected model from user config",
184
+ tier: tier,
185
+ original_provider: provider,
186
+ selected_provider: first_model[:provider],
187
+ model: first_model[:model])
188
+ return [first_model[:provider], first_model[:model], {}]
189
+ end
190
+ end
191
+
192
+ # Fall back to catalog-based selection if no models in user config
193
+ # If provider specified, try to find model for that provider in catalog
154
194
  if provider
155
195
  model_name, model_data = @registry.best_model_for_tier(tier, provider)
156
196
  if model_name
157
- Aidp.log_debug("thinking_depth_manager", "Selected model",
197
+ Aidp.log_debug("thinking_depth_manager", "Selected model from catalog",
158
198
  tier: tier,
159
199
  provider: provider,
160
200
  model: model_name)
@@ -163,20 +203,24 @@ module Aidp
163
203
 
164
204
  # If provider doesn't support tier and switching allowed, try others
165
205
  unless configuration.allow_provider_switch_for_tier?
166
- Aidp.log_warn("thinking_depth_manager", "Provider lacks tier, switching disabled",
206
+ Aidp.log_warn("thinking_depth_manager", "Provider lacks tier in catalog, switching disabled",
167
207
  tier: tier,
168
208
  provider: provider)
169
209
  return nil
170
210
  end
171
211
  end
172
212
 
173
- # Try all providers
213
+ # Try all providers in catalog
214
+ if provider && !configuration.allow_provider_switch_for_tier?
215
+ return nil
216
+ end
217
+
174
218
  providers_to_try = provider ? [@registry.provider_names - [provider]].flatten : @registry.provider_names
175
219
 
176
220
  providers_to_try.each do |prov_name|
177
221
  model_name, model_data = @registry.best_model_for_tier(tier, prov_name)
178
222
  if model_name
179
- Aidp.log_info("thinking_depth_manager", "Selected model from alternate provider",
223
+ Aidp.log_info("thinking_depth_manager", "Selected model from catalog (alternate provider)",
180
224
  tier: tier,
181
225
  original_provider: provider,
182
226
  selected_provider: prov_name,
@@ -185,10 +229,23 @@ module Aidp
185
229
  end
186
230
  end
187
231
 
188
- Aidp.log_error("thinking_depth_manager", "No model found for tier",
232
+ # No model found for requested tier - try fallback to other tiers
233
+ Aidp.log_warn("thinking_depth_manager", "No model found for requested tier, trying fallback",
189
234
  tier: tier,
190
235
  provider: provider)
191
- nil
236
+
237
+ result = try_fallback_tiers(tier, provider)
238
+
239
+ unless result
240
+ # Enhanced error message with discovery hints
241
+ display_enhanced_tier_error(tier, provider)
242
+
243
+ Aidp.log_error("thinking_depth_manager", "No model found for tier or fallback tiers",
244
+ tier: tier,
245
+ provider: provider)
246
+ end
247
+
248
+ result
192
249
  end
193
250
 
194
251
  # Get tier for a specific model
@@ -330,6 +387,165 @@ module Aidp
330
387
  # Keep history bounded
331
388
  @tier_history.shift if @tier_history.size > 100
332
389
  end
390
+
391
+ # Try to find a model in fallback tiers when requested tier has no models
392
+ # Tries lower tiers first (cheaper), then higher tiers
393
+ # Returns [provider_name, model_name, model_data] or nil
394
+ def try_fallback_tiers(requested_tier, provider)
395
+ # Generate fallback order: try lower tiers first, then higher
396
+ fallback_tiers = generate_fallback_tier_order(requested_tier)
397
+
398
+ fallback_tiers.each do |fallback_tier|
399
+ # First, try user's configuration for this fallback tier
400
+ configured_models = configuration.models_for_tier(fallback_tier)
401
+
402
+ if configured_models.any?
403
+ # Try specified provider first if given
404
+ if provider
405
+ matching_model = configured_models.find { |m| m[:provider] == provider }
406
+ if matching_model
407
+ Aidp.log_warn("thinking_depth_manager", "Falling back to different tier (from config)",
408
+ requested_tier: requested_tier,
409
+ fallback_tier: fallback_tier,
410
+ provider: provider,
411
+ model: matching_model[:model])
412
+ return [matching_model[:provider], matching_model[:model], {}]
413
+ end
414
+ end
415
+
416
+ # Try any configured model for this tier
417
+ first_model = configured_models.first
418
+ if first_model
419
+ Aidp.log_warn("thinking_depth_manager", "Falling back to different tier and provider (from config)",
420
+ requested_tier: requested_tier,
421
+ fallback_tier: fallback_tier,
422
+ requested_provider: provider,
423
+ fallback_provider: first_model[:provider],
424
+ model: first_model[:model])
425
+ return [first_model[:provider], first_model[:model], {}]
426
+ end
427
+ end
428
+
429
+ # Fall back to catalog if no models in config
430
+ # Try specified provider first if given
431
+ if provider
432
+ model_name, model_data = @registry.best_model_for_tier(fallback_tier, provider)
433
+ if model_name
434
+ Aidp.log_warn("thinking_depth_manager", "Falling back to different tier (from catalog)",
435
+ requested_tier: requested_tier,
436
+ fallback_tier: fallback_tier,
437
+ provider: provider,
438
+ model: model_name)
439
+ return [provider, model_name, model_data]
440
+ end
441
+ end
442
+
443
+ # Try all available providers in catalog
444
+ @registry.provider_names.each do |prov_name|
445
+ next if prov_name == provider # Skip if already tried above
446
+
447
+ model_name, model_data = @registry.best_model_for_tier(fallback_tier, prov_name)
448
+ if model_name
449
+ Aidp.log_warn("thinking_depth_manager", "Falling back to different tier and provider (from catalog)",
450
+ requested_tier: requested_tier,
451
+ fallback_tier: fallback_tier,
452
+ requested_provider: provider,
453
+ fallback_provider: prov_name,
454
+ model: model_name)
455
+ return [prov_name, model_name, model_data]
456
+ end
457
+ end
458
+ end
459
+
460
+ nil
461
+ end
462
+
463
+ # Generate fallback tier order: lower tiers first (cheaper), then higher
464
+ # For example, if tier is "standard", try: mini, thinking, pro, max
465
+ def generate_fallback_tier_order(tier)
466
+ current_priority = @registry.tier_priority(tier) || 1
467
+ all_tiers = CapabilityRegistry::VALID_TIERS
468
+
469
+ # Split into lower and higher tiers
470
+ lower_tiers = all_tiers.select { |t| (@registry.tier_priority(t) || 0) < current_priority }.reverse
471
+ higher_tiers = all_tiers.select { |t| (@registry.tier_priority(t) || 0) > current_priority }
472
+
473
+ # Try lower tiers first (cost optimization), then higher tiers
474
+ lower_tiers + higher_tiers
475
+ end
476
+
477
+ # Display enhanced error message with discovery hints
478
+ def display_enhanced_tier_error(tier, provider)
479
+ return unless defined?(Aidp::MessageDisplay)
480
+
481
+ # Check if there are discovered models in cache
482
+ discovered_models = check_discovered_models(tier, provider)
483
+
484
+ if discovered_models&.any?
485
+ display_tier_error_with_suggestions(tier, provider, discovered_models)
486
+ else
487
+ display_tier_error_with_discovery_hint(tier, provider)
488
+ end
489
+ end
490
+
491
+ # Check cache for discovered models for this tier
492
+ def check_discovered_models(tier, provider)
493
+ require_relative "model_cache"
494
+ require_relative "model_registry"
495
+
496
+ cache = Aidp::Harness::ModelCache.new
497
+ registry = Aidp::Harness::ModelRegistry.new
498
+
499
+ # Get all cached models for the provider
500
+ cached_models = cache.get_cached_models(provider)
501
+ return nil unless cached_models&.any?
502
+
503
+ # Filter to models for the requested tier
504
+ tier_models = cached_models.select do |model|
505
+ family = model[:family] || model["family"]
506
+ model_info = registry.get_model_info(family)
507
+ model_info && model_info["tier"] == tier.to_s
508
+ end
509
+
510
+ tier_models.any? ? tier_models : nil
511
+ rescue => e
512
+ Aidp.log_debug("thinking_depth_manager", "failed to check cached models",
513
+ error: e.message)
514
+ nil
515
+ end
516
+
517
+ # Display error with model suggestions from cache
518
+ def display_tier_error_with_suggestions(tier, provider, models)
519
+ display_message("\nāŒ No model configured for '#{tier}' tier", type: :error)
520
+ display_message(" Provider: #{provider}", type: :info) if provider
521
+
522
+ display_message("\nšŸ’” Discovered models for this tier:", type: :highlight)
523
+ models.first(3).each do |model|
524
+ model_name = model[:name] || model["name"]
525
+ display_message(" - #{model_name}", type: :info)
526
+ end
527
+
528
+ display_message("\n Add to aidp.yml:", type: :highlight)
529
+ display_message(" providers:", type: :info)
530
+ display_message(" #{provider}:", type: :info)
531
+ display_message(" thinking:", type: :info)
532
+ display_message(" tiers:", type: :info)
533
+ display_message(" #{tier}:", type: :info)
534
+ display_message(" models:", type: :info)
535
+ first_model = models.first[:name] || models.first["name"]
536
+ display_message(" - model: #{first_model}\n", type: :info)
537
+ end
538
+
539
+ # Display error with discovery hint
540
+ def display_tier_error_with_discovery_hint(tier, provider)
541
+ display_message("\nāŒ No model configured for '#{tier}' tier", type: :error)
542
+ display_message(" Provider: #{provider}", type: :info) if provider
543
+
544
+ display_message("\nšŸ’” Suggested actions:", type: :highlight)
545
+ display_message(" 1. Run 'aidp models discover' to find available models", type: :info)
546
+ display_message(" 2. Run 'aidp models list --tier=#{tier}' to see models for this tier", type: :info)
547
+ display_message(" 3. Run 'aidp models validate' to check your configuration\n", type: :info)
548
+ end
333
549
  end
334
550
  end
335
551
  end
@@ -23,13 +23,16 @@ module Aidp
23
23
  def initialize(prompt: TTY::Prompt.new, tty: $stdin)
24
24
  @cursor = TTY::Cursor
25
25
  @screen = TTY::Screen
26
- @pastel = Pastel.new
27
26
  @prompt = prompt
28
27
 
29
28
  # Headless (non-interactive) detection for test/CI environments:
30
29
  # - STDIN not a TTY (captured by PTY/tmux harness or test environment)
31
30
  @headless = !!(tty.nil? || !tty.tty?)
32
31
 
32
+ # Initialize Pastel with disabled colors in headless mode to avoid
33
+ # "closed stream" errors when checking TTY capabilities
34
+ @pastel = Pastel.new(enabled: !@headless)
35
+
33
36
  @current_mode = nil
34
37
  @workflow_active = false
35
38
  @current_step = nil