ace-bundle 0.40.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/bundle/config.yml +28 -0
  3. data/.ace-defaults/bundle/presets/base.md +15 -0
  4. data/.ace-defaults/bundle/presets/code-review.md +61 -0
  5. data/.ace-defaults/bundle/presets/development.md +16 -0
  6. data/.ace-defaults/bundle/presets/documentation-review.md +52 -0
  7. data/.ace-defaults/bundle/presets/mixed-content-example.md +94 -0
  8. data/.ace-defaults/bundle/presets/project-context.md +79 -0
  9. data/.ace-defaults/bundle/presets/project.md +35 -0
  10. data/.ace-defaults/bundle/presets/section-example-simple.md +27 -0
  11. data/.ace-defaults/bundle/presets/security-review.md +53 -0
  12. data/.ace-defaults/bundle/presets/simple-project.md +43 -0
  13. data/.ace-defaults/bundle/presets/team.md +18 -0
  14. data/.ace-defaults/nav/protocols/wfi-sources/ace-bundle.yml +19 -0
  15. data/CHANGELOG.md +384 -0
  16. data/LICENSE +21 -0
  17. data/README.md +40 -0
  18. data/Rakefile +22 -0
  19. data/exe/ace-bundle +14 -0
  20. data/handbook/skills/as-bundle/SKILL.md +28 -0
  21. data/handbook/skills/as-onboard/SKILL.md +33 -0
  22. data/handbook/workflow-instructions/bundle.wf.md +111 -0
  23. data/handbook/workflow-instructions/onboard.wf.md +20 -0
  24. data/lib/ace/bundle/atoms/boundary_finder.rb +122 -0
  25. data/lib/ace/bundle/atoms/bundle_normalizer.rb +128 -0
  26. data/lib/ace/bundle/atoms/content_checker.rb +46 -0
  27. data/lib/ace/bundle/atoms/line_counter.rb +37 -0
  28. data/lib/ace/bundle/atoms/preset_list_formatter.rb +44 -0
  29. data/lib/ace/bundle/atoms/preset_validator.rb +69 -0
  30. data/lib/ace/bundle/atoms/section_validator.rb +215 -0
  31. data/lib/ace/bundle/atoms/typo_detector.rb +76 -0
  32. data/lib/ace/bundle/cli/commands/load.rb +347 -0
  33. data/lib/ace/bundle/cli.rb +26 -0
  34. data/lib/ace/bundle/models/bundle_data.rb +75 -0
  35. data/lib/ace/bundle/molecules/bundle_chunker.rb +280 -0
  36. data/lib/ace/bundle/molecules/bundle_file_writer.rb +269 -0
  37. data/lib/ace/bundle/molecules/bundle_merger.rb +248 -0
  38. data/lib/ace/bundle/molecules/preset_manager.rb +331 -0
  39. data/lib/ace/bundle/molecules/section_compressor.rb +249 -0
  40. data/lib/ace/bundle/molecules/section_formatter.rb +580 -0
  41. data/lib/ace/bundle/molecules/section_processor.rb +460 -0
  42. data/lib/ace/bundle/organisms/bundle_loader.rb +1436 -0
  43. data/lib/ace/bundle/organisms/pr_bundle_loader.rb +147 -0
  44. data/lib/ace/bundle/version.rb +7 -0
  45. data/lib/ace/bundle.rb +251 -0
  46. metadata +190 -0
@@ -0,0 +1,1436 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "date"
5
+ require "ace/core"
6
+ require_relative "../molecules/bundle_merger"
7
+ require "ace/core/molecules/file_aggregator"
8
+ require "ace/core/molecules/output_formatter"
9
+ require "ace/support/fs"
10
+ require "ace/core/atoms/command_executor"
11
+ require "ace/core/atoms/template_parser"
12
+ require "ace/core/atoms/file_reader"
13
+ require "ace/git"
14
+ require_relative "../molecules/preset_manager"
15
+ require_relative "../molecules/section_processor"
16
+ require_relative "../molecules/section_formatter"
17
+ require_relative "../molecules/section_compressor"
18
+ require_relative "pr_bundle_loader"
19
+ require_relative "../models/bundle_data"
20
+ require_relative "../atoms/content_checker"
21
+ require_relative "../atoms/typo_detector"
22
+
23
+ module Ace
24
+ module Bundle
25
+ module Organisms
26
+ # Main bundle loader that orchestrates preset loading using ace-core components
27
+ class BundleLoader
28
+ def initialize(options = {})
29
+ @options = options
30
+ @template_dir = nil
31
+ @preset_manager = Molecules::PresetManager.new
32
+ @section_processor = Molecules::SectionProcessor.new
33
+ @merger = Molecules::BundleMerger.new
34
+ @file_aggregator = Ace::Core::Molecules::FileAggregator.new(
35
+ max_size: options[:max_size],
36
+ base_dir: options[:base_dir] || project_root
37
+ )
38
+ @command_executor = Ace::Core::Atoms::CommandExecutor
39
+ @output_formatter = Ace::Core::Molecules::OutputFormatter.new(
40
+ options[:format] || "markdown-xml"
41
+ )
42
+ end
43
+
44
+ def load_preset(preset_name)
45
+ # Use composition-aware loading
46
+ preset = @preset_manager.load_preset_with_composition(preset_name)
47
+
48
+ # Handle errors from composition loading
49
+ unless preset[:success]
50
+ return Models::BundleData.new(
51
+ preset_name: preset_name,
52
+ metadata: {error: preset[:error]}
53
+ )
54
+ end
55
+
56
+ # Merge params into options for processing
57
+ params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
58
+ merged_options = @options.merge(params)
59
+
60
+ # Process the preset bundle configuration
61
+ begin
62
+ bundle = load_from_preset_config(preset, merged_options)
63
+ rescue Ace::Bundle::PresetLoadError => e
64
+ # Handle errors from top-level preset processing (fail-fast behavior)
65
+ return Models::BundleData.new(
66
+ preset_name: preset_name,
67
+ metadata: {error: e.message}
68
+ )
69
+ end
70
+ bundle.metadata[:preset_name] = preset_name
71
+ bundle.metadata[:output] = preset[:output] # Store default output mode
72
+ bundle.metadata[:compressor_mode] = preset[:compressor_mode] if preset[:compressor_mode]
73
+ bundle.metadata[:compressor_source_scope] = preset[:compressor_source_scope] if preset[:compressor_source_scope]
74
+
75
+ # Add composition metadata if preset was composed
76
+ if preset[:composed]
77
+ bundle.metadata[:composed] = true
78
+ bundle.metadata[:composed_from] = preset[:composed_from]
79
+ end
80
+
81
+ # Determine format - respect explicit format requests but default to markdown-xml for embedded sources
82
+ # Check for explicit format request in preset or params
83
+ explicit_format = preset[:format] || params["format"] || params[:format] || merged_options[:format]
84
+
85
+ format = if explicit_format
86
+ # Use the explicitly requested format
87
+ explicit_format
88
+ elsif preset.dig(:context, "embed_document_source")
89
+ # Default to markdown-xml format when embed_document_source is true and no explicit format requested
90
+ "markdown-xml"
91
+ else
92
+ # Fallback to markdown
93
+ "markdown"
94
+ end
95
+ format_bundle(bundle, format)
96
+
97
+ bundle
98
+ end
99
+
100
+ def load_file(path)
101
+ # Check if it's a template file
102
+ content = begin
103
+ File.read(path)
104
+ rescue
105
+ nil
106
+ end
107
+
108
+ # Treat as template if:
109
+ # 1. TemplateParser recognizes it as a template, OR
110
+ # 2. It has YAML frontmatter (starts with ---)
111
+ is_template = content && (
112
+ Ace::Core::Atoms::TemplateParser.template?(content) ||
113
+ content.start_with?("---")
114
+ )
115
+
116
+ if is_template
117
+ # Parse as template
118
+ load_template(path)
119
+ else
120
+ # Load as regular file
121
+ max_size = @options[:max_size] || Ace::Core::Atoms::FileReader::MAX_FILE_SIZE
122
+ result = Ace::Core::Atoms::FileReader.read(path, max_size: max_size)
123
+
124
+ bundle = Models::BundleData.new
125
+ if result[:success]
126
+ # Plain file inputs should emit readable content to stdout.
127
+ # Keep content as primary payload and record source metadata.
128
+ bundle.content = result[:content]
129
+ bundle.metadata[:raw_content_for_auto_format] = bundle.content
130
+ bundle.metadata[:source] = path
131
+ else
132
+ bundle.metadata[:error] = result[:error]
133
+ end
134
+
135
+ compress_bundle_sections(bundle)
136
+ bundle
137
+ end
138
+ end
139
+
140
+ def load_multiple_presets(preset_names)
141
+ bundles = []
142
+ warnings = []
143
+
144
+ preset_names.each do |preset_name|
145
+ # Use composition-aware loading for each preset
146
+ preset = @preset_manager.load_preset_with_composition(preset_name)
147
+
148
+ if preset[:success]
149
+ params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
150
+ merged_options = @options.merge(params)
151
+ bundle = load_from_preset_config(preset, merged_options)
152
+ bundle.metadata[:preset_name] = preset_name
153
+ bundle.metadata[:output] = preset[:output] # Store preset's output mode
154
+
155
+ # Add composition metadata if preset was composed
156
+ if preset[:composed]
157
+ bundle.metadata[:composed] = true
158
+ bundle.metadata[:composed_from] = preset[:composed_from]
159
+ end
160
+
161
+ bundles << bundle
162
+ else
163
+ # Log warning but continue with other presets
164
+ warnings << "Warning: #{preset[:error]}"
165
+ warn "Warning: #{preset[:error]}" if @options[:debug]
166
+ end
167
+ end
168
+
169
+ # If no successful presets loaded, return error
170
+ if bundles.empty?
171
+ error_bundle = Models::BundleData.new(
172
+ metadata: {
173
+ error: "No valid presets loaded",
174
+ warnings: warnings
175
+ }
176
+ )
177
+ return error_bundle
178
+ end
179
+
180
+ # Merge all bundles
181
+ merged = merge_bundles(bundles)
182
+ merged.metadata[:warnings] = warnings if warnings.any?
183
+
184
+ merged
185
+ end
186
+
187
+ # Inspect configuration without loading files or executing commands
188
+ # Returns a ContextData with just the merged configuration as YAML
189
+ def inspect_config(inputs)
190
+ require "yaml"
191
+
192
+ # Load all inputs (presets and files) with composition
193
+ configs = []
194
+ warnings = []
195
+
196
+ inputs.each do |input|
197
+ # Auto-detect if it's a file or preset
198
+ if File.exist?(input)
199
+ # Load as file
200
+ begin
201
+ # Read file and parse config
202
+ content = File.read(input)
203
+ config = {}
204
+
205
+ if input.match?(/\.ya?ml$/i)
206
+ # Date is permitted so bundle/frontmatter configs can round-trip
207
+ # ace-docs date-only fields without coercing them to strings.
208
+ config = YAML.safe_load(content, aliases: true, permitted_classes: [Symbol, Date]) || {}
209
+ elsif has_frontmatter?(input)
210
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n/m
211
+ # Keep Date aligned with YAML file parsing above for frontmatter
212
+ # sources that include ace-docs date-only metadata.
213
+ frontmatter = YAML.safe_load($1, aliases: true, permitted_classes: [Symbol, Date]) || {}
214
+ config = unwrap_bundle_config(frontmatter)
215
+ end
216
+ end
217
+
218
+ # Handle preset composition if file references presets
219
+ preset_refs = config["presets"] || config[:presets]
220
+ if preset_refs && !preset_refs.empty?
221
+ # Load all referenced presets first
222
+ preset_bundles = []
223
+ preset_refs.each do |preset_name|
224
+ preset = @preset_manager.load_preset_with_composition(preset_name)
225
+ if preset[:success]
226
+ preset_bundles << {bundle: preset[:bundle]}
227
+ else
228
+ warnings << "Failed to load preset '#{preset_name}' from file #{input}"
229
+ end
230
+ end
231
+
232
+ # Merge all presets + file config (file config last = file wins)
233
+ # Order: preset1, preset2, ..., file config
234
+ if preset_bundles.any?
235
+ merged = @preset_manager.send(:merge_preset_data, preset_bundles + [{bundle: config}])
236
+ config = merged[:bundle]
237
+ end
238
+
239
+ # Remove presets key from final config
240
+ config.delete("presets")
241
+ config.delete(:presets)
242
+ end
243
+
244
+ configs << {
245
+ success: true,
246
+ bundle: config,
247
+ name: File.basename(input),
248
+ source_file: input
249
+ }
250
+ rescue => e
251
+ warnings << "Failed to load file #{input}: #{e.message}"
252
+ end
253
+ else
254
+ # Load as preset
255
+ preset = @preset_manager.load_preset_with_composition(input)
256
+ if preset[:success]
257
+ configs << preset
258
+ else
259
+ warnings << preset[:error]
260
+ end
261
+ end
262
+ end
263
+
264
+ # If no successful configs, return error
265
+ if configs.empty?
266
+ bundle = Models::BundleData.new
267
+ bundle.metadata[:error] = "No valid inputs loaded"
268
+ bundle.metadata[:warnings] = warnings
269
+ return bundle
270
+ end
271
+
272
+ # Merge configurations (just the config, not content)
273
+ merged_config = merge_preset_configurations(configs)
274
+
275
+ # Add warnings if any
276
+ merged_config[:warnings] = warnings if warnings.any?
277
+
278
+ # Format as YAML
279
+ yaml_output = YAML.dump(merged_config)
280
+
281
+ # Create bundle with YAML content
282
+ bundle = Models::BundleData.new
283
+ bundle.content = yaml_output
284
+ bundle.metadata[:inspect_mode] = true
285
+ bundle.metadata[:inputs] = inputs
286
+
287
+ bundle
288
+ end
289
+
290
+ def load_multiple(inputs)
291
+ bundles = []
292
+
293
+ inputs.each do |input|
294
+ bundle = load_auto(input)
295
+ bundle.metadata[:source_input] = input
296
+ bundles << bundle
297
+ end
298
+
299
+ # Merge all bundles
300
+ merge_bundles(bundles)
301
+ end
302
+
303
+ # Load multiple inputs (presets and files) and merge them
304
+ # Maintains order of specification to allow proper override semantics
305
+ def load_multiple_inputs(preset_names, file_paths, options = {})
306
+ bundles = []
307
+ warnings = []
308
+
309
+ # Process presets
310
+ preset_names.each do |preset_name|
311
+ # Use composition-aware loading for each preset
312
+ preset = @preset_manager.load_preset_with_composition(preset_name)
313
+
314
+ if preset[:success]
315
+ params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
316
+ merged_options = @options.merge(params)
317
+ bundle = load_from_preset_config(preset, merged_options)
318
+ bundle.metadata[:preset_name] = preset_name
319
+ bundle.metadata[:source_type] = "preset"
320
+ bundle.metadata[:output] = preset[:output] # Store preset's output mode
321
+
322
+ # Add composition metadata if preset was composed
323
+ if preset[:composed]
324
+ bundle.metadata[:composed] = true
325
+ bundle.metadata[:composed_from] = preset[:composed_from]
326
+ end
327
+
328
+ bundles << bundle
329
+ else
330
+ # Log warning but continue with other inputs
331
+ warnings << "Warning: #{preset[:error]}"
332
+ warn "Warning: #{preset[:error]}" if @options[:debug]
333
+ end
334
+ end
335
+
336
+ # Process files
337
+ file_paths.each do |file_path|
338
+ bundle = load_file(file_path)
339
+ bundle.metadata[:source_type] = "file"
340
+ bundle.metadata[:source_path] = file_path
341
+ bundles << bundle
342
+ rescue => e
343
+ warnings << "Warning: Failed to load file #{file_path}: #{e.message}"
344
+ warn "Warning: Failed to load file #{file_path}: #{e.message}" if @options[:debug]
345
+ end
346
+
347
+ # Return error if all inputs failed
348
+ if bundles.empty? && warnings.any?
349
+ return Models::BundleData.new.tap do |c|
350
+ c.metadata[:error] = "Failed to load any inputs"
351
+ c.metadata[:errors] = warnings
352
+ c.content = warnings.join("\n")
353
+ end
354
+ end
355
+
356
+ # Merge all bundles (with proper order for overrides)
357
+ merged_bundle = merge_bundles(bundles)
358
+
359
+ # Add warnings to metadata if any
360
+ merged_bundle.metadata[:warnings] = warnings if warnings.any?
361
+
362
+ merged_bundle
363
+ end
364
+
365
+ def load_auto(input)
366
+ # Auto-detect input type
367
+ # Strip whitespace to handle CLI arguments properly
368
+ input = input.strip
369
+
370
+ # Check for protocol first (e.g., wfi://, guide://, task://)
371
+ if input.match?(/\A[\w-]+:\/\//)
372
+ return load_protocol(input)
373
+ end
374
+
375
+ if File.exist?(input)
376
+ # It's a file
377
+ load_file(input)
378
+ elsif input.match?(/\A[\w-]+\z/)
379
+ # Looks like a preset name
380
+ load_preset(input)
381
+ elsif input.include?("files:") || input.include?("commands:") || input.include?("include:") || input.include?("diffs:") || input.include?("presets:") || input.include?("pr:")
382
+ # Looks like inline YAML
383
+ load_inline_yaml(input)
384
+ elsif File.exist?(input)
385
+ # Try as file first, then preset
386
+ load_file(input)
387
+ else
388
+ load_preset(input)
389
+ end
390
+ end
391
+
392
+ def load_inline_yaml(yaml_string)
393
+ require "yaml"
394
+ # Inline bundle YAML accepts Date for the same reason as file/frontmatter
395
+ # loading: ace-docs date-only metadata may deserialize to Date.
396
+ config = YAML.safe_load(yaml_string, aliases: true, permitted_classes: [Symbol, Date])
397
+ # Unwrap 'bundle' key if present (typed subjects use nested structure)
398
+ # This allows both flat configs (diffs: [...]) and nested (bundle: { diffs: [...] })
399
+ template_config = unwrap_bundle_config(config)
400
+ bundle = process_template_config(template_config)
401
+ # Process PR references if present (uses same unwrapped config)
402
+ pr_processed = process_pr_config(bundle, template_config, @options)
403
+ # Re-format bundle if PR processing added sections
404
+ # Note: process_template_config already formats files/diffs/commands into bundle.content
405
+ # We only need to re-format if process_pr_config added new sections (PR diffs)
406
+ # If PR had no changes or failed, has_sections? returns false and we keep existing content
407
+ if bundle.has_sections? || pr_processed
408
+ format = config["format"] || @options[:format] || "markdown-xml"
409
+ format_bundle(bundle, format)
410
+ end
411
+ bundle
412
+ rescue => e
413
+ bundle = Models::BundleData.new
414
+ bundle.metadata[:error] = "Failed to parse inline YAML: #{e.message}"
415
+ bundle
416
+ end
417
+
418
+ def load_template(path)
419
+ # Track the template file's directory for resolving ./ relative paths
420
+ @template_dir = File.dirname(File.expand_path(path))
421
+
422
+ # Read template file (preserve original for workflow fallback)
423
+ original_content = File.read(path)
424
+ template_content = original_content
425
+
426
+ # Extract and strip frontmatter if present
427
+ frontmatter = {}
428
+ frontmatter_yaml = nil
429
+ if template_content =~ /\A---\s*\n(.*?)\n---\s*\n/m
430
+ frontmatter_text = $1
431
+ frontmatter_yaml = frontmatter_text # Store original YAML for output
432
+ begin
433
+ require "yaml"
434
+ # Workflow/template frontmatter can include date-only ace-docs fields,
435
+ # so Date remains an explicit safe-load allowance here as well.
436
+ frontmatter = YAML.safe_load(frontmatter_text, aliases: true, permitted_classes: [Symbol, Date]) || {}
437
+ frontmatter = {} unless frontmatter.is_a?(Hash)
438
+ # Remove frontmatter from content for processing
439
+ template_content = template_content.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
440
+ rescue Psych::SyntaxError
441
+ # Invalid YAML, ignore frontmatter
442
+ frontmatter_yaml = nil
443
+ end
444
+ end
445
+
446
+ # Check if frontmatter contains config directly (via 'bundle' key or template config keys)
447
+ # This is the newer pattern for workflow files
448
+ if frontmatter["bundle"].is_a?(Hash) ||
449
+ (frontmatter.keys & %w[preset presets files commands include exclude diffs]).any?
450
+ # Use frontmatter as the main config
451
+ config = unwrap_bundle_config(frontmatter)
452
+
453
+ # Merge params into options if present
454
+ params = config["params"]
455
+ if params.is_a?(Hash)
456
+ @options = @options.merge(params)
457
+ end
458
+
459
+ # Handle preset/presets keys from frontmatter
460
+ preset_names = []
461
+ if frontmatter["preset"] && !frontmatter["preset"].to_s.strip.empty?
462
+ preset_names << frontmatter["preset"].to_s.strip
463
+ end
464
+ if frontmatter["presets"] && frontmatter["presets"].is_a?(Array)
465
+ preset_names += frontmatter["presets"].compact.map(&:to_s).map(&:strip)
466
+ end
467
+
468
+ if preset_names.any?
469
+ existing_presets = config["presets"] || []
470
+ config["presets"] = preset_names + existing_presets
471
+ end
472
+
473
+ # Apply CLI overrides to config (CLI takes precedence)
474
+ config = apply_cli_overrides(config)
475
+
476
+ # Process presets from frontmatter
477
+ preset_error = nil
478
+ preset_names_loaded = []
479
+ if config["presets"] && config["presets"].any?
480
+ begin
481
+ preset_names_loaded = config["presets"].dup
482
+ config = process_top_level_presets(config)
483
+ rescue Ace::Bundle::PresetLoadError => e
484
+ preset_error = e.message
485
+ warn "Warning: #{e.message}" if @options[:debug]
486
+ config.delete("presets")
487
+ config.delete(:presets)
488
+ end
489
+ end
490
+
491
+ # Process the config (loads embedded files from bundle.files)
492
+ bundle = process_template_config(config)
493
+
494
+ bundle.metadata[:presets] = preset_names_loaded if preset_names_loaded.any?
495
+ bundle.metadata[:preset_error] = preset_error if preset_error
496
+
497
+ # Process base content if present (for template files with context.base)
498
+ process_base_content(bundle, config, @options)
499
+
500
+ # Process PR references (context.pr)
501
+ process_pr_config(bundle, config, @options)
502
+
503
+ # Process sections if present (same as preset loading)
504
+ preset_like_config = {"bundle" => config}
505
+ if @section_processor.has_sections?(preset_like_config)
506
+ sections = @section_processor.process_sections(preset_like_config, @preset_manager)
507
+ bundle.sections = sections
508
+
509
+ # Process content for each section
510
+ sections.each do |section_name, section_data|
511
+ process_section_content(bundle, section_name, section_data, @options, config)
512
+ end
513
+ end
514
+
515
+ # Track base resolution before metadata reset (metadata gets replaced below)
516
+ resolved = bundle.metadata[:base_type] ? bundle.content : nil
517
+ base_content_resolved = resolved.to_s.strip.empty? ? nil : resolved
518
+
519
+ # Replace metadata with original frontmatter (keep it unmodified)
520
+ # Convert string keys to symbols for consistency
521
+ bundle.metadata = {}
522
+ frontmatter.each do |key, value|
523
+ bundle.metadata[key.to_sym] = value
524
+ end
525
+ # Store original YAML for output formatting
526
+ bundle.metadata[:frontmatter_yaml] = frontmatter_yaml if frontmatter_yaml
527
+
528
+ # If embed_document_source is true, store original document and keep embedded files separate
529
+ if config["embed_document_source"]
530
+ # base replaces the source document for embedding
531
+ bundle.content = base_content_resolved || original_content
532
+
533
+ # bundle.files already has embedded files from process_template_config
534
+ # Don't add source to files array - it will be output as raw content
535
+
536
+ # Format and return
537
+ format = config["format"] || @options[:format] || "markdown-xml"
538
+ return format_bundle(bundle, format)
539
+ end
540
+
541
+ # Format bundle before returning (same as preset loading)
542
+ format = config["format"] || @options[:format] || "markdown-xml"
543
+ format_bundle(bundle, format)
544
+
545
+ return bundle
546
+ end
547
+
548
+ # Check if this is plain markdown with metadata-only frontmatter
549
+ # (e.g., workflow files with description/allowed-tools but no context config)
550
+ if frontmatter.any?
551
+ return load_plain_markdown(original_content, frontmatter, path)
552
+ end
553
+
554
+ # Otherwise, parse template configuration from body
555
+ parse_result = Ace::Core::Atoms::TemplateParser.parse(template_content)
556
+
557
+ unless parse_result[:success]
558
+ bundle = Models::BundleData.new
559
+ bundle.metadata[:error] = parse_result[:error]
560
+ return bundle
561
+ end
562
+
563
+ config = parse_result[:config]
564
+
565
+ # Merge frontmatter into config (frontmatter has lower priority)
566
+ config = frontmatter.merge(config) if frontmatter.any?
567
+
568
+ # Process files and commands from template
569
+ bundle = process_template_config(config)
570
+
571
+ # Add frontmatter to metadata for reference
572
+ bundle.metadata[:frontmatter] = frontmatter if frontmatter.any?
573
+
574
+ bundle
575
+ end
576
+
577
+ def load_from_config(config)
578
+ # If config has a template path, load from template instead
579
+ if config[:template] && File.exist?(config[:template])
580
+ return load_template(config[:template])
581
+ end
582
+
583
+ bundle = Models::BundleData.new(
584
+ preset_name: config[:name],
585
+ metadata: config[:metadata] || {}
586
+ )
587
+
588
+ # Use file aggregator for include patterns
589
+ if config[:include] && config[:include].any?
590
+ aggregator = Ace::Core::Molecules::FileAggregator.new(
591
+ max_size: @options[:max_size],
592
+ base_dir: @options[:base_dir] || project_root,
593
+ exclude: config[:exclude] || []
594
+ )
595
+
596
+ result = aggregator.aggregate(config[:include])
597
+
598
+ # Add files to bundle
599
+ result[:files].each do |file_info|
600
+ bundle.add_file(file_info[:path], file_info[:content])
601
+ end
602
+
603
+ # Add errors if any
604
+ result[:errors].each do |error|
605
+ bundle.metadata[:errors] ||= []
606
+ bundle.metadata[:errors] << error
607
+ end
608
+ end
609
+
610
+ # Format output
611
+ format_bundle(bundle, config[:format])
612
+ end
613
+
614
+ def load_from_preset_config(preset, options)
615
+ bundle_config = preset[:bundle] || {}
616
+
617
+ # Apply CLI overrides to context config (CLI takes precedence)
618
+ bundle_config = apply_cli_overrides(bundle_config)
619
+
620
+ # Process top-level preset references (context.presets)
621
+ # This merges files, commands, and params from referenced presets
622
+ bundle_config = process_top_level_presets(bundle_config)
623
+
624
+ preset[:bundle] = bundle_config
625
+
626
+ bundle = Models::BundleData.new(
627
+ preset_name: preset[:name],
628
+ metadata: preset[:metadata] || {}
629
+ )
630
+
631
+ # Process base content if present
632
+ process_base_content(bundle, bundle_config, options)
633
+
634
+ # Process sections (legacy non-section preset formats are no longer supported)
635
+ raise Ace::Bundle::PresetLoadError, "Preset '#{preset[:name]}' must define bundle sections" unless @section_processor.has_sections?(preset)
636
+
637
+ sections = @section_processor.process_sections(preset, @preset_manager)
638
+ bundle.sections = sections
639
+
640
+ # Process content for each section
641
+ sections.each do |section_name, section_data|
642
+ process_section_content(bundle, section_name, section_data, options, bundle_config)
643
+ end
644
+
645
+ # Process top-level PR references
646
+ # Called after section processing so PR diffs merge into existing sections
647
+ process_pr_config(bundle, bundle_config, options)
648
+
649
+ # If embed_document_source is true, set content to trigger XML formatting
650
+ if bundle_config["embed_document_source"] && preset[:body] && !preset[:body].empty?
651
+ bundle.content = preset[:body]
652
+ elsif preset[:body] && !preset[:body].empty?
653
+ # Add preset body to metadata (old behavior for non-embedded)
654
+ bundle.metadata[:preset_content] = preset[:body]
655
+ end
656
+
657
+ bundle
658
+ end
659
+
660
+ # Compose a file configuration with referenced presets
661
+ def compose_file_with_presets(file_data)
662
+ preset_names = file_data[:presets] || []
663
+ return file_data if preset_names.empty?
664
+
665
+ # Load all referenced presets first
666
+ base_bundle = file_data[:bundle]
667
+ composed_from = [file_data[:name]]
668
+ preset_bundles = []
669
+
670
+ preset_names.each do |preset_name|
671
+ preset = @preset_manager.load_preset_with_composition(preset_name)
672
+ if preset[:success]
673
+ preset_bundle = preset[:bundle]
674
+ preset_bundles << {bundle: preset_bundle}
675
+ composed_from << preset_name
676
+ composed_from.concat(preset[:composed_from]) if preset[:composed_from]
677
+ elsif @options[:debug]
678
+ warn "Warning: Failed to load preset '#{preset_name}' referenced in file"
679
+ end
680
+ end
681
+
682
+ # Merge all presets + file bundle (file bundle last = file wins)
683
+ # Order: preset1, preset2, ..., file bundle
684
+ if preset_bundles.any?
685
+ merged = @preset_manager.send(:merge_preset_data, preset_bundles + [{bundle: base_bundle}])
686
+ base_bundle = merged[:bundle]
687
+ end
688
+
689
+ # Remove presets key from bundle (it's metadata, already processed)
690
+ base_bundle.delete("presets")
691
+ base_bundle.delete(:presets)
692
+
693
+ file_data[:bundle] = base_bundle
694
+ file_data[:composed] = true
695
+ file_data[:composed_from] = composed_from.uniq
696
+ file_data
697
+ end
698
+
699
+ private
700
+
701
+ # Unwrap bundle configuration from wrapper if present
702
+ # Handles both nested (bundle: { ... }) and flat ({ ... }) formats
703
+ # @param config [Hash] Configuration hash, possibly with 'bundle' key
704
+ # @return [Hash] The bundle configuration
705
+ def unwrap_bundle_config(config)
706
+ config["bundle"] || config
707
+ end
708
+
709
+ # Apply CLI overrides to configuration
710
+ # CLI options take precedence over frontmatter/config settings
711
+ #
712
+ # This method is called at multiple points in the loading pipeline to ensure
713
+ # CLI flags consistently override configuration at all entry points:
714
+ # - load_template: For files with frontmatter
715
+ # - load_from_preset_config: For preset-based loading
716
+ # - process_template_config: For template processing
717
+ def apply_cli_overrides(config)
718
+ config ||= {} # Guard against nil config
719
+ if @options[:embed_source]
720
+ config.merge("embed_document_source" => true)
721
+ else
722
+ config
723
+ end
724
+ end
725
+
726
+ # Process top-level preset references in bundle configuration
727
+ #
728
+ # When a preset or file has `bundle: presets: [preset-name]` at the top level,
729
+ # this method loads each referenced preset and merges their content (files,
730
+ # commands, params) into the current configuration.
731
+ #
732
+ # Merge order: referenced presets first, then current config (current wins).
733
+ # This is consistent with section-based preset handling.
734
+ #
735
+ # @param bundle_config [Hash] The bundle configuration to process
736
+ # @return [Hash] Merged configuration with preset content incorporated
737
+ def process_top_level_presets(bundle_config)
738
+ return bundle_config unless bundle_config
739
+
740
+ preset_refs = bundle_config["presets"] || bundle_config[:presets]
741
+ return bundle_config unless preset_refs&.any?
742
+
743
+ # Load all referenced presets, collecting any errors
744
+ preset_bundles = []
745
+ errors = []
746
+
747
+ preset_refs.each do |preset_name|
748
+ preset = @preset_manager.load_preset_with_composition(preset_name)
749
+ if preset[:success]
750
+ preset_bundle = preset[:bundle]
751
+ preset_bundles << {bundle: preset_bundle}
752
+ else
753
+ errors << "#{preset_name}: #{preset[:error]}"
754
+ end
755
+ end
756
+
757
+ # Fail fast if any referenced preset failed to load
758
+ if errors.any?
759
+ raise Ace::Bundle::PresetLoadError, "Failed to load referenced presets: #{errors.join("; ")}"
760
+ end
761
+
762
+ return bundle_config unless preset_bundles.any?
763
+
764
+ # Merge: referenced presets first, then current config (current wins)
765
+ merged = @preset_manager.merge_preset_data(preset_bundles + [{bundle: bundle_config}])
766
+ merged_config = merged[:bundle]
767
+
768
+ # Remove presets key from merged config (already processed)
769
+ merged_config.delete("presets")
770
+ merged_config.delete(:presets)
771
+
772
+ merged_config
773
+ end
774
+
775
+ # Check if a file has YAML frontmatter
776
+ def has_frontmatter?(path)
777
+ return false unless File.exist?(path)
778
+ content = begin
779
+ File.read(path, 100)
780
+ rescue
781
+ ""
782
+ end # Read only beginning
783
+ content.start_with?("---\n", "---\r\n")
784
+ end
785
+
786
+ # Merge preset configurations (just config data, not content)
787
+ def merge_preset_configurations(presets)
788
+ merged = {
789
+ "description" => nil,
790
+ "bundle" => {
791
+ "params" => {},
792
+ "files" => [],
793
+ "commands" => []
794
+ }
795
+ }
796
+
797
+ presets.each do |preset|
798
+ # Merge description (last wins)
799
+ merged["description"] = preset[:description] if preset[:description]
800
+
801
+ # Merge bundle configuration
802
+ if preset[:bundle]
803
+ bundle_config = preset[:bundle]
804
+
805
+ # Merge params
806
+ if bundle_config["params"]
807
+ merged["bundle"]["params"].merge!(bundle_config["params"])
808
+ end
809
+
810
+ # Merge files
811
+ if bundle_config["files"]
812
+ merged["bundle"]["files"].concat(bundle_config["files"])
813
+ end
814
+
815
+ # Merge commands
816
+ if bundle_config["commands"]
817
+ merged["bundle"]["commands"].concat(bundle_config["commands"])
818
+ end
819
+
820
+ # Copy other bundle keys (embed_document_source, etc.)
821
+ bundle_config.each do |key, value|
822
+ next if %w[params files commands presets].include?(key)
823
+ merged["bundle"][key] = value
824
+ end
825
+ end
826
+ end
827
+
828
+ # Deduplicate arrays
829
+ merged["bundle"]["files"].uniq!
830
+ merged["bundle"]["commands"].uniq!
831
+
832
+ merged
833
+ end
834
+
835
+ def merge_bundles(bundles)
836
+ return Models::BundleData.new if bundles.empty?
837
+
838
+ # Single context with actual processed section content: preserve sections
839
+ # This handles presets with explicit `sections:` that have real content
840
+ if bundles.size == 1 && has_processed_section_content?(bundles.first)
841
+ result = bundles.first
842
+ result.metadata[:merged] = true
843
+ result.metadata[:total_bundles] = 1
844
+ result.metadata[:sources] = [result.metadata[:preset_name] || result.metadata[:source_path]].compact
845
+ return format_bundle(result, @options[:format] || "markdown-xml")
846
+ end
847
+
848
+ # Default path: use original merge logic for backward compatibility
849
+ # This creates a new bundle without sections (uses OutputFormatter with metadata)
850
+ bundle_hashes = bundles.map do |bundle|
851
+ {
852
+ files: bundle.files,
853
+ metadata: bundle.metadata,
854
+ preset_name: bundle.metadata[:preset_name],
855
+ source_input: bundle.metadata[:source_input],
856
+ errors: bundle.metadata[:errors] || []
857
+ }
858
+ end
859
+
860
+ merged = @merger.merge_bundles(bundle_hashes)
861
+
862
+ result = Models::BundleData.new(
863
+ metadata: merged[:metadata] || {}
864
+ )
865
+
866
+ merged[:files]&.each do |file|
867
+ result.add_file(file[:path], file[:content])
868
+ end
869
+
870
+ result.metadata[:merged] = true
871
+ result.metadata[:total_bundles] = merged[:total_bundles]
872
+ result.metadata[:sources] = merged[:sources]
873
+ result.metadata[:errors] = merged[:errors] if merged[:errors]&.any?
874
+
875
+ format_bundle(result, @options[:format] || "markdown-xml")
876
+ end
877
+
878
+ # Check if bundle has sections with actual processed content
879
+ # Returns true if sections have _processed_files, _processed_commands, or _processed_diffs
880
+ # Note: Section data is normalized to symbol keys by SectionProcessor
881
+ def has_processed_section_content?(bundle)
882
+ return false unless bundle.has_sections?
883
+
884
+ bundle.sections.any? do |_name, data|
885
+ processed_files = data[:_processed_files] || []
886
+ processed_commands = data[:_processed_commands] || []
887
+ processed_diffs = data[:_processed_diffs] || []
888
+ processed_files.any? || processed_commands.any? || processed_diffs.any?
889
+ end
890
+ end
891
+
892
+ def process_template_config(config)
893
+ # Apply CLI overrides to config (CLI takes precedence)
894
+ config = apply_cli_overrides(config)
895
+
896
+ data = {
897
+ files: [],
898
+ commands: [],
899
+ errors: [],
900
+ metadata: config.slice("format", "embed_document_source")
901
+ }
902
+
903
+ # Process files
904
+ if config["files"] && config["files"].any?
905
+ # Resolve any protocol references (e.g., wfi://workflow-name)
906
+ resolved_files = config["files"].map do |file_ref|
907
+ resolve_file_reference(file_ref)
908
+ end.compact
909
+
910
+ aggregator = Ace::Core::Molecules::FileAggregator.new(
911
+ max_size: config["max_size"] || @options[:max_size],
912
+ base_dir: @options[:base_dir] || project_root,
913
+ exclude: config["exclude"] || []
914
+ )
915
+
916
+ # Check if any patterns contain glob characters
917
+ has_globs = resolved_files.any? { |f| f.include?("*") || f.include?("?") || f.include?("[") }
918
+
919
+ # Use aggregate for globs, aggregate_files for literal paths
920
+ result = if has_globs
921
+ aggregator.aggregate(resolved_files)
922
+ else
923
+ aggregator.aggregate_files(resolved_files)
924
+ end
925
+ data[:files] = result[:files]
926
+ data[:errors].concat(result[:errors])
927
+ end
928
+
929
+ # Process include patterns (similar to files)
930
+ if config["include"] && config["include"].any?
931
+ aggregator = Ace::Core::Molecules::FileAggregator.new(
932
+ max_size: config["max_size"] || @options[:max_size],
933
+ base_dir: @options[:base_dir] || project_root,
934
+ exclude: config["exclude"] || []
935
+ )
936
+
937
+ result = aggregator.aggregate(config["include"])
938
+ data[:files].concat(result[:files])
939
+ data[:errors].concat(result[:errors])
940
+ end
941
+
942
+ # Process commands
943
+ if config["commands"] && config["commands"].any?
944
+ timeout = config["timeout"] || @options[:timeout] || 30
945
+ config["commands"].each do |command|
946
+ cmd_result = @command_executor.execute(command, timeout: timeout, cwd: project_root)
947
+ data[:commands] << {
948
+ command: command,
949
+ output: cmd_result[:stdout],
950
+ success: cmd_result[:success],
951
+ error: cmd_result[:error]
952
+ }
953
+ end
954
+ end
955
+
956
+ # Process diffs
957
+ if config["diffs"] && config["diffs"].any?
958
+ data[:diffs] ||= []
959
+ config["diffs"].each do |diff_range|
960
+ result = generate_diff_safe(diff_range)
961
+ data[:diffs] << result.slice(:range, :output, :success, :error, :error_type)
962
+
963
+ unless result[:success]
964
+ error_prefix = (result[:error_type] == :git_error) ? "Git diff failed" : "Invalid diff range"
965
+ data[:errors] << "#{error_prefix} for '#{diff_range}': #{result[:error]}"
966
+ end
967
+ end
968
+ end
969
+
970
+ # Format output
971
+ formatter = Ace::Core::Molecules::OutputFormatter.new(
972
+ config["format"] || @options[:format] || "markdown-xml"
973
+ )
974
+ formatted_content = formatter.format(data)
975
+
976
+ # Create bundle with formatted content
977
+ bundle = Models::BundleData.new(metadata: data[:metadata])
978
+ bundle.content = formatted_content
979
+ bundle.commands = data[:commands]
980
+
981
+ # Store individual files if embed_document_source is true
982
+ if config["embed_document_source"]
983
+ data[:files].each do |file_info|
984
+ bundle.add_file(file_info[:path], file_info[:content])
985
+ end
986
+ end
987
+
988
+ bundle
989
+ end
990
+
991
+ def format_bundle(bundle, format)
992
+ bundle.metadata[:raw_content_for_auto_format] = bundle.content
993
+
994
+ # Apply compression before formatting (if enabled)
995
+ compress_bundle_sections(bundle)
996
+
997
+ case format
998
+ when "markdown", "yaml", "xml", "markdown-xml", "json"
999
+ # Use SectionFormatter if bundle has sections, otherwise fallback to OutputFormatter
1000
+ if bundle.has_sections?
1001
+ formatter = Molecules::SectionFormatter.new(format)
1002
+ bundle.content = formatter.format_with_sections(bundle)
1003
+ else
1004
+ # Use OutputFormatter for legacy bundles
1005
+ data = {
1006
+ files: bundle.files,
1007
+ metadata: bundle.metadata.dup,
1008
+ commands: bundle.commands,
1009
+ content: bundle.content
1010
+ }
1011
+
1012
+ # Include preset_name at the top level for YAML format
1013
+ if bundle.metadata[:preset_name]
1014
+ data[:preset_name] = bundle.metadata[:preset_name]
1015
+ end
1016
+
1017
+ formatter = Ace::Core::Molecules::OutputFormatter.new(format)
1018
+ bundle.content = formatter.format(data)
1019
+ end
1020
+ end
1021
+
1022
+ # Post-format: compress rendered content for section bundles where
1023
+ # section-level compression was not applicable (sections had commands/diffs
1024
+ # but no files, so _processed_files was empty)
1025
+ compress_rendered_bundle(bundle)
1026
+
1027
+ bundle
1028
+ end
1029
+
1030
+ def compress_bundle_sections(bundle)
1031
+ # --compressor off: absolute kill switch
1032
+ return if @options[:compressor]&.to_s == "off"
1033
+
1034
+ # Resolve mode: CLI > preset > config > "exact"
1035
+ compressor_mode = @options[:compressor_mode]&.to_s
1036
+ compressor_mode = bundle.metadata[:compressor_mode]&.to_s if compressor_mode.nil? || compressor_mode.empty?
1037
+ compressor_mode = Ace::Bundle.compressor_mode if compressor_mode.nil? || compressor_mode.empty?
1038
+ compressor_mode = "exact" if compressor_mode.nil? || compressor_mode.empty?
1039
+
1040
+ # Resolve source_scope: CLI > preset > config > "off"
1041
+ source_scope = @options[:compressor_source_scope]&.to_s
1042
+ source_scope = bundle.metadata[:compressor_source_scope]&.to_s if source_scope.nil? || source_scope.empty?
1043
+ source_scope = Ace::Bundle.compressor_source_scope if source_scope.nil? || source_scope.empty?
1044
+
1045
+ # --compressor on: force-enable if scope resolved to "off"
1046
+ if @options[:compressor]&.to_s == "on" && (source_scope.nil? || source_scope.empty? || source_scope == "off")
1047
+ source_scope = "per-source"
1048
+ end
1049
+
1050
+ return if source_scope.nil? || source_scope.empty? || source_scope == "off"
1051
+ source_scope = "per-source" if %w[true on yes].include?(source_scope)
1052
+
1053
+ require "ace/compressor"
1054
+ compressor = Molecules::SectionCompressor.new(default_mode: source_scope, compressor_mode: compressor_mode)
1055
+ compressor.call(bundle)
1056
+ end
1057
+
1058
+ # Compress rendered bundle content in-memory for section bundles where
1059
+ # section-level compression was a no-op (no _processed_files to compress).
1060
+ # This handles template bundles with command-only/diff-only sections.
1061
+ def compress_rendered_bundle(bundle)
1062
+ return unless bundle.has_sections?
1063
+ return if bundle.metadata[:compressed]
1064
+ return if sections_have_processed_files?(bundle)
1065
+
1066
+ # --compressor off: absolute kill switch
1067
+ return if @options[:compressor]&.to_s == "off"
1068
+
1069
+ # Resolve compressor_mode: CLI > preset > config > "exact"
1070
+ compressor_mode = @options[:compressor_mode]&.to_s
1071
+ compressor_mode = bundle.metadata[:compressor_mode]&.to_s if compressor_mode.nil? || compressor_mode.empty?
1072
+ compressor_mode = Ace::Bundle.compressor_mode if compressor_mode.nil? || compressor_mode.empty?
1073
+ compressor_mode = "exact" if compressor_mode.nil? || compressor_mode.empty?
1074
+
1075
+ # Resolve source_scope: CLI > preset > config > "off"
1076
+ source_scope = @options[:compressor_source_scope]&.to_s
1077
+ source_scope = bundle.metadata[:compressor_source_scope]&.to_s if source_scope.nil? || source_scope.empty?
1078
+ source_scope = Ace::Bundle.compressor_source_scope if source_scope.nil? || source_scope.empty?
1079
+
1080
+ # --compressor on: force-enable if scope resolved to "off"
1081
+ if @options[:compressor]&.to_s == "on" && (source_scope.nil? || source_scope.empty? || source_scope == "off")
1082
+ source_scope = "per-source"
1083
+ end
1084
+
1085
+ return if source_scope.nil? || source_scope.empty? || source_scope == "off"
1086
+
1087
+ content = bundle.content.to_s
1088
+ return if content.strip.empty?
1089
+
1090
+ require "ace/compressor"
1091
+ label = bundle.metadata[:source]&.to_s || "bundle.md"
1092
+ compressed = Ace::Compressor.compress_text(content, label: label, mode: compressor_mode)
1093
+ bundle.content = compressed
1094
+ bundle.metadata[:compressed] = true
1095
+ end
1096
+
1097
+ def sections_have_processed_files?(bundle)
1098
+ return false unless bundle.has_sections?
1099
+
1100
+ bundle.sections.any? { |_, data| data[:_processed_files]&.any? }
1101
+ end
1102
+
1103
+ def load_protocol(protocol_ref)
1104
+ # Resolve protocol using ace-nav
1105
+ resolved_path = resolve_protocol(protocol_ref)
1106
+
1107
+ if resolved_path && File.exist?(resolved_path)
1108
+ # Load the resolved file
1109
+ load_file(resolved_path)
1110
+ else
1111
+ # Protocol resolution failed - log warning for debugging
1112
+ warn "Warning: Failed to resolve protocol '#{protocol_ref}'" if @options[:debug]
1113
+ bundle = Models::BundleData.new
1114
+ bundle.metadata[:error] = "Failed to resolve protocol: #{protocol_ref}"
1115
+ bundle.metadata[:protocol_ref] = protocol_ref
1116
+ bundle
1117
+ end
1118
+ end
1119
+
1120
+ def resolve_protocol(protocol_ref)
1121
+ require "ace/support/nav"
1122
+ engine = Ace::Support::Nav::Organisms::NavigationEngine.new
1123
+ path = engine.resolve(protocol_ref)
1124
+
1125
+ return path if path && File.exist?(path)
1126
+
1127
+ # Fallback: handle cmd-type protocols (e.g., task://) by capturing command output
1128
+ protocol = protocol_ref.split("://", 2).first
1129
+ if engine.cmd_protocol?(protocol)
1130
+ cmd_path = engine.resolve_cmd_to_path(protocol_ref)
1131
+ return cmd_path if cmd_path && File.exist?(cmd_path)
1132
+ end
1133
+
1134
+ if @options[:debug]
1135
+ if path.nil?
1136
+ warn "Warning: ace-nav could not resolve '#{protocol_ref}'"
1137
+ else
1138
+ warn "Warning: ace-nav path does not exist for '#{protocol_ref}': #{path}"
1139
+ end
1140
+ end
1141
+ nil
1142
+ end
1143
+
1144
+ def resolve_file_reference(file_ref)
1145
+ # Check if it's a protocol reference (contains ://)
1146
+ if file_ref.match?(/^[\w-]+:\/\//)
1147
+ resolve_protocol(file_ref)
1148
+ elsif file_ref.start_with?("./") && @template_dir
1149
+ # Resolve ./ paths relative to the template file's directory
1150
+ File.join(@template_dir, file_ref)
1151
+ else
1152
+ # Regular file path or glob pattern (resolved from project root)
1153
+ file_ref
1154
+ end
1155
+ end
1156
+
1157
+ # Process content for a specific section
1158
+ def process_section_content(bundle, section_name, section_data, options, bundle_config = {})
1159
+ # Process all content types that are present in the section
1160
+ if has_files_content?(section_data)
1161
+ process_files_section(bundle, section_name, section_data, options, bundle_config)
1162
+ end
1163
+
1164
+ if has_commands_content?(section_data)
1165
+ process_commands_section(bundle, section_name, section_data, options, bundle_config)
1166
+ end
1167
+
1168
+ if has_diffs_content?(section_data)
1169
+ process_diffs_section(bundle, section_name, section_data, options)
1170
+ end
1171
+
1172
+ if has_content_content?(section_data)
1173
+ process_inline_content_section(bundle, section_name, section_data, options)
1174
+ end
1175
+ end
1176
+
1177
+ # Process files section content
1178
+ def process_files_section(bundle, section_name, section_data, options, bundle_config = {})
1179
+ files = section_data[:files] || section_data["files"] || []
1180
+ return unless files.any?
1181
+
1182
+ # Resolve any protocol references (e.g., wfi://workflow-name)
1183
+ resolved_files = files.map do |file_ref|
1184
+ resolve_file_reference(file_ref)
1185
+ end.compact
1186
+
1187
+ aggregator = Ace::Core::Molecules::FileAggregator.new(
1188
+ max_size: options[:max_size] || options["max_size"],
1189
+ base_dir: options[:base_dir] || project_root,
1190
+ exclude: section_data[:exclude] || section_data["exclude"] || []
1191
+ )
1192
+
1193
+ # Check if any patterns contain glob characters
1194
+ has_globs = resolved_files.any? { |f| f.include?("*") || f.include?("?") || f.include?("[") }
1195
+
1196
+ # Use aggregate for globs, aggregate_files for literal paths to preserve order
1197
+ result = if has_globs
1198
+ aggregator.aggregate(resolved_files)
1199
+ else
1200
+ aggregator.aggregate_files(resolved_files)
1201
+ end
1202
+
1203
+ # Store section files in section data
1204
+ section_data[:_processed_files] = result[:files]
1205
+
1206
+ # Add files to bundle if embed_document_source is true
1207
+ if bundle_config["embed_document_source"]
1208
+ result[:files].each do |file_info|
1209
+ bundle.add_file(file_info[:path], file_info[:content])
1210
+ end
1211
+ end
1212
+
1213
+ # Add errors if any
1214
+ result[:errors].each do |error|
1215
+ bundle.metadata[:errors] ||= []
1216
+ bundle.metadata[:errors] << "Section '#{section_name}': #{error}"
1217
+ end
1218
+ end
1219
+
1220
+ # Process commands section content
1221
+ def process_commands_section(bundle, section_name, section_data, options, bundle_config = {})
1222
+ commands = section_data[:commands] || section_data["commands"] || []
1223
+ return unless commands.any?
1224
+
1225
+ timeout = options[:timeout] || options["timeout"] || 30
1226
+ processed_commands = []
1227
+
1228
+ commands.each do |command|
1229
+ cmd_result = @command_executor.execute(command, timeout: timeout, cwd: project_root)
1230
+ processed_commands << {
1231
+ command: command,
1232
+ output: cmd_result[:stdout],
1233
+ success: cmd_result[:success],
1234
+ error: cmd_result[:error]
1235
+ }
1236
+ end
1237
+
1238
+ # Store processed commands in section data
1239
+ section_data[:_processed_commands] = processed_commands
1240
+
1241
+ # Add commands to bundle (always, like in legacy processing)
1242
+ bundle.commands = (bundle.commands || []) + processed_commands
1243
+ end
1244
+
1245
+ # Process top-level PR configuration
1246
+ # Delegates to PrBundleLoader for PR fetching and section integration
1247
+ # @return [Boolean] true if PR config was present (even if fetch failed), false otherwise
1248
+ def process_pr_config(bundle, bundle_config, options)
1249
+ pr_refs = bundle_config["pr"] || bundle_config[:pr]
1250
+ return false unless pr_refs
1251
+
1252
+ PrBundleLoader.new(options).process(bundle, pr_refs)
1253
+ end
1254
+
1255
+ # Process diffs section content
1256
+ def process_diffs_section(bundle, section_name, section_data, options)
1257
+ ranges = section_data[:ranges] || section_data["ranges"] || []
1258
+ return unless ranges.any?
1259
+
1260
+ processed_diffs = []
1261
+
1262
+ ranges.each do |diff_range|
1263
+ result = generate_diff_safe(diff_range)
1264
+ processed_diffs << result.slice(:range, :output, :success, :error)
1265
+
1266
+ unless result[:success]
1267
+ bundle.metadata[:errors] ||= []
1268
+ error_prefix = (result[:error_type] == :git_error) ? "Git diff failed" : "Invalid diff range"
1269
+ bundle.metadata[:errors] << "Section '#{section_name}': #{error_prefix} for '#{diff_range}': #{result[:error]}"
1270
+ end
1271
+ end
1272
+
1273
+ # Store processed diffs in section data
1274
+ section_data[:_processed_diffs] = processed_diffs
1275
+ end
1276
+
1277
+ # Process inline content section
1278
+ def process_inline_content_section(bundle, section_name, section_data, options)
1279
+ content = section_data[:content] || section_data["content"]
1280
+ # Store content in section data
1281
+ section_data[:_processed_content] = content if content
1282
+ end
1283
+
1284
+ # Process base content from context.base field
1285
+ #
1286
+ # Supports both file paths and inline content strings:
1287
+ # - File paths: Resolved via protocol or filesystem (e.g., "path/to/file.md", "wfi://context", "README")
1288
+ # - Inline content: Simple strings without path indicators (e.g., "System instructions")
1289
+ #
1290
+ # Resolution strategy prioritizes file existence to correctly handle extension-less files
1291
+ # (README, CONTEXT, etc.) while still supporting inline strings for simple use cases.
1292
+ def process_base_content(bundle, bundle_config, options)
1293
+ base_ref = bundle_config["base"] || bundle_config[:base]
1294
+ return unless base_ref && !base_ref.to_s.strip.empty?
1295
+
1296
+ # Check if base_ref looks like a file reference (has protocol, slashes, or is a known path pattern)
1297
+ # This heuristic helps prioritize file resolution for extension-less files
1298
+ has_protocol = base_ref.match?(/^[\w-]+:\/\//)
1299
+ has_path_separators = base_ref.match?(/[\/\\]/)
1300
+ looks_like_file_ref = has_protocol || has_path_separators
1301
+
1302
+ # Try to resolve as file reference first (handles extension-less files like README, CONTEXT)
1303
+ resolved_path = resolve_file_reference(base_ref)
1304
+
1305
+ # Check if we successfully resolved to an existing file
1306
+ if resolved_path && File.exist?(resolved_path)
1307
+ # Load base content from file
1308
+ base_content = File.read(resolved_path).strip
1309
+ if base_content.empty?
1310
+ warn "Warning: Base file is empty: #{resolved_path}" if options[:debug]
1311
+ end
1312
+
1313
+ # Store base content as primary content
1314
+ bundle.content = base_content
1315
+ bundle.metadata[:base_path] = resolved_path
1316
+ bundle.metadata[:base_ref] = base_ref
1317
+ bundle.metadata[:base_type] = "file"
1318
+ elsif looks_like_file_ref
1319
+ # It looks like a file reference but resolution failed - set error
1320
+ if !resolved_path
1321
+ bundle.metadata[:base_error] = "Failed to resolve base reference: #{base_ref}"
1322
+ warn "Warning: Failed to resolve base reference: #{base_ref}" if options[:debug]
1323
+ else
1324
+ bundle.metadata[:base_error] = "Base file not found: #{resolved_path}"
1325
+ warn "Warning: Base file not found: #{resolved_path}" if options[:debug]
1326
+ end
1327
+ else
1328
+ # Simple string without path indicators - treat as inline content
1329
+ # This allows direct definition of base context without requiring separate files
1330
+ # Example: base: "System instructions for the task"
1331
+ bundle.content = base_ref.to_s.strip
1332
+ bundle.metadata[:base_type] = "inline"
1333
+ bundle.metadata[:base_ref] = base_ref
1334
+ end
1335
+ end
1336
+
1337
+ # Helper methods to detect content types in sections
1338
+ # Delegates to shared ContentChecker atom for consistency
1339
+
1340
+ def has_files_content?(section_data)
1341
+ Atoms::ContentChecker.has_files_content?(section_data)
1342
+ end
1343
+
1344
+ def has_commands_content?(section_data)
1345
+ Atoms::ContentChecker.has_commands_content?(section_data)
1346
+ end
1347
+
1348
+ def has_diffs_content?(section_data)
1349
+ Atoms::ContentChecker.has_diffs_content?(section_data)
1350
+ end
1351
+
1352
+ def has_content_content?(section_data)
1353
+ Atoms::ContentChecker.has_content_content?(section_data)
1354
+ end
1355
+
1356
+ def project_root
1357
+ @project_root ||= Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
1358
+ end
1359
+
1360
+ # Generate diff with ace-git and return standardized result hash
1361
+ # Handles GitError and ArgumentError with standardized error handling
1362
+ # @param diff_range [String] Git range to diff
1363
+ # @return [Hash] Result with :range, :output, :success, and optional :error
1364
+ def generate_diff_safe(diff_range)
1365
+ diff_result = Ace::Git::Organisms::DiffOrchestrator.generate(ranges: [diff_range])
1366
+ {
1367
+ range: diff_range,
1368
+ output: diff_result.content,
1369
+ success: true
1370
+ }
1371
+ rescue Ace::Git::Error => e
1372
+ {
1373
+ range: diff_range,
1374
+ output: "",
1375
+ success: false,
1376
+ error: e.message,
1377
+ error_type: :git_error
1378
+ }
1379
+ rescue ArgumentError => e
1380
+ {
1381
+ range: diff_range,
1382
+ output: "",
1383
+ success: false,
1384
+ error: e.message,
1385
+ error_type: :invalid_range
1386
+ }
1387
+ end
1388
+
1389
+ # Load plain markdown file with metadata-only frontmatter
1390
+ # Used as fallback for workflow files with description/allowed-tools but no context config
1391
+ # @param original_content [String] The full file content with frontmatter
1392
+ # @param frontmatter [Hash] Parsed frontmatter YAML
1393
+ # @param path [String] File path for source metadata
1394
+ # @return [Models::BundleData] Context with original content and metadata
1395
+ def load_plain_markdown(original_content, frontmatter, path)
1396
+ bundle = Models::BundleData.new
1397
+ # Use original_content (preserved before frontmatter stripping)
1398
+ bundle.content = original_content
1399
+ bundle.metadata[:raw_content_for_auto_format] = original_content
1400
+ # Store metadata from frontmatter using merge to preserve any existing metadata
1401
+ # Include frontmatter and frontmatter_yaml for parity with template path
1402
+ bundle.metadata = (bundle.metadata || {}).merge(
1403
+ frontmatter.transform_keys(&:to_sym)
1404
+ ).merge(
1405
+ source: path,
1406
+ frontmatter: frontmatter
1407
+ )
1408
+ # Store frontmatter_yaml if frontmatter was present
1409
+ bundle.metadata[:frontmatter_yaml] = frontmatter.to_yaml if frontmatter.any?
1410
+
1411
+ # Check for frontmatter typos and store warnings in metadata
1412
+ warnings = detect_suspicious_keys(frontmatter, path)
1413
+ bundle.metadata[:warnings] = warnings if warnings.any?
1414
+
1415
+ compress_bundle_sections(bundle)
1416
+ compress_rendered_bundle(bundle)
1417
+ bundle
1418
+ end
1419
+
1420
+ # Delegate to Atoms::TypoDetector for architectural consistency
1421
+ # @deprecated Use Atoms::TypoDetector.detect_suspicious_keys directly
1422
+ def detect_suspicious_keys(frontmatter, path)
1423
+ return [] unless ENV["ACE_BUNDLE_STRICT"]
1424
+
1425
+ Atoms::TypoDetector.detect_suspicious_keys(frontmatter, path)
1426
+ end
1427
+
1428
+ # Delegate to Atoms::TypoDetector for architectural consistency
1429
+ # @deprecated Use Atoms::TypoDetector.typo_distance directly
1430
+ def typo_distance(str1, str2)
1431
+ Atoms::TypoDetector.typo_distance(str1, str2)
1432
+ end
1433
+ end
1434
+ end
1435
+ end
1436
+ end