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,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/boundary_finder"
4
+
5
+ module Ace
6
+ module Bundle
7
+ module Molecules
8
+ # BundleChunker splits large content into manageable chunks
9
+ # Configuration is loaded from Ace::Bundle.max_lines (ADR-022)
10
+ #
11
+ # Performance Note: Current implementation loads full content into memory.
12
+ # For very large files (>100MB), consider implementing streaming chunking
13
+ # to reduce memory footprint. This optimization can be added in future versions.
14
+ class BundleChunker
15
+ # Fallback value if config is not available
16
+ DEFAULT_MAX_LINES = 2_000
17
+ DEFAULT_CHUNK_SUFFIX = "_chunk"
18
+
19
+ attr_reader :max_lines
20
+
21
+ # @param max_lines [Integer, nil] Override max lines per chunk (nil uses config)
22
+ def initialize(max_lines = nil)
23
+ @max_lines = max_lines || config_max_lines || DEFAULT_MAX_LINES
24
+ end
25
+
26
+ private
27
+
28
+ # Load max_lines from configuration
29
+ # Falls back to DEFAULT_MAX_LINES if config is unavailable
30
+ # @return [Integer] Configured max lines per chunk
31
+ def config_max_lines
32
+ Ace::Bundle.max_lines
33
+ rescue
34
+ DEFAULT_MAX_LINES
35
+ end
36
+
37
+ public
38
+
39
+ # Check if content needs chunking
40
+ def needs_chunking?(content)
41
+ return false if content.nil? || content.empty?
42
+
43
+ line_count = content.lines.size
44
+ line_count > @max_lines
45
+ end
46
+
47
+ # Split content into chunks
48
+ def chunk_content(content, base_path, options = {})
49
+ opts = {
50
+ chunk_suffix: DEFAULT_CHUNK_SUFFIX,
51
+ include_metadata: true
52
+ }.merge(options)
53
+
54
+ return single_file_result(content, base_path) unless needs_chunking?(content)
55
+
56
+ lines = content.lines
57
+ chunks = split_into_chunks(lines)
58
+
59
+ chunk_files = generate_chunk_files(chunks, base_path, opts)
60
+ index_content = generate_index_content(chunk_files, base_path, opts)
61
+
62
+ {
63
+ chunked: true,
64
+ total_chunks: chunks.size,
65
+ max_lines: @max_lines,
66
+ total_lines: lines.size,
67
+ index_file: "#{base_path}.md",
68
+ index_content: index_content,
69
+ chunk_files: chunk_files,
70
+ total_size: calculate_total_size(chunk_files)
71
+ }
72
+ end
73
+
74
+ # Split content and write all files (index + chunks)
75
+ def chunk_and_write(content, base_path, file_writer, options = {})
76
+ chunk_result = chunk_content(content, base_path, options)
77
+
78
+ unless chunk_result[:chunked]
79
+ # Write single file
80
+ return {
81
+ chunked: false,
82
+ files_written: 1,
83
+ results: [
84
+ file_writer.write(content, "#{base_path}.md", options)
85
+ ]
86
+ }
87
+ end
88
+
89
+ # Write index file and all chunks
90
+ write_results = []
91
+
92
+ # Write index file
93
+ index_result = file_writer.write(
94
+ chunk_result[:index_content],
95
+ chunk_result[:index_file],
96
+ options
97
+ )
98
+ write_results << index_result.merge(file_type: "index")
99
+
100
+ # Write chunk files
101
+ chunk_result[:chunk_files].each do |chunk_info|
102
+ chunk_result_write = file_writer.write(
103
+ chunk_info[:content],
104
+ chunk_info[:path],
105
+ options
106
+ )
107
+ write_results << chunk_result_write.merge(file_type: "chunk", chunk_number: chunk_info[:chunk_number])
108
+
109
+ # Progress callback if provided
110
+ if options[:progress_callback]
111
+ options[:progress_callback].call("Wrote chunk #{chunk_info[:chunk_number]} of #{chunk_result[:total_chunks]}")
112
+ end
113
+ end
114
+
115
+ {
116
+ chunked: true,
117
+ total_chunks: chunk_result[:total_chunks],
118
+ files_written: write_results.size,
119
+ results: write_results
120
+ }
121
+ end
122
+
123
+ private
124
+
125
+ # Generate result for single file (no chunking needed)
126
+ def single_file_result(content, base_path)
127
+ {
128
+ chunked: false,
129
+ total_chunks: 1,
130
+ total_lines: content.lines.size,
131
+ file_path: "#{base_path}.md",
132
+ content: content,
133
+ total_size: content.bytesize
134
+ }
135
+ end
136
+
137
+ # Split lines into chunks using semantic boundaries when possible
138
+ # Semantic boundaries ensure XML elements like <file> and <output> are never split
139
+ def split_into_chunks(lines)
140
+ content = lines.join
141
+
142
+ # Check if content has semantic elements (XML tags we shouldn't split)
143
+ if Atoms::BoundaryFinder.has_semantic_elements?(content)
144
+ split_by_semantic_boundaries(content)
145
+ else
146
+ split_by_line_count(lines)
147
+ end
148
+ end
149
+
150
+ # Split content using semantic boundaries (never splits <file> or <output> elements)
151
+ # Falls back to keeping large single elements whole rather than splitting them
152
+ def split_by_semantic_boundaries(content)
153
+ blocks = Atoms::BoundaryFinder.parse_blocks(content)
154
+
155
+ chunks = []
156
+ current_chunk_blocks = []
157
+ current_line_count = 0
158
+
159
+ blocks.each do |block|
160
+ block_lines = block[:lines]
161
+
162
+ # If adding this block would exceed limit and we have content, flush current chunk
163
+ if current_line_count + block_lines > @max_lines && current_chunk_blocks.any?
164
+ chunks << current_chunk_blocks.map { |b| b[:content] }.join
165
+ current_chunk_blocks = []
166
+ current_line_count = 0
167
+ end
168
+
169
+ # Add block to current chunk (even if it exceeds limit - we don't split elements)
170
+ current_chunk_blocks << block
171
+ current_line_count += block_lines
172
+ end
173
+
174
+ # Add remaining blocks
175
+ chunks << current_chunk_blocks.map { |b| b[:content] }.join if current_chunk_blocks.any?
176
+
177
+ chunks
178
+ end
179
+
180
+ # Original line-based splitting for content without semantic elements
181
+ def split_by_line_count(lines)
182
+ chunks = []
183
+ current_chunk = []
184
+
185
+ lines.each do |line|
186
+ current_chunk << line
187
+
188
+ if current_chunk.size >= @max_lines
189
+ chunks << current_chunk.join
190
+ current_chunk = []
191
+ end
192
+ end
193
+
194
+ # Add remaining lines
195
+ chunks << current_chunk.join unless current_chunk.empty?
196
+
197
+ chunks
198
+ end
199
+
200
+ # Generate chunk file information
201
+ def generate_chunk_files(chunks, base_path, options)
202
+ chunk_files = []
203
+
204
+ chunks.each_with_index do |chunk_content, index|
205
+ chunk_number = index + 1
206
+ chunk_path = "#{base_path}#{options[:chunk_suffix]}_#{chunk_number.to_s.rjust(3, "0")}.md"
207
+
208
+ chunk_files << {
209
+ chunk_number: chunk_number,
210
+ path: chunk_path,
211
+ content: chunk_content,
212
+ lines: chunk_content.lines.size,
213
+ size: chunk_content.bytesize
214
+ }
215
+ end
216
+
217
+ chunk_files
218
+ end
219
+
220
+ # Generate index file content
221
+ def generate_index_content(chunk_files, base_path, options)
222
+ index_lines = []
223
+
224
+ index_lines << "# Bundle Index"
225
+ index_lines << ""
226
+ index_lines << "This content has been split into #{chunk_files.size} chunks due to size constraints."
227
+ index_lines << ""
228
+ index_lines << "## Summary"
229
+ index_lines << ""
230
+ index_lines << "- Total chunks: #{chunk_files.size}"
231
+ index_lines << "- Max lines per chunk: #{@max_lines}"
232
+ index_lines << "- Total size: #{format_bytes(calculate_total_size(chunk_files))}"
233
+ index_lines << ""
234
+ index_lines << "## Chunks"
235
+ index_lines << ""
236
+
237
+ chunk_files.each do |chunk_info|
238
+ relative_path = chunk_info[:path].sub(%r{^.*/}, "")
239
+ index_lines << "### Chunk #{chunk_info[:chunk_number]}"
240
+ index_lines << ""
241
+ index_lines << "- File: [#{relative_path}](#{relative_path})"
242
+ index_lines << "- Lines: #{chunk_info[:lines]}"
243
+ index_lines << "- Size: #{format_bytes(chunk_info[:size])}"
244
+ index_lines << ""
245
+ end
246
+
247
+ if options[:include_metadata]
248
+ index_lines << "## Metadata"
249
+ index_lines << ""
250
+ index_lines << "- Generated at: #{Time.now.iso8601}"
251
+ index_lines << "- Base path: #{base_path}"
252
+ index_lines << "- Chunk suffix: #{options[:chunk_suffix]}"
253
+ index_lines << ""
254
+ end
255
+
256
+ index_lines.join("\n")
257
+ end
258
+
259
+ # Calculate total size of all chunks
260
+ def calculate_total_size(chunk_files)
261
+ chunk_files.sum { |chunk| chunk[:size] }
262
+ end
263
+
264
+ # Format bytes for human readability
265
+ def format_bytes(bytes)
266
+ units = ["B", "KB", "MB", "GB"]
267
+ size = bytes.to_f
268
+ unit_index = 0
269
+
270
+ while size >= 1024 && unit_index < units.size - 1
271
+ size /= 1024
272
+ unit_index += 1
273
+ end
274
+
275
+ "#{size.round(2)} #{units[unit_index]}"
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+ require_relative "bundle_chunker"
6
+ require_relative "section_formatter"
7
+
8
+ module Ace
9
+ module Bundle
10
+ module Molecules
11
+ # BundleFileWriter handles writing bundle to files with caching and chunking
12
+ # Configuration values (cache_dir, max_lines) are loaded from Ace::Bundle.config
13
+ # following ADR-022 pattern.
14
+ class BundleFileWriter
15
+ def initialize(cache_dir: nil, max_lines: nil)
16
+ @cache_dir = cache_dir || Ace::Bundle.cache_dir
17
+ @max_lines = max_lines || Ace::Bundle.max_lines
18
+ @chunker = BundleChunker.new(@max_lines)
19
+ end
20
+
21
+ # Write bundle with optional chunking
22
+ def write_with_chunking(bundle, output_path, options = {})
23
+ # Check if we should organize by sections
24
+ if options[:organize_by_sections] && bundle.respond_to?(:has_sections?) && bundle.has_sections?
25
+ write_sections_organized(bundle, output_path, options)
26
+ else
27
+ content = format_content(bundle, options[:format])
28
+
29
+ # Determine actual output path
30
+ path = resolve_output_path(output_path)
31
+
32
+ # Check if chunking is needed
33
+ if @chunker.needs_chunking?(content)
34
+ write_chunked_content(content, path, options)
35
+ else
36
+ write_single_file(content, path, options)
37
+ end
38
+ end
39
+ end
40
+
41
+ # Write single file
42
+ def write(content, path, options = {})
43
+ # Ensure directory exists
44
+ dir = File.dirname(path)
45
+ unless File.directory?(dir)
46
+ FileUtils.mkdir_p(dir)
47
+ # Validate that directory was created successfully
48
+ unless File.directory?(dir)
49
+ return {
50
+ success: false,
51
+ error: "Failed to create directory: #{dir}",
52
+ path: path
53
+ }
54
+ end
55
+ end
56
+
57
+ # Write file
58
+ File.write(path, content)
59
+
60
+ # Calculate statistics
61
+ lines = content.lines.size
62
+ size = content.bytesize
63
+
64
+ {
65
+ success: true,
66
+ path: path,
67
+ lines: lines,
68
+ size: size,
69
+ size_formatted: format_bytes(size)
70
+ }
71
+ rescue => e
72
+ {
73
+ success: false,
74
+ error: e.message,
75
+ path: path
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ # Format content based on bundle data
82
+ def format_content(bundle, format = nil)
83
+ if bundle.respond_to?(:content)
84
+ bundle.content
85
+ elsif bundle.is_a?(Hash)
86
+ formatter = Ace::Core::Molecules::OutputFormatter.new(format || "markdown-xml")
87
+ formatter.format(bundle)
88
+ else
89
+ bundle.to_s
90
+ end
91
+ end
92
+
93
+ # Write bundle organized by sections
94
+ def write_sections_organized(bundle, output_path, options = {})
95
+ base_path = resolve_output_path(output_path)
96
+ format = options[:format] || "markdown-xml"
97
+
98
+ # Create section formatter
99
+ section_formatter = SectionFormatter.new(format)
100
+
101
+ # Write main index file
102
+ index_content = create_section_index(bundle, section_formatter)
103
+ index_result = write_single_file(index_content, base_path, options)
104
+
105
+ return index_result unless index_result[:success]
106
+
107
+ # Write individual section files
108
+ section_files = write_individual_sections(bundle, base_path, section_formatter, options)
109
+
110
+ {
111
+ success: true,
112
+ chunked: true,
113
+ index_file: base_path,
114
+ section_files: section_files,
115
+ total_files: section_files.size + 1,
116
+ sections_written: section_files.size,
117
+ lines: index_result[:lines],
118
+ size_formatted: index_result[:size_formatted]
119
+ }
120
+ end
121
+
122
+ # Create section index content
123
+ def create_section_index(bundle, section_formatter)
124
+ content = []
125
+
126
+ content << "# Bundle Sections Index"
127
+ content << ""
128
+ content << "This bundle is organized into the following sections:"
129
+ content << ""
130
+
131
+ # Add section links
132
+ bundle.sorted_sections.each do |section_name, section_data|
133
+ title = section_data[:title] || section_data["title"] || section_name.to_s.humanize
134
+ content << "- [#{title}](#{section_name}.md)"
135
+ end
136
+
137
+ content << ""
138
+ content << "---"
139
+ content << ""
140
+ content << "## Complete Bundle"
141
+ content << ""
142
+
143
+ # Add complete formatted bundle
144
+ content << section_formatter.format_with_sections(bundle)
145
+
146
+ content.join("\n")
147
+ end
148
+
149
+ # Write individual section files
150
+ def write_individual_sections(bundle, base_path, section_formatter, options)
151
+ section_files = []
152
+ base_dir = File.dirname(base_path)
153
+ base_name = File.basename(base_path, ".*")
154
+
155
+ bundle.sorted_sections.each do |section_name, section_data|
156
+ # Create section filename
157
+ section_filename = File.join(base_dir, "#{base_name}-#{section_name}.md")
158
+
159
+ # Create section content
160
+ section_content = create_section_content(section_name, section_data, bundle, section_formatter)
161
+
162
+ # Write section file
163
+ result = write_single_file(section_content, section_filename, options)
164
+
165
+ if result[:success]
166
+ section_files << {
167
+ section: section_name,
168
+ title: section_data[:title] || section_data["title"] || section_name.to_s.humanize,
169
+ file: section_filename,
170
+ lines: result[:lines],
171
+ size: result[:size]
172
+ }
173
+ end
174
+ end
175
+
176
+ section_files
177
+ end
178
+
179
+ # Create content for an individual section
180
+ def create_section_content(section_name, section_data, bundle, section_formatter)
181
+ content = []
182
+
183
+ title = section_data[:title] || section_data["title"] || section_name.to_s.humanize
184
+
185
+ content << "# #{title}"
186
+ content << ""
187
+
188
+ # Add section metadata
189
+ content << "**Section:** #{section_name}"
190
+ content << "**Content Type:** #{section_data[:content_type] || section_data["content_type"]}"
191
+ content << "**Priority:** #{section_data[:priority] || section_data["priority"] || "N/A"}"
192
+ content << ""
193
+
194
+ # Add section description if available
195
+ if section_data[:description] || section_data["description"]
196
+ content << "## Description"
197
+ content << section_data[:description] || section_data["description"]
198
+ content << ""
199
+ end
200
+
201
+ # Add section content
202
+ content << "## Content"
203
+ content << ""
204
+
205
+ # Format just this section
206
+ single_section = {section_name => section_data}
207
+ content << section_formatter.format_sections_only(single_section)
208
+
209
+ # Add navigation back to index
210
+ content << ""
211
+ content << "---"
212
+ content << ""
213
+ content << "[← Back to Index](#{File.basename(bundle.metadata[:output_file] || "bundle.md")})"
214
+
215
+ content.join("\n")
216
+ end
217
+
218
+ # Resolve output path with cache directory
219
+ def resolve_output_path(output_path)
220
+ # If output path is explicitly provided, use it as-is
221
+ output_path
222
+ end
223
+
224
+ # Write content in chunks
225
+ def write_chunked_content(content, base_path, options)
226
+ # Remove extension from base path for chunking
227
+ base_path_no_ext = base_path.sub(/\.[^.]+$/, "")
228
+
229
+ # Chunk and write content
230
+ result = @chunker.chunk_and_write(content, base_path_no_ext, self, options)
231
+
232
+ # Add formatted output path
233
+ result[:index_file] = "#{base_path_no_ext}.md"
234
+ result[:success] = true
235
+
236
+ result
237
+ end
238
+
239
+ # Write single file (no chunking)
240
+ def write_single_file(content, path, options)
241
+ result = write(content, path, options)
242
+
243
+ {
244
+ success: result[:success],
245
+ chunked: false,
246
+ files_written: result[:success] ? 1 : 0,
247
+ lines: result[:lines],
248
+ size_formatted: result[:size_formatted],
249
+ error: result[:error]
250
+ }
251
+ end
252
+
253
+ # Format bytes for human readability
254
+ def format_bytes(bytes)
255
+ units = ["B", "KB", "MB", "GB"]
256
+ size = bytes.to_f
257
+ unit_index = 0
258
+
259
+ while size >= 1024 && unit_index < units.size - 1
260
+ size /= 1024
261
+ unit_index += 1
262
+ end
263
+
264
+ "#{size.round(2)} #{units[unit_index]}"
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end