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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ace/support/items"
5
+
6
+ module Ace
7
+ module Idea
8
+ module Molecules
9
+ # Moves idea folders to different locations within the ideas root directory.
10
+ # Delegates to SpecialFolderDetector for folder name normalization.
11
+ class IdeaMover
12
+ # @param root_dir [String] Root directory for ideas
13
+ def initialize(root_dir)
14
+ @root_dir = root_dir
15
+ end
16
+
17
+ # Move an idea folder to a target location
18
+ # @param idea [Idea] Idea to move
19
+ # @param to [String] Target folder name (short or full, e.g., "maybe", "_archive")
20
+ # @param date [Time, nil] Date used to compute archive partition (default: Time.now)
21
+ # @return [String] New path of the idea directory
22
+ def move(idea, to:, date: nil)
23
+ normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(to)
24
+
25
+ target_parent = if normalized == "_archive"
26
+ partition = Ace::Support::Items::Atoms::DatePartitionPath.compute(date || Time.now)
27
+ File.expand_path(File.join(@root_dir, normalized, partition))
28
+ else
29
+ File.expand_path(File.join(@root_dir, normalized))
30
+ end
31
+
32
+ root_real = File.expand_path(@root_dir)
33
+ unless target_parent.start_with?(root_real + File::SEPARATOR)
34
+ raise ArgumentError, "Path traversal detected in --to option"
35
+ end
36
+ FileUtils.mkdir_p(target_parent)
37
+
38
+ folder_name = File.basename(idea.path)
39
+ new_path = File.join(target_parent, folder_name)
40
+
41
+ # Same-location no-op check
42
+ return idea.path if File.expand_path(idea.path) == File.expand_path(new_path)
43
+
44
+ atomic_move(idea.path, new_path)
45
+ end
46
+
47
+ # Move an idea to root (remove from special folder)
48
+ # @param idea [Idea] Idea to move
49
+ # @return [String] New path of the idea directory
50
+ def move_to_root(idea)
51
+ folder_name = File.basename(idea.path)
52
+ new_path = File.join(@root_dir, folder_name)
53
+
54
+ # Same-location no-op check
55
+ return idea.path if File.expand_path(idea.path) == File.expand_path(new_path)
56
+
57
+ atomic_move(idea.path, new_path)
58
+ end
59
+
60
+ private
61
+
62
+ # Move src to dest, raising ArgumentError if dest already exists.
63
+ def atomic_move(src, dest)
64
+ raise ArgumentError, "Destination already exists: #{dest}" if File.exist?(dest)
65
+
66
+ begin
67
+ File.rename(src, dest)
68
+ rescue Errno::EXDEV
69
+ # Cross-device: fall back to copy+remove
70
+ FileUtils.cp_r(src, dest)
71
+ FileUtils.rm_rf(src)
72
+ end
73
+ dest
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/items"
4
+ require_relative "idea_scanner"
5
+
6
+ module Ace
7
+ module Idea
8
+ module Molecules
9
+ # Wraps ShortcutResolver for raw idea IDs (no .t. type marker).
10
+ # Resolves 3-char suffix shortcuts, full 6-char IDs.
11
+ # Explicitly detects and warns on ambiguity collisions.
12
+ class IdeaResolver
13
+ # @param root_dir [String] Root directory containing ideas
14
+ def initialize(root_dir)
15
+ @root_dir = root_dir
16
+ @scanner = IdeaScanner.new(root_dir)
17
+ end
18
+
19
+ # Resolve a reference to a scan result
20
+ # @param ref [String] Full ID (6 chars) or suffix shortcut (3 chars)
21
+ # @param warn_on_ambiguity [Boolean] Whether to print warning on ambiguity
22
+ # @return [ScanResult, nil] Resolved result or nil
23
+ def resolve(ref, warn_on_ambiguity: true)
24
+ scan_results = @scanner.scan
25
+ resolver = Ace::Support::Items::Molecules::ShortcutResolver.new(scan_results)
26
+
27
+ on_ambiguity = if warn_on_ambiguity
28
+ ->(matches) {
29
+ ids = matches.map(&:id).join(", ")
30
+ warn "Warning: Ambiguous shortcut '#{ref}' matches #{matches.size} ideas: #{ids}. Using most recent."
31
+ }
32
+ end
33
+
34
+ resolver.resolve(ref, on_ambiguity: on_ambiguity)
35
+ end
36
+
37
+ # Resolve with explicit ambiguity detection
38
+ # @param ref [String] Reference to resolve
39
+ # @return [Hash] Result with :result, :ambiguous, :matches keys
40
+ def resolve_with_info(ref)
41
+ scan_results = @scanner.scan
42
+ resolver = Ace::Support::Items::Molecules::ShortcutResolver.new(scan_results)
43
+
44
+ matches = resolver.all_matches(ref)
45
+
46
+ if matches.empty?
47
+ {result: nil, ambiguous: false, matches: []}
48
+ elsif matches.size == 1
49
+ {result: matches.first, ambiguous: false, matches: matches}
50
+ else
51
+ {result: matches.last, ambiguous: true, matches: matches}
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/items"
4
+ require_relative "../atoms/idea_file_pattern"
5
+
6
+ module Ace
7
+ module Idea
8
+ module Molecules
9
+ # Wraps DirectoryScanner for .idea.s.md files.
10
+ # Returns scan results with raw b36ts IDs (no type markers).
11
+ class IdeaScanner
12
+ attr_reader :last_scan_total, :last_folder_counts
13
+
14
+ # @param root_dir [String] Root directory containing ideas (e.g., ".ace-ideas")
15
+ def initialize(root_dir)
16
+ @root_dir = root_dir
17
+ @scanner = Ace::Support::Items::Molecules::DirectoryScanner.new(
18
+ root_dir,
19
+ file_pattern: Atoms::IdeaFilePattern::FILE_GLOB
20
+ )
21
+ end
22
+
23
+ # Scan for all ideas
24
+ # @return [Array<ScanResult>] Sorted scan results
25
+ def scan
26
+ @scanner.scan
27
+ end
28
+
29
+ # Scan and filter by special folder or virtual filter
30
+ # @param folder [String, nil] Folder name, virtual filter ("next", "all"), or nil for all
31
+ # @return [Array<ScanResult>] Filtered scan results
32
+ def scan_in_folder(folder)
33
+ results = scan
34
+ @last_scan_total = results.size
35
+ @last_folder_counts = results.group_by(&:special_folder).transform_values(&:size)
36
+ return results if folder.nil?
37
+
38
+ virtual = Ace::Support::Items::Atoms::SpecialFolderDetector.virtual_filter?(folder)
39
+ case virtual
40
+ when :all then results
41
+ when :next then results.select { |r| r.special_folder.nil? }
42
+ else
43
+ normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(folder)
44
+ results.select { |r| r.special_folder == normalized }
45
+ end
46
+ end
47
+
48
+ # Check if root directory exists
49
+ # @return [Boolean]
50
+ def root_exists?
51
+ Dir.exist?(@root_dir)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/idea_file_pattern"
4
+ require_relative "../atoms/idea_id_formatter"
5
+
6
+ module Ace
7
+ module Idea
8
+ module Molecules
9
+ # Validates the directory structure of an ideas root directory.
10
+ # Checks folder naming, file naming, and structural conventions.
11
+ class IdeaStructureValidator
12
+ # @param root_dir [String] Root directory for ideas (e.g., ".ace-ideas")
13
+ def initialize(root_dir)
14
+ @root_dir = root_dir
15
+ end
16
+
17
+ # Validate the entire ideas directory structure
18
+ # @return [Array<Hash>] List of issues found
19
+ def validate(root_dir = @root_dir)
20
+ issues = []
21
+
22
+ unless Dir.exist?(root_dir)
23
+ issues << {type: :error, message: "Ideas root directory does not exist", location: root_dir}
24
+ return issues
25
+ end
26
+
27
+ check_folder_naming(root_dir, issues)
28
+ check_spec_files(root_dir, issues)
29
+ check_stale_backups(root_dir, issues)
30
+ check_empty_directories(root_dir, issues)
31
+
32
+ issues
33
+ end
34
+
35
+ private
36
+
37
+ # Check that idea folders follow {6-char-id}-{slug} naming
38
+ def check_folder_naming(root_dir, issues)
39
+ idea_dirs(root_dir).each do |dir|
40
+ folder_name = File.basename(dir)
41
+
42
+ # Skip special folders
43
+ next if folder_name.start_with?("_")
44
+
45
+ unless folder_name.match?(/^[0-9a-z]{6}-.+$/)
46
+ issues << {
47
+ type: :error,
48
+ message: "Folder name does not match '{id}-{slug}' convention: '#{folder_name}'",
49
+ location: dir
50
+ }
51
+ end
52
+ end
53
+ end
54
+
55
+ # Check that each idea folder has exactly one .idea.s.md spec file
56
+ def check_spec_files(root_dir, issues)
57
+ idea_dirs(root_dir).each do |dir|
58
+ folder_name = File.basename(dir)
59
+ next if folder_name.start_with?("_")
60
+
61
+ spec_files = Dir.glob(File.join(dir, Atoms::IdeaFilePattern::FILE_GLOB))
62
+
63
+ if spec_files.empty?
64
+ issues << {
65
+ type: :warning,
66
+ message: "No .idea.s.md spec file in idea folder",
67
+ location: dir
68
+ }
69
+ elsif spec_files.size > 1
70
+ issues << {
71
+ type: :warning,
72
+ message: "Multiple .idea.s.md spec files in folder (#{spec_files.size} found)",
73
+ location: dir
74
+ }
75
+ end
76
+ end
77
+ end
78
+
79
+ # Check for stale backup/tmp files
80
+ def check_stale_backups(root_dir, issues)
81
+ backup_patterns = [
82
+ File.join(root_dir, "**", "*.backup.*"),
83
+ File.join(root_dir, "**", "*.tmp"),
84
+ File.join(root_dir, "**", "*~")
85
+ ]
86
+
87
+ backup_patterns.each do |pattern|
88
+ Dir.glob(pattern).each do |file|
89
+ next if file.include?("/.git/")
90
+
91
+ issues << {
92
+ type: :warning,
93
+ message: "Stale backup file (safe to delete)",
94
+ location: file
95
+ }
96
+ end
97
+ end
98
+ end
99
+
100
+ # Check for empty directories
101
+ def check_empty_directories(root_dir, issues)
102
+ Dir.glob(File.join(root_dir, "**", "*")).each do |path|
103
+ next unless File.directory?(path)
104
+ next if path.include?("/.git/")
105
+
106
+ files = Dir.glob(File.join(path, "**", "*")).select { |f| File.file?(f) }
107
+ if files.empty?
108
+ issues << {
109
+ type: :warning,
110
+ message: "Empty directory (safe to delete)",
111
+ location: path
112
+ }
113
+ end
114
+ end
115
+ end
116
+
117
+ # Find all immediate subdirectories that look like idea folders
118
+ # (excludes special folders which are containers, includes their children)
119
+ # Also excludes category folders (folders containing only subdirectories)
120
+ def idea_dirs(root_dir)
121
+ dirs = []
122
+
123
+ # Direct children of root
124
+ Dir.glob(File.join(root_dir, "*")).each do |path|
125
+ next unless File.directory?(path)
126
+
127
+ folder_name = File.basename(path)
128
+ if folder_name.start_with?("_")
129
+ # Recurse into special folders
130
+ Dir.glob(File.join(path, "*")).each do |subpath|
131
+ dirs << subpath if File.directory?(subpath) && !category_folder?(subpath)
132
+ end
133
+ else
134
+ dirs << path unless category_folder?(path)
135
+ end
136
+ end
137
+
138
+ dirs
139
+ end
140
+
141
+ # Check if a folder is a category folder (only contains subdirectories, no files)
142
+ # These are organizational folders and not individual ideas
143
+ def category_folder?(dir_path)
144
+ return false unless Dir.exist?(dir_path)
145
+
146
+ # Check if folder has any files
147
+ files = Dir.glob(File.join(dir_path, "*")).select { |f| File.file?(f) }
148
+ return false if files.any?
149
+
150
+ # Check if folder has any subdirectories
151
+ subdirs = Dir.glob(File.join(dir_path, "*")).select { |f| File.directory?(f) }
152
+ subdirs.any?
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../molecules/idea_scanner"
4
+ require_relative "../molecules/idea_loader"
5
+ require_relative "../molecules/idea_frontmatter_validator"
6
+ require_relative "../molecules/idea_structure_validator"
7
+ require_relative "../atoms/idea_validation_rules"
8
+
9
+ module Ace
10
+ module Idea
11
+ module Organisms
12
+ # Orchestrates comprehensive health checks for the ideas system.
13
+ # Runs structure validation, frontmatter validation, and scope/status
14
+ # consistency checks across all ideas in a root directory.
15
+ class IdeaDoctor
16
+ attr_reader :root_path, :options
17
+
18
+ # @param root_path [String] Path to ideas root directory
19
+ # @param options [Hash] Diagnosis options (:check, :verbose, etc.)
20
+ def initialize(root_path, options = {})
21
+ @root_path = root_path
22
+ @options = options
23
+ @issues = []
24
+ @stats = {
25
+ ideas_scanned: 0,
26
+ folders_checked: 0,
27
+ errors: 0,
28
+ warnings: 0,
29
+ info: 0
30
+ }
31
+ end
32
+
33
+ # Run comprehensive health check
34
+ # @return [Hash] Diagnosis results
35
+ def run_diagnosis
36
+ unless @root_path && Dir.exist?(@root_path)
37
+ return {
38
+ valid: false,
39
+ health_score: 0,
40
+ issues: [{type: :error, message: "Ideas root directory not found: #{@root_path}"}],
41
+ stats: @stats,
42
+ duration: 0,
43
+ root_path: @root_path
44
+ }
45
+ end
46
+
47
+ @start_time = Time.now
48
+
49
+ if options[:check]
50
+ run_specific_check(options[:check])
51
+ else
52
+ run_full_check
53
+ end
54
+
55
+ health_score = calculate_health_score
56
+
57
+ {
58
+ valid: @stats[:errors] == 0,
59
+ health_score: health_score,
60
+ issues: @issues,
61
+ stats: @stats,
62
+ duration: Time.now - @start_time,
63
+ root_path: @root_path
64
+ }
65
+ end
66
+
67
+ # Check if an issue can be auto-fixed
68
+ # @param issue [Hash] Issue to check
69
+ # @return [Boolean]
70
+ def auto_fixable?(issue)
71
+ return false unless issue[:type] == :error || issue[:type] == :warning
72
+
73
+ Molecules::IdeaDoctorFixer::FIXABLE_PATTERNS.any? { |pattern| issue[:message].match?(pattern) }
74
+ end
75
+
76
+ private
77
+
78
+ def run_full_check
79
+ # 1. Structure checks (folder naming, file naming, backups, empty dirs)
80
+ run_structure_check
81
+
82
+ # 2. Frontmatter checks (per-file validation)
83
+ run_frontmatter_check
84
+
85
+ # 3. Scope/status consistency (cross-cutting)
86
+ run_scope_check
87
+ end
88
+
89
+ def run_specific_check(check_type)
90
+ case check_type.to_s
91
+ when "structure"
92
+ run_structure_check
93
+ when "frontmatter"
94
+ run_frontmatter_check
95
+ when "scope"
96
+ run_scope_check
97
+ else
98
+ add_issue(:error, "Unknown check type: #{check_type}")
99
+ end
100
+ end
101
+
102
+ def run_structure_check
103
+ validator = Molecules::IdeaStructureValidator.new(@root_path)
104
+ issues = validator.validate
105
+
106
+ issues.each do |issue|
107
+ add_issue(issue[:type], issue[:message], issue[:location])
108
+ end
109
+
110
+ # Count folders checked
111
+ @stats[:folders_checked] = count_idea_folders
112
+ end
113
+
114
+ def run_frontmatter_check
115
+ scanner = Molecules::IdeaScanner.new(@root_path)
116
+ return unless scanner.root_exists?
117
+
118
+ scan_results = scanner.scan
119
+ @stats[:ideas_scanned] = scan_results.size
120
+
121
+ scan_results.each do |scan_result|
122
+ spec_file = scan_result.file_path
123
+ next unless spec_file && File.exist?(spec_file)
124
+
125
+ issues = Molecules::IdeaFrontmatterValidator.validate(
126
+ spec_file,
127
+ special_folder: scan_result.special_folder
128
+ )
129
+
130
+ # Filter out scope issues here (handled separately in run_scope_check)
131
+ issues.reject! { |i| scope_issue?(i[:message]) }
132
+
133
+ issues.each do |issue|
134
+ add_issue(issue[:type], issue[:message], issue[:location])
135
+ end
136
+ end
137
+ end
138
+
139
+ def run_scope_check
140
+ scanner = Molecules::IdeaScanner.new(@root_path)
141
+ return unless scanner.root_exists?
142
+
143
+ scan_results = scanner.scan
144
+
145
+ scan_results.each do |scan_result|
146
+ spec_file = scan_result.file_path
147
+ next unless spec_file && File.exist?(spec_file)
148
+
149
+ # Load frontmatter for scope checking
150
+ content = File.read(spec_file)
151
+ frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
152
+ next unless frontmatter.is_a?(Hash)
153
+
154
+ status = frontmatter["status"]
155
+ special_folder = scan_result.special_folder
156
+
157
+ scope_issues = Atoms::IdeaValidationRules.scope_consistent?(status, special_folder)
158
+ scope_issues.each do |issue|
159
+ add_issue(issue[:type], issue[:message], spec_file)
160
+ end
161
+ end
162
+ end
163
+
164
+ def scope_issue?(message)
165
+ message.include?("not in _archive") ||
166
+ message.include?("in _archive/ but status") ||
167
+ message.include?("in _maybe/ with terminal")
168
+ end
169
+
170
+ def count_idea_folders
171
+ return 0 unless Dir.exist?(@root_path)
172
+
173
+ count = 0
174
+ Dir.glob(File.join(@root_path, "*")).each do |path|
175
+ next unless File.directory?(path)
176
+
177
+ count += if File.basename(path).start_with?("_")
178
+ Dir.glob(File.join(path, "*")).count { |p| File.directory?(p) }
179
+ else
180
+ 1
181
+ end
182
+ end
183
+ count
184
+ end
185
+
186
+ def add_issue(type, message, location = nil)
187
+ issue = {type: type, message: message}
188
+ issue[:location] = location if location
189
+ @issues << issue
190
+
191
+ case type
192
+ when :error then @stats[:errors] += 1
193
+ when :warning then @stats[:warnings] += 1
194
+ when :info then @stats[:info] += 1
195
+ end
196
+ end
197
+
198
+ def calculate_health_score
199
+ score = 100
200
+ score -= @stats[:errors] * 10
201
+ score -= @stats[:warnings] * 2
202
+ [[score, 0].max, 100].min
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end