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,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Atoms
|
|
8
|
+
# Pure functions for loading and applying composition rules.
|
|
9
|
+
#
|
|
10
|
+
# Composition rules define ordering constraints, step pairs,
|
|
11
|
+
# and conditional suggestions for building assignments.
|
|
12
|
+
#
|
|
13
|
+
# @example Validating ordering
|
|
14
|
+
# rules = CompositionRules.load("/path/to/catalog")
|
|
15
|
+
# violations = CompositionRules.validate_ordering(
|
|
16
|
+
# ["work-on-task", "onboard"],
|
|
17
|
+
# rules
|
|
18
|
+
# )
|
|
19
|
+
# # => [{ rule: "onboard-first", message: "onboard must be first" }]
|
|
20
|
+
module CompositionRules
|
|
21
|
+
# Load composition rules from catalog directory.
|
|
22
|
+
#
|
|
23
|
+
# @param catalog_dir [String] Path to catalog/ directory
|
|
24
|
+
# @return [Hash] Parsed rules with ordering, pairs, conditional, review_cycles
|
|
25
|
+
def self.load(catalog_dir)
|
|
26
|
+
rules_path = File.join(catalog_dir, "composition-rules.yml")
|
|
27
|
+
return default_rules unless File.exist?(rules_path)
|
|
28
|
+
|
|
29
|
+
YAML.safe_load_file(rules_path, permitted_classes: [Date]) || default_rules
|
|
30
|
+
rescue
|
|
31
|
+
default_rules
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Validate step ordering against rules.
|
|
35
|
+
#
|
|
36
|
+
# @param step_names [Array<String>] Ordered list of step names
|
|
37
|
+
# @param rules [Hash] Loaded composition rules
|
|
38
|
+
# @return [Array<Hash>] Violations, each with :rule and :message
|
|
39
|
+
def self.validate_ordering(step_names, rules)
|
|
40
|
+
violations = []
|
|
41
|
+
ordering_rules = rules["ordering"] || []
|
|
42
|
+
|
|
43
|
+
ordering_rules.each do |rule|
|
|
44
|
+
violation = check_ordering_rule(step_names, rule)
|
|
45
|
+
violations << violation if violation
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
violations
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Suggest additional steps based on the selected set and rules.
|
|
52
|
+
#
|
|
53
|
+
# @param step_names [Array<String>] Currently selected step names
|
|
54
|
+
# @param rules [Hash] Loaded composition rules
|
|
55
|
+
# @return [Array<Hash>] Suggestions, each with :step, :strength, :reason
|
|
56
|
+
def self.suggest_additions(step_names, rules)
|
|
57
|
+
suggestions = []
|
|
58
|
+
|
|
59
|
+
# Check pair completeness
|
|
60
|
+
(rules["pairs"] || []).each do |pair|
|
|
61
|
+
pair_suggestions = check_pair_completeness(step_names, pair)
|
|
62
|
+
suggestions.concat(pair_suggestions)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check conditional rules
|
|
66
|
+
(rules["conditional"] || []).each do |conditional|
|
|
67
|
+
conditional_suggestions = check_conditional_rule(step_names, conditional)
|
|
68
|
+
suggestions.concat(conditional_suggestions)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
suggestions
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Default empty rules structure.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash] Empty rules
|
|
77
|
+
def self.default_rules
|
|
78
|
+
{
|
|
79
|
+
"ordering" => [],
|
|
80
|
+
"pairs" => [],
|
|
81
|
+
"conditional" => [],
|
|
82
|
+
"review_cycles" => {
|
|
83
|
+
"default_count" => 2,
|
|
84
|
+
"max_count" => 5
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
private_class_method :default_rules
|
|
89
|
+
|
|
90
|
+
# Check a single ordering rule against step sequence.
|
|
91
|
+
#
|
|
92
|
+
# Uses prefix matching so a rule referencing "release" matches
|
|
93
|
+
# steps named "release-minor" or "release-patch-1".
|
|
94
|
+
#
|
|
95
|
+
# @param step_names [Array<String>] Ordered list of step names
|
|
96
|
+
# @param rule [Hash] Ordering rule definition
|
|
97
|
+
# @return [Hash, nil] Violation hash or nil if rule is satisfied
|
|
98
|
+
def self.check_ordering_rule(step_names, rule)
|
|
99
|
+
# Position rules (e.g., "onboard must be first")
|
|
100
|
+
if rule["position"] == "first" && rule["step"]
|
|
101
|
+
step = rule["step"]
|
|
102
|
+
idx = find_step_index(step_names, step)
|
|
103
|
+
if idx && idx != 0
|
|
104
|
+
return {rule: rule["rule"], message: "'#{step}' must be first"}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Before/after rules (e.g., "create-pr must come before review-pr")
|
|
109
|
+
if rule["before"] && rule["after"]
|
|
110
|
+
before_idx = find_step_index(step_names, rule["before"])
|
|
111
|
+
after_idx = find_step_index(step_names, rule["after"])
|
|
112
|
+
|
|
113
|
+
if before_idx && after_idx && before_idx >= after_idx
|
|
114
|
+
return {
|
|
115
|
+
rule: rule["rule"],
|
|
116
|
+
message: "'#{rule["before"]}' must come before '#{rule["after"]}'"
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
private_class_method :check_ordering_rule
|
|
124
|
+
|
|
125
|
+
# Find the index of a step by exact name or prefix match.
|
|
126
|
+
#
|
|
127
|
+
# Allows rules to reference base names (e.g., "release") that match
|
|
128
|
+
# suffixed variants (e.g., "release-minor", "release-patch-1").
|
|
129
|
+
#
|
|
130
|
+
# Note: Rule names should be specific enough to avoid unintended matches.
|
|
131
|
+
# A short prefix like "re" would match both "release-minor" and
|
|
132
|
+
# "reorganize-commits". Use full base names in composition rules.
|
|
133
|
+
#
|
|
134
|
+
# @param step_names [Array<String>] Step name list
|
|
135
|
+
# @param name [String] Name to find (exact or prefix)
|
|
136
|
+
# @return [Integer, nil] Index or nil if not found
|
|
137
|
+
def self.find_step_index(step_names, name)
|
|
138
|
+
idx = step_names.index(name)
|
|
139
|
+
return idx if idx
|
|
140
|
+
|
|
141
|
+
step_names.index { |p| p.start_with?("#{name}-") }
|
|
142
|
+
end
|
|
143
|
+
private_class_method :find_step_index
|
|
144
|
+
|
|
145
|
+
# Check a conditional rule against the selected steps.
|
|
146
|
+
#
|
|
147
|
+
# Parses "assignment includes X or Y" patterns from the when field
|
|
148
|
+
# and checks if any referenced steps are present.
|
|
149
|
+
#
|
|
150
|
+
# @param step_names [Array<String>] Currently selected step names
|
|
151
|
+
# @param conditional [Hash] Conditional rule definition
|
|
152
|
+
# @return [Array<Hash>] Suggestions for steps to add
|
|
153
|
+
def self.check_conditional_rule(step_names, conditional)
|
|
154
|
+
suggestions = []
|
|
155
|
+
when_clause = conditional["when"] || ""
|
|
156
|
+
suggest_steps = conditional["suggest"] || []
|
|
157
|
+
strength = conditional["strength"] || "recommended"
|
|
158
|
+
|
|
159
|
+
# Parse "assignment includes X or/and Y" pattern.
|
|
160
|
+
# Supports "or" (any trigger) and "and" (all triggers) separately.
|
|
161
|
+
# Mixed conjunctions (e.g., "A or B and C") are not supported —
|
|
162
|
+
# "and" takes precedence when present.
|
|
163
|
+
if (match = when_clause.match(/assignment includes (.+)/i))
|
|
164
|
+
raw = match[1]
|
|
165
|
+
if raw.include?(" and ")
|
|
166
|
+
trigger_steps = raw.split(/\s+and\s+/).map(&:strip)
|
|
167
|
+
triggered = trigger_steps.all? { |p| step_names.include?(p) }
|
|
168
|
+
else
|
|
169
|
+
trigger_steps = raw.split(/\s+or\s+/).map(&:strip)
|
|
170
|
+
triggered = trigger_steps.any? { |p| step_names.include?(p) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if triggered
|
|
174
|
+
suggest_steps.each do |step|
|
|
175
|
+
next if step_names.include?(step)
|
|
176
|
+
|
|
177
|
+
suggestions << {
|
|
178
|
+
step: step,
|
|
179
|
+
strength: strength,
|
|
180
|
+
reason: when_clause
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
suggestions
|
|
187
|
+
end
|
|
188
|
+
private_class_method :check_conditional_rule
|
|
189
|
+
|
|
190
|
+
# Check if paired steps are complete.
|
|
191
|
+
#
|
|
192
|
+
# @param step_names [Array<String>] Currently selected step names
|
|
193
|
+
# @param pair [Hash] Pair definition
|
|
194
|
+
# @return [Array<Hash>] Suggestions for missing pair members
|
|
195
|
+
def self.check_pair_completeness(step_names, pair)
|
|
196
|
+
suggestions = []
|
|
197
|
+
pair_steps = pair["steps"] || []
|
|
198
|
+
return suggestions if pair_steps.length < 2
|
|
199
|
+
return suggestions if pair["pattern"] == "conditional"
|
|
200
|
+
|
|
201
|
+
present = pair_steps.select { |p| step_names.include?(p) }
|
|
202
|
+
return suggestions if present.empty? || present.length == pair_steps.length
|
|
203
|
+
|
|
204
|
+
missing = pair_steps - present
|
|
205
|
+
missing.each do |step|
|
|
206
|
+
suggestions << {
|
|
207
|
+
step: step,
|
|
208
|
+
strength: "recommended",
|
|
209
|
+
reason: "Part of '#{pair["name"]}' pair (#{pair["note"] || "paired steps"})"
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
suggestions
|
|
214
|
+
end
|
|
215
|
+
private_class_method :check_pair_completeness
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure functions for generating step numbers.
|
|
7
|
+
#
|
|
8
|
+
# Follows the numbering convention:
|
|
9
|
+
# - Main steps: 010, 020, 030 (10-step gaps for injection room)
|
|
10
|
+
# - Sub-steps: 010.01, 010.02 (2-digit padding)
|
|
11
|
+
# - Sub-sub-steps: 010.01.01 (3 levels max)
|
|
12
|
+
# - Dynamic injection: 041 after 040
|
|
13
|
+
module NumberGenerator
|
|
14
|
+
# Default increment between main steps
|
|
15
|
+
DEFAULT_INCREMENT = 10
|
|
16
|
+
|
|
17
|
+
# Default starting number
|
|
18
|
+
DEFAULT_START = 10
|
|
19
|
+
|
|
20
|
+
# Generate the next main step number
|
|
21
|
+
#
|
|
22
|
+
# @param last [String, nil] Last main step number (e.g., "040")
|
|
23
|
+
# @param increment [Integer] Step increment (default: 10)
|
|
24
|
+
# @return [String] Next main step number (e.g., "050")
|
|
25
|
+
def self.next_main(last, increment: DEFAULT_INCREMENT)
|
|
26
|
+
return format("%03d", DEFAULT_START) if last.nil?
|
|
27
|
+
|
|
28
|
+
# Extract the main number (before any dots)
|
|
29
|
+
main_part = last.split(".").first
|
|
30
|
+
current = main_part.to_i
|
|
31
|
+
|
|
32
|
+
# Round up to next increment
|
|
33
|
+
next_num = ((current / increment) + 1) * increment
|
|
34
|
+
format("%03d", next_num)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Generate the next number after a given step (for dynamic injection)
|
|
38
|
+
#
|
|
39
|
+
# @param base [String] Base step number (e.g., "040")
|
|
40
|
+
# @param existing [Array<String>] Existing step numbers
|
|
41
|
+
# @return [String] Next available number (e.g., "041")
|
|
42
|
+
def self.next_after(base, existing = [])
|
|
43
|
+
# Extract main part
|
|
44
|
+
main_part = base.split(".").first.to_i
|
|
45
|
+
|
|
46
|
+
# Find existing numbers in the range base+1 to base+9
|
|
47
|
+
range_numbers = existing.map { |n| n.split(".").first.to_i }
|
|
48
|
+
.select { |n| n > main_part && n < main_part + 10 }
|
|
49
|
+
|
|
50
|
+
# Find next available
|
|
51
|
+
next_num = main_part + 1
|
|
52
|
+
while range_numbers.include?(next_num)
|
|
53
|
+
next_num += 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
format("%03d", next_num)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Generate a sub-step number
|
|
60
|
+
#
|
|
61
|
+
# @param parent [String] Parent step number (e.g., "030")
|
|
62
|
+
# @param sequence [Integer] Sub-step sequence (1, 2, 3...)
|
|
63
|
+
# @return [String] Sub-step number (e.g., "030.01")
|
|
64
|
+
def self.subtask(parent, sequence)
|
|
65
|
+
"#{parent}.#{format("%02d", sequence)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Generate a sub-sub-step number
|
|
69
|
+
#
|
|
70
|
+
# @param parent [String] Parent sub-step number (e.g., "030.01")
|
|
71
|
+
# @param sequence [Integer] Sub-sub-step sequence
|
|
72
|
+
# @return [String] Sub-sub-step number (e.g., "030.01.01")
|
|
73
|
+
def self.sub_subtask(parent, sequence)
|
|
74
|
+
"#{parent}.#{format("%02d", sequence)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Parse a step number into its components
|
|
78
|
+
#
|
|
79
|
+
# @param number [String] Step number (e.g., "030.01.02")
|
|
80
|
+
# @return [Hash] Parsed components
|
|
81
|
+
def self.parse(number)
|
|
82
|
+
parts = number.split(".").map(&:to_i)
|
|
83
|
+
{
|
|
84
|
+
main: parts.first,
|
|
85
|
+
parts: parts,
|
|
86
|
+
depth: parts.size
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if a number is a sub-step of another
|
|
91
|
+
#
|
|
92
|
+
# @param number [String] Step number to check
|
|
93
|
+
# @param parent [String] Potential parent number
|
|
94
|
+
# @return [Boolean] True if number is a sub-step of parent
|
|
95
|
+
def self.subtask_of?(number, parent)
|
|
96
|
+
number.start_with?("#{parent}.")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Format a number from index (0-based) to step number
|
|
100
|
+
#
|
|
101
|
+
# @param index [Integer] Zero-based index
|
|
102
|
+
# @param increment [Integer] Step increment
|
|
103
|
+
# @return [String] Step number
|
|
104
|
+
def self.from_index(index, increment: DEFAULT_INCREMENT)
|
|
105
|
+
format("%03d", (index + 1) * increment)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure functions for expanding preset templates into step definitions.
|
|
7
|
+
#
|
|
8
|
+
# Handles `expansion:` directives in preset YAML files to generate
|
|
9
|
+
# hierarchical step structures from parameter arrays.
|
|
10
|
+
#
|
|
11
|
+
# Supports:
|
|
12
|
+
# - `batch-parent`: Creates a parent container step that auto-completes
|
|
13
|
+
# - `foreach`: Iterates over array parameter to create child steps
|
|
14
|
+
# - `child-template`: Template for generating foreach children
|
|
15
|
+
#
|
|
16
|
+
# @example Preset with expansion
|
|
17
|
+
# expansion:
|
|
18
|
+
# batch-parent:
|
|
19
|
+
# name: batch-tasks
|
|
20
|
+
# number: "010"
|
|
21
|
+
# instructions: "Batch container - auto-completes when children done."
|
|
22
|
+
# foreach: taskrefs
|
|
23
|
+
# child-template:
|
|
24
|
+
# name: "work-on-{{item}}"
|
|
25
|
+
# parent: "010"
|
|
26
|
+
# context: fork
|
|
27
|
+
# instructions: "Implement task {{item}}"
|
|
28
|
+
#
|
|
29
|
+
# @example Generated steps for taskrefs: [148, 149, 150]
|
|
30
|
+
# [
|
|
31
|
+
# { number: "010", name: "batch-tasks", instructions: "..." },
|
|
32
|
+
# { number: "010.01", name: "work-on-148", parent: "010", context: "fork", ... },
|
|
33
|
+
# { number: "010.02", name: "work-on-149", parent: "010", context: "fork", ... },
|
|
34
|
+
# { number: "010.03", name: "work-on-150", parent: "010", context: "fork", ... }
|
|
35
|
+
# ]
|
|
36
|
+
module PresetExpander
|
|
37
|
+
# Expand a preset configuration into concrete steps.
|
|
38
|
+
#
|
|
39
|
+
# @param preset [Hash] Parsed preset YAML with optional expansion section
|
|
40
|
+
# @param parameters [Hash] Parameter values including arrays for foreach
|
|
41
|
+
# @return [Array<Hash>] Expanded steps ready for job.yaml
|
|
42
|
+
def self.expand(preset, parameters = {})
|
|
43
|
+
parameters = normalize_taskref_alias(parameters)
|
|
44
|
+
expansion = preset["expansion"]
|
|
45
|
+
base_steps = preset["steps"] || []
|
|
46
|
+
|
|
47
|
+
# If no expansion section, return base steps with parameter substitution
|
|
48
|
+
unless expansion
|
|
49
|
+
return base_steps.map { |step| substitute_parameters(step, parameters) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
expanded_steps = []
|
|
53
|
+
|
|
54
|
+
# Process batch-parent if present
|
|
55
|
+
if expansion["batch-parent"]
|
|
56
|
+
parent_step = build_batch_parent(expansion["batch-parent"], parameters)
|
|
57
|
+
expanded_steps << parent_step
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Process foreach expansion if present
|
|
61
|
+
if expansion["foreach"] && expansion["child-template"]
|
|
62
|
+
foreach_param = expansion["foreach"]
|
|
63
|
+
items = normalize_array_parameter(parameters[foreach_param])
|
|
64
|
+
|
|
65
|
+
unless items.empty?
|
|
66
|
+
child_steps = build_foreach_children(
|
|
67
|
+
expansion["child-template"],
|
|
68
|
+
items,
|
|
69
|
+
parameters
|
|
70
|
+
)
|
|
71
|
+
expanded_steps.concat(child_steps)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Add remaining base steps with parameter substitution
|
|
76
|
+
base_steps.each do |step|
|
|
77
|
+
expanded_step = substitute_parameters(step, parameters)
|
|
78
|
+
expanded_steps << expanded_step
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
expanded_steps
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Parse array parameter from various input formats.
|
|
85
|
+
#
|
|
86
|
+
# @param value [String, Array, nil] Parameter value
|
|
87
|
+
# @return [Array<String>] Normalized array of values
|
|
88
|
+
def self.parse_array_parameter(value)
|
|
89
|
+
return [] if value.nil?
|
|
90
|
+
return value.map(&:to_s) if value.is_a?(Array)
|
|
91
|
+
|
|
92
|
+
value_str = value.to_s.strip
|
|
93
|
+
return [] if value_str.empty?
|
|
94
|
+
|
|
95
|
+
# Check for range pattern (e.g., "148-152")
|
|
96
|
+
if value_str.match?(/^\d+-\d+$/)
|
|
97
|
+
start_num, end_num = value_str.split("-").map(&:to_i)
|
|
98
|
+
return (start_num..end_num).map(&:to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check for comma-separated values
|
|
102
|
+
if value_str.include?(",")
|
|
103
|
+
return value_str.split(",").map(&:strip)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check for pattern (contains * or ?)
|
|
107
|
+
if value_str.match?(/[*?]/)
|
|
108
|
+
# Return pattern as single-element array for later resolution
|
|
109
|
+
return [value_str]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Single value
|
|
113
|
+
[value_str]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Validate preset parameters against requirements.
|
|
117
|
+
#
|
|
118
|
+
# @param preset [Hash] Preset configuration
|
|
119
|
+
# @param parameters [Hash] Provided parameter values
|
|
120
|
+
# @return [Array<String>] List of validation errors (empty if valid)
|
|
121
|
+
def self.validate_parameters(preset, parameters)
|
|
122
|
+
parameters = normalize_taskref_alias(parameters)
|
|
123
|
+
errors = []
|
|
124
|
+
param_defs = preset["parameters"] || {}
|
|
125
|
+
|
|
126
|
+
param_defs.each do |name, config|
|
|
127
|
+
next unless config["required"]
|
|
128
|
+
|
|
129
|
+
value = parameters[name]
|
|
130
|
+
if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
131
|
+
errors << "Required parameter '#{name}' is missing"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
errors
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Check if a preset has expansion directives.
|
|
139
|
+
#
|
|
140
|
+
# @param preset [Hash] Preset configuration
|
|
141
|
+
# @return [Boolean] True if preset uses expansion
|
|
142
|
+
def self.has_expansion?(preset)
|
|
143
|
+
!preset["expansion"].nil?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get the foreach parameter name from preset expansion.
|
|
147
|
+
#
|
|
148
|
+
# @param preset [Hash] Preset configuration
|
|
149
|
+
# @return [String, nil] Name of the foreach parameter
|
|
150
|
+
def self.foreach_parameter(preset)
|
|
151
|
+
preset.dig("expansion", "foreach")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Normalize array parameter value.
|
|
157
|
+
#
|
|
158
|
+
# @param value [Object] Raw parameter value
|
|
159
|
+
# @return [Array<String>] Normalized array
|
|
160
|
+
def self.normalize_array_parameter(value)
|
|
161
|
+
parse_array_parameter(value)
|
|
162
|
+
end
|
|
163
|
+
private_class_method :normalize_array_parameter
|
|
164
|
+
|
|
165
|
+
# Normalize single-task shorthand onto batch parameter name.
|
|
166
|
+
#
|
|
167
|
+
# Canonical batch presets now use taskrefs; callers may still pass
|
|
168
|
+
# taskref for single-task usage.
|
|
169
|
+
def self.normalize_taskref_alias(parameters)
|
|
170
|
+
params = (parameters || {}).dup
|
|
171
|
+
return params unless params["taskrefs"].nil? || params["taskrefs"] == ""
|
|
172
|
+
|
|
173
|
+
taskref = params["taskref"]
|
|
174
|
+
return params if taskref.nil? || taskref.to_s.strip.empty?
|
|
175
|
+
|
|
176
|
+
params["taskrefs"] = [taskref.to_s]
|
|
177
|
+
params
|
|
178
|
+
end
|
|
179
|
+
private_class_method :normalize_taskref_alias
|
|
180
|
+
|
|
181
|
+
# Build the batch parent step.
|
|
182
|
+
#
|
|
183
|
+
# @param config [Hash] batch-parent configuration
|
|
184
|
+
# @param parameters [Hash] Parameter values for substitution
|
|
185
|
+
# @return [Hash] Parent step definition
|
|
186
|
+
def self.build_batch_parent(config, parameters)
|
|
187
|
+
step = {
|
|
188
|
+
"number" => config["number"] || "010",
|
|
189
|
+
"name" => substitute_string(config["name"] || "batch-tasks", parameters),
|
|
190
|
+
"instructions" => substitute_string(config["instructions"] || "", parameters)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
step["skill"] = config["skill"] if config["skill"]
|
|
194
|
+
step["context"] = config["context"] if config["context"]
|
|
195
|
+
|
|
196
|
+
step
|
|
197
|
+
end
|
|
198
|
+
private_class_method :build_batch_parent
|
|
199
|
+
|
|
200
|
+
# Build child steps from foreach template.
|
|
201
|
+
#
|
|
202
|
+
# @param template [Hash] child-template configuration
|
|
203
|
+
# @param items [Array<String>] Items to iterate over
|
|
204
|
+
# @param parameters [Hash] Additional parameter values
|
|
205
|
+
# @return [Array<Hash>] Generated child steps
|
|
206
|
+
def self.build_foreach_children(template, items, parameters)
|
|
207
|
+
parent_number = template["parent"] || "010"
|
|
208
|
+
steps = []
|
|
209
|
+
|
|
210
|
+
items.each_with_index do |item, index|
|
|
211
|
+
# Build child number: parent.01, parent.02, etc.
|
|
212
|
+
child_number = "#{parent_number}.#{format("%02d", index + 1)}"
|
|
213
|
+
|
|
214
|
+
# Create merged parameters with {{item}} available
|
|
215
|
+
item_params = parameters.merge("item" => item)
|
|
216
|
+
raw_step = template.dup
|
|
217
|
+
raw_step["number"] = child_number
|
|
218
|
+
raw_step["parent"] = parent_number
|
|
219
|
+
raw_step["name"] ||= "item-#{item}"
|
|
220
|
+
raw_step["instructions"] ||= ""
|
|
221
|
+
step = substitute_parameters(raw_step, item_params)
|
|
222
|
+
|
|
223
|
+
steps << step
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
steps
|
|
227
|
+
end
|
|
228
|
+
private_class_method :build_foreach_children
|
|
229
|
+
|
|
230
|
+
# Substitute parameters in a step definition.
|
|
231
|
+
#
|
|
232
|
+
# @param step [Hash] Step configuration
|
|
233
|
+
# @param parameters [Hash] Parameter values
|
|
234
|
+
# @return [Hash] Step with substituted values
|
|
235
|
+
def self.substitute_parameters(step, parameters)
|
|
236
|
+
substitute_value(step, parameters)
|
|
237
|
+
end
|
|
238
|
+
private_class_method :substitute_parameters
|
|
239
|
+
|
|
240
|
+
def self.substitute_value(value, parameters)
|
|
241
|
+
case value
|
|
242
|
+
when Hash
|
|
243
|
+
value.each_with_object({}) do |(key, nested), result|
|
|
244
|
+
result[key] = substitute_value(nested, parameters)
|
|
245
|
+
end
|
|
246
|
+
when Array
|
|
247
|
+
value.map { |item| substitute_value(item, parameters) }
|
|
248
|
+
when String
|
|
249
|
+
substitute_string(value, parameters)
|
|
250
|
+
else
|
|
251
|
+
value
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
private_class_method :substitute_value
|
|
255
|
+
|
|
256
|
+
# Substitute {{placeholder}} tokens in a string.
|
|
257
|
+
#
|
|
258
|
+
# @param text [String, nil] Text with placeholders
|
|
259
|
+
# @param parameters [Hash] Parameter values
|
|
260
|
+
# @return [String] Text with substituted values
|
|
261
|
+
def self.substitute_string(text, parameters)
|
|
262
|
+
return "" if text.nil?
|
|
263
|
+
return text unless text.is_a?(String)
|
|
264
|
+
|
|
265
|
+
result = text.dup
|
|
266
|
+
parameters.each do |key, value|
|
|
267
|
+
# Handle array values by joining with comma
|
|
268
|
+
display_value = value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
269
|
+
result = result.gsub("{{#{key}}}", display_value)
|
|
270
|
+
end
|
|
271
|
+
result
|
|
272
|
+
end
|
|
273
|
+
private_class_method :substitute_string
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|