aidp 0.15.2 → 0.16.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -0
  3. data/lib/aidp/analyze/error_handler.rb +14 -15
  4. data/lib/aidp/analyze/runner.rb +27 -5
  5. data/lib/aidp/analyze/steps.rb +4 -0
  6. data/lib/aidp/cli/jobs_command.rb +2 -1
  7. data/lib/aidp/cli.rb +812 -3
  8. data/lib/aidp/concurrency/backoff.rb +148 -0
  9. data/lib/aidp/concurrency/exec.rb +192 -0
  10. data/lib/aidp/concurrency/wait.rb +148 -0
  11. data/lib/aidp/concurrency.rb +71 -0
  12. data/lib/aidp/config.rb +20 -0
  13. data/lib/aidp/daemon/runner.rb +9 -8
  14. data/lib/aidp/debug_mixin.rb +1 -0
  15. data/lib/aidp/errors.rb +12 -0
  16. data/lib/aidp/execute/interactive_repl.rb +102 -11
  17. data/lib/aidp/execute/repl_macros.rb +776 -2
  18. data/lib/aidp/execute/runner.rb +27 -5
  19. data/lib/aidp/execute/steps.rb +2 -0
  20. data/lib/aidp/harness/config_loader.rb +24 -2
  21. data/lib/aidp/harness/enhanced_runner.rb +16 -2
  22. data/lib/aidp/harness/error_handler.rb +1 -1
  23. data/lib/aidp/harness/provider_info.rb +19 -15
  24. data/lib/aidp/harness/provider_manager.rb +47 -41
  25. data/lib/aidp/harness/runner.rb +3 -11
  26. data/lib/aidp/harness/state/persistence.rb +1 -6
  27. data/lib/aidp/harness/state_manager.rb +115 -7
  28. data/lib/aidp/harness/status_display.rb +11 -18
  29. data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
  30. data/lib/aidp/harness/ui/workflow_controller.rb +1 -1
  31. data/lib/aidp/harness/user_interface.rb +12 -15
  32. data/lib/aidp/jobs/background_runner.rb +15 -5
  33. data/lib/aidp/providers/codex.rb +0 -1
  34. data/lib/aidp/providers/cursor.rb +0 -1
  35. data/lib/aidp/providers/github_copilot.rb +0 -1
  36. data/lib/aidp/providers/opencode.rb +0 -1
  37. data/lib/aidp/skills/composer.rb +178 -0
  38. data/lib/aidp/skills/loader.rb +205 -0
  39. data/lib/aidp/skills/registry.rb +220 -0
  40. data/lib/aidp/skills/skill.rb +174 -0
  41. data/lib/aidp/skills.rb +30 -0
  42. data/lib/aidp/version.rb +1 -1
  43. data/lib/aidp/watch/build_processor.rb +93 -28
  44. data/lib/aidp/watch/runner.rb +3 -2
  45. data/lib/aidp/workstream_executor.rb +244 -0
  46. data/lib/aidp/workstream_state.rb +212 -0
  47. data/lib/aidp/worktree.rb +208 -0
  48. data/lib/aidp.rb +6 -0
  49. metadata +17 -4
@@ -2056,14 +2056,13 @@ module Aidp
2056
2056
  return unless @control_interface_enabled
2057
2057
 
2058
2058
  @control_mutex.synchronize do
2059
- return if @control_thread&.alive?
2059
+ return if @control_future&.pending?
2060
2060
 
2061
- # Start control interface using Async (skip in test mode)
2061
+ # Start control interface using concurrent-ruby (skip in test mode)
2062
+ # Using Concurrent::Future for background execution with proper thread pool management
2062
2063
  unless ENV["RACK_ENV"] == "test" || defined?(RSpec)
2063
- require "async"
2064
- Async do |task|
2065
- task.async { control_interface_loop }
2066
- end
2064
+ require "concurrent"
2065
+ @control_future = Concurrent::Future.execute { control_interface_loop }
2067
2066
  end
2068
2067
  end
2069
2068
 
@@ -2079,9 +2078,9 @@ module Aidp
2079
2078
  # Stop the control interface
2080
2079
  def stop_control_interface
2081
2080
  @control_mutex.synchronize do
2082
- if @control_thread&.alive?
2083
- @control_thread.kill
2084
- @control_thread = nil
2081
+ if @control_future
2082
+ @control_future.cancel
2083
+ @control_future = nil
2085
2084
  end
2086
2085
  end
2087
2086
 
@@ -2153,10 +2152,9 @@ module Aidp
2153
2152
  elsif resume_requested?
2154
2153
  handle_resume_state
2155
2154
  break
2156
- elsif ENV["RACK_ENV"] == "test" || defined?(RSpec)
2157
- sleep(0.1)
2158
2155
  else
2159
- Async::Task.current.sleep(0.1)
2156
+ # Periodic check for user input/state changes
2157
+ sleep(0.1)
2160
2158
  end
2161
2159
  end
2162
2160
  end
@@ -2400,10 +2398,9 @@ module Aidp
2400
2398
  elsif Time.now - start_time > timeout_seconds
2401
2399
  display_message("\n⏰ Control interface timeout reached. Continuing execution...", type: :warning)
2402
2400
  break
2403
- elsif ENV["RACK_ENV"] == "test" || defined?(RSpec)
2404
- sleep(0.1)
2405
2401
  else
2406
- Async::Task.current.sleep(0.1)
2402
+ # Periodic check for user confirmation
2403
+ sleep(0.1)
2407
2404
  end
2408
2405
  end
2409
2406
  end
@@ -3,7 +3,9 @@
3
3
  require "securerandom"
4
4
  require "yaml"
5
5
  require "fileutils"
6
+ require "time"
6
7
  require_relative "../rescue_logging"
8
+ require_relative "../concurrency"
7
9
 
8
10
  module Aidp
9
11
  module Jobs
@@ -69,7 +71,14 @@ module Aidp
69
71
 
70
72
  # Wait for child to fork
71
73
  Process.detach(pid)
72
- sleep 0.1 # Give daemon time to write PID file
74
+
75
+ # Wait for daemon to write PID file (with timeout)
76
+ begin
77
+ Aidp::Concurrency::Wait.for_file(pid_file, timeout: 5, interval: 0.05)
78
+ rescue Aidp::Concurrency::TimeoutError
79
+ # PID file not created - daemon may have failed to start
80
+ # Continue anyway, metadata will reflect this
81
+ end
73
82
 
74
83
  # Save job metadata in parent process
75
84
  save_job_metadata(job_id, pid, mode, options)
@@ -191,7 +200,7 @@ module Aidp
191
200
  job_id: job_id,
192
201
  pid: pid,
193
202
  mode: mode,
194
- started_at: Time.now,
203
+ started_at: Time.now.iso8601,
195
204
  status: "running",
196
205
  options: options.except(:prompt) # Don't save prompt object
197
206
  }
@@ -203,6 +212,7 @@ module Aidp
203
212
  metadata_file = File.join(@jobs_dir, job_id, "metadata.yml")
204
213
  return nil unless File.exist?(metadata_file)
205
214
 
215
+ # Return raw metadata with times as ISO8601 strings to avoid unsafe class loading
206
216
  YAML.load_file(metadata_file)
207
217
  rescue
208
218
  nil
@@ -220,7 +230,7 @@ module Aidp
220
230
  def mark_job_completed(job_id, result)
221
231
  update_job_metadata(job_id, {
222
232
  status: "completed",
223
- completed_at: Time.now,
233
+ completed_at: Time.now.iso8601,
224
234
  result: result
225
235
  })
226
236
  end
@@ -228,7 +238,7 @@ module Aidp
228
238
  def mark_job_failed(job_id, error)
229
239
  update_job_metadata(job_id, {
230
240
  status: "failed",
231
- completed_at: Time.now,
241
+ completed_at: Time.now.iso8601,
232
242
  error: {
233
243
  message: error.message,
234
244
  class: error.class.name,
@@ -240,7 +250,7 @@ module Aidp
240
250
  def mark_job_stopped(job_id)
241
251
  update_job_metadata(job_id, {
242
252
  status: "stopped",
243
- completed_at: Time.now
253
+ completed_at: Time.now.iso8601
244
254
  })
245
255
  end
246
256
 
@@ -57,7 +57,6 @@ module Aidp
57
57
  spinner = TTY::Spinner.new("[:spinner] :title", format: :dots, hide_cursor: true)
58
58
  spinner.auto_spin
59
59
 
60
- # Start activity display thread with timeout
61
60
  activity_display_thread = Thread.new do
62
61
  start_time = Time.now
63
62
  loop do
@@ -54,7 +54,6 @@ module Aidp
54
54
  spinner = TTY::Spinner.new("[:spinner] :title", format: :dots, hide_cursor: true)
55
55
  spinner.auto_spin
56
56
 
57
- # Start activity display thread with timeout
58
57
  activity_display_thread = Thread.new do
59
58
  start_time = Time.now
60
59
  loop do
@@ -57,7 +57,6 @@ module Aidp
57
57
  spinner = TTY::Spinner.new("[:spinner] :title", format: :dots, hide_cursor: true)
58
58
  spinner.auto_spin
59
59
 
60
- # Start activity display thread with timeout
61
60
  activity_display_thread = Thread.new do
62
61
  start_time = Time.now
63
62
  loop do
@@ -50,7 +50,6 @@ module Aidp
50
50
  spinner = TTY::Spinner.new("[:spinner] :title", format: :dots, hide_cursor: true)
51
51
  spinner.auto_spin
52
52
 
53
- # Start activity display thread with timeout
54
53
  activity_display_thread = Thread.new do
55
54
  start_time = Time.now
56
55
  loop do
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Aidp
6
+ module Skills
7
+ # Composes skills with templates to create complete prompts
8
+ #
9
+ # The Composer combines skill content (WHO the agent is and WHAT capabilities
10
+ # they have) with template content (WHEN/HOW to execute a specific task).
11
+ #
12
+ # Composition structure:
13
+ # 1. Skill content (persona, expertise, philosophy)
14
+ # 2. Separator
15
+ # 3. Template content (task-specific instructions)
16
+ #
17
+ # @example Basic composition
18
+ # composer = Composer.new
19
+ # prompt = composer.compose(
20
+ # skill: repository_analyst_skill,
21
+ # template: "Analyze the repository..."
22
+ # )
23
+ #
24
+ # @example Template-only (no skill)
25
+ # prompt = composer.compose(template: "Do this task...")
26
+ class Composer
27
+ # Separator between skill and template content
28
+ SKILL_TEMPLATE_SEPARATOR = "\n\n---\n\n"
29
+
30
+ # Compose a skill and template into a complete prompt
31
+ #
32
+ # @param skill [Skill, nil] Skill to include (optional)
33
+ # @param template [String] Template content
34
+ # @param options [Hash] Optional parameters for template variable replacement
35
+ # @return [String] Composed prompt
36
+ def compose(template:, skill: nil, options: {})
37
+ Aidp.log_debug(
38
+ "skills",
39
+ "Composing prompt",
40
+ skill_id: skill&.id,
41
+ template_length: template.length,
42
+ options_count: options.size
43
+ )
44
+
45
+ # Replace template variables
46
+ rendered_template = render_template(template, options: options)
47
+
48
+ # If no skill, return template only
49
+ unless skill
50
+ Aidp.log_debug("skills", "Template-only composition", template_length: rendered_template.length)
51
+ return rendered_template
52
+ end
53
+
54
+ # Compose skill + template
55
+ composed = [
56
+ skill.content,
57
+ SKILL_TEMPLATE_SEPARATOR,
58
+ "# Current Task",
59
+ "",
60
+ rendered_template
61
+ ].join("\n")
62
+
63
+ Aidp.log_debug(
64
+ "skills",
65
+ "Composed prompt with skill",
66
+ skill_id: skill.id,
67
+ total_length: composed.length,
68
+ skill_length: skill.content.length,
69
+ template_length: rendered_template.length
70
+ )
71
+
72
+ composed
73
+ end
74
+
75
+ # Render a template with variable substitution
76
+ #
77
+ # Replaces {{variable}} placeholders with values from options hash
78
+ #
79
+ # @param template [String] Template content
80
+ # @param options [Hash] Variable values for substitution
81
+ # @return [String] Rendered template
82
+ def render_template(template, options: {})
83
+ return template if options.empty?
84
+
85
+ rendered = template.dup
86
+
87
+ options.each do |key, value|
88
+ placeholder = "{{#{key}}}"
89
+ rendered = rendered.gsub(placeholder, value.to_s)
90
+ end
91
+
92
+ # Log if there are unreplaced placeholders
93
+ remaining_placeholders = extract_placeholders(rendered)
94
+ if remaining_placeholders.any?
95
+ Aidp.log_warn(
96
+ "skills",
97
+ "Unreplaced template variables",
98
+ placeholders: remaining_placeholders
99
+ )
100
+ end
101
+
102
+ rendered
103
+ end
104
+
105
+ # Compose multiple skills with a template
106
+ #
107
+ # Note: This is for future use when skill composition is supported.
108
+ # Currently raises an error as it's not implemented in v1.
109
+ #
110
+ # @param skills [Array<Skill>] Skills to compose
111
+ # @param template [String] Template content
112
+ # @param options [Hash] Template variables
113
+ # @return [String] Composed prompt
114
+ # @raise [NotImplementedError] Skill composition not yet supported
115
+ def compose_multiple(skills:, template:, options: {})
116
+ raise NotImplementedError, "Multiple skill composition not yet supported in v1"
117
+ end
118
+
119
+ # Preview what a composed prompt would look like
120
+ #
121
+ # Returns a hash with skill content, template content, and full composition
122
+ # for inspection without executing.
123
+ #
124
+ # @param skill [Skill, nil] Skill to include
125
+ # @param template [String] Template content
126
+ # @param options [Hash] Template variables
127
+ # @return [Hash] Preview with :skill, :template, :composed, :metadata
128
+ def preview(template:, skill: nil, options: {})
129
+ rendered_template = render_template(template, options: options)
130
+ composed = compose(skill: skill, template: template, options: options)
131
+
132
+ {
133
+ skill: skill ? {
134
+ id: skill.id,
135
+ name: skill.name,
136
+ content: skill.content,
137
+ length: skill.content.length
138
+ } : nil,
139
+ template: {
140
+ content: rendered_template,
141
+ length: rendered_template.length,
142
+ variables: options.keys
143
+ },
144
+ composed: {
145
+ content: composed,
146
+ length: composed.length
147
+ },
148
+ metadata: {
149
+ has_skill: !skill.nil?,
150
+ separator_used: !skill.nil?,
151
+ unreplaced_vars: extract_placeholders(composed)
152
+ }
153
+ }
154
+ end
155
+
156
+ private
157
+
158
+ def extract_placeholders(text)
159
+ return [] if text.nil? || text.empty?
160
+
161
+ scanner = StringScanner.new(text)
162
+ placeholders = []
163
+
164
+ while scanner.skip_until(/\{\{/)
165
+ fragment = scanner.scan_until(/\}\}/)
166
+ break unless fragment
167
+
168
+ placeholder = fragment[0...-2]
169
+ next if placeholder.nil? || placeholder.empty? || placeholder.include?("{")
170
+
171
+ placeholders << placeholder
172
+ end
173
+
174
+ placeholders
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../errors"
5
+
6
+ module Aidp
7
+ module Skills
8
+ # Loads skills from SKILL.md files with YAML frontmatter
9
+ #
10
+ # Parses skill files in the format:
11
+ # ---
12
+ # id: skill_id
13
+ # name: Skill Name
14
+ # ...
15
+ # ---
16
+ # # Skill content in markdown
17
+ #
18
+ # @example Loading a skill
19
+ # skill = Loader.load_from_file("/path/to/SKILL.md")
20
+ #
21
+ # @example Loading a skill with provider filtering
22
+ # skill = Loader.load_from_file("/path/to/SKILL.md", provider: "anthropic")
23
+ class Loader
24
+ # Load a skill from a file path
25
+ #
26
+ # @param file_path [String] Path to SKILL.md file
27
+ # @param provider [String, nil] Optional provider name for compatibility check
28
+ # @return [Skill, nil] Loaded skill or nil if incompatible with provider
29
+ # @raise [Aidp::Errors::ValidationError] if file format is invalid
30
+ def self.load_from_file(file_path, provider: nil)
31
+ Aidp.log_debug("skills", "Loading skill from file", file: file_path, provider: provider)
32
+
33
+ unless File.exist?(file_path)
34
+ raise Aidp::Errors::ValidationError, "Skill file not found: #{file_path}"
35
+ end
36
+
37
+ content = File.read(file_path)
38
+ load_from_string(content, source_path: file_path, provider: provider)
39
+ end
40
+
41
+ # Load a skill from a string
42
+ #
43
+ # @param content [String] SKILL.md file content
44
+ # @param source_path [String] Source file path for reference
45
+ # @param provider [String, nil] Optional provider name for compatibility check
46
+ # @return [Skill, nil] Loaded skill or nil if incompatible with provider
47
+ # @raise [Aidp::Errors::ValidationError] if format is invalid
48
+ def self.load_from_string(content, source_path:, provider: nil)
49
+ metadata, markdown = parse_frontmatter(content, source_path: source_path)
50
+
51
+ skill = Skill.new(
52
+ id: metadata["id"],
53
+ name: metadata["name"],
54
+ description: metadata["description"],
55
+ version: metadata["version"],
56
+ expertise: metadata["expertise"] || [],
57
+ keywords: metadata["keywords"] || [],
58
+ when_to_use: metadata["when_to_use"] || [],
59
+ when_not_to_use: metadata["when_not_to_use"] || [],
60
+ compatible_providers: metadata["compatible_providers"] || [],
61
+ content: markdown,
62
+ source_path: source_path
63
+ )
64
+
65
+ # Filter by provider compatibility if specified
66
+ if provider && !skill.compatible_with?(provider)
67
+ Aidp.log_debug(
68
+ "skills",
69
+ "Skipping incompatible skill",
70
+ skill_id: skill.id,
71
+ provider: provider,
72
+ compatible: skill.compatible_providers
73
+ )
74
+ return nil
75
+ end
76
+
77
+ Aidp.log_debug(
78
+ "skills",
79
+ "Loaded skill",
80
+ skill_id: skill.id,
81
+ version: skill.version,
82
+ source: source_path
83
+ )
84
+
85
+ skill
86
+ rescue Aidp::Errors::ValidationError => e
87
+ Aidp.log_error("skills", "Skill validation failed", error: e.message, file: source_path)
88
+ raise
89
+ end
90
+
91
+ # Load all skills from a directory
92
+ #
93
+ # @param directory [String] Path to directory containing skill subdirectories
94
+ # @param provider [String, nil] Optional provider name for compatibility check
95
+ # @return [Array<Skill>] Array of loaded skills (excludes incompatible)
96
+ def self.load_from_directory(directory, provider: nil)
97
+ Aidp.log_debug("skills", "Loading skills from directory", directory: directory, provider: provider)
98
+
99
+ unless Dir.exist?(directory)
100
+ Aidp.log_warn("skills", "Skills directory not found", directory: directory)
101
+ return []
102
+ end
103
+
104
+ skills = []
105
+ skill_dirs = Dir.glob(File.join(directory, "*")).select { |path| File.directory?(path) }
106
+
107
+ skill_dirs.each do |skill_dir|
108
+ skill_file = File.join(skill_dir, "SKILL.md")
109
+ next unless File.exist?(skill_file)
110
+
111
+ begin
112
+ skill = load_from_file(skill_file, provider: provider)
113
+ skills << skill if skill # nil if incompatible with provider
114
+ rescue Aidp::Errors::ValidationError => e
115
+ Aidp.log_warn(
116
+ "skills",
117
+ "Failed to load skill",
118
+ file: skill_file,
119
+ error: e.message
120
+ )
121
+ # Continue loading other skills even if one fails
122
+ end
123
+ end
124
+
125
+ Aidp.log_info(
126
+ "skills",
127
+ "Loaded skills from directory",
128
+ directory: directory,
129
+ count: skills.size
130
+ )
131
+
132
+ skills
133
+ end
134
+
135
+ # Parse YAML frontmatter from content
136
+ #
137
+ # @param content [String] File content with frontmatter
138
+ # @param source_path [String] Source path for error messages
139
+ # @return [Array(Hash, String)] Tuple of [metadata, markdown_content]
140
+ # @raise [Aidp::Errors::ValidationError] if frontmatter is missing or invalid
141
+ def self.parse_frontmatter(content, source_path:)
142
+ lines = content.lines
143
+
144
+ unless lines.first&.strip == "---"
145
+ raise Aidp::Errors::ValidationError,
146
+ "Invalid SKILL.md format: missing YAML frontmatter in #{source_path}"
147
+ end
148
+
149
+ frontmatter_lines = []
150
+ body_start_index = nil
151
+
152
+ lines[1..].each_with_index do |line, index|
153
+ if line.strip == "---"
154
+ body_start_index = index + 2
155
+ break
156
+ end
157
+
158
+ frontmatter_lines << line
159
+ end
160
+
161
+ unless body_start_index
162
+ raise Aidp::Errors::ValidationError,
163
+ "Invalid SKILL.md format: missing closing frontmatter delimiter in #{source_path}"
164
+ end
165
+
166
+ markdown_content = lines[body_start_index..]&.join.to_s.strip
167
+ frontmatter_yaml = frontmatter_lines.join
168
+
169
+ begin
170
+ metadata = YAML.safe_load(frontmatter_yaml, permitted_classes: [Symbol])
171
+ rescue Psych::SyntaxError => e
172
+ raise Aidp::Errors::ValidationError,
173
+ "Invalid YAML frontmatter in #{source_path}: #{e.message}"
174
+ end
175
+
176
+ unless metadata.is_a?(Hash)
177
+ raise Aidp::Errors::ValidationError,
178
+ "YAML frontmatter must be a hash in #{source_path}"
179
+ end
180
+
181
+ validate_required_fields(metadata, source_path: source_path)
182
+
183
+ [metadata, markdown_content]
184
+ end
185
+
186
+ # Validate required frontmatter fields
187
+ #
188
+ # @param metadata [Hash] Parsed YAML metadata
189
+ # @param source_path [String] Source path for error messages
190
+ # @raise [Aidp::Errors::ValidationError] if required fields are missing
191
+ def self.validate_required_fields(metadata, source_path:)
192
+ required_fields = %w[id name description version]
193
+
194
+ required_fields.each do |field|
195
+ next if metadata[field] && !metadata[field].to_s.strip.empty?
196
+
197
+ raise Aidp::Errors::ValidationError,
198
+ "Missing required field '#{field}' in #{source_path}"
199
+ end
200
+ end
201
+
202
+ private_class_method :parse_frontmatter, :validate_required_fields
203
+ end
204
+ end
205
+ end