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,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
|