ace-idea 0.18.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/idea/config.yml +21 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-idea.yml +19 -0
  4. data/CHANGELOG.md +387 -0
  5. data/README.md +42 -0
  6. data/Rakefile +13 -0
  7. data/docs/demo/ace-idea-getting-started.gif +0 -0
  8. data/docs/demo/ace-idea-getting-started.tape.yml +44 -0
  9. data/docs/demo/fixtures/README.md +3 -0
  10. data/docs/demo/fixtures/sample.txt +1 -0
  11. data/docs/getting-started.md +102 -0
  12. data/docs/handbook.md +39 -0
  13. data/docs/usage.md +320 -0
  14. data/exe/ace-idea +22 -0
  15. data/handbook/skills/as-idea-capture/SKILL.md +25 -0
  16. data/handbook/skills/as-idea-capture-features/SKILL.md +26 -0
  17. data/handbook/skills/as-idea-review/SKILL.md +26 -0
  18. data/handbook/workflow-instructions/idea/capture-features.wf.md +243 -0
  19. data/handbook/workflow-instructions/idea/capture.wf.md +270 -0
  20. data/handbook/workflow-instructions/idea/prioritize.wf.md +223 -0
  21. data/handbook/workflow-instructions/idea/review.wf.md +93 -0
  22. data/lib/ace/idea/atoms/idea_file_pattern.rb +40 -0
  23. data/lib/ace/idea/atoms/idea_frontmatter_defaults.rb +39 -0
  24. data/lib/ace/idea/atoms/idea_id_formatter.rb +37 -0
  25. data/lib/ace/idea/atoms/idea_validation_rules.rb +89 -0
  26. data/lib/ace/idea/atoms/slug_sanitizer_adapter.rb +6 -0
  27. data/lib/ace/idea/cli/commands/create.rb +98 -0
  28. data/lib/ace/idea/cli/commands/doctor.rb +206 -0
  29. data/lib/ace/idea/cli/commands/list.rb +62 -0
  30. data/lib/ace/idea/cli/commands/show.rb +55 -0
  31. data/lib/ace/idea/cli/commands/status.rb +61 -0
  32. data/lib/ace/idea/cli/commands/update.rb +118 -0
  33. data/lib/ace/idea/cli.rb +75 -0
  34. data/lib/ace/idea/models/idea.rb +39 -0
  35. data/lib/ace/idea/molecules/idea_clipboard_reader.rb +117 -0
  36. data/lib/ace/idea/molecules/idea_config_loader.rb +93 -0
  37. data/lib/ace/idea/molecules/idea_creator.rb +248 -0
  38. data/lib/ace/idea/molecules/idea_display_formatter.rb +165 -0
  39. data/lib/ace/idea/molecules/idea_doctor_fixer.rb +504 -0
  40. data/lib/ace/idea/molecules/idea_doctor_reporter.rb +264 -0
  41. data/lib/ace/idea/molecules/idea_frontmatter_validator.rb +137 -0
  42. data/lib/ace/idea/molecules/idea_llm_enhancer.rb +177 -0
  43. data/lib/ace/idea/molecules/idea_loader.rb +124 -0
  44. data/lib/ace/idea/molecules/idea_mover.rb +78 -0
  45. data/lib/ace/idea/molecules/idea_resolver.rb +57 -0
  46. data/lib/ace/idea/molecules/idea_scanner.rb +56 -0
  47. data/lib/ace/idea/molecules/idea_structure_validator.rb +157 -0
  48. data/lib/ace/idea/organisms/idea_doctor.rb +207 -0
  49. data/lib/ace/idea/organisms/idea_manager.rb +251 -0
  50. data/lib/ace/idea/version.rb +7 -0
  51. data/lib/ace/idea.rb +37 -0
  52. metadata +166 -0
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Idea
5
+ module Molecules
6
+ # Formats idea objects for terminal display.
7
+ class IdeaDisplayFormatter
8
+ STATUS_SYMBOLS = {
9
+ "pending" => "○",
10
+ "in-progress" => "▶",
11
+ "done" => "✓",
12
+ "obsolete" => "✗"
13
+ }.freeze
14
+
15
+ STATUS_COLORS = {
16
+ "pending" => nil,
17
+ "in-progress" => Ace::Support::Items::Atoms::AnsiColors::YELLOW,
18
+ "done" => Ace::Support::Items::Atoms::AnsiColors::GREEN,
19
+ "obsolete" => Ace::Support::Items::Atoms::AnsiColors::DIM
20
+ }.freeze
21
+
22
+ # Return the status symbol with ANSI color applied.
23
+ def self.colored_status_sym(status)
24
+ normalized = normalize_status(status)
25
+ sym = STATUS_SYMBOLS[normalized] || "○"
26
+ color = STATUS_COLORS[normalized]
27
+ color ? Ace::Support::Items::Atoms::AnsiColors.colorize(sym, color) : sym
28
+ end
29
+
30
+ private_class_method :colored_status_sym
31
+
32
+ def self.normalize_status(status)
33
+ value = status.to_s
34
+ return "obsolete" if value == "cancelled"
35
+
36
+ value
37
+ end
38
+
39
+ private_class_method :normalize_status
40
+
41
+ # Format a single idea for display
42
+ # @param idea [Idea] Idea to format
43
+ # @param show_content [Boolean] Whether to include full content
44
+ # @return [String] Formatted output
45
+ def self.format(idea, show_content: false)
46
+ c = Ace::Support::Items::Atoms::AnsiColors
47
+ status_sym = colored_status_sym(idea.status)
48
+ id_str = show_content ? idea.id : c.colorize(idea.id, c::DIM)
49
+ tags_str = idea.tags.any? ? c.colorize(" [#{idea.tags.join(", ")}]", c::DIM) : ""
50
+ folder_str = idea.special_folder ? c.colorize(" (#{idea.special_folder})", c::DIM) : ""
51
+
52
+ lines = []
53
+ lines << "#{status_sym} #{id_str} #{idea.title}#{tags_str}#{folder_str}"
54
+
55
+ if show_content && idea.content && !idea.content.strip.empty?
56
+ lines << ""
57
+ lines << idea.content
58
+ end
59
+
60
+ if show_content && idea.attachments.any?
61
+ lines << ""
62
+ lines << "Attachments: #{idea.attachments.join(", ")}"
63
+ end
64
+
65
+ lines.join("\n")
66
+ end
67
+
68
+ # Format a list of ideas for display
69
+ # @param ideas [Array<Idea>] Ideas to format
70
+ # @param total_count [Integer, nil] Total items before folder filtering
71
+ # @param global_folder_stats [Hash, nil] Folder name → count hash from full scan
72
+ # @return [String] Formatted list output
73
+ def self.format_list(ideas, total_count: nil, global_folder_stats: nil)
74
+ stats_line = format_stats_line(
75
+ ideas,
76
+ total_count: total_count,
77
+ global_folder_stats: global_folder_stats
78
+ )
79
+
80
+ if ideas.empty?
81
+ "No ideas found.\n\n#{stats_line}"
82
+ else
83
+ "#{ideas.map { |idea| format(idea) }.join("\n")}\n\n#{stats_line}"
84
+ end
85
+ end
86
+
87
+ STATUS_ORDER = %w[pending in-progress done obsolete].freeze
88
+
89
+ # Format a status overview with up-next, stats, and recently-done sections.
90
+ # @param categorized [Hash] Output of StatusCategorizer.categorize
91
+ # @param all_ideas [Array<Idea>] All ideas for stats computation
92
+ # @return [String] Formatted status output
93
+ def self.format_status(categorized, all_ideas:)
94
+ sections = []
95
+
96
+ # Up Next
97
+ sections << format_up_next_section(categorized[:up_next])
98
+
99
+ # Stats summary
100
+ sections << format_stats_line(all_ideas)
101
+
102
+ # Recently Done
103
+ sections << format_recently_done_section(categorized[:recently_done])
104
+
105
+ sections.join("\n\n")
106
+ end
107
+
108
+ # Format a single idea as a compact status line (id + title only).
109
+ # @param idea [Idea] Idea to format
110
+ # @return [String] e.g. " ⚪ 8ppq7w Dark mode support"
111
+ def self.format_status_line(idea)
112
+ status_sym = colored_status_sym(idea.status)
113
+ " #{status_sym} #{idea.id} #{idea.title}"
114
+ end
115
+
116
+ # Format a stats summary line for a list of ideas.
117
+ # @param ideas [Array<Idea>] Ideas to summarize
118
+ # @param total_count [Integer, nil] Total items before folder filtering
119
+ # @param global_folder_stats [Hash, nil] Folder name → count hash from full scan
120
+ # @return [String] e.g. "Ideas: ○ 3 | ▶ 1 | ✓ 2 • 3 of 8"
121
+ def self.format_stats_line(ideas, total_count: nil, global_folder_stats: nil)
122
+ stats = {total: ideas.size, by_field: Hash.new(0)}
123
+ folder_stats = {total: ideas.size, by_field: Hash.new(0)}
124
+
125
+ ideas.each do |idea|
126
+ stats[:by_field][normalize_status(idea.status)] += 1
127
+ folder_stats[:by_field][idea.special_folder] += 1
128
+ end
129
+
130
+ Ace::Support::Items::Atoms::StatsLineFormatter.format(
131
+ label: "Ideas",
132
+ stats: stats,
133
+ status_order: STATUS_ORDER,
134
+ status_icons: STATUS_SYMBOLS,
135
+ folder_stats: folder_stats,
136
+ total_count: total_count,
137
+ global_folder_stats: global_folder_stats
138
+ )
139
+ end
140
+
141
+ # Format the "Up Next" section.
142
+ def self.format_up_next_section(up_next)
143
+ return "Up Next:\n (none)" if up_next.empty?
144
+
145
+ lines = up_next.map { |idea| format_status_line(idea) }
146
+ "Up Next:\n#{lines.join("\n")}"
147
+ end
148
+
149
+ # Format the "Recently Done" section.
150
+ def self.format_recently_done_section(recently_done)
151
+ return "Recently Done:\n (none)" if recently_done.empty?
152
+
153
+ lines = recently_done.map do |entry|
154
+ idea = entry[:item]
155
+ time_str = Ace::Support::Items::Atoms::RelativeTimeFormatter.format(entry[:completed_at])
156
+ " #{format_status_line(idea).strip} (#{time_str})"
157
+ end
158
+ "Recently Done:\n#{lines.join("\n")}"
159
+ end
160
+
161
+ private_class_method :format_up_next_section, :format_recently_done_section
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,504 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ace/support/markdown"
5
+ require "ace/support/items"
6
+ require_relative "../atoms/idea_id_formatter"
7
+ require_relative "../atoms/idea_validation_rules"
8
+ require_relative "../atoms/idea_frontmatter_defaults"
9
+
10
+ module Ace
11
+ module Idea
12
+ module Molecules
13
+ # Handles auto-fixing of common idea issues detected by doctor.
14
+ # Supports dry_run mode to preview fixes without applying them.
15
+ class IdeaDoctorFixer
16
+ attr_reader :dry_run, :fixed_count, :skipped_count
17
+
18
+ def initialize(dry_run: false, root_dir: nil)
19
+ @dry_run = dry_run
20
+ @root_dir = root_dir
21
+ @fixed_count = 0
22
+ @skipped_count = 0
23
+ @fixes_applied = []
24
+ end
25
+
26
+ # Fix a batch of issues
27
+ # @param issues [Array<Hash>] Issues to fix
28
+ # @return [Hash] Fix results summary
29
+ def fix_issues(issues)
30
+ fixable_issues = issues.select { |issue| can_fix?(issue) }
31
+
32
+ fixable_issues.each do |issue|
33
+ fix_issue(issue)
34
+ end
35
+
36
+ {
37
+ fixed: @fixed_count,
38
+ skipped: @skipped_count,
39
+ fixes_applied: @fixes_applied,
40
+ dry_run: @dry_run
41
+ }
42
+ end
43
+
44
+ # Fix a single issue by pattern matching its message
45
+ # @param issue [Hash] Issue to fix
46
+ # @return [Boolean] Whether fix was successful
47
+ def fix_issue(issue)
48
+ case issue[:message]
49
+ when /Missing opening '---' delimiter/
50
+ fix_missing_opening_delimiter(issue[:location])
51
+ when /Missing closing '---' delimiter/
52
+ fix_missing_closing_delimiter(issue[:location])
53
+ when /Missing required field: id/
54
+ fix_missing_id(issue[:location])
55
+ when /Missing required field: status/,
56
+ /Missing recommended field: status/
57
+ fix_missing_status(issue[:location])
58
+ when /Missing required field: title/,
59
+ /Missing recommended field: title/
60
+ fix_missing_title(issue[:location])
61
+ when /Derived field 'location' should not be stored in frontmatter/
62
+ fix_remove_location(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
+ /Derived field 'location' should not be stored in frontmatter/,
109
+ /Field 'tags' is not an array/,
110
+ /terminal status.*not in _archive/,
111
+ /in _archive\/ but status is/,
112
+ /in _maybe\/ with terminal status/,
113
+ /Stale backup file/,
114
+ /Empty directory/,
115
+ /Folder name does not match '\{id\}-\{slug\}' convention/
116
+ ].freeze
117
+
118
+ def fix_missing_closing_delimiter(file_path)
119
+ return false unless File.exist?(file_path)
120
+
121
+ content = File.read(file_path)
122
+ # Append closing delimiter after frontmatter content
123
+ lines = content.lines
124
+ # Find where frontmatter content ends (first blank line or markdown heading)
125
+ insert_idx = nil
126
+ lines[1..].each_with_index do |line, i|
127
+ if line.strip.empty? || line.start_with?("#")
128
+ insert_idx = i + 1
129
+ break
130
+ end
131
+ end
132
+ insert_idx ||= lines.size
133
+
134
+ fixed_lines = lines.dup
135
+ fixed_lines.insert(insert_idx, "---\n")
136
+ fixed_content = fixed_lines.join
137
+
138
+ apply_file_fix(file_path, fixed_content, "Added missing closing '---' delimiter")
139
+ end
140
+
141
+ def fix_missing_id(file_path)
142
+ return false unless File.exist?(file_path)
143
+
144
+ # Extract ID from folder name
145
+ dir_name = File.basename(File.dirname(file_path))
146
+ id_match = dir_name.match(/^([0-9a-z]{6})/)
147
+ unless id_match
148
+ return (@skipped_count += 1
149
+ false)
150
+ end
151
+
152
+ id = id_match[1]
153
+ update_frontmatter_field(file_path, "id", id, "Added missing 'id' field from folder name")
154
+ end
155
+
156
+ def fix_missing_status(file_path)
157
+ update_frontmatter_field(file_path, "status", "pending", "Added missing 'status' field with default 'pending'")
158
+ end
159
+
160
+ def fix_missing_title(file_path)
161
+ return false unless File.exist?(file_path)
162
+
163
+ content = File.read(file_path)
164
+ # Try to extract title from body H1
165
+ title = nil
166
+ _fm, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
167
+ if body
168
+ h1_match = body.match(/^#\s+(.+)/)
169
+ title = h1_match[1].strip if h1_match
170
+ end
171
+
172
+ # Fallback: extract from folder slug
173
+ unless title
174
+ dir_name = File.basename(File.dirname(file_path))
175
+ slug_match = dir_name.match(/^[0-9a-z]{6}-(.+)$/)
176
+ title = slug_match ? slug_match[1].tr("-", " ").capitalize : "Untitled"
177
+ end
178
+
179
+ update_frontmatter_field(file_path, "title", title, "Added missing 'title' field: '#{title}'")
180
+ end
181
+
182
+ def fix_tags_not_array(file_path)
183
+ update_frontmatter_field(file_path, "tags", [], "Coerced 'tags' field to empty array")
184
+ end
185
+
186
+ def fix_remove_location(file_path)
187
+ return false unless file_path && File.exist?(file_path)
188
+
189
+ content = File.read(file_path)
190
+ frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
191
+ unless frontmatter.is_a?(Hash)
192
+ return (@skipped_count += 1
193
+ false)
194
+ end
195
+ unless frontmatter.key?("location")
196
+ return (@skipped_count += 1
197
+ false)
198
+ end
199
+
200
+ updated = frontmatter.dup
201
+ updated.delete("location")
202
+ cleaned_body = body.to_s.sub(/\A\n/, "")
203
+ new_content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(updated, cleaned_body)
204
+
205
+ apply_file_fix(file_path, new_content, "Removed derived 'location' field from frontmatter")
206
+ end
207
+
208
+ def fix_missing_tags(file_path)
209
+ update_frontmatter_field(file_path, "tags", [], "Added missing 'tags' field with empty array")
210
+ end
211
+
212
+ def fix_missing_created_at(file_path)
213
+ return false unless File.exist?(file_path)
214
+
215
+ # Try to decode time from ID in frontmatter or folder name
216
+ content = File.read(file_path)
217
+ frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
218
+
219
+ id = frontmatter&.dig("id")
220
+ unless id
221
+ match = File.basename(File.dirname(file_path)).match(/^([0-9a-z]{6})/)
222
+ id = match[1] if match
223
+ end
224
+
225
+ created_at = if id && Atoms::IdeaIdFormatter.valid?(id)
226
+ Atoms::IdeaIdFormatter.decode_time(id).strftime("%Y-%m-%d %H:%M:%S")
227
+ else
228
+ Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
229
+ end
230
+
231
+ update_frontmatter_field(file_path, "created_at", created_at, "Added missing 'created_at' field decoded from ID")
232
+ end
233
+
234
+ def fix_move_to_archive(file_path)
235
+ return false unless file_path && @root_dir
236
+
237
+ idea_dir = File.directory?(file_path) ? file_path : File.dirname(file_path)
238
+ folder_name = File.basename(idea_dir)
239
+ archive_dir = File.join(@root_dir, "_archive")
240
+ target = File.join(archive_dir, folder_name)
241
+
242
+ if @dry_run
243
+ log_fix(idea_dir, "Would move to _archive/")
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(idea_dir, target)
255
+ log_fix(idea_dir, "Moved to _archive/")
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_maybe_terminal(file_path)
268
+ return false unless file_path && @root_dir
269
+
270
+ idea_dir = File.directory?(file_path) ? file_path : File.dirname(file_path)
271
+ folder_name = File.basename(idea_dir)
272
+ archive_dir = File.join(@root_dir, "_archive")
273
+ target = File.join(archive_dir, folder_name)
274
+
275
+ if @dry_run
276
+ log_fix(idea_dir, "Would move from _maybe/ to _archive/")
277
+ @fixed_count += 1
278
+ return true
279
+ end
280
+
281
+ FileUtils.mkdir_p(archive_dir)
282
+ if File.exist?(target)
283
+ return (@skipped_count += 1
284
+ false)
285
+ end
286
+
287
+ FileUtils.mv(idea_dir, target)
288
+ log_fix(idea_dir, "Moved from _maybe/ to _archive/")
289
+ @fixed_count += 1
290
+ true
291
+ rescue
292
+ @skipped_count += 1
293
+ false
294
+ end
295
+
296
+ def fix_stale_backup(file_path)
297
+ return false unless file_path && File.exist?(file_path)
298
+
299
+ if @dry_run
300
+ log_fix(file_path, "Would delete stale backup file")
301
+ else
302
+ File.delete(file_path)
303
+ log_fix(file_path, "Deleted stale backup file")
304
+ end
305
+
306
+ @fixed_count += 1
307
+ true
308
+ end
309
+
310
+ def fix_empty_directory(dir_path)
311
+ return false unless dir_path && Dir.exist?(dir_path)
312
+
313
+ # Safety: only remove if truly empty
314
+ files = Dir.glob(File.join(dir_path, "**", "*")).select { |f| File.file?(f) }
315
+ unless files.empty?
316
+ @skipped_count += 1
317
+ return false
318
+ end
319
+
320
+ if @dry_run
321
+ log_fix(dir_path, "Would delete empty directory")
322
+ else
323
+ FileUtils.rm_rf(dir_path)
324
+ log_fix(dir_path, "Deleted empty directory")
325
+ end
326
+
327
+ @fixed_count += 1
328
+ true
329
+ end
330
+
331
+ def fix_missing_opening_delimiter(file_path)
332
+ return false unless File.exist?(file_path)
333
+
334
+ content = File.read(file_path)
335
+
336
+ # Extract ID from folder name
337
+ dir_name = File.basename(File.dirname(file_path))
338
+ id_match = dir_name.match(/^([0-9a-z]{6})/)
339
+ id = id_match ? id_match[1] : nil
340
+
341
+ # Extract title from first H1 or folder slug
342
+ title = extract_title_from_content(content) || extract_slug_title(dir_name)
343
+
344
+ # Build minimal frontmatter
345
+ if id && Atoms::IdeaIdFormatter.valid?(id)
346
+ Atoms::IdeaIdFormatter.decode_time(id).strftime("%Y-%m-%d %H:%M:%S")
347
+ else
348
+ Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
349
+ end
350
+
351
+ frontmatter = Atoms::IdeaFrontmatterDefaults.build(
352
+ id: id || Atoms::IdeaIdFormatter.generate,
353
+ title: title,
354
+ status: "pending",
355
+ created_at: Time.now.utc
356
+ )
357
+ frontmatter["id"] = id if id # Use existing ID if available
358
+
359
+ # Prepend proper frontmatter structure
360
+ yaml_block = Atoms::IdeaFrontmatterDefaults.serialize(frontmatter)
361
+ new_content = "#{yaml_block}\n#{content}"
362
+
363
+ apply_file_fix(file_path, new_content, "Added opening '---' delimiter and frontmatter")
364
+ end
365
+
366
+ def fix_folder_naming(dir_path)
367
+ return false unless Dir.exist?(dir_path)
368
+
369
+ # Generate new valid ID
370
+ new_id = Atoms::IdeaIdFormatter.generate
371
+
372
+ # Extract slug from old folder name (remove prefix patterns)
373
+ old_name = File.basename(dir_path)
374
+ slug = extract_slug_from_folder_name(old_name)
375
+
376
+ # Find spec file
377
+ spec_files = Dir.glob(File.join(dir_path, "*.idea.s.md"))
378
+ if spec_files.empty?
379
+ return (@skipped_count += 1
380
+ false)
381
+ end
382
+
383
+ spec_file = spec_files.first
384
+
385
+ if @dry_run
386
+ new_folder_name = "#{new_id}-#{slug}"
387
+ log_fix(dir_path, "Would rename folder to #{new_folder_name}")
388
+ @fixed_count += 1
389
+ return true
390
+ end
391
+
392
+ # Update frontmatter id in spec file
393
+ editor = Ace::Support::Markdown::Organisms::DocumentEditor.new(spec_file)
394
+ editor.update_frontmatter("id" => new_id)
395
+ editor.save!(backup: true, validate_before: false)
396
+
397
+ # Build new names
398
+ new_folder_name = "#{new_id}-#{slug}"
399
+ parent = File.dirname(dir_path)
400
+ new_dir_path = File.join(parent, new_folder_name)
401
+
402
+ # Rename spec file
403
+ old_spec_name = File.basename(spec_file)
404
+ new_spec_name = "#{new_folder_name}.idea.s.md"
405
+ new_spec_path = File.join(new_dir_path, new_spec_name)
406
+
407
+ # Rename folder
408
+ FileUtils.mv(dir_path, new_dir_path)
409
+ FileUtils.mv(File.join(new_dir_path, old_spec_name), new_spec_path)
410
+
411
+ log_fix(dir_path, "Renamed folder to #{new_folder_name}")
412
+ @fixed_count += 1
413
+ true
414
+ rescue
415
+ @skipped_count += 1
416
+ false
417
+ end
418
+
419
+ def extract_title_from_content(content)
420
+ # Try to extract title from first H1
421
+ # Note: don't use /m flag - we only want first line after #, not entire content
422
+ h1_match = content.match(/^#\s+(.+)$/)
423
+ h1_match ? h1_match[1].strip : nil
424
+ end
425
+
426
+ def extract_slug_title(dir_name)
427
+ # Extract slug part after ID prefix
428
+ slug_match = dir_name.match(/^[0-9a-z]{6}-(.+)$/)
429
+ slug = slug_match ? slug_match[1] : dir_name
430
+ slug.tr("-", " ").capitalize
431
+ end
432
+
433
+ def extract_slug_from_folder_name(name)
434
+ # Remove various prefix patterns:
435
+ # "056-20250930-105556-slug-here" -> "slug-here"
436
+ # "20251013-slug-here" -> "slug-here"
437
+ # "2025111-slug-here" -> "slug-here"
438
+
439
+ # Try to find slug after numeric prefixes
440
+ slug = name.sub(/^\d+-\d+-\d+-/, "") # Remove NNN-YYYYMMDD-HHMMSS-
441
+ .sub(/^\d{7,}-/, "") # Remove 7+ digit prefix (like 2025111)
442
+ .sub(/^\d{6}-/, "") # Remove 6-digit date prefix (YYYYMM)
443
+ .sub(/^\d+-/, "") # Remove issue number prefix
444
+
445
+ # Fallback: use the original name cleaned up
446
+ if slug.empty? || slug.match?(/^\d+$/)
447
+ slug = name.gsub(/[^a-zA-Z0-9]+/, "-").downcase
448
+ end
449
+
450
+ slug = slug.gsub(/^-+|-+$/, "") # Strip leading/trailing dashes
451
+ slug = "untitled" if slug.empty?
452
+ slug[0..50] # Truncate to reasonable length
453
+ end
454
+
455
+ def update_frontmatter_field(file_path, field, value, description)
456
+ return false unless file_path && File.exist?(file_path)
457
+
458
+ if @dry_run
459
+ log_fix(file_path, "Would: #{description}")
460
+ @fixed_count += 1
461
+ return true
462
+ end
463
+
464
+ editor = Ace::Support::Markdown::Organisms::DocumentEditor.new(file_path)
465
+ editor.update_frontmatter(field => value)
466
+ editor.save!(backup: true, validate_before: false)
467
+ log_fix(file_path, description)
468
+ @fixed_count += 1
469
+ true
470
+ rescue
471
+ @skipped_count += 1
472
+ false
473
+ end
474
+
475
+ def apply_file_fix(file_path, new_content, description)
476
+ if @dry_run
477
+ log_fix(file_path, "Would: #{description}")
478
+ else
479
+ Ace::Support::Markdown::Organisms::SafeFileWriter.write(
480
+ file_path,
481
+ new_content,
482
+ backup: true
483
+ )
484
+ log_fix(file_path, description)
485
+ end
486
+
487
+ @fixed_count += 1
488
+ true
489
+ rescue
490
+ @skipped_count += 1
491
+ false
492
+ end
493
+
494
+ def log_fix(file_path, description)
495
+ @fixes_applied << {
496
+ file: file_path,
497
+ description: description,
498
+ timestamp: Time.now
499
+ }
500
+ end
501
+ end
502
+ end
503
+ end
504
+ end