aidp 0.28.0 → 0.30.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,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "tool_metadata"
5
+ require_relative "cache"
6
+
7
+ module Aidp
8
+ module Metadata
9
+ # Query interface for tool-choosing agent
10
+ #
11
+ # Provides filtering, ranking, and dependency resolution for tools
12
+ # based on metadata criteria.
13
+ #
14
+ # @example Querying for tools
15
+ # query = Query.new(cache: cache)
16
+ # tools = query.find_by_tags(["ruby", "testing"])
17
+ # ranked = query.rank_by_priority(tools)
18
+ class Query
19
+ # Initialize query interface
20
+ #
21
+ # @param cache [Cache] Metadata cache instance
22
+ def initialize(cache:)
23
+ @cache = cache
24
+ @directory = nil
25
+ end
26
+
27
+ # Load directory (lazy)
28
+ #
29
+ # @return [Hash] Tool directory
30
+ def directory
31
+ @directory ||= @cache.load
32
+ end
33
+
34
+ # Reload directory
35
+ def reload
36
+ @directory = @cache.reload
37
+ end
38
+
39
+ # Find tool by ID
40
+ #
41
+ # @param id [String] Tool ID
42
+ # @return [Hash, nil] Tool metadata or nil
43
+ def find_by_id(id)
44
+ tools = directory["tools"]
45
+ tools.find { |tool| tool["id"] == id }
46
+ end
47
+
48
+ # Find tools by type
49
+ #
50
+ # @param type [String] Tool type ("skill", "persona", "template")
51
+ # @return [Array<Hash>] Matching tools
52
+ def find_by_type(type)
53
+ indexes = directory["indexes"]["by_type"]
54
+ tool_ids = indexes[type] || []
55
+ tools_by_ids(tool_ids)
56
+ end
57
+
58
+ # Find tools by applies_to tags
59
+ #
60
+ # @param tags [Array<String>] Tags to filter by
61
+ # @param match_all [Boolean] Whether to match all tags (AND) or any tag (OR)
62
+ # @return [Array<Hash>] Matching tools
63
+ def find_by_tags(tags, match_all: false)
64
+ Aidp.log_debug("metadata", "Finding by tags", tags: tags, match_all: match_all)
65
+
66
+ tags = Array(tags).map(&:downcase)
67
+ indexes = directory["indexes"]["by_tag"]
68
+
69
+ if match_all
70
+ # Find tools that have ALL specified tags
71
+ tool_ids_sets = tags.map { |tag| indexes[tag] || [] }
72
+ tool_ids = tool_ids_sets.reduce(&:&) || []
73
+ else
74
+ # Find tools that have ANY specified tag
75
+ tool_ids = tags.flat_map { |tag| indexes[tag] || [] }.uniq
76
+ end
77
+
78
+ tools = tools_by_ids(tool_ids)
79
+
80
+ Aidp.log_debug("metadata", "Found tools by tags", count: tools.size)
81
+
82
+ tools
83
+ end
84
+
85
+ # Find tools by work unit type
86
+ #
87
+ # @param work_unit_type [String] Work unit type (e.g., "implementation", "analysis")
88
+ # @return [Array<Hash>] Matching tools
89
+ def find_by_work_unit_type(work_unit_type)
90
+ Aidp.log_debug("metadata", "Finding by work unit type", type: work_unit_type)
91
+
92
+ indexes = directory["indexes"]["by_work_unit_type"]
93
+ tool_ids = indexes[work_unit_type.downcase] || []
94
+ tools = tools_by_ids(tool_ids)
95
+
96
+ Aidp.log_debug("metadata", "Found tools by work unit type", count: tools.size)
97
+
98
+ tools
99
+ end
100
+
101
+ # Filter tools by multiple criteria
102
+ #
103
+ # @param type [String, nil] Tool type filter
104
+ # @param tags [Array<String>, nil] Tag filter
105
+ # @param work_unit_type [String, nil] Work unit type filter
106
+ # @param experimental [Boolean, nil] Experimental filter (true/false/nil for all)
107
+ # @return [Array<Hash>] Filtered tools
108
+ def filter(type: nil, tags: nil, work_unit_type: nil, experimental: nil)
109
+ Aidp.log_debug(
110
+ "metadata",
111
+ "Filtering tools",
112
+ type: type,
113
+ tags: tags,
114
+ work_unit_type: work_unit_type,
115
+ experimental: experimental
116
+ )
117
+
118
+ tools = directory["tools"]
119
+
120
+ # Filter by type
121
+ tools = tools.select { |t| t["type"] == type } if type
122
+
123
+ # Filter by tags
124
+ if tags && !tags.empty?
125
+ tags = Array(tags).map(&:downcase)
126
+ tools = tools.select do |t|
127
+ tool_tags = (t["applies_to"] || []).map(&:downcase)
128
+ (tool_tags & tags).any?
129
+ end
130
+ end
131
+
132
+ # Filter by work unit type
133
+ if work_unit_type
134
+ wut_lower = work_unit_type.downcase
135
+ tools = tools.select do |t|
136
+ (t["work_unit_types"] || []).map(&:downcase).include?(wut_lower)
137
+ end
138
+ end
139
+
140
+ # Filter by experimental flag
141
+ tools = tools.select { |t| t["experimental"] == experimental } unless experimental.nil?
142
+
143
+ Aidp.log_debug("metadata", "Filtered tools", count: tools.size)
144
+
145
+ tools
146
+ end
147
+
148
+ # Rank tools by priority (highest first)
149
+ #
150
+ # @param tools [Array<Hash>] Tools to rank
151
+ # @return [Array<Hash>] Ranked tools
152
+ def rank_by_priority(tools)
153
+ tools.sort_by { |t| -(t["priority"] || ToolMetadata::DEFAULT_PRIORITY) }
154
+ end
155
+
156
+ # Resolve dependencies for a tool
157
+ #
158
+ # Returns all dependencies recursively in dependency order (topological sort)
159
+ #
160
+ # @param tool_id [String] Tool ID
161
+ # @return [Array<String>] Ordered list of dependency IDs
162
+ # @raise [Aidp::Errors::ValidationError] if circular dependency detected
163
+ def resolve_dependencies(tool_id)
164
+ Aidp.log_debug("metadata", "Resolving dependencies", tool_id: tool_id)
165
+
166
+ graph = directory["dependency_graph"]
167
+ resolved = []
168
+ seen = Set.new
169
+
170
+ resolve_recursive(tool_id, graph, resolved, seen)
171
+
172
+ Aidp.log_debug("metadata", "Dependencies resolved", tool_id: tool_id, dependencies: resolved)
173
+
174
+ resolved
175
+ end
176
+
177
+ # Find dependents of a tool
178
+ #
179
+ # Returns all tools that depend on this tool (directly or indirectly)
180
+ #
181
+ # @param tool_id [String] Tool ID
182
+ # @return [Array<String>] List of dependent tool IDs
183
+ def find_dependents(tool_id)
184
+ graph = directory["dependency_graph"]
185
+ return [] unless graph[tool_id]
186
+
187
+ graph[tool_id]["dependents"] || []
188
+ end
189
+
190
+ # Get statistics about the tool directory
191
+ #
192
+ # @return [Hash] Statistics
193
+ def statistics
194
+ directory["statistics"]
195
+ end
196
+
197
+ private
198
+
199
+ # Get tools by IDs
200
+ #
201
+ # @param tool_ids [Array<String>] Tool IDs
202
+ # @return [Array<Hash>] Tools
203
+ def tools_by_ids(tool_ids)
204
+ all_tools = directory["tools"]
205
+ tool_ids.map { |id| all_tools.find { |t| t["id"] == id } }.compact
206
+ end
207
+
208
+ # Resolve dependencies recursively (topological sort)
209
+ #
210
+ # @param tool_id [String] Tool ID
211
+ # @param graph [Hash] Dependency graph
212
+ # @param resolved [Array<String>] Resolved dependencies (output)
213
+ # @param seen [Set<String>] Seen tools (for cycle detection)
214
+ # @raise [Aidp::Errors::ValidationError] if circular dependency detected
215
+ def resolve_recursive(tool_id, graph, resolved, seen)
216
+ return if resolved.include?(tool_id)
217
+
218
+ if seen.include?(tool_id)
219
+ raise Aidp::Errors::ValidationError, "Circular dependency detected: #{tool_id}"
220
+ end
221
+
222
+ seen.add(tool_id)
223
+
224
+ node = graph[tool_id]
225
+ if node
226
+ dependencies = node["dependencies"] || []
227
+ dependencies.each do |dep_id|
228
+ resolve_recursive(dep_id, graph, resolved, seen)
229
+ end
230
+ end
231
+
232
+ resolved << tool_id unless resolved.include?(tool_id)
233
+ seen.delete(tool_id)
234
+ end
235
+ end
236
+ end
237
+ end
@@ -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