ace-assign 0.37.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 +7 -0
- data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
- data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
- data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
- data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
- data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
- data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
- data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
- data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
- data/.ace-defaults/assign/config.yml +48 -0
- data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
- data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
- data/.ace-defaults/assign/presets/release-only.yml +35 -0
- data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
- data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
- data/CHANGELOG.md +1415 -0
- data/README.md +87 -0
- data/Rakefile +16 -0
- data/docs/exit-codes.md +61 -0
- data/docs/getting-started.md +121 -0
- data/docs/handbook.md +40 -0
- data/docs/usage.md +224 -0
- data/exe/ace-assign +16 -0
- data/handbook/guides/fork-context.g.md +231 -0
- data/handbook/skills/as-assign-compose/SKILL.md +24 -0
- data/handbook/skills/as-assign-create/SKILL.md +23 -0
- data/handbook/skills/as-assign-drive/SKILL.md +24 -0
- data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
- data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
- data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
- data/handbook/skills/as-assign-start/SKILL.md +25 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
- data/handbook/workflow-instructions/assign/create.wf.md +215 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
- data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
- data/handbook/workflow-instructions/assign/start.wf.md +46 -0
- data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
- data/lib/ace/assign/atoms/composition_rules.rb +219 -0
- data/lib/ace/assign/atoms/number_generator.rb +110 -0
- data/lib/ace/assign/atoms/preset_expander.rb +277 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
- data/lib/ace/assign/atoms/step_numbering.rb +227 -0
- data/lib/ace/assign/atoms/step_sorter.rb +66 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
- data/lib/ace/assign/cli/commands/add.rb +102 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
- data/lib/ace/assign/cli/commands/create.rb +63 -0
- data/lib/ace/assign/cli/commands/fail.rb +43 -0
- data/lib/ace/assign/cli/commands/finish.rb +88 -0
- data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
- data/lib/ace/assign/cli/commands/list.rb +166 -0
- data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
- data/lib/ace/assign/cli/commands/select.rb +45 -0
- data/lib/ace/assign/cli/commands/start.rb +40 -0
- data/lib/ace/assign/cli/commands/status.rb +407 -0
- data/lib/ace/assign/cli.rb +144 -0
- data/lib/ace/assign/models/assignment.rb +107 -0
- data/lib/ace/assign/models/assignment_info.rb +66 -0
- data/lib/ace/assign/models/queue_state.rb +326 -0
- data/lib/ace/assign/models/step.rb +197 -0
- data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
- data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
- data/lib/ace/assign/molecules/step_writer.rb +246 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
- data/lib/ace/assign/version.rb +7 -0
- data/lib/ace/assign.rb +141 -0
- metadata +289 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
# Resolves assignment metadata from skill frontmatter.
|
|
11
|
+
#
|
|
12
|
+
# Flow:
|
|
13
|
+
# 1) Find SKILL.md by skill name (e.g., "ace-task-work")
|
|
14
|
+
# 2) Read assign.source URI from skill frontmatter (e.g., wfi://task/work)
|
|
15
|
+
# 3) Resolve workflow file from URI
|
|
16
|
+
# 4) Parse workflow assign frontmatter (sub-steps/context)
|
|
17
|
+
class SkillAssignSourceResolver
|
|
18
|
+
ASSIGN_CAPABLE_KINDS = %w[workflow orchestration].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(project_root: nil, skill_paths: nil, workflow_paths: nil)
|
|
21
|
+
@project_root = project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
22
|
+
configured_skill_paths = skill_paths || Ace::Assign.config["skill_source_paths"]
|
|
23
|
+
configured_workflow_paths = workflow_paths || Ace::Assign.config["workflow_source_paths"]
|
|
24
|
+
|
|
25
|
+
canonical_paths = discover_canonical_skill_source_paths
|
|
26
|
+
canonical_workflow_paths = discover_canonical_workflow_source_paths
|
|
27
|
+
override_paths = normalize_paths(configured_skill_paths || [])
|
|
28
|
+
if canonical_workflow_paths.empty? && (configured_workflow_paths.nil? || configured_workflow_paths.empty?)
|
|
29
|
+
configured_workflow_paths = discover_workspace_workflow_paths
|
|
30
|
+
end
|
|
31
|
+
configured_workflow_paths = canonical_workflow_paths if configured_workflow_paths.nil? || configured_workflow_paths.empty?
|
|
32
|
+
@skill_paths = (canonical_paths + override_paths).uniq
|
|
33
|
+
@workflow_paths = (canonical_workflow_paths + normalize_paths(configured_workflow_paths || [])).uniq
|
|
34
|
+
@skill_index = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve assign config for a skill.
|
|
38
|
+
#
|
|
39
|
+
# @param skill_name [String] Skill identifier (e.g., "ace-task-work")
|
|
40
|
+
# @return [Hash, nil] Parsed assign config (keys: :sub_steps, :context, etc.) or nil if not declared
|
|
41
|
+
# @raise [Ace::Assign::Error] If skill declares an invalid/unresolvable source
|
|
42
|
+
def resolve_assign_config(skill_name)
|
|
43
|
+
skill_path = skill_index[skill_name] || find_skill_by_convention(skill_name)
|
|
44
|
+
return nil unless skill_path
|
|
45
|
+
|
|
46
|
+
skill_frontmatter = parse_frontmatter(File.read(skill_path))
|
|
47
|
+
if assign_capable_skill_frontmatter?(skill_frontmatter) && skill_frontmatter["assign"]
|
|
48
|
+
validate_assign_source!(skill_frontmatter["assign"], skill_name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
assign_block = skill_frontmatter["assign"]
|
|
52
|
+
return nil unless assign_block.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
source = assign_block["source"]&.to_s&.strip
|
|
55
|
+
return nil if source.nil? || source.empty?
|
|
56
|
+
|
|
57
|
+
workflow_path = resolve_source_uri(source, skill_name)
|
|
58
|
+
workflow_frontmatter = parse_frontmatter(File.read(workflow_path))
|
|
59
|
+
parsed = Atoms::AssignFrontmatterParser.parse(workflow_frontmatter)
|
|
60
|
+
|
|
61
|
+
unless parsed[:valid]
|
|
62
|
+
raise Error, "Invalid assign frontmatter in '#{workflow_path}' for skill '#{skill_name}': #{parsed[:errors].join("; ")}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
parsed[:config]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# List assign-capable canonical skills discovered from skill sources.
|
|
69
|
+
#
|
|
70
|
+
# Assign-capable skills are canonical skills with:
|
|
71
|
+
# - skill.kind: workflow|orchestration
|
|
72
|
+
# - assign.source present and non-empty
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<String>] Skill names
|
|
75
|
+
# @raise [Ace::Assign::Error] When assign-capable skill has invalid assign metadata
|
|
76
|
+
def assign_capable_skill_names
|
|
77
|
+
skill_index.keys.sort.filter do |skill_name|
|
|
78
|
+
skill_path = skill_index[skill_name]
|
|
79
|
+
frontmatter = parse_frontmatter(File.read(skill_path))
|
|
80
|
+
next false unless assign_capable_skill_frontmatter?(frontmatter)
|
|
81
|
+
next false unless frontmatter["assign"].is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
validate_assign_source!(frontmatter["assign"], skill_name)
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build assignment step entries from canonical skills.
|
|
89
|
+
#
|
|
90
|
+
# Only steps declared under `assign.steps` are emitted here. This keeps
|
|
91
|
+
# canonical skills authoritative for public skill-backed assignment steps,
|
|
92
|
+
# while internal helper steps can continue to use catalog templates.
|
|
93
|
+
#
|
|
94
|
+
# @return [Array<Hash>] Step definitions keyed by canonical precedence
|
|
95
|
+
def assign_step_catalog
|
|
96
|
+
catalog = {}
|
|
97
|
+
|
|
98
|
+
each_assign_capable_skill do |skill_name, frontmatter|
|
|
99
|
+
steps = frontmatter.dig("assign", "steps")
|
|
100
|
+
workflow_source = frontmatter.dig("assign", "source")&.to_s&.strip
|
|
101
|
+
workflow_source = frontmatter.dig("skill", "execution", "workflow")&.to_s&.strip if workflow_source.nil? || workflow_source.empty?
|
|
102
|
+
next unless steps.is_a?(Array)
|
|
103
|
+
|
|
104
|
+
steps.each do |step|
|
|
105
|
+
next unless step.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
step_name = step["name"]&.to_s&.strip
|
|
108
|
+
next if step_name.nil? || step_name.empty?
|
|
109
|
+
next if catalog.key?(step_name)
|
|
110
|
+
|
|
111
|
+
entry = step.dup
|
|
112
|
+
entry["name"] = step_name
|
|
113
|
+
entry["skill"] = skill_name
|
|
114
|
+
entry["source_skill"] = skill_name
|
|
115
|
+
entry["workflow"] = workflow_source if workflow_source && !workflow_source.empty?
|
|
116
|
+
entry["description"] ||= frontmatter["description"]
|
|
117
|
+
catalog[step_name] = entry
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
catalog.values
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Resolve canonical skill rendering details for a skill-backed step.
|
|
125
|
+
#
|
|
126
|
+
# @param skill_name [String]
|
|
127
|
+
# @return [Hash, nil]
|
|
128
|
+
def resolve_skill_rendering(skill_name)
|
|
129
|
+
skill_path = skill_index[skill_name] || find_skill_by_convention(skill_name)
|
|
130
|
+
return nil unless skill_path
|
|
131
|
+
|
|
132
|
+
frontmatter, skill_body = parse_frontmatter_and_body(File.read(skill_path))
|
|
133
|
+
workflow_source = frontmatter.dig("assign", "source")&.to_s&.strip
|
|
134
|
+
if workflow_source.nil? || workflow_source.empty?
|
|
135
|
+
workflow_source = frontmatter.dig("skill", "execution", "workflow")&.to_s&.strip
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
workflow_path = resolve_workflow_path(workflow_source, skill_name)
|
|
139
|
+
workflow_body = workflow_path ? parse_frontmatter_and_body(File.read(workflow_path)).last.to_s.strip : ""
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
"name" => frontmatter["name"] || skill_name,
|
|
143
|
+
"description" => frontmatter["description"],
|
|
144
|
+
"skill" => skill_name,
|
|
145
|
+
"workflow" => workflow_source,
|
|
146
|
+
"workflow_path" => workflow_path,
|
|
147
|
+
"body" => workflow_body.empty? ? skill_body.to_s.strip : workflow_body
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def resolve_workflow_rendering(workflow_source, step_name: nil, source_skill: nil)
|
|
152
|
+
workflow_ref = workflow_source&.to_s&.strip
|
|
153
|
+
return nil if workflow_ref.nil? || workflow_ref.empty?
|
|
154
|
+
|
|
155
|
+
workflow_path = resolve_workflow_path(workflow_ref, step_name || source_skill || workflow_ref)
|
|
156
|
+
return nil unless workflow_path
|
|
157
|
+
|
|
158
|
+
frontmatter, body = parse_frontmatter_and_body(File.read(workflow_path))
|
|
159
|
+
{
|
|
160
|
+
"name" => step_name || frontmatter["name"],
|
|
161
|
+
"description" => frontmatter["description"],
|
|
162
|
+
"workflow" => workflow_ref,
|
|
163
|
+
"workflow_path" => workflow_path,
|
|
164
|
+
"source_skill" => source_skill,
|
|
165
|
+
"body" => body.to_s.strip
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def resolve_workflow_assign_config(workflow_source, step_name: nil, source_skill: nil)
|
|
170
|
+
rendering = resolve_workflow_rendering(workflow_source, step_name: step_name, source_skill: source_skill)
|
|
171
|
+
return nil unless rendering && rendering["workflow_path"]
|
|
172
|
+
|
|
173
|
+
workflow_frontmatter = parse_frontmatter(File.read(rendering["workflow_path"]))
|
|
174
|
+
parsed = Atoms::AssignFrontmatterParser.parse(workflow_frontmatter)
|
|
175
|
+
return nil unless parsed[:valid]
|
|
176
|
+
|
|
177
|
+
parsed[:config]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Resolve canonical rendering details for a public step name.
|
|
181
|
+
#
|
|
182
|
+
# @param step_name [String]
|
|
183
|
+
# @return [Hash, nil]
|
|
184
|
+
def resolve_step_rendering(step_name)
|
|
185
|
+
entry = assign_step_catalog.find { |step| step["name"] == step_name }
|
|
186
|
+
return nil unless entry
|
|
187
|
+
|
|
188
|
+
workflow_rendering = resolve_workflow_rendering(
|
|
189
|
+
entry["workflow"],
|
|
190
|
+
step_name: entry["name"],
|
|
191
|
+
source_skill: entry["source_skill"] || entry["skill"]
|
|
192
|
+
)
|
|
193
|
+
return entry.merge(workflow_rendering) if workflow_rendering
|
|
194
|
+
|
|
195
|
+
rendering = resolve_skill_rendering(entry["skill"])
|
|
196
|
+
return nil unless rendering
|
|
197
|
+
|
|
198
|
+
entry.merge(rendering)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
attr_reader :project_root, :skill_paths, :workflow_paths
|
|
204
|
+
|
|
205
|
+
def skill_index
|
|
206
|
+
@skill_index ||= begin
|
|
207
|
+
index = {}
|
|
208
|
+
skill_paths.each do |base_path|
|
|
209
|
+
Dir.glob(File.join(base_path, "**", "SKILL.md")).sort.each do |path|
|
|
210
|
+
frontmatter = parse_frontmatter(File.read(path))
|
|
211
|
+
name = frontmatter["name"]&.to_s
|
|
212
|
+
index[name] ||= path if name && !name.empty?
|
|
213
|
+
rescue
|
|
214
|
+
next
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
index
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def normalize_paths(paths)
|
|
222
|
+
paths.map do |path|
|
|
223
|
+
path_str = path.to_s
|
|
224
|
+
if Pathname.new(path_str).absolute?
|
|
225
|
+
path_str
|
|
226
|
+
else
|
|
227
|
+
File.expand_path(path_str, project_root)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def find_skill_by_convention(skill_name)
|
|
233
|
+
dir_name = skill_name.to_s.tr(":", "_")
|
|
234
|
+
skill_paths.each do |base_path|
|
|
235
|
+
candidate = File.join(base_path, dir_name, "SKILL.md")
|
|
236
|
+
return candidate if File.exist?(candidate)
|
|
237
|
+
end
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def parse_frontmatter(content)
|
|
242
|
+
parse_frontmatter_and_body(content).first
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def parse_frontmatter_and_body(content)
|
|
246
|
+
lines = content.lines
|
|
247
|
+
return [{}, content.to_s] unless lines.first&.strip == "---"
|
|
248
|
+
|
|
249
|
+
closing_index = lines[1..]&.index { |line| line.strip == "---" }
|
|
250
|
+
return [{}, content.to_s] unless closing_index
|
|
251
|
+
|
|
252
|
+
frontmatter_yaml = lines[1, closing_index].join
|
|
253
|
+
frontmatter = YAML.safe_load(frontmatter_yaml, permitted_classes: [Date, Time]) || {}
|
|
254
|
+
body_lines = lines[(closing_index + 2)..] || []
|
|
255
|
+
[frontmatter, body_lines.join]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def assign_capable_skill_frontmatter?(frontmatter)
|
|
259
|
+
kind = frontmatter.dig("skill", "kind")&.to_s
|
|
260
|
+
ASSIGN_CAPABLE_KINDS.include?(kind)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def validate_assign_source!(assign_block, skill_name)
|
|
264
|
+
source = assign_block["source"]&.to_s&.strip
|
|
265
|
+
if source.nil? || source.empty?
|
|
266
|
+
raise Error, "Missing assign.source for assign-capable skill '#{skill_name}'"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def resolve_workflow_path(workflow_source, reference_name)
|
|
271
|
+
return nil if workflow_source.nil? || workflow_source.empty?
|
|
272
|
+
return nil unless workflow_source.start_with?("wfi://")
|
|
273
|
+
|
|
274
|
+
resolve_source_uri(workflow_source, reference_name)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def each_assign_capable_skill
|
|
278
|
+
skill_index.each do |skill_name, skill_path|
|
|
279
|
+
frontmatter = parse_frontmatter(File.read(skill_path))
|
|
280
|
+
next unless assign_capable_skill_frontmatter?(frontmatter)
|
|
281
|
+
next unless frontmatter["assign"].is_a?(Hash)
|
|
282
|
+
|
|
283
|
+
validate_assign_source!(frontmatter["assign"], skill_name)
|
|
284
|
+
yield skill_name, frontmatter
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def discover_canonical_skill_source_paths
|
|
289
|
+
discover_protocol_source_paths(
|
|
290
|
+
protocol: "skill",
|
|
291
|
+
package_glob: File.join(project_root, "*", ".ace-defaults", "nav", "protocols", "skill-sources", "*.yml")
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def discover_canonical_workflow_source_paths
|
|
296
|
+
discover_protocol_source_paths(
|
|
297
|
+
protocol: "wfi",
|
|
298
|
+
package_glob: File.join(project_root, "*", ".ace-defaults", "nav", "protocols", "wfi-sources", "*.yml")
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def discover_protocol_source_paths(protocol:, package_glob:)
|
|
303
|
+
registry_paths = Dir.chdir(project_root) do
|
|
304
|
+
registry = Ace::Support::Nav::Molecules::SourceRegistry.new
|
|
305
|
+
registry.sources_for_protocol(protocol).filter_map do |source|
|
|
306
|
+
next if source.config.is_a?(Hash) && source.config["enabled"] == false
|
|
307
|
+
|
|
308
|
+
candidate = resolve_source_directory(source)
|
|
309
|
+
File.directory?(candidate) ? candidate : nil
|
|
310
|
+
rescue
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
(registry_paths + discover_package_default_source_paths(package_glob)).uniq
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def resolve_source_directory(source)
|
|
319
|
+
candidate = source.full_path
|
|
320
|
+
return candidate if File.directory?(candidate)
|
|
321
|
+
|
|
322
|
+
relative_path = source.config&.dig("relative_path")&.to_s&.strip
|
|
323
|
+
return nil if relative_path.nil? || relative_path.empty?
|
|
324
|
+
|
|
325
|
+
return nil unless source.config_file&.include?("/.ace-defaults/")
|
|
326
|
+
|
|
327
|
+
package_root = source.config_file.split("/.ace-defaults/").first
|
|
328
|
+
fallback = File.expand_path(relative_path, package_root)
|
|
329
|
+
File.directory?(fallback) ? fallback : nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def discover_package_default_source_paths(source_glob)
|
|
333
|
+
source_files = Dir.glob(source_glob).sort
|
|
334
|
+
source_files.filter_map do |source_file|
|
|
335
|
+
source_data = YAML.safe_load_file(source_file, permitted_classes: [Date, Time]) || {}
|
|
336
|
+
relative_path = source_data.dig("config", "relative_path")&.to_s&.strip
|
|
337
|
+
next if relative_path.nil? || relative_path.empty?
|
|
338
|
+
|
|
339
|
+
package_root = File.expand_path("../../../../..", source_file)
|
|
340
|
+
candidate = File.expand_path(relative_path, package_root)
|
|
341
|
+
File.directory?(candidate) ? candidate : nil
|
|
342
|
+
rescue
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def discover_workspace_workflow_paths
|
|
348
|
+
Dir.glob(File.join(project_root, "*", "handbook", "workflow-instructions")).sort
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def resolve_source_uri(uri, skill_name)
|
|
352
|
+
if uri.start_with?("wfi://")
|
|
353
|
+
resolve_wfi_uri(uri, skill_name)
|
|
354
|
+
else
|
|
355
|
+
raise Error, "Unsupported assign.source '#{uri}' for skill '#{skill_name}'. Supported: wfi://..."
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def resolve_wfi_uri(uri, skill_name)
|
|
360
|
+
workflow_name = uri.delete_prefix("wfi://").strip
|
|
361
|
+
if workflow_name.empty?
|
|
362
|
+
raise Error, "Empty workflow name in assign.source '#{uri}' for skill '#{skill_name}'"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
workflow_paths.each do |base_path|
|
|
366
|
+
candidate = File.join(base_path, "#{workflow_name}.wf.md")
|
|
367
|
+
return candidate if File.exist?(candidate)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
searched = workflow_paths.join(", ")
|
|
371
|
+
raise Error, "Could not resolve assign.source '#{uri}' for skill '#{skill_name}'. Searched: #{searched}"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
# Handles step file renumbering with cascade support and atomic operations.
|
|
9
|
+
#
|
|
10
|
+
# Extracted from AssignmentExecutor to provide:
|
|
11
|
+
# - Testable renumbering logic
|
|
12
|
+
# - Transactional rename with rollback on failure
|
|
13
|
+
# - Parent metadata updates for descendants
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# renumberer = StepRenumberer.new(step_writer: step_writer, queue_scanner: scanner)
|
|
17
|
+
# renumberer.renumber(steps_dir, ["010", "011"])
|
|
18
|
+
class StepRenumberer
|
|
19
|
+
attr_reader :step_writer, :queue_scanner
|
|
20
|
+
|
|
21
|
+
def initialize(step_writer:, queue_scanner:)
|
|
22
|
+
@step_writer = step_writer
|
|
23
|
+
@queue_scanner = queue_scanner
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Renumber steps by shifting their file numbers.
|
|
27
|
+
# Also cascades to all descendants to prevent orphaning children.
|
|
28
|
+
#
|
|
29
|
+
# @param steps_dir [String] Path to steps directory
|
|
30
|
+
# @param numbers_to_shift [Array<String>] Step numbers to shift
|
|
31
|
+
# @return [Hash] Result with :renamed (count) and :rollback_needed (bool)
|
|
32
|
+
def renumber(steps_dir, numbers_to_shift)
|
|
33
|
+
return {renamed: 0, rollback_needed: false} if numbers_to_shift.empty?
|
|
34
|
+
|
|
35
|
+
all_numbers = queue_scanner.step_numbers(steps_dir)
|
|
36
|
+
sorted_steps = build_shift_list(numbers_to_shift, all_numbers)
|
|
37
|
+
|
|
38
|
+
# Track operations for potential rollback
|
|
39
|
+
completed_renames = []
|
|
40
|
+
rollback_needed = false
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
sorted_steps.each do |old_number|
|
|
44
|
+
new_number = calculate_new_number(old_number, numbers_to_shift)
|
|
45
|
+
next if new_number == old_number
|
|
46
|
+
|
|
47
|
+
# Calculate new parent for frontmatter update
|
|
48
|
+
new_parent = calculate_new_parent(old_number, numbers_to_shift)
|
|
49
|
+
|
|
50
|
+
rename_result = rename_step_files(
|
|
51
|
+
steps_dir,
|
|
52
|
+
old_number,
|
|
53
|
+
new_number,
|
|
54
|
+
new_parent: new_parent
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
completed_renames << {old: old_number, new: new_number, files: rename_result[:files]}
|
|
58
|
+
end
|
|
59
|
+
rescue => e
|
|
60
|
+
rollback_needed = true
|
|
61
|
+
rollback_renames(completed_renames)
|
|
62
|
+
raise e
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{renamed: completed_renames.size, rollback_needed: rollback_needed}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Build complete list of steps to shift including descendants.
|
|
71
|
+
#
|
|
72
|
+
# @param numbers_to_shift [Array<String>] Explicit steps to shift
|
|
73
|
+
# @param all_numbers [Array<String>] All existing step numbers
|
|
74
|
+
# @return [Array<String>] Sorted list (deepest children first)
|
|
75
|
+
def build_shift_list(numbers_to_shift, all_numbers)
|
|
76
|
+
all_to_shift = []
|
|
77
|
+
numbers_to_shift.each do |num|
|
|
78
|
+
all_to_shift << num
|
|
79
|
+
# Find all descendants (steps starting with "num.")
|
|
80
|
+
all_numbers.each do |n|
|
|
81
|
+
all_to_shift << n if Atoms::StepNumbering.child_of?(n, num)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
all_to_shift.uniq!
|
|
85
|
+
|
|
86
|
+
# Sort in reverse by full number (deeper children first, then parents)
|
|
87
|
+
# This prevents filename collisions and ensures proper cascade
|
|
88
|
+
all_to_shift.sort.reverse
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Calculate the new number for a step being shifted.
|
|
92
|
+
#
|
|
93
|
+
# @param old_number [String] Current step number
|
|
94
|
+
# @param numbers_to_shift [Array<String>] Steps explicitly being shifted
|
|
95
|
+
# @return [String] New step number
|
|
96
|
+
def calculate_new_number(old_number, numbers_to_shift)
|
|
97
|
+
if numbers_to_shift.include?(old_number)
|
|
98
|
+
Atoms::StepNumbering.shift_number(old_number, 1)
|
|
99
|
+
else
|
|
100
|
+
# This is a descendant - cascade parent shift
|
|
101
|
+
parent_old = Atoms::StepNumbering.parse(old_number)[:parent]
|
|
102
|
+
parent_new = if parent_old && numbers_to_shift.include?(parent_old)
|
|
103
|
+
Atoms::StepNumbering.shift_number(parent_old, 1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Replace old parent prefix with new parent prefix
|
|
107
|
+
if parent_new
|
|
108
|
+
old_number.sub(/^#{Regexp.escape(parent_old)}/, parent_new)
|
|
109
|
+
else
|
|
110
|
+
# Find shifted ancestor
|
|
111
|
+
ancestor = numbers_to_shift.find { |n| Atoms::StepNumbering.child_of?(old_number, n) }
|
|
112
|
+
if ancestor
|
|
113
|
+
new_ancestor = Atoms::StepNumbering.shift_number(ancestor, 1)
|
|
114
|
+
old_number.sub(/^#{Regexp.escape(ancestor)}/, new_ancestor)
|
|
115
|
+
else
|
|
116
|
+
old_number
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Calculate new parent number for frontmatter update.
|
|
123
|
+
#
|
|
124
|
+
# @param old_number [String] Step being renamed
|
|
125
|
+
# @param numbers_to_shift [Array<String>] Steps explicitly being shifted
|
|
126
|
+
# @return [String, nil] New parent number or nil if no parent update needed
|
|
127
|
+
def calculate_new_parent(old_number, numbers_to_shift)
|
|
128
|
+
old_parent = Atoms::StepNumbering.parse(old_number)[:parent]
|
|
129
|
+
return nil unless old_parent
|
|
130
|
+
|
|
131
|
+
ancestor = numbers_to_shift.find { |n| old_parent == n || Atoms::StepNumbering.child_of?(old_parent, n) }
|
|
132
|
+
if ancestor
|
|
133
|
+
new_ancestor = Atoms::StepNumbering.shift_number(ancestor, 1)
|
|
134
|
+
old_parent.sub(/^#{Regexp.escape(ancestor)}/, new_ancestor)
|
|
135
|
+
else
|
|
136
|
+
old_parent
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Rename step and report files from one number to another.
|
|
141
|
+
#
|
|
142
|
+
# @param steps_dir [String] Path to steps directory
|
|
143
|
+
# @param old_number [String] Old step number
|
|
144
|
+
# @param new_number [String] New step number
|
|
145
|
+
# @param new_parent [String, nil] New parent number for frontmatter update
|
|
146
|
+
# @return [Hash] Result with :files (renamed file paths)
|
|
147
|
+
def rename_step_files(steps_dir, old_number, new_number, new_parent: nil)
|
|
148
|
+
renamed_files = []
|
|
149
|
+
|
|
150
|
+
# Find step file with this number
|
|
151
|
+
pattern = File.join(steps_dir, "#{old_number}-*.st.md")
|
|
152
|
+
step_files = Dir.glob(pattern)
|
|
153
|
+
|
|
154
|
+
step_files.each do |old_path|
|
|
155
|
+
filename = File.basename(old_path)
|
|
156
|
+
new_filename = filename.sub(/^#{Regexp.escape(old_number)}/, new_number)
|
|
157
|
+
new_path = File.join(steps_dir, new_filename)
|
|
158
|
+
|
|
159
|
+
FileUtils.mv(old_path, new_path)
|
|
160
|
+
renamed_files << {old_path: old_path, new_path: new_path, type: :step}
|
|
161
|
+
|
|
162
|
+
# Add audit trail metadata to track renumbering history
|
|
163
|
+
metadata = {
|
|
164
|
+
"renumbered_from" => old_number,
|
|
165
|
+
"renumbered_at" => Time.now.utc.iso8601
|
|
166
|
+
}
|
|
167
|
+
metadata["parent"] = new_parent if new_parent
|
|
168
|
+
step_writer.update_frontmatter(new_path, metadata)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Also rename any report files
|
|
172
|
+
cache_dir = File.dirname(steps_dir)
|
|
173
|
+
reports_dir = File.join(cache_dir, "reports")
|
|
174
|
+
if File.directory?(reports_dir)
|
|
175
|
+
report_pattern = File.join(reports_dir, "#{old_number}-*.r.md")
|
|
176
|
+
report_files = Dir.glob(report_pattern)
|
|
177
|
+
|
|
178
|
+
report_files.each do |old_path|
|
|
179
|
+
filename = File.basename(old_path)
|
|
180
|
+
new_filename = filename.sub(/^#{Regexp.escape(old_number)}/, new_number)
|
|
181
|
+
new_path = File.join(reports_dir, new_filename)
|
|
182
|
+
|
|
183
|
+
FileUtils.mv(old_path, new_path)
|
|
184
|
+
renamed_files << {old_path: old_path, new_path: new_path, type: :report}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
{files: renamed_files}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Rollback completed renames on failure.
|
|
192
|
+
#
|
|
193
|
+
# @param completed_renames [Array<Hash>] List of completed rename operations
|
|
194
|
+
# @return [Array<Hash>] List of rollback errors (empty if all succeeded)
|
|
195
|
+
def rollback_renames(completed_renames)
|
|
196
|
+
rollback_errors = []
|
|
197
|
+
|
|
198
|
+
# Reverse order to undo in opposite sequence
|
|
199
|
+
completed_renames.reverse_each do |rename|
|
|
200
|
+
rename[:files].each do |file_info|
|
|
201
|
+
next unless File.exist?(file_info[:new_path])
|
|
202
|
+
|
|
203
|
+
FileUtils.mv(file_info[:new_path], file_info[:old_path])
|
|
204
|
+
rescue => e
|
|
205
|
+
# Capture rollback errors but continue attempting remaining rollbacks
|
|
206
|
+
rollback_errors << {
|
|
207
|
+
file: file_info[:new_path],
|
|
208
|
+
target: file_info[:old_path],
|
|
209
|
+
error: e.message
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Warn about rollback failures if any occurred
|
|
215
|
+
if rollback_errors.any?
|
|
216
|
+
warn "[ace-assign] Warning: #{rollback_errors.size} file(s) failed to rollback during renumber recovery:"
|
|
217
|
+
rollback_errors.each do |err|
|
|
218
|
+
warn " - #{err[:file]} -> #{err[:target]}: #{err[:error]}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
rollback_errors
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|