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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -0
  3. data/lib/aidp/cli.rb +43 -2
  4. data/lib/aidp/config.rb +9 -14
  5. data/lib/aidp/execute/prompt_manager.rb +128 -1
  6. data/lib/aidp/execute/repl_macros.rb +555 -0
  7. data/lib/aidp/execute/work_loop_runner.rb +108 -1
  8. data/lib/aidp/harness/ai_decision_engine.rb +376 -0
  9. data/lib/aidp/harness/capability_registry.rb +273 -0
  10. data/lib/aidp/harness/config_schema.rb +305 -1
  11. data/lib/aidp/harness/configuration.rb +452 -0
  12. data/lib/aidp/harness/enhanced_runner.rb +7 -1
  13. data/lib/aidp/harness/provider_factory.rb +0 -2
  14. data/lib/aidp/harness/runner.rb +7 -1
  15. data/lib/aidp/harness/thinking_depth_manager.rb +335 -0
  16. data/lib/aidp/harness/zfc_condition_detector.rb +395 -0
  17. data/lib/aidp/init/devcontainer_generator.rb +274 -0
  18. data/lib/aidp/init/runner.rb +37 -10
  19. data/lib/aidp/init.rb +1 -0
  20. data/lib/aidp/prompt_optimization/context_composer.rb +286 -0
  21. data/lib/aidp/prompt_optimization/optimizer.rb +335 -0
  22. data/lib/aidp/prompt_optimization/prompt_builder.rb +309 -0
  23. data/lib/aidp/prompt_optimization/relevance_scorer.rb +256 -0
  24. data/lib/aidp/prompt_optimization/source_code_fragmenter.rb +308 -0
  25. data/lib/aidp/prompt_optimization/style_guide_indexer.rb +240 -0
  26. data/lib/aidp/prompt_optimization/template_indexer.rb +250 -0
  27. data/lib/aidp/provider_manager.rb +0 -2
  28. data/lib/aidp/providers/anthropic.rb +19 -0
  29. data/lib/aidp/setup/wizard.rb +299 -4
  30. data/lib/aidp/utils/devcontainer_detector.rb +166 -0
  31. data/lib/aidp/version.rb +1 -1
  32. data/lib/aidp/watch/build_processor.rb +72 -6
  33. data/lib/aidp/watch/repository_client.rb +2 -1
  34. data/lib/aidp.rb +0 -1
  35. data/templates/aidp.yml.example +128 -0
  36. metadata +14 -2
  37. 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