ace-task 0.40.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e07d26b4b14644e4227398532fa33c32066628db686f51274628d8164608225
4
- data.tar.gz: ae7def12a1bacbcd29744736de409970b612e943a48e291c8bdf6fdbb6480424
3
+ metadata.gz: a67866ad3602fb4570aca58dc63b0f60ea7575ce6baae62a6c1d6414970651c9
4
+ data.tar.gz: 7e547e93da96598b520866b605cf0368ec5beb2ff90c313c7118e649f029cae8
5
5
  SHA512:
6
- metadata.gz: 49a85005d9283e2920f421957bc4d5e2f6b5a181c8259a3d8e63c5206a2fdcde6c4ea4342be3cbb84acce05fe12895f3a2e57301202c362b8a1a305ddd1e141b
7
- data.tar.gz: dddf45e0bd3c18cef1cf1568987f91ab56b3f15567dee849ced55483e818fd147c4e0dfaadc95a872add0ab6510c4ded2538bd7a07051d04ec1306c44e6c48bb
6
+ metadata.gz: 9a86efa97d4a87a6e3de9561bb65a2cd9c0456ede8ece70e34c4a665776c4602c26977aa3557699c774118133571734c3266eba9b4523912225cf71f0f4aa4fd
7
+ data.tar.gz: e0301e6f51931b8b7320b01e362bab0d7988f67607660b0374e87b70b9bec524ed41e1640d7692bb914dc201553becb1a13057b036d949d6fef930ff0378929e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.41.0] - 2026-10-17
11
+
12
+ ### Added
13
+
14
+ - Detect Git-ignored task paths through Git itself (`git check-ignore` on the
15
+ actual path), from any working directory and with a custom task root, so
16
+ behavior for uncommitted task folders needs no folder-name knowledge.
17
+
18
+ ### Changed
19
+
20
+ - Remove all folder-name exceptions from `ace-task`: no private/shared scope
21
+ constants, no special `_overseer` create destination, and no special default
22
+ listing. The default `ace-task list` is the ordinary root view; any existing
23
+ special folder stays reachable with `--in FOLDER` or `--in all`.
24
+ - Refuse `--git-commit` when the task path is Git-ignored — on create, subtask
25
+ creation, update, and move — instead of rejecting a named folder. The check
26
+ covers the task directory, individual children, the spec file, and the
27
+ destination paths a relocation would create (including archive partitions);
28
+ a rejected create rolls back the task artifacts and a rejected move/update
29
+ leaves the task unchanged.
30
+ - Do not auto-archive a completed Git-ignored task (or ignored family), and do
31
+ not report or perform an archive move for it in `ace-task doctor`; a move is
32
+ also skipped when the resulting archive destination is Git-ignored. The
33
+ folder owner decides when to delete obsolete history. No private archive
34
+ folder or retention mechanism is created.
35
+ - The canonical lab-flow contract records that the delegation tools
36
+ (`ace-git-worktree create --task`, `ace-assign create --task`) are role- and
37
+ folder-name agnostic: they do not reject management tasks and do not inspect
38
+ `.gitignore`; preparing a correct delegation stays with the caller. The
39
+ tools' own changelogs carry their release scope.
40
+ - Resolve the project task root from the project root (not the current
41
+ directory) for `list --in FOLDER` validation and for the plan cache/context
42
+ resolution, so `ace-task list --in _maybe` and `plan` work from a
43
+ subdirectory. `--root` remains an explicit path override resolved from the
44
+ current directory.
45
+
46
+ ### Fixed
47
+
48
+ - Judge the `--git-commit` preflight against the paths create actually writes:
49
+ the generated ID and title-derived slugs, or the allocated subtask character,
50
+ including the real placement folder. The previous sample-name probes could
51
+ reject a valid root task (for example with a `0-*/` ignore rule) even though
52
+ its generated path is tracked.
53
+ - Check the generated destination names of a reparent's whole family. Demoting
54
+ or promoting a task renames its own spec and every descendant spec, so a
55
+ guard using the pre-move names could pass while the new child spec is
56
+ Git-ignored — the commit then lost the child from the repository. The guard
57
+ now previews the actual renamed spec paths before mutating.
58
+
10
59
  ## [0.40.0] - 2026-10-16
11
60
 
12
61
  ### Added
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <div align="center">
2
2
  <h1> ACE - Task </h1>
3
3
 
4
- Draft, organize, and tackle tasks — for you and your agents.
4
+ Draft, organize, and tackle tasks -- for you and your agents.
5
5
 
6
6
  <img src="../docs/brand/AgenticCodingEnvironment.Logo.XS.jpg" alt="ACE Logo" width="480">
7
7
  <br><br>
@@ -14,6 +14,17 @@
14
14
 
15
15
  > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
16
16
 
17
+ > **State owner:** `.ace-tasks/` -- project task markdown files and frontmatter
18
+ > owned by `ace-task`. Each project task gets one worktree under `.ace-wt/`.
19
+ > `ace-task` is folder-name agnostic: behavior that depends on Git tracking
20
+ > comes from Git's ignore rules for the actual path, never from a folder name.
21
+ > `--git-commit` is refused for Git-ignored tasks, and completed ignored tasks
22
+ > are neither auto-archived nor moved by `doctor --auto-fix`. A lab may prepare
23
+ > `.ace-tasks/_overseer/` (gitignored) and manage it with
24
+ > `ace-task --in _overseer`; the delegation tools are role- and folder-name
25
+ > agnostic and do not reject management tasks. The canonical flow is
26
+ > [docs/lab-flow.md](../docs/lab-flow.md).
27
+
17
28
  [Getting Started](docs/getting-started.md) | [Usage Guide](docs/usage.md) | [Handbook - Skills, Agents, Templates](docs/handbook.md)
18
29
 
19
30
  ![ace-task demo](docs/demo/ace-task-getting-started.gif)
@@ -41,13 +52,15 @@ Tasks are markdown specs living in git - attached to branches and worktrees, not
41
52
 
42
53
  **Plan the work** - generate a step-by-step implementation plan with `/as-task-plan` or `ace-task plan`. Define subtasks, configure which agent handles execution, and break scope into assignable units. The plan lives in the task spec so any agent can pick it up.
43
54
 
44
- **Run as dark factory** - hand a task to [ace-overseer](../ace-overseer) and it provisions a worktree, opens a tmux window, creates an assignment, and drives execution through the full lifecycle - onboard, implement, verify, create PR, review, ship. One command kicks it off:
55
+ **Start task work in one command** - hand a task to [ace-overseer](../ace-overseer) and it provisions a worktree under `.ace-wt/`, prepares an assignment in `.ace-local/assign/`, and opens a terminal window (tmux or Herdr). One command kicks it off:
45
56
 
46
57
  ```bash
47
58
  ace-overseer work-on --task t.tt6 --preset work-on-task
48
59
  ```
49
60
 
50
- Under the hood this chains [ace-git-worktree](../ace-git-worktree) (isolated branch) -> [ace-tmux](../ace-tmux) (dedicated window and panes layout) -> [ace-assign](../ace-assign) (step-by-step execution). Switch to the tmux window and run `/as-assign-drive` to walk through each step.
61
+ Under the hood this chains [ace-git-worktree](../ace-git-worktree) (isolated branch and worktree) → [ace-tmux](../ace-tmux) or [ace-herdr](../ace-herdr) (terminal) → [ace-assign](../ace-assign) (step-by-step plan). Switch to the window and run `/as-assign-drive` to walk through each step.
62
+
63
+ > **Planned (L6):** the private overseer queue that automatically continues already delegated, unblocked work is not available yet.
51
64
 
52
65
  **Track progress** - `ace-task list` shows tasks by status - what is next, what is in progress, and what was recently completed. `ace-task status` gives a focused view of the current task with subtask progress.
53
66
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "ace/support/cli"
4
4
  require "fileutils"
5
+ require_relative "../../molecules/git_ignored_path"
5
6
 
6
7
  module Ace
7
8
  module Task
@@ -27,6 +28,7 @@ module Ace
27
28
  '"Setup DB" --child-of q7w # Create as subtask',
28
29
  '"Track issue" --issue 276 --server forgejo-lab # Link one exact issue',
29
30
  '"Quick task" --in maybe # Create in _maybe/ folder',
31
+ '"Filed task" --in _review # Create in an existing custom folder',
30
32
  '"Draft spec" --status draft --estimate TBD # Create as draft with estimate',
31
33
  '"Preview only" --dry-run # Show what would be created'
32
34
  ]
@@ -43,7 +45,7 @@ module Ace
43
45
  option :"default-server", type: :boolean, desc: "Use the configured default forge server"
44
46
  option :"child-of", type: :string, desc: "Parent task reference (creates subtask)"
45
47
  option :in, type: :string, aliases: %w[-i],
46
- desc: "Top-level target (next, maybe, anytime, archive, or existing custom folder)"
48
+ desc: "Top-level target (next, maybe, anytime, archive, or an existing custom folder)"
47
49
  option :"dry-run", type: :boolean, aliases: %w[-n], desc: "Preview without writing"
48
50
 
49
51
  option :git_commit, type: :boolean, aliases: %w[--gc], desc: "Auto-commit changes"
@@ -85,7 +87,18 @@ module Ace
85
87
  Ace::Task::Organisms::TaskManager.new
86
88
  end
87
89
  validate_destination!(manager, in_folder) if in_folder && !child_of
88
- placement_directories_before = directories_under(manager.root_dir) if in_folder && !child_of
90
+ # The preflight and the write resolve the same creation time, so
91
+ # the checked paths are exactly the generated paths create writes.
92
+ creation_time = Time.now.utc
93
+ if options[:git_commit]
94
+ reject_ignored_creation_target!(manager, title: title, in_folder: in_folder,
95
+ child_of: child_of, time: creation_time)
96
+ end
97
+ # Snapshot before any write so a rejected --git-commit can remove
98
+ # exactly the artifacts this command created (including a placed
99
+ # task, a new subtask, or an empty destination folder).
100
+ placement_directories_before = directories_under(manager.root_dir) if
101
+ (in_folder && !child_of) || options[:git_commit]
89
102
 
90
103
  if dry_run
91
104
  puts "Would create task:"
@@ -118,7 +131,8 @@ module Ace
118
131
  priority: priority,
119
132
  tags: tags,
120
133
  estimate: estimate,
121
- remote_issue: remote_issue
134
+ remote_issue: remote_issue,
135
+ time: creation_time
122
136
  )
123
137
  end
124
138
  rescue Ace::Task::Organisms::TaskManager::CreateRetriesExhaustedError => e
@@ -135,6 +149,10 @@ module Ace
135
149
  task = place_created_task(manager, task, in_folder, placement_directories_before)
136
150
  end
137
151
 
152
+ if options[:git_commit]
153
+ reject_ignored_created_task!(manager, task, placement_directories_before)
154
+ end
155
+
138
156
  puts "Created task #{task.id}"
139
157
  puts " Path: #{task.file_path}"
140
158
  if manager.last_update_note && !manager.last_update_note.strip.empty?
@@ -153,6 +171,63 @@ module Ace
153
171
 
154
172
  private
155
173
 
174
+ # --git-commit preflight: resolve the known destination (the parent
175
+ # family or the placement folder) and the exact task paths this
176
+ # create call would write — generated ID and title-derived slugs, or
177
+ # the next subtask character — so ignore rules are judged against the
178
+ # real operation, never against sample names. The post-creation check
179
+ # stays authoritative for ID-collision retries and concurrent changes.
180
+ def reject_ignored_creation_target!(manager, title:, in_folder:, child_of:, time:)
181
+ detector = Ace::Support::Items::Atoms::SpecialFolderDetector
182
+ target = if child_of
183
+ manager.show(child_of)&.path
184
+ elsif in_folder && !detector.move_to_root?(in_folder)
185
+ File.join(manager.root_dir, detector.normalize(in_folder))
186
+ else
187
+ manager.root_dir
188
+ end
189
+ return unless target
190
+
191
+ if Ace::Task::Molecules::GitIgnoredPath.ignored?(target)
192
+ raise Ace::Support::Cli::Error,
193
+ "Cannot --git-commit into a Git-ignored destination: #{target}. " \
194
+ "Choose a tracked folder or omit --git-commit."
195
+ end
196
+
197
+ ignored = manager.planned_creation_paths(title, in_folder: in_folder, child_of: child_of, time: time)
198
+ .find { |path| Ace::Task::Molecules::GitIgnoredPath.ignored?(path) }
199
+ return unless ignored
200
+
201
+ raise Ace::Support::Cli::Error,
202
+ "Cannot --git-commit into a destination with Git-ignored child paths: #{ignored}. " \
203
+ "Choose a tracked folder or adjust the ignore rules."
204
+ end
205
+
206
+ # --git-commit must not stage a task whose directory or spec path Git
207
+ # ignores. The check runs on the created paths (covering generated
208
+ # IDs, subtask folders, and the placed destination) and rolls the
209
+ # creation back, so a rejected commit leaves no task behind.
210
+ def reject_ignored_created_task!(manager, task, directories_before)
211
+ return unless Ace::Task::Molecules::GitIgnoredPath.ignored_task?(task)
212
+
213
+ begin
214
+ remove_created_task_artifacts(manager, task)
215
+ remove_empty_directories_created_since(manager.root_dir, directories_before)
216
+ rescue StandardError => cleanup_error
217
+ raise Ace::Support::Cli::Error,
218
+ "Cannot --git-commit a Git-ignored task path: #{task.path}. " \
219
+ "Cleanup also failed: #{cleanup_error.message}"
220
+ end
221
+
222
+ raise_ignored_commit!(task.path)
223
+ end
224
+
225
+ def raise_ignored_commit!(path)
226
+ raise Ace::Support::Cli::Error,
227
+ "Cannot --git-commit a Git-ignored task path: #{path}. " \
228
+ "Move the task to a tracked folder or omit --git-commit."
229
+ end
230
+
156
231
  def validate_destination!(manager, target_folder)
157
232
  detector = Ace::Support::Items::Atoms::SpecialFolderDetector
158
233
  return if detector.move_to_root?(target_folder)
@@ -20,9 +20,10 @@ module Ace
20
20
  remove_const(:C)
21
21
 
22
22
  example [
23
- " # Active tasks (root only, default)",
23
+ " # Root tasks (default next view)",
24
24
  "--in all # All tasks including archived/maybe",
25
25
  "--in maybe # Tasks in _maybe/",
26
+ "--in archive # Archived tasks",
26
27
  "--status pending # Filter by status",
27
28
  "--tags ux,design # Tasks matching any tag",
28
29
  "--in next --status pending # Combined filters",
@@ -33,7 +34,8 @@ module Ace
33
34
 
34
35
  option :status, type: :string, aliases: %w[-s], desc: "Filter by status (pending, in-progress, done, blocked)"
35
36
  option :tags, type: :string, aliases: %w[-T], desc: "Filter by tags (comma-separated, any match)"
36
- option :in, type: :string, aliases: %w[-i], desc: "Filter by folder (next=root only [default], all=everything, maybe, archive)"
37
+ option :in, type: :string, aliases: %w[-i],
38
+ desc: "Folder (next [default: root tasks], all, maybe, archive, or an existing special folder)"
37
39
  option :root, type: :string, aliases: %w[-r], desc: "Override root path (subpath within tasks root)"
38
40
  option :filter, type: :array, aliases: %w[-f], desc: "Filter by key:value (repeatable, supports key:a|b and key:!value)"
39
41
  option :sort, type: :string, aliases: %w[-S], desc: "Sort order: smart (default, dependency-aware), id, priority, created"
@@ -89,7 +89,8 @@ module Ace
89
89
 
90
90
  def capture_context_files(task)
91
91
  files = Array(task.metadata.dig("bundle", "files"))
92
- expanded = files.map { |path| File.expand_path(path, Dir.pwd) }.uniq
92
+ project_root = Molecules::TaskConfigLoader.project_root
93
+ expanded = files.map { |path| File.expand_path(path, project_root) }.uniq
93
94
  resolved = expanded.select { |path| File.file?(path) }
94
95
 
95
96
  missing = expanded - resolved
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "ace/support/cli"
4
4
  require "ace/support/items"
5
+ require_relative "../../molecules/git_ignored_path"
5
6
 
6
7
  module Ace
7
8
  module Task
@@ -26,6 +27,7 @@ module Ace
26
27
  "q7w --set worktree.branch=my-branch",
27
28
  "q7w --set status=done --move-to archive",
28
29
  "q7w --move-to next",
30
+ "q7w --move-to _maybe # Move to an existing special folder",
29
31
  "q7w.a --move-as-child-of none # Promote subtask to standalone",
30
32
  "q7w --move-as-child-of self # Convert to orchestrator",
31
33
  "q7w --move-as-child-of abc # Demote to subtask of abc",
@@ -40,7 +42,8 @@ module Ace
40
42
  option :set, type: :array, desc: "Set field: key=value (comma-separated for multiple)"
41
43
  option :add, type: :array, desc: "Add to array field: key=value (comma-separated for multiple)"
42
44
  option :remove, type: :array, desc: "Remove from array field: key=value (comma-separated for multiple)"
43
- option :move_to, type: :string, aliases: %w[-m], desc: "Move to folder (archive, maybe, anytime, next)"
45
+ option :move_to, type: :string, aliases: %w[-m],
46
+ desc: "Move to folder (archive, maybe, anytime, next, or an existing custom folder)"
44
47
  option :move_as_child_of, type: :string, desc: "Reparent: <parent_ref>, 'none' (promote), 'self' (orchestrator)"
45
48
  option :position, type: :string, aliases: %w[-p], desc: "Set position: first, last, after:<ref>, before:<ref>"
46
49
 
@@ -82,6 +85,8 @@ module Ace
82
85
  else
83
86
  Ace::Task::Organisms::TaskManager.new
84
87
  end
88
+ reject_ignored_git_commit!(manager, ref: ref, move_to: move_to,
89
+ move_as_child_of: move_as_child) if options[:git_commit]
85
90
 
86
91
  # Resolve --position into a set or remove operation
87
92
  if position_arg
@@ -126,6 +131,20 @@ module Ace
126
131
 
127
132
  private
128
133
 
134
+ # --git-commit must not stage a task whose path Git ignores. Both the
135
+ # current paths and the paths the relocation would create are
136
+ # resolved before mutation: an ignore rule may target the destination
137
+ # folder, its children, individual spec files, or archive partitions.
138
+ def reject_ignored_git_commit!(manager, ref:, move_to:, move_as_child_of:)
139
+ paths = manager.commit_guard_paths(ref, move_to: move_to, move_as_child_of: move_as_child_of)
140
+ ignored = paths.find { |path| Ace::Task::Molecules::GitIgnoredPath.ignored?(path) }
141
+ return unless ignored
142
+
143
+ raise Ace::Support::Cli::Error,
144
+ "Cannot --git-commit a Git-ignored task path: #{ignored}. " \
145
+ "Move the task to a tracked folder or omit --git-commit."
146
+ end
147
+
129
148
  # Resolve a --position argument into a B36TS value.
130
149
  # Supports: first, last, after:<ref>, before:<ref>
131
150
  def resolve_position(arg, manager)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+
5
+ module Ace
6
+ module Task
7
+ module Molecules
8
+ # Detects whether a task path is ignored by Git.
9
+ #
10
+ # ace-task is a universal task tool: it does not know which folders a lab
11
+ # or a role reserves. When behavior has to differ for a path that is not
12
+ # committed, the decision comes from Git's own ignore rules for the actual
13
+ # path — never from a folder-name list. The same query covers a task
14
+ # directory or its spec file, a path that does not exist yet (a destination
15
+ # computed before a move), any working directory, and a custom task root:
16
+ # Git resolves the repository from the path itself.
17
+ class GitIgnoredPath
18
+ # @param path [String] Path to check (file or directory, may not exist)
19
+ # @return [Boolean] true when Git ignores the path (a commit would skip it)
20
+ def self.ignored?(path)
21
+ absolute = File.expand_path(path.to_s)
22
+ working_dir = File.directory?(absolute) ? absolute : File.dirname(absolute)
23
+ working_dir = File.dirname(working_dir) until Dir.exist?(working_dir) ||
24
+ File.dirname(working_dir) == working_dir
25
+ return false unless Dir.exist?(working_dir)
26
+
27
+ result = Ace::Git::Atoms::CommandExecutor.execute(
28
+ "git", "-C", working_dir, "check-ignore", "-q", "--", absolute
29
+ )
30
+ result[:success] == true
31
+ rescue StandardError
32
+ false
33
+ end
34
+
35
+ # A task is uncommittable when its directory or its spec file is ignored:
36
+ # ignore rules may target the folder, individual children, or spec names.
37
+ #
38
+ # @param task [Models::Task]
39
+ # @return [Boolean]
40
+ def self.ignored_task?(task)
41
+ ignored?(task.path) || ignored?(task.file_path)
42
+ end
43
+
44
+ # A task family is uncommittable when its directory or any spec file
45
+ # inside it is ignored (auto-archive and doctor move whole families).
46
+ #
47
+ # @param dir_path [String]
48
+ # @return [Boolean]
49
+ def self.ignored_family?(dir_path)
50
+ ignored?(dir_path) ||
51
+ Dir.glob(File.join(dir_path.to_s, "**", "*.s.md")).any? { |spec| ignored?(spec) }
52
+ end
53
+
54
+ # Relocating into an ignored destination would expose committed-looking
55
+ # content to normal staging, so both the destination directory and the
56
+ # destination paths of the moving specs must be checked before a move.
57
+ #
58
+ # @param source_dir [String] Current task/family directory
59
+ # @param destination_dir [String] Directory the move would create
60
+ # @return [Boolean]
61
+ def self.ignored_after_relocation?(source_dir, destination_dir)
62
+ return true if ignored?(destination_dir)
63
+
64
+ source_root = File.expand_path(source_dir.to_s)
65
+ Dir.glob(File.join(source_root, "**", "*.s.md")).any? do |spec|
66
+ relative = spec.delete_prefix(source_root + File::SEPARATOR)
67
+ ignored?(File.join(destination_dir.to_s, relative))
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -44,20 +44,9 @@ module Ace
44
44
  )
45
45
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
46
46
 
47
- # Find next available subtask character
48
- existing_chars = scan_existing_subtask_chars(parent_task.path, parent_task.id)
49
- next_char = allocate_next_char(existing_chars)
50
-
51
- # Build subtask ID: parent_id + ".{char}"
52
- subtask_id = "#{parent_task.id}.#{next_char}"
53
-
54
- # Generate slugs (folder: 5 words, file: 7 words)
55
- folder_slug = generate_folder_slug(title)
56
- file_slug = generate_file_slug(title)
57
-
58
- # Build folder and file names
59
- folder_name = "#{next_char}-#{folder_slug}"
60
- subtask_dir = File.join(parent_task.path, folder_name)
47
+ paths = destination_paths_for(parent_task, title)
48
+ subtask_id = paths[:id]
49
+ subtask_dir = paths[:dir]
61
50
  FileUtils.mkdir_p(subtask_dir)
62
51
 
63
52
  # Build frontmatter
@@ -74,8 +63,7 @@ module Ace
74
63
  )
75
64
 
76
65
  # Write spec file
77
- spec_filename = "#{subtask_id}-#{file_slug}.s.md"
78
- spec_file = File.join(subtask_dir, spec_filename)
66
+ spec_file = paths[:spec]
79
67
  content = build_spec_content(frontmatter: frontmatter, title: title)
80
68
  File.write(spec_file, content)
81
69
 
@@ -84,8 +72,33 @@ module Ace
84
72
  loader.load(subtask_dir, id: subtask_id, load_subtasks: false)
85
73
  end
86
74
 
75
+ # Non-mutating preview of the directory and spec file a subtask create
76
+ # call would write: the next allocated subtask character and the
77
+ # title-derived slugs, matching create exactly. Pre-write checks use it
78
+ # to judge Git ignore rules against the real generated names.
79
+ #
80
+ # @param parent_task [Models::Task] Parent task
81
+ # @param title [String] Subtask title
82
+ # @return [Array<String>] Subtask directory and spec file path
83
+ def destination_paths(parent_task, title)
84
+ raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
85
+
86
+ paths = destination_paths_for(parent_task, title)
87
+ [paths[:dir], paths[:spec]]
88
+ end
89
+
87
90
  private
88
91
 
92
+ def destination_paths_for(parent_task, title)
93
+ existing_chars = scan_existing_subtask_chars(parent_task.path, parent_task.id)
94
+ next_char = allocate_next_char(existing_chars)
95
+ subtask_id = "#{parent_task.id}.#{next_char}"
96
+ folder_slug = generate_folder_slug(title)
97
+ file_slug = generate_file_slug(title)
98
+ subtask_dir = File.join(parent_task.path, "#{next_char}-#{folder_slug}")
99
+ {id: subtask_id, dir: subtask_dir, spec: File.join(subtask_dir, "#{subtask_id}-#{file_slug}.s.md")}
100
+ end
101
+
89
102
  # Scan parent directory for existing subtask folders and extract their chars.
90
103
  def scan_existing_subtask_chars(parent_dir, _parent_id)
91
104
  chars = []
@@ -31,6 +31,12 @@ module Ace
31
31
  deep_merge(config, load_project_config)
32
32
  end
33
33
 
34
+ # Project root used to resolve relative configuration and file paths.
35
+ # @return [String] Absolute project root (current directory when no marker is found)
36
+ def self.project_root
37
+ Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
38
+ end
39
+
34
40
  # Get the root directory for tasks
35
41
  # @param config [Hash] Configuration hash
36
42
  # @return [String] Absolute path to tasks root directory
@@ -41,7 +47,7 @@ module Ace
41
47
  if dir.start_with?("/")
42
48
  dir
43
49
  else
44
- File.join(Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current, dir)
50
+ File.join(project_root, dir)
45
51
  end
46
52
  end
47
53
 
@@ -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
@@ -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)
@@ -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)
@@ -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.40.0"
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.40.0
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-16 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