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,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Task
|
|
7
|
+
module Molecules
|
|
8
|
+
# Wraps ShortcutResolver for task-format IDs (9-char formatted IDs like "8pp.t.q7w").
|
|
9
|
+
#
|
|
10
|
+
# Normalizes various reference forms before resolving:
|
|
11
|
+
# "8pp.t.q7w" → full ID lookup (9 chars)
|
|
12
|
+
# "t.q7w" → strip marker, suffix lookup "q7w"
|
|
13
|
+
# "q7w" → bare suffix lookup
|
|
14
|
+
# "8pp.t.q7w.a" → subtask lookup (11 chars: parent + ".{char}")
|
|
15
|
+
class TaskResolver
|
|
16
|
+
# Task formatted IDs are 9 chars: "8pp.t.q7w"
|
|
17
|
+
FULL_ID_LENGTH = 9
|
|
18
|
+
|
|
19
|
+
# Short reference pattern: "t.q7w" → extract suffix "q7w"
|
|
20
|
+
SHORT_REF_PATTERN = /^[a-z]\.([0-9a-z]{3})$/
|
|
21
|
+
|
|
22
|
+
# Subtask reference pattern: "8pp.t.q7w.a" (parent ID + dot + single char)
|
|
23
|
+
SUBTASK_REF_PATTERN = /^([0-9a-z]{3}\.[a-z]\.[0-9a-z]{3})\.([a-z0-9])$/
|
|
24
|
+
|
|
25
|
+
# Short subtask reference: "q7w.a" or "t.q7w.a" (suffix + subtask char)
|
|
26
|
+
SHORT_SUBTASK_REF_PATTERN = /^(?:[a-z]\.)?([0-9a-z]{3})\.([a-z0-9])$/
|
|
27
|
+
|
|
28
|
+
# @param scan_results [Array<ScanResult>] Scan results to resolve against
|
|
29
|
+
def initialize(scan_results)
|
|
30
|
+
@scan_results = scan_results
|
|
31
|
+
@resolver = Ace::Support::Items::Molecules::ShortcutResolver.new(
|
|
32
|
+
scan_results,
|
|
33
|
+
full_id_length: FULL_ID_LENGTH
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve a task reference to a ScanResult.
|
|
38
|
+
# Handles full IDs, shortcuts, and subtask references.
|
|
39
|
+
#
|
|
40
|
+
# @param ref [String] Task reference (full ID, short, suffix, or subtask)
|
|
41
|
+
# @param on_ambiguity [Proc, nil] Called with array of matches on ambiguity
|
|
42
|
+
# @return [ScanResult, nil]
|
|
43
|
+
def resolve(ref, on_ambiguity: nil)
|
|
44
|
+
return nil if ref.nil? || ref.empty?
|
|
45
|
+
|
|
46
|
+
cleaned = ref.strip.downcase
|
|
47
|
+
|
|
48
|
+
# Check for full subtask reference first: "8pp.t.q7w.a"
|
|
49
|
+
if (subtask_match = cleaned.match(SUBTASK_REF_PATTERN))
|
|
50
|
+
return resolve_subtask(subtask_match[1], subtask_match[2])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check for short subtask reference: "q7w.a" or "t.q7w.a"
|
|
54
|
+
if (short_sub = cleaned.match(SHORT_SUBTASK_REF_PATTERN))
|
|
55
|
+
suffix = short_sub[1]
|
|
56
|
+
subtask_char = short_sub[2]
|
|
57
|
+
parent_result = @resolver.resolve(suffix)
|
|
58
|
+
return resolve_subtask(parent_result.id, subtask_char) if parent_result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
normalized = normalize_ref(cleaned)
|
|
62
|
+
@resolver.resolve(normalized, on_ambiguity: on_ambiguity)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def normalize_ref(ref)
|
|
68
|
+
# Check for short ref pattern: "t.q7w" → "q7w"
|
|
69
|
+
if (match = ref.match(SHORT_REF_PATTERN))
|
|
70
|
+
match[1]
|
|
71
|
+
else
|
|
72
|
+
ref
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Resolve a subtask reference by finding the parent's scan result,
|
|
77
|
+
# then looking for the subtask folder within that parent's directory.
|
|
78
|
+
def resolve_subtask(parent_id, subtask_char)
|
|
79
|
+
parent_result = @scan_results.find { |sr| sr.id == parent_id }
|
|
80
|
+
return nil unless parent_result
|
|
81
|
+
|
|
82
|
+
subtask_id = "#{parent_id}.#{subtask_char}"
|
|
83
|
+
|
|
84
|
+
Dir.entries(parent_result.dir_path).sort.each do |entry|
|
|
85
|
+
next if entry.start_with?(".")
|
|
86
|
+
|
|
87
|
+
full_path = File.join(parent_result.dir_path, entry)
|
|
88
|
+
next unless File.directory?(full_path)
|
|
89
|
+
|
|
90
|
+
# Short format: "0-slug" or "a-slug"
|
|
91
|
+
short_match = entry.match(/^([a-z0-9])-(.+)$/)
|
|
92
|
+
next unless short_match
|
|
93
|
+
next unless short_match[1] == subtask_char
|
|
94
|
+
|
|
95
|
+
slug = short_match[2]
|
|
96
|
+
|
|
97
|
+
spec_files = Dir.glob(File.join(full_path, "*.s.md"))
|
|
98
|
+
next if spec_files.empty?
|
|
99
|
+
|
|
100
|
+
return Ace::Support::Items::Models::ScanResult.new(
|
|
101
|
+
id: subtask_id,
|
|
102
|
+
slug: slug,
|
|
103
|
+
folder_name: entry,
|
|
104
|
+
dir_path: full_path,
|
|
105
|
+
file_path: spec_files.first,
|
|
106
|
+
special_folder: parent_result.special_folder
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Task
|
|
7
|
+
module Molecules
|
|
8
|
+
# Wraps DirectoryScanner for task-format directories.
|
|
9
|
+
# Uses a custom id_extractor that matches xxx.t.yyy-slug folder names.
|
|
10
|
+
# Excludes subtask folders from primary scan results.
|
|
11
|
+
class TaskScanner
|
|
12
|
+
attr_reader :last_scan_total, :last_folder_counts
|
|
13
|
+
|
|
14
|
+
# ID extractor for task-format folders: "8pp.t.q7w-fix-login"
|
|
15
|
+
TASK_ID_EXTRACTOR = ->(folder_name) {
|
|
16
|
+
match = folder_name.match(/^([0-9a-z]{3}\.[a-z]\.[0-9a-z]{3})-(.+)$/)
|
|
17
|
+
return nil unless match
|
|
18
|
+
|
|
19
|
+
id = match[1]
|
|
20
|
+
slug = match[2]
|
|
21
|
+
[id, slug]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
FILE_PATTERN = "*.s.md"
|
|
25
|
+
|
|
26
|
+
# @param root_dir [String] Root directory containing tasks (e.g., ".ace-tasks")
|
|
27
|
+
def initialize(root_dir)
|
|
28
|
+
@root_dir = root_dir
|
|
29
|
+
@scanner = Ace::Support::Items::Molecules::DirectoryScanner.new(
|
|
30
|
+
root_dir,
|
|
31
|
+
file_pattern: FILE_PATTERN,
|
|
32
|
+
id_extractor: TASK_ID_EXTRACTOR
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Scan for all primary tasks (excludes subtask folders).
|
|
37
|
+
# @return [Array<ScanResult>] Sorted scan results
|
|
38
|
+
def scan
|
|
39
|
+
@scanner.scan.reject { |sr| subtask_folder?(sr.folder_name) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Scan and filter by special folder or virtual filter
|
|
43
|
+
# @param folder [String, nil] Folder name, virtual filter ("next", "all"), or nil for all
|
|
44
|
+
# @return [Array<ScanResult>] Filtered scan results
|
|
45
|
+
def scan_in_folder(folder)
|
|
46
|
+
results = scan
|
|
47
|
+
@last_scan_total = results.size
|
|
48
|
+
@last_folder_counts = results.group_by(&:special_folder).transform_values(&:size)
|
|
49
|
+
return results if folder.nil?
|
|
50
|
+
|
|
51
|
+
virtual = Ace::Support::Items::Atoms::SpecialFolderDetector.virtual_filter?(folder)
|
|
52
|
+
case virtual
|
|
53
|
+
when :all then results
|
|
54
|
+
when :next then results.select { |r| r.special_folder.nil? }
|
|
55
|
+
else
|
|
56
|
+
normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(folder)
|
|
57
|
+
results.select { |r| r.special_folder == normalized }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Scan for all items including subtask folders.
|
|
62
|
+
# @return [Array<ScanResult>] Sorted scan results
|
|
63
|
+
def scan_all
|
|
64
|
+
@scanner.scan
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Scan for subtask directories within a parent task directory.
|
|
68
|
+
# Subtask folders follow the pattern: {parent_id}.{char}-{slug}
|
|
69
|
+
#
|
|
70
|
+
# @param parent_dir [String] Path to the parent task directory
|
|
71
|
+
# @param parent_id [String] Formatted parent task ID (e.g., "8pp.t.q7w")
|
|
72
|
+
# @return [Array<ScanResult>] Subtask scan results, sorted by ID
|
|
73
|
+
def scan_subtasks(parent_dir, parent_id:)
|
|
74
|
+
return [] unless Dir.exist?(parent_dir)
|
|
75
|
+
|
|
76
|
+
results = []
|
|
77
|
+
Dir.entries(parent_dir).sort.each do |entry|
|
|
78
|
+
next if entry.start_with?(".")
|
|
79
|
+
|
|
80
|
+
full_path = File.join(parent_dir, entry)
|
|
81
|
+
next unless File.directory?(full_path)
|
|
82
|
+
|
|
83
|
+
subtask_id = nil
|
|
84
|
+
slug = nil
|
|
85
|
+
|
|
86
|
+
# Short format: "0-slug" or "a-slug"
|
|
87
|
+
if (short_match = entry.match(/^([a-z0-9])-(.+)$/))
|
|
88
|
+
subtask_id = "#{parent_id}.#{short_match[1]}"
|
|
89
|
+
slug = short_match[2]
|
|
90
|
+
else
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
spec_files = Dir.glob(File.join(full_path, FILE_PATTERN))
|
|
95
|
+
next if spec_files.empty?
|
|
96
|
+
|
|
97
|
+
special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
|
|
98
|
+
full_path, root: @root_dir
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
results << Ace::Support::Items::Models::ScanResult.new(
|
|
102
|
+
id: subtask_id,
|
|
103
|
+
slug: slug,
|
|
104
|
+
folder_name: entry,
|
|
105
|
+
dir_path: full_path,
|
|
106
|
+
file_path: spec_files.first,
|
|
107
|
+
special_folder: special_folder
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
results.sort_by(&:id)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if root directory exists
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def root_exists?
|
|
117
|
+
Dir.exist?(@root_dir)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# Check if a folder name matches the subtask pattern
|
|
123
|
+
def subtask_folder?(folder_name)
|
|
124
|
+
folder_name.match?(/\A[a-z0-9]-/)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/task_file_pattern"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Task
|
|
7
|
+
module Molecules
|
|
8
|
+
# Validates the directory structure of a tasks root directory.
|
|
9
|
+
# Checks folder naming, file naming, and structural conventions.
|
|
10
|
+
class TaskStructureValidator
|
|
11
|
+
# Task folder naming pattern: {xxx.t.yyy}-{slug}
|
|
12
|
+
FOLDER_PATTERN = /^[0-9a-z]{3}\.[a-z]\.[0-9a-z]{3}-.+$/
|
|
13
|
+
|
|
14
|
+
# @param root_dir [String] Root directory for tasks (e.g., ".ace-tasks")
|
|
15
|
+
def initialize(root_dir)
|
|
16
|
+
@root_dir = root_dir
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Validate the entire tasks directory structure
|
|
20
|
+
# @return [Array<Hash>] List of issues found
|
|
21
|
+
def validate(root_dir = @root_dir)
|
|
22
|
+
issues = []
|
|
23
|
+
|
|
24
|
+
unless Dir.exist?(root_dir)
|
|
25
|
+
issues << {type: :error, message: "Tasks root directory does not exist", location: root_dir}
|
|
26
|
+
return issues
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
check_folder_naming(root_dir, issues)
|
|
30
|
+
check_spec_files(root_dir, issues)
|
|
31
|
+
check_stale_backups(root_dir, issues)
|
|
32
|
+
check_empty_directories(root_dir, issues)
|
|
33
|
+
|
|
34
|
+
issues
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Check that task folders follow {xxx.t.yyy}-{slug} naming
|
|
40
|
+
def check_folder_naming(root_dir, issues)
|
|
41
|
+
task_dirs(root_dir).each do |dir|
|
|
42
|
+
folder_name = File.basename(dir)
|
|
43
|
+
|
|
44
|
+
next if folder_name.start_with?("_")
|
|
45
|
+
|
|
46
|
+
unless folder_name.match?(FOLDER_PATTERN)
|
|
47
|
+
issues << {
|
|
48
|
+
type: :error,
|
|
49
|
+
message: "Folder name does not match '{id}-{slug}' convention: '#{folder_name}'",
|
|
50
|
+
location: dir
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check that each task folder has exactly one .s.md spec file (excluding .idea.s.md)
|
|
57
|
+
def check_spec_files(root_dir, issues)
|
|
58
|
+
task_dirs(root_dir).each do |dir|
|
|
59
|
+
folder_name = File.basename(dir)
|
|
60
|
+
next if folder_name.start_with?("_")
|
|
61
|
+
|
|
62
|
+
spec_files = Dir.glob(File.join(dir, Atoms::TaskFilePattern::SPEC_PATTERN))
|
|
63
|
+
.reject { |f| f.end_with?(".idea.s.md") }
|
|
64
|
+
|
|
65
|
+
if spec_files.empty?
|
|
66
|
+
issues << {
|
|
67
|
+
type: :warning,
|
|
68
|
+
message: "No .s.md spec file in task folder",
|
|
69
|
+
location: dir
|
|
70
|
+
}
|
|
71
|
+
elsif spec_files.size > 1
|
|
72
|
+
issues << {
|
|
73
|
+
type: :warning,
|
|
74
|
+
message: "Multiple .s.md spec files in folder (#{spec_files.size} found)",
|
|
75
|
+
location: dir
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check for stale backup/tmp files
|
|
82
|
+
def check_stale_backups(root_dir, issues)
|
|
83
|
+
backup_patterns = [
|
|
84
|
+
File.join(root_dir, "**", "*.backup.*"),
|
|
85
|
+
File.join(root_dir, "**", "*.tmp"),
|
|
86
|
+
File.join(root_dir, "**", "*~")
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
backup_patterns.each do |pattern|
|
|
90
|
+
Dir.glob(pattern).each do |file|
|
|
91
|
+
next if file.include?("/.git/")
|
|
92
|
+
|
|
93
|
+
issues << {
|
|
94
|
+
type: :warning,
|
|
95
|
+
message: "Stale backup file (safe to delete)",
|
|
96
|
+
location: file
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check for empty directories
|
|
103
|
+
def check_empty_directories(root_dir, issues)
|
|
104
|
+
Dir.glob(File.join(root_dir, "**", "*")).each do |path|
|
|
105
|
+
next unless File.directory?(path)
|
|
106
|
+
next if path.include?("/.git/")
|
|
107
|
+
|
|
108
|
+
files = Dir.glob(File.join(path, "**", "*")).select { |f| File.file?(f) }
|
|
109
|
+
if files.empty?
|
|
110
|
+
issues << {
|
|
111
|
+
type: :warning,
|
|
112
|
+
message: "Empty directory (safe to delete)",
|
|
113
|
+
location: path
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Find all immediate subdirectories that look like task folders
|
|
120
|
+
# (excludes special folders which are containers, includes their children)
|
|
121
|
+
def task_dirs(root_dir)
|
|
122
|
+
dirs = []
|
|
123
|
+
|
|
124
|
+
Dir.glob(File.join(root_dir, "*")).each do |path|
|
|
125
|
+
next unless File.directory?(path)
|
|
126
|
+
|
|
127
|
+
folder_name = File.basename(path)
|
|
128
|
+
if folder_name.start_with?("_")
|
|
129
|
+
# Recurse into special folders
|
|
130
|
+
Dir.glob(File.join(path, "*")).each do |subpath|
|
|
131
|
+
dirs << subpath if File.directory?(subpath) && !category_folder?(subpath)
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
dirs << path unless category_folder?(path)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
dirs
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check if a folder is a category folder (only contains subdirectories, no files)
|
|
142
|
+
def category_folder?(dir_path)
|
|
143
|
+
return false unless Dir.exist?(dir_path)
|
|
144
|
+
|
|
145
|
+
files = Dir.glob(File.join(dir_path, "*")).select { |f| File.file?(f) }
|
|
146
|
+
return false if files.any?
|
|
147
|
+
|
|
148
|
+
subdirs = Dir.glob(File.join(dir_path, "*")).select { |f| File.directory?(f) }
|
|
149
|
+
subdirs.any?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../molecules/task_scanner"
|
|
4
|
+
require_relative "../molecules/task_frontmatter_validator"
|
|
5
|
+
require_relative "../molecules/task_structure_validator"
|
|
6
|
+
require_relative "../atoms/task_validation_rules"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Task
|
|
10
|
+
module Organisms
|
|
11
|
+
# Orchestrates comprehensive health checks for the tasks system.
|
|
12
|
+
# Runs structure validation, frontmatter validation, and scope/status
|
|
13
|
+
# consistency checks across all tasks in a root directory.
|
|
14
|
+
class TaskDoctor
|
|
15
|
+
attr_reader :root_path, :options
|
|
16
|
+
|
|
17
|
+
# @param root_path [String] Path to tasks root directory
|
|
18
|
+
# @param options [Hash] Diagnosis options (:check, :verbose, etc.)
|
|
19
|
+
def initialize(root_path, options = {})
|
|
20
|
+
@root_path = root_path
|
|
21
|
+
@options = options
|
|
22
|
+
@issues = []
|
|
23
|
+
@stats = {
|
|
24
|
+
tasks_scanned: 0,
|
|
25
|
+
folders_checked: 0,
|
|
26
|
+
errors: 0,
|
|
27
|
+
warnings: 0,
|
|
28
|
+
info: 0
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Run comprehensive health check
|
|
33
|
+
# @return [Hash] Diagnosis results
|
|
34
|
+
def run_diagnosis
|
|
35
|
+
unless @root_path && Dir.exist?(@root_path)
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
health_score: 0,
|
|
39
|
+
issues: [{type: :error, message: "Tasks root directory not found: #{@root_path}"}],
|
|
40
|
+
stats: @stats,
|
|
41
|
+
duration: 0,
|
|
42
|
+
root_path: @root_path
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@start_time = Time.now
|
|
47
|
+
|
|
48
|
+
if options[:check]
|
|
49
|
+
run_specific_check(options[:check])
|
|
50
|
+
else
|
|
51
|
+
run_full_check
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
health_score = calculate_health_score
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
valid: @stats[:errors] == 0,
|
|
58
|
+
health_score: health_score,
|
|
59
|
+
issues: @issues,
|
|
60
|
+
stats: @stats,
|
|
61
|
+
duration: Time.now - @start_time,
|
|
62
|
+
root_path: @root_path
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if an issue can be auto-fixed
|
|
67
|
+
# @param issue [Hash] Issue to check
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def auto_fixable?(issue)
|
|
70
|
+
return false unless issue[:type] == :error || issue[:type] == :warning
|
|
71
|
+
|
|
72
|
+
Molecules::TaskDoctorFixer::FIXABLE_PATTERNS.any? { |pattern| issue[:message].match?(pattern) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def run_full_check
|
|
78
|
+
run_structure_check
|
|
79
|
+
run_frontmatter_check
|
|
80
|
+
run_scope_check
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def run_specific_check(check_type)
|
|
84
|
+
case check_type.to_s
|
|
85
|
+
when "structure"
|
|
86
|
+
run_structure_check
|
|
87
|
+
when "frontmatter"
|
|
88
|
+
run_frontmatter_check
|
|
89
|
+
when "scope"
|
|
90
|
+
run_scope_check
|
|
91
|
+
else
|
|
92
|
+
add_issue(:error, "Unknown check type: #{check_type}")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_structure_check
|
|
97
|
+
validator = Molecules::TaskStructureValidator.new(@root_path)
|
|
98
|
+
issues = validator.validate
|
|
99
|
+
|
|
100
|
+
issues.each do |issue|
|
|
101
|
+
add_issue(issue[:type], issue[:message], issue[:location])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@stats[:folders_checked] = count_task_folders
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_frontmatter_check
|
|
108
|
+
scanner = Molecules::TaskScanner.new(@root_path)
|
|
109
|
+
return unless scanner.root_exists?
|
|
110
|
+
|
|
111
|
+
scan_results = scanner.scan
|
|
112
|
+
@stats[:tasks_scanned] = scan_results.size
|
|
113
|
+
|
|
114
|
+
scan_results.each do |scan_result|
|
|
115
|
+
spec_file = scan_result.file_path
|
|
116
|
+
next unless spec_file && File.exist?(spec_file)
|
|
117
|
+
|
|
118
|
+
issues = Molecules::TaskFrontmatterValidator.validate(
|
|
119
|
+
spec_file,
|
|
120
|
+
special_folder: scan_result.special_folder
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Filter out scope issues here (handled separately in run_scope_check)
|
|
124
|
+
issues.reject! { |i| scope_issue?(i[:message]) }
|
|
125
|
+
|
|
126
|
+
issues.each do |issue|
|
|
127
|
+
add_issue(issue[:type], issue[:message], issue[:location])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def run_scope_check
|
|
133
|
+
scanner = Molecules::TaskScanner.new(@root_path)
|
|
134
|
+
return unless scanner.root_exists?
|
|
135
|
+
|
|
136
|
+
scan_results = scanner.scan
|
|
137
|
+
|
|
138
|
+
scan_results.each do |scan_result|
|
|
139
|
+
spec_file = scan_result.file_path
|
|
140
|
+
next unless spec_file && File.exist?(spec_file)
|
|
141
|
+
|
|
142
|
+
content = File.read(spec_file)
|
|
143
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
144
|
+
next unless frontmatter.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
status = frontmatter["status"]
|
|
147
|
+
special_folder = scan_result.special_folder
|
|
148
|
+
|
|
149
|
+
scope_issues = Atoms::TaskValidationRules.scope_consistent?(status, special_folder)
|
|
150
|
+
scope_issues.each do |issue|
|
|
151
|
+
add_issue(issue[:type], issue[:message], spec_file)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def scope_issue?(message)
|
|
157
|
+
message.include?("not in _archive") ||
|
|
158
|
+
message.include?("in _archive/ but status") ||
|
|
159
|
+
message.include?("in _maybe/ with terminal")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def count_task_folders
|
|
163
|
+
return 0 unless Dir.exist?(@root_path)
|
|
164
|
+
|
|
165
|
+
count = 0
|
|
166
|
+
Dir.glob(File.join(@root_path, "*")).each do |path|
|
|
167
|
+
next unless File.directory?(path)
|
|
168
|
+
|
|
169
|
+
count += if File.basename(path).start_with?("_")
|
|
170
|
+
Dir.glob(File.join(path, "*")).count { |p| File.directory?(p) }
|
|
171
|
+
else
|
|
172
|
+
1
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
count
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def add_issue(type, message, location = nil)
|
|
179
|
+
issue = {type: type, message: message}
|
|
180
|
+
issue[:location] = location if location
|
|
181
|
+
@issues << issue
|
|
182
|
+
|
|
183
|
+
case type
|
|
184
|
+
when :error then @stats[:errors] += 1
|
|
185
|
+
when :warning then @stats[:warnings] += 1
|
|
186
|
+
when :info then @stats[:info] += 1
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def calculate_health_score
|
|
191
|
+
score = 100
|
|
192
|
+
score -= @stats[:errors] * 10
|
|
193
|
+
score -= @stats[:warnings] * 2
|
|
194
|
+
[[score, 0].max, 100].min
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|