aidp 0.34.1 → 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 (132) 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/usage_limit.rb +179 -0
  61. data/lib/aidp/harness/usage_limit_enforcer.rb +240 -0
  62. data/lib/aidp/harness/usage_limit_tracker.rb +308 -0
  63. data/lib/aidp/harness/usage_period.rb +234 -0
  64. data/lib/aidp/init/agent_instructions_generator.rb +317 -0
  65. data/lib/aidp/init/runner.rb +25 -4
  66. data/lib/aidp/interfaces/activity_monitor_interface.rb +234 -0
  67. data/lib/aidp/interfaces/binary_checker_interface.rb +232 -0
  68. data/lib/aidp/interfaces/command_executor_interface.rb +216 -0
  69. data/lib/aidp/interfaces/logger_interface.rb +150 -0
  70. data/lib/aidp/interfaces/ui_interface.rb +254 -0
  71. data/lib/aidp/logger.rb +25 -7
  72. data/lib/aidp/orchestration_adapter.rb +279 -0
  73. data/lib/aidp/pr_worktree_manager.rb +4 -15
  74. data/lib/aidp/prompts/feedback_collector.rb +198 -0
  75. data/lib/aidp/prompts/prompt_template_manager.rb +415 -0
  76. data/lib/aidp/prompts/template_evolver.rb +305 -0
  77. data/lib/aidp/prompts/template_version_manager.rb +325 -0
  78. data/lib/aidp/setup/in_memory_config_adapter.rb +257 -0
  79. data/lib/aidp/setup/in_memory_config_manager.rb +262 -0
  80. data/lib/aidp/setup/wizard.rb +616 -176
  81. data/lib/aidp/shell_executor.rb +58 -0
  82. data/lib/aidp/style_guide/selector.rb +56 -1
  83. data/lib/aidp/temporal/activities/analyze_issue_activity.rb +198 -0
  84. data/lib/aidp/temporal/activities/analyze_sub_task_activity.rb +163 -0
  85. data/lib/aidp/temporal/activities/base_activity.rb +78 -0
  86. data/lib/aidp/temporal/activities/create_plan_activity.rb +115 -0
  87. data/lib/aidp/temporal/activities/create_pr_activity.rb +193 -0
  88. data/lib/aidp/temporal/activities/create_prompt_activity.rb +118 -0
  89. data/lib/aidp/temporal/activities/diagnose_failure_activity.rb +228 -0
  90. data/lib/aidp/temporal/activities/prepare_next_iteration_activity.rb +147 -0
  91. data/lib/aidp/temporal/activities/record_checkpoint_activity.rb +81 -0
  92. data/lib/aidp/temporal/activities/run_agent_activity.rb +139 -0
  93. data/lib/aidp/temporal/activities/run_tests_activity.rb +114 -0
  94. data/lib/aidp/temporal/activities/run_work_loop_iteration_activity.rb +249 -0
  95. data/lib/aidp/temporal/configuration.rb +169 -0
  96. data/lib/aidp/temporal/connection.rb +93 -0
  97. data/lib/aidp/temporal/worker.rb +119 -0
  98. data/lib/aidp/temporal/workflow_client.rb +171 -0
  99. data/lib/aidp/temporal/workflows/base_workflow.rb +73 -0
  100. data/lib/aidp/temporal/workflows/issue_to_pr_workflow.rb +278 -0
  101. data/lib/aidp/temporal/workflows/sub_issue_workflow.rb +253 -0
  102. data/lib/aidp/temporal/workflows/work_loop_workflow.rb +321 -0
  103. data/lib/aidp/temporal.rb +120 -0
  104. data/lib/aidp/version.rb +1 -1
  105. data/lib/aidp/watch/base_processor.rb +35 -0
  106. data/lib/aidp/watch/build_processor.rb +132 -0
  107. data/lib/aidp/watch/github_state_extractor.rb +0 -8
  108. data/lib/aidp/watch/rebase_processor.rb +245 -0
  109. data/lib/aidp/watch/round_robin_scheduler.rb +150 -0
  110. data/lib/aidp/watch/runner.rb +366 -285
  111. data/lib/aidp/watch/state_store.rb +80 -0
  112. data/lib/aidp/watch/work_item.rb +96 -0
  113. data/lib/aidp/watch/worktree_cleanup_job.rb +226 -0
  114. data/lib/aidp/watch/worktree_reconciler.rb +643 -0
  115. data/lib/aidp/workflows/guided_agent.rb +1 -1
  116. data/lib/aidp/worktree.rb +62 -0
  117. data/templates/aidp.yml.example +70 -0
  118. metadata +117 -16
  119. data/lib/aidp/analyze/json_file_storage.rb +0 -292
  120. data/lib/aidp/execute/future_work_backlog.rb +0 -411
  121. data/lib/aidp/providers/adapter.rb +0 -239
  122. data/lib/aidp/providers/aider.rb +0 -264
  123. data/lib/aidp/providers/anthropic.rb +0 -614
  124. data/lib/aidp/providers/base.rb +0 -617
  125. data/lib/aidp/providers/capability_registry.rb +0 -204
  126. data/lib/aidp/providers/codex.rb +0 -267
  127. data/lib/aidp/providers/cursor.rb +0 -304
  128. data/lib/aidp/providers/error_taxonomy.rb +0 -195
  129. data/lib/aidp/providers/gemini.rb +0 -110
  130. data/lib/aidp/providers/github_copilot.rb +0 -227
  131. data/lib/aidp/providers/kilocode.rb +0 -157
  132. data/lib/aidp/providers/opencode.rb +0 -139
@@ -7,6 +7,8 @@ require "time"
7
7
  require "fileutils"
8
8
  require "json"
9
9
  require "ostruct"
10
+ require_relative "in_memory_config_adapter"
11
+ require_relative "in_memory_config_manager"
10
12
 
11
13
  module Aidp
12
14
  module Setup
@@ -104,35 +106,23 @@ module Aidp
104
106
  # Provider configuration
105
107
  # -------------------------------------------
106
108
  def discover_available_providers
107
- providers_dir = File.join(__dir__, "../providers")
108
- provider_files = Dir.glob("*.rb", base: providers_dir)
109
-
110
- # Exclude base classes and non-provider utility files
111
- excluded_files = ["base.rb", "adapter.rb", "capability_registry.rb", "error_taxonomy.rb"]
112
- provider_files -= excluded_files
109
+ require "agent_harness"
113
110
 
114
111
  providers = {}
112
+ registry = AgentHarness::Providers::Registry.instance
115
113
 
116
- provider_files.each do |file|
117
- provider_name = File.basename(file, ".rb")
118
- begin
119
- # Require the provider file if not already loaded
120
- require_relative "../providers/#{provider_name}"
121
-
122
- # Convert to class name (e.g., "anthropic" -> "Anthropic")
123
- class_name = provider_name.split("_").map(&:capitalize).join
124
- provider_class = Aidp::Providers.const_get(class_name)
125
-
126
- # Create a temporary instance to get the display name
127
- if provider_class.respond_to?(:new)
128
- instance = provider_class.new
129
- display_name = instance.respond_to?(:display_name) ? instance.display_name : provider_name.capitalize
130
- providers[display_name] = provider_name
131
- end
132
- rescue => e
133
- # Skip providers that can't be loaded, but don't fail the entire discovery
134
- warn "Warning: Could not load provider #{provider_name}: #{e.message}" if ENV["DEBUG"]
114
+ registry.all.each do |provider_name|
115
+ provider_class = registry.get(provider_name)
116
+
117
+ # Create a temporary instance to get the display name
118
+ if provider_class.respond_to?(:new)
119
+ instance = provider_class.new
120
+ display_name = instance.respond_to?(:display_name) ? instance.display_name : provider_name.to_s.capitalize
121
+ providers[display_name] = provider_name.to_s
135
122
  end
123
+ rescue => e
124
+ # Skip providers that can't be loaded, but don't fail the entire discovery
125
+ warn "Warning: Could not load provider #{provider_name}: #{e.message}" if Aidp.debug_env_enabled?
136
126
  end
137
127
 
138
128
  providers
@@ -156,17 +146,20 @@ module Aidp
156
146
  end
157
147
 
158
148
  # Save primary provider
159
- set([:harness, :default_provider], provider_choice) unless provider_choice == "custom"
149
+ set(%i[harness default_provider], provider_choice) unless provider_choice == "custom"
160
150
 
161
- ensure_provider_billing_config(provider_choice) unless provider_choice == "custom"
151
+ # Always ask for billing config when running interactive wizard (force: true)
152
+ # This ensures users can update billing type when reconfiguring
153
+ ensure_provider_billing_config(provider_choice, force: true) unless provider_choice == "custom"
162
154
 
163
155
  # Prompt for fallback providers (excluding the primary), pre-select existing
164
- existing_fallbacks = Array(get([:harness, :fallback_providers])).map(&:to_s) - [provider_choice]
156
+ existing_fallbacks = Array(get(%i[harness fallback_providers])).map(&:to_s) - [provider_choice]
165
157
  fallback_choices = available_providers.reject { |_, name| name == provider_choice }
166
158
  fallback_default_names = existing_fallbacks.filter_map { |provider_name| fallback_choices.key(provider_name) }
167
159
 
168
160
  prompt.say("\nšŸ’” Use ↑/↓ arrows to navigate, SPACE to select/deselect, ENTER to confirm")
169
- fallback_selected = prompt.multi_select("Select fallback providers (used if primary fails):", default: fallback_default_names) do |menu|
161
+ fallback_selected = prompt.multi_select("Select fallback providers (used if primary fails):",
162
+ default: fallback_default_names) do |menu|
170
163
  fallback_choices.each do |display_name, provider_name|
171
164
  menu.choice display_name, provider_name
172
165
  end
@@ -176,9 +169,7 @@ module Aidp
176
169
  end
177
170
  # Recovery: if multi_select unexpectedly returns empty and there were no existing fallbacks, offer a single-select
178
171
  if fallback_selected.empty? && existing_fallbacks.empty? && !fallback_choices.empty?
179
- if ENV["AIDP_FALLBACK_DEBUG"] == "1"
180
- prompt.say("[debug] invoking recovery single-select for first fallback")
181
- end
172
+ prompt.say("[debug] invoking recovery single-select for first fallback") if ENV["AIDP_FALLBACK_DEBUG"] == "1"
182
173
  if prompt.yes?("No fallback selected. Add one?", default: true)
183
174
  recovery_choice = prompt.select("Select a fallback provider:") do |menu|
184
175
  fallback_choices.each do |display_name, provider_name|
@@ -192,13 +183,14 @@ module Aidp
192
183
 
193
184
  # If user selected none but we had existing fallbacks, confirm removal
194
185
  if fallback_selected.empty? && existing_fallbacks.any?
195
- keep = prompt.no?("No fallbacks selected. Remove existing fallbacks (#{existing_fallbacks.join(", ")})?", default: false)
186
+ keep = prompt.no?("No fallbacks selected. Remove existing fallbacks (#{existing_fallbacks.join(", ")})?",
187
+ default: false)
196
188
  fallback_selected = existing_fallbacks if keep
197
189
  end
198
190
 
199
191
  # Remove any accidental duplication of primary provider & save (preserve order)
200
192
  cleaned_fallbacks = fallback_selected.reject { |name| name == provider_choice }
201
- set([:harness, :fallback_providers], cleaned_fallbacks)
193
+ set(%i[harness fallback_providers], cleaned_fallbacks)
202
194
 
203
195
  # Auto-create minimal provider configs for fallbacks if missing
204
196
  cleaned_fallbacks.each do |fp|
@@ -215,16 +207,18 @@ module Aidp
215
207
  loop do
216
208
  remaining = available_providers.reject { |_, name| ([provider_choice] + cleaned_fallbacks).include?(name) }
217
209
  break if remaining.empty?
210
+
218
211
  add_choice = prompt.select("Select additional fallback provider:") do |menu|
219
212
  remaining.each { |display, name| menu.choice display, name }
220
213
  menu.choice "Done", :done
221
214
  end
222
215
  break if add_choice == :done
223
- unless cleaned_fallbacks.include?(add_choice)
224
- cleaned_fallbacks << add_choice
225
- set([:harness, :fallback_providers], cleaned_fallbacks)
226
- ensure_provider_billing_config(add_choice, force: true)
227
- end
216
+
217
+ next if cleaned_fallbacks.include?(add_choice)
218
+
219
+ cleaned_fallbacks << add_choice
220
+ set(%i[harness fallback_providers], cleaned_fallbacks)
221
+ ensure_provider_billing_config(add_choice, force: true)
228
222
  end
229
223
  end
230
224
  # Recompute editable after additions
@@ -241,9 +235,7 @@ module Aidp
241
235
  end
242
236
  # Sentinel option: add a new fallback provider that isn't yet in editable list
243
237
  remaining = available_map.values - editable
244
- if remaining.any?
245
- menu.choice "āž• Add fallback provider…", :add_fallback
246
- end
238
+ menu.choice "āž• Add fallback provider…", :add_fallback if remaining.any?
247
239
  menu.choice "Done", :done
248
240
  end
249
241
 
@@ -252,16 +244,21 @@ module Aidp
252
244
  break
253
245
  when :add_fallback
254
246
  # Allow user to pick from remaining providers by display name
255
- remaining_map = available_map.select { |disp, internal| !editable.include?(internal) && internal != provider_choice }
247
+ remaining_map = available_map.select do |disp, internal|
248
+ !editable.include?(internal) && internal != provider_choice
249
+ end
256
250
  add_choice = prompt.select("Select provider to add as fallback:") do |menu|
257
251
  remaining_map.each { |disp, internal| menu.choice disp, internal }
258
252
  menu.choice "Cancel", :cancel
259
253
  end
260
254
  next if add_choice == :cancel
255
+
261
256
  unless cleaned_fallbacks.include?(add_choice)
262
257
  cleaned_fallbacks << add_choice
263
- set([:harness, :fallback_providers], cleaned_fallbacks)
264
- prompt.say("[debug] ensuring billing config for newly added fallback '#{add_choice}'") if ENV["AIDP_FALLBACK_DEBUG"] == "1"
258
+ set(%i[harness fallback_providers], cleaned_fallbacks)
259
+ if ENV["AIDP_FALLBACK_DEBUG"] == "1"
260
+ prompt.say("[debug] ensuring billing config for newly added fallback '#{add_choice}'")
261
+ end
265
262
  ensure_provider_billing_config(add_choice, force: true)
266
263
  editable = ([provider_choice] + cleaned_fallbacks).uniq.reject { |p| p == "custom" }
267
264
  end
@@ -296,7 +293,7 @@ module Aidp
296
293
  (existing[:max_retries] || 2).to_s
297
294
  ) { |value| value.to_i }
298
295
 
299
- set([:harness, :max_retries], max_retries)
296
+ set(%i[harness max_retries], max_retries)
300
297
  end
301
298
 
302
299
  # Removed MCP configuration step (MCP now expected to be provider-specific if used)
@@ -309,8 +306,8 @@ module Aidp
309
306
  prompt.say("-" * 40)
310
307
 
311
308
  # Get configured providers
312
- primary_provider = get([:harness, :default_provider])
313
- fallback_providers = Array(get([:harness, :fallback_providers]))
309
+ primary_provider = get(%i[harness default_provider])
310
+ fallback_providers = Array(get(%i[harness fallback_providers]))
314
311
  all_providers = ([primary_provider] + fallback_providers).compact.uniq
315
312
 
316
313
  if all_providers.empty?
@@ -326,9 +323,7 @@ module Aidp
326
323
 
327
324
  if has_existing_tiers
328
325
  prompt.say("šŸ“ Found existing tier configuration")
329
- unless prompt.yes?("Would you like to update it with discovered models?", default: false)
330
- return
331
- end
326
+ return unless prompt.yes?("Would you like to update it with discovered models?", default: false)
332
327
  elsif !prompt.yes?("Auto-configure thinking tiers with discovered models?", default: true)
333
328
  prompt.say("šŸ’” You can run 'aidp models discover' later to see available models")
334
329
  return
@@ -432,11 +427,11 @@ module Aidp
432
427
  tier_models = find_models_for_tier(models, tier)
433
428
 
434
429
  # Add to config if we found any models for this tier
435
- if tier_models.any?
436
- provider_tiers[tier.to_sym] = {
437
- models: tier_models.map { |m| m[:name] }
438
- }
439
- end
430
+ next unless tier_models.any?
431
+
432
+ provider_tiers[tier.to_sym] = {
433
+ models: tier_models.map { |m| m[:name] }
434
+ }
440
435
  end
441
436
 
442
437
  # Only add provider if it has at least one tier configured
@@ -489,8 +484,7 @@ module Aidp
489
484
 
490
485
  configure_work_loop_limits
491
486
  configure_output_filtering
492
- configure_test_commands
493
- configure_linting
487
+ configure_commands
494
488
  configure_watch_patterns
495
489
  configure_guards
496
490
  configure_coverage
@@ -498,6 +492,208 @@ module Aidp
498
492
  configure_vcs_behavior
499
493
  end
500
494
 
495
+ # Configure generic deterministic commands for work loop
496
+ # Replaces the old category-specific configure_test_commands and configure_linting
497
+ def configure_commands
498
+ prompt.say("\nšŸ“‹ Deterministic Commands Configuration")
499
+ prompt.say(" Commands run automatically during work loops to validate changes")
500
+ prompt.say(" Commands can run: after each unit, at full loop end, or on completion")
501
+
502
+ existing_commands = get(%i[work_loop commands]) || []
503
+
504
+ # If user has existing commands, offer to edit or start fresh
505
+ if existing_commands.any?
506
+ prompt.say("\n Found #{existing_commands.size} existing command(s)")
507
+ action = prompt.select("What would you like to do?") do |menu|
508
+ menu.choice "Keep existing commands", :keep
509
+ menu.choice "Add more commands", :add
510
+ menu.choice "Replace all commands", :replace
511
+ menu.choice "Skip command configuration", :skip
512
+ end
513
+
514
+ case action
515
+ when :skip, :keep, nil
516
+ return
517
+ when :replace
518
+ existing_commands = []
519
+ when :add
520
+ # Continue to the loop below with existing commands
521
+ else
522
+ # Unexpected value - treat as skip for safety
523
+ return
524
+ end
525
+ else
526
+ return unless prompt.yes?("Configure deterministic commands?", default: true)
527
+ end
528
+
529
+ commands = existing_commands.dup
530
+ loop do
531
+ prompt.say("\n Current commands: #{commands.size}")
532
+ commands.each_with_index do |cmd, i|
533
+ prompt.say(" #{i + 1}. [#{cmd[:category]}] #{cmd[:name]}: #{cmd[:command]}")
534
+ end
535
+
536
+ action = prompt.select("\nWhat would you like to do?") do |menu|
537
+ # Done is first so tests default to exiting the loop
538
+ menu.choice "Done configuring commands", :done
539
+ menu.choice "Add a command", :add
540
+ menu.choice "Add from auto-detected tooling", :detect
541
+ menu.choice "Remove a command", :remove if commands.any?
542
+ end
543
+
544
+ case action
545
+ when :add
546
+ cmd = collect_command_details
547
+ commands << cmd if cmd
548
+ when :detect
549
+ detected = add_detected_commands(commands)
550
+ commands = detected
551
+ when :remove
552
+ commands = remove_command_interactive(commands)
553
+ when :done, nil
554
+ # Exit on explicit done or unexpected nil (safety for tests)
555
+ break
556
+ end
557
+ end
558
+
559
+ set(%i[work_loop commands], commands)
560
+ prompt.say("āœ… Configured #{commands.size} command(s)")
561
+ end
562
+
563
+ # Collect details for a single command
564
+ def collect_command_details
565
+ prompt.say("\n Adding new command:")
566
+
567
+ name = prompt.ask("Command name (identifier):", required: true) do |q|
568
+ q.validate(/\A[a-z0-9_]+\z/i, "Name must be alphanumeric with underscores")
569
+ end
570
+
571
+ command = prompt.ask("Shell command to run:", required: true)
572
+
573
+ category_choices = [
574
+ ["Test (unit, integration, e2e)", :test],
575
+ ["Lint (code style)", :lint],
576
+ ["Formatter (auto-fix)", :formatter],
577
+ ["Build (compilation)", :build],
578
+ ["Documentation", :documentation],
579
+ ["Custom", :custom]
580
+ ]
581
+ category = prompt.select("Category:") do |menu|
582
+ category_choices.each { |label, value| menu.choice label, value }
583
+ end
584
+
585
+ run_after_choices = [
586
+ ["After each work unit iteration (recommended for tests/linters)", :each_unit],
587
+ ["Only at end of full work loop", :full_loop],
588
+ ["Only when agent marks work complete (recommended for formatters)", :on_completion]
589
+ ]
590
+ run_after = prompt.select("When should this command run?") do |menu|
591
+ run_after_choices.each { |label, value| menu.choice label, value }
592
+ end
593
+
594
+ required = prompt.yes?("Is this command required to pass? (failures block completion)", default: true)
595
+
596
+ {
597
+ name: name,
598
+ command: command,
599
+ category: category,
600
+ run_after: run_after,
601
+ required: required,
602
+ timeout_seconds: nil
603
+ }
604
+ end
605
+
606
+ # Add auto-detected commands
607
+ def add_detected_commands(existing_commands)
608
+ detected = detect_all_tooling
609
+ return existing_commands if detected.empty?
610
+
611
+ prompt.say("\n Auto-detected tooling:")
612
+ detected.each_with_index do |cmd, i|
613
+ prompt.say(" #{i + 1}. [#{cmd[:category]}] #{cmd[:command]}")
614
+ end
615
+
616
+ selected = prompt.multi_select("Select commands to add:") do |menu|
617
+ detected.each_with_index do |cmd, i|
618
+ # Skip if already exists
619
+ existing = existing_commands.any? { |e| e[:command] == cmd[:command] }
620
+ label = "[#{cmd[:category]}] #{cmd[:command]}"
621
+ label += " (already added)" if existing
622
+ menu.choice label, i, disabled: existing ? "(already added)" : nil
623
+ end
624
+ end
625
+
626
+ new_commands = existing_commands.dup
627
+ selected.each do |idx|
628
+ new_commands << detected[idx]
629
+ end
630
+ new_commands
631
+ end
632
+
633
+ # Detect all tooling and return as generic command configs
634
+ def detect_all_tooling
635
+ commands = []
636
+
637
+ # Detect test command
638
+ test_cmd = detect_unit_test_command
639
+ if test_cmd && !test_cmd.empty?
640
+ commands << {
641
+ name: "unit_tests",
642
+ command: test_cmd,
643
+ category: :test,
644
+ run_after: :each_unit,
645
+ required: true,
646
+ timeout_seconds: 1800
647
+ }
648
+ end
649
+
650
+ # Detect lint command
651
+ lint_cmd = detect_lint_command
652
+ if lint_cmd && !lint_cmd.empty?
653
+ commands << {
654
+ name: "lint",
655
+ command: lint_cmd,
656
+ category: :lint,
657
+ run_after: :each_unit,
658
+ required: true,
659
+ timeout_seconds: 300
660
+ }
661
+ end
662
+
663
+ # Detect format command
664
+ format_cmd = detect_format_command
665
+ if format_cmd && !format_cmd.empty?
666
+ commands << {
667
+ name: "format",
668
+ command: format_cmd,
669
+ category: :formatter,
670
+ run_after: :on_completion,
671
+ required: false,
672
+ timeout_seconds: 120
673
+ }
674
+ end
675
+
676
+ commands
677
+ end
678
+
679
+ # Remove a command interactively
680
+ def remove_command_interactive(commands)
681
+ return commands if commands.empty?
682
+
683
+ choices = commands.each_with_index.map do |cmd, i|
684
+ ["#{cmd[:name]}: #{cmd[:command]}", i]
685
+ end
686
+
687
+ idx = prompt.select("Select command to remove:") do |menu|
688
+ choices.each { |label, value| menu.choice label, value }
689
+ menu.choice "Cancel", nil
690
+ end
691
+
692
+ return commands if idx.nil?
693
+
694
+ commands.dup.tap { |c| c.delete_at(idx) }
695
+ end
696
+
501
697
  def configure_work_loop_limits
502
698
  existing = get([:work_loop]) || {}
503
699
 
@@ -508,13 +704,13 @@ module Aidp
508
704
  (existing[:max_iterations] || 50).to_s
509
705
  ) { |value| value.to_i }
510
706
 
511
- set([:work_loop, :max_iterations], max_iterations)
707
+ set(%i[work_loop max_iterations], max_iterations)
512
708
  end
513
709
 
514
710
  def configure_output_filtering
515
711
  prompt.say("\nšŸ” Output filtering configuration")
516
712
  prompt.say(" Reduces token consumption by filtering test/lint output")
517
- existing = get([:work_loop, :output_filtering]) || {}
713
+ existing = get(%i[work_loop output_filtering]) || {}
518
714
 
519
715
  return unless prompt.yes?("Configure output filtering?", default: false)
520
716
 
@@ -524,7 +720,7 @@ module Aidp
524
720
  )
525
721
 
526
722
  unless enabled
527
- set([:work_loop, :output_filtering], {enabled: false})
723
+ set(%i[work_loop output_filtering], {enabled: false})
528
724
  return
529
725
  end
530
726
 
@@ -576,7 +772,7 @@ module Aidp
576
772
  0
577
773
  end
578
774
 
579
- set([:work_loop, :output_filtering], {
775
+ set(%i[work_loop output_filtering], {
580
776
  enabled: true,
581
777
  test_mode: test_mode,
582
778
  lint_mode: lint_mode,
@@ -611,7 +807,7 @@ module Aidp
611
807
  end
612
808
 
613
809
  # Check if AI provider is configured
614
- primary_provider = get([:harness, :default_provider])
810
+ primary_provider = get(%i[harness default_provider])
615
811
  unless primary_provider
616
812
  prompt.warn("āš ļø No AI provider configured. Configure providers first.")
617
813
  return
@@ -655,18 +851,18 @@ module Aidp
655
851
  end
656
852
  end
657
853
 
658
- if filter_definitions.any?
659
- set([:work_loop, :output_filtering, :filter_definitions], filter_definitions)
660
- prompt.ok("\nāœ… Generated #{filter_definitions.size} filter definition(s)")
661
- prompt.say(" These filters will be applied deterministically (no AI calls at runtime)")
662
- end
854
+ return unless filter_definitions.any?
855
+
856
+ set(%i[work_loop output_filtering filter_definitions], filter_definitions)
857
+ prompt.ok("\nāœ… Generated #{filter_definitions.size} filter definition(s)")
858
+ prompt.say(" These filters will be applied deterministically (no AI calls at runtime)")
663
859
  end
664
860
 
665
861
  def collect_commands_for_filtering
666
862
  commands = []
667
863
 
668
864
  # Test commands
669
- test_config = get([:work_loop, :test]) || {}
865
+ test_config = get(%i[work_loop test]) || {}
670
866
  if test_config[:unit] && !test_config[:unit].start_with?("echo")
671
867
  commands << {
672
868
  key: "unit_test",
@@ -693,7 +889,7 @@ module Aidp
693
889
  end
694
890
 
695
891
  # Lint commands
696
- lint_config = get([:work_loop, :lint]) || {}
892
+ lint_config = get(%i[work_loop lint]) || {}
697
893
  if lint_config[:command] && !lint_config[:command].start_with?("echo")
698
894
  commands << {
699
895
  key: "lint",
@@ -707,30 +903,43 @@ module Aidp
707
903
  end
708
904
 
709
905
  def create_filter_factory
710
- # Build a minimal configuration for the factory
711
- config = build_harness_config_for_factory
712
- Aidp::Harness::AIFilterFactory.new(config)
906
+ # Build in-memory configuration adapters for the factory
907
+ # This enables AGD to work before the config file is written
908
+ config_adapter = build_in_memory_config_adapter
909
+ config_manager = build_in_memory_config_manager
910
+
911
+ # Create provider factory with in-memory config manager
912
+ provider_factory = Aidp::Harness::ProviderFactory.new(config_manager)
913
+
914
+ Aidp.log_debug("setup_wizard", "creating_filter_factory",
915
+ provider: config_adapter.default_provider,
916
+ configured_providers: config_adapter.configured_providers)
917
+
918
+ Aidp::Harness::AIFilterFactory.new(config_adapter, provider_factory: provider_factory)
713
919
  end
714
920
 
715
- def build_harness_config_for_factory
716
- # Create a minimal config object that the factory needs
717
- OpenStruct.new(
718
- default_provider: get([:harness, :default_provider]),
719
- providers: get([:providers]) || {},
720
- thinking_tiers: get([:thinking, :tiers])
721
- )
921
+ def build_in_memory_config_adapter
922
+ # Create adapter that provides Configuration-like interface from in-memory config
923
+ InMemoryConfigAdapter.new(@config, project_dir)
924
+ end
925
+
926
+ def build_in_memory_config_manager
927
+ # Create ConfigManager-compatible wrapper for in-memory config
928
+ InMemoryConfigManager.new(@config, project_dir)
722
929
  end
723
930
 
724
931
  def configure_test_commands
725
- existing = get([:work_loop, :test]) || {}
932
+ existing = get(%i[work_loop test]) || {}
726
933
 
727
934
  unit = ask_with_default("Unit test command", existing[:unit] || detect_unit_test_command)
728
935
  integration = ask_with_default("Integration test command", existing[:integration])
729
936
  e2e = ask_with_default("End-to-end test command", existing[:e2e])
730
937
 
731
- timeout = ask_with_default("Test timeout (seconds)", (existing[:timeout_seconds] || 1800).to_s) { |value| value.to_i }
938
+ timeout = ask_with_default("Test timeout (seconds)", (existing[:timeout_seconds] || 1800).to_s) do |value|
939
+ value.to_i
940
+ end
732
941
 
733
- set([:work_loop, :test], {
942
+ set(%i[work_loop test], {
734
943
  unit: unit,
735
944
  integration: integration,
736
945
  e2e: e2e,
@@ -740,16 +949,48 @@ module Aidp
740
949
  validate_command(unit)
741
950
  validate_command(integration)
742
951
  validate_command(e2e)
952
+
953
+ # Check RSpec persistence configuration for --only-failures optimization
954
+ check_rspec_persistence_configuration(unit, integration, e2e)
955
+ end
956
+
957
+ # Validates RSpec persistence configuration if RSpec is detected
958
+ # Warns user if example_status_persistence_file_path is not configured
959
+ def check_rspec_persistence_configuration(*commands)
960
+ rspec_commands = commands.compact.select { |cmd| cmd.to_s.downcase.include?("rspec") }
961
+ return if rspec_commands.empty?
962
+
963
+ # Check if spec_helper.rb has persistence configured
964
+ require_relative "../harness/rspec_command_optimizer"
965
+ optimizer = Aidp::Harness::RSpecCommandOptimizer.new(project_dir)
966
+ config_check = optimizer.check_persistence_configuration
967
+
968
+ if config_check[:configured]
969
+ prompt.ok("āœ… RSpec persistence configured for --only-failures optimization")
970
+ else
971
+ prompt.warn("\nāš ļø RSpec --only-failures optimization not available")
972
+ prompt.say(" The work loop can run significantly faster with --only-failures on subsequent iterations.")
973
+ prompt.say(" To enable, add this to your spec/spec_helper.rb:")
974
+ prompt.say("")
975
+ prompt.say(" RSpec.configure do |config|")
976
+ prompt.say(" config.example_status_persistence_file_path = '.rspec_status'")
977
+ prompt.say(" end")
978
+ prompt.say("")
979
+ prompt.say(" Also add .rspec_status to your .gitignore")
980
+
981
+ Aidp.log_info("setup_wizard", "rspec_persistence_missing",
982
+ message: "RSpec --only-failures optimization not available")
983
+ end
743
984
  end
744
985
 
745
986
  def configure_linting
746
- existing = get([:work_loop, :lint]) || {}
987
+ existing = get(%i[work_loop lint]) || {}
747
988
 
748
989
  lint_cmd = ask_with_default("Lint command", existing[:command] || detect_lint_command)
749
990
  format_cmd = ask_with_default("Format command", existing[:format] || detect_format_command)
750
991
  autofix = prompt.yes?("Run formatter automatically?", default: existing.fetch(:autofix, false))
751
992
 
752
- set([:work_loop, :lint], {
993
+ set(%i[work_loop lint], {
753
994
  command: lint_cmd,
754
995
  format: format_cmd,
755
996
  autofix: autofix
@@ -760,23 +1001,29 @@ module Aidp
760
1001
  end
761
1002
 
762
1003
  def configure_watch_patterns
763
- existing = get([:work_loop, :test, :watch]) || {}
1004
+ existing = get(%i[work_loop test watch]) || {}
764
1005
  default_patterns = detect_watch_patterns
765
1006
 
766
1007
  watch_patterns = ask_list("Test watch patterns (comma-separated)", existing.fetch(:patterns, default_patterns))
767
- set([:work_loop, :test, :watch], {patterns: watch_patterns}) if watch_patterns.any?
1008
+ set(%i[work_loop test watch], {patterns: watch_patterns}) if watch_patterns.any?
768
1009
  end
769
1010
 
770
1011
  def configure_guards
771
- existing = get([:work_loop, :guards]) || {}
1012
+ existing = get(%i[work_loop guards]) || {}
772
1013
 
773
1014
  include_patterns = ask_list("Guard include patterns", existing[:include] || detect_source_patterns)
774
- exclude_patterns = ask_list("Guard exclude patterns", existing[:exclude] || ["node_modules/**", "dist/**", "build/**"])
775
- max_lines = ask_with_default("Max lines changed per commit", (existing[:max_lines_changed_per_commit] || 300).to_s) { |value| value.to_i }
776
- protected_paths = ask_list("Protected paths (require confirmation)", existing[:protected_paths] || [], allow_empty: true)
777
- confirmation_required = prompt.yes?("Require confirmation before editing protected paths?", default: existing.fetch(:confirm_protected, true))
778
-
779
- set([:work_loop, :guards], {
1015
+ exclude_patterns = ask_list("Guard exclude patterns",
1016
+ existing[:exclude] || ["node_modules/**", "dist/**", "build/**"])
1017
+ max_lines = ask_with_default("Max lines changed per commit",
1018
+ (existing[:max_lines_changed_per_commit] || 300).to_s) do |value|
1019
+ value.to_i
1020
+ end
1021
+ protected_paths = ask_list("Protected paths (require confirmation)", existing[:protected_paths] || [],
1022
+ allow_empty: true)
1023
+ confirmation_required = prompt.yes?("Require confirmation before editing protected paths?",
1024
+ default: existing.fetch(:confirm_protected, true))
1025
+
1026
+ set(%i[work_loop guards], {
780
1027
  include: include_patterns,
781
1028
  exclude: exclude_patterns,
782
1029
  max_lines_changed_per_commit: max_lines,
@@ -787,10 +1034,10 @@ module Aidp
787
1034
 
788
1035
  def configure_coverage
789
1036
  prompt.say("\nšŸ“Š Coverage configuration")
790
- existing = get([:work_loop, :coverage]) || {}
1037
+ existing = get(%i[work_loop coverage]) || {}
791
1038
 
792
1039
  enabled = prompt.yes?("Enable coverage tracking?", default: existing.fetch(:enabled, false))
793
- return set([:work_loop, :coverage], {enabled: false}) unless enabled
1040
+ return set(%i[work_loop coverage], {enabled: false}) unless enabled
794
1041
 
795
1042
  coverage_tool_choices = [
796
1043
  ["SimpleCov (Ruby)", "simplecov"],
@@ -801,7 +1048,9 @@ module Aidp
801
1048
  ["Other", "other"]
802
1049
  ]
803
1050
  coverage_tool_default = existing[:tool]
804
- coverage_tool_default_label = coverage_tool_choices.find { |label, value| value == coverage_tool_default }&.first
1051
+ coverage_tool_default_label = coverage_tool_choices.find do |label, value|
1052
+ value == coverage_tool_default
1053
+ end&.first
805
1054
 
806
1055
  tool = prompt.select("Which coverage tool do you use?", default: coverage_tool_default_label) do |menu|
807
1056
  coverage_tool_choices.each { |label, value| menu.choice label, value }
@@ -812,12 +1061,13 @@ module Aidp
812
1061
  fail_on_drop = prompt.yes?("Fail on coverage drop?", default: existing.fetch(:fail_on_drop, false))
813
1062
 
814
1063
  minimum_coverage_default = existing[:minimum_coverage]&.to_s
815
- minimum_coverage_answer = ask_with_default("Minimum coverage % (optional - press enter to skip)", minimum_coverage_default)
1064
+ minimum_coverage_answer = ask_with_default("Minimum coverage % (optional - press enter to skip)",
1065
+ minimum_coverage_default)
816
1066
  minimum_coverage = if minimum_coverage_answer && !minimum_coverage_answer.to_s.strip.empty?
817
1067
  minimum_coverage_answer.to_f
818
1068
  end
819
1069
 
820
- set([:work_loop, :coverage], {
1070
+ set(%i[work_loop coverage], {
821
1071
  enabled: true,
822
1072
  tool: tool,
823
1073
  run_command: run_command,
@@ -831,10 +1081,10 @@ module Aidp
831
1081
 
832
1082
  def configure_interactive_testing
833
1083
  prompt.say("\nšŸŽÆ Interactive testing configuration")
834
- existing = get([:work_loop, :interactive_testing]) || {}
1084
+ existing = get(%i[work_loop interactive_testing]) || {}
835
1085
 
836
1086
  enabled = prompt.yes?("Enable interactive testing tools?", default: existing.fetch(:enabled, false))
837
- return set([:work_loop, :interactive_testing], {enabled: false}) unless enabled
1087
+ return set(%i[work_loop interactive_testing], {enabled: false}) unless enabled
838
1088
 
839
1089
  app_type_choices = [
840
1090
  ["Web application", "web"],
@@ -859,7 +1109,7 @@ module Aidp
859
1109
  tools[:desktop] = configure_desktop_testing_tools(existing.dig(:tools, :desktop) || {})
860
1110
  end
861
1111
 
862
- set([:work_loop, :interactive_testing], {
1112
+ set(%i[work_loop interactive_testing], {
863
1113
  enabled: true,
864
1114
  app_type: app_type,
865
1115
  tools: tools
@@ -869,17 +1119,22 @@ module Aidp
869
1119
  def configure_web_testing_tools(existing)
870
1120
  tools = {}
871
1121
 
872
- playwright_enabled = prompt.yes?("Enable Playwright MCP?", default: existing.dig(:playwright_mcp, :enabled) || false)
1122
+ playwright_enabled = prompt.yes?("Enable Playwright MCP?",
1123
+ default: existing.dig(:playwright_mcp, :enabled) || false)
873
1124
  if playwright_enabled
874
- playwright_run = ask_with_default("Playwright run command", existing.dig(:playwright_mcp, :run) || "npx playwright test")
875
- playwright_specs = ask_with_default("Playwright specs directory", existing.dig(:playwright_mcp, :specs_dir) || ".aidp/tests/web")
1125
+ playwright_run = ask_with_default("Playwright run command",
1126
+ existing.dig(:playwright_mcp, :run) || "npx playwright test")
1127
+ playwright_specs = ask_with_default("Playwright specs directory",
1128
+ existing.dig(:playwright_mcp, :specs_dir) || ".aidp/tests/web")
876
1129
  tools[:playwright_mcp] = {enabled: true, run: playwright_run, specs_dir: playwright_specs}
877
1130
  end
878
1131
 
879
- chrome_enabled = prompt.yes?("Enable Chrome DevTools MCP?", default: existing.dig(:chrome_devtools_mcp, :enabled) || false)
1132
+ chrome_enabled = prompt.yes?("Enable Chrome DevTools MCP?",
1133
+ default: existing.dig(:chrome_devtools_mcp, :enabled) || false)
880
1134
  if chrome_enabled
881
1135
  chrome_run = ask_with_default("Chrome DevTools run command", existing.dig(:chrome_devtools_mcp, :run) || "")
882
- chrome_specs = ask_with_default("Chrome DevTools specs directory", existing.dig(:chrome_devtools_mcp, :specs_dir) || ".aidp/tests/web")
1136
+ chrome_specs = ask_with_default("Chrome DevTools specs directory",
1137
+ existing.dig(:chrome_devtools_mcp, :specs_dir) || ".aidp/tests/web")
883
1138
  tools[:chrome_devtools_mcp] = {enabled: true, run: chrome_run, specs_dir: chrome_specs}
884
1139
  end
885
1140
 
@@ -891,8 +1146,10 @@ module Aidp
891
1146
 
892
1147
  expect_enabled = prompt.yes?("Enable expect scripts?", default: existing.dig(:expect, :enabled) || false)
893
1148
  if expect_enabled
894
- expect_run = ask_with_default("Expect run command", existing.dig(:expect, :run) || "expect .aidp/tests/cli/smoke.exp")
895
- expect_specs = ask_with_default("Expect specs directory", existing.dig(:expect, :specs_dir) || ".aidp/tests/cli")
1149
+ expect_run = ask_with_default("Expect run command",
1150
+ existing.dig(:expect, :run) || "expect .aidp/tests/cli/smoke.exp")
1151
+ expect_specs = ask_with_default("Expect specs directory",
1152
+ existing.dig(:expect, :specs_dir) || ".aidp/tests/cli")
896
1153
  tools[:expect] = {enabled: true, run: expect_run, specs_dir: expect_specs}
897
1154
  end
898
1155
 
@@ -902,16 +1159,22 @@ module Aidp
902
1159
  def configure_desktop_testing_tools(existing)
903
1160
  tools = {}
904
1161
 
905
- applescript_enabled = prompt.yes?("Enable AppleScript testing?", default: existing.dig(:applescript, :enabled) || false)
1162
+ applescript_enabled = prompt.yes?("Enable AppleScript testing?",
1163
+ default: existing.dig(:applescript, :enabled) || false)
906
1164
  if applescript_enabled
907
- applescript_run = ask_with_default("AppleScript run command", existing.dig(:applescript, :run) || "osascript .aidp/tests/desktop/smoke.scpt")
908
- applescript_specs = ask_with_default("AppleScript specs directory", existing.dig(:applescript, :specs_dir) || ".aidp/tests/desktop")
1165
+ applescript_run = ask_with_default("AppleScript run command",
1166
+ existing.dig(:applescript,
1167
+ :run) || "osascript .aidp/tests/desktop/smoke.scpt")
1168
+ applescript_specs = ask_with_default("AppleScript specs directory",
1169
+ existing.dig(:applescript, :specs_dir) || ".aidp/tests/desktop")
909
1170
  tools[:applescript] = {enabled: true, run: applescript_run, specs_dir: applescript_specs}
910
1171
  end
911
1172
 
912
- screen_reader_enabled = prompt.yes?("Enable screen reader testing?", default: existing.dig(:screen_reader, :enabled) || false)
1173
+ screen_reader_enabled = prompt.yes?("Enable screen reader testing?",
1174
+ default: existing.dig(:screen_reader, :enabled) || false)
913
1175
  if screen_reader_enabled
914
- screen_reader_notes = ask_with_default("Screen reader testing notes (optional)", existing.dig(:screen_reader, :notes) || "VoiceOver scripted checks")
1176
+ screen_reader_notes = ask_with_default("Screen reader testing notes (optional)",
1177
+ existing.dig(:screen_reader, :notes) || "VoiceOver scripted checks")
915
1178
  tools[:screen_reader] = {enabled: true, notes: screen_reader_notes}
916
1179
  end
917
1180
 
@@ -920,7 +1183,7 @@ module Aidp
920
1183
 
921
1184
  def configure_vcs_behavior
922
1185
  prompt.say("\nšŸ—‚ļø Version control configuration")
923
- existing = get([:work_loop, :version_control]) || {}
1186
+ existing = get(%i[work_loop version_control]) || {}
924
1187
 
925
1188
  # Detect VCS
926
1189
  detected_vcs = detect_vcs_tool
@@ -933,7 +1196,8 @@ module Aidp
933
1196
  vcs_default_label = vcs_choices.find { |label, value| value == vcs_default }&.first
934
1197
 
935
1198
  vcs_tool = if detected_vcs
936
- prompt.select("Detected #{detected_vcs}. Use this version control system?", default: vcs_default_label) do |menu|
1199
+ prompt.select("Detected #{detected_vcs}. Use this version control system?",
1200
+ default: vcs_default_label) do |menu|
937
1201
  vcs_choices.each { |label, value| menu.choice label, value }
938
1202
  end
939
1203
  else
@@ -942,7 +1206,7 @@ module Aidp
942
1206
  end
943
1207
  end
944
1208
 
945
- return set([:work_loop, :version_control], {tool: "none", behavior: "nothing"}) if vcs_tool == "none"
1209
+ return set(%i[work_loop version_control], {tool: "none", behavior: "nothing"}) if vcs_tool == "none"
946
1210
 
947
1211
  prompt.say("\nšŸ“‹ Commit Behavior (applies to copilot/interactive mode only)")
948
1212
  prompt.say("Note: Watch mode and fully automatic daemon mode will always commit changes.")
@@ -970,7 +1234,7 @@ module Aidp
970
1234
  {auto_create_pr: false}
971
1235
  end
972
1236
 
973
- set([:work_loop, :version_control], {
1237
+ set(%i[work_loop version_control], {
974
1238
  tool: vcs_tool,
975
1239
  behavior: behavior,
976
1240
  **commit_config,
@@ -997,7 +1261,9 @@ module Aidp
997
1261
  ["Emoji (e.g., '✨ feat: add user authentication')", "emoji"]
998
1262
  ]
999
1263
  commit_style_default = existing[:commit_style] || "default"
1000
- commit_style_default_label = commit_style_choices.find { |label, value| value == commit_style_default }&.first
1264
+ commit_style_default_label = commit_style_choices.find do |label, value|
1265
+ value == commit_style_default
1266
+ end&.first
1001
1267
 
1002
1268
  prompt.select("Conventional commit style:", default: commit_style_default_label) do |menu|
1003
1269
  commit_style_choices.each { |label, value| menu.choice label, value }
@@ -1060,13 +1326,14 @@ module Aidp
1060
1326
  def configure_branching
1061
1327
  prompt.say("\n🌿 Branching strategy")
1062
1328
  prompt.say("-" * 40)
1063
- existing = get([:work_loop, :branching]) || {}
1329
+ existing = get(%i[work_loop branching]) || {}
1064
1330
 
1065
1331
  prefix = ask_with_default("Branch prefix for work loops", existing[:prefix] || "aidp")
1066
- slug_format = ask_with_default("Slug format (use %{id} and %{title})", existing[:slug_format] || "issue-%{id}-%{title}")
1067
- checkpoint_tag = ask_with_default("Checkpoint tag template", existing[:checkpoint_tag] || "aidp-start/%{id}")
1332
+ slug_format = ask_with_default("Slug format (use %<id>s and %<title>s)",
1333
+ existing[:slug_format] || "issue-%<id>s-%<title>s")
1334
+ checkpoint_tag = ask_with_default("Checkpoint tag template", existing[:checkpoint_tag] || "aidp-start/%<id>s")
1068
1335
 
1069
- set([:work_loop, :branching], {
1336
+ set(%i[work_loop branching], {
1070
1337
  prefix: prefix,
1071
1338
  slug_format: slug_format,
1072
1339
  checkpoint_tag: checkpoint_tag
@@ -1076,13 +1343,13 @@ module Aidp
1076
1343
  def configure_artifacts
1077
1344
  prompt.say("\nšŸ“ Artifact storage")
1078
1345
  prompt.say("-" * 40)
1079
- existing = get([:work_loop, :artifacts]) || {}
1346
+ existing = get(%i[work_loop artifacts]) || {}
1080
1347
 
1081
1348
  evidence_dir = ask_with_default("Evidence pack directory", existing[:evidence_dir] || ".aidp/evidence")
1082
1349
  logs_dir = ask_with_default("Logs directory", existing[:logs_dir] || ".aidp/logs")
1083
1350
  screenshots_dir = ask_with_default("Screenshots directory", existing[:screenshots_dir] || ".aidp/screenshots")
1084
1351
 
1085
- set([:work_loop, :artifacts], {
1352
+ set(%i[work_loop artifacts], {
1086
1353
  evidence_dir: evidence_dir,
1087
1354
  logs_dir: logs_dir,
1088
1355
  screenshots_dir: screenshots_dir
@@ -1104,10 +1371,10 @@ module Aidp
1104
1371
 
1105
1372
  unless configure
1106
1373
  Aidp.log_debug("setup_wizard.nfrs", "opt_out")
1107
- return set([:nfrs, :configure], false)
1374
+ return set(%i[nfrs configure], false)
1108
1375
  end
1109
1376
 
1110
- set([:nfrs, :configure], true)
1377
+ set(%i[nfrs configure], true)
1111
1378
  categories = %i[performance security reliability accessibility internationalization]
1112
1379
  categories.each do |category|
1113
1380
  existing = get([:nfrs, category])
@@ -1126,11 +1393,11 @@ module Aidp
1126
1393
  prompt.say("\nšŸ“š Detected stack: #{(stack == :other) ? "Custom" : stack.to_s.capitalize}")
1127
1394
  case stack
1128
1395
  when :rails
1129
- set([:nfrs, :preferred_libraries, :rails], configure_rails_libraries)
1396
+ set(%i[nfrs preferred_libraries rails], configure_rails_libraries)
1130
1397
  when :node
1131
- set([:nfrs, :preferred_libraries, :node], configure_node_libraries)
1398
+ set(%i[nfrs preferred_libraries node], configure_node_libraries)
1132
1399
  when :python
1133
- set([:nfrs, :preferred_libraries, :python], configure_python_libraries)
1400
+ set(%i[nfrs preferred_libraries python], configure_python_libraries)
1134
1401
  else
1135
1402
  custom_stack = ask_with_default("Name this stack (e.g. go, php)", "custom")
1136
1403
  libs = ask_list("Preferred libraries (comma-separated)", [])
@@ -1154,7 +1421,7 @@ module Aidp
1154
1421
  end
1155
1422
 
1156
1423
  def configure_rails_libraries
1157
- existing = get([:nfrs, :preferred_libraries, :rails]) || {}
1424
+ existing = get(%i[nfrs preferred_libraries rails]) || {}
1158
1425
  {
1159
1426
  auth: ask_with_default("Authentication gem", existing[:auth] || "devise"),
1160
1427
  authz: ask_with_default("Authorization gem", existing[:authz] || "pundit"),
@@ -1164,7 +1431,7 @@ module Aidp
1164
1431
  end
1165
1432
 
1166
1433
  def configure_node_libraries
1167
- existing = get([:nfrs, :preferred_libraries, :node]) || {}
1434
+ existing = get(%i[nfrs preferred_libraries node]) || {}
1168
1435
  {
1169
1436
  validation: ask_with_default("Validation library", existing[:validation] || "zod"),
1170
1437
  orm: ask_with_default("ORM/Database", existing[:orm] || "prisma"),
@@ -1173,7 +1440,7 @@ module Aidp
1173
1440
  end
1174
1441
 
1175
1442
  def configure_python_libraries
1176
- existing = get([:nfrs, :preferred_libraries, :python]) || {}
1443
+ existing = get(%i[nfrs preferred_libraries python]) || {}
1177
1444
  linting = ask_list("Linting tools", existing[:linting] || %w[ruff mypy])
1178
1445
  {
1179
1446
  validation: ask_with_default("Validation library", existing[:validation] || "pydantic"),
@@ -1191,9 +1458,9 @@ module Aidp
1191
1458
  existing = get([:logging]) || {}
1192
1459
 
1193
1460
  log_level_choices = [
1194
- ["Debug", "debug"],
1195
- ["Info", "info"],
1196
- ["Error", "error"]
1461
+ %w[Debug debug],
1462
+ %w[Info info],
1463
+ %w[Error error]
1197
1464
  ]
1198
1465
  log_level_default = existing[:level] || "info"
1199
1466
  log_level_default_label = log_level_choices.find { |label, value| value == log_level_default }&.first
@@ -1284,9 +1551,11 @@ module Aidp
1284
1551
  prompt.say("-" * 40)
1285
1552
  existing = get([:modes]) || {}
1286
1553
 
1287
- background = prompt.yes?("Run in background mode by default?", default: existing.fetch(:background_default, false))
1554
+ background = prompt.yes?("Run in background mode by default?",
1555
+ default: existing.fetch(:background_default, false))
1288
1556
  watch = prompt.yes?("Enable watch mode integrations?", default: existing.fetch(:watch_enabled, false))
1289
- quick_mode = prompt.yes?("Enable quick mode (short timeouts) by default?", default: existing.fetch(:quick_mode_default, false))
1557
+ quick_mode = prompt.yes?("Enable quick mode (short timeouts) by default?",
1558
+ default: existing.fetch(:quick_mode_default, false))
1290
1559
 
1291
1560
  set([:modes], {
1292
1561
  background_default: background,
@@ -1310,7 +1579,7 @@ module Aidp
1310
1579
 
1311
1580
  def configure_watch_safety
1312
1581
  prompt.say("\nšŸ”’ Watch mode safety settings")
1313
- existing = get([:watch, :safety]) || {}
1582
+ existing = get(%i[watch safety]) || {}
1314
1583
 
1315
1584
  allow_public_repos = prompt.yes?(
1316
1585
  "Allow watch mode on public repositories?",
@@ -1330,7 +1599,7 @@ module Aidp
1330
1599
  default: existing.fetch(:require_container, true)
1331
1600
  )
1332
1601
 
1333
- set([:watch, :safety], {
1602
+ set(%i[watch safety], {
1334
1603
  allow_public_repos: allow_public_repos,
1335
1604
  author_allowlist: author_allowlist,
1336
1605
  require_container: require_container
@@ -1340,7 +1609,7 @@ module Aidp
1340
1609
  def configure_watch_labels
1341
1610
  prompt.say("\nšŸ·ļø Watch mode label configuration")
1342
1611
  prompt.say(" Configure GitHub issue and PR labels that trigger watch mode actions")
1343
- existing = get([:watch, :labels]) || {}
1612
+ existing = get(%i[watch labels]) || {}
1344
1613
 
1345
1614
  plan_trigger = ask_with_default(
1346
1615
  "Label to trigger plan generation",
@@ -1382,7 +1651,7 @@ module Aidp
1382
1651
  existing[:change_request_trigger] || "aidp-request-changes"
1383
1652
  )
1384
1653
 
1385
- set([:watch, :labels], {
1654
+ set(%i[watch labels], {
1386
1655
  plan_trigger: plan_trigger,
1387
1656
  needs_input: needs_input,
1388
1657
  ready_to_build: ready_to_build,
@@ -1397,7 +1666,7 @@ module Aidp
1397
1666
  def configure_watch_change_requests
1398
1667
  prompt.say("\nšŸ“ PR Change Request Configuration")
1399
1668
  prompt.say(" Configure how AIDP handles automated PR change requests")
1400
- existing = get([:watch, :change_requests]) || {}
1669
+ existing = get(%i[watch change_requests]) || {}
1401
1670
 
1402
1671
  max_diff_size = ask_with_default(
1403
1672
  "Maximum PR diff size (lines) for change requests",
@@ -1409,7 +1678,7 @@ module Aidp
1409
1678
  default: existing.fetch(:post_detection_comments, true)
1410
1679
  )
1411
1680
 
1412
- set([:watch, :change_requests], {
1681
+ set(%i[watch change_requests], {
1413
1682
  max_diff_size: max_diff_size
1414
1683
  })
1415
1684
 
@@ -1462,7 +1731,7 @@ module Aidp
1462
1731
  Aidp.log_debug("setup_wizard.label_creation", "existing_labels_fetched", count: existing_labels.size)
1463
1732
 
1464
1733
  # Get configured label names
1465
- labels_config = get([:watch, :labels]) || {}
1734
+ labels_config = get(%i[watch labels]) || {}
1466
1735
  required_labels = collect_required_labels(labels_config)
1467
1736
 
1468
1737
  # Determine which labels need to be created
@@ -1534,7 +1803,8 @@ module Aidp
1534
1803
  # Fetch existing labels from GitHub
1535
1804
  def fetch_existing_labels(owner, repo)
1536
1805
  require "open3"
1537
- stdout, stderr, status = Open3.capture3("gh", "label", "list", "-R", "#{owner}/#{repo}", "--json", "name", "--jq", ".[].name")
1806
+ stdout, stderr, status = Open3.capture3("gh", "label", "list", "-R", "#{owner}/#{repo}", "--json", "name",
1807
+ "--jq", ".[].name")
1538
1808
 
1539
1809
  unless status.success?
1540
1810
  Aidp.log_error("setup_wizard.fetch_labels", "gh_failed", error: stderr)
@@ -1559,26 +1829,18 @@ module Aidp
1559
1829
  review_trigger: "FBCA04", # Yellow
1560
1830
  ci_fix_trigger: "D93F0B", # Red
1561
1831
  auto_trigger: "0C8BD6", # Blue (distinct from build)
1562
- change_request_trigger: "F9D0C4", # Light pink
1563
- in_progress: "1D76DB" # Dark blue (internal coordination)
1832
+ change_request_trigger: "F9D0C4", # Light pink
1833
+ in_progress: "1D76DB" # Dark blue (internal coordination)
1564
1834
  }
1565
1835
 
1566
1836
  required = []
1567
1837
  labels_config.each do |key, name|
1568
1838
  next if name.nil? || name.to_s.strip.empty?
1569
1839
 
1570
- color = default_colors[key] || "EDEDED" # Gray fallback
1840
+ color = default_colors[key] || "EDEDED" # Gray fallback
1571
1841
  required << {name: name, color: color, key: key}
1572
1842
  end
1573
1843
 
1574
- # Always include the internal in-progress label for coordination
1575
- required << {
1576
- name: "aidp-in-progress",
1577
- color: default_colors[:in_progress],
1578
- key: :in_progress,
1579
- internal: true
1580
- }
1581
-
1582
1844
  Aidp.log_debug("setup_wizard.collect_labels", "collected", count: required.size)
1583
1845
  required
1584
1846
  end
@@ -1728,6 +1990,7 @@ module Aidp
1728
1990
 
1729
1991
  if answer.nil? || answer.strip.empty?
1730
1992
  return default if default.nil? || !block_given?
1993
+
1731
1994
  return yield(default)
1732
1995
  end
1733
1996
 
@@ -1744,6 +2007,7 @@ module Aidp
1744
2007
  line = prompt.ask("", default: nil)
1745
2008
  break if line.nil? || line.empty?
1746
2009
  return nil if line.strip.casecmp("clear").zero?
2010
+
1747
2011
  lines << line
1748
2012
  end
1749
2013
  return default if lines.empty?
@@ -1786,6 +2050,7 @@ module Aidp
1786
2050
  return "bundle exec rspec" if project_file?("Gemfile") && Dir.exist?(File.join(project_dir, "spec"))
1787
2051
  return "npm test" if project_file?("package.json")
1788
2052
  return "pytest" if project_file?("pytest.ini") || Dir.exist?(File.join(project_dir, "tests"))
2053
+
1789
2054
  "echo 'No tests configured'"
1790
2055
  end
1791
2056
 
@@ -1793,6 +2058,7 @@ module Aidp
1793
2058
  return "bundle exec rubocop" if project_file?(".rubocop.yml")
1794
2059
  return "npm run lint" if project_file?("package.json")
1795
2060
  return "ruff check ." if project_file?("pyproject.toml")
2061
+
1796
2062
  "echo 'No linter configured'"
1797
2063
  end
1798
2064
 
@@ -1800,6 +2066,7 @@ module Aidp
1800
2066
  return "bundle exec rubocop -A" if project_file?(".rubocop.yml")
1801
2067
  return "npm run format" if project_file?("package.json")
1802
2068
  return "ruff format ." if project_file?("pyproject.toml")
2069
+
1803
2070
  "echo 'No formatter configured'"
1804
2071
  end
1805
2072
 
@@ -1862,6 +2129,7 @@ module Aidp
1862
2129
  def detect_vcs_tool
1863
2130
  return "git" if Dir.exist?(File.join(project_dir, ".git"))
1864
2131
  return "svn" if Dir.exist?(File.join(project_dir, ".svn"))
2132
+
1865
2133
  nil
1866
2134
  end
1867
2135
 
@@ -1880,7 +2148,8 @@ module Aidp
1880
2148
  end
1881
2149
 
1882
2150
  def show_provider_summary(primary, fallbacks)
1883
- Aidp.log_debug("wizard.provider_summary", "displaying provider configuration table", primary: primary, fallback_count: fallbacks&.size || 0)
2151
+ Aidp.log_debug("wizard.provider_summary", "displaying provider configuration table", primary: primary,
2152
+ fallback_count: fallbacks&.size || 0)
1884
2153
  prompt.say("\nšŸ“‹ Provider Configuration Summary:")
1885
2154
  providers_config = get([:providers]) || {}
1886
2155
 
@@ -1960,6 +2229,7 @@ module Aidp
1960
2229
  # Ensure a minimal billing configuration exists for a selected provider (no secrets)
1961
2230
  def ensure_provider_billing_config(provider_name, force: false)
1962
2231
  return if provider_name.nil? || provider_name == "custom"
2232
+
1963
2233
  providers_section = get([:providers]) || {}
1964
2234
  existing = providers_section[provider_name.to_sym]
1965
2235
 
@@ -1977,6 +2247,10 @@ module Aidp
1977
2247
  merged = (existing || {}).merge(type: provider_type, model_family: model_family)
1978
2248
  set([:providers, provider_name.to_sym], merged)
1979
2249
  normalize_existing_model_families!
2250
+
2251
+ # Offer to configure usage limits for usage-based providers
2252
+ configure_usage_limits(provider_name) if provider_type == "usage_based"
2253
+
1980
2254
  action_word = if existing
1981
2255
  force ? "reconfigured" : "updated"
1982
2256
  else
@@ -1993,9 +2267,7 @@ module Aidp
1993
2267
 
1994
2268
  action = prompt.select("What would you like to do with '#{display_name}'?") do |menu|
1995
2269
  menu.choice "Edit configuration", :edit
1996
- unless is_primary
1997
- menu.choice "Remove from configuration", :remove
1998
- end
2270
+ menu.choice "Remove from configuration", :remove unless is_primary
1999
2271
  menu.choice "Cancel", :cancel
2000
2272
  end
2001
2273
 
@@ -2015,12 +2287,12 @@ module Aidp
2015
2287
 
2016
2288
  def remove_fallback_provider(provider_name, fallbacks)
2017
2289
  display_name = discover_available_providers.invert.fetch(provider_name, provider_name)
2018
- if prompt.yes?("Remove '#{display_name}' from fallback providers?", default: false)
2019
- fallbacks.delete(provider_name)
2020
- set([:harness, :fallback_providers], fallbacks)
2021
- Aidp.log_info("wizard.remove_provider", "removed fallback provider", provider: provider_name)
2022
- prompt.ok("Removed '#{display_name}' from fallback providers")
2023
- end
2290
+ return unless prompt.yes?("Remove '#{display_name}' from fallback providers?", default: false)
2291
+
2292
+ fallbacks.delete(provider_name)
2293
+ set(%i[harness fallback_providers], fallbacks)
2294
+ Aidp.log_info("wizard.remove_provider", "removed fallback provider", provider: provider_name)
2295
+ prompt.ok("Removed '#{display_name}' from fallback providers")
2024
2296
  end
2025
2297
 
2026
2298
  def edit_provider_configuration(provider_name)
@@ -2028,12 +2300,177 @@ module Aidp
2028
2300
  prompt.say("\nšŸ”§ Editing provider '#{provider_name}' (current: type=#{existing[:type] || "unset"}, model_family=#{existing[:model_family] || "unset"})")
2029
2301
  new_type = ask_provider_billing_type_with_default(provider_name, existing[:type])
2030
2302
  new_family = ask_model_family(provider_name, existing[:model_family] || "auto")
2031
- set([:providers, provider_name.to_sym], {type: new_type, model_family: new_family})
2303
+
2304
+ # Merge with existing config to preserve other keys (e.g., usage_limits, custom settings)
2305
+ updated_config = existing.merge(type: new_type, model_family: new_family)
2306
+
2307
+ set([:providers, provider_name.to_sym], updated_config)
2032
2308
  # Normalize immediately so tests relying on canonical value see 'claude' rather than label
2033
2309
  normalize_existing_model_families!
2310
+
2311
+ # Offer to configure/edit usage limits for usage-based providers
2312
+ configure_usage_limits(provider_name) if new_type == "usage_based"
2313
+
2034
2314
  prompt.ok("Updated '#{provider_name}' → type=#{new_type}, model_family=#{new_family}")
2035
2315
  end
2036
2316
 
2317
+ # Configure usage limits for a usage-based provider
2318
+ # @param provider_name [String] Name of the provider
2319
+ def configure_usage_limits(provider_name)
2320
+ existing = get([:providers, provider_name.to_sym, :usage_limits]) || {}
2321
+ existing_enabled = existing[:enabled] == true
2322
+
2323
+ display_name = discover_available_providers.invert.fetch(provider_name, provider_name)
2324
+
2325
+ if existing_enabled
2326
+ prompt.say("\nšŸ’° Usage limits currently enabled for '#{display_name}'")
2327
+ action = prompt.select("Configure usage limits?") do |menu|
2328
+ menu.choice "Keep current settings", :keep
2329
+ menu.choice "Edit usage limits", :edit
2330
+ menu.choice "Disable usage limits", :disable
2331
+ end
2332
+
2333
+ case action
2334
+ when :keep
2335
+ return
2336
+ when :disable
2337
+ set([:providers, provider_name.to_sym, :usage_limits], {enabled: false})
2338
+ prompt.ok("Disabled usage limits for '#{display_name}'")
2339
+ return
2340
+ end
2341
+ else
2342
+ return unless prompt.yes?("\nšŸ’° Configure usage limits for '#{display_name}'? (prevents runaway costs)",
2343
+ default: false)
2344
+ end
2345
+
2346
+ # Configure usage limits
2347
+ limits_config = ask_usage_limits_config(provider_name, existing)
2348
+ set([:providers, provider_name.to_sym, :usage_limits], limits_config)
2349
+
2350
+ return unless limits_config[:enabled]
2351
+
2352
+ prompt.ok("Configured usage limits for '#{display_name}'")
2353
+ end
2354
+
2355
+ # Ask for usage limits configuration
2356
+ # @param provider_name [String] Provider name
2357
+ # @param existing [Hash] Existing configuration
2358
+ # @return [Hash] Usage limits configuration
2359
+ def ask_usage_limits_config(provider_name, existing = {})
2360
+ display_name = discover_available_providers.invert.fetch(provider_name, provider_name)
2361
+
2362
+ # Period selection
2363
+ period_choices = [
2364
+ %w[Monthly monthly],
2365
+ %w[Weekly weekly],
2366
+ %w[Daily daily]
2367
+ ]
2368
+ period_default = existing[:period] || "monthly"
2369
+ period_default_label = period_choices.find { |label, value| value == period_default }&.first
2370
+
2371
+ period = prompt.select("Billing period for '#{display_name}':", default: period_default_label) do |menu|
2372
+ period_choices.each { |label, value| menu.choice label, value }
2373
+ end
2374
+
2375
+ # Reset day (only for monthly)
2376
+ reset_day = 1
2377
+ if period == "monthly"
2378
+ reset_day = prompt.ask("Day of month for period reset (1-28):",
2379
+ default: (existing[:reset_day] || 1).to_s,
2380
+ convert: :int) do |q|
2381
+ q.validate(->(v) { v.to_i.between?(1, 28) }, "Please enter a day between 1 and 28")
2382
+ end
2383
+ end
2384
+
2385
+ # Limit type selection
2386
+ limit_type = prompt.select("How would you like to set limits?") do |menu|
2387
+ menu.choice "By tier (different limits for mini vs advanced)", :tier
2388
+ menu.choice "Global (single limit for all usage)", :global
2389
+ menu.choice "Skip (no limits)", :skip
2390
+ end
2391
+
2392
+ return {enabled: false} if limit_type == :skip
2393
+
2394
+ if limit_type == :tier
2395
+ ask_tier_limits_config(period, reset_day, existing)
2396
+ else
2397
+ ask_global_limits_config(period, reset_day, existing)
2398
+ end
2399
+ end
2400
+
2401
+ # Ask for global limits configuration
2402
+ def ask_global_limits_config(period, reset_day, existing = {})
2403
+ max_cost = prompt.ask("Maximum cost per #{period} in USD (e.g., 50.00, or blank for no limit):",
2404
+ default: existing[:max_cost]&.to_s) do |q|
2405
+ q.validate(/\A(\d+\.?\d*|)$/, "Please enter a valid number or leave blank")
2406
+ end
2407
+
2408
+ max_tokens = prompt.ask("Maximum tokens per #{period} (e.g., 1000000, or blank for no limit):",
2409
+ default: existing[:max_tokens]&.to_s) do |q|
2410
+ q.validate(/\A(\d+|)$/, "Please enter a valid integer or leave blank")
2411
+ end
2412
+
2413
+ config = {
2414
+ enabled: true,
2415
+ period: period,
2416
+ reset_day: reset_day
2417
+ }
2418
+
2419
+ config[:max_cost] = max_cost.to_f if max_cost && !max_cost.strip.empty?
2420
+ config[:max_tokens] = max_tokens.to_i if max_tokens && !max_tokens.strip.empty?
2421
+
2422
+ config
2423
+ end
2424
+
2425
+ # Ask for tier-based limits configuration
2426
+ def ask_tier_limits_config(period, reset_day, existing = {})
2427
+ existing_tier_limits = existing[:tier_limits] || {}
2428
+
2429
+ prompt.say("\nšŸ“Š Configure limits by tier (mini models typically have higher limits)")
2430
+
2431
+ tier_limits = {}
2432
+
2433
+ # Mini tier
2434
+ prompt.say("\nšŸ”¹ Mini tier (fast, cheap models):")
2435
+ mini_limits = ask_single_tier_limits("mini", period,
2436
+ existing_tier_limits[:mini] || existing_tier_limits["mini"])
2437
+ tier_limits[:mini] = mini_limits if mini_limits
2438
+
2439
+ # Advanced tier
2440
+ prompt.say("\nšŸ”ø Advanced tier (powerful, expensive models):")
2441
+ advanced_limits = ask_single_tier_limits("advanced", period,
2442
+ existing_tier_limits[:advanced] || existing_tier_limits["advanced"])
2443
+ tier_limits[:advanced] = advanced_limits if advanced_limits
2444
+
2445
+ {
2446
+ enabled: true,
2447
+ period: period,
2448
+ reset_day: reset_day,
2449
+ tier_limits: tier_limits
2450
+ }
2451
+ end
2452
+
2453
+ # Ask for limits for a single tier
2454
+ def ask_single_tier_limits(tier, period, existing = {})
2455
+ existing ||= {}
2456
+
2457
+ max_cost = prompt.ask(" Max cost per #{period} for #{tier} tier (USD, blank for no limit):",
2458
+ default: existing[:max_cost]&.to_s) do |q|
2459
+ q.validate(/\A(\d+\.?\d*|)$/, "Please enter a valid number or leave blank")
2460
+ end
2461
+
2462
+ max_tokens = prompt.ask(" Max tokens per #{period} for #{tier} tier (blank for no limit):",
2463
+ default: existing[:max_tokens]&.to_s) do |q|
2464
+ q.validate(/\A(\d+|)$/, "Please enter a valid integer or leave blank")
2465
+ end
2466
+
2467
+ limits = {}
2468
+ limits[:max_cost] = max_cost.to_f if max_cost && !max_cost.strip.empty?
2469
+ limits[:max_tokens] = max_tokens.to_i if max_tokens && !max_tokens.strip.empty?
2470
+
2471
+ limits.empty? ? nil : limits
2472
+ end
2473
+
2037
2474
  def ask_provider_billing_type(provider_name)
2038
2475
  ask_provider_billing_type_with_default(provider_name, nil)
2039
2476
  end
@@ -2076,7 +2513,7 @@ module Aidp
2076
2513
 
2077
2514
  # Try label -> value mapping (case-insensitive)
2078
2515
  choices = ProviderRegistry.model_family_choices
2079
- mapped = choices.find { |label, _| label.downcase == value.to_s.downcase }&.last
2516
+ mapped = choices.find { |label, _| label.casecmp(value.to_s).zero? }&.last
2080
2517
  return mapped if mapped
2081
2518
 
2082
2519
  # Unknown legacy entry -> fallback to auto
@@ -2086,8 +2523,10 @@ module Aidp
2086
2523
  def normalize_existing_model_families!
2087
2524
  providers_cfg = @config[:providers]
2088
2525
  return unless providers_cfg.is_a?(Hash)
2526
+
2089
2527
  providers_cfg.each do |prov_name, prov_cfg|
2090
2528
  next unless prov_cfg.is_a?(Hash)
2529
+
2091
2530
  mf = prov_cfg[:model_family]
2092
2531
  # Normalize and write back only if different to avoid unnecessary YAML churn
2093
2532
  normalized = normalize_model_family(mf)
@@ -2122,19 +2561,19 @@ module Aidp
2122
2561
  combined = []
2123
2562
  (Array(existing_models) + Array(new_models)).each do |entry|
2124
2563
  next unless entry.is_a?(Hash)
2564
+
2125
2565
  provider = entry[:provider]
2126
2566
  model = entry[:model]
2127
2567
  next unless provider && model
2128
2568
 
2129
- unless combined.any? { |m| m[:provider] == provider && m[:model] == model }
2130
- combined << entry
2131
- end
2569
+ combined << entry unless combined.any? { |m| m[:provider] == provider && m[:model] == model }
2132
2570
  end
2133
2571
  combined
2134
2572
  end
2135
2573
 
2136
2574
  def load_existing_config
2137
2575
  return {} unless File.exist?(config_path)
2576
+
2138
2577
  YAML.safe_load_file(config_path, permitted_classes: [Time]) || {}
2139
2578
  rescue => e
2140
2579
  @warnings << "Failed to parse existing configuration: #{e.message}"
@@ -2302,7 +2741,7 @@ module Aidp
2302
2741
 
2303
2742
  unless manage
2304
2743
  Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.opt_out")
2305
- return set([:devcontainer, :manage], false)
2744
+ return set(%i[devcontainer manage], false)
2306
2745
  end
2307
2746
 
2308
2747
  # Build wizard config and detect ports
@@ -2340,9 +2779,9 @@ module Aidp
2340
2779
  end
2341
2780
 
2342
2781
  # Save configuration
2343
- set([:devcontainer, :manage], true)
2344
- set([:devcontainer, :custom_ports], custom_ports) if custom_ports.any?
2345
- set([:devcontainer, :last_generated], Time.now.utc.iso8601)
2782
+ set(%i[devcontainer manage], true)
2783
+ set(%i[devcontainer custom_ports], custom_ports) if custom_ports.any?
2784
+ set(%i[devcontainer last_generated], Time.now.utc.iso8601)
2346
2785
  Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.enabled",
2347
2786
  custom_port_count: custom_ports.count,
2348
2787
  detected_port_count: detected_ports.count)
@@ -2364,6 +2803,7 @@ module Aidp
2364
2803
  return "rails_web" if project_file?("config/routes.rb")
2365
2804
  return "sinatra" if project_file?("config.ru")
2366
2805
  return "express" if project_file?("app.js") && project_file?("package.json")
2806
+
2367
2807
  "cli"
2368
2808
  end
2369
2809