ace-bundle 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/bundle/config.yml +28 -0
  3. data/.ace-defaults/bundle/presets/base.md +15 -0
  4. data/.ace-defaults/bundle/presets/code-review.md +61 -0
  5. data/.ace-defaults/bundle/presets/development.md +16 -0
  6. data/.ace-defaults/bundle/presets/documentation-review.md +52 -0
  7. data/.ace-defaults/bundle/presets/mixed-content-example.md +94 -0
  8. data/.ace-defaults/bundle/presets/project-context.md +79 -0
  9. data/.ace-defaults/bundle/presets/project.md +35 -0
  10. data/.ace-defaults/bundle/presets/section-example-simple.md +27 -0
  11. data/.ace-defaults/bundle/presets/security-review.md +53 -0
  12. data/.ace-defaults/bundle/presets/simple-project.md +43 -0
  13. data/.ace-defaults/bundle/presets/team.md +18 -0
  14. data/.ace-defaults/nav/protocols/wfi-sources/ace-bundle.yml +19 -0
  15. data/CHANGELOG.md +384 -0
  16. data/LICENSE +21 -0
  17. data/README.md +40 -0
  18. data/Rakefile +22 -0
  19. data/exe/ace-bundle +14 -0
  20. data/handbook/skills/as-bundle/SKILL.md +28 -0
  21. data/handbook/skills/as-onboard/SKILL.md +33 -0
  22. data/handbook/workflow-instructions/bundle.wf.md +111 -0
  23. data/handbook/workflow-instructions/onboard.wf.md +20 -0
  24. data/lib/ace/bundle/atoms/boundary_finder.rb +122 -0
  25. data/lib/ace/bundle/atoms/bundle_normalizer.rb +128 -0
  26. data/lib/ace/bundle/atoms/content_checker.rb +46 -0
  27. data/lib/ace/bundle/atoms/line_counter.rb +37 -0
  28. data/lib/ace/bundle/atoms/preset_list_formatter.rb +44 -0
  29. data/lib/ace/bundle/atoms/preset_validator.rb +69 -0
  30. data/lib/ace/bundle/atoms/section_validator.rb +215 -0
  31. data/lib/ace/bundle/atoms/typo_detector.rb +76 -0
  32. data/lib/ace/bundle/cli/commands/load.rb +347 -0
  33. data/lib/ace/bundle/cli.rb +26 -0
  34. data/lib/ace/bundle/models/bundle_data.rb +75 -0
  35. data/lib/ace/bundle/molecules/bundle_chunker.rb +280 -0
  36. data/lib/ace/bundle/molecules/bundle_file_writer.rb +269 -0
  37. data/lib/ace/bundle/molecules/bundle_merger.rb +248 -0
  38. data/lib/ace/bundle/molecules/preset_manager.rb +331 -0
  39. data/lib/ace/bundle/molecules/section_compressor.rb +249 -0
  40. data/lib/ace/bundle/molecules/section_formatter.rb +580 -0
  41. data/lib/ace/bundle/molecules/section_processor.rb +460 -0
  42. data/lib/ace/bundle/organisms/bundle_loader.rb +1436 -0
  43. data/lib/ace/bundle/organisms/pr_bundle_loader.rb +147 -0
  44. data/lib/ace/bundle/version.rb +7 -0
  45. data/lib/ace/bundle.rb +251 -0
  46. metadata +190 -0
@@ -0,0 +1,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