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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf3a2b4bdbf7c413363c5f9b81a0f0b4161e6b50e58cce724adcb67ba03fc1c2
4
- data.tar.gz: b08a3d0f30ad8dfb455c6232bde690e632a9028a603ac37fa289a4768c2f0312
3
+ metadata.gz: a67866ad3602fb4570aca58dc63b0f60ea7575ce6baae62a6c1d6414970651c9
4
+ data.tar.gz: 7e547e93da96598b520866b605cf0368ec5beb2ff90c313c7118e649f029cae8
5
5
  SHA512:
6
- metadata.gz: ea63fb03267e6eb84875128ff0ec56becc9fbc0a650a40344cf9a1b11090a61791eed9c97a203ce8a231b132534d442d0238d93cac07162ecd96f522cdd930be
7
- data.tar.gz: 2be684d6224ec2a57dc9cc4f84eafdee43c14e1b044116e470f12b51dd1cab952267ff9b4a05d96f6e2df5eb4ef1b10d0bd52e32a31e53275a8cdca7e43b0626
6
+ metadata.gz: 9a86efa97d4a87a6e3de9561bb65a2cd9c0456ede8ece70e34c4a665776c4602c26977aa3557699c774118133571734c3266eba9b4523912225cf71f0f4aa4fd
7
+ data.tar.gz: e0301e6f51931b8b7320b01e362bab0d7988f67607660b0374e87b70b9bec524ed41e1640d7692bb914dc201553becb1a13057b036d949d6fef930ff0378929e
File without changes
File without changes
data/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@ 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
+
59
+ ## [0.40.0] - 2026-10-16
60
+
61
+ ### Added
62
+
63
+ - Support exact --root selection in create, show and update so project objective queues can use the configured directory without changing the global _folder convention.
64
+
10
65
  ## [0.39.3] - 2026-10-12
11
66
 
12
67
  ### Fixed
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
 
data/Rakefile CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -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,11 +45,13 @@ 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"
50
52
 
53
+ option :root, type: :string, aliases: %w[-r], desc: "Exact task root directory (overrides project config)"
54
+
51
55
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
52
56
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
53
57
  option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
@@ -77,9 +81,24 @@ module Ace
77
81
  end
78
82
  end
79
83
 
80
- manager = Ace::Task::Organisms::TaskManager.new
84
+ manager = if options[:root]
85
+ Ace::Task::Organisms::TaskManager.new(root_dir: File.expand_path(options[:root]))
86
+ else
87
+ Ace::Task::Organisms::TaskManager.new
88
+ end
81
89
  validate_destination!(manager, in_folder) if in_folder && !child_of
82
- 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]
83
102
 
84
103
  if dry_run
85
104
  puts "Would create task:"
@@ -112,7 +131,8 @@ module Ace
112
131
  priority: priority,
113
132
  tags: tags,
114
133
  estimate: estimate,
115
- remote_issue: remote_issue
134
+ remote_issue: remote_issue,
135
+ time: creation_time
116
136
  )
117
137
  end
118
138
  rescue Ace::Task::Organisms::TaskManager::CreateRetriesExhaustedError => e
@@ -129,6 +149,10 @@ module Ace
129
149
  task = place_created_task(manager, task, in_folder, placement_directories_before)
130
150
  end
131
151
 
152
+ if options[:git_commit]
153
+ reject_ignored_created_task!(manager, task, placement_directories_before)
154
+ end
155
+
132
156
  puts "Created task #{task.id}"
133
157
  puts " Path: #{task.file_path}"
134
158
  if manager.last_update_note && !manager.last_update_note.strip.empty?
@@ -147,6 +171,63 @@ module Ace
147
171
 
148
172
  private
149
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
+
150
231
  def validate_destination!(manager, target_folder)
151
232
  detector = Ace::Support::Items::Atoms::SpecialFolderDetector
152
233
  return if detector.move_to_root?(target_folder)
File without changes
@@ -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
@@ -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
File without changes
@@ -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,12 +42,15 @@ 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
 
47
50
  option :git_commit, type: :boolean, aliases: %w[--gc], desc: "Auto-commit changes"
48
51
 
52
+ option :root, type: :string, aliases: %w[-r], desc: "Exact task root directory (overrides project config)"
53
+
49
54
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
50
55
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
51
56
  option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
@@ -75,7 +80,13 @@ module Ace
75
80
  add_hash = parse_kv_pairs(add_args)
76
81
  remove_hash = parse_kv_pairs(remove_args)
77
82
 
78
- manager = Ace::Task::Organisms::TaskManager.new
83
+ manager = if options[:root]
84
+ Ace::Task::Organisms::TaskManager.new(root_dir: File.expand_path(options[:root]))
85
+ else
86
+ Ace::Task::Organisms::TaskManager.new
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]
79
90
 
80
91
  # Resolve --position into a set or remove operation
81
92
  if position_arg
@@ -120,6 +131,20 @@ module Ace
120
131
 
121
132
  private
122
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
+
123
148
  # Resolve a --position argument into a B36TS value.
124
149
  # Supports: first, last, after:<ref>, before:<ref>
125
150
  def resolve_position(arg, manager)
File without changes
@@ -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
File without changes
@@ -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