aidp 0.15.2 → 0.17.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -0
  3. data/lib/aidp/analyze/error_handler.rb +46 -28
  4. data/lib/aidp/analyze/progress.rb +1 -1
  5. data/lib/aidp/analyze/runner.rb +27 -5
  6. data/lib/aidp/analyze/steps.rb +4 -0
  7. data/lib/aidp/cli/jobs_command.rb +2 -1
  8. data/lib/aidp/cli.rb +1086 -4
  9. data/lib/aidp/concurrency/backoff.rb +148 -0
  10. data/lib/aidp/concurrency/exec.rb +192 -0
  11. data/lib/aidp/concurrency/wait.rb +148 -0
  12. data/lib/aidp/concurrency.rb +71 -0
  13. data/lib/aidp/config.rb +21 -1
  14. data/lib/aidp/daemon/runner.rb +9 -8
  15. data/lib/aidp/debug_mixin.rb +1 -0
  16. data/lib/aidp/errors.rb +12 -0
  17. data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
  18. data/lib/aidp/execute/checkpoint.rb +1 -1
  19. data/lib/aidp/execute/future_work_backlog.rb +1 -1
  20. data/lib/aidp/execute/interactive_repl.rb +102 -11
  21. data/lib/aidp/execute/progress.rb +1 -1
  22. data/lib/aidp/execute/repl_macros.rb +845 -2
  23. data/lib/aidp/execute/runner.rb +27 -5
  24. data/lib/aidp/execute/steps.rb +2 -0
  25. data/lib/aidp/harness/config_loader.rb +24 -2
  26. data/lib/aidp/harness/config_validator.rb +1 -1
  27. data/lib/aidp/harness/enhanced_runner.rb +16 -2
  28. data/lib/aidp/harness/error_handler.rb +1 -1
  29. data/lib/aidp/harness/provider_info.rb +19 -15
  30. data/lib/aidp/harness/provider_manager.rb +47 -41
  31. data/lib/aidp/harness/runner.rb +3 -11
  32. data/lib/aidp/harness/state/persistence.rb +1 -6
  33. data/lib/aidp/harness/state_manager.rb +115 -7
  34. data/lib/aidp/harness/status_display.rb +11 -18
  35. data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
  36. data/lib/aidp/harness/ui/workflow_controller.rb +1 -1
  37. data/lib/aidp/harness/user_interface.rb +12 -15
  38. data/lib/aidp/jobs/background_runner.rb +16 -6
  39. data/lib/aidp/providers/codex.rb +0 -1
  40. data/lib/aidp/providers/cursor.rb +0 -1
  41. data/lib/aidp/providers/github_copilot.rb +0 -1
  42. data/lib/aidp/providers/opencode.rb +0 -1
  43. data/lib/aidp/skills/composer.rb +178 -0
  44. data/lib/aidp/skills/loader.rb +205 -0
  45. data/lib/aidp/skills/registry.rb +222 -0
  46. data/lib/aidp/skills/router.rb +178 -0
  47. data/lib/aidp/skills/skill.rb +174 -0
  48. data/lib/aidp/skills/wizard/builder.rb +141 -0
  49. data/lib/aidp/skills/wizard/controller.rb +145 -0
  50. data/lib/aidp/skills/wizard/differ.rb +232 -0
  51. data/lib/aidp/skills/wizard/prompter.rb +317 -0
  52. data/lib/aidp/skills/wizard/template_library.rb +164 -0
  53. data/lib/aidp/skills/wizard/writer.rb +105 -0
  54. data/lib/aidp/skills.rb +30 -0
  55. data/lib/aidp/version.rb +1 -1
  56. data/lib/aidp/watch/build_processor.rb +93 -28
  57. data/lib/aidp/watch/runner.rb +3 -2
  58. data/lib/aidp/workstream_executor.rb +244 -0
  59. data/lib/aidp/workstream_state.rb +212 -0
  60. data/lib/aidp/worktree.rb +208 -0
  61. data/lib/aidp.rb +6 -0
  62. data/templates/skills/README.md +334 -0
  63. data/templates/skills/architecture_analyst/SKILL.md +173 -0
  64. data/templates/skills/product_strategist/SKILL.md +141 -0
  65. data/templates/skills/repository_analyst/SKILL.md +117 -0
  66. data/templates/skills/test_analyzer/SKILL.md +213 -0
  67. metadata +29 -4
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Skills
5
+ # Registry for managing available skills
6
+ #
7
+ # The Registry loads skills from multiple search paths and provides
8
+ # lookup, filtering, and management capabilities.
9
+ #
10
+ # Skills are loaded from:
11
+ # 1. Template skills directory (gem templates/skills/) - built-in templates
12
+ # 2. Project skills directory (.aidp/skills/) - project-specific skills
13
+ #
14
+ # Project skills with matching IDs override template skills.
15
+ #
16
+ # @example Basic usage
17
+ # registry = Registry.new(project_dir: "/path/to/project")
18
+ # registry.load_skills
19
+ # skill = registry.find("repository_analyst")
20
+ #
21
+ # @example With provider filtering
22
+ # registry = Registry.new(project_dir: "/path/to/project", provider: "anthropic")
23
+ # registry.load_skills
24
+ # skills = registry.all # Only anthropic-compatible skills
25
+ class Registry
26
+ attr_reader :project_dir, :provider
27
+
28
+ # Initialize a new skills registry
29
+ #
30
+ # @param project_dir [String] Root directory of the project
31
+ # @param provider [String, nil] Optional provider name for filtering
32
+ def initialize(project_dir:, provider: nil)
33
+ @project_dir = project_dir
34
+ @provider = provider
35
+ @skills = {}
36
+ @loaded = false
37
+ end
38
+
39
+ # Load skills from all search paths
40
+ #
41
+ # Skills are loaded in order:
42
+ # 1. Template skills (gem templates/skills/) - built-in templates
43
+ # 2. Project skills (.aidp/skills/) - override templates
44
+ #
45
+ # @return [Integer] Number of skills loaded
46
+ def load_skills
47
+ Aidp.log_debug("skills", "Loading skills", project_dir: project_dir, provider: provider)
48
+
49
+ @skills = {}
50
+
51
+ # Load template skills first
52
+ template_skills = load_from_path(template_skills_path)
53
+ template_skills.each { |skill| register_skill(skill, source: :template) }
54
+
55
+ # Load project skills (override templates if IDs match)
56
+ project_skills = load_from_path(project_skills_path)
57
+ project_skills.each { |skill| register_skill(skill, source: :project) }
58
+
59
+ @loaded = true
60
+
61
+ Aidp.log_info("skills", "Loaded skills", count: @skills.size, provider: provider)
62
+ @skills.size
63
+ end
64
+
65
+ # Find a skill by ID
66
+ #
67
+ # @param skill_id [String] Skill identifier
68
+ # @return [Skill, nil] Skill if found, nil otherwise
69
+ def find(skill_id)
70
+ load_skills unless loaded?
71
+ @skills[skill_id.to_s]
72
+ end
73
+
74
+ # Get all registered skills
75
+ #
76
+ # @return [Array<Skill>] Array of all skills
77
+ def all
78
+ load_skills unless loaded?
79
+ @skills.values
80
+ end
81
+
82
+ # Get skills matching a search query
83
+ #
84
+ # @param query [String] Search query (searches id, name, description, keywords, expertise)
85
+ # @return [Array<Skill>] Matching skills
86
+ def search(query)
87
+ load_skills unless loaded?
88
+ all.select { |skill| skill.matches?(query) }
89
+ end
90
+
91
+ # Get skills by keyword
92
+ #
93
+ # @param keyword [String] Keyword to match
94
+ # @return [Array<Skill>] Skills with matching keyword
95
+ def by_keyword(keyword)
96
+ load_skills unless loaded?
97
+ all.select { |skill| skill.keywords.include?(keyword) }
98
+ end
99
+
100
+ # Get skills compatible with a specific provider
101
+ #
102
+ # @param provider_name [String] Provider name
103
+ # @return [Array<Skill>] Compatible skills
104
+ def compatible_with(provider_name)
105
+ load_skills unless loaded?
106
+ all.select { |skill| skill.compatible_with?(provider_name) }
107
+ end
108
+
109
+ # Check if a skill exists
110
+ #
111
+ # @param skill_id [String] Skill identifier
112
+ # @return [Boolean] True if skill exists
113
+ def exists?(skill_id)
114
+ find(skill_id) != nil
115
+ end
116
+
117
+ # Check if skills have been loaded
118
+ #
119
+ # @return [Boolean] True if loaded
120
+ def loaded?
121
+ @loaded
122
+ end
123
+
124
+ # Get count of registered skills
125
+ #
126
+ # @return [Integer] Number of skills
127
+ def count
128
+ load_skills unless loaded?
129
+ @skills.size
130
+ end
131
+
132
+ # Reload skills from disk
133
+ #
134
+ # @return [Integer] Number of skills loaded
135
+ def reload
136
+ @loaded = false
137
+ load_skills
138
+ end
139
+
140
+ # Get skill IDs grouped by source
141
+ #
142
+ # @return [Hash] Hash with :template and :project arrays
143
+ def by_source
144
+ load_skills unless loaded?
145
+
146
+ {
147
+ template: @skills.values.select { |s| template_skill?(s) }.map(&:id),
148
+ project: @skills.values.select { |s| project_skill?(s) }.map(&:id)
149
+ }
150
+ end
151
+
152
+ private
153
+
154
+ # Register a skill in the registry
155
+ #
156
+ # @param skill [Skill] Skill to register
157
+ # @param source [Symbol] Source type (:builtin or :custom)
158
+ def register_skill(skill, source:)
159
+ if @skills.key?(skill.id)
160
+ Aidp.log_debug(
161
+ "skills",
162
+ "Overriding skill",
163
+ skill_id: skill.id,
164
+ old_source: @skills[skill.id].source_path,
165
+ new_source: skill.source_path
166
+ )
167
+ end
168
+
169
+ @skills[skill.id] = skill
170
+
171
+ Aidp.log_debug(
172
+ "skills",
173
+ "Registered skill",
174
+ skill_id: skill.id,
175
+ source: source,
176
+ version: skill.version
177
+ )
178
+ end
179
+
180
+ # Load skills from a directory path
181
+ #
182
+ # @param path [String] Directory path
183
+ # @return [Array<Skill>] Loaded skills
184
+ def load_from_path(path)
185
+ return [] unless Dir.exist?(path)
186
+ Loader.load_from_directory(path, provider: provider)
187
+ end
188
+
189
+ # Get template skills path (from gem)
190
+ #
191
+ # @return [String] Path to template skills directory
192
+ def template_skills_path
193
+ # Get the gem root directory (go up from lib/aidp/skills/registry.rb)
194
+ gem_root = File.expand_path("../../../..", __dir__)
195
+ File.join(gem_root, "templates", "skills")
196
+ end
197
+
198
+ # Get project skills path
199
+ #
200
+ # @return [String] Path to project-specific skills directory
201
+ def project_skills_path
202
+ File.join(project_dir, ".aidp", "skills")
203
+ end
204
+
205
+ # Check if skill is from template directory
206
+ #
207
+ # @param skill [Skill] Skill to check
208
+ # @return [Boolean] True if template
209
+ def template_skill?(skill)
210
+ skill.source_path.start_with?(template_skills_path)
211
+ end
212
+
213
+ # Check if skill is from project directory
214
+ #
215
+ # @param skill [Skill] Skill to check
216
+ # @return [Boolean] True if project
217
+ def project_skill?(skill)
218
+ skill.source_path.start_with?(project_skills_path)
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Aidp
6
+ module Skills
7
+ # Routes file paths and tasks to appropriate skills based on routing rules
8
+ #
9
+ # The Router reads routing configuration from aidp.yml and matches:
10
+ # - File paths against glob patterns (path_rules)
11
+ # - Task descriptions against keywords (task_rules)
12
+ # - Combined path + task rules (combined_rules)
13
+ #
14
+ # @example Basic usage
15
+ # router = Router.new(project_dir: Dir.pwd)
16
+ # skill_id = router.route_by_path("app/controllers/users_controller.rb")
17
+ # # => "rails_expert"
18
+ #
19
+ # @example Task-based routing
20
+ # router = Router.new(project_dir: Dir.pwd)
21
+ # skill_id = router.route_by_task("Add API endpoint")
22
+ # # => "backend_developer"
23
+ #
24
+ # @example Combined routing
25
+ # router = Router.new(project_dir: Dir.pwd)
26
+ # skill_id = router.route(path: "lib/cli.rb", task: "Add command")
27
+ # # => "cli_expert"
28
+ class Router
29
+ attr_reader :config, :project_dir
30
+
31
+ # Initialize router with project directory
32
+ #
33
+ # @param project_dir [String] Path to project directory
34
+ def initialize(project_dir:)
35
+ @project_dir = project_dir
36
+ @config = load_config
37
+ end
38
+
39
+ # Route based on both path and task (highest priority)
40
+ #
41
+ # @param path [String, nil] File path to route
42
+ # @param task [String, nil] Task description to route
43
+ # @return [String, nil] Matched skill ID or nil
44
+ def route(path: nil, task: nil)
45
+ # Priority 1: Combined rules (path AND task)
46
+ if path && task
47
+ combined_match = match_combined_rules(path, task)
48
+ return combined_match if combined_match
49
+ end
50
+
51
+ # Priority 2: Path rules
52
+ path_match = route_by_path(path) if path
53
+ return path_match if path_match
54
+
55
+ # Priority 3: Task rules
56
+ task_match = route_by_task(task) if task
57
+ return task_match if task_match
58
+
59
+ # Priority 4: Default skill
60
+ config.dig("skills", "routing", "default")
61
+ end
62
+
63
+ # Route based on file path
64
+ #
65
+ # @param path [String] File path to match against patterns
66
+ # @return [String, nil] Matched skill ID or nil
67
+ def route_by_path(path)
68
+ return nil unless path
69
+
70
+ path_rules = config.dig("skills", "routing", "path_rules") || {}
71
+
72
+ path_rules.each do |skill_id, patterns|
73
+ patterns = [patterns] unless patterns.is_a?(Array)
74
+ patterns.each do |pattern|
75
+ return skill_id if File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
76
+ end
77
+ end
78
+
79
+ nil
80
+ end
81
+
82
+ # Route based on task description
83
+ #
84
+ # @param task [String] Task description to match against keywords
85
+ # @return [String, nil] Matched skill ID or nil
86
+ def route_by_task(task)
87
+ return nil unless task
88
+
89
+ task_rules = config.dig("skills", "routing", "task_rules") || {}
90
+ task_lower = task.downcase
91
+
92
+ task_rules.each do |skill_id, keywords|
93
+ keywords = [keywords] unless keywords.is_a?(Array)
94
+ keywords.each do |keyword|
95
+ return skill_id if task_lower.include?(keyword.downcase)
96
+ end
97
+ end
98
+
99
+ nil
100
+ end
101
+
102
+ # Check if routing is enabled
103
+ #
104
+ # @return [Boolean] true if routing is configured
105
+ def routing_enabled?
106
+ config.dig("skills", "routing", "enabled") == true
107
+ end
108
+
109
+ # Get default skill
110
+ #
111
+ # @return [String, nil] Default skill ID or nil
112
+ def default_skill
113
+ config.dig("skills", "routing", "default")
114
+ end
115
+
116
+ # Get all routing rules
117
+ #
118
+ # @return [Hash] Hash containing path_rules, task_rules, and combined_rules
119
+ def rules
120
+ {
121
+ path_rules: config.dig("skills", "routing", "path_rules") || {},
122
+ task_rules: config.dig("skills", "routing", "task_rules") || {},
123
+ combined_rules: config.dig("skills", "routing", "combined_rules") || {}
124
+ }
125
+ end
126
+
127
+ private
128
+
129
+ # Match combined rules (path AND task must both match)
130
+ #
131
+ # @param path [String] File path
132
+ # @param task [String] Task description
133
+ # @return [String, nil] Matched skill ID or nil
134
+ def match_combined_rules(path, task)
135
+ combined_rules = config.dig("skills", "routing", "combined_rules") || {}
136
+ task_lower = task.downcase
137
+
138
+ combined_rules.each do |skill_id, rule|
139
+ path_patterns = rule["paths"] || []
140
+ task_keywords = rule["tasks"] || []
141
+
142
+ path_patterns = [path_patterns] unless path_patterns.is_a?(Array)
143
+ task_keywords = [task_keywords] unless task_keywords.is_a?(Array)
144
+
145
+ # Check if path matches any pattern
146
+ path_match = path_patterns.any? do |pattern|
147
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
148
+ end
149
+
150
+ # Check if task matches any keyword
151
+ task_match = task_keywords.any? do |keyword|
152
+ task_lower.include?(keyword.downcase)
153
+ end
154
+
155
+ return skill_id if path_match && task_match
156
+ end
157
+
158
+ nil
159
+ end
160
+
161
+ # Load routing configuration from aidp.yml
162
+ #
163
+ # @return [Hash] Configuration hash
164
+ def load_config
165
+ config_path = File.join(project_dir, ".aidp", "aidp.yml")
166
+
167
+ if File.exist?(config_path)
168
+ YAML.safe_load_file(config_path, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
169
+ else
170
+ {}
171
+ end
172
+ rescue => e
173
+ Aidp.log_error("skills", "Failed to load routing config", error: e.message)
174
+ {}
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Aidp
6
+ module Skills
7
+ # Represents a skill/persona with metadata and content
8
+ #
9
+ # A Skill encapsulates an agent's persona, expertise, and capabilities.
10
+ # Skills are loaded from SKILL.md files with YAML frontmatter.
11
+ #
12
+ # @example Creating a skill
13
+ # skill = Skill.new(
14
+ # id: "repository_analyst",
15
+ # name: "Repository Analyst",
16
+ # description: "Expert in version control analysis",
17
+ # version: "1.0.0",
18
+ # expertise: ["git analysis", "code metrics"],
19
+ # keywords: ["git", "metrics"],
20
+ # when_to_use: ["Analyzing repository history"],
21
+ # when_not_to_use: ["Writing new code"],
22
+ # compatible_providers: ["anthropic", "openai"],
23
+ # content: "You are a Repository Analyst...",
24
+ # source_path: "/path/to/SKILL.md"
25
+ # )
26
+ class Skill
27
+ attr_reader :id, :name, :description, :version, :expertise, :keywords,
28
+ :when_to_use, :when_not_to_use, :compatible_providers,
29
+ :content, :source_path
30
+
31
+ # Initialize a new Skill
32
+ #
33
+ # @param id [String] Unique identifier for the skill
34
+ # @param name [String] Human-readable name
35
+ # @param description [String] Brief one-line description
36
+ # @param version [String] Semantic version (e.g., "1.0.0")
37
+ # @param expertise [Array<String>] List of expertise areas
38
+ # @param keywords [Array<String>] Search/filter keywords
39
+ # @param when_to_use [Array<String>] Guidance for when to use this skill
40
+ # @param when_not_to_use [Array<String>] Guidance for when NOT to use
41
+ # @param compatible_providers [Array<String>] Compatible provider names
42
+ # @param content [String] The skill content (markdown)
43
+ # @param source_path [String] Path to source SKILL.md file
44
+ def initialize(
45
+ id:,
46
+ name:,
47
+ description:,
48
+ version:,
49
+ content:, source_path:, expertise: [],
50
+ keywords: [],
51
+ when_to_use: [],
52
+ when_not_to_use: [],
53
+ compatible_providers: []
54
+ )
55
+ @id = id
56
+ @name = name
57
+ @description = description
58
+ @version = version
59
+ @expertise = Array(expertise)
60
+ @keywords = Array(keywords)
61
+ @when_to_use = Array(when_to_use)
62
+ @when_not_to_use = Array(when_not_to_use)
63
+ @compatible_providers = Array(compatible_providers)
64
+ @content = content
65
+ @source_path = source_path
66
+
67
+ validate!
68
+ end
69
+
70
+ # Check if this skill is compatible with a given provider
71
+ #
72
+ # @param provider_name [String] Provider name (e.g., "anthropic")
73
+ # @return [Boolean] True if compatible or no restrictions defined
74
+ def compatible_with?(provider_name)
75
+ return true if compatible_providers.empty?
76
+ compatible_providers.include?(provider_name.to_s.downcase)
77
+ end
78
+
79
+ # Check if this skill matches a search query
80
+ #
81
+ # Searches across: id, name, description, expertise, keywords
82
+ #
83
+ # @param query [String] Search query (case-insensitive)
84
+ # @return [Boolean] True if skill matches the query
85
+ def matches?(query)
86
+ return true if query.nil? || query.strip.empty?
87
+
88
+ query_lower = query.downcase
89
+ searchable_text = [
90
+ id,
91
+ name,
92
+ description,
93
+ expertise,
94
+ keywords
95
+ ].flatten.join(" ").downcase
96
+
97
+ searchable_text.include?(query_lower)
98
+ end
99
+
100
+ # Get a summary of this skill for display
101
+ #
102
+ # @return [Hash] Summary with key skill metadata
103
+ def summary
104
+ {
105
+ id: id,
106
+ name: name,
107
+ description: description,
108
+ version: version,
109
+ expertise_areas: expertise.size,
110
+ keywords: keywords,
111
+ providers: compatible_providers.empty? ? "all" : compatible_providers.join(", ")
112
+ }
113
+ end
114
+
115
+ # Get full details of this skill for display
116
+ #
117
+ # @return [Hash] Detailed skill information
118
+ def details
119
+ {
120
+ id: id,
121
+ name: name,
122
+ description: description,
123
+ version: version,
124
+ expertise: expertise,
125
+ keywords: keywords,
126
+ when_to_use: when_to_use,
127
+ when_not_to_use: when_not_to_use,
128
+ compatible_providers: compatible_providers,
129
+ source: source_path,
130
+ content_length: content.length
131
+ }
132
+ end
133
+
134
+ # Return string representation
135
+ #
136
+ # @return [String] Skill representation
137
+ def to_s
138
+ "Skill[#{id}](#{name} v#{version})"
139
+ end
140
+
141
+ # Return inspection string
142
+ #
143
+ # @return [String] Detailed inspection
144
+ def inspect
145
+ "#<Aidp::Skills::Skill id=#{id} name=\"#{name}\" version=#{version} " \
146
+ "source=#{source_path}>"
147
+ end
148
+
149
+ private
150
+
151
+ # Validate required fields
152
+ #
153
+ # @raise [Aidp::Errors::ValidationError] if validation fails
154
+ def validate!
155
+ raise Aidp::Errors::ValidationError, "Skill id is required" if id.nil? || id.strip.empty?
156
+ raise Aidp::Errors::ValidationError, "Skill name is required" if name.nil? || name.strip.empty?
157
+ raise Aidp::Errors::ValidationError, "Skill description is required" if description.nil? || description.strip.empty?
158
+ raise Aidp::Errors::ValidationError, "Skill version is required" if version.nil? || version.strip.empty?
159
+ raise Aidp::Errors::ValidationError, "Skill content is required" if content.nil? || content.strip.empty?
160
+ raise Aidp::Errors::ValidationError, "Skill source_path is required" if source_path.nil? || source_path.strip.empty?
161
+
162
+ # Validate version format (simple semantic version check)
163
+ unless version.match?(/^\d+\.\d+\.\d+/)
164
+ raise Aidp::Errors::ValidationError, "Skill version must be in format X.Y.Z (e.g., 1.0.0)"
165
+ end
166
+
167
+ # Validate id format (lowercase, alphanumeric, underscores only)
168
+ unless id.match?(/^[a-z0-9_]+$/)
169
+ raise Aidp::Errors::ValidationError, "Skill id must be lowercase alphanumeric with underscores only"
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end