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,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Idea
7
+ module Molecules
8
+ # Formats doctor diagnosis results for terminal, JSON, or summary output.
9
+ class IdeaDoctorReporter
10
+ COLORS = {
11
+ red: "\e[31m",
12
+ yellow: "\e[33m",
13
+ green: "\e[32m",
14
+ blue: "\e[34m",
15
+ cyan: "\e[36m",
16
+ reset: "\e[0m",
17
+ bold: "\e[1m"
18
+ }.freeze
19
+
20
+ ICONS = {
21
+ error: "❌",
22
+ warning: "⚠️",
23
+ info: "ℹ️",
24
+ success: "✅",
25
+ doctor: "🏥",
26
+ stats: "📊",
27
+ search: "🔍",
28
+ fix: "🔧",
29
+ score: "📈"
30
+ }.freeze
31
+
32
+ # Format diagnosis results
33
+ # @param results [Hash] Results from IdeaDoctor#run_diagnosis
34
+ # @param format [Symbol] Output format (:terminal, :json, :summary)
35
+ # @param verbose [Boolean] Show verbose output
36
+ # @param colors [Boolean] Enable colored output
37
+ # @return [String] Formatted output
38
+ def self.format_results(results, format: :terminal, verbose: false, colors: true)
39
+ case format.to_sym
40
+ when :json
41
+ format_json(results)
42
+ when :summary
43
+ format_summary(results, colors: colors)
44
+ else
45
+ format_terminal(results, verbose: verbose, colors: colors)
46
+ end
47
+ end
48
+
49
+ # Format auto-fix results
50
+ # @param fix_results [Hash] Results from IdeaDoctorFixer#fix_issues
51
+ # @param colors [Boolean] Enable colored output
52
+ # @return [String] Formatted fix output
53
+ def self.format_fix_results(fix_results, colors: true)
54
+ output = []
55
+
56
+ output << if fix_results[:dry_run]
57
+ "\n#{colorize("#{ICONS[:search]} DRY RUN MODE", :cyan, colors)} - No changes applied"
58
+ else
59
+ "\n#{colorize("#{ICONS[:fix]} Auto-Fix Applied", :green, colors)}"
60
+ end
61
+
62
+ if fix_results[:fixed] > 0
63
+ output << "#{colorize("Fixed:", :green, colors)} #{fix_results[:fixed]} issues"
64
+
65
+ if fix_results[:fixes_applied]&.any?
66
+ output << "\nFixes applied:"
67
+ fix_results[:fixes_applied].each do |fix|
68
+ output << " #{colorize("✓", :green, colors)} #{fix[:description]}"
69
+ output << " #{colorize(fix[:file], :blue, colors)}" if fix[:file]
70
+ end
71
+ end
72
+ end
73
+
74
+ if fix_results[:skipped] > 0
75
+ output << "#{colorize("Skipped:", :yellow, colors)} #{fix_results[:skipped]} issues (manual fix required)"
76
+ end
77
+
78
+ output.join("\n")
79
+ end
80
+
81
+ class << self
82
+ private
83
+
84
+ def format_terminal(results, verbose: false, colors: true)
85
+ output = []
86
+
87
+ # Header
88
+ output << "\n#{colorize("#{ICONS[:doctor]} Idea Health Check", :bold, colors)}"
89
+ output << "=" * 40
90
+
91
+ # Stats overview
92
+ if results[:stats]
93
+ output << "\n#{colorize("#{ICONS[:stats]} Overview", :cyan, colors)}"
94
+ output << "-" * 20
95
+ output << " Ideas scanned: #{results[:stats][:ideas_scanned]}"
96
+ output << " Folders checked: #{results[:stats][:folders_checked]}"
97
+ end
98
+
99
+ # Issues
100
+ if results[:issues]&.any?
101
+ output << "\n#{colorize("Issues Found:", :yellow, colors)}"
102
+ output << "-" * 20
103
+ output.concat(format_issues(results[:issues], verbose, colors))
104
+ else
105
+ output << "\n#{colorize("#{ICONS[:success]} All ideas healthy", :green, colors)}"
106
+ end
107
+
108
+ # Health Score
109
+ output << "\n#{colorize("#{ICONS[:score]} Health Score:", :bold, colors)} #{format_health_score(results[:health_score], colors)}"
110
+ output << "=" * 40
111
+
112
+ # Summary
113
+ if results[:stats]
114
+ output << format_issue_summary(results[:stats], colors)
115
+ end
116
+
117
+ # Duration
118
+ if results[:duration]
119
+ output << "\n#{colorize("Completed in #{format_duration(results[:duration])}", :blue, colors)}"
120
+ end
121
+
122
+ output.join("\n")
123
+ end
124
+
125
+ def format_summary(results, colors: true)
126
+ output = []
127
+
128
+ health_status = if results[:health_score] >= 90
129
+ colorize("Excellent", :green, colors)
130
+ elsif results[:health_score] >= 70
131
+ colorize("Good", :yellow, colors)
132
+ elsif results[:health_score] >= 50
133
+ colorize("Fair", :yellow, colors)
134
+ else
135
+ colorize("Poor", :red, colors)
136
+ end
137
+
138
+ output << "Health: #{health_status} (#{results[:health_score]}/100)"
139
+
140
+ if results[:stats]
141
+ errors = results[:stats][:errors] || 0
142
+ warnings = results[:stats][:warnings] || 0
143
+
144
+ output << colorize("Errors: #{errors}", :red, colors) if errors > 0
145
+ output << colorize("Warnings: #{warnings}", :yellow, colors) if warnings > 0
146
+ end
147
+
148
+ output.join(" | ")
149
+ end
150
+
151
+ def format_json(results)
152
+ clean = {
153
+ health_score: results[:health_score],
154
+ valid: results[:valid],
155
+ errors: [],
156
+ warnings: [],
157
+ info: [],
158
+ stats: results[:stats],
159
+ duration: results[:duration],
160
+ root_path: results[:root_path]
161
+ }
162
+
163
+ if results[:issues]
164
+ results[:issues].each do |issue|
165
+ category = case issue[:type]
166
+ when :error then :errors
167
+ when :warning then :warnings
168
+ else :info
169
+ end
170
+ clean[category] << {
171
+ message: issue[:message],
172
+ location: issue[:location]
173
+ }
174
+ end
175
+ end
176
+
177
+ JSON.pretty_generate(clean)
178
+ end
179
+
180
+ def format_issues(issues, verbose, colors)
181
+ output = []
182
+ grouped = issues.group_by { |i| i[:type] }
183
+
184
+ if grouped[:error]
185
+ output << "\n#{colorize("#{ICONS[:error]} Errors (#{grouped[:error].size})", :red, colors)}"
186
+ grouped[:error].each_with_index do |issue, i|
187
+ output << format_issue(issue, i + 1, colors)
188
+ end
189
+ end
190
+
191
+ if grouped[:warning]
192
+ output << "\n#{colorize("#{ICONS[:warning]} Warnings (#{grouped[:warning].size})", :yellow, colors)}"
193
+ if verbose || grouped[:warning].size <= 10
194
+ grouped[:warning].each_with_index do |issue, i|
195
+ output << format_issue(issue, i + 1, colors)
196
+ end
197
+ else
198
+ grouped[:warning].first(5).each_with_index do |issue, i|
199
+ output << format_issue(issue, i + 1, colors)
200
+ end
201
+ output << " ... and #{grouped[:warning].size - 5} more warnings (use --verbose to see all)"
202
+ end
203
+ end
204
+
205
+ output
206
+ end
207
+
208
+ def format_issue(issue, number, colors)
209
+ location = issue[:location] ? " (#{colorize(issue[:location], :blue, colors)})" : ""
210
+ "#{number}. #{issue[:message]}#{location}"
211
+ end
212
+
213
+ def format_health_score(score, colors)
214
+ color = if score >= 90
215
+ :green
216
+ elsif score >= 70
217
+ :yellow
218
+ else
219
+ :red
220
+ end
221
+
222
+ status = if score >= 90
223
+ "Excellent"
224
+ elsif score >= 70
225
+ "Good"
226
+ elsif score >= 50
227
+ "Fair"
228
+ else
229
+ "Poor"
230
+ end
231
+
232
+ "#{colorize("#{score}/100", color, colors)} (#{status})"
233
+ end
234
+
235
+ def format_issue_summary(stats, colors)
236
+ parts = []
237
+ parts << colorize("#{stats[:errors]} errors", :red, colors) if stats[:errors] > 0
238
+ parts << colorize("#{stats[:warnings]} warnings", :yellow, colors) if stats[:warnings] > 0
239
+
240
+ if parts.empty?
241
+ colorize("No issues found", :green, colors)
242
+ else
243
+ parts.join(", ")
244
+ end
245
+ end
246
+
247
+ def format_duration(duration)
248
+ if duration < 1
249
+ "#{(duration * 1000).round}ms"
250
+ else
251
+ "#{duration.round(2)}s"
252
+ end
253
+ end
254
+
255
+ def colorize(text, color, enabled = true)
256
+ return text unless enabled && COLORS[color]
257
+
258
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/items"
5
+ require_relative "../atoms/idea_validation_rules"
6
+ require_relative "../atoms/idea_id_formatter"
7
+
8
+ module Ace
9
+ module Idea
10
+ module Molecules
11
+ # Validates idea spec file frontmatter for correctness and completeness.
12
+ # Returns an array of issue hashes with :type, :message, and :location keys.
13
+ class IdeaFrontmatterValidator
14
+ # Validate a single idea spec file
15
+ # @param file_path [String] Path to the .idea.s.md file
16
+ # @param special_folder [String, nil] Special folder the idea is in
17
+ # @return [Array<Hash>] List of issues found
18
+ def self.validate(file_path, special_folder: nil)
19
+ issues = []
20
+
21
+ unless File.exist?(file_path)
22
+ issues << {type: :error, message: "File does not exist", location: file_path}
23
+ return issues
24
+ end
25
+
26
+ content = File.read(file_path)
27
+
28
+ # Check delimiters
29
+ validate_delimiters(content, file_path, issues)
30
+ return issues if issues.any? { |i| i[:type] == :error }
31
+
32
+ # Parse frontmatter
33
+ frontmatter = parse_frontmatter(content, file_path, issues)
34
+ return issues unless frontmatter
35
+
36
+ # Validate required fields
37
+ validate_required_fields(frontmatter, file_path, issues)
38
+
39
+ # Validate field values
40
+ validate_field_values(frontmatter, file_path, issues)
41
+
42
+ # Validate recommended fields
43
+ validate_recommended_fields(frontmatter, file_path, content, issues)
44
+
45
+ # Validate scope/status consistency
46
+ validate_scope_consistency(frontmatter, special_folder, file_path, issues)
47
+
48
+ issues
49
+ end
50
+
51
+ class << self
52
+ private
53
+
54
+ def validate_delimiters(content, file_path, issues)
55
+ lines = content.lines
56
+
57
+ unless lines.first&.strip == "---"
58
+ issues << {type: :error, message: "Missing opening '---' delimiter", location: file_path}
59
+ return
60
+ end
61
+
62
+ # Find closing delimiter (skip first line)
63
+ has_closing = lines[1..].any? { |line| line.strip == "---" }
64
+ unless has_closing
65
+ issues << {type: :error, message: "Missing closing '---' delimiter", location: file_path}
66
+ end
67
+ end
68
+
69
+ def parse_frontmatter(content, file_path, issues)
70
+ frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
71
+
72
+ if frontmatter.nil? || !frontmatter.is_a?(Hash)
73
+ issues << {type: :error, message: "Failed to parse YAML frontmatter", location: file_path}
74
+ return nil
75
+ end
76
+
77
+ frontmatter
78
+ rescue Psych::SyntaxError => e
79
+ issues << {type: :error, message: "YAML syntax error: #{e.message}", location: file_path}
80
+ nil
81
+ end
82
+
83
+ def validate_required_fields(frontmatter, file_path, issues)
84
+ missing = Atoms::IdeaValidationRules.missing_required_fields(frontmatter)
85
+
86
+ missing.each do |field|
87
+ severity = (field == "title") ? :warning : :error
88
+ issues << {type: severity, message: "Missing required field: #{field}", location: file_path}
89
+ end
90
+ end
91
+
92
+ def validate_field_values(frontmatter, file_path, issues)
93
+ # Validate ID format
94
+ if frontmatter["id"] && !Atoms::IdeaValidationRules.valid_id?(frontmatter["id"].to_s)
95
+ issues << {type: :error, message: "Invalid idea ID format: '#{frontmatter["id"]}'", location: file_path}
96
+ end
97
+
98
+ # Validate status value
99
+ if frontmatter["status"] && !Atoms::IdeaValidationRules.valid_status?(frontmatter["status"])
100
+ issues << {type: :error, message: "Invalid status value: '#{frontmatter["status"]}'", location: file_path}
101
+ end
102
+
103
+ # Validate tags is an array
104
+ if frontmatter.key?("tags") && !frontmatter["tags"].is_a?(Array)
105
+ issues << {type: :warning, message: "Field 'tags' is not an array", location: file_path}
106
+ end
107
+
108
+ if frontmatter.key?("location")
109
+ issues << {type: :warning, message: "Derived field 'location' should not be stored in frontmatter", location: file_path}
110
+ end
111
+ end
112
+
113
+ def validate_recommended_fields(frontmatter, file_path, content, issues)
114
+ missing = Atoms::IdeaValidationRules.missing_recommended_fields(frontmatter)
115
+
116
+ missing.each do |field|
117
+ issues << {type: :warning, message: "Missing recommended field: #{field}", location: file_path}
118
+ end
119
+ end
120
+
121
+ def validate_scope_consistency(frontmatter, special_folder, file_path, issues)
122
+ return unless frontmatter["status"]
123
+
124
+ scope_issues = Atoms::IdeaValidationRules.scope_consistent?(
125
+ frontmatter["status"],
126
+ special_folder
127
+ )
128
+
129
+ scope_issues.each do |issue|
130
+ issues << issue.merge(location: file_path)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Idea
7
+ module Molecules
8
+ # Enhances ideas with LLM to produce the 3-Question Brief structure.
9
+ # System prompt is hardcoded for this iteration (as specified by task 291.01).
10
+ #
11
+ # 3-Question Brief structure:
12
+ # - What I Hope to Accomplish
13
+ # - What "Complete" Looks Like
14
+ # - Success Criteria
15
+ class IdeaLlmEnhancer
16
+ # Hardcoded system prompt for 3-Question Brief generation
17
+ SYSTEM_PROMPT = <<~PROMPT
18
+ You are an assistant that helps structure raw software development ideas into clear, actionable briefs.
19
+
20
+ Given a raw idea, produce a structured response as a JSON object with these fields:
21
+ - "title": a concise, clear title for the idea (max 60 chars)
22
+ - "enhanced_content": the full enhanced idea in markdown with exactly these 3 sections:
23
+ ## What I Hope to Accomplish
24
+ (The desired impact or outcome - why this matters)
25
+
26
+ ## What "Complete" Looks Like
27
+ (A concrete end state that would indicate this idea is fully realized)
28
+
29
+ ## Success Criteria
30
+ (Verifiable checks that confirm success - use bullet points)
31
+
32
+ Keep it concise and actionable. Respond with valid JSON only.
33
+ PROMPT
34
+
35
+ # @param config [Hash] Configuration hash (may contain llm_model)
36
+ def initialize(config: {})
37
+ @config = config
38
+ @model = config.dig("idea", "llm_model") ||
39
+ config["llm_model"] ||
40
+ "gflash"
41
+ end
42
+
43
+ # Enhance content using LLM
44
+ # @param content [String] Raw idea content
45
+ # @return [Hash] Result with :success, :content (on success), :error (on failure)
46
+ def enhance(content)
47
+ return fallback_enhancement(content) unless llm_available?
48
+
49
+ result = call_llm(content)
50
+ if result[:success]
51
+ format_enhanced(result[:data], content)
52
+ else
53
+ fallback_enhancement(content)
54
+ end
55
+ rescue => e
56
+ fallback_enhancement(content, error: e.message)
57
+ end
58
+
59
+ private
60
+
61
+ def llm_available?
62
+ # Check if ace-llm is loadable
63
+ require "ace/llm/query_interface"
64
+ true
65
+ rescue LoadError
66
+ false
67
+ end
68
+
69
+ def call_llm(content)
70
+ require "ace/llm/query_interface"
71
+
72
+ prompt = "Structure this idea into a 3-Question Brief:\n\n#{content}"
73
+
74
+ response = Ace::LLM::QueryInterface.query(
75
+ @model,
76
+ prompt,
77
+ system: SYSTEM_PROMPT,
78
+ temperature: 0.3,
79
+ max_tokens: 2000
80
+ )
81
+
82
+ if response[:text]
83
+ text = response[:text].strip
84
+ # Extract JSON from optional markdown code block (handles preamble text)
85
+ if (m = text.match(/```(?:json)?\s*\n?(.*?)\n?```/m))
86
+ text = m[1].strip
87
+ end
88
+
89
+ data = JSON.parse(text)
90
+ {success: true, data: data}
91
+ else
92
+ {success: false, error: "No text in LLM response"}
93
+ end
94
+ rescue JSON::ParserError => e
95
+ {success: false, error: "Invalid JSON from LLM: #{e.message}"}
96
+ rescue => e
97
+ {success: false, error: e.message}
98
+ end
99
+
100
+ def format_enhanced(data, _original_content)
101
+ title = data["title"] || "Untitled Idea"
102
+ enhanced = data["enhanced_content"] || generate_stub_content(_original_content)
103
+
104
+ content = "# #{title}\n\n#{enhanced}"
105
+
106
+ {success: true, content: content, title: title}
107
+ end
108
+
109
+ def fallback_enhancement(content, error: nil)
110
+ title = extract_title(content)
111
+
112
+ # Build stub structure
113
+ body = []
114
+ body << "# #{title}"
115
+ body << ""
116
+ body << "## What I Hope to Accomplish"
117
+ body << ""
118
+ body << "_[What impact should this have? Why does it matter?]_"
119
+ body << ""
120
+ body << "## What \"Complete\" Looks Like"
121
+ body << ""
122
+ body << "_[What concrete end state would indicate this idea is fully realized?]_"
123
+ body << ""
124
+ body << "## Success Criteria"
125
+ body << ""
126
+ body << "_[What verifiable checks would confirm success?]_"
127
+ body << ""
128
+ body << "---"
129
+ body << ""
130
+ body << "## Original Idea"
131
+ body << ""
132
+ body << content.strip
133
+
134
+ enhanced_content = body.join("\n")
135
+
136
+ if error
137
+ {success: true, content: enhanced_content, fallback: true, error: error}
138
+ else
139
+ {success: true, content: enhanced_content, fallback: true}
140
+ end
141
+ end
142
+
143
+ def generate_stub_content(content)
144
+ <<~STUB
145
+ ## What I Hope to Accomplish
146
+
147
+ _[What impact should this have? Why does it matter?]_
148
+
149
+ ## What "Complete" Looks Like
150
+
151
+ _[What concrete end state would indicate this idea is fully realized?]_
152
+
153
+ ## Success Criteria
154
+
155
+ _[What verifiable checks would confirm success?]_
156
+
157
+ ---
158
+
159
+ ## Original Idea
160
+
161
+ #{content.strip}
162
+ STUB
163
+ end
164
+
165
+ def extract_title(content)
166
+ return "Untitled Idea" if content.nil? || content.strip.empty?
167
+
168
+ match = content.match(/^#\s+(.+)$/)
169
+ return match[1].strip if match
170
+
171
+ first_line = content.split("\n").first&.strip || ""
172
+ first_line.empty? ? "Untitled Idea" : first_line[0..59]
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../atoms/idea_file_pattern"
5
+ require_relative "../atoms/idea_frontmatter_defaults"
6
+ require_relative "../models/idea"
7
+
8
+ # Shared atoms from ace-support-items
9
+ require "ace/support/items"
10
+
11
+ module Ace
12
+ module Idea
13
+ module Molecules
14
+ # Loads an idea from its directory, parsing frontmatter + body,
15
+ # and enumerating attachments (images, files).
16
+ class IdeaLoader
17
+ # Load an idea from a ScanResult
18
+ # @param scan_result [ScanResult] Scan result pointing to the idea directory
19
+ # @return [Idea, nil] Loaded idea or nil if load fails
20
+ def self.from_scan_result(scan_result)
21
+ new.load(scan_result.dir_path,
22
+ id: scan_result.id,
23
+ special_folder: scan_result.special_folder)
24
+ end
25
+
26
+ # Load an idea from a directory path
27
+ # @param dir_path [String] Path to the idea directory
28
+ # @param id [String, nil] Known ID (extracted from folder name if nil)
29
+ # @param special_folder [String, nil] Known special folder
30
+ # @return [Idea, nil] Loaded idea or nil
31
+ def load(dir_path, id: nil, special_folder: nil)
32
+ return nil unless Dir.exist?(dir_path)
33
+
34
+ # Find the spec file
35
+ spec_file = Dir.glob(File.join(dir_path, Atoms::IdeaFilePattern::FILE_GLOB)).first
36
+ return nil unless spec_file
37
+
38
+ # Extract ID from folder name if not provided
39
+ folder_name = File.basename(dir_path)
40
+ id ||= extract_id(folder_name)
41
+
42
+ # Parse the spec file
43
+ content = File.read(spec_file)
44
+ frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
45
+
46
+ # Enumerate attachments (all non-.idea.s.md files in the directory)
47
+ attachments = list_attachments(dir_path)
48
+
49
+ # Extract title from frontmatter or body header
50
+ title = frontmatter["title"] || Ace::Support::Items::Atoms::TitleExtractor.extract(body) || folder_name
51
+
52
+ # Parse creation time
53
+ created_at = parse_created_at(frontmatter["created_at"], id)
54
+
55
+ # Extract known fields, preserve others in metadata
56
+ known_keys = %w[id status title tags created_at]
57
+ extra_metadata = frontmatter.reject { |k, _| known_keys.include?(k) }
58
+
59
+ Models::Idea.new(
60
+ id: id || frontmatter["id"],
61
+ status: normalize_status(frontmatter["status"] || "pending"),
62
+ title: title,
63
+ tags: Array(frontmatter["tags"]),
64
+ content: body.to_s.strip,
65
+ path: dir_path,
66
+ file_path: spec_file,
67
+ special_folder: special_folder,
68
+ created_at: created_at,
69
+ attachments: attachments,
70
+ metadata: extra_metadata
71
+ )
72
+ rescue
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def extract_id(folder_name)
79
+ match = folder_name.match(/^([0-9a-z]{6})/)
80
+ match ? match[1] : nil
81
+ end
82
+
83
+ def list_attachments(dir_path)
84
+ Dir.glob(File.join(dir_path, "*"))
85
+ .select { |f| File.file?(f) }
86
+ .reject { |f| f.end_with?(Atoms::IdeaFilePattern::FILE_EXTENSION) }
87
+ .map { |f| File.basename(f) }
88
+ .reject { |name| name.start_with?(".") } # skip hidden OS files (.DS_Store etc)
89
+ .sort
90
+ end
91
+
92
+ def parse_created_at(value, id)
93
+ return Time.now if value.nil?
94
+
95
+ case value
96
+ when Time then value
97
+ when String
98
+ begin
99
+ Time.parse(value)
100
+ rescue ArgumentError
101
+ id ? decode_time_from_id(id) : Time.now
102
+ end
103
+ else
104
+ Time.now
105
+ end
106
+ end
107
+
108
+ def decode_time_from_id(id)
109
+ require "ace/b36ts"
110
+ Ace::B36ts.decode(id)
111
+ rescue
112
+ Time.now
113
+ end
114
+
115
+ def normalize_status(status)
116
+ value = status.to_s
117
+ return "obsolete" if value == "cancelled"
118
+
119
+ value
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end