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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/support/items"
5
+
6
+ module Ace
7
+ module Idea
8
+ module CLI
9
+ module Commands
10
+ # ace-support-cli Command class for ace-idea update
11
+ class Update < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+
14
+ desc <<~DESC.strip
15
+ Update idea metadata and/or move to a folder
16
+
17
+ Updates frontmatter fields using set, add, or remove operations.
18
+ Use --set for scalar fields, --add/--remove for array fields like tags.
19
+ Use --move-to to relocate to a special folder or back to root.
20
+ DESC
21
+
22
+ example [
23
+ "q7w --set status=done",
24
+ 'q7w --set status=in-progress --set title="Refined title"',
25
+ "q7w --add tags=implemented --remove tags=pending-review",
26
+ "q7w --set status=done --add tags=shipped",
27
+ "q7w --set status=done --move-to archive",
28
+ "q7w --move-to next"
29
+ ]
30
+
31
+ argument :ref, required: true, desc: "Idea reference (6-char ID or 3-char shortcut)"
32
+
33
+ option :set, type: :string, repeat: true, desc: "Set field: key=value (can repeat)"
34
+ option :add, type: :string, repeat: true, desc: "Add to array field: key=value (can repeat)"
35
+ option :remove, type: :string, repeat: true, desc: "Remove from array field: key=value (can repeat)"
36
+ option :move_to, type: :string, aliases: %w[-m], desc: "Move to folder (archive, maybe, anytime, next)"
37
+
38
+ option :git_commit, type: :boolean, aliases: %w[--gc], desc: "Auto-commit changes"
39
+
40
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
41
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
42
+ option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
43
+
44
+ def call(ref:, **options)
45
+ set_args = Array(options[:set])
46
+ add_args = Array(options[:add])
47
+ remove_args = Array(options[:remove])
48
+ move_to = options[:move_to]
49
+
50
+ if set_args.empty? && add_args.empty? && remove_args.empty? && move_to.nil?
51
+ warn "Error: at least one of --set, --add, --remove, or --move-to is required"
52
+ warn ""
53
+ warn "Usage: ace-idea update REF [--set K=V]... [--add K=V]... [--remove K=V]... [--move-to FOLDER]"
54
+ raise Ace::Support::Cli::Error.new("No update operations specified")
55
+ end
56
+
57
+ set_hash = parse_kv_pairs(set_args)
58
+ add_hash = parse_kv_pairs(add_args)
59
+ remove_hash = parse_kv_pairs(remove_args)
60
+
61
+ manager = Ace::Idea::Organisms::IdeaManager.new
62
+ idea = manager.update(ref, set: set_hash, add: add_hash, remove: remove_hash, move_to: move_to)
63
+
64
+ unless idea
65
+ raise Ace::Support::Cli::Error.new("Idea '#{ref}' not found")
66
+ end
67
+
68
+ if move_to
69
+ folder_info = idea.special_folder || "root"
70
+ puts "Idea updated: #{idea.id} #{idea.title} → #{folder_info}"
71
+ else
72
+ puts "Idea updated: #{idea.id} #{idea.title}"
73
+ end
74
+
75
+ if options[:git_commit]
76
+ commit_paths = move_to ? [manager.root_dir] : [idea.path]
77
+ intention = if move_to
78
+ "update idea #{idea.id} and move to #{idea.special_folder || "root"}"
79
+ else
80
+ "update idea #{idea.id}"
81
+ end
82
+ Ace::Support::Items::Molecules::GitCommitter.commit(
83
+ paths: commit_paths,
84
+ intention: intention
85
+ )
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ # Parse ["key=value", "key=value2"] into {"key" => typed_value, ...}
92
+ # Delegates to FieldArgumentParser for type inference (arrays, booleans, numerics).
93
+ def parse_kv_pairs(args)
94
+ result = {}
95
+ args.each do |arg|
96
+ unless arg.include?("=")
97
+ raise Ace::Support::Cli::Error.new("Invalid format '#{arg}': expected key=value")
98
+ end
99
+
100
+ # parse([arg]) returns {key => typed_value}
101
+ parsed = Ace::Support::Items::Atoms::FieldArgumentParser.parse([arg])
102
+ parsed.each do |key, value|
103
+ result[key] = if result.key?(key)
104
+ Array(result[key]) + Array(value)
105
+ else
106
+ value
107
+ end
108
+ end
109
+ rescue Ace::Support::Items::Atoms::FieldArgumentParser::ParseError => e
110
+ raise Ace::Support::Cli::Error.new("Invalid argument '#{arg}': #{e.message}")
111
+ end
112
+ result
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../idea/version"
6
+ require_relative "cli/commands/create"
7
+ require_relative "cli/commands/show"
8
+ require_relative "cli/commands/list"
9
+ require_relative "cli/commands/update"
10
+ require_relative "cli/commands/doctor"
11
+ require_relative "cli/commands/status"
12
+
13
+ module Ace
14
+ module Idea
15
+ # Flat CLI registry for ace-idea (idea management).
16
+ #
17
+ # Provides the flat `ace-idea <command>` invocation pattern.
18
+ module IdeaCLI
19
+ extend Ace::Support::Cli::RegistryDsl
20
+
21
+ PROGRAM_NAME = "ace-idea"
22
+
23
+ REGISTERED_COMMANDS = [
24
+ ["create", "Create a new idea"],
25
+ ["show", "Show idea details"],
26
+ ["list", "List ideas"],
27
+ ["update", "Update idea metadata (fields and move)"],
28
+ ["doctor", "Run health checks on ideas"],
29
+ ["status", "Show idea status overview"]
30
+ ].freeze
31
+
32
+ HELP_EXAMPLES = [
33
+ 'ace-idea create "Dark mode" --tags ux,design --move-to next',
34
+ "ace-idea show q7w",
35
+ "ace-idea list --in maybe --status pending",
36
+ "ace-idea update q7w --set status=done --move-to archive",
37
+ "ace-idea update q7w --set status=done --add tags=shipped",
38
+ "ace-idea update q7w --move-to next",
39
+ "ace-idea doctor --auto-fix",
40
+ "ace-idea status",
41
+ "ace-idea status --up-next-limit 5"
42
+ ].freeze
43
+
44
+ register "create", CLI::Commands::Create
45
+ register "show", CLI::Commands::Show
46
+ register "list", CLI::Commands::List
47
+ register "update", CLI::Commands::Update
48
+ register "doctor", CLI::Commands::Doctor
49
+ register "status", CLI::Commands::Status
50
+
51
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
52
+ gem_name: "ace-idea",
53
+ version: Ace::Idea::VERSION
54
+ )
55
+ register "version", version_cmd
56
+ register "--version", version_cmd
57
+
58
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
59
+ program_name: PROGRAM_NAME,
60
+ version: Ace::Idea::VERSION,
61
+ commands: REGISTERED_COMMANDS,
62
+ examples: HELP_EXAMPLES
63
+ )
64
+ register "help", help_cmd
65
+ register "--help", help_cmd
66
+ register "-h", help_cmd
67
+
68
+ # Entry point for CLI invocation
69
+ # @param args [Array<String>] Command-line arguments
70
+ def self.start(args)
71
+ Ace::Support::Cli::Runner.new(self).call(args: args)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Idea
5
+ module Models
6
+ # Value object representing an idea
7
+ # Holds all metadata and content for a single idea
8
+ Idea = Struct.new(
9
+ :id, # Raw 6-char b36ts ID (e.g., "8ppq7w")
10
+ :status, # Status string: "pending", "in-progress", "done", "obsolete"
11
+ :title, # Human-readable title
12
+ :tags, # Array of tag strings
13
+ :content, # Body content (markdown, excluding frontmatter)
14
+ :path, # Directory path for the idea folder
15
+ :file_path, # Full path to the .idea.s.md spec file
16
+ :special_folder, # Special folder if any (e.g., "_maybe", nil)
17
+ :created_at, # Time object for creation time
18
+ :attachments, # Array of attachment filenames in the idea folder
19
+ :metadata, # Additional frontmatter fields as Hash
20
+ keyword_init: true
21
+ ) do
22
+ # Display-friendly representation
23
+ def to_s
24
+ "Idea(#{id}: #{title})"
25
+ end
26
+
27
+ # Short reference (last 3 chars of ID)
28
+ def shortcut
29
+ id[-3..] if id
30
+ end
31
+
32
+ # Check if idea is in a special folder
33
+ def special?
34
+ !special_folder.nil?
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Clipboard reader adapted from ace-taskflow's ClipboardReader.
4
+ # Kept independent (no ace-taskflow dependency) as per task spec:
5
+ # "Shared utilities from ace-taskflow are duplicated rather than centralized."
6
+
7
+ begin
8
+ require "clipboard"
9
+ rescue LoadError
10
+ # clipboard gem not available - will gracefully degrade
11
+ end
12
+
13
+ begin
14
+ require "ace/support/mac_clipboard"
15
+ rescue LoadError
16
+ # Not available on this platform
17
+ end
18
+
19
+ module Ace
20
+ module Idea
21
+ module Molecules
22
+ # Reads content from the system clipboard for idea capture.
23
+ # Supports: plain text, RTF, HTML (as text), images (as attachments).
24
+ class IdeaClipboardReader
25
+ MAX_CONTENT_SIZE = 100 * 1024 # 100KB
26
+
27
+ # Read clipboard content
28
+ # @return [Hash] Result with :success, :content, :type, :attachments keys
29
+ def self.read
30
+ if macos? && macos_clipboard_available?
31
+ read_macos
32
+ else
33
+ read_generic
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def self.macos?
40
+ RUBY_PLATFORM.include?("darwin")
41
+ end
42
+
43
+ def self.macos_clipboard_available?
44
+ defined?(Ace::Support::MacClipboard)
45
+ end
46
+
47
+ def self.read_macos
48
+ raw = Ace::Support::MacClipboard::Reader.read
49
+ return {success: false, error: raw[:error]} unless raw[:success]
50
+
51
+ parsed = Ace::Support::MacClipboard::ContentParser.parse(raw)
52
+
53
+ has_attachments = parsed[:attachments].any?
54
+ type = has_attachments ? :rich : :text
55
+ file_attachments = parsed[:attachments].select { |a| a[:type] == :file }
56
+
57
+ {
58
+ success: true,
59
+ platform: :macos,
60
+ type: type,
61
+ content: parsed[:text],
62
+ attachments: parsed[:attachments],
63
+ files: file_attachments.map { |a| a[:source_path] }
64
+ }
65
+ rescue
66
+ read_generic
67
+ end
68
+
69
+ def self.read_generic
70
+ unless defined?(Clipboard)
71
+ return {
72
+ success: false,
73
+ error: "Clipboard gem not available. Install 'clipboard' gem for clipboard support."
74
+ }
75
+ end
76
+
77
+ content = Clipboard.paste
78
+
79
+ if content.nil? || content.strip.empty?
80
+ return {
81
+ success: false,
82
+ error: "Clipboard is empty. Provide text argument or copy content to clipboard."
83
+ }
84
+ end
85
+
86
+ if content.bytesize > MAX_CONTENT_SIZE
87
+ return {
88
+ success: false,
89
+ error: "Clipboard content too large (#{content.bytesize} bytes, max #{MAX_CONTENT_SIZE} bytes)"
90
+ }
91
+ end
92
+
93
+ if content.encoding == Encoding::ASCII_8BIT || content.include?("\x00")
94
+ return {
95
+ success: false,
96
+ error: "Clipboard contains binary data. Only text content is supported."
97
+ }
98
+ end
99
+
100
+ {
101
+ success: true,
102
+ platform: :generic,
103
+ type: :text,
104
+ content: content,
105
+ attachments: [],
106
+ files: []
107
+ }
108
+ rescue => e
109
+ {
110
+ success: false,
111
+ error: "Unable to read clipboard: #{e.message}"
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ace/support/fs"
5
+
6
+ module Ace
7
+ module Idea
8
+ module Molecules
9
+ # Loads and merges configuration for ace-idea from the cascade:
10
+ # .ace-defaults/idea/config.yml (gem) -> ~/.ace/idea/config.yml (user) -> .ace/idea/config.yml (project)
11
+ class IdeaConfigLoader
12
+ DEFAULT_ROOT_DIR = ".ace-ideas"
13
+
14
+ # Load configuration with cascade merge
15
+ # @param gem_root [String] Path to the ace-idea gem root
16
+ # @return [Hash] Merged configuration
17
+ def self.load(gem_root: nil)
18
+ gem_root ||= File.expand_path("../../../..", __dir__)
19
+ new(gem_root: gem_root).load
20
+ end
21
+
22
+ def initialize(gem_root:)
23
+ @gem_root = gem_root
24
+ end
25
+
26
+ # Load and merge configuration
27
+ # @return [Hash] Merged configuration
28
+ def load
29
+ config = load_defaults
30
+ config = deep_merge(config, load_user_config)
31
+ deep_merge(config, load_project_config)
32
+ end
33
+
34
+ # Get the root directory for ideas
35
+ # @param config [Hash] Configuration hash
36
+ # @return [String] Absolute path to ideas root directory
37
+ def self.root_dir(config = nil)
38
+ config ||= load
39
+ dir = config.dig("idea", "root_dir") || DEFAULT_ROOT_DIR
40
+
41
+ # Make absolute if relative
42
+ if dir.start_with?("/")
43
+ dir
44
+ else
45
+ File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, dir)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def load_defaults
52
+ path = File.join(@gem_root, ".ace-defaults", "idea", "config.yml")
53
+ load_yaml(path) || {}
54
+ end
55
+
56
+ def load_user_config
57
+ path = File.join(Dir.home, ".ace", "idea", "config.yml")
58
+ load_yaml(path) || {}
59
+ end
60
+
61
+ def load_project_config
62
+ path = File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, ".ace", "idea", "config.yml")
63
+ load_yaml(path) || {}
64
+ end
65
+
66
+ def load_yaml(path)
67
+ return nil unless File.exist?(path)
68
+
69
+ YAML.safe_load_file(path, permitted_classes: [Date, Time, Symbol])
70
+ rescue Errno::ENOENT
71
+ nil
72
+ rescue Psych::SyntaxError => e
73
+ warn "Warning: ace-idea config parse error in #{path}: #{e.message}"
74
+ nil
75
+ end
76
+
77
+ def deep_merge(base, override)
78
+ return base unless override.is_a?(Hash)
79
+
80
+ result = base.dup
81
+ override.each do |key, value|
82
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
83
+ deep_merge(result[key], value)
84
+ else
85
+ value
86
+ end
87
+ end
88
+ result
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "../atoms/idea_id_formatter"
6
+ require_relative "../atoms/idea_file_pattern"
7
+ require_relative "../atoms/idea_frontmatter_defaults"
8
+ require_relative "../atoms/slug_sanitizer_adapter"
9
+ require_relative "idea_loader"
10
+ require_relative "idea_llm_enhancer"
11
+ require_relative "idea_clipboard_reader"
12
+
13
+ module Ace
14
+ module Idea
15
+ module Molecules
16
+ # Creates new ideas with b36ts IDs, folder/file creation.
17
+ # Supports --clipboard, --llm-enhance, and --move-to options.
18
+ class IdeaCreator
19
+ # @param root_dir [String] Root directory for ideas
20
+ # @param config [Hash] Configuration hash
21
+ def initialize(root_dir:, config: {})
22
+ @root_dir = root_dir
23
+ @config = config
24
+ end
25
+
26
+ # Create a new idea
27
+ # @param content [String] Raw idea content/text
28
+ # @param title [String, nil] Optional title (extracted from content if nil)
29
+ # @param tags [Array<String>] Tags for the idea
30
+ # @param move_to [String, nil] Target folder for the idea
31
+ # @param clipboard [Boolean] Capture from system clipboard
32
+ # @param llm_enhance [Boolean] Enhance with LLM
33
+ # @param time [Time] Creation time (default: now)
34
+ # @return [Idea] Created idea object
35
+ def create(content = nil, title: nil, tags: [], move_to: nil,
36
+ clipboard: false, llm_enhance: false, time: Time.now.utc)
37
+ # Step 1: Gather content
38
+ body, attachments_to_save = gather_content(content, clipboard: clipboard)
39
+
40
+ if body.nil? || body.strip.empty?
41
+ raise ArgumentError, "No content provided. Provide text or use --clipboard."
42
+ end
43
+
44
+ # Step 2: Optionally enhance with LLM
45
+ enhanced_body = if llm_enhance
46
+ enhance_with_llm(body, config: @config)
47
+ else
48
+ body
49
+ end
50
+
51
+ # Step 3: Generate ID and slugs
52
+ id = Atoms::IdeaIdFormatter.generate(time)
53
+ slug_title = title || extract_title(enhanced_body)
54
+ folder_slug = generate_folder_slug(slug_title)
55
+ file_slug = generate_file_slug(slug_title)
56
+
57
+ # Step 4: Determine target directory
58
+ target_dir = determine_target_dir(move_to)
59
+ FileUtils.mkdir_p(target_dir)
60
+
61
+ # Step 5: Create idea folder (ensure unique name if ID collision occurs)
62
+ folder_name, _ = unique_folder_name(id, folder_slug, target_dir)
63
+ idea_dir = File.join(target_dir, folder_name)
64
+ FileUtils.mkdir_p(idea_dir)
65
+
66
+ # Step 6: Handle attachments
67
+ if attachments_to_save.any?
68
+ enhanced_body = save_attachments_and_inject_refs(attachments_to_save, idea_dir, enhanced_body)
69
+ end
70
+
71
+ # Step 7: Write spec file
72
+ effective_title = title || extract_title(enhanced_body) || "Untitled Idea"
73
+ frontmatter = Atoms::IdeaFrontmatterDefaults.build(
74
+ id: id,
75
+ title: effective_title,
76
+ tags: tags,
77
+ status: "pending",
78
+ created_at: time
79
+ )
80
+
81
+ file_content = build_file_content(frontmatter, enhanced_body, effective_title)
82
+ spec_filename = Atoms::IdeaFilePattern.spec_filename(id, file_slug)
83
+ spec_file = File.join(idea_dir, spec_filename)
84
+ File.write(spec_file, file_content)
85
+
86
+ # Step 8: Load and return the created idea
87
+ loader = IdeaLoader.new
88
+ special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
89
+ idea_dir, root: @root_dir
90
+ )
91
+ loader.load(idea_dir, id: id, special_folder: special_folder)
92
+ end
93
+
94
+ private
95
+
96
+ def gather_content(content, clipboard: false)
97
+ attachments = []
98
+
99
+ if clipboard
100
+ clipboard_result = IdeaClipboardReader.read
101
+ unless clipboard_result[:success]
102
+ raise ArgumentError, clipboard_result[:error]
103
+ end
104
+
105
+ clipboard_content = clipboard_result[:content]
106
+ clipboard_attachments = clipboard_result[:attachments] || []
107
+
108
+ # Merge clipboard content with provided content
109
+ if content.nil? || content.strip.empty?
110
+ content = clipboard_content
111
+ elsif clipboard_content && !clipboard_content.strip.empty?
112
+ content = "#{content}\n\n#{clipboard_content}"
113
+ end
114
+
115
+ attachments = clipboard_attachments
116
+ end
117
+
118
+ [content, attachments]
119
+ end
120
+
121
+ def enhance_with_llm(content, config: {})
122
+ enhancer = IdeaLlmEnhancer.new(config: config)
123
+ result = enhancer.enhance(content)
124
+
125
+ if result[:success]
126
+ result[:content]
127
+ else
128
+ # Fallback to original content on LLM failure
129
+ content
130
+ end
131
+ end
132
+
133
+ # Ensure unique folder name when the same b36ts ID is generated within the
134
+ # same 2-second window. If the candidate folder already exists, appends a
135
+ # numeric counter to the slug: {id}-{slug}-2, {id}-{slug}-3, etc.
136
+ # @return [Array<String>] [folder_name, effective_slug]
137
+ def unique_folder_name(id, slug, target_dir)
138
+ folder_name = Atoms::IdeaFilePattern.folder_name(id, slug)
139
+ candidate_dir = File.join(target_dir, folder_name)
140
+
141
+ return [folder_name, slug] unless Dir.exist?(candidate_dir)
142
+
143
+ counter = 2
144
+ loop do
145
+ unique_slug = "#{slug}-#{counter}"
146
+ folder_name = Atoms::IdeaFilePattern.folder_name(id, unique_slug)
147
+ candidate_dir = File.join(target_dir, folder_name)
148
+ break [folder_name, unique_slug] unless Dir.exist?(candidate_dir)
149
+
150
+ counter += 1
151
+ end
152
+ end
153
+
154
+ def generate_folder_slug(title)
155
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
156
+ words = sanitized.split("-")
157
+ words.take(5).join("-").then { |s| s.empty? ? "idea" : s }
158
+ end
159
+
160
+ def generate_file_slug(title)
161
+ sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
162
+ words = sanitized.split("-")
163
+ words.take(7).join("-").then { |s| s.empty? ? "idea" : s }
164
+ end
165
+
166
+ def extract_title(content)
167
+ return nil if content.nil? || content.strip.empty?
168
+
169
+ # Try to get first heading
170
+ match = content.match(/^#\s+(.+)$/)
171
+ return match[1].strip if match
172
+
173
+ # Fall back to first line (max 50 chars)
174
+ first_line = content.split("\n").first&.strip
175
+ return nil if first_line.nil? || first_line.empty?
176
+
177
+ (first_line.length > 50) ? first_line[0..49] : first_line
178
+ end
179
+
180
+ def determine_target_dir(move_to)
181
+ if move_to
182
+ if Ace::Support::Items::Atoms::SpecialFolderDetector.virtual_filter?(move_to)
183
+ raise ArgumentError, "Cannot move to virtual filter '#{move_to}' — it is not a physical folder"
184
+ end
185
+ normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(move_to)
186
+ candidate = File.expand_path(File.join(@root_dir, normalized))
187
+ root_real = File.expand_path(@root_dir)
188
+ unless candidate.start_with?(root_real + File::SEPARATOR) || candidate == root_real
189
+ raise ArgumentError, "Path traversal detected in --move-to option"
190
+ end
191
+ candidate
192
+ else
193
+ @root_dir
194
+ end
195
+ end
196
+
197
+ def save_attachments_and_inject_refs(attachments, idea_dir, content)
198
+ refs = []
199
+
200
+ attachments.each do |attachment|
201
+ next unless attachment[:source_path] || attachment[:data]
202
+
203
+ raw_name = attachment[:filename] || File.basename(attachment[:source_path].to_s)
204
+ filename = File.basename(raw_name.to_s)
205
+ if filename.empty? || filename.include?("/") || filename.include?("\0")
206
+ warn "Warning: Skipping attachment with unsafe filename: #{raw_name.inspect}"
207
+ next
208
+ end
209
+ dest_path = File.join(idea_dir, filename)
210
+
211
+ if attachment[:source_path] && File.exist?(attachment[:source_path])
212
+ FileUtils.cp(attachment[:source_path], dest_path)
213
+ elsif attachment[:data]
214
+ File.binwrite(dest_path, attachment[:data])
215
+ end
216
+
217
+ # Add markdown reference based on type
218
+ refs << case attachment[:type]
219
+ when :image
220
+ "![#{filename}](#{filename})"
221
+ else
222
+ "[#{filename}](#{filename})"
223
+ end
224
+ rescue => e
225
+ warn "Warning: Failed to save attachment #{filename}: #{e.message}"
226
+ end
227
+
228
+ if refs.any?
229
+ "#{content}\n\n## Attachments\n\n#{refs.join("\n")}"
230
+ else
231
+ content
232
+ end
233
+ end
234
+
235
+ def build_file_content(frontmatter, body, title)
236
+ fm_str = Atoms::IdeaFrontmatterDefaults.serialize(frontmatter)
237
+
238
+ # Check if body already has a title heading
239
+ if body.match?(/^#\s+/)
240
+ "#{fm_str}\n\n#{body}\n"
241
+ else
242
+ "#{fm_str}\n\n# #{title}\n\n#{body}\n"
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end