ace-task 0.37.6 → 0.38.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5979cedf5bba02d87a871387ecaa348ba33ebb86ac583eb6341889f74ad05d6
4
- data.tar.gz: c3b2cd69ddff0bd6b557ce9606f496efe9a9c915e02b2fb4e971c120eef2bde5
3
+ metadata.gz: b5816e661bad83e251f7163f2c31be9c0395492ab92ef00d2f8d0ddc63c02291
4
+ data.tar.gz: 8c69d8964833c9f008de95ea38dde3efa36095baa9814a60f8ebf8a8da86956e
5
5
  SHA512:
6
- metadata.gz: 4c59cf04b650430acc2f6e59c3659dc62330dcc3383444e4040d35890134445f3b4d47fce150f62be1d1a9d55d66f70809e7fbe0c83da14105cf8cbc156ea2fd
7
- data.tar.gz: de3fb447f8fbdb4c802a4ccc68b0476680d89cd78161434cf48ef2528e045830606a90a04a6b96bd633e6bca64acfab0588519efa571af8acdb56ebbd3254498
6
+ metadata.gz: 4183d716b57fdde53188afb4a3da9d63e62398ea9223e9ffc954c9c7bd6061cd23e6005208cf4e58313176c2b33feb0831155d3490c6bd9f9859feda29c17b6a
7
+ data.tar.gz: a5dbbd8e5795dac65bad64b8c79cf515f6b5f3daf736a2e0baea7f9195a5e2a042797689a048fa913eb05532d437ab30dec6fa8c0f137c0ce037cf64a06ec9cb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.38.1] - 2026-09-29
11
+
12
+ ### Fixed
13
+ - The released gemspec now declares `ace-git-github ~> 0.2`, matching the source
14
+ constraint: the previously published 0.38.0 metadata still pinned `~> 0.1.1`,
15
+ which kept published installations resolving ace-git-github 0.1.2 and made
16
+ the released 0.2.0 unreachable through dependency resolution.
17
+
18
+ ## [0.38.0] - 2026-09-28
19
+
20
+ ### Added
21
+ - Dependency-aware `--sort=smart` for `ace-task list`: ready tasks (dependencies done or archived, or the task itself done/archived) list first under the existing priority/position ordering; tasks with unmet dependencies follow in a topological tail ordered by fewest dependency hops to readiness; dependency cycles render last with a `↺cycle` marker instead of hiding. Missing dependency references stay unmet and never crash listing; `id`, `priority`, and `created` sorts are unchanged.
22
+
10
23
  ## [0.37.6] - 2026-09-27
11
24
 
12
25
  ### Technical
@@ -36,7 +36,7 @@ module Ace
36
36
  option :in, type: :string, aliases: %w[-i], desc: "Filter by folder (next=root only [default], all=everything, maybe, archive)"
37
37
  option :root, type: :string, aliases: %w[-r], desc: "Override root path (subpath within tasks root)"
38
38
  option :filter, type: :array, aliases: %w[-f], desc: "Filter by key:value (repeatable, supports key:a|b and key:!value)"
39
- option :sort, type: :string, aliases: %w[-S], desc: "Sort order: smart (default), id, priority, created"
39
+ option :sort, type: :string, aliases: %w[-S], desc: "Sort order: smart (default, dependency-aware), id, priority, created"
40
40
 
41
41
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
42
42
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
@@ -63,7 +63,8 @@ module Ace
63
63
 
64
64
  puts Ace::Task::Molecules::TaskDisplayFormatter.format_list(
65
65
  tasks, total_count: manager.last_list_total,
66
- global_folder_stats: manager.last_folder_counts
66
+ global_folder_stats: manager.last_folder_counts,
67
+ cycle_ids: manager.last_list_cycle_ids
67
68
  )
68
69
  end
69
70
  end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/items"
4
+
5
+ module Ace
6
+ module Task
7
+ module Molecules
8
+ # Dependency-aware ordering for the smart list sort.
9
+ #
10
+ # Ready tasks (every dependency satisfied, or the task itself done or
11
+ # archived) come first under the existing smart comparator. Tasks with
12
+ # unmet dependencies follow in a topological tail ordered by dependency
13
+ # depth (fewest hops to readiness first, with the smart comparator as
14
+ # tie-breaker). Tasks caught in a dependency cycle render last so cycles
15
+ # stay visible instead of hiding or looping.
16
+ class DependencyAwareSmartSorter
17
+ # Status that satisfies a dependency.
18
+ SATISFIED_STATUSES = %w[done].freeze
19
+ # Folder that satisfies a dependency regardless of status (archived).
20
+ ARCHIVE_FOLDER = "_archive"
21
+
22
+ # Ordered listing plus the ids of cyclic tasks (for list rendering).
23
+ Result = Struct.new(:tasks, :cycle_ids)
24
+
25
+ # @param tasks [Array<Models::Task>] Tasks in the current listing
26
+ # @param score_fn [Proc] ->(task) { Float } existing smart score
27
+ # @param pin_accessor [Proc] ->(task) { position, nil } pinned position
28
+ # @param external_statuses [Hash{String => Array(String, String)}]
29
+ # [status, special_folder] for dependency references outside the
30
+ # listing (e.g. archived); absent entries count as unmet.
31
+ # @return [Result]
32
+ def self.sort(tasks, score_fn:, pin_accessor:, external_statuses: {})
33
+ return Result.new([], []) if tasks.nil? || tasks.empty?
34
+
35
+ index = {}
36
+ tasks.each { |task| index[task.id] = task }
37
+
38
+ unmet_deps = {}
39
+ tasks.each do |task|
40
+ deps = normalize_dependencies(task.dependencies)
41
+ unmet_deps[task.id] = deps.reject { |dep| satisfied?(dep, index, external_statuses) }
42
+ end
43
+
44
+ ready = []
45
+ unmet_ids = {}
46
+ tasks.each do |task|
47
+ if unmet_deps[task.id].empty? || satisfied_state?(task.status, task.special_folder)
48
+ ready << task
49
+ else
50
+ unmet_ids[task.id] = true
51
+ end
52
+ end
53
+
54
+ cycle_ids = cyclic_ids(index, unmet_deps)
55
+ cycle_lookup = cycle_ids.each_with_object({}) { |id, lookup| lookup[id] = true }
56
+ cycle_ids.each { |id| unmet_ids.delete(id) }
57
+
58
+ depths = depth_map(index, unmet_deps, unmet_ids)
59
+
60
+ ordered = order_group(ready, score_fn, pin_accessor)
61
+ unmet_ids.keys.group_by { |id| depths[id] }.sort.each do |_depth, ids|
62
+ ordered.concat(order_group(ids.map { |id| index[id] }, score_fn, pin_accessor))
63
+ end
64
+ cyclic_tasks = tasks.select { |task| cycle_lookup[task.id] }
65
+ ordered.concat(order_group(cyclic_tasks, score_fn, pin_accessor))
66
+
67
+ Result.new(ordered, cycle_ids)
68
+ end
69
+
70
+ class << self
71
+ private
72
+
73
+ def order_group(group, score_fn, pin_accessor)
74
+ return [] if group.empty?
75
+
76
+ Ace::Support::Items::Molecules::SmartSorter.sort(
77
+ group, score_fn: score_fn, pin_accessor: pin_accessor
78
+ )
79
+ end
80
+
81
+ def normalize_dependencies(raw)
82
+ Array(raw).compact.map(&:to_s)
83
+ end
84
+
85
+ # A dependency is satisfied when the referenced task is done or
86
+ # archived. References outside the listing consult external_statuses;
87
+ # unknown references stay unmet so a task cannot appear ready.
88
+ def satisfied?(dep_id, index, external_statuses)
89
+ task = index[dep_id]
90
+ return satisfied_state?(task.status, task.special_folder) if task
91
+
92
+ external = external_statuses[dep_id]
93
+ return satisfied_state?(external[0], external[1]) if external
94
+
95
+ false
96
+ end
97
+
98
+ def satisfied_state?(status, special_folder)
99
+ SATISFIED_STATUSES.include?(status.to_s.downcase) || special_folder == ARCHIVE_FOLDER
100
+ end
101
+
102
+ # Cyclic ids: members of any strongly connected component of the
103
+ # unmet-dependency graph larger than one, plus self-dependencies.
104
+ def cyclic_ids(index, unmet_deps)
105
+ edges = {}
106
+ index.each_key do |id|
107
+ edges[id] = unmet_deps[id].select { |dep| index.key?(dep) }
108
+ end
109
+
110
+ cyclic = []
111
+ strongly_connected_cyclic_ids(edges, cyclic)
112
+ cyclic
113
+ end
114
+
115
+ # Tarjan's algorithm over unmet edges (recursion depth is bounded by
116
+ # the dependency chain length, which is small for task graphs).
117
+ def strongly_connected_cyclic_ids(edges, cyclic)
118
+ counter = 0
119
+ indices = {}
120
+ lowlinks = {}
121
+ stack = []
122
+ on_stack = {}
123
+
124
+ visit = nil
125
+ visit = lambda do |vertex|
126
+ indices[vertex] = lowlinks[vertex] = counter
127
+ counter += 1
128
+ stack.push(vertex)
129
+ on_stack[vertex] = true
130
+
131
+ edges[vertex].each do |neighbor|
132
+ if indices.key?(neighbor)
133
+ lowlinks[vertex] = [lowlinks[vertex], indices[neighbor]].min if on_stack[neighbor]
134
+ else
135
+ visit.call(neighbor)
136
+ lowlinks[vertex] = [lowlinks[vertex], lowlinks[neighbor]].min
137
+ end
138
+ end
139
+
140
+ if lowlinks[vertex] == indices[vertex]
141
+ component = []
142
+ loop do
143
+ member = stack.pop
144
+ on_stack[member] = false
145
+ component << member
146
+ break if member == vertex
147
+ end
148
+
149
+ cyclic.concat(component) if component.length > 1 || component.any? { |id| edges[id].include?(id) }
150
+ end
151
+ end
152
+
153
+ edges.each_key do |vertex|
154
+ visit.call(vertex) unless indices.key?(vertex)
155
+ end
156
+ end
157
+
158
+ # Hop distance to the ready/satisfied boundary for unmet acyclic
159
+ # tasks. Missing, ready, and cyclic dependencies contribute zero, so
160
+ # traversal stays finite and acyclic by construction.
161
+ def depth_map(index, unmet_deps, unmet_ids)
162
+ depths = {}
163
+ unmet_ids.each_key do |id|
164
+ compute_depth(id, index, unmet_deps, unmet_ids, depths)
165
+ end
166
+ depths
167
+ end
168
+
169
+ def compute_depth(id, index, unmet_deps, unmet_ids, depths)
170
+ return depths[id] if depths.key?(id)
171
+
172
+ deepest = unmet_deps[id].map do |dep|
173
+ if unmet_ids[dep]
174
+ compute_depth(dep, index, unmet_deps, unmet_ids, depths)
175
+ else
176
+ 0
177
+ end
178
+ end.max || 0
179
+ depths[id] = 1 + deepest
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -113,8 +113,9 @@ module Ace
113
113
  # @param tasks [Array<Models::Task>] Tasks to format
114
114
  # @param total_count [Integer, nil] Total items before folder filtering
115
115
  # @param global_folder_stats [Hash, nil] Folder name → count hash from full scan
116
+ # @param cycle_ids [Array<String>] Dependency-cycle task ids to mark
116
117
  # @return [String] Formatted list output
117
- def self.format_list(tasks, total_count: nil, global_folder_stats: nil)
118
+ def self.format_list(tasks, total_count: nil, global_folder_stats: nil, cycle_ids: [])
118
119
  stats_line = format_stats_line(
119
120
  tasks,
120
121
  total_count: total_count,
@@ -124,7 +125,7 @@ module Ace
124
125
  body = if tasks.empty?
125
126
  "No tasks found."
126
127
  else
127
- tasks.map { |task| format_list_item(task) }.join("\n")
128
+ tasks.map { |task| format_list_item(task, cycle_ids: cycle_ids) }.join("\n")
128
129
  end
129
130
 
130
131
  "#{body}\n\n#{stats_line}"
@@ -203,7 +204,7 @@ module Ace
203
204
  private_class_method :format_up_next_section, :format_recently_done_section
204
205
 
205
206
  # Format a single task as a compact list item.
206
- def self.format_list_item(task)
207
+ def self.format_list_item(task, cycle_ids: [])
207
208
  c = Ace::Support::Items::Atoms::AnsiColors
208
209
  status_sym = colored_status_sym(task.status)
209
210
  priority_sym = PRIORITY_LABELS[task.priority] || ""
@@ -219,8 +220,9 @@ module Ace
219
220
  subtask_str = task.has_subtasks? ? c.colorize(" \u203a#{task.subtasks.length}", c::DIM) : ""
220
221
  tags_str = (task.tags && task.tags.any?) ? c.colorize(" [#{task.tags.join(", ")}]", c::DIM) : ""
221
222
  folder_str = task.special_folder ? c.colorize(" (#{task.special_folder})", c::DIM) : ""
223
+ cycle_str = cycle_ids.include?(task.id) ? c.colorize(" \u21BAcycle", c::DIM) : ""
222
224
 
223
- "#{status_sym} #{priority_prefix}#{id_str} #{task.title}#{subtask_str}#{tags_str}#{folder_str}"
225
+ "#{status_sym} #{priority_prefix}#{id_str} #{task.title}#{subtask_str}#{tags_str}#{folder_str}#{cycle_str}"
224
226
  end
225
227
 
226
228
  private_class_method :format_list_item
@@ -9,7 +9,7 @@ module Ace
9
9
  # Uses a custom id_extractor that matches xxx.t.yyy-slug folder names.
10
10
  # Excludes subtask folders from primary scan results.
11
11
  class TaskScanner
12
- attr_reader :last_scan_total, :last_folder_counts
12
+ attr_reader :last_scan_total, :last_folder_counts, :last_scan_results
13
13
 
14
14
  # ID extractor for task-format folders: "8pp.t.q7w-fix-login"
15
15
  TASK_ID_EXTRACTOR = ->(folder_name) {
@@ -45,6 +45,7 @@ module Ace
45
45
  def scan_in_folder(folder)
46
46
  results = scan
47
47
  @last_scan_total = results.size
48
+ @last_scan_results = results
48
49
  @last_folder_counts = results.group_by(&:special_folder).transform_values(&:size)
49
50
  return results if folder.nil?
50
51
 
@@ -19,7 +19,7 @@ module Ace
19
19
  CREATE_RETRY_LIMIT = 3
20
20
  class CreateRetriesExhaustedError < StandardError; end
21
21
 
22
- attr_reader :last_list_total, :last_folder_counts
22
+ attr_reader :last_list_total, :last_folder_counts, :last_list_cycle_ids
23
23
 
24
24
  # @param root_dir [String, nil] Override root directory for tasks
25
25
  # @param config [Hash, nil] Override configuration
@@ -27,6 +27,8 @@ module Ace
27
27
  @config = config || load_config
28
28
  @root_dir = root_dir || resolve_root_dir
29
29
  @last_update_note = nil
30
+ @last_list_cycle_ids = []
31
+ @last_scan_results = []
30
32
  end
31
33
 
32
34
  attr_reader :last_update_note
@@ -94,13 +96,15 @@ module Ace
94
96
  # @param in_folder [String, nil] Filter by special folder (default: "next" = root items only)
95
97
  # @param tags [Array<String>] Filter by tags (any match)
96
98
  # @param filters [Array<String>, nil] Generic filter strings
97
- # @param sort [String] Sort order: "smart", "id", "priority", "created" (default: "smart")
99
+ # @param sort [String] Sort order: "smart" (default, dependency-aware), "id", "priority", "created"
98
100
  # @return [Array<Models::Task>] List of tasks
99
101
  def list(status: nil, in_folder: "next", tags: [], filters: nil, sort: "smart")
100
102
  scanner = Molecules::TaskScanner.new(@root_dir)
101
103
  scan_results = scanner.scan_in_folder(in_folder)
102
104
  @last_list_total = scanner.last_scan_total
103
105
  @last_folder_counts = scanner.last_folder_counts
106
+ @last_scan_results = scanner.last_scan_results
107
+ @last_list_cycle_ids = []
104
108
 
105
109
  loader = Molecules::TaskLoader.new
106
110
  tasks = scan_results.filter_map do |sr|
@@ -293,11 +297,36 @@ module Ace
293
297
  end
294
298
 
295
299
  def smart_sort(tasks)
296
- Ace::Support::Items::Molecules::SmartSorter.sort(
300
+ result = Molecules::DependencyAwareSmartSorter.sort(
297
301
  tasks,
298
302
  score_fn: method(:compute_task_score),
299
- pin_accessor: ->(t) { t.metadata&.dig("position") }
303
+ pin_accessor: ->(t) { t.metadata&.dig("position") },
304
+ external_statuses: external_dep_statuses(tasks)
300
305
  )
306
+ @last_list_cycle_ids = result.cycle_ids
307
+ result.tasks
308
+ end
309
+
310
+ # Load statuses for dependency references pointing outside the current
311
+ # listing (e.g. archived tasks), so smart sort can classify them as
312
+ # satisfied or unmet.
313
+ def external_dep_statuses(tasks)
314
+ listed_ids = {}
315
+ tasks.each { |t| listed_ids[t.id] = true }
316
+ external_ids = tasks.flat_map { |t| Array(t.dependencies) }.uniq - listed_ids.keys
317
+ return {} if external_ids.empty?
318
+
319
+ by_id = {}
320
+ @last_scan_results.each { |sr| by_id[sr.id] = sr }
321
+
322
+ loader = Molecules::TaskLoader.new
323
+ external_ids.each_with_object({}) do |dep_id, statuses|
324
+ scan_result = by_id[dep_id]
325
+ next unless scan_result
326
+
327
+ dep = loader.load(scan_result.dir_path, id: scan_result.id, special_folder: scan_result.special_folder)
328
+ statuses[dep_id] = [dep.status, dep.special_folder] if dep
329
+ end
301
330
  end
302
331
 
303
332
  def compute_task_score(task)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Task
5
- VERSION = "0.37.6"
5
+ VERSION = "0.38.1"
6
6
  end
7
7
  end
data/lib/ace/task.rb CHANGED
@@ -24,6 +24,7 @@ require_relative "task/molecules/task_loader"
24
24
  require_relative "task/molecules/task_creator"
25
25
  require_relative "task/molecules/subtask_creator"
26
26
  require_relative "task/molecules/task_display_formatter"
27
+ require_relative "task/molecules/dependency_aware_smart_sorter"
27
28
  require_relative "task/molecules/path_utils"
28
29
  require_relative "task/molecules/github_issue_sync_adapter"
29
30
  require_relative "task/molecules/task_plan_cache"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-task
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.37.6
4
+ version: 0.38.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-27 00:00:00.000000000 Z
10
+ date: 2026-09-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-core
@@ -99,14 +99,14 @@ dependencies:
99
99
  requirements:
100
100
  - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: '0.1'
102
+ version: '0.2'
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '0.1'
109
+ version: '0.2'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: ace-support-cli
112
112
  requirement: !ruby/object:Gem::Requirement
@@ -179,6 +179,7 @@ files:
179
179
  - lib/ace/task/cli/commands/status.rb
180
180
  - lib/ace/task/cli/commands/update.rb
181
181
  - lib/ace/task/models/task.rb
182
+ - lib/ace/task/molecules/dependency_aware_smart_sorter.rb
182
183
  - lib/ace/task/molecules/github_issue_sync_adapter.rb
183
184
  - lib/ace/task/molecules/path_utils.rb
184
185
  - lib/ace/task/molecules/subtask_creator.rb