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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
  3. data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
  4. data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
  5. data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
  6. data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
  7. data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
  8. data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
  9. data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
  10. data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
  11. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
  12. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
  13. data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
  14. data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
  15. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
  16. data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
  17. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
  18. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
  19. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
  20. data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
  21. data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
  22. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
  23. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
  24. data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
  25. data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
  26. data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
  27. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
  28. data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
  29. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
  30. data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
  31. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
  32. data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
  33. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
  34. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
  35. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
  36. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
  37. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
  38. data/.ace-defaults/assign/config.yml +48 -0
  39. data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
  40. data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
  41. data/.ace-defaults/assign/presets/release-only.yml +35 -0
  42. data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
  43. data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
  44. data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
  45. data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
  46. data/CHANGELOG.md +1415 -0
  47. data/README.md +87 -0
  48. data/Rakefile +16 -0
  49. data/docs/exit-codes.md +61 -0
  50. data/docs/getting-started.md +121 -0
  51. data/docs/handbook.md +40 -0
  52. data/docs/usage.md +224 -0
  53. data/exe/ace-assign +16 -0
  54. data/handbook/guides/fork-context.g.md +231 -0
  55. data/handbook/skills/as-assign-compose/SKILL.md +24 -0
  56. data/handbook/skills/as-assign-create/SKILL.md +23 -0
  57. data/handbook/skills/as-assign-drive/SKILL.md +24 -0
  58. data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
  59. data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
  60. data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
  61. data/handbook/skills/as-assign-start/SKILL.md +25 -0
  62. data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
  63. data/handbook/workflow-instructions/assign/create.wf.md +215 -0
  64. data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
  65. data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
  66. data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
  67. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
  68. data/handbook/workflow-instructions/assign/start.wf.md +46 -0
  69. data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
  70. data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
  71. data/lib/ace/assign/atoms/composition_rules.rb +219 -0
  72. data/lib/ace/assign/atoms/number_generator.rb +110 -0
  73. data/lib/ace/assign/atoms/preset_expander.rb +277 -0
  74. data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
  75. data/lib/ace/assign/atoms/step_numbering.rb +227 -0
  76. data/lib/ace/assign/atoms/step_sorter.rb +66 -0
  77. data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
  78. data/lib/ace/assign/cli/commands/add.rb +102 -0
  79. data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
  80. data/lib/ace/assign/cli/commands/create.rb +63 -0
  81. data/lib/ace/assign/cli/commands/fail.rb +43 -0
  82. data/lib/ace/assign/cli/commands/finish.rb +88 -0
  83. data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
  84. data/lib/ace/assign/cli/commands/list.rb +166 -0
  85. data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
  86. data/lib/ace/assign/cli/commands/select.rb +45 -0
  87. data/lib/ace/assign/cli/commands/start.rb +40 -0
  88. data/lib/ace/assign/cli/commands/status.rb +407 -0
  89. data/lib/ace/assign/cli.rb +144 -0
  90. data/lib/ace/assign/models/assignment.rb +107 -0
  91. data/lib/ace/assign/models/assignment_info.rb +66 -0
  92. data/lib/ace/assign/models/queue_state.rb +326 -0
  93. data/lib/ace/assign/models/step.rb +197 -0
  94. data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
  95. data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
  96. data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
  97. data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
  98. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
  99. data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
  100. data/lib/ace/assign/molecules/step_writer.rb +246 -0
  101. data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
  102. data/lib/ace/assign/version.rb +7 -0
  103. data/lib/ace/assign.rb +141 -0
  104. 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