ace-task 0.31.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 +7 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-task.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-task.yml +19 -0
- data/.ace-defaults/task/config.yml +25 -0
- data/CHANGELOG.md +518 -0
- data/README.md +52 -0
- data/Rakefile +12 -0
- data/exe/ace-task +22 -0
- data/handbook/guides/task-definition.g.md +156 -0
- data/handbook/skills/as-bug-analyze/SKILL.md +26 -0
- data/handbook/skills/as-bug-fix/SKILL.md +27 -0
- data/handbook/skills/as-task-document-unplanned/SKILL.md +27 -0
- data/handbook/skills/as-task-draft/SKILL.md +24 -0
- data/handbook/skills/as-task-finder/SKILL.md +27 -0
- data/handbook/skills/as-task-plan/SKILL.md +30 -0
- data/handbook/skills/as-task-review/SKILL.md +25 -0
- data/handbook/skills/as-task-review-questions/SKILL.md +25 -0
- data/handbook/skills/as-task-update/SKILL.md +21 -0
- data/handbook/skills/as-task-work/SKILL.md +41 -0
- data/handbook/templates/task/draft.template.md +166 -0
- data/handbook/templates/task/file-modification-checklist.template.md +26 -0
- data/handbook/templates/task/technical-approach.template.md +26 -0
- data/handbook/workflow-instructions/bug/analyze.wf.md +458 -0
- data/handbook/workflow-instructions/bug/fix.wf.md +512 -0
- data/handbook/workflow-instructions/task/document-unplanned.wf.md +222 -0
- data/handbook/workflow-instructions/task/draft.wf.md +552 -0
- data/handbook/workflow-instructions/task/finder.wf.md +22 -0
- data/handbook/workflow-instructions/task/plan.wf.md +489 -0
- data/handbook/workflow-instructions/task/review-plan.wf.md +144 -0
- data/handbook/workflow-instructions/task/review-questions.wf.md +411 -0
- data/handbook/workflow-instructions/task/review-work.wf.md +146 -0
- data/handbook/workflow-instructions/task/review.wf.md +351 -0
- data/handbook/workflow-instructions/task/update.wf.md +118 -0
- data/handbook/workflow-instructions/task/work.wf.md +106 -0
- data/lib/ace/task/atoms/task_file_pattern.rb +68 -0
- data/lib/ace/task/atoms/task_frontmatter_defaults.rb +46 -0
- data/lib/ace/task/atoms/task_id_formatter.rb +62 -0
- data/lib/ace/task/atoms/task_validation_rules.rb +51 -0
- data/lib/ace/task/cli/commands/create.rb +105 -0
- data/lib/ace/task/cli/commands/doctor.rb +206 -0
- data/lib/ace/task/cli/commands/list.rb +73 -0
- data/lib/ace/task/cli/commands/plan.rb +119 -0
- data/lib/ace/task/cli/commands/show.rb +58 -0
- data/lib/ace/task/cli/commands/status.rb +77 -0
- data/lib/ace/task/cli/commands/update.rb +183 -0
- data/lib/ace/task/cli.rb +83 -0
- data/lib/ace/task/models/task.rb +46 -0
- data/lib/ace/task/molecules/path_utils.rb +20 -0
- data/lib/ace/task/molecules/subtask_creator.rb +130 -0
- data/lib/ace/task/molecules/task_config_loader.rb +92 -0
- data/lib/ace/task/molecules/task_creator.rb +115 -0
- data/lib/ace/task/molecules/task_display_formatter.rb +221 -0
- data/lib/ace/task/molecules/task_doctor_fixer.rb +510 -0
- data/lib/ace/task/molecules/task_doctor_reporter.rb +264 -0
- data/lib/ace/task/molecules/task_frontmatter_validator.rb +138 -0
- data/lib/ace/task/molecules/task_loader.rb +119 -0
- data/lib/ace/task/molecules/task_plan_cache.rb +190 -0
- data/lib/ace/task/molecules/task_plan_generator.rb +141 -0
- data/lib/ace/task/molecules/task_plan_prompt_builder.rb +91 -0
- data/lib/ace/task/molecules/task_reparenter.rb +247 -0
- data/lib/ace/task/molecules/task_resolver.rb +115 -0
- data/lib/ace/task/molecules/task_scanner.rb +129 -0
- data/lib/ace/task/molecules/task_structure_validator.rb +154 -0
- data/lib/ace/task/organisms/task_doctor.rb +199 -0
- data/lib/ace/task/organisms/task_manager.rb +353 -0
- data/lib/ace/task/version.rb +7 -0
- data/lib/ace/task.rb +37 -0
- metadata +197 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Task
|
|
7
|
+
module Molecules
|
|
8
|
+
# Formats doctor diagnosis results for terminal, JSON, or summary output.
|
|
9
|
+
class TaskDoctorReporter
|
|
10
|
+
COLORS = {
|
|
11
|
+
red: "\e[31m",
|
|
12
|
+
yellow: "\e[33m",
|
|
13
|
+
green: "\e[32m",
|
|
14
|
+
blue: "\e[34m",
|
|
15
|
+
cyan: "\e[36m",
|
|
16
|
+
reset: "\e[0m",
|
|
17
|
+
bold: "\e[1m"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
ICONS = {
|
|
21
|
+
error: "❌",
|
|
22
|
+
warning: "⚠️",
|
|
23
|
+
info: "ℹ️",
|
|
24
|
+
success: "✅",
|
|
25
|
+
doctor: "🏥",
|
|
26
|
+
stats: "📊",
|
|
27
|
+
search: "🔍",
|
|
28
|
+
fix: "🔧",
|
|
29
|
+
score: "📈"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Format diagnosis results
|
|
33
|
+
# @param results [Hash] Results from TaskDoctor#run_diagnosis
|
|
34
|
+
# @param format [Symbol] Output format (:terminal, :json, :summary)
|
|
35
|
+
# @param verbose [Boolean] Show verbose output
|
|
36
|
+
# @param colors [Boolean] Enable colored output
|
|
37
|
+
# @return [String] Formatted output
|
|
38
|
+
def self.format_results(results, format: :terminal, verbose: false, colors: true)
|
|
39
|
+
case format.to_sym
|
|
40
|
+
when :json
|
|
41
|
+
format_json(results)
|
|
42
|
+
when :summary
|
|
43
|
+
format_summary(results, colors: colors)
|
|
44
|
+
else
|
|
45
|
+
format_terminal(results, verbose: verbose, colors: colors)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Format auto-fix results
|
|
50
|
+
# @param fix_results [Hash] Results from TaskDoctorFixer#fix_issues
|
|
51
|
+
# @param colors [Boolean] Enable colored output
|
|
52
|
+
# @return [String] Formatted fix output
|
|
53
|
+
def self.format_fix_results(fix_results, colors: true)
|
|
54
|
+
output = []
|
|
55
|
+
|
|
56
|
+
output << if fix_results[:dry_run]
|
|
57
|
+
"\n#{colorize("#{ICONS[:search]} DRY RUN MODE", :cyan, colors)} - No changes applied"
|
|
58
|
+
else
|
|
59
|
+
"\n#{colorize("#{ICONS[:fix]} Auto-Fix Applied", :green, colors)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if fix_results[:fixed] > 0
|
|
63
|
+
output << "#{colorize("Fixed:", :green, colors)} #{fix_results[:fixed]} issues"
|
|
64
|
+
|
|
65
|
+
if fix_results[:fixes_applied]&.any?
|
|
66
|
+
output << "\nFixes applied:"
|
|
67
|
+
fix_results[:fixes_applied].each do |fix|
|
|
68
|
+
output << " #{colorize("✓", :green, colors)} #{fix[:description]}"
|
|
69
|
+
output << " #{colorize(fix[:file], :blue, colors)}" if fix[:file]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if fix_results[:skipped] > 0
|
|
75
|
+
output << "#{colorize("Skipped:", :yellow, colors)} #{fix_results[:skipped]} issues (manual fix required)"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
output.join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def format_terminal(results, verbose: false, colors: true)
|
|
85
|
+
output = []
|
|
86
|
+
|
|
87
|
+
# Header
|
|
88
|
+
output << "\n#{colorize("#{ICONS[:doctor]} Task Health Check", :bold, colors)}"
|
|
89
|
+
output << "=" * 40
|
|
90
|
+
|
|
91
|
+
# Stats overview
|
|
92
|
+
if results[:stats]
|
|
93
|
+
output << "\n#{colorize("#{ICONS[:stats]} Overview", :cyan, colors)}"
|
|
94
|
+
output << "-" * 20
|
|
95
|
+
output << " Tasks scanned: #{results[:stats][:tasks_scanned]}"
|
|
96
|
+
output << " Folders checked: #{results[:stats][:folders_checked]}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Issues
|
|
100
|
+
if results[:issues]&.any?
|
|
101
|
+
output << "\n#{colorize("Issues Found:", :yellow, colors)}"
|
|
102
|
+
output << "-" * 20
|
|
103
|
+
output.concat(format_issues(results[:issues], verbose, colors))
|
|
104
|
+
else
|
|
105
|
+
output << "\n#{colorize("#{ICONS[:success]} All tasks healthy", :green, colors)}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Health Score
|
|
109
|
+
output << "\n#{colorize("#{ICONS[:score]} Health Score:", :bold, colors)} #{format_health_score(results[:health_score], colors)}"
|
|
110
|
+
output << "=" * 40
|
|
111
|
+
|
|
112
|
+
# Summary
|
|
113
|
+
if results[:stats]
|
|
114
|
+
output << format_issue_summary(results[:stats], colors)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Duration
|
|
118
|
+
if results[:duration]
|
|
119
|
+
output << "\n#{colorize("Completed in #{format_duration(results[:duration])}", :blue, colors)}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
output.join("\n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_summary(results, colors: true)
|
|
126
|
+
output = []
|
|
127
|
+
|
|
128
|
+
health_status = if results[:health_score] >= 90
|
|
129
|
+
colorize("Excellent", :green, colors)
|
|
130
|
+
elsif results[:health_score] >= 70
|
|
131
|
+
colorize("Good", :yellow, colors)
|
|
132
|
+
elsif results[:health_score] >= 50
|
|
133
|
+
colorize("Fair", :yellow, colors)
|
|
134
|
+
else
|
|
135
|
+
colorize("Poor", :red, colors)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
output << "Health: #{health_status} (#{results[:health_score]}/100)"
|
|
139
|
+
|
|
140
|
+
if results[:stats]
|
|
141
|
+
errors = results[:stats][:errors] || 0
|
|
142
|
+
warnings = results[:stats][:warnings] || 0
|
|
143
|
+
|
|
144
|
+
output << colorize("Errors: #{errors}", :red, colors) if errors > 0
|
|
145
|
+
output << colorize("Warnings: #{warnings}", :yellow, colors) if warnings > 0
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
output.join(" | ")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def format_json(results)
|
|
152
|
+
clean = {
|
|
153
|
+
health_score: results[:health_score],
|
|
154
|
+
valid: results[:valid],
|
|
155
|
+
errors: [],
|
|
156
|
+
warnings: [],
|
|
157
|
+
info: [],
|
|
158
|
+
stats: results[:stats],
|
|
159
|
+
duration: results[:duration],
|
|
160
|
+
root_path: results[:root_path]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if results[:issues]
|
|
164
|
+
results[:issues].each do |issue|
|
|
165
|
+
category = case issue[:type]
|
|
166
|
+
when :error then :errors
|
|
167
|
+
when :warning then :warnings
|
|
168
|
+
else :info
|
|
169
|
+
end
|
|
170
|
+
clean[category] << {
|
|
171
|
+
message: issue[:message],
|
|
172
|
+
location: issue[:location]
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
JSON.pretty_generate(clean)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def format_issues(issues, verbose, colors)
|
|
181
|
+
output = []
|
|
182
|
+
grouped = issues.group_by { |i| i[:type] }
|
|
183
|
+
|
|
184
|
+
if grouped[:error]
|
|
185
|
+
output << "\n#{colorize("#{ICONS[:error]} Errors (#{grouped[:error].size})", :red, colors)}"
|
|
186
|
+
grouped[:error].each_with_index do |issue, i|
|
|
187
|
+
output << format_issue(issue, i + 1, colors)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if grouped[:warning]
|
|
192
|
+
output << "\n#{colorize("#{ICONS[:warning]} Warnings (#{grouped[:warning].size})", :yellow, colors)}"
|
|
193
|
+
if verbose || grouped[:warning].size <= 10
|
|
194
|
+
grouped[:warning].each_with_index do |issue, i|
|
|
195
|
+
output << format_issue(issue, i + 1, colors)
|
|
196
|
+
end
|
|
197
|
+
else
|
|
198
|
+
grouped[:warning].first(5).each_with_index do |issue, i|
|
|
199
|
+
output << format_issue(issue, i + 1, colors)
|
|
200
|
+
end
|
|
201
|
+
output << " ... and #{grouped[:warning].size - 5} more warnings (use --verbose to see all)"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
output
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def format_issue(issue, number, colors)
|
|
209
|
+
location = issue[:location] ? " (#{colorize(issue[:location], :blue, colors)})" : ""
|
|
210
|
+
"#{number}. #{issue[:message]}#{location}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def format_health_score(score, colors)
|
|
214
|
+
color = if score >= 90
|
|
215
|
+
:green
|
|
216
|
+
elsif score >= 70
|
|
217
|
+
:yellow
|
|
218
|
+
else
|
|
219
|
+
:red
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
status = if score >= 90
|
|
223
|
+
"Excellent"
|
|
224
|
+
elsif score >= 70
|
|
225
|
+
"Good"
|
|
226
|
+
elsif score >= 50
|
|
227
|
+
"Fair"
|
|
228
|
+
else
|
|
229
|
+
"Poor"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
"#{colorize("#{score}/100", color, colors)} (#{status})"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def format_issue_summary(stats, colors)
|
|
236
|
+
parts = []
|
|
237
|
+
parts << colorize("#{stats[:errors]} errors", :red, colors) if stats[:errors] > 0
|
|
238
|
+
parts << colorize("#{stats[:warnings]} warnings", :yellow, colors) if stats[:warnings] > 0
|
|
239
|
+
|
|
240
|
+
if parts.empty?
|
|
241
|
+
colorize("No issues found", :green, colors)
|
|
242
|
+
else
|
|
243
|
+
parts.join(", ")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def format_duration(duration)
|
|
248
|
+
if duration < 1
|
|
249
|
+
"#{(duration * 1000).round}ms"
|
|
250
|
+
else
|
|
251
|
+
"#{duration.round(2)}s"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def colorize(text, color, enabled = true)
|
|
256
|
+
return text unless enabled && COLORS[color]
|
|
257
|
+
|
|
258
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "ace/support/items"
|
|
5
|
+
require_relative "../atoms/task_validation_rules"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Task
|
|
9
|
+
module Molecules
|
|
10
|
+
# Validates task spec file frontmatter for correctness and completeness.
|
|
11
|
+
# Returns an array of issue hashes with :type, :message, and :location keys.
|
|
12
|
+
class TaskFrontmatterValidator
|
|
13
|
+
# Validate a single task spec file
|
|
14
|
+
# @param file_path [String] Path to the .s.md file
|
|
15
|
+
# @param special_folder [String, nil] Special folder the task is in
|
|
16
|
+
# @return [Array<Hash>] List of issues found
|
|
17
|
+
def self.validate(file_path, special_folder: nil)
|
|
18
|
+
issues = []
|
|
19
|
+
|
|
20
|
+
unless File.exist?(file_path)
|
|
21
|
+
issues << {type: :error, message: "File does not exist", location: file_path}
|
|
22
|
+
return issues
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
content = File.read(file_path)
|
|
26
|
+
|
|
27
|
+
# Check delimiters
|
|
28
|
+
validate_delimiters(content, file_path, issues)
|
|
29
|
+
return issues if issues.any? { |i| i[:type] == :error }
|
|
30
|
+
|
|
31
|
+
# Parse frontmatter
|
|
32
|
+
frontmatter = parse_frontmatter(content, file_path, issues)
|
|
33
|
+
return issues unless frontmatter
|
|
34
|
+
|
|
35
|
+
# Validate required fields
|
|
36
|
+
validate_required_fields(frontmatter, file_path, issues)
|
|
37
|
+
|
|
38
|
+
# Validate field values
|
|
39
|
+
validate_field_values(frontmatter, file_path, issues)
|
|
40
|
+
|
|
41
|
+
# Validate recommended fields
|
|
42
|
+
validate_recommended_fields(frontmatter, file_path, issues)
|
|
43
|
+
|
|
44
|
+
# Validate scope/status consistency
|
|
45
|
+
validate_scope_consistency(frontmatter, special_folder, file_path, issues)
|
|
46
|
+
|
|
47
|
+
issues
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_delimiters(content, file_path, issues)
|
|
54
|
+
lines = content.lines
|
|
55
|
+
|
|
56
|
+
unless lines.first&.strip == "---"
|
|
57
|
+
issues << {type: :error, message: "Missing opening '---' delimiter", location: file_path}
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Find closing delimiter (skip first line)
|
|
62
|
+
has_closing = lines[1..].any? { |line| line.strip == "---" }
|
|
63
|
+
unless has_closing
|
|
64
|
+
issues << {type: :error, message: "Missing closing '---' delimiter", location: file_path}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_frontmatter(content, file_path, issues)
|
|
69
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
70
|
+
|
|
71
|
+
if frontmatter.nil? || !frontmatter.is_a?(Hash)
|
|
72
|
+
issues << {type: :error, message: "Failed to parse YAML frontmatter", location: file_path}
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
frontmatter
|
|
77
|
+
rescue Psych::SyntaxError => e
|
|
78
|
+
issues << {type: :error, message: "YAML syntax error: #{e.message}", location: file_path}
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_required_fields(frontmatter, file_path, issues)
|
|
83
|
+
missing = Atoms::TaskValidationRules.missing_required_fields(frontmatter)
|
|
84
|
+
|
|
85
|
+
missing.each do |field|
|
|
86
|
+
severity = (field == "title") ? :warning : :error
|
|
87
|
+
issues << {type: severity, message: "Missing required field: #{field}", location: file_path}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_field_values(frontmatter, file_path, issues)
|
|
92
|
+
# Validate ID format
|
|
93
|
+
if frontmatter["id"] && !Atoms::TaskValidationRules.valid_id?(frontmatter["id"].to_s)
|
|
94
|
+
issues << {type: :error, message: "Invalid task ID format: '#{frontmatter["id"]}'", location: file_path}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Validate status value
|
|
98
|
+
if frontmatter["status"] && !Atoms::TaskValidationRules.valid_status?(frontmatter["status"])
|
|
99
|
+
issues << {type: :error, message: "Invalid status value: '#{frontmatter["status"]}'", location: file_path}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Validate tags is an array
|
|
103
|
+
if frontmatter.key?("tags") && !frontmatter["tags"].is_a?(Array)
|
|
104
|
+
issues << {type: :warning, message: "Field 'tags' is not an array", location: file_path}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Validate title length
|
|
108
|
+
title = frontmatter["title"].to_s
|
|
109
|
+
if title.length > Atoms::TaskValidationRules::MAX_TITLE_LENGTH
|
|
110
|
+
issues << {type: :warning, message: "Title exceeds #{Atoms::TaskValidationRules::MAX_TITLE_LENGTH} characters (#{title.length} chars)", location: file_path}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_recommended_fields(frontmatter, file_path, issues)
|
|
115
|
+
missing = Atoms::TaskValidationRules.missing_recommended_fields(frontmatter)
|
|
116
|
+
|
|
117
|
+
missing.each do |field|
|
|
118
|
+
issues << {type: :warning, message: "Missing recommended field: #{field}", location: file_path}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_scope_consistency(frontmatter, special_folder, file_path, issues)
|
|
123
|
+
return unless frontmatter["status"]
|
|
124
|
+
|
|
125
|
+
scope_issues = Atoms::TaskValidationRules.scope_consistent?(
|
|
126
|
+
frontmatter["status"],
|
|
127
|
+
special_folder
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
scope_issues.each do |issue|
|
|
131
|
+
issues << issue.merge(location: file_path)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
require "ace/b36ts"
|
|
5
|
+
require_relative "../atoms/task_file_pattern"
|
|
6
|
+
require_relative "../models/task"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Task
|
|
10
|
+
module Molecules
|
|
11
|
+
# Loads a single task from its directory.
|
|
12
|
+
# Parses the spec file frontmatter and content into a Task model.
|
|
13
|
+
# Detects subtasks from folder co-location.
|
|
14
|
+
class TaskLoader
|
|
15
|
+
# Load a task from a directory
|
|
16
|
+
# @param dir_path [String] Path to the task directory
|
|
17
|
+
# @param id [String] Formatted task ID (e.g., "8pp.t.q7w")
|
|
18
|
+
# @param special_folder [String, nil] Special folder name if applicable
|
|
19
|
+
# @param load_subtasks [Boolean] Whether to scan for subtask directories (default: true)
|
|
20
|
+
# @return [Models::Task] Loaded task
|
|
21
|
+
def load(dir_path, id:, special_folder: nil, load_subtasks: true)
|
|
22
|
+
spec_files = Dir.glob(File.join(dir_path, "*.s.md"))
|
|
23
|
+
return nil if spec_files.empty?
|
|
24
|
+
|
|
25
|
+
# Find primary spec file matching the folder ID
|
|
26
|
+
spec_file = find_primary_spec(spec_files, id) || spec_files.first
|
|
27
|
+
content = File.read(spec_file)
|
|
28
|
+
|
|
29
|
+
# Parse frontmatter
|
|
30
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
31
|
+
|
|
32
|
+
# Extract title from body
|
|
33
|
+
title = Ace::Support::Items::Atoms::TitleExtractor.extract(body) ||
|
|
34
|
+
frontmatter["title"] ||
|
|
35
|
+
File.basename(dir_path)
|
|
36
|
+
|
|
37
|
+
# Decode creation time from raw b36ts
|
|
38
|
+
created_at = decode_created_at(id)
|
|
39
|
+
|
|
40
|
+
# Detect parent_id from frontmatter
|
|
41
|
+
parent_id = frontmatter["parent"]
|
|
42
|
+
|
|
43
|
+
# Load subtasks from co-located directories
|
|
44
|
+
subtasks = if load_subtasks && !parent_id
|
|
45
|
+
load_subtask_dirs(dir_path, id, special_folder)
|
|
46
|
+
else
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Models::Task.new(
|
|
51
|
+
id: id,
|
|
52
|
+
status: frontmatter["status"] || "pending",
|
|
53
|
+
title: title,
|
|
54
|
+
priority: frontmatter["priority"] || "medium",
|
|
55
|
+
estimate: frontmatter["estimate"],
|
|
56
|
+
dependencies: Array(frontmatter["dependencies"]),
|
|
57
|
+
tags: Array(frontmatter["tags"]),
|
|
58
|
+
content: body,
|
|
59
|
+
path: dir_path,
|
|
60
|
+
file_path: spec_file,
|
|
61
|
+
special_folder: special_folder,
|
|
62
|
+
created_at: created_at,
|
|
63
|
+
subtasks: subtasks,
|
|
64
|
+
parent_id: parent_id,
|
|
65
|
+
metadata: frontmatter
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Find the primary spec file matching the task ID (not a subtask file).
|
|
72
|
+
def find_primary_spec(spec_files, folder_id)
|
|
73
|
+
spec_files.find do |f|
|
|
74
|
+
Atoms::TaskFilePattern.primary_file?(File.basename(f), folder_id)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def decode_created_at(id)
|
|
79
|
+
raw_b36ts = Ace::Support::Items::Atoms::ItemIdFormatter.reconstruct(id)
|
|
80
|
+
Ace::B36ts.decode(raw_b36ts)
|
|
81
|
+
rescue
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Scan for subtask directories within the parent task directory.
|
|
86
|
+
def load_subtask_dirs(parent_dir, parent_id, special_folder)
|
|
87
|
+
subtask_dirs = find_subtask_dirs(parent_dir, parent_id)
|
|
88
|
+
subtask_dirs.filter_map do |subtask_id, subtask_dir|
|
|
89
|
+
load(subtask_dir, id: subtask_id, special_folder: special_folder, load_subtasks: false)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Find all subtask directories within a parent directory.
|
|
94
|
+
# Returns array of [subtask_id, dir_path] pairs.
|
|
95
|
+
# Supports short format ("0-slug").
|
|
96
|
+
def find_subtask_dirs(parent_dir, parent_id)
|
|
97
|
+
results = []
|
|
98
|
+
return results unless Dir.exist?(parent_dir)
|
|
99
|
+
|
|
100
|
+
Dir.entries(parent_dir).sort.each do |entry|
|
|
101
|
+
next if entry.start_with?(".")
|
|
102
|
+
|
|
103
|
+
full_path = File.join(parent_dir, entry)
|
|
104
|
+
next unless File.directory?(full_path)
|
|
105
|
+
|
|
106
|
+
# New short format: "0-slug" or "a-slug"
|
|
107
|
+
if (short_match = entry.match(/^([a-z0-9])-/))
|
|
108
|
+
subtask_id = "#{parent_id}.#{short_match[1]}"
|
|
109
|
+
results << [subtask_id, full_path]
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
results
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|