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.
- checksums.yaml +4 -4
- data/README.md +7 -0
- data/lib/aidp/cli/first_run_wizard.rb +28 -303
- data/lib/aidp/cli/issue_importer.rb +359 -0
- data/lib/aidp/cli.rb +151 -3
- data/lib/aidp/daemon/process_manager.rb +146 -0
- data/lib/aidp/daemon/runner.rb +232 -0
- data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
- data/lib/aidp/execute/future_work_backlog.rb +411 -0
- data/lib/aidp/execute/guard_policy.rb +246 -0
- data/lib/aidp/execute/instruction_queue.rb +131 -0
- data/lib/aidp/execute/interactive_repl.rb +335 -0
- data/lib/aidp/execute/repl_macros.rb +651 -0
- data/lib/aidp/execute/steps.rb +8 -0
- data/lib/aidp/execute/work_loop_runner.rb +322 -36
- data/lib/aidp/execute/work_loop_state.rb +162 -0
- data/lib/aidp/harness/config_schema.rb +88 -0
- data/lib/aidp/harness/configuration.rb +48 -1
- data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +2 -0
- data/lib/aidp/init/doc_generator.rb +256 -0
- data/lib/aidp/init/project_analyzer.rb +343 -0
- data/lib/aidp/init/runner.rb +83 -0
- data/lib/aidp/init.rb +5 -0
- data/lib/aidp/logger.rb +279 -0
- data/lib/aidp/setup/wizard.rb +777 -0
- data/lib/aidp/tooling_detector.rb +115 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +282 -0
- data/lib/aidp/watch/plan_generator.rb +166 -0
- data/lib/aidp/watch/plan_processor.rb +83 -0
- data/lib/aidp/watch/repository_client.rb +243 -0
- data/lib/aidp/watch/runner.rb +93 -0
- data/lib/aidp/watch/state_store.rb +105 -0
- data/lib/aidp/watch.rb +9 -0
- data/lib/aidp.rb +14 -0
- data/templates/implementation/simple_task.md +36 -0
- 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
@@ -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
|