aidp 0.10.0 → 0.11.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +194 -25
  3. data/lib/aidp/analyze/kb_inspector.rb +2 -15
  4. data/lib/aidp/analyze/progress.rb +2 -1
  5. data/lib/aidp/analyze/ruby_maat_integration.rb +2 -15
  6. data/lib/aidp/analyze/runner.rb +64 -20
  7. data/lib/aidp/analyze/steps.rb +10 -8
  8. data/lib/aidp/analyze/tree_sitter_grammar_loader.rb +2 -13
  9. data/lib/aidp/analyze/tree_sitter_scan.rb +2 -13
  10. data/lib/aidp/cli/checkpoint_command.rb +98 -0
  11. data/lib/aidp/cli/first_run_wizard.rb +65 -94
  12. data/lib/aidp/cli/jobs_command.rb +249 -34
  13. data/lib/aidp/cli.rb +312 -38
  14. data/lib/aidp/config.rb +5 -8
  15. data/lib/aidp/debug_logger.rb +4 -4
  16. data/lib/aidp/debug_mixin.rb +11 -4
  17. data/lib/aidp/execute/checkpoint.rb +282 -0
  18. data/lib/aidp/execute/checkpoint_display.rb +221 -0
  19. data/lib/aidp/execute/progress.rb +2 -1
  20. data/lib/aidp/execute/prompt_manager.rb +62 -0
  21. data/lib/aidp/execute/runner.rb +53 -24
  22. data/lib/aidp/execute/steps.rb +36 -27
  23. data/lib/aidp/execute/work_loop_runner.rb +308 -0
  24. data/lib/aidp/execute/workflow_selector.rb +26 -17
  25. data/lib/aidp/harness/condition_detector.rb +4 -4
  26. data/lib/aidp/harness/config_schema.rb +40 -0
  27. data/lib/aidp/harness/config_validator.rb +3 -6
  28. data/lib/aidp/harness/configuration.rb +35 -1
  29. data/lib/aidp/harness/enhanced_runner.rb +22 -1
  30. data/lib/aidp/harness/error_handler.rb +103 -28
  31. data/lib/aidp/harness/provider_factory.rb +4 -1
  32. data/lib/aidp/harness/provider_manager.rb +250 -15
  33. data/lib/aidp/harness/runner.rb +3 -14
  34. data/lib/aidp/harness/simple_user_interface.rb +2 -15
  35. data/lib/aidp/harness/status_display.rb +12 -17
  36. data/lib/aidp/harness/test_runner.rb +83 -0
  37. data/lib/aidp/harness/ui/enhanced_tui.rb +2 -0
  38. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +22 -4
  39. data/lib/aidp/harness/ui/error_handler.rb +4 -0
  40. data/lib/aidp/harness/ui/frame_manager.rb +10 -8
  41. data/lib/aidp/harness/ui/job_monitor.rb +2 -0
  42. data/lib/aidp/harness/ui/navigation/main_menu.rb +4 -2
  43. data/lib/aidp/harness/ui/navigation/menu_item.rb +1 -0
  44. data/lib/aidp/harness/ui/navigation/menu_state.rb +1 -0
  45. data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
  46. data/lib/aidp/harness/ui/navigation/workflow_selector.rb +2 -0
  47. data/lib/aidp/harness/ui/progress_display.rb +8 -12
  48. data/lib/aidp/harness/ui/question_collector.rb +2 -0
  49. data/lib/aidp/harness/ui/spinner_group.rb +2 -0
  50. data/lib/aidp/harness/ui/spinner_helper.rb +1 -1
  51. data/lib/aidp/harness/ui/status_manager.rb +4 -2
  52. data/lib/aidp/harness/ui/status_widget.rb +3 -1
  53. data/lib/aidp/harness/ui/workflow_controller.rb +2 -0
  54. data/lib/aidp/harness/user_interface.rb +12 -17
  55. data/lib/aidp/jobs/background_runner.rb +278 -0
  56. data/lib/aidp/message_display.rb +48 -0
  57. data/lib/aidp/provider_manager.rb +3 -1
  58. data/lib/aidp/providers/anthropic.rb +100 -17
  59. data/lib/aidp/providers/base.rb +42 -11
  60. data/lib/aidp/providers/codex.rb +248 -0
  61. data/lib/aidp/providers/cursor.rb +35 -42
  62. data/lib/aidp/providers/gemini.rb +25 -15
  63. data/lib/aidp/providers/github_copilot.rb +41 -42
  64. data/lib/aidp/providers/opencode.rb +34 -41
  65. data/lib/aidp/version.rb +1 -1
  66. data/lib/aidp/workflows/definitions.rb +357 -0
  67. data/lib/aidp/workflows/selector.rb +171 -0
  68. data/lib/aidp.rb +12 -0
  69. data/templates/planning/generate_llm_style_guide.md +119 -0
  70. metadata +38 -26
  71. /data/templates/{ANALYZE/02_ARCHITECTURE_ANALYSIS.md → analysis/analyze_architecture.md} +0 -0
  72. /data/templates/{ANALYZE/05_DOCUMENTATION_ANALYSIS.md → analysis/analyze_documentation.md} +0 -0
  73. /data/templates/{ANALYZE/04_FUNCTIONALITY_ANALYSIS.md → analysis/analyze_functionality.md} +0 -0
  74. /data/templates/{ANALYZE/01_REPOSITORY_ANALYSIS.md → analysis/analyze_repository.md} +0 -0
  75. /data/templates/{ANALYZE/06_STATIC_ANALYSIS.md → analysis/analyze_static_code.md} +0 -0
  76. /data/templates/{ANALYZE/03_TEST_ANALYSIS.md → analysis/analyze_tests.md} +0 -0
  77. /data/templates/{ANALYZE/07_REFACTORING_RECOMMENDATIONS.md → analysis/recommend_refactoring.md} +0 -0
  78. /data/templates/{ANALYZE/06a_tree_sitter_scan.md → analysis/scan_with_tree_sitter.md} +0 -0
  79. /data/templates/{EXECUTE/11_STATIC_ANALYSIS.md → implementation/configure_static_analysis.md} +0 -0
  80. /data/templates/{EXECUTE/14_DOCS_PORTAL.md → implementation/create_documentation_portal.md} +0 -0
  81. /data/templates/{EXECUTE/10_IMPLEMENTATION_AGENT.md → implementation/implement_features.md} +0 -0
  82. /data/templates/{EXECUTE/13_DELIVERY_ROLLOUT.md → implementation/plan_delivery.md} +0 -0
  83. /data/templates/{EXECUTE/15_POST_RELEASE.md → implementation/review_post_release.md} +0 -0
  84. /data/templates/{EXECUTE/09_SCAFFOLDING_DEVEX.md → implementation/setup_scaffolding.md} +0 -0
  85. /data/templates/{EXECUTE/02A_ARCH_GATE_QUESTIONS.md → planning/ask_architecture_questions.md} +0 -0
  86. /data/templates/{EXECUTE/00_PRD.md → planning/create_prd.md} +0 -0
  87. /data/templates/{EXECUTE/08_TASKS.md → planning/create_tasks.md} +0 -0
  88. /data/templates/{EXECUTE/04_DOMAIN_DECOMPOSITION.md → planning/decompose_domain.md} +0 -0
  89. /data/templates/{EXECUTE/01_NFRS.md → planning/define_nfrs.md} +0 -0
  90. /data/templates/{EXECUTE/05_CONTRACTS.md → planning/design_apis.md} +0 -0
  91. /data/templates/{EXECUTE/02_ARCHITECTURE.md → planning/design_architecture.md} +0 -0
  92. /data/templates/{EXECUTE/06_THREAT_MODEL.md → planning/design_data_model.md} +0 -0
  93. /data/templates/{EXECUTE/03_ADR_FACTORY.md → planning/generate_adrs.md} +0 -0
  94. /data/templates/{EXECUTE/12_OBSERVABILITY_SLOS.md → planning/plan_observability.md} +0 -0
  95. /data/templates/{EXECUTE/07_TEST_PLAN.md → planning/plan_testing.md} +0 -0
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "yaml"
5
+ require "fileutils"
6
+
7
+ module Aidp
8
+ module Jobs
9
+ # Manages background execution of work loops
10
+ # Runs harness in daemon process and tracks job metadata
11
+ class BackgroundRunner
12
+ include Aidp::MessageDisplay
13
+
14
+ attr_reader :project_dir, :jobs_dir
15
+
16
+ def initialize(project_dir = Dir.pwd)
17
+ @project_dir = project_dir
18
+ @jobs_dir = File.join(project_dir, ".aidp", "jobs")
19
+ ensure_jobs_directory
20
+ end
21
+
22
+ # Start a background job
23
+ # Returns job_id
24
+ def start(mode, options = {})
25
+ job_id = generate_job_id
26
+ log_file = File.join(@jobs_dir, job_id, "output.log")
27
+ pid_file = File.join(@jobs_dir, job_id, "job.pid")
28
+
29
+ # Create job directory
30
+ FileUtils.mkdir_p(File.dirname(log_file))
31
+
32
+ # Fork and daemonize
33
+ pid = fork do
34
+ # Detach from parent process
35
+ Process.daemon(true)
36
+
37
+ # Redirect stdout/stderr to log file
38
+ $stdout.reopen(log_file, "a")
39
+ $stderr.reopen(log_file, "a")
40
+ $stdout.sync = true
41
+ $stderr.sync = true
42
+
43
+ # Write PID file
44
+ File.write(pid_file, Process.pid)
45
+
46
+ begin
47
+ # Run the harness
48
+ puts "[#{Time.now}] Starting #{mode} mode in background"
49
+ puts "[#{Time.now}] Job ID: #{job_id}"
50
+ puts "[#{Time.now}] PID: #{Process.pid}"
51
+
52
+ runner = Aidp::Harness::Runner.new(@project_dir, mode, options.merge(job_id: job_id))
53
+ result = runner.run
54
+
55
+ puts "[#{Time.now}] Job completed with status: #{result[:status]}"
56
+ mark_job_completed(job_id, result)
57
+ rescue => e
58
+ puts "[#{Time.now}] Job failed with error: #{e.message}"
59
+ puts e.backtrace.join("\n")
60
+ mark_job_failed(job_id, e)
61
+ ensure
62
+ # Clean up PID file
63
+ File.delete(pid_file) if File.exist?(pid_file)
64
+ end
65
+ end
66
+
67
+ # Wait for child to fork
68
+ Process.detach(pid)
69
+ sleep 0.1 # Give daemon time to write PID file
70
+
71
+ # Save job metadata in parent process
72
+ save_job_metadata(job_id, pid, mode, options)
73
+
74
+ job_id
75
+ end
76
+
77
+ # List all jobs
78
+ def list_jobs
79
+ return [] unless Dir.exist?(@jobs_dir)
80
+
81
+ Dir.glob(File.join(@jobs_dir, "*")).select { |d| File.directory?(d) }.map do |job_dir|
82
+ job_id = File.basename(job_dir)
83
+ load_job_metadata(job_id)
84
+ end.compact.sort_by { |job| job[:started_at] || Time.now }.reverse
85
+ end
86
+
87
+ # Get job status
88
+ def job_status(job_id)
89
+ metadata = load_job_metadata(job_id)
90
+ return nil unless metadata
91
+
92
+ # Check if process is still running
93
+ pid = metadata[:pid]
94
+ running = pid && process_running?(pid)
95
+
96
+ # Get checkpoint data
97
+ checkpoint = get_job_checkpoint(job_id)
98
+
99
+ {
100
+ job_id: job_id,
101
+ mode: metadata[:mode],
102
+ status: determine_job_status(metadata, running, checkpoint),
103
+ pid: pid,
104
+ running: running,
105
+ started_at: metadata[:started_at],
106
+ completed_at: metadata[:completed_at],
107
+ checkpoint: checkpoint,
108
+ log_file: File.join(@jobs_dir, job_id, "output.log")
109
+ }
110
+ end
111
+
112
+ # Stop a running job
113
+ def stop_job(job_id)
114
+ metadata = load_job_metadata(job_id)
115
+ return {success: false, message: "Job not found"} unless metadata
116
+
117
+ pid = metadata[:pid]
118
+ unless pid && process_running?(pid)
119
+ return {success: false, message: "Job is not running"}
120
+ end
121
+
122
+ begin
123
+ # Send TERM signal
124
+ Process.kill("TERM", pid)
125
+
126
+ # Wait for process to terminate (max 10 seconds)
127
+ 10.times do
128
+ sleep 0.5
129
+ break unless process_running?(pid)
130
+ end
131
+
132
+ # Force kill if still running
133
+ if process_running?(pid)
134
+ Process.kill("KILL", pid)
135
+ end
136
+
137
+ mark_job_stopped(job_id)
138
+ {success: true, message: "Job stopped successfully"}
139
+ rescue Errno::ESRCH
140
+ # Process already dead
141
+ mark_job_stopped(job_id)
142
+ {success: true, message: "Job was already stopped"}
143
+ rescue => e
144
+ {success: false, message: "Failed to stop job: #{e.message}"}
145
+ end
146
+ end
147
+
148
+ # Get job logs
149
+ def job_logs(job_id, options = {})
150
+ log_file = File.join(@jobs_dir, job_id, "output.log")
151
+ return nil unless File.exist?(log_file)
152
+
153
+ if options[:tail]
154
+ lines = options[:lines] || 50
155
+ `tail -n #{lines} #{log_file}`
156
+ else
157
+ File.read(log_file)
158
+ end
159
+ end
160
+
161
+ # Follow job logs in real-time
162
+ def follow_job_logs(job_id)
163
+ log_file = File.join(@jobs_dir, job_id, "output.log")
164
+ return unless File.exist?(log_file)
165
+
166
+ # Use tail -f to follow logs
167
+ exec("tail", "-f", log_file)
168
+ end
169
+
170
+ private
171
+
172
+ def ensure_jobs_directory
173
+ FileUtils.mkdir_p(@jobs_dir) unless Dir.exist?(@jobs_dir)
174
+ end
175
+
176
+ def generate_job_id
177
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
178
+ random = SecureRandom.hex(4)
179
+ "#{timestamp}_#{random}"
180
+ end
181
+
182
+ def save_job_metadata(job_id, pid, mode, options)
183
+ metadata_file = File.join(@jobs_dir, job_id, "metadata.yml")
184
+
185
+ metadata = {
186
+ job_id: job_id,
187
+ pid: pid,
188
+ mode: mode,
189
+ started_at: Time.now,
190
+ status: "running",
191
+ options: options.except(:prompt) # Don't save prompt object
192
+ }
193
+
194
+ File.write(metadata_file, metadata.to_yaml)
195
+ end
196
+
197
+ def load_job_metadata(job_id)
198
+ metadata_file = File.join(@jobs_dir, job_id, "metadata.yml")
199
+ return nil unless File.exist?(metadata_file)
200
+
201
+ YAML.load_file(metadata_file)
202
+ rescue
203
+ nil
204
+ end
205
+
206
+ def update_job_metadata(job_id, updates)
207
+ metadata = load_job_metadata(job_id)
208
+ return unless metadata
209
+
210
+ metadata.merge!(updates)
211
+ metadata_file = File.join(@jobs_dir, job_id, "metadata.yml")
212
+ File.write(metadata_file, metadata.to_yaml)
213
+ end
214
+
215
+ def mark_job_completed(job_id, result)
216
+ update_job_metadata(job_id, {
217
+ status: "completed",
218
+ completed_at: Time.now,
219
+ result: result
220
+ })
221
+ end
222
+
223
+ def mark_job_failed(job_id, error)
224
+ update_job_metadata(job_id, {
225
+ status: "failed",
226
+ completed_at: Time.now,
227
+ error: {
228
+ message: error.message,
229
+ class: error.class.name,
230
+ backtrace: error.backtrace&.first(10)
231
+ }
232
+ })
233
+ end
234
+
235
+ def mark_job_stopped(job_id)
236
+ update_job_metadata(job_id, {
237
+ status: "stopped",
238
+ completed_at: Time.now
239
+ })
240
+ end
241
+
242
+ def process_running?(pid)
243
+ return false unless pid
244
+
245
+ Process.kill(0, pid)
246
+ true
247
+ rescue Errno::ESRCH, Errno::EPERM
248
+ false
249
+ end
250
+
251
+ def get_job_checkpoint(job_id)
252
+ # Try to load checkpoint from project directory
253
+ checkpoint = Aidp::Execute::Checkpoint.new(@project_dir)
254
+ checkpoint.latest_checkpoint
255
+ rescue
256
+ nil
257
+ end
258
+
259
+ def determine_job_status(metadata, running, checkpoint)
260
+ return metadata[:status] if metadata[:status] && !%w[running].include?(metadata[:status])
261
+
262
+ if running
263
+ # Check if job appears stuck based on checkpoint
264
+ if checkpoint && checkpoint[:timestamp]
265
+ last_update = Time.parse(checkpoint[:timestamp])
266
+ age = Time.now - last_update
267
+
268
+ return "stuck" if age > 600 # No update in 10 minutes
269
+ return "running"
270
+ end
271
+ "running"
272
+ else
273
+ metadata[:status] || "completed"
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ module Aidp
6
+ # Mixin providing a consistent display_message helper across classes.
7
+ # Usage:
8
+ # include Aidp::MessageDisplay
9
+ # display_message("Hello", type: :success)
10
+ # Supports color types: :error, :success, :warning, :info, :highlight, :muted
11
+ module MessageDisplay
12
+ COLOR_MAP = {
13
+ error: :red,
14
+ success: :green,
15
+ warning: :yellow,
16
+ warn: :yellow,
17
+ info: :blue,
18
+ highlight: :cyan,
19
+ muted: :bright_black
20
+ }.freeze
21
+
22
+ def self.included(base)
23
+ base.extend(ClassMethods)
24
+ end
25
+
26
+ # Instance helper for displaying a colored message via TTY::Prompt
27
+ def display_message(message, type: :info)
28
+ prompt = message_display_prompt
29
+ prompt.say(message, color: COLOR_MAP.fetch(type, :white))
30
+ end
31
+
32
+ # Provide a memoized prompt per including instance (if it defines @prompt)
33
+ def message_display_prompt
34
+ if instance_variable_defined?(:@prompt) && @prompt
35
+ @prompt
36
+ else
37
+ @__message_display_prompt ||= TTY::Prompt.new
38
+ end
39
+ end
40
+
41
+ module ClassMethods
42
+ # Class-level display helper (stateless)
43
+ def display_message(message, type: :info)
44
+ TTY::Prompt.new.say(message, color: COLOR_MAP.fetch(type, :white))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -138,7 +138,7 @@ module Aidp
138
138
  case provider_type
139
139
  when "cursor"
140
140
  Aidp::Providers::Cursor.new(prompt: prompt)
141
- when "anthropic"
141
+ when "anthropic", "claude"
142
142
  Aidp::Providers::Anthropic.new(prompt: prompt)
143
143
  when "gemini"
144
144
  Aidp::Providers::Gemini.new(prompt: prompt)
@@ -146,6 +146,8 @@ module Aidp
146
146
  Aidp::Providers::MacOSUI.new(prompt: prompt)
147
147
  when "github_copilot"
148
148
  Aidp::Providers::GithubCopilot.new(prompt: prompt)
149
+ when "codex"
150
+ Aidp::Providers::Codex.new(prompt: prompt)
149
151
  end
150
152
  end
151
153
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "base"
4
5
  require_relative "../debug_mixin"
5
6
 
@@ -16,6 +17,10 @@ module Aidp
16
17
  "anthropic"
17
18
  end
18
19
 
20
+ def display_name
21
+ "Anthropic Claude CLI"
22
+ end
23
+
19
24
  def available?
20
25
  self.class.available?
21
26
  end
@@ -29,16 +34,46 @@ module Aidp
29
34
  debug_provider("claude", "Starting execution", {timeout: timeout_seconds})
30
35
  debug_log("📝 Sending prompt to claude...", level: :info)
31
36
 
37
+ # Check if streaming mode is enabled
38
+ streaming_enabled = ENV["AIDP_STREAMING"] == "1" || ENV["DEBUG"] == "1"
39
+
40
+ # Build command arguments with proper streaming support
41
+ args = ["--print"]
42
+ if streaming_enabled
43
+ # Claude CLI requires --verbose when using --print with --output-format=stream-json
44
+ args += ["--verbose", "--output-format=stream-json", "--include-partial-messages"]
45
+ display_message("📺 True streaming enabled - real-time chunks from Claude API", type: :info)
46
+ else
47
+ # Use text format for non-streaming (default behavior)
48
+ args += ["--output-format=text"]
49
+ end
50
+
32
51
  begin
33
- # Use debug_execute_command for better debugging
34
- result = debug_execute_command("claude", args: ["--print"], input: prompt, timeout: timeout_seconds)
52
+ # Use debug_execute_command with streaming support
53
+ result = debug_execute_command("claude", args: args, input: prompt, timeout: timeout_seconds, streaming: streaming_enabled)
35
54
 
36
55
  # Log the results
37
- debug_command("claude", args: ["--print"], input: prompt, output: result.out, error: result.err, exit_code: result.exit_status)
56
+ debug_command("claude", args: args, input: prompt, output: result.out, error: result.err, exit_code: result.exit_status)
38
57
 
39
58
  if result.exit_status == 0
40
- result.out
59
+ # Handle different output formats
60
+ if streaming_enabled && args.include?("--output-format=stream-json")
61
+ # Parse stream-json output and extract final content
62
+ parse_stream_json_output(result.out)
63
+ else
64
+ # Return text output as-is
65
+ result.out
66
+ end
41
67
  else
68
+ # Detect auth issues in stdout/stderr (Claude sometimes prints JSON with auth error to stdout)
69
+ combined = [result.out, result.err].compact.join("\n")
70
+ if combined.downcase.include?("oauth token has expired") || combined.downcase.include?("authentication_error")
71
+ error_message = "Authentication error from Claude CLI: token expired or invalid. Run 'claude /login' or refresh credentials."
72
+ debug_error(StandardError.new(error_message), {exit_code: result.exit_status, stdout: result.out, stderr: result.err})
73
+ # Raise a recognizable error for classifier
74
+ raise error_message
75
+ end
76
+
42
77
  debug_error(StandardError.new("claude failed"), {exit_code: result.exit_status, stderr: result.err})
43
78
  raise "claude failed with exit code #{result.exit_status}: #{result.err}"
44
79
  end
@@ -58,8 +93,8 @@ module Aidp
58
93
  # 4. Default timeout
59
94
 
60
95
  if ENV["AIDP_QUICK_MODE"]
61
- display_message("⚡ Quick mode enabled - 2 minute timeout", type: :highlight)
62
- return 120
96
+ display_message("⚡ Quick mode enabled - #{TIMEOUT_QUICK_MODE / 60} minute timeout", type: :highlight)
97
+ return TIMEOUT_QUICK_MODE
63
98
  end
64
99
 
65
100
  if ENV["AIDP_ANTHROPIC_TIMEOUT"]
@@ -73,9 +108,9 @@ module Aidp
73
108
  return step_timeout
74
109
  end
75
110
 
76
- # Default timeout (5 minutes for interactive use)
77
- display_message("📋 Using default timeout: 5 minutes", type: :info)
78
- 300
111
+ # Default timeout
112
+ display_message("📋 Using default timeout: #{TIMEOUT_DEFAULT / 60} minutes", type: :info)
113
+ TIMEOUT_DEFAULT
79
114
  end
80
115
 
81
116
  def get_adaptive_timeout
@@ -84,23 +119,71 @@ module Aidp
84
119
 
85
120
  case step_name
86
121
  when /REPOSITORY_ANALYSIS/
87
- 180 # 3 minutes - repository analysis can be quick
122
+ TIMEOUT_REPOSITORY_ANALYSIS
88
123
  when /ARCHITECTURE_ANALYSIS/
89
- 600 # 10 minutes - architecture analysis needs more time
124
+ TIMEOUT_ARCHITECTURE_ANALYSIS
90
125
  when /TEST_ANALYSIS/
91
- 300 # 5 minutes - test analysis is moderate
126
+ TIMEOUT_TEST_ANALYSIS
92
127
  when /FUNCTIONALITY_ANALYSIS/
93
- 600 # 10 minutes - functionality analysis is complex
128
+ TIMEOUT_FUNCTIONALITY_ANALYSIS
94
129
  when /DOCUMENTATION_ANALYSIS/
95
- 300 # 5 minutes - documentation analysis is moderate
130
+ TIMEOUT_DOCUMENTATION_ANALYSIS
96
131
  when /STATIC_ANALYSIS/
97
- 450 # 7.5 minutes - static analysis can be intensive
132
+ TIMEOUT_STATIC_ANALYSIS
98
133
  when /REFACTORING_RECOMMENDATIONS/
99
- 600 # 10 minutes - refactoring recommendations are complex
134
+ TIMEOUT_REFACTORING_RECOMMENDATIONS
100
135
  else
101
- nil # Use default
136
+ nil # Use default
102
137
  end
103
138
  end
139
+
140
+ # Parse stream-json output from Claude CLI
141
+ def parse_stream_json_output(output)
142
+ return output if output.nil? || output.empty?
143
+
144
+ # Stream-json output contains multiple JSON objects, one per line
145
+ # We want to extract the final content from the last complete message
146
+ lines = output.strip.split("\n")
147
+ content_parts = []
148
+
149
+ lines.each do |line|
150
+ next if line.strip.empty?
151
+
152
+ begin
153
+ json_obj = JSON.parse(line)
154
+
155
+ # Look for content in various possible structures
156
+ if json_obj["type"] == "content_block_delta" && json_obj["delta"] && json_obj["delta"]["text"]
157
+ content_parts << json_obj["delta"]["text"]
158
+ elsif json_obj["content"]&.is_a?(Array)
159
+ json_obj["content"].each do |content_item|
160
+ content_parts << content_item["text"] if content_item["text"]
161
+ end
162
+ elsif json_obj["message"] && json_obj["message"]["content"]
163
+ if json_obj["message"]["content"].is_a?(Array)
164
+ json_obj["message"]["content"].each do |content_item|
165
+ content_parts << content_item["text"] if content_item["text"]
166
+ end
167
+ elsif json_obj["message"]["content"].is_a?(String)
168
+ content_parts << json_obj["message"]["content"]
169
+ end
170
+ end
171
+ rescue JSON::ParserError => e
172
+ debug_log("⚠️ Failed to parse JSON line: #{e.message}", level: :warn, data: {line: line})
173
+ # If JSON parsing fails, treat as plain text
174
+ content_parts << line
175
+ end
176
+ end
177
+
178
+ result = content_parts.join
179
+
180
+ # Fallback: if no content found in JSON, return original output
181
+ result.empty? ? output : result
182
+ rescue => e
183
+ debug_log("⚠️ Failed to parse stream-json output: #{e.message}", level: :warn)
184
+ # Return original output if parsing fails
185
+ output
186
+ end
104
187
  end
105
188
  end
106
189
  end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tty-prompt"
4
+ require "tty-spinner"
4
5
 
5
6
  module Aidp
6
7
  module Providers
7
8
  class Base
9
+ include Aidp::MessageDisplay
10
+
8
11
  # Activity indicator states
9
12
  ACTIVITY_STATES = {
10
13
  idle: "⏳",
@@ -17,6 +20,18 @@ module Aidp
17
20
  # Default timeout for stuck detection (2 minutes)
18
21
  DEFAULT_STUCK_TIMEOUT = 120
19
22
 
23
+ # Configurable timeout values (can be overridden via environment or config)
24
+ # These defaults provide reasonable values for different execution scenarios
25
+ TIMEOUT_QUICK_MODE = 120 # 2 minutes - for quick testing
26
+ TIMEOUT_DEFAULT = 300 # 5 minutes - standard interactive timeout
27
+ TIMEOUT_REPOSITORY_ANALYSIS = 180 # 3 minutes - repository analysis
28
+ TIMEOUT_ARCHITECTURE_ANALYSIS = 600 # 10 minutes - architecture analysis
29
+ TIMEOUT_TEST_ANALYSIS = 300 # 5 minutes - test analysis
30
+ TIMEOUT_FUNCTIONALITY_ANALYSIS = 600 # 10 minutes - functionality analysis
31
+ TIMEOUT_DOCUMENTATION_ANALYSIS = 300 # 5 minutes - documentation analysis
32
+ TIMEOUT_STATIC_ANALYSIS = 450 # 7.5 minutes - static analysis
33
+ TIMEOUT_REFACTORING_RECOMMENDATIONS = 600 # 10 minutes - refactoring
34
+
20
35
  attr_reader :activity_state, :last_activity_time, :start_time, :step_name
21
36
 
22
37
  def initialize(output: nil, prompt: TTY::Prompt.new)
@@ -48,6 +63,12 @@ module Aidp
48
63
  raise NotImplementedError, "#{self.class} must implement #name"
49
64
  end
50
65
 
66
+ # Human-friendly display name for UI
67
+ # Override in subclasses to provide a better display name
68
+ def display_name
69
+ name
70
+ end
71
+
51
72
  def send(prompt:, session: nil)
52
73
  raise NotImplementedError, "#{self.class} must implement #send"
53
74
  end
@@ -332,20 +353,30 @@ module Aidp
332
353
  (success_rate * 50) + ((1 - rate_limit_ratio) * 30) + (response_time_score * 0.2)
333
354
  end
334
355
 
335
- private
356
+ protected
336
357
 
337
- def display_message(message, type: :info)
338
- color = case type
339
- when :error then :red
340
- when :success then :green
341
- when :warning then :yellow
342
- when :info then :blue
343
- when :highlight then :cyan
344
- when :muted then :bright_black
345
- else :white
358
+ # Update spinner status with elapsed time
359
+ # This is a shared method used by all providers to display progress
360
+ def update_spinner_status(spinner, elapsed, provider_name)
361
+ minutes = (elapsed / 60).to_i
362
+ seconds = (elapsed % 60).to_i
363
+
364
+ if minutes > 0
365
+ spinner.update(title: "#{provider_name} is running... (#{minutes}m #{seconds}s)")
366
+ else
367
+ spinner.update(title: "#{provider_name} is running... (#{seconds}s)")
346
368
  end
347
- @prompt.say(message, color: color)
348
369
  end
370
+
371
+ # Clean up activity display thread and spinner
372
+ # Used by providers to ensure proper cleanup in both success and error paths
373
+ def cleanup_activity_display(activity_display_thread, spinner)
374
+ activity_display_thread.kill if activity_display_thread&.alive?
375
+ activity_display_thread&.join(0.1) # Give it 100ms to finish
376
+ spinner&.stop
377
+ end
378
+
379
+ private
349
380
  end
350
381
  end
351
382
  end