ace-task 0.39.2 → 0.40.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 425d8beebe8b18f6d816767d85d9f1b0a29c68a470b8c6cf4590629b2028bddd
4
- data.tar.gz: a28fc844b081cc3f9b969e2ab15a674df2dca08bb30fc48cd90b1741d3024663
3
+ metadata.gz: 7e07d26b4b14644e4227398532fa33c32066628db686f51274628d8164608225
4
+ data.tar.gz: ae7def12a1bacbcd29744736de409970b612e943a48e291c8bdf6fdbb6480424
5
5
  SHA512:
6
- metadata.gz: c2be6996b2753ec7ff709cba139a8533e2b746186e7ba3c29b26e51d9361d163e99a022bc5c05a39db372e71ee5a843c64f4f06fbe5b66298e7488b922da27d5
7
- data.tar.gz: b976035a868e0d4d433b956d41520d5b7a2670aff609754bd975797a6bc6a0417ca1e3fe364d429c65dfe9edfc8c4e9609ad20accfb0db4f2743c8b26ec304a5
6
+ metadata.gz: 49a85005d9283e2920f421957bc4d5e2f6b5a181c8259a3d8e63c5206a2fdcde6c4ea4342be3cbb84acce05fe12895f3a2e57301202c362b8a1a305ddd1e141b
7
+ data.tar.gz: dddf45e0bd3c18cef1cf1568987f91ab56b3f15567dee849ced55483e818fd147c4e0dfaadc95a872add0ab6510c4ded2538bd7a07051d04ec1306c44e6c48bb
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.40.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Support exact --root selection in create, show and update so project objective queues can use the configured directory without changing the global _folder convention.
15
+
16
+ ## [0.39.3] - 2026-10-12
17
+
18
+ ### Fixed
19
+
20
+ - Route `ace-task create --in FOLDER` through the task manager's relocation API
21
+ and remove the newly created task artifacts when destination placement fails.
22
+
10
23
  ## [0.39.2] - 2026-10-06
11
24
 
12
25
  ### Fixed
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ace/support/cli"
4
+ require "fileutils"
4
5
 
5
6
  module Ace
6
7
  module Task
@@ -10,6 +11,9 @@ module Ace
10
11
  class Create < Ace::Support::Cli::Command
11
12
  include Ace::Support::Cli::Base
12
13
 
14
+ # "next" is a move-to-root alias and is validated separately.
15
+ BUILT_IN_DESTINATIONS = %w[maybe anytime archive].freeze
16
+
13
17
  desc <<~DESC.strip
14
18
  Create a new task
15
19
 
@@ -38,11 +42,14 @@ module Ace
38
42
  option :server, type: :string, desc: "Named forge server"
39
43
  option :"default-server", type: :boolean, desc: "Use the configured default forge server"
40
44
  option :"child-of", type: :string, desc: "Parent task reference (creates subtask)"
41
- option :in, type: :string, aliases: %w[-i], desc: "Target folder (e.g. next, maybe)"
45
+ option :in, type: :string, aliases: %w[-i],
46
+ desc: "Top-level target (next, maybe, anytime, archive, or existing custom folder)"
42
47
  option :"dry-run", type: :boolean, aliases: %w[-n], desc: "Preview without writing"
43
48
 
44
49
  option :git_commit, type: :boolean, aliases: %w[--gc], desc: "Auto-commit changes"
45
50
 
51
+ option :root, type: :string, aliases: %w[-r], desc: "Exact task root directory (overrides project config)"
52
+
46
53
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
47
54
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
48
55
  option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
@@ -72,6 +79,14 @@ module Ace
72
79
  end
73
80
  end
74
81
 
82
+ manager = if options[:root]
83
+ Ace::Task::Organisms::TaskManager.new(root_dir: File.expand_path(options[:root]))
84
+ else
85
+ Ace::Task::Organisms::TaskManager.new
86
+ end
87
+ validate_destination!(manager, in_folder) if in_folder && !child_of
88
+ placement_directories_before = directories_under(manager.root_dir) if in_folder && !child_of
89
+
75
90
  if dry_run
76
91
  puts "Would create task:"
77
92
  puts " Title: #{title}"
@@ -85,8 +100,6 @@ module Ace
85
100
  return
86
101
  end
87
102
 
88
- manager = Ace::Task::Organisms::TaskManager.new
89
-
90
103
  task = begin
91
104
  if child_of
92
105
  manager.create_subtask(
@@ -119,7 +132,7 @@ module Ace
119
132
 
120
133
  # Move to folder if specified
121
134
  if in_folder && !child_of
122
- task = manager.move(task.id, to: in_folder)
135
+ task = place_created_task(manager, task, in_folder, placement_directories_before)
123
136
  end
124
137
 
125
138
  puts "Created task #{task.id}"
@@ -140,6 +153,117 @@ module Ace
140
153
 
141
154
  private
142
155
 
156
+ def validate_destination!(manager, target_folder)
157
+ detector = Ace::Support::Items::Atoms::SpecialFolderDetector
158
+ return if detector.move_to_root?(target_folder)
159
+
160
+ if detector.virtual_filter?(target_folder)
161
+ raise Ace::Support::Cli::Error,
162
+ "Destination '#{target_folder}' is a virtual list filter, not a physical folder."
163
+ end
164
+
165
+ normalized = detector.normalize(target_folder)
166
+ configured_path = File.join(manager.root_dir, normalized)
167
+ normalized_built_ins = BUILT_IN_DESTINATIONS.map { |name| detector.normalize(name) }
168
+ valid = !target_folder.strip.empty? &&
169
+ !normalized.include?(File::SEPARATOR) &&
170
+ !normalized.include?("..") &&
171
+ (normalized_built_ins.include?(normalized) || Dir.exist?(configured_path)) &&
172
+ destination_within_root?(manager.root_dir, configured_path)
173
+ return if valid
174
+
175
+ choices = (["next"] + BUILT_IN_DESTINATIONS).join(", ")
176
+ raise Ace::Support::Cli::Error,
177
+ "Destination '#{target_folder}' does not exist. " \
178
+ "Use #{choices}, or an existing custom task folder."
179
+ end
180
+
181
+ def destination_within_root?(root, destination)
182
+ return true unless File.exist?(destination)
183
+
184
+ root_path = File.realpath(root)
185
+ destination_path = File.realpath(destination)
186
+ destination_path.start_with?(root_path + File::SEPARATOR)
187
+ rescue Errno::ENOENT, Errno::EACCES
188
+ false
189
+ end
190
+
191
+ def place_created_task(manager, task, target_folder, directories_before)
192
+ placed_task = manager.update(task.id, move_to: target_folder)
193
+ expected_folder = if Ace::Support::Items::Atoms::SpecialFolderDetector.move_to_root?(target_folder)
194
+ nil
195
+ else
196
+ Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(target_folder)
197
+ end
198
+
199
+ unless placed_task && placed_task.special_folder == expected_folder
200
+ actual_folder = if placed_task
201
+ placed_task.special_folder || "root"
202
+ else
203
+ "no task returned"
204
+ end
205
+ # This internal signal is wrapped below with rollback and an actionable CLI error.
206
+ raise "placement returned #{actual_folder.inspect}, expected #{expected_folder || "root"}"
207
+ end
208
+
209
+ placed_task
210
+ rescue Ace::Git::Error
211
+ # update retains linked tasks after post-placement sync failures as
212
+ # recovery records; those are not destination-placement failures.
213
+ raise
214
+ rescue StandardError => e
215
+ begin
216
+ remove_created_task_artifacts(manager, task)
217
+ remove_empty_directories_created_since(manager.root_dir, directories_before)
218
+ rescue StandardError => cleanup_error
219
+ raise Ace::Support::Cli::Error,
220
+ "Failed to create task in '#{target_folder}': #{e.message}. " \
221
+ "Cleanup also failed: #{cleanup_error.message}"
222
+ end
223
+ raise Ace::Support::Cli::Error,
224
+ "Failed to create task in '#{target_folder}': #{e.message}. " \
225
+ "No task was created; fix the destination and retry."
226
+ end
227
+
228
+ def remove_created_task_artifacts(manager, task)
229
+ root = File.expand_path(manager.root_dir)
230
+ candidates = [task.path]
231
+ candidates.concat(
232
+ Dir.glob(File.join(root, "**", "#{task.id}-*")).select { |path| File.directory?(path) }
233
+ )
234
+
235
+ candidates.uniq.each do |path|
236
+ expanded = File.expand_path(path)
237
+ next unless expanded.start_with?(root + File::SEPARATOR)
238
+
239
+ FileUtils.rm_rf(expanded)
240
+ end
241
+
242
+ remaining = Dir.glob(File.join(root, "**", "#{task.id}-*")).select { |path| File.exist?(path) }
243
+ return if remaining.empty?
244
+
245
+ raise Ace::Support::Cli::Error,
246
+ "Failed to remove partial task artifacts after placement failure: #{remaining.join(", ")}"
247
+ end
248
+
249
+ def directories_under(root)
250
+ return [] unless Dir.exist?(root)
251
+
252
+ [File.expand_path(root)] + Dir.glob(File.join(root, "**", "*"))
253
+ .select { |path| File.directory?(path) }
254
+ .map { |path| File.expand_path(path) }
255
+ end
256
+
257
+ def remove_empty_directories_created_since(root, directories_before)
258
+ # Only directories created during this short command transaction
259
+ # are candidates, and non-empty concurrent directories are retained.
260
+ (directories_under(root) - directories_before).sort_by(&:length).reverse_each do |path|
261
+ Dir.rmdir(path)
262
+ rescue Errno::ENOENT, Errno::ENOTEMPTY
263
+ next
264
+ end
265
+ end
266
+
143
267
  def resolve_title(positional_title, flag_title)
144
268
  positional_present = present_string?(positional_title)
145
269
  flag_present = present_string?(flag_title)
@@ -31,12 +31,18 @@ module Ace
31
31
  option :content, type: :boolean, desc: "Print raw markdown content"
32
32
  option :tree, type: :boolean, desc: "Show parent + subtask tree view"
33
33
 
34
+ option :root, type: :string, aliases: %w[-r], desc: "Exact task root directory (overrides project config)"
35
+
34
36
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
35
37
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
36
38
  option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
37
39
 
38
40
  def call(ref:, **options)
39
- manager = Ace::Task::Organisms::TaskManager.new
41
+ manager = if options[:root]
42
+ Ace::Task::Organisms::TaskManager.new(root_dir: File.expand_path(options[:root]))
43
+ else
44
+ Ace::Task::Organisms::TaskManager.new
45
+ end
40
46
  task = manager.show(ref)
41
47
 
42
48
  unless task
@@ -46,6 +46,8 @@ module Ace
46
46
 
47
47
  option :git_commit, type: :boolean, aliases: %w[--gc], desc: "Auto-commit changes"
48
48
 
49
+ option :root, type: :string, aliases: %w[-r], desc: "Exact task root directory (overrides project config)"
50
+
49
51
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
50
52
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
51
53
  option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
@@ -75,7 +77,11 @@ module Ace
75
77
  add_hash = parse_kv_pairs(add_args)
76
78
  remove_hash = parse_kv_pairs(remove_args)
77
79
 
78
- manager = Ace::Task::Organisms::TaskManager.new
80
+ manager = if options[:root]
81
+ Ace::Task::Organisms::TaskManager.new(root_dir: File.expand_path(options[:root]))
82
+ else
83
+ Ace::Task::Organisms::TaskManager.new
84
+ end
79
85
 
80
86
  # Resolve --position into a set or remove operation
81
87
  if position_arg
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Task
5
- VERSION = "0.39.2"
5
+ VERSION = "0.40.0"
6
6
  end
7
7
  end
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.39.2
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-08 00:00:00.000000000 Z
10
+ date: 2026-10-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-core