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,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../errors"
5
+ require_relative "scanner"
6
+ require_relative "validator"
7
+
8
+ module Aidp
9
+ module Metadata
10
+ # Compiles tool metadata into a cached directory structure
11
+ #
12
+ # Aggregates metadata from all tool files, builds indexes, resolves dependencies,
13
+ # and generates a cached tool_directory.json for fast lookups.
14
+ #
15
+ # @example Compiling the tool directory
16
+ # compiler = Compiler.new(directories: [".aidp/skills", ".aidp/templates"])
17
+ # compiler.compile(output_path: ".aidp/cache/tool_directory.json")
18
+ class Compiler
19
+ # Compiled directory structure
20
+ attr_reader :tools, :indexes, :dependency_graph
21
+
22
+ # Initialize compiler
23
+ #
24
+ # @param directories [Array<String>] Directories to scan
25
+ # @param strict [Boolean] Whether to fail on validation errors
26
+ def initialize(directories: [], strict: false)
27
+ @directories = Array(directories)
28
+ @strict = strict
29
+ @tools = []
30
+ @indexes = {}
31
+ @dependency_graph = {}
32
+ end
33
+
34
+ # Compile tool directory
35
+ #
36
+ # @param output_path [String] Path to output JSON file
37
+ # @return [Hash] Compiled directory structure
38
+ def compile(output_path:)
39
+ Aidp.log_info("metadata", "Compiling tool directory", directories: @directories, output: output_path)
40
+
41
+ # Scan all directories
42
+ scanner = Scanner.new(@directories)
43
+ @tools = scanner.scan_all
44
+
45
+ # Validate tools
46
+ validator = Validator.new(@tools)
47
+ validation_results = validator.validate_all
48
+
49
+ # Handle validation failures
50
+ handle_validation_results(validation_results)
51
+
52
+ # Build indexes and graphs
53
+ build_indexes
54
+ build_dependency_graph
55
+
56
+ # Create directory structure
57
+ directory = create_directory_structure
58
+
59
+ # Write to file
60
+ write_directory(directory, output_path)
61
+
62
+ Aidp.log_info(
63
+ "metadata",
64
+ "Compilation complete",
65
+ tools: @tools.size,
66
+ output: output_path
67
+ )
68
+
69
+ directory
70
+ end
71
+
72
+ # Build indexes for fast lookups
73
+ def build_indexes
74
+ Aidp.log_debug("metadata", "Building indexes")
75
+
76
+ @indexes = {
77
+ by_id: {},
78
+ by_type: {},
79
+ by_tag: {},
80
+ by_work_unit_type: {}
81
+ }
82
+
83
+ @tools.each do |tool|
84
+ # Index by ID
85
+ @indexes[:by_id][tool.id] = tool
86
+
87
+ # Index by type
88
+ @indexes[:by_type][tool.type] ||= []
89
+ @indexes[:by_type][tool.type] << tool
90
+
91
+ # Index by tags
92
+ tool.applies_to.each do |tag|
93
+ @indexes[:by_tag][tag] ||= []
94
+ @indexes[:by_tag][tag] << tool
95
+ end
96
+
97
+ # Index by work unit types
98
+ tool.work_unit_types.each do |wut|
99
+ @indexes[:by_work_unit_type][wut] ||= []
100
+ @indexes[:by_work_unit_type][wut] << tool
101
+ end
102
+ end
103
+
104
+ Aidp.log_debug(
105
+ "metadata",
106
+ "Indexes built",
107
+ types: @indexes[:by_type].keys,
108
+ tags: @indexes[:by_tag].keys.size,
109
+ work_unit_types: @indexes[:by_work_unit_type].keys
110
+ )
111
+ end
112
+
113
+ # Build dependency graph
114
+ def build_dependency_graph
115
+ Aidp.log_debug("metadata", "Building dependency graph")
116
+
117
+ @dependency_graph = {}
118
+
119
+ @tools.each do |tool|
120
+ @dependency_graph[tool.id] = {
121
+ dependencies: tool.dependencies,
122
+ dependents: []
123
+ }
124
+ end
125
+
126
+ # Build reverse dependencies (dependents)
127
+ @tools.each do |tool|
128
+ tool.dependencies.each do |dep_id|
129
+ next unless @dependency_graph[dep_id]
130
+
131
+ @dependency_graph[dep_id][:dependents] << tool.id
132
+ end
133
+ end
134
+
135
+ Aidp.log_debug(
136
+ "metadata",
137
+ "Dependency graph built",
138
+ nodes: @dependency_graph.size
139
+ )
140
+ end
141
+
142
+ # Create directory structure for serialization
143
+ #
144
+ # @return [Hash] Directory structure
145
+ def create_directory_structure
146
+ {
147
+ version: "1.0.0",
148
+ compiled_at: Time.now.iso8601,
149
+ tools: @tools.map(&:to_h),
150
+ indexes: {
151
+ by_type: @indexes[:by_type].transform_values { |tools| tools.map(&:id) },
152
+ by_tag: @indexes[:by_tag].transform_values { |tools| tools.map(&:id) },
153
+ by_work_unit_type: @indexes[:by_work_unit_type].transform_values { |tools| tools.map(&:id) }
154
+ },
155
+ dependency_graph: @dependency_graph,
156
+ statistics: {
157
+ total_tools: @tools.size,
158
+ by_type: @tools.group_by(&:type).transform_values(&:size),
159
+ total_tags: @indexes[:by_tag].size,
160
+ total_work_unit_types: @indexes[:by_work_unit_type].size
161
+ }
162
+ }
163
+ end
164
+
165
+ # Write directory to JSON file
166
+ #
167
+ # @param directory [Hash] Directory structure
168
+ # @param output_path [String] Output file path
169
+ def write_directory(directory, output_path)
170
+ # Ensure output directory exists
171
+ output_dir = File.dirname(output_path)
172
+ FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
173
+
174
+ # Write with pretty formatting
175
+ File.write(output_path, JSON.pretty_generate(directory))
176
+
177
+ Aidp.log_debug("metadata", "Wrote directory", path: output_path, size: File.size(output_path))
178
+ end
179
+
180
+ # Handle validation results
181
+ #
182
+ # @param results [Array<ValidationResult>] Validation results
183
+ # @raise [Aidp::Errors::ValidationError] if strict mode and errors found
184
+ def handle_validation_results(results)
185
+ invalid_results = results.reject(&:valid)
186
+
187
+ if invalid_results.any?
188
+ Aidp.log_warn(
189
+ "metadata",
190
+ "Validation errors found",
191
+ count: invalid_results.size
192
+ )
193
+
194
+ invalid_results.each do |result|
195
+ Aidp.log_error(
196
+ "metadata",
197
+ "Tool validation failed",
198
+ tool_id: result.tool_id,
199
+ file: result.file_path,
200
+ errors: result.errors
201
+ )
202
+ end
203
+
204
+ if @strict
205
+ raise Aidp::Errors::ValidationError,
206
+ "#{invalid_results.size} tool(s) failed validation (strict mode enabled)"
207
+ end
208
+
209
+ # Remove invalid tools from compilation
210
+ invalid_ids = invalid_results.map(&:tool_id)
211
+ @tools.reject! { |tool| invalid_ids.include?(tool.id) }
212
+ end
213
+
214
+ # Log warnings
215
+ results.each do |result|
216
+ next if result.warnings.empty?
217
+
218
+ Aidp.log_warn(
219
+ "metadata",
220
+ "Tool validation warnings",
221
+ tool_id: result.tool_id,
222
+ file: result.file_path,
223
+ warnings: result.warnings
224
+ )
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "digest"
5
+ require_relative "../errors"
6
+ require_relative "tool_metadata"
7
+
8
+ module Aidp
9
+ module Metadata
10
+ # Parses tool metadata from markdown files with YAML frontmatter
11
+ #
12
+ # Extracts metadata headers from skill, persona, and template files.
13
+ # Supports both new metadata format and legacy skill format.
14
+ #
15
+ # @example Parsing a file
16
+ # metadata = Parser.parse_file("/path/to/tool.md", type: "skill")
17
+ #
18
+ # @example Parsing with auto-detection
19
+ # metadata = Parser.parse_file("/path/to/SKILL.md")
20
+ class Parser
21
+ # Parse metadata from a file
22
+ #
23
+ # @param file_path [String] Path to .md file
24
+ # @param type [String, nil] Tool type ("skill", "persona", "template") or nil to auto-detect
25
+ # @return [ToolMetadata] Parsed metadata
26
+ # @raise [Aidp::Errors::ValidationError] if file format is invalid
27
+ def self.parse_file(file_path, type: nil)
28
+ Aidp.log_debug("metadata", "Parsing file", file: file_path, type: type)
29
+
30
+ unless File.exist?(file_path)
31
+ raise Aidp::Errors::ValidationError, "File not found: #{file_path}"
32
+ end
33
+
34
+ content = File.read(file_path, encoding: "UTF-8")
35
+ file_hash = compute_file_hash(content)
36
+
37
+ # Auto-detect type from filename or path if not specified
38
+ type ||= detect_type(file_path)
39
+
40
+ parse_string(content, source_path: file_path, file_hash: file_hash, type: type)
41
+ end
42
+
43
+ # Parse metadata from a string
44
+ #
45
+ # @param content [String] File content with frontmatter
46
+ # @param source_path [String] Source file path for reference
47
+ # @param file_hash [String] SHA256 hash of content
48
+ # @param type [String] Tool type ("skill", "persona", "template")
49
+ # @return [ToolMetadata] Parsed metadata
50
+ # @raise [Aidp::Errors::ValidationError] if format is invalid
51
+ def self.parse_string(content, source_path:, file_hash:, type:)
52
+ metadata_hash, markdown = parse_frontmatter(content, source_path: source_path)
53
+
54
+ # Map legacy skill fields to new metadata schema
55
+ normalized = normalize_metadata(metadata_hash, type: type)
56
+
57
+ ToolMetadata.new(
58
+ type: type,
59
+ id: normalized["id"],
60
+ title: normalized["title"],
61
+ summary: normalized["summary"],
62
+ version: normalized["version"],
63
+ applies_to: normalized["applies_to"] || [],
64
+ work_unit_types: normalized["work_unit_types"] || [],
65
+ priority: normalized["priority"] || ToolMetadata::DEFAULT_PRIORITY,
66
+ capabilities: normalized["capabilities"] || [],
67
+ dependencies: normalized["dependencies"] || [],
68
+ experimental: normalized["experimental"] || false,
69
+ content: markdown,
70
+ source_path: source_path,
71
+ file_hash: file_hash
72
+ )
73
+ rescue Aidp::Errors::ValidationError => e
74
+ Aidp.log_error("metadata", "Metadata validation failed", error: e.message, file: source_path)
75
+ raise
76
+ end
77
+
78
+ # Compute SHA256 hash of file content
79
+ #
80
+ # @param content [String] File content
81
+ # @return [String] SHA256 hex string
82
+ def self.compute_file_hash(content)
83
+ Digest::SHA256.hexdigest(content)
84
+ end
85
+
86
+ # Detect tool type from file path
87
+ #
88
+ # @param file_path [String] File path
89
+ # @return [String] Detected type ("skill", "persona", or "template")
90
+ def self.detect_type(file_path)
91
+ case file_path
92
+ when %r{/skills/}
93
+ "skill"
94
+ when %r{/personas/}
95
+ "persona"
96
+ when /SKILL\.md$/
97
+ "skill"
98
+ else
99
+ "template"
100
+ end
101
+ end
102
+
103
+ # Parse YAML frontmatter from content
104
+ #
105
+ # @param content [String] File content with frontmatter
106
+ # @param source_path [String] Source path for error messages
107
+ # @return [Array(Hash, String)] Tuple of [metadata, markdown_content]
108
+ # @raise [Aidp::Errors::ValidationError] if frontmatter is invalid
109
+ def self.parse_frontmatter(content, source_path:)
110
+ # Ensure content is UTF-8 encoded
111
+ content = content.encode("UTF-8", invalid: :replace, undef: :replace) unless content.encoding == Encoding::UTF_8
112
+ lines = content.lines
113
+
114
+ unless lines.first&.strip == "---"
115
+ raise Aidp::Errors::ValidationError,
116
+ "Invalid format: missing YAML frontmatter in #{source_path}"
117
+ end
118
+
119
+ frontmatter_lines = []
120
+ body_start_index = nil
121
+
122
+ lines[1..].each_with_index do |line, index|
123
+ if line.strip == "---"
124
+ body_start_index = index + 2
125
+ break
126
+ end
127
+
128
+ frontmatter_lines << line
129
+ end
130
+
131
+ unless body_start_index
132
+ raise Aidp::Errors::ValidationError,
133
+ "Invalid format: missing closing frontmatter delimiter in #{source_path}"
134
+ end
135
+
136
+ markdown_content = lines[body_start_index..]&.join.to_s.strip
137
+ frontmatter_yaml = frontmatter_lines.join
138
+
139
+ begin
140
+ metadata = YAML.safe_load(frontmatter_yaml, permitted_classes: [Symbol])
141
+ rescue Psych::SyntaxError => e
142
+ raise Aidp::Errors::ValidationError,
143
+ "Invalid YAML frontmatter in #{source_path}: #{e.message}"
144
+ end
145
+
146
+ unless metadata.is_a?(Hash)
147
+ raise Aidp::Errors::ValidationError,
148
+ "YAML frontmatter must be a hash in #{source_path}"
149
+ end
150
+
151
+ [metadata, markdown_content]
152
+ end
153
+
154
+ # Normalize metadata from various formats to unified schema
155
+ #
156
+ # Handles both legacy skill format and new metadata format.
157
+ #
158
+ # @param metadata [Hash] Raw metadata from frontmatter
159
+ # @param type [String] Tool type
160
+ # @return [Hash] Normalized metadata
161
+ def self.normalize_metadata(metadata, type:)
162
+ normalized = {}
163
+
164
+ # Required fields (map from legacy names)
165
+ normalized["id"] = metadata["id"]
166
+ normalized["title"] = metadata["title"] || metadata["name"]
167
+ normalized["summary"] = metadata["summary"] || metadata["description"]
168
+ normalized["version"] = metadata["version"]
169
+
170
+ # Optional fields (new schema)
171
+ normalized["applies_to"] = extract_applies_to(metadata)
172
+ normalized["work_unit_types"] = metadata["work_unit_types"] || []
173
+ normalized["priority"] = metadata["priority"]&.to_i
174
+ normalized["capabilities"] = metadata["capabilities"] || []
175
+ normalized["dependencies"] = metadata["dependencies"] || []
176
+ normalized["experimental"] = metadata["experimental"] || false
177
+
178
+ normalized
179
+ end
180
+
181
+ # Extract applies_to tags from various metadata fields
182
+ #
183
+ # Combines keywords, tags, expertise areas, etc. into unified applies_to list
184
+ #
185
+ # @param metadata [Hash] Raw metadata
186
+ # @return [Array<String>] Combined applies_to tags
187
+ def self.extract_applies_to(metadata)
188
+ applies_to = []
189
+
190
+ # New schema
191
+ applies_to.concat(metadata["applies_to"] || [])
192
+
193
+ # Legacy skill schema
194
+ applies_to.concat(metadata["keywords"] || [])
195
+ applies_to.concat(metadata["tags"] || [])
196
+
197
+ # Flatten and deduplicate
198
+ applies_to.flatten.compact.uniq
199
+ end
200
+
201
+ private_class_method :parse_frontmatter, :normalize_metadata, :extract_applies_to
202
+ end
203
+ end
204
+ end
@@ -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