ace-review 0.49.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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
  3. data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
  6. data/.ace-defaults/review/config.yml +79 -0
  7. data/.ace-defaults/review/presets/code-fit.yml +64 -0
  8. data/.ace-defaults/review/presets/code-shine.yml +44 -0
  9. data/.ace-defaults/review/presets/code-valid.yml +39 -0
  10. data/.ace-defaults/review/presets/docs.yml +42 -0
  11. data/.ace-defaults/review/presets/spec.yml +37 -0
  12. data/CHANGELOG.md +1780 -0
  13. data/LICENSE +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +14 -0
  16. data/exe/ace-review +27 -0
  17. data/exe/ace-review-feedback +17 -0
  18. data/handbook/guides/code-review-process.g.md +234 -0
  19. data/handbook/prompts/base/sections.md +23 -0
  20. data/handbook/prompts/base/system.md +60 -0
  21. data/handbook/prompts/focus/architecture/atom.md +30 -0
  22. data/handbook/prompts/focus/architecture/reflection.md +60 -0
  23. data/handbook/prompts/focus/frameworks/rails.md +40 -0
  24. data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
  25. data/handbook/prompts/focus/languages/ruby.md +50 -0
  26. data/handbook/prompts/focus/phase/correctness.md +51 -0
  27. data/handbook/prompts/focus/phase/polish.md +43 -0
  28. data/handbook/prompts/focus/phase/quality.md +42 -0
  29. data/handbook/prompts/focus/quality/performance.md +48 -0
  30. data/handbook/prompts/focus/quality/security.md +47 -0
  31. data/handbook/prompts/focus/scope/docs.md +38 -0
  32. data/handbook/prompts/focus/scope/spec.md +58 -0
  33. data/handbook/prompts/focus/scope/tests.md +36 -0
  34. data/handbook/prompts/format/compact.md +12 -0
  35. data/handbook/prompts/format/detailed.md +39 -0
  36. data/handbook/prompts/format/standard.md +16 -0
  37. data/handbook/prompts/guidelines/icons.md +19 -0
  38. data/handbook/prompts/guidelines/tone.md +21 -0
  39. data/handbook/prompts/synthesis-review-reports.system.md +318 -0
  40. data/handbook/prompts/synthesize-feedback.system.md +147 -0
  41. data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
  42. data/handbook/skills/as-review-package/SKILL.md +36 -0
  43. data/handbook/skills/as-review-pr/SKILL.md +38 -0
  44. data/handbook/skills/as-review-run/SKILL.md +30 -0
  45. data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
  46. data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
  47. data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
  48. data/handbook/workflow-instructions/review/package.wf.md +16 -0
  49. data/handbook/workflow-instructions/review/pr.wf.md +284 -0
  50. data/handbook/workflow-instructions/review/run.wf.md +262 -0
  51. data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
  52. data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
  53. data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
  54. data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
  55. data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
  56. data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
  57. data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
  58. data/lib/ace/review/atoms/preset_validator.rb +103 -0
  59. data/lib/ace/review/atoms/priority_filter.rb +115 -0
  60. data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
  61. data/lib/ace/review/atoms/slug_generator.rb +50 -0
  62. data/lib/ace/review/atoms/token_estimator.rb +86 -0
  63. data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
  64. data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
  65. data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
  66. data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
  67. data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
  68. data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
  69. data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
  70. data/lib/ace/review/cli/commands/feedback.rb +79 -0
  71. data/lib/ace/review/cli/commands/review.rb +378 -0
  72. data/lib/ace/review/cli/feedback_cli.rb +71 -0
  73. data/lib/ace/review/cli.rb +103 -0
  74. data/lib/ace/review/errors.rb +146 -0
  75. data/lib/ace/review/models/feedback_item.rb +216 -0
  76. data/lib/ace/review/models/review_options.rb +208 -0
  77. data/lib/ace/review/models/reviewer.rb +181 -0
  78. data/lib/ace/review/molecules/context_composer.rb +123 -0
  79. data/lib/ace/review/molecules/context_extractor.rb +159 -0
  80. data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
  81. data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
  82. data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
  83. data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
  84. data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
  85. data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
  86. data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
  87. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
  88. data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
  89. data/lib/ace/review/molecules/llm_executor.rb +142 -0
  90. data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
  91. data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
  92. data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
  93. data/lib/ace/review/molecules/preset_manager.rb +494 -0
  94. data/lib/ace/review/molecules/prompt_composer.rb +76 -0
  95. data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
  96. data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
  97. data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
  98. data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
  99. data/lib/ace/review/molecules/subject_extractor.rb +315 -0
  100. data/lib/ace/review/molecules/subject_filter.rb +199 -0
  101. data/lib/ace/review/molecules/subject_strategy.rb +96 -0
  102. data/lib/ace/review/molecules/task_report_saver.rb +161 -0
  103. data/lib/ace/review/molecules/task_resolver.rb +48 -0
  104. data/lib/ace/review/organisms/feedback_manager.rb +386 -0
  105. data/lib/ace/review/organisms/review_manager.rb +1059 -0
  106. data/lib/ace/review/version.rb +7 -0
  107. data/lib/ace/review.rb +135 -0
  108. metadata +351 -0
@@ -0,0 +1,1059 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "time"
6
+ require "yaml"
7
+ require "open3"
8
+ require "ace/support/fs"
9
+ require "ace/b36ts"
10
+ require "ace/bundle/atoms/bundle_normalizer"
11
+
12
+ module Ace
13
+ module Review
14
+ module Organisms
15
+ # Main orchestrator for code review workflow
16
+ class ReviewManager
17
+ attr_reader :preset_manager, :prompt_resolver, :prompt_composer,
18
+ :subject_extractor, :context_extractor
19
+
20
+ def initialize(project_root: nil)
21
+ @project_root = project_root
22
+ @preset_manager = Ace::Review::Molecules::PresetManager.new(project_root: project_root)
23
+ @prompt_resolver = Ace::Review::Molecules::NavPromptResolver.new
24
+ @prompt_composer = Ace::Review::Molecules::PromptComposer.new(resolver: @prompt_resolver)
25
+ @subject_extractor = Ace::Review::Molecules::SubjectExtractor.new
26
+ @context_extractor = Ace::Review::Molecules::ContextExtractor.new
27
+ end
28
+
29
+ # Execute a code review with the given options
30
+ # @param options [ReviewOptions] review options object
31
+ # @return [Hash] review results
32
+ def execute_review(options)
33
+ # Convert to ReviewOptions if needed
34
+ options = ensure_review_options(options)
35
+
36
+ # Step 1: Prepare configuration
37
+ config_result = prepare_review_config(options)
38
+ return config_result unless config_result[:success]
39
+
40
+ # Step 2: Create session directory early (needed for ace-bundle)
41
+ cache_dir = create_cache_directory
42
+ session_dir = create_session_directory(options, cache_dir)
43
+
44
+ # Step 3: Extract content
45
+ content_result = extract_review_content(config_result[:config], options)
46
+ return content_result unless content_result[:success]
47
+
48
+ # Step 4: Compose prompts via ace-bundle
49
+ prompt_result = compose_review_prompt(
50
+ config_result[:config],
51
+ content_result[:context],
52
+ content_result[:subject],
53
+ session_dir,
54
+ options, # Pass options to check for PR mode
55
+ content_result[:typed_subject_config] # Pass typed subject config directly
56
+ )
57
+ return prompt_result unless prompt_result[:success]
58
+
59
+ # Step 5: Prepare review data structure
60
+ review_data = build_review_data(
61
+ options,
62
+ config_result[:config],
63
+ content_result,
64
+ prompt_result, # Pass the entire prompt_result to handle both formats
65
+ cache_dir
66
+ )
67
+
68
+ # Step 6: Save session files
69
+ save_session_files(session_dir, review_data)
70
+
71
+ # Step 7: Execute or just prepare
72
+ if options.auto_execute
73
+ execute_with_llm(review_data, session_dir, options)
74
+ else
75
+ {
76
+ success: true,
77
+ session_dir: session_dir,
78
+ system_prompt_file: File.join(session_dir, "system.prompt.md"),
79
+ user_prompt_file: File.join(session_dir, "user.prompt.md"),
80
+ message: "Review session prepared in #{session_dir}"
81
+ }
82
+ end
83
+ end
84
+
85
+ # List available presets
86
+ def list_presets
87
+ @preset_manager.available_presets
88
+ end
89
+
90
+ # List available prompt modules
91
+ def list_prompts
92
+ prompts = @prompt_resolver.list_available
93
+ prompts.is_a?(Hash) ? prompts.keys : []
94
+ end
95
+
96
+ private
97
+
98
+ # Ensure we have a ReviewOptions object
99
+ def ensure_review_options(options)
100
+ return options if options.is_a?(Models::ReviewOptions)
101
+ Models::ReviewOptions.new(options.is_a?(Hash) ? options : {})
102
+ end
103
+
104
+ # Step 1: Prepare and validate configuration
105
+ def prepare_review_config(options)
106
+ preset_name = options.preset
107
+
108
+ unless preset_name
109
+ return {
110
+ success: false,
111
+ error: "No preset specified. Use --preset NAME or set defaults.preset in .ace/review/config.yml"
112
+ }
113
+ end
114
+
115
+ unless @preset_manager.preset_exists?(preset_name)
116
+ available = @preset_manager.available_presets.join(", ")
117
+ return {
118
+ success: false,
119
+ error: "Preset '#{preset_name}' not found. Available: #{available}"
120
+ }
121
+ end
122
+
123
+ # Resolve preset with options
124
+ config = @preset_manager.resolve_preset(preset_name, options.to_h)
125
+
126
+ # Check for composition failure (circular deps, missing refs return nil)
127
+ unless config
128
+ return {
129
+ success: false,
130
+ error: "Failed to load preset '#{preset_name}'. Check for circular dependencies or missing preset references."
131
+ }
132
+ end
133
+
134
+ # Merge options with config
135
+ options.merge_config(config)
136
+
137
+ {success: true, config: config}
138
+ end
139
+
140
+ # Step 2: Extract subject and context
141
+ def extract_review_content(config, options)
142
+ # Handle PR mode
143
+ if options.pr_review?
144
+ return extract_pr_content(options.pr, config, options)
145
+ end
146
+
147
+ # Extract subject (what to review)
148
+ subject_config = options.subject || config[:subject]
149
+
150
+ # Handle array of subjects - merge configs without extraction
151
+ # This allows multiple --subject flags to be combined into a single ace-bundle config
152
+ if subject_config.is_a?(Array)
153
+ merged_config = @subject_extractor.merge_typed_subject_configs(subject_config)
154
+ if merged_config
155
+ cache_dir = options.session_dir || create_cache_directory
156
+ context_config = options.context || config[:context]
157
+ context = extract_context(context_config, cache_dir)
158
+
159
+ return {
160
+ success: true,
161
+ typed_subject_config: merged_config, # Pass merged config, not content
162
+ subject: nil, # No pre-extracted content
163
+ context: context,
164
+ cache_dir: cache_dir
165
+ }
166
+ end
167
+ end
168
+
169
+ # Check for typed subject - pass config directly to ace-bundle (no extraction)
170
+ # This avoids extracting content only to save it and re-read it
171
+ if subject_config.is_a?(String)
172
+ typed_config = @subject_extractor.parse_typed_subject_config(subject_config)
173
+ if typed_config
174
+ # Create cache directory for context.md if not provided
175
+ cache_dir = options.session_dir || create_cache_directory
176
+
177
+ # Extract context (background info)
178
+ context_config = options.context || config[:context]
179
+ context = extract_context(context_config, cache_dir)
180
+
181
+ return {
182
+ success: true,
183
+ typed_subject_config: typed_config, # Pass config, not content
184
+ subject: nil, # No pre-extracted content
185
+ context: context,
186
+ cache_dir: cache_dir
187
+ }
188
+ end
189
+ end
190
+
191
+ # Fall back to legacy flow for non-typed subjects
192
+ subject = extract_subject(subject_config)
193
+
194
+ if subject.nil? || subject.empty?
195
+ return {success: false, error: "No code to review"}
196
+ end
197
+
198
+ # Extract context (background info)
199
+ context_config = options.context || config[:context]
200
+
201
+ # Create cache directory for context.md if not provided
202
+ cache_dir = options.session_dir || create_cache_directory
203
+
204
+ context = extract_context(context_config, cache_dir)
205
+
206
+ {
207
+ success: true,
208
+ subject: subject,
209
+ context: context,
210
+ cache_dir: cache_dir
211
+ }
212
+ end
213
+
214
+ # Extract PR content (diff and metadata)
215
+ def extract_pr_content(pr_identifier, config, options)
216
+ # Fetch PR diff and metadata
217
+ fetch_options = options.gh_timeout ? {timeout: options.gh_timeout} : {}
218
+ result = Ace::Review::Molecules::GhPrFetcher.fetch_pr(pr_identifier, fetch_options)
219
+
220
+ unless result[:success]
221
+ return {success: false, error: result[:error]}
222
+ end
223
+
224
+ # Store PR metadata in options for later use
225
+ options.pr_metadata = result[:metadata]
226
+
227
+ # Fetch PR comments if enabled
228
+ if options.include_pr_comments?
229
+ comments_result = Ace::Review::Molecules::GhPrCommentFetcher.fetch(pr_identifier, fetch_options)
230
+ if comments_result[:success]
231
+ if Ace::Review::Molecules::GhPrCommentFetcher.has_comments?(comments_result)
232
+ options.pr_comment_data = comments_result
233
+ end
234
+ else
235
+ # Log warning but continue with review (comments are optional enhancement)
236
+ warn "Warning: Failed to fetch PR comments: #{comments_result[:error]}. " \
237
+ "Review will proceed without developer feedback."
238
+ end
239
+ end
240
+
241
+ # Create cache directory
242
+ cache_dir = options.session_dir || create_cache_directory
243
+
244
+ # Extract context (background info) and enrich with task behavioral spec when available.
245
+ context_config = options.context || config[:context]
246
+ spec_aware_context = build_pr_context_with_task_spec(
247
+ context_config: context_config,
248
+ pr_metadata: result[:metadata]
249
+ )
250
+ context = extract_context(spec_aware_context, cache_dir)
251
+
252
+ # Add PR metadata to context
253
+ pr_info = format_pr_metadata(result[:metadata])
254
+
255
+ {
256
+ success: true,
257
+ subject: result[:diff],
258
+ context: context.empty? ? pr_info : "#{context}\n\n#{pr_info}",
259
+ cache_dir: cache_dir,
260
+ pr_metadata: result[:metadata]
261
+ }
262
+ end
263
+
264
+ # Add task behavioral spec file to PR context when task can be detected.
265
+ def build_pr_context_with_task_spec(context_config:, pr_metadata:)
266
+ spec_path = Molecules::PrTaskSpecResolver.resolve_spec_path(pr_metadata)
267
+ return context_config unless spec_path
268
+
269
+ case context_config
270
+ when nil, false, "none"
271
+ {"files" => [spec_path]}
272
+ when String
273
+ {"presets" => [context_config], "files" => [spec_path]}
274
+ when Hash
275
+ deep_merge_context(context_config, {"files" => [spec_path]})
276
+ else
277
+ {"files" => [spec_path]}
278
+ end
279
+ end
280
+
281
+ # Format PR metadata for context
282
+ def format_pr_metadata(metadata)
283
+ info = "## Pull Request Information\n\n"
284
+ info += "- **Title**: #{metadata["title"]}\n"
285
+ info += "- **Number**: ##{metadata["number"]}\n"
286
+ info += "- **Author**: #{metadata["author"]["login"]}\n" if metadata["author"]
287
+ info += "- **State**: #{metadata["state"]}\n"
288
+ info += "- **Draft**: #{metadata["isDraft"] ? "Yes" : "No"}\n"
289
+ info += "- **Base**: #{metadata["baseRefName"]}\n"
290
+ info += "- **Head**: #{metadata["headRefName"]}\n"
291
+ info += "- **URL**: #{metadata["url"]}\n"
292
+ info
293
+ end
294
+
295
+ # Step 3: Generate system and user prompts via ace-bundle
296
+ def compose_review_prompt(config, context, subject, session_dir, options = nil, typed_subject_config = nil)
297
+ # Extract prompt composition and context config
298
+ config[:system_prompt] || config["system_prompt"] || {}
299
+ context_config = config[:context] || config["context"] || "project"
300
+
301
+ # Step 3a: Create system.context.md with instructions configuration
302
+ instructions_config = config["instructions"] || config[:instructions]
303
+ unless instructions_config
304
+ return {
305
+ success: false,
306
+ error: "No instructions found in config. All presets must use instructions format."
307
+ }
308
+ end
309
+ system_context_path = create_context_file(session_dir, instructions_config, context_config, "system.context.md")
310
+
311
+ # Step 3b: Create user.context.md with subject configuration
312
+ subject_config = resolve_subject_config(
313
+ config: config,
314
+ subject: subject,
315
+ session_dir: session_dir,
316
+ options: options,
317
+ typed_subject_config: typed_subject_config
318
+ )
319
+
320
+ unless subject_config
321
+ return {
322
+ success: false,
323
+ error: "No subject found in config. All presets must use subject format."
324
+ }
325
+ end
326
+ user_context_path = create_context_file(session_dir, subject_config, nil, "user.context.md")
327
+
328
+ # Step 3c: Generate system.prompt.md via ace-bundle
329
+ system_prompt_path = File.join(session_dir, "system.prompt.md")
330
+ begin
331
+ execute_ace_context(system_context_path, system_prompt_path)
332
+ rescue Errors::MissingDependencyError, Errors::BundleProcessingError => e
333
+ return {success: false, error: "Failed to generate system prompt: #{e.message}"}
334
+ end
335
+
336
+ # Step 3d: Generate user.prompt.md via ace-bundle
337
+ user_prompt_path = File.join(session_dir, "user.prompt.md")
338
+ begin
339
+ execute_ace_context(user_context_path, user_prompt_path)
340
+ rescue Errors::MissingDependencyError, Errors::BundleProcessingError => e
341
+ return {success: false, error: "Failed to generate user prompt: #{e.message}"}
342
+ end
343
+
344
+ # Load the generated prompts
345
+ system_prompt = File.read(system_prompt_path) if File.exist?(system_prompt_path)
346
+ user_prompt = File.read(user_prompt_path) if File.exist?(user_prompt_path)
347
+
348
+ if system_prompt.nil? || system_prompt.empty?
349
+ return {success: false, error: "Failed to generate system prompt"}
350
+ end
351
+
352
+ {
353
+ success: true,
354
+ system_prompt: system_prompt,
355
+ user_prompt: user_prompt || "Please review the provided code.",
356
+ system_prompt_path: system_prompt_path,
357
+ user_prompt_path: user_prompt_path
358
+ }
359
+ end
360
+
361
+ # Detect whether preset uses instructions format or legacy system_prompt format
362
+ def uses_instructions_format?(resolved_config)
363
+ instructions = resolved_config["instructions"] || resolved_config[:instructions]
364
+ instructions && instructions.is_a?(Hash)
365
+ end
366
+
367
+ # Unified context file processor - pass configuration directly to ace-bundle
368
+ def create_context_file(session_dir, context_config, additional_context, output_filename)
369
+ # Build complete ace-bundle configuration
370
+ ace_context_config = {}
371
+
372
+ # Normalize and merge context_config if provided
373
+ if context_config
374
+ normalized_config = Ace::Bundle::Atoms::BundleNormalizer.normalize_config(context_config)
375
+ ace_context_config = deep_merge_context(ace_context_config, normalized_config)
376
+ end
377
+
378
+ # Add additional context as "bundle" key for ace-bundle, but avoid duplicates
379
+ if additional_context && additional_context != "none" && !additional_context.empty?
380
+ ace_context_config["bundle"] ||= {}
381
+ if additional_context.is_a?(String)
382
+ # Check if this preset is already included in the sections to avoid duplication
383
+ existing_presets = extract_presets_from_sections(ace_context_config)
384
+ unless existing_presets.include?(additional_context)
385
+ ace_context_config["bundle"]["presets"] ||= []
386
+ ace_context_config["bundle"]["presets"] << additional_context
387
+ end
388
+ elsif additional_context.is_a?(Hash)
389
+ additional_normalized = Ace::Bundle::Atoms::BundleNormalizer.normalize_config(additional_context)
390
+ ace_context_config = deep_merge_context(ace_context_config, additional_normalized)
391
+ end
392
+ end
393
+
394
+ # Create context.md content with full configuration as frontmatter
395
+ context_content = "#{YAML.dump(ace_context_config).strip}\n---\n\n"
396
+
397
+ # Write to file
398
+ context_path = File.join(session_dir, output_filename)
399
+ File.write(context_path, context_content)
400
+
401
+ context_path
402
+ end
403
+
404
+ # Deep merge two context configurations (top-level wrapper)
405
+ #
406
+ # Merges overlay into base with smart type handling. This is a simplified
407
+ # version that delegates to deep_merge_hash for all hash values.
408
+ #
409
+ # @param base [Hash] Base configuration (lower priority)
410
+ # @param overlay [Hash] Overlay configuration (higher priority)
411
+ # @return [Hash] Merged configuration
412
+ #
413
+ # @example Simple merge
414
+ # deep_merge_context({a: 1}, {b: 2})
415
+ # #=> {a: 1, b: 2}
416
+ #
417
+ # @example Nested hash merge
418
+ # deep_merge_context({a: {b: 1}}, {a: {c: 2}})
419
+ # #=> {a: {b: 1, c: 2}}
420
+ #
421
+ # @api private
422
+ def deep_merge_context(base, overlay)
423
+ # Use centralized DeepMerger with :union strategy for array deduplication
424
+ Ace::Support::Config::Atoms::DeepMerger.merge(base, overlay, array_strategy: :union)
425
+ end
426
+
427
+ # Extract preset names from sections to avoid duplication
428
+ def extract_presets_from_sections(config)
429
+ presets = []
430
+ return presets unless config.is_a?(Hash)
431
+
432
+ # Check if config has bundle with sections
433
+ bundle = config["bundle"] || config[:bundle]
434
+ return presets unless bundle.is_a?(Hash)
435
+
436
+ sections = bundle["sections"] || bundle[:sections]
437
+ return presets unless sections.is_a?(Hash)
438
+
439
+ # Extract presets from all sections
440
+ sections.each do |section_name, section_config|
441
+ if section_config.is_a?(Hash)
442
+ section_presets = section_config["presets"] || section_config[:presets]
443
+ if section_presets.is_a?(Array)
444
+ presets.concat(section_presets)
445
+ end
446
+ end
447
+ end
448
+
449
+ presets.uniq
450
+ end
451
+
452
+ # Execute ace-bundle to generate prompts using Ruby API
453
+ # @param input_file [String] Path to context configuration file
454
+ # @param output_file [String] Path to write rendered context
455
+ # @raise [Errors::MissingDependencyError] If ace-bundle gem not available
456
+ # @raise [Errors::BundleProcessingError] If context processing fails
457
+ # @return [true] On success
458
+ def execute_ace_context(input_file, output_file)
459
+ # Ensure ace-bundle is available
460
+ begin
461
+ require "ace/bundle"
462
+ rescue LoadError
463
+ raise Errors::MissingDependencyError.new(
464
+ "ace-bundle",
465
+ "gem install ace-bundle"
466
+ )
467
+ end
468
+
469
+ # Check if Ace::Bundle is actually defined (might fail silently)
470
+ unless defined?(Ace::Bundle)
471
+ raise Errors::MissingDependencyError.new(
472
+ "ace-bundle",
473
+ "gem install ace-bundle"
474
+ )
475
+ end
476
+
477
+ begin
478
+ # Load context using ace-bundle Ruby API
479
+ context_result = Ace::Bundle.load_file(input_file)
480
+
481
+ # Check for fatal error in metadata
482
+ if context_result.metadata[:error]
483
+ error_message = context_result.metadata[:error]
484
+ raise Errors::BundleProcessingError.new(
485
+ "Failed to process context file: #{error_message}",
486
+ {input_file: input_file, error: error_message}
487
+ )
488
+ end
489
+
490
+ # Surface non-fatal errors (e.g., PR fetch failures) as warnings
491
+ # These are stored in metadata[:errors] array by ace-bundle
492
+ if context_result.metadata[:errors]&.any?
493
+ context_result.metadata[:errors].each do |error_msg|
494
+ warn "[ace-review] Warning: #{error_msg}"
495
+ end
496
+ end
497
+
498
+ # Write the rendered content to output file
499
+ File.write(output_file, context_result.content)
500
+ true
501
+ rescue Errors::BundleProcessingError
502
+ # Re-raise our own errors
503
+ raise
504
+ rescue => e
505
+ raise Errors::BundleProcessingError.new(
506
+ "ace-bundle processing failed: #{e.message}",
507
+ {input_file: input_file, error: e.message, backtrace: e.backtrace.first(5)}
508
+ )
509
+ end
510
+ end
511
+
512
+ # Build the complete review data structure
513
+ def build_review_data(options, config, content, prompt_result, cache_dir)
514
+ # v0.13.0 architecture: only supports system/user prompt format
515
+ effective_models = options.effective_models(config[:models])
516
+
517
+ review_data = {
518
+ preset: options.preset,
519
+ config: config,
520
+ subject: content[:subject],
521
+ context: content[:context],
522
+ model: effective_models.first,
523
+ models: effective_models,
524
+ cache_dir: cache_dir,
525
+ system_prompt: prompt_result[:system_prompt],
526
+ user_prompt: prompt_result[:user_prompt],
527
+ system_prompt_path: prompt_result[:system_prompt_path],
528
+ user_prompt_path: prompt_result[:user_prompt_path]
529
+ }
530
+
531
+ # Include PR comment data if available
532
+ review_data[:pr_comment_data] = options.pr_comment_data if options.pr_comment_data
533
+
534
+ review_data
535
+ end
536
+
537
+ # Resolve subject configuration from multiple sources
538
+ # Priority: typed subject config > --pr flag > preset subject config
539
+ # @param config [Hash] Preset configuration
540
+ # @param subject [String, nil] Pre-extracted subject content (for --pr flag only)
541
+ # @param session_dir [String] Session directory for saving intermediate files
542
+ # @param options [ReviewOptions, nil] Review options
543
+ # @param typed_subject_config [Hash, nil] Parsed typed subject (pr:, files:, diff:, task:)
544
+ # @return [Hash, nil] Resolved subject configuration for ace-bundle
545
+ def resolve_subject_config(config:, subject:, session_dir:, options:, typed_subject_config:)
546
+ # Handle typed subject config (pr:, files:, diff:, task:) - pass directly to ace-bundle
547
+ # This is the primary path - ace-bundle handles all content extraction
548
+ if typed_subject_config
549
+ return typed_subject_config
550
+ end
551
+
552
+ # Handle --pr flag (full PR mode with GhPrFetcher)
553
+ if subject && !subject.empty? && options&.pr_review?
554
+ pr_diff_path = File.join(session_dir, "pr-diff.patch")
555
+ File.write(pr_diff_path, subject)
556
+
557
+ return {
558
+ "bundle" => {
559
+ "sections" => {
560
+ "pr_changes" => {
561
+ "title" => "Pull Request Changes",
562
+ "description" => "Code changes from GitHub Pull Request",
563
+ "files" => [pr_diff_path]
564
+ }
565
+ }
566
+ }
567
+ }
568
+ end
569
+
570
+ # Fallback to preset subject config
571
+ config["subject"] || config[:subject]
572
+ end
573
+
574
+ def extract_subject(subject_config)
575
+ return "" unless subject_config
576
+ @subject_extractor.extract(subject_config)
577
+ end
578
+
579
+ def extract_context(context_config, cache_dir = nil)
580
+ @context_extractor.extract(context_config, cache_dir)
581
+ end
582
+
583
+ def execute_with_llm(review_data, session_dir, options = nil)
584
+ models = review_data[:models]
585
+
586
+ # Detect single vs multi-model execution
587
+ if models.size == 1
588
+ # Single model execution (existing path)
589
+ execute_single_model(review_data, session_dir, options, models.first)
590
+ else
591
+ # Multi-model execution (new path)
592
+ execute_multi_model(review_data, session_dir, options, models)
593
+ end
594
+ end
595
+
596
+ # Execute single model review (existing behavior)
597
+ def execute_single_model(review_data, session_dir, options, model)
598
+ executor = Ace::Review::Molecules::LlmExecutor.new
599
+
600
+ # v0.13.0 architecture: only supports system/user prompt format
601
+ result = executor.execute(
602
+ system_prompt: review_data[:system_prompt],
603
+ user_prompt: review_data[:user_prompt],
604
+ model: model,
605
+ session_dir: session_dir
606
+ )
607
+
608
+ if result[:success]
609
+ # Save Ruby API metadata if available
610
+ save_ruby_api_metadata(session_dir, result) if result[:metadata]
611
+
612
+ # Copy final review to release folder
613
+ release_path = copy_to_release(session_dir, review_data)
614
+
615
+ # Handle PR comment posting if requested
616
+ comment_result = handle_pr_comment_posting(options, result[:output_file], review_data)
617
+
618
+ # Build response with comment info if applicable
619
+ response = build_success_response(result, release_path, comment_result)
620
+
621
+ # Extract feedback after successful single model review (if enabled)
622
+ feedback_result = maybe_extract_single_model_feedback(
623
+ result, session_dir, review_data, options, model
624
+ )
625
+
626
+ # Add feedback info to response if extraction succeeded
627
+ if feedback_result && feedback_result[:success]
628
+ response[:feedback_count] = feedback_result[:items_count]
629
+ response[:feedback_paths] = feedback_result[:paths]
630
+ end
631
+
632
+ response
633
+ else
634
+ # Enhanced error information from Ruby API
635
+ error_result = result.dup
636
+ if result[:error_type]
637
+ error_result[:enhanced_error] = "#{result[:error_type]}: #{result[:error]}"
638
+ end
639
+ error_result
640
+ end
641
+ end
642
+
643
+ # Execute multi-model review (new capability)
644
+ def execute_multi_model(review_data, session_dir, options, models)
645
+ require_relative "../molecules/multi_model_executor"
646
+ executor = Ace::Review::Molecules::MultiModelExecutor.new
647
+
648
+ # Execute all models concurrently
649
+ result = executor.execute(
650
+ models: models,
651
+ system_prompt: review_data[:system_prompt],
652
+ user_prompt: review_data[:user_prompt],
653
+ session_dir: session_dir
654
+ )
655
+
656
+ if result[:success]
657
+ # Save metadata for all models
658
+ save_multi_model_metadata(session_dir, result)
659
+
660
+ # For multi-model, we don't copy to a single release location
661
+ # Each model has its own output file already in session_dir
662
+
663
+ # Extract feedback (always runs if we have results)
664
+ feedback_result = nil
665
+ if should_extract_feedback?(result, options)
666
+ feedback_result = extract_feedback(result, session_dir, review_data, options)
667
+ end
668
+
669
+ # Build multi-model success response
670
+ build_multi_model_response(result, session_dir, feedback_result)
671
+ else
672
+ {
673
+ success: false,
674
+ error: "All models failed to execute"
675
+ }
676
+ end
677
+ end
678
+
679
+ # Post review comment to PR
680
+ def post_pr_comment(options, review_file, review_data)
681
+ return {success: false, error: "No review file to post"} unless File.exist?(review_file)
682
+
683
+ # Read review content
684
+ review_content = File.read(review_file)
685
+
686
+ # Prepare metadata for comment
687
+ metadata = {
688
+ preset: review_data[:preset],
689
+ model: review_data[:model],
690
+ timestamp: Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
691
+ }
692
+
693
+ # Post comment
694
+ Ace::Review::Molecules::GhCommentPoster.post_comment(
695
+ options.pr,
696
+ review_content,
697
+ metadata: metadata,
698
+ dry_run: options.dry_run
699
+ )
700
+ end
701
+
702
+ def save_session_files(session_dir, review_data)
703
+ # v0.13.0+ architecture: system and user prompts are already saved as .prompt.md files
704
+ # Subject and context are handled directly via ace-bundle workflow, no need for separate files
705
+
706
+ # Save metadata (committable - no .tmp extension)
707
+ metadata = create_metadata(review_data)
708
+ File.write(File.join(session_dir, "metadata.yml"), YAML.dump(metadata))
709
+
710
+ # Save PR comments as developer feedback report if available
711
+ if review_data[:pr_comment_data]
712
+ feedback_report = Ace::Review::Atoms::PrCommentFormatter.format(review_data[:pr_comment_data])
713
+ if feedback_report && !feedback_report.empty?
714
+ feedback_file = File.join(session_dir, "review-dev-feedback.md")
715
+ File.write(feedback_file, feedback_report)
716
+ end
717
+ end
718
+ end
719
+
720
+ def save_review_output(response, review_data, session_dir)
721
+ # Save review to session directory as review.md
722
+ output_file = File.join(session_dir, "review.md")
723
+
724
+ # Add metadata header to response
725
+ full_content = add_review_metadata(response, review_data)
726
+
727
+ File.write(output_file, full_content)
728
+
729
+ {
730
+ success: true,
731
+ output_file: output_file,
732
+ message: "Review saved to #{output_file}"
733
+ }
734
+ end
735
+
736
+ def create_session_directory(options, cache_dir)
737
+ if options.session_dir
738
+ FileUtils.mkdir_p(options.session_dir)
739
+ return options.session_dir
740
+ end
741
+
742
+ # Use cache directory (cache-first approach)
743
+ compact_id = Ace::B36ts.encode(Time.now)
744
+
745
+ # All reviews use the same naming pattern
746
+ session_dir = File.join(cache_dir, "review-#{compact_id}")
747
+
748
+ FileUtils.mkdir_p(session_dir)
749
+ session_dir
750
+ end
751
+
752
+ def create_cache_directory
753
+ # Create cache directory in .ace-local/review/sessions/ relative to project root
754
+ # Use @project_root if set (e.g., in tests), otherwise use ProjectRootFinder
755
+ root = @project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
756
+ base_cache_path = File.join(root, ".ace-local", "review", "sessions")
757
+ FileUtils.mkdir_p(base_cache_path)
758
+ base_cache_path
759
+ end
760
+
761
+ def copy_to_release(session_dir, review_data)
762
+ # Copy final review reports to release folder
763
+ release_base_path = @preset_manager.review_base_path
764
+ FileUtils.mkdir_p(release_base_path)
765
+
766
+ # Create output filename
767
+ model_slug = Ace::Review::Atoms::SlugGenerator.generate(review_data[:model])
768
+ compact_id = Ace::B36ts.encode(Time.now)
769
+ release_filename = "review-report-#{model_slug}-#{compact_id}.md"
770
+ release_path = File.join(release_base_path, release_filename)
771
+
772
+ # Copy review file if it exists
773
+ review_file = File.join(session_dir, "review.md")
774
+ if File.exist?(review_file)
775
+ FileUtils.cp(review_file, release_path)
776
+ return release_path
777
+ end
778
+
779
+ nil
780
+ end
781
+
782
+ def create_metadata(review_data)
783
+ {
784
+ "timestamp" => Time.now.iso8601,
785
+ "preset" => review_data[:preset],
786
+ "model" => review_data[:model],
787
+ "has_context" => !review_data[:context].to_s.empty?,
788
+ "subject_size" => review_data[:subject]&.length || 0,
789
+ "system_prompt_size" => review_data[:system_prompt]&.length || 0,
790
+ "user_prompt_size" => review_data[:user_prompt]&.length || 0
791
+ }
792
+ end
793
+
794
+ def save_ruby_api_metadata(session_dir, result)
795
+ # Save rich metadata from Ruby API
796
+ metadata_file = File.join(session_dir, "llm_metadata.yml")
797
+ metadata_content = {
798
+ "timestamp" => Time.now.iso8601,
799
+ "usage" => result[:usage],
800
+ "model_info" => result[:model_info],
801
+ "provider_info" => result[:provider_info],
802
+ "raw_metadata" => result[:metadata]
803
+ }
804
+ File.write(metadata_file, YAML.dump(metadata_content))
805
+ end
806
+
807
+ # Save metadata for multi-model execution
808
+ def save_multi_model_metadata(session_dir, result)
809
+ metadata_file = File.join(session_dir, "metadata.yml")
810
+ models_metadata = result[:results].map do |model, model_result|
811
+ {
812
+ "name" => model,
813
+ "status" => model_result[:success] ? "success" : "failed",
814
+ "duration" => model_result[:duration],
815
+ "output_file" => model_result[:output_file] ? File.basename(model_result[:output_file]) : nil,
816
+ "error" => model_result[:error],
817
+ "model_slug" => model_result[:model_slug]
818
+ }
819
+ end
820
+
821
+ metadata_content = {
822
+ "timestamp" => Time.now.iso8601,
823
+ "models" => models_metadata,
824
+ "summary" => result[:summary]
825
+ }
826
+
827
+ File.write(metadata_file, YAML.dump(metadata_content))
828
+ end
829
+
830
+ def add_review_metadata(response, review_data)
831
+ metadata = <<~METADATA
832
+ ---
833
+ timestamp: #{Time.now.iso8601}
834
+ preset: #{review_data[:preset]}
835
+ model: #{review_data[:model]}
836
+ ---
837
+
838
+ METADATA
839
+
840
+ metadata + response
841
+ end
842
+
843
+ # Handle PR comment posting workflow
844
+ # @param options [ReviewOptions] Review options
845
+ # @param review_file [String] Path to review file
846
+ # @param review_data [Hash] Review metadata
847
+ # @return [Hash, nil] Comment result or nil if no posting needed
848
+ def handle_pr_comment_posting(options, review_file, review_data)
849
+ return nil unless options && options.should_post_comment?
850
+ post_pr_comment(options, review_file, review_data)
851
+ end
852
+
853
+ # Build success response with optional comment info
854
+ # @param result [Hash] LLM execution result
855
+ # @param release_path [String] Path to saved release file
856
+ # @param comment_result [Hash, nil] Comment posting result
857
+ # @return [Hash] Final response hash
858
+ def build_success_response(result, release_path, comment_result)
859
+ # Build result message
860
+ messages = []
861
+ messages << "Review saved to #{release_path}" if release_path
862
+ messages << "Review saved to #{result[:output_file]}" if messages.empty?
863
+
864
+ # Build base response
865
+ response = {
866
+ success: true,
867
+ output_file: release_path || result[:output_file],
868
+ message: messages.join("\n"),
869
+ usage: result[:usage],
870
+ model_info: result[:model_info],
871
+ provider_info: result[:provider_info]
872
+ }
873
+
874
+ # Add comment info to response
875
+ if comment_result && comment_result[:success]
876
+ if comment_result[:dry_run]
877
+ # Dry-run mode: add preview to response
878
+ response[:dry_run_preview] = comment_result[:preview]
879
+ else
880
+ # Actual posting: add comment URL
881
+ response[:comment_url] = comment_result[:comment_url]
882
+ response[:message] += "\n✓ Review posted to PR: #{comment_result[:comment_url]}"
883
+ end
884
+ elsif comment_result && !comment_result[:success]
885
+ response[:comment_error] = comment_result[:error]
886
+ response[:message] += "\n✗ Failed to post comment: #{comment_result[:error]}"
887
+ end
888
+
889
+ response
890
+ end
891
+
892
+ # Determine if feedback extraction should be triggered
893
+ # Feedback extraction always runs if we have successful results,
894
+ # unless explicitly disabled via --no-feedback CLI flag.
895
+ # @param result [Hash] multi-model execution result
896
+ # @param options [ReviewOptions, nil] review options
897
+ # @return [Boolean] true if feedback extraction should run
898
+ def should_extract_feedback?(result, options)
899
+ # Check if feedback is disabled via CLI flag (--no-feedback)
900
+ return false if options&.no_feedback == true
901
+
902
+ # Need at least 1 successful result
903
+ success_count = result[:results].count { |_, r| r[:success] }
904
+ success_count >= 1
905
+ end
906
+
907
+ # Extract feedback for single-model reviews
908
+ # Wraps the single model result in the multi-model format and delegates to extract_feedback
909
+ # @param result [Hash] LLM execution result (single model)
910
+ # @param session_dir [String] session directory
911
+ # @param review_data [Hash] review metadata
912
+ # @param options [ReviewOptions, nil] review options
913
+ # @param model [String] model name used for review
914
+ # @return [Hash, nil] feedback extraction result or nil if disabled/failed
915
+ def maybe_extract_single_model_feedback(result, session_dir, review_data, options, model)
916
+ # Check if feedback is disabled via CLI flag (--no-feedback)
917
+ return nil if options&.no_feedback == true
918
+
919
+ # Build a result structure compatible with extract_feedback (multi-model format)
920
+ single_model_result = {
921
+ results: {model => {success: true, output_file: result[:output_file]}}
922
+ }
923
+
924
+ extract_feedback(single_model_result, session_dir, review_data, options)
925
+ end
926
+
927
+ # Extract feedback items from review reports and save them
928
+ # @param result [Hash] multi-model execution result
929
+ # @param session_dir [String] session directory
930
+ # @param review_data [Hash] review metadata
931
+ # @param options [ReviewOptions, nil] review options
932
+ # @return [Hash, nil] feedback extraction result or nil on failure
933
+ def extract_feedback(result, session_dir, review_data, options)
934
+ require_relative "feedback_manager"
935
+
936
+ # Collect successful report paths
937
+ report_paths = collect_report_paths(result, session_dir)
938
+
939
+ return nil if report_paths.empty?
940
+
941
+ # Determine feedback base path
942
+ base_path = determine_feedback_path(review_data, session_dir)
943
+
944
+ # Build ordered list of models to try: primary + fallbacks
945
+ models_to_try = build_synthesis_model_list(options, review_data)
946
+
947
+ feedback_manager = FeedbackManager.new
948
+ last_error = nil
949
+
950
+ models_to_try.each do |model|
951
+ feedback_result = feedback_manager.extract_and_save(
952
+ report_paths: report_paths,
953
+ base_path: base_path,
954
+ model: model,
955
+ session_dir: File.join(session_dir, "feedback-synthesis")
956
+ )
957
+
958
+ if feedback_result[:success]
959
+ feedback_result[:synthesis_model] = model
960
+ return feedback_result
961
+ end
962
+
963
+ last_error = feedback_result[:error]
964
+ warn "Feedback synthesis failed with #{model}: #{last_error}"
965
+ end
966
+
967
+ # All models failed
968
+ {success: false, error: last_error, models_tried: models_to_try}
969
+ rescue => e
970
+ warn "Feedback extraction error: #{e.message}"
971
+ {success: false, error: e.message}
972
+ end
973
+
974
+ # Build ordered list of synthesis models: primary + fallbacks
975
+ # @param options [ReviewOptions, nil] review options
976
+ # @param review_data [Hash] review metadata
977
+ # @return [Array<String>] ordered list of models to try
978
+ def build_synthesis_model_list(options, review_data)
979
+ primary = options&.feedback_model ||
980
+ Ace::Review.get("feedback", "synthesis_model") ||
981
+ review_data[:model]
982
+
983
+ fallbacks = Ace::Review.get("feedback", "fallback_models") || []
984
+
985
+ [primary, *fallbacks].compact.uniq
986
+ end
987
+
988
+ # Collect report paths for feedback synthesis
989
+ #
990
+ # Collects all successful model reports for FeedbackSynthesizer processing.
991
+ # The synthesizer produces deduplicated findings with reviewer arrays.
992
+ #
993
+ # @param result [Hash] multi-model execution result
994
+ # @param session_dir [String] session directory
995
+ # @return [Array<String>] list of report file paths
996
+ def collect_report_paths(result, session_dir)
997
+ report_paths = []
998
+
999
+ # Add successful model reports
1000
+ result[:results].each do |_, model_result|
1001
+ if model_result[:success] && model_result[:output_file]
1002
+ report_paths << model_result[:output_file]
1003
+ end
1004
+ end
1005
+
1006
+ # Add dev-feedback report if it exists (PR comments)
1007
+ dev_feedback_path = File.join(session_dir, "review-dev-feedback.md")
1008
+ report_paths << dev_feedback_path if File.exist?(dev_feedback_path)
1009
+
1010
+ report_paths.compact.uniq
1011
+ end
1012
+
1013
+ # Determine the base path for feedback storage
1014
+ #
1015
+ # Feedback lives in the session directory under .ace-local/review/sessions/.
1016
+ #
1017
+ # @param review_data [Hash] review metadata (unused, kept for API compatibility)
1018
+ # @param session_dir [String] session directory
1019
+ # @return [String] session directory (feedback lives in session)
1020
+ def determine_feedback_path(review_data, session_dir)
1021
+ session_dir
1022
+ end
1023
+
1024
+ # Build multi-model response with optional feedback info
1025
+ # @param result [Hash] multi-model execution result
1026
+ # @param session_dir [String] session directory
1027
+ # @param feedback_result [Hash, nil] feedback extraction result
1028
+ # @return [Hash] response hash
1029
+ def build_multi_model_response(result, session_dir, feedback_result = nil)
1030
+ successful_models = result[:results].select { |_, r| r[:success] }
1031
+ failed_models = result[:results].reject { |_, r| r[:success] }
1032
+
1033
+ response = {
1034
+ success: true,
1035
+ session_dir: session_dir,
1036
+ summary: result[:summary],
1037
+ models: result[:results].keys,
1038
+ successful_models: successful_models.keys,
1039
+ failed_models: failed_models.keys
1040
+ }
1041
+
1042
+ # Add output files
1043
+ output_files = successful_models.values.map { |r| r[:output_file] }.compact
1044
+ response[:output_files] = output_files
1045
+
1046
+ # Add feedback info
1047
+ if feedback_result && feedback_result[:success]
1048
+ response[:feedback_count] = feedback_result[:items_count]
1049
+ response[:feedback_paths] = feedback_result[:paths]
1050
+ elsif feedback_result && !feedback_result[:success]
1051
+ response[:feedback_error] = feedback_result[:error]
1052
+ end
1053
+
1054
+ response
1055
+ end
1056
+ end
1057
+ end
1058
+ end
1059
+ end