ace-retro 0.16.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/wfi-sources/ace-retro.yml +19 -0
  3. data/.ace-defaults/retro/config.yml +16 -0
  4. data/CHANGELOG.md +252 -0
  5. data/LICENSE +21 -0
  6. data/README.md +40 -0
  7. data/Rakefile +13 -0
  8. data/docs/demo/ace-retro-getting-started.gif +0 -0
  9. data/docs/demo/ace-retro-getting-started.tape.yml +33 -0
  10. data/docs/demo/fixtures/README.md +3 -0
  11. data/docs/demo/fixtures/sample.txt +1 -0
  12. data/docs/getting-started.md +77 -0
  13. data/docs/handbook.md +60 -0
  14. data/docs/usage.md +141 -0
  15. data/exe/ace-retro +22 -0
  16. data/handbook/skills/as-handbook-selfimprove/SKILL.md +31 -0
  17. data/handbook/skills/as-retro-create/SKILL.md +26 -0
  18. data/handbook/skills/as-retro-synthesize/SKILL.md +26 -0
  19. data/handbook/templates/retro/retro.template.md +194 -0
  20. data/handbook/workflow-instructions/retro/create.wf.md +141 -0
  21. data/handbook/workflow-instructions/retro/selfimprove.wf.md +197 -0
  22. data/handbook/workflow-instructions/retro/synthesize.wf.md +94 -0
  23. data/lib/ace/retro/atoms/retro_file_pattern.rb +40 -0
  24. data/lib/ace/retro/atoms/retro_frontmatter_defaults.rb +42 -0
  25. data/lib/ace/retro/atoms/retro_id_formatter.rb +37 -0
  26. data/lib/ace/retro/atoms/retro_validation_rules.rb +82 -0
  27. data/lib/ace/retro/cli/commands/create.rb +87 -0
  28. data/lib/ace/retro/cli/commands/doctor.rb +204 -0
  29. data/lib/ace/retro/cli/commands/list.rb +63 -0
  30. data/lib/ace/retro/cli/commands/show.rb +55 -0
  31. data/lib/ace/retro/cli/commands/update.rb +117 -0
  32. data/lib/ace/retro/cli.rb +70 -0
  33. data/lib/ace/retro/models/retro.rb +40 -0
  34. data/lib/ace/retro/molecules/retro_config_loader.rb +93 -0
  35. data/lib/ace/retro/molecules/retro_creator.rb +165 -0
  36. data/lib/ace/retro/molecules/retro_display_formatter.rb +95 -0
  37. data/lib/ace/retro/molecules/retro_doctor_fixer.rb +404 -0
  38. data/lib/ace/retro/molecules/retro_doctor_reporter.rb +257 -0
  39. data/lib/ace/retro/molecules/retro_frontmatter_validator.rb +120 -0
  40. data/lib/ace/retro/molecules/retro_loader.rb +119 -0
  41. data/lib/ace/retro/molecules/retro_mover.rb +80 -0
  42. data/lib/ace/retro/molecules/retro_resolver.rb +57 -0
  43. data/lib/ace/retro/molecules/retro_scanner.rb +56 -0
  44. data/lib/ace/retro/molecules/retro_structure_validator.rb +193 -0
  45. data/lib/ace/retro/organisms/retro_doctor.rb +199 -0
  46. data/lib/ace/retro/organisms/retro_manager.rb +210 -0
  47. data/lib/ace/retro/version.rb +7 -0
  48. data/lib/ace/retro.rb +41 -0
  49. metadata +165 -0
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "../atoms/retro_id_formatter"
6
+ require_relative "../atoms/retro_file_pattern"
7
+ require_relative "../atoms/retro_frontmatter_defaults"
8
+ require_relative "retro_loader"
9
+
10
+ module Ace
11
+ module Retro
12
+ module Molecules
13
+ # Creates new retros with b36ts IDs, folder+file creation.
14
+ # Supports --type and --move-to options.
15
+ class RetroCreator
16
+ # @param root_dir [String] Root directory for retros
17
+ # @param config [Hash] Configuration hash
18
+ def initialize(root_dir:, config: {})
19
+ @root_dir = root_dir
20
+ @config = config
21
+ end
22
+
23
+ # Create a new retro
24
+ # @param title [String] Retro title
25
+ # @param type [String] Retro type (standard, conversation-analysis, self-review)
26
+ # @param tags [Array<String>] Tags for the retro
27
+ # @param move_to [String, nil] Target folder for the retro
28
+ # @param time [Time] Creation time (default: now)
29
+ # @return [Retro] Created retro object
30
+ def create(title, type: nil, tags: [], move_to: nil, time: Time.now.utc)
31
+ raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
32
+
33
+ effective_type = type || @config.dig("retro", "default_type") || "standard"
34
+
35
+ # Generate ID and slugs
36
+ id = Atoms::RetroIdFormatter.generate(time)
37
+ folder_slug = generate_folder_slug(title)
38
+ file_slug = generate_file_slug(title)
39
+
40
+ # Determine target directory
41
+ target_dir = determine_target_dir(move_to)
42
+ FileUtils.mkdir_p(target_dir)
43
+
44
+ # Create retro folder (ensure unique name if ID collision occurs)
45
+ folder_name, _ = unique_folder_name(id, folder_slug, target_dir)
46
+ retro_dir = File.join(target_dir, folder_name)
47
+ FileUtils.mkdir_p(retro_dir)
48
+
49
+ # Build frontmatter
50
+ frontmatter = Atoms::RetroFrontmatterDefaults.build(
51
+ id: id,
52
+ title: title,
53
+ type: effective_type,
54
+ tags: tags,
55
+ status: "active",
56
+ created_at: time
57
+ )
58
+
59
+ # Write retro file
60
+ file_content = build_file_content(frontmatter, title, effective_type)
61
+ retro_filename = Atoms::RetroFilePattern.retro_filename(id, file_slug)
62
+ retro_file = File.join(retro_dir, retro_filename)
63
+ File.write(retro_file, file_content)
64
+
65
+ # Load and return the created retro
66
+ loader = RetroLoader.new
67
+ special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
68
+ retro_dir, root: @root_dir
69
+ )
70
+ loader.load(retro_dir, id: id, special_folder: special_folder)
71
+ end
72
+
73
+ private
74
+
75
+ # Ensure unique folder name when the same b36ts ID is generated within the
76
+ # same 2-second window. If the candidate folder already exists, appends a
77
+ # numeric counter to the slug: {id}-{slug}-2, {id}-{slug}-3, etc.
78
+ # @return [Array<String>] [folder_name, effective_slug]
79
+ def unique_folder_name(id, slug, target_dir)
80
+ folder_name = Atoms::RetroFilePattern.folder_name(id, slug)
81
+ candidate_dir = File.join(target_dir, folder_name)
82
+
83
+ return [folder_name, slug] unless Dir.exist?(candidate_dir)
84
+
85
+ counter = 2
86
+ loop do
87
+ unique_slug = "#{slug}-#{counter}"
88
+ folder_name = Atoms::RetroFilePattern.folder_name(id, unique_slug)
89
+ candidate_dir = File.join(target_dir, folder_name)
90
+ break [folder_name, unique_slug] unless Dir.exist?(candidate_dir)
91
+
92
+ counter += 1
93
+ end
94
+ end
95
+
96
+ def generate_folder_slug(title)
97
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
98
+ words = sanitized.split("-")
99
+ words.take(5).join("-").then { |s| s.empty? ? "retro" : s }
100
+ end
101
+
102
+ def generate_file_slug(title)
103
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
104
+ words = sanitized.split("-")
105
+ words.take(7).join("-").then { |s| s.empty? ? "retro" : s }
106
+ end
107
+
108
+ def determine_target_dir(move_to)
109
+ if move_to
110
+ normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(move_to)
111
+ candidate = File.expand_path(File.join(@root_dir, normalized))
112
+ root_real = File.expand_path(@root_dir)
113
+ unless candidate.start_with?(root_real + File::SEPARATOR) || candidate == root_real
114
+ raise ArgumentError, "Path traversal detected in --move-to option"
115
+ end
116
+ candidate
117
+ else
118
+ @root_dir
119
+ end
120
+ end
121
+
122
+ def build_file_content(frontmatter, title, type)
123
+ fm_str = Atoms::RetroFrontmatterDefaults.serialize(frontmatter)
124
+
125
+ body = retro_template(type)
126
+
127
+ "#{fm_str}\n\n# #{title}\n\n#{body}\n"
128
+ end
129
+
130
+ def retro_template(type)
131
+ case type
132
+ when "conversation-analysis"
133
+ <<~BODY
134
+ ## Context
135
+
136
+ ## Key Observations
137
+
138
+ ## Patterns Identified
139
+
140
+ ## Action Items
141
+ BODY
142
+ when "self-review"
143
+ <<~BODY
144
+ ## What I Did Well
145
+
146
+ ## What I Could Improve
147
+
148
+ ## Key Learnings
149
+
150
+ ## Action Items
151
+ BODY
152
+ else # standard
153
+ <<~BODY
154
+ ## What Went Well
155
+
156
+ ## What Could Be Improved
157
+
158
+ ## Action Items
159
+ BODY
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Retro
5
+ module Molecules
6
+ # Formats retro objects for terminal display.
7
+ class RetroDisplayFormatter
8
+ STATUS_SYMBOLS = {
9
+ "active" => "○",
10
+ "done" => "✓"
11
+ }.freeze
12
+
13
+ STATUS_COLORS = {
14
+ "active" => Ace::Support::Items::Atoms::AnsiColors::YELLOW,
15
+ "done" => Ace::Support::Items::Atoms::AnsiColors::GREEN
16
+ }.freeze
17
+
18
+ TYPE_LABELS = {
19
+ "standard" => "standard",
20
+ "conversation-analysis" => "conversation",
21
+ "self-review" => "self-review"
22
+ }.freeze
23
+
24
+ # Return the status symbol with ANSI color applied.
25
+ def self.colored_status_sym(status)
26
+ sym = STATUS_SYMBOLS[status] || "○"
27
+ color = STATUS_COLORS[status]
28
+ color ? Ace::Support::Items::Atoms::AnsiColors.colorize(sym, color) : sym
29
+ end
30
+
31
+ private_class_method :colored_status_sym
32
+
33
+ # Format a single retro for display
34
+ # @param retro [Retro] Retro to format
35
+ # @param show_content [Boolean] Whether to include full content
36
+ # @return [String] Formatted output
37
+ def self.format(retro, show_content: false)
38
+ c = Ace::Support::Items::Atoms::AnsiColors
39
+ status_sym = colored_status_sym(retro.status)
40
+ id_str = show_content ? retro.id : c.colorize(retro.id, c::DIM)
41
+ tags_str = retro.tags.any? ? c.colorize(" [#{retro.tags.join(", ")}]", c::DIM) : ""
42
+ folder_str = retro.special_folder ? c.colorize(" (#{retro.special_folder})", c::DIM) : ""
43
+ type_str = c.colorize(" <#{TYPE_LABELS[retro.type] || retro.type}>", c::DIM)
44
+ lines = []
45
+ lines << "#{status_sym} #{id_str} #{retro.title}#{type_str}#{tags_str}#{folder_str}"
46
+
47
+ if show_content && retro.content && !retro.content.strip.empty?
48
+ lines << ""
49
+ lines << retro.content
50
+ end
51
+
52
+ if retro.folder_contents&.any?
53
+ lines << ""
54
+ lines << "Files: #{retro.folder_contents.join(", ")}"
55
+ end
56
+
57
+ lines.join("\n")
58
+ end
59
+
60
+ # Format a list of retros for display
61
+ # @param retros [Array<Retro>] Retros to format
62
+ # @param total_count [Integer, nil] Total items before folder filtering
63
+ # @param global_folder_stats [Hash, nil] Folder name → count hash from full scan
64
+ # @return [String] Formatted list output
65
+ def self.format_list(retros, total_count: nil, global_folder_stats: nil)
66
+ return "No retros found." if retros.empty?
67
+
68
+ lines = retros.map { |retro| format(retro) }.join("\n")
69
+ "#{lines}\n\n#{format_stats_line(retros, total_count: total_count, global_folder_stats: global_folder_stats)}"
70
+ end
71
+
72
+ STATUS_ORDER = %w[active done].freeze
73
+
74
+ # Format a stats summary line for a list of retros.
75
+ # @param retros [Array<Retro>] Retros to summarize
76
+ # @param total_count [Integer, nil] Total items before folder filtering
77
+ # @param global_folder_stats [Hash, nil] Folder name → count hash from full scan
78
+ # @return [String] e.g. "Retros: ○ 2 | ✓ 5 • 2 of 7"
79
+ def self.format_stats_line(retros, total_count: nil, global_folder_stats: nil)
80
+ stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(retros, :status)
81
+ folder_stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(retros, :special_folder)
82
+ Ace::Support::Items::Atoms::StatsLineFormatter.format(
83
+ label: "Retros",
84
+ stats: stats,
85
+ status_order: STATUS_ORDER,
86
+ status_icons: STATUS_SYMBOLS,
87
+ folder_stats: folder_stats,
88
+ total_count: total_count,
89
+ global_folder_stats: global_folder_stats
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ace/support/markdown"
5
+ require "ace/support/items"
6
+ require_relative "../atoms/retro_id_formatter"
7
+ require_relative "../atoms/retro_validation_rules"
8
+ require_relative "../atoms/retro_frontmatter_defaults"
9
+ require_relative "retro_loader"
10
+ require_relative "retro_mover"
11
+
12
+ module Ace
13
+ module Retro
14
+ module Molecules
15
+ # Handles auto-fixing of common retro issues detected by doctor.
16
+ # Supports dry_run mode to preview fixes without applying them.
17
+ class RetroDoctorFixer
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 /Missing required field: type/
64
+ fix_missing_type(issue[:location])
65
+ when /Missing required field: created_at/
66
+ fix_missing_created_at(issue[:location])
67
+ when /Field 'tags' is not an array/
68
+ fix_tags_not_array(issue[:location])
69
+ when /Missing recommended field: tags/
70
+ fix_missing_tags(issue[:location])
71
+ when /terminal status.*not in _archive/
72
+ fix_move_to_archive(issue[:location])
73
+ when /in _archive\/ but status is/
74
+ fix_archive_status(issue[:location])
75
+ when /Invalid archive partition/
76
+ fix_invalid_archive_partition(issue[:location])
77
+ when /Stale backup file/
78
+ fix_stale_backup(issue[:location])
79
+ when /Empty directory/
80
+ fix_empty_directory(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
+ FIXABLE_PATTERNS = [
97
+ /Missing opening '---' delimiter/,
98
+ /Missing closing '---' delimiter/,
99
+ /Missing required field: id/,
100
+ /Missing required field: status/,
101
+ /Missing required field: title/,
102
+ /Missing required field: type/,
103
+ /Missing required field: created_at/,
104
+ /Missing recommended field: status/,
105
+ /Missing recommended field: title/,
106
+ /Missing recommended field: tags/,
107
+ /Field 'tags' is not an array/,
108
+ /terminal status.*not in _archive/,
109
+ /in _archive\/ but status is/,
110
+ /Invalid archive partition/,
111
+ /Stale backup file/,
112
+ /Empty directory/
113
+ ].freeze
114
+
115
+ private
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_opening_delimiter(file_path)
139
+ return false unless File.exist?(file_path)
140
+
141
+ content = File.read(file_path)
142
+ dir_name = File.basename(File.dirname(file_path))
143
+ id_match = dir_name.match(/^([0-9a-z]{6})/)
144
+ id = id_match ? id_match[1] : nil
145
+
146
+ title = extract_title_from_content(content) || extract_slug_title(dir_name)
147
+
148
+ frontmatter = Atoms::RetroFrontmatterDefaults.build(
149
+ id: id || Atoms::RetroIdFormatter.generate,
150
+ title: title,
151
+ status: "active",
152
+ created_at: Time.now.utc
153
+ )
154
+ frontmatter["id"] = id if id
155
+
156
+ yaml_block = Atoms::RetroFrontmatterDefaults.serialize(frontmatter)
157
+ new_content = "#{yaml_block}\n#{content}"
158
+
159
+ apply_file_fix(file_path, new_content, "Added opening '---' delimiter and frontmatter")
160
+ end
161
+
162
+ def fix_missing_id(file_path)
163
+ return false unless File.exist?(file_path)
164
+
165
+ dir_name = File.basename(File.dirname(file_path))
166
+ id_match = dir_name.match(/^([0-9a-z]{6})/)
167
+ unless id_match
168
+ return (@skipped_count += 1
169
+ false)
170
+ end
171
+
172
+ id = id_match[1]
173
+ update_frontmatter_field(file_path, "id", id, "Added missing 'id' field from folder name")
174
+ end
175
+
176
+ def fix_missing_status(file_path)
177
+ update_frontmatter_field(file_path, "status", "active", "Added missing 'status' field with default 'active'")
178
+ end
179
+
180
+ def fix_missing_title(file_path)
181
+ return false unless File.exist?(file_path)
182
+
183
+ content = File.read(file_path)
184
+ title = nil
185
+ _fm, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
186
+ if body
187
+ h1_match = body.match(/^#\s+(.+)/)
188
+ title = h1_match[1].strip if h1_match
189
+ end
190
+
191
+ unless title
192
+ dir_name = File.basename(File.dirname(file_path))
193
+ slug_match = dir_name.match(/^[0-9a-z]{6}-(.+)$/)
194
+ title = slug_match ? slug_match[1].tr("-", " ").capitalize : "Untitled"
195
+ end
196
+
197
+ update_frontmatter_field(file_path, "title", title, "Added missing 'title' field: '#{title}'")
198
+ end
199
+
200
+ def fix_missing_type(file_path)
201
+ update_frontmatter_field(file_path, "type", "standard", "Added missing 'type' field with default 'standard'")
202
+ end
203
+
204
+ def fix_missing_created_at(file_path)
205
+ return false unless File.exist?(file_path)
206
+
207
+ content = File.read(file_path)
208
+ frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
209
+
210
+ id = frontmatter&.dig("id")
211
+ unless id
212
+ match = File.basename(File.dirname(file_path)).match(/^([0-9a-z]{6})/)
213
+ id = match[1] if match
214
+ end
215
+
216
+ created_at = if id && Atoms::RetroIdFormatter.valid?(id)
217
+ Atoms::RetroIdFormatter.decode_time(id).strftime("%Y-%m-%d %H:%M:%S")
218
+ else
219
+ Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
220
+ end
221
+
222
+ update_frontmatter_field(file_path, "created_at", created_at, "Added missing 'created_at' field")
223
+ end
224
+
225
+ def fix_tags_not_array(file_path)
226
+ update_frontmatter_field(file_path, "tags", [], "Coerced 'tags' field to empty array")
227
+ end
228
+
229
+ def fix_missing_tags(file_path)
230
+ update_frontmatter_field(file_path, "tags", [], "Added missing 'tags' field with empty array")
231
+ end
232
+
233
+ def fix_move_to_archive(file_path)
234
+ return false unless file_path && @root_dir
235
+
236
+ retro_dir = File.directory?(file_path) ? file_path : File.dirname(file_path)
237
+ folder_name = File.basename(retro_dir)
238
+ partition = Ace::Support::Items::Atoms::DatePartitionPath.compute(Time.now, levels: [:month])
239
+ archive_dir = File.join(@root_dir, "_archive", partition)
240
+ target = File.join(archive_dir, folder_name)
241
+
242
+ if @dry_run
243
+ log_fix(retro_dir, "Would move to _archive/#{partition}/")
244
+ @fixed_count += 1
245
+ return true
246
+ end
247
+
248
+ FileUtils.mkdir_p(archive_dir)
249
+ if File.exist?(target)
250
+ return (@skipped_count += 1
251
+ false)
252
+ end
253
+
254
+ FileUtils.mv(retro_dir, target)
255
+ log_fix(retro_dir, "Moved to _archive/#{partition}/")
256
+ @fixed_count += 1
257
+ true
258
+ rescue
259
+ @skipped_count += 1
260
+ false
261
+ end
262
+
263
+ def fix_archive_status(file_path)
264
+ update_frontmatter_field(file_path, "status", "done", "Updated status to 'done' (in _archive/)")
265
+ end
266
+
267
+ def fix_invalid_archive_partition(partition_dir)
268
+ return false unless partition_dir && Dir.exist?(partition_dir) && @root_dir
269
+
270
+ loader = RetroLoader.new
271
+ mover = RetroMover.new(@root_dir)
272
+ moved = 0
273
+
274
+ Dir.glob(File.join(partition_dir, "*")).each do |retro_path|
275
+ next unless File.directory?(retro_path)
276
+
277
+ retro = loader.load(retro_path, special_folder: "_archive")
278
+ next unless retro
279
+
280
+ if @dry_run
281
+ partition = Ace::Support::Items::Atoms::DatePartitionPath.compute(retro.created_at || Time.now)
282
+ log_fix(retro_path, "Would move to _archive/#{partition}/")
283
+ else
284
+ mover.move(retro, to: "archive", date: retro.created_at)
285
+ end
286
+ moved += 1
287
+ end
288
+
289
+ # Remove empty partition dir
290
+ unless @dry_run
291
+ remaining = Dir.glob(File.join(partition_dir, "*"))
292
+ FileUtils.rmdir(partition_dir) if remaining.empty?
293
+ end
294
+
295
+ if moved > 0
296
+ unless @dry_run
297
+ log_fix(partition_dir, "Relocated #{moved} retro(s) to b36ts partition(s)")
298
+ end
299
+ @fixed_count += 1
300
+ true
301
+ else
302
+ @skipped_count += 1
303
+ false
304
+ end
305
+ rescue
306
+ @skipped_count += 1
307
+ false
308
+ end
309
+
310
+ def fix_stale_backup(file_path)
311
+ return false unless file_path && File.exist?(file_path)
312
+
313
+ if @dry_run
314
+ log_fix(file_path, "Would delete stale backup file")
315
+ else
316
+ File.delete(file_path)
317
+ log_fix(file_path, "Deleted stale backup file")
318
+ end
319
+
320
+ @fixed_count += 1
321
+ true
322
+ end
323
+
324
+ def fix_empty_directory(dir_path)
325
+ return false unless dir_path && Dir.exist?(dir_path)
326
+
327
+ files = Dir.glob(File.join(dir_path, "**", "*")).select { |f| File.file?(f) }
328
+ unless files.empty?
329
+ @skipped_count += 1
330
+ return false
331
+ end
332
+
333
+ if @dry_run
334
+ log_fix(dir_path, "Would delete empty directory")
335
+ else
336
+ FileUtils.rm_rf(dir_path)
337
+ log_fix(dir_path, "Deleted empty directory")
338
+ end
339
+
340
+ @fixed_count += 1
341
+ true
342
+ end
343
+
344
+ def extract_title_from_content(content)
345
+ h1_match = content.match(/^#\s+(.+)$/)
346
+ h1_match ? h1_match[1].strip : nil
347
+ end
348
+
349
+ def extract_slug_title(dir_name)
350
+ slug_match = dir_name.match(/^[0-9a-z]{6}-(.+)$/)
351
+ slug = slug_match ? slug_match[1] : dir_name
352
+ slug.tr("-", " ").capitalize
353
+ end
354
+
355
+ def update_frontmatter_field(file_path, field, value, description)
356
+ return false unless file_path && File.exist?(file_path)
357
+
358
+ if @dry_run
359
+ log_fix(file_path, "Would: #{description}")
360
+ @fixed_count += 1
361
+ return true
362
+ end
363
+
364
+ editor = Ace::Support::Markdown::Organisms::DocumentEditor.new(file_path)
365
+ editor.update_frontmatter(field => value)
366
+ editor.save!(backup: true, validate_before: false)
367
+ log_fix(file_path, description)
368
+ @fixed_count += 1
369
+ true
370
+ rescue
371
+ @skipped_count += 1
372
+ false
373
+ end
374
+
375
+ def apply_file_fix(file_path, new_content, description)
376
+ if @dry_run
377
+ log_fix(file_path, "Would: #{description}")
378
+ else
379
+ Ace::Support::Markdown::Organisms::SafeFileWriter.write(
380
+ file_path,
381
+ new_content,
382
+ backup: true
383
+ )
384
+ log_fix(file_path, description)
385
+ end
386
+
387
+ @fixed_count += 1
388
+ true
389
+ rescue
390
+ @skipped_count += 1
391
+ false
392
+ end
393
+
394
+ def log_fix(file_path, description)
395
+ @fixes_applied << {
396
+ file: file_path,
397
+ description: description,
398
+ timestamp: Time.now
399
+ }
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end