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.
- checksums.yaml +7 -0
- data/.ace-defaults/idea/config.yml +21 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-idea.yml +19 -0
- data/CHANGELOG.md +387 -0
- data/README.md +42 -0
- data/Rakefile +13 -0
- data/docs/demo/ace-idea-getting-started.gif +0 -0
- data/docs/demo/ace-idea-getting-started.tape.yml +44 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +102 -0
- data/docs/handbook.md +39 -0
- data/docs/usage.md +320 -0
- data/exe/ace-idea +22 -0
- data/handbook/skills/as-idea-capture/SKILL.md +25 -0
- data/handbook/skills/as-idea-capture-features/SKILL.md +26 -0
- data/handbook/skills/as-idea-review/SKILL.md +26 -0
- data/handbook/workflow-instructions/idea/capture-features.wf.md +243 -0
- data/handbook/workflow-instructions/idea/capture.wf.md +270 -0
- data/handbook/workflow-instructions/idea/prioritize.wf.md +223 -0
- data/handbook/workflow-instructions/idea/review.wf.md +93 -0
- data/lib/ace/idea/atoms/idea_file_pattern.rb +40 -0
- data/lib/ace/idea/atoms/idea_frontmatter_defaults.rb +39 -0
- data/lib/ace/idea/atoms/idea_id_formatter.rb +37 -0
- data/lib/ace/idea/atoms/idea_validation_rules.rb +89 -0
- data/lib/ace/idea/atoms/slug_sanitizer_adapter.rb +6 -0
- data/lib/ace/idea/cli/commands/create.rb +98 -0
- data/lib/ace/idea/cli/commands/doctor.rb +206 -0
- data/lib/ace/idea/cli/commands/list.rb +62 -0
- data/lib/ace/idea/cli/commands/show.rb +55 -0
- data/lib/ace/idea/cli/commands/status.rb +61 -0
- data/lib/ace/idea/cli/commands/update.rb +118 -0
- data/lib/ace/idea/cli.rb +75 -0
- data/lib/ace/idea/models/idea.rb +39 -0
- data/lib/ace/idea/molecules/idea_clipboard_reader.rb +117 -0
- data/lib/ace/idea/molecules/idea_config_loader.rb +93 -0
- data/lib/ace/idea/molecules/idea_creator.rb +248 -0
- data/lib/ace/idea/molecules/idea_display_formatter.rb +165 -0
- data/lib/ace/idea/molecules/idea_doctor_fixer.rb +504 -0
- data/lib/ace/idea/molecules/idea_doctor_reporter.rb +264 -0
- data/lib/ace/idea/molecules/idea_frontmatter_validator.rb +137 -0
- data/lib/ace/idea/molecules/idea_llm_enhancer.rb +177 -0
- data/lib/ace/idea/molecules/idea_loader.rb +124 -0
- data/lib/ace/idea/molecules/idea_mover.rb +78 -0
- data/lib/ace/idea/molecules/idea_resolver.rb +57 -0
- data/lib/ace/idea/molecules/idea_scanner.rb +56 -0
- data/lib/ace/idea/molecules/idea_structure_validator.rb +157 -0
- data/lib/ace/idea/organisms/idea_doctor.rb +207 -0
- data/lib/ace/idea/organisms/idea_manager.rb +251 -0
- data/lib/ace/idea/version.rb +7 -0
- data/lib/ace/idea.rb +37 -0
- 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
|