aidp 0.28.0 → 0.29.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.
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "parser"
5
+
6
+ module Aidp
7
+ module Metadata
8
+ # Scans directories for tool files and extracts metadata
9
+ #
10
+ # Recursively finds all .md files in configured directories,
11
+ # parses their metadata, and returns a collection of ToolMetadata objects.
12
+ #
13
+ # @example Scanning directories
14
+ # scanner = Scanner.new([".aidp/skills", ".aidp/templates"])
15
+ # tools = scanner.scan_all
16
+ class Scanner
17
+ # Initialize scanner with directory paths
18
+ #
19
+ # @param directories [Array<String>] Directories to scan
20
+ def initialize(directories = [])
21
+ @directories = Array(directories)
22
+ end
23
+
24
+ # Scan all configured directories
25
+ #
26
+ # @return [Array<ToolMetadata>] All discovered tool metadata
27
+ def scan_all
28
+ Aidp.log_debug("metadata", "Scanning directories", directories: @directories)
29
+
30
+ all_tools = []
31
+ @directories.each do |dir|
32
+ tools = scan_directory(dir)
33
+ all_tools.concat(tools)
34
+ end
35
+
36
+ Aidp.log_info(
37
+ "metadata",
38
+ "Scan complete",
39
+ directories: @directories.size,
40
+ tools_found: all_tools.size
41
+ )
42
+
43
+ all_tools
44
+ end
45
+
46
+ # Scan a single directory
47
+ #
48
+ # @param directory [String] Directory path to scan
49
+ # @param type [String, nil] Tool type filter or nil for all
50
+ # @return [Array<ToolMetadata>] Discovered tool metadata
51
+ def scan_directory(directory, type: nil)
52
+ unless Dir.exist?(directory)
53
+ Aidp.log_warn("metadata", "Directory not found", directory: directory)
54
+ return []
55
+ end
56
+
57
+ Aidp.log_debug("metadata", "Scanning directory", directory: directory, type: type)
58
+
59
+ tools = []
60
+ md_files = find_markdown_files(directory)
61
+
62
+ md_files.each do |file_path|
63
+ tool = Parser.parse_file(file_path, type: type)
64
+ tools << tool if type.nil? || tool.type == type
65
+ rescue Aidp::Errors::ValidationError => e
66
+ Aidp.log_warn(
67
+ "metadata",
68
+ "Failed to parse file",
69
+ file: file_path,
70
+ error: e.message
71
+ )
72
+ end
73
+
74
+ Aidp.log_debug(
75
+ "metadata",
76
+ "Directory scan complete",
77
+ directory: directory,
78
+ files_found: md_files.size,
79
+ tools_parsed: tools.size
80
+ )
81
+
82
+ tools
83
+ end
84
+
85
+ # Find all markdown files in directory recursively
86
+ #
87
+ # @param directory [String] Directory path
88
+ # @return [Array<String>] Paths to .md files
89
+ def find_markdown_files(directory)
90
+ pattern = File.join(directory, "**", "*.md")
91
+ files = Dir.glob(pattern)
92
+
93
+ Aidp.log_debug(
94
+ "metadata",
95
+ "Found markdown files",
96
+ directory: directory,
97
+ count: files.size
98
+ )
99
+
100
+ files
101
+ end
102
+
103
+ # Scan with file filtering
104
+ #
105
+ # @param directory [String] Directory path
106
+ # @param filter [Proc] Filter proc that receives file_path and returns boolean
107
+ # @return [Array<ToolMetadata>] Filtered tool metadata
108
+ def scan_with_filter(directory, &filter)
109
+ unless Dir.exist?(directory)
110
+ Aidp.log_warn("metadata", "Directory not found", directory: directory)
111
+ return []
112
+ end
113
+
114
+ tools = []
115
+ md_files = find_markdown_files(directory)
116
+
117
+ md_files.each do |file_path|
118
+ next unless filter.call(file_path)
119
+
120
+ begin
121
+ tool = Parser.parse_file(file_path)
122
+ tools << tool
123
+ rescue Aidp::Errors::ValidationError => e
124
+ Aidp.log_warn(
125
+ "metadata",
126
+ "Failed to parse file",
127
+ file: file_path,
128
+ error: e.message
129
+ )
130
+ end
131
+ end
132
+
133
+ tools
134
+ end
135
+
136
+ # Scan for changes since last scan
137
+ #
138
+ # Compares file hashes to detect changes
139
+ #
140
+ # @param directory [String] Directory path
141
+ # @param previous_hashes [Hash<String, String>] Map of file_path => file_hash
142
+ # @return [Hash] Hash with :added, :modified, :removed keys
143
+ def scan_changes(directory, previous_hashes = {})
144
+ Aidp.log_debug("metadata", "Scanning for changes", directory: directory)
145
+
146
+ current_files = find_markdown_files(directory)
147
+ current_hashes = {}
148
+
149
+ changes = {
150
+ added: [],
151
+ modified: [],
152
+ removed: [],
153
+ unchanged: []
154
+ }
155
+
156
+ # Check for added and modified files
157
+ current_files.each do |file_path|
158
+ content = File.read(file_path, encoding: "UTF-8")
159
+ file_hash = Parser.compute_file_hash(content)
160
+ current_hashes[file_path] = file_hash
161
+
162
+ if previous_hashes.key?(file_path)
163
+ if previous_hashes[file_path] != file_hash
164
+ changes[:modified] << file_path
165
+ else
166
+ changes[:unchanged] << file_path
167
+ end
168
+ else
169
+ changes[:added] << file_path
170
+ end
171
+ end
172
+
173
+ # Check for removed files
174
+ previous_hashes.keys.each do |file_path|
175
+ changes[:removed] << file_path unless current_hashes.key?(file_path)
176
+ end
177
+
178
+ Aidp.log_info(
179
+ "metadata",
180
+ "Change detection complete",
181
+ added: changes[:added].size,
182
+ modified: changes[:modified].size,
183
+ removed: changes[:removed].size,
184
+ unchanged: changes[:unchanged].size
185
+ )
186
+
187
+ changes
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Aidp
6
+ module Metadata
7
+ # Base class for tool metadata (skills, personas, templates)
8
+ #
9
+ # Represents metadata extracted from YAML frontmatter in .md files.
10
+ # Provides validation and query capabilities for the tool directory.
11
+ #
12
+ # @example Creating tool metadata
13
+ # metadata = ToolMetadata.new(
14
+ # type: "skill",
15
+ # id: "ruby_rspec_tdd",
16
+ # title: "Ruby RSpec TDD Implementer",
17
+ # summary: "Expert in Test-Driven Development",
18
+ # version: "1.0.0",
19
+ # applies_to: ["ruby", "testing"],
20
+ # work_unit_types: ["implementation", "testing"],
21
+ # priority: 10,
22
+ # content: "You are a TDD expert...",
23
+ # source_path: "/path/to/SKILL.md"
24
+ # )
25
+ class ToolMetadata
26
+ attr_reader :type, :id, :title, :summary, :version,
27
+ :applies_to, :work_unit_types, :priority,
28
+ :capabilities, :dependencies, :experimental,
29
+ :content, :source_path, :file_hash
30
+
31
+ # Valid tool types
32
+ VALID_TYPES = %w[skill persona template].freeze
33
+
34
+ # Default priority (medium)
35
+ DEFAULT_PRIORITY = 5
36
+
37
+ # Initialize new tool metadata
38
+ #
39
+ # @param type [String] Tool type: "skill", "persona", or "template"
40
+ # @param id [String] Unique identifier (lowercase, alphanumeric, underscores)
41
+ # @param title [String] Human-readable title
42
+ # @param summary [String] Brief one-line summary
43
+ # @param version [String] Semantic version (e.g., "1.0.0")
44
+ # @param applies_to [Array<String>] Tags indicating applicability
45
+ # @param work_unit_types [Array<String>] Work unit types this tool supports
46
+ # @param priority [Integer] Priority for ranking (1-10, default 5)
47
+ # @param capabilities [Array<String>] Capabilities provided by this tool
48
+ # @param dependencies [Array<String>] IDs of required tools
49
+ # @param experimental [Boolean] Whether this is experimental
50
+ # @param content [String] The tool content (markdown)
51
+ # @param source_path [String] Path to source .md file
52
+ # @param file_hash [String] SHA256 hash of source file for cache invalidation
53
+ def initialize(
54
+ type:,
55
+ id:,
56
+ title:,
57
+ summary:,
58
+ version:,
59
+ content:,
60
+ source_path:,
61
+ file_hash:,
62
+ applies_to: [],
63
+ work_unit_types: [],
64
+ priority: DEFAULT_PRIORITY,
65
+ capabilities: [],
66
+ dependencies: [],
67
+ experimental: false
68
+ )
69
+ @type = type
70
+ @id = id
71
+ @title = title
72
+ @summary = summary
73
+ @version = version
74
+ @applies_to = Array(applies_to)
75
+ @work_unit_types = Array(work_unit_types)
76
+ @priority = priority
77
+ @capabilities = Array(capabilities)
78
+ @dependencies = Array(dependencies)
79
+ @experimental = experimental
80
+ @content = content
81
+ @source_path = source_path
82
+ @file_hash = file_hash
83
+
84
+ validate!
85
+ end
86
+
87
+ # Check if this tool applies to given tags
88
+ #
89
+ # @param tags [Array<String>] Tags to check against
90
+ # @return [Boolean] True if any tag matches applies_to
91
+ def applies_to?(tags)
92
+ return true if applies_to.empty?
93
+ tags = Array(tags).map(&:downcase)
94
+ applies_to.map(&:downcase).any? { |tag| tags.include?(tag) }
95
+ end
96
+
97
+ # Check if this tool supports a given work unit type
98
+ #
99
+ # @param type [String] Work unit type (e.g., "implementation", "analysis")
100
+ # @return [Boolean] True if type is supported
101
+ def supports_work_unit?(type)
102
+ return true if work_unit_types.empty?
103
+ work_unit_types.map(&:downcase).include?(type.to_s.downcase)
104
+ end
105
+
106
+ # Get a summary of this tool for display
107
+ #
108
+ # @return [Hash] Summary with key metadata
109
+ def summary_hash
110
+ {
111
+ type: type,
112
+ id: id,
113
+ title: title,
114
+ summary: summary,
115
+ version: version,
116
+ priority: priority,
117
+ tags: applies_to,
118
+ experimental: experimental
119
+ }
120
+ end
121
+
122
+ # Get full details of this tool for display
123
+ #
124
+ # @return [Hash] Detailed tool information
125
+ def details
126
+ {
127
+ type: type,
128
+ id: id,
129
+ title: title,
130
+ summary: summary,
131
+ version: version,
132
+ applies_to: applies_to,
133
+ work_unit_types: work_unit_types,
134
+ priority: priority,
135
+ capabilities: capabilities,
136
+ dependencies: dependencies,
137
+ experimental: experimental,
138
+ source: source_path,
139
+ file_hash: file_hash,
140
+ content_length: content.length
141
+ }
142
+ end
143
+
144
+ # Convert to hash for serialization
145
+ #
146
+ # @return [Hash] Metadata as hash (for JSON)
147
+ def to_h
148
+ {
149
+ type: type,
150
+ id: id,
151
+ title: title,
152
+ summary: summary,
153
+ version: version,
154
+ applies_to: applies_to,
155
+ work_unit_types: work_unit_types,
156
+ priority: priority,
157
+ capabilities: capabilities,
158
+ dependencies: dependencies,
159
+ experimental: experimental,
160
+ source_path: source_path,
161
+ file_hash: file_hash
162
+ }
163
+ end
164
+
165
+ # Return string representation
166
+ #
167
+ # @return [String] Tool representation
168
+ def to_s
169
+ "#{type.capitalize}[#{id}](#{title} v#{version})"
170
+ end
171
+
172
+ # Return inspection string
173
+ #
174
+ # @return [String] Detailed inspection
175
+ def inspect
176
+ "#<Aidp::Metadata::ToolMetadata type=#{type} id=#{id} title=\"#{title}\" " \
177
+ "version=#{version} source=#{source_path}>"
178
+ end
179
+
180
+ private
181
+
182
+ # Validate required fields and types
183
+ #
184
+ # @raise [Aidp::Errors::ValidationError] if validation fails
185
+ def validate!
186
+ validate_required_fields!
187
+ validate_types!
188
+ validate_formats!
189
+ validate_ranges!
190
+ end
191
+
192
+ # Validate required fields are present
193
+ def validate_required_fields!
194
+ raise Aidp::Errors::ValidationError, "type is required" if type.nil? || type.strip.empty?
195
+ raise Aidp::Errors::ValidationError, "id is required" if id.nil? || id.strip.empty?
196
+ raise Aidp::Errors::ValidationError, "title is required" if title.nil? || title.strip.empty?
197
+ raise Aidp::Errors::ValidationError, "summary is required" if summary.nil? || summary.strip.empty?
198
+ raise Aidp::Errors::ValidationError, "version is required" if version.nil? || version.strip.empty?
199
+ raise Aidp::Errors::ValidationError, "content is required" if content.nil? || content.strip.empty?
200
+ raise Aidp::Errors::ValidationError, "source_path is required" if source_path.nil? || source_path.strip.empty?
201
+ raise Aidp::Errors::ValidationError, "file_hash is required" if file_hash.nil? || file_hash.strip.empty?
202
+ end
203
+
204
+ # Validate field types
205
+ def validate_types!
206
+ unless VALID_TYPES.include?(type)
207
+ raise Aidp::Errors::ValidationError, "type must be one of: #{VALID_TYPES.join(", ")}"
208
+ end
209
+
210
+ unless priority.is_a?(Integer)
211
+ raise Aidp::Errors::ValidationError, "priority must be an integer"
212
+ end
213
+
214
+ unless [true, false].include?(experimental)
215
+ raise Aidp::Errors::ValidationError, "experimental must be boolean"
216
+ end
217
+ end
218
+
219
+ # Validate field formats
220
+ def validate_formats!
221
+ # Validate version format (simple semantic version check)
222
+ unless version.match?(/^\d+\.\d+\.\d+/)
223
+ raise Aidp::Errors::ValidationError, "version must be in format X.Y.Z (e.g., 1.0.0)"
224
+ end
225
+
226
+ # Validate id format (lowercase, alphanumeric, underscores only)
227
+ unless id.match?(/^[a-z0-9_]+$/)
228
+ raise Aidp::Errors::ValidationError, "id must be lowercase alphanumeric with underscores only"
229
+ end
230
+
231
+ # Validate file_hash format (SHA256 hex)
232
+ unless file_hash.match?(/^[a-f0-9]{64}$/)
233
+ raise Aidp::Errors::ValidationError, "file_hash must be a valid SHA256 hex string"
234
+ end
235
+ end
236
+
237
+ # Validate value ranges
238
+ def validate_ranges!
239
+ unless priority.between?(1, 10)
240
+ raise Aidp::Errors::ValidationError, "priority must be between 1 and 10"
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Aidp
6
+ module Metadata
7
+ # Validates tool metadata and detects issues
8
+ #
9
+ # Performs validation checks on tool metadata including:
10
+ # - Required field presence
11
+ # - Field type validation
12
+ # - Duplicate ID detection
13
+ # - Dependency resolution
14
+ # - Version format validation
15
+ #
16
+ # @example Validating a collection of tools
17
+ # validator = Validator.new(tools)
18
+ # results = validator.validate_all
19
+ # results.each { |r| puts "#{r[:file]}: #{r[:errors].join(", ")}" }
20
+ class Validator
21
+ # Validation result structure
22
+ ValidationResult = Struct.new(:tool_id, :file_path, :valid, :errors, :warnings, keyword_init: true)
23
+
24
+ # Initialize validator with tool metadata collection
25
+ #
26
+ # @param tools [Array<ToolMetadata>] Tools to validate
27
+ def initialize(tools = [])
28
+ @tools = tools
29
+ @errors_by_id = {}
30
+ @warnings_by_id = {}
31
+ end
32
+
33
+ # Validate all tools
34
+ #
35
+ # @return [Array<ValidationResult>] Validation results for each tool
36
+ def validate_all
37
+ Aidp.log_debug("metadata", "Validating all tools", count: @tools.size)
38
+
39
+ results = @tools.map do |tool|
40
+ validate_tool(tool)
41
+ end
42
+
43
+ # Cross-tool validations
44
+ validate_duplicate_ids(results)
45
+ validate_dependencies(results)
46
+
47
+ Aidp.log_info(
48
+ "metadata",
49
+ "Validation complete",
50
+ total: results.size,
51
+ valid: results.count(&:valid),
52
+ invalid: results.count { |r| !r.valid }
53
+ )
54
+
55
+ results
56
+ end
57
+
58
+ # Validate a single tool
59
+ #
60
+ # @param tool [ToolMetadata] Tool to validate
61
+ # @return [ValidationResult] Validation result
62
+ def validate_tool(tool)
63
+ errors = []
64
+ warnings = []
65
+
66
+ # Tool metadata validates itself on initialization
67
+ # Here we add additional cross-cutting validations
68
+
69
+ # Check for empty arrays in key fields
70
+ if tool.applies_to.empty? && tool.work_unit_types.empty?
71
+ warnings << "No applies_to tags or work_unit_types specified (tool may not be discoverable)"
72
+ end
73
+
74
+ # Check for deprecated fields or patterns
75
+ validate_deprecated_patterns(tool, warnings)
76
+
77
+ # Check for experimental tools
78
+ if tool.experimental
79
+ warnings << "Tool is marked as experimental"
80
+ end
81
+
82
+ # Check content length
83
+ if tool.content.length < 100
84
+ warnings << "Tool content is very short (#{tool.content.length} characters)"
85
+ end
86
+
87
+ ValidationResult.new(
88
+ tool_id: tool.id,
89
+ file_path: tool.source_path,
90
+ valid: errors.empty?,
91
+ errors: errors,
92
+ warnings: warnings
93
+ )
94
+ rescue Aidp::Errors::ValidationError => e
95
+ # Catch validation errors from tool initialization
96
+ ValidationResult.new(
97
+ tool_id: tool&.id || "unknown",
98
+ file_path: tool&.source_path || "unknown",
99
+ valid: false,
100
+ errors: [e.message],
101
+ warnings: []
102
+ )
103
+ end
104
+
105
+ # Validate for duplicate IDs across tools
106
+ #
107
+ # @param results [Array<ValidationResult>] Validation results
108
+ def validate_duplicate_ids(results)
109
+ ids = @tools.map(&:id)
110
+ duplicates = ids.tally.select { |_, count| count > 1 }.keys
111
+
112
+ return if duplicates.empty?
113
+
114
+ duplicates.each do |dup_id|
115
+ matching_tools = @tools.select { |t| t.id == dup_id }
116
+ matching_tools.each do |tool|
117
+ result = results.find { |r| r.tool_id == tool.id && r.file_path == tool.source_path }
118
+ next unless result
119
+
120
+ paths = matching_tools.map(&:source_path).join(", ")
121
+ result.errors << "Duplicate ID '#{dup_id}' found in: #{paths}"
122
+ result.valid = false
123
+ end
124
+ end
125
+
126
+ Aidp.log_warn("metadata", "Duplicate IDs detected", duplicates: duplicates)
127
+ end
128
+
129
+ # Validate tool dependencies are satisfied
130
+ #
131
+ # @param results [Array<ValidationResult>] Validation results
132
+ def validate_dependencies(results)
133
+ available_ids = @tools.map(&:id).to_set
134
+
135
+ @tools.each do |tool|
136
+ next if tool.dependencies.empty?
137
+
138
+ tool.dependencies.each do |dep_id|
139
+ next if available_ids.include?(dep_id)
140
+
141
+ result = results.find { |r| r.tool_id == tool.id && r.file_path == tool.source_path }
142
+ next unless result
143
+
144
+ result.errors << "Missing dependency: '#{dep_id}'"
145
+ result.valid = false
146
+ end
147
+ end
148
+ end
149
+
150
+ # Check for deprecated patterns in tool metadata
151
+ #
152
+ # @param tool [ToolMetadata] Tool to check
153
+ # @param warnings [Array<String>] Warnings array to append to
154
+ def validate_deprecated_patterns(tool, warnings)
155
+ # Check for legacy field usage (this would be expanded based on actual deprecations)
156
+ # For now, this is a placeholder for future deprecation warnings
157
+ end
158
+
159
+ # Write validation errors to log file
160
+ #
161
+ # @param results [Array<ValidationResult>] Validation results
162
+ # @param log_path [String] Path to error log file
163
+ def write_error_log(results, log_path)
164
+ Aidp.log_debug("metadata", "Writing error log", path: log_path)
165
+
166
+ errors = results.reject(&:valid)
167
+ return if errors.empty?
168
+
169
+ File.open(log_path, "w") do |f|
170
+ f.puts "# Metadata Validation Errors"
171
+ f.puts "# Generated: #{Time.now.iso8601}"
172
+ f.puts
173
+
174
+ errors.each do |result|
175
+ f.puts "## #{result.tool_id} (#{result.file_path})"
176
+ f.puts
177
+ result.errors.each { |err| f.puts "- ERROR: #{err}" }
178
+ result.warnings.each { |warn| f.puts "- WARNING: #{warn}" }
179
+ f.puts
180
+ end
181
+ end
182
+
183
+ Aidp.log_info("metadata", "Wrote error log", path: log_path, error_count: errors.size)
184
+ end
185
+ end
186
+ end
187
+ end
@@ -75,7 +75,7 @@ module Aidp
75
75
  end
76
76
  end
77
77
 
78
- def send_message(prompt:, session: nil)
78
+ def send_message(prompt:, session: nil, options: {})
79
79
  raise "aider CLI not available" unless self.class.available?
80
80
 
81
81
  # Smart timeout calculation (store prompt length for adaptive logic)
@@ -9,6 +9,8 @@ module Aidp
9
9
  class Anthropic < Base
10
10
  include Aidp::DebugMixin
11
11
 
12
+ attr_reader :model
13
+
12
14
  # Model name pattern for Anthropic Claude models
13
15
  MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
14
16
 
@@ -250,13 +252,13 @@ module Aidp
250
252
  }
251
253
  end
252
254
 
253
- def send_message(prompt:, session: nil)
255
+ def send_message(prompt:, session: nil, options: {})
254
256
  raise "claude CLI not available" unless self.class.available?
255
257
 
256
- # Smart timeout calculation
257
- timeout_seconds = calculate_timeout
258
+ # Smart timeout calculation with tier awareness
259
+ timeout_seconds = calculate_timeout(options)
258
260
 
259
- debug_provider("claude", "Starting execution", {timeout: timeout_seconds})
261
+ debug_provider("claude", "Starting execution", {timeout: timeout_seconds, tier: options[:tier]})
260
262
  debug_log("📝 Sending prompt to claude...", level: :info)
261
263
 
262
264
  # Build command arguments