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,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
|
data/lib/ace/idea/cli.rb
ADDED
|
@@ -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
|
+
""
|
|
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
|