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.
- checksums.yaml +7 -0
- data/.ace-defaults/bundle/config.yml +28 -0
- data/.ace-defaults/bundle/presets/base.md +15 -0
- data/.ace-defaults/bundle/presets/code-review.md +61 -0
- data/.ace-defaults/bundle/presets/development.md +16 -0
- data/.ace-defaults/bundle/presets/documentation-review.md +52 -0
- data/.ace-defaults/bundle/presets/mixed-content-example.md +94 -0
- data/.ace-defaults/bundle/presets/project-context.md +79 -0
- data/.ace-defaults/bundle/presets/project.md +35 -0
- data/.ace-defaults/bundle/presets/section-example-simple.md +27 -0
- data/.ace-defaults/bundle/presets/security-review.md +53 -0
- data/.ace-defaults/bundle/presets/simple-project.md +43 -0
- data/.ace-defaults/bundle/presets/team.md +18 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-bundle.yml +19 -0
- data/CHANGELOG.md +384 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +22 -0
- data/exe/ace-bundle +14 -0
- data/handbook/skills/as-bundle/SKILL.md +28 -0
- data/handbook/skills/as-onboard/SKILL.md +33 -0
- data/handbook/workflow-instructions/bundle.wf.md +111 -0
- data/handbook/workflow-instructions/onboard.wf.md +20 -0
- data/lib/ace/bundle/atoms/boundary_finder.rb +122 -0
- data/lib/ace/bundle/atoms/bundle_normalizer.rb +128 -0
- data/lib/ace/bundle/atoms/content_checker.rb +46 -0
- data/lib/ace/bundle/atoms/line_counter.rb +37 -0
- data/lib/ace/bundle/atoms/preset_list_formatter.rb +44 -0
- data/lib/ace/bundle/atoms/preset_validator.rb +69 -0
- data/lib/ace/bundle/atoms/section_validator.rb +215 -0
- data/lib/ace/bundle/atoms/typo_detector.rb +76 -0
- data/lib/ace/bundle/cli/commands/load.rb +347 -0
- data/lib/ace/bundle/cli.rb +26 -0
- data/lib/ace/bundle/models/bundle_data.rb +75 -0
- data/lib/ace/bundle/molecules/bundle_chunker.rb +280 -0
- data/lib/ace/bundle/molecules/bundle_file_writer.rb +269 -0
- data/lib/ace/bundle/molecules/bundle_merger.rb +248 -0
- data/lib/ace/bundle/molecules/preset_manager.rb +331 -0
- data/lib/ace/bundle/molecules/section_compressor.rb +249 -0
- data/lib/ace/bundle/molecules/section_formatter.rb +580 -0
- data/lib/ace/bundle/molecules/section_processor.rb +460 -0
- data/lib/ace/bundle/organisms/bundle_loader.rb +1436 -0
- data/lib/ace/bundle/organisms/pr_bundle_loader.rb +147 -0
- data/lib/ace/bundle/version.rb +7 -0
- data/lib/ace/bundle.rb +251 -0
- 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
|