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,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Bundle
|
|
7
|
+
module Molecules
|
|
8
|
+
# BundleMerger merges multiple bundle results into a single combined result
|
|
9
|
+
class BundleMerger
|
|
10
|
+
# Merge multiple bundle results into one
|
|
11
|
+
def merge_bundles(bundles)
|
|
12
|
+
return empty_merge_result if bundles.nil? || bundles.empty?
|
|
13
|
+
return bundles.first if bundles.size == 1
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
success: true,
|
|
17
|
+
files: merge_files(bundles),
|
|
18
|
+
commands: merge_commands(bundles),
|
|
19
|
+
errors: merge_errors(bundles),
|
|
20
|
+
sources: extract_sources(bundles),
|
|
21
|
+
metadata: merge_metadata(bundles),
|
|
22
|
+
merged: true,
|
|
23
|
+
total_bundles: bundles.size,
|
|
24
|
+
total_files: bundles.sum { |c| c[:total_files] || c[:files]&.size || 0 },
|
|
25
|
+
total_commands: bundles.sum { |c| c[:total_commands] || c[:commands]&.size || 0 },
|
|
26
|
+
total_errors: bundles.sum { |c| c[:total_errors] || c[:errors]&.size || 0 },
|
|
27
|
+
total_size: bundles.sum { |c| c[:total_size] || 0 }
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Determine output path from multiple presets
|
|
32
|
+
def resolve_output_path(presets, command_output = nil)
|
|
33
|
+
# Command-line flag has highest priority
|
|
34
|
+
return command_output if command_output
|
|
35
|
+
|
|
36
|
+
# Extract output paths from presets
|
|
37
|
+
output_paths = presets.map { |p| p[:output] }.compact
|
|
38
|
+
|
|
39
|
+
# If any preset wants stdout (no output), use stdout
|
|
40
|
+
return nil if output_paths.size < presets.size
|
|
41
|
+
|
|
42
|
+
# If all presets have the same output path, use it
|
|
43
|
+
return output_paths.first if output_paths.uniq.size == 1
|
|
44
|
+
|
|
45
|
+
# Different output paths = conflict, default to stdout
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Merge data structures with source attribution
|
|
50
|
+
def merge_with_attribution(bundles, source_key = nil)
|
|
51
|
+
merged_data = {
|
|
52
|
+
files: [],
|
|
53
|
+
commands: [],
|
|
54
|
+
errors: [],
|
|
55
|
+
metadata: {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
bundles.each do |bundle|
|
|
59
|
+
source = extract_source(bundle, source_key)
|
|
60
|
+
|
|
61
|
+
# Add files with source
|
|
62
|
+
if bundle[:files]
|
|
63
|
+
bundle[:files].each do |file|
|
|
64
|
+
file_with_source = file.dup
|
|
65
|
+
file_with_source[:source] = source if source
|
|
66
|
+
merged_data[:files] << file_with_source
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Add commands with source
|
|
71
|
+
if bundle[:commands]
|
|
72
|
+
bundle[:commands].each do |cmd|
|
|
73
|
+
cmd_with_source = cmd.dup
|
|
74
|
+
cmd_with_source[:source] = source if source
|
|
75
|
+
merged_data[:commands] << cmd_with_source
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Collect errors
|
|
80
|
+
if bundle[:errors]
|
|
81
|
+
merged_data[:errors].concat(bundle[:errors])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Merge metadata
|
|
85
|
+
if bundle[:metadata]
|
|
86
|
+
merged_data[:metadata] = deep_merge(merged_data[:metadata], bundle[:metadata])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
merged_data
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Return empty hash structure for nil/empty bundle inputs
|
|
96
|
+
# This ensures consistent return type with multi-bundle merge results
|
|
97
|
+
def empty_merge_result
|
|
98
|
+
{
|
|
99
|
+
success: true,
|
|
100
|
+
files: [],
|
|
101
|
+
commands: [],
|
|
102
|
+
errors: [],
|
|
103
|
+
sources: [],
|
|
104
|
+
metadata: {merged_at: Time.now.iso8601},
|
|
105
|
+
merged: false,
|
|
106
|
+
total_bundles: 0,
|
|
107
|
+
total_files: 0,
|
|
108
|
+
total_commands: 0,
|
|
109
|
+
total_errors: 0,
|
|
110
|
+
total_size: 0
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Merge files from multiple bundles, deduplicating by path
|
|
115
|
+
def merge_files(bundles)
|
|
116
|
+
seen_paths = Set.new
|
|
117
|
+
merged = []
|
|
118
|
+
|
|
119
|
+
bundles.each do |bundle|
|
|
120
|
+
next unless bundle[:files]
|
|
121
|
+
|
|
122
|
+
bundle[:files].each do |file|
|
|
123
|
+
path = file[:path]
|
|
124
|
+
unless seen_paths.include?(path)
|
|
125
|
+
seen_paths.add(path)
|
|
126
|
+
# Add source attribution if available
|
|
127
|
+
file[:source] = bundle[:source_input] || bundle[:preset_name] if bundle[:source_input] || bundle[:preset_name]
|
|
128
|
+
merged << file
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
merged
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Merge commands from multiple bundles
|
|
137
|
+
def merge_commands(bundles)
|
|
138
|
+
merged = []
|
|
139
|
+
|
|
140
|
+
bundles.each do |bundle|
|
|
141
|
+
next unless bundle[:commands]
|
|
142
|
+
|
|
143
|
+
bundle[:commands].each do |cmd|
|
|
144
|
+
# Add source attribution
|
|
145
|
+
cmd[:source] = bundle[:source_input] || bundle[:preset_name] if bundle[:source_input] || bundle[:preset_name]
|
|
146
|
+
merged << cmd
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
merged
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Merge errors from multiple bundles
|
|
154
|
+
def merge_errors(bundles)
|
|
155
|
+
errors = []
|
|
156
|
+
|
|
157
|
+
bundles.each do |bundle|
|
|
158
|
+
next unless bundle[:errors]
|
|
159
|
+
|
|
160
|
+
bundle[:errors].each do |error|
|
|
161
|
+
# Add source information to error if not present
|
|
162
|
+
if error.is_a?(String)
|
|
163
|
+
source = bundle[:source_input] || bundle[:preset_name]
|
|
164
|
+
errors << (source ? "[#{source}] #{error}" : error)
|
|
165
|
+
else
|
|
166
|
+
errors << error
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
errors.uniq
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Extract source information from bundles
|
|
175
|
+
def extract_sources(bundles)
|
|
176
|
+
sources = []
|
|
177
|
+
|
|
178
|
+
bundles.each do |bundle|
|
|
179
|
+
if bundle[:preset_name]
|
|
180
|
+
sources << {type: "preset", name: bundle[:preset_name]}
|
|
181
|
+
elsif bundle[:source_input]
|
|
182
|
+
sources << {type: "input", path: bundle[:source_input]}
|
|
183
|
+
elsif bundle[:file_path]
|
|
184
|
+
sources << {type: "file", path: bundle[:file_path]}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
sources
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Merge metadata from multiple bundles
|
|
192
|
+
def merge_metadata(bundles)
|
|
193
|
+
metadata = {}
|
|
194
|
+
|
|
195
|
+
bundles.each do |bundle|
|
|
196
|
+
next unless bundle[:metadata]
|
|
197
|
+
|
|
198
|
+
metadata = deep_merge(metadata, bundle[:metadata])
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Add merge timestamp
|
|
202
|
+
metadata[:merged_at] = Time.now.iso8601
|
|
203
|
+
|
|
204
|
+
metadata
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Extract source identifier from bundle
|
|
208
|
+
def extract_source(bundle, source_key = nil)
|
|
209
|
+
if source_key && bundle[source_key]
|
|
210
|
+
bundle[source_key]
|
|
211
|
+
elsif bundle[:preset_name]
|
|
212
|
+
"preset:#{bundle[:preset_name]}"
|
|
213
|
+
elsif bundle[:source_input]
|
|
214
|
+
"input:#{bundle[:source_input]}"
|
|
215
|
+
elsif bundle[:file_path]
|
|
216
|
+
"file:#{bundle[:file_path]}"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Deep merge two hashes
|
|
221
|
+
def deep_merge(hash1, hash2)
|
|
222
|
+
return hash2 if hash1.nil?
|
|
223
|
+
return hash1 if hash2.nil?
|
|
224
|
+
|
|
225
|
+
merged = hash1.dup
|
|
226
|
+
|
|
227
|
+
hash2.each do |key, value2|
|
|
228
|
+
if merged.key?(key)
|
|
229
|
+
value1 = merged[key]
|
|
230
|
+
|
|
231
|
+
merged[key] = if value1.is_a?(Hash) && value2.is_a?(Hash)
|
|
232
|
+
deep_merge(value1, value2)
|
|
233
|
+
elsif value1.is_a?(Array) && value2.is_a?(Array)
|
|
234
|
+
(value1 + value2).uniq
|
|
235
|
+
else
|
|
236
|
+
value2
|
|
237
|
+
end
|
|
238
|
+
else
|
|
239
|
+
merged[key] = value2
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
merged
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/config"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "../atoms/preset_validator"
|
|
6
|
+
require_relative "../atoms/section_validator"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Bundle
|
|
10
|
+
module Molecules
|
|
11
|
+
# Manages context presets from markdown files in .ace/bundle/presets/
|
|
12
|
+
class PresetManager
|
|
13
|
+
attr_reader :presets
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@section_validator = Atoms::SectionValidator.new
|
|
17
|
+
@presets = load_presets
|
|
18
|
+
@preset_cache = {} # Cache for composed presets during single execution
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def list_presets
|
|
22
|
+
@presets.values.map(&:dup)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get_preset(name)
|
|
26
|
+
preset = @presets[name.to_s]
|
|
27
|
+
preset&.dup
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def preset_exists?(name)
|
|
31
|
+
@presets.key?(name.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Load a preset with composition support
|
|
35
|
+
# Returns fully composed preset data with all dependent presets merged
|
|
36
|
+
def load_preset_with_composition(name, visited = Set.new)
|
|
37
|
+
# Check circular dependency
|
|
38
|
+
validation = Atoms::PresetValidator.check_circular_dependency(name, visited.to_a)
|
|
39
|
+
unless validation[:success]
|
|
40
|
+
return {
|
|
41
|
+
error: validation[:error],
|
|
42
|
+
success: false
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if preset exists
|
|
47
|
+
preset = get_preset(name)
|
|
48
|
+
unless preset
|
|
49
|
+
return {
|
|
50
|
+
error: "Preset '#{name}' not found. Available presets: #{@presets.keys.join(", ")}",
|
|
51
|
+
success: false
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Mark this preset as visited
|
|
56
|
+
new_visited = visited.dup.add(name)
|
|
57
|
+
|
|
58
|
+
# Extract preset references
|
|
59
|
+
preset_refs = Atoms::PresetValidator.extract_preset_references(preset)
|
|
60
|
+
|
|
61
|
+
# If no references, return preset as-is
|
|
62
|
+
if preset_refs.empty?
|
|
63
|
+
preset[:success] = true
|
|
64
|
+
return preset
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Load all referenced presets recursively
|
|
68
|
+
composed_presets = []
|
|
69
|
+
errors = []
|
|
70
|
+
|
|
71
|
+
preset_refs.each do |ref_name|
|
|
72
|
+
composed = load_preset_with_composition(ref_name, new_visited)
|
|
73
|
+
if composed[:success]
|
|
74
|
+
composed_presets << composed
|
|
75
|
+
else
|
|
76
|
+
errors << composed[:error]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# If there were errors loading dependencies, return error
|
|
81
|
+
if errors.any?
|
|
82
|
+
return {
|
|
83
|
+
error: "Failed to load preset dependencies: #{errors.join(", ")}",
|
|
84
|
+
success: false,
|
|
85
|
+
partial_presets: composed_presets
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Merge all composed presets with current preset
|
|
90
|
+
# Order: dependencies first, then current preset
|
|
91
|
+
merged = merge_preset_data(composed_presets + [preset])
|
|
92
|
+
merged[:success] = true
|
|
93
|
+
merged[:composed] = true
|
|
94
|
+
merged[:composed_from] = preset_refs + [name]
|
|
95
|
+
|
|
96
|
+
merged
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Merge multiple preset data structures
|
|
100
|
+
# Arrays are concatenated and deduplicated (first occurrence wins)
|
|
101
|
+
# Scalars follow "last wins" strategy
|
|
102
|
+
def merge_preset_data(presets)
|
|
103
|
+
return presets.first if presets.size == 1
|
|
104
|
+
|
|
105
|
+
merged = {
|
|
106
|
+
description: nil,
|
|
107
|
+
params: {},
|
|
108
|
+
bundle: {},
|
|
109
|
+
body: "",
|
|
110
|
+
# Don't set format here - let BundleLoader determine the default
|
|
111
|
+
output: nil,
|
|
112
|
+
cache: false,
|
|
113
|
+
metadata: {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Collect all sections for merging
|
|
117
|
+
all_sections = []
|
|
118
|
+
|
|
119
|
+
presets.each do |preset|
|
|
120
|
+
# Merge bundle configuration
|
|
121
|
+
if preset[:bundle]
|
|
122
|
+
bundle_config = preset[:bundle]
|
|
123
|
+
|
|
124
|
+
# Merge params (scalar override)
|
|
125
|
+
if bundle_config["params"]
|
|
126
|
+
merged[:bundle]["params"] ||= {}
|
|
127
|
+
merged[:bundle]["params"].merge!(bundle_config["params"])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Merge files array (deduplicate)
|
|
131
|
+
if bundle_config["files"]
|
|
132
|
+
merged[:bundle]["files"] ||= []
|
|
133
|
+
merged[:bundle]["files"].concat(bundle_config["files"])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Merge commands array (deduplicate)
|
|
137
|
+
if bundle_config["commands"]
|
|
138
|
+
merged[:bundle]["commands"] ||= []
|
|
139
|
+
merged[:bundle]["commands"].concat(bundle_config["commands"])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Collect sections for separate processing
|
|
143
|
+
if bundle_config["sections"]
|
|
144
|
+
all_sections << bundle_config["sections"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Copy other bundle keys
|
|
148
|
+
bundle_config.each do |key, value|
|
|
149
|
+
next if %w[params files commands sections].include?(key)
|
|
150
|
+
merged[:bundle][key] = value
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Scalar overrides (last wins)
|
|
155
|
+
merged[:description] = preset[:description] if preset[:description]
|
|
156
|
+
# Don't override format from preset - let ContextLoader handle defaults based on embed_document_source
|
|
157
|
+
merged[:output] = preset[:output] if preset[:output]
|
|
158
|
+
merged[:compressor_mode] = preset[:compressor_mode] if preset[:compressor_mode]
|
|
159
|
+
merged[:compressor_source_scope] = preset[:compressor_source_scope] if preset[:compressor_source_scope]
|
|
160
|
+
merged[:cache] = preset[:cache] if preset[:cache]
|
|
161
|
+
|
|
162
|
+
# Merge params at root level for direct access
|
|
163
|
+
if preset[:params]
|
|
164
|
+
merged[:params].merge!(preset[:params])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Concatenate body content
|
|
168
|
+
if preset[:body] && !preset[:body].empty?
|
|
169
|
+
merged[:body] += "\n\n" unless merged[:body].empty?
|
|
170
|
+
merged[:body] += preset[:body]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Deep merge metadata
|
|
174
|
+
if preset[:metadata]
|
|
175
|
+
merged[:metadata] = deep_merge_hash(merged[:metadata], preset[:metadata])
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Merge sections using SectionProcessor if any exist
|
|
180
|
+
if all_sections.any?
|
|
181
|
+
require_relative "section_processor"
|
|
182
|
+
section_processor = Molecules::SectionProcessor.new
|
|
183
|
+
merged_sections = section_processor.merge_sections(*all_sections)
|
|
184
|
+
merged[:bundle]["sections"] = merged_sections
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Deduplicate arrays
|
|
188
|
+
if merged[:bundle]["files"]
|
|
189
|
+
merged[:bundle]["files"].uniq!
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if merged[:bundle]["commands"]
|
|
193
|
+
merged[:bundle]["commands"].uniq!
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Extract all merged params to root level
|
|
197
|
+
# This ensures params like output, format, timeout, max_size are accessible at root
|
|
198
|
+
if merged[:bundle]["params"]
|
|
199
|
+
merged_params = merged[:bundle]["params"]
|
|
200
|
+
|
|
201
|
+
# Store params hash at root level
|
|
202
|
+
merged[:params] = merged_params
|
|
203
|
+
|
|
204
|
+
# Extract ALL param keys to root level
|
|
205
|
+
merged_params.each do |key, value|
|
|
206
|
+
merged[key.to_sym] = value
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Derive cache boolean from output param
|
|
210
|
+
merged[:cache] = (merged_params["output"] == "cache")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
merged
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Deep merge two hashes (similar to BundleMerger but simpler)
|
|
219
|
+
def deep_merge_hash(hash1, hash2)
|
|
220
|
+
merged = hash1.dup
|
|
221
|
+
|
|
222
|
+
hash2.each do |key, value2|
|
|
223
|
+
if merged.key?(key)
|
|
224
|
+
value1 = merged[key]
|
|
225
|
+
merged[key] = if value1.is_a?(Hash) && value2.is_a?(Hash)
|
|
226
|
+
deep_merge_hash(value1, value2)
|
|
227
|
+
elsif value1.is_a?(Array) && value2.is_a?(Array)
|
|
228
|
+
(value1 + value2).uniq
|
|
229
|
+
else
|
|
230
|
+
value2 # Last wins
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
merged[key] = value2
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
merged
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def load_presets
|
|
241
|
+
presets = {}
|
|
242
|
+
|
|
243
|
+
# Use ace-config VirtualConfigResolver to find all context/*.md files
|
|
244
|
+
resolver = Ace::Support::Config.virtual_resolver
|
|
245
|
+
|
|
246
|
+
# Get all bundle/presets/*.md files from virtual map
|
|
247
|
+
resolver.glob("bundle/presets/*.md").each do |relative_path, absolute_path|
|
|
248
|
+
name = File.basename(absolute_path, ".md")
|
|
249
|
+
preset_data = load_preset_from_file(absolute_path)
|
|
250
|
+
|
|
251
|
+
if preset_data
|
|
252
|
+
preset_data[:name] = name
|
|
253
|
+
preset_data[:source_file] = absolute_path
|
|
254
|
+
presets[name] = preset_data
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
presets
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def load_preset_from_file(file)
|
|
262
|
+
content = File.read(file)
|
|
263
|
+
frontmatter, body = parse_frontmatter(content)
|
|
264
|
+
|
|
265
|
+
return nil unless frontmatter
|
|
266
|
+
|
|
267
|
+
# Use 'bundle' key for preset configuration
|
|
268
|
+
bundle_config = frontmatter["bundle"] || {}
|
|
269
|
+
params = bundle_config["params"] || {}
|
|
270
|
+
|
|
271
|
+
# Validate sections if present
|
|
272
|
+
if bundle_config["sections"]
|
|
273
|
+
unless @section_validator.validate_sections(bundle_config["sections"])
|
|
274
|
+
errors = @section_validator.errors
|
|
275
|
+
warn "Warning: Section validation failed in #{file}:\n #{errors.join("\n ")}\n\nPlease review the sections configuration in this file. The preset will continue to load, but section functionality may be limited."
|
|
276
|
+
# Don't fail loading, just warn - allow users to fix configuration
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
preset_data = {
|
|
281
|
+
description: frontmatter["description"] || "#{File.basename(file, ".md")} preset",
|
|
282
|
+
params: params,
|
|
283
|
+
bundle: bundle_config,
|
|
284
|
+
body: body.strip,
|
|
285
|
+
format: params["format"], # Don't set default here - let BundleLoader handle defaults
|
|
286
|
+
output: params["output"], # nil allows auto-format to determine output mode
|
|
287
|
+
cache: params["output"] == "cache",
|
|
288
|
+
metadata: frontmatter["metadata"] || {}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Add section validation metadata if sections were validated
|
|
292
|
+
if bundle_config["sections"]
|
|
293
|
+
preset_data[:metadata][:sections_validated] = true
|
|
294
|
+
preset_data[:metadata][:section_validation_errors] = @section_validator.errors unless @section_validator.errors.empty?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Extract all params to root level for direct access
|
|
298
|
+
params.each do |key, value|
|
|
299
|
+
preset_data[key.to_sym] = value
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Re-derive cache from output param (in case it was set via params extraction)
|
|
303
|
+
preset_data[:cache] = (params["output"] == "cache")
|
|
304
|
+
|
|
305
|
+
preset_data
|
|
306
|
+
rescue => e
|
|
307
|
+
warn "Error loading preset from #{file}: #{e.message}"
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def parse_frontmatter(content)
|
|
312
|
+
# Match YAML frontmatter between --- markers
|
|
313
|
+
if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
|
|
314
|
+
yaml_content = $1
|
|
315
|
+
body_content = $2
|
|
316
|
+
|
|
317
|
+
begin
|
|
318
|
+
frontmatter = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
|
|
319
|
+
[frontmatter, body_content]
|
|
320
|
+
rescue => e
|
|
321
|
+
warn "Error parsing YAML frontmatter: #{e.message}"
|
|
322
|
+
[nil, content]
|
|
323
|
+
end
|
|
324
|
+
else
|
|
325
|
+
[nil, content]
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|