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,494 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require_relative "../atoms/preset_validator"
6
+
7
+ module Ace
8
+ module Review
9
+ module Molecules
10
+ # Manages loading and resolving review presets from configuration
11
+ class PresetManager
12
+ attr_reader :config_path, :config, :project_root
13
+
14
+ # Metadata keys that are added during composition and should be stripped before use
15
+ COMPOSITION_METADATA_KEYS = %w[success composed composed_from].freeze
16
+
17
+ def initialize(config_path: nil, project_root: nil)
18
+ @project_root = project_root || find_project_root
19
+ @config_path = resolve_config_path(config_path)
20
+ @config = load_configuration
21
+ @preset_cache = {} # Final preset cache (after merging with defaults)
22
+ @composition_cache = {} # Intermediate composition cache (before defaults merge)
23
+ end
24
+
25
+ # Load a specific preset by name
26
+ # Cached results are returned immediately to avoid redundant composition
27
+ def load_preset(preset_name)
28
+ return nil unless preset_name
29
+
30
+ # Check cache first (composition can be expensive for deeply nested presets)
31
+ return @preset_cache[preset_name] if @preset_cache.key?(preset_name)
32
+
33
+ # Load with composition support
34
+ result = load_preset_with_composition(preset_name)
35
+
36
+ # Handle composition errors
37
+ unless result && result["success"]
38
+ # Log composition failure for debugging
39
+ if result && result["error"]
40
+ warn "Failed to compose preset '#{preset_name}': #{result["error"]}" if Ace::Review.debug?
41
+ end
42
+ return nil
43
+ end
44
+
45
+ # Extract preset data (remove composition metadata)
46
+ preset = strip_composition_metadata(result)
47
+
48
+ # Merge with defaults and cache
49
+ @preset_cache[preset_name] = merge_with_defaults(preset)
50
+ end
51
+
52
+ # Get list of available preset names
53
+ def available_presets
54
+ presets = []
55
+
56
+ # Add presets from main config
57
+ presets.concat(config_presets) if config
58
+
59
+ # Add presets from preset directory
60
+ presets.concat(file_presets)
61
+
62
+ presets.uniq.sort
63
+ end
64
+
65
+ # Check if a preset exists
66
+ def preset_exists?(preset_name)
67
+ available_presets.include?(preset_name.to_s)
68
+ end
69
+
70
+ # Get the default model from configuration
71
+ def default_model
72
+ config&.dig("defaults", "model") ||
73
+ Ace::Review.get("defaults", "model")
74
+ end
75
+
76
+ # Get the default context from configuration
77
+ def default_context
78
+ config&.dig("defaults", "bundle") ||
79
+ Ace::Review.get("defaults", "bundle")
80
+ end
81
+
82
+ # Get the default output format
83
+ def default_output_format
84
+ config&.dig("defaults", "output_format") ||
85
+ Ace::Review.get("defaults", "output_format") ||
86
+ "markdown"
87
+ end
88
+
89
+ # Resolve a preset configuration into actionable components
90
+ def resolve_preset(preset_name, overrides = {})
91
+ preset = load_preset(preset_name)
92
+ return nil unless preset
93
+
94
+ models_config = resolve_models_config(preset, overrides)
95
+
96
+ # Support both bundle: and context: for context resolution
97
+ preset_context = preset["bundle"] || preset["context"]
98
+
99
+ {
100
+ description: preset["description"],
101
+ # Extract prompt composition for ace-bundle frontmatter (but let ace-bundle process it)
102
+ system_prompt: preset["system_prompt"] || preset["prompt_composition"],
103
+ # Preserve instructions field for section-based context generation
104
+ instructions: preset["instructions"],
105
+ context: resolve_context_config(preset_context, overrides[:context]),
106
+ subject: resolve_subject_config(preset["subject"], overrides[:subject]),
107
+ models: models_config,
108
+ output_format: overrides[:output_format] || preset["output_format"] || default_output_format
109
+ }
110
+ end
111
+
112
+ # Get storage configuration (user config only, no defaults)
113
+ def storage_config
114
+ config&.dig("storage") || {}
115
+ end
116
+
117
+ # Get the base path for storing reviews
118
+ def review_base_path
119
+ # 1. Check for configured path first (user config only)
120
+ configured_path = storage_config["base_path"]
121
+ return expand_path_template(configured_path) if configured_path
122
+
123
+ # 2. Fallback to cache directory
124
+ File.join(project_root, ".ace-local/review/sessions")
125
+ end
126
+
127
+ # Load a preset with composition support
128
+ # Returns fully composed preset data with all dependent presets merged
129
+ # Composition order: base presets first, then composing preset (last wins for scalars)
130
+ # Uses intermediate caching to avoid redundant composition of shared dependencies
131
+ def load_preset_with_composition(name, visited = Set.new)
132
+ start_time = Time.now if Ace::Review.debug?
133
+
134
+ # Check circular dependency first (before cache to prevent caching incomplete compositions)
135
+ validation = Atoms::PresetValidator.check_circular_dependency(name, visited.to_a)
136
+ unless validation[:success]
137
+ return {
138
+ "error" => validation[:error],
139
+ "success" => false
140
+ }
141
+ end
142
+
143
+ # Check composition cache (enables intermediate caching for shared base presets)
144
+ if @composition_cache.key?(name)
145
+ warn "[COMPOSITION] Cache hit for '#{name}'" if Ace::Review.debug?
146
+ return @composition_cache[name].dup
147
+ end
148
+
149
+ # Load preset from file or config
150
+ preset = load_preset_from_file(name) || load_preset_from_config(name)
151
+ unless preset
152
+ return {
153
+ "error" => "Preset '#{name}' not found. Available presets: #{available_presets.join(", ")}",
154
+ "success" => false
155
+ }
156
+ end
157
+
158
+ # Mark this preset as visited
159
+ new_visited = visited.dup.add(name)
160
+
161
+ # Extract preset references
162
+ preset_refs = Atoms::PresetValidator.extract_preset_references(preset)
163
+
164
+ # If no references, return preset as-is
165
+ if preset_refs.empty?
166
+ # Ensure consistent string keys
167
+ result = deep_stringify_keys(preset)
168
+ result["success"] = true
169
+ return result
170
+ end
171
+
172
+ # Load all referenced presets recursively
173
+ composed_presets = []
174
+ errors = []
175
+
176
+ preset_refs.each do |ref_name|
177
+ composed = load_preset_with_composition(ref_name, new_visited)
178
+ if composed["success"]
179
+ composed_presets << composed
180
+ else
181
+ errors << composed["error"]
182
+ end
183
+ end
184
+
185
+ # If there were errors loading dependencies, return error
186
+ if errors.any?
187
+ return {
188
+ "error" => "Failed to load preset dependencies: #{errors.join(", ")}",
189
+ "success" => false,
190
+ "partial_presets" => composed_presets
191
+ }
192
+ end
193
+
194
+ # Strip metadata from composed presets before merging
195
+ clean_composed = composed_presets.map { |p| strip_composition_metadata(p) }
196
+
197
+ # Merge all composed presets with current preset
198
+ # Order: dependencies first, then current preset (last wins for scalars)
199
+ merged = merge_preset_data(clean_composed + [preset])
200
+
201
+ # Ensure consistent string keys and add composition metadata
202
+ merged = deep_stringify_keys(merged)
203
+ merged["success"] = true
204
+ merged["composed"] = true
205
+ merged["composed_from"] = preset_refs + [name]
206
+
207
+ # Cache the composed result for future reuse (enables intermediate caching)
208
+ @composition_cache[name] = merged.dup
209
+
210
+ # Log composition performance metrics in debug mode
211
+ if Ace::Review.debug?
212
+ elapsed = Time.now - start_time
213
+ depth = visited.size + 1
214
+ ref_count = preset_refs.size
215
+ warn "[COMPOSITION] Composed '#{name}' in #{(elapsed * 1000).round(2)}ms (depth: #{depth}, refs: #{ref_count})"
216
+ end
217
+
218
+ merged
219
+ end
220
+
221
+ private
222
+
223
+ # Strip composition metadata from a preset hash (deep recursive)
224
+ # Removes internal keys used for composition tracking: success, composed, composed_from
225
+ # Recursively processes nested hashes and arrays to ensure complete metadata removal
226
+ # @param preset_hash [Hash] Preset data with potential metadata
227
+ # @return [Hash] Preset data without composition metadata at any nesting level
228
+ def strip_composition_metadata(preset_hash)
229
+ result = preset_hash.reject { |k, _| COMPOSITION_METADATA_KEYS.include?(k) }
230
+
231
+ # Recursively strip metadata from nested structures
232
+ result.transform_values do |value|
233
+ case value
234
+ when Hash
235
+ strip_composition_metadata(value)
236
+ when Array
237
+ value.map { |item| item.is_a?(Hash) ? strip_composition_metadata(item) : item }
238
+ else
239
+ value
240
+ end
241
+ end
242
+ end
243
+
244
+ # Merge multiple preset data structures
245
+ # Arrays are concatenated and deduplicated (first occurrence wins)
246
+ # Hashes are deep merged recursively
247
+ # Scalars follow "last wins" strategy
248
+ # Uses centralized DeepMerger from ace-support-config for consistency
249
+ def merge_preset_data(presets)
250
+ return presets.first if presets.size == 1
251
+
252
+ # Use DeepMerger with :union strategy to concatenate and deduplicate arrays
253
+ Ace::Support::Config::Atoms::DeepMerger.merge_all(presets, array_strategy: :union)
254
+ end
255
+
256
+ def find_project_root
257
+ # Use ace-config for project root discovery
258
+ Ace::Support::Config.find_project_root || Dir.pwd
259
+ end
260
+
261
+ def resolve_config_path(custom_path)
262
+ if custom_path
263
+ path = Pathname.new(custom_path)
264
+ return path.absolute? ? custom_path : File.join(project_root, custom_path)
265
+ end
266
+
267
+ # Try review/config.yml first, then fallbacks
268
+ config_patterns = [
269
+ "review/config.yml",
270
+ "review.yml" # Fallback to old naming
271
+ ]
272
+
273
+ config_patterns.each do |pattern|
274
+ path = config_finder.find_file(pattern)
275
+ return path if path
276
+ end
277
+
278
+ # Fallback to .ace/review/config.yml for tests and standalone usage
279
+ fallback_path = File.join(project_root, ".ace/review/config.yml")
280
+ return fallback_path if File.exist?(fallback_path)
281
+
282
+ nil
283
+ end
284
+
285
+ def load_configuration
286
+ return {} unless config_path && File.exist?(config_path)
287
+
288
+ content = File.read(config_path)
289
+ config_data = YAML.safe_load(content, permitted_classes: [Symbol]) || {}
290
+ deep_stringify_keys(config_data)
291
+ rescue => e
292
+ warn "Failed to load configuration from #{config_path}: #{e.message}" if Ace::Review.debug?
293
+ {}
294
+ end
295
+
296
+ def load_preset_from_file(preset_name)
297
+ # Validate preset name for security before any filesystem access
298
+ validation = Atoms::PresetValidator.validate_preset_name(preset_name)
299
+ unless validation[:success]
300
+ raise ArgumentError, validation[:error]
301
+ end
302
+
303
+ # Use ace-config ConfigFinder to find preset in cascade
304
+ preset_file = config_finder.find_file("review/presets/#{preset_name}.yml")
305
+
306
+ if preset_file && File.exist?(preset_file)
307
+ content = File.read(preset_file)
308
+ preset_data = YAML.safe_load(content, permitted_classes: [Symbol])
309
+ return deep_stringify_keys(preset_data)
310
+ end
311
+
312
+ # Fallback to .ace/review/presets for tests and standalone usage
313
+ preset_dir = File.join(project_root, ".ace/review/presets")
314
+ preset_file = File.join(preset_dir, "#{preset_name}.yml")
315
+
316
+ if File.exist?(preset_file)
317
+ content = File.read(preset_file)
318
+ preset_data = YAML.safe_load(content, permitted_classes: [Symbol])
319
+ return deep_stringify_keys(preset_data)
320
+ end
321
+
322
+ nil
323
+ rescue ArgumentError
324
+ # Re-raise validation errors (don't suppress security checks)
325
+ raise
326
+ rescue => e
327
+ warn "Failed to load preset from #{preset_name}: #{e.message}" if Ace::Review.debug?
328
+ nil
329
+ end
330
+
331
+ def load_preset_from_config(preset_name)
332
+ return nil unless config && config["presets"]
333
+ config["presets"][preset_name.to_s]
334
+ end
335
+
336
+ def config_presets
337
+ config["presets"]&.keys || []
338
+ end
339
+
340
+ def file_presets
341
+ presets = []
342
+
343
+ preset_dirs.each do |preset_dir|
344
+ next unless Dir.exist?(preset_dir)
345
+
346
+ Dir.glob(File.join(preset_dir, "*.yml")).sort.each do |file|
347
+ presets << File.basename(file, ".yml")
348
+ end
349
+ end
350
+
351
+ presets.uniq
352
+ rescue => e
353
+ warn "Failed to find preset files: #{e.message}" if Ace::Review.debug?
354
+ []
355
+ end
356
+
357
+ def config_finder
358
+ @config_finder ||= Ace::Support::Config::Molecules::ConfigFinder.new(
359
+ config_dir: ".ace",
360
+ defaults_dir: ".ace-defaults",
361
+ gem_path: gem_root,
362
+ start_path: project_root
363
+ )
364
+ end
365
+
366
+ def preset_dirs
367
+ dirs = config_finder.search_paths.map { |dir| File.join(dir, "review/presets") }
368
+ dirs << File.join(gem_root, ".ace-defaults", "review", "presets")
369
+ dirs.uniq
370
+ end
371
+
372
+ def gem_root
373
+ @gem_root ||= Gem.loaded_specs["ace-review"]&.gem_dir ||
374
+ File.expand_path("../../../..", __dir__)
375
+ end
376
+
377
+ def merge_with_defaults(preset)
378
+ defaults = config&.dig("defaults") || {}
379
+ deep_merge(defaults, preset)
380
+ end
381
+
382
+ def deep_merge(base, override)
383
+ return override unless base.is_a?(Hash) && override.is_a?(Hash)
384
+
385
+ base.merge(override) do |_key, base_val, override_val|
386
+ deep_merge(base_val, override_val)
387
+ end
388
+ end
389
+
390
+ def resolve_system_prompt_composition(composition, overrides)
391
+ return {} unless composition
392
+
393
+ result = composition.dup
394
+
395
+ # Apply overrides
396
+ result["base"] = overrides[:prompt_base] if overrides[:prompt_base]
397
+ result["format"] = overrides[:prompt_format] if overrides[:prompt_format]
398
+
399
+ if overrides[:prompt_focus]
400
+ result["focus"] = overrides[:prompt_focus].split(",").map(&:strip)
401
+ elsif overrides[:add_focus]
402
+ result["focus"] ||= []
403
+ result["focus"].concat(overrides[:add_focus].split(",").map(&:strip))
404
+ result["focus"].uniq!
405
+ end
406
+
407
+ if overrides[:prompt_guidelines]
408
+ result["guidelines"] = overrides[:prompt_guidelines].split(",").map(&:strip)
409
+ end
410
+
411
+ result
412
+ end
413
+
414
+ def resolve_context_config(preset_context, override_context)
415
+ return override_context if override_context
416
+ preset_context || default_context
417
+ end
418
+
419
+ def resolve_subject_config(preset_subject, override_subject)
420
+ return override_subject if override_subject
421
+ preset_subject
422
+ end
423
+
424
+ # Resolve models configuration
425
+ # Priority: override models > preset models > default
426
+ def resolve_models_config(preset, overrides)
427
+ # If override provides models array, use it
428
+ if overrides[:models].is_a?(Array) && overrides[:models].any?
429
+ return overrides[:models]
430
+ end
431
+
432
+ # If preset has models array, use it
433
+ if preset["models"].is_a?(Array) && preset["models"].any?
434
+ return preset["models"]
435
+ end
436
+
437
+ # Fallback to default model
438
+ [default_model]
439
+ end
440
+
441
+ def current_release
442
+ "v.0.0.0"
443
+ end
444
+
445
+ def get_release_path
446
+ nil
447
+ end
448
+
449
+ def expand_path_template(template)
450
+ return template unless template
451
+
452
+ # Keep existing %{release} expansion if user configured it
453
+ release = current_release
454
+ template.gsub("%{release}", release)
455
+ end
456
+
457
+ # Recursively convert all hash keys to strings
458
+ #
459
+ # YAML.safe_load with permitted_classes: [Symbol] can return hashes with
460
+ # both string and symbol keys. This normalizes all keys to strings for
461
+ # consistent access patterns throughout the codebase.
462
+ #
463
+ # @param value [Object] Value to stringify (Hash, Array, or other)
464
+ # @return [Object] Value with all hash keys stringified
465
+ #
466
+ # @example Simple hash
467
+ # deep_stringify_keys({a: 1, b: 2})
468
+ # #=> {"a" => 1, "b" => 2}
469
+ #
470
+ # @example Nested hash
471
+ # deep_stringify_keys({a: {b: {c: 1}}})
472
+ # #=> {"a" => {"b" => {"c" => 1}}}
473
+ #
474
+ # @example Hash in array
475
+ # deep_stringify_keys([{a: 1}, {b: 2}])
476
+ # #=> [{"a" => 1}, {"b" => 2}]
477
+ #
478
+ # @api private
479
+ def deep_stringify_keys(value)
480
+ case value
481
+ when Hash
482
+ value.each_with_object({}) do |(k, v), result|
483
+ result[k.to_s] = deep_stringify_keys(v)
484
+ end
485
+ when Array
486
+ value.map { |v| deep_stringify_keys(v) }
487
+ else
488
+ value
489
+ end
490
+ end
491
+ end
492
+ end
493
+ end
494
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Molecules
6
+ # Composes final prompt from modular components
7
+ class PromptComposer
8
+ attr_reader :resolver
9
+
10
+ def initialize(resolver: nil)
11
+ @resolver = resolver || NavPromptResolver.new
12
+ end
13
+
14
+ # Compose a full prompt from composition configuration
15
+ # @param composition [Hash] prompt composition with base, format, focus, guidelines
16
+ # @param config_dir [String] directory for relative path resolution
17
+ # @return [String] composed prompt
18
+ def compose(composition, config_dir: nil)
19
+ return "" unless composition
20
+
21
+ sections = []
22
+
23
+ # Add base prompt (required)
24
+ if composition["base"]
25
+ base_content = resolver.resolve(composition["base"], config_dir: config_dir)
26
+ sections << base_content if base_content
27
+ end
28
+
29
+ # Add format section
30
+ if composition["format"]
31
+ format_content = resolver.resolve(composition["format"], config_dir: config_dir)
32
+ sections << wrap_section("Output Format", format_content) if format_content
33
+ end
34
+
35
+ # Add focus modules (can be multiple)
36
+ if composition["focus"] && !composition["focus"].empty?
37
+ focus_contents = composition["focus"].map do |focus_ref|
38
+ resolver.resolve(focus_ref, config_dir: config_dir)
39
+ end.compact
40
+
41
+ unless focus_contents.empty?
42
+ combined_focus = focus_contents.join("\n\n---\n\n")
43
+ sections << wrap_section("Review Focus", combined_focus)
44
+ end
45
+ end
46
+
47
+ # Add guidelines
48
+ if composition["guidelines"] && !composition["guidelines"].empty?
49
+ guideline_contents = composition["guidelines"].map do |guideline_ref|
50
+ resolver.resolve(guideline_ref, config_dir: config_dir)
51
+ end.compact
52
+
53
+ unless guideline_contents.empty?
54
+ combined_guidelines = guideline_contents.join("\n\n")
55
+ sections << wrap_section("Guidelines", combined_guidelines)
56
+ end
57
+ end
58
+
59
+ sections.join("\n\n")
60
+ end
61
+
62
+ private
63
+
64
+ def wrap_section(title, content)
65
+ return "" unless content && !content.strip.empty?
66
+
67
+ <<~SECTION
68
+ ## #{title}
69
+
70
+ #{content}
71
+ SECTION
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end