aidp 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -31
  3. data/lib/aidp/cli.rb +19 -2
  4. data/lib/aidp/execute/work_loop_runner.rb +252 -45
  5. data/lib/aidp/execute/work_loop_unit_scheduler.rb +27 -2
  6. data/lib/aidp/harness/condition_detector.rb +42 -8
  7. data/lib/aidp/harness/config_manager.rb +7 -0
  8. data/lib/aidp/harness/config_schema.rb +25 -0
  9. data/lib/aidp/harness/configuration.rb +69 -6
  10. data/lib/aidp/harness/error_handler.rb +117 -44
  11. data/lib/aidp/harness/provider_manager.rb +64 -0
  12. data/lib/aidp/harness/provider_metrics.rb +138 -0
  13. data/lib/aidp/harness/runner.rb +110 -35
  14. data/lib/aidp/harness/simple_user_interface.rb +4 -0
  15. data/lib/aidp/harness/state/ui_state.rb +0 -10
  16. data/lib/aidp/harness/state_manager.rb +1 -15
  17. data/lib/aidp/harness/test_runner.rb +39 -2
  18. data/lib/aidp/logger.rb +34 -4
  19. data/lib/aidp/providers/adapter.rb +241 -0
  20. data/lib/aidp/providers/anthropic.rb +75 -7
  21. data/lib/aidp/providers/base.rb +29 -1
  22. data/lib/aidp/providers/capability_registry.rb +205 -0
  23. data/lib/aidp/providers/codex.rb +14 -0
  24. data/lib/aidp/providers/error_taxonomy.rb +195 -0
  25. data/lib/aidp/providers/gemini.rb +3 -2
  26. data/lib/aidp/setup/devcontainer/backup_manager.rb +11 -4
  27. data/lib/aidp/setup/provider_registry.rb +107 -0
  28. data/lib/aidp/setup/wizard.rb +189 -31
  29. data/lib/aidp/version.rb +1 -1
  30. data/lib/aidp/watch/build_processor.rb +357 -27
  31. data/lib/aidp/watch/plan_generator.rb +16 -1
  32. data/lib/aidp/watch/plan_processor.rb +54 -3
  33. data/lib/aidp/watch/repository_client.rb +78 -4
  34. data/lib/aidp/watch/repository_safety_checker.rb +12 -3
  35. data/lib/aidp/watch/runner.rb +52 -10
  36. data/lib/aidp/workflows/guided_agent.rb +53 -0
  37. data/lib/aidp/worktree.rb +67 -10
  38. data/templates/work_loop/decide_whats_next.md +21 -0
  39. data/templates/work_loop/diagnose_failures.md +21 -0
  40. metadata +10 -3
  41. /data/{bin → exe}/aidp +0 -0
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Providers
5
+ # ErrorTaxonomy defines the five standardized error categories that all providers
6
+ # use for consistent error handling, retry logic, and escalation.
7
+ #
8
+ # Categories:
9
+ # - rate_limited: Provider is rate-limiting requests (switch provider immediately)
10
+ # - auth_expired: Authentication credentials are invalid or expired (escalate or switch)
11
+ # - quota_exceeded: Usage quota has been exceeded (switch provider)
12
+ # - transient: Temporary error that may resolve on retry (retry with backoff)
13
+ # - permanent: Permanent error that won't resolve with retry (escalate or abort)
14
+ #
15
+ # @see https://github.com/viamin/aidp/issues/243
16
+ module ErrorTaxonomy
17
+ # Error category constants
18
+ RATE_LIMITED = :rate_limited
19
+ AUTH_EXPIRED = :auth_expired
20
+ QUOTA_EXCEEDED = :quota_exceeded
21
+ TRANSIENT = :transient
22
+ PERMANENT = :permanent
23
+
24
+ # All valid error categories
25
+ CATEGORIES = [
26
+ RATE_LIMITED,
27
+ AUTH_EXPIRED,
28
+ QUOTA_EXCEEDED,
29
+ TRANSIENT,
30
+ PERMANENT
31
+ ].freeze
32
+
33
+ # Default error patterns for common error messages
34
+ # Providers can override these with provider-specific patterns
35
+ DEFAULT_PATTERNS = {
36
+ rate_limited: [
37
+ /rate.?limit/i,
38
+ /too.?many.?requests/i,
39
+ /429/,
40
+ /throttl(ed|ing)/i,
41
+ /request.?limit/i,
42
+ /requests.?per.?minute/i,
43
+ /rpm.?exceeded/i
44
+ ],
45
+ auth_expired: [
46
+ /auth(entication|orization).?(fail(ed|ure)|error)/i,
47
+ /invalid.?(api.?key|token|credential)/i,
48
+ /expired.?(api.?key|token|credential)/i,
49
+ /unauthorized/i,
50
+ /401/,
51
+ /403/,
52
+ /permission.?denied/i,
53
+ /access.?denied/i
54
+ ],
55
+ quota_exceeded: [
56
+ /quota.?(exceed(ed)?|limit|exhausted)/i,
57
+ /usage.?limit/i,
58
+ /billing.?limit/i,
59
+ /credit.?limit/i,
60
+ /insufficient.?quota/i,
61
+ /usage.?cap/i
62
+ ],
63
+ transient: [
64
+ /timeout/i,
65
+ /timed?.?out/i,
66
+ /connection.?(reset|refused|lost|closed)/i,
67
+ /temporary.?error/i,
68
+ /try.?again/i,
69
+ /service.?unavailable/i,
70
+ /503/,
71
+ /502/,
72
+ /504/,
73
+ /gateway.?timeout/i,
74
+ /network.?error/i,
75
+ /socket.?error/i,
76
+ /connection.?error/i,
77
+ /broken.?pipe/i,
78
+ /host.?unreachable/i
79
+ ],
80
+ permanent: [
81
+ /invalid.?(model|parameter|request|input)/i,
82
+ /unsupported.?(operation|feature|model)/i,
83
+ /not.?found/i,
84
+ /404/,
85
+ /bad.?request/i,
86
+ /400/,
87
+ /malformed/i,
88
+ /syntax.?error/i,
89
+ /validation.?error/i,
90
+ /model.?not.?available/i,
91
+ /model.?deprecated/i
92
+ ]
93
+ }.freeze
94
+
95
+ # Retry policy for each category
96
+ RETRY_POLICIES = {
97
+ rate_limited: {
98
+ retry: false,
99
+ switch_provider: true,
100
+ escalate: false,
101
+ backoff_strategy: :none
102
+ },
103
+ auth_expired: {
104
+ retry: false,
105
+ switch_provider: true,
106
+ escalate: true,
107
+ backoff_strategy: :none
108
+ },
109
+ quota_exceeded: {
110
+ retry: false,
111
+ switch_provider: true,
112
+ escalate: false,
113
+ backoff_strategy: :none
114
+ },
115
+ transient: {
116
+ retry: true,
117
+ switch_provider: false,
118
+ escalate: false,
119
+ backoff_strategy: :exponential
120
+ },
121
+ permanent: {
122
+ retry: false,
123
+ switch_provider: false,
124
+ escalate: true,
125
+ backoff_strategy: :none
126
+ }
127
+ }.freeze
128
+
129
+ # Check if a category is valid
130
+ # @param category [Symbol] category to check
131
+ # @return [Boolean] true if valid
132
+ def self.valid_category?(category)
133
+ CATEGORIES.include?(category)
134
+ end
135
+
136
+ # Get retry policy for a category
137
+ # @param category [Symbol] error category
138
+ # @return [Hash] retry policy configuration
139
+ def self.retry_policy(category)
140
+ RETRY_POLICIES[category] || RETRY_POLICIES[:transient]
141
+ end
142
+
143
+ # Classify an error message using default patterns
144
+ # @param message [String] error message
145
+ # @return [Symbol] error category
146
+ def self.classify_message(message)
147
+ return :transient if message.nil? || message.empty?
148
+
149
+ message_lower = message.downcase
150
+
151
+ # Check each category's patterns
152
+ DEFAULT_PATTERNS.each do |category, patterns|
153
+ patterns.each do |pattern|
154
+ return category if message_lower.match?(pattern)
155
+ end
156
+ end
157
+
158
+ # Default to transient for unknown errors
159
+ :transient
160
+ end
161
+
162
+ # Check if an error category is retryable
163
+ # @param category [Symbol] error category
164
+ # @return [Boolean] true if should retry
165
+ def self.retryable?(category)
166
+ policy = retry_policy(category)
167
+ policy[:retry] == true
168
+ end
169
+
170
+ # Check if an error category should trigger provider switch
171
+ # @param category [Symbol] error category
172
+ # @return [Boolean] true if should switch provider
173
+ def self.should_switch_provider?(category)
174
+ policy = retry_policy(category)
175
+ policy[:switch_provider] == true
176
+ end
177
+
178
+ # Check if an error category should be escalated
179
+ # @param category [Symbol] error category
180
+ # @return [Boolean] true if should escalate
181
+ def self.should_escalate?(category)
182
+ policy = retry_policy(category)
183
+ policy[:escalate] == true
184
+ end
185
+
186
+ # Get backoff strategy for a category
187
+ # @param category [Symbol] error category
188
+ # @return [Symbol] backoff strategy (:none, :linear, :exponential)
189
+ def self.backoff_strategy(category)
190
+ policy = retry_policy(category)
191
+ policy[:backoff_strategy] || :none
192
+ end
193
+ end
194
+ end
195
+ end
@@ -36,11 +36,12 @@ module Aidp
36
36
  end
37
37
 
38
38
  begin
39
+ command_args = ["--prompt", prompt]
39
40
  # Use debug_execute_command with streaming support
40
- result = debug_execute_command("gemini", args: ["--print"], input: prompt, timeout: timeout_seconds, streaming: streaming_enabled)
41
+ result = debug_execute_command("gemini", args: command_args, timeout: timeout_seconds, streaming: streaming_enabled)
41
42
 
42
43
  # Log the results
43
- debug_command("gemini", args: ["--print"], input: prompt, output: result.out, error: result.err, exit_code: result.exit_status)
44
+ debug_command("gemini", args: command_args, input: nil, output: result.out, error: result.err, exit_code: result.exit_status)
44
45
 
45
46
  if result.exit_status == 0
46
47
  result.out
@@ -10,9 +10,10 @@ module Aidp
10
10
  class BackupManager
11
11
  class BackupError < StandardError; end
12
12
 
13
- def initialize(project_dir)
13
+ def initialize(project_dir, clock: Time)
14
14
  @project_dir = project_dir
15
15
  @backup_dir = File.join(project_dir, ".aidp", "backups", "devcontainer")
16
+ @clock = clock
16
17
  end
17
18
 
18
19
  # Create a backup of the devcontainer file
@@ -26,7 +27,7 @@ module Aidp
26
27
 
27
28
  ensure_backup_directory_exists
28
29
 
29
- timestamp = Time.now.utc.strftime("%Y%m%d_%H%M%S")
30
+ timestamp = current_time.utc.strftime("%Y%m%d_%H%M%S")
30
31
  backup_filename = "devcontainer-#{timestamp}.json"
31
32
  backup_path = File.join(@backup_dir, backup_filename)
32
33
 
@@ -163,11 +164,17 @@ module Aidp
163
164
  end
164
165
 
165
166
  def parse_timestamp(timestamp_str)
166
- return Time.now if timestamp_str.nil?
167
+ return current_time if timestamp_str.nil?
167
168
 
168
169
  Time.strptime(timestamp_str, "%Y%m%d_%H%M%S")
169
170
  rescue ArgumentError
170
- Time.now
171
+ current_time
172
+ end
173
+
174
+ attr_reader :clock
175
+
176
+ def current_time
177
+ clock.respond_to?(:call) ? clock.call : clock.now
171
178
  end
172
179
  end
173
180
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Setup
5
+ # Centralized registry for provider metadata including billing types and model families.
6
+ # This module provides a single source of truth for provider configuration options.
7
+ module ProviderRegistry
8
+ # Billing type options for providers
9
+ BILLING_TYPES = [
10
+ {
11
+ label: "Subscription / flat-rate",
12
+ value: "subscription",
13
+ description: "Monthly or annual subscription with unlimited usage"
14
+ },
15
+ {
16
+ label: "Usage-based / metered (API)",
17
+ value: "usage_based",
18
+ description: "Pay per API call or token usage"
19
+ },
20
+ {
21
+ label: "Passthrough / local (no billing)",
22
+ value: "passthrough",
23
+ description: "Local execution or proxy without direct billing"
24
+ }
25
+ ].freeze
26
+
27
+ # Model family options for providers
28
+ MODEL_FAMILIES = [
29
+ {
30
+ label: "Auto (let provider decide)",
31
+ value: "auto",
32
+ description: "Use provider's default model selection"
33
+ },
34
+ {
35
+ label: "OpenAI o-series (reasoning models)",
36
+ value: "openai_o",
37
+ description: "Advanced reasoning capabilities, slower but more thorough"
38
+ },
39
+ {
40
+ label: "Anthropic Claude (balanced)",
41
+ value: "claude",
42
+ description: "Balanced performance for general-purpose tasks"
43
+ },
44
+ {
45
+ label: "Mistral (European/open)",
46
+ value: "mistral",
47
+ description: "European provider with open-source focus"
48
+ },
49
+ {
50
+ label: "Local LLM (self-hosted)",
51
+ value: "local",
52
+ description: "Self-hosted or local model execution"
53
+ }
54
+ ].freeze
55
+
56
+ # Returns array of [label, value] pairs for billing types
57
+ def self.billing_type_choices
58
+ BILLING_TYPES.map { |bt| [bt[:label], bt[:value]] }
59
+ end
60
+
61
+ # Returns array of [label, value] pairs for model families
62
+ def self.model_family_choices
63
+ MODEL_FAMILIES.map { |mf| [mf[:label], mf[:value]] }
64
+ end
65
+
66
+ # Finds label for a given billing type value
67
+ def self.billing_type_label(value)
68
+ BILLING_TYPES.find { |bt| bt[:value] == value }&.dig(:label) || value
69
+ end
70
+
71
+ # Finds label for a given model family value
72
+ def self.model_family_label(value)
73
+ MODEL_FAMILIES.find { |mf| mf[:value] == value }&.dig(:label) || value
74
+ end
75
+
76
+ # Finds description for a given billing type value
77
+ def self.billing_type_description(value)
78
+ BILLING_TYPES.find { |bt| bt[:value] == value }&.dig(:description)
79
+ end
80
+
81
+ # Finds description for a given model family value
82
+ def self.model_family_description(value)
83
+ MODEL_FAMILIES.find { |mf| mf[:value] == value }&.dig(:description)
84
+ end
85
+
86
+ # Validates if a billing type value is valid
87
+ def self.valid_billing_type?(value)
88
+ BILLING_TYPES.any? { |bt| bt[:value] == value }
89
+ end
90
+
91
+ # Validates if a model family value is valid
92
+ def self.valid_model_family?(value)
93
+ MODEL_FAMILIES.any? { |mf| mf[:value] == value }
94
+ end
95
+
96
+ # Returns all valid billing type values
97
+ def self.billing_type_values
98
+ BILLING_TYPES.map { |bt| bt[:value] }
99
+ end
100
+
101
+ # Returns all valid model family values
102
+ def self.model_family_values
103
+ MODEL_FAMILIES.map { |mf| mf[:value] }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tty-prompt"
4
+ require "tty-table"
4
5
  require "yaml"
5
6
  require "time"
6
7
  require "fileutils"
@@ -8,6 +9,7 @@ require "json"
8
9
 
9
10
  require_relative "../util"
10
11
  require_relative "../config/paths"
12
+ require_relative "provider_registry"
11
13
  require_relative "devcontainer/parser"
12
14
  require_relative "devcontainer/generator"
13
15
  require_relative "devcontainer/port_manager"
@@ -260,7 +262,10 @@ module Aidp
260
262
  editable = ([provider_choice] + cleaned_fallbacks).uniq.reject { |p| p == "custom" }
261
263
  end
262
264
  else
263
- edit_provider_configuration(to_edit)
265
+ # Edit the selected provider or offer to remove it
266
+ edit_or_remove_provider(to_edit, provider_choice, cleaned_fallbacks)
267
+ # Refresh editable list after potential removal
268
+ editable = ([provider_choice] + cleaned_fallbacks).uniq.reject { |p| p == "custom" }
264
269
  end
265
270
  end
266
271
  end
@@ -786,6 +791,79 @@ module Aidp
786
791
  watch_enabled: watch,
787
792
  quick_mode_default: quick_mode
788
793
  })
794
+
795
+ # Configure watch mode settings if enabled
796
+ configure_watch_mode if watch
797
+ end
798
+
799
+ def configure_watch_mode
800
+ prompt.say("\n👀 Watch Mode Configuration")
801
+ prompt.say("-" * 40)
802
+
803
+ configure_watch_safety
804
+ configure_watch_labels
805
+ end
806
+
807
+ def configure_watch_safety
808
+ prompt.say("\n🔒 Watch mode safety settings")
809
+ existing = get([:watch, :safety]) || {}
810
+
811
+ allow_public_repos = prompt.yes?(
812
+ "Allow watch mode on public repositories?",
813
+ default: existing.fetch(:allow_public_repos, false)
814
+ )
815
+
816
+ prompt.say("\n📝 Author allowlist (GitHub usernames allowed to trigger watch mode)")
817
+ prompt.say(" Leave empty to allow all authors (not recommended for public repos)")
818
+ author_allowlist = ask_list(
819
+ "Author allowlist (comma-separated GitHub usernames)",
820
+ existing[:author_allowlist] || [],
821
+ allow_empty: true
822
+ )
823
+
824
+ require_container = prompt.yes?(
825
+ "Require watch mode to run in a container?",
826
+ default: existing.fetch(:require_container, true)
827
+ )
828
+
829
+ set([:watch, :safety], {
830
+ allow_public_repos: allow_public_repos,
831
+ author_allowlist: author_allowlist,
832
+ require_container: require_container
833
+ })
834
+ end
835
+
836
+ def configure_watch_labels
837
+ prompt.say("\n🏷️ Watch mode label configuration")
838
+ prompt.say(" Configure GitHub issue labels that trigger watch mode actions")
839
+ existing = get([:watch, :labels]) || {}
840
+
841
+ plan_trigger = ask_with_default(
842
+ "Label to trigger plan generation",
843
+ existing[:plan_trigger] || "aidp-plan"
844
+ )
845
+
846
+ needs_input = ask_with_default(
847
+ "Label for plans needing user input",
848
+ existing[:needs_input] || "aidp-needs-input"
849
+ )
850
+
851
+ ready_to_build = ask_with_default(
852
+ "Label for plans ready to build",
853
+ existing[:ready_to_build] || "aidp-ready"
854
+ )
855
+
856
+ build_trigger = ask_with_default(
857
+ "Label to trigger implementation",
858
+ existing[:build_trigger] || "aidp-build"
859
+ )
860
+
861
+ set([:watch, :labels], {
862
+ plan_trigger: plan_trigger,
863
+ needs_input: needs_input,
864
+ ready_to_build: ready_to_build,
865
+ build_trigger: build_trigger
866
+ })
789
867
  end
790
868
 
791
869
  # -------------------------------------------
@@ -816,6 +894,7 @@ module Aidp
816
894
  .sub(/^nfrs:/, "# Non-functional requirements to reference during planning\nnfrs:")
817
895
  .sub(/^logging:/, "# Logging configuration\nlogging:")
818
896
  .sub(/^modes:/, "# Defaults for background/watch/quick modes\nmodes:")
897
+ .sub(/^watch:/, "# Watch mode safety and label configuration\nwatch:")
819
898
  end
820
899
 
821
900
  def display_preview(yaml_content)
@@ -1041,22 +1120,81 @@ module Aidp
1041
1120
  end
1042
1121
 
1043
1122
  def show_provider_summary(primary, fallbacks)
1123
+ Aidp.log_debug("wizard.provider_summary", "displaying provider configuration table", primary: primary, fallback_count: fallbacks&.size || 0)
1044
1124
  prompt.say("\n📋 Provider Configuration Summary:")
1045
1125
  providers_config = get([:providers]) || {}
1046
1126
 
1047
- # Show primary
1127
+ rows = []
1128
+
1129
+ # Add primary provider to table
1048
1130
  if primary && primary != "custom"
1049
1131
  primary_cfg = providers_config[primary.to_sym] || {}
1050
- prompt.say(" ✓ Primary: #{primary} (#{primary_cfg[:type] || "not configured"}, #{primary_cfg[:model_family] || "auto"})")
1132
+ rows << [
1133
+ "Primary",
1134
+ primary,
1135
+ primary_cfg[:type] || "not configured",
1136
+ primary_cfg[:model_family] || "auto"
1137
+ ]
1051
1138
  end
1052
1139
 
1053
- # Show fallbacks
1140
+ # Add fallback providers to table
1054
1141
  if fallbacks && !fallbacks.empty?
1055
- fallbacks.each do |fallback|
1142
+ fallbacks.each_with_index do |fallback, index|
1056
1143
  fallback_cfg = providers_config[fallback.to_sym] || {}
1057
- prompt.say(" ✓ Fallback: #{fallback} (#{fallback_cfg[:type] || "not configured"}, #{fallback_cfg[:model_family] || "auto"})")
1144
+ rows << [
1145
+ "Fallback #{index + 1}",
1146
+ fallback,
1147
+ fallback_cfg[:type] || "not configured",
1148
+ fallback_cfg[:model_family] || "auto"
1149
+ ]
1058
1150
  end
1059
1151
  end
1152
+
1153
+ # Detect duplicate providers with identical characteristics
1154
+ duplicates = detect_duplicate_providers(rows)
1155
+ if duplicates.any?
1156
+ Aidp.log_warn("wizard.provider_summary", "duplicate provider configurations detected", duplicates: duplicates)
1157
+ end
1158
+
1159
+ if rows.any?
1160
+ table = TTY::Table.new(
1161
+ header: ["Role", "Provider", "Billing Type", "Model Family"],
1162
+ rows: rows
1163
+ )
1164
+ prompt.say(table.render(:unicode, padding: [0, 1]))
1165
+
1166
+ # Show warning for duplicates
1167
+ if duplicates.any?
1168
+ prompt.say("")
1169
+ prompt.warn("⚠️ Duplicate configurations detected:")
1170
+ duplicates.each do |dup|
1171
+ prompt.say(" • #{dup[:providers].join(" and ")} have identical billing type (#{dup[:type]}) and model family (#{dup[:family]})")
1172
+ end
1173
+ prompt.say(" Consider using different providers or model families for better redundancy.")
1174
+ end
1175
+ else
1176
+ prompt.say(" (No providers configured)")
1177
+ end
1178
+ end
1179
+
1180
+ def detect_duplicate_providers(rows)
1181
+ # Group providers by their billing type and model family
1182
+ # Returns array of duplicate groups with identical characteristics
1183
+ duplicates = []
1184
+ config_groups = rows.group_by { |row| [row[2], row[3]] }
1185
+
1186
+ config_groups.each do |(type, family), group|
1187
+ next if group.size < 2
1188
+ next if type == "not configured" # Skip unconfigured providers
1189
+
1190
+ duplicates << {
1191
+ providers: group.map { |row| row[1] },
1192
+ type: type,
1193
+ family: family
1194
+ }
1195
+ end
1196
+
1197
+ duplicates
1060
1198
  end
1061
1199
 
1062
1200
  # Ensure a minimal billing configuration exists for a selected provider (no secrets)
@@ -1089,6 +1227,42 @@ module Aidp
1089
1227
  prompt.say(" • #{action_word.capitalize} provider '#{display_name}' (#{provider_name}) with billing type '#{provider_type}' and model family '#{model_family}'")
1090
1228
  end
1091
1229
 
1230
+ def edit_or_remove_provider(provider_name, primary_provider, fallbacks)
1231
+ is_primary = (provider_name == primary_provider)
1232
+ display_name = discover_available_providers.invert.fetch(provider_name, provider_name)
1233
+
1234
+ action = prompt.select("What would you like to do with '#{display_name}'?") do |menu|
1235
+ menu.choice "Edit configuration", :edit
1236
+ unless is_primary
1237
+ menu.choice "Remove from configuration", :remove
1238
+ end
1239
+ menu.choice "Cancel", :cancel
1240
+ end
1241
+
1242
+ case action
1243
+ when :edit
1244
+ edit_provider_configuration(provider_name)
1245
+ when :remove
1246
+ if is_primary
1247
+ prompt.warn("Cannot remove primary provider. Change primary provider first.")
1248
+ else
1249
+ remove_fallback_provider(provider_name, fallbacks)
1250
+ end
1251
+ when :cancel
1252
+ Aidp.log_debug("wizard.edit_provider", "user cancelled edit operation", provider: provider_name)
1253
+ end
1254
+ end
1255
+
1256
+ def remove_fallback_provider(provider_name, fallbacks)
1257
+ display_name = discover_available_providers.invert.fetch(provider_name, provider_name)
1258
+ if prompt.yes?("Remove '#{display_name}' from fallback providers?", default: false)
1259
+ fallbacks.delete(provider_name)
1260
+ set([:harness, :fallback_providers], fallbacks)
1261
+ Aidp.log_info("wizard.remove_provider", "removed fallback provider", provider: provider_name)
1262
+ prompt.ok("Removed '#{display_name}' from fallback providers")
1263
+ end
1264
+ end
1265
+
1092
1266
  def edit_provider_configuration(provider_name)
1093
1267
  existing = get([:providers, provider_name.to_sym]) || {}
1094
1268
  prompt.say("\n🔧 Editing provider '#{provider_name}' (current: type=#{existing[:type] || "unset"}, model_family=#{existing[:model_family] || "unset"})")
@@ -1104,54 +1278,38 @@ module Aidp
1104
1278
  ask_provider_billing_type_with_default(provider_name, nil)
1105
1279
  end
1106
1280
 
1107
- BILLING_TYPE_CHOICES = [
1108
- ["Subscription / flat-rate", "subscription"],
1109
- ["Usage-based / metered (API)", "usage_based"],
1110
- ["Passthrough / local (no billing)", "passthrough"]
1111
- ].freeze
1112
-
1113
1281
  def ask_provider_billing_type_with_default(provider_name, default_value)
1114
- default_label = BILLING_TYPE_CHOICES.find { |label, value| value == default_value }&.first
1282
+ choices = ProviderRegistry.billing_type_choices
1283
+ default_label = choices.find { |label, value| value == default_value }&.first
1115
1284
  suffix = default_value ? " (current: #{default_value})" : ""
1116
1285
  prompt.select("Billing model for #{provider_name}:#{suffix}", default: default_label) do |menu|
1117
- BILLING_TYPE_CHOICES.each do |label, value|
1286
+ choices.each do |label, value|
1118
1287
  menu.choice(label, value)
1119
1288
  end
1120
1289
  end
1121
1290
  end
1122
1291
 
1123
- MODEL_FAMILY_CHOICES = [
1124
- ["Auto (let provider decide)", "auto"],
1125
- ["OpenAI o-series (reasoning models)", "openai_o"],
1126
- ["Anthropic Claude (balanced)", "claude"],
1127
- ["Mistral (European/open)", "mistral"],
1128
- ["Local LLM (self-hosted)", "local"]
1129
- ].freeze
1130
-
1131
1292
  def ask_model_family(provider_name, default = "auto")
1132
1293
  # TTY::Prompt validates defaults against the displayed choice labels, not values.
1133
1294
  # Map the value default (e.g. "auto") to its corresponding label.
1134
- default_label = MODEL_FAMILY_CHOICES.find { |label, value| value == default }&.first
1295
+ choices = ProviderRegistry.model_family_choices
1296
+ default_label = choices.find { |label, value| value == default }&.first
1135
1297
 
1136
1298
  prompt.select("Preferred model family for #{provider_name}:", default: default_label) do |menu|
1137
- MODEL_FAMILY_CHOICES.each do |label, value|
1299
+ choices.each do |label, value|
1138
1300
  menu.choice(label, value)
1139
1301
  end
1140
1302
  end
1141
1303
  end
1142
1304
 
1143
1305
  # Canonicalization helpers ------------------------------------------------
1144
- MODEL_FAMILY_LABEL_TO_VALUE = MODEL_FAMILY_CHOICES.each_with_object({}) do |(label, value), h|
1145
- h[label] = value
1146
- end.freeze
1147
- MODEL_FAMILY_VALUES = MODEL_FAMILY_CHOICES.map { |(_, value)| value }.freeze
1148
-
1149
1306
  def normalize_model_family(value)
1150
1307
  return "auto" if value.nil? || value.to_s.strip.empty?
1151
1308
  # Already a canonical value
1152
- return value if MODEL_FAMILY_VALUES.include?(value)
1309
+ return value if ProviderRegistry.valid_model_family?(value)
1153
1310
  # Try label -> value
1154
- mapped = MODEL_FAMILY_LABEL_TO_VALUE[value]
1311
+ choices = ProviderRegistry.model_family_choices
1312
+ mapped = choices.find { |label, _| label == value }&.last
1155
1313
  return mapped if mapped
1156
1314
  # Unknown legacy entry -> fallback to auto
1157
1315
  "auto"
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.22.0"
4
+ VERSION = "0.24.0"
5
5
  end