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.
- checksums.yaml +4 -4
- data/README.md +47 -0
- data/lib/aidp/analyze/error_handler.rb +14 -15
- data/lib/aidp/analyze/runner.rb +27 -5
- data/lib/aidp/analyze/steps.rb +4 -0
- data/lib/aidp/cli/jobs_command.rb +2 -1
- data/lib/aidp/cli.rb +812 -3
- data/lib/aidp/concurrency/backoff.rb +148 -0
- data/lib/aidp/concurrency/exec.rb +192 -0
- data/lib/aidp/concurrency/wait.rb +148 -0
- data/lib/aidp/concurrency.rb +71 -0
- data/lib/aidp/config.rb +20 -0
- data/lib/aidp/daemon/runner.rb +9 -8
- data/lib/aidp/debug_mixin.rb +1 -0
- data/lib/aidp/errors.rb +12 -0
- data/lib/aidp/execute/interactive_repl.rb +102 -11
- data/lib/aidp/execute/repl_macros.rb +776 -2
- data/lib/aidp/execute/runner.rb +27 -5
- data/lib/aidp/execute/steps.rb +2 -0
- data/lib/aidp/harness/config_loader.rb +24 -2
- data/lib/aidp/harness/enhanced_runner.rb +16 -2
- data/lib/aidp/harness/error_handler.rb +1 -1
- data/lib/aidp/harness/provider_info.rb +19 -15
- data/lib/aidp/harness/provider_manager.rb +47 -41
- data/lib/aidp/harness/runner.rb +3 -11
- data/lib/aidp/harness/state/persistence.rb +1 -6
- data/lib/aidp/harness/state_manager.rb +115 -7
- data/lib/aidp/harness/status_display.rb +11 -18
- data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
- data/lib/aidp/harness/ui/workflow_controller.rb +1 -1
- data/lib/aidp/harness/user_interface.rb +12 -15
- data/lib/aidp/jobs/background_runner.rb +15 -5
- data/lib/aidp/providers/codex.rb +0 -1
- data/lib/aidp/providers/cursor.rb +0 -1
- data/lib/aidp/providers/github_copilot.rb +0 -1
- data/lib/aidp/providers/opencode.rb +0 -1
- data/lib/aidp/skills/composer.rb +178 -0
- data/lib/aidp/skills/loader.rb +205 -0
- data/lib/aidp/skills/registry.rb +220 -0
- data/lib/aidp/skills/skill.rb +174 -0
- data/lib/aidp/skills.rb +30 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +93 -28
- data/lib/aidp/watch/runner.rb +3 -2
- data/lib/aidp/workstream_executor.rb +244 -0
- data/lib/aidp/workstream_state.rb +212 -0
- data/lib/aidp/worktree.rb +208 -0
- data/lib/aidp.rb +6 -0
- 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 @
|
2059
|
+
return if @control_future&.pending?
|
2060
2060
|
|
2061
|
-
# Start control interface using
|
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 "
|
2064
|
-
|
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 @
|
2083
|
-
@
|
2084
|
-
@
|
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
|
-
|
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
|
-
|
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
|
-
|
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/providers/codex.rb
CHANGED
@@ -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
|