aidp 0.17.1 ā 0.18.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 +4 -4
- data/README.md +69 -0
- data/lib/aidp/cli.rb +43 -2
- data/lib/aidp/config.rb +9 -14
- data/lib/aidp/execute/prompt_manager.rb +128 -1
- data/lib/aidp/execute/repl_macros.rb +555 -0
- data/lib/aidp/execute/work_loop_runner.rb +108 -1
- data/lib/aidp/harness/ai_decision_engine.rb +376 -0
- data/lib/aidp/harness/capability_registry.rb +273 -0
- data/lib/aidp/harness/config_schema.rb +305 -1
- data/lib/aidp/harness/configuration.rb +452 -0
- data/lib/aidp/harness/enhanced_runner.rb +7 -1
- data/lib/aidp/harness/provider_factory.rb +0 -2
- data/lib/aidp/harness/runner.rb +7 -1
- data/lib/aidp/harness/thinking_depth_manager.rb +335 -0
- data/lib/aidp/harness/zfc_condition_detector.rb +395 -0
- data/lib/aidp/init/devcontainer_generator.rb +274 -0
- data/lib/aidp/init/runner.rb +37 -10
- data/lib/aidp/init.rb +1 -0
- data/lib/aidp/prompt_optimization/context_composer.rb +286 -0
- data/lib/aidp/prompt_optimization/optimizer.rb +335 -0
- data/lib/aidp/prompt_optimization/prompt_builder.rb +309 -0
- data/lib/aidp/prompt_optimization/relevance_scorer.rb +256 -0
- data/lib/aidp/prompt_optimization/source_code_fragmenter.rb +308 -0
- data/lib/aidp/prompt_optimization/style_guide_indexer.rb +240 -0
- data/lib/aidp/prompt_optimization/template_indexer.rb +250 -0
- data/lib/aidp/provider_manager.rb +0 -2
- data/lib/aidp/providers/anthropic.rb +19 -0
- data/lib/aidp/setup/wizard.rb +299 -4
- data/lib/aidp/utils/devcontainer_detector.rb +166 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +72 -6
- data/lib/aidp/watch/repository_client.rb +2 -1
- data/lib/aidp.rb +0 -1
- data/templates/aidp.yml.example +128 -0
- metadata +14 -2
- data/lib/aidp/providers/macos_ui.rb +0 -102
    
        data/lib/aidp/init/runner.rb
    CHANGED
    
    | @@ -4,6 +4,7 @@ require "tty-prompt" | |
| 4 4 | 
             
            require_relative "../message_display"
         | 
| 5 5 | 
             
            require_relative "project_analyzer"
         | 
| 6 6 | 
             
            require_relative "doc_generator"
         | 
| 7 | 
            +
            require_relative "devcontainer_generator"
         | 
| 7 8 |  | 
| 8 9 | 
             
            module Aidp
         | 
| 9 10 | 
             
              module Init
         | 
| @@ -12,11 +13,12 @@ module Aidp | |
| 12 13 | 
             
                class Runner
         | 
| 13 14 | 
             
                  include Aidp::MessageDisplay
         | 
| 14 15 |  | 
| 15 | 
            -
                  def initialize(project_dir = Dir.pwd, prompt: TTY::Prompt.new, analyzer: nil, doc_generator: nil, options: {})
         | 
| 16 | 
            +
                  def initialize(project_dir = Dir.pwd, prompt: TTY::Prompt.new, analyzer: nil, doc_generator: nil, devcontainer_generator: nil, options: {})
         | 
| 16 17 | 
             
                    @project_dir = project_dir
         | 
| 17 18 | 
             
                    @prompt = prompt
         | 
| 18 19 | 
             
                    @analyzer = analyzer || ProjectAnalyzer.new(project_dir)
         | 
| 19 20 | 
             
                    @doc_generator = doc_generator || DocGenerator.new(project_dir)
         | 
| 21 | 
            +
                    @devcontainer_generator = devcontainer_generator || DevcontainerGenerator.new(project_dir)
         | 
| 20 22 | 
             
                    @options = options
         | 
| 21 23 | 
             
                  end
         | 
| 22 24 |  | 
| @@ -62,20 +64,30 @@ module Aidp | |
| 62 64 |  | 
| 63 65 | 
             
                    @doc_generator.generate(analysis: analysis, preferences: preferences)
         | 
| 64 66 |  | 
| 67 | 
            +
                    generated_files = [
         | 
| 68 | 
            +
                      "docs/LLM_STYLE_GUIDE.md",
         | 
| 69 | 
            +
                      "docs/PROJECT_ANALYSIS.md",
         | 
| 70 | 
            +
                      "docs/CODE_QUALITY_PLAN.md"
         | 
| 71 | 
            +
                    ]
         | 
| 72 | 
            +
             | 
| 65 73 | 
             
                    display_message("\nš Generated documentation:", type: :info)
         | 
| 66 | 
            -
                    display_message("  -  | 
| 67 | 
            -
             | 
| 68 | 
            -
                     | 
| 74 | 
            +
                    generated_files.each { |file| display_message("  - #{file}", type: :success) }
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                    # Optionally generate devcontainer
         | 
| 77 | 
            +
                    if @options[:with_devcontainer] || should_generate_devcontainer?(preferences)
         | 
| 78 | 
            +
                      devcontainer_files = @devcontainer_generator.generate(analysis: analysis, preferences: preferences)
         | 
| 79 | 
            +
                      generated_files.concat(devcontainer_files)
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                      display_message("\nš¦ Generated devcontainer:", type: :info)
         | 
| 82 | 
            +
                      devcontainer_files.each { |file| display_message("  - #{file}", type: :success) }
         | 
| 83 | 
            +
                    end
         | 
| 84 | 
            +
             | 
| 69 85 | 
             
                    display_message("\nā
 aidp init complete.", type: :success)
         | 
| 70 86 |  | 
| 71 87 | 
             
                    {
         | 
| 72 88 | 
             
                      analysis: analysis,
         | 
| 73 89 | 
             
                      preferences: preferences,
         | 
| 74 | 
            -
                      generated_files:  | 
| 75 | 
            -
                        "docs/LLM_STYLE_GUIDE.md",
         | 
| 76 | 
            -
                        "docs/PROJECT_ANALYSIS.md",
         | 
| 77 | 
            -
                        "docs/CODE_QUALITY_PLAN.md"
         | 
| 78 | 
            -
                      ]
         | 
| 90 | 
            +
                      generated_files: generated_files
         | 
| 79 91 | 
             
                    }
         | 
| 80 92 | 
             
                  end
         | 
| 81 93 |  | 
| @@ -225,7 +237,7 @@ module Aidp | |
| 225 237 | 
             
                    display_message("The following questions will help customize the generated documentation.", type: :info)
         | 
| 226 238 | 
             
                    display_message("Press Enter to accept defaults shown in brackets.\n", type: :info)
         | 
| 227 239 |  | 
| 228 | 
            -
                    {
         | 
| 240 | 
            +
                    prefs = {
         | 
| 229 241 | 
             
                      adopt_new_conventions: ask_yes_no_with_context(
         | 
| 230 242 | 
             
                        "Make these conventions official for this repository?",
         | 
| 231 243 | 
             
                        context: "This saves the detected patterns to LLM_STYLE_GUIDE.md and guides future AI-assisted work.",
         | 
| @@ -242,6 +254,21 @@ module Aidp | |
| 242 254 | 
             
                        default: false
         | 
| 243 255 | 
             
                      )
         | 
| 244 256 | 
             
                    }
         | 
| 257 | 
            +
             | 
| 258 | 
            +
                    # Ask about devcontainer generation unless explicitly set via options
         | 
| 259 | 
            +
                    unless @options.key?(:with_devcontainer)
         | 
| 260 | 
            +
                      prefs[:generate_devcontainer] = ask_yes_no_with_context(
         | 
| 261 | 
            +
                        "Generate devcontainer configuration for sandboxed development?",
         | 
| 262 | 
            +
                        context: "Creates .devcontainer/ with Docker setup and network security for safe AI agent execution.",
         | 
| 263 | 
            +
                        default: !@devcontainer_generator.exists?
         | 
| 264 | 
            +
                      )
         | 
| 265 | 
            +
                    end
         | 
| 266 | 
            +
             | 
| 267 | 
            +
                    prefs
         | 
| 268 | 
            +
                  end
         | 
| 269 | 
            +
             | 
| 270 | 
            +
                  def should_generate_devcontainer?(preferences)
         | 
| 271 | 
            +
                    preferences[:generate_devcontainer] == true
         | 
| 245 272 | 
             
                  end
         | 
| 246 273 |  | 
| 247 274 | 
             
                  def ask_yes_no_with_context(question, context:, default:)
         | 
    
        data/lib/aidp/init.rb
    CHANGED
    
    
| @@ -0,0 +1,286 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Aidp
         | 
| 4 | 
            +
              module PromptOptimization
         | 
| 5 | 
            +
                # Composes optimal context from scored fragments within token budget
         | 
| 6 | 
            +
                #
         | 
| 7 | 
            +
                # Selects the best combination of fragments that fits within
         | 
| 8 | 
            +
                # the token budget while maximizing relevance and coverage.
         | 
| 9 | 
            +
                #
         | 
| 10 | 
            +
                # Algorithm:
         | 
| 11 | 
            +
                # 1. Sort fragments by relevance score
         | 
| 12 | 
            +
                # 2. Always include critical fragments (score > 0.9)
         | 
| 13 | 
            +
                # 3. Fill remaining budget with highest-scoring fragments
         | 
| 14 | 
            +
                # 4. Deduplicate overlapping content
         | 
| 15 | 
            +
                # 5. Return selected fragments with statistics
         | 
| 16 | 
            +
                #
         | 
| 17 | 
            +
                # @example Basic usage
         | 
| 18 | 
            +
                #   composer = ContextComposer.new(max_tokens: 8000)
         | 
| 19 | 
            +
                #   selection = composer.compose(scored_fragments, thresholds: {...})
         | 
| 20 | 
            +
                class ContextComposer
         | 
| 21 | 
            +
                  attr_reader :max_tokens
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  # Thresholds for different fragment types
         | 
| 24 | 
            +
                  CRITICAL_SCORE_THRESHOLD = 0.9
         | 
| 25 | 
            +
                  MINIMUM_SCORE_THRESHOLD = 0.3
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def initialize(max_tokens: 16000)
         | 
| 28 | 
            +
                    @max_tokens = max_tokens
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  # Compose optimal context from scored fragments
         | 
| 32 | 
            +
                  #
         | 
| 33 | 
            +
                  # @param scored_fragments [Array<Hash>] List of {fragment:, score:, breakdown:}
         | 
| 34 | 
            +
                  # @param thresholds [Hash] Type-specific thresholds {:style_guide, :templates, :source}
         | 
| 35 | 
            +
                  # @param reserved_tokens [Integer] Tokens to reserve for task description, etc.
         | 
| 36 | 
            +
                  # @return [CompositionResult] Selected fragments and statistics
         | 
| 37 | 
            +
                  def compose(scored_fragments, thresholds: {}, reserved_tokens: 2000)
         | 
| 38 | 
            +
                    available_budget = @max_tokens - reserved_tokens
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                    # Separate fragments by type for threshold checking
         | 
| 41 | 
            +
                    categorized = categorize_fragments(scored_fragments, thresholds)
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                    # Start with critical fragments (always included)
         | 
| 44 | 
            +
                    selected = select_critical_fragments(categorized[:critical])
         | 
| 45 | 
            +
                    used_tokens = calculate_total_tokens(selected)
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                    # Add high-priority fragments within budget
         | 
| 48 | 
            +
                    selected, used_tokens = add_fragments_within_budget(
         | 
| 49 | 
            +
                      selected,
         | 
| 50 | 
            +
                      categorized[:high_priority],
         | 
| 51 | 
            +
                      available_budget - used_tokens
         | 
| 52 | 
            +
                    )
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                    # Fill remaining budget with other fragments if space allows
         | 
| 55 | 
            +
                    selected, used_tokens = add_fragments_within_budget(
         | 
| 56 | 
            +
                      selected,
         | 
| 57 | 
            +
                      categorized[:medium_priority],
         | 
| 58 | 
            +
                      available_budget - used_tokens
         | 
| 59 | 
            +
                    )
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                    # Deduplicate if requested
         | 
| 62 | 
            +
                    selected = deduplicate_fragments(selected) if categorized[:needs_dedup]
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                    CompositionResult.new(
         | 
| 65 | 
            +
                      selected_fragments: selected,
         | 
| 66 | 
            +
                      total_tokens: used_tokens,
         | 
| 67 | 
            +
                      budget: available_budget,
         | 
| 68 | 
            +
                      excluded_count: scored_fragments.length - selected.length,
         | 
| 69 | 
            +
                      average_score: calculate_average_score(selected)
         | 
| 70 | 
            +
                    )
         | 
| 71 | 
            +
                  end
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                  private
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                  # Categorize fragments by priority and type
         | 
| 76 | 
            +
                  #
         | 
| 77 | 
            +
                  # @param scored_fragments [Array<Hash>] Scored fragments
         | 
| 78 | 
            +
                  # @param thresholds [Hash] Type-specific thresholds
         | 
| 79 | 
            +
                  # @return [Hash] Categorized fragments
         | 
| 80 | 
            +
                  def categorize_fragments(scored_fragments, thresholds)
         | 
| 81 | 
            +
                    critical = []
         | 
| 82 | 
            +
                    high_priority = []
         | 
| 83 | 
            +
                    medium_priority = []
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                    scored_fragments.each do |item|
         | 
| 86 | 
            +
                      score = item[:score]
         | 
| 87 | 
            +
                      fragment = item[:fragment]
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                      # Critical fragments always included
         | 
| 90 | 
            +
                      if score >= CRITICAL_SCORE_THRESHOLD
         | 
| 91 | 
            +
                        critical << item
         | 
| 92 | 
            +
                      # Check type-specific thresholds
         | 
| 93 | 
            +
                      elsif meets_threshold?(fragment, score, thresholds)
         | 
| 94 | 
            +
                        high_priority << item
         | 
| 95 | 
            +
                      # Medium priority if above minimum
         | 
| 96 | 
            +
                      elsif score >= MINIMUM_SCORE_THRESHOLD
         | 
| 97 | 
            +
                        medium_priority << item
         | 
| 98 | 
            +
                      end
         | 
| 99 | 
            +
                    end
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                    {
         | 
| 102 | 
            +
                      critical: critical,
         | 
| 103 | 
            +
                      high_priority: high_priority.sort_by { |item| -item[:score] },
         | 
| 104 | 
            +
                      medium_priority: medium_priority.sort_by { |item| -item[:score] },
         | 
| 105 | 
            +
                      needs_dedup: true
         | 
| 106 | 
            +
                    }
         | 
| 107 | 
            +
                  end
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                  # Check if fragment meets type-specific threshold
         | 
| 110 | 
            +
                  #
         | 
| 111 | 
            +
                  # @param fragment [Fragment] Fragment to check
         | 
| 112 | 
            +
                  # @param score [Float] Relevance score
         | 
| 113 | 
            +
                  # @param thresholds [Hash] Type-specific thresholds
         | 
| 114 | 
            +
                  # @return [Boolean] True if meets threshold
         | 
| 115 | 
            +
                  def meets_threshold?(fragment, score, thresholds)
         | 
| 116 | 
            +
                    threshold = if fragment.class.name.include?("Fragment") && fragment.respond_to?(:heading)
         | 
| 117 | 
            +
                      thresholds[:style_guide] || 0.75
         | 
| 118 | 
            +
                    elsif fragment.respond_to?(:category)
         | 
| 119 | 
            +
                      thresholds[:templates] || 0.8
         | 
| 120 | 
            +
                    elsif fragment.respond_to?(:file_path) && fragment.respond_to?(:type)
         | 
| 121 | 
            +
                      thresholds[:source] || 0.7
         | 
| 122 | 
            +
                    else
         | 
| 123 | 
            +
                      0.75
         | 
| 124 | 
            +
                    end
         | 
| 125 | 
            +
             | 
| 126 | 
            +
                    score >= threshold
         | 
| 127 | 
            +
                  end
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                  # Select critical fragments (always included regardless of budget)
         | 
| 130 | 
            +
                  #
         | 
| 131 | 
            +
                  # @param critical_items [Array<Hash>] Critical scored fragments
         | 
| 132 | 
            +
                  # @return [Array<Hash>] Selected critical fragments
         | 
| 133 | 
            +
                  def select_critical_fragments(critical_items)
         | 
| 134 | 
            +
                    critical_items
         | 
| 135 | 
            +
                  end
         | 
| 136 | 
            +
             | 
| 137 | 
            +
                  # Add fragments within remaining budget
         | 
| 138 | 
            +
                  #
         | 
| 139 | 
            +
                  # @param selected [Array<Hash>] Already selected fragments
         | 
| 140 | 
            +
                  # @param candidates [Array<Hash>] Candidate fragments to add
         | 
| 141 | 
            +
                  # @param remaining_budget [Integer] Remaining token budget
         | 
| 142 | 
            +
                  # @return [Array] [updated_selected, tokens_used]
         | 
| 143 | 
            +
                  def add_fragments_within_budget(selected, candidates, remaining_budget)
         | 
| 144 | 
            +
                    used_tokens = 0
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                    candidates.each do |item|
         | 
| 147 | 
            +
                      fragment_tokens = estimate_fragment_tokens(item[:fragment])
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                      if used_tokens + fragment_tokens <= remaining_budget
         | 
| 150 | 
            +
                        selected << item
         | 
| 151 | 
            +
                        used_tokens += fragment_tokens
         | 
| 152 | 
            +
                      end
         | 
| 153 | 
            +
                    end
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                    [selected, calculate_total_tokens(selected)]
         | 
| 156 | 
            +
                  end
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                  # Deduplicate fragments with overlapping content
         | 
| 159 | 
            +
                  #
         | 
| 160 | 
            +
                  # @param selected [Array<Hash>] Selected fragments
         | 
| 161 | 
            +
                  # @return [Array<Hash>] Deduplicated fragments
         | 
| 162 | 
            +
                  def deduplicate_fragments(selected)
         | 
| 163 | 
            +
                    # Simple deduplication: remove fragments with identical IDs
         | 
| 164 | 
            +
                    seen_ids = Set.new
         | 
| 165 | 
            +
                    selected.select do |item|
         | 
| 166 | 
            +
                      id = item[:fragment].respond_to?(:id) ? item[:fragment].id : item[:fragment].object_id
         | 
| 167 | 
            +
                      !seen_ids.include?(id).tap { seen_ids << id }
         | 
| 168 | 
            +
                    end
         | 
| 169 | 
            +
                  end
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                  # Estimate tokens for a fragment
         | 
| 172 | 
            +
                  #
         | 
| 173 | 
            +
                  # @param fragment [Fragment] Fragment to estimate
         | 
| 174 | 
            +
                  # @return [Integer] Estimated tokens
         | 
| 175 | 
            +
                  def estimate_fragment_tokens(fragment)
         | 
| 176 | 
            +
                    if fragment.respond_to?(:estimated_tokens)
         | 
| 177 | 
            +
                      fragment.estimated_tokens
         | 
| 178 | 
            +
                    elsif fragment.respond_to?(:content)
         | 
| 179 | 
            +
                      (fragment.content.length / 4.0).ceil
         | 
| 180 | 
            +
                    else
         | 
| 181 | 
            +
                      100 # Default estimate
         | 
| 182 | 
            +
                    end
         | 
| 183 | 
            +
                  end
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                  # Calculate total tokens for selected fragments
         | 
| 186 | 
            +
                  #
         | 
| 187 | 
            +
                  # @param selected [Array<Hash>] Selected fragments
         | 
| 188 | 
            +
                  # @return [Integer] Total tokens
         | 
| 189 | 
            +
                  def calculate_total_tokens(selected)
         | 
| 190 | 
            +
                    selected.sum { |item| estimate_fragment_tokens(item[:fragment]) }
         | 
| 191 | 
            +
                  end
         | 
| 192 | 
            +
             | 
| 193 | 
            +
                  # Calculate average score of selected fragments
         | 
| 194 | 
            +
                  #
         | 
| 195 | 
            +
                  # @param selected [Array<Hash>] Selected fragments
         | 
| 196 | 
            +
                  # @return [Float] Average score
         | 
| 197 | 
            +
                  def calculate_average_score(selected)
         | 
| 198 | 
            +
                    return 0.0 if selected.empty?
         | 
| 199 | 
            +
             | 
| 200 | 
            +
                    total = selected.sum { |item| item[:score] }
         | 
| 201 | 
            +
                    (total / selected.length.to_f).round(3)
         | 
| 202 | 
            +
                  end
         | 
| 203 | 
            +
                end
         | 
| 204 | 
            +
             | 
| 205 | 
            +
                # Result of context composition
         | 
| 206 | 
            +
                #
         | 
| 207 | 
            +
                # Contains selected fragments and composition statistics
         | 
| 208 | 
            +
                class CompositionResult
         | 
| 209 | 
            +
                  attr_reader :selected_fragments, :total_tokens, :budget, :excluded_count, :average_score
         | 
| 210 | 
            +
             | 
| 211 | 
            +
                  def initialize(selected_fragments:, total_tokens:, budget:, excluded_count:, average_score:)
         | 
| 212 | 
            +
                    @selected_fragments = selected_fragments
         | 
| 213 | 
            +
                    @total_tokens = total_tokens
         | 
| 214 | 
            +
                    @budget = budget
         | 
| 215 | 
            +
                    @excluded_count = excluded_count
         | 
| 216 | 
            +
                    @average_score = average_score
         | 
| 217 | 
            +
                  end
         | 
| 218 | 
            +
             | 
| 219 | 
            +
                  # Calculate budget utilization percentage
         | 
| 220 | 
            +
                  #
         | 
| 221 | 
            +
                  # @return [Float] Percentage used (0.0-100.0)
         | 
| 222 | 
            +
                  def budget_utilization
         | 
| 223 | 
            +
                    return 0.0 if @budget.zero?
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                    ((@total_tokens.to_f / @budget) * 100).round(2)
         | 
| 226 | 
            +
                  end
         | 
| 227 | 
            +
             | 
| 228 | 
            +
                  # Get count of selected fragments
         | 
| 229 | 
            +
                  #
         | 
| 230 | 
            +
                  # @return [Integer] Number of selected fragments
         | 
| 231 | 
            +
                  def selected_count
         | 
| 232 | 
            +
                    @selected_fragments.length
         | 
| 233 | 
            +
                  end
         | 
| 234 | 
            +
             | 
| 235 | 
            +
                  # Check if budget was exceeded
         | 
| 236 | 
            +
                  #
         | 
| 237 | 
            +
                  # @return [Boolean] True if over budget
         | 
| 238 | 
            +
                  def over_budget?
         | 
| 239 | 
            +
                    @total_tokens > @budget
         | 
| 240 | 
            +
                  end
         | 
| 241 | 
            +
             | 
| 242 | 
            +
                  # Get fragments by type
         | 
| 243 | 
            +
                  #
         | 
| 244 | 
            +
                  # @param type [Symbol] Fragment type (:style_guide, :template, :code)
         | 
| 245 | 
            +
                  # @return [Array<Hash>] Fragments of specified type
         | 
| 246 | 
            +
                  def fragments_by_type(type)
         | 
| 247 | 
            +
                    @selected_fragments.select do |item|
         | 
| 248 | 
            +
                      case type
         | 
| 249 | 
            +
                      when :style_guide
         | 
| 250 | 
            +
                        item[:fragment].class.name.include?("Fragment") && item[:fragment].respond_to?(:heading)
         | 
| 251 | 
            +
                      when :template
         | 
| 252 | 
            +
                        item[:fragment].respond_to?(:category) && !item[:fragment].respond_to?(:type)
         | 
| 253 | 
            +
                      when :code
         | 
| 254 | 
            +
                        item[:fragment].respond_to?(:type) && item[:fragment].respond_to?(:file_path)
         | 
| 255 | 
            +
                      else
         | 
| 256 | 
            +
                        false
         | 
| 257 | 
            +
                      end
         | 
| 258 | 
            +
                    end
         | 
| 259 | 
            +
                  end
         | 
| 260 | 
            +
             | 
| 261 | 
            +
                  # Get summary statistics
         | 
| 262 | 
            +
                  #
         | 
| 263 | 
            +
                  # @return [Hash] Composition statistics
         | 
| 264 | 
            +
                  def summary
         | 
| 265 | 
            +
                    {
         | 
| 266 | 
            +
                      selected_count: selected_count,
         | 
| 267 | 
            +
                      excluded_count: @excluded_count,
         | 
| 268 | 
            +
                      total_tokens: @total_tokens,
         | 
| 269 | 
            +
                      budget: @budget,
         | 
| 270 | 
            +
                      utilization: budget_utilization,
         | 
| 271 | 
            +
                      average_score: @average_score,
         | 
| 272 | 
            +
                      over_budget: over_budget?,
         | 
| 273 | 
            +
                      by_type: {
         | 
| 274 | 
            +
                        style_guide: fragments_by_type(:style_guide).count,
         | 
| 275 | 
            +
                        templates: fragments_by_type(:template).count,
         | 
| 276 | 
            +
                        code: fragments_by_type(:code).count
         | 
| 277 | 
            +
                      }
         | 
| 278 | 
            +
                    }
         | 
| 279 | 
            +
                  end
         | 
| 280 | 
            +
             | 
| 281 | 
            +
                  def to_s
         | 
| 282 | 
            +
                    "CompositionResult<#{selected_count} fragments, #{@total_tokens}/#{@budget} tokens (#{budget_utilization}%)>"
         | 
| 283 | 
            +
                  end
         | 
| 284 | 
            +
                end
         | 
| 285 | 
            +
              end
         | 
| 286 | 
            +
            end
         |