ace-task 0.38.0 → 0.39.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 +23 -0
- data/handbook/workflow-instructions/task/draft.wf.md +18 -18
- data/lib/ace/task/atoms/task_frontmatter_defaults.rb +5 -2
- data/lib/ace/task/cli/commands/create.rb +20 -13
- data/lib/ace/task/cli/commands/issue_link.rb +38 -0
- data/lib/ace/task/cli/commands/issue_sync.rb +42 -0
- data/lib/ace/task/cli/commands/update.rb +2 -0
- data/lib/ace/task/cli.rb +9 -5
- data/lib/ace/task/molecules/issue_link.rb +75 -0
- data/lib/ace/task/molecules/issue_sync_adapter.rb +99 -0
- data/lib/ace/task/molecules/subtask_creator.rb +2 -2
- data/lib/ace/task/molecules/task_creator.rb +2 -2
- data/lib/ace/task/molecules/task_frontmatter_validator.rb +44 -18
- data/lib/ace/task/molecules/task_plan_prompt_builder.rb +3 -3
- data/lib/ace/task/molecules/task_reparenter.rb +66 -0
- data/lib/ace/task/molecules/task_resolver.rb +31 -3
- data/lib/ace/task/organisms/task_manager.rb +780 -107
- data/lib/ace/task/version.rb +1 -1
- data/lib/ace/task.rb +0 -1
- metadata +24 -8
- data/lib/ace/task/cli/commands/github_sync.rb +0 -73
- data/lib/ace/task/molecules/github_issue_sync_adapter.rb +0 -88
|
@@ -110,7 +110,7 @@ module Ace
|
|
|
110
110
|
issues << {type: :warning, message: "Title exceeds #{Atoms::TaskValidationRules::MAX_TITLE_LENGTH} characters (#{title.length} chars)", location: file_path}
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
validate_issue_fields(frontmatter, file_path, issues)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
def validate_recommended_fields(frontmatter, file_path, issues)
|
|
@@ -134,24 +134,50 @@ module Ace
|
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
issues << {
|
|
141
|
-
type: :error,
|
|
142
|
-
message: "Invalid GitHub issue ID '#{linked}' in github_issue (expected positive integer)",
|
|
143
|
-
location: file_path
|
|
144
|
-
}
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
pending = frontmatter["github_sync_pending"]
|
|
148
|
-
return if pending.nil? || pending == true || pending == false
|
|
137
|
+
def validate_issue_fields(frontmatter, file_path, issues)
|
|
138
|
+
%w[github_issue github_sync_pending].each do |key|
|
|
139
|
+
next unless frontmatter.key?(key)
|
|
149
140
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
141
|
+
issues << {type: :error, message: "Obsolete #{key}; use remote_issue/issue_sync_pending",
|
|
142
|
+
location: file_path}
|
|
143
|
+
end
|
|
144
|
+
if frontmatter.key?("remote_issue")
|
|
145
|
+
linked = frontmatter["remote_issue"]
|
|
146
|
+
valid = linked.is_a?(Hash) && linked.keys.all? { |key| key.is_a?(String) } &&
|
|
147
|
+
linked.keys.sort ==
|
|
148
|
+
%w[number provider repository_url server_name url] &&
|
|
149
|
+
linked["number"].is_a?(Integer) && linked["number"].positive? &&
|
|
150
|
+
%w[server_name provider repository_url url].all? { |key| linked[key].is_a?(String) && !linked[key].empty? } &&
|
|
151
|
+
linked["url"] == "#{Ace::Git::Atoms::ServerUrl.web_base(linked["repository_url"])}/issues/#{linked["number"]}"
|
|
152
|
+
unless valid
|
|
153
|
+
issues << {type: :error, message: "Invalid complete remote_issue identity", location: file_path}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
pending = frontmatter["issue_sync_pending"]
|
|
157
|
+
unless pending.nil? || pending == true || pending == false
|
|
158
|
+
issues << {type: :error, message: "Invalid issue_sync_pending value (expected boolean)",
|
|
159
|
+
location: file_path}
|
|
160
|
+
end
|
|
161
|
+
if pending == true && !frontmatter.key?("remote_issue")
|
|
162
|
+
issues << {type: :error, message: "issue_sync_pending requires a complete remote_issue identity",
|
|
163
|
+
location: file_path}
|
|
164
|
+
end
|
|
165
|
+
operation = frontmatter["issue_sync_operation"]
|
|
166
|
+
valid_operation = operation.nil? ||
|
|
167
|
+
(%w[clear reconcile-create].include?(operation) && pending == true && frontmatter["remote_issue"].is_a?(Hash))
|
|
168
|
+
unless valid_operation
|
|
169
|
+
issues << {type: :error, message: "Invalid issue_sync_operation", location: file_path}
|
|
170
|
+
end
|
|
171
|
+
previous_id = frontmatter["issue_sync_previous_id"]
|
|
172
|
+
unless previous_id.nil? || (previous_id.is_a?(String) && !previous_id.empty?)
|
|
173
|
+
issues << {type: :error, message: "Invalid issue_sync_previous_id value (expected task ID string)",
|
|
174
|
+
location: file_path}
|
|
175
|
+
end
|
|
176
|
+
reconcile_flag = frontmatter["issue_sync_reconcile_create"]
|
|
177
|
+
unless reconcile_flag.nil? || reconcile_flag == true
|
|
178
|
+
issues << {type: :error, message: "Invalid issue_sync_reconcile_create value (expected boolean)",
|
|
179
|
+
location: file_path}
|
|
180
|
+
end
|
|
155
181
|
end
|
|
156
182
|
end
|
|
157
183
|
end
|
|
@@ -76,9 +76,9 @@ module Ace
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def run_ace_bundle(input, output_path)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
79
|
+
checkout_binstub = File.expand_path("../../../../../bin/ace-bundle", __dir__)
|
|
80
|
+
executable = File.file?(checkout_binstub) ? checkout_binstub : Gem.bin_path("ace-bundle", "ace-bundle")
|
|
81
|
+
_stdout, status = Open3.capture2(executable, input, "--format", "markdown-xml", "--output", output_path)
|
|
82
82
|
unless status.success?
|
|
83
83
|
raise Ace::Support::Cli::Error.new("ace-bundle failed for: #{input}")
|
|
84
84
|
end
|
|
@@ -75,6 +75,11 @@ module Ace
|
|
|
75
75
|
fm.delete("parent")
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Promoted descendants rebase from the standalone ID.
|
|
79
|
+
id_map = {task.id => new_id}
|
|
80
|
+
rewrite_descendants(new_dir, old_parent_id: task.id, new_parent_id: new_id, id_map: id_map)
|
|
81
|
+
rewrite_dependency_references(id_map)
|
|
82
|
+
|
|
78
83
|
loader = TaskLoader.new
|
|
79
84
|
loader.load(new_dir, id: new_id)
|
|
80
85
|
end
|
|
@@ -164,10 +169,71 @@ module Ace
|
|
|
164
169
|
fm["parent"] = parent_task.id
|
|
165
170
|
end
|
|
166
171
|
|
|
172
|
+
# Descendant identities hang off the parent ID: rewrite their
|
|
173
|
+
# frontmatter and spec filenames so persisted IDs stay authoritative.
|
|
174
|
+
id_map = {task.id => new_id}
|
|
175
|
+
rewrite_descendants(new_dir, old_parent_id: task.id, new_parent_id: new_id, id_map: id_map)
|
|
176
|
+
rewrite_dependency_references(id_map)
|
|
177
|
+
|
|
167
178
|
loader = TaskLoader.new
|
|
168
179
|
loader.load(new_dir, id: new_id)
|
|
169
180
|
end
|
|
170
181
|
|
|
182
|
+
# Rewrite dependency references in every task spec after an ID map
|
|
183
|
+
# changes (stale references would read as unmet dependencies).
|
|
184
|
+
def rewrite_dependency_references(id_map)
|
|
185
|
+
return if id_map.empty?
|
|
186
|
+
|
|
187
|
+
Dir.glob(File.join(@root_dir, "**", "*.s.md")).sort.each do |spec|
|
|
188
|
+
content = File.read(spec)
|
|
189
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
190
|
+
deps = frontmatter["dependencies"]
|
|
191
|
+
next unless deps.is_a?(Array)
|
|
192
|
+
|
|
193
|
+
mapped = deps.map { |dep| id_map.fetch(dep, dep) }
|
|
194
|
+
next if mapped == deps
|
|
195
|
+
|
|
196
|
+
frontmatter["dependencies"] = mapped
|
|
197
|
+
tmp_path = "#{spec}.tmp.#{Process.pid}"
|
|
198
|
+
File.write(tmp_path, Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body))
|
|
199
|
+
File.rename(tmp_path, spec)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Recursively rewrite descendant frontmatter IDs, parent references and
|
|
204
|
+
# spec filenames after their ancestor's ID changed.
|
|
205
|
+
def rewrite_descendants(dir, old_parent_id:, new_parent_id:, id_map: {})
|
|
206
|
+
return unless Dir.exist?(dir)
|
|
207
|
+
|
|
208
|
+
Dir.entries(dir).sort.each do |entry|
|
|
209
|
+
next if entry.start_with?(".")
|
|
210
|
+
next unless (short_match = entry.match(/\A([a-z0-9])-(.+)/))
|
|
211
|
+
|
|
212
|
+
char = short_match[1]
|
|
213
|
+
slug = short_match[2]
|
|
214
|
+
child_dir = File.join(dir, entry)
|
|
215
|
+
next unless File.directory?(child_dir)
|
|
216
|
+
|
|
217
|
+
old_child_id = "#{old_parent_id}.#{char}"
|
|
218
|
+
new_child_id = "#{new_parent_id}.#{char}"
|
|
219
|
+
id_map[old_child_id] = new_child_id
|
|
220
|
+
|
|
221
|
+
spec = Dir.glob(File.join(child_dir, "*.s.md")).sort.first
|
|
222
|
+
if spec
|
|
223
|
+
new_spec = File.join(child_dir, "#{new_child_id}-#{slug}.s.md")
|
|
224
|
+
File.rename(spec, new_spec) if spec != new_spec && !File.exist?(new_spec)
|
|
225
|
+
if File.exist?(new_spec)
|
|
226
|
+
update_frontmatter(new_spec) do |fm|
|
|
227
|
+
fm["id"] = new_child_id
|
|
228
|
+
fm["parent"] = new_parent_id
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
rewrite_descendants(child_dir, old_parent_id: old_child_id, new_parent_id: new_child_id, id_map: id_map)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
171
237
|
# Extract the base task ID (without subtask char) from a potentially dotted ID
|
|
172
238
|
# "8pp.t.q7w.a" => "8pp.t.q7w"
|
|
173
239
|
def base_task_id(id)
|
|
@@ -19,8 +19,9 @@ module Ace
|
|
|
19
19
|
# Short reference pattern: "t.q7w" → extract suffix "q7w"
|
|
20
20
|
SHORT_REF_PATTERN = /^[a-z]\.([0-9a-z]{3})$/
|
|
21
21
|
|
|
22
|
-
# Subtask reference pattern: "8pp.t.q7w.a"
|
|
23
|
-
|
|
22
|
+
# Subtask reference pattern: "8pp.t.q7w.a" or nested "8pp.t.q7w.0.a"
|
|
23
|
+
# (parent ID + one or more dot-separated subtask chars)
|
|
24
|
+
SUBTASK_REF_PATTERN = /^([0-9a-z]{3}\.[a-z]\.[0-9a-z]{3}(?:\.[a-z0-9])*)\.([a-z0-9])$/
|
|
24
25
|
|
|
25
26
|
# Short subtask reference: "q7w.a" or "t.q7w.a" (suffix + subtask char)
|
|
26
27
|
SHORT_SUBTASK_REF_PATTERN = /^(?:[a-z]\.)?([0-9a-z]{3})\.([a-z0-9])$/
|
|
@@ -76,7 +77,17 @@ module Ace
|
|
|
76
77
|
# Resolve a subtask reference by finding the parent's scan result,
|
|
77
78
|
# then looking for the subtask folder within that parent's directory.
|
|
78
79
|
def resolve_subtask(parent_id, subtask_char)
|
|
79
|
-
parent_result =
|
|
80
|
+
parent_result = if parent_id.count(".") >= 3
|
|
81
|
+
# Nested parent: resolve level by level from the base task.
|
|
82
|
+
base_id = parent_id.split(".")[0, 3].join(".")
|
|
83
|
+
nested_chars = parent_id.split(".")[3..]
|
|
84
|
+
current = resolve_subtask_chain(base_id, nested_chars)
|
|
85
|
+
return nil unless current
|
|
86
|
+
|
|
87
|
+
current
|
|
88
|
+
else
|
|
89
|
+
@scan_results.find { |sr| sr.id == parent_id }
|
|
90
|
+
end
|
|
80
91
|
return nil unless parent_result
|
|
81
92
|
|
|
82
93
|
subtask_id = "#{parent_id}.#{subtask_char}"
|
|
@@ -109,6 +120,23 @@ module Ace
|
|
|
109
120
|
|
|
110
121
|
nil
|
|
111
122
|
end
|
|
123
|
+
|
|
124
|
+
# Walk nested subtask chars from the base task, returning the final
|
|
125
|
+
# scan result (ids are rebuilt per level exactly like the folders).
|
|
126
|
+
def resolve_subtask_chain(base_id, nested_chars)
|
|
127
|
+
current = @scan_results.find { |sr| sr.id == base_id }
|
|
128
|
+
return nil unless current
|
|
129
|
+
|
|
130
|
+
current_id = base_id
|
|
131
|
+
nested_chars.each do |char|
|
|
132
|
+
next_level = resolve_subtask(current_id, char)
|
|
133
|
+
return nil unless next_level
|
|
134
|
+
|
|
135
|
+
current = next_level
|
|
136
|
+
current_id = next_level.id
|
|
137
|
+
end
|
|
138
|
+
current
|
|
139
|
+
end
|
|
112
140
|
end
|
|
113
141
|
end
|
|
114
142
|
end
|