ace-task 0.39.3 → 0.41.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/nav/protocols/skill-sources/ace-task.yml +0 -0
  3. data/.ace-defaults/nav/protocols/wfi-sources/ace-task.yml +0 -0
  4. data/.ace-defaults/task/config.yml +0 -0
  5. data/CHANGELOG.md +55 -0
  6. data/README.md +16 -3
  7. data/Rakefile +0 -0
  8. data/handbook/guides/task-definition.g.md +0 -0
  9. data/handbook/skills/as-bug-analyze/SKILL.md +0 -0
  10. data/handbook/skills/as-bug-fix/SKILL.md +0 -0
  11. data/handbook/skills/as-task-document-unplanned/SKILL.md +0 -0
  12. data/handbook/skills/as-task-draft/SKILL.md +0 -0
  13. data/handbook/skills/as-task-finder/SKILL.md +0 -0
  14. data/handbook/skills/as-task-plan/SKILL.md +0 -0
  15. data/handbook/skills/as-task-review/SKILL.md +0 -0
  16. data/handbook/skills/as-task-review-questions/SKILL.md +0 -0
  17. data/handbook/skills/as-task-update/SKILL.md +0 -0
  18. data/handbook/skills/as-task-work/SKILL.md +0 -0
  19. data/handbook/templates/task/draft.template.md +0 -0
  20. data/handbook/templates/task/file-modification-checklist.template.md +0 -0
  21. data/handbook/templates/task/technical-approach.template.md +0 -0
  22. data/handbook/workflow-instructions/bug/analyze.wf.md +0 -0
  23. data/handbook/workflow-instructions/bug/fix.wf.md +0 -0
  24. data/handbook/workflow-instructions/task/document-unplanned.wf.md +0 -0
  25. data/handbook/workflow-instructions/task/finder.wf.md +0 -0
  26. data/handbook/workflow-instructions/task/plan.wf.md +0 -0
  27. data/handbook/workflow-instructions/task/review-plan.wf.md +0 -0
  28. data/handbook/workflow-instructions/task/review-questions.wf.md +0 -0
  29. data/handbook/workflow-instructions/task/review-work.wf.md +0 -0
  30. data/handbook/workflow-instructions/task/review.wf.md +0 -0
  31. data/handbook/workflow-instructions/task/update.wf.md +0 -0
  32. data/lib/ace/task/atoms/task_file_pattern.rb +0 -0
  33. data/lib/ace/task/atoms/task_id_formatter.rb +0 -0
  34. data/lib/ace/task/atoms/task_validation_rules.rb +0 -0
  35. data/lib/ace/task/cli/commands/create.rb +85 -4
  36. data/lib/ace/task/cli/commands/doctor.rb +0 -0
  37. data/lib/ace/task/cli/commands/list.rb +4 -2
  38. data/lib/ace/task/cli/commands/plan.rb +2 -1
  39. data/lib/ace/task/cli/commands/show.rb +7 -1
  40. data/lib/ace/task/cli/commands/status.rb +0 -0
  41. data/lib/ace/task/cli/commands/update.rb +27 -2
  42. data/lib/ace/task/models/task.rb +0 -0
  43. data/lib/ace/task/molecules/git_ignored_path.rb +73 -0
  44. data/lib/ace/task/molecules/path_utils.rb +0 -0
  45. data/lib/ace/task/molecules/subtask_creator.rb +29 -16
  46. data/lib/ace/task/molecules/task_config_loader.rb +7 -1
  47. data/lib/ace/task/molecules/task_creator.rb +28 -9
  48. data/lib/ace/task/molecules/task_doctor_fixer.rb +16 -1
  49. data/lib/ace/task/molecules/task_doctor_reporter.rb +0 -0
  50. data/lib/ace/task/molecules/task_loader.rb +0 -0
  51. data/lib/ace/task/molecules/task_plan_cache.rb +7 -2
  52. data/lib/ace/task/molecules/task_plan_generator.rb +0 -0
  53. data/lib/ace/task/molecules/task_reparenter.rb +127 -30
  54. data/lib/ace/task/molecules/task_scanner.rb +2 -1
  55. data/lib/ace/task/molecules/task_structure_validator.rb +0 -0
  56. data/lib/ace/task/organisms/task_doctor.rb +7 -0
  57. data/lib/ace/task/organisms/task_manager.rb +153 -3
  58. data/lib/ace/task/version.rb +1 -1
  59. data/lib/ace/task.rb +1 -0
  60. metadata +3 -2
@@ -57,12 +57,8 @@ module Ace
57
57
  else
58
58
  generate_slugs(title)
59
59
  end
60
- folder_slug = slugs[:folder]
61
- file_slug = slugs[:file]
62
-
63
- # Create folder with folder_slug
64
- folder_name = Atoms::TaskIdFormatter.folder_name(formatted_id, folder_slug)
65
- task_dir = File.join(@root_dir, folder_name)
60
+ paths = destination_paths_for(formatted_id, slugs)
61
+ task_dir = paths[:dir]
66
62
  FileUtils.mkdir_p(task_dir)
67
63
 
68
64
  begin
@@ -79,9 +75,8 @@ module Ace
79
75
  remote_issue: remote_issue
80
76
  )
81
77
 
82
- # Write spec file with file_slug
83
- spec_filename = Atoms::TaskIdFormatter.spec_filename(formatted_id, file_slug)
84
- spec_file = File.join(task_dir, spec_filename)
78
+ # Write the spec file with the title-derived file slug
79
+ spec_file = paths[:spec]
85
80
  content = build_spec_content(frontmatter: frontmatter, title: title)
86
81
  File.write(spec_file, content)
87
82
 
@@ -95,8 +90,32 @@ module Ace
95
90
  end
96
91
  end
97
92
 
93
+ # Non-mutating preview of the directory and spec file a create call
94
+ # with this title would write at the given time. Pre-write checks use
95
+ # it to inspect the real generated ID and title-derived slugs instead
96
+ # of sampling representative names; create uses the same naming helper.
97
+ # The CLI never requests LLM slugs, so the preview is deterministic.
98
+ #
99
+ # @param title [String] Task title
100
+ # @param time [Time] Creation time used for the generated ID
101
+ # @return [Array<String>] Task directory and spec file path
102
+ def destination_paths(title, time: Time.now.utc)
103
+ raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
104
+
105
+ formatted_id = Atoms::TaskIdFormatter.generate(time).formatted_id
106
+ paths = destination_paths_for(formatted_id, generate_slugs(title))
107
+ [paths[:dir], paths[:spec]]
108
+ end
109
+
98
110
  private
99
111
 
112
+ def destination_paths_for(formatted_id, slugs)
113
+ folder_name = Atoms::TaskIdFormatter.folder_name(formatted_id, slugs[:folder])
114
+ task_dir = File.join(@root_dir, folder_name)
115
+ spec_filename = Atoms::TaskIdFormatter.spec_filename(formatted_id, slugs[:file])
116
+ {dir: task_dir, spec: File.join(task_dir, spec_filename)}
117
+ end
118
+
100
119
  def task_id_exists?(formatted_id)
101
120
  Dir.glob(File.join(@root_dir, "**", "#{formatted_id}-*")).any? do |path|
102
121
  File.directory?(path)
@@ -8,6 +8,7 @@ require_relative "../atoms/task_id_formatter"
8
8
  require_relative "../atoms/task_validation_rules"
9
9
  require_relative "../atoms/task_frontmatter_defaults"
10
10
  require_relative "task_scanner"
11
+ require_relative "git_ignored_path"
11
12
  require_relative "task_family_completion"
12
13
 
13
14
  module Ace
@@ -459,7 +460,22 @@ module Ace
459
460
  description = "Moved parent task to _archive/ (all subtasks terminal)"
460
461
  end
461
462
 
463
+ # A Git-ignored task path is not part of the committed tree; the
464
+ # repository archive policy neither applies nor authorizes a move.
465
+ # The destination is checked too: moving into an ignored archive
466
+ # bucket would expose the family content to normal staging.
462
467
  source_spec = find_primary_spec(source_dir)
468
+ mover = Ace::Support::Items::Molecules::FolderMover.new(@root_dir)
469
+ destination = mover.destination_path(
470
+ Struct.new(:path).new(source_dir), to: "archive", date: archive_time
471
+ )
472
+ if Molecules::GitIgnoredPath.ignored_family?(source_dir) ||
473
+ Molecules::GitIgnoredPath.ignored_after_relocation?(source_dir, destination)
474
+ log_fix(source_dir, "Skipped archive move: Git-ignored task path")
475
+ @skipped_count += 1
476
+ return false
477
+ end
478
+
463
479
  source_frontmatter, = Ace::Support::Items::Atoms::FrontmatterParser.parse(File.read(source_spec))
464
480
  unless TaskFamilyCompletion.new(@root_dir).descendants_terminal?(
465
481
  source_dir, id: source_frontmatter["id"]
@@ -475,7 +491,6 @@ module Ace
475
491
  return true
476
492
  end
477
493
 
478
- mover = Ace::Support::Items::Molecules::FolderMover.new(@root_dir)
479
494
  mover.move(Struct.new(:path).new(source_dir), to: "archive", date: archive_time)
480
495
  log_fix(source_dir, description)
481
496
  @fixed_count += 1
File without changes
File without changes
@@ -6,6 +6,7 @@ require "time"
6
6
  require "ace/support/items"
7
7
  require "ace/b36ts"
8
8
  require_relative "path_utils"
9
+ require_relative "task_config_loader"
9
10
 
10
11
  module Ace
11
12
  module Task
@@ -20,7 +21,7 @@ module Ace
20
21
  end
21
22
 
22
23
  def cache_dir
23
- File.join(Dir.pwd, @cache_root, @task_id)
24
+ File.join(project_root, @cache_root, @task_id)
24
25
  end
25
26
 
26
27
  def prompts_dir
@@ -162,7 +163,11 @@ module Ace
162
163
  return "" if path.nil? || path.to_s.empty?
163
164
  return path if path.start_with?("/")
164
165
 
165
- File.expand_path(path, Dir.pwd)
166
+ File.expand_path(path, project_root)
167
+ end
168
+
169
+ def project_root
170
+ Ace::Task::Molecules::TaskConfigLoader.project_root
166
171
  end
167
172
 
168
173
  def relative_path(path)
File without changes
@@ -35,6 +35,32 @@ module Ace
35
35
  end
36
36
  end
37
37
 
38
+ # Destination paths a reparent will create, without mutating anything.
39
+ # Returns the destination directories and every generated spec file
40
+ # (order is not significant): the task's own renamed spec plus each
41
+ # descendant's spec, whose basename follows the descendant's new ID.
42
+ # Used by pre-flight checks such as the --git-commit ignored-path
43
+ # guard, which must test the names the move will actually write.
44
+ #
45
+ # @param task [Models::Task] Task to reparent
46
+ # @param target [String] "none", "self", or a parent ref
47
+ # @param resolve_ref [Proc] Resolves a parent ref to a Task
48
+ # @return [Array<String>] Destination directories and spec file paths
49
+ def destination_paths(task, target:, resolve_ref:)
50
+ case target.downcase
51
+ when "none"
52
+ promoted_destination_paths(task)
53
+ when "self"
54
+ orchestrator_destination_paths(task)
55
+ else
56
+ parent_task = resolve_ref.call(target)
57
+ raise ArgumentError, "Parent task '#{target}' not found" unless parent_task
58
+ raise ArgumentError, "Cannot reparent task to itself" if task.id == parent_task.id
59
+
60
+ demoted_destination_paths(task, parent_task)
61
+ end
62
+ end
63
+
38
64
  private
39
65
 
40
66
  # Promote a subtask to a standalone task at root level.
@@ -44,17 +70,10 @@ module Ace
44
70
  raise ArgumentError, "Task #{task.id} is already a standalone task"
45
71
  end
46
72
 
47
- # Generate new standalone ID preserving the original timestamp
48
- raw_b36ts = Atoms::TaskIdFormatter.reconstruct(base_task_id(task.id))
49
- new_item_id = Atoms::TaskIdFormatter.format(raw_b36ts)
50
- new_id = new_item_id.formatted_id
51
-
52
- # Extract slug from current folder name
53
- slug = extract_slug(task.path, task.id)
54
-
55
- # Build new folder/file names
56
- new_folder_name = Atoms::TaskIdFormatter.folder_name(new_id, slug)
57
- new_dir = File.join(@root_dir, new_folder_name)
73
+ paths = promoted_paths(task)
74
+ new_id = paths[:id]
75
+ new_dir = paths[:dir]
76
+ new_folder_name = paths[:folder_name]
58
77
 
59
78
  raise ArgumentError, "Destination already exists: #{new_dir}" if File.exist?(new_dir)
60
79
 
@@ -63,7 +82,7 @@ module Ace
63
82
 
64
83
  # Rename spec file and update frontmatter
65
84
  old_spec_basename = File.basename(task.file_path)
66
- new_spec_basename = "#{new_folder_name}.s.md"
85
+ new_spec_basename = paths[:spec_basename]
67
86
  old_spec_in_new_dir = File.join(new_dir, old_spec_basename)
68
87
  new_spec_path = File.join(new_dir, new_spec_basename)
69
88
 
@@ -91,20 +110,16 @@ module Ace
91
110
  raise ArgumentError, "Cannot convert subtask #{task.id} to orchestrator — promote first"
92
111
  end
93
112
 
94
- slug = extract_slug(task.path, task.id)
95
-
96
- # Create subtask ID using first subtask char (0)
97
- next_char = SubtaskCreator::SUBTASK_CHARS[0]
98
- subtask_id = "#{task.id}.#{next_char}"
99
- subtask_folder_name = "#{next_char}-#{slug}"
100
- subtask_dir = File.join(task.path, subtask_folder_name)
113
+ paths = orchestrator_paths(task)
114
+ subtask_id = paths[:id]
115
+ subtask_dir = paths[:dir]
116
+ subtask_spec_name = paths[:spec_basename]
101
117
 
102
118
  raise ArgumentError, "Subtask directory already exists: #{subtask_dir}" if File.exist?(subtask_dir)
103
119
 
104
120
  FileUtils.mkdir_p(subtask_dir)
105
121
 
106
122
  # Move current spec file into subtask dir with renamed filename
107
- subtask_spec_name = "#{subtask_id}-#{slug}.s.md"
108
123
  subtask_spec_path = File.join(subtask_dir, subtask_spec_name)
109
124
  FileUtils.mv(task.file_path, subtask_spec_path)
110
125
 
@@ -140,15 +155,10 @@ module Ace
140
155
  raise ArgumentError, "Cannot reparent task to itself"
141
156
  end
142
157
 
143
- # Allocate next subtask char
144
- existing_chars = scan_existing_subtask_chars(parent_task.path, parent_task.id)
145
- next_char = allocate_next_char(existing_chars)
146
-
147
- # Build new subtask ID
148
- new_id = "#{parent_task.id}.#{next_char}"
149
- slug = extract_slug(task.path, task.id)
150
- new_folder_name = "#{next_char}-#{slug}"
151
- new_dir = File.join(parent_task.path, new_folder_name)
158
+ paths = demoted_paths(task, parent_task)
159
+ new_id = paths[:id]
160
+ new_dir = paths[:dir]
161
+ new_spec_basename = paths[:spec_basename]
152
162
 
153
163
  raise ArgumentError, "Destination already exists: #{new_dir}" if File.exist?(new_dir)
154
164
 
@@ -157,7 +167,6 @@ module Ace
157
167
 
158
168
  # Rename spec file
159
169
  old_spec_basename = File.basename(task.file_path)
160
- new_spec_basename = "#{new_id}-#{slug}.s.md"
161
170
  old_spec_in_new_dir = File.join(new_dir, old_spec_basename)
162
171
  new_spec_path = File.join(new_dir, new_spec_basename)
163
172
 
@@ -234,6 +243,94 @@ module Ace
234
243
  end
235
244
  end
236
245
 
246
+ # Destination naming shared by the move and its pre-flight preview.
247
+
248
+ def promoted_paths(task)
249
+ raw_b36ts = Atoms::TaskIdFormatter.reconstruct(base_task_id(task.id))
250
+ new_id = Atoms::TaskIdFormatter.format(raw_b36ts).formatted_id
251
+ slug = extract_slug(task.path, task.id)
252
+ folder_name = Atoms::TaskIdFormatter.folder_name(new_id, slug)
253
+ dir = File.join(@root_dir, folder_name)
254
+ {id: new_id, dir: dir, folder_name: folder_name, spec_basename: "#{folder_name}.s.md"}
255
+ end
256
+
257
+ def orchestrator_paths(task)
258
+ slug = extract_slug(task.path, task.id)
259
+ next_char = SubtaskCreator::SUBTASK_CHARS[0]
260
+ subtask_id = "#{task.id}.#{next_char}"
261
+ folder_name = "#{next_char}-#{slug}"
262
+ dir = File.join(task.path, folder_name)
263
+ {id: subtask_id, dir: dir, folder_name: folder_name, spec_basename: "#{subtask_id}-#{slug}.s.md"}
264
+ end
265
+
266
+ def demoted_paths(task, parent_task)
267
+ existing_chars = scan_existing_subtask_chars(parent_task.path, parent_task.id)
268
+ next_char = allocate_next_char(existing_chars)
269
+ new_id = "#{parent_task.id}.#{next_char}"
270
+ slug = extract_slug(task.path, task.id)
271
+ folder_name = "#{next_char}-#{slug}"
272
+ dir = File.join(parent_task.path, folder_name)
273
+ {id: new_id, dir: dir, folder_name: folder_name, spec_basename: "#{new_id}-#{slug}.s.md"}
274
+ end
275
+
276
+ # Destination previews pair each naming plan with the spec files the
277
+ # move will actually write: the task's renamed spec and every
278
+ # descendant spec renamed to the new ID.
279
+ def promoted_destination_paths(task)
280
+ paths = promoted_paths(task)
281
+ [
282
+ paths[:dir],
283
+ File.join(paths[:dir], paths[:spec_basename]),
284
+ *descendant_destination_specs(task.path, paths[:dir], new_parent_id: paths[:id])
285
+ ]
286
+ end
287
+
288
+ def orchestrator_destination_paths(task)
289
+ paths = orchestrator_paths(task)
290
+ [
291
+ task.path,
292
+ File.join(task.path, "#{File.basename(task.path)}.s.md"),
293
+ paths[:dir],
294
+ File.join(paths[:dir], paths[:spec_basename])
295
+ ]
296
+ end
297
+
298
+ def demoted_destination_paths(task, parent_task)
299
+ paths = demoted_paths(task, parent_task)
300
+ [
301
+ paths[:dir],
302
+ File.join(paths[:dir], paths[:spec_basename]),
303
+ *descendant_destination_specs(task.path, paths[:dir], new_parent_id: paths[:id])
304
+ ]
305
+ end
306
+
307
+ # Spec paths descendants receive after their ancestor's ID changes.
308
+ # Folder names stay, spec basenames follow the new IDs, and an entry
309
+ # without a spec file is not renamed by rewrite_descendants either.
310
+ def descendant_destination_specs(source_dir, destination_dir, new_parent_id:)
311
+ specs = []
312
+ Dir.entries(source_dir).sort.each do |entry|
313
+ next if entry.start_with?(".")
314
+
315
+ match = entry.match(/\A([a-z0-9])-(.+)\z/)
316
+ next unless match
317
+
318
+ child_source = File.join(source_dir, entry)
319
+ next unless File.directory?(child_source)
320
+
321
+ char = match[1]
322
+ slug = match[2]
323
+ child_destination = File.join(destination_dir, entry)
324
+ unless Dir.glob(File.join(child_source, "*.s.md")).empty?
325
+ specs << File.join(child_destination, "#{new_parent_id}.#{char}-#{slug}.s.md")
326
+ end
327
+ specs.concat(descendant_destination_specs(
328
+ child_source, child_destination, new_parent_id: "#{new_parent_id}.#{char}"
329
+ ))
330
+ end
331
+ specs
332
+ end
333
+
237
334
  # Extract the base task ID (without subtask char) from a potentially dotted ID
238
335
  # "8pp.t.q7w.a" => "8pp.t.q7w"
239
336
  def base_task_id(id)
@@ -39,7 +39,8 @@ module Ace
39
39
  @scanner.scan.reject { |sr| subtask_folder?(sr.folder_name) }
40
40
  end
41
41
 
42
- # Scan and filter by special folder or virtual filter
42
+ # Scan and filter by special folder or virtual filter.
43
+ # `next` is the ordinary root view; `all` includes every special folder.
43
44
  # @param folder [String, nil] Folder name, virtual filter ("next", "all"), or nil for all
44
45
  # @return [Array<ScanResult>] Filtered scan results
45
46
  def scan_in_folder(folder)
File without changes
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../molecules/task_scanner"
4
+ require_relative "../molecules/git_ignored_path"
4
5
  require_relative "../molecules/task_frontmatter_validator"
5
6
  require_relative "../molecules/task_structure_validator"
6
7
  require_relative "../molecules/task_doctor_fixer"
@@ -153,6 +154,12 @@ module Ace
153
154
  frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
154
155
  next unless frontmatter.is_a?(Hash)
155
156
 
157
+ # A Git-ignored task path (folder or spec file) is not part of the
158
+ # committed task tree: repository archive policy neither applies
159
+ # nor authorizes a move.
160
+ next if Molecules::GitIgnoredPath.ignored?(scan_result.dir_path) ||
161
+ Molecules::GitIgnoredPath.ignored?(scan_result.file_path)
162
+
156
163
  status = frontmatter["status"]
157
164
  special_folder = scan_result.special_folder
158
165
 
@@ -4,6 +4,7 @@ require "digest"
4
4
  require "tmpdir"
5
5
 
6
6
  require_relative "../molecules/task_config_loader"
7
+ require_relative "../molecules/git_ignored_path"
7
8
  require_relative "../molecules/task_scanner"
8
9
  require_relative "../molecules/task_resolver"
9
10
  require_relative "../molecules/task_loader"
@@ -13,6 +14,7 @@ require_relative "../molecules/task_family_completion"
13
14
  require_relative "../molecules/task_reparenter"
14
15
  require_relative "../molecules/issue_link"
15
16
  require_relative "../atoms/task_validation_rules"
17
+ require_relative "../atoms/task_frontmatter_defaults"
16
18
  module Ace
17
19
  module Task
18
20
  module Organisms
@@ -22,6 +24,10 @@ module Ace
22
24
  CREATE_RETRY_LIMIT = 3
23
25
  class CreateRetriesExhaustedError < StandardError; end
24
26
 
27
+ # Minimal item shape FolderMover#destination_path reads for a preview;
28
+ # the guard only needs the destination path, not a loaded task.
29
+ PlacementItem = Struct.new(:path)
30
+
25
31
  attr_reader :last_list_total, :last_folder_counts, :last_list_cycle_ids
26
32
 
27
33
  # @param root_dir [String, nil] Override root directory for tasks
@@ -43,6 +49,9 @@ module Ace
43
49
  # @param tags [Array<String>] Tags
44
50
  # @param dependencies [Array<String>] Dependency task IDs
45
51
  # @param use_llm_slug [Boolean] Whether to attempt LLM slug generation
52
+ # @param time [Time, nil] Creation time for the generated ID and
53
+ # created_at; a caller that pre-flighted generated paths passes the
54
+ # same value so the checked paths are the ones create writes
46
55
  # @return [Models::Task] Created task
47
56
  def create(
48
57
  title,
@@ -52,11 +61,13 @@ module Ace
52
61
  dependencies: [],
53
62
  use_llm_slug: false,
54
63
  estimate: nil,
55
- remote_issue: nil
64
+ remote_issue: nil,
65
+ time: nil
56
66
  )
57
67
  ensure_root_dir
58
68
  creator = Molecules::TaskCreator.new(root_dir: @root_dir, config: @config)
59
69
  attempts = 0
70
+ creation_time = time || Time.now.utc
60
71
 
61
72
  # Authoritative validation precedes the task write: an offline or
62
73
  # conflicting link must not create a task artifact at all. Offline
@@ -81,7 +92,7 @@ module Ace
81
92
  tags: tags,
82
93
  dependencies: dependencies,
83
94
  use_llm_slug: use_llm_slug,
84
- time: Time.now.utc + ((attempts - 1) * 2),
95
+ time: creation_time + ((attempts - 1) * 2),
85
96
  estimate: estimate,
86
97
  remote_issue: remote_issue
87
98
  )
@@ -137,9 +148,66 @@ module Ace
137
148
  special_folder: scan_result.special_folder)
138
149
  end
139
150
 
151
+ # Paths a --git-commit guard must validate for an update: every spec of
152
+ # the moving family at its current location and at the destination the
153
+ # relocation would create. Resolved before mutation, so a rejected
154
+ # commit leaves state unchanged.
155
+ #
156
+ # @param ref [String] Task reference
157
+ # @param move_to [String, nil] Requested folder move
158
+ # @param move_as_child_of [String, nil] Requested reparent target
159
+ # @return [Array<String>] Directory and spec paths to test with Git
160
+ def commit_guard_paths(ref, move_to: nil, move_as_child_of: nil)
161
+ task = show(ref)
162
+ return [] unless task
163
+
164
+ source_roots = [task.path]
165
+ destination_roots = []
166
+ extra_paths = []
167
+
168
+ if move_to
169
+ source_root, destination_root = relocation_roots(task, move_to)
170
+ source_roots = [source_root] if source_root
171
+ destination_roots << destination_root if destination_root
172
+ elsif move_as_child_of
173
+ # A reparent renames the family's spec files, so the guard must
174
+ # check the generated destination names, not the pre-move ones.
175
+ extra_paths.concat(reparenter_destination_paths(task, move_as_child_of))
176
+ end
177
+
178
+ paths = [task.path, task.file_path]
179
+ source_roots.each { |root| paths.concat(task_specs(root)) }
180
+ destination_roots.each do |destination_root|
181
+ paths << destination_root
182
+ source_roots.each do |source_root|
183
+ source_root_path = File.expand_path(source_root)
184
+ task_specs(source_root).each do |spec|
185
+ relative = spec.delete_prefix(source_root_path + File::SEPARATOR)
186
+ paths << File.join(destination_root, relative)
187
+ end
188
+ end
189
+ end
190
+ (paths + extra_paths).compact.uniq
191
+ end
192
+
193
+ # Destination directory a relocation would create, without mutating.
194
+ # Submitting an archive move of a subtask archives the parent family.
195
+ #
196
+ # @param task_or_ref [Models::Task, String]
197
+ # @param move_to [String]
198
+ # @return [String, nil]
199
+ def relocation_destination_path(task_or_ref, move_to)
200
+ task = task_or_ref.is_a?(Models::Task) ? task_or_ref : show(task_or_ref)
201
+ return nil unless task
202
+
203
+ _source_root, destination_root = relocation_roots(task, move_to)
204
+ destination_root
205
+ end
206
+
140
207
  # List tasks with optional filtering and sorting.
141
208
  # @param status [String, nil] Filter by status
142
- # @param in_folder [String, nil] Filter by special folder (default: "next" = root items only)
209
+ # @param in_folder [String, nil] Filter by special folder
210
+ # (default: "next" = ordinary root view)
143
211
  # @param tags [Array<String>] Filter by tags (any match)
144
212
  # @param filters [Array<String>, nil] Generic filter strings
145
213
  # @param sort [String] Sort order: "smart" (default, dependency-aware), "id", "priority", "created"
@@ -572,6 +640,38 @@ module Ace
572
640
  show_after_sync(created_subtask) || created_subtask
573
641
  end
574
642
 
643
+ # Paths a create call would write, resolved without mutating anything.
644
+ # The preview uses the real generated ID and title-derived slugs (or the
645
+ # next subtask character) plus the placement folder, so a pre-write
646
+ # --git-commit guard judges Git ignore rules against the actual
647
+ # operation instead of sample names. Pass the same `time` to create so
648
+ # both resolve the same ID.
649
+ #
650
+ # @param title [String] Task or subtask title
651
+ # @param in_folder [String, nil] Requested placement folder
652
+ # @param child_of [String, nil] Parent task reference for a subtask
653
+ # @param time [Time] Creation time used for the generated ID
654
+ # @return [Array<String>] Task directory and spec file path
655
+ def planned_creation_paths(title, in_folder: nil, child_of: nil, time: Time.now.utc)
656
+ if child_of
657
+ parent = show(child_of)
658
+ return [] unless parent
659
+
660
+ creator = Molecules::SubtaskCreator.new(config: @config)
661
+ return creator.destination_paths(parent, title)
662
+ end
663
+
664
+ creator = Molecules::TaskCreator.new(root_dir: @root_dir, config: @config)
665
+ dir, spec = creator.destination_paths(title, time: time)
666
+ detector = Ace::Support::Items::Atoms::SpecialFolderDetector
667
+ return [dir, spec] unless in_folder && !detector.move_to_root?(in_folder)
668
+
669
+ mover = Ace::Support::Items::Molecules::FolderMover.new(@root_dir)
670
+ placed_dir = mover.destination_path(PlacementItem.new(dir), to: in_folder,
671
+ date: planned_archive_date(time))
672
+ [placed_dir, File.join(placed_dir, File.basename(spec))]
673
+ end
674
+
575
675
  def issue_sync(ref: nil, all: false, pending: false)
576
676
  raise ArgumentError, "Provide --all or a task reference" if !all && !pending && (ref.nil? || ref.strip.empty?)
577
677
  raise ArgumentError, "--all and --pending are mutually exclusive" if all && pending
@@ -855,6 +955,8 @@ module Ace
855
955
 
856
956
  # Auto-archive: if a subtask reaches terminal status and all siblings
857
957
  # in the parent directory are also terminal, move the parent to archive.
958
+ # A Git-ignored family is not part of the committed task tree, so its
959
+ # completion never triggers a repository relocation.
858
960
  def check_auto_archive(task, new_status)
859
961
  return unless Atoms::TaskValidationRules.terminal_status?(new_status.to_s.downcase)
860
962
  return unless task.subtask?
@@ -864,14 +966,62 @@ module Ace
864
966
 
865
967
  parent = show(current.parent_id)
866
968
  return unless parent && family_completion.complete?(parent.path, id: parent.id)
969
+ # A Git-ignored family is not part of the committed task tree, and a
970
+ # relocation into an ignored destination would expose its content to
971
+ # normal staging: neither case triggers an automatic archive.
972
+ return if Molecules::GitIgnoredPath.ignored_family?(parent.path)
973
+
974
+ destination = relocation_destination_path(parent, "archive")
975
+ return if destination && Molecules::GitIgnoredPath.ignored_after_relocation?(parent.path, destination)
867
976
 
868
977
  archive_parent_via_update(parent)
869
978
  end
870
979
 
980
+ # Destination directory and generated spec paths a reparent would
981
+ # create, without mutating. Empty when the reparent itself is invalid.
982
+ def reparenter_destination_paths(task, target)
983
+ reparenter = Molecules::TaskReparenter.new(root_dir: @root_dir, config: @config)
984
+ reparenter.destination_paths(task, target: target, resolve_ref: ->(ref) { show(ref) })
985
+ rescue ArgumentError
986
+ []
987
+ end
988
+
989
+ # Source family root and destination root for a folder move.
990
+ # A subtask archive move relocates the whole parent family.
991
+ def relocation_roots(task, move_to)
992
+ detector = Ace::Support::Items::Atoms::SpecialFolderDetector
993
+ return [task.path, File.join(@root_dir, File.basename(task.path))] if detector.move_to_root?(move_to)
994
+
995
+ normalized = detector.normalize(move_to)
996
+ mover = Ace::Support::Items::Molecules::FolderMover.new(@root_dir)
997
+ if task.subtask? && normalized == "_archive"
998
+ parent = show(task.parent_id)
999
+ return [nil, nil] unless parent
1000
+
1001
+ [parent.path, mover.destination_path(parent, to: "archive", date: parse_archive_date(parent))]
1002
+ else
1003
+ [task.path, mover.destination_path(task, to: normalized, date: parse_archive_date(task))]
1004
+ end
1005
+ end
1006
+
1007
+ def task_specs(root)
1008
+ Dir.glob(File.join(root.to_s, "**", "*.s.md"))
1009
+ end
1010
+
871
1011
  def family_completion
872
1012
  Molecules::TaskFamilyCompletion.new(@root_dir)
873
1013
  end
874
1014
 
1015
+ # Archive placement derives its partition from the task's stored
1016
+ # created_at, which frontmatter records as zone-less UTC wall time and
1017
+ # parse_archive_date parses in the local zone. Mirror that parse so the
1018
+ # preview names the bucket the placement actually writes.
1019
+ def planned_archive_date(time)
1020
+ Time.parse(Atoms::TaskFrontmatterDefaults.format_time(time))
1021
+ rescue
1022
+ time
1023
+ end
1024
+
875
1025
  def parse_archive_date(task)
876
1026
  raw = task.metadata["completed_at"] || task.metadata["created_at"]
877
1027
  return nil unless raw
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Task
5
- VERSION = "0.39.3"
5
+ VERSION = "0.41.0"
6
6
  end
7
7
  end
data/lib/ace/task.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "task/models/task"
18
18
 
19
19
  # Molecules
20
20
  require_relative "task/molecules/task_config_loader"
21
+ require_relative "task/molecules/git_ignored_path"
21
22
  require_relative "task/molecules/task_scanner"
22
23
  require_relative "task/molecules/task_resolver"
23
24
  require_relative "task/molecules/task_loader"
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.3
4
+ version: 0.41.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-12 00:00:00.000000000 Z
10
+ date: 2026-10-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-core
@@ -195,6 +195,7 @@ files:
195
195
  - lib/ace/task/cli/commands/update.rb
196
196
  - lib/ace/task/models/task.rb
197
197
  - lib/ace/task/molecules/dependency_aware_smart_sorter.rb
198
+ - lib/ace/task/molecules/git_ignored_path.rb
198
199
  - lib/ace/task/molecules/issue_link.rb
199
200
  - lib/ace/task/molecules/issue_sync_adapter.rb
200
201
  - lib/ace/task/molecules/path_utils.rb