aidp 0.26.0 → 0.28.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +89 -0
  3. data/lib/aidp/cli/checkpoint_command.rb +198 -0
  4. data/lib/aidp/cli/config_command.rb +71 -0
  5. data/lib/aidp/cli/enhanced_input.rb +2 -0
  6. data/lib/aidp/cli/first_run_wizard.rb +8 -7
  7. data/lib/aidp/cli/harness_command.rb +102 -0
  8. data/lib/aidp/cli/jobs_command.rb +3 -3
  9. data/lib/aidp/cli/mcp_dashboard.rb +4 -3
  10. data/lib/aidp/cli/models_command.rb +661 -0
  11. data/lib/aidp/cli/providers_command.rb +223 -0
  12. data/lib/aidp/cli.rb +45 -464
  13. data/lib/aidp/config.rb +54 -0
  14. data/lib/aidp/daemon/runner.rb +2 -2
  15. data/lib/aidp/debug_mixin.rb +25 -10
  16. data/lib/aidp/execute/agent_signal_parser.rb +22 -0
  17. data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
  18. data/lib/aidp/execute/checkpoint_display.rb +38 -37
  19. data/lib/aidp/execute/interactive_repl.rb +2 -1
  20. data/lib/aidp/execute/prompt_manager.rb +4 -4
  21. data/lib/aidp/execute/repl_macros.rb +2 -2
  22. data/lib/aidp/execute/steps.rb +94 -1
  23. data/lib/aidp/execute/work_loop_runner.rb +238 -19
  24. data/lib/aidp/execute/workflow_selector.rb +4 -27
  25. data/lib/aidp/firewall/provider_requirements_collector.rb +262 -0
  26. data/lib/aidp/harness/ai_decision_engine.rb +35 -2
  27. data/lib/aidp/harness/config_manager.rb +5 -10
  28. data/lib/aidp/harness/config_schema.rb +8 -0
  29. data/lib/aidp/harness/configuration.rb +40 -2
  30. data/lib/aidp/harness/enhanced_runner.rb +25 -19
  31. data/lib/aidp/harness/error_handler.rb +23 -73
  32. data/lib/aidp/harness/model_cache.rb +269 -0
  33. data/lib/aidp/harness/model_discovery_service.rb +259 -0
  34. data/lib/aidp/harness/model_registry.rb +201 -0
  35. data/lib/aidp/harness/provider_factory.rb +11 -2
  36. data/lib/aidp/harness/runner.rb +5 -0
  37. data/lib/aidp/harness/state_manager.rb +0 -7
  38. data/lib/aidp/harness/thinking_depth_manager.rb +202 -7
  39. data/lib/aidp/harness/ui/enhanced_tui.rb +8 -18
  40. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +0 -18
  41. data/lib/aidp/harness/ui/progress_display.rb +6 -2
  42. data/lib/aidp/harness/user_interface.rb +0 -58
  43. data/lib/aidp/init/runner.rb +7 -2
  44. data/lib/aidp/message_display.rb +0 -46
  45. data/lib/aidp/planning/analyzers/feedback_analyzer.rb +365 -0
  46. data/lib/aidp/planning/builders/agile_plan_builder.rb +387 -0
  47. data/lib/aidp/planning/builders/project_plan_builder.rb +193 -0
  48. data/lib/aidp/planning/generators/gantt_generator.rb +190 -0
  49. data/lib/aidp/planning/generators/iteration_plan_generator.rb +392 -0
  50. data/lib/aidp/planning/generators/legacy_research_planner.rb +473 -0
  51. data/lib/aidp/planning/generators/marketing_report_generator.rb +348 -0
  52. data/lib/aidp/planning/generators/mvp_scope_generator.rb +310 -0
  53. data/lib/aidp/planning/generators/user_test_plan_generator.rb +373 -0
  54. data/lib/aidp/planning/generators/wbs_generator.rb +259 -0
  55. data/lib/aidp/planning/mappers/persona_mapper.rb +163 -0
  56. data/lib/aidp/planning/parsers/document_parser.rb +141 -0
  57. data/lib/aidp/planning/parsers/feedback_data_parser.rb +252 -0
  58. data/lib/aidp/provider_manager.rb +8 -32
  59. data/lib/aidp/providers/adapter.rb +2 -4
  60. data/lib/aidp/providers/aider.rb +264 -0
  61. data/lib/aidp/providers/anthropic.rb +206 -121
  62. data/lib/aidp/providers/base.rb +123 -3
  63. data/lib/aidp/providers/capability_registry.rb +0 -1
  64. data/lib/aidp/providers/codex.rb +75 -70
  65. data/lib/aidp/providers/cursor.rb +87 -59
  66. data/lib/aidp/providers/gemini.rb +57 -60
  67. data/lib/aidp/providers/github_copilot.rb +19 -66
  68. data/lib/aidp/providers/kilocode.rb +35 -80
  69. data/lib/aidp/providers/opencode.rb +35 -80
  70. data/lib/aidp/setup/wizard.rb +555 -8
  71. data/lib/aidp/version.rb +1 -1
  72. data/lib/aidp/watch/build_processor.rb +211 -30
  73. data/lib/aidp/watch/change_request_processor.rb +128 -14
  74. data/lib/aidp/watch/ci_fix_processor.rb +103 -37
  75. data/lib/aidp/watch/ci_log_extractor.rb +258 -0
  76. data/lib/aidp/watch/github_state_extractor.rb +177 -0
  77. data/lib/aidp/watch/implementation_verifier.rb +284 -0
  78. data/lib/aidp/watch/plan_generator.rb +95 -52
  79. data/lib/aidp/watch/plan_processor.rb +7 -6
  80. data/lib/aidp/watch/repository_client.rb +245 -17
  81. data/lib/aidp/watch/review_processor.rb +100 -19
  82. data/lib/aidp/watch/reviewers/base_reviewer.rb +1 -1
  83. data/lib/aidp/watch/runner.rb +181 -29
  84. data/lib/aidp/watch/state_store.rb +22 -1
  85. data/lib/aidp/workflows/definitions.rb +147 -0
  86. data/lib/aidp/workflows/guided_agent.rb +3 -3
  87. data/lib/aidp/workstream_cleanup.rb +245 -0
  88. data/lib/aidp/worktree.rb +19 -0
  89. data/templates/aidp-development.yml.example +2 -2
  90. data/templates/aidp-production.yml.example +3 -3
  91. data/templates/aidp.yml.example +57 -0
  92. data/templates/implementation/generate_tdd_specs.md +213 -0
  93. data/templates/implementation/iterative_implementation.md +122 -0
  94. data/templates/planning/agile/analyze_feedback.md +183 -0
  95. data/templates/planning/agile/generate_iteration_plan.md +179 -0
  96. data/templates/planning/agile/generate_legacy_research_plan.md +171 -0
  97. data/templates/planning/agile/generate_marketing_report.md +162 -0
  98. data/templates/planning/agile/generate_mvp_scope.md +127 -0
  99. data/templates/planning/agile/generate_user_test_plan.md +143 -0
  100. data/templates/planning/agile/ingest_feedback.md +174 -0
  101. data/templates/planning/assemble_project_plan.md +113 -0
  102. data/templates/planning/assign_personas.md +108 -0
  103. data/templates/planning/create_tasks.md +52 -6
  104. data/templates/planning/generate_gantt.md +86 -0
  105. data/templates/planning/generate_wbs.md +85 -0
  106. data/templates/planning/initialize_planning_mode.md +70 -0
  107. data/templates/skills/README.md +2 -2
  108. data/templates/skills/marketing_strategist/SKILL.md +279 -0
  109. data/templates/skills/product_manager/SKILL.md +177 -0
  110. data/templates/skills/ruby_aidp_planning/SKILL.md +497 -0
  111. data/templates/skills/ruby_rspec_tdd/SKILL.md +514 -0
  112. data/templates/skills/ux_researcher/SKILL.md +222 -0
  113. metadata +47 -1
@@ -11,6 +11,9 @@ require_relative "../harness/config_manager"
11
11
  require_relative "../execute/prompt_manager"
12
12
  require_relative "../harness/runner"
13
13
  require_relative "../harness/state_manager"
14
+ require_relative "../worktree"
15
+ require_relative "github_state_extractor"
16
+ require_relative "ci_log_extractor"
14
17
 
15
18
  module Aidp
16
19
  module Watch
@@ -30,6 +33,7 @@ module Aidp
30
33
  def initialize(repository_client:, state_store:, provider_name: nil, project_dir: Dir.pwd, label_config: {}, verbose: false)
31
34
  @repository_client = repository_client
32
35
  @state_store = state_store
36
+ @state_extractor = GitHubStateExtractor.new(repository_client: repository_client)
33
37
  @provider_name = provider_name
34
38
  @project_dir = project_dir
35
39
  @verbose = verbose
@@ -41,9 +45,12 @@ module Aidp
41
45
  def process(pr)
42
46
  number = pr[:number]
43
47
 
44
- # Check if already processed successfully
45
- if @state_store.ci_fix_completed?(number)
48
+ Aidp.log_debug("ci_fix_processor", "process_started", pr_number: number, pr_title: pr[:title])
49
+
50
+ # Check if already processed successfully via GitHub comments
51
+ if @state_extractor.ci_fix_completed?(pr)
46
52
  display_message("ℹ️ CI fix for PR ##{number} already completed. Skipping.", type: :muted)
53
+ Aidp.log_debug("ci_fix_processor", "already_completed", pr_number: number)
47
54
  return
48
55
  end
49
56
 
@@ -53,9 +60,16 @@ module Aidp
53
60
  pr_data = @repository_client.fetch_pull_request(number)
54
61
  ci_status = @repository_client.fetch_ci_status(number)
55
62
 
63
+ Aidp.log_debug("ci_fix_processor", "ci_status_fetched",
64
+ pr_number: number,
65
+ ci_state: ci_status[:state],
66
+ check_count: ci_status[:checks]&.length || 0,
67
+ checks: ci_status[:checks]&.map { |c| {name: c[:name], status: c[:status], conclusion: c[:conclusion]} })
68
+
56
69
  # Check if there are failures
57
70
  if ci_status[:state] == "success"
58
71
  display_message("✅ CI is passing for PR ##{number}. No fixes needed.", type: :success)
72
+ Aidp.log_debug("ci_fix_processor", "ci_passing", pr_number: number)
59
73
  post_success_comment(pr_data)
60
74
  @state_store.record_ci_fix(number, {status: "no_failures", timestamp: Time.now.utc.iso8601})
61
75
  begin
@@ -68,14 +82,25 @@ module Aidp
68
82
 
69
83
  if ci_status[:state] == "pending"
70
84
  display_message("⏳ CI is still running for PR ##{number}. Skipping for now.", type: :muted)
85
+ Aidp.log_debug("ci_fix_processor", "ci_pending", pr_number: number)
71
86
  return
72
87
  end
73
88
 
74
89
  # Get failed checks
75
90
  failed_checks = ci_status[:checks].select { |check| check[:conclusion] == "failure" }
76
91
 
92
+ Aidp.log_debug("ci_fix_processor", "failed_checks_filtered",
93
+ pr_number: number,
94
+ total_checks: ci_status[:checks]&.length || 0,
95
+ failed_count: failed_checks.length,
96
+ failed_checks: failed_checks.map { |c| c[:name] })
97
+
77
98
  if failed_checks.empty?
78
99
  display_message("⚠️ No specific failed checks found for PR ##{number}.", type: :warn)
100
+ Aidp.log_debug("ci_fix_processor", "no_failed_checks",
101
+ pr_number: number,
102
+ ci_state: ci_status[:state],
103
+ all_checks: ci_status[:checks]&.map { |c| {name: c[:name], conclusion: c[:conclusion]} })
79
104
  return
80
105
  end
81
106
 
@@ -99,31 +124,36 @@ module Aidp
99
124
  display_message("❌ CI fix failed: #{e.message}", type: :error)
100
125
  Aidp.log_error("ci_fix_processor", "CI fix failed", pr: pr[:number], error: e.message, backtrace: e.backtrace&.first(10))
101
126
 
102
- # Post error comment
103
- error_comment = <<~COMMENT
104
- #{COMMENT_HEADER}
105
-
106
- ❌ Automated CI fix failed: #{e.message}
107
-
108
- Please investigate the CI failures manually or retry by re-adding the `#{@ci_fix_label}` label.
109
- COMMENT
110
- begin
111
- @repository_client.post_comment(pr[:number], error_comment)
112
- rescue
113
- nil
114
- end
127
+ # Record failure state internally but DON'T post error to GitHub
128
+ # (per issue #280 - error messages should never appear on issues)
129
+ @state_store.record_ci_fix(pr[:number], {
130
+ status: "error",
131
+ error: e.message,
132
+ error_class: e.class.name,
133
+ timestamp: Time.now.utc.iso8601
134
+ })
115
135
  end
116
136
 
117
137
  private
118
138
 
119
139
  def analyze_and_fix(pr_data:, ci_status:, failed_checks:)
120
- # Fetch logs for failed checks (if available)
140
+ # Extract concise failure information to reduce token usage
141
+ provider = detect_default_provider
142
+ provider_manager = Aidp::ProviderManager.get_provider(provider)
143
+ log_extractor = CiLogExtractor.new(provider_manager: provider_manager)
144
+
121
145
  failure_details = failed_checks.map do |check|
146
+ Aidp.log_debug("ci_fix_processor", "extracting_logs", check_name: check[:name])
147
+ extracted = log_extractor.extract_failure_info(
148
+ check: check,
149
+ check_run_url: check[:details_url]
150
+ )
151
+
122
152
  {
123
153
  name: check[:name],
124
- conclusion: check[:conclusion],
125
- output: check[:output],
126
- details_url: check[:details_url]
154
+ summary: extracted[:summary],
155
+ details: extracted[:details],
156
+ extraction_method: extracted[:extraction_method]
127
157
  }
128
158
  end
129
159
 
@@ -131,14 +161,14 @@ module Aidp
131
161
  analysis = analyze_failures_with_ai(pr_data: pr_data, failures: failure_details)
132
162
 
133
163
  if analysis[:can_fix]
134
- # Checkout the PR branch and apply fixes
135
- checkout_pr_branch(pr_data)
164
+ # Setup worktree for the PR branch
165
+ working_dir = setup_pr_worktree(pr_data)
136
166
 
137
167
  # Apply the proposed fixes
138
- apply_fixes(analysis[:fixes])
168
+ apply_fixes(analysis[:fixes], working_dir: working_dir)
139
169
 
140
170
  # Commit and push
141
- if commit_and_push(pr_data, analysis)
171
+ if commit_and_push(pr_data, analysis, working_dir: working_dir)
142
172
  {success: true, analysis: analysis, commit_created: true}
143
173
  else
144
174
  {success: false, analysis: analysis, reason: "No changes to commit"}
@@ -152,7 +182,7 @@ module Aidp
152
182
 
153
183
  def analyze_failures_with_ai(pr_data:, failures:)
154
184
  provider_name = @provider_name || detect_default_provider
155
- provider = Aidp::ProviderManager.get_provider(provider_name, use_harness: false)
185
+ provider = Aidp::ProviderManager.get_provider(provider_name)
156
186
 
157
187
  user_prompt = build_ci_analysis_prompt(pr_data: pr_data, failures: failures)
158
188
  full_prompt = "#{ci_fix_system_prompt}\n\n#{user_prompt}"
@@ -222,12 +252,20 @@ module Aidp
222
252
  #{pr_data[:body]}
223
253
 
224
254
  **Failed Checks:**
225
- #{failures.map { |f| "- #{f[:name]}: #{f[:conclusion]}\n Output: #{f[:output].inspect}" }.join("\n")}
255
+ #{failures.map { |f| format_failure_for_prompt(f) }.join("\n\n")}
226
256
 
227
257
  Please analyze these failures and propose fixes if possible.
228
258
  PROMPT
229
259
  end
230
260
 
261
+ def format_failure_for_prompt(failure)
262
+ output = "**Check: #{failure[:name]}**\n"
263
+ output += "Summary: #{failure[:summary]}\n" if failure[:summary]
264
+ output += "\nDetails:\n```\n#{failure[:details]}\n```" if failure[:details]
265
+ output += "\n(Logs extracted using: #{failure[:extraction_method]})" if failure[:extraction_method]
266
+ output
267
+ end
268
+
231
269
  def detect_default_provider
232
270
  config_manager = Aidp::Harness::ConfigManager.new(@project_dir)
233
271
  config_manager.default_provider || "anthropic"
@@ -261,26 +299,54 @@ module Aidp
261
299
  end
262
300
  end
263
301
 
264
- def checkout_pr_branch(pr_data)
302
+ def setup_pr_worktree(pr_data)
265
303
  head_ref = pr_data[:head_ref]
304
+ pr_number = pr_data[:number]
305
+
306
+ # Check if a worktree already exists for this branch
307
+ existing = Aidp::Worktree.find_by_branch(branch: head_ref, project_dir: @project_dir)
308
+
309
+ if existing && existing[:active]
310
+ display_message("🔄 Reusing existing worktree for branch: #{head_ref}", type: :info)
311
+ Aidp.log_debug("ci_fix_processor", "worktree_reused", pr_number: pr_number, branch: head_ref, path: existing[:path])
312
+
313
+ # Pull latest changes in the worktree
314
+ Dir.chdir(existing[:path]) do
315
+ run_git(%w[fetch origin], allow_failure: true)
316
+ run_git(["checkout", head_ref])
317
+ run_git(%w[pull --ff-only], allow_failure: true)
318
+ end
266
319
 
320
+ return existing[:path]
321
+ end
322
+
323
+ # Create a new worktree for this PR
324
+ slug = "pr-#{pr_number}-ci-fix"
325
+ display_message("🌿 Creating worktree for PR ##{pr_number}: #{head_ref}", type: :info)
326
+
327
+ # Fetch the branch first
267
328
  Dir.chdir(@project_dir) do
268
- # Fetch latest
269
329
  run_git(%w[fetch origin])
330
+ end
270
331
 
271
- # Checkout the PR branch
272
- run_git(["checkout", head_ref])
332
+ # Create worktree
333
+ result = Aidp::Worktree.create(
334
+ slug: slug,
335
+ project_dir: @project_dir,
336
+ branch: head_ref,
337
+ base_branch: nil # Branch already exists, no base needed
338
+ )
273
339
 
274
- # Pull latest changes
275
- run_git(%w[pull --ff-only], allow_failure: true)
276
- end
340
+ worktree_path = result[:path]
341
+ Aidp.log_debug("ci_fix_processor", "worktree_created", pr_number: pr_number, branch: head_ref, path: worktree_path)
342
+ display_message("✅ Worktree created at #{worktree_path}", type: :success)
277
343
 
278
- display_message("🌿 Checked out branch: #{head_ref}", type: :info)
344
+ worktree_path
279
345
  end
280
346
 
281
- def apply_fixes(fixes)
347
+ def apply_fixes(fixes, working_dir:)
282
348
  fixes.each do |fix|
283
- file_path = File.join(@project_dir, fix["file"])
349
+ file_path = File.join(working_dir, fix["file"])
284
350
 
285
351
  case fix["action"]
286
352
  when "create", "edit"
@@ -296,8 +362,8 @@ module Aidp
296
362
  end
297
363
  end
298
364
 
299
- def commit_and_push(pr_data, analysis)
300
- Dir.chdir(@project_dir) do
365
+ def commit_and_push(pr_data, analysis, working_dir:)
366
+ Dir.chdir(working_dir) do
301
367
  # Check if there are changes
302
368
  status_output = run_git(%w[status --porcelain])
303
369
  if status_output.strip.empty?
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require "json"
6
+
7
+ module Aidp
8
+ module Watch
9
+ # Intelligently extracts relevant failure information from CI logs
10
+ # to reduce token usage when analyzing failures with AI.
11
+ #
12
+ # Instead of feeding full CI output to AI, this creates a tailored
13
+ # extraction script for each failure type, runs it, and provides
14
+ # only the relevant excerpts.
15
+ class CiLogExtractor
16
+ # Maximum size for extracted log content (in characters)
17
+ MAX_EXTRACTED_SIZE = 10_000
18
+
19
+ def initialize(provider_manager:)
20
+ @provider_manager = provider_manager
21
+ end
22
+
23
+ # Extract relevant failure information from a CI check
24
+ #
25
+ # @param check [Hash] Failed check information
26
+ # @param check_run_url [String] URL to the GitHub Actions run (optional)
27
+ # @return [Hash] Extracted failure info with :summary, :details, :script_used
28
+ def extract_failure_info(check:, check_run_url: nil)
29
+ Aidp.log_debug("ci_log_extractor", "extract_start",
30
+ check_name: check[:name],
31
+ has_output: !check[:output].nil?)
32
+
33
+ # If we have structured output from the check, use it
34
+ if check[:output] && check[:output]["summary"]
35
+ return extract_from_structured_output(check)
36
+ end
37
+
38
+ # If we have a check run URL, fetch the logs
39
+ if check_run_url
40
+ raw_logs = fetch_check_logs(check_run_url)
41
+ return extract_from_raw_logs(check: check, raw_logs: raw_logs) if raw_logs
42
+ end
43
+
44
+ # Fallback: minimal information
45
+ {
46
+ summary: "Check '#{check[:name]}' failed",
47
+ details: check[:output]&.dig("text") || "No additional details available",
48
+ extraction_method: "fallback"
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def extract_from_structured_output(check)
55
+ summary = check[:output]["summary"] || ""
56
+ text = check[:output]["text"] || ""
57
+
58
+ # If the output is already concise, return it directly
59
+ full_output = "#{summary}\n\n#{text}".strip
60
+ if full_output.length <= MAX_EXTRACTED_SIZE
61
+ return {
62
+ summary: summary,
63
+ details: text,
64
+ extraction_method: "structured"
65
+ }
66
+ end
67
+
68
+ # Output is too large, create extraction script
69
+ extraction_result = create_and_run_extraction_script(
70
+ check_name: check[:name],
71
+ raw_content: full_output
72
+ )
73
+
74
+ {
75
+ summary: summary,
76
+ details: extraction_result[:extracted_content],
77
+ extraction_method: "ai_script",
78
+ script_used: extraction_result[:script]
79
+ }
80
+ end
81
+
82
+ def extract_from_raw_logs(check:, raw_logs:)
83
+ # If logs are already concise, return them
84
+ if raw_logs.length <= MAX_EXTRACTED_SIZE
85
+ return {
86
+ summary: "Check '#{check[:name]}' failed",
87
+ details: raw_logs,
88
+ extraction_method: "raw"
89
+ }
90
+ end
91
+
92
+ # Logs are too large, create extraction script
93
+ extraction_result = create_and_run_extraction_script(
94
+ check_name: check[:name],
95
+ raw_content: raw_logs
96
+ )
97
+
98
+ {
99
+ summary: "Check '#{check[:name]}' failed",
100
+ details: extraction_result[:extracted_content],
101
+ extraction_method: "ai_script",
102
+ script_used: extraction_result[:script]
103
+ }
104
+ end
105
+
106
+ def create_and_run_extraction_script(check_name:, raw_content:)
107
+ Aidp.log_debug("ci_log_extractor", "creating_script",
108
+ check_name: check_name,
109
+ content_size: raw_content.length)
110
+
111
+ # Ask AI to create an extraction script
112
+ script = generate_extraction_script(
113
+ check_name: check_name,
114
+ sample_content: truncate_sample(raw_content)
115
+ )
116
+
117
+ # Run the script
118
+ extracted = run_extraction_script(script: script, input: raw_content)
119
+
120
+ {
121
+ extracted_content: extracted,
122
+ script: script
123
+ }
124
+ rescue => e
125
+ Aidp.log_warn("ci_log_extractor", "script_execution_failed",
126
+ error: e.message,
127
+ check_name: check_name)
128
+
129
+ # Fallback: simple head/tail extraction
130
+ {
131
+ extracted_content: simple_extract(raw_content),
132
+ script: nil
133
+ }
134
+ end
135
+
136
+ def generate_extraction_script(check_name:, sample_content:)
137
+ prompt = <<~PROMPT
138
+ Create a shell script that extracts relevant failure information from CI logs.
139
+
140
+ The script should:
141
+ 1. Read from STDIN
142
+ 2. Extract ONLY the relevant error messages, failed tests, and stack traces
143
+ 3. Omit verbose output, successful tests, and build information
144
+ 4. Keep the extracted output under #{MAX_EXTRACTED_SIZE} characters
145
+ 5. Output the extracted content to STDOUT
146
+
147
+ Check type: #{check_name}
148
+
149
+ Sample of the log content (first ~2000 chars):
150
+ ```
151
+ #{sample_content}
152
+ ```
153
+
154
+ Requirements:
155
+ - Use standard Unix tools (grep, awk, sed, head, tail)
156
+ - Handle multi-line error messages
157
+ - Focus on actionable error information
158
+ - If it's a test failure, extract test names and failure messages
159
+ - If it's a linting error, extract file names, line numbers, and violations
160
+
161
+ Respond ONLY with the shell script code, no explanation.
162
+ The script must be a valid bash script that reads STDIN.
163
+
164
+ Example format:
165
+ #!/bin/bash
166
+ grep -A 5 "FAILED" | head -n 100
167
+ PROMPT
168
+
169
+ response = @provider_manager.send_message(prompt: prompt)
170
+ extract_script_from_response(response.to_s)
171
+ end
172
+
173
+ def extract_script_from_response(response)
174
+ # Remove markdown code fences if present
175
+ script = response.strip
176
+ script = script.gsub(/^```(?:bash|sh)?\n/, "")
177
+ script = script.gsub(/\n```$/, "")
178
+
179
+ # Ensure it starts with shebang
180
+ unless script.start_with?("#!/")
181
+ script = "#!/bin/bash\n#{script}"
182
+ end
183
+
184
+ script
185
+ end
186
+
187
+ def run_extraction_script(script:, input:)
188
+ Tempfile.create(["ci_extract", ".sh"]) do |script_file|
189
+ script_file.write(script)
190
+ script_file.flush
191
+ script_file.chmod(0o755)
192
+
193
+ stdout, stderr, status = Open3.capture3(
194
+ "bash", script_file.path,
195
+ stdin_data: input,
196
+ chdir: Dir.tmpdir
197
+ )
198
+
199
+ unless status.success?
200
+ Aidp.log_warn("ci_log_extractor", "script_failed",
201
+ exit_code: status.exitstatus,
202
+ stderr: stderr[0, 500])
203
+ return simple_extract(input)
204
+ end
205
+
206
+ # Ensure output is within size limit
207
+ truncate_to_size(stdout)
208
+ end
209
+ end
210
+
211
+ def fetch_check_logs(check_run_url)
212
+ # TODO: Implement fetching logs from GitHub Actions
213
+ # This would require parsing the check run URL and using gh CLI or API
214
+ # For now, return nil to use structured output
215
+ nil
216
+ end
217
+
218
+ def truncate_sample(content, size: 2000)
219
+ return content if content.length <= size
220
+ "#{content[0, size]}\n... [truncated]"
221
+ end
222
+
223
+ def simple_extract(content)
224
+ # Simple fallback: extract lines containing error keywords
225
+ lines = content.lines
226
+ error_lines = lines.select do |line|
227
+ line.match?(/error|fail|exception|assert/i)
228
+ end
229
+
230
+ # If we found error lines, return them with context
231
+ if error_lines.any?
232
+ # Get line numbers of errors and include surrounding context
233
+ result = []
234
+ lines.each_with_index do |line, i|
235
+ if line.match?(/error|fail|exception|assert/i)
236
+ # Include 2 lines before and 3 lines after
237
+ start_idx = [0, i - 2].max
238
+ end_idx = [lines.length - 1, i + 3].min
239
+ result.concat(lines[start_idx..end_idx])
240
+ end
241
+ end
242
+
243
+ truncate_to_size(result.uniq.join)
244
+ else
245
+ # No clear errors, return head and tail
246
+ head = lines.first(50).join
247
+ tail = lines.last(50).join
248
+ truncate_to_size("=== First 50 lines ===\n#{head}\n\n=== Last 50 lines ===\n#{tail}")
249
+ end
250
+ end
251
+
252
+ def truncate_to_size(content, size: MAX_EXTRACTED_SIZE)
253
+ return content if content.length <= size
254
+ "#{content[0, size - 20]}\n... [truncated]"
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Aidp
6
+ module Watch
7
+ # Extracts state information from GitHub issues/PRs using labels and comments
8
+ # as the single source of truth, enabling multiple AIDP instances to coordinate
9
+ # without local state files.
10
+ class GitHubStateExtractor
11
+ # Pattern for detecting completion comments
12
+ COMPLETION_PATTERN = /✅ Implementation complete for #(\d+)/i
13
+
14
+ # Pattern for detecting detection comments
15
+ DETECTION_PATTERN = /aidp detected `([^`]+)` at ([^\n]+) and is working on it/i
16
+
17
+ # Pattern for detecting plan proposal comments
18
+ PLAN_PROPOSAL_PATTERN = /<!-- PLAN_SUMMARY_START -->/i
19
+
20
+ # Pattern for detecting in-progress label
21
+ IN_PROGRESS_LABEL = "aidp-in-progress"
22
+
23
+ def initialize(repository_client:)
24
+ @repository_client = repository_client
25
+ end
26
+
27
+ # Check if an issue/PR is currently being worked on by another instance
28
+ def in_progress?(item)
29
+ has_label?(item, IN_PROGRESS_LABEL)
30
+ end
31
+
32
+ # Check if build is already completed for an issue
33
+ # Looks for completion comment from AIDP
34
+ def build_completed?(issue)
35
+ return false unless issue[:comments]
36
+
37
+ issue[:comments].any? do |comment|
38
+ comment["body"]&.match?(COMPLETION_PATTERN)
39
+ end
40
+ end
41
+
42
+ # Check if a plan has been posted for an issue
43
+ def plan_posted?(issue)
44
+ return false unless issue[:comments]
45
+
46
+ issue[:comments].any? do |comment|
47
+ comment["body"]&.match?(PLAN_PROPOSAL_PATTERN)
48
+ end
49
+ end
50
+
51
+ # Extract the most recent plan data from comments
52
+ def extract_plan_data(issue)
53
+ return nil unless issue[:comments]
54
+
55
+ # Find the most recent plan proposal comment
56
+ plan_comment = issue[:comments].reverse.find do |comment|
57
+ comment["body"]&.match?(PLAN_PROPOSAL_PATTERN)
58
+ end
59
+
60
+ return nil unless plan_comment
61
+
62
+ body = plan_comment["body"]
63
+
64
+ {
65
+ summary: extract_section(body, "PLAN_SUMMARY"),
66
+ tasks: extract_tasks(body),
67
+ questions: extract_questions(body),
68
+ comment_body: body,
69
+ comment_hint: "## 🤖 AIDP Plan Proposal",
70
+ comment_id: plan_comment["id"],
71
+ posted_at: plan_comment["createdAt"] || Time.now.utc.iso8601
72
+ }
73
+ end
74
+
75
+ # Check if detection comment was already posted for this label
76
+ def detection_comment_posted?(item, label)
77
+ return false unless item[:comments]
78
+
79
+ item[:comments].any? do |comment|
80
+ next unless comment["body"]
81
+
82
+ match = comment["body"].match(DETECTION_PATTERN)
83
+ match && match[1] == label
84
+ end
85
+ end
86
+
87
+ # Check if review has been completed for a PR
88
+ def review_completed?(pr)
89
+ return false unless pr[:comments]
90
+
91
+ pr[:comments].any? do |comment|
92
+ comment["body"]&.match?(/🔍.*Review complete/i)
93
+ end
94
+ end
95
+
96
+ # Check if CI fix has been completed for a PR
97
+ def ci_fix_completed?(pr)
98
+ return false unless pr[:comments]
99
+
100
+ pr[:comments].any? do |comment|
101
+ body = comment["body"]
102
+ next false unless body
103
+
104
+ body.include?("✅") && (
105
+ body.match?(/CI fixes applied/i) ||
106
+ (body.match?(/CI check/i) && body.match?(/passed/i))
107
+ )
108
+ end
109
+ end
110
+
111
+ # Check if change request has been processed for a PR
112
+ def change_request_processed?(pr)
113
+ return false unless pr[:comments]
114
+
115
+ pr[:comments].any? do |comment|
116
+ body = comment["body"]
117
+ next false unless body
118
+
119
+ body.include?("✅") && body.match?(/Change requests? (?:addressed|applied|complete)/i)
120
+ end
121
+ end
122
+
123
+ # Extract linked issue number from PR description
124
+ # Looks for patterns like "Fixes #123", "Closes #456", "Resolves #789"
125
+ # Returns the issue number as an integer, or nil if not found
126
+ def extract_linked_issue(pr_body)
127
+ return nil unless pr_body
128
+
129
+ # Match common GitHub issue linking patterns
130
+ # https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
131
+ match = pr_body.match(/(?:Fixes|Closes|Resolves|Close|Fix|Resolve)\s+#(\d+)/i)
132
+ match ? match[1].to_i : nil
133
+ end
134
+
135
+ private
136
+
137
+ def has_label?(item, label_name)
138
+ Array(item[:labels]).any? do |label|
139
+ name = label.is_a?(Hash) ? label["name"] : label.to_s
140
+ name.casecmp?(label_name)
141
+ end
142
+ end
143
+
144
+ def extract_section(body, section_name)
145
+ start_marker = "<!-- #{section_name}_START -->"
146
+ end_marker = "<!-- #{section_name}_END -->"
147
+
148
+ start_idx = body.index(start_marker)
149
+ end_idx = body.index(end_marker)
150
+
151
+ return nil unless start_idx && end_idx
152
+
153
+ content = body[(start_idx + start_marker.length)...end_idx]
154
+
155
+ # Remove markdown heading if present (e.g., "## Heading\n" or "### Heading\n")
156
+ # Strip lines starting with ## or ### to avoid ReDoS
157
+ content.lines.reject { |line| line.start_with?("##", "###") }.join.strip
158
+ end
159
+
160
+ def extract_tasks(body)
161
+ tasks_section = extract_section(body, "PLAN_TASKS")
162
+ return [] unless tasks_section
163
+
164
+ # Extract markdown list items (- followed by space and content)
165
+ tasks_section.scan(/^- (.+)$/).flatten
166
+ end
167
+
168
+ def extract_questions(body)
169
+ questions_section = extract_section(body, "CLARIFYING_QUESTIONS")
170
+ return [] unless questions_section
171
+
172
+ # Extract numbered list items (number, dot, space, content)
173
+ questions_section.scan(/^\d+\. (.+)$/).flatten
174
+ end
175
+ end
176
+ end
177
+ end