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
| @@ -0,0 +1,256 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Aidp
         | 
| 4 | 
            +
              module PromptOptimization
         | 
| 5 | 
            +
                # Scores fragments based on relevance to current task context
         | 
| 6 | 
            +
                #
         | 
| 7 | 
            +
                # Calculates relevance scores (0.0-1.0) for fragments based on:
         | 
| 8 | 
            +
                # - Task type (feature, bugfix, refactor, test)
         | 
| 9 | 
            +
                # - Affected files and code locations
         | 
| 10 | 
            +
                # - Work loop step (planning vs implementation)
         | 
| 11 | 
            +
                # - Keywords and semantic similarity
         | 
| 12 | 
            +
                #
         | 
| 13 | 
            +
                # @example Basic usage
         | 
| 14 | 
            +
                #   scorer = RelevanceScorer.new
         | 
| 15 | 
            +
                #   context = TaskContext.new(task_type: :feature, affected_files: ["user.rb"])
         | 
| 16 | 
            +
                #   score = scorer.score_fragment(fragment, context)
         | 
| 17 | 
            +
                class RelevanceScorer
         | 
| 18 | 
            +
                  # Default scoring weights
         | 
| 19 | 
            +
                  DEFAULT_WEIGHTS = {
         | 
| 20 | 
            +
                    task_type_match: 0.3,
         | 
| 21 | 
            +
                    tag_match: 0.25,
         | 
| 22 | 
            +
                    file_location_match: 0.25,
         | 
| 23 | 
            +
                    step_match: 0.2
         | 
| 24 | 
            +
                  }.freeze
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                  def initialize(weights: DEFAULT_WEIGHTS)
         | 
| 27 | 
            +
                    @weights = weights
         | 
| 28 | 
            +
                  end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  # Score a single fragment
         | 
| 31 | 
            +
                  #
         | 
| 32 | 
            +
                  # @param fragment [Fragment, TemplateFragment, CodeFragment] Fragment to score
         | 
| 33 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 34 | 
            +
                  # @return [Float] Relevance score (0.0-1.0)
         | 
| 35 | 
            +
                  def score_fragment(fragment, context)
         | 
| 36 | 
            +
                    scores = {}
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                    scores[:task_type] = score_task_type_match(fragment, context) * @weights[:task_type_match]
         | 
| 39 | 
            +
                    scores[:tags] = score_tag_match(fragment, context) * @weights[:tag_match]
         | 
| 40 | 
            +
                    scores[:location] = score_file_location_match(fragment, context) * @weights[:file_location_match]
         | 
| 41 | 
            +
                    scores[:step] = score_step_match(fragment, context) * @weights[:step_match]
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                    total_score = scores.values.sum
         | 
| 44 | 
            +
                    normalize_score(total_score)
         | 
| 45 | 
            +
                  end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                  # Score multiple fragments
         | 
| 48 | 
            +
                  #
         | 
| 49 | 
            +
                  # @param fragments [Array] List of fragments
         | 
| 50 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 51 | 
            +
                  # @return [Array<Hash>] List of {fragment:, score:, breakdown:}
         | 
| 52 | 
            +
                  def score_fragments(fragments, context)
         | 
| 53 | 
            +
                    fragments.map do |fragment|
         | 
| 54 | 
            +
                      score = score_fragment(fragment, context)
         | 
| 55 | 
            +
                      {
         | 
| 56 | 
            +
                        fragment: fragment,
         | 
| 57 | 
            +
                        score: score,
         | 
| 58 | 
            +
                        breakdown: score_breakdown(fragment, context)
         | 
| 59 | 
            +
                      }
         | 
| 60 | 
            +
                    end.sort_by { |result| -result[:score] }
         | 
| 61 | 
            +
                  end
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                  private
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                  # Score based on task type matching
         | 
| 66 | 
            +
                  #
         | 
| 67 | 
            +
                  # @param fragment [Fragment] Fragment to score
         | 
| 68 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 69 | 
            +
                  # @return [Float] Score 0.0-1.0
         | 
| 70 | 
            +
                  def score_task_type_match(fragment, context)
         | 
| 71 | 
            +
                    return 0.5 unless context.task_type # Neutral if unknown
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                    task_tags = task_type_to_tags(context.task_type)
         | 
| 74 | 
            +
                    return 0.3 if task_tags.empty? # Low default score
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                    if fragment.respond_to?(:tags)
         | 
| 77 | 
            +
                      matching_tags = fragment.tags & task_tags
         | 
| 78 | 
            +
                      matching_tags.empty? ? 0.3 : (matching_tags.count.to_f / task_tags.count)
         | 
| 79 | 
            +
                    else
         | 
| 80 | 
            +
                      0.3
         | 
| 81 | 
            +
                    end
         | 
| 82 | 
            +
                  end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                  # Score based on tag matching
         | 
| 85 | 
            +
                  #
         | 
| 86 | 
            +
                  # @param fragment [Fragment] Fragment to score
         | 
| 87 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 88 | 
            +
                  # @return [Float] Score 0.0-1.0
         | 
| 89 | 
            +
                  def score_tag_match(fragment, context)
         | 
| 90 | 
            +
                    return 0.5 unless context.tags && !context.tags.empty?
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                    if fragment.respond_to?(:tags)
         | 
| 93 | 
            +
                      matching_tags = fragment.tags & context.tags
         | 
| 94 | 
            +
                      matching_tags.empty? ? 0.2 : (matching_tags.count.to_f / context.tags.count).clamp(0.0, 1.0)
         | 
| 95 | 
            +
                    else
         | 
| 96 | 
            +
                      0.5
         | 
| 97 | 
            +
                    end
         | 
| 98 | 
            +
                  end
         | 
| 99 | 
            +
             | 
| 100 | 
            +
                  # Score based on file location matching
         | 
| 101 | 
            +
                  #
         | 
| 102 | 
            +
                  # @param fragment [Fragment] Fragment to score
         | 
| 103 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 104 | 
            +
                  # @return [Float] Score 0.0-1.0
         | 
| 105 | 
            +
                  def score_file_location_match(fragment, context)
         | 
| 106 | 
            +
                    return 0.5 unless context.affected_files && !context.affected_files.empty?
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                    # Only code fragments have file_path
         | 
| 109 | 
            +
                    return 0.5 unless fragment.respond_to?(:file_path)
         | 
| 110 | 
            +
             | 
| 111 | 
            +
                    # Check if fragment is from an affected file
         | 
| 112 | 
            +
                    (context.affected_files.any? do |affected_file|
         | 
| 113 | 
            +
                      fragment.file_path.include?(affected_file)
         | 
| 114 | 
            +
                    end) ? 1.0 : 0.1
         | 
| 115 | 
            +
                  end
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                  # Score based on work loop step
         | 
| 118 | 
            +
                  #
         | 
| 119 | 
            +
                  # @param fragment [Fragment] Fragment to score
         | 
| 120 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 121 | 
            +
                  # @return [Float] Score 0.0-1.0
         | 
| 122 | 
            +
                  def score_step_match(fragment, context)
         | 
| 123 | 
            +
                    return 0.5 unless context.step_name
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                    step_tags = step_to_tags(context.step_name)
         | 
| 126 | 
            +
                    return 0.5 if step_tags.empty?
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                    if fragment.respond_to?(:tags)
         | 
| 129 | 
            +
                      matching_tags = fragment.tags & step_tags
         | 
| 130 | 
            +
                      matching_tags.empty? ? 0.3 : 0.8
         | 
| 131 | 
            +
                    elsif fragment.respond_to?(:category)
         | 
| 132 | 
            +
                      # Template fragments have categories
         | 
| 133 | 
            +
                      step_tags.include?(fragment.category) ? 0.9 : 0.4
         | 
| 134 | 
            +
                    else
         | 
| 135 | 
            +
                      0.5
         | 
| 136 | 
            +
                    end
         | 
| 137 | 
            +
                  end
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                  # Get detailed score breakdown
         | 
| 140 | 
            +
                  #
         | 
| 141 | 
            +
                  # @param fragment [Fragment] Fragment to score
         | 
| 142 | 
            +
                  # @param context [TaskContext] Task context
         | 
| 143 | 
            +
                  # @return [Hash] Score breakdown
         | 
| 144 | 
            +
                  def score_breakdown(fragment, context)
         | 
| 145 | 
            +
                    {
         | 
| 146 | 
            +
                      task_type: score_task_type_match(fragment, context),
         | 
| 147 | 
            +
                      tags: score_tag_match(fragment, context),
         | 
| 148 | 
            +
                      location: score_file_location_match(fragment, context),
         | 
| 149 | 
            +
                      step: score_step_match(fragment, context)
         | 
| 150 | 
            +
                    }
         | 
| 151 | 
            +
                  end
         | 
| 152 | 
            +
             | 
| 153 | 
            +
                  # Normalize score to 0.0-1.0 range
         | 
| 154 | 
            +
                  #
         | 
| 155 | 
            +
                  # @param score [Float] Raw score
         | 
| 156 | 
            +
                  # @return [Float] Normalized score
         | 
| 157 | 
            +
                  def normalize_score(score)
         | 
| 158 | 
            +
                    score.clamp(0.0, 1.0)
         | 
| 159 | 
            +
                  end
         | 
| 160 | 
            +
             | 
| 161 | 
            +
                  # Map task type to relevant tags
         | 
| 162 | 
            +
                  #
         | 
| 163 | 
            +
                  # @param task_type [Symbol] Task type
         | 
| 164 | 
            +
                  # @return [Array<String>] List of relevant tags
         | 
| 165 | 
            +
                  def task_type_to_tags(task_type)
         | 
| 166 | 
            +
                    case task_type
         | 
| 167 | 
            +
                    when :feature, :enhancement
         | 
| 168 | 
            +
                      ["implementation", "planning", "testing", "api"]
         | 
| 169 | 
            +
                    when :bugfix, :fix
         | 
| 170 | 
            +
                      ["testing", "error", "debugging", "logging"]
         | 
| 171 | 
            +
                    when :refactor, :refactoring
         | 
| 172 | 
            +
                      ["refactor", "architecture", "testing", "performance"]
         | 
| 173 | 
            +
                    when :test, :testing
         | 
| 174 | 
            +
                      ["testing", "analyst"]
         | 
| 175 | 
            +
                    when :documentation, :docs
         | 
| 176 | 
            +
                      ["documentation", "planning"]
         | 
| 177 | 
            +
                    when :security
         | 
| 178 | 
            +
                      ["security", "testing", "error"]
         | 
| 179 | 
            +
                    when :performance
         | 
| 180 | 
            +
                      ["performance", "testing", "refactor"]
         | 
| 181 | 
            +
                    else
         | 
| 182 | 
            +
                      []
         | 
| 183 | 
            +
                    end
         | 
| 184 | 
            +
                  end
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                  # Map work loop step to relevant tags
         | 
| 187 | 
            +
                  #
         | 
| 188 | 
            +
                  # @param step_name [String] Step name
         | 
| 189 | 
            +
                  # @return [Array<String>] List of relevant tags
         | 
| 190 | 
            +
                  def step_to_tags(step_name)
         | 
| 191 | 
            +
                    step_lower = step_name.to_s.downcase
         | 
| 192 | 
            +
             | 
| 193 | 
            +
                    tags = []
         | 
| 194 | 
            +
                    tags << "planning" if step_lower.include?("plan") || step_lower.include?("design")
         | 
| 195 | 
            +
                    tags << "analysis" if step_lower.include?("analy")
         | 
| 196 | 
            +
                    tags << "implementation" if step_lower.include?("implement") || step_lower.include?("code")
         | 
| 197 | 
            +
                    tags << "testing" if step_lower.include?("test")
         | 
| 198 | 
            +
                    tags << "refactor" if step_lower.include?("refactor")
         | 
| 199 | 
            +
                    tags << "documentation" if step_lower.include?("doc")
         | 
| 200 | 
            +
                    tags << "security" if step_lower.include?("security")
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                    tags
         | 
| 203 | 
            +
                  end
         | 
| 204 | 
            +
                end
         | 
| 205 | 
            +
             | 
| 206 | 
            +
                # Represents the context for a task
         | 
| 207 | 
            +
                #
         | 
| 208 | 
            +
                # Contains information about the current work being done,
         | 
| 209 | 
            +
                # used to calculate relevance scores for fragments
         | 
| 210 | 
            +
                class TaskContext
         | 
| 211 | 
            +
                  attr_accessor :task_type, :description, :affected_files, :step_name, :tags
         | 
| 212 | 
            +
             | 
| 213 | 
            +
                  # @param task_type [Symbol] Type of task (:feature, :bugfix, :refactor, etc.)
         | 
| 214 | 
            +
                  # @param description [String] Task description
         | 
| 215 | 
            +
                  # @param affected_files [Array<String>] List of files being modified
         | 
| 216 | 
            +
                  # @param step_name [String] Current work loop step name
         | 
| 217 | 
            +
                  # @param tags [Array<String>] Additional context tags
         | 
| 218 | 
            +
                  def initialize(task_type: nil, description: nil, affected_files: [], step_name: nil, tags: [])
         | 
| 219 | 
            +
                    @task_type = task_type
         | 
| 220 | 
            +
                    @description = description
         | 
| 221 | 
            +
                    @affected_files = affected_files || []
         | 
| 222 | 
            +
                    @step_name = step_name
         | 
| 223 | 
            +
                    @tags = tags || []
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                    # Extract additional tags from description if provided
         | 
| 226 | 
            +
                    extract_tags_from_description if @description
         | 
| 227 | 
            +
                  end
         | 
| 228 | 
            +
             | 
| 229 | 
            +
                  # Extract relevant tags from description text
         | 
| 230 | 
            +
                  def extract_tags_from_description
         | 
| 231 | 
            +
                    return unless @description
         | 
| 232 | 
            +
             | 
| 233 | 
            +
                    desc_lower = @description.downcase
         | 
| 234 | 
            +
             | 
| 235 | 
            +
                    @tags << "testing" if /test|spec|coverage/.match?(desc_lower)
         | 
| 236 | 
            +
                    @tags << "security" if /security|auth|permission/.match?(desc_lower)
         | 
| 237 | 
            +
                    @tags << "performance" if /performance|speed|optimization/.match?(desc_lower)
         | 
| 238 | 
            +
                    @tags << "database" if /database|sql|migration/.match?(desc_lower)
         | 
| 239 | 
            +
                    @tags << "api" if /\bapi\b|endpoint|rest/.match?(desc_lower)
         | 
| 240 | 
            +
                    @tags << "ui" if /\bui\b|interface|view/.match?(desc_lower)
         | 
| 241 | 
            +
             | 
| 242 | 
            +
                    @tags.uniq!
         | 
| 243 | 
            +
                  end
         | 
| 244 | 
            +
             | 
| 245 | 
            +
                  def to_h
         | 
| 246 | 
            +
                    {
         | 
| 247 | 
            +
                      task_type: @task_type,
         | 
| 248 | 
            +
                      description: @description,
         | 
| 249 | 
            +
                      affected_files: @affected_files,
         | 
| 250 | 
            +
                      step_name: @step_name,
         | 
| 251 | 
            +
                      tags: @tags
         | 
| 252 | 
            +
                    }
         | 
| 253 | 
            +
                  end
         | 
| 254 | 
            +
                end
         | 
| 255 | 
            +
              end
         | 
| 256 | 
            +
            end
         | 
| @@ -0,0 +1,308 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Aidp
         | 
| 4 | 
            +
              module PromptOptimization
         | 
| 5 | 
            +
                # Fragments source code files into retrievable code units
         | 
| 6 | 
            +
                #
         | 
| 7 | 
            +
                # Parses Ruby source files and extracts methods, classes, modules
         | 
| 8 | 
            +
                # along with their dependencies and imports. Each fragment can be
         | 
| 9 | 
            +
                # independently included or excluded from prompts.
         | 
| 10 | 
            +
                #
         | 
| 11 | 
            +
                # @example Basic usage
         | 
| 12 | 
            +
                #   fragmenter = SourceCodeFragmenter.new(project_dir: "/path/to/project")
         | 
| 13 | 
            +
                #   fragments = fragmenter.fragment_file("lib/my_file.rb")
         | 
| 14 | 
            +
                class SourceCodeFragmenter
         | 
| 15 | 
            +
                  attr_reader :project_dir
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  def initialize(project_dir:)
         | 
| 18 | 
            +
                    @project_dir = project_dir
         | 
| 19 | 
            +
                  end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  # Fragment a source file into code units
         | 
| 22 | 
            +
                  #
         | 
| 23 | 
            +
                  # @param file_path [String] Path to source file (relative or absolute)
         | 
| 24 | 
            +
                  # @param context_lines [Integer] Number of context lines around code units
         | 
| 25 | 
            +
                  # @return [Array<CodeFragment>] List of code fragments
         | 
| 26 | 
            +
                  def fragment_file(file_path, context_lines: 2)
         | 
| 27 | 
            +
                    abs_path = File.absolute_path?(file_path) ? file_path : File.join(@project_dir, file_path)
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                    return [] unless File.exist?(abs_path)
         | 
| 30 | 
            +
                    return [] unless abs_path.end_with?(".rb")
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    content = File.read(abs_path)
         | 
| 33 | 
            +
                    fragments = []
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                    # Extract requires/imports as first fragment
         | 
| 36 | 
            +
                    requires = extract_requires(content)
         | 
| 37 | 
            +
                    if requires && !requires.empty?
         | 
| 38 | 
            +
                      fragments << create_requires_fragment(abs_path, requires)
         | 
| 39 | 
            +
                    end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                    # Extract classes and modules
         | 
| 42 | 
            +
                    fragments.concat(extract_classes_and_modules(abs_path, content))
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                    # Extract top-level methods
         | 
| 45 | 
            +
                    fragments.concat(extract_methods(abs_path, content, context_lines: context_lines))
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                    fragments
         | 
| 48 | 
            +
                  end
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                  # Fragment multiple files
         | 
| 51 | 
            +
                  #
         | 
| 52 | 
            +
                  # @param file_paths [Array<String>] List of file paths
         | 
| 53 | 
            +
                  # @return [Array<CodeFragment>] All fragments from all files
         | 
| 54 | 
            +
                  def fragment_files(file_paths)
         | 
| 55 | 
            +
                    file_paths.flat_map { |path| fragment_file(path) }
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  private
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  # Extract require statements from content
         | 
| 61 | 
            +
                  #
         | 
| 62 | 
            +
                  # @param content [String] File content
         | 
| 63 | 
            +
                  # @return [String, nil] Combined require statements
         | 
| 64 | 
            +
                  def extract_requires(content)
         | 
| 65 | 
            +
                    lines = content.lines
         | 
| 66 | 
            +
                    require_lines = lines.select do |line|
         | 
| 67 | 
            +
                      line.strip =~ /^require(_relative)?\s+/
         | 
| 68 | 
            +
                    end
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                    return nil if require_lines.empty?
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                    require_lines.join
         | 
| 73 | 
            +
                  end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                  # Create a fragment for require statements
         | 
| 76 | 
            +
                  #
         | 
| 77 | 
            +
                  # @param file_path [String] Source file path
         | 
| 78 | 
            +
                  # @param requires [String] Require statements
         | 
| 79 | 
            +
                  # @return [CodeFragment] Requires fragment
         | 
| 80 | 
            +
                  def create_requires_fragment(file_path, requires)
         | 
| 81 | 
            +
                    CodeFragment.new(
         | 
| 82 | 
            +
                      id: "#{file_path}:requires",
         | 
| 83 | 
            +
                      file_path: file_path,
         | 
| 84 | 
            +
                      type: :requires,
         | 
| 85 | 
            +
                      name: "requires",
         | 
| 86 | 
            +
                      content: requires,
         | 
| 87 | 
            +
                      line_start: 1,
         | 
| 88 | 
            +
                      line_end: requires.lines.count
         | 
| 89 | 
            +
                    )
         | 
| 90 | 
            +
                  end
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                  # Extract classes and modules with their methods
         | 
| 93 | 
            +
                  #
         | 
| 94 | 
            +
                  # @param file_path [String] Source file path
         | 
| 95 | 
            +
                  # @param content [String] File content
         | 
| 96 | 
            +
                  # @return [Array<CodeFragment>] Class/module fragments
         | 
| 97 | 
            +
                  def extract_classes_and_modules(file_path, content)
         | 
| 98 | 
            +
                    fragments = []
         | 
| 99 | 
            +
                    lines = content.lines
         | 
| 100 | 
            +
             | 
| 101 | 
            +
                    current_class = nil
         | 
| 102 | 
            +
                    class_start = nil
         | 
| 103 | 
            +
                    indent_level = 0
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                    lines.each_with_index do |line, idx|
         | 
| 106 | 
            +
                      # Detect class/module definition
         | 
| 107 | 
            +
                      if line =~ /^(\s*)(class|module)\s+(\S+)/
         | 
| 108 | 
            +
                        current_indent = $1.length
         | 
| 109 | 
            +
                        $2
         | 
| 110 | 
            +
                        name = $3
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                        # Save previous class if exists
         | 
| 113 | 
            +
                        if current_class && class_start
         | 
| 114 | 
            +
                          class_content = lines[class_start..idx - 1].join
         | 
| 115 | 
            +
                          fragments << create_class_fragment(file_path, current_class, class_content, class_start + 1, idx)
         | 
| 116 | 
            +
                        end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                        current_class = name
         | 
| 119 | 
            +
                        class_start = idx
         | 
| 120 | 
            +
                        indent_level = current_indent
         | 
| 121 | 
            +
                      elsif line =~ /^(\s*)end/ && current_class
         | 
| 122 | 
            +
                        end_indent = $1.length
         | 
| 123 | 
            +
                        if end_indent <= indent_level
         | 
| 124 | 
            +
                          # Class/module end
         | 
| 125 | 
            +
                          class_content = lines[class_start..idx].join
         | 
| 126 | 
            +
                          fragments << create_class_fragment(file_path, current_class, class_content, class_start + 1, idx + 1)
         | 
| 127 | 
            +
                          current_class = nil
         | 
| 128 | 
            +
                          class_start = nil
         | 
| 129 | 
            +
                        end
         | 
| 130 | 
            +
                      end
         | 
| 131 | 
            +
                    end
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                    # Save last class if exists
         | 
| 134 | 
            +
                    if current_class && class_start
         | 
| 135 | 
            +
                      class_content = lines[class_start..].join
         | 
| 136 | 
            +
                      fragments << create_class_fragment(file_path, current_class, class_content, class_start + 1, lines.count)
         | 
| 137 | 
            +
                    end
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                    fragments
         | 
| 140 | 
            +
                  end
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                  # Create a fragment for a class/module
         | 
| 143 | 
            +
                  #
         | 
| 144 | 
            +
                  # @param file_path [String] Source file path
         | 
| 145 | 
            +
                  # @param name [String] Class/module name
         | 
| 146 | 
            +
                  # @param content [String] Class/module content
         | 
| 147 | 
            +
                  # @param line_start [Integer] Starting line number
         | 
| 148 | 
            +
                  # @param line_end [Integer] Ending line number
         | 
| 149 | 
            +
                  # @return [CodeFragment] Class fragment
         | 
| 150 | 
            +
                  def create_class_fragment(file_path, name, content, line_start, line_end)
         | 
| 151 | 
            +
                    CodeFragment.new(
         | 
| 152 | 
            +
                      id: "#{file_path}:#{name}",
         | 
| 153 | 
            +
                      file_path: file_path,
         | 
| 154 | 
            +
                      type: :class,
         | 
| 155 | 
            +
                      name: name,
         | 
| 156 | 
            +
                      content: content,
         | 
| 157 | 
            +
                      line_start: line_start,
         | 
| 158 | 
            +
                      line_end: line_end
         | 
| 159 | 
            +
                    )
         | 
| 160 | 
            +
                  end
         | 
| 161 | 
            +
             | 
| 162 | 
            +
                  # Extract top-level methods (not inside classes)
         | 
| 163 | 
            +
                  #
         | 
| 164 | 
            +
                  # @param file_path [String] Source file path
         | 
| 165 | 
            +
                  # @param content [String] File content
         | 
| 166 | 
            +
                  # @param context_lines [Integer] Lines of context around method
         | 
| 167 | 
            +
                  # @return [Array<CodeFragment>] Method fragments
         | 
| 168 | 
            +
                  def extract_methods(file_path, content, context_lines: 2)
         | 
| 169 | 
            +
                    fragments = []
         | 
| 170 | 
            +
                    lines = content.lines
         | 
| 171 | 
            +
             | 
| 172 | 
            +
                    in_class = false
         | 
| 173 | 
            +
                    method_start = nil
         | 
| 174 | 
            +
                    method_name = nil
         | 
| 175 | 
            +
                    indent_level = 0
         | 
| 176 | 
            +
             | 
| 177 | 
            +
                    lines.each_with_index do |line, idx|
         | 
| 178 | 
            +
                      # Track if we're inside a class
         | 
| 179 | 
            +
                      if /^(\s*)(class|module)\s+/.match?(line)
         | 
| 180 | 
            +
                        in_class = true
         | 
| 181 | 
            +
                        next
         | 
| 182 | 
            +
                      elsif line =~ /^end/ && in_class
         | 
| 183 | 
            +
                        in_class = false
         | 
| 184 | 
            +
                        next
         | 
| 185 | 
            +
                      end
         | 
| 186 | 
            +
             | 
| 187 | 
            +
                      # Skip methods inside classes
         | 
| 188 | 
            +
                      next if in_class
         | 
| 189 | 
            +
             | 
| 190 | 
            +
                      # Detect method definition
         | 
| 191 | 
            +
                      if line =~ /^(\s*)def\s+(\S+)/
         | 
| 192 | 
            +
                        method_start = [idx - context_lines, 0].max
         | 
| 193 | 
            +
                        method_name = $2
         | 
| 194 | 
            +
                        indent_level = $1.length
         | 
| 195 | 
            +
                      elsif line =~ /^(\s*)end/ && method_name
         | 
| 196 | 
            +
                        end_indent = $1.length
         | 
| 197 | 
            +
                        if end_indent <= indent_level
         | 
| 198 | 
            +
                          # Method end
         | 
| 199 | 
            +
                          method_end = [idx + context_lines, lines.count - 1].min
         | 
| 200 | 
            +
                          method_content = lines[method_start..method_end].join
         | 
| 201 | 
            +
             | 
| 202 | 
            +
                          fragments << CodeFragment.new(
         | 
| 203 | 
            +
                            id: "#{file_path}:#{method_name}",
         | 
| 204 | 
            +
                            file_path: file_path,
         | 
| 205 | 
            +
                            type: :method,
         | 
| 206 | 
            +
                            name: method_name,
         | 
| 207 | 
            +
                            content: method_content,
         | 
| 208 | 
            +
                            line_start: method_start + 1,
         | 
| 209 | 
            +
                            line_end: method_end + 1
         | 
| 210 | 
            +
                          )
         | 
| 211 | 
            +
             | 
| 212 | 
            +
                          method_start = nil
         | 
| 213 | 
            +
                          method_name = nil
         | 
| 214 | 
            +
                        end
         | 
| 215 | 
            +
                      end
         | 
| 216 | 
            +
                    end
         | 
| 217 | 
            +
             | 
| 218 | 
            +
                    fragments
         | 
| 219 | 
            +
                  end
         | 
| 220 | 
            +
                end
         | 
| 221 | 
            +
             | 
| 222 | 
            +
                # Represents a code fragment (class, method, requires, etc.)
         | 
| 223 | 
            +
                #
         | 
| 224 | 
            +
                # Each fragment is a logical unit of code that can be independently
         | 
| 225 | 
            +
                # included or excluded from prompts based on relevance
         | 
| 226 | 
            +
                class CodeFragment
         | 
| 227 | 
            +
                  attr_reader :id, :file_path, :type, :name, :content, :line_start, :line_end
         | 
| 228 | 
            +
             | 
| 229 | 
            +
                  # @param id [String] Unique identifier (e.g., "lib/user.rb:User")
         | 
| 230 | 
            +
                  # @param file_path [String] Source file path
         | 
| 231 | 
            +
                  # @param type [Symbol] Fragment type (:class, :module, :method, :requires)
         | 
| 232 | 
            +
                  # @param name [String] Name of the code unit
         | 
| 233 | 
            +
                  # @param content [String] Code content
         | 
| 234 | 
            +
                  # @param line_start [Integer] Starting line number
         | 
| 235 | 
            +
                  # @param line_end [Integer] Ending line number
         | 
| 236 | 
            +
                  def initialize(id:, file_path:, type:, name:, content:, line_start:, line_end:)
         | 
| 237 | 
            +
                    @id = id
         | 
| 238 | 
            +
                    @file_path = file_path
         | 
| 239 | 
            +
                    @type = type
         | 
| 240 | 
            +
                    @name = name
         | 
| 241 | 
            +
                    @content = content
         | 
| 242 | 
            +
                    @line_start = line_start
         | 
| 243 | 
            +
                    @line_end = line_end
         | 
| 244 | 
            +
                  end
         | 
| 245 | 
            +
             | 
| 246 | 
            +
                  # Get the size of the fragment in characters
         | 
| 247 | 
            +
                  #
         | 
| 248 | 
            +
                  # @return [Integer] Character count
         | 
| 249 | 
            +
                  def size
         | 
| 250 | 
            +
                    @content.length
         | 
| 251 | 
            +
                  end
         | 
| 252 | 
            +
             | 
| 253 | 
            +
                  # Estimate token count (rough approximation: 1 token ≈ 4 chars)
         | 
| 254 | 
            +
                  #
         | 
| 255 | 
            +
                  # @return [Integer] Estimated token count
         | 
| 256 | 
            +
                  def estimated_tokens
         | 
| 257 | 
            +
                    (size / 4.0).ceil
         | 
| 258 | 
            +
                  end
         | 
| 259 | 
            +
             | 
| 260 | 
            +
                  # Get line count
         | 
| 261 | 
            +
                  #
         | 
| 262 | 
            +
                  # @return [Integer] Number of lines
         | 
| 263 | 
            +
                  def line_count
         | 
| 264 | 
            +
                    @line_end - @line_start + 1
         | 
| 265 | 
            +
                  end
         | 
| 266 | 
            +
             | 
| 267 | 
            +
                  # Get relative file path from project root
         | 
| 268 | 
            +
                  #
         | 
| 269 | 
            +
                  # @param project_dir [String] Project directory
         | 
| 270 | 
            +
                  # @return [String] Relative path
         | 
| 271 | 
            +
                  def relative_path(project_dir)
         | 
| 272 | 
            +
                    @file_path.sub(%r{^#{Regexp.escape(project_dir)}/?}, "")
         | 
| 273 | 
            +
                  end
         | 
| 274 | 
            +
             | 
| 275 | 
            +
                  # Check if this is a test file fragment
         | 
| 276 | 
            +
                  #
         | 
| 277 | 
            +
                  # @return [Boolean] True if from spec file
         | 
| 278 | 
            +
                  def test_file?
         | 
| 279 | 
            +
                    !!(@file_path =~ /_(spec|test)\.rb$/)
         | 
| 280 | 
            +
                  end
         | 
| 281 | 
            +
             | 
| 282 | 
            +
                  # Get a summary of the fragment
         | 
| 283 | 
            +
                  #
         | 
| 284 | 
            +
                  # @return [Hash] Fragment summary
         | 
| 285 | 
            +
                  def summary
         | 
| 286 | 
            +
                    {
         | 
| 287 | 
            +
                      id: @id,
         | 
| 288 | 
            +
                      file_path: @file_path,
         | 
| 289 | 
            +
                      type: @type,
         | 
| 290 | 
            +
                      name: @name,
         | 
| 291 | 
            +
                      lines: "#{@line_start}-#{@line_end}",
         | 
| 292 | 
            +
                      line_count: line_count,
         | 
| 293 | 
            +
                      size: size,
         | 
| 294 | 
            +
                      estimated_tokens: estimated_tokens,
         | 
| 295 | 
            +
                      test_file: test_file?
         | 
| 296 | 
            +
                    }
         | 
| 297 | 
            +
                  end
         | 
| 298 | 
            +
             | 
| 299 | 
            +
                  def to_s
         | 
| 300 | 
            +
                    "CodeFragment<#{@type}:#{@name}>"
         | 
| 301 | 
            +
                  end
         | 
| 302 | 
            +
             | 
| 303 | 
            +
                  def inspect
         | 
| 304 | 
            +
                    "#<CodeFragment id=#{@id} type=#{@type} lines=#{@line_start}-#{@line_end}>"
         | 
| 305 | 
            +
                  end
         | 
| 306 | 
            +
                end
         | 
| 307 | 
            +
              end
         | 
| 308 | 
            +
            end
         |