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,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