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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/skill-sources/ace-task.yml +19 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-task.yml +19 -0
  4. data/.ace-defaults/task/config.yml +25 -0
  5. data/CHANGELOG.md +518 -0
  6. data/README.md +52 -0
  7. data/Rakefile +12 -0
  8. data/exe/ace-task +22 -0
  9. data/handbook/guides/task-definition.g.md +156 -0
  10. data/handbook/skills/as-bug-analyze/SKILL.md +26 -0
  11. data/handbook/skills/as-bug-fix/SKILL.md +27 -0
  12. data/handbook/skills/as-task-document-unplanned/SKILL.md +27 -0
  13. data/handbook/skills/as-task-draft/SKILL.md +24 -0
  14. data/handbook/skills/as-task-finder/SKILL.md +27 -0
  15. data/handbook/skills/as-task-plan/SKILL.md +30 -0
  16. data/handbook/skills/as-task-review/SKILL.md +25 -0
  17. data/handbook/skills/as-task-review-questions/SKILL.md +25 -0
  18. data/handbook/skills/as-task-update/SKILL.md +21 -0
  19. data/handbook/skills/as-task-work/SKILL.md +41 -0
  20. data/handbook/templates/task/draft.template.md +166 -0
  21. data/handbook/templates/task/file-modification-checklist.template.md +26 -0
  22. data/handbook/templates/task/technical-approach.template.md +26 -0
  23. data/handbook/workflow-instructions/bug/analyze.wf.md +458 -0
  24. data/handbook/workflow-instructions/bug/fix.wf.md +512 -0
  25. data/handbook/workflow-instructions/task/document-unplanned.wf.md +222 -0
  26. data/handbook/workflow-instructions/task/draft.wf.md +552 -0
  27. data/handbook/workflow-instructions/task/finder.wf.md +22 -0
  28. data/handbook/workflow-instructions/task/plan.wf.md +489 -0
  29. data/handbook/workflow-instructions/task/review-plan.wf.md +144 -0
  30. data/handbook/workflow-instructions/task/review-questions.wf.md +411 -0
  31. data/handbook/workflow-instructions/task/review-work.wf.md +146 -0
  32. data/handbook/workflow-instructions/task/review.wf.md +351 -0
  33. data/handbook/workflow-instructions/task/update.wf.md +118 -0
  34. data/handbook/workflow-instructions/task/work.wf.md +106 -0
  35. data/lib/ace/task/atoms/task_file_pattern.rb +68 -0
  36. data/lib/ace/task/atoms/task_frontmatter_defaults.rb +46 -0
  37. data/lib/ace/task/atoms/task_id_formatter.rb +62 -0
  38. data/lib/ace/task/atoms/task_validation_rules.rb +51 -0
  39. data/lib/ace/task/cli/commands/create.rb +105 -0
  40. data/lib/ace/task/cli/commands/doctor.rb +206 -0
  41. data/lib/ace/task/cli/commands/list.rb +73 -0
  42. data/lib/ace/task/cli/commands/plan.rb +119 -0
  43. data/lib/ace/task/cli/commands/show.rb +58 -0
  44. data/lib/ace/task/cli/commands/status.rb +77 -0
  45. data/lib/ace/task/cli/commands/update.rb +183 -0
  46. data/lib/ace/task/cli.rb +83 -0
  47. data/lib/ace/task/models/task.rb +46 -0
  48. data/lib/ace/task/molecules/path_utils.rb +20 -0
  49. data/lib/ace/task/molecules/subtask_creator.rb +130 -0
  50. data/lib/ace/task/molecules/task_config_loader.rb +92 -0
  51. data/lib/ace/task/molecules/task_creator.rb +115 -0
  52. data/lib/ace/task/molecules/task_display_formatter.rb +221 -0
  53. data/lib/ace/task/molecules/task_doctor_fixer.rb +510 -0
  54. data/lib/ace/task/molecules/task_doctor_reporter.rb +264 -0
  55. data/lib/ace/task/molecules/task_frontmatter_validator.rb +138 -0
  56. data/lib/ace/task/molecules/task_loader.rb +119 -0
  57. data/lib/ace/task/molecules/task_plan_cache.rb +190 -0
  58. data/lib/ace/task/molecules/task_plan_generator.rb +141 -0
  59. data/lib/ace/task/molecules/task_plan_prompt_builder.rb +91 -0
  60. data/lib/ace/task/molecules/task_reparenter.rb +247 -0
  61. data/lib/ace/task/molecules/task_resolver.rb +115 -0
  62. data/lib/ace/task/molecules/task_scanner.rb +129 -0
  63. data/lib/ace/task/molecules/task_structure_validator.rb +154 -0
  64. data/lib/ace/task/organisms/task_doctor.rb +199 -0
  65. data/lib/ace/task/organisms/task_manager.rb +353 -0
  66. data/lib/ace/task/version.rb +7 -0
  67. data/lib/ace/task.rb +37 -0
  68. 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