aidp 0.19.1 → 0.20.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25ff9a05d33bc6dc88dfac85b9fc37ff5023af5f756c1d7b2533b1d2f473b103
4
- data.tar.gz: 11f7a0c6ca6d1c8b2f9f13475fe7d8a2ba9851f2ba09037264e6808171291d62
3
+ metadata.gz: 724d07296380ad69a72b1f9d6bf4dd44d77cf35f994eb35e1e0d224ed32e93a0
4
+ data.tar.gz: f54213c88e3e532205852f8dfbedcc0c264a446a65a1110ddf7de524f10e64d5
5
5
  SHA512:
6
- metadata.gz: e05fe743a66e0370dff5a60194c824a71e1d99e8ffcbea03786ab9f8b98e7983ff9a01ce48bf6d2ac8f294781d9808715181f9d92f292dc6bfa4ca568a3a4175
7
- data.tar.gz: 59774b09928fbc2e1b6e1023247aa5b2b1208531a965c2fe8c05f9012b2cb03a94a9ccf7dc6029a77086ed6f425a9570c3860b241a50094a6dd71c4053f782b6
6
+ metadata.gz: f8788f4f8af45642b276e350f7d64e2413ae1532cbe02fe29c95c06c8fcbf84ecd66c35cb73e5b16637b6780f52ba77991b255d78f5f2464200a5db6acce262b
7
+ data.tar.gz: 2cd95f1e5369205332ad1f87d313b6dc99801d7bdb6f13d0b661cfbba4a771a9e8277e6e8f43e8234a2bea1a6ba23cc73ef54e480f2cf73788bc6dd17aa8de74
data/lib/aidp/cli.rb CHANGED
@@ -146,6 +146,16 @@ module Aidp
146
146
 
147
147
  class << self
148
148
  extend Aidp::MessageDisplay::ClassMethods
149
+ extend Aidp::RescueLogging
150
+
151
+ # Explicit singleton delegator (defensive: ensure availability even if extend fails to attach)
152
+ def log_rescue(error, component:, action:, fallback: nil, level: :warn, **context)
153
+ Aidp::RescueLogging.log_rescue(error, component: component, action: action, fallback: fallback, level: level, **context)
154
+ end
155
+
156
+ # Store last parsed options for access by UI components (e.g., verbose flag)
157
+ @last_options = nil
158
+ attr_accessor :last_options
149
159
 
150
160
  def create_prompt
151
161
  ::TTY::Prompt.new
@@ -156,6 +166,7 @@ module Aidp
156
166
  return run_subcommand(args) if subcommand?(args)
157
167
 
158
168
  options = parse_options(args)
169
+ self.last_options = options
159
170
 
160
171
  if options[:help]
161
172
  display_message(options[:parser].to_s, type: :info)
@@ -324,6 +335,7 @@ module Aidp
324
335
  opts.on("-h", "--help", "Show this help message") { options[:help] = true }
325
336
  opts.on("-v", "--version", "Show version information") { options[:version] = true }
326
337
  opts.on("--setup-config", "Setup or reconfigure config file") { options[:setup_config] = true }
338
+ opts.on("--verbose", "Show detailed prompts and raw provider responses during guided workflow") { options[:verbose] = true }
327
339
 
328
340
  opts.separator ""
329
341
  opts.separator "Examples:"
@@ -18,21 +18,27 @@ module Aidp
18
18
  # Callers should set skip_persistence: true for test/dry-run scenarios
19
19
  @skip_persistence = skip_persistence
20
20
  ensure_state_directory
21
+ Aidp.log_debug("state_persistence", "initialized", mode: @mode, skip: @skip_persistence, dir: @state_dir)
21
22
  end
22
23
 
23
24
  def has_state?
24
25
  return false if @skip_persistence
25
- File.exist?(@state_file)
26
+ exists = File.exist?(@state_file)
27
+ Aidp.log_debug("state_persistence", "has_state?", exists: exists, file: @state_file) if exists
28
+ exists
26
29
  end
27
30
 
28
31
  def load_state
29
32
  return {} if @skip_persistence || !has_state?
30
33
 
31
34
  with_lock do
35
+ Aidp.log_debug("state_persistence", "load_state.start", file: @state_file)
32
36
  content = File.read(@state_file)
33
- JSON.parse(content, symbolize_names: true)
37
+ parsed = JSON.parse(content, symbolize_names: true)
38
+ Aidp.log_debug("state_persistence", "load_state.success", keys: parsed.keys.size, file: @state_file)
39
+ parsed
34
40
  rescue JSON::ParserError => e
35
- warn "Failed to parse state file: #{e.message}"
41
+ Aidp.log_warn("state_persistence", "parse_error", error: e.message, file: @state_file)
36
42
  {}
37
43
  end
38
44
  end
@@ -41,8 +47,10 @@ module Aidp
41
47
  return if @skip_persistence
42
48
 
43
49
  with_lock do
50
+ Aidp.log_debug("state_persistence", "save_state.start", keys: state_data.keys.size)
44
51
  state_with_metadata = add_metadata(state_data)
45
52
  write_atomically(state_with_metadata)
53
+ Aidp.log_debug("state_persistence", "save_state.written", file: @state_file, size: state_with_metadata.keys.size)
46
54
  end
47
55
  end
48
56
 
@@ -50,7 +58,9 @@ module Aidp
50
58
  return if @skip_persistence
51
59
 
52
60
  with_lock do
61
+ Aidp.log_debug("state_persistence", "clear_state.start", file: @state_file)
53
62
  File.delete(@state_file) if File.exist?(@state_file)
63
+ Aidp.log_debug("state_persistence", "clear_state.done", file: @state_file)
54
64
  end
55
65
  end
56
66
 
@@ -66,8 +76,10 @@ module Aidp
66
76
 
67
77
  def write_atomically(state_with_metadata)
68
78
  temp_file = "#{@state_file}.tmp"
79
+ Aidp.log_debug("state_persistence", "write_atomically.start", temp: temp_file)
69
80
  File.write(temp_file, JSON.pretty_generate(state_with_metadata))
70
81
  File.rename(temp_file, @state_file)
82
+ Aidp.log_debug("state_persistence", "write_atomically.rename", file: @state_file)
71
83
  end
72
84
 
73
85
  def ensure_state_directory
@@ -76,45 +88,50 @@ module Aidp
76
88
 
77
89
  def with_lock(&block)
78
90
  return yield if @skip_persistence
79
-
80
- acquire_lock_with_timeout(&block)
91
+ result = acquire_lock_with_timeout(&block)
92
+ result
81
93
  ensure
82
94
  cleanup_lock_file
83
95
  end
84
96
 
85
97
  def acquire_lock_with_timeout(&block)
86
- lock_acquired = false
87
- timeout = 30
98
+ timeout = ENV["AIDP_STATE_LOCK_TIMEOUT"]&.to_f || ((ENV["RSPEC_RUNNING"] == "true") ? 1.0 : 30.0)
88
99
  start_time = Time.now
89
-
100
+ attempt_result = nil
90
101
  while (Time.now - start_time) < timeout
91
- lock_acquired = try_acquire_lock(&block)
92
- break if lock_acquired
102
+ acquired, attempt_result = try_acquire_lock(&block)
103
+ return attempt_result if acquired
93
104
  sleep_briefly
94
105
  end
95
-
96
- raise_lock_timeout_error unless lock_acquired
106
+ raise_lock_timeout_error(timeout)
97
107
  end
98
108
 
99
109
  def try_acquire_lock(&block)
100
110
  File.open(@lock_file, File::CREAT | File::EXCL | File::WRONLY) do |_lock|
101
- yield
102
- true
111
+ Aidp.log_debug("state_persistence", "lock.acquired", file: @lock_file)
112
+ [true, yield]
103
113
  end
104
114
  rescue Errno::EEXIST
105
- false
115
+ Aidp.log_debug("state_persistence", "lock.busy", file: @lock_file)
116
+ [false, nil]
106
117
  end
107
118
 
108
119
  def sleep_briefly
109
- sleep(0.1)
120
+ sleep(ENV["AIDP_STATE_LOCK_SLEEP"]&.to_f || 0.05)
110
121
  end
111
122
 
112
- def raise_lock_timeout_error
113
- raise "Could not acquire state lock within 30 seconds"
123
+ def raise_lock_timeout_error(timeout)
124
+ # Prefer explicit error class; fall back if not defined yet
125
+ error_class = defined?(Aidp::Errors::StateError) ? Aidp::Errors::StateError : RuntimeError
126
+ Aidp.log_error("state_persistence", "lock.timeout", file: @lock_file, waited: timeout)
127
+ raise error_class, "Could not acquire state lock within #{timeout} seconds"
114
128
  end
115
129
 
116
130
  def cleanup_lock_file
117
- File.delete(@lock_file) if File.exist?(@lock_file)
131
+ if File.exist?(@lock_file)
132
+ File.delete(@lock_file)
133
+ Aidp.log_debug("state_persistence", "lock.cleaned", file: @lock_file)
134
+ end
118
135
  end
119
136
  end
120
137
  end
@@ -251,7 +251,10 @@ module Aidp
251
251
  def select_guided_workflow
252
252
  # Use the guided agent to help user select workflow
253
253
  # Don't pass prompt so it uses EnhancedInput with full readline support
254
- guided_agent = Aidp::Workflows::GuidedAgent.new(@project_dir)
254
+ verbose_flag = (defined?(Aidp::CLI) && Aidp::CLI.respond_to?(:last_options) && Aidp::CLI.last_options) ? Aidp::CLI.last_options[:verbose] : false
255
+ # Fallback: store verbose in an env for easier access if options not available
256
+ verbose = verbose_flag || ENV["AIDP_VERBOSE"] == "1"
257
+ guided_agent = Aidp::Workflows::GuidedAgent.new(@project_dir, verbose: verbose)
255
258
  result = guided_agent.select_workflow
256
259
 
257
260
  # Store user input for later use
data/lib/aidp/logger.rb CHANGED
@@ -33,7 +33,7 @@ module Aidp
33
33
  attr_reader :level, :json_format
34
34
 
35
35
  def initialize(project_dir = Dir.pwd, config = {})
36
- @project_dir = project_dir
36
+ @project_dir = sanitize_project_dir(project_dir)
37
37
  @config = config
38
38
  @level = determine_log_level
39
39
  @json_format = config[:json] || false
@@ -195,6 +195,18 @@ module Aidp
195
195
  def redact_hash(hash)
196
196
  hash.transform_values { |v| v.is_a?(String) ? redact(v) : v }
197
197
  end
198
+
199
+ # Guard against accidentally passing stream sentinel strings or invalid characters
200
+ # that would create odd top-level directories like "<STDERR>".
201
+ def sanitize_project_dir(dir)
202
+ return Dir.pwd if dir.nil?
203
+ str = dir.to_s
204
+ if str.empty? || str.match?(/[<>|]/) || str.match?(/[\x00-\x1F]/)
205
+ Kernel.warn "[AIDP Logger] Invalid project_dir '#{str}' - falling back to #{Dir.pwd}"
206
+ return Dir.pwd
207
+ end
208
+ str
209
+ end
198
210
  end
199
211
 
200
212
  # Module-level logger accessor
@@ -13,24 +13,35 @@ module Aidp
13
13
  # - includes error class, message
14
14
  # - optional fallback and extra context hash merged in
15
15
  module RescueLogging
16
+ # Instance-level helper (made public so extend works for singleton contexts)
16
17
  def log_rescue(error, component:, action:, fallback: nil, level: :warn, **context)
18
+ Aidp::RescueLogging.__log_rescue_impl(self, error, component: component, action: action, fallback: fallback, level: level, **context)
19
+ end
20
+
21
+ # Module-level access (Aidp::RescueLogging.log_rescue) for direct calls if desired
22
+ def self.log_rescue(error, component:, action:, fallback: nil, level: :warn, **context)
23
+ Aidp::RescueLogging.__log_rescue_impl(self, error, component: component, action: action, fallback: fallback, level: level, **context)
24
+ end
25
+
26
+ # Internal implementation shared by instance & module forms
27
+ def self.__log_rescue_impl(context_object, error, component:, action:, fallback:, level:, **extra)
17
28
  data = {
18
29
  error_class: error.class.name,
19
30
  error_message: error.message,
20
31
  action: action
21
32
  }
22
33
  data[:fallback] = fallback if fallback
23
- data.merge!(context) unless context.empty?
34
+ data.merge!(extra) unless extra.empty?
24
35
 
25
- # Prefer debug_mixin if present; otherwise use Aidp.logger directly
26
- if respond_to?(:debug_log)
27
- debug_log("⚠️ Rescue in #{component}: #{action}", level: level, data: data)
28
- else
29
- Aidp.logger.send(level, component, "Rescued exception during #{action}", **data)
36
+ begin
37
+ if context_object.respond_to?(:debug_log)
38
+ context_object.debug_log("⚠️ Rescue in #{component}: #{action}", level: level, data: data)
39
+ else
40
+ Aidp.logger.send(level, component, "Rescued exception during #{action}", **data)
41
+ end
42
+ rescue => logging_error
43
+ warn "[AIDP Rescue Logging Error] Failed to log rescue for #{component}:#{action} - #{error.class}: #{error.message} (logging error: #{logging_error.message})"
30
44
  end
31
- rescue => logging_error
32
- # Last resort: avoid raising from logging path - fall back to STDERR
33
- warn "[AIDP Rescue Logging Error] Failed to log rescue for #{component}:#{action} - #{error.class}: #{error.message} (logging error: #{logging_error.message})"
34
45
  end
35
46
  end
36
47
  end
@@ -30,6 +30,8 @@ module Aidp
30
30
 
31
31
  def run
32
32
  display_welcome
33
+ # Normalize any legacy or label-based model_family entries before prompting
34
+ normalize_existing_model_families!
33
35
  return @saved if skip_wizard?
34
36
 
35
37
  configure_providers
@@ -150,11 +152,30 @@ module Aidp
150
152
  fallback_choices = available_providers.reject { |_, name| name == provider_choice }
151
153
  fallback_default_names = existing_fallbacks.filter_map { |provider_name| fallback_choices.key(provider_name) }
152
154
 
155
+ prompt.say("\n💡 Use ↑/↓ arrows to navigate, SPACE to select/deselect, ENTER to confirm")
153
156
  fallback_selected = prompt.multi_select("Select fallback providers (used if primary fails):", default: fallback_default_names) do |menu|
154
157
  fallback_choices.each do |display_name, provider_name|
155
158
  menu.choice display_name, provider_name
156
159
  end
157
160
  end
161
+ if ENV["AIDP_FALLBACK_DEBUG"] == "1"
162
+ prompt.say("[debug] raw multi_select fallback_selected=#{fallback_selected.inspect}")
163
+ end
164
+ # Recovery: if multi_select unexpectedly returns empty and there were no existing fallbacks, offer a single-select
165
+ if fallback_selected.empty? && existing_fallbacks.empty? && !fallback_choices.empty?
166
+ if ENV["AIDP_FALLBACK_DEBUG"] == "1"
167
+ prompt.say("[debug] invoking recovery single-select for first fallback")
168
+ end
169
+ if prompt.yes?("No fallback selected. Add one?", default: true)
170
+ recovery_choice = prompt.select("Select a fallback provider:") do |menu|
171
+ fallback_choices.each do |display_name, provider_name|
172
+ menu.choice display_name, provider_name
173
+ end
174
+ menu.choice "Skip", :skip
175
+ end
176
+ fallback_selected = [recovery_choice] unless recovery_choice == :skip
177
+ end
178
+ end
158
179
 
159
180
  # If user selected none but we had existing fallbacks, confirm removal
160
181
  if fallback_selected.empty? && existing_fallbacks.any?
@@ -167,10 +188,81 @@ module Aidp
167
188
  set([:harness, :fallback_providers], cleaned_fallbacks)
168
189
 
169
190
  # Auto-create minimal provider configs for fallbacks if missing
170
- cleaned_fallbacks.each { |fp| ensure_provider_billing_config(fp) }
191
+ cleaned_fallbacks.each do |fp|
192
+ prompt.say("[debug] ensuring billing config for fallback '#{fp}'") if ENV["AIDP_FALLBACK_DEBUG"] == "1"
193
+ ensure_provider_billing_config(fp, force: true)
194
+ end
195
+
196
+ # Offer editing of existing provider configurations (primary + fallbacks)
197
+ # (editable will be recomputed after any additional fallback additions)
198
+ ([provider_choice] + cleaned_fallbacks).uniq.reject { |p| p == "custom" }
199
+
200
+ # Optional: allow adding more fallbacks iteratively
201
+ if prompt.yes?("Add another fallback provider?", default: false)
202
+ loop do
203
+ remaining = available_providers.reject { |_, name| ([provider_choice] + cleaned_fallbacks).include?(name) }
204
+ break if remaining.empty?
205
+ add_choice = prompt.select("Select additional fallback provider:") do |menu|
206
+ remaining.each { |display, name| menu.choice display, name }
207
+ menu.choice "Done", :done
208
+ end
209
+ break if add_choice == :done
210
+ unless cleaned_fallbacks.include?(add_choice)
211
+ cleaned_fallbacks << add_choice
212
+ set([:harness, :fallback_providers], cleaned_fallbacks)
213
+ ensure_provider_billing_config(add_choice, force: true)
214
+ end
215
+ end
216
+ end
217
+ # Recompute editable after additions
218
+ editable = ([provider_choice] + cleaned_fallbacks).uniq.reject { |p| p == "custom" }
219
+ if editable.any? && prompt.yes?("Edit provider configuration details (billing/model family)?", default: false)
220
+ loop do
221
+ # Build dynamic mapping of display names -> internal names for edit menu
222
+ available_map = discover_available_providers # {display_name => internal_name}
223
+ display_name_for = available_map.invert # {internal_name => display_name}
224
+ to_edit = prompt.select("Select a provider to edit or add:") do |menu|
225
+ editable.each do |prov|
226
+ display_label = display_name_for.fetch(prov, prov.capitalize)
227
+ menu.choice display_label, prov
228
+ end
229
+ # Sentinel option: add a new fallback provider that isn't yet in editable list
230
+ remaining = available_map.values - editable
231
+ if remaining.any?
232
+ menu.choice "➕ Add fallback provider…", :add_fallback
233
+ end
234
+ menu.choice "Done", :done
235
+ end
236
+
237
+ case to_edit
238
+ when :done
239
+ break
240
+ when :add_fallback
241
+ # Allow user to pick from remaining providers by display name
242
+ remaining_map = available_map.select { |disp, internal| !editable.include?(internal) && internal != provider_choice }
243
+ add_choice = prompt.select("Select provider to add as fallback:") do |menu|
244
+ remaining_map.each { |disp, internal| menu.choice disp, internal }
245
+ menu.choice "Cancel", :cancel
246
+ end
247
+ next if add_choice == :cancel
248
+ unless cleaned_fallbacks.include?(add_choice)
249
+ cleaned_fallbacks << add_choice
250
+ set([:harness, :fallback_providers], cleaned_fallbacks)
251
+ prompt.say("[debug] ensuring billing config for newly added fallback '#{add_choice}'") if ENV["AIDP_FALLBACK_DEBUG"] == "1"
252
+ ensure_provider_billing_config(add_choice, force: true)
253
+ editable = ([provider_choice] + cleaned_fallbacks).uniq.reject { |p| p == "custom" }
254
+ end
255
+ else
256
+ edit_provider_configuration(to_edit)
257
+ end
258
+ end
259
+ end
171
260
 
172
261
  # Provide informational note (no secret handling stored)
173
262
  show_provider_info_note(provider_choice) unless provider_choice == "custom"
263
+
264
+ # Show summary of configured providers (replaces the earlier inline summary)
265
+ show_provider_summary(provider_choice, cleaned_fallbacks) unless provider_choice == "custom"
174
266
  end
175
267
 
176
268
  # Removed MCP configuration step (MCP now expected to be provider-specific if used)
@@ -262,13 +354,19 @@ module Aidp
262
354
  enabled = prompt.yes?("Enable coverage tracking?", default: existing.fetch(:enabled, false))
263
355
  return set([:work_loop, :coverage], {enabled: false}) unless enabled
264
356
 
265
- tool = prompt.select("Which coverage tool do you use?", default: existing[:tool]) do |menu|
266
- menu.choice "SimpleCov (Ruby)", "simplecov"
267
- menu.choice "NYC/Istanbul (JavaScript)", "nyc"
268
- menu.choice "Coverage.py (Python)", "coverage.py"
269
- menu.choice "go test -cover (Go)", "go-cover"
270
- menu.choice "Jest (JavaScript)", "jest"
271
- menu.choice "Other", "other"
357
+ coverage_tool_choices = [
358
+ ["SimpleCov (Ruby)", "simplecov"],
359
+ ["NYC/Istanbul (JavaScript)", "nyc"],
360
+ ["Coverage.py (Python)", "coverage.py"],
361
+ ["go test -cover (Go)", "go-cover"],
362
+ ["Jest (JavaScript)", "jest"],
363
+ ["Other", "other"]
364
+ ]
365
+ coverage_tool_default = existing[:tool]
366
+ coverage_tool_default_label = coverage_tool_choices.find { |label, value| value == coverage_tool_default }&.first
367
+
368
+ tool = prompt.select("Which coverage tool do you use?", default: coverage_tool_default_label) do |menu|
369
+ coverage_tool_choices.each { |label, value| menu.choice label, value }
272
370
  end
273
371
 
274
372
  run_command = ask_with_default("Coverage run command", existing[:run_command] || detect_coverage_command(tool))
@@ -300,10 +398,16 @@ module Aidp
300
398
  enabled = prompt.yes?("Enable interactive testing tools?", default: existing.fetch(:enabled, false))
301
399
  return set([:work_loop, :interactive_testing], {enabled: false}) unless enabled
302
400
 
303
- app_type = prompt.select("What type of application are you testing?", default: existing[:app_type]) do |menu|
304
- menu.choice "Web application", "web"
305
- menu.choice "CLI application", "cli"
306
- menu.choice "Desktop application", "desktop"
401
+ app_type_choices = [
402
+ ["Web application", "web"],
403
+ ["CLI application", "cli"],
404
+ ["Desktop application", "desktop"]
405
+ ]
406
+ app_type_default = existing[:app_type]
407
+ app_type_default_label = app_type_choices.find { |label, value| value == app_type_default }&.first
408
+
409
+ app_type = prompt.select("What type of application are you testing?", default: app_type_default_label) do |menu|
410
+ app_type_choices.each { |label, value| menu.choice label, value }
307
411
  end
308
412
 
309
413
  tools = {}
@@ -382,17 +486,21 @@ module Aidp
382
486
 
383
487
  # Detect VCS
384
488
  detected_vcs = detect_vcs_tool
489
+ vcs_choices = [
490
+ ["git", "git"],
491
+ ["svn", "svn"],
492
+ ["none (no VCS)", "none"]
493
+ ]
494
+ vcs_default = existing[:tool] || detected_vcs || "git"
495
+ vcs_default_label = vcs_choices.find { |label, value| value == vcs_default }&.first
496
+
385
497
  vcs_tool = if detected_vcs
386
- prompt.select("Detected #{detected_vcs}. Use this version control system?", default: existing[:tool] || detected_vcs) do |menu|
387
- menu.choice "git", "git"
388
- menu.choice "svn", "svn"
389
- menu.choice "none (no VCS)", "none"
498
+ prompt.select("Detected #{detected_vcs}. Use this version control system?", default: vcs_default_label) do |menu|
499
+ vcs_choices.each { |label, value| menu.choice label, value }
390
500
  end
391
501
  else
392
- prompt.select("Which version control system do you use?", default: existing[:tool] || "git") do |menu|
393
- menu.choice "git", "git"
394
- menu.choice "svn", "svn"
395
- menu.choice "none (no VCS)", "none"
502
+ prompt.select("Which version control system do you use?", default: vcs_default_label) do |menu|
503
+ vcs_choices.each { |label, value| menu.choice label, value }
396
504
  end
397
505
  end
398
506
 
@@ -400,10 +508,18 @@ module Aidp
400
508
 
401
509
  prompt.say("\n📋 Commit Behavior (applies to copilot/interactive mode only)")
402
510
  prompt.say("Note: Watch mode and fully automatic daemon mode will always commit changes.")
403
- behavior = prompt.select("In copilot mode, should aidp:", default: existing[:behavior] || "nothing") do |menu|
404
- menu.choice "Do nothing (manual git operations)", "nothing"
405
- menu.choice "Stage changes only", "stage"
406
- menu.choice "Stage and commit changes", "commit"
511
+
512
+ # Map value defaults to choice labels for TTY::Prompt validation
513
+ behavior_choices = [
514
+ ["Do nothing (manual git operations)", "nothing"],
515
+ ["Stage changes only", "stage"],
516
+ ["Stage and commit changes", "commit"]
517
+ ]
518
+ behavior_default = existing[:behavior] || "nothing"
519
+ behavior_default_label = behavior_choices.find { |label, value| value == behavior_default }&.first
520
+
521
+ behavior = prompt.select("In copilot mode, should aidp:", default: behavior_default_label) do |menu|
522
+ behavior_choices.each { |label, value| menu.choice label, value }
407
523
  end
408
524
 
409
525
  # Commit message configuration
@@ -437,10 +553,16 @@ module Aidp
437
553
 
438
554
  # Commit message style
439
555
  commit_style = if conventional_commits
440
- prompt.select("Conventional commit style:", default: existing[:commit_style] || "default") do |menu|
441
- menu.choice "Default (e.g., 'feat: add user authentication')", "default"
442
- menu.choice "Angular (with scope: 'feat(auth): add login')", "angular"
443
- menu.choice "Emoji (e.g., '✨ feat: add user authentication')", "emoji"
556
+ commit_style_choices = [
557
+ ["Default (e.g., 'feat: add user authentication')", "default"],
558
+ ["Angular (with scope: 'feat(auth): add login')", "angular"],
559
+ ["Emoji (e.g., '✨ feat: add user authentication')", "emoji"]
560
+ ]
561
+ commit_style_default = existing[:commit_style] || "default"
562
+ commit_style_default_label = commit_style_choices.find { |label, value| value == commit_style_default }&.first
563
+
564
+ prompt.select("Conventional commit style:", default: commit_style_default_label) do |menu|
565
+ commit_style_choices.each { |label, value| menu.choice label, value }
444
566
  end
445
567
  else
446
568
  "default"
@@ -476,10 +598,16 @@ module Aidp
476
598
  )
477
599
 
478
600
  if auto_create_pr
479
- pr_strategy = prompt.select("PR creation strategy:", default: existing[:pr_strategy] || "draft") do |menu|
480
- menu.choice "Create as draft PR (safe, allows review before merge)", "draft"
481
- menu.choice "Create as ready PR (immediately reviewable)", "ready"
482
- menu.choice "Create and auto-merge (fully autonomous, requires approval rules)", "auto_merge"
601
+ pr_strategy_choices = [
602
+ ["Create as draft PR (safe, allows review before merge)", "draft"],
603
+ ["Create as ready PR (immediately reviewable)", "ready"],
604
+ ["Create and auto-merge (fully autonomous, requires approval rules)", "auto_merge"]
605
+ ]
606
+ pr_strategy_default = existing[:pr_strategy] || "draft"
607
+ pr_strategy_default_label = pr_strategy_choices.find { |label, value| value == pr_strategy_default }&.first
608
+
609
+ pr_strategy = prompt.select("PR creation strategy:", default: pr_strategy_default_label) do |menu|
610
+ pr_strategy_choices.each { |label, value| menu.choice label, value }
483
611
  end
484
612
 
485
613
  {
@@ -614,11 +742,16 @@ module Aidp
614
742
  prompt.say("-" * 40)
615
743
  existing = get([:logging]) || {}
616
744
 
617
- # TODO: Add default back once TTY-Prompt default validation issue is resolved
618
- log_level = prompt.select("Log level:") do |menu|
619
- menu.choice "Debug", "debug"
620
- menu.choice "Info", "info"
621
- menu.choice "Error", "error"
745
+ log_level_choices = [
746
+ ["Debug", "debug"],
747
+ ["Info", "info"],
748
+ ["Error", "error"]
749
+ ]
750
+ log_level_default = existing[:level] || "info"
751
+ log_level_default_label = log_level_choices.find { |label, value| value == log_level_default }&.first
752
+
753
+ log_level = prompt.select("Log level:", default: log_level_default_label) do |menu|
754
+ log_level_choices.each { |label, value| menu.choice label, value }
622
755
  end
623
756
  json = prompt.yes?("Use JSON log format?", default: existing.fetch(:json, false))
624
757
  max_size = ask_with_default("Max log size (MB)", (existing[:max_size_mb] || 10).to_s) { |value| value.to_i }
@@ -897,44 +1030,132 @@ module Aidp
897
1030
  prompt.say("Only the billing model (subscription vs usage_based) is recorded for fallback decisions.")
898
1031
  end
899
1032
 
1033
+ def show_provider_summary(primary, fallbacks)
1034
+ prompt.say("\n📋 Provider Configuration Summary:")
1035
+ providers_config = get([:providers]) || {}
1036
+
1037
+ # Show primary
1038
+ if primary && primary != "custom"
1039
+ primary_cfg = providers_config[primary.to_sym] || {}
1040
+ prompt.say(" ✓ Primary: #{primary} (#{primary_cfg[:type] || "not configured"}, #{primary_cfg[:model_family] || "auto"})")
1041
+ end
1042
+
1043
+ # Show fallbacks
1044
+ if fallbacks && !fallbacks.empty?
1045
+ fallbacks.each do |fallback|
1046
+ fallback_cfg = providers_config[fallback.to_sym] || {}
1047
+ prompt.say(" ✓ Fallback: #{fallback} (#{fallback_cfg[:type] || "not configured"}, #{fallback_cfg[:model_family] || "auto"})")
1048
+ end
1049
+ end
1050
+ end
1051
+
900
1052
  # Ensure a minimal billing configuration exists for a selected provider (no secrets)
901
- def ensure_provider_billing_config(provider_name)
1053
+ def ensure_provider_billing_config(provider_name, force: false)
902
1054
  return if provider_name.nil? || provider_name == "custom"
903
1055
  providers_section = get([:providers]) || {}
904
1056
  existing = providers_section[provider_name.to_sym]
905
1057
 
906
- if existing && existing[:type]
1058
+ if existing && existing[:type] && !force
907
1059
  prompt.say(" • Provider '#{provider_name}' already configured (type: #{existing[:type]})")
908
- # Still ask for model family if not set
909
1060
  unless existing[:model_family]
910
- model_family = ask_model_family(provider_name, existing[:model_family])
1061
+ model_family = ask_model_family(provider_name)
911
1062
  set([:providers, provider_name.to_sym, :model_family], model_family)
912
1063
  end
913
1064
  return
914
1065
  end
915
1066
 
916
- provider_type = ask_provider_billing_type(provider_name)
917
- model_family = ask_model_family(provider_name)
918
- set([:providers, provider_name.to_sym], {type: provider_type, model_family: model_family})
919
- prompt.say(" • Added provider '#{provider_name}' with billing type '#{provider_type}' and model family '#{model_family}' (no secrets stored)")
1067
+ provider_type = ask_provider_billing_type_with_default(provider_name, existing&.dig(:type))
1068
+ model_family = ask_model_family(provider_name, existing&.dig(:model_family) || "auto")
1069
+ merged = (existing || {}).merge(type: provider_type, model_family: model_family)
1070
+ set([:providers, provider_name.to_sym], merged)
1071
+ normalize_existing_model_families!
1072
+ action_word = if existing
1073
+ force ? "reconfigured" : "updated"
1074
+ else
1075
+ "added"
1076
+ end
1077
+ # Enhance messaging with display name when available
1078
+ display_name = discover_available_providers.invert.fetch(provider_name, provider_name)
1079
+ prompt.say(" • #{action_word.capitalize} provider '#{display_name}' (#{provider_name}) with billing type '#{provider_type}' and model family '#{model_family}'")
1080
+ end
1081
+
1082
+ def edit_provider_configuration(provider_name)
1083
+ existing = get([:providers, provider_name.to_sym]) || {}
1084
+ prompt.say("\n🔧 Editing provider '#{provider_name}' (current: type=#{existing[:type] || "unset"}, model_family=#{existing[:model_family] || "unset"})")
1085
+ new_type = ask_provider_billing_type_with_default(provider_name, existing[:type])
1086
+ new_family = ask_model_family(provider_name, existing[:model_family] || "auto")
1087
+ set([:providers, provider_name.to_sym], {type: new_type, model_family: new_family})
1088
+ # Normalize immediately so tests relying on canonical value see 'claude' rather than label
1089
+ normalize_existing_model_families!
1090
+ prompt.ok("Updated '#{provider_name}' → type=#{new_type}, model_family=#{new_family}")
920
1091
  end
921
1092
 
922
1093
  def ask_provider_billing_type(provider_name)
923
- prompt.select("Billing model for #{provider_name}:") do |menu|
924
- menu.choice "Subscription / flat-rate", "subscription"
925
- # e.g. tools that expose an integrated model under a subscription cost
926
- menu.choice "Usage-based / metered (API)", "usage_based"
927
- menu.choice "Passthrough / local (no billing)", "passthrough"
1094
+ ask_provider_billing_type_with_default(provider_name, nil)
1095
+ end
1096
+
1097
+ BILLING_TYPE_CHOICES = [
1098
+ ["Subscription / flat-rate", "subscription"],
1099
+ ["Usage-based / metered (API)", "usage_based"],
1100
+ ["Passthrough / local (no billing)", "passthrough"]
1101
+ ].freeze
1102
+
1103
+ def ask_provider_billing_type_with_default(provider_name, default_value)
1104
+ default_label = BILLING_TYPE_CHOICES.find { |label, value| value == default_value }&.first
1105
+ suffix = default_value ? " (current: #{default_value})" : ""
1106
+ prompt.select("Billing model for #{provider_name}:#{suffix}", default: default_label) do |menu|
1107
+ BILLING_TYPE_CHOICES.each do |label, value|
1108
+ menu.choice(label, value)
1109
+ end
928
1110
  end
929
1111
  end
930
1112
 
1113
+ MODEL_FAMILY_CHOICES = [
1114
+ ["Auto (let provider decide)", "auto"],
1115
+ ["OpenAI o-series (reasoning models)", "openai_o"],
1116
+ ["Anthropic Claude (balanced)", "claude"],
1117
+ ["Mistral (European/open)", "mistral"],
1118
+ ["Local LLM (self-hosted)", "local"]
1119
+ ].freeze
1120
+
931
1121
  def ask_model_family(provider_name, default = "auto")
932
- prompt.select("Preferred model family for #{provider_name}:", default: default) do |menu|
933
- menu.choice "Auto (let provider decide)", "auto"
934
- menu.choice "OpenAI o-series (reasoning models)", "openai_o"
935
- menu.choice "Anthropic Claude (balanced)", "claude"
936
- menu.choice "Mistral (European/open)", "mistral"
937
- menu.choice "Local LLM (self-hosted)", "local"
1122
+ # TTY::Prompt validates defaults against the displayed choice labels, not values.
1123
+ # Map the value default (e.g. "auto") to its corresponding label.
1124
+ default_label = MODEL_FAMILY_CHOICES.find { |label, value| value == default }&.first
1125
+
1126
+ prompt.select("Preferred model family for #{provider_name}:", default: default_label) do |menu|
1127
+ MODEL_FAMILY_CHOICES.each do |label, value|
1128
+ menu.choice(label, value)
1129
+ end
1130
+ end
1131
+ end
1132
+
1133
+ # Canonicalization helpers ------------------------------------------------
1134
+ MODEL_FAMILY_LABEL_TO_VALUE = MODEL_FAMILY_CHOICES.each_with_object({}) do |(label, value), h|
1135
+ h[label] = value
1136
+ end.freeze
1137
+ MODEL_FAMILY_VALUES = MODEL_FAMILY_CHOICES.map { |(_, value)| value }.freeze
1138
+
1139
+ def normalize_model_family(value)
1140
+ return "auto" if value.nil? || value.to_s.strip.empty?
1141
+ # Already a canonical value
1142
+ return value if MODEL_FAMILY_VALUES.include?(value)
1143
+ # Try label -> value
1144
+ mapped = MODEL_FAMILY_LABEL_TO_VALUE[value]
1145
+ return mapped if mapped
1146
+ # Unknown legacy entry -> fallback to auto
1147
+ "auto"
1148
+ end
1149
+
1150
+ def normalize_existing_model_families!
1151
+ providers_cfg = @config[:providers]
1152
+ return unless providers_cfg.is_a?(Hash)
1153
+ providers_cfg.each do |prov_name, prov_cfg|
1154
+ next unless prov_cfg.is_a?(Hash)
1155
+ mf = prov_cfg[:model_family]
1156
+ # Normalize and write back only if different to avoid unnecessary YAML churn
1157
+ normalized = normalize_model_family(mf)
1158
+ prov_cfg[:model_family] = normalized
938
1159
  end
939
1160
  end
940
1161
 
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.19.1"
4
+ VERSION = "0.20.0"
5
5
  end
@@ -18,7 +18,7 @@ module Aidp
18
18
 
19
19
  class ConversationError < StandardError; end
20
20
 
21
- def initialize(project_dir, prompt: nil, use_enhanced_input: true)
21
+ def initialize(project_dir, prompt: nil, use_enhanced_input: true, verbose: false)
22
22
  @project_dir = project_dir
23
23
 
24
24
  # Use EnhancedInput with Reline for full readline-style key bindings
@@ -32,6 +32,9 @@ module Aidp
32
32
  @provider_manager = Aidp::Harness::ProviderManager.new(@config_manager, prompt: @prompt)
33
33
  @conversation_history = []
34
34
  @user_input = {}
35
+ @invalid_planning_responses = 0
36
+ @verbose = verbose
37
+ @debug_env = ENV["DEBUG"] == "1" || ENV["DEBUG"] == "2"
35
38
  end
36
39
 
37
40
  # Main entry point for guided workflow selection
@@ -79,6 +82,7 @@ module Aidp
79
82
  iteration += 1
80
83
  # Ask AI for next question based on current plan
81
84
  question_response = get_planning_questions(plan)
85
+ emit_verbose_iteration(plan, question_response)
82
86
 
83
87
  # Debug: show raw provider response and parsed result
84
88
  debug_log("Planning iteration #{iteration} provider response", level: :debug, data: {
@@ -131,9 +135,10 @@ module Aidp
131
135
  end
132
136
 
133
137
  response = call_provider_for_analysis(system_prompt, user_prompt)
134
- parsed = parse_planning_response(response)
138
+ parsed = safe_parse_planning_response(response)
135
139
  # Attach raw response for debug
136
140
  parsed[:raw_response] = response
141
+ emit_verbose_raw_prompt(system_prompt, user_prompt, response)
137
142
  parsed
138
143
  end
139
144
 
@@ -239,16 +244,64 @@ module Aidp
239
244
 
240
245
  if classified && attempts < max_attempts
241
246
  display_message("⚠️ Provider '#{provider_name}' #{classified.tr("_", " ")} – attempting fallback...", type: :warning)
242
- switched = @provider_manager.switch_provider_for_error(classified, stderr: message) if @provider_manager.respond_to?(:switch_provider_for_error)
243
- if switched && switched != provider_name
244
- display_message("↩️ Switched to provider '#{switched}'", type: :info)
245
- retry
247
+ if @provider_manager.respond_to?(:switch_provider_for_error)
248
+ switched = @provider_manager.switch_provider_for_error(classified, stderr: message)
249
+ if switched && switched != provider_name
250
+ display_message("↩️ Switched to provider '#{switched}'", type: :info)
251
+ retry
252
+ elsif switched == provider_name
253
+ # ProviderManager could not advance; mark current as rate limited to encourage next attempt to move on.
254
+ Aidp.logger.debug("guided_agent", "provider_switch_noop", provider: provider_name, reason: classified)
255
+ if @provider_manager.respond_to?(:mark_rate_limited)
256
+ @provider_manager.mark_rate_limited(provider_name)
257
+ next_provider = @provider_manager.switch_provider("rate_limit_forced", previous_error: message)
258
+ if next_provider && next_provider != provider_name
259
+ display_message("↩️ Switched to provider '#{next_provider}' (forced)", type: :info)
260
+ retry
261
+ end
262
+ end
263
+ end
246
264
  end
247
265
  end
248
266
  raise
249
267
  end
250
268
  end
251
269
 
270
+ # Verbose output helpers
271
+ def emit_verbose_raw_prompt(system_prompt, user_prompt, raw_response)
272
+ return unless @verbose || @debug_env
273
+ if @verbose
274
+ display_message("\n--- Prompt Sent (Planning) ---", type: :muted)
275
+ display_message(system_prompt.strip, type: :muted)
276
+ display_message(user_prompt.strip, type: :muted)
277
+ display_message("--- Raw Provider Response ---", type: :muted)
278
+ display_message(raw_response.to_s.strip, type: :muted)
279
+ display_message("------------------------------\n", type: :muted)
280
+ elsif @debug_env
281
+ Aidp.logger.debug("guided_agent", "planning_prompt", system: system_prompt.strip, user: user_prompt.strip)
282
+ Aidp.logger.debug("guided_agent", "planning_raw_response", raw: raw_response.to_s.strip)
283
+ end
284
+ rescue => e
285
+ Aidp.logger.warn("guided_agent", "Failed verbose prompt emit", error: e.message)
286
+ end
287
+
288
+ def emit_verbose_iteration(plan, question_response)
289
+ return unless @verbose || @debug_env
290
+ summary = {complete: question_response[:complete], questions: question_response[:questions], reasoning: question_response[:reasoning], error: question_response[:error]}
291
+ if @verbose
292
+ display_message("\n=== Planning Iteration Summary ===", type: :info)
293
+ display_message("Questions: #{(summary[:questions] || []).join(" | ")}", type: :info)
294
+ display_message("Complete? #{summary[:complete]}", type: :info)
295
+ display_message("Reasoning: #{summary[:reasoning]}", type: :muted) if summary[:reasoning]
296
+ display_message("Error: #{summary[:error]}", type: :warning) if summary[:error]
297
+ display_message("=================================", type: :info)
298
+ elsif @debug_env
299
+ Aidp.logger.debug("guided_agent", "iteration_summary", summary: summary, plan_progress_keys: plan.keys)
300
+ end
301
+ rescue => e
302
+ Aidp.logger.warn("guided_agent", "Failed verbose iteration emit", error: e.message)
303
+ end
304
+
252
305
  def validate_provider_configuration!
253
306
  configured = @provider_manager.configured_providers
254
307
  if configured.nil? || configured.empty?
@@ -307,20 +360,53 @@ module Aidp
307
360
  json_match = response_text.match(/```json\s*(\{.*?\})\s*```/m) ||
308
361
  response_text.match(/(\{.*\})/m)
309
362
 
310
- unless json_match
311
- return {complete: false, questions: ["Could you tell me more about your requirements?"]}
312
- end
313
-
363
+ return {error: :invalid_format} unless json_match
314
364
  JSON.parse(json_match[1], symbolize_names: true)
315
365
  rescue JSON::ParserError
316
- {complete: false, questions: ["Could you tell me more about your requirements?"]}
366
+ {error: :invalid_format}
367
+ end
368
+
369
+ # Provides structured fallback sequence when provider keeps returning invalid planning JSON.
370
+ # After exceeding sequence length, switches to manual entry question.
371
+ def safe_parse_planning_response(response_text)
372
+ parsed = parse_planning_response(response_text)
373
+ return parsed unless parsed.is_a?(Hash) && parsed[:error] == :invalid_format
374
+
375
+ @invalid_planning_responses += 1
376
+ fallback_sequence = [
377
+ "Provide scope (key features) and primary users.",
378
+ "List 3-5 key functional requirements and any technical constraints.",
379
+ "Supply any non-functional requirements (performance/security) or type 'skip'."
380
+ ]
381
+
382
+ if @invalid_planning_responses <= fallback_sequence.size
383
+ {complete: false, questions: [fallback_sequence[@invalid_planning_responses - 1]], reasoning: "Fallback due to invalid provider response (format)", error: :fallback}
384
+ else
385
+ display_message("[ERROR] Provider returned invalid planning JSON #{@invalid_planning_responses} times. Enter combined plan details manually.", type: :error)
386
+ {complete: false, questions: ["Enter plan details manually (features; users; requirements; constraints) or type 'skip'"], reasoning: "Manual recovery mode", error: :manual_recovery}
387
+ end
317
388
  end
318
389
 
319
390
  def update_plan_from_answer(plan, question, answer)
320
391
  # Simple heuristic-based plan updates
321
392
  # In a more sophisticated implementation, use AI to categorize answers
322
393
 
323
- if question.downcase.include?("scope") || question.downcase.include?("include")
394
+ # IMPORTANT: Check manual recovery sentinel prompt first so it isn't misclassified
395
+ # by broader keyword heuristics (e.g., it contains the word 'users').
396
+ if question.start_with?("Enter plan details manually")
397
+ unless answer.strip.downcase == "skip"
398
+ parts = answer.split(/;|\|/).map(&:strip).reject(&:empty?)
399
+ features, users, requirements, constraints = parts
400
+ plan[:scope][:included] ||= []
401
+ plan[:scope][:included] << features if features
402
+ plan[:users][:personas] ||= []
403
+ plan[:users][:personas] << users if users
404
+ plan[:requirements][:functional] ||= []
405
+ plan[:requirements][:functional] << requirements if requirements
406
+ plan[:constraints][:technical] ||= []
407
+ plan[:constraints][:technical] << constraints if constraints
408
+ end
409
+ elsif question.downcase.include?("scope") || question.downcase.include?("include")
324
410
  plan[:scope][:included] ||= []
325
411
  plan[:scope][:included] << answer
326
412
  elsif question.downcase.include?("user") || question.downcase.include?("who")
@@ -338,7 +424,6 @@ module Aidp
338
424
  elsif question.downcase.include?("complete") || question.downcase.include?("done") || question.downcase.include?("success")
339
425
  plan[:completion_criteria] << answer
340
426
  else
341
- # General information
342
427
  plan[:additional_context] ||= []
343
428
  plan[:additional_context] << {question: question, answer: answer}
344
429
  end
data/lib/aidp.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "aidp/core_ext/class_attribute"
7
7
  require_relative "aidp/version"
8
8
  require_relative "aidp/config"
9
9
  require_relative "aidp/util"
10
+ require_relative "aidp/rescue_logging"
10
11
  require_relative "aidp/message_display"
11
12
  require_relative "aidp/concurrency"
12
13
  require_relative "aidp/setup/wizard"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan