aidp 0.20.0 → 0.21.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 724d07296380ad69a72b1f9d6bf4dd44d77cf35f994eb35e1e0d224ed32e93a0
4
- data.tar.gz: f54213c88e3e532205852f8dfbedcc0c264a446a65a1110ddf7de524f10e64d5
3
+ metadata.gz: d4fbf6c86d50426f952b62da4b353cce6560aa5321c33d641a972a3af5989456
4
+ data.tar.gz: da684c72ccdc0d4770d3029a0977f592a9f9baf8e1ce4c14df47ccfbda559b38
5
5
  SHA512:
6
- metadata.gz: f8788f4f8af45642b276e350f7d64e2413ae1532cbe02fe29c95c06c8fcbf84ecd66c35cb73e5b16637b6780f52ba77991b255d78f5f2464200a5db6acce262b
7
- data.tar.gz: 2cd95f1e5369205332ad1f87d313b6dc99801d7bdb6f13d0b661cfbba4a771a9e8277e6e8f43e8234a2bea1a6ba23cc73ef54e480f2cf73788bc6dd17aa8de74
6
+ metadata.gz: bf57b96f9393e533508624fabe2ca3bdc8e49a241a68f6404c144d124ab0c19c4f3cea415e8e8e2f4a7d3145e06e23f86f2207e61414b8397ea929304ae29f96
7
+ data.tar.gz: f15ca608bda7e5ed3358c9b633b7d415ce943e99d9a10a74f114de3e31373daec0d4ae52d01000bbb42c6950b252e50ad13daa176c4d32932a6f98b2153a678b
@@ -4,20 +4,30 @@ require "json"
4
4
  require "net/http"
5
5
  require "uri"
6
6
  require "open3"
7
+ require "timeout"
7
8
 
8
9
  module Aidp
9
10
  # Handles importing GitHub issues into AIDP work loops
10
11
  class IssueImporter
11
12
  include Aidp::MessageDisplay
12
13
 
14
+ COMPONENT = "issue_importer"
15
+
13
16
  # Initialize the importer
14
17
  #
15
18
  # @param gh_available [Boolean, nil] (test-only) forcibly sets whether gh CLI is considered
16
19
  # available. When nil (default) we auto-detect. This enables deterministic specs without
17
20
  # depending on developer environment.
18
21
  def initialize(gh_available: nil, enable_bootstrap: true)
19
- @gh_available = gh_available.nil? ? gh_cli_available? : gh_available
22
+ disabled_via_env = ENV["AIDP_DISABLE_GH_CLI"] == "1"
23
+ @gh_available = if disabled_via_env
24
+ false
25
+ else
26
+ gh_available.nil? ? gh_cli_available? : gh_available
27
+ end
20
28
  @enable_bootstrap = enable_bootstrap
29
+ Aidp.log_debug(COMPONENT, "Initialized importer", gh_available: @gh_available, enable_bootstrap: @enable_bootstrap, disabled_via_env: disabled_via_env)
30
+ Aidp.log_debug(COMPONENT, "GitHub CLI disabled via env flag") if disabled_via_env
21
31
  end
22
32
 
23
33
  def import_issue(identifier)
@@ -81,16 +91,28 @@ module Aidp
81
91
  return nil unless match
82
92
 
83
93
  owner, repo, number = match[1], match[2], match[3]
94
+ Aidp.log_debug(COMPONENT, "Fetching issue data", owner: owner, repo: repo, number: number, via: (@gh_available ? "gh_cli" : "api"))
84
95
 
85
96
  # First try GitHub CLI if available (works for private repos)
86
97
  if @gh_available
87
98
  display_message("🔍 Fetching issue via GitHub CLI...", type: :info)
99
+ Aidp.log_debug(COMPONENT, "Attempting GitHub CLI fetch", owner: owner, repo: repo, number: number)
88
100
  issue_data = fetch_via_gh_cli(owner, repo, number)
89
- return issue_data if issue_data
101
+ if issue_data
102
+ Aidp.log_debug(COMPONENT, "GitHub CLI fetch succeeded", owner: owner, repo: repo, number: number)
103
+ return issue_data
104
+ end
105
+ Aidp.log_debug(COMPONENT, "GitHub CLI fetch failed, falling back to API", owner: owner, repo: repo, number: number)
90
106
  end
91
107
 
92
108
  # Fallback to public API
93
109
  display_message("🔍 Fetching issue via GitHub API...", type: :info)
110
+ Aidp.log_debug(COMPONENT, "Fetching issue via GitHub API", owner: owner, repo: repo, number: number)
111
+ fixture = test_fixture(owner, repo, number)
112
+ if fixture
113
+ Aidp.log_debug(COMPONENT, "Using test fixture for issue fetch", owner: owner, repo: repo, number: number, status: fixture["status"])
114
+ return handle_test_fixture(fixture, owner, repo, number)
115
+ end
94
116
  fetch_via_api(owner, repo, number)
95
117
  end
96
118
 
@@ -101,9 +123,12 @@ module Aidp
101
123
  "--json", "title,body,labels,milestone,comments,state,assignees,number,url"
102
124
  ]
103
125
 
104
- stdout, stderr, status = Open3.capture3(*cmd)
126
+ Aidp.log_debug(COMPONENT, "Running gh cli", owner: owner, repo: repo, number: number, command: cmd.join(" "))
127
+ stdout, stderr, status = capture3_with_timeout(*cmd, timeout: gh_cli_timeout)
128
+ Aidp.log_debug(COMPONENT, "Completed gh cli", owner: owner, repo: repo, number: number, exitstatus: status.exitstatus)
105
129
 
106
130
  unless status.success?
131
+ Aidp.log_warn(COMPONENT, "GitHub CLI fetch failed", owner: owner, repo: repo, number: number, exitstatus: status.exitstatus, error: stderr.strip)
107
132
  display_message("⚠️ GitHub CLI failed: #{stderr.strip}", type: :warn)
108
133
  return nil
109
134
  end
@@ -112,9 +137,14 @@ module Aidp
112
137
  data = JSON.parse(stdout)
113
138
  normalize_gh_cli_data(data)
114
139
  rescue JSON::ParserError => e
140
+ Aidp.log_warn(COMPONENT, "GitHub CLI response parse failed", owner: owner, repo: repo, number: number, error: e.message)
115
141
  display_message("❌ Failed to parse GitHub CLI response: #{e.message}", type: :error)
116
142
  nil
117
143
  end
144
+ rescue Timeout::Error
145
+ Aidp.log_warn(COMPONENT, "GitHub CLI timed out", owner: owner, repo: repo, number: number, timeout: gh_cli_timeout)
146
+ display_message("⚠️ GitHub CLI timed out after #{gh_cli_timeout}s, falling back to API", type: :warn)
147
+ nil
118
148
  end
119
149
 
120
150
  def fetch_via_api(owner, repo, number)
@@ -281,6 +311,103 @@ module Aidp
281
311
  false
282
312
  end
283
313
 
314
+ def capture3_with_timeout(*cmd, timeout:)
315
+ stdout_str = +""
316
+ stderr_str = +""
317
+ status = nil
318
+ wait_thr = nil
319
+
320
+ Timeout.timeout(timeout) do
321
+ Open3.popen3(*cmd) do |stdin, stdout_io, stderr_io, thread|
322
+ wait_thr = thread
323
+ stdin.close
324
+ stdout_str = stdout_io.read
325
+ stderr_str = stderr_io.read
326
+ status = thread.value
327
+ end
328
+ end
329
+
330
+ [stdout_str, stderr_str, status]
331
+ rescue Timeout::Error
332
+ terminate_process(wait_thr)
333
+ raise
334
+ end
335
+
336
+ def terminate_process(wait_thr)
337
+ return unless wait_thr&.alive?
338
+
339
+ begin
340
+ Process.kill("TERM", wait_thr.pid)
341
+ rescue Errno::ESRCH
342
+ return
343
+ end
344
+
345
+ begin
346
+ wait_thr.join(1)
347
+ rescue
348
+ nil
349
+ end
350
+
351
+ return unless wait_thr.alive?
352
+
353
+ begin
354
+ Process.kill("KILL", wait_thr.pid)
355
+ rescue Errno::ESRCH
356
+ return
357
+ end
358
+
359
+ begin
360
+ wait_thr.join(1)
361
+ rescue
362
+ nil
363
+ end
364
+ end
365
+
366
+ def gh_cli_timeout
367
+ Integer(ENV.fetch("AIDP_GH_CLI_TIMEOUT", 5))
368
+ rescue ArgumentError, TypeError
369
+ 5
370
+ end
371
+
372
+ def test_fixture(owner, repo, number)
373
+ fixtures_raw = ENV["AIDP_TEST_ISSUE_FIXTURES"]
374
+ return nil unless fixtures_raw
375
+
376
+ fixtures = JSON.parse(fixtures_raw)
377
+ fixtures["#{owner}/#{repo}##{number}"]
378
+ rescue JSON::ParserError => e
379
+ Aidp.log_warn(COMPONENT, "Invalid issue fixtures JSON", error: e.message)
380
+ nil
381
+ end
382
+
383
+ def handle_test_fixture(fixture, owner, repo, number)
384
+ status = fixture["status"].to_i
385
+ case status
386
+ when 200
387
+ data = fixture.fetch("data", {})
388
+ Aidp.log_debug(COMPONENT, "Returning fixture issue data", owner: owner, repo: repo, number: number)
389
+ normalize_api_data(stringify_keys(data))
390
+ when 404
391
+ Aidp.log_warn(COMPONENT, "Fixture indicates issue not found", owner: owner, repo: repo, number: number)
392
+ display_message("❌ Issue not found (may be private)", type: :error)
393
+ nil
394
+ when 403
395
+ Aidp.log_warn(COMPONENT, "Fixture indicates API rate limit", owner: owner, repo: repo, number: number)
396
+ display_message("❌ API rate limit exceeded", type: :error)
397
+ nil
398
+ else
399
+ Aidp.log_warn(COMPONENT, "Fixture indicates API error", owner: owner, repo: repo, number: number, status: status)
400
+ display_message("❌ GitHub API error: #{status}", type: :error)
401
+ nil
402
+ end
403
+ end
404
+
405
+ def stringify_keys(hash)
406
+ hash.each_with_object({}) do |(k, v), result|
407
+ result[k.to_s] = v
408
+ end
409
+ end
410
+
284
411
  def perform_bootstrap(issue_data)
285
412
  return if ENV["AIDP_DISABLE_BOOTSTRAP"] == "1"
286
413
  return unless @enable_bootstrap
@@ -1110,6 +1110,9 @@ module Aidp
1110
1110
 
1111
1111
  # Apply defaults to configuration
1112
1112
  def self.apply_defaults(config)
1113
+ # Guard against non-hash config (e.g., when YAML parsing fails)
1114
+ return config unless config.is_a?(Hash)
1115
+
1113
1116
  result = deep_dup(config)
1114
1117
 
1115
1118
  # Apply harness defaults
@@ -279,6 +279,7 @@ module Aidp
279
279
 
280
280
  def fix_validation_issues
281
281
  return unless @config
282
+ return unless @config.is_a?(Hash)
282
283
 
283
284
  # Fix common issues that can be automatically corrected
284
285
 
@@ -108,10 +108,16 @@ module Aidp
108
108
  # Last resort: try any available provider
109
109
  next_provider = find_any_available_provider
110
110
  if next_provider
111
- success = set_current_provider(next_provider, reason, context)
112
- if success
113
- log_provider_switch(old_provider, next_provider, reason, context)
114
- return next_provider
111
+ # Only attempt switch if it's actually a different provider
112
+ if next_provider != old_provider
113
+ success = set_current_provider(next_provider, reason, context)
114
+ if success
115
+ log_provider_switch(old_provider, next_provider, reason, context)
116
+ return next_provider
117
+ end
118
+ else
119
+ # Same provider - no switch possible
120
+ Aidp.logger.debug("provider_manager", "Only provider available is current provider", provider: next_provider)
115
121
  end
116
122
  end
117
123
 
@@ -132,6 +138,14 @@ module Aidp
132
138
  # Treat capacity/resource exhaustion like rate limit for fallback purposes
133
139
  Aidp.logger.warn("provider_manager", "Resource/quota exhaustion detected", classified_from: error_type)
134
140
  switch_provider("rate_limit", error_details.merge(classified_from: error_type))
141
+ when "empty_response"
142
+ # Empty response indicates provider failure, try next provider
143
+ Aidp.logger.warn("provider_manager", "Empty response from provider", classified_from: error_type)
144
+ switch_provider("provider_failure", error_details.merge(classified_from: error_type))
145
+ when "provider_error"
146
+ # Generic provider error, try next provider
147
+ Aidp.logger.warn("provider_manager", "Provider error detected", classified_from: error_type)
148
+ switch_provider("provider_failure", error_details.merge(classified_from: error_type))
135
149
  when "authentication"
136
150
  switch_provider("authentication_error", error_details)
137
151
  when "network"
@@ -1504,7 +1518,13 @@ module Aidp
1504
1518
 
1505
1519
  # Log provider switch
1506
1520
  def log_provider_switch(from_provider, to_provider, reason, context)
1507
- display_message("🔄 Provider switch: #{from_provider} #{to_provider} (#{reason})", type: :info)
1521
+ if from_provider == to_provider
1522
+ # Same provider - this indicates no fallback was possible
1523
+ display_message("⚠️ Provider switch failed: #{from_provider} → #{to_provider} (#{reason})", type: :warning)
1524
+ display_message(" No alternative providers available", type: :warning)
1525
+ else
1526
+ display_message("🔄 Provider switch: #{from_provider} → #{to_provider} (#{reason})", type: :info)
1527
+ end
1508
1528
  if context.any?
1509
1529
  display_message(" Context: #{context.inspect}", type: :muted)
1510
1530
  end
@@ -1513,7 +1533,31 @@ module Aidp
1513
1533
  # Log no providers available
1514
1534
  def log_no_providers_available(reason, context)
1515
1535
  display_message("❌ No providers available for switching (#{reason})", type: :error)
1516
- display_message(" All providers are rate limited, unhealthy, or circuit breaker open", type: :warning)
1536
+
1537
+ # Check if we have any fallback providers configured
1538
+ harness_fallbacks = if @configuration.respond_to?(:fallback_providers)
1539
+ Array(@configuration.fallback_providers).compact
1540
+ else
1541
+ []
1542
+ end
1543
+
1544
+ all_providers = configured_providers
1545
+
1546
+ if harness_fallbacks.empty? && all_providers.size <= 1
1547
+ display_message(" No fallback providers configured in aidp.yml", type: :warning)
1548
+ display_message(" 💡 Add fallback providers to enable automatic failover:", type: :info)
1549
+ display_message(" harness:", type: :muted)
1550
+ display_message(" fallback_providers:", type: :muted)
1551
+ display_message(" - anthropic", type: :muted)
1552
+ display_message(" - gemini", type: :muted)
1553
+ display_message(" Run 'aidp config --interactive' to configure providers", type: :info)
1554
+ else
1555
+ display_message(" All providers are rate limited, unhealthy, or circuit breaker open", type: :warning)
1556
+ if harness_fallbacks.any?
1557
+ display_message(" Configured fallbacks: #{harness_fallbacks.join(", ")}", type: :muted)
1558
+ end
1559
+ end
1560
+
1517
1561
  if context.any?
1518
1562
  display_message(" Context: #{context.inspect}", type: :muted)
1519
1563
  end
@@ -112,6 +112,12 @@ module Aidp
112
112
 
113
113
  ---
114
114
  Generated from template `planning/generate_llm_style_guide.md` with repository-aware adjustments.
115
+
116
+ **Note**: For comprehensive AIDP projects, consider creating both:
117
+ 1. `STYLE_GUIDE.md` - Detailed explanations with examples and rationale
118
+ 2. `LLM_STYLE_GUIDE.md` - Quick reference with `STYLE_GUIDE:line-range` cross-references
119
+
120
+ See `planning/generate_llm_style_guide.md` for the two-guide approach.
115
121
  GUIDE
116
122
 
117
123
  File.write(File.join(@project_dir, STYLE_GUIDE_PATH), content)
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.20.0"
4
+ VERSION = "0.21.0"
5
5
  end
@@ -209,6 +209,7 @@ module Aidp
209
209
  number: raw["number"],
210
210
  title: raw["title"],
211
211
  body: raw["body"] || "",
212
+ author: raw.dig("author", "login") || raw["author"],
212
213
  comments: Array(raw["comments"]).map { |comment| normalize_comment(comment) },
213
214
  labels: Array(raw["labels"]).map { |label| label.is_a?(Hash) ? label["name"] : label },
214
215
  state: raw["state"],
@@ -223,6 +224,7 @@ module Aidp
223
224
  number: raw["number"],
224
225
  title: raw["title"],
225
226
  body: raw["body"] || "",
227
+ author: raw.dig("user", "login"),
226
228
  comments: Array(raw["comments"]).map { |comment| normalize_comment(comment) },
227
229
  labels: Array(raw["labels"]).map { |label| label["name"] },
228
230
  state: raw["state"],
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require_relative "../message_display"
7
+
8
+ module Aidp
9
+ module Watch
10
+ # Validates watch mode safety requirements for public repositories
11
+ # and enforces author allowlists to prevent untrusted input execution.
12
+ class RepositorySafetyChecker
13
+ include Aidp::MessageDisplay
14
+
15
+ class UnsafeRepositoryError < StandardError; end
16
+ class UnauthorizedAuthorError < StandardError; end
17
+
18
+ def initialize(repository_client:, config: {})
19
+ @repository_client = repository_client
20
+ @config = config
21
+ @repo_visibility_cache = {}
22
+ end
23
+
24
+ # Check if watch mode is safe to run for this repository
25
+ # @param force [Boolean] Skip safety checks (dangerous!)
26
+ # @return [Boolean] true if safe, raises error otherwise
27
+ def validate_watch_mode_safety!(force: false)
28
+ Aidp.log_debug("repository_safety", "validate watch mode safety",
29
+ repo: @repository_client.full_repo,
30
+ force: force)
31
+
32
+ # Skip checks if forced (user takes responsibility)
33
+ if force
34
+ display_message("⚠️ Watch mode safety checks BYPASSED (--force)", type: :warning)
35
+ return true
36
+ end
37
+
38
+ # Check repository visibility
39
+ unless repository_safe_for_watch_mode?
40
+ raise UnsafeRepositoryError, unsafe_repository_message
41
+ end
42
+
43
+ # Check if running in safe environment
44
+ unless safe_environment?
45
+ display_message("⚠️ Watch mode running outside container/sandbox", type: :warning)
46
+ display_message(" Consider using a containerized environment for additional safety", type: :muted)
47
+ end
48
+
49
+ display_message("✅ Watch mode safety checks passed", type: :success)
50
+ true
51
+ end
52
+
53
+ # Check if an issue author is allowed to trigger automated work
54
+ # @param issue [Hash] Issue data with :author or :assignees
55
+ # @return [Boolean] true if authorized
56
+ def author_authorized?(issue)
57
+ author = extract_author(issue)
58
+ return false unless author
59
+
60
+ # If no allowlist configured, allow all (backward compatible)
61
+ return true if author_allowlist.empty?
62
+
63
+ # Check if author is in allowlist
64
+ authorized = author_allowlist.include?(author)
65
+
66
+ Aidp.log_debug("repository_safety", "check author authorization",
67
+ author: author,
68
+ authorized: authorized,
69
+ allowlist_size: author_allowlist.size)
70
+
71
+ authorized
72
+ end
73
+
74
+ # Check if an issue should be processed based on author authorization
75
+ # @param issue [Hash] Issue data
76
+ # @param enforce [Boolean] Raise error if unauthorized
77
+ # @return [Boolean] true if should process
78
+ def should_process_issue?(issue, enforce: true)
79
+ unless author_authorized?(issue)
80
+ author = extract_author(issue)
81
+ if enforce && author_allowlist.any?
82
+ raise UnauthorizedAuthorError,
83
+ "Issue ##{issue[:number]} author '#{author}' not in allowlist. " \
84
+ "Add to watch.safety.author_allowlist in aidp.yml to allow."
85
+ end
86
+
87
+ display_message("⏭️ Skipping issue ##{issue[:number]} - author '#{author}' not authorized",
88
+ type: :muted)
89
+ return false
90
+ end
91
+
92
+ true
93
+ end
94
+
95
+ private
96
+
97
+ def repository_safe_for_watch_mode?
98
+ # Check if repository is private
99
+ return true if repository_private?
100
+
101
+ # Public repositories require explicit opt-in
102
+ if public_repos_allowed?
103
+ display_message("⚠️ Watch mode enabled for PUBLIC repository", type: :warning)
104
+ display_message(" Ensure you trust all contributors and have proper safety measures", type: :muted)
105
+ return true
106
+ end
107
+
108
+ false
109
+ end
110
+
111
+ def repository_private?
112
+ # Cache visibility check to avoid repeated API calls
113
+ return @repo_visibility_cache[:private] if @repo_visibility_cache.key?(:private)
114
+
115
+ is_private = if @repository_client.gh_available?
116
+ check_visibility_via_gh
117
+ else
118
+ check_visibility_via_api
119
+ end
120
+
121
+ @repo_visibility_cache[:private] = is_private
122
+ Aidp.log_debug("repository_safety", "repository visibility check",
123
+ repo: @repository_client.full_repo,
124
+ private: is_private)
125
+
126
+ is_private
127
+ end
128
+
129
+ def check_visibility_via_gh
130
+ cmd = ["gh", "repo", "view", @repository_client.full_repo, "--json", "visibility"]
131
+ stdout, stderr, status = Open3.capture3(*cmd)
132
+
133
+ unless status.success?
134
+ Aidp.log_warn("repository_safety", "failed to check repo visibility via gh",
135
+ error: stderr.strip)
136
+ # Assume public if we can't determine (safer default)
137
+ return false
138
+ end
139
+
140
+ data = JSON.parse(stdout)
141
+ data["visibility"]&.downcase == "private"
142
+ rescue JSON::ParserError => e
143
+ Aidp.log_error("repository_safety", "failed to parse gh repo response", error: e.message)
144
+ false # Assume public on error
145
+ end
146
+
147
+ def check_visibility_via_api
148
+ uri = URI("https://api.github.com/repos/#{@repository_client.full_repo}")
149
+ response = Net::HTTP.get_response(uri)
150
+
151
+ unless response.code == "200"
152
+ Aidp.log_warn("repository_safety", "failed to check repo visibility via API",
153
+ status: response.code)
154
+ # Assume public if we can't determine
155
+ return false
156
+ end
157
+
158
+ data = JSON.parse(response.body)
159
+ data["private"] == true
160
+ rescue => e
161
+ Aidp.log_error("repository_safety", "failed to check repo visibility", error: e.message)
162
+ false # Assume public on error
163
+ end
164
+
165
+ def public_repos_allowed?
166
+ @config.dig(:safety, :allow_public_repos) == true
167
+ end
168
+
169
+ def author_allowlist
170
+ @author_allowlist ||= Array(@config.dig(:safety, :author_allowlist)).compact.map(&:to_s)
171
+ end
172
+
173
+ def safe_environment?
174
+ # Check if running in a container
175
+ in_container? || @config.dig(:safety, :require_container) == false
176
+ end
177
+
178
+ def in_container?
179
+ # Check for container indicators
180
+ File.exist?("/.dockerenv") ||
181
+ File.exist?("/run/.containerenv") ||
182
+ ENV["AIDP_ENV"] == "development" # devcontainer
183
+ end
184
+
185
+ def extract_author(issue)
186
+ # Try different author fields
187
+ issue[:author] ||
188
+ issue.dig(:assignees, 0) ||
189
+ issue["author"] ||
190
+ issue.dig("assignees", 0)
191
+ end
192
+
193
+ def unsafe_repository_message
194
+ <<~MSG
195
+ 🛑 Watch mode is DISABLED for public repositories by default.
196
+
197
+ Running automated code execution on untrusted public input is dangerous!
198
+
199
+ To enable watch mode for this public repository, add to your aidp.yml:
200
+
201
+ watch:
202
+ safety:
203
+ allow_public_repos: true
204
+ author_allowlist: # Only these users can trigger automation
205
+ - trusted_maintainer
206
+ - another_admin
207
+ require_container: true # Require sandboxed environment
208
+
209
+ Alternatively, use --force to bypass this check (NOT RECOMMENDED).
210
+ MSG
211
+ end
212
+ end
213
+ end
214
+ end
@@ -4,6 +4,7 @@ require "tty-prompt"
4
4
 
5
5
  require_relative "../message_display"
6
6
  require_relative "repository_client"
7
+ require_relative "repository_safety_checker"
7
8
  require_relative "state_store"
8
9
  require_relative "plan_generator"
9
10
  require_relative "plan_processor"
@@ -18,14 +19,19 @@ module Aidp
18
19
 
19
20
  DEFAULT_INTERVAL = 30
20
21
 
21
- def initialize(issues_url:, interval: DEFAULT_INTERVAL, provider_name: nil, gh_available: nil, project_dir: Dir.pwd, once: false, use_workstreams: true, prompt: TTY::Prompt.new)
22
+ def initialize(issues_url:, interval: DEFAULT_INTERVAL, provider_name: nil, gh_available: nil, project_dir: Dir.pwd, once: false, use_workstreams: true, prompt: TTY::Prompt.new, safety_config: {}, force: false)
22
23
  @prompt = prompt
23
24
  @interval = interval
24
25
  @once = once
25
26
  @project_dir = project_dir
27
+ @force = force
26
28
 
27
29
  owner, repo = RepositoryClient.parse_issues_url(issues_url)
28
30
  @repository_client = RepositoryClient.new(owner: owner, repo: repo, gh_available: gh_available)
31
+ @safety_checker = RepositorySafetyChecker.new(
32
+ repository_client: @repository_client,
33
+ config: safety_config
34
+ )
29
35
  @state_store = StateStore.new(project_dir: project_dir, repository: "#{owner}/#{repo}")
30
36
  @plan_processor = PlanProcessor.new(
31
37
  repository_client: @repository_client,
@@ -41,6 +47,9 @@ module Aidp
41
47
  end
42
48
 
43
49
  def start
50
+ # Validate safety requirements before starting
51
+ @safety_checker.validate_watch_mode_safety!(force: @force)
52
+
44
53
  display_message("👀 Watch mode enabled for #{@repository_client.full_repo}", type: :highlight)
45
54
  display_message("Polling every #{@interval} seconds. Press Ctrl+C to stop.", type: :muted)
46
55
 
@@ -51,6 +60,9 @@ module Aidp
51
60
  end
52
61
  rescue Interrupt
53
62
  display_message("\n⏹️ Watch mode interrupted by user", type: :warning)
63
+ rescue RepositorySafetyChecker::UnsafeRepositoryError => e
64
+ display_message("\n#{e.message}", type: :error)
65
+ raise
54
66
  end
55
67
 
56
68
  private
@@ -66,7 +78,13 @@ module Aidp
66
78
  next unless issue_has_label?(issue, PlanProcessor::PLAN_LABEL)
67
79
 
68
80
  detailed = @repository_client.fetch_issue(issue[:number])
81
+
82
+ # Check author authorization before processing
83
+ next unless @safety_checker.should_process_issue?(detailed, enforce: false)
84
+
69
85
  @plan_processor.process(detailed)
86
+ rescue RepositorySafetyChecker::UnauthorizedAuthorError => e
87
+ Aidp.log_warn("watch_runner", "unauthorized issue author", issue: issue[:number], error: e.message)
70
88
  end
71
89
  end
72
90
 
@@ -79,7 +97,13 @@ module Aidp
79
97
  next if status["status"] == "completed"
80
98
 
81
99
  detailed = @repository_client.fetch_issue(issue[:number])
100
+
101
+ # Check author authorization before processing
102
+ next unless @safety_checker.should_process_issue?(detailed, enforce: false)
103
+
82
104
  @build_processor.process(detailed)
105
+ rescue RepositorySafetyChecker::UnauthorizedAuthorError => e
106
+ Aidp.log_warn("watch_runner", "unauthorized issue author", issue: issue[:number], error: e.message)
83
107
  end
84
108
  end
85
109
 
@@ -236,18 +236,28 @@ module Aidp
236
236
  result
237
237
  rescue => e
238
238
  message = e.message.to_s
239
+
240
+ # Classify error type for better handling
239
241
  classified = if message =~ /resource[_ ]exhausted/i || message =~ /\[resource_exhausted\]/i
240
242
  "resource_exhausted"
241
243
  elsif message =~ /quota[_ ]exceeded/i || message =~ /\[quota_exceeded\]/i
242
244
  "quota_exceeded"
245
+ elsif /empty response/i.match?(message)
246
+ "empty_response"
247
+ else
248
+ "provider_error"
243
249
  end
244
250
 
245
- if classified && attempts < max_attempts
246
- display_message("⚠️ Provider '#{provider_name}' #{classified.tr("_", " ")} – attempting fallback...", type: :warning)
251
+ # Attempt fallback if we haven't exhausted all providers
252
+ if attempts < max_attempts
253
+ display_message("⚠️ Provider '#{provider_name}' failed (#{classified.tr("_", " ")}) – attempting fallback...", type: :warning)
254
+
247
255
  if @provider_manager.respond_to?(:switch_provider_for_error)
256
+ # Try to switch to fallback provider
248
257
  switched = @provider_manager.switch_provider_for_error(classified, stderr: message)
258
+
249
259
  if switched && switched != provider_name
250
- display_message("↩️ Switched to provider '#{switched}'", type: :info)
260
+ display_message("↩️ Switched to provider '#{switched}' – retrying with same prompt", type: :info)
251
261
  retry
252
262
  elsif switched == provider_name
253
263
  # ProviderManager could not advance; mark current as rate limited to encourage next attempt to move on.
@@ -256,13 +266,18 @@ module Aidp
256
266
  @provider_manager.mark_rate_limited(provider_name)
257
267
  next_provider = @provider_manager.switch_provider("rate_limit_forced", previous_error: message)
258
268
  if next_provider && next_provider != provider_name
259
- display_message("↩️ Switched to provider '#{next_provider}' (forced)", type: :info)
269
+ display_message("↩️ Switched to provider '#{next_provider}' (forced) – retrying with same prompt", type: :info)
260
270
  retry
261
271
  end
262
272
  end
263
273
  end
264
274
  end
265
275
  end
276
+
277
+ # If we get here, either we've exhausted all attempts or couldn't switch providers
278
+ if attempts >= max_attempts
279
+ display_message("❌ All providers exhausted after #{attempts} attempts", type: :error)
280
+ end
266
281
  raise
267
282
  end
268
283
  end
@@ -1,6 +1,6 @@
1
1
  # Generate LLM Style Guide
2
2
 
3
- Your task is to create a project-specific **LLM_STYLE_GUIDE.md** that will be used by AI agents working on this project. This guide should be concise, actionable, and tailored to this specific codebase.
3
+ Your task is to create a project-specific **LLM_STYLE_GUIDE.md** and **STYLE_GUIDE.md** that will be used by AI agents working on this project. The LLM guide should be concise and actionable, while the full guide provides detailed context and rationale.
4
4
 
5
5
  ## Context
6
6
 
@@ -13,9 +13,27 @@ You have access to the project directory. Examine the codebase to understand:
13
13
  - Project structure
14
14
  - Dependencies and frameworks
15
15
 
16
- ## Requirements
16
+ ## Two-Guide Approach
17
17
 
18
- Create a file at `docs/LLM_STYLE_GUIDE.md` with the following sections:
18
+ Create **TWO** complementary guides:
19
+
20
+ ### 1. STYLE_GUIDE.md (Detailed Reference)
21
+
22
+ - Comprehensive explanations with examples
23
+ - Rationale and context for each guideline
24
+ - Deep dives into patterns and anti-patterns
25
+ - Located at `docs/STYLE_GUIDE.md`
26
+
27
+ ### 2. LLM_STYLE_GUIDE.md (Quick Reference)
28
+
29
+ - Ultra-concise bullet points for quick scanning
30
+ - Each guideline references back to STYLE_GUIDE.md with line numbers
31
+ - Format: `Guideline text. STYLE_GUIDE:start-end`
32
+ - Located at `docs/LLM_STYLE_GUIDE.md`
33
+
34
+ ## Requirements for LLM_STYLE_GUIDE.md
35
+
36
+ Create a file at `docs/LLM_STYLE_GUIDE.md` with the following sections, **adding line number references to the detailed guide**:
19
37
 
20
38
  ### 1. Core Engineering Rules
21
39
 
@@ -88,32 +106,97 @@ Create a file at `docs/LLM_STYLE_GUIDE.md` with the following sections:
88
106
 
89
107
  ## Output Format
90
108
 
91
- The LLM_STYLE_GUIDE.md should be:
109
+ ### STYLE_GUIDE.md Format
110
+
111
+ The detailed guide should be:
112
+
113
+ - **Comprehensive**: Full explanations with context and rationale
114
+ - **Example-driven**: Show code examples for each pattern
115
+ - **Well-structured**: Clear section headers that can be referenced by line number
116
+ - **Numbered sections**: Use consistent heading levels for easy reference
117
+
118
+ ### LLM_STYLE_GUIDE.md Format
119
+
120
+ The concise guide should be:
92
121
 
93
122
  - **Concise**: Use bullet points and tables where possible
123
+ - **Cross-referenced**: Every guideline includes `STYLE_GUIDE:start-end` reference
94
124
  - **Specific**: Reference actual code patterns from this project
95
125
  - **Actionable**: Provide clear do's and don'ts
96
126
  - **Scannable**: Use headers, lists, and formatting for easy reference
97
127
 
128
+ ## Creating Cross-References
129
+
130
+ ### Step 1: Write STYLE_GUIDE.md first
131
+
132
+ ```markdown
133
+ ## Code Organization # Line 18
134
+
135
+ ### Class Structure # Line 20
136
+ ...detailed explanation...
137
+
138
+ ### File Organization # Line 45
139
+ ...detailed explanation...
140
+ ```
141
+
142
+ ### Step 2: Add references to LLM_STYLE_GUIDE.md
143
+
144
+ For each guideline in the LLM guide, add the line range from STYLE_GUIDE.md:
145
+
146
+ ```markdown
147
+ ## 1. Core Engineering Rules
148
+
149
+ - Small objects, clear roles. Avoid god classes. `STYLE_GUIDE:18-50`
150
+ - Methods: do one thing; extract early. `STYLE_GUIDE:108-117`
151
+ ```
152
+
153
+ ### Step 3: Verify line numbers
154
+
155
+ Use `grep -n "^##" docs/STYLE_GUIDE.md` to find section headers and their line numbers.
156
+
98
157
  ## Example Structure
99
158
 
159
+ ### STYLE_GUIDE.md
160
+
161
+ ```markdown
162
+ # Project Style Guide
163
+
164
+ ## Code Organization
165
+
166
+ Detailed explanation of code organization principles...
167
+
168
+ ### Single Responsibility Principle
169
+
170
+ Detailed explanation with examples...
171
+
172
+ ## Testing Guidelines
173
+
174
+ Comprehensive testing approach...
175
+ ```
176
+
177
+ ### LLM_STYLE_GUIDE.md
178
+
100
179
  ```markdown
101
- # Project LLM Style Guide
180
+ # Project LLM Style Cheat Sheet
102
181
 
103
- > Concise rules for AI agents working on [Project Name]. Based on [Language/Framework].
182
+ > Ultra-concise rules for automated coding agents.
104
183
 
105
184
  ## 1. Core Engineering Rules
106
- - [Specific rule based on this project]
107
- - [Another specific rule]
108
185
 
109
- ## 2. Naming & Structure
110
- - Classes: [convention]
111
- - Files: [convention]
112
- - [etc.]
186
+ - Small objects, clear roles. `STYLE_GUIDE:18-50`
187
+ - Methods: do one thing; extract early. `STYLE_GUIDE:108-117`
188
+
189
+ ## 2. Testing Contracts
113
190
 
114
- [Continue with all sections...]
191
+ - Test public behavior only. `STYLE_GUIDE:1022-1261`
192
+ - Mock external boundaries only. `STYLE_GUIDE:1022-1261`
115
193
  ```
116
194
 
117
195
  ## Deliverable
118
196
 
119
- Create `docs/LLM_STYLE_GUIDE.md` with all the sections above, tailored specifically to this project's codebase, languages, and frameworks.
197
+ Create **BOTH** files:
198
+
199
+ 1. `docs/STYLE_GUIDE.md` - Comprehensive guide with detailed explanations
200
+ 2. `docs/LLM_STYLE_GUIDE.md` - Quick reference with line number cross-references to the detailed guide
201
+
202
+ The guides should work together: AI agents read the concise LLM guide for quick decisions, then reference the detailed STYLE_GUIDE for context and examples when needed.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -379,6 +379,7 @@ files:
379
379
  - lib/aidp/watch/plan_generator.rb
380
380
  - lib/aidp/watch/plan_processor.rb
381
381
  - lib/aidp/watch/repository_client.rb
382
+ - lib/aidp/watch/repository_safety_checker.rb
382
383
  - lib/aidp/watch/runner.rb
383
384
  - lib/aidp/watch/state_store.rb
384
385
  - lib/aidp/workflows/definitions.rb