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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Bundle
5
+ module Atoms
6
+ # Pure functions for detecting typos in frontmatter keys
7
+ # Uses Levenshtein-like edit distance for similarity detection
8
+ module TypoDetector
9
+ # Known keys for templates and workflows
10
+ KNOWN_FRONTMATTER_KEYS = %w[
11
+ context files commands include exclude diffs
12
+ name description allowed-tools params argument-hint
13
+ update frequency sections last-updated
14
+ auto_generate template-refs embed_document_source
15
+ doc-type purpose source title author version
16
+ ].freeze
17
+
18
+ class << self
19
+ # Detect suspicious frontmatter keys that might be typos
20
+ # @param frontmatter [Hash] Parsed frontmatter YAML
21
+ # @param path [String] File path for warning message
22
+ # @return [Array<String>] List of warning messages
23
+ def detect_suspicious_keys(frontmatter, path)
24
+ warnings = []
25
+ frontmatter.keys.each do |key|
26
+ next if KNOWN_FRONTMATTER_KEYS.include?(key)
27
+
28
+ # Check for common typos using Levenshtein-like distance
29
+ KNOWN_FRONTMATTER_KEYS.each do |known|
30
+ if typo_distance(key, known) <= 2
31
+ warnings << "Possible typo in #{path}: frontmatter key '#{key}' looks similar to known key '#{known}'"
32
+ break
33
+ end
34
+ end
35
+ end
36
+
37
+ warnings
38
+ end
39
+
40
+ # Calculate simple edit distance between two strings
41
+ # Uses Levenshtein distance algorithm
42
+ # @param str1 [String] First string
43
+ # @param str2 [String] Second string
44
+ # @return [Integer] Edit distance
45
+ def typo_distance(str1, str2)
46
+ return str2.length if str1.empty?
47
+ return str1.length if str2.empty?
48
+
49
+ # Create distance matrix
50
+ rows = str1.length + 1
51
+ cols = str2.length + 1
52
+ dist = Array.new(rows) { Array.new(cols, 0) }
53
+
54
+ # Initialize first row and column
55
+ (0...rows).each { |i| dist[i][0] = i }
56
+ (0...cols).each { |j| dist[0][j] = j }
57
+
58
+ # Fill in rest of matrix
59
+ (1...rows).each do |i|
60
+ (1...cols).each do |j|
61
+ cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
62
+ dist[i][j] = [
63
+ dist[i - 1][j] + 1, # deletion
64
+ dist[i][j - 1] + 1, # insertion
65
+ dist[i - 1][j - 1] + cost # substitution
66
+ ].min
67
+ end
68
+ end
69
+
70
+ dist[rows - 1][cols - 1]
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ace/support/fs"
5
+ require_relative "../../atoms/line_counter"
6
+ require_relative "../../atoms/preset_list_formatter"
7
+
8
+ module Ace
9
+ module Bundle
10
+ module CLI
11
+ module Commands
12
+ # ace-support-cli Command class for the load command
13
+ #
14
+ # Loads context from preset, file, or protocol URL
15
+ class Load < Ace::Support::Cli::Command
16
+ include Ace::Support::Cli::Base
17
+
18
+ desc <<~DESC.strip
19
+ Load context from preset, file, or protocol URL
20
+
21
+ INPUT can be:
22
+ - Preset name (e.g., 'project', 'base')
23
+ - File path (e.g., '/path/to/config.yml', './context.md')
24
+ - Protocol URL (e.g., 'wfi://workflow', 'guide://testing')
25
+
26
+ Configuration:
27
+ Global config: ~/.ace/bundle/config.yml
28
+ Project config: .ace/bundle/config.yml
29
+ Example: ace-bundle/.ace-defaults/bundle/config.yml
30
+
31
+ Presets defined in: .ace/bundle/presets/
32
+
33
+ Output:
34
+ By default, output saved to cache and file path printed
35
+ Use --output stdio to print to stdout
36
+ Exit codes: 0 (success), 1 (error)
37
+
38
+ Protocols:
39
+ wfi:// Workflow instructions
40
+ guide:// Development guides
41
+ prompt:// Prompt templates
42
+ tmpl:// General templates
43
+ DESC
44
+
45
+ example [
46
+ "project # Load project preset",
47
+ "wfi://work-on-task # Load workflow via protocol",
48
+ "-p base -p custom # Merge multiple presets",
49
+ "-f config.yml # Load from file",
50
+ "--inspect-config # Show resolved configuration"
51
+ ]
52
+
53
+ # Define positional argument
54
+ argument :input, required: false, desc: "Preset name, file path, or protocol URL"
55
+
56
+ # Preset options
57
+ option :preset, type: :array, aliases: %w[-p], desc: "Load context from preset (can be used multiple times)"
58
+ option :presets, type: :string, desc: "Load multiple presets (comma-separated list)"
59
+
60
+ # File options
61
+ option :file, type: :array, aliases: %w[-f], desc: "Load context from file (can be used multiple times)"
62
+
63
+ # Config options
64
+ option :inspect_config, type: :boolean, desc: "Show merged configuration without loading files"
65
+
66
+ # Output options
67
+ option :embed_source, type: :boolean, aliases: %w[-e], desc: "Embed source document in output"
68
+ option :output, type: :string, aliases: %w[-o], desc: "Output mode: stdio, cache, or file path"
69
+ option :format, type: :string, desc: "Output format (markdown, yaml, xml, markdown-xml, json)"
70
+
71
+ # Compression
72
+ option :compressor, type: :string, default: nil,
73
+ desc: "Enable/disable compression: on, off"
74
+ option :compressor_mode, type: :string, default: nil,
75
+ desc: "Compressor engine: exact, agent (default: exact)"
76
+ option :compressor_source_scope, type: :string, default: nil,
77
+ desc: "Source handling: off, per-source, merged (default: off)"
78
+
79
+ # Resource limits
80
+ option :max_size, type: :integer, desc: "Maximum file size in bytes"
81
+ option :timeout, type: :integer, desc: "Command timeout in seconds"
82
+
83
+ # Standard options (inherited from Base but need explicit definition for ace-support-cli)
84
+ option :version, type: :boolean, desc: "Show version information"
85
+ option :list_presets, type: :boolean, desc: "List available context presets"
86
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
87
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
88
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
89
+
90
+ def call(input: nil, **options)
91
+ # Handle --help/-h passed as input argument
92
+ if input == "--help" || input == "-h"
93
+ # ace-support-cli will handle this
94
+ return
95
+ end
96
+
97
+ if options[:version]
98
+ puts "ace-bundle #{Ace::Bundle::VERSION}"
99
+ return
100
+ end
101
+
102
+ if options[:list_presets]
103
+ presets = Ace::Bundle.list_presets
104
+ Atoms::PresetListFormatter.format(presets).each { |line| puts line }
105
+ return
106
+ end
107
+
108
+ # Type-convert numeric options using Base helper for proper validation
109
+ # coerce_types uses Integer() which raises ArgumentError on invalid input
110
+ # (unlike .to_i which silently returns 0)
111
+ coerce_types(options, max_size: :integer, timeout: :integer)
112
+
113
+ # Handle repeatable options (type: :array returns array, single values need wrapping)
114
+ # --preset returns array when used multiple times, nil otherwise
115
+ if options[:preset] && options[:presets]
116
+ # If both --preset and --presets provided, merge them
117
+ presets = [options[:preset]].flatten + options[:presets].split(",")
118
+ options[:preset] = presets.map(&:strip)
119
+ elsif options[:presets]
120
+ options[:preset] = options[:presets].split(",").map(&:strip)
121
+ elsif options[:preset]
122
+ # Ensure array even for single value
123
+ options[:preset] = [options[:preset]].flatten
124
+ end
125
+
126
+ # Same for file option
127
+ options[:file] = [options[:file]].flatten if options[:file]
128
+
129
+ # Normalize --compressor toggle
130
+ if options.key?(:compressor) && options[:compressor]
131
+ val = options[:compressor].to_s.downcase
132
+ options[:compressor] = case val
133
+ when "true", "yes", "on", "1" then "on"
134
+ when "false", "no", "off", "0" then "off"
135
+ else val
136
+ end
137
+ end
138
+
139
+ # Normalize compressor_source_scope option
140
+ if options.key?(:compressor_source_scope) && options[:compressor_source_scope]
141
+ val = options[:compressor_source_scope].to_s.downcase
142
+ options[:compressor_source_scope] = case val
143
+ when "true", "yes", "on", "" then "per-source"
144
+ when "false", "no", "off" then "off"
145
+ else val
146
+ end
147
+ end
148
+
149
+ execute(input, options)
150
+ end
151
+
152
+ private
153
+
154
+ def execute(input, options)
155
+ display_config_summary(options)
156
+ # Process repeatable options and extract mutable values
157
+ presets, files = process_options(options)
158
+
159
+ # Determine input source and load context
160
+ result = if options[:inspect_config]
161
+ inspect_config_mode(presets, files, input, options)
162
+ elsif @multi_input_mode
163
+ load_multiple_inputs(presets, files, options)
164
+ elsif input
165
+ load_auto(input, options)
166
+ else
167
+ load_auto("default", options)
168
+ end
169
+
170
+ # Handle errors
171
+ if result[:context].metadata[:error]
172
+ msg = result[:context].metadata[:error]
173
+ if result[:context].metadata[:errors] && options[:debug]
174
+ msg = "#{msg}\n#{result[:context].metadata[:errors].join("\n")}"
175
+ end
176
+ raise Ace::Support::Cli::Error.new(msg)
177
+ end
178
+
179
+ # Handle output
180
+ handle_output(result[:context], result[:input], options)
181
+ end
182
+
183
+ def process_options(options)
184
+ # Extract preset options (already normalized in call method)
185
+ presets = Array(options[:preset] || []).compact
186
+
187
+ # Extract file options
188
+ files = Array(options[:file] || []).compact
189
+
190
+ # Determine if we're in multi-input mode
191
+ @multi_input_mode = presets.any? || files.any?
192
+
193
+ [presets, files]
194
+ end
195
+
196
+ def inspect_config_mode(presets, files, input, options)
197
+ inputs = []
198
+ inputs.concat(presets) if presets.any?
199
+ inputs.concat(files) if files.any?
200
+ inputs << input if input && inputs.empty?
201
+ inputs << "default" if inputs.empty?
202
+
203
+ context = Ace::Bundle.inspect_config(inputs, options)
204
+ {context: context, input: inputs.join("-")}
205
+ end
206
+
207
+ def load_multiple_inputs(presets, files, options)
208
+ context = Ace::Bundle.load_multiple_inputs(presets, files, options)
209
+
210
+ # Create input string for cache filename
211
+ all_inputs = presets + files.map { |f| File.basename(f, ".*") }
212
+ input = all_inputs.join("-")
213
+
214
+ {context: context, input: input}
215
+ end
216
+
217
+ def load_auto(input, options)
218
+ context = Ace::Bundle.load_auto(input, options)
219
+ {context: context, input: input}
220
+ end
221
+
222
+ def handle_output(context, input, options)
223
+ # Determine output mode
224
+ # Priority: CLI flag > preset metadata > auto-format based on line count
225
+ explicit_output = options[:output] || context.metadata[:output]
226
+
227
+ if explicit_output
228
+ # Explicit output mode specified - honor it
229
+ output_mode = explicit_output
230
+ else
231
+ # Auto-format: decide based on line count vs threshold
232
+ size_key = :raw_content_for_auto_format
233
+ size_source = context.metadata[size_key] ||
234
+ context.metadata[size_key.to_s] ||
235
+ context.content
236
+ line_count = Atoms::LineCounter.count(size_source)
237
+ threshold = Ace::Bundle.auto_format_threshold
238
+
239
+ output_mode = (line_count >= threshold) ? "cache" : "stdio"
240
+ end
241
+
242
+ # Handle output based on mode
243
+ case output_mode
244
+ when "stdio"
245
+ puts context.content
246
+ when "cache"
247
+ write_to_cache(context, input, options)
248
+ else
249
+ write_to_file(context, output_mode, options)
250
+ end
251
+ end
252
+
253
+ def write_to_cache(context, input, options)
254
+ project_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
255
+ configured_cache_dir = Ace::Bundle.cache_dir
256
+ cache_dir = if configured_cache_dir.start_with?("/")
257
+ configured_cache_dir
258
+ else
259
+ File.join(project_root, configured_cache_dir)
260
+ end
261
+ FileUtils.mkdir_p(cache_dir)
262
+
263
+ # Generate cache filename from input (preset name, protocol, or sanitized file path)
264
+ cache_name = input.gsub(/[^a-zA-Z0-9-]/, "_")
265
+ cache_file = File.join(cache_dir, "#{cache_name}.md")
266
+ result = Ace::Bundle.write_output(context, cache_file, options)
267
+
268
+ if result[:success]
269
+ if result[:chunked]
270
+ chunks = result[:results].select { |r| r[:file_type] == "chunk" }
271
+ total_lines = chunks.sum { |r| r[:lines] || 0 }
272
+ total_size = chunks.sum { |r| r[:size] || 0 }
273
+ puts "Bundle saved (#{total_lines} lines, #{format_size(total_size)}) in #{chunks.size} chunks:"
274
+ chunks.each { |r| puts r[:path] }
275
+ else
276
+ puts "Bundle saved (#{result[:lines]} lines, #{result[:size_formatted]}), output file:"
277
+ puts cache_file
278
+ end
279
+ else
280
+ raise Ace::Support::Cli::Error.new("Error writing cache: #{result[:error]}")
281
+ end
282
+ end
283
+
284
+ def write_to_file(context, file_path, options)
285
+ output_dir = File.dirname(file_path)
286
+ FileUtils.mkdir_p(output_dir) unless output_dir == "."
287
+
288
+ result = Ace::Bundle.write_output(context, file_path, options)
289
+
290
+ if result[:success]
291
+ if result[:chunked]
292
+ chunks = result[:results].select { |r| r[:file_type] == "chunk" }
293
+ total_lines = chunks.sum { |r| r[:lines] || 0 }
294
+ total_size = chunks.sum { |r| r[:size] || 0 }
295
+ puts "Bundle saved (#{total_lines} lines, #{format_size(total_size)}) in #{chunks.size} chunks:"
296
+ chunks.each { |r| puts r[:path] }
297
+ else
298
+ puts "Bundle saved (#{result[:lines]} lines, #{result[:size_formatted]}), output file:"
299
+ puts file_path
300
+ end
301
+ else
302
+ raise Ace::Support::Cli::Error.new("Error writing file: #{result[:error]}")
303
+ end
304
+ end
305
+
306
+ def format_size(bytes)
307
+ units = ["B", "KB", "MB", "GB"]
308
+ size = bytes.to_f
309
+ unit_index = 0
310
+ while size >= 1024 && unit_index < units.size - 1
311
+ size /= 1024
312
+ unit_index += 1
313
+ end
314
+ "#{size.round(2)} #{units[unit_index]}"
315
+ end
316
+
317
+ def display_config_summary(options)
318
+ return if options[:quiet]
319
+
320
+ require "ace/core"
321
+ Ace::Core::Atoms::ConfigSummary.display(
322
+ command: "load",
323
+ config: Ace::Bundle.config,
324
+ defaults: load_gem_defaults,
325
+ options: options,
326
+ quiet: false
327
+ )
328
+ end
329
+
330
+ def load_gem_defaults
331
+ gem_root = Gem.loaded_specs["ace-bundle"]&.gem_dir ||
332
+ File.expand_path("../../../../..", __dir__)
333
+ defaults_path = File.join(gem_root, ".ace-defaults", "bundle", "config.yml")
334
+
335
+ if File.exist?(defaults_path)
336
+ require "yaml"
337
+ data = YAML.safe_load_file(defaults_path, permitted_classes: [Date], aliases: true) || {}
338
+ data["bundle"] || data
339
+ else
340
+ {}
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../bundle"
6
+ require_relative "cli/commands/load"
7
+
8
+ module Ace
9
+ module Bundle
10
+ # CLI namespace for ace-bundle command loading.
11
+ #
12
+ # ace-bundle uses a single-command ace-support-cli entrypoint that calls
13
+ # CLI::Commands::Load directly from the executable.
14
+ module CLI
15
+ # Entry point for CLI invocation (used by tests via cli_helpers)
16
+ #
17
+ # Mirrors exe behavior: empty args show help.
18
+ #
19
+ # @param args [Array<String>] Command-line arguments
20
+ def self.start(args)
21
+ args = ["--help"] if args.empty?
22
+ Ace::Support::Cli::Runner.new(Commands::Load).call(args: args)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Bundle
5
+ module Models
6
+ # Data model for bundle information
7
+ class BundleData
8
+ attr_accessor :preset_name, :files, :metadata, :content, :commands, :sections
9
+
10
+ def initialize(preset_name: nil, files: nil, metadata: nil, content: "", commands: nil, sections: nil)
11
+ @preset_name = preset_name
12
+ @files = files || []
13
+ @metadata = metadata || {}
14
+ @content = content
15
+ @commands = commands || []
16
+ @sections = sections || {}
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ preset_name: preset_name,
22
+ files: files,
23
+ metadata: metadata,
24
+ content: content,
25
+ commands: commands,
26
+ sections: sections
27
+ }
28
+ end
29
+
30
+ def add_file(path, content)
31
+ @files << {path: path, content: content}
32
+ end
33
+
34
+ def file_count
35
+ @files.size
36
+ end
37
+
38
+ def total_size
39
+ @files.sum { |f| f[:content].to_s.bytesize }
40
+ end
41
+
42
+ # Section-related methods
43
+ def add_section(name, section_data)
44
+ @sections[name] = section_data
45
+ end
46
+
47
+ def get_section(name)
48
+ @sections[name]
49
+ end
50
+
51
+ def has_sections?
52
+ !@sections.empty?
53
+ end
54
+
55
+ def section_count
56
+ @sections.size
57
+ end
58
+
59
+ def sorted_sections
60
+ # In Ruby 3.2+, hash insertion order is preserved
61
+ # This returns sections in the order they appear in the YAML file
62
+ @sections.to_a
63
+ end
64
+
65
+ def section_names
66
+ @sections.keys
67
+ end
68
+
69
+ def clear_sections
70
+ @sections.clear
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end