aidp 0.15.1 → 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 (56) 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 +853 -6
  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 +20 -16
  24. data/lib/aidp/harness/provider_manager.rb +56 -49
  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/init/doc_generator.rb +75 -10
  33. data/lib/aidp/init/project_analyzer.rb +154 -26
  34. data/lib/aidp/init/runner.rb +263 -10
  35. data/lib/aidp/jobs/background_runner.rb +15 -5
  36. data/lib/aidp/logger.rb +11 -0
  37. data/lib/aidp/providers/codex.rb +0 -1
  38. data/lib/aidp/providers/cursor.rb +0 -1
  39. data/lib/aidp/providers/github_copilot.rb +0 -1
  40. data/lib/aidp/providers/opencode.rb +0 -1
  41. data/lib/aidp/skills/composer.rb +178 -0
  42. data/lib/aidp/skills/loader.rb +205 -0
  43. data/lib/aidp/skills/registry.rb +220 -0
  44. data/lib/aidp/skills/skill.rb +174 -0
  45. data/lib/aidp/skills.rb +30 -0
  46. data/lib/aidp/version.rb +1 -1
  47. data/lib/aidp/watch/build_processor.rb +93 -28
  48. data/lib/aidp/watch/runner.rb +3 -2
  49. data/lib/aidp/workstream_executor.rb +244 -0
  50. data/lib/aidp/workstream_state.rb +212 -0
  51. data/lib/aidp/worktree.rb +208 -0
  52. data/lib/aidp.rb +6 -0
  53. metadata +17 -7
  54. data/lib/aidp/analyze/prioritizer.rb +0 -403
  55. data/lib/aidp/analyze/report_generator.rb +0 -582
  56. data/lib/aidp/cli/checkpoint_command.rb +0 -98
@@ -12,20 +12,54 @@ module Aidp
12
12
  class Runner
13
13
  include Aidp::MessageDisplay
14
14
 
15
- def initialize(project_dir = Dir.pwd, prompt: TTY::Prompt.new, analyzer: nil, doc_generator: nil)
15
+ def initialize(project_dir = Dir.pwd, prompt: TTY::Prompt.new, analyzer: nil, doc_generator: nil, options: {})
16
16
  @project_dir = project_dir
17
17
  @prompt = prompt
18
18
  @analyzer = analyzer || ProjectAnalyzer.new(project_dir)
19
19
  @doc_generator = doc_generator || DocGenerator.new(project_dir)
20
+ @options = options
20
21
  end
21
22
 
22
23
  def run
23
24
  display_message("šŸ” Running aidp init project analysis...", type: :info)
24
- analysis = @analyzer.analyze
25
- display_summary(analysis)
25
+ analysis = @analyzer.analyze(explain_detection: @options[:explain_detection])
26
+
27
+ if @options[:explain_detection]
28
+ display_detailed_analysis(analysis)
29
+ else
30
+ display_summary(analysis)
31
+ end
32
+
33
+ # Dry run: skip preferences and generation
34
+ if @options[:dry_run]
35
+ display_message("\nšŸ” Dry run mode - no files will be written.", type: :info)
36
+ return {
37
+ analysis: analysis,
38
+ preferences: {},
39
+ generated_files: []
40
+ }
41
+ end
26
42
 
27
43
  preferences = gather_preferences
28
44
 
45
+ # Offer preview before writing
46
+ if @options[:preview] || ask_yes_no_with_context(
47
+ "Preview generated files before saving?",
48
+ context: "Shows a summary of what will be written to docs/",
49
+ default: false
50
+ )
51
+ preview_generated_docs(analysis, preferences)
52
+
53
+ unless ask_yes_no("Proceed with writing these files?", default: true)
54
+ display_message("\nāŒ Cancelled. No files were written.", type: :info)
55
+ return {
56
+ analysis: analysis,
57
+ preferences: preferences,
58
+ generated_files: []
59
+ }
60
+ end
61
+ end
62
+
29
63
  @doc_generator.generate(analysis: analysis, preferences: preferences)
30
64
 
31
65
  display_message("\nšŸ“„ Generated documentation:", type: :info)
@@ -52,24 +86,170 @@ module Aidp
52
86
  frameworks = analysis[:frameworks]
53
87
  tests = analysis[:test_frameworks]
54
88
  config_files = analysis[:config_files]
89
+ tooling = analysis[:tooling]
90
+
91
+ # Extract high-confidence frameworks (>= 0.7)
92
+ confident_frameworks = frameworks.select { |f| f[:confidence] >= 0.7 }.map { |f| f[:name] }
93
+ uncertain_frameworks = frameworks.select { |f| f[:confidence] < 0.7 }
94
+
95
+ # Extract high-confidence test frameworks (>= 0.7)
96
+ confident_tests = tests.select { |t| t[:confidence] >= 0.7 }.map { |t| t[:name] }
97
+
98
+ # Extract high-confidence tooling (>= 0.7)
99
+ confident_tooling = tooling.select { |t| t[:confidence] >= 0.7 }.map { |t| format_tool(t[:tool]) }
55
100
 
56
101
  display_message("\nšŸ“Š Repository Snapshot", type: :highlight)
57
102
  display_message(" Languages: #{languages.empty? ? "Unknown" : languages.join(", ")}", type: :info)
58
- display_message(" Frameworks: #{frameworks.empty? ? "None detected" : frameworks.join(", ")}", type: :info)
59
- display_message(" Test suites: #{tests.empty? ? "Not found" : tests.join(", ")}", type: :info)
60
- display_message(" Config files: #{config_files.empty? ? "None detected" : config_files.join(", ")}", type: :info)
103
+
104
+ if confident_frameworks.any?
105
+ display_message(" Frameworks: #{confident_frameworks.join(", ")}", type: :info)
106
+ else
107
+ display_message(" Frameworks: None confidently detected", type: :info)
108
+ end
109
+
110
+ if uncertain_frameworks.any?
111
+ uncertain_list = uncertain_frameworks.map { |f| "#{f[:name]} (#{(f[:confidence] * 100).round}%)" }.join(", ")
112
+ display_message(" Possible frameworks: #{uncertain_list}", type: :info)
113
+ end
114
+
115
+ if confident_tests.any?
116
+ display_message(" Test suites: #{confident_tests.join(", ")}", type: :info)
117
+ else
118
+ display_message(" Test suites: Not found", type: :info)
119
+ end
120
+
121
+ if confident_tooling.any?
122
+ display_message(" Quality tools: #{confident_tooling.join(", ")}", type: :info)
123
+ end
124
+
125
+ display_message(" Config files: #{config_files.empty? ? "None detected" : config_files.size} found", type: :info)
126
+ end
127
+
128
+ def display_detailed_analysis(analysis)
129
+ display_message("\nšŸ” Detailed Detection Analysis", type: :highlight)
130
+ display_message("=" * 60, type: :info)
131
+
132
+ # Languages
133
+ display_message("\nšŸ“ Languages (by file size):", type: :highlight)
134
+ if analysis[:languages].any?
135
+ total_size = analysis[:languages].values.sum
136
+ analysis[:languages].each do |lang, size|
137
+ percentage = ((size.to_f / total_size) * 100).round(1)
138
+ display_message(" • #{lang}: #{percentage}%", type: :info)
139
+ end
140
+ else
141
+ display_message(" None detected", type: :info)
142
+ end
143
+
144
+ # Frameworks
145
+ display_message("\nšŸŽÆ Frameworks:", type: :highlight)
146
+ if analysis[:frameworks].any?
147
+ analysis[:frameworks].each do |fw|
148
+ confidence_pct = (fw[:confidence] * 100).round
149
+ display_message(" • #{fw[:name]} (#{confidence_pct}% confidence):", type: :info)
150
+ fw[:evidence].each do |evidence|
151
+ display_message(" - #{evidence}", type: :info)
152
+ end
153
+ end
154
+ else
155
+ display_message(" None detected", type: :info)
156
+ end
157
+
158
+ # Test Frameworks
159
+ display_message("\n🧪 Test Frameworks:", type: :highlight)
160
+ if analysis[:test_frameworks].any?
161
+ analysis[:test_frameworks].each do |test|
162
+ confidence_pct = (test[:confidence] * 100).round
163
+ display_message(" • #{test[:name]} (#{confidence_pct}% confidence):", type: :info)
164
+ test[:evidence].each do |evidence|
165
+ display_message(" - #{evidence}", type: :info)
166
+ end
167
+ end
168
+ else
169
+ display_message(" None detected", type: :info)
170
+ end
171
+
172
+ # Tooling
173
+ display_message("\nšŸ”§ Quality Tooling:", type: :highlight)
174
+ if analysis[:tooling].any?
175
+ analysis[:tooling].each do |tool_data|
176
+ tool_name = format_tool(tool_data[:tool])
177
+ confidence_pct = (tool_data[:confidence] * 100).round
178
+ display_message(" • #{tool_name} (#{confidence_pct}% confidence):", type: :info)
179
+ tool_data[:evidence].each do |evidence|
180
+ display_message(" - #{evidence}", type: :info)
181
+ end
182
+ end
183
+ else
184
+ display_message(" None detected", type: :info)
185
+ end
186
+
187
+ # Key Directories
188
+ display_message("\nšŸ“ Key Directories:", type: :highlight)
189
+ if analysis[:key_directories].any?
190
+ analysis[:key_directories].each do |dir|
191
+ display_message(" • #{dir}", type: :info)
192
+ end
193
+ else
194
+ display_message(" None detected", type: :info)
195
+ end
196
+
197
+ # Config Files
198
+ display_message("\nāš™ļø Configuration Files:", type: :highlight)
199
+ if analysis[:config_files].any?
200
+ analysis[:config_files].each do |file|
201
+ display_message(" • #{file}", type: :info)
202
+ end
203
+ else
204
+ display_message(" None detected", type: :info)
205
+ end
206
+
207
+ # Repository Stats
208
+ display_message("\nšŸ“ˆ Repository Stats:", type: :highlight)
209
+ stats = analysis[:repo_stats]
210
+ display_message(" • Total files: #{stats[:total_files]}", type: :info)
211
+ display_message(" • Total directories: #{stats[:total_directories]}", type: :info)
212
+ display_message(" • Documentation: #{stats[:docs_present] ? "Present" : "Not found"}", type: :info)
213
+ display_message(" • CI/CD config: #{stats[:has_ci_config] ? "Present" : "Not found"}", type: :info)
214
+ display_message(" • Containerization: #{stats[:has_containerization] ? "Present" : "Not found"}", type: :info)
215
+
216
+ display_message("\n" + "=" * 60, type: :info)
217
+ end
218
+
219
+ def format_tool(tool)
220
+ tool.to_s.split("_").map(&:capitalize).join(" ")
61
221
  end
62
222
 
63
223
  def gather_preferences
64
- display_message("\nāš™ļø Customise bootstrap plans (press Enter to accept defaults):", type: :info)
224
+ display_message("\nāš™ļø Configuration Options", type: :highlight)
225
+ display_message("The following questions will help customize the generated documentation.", type: :info)
226
+ display_message("Press Enter to accept defaults shown in brackets.\n", type: :info)
65
227
 
66
228
  {
67
- adopt_new_conventions: ask_yes_no("Adopt the newly generated conventions as canonical defaults?", default: true),
68
- stricter_linters: ask_yes_no("Enforce stricter linting based on detected tools?", default: false),
69
- migrate_styles: ask_yes_no("Plan migrations to align legacy files with the new style guide?", default: false)
229
+ adopt_new_conventions: ask_yes_no_with_context(
230
+ "Make these conventions official for this repository?",
231
+ context: "This saves the detected patterns to LLM_STYLE_GUIDE.md and guides future AI-assisted work.",
232
+ default: true
233
+ ),
234
+ stricter_linters: ask_yes_no_with_context(
235
+ "Enable stricter linting rules in the quality plan?",
236
+ context: "Recommends failing CI on linting violations for better code quality.",
237
+ default: false
238
+ ),
239
+ migrate_styles: ask_yes_no_with_context(
240
+ "Plan gradual migration of legacy code to new style guide?",
241
+ context: "Adds migration tasks to CODE_QUALITY_PLAN.md for incremental improvements.",
242
+ default: false
243
+ )
70
244
  }
71
245
  end
72
246
 
247
+ def ask_yes_no_with_context(question, context:, default:)
248
+ display_message("\n#{question}", type: :info)
249
+ display_message(" ā„¹ļø #{context}", type: :info)
250
+ ask_yes_no(question, default: default)
251
+ end
252
+
73
253
  def ask_yes_no(question, default:)
74
254
  @prompt.yes?(question) do |q|
75
255
  q.default default ? "yes" : "no"
@@ -78,6 +258,79 @@ module Aidp
78
258
  # Compatibility with simplified prompts in tests (e.g. TestPrompt)
79
259
  default
80
260
  end
261
+
262
+ def preview_generated_docs(analysis, preferences)
263
+ display_message("\nšŸ“„ Preview of Generated Documentation", type: :highlight)
264
+ display_message("=" * 60, type: :info)
265
+
266
+ # LLM_STYLE_GUIDE summary
267
+ display_message("\n1. docs/LLM_STYLE_GUIDE.md", type: :highlight)
268
+ confident_frameworks = analysis[:frameworks].select { |f| f[:confidence] >= 0.7 }.map { |f| f[:name] }
269
+ display_message(" - Detected frameworks: #{confident_frameworks.any? ? confident_frameworks.join(", ") : "None"}", type: :info)
270
+ display_message(" - Adoption status: #{preferences[:adopt_new_conventions] ? "Official conventions" : "Optional reference"}", type: :info)
271
+
272
+ # PROJECT_ANALYSIS summary
273
+ display_message("\n2. docs/PROJECT_ANALYSIS.md", type: :highlight)
274
+ display_message(" - Languages: #{analysis[:languages].keys.join(", ")}", type: :info)
275
+ display_message(" - Total frameworks detected: #{analysis[:frameworks].size}", type: :info)
276
+ display_message(" - Test frameworks: #{analysis[:test_frameworks].map { |t| t[:name] }.join(", ") || "None"}", type: :info)
277
+
278
+ # CODE_QUALITY_PLAN summary
279
+ display_message("\n3. docs/CODE_QUALITY_PLAN.md", type: :highlight)
280
+ tooling_count = analysis[:tooling].count { |t| t[:confidence] >= 0.7 }
281
+ display_message(" - Quality tools detected: #{tooling_count}", type: :info)
282
+ display_message(" - Stricter linting: #{preferences[:stricter_linters] ? "Yes" : "No"}", type: :info)
283
+ display_message(" - Migration planning: #{preferences[:migrate_styles] ? "Yes" : "No"}", type: :info)
284
+
285
+ # Validation warnings
286
+ validate_tooling(analysis)
287
+
288
+ display_message("\n" + "=" * 60, type: :info)
289
+ end
290
+
291
+ def validate_tooling(analysis)
292
+ display_message("\nšŸ” Validation", type: :highlight)
293
+
294
+ warnings = []
295
+
296
+ # Check if detected tools actually exist
297
+ analysis[:tooling].select { |t| t[:confidence] >= 0.7 }.each do |tool_data|
298
+ tool_name = tool_data[:tool].to_s
299
+
300
+ # Try to find the tool command
301
+ tool_command = case tool_name
302
+ when "rubocop", "standardrb", "eslint", "prettier", "stylelint", "flake8", "black", "pytest", "jest"
303
+ tool_name
304
+ when "cargo_fmt"
305
+ "cargo"
306
+ when "gofmt"
307
+ "gofmt"
308
+ end
309
+
310
+ next unless tool_command
311
+
312
+ # Check if command exists
313
+ unless system("which #{tool_command} > /dev/null 2>&1")
314
+ warnings << " āš ļø #{format_tool(tool_data[:tool])} detected but command '#{tool_command}' not found in PATH"
315
+ end
316
+ end
317
+
318
+ # Check if test commands can be inferred
319
+ if analysis[:test_frameworks].empty?
320
+ warnings << " āš ļø No test framework detected - consider adding one for better quality assurance"
321
+ end
322
+
323
+ # Check for CI configuration
324
+ unless analysis[:repo_stats][:has_ci_config]
325
+ warnings << " ā„¹ļø No CI configuration detected - consider adding GitHub Actions or similar"
326
+ end
327
+
328
+ if warnings.any?
329
+ warnings.each { |warning| display_message(warning, type: :info) }
330
+ else
331
+ display_message(" āœ… All detected tools validated successfully", type: :success)
332
+ end
333
+ end
81
334
  end
82
335
  end
83
336
  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
 
data/lib/aidp/logger.rb CHANGED
@@ -104,10 +104,21 @@ module Aidp
104
104
  end
105
105
 
106
106
  def create_logger(path)
107
+ # Ensure parent directory exists before creating logger
108
+ dir = File.dirname(path)
109
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
110
+
107
111
  logger = ::Logger.new(path, @max_files, @max_size)
108
112
  logger.level = ::Logger::DEBUG # Control at write level instead
109
113
  logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" }
110
114
  logger
115
+ rescue => e
116
+ # Fall back to STDERR if file logging fails
117
+ warn "[AIDP Logger] Failed to create log file at #{path}: #{e.message}. Falling back to STDERR."
118
+ logger = ::Logger.new($stderr)
119
+ logger.level = ::Logger::DEBUG
120
+ logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" }
121
+ logger
111
122
  end
112
123
 
113
124
  def write_entry(level, component, message, metadata)
@@ -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