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 +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +16 -3
- data/lib/ace/task/cli/commands/create.rb +78 -3
- data/lib/ace/task/cli/commands/list.rb +4 -2
- data/lib/ace/task/cli/commands/plan.rb +2 -1
- data/lib/ace/task/cli/commands/update.rb +20 -1
- data/lib/ace/task/molecules/git_ignored_path.rb +73 -0
- data/lib/ace/task/molecules/subtask_creator.rb +29 -16
- data/lib/ace/task/molecules/task_config_loader.rb +7 -1
- data/lib/ace/task/molecules/task_creator.rb +28 -9
- data/lib/ace/task/molecules/task_doctor_fixer.rb +16 -1
- data/lib/ace/task/molecules/task_plan_cache.rb +7 -2
- data/lib/ace/task/molecules/task_reparenter.rb +127 -30
- data/lib/ace/task/molecules/task_scanner.rb +2 -1
- data/lib/ace/task/organisms/task_doctor.rb +7 -0
- data/lib/ace/task/organisms/task_manager.rb +153 -3
- data/lib/ace/task/version.rb +1 -1
- data/lib/ace/task.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a67866ad3602fb4570aca58dc63b0f60ea7575ce6baae62a6c1d6414970651c9
|
|
4
|
+
data.tar.gz: 7e547e93da96598b520866b605cf0368ec5beb2ff90c313c7118e649f029cae8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|

|
|
@@ -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
|
-
**
|
|
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)
|
|
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
|
-
|
|
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
|
-
" #
|
|
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],
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
83
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 =
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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
|
|
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
|
data/lib/ace/task/version.rb
CHANGED
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.
|
|
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-
|
|
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
|