aidp 0.34.0 → 0.39.3

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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -14
  3. data/lib/aidp/agent_harness_adapter.rb +309 -0
  4. data/lib/aidp/cli/issue_importer.rb +20 -6
  5. data/lib/aidp/cli/jobs_command.rb +1 -1
  6. data/lib/aidp/cli/models_command.rb +9 -16
  7. data/lib/aidp/cli/prompts_command.rb +253 -0
  8. data/lib/aidp/cli/security_command.rb +4 -10
  9. data/lib/aidp/cli/storage_command.rb +277 -0
  10. data/lib/aidp/cli/temporal_command.rb +460 -0
  11. data/lib/aidp/cli.rb +24 -2
  12. data/lib/aidp/config.rb +15 -0
  13. data/lib/aidp/config_paths.rb +12 -2
  14. data/lib/aidp/database/migrations.rb +114 -0
  15. data/lib/aidp/database/repositories/checkpoint_repository.rb +174 -0
  16. data/lib/aidp/database/repositories/deprecated_models_repository.rb +206 -0
  17. data/lib/aidp/database/repositories/evaluation_repository.rb +208 -0
  18. data/lib/aidp/database/repositories/harness_state_repository.rb +140 -0
  19. data/lib/aidp/database/repositories/job_repository.rb +278 -0
  20. data/lib/aidp/database/repositories/model_cache_repository.rb +194 -0
  21. data/lib/aidp/database/repositories/progress_repository.rb +210 -0
  22. data/lib/aidp/database/repositories/prompt_archive_repository.rb +195 -0
  23. data/lib/aidp/database/repositories/prompt_feedback_repository.rb +266 -0
  24. data/lib/aidp/database/repositories/provider_info_cache_repository.rb +208 -0
  25. data/lib/aidp/database/repositories/provider_metrics_repository.rb +245 -0
  26. data/lib/aidp/database/repositories/secrets_repository.rb +187 -0
  27. data/lib/aidp/database/repositories/task_repository.rb +231 -0
  28. data/lib/aidp/database/repositories/template_version_repository.rb +383 -0
  29. data/lib/aidp/database/repositories/watch_state_repository.rb +472 -0
  30. data/lib/aidp/database/repositories/workstream_repository.rb +310 -0
  31. data/lib/aidp/database/repositories/worktree_repository.rb +240 -0
  32. data/lib/aidp/database/repository.rb +166 -0
  33. data/lib/aidp/database/schema.rb +347 -0
  34. data/lib/aidp/database/storage_migrator.rb +693 -0
  35. data/lib/aidp/database.rb +139 -0
  36. data/lib/aidp/debug_mixin.rb +2 -2
  37. data/lib/aidp/execute/checkpoint.rb +26 -0
  38. data/lib/aidp/execute/checkpoint_display.rb +16 -0
  39. data/lib/aidp/execute/prompt_evaluator.rb +30 -57
  40. data/lib/aidp/execute/work_loop_runner.rb +294 -46
  41. data/lib/aidp/firewall/provider_requirements_collector.rb +7 -23
  42. data/lib/aidp/harness/agent_harness_provider_manager.rb +275 -0
  43. data/lib/aidp/harness/ai_decision_engine.rb +78 -296
  44. data/lib/aidp/harness/config_schema.rb +59 -0
  45. data/lib/aidp/harness/configuration.rb +229 -10
  46. data/lib/aidp/harness/deprecation_cache.rb +53 -11
  47. data/lib/aidp/harness/enhanced_runner.rb +1 -1
  48. data/lib/aidp/harness/error_handler.rb +11 -5
  49. data/lib/aidp/harness/provider_config.rb +36 -0
  50. data/lib/aidp/harness/provider_factory.rb +26 -24
  51. data/lib/aidp/harness/provider_info.rb +9 -5
  52. data/lib/aidp/harness/provider_manager.rb +2 -2
  53. data/lib/aidp/harness/provider_metrics.rb +103 -1
  54. data/lib/aidp/harness/rspec_command_optimizer.rb +166 -0
  55. data/lib/aidp/harness/ruby_llm_registry.rb +97 -8
  56. data/lib/aidp/harness/runner.rb +7 -2
  57. data/lib/aidp/harness/state_manager.rb +1 -1
  58. data/lib/aidp/harness/test_runner.rb +167 -11
  59. data/lib/aidp/harness/thinking_depth_manager.rb +564 -5
  60. data/lib/aidp/harness/ui.rb +11 -0
  61. data/lib/aidp/harness/usage_limit.rb +179 -0
  62. data/lib/aidp/harness/usage_limit_enforcer.rb +240 -0
  63. data/lib/aidp/harness/usage_limit_tracker.rb +308 -0
  64. data/lib/aidp/harness/usage_period.rb +234 -0
  65. data/lib/aidp/init/agent_instructions_generator.rb +317 -0
  66. data/lib/aidp/init/runner.rb +25 -4
  67. data/lib/aidp/interfaces/activity_monitor_interface.rb +234 -0
  68. data/lib/aidp/interfaces/binary_checker_interface.rb +232 -0
  69. data/lib/aidp/interfaces/command_executor_interface.rb +216 -0
  70. data/lib/aidp/interfaces/logger_interface.rb +150 -0
  71. data/lib/aidp/interfaces/ui_interface.rb +254 -0
  72. data/lib/aidp/logger.rb +25 -7
  73. data/lib/aidp/orchestration_adapter.rb +279 -0
  74. data/lib/aidp/pr_worktree_manager.rb +4 -15
  75. data/lib/aidp/prompts/feedback_collector.rb +198 -0
  76. data/lib/aidp/prompts/prompt_template_manager.rb +415 -0
  77. data/lib/aidp/prompts/template_evolver.rb +305 -0
  78. data/lib/aidp/prompts/template_version_manager.rb +325 -0
  79. data/lib/aidp/setup/in_memory_config_adapter.rb +257 -0
  80. data/lib/aidp/setup/in_memory_config_manager.rb +262 -0
  81. data/lib/aidp/setup/wizard.rb +616 -176
  82. data/lib/aidp/shell_executor.rb +58 -0
  83. data/lib/aidp/style_guide/selector.rb +56 -1
  84. data/lib/aidp/temporal/activities/analyze_issue_activity.rb +198 -0
  85. data/lib/aidp/temporal/activities/analyze_sub_task_activity.rb +163 -0
  86. data/lib/aidp/temporal/activities/base_activity.rb +78 -0
  87. data/lib/aidp/temporal/activities/create_plan_activity.rb +115 -0
  88. data/lib/aidp/temporal/activities/create_pr_activity.rb +193 -0
  89. data/lib/aidp/temporal/activities/create_prompt_activity.rb +118 -0
  90. data/lib/aidp/temporal/activities/diagnose_failure_activity.rb +228 -0
  91. data/lib/aidp/temporal/activities/prepare_next_iteration_activity.rb +147 -0
  92. data/lib/aidp/temporal/activities/record_checkpoint_activity.rb +81 -0
  93. data/lib/aidp/temporal/activities/run_agent_activity.rb +139 -0
  94. data/lib/aidp/temporal/activities/run_tests_activity.rb +114 -0
  95. data/lib/aidp/temporal/activities/run_work_loop_iteration_activity.rb +249 -0
  96. data/lib/aidp/temporal/configuration.rb +169 -0
  97. data/lib/aidp/temporal/connection.rb +93 -0
  98. data/lib/aidp/temporal/worker.rb +119 -0
  99. data/lib/aidp/temporal/workflow_client.rb +171 -0
  100. data/lib/aidp/temporal/workflows/base_workflow.rb +73 -0
  101. data/lib/aidp/temporal/workflows/issue_to_pr_workflow.rb +278 -0
  102. data/lib/aidp/temporal/workflows/sub_issue_workflow.rb +253 -0
  103. data/lib/aidp/temporal/workflows/work_loop_workflow.rb +321 -0
  104. data/lib/aidp/temporal.rb +120 -0
  105. data/lib/aidp/version.rb +1 -1
  106. data/lib/aidp/watch/base_processor.rb +35 -0
  107. data/lib/aidp/watch/build_processor.rb +132 -0
  108. data/lib/aidp/watch/github_state_extractor.rb +0 -8
  109. data/lib/aidp/watch/rebase_processor.rb +245 -0
  110. data/lib/aidp/watch/round_robin_scheduler.rb +150 -0
  111. data/lib/aidp/watch/runner.rb +366 -285
  112. data/lib/aidp/watch/state_store.rb +80 -0
  113. data/lib/aidp/watch/work_item.rb +96 -0
  114. data/lib/aidp/watch/worktree_cleanup_job.rb +226 -0
  115. data/lib/aidp/watch/worktree_reconciler.rb +643 -0
  116. data/lib/aidp/workflows/guided_agent.rb +1 -1
  117. data/lib/aidp/worktree.rb +62 -0
  118. data/templates/aidp.yml.example +70 -0
  119. metadata +118 -16
  120. data/lib/aidp/analyze/json_file_storage.rb +0 -292
  121. data/lib/aidp/execute/future_work_backlog.rb +0 -411
  122. data/lib/aidp/providers/adapter.rb +0 -239
  123. data/lib/aidp/providers/aider.rb +0 -264
  124. data/lib/aidp/providers/anthropic.rb +0 -614
  125. data/lib/aidp/providers/base.rb +0 -617
  126. data/lib/aidp/providers/capability_registry.rb +0 -204
  127. data/lib/aidp/providers/codex.rb +0 -267
  128. data/lib/aidp/providers/cursor.rb +0 -304
  129. data/lib/aidp/providers/error_taxonomy.rb +0 -195
  130. data/lib/aidp/providers/gemini.rb +0 -110
  131. data/lib/aidp/providers/github_copilot.rb +0 -227
  132. data/lib/aidp/providers/kilocode.rb +0 -157
  133. data/lib/aidp/providers/opencode.rb +0 -139
@@ -12,13 +12,14 @@ module Aidp
12
12
  class ProviderMetrics
13
13
  include Aidp::RescueLogging
14
14
 
15
- attr_reader :project_dir, :metrics_file, :rate_limit_file
15
+ attr_reader :project_dir, :metrics_file, :rate_limit_file, :usage_tracking_dir
16
16
 
17
17
  def initialize(project_dir)
18
18
  # Store metrics at the repository root so different worktrees/modes share state
19
19
  @project_dir = Aidp::Util.find_project_root(project_dir)
20
20
  @metrics_file = File.join(@project_dir, ".aidp", "provider_metrics.yml")
21
21
  @rate_limit_file = File.join(@project_dir, ".aidp", "provider_rate_limits.yml")
22
+ @usage_tracking_dir = File.join(@project_dir, ".aidp", "usage_tracking")
22
23
  ensure_directory
23
24
  end
24
25
 
@@ -78,6 +79,76 @@ module Aidp
78
79
  def clear
79
80
  File.delete(@metrics_file) if File.exist?(@metrics_file)
80
81
  File.delete(@rate_limit_file) if File.exist?(@rate_limit_file)
82
+ clear_usage_tracking
83
+ end
84
+
85
+ # Save usage tracking data for a provider
86
+ #
87
+ # @param provider_name [String] Name of the provider
88
+ # @param usage_data [Hash] Usage tracking data
89
+ def save_usage_tracking(provider_name, usage_data)
90
+ return if usage_data.nil? || usage_data.empty?
91
+
92
+ ensure_usage_tracking_directory
93
+ usage_file = usage_tracking_file(provider_name)
94
+
95
+ # Serialize Time objects for YAML
96
+ serializable_data = serialize_usage_tracking(usage_data)
97
+
98
+ # Write atomically using temp file + rename
99
+ temp_file = "#{usage_file}.tmp"
100
+ File.write(temp_file, YAML.dump(serializable_data))
101
+ File.rename(temp_file, usage_file)
102
+ rescue => e
103
+ log_rescue(e, component: "provider_metrics", action: "save_usage_tracking", fallback: nil)
104
+ File.delete(temp_file) if defined?(temp_file) && File.exist?(temp_file)
105
+ end
106
+
107
+ # Load usage tracking data for a provider
108
+ #
109
+ # @param provider_name [String] Name of the provider
110
+ # @return [Hash] Usage tracking data or empty hash
111
+ def load_usage_tracking(provider_name)
112
+ usage_file = usage_tracking_file(provider_name)
113
+ return {} unless File.exist?(usage_file)
114
+
115
+ data = YAML.safe_load_file(usage_file, permitted_classes: [Time, Date, Symbol], aliases: true)
116
+ return {} unless data.is_a?(Hash)
117
+
118
+ deserialize_usage_tracking(data)
119
+ rescue => e
120
+ log_rescue(e, component: "provider_metrics", action: "load_usage_tracking", fallback: {})
121
+ {}
122
+ end
123
+
124
+ # Clear usage tracking data for a specific provider
125
+ #
126
+ # @param provider_name [String] Name of the provider (optional, clears all if nil)
127
+ def clear_usage_tracking(provider_name = nil)
128
+ if provider_name
129
+ usage_file = usage_tracking_file(provider_name)
130
+ File.delete(usage_file) if File.exist?(usage_file)
131
+ else
132
+ # Clear all usage tracking files
133
+ return unless File.directory?(@usage_tracking_dir)
134
+
135
+ Dir.glob(File.join(@usage_tracking_dir, "*.yml")).each do |file|
136
+ File.delete(file)
137
+ rescue => e
138
+ log_rescue(e, component: "provider_metrics", action: "clear_usage_file", fallback: nil)
139
+ end
140
+ end
141
+ end
142
+
143
+ # List all providers with usage tracking data
144
+ #
145
+ # @return [Array<String>] Array of provider names
146
+ def providers_with_usage_tracking
147
+ return [] unless File.directory?(@usage_tracking_dir)
148
+
149
+ Dir.glob(File.join(@usage_tracking_dir, "*.yml")).map do |file|
150
+ File.basename(file, ".yml")
151
+ end
81
152
  end
82
153
 
83
154
  private
@@ -87,6 +158,37 @@ module Aidp
87
158
  FileUtils.mkdir_p(aidp_dir) unless File.directory?(aidp_dir)
88
159
  end
89
160
 
161
+ def ensure_usage_tracking_directory
162
+ FileUtils.mkdir_p(@usage_tracking_dir) unless File.directory?(@usage_tracking_dir)
163
+ end
164
+
165
+ def usage_tracking_file(provider_name)
166
+ File.join(@usage_tracking_dir, "#{provider_name}.yml")
167
+ end
168
+
169
+ def serialize_usage_tracking(data)
170
+ deep_transform_values(data) do |value|
171
+ value.is_a?(Time) ? value.iso8601 : value
172
+ end
173
+ end
174
+
175
+ def deserialize_usage_tracking(data)
176
+ deep_transform_values(data) do |value|
177
+ parse_time_if_string(value)
178
+ end
179
+ end
180
+
181
+ def deep_transform_values(obj, &block)
182
+ case obj
183
+ when Hash
184
+ obj.transform_values { |v| deep_transform_values(v, &block) }
185
+ when Array
186
+ obj.map { |v| deep_transform_values(v, &block) }
187
+ else
188
+ yield(obj)
189
+ end
190
+ end
191
+
90
192
  def serialize_metrics(metrics_hash)
91
193
  metrics_hash.transform_values do |provider_metrics|
92
194
  next provider_metrics unless provider_metrics.is_a?(Hash)
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Harness
5
+ # Optimizes RSpec command execution by using --only-failures on subsequent runs.
6
+ # This dramatically reduces both execution time and output volume when fixing
7
+ # failing tests during work loops.
8
+ #
9
+ # Requirements:
10
+ # - RSpec must be configured with example_status_persistence_file_path in spec_helper.rb
11
+ # - A .rspec_status file (or configured path) must exist from a previous run
12
+ #
13
+ # @example
14
+ # optimizer = RSpecCommandOptimizer.new("/path/to/project")
15
+ # result = optimizer.optimize_command("bundle exec rspec", iteration: 2, had_failures: true)
16
+ # # => "bundle exec rspec --only-failures"
17
+ #
18
+ class RSpecCommandOptimizer
19
+ # Default paths where RSpec stores example status
20
+ DEFAULT_STATUS_PATHS = [
21
+ ".rspec_status",
22
+ "tmp/.rspec_status",
23
+ "spec/.rspec_status"
24
+ ].freeze
25
+
26
+ # Common spec_helper paths to check for persistence configuration
27
+ SPEC_HELPER_PATHS = [
28
+ "spec/spec_helper.rb",
29
+ "spec/rails_helper.rb"
30
+ ].freeze
31
+
32
+ attr_reader :project_dir
33
+
34
+ def initialize(project_dir)
35
+ @project_dir = project_dir
36
+ @status_file_cache = nil
37
+ @persistence_configured_cache = nil
38
+ end
39
+
40
+ # Optimize an RSpec command based on iteration context
41
+ #
42
+ # @param command [String] The original RSpec command
43
+ # @param iteration [Integer] Current iteration number (1-indexed)
44
+ # @param had_failures [Boolean] Whether the previous iteration had test failures
45
+ # @return [Hash] { command: String, optimized: Boolean, reason: String }
46
+ def optimize_command(command, iteration:, had_failures:)
47
+ return skip_optimization(command, "not an RSpec command") unless rspec_command?(command)
48
+ return skip_optimization(command, "first iteration") if iteration <= 1
49
+ return skip_optimization(command, "no previous failures") unless had_failures
50
+ return skip_optimization(command, "already has --only-failures") if command.include?("--only-failures")
51
+
52
+ status_file = find_status_file
53
+ unless status_file
54
+ Aidp.log_debug("rspec_optimizer", "no_status_file",
55
+ project_dir: @project_dir,
56
+ checked_paths: DEFAULT_STATUS_PATHS)
57
+
58
+ return skip_optimization(command, "no .rspec_status file found - see setup instructions")
59
+ end
60
+
61
+ optimized_command = add_only_failures_flag(command)
62
+
63
+ Aidp.log_info("rspec_optimizer", "command_optimized",
64
+ original: command,
65
+ optimized: optimized_command,
66
+ iteration: iteration,
67
+ status_file: status_file)
68
+
69
+ {
70
+ command: optimized_command,
71
+ optimized: true,
72
+ reason: "using --only-failures (iteration #{iteration})",
73
+ status_file: status_file
74
+ }
75
+ end
76
+
77
+ # Check if RSpec persistence is properly configured in this project
78
+ #
79
+ # @return [Hash] { configured: Boolean, file_path: String, message: String }
80
+ def check_persistence_configuration
81
+ return @persistence_configured_cache if @persistence_configured_cache
82
+
83
+ @persistence_configured_cache = begin
84
+ # Check if any spec_helper configures persistence
85
+ configured_in_spec_helper = SPEC_HELPER_PATHS.any? do |path|
86
+ full_path = File.join(@project_dir, path)
87
+ next false unless File.exist?(full_path)
88
+
89
+ content = File.read(full_path, encoding: "UTF-8")
90
+ content.include?("example_status_persistence_file_path")
91
+ end
92
+
93
+ if configured_in_spec_helper
94
+ status_file = find_status_file
95
+ {
96
+ configured: true,
97
+ file_path: status_file,
98
+ message: "RSpec persistence configured" + (status_file ? " (#{status_file} exists)" : " (waiting for first run)")
99
+ }
100
+ else
101
+ {
102
+ configured: false,
103
+ file_path: nil,
104
+ message: "RSpec persistence not configured. Add to spec_helper.rb:\n" \
105
+ " config.example_status_persistence_file_path = '.rspec_status'"
106
+ }
107
+ end
108
+ end
109
+ end
110
+
111
+ # Find the RSpec status file if it exists
112
+ #
113
+ # @return [String, nil] Path to status file or nil if not found
114
+ def find_status_file
115
+ return @status_file_cache if @status_file_cache
116
+
117
+ @status_file_cache = DEFAULT_STATUS_PATHS.find do |path|
118
+ full_path = File.join(@project_dir, path)
119
+ File.exist?(full_path)
120
+ end
121
+ end
122
+
123
+ # Check if command is an RSpec command
124
+ #
125
+ # @param command [String] Command to check
126
+ # @return [Boolean]
127
+ def rspec_command?(command)
128
+ return false unless command.is_a?(String)
129
+ command.downcase.include?("rspec")
130
+ end
131
+
132
+ # Reset caches (useful after test runs that may create status file)
133
+ def reset_caches!
134
+ @status_file_cache = nil
135
+ @persistence_configured_cache = nil
136
+ end
137
+
138
+ private
139
+
140
+ def skip_optimization(command, reason)
141
+ {
142
+ command: command,
143
+ optimized: false,
144
+ reason: reason
145
+ }
146
+ end
147
+
148
+ def add_only_failures_flag(command)
149
+ # Insert --only-failures before any file arguments
150
+ # "bundle exec rspec" -> "bundle exec rspec --only-failures"
151
+ # "bundle exec rspec spec/foo_spec.rb" -> "bundle exec rspec --only-failures spec/foo_spec.rb"
152
+ parts = command.split(/\s+/)
153
+ rspec_index = parts.index { |p| p.downcase == "rspec" }
154
+
155
+ if rspec_index
156
+ # Insert after "rspec" but before any file arguments
157
+ parts.insert(rspec_index + 1, "--only-failures")
158
+ parts.join(" ")
159
+ else
160
+ # Fallback: append to end
161
+ "#{command} --only-failures"
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -12,6 +12,9 @@ module Aidp
12
12
  class RegistryError < StandardError; end
13
13
  class ModelNotFound < RegistryError; end
14
14
 
15
+ ENCODING_MUTEX = Mutex.new
16
+ private_constant :ENCODING_MUTEX
17
+
15
18
  # Map AIDP provider names to RubyLLM provider names
16
19
  # Some AIDP providers use different names than the upstream APIs
17
20
  PROVIDER_NAME_MAPPING = {
@@ -67,8 +70,8 @@ module Aidp
67
70
 
68
71
  def initialize(deprecation_cache: nil)
69
72
  @deprecation_cache = deprecation_cache
70
- @models = RubyLLM::Models.instance.instance_variable_get(:@models)
71
- @index_by_id = @models.to_h { |m| [m.id, m] }
73
+ @models = load_models_with_utf8_fallback
74
+ @index_by_id = build_id_index(@models)
72
75
 
73
76
  # Build family index for mapping versioned names to families
74
77
  @family_index = build_family_index
@@ -125,9 +128,12 @@ module Aidp
125
128
  # Get model information
126
129
  #
127
130
  # @param model_id [String] The model ID
131
+ # @param provider [String, nil] Optional AIDP provider name. When given,
132
+ # returns info for that provider's entry (useful when the same model ID
133
+ # exists under multiple providers, e.g. anthropic and azure).
128
134
  # @return [Hash, nil] Model information hash or nil if not found
129
- def get_model_info(model_id)
130
- model = @index_by_id[model_id]
135
+ def get_model_info(model_id, provider: nil)
136
+ model = find_model_entry(model_id, provider: provider)
131
137
  return nil unless model
132
138
 
133
139
  {
@@ -212,8 +218,8 @@ module Aidp
212
218
  # Refresh the model registry from ruby_llm
213
219
  def refresh!
214
220
  RubyLLM::Models.refresh!
215
- @models = RubyLLM::Models.instance.instance_variable_get(:@models)
216
- @index_by_id = @models.to_h { |m| [m.id, m] }
221
+ @models = load_models_with_utf8_fallback
222
+ @index_by_id = build_id_index(@models)
217
223
  @family_index = build_family_index
218
224
  Aidp.log_info("ruby_llm_registry", "refreshed", models: @models.size)
219
225
  end
@@ -229,7 +235,8 @@ module Aidp
229
235
  end
230
236
 
231
237
  # Find replacement for a deprecated model
232
- # Returns the latest non-deprecated model in the same family/tier
238
+ # Returns the cached replacement if available, otherwise finds the latest
239
+ # non-deprecated model in the same family/tier
233
240
  # @param deprecated_model [String] The deprecated model ID
234
241
  # @param provider [String, nil] The provider name (AIDP format)
235
242
  # @return [String, nil] Replacement model ID or nil
@@ -237,7 +244,16 @@ module Aidp
237
244
  registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
238
245
  return nil unless registry_provider
239
246
 
240
- # Determine tier of deprecated model
247
+ # First check the deprecation cache for a stored replacement
248
+ cached_replacement = deprecation_cache.replacement_for(provider: registry_provider, model_id: deprecated_model)
249
+ if cached_replacement
250
+ Aidp.log_info("ruby_llm_registry", "found cached replacement",
251
+ deprecated: deprecated_model,
252
+ replacement: cached_replacement)
253
+ return cached_replacement
254
+ end
255
+
256
+ # Determine tier of deprecated model for dynamic lookup
241
257
  deprecated_info = @index_by_id[deprecated_model]
242
258
  return nil unless deprecated_info
243
259
 
@@ -265,6 +281,79 @@ module Aidp
265
281
 
266
282
  private
267
283
 
284
+ # Load models from RubyLLM, working around encoding issues in the
285
+ # upstream gem when the process locale is US-ASCII.
286
+ def load_models_with_utf8_fallback
287
+ RubyLLM::Models.instance.instance_variable_get(:@models)
288
+ rescue Encoding::InvalidByteSequenceError => e
289
+ # Only switch encoding when the current default is US-ASCII, which is
290
+ # the known problematic locale for RubyLLM's UTF-8 model JSON.
291
+ # For other non-UTF-8 encodings, propagate the error rather than
292
+ # silently clobbering the process-wide setting.
293
+ ENCODING_MUTEX.synchronize do
294
+ unless Encoding.default_external == Encoding::US_ASCII
295
+ raise e
296
+ end
297
+
298
+ Aidp.log_debug(
299
+ "ruby_llm_registry",
300
+ "setting default external encoding from US-ASCII to UTF-8 for RubyLLM model load"
301
+ )
302
+ Encoding.default_external = Encoding::UTF_8
303
+
304
+ # RubyLLM::Models uses a singleton @instance that caches the parsed
305
+ # model list. We reset it so the retry re-reads the JSON under
306
+ # UTF-8 encoding. This relies on an internal implementation detail;
307
+ # if upstream adds a public reset API we should prefer that.
308
+ RubyLLM::Models.instance_variable_set(:@instance, nil)
309
+ RubyLLM::Models.instance.instance_variable_get(:@models)
310
+ end
311
+ end
312
+
313
+ # Find the model entry for a given ID, optionally scoped to a provider.
314
+ # When a provider is given, only returns entries for that provider;
315
+ # returns nil if the provider is unsupported or no match is found.
316
+ # Falls back to the flat index only when no provider is given.
317
+ def find_model_entry(model_id, provider: nil)
318
+ if provider
319
+ registry_provider = PROVIDER_NAME_MAPPING[provider]
320
+ unless registry_provider
321
+ Aidp.log_debug("ruby_llm_registry", "unsupported provider for model lookup",
322
+ provider: provider, model_id: model_id)
323
+ return nil
324
+ end
325
+
326
+ match = @models.find { |m| m.id == model_id && m.provider.to_s == registry_provider }
327
+ unless match
328
+ Aidp.log_debug("ruby_llm_registry", "model not found for provider",
329
+ model_id: model_id, provider: provider, registry_provider: registry_provider)
330
+ end
331
+ return match
332
+ end
333
+ @index_by_id[model_id]
334
+ end
335
+
336
+ # Build an index of model ID to model object, preferring primary
337
+ # providers (anthropic, openai, gemini) over resellers (azure, bedrock)
338
+ # when the same model ID appears under multiple providers.
339
+ #
340
+ # NOTE: This flat index stores one entry per model ID. Callers that need
341
+ # provider-specific results (e.g. models_for_provider) should filter
342
+ # @models directly rather than relying on this index.
343
+ PRIMARY_PROVIDERS = %w[anthropic openai gemini deepseek mistral].freeze
344
+
345
+ def build_id_index(models)
346
+ index = {}
347
+ models.each do |m|
348
+ existing = index[m.id]
349
+ if existing.nil? || (!PRIMARY_PROVIDERS.include?(existing.provider.to_s) &&
350
+ PRIMARY_PROVIDERS.include?(m.provider.to_s))
351
+ index[m.id] = m
352
+ end
353
+ end
354
+ index
355
+ end
356
+
268
357
  # Build an index mapping family names to model objects
269
358
  # Family name is model ID with version suffix removed
270
359
  def build_family_index
@@ -50,7 +50,7 @@ module Aidp
50
50
  # Initialize components
51
51
  @configuration = Configuration.new(project_dir)
52
52
  @state_manager = StateManager.new(project_dir, @mode)
53
- @provider_manager = ProviderManager.new(@configuration, prompt: @prompt)
53
+ @provider_manager = create_provider_manager(options)
54
54
 
55
55
  # Use ZFC-enabled condition detector
56
56
  # ZfcConditionDetector will create its own ProviderFactory if needed
@@ -224,6 +224,11 @@ module Aidp
224
224
 
225
225
  private
226
226
 
227
+ def create_provider_manager(_options)
228
+ require_relative "agent_harness_provider_manager"
229
+ AgentHarnessProviderManager.new(@configuration, prompt: @prompt)
230
+ end
231
+
227
232
  def get_mode_runner
228
233
  case @mode
229
234
  when :analyze
@@ -459,7 +464,7 @@ module Aidp
459
464
  **data.slice(:error, :error_class, :criteria, :all_complete, :summary).compact)
460
465
 
461
466
  # Also log to standard output in debug mode
462
- puts "[#{Time.now.strftime("%H:%M:%S")}] #{message}" if ENV["AIDP_DEBUG"] == "1"
467
+ puts "[#{Time.now.strftime("%H:%M:%S")}] #{message}" if Aidp.debug_env_level >= 1
463
468
  end
464
469
 
465
470
  def get_completion_message
@@ -616,7 +616,7 @@ module Aidp
616
616
  end
617
617
  rescue => e
618
618
  # Ignore errors when cleaning up stale locks
619
- warn "Failed to cleanup stale lock: #{e.message}" if ENV["DEBUG"]
619
+ warn "Failed to cleanup stale lock: #{e.message}" if Aidp.debug_env_enabled?
620
620
  end
621
621
  end
622
622
  end