aidp 0.24.0 → 0.26.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +72 -7
  3. data/lib/aidp/analyze/error_handler.rb +11 -0
  4. data/lib/aidp/auto_update/bundler_adapter.rb +66 -0
  5. data/lib/aidp/auto_update/checkpoint.rb +178 -0
  6. data/lib/aidp/auto_update/checkpoint_store.rb +182 -0
  7. data/lib/aidp/auto_update/coordinator.rb +204 -0
  8. data/lib/aidp/auto_update/errors.rb +17 -0
  9. data/lib/aidp/auto_update/failure_tracker.rb +162 -0
  10. data/lib/aidp/auto_update/rubygems_api_adapter.rb +95 -0
  11. data/lib/aidp/auto_update/update_check.rb +106 -0
  12. data/lib/aidp/auto_update/update_logger.rb +143 -0
  13. data/lib/aidp/auto_update/update_policy.rb +109 -0
  14. data/lib/aidp/auto_update/version_detector.rb +144 -0
  15. data/lib/aidp/auto_update.rb +52 -0
  16. data/lib/aidp/cli.rb +165 -1
  17. data/lib/aidp/execute/work_loop_runner.rb +225 -55
  18. data/lib/aidp/harness/config_loader.rb +20 -11
  19. data/lib/aidp/harness/config_schema.rb +80 -8
  20. data/lib/aidp/harness/configuration.rb +73 -2
  21. data/lib/aidp/harness/filter_strategy.rb +45 -0
  22. data/lib/aidp/harness/generic_filter_strategy.rb +63 -0
  23. data/lib/aidp/harness/output_filter.rb +136 -0
  24. data/lib/aidp/harness/provider_factory.rb +2 -0
  25. data/lib/aidp/harness/provider_manager.rb +18 -3
  26. data/lib/aidp/harness/rspec_filter_strategy.rb +82 -0
  27. data/lib/aidp/harness/test_runner.rb +165 -27
  28. data/lib/aidp/harness/ui/enhanced_tui.rb +4 -1
  29. data/lib/aidp/logger.rb +35 -5
  30. data/lib/aidp/message_display.rb +56 -2
  31. data/lib/aidp/prompt_optimization/style_guide_indexer.rb +3 -1
  32. data/lib/aidp/provider_manager.rb +2 -0
  33. data/lib/aidp/providers/kilocode.rb +202 -0
  34. data/lib/aidp/safe_directory.rb +10 -3
  35. data/lib/aidp/setup/provider_registry.rb +15 -0
  36. data/lib/aidp/setup/wizard.rb +12 -4
  37. data/lib/aidp/skills/composer.rb +4 -0
  38. data/lib/aidp/skills/loader.rb +3 -1
  39. data/lib/aidp/storage/csv_storage.rb +9 -3
  40. data/lib/aidp/storage/file_manager.rb +8 -2
  41. data/lib/aidp/storage/json_storage.rb +9 -3
  42. data/lib/aidp/version.rb +1 -1
  43. data/lib/aidp/watch/build_processor.rb +106 -17
  44. data/lib/aidp/watch/change_request_processor.rb +659 -0
  45. data/lib/aidp/watch/ci_fix_processor.rb +448 -0
  46. data/lib/aidp/watch/plan_processor.rb +81 -8
  47. data/lib/aidp/watch/repository_client.rb +465 -20
  48. data/lib/aidp/watch/review_processor.rb +266 -0
  49. data/lib/aidp/watch/reviewers/base_reviewer.rb +164 -0
  50. data/lib/aidp/watch/reviewers/performance_reviewer.rb +65 -0
  51. data/lib/aidp/watch/reviewers/security_reviewer.rb +65 -0
  52. data/lib/aidp/watch/reviewers/senior_dev_reviewer.rb +33 -0
  53. data/lib/aidp/watch/runner.rb +222 -0
  54. data/lib/aidp/watch/state_store.rb +99 -1
  55. data/lib/aidp/workstream_executor.rb +5 -2
  56. data/lib/aidp.rb +5 -0
  57. data/templates/aidp.yml.example +53 -0
  58. metadata +25 -1
@@ -22,14 +22,21 @@ module Aidp
22
22
  path
23
23
  rescue SystemCallError => e
24
24
  fallback = determine_fallback_path(path)
25
- Kernel.warn "[#{component_name}] Cannot create directory #{path}: #{e.class}: #{e.message}"
26
- Kernel.warn "[#{component_name}] Using fallback directory: #{fallback}"
25
+
26
+ # Suppress permission warnings during tests to reduce noise
27
+ unless ENV["RSPEC_RUNNING"] == "true"
28
+ Kernel.warn "[#{component_name}] Cannot create directory #{path}: #{e.class}: #{e.message}"
29
+ Kernel.warn "[#{component_name}] Using fallback directory: #{fallback}"
30
+ end
27
31
 
28
32
  # Try to create fallback directory
29
33
  begin
30
34
  FileUtils.mkdir_p(fallback) unless Dir.exist?(fallback)
31
35
  rescue SystemCallError => e2
32
- Kernel.warn "[#{component_name}] Fallback directory creation also failed: #{e2.class}: #{e2.message}"
36
+ # Suppress fallback errors during tests too
37
+ unless ENV["RSPEC_RUNNING"] == "true"
38
+ Kernel.warn "[#{component_name}] Fallback directory creation also failed: #{e2.class}: #{e2.message}"
39
+ end
33
40
  end
34
41
 
35
42
  fallback
@@ -41,6 +41,21 @@ module Aidp
41
41
  value: "claude",
42
42
  description: "Balanced performance for general-purpose tasks"
43
43
  },
44
+ {
45
+ label: "Google Gemini (multimodal)",
46
+ value: "gemini",
47
+ description: "Google's multimodal AI with strong reasoning and vision capabilities"
48
+ },
49
+ {
50
+ label: "Meta Llama (open-source)",
51
+ value: "llama",
52
+ description: "Meta's open-source model family, suitable for self-hosting"
53
+ },
54
+ {
55
+ label: "DeepSeek (efficient reasoning)",
56
+ value: "deepseek",
57
+ description: "Cost-efficient reasoning models with strong performance"
58
+ },
44
59
  {
45
60
  label: "Mistral (European/open)",
46
61
  value: "mistral",
@@ -1305,12 +1305,20 @@ module Aidp
1305
1305
  # Canonicalization helpers ------------------------------------------------
1306
1306
  def normalize_model_family(value)
1307
1307
  return "auto" if value.nil? || value.to_s.strip.empty?
1308
- # Already a canonical value
1309
- return value if ProviderRegistry.valid_model_family?(value)
1310
- # Try label -> value
1308
+
1309
+ normalized_input = value.to_s.strip.downcase
1310
+
1311
+ # Check for exact canonical value match (case-insensitive)
1312
+ canonical_match = ProviderRegistry.model_family_values.find do |v|
1313
+ v.downcase == normalized_input
1314
+ end
1315
+ return canonical_match if canonical_match
1316
+
1317
+ # Try label -> value mapping (case-insensitive)
1311
1318
  choices = ProviderRegistry.model_family_choices
1312
- mapped = choices.find { |label, _| label == value }&.last
1319
+ mapped = choices.find { |label, _| label.downcase == value.to_s.downcase }&.last
1313
1320
  return mapped if mapped
1321
+
1314
1322
  # Unknown legacy entry -> fallback to auto
1315
1323
  "auto"
1316
1324
  end
@@ -82,6 +82,8 @@ module Aidp
82
82
  def render_template(template, options: {})
83
83
  return template if options.empty?
84
84
 
85
+ # Ensure template is UTF-8 encoded
86
+ template = template.encode("UTF-8", invalid: :replace, undef: :replace) unless template.encoding == Encoding::UTF_8
85
87
  rendered = template.dup
86
88
 
87
89
  options.each do |key, value|
@@ -158,6 +160,8 @@ module Aidp
158
160
  def extract_placeholders(text)
159
161
  return [] if text.nil? || text.empty?
160
162
 
163
+ # Ensure text is UTF-8 encoded
164
+ text = text.encode("UTF-8", invalid: :replace, undef: :replace) unless text.encoding == Encoding::UTF_8
161
165
  scanner = StringScanner.new(text)
162
166
  placeholders = []
163
167
 
@@ -34,7 +34,7 @@ module Aidp
34
34
  raise Aidp::Errors::ValidationError, "Skill file not found: #{file_path}"
35
35
  end
36
36
 
37
- content = File.read(file_path)
37
+ content = File.read(file_path, encoding: "UTF-8")
38
38
  load_from_string(content, source_path: file_path, provider: provider)
39
39
  end
40
40
 
@@ -139,6 +139,8 @@ module Aidp
139
139
  # @return [Array(Hash, String)] Tuple of [metadata, markdown_content]
140
140
  # @raise [Aidp::Errors::ValidationError] if frontmatter is missing or invalid
141
141
  def self.parse_frontmatter(content, source_path:)
142
+ # Ensure content is UTF-8 encoded
143
+ content = content.encode("UTF-8", invalid: :replace, undef: :replace) unless content.encoding == Encoding::UTF_8
142
144
  lines = content.lines
143
145
 
144
146
  unless lines.first&.strip == "---"
@@ -205,12 +205,12 @@ module Aidp
205
205
  rescue
206
206
  File.join(Dir.tmpdir, "aidp_storage")
207
207
  end
208
- Kernel.warn "[AIDP Storage] Cannot create base directory #{@base_dir}: #{e.class}: #{e.message}. Using fallback #{fallback}"
208
+ warn_storage("[AIDP Storage] Cannot create base directory #{@base_dir}: #{e.class}: #{e.message}. Using fallback #{fallback}")
209
209
  @base_dir = fallback
210
210
  begin
211
211
  FileUtils.mkdir_p(@base_dir) unless Dir.exist?(@base_dir)
212
212
  rescue SystemCallError => e2
213
- Kernel.warn "[AIDP Storage] Fallback directory creation also failed: #{e2.class}: #{e2.message}. Continuing without persistent CSV storage."
213
+ warn_storage("[AIDP Storage] Fallback directory creation also failed: #{e2.class}: #{e2.message}. Continuing without persistent CSV storage.")
214
214
  end
215
215
  end
216
216
  end
@@ -225,11 +225,17 @@ module Aidp
225
225
  rescue
226
226
  File.join(Dir.tmpdir, "aidp_storage")
227
227
  end
228
- Kernel.warn "[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'"
228
+ warn_storage("[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'")
229
229
  return fallback
230
230
  end
231
231
  str
232
232
  end
233
+
234
+ # Suppress storage warnings in test/CI environments
235
+ def warn_storage(message)
236
+ return if ENV["RSPEC_RUNNING"] || ENV["CI"] || ENV["RAILS_ENV"] == "test" || ENV["RACK_ENV"] == "test"
237
+ Kernel.warn(message)
238
+ end
233
239
  end
234
240
  end
235
241
  end
@@ -16,7 +16,7 @@ module Aidp
16
16
  csv_dir = @csv_storage.instance_variable_get(:@base_dir)
17
17
  if json_dir != @base_dir || csv_dir != @base_dir
18
18
  @base_dir = json_dir # Prefer JSON storage directory
19
- Kernel.warn "[AIDP Storage] Base directory normalized to #{@base_dir} after fallback."
19
+ warn_storage("[AIDP Storage] Base directory normalized to #{@base_dir} after fallback.")
20
20
  end
21
21
  end
22
22
 
@@ -229,11 +229,17 @@ module Aidp
229
229
  rescue
230
230
  File.join(Dir.tmpdir, "aidp_storage")
231
231
  end
232
- Kernel.warn "[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'"
232
+ warn_storage("[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'")
233
233
  return fallback
234
234
  end
235
235
  str
236
236
  end
237
+
238
+ # Suppress storage warnings in test/CI environments
239
+ def warn_storage(message)
240
+ return if ENV["RSPEC_RUNNING"] || ENV["CI"] || ENV["RAILS_ENV"] == "test" || ENV["RACK_ENV"] == "test"
241
+ Kernel.warn(message)
242
+ end
237
243
  end
238
244
  end
239
245
  end
@@ -171,12 +171,12 @@ module Aidp
171
171
  rescue
172
172
  File.join(Dir.tmpdir, "aidp_storage")
173
173
  end
174
- Kernel.warn "[AIDP Storage] Cannot create base directory #{@base_dir}: #{e.class}: #{e.message}. Using fallback #{fallback}"
174
+ warn_storage("[AIDP Storage] Cannot create base directory #{@base_dir}: #{e.class}: #{e.message}. Using fallback #{fallback}")
175
175
  @base_dir = fallback
176
176
  begin
177
177
  FileUtils.mkdir_p(@base_dir) unless Dir.exist?(@base_dir)
178
178
  rescue SystemCallError => e2
179
- Kernel.warn "[AIDP Storage] Fallback directory creation also failed: #{e2.class}: #{e2.message}. Continuing without persistent JSON storage."
179
+ warn_storage("[AIDP Storage] Fallback directory creation also failed: #{e2.class}: #{e2.message}. Continuing without persistent JSON storage.")
180
180
  end
181
181
  end
182
182
  end
@@ -192,11 +192,17 @@ module Aidp
192
192
  rescue
193
193
  File.join(Dir.tmpdir, "aidp_storage")
194
194
  end
195
- Kernel.warn "[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'"
195
+ warn_storage("[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'")
196
196
  return fallback
197
197
  end
198
198
  str
199
199
  end
200
+
201
+ # Suppress storage warnings in test/CI environments
202
+ def warn_storage(message)
203
+ return if ENV["RSPEC_RUNNING"] || ENV["CI"] || ENV["RAILS_ENV"] == "test" || ENV["RACK_ENV"] == "test"
204
+ Kernel.warn(message)
205
+ end
200
206
  end
201
207
  end
202
208
  end
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.24.0"
4
+ VERSION = "0.26.0"
5
5
  end
@@ -228,12 +228,51 @@ module Aidp
228
228
  relevant.map do |comment|
229
229
  author = comment["author"] || "unknown"
230
230
  created = comment["createdAt"] ? Time.parse(comment["createdAt"]).utc.iso8601 : "unknown"
231
- "### #{author} (#{created})\n#{comment["body"]}"
231
+ body = strip_archived_plans(comment["body"])
232
+ "### #{author} (#{created})\n#{body}"
232
233
  end.join("\n\n")
233
234
  rescue
234
235
  "_Unable to parse comment thread._"
235
236
  end
236
237
 
238
+ def strip_archived_plans(content)
239
+ return content unless content
240
+
241
+ # Remove all archived plan sections (wrapped in HTML comments)
242
+ result = content.dup
243
+
244
+ # Remove archived plan blocks
245
+ # Safe string-based approach to avoid ReDoS vulnerabilities
246
+ start_prefix = "<!-- ARCHIVED_PLAN_START"
247
+ end_marker = "<!-- ARCHIVED_PLAN_END -->"
248
+
249
+ loop do
250
+ # Find the start of an archived plan block (may have attributes after ARCHIVED_PLAN_START)
251
+ start_idx = result.index(start_prefix)
252
+ break unless start_idx
253
+
254
+ # Find the closing --> of the start marker
255
+ start_marker_end = result.index("-->", start_idx)
256
+ break unless start_marker_end
257
+
258
+ # Find the corresponding end marker
259
+ end_idx = result.index(end_marker, start_marker_end)
260
+ break unless end_idx
261
+
262
+ # Remove the entire block including markers
263
+ result = result[0...start_idx] + result[(end_idx + end_marker.length)..]
264
+ end
265
+
266
+ # Remove HTML-commented sections from active plan
267
+ # Keep the content between START and END markers, but strip the markers themselves
268
+ # This preserves the current plan while removing archived content
269
+ result = result.gsub(/<!-- (PLAN_SUMMARY_START|PLAN_TASKS_START|CLARIFYING_QUESTIONS_START) -->/, "")
270
+ result = result.gsub(/<!-- (PLAN_SUMMARY_END|PLAN_TASKS_END|CLARIFYING_QUESTIONS_END) -->/, "")
271
+
272
+ # Clean up any extra blank lines
273
+ result.gsub(/\n{3,}/, "\n\n").strip
274
+ end
275
+
237
276
  def write_prompt(content, working_dir: @project_dir)
238
277
  prompt_manager = Aidp::Execute::PromptManager.new(working_dir)
239
278
  prompt_manager.write(content, step_name: IMPLEMENTATION_STEP)
@@ -355,7 +394,7 @@ module Aidp
355
394
  FileUtils.mkdir_p(File.dirname(target_config))
356
395
 
357
396
  # Only copy when target missing or differs
358
- if !File.exist?(target_config) || File.read(source_config) != File.read(target_config)
397
+ if !File.exist?(target_config) || File.read(source_config, encoding: "UTF-8") != File.read(target_config, encoding: "UTF-8")
359
398
  FileUtils.cp(source_config, target_config)
360
399
  end
361
400
  rescue => e
@@ -382,8 +421,8 @@ module Aidp
382
421
 
383
422
  # Check if PR should be created based on VCS preferences
384
423
  # For watch mode, default to creating PRs (set to false to disable)
385
- vcs_config = config.dig(:work_loop, :version_control) || {}
386
- auto_create_pr = vcs_config.fetch(:auto_create_pr, true)
424
+ vcs_config = config_dig(:work_loop, :version_control) || {}
425
+ auto_create_pr = config_value(vcs_config, :auto_create_pr, true)
387
426
 
388
427
  pr_url = if !changes_committed
389
428
  Aidp.log_info(
@@ -410,12 +449,17 @@ module Aidp
410
449
  nil
411
450
  end
412
451
 
452
+ # Fetch the user who added the most recent label
453
+ label_actor = @repository_client.most_recent_label_actor(issue[:number])
454
+
413
455
  workstream_note = @use_workstreams ? "\n- Workstream: `#{slug}`" : ""
414
456
  pr_line = pr_url ? "\n- Pull Request: #{pr_url}" : ""
457
+ actor_tag = label_actor ? "cc @#{label_actor}\n\n" : ""
415
458
 
416
459
  comment = <<~COMMENT
417
460
  ✅ Implementation complete for ##{issue[:number]}.
418
- - Branch: `#{branch_name}`#{workstream_note}#{pr_line}
461
+
462
+ #{actor_tag}- Branch: `#{branch_name}`#{workstream_note}#{pr_line}
419
463
 
420
464
  Summary:
421
465
  #{plan_value(plan_data, "summary")}
@@ -448,10 +492,20 @@ module Aidp
448
492
  questions = result[:clarification_questions] || []
449
493
  workstream_note = @use_workstreams ? " The workstream `#{slug}` has been preserved." : " The branch has been preserved."
450
494
 
495
+ # Fetch the user who added the most recent label
496
+ label_actor = @repository_client.most_recent_label_actor(issue[:number])
497
+
451
498
  # Build comment with questions
452
499
  comment_parts = []
453
500
  comment_parts << "❓ Implementation needs clarification for ##{issue[:number]}."
454
501
  comment_parts << ""
502
+
503
+ # Tag the label actor if available
504
+ if label_actor
505
+ comment_parts << "cc @#{label_actor}"
506
+ comment_parts << ""
507
+ end
508
+
455
509
  comment_parts << "The AI agent needs additional information to proceed with implementation:"
456
510
  comment_parts << ""
457
511
  questions.each_with_index do |question, index|
@@ -615,15 +669,15 @@ module Aidp
615
669
  end
616
670
 
617
671
  def build_commit_message(issue)
618
- vcs_config = config.dig(:work_loop, :version_control) || {}
672
+ vcs_config = config_dig(:work_loop, :version_control) || {}
619
673
 
620
674
  # Base message components
621
675
  issue_ref = "##{issue[:number]}"
622
676
  title = issue[:title]
623
677
 
624
678
  # Determine commit prefix based on configuration
625
- prefix = if vcs_config[:conventional_commits]
626
- commit_style = vcs_config[:commit_style] || "default"
679
+ prefix = if config_value(vcs_config, :conventional_commits)
680
+ commit_style = config_value(vcs_config, :commit_style, "default")
627
681
  emoji = (commit_style == "emoji") ? "✨ " : ""
628
682
  scope = (commit_style == "angular") ? "(implementation)" : ""
629
683
  "#{emoji}feat#{scope}: "
@@ -635,7 +689,7 @@ module Aidp
635
689
  main_message = "#{prefix}implement #{issue_ref} #{title}"
636
690
 
637
691
  # Add co-author attribution if configured
638
- if vcs_config.fetch(:co_author_ai, true)
692
+ if config_value(vcs_config, :co_author_ai, true)
639
693
  provider_name = detect_current_provider || "AI Agent"
640
694
  co_author = "\n\nCo-authored-by: #{provider_name} <ai@aidp.dev>"
641
695
  main_message + co_author
@@ -658,30 +712,64 @@ module Aidp
658
712
  @config ||= begin
659
713
  config_manager = Aidp::Harness::ConfigManager.new(@project_dir)
660
714
  config_manager.config || {}
661
- rescue
715
+ rescue => e
716
+ Aidp.log_error("build_processor", "config_load_exception", project_dir: @project_dir, error: e.message, backtrace: e.backtrace&.first(5))
662
717
  {}
663
718
  end
664
719
  end
665
720
 
721
+ # Helper to safely dig into config with both string and symbol keys
722
+ def config_dig(*keys)
723
+ value = config
724
+ keys.each do |key|
725
+ return nil unless value.is_a?(Hash)
726
+ # Try both symbol and string versions of the key
727
+ value = value[key] || value[key.to_s] || value[key.to_sym]
728
+ return nil if value.nil?
729
+ end
730
+ value
731
+ end
732
+
733
+ # Helper to get config value with both string and symbol key support
734
+ def config_value(hash, key, default = nil)
735
+ return default unless hash.is_a?(Hash)
736
+ # Check each key variation explicitly to handle false/nil values correctly
737
+ return hash[key] if hash.key?(key)
738
+ return hash[key.to_s] if hash.key?(key.to_s)
739
+ return hash[key.to_sym] if hash.key?(key.to_sym)
740
+ default
741
+ end
742
+
666
743
  def create_pull_request(issue:, branch_name:, base_branch:, working_dir: @project_dir)
667
744
  title = "aidp: Resolve ##{issue[:number]} - #{issue[:title]}"
668
745
  test_summary = gather_test_summary(working_dir: working_dir)
669
746
  body = <<~BODY
747
+ Fixes ##{issue[:number]}
748
+
670
749
  ## Summary
671
750
  - Automated resolution for ##{issue[:number]}
672
- - Fixes ##{issue[:number]}
673
751
 
674
752
  ## Testing
675
753
  #{test_summary}
676
754
  BODY
677
755
 
678
756
  # Determine if PR should be draft based on VCS preferences
679
- vcs_config = config.dig(:work_loop, :version_control) || {}
680
- pr_strategy = vcs_config[:pr_strategy] || "draft"
757
+ vcs_config = config_dig(:work_loop, :version_control) || {}
758
+ pr_strategy = config_value(vcs_config, :pr_strategy, "draft")
681
759
  draft = (pr_strategy == "draft")
682
760
 
683
- # Assign PR to the issue author
684
- assignee = issue[:author]
761
+ # Fetch the user who added the most recent label to assign the PR
762
+ label_actor = @repository_client.most_recent_label_actor(issue[:number])
763
+ assignee = label_actor || issue[:author]
764
+
765
+ Aidp.log_info(
766
+ "build_processor",
767
+ "assigning_pr",
768
+ issue: issue[:number],
769
+ assignee: assignee,
770
+ label_actor: label_actor,
771
+ fallback_to_author: label_actor.nil?
772
+ )
685
773
 
686
774
  output = @repository_client.create_pull_request(
687
775
  title: title,
@@ -700,7 +788,8 @@ module Aidp
700
788
  issue: issue[:number],
701
789
  branch: branch_name,
702
790
  base_branch: base_branch,
703
- pr_url: pr_url
791
+ pr_url: pr_url,
792
+ assignee: assignee
704
793
  )
705
794
  pr_url
706
795
  end
@@ -710,7 +799,7 @@ module Aidp
710
799
  log_path = File.join(".aidp", "logs", "test_runner.log")
711
800
  return "- Fix-forward harness executed; refer to #{log_path}" unless File.exist?(log_path)
712
801
 
713
- recent = File.readlines(log_path).last(20).map(&:strip).reject(&:empty?)
802
+ recent = File.readlines(log_path, encoding: "UTF-8").last(20).map(&:strip).reject(&:empty?)
714
803
  if recent.empty?
715
804
  "- Fix-forward harness executed successfully."
716
805
  else