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.
- checksums.yaml +4 -4
- data/.ace-defaults/nav/protocols/skill-sources/ace-task.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-task.yml +0 -0
- data/.ace-defaults/task/config.yml +0 -0
- data/CHANGELOG.md +55 -0
- data/README.md +16 -3
- data/Rakefile +0 -0
- data/handbook/guides/task-definition.g.md +0 -0
- data/handbook/skills/as-bug-analyze/SKILL.md +0 -0
- data/handbook/skills/as-bug-fix/SKILL.md +0 -0
- data/handbook/skills/as-task-document-unplanned/SKILL.md +0 -0
- data/handbook/skills/as-task-draft/SKILL.md +0 -0
- data/handbook/skills/as-task-finder/SKILL.md +0 -0
- data/handbook/skills/as-task-plan/SKILL.md +0 -0
- data/handbook/skills/as-task-review/SKILL.md +0 -0
- data/handbook/skills/as-task-review-questions/SKILL.md +0 -0
- data/handbook/skills/as-task-update/SKILL.md +0 -0
- data/handbook/skills/as-task-work/SKILL.md +0 -0
- data/handbook/templates/task/draft.template.md +0 -0
- data/handbook/templates/task/file-modification-checklist.template.md +0 -0
- data/handbook/templates/task/technical-approach.template.md +0 -0
- data/handbook/workflow-instructions/bug/analyze.wf.md +0 -0
- data/handbook/workflow-instructions/bug/fix.wf.md +0 -0
- data/handbook/workflow-instructions/task/document-unplanned.wf.md +0 -0
- data/handbook/workflow-instructions/task/finder.wf.md +0 -0
- data/handbook/workflow-instructions/task/plan.wf.md +0 -0
- data/handbook/workflow-instructions/task/review-plan.wf.md +0 -0
- data/handbook/workflow-instructions/task/review-questions.wf.md +0 -0
- data/handbook/workflow-instructions/task/review-work.wf.md +0 -0
- data/handbook/workflow-instructions/task/review.wf.md +0 -0
- data/handbook/workflow-instructions/task/update.wf.md +0 -0
- data/lib/ace/task/atoms/task_file_pattern.rb +0 -0
- data/lib/ace/task/atoms/task_id_formatter.rb +0 -0
- data/lib/ace/task/atoms/task_validation_rules.rb +0 -0
- data/lib/ace/task/cli/commands/create.rb +85 -4
- data/lib/ace/task/cli/commands/doctor.rb +0 -0
- 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/show.rb +7 -1
- data/lib/ace/task/cli/commands/status.rb +0 -0
- data/lib/ace/task/cli/commands/update.rb +27 -2
- data/lib/ace/task/models/task.rb +0 -0
- data/lib/ace/task/molecules/git_ignored_path.rb +73 -0
- data/lib/ace/task/molecules/path_utils.rb +0 -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_doctor_reporter.rb +0 -0
- data/lib/ace/task/molecules/task_loader.rb +0 -0
- data/lib/ace/task/molecules/task_plan_cache.rb +7 -2
- data/lib/ace/task/molecules/task_plan_generator.rb +0 -0
- data/lib/ace/task/molecules/task_reparenter.rb +127 -30
- data/lib/ace/task/molecules/task_scanner.rb +2 -1
- data/lib/ace/task/molecules/task_structure_validator.rb +0 -0
- 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
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "ace/support/items"
|
|
7
7
|
require "ace/b36ts"
|
|
8
8
|
require_relative "path_utils"
|
|
9
|
+
require_relative "task_config_loader"
|
|
9
10
|
|
|
10
11
|
module Ace
|
|
11
12
|
module Task
|
|
@@ -20,7 +21,7 @@ module Ace
|
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def cache_dir
|
|
23
|
-
File.join(
|
|
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)
|
|
File without changes
|
|
@@ -35,6 +35,32 @@ module Ace
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Destination paths a reparent will create, without mutating anything.
|
|
39
|
+
# Returns the destination directories and every generated spec file
|
|
40
|
+
# (order is not significant): the task's own renamed spec plus each
|
|
41
|
+
# descendant's spec, whose basename follows the descendant's new ID.
|
|
42
|
+
# Used by pre-flight checks such as the --git-commit ignored-path
|
|
43
|
+
# guard, which must test the names the move will actually write.
|
|
44
|
+
#
|
|
45
|
+
# @param task [Models::Task] Task to reparent
|
|
46
|
+
# @param target [String] "none", "self", or a parent ref
|
|
47
|
+
# @param resolve_ref [Proc] Resolves a parent ref to a Task
|
|
48
|
+
# @return [Array<String>] Destination directories and spec file paths
|
|
49
|
+
def destination_paths(task, target:, resolve_ref:)
|
|
50
|
+
case target.downcase
|
|
51
|
+
when "none"
|
|
52
|
+
promoted_destination_paths(task)
|
|
53
|
+
when "self"
|
|
54
|
+
orchestrator_destination_paths(task)
|
|
55
|
+
else
|
|
56
|
+
parent_task = resolve_ref.call(target)
|
|
57
|
+
raise ArgumentError, "Parent task '#{target}' not found" unless parent_task
|
|
58
|
+
raise ArgumentError, "Cannot reparent task to itself" if task.id == parent_task.id
|
|
59
|
+
|
|
60
|
+
demoted_destination_paths(task, parent_task)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
38
64
|
private
|
|
39
65
|
|
|
40
66
|
# Promote a subtask to a standalone task at root level.
|
|
@@ -44,17 +70,10 @@ module Ace
|
|
|
44
70
|
raise ArgumentError, "Task #{task.id} is already a standalone task"
|
|
45
71
|
end
|
|
46
72
|
|
|
47
|
-
|
|
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)
|
|
File without changes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../molecules/task_scanner"
|
|
4
|
+
require_relative "../molecules/git_ignored_path"
|
|
4
5
|
require_relative "../molecules/task_frontmatter_validator"
|
|
5
6
|
require_relative "../molecules/task_structure_validator"
|
|
6
7
|
require_relative "../molecules/task_doctor_fixer"
|
|
@@ -153,6 +154,12 @@ module Ace
|
|
|
153
154
|
frontmatter, _body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
154
155
|
next unless frontmatter.is_a?(Hash)
|
|
155
156
|
|
|
157
|
+
# A Git-ignored task path (folder or spec file) is not part of the
|
|
158
|
+
# committed task tree: repository archive policy neither applies
|
|
159
|
+
# nor authorizes a move.
|
|
160
|
+
next if Molecules::GitIgnoredPath.ignored?(scan_result.dir_path) ||
|
|
161
|
+
Molecules::GitIgnoredPath.ignored?(scan_result.file_path)
|
|
162
|
+
|
|
156
163
|
status = frontmatter["status"]
|
|
157
164
|
special_folder = scan_result.special_folder
|
|
158
165
|
|
|
@@ -4,6 +4,7 @@ require "digest"
|
|
|
4
4
|
require "tmpdir"
|
|
5
5
|
|
|
6
6
|
require_relative "../molecules/task_config_loader"
|
|
7
|
+
require_relative "../molecules/git_ignored_path"
|
|
7
8
|
require_relative "../molecules/task_scanner"
|
|
8
9
|
require_relative "../molecules/task_resolver"
|
|
9
10
|
require_relative "../molecules/task_loader"
|
|
@@ -13,6 +14,7 @@ require_relative "../molecules/task_family_completion"
|
|
|
13
14
|
require_relative "../molecules/task_reparenter"
|
|
14
15
|
require_relative "../molecules/issue_link"
|
|
15
16
|
require_relative "../atoms/task_validation_rules"
|
|
17
|
+
require_relative "../atoms/task_frontmatter_defaults"
|
|
16
18
|
module Ace
|
|
17
19
|
module Task
|
|
18
20
|
module Organisms
|
|
@@ -22,6 +24,10 @@ module Ace
|
|
|
22
24
|
CREATE_RETRY_LIMIT = 3
|
|
23
25
|
class CreateRetriesExhaustedError < StandardError; end
|
|
24
26
|
|
|
27
|
+
# Minimal item shape FolderMover#destination_path reads for a preview;
|
|
28
|
+
# the guard only needs the destination path, not a loaded task.
|
|
29
|
+
PlacementItem = Struct.new(:path)
|
|
30
|
+
|
|
25
31
|
attr_reader :last_list_total, :last_folder_counts, :last_list_cycle_ids
|
|
26
32
|
|
|
27
33
|
# @param root_dir [String, nil] Override root directory for tasks
|
|
@@ -43,6 +49,9 @@ module Ace
|
|
|
43
49
|
# @param tags [Array<String>] Tags
|
|
44
50
|
# @param dependencies [Array<String>] Dependency task IDs
|
|
45
51
|
# @param use_llm_slug [Boolean] Whether to attempt LLM slug generation
|
|
52
|
+
# @param time [Time, nil] Creation time for the generated ID and
|
|
53
|
+
# created_at; a caller that pre-flighted generated paths passes the
|
|
54
|
+
# same value so the checked paths are the ones create writes
|
|
46
55
|
# @return [Models::Task] Created task
|
|
47
56
|
def create(
|
|
48
57
|
title,
|
|
@@ -52,11 +61,13 @@ module Ace
|
|
|
52
61
|
dependencies: [],
|
|
53
62
|
use_llm_slug: false,
|
|
54
63
|
estimate: nil,
|
|
55
|
-
remote_issue: nil
|
|
64
|
+
remote_issue: nil,
|
|
65
|
+
time: nil
|
|
56
66
|
)
|
|
57
67
|
ensure_root_dir
|
|
58
68
|
creator = Molecules::TaskCreator.new(root_dir: @root_dir, config: @config)
|
|
59
69
|
attempts = 0
|
|
70
|
+
creation_time = time || Time.now.utc
|
|
60
71
|
|
|
61
72
|
# Authoritative validation precedes the task write: an offline or
|
|
62
73
|
# conflicting link must not create a task artifact at all. Offline
|
|
@@ -81,7 +92,7 @@ module Ace
|
|
|
81
92
|
tags: tags,
|
|
82
93
|
dependencies: dependencies,
|
|
83
94
|
use_llm_slug: use_llm_slug,
|
|
84
|
-
time:
|
|
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
|