aidp 0.19.1 → 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: 25ff9a05d33bc6dc88dfac85b9fc37ff5023af5f756c1d7b2533b1d2f473b103
4
- data.tar.gz: 11f7a0c6ca6d1c8b2f9f13475fe7d8a2ba9851f2ba09037264e6808171291d62
3
+ metadata.gz: d4fbf6c86d50426f952b62da4b353cce6560aa5321c33d641a972a3af5989456
4
+ data.tar.gz: da684c72ccdc0d4770d3029a0977f592a9f9baf8e1ce4c14df47ccfbda559b38
5
5
  SHA512:
6
- metadata.gz: e05fe743a66e0370dff5a60194c824a71e1d99e8ffcbea03786ab9f8b98e7983ff9a01ce48bf6d2ac8f294781d9808715181f9d92f292dc6bfa4ca568a3a4175
7
- data.tar.gz: 59774b09928fbc2e1b6e1023247aa5b2b1208531a965c2fe8c05f9012b2cb03a94a9ccf7dc6029a77086ed6f425a9570c3860b241a50094a6dd71c4053f782b6
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
data/lib/aidp/cli.rb CHANGED
@@ -146,6 +146,16 @@ module Aidp
146
146
 
147
147
  class << self
148
148
  extend Aidp::MessageDisplay::ClassMethods
149
+ extend Aidp::RescueLogging
150
+
151
+ # Explicit singleton delegator (defensive: ensure availability even if extend fails to attach)
152
+ def log_rescue(error, component:, action:, fallback: nil, level: :warn, **context)
153
+ Aidp::RescueLogging.log_rescue(error, component: component, action: action, fallback: fallback, level: level, **context)
154
+ end
155
+
156
+ # Store last parsed options for access by UI components (e.g., verbose flag)
157
+ @last_options = nil
158
+ attr_accessor :last_options
149
159
 
150
160
  def create_prompt
151
161
  ::TTY::Prompt.new
@@ -156,6 +166,7 @@ module Aidp
156
166
  return run_subcommand(args) if subcommand?(args)
157
167
 
158
168
  options = parse_options(args)
169
+ self.last_options = options
159
170
 
160
171
  if options[:help]
161
172
  display_message(options[:parser].to_s, type: :info)
@@ -324,6 +335,7 @@ module Aidp
324
335
  opts.on("-h", "--help", "Show this help message") { options[:help] = true }
325
336
  opts.on("-v", "--version", "Show version information") { options[:version] = true }
326
337
  opts.on("--setup-config", "Setup or reconfigure config file") { options[:setup_config] = true }
338
+ opts.on("--verbose", "Show detailed prompts and raw provider responses during guided workflow") { options[:verbose] = true }
327
339
 
328
340
  opts.separator ""
329
341
  opts.separator "Examples:"
@@ -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
@@ -18,21 +18,27 @@ module Aidp
18
18
  # Callers should set skip_persistence: true for test/dry-run scenarios
19
19
  @skip_persistence = skip_persistence
20
20
  ensure_state_directory
21
+ Aidp.log_debug("state_persistence", "initialized", mode: @mode, skip: @skip_persistence, dir: @state_dir)
21
22
  end
22
23
 
23
24
  def has_state?
24
25
  return false if @skip_persistence
25
- File.exist?(@state_file)
26
+ exists = File.exist?(@state_file)
27
+ Aidp.log_debug("state_persistence", "has_state?", exists: exists, file: @state_file) if exists
28
+ exists
26
29
  end
27
30
 
28
31
  def load_state
29
32
  return {} if @skip_persistence || !has_state?
30
33
 
31
34
  with_lock do
35
+ Aidp.log_debug("state_persistence", "load_state.start", file: @state_file)
32
36
  content = File.read(@state_file)
33
- JSON.parse(content, symbolize_names: true)
37
+ parsed = JSON.parse(content, symbolize_names: true)
38
+ Aidp.log_debug("state_persistence", "load_state.success", keys: parsed.keys.size, file: @state_file)
39
+ parsed
34
40
  rescue JSON::ParserError => e
35
- warn "Failed to parse state file: #{e.message}"
41
+ Aidp.log_warn("state_persistence", "parse_error", error: e.message, file: @state_file)
36
42
  {}
37
43
  end
38
44
  end
@@ -41,8 +47,10 @@ module Aidp
41
47
  return if @skip_persistence
42
48
 
43
49
  with_lock do
50
+ Aidp.log_debug("state_persistence", "save_state.start", keys: state_data.keys.size)
44
51
  state_with_metadata = add_metadata(state_data)
45
52
  write_atomically(state_with_metadata)
53
+ Aidp.log_debug("state_persistence", "save_state.written", file: @state_file, size: state_with_metadata.keys.size)
46
54
  end
47
55
  end
48
56
 
@@ -50,7 +58,9 @@ module Aidp
50
58
  return if @skip_persistence
51
59
 
52
60
  with_lock do
61
+ Aidp.log_debug("state_persistence", "clear_state.start", file: @state_file)
53
62
  File.delete(@state_file) if File.exist?(@state_file)
63
+ Aidp.log_debug("state_persistence", "clear_state.done", file: @state_file)
54
64
  end
55
65
  end
56
66
 
@@ -66,8 +76,10 @@ module Aidp
66
76
 
67
77
  def write_atomically(state_with_metadata)
68
78
  temp_file = "#{@state_file}.tmp"
79
+ Aidp.log_debug("state_persistence", "write_atomically.start", temp: temp_file)
69
80
  File.write(temp_file, JSON.pretty_generate(state_with_metadata))
70
81
  File.rename(temp_file, @state_file)
82
+ Aidp.log_debug("state_persistence", "write_atomically.rename", file: @state_file)
71
83
  end
72
84
 
73
85
  def ensure_state_directory
@@ -76,45 +88,50 @@ module Aidp
76
88
 
77
89
  def with_lock(&block)
78
90
  return yield if @skip_persistence
79
-
80
- acquire_lock_with_timeout(&block)
91
+ result = acquire_lock_with_timeout(&block)
92
+ result
81
93
  ensure
82
94
  cleanup_lock_file
83
95
  end
84
96
 
85
97
  def acquire_lock_with_timeout(&block)
86
- lock_acquired = false
87
- timeout = 30
98
+ timeout = ENV["AIDP_STATE_LOCK_TIMEOUT"]&.to_f || ((ENV["RSPEC_RUNNING"] == "true") ? 1.0 : 30.0)
88
99
  start_time = Time.now
89
-
100
+ attempt_result = nil
90
101
  while (Time.now - start_time) < timeout
91
- lock_acquired = try_acquire_lock(&block)
92
- break if lock_acquired
102
+ acquired, attempt_result = try_acquire_lock(&block)
103
+ return attempt_result if acquired
93
104
  sleep_briefly
94
105
  end
95
-
96
- raise_lock_timeout_error unless lock_acquired
106
+ raise_lock_timeout_error(timeout)
97
107
  end
98
108
 
99
109
  def try_acquire_lock(&block)
100
110
  File.open(@lock_file, File::CREAT | File::EXCL | File::WRONLY) do |_lock|
101
- yield
102
- true
111
+ Aidp.log_debug("state_persistence", "lock.acquired", file: @lock_file)
112
+ [true, yield]
103
113
  end
104
114
  rescue Errno::EEXIST
105
- false
115
+ Aidp.log_debug("state_persistence", "lock.busy", file: @lock_file)
116
+ [false, nil]
106
117
  end
107
118
 
108
119
  def sleep_briefly
109
- sleep(0.1)
120
+ sleep(ENV["AIDP_STATE_LOCK_SLEEP"]&.to_f || 0.05)
110
121
  end
111
122
 
112
- def raise_lock_timeout_error
113
- raise "Could not acquire state lock within 30 seconds"
123
+ def raise_lock_timeout_error(timeout)
124
+ # Prefer explicit error class; fall back if not defined yet
125
+ error_class = defined?(Aidp::Errors::StateError) ? Aidp::Errors::StateError : RuntimeError
126
+ Aidp.log_error("state_persistence", "lock.timeout", file: @lock_file, waited: timeout)
127
+ raise error_class, "Could not acquire state lock within #{timeout} seconds"
114
128
  end
115
129
 
116
130
  def cleanup_lock_file
117
- File.delete(@lock_file) if File.exist?(@lock_file)
131
+ if File.exist?(@lock_file)
132
+ File.delete(@lock_file)
133
+ Aidp.log_debug("state_persistence", "lock.cleaned", file: @lock_file)
134
+ end
118
135
  end
119
136
  end
120
137
  end
@@ -251,7 +251,10 @@ module Aidp
251
251
  def select_guided_workflow
252
252
  # Use the guided agent to help user select workflow
253
253
  # Don't pass prompt so it uses EnhancedInput with full readline support
254
- guided_agent = Aidp::Workflows::GuidedAgent.new(@project_dir)
254
+ verbose_flag = (defined?(Aidp::CLI) && Aidp::CLI.respond_to?(:last_options) && Aidp::CLI.last_options) ? Aidp::CLI.last_options[:verbose] : false
255
+ # Fallback: store verbose in an env for easier access if options not available
256
+ verbose = verbose_flag || ENV["AIDP_VERBOSE"] == "1"
257
+ guided_agent = Aidp::Workflows::GuidedAgent.new(@project_dir, verbose: verbose)
255
258
  result = guided_agent.select_workflow
256
259
 
257
260
  # Store user input for later use
@@ -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/logger.rb CHANGED
@@ -33,7 +33,7 @@ module Aidp
33
33
  attr_reader :level, :json_format
34
34
 
35
35
  def initialize(project_dir = Dir.pwd, config = {})
36
- @project_dir = project_dir
36
+ @project_dir = sanitize_project_dir(project_dir)
37
37
  @config = config
38
38
  @level = determine_log_level
39
39
  @json_format = config[:json] || false
@@ -195,6 +195,18 @@ module Aidp
195
195
  def redact_hash(hash)
196
196
  hash.transform_values { |v| v.is_a?(String) ? redact(v) : v }
197
197
  end
198
+
199
+ # Guard against accidentally passing stream sentinel strings or invalid characters
200
+ # that would create odd top-level directories like "<STDERR>".
201
+ def sanitize_project_dir(dir)
202
+ return Dir.pwd if dir.nil?
203
+ str = dir.to_s
204
+ if str.empty? || str.match?(/[<>|]/) || str.match?(/[\x00-\x1F]/)
205
+ Kernel.warn "[AIDP Logger] Invalid project_dir '#{str}' - falling back to #{Dir.pwd}"
206
+ return Dir.pwd
207
+ end
208
+ str
209
+ end
198
210
  end
199
211
 
200
212
  # Module-level logger accessor
@@ -13,24 +13,35 @@ module Aidp
13
13
  # - includes error class, message
14
14
  # - optional fallback and extra context hash merged in
15
15
  module RescueLogging
16
+ # Instance-level helper (made public so extend works for singleton contexts)
16
17
  def log_rescue(error, component:, action:, fallback: nil, level: :warn, **context)
18
+ Aidp::RescueLogging.__log_rescue_impl(self, error, component: component, action: action, fallback: fallback, level: level, **context)
19
+ end
20
+
21
+ # Module-level access (Aidp::RescueLogging.log_rescue) for direct calls if desired
22
+ def self.log_rescue(error, component:, action:, fallback: nil, level: :warn, **context)
23
+ Aidp::RescueLogging.__log_rescue_impl(self, error, component: component, action: action, fallback: fallback, level: level, **context)
24
+ end
25
+
26
+ # Internal implementation shared by instance & module forms
27
+ def self.__log_rescue_impl(context_object, error, component:, action:, fallback:, level:, **extra)
17
28
  data = {
18
29
  error_class: error.class.name,
19
30
  error_message: error.message,
20
31
  action: action
21
32
  }
22
33
  data[:fallback] = fallback if fallback
23
- data.merge!(context) unless context.empty?
34
+ data.merge!(extra) unless extra.empty?
24
35
 
25
- # Prefer debug_mixin if present; otherwise use Aidp.logger directly
26
- if respond_to?(:debug_log)
27
- debug_log("⚠️ Rescue in #{component}: #{action}", level: level, data: data)
28
- else
29
- Aidp.logger.send(level, component, "Rescued exception during #{action}", **data)
36
+ begin
37
+ if context_object.respond_to?(:debug_log)
38
+ context_object.debug_log("⚠️ Rescue in #{component}: #{action}", level: level, data: data)
39
+ else
40
+ Aidp.logger.send(level, component, "Rescued exception during #{action}", **data)
41
+ end
42
+ rescue => logging_error
43
+ warn "[AIDP Rescue Logging Error] Failed to log rescue for #{component}:#{action} - #{error.class}: #{error.message} (logging error: #{logging_error.message})"
30
44
  end
31
- rescue => logging_error
32
- # Last resort: avoid raising from logging path - fall back to STDERR
33
- warn "[AIDP Rescue Logging Error] Failed to log rescue for #{component}:#{action} - #{error.class}: #{error.message} (logging error: #{logging_error.message})"
34
45
  end
35
46
  end
36
47
  end