aidp 0.16.0 → 0.17.1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/aidp/analyze/error_handler.rb +32 -13
  3. data/lib/aidp/analyze/kb_inspector.rb +2 -3
  4. data/lib/aidp/analyze/progress.rb +6 -11
  5. data/lib/aidp/cli/mcp_dashboard.rb +1 -1
  6. data/lib/aidp/cli.rb +300 -33
  7. data/lib/aidp/config.rb +1 -1
  8. data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
  9. data/lib/aidp/execute/checkpoint.rb +1 -1
  10. data/lib/aidp/execute/future_work_backlog.rb +1 -1
  11. data/lib/aidp/execute/progress.rb +6 -9
  12. data/lib/aidp/execute/repl_macros.rb +79 -10
  13. data/lib/aidp/harness/config_loader.rb +2 -2
  14. data/lib/aidp/harness/config_validator.rb +1 -1
  15. data/lib/aidp/harness/enhanced_runner.rb +16 -7
  16. data/lib/aidp/harness/error_handler.rb +12 -5
  17. data/lib/aidp/harness/provider_manager.rb +4 -19
  18. data/lib/aidp/harness/runner.rb +2 -2
  19. data/lib/aidp/harness/state/persistence.rb +9 -10
  20. data/lib/aidp/harness/state/workflow_state.rb +3 -2
  21. data/lib/aidp/harness/state_manager.rb +33 -97
  22. data/lib/aidp/harness/status_display.rb +22 -12
  23. data/lib/aidp/harness/ui/enhanced_tui.rb +3 -4
  24. data/lib/aidp/harness/user_interface.rb +11 -6
  25. data/lib/aidp/jobs/background_runner.rb +8 -2
  26. data/lib/aidp/logger.rb +1 -1
  27. data/lib/aidp/message_display.rb +9 -2
  28. data/lib/aidp/providers/anthropic.rb +1 -1
  29. data/lib/aidp/providers/base.rb +4 -4
  30. data/lib/aidp/providers/codex.rb +1 -1
  31. data/lib/aidp/providers/cursor.rb +1 -1
  32. data/lib/aidp/providers/gemini.rb +1 -1
  33. data/lib/aidp/providers/github_copilot.rb +1 -1
  34. data/lib/aidp/providers/macos_ui.rb +1 -1
  35. data/lib/aidp/providers/opencode.rb +1 -1
  36. data/lib/aidp/skills/registry.rb +31 -29
  37. data/lib/aidp/skills/router.rb +178 -0
  38. data/lib/aidp/skills/wizard/builder.rb +141 -0
  39. data/lib/aidp/skills/wizard/controller.rb +145 -0
  40. data/lib/aidp/skills/wizard/differ.rb +232 -0
  41. data/lib/aidp/skills/wizard/prompter.rb +317 -0
  42. data/lib/aidp/skills/wizard/template_library.rb +164 -0
  43. data/lib/aidp/skills/wizard/writer.rb +105 -0
  44. data/lib/aidp/version.rb +1 -1
  45. data/lib/aidp/watch/plan_generator.rb +1 -1
  46. data/lib/aidp/watch/repository_client.rb +13 -9
  47. data/lib/aidp/workflows/guided_agent.rb +2 -312
  48. data/lib/aidp/workstream_executor.rb +8 -2
  49. data/templates/skills/README.md +334 -0
  50. data/templates/skills/architecture_analyst/SKILL.md +173 -0
  51. data/templates/skills/product_strategist/SKILL.md +141 -0
  52. data/templates/skills/repository_analyst/SKILL.md +117 -0
  53. data/templates/skills/test_analyzer/SKILL.md +213 -0
  54. metadata +13 -1
@@ -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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../skill"
4
+
5
+ module Aidp
6
+ module Skills
7
+ module Wizard
8
+ # Builds Skill objects from wizard responses
9
+ #
10
+ # Takes user input from the wizard and constructs a valid Skill object.
11
+ # Handles template inheritance by merging metadata and content.
12
+ #
13
+ # @example Building a new skill
14
+ # builder = Builder.new
15
+ # responses = { id: "my_skill", name: "My Skill", ... }
16
+ # skill = builder.build(responses)
17
+ #
18
+ # @example Building from a template
19
+ # builder = Builder.new(base_skill: template)
20
+ # responses = { id: "my_skill", ... }
21
+ # skill = builder.build(responses) # Inherits from template
22
+ class Builder
23
+ attr_reader :base_skill
24
+
25
+ # Initialize builder
26
+ #
27
+ # @param base_skill [Skill, nil] Optional base skill for inheritance
28
+ def initialize(base_skill: nil)
29
+ @base_skill = base_skill
30
+ end
31
+
32
+ # Build a Skill from wizard responses
33
+ #
34
+ # @param responses [Hash] Wizard responses
35
+ # @option responses [String] :id Skill ID
36
+ # @option responses [String] :name Skill name
37
+ # @option responses [String] :description Description
38
+ # @option responses [String] :version Version (default: "1.0.0")
39
+ # @option responses [Array<String>] :expertise Expertise areas
40
+ # @option responses [Array<String>] :keywords Keywords
41
+ # @option responses [Array<String>] :when_to_use When to use
42
+ # @option responses [Array<String>] :when_not_to_use When not to use
43
+ # @option responses [Array<String>] :compatible_providers Compatible providers
44
+ # @option responses [String] :content Markdown content
45
+ # @return [Skill] Built skill
46
+ def build(responses)
47
+ # Merge with base skill if provided
48
+ merged = if base_skill
49
+ merge_with_base(responses)
50
+ else
51
+ responses
52
+ end
53
+
54
+ # Create skill with merged attributes
55
+ # Note: source_path will be set by Writer when saved
56
+ Skill.new(
57
+ id: merged[:id],
58
+ name: merged[:name],
59
+ description: merged[:description],
60
+ version: merged[:version] || "1.0.0",
61
+ expertise: Array(merged[:expertise]),
62
+ keywords: Array(merged[:keywords]),
63
+ when_to_use: Array(merged[:when_to_use]),
64
+ when_not_to_use: Array(merged[:when_not_to_use]),
65
+ compatible_providers: Array(merged[:compatible_providers]),
66
+ content: merged[:content],
67
+ source_path: merged[:source_path] || "<pending>"
68
+ )
69
+ end
70
+
71
+ # Generate YAML frontmatter + content for writing
72
+ #
73
+ # @param skill [Skill] Skill to serialize
74
+ # @return [String] Complete SKILL.md content
75
+ def to_skill_md(skill)
76
+ frontmatter = build_frontmatter(skill)
77
+ "#{frontmatter}\n#{skill.content}"
78
+ end
79
+
80
+ private
81
+
82
+ # Merge responses with base skill
83
+ #
84
+ # @param responses [Hash] User responses
85
+ # @return [Hash] Merged attributes
86
+ def merge_with_base(responses)
87
+ {
88
+ id: responses[:id],
89
+ name: responses[:name] || base_skill.name,
90
+ description: responses[:description] || base_skill.description,
91
+ version: responses[:version] || "1.0.0",
92
+ expertise: merge_arrays(base_skill.expertise, responses[:expertise]),
93
+ keywords: merge_arrays(base_skill.keywords, responses[:keywords]),
94
+ when_to_use: merge_arrays(base_skill.when_to_use, responses[:when_to_use]),
95
+ when_not_to_use: merge_arrays(base_skill.when_not_to_use, responses[:when_not_to_use]),
96
+ compatible_providers: responses[:compatible_providers] || base_skill.compatible_providers,
97
+ content: responses[:content] || base_skill.content,
98
+ source_path: responses[:source_path]
99
+ }
100
+ end
101
+
102
+ # Merge arrays (base + new, deduplicated)
103
+ #
104
+ # @param base [Array] Base array
105
+ # @param new_items [Array, nil] New items
106
+ # @return [Array] Merged and deduplicated array
107
+ def merge_arrays(base, new_items)
108
+ return base if new_items.nil? || new_items.empty?
109
+ (base + Array(new_items)).uniq
110
+ end
111
+
112
+ # Build YAML frontmatter
113
+ #
114
+ # @param skill [Skill] Skill object
115
+ # @return [String] YAML frontmatter block
116
+ def build_frontmatter(skill)
117
+ data = {
118
+ "id" => skill.id,
119
+ "name" => skill.name,
120
+ "description" => skill.description,
121
+ "version" => skill.version
122
+ }
123
+
124
+ # Add optional arrays if not empty
125
+ data["expertise"] = skill.expertise unless skill.expertise.empty?
126
+ data["keywords"] = skill.keywords unless skill.keywords.empty?
127
+ data["when_to_use"] = skill.when_to_use unless skill.when_to_use.empty?
128
+ data["when_not_to_use"] = skill.when_not_to_use unless skill.when_not_to_use.empty?
129
+ data["compatible_providers"] = skill.compatible_providers unless skill.compatible_providers.empty?
130
+
131
+ # Generate YAML
132
+ yaml_content = data.to_yaml
133
+ # Remove the leading "---\n" that to_yaml adds, we'll add our own delimiters
134
+ yaml_content = yaml_content.sub(/\A---\n/, "")
135
+
136
+ "---\n#{yaml_content}---"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_library"
4
+ require_relative "prompter"
5
+ require_relative "builder"
6
+ require_relative "writer"
7
+
8
+ module Aidp
9
+ module Skills
10
+ module Wizard
11
+ # Controller orchestrating the skill creation wizard
12
+ #
13
+ # Coordinates the wizard flow: template selection, prompting, building,
14
+ # preview, and writing the skill.
15
+ #
16
+ # @example Creating a new skill
17
+ # controller = Controller.new(project_dir: "/path/to/project")
18
+ # controller.run
19
+ #
20
+ # @example With options
21
+ # controller = Controller.new(
22
+ # project_dir: "/path/to/project",
23
+ # options: { id: "my_skill", dry_run: true }
24
+ # )
25
+ # controller.run
26
+ class Controller
27
+ attr_reader :project_dir, :options, :template_library, :prompter, :writer
28
+
29
+ # Initialize controller
30
+ #
31
+ # @param project_dir [String] Root directory of the project
32
+ # @param options [Hash] Wizard options
33
+ # @option options [String] :id Pre-filled skill ID
34
+ # @option options [String] :name Pre-filled skill name
35
+ # @option options [Boolean] :dry_run Don't write files
36
+ # @option options [Boolean] :minimal Skip optional sections
37
+ # @option options [Boolean] :open_editor Open in $EDITOR (future)
38
+ def initialize(project_dir:, options: {})
39
+ @project_dir = project_dir
40
+ @options = options
41
+ @template_library = TemplateLibrary.new(project_dir: project_dir)
42
+ @prompter = Prompter.new
43
+ @writer = Writer.new(project_dir: project_dir)
44
+ end
45
+
46
+ # Run the wizard
47
+ #
48
+ # @return [String, nil] Path to created skill file, or nil if cancelled/dry-run
49
+ def run
50
+ Aidp.log_info("wizard", "Starting skill creation wizard", options: options)
51
+
52
+ # Gather user responses
53
+ responses = prompter.gather_responses(template_library, options: options)
54
+
55
+ # Build skill from responses
56
+ base_skill = responses.delete(:base_skill)
57
+ builder = Builder.new(base_skill: base_skill)
58
+ skill = builder.build(responses)
59
+
60
+ # Generate SKILL.md content
61
+ skill_md_content = builder.to_skill_md(skill)
62
+
63
+ # Preview
64
+ show_preview(skill, skill_md_content) unless options[:minimal]
65
+
66
+ # Confirm
67
+ unless options[:dry_run] || options[:yes] || confirm_save(skill)
68
+ prompter.prompt.warn("Cancelled")
69
+ return nil
70
+ end
71
+
72
+ # Write to disk
73
+ path = writer.write(
74
+ skill,
75
+ content: skill_md_content,
76
+ dry_run: options[:dry_run],
77
+ backup: true
78
+ )
79
+
80
+ # Show success message
81
+ show_success(skill, path) unless options[:dry_run]
82
+
83
+ path
84
+ rescue Interrupt
85
+ prompter.prompt.warn("\nWizard cancelled")
86
+ nil
87
+ rescue => e
88
+ Aidp.log_error("wizard", "Wizard failed", error: e.message, backtrace: e.backtrace.first(5))
89
+ prompter.prompt.error("Error: #{e.message}")
90
+ nil
91
+ end
92
+
93
+ private
94
+
95
+ # Show preview of the skill
96
+ #
97
+ # @param skill [Skill] Built skill
98
+ # @param content [String] SKILL.md content
99
+ def show_preview(skill, content)
100
+ prompter.prompt.say("\n" + "=" * 60)
101
+ prompter.prompt.say("Skill Preview: #{skill.id} v#{skill.version}")
102
+ prompter.prompt.say("=" * 60 + "\n")
103
+
104
+ # Show first 20 lines of content
105
+ lines = content.lines
106
+ preview_lines = lines.first(20)
107
+ prompter.prompt.say(preview_lines.join)
108
+
109
+ if lines.size > 20
110
+ prompter.prompt.say("\n... (#{lines.size - 20} more lines)")
111
+ end
112
+
113
+ prompter.prompt.say("\n" + "=" * 60 + "\n")
114
+ end
115
+
116
+ # Confirm before saving
117
+ #
118
+ # @param skill [Skill] Skill to save
119
+ # @return [Boolean] True if user confirms
120
+ def confirm_save(skill)
121
+ if writer.exists?(skill.id)
122
+ prompter.prompt.warn("Warning: Skill '#{skill.id}' already exists and will be overwritten (backup will be created)")
123
+ end
124
+
125
+ prompter.prompt.yes?("Save this skill?")
126
+ end
127
+
128
+ # Show success message
129
+ #
130
+ # @param skill [Skill] Created skill
131
+ # @param path [String] Path to file
132
+ def show_success(skill, path)
133
+ prompter.prompt.say("\n✅ Skill created successfully!\n")
134
+ prompter.prompt.say(" ID: #{skill.id}")
135
+ prompter.prompt.say(" Version: #{skill.version}")
136
+ prompter.prompt.say(" File: #{path}")
137
+ prompter.prompt.say("\nNext steps:")
138
+ prompter.prompt.say(" • Review: aidp skill preview #{skill.id}")
139
+ prompter.prompt.say(" • Edit: aidp skill edit #{skill.id}")
140
+ prompter.prompt.say(" • Use: Reference in your workflow steps\n")
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require_relative "builder"
5
+
6
+ module Aidp
7
+ module Skills
8
+ module Wizard
9
+ # Generates and displays diffs between skills
10
+ #
11
+ # Shows differences between an original skill and a modified version,
12
+ # or between a project skill and its template.
13
+ #
14
+ # @example Showing a diff
15
+ # differ = Differ.new
16
+ # diff = differ.diff(original_skill, modified_skill)
17
+ # differ.display(diff)
18
+ class Differ
19
+ attr_reader :pastel
20
+
21
+ def initialize
22
+ @pastel = Pastel.new
23
+ end
24
+
25
+ # Generate a diff between two skills
26
+ #
27
+ # @param original [Skill, String] Original skill or content
28
+ # @param modified [Skill, String] Modified skill or content
29
+ # @return [Hash] Diff information
30
+ def diff(original, modified)
31
+ original_content = skill_to_content(original)
32
+ modified_content = skill_to_content(modified)
33
+
34
+ {
35
+ original: original_content,
36
+ modified: modified_content,
37
+ lines: generate_line_diff(original_content, modified_content),
38
+ has_changes: original_content != modified_content
39
+ }
40
+ end
41
+
42
+ # Display a diff to the terminal
43
+ #
44
+ # @param diff [Hash] Diff information from #diff
45
+ # @param output [IO] Output stream (default: $stdout)
46
+ def display(diff, output: $stdout)
47
+ unless diff[:has_changes]
48
+ output.puts pastel.dim("No differences found")
49
+ return
50
+ end
51
+
52
+ output.puts pastel.bold("\n" + "=" * 60)
53
+ output.puts pastel.bold("Skill Diff")
54
+ output.puts pastel.bold("=" * 60)
55
+
56
+ diff[:lines].each do |line_info|
57
+ case line_info[:type]
58
+ when :context
59
+ output.puts pastel.dim(" #{line_info[:line]}")
60
+ when :add
61
+ output.puts pastel.green("+ #{line_info[:line]}")
62
+ when :remove
63
+ output.puts pastel.red("- #{line_info[:line]}")
64
+ end
65
+ end
66
+
67
+ output.puts pastel.bold("=" * 60 + "\n")
68
+ end
69
+
70
+ # Generate a unified diff string
71
+ #
72
+ # @param original [Skill, String] Original skill or content
73
+ # @param modified [Skill, String] Modified skill or content
74
+ # @return [String] Unified diff format
75
+ def unified_diff(original, modified)
76
+ original_content = skill_to_content(original)
77
+ modified_content = skill_to_content(modified)
78
+
79
+ original_lines = original_content.lines
80
+ modified_lines = modified_content.lines
81
+
82
+ diff_lines = []
83
+ diff_lines << "--- original"
84
+ diff_lines << "+++ modified"
85
+
86
+ # Simple line-by-line diff (not optimal but functional)
87
+ max_lines = [original_lines.size, modified_lines.size].max
88
+
89
+ (0...max_lines).each do |i|
90
+ orig_line = original_lines[i]
91
+ mod_line = modified_lines[i]
92
+
93
+ if orig_line && !mod_line
94
+ diff_lines << "-#{orig_line}"
95
+ elsif !orig_line && mod_line
96
+ diff_lines << "+#{mod_line}"
97
+ elsif orig_line != mod_line
98
+ diff_lines << "-#{orig_line}"
99
+ diff_lines << "+#{mod_line}"
100
+ else
101
+ diff_lines << " #{orig_line}"
102
+ end
103
+ end
104
+
105
+ diff_lines.join
106
+ end
107
+
108
+ # Compare a project skill with its template
109
+ #
110
+ # @param project_skill [Skill] Project skill
111
+ # @param template_skill [Skill] Template skill
112
+ # @return [Hash] Comparison information
113
+ def compare_with_template(project_skill, template_skill)
114
+ {
115
+ skill_id: project_skill.id,
116
+ overrides: detect_overrides(project_skill, template_skill),
117
+ additions: detect_additions(project_skill, template_skill),
118
+ diff: diff(template_skill, project_skill)
119
+ }
120
+ end
121
+
122
+ private
123
+
124
+ # Convert skill or string to content
125
+ #
126
+ # @param skill_or_content [Skill, String] Skill object or content string
127
+ # @return [String] Content string
128
+ def skill_to_content(skill_or_content)
129
+ if skill_or_content.is_a?(String)
130
+ skill_or_content
131
+ elsif skill_or_content.respond_to?(:content)
132
+ # Build full SKILL.md content
133
+ builder = Builder.new
134
+ builder.to_skill_md(skill_or_content)
135
+ else
136
+ skill_or_content.to_s
137
+ end
138
+ end
139
+
140
+ # Generate line-by-line diff
141
+ #
142
+ # @param original [String] Original content
143
+ # @param modified [String] Modified content
144
+ # @return [Array<Hash>] Array of line diff information
145
+ def generate_line_diff(original, modified)
146
+ original_lines = original.lines.map(&:chomp)
147
+ modified_lines = modified.lines.map(&:chomp)
148
+
149
+ lines = []
150
+ i = 0
151
+ j = 0
152
+
153
+ while i < original_lines.size || j < modified_lines.size
154
+ if i >= original_lines.size
155
+ # Only modified lines remain
156
+ lines << {type: :add, line: modified_lines[j]}
157
+ j += 1
158
+ elsif j >= modified_lines.size
159
+ # Only original lines remain
160
+ lines << {type: :remove, line: original_lines[i]}
161
+ i += 1
162
+ elsif original_lines[i] == modified_lines[j]
163
+ # Lines match
164
+ lines << {type: :context, line: original_lines[i]}
165
+ i += 1
166
+ j += 1
167
+ else
168
+ # Lines differ - show removal then addition
169
+ lines << {type: :remove, line: original_lines[i]}
170
+ lines << {type: :add, line: modified_lines[j]}
171
+ i += 1
172
+ j += 1
173
+ end
174
+ end
175
+
176
+ lines
177
+ end
178
+
179
+ # Detect field overrides between project and template
180
+ #
181
+ # @param project_skill [Skill] Project skill
182
+ # @param template_skill [Skill] Template skill
183
+ # @return [Hash] Hash of overridden fields
184
+ def detect_overrides(project_skill, template_skill)
185
+ overrides = {}
186
+
187
+ # Check metadata fields
188
+ if project_skill.name != template_skill.name
189
+ overrides[:name] = {template: template_skill.name, project: project_skill.name}
190
+ end
191
+
192
+ if project_skill.description != template_skill.description
193
+ overrides[:description] = {template: template_skill.description, project: project_skill.description}
194
+ end
195
+
196
+ if project_skill.version != template_skill.version
197
+ overrides[:version] = {template: template_skill.version, project: project_skill.version}
198
+ end
199
+
200
+ if project_skill.content != template_skill.content
201
+ overrides[:content] = {template: "...", project: "..."}
202
+ end
203
+
204
+ overrides
205
+ end
206
+
207
+ # Detect additions in project skill compared to template
208
+ #
209
+ # @param project_skill [Skill] Project skill
210
+ # @param template_skill [Skill] Template skill
211
+ # @return [Hash] Hash of added items
212
+ def detect_additions(project_skill, template_skill)
213
+ additions = {}
214
+
215
+ # Check for added expertise
216
+ new_expertise = project_skill.expertise - template_skill.expertise
217
+ additions[:expertise] = new_expertise if new_expertise.any?
218
+
219
+ # Check for added keywords
220
+ new_keywords = project_skill.keywords - template_skill.keywords
221
+ additions[:keywords] = new_keywords if new_keywords.any?
222
+
223
+ # Check for added when_to_use
224
+ new_when_to_use = project_skill.when_to_use - template_skill.when_to_use
225
+ additions[:when_to_use] = new_when_to_use if new_when_to_use.any?
226
+
227
+ additions
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end