aidp 0.13.0 → 0.14.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -0
  3. data/lib/aidp/cli/first_run_wizard.rb +28 -303
  4. data/lib/aidp/cli/issue_importer.rb +359 -0
  5. data/lib/aidp/cli.rb +151 -3
  6. data/lib/aidp/daemon/process_manager.rb +146 -0
  7. data/lib/aidp/daemon/runner.rb +232 -0
  8. data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
  9. data/lib/aidp/execute/future_work_backlog.rb +411 -0
  10. data/lib/aidp/execute/guard_policy.rb +246 -0
  11. data/lib/aidp/execute/instruction_queue.rb +131 -0
  12. data/lib/aidp/execute/interactive_repl.rb +335 -0
  13. data/lib/aidp/execute/repl_macros.rb +651 -0
  14. data/lib/aidp/execute/steps.rb +8 -0
  15. data/lib/aidp/execute/work_loop_runner.rb +322 -36
  16. data/lib/aidp/execute/work_loop_state.rb +162 -0
  17. data/lib/aidp/harness/config_schema.rb +88 -0
  18. data/lib/aidp/harness/configuration.rb +48 -1
  19. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +2 -0
  20. data/lib/aidp/init/doc_generator.rb +256 -0
  21. data/lib/aidp/init/project_analyzer.rb +343 -0
  22. data/lib/aidp/init/runner.rb +83 -0
  23. data/lib/aidp/init.rb +5 -0
  24. data/lib/aidp/logger.rb +279 -0
  25. data/lib/aidp/setup/wizard.rb +777 -0
  26. data/lib/aidp/tooling_detector.rb +115 -0
  27. data/lib/aidp/version.rb +1 -1
  28. data/lib/aidp/watch/build_processor.rb +282 -0
  29. data/lib/aidp/watch/plan_generator.rb +166 -0
  30. data/lib/aidp/watch/plan_processor.rb +83 -0
  31. data/lib/aidp/watch/repository_client.rb +243 -0
  32. data/lib/aidp/watch/runner.rb +93 -0
  33. data/lib/aidp/watch/state_store.rb +105 -0
  34. data/lib/aidp/watch.rb +9 -0
  35. data/lib/aidp.rb +14 -0
  36. data/templates/implementation/simple_task.md +36 -0
  37. metadata +26 -1
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ # Detect basic project tooling to seed work loop test & lint commands.
5
+ # Lightweight heuristic pass – prefers safety over guessing incorrectly.
6
+ class ToolingDetector
7
+ DETECTORS = [
8
+ :ruby_bundle,
9
+ :rspec,
10
+ :ruby_standardrb,
11
+ :node_jest,
12
+ :node_mocha,
13
+ :node_eslint,
14
+ :python_pytest
15
+ ].freeze
16
+
17
+ Result = Struct.new(:test_commands, :lint_commands, keyword_init: true)
18
+
19
+ def self.detect(root = Dir.pwd)
20
+ new(root).detect
21
+ end
22
+
23
+ def initialize(root = Dir.pwd)
24
+ @root = root
25
+ end
26
+
27
+ def detect
28
+ tests = []
29
+ linters = []
30
+
31
+ if ruby_project?
32
+ tests << bundle_prefix("rspec") if rspec?
33
+ linters << bundle_prefix("standardrb") if standard_rb?
34
+ end
35
+
36
+ if node_project?
37
+ tests << npm_or_yarn("test") if package_script?("test")
38
+ %w[lint eslint].each do |script|
39
+ if package_script?(script)
40
+ linters << npm_or_yarn(script)
41
+ break
42
+ end
43
+ end
44
+ end
45
+
46
+ if python_pytest?
47
+ tests << "pytest -q"
48
+ end
49
+
50
+ Result.new(
51
+ test_commands: tests.uniq,
52
+ lint_commands: linters.uniq
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def bundle_prefix(cmd)
59
+ File.exist?(File.join(@root, "Gemfile")) ? "bundle exec #{cmd}" : cmd
60
+ end
61
+
62
+ def ruby_project?
63
+ File.exist?(File.join(@root, "Gemfile"))
64
+ end
65
+
66
+ def rspec?
67
+ File.exist?(File.join(@root, "spec")) &&
68
+ begin
69
+ File.readlines(File.join(@root, "Gemfile")).grep(/rspec/).any?
70
+ rescue
71
+ false
72
+ end
73
+ end
74
+
75
+ def standard_rb?
76
+ File.exist?(File.join(@root, "Gemfile")) &&
77
+ begin
78
+ File.readlines(File.join(@root, "Gemfile")).grep(/standard/).any?
79
+ rescue
80
+ false
81
+ end
82
+ end
83
+
84
+ def package_json
85
+ @package_json ||= begin
86
+ path = File.join(@root, "package.json")
87
+ return nil unless File.exist?(path)
88
+ JSON.parse(File.read(path))
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+ end
93
+
94
+ def node_project?
95
+ !!package_json
96
+ end
97
+
98
+ def package_script?(name)
99
+ package_json&.dig("scripts", name)
100
+ end
101
+
102
+ def npm_or_yarn(script)
103
+ if File.exist?(File.join(@root, "yarn.lock"))
104
+ "yarn #{script}"
105
+ else
106
+ "npm run #{script}"
107
+ end
108
+ end
109
+
110
+ def python_pytest?
111
+ Dir.glob(File.join(@root, "**", "pytest.ini")).any? ||
112
+ Dir.glob(File.join(@root, "**", "conftest.py")).any?
113
+ end
114
+ end
115
+ 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.13.0"
4
+ VERSION = "0.14.0"
5
5
  end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "time"
5
+
6
+ require_relative "../message_display"
7
+ require_relative "../execute/prompt_manager"
8
+ require_relative "../harness/runner"
9
+
10
+ module Aidp
11
+ module Watch
12
+ # Handles the aidp-build trigger by running the autonomous work loop, creating
13
+ # a branch/PR, and posting completion status back to GitHub.
14
+ class BuildProcessor
15
+ include Aidp::MessageDisplay
16
+
17
+ BUILD_LABEL = "aidp-build"
18
+ IMPLEMENTATION_STEP = "16_IMPLEMENTATION"
19
+
20
+ def initialize(repository_client:, state_store:, project_dir: Dir.pwd)
21
+ @repository_client = repository_client
22
+ @state_store = state_store
23
+ @project_dir = project_dir
24
+ end
25
+
26
+ def process(issue)
27
+ number = issue[:number]
28
+ display_message("đŸ› ī¸ Starting implementation for issue ##{number}", type: :info)
29
+
30
+ plan_data = ensure_plan_data(number)
31
+ return unless plan_data
32
+
33
+ branch_name = branch_name_for(issue)
34
+ @state_store.record_build_status(number, status: "running", details: {branch: branch_name, started_at: Time.now.utc.iso8601})
35
+
36
+ ensure_git_repo!
37
+ base_branch = detect_base_branch
38
+
39
+ checkout_branch(base_branch, branch_name)
40
+ prompt_content = build_prompt(issue: issue, plan_data: plan_data)
41
+ write_prompt(prompt_content)
42
+
43
+ user_input = build_user_input(issue: issue, plan_data: plan_data)
44
+ result = run_harness(user_input: user_input)
45
+
46
+ if result[:status] == "completed"
47
+ handle_success(issue: issue, branch_name: branch_name, base_branch: base_branch, plan_data: plan_data)
48
+ else
49
+ handle_failure(issue: issue, result: result)
50
+ end
51
+ rescue => e
52
+ display_message("❌ Implementation failed: #{e.message}", type: :error)
53
+ @state_store.record_build_status(issue[:number], status: "failed", details: {error: e.message})
54
+ raise
55
+ end
56
+
57
+ private
58
+
59
+ def ensure_plan_data(number)
60
+ data = @state_store.plan_data(number)
61
+ unless data
62
+ display_message("âš ī¸ No recorded plan for issue ##{number}. Skipping build trigger.", type: :warn)
63
+ end
64
+ data
65
+ end
66
+
67
+ def ensure_git_repo!
68
+ Dir.chdir(@project_dir) do
69
+ stdout, stderr, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree")
70
+ raise "Not a git repository: #{stderr.strip}" unless status.success? && stdout.strip == "true"
71
+ end
72
+ end
73
+
74
+ def detect_base_branch
75
+ Dir.chdir(@project_dir) do
76
+ stdout, _stderr, status = Open3.capture3("git", "symbolic-ref", "refs/remotes/origin/HEAD")
77
+ if status.success?
78
+ ref = stdout.strip
79
+ return ref.split("/").last if ref.include?("/")
80
+ end
81
+
82
+ %w[main master trunk].find do |candidate|
83
+ _out, _err, branch_status = Open3.capture3("git", "rev-parse", "--verify", candidate)
84
+ branch_status.success?
85
+ end || "main"
86
+ end
87
+ end
88
+
89
+ def checkout_branch(base_branch, branch_name)
90
+ Dir.chdir(@project_dir) do
91
+ run_git(%w[fetch origin], allow_failure: true)
92
+ run_git(["checkout", base_branch])
93
+ run_git(%w[pull --ff-only], allow_failure: true)
94
+ run_git(["checkout", "-B", branch_name])
95
+ end
96
+ display_message("đŸŒŋ Checked out #{branch_name}", type: :info)
97
+ end
98
+
99
+ def branch_name_for(issue)
100
+ slug = issue[:title].to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
101
+ "aidp/issue-#{issue[:number]}-#{slug[0, 32]}"
102
+ end
103
+
104
+ def build_prompt(issue:, plan_data:)
105
+ lines = []
106
+ lines << "# Implementation Contract for Issue ##{issue[:number]}"
107
+ lines << ""
108
+ lines << "## Summary"
109
+ lines << plan_value(plan_data, "summary").to_s.strip
110
+ lines << ""
111
+ lines << "## Tasks"
112
+ Array(plan_value(plan_data, "tasks")).each do |task|
113
+ lines << "- [ ] #{task}"
114
+ end
115
+ lines << "" unless Array(plan_value(plan_data, "tasks")).empty?
116
+ lines << "## Clarifying Answers / Notes"
117
+ lines << clarifications_from_comments(issue[:comments], plan_data)
118
+ lines << ""
119
+ lines << "## Original Issue Body"
120
+ lines << issue[:body].to_s
121
+ lines.join("\n")
122
+ end
123
+
124
+ def clarifications_from_comments(comments, plan_data)
125
+ return "_No additional context provided._" if comments.nil? || comments.empty?
126
+
127
+ comment_hint = plan_value(plan_data, "comment_hint")
128
+ relevant = comments.reject do |comment|
129
+ body = comment["body"].to_s
130
+ comment_hint && body.start_with?(comment_hint)
131
+ end
132
+
133
+ return "_No follow-up responses yet._" if relevant.empty?
134
+
135
+ relevant.map do |comment|
136
+ author = comment["author"] || "unknown"
137
+ created = comment["createdAt"] ? Time.parse(comment["createdAt"]).utc.iso8601 : "unknown"
138
+ "### #{author} (#{created})\n#{comment["body"]}"
139
+ end.join("\n\n")
140
+ rescue
141
+ "_Unable to parse comment thread._"
142
+ end
143
+
144
+ def write_prompt(content)
145
+ prompt_manager = Aidp::Execute::PromptManager.new(@project_dir)
146
+ prompt_manager.write(content)
147
+ display_message("📝 Wrote PROMPT.md with implementation contract", type: :info)
148
+ end
149
+
150
+ def build_user_input(issue:, plan_data:)
151
+ tasks = Array(plan_value(plan_data, "tasks"))
152
+ {
153
+ "Implementation Contract" => plan_value(plan_data, "summary").to_s,
154
+ "Tasks" => tasks.map { |task| "- #{task}" }.join("\n"),
155
+ "Issue URL" => issue[:url]
156
+ }.delete_if { |_k, v| v.nil? || v.empty? }
157
+ end
158
+
159
+ def run_harness(user_input:)
160
+ options = {
161
+ selected_steps: [IMPLEMENTATION_STEP],
162
+ workflow_type: :watch_mode,
163
+ user_input: user_input
164
+ }
165
+ runner = Aidp::Harness::Runner.new(@project_dir, :execute, options)
166
+ runner.run
167
+ end
168
+
169
+ def handle_success(issue:, branch_name:, base_branch:, plan_data:)
170
+ stage_and_commit(issue)
171
+ pr_url = create_pull_request(issue: issue, branch_name: branch_name, base_branch: base_branch)
172
+
173
+ comment = <<~COMMENT
174
+ ✅ Implementation complete for ##{issue[:number]}.
175
+ - Branch: `#{branch_name}`
176
+ - Pull Request: #{pr_url}
177
+
178
+ Summary:
179
+ #{plan_value(plan_data, "summary")}
180
+ COMMENT
181
+
182
+ @repository_client.post_comment(issue[:number], comment)
183
+ @state_store.record_build_status(
184
+ issue[:number],
185
+ status: "completed",
186
+ details: {branch: branch_name, pr_url: pr_url}
187
+ )
188
+ display_message("🎉 Posted completion comment for issue ##{issue[:number]}", type: :success)
189
+ end
190
+
191
+ def handle_failure(issue:, result:)
192
+ message = result[:message] || "Unknown failure"
193
+ comment = <<~COMMENT
194
+ ❌ Implementation attempt for ##{issue[:number]} failed.
195
+
196
+ Status: #{result[:status]}
197
+ Details: #{message}
198
+
199
+ Please review the repository for partial changes. The branch has been left intact for debugging.
200
+ COMMENT
201
+ @repository_client.post_comment(issue[:number], comment)
202
+ @state_store.record_build_status(
203
+ issue[:number],
204
+ status: "failed",
205
+ details: {message: message}
206
+ )
207
+ display_message("âš ī¸ Build failure recorded for issue ##{issue[:number]}", type: :warn)
208
+ end
209
+
210
+ def stage_and_commit(issue)
211
+ Dir.chdir(@project_dir) do
212
+ status_output = run_git(%w[status --porcelain])
213
+ if status_output.strip.empty?
214
+ display_message("â„šī¸ No file changes detected after work loop.", type: :muted)
215
+ return
216
+ end
217
+
218
+ run_git(%w[add -A])
219
+ commit_message = "feat: implement ##{issue[:number]} #{issue[:title]}"
220
+ run_git(["commit", "-m", commit_message])
221
+ display_message("💾 Created commit: #{commit_message}", type: :info)
222
+ end
223
+ end
224
+
225
+ def create_pull_request(issue:, branch_name:, base_branch:)
226
+ title = "aidp: Resolve ##{issue[:number]} - #{issue[:title]}"
227
+ test_summary = gather_test_summary
228
+ body = <<~BODY
229
+ ## Summary
230
+ - Automated resolution for ##{issue[:number]}
231
+
232
+ ## Testing
233
+ #{test_summary}
234
+ BODY
235
+
236
+ output = @repository_client.create_pull_request(
237
+ title: title,
238
+ body: body,
239
+ head: branch_name,
240
+ base: base_branch,
241
+ issue_number: issue[:number]
242
+ )
243
+
244
+ extract_pr_url(output)
245
+ end
246
+
247
+ def gather_test_summary
248
+ Dir.chdir(@project_dir) do
249
+ log_path = File.join(".aidp", "logs", "test_runner.log")
250
+ return "- Fix-forward harness executed; refer to #{log_path}" unless File.exist?(log_path)
251
+
252
+ recent = File.readlines(log_path).last(20).map(&:strip).reject(&:empty?)
253
+ if recent.empty?
254
+ "- Fix-forward harness executed successfully."
255
+ else
256
+ "- Recent test output:\n" + recent.map { |line| " - #{line}" }.join("\n")
257
+ end
258
+ end
259
+ rescue
260
+ "- Fix-forward harness executed successfully."
261
+ end
262
+
263
+ def extract_pr_url(output)
264
+ output.to_s.split("\n").reverse.find { |line| line.include?("http") } || output
265
+ end
266
+
267
+ def run_git(args, allow_failure: false)
268
+ stdout, stderr, status = Open3.capture3("git", *Array(args))
269
+ raise "git #{args.join(" ")} failed: #{stderr.strip}" unless status.success? || allow_failure
270
+ stdout
271
+ end
272
+
273
+ def plan_value(plan_data, key)
274
+ return nil unless plan_data
275
+
276
+ symbol_key = key.to_sym
277
+ string_key = key.to_s
278
+ plan_data[symbol_key] || plan_data[string_key]
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../message_display"
6
+ require_relative "../harness/config_manager"
7
+ require_relative "../provider_manager"
8
+
9
+ module Aidp
10
+ module Watch
11
+ # Generates implementation plans for issues during watch mode. Attempts to
12
+ # use a configured provider for high quality output and falls back to a
13
+ # deterministic heuristic plan when no provider can be invoked.
14
+ class PlanGenerator
15
+ include Aidp::MessageDisplay
16
+
17
+ PROVIDER_PROMPT = <<~PROMPT
18
+ You are AIDP's planning specialist. Read the GitHub issue and existing comments.
19
+ Produce a concise implementation contract describing the plan for the aidp agent.
20
+ Respond in JSON with the following shape (no extra text, no code fences):
21
+ {
22
+ "plan_summary": "one paragraph summary of what will be implemented",
23
+ "plan_tasks": ["task 1", "task 2", "..."],
24
+ "clarifying_questions": ["question 1", "question 2"]
25
+ }
26
+ Focus on concrete engineering tasks. Ensure questions are actionable.
27
+ PROMPT
28
+
29
+ def initialize(provider_name: nil)
30
+ @provider_name = provider_name
31
+ end
32
+
33
+ def generate(issue)
34
+ provider = resolve_provider
35
+ if provider
36
+ generate_with_provider(provider, issue)
37
+ else
38
+ display_message("âš ī¸ No active provider available. Falling back to heuristic plan.", type: :warn)
39
+ heuristic_plan(issue)
40
+ end
41
+ rescue => e
42
+ display_message("âš ī¸ Plan generation failed (#{e.message}). Using heuristic.", type: :warn)
43
+ heuristic_plan(issue)
44
+ end
45
+
46
+ private
47
+
48
+ def resolve_provider
49
+ provider_name = @provider_name || detect_default_provider
50
+ return nil unless provider_name
51
+
52
+ provider = Aidp::ProviderManager.get_provider(provider_name, use_harness: false)
53
+ return provider if provider&.available?
54
+
55
+ nil
56
+ rescue => e
57
+ display_message("âš ī¸ Failed to resolve provider #{provider_name}: #{e.message}", type: :warn)
58
+ nil
59
+ end
60
+
61
+ def detect_default_provider
62
+ config_manager = Aidp::Harness::ConfigManager.new(Dir.pwd)
63
+ config_manager.default_provider || "cursor"
64
+ rescue
65
+ "cursor"
66
+ end
67
+
68
+ def generate_with_provider(provider, issue)
69
+ payload = build_prompt(issue)
70
+ response = provider.send(prompt: payload)
71
+ parsed = parse_structured_response(response)
72
+
73
+ return parsed if parsed
74
+
75
+ display_message("âš ī¸ Unable to parse provider response. Using heuristic plan.", type: :warn)
76
+ heuristic_plan(issue)
77
+ end
78
+
79
+ def build_prompt(issue)
80
+ comments_text = issue[:comments]
81
+ .sort_by { |comment| comment["createdAt"].to_s }
82
+ .map do |comment|
83
+ author = comment["author"] || "unknown"
84
+ body = comment["body"] || ""
85
+ "#{author}:\n#{body}"
86
+ end
87
+ .join("\n\n")
88
+
89
+ <<~PROMPT
90
+ #{PROVIDER_PROMPT}
91
+
92
+ Issue Title: #{issue[:title]}
93
+ Issue URL: #{issue[:url]}
94
+
95
+ Issue Body:
96
+ #{issue[:body]}
97
+
98
+ Existing Comments:
99
+ #{comments_text}
100
+ PROMPT
101
+ end
102
+
103
+ def parse_structured_response(response)
104
+ text = response.to_s.strip
105
+ candidate = extract_json_payload(text)
106
+ return nil unless candidate
107
+
108
+ data = JSON.parse(candidate)
109
+ {
110
+ summary: data["plan_summary"].to_s.strip,
111
+ tasks: Array(data["plan_tasks"]).map(&:to_s),
112
+ questions: Array(data["clarifying_questions"]).map(&:to_s)
113
+ }
114
+ rescue JSON::ParserError
115
+ nil
116
+ end
117
+
118
+ def extract_json_payload(text)
119
+ return text if text.start_with?("{") && text.end_with?("}")
120
+
121
+ if text =~ /```json\s*(\{.*\})\s*```/m
122
+ return $1
123
+ end
124
+
125
+ json_match = text.match(/\{.*\}/m)
126
+ json_match ? json_match[0] : nil
127
+ end
128
+
129
+ def heuristic_plan(issue)
130
+ body = issue[:body].to_s
131
+ bullet_tasks = body.lines
132
+ .map(&:strip)
133
+ .select { |line| line.start_with?("-", "*") }
134
+ .map { |line| line.sub(/\A[-*]\s*/, "") }
135
+ .uniq
136
+ .first(5)
137
+
138
+ paragraphs = body.split(/\n{2,}/).map(&:strip).reject(&:empty?)
139
+ summary = paragraphs.first(2).join(" ")
140
+ summary = summary.empty? ? "Implement the requested changes described in the issue." : summary
141
+
142
+ tasks = if bullet_tasks.empty?
143
+ [
144
+ "Review the repository context and identify impacted components.",
145
+ "Implement the necessary code changes and add tests.",
146
+ "Document the changes and ensure lint/test pipelines succeed."
147
+ ]
148
+ else
149
+ bullet_tasks
150
+ end
151
+
152
+ questions = [
153
+ "Are there constraints (framework versions, performance budgets) we must respect?",
154
+ "Are there existing tests or acceptance criteria we should extend?",
155
+ "Is there additional context (design docs, related issues) we should review?"
156
+ ]
157
+
158
+ {
159
+ summary: summary,
160
+ tasks: tasks,
161
+ questions: questions
162
+ }
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_display"
4
+ require_relative "plan_generator"
5
+ require_relative "state_store"
6
+
7
+ module Aidp
8
+ module Watch
9
+ # Handles the aidp-plan label trigger by generating an implementation plan
10
+ # and posting it back to the originating GitHub issue.
11
+ class PlanProcessor
12
+ include Aidp::MessageDisplay
13
+
14
+ PLAN_LABEL = "aidp-plan"
15
+ COMMENT_HEADER = "## 🤖 AIDP Plan Proposal"
16
+
17
+ def initialize(repository_client:, state_store:, plan_generator:)
18
+ @repository_client = repository_client
19
+ @state_store = state_store
20
+ @plan_generator = plan_generator
21
+ end
22
+
23
+ def process(issue)
24
+ number = issue[:number]
25
+ if @state_store.plan_processed?(number)
26
+ display_message("â„šī¸ Plan for issue ##{number} already posted. Skipping.", type: :muted)
27
+ return
28
+ end
29
+
30
+ display_message("🧠 Generating plan for issue ##{number} (#{issue[:title]})", type: :info)
31
+ plan_data = @plan_generator.generate(issue)
32
+
33
+ comment_body = build_comment(issue: issue, plan: plan_data)
34
+ @repository_client.post_comment(number, comment_body)
35
+
36
+ display_message("đŸ’Ŧ Posted plan comment for issue ##{number}", type: :success)
37
+ @state_store.record_plan(number, plan_data.merge(comment_body: comment_body, comment_hint: COMMENT_HEADER))
38
+ end
39
+
40
+ private
41
+
42
+ def build_comment(issue:, plan:)
43
+ summary = plan[:summary].to_s.strip
44
+ tasks = Array(plan[:tasks])
45
+ questions = Array(plan[:questions])
46
+
47
+ parts = []
48
+ parts << COMMENT_HEADER
49
+ parts << ""
50
+ parts << "**Issue**: [##{issue[:number]}](#{issue[:url]})"
51
+ parts << "**Title**: #{issue[:title]}"
52
+ parts << ""
53
+ parts << "### Plan Summary"
54
+ parts << (summary.empty? ? "_No summary generated_" : summary)
55
+ parts << ""
56
+ parts << "### Proposed Tasks"
57
+ parts << format_bullets(tasks, placeholder: "_Pending task breakdown_")
58
+ parts << ""
59
+ parts << "### Clarifying Questions"
60
+ parts << format_numbered(questions, placeholder: "_No questions identified_")
61
+ 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."
63
+ parts.join("\n")
64
+ end
65
+
66
+ def format_bullets(items, placeholder:)
67
+ if items.empty?
68
+ placeholder
69
+ else
70
+ items.map { |item| "- #{item}" }.join("\n")
71
+ end
72
+ end
73
+
74
+ def format_numbered(items, placeholder:)
75
+ if items.empty?
76
+ placeholder
77
+ else
78
+ items.each_with_index.map { |item, index| "#{index + 1}. #{item}" }.join("\n")
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end