Class: Ace::Bundle::Organisms::BundleLoader

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/bundle/organisms/bundle_loader.rb

Overview

Main bundle loader that orchestrates preset loading using ace-core components

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ BundleLoader

Returns a new instance of BundleLoader.



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 28

def initialize(options = {})
  @options = options
  @template_dir = nil
  @preset_manager = Molecules::PresetManager.new
  @section_processor = Molecules::SectionProcessor.new
  @merger = Molecules::BundleMerger.new
  @file_aggregator = Ace::Core::Molecules::FileAggregator.new(
    max_size: options[:max_size],
    base_dir: options[:base_dir] || project_root
  )
  @command_executor = Ace::Core::Atoms::CommandExecutor
  @output_formatter = Ace::Core::Molecules::OutputFormatter.new(
    options[:format] || "markdown-xml"
  )
end

Instance Method Details

#compose_file_with_presets(file_data) ⇒ Object

Compose a file configuration with referenced presets



663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 663

def compose_file_with_presets(file_data)
  preset_names = file_data[:presets] || []
  return file_data if preset_names.empty?

  # Load all referenced presets first
  base_bundle = file_data[:bundle]
  composed_from = [file_data[:name]]
  preset_bundles = []

  preset_names.each do |preset_name|
    preset = @preset_manager.load_preset_with_composition(preset_name)
    if preset[:success]
      preset_bundle = preset[:bundle]
      preset_bundles << {bundle: preset_bundle}
      composed_from << preset_name
      composed_from.concat(preset[:composed_from]) if preset[:composed_from]
    elsif @options[:debug]
      warn "Warning: Failed to load preset '#{preset_name}' referenced in file"
    end
  end

  # Merge all presets + file bundle (file bundle last = file wins)
  # Order: preset1, preset2, ..., file bundle
  if preset_bundles.any?
    merged = @preset_manager.send(:merge_preset_data, preset_bundles + [{bundle: base_bundle}])
    base_bundle = merged[:bundle]
  end

  # Remove presets key from bundle (it's metadata, already processed)
  base_bundle.delete("presets")
  base_bundle.delete(:presets)

  file_data[:bundle] = base_bundle
  file_data[:composed] = true
  file_data[:composed_from] = composed_from.uniq
  file_data
end

#inspect_config(inputs) ⇒ Object

Inspect configuration without loading files or executing commands Returns a ContextData with just the merged configuration as YAML



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 189

def inspect_config(inputs)
  require "yaml"

  # Load all inputs (presets and files) with composition
  configs = []
  warnings = []

  inputs.each do |input|
    # Auto-detect if it's a file or preset
    if File.exist?(input)
      # Load as file
      begin
        # Read file and parse config
        content = File.read(input)
        config = {}

        if input.match?(/\.ya?ml$/i)
          # Date is permitted so bundle/frontmatter configs can round-trip
          # ace-docs date-only fields without coercing them to strings.
          config = YAML.safe_load(content, aliases: true, permitted_classes: [Symbol, Date]) || {}
        elsif has_frontmatter?(input)
          if content =~ /\A---\s*\n(.*?)\n---\s*\n/m
            # Keep Date aligned with YAML file parsing above for frontmatter
            # sources that include ace-docs date-only metadata.
            frontmatter = YAML.safe_load($1, aliases: true, permitted_classes: [Symbol, Date]) || {}
            config = unwrap_bundle_config(frontmatter)
          end
        end

        # Handle preset composition if file references presets
        preset_refs = config["presets"] || config[:presets]
        if preset_refs && !preset_refs.empty?
          # Load all referenced presets first
          preset_bundles = []
          preset_refs.each do |preset_name|
            preset = @preset_manager.load_preset_with_composition(preset_name)
            if preset[:success]
              preset_bundles << {bundle: preset[:bundle]}
            else
              warnings << "Failed to load preset '#{preset_name}' from file #{input}"
            end
          end

          # Merge all presets + file config (file config last = file wins)
          # Order: preset1, preset2, ..., file config
          if preset_bundles.any?
            merged = @preset_manager.send(:merge_preset_data, preset_bundles + [{bundle: config}])
            config = merged[:bundle]
          end

          # Remove presets key from final config
          config.delete("presets")
          config.delete(:presets)
        end

        configs << {
          success: true,
          bundle: config,
          name: File.basename(input),
          source_file: input
        }
      rescue => e
        warnings << "Failed to load file #{input}: #{e.message}"
      end
    else
      # Load as preset
      preset = @preset_manager.load_preset_with_composition(input)
      if preset[:success]
        configs << preset
      else
        warnings << preset[:error]
      end
    end
  end

  # If no successful configs, return error
  if configs.empty?
    bundle = Models::BundleData.new
    bundle.[:error] = "No valid inputs loaded"
    bundle.[:warnings] = warnings
    return bundle
  end

  # Merge configurations (just the config, not content)
  merged_config = merge_preset_configurations(configs)

  # Add warnings if any
  merged_config[:warnings] = warnings if warnings.any?

  # Format as YAML
  yaml_output = YAML.dump(merged_config)

  # Create bundle with YAML content
  bundle = Models::BundleData.new
  bundle.content = yaml_output
  bundle.[:inspect_mode] = true
  bundle.[:inputs] = inputs

  bundle
end

#load_auto(input) ⇒ Object



365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 365

def load_auto(input)
  # Auto-detect input type
  # Strip whitespace to handle CLI arguments properly
  input = input.strip

  # Check for protocol first (e.g., wfi://,  guide://, task://)
  if input.match?(/\A[\w-]+:\/\//)
    return load_protocol(input)
  end

  if File.exist?(input)
    # It's a file
    load_file(input)
  elsif input.match?(/\A[\w-]+\z/)
    # Looks like a preset name
    load_preset(input)
  elsif input.include?("files:") || input.include?("commands:") || input.include?("include:") || input.include?("diffs:") || input.include?("presets:") || input.include?("pr:")
    # Looks like inline YAML
    load_inline_yaml(input)
  elsif File.exist?(input)
    # Try as file first, then preset
    load_file(input)
  else
    load_preset(input)
  end
end

#load_file(path) ⇒ Object



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 100

def load_file(path)
  # Check if it's a template file
  content = begin
    File.read(path)
  rescue
    nil
  end

  # Treat as template if:
  # 1. TemplateParser recognizes it as a template, OR
  # 2. It has YAML frontmatter (starts with ---)
  is_template = content && (
    Ace::Core::Atoms::TemplateParser.template?(content) ||
    content.start_with?("---")
  )

  if is_template
    # Parse as template
    load_template(path)
  else
    # Load as regular file
    max_size = @options[:max_size] || Ace::Core::Atoms::FileReader::MAX_FILE_SIZE
    result = Ace::Core::Atoms::FileReader.read(path, max_size: max_size)

    bundle = Models::BundleData.new
    if result[:success]
      # Plain file inputs should emit readable content to stdout.
      # Keep content as primary payload and record source metadata.
      bundle.content = result[:content]
      bundle.[:raw_content_for_auto_format] = bundle.content
      bundle.[:source] = path
    else
      bundle.[:error] = result[:error]
    end

    compress_bundle_sections(bundle)
    bundle
  end
end

#load_from_config(config) ⇒ Object



578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 578

def load_from_config(config)
  # If config has a template path, load from template instead
  if config[:template] && File.exist?(config[:template])
    return load_template(config[:template])
  end

  bundle = Models::BundleData.new(
    preset_name: config[:name],
    metadata: config[:metadata] || {}
  )

  # Use file aggregator for include patterns
  if config[:include] && config[:include].any?
    aggregator = Ace::Core::Molecules::FileAggregator.new(
      max_size: @options[:max_size],
      base_dir: @options[:base_dir] || project_root,
      exclude: config[:exclude] || []
    )

    result = aggregator.aggregate(config[:include])

    # Add files to bundle
    result[:files].each do |file_info|
      bundle.add_file(file_info[:path], file_info[:content])
    end

    # Add errors if any
    result[:errors].each do |error|
      bundle.[:errors] ||= []
      bundle.[:errors] << error
    end
  end

  # Format output
  format_bundle(bundle, config[:format])
end

#load_from_preset_config(preset, options) ⇒ Object



615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 615

def load_from_preset_config(preset, options)
  bundle_config = preset[:bundle] || {}
  bundle_config = normalize_bundle_sources(bundle_config)

  # Apply CLI overrides to context config (CLI takes precedence)
  bundle_config = apply_cli_overrides(bundle_config)

  # Process top-level preset references (context.presets)
  # This merges files, commands, and params from referenced presets
  bundle_config = process_top_level_presets(bundle_config)

  preset[:bundle] = bundle_config

  bundle = Models::BundleData.new(
    preset_name: preset[:name],
    metadata: preset[:metadata] || {}
  )

  # Process base content if present
  process_base_content(bundle, bundle_config, options)

  # Process sections (legacy non-section preset formats are no longer supported)
  raise Ace::Bundle::PresetLoadError, "Preset '#{preset[:name]}' must define bundle sections" unless @section_processor.has_sections?(preset)

  sections = @section_processor.process_sections(preset, @preset_manager)
  bundle.sections = sections

  # Process content for each section
  sections.each do |section_name, section_data|
    process_section_content(bundle, section_name, section_data, options, bundle_config)
  end

  # Process top-level PR references
  # Called after section processing so PR diffs merge into existing sections
  process_pr_config(bundle, bundle_config, options)

  # If embed_document_source is true, set content to trigger XML formatting
  if bundle_config["embed_document_source"] && preset[:body] && !preset[:body].empty?
    bundle.content = preset[:body]
  elsif preset[:body] && !preset[:body].empty?
    # Add preset body to metadata (old behavior for non-embedded)
    bundle.[:preset_content] = preset[:body]
  end

  bundle
end

#load_inline_yaml(yaml_string) ⇒ Object



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 392

def load_inline_yaml(yaml_string)
  require "yaml"
  # Inline bundle YAML accepts Date for the same reason as file/frontmatter
  # loading: ace-docs date-only metadata may deserialize to Date.
  config = YAML.safe_load(yaml_string, aliases: true, permitted_classes: [Symbol, Date])
  # Unwrap 'bundle' key if present (typed subjects use nested structure)
  # This allows both flat configs (diffs: [...]) and nested (bundle: { diffs: [...] })
  template_config = unwrap_bundle_config(config)
  bundle = process_template_config(template_config)
  # Process PR references if present (uses same unwrapped config)
  pr_processed = process_pr_config(bundle, template_config, @options)
  # Re-format bundle if PR processing added sections
  # Note: process_template_config already formats files/diffs/commands into bundle.content
  # We only need to re-format if process_pr_config added new sections (PR diffs)
  # If PR had no changes or failed, has_sections? returns false and we keep existing content
  if bundle.has_sections? || pr_processed
    format = config["format"] || @options[:format] || "markdown-xml"
    format_bundle(bundle, format)
  end
  bundle
rescue => e
  bundle = Models::BundleData.new
  bundle.[:error] = "Failed to parse inline YAML: #{e.message}"
  bundle
end

#load_multiple(inputs) ⇒ Object



290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 290

def load_multiple(inputs)
  bundles = []

  inputs.each do |input|
    bundle = load_auto(input)
    bundle.[:source_input] = input
    bundles << bundle
  end

  # Merge all bundles
  merge_bundles(bundles)
end

#load_multiple_inputs(preset_names, file_paths, options = {}) ⇒ Object

Load multiple inputs (presets and files) and merge them Maintains order of specification to allow proper override semantics



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 305

def load_multiple_inputs(preset_names, file_paths, options = {})
  bundles = []
  warnings = []

  # Process presets
  preset_names.each do |preset_name|
    # Use composition-aware loading for each preset
    preset = @preset_manager.load_preset_with_composition(preset_name)

    if preset[:success]
      params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
      merged_options = @options.merge(params)
      bundle = load_from_preset_config(preset, merged_options)
      bundle.[:preset_name] = preset_name
      bundle.[:source_type] = "preset"
      bundle.[:output] = preset[:output]  # Store preset's output mode

      # Add composition metadata if preset was composed
      if preset[:composed]
        bundle.[:composed] = true
        bundle.[:composed_from] = preset[:composed_from]
      end

      bundles << bundle
    else
      # Log warning but continue with other inputs
      warnings << "Warning: #{preset[:error]}"
      warn "Warning: #{preset[:error]}" if @options[:debug]
    end
  end

  # Process files
  file_paths.each do |file_path|
    bundle = load_file(file_path)
    bundle.[:source_type] = "file"
    bundle.[:source_path] = file_path
    bundles << bundle
  rescue => e
    warnings << "Warning: Failed to load file #{file_path}: #{e.message}"
    warn "Warning: Failed to load file #{file_path}: #{e.message}" if @options[:debug]
  end

  # Return error if all inputs failed
  if bundles.empty? && warnings.any?
    return Models::BundleData.new.tap do |c|
      c.[:error] = "Failed to load any inputs"
      c.[:errors] = warnings
      c.content = warnings.join("\n")
    end
  end

  # Merge all bundles (with proper order for overrides)
  merged_bundle = merge_bundles(bundles)

  # Add warnings to metadata if any
  merged_bundle.[:warnings] = warnings if warnings.any?

  merged_bundle
end

#load_multiple_presets(preset_names) ⇒ Object



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 140

def load_multiple_presets(preset_names)
  bundles = []
  warnings = []

  preset_names.each do |preset_name|
    # Use composition-aware loading for each preset
    preset = @preset_manager.load_preset_with_composition(preset_name)

    if preset[:success]
      params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
      merged_options = @options.merge(params)
      bundle = load_from_preset_config(preset, merged_options)
      bundle.[:preset_name] = preset_name
      bundle.[:output] = preset[:output]  # Store preset's output mode

      # Add composition metadata if preset was composed
      if preset[:composed]
        bundle.[:composed] = true
        bundle.[:composed_from] = preset[:composed_from]
      end

      bundles << bundle
    else
      # Log warning but continue with other presets
      warnings << "Warning: #{preset[:error]}"
      warn "Warning: #{preset[:error]}" if @options[:debug]
    end
  end

  # If no successful presets loaded, return error
  if bundles.empty?
    error_bundle = Models::BundleData.new(
      metadata: {
        error: "No valid presets loaded",
        warnings: warnings
      }
    )
    return error_bundle
  end

  # Merge all bundles
  merged = merge_bundles(bundles)
  merged.[:warnings] = warnings if warnings.any?

  merged
end

#load_preset(preset_name) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 44

def load_preset(preset_name)
  # Use composition-aware loading
  preset = @preset_manager.load_preset_with_composition(preset_name)

  # Handle errors from composition loading
  unless preset[:success]
    return Models::BundleData.new(
      preset_name: preset_name,
      metadata: {error: preset[:error]}
    )
  end

  # Merge params into options for processing
  params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
  merged_options = @options.merge(params)

  # Process the preset bundle configuration
  begin
    bundle = load_from_preset_config(preset, merged_options)
  rescue Ace::Bundle::PresetLoadError => e
    # Handle errors from top-level preset processing (fail-fast behavior)
    return Models::BundleData.new(
      preset_name: preset_name,
      metadata: {error: e.message}
    )
  end
  bundle.[:preset_name] = preset_name
  bundle.[:output] = preset[:output]  # Store default output mode
  bundle.[:compressor_mode] = preset[:compressor_mode] if preset[:compressor_mode]
  bundle.[:compressor_source_scope] = preset[:compressor_source_scope] if preset[:compressor_source_scope]

  # Add composition metadata if preset was composed
  if preset[:composed]
    bundle.[:composed] = true
    bundle.[:composed_from] = preset[:composed_from]
  end

  # Determine format - respect explicit format requests but default to markdown-xml for embedded sources
  # Check for explicit format request in preset or params
  explicit_format = preset[:format] || params["format"] || params[:format] || merged_options[:format]

  format = if explicit_format
    # Use the explicitly requested format
    explicit_format
  elsif preset.dig(:context, "embed_document_source")
    # Default to markdown-xml format when embed_document_source is true and no explicit format requested
    "markdown-xml"
  else
    # Fallback to markdown
    "markdown"
  end
  format_bundle(bundle, format)

  bundle
end

#load_template(path) ⇒ Object



418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
# File 'lib/ace/bundle/organisms/bundle_loader.rb', line 418

def load_template(path)
  # Track the template file's directory for resolving ./ relative paths
  @template_dir = File.dirname(File.expand_path(path))

  # Read template file (preserve original for workflow fallback)
  original_content = File.read(path)
  template_content = original_content

  # Extract and strip frontmatter if present
  frontmatter = {}
  frontmatter_yaml = nil
  if template_content =~ /\A---\s*\n(.*?)\n---\s*\n/m
    frontmatter_text = $1
    frontmatter_yaml = frontmatter_text  # Store original YAML for output
    begin
      require "yaml"
      # Workflow/template frontmatter can include date-only ace-docs fields,
      # so Date remains an explicit safe-load allowance here as well.
      frontmatter = YAML.safe_load(frontmatter_text, aliases: true, permitted_classes: [Symbol, Date]) || {}
      frontmatter = {} unless frontmatter.is_a?(Hash)
      # Remove frontmatter from content for processing
      template_content = template_content.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
    rescue Psych::SyntaxError
      # Invalid YAML, ignore frontmatter
      frontmatter_yaml = nil
    end
  end

  # Check if frontmatter contains config directly (via 'bundle' key or template config keys)
  # This is the newer pattern for workflow files
  if frontmatter["bundle"].is_a?(Hash) ||
      (frontmatter.keys & %w[preset presets files commands include exclude diffs]).any?
    # Use frontmatter as the main config
    config = unwrap_bundle_config(frontmatter)
    config = normalize_bundle_sources(config)

    # Merge params into options if present
    params = config["params"]
    if params.is_a?(Hash)
      @options = @options.merge(params)
    end

    # Handle preset/presets keys from frontmatter
    preset_names = []
    if frontmatter["preset"] && !frontmatter["preset"].to_s.strip.empty?
      preset_names << frontmatter["preset"].to_s.strip
    end
    if frontmatter["presets"] && frontmatter["presets"].is_a?(Array)
      preset_names += frontmatter["presets"].compact.map(&:to_s).map(&:strip)
    end

    if preset_names.any?
      existing_presets = config["presets"] || []
      config["presets"] = preset_names + existing_presets
    end

    # Apply CLI overrides to config (CLI takes precedence)
    config = apply_cli_overrides(config)

    # Process presets from frontmatter
    preset_error = nil
    preset_names_loaded = []
    if config["presets"] && config["presets"].any?
      begin
        preset_names_loaded = config["presets"].dup
        config = process_top_level_presets(config)
      rescue Ace::Bundle::PresetLoadError => e
        preset_error = e.message
        warn "Warning: #{e.message}" if @options[:debug]
        config.delete("presets")
        config.delete(:presets)
      end
    end

    # Process the config (loads embedded files from bundle.files)
    bundle = process_template_config(config)

    bundle.[:presets] = preset_names_loaded if preset_names_loaded.any?
    bundle.[:preset_error] = preset_error if preset_error

    # Process base content if present (for template files with context.base)
    process_base_content(bundle, config, @options)

    # Process PR references (context.pr)
    process_pr_config(bundle, config, @options)

    # Process sections if present (same as preset loading)
    preset_like_config = {"bundle" => config}
    if @section_processor.has_sections?(preset_like_config)
      sections = @section_processor.process_sections(preset_like_config, @preset_manager)
      bundle.sections = sections

      # Process content for each section
      sections.each do |section_name, section_data|
        process_section_content(bundle, section_name, section_data, @options, config)
      end
    end

    # Track base resolution before metadata reset (metadata gets replaced below)
    resolved = bundle.[:base_type] ? bundle.content : nil
    base_content_resolved = resolved.to_s.strip.empty? ? nil : resolved

    # Replace metadata with original frontmatter (keep it unmodified)
    # Convert string keys to symbols for consistency
    bundle. = {}
    frontmatter.each do |key, value|
      bundle.[key.to_sym] = value
    end
    # Store original YAML for output formatting
    bundle.[:frontmatter_yaml] = frontmatter_yaml if frontmatter_yaml

    # If embed_document_source is true, store original document and keep embedded files separate
    if config["embed_document_source"]
      # base replaces the source document for embedding
      bundle.content = base_content_resolved || original_content

      # bundle.files already has embedded files from process_template_config
      # Don't add source to files array - it will be output as raw content

      # Format and return
      format = config["format"] || @options[:format] || "markdown-xml"
      return format_bundle(bundle, format)
    end

    # Format bundle before returning (same as preset loading)
    format = config["format"] || @options[:format] || "markdown-xml"
    format_bundle(bundle, format)

    return bundle
  end

  # Check if this is plain markdown with metadata-only frontmatter
  # (e.g., workflow files with description/allowed-tools but no context config)
  if frontmatter.any?
    return load_plain_markdown(original_content, frontmatter, path)
  end

  # Otherwise, parse template configuration from body
  parse_result = Ace::Core::Atoms::TemplateParser.parse(template_content)

  unless parse_result[:success]
    bundle = Models::BundleData.new
    bundle.[:error] = parse_result[:error]
    return bundle
  end

  config = parse_result[:config]

  # Merge frontmatter into config (frontmatter has lower priority)
  config = frontmatter.merge(config) if frontmatter.any?

  # Process files and commands from template
  bundle = process_template_config(config)

  # Add frontmatter to metadata for reference
  bundle.[:frontmatter] = frontmatter if frontmatter.any?

  bundle
end