aidp 0.12.1 → 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -0
  3. data/lib/aidp/analyze/json_file_storage.rb +21 -21
  4. data/lib/aidp/cli/enhanced_input.rb +114 -0
  5. data/lib/aidp/cli/first_run_wizard.rb +28 -309
  6. data/lib/aidp/cli/issue_importer.rb +359 -0
  7. data/lib/aidp/cli/mcp_dashboard.rb +3 -3
  8. data/lib/aidp/cli/terminal_io.rb +26 -0
  9. data/lib/aidp/cli.rb +155 -7
  10. data/lib/aidp/daemon/process_manager.rb +146 -0
  11. data/lib/aidp/daemon/runner.rb +232 -0
  12. data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
  13. data/lib/aidp/execute/future_work_backlog.rb +411 -0
  14. data/lib/aidp/execute/guard_policy.rb +246 -0
  15. data/lib/aidp/execute/instruction_queue.rb +131 -0
  16. data/lib/aidp/execute/interactive_repl.rb +335 -0
  17. data/lib/aidp/execute/repl_macros.rb +651 -0
  18. data/lib/aidp/execute/steps.rb +8 -0
  19. data/lib/aidp/execute/work_loop_runner.rb +322 -36
  20. data/lib/aidp/execute/work_loop_state.rb +162 -0
  21. data/lib/aidp/harness/condition_detector.rb +6 -6
  22. data/lib/aidp/harness/config_loader.rb +23 -23
  23. data/lib/aidp/harness/config_manager.rb +61 -61
  24. data/lib/aidp/harness/config_schema.rb +88 -0
  25. data/lib/aidp/harness/config_validator.rb +9 -9
  26. data/lib/aidp/harness/configuration.rb +76 -29
  27. data/lib/aidp/harness/error_handler.rb +13 -13
  28. data/lib/aidp/harness/provider_config.rb +79 -79
  29. data/lib/aidp/harness/provider_factory.rb +40 -40
  30. data/lib/aidp/harness/provider_info.rb +37 -20
  31. data/lib/aidp/harness/provider_manager.rb +58 -53
  32. data/lib/aidp/harness/provider_type_checker.rb +6 -6
  33. data/lib/aidp/harness/runner.rb +7 -7
  34. data/lib/aidp/harness/status_display.rb +33 -46
  35. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +4 -1
  36. data/lib/aidp/harness/ui/job_monitor.rb +7 -7
  37. data/lib/aidp/harness/user_interface.rb +43 -43
  38. data/lib/aidp/init/doc_generator.rb +256 -0
  39. data/lib/aidp/init/project_analyzer.rb +343 -0
  40. data/lib/aidp/init/runner.rb +83 -0
  41. data/lib/aidp/init.rb +5 -0
  42. data/lib/aidp/logger.rb +279 -0
  43. data/lib/aidp/providers/anthropic.rb +100 -26
  44. data/lib/aidp/providers/base.rb +13 -0
  45. data/lib/aidp/providers/codex.rb +28 -27
  46. data/lib/aidp/providers/cursor.rb +141 -34
  47. data/lib/aidp/providers/github_copilot.rb +26 -26
  48. data/lib/aidp/providers/macos_ui.rb +2 -18
  49. data/lib/aidp/providers/opencode.rb +26 -26
  50. data/lib/aidp/setup/wizard.rb +777 -0
  51. data/lib/aidp/tooling_detector.rb +115 -0
  52. data/lib/aidp/version.rb +1 -1
  53. data/lib/aidp/watch/build_processor.rb +282 -0
  54. data/lib/aidp/watch/plan_generator.rb +166 -0
  55. data/lib/aidp/watch/plan_processor.rb +83 -0
  56. data/lib/aidp/watch/repository_client.rb +243 -0
  57. data/lib/aidp/watch/runner.rb +93 -0
  58. data/lib/aidp/watch/state_store.rb +105 -0
  59. data/lib/aidp/watch.rb +9 -0
  60. data/lib/aidp/workflows/guided_agent.rb +344 -23
  61. data/lib/aidp.rb +14 -0
  62. data/templates/implementation/simple_task.md +36 -0
  63. metadata +27 -1
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require "uri"
7
+
8
+ module Aidp
9
+ module Watch
10
+ # Lightweight adapter around GitHub for watch mode. Prefers the GitHub CLI
11
+ # (works for private repositories) and falls back to public REST endpoints
12
+ # when the CLI is unavailable.
13
+ class RepositoryClient
14
+ attr_reader :owner, :repo
15
+
16
+ def self.parse_issues_url(issues_url)
17
+ case issues_url
18
+ when %r{\Ahttps://github\.com/([^/]+)/([^/]+)(?:/issues)?/?\z}
19
+ [$1, $2]
20
+ when %r{\A([^/]+)/([^/]+)\z}
21
+ [$1, $2]
22
+ else
23
+ raise ArgumentError, "Unsupported issues URL: #{issues_url}"
24
+ end
25
+ end
26
+
27
+ def initialize(owner:, repo:, gh_available: nil)
28
+ @owner = owner
29
+ @repo = repo
30
+ @gh_available = gh_available.nil? ? gh_cli_available? : gh_available
31
+ end
32
+
33
+ def gh_available?
34
+ @gh_available
35
+ end
36
+
37
+ def full_repo
38
+ "#{owner}/#{repo}"
39
+ end
40
+
41
+ def list_issues(labels: [], state: "open")
42
+ gh_available? ? list_issues_via_gh(labels: labels, state: state) : list_issues_via_api(labels: labels, state: state)
43
+ end
44
+
45
+ def fetch_issue(number)
46
+ gh_available? ? fetch_issue_via_gh(number) : fetch_issue_via_api(number)
47
+ end
48
+
49
+ def post_comment(number, body)
50
+ gh_available? ? post_comment_via_gh(number, body) : post_comment_via_api(number, body)
51
+ end
52
+
53
+ def create_pull_request(title:, body:, head:, base:, issue_number:)
54
+ gh_available? ? create_pull_request_via_gh(title: title, body: body, head: head, base: base, issue_number: issue_number) : raise("GitHub CLI not available - cannot create PR")
55
+ end
56
+
57
+ private
58
+
59
+ def gh_cli_available?
60
+ _stdout, _stderr, status = Open3.capture3("gh", "--version")
61
+ status.success?
62
+ rescue Errno::ENOENT
63
+ false
64
+ end
65
+
66
+ def list_issues_via_gh(labels:, state:)
67
+ json_fields = %w[number title labels updatedAt state url assignees]
68
+ cmd = ["gh", "issue", "list", "--repo", full_repo, "--state", state, "--json", json_fields.join(",")]
69
+ labels.each do |label|
70
+ cmd += ["--label", label]
71
+ end
72
+
73
+ stdout, stderr, status = Open3.capture3(*cmd)
74
+ unless status.success?
75
+ warn("GitHub CLI list failed: #{stderr}")
76
+ return []
77
+ end
78
+
79
+ JSON.parse(stdout).map { |raw| normalize_issue(raw) }
80
+ rescue JSON::ParserError => e
81
+ warn("Failed to parse GH CLI response: #{e.message}")
82
+ []
83
+ end
84
+
85
+ def list_issues_via_api(labels:, state:)
86
+ label_param = labels.join(",")
87
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues?state=#{state}")
88
+ uri.query = [uri.query, "labels=#{URI.encode_www_form_component(label_param)}"].compact.join("&") unless label_param.empty?
89
+
90
+ response = Net::HTTP.get_response(uri)
91
+ return [] unless response.code == "200"
92
+
93
+ JSON.parse(response.body).reject { |item| item["pull_request"] }.map { |raw| normalize_issue_api(raw) }
94
+ rescue => e
95
+ warn("GitHub API list failed: #{e.message}")
96
+ []
97
+ end
98
+
99
+ def fetch_issue_via_gh(number)
100
+ fields = %w[number title body comments labels state assignees url updatedAt author]
101
+ cmd = ["gh", "issue", "view", number.to_s, "--repo", full_repo, "--json", fields.join(",")]
102
+
103
+ stdout, stderr, status = Open3.capture3(*cmd)
104
+ raise "GitHub CLI error: #{stderr.strip}" unless status.success?
105
+
106
+ data = JSON.parse(stdout)
107
+ normalize_issue_detail(data)
108
+ rescue JSON::ParserError => e
109
+ raise "Failed to parse GitHub CLI issue response: #{e.message}"
110
+ end
111
+
112
+ def fetch_issue_via_api(number)
113
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}")
114
+ response = Net::HTTP.get_response(uri)
115
+ raise "GitHub API error (#{response.code})" unless response.code == "200"
116
+
117
+ data = JSON.parse(response.body)
118
+ comments = fetch_comments_via_api(number)
119
+ data["comments"] = comments
120
+ normalize_issue_detail_api(data)
121
+ end
122
+
123
+ def fetch_comments_via_api(number)
124
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/comments")
125
+ response = Net::HTTP.get_response(uri)
126
+ return [] unless response.code == "200"
127
+
128
+ JSON.parse(response.body).map do |raw|
129
+ {
130
+ "body" => raw["body"],
131
+ "author" => raw.dig("user", "login"),
132
+ "createdAt" => raw["created_at"]
133
+ }
134
+ end
135
+ rescue
136
+ []
137
+ end
138
+
139
+ def post_comment_via_gh(number, body)
140
+ cmd = ["gh", "issue", "comment", number.to_s, "--repo", full_repo, "--body", body]
141
+ stdout, stderr, status = Open3.capture3(*cmd)
142
+ raise "Failed to post comment via gh: #{stderr.strip}" unless status.success?
143
+
144
+ stdout.strip
145
+ end
146
+
147
+ def post_comment_via_api(number, body)
148
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/comments")
149
+ request = Net::HTTP::Post.new(uri)
150
+ request["Content-Type"] = "application/json"
151
+ request.body = JSON.dump({body: body})
152
+
153
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
154
+ http.request(request)
155
+ end
156
+
157
+ raise "GitHub API comment failed (#{response.code})" unless response.code.start_with?("2")
158
+ response.body
159
+ end
160
+
161
+ def create_pull_request_via_gh(title:, body:, head:, base:, issue_number:)
162
+ cmd = [
163
+ "gh", "pr", "create",
164
+ "--repo", full_repo,
165
+ "--title", title,
166
+ "--body", body,
167
+ "--head", head,
168
+ "--base", base
169
+ ]
170
+ cmd += ["--issue", issue_number.to_s] if issue_number
171
+
172
+ stdout, stderr, status = Open3.capture3(*cmd)
173
+ raise "Failed to create PR via gh: #{stderr.strip}" unless status.success?
174
+
175
+ stdout.strip
176
+ end
177
+
178
+ def normalize_issue(raw)
179
+ {
180
+ number: raw["number"],
181
+ title: raw["title"],
182
+ labels: Array(raw["labels"]).map { |label| label.is_a?(Hash) ? label["name"] : label },
183
+ updated_at: raw["updatedAt"],
184
+ state: raw["state"],
185
+ url: raw["url"],
186
+ assignees: Array(raw["assignees"]).map { |assignee| assignee.is_a?(Hash) ? assignee["login"] : assignee }
187
+ }
188
+ end
189
+
190
+ def normalize_issue_api(raw)
191
+ {
192
+ number: raw["number"],
193
+ title: raw["title"],
194
+ labels: Array(raw["labels"]).map { |label| label["name"] },
195
+ updated_at: raw["updated_at"],
196
+ state: raw["state"],
197
+ url: raw["html_url"],
198
+ assignees: Array(raw["assignees"]).map { |assignee| assignee["login"] }
199
+ }
200
+ end
201
+
202
+ def normalize_issue_detail(raw)
203
+ {
204
+ number: raw["number"],
205
+ title: raw["title"],
206
+ body: raw["body"] || "",
207
+ comments: Array(raw["comments"]).map { |comment| normalize_comment(comment) },
208
+ labels: Array(raw["labels"]).map { |label| label.is_a?(Hash) ? label["name"] : label },
209
+ state: raw["state"],
210
+ assignees: Array(raw["assignees"]).map { |assignee| assignee.is_a?(Hash) ? assignee["login"] : assignee },
211
+ url: raw["url"],
212
+ updated_at: raw["updatedAt"]
213
+ }
214
+ end
215
+
216
+ def normalize_issue_detail_api(raw)
217
+ {
218
+ number: raw["number"],
219
+ title: raw["title"],
220
+ body: raw["body"] || "",
221
+ comments: Array(raw["comments"]).map { |comment| normalize_comment(comment) },
222
+ labels: Array(raw["labels"]).map { |label| label["name"] },
223
+ state: raw["state"],
224
+ assignees: Array(raw["assignees"]).map { |assignee| assignee["login"] },
225
+ url: raw["html_url"],
226
+ updated_at: raw["updated_at"]
227
+ }
228
+ end
229
+
230
+ def normalize_comment(comment)
231
+ if comment.is_a?(Hash)
232
+ {
233
+ "body" => comment["body"],
234
+ "author" => comment["author"] || comment.dig("user", "login"),
235
+ "createdAt" => comment["createdAt"] || comment["created_at"]
236
+ }
237
+ else
238
+ {"body" => comment.to_s}
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ require_relative "../message_display"
6
+ require_relative "repository_client"
7
+ require_relative "state_store"
8
+ require_relative "plan_generator"
9
+ require_relative "plan_processor"
10
+ require_relative "build_processor"
11
+
12
+ module Aidp
13
+ module Watch
14
+ # Coordinates the watch mode loop: monitors issues, handles plan/build
15
+ # triggers, and keeps running until interrupted.
16
+ class Runner
17
+ include Aidp::MessageDisplay
18
+
19
+ DEFAULT_INTERVAL = 30
20
+
21
+ def initialize(issues_url:, interval: DEFAULT_INTERVAL, provider_name: nil, gh_available: nil, project_dir: Dir.pwd, once: false, prompt: TTY::Prompt.new)
22
+ @prompt = prompt
23
+ @interval = interval
24
+ @once = once
25
+ @project_dir = project_dir
26
+
27
+ owner, repo = RepositoryClient.parse_issues_url(issues_url)
28
+ @repository_client = RepositoryClient.new(owner: owner, repo: repo, gh_available: gh_available)
29
+ @state_store = StateStore.new(project_dir: project_dir, repository: "#{owner}/#{repo}")
30
+ @plan_processor = PlanProcessor.new(
31
+ repository_client: @repository_client,
32
+ state_store: @state_store,
33
+ plan_generator: PlanGenerator.new(provider_name: provider_name)
34
+ )
35
+ @build_processor = BuildProcessor.new(
36
+ repository_client: @repository_client,
37
+ state_store: @state_store,
38
+ project_dir: project_dir
39
+ )
40
+ end
41
+
42
+ def start
43
+ display_message("👀 Watch mode enabled for #{@repository_client.full_repo}", type: :highlight)
44
+ display_message("Polling every #{@interval} seconds. Press Ctrl+C to stop.", type: :muted)
45
+
46
+ loop do
47
+ process_cycle
48
+ break if @once
49
+ sleep @interval
50
+ end
51
+ rescue Interrupt
52
+ display_message("\n⏹️ Watch mode interrupted by user", type: :warning)
53
+ end
54
+
55
+ private
56
+
57
+ def process_cycle
58
+ process_plan_triggers
59
+ process_build_triggers
60
+ end
61
+
62
+ def process_plan_triggers
63
+ issues = @repository_client.list_issues(labels: [PlanProcessor::PLAN_LABEL], state: "open")
64
+ issues.each do |issue|
65
+ next unless issue_has_label?(issue, PlanProcessor::PLAN_LABEL)
66
+
67
+ detailed = @repository_client.fetch_issue(issue[:number])
68
+ @plan_processor.process(detailed)
69
+ end
70
+ end
71
+
72
+ def process_build_triggers
73
+ issues = @repository_client.list_issues(labels: [BuildProcessor::BUILD_LABEL], state: "open")
74
+ issues.each do |issue|
75
+ next unless issue_has_label?(issue, BuildProcessor::BUILD_LABEL)
76
+
77
+ status = @state_store.build_status(issue[:number])
78
+ next if status["status"] == "completed"
79
+
80
+ detailed = @repository_client.fetch_issue(issue[:number])
81
+ @build_processor.process(detailed)
82
+ end
83
+ end
84
+
85
+ def issue_has_label?(issue, label)
86
+ Array(issue[:labels]).any? do |issue_label|
87
+ name = issue_label.is_a?(Hash) ? issue_label["name"] : issue_label.to_s
88
+ name.casecmp(label).zero?
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "time"
6
+
7
+ module Aidp
8
+ module Watch
9
+ # Persists watch mode progress for each repository/issue pair. Used to
10
+ # avoid re-processing plan/build triggers and to retain generated plan
11
+ # context between runs.
12
+ class StateStore
13
+ attr_reader :path
14
+
15
+ def initialize(project_dir:, repository:)
16
+ @project_dir = project_dir
17
+ @repository = repository
18
+ @path = File.join(project_dir, ".aidp", "watch", "#{sanitize_repository(repository)}.yml")
19
+ ensure_directory
20
+ end
21
+
22
+ def plan_processed?(issue_number)
23
+ plans.key?(issue_number.to_s)
24
+ end
25
+
26
+ def plan_data(issue_number)
27
+ plans[issue_number.to_s]
28
+ end
29
+
30
+ def record_plan(issue_number, data)
31
+ payload = {
32
+ "summary" => data[:summary],
33
+ "tasks" => data[:tasks],
34
+ "questions" => data[:questions],
35
+ "comment_body" => data[:comment_body],
36
+ "comment_hint" => data[:comment_hint],
37
+ "posted_at" => data[:posted_at] || Time.now.utc.iso8601
38
+ }.compact
39
+
40
+ plans[issue_number.to_s] = payload
41
+ save!
42
+ end
43
+
44
+ def build_status(issue_number)
45
+ builds[issue_number.to_s] || {}
46
+ end
47
+
48
+ def record_build_status(issue_number, status:, details: {})
49
+ builds[issue_number.to_s] = {
50
+ "status" => status,
51
+ "updated_at" => Time.now.utc.iso8601
52
+ }.merge(stringify_keys(details))
53
+ save!
54
+ end
55
+
56
+ private
57
+
58
+ def ensure_directory
59
+ FileUtils.mkdir_p(File.dirname(@path))
60
+ end
61
+
62
+ def sanitize_repository(repository)
63
+ repository.tr("/", "_")
64
+ end
65
+
66
+ def load_state
67
+ @state ||= if File.exist?(@path)
68
+ YAML.safe_load_file(@path, permitted_classes: [Time]) || {}
69
+ else
70
+ {}
71
+ end
72
+ end
73
+
74
+ def save!
75
+ File.write(@path, YAML.dump(state))
76
+ end
77
+
78
+ def state
79
+ @state = nil if @state && !@state.is_a?(Hash)
80
+ @state ||= begin
81
+ base = load_state
82
+ base["plans"] ||= {}
83
+ base["builds"] ||= {}
84
+ base
85
+ end
86
+ end
87
+
88
+ def plans
89
+ state["plans"]
90
+ end
91
+
92
+ def builds
93
+ state["builds"]
94
+ end
95
+
96
+ def stringify_keys(hash)
97
+ return {} unless hash
98
+
99
+ hash.each_with_object({}) do |(key, value), memo|
100
+ memo[key.to_s] = value
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/aidp/watch.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "message_display"
4
+ require_relative "watch/repository_client"
5
+ require_relative "watch/state_store"
6
+ require_relative "watch/plan_generator"
7
+ require_relative "watch/plan_processor"
8
+ require_relative "watch/build_processor"
9
+ require_relative "watch/runner"