aidp 0.21.1 → 0.23.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.
@@ -11,7 +11,7 @@ module Aidp
11
11
  include Aidp::RescueLogging
12
12
 
13
13
  def initialize(base_dir = ".aidp")
14
- @base_dir = base_dir
14
+ @base_dir = sanitize_base_dir(base_dir)
15
15
  ensure_directory_exists
16
16
  end
17
17
 
@@ -156,7 +156,46 @@ module Aidp
156
156
  end
157
157
 
158
158
  def ensure_directory_exists
159
- FileUtils.mkdir_p(@base_dir) unless Dir.exist?(@base_dir)
159
+ return if Dir.exist?(@base_dir)
160
+ begin
161
+ FileUtils.mkdir_p(@base_dir)
162
+ rescue SystemCallError => e
163
+ # Fallback when directory creation fails (e.g., attempting to write to '/.aidp')
164
+ fallback = begin
165
+ home = Dir.respond_to?(:home) ? Dir.home : nil
166
+ if home && !home.empty? && File.writable?(home)
167
+ File.join(home, ".aidp")
168
+ else
169
+ File.join(Dir.tmpdir, "aidp_storage")
170
+ end
171
+ rescue
172
+ File.join(Dir.tmpdir, "aidp_storage")
173
+ end
174
+ Kernel.warn "[AIDP Storage] Cannot create base directory #{@base_dir}: #{e.class}: #{e.message}. Using fallback #{fallback}"
175
+ @base_dir = fallback
176
+ begin
177
+ FileUtils.mkdir_p(@base_dir) unless Dir.exist?(@base_dir)
178
+ rescue SystemCallError => e2
179
+ Kernel.warn "[AIDP Storage] Fallback directory creation also failed: #{e2.class}: #{e2.message}. Continuing without persistent JSON storage."
180
+ end
181
+ end
182
+ end
183
+
184
+ def sanitize_base_dir(dir)
185
+ return Dir.pwd if dir.nil? || dir.to_s.strip.empty?
186
+ str = dir.to_s
187
+ # If given root '/', redirect to a writable location to avoid EACCES on CI
188
+ if str == File::SEPARATOR
189
+ fallback = begin
190
+ home = Dir.home
191
+ (home && !home.empty? && File.writable?(home)) ? File.join(home, ".aidp") : File.join(Dir.tmpdir, "aidp_storage")
192
+ rescue
193
+ File.join(Dir.tmpdir, "aidp_storage")
194
+ end
195
+ Kernel.warn "[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'"
196
+ return fallback
197
+ end
198
+ str
160
199
  end
161
200
  end
162
201
  end
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.21.1"
4
+ VERSION = "0.23.0"
5
5
  end
@@ -15,14 +15,22 @@ module Aidp
15
15
  class BuildProcessor
16
16
  include Aidp::MessageDisplay
17
17
 
18
- BUILD_LABEL = "aidp-build"
18
+ DEFAULT_BUILD_LABEL = "aidp-build"
19
+ DEFAULT_NEEDS_INPUT_LABEL = "aidp-needs-input"
19
20
  IMPLEMENTATION_STEP = "16_IMPLEMENTATION"
20
21
 
21
- def initialize(repository_client:, state_store:, project_dir: Dir.pwd, use_workstreams: true)
22
+ attr_reader :build_label, :needs_input_label
23
+
24
+ def initialize(repository_client:, state_store:, project_dir: Dir.pwd, use_workstreams: true, verbose: false, label_config: {})
22
25
  @repository_client = repository_client
23
26
  @state_store = state_store
24
27
  @project_dir = project_dir
25
28
  @use_workstreams = use_workstreams
29
+ @verbose = verbose
30
+
31
+ # Load label configuration
32
+ @build_label = label_config[:build_trigger] || label_config["build_trigger"] || DEFAULT_BUILD_LABEL
33
+ @needs_input_label = label_config[:needs_input] || label_config["needs_input"] || DEFAULT_NEEDS_INPUT_LABEL
26
34
  end
27
35
 
28
36
  def process(issue)
@@ -55,6 +63,8 @@ module Aidp
55
63
 
56
64
  if result[:status] == "completed"
57
65
  handle_success(issue: issue, slug: slug, branch_name: branch_name, base_branch: base_branch, plan_data: plan_data, working_dir: working_dir)
66
+ elsif result[:status] == "needs_clarification"
67
+ handle_clarification_request(issue: issue, slug: slug, result: result)
58
68
  else
59
69
  handle_failure(issue: issue, slug: slug, result: result)
60
70
  end
@@ -203,15 +213,33 @@ module Aidp
203
213
  prompt_manager = Aidp::Execute::PromptManager.new(working_dir)
204
214
  prompt_manager.write(content)
205
215
  display_message("📝 Wrote PROMPT.md with implementation contract", type: :info)
216
+
217
+ if @verbose
218
+ display_message("\n--- Implementation Prompt ---", type: :muted)
219
+ display_message(content.strip, type: :muted)
220
+ display_message("--- End Prompt ---\n", type: :muted)
221
+ end
206
222
  end
207
223
 
208
224
  def build_user_input(issue:, plan_data:)
209
225
  tasks = Array(plan_value(plan_data, "tasks"))
210
- {
226
+ user_input = {
211
227
  "Implementation Contract" => plan_value(plan_data, "summary").to_s,
212
228
  "Tasks" => tasks.map { |task| "- #{task}" }.join("\n"),
213
229
  "Issue URL" => issue[:url]
214
230
  }.delete_if { |_k, v| v.nil? || v.empty? }
231
+
232
+ if @verbose
233
+ display_message("\n--- User Input for Harness ---", type: :muted)
234
+ user_input.each do |key, value|
235
+ display_message("#{key}:", type: :muted)
236
+ display_message(value, type: :muted)
237
+ display_message("", type: :muted)
238
+ end
239
+ display_message("--- End User Input ---\n", type: :muted)
240
+ end
241
+
242
+ user_input
215
243
  end
216
244
 
217
245
  def run_harness(user_input:, working_dir: @project_dir)
@@ -220,8 +248,20 @@ module Aidp
220
248
  workflow_type: :watch_mode,
221
249
  user_input: user_input
222
250
  }
251
+
252
+ display_message("🚀 Running harness in execute mode...", type: :info) if @verbose
253
+
223
254
  runner = Aidp::Harness::Runner.new(working_dir, :execute, options)
224
- runner.run
255
+ result = runner.run
256
+
257
+ if @verbose
258
+ display_message("\n--- Harness Result ---", type: :muted)
259
+ display_message("Status: #{result[:status]}", type: :muted)
260
+ display_message("Message: #{result[:message]}", type: :muted) if result[:message]
261
+ display_message("--- End Result ---\n", type: :muted)
262
+ end
263
+
264
+ result
225
265
  end
226
266
 
227
267
  def handle_success(issue:, slug:, branch_name:, base_branch:, plan_data:, working_dir:)
@@ -257,12 +297,62 @@ module Aidp
257
297
  )
258
298
  display_message("🎉 Posted completion comment for issue ##{issue[:number]}", type: :success)
259
299
 
300
+ # Remove build label after successful completion
301
+ begin
302
+ @repository_client.remove_labels(issue[:number], @build_label)
303
+ display_message("🏷️ Removed '#{@build_label}' label after completion", type: :info)
304
+ rescue => e
305
+ display_message("⚠️ Failed to remove build label: #{e.message}", type: :warn)
306
+ # Don't fail the process if label removal fails
307
+ end
308
+
260
309
  # Keep workstream for review - don't auto-cleanup on success
261
310
  if @use_workstreams
262
311
  display_message("ℹ️ Workstream #{slug} preserved for review. Remove with: aidp ws rm #{slug}", type: :muted)
263
312
  end
264
313
  end
265
314
 
315
+ def handle_clarification_request(issue:, slug:, result:)
316
+ questions = result[:clarification_questions] || []
317
+ workstream_note = @use_workstreams ? " The workstream `#{slug}` has been preserved." : " The branch has been preserved."
318
+
319
+ # Build comment with questions
320
+ comment_parts = []
321
+ comment_parts << "❓ Implementation needs clarification for ##{issue[:number]}."
322
+ comment_parts << ""
323
+ comment_parts << "The AI agent needs additional information to proceed with implementation:"
324
+ comment_parts << ""
325
+ questions.each_with_index do |question, index|
326
+ comment_parts << "#{index + 1}. #{question}"
327
+ end
328
+ comment_parts << ""
329
+ comment_parts << "**Next Steps**: Please reply with answers to the questions above. Once resolved, remove the `#{@needs_input_label}` label and add the `#{@build_label}` label to resume implementation."
330
+ comment_parts << ""
331
+ comment_parts << workstream_note.to_s
332
+
333
+ comment = comment_parts.join("\n")
334
+ @repository_client.post_comment(issue[:number], comment)
335
+
336
+ # Update labels: remove build trigger, add needs input
337
+ begin
338
+ @repository_client.replace_labels(
339
+ issue[:number],
340
+ old_labels: [@build_label],
341
+ new_labels: [@needs_input_label]
342
+ )
343
+ display_message("🏷️ Updated labels: removed '#{@build_label}', added '#{@needs_input_label}' (needs clarification)", type: :info)
344
+ rescue => e
345
+ display_message("⚠️ Failed to update labels for issue ##{issue[:number]}: #{e.message}", type: :warn)
346
+ end
347
+
348
+ @state_store.record_build_status(
349
+ issue[:number],
350
+ status: "needs_clarification",
351
+ details: {questions: questions, workstream: slug}
352
+ )
353
+ display_message("💬 Posted clarification request for issue ##{issue[:number]}", type: :success)
354
+ end
355
+
266
356
  def handle_failure(issue:, slug:, result:)
267
357
  message = result[:message] || "Unknown failure"
268
358
  workstream_note = @use_workstreams ? " The workstream `#{slug}` has been left intact for debugging." : " The branch has been left intact for debugging."
@@ -26,8 +26,9 @@ module Aidp
26
26
  Focus on concrete engineering tasks. Ensure questions are actionable.
27
27
  PROMPT
28
28
 
29
- def initialize(provider_name: nil)
29
+ def initialize(provider_name: nil, verbose: false)
30
30
  @provider_name = provider_name
31
+ @verbose = verbose
31
32
  end
32
33
 
33
34
  def generate(issue)
@@ -67,7 +68,21 @@ module Aidp
67
68
 
68
69
  def generate_with_provider(provider, issue)
69
70
  payload = build_prompt(issue)
71
+
72
+ if @verbose
73
+ display_message("\n--- Plan Generation Prompt ---", type: :muted)
74
+ display_message(payload.strip, type: :muted)
75
+ display_message("--- End Prompt ---\n", type: :muted)
76
+ end
77
+
70
78
  response = provider.send_message(prompt: payload)
79
+
80
+ if @verbose
81
+ display_message("\n--- Provider Response ---", type: :muted)
82
+ display_message(response.strip, type: :muted)
83
+ display_message("--- End Response ---\n", type: :muted)
84
+ end
85
+
71
86
  parsed = parse_structured_response(response)
72
87
 
73
88
  return parsed if parsed
@@ -11,13 +11,32 @@ module Aidp
11
11
  class PlanProcessor
12
12
  include Aidp::MessageDisplay
13
13
 
14
- PLAN_LABEL = "aidp-plan"
14
+ # Default label names
15
+ DEFAULT_PLAN_LABEL = "aidp-plan"
16
+ DEFAULT_NEEDS_INPUT_LABEL = "aidp-needs-input"
17
+ DEFAULT_READY_LABEL = "aidp-ready"
18
+ DEFAULT_BUILD_LABEL = "aidp-build"
19
+
15
20
  COMMENT_HEADER = "## 🤖 AIDP Plan Proposal"
16
21
 
17
- def initialize(repository_client:, state_store:, plan_generator:)
22
+ attr_reader :plan_label, :needs_input_label, :ready_label, :build_label
23
+
24
+ def initialize(repository_client:, state_store:, plan_generator:, label_config: {})
18
25
  @repository_client = repository_client
19
26
  @state_store = state_store
20
27
  @plan_generator = plan_generator
28
+
29
+ # Load label configuration with defaults
30
+ @plan_label = label_config[:plan_trigger] || label_config["plan_trigger"] || DEFAULT_PLAN_LABEL
31
+ @needs_input_label = label_config[:needs_input] || label_config["needs_input"] || DEFAULT_NEEDS_INPUT_LABEL
32
+ @ready_label = label_config[:ready_to_build] || label_config["ready_to_build"] || DEFAULT_READY_LABEL
33
+ @build_label = label_config[:build_trigger] || label_config["build_trigger"] || DEFAULT_BUILD_LABEL
34
+ end
35
+
36
+ # For backward compatibility
37
+ def self.plan_label_from_config(config)
38
+ labels = config[:labels] || config["labels"] || {}
39
+ labels[:plan_trigger] || labels["plan_trigger"] || DEFAULT_PLAN_LABEL
21
40
  end
22
41
 
23
42
  def process(issue)
@@ -35,14 +54,39 @@ module Aidp
35
54
 
36
55
  display_message("💬 Posted plan comment for issue ##{number}", type: :success)
37
56
  @state_store.record_plan(number, plan_data.merge(comment_body: comment_body, comment_hint: COMMENT_HEADER))
57
+
58
+ # Update labels: remove plan trigger, add appropriate status label
59
+ update_labels_after_plan(number, plan_data)
38
60
  end
39
61
 
40
62
  private
41
63
 
64
+ def update_labels_after_plan(number, plan_data)
65
+ questions = Array(plan_data[:questions])
66
+ has_questions = questions.any? && !questions.all? { |q| q.to_s.strip.empty? }
67
+
68
+ # Determine which label to add based on whether there are questions
69
+ new_label = has_questions ? @needs_input_label : @ready_label
70
+ status_text = has_questions ? "needs input" : "ready to build"
71
+
72
+ begin
73
+ @repository_client.replace_labels(
74
+ number,
75
+ old_labels: [@plan_label],
76
+ new_labels: [new_label]
77
+ )
78
+ display_message("🏷️ Updated labels: removed '#{@plan_label}', added '#{new_label}' (#{status_text})", type: :info)
79
+ rescue => e
80
+ display_message("⚠️ Failed to update labels for issue ##{number}: #{e.message}", type: :warn)
81
+ # Don't fail the whole process if label update fails
82
+ end
83
+ end
84
+
42
85
  def build_comment(issue:, plan:)
43
86
  summary = plan[:summary].to_s.strip
44
87
  tasks = Array(plan[:tasks])
45
88
  questions = Array(plan[:questions])
89
+ has_questions = questions.any? && !questions.all? { |q| q.to_s.strip.empty? }
46
90
 
47
91
  parts = []
48
92
  parts << COMMENT_HEADER
@@ -59,7 +103,14 @@ module Aidp
59
103
  parts << "### Clarifying Questions"
60
104
  parts << format_numbered(questions, placeholder: "_No questions identified_")
61
105
  parts << ""
62
- parts << "Please reply inline with answers to the questions above. Once the discussion is resolved, apply the `aidp-build` label to begin implementation."
106
+
107
+ # Add instructions based on whether there are questions
108
+ parts << if has_questions
109
+ "**Next Steps**: Please reply with answers to the questions above. Once resolved, remove the `#{@needs_input_label}` label and add the `#{@build_label}` label to begin implementation."
110
+ else
111
+ "**Next Steps**: This plan is ready for implementation. Add the `#{@build_label}` label to begin."
112
+ end
113
+
63
114
  parts.join("\n")
64
115
  end
65
116
 
@@ -65,6 +65,20 @@ module Aidp
65
65
  gh_available? ? create_pull_request_via_gh(title: title, body: body, head: head, base: base, issue_number: issue_number) : raise("GitHub CLI not available - cannot create PR")
66
66
  end
67
67
 
68
+ def add_labels(number, *labels)
69
+ gh_available? ? add_labels_via_gh(number, labels.flatten) : add_labels_via_api(number, labels.flatten)
70
+ end
71
+
72
+ def remove_labels(number, *labels)
73
+ gh_available? ? remove_labels_via_gh(number, labels.flatten) : remove_labels_via_api(number, labels.flatten)
74
+ end
75
+
76
+ def replace_labels(number, old_labels:, new_labels:)
77
+ # Remove old labels and add new ones atomically where possible
78
+ remove_labels(number, *old_labels) unless old_labels.empty?
79
+ add_labels(number, *new_labels) unless new_labels.empty?
80
+ end
81
+
68
82
  private
69
83
 
70
84
  def list_issues_via_gh(labels:, state:)
@@ -180,6 +194,66 @@ module Aidp
180
194
  stdout.strip
181
195
  end
182
196
 
197
+ def add_labels_via_gh(number, labels)
198
+ return if labels.empty?
199
+
200
+ cmd = ["gh", "issue", "edit", number.to_s, "--repo", full_repo]
201
+ labels.each { |label| cmd += ["--add-label", label] }
202
+
203
+ stdout, stderr, status = Open3.capture3(*cmd)
204
+ raise "Failed to add labels via gh: #{stderr.strip}" unless status.success?
205
+
206
+ stdout.strip
207
+ end
208
+
209
+ def add_labels_via_api(number, labels)
210
+ return if labels.empty?
211
+
212
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/labels")
213
+ request = Net::HTTP::Post.new(uri)
214
+ request["Content-Type"] = "application/json"
215
+ request.body = JSON.dump({labels: labels})
216
+
217
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
218
+ http.request(request)
219
+ end
220
+
221
+ raise "Failed to add labels via API (#{response.code})" unless response.code.start_with?("2")
222
+ response.body
223
+ end
224
+
225
+ def remove_labels_via_gh(number, labels)
226
+ return if labels.empty?
227
+
228
+ cmd = ["gh", "issue", "edit", number.to_s, "--repo", full_repo]
229
+ labels.each { |label| cmd += ["--remove-label", label] }
230
+
231
+ stdout, stderr, status = Open3.capture3(*cmd)
232
+ raise "Failed to remove labels via gh: #{stderr.strip}" unless status.success?
233
+
234
+ stdout.strip
235
+ end
236
+
237
+ def remove_labels_via_api(number, labels)
238
+ return if labels.empty?
239
+
240
+ labels.each do |label|
241
+ # URL encode the label name
242
+ encoded_label = URI.encode_www_form_component(label)
243
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/labels/#{encoded_label}")
244
+ request = Net::HTTP::Delete.new(uri)
245
+
246
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
247
+ http.request(request)
248
+ end
249
+
250
+ # 404 is OK - label didn't exist
251
+ unless response.code.start_with?("2") || response.code == "404"
252
+ raise "Failed to remove label '#{label}' via API (#{response.code})"
253
+ end
254
+ end
255
+ end
256
+
183
257
  def normalize_issue(raw)
184
258
  {
185
259
  number: raw["number"],
@@ -163,16 +163,25 @@ module Aidp
163
163
  end
164
164
 
165
165
  def public_repos_allowed?
166
- @config.dig(:safety, :allow_public_repos) == true
166
+ # Support both string and symbol keys
167
+ safety_config = @config[:safety] || @config["safety"] || {}
168
+ (safety_config[:allow_public_repos] || safety_config["allow_public_repos"]) == true
167
169
  end
168
170
 
169
171
  def author_allowlist
170
- @author_allowlist ||= Array(@config.dig(:safety, :author_allowlist)).compact.map(&:to_s)
172
+ # Support both string and symbol keys
173
+ safety_config = @config[:safety] || @config["safety"] || {}
174
+ @author_allowlist ||= Array(
175
+ safety_config[:author_allowlist] || safety_config["author_allowlist"]
176
+ ).compact.map(&:to_s)
171
177
  end
172
178
 
173
179
  def safe_environment?
174
180
  # Check if running in a container
175
- in_container? || @config.dig(:safety, :require_container) == false
181
+ # Support both string and symbol keys
182
+ safety_config = @config[:safety] || @config["safety"] || {}
183
+ require_container = safety_config[:require_container] || safety_config["require_container"]
184
+ in_container? || require_container == false
176
185
  end
177
186
 
178
187
  def in_container?
@@ -19,12 +19,13 @@ module Aidp
19
19
 
20
20
  DEFAULT_INTERVAL = 30
21
21
 
22
- def initialize(issues_url:, interval: DEFAULT_INTERVAL, provider_name: nil, gh_available: nil, project_dir: Dir.pwd, once: false, use_workstreams: true, prompt: TTY::Prompt.new, safety_config: {}, force: false)
22
+ def initialize(issues_url:, interval: DEFAULT_INTERVAL, provider_name: nil, gh_available: nil, project_dir: Dir.pwd, once: false, use_workstreams: true, prompt: TTY::Prompt.new, safety_config: {}, force: false, verbose: false)
23
23
  @prompt = prompt
24
24
  @interval = interval
25
25
  @once = once
26
26
  @project_dir = project_dir
27
27
  @force = force
28
+ @verbose = verbose
28
29
 
29
30
  owner, repo = RepositoryClient.parse_issues_url(issues_url)
30
31
  @repository_client = RepositoryClient.new(owner: owner, repo: repo, gh_available: gh_available)
@@ -33,16 +34,23 @@ module Aidp
33
34
  config: safety_config
34
35
  )
35
36
  @state_store = StateStore.new(project_dir: project_dir, repository: "#{owner}/#{repo}")
37
+
38
+ # Extract label configuration from safety_config (it's actually the full watch config)
39
+ label_config = safety_config[:labels] || safety_config["labels"] || {}
40
+
36
41
  @plan_processor = PlanProcessor.new(
37
42
  repository_client: @repository_client,
38
43
  state_store: @state_store,
39
- plan_generator: PlanGenerator.new(provider_name: provider_name)
44
+ plan_generator: PlanGenerator.new(provider_name: provider_name, verbose: verbose),
45
+ label_config: label_config
40
46
  )
41
47
  @build_processor = BuildProcessor.new(
42
48
  repository_client: @repository_client,
43
49
  state_store: @state_store,
44
50
  project_dir: project_dir,
45
- use_workstreams: use_workstreams
51
+ use_workstreams: use_workstreams,
52
+ verbose: verbose,
53
+ label_config: label_config
46
54
  )
47
55
  end
48
56
 
@@ -73,9 +81,10 @@ module Aidp
73
81
  end
74
82
 
75
83
  def process_plan_triggers
76
- issues = @repository_client.list_issues(labels: [PlanProcessor::PLAN_LABEL], state: "open")
84
+ plan_label = @plan_processor.plan_label
85
+ issues = @repository_client.list_issues(labels: [plan_label], state: "open")
77
86
  issues.each do |issue|
78
- next unless issue_has_label?(issue, PlanProcessor::PLAN_LABEL)
87
+ next unless issue_has_label?(issue, plan_label)
79
88
 
80
89
  detailed = @repository_client.fetch_issue(issue[:number])
81
90
 
@@ -89,9 +98,10 @@ module Aidp
89
98
  end
90
99
 
91
100
  def process_build_triggers
92
- issues = @repository_client.list_issues(labels: [BuildProcessor::BUILD_LABEL], state: "open")
101
+ build_label = @build_processor.build_label
102
+ issues = @repository_client.list_issues(labels: [build_label], state: "open")
93
103
  issues.each do |issue|
94
- next unless issue_has_label?(issue, BuildProcessor::BUILD_LABEL)
104
+ next unless issue_has_label?(issue, build_label)
95
105
 
96
106
  status = @state_store.build_status(issue[:number])
97
107
  next if status["status"] == "completed"
@@ -33,7 +33,6 @@ module Aidp
33
33
  @provider_manager = Aidp::Harness::ProviderManager.new(@config_manager, prompt: @prompt)
34
34
  @conversation_history = []
35
35
  @user_input = {}
36
- @invalid_planning_responses = 0
37
36
  @verbose = verbose
38
37
  @debug_env = ENV["DEBUG"] == "1" || ENV["DEBUG"] == "2"
39
38
  end
@@ -138,7 +137,7 @@ module Aidp
138
137
  end
139
138
 
140
139
  response = call_provider_for_analysis(system_prompt, user_prompt)
141
- parsed = safe_parse_planning_response(response)
140
+ parsed = parse_planning_response(response)
142
141
  # Attach raw response for debug
143
142
  parsed[:raw_response] = response
144
143
  emit_verbose_raw_prompt(system_prompt, user_prompt, response)
@@ -378,53 +377,20 @@ module Aidp
378
377
  json_match = response_text.match(/```json\s*(\{.*?\})\s*```/m) ||
379
378
  response_text.match(/(\{.*\})/m)
380
379
 
381
- return {error: :invalid_format} unless json_match
382
- JSON.parse(json_match[1], symbolize_names: true)
383
- rescue JSON::ParserError
384
- {error: :invalid_format}
385
- end
386
-
387
- # Provides structured fallback sequence when provider keeps returning invalid planning JSON.
388
- # After exceeding sequence length, switches to manual entry question.
389
- def safe_parse_planning_response(response_text)
390
- parsed = parse_planning_response(response_text)
391
- return parsed unless parsed.is_a?(Hash) && parsed[:error] == :invalid_format
392
-
393
- @invalid_planning_responses += 1
394
- fallback_sequence = [
395
- "Provide scope (key features) and primary users.",
396
- "List 3-5 key functional requirements and any technical constraints.",
397
- "Supply any non-functional requirements (performance/security) or type 'skip'."
398
- ]
399
-
400
- if @invalid_planning_responses <= fallback_sequence.size
401
- {complete: false, questions: [fallback_sequence[@invalid_planning_responses - 1]], reasoning: "Fallback due to invalid provider response (format)", error: :fallback}
402
- else
403
- display_message("[ERROR] Provider returned invalid planning JSON #{@invalid_planning_responses} times. Enter combined plan details manually.", type: :error)
404
- {complete: false, questions: ["Enter plan details manually (features; users; requirements; constraints) or type 'skip'"], reasoning: "Manual recovery mode", error: :manual_recovery}
380
+ unless json_match
381
+ raise ConversationError, "Provider returned invalid format: no JSON found in response"
405
382
  end
383
+
384
+ JSON.parse(json_match[1], symbolize_names: true)
385
+ rescue JSON::ParserError => e
386
+ raise ConversationError, "Provider returned invalid JSON: #{e.message}"
406
387
  end
407
388
 
408
389
  def update_plan_from_answer(plan, question, answer)
409
390
  # Simple heuristic-based plan updates
410
391
  # In a more sophisticated implementation, use AI to categorize answers
411
392
 
412
- # IMPORTANT: Check manual recovery sentinel prompt first so it isn't misclassified
413
- # by broader keyword heuristics (e.g., it contains the word 'users').
414
- if question.start_with?("Enter plan details manually")
415
- unless answer.strip.downcase == "skip"
416
- parts = answer.split(/;|\|/).map(&:strip).reject(&:empty?)
417
- features, users, requirements, constraints = parts
418
- plan[:scope][:included] ||= []
419
- plan[:scope][:included] << features if features
420
- plan[:users][:personas] ||= []
421
- plan[:users][:personas] << users if users
422
- plan[:requirements][:functional] ||= []
423
- plan[:requirements][:functional] << requirements if requirements
424
- plan[:constraints][:technical] ||= []
425
- plan[:constraints][:technical] << constraints if constraints
426
- end
427
- elsif question.downcase.include?("scope") || question.downcase.include?("include")
393
+ if question.downcase.include?("scope") || question.downcase.include?("include")
428
394
  plan[:scope][:included] ||= []
429
395
  plan[:scope][:included] << answer
430
396
  elsif question.downcase.include?("user") || question.downcase.include?("who")
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.21.1
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -245,6 +245,7 @@ files:
245
245
  - lib/aidp/analyze/tree_sitter_grammar_loader.rb
246
246
  - lib/aidp/analyze/tree_sitter_scan.rb
247
247
  - lib/aidp/cli.rb
248
+ - lib/aidp/cli/devcontainer_commands.rb
248
249
  - lib/aidp/cli/enhanced_input.rb
249
250
  - lib/aidp/cli/first_run_wizard.rb
250
251
  - lib/aidp/cli/issue_importer.rb
@@ -354,6 +355,11 @@ files:
354
355
  - lib/aidp/providers/github_copilot.rb
355
356
  - lib/aidp/providers/opencode.rb
356
357
  - lib/aidp/rescue_logging.rb
358
+ - lib/aidp/safe_directory.rb
359
+ - lib/aidp/setup/devcontainer/backup_manager.rb
360
+ - lib/aidp/setup/devcontainer/generator.rb
361
+ - lib/aidp/setup/devcontainer/parser.rb
362
+ - lib/aidp/setup/devcontainer/port_manager.rb
357
363
  - lib/aidp/setup/wizard.rb
358
364
  - lib/aidp/skills.rb
359
365
  - lib/aidp/skills/composer.rb