ace-support-items 0.15.3
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/CHANGELOG.md +174 -0
- data/README.md +29 -0
- data/Rakefile +13 -0
- data/lib/ace/support/items/atoms/ansi_colors.rb +37 -0
- data/lib/ace/support/items/atoms/date_partition_path.rb +24 -0
- data/lib/ace/support/items/atoms/field_argument_parser.rb +107 -0
- data/lib/ace/support/items/atoms/filter_parser.rb +79 -0
- data/lib/ace/support/items/atoms/folder_completion_detector.rb +40 -0
- data/lib/ace/support/items/atoms/frontmatter_parser.rb +57 -0
- data/lib/ace/support/items/atoms/frontmatter_serializer.rb +81 -0
- data/lib/ace/support/items/atoms/item_id_formatter.rb +81 -0
- data/lib/ace/support/items/atoms/item_id_parser.rb +89 -0
- data/lib/ace/support/items/atoms/item_statistics.rb +26 -0
- data/lib/ace/support/items/atoms/position_generator.rb +64 -0
- data/lib/ace/support/items/atoms/relative_time_formatter.rb +48 -0
- data/lib/ace/support/items/atoms/slug_sanitizer.rb +67 -0
- data/lib/ace/support/items/atoms/sort_score_calculator.rb +54 -0
- data/lib/ace/support/items/atoms/special_folder_detector.rb +101 -0
- data/lib/ace/support/items/atoms/stats_line_formatter.rb +85 -0
- data/lib/ace/support/items/atoms/title_extractor.rb +22 -0
- data/lib/ace/support/items/models/item_id.rb +55 -0
- data/lib/ace/support/items/models/loaded_document.rb +28 -0
- data/lib/ace/support/items/models/scan_result.rb +32 -0
- data/lib/ace/support/items/molecules/base_formatter.rb +51 -0
- data/lib/ace/support/items/molecules/directory_scanner.rb +106 -0
- data/lib/ace/support/items/molecules/document_loader.rb +77 -0
- data/lib/ace/support/items/molecules/field_updater.rb +120 -0
- data/lib/ace/support/items/molecules/filter_applier.rb +117 -0
- data/lib/ace/support/items/molecules/folder_mover.rb +91 -0
- data/lib/ace/support/items/molecules/git_committer.rb +21 -0
- data/lib/ace/support/items/molecules/item_sorter.rb +73 -0
- data/lib/ace/support/items/molecules/llm_slug_generator.rb +264 -0
- data/lib/ace/support/items/molecules/shortcut_resolver.rb +87 -0
- data/lib/ace/support/items/molecules/smart_sorter.rb +39 -0
- data/lib/ace/support/items/molecules/status_categorizer.rb +60 -0
- data/lib/ace/support/items/version.rb +9 -0
- data/lib/ace/support/items.rb +50 -0
- metadata +111 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Items
|
|
6
|
+
module Molecules
|
|
7
|
+
# Shells out to ace-git-commit for auto-committing after mutations.
|
|
8
|
+
# Pure CLI invocation — no gem dependency on ace-git-commit.
|
|
9
|
+
class GitCommitter
|
|
10
|
+
# @param paths [Array<String>] File/directory paths to commit
|
|
11
|
+
# @param intention [String] Commit intention for LLM message generation
|
|
12
|
+
# @return [Boolean] true if commit succeeded
|
|
13
|
+
def self.commit(paths:, intention:)
|
|
14
|
+
cmd = ["ace-git-commit"] + paths + ["-i", intention]
|
|
15
|
+
system(*cmd)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Items
|
|
6
|
+
module Molecules
|
|
7
|
+
# Sorts collections of items by a field with configurable direction.
|
|
8
|
+
# Nil values sort last regardless of direction.
|
|
9
|
+
class ItemSorter
|
|
10
|
+
# Sort items by a field
|
|
11
|
+
# @param items [Array] Items to sort
|
|
12
|
+
# @param field [String, Symbol] Field name to sort by
|
|
13
|
+
# @param direction [Symbol] :asc or :desc (default: :asc)
|
|
14
|
+
# @param value_accessor [Proc, nil] Custom accessor: ->(item, key) { value }
|
|
15
|
+
# @return [Array] Sorted items
|
|
16
|
+
def self.sort(items, field:, direction: :asc, value_accessor: nil)
|
|
17
|
+
return [] if items.nil? || items.empty?
|
|
18
|
+
|
|
19
|
+
accessor = value_accessor || method(:default_value_accessor)
|
|
20
|
+
|
|
21
|
+
multiplier = (direction == :desc) ? -1 : 1
|
|
22
|
+
|
|
23
|
+
items.sort do |a, b|
|
|
24
|
+
val_a = accessor.call(a, field.to_s)
|
|
25
|
+
val_b = accessor.call(b, field.to_s)
|
|
26
|
+
|
|
27
|
+
# Nil values sort last regardless of direction
|
|
28
|
+
if val_a.nil? && val_b.nil?
|
|
29
|
+
0
|
|
30
|
+
elsif val_a.nil?
|
|
31
|
+
1
|
|
32
|
+
elsif val_b.nil?
|
|
33
|
+
-1
|
|
34
|
+
else
|
|
35
|
+
(val_a <=> val_b || 0) * multiplier
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Default value accessor matching FilterApplier convention
|
|
41
|
+
private_class_method def self.default_value_accessor(item, key)
|
|
42
|
+
if item.respond_to?(:[])
|
|
43
|
+
val = begin
|
|
44
|
+
item[key.to_sym]
|
|
45
|
+
rescue
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
return val unless val.nil?
|
|
49
|
+
|
|
50
|
+
val = begin
|
|
51
|
+
item[key.to_s]
|
|
52
|
+
rescue
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
return val unless val.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if item.respond_to?(key.to_sym)
|
|
59
|
+
return item.send(key.to_sym)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if item.respond_to?(:frontmatter) && item.frontmatter.is_a?(Hash)
|
|
63
|
+
val = item.frontmatter[key.to_s] || item.frontmatter[key.to_sym]
|
|
64
|
+
return val unless val.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Support
|
|
7
|
+
module Items
|
|
8
|
+
module Molecules
|
|
9
|
+
# Generates hierarchical slugs using LLM with fallback to deterministic generation.
|
|
10
|
+
# Soft dependency on ace-llm — gracefully falls back if not available.
|
|
11
|
+
class LlmSlugGenerator
|
|
12
|
+
# Goal type keywords for consistent naming
|
|
13
|
+
GOAL_TYPES = %w[add enhance fix refactor].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(debug: false)
|
|
16
|
+
@debug = debug
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Generate hierarchical slugs for a task
|
|
20
|
+
# @param title [String] Task title
|
|
21
|
+
# @param context [Hash] Additional context (project_name, type, etc.)
|
|
22
|
+
# @return [Hash] { folder_slug:, file_slug:, success:, source: } or fallback result
|
|
23
|
+
def generate_task_slugs(title, context = {})
|
|
24
|
+
llm_result = try_llm_task_generation(title, context)
|
|
25
|
+
return llm_result if llm_result[:success]
|
|
26
|
+
|
|
27
|
+
debug_log("LLM generation failed, using fallback")
|
|
28
|
+
fallback_task_generation(title, context)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Generate hierarchical slugs for an idea
|
|
32
|
+
# @param description [String] Idea description/content
|
|
33
|
+
# @param context [Hash] Additional context
|
|
34
|
+
# @return [Hash] { folder_slug:, file_slug:, success:, source: } or fallback result
|
|
35
|
+
def generate_idea_slugs(description, context = {})
|
|
36
|
+
llm_result = try_llm_idea_generation(description, context)
|
|
37
|
+
return llm_result if llm_result[:success]
|
|
38
|
+
|
|
39
|
+
debug_log("LLM generation failed, using fallback")
|
|
40
|
+
fallback_idea_generation(description, context)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def load_slug_prompt
|
|
46
|
+
require "open3"
|
|
47
|
+
|
|
48
|
+
stdout, _stderr, status = Open3.capture3("ace-nav", "prompt://slug-generation", "--content")
|
|
49
|
+
|
|
50
|
+
if status.success?
|
|
51
|
+
debug_log("Successfully loaded slug-generation prompt via ace-nav")
|
|
52
|
+
stdout.strip
|
|
53
|
+
else
|
|
54
|
+
raise "Slug generation prompt not found via ace-nav"
|
|
55
|
+
end
|
|
56
|
+
rescue => e
|
|
57
|
+
debug_log("Error loading slug prompt: #{e.message}")
|
|
58
|
+
raise "Failed to load slug generation prompt: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def try_llm_task_generation(title, context)
|
|
62
|
+
prompt = build_task_slug_prompt(title, context)
|
|
63
|
+
result = call_llm(prompt)
|
|
64
|
+
return {success: false} unless result[:success]
|
|
65
|
+
|
|
66
|
+
parsed = parse_llm_response(result[:text])
|
|
67
|
+
return {success: false} unless parsed
|
|
68
|
+
|
|
69
|
+
if valid_slugs?(parsed)
|
|
70
|
+
{
|
|
71
|
+
success: true,
|
|
72
|
+
folder_slug: parsed["folder_slug"],
|
|
73
|
+
file_slug: parsed["file_slug"],
|
|
74
|
+
source: :llm
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
debug_log("LLM returned invalid slug format: #{parsed.inspect}")
|
|
78
|
+
{success: false}
|
|
79
|
+
end
|
|
80
|
+
rescue => e
|
|
81
|
+
debug_log("LLM task generation error: #{e.message}")
|
|
82
|
+
{success: false}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def try_llm_idea_generation(description, context)
|
|
86
|
+
prompt = build_idea_slug_prompt(description, context)
|
|
87
|
+
result = call_llm(prompt)
|
|
88
|
+
return {success: false} unless result[:success]
|
|
89
|
+
|
|
90
|
+
parsed = parse_llm_response(result[:text])
|
|
91
|
+
return {success: false} unless parsed
|
|
92
|
+
|
|
93
|
+
if valid_slugs?(parsed)
|
|
94
|
+
{
|
|
95
|
+
success: true,
|
|
96
|
+
folder_slug: parsed["folder_slug"],
|
|
97
|
+
file_slug: parsed["file_slug"],
|
|
98
|
+
source: :llm
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
debug_log("LLM returned invalid slug format: #{parsed.inspect}")
|
|
102
|
+
{success: false}
|
|
103
|
+
end
|
|
104
|
+
rescue => e
|
|
105
|
+
debug_log("LLM idea generation error: #{e.message}")
|
|
106
|
+
{success: false}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def call_llm(prompt)
|
|
110
|
+
# Soft dependency on ace-llm
|
|
111
|
+
require "ace/llm/query_interface"
|
|
112
|
+
require "ace/llm/molecules/llm_alias_resolver"
|
|
113
|
+
|
|
114
|
+
debug_log("=== LLM PROMPT ===")
|
|
115
|
+
debug_log(prompt)
|
|
116
|
+
debug_log("=== END PROMPT ===")
|
|
117
|
+
|
|
118
|
+
response = Ace::LLM::QueryInterface.query(
|
|
119
|
+
"glite",
|
|
120
|
+
prompt,
|
|
121
|
+
temperature: 0.3,
|
|
122
|
+
max_tokens: 500,
|
|
123
|
+
debug: @debug
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
debug_log("=== LLM RESPONSE ===")
|
|
127
|
+
debug_log(response[:text])
|
|
128
|
+
debug_log("=== END RESPONSE ===")
|
|
129
|
+
|
|
130
|
+
{success: true, text: response[:text]}
|
|
131
|
+
rescue LoadError => e
|
|
132
|
+
debug_log("ace-llm not available: #{e.message}")
|
|
133
|
+
{success: false, error: e.message}
|
|
134
|
+
rescue => e
|
|
135
|
+
debug_log("LLM call failed: #{e.message}")
|
|
136
|
+
{success: false, error: e.message}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_llm_response(text)
|
|
140
|
+
json_text = text.strip
|
|
141
|
+
json_text = json_text.gsub(/^```json\s*\n?/, "").gsub(/\n?```$/, "") if json_text.include?("```")
|
|
142
|
+
|
|
143
|
+
JSON.parse(json_text)
|
|
144
|
+
rescue JSON::ParserError => e
|
|
145
|
+
debug_log("Failed to parse LLM JSON response: #{e.message}")
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_task_slug_prompt(title, context)
|
|
150
|
+
prompt_template = load_slug_prompt
|
|
151
|
+
|
|
152
|
+
<<~PROMPT
|
|
153
|
+
#{prompt_template}
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Task Details
|
|
158
|
+
|
|
159
|
+
**Task Title**: #{title}
|
|
160
|
+
**Additional Context**: #{context.to_json}
|
|
161
|
+
|
|
162
|
+
Generate the hierarchical slugs for this task.
|
|
163
|
+
PROMPT
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_idea_slug_prompt(description, context)
|
|
167
|
+
prompt_template = load_slug_prompt
|
|
168
|
+
desc_preview = description[0..1000]
|
|
169
|
+
|
|
170
|
+
<<~PROMPT
|
|
171
|
+
#{prompt_template}
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Idea Details
|
|
176
|
+
|
|
177
|
+
**Idea Description**: #{desc_preview}
|
|
178
|
+
**Additional Context**: #{context.to_json}
|
|
179
|
+
|
|
180
|
+
Generate the hierarchical slugs for this idea.
|
|
181
|
+
PROMPT
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def valid_slugs?(parsed)
|
|
185
|
+
return false unless parsed.is_a?(Hash)
|
|
186
|
+
return false unless parsed["folder_slug"].is_a?(String)
|
|
187
|
+
return false unless parsed["file_slug"].is_a?(String)
|
|
188
|
+
return false if parsed["folder_slug"].empty?
|
|
189
|
+
return false if parsed["file_slug"].empty?
|
|
190
|
+
|
|
191
|
+
folder_valid = parsed["folder_slug"] =~ /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
192
|
+
file_valid = parsed["file_slug"] =~ /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
193
|
+
|
|
194
|
+
folder_valid && file_valid
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def fallback_task_generation(title, _context)
|
|
198
|
+
slug = sanitize_to_slug(title)
|
|
199
|
+
parts = slug.split("-")
|
|
200
|
+
folder_parts = parts.take(3)
|
|
201
|
+
file_parts = parts.drop(3)
|
|
202
|
+
|
|
203
|
+
folder_slug = folder_parts.join("-")
|
|
204
|
+
file_slug = file_parts.any? ? file_parts.join("-") : folder_slug
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
success: true,
|
|
208
|
+
folder_slug: folder_slug,
|
|
209
|
+
file_slug: file_slug,
|
|
210
|
+
source: :fallback
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def fallback_idea_generation(description, _context)
|
|
215
|
+
title = description.split("\n").first || description
|
|
216
|
+
title = title[0..49] if title.length > 50
|
|
217
|
+
file_slug = sanitize_to_slug(title)
|
|
218
|
+
|
|
219
|
+
area = extract_area_from_content(description)
|
|
220
|
+
goal_type = extract_goal_type_from_content(description)
|
|
221
|
+
folder_slug = [area, goal_type].compact.join("-")
|
|
222
|
+
folder_slug = file_slug if folder_slug.empty?
|
|
223
|
+
|
|
224
|
+
{
|
|
225
|
+
success: true,
|
|
226
|
+
folder_slug: folder_slug,
|
|
227
|
+
file_slug: file_slug,
|
|
228
|
+
source: :fallback
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def sanitize_to_slug(text)
|
|
233
|
+
text.to_s.downcase
|
|
234
|
+
.gsub(/[^\w\s-]/, "")
|
|
235
|
+
.gsub(/[\s_]+/, "-").squeeze("-")
|
|
236
|
+
.gsub(/^-|-$/, "")
|
|
237
|
+
.strip
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def extract_area_from_content(content)
|
|
241
|
+
content_lower = content.downcase
|
|
242
|
+
areas = %w[taskflow search docs git llm nav review lint test]
|
|
243
|
+
areas.find { |area| content_lower.include?(area) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def extract_goal_type_from_content(content)
|
|
247
|
+
content_lower = content.downcase
|
|
248
|
+
|
|
249
|
+
return "add" if /\b(add|create|implement|new)\b/.match?(content_lower)
|
|
250
|
+
return "enhance" if /\b(enhance|improve|update|upgrade)\b/.match?(content_lower)
|
|
251
|
+
return "fix" if /\b(fix|repair|resolve|correct)\b/.match?(content_lower)
|
|
252
|
+
return "refactor" if /\b(refactor|restructure|reorganize)\b/.match?(content_lower)
|
|
253
|
+
|
|
254
|
+
"enhance"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def debug_log(message)
|
|
258
|
+
warn "[LlmSlugGenerator] #{message}" if @debug
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Items
|
|
6
|
+
module Molecules
|
|
7
|
+
# Resolves shortcut references to item ScanResult objects.
|
|
8
|
+
#
|
|
9
|
+
# Shortcuts are the last N characters of an item ID.
|
|
10
|
+
# For ideas (6-char IDs): "8ppq7w" => shortcut "q7w"
|
|
11
|
+
# For tasks (9-char formatted IDs): "8pp.t.q7w" => shortcut "q7w"
|
|
12
|
+
# Full IDs are also accepted directly.
|
|
13
|
+
#
|
|
14
|
+
# Warns (via callback or STDERR) when multiple matches are found.
|
|
15
|
+
class ShortcutResolver
|
|
16
|
+
# @param scan_results [Array<ScanResult>] Scan results to resolve against
|
|
17
|
+
# @param full_id_length [Integer] Length of a full ID (6 for raw b36ts, 9 for type-marked)
|
|
18
|
+
def initialize(scan_results, full_id_length: 6)
|
|
19
|
+
@scan_results = scan_results
|
|
20
|
+
@full_id_length = full_id_length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Resolve a reference to a single ScanResult
|
|
24
|
+
# @param ref [String] Full ID or suffix shortcut
|
|
25
|
+
# @param on_ambiguity [Proc, nil] Called with array of matches on ambiguity
|
|
26
|
+
# @return [ScanResult, nil] The resolved result, or nil if not found
|
|
27
|
+
def resolve(ref, on_ambiguity: nil)
|
|
28
|
+
return nil if ref.nil? || ref.empty?
|
|
29
|
+
|
|
30
|
+
ref = ref.strip.downcase
|
|
31
|
+
|
|
32
|
+
if ref.length == @full_id_length
|
|
33
|
+
# Full ID match
|
|
34
|
+
exact = @scan_results.find { |r| r.id == ref }
|
|
35
|
+
return exact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Suffix match (last N characters)
|
|
39
|
+
matches = @scan_results.select { |r| r.id.end_with?(ref) }
|
|
40
|
+
|
|
41
|
+
if matches.empty?
|
|
42
|
+
nil
|
|
43
|
+
elsif matches.size == 1
|
|
44
|
+
matches.first
|
|
45
|
+
else
|
|
46
|
+
# Ambiguity: multiple matches
|
|
47
|
+
if on_ambiguity
|
|
48
|
+
on_ambiguity.call(matches)
|
|
49
|
+
else
|
|
50
|
+
warn "Warning: Ambiguous shortcut '#{ref}' matches #{matches.size} items: " \
|
|
51
|
+
"#{matches.map(&:id).join(", ")}. Using most recent."
|
|
52
|
+
end
|
|
53
|
+
# Return most recent (last by sorted ID = chronologically latest)
|
|
54
|
+
matches.last
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if a reference would be ambiguous
|
|
59
|
+
# @param ref [String] Reference to check
|
|
60
|
+
# @return [Boolean] True if multiple matches exist
|
|
61
|
+
def ambiguous?(ref)
|
|
62
|
+
return false if ref.nil? || ref.length == @full_id_length
|
|
63
|
+
|
|
64
|
+
ref = ref.strip.downcase
|
|
65
|
+
matches = @scan_results.select { |r| r.id.end_with?(ref) }
|
|
66
|
+
matches.size > 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Return all matches for a reference (useful for listing ambiguous matches)
|
|
70
|
+
# @param ref [String] Reference to resolve
|
|
71
|
+
# @return [Array<ScanResult>] All matching results
|
|
72
|
+
def all_matches(ref)
|
|
73
|
+
return [] if ref.nil? || ref.empty?
|
|
74
|
+
|
|
75
|
+
ref = ref.strip.downcase
|
|
76
|
+
|
|
77
|
+
if ref.length == @full_id_length
|
|
78
|
+
@scan_results.select { |r| r.id == ref }
|
|
79
|
+
else
|
|
80
|
+
@scan_results.select { |r| r.id.end_with?(ref) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Items
|
|
6
|
+
module Molecules
|
|
7
|
+
# Sorts items using pinned-first + auto-sort logic.
|
|
8
|
+
# Pinned items (those with a position value) sort first by position ascending.
|
|
9
|
+
# Unpinned items sort second by computed score descending.
|
|
10
|
+
class SmartSorter
|
|
11
|
+
# @param items [Array] Items to sort
|
|
12
|
+
# @param score_fn [Proc] ->(item) { Float } computes auto-sort score
|
|
13
|
+
# @param pin_accessor [Proc] ->(item) { String|nil } reads position field
|
|
14
|
+
# @return [Array] Pinned items (by position asc) + unpinned items (by score desc)
|
|
15
|
+
def self.sort(items, score_fn:, pin_accessor:)
|
|
16
|
+
return [] if items.nil? || items.empty?
|
|
17
|
+
|
|
18
|
+
pinned = []
|
|
19
|
+
unpinned = []
|
|
20
|
+
|
|
21
|
+
items.each do |item|
|
|
22
|
+
pos = pin_accessor.call(item)
|
|
23
|
+
if pos && !pos.to_s.empty?
|
|
24
|
+
pinned << item
|
|
25
|
+
else
|
|
26
|
+
unpinned << item
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sorted_pinned = pinned.sort_by { |item| pin_accessor.call(item).to_s }
|
|
31
|
+
sorted_unpinned = unpinned.sort_by { |item| -score_fn.call(item) }
|
|
32
|
+
|
|
33
|
+
sorted_pinned + sorted_unpinned
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Items
|
|
6
|
+
module Molecules
|
|
7
|
+
# Categorizes items into "up next" and "recently done" buckets
|
|
8
|
+
# for status overview displays. Generic — works with any item
|
|
9
|
+
# responding to :status, :file_path, :special_folder, :id.
|
|
10
|
+
class StatusCategorizer
|
|
11
|
+
# @param items [Array] All loaded items
|
|
12
|
+
# @param up_next_limit [Integer] Max up-next items (0 = disable section)
|
|
13
|
+
# @param recently_done_limit [Integer] Max recently-done items (0 = disable section)
|
|
14
|
+
# @param pending_statuses [Array<String>] Statuses considered "up next"
|
|
15
|
+
# @param done_statuses [Array<String>] Statuses considered "recently done"
|
|
16
|
+
# @return [Hash] { up_next: [...], recently_done: [{ item:, completed_at: }] }
|
|
17
|
+
# @param up_next_sorter [Proc, nil] Custom sorter for up-next items.
|
|
18
|
+
# Receives array of items, returns sorted array. Default: sort by id.
|
|
19
|
+
def self.categorize(items, up_next_limit:, recently_done_limit:,
|
|
20
|
+
pending_statuses: ["pending"], done_statuses: ["done"],
|
|
21
|
+
up_next_sorter: nil)
|
|
22
|
+
up_next = if up_next_limit > 0
|
|
23
|
+
candidates = items
|
|
24
|
+
.select { |i| pending_statuses.include?(i.status) && i.special_folder.nil? }
|
|
25
|
+
sorted = if up_next_sorter
|
|
26
|
+
up_next_sorter.call(candidates)
|
|
27
|
+
else
|
|
28
|
+
candidates.sort_by(&:id)
|
|
29
|
+
end
|
|
30
|
+
sorted.first(up_next_limit)
|
|
31
|
+
else
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
recently_done = if recently_done_limit > 0
|
|
36
|
+
items
|
|
37
|
+
.select { |i| done_statuses.include?(i.status) }
|
|
38
|
+
.map { |i| {item: i, completed_at: safe_mtime(i.file_path)} }
|
|
39
|
+
.sort_by { |entry| -(entry[:completed_at]&.to_f || 0) }
|
|
40
|
+
.first(recently_done_limit)
|
|
41
|
+
else
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
{up_next: up_next, recently_done: recently_done}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Safely read file mtime, returning nil if the file is missing.
|
|
49
|
+
def self.safe_mtime(path)
|
|
50
|
+
File.mtime(path)
|
|
51
|
+
rescue Errno::ENOENT, TypeError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private_class_method :safe_mtime
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "items/version"
|
|
4
|
+
|
|
5
|
+
# Atoms
|
|
6
|
+
require_relative "items/atoms/ansi_colors"
|
|
7
|
+
require_relative "items/atoms/slug_sanitizer"
|
|
8
|
+
require_relative "items/atoms/field_argument_parser"
|
|
9
|
+
require_relative "items/atoms/special_folder_detector"
|
|
10
|
+
require_relative "items/atoms/folder_completion_detector"
|
|
11
|
+
require_relative "items/atoms/frontmatter_parser"
|
|
12
|
+
require_relative "items/atoms/frontmatter_serializer"
|
|
13
|
+
require_relative "items/atoms/filter_parser"
|
|
14
|
+
require_relative "items/atoms/title_extractor"
|
|
15
|
+
require_relative "items/atoms/date_partition_path"
|
|
16
|
+
require_relative "items/atoms/item_id_formatter"
|
|
17
|
+
require_relative "items/atoms/item_id_parser"
|
|
18
|
+
require_relative "items/atoms/item_statistics"
|
|
19
|
+
require_relative "items/atoms/stats_line_formatter"
|
|
20
|
+
require_relative "items/atoms/relative_time_formatter"
|
|
21
|
+
require_relative "items/atoms/sort_score_calculator"
|
|
22
|
+
require_relative "items/atoms/position_generator"
|
|
23
|
+
|
|
24
|
+
# Models
|
|
25
|
+
require_relative "items/models/scan_result"
|
|
26
|
+
require_relative "items/models/loaded_document"
|
|
27
|
+
require_relative "items/models/item_id"
|
|
28
|
+
|
|
29
|
+
# Molecules
|
|
30
|
+
require_relative "items/molecules/directory_scanner"
|
|
31
|
+
require_relative "items/molecules/shortcut_resolver"
|
|
32
|
+
require_relative "items/molecules/document_loader"
|
|
33
|
+
require_relative "items/molecules/filter_applier"
|
|
34
|
+
require_relative "items/molecules/item_sorter"
|
|
35
|
+
require_relative "items/molecules/base_formatter"
|
|
36
|
+
require_relative "items/molecules/field_updater"
|
|
37
|
+
require_relative "items/molecules/folder_mover"
|
|
38
|
+
require_relative "items/molecules/llm_slug_generator"
|
|
39
|
+
require_relative "items/molecules/status_categorizer"
|
|
40
|
+
require_relative "items/molecules/git_committer"
|
|
41
|
+
require_relative "items/molecules/smart_sorter"
|
|
42
|
+
|
|
43
|
+
module Ace
|
|
44
|
+
module Support
|
|
45
|
+
# Items provides shared infrastructure for item management (tasks, ideas, etc.)
|
|
46
|
+
# across ace-* gems. Built on b36ts-based IDs and folder conventions.
|
|
47
|
+
module Items
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|