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,510 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require "ace/support/markdown"
|
|
6
|
+
require "ace/support/items"
|
|
7
|
+
require_relative "../atoms/task_id_formatter"
|
|
8
|
+
require_relative "../atoms/task_validation_rules"
|
|
9
|
+
require_relative "../atoms/task_frontmatter_defaults"
|
|
10
|
+
require_relative "task_scanner"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Task
|
|
14
|
+
module Molecules
|
|
15
|
+
# Handles auto-fixing of common task issues detected by doctor.
|
|
16
|
+
# Supports dry_run mode to preview fixes without applying them.
|
|
17
|
+
class TaskDoctorFixer
|
|
18
|
+
attr_reader :dry_run, :fixed_count, :skipped_count
|
|
19
|
+
|
|
20
|
+
def initialize(dry_run: false, root_dir: nil)
|
|
21
|
+
@dry_run = dry_run
|
|
22
|
+
@root_dir = root_dir
|
|
23
|
+
@fixed_count = 0
|
|
24
|
+
@skipped_count = 0
|
|
25
|
+
@fixes_applied = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Fix a batch of issues
|
|
29
|
+
# @param issues [Array<Hash>] Issues to fix
|
|
30
|
+
# @return [Hash] Fix results summary
|
|
31
|
+
def fix_issues(issues)
|
|
32
|
+
fixable_issues = issues.select { |issue| can_fix?(issue) }
|
|
33
|
+
|
|
34
|
+
fixable_issues.each do |issue|
|
|
35
|
+
fix_issue(issue)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
fixed: @fixed_count,
|
|
40
|
+
skipped: @skipped_count,
|
|
41
|
+
fixes_applied: @fixes_applied,
|
|
42
|
+
dry_run: @dry_run
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fix a single issue by pattern matching its message
|
|
47
|
+
# @param issue [Hash] Issue to fix
|
|
48
|
+
# @return [Boolean] Whether fix was successful
|
|
49
|
+
def fix_issue(issue)
|
|
50
|
+
case issue[:message]
|
|
51
|
+
when /Missing opening '---' delimiter/
|
|
52
|
+
fix_missing_opening_delimiter(issue[:location])
|
|
53
|
+
when /Missing closing '---' delimiter/
|
|
54
|
+
fix_missing_closing_delimiter(issue[:location])
|
|
55
|
+
when /Missing required field: id/
|
|
56
|
+
fix_missing_id(issue[:location])
|
|
57
|
+
when /Missing required field: status/,
|
|
58
|
+
/Missing recommended field: status/
|
|
59
|
+
fix_missing_status(issue[:location])
|
|
60
|
+
when /Missing required field: title/,
|
|
61
|
+
/Missing recommended field: title/
|
|
62
|
+
fix_missing_title(issue[:location])
|
|
63
|
+
when /Field 'tags' is not an array/
|
|
64
|
+
fix_tags_not_array(issue[:location])
|
|
65
|
+
when /Missing recommended field: tags/
|
|
66
|
+
fix_missing_tags(issue[:location])
|
|
67
|
+
when /Missing recommended field: created_at/
|
|
68
|
+
fix_missing_created_at(issue[:location])
|
|
69
|
+
when /terminal status.*not in _archive/
|
|
70
|
+
fix_move_to_archive(issue[:location])
|
|
71
|
+
when /in _archive\/ but status is/
|
|
72
|
+
fix_archive_status(issue[:location])
|
|
73
|
+
when /in _maybe\/ with terminal status/
|
|
74
|
+
fix_maybe_terminal(issue[:location])
|
|
75
|
+
when /Stale backup file/
|
|
76
|
+
fix_stale_backup(issue[:location])
|
|
77
|
+
when /Empty directory/
|
|
78
|
+
fix_empty_directory(issue[:location])
|
|
79
|
+
when /Folder name does not match '\{id\}-\{slug\}' convention/
|
|
80
|
+
fix_folder_naming(issue[:location])
|
|
81
|
+
else
|
|
82
|
+
@skipped_count += 1
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if an issue can be auto-fixed
|
|
88
|
+
# @param issue [Hash] Issue to check
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def can_fix?(issue)
|
|
91
|
+
return false unless issue[:location]
|
|
92
|
+
|
|
93
|
+
FIXABLE_PATTERNS.any? { |pattern| issue[:message].match?(pattern) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
FIXABLE_PATTERNS = [
|
|
99
|
+
/Missing opening '---' delimiter/,
|
|
100
|
+
/Missing closing '---' delimiter/,
|
|
101
|
+
/Missing required field: id/,
|
|
102
|
+
/Missing required field: status/,
|
|
103
|
+
/Missing required field: title/,
|
|
104
|
+
/Missing recommended field: status/,
|
|
105
|
+
/Missing recommended field: title/,
|
|
106
|
+
/Missing recommended field: tags/,
|
|
107
|
+
/Missing recommended field: created_at/,
|
|
108
|
+
/Field 'tags' is not an array/,
|
|
109
|
+
/terminal status.*not in _archive/,
|
|
110
|
+
/in _archive\/ but status is/,
|
|
111
|
+
/in _maybe\/ with terminal status/,
|
|
112
|
+
/Stale backup file/,
|
|
113
|
+
/Empty directory/,
|
|
114
|
+
/Folder name does not match '\{id\}-\{slug\}' convention/
|
|
115
|
+
].freeze
|
|
116
|
+
|
|
117
|
+
def fix_missing_closing_delimiter(file_path)
|
|
118
|
+
return false unless File.exist?(file_path)
|
|
119
|
+
|
|
120
|
+
content = File.read(file_path)
|
|
121
|
+
lines = content.lines
|
|
122
|
+
insert_idx = nil
|
|
123
|
+
lines[1..].each_with_index do |line, i|
|
|
124
|
+
if line.strip.empty? || line.start_with?("#")
|
|
125
|
+
insert_idx = i + 1
|
|
126
|
+
break
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
insert_idx ||= lines.size
|
|
130
|
+
|
|
131
|
+
fixed_lines = lines.dup
|
|
132
|
+
fixed_lines.insert(insert_idx, "---\n")
|
|
133
|
+
fixed_content = fixed_lines.join
|
|
134
|
+
|
|
135
|
+
apply_file_fix(file_path, fixed_content, "Added missing closing '---' delimiter")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fix_missing_id(file_path)
|
|
139
|
+
return false unless File.exist?(file_path)
|
|
140
|
+
|
|
141
|
+
# Extract ID from folder name using task pattern (xxx.t.yyy)
|
|
142
|
+
dir_name = File.basename(File.dirname(file_path))
|
|
143
|
+
id_and_slug = TaskScanner::TASK_ID_EXTRACTOR.call(dir_name)
|
|
144
|
+
unless id_and_slug
|
|
145
|
+
return (@skipped_count += 1
|
|
146
|
+
false)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
id = id_and_slug[0]
|
|
150
|
+
update_frontmatter_field(file_path, "id", id, "Added missing 'id' field from folder name")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def fix_missing_status(file_path)
|
|
154
|
+
update_frontmatter_field(file_path, "status", "pending", "Added missing 'status' field with default 'pending'")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fix_missing_title(file_path)
|
|
158
|
+
return false unless File.exist?(file_path)
|
|
159
|
+
|
|
160
|
+
content = File.read(file_path)
|
|
161
|
+
# Try to extract title from body H1
|
|
162
|
+
title = nil
|
|
163
|
+
_fm, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
164
|
+
if body
|
|
165
|
+
h1_match = body.match(/^#\s+(.+)/)
|
|
166
|
+
title = h1_match[1].strip if h1_match
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Fallback: extract from folder slug
|
|
170
|
+
unless title
|
|
171
|
+
dir_name = File.basename(File.dirname(file_path))
|
|
172
|
+
id_and_slug = TaskScanner::TASK_ID_EXTRACTOR.call(dir_name)
|
|
173
|
+
title = if id_and_slug
|
|
174
|
+
id_and_slug[1].tr("-", " ").capitalize
|
|
175
|
+
else
|
|
176
|
+
"Untitled"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
update_frontmatter_field(file_path, "title", title, "Added missing 'title' field: '#{title}'")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def fix_tags_not_array(file_path)
|
|
184
|
+
update_frontmatter_field(file_path, "tags", [], "Coerced 'tags' field to empty array")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def fix_missing_tags(file_path)
|
|
188
|
+
update_frontmatter_field(file_path, "tags", [], "Added missing 'tags' field with empty array")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def fix_missing_created_at(file_path)
|
|
192
|
+
return false unless File.exist?(file_path)
|
|
193
|
+
|
|
194
|
+
content = File.read(file_path)
|
|
195
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
196
|
+
|
|
197
|
+
id = frontmatter&.dig("id")
|
|
198
|
+
unless id
|
|
199
|
+
dir_name = File.basename(File.dirname(file_path))
|
|
200
|
+
id_and_slug = TaskScanner::TASK_ID_EXTRACTOR.call(dir_name)
|
|
201
|
+
id = id_and_slug[0] if id_and_slug
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
created_at = if id && Atoms::TaskValidationRules.valid_id?(id)
|
|
205
|
+
raw = Atoms::TaskIdFormatter.reconstruct(id)
|
|
206
|
+
Ace::B36ts.decode(raw).strftime("%Y-%m-%d %H:%M:%S")
|
|
207
|
+
else
|
|
208
|
+
Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
update_frontmatter_field(file_path, "created_at", created_at, "Added missing 'created_at' field decoded from ID")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def fix_move_to_archive(file_path)
|
|
215
|
+
return false unless file_path && @root_dir
|
|
216
|
+
|
|
217
|
+
move_issue_to_archive(file_path, from_maybe: false)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def fix_archive_status(file_path)
|
|
221
|
+
update_frontmatter_field(file_path, "status", "done", "Updated status to 'done' (in _archive/)")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def fix_maybe_terminal(file_path)
|
|
225
|
+
return false unless file_path && @root_dir
|
|
226
|
+
|
|
227
|
+
move_issue_to_archive(file_path, from_maybe: true)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
BACKUP_EXTENSIONS = /\.(backup\.\w+|tmp)$|~$/
|
|
231
|
+
|
|
232
|
+
def fix_stale_backup(file_path)
|
|
233
|
+
return false unless file_path && File.exist?(file_path)
|
|
234
|
+
unless File.basename(file_path).match?(BACKUP_EXTENSIONS)
|
|
235
|
+
@skipped_count += 1
|
|
236
|
+
return false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
if @dry_run
|
|
240
|
+
log_fix(file_path, "Would delete stale backup file")
|
|
241
|
+
else
|
|
242
|
+
File.delete(file_path)
|
|
243
|
+
log_fix(file_path, "Deleted stale backup file")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
@fixed_count += 1
|
|
247
|
+
true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def fix_empty_directory(dir_path)
|
|
251
|
+
return false unless dir_path && Dir.exist?(dir_path)
|
|
252
|
+
|
|
253
|
+
# Safety: only remove if truly empty
|
|
254
|
+
files = Dir.glob(File.join(dir_path, "**", "*")).select { |f| File.file?(f) }
|
|
255
|
+
unless files.empty?
|
|
256
|
+
@skipped_count += 1
|
|
257
|
+
return false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
if @dry_run
|
|
261
|
+
log_fix(dir_path, "Would delete empty directory")
|
|
262
|
+
else
|
|
263
|
+
FileUtils.rm_rf(dir_path)
|
|
264
|
+
log_fix(dir_path, "Deleted empty directory")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
@fixed_count += 1
|
|
268
|
+
true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def fix_missing_opening_delimiter(file_path)
|
|
272
|
+
return false unless File.exist?(file_path)
|
|
273
|
+
|
|
274
|
+
content = File.read(file_path)
|
|
275
|
+
|
|
276
|
+
# Extract ID from folder name
|
|
277
|
+
dir_name = File.basename(File.dirname(file_path))
|
|
278
|
+
id_and_slug = TaskScanner::TASK_ID_EXTRACTOR.call(dir_name)
|
|
279
|
+
id = id_and_slug ? id_and_slug[0] : nil
|
|
280
|
+
|
|
281
|
+
# Extract title from first H1 or folder slug
|
|
282
|
+
title = extract_title_from_content(content) || extract_slug_title(dir_name)
|
|
283
|
+
|
|
284
|
+
# Build minimal frontmatter
|
|
285
|
+
frontmatter = Atoms::TaskFrontmatterDefaults.build(
|
|
286
|
+
id: id || Atoms::TaskIdFormatter.generate.formatted_id,
|
|
287
|
+
status: "pending",
|
|
288
|
+
created_at: Time.now.utc
|
|
289
|
+
)
|
|
290
|
+
frontmatter["title"] = title
|
|
291
|
+
frontmatter["id"] = id if id # Use existing ID if available
|
|
292
|
+
|
|
293
|
+
# Prepend proper frontmatter structure
|
|
294
|
+
yaml_block = "---\n#{YAML.dump(frontmatter).sub(/^---\n/, "")}---\n"
|
|
295
|
+
new_content = "#{yaml_block}\n#{content}"
|
|
296
|
+
|
|
297
|
+
apply_file_fix(file_path, new_content, "Added opening '---' delimiter and frontmatter")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def fix_folder_naming(dir_path)
|
|
301
|
+
return false unless Dir.exist?(dir_path)
|
|
302
|
+
|
|
303
|
+
# Generate new valid ID
|
|
304
|
+
item_id = Atoms::TaskIdFormatter.generate
|
|
305
|
+
new_id = item_id.formatted_id
|
|
306
|
+
|
|
307
|
+
# Extract slug from old folder name
|
|
308
|
+
old_name = File.basename(dir_path)
|
|
309
|
+
slug = extract_slug_from_folder_name(old_name)
|
|
310
|
+
|
|
311
|
+
# Find spec file
|
|
312
|
+
spec_files = Dir.glob(File.join(dir_path, "*.s.md"))
|
|
313
|
+
.reject { |f| f.end_with?(".idea.s.md") }
|
|
314
|
+
if spec_files.empty?
|
|
315
|
+
return (@skipped_count += 1
|
|
316
|
+
false)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
spec_file = spec_files.first
|
|
320
|
+
|
|
321
|
+
if @dry_run
|
|
322
|
+
new_folder_name = "#{new_id}-#{slug}"
|
|
323
|
+
log_fix(dir_path, "Would rename folder to #{new_folder_name}")
|
|
324
|
+
@fixed_count += 1
|
|
325
|
+
return true
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Update frontmatter id in spec file
|
|
329
|
+
editor = Ace::Support::Markdown::Organisms::DocumentEditor.new(spec_file)
|
|
330
|
+
editor.update_frontmatter("id" => new_id)
|
|
331
|
+
editor.save!(backup: true, validate_before: false)
|
|
332
|
+
|
|
333
|
+
# Build new names
|
|
334
|
+
new_folder_name = "#{new_id}-#{slug}"
|
|
335
|
+
parent = File.dirname(dir_path)
|
|
336
|
+
new_dir_path = File.join(parent, new_folder_name)
|
|
337
|
+
|
|
338
|
+
# Rename spec file
|
|
339
|
+
old_spec_name = File.basename(spec_file)
|
|
340
|
+
new_spec_name = "#{new_folder_name}.s.md"
|
|
341
|
+
|
|
342
|
+
# Rename folder
|
|
343
|
+
FileUtils.mv(dir_path, new_dir_path)
|
|
344
|
+
FileUtils.mv(File.join(new_dir_path, old_spec_name), File.join(new_dir_path, new_spec_name))
|
|
345
|
+
|
|
346
|
+
log_fix(dir_path, "Renamed folder to #{new_folder_name}")
|
|
347
|
+
@fixed_count += 1
|
|
348
|
+
true
|
|
349
|
+
rescue => e
|
|
350
|
+
log_fix(e.message, "Error: #{e.class}")
|
|
351
|
+
@skipped_count += 1
|
|
352
|
+
false
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def extract_title_from_content(content)
|
|
356
|
+
h1_match = content.match(/^#\s+(.+)$/)
|
|
357
|
+
h1_match ? h1_match[1].strip : nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def extract_slug_title(dir_name)
|
|
361
|
+
id_and_slug = TaskScanner::TASK_ID_EXTRACTOR.call(dir_name)
|
|
362
|
+
slug = id_and_slug ? id_and_slug[1] : dir_name
|
|
363
|
+
slug.tr("-", " ").capitalize
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def extract_slug_from_folder_name(name)
|
|
367
|
+
# Remove task ID prefix patterns
|
|
368
|
+
slug = name.sub(/^[0-9a-z]{3}\.[a-z]\.[0-9a-z]{3}-/, "") # Remove xxx.t.yyy-
|
|
369
|
+
.sub(/^\d+-\d+-\d+-/, "") # Remove NNN-YYYYMMDD-HHMMSS-
|
|
370
|
+
.sub(/^\d{7,}-/, "") # Remove 7+ digit prefix
|
|
371
|
+
.sub(/^\d{6}-/, "") # Remove 6-digit date prefix
|
|
372
|
+
.sub(/^\d+-/, "") # Remove issue number prefix
|
|
373
|
+
|
|
374
|
+
if slug.empty? || slug.match?(/^\d+$/)
|
|
375
|
+
slug = name.gsub(/[^a-zA-Z0-9]+/, "-").downcase
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
slug = slug.gsub(/^-+|-+$/, "")
|
|
379
|
+
slug = "untitled" if slug.empty?
|
|
380
|
+
slug[0..50]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def update_frontmatter_field(file_path, field, value, description)
|
|
384
|
+
return false unless file_path && File.exist?(file_path)
|
|
385
|
+
|
|
386
|
+
if @dry_run
|
|
387
|
+
log_fix(file_path, "Would: #{description}")
|
|
388
|
+
@fixed_count += 1
|
|
389
|
+
return true
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
editor = Ace::Support::Markdown::Organisms::DocumentEditor.new(file_path)
|
|
393
|
+
editor.update_frontmatter(field => value)
|
|
394
|
+
editor.save!(backup: true, validate_before: false)
|
|
395
|
+
log_fix(file_path, description)
|
|
396
|
+
@fixed_count += 1
|
|
397
|
+
true
|
|
398
|
+
rescue => e
|
|
399
|
+
log_fix(e.message, "Error: #{e.class}")
|
|
400
|
+
@skipped_count += 1
|
|
401
|
+
false
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def apply_file_fix(file_path, new_content, description)
|
|
405
|
+
if @dry_run
|
|
406
|
+
log_fix(file_path, "Would: #{description}")
|
|
407
|
+
else
|
|
408
|
+
Ace::Support::Markdown::Organisms::SafeFileWriter.write(
|
|
409
|
+
file_path,
|
|
410
|
+
new_content,
|
|
411
|
+
backup: true
|
|
412
|
+
)
|
|
413
|
+
log_fix(file_path, description)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
@fixed_count += 1
|
|
417
|
+
true
|
|
418
|
+
rescue => e
|
|
419
|
+
log_fix(e.message, "Error: #{e.class}")
|
|
420
|
+
@skipped_count += 1
|
|
421
|
+
false
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def log_fix(file_path, description)
|
|
425
|
+
@fixes_applied << {
|
|
426
|
+
file: file_path,
|
|
427
|
+
description: description,
|
|
428
|
+
timestamp: Time.now
|
|
429
|
+
}
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def move_issue_to_archive(file_path, from_maybe:)
|
|
433
|
+
task_file = File.directory?(file_path) ? find_primary_spec(file_path) : file_path
|
|
434
|
+
unless task_file && File.exist?(task_file)
|
|
435
|
+
return (@skipped_count += 1
|
|
436
|
+
false)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
task_dir = File.dirname(task_file)
|
|
440
|
+
content = File.read(task_file)
|
|
441
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
442
|
+
parent_id = frontmatter.is_a?(Hash) ? frontmatter["parent"] : nil
|
|
443
|
+
archive_time = parse_archive_time(frontmatter)
|
|
444
|
+
|
|
445
|
+
source_dir = task_dir
|
|
446
|
+
description = from_maybe ? "Moved from _maybe/ to _archive/" : "Moved to _archive/"
|
|
447
|
+
|
|
448
|
+
if parent_id
|
|
449
|
+
parent_dir = File.dirname(task_dir)
|
|
450
|
+
unless all_siblings_terminal?(parent_dir, parent_id)
|
|
451
|
+
log_fix(task_dir, "Skipped archive move for subtask (siblings are not all terminal)")
|
|
452
|
+
@skipped_count += 1
|
|
453
|
+
return false
|
|
454
|
+
end
|
|
455
|
+
source_dir = parent_dir
|
|
456
|
+
description = "Moved parent task to _archive/ (all subtasks terminal)"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
if @dry_run
|
|
460
|
+
log_fix(source_dir, "Would #{description.downcase}")
|
|
461
|
+
@fixed_count += 1
|
|
462
|
+
return true
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
mover = Ace::Support::Items::Molecules::FolderMover.new(@root_dir)
|
|
466
|
+
mover.move(Struct.new(:path).new(source_dir), to: "archive", date: archive_time)
|
|
467
|
+
log_fix(source_dir, description)
|
|
468
|
+
@fixed_count += 1
|
|
469
|
+
true
|
|
470
|
+
rescue => e
|
|
471
|
+
log_fix(e.message, "Error: #{e.class}")
|
|
472
|
+
@skipped_count += 1
|
|
473
|
+
false
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def find_primary_spec(dir_path)
|
|
477
|
+
Dir.glob(File.join(dir_path, "*.s.md"))
|
|
478
|
+
.reject { |path| path.end_with?(".idea.s.md") }
|
|
479
|
+
.first
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def parse_archive_time(frontmatter)
|
|
483
|
+
return nil unless frontmatter.is_a?(Hash)
|
|
484
|
+
|
|
485
|
+
raw = frontmatter["completed_at"] || frontmatter["created_at"]
|
|
486
|
+
return nil unless raw
|
|
487
|
+
return raw if raw.is_a?(Time)
|
|
488
|
+
return raw.to_time if raw.respond_to?(:to_time)
|
|
489
|
+
|
|
490
|
+
Time.parse(raw.to_s)
|
|
491
|
+
rescue ArgumentError
|
|
492
|
+
nil
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def all_siblings_terminal?(parent_dir, parent_id)
|
|
496
|
+
scanner = TaskScanner.new(@root_dir)
|
|
497
|
+
subtasks = scanner.scan_subtasks(parent_dir, parent_id: parent_id)
|
|
498
|
+
return false if subtasks.empty?
|
|
499
|
+
|
|
500
|
+
subtasks.all? do |scan_result|
|
|
501
|
+
content = File.read(scan_result.file_path)
|
|
502
|
+
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
503
|
+
status = frontmatter.is_a?(Hash) ? frontmatter["status"] : nil
|
|
504
|
+
Atoms::TaskValidationRules.terminal_status?(status.to_s.downcase)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|