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,376 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "json"
         | 
| 4 | 
            +
            require_relative "provider_factory"
         | 
| 5 | 
            +
            require_relative "thinking_depth_manager"
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            module Aidp
         | 
| 8 | 
            +
              module Harness
         | 
| 9 | 
            +
                # Zero Framework Cognition (ZFC) Decision Engine
         | 
| 10 | 
            +
                #
         | 
| 11 | 
            +
                # Delegates semantic analysis and decision-making to AI models instead of
         | 
| 12 | 
            +
                # using brittle pattern matching, scoring formulas, or heuristic thresholds.
         | 
| 13 | 
            +
                #
         | 
| 14 | 
            +
                # @example Basic usage
         | 
| 15 | 
            +
                #   engine = AIDecisionEngine.new(config, provider_manager)
         | 
| 16 | 
            +
                #   result = engine.decide(:condition_detection,
         | 
| 17 | 
            +
                #     context: { error: "Rate limit exceeded" },
         | 
| 18 | 
            +
                #     schema: ConditionSchema,
         | 
| 19 | 
            +
                #     tier: "mini"
         | 
| 20 | 
            +
                #   )
         | 
| 21 | 
            +
                #   # => { condition: "rate_limit", confidence: 0.95, reasoning: "..." }
         | 
| 22 | 
            +
                #
         | 
| 23 | 
            +
                # @see docs/ZFC_COMPLIANCE_ASSESSMENT.md
         | 
| 24 | 
            +
                # @see docs/ZFC_IMPLEMENTATION_PLAN.md
         | 
| 25 | 
            +
                class AIDecisionEngine
         | 
| 26 | 
            +
                  # Decision templates define prompts, schemas, and defaults for each decision type
         | 
| 27 | 
            +
                  DECISION_TEMPLATES = {
         | 
| 28 | 
            +
                    condition_detection: {
         | 
| 29 | 
            +
                      prompt_template: <<~PROMPT,
         | 
| 30 | 
            +
                        Analyze the following API response or error message and classify the condition.
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                        Response/Error:
         | 
| 33 | 
            +
                        {{response}}
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                        Classify this into one of the following conditions:
         | 
| 36 | 
            +
                        - rate_limit: API rate limiting or quota exceeded
         | 
| 37 | 
            +
                        - auth_error: Authentication or authorization failure
         | 
| 38 | 
            +
                        - timeout: Request timeout or network timeout
         | 
| 39 | 
            +
                        - completion_marker: Work is complete or done
         | 
| 40 | 
            +
                        - user_feedback_needed: AI is asking for user input/clarification
         | 
| 41 | 
            +
                        - api_error: General API error (not rate limit/auth)
         | 
| 42 | 
            +
                        - success: Successful response
         | 
| 43 | 
            +
                        - other: None of the above
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                        Provide your classification with a confidence score (0.0 to 1.0) and brief reasoning.
         | 
| 46 | 
            +
                      PROMPT
         | 
| 47 | 
            +
                      schema: {
         | 
| 48 | 
            +
                        type: "object",
         | 
| 49 | 
            +
                        properties: {
         | 
| 50 | 
            +
                          condition: {
         | 
| 51 | 
            +
                            type: "string",
         | 
| 52 | 
            +
                            enum: [
         | 
| 53 | 
            +
                              "rate_limit",
         | 
| 54 | 
            +
                              "auth_error",
         | 
| 55 | 
            +
                              "timeout",
         | 
| 56 | 
            +
                              "completion_marker",
         | 
| 57 | 
            +
                              "user_feedback_needed",
         | 
| 58 | 
            +
                              "api_error",
         | 
| 59 | 
            +
                              "success",
         | 
| 60 | 
            +
                              "other"
         | 
| 61 | 
            +
                            ]
         | 
| 62 | 
            +
                          },
         | 
| 63 | 
            +
                          confidence: {
         | 
| 64 | 
            +
                            type: "number",
         | 
| 65 | 
            +
                            minimum: 0.0,
         | 
| 66 | 
            +
                            maximum: 1.0
         | 
| 67 | 
            +
                          },
         | 
| 68 | 
            +
                          reasoning: {
         | 
| 69 | 
            +
                            type: "string"
         | 
| 70 | 
            +
                          }
         | 
| 71 | 
            +
                        },
         | 
| 72 | 
            +
                        required: ["condition", "confidence"]
         | 
| 73 | 
            +
                      },
         | 
| 74 | 
            +
                      default_tier: "mini",
         | 
| 75 | 
            +
                      cache_ttl: nil  # Each response is unique
         | 
| 76 | 
            +
                    },
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                    error_classification: {
         | 
| 79 | 
            +
                      prompt_template: <<~PROMPT,
         | 
| 80 | 
            +
                        Classify the following error and determine if it's retryable.
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                        Error:
         | 
| 83 | 
            +
                        {{error_message}}
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                        Context:
         | 
| 86 | 
            +
                        {{context}}
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                        Determine:
         | 
| 89 | 
            +
                        1. Error type (rate_limit, auth, timeout, network, api_bug, other)
         | 
| 90 | 
            +
                        2. Whether it's retryable (transient vs permanent)
         | 
| 91 | 
            +
                        3. Recommended action (retry, switch_provider, escalate, fail)
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                        Provide classification with confidence and reasoning.
         | 
| 94 | 
            +
                      PROMPT
         | 
| 95 | 
            +
                      schema: {
         | 
| 96 | 
            +
                        type: "object",
         | 
| 97 | 
            +
                        properties: {
         | 
| 98 | 
            +
                          error_type: {
         | 
| 99 | 
            +
                            type: "string",
         | 
| 100 | 
            +
                            enum: ["rate_limit", "auth", "timeout", "network", "api_bug", "other"]
         | 
| 101 | 
            +
                          },
         | 
| 102 | 
            +
                          retryable: {
         | 
| 103 | 
            +
                            type: "boolean"
         | 
| 104 | 
            +
                          },
         | 
| 105 | 
            +
                          recommended_action: {
         | 
| 106 | 
            +
                            type: "string",
         | 
| 107 | 
            +
                            enum: ["retry", "switch_provider", "escalate", "fail"]
         | 
| 108 | 
            +
                          },
         | 
| 109 | 
            +
                          confidence: {
         | 
| 110 | 
            +
                            type: "number",
         | 
| 111 | 
            +
                            minimum: 0.0,
         | 
| 112 | 
            +
                            maximum: 1.0
         | 
| 113 | 
            +
                          },
         | 
| 114 | 
            +
                          reasoning: {
         | 
| 115 | 
            +
                            type: "string"
         | 
| 116 | 
            +
                          }
         | 
| 117 | 
            +
                        },
         | 
| 118 | 
            +
                        required: ["error_type", "retryable", "recommended_action", "confidence"]
         | 
| 119 | 
            +
                      },
         | 
| 120 | 
            +
                      default_tier: "mini",
         | 
| 121 | 
            +
                      cache_ttl: nil
         | 
| 122 | 
            +
                    },
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                    completion_detection: {
         | 
| 125 | 
            +
                      prompt_template: <<~PROMPT,
         | 
| 126 | 
            +
                        Determine if the work described is complete based on the AI response.
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                        Task:
         | 
| 129 | 
            +
                        {{task_description}}
         | 
| 130 | 
            +
             | 
| 131 | 
            +
                        AI Response:
         | 
| 132 | 
            +
                        {{response}}
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                        Is the work complete? Consider:
         | 
| 135 | 
            +
                        - Explicit completion markers ("done", "finished", etc.)
         | 
| 136 | 
            +
                        - Implicit indicators (results provided, no follow-up questions)
         | 
| 137 | 
            +
                        - Requests for more information (incomplete)
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                        Provide boolean completion status with confidence and reasoning.
         | 
| 140 | 
            +
                      PROMPT
         | 
| 141 | 
            +
                      schema: {
         | 
| 142 | 
            +
                        type: "object",
         | 
| 143 | 
            +
                        properties: {
         | 
| 144 | 
            +
                          complete: {
         | 
| 145 | 
            +
                            type: "boolean"
         | 
| 146 | 
            +
                          },
         | 
| 147 | 
            +
                          confidence: {
         | 
| 148 | 
            +
                            type: "number",
         | 
| 149 | 
            +
                            minimum: 0.0,
         | 
| 150 | 
            +
                            maximum: 1.0
         | 
| 151 | 
            +
                          },
         | 
| 152 | 
            +
                          reasoning: {
         | 
| 153 | 
            +
                            type: "string"
         | 
| 154 | 
            +
                          }
         | 
| 155 | 
            +
                        },
         | 
| 156 | 
            +
                        required: ["complete", "confidence"]
         | 
| 157 | 
            +
                      },
         | 
| 158 | 
            +
                      default_tier: "mini",
         | 
| 159 | 
            +
                      cache_ttl: nil
         | 
| 160 | 
            +
                    }
         | 
| 161 | 
            +
                  }.freeze
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                  attr_reader :config, :provider_factory, :cache
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                  # Initialize the AI Decision Engine
         | 
| 166 | 
            +
                  #
         | 
| 167 | 
            +
                  # @param config [Configuration] AIDP configuration object
         | 
| 168 | 
            +
                  # @param provider_factory [ProviderFactory] Factory for creating provider instances
         | 
| 169 | 
            +
                  def initialize(config, provider_factory: nil)
         | 
| 170 | 
            +
                    @config = config
         | 
| 171 | 
            +
                    @provider_factory = provider_factory || ProviderFactory.new(config)
         | 
| 172 | 
            +
                    @cache = {}
         | 
| 173 | 
            +
                    @cache_timestamps = {}
         | 
| 174 | 
            +
                  end
         | 
| 175 | 
            +
             | 
| 176 | 
            +
                  # Make an AI-powered decision
         | 
| 177 | 
            +
                  #
         | 
| 178 | 
            +
                  # @param decision_type [Symbol] Type of decision (:condition_detection, :error_classification, etc.)
         | 
| 179 | 
            +
                  # @param context [Hash] Context data for the decision
         | 
| 180 | 
            +
                  # @param schema [Hash, nil] JSON schema for response validation (overrides default)
         | 
| 181 | 
            +
                  # @param tier [String, nil] Thinking depth tier (overrides default)
         | 
| 182 | 
            +
                  # @param cache_ttl [Integer, nil] Cache TTL in seconds (overrides default)
         | 
| 183 | 
            +
                  # @return [Hash] Validated decision result
         | 
| 184 | 
            +
                  # @raise [ArgumentError] If decision_type is unknown
         | 
| 185 | 
            +
                  # @raise [ValidationError] If response doesn't match schema
         | 
| 186 | 
            +
                  def decide(decision_type, context:, schema: nil, tier: nil, cache_ttl: nil)
         | 
| 187 | 
            +
                    template = DECISION_TEMPLATES[decision_type]
         | 
| 188 | 
            +
                    raise ArgumentError, "Unknown decision type: #{decision_type}" unless template
         | 
| 189 | 
            +
             | 
| 190 | 
            +
                    # Check cache if TTL specified
         | 
| 191 | 
            +
                    cache_key = build_cache_key(decision_type, context)
         | 
| 192 | 
            +
                    ttl = cache_ttl || template[:cache_ttl]
         | 
| 193 | 
            +
                    if ttl && (cached_result = get_cached(cache_key, ttl))
         | 
| 194 | 
            +
                      Aidp.log_debug("ai_decision_engine", "Cache hit for #{decision_type}", {
         | 
| 195 | 
            +
                        cache_key: cache_key,
         | 
| 196 | 
            +
                        ttl: ttl
         | 
| 197 | 
            +
                      })
         | 
| 198 | 
            +
                      return cached_result
         | 
| 199 | 
            +
                    end
         | 
| 200 | 
            +
             | 
| 201 | 
            +
                    # Build prompt from template
         | 
| 202 | 
            +
                    prompt = build_prompt(template[:prompt_template], context)
         | 
| 203 | 
            +
             | 
| 204 | 
            +
                    # Select tier
         | 
| 205 | 
            +
                    selected_tier = tier || template[:default_tier]
         | 
| 206 | 
            +
             | 
| 207 | 
            +
                    # Get model for tier
         | 
| 208 | 
            +
                    thinking_manager = ThinkingDepthManager.new(config)
         | 
| 209 | 
            +
                    provider_name, model_name, _model_data = thinking_manager.select_model_for_tier(selected_tier)
         | 
| 210 | 
            +
             | 
| 211 | 
            +
                    Aidp.log_debug("ai_decision_engine", "Making AI decision", {
         | 
| 212 | 
            +
                      decision_type: decision_type,
         | 
| 213 | 
            +
                      tier: selected_tier,
         | 
| 214 | 
            +
                      provider: provider_name,
         | 
| 215 | 
            +
                      model: model_name,
         | 
| 216 | 
            +
                      cache_ttl: ttl
         | 
| 217 | 
            +
                    })
         | 
| 218 | 
            +
             | 
| 219 | 
            +
                    # Call AI with schema validation
         | 
| 220 | 
            +
                    response_schema = schema || template[:schema]
         | 
| 221 | 
            +
                    result = call_ai_with_schema(provider_name, model_name, prompt, response_schema)
         | 
| 222 | 
            +
             | 
| 223 | 
            +
                    # Validate result
         | 
| 224 | 
            +
                    validate_schema(result, response_schema)
         | 
| 225 | 
            +
             | 
| 226 | 
            +
                    # Cache if TTL specified
         | 
| 227 | 
            +
                    if ttl
         | 
| 228 | 
            +
                      set_cached(cache_key, result)
         | 
| 229 | 
            +
                    end
         | 
| 230 | 
            +
             | 
| 231 | 
            +
                    result
         | 
| 232 | 
            +
                  end
         | 
| 233 | 
            +
             | 
| 234 | 
            +
                  private
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                  # Build cache key from decision type and context
         | 
| 237 | 
            +
                  def build_cache_key(decision_type, context)
         | 
| 238 | 
            +
                    # Simple hash-based key
         | 
| 239 | 
            +
                    "#{decision_type}:#{context.hash}"
         | 
| 240 | 
            +
                  end
         | 
| 241 | 
            +
             | 
| 242 | 
            +
                  # Get cached result if still valid
         | 
| 243 | 
            +
                  def get_cached(key, ttl)
         | 
| 244 | 
            +
                    return nil unless @cache.key?(key)
         | 
| 245 | 
            +
                    return nil if Time.now - @cache_timestamps[key] > ttl
         | 
| 246 | 
            +
                    @cache[key]
         | 
| 247 | 
            +
                  end
         | 
| 248 | 
            +
             | 
| 249 | 
            +
                  # Store result in cache
         | 
| 250 | 
            +
                  def set_cached(key, value)
         | 
| 251 | 
            +
                    @cache[key] = value
         | 
| 252 | 
            +
                    @cache_timestamps[key] = Time.now
         | 
| 253 | 
            +
                  end
         | 
| 254 | 
            +
             | 
| 255 | 
            +
                  # Build prompt from template with context substitution
         | 
| 256 | 
            +
                  def build_prompt(template, context)
         | 
| 257 | 
            +
                    prompt = template.dup
         | 
| 258 | 
            +
                    context.each do |key, value|
         | 
| 259 | 
            +
                      prompt.gsub!("{{#{key}}}", value.to_s)
         | 
| 260 | 
            +
                    end
         | 
| 261 | 
            +
                    prompt
         | 
| 262 | 
            +
                  end
         | 
| 263 | 
            +
             | 
| 264 | 
            +
                  # Call AI with schema validation using structured output
         | 
| 265 | 
            +
                  def call_ai_with_schema(provider_name, model_name, prompt, schema)
         | 
| 266 | 
            +
                    # Create provider instance
         | 
| 267 | 
            +
                    provider_options = {
         | 
| 268 | 
            +
                      model: model_name,
         | 
| 269 | 
            +
                      output: nil,  # No output for background decisions
         | 
| 270 | 
            +
                      prompt: nil   # No TTY prompt needed
         | 
| 271 | 
            +
                    }
         | 
| 272 | 
            +
             | 
| 273 | 
            +
                    provider = @provider_factory.create_provider(provider_name, provider_options)
         | 
| 274 | 
            +
             | 
| 275 | 
            +
                    # Build enhanced prompt requesting JSON output
         | 
| 276 | 
            +
                    enhanced_prompt = <<~PROMPT
         | 
| 277 | 
            +
                      #{prompt}
         | 
| 278 | 
            +
             | 
| 279 | 
            +
                      IMPORTANT: Respond with ONLY valid JSON. No additional text or explanation.
         | 
| 280 | 
            +
                      The JSON must match this structure: #{JSON.generate(schema[:properties].keys)}
         | 
| 281 | 
            +
                    PROMPT
         | 
| 282 | 
            +
             | 
| 283 | 
            +
                    # Call provider
         | 
| 284 | 
            +
                    response = provider.send_message(prompt: enhanced_prompt, session: nil)
         | 
| 285 | 
            +
             | 
| 286 | 
            +
                    # Parse JSON response
         | 
| 287 | 
            +
                    begin
         | 
| 288 | 
            +
                      # Response might be a string or already structured
         | 
| 289 | 
            +
                      response_text = response.is_a?(String) ? response : response.to_s
         | 
| 290 | 
            +
             | 
| 291 | 
            +
                      # Try to extract JSON if there's extra text
         | 
| 292 | 
            +
                      # Use non-greedy match and handle nested braces
         | 
| 293 | 
            +
                      json_match = response_text.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/m) || response_text.match(/\{.*\}/m)
         | 
| 294 | 
            +
                      json_text = json_match ? json_match[0] : response_text
         | 
| 295 | 
            +
             | 
| 296 | 
            +
                      result = JSON.parse(json_text, symbolize_names: true)
         | 
| 297 | 
            +
             | 
| 298 | 
            +
                      Aidp.log_debug("ai_decision_engine", "Parsed JSON successfully", {
         | 
| 299 | 
            +
                        response_length: response_text.length,
         | 
| 300 | 
            +
                        json_length: json_text.length,
         | 
| 301 | 
            +
                        result_keys: result.keys,
         | 
| 302 | 
            +
                        provider: provider_name
         | 
| 303 | 
            +
                      })
         | 
| 304 | 
            +
             | 
| 305 | 
            +
                      result
         | 
| 306 | 
            +
                    rescue JSON::ParserError => e
         | 
| 307 | 
            +
                      Aidp.log_error("ai_decision_engine", "Failed to parse AI response as JSON", {
         | 
| 308 | 
            +
                        error: e.message,
         | 
| 309 | 
            +
                        response: response_text&.slice(0, 200),
         | 
| 310 | 
            +
                        provider: provider_name,
         | 
| 311 | 
            +
                        model: model_name
         | 
| 312 | 
            +
                      })
         | 
| 313 | 
            +
                      raise ValidationError, "AI response is not valid JSON: #{e.message}"
         | 
| 314 | 
            +
                    end
         | 
| 315 | 
            +
                  rescue => e
         | 
| 316 | 
            +
                    Aidp.log_error("ai_decision_engine", "Error calling AI provider", {
         | 
| 317 | 
            +
                      error: e.message,
         | 
| 318 | 
            +
                      provider: provider_name,
         | 
| 319 | 
            +
                      model: model_name,
         | 
| 320 | 
            +
                      error_class: e.class.name
         | 
| 321 | 
            +
                    })
         | 
| 322 | 
            +
                    raise
         | 
| 323 | 
            +
                  end
         | 
| 324 | 
            +
             | 
| 325 | 
            +
                  # Validate response against JSON schema
         | 
| 326 | 
            +
                  def validate_schema(result, schema)
         | 
| 327 | 
            +
                    # Basic validation of required fields and types
         | 
| 328 | 
            +
                    # Schema uses string keys, but our result uses symbol keys from JSON parsing
         | 
| 329 | 
            +
                    schema[:required]&.each do |field|
         | 
| 330 | 
            +
                      field_sym = field.to_sym
         | 
| 331 | 
            +
                      unless result.key?(field_sym)
         | 
| 332 | 
            +
                        raise ValidationError, "Missing required field: #{field}"
         | 
| 333 | 
            +
                      end
         | 
| 334 | 
            +
                    end
         | 
| 335 | 
            +
             | 
| 336 | 
            +
                    schema[:properties]&.each do |field, constraints|
         | 
| 337 | 
            +
                      field_sym = field.to_sym
         | 
| 338 | 
            +
                      next unless result.key?(field_sym)
         | 
| 339 | 
            +
                      value = result[field_sym]
         | 
| 340 | 
            +
             | 
| 341 | 
            +
                      # Type validation
         | 
| 342 | 
            +
                      case constraints[:type]
         | 
| 343 | 
            +
                      when "string"
         | 
| 344 | 
            +
                        unless value.is_a?(String)
         | 
| 345 | 
            +
                          raise ValidationError, "Field #{field} must be string, got #{value.class}"
         | 
| 346 | 
            +
                        end
         | 
| 347 | 
            +
                        # Enum validation
         | 
| 348 | 
            +
                        if constraints[:enum] && !constraints[:enum].include?(value)
         | 
| 349 | 
            +
                          raise ValidationError, "Field #{field} must be one of #{constraints[:enum]}, got #{value}"
         | 
| 350 | 
            +
                        end
         | 
| 351 | 
            +
                      when "number"
         | 
| 352 | 
            +
                        unless value.is_a?(Numeric)
         | 
| 353 | 
            +
                          raise ValidationError, "Field #{field} must be number, got #{value.class}"
         | 
| 354 | 
            +
                        end
         | 
| 355 | 
            +
                        # Range validation
         | 
| 356 | 
            +
                        if constraints[:minimum] && value < constraints[:minimum]
         | 
| 357 | 
            +
                          raise ValidationError, "Field #{field} must be >= #{constraints[:minimum]}"
         | 
| 358 | 
            +
                        end
         | 
| 359 | 
            +
                        if constraints[:maximum] && value > constraints[:maximum]
         | 
| 360 | 
            +
                          raise ValidationError, "Field #{field} must be <= #{constraints[:maximum]}"
         | 
| 361 | 
            +
                        end
         | 
| 362 | 
            +
                      when "boolean"
         | 
| 363 | 
            +
                        unless [true, false].include?(value)
         | 
| 364 | 
            +
                          raise ValidationError, "Field #{field} must be boolean, got #{value.class}"
         | 
| 365 | 
            +
                        end
         | 
| 366 | 
            +
                      end
         | 
| 367 | 
            +
                    end
         | 
| 368 | 
            +
             | 
| 369 | 
            +
                    true
         | 
| 370 | 
            +
                  end
         | 
| 371 | 
            +
                end
         | 
| 372 | 
            +
             | 
| 373 | 
            +
                # Validation error for schema violations
         | 
| 374 | 
            +
                class ValidationError < StandardError; end
         | 
| 375 | 
            +
              end
         | 
| 376 | 
            +
            end
         | 
| @@ -0,0 +1,273 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "yaml"
         | 
| 4 | 
            +
            require "fileutils"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Aidp
         | 
| 7 | 
            +
              module Harness
         | 
| 8 | 
            +
                # Stores and queries model capability metadata from the catalog
         | 
| 9 | 
            +
                # Provides information about model tiers, features, costs, and context windows
         | 
| 10 | 
            +
                class CapabilityRegistry
         | 
| 11 | 
            +
                  # Valid thinking depth tiers
         | 
| 12 | 
            +
                  VALID_TIERS = %w[mini standard thinking pro max].freeze
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  # Tier priority for escalation (lower index = lower tier)
         | 
| 15 | 
            +
                  TIER_PRIORITY = {
         | 
| 16 | 
            +
                    "mini" => 0,
         | 
| 17 | 
            +
                    "standard" => 1,
         | 
| 18 | 
            +
                    "thinking" => 2,
         | 
| 19 | 
            +
                    "pro" => 3,
         | 
| 20 | 
            +
                    "max" => 4
         | 
| 21 | 
            +
                  }.freeze
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  attr_reader :catalog_path
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                  def initialize(catalog_path: nil, root_dir: nil)
         | 
| 26 | 
            +
                    @root_dir = root_dir || Dir.pwd
         | 
| 27 | 
            +
                    @catalog_path = catalog_path || default_catalog_path
         | 
| 28 | 
            +
                    @catalog_data = nil
         | 
| 29 | 
            +
                    @loaded_at = nil
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  # Load catalog from YAML file
         | 
| 33 | 
            +
                  def load_catalog
         | 
| 34 | 
            +
                    return false unless File.exist?(@catalog_path)
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                    @catalog_data = YAML.safe_load_file(
         | 
| 37 | 
            +
                      @catalog_path,
         | 
| 38 | 
            +
                      permitted_classes: [Symbol],
         | 
| 39 | 
            +
                      symbolize_names: false
         | 
| 40 | 
            +
                    )
         | 
| 41 | 
            +
                    @loaded_at = Time.now
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                    validate_catalog(@catalog_data)
         | 
| 44 | 
            +
                    Aidp.log_debug("capability_registry", "Loaded catalog", path: @catalog_path, providers: provider_names.size)
         | 
| 45 | 
            +
                    true
         | 
| 46 | 
            +
                  rescue => e
         | 
| 47 | 
            +
                    Aidp.log_error("capability_registry", "Failed to load catalog", error: e.message, path: @catalog_path)
         | 
| 48 | 
            +
                    @catalog_data = nil
         | 
| 49 | 
            +
                    false
         | 
| 50 | 
            +
                  end
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                  # Get catalog data (lazy load if needed)
         | 
| 53 | 
            +
                  def catalog
         | 
| 54 | 
            +
                    load_catalog if @catalog_data.nil?
         | 
| 55 | 
            +
                    @catalog_data || default_empty_catalog
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  # Get all provider names in catalog
         | 
| 59 | 
            +
                  def provider_names
         | 
| 60 | 
            +
                    catalog.dig("providers")&.keys || []
         | 
| 61 | 
            +
                  end
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                  # Get all models for a provider
         | 
| 64 | 
            +
                  def models_for_provider(provider_name)
         | 
| 65 | 
            +
                    provider_data = catalog.dig("providers", provider_name)
         | 
| 66 | 
            +
                    return {} unless provider_data
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                    provider_data["models"] || {}
         | 
| 69 | 
            +
                  end
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                  # Get tier for a specific model
         | 
| 72 | 
            +
                  def tier_for_model(provider_name, model_name)
         | 
| 73 | 
            +
                    model_data = model_info(provider_name, model_name)
         | 
| 74 | 
            +
                    return nil unless model_data
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                    model_data["tier"]
         | 
| 77 | 
            +
                  end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                  # Get all models matching a specific tier
         | 
| 80 | 
            +
                  # Returns hash: { provider_name => [model_name, ...] }
         | 
| 81 | 
            +
                  def models_by_tier(tier, provider: nil)
         | 
| 82 | 
            +
                    validate_tier!(tier)
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                    results = {}
         | 
| 85 | 
            +
                    providers_to_search = provider ? [provider] : provider_names
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                    providers_to_search.each do |provider_name|
         | 
| 88 | 
            +
                      matching_models = []
         | 
| 89 | 
            +
                      models_for_provider(provider_name).each do |model_name, model_data|
         | 
| 90 | 
            +
                        matching_models << model_name if model_data["tier"] == tier
         | 
| 91 | 
            +
                      end
         | 
| 92 | 
            +
                      results[provider_name] = matching_models unless matching_models.empty?
         | 
| 93 | 
            +
                    end
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                    results
         | 
| 96 | 
            +
                  end
         | 
| 97 | 
            +
             | 
| 98 | 
            +
                  # Get complete info for a specific model
         | 
| 99 | 
            +
                  def model_info(provider_name, model_name)
         | 
| 100 | 
            +
                    catalog.dig("providers", provider_name, "models", model_name)
         | 
| 101 | 
            +
                  end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                  # Get display name for a provider
         | 
| 104 | 
            +
                  def provider_display_name(provider_name)
         | 
| 105 | 
            +
                    catalog.dig("providers", provider_name, "display_name") || provider_name
         | 
| 106 | 
            +
                  end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                  # Get all tiers supported by a provider
         | 
| 109 | 
            +
                  def supported_tiers(provider_name)
         | 
| 110 | 
            +
                    models = models_for_provider(provider_name)
         | 
| 111 | 
            +
                    tiers = models.values.map { |m| m["tier"] }.compact.uniq
         | 
| 112 | 
            +
                    tiers.sort_by { |t| TIER_PRIORITY[t] || 999 }
         | 
| 113 | 
            +
                  end
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                  # Check if a tier is valid
         | 
| 116 | 
            +
                  def valid_tier?(tier)
         | 
| 117 | 
            +
                    VALID_TIERS.include?(tier)
         | 
| 118 | 
            +
                  end
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                  # Get tier priority (0 = lowest, 4 = highest)
         | 
| 121 | 
            +
                  def tier_priority(tier)
         | 
| 122 | 
            +
                    TIER_PRIORITY[tier]
         | 
| 123 | 
            +
                  end
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                  # Compare two tiers (returns -1, 0, 1 like <=>)
         | 
| 126 | 
            +
                  def compare_tiers(tier1, tier2)
         | 
| 127 | 
            +
                    priority1 = tier_priority(tier1) || -1
         | 
| 128 | 
            +
                    priority2 = tier_priority(tier2) || -1
         | 
| 129 | 
            +
                    priority1 <=> priority2
         | 
| 130 | 
            +
                  end
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                  # Get next higher tier (or nil if already at max)
         | 
| 133 | 
            +
                  def next_tier(tier)
         | 
| 134 | 
            +
                    validate_tier!(tier)
         | 
| 135 | 
            +
                    current_priority = tier_priority(tier)
         | 
| 136 | 
            +
                    return nil if current_priority >= TIER_PRIORITY["max"]
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                    TIER_PRIORITY.key(current_priority + 1)
         | 
| 139 | 
            +
                  end
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                  # Get next lower tier (or nil if already at mini)
         | 
| 142 | 
            +
                  def previous_tier(tier)
         | 
| 143 | 
            +
                    validate_tier!(tier)
         | 
| 144 | 
            +
                    current_priority = tier_priority(tier)
         | 
| 145 | 
            +
                    return nil if current_priority <= TIER_PRIORITY["mini"]
         | 
| 146 | 
            +
             | 
| 147 | 
            +
                    TIER_PRIORITY.key(current_priority - 1)
         | 
| 148 | 
            +
                  end
         | 
| 149 | 
            +
             | 
| 150 | 
            +
                  # Find best model for a tier and provider
         | 
| 151 | 
            +
                  # Returns [model_name, model_data] or nil
         | 
| 152 | 
            +
                  def best_model_for_tier(tier, provider_name)
         | 
| 153 | 
            +
                    validate_tier!(tier)
         | 
| 154 | 
            +
                    models = models_for_provider(provider_name)
         | 
| 155 | 
            +
             | 
| 156 | 
            +
                    # Find all models matching tier
         | 
| 157 | 
            +
                    tier_models = models.select { |_name, data| data["tier"] == tier }
         | 
| 158 | 
            +
                    return nil if tier_models.empty?
         | 
| 159 | 
            +
             | 
| 160 | 
            +
                    # Prefer newer models (higher in the list)
         | 
| 161 | 
            +
                    # Sort by cost (cheaper first) as tiebreaker
         | 
| 162 | 
            +
                    tier_models.min_by do |_name, data|
         | 
| 163 | 
            +
                      cost = data["cost_per_mtok_input"] || 0
         | 
| 164 | 
            +
                      [cost]
         | 
| 165 | 
            +
                    end
         | 
| 166 | 
            +
                  end
         | 
| 167 | 
            +
             | 
| 168 | 
            +
                  # Get tier recommendations from catalog
         | 
| 169 | 
            +
                  def tier_recommendations
         | 
| 170 | 
            +
                    catalog["tier_recommendations"] || {}
         | 
| 171 | 
            +
                  end
         | 
| 172 | 
            +
             | 
| 173 | 
            +
                  # Recommend tier based on complexity score (0.0-1.0)
         | 
| 174 | 
            +
                  def recommend_tier_for_complexity(complexity_score)
         | 
| 175 | 
            +
                    return "mini" if complexity_score <= 0.0
         | 
| 176 | 
            +
             | 
| 177 | 
            +
                    recommendations = tier_recommendations.sort_by do |_name, data|
         | 
| 178 | 
            +
                      data["complexity_threshold"] || 0.0
         | 
| 179 | 
            +
                    end
         | 
| 180 | 
            +
             | 
| 181 | 
            +
                    # Find first recommendation where complexity exceeds threshold
         | 
| 182 | 
            +
                    recommendation = recommendations.find do |_name, data|
         | 
| 183 | 
            +
                      complexity_score <= (data["complexity_threshold"] || 0.0)
         | 
| 184 | 
            +
                    end
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                    recommendation ? recommendation[1]["recommended_tier"] : "max"
         | 
| 187 | 
            +
                  end
         | 
| 188 | 
            +
             | 
| 189 | 
            +
                  # Reload catalog from disk
         | 
| 190 | 
            +
                  def reload
         | 
| 191 | 
            +
                    @catalog_data = nil
         | 
| 192 | 
            +
                    @loaded_at = nil
         | 
| 193 | 
            +
                    load_catalog
         | 
| 194 | 
            +
                  end
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                  # Check if catalog needs reload (based on file modification time)
         | 
| 197 | 
            +
                  def stale?(max_age_seconds = 3600)
         | 
| 198 | 
            +
                    return true unless @loaded_at
         | 
| 199 | 
            +
                    return true unless File.exist?(@catalog_path)
         | 
| 200 | 
            +
             | 
| 201 | 
            +
                    file_mtime = File.mtime(@catalog_path)
         | 
| 202 | 
            +
                    file_mtime > @loaded_at || (Time.now - @loaded_at) > max_age_seconds
         | 
| 203 | 
            +
                  end
         | 
| 204 | 
            +
             | 
| 205 | 
            +
                  # Export catalog as structured data for display
         | 
| 206 | 
            +
                  def export_for_display
         | 
| 207 | 
            +
                    {
         | 
| 208 | 
            +
                      schema_version: catalog["schema_version"],
         | 
| 209 | 
            +
                      providers: provider_names.map do |provider_name|
         | 
| 210 | 
            +
                        {
         | 
| 211 | 
            +
                          name: provider_name,
         | 
| 212 | 
            +
                          display_name: provider_display_name(provider_name),
         | 
| 213 | 
            +
                          tiers: supported_tiers(provider_name),
         | 
| 214 | 
            +
                          models: models_for_provider(provider_name)
         | 
| 215 | 
            +
                        }
         | 
| 216 | 
            +
                      end,
         | 
| 217 | 
            +
                      tier_order: VALID_TIERS
         | 
| 218 | 
            +
                    }
         | 
| 219 | 
            +
                  end
         | 
| 220 | 
            +
             | 
| 221 | 
            +
                  private
         | 
| 222 | 
            +
             | 
| 223 | 
            +
                  def default_catalog_path
         | 
| 224 | 
            +
                    File.join(@root_dir, ".aidp", "models_catalog.yml")
         | 
| 225 | 
            +
                  end
         | 
| 226 | 
            +
             | 
| 227 | 
            +
                  def default_empty_catalog
         | 
| 228 | 
            +
                    {
         | 
| 229 | 
            +
                      "schema_version" => "1.0",
         | 
| 230 | 
            +
                      "providers" => {},
         | 
| 231 | 
            +
                      "tier_order" => VALID_TIERS,
         | 
| 232 | 
            +
                      "tier_recommendations" => {}
         | 
| 233 | 
            +
                    }
         | 
| 234 | 
            +
                  end
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                  def validate_catalog(data)
         | 
| 237 | 
            +
                    unless data.is_a?(Hash)
         | 
| 238 | 
            +
                      raise ArgumentError, "Catalog must be a hash"
         | 
| 239 | 
            +
                    end
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                    unless data["providers"].is_a?(Hash)
         | 
| 242 | 
            +
                      raise ArgumentError, "Catalog must have 'providers' hash"
         | 
| 243 | 
            +
                    end
         | 
| 244 | 
            +
             | 
| 245 | 
            +
                    # Validate each provider has models
         | 
| 246 | 
            +
                    data["providers"].each do |provider_name, provider_data|
         | 
| 247 | 
            +
                      unless provider_data.is_a?(Hash) && provider_data["models"].is_a?(Hash)
         | 
| 248 | 
            +
                        raise ArgumentError, "Provider #{provider_name} must have 'models' hash"
         | 
| 249 | 
            +
                      end
         | 
| 250 | 
            +
             | 
| 251 | 
            +
                      # Validate each model has required fields
         | 
| 252 | 
            +
                      provider_data["models"].each do |model_name, model_data|
         | 
| 253 | 
            +
                        unless model_data["tier"]
         | 
| 254 | 
            +
                          raise ArgumentError, "Model #{provider_name}/#{model_name} missing 'tier'"
         | 
| 255 | 
            +
                        end
         | 
| 256 | 
            +
             | 
| 257 | 
            +
                        unless valid_tier?(model_data["tier"])
         | 
| 258 | 
            +
                          raise ArgumentError, "Model #{provider_name}/#{model_name} has invalid tier: #{model_data["tier"]}"
         | 
| 259 | 
            +
                        end
         | 
| 260 | 
            +
                      end
         | 
| 261 | 
            +
                    end
         | 
| 262 | 
            +
             | 
| 263 | 
            +
                    Aidp.log_debug("capability_registry", "Catalog validation passed", providers: data["providers"].size)
         | 
| 264 | 
            +
                  end
         | 
| 265 | 
            +
             | 
| 266 | 
            +
                  def validate_tier!(tier)
         | 
| 267 | 
            +
                    unless valid_tier?(tier)
         | 
| 268 | 
            +
                      raise ArgumentError, "Invalid tier: #{tier}. Must be one of: #{VALID_TIERS.join(", ")}"
         | 
| 269 | 
            +
                    end
         | 
| 270 | 
            +
                  end
         | 
| 271 | 
            +
                end
         | 
| 272 | 
            +
              end
         | 
| 273 | 
            +
            end
         |