ace-assign 0.37.0 → 0.40.4

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.
@@ -74,7 +74,8 @@ If input is an existing `.yml`/`.yaml` file path:
74
74
 
75
75
  If input is an exact preset/recipe-style request (for example `work-on-task --taskref 123`):
76
76
  - Run `wfi://assign/prepare` to produce normalized job content
77
- - Continue to hidden-spec rendering (step 4)
77
+ - If prepare reports all requested refs are already terminal (`done/skipped/cancelled`), stop and return that no assignment was created
78
+ - Otherwise continue to hidden-spec rendering (step 4) using the filtered ref set from prepare
78
79
 
79
80
  #### Path C: Explicit Step or Freeform Intent (Default)
80
81
 
@@ -100,7 +101,11 @@ For unmatched phrases:
100
101
  Skill-backed steps (for example `work-on-task`) stay high-level in rendered YAML.
101
102
  Runtime `ace-assign create` will materialize `assign.source` sub-steps deterministically.
102
103
 
103
- ### 4. Render Hidden Spec (Paths B/C)
104
+ ### 4. Render Hidden Spec (Paths B/C with Workable Input)
105
+
106
+ Precondition:
107
+ - Path B: prepare returned workable, filtered content (not an all-terminal abort)
108
+ - Path C: compose resolved at least one actionable step
104
109
 
105
110
  Create hidden spec directory if missing:
106
111
 
@@ -133,6 +138,7 @@ steps:
133
138
  Rules:
134
139
  - Each invocation writes a new file (no in-place mutation of prior hidden specs).
135
140
  - Hidden specs are internal provenance artifacts; users are not required to edit them.
141
+ - Do not render a hidden spec for all-terminal `work-on-task` requests that aborted in Path B.
136
142
 
137
143
  ### 5. Create Assignment Deterministically
138
144
 
@@ -170,6 +176,7 @@ Step 010: ...
170
176
  |----------|--------|
171
177
  | Unknown explicit phrase | Return unmatched phrase + closest catalog/skill candidates; no assignment created |
172
178
  | Conflicting explicit order | Reorder only by hard rule and report the named rule that required it |
179
+ | Path B all requested refs already terminal | Return clear no-op result (`All requested tasks are already terminal (done/skipped/cancelled): ...`, `No assignment created.`); skip hidden-spec render and `ace-assign create` |
173
180
  | Hidden-spec render failure | Return concrete render error; no assignment created |
174
181
  | `ace-assign create` rejection | Surface CLI error unchanged |
175
182
  | `--run` requested but no workable step | Keep create success; report why drive did not continue |
@@ -179,6 +186,8 @@ Step 010: ...
179
186
  - Re-running the same request creates a new hidden spec file.
180
187
  - Explicit duplicate steps are normalized unless repetition is clearly requested.
181
188
  - Explicit steps take precedence over recipe defaults when both are present.
189
+ - Path B mixed refs (terminal + non-terminal) continue with filtered non-terminal refs only.
190
+ - Path B all-terminal refs produce no assignment and no hidden-spec artifact.
182
191
  - High-level skill-backed steps may expand into sub-steps at create runtime via `assign.source` metadata.
183
192
  - `--run` is a workflow-level create-then-drive handoff, not natural-language parsing in `ace-assign create`.
184
193
  - Quiet mode for `ace-assign create` suppresses non-essential output (including provenance line).
@@ -187,6 +196,8 @@ Step 010: ...
187
196
 
188
197
  - Hidden spec is written under `.ace-local/assign/jobs/` for generated inputs
189
198
  - `ace-assign create FILE` receives the rendered spec path
199
+ - Path B all-terminal requests do not render a hidden spec and do not call `ace-assign create`
200
+ - Path B mixed requests render/create from filtered non-terminal refs only
190
201
  - Assignment metadata preserves hidden-spec provenance
191
202
  - Explicit step requests map to expected steps with explainable ordering
192
203
  - Capability skills remain excluded from assign composition
@@ -197,7 +208,7 @@ Step 010: ...
197
208
 
198
209
  ```bash
199
210
  # Validate intent-resolution language exists in create workflow
200
- rg -n "explicit|phrase|advisory|assign.source|--run" ace-assign/handbook/workflow-instructions/assign/create.wf.md
211
+ rg -n "explicit|phrase|advisory|assign.source|--run|already terminal|No assignment created" ace-assign/handbook/workflow-instructions/assign/create.wf.md
201
212
 
202
213
  # Validate hidden-spec references remain present
203
214
  rg -n "\.ace-local/assign/jobs|Created from hidden spec" ace-assign
@@ -212,4 +223,4 @@ After assignment creation:
212
223
 
213
224
  ```bash
214
225
  /as-assign-drive <assignment-id>
215
- ```
226
+ ```
@@ -11,7 +11,7 @@ ace-docs:
11
11
 
12
12
  ## Purpose
13
13
 
14
- Transform informal instructions OR preset names into a structured job.yaml file that can be used with `ace-assign create job.yaml`. This workflow bridges the gap between high-level intent and the structured work queue format.
14
+ Transform informal instructions OR preset names into a structured job.yaml file that can be used with `ace-assign create --yaml job.yaml`. This workflow bridges the gap between high-level intent and the structured work queue format.
15
15
 
16
16
  ## Input Formats
17
17
 
@@ -227,6 +227,38 @@ From informal instructions:
227
227
  - "tasks 148, 149, 150" → `taskrefs: ["148", "149", "150"]`
228
228
  - "PR 45" → `pr_number: "45"`
229
229
 
230
+ ### 3.1 Resolve Requested Taskrefs and Filter Terminal Tasks (`work-on-task` only)
231
+
232
+ Apply this step when preparing the `work-on-task` preset.
233
+
234
+ 1. Resolve requested refs to concrete task refs first (before any filtering):
235
+ - `--taskref` single value
236
+ - comma-separated `--taskrefs`
237
+ - ranges like `148-152`
238
+ - patterns like `240.*`
239
+ 2. For each resolved ref, run:
240
+
241
+ ```bash
242
+ ace-task show <taskref>
243
+ ```
244
+
245
+ 3. Parse the reported status using `Ace::Task::Atoms::TaskValidationRules::TERMINAL_STATUSES` as the source of truth:
246
+ - If status is terminal (`done`, `skipped`, `cancelled`) → add to `skipped_terminal_refs`
247
+ - Otherwise (`pending`, `draft`, `in-progress`, `blocked`) → keep in `effective_taskrefs`
248
+ 4. Preserve existing invalid-ref behavior:
249
+ - If a ref cannot be resolved/found, fail with the existing invalid task reference path.
250
+ 5. Branch by result:
251
+ - Mixed set (`effective_taskrefs` non-empty, `skipped_terminal_refs` non-empty):
252
+ - Continue with `effective_taskrefs`
253
+ - Report skipped refs clearly (example: `Skipped terminal tasks (done/skipped/cancelled): 149`)
254
+ - All-terminal set (`effective_taskrefs` empty, `skipped_terminal_refs` non-empty):
255
+ - Stop before preset expansion and before job generation
256
+ - Report:
257
+ - `All requested tasks are already terminal (done/skipped/cancelled): <refs>`
258
+ - `No assignment created.`
259
+
260
+ `effective_taskrefs` is now the source of truth for downstream expansion, hidden spec rendering, and mark-tasks-done behavior.
261
+
230
262
  ### 4. Apply Customizations (if prose provided)
231
263
 
232
264
  Parse modifications:
@@ -246,6 +278,8 @@ params = { "taskrefs" => ["148", "149", "150"], "review_preset" => "batch" }
246
278
  steps = Ace::Assign::Atoms::PresetExpander.expand(preset, params)
247
279
  ```
248
280
 
281
+ For `work-on-task`, pass `effective_taskrefs` from step 3.1 (not the unfiltered requested list).
282
+
249
283
  ### 5.1 Resolve Skill `assign.source` Metadata (Deterministic Runtime Expansion)
250
284
 
251
285
  After preset expansion, each step with a `skill:` field may declare assignment source metadata via the skill frontmatter:
@@ -331,7 +365,7 @@ Steps: 10 total
331
365
  - apply-feedback-3
332
366
  - finalize
333
367
 
334
- Start assignment with: ace-assign create job.yaml
368
+ Start assignment with: ace-assign create --yaml job.yaml
335
369
  ```
336
370
 
337
371
  ## Output Format
@@ -373,6 +407,8 @@ steps:
373
407
  | Unknown preset | List available presets, ask for selection |
374
408
  | Missing required parameter | Prompt for value |
375
409
  | Invalid task reference | Show expected formats |
410
+ | Mixed refs include terminal tasks | Skip terminal refs, continue with remaining refs, and report skipped refs |
411
+ | All requested refs are terminal | Stop before expansion/job generation and report: `All requested tasks are already terminal (done/skipped/cancelled): ...` + `No assignment created.` |
376
412
  | Unresolved placeholders | Report which parameters need values |
377
413
 
378
414
  ## Examples
@@ -437,10 +473,35 @@ Expands range to tasks 148, 149, 150, 151, 152.
437
473
 
438
474
  Expands pattern to match subtasks (requires resolution at prepare time).
439
475
 
476
+ ### Example 7: Mixed Set with Terminal Tasks
477
+
478
+ ```
479
+ /as-assign-prepare work-on-task --taskrefs 148,149,150
480
+ ```
481
+
482
+ If `149` is terminal (for example `done`):
483
+ - continue with `148,150`
484
+ - report `Skipped terminal tasks (done/skipped/cancelled): 149`
485
+
486
+ ### Example 8: All Requested Tasks Already Terminal
487
+
488
+ ```
489
+ /as-assign-prepare work-on-task --taskrefs 148,149
490
+ ```
491
+
492
+ If both are terminal:
493
+ - report `All requested tasks are already terminal (done/skipped/cancelled): 148,149`
494
+ - report `No assignment created.`
495
+ - do not generate a job.yaml
496
+
440
497
  ## Success Criteria
441
498
 
442
499
  - [ ] Input correctly parsed (preset, parameters, or prose)
443
500
  - [ ] Preset loaded and parameters injected
501
+ - [ ] Requested refs resolve to concrete task refs before any terminal-status filtering
502
+ - [ ] Terminal filtering uses `Ace::Task::Atoms::TaskValidationRules::TERMINAL_STATUSES`
503
+ - [ ] Mixed requested sets continue with non-terminal refs and report skipped terminal refs
504
+ - [ ] All-terminal requested sets stop before queue/job creation
444
505
  - [ ] Expansion directives processed (if present)
445
506
  - [ ] Hierarchical steps numbered correctly
446
507
  - [ ] Loops expanded into separate steps
@@ -457,7 +518,7 @@ After job.yaml is created:
457
518
  /as-assign-create job.yaml
458
519
 
459
520
  # Or directly:
460
- ace-assign create job.yaml
521
+ ace-assign create --yaml job.yaml
461
522
 
462
523
  # Check status
463
524
  ace-assign status
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Atoms
8
+ # Loads assign presets from project overrides first, then gem defaults.
9
+ module PresetLoader
10
+ VALID_PRESET_NAME = /\A[A-Za-z0-9_-]+\z/.freeze
11
+
12
+ def self.load(preset_name)
13
+ name = preset_name.to_s.strip
14
+ raise Ace::Support::Cli::Error, "Preset name cannot be empty" if name.empty?
15
+ unless VALID_PRESET_NAME.match?(name)
16
+ raise Ace::Support::Cli::Error,
17
+ "Invalid preset name '#{name}'. Allowed characters: letters, numbers, underscore, hyphen."
18
+ end
19
+
20
+ path = resolve_path(name)
21
+ raise Ace::Support::Cli::Error, "Preset '#{name}' not found" unless path
22
+
23
+ data = YAML.safe_load_file(path, aliases: true)
24
+ unless data.is_a?(Hash)
25
+ raise Ace::Support::Cli::Error, "Preset '#{name}' is invalid"
26
+ end
27
+
28
+ data
29
+ rescue Psych::SyntaxError => e
30
+ raise Ace::Support::Cli::Error, "Invalid YAML in preset '#{name}': #{e.message}"
31
+ end
32
+
33
+ def self.resolve_path(name)
34
+ project_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
35
+ gem_root = Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
36
+
37
+ project_path = File.join(project_root, ".ace", "assign", "presets", "#{name}.yml")
38
+ default_path = File.join(gem_root, ".ace-defaults", "assign", "presets", "#{name}.yml")
39
+
40
+ return project_path if File.exist?(project_path)
41
+ return default_path if File.exist?(default_path)
42
+
43
+ nil
44
+ end
45
+ private_class_method :resolve_path
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Resolves preset step definitions by exact or base-name matching.
7
+ module PresetStepResolver
8
+ ITERATION_SUFFIX_REGEX = /-\d+\z/.freeze
9
+
10
+ def self.find_steps(preset, names)
11
+ Array(names).map { |name| find_step(preset, name) }
12
+ end
13
+
14
+ def self.find_step(preset, name)
15
+ requested = name.to_s.strip
16
+ raise Ace::Support::Cli::Error, "Step name cannot be empty" if requested.empty?
17
+
18
+ steps = Array(preset["steps"])
19
+ if steps.empty?
20
+ raise Ace::Support::Cli::Error,
21
+ "Preset '#{preset['name'] || 'unknown'}' has no steps defined. Add a non-empty 'steps' array."
22
+ end
23
+
24
+ exact = steps.find { |step| step_name(step) == requested }
25
+ return exact if exact
26
+
27
+ base = base_name(requested)
28
+ matched = steps.find { |step| base_name(step_name(step)) == base }
29
+ return matched if matched
30
+
31
+ available = available_names(steps)
32
+ raise Ace::Support::Cli::Error,
33
+ "Step '#{requested}' not found in preset '#{preset['name'] || 'unknown'}'. " \
34
+ "Available: #{available.join(', ')}"
35
+ end
36
+
37
+ def self.base_name(name)
38
+ name.to_s.sub(ITERATION_SUFFIX_REGEX, "")
39
+ end
40
+
41
+ def self.iteration_name?(name)
42
+ name.to_s.match?(ITERATION_SUFFIX_REGEX)
43
+ end
44
+
45
+ def self.next_iteration_name(base, existing_names)
46
+ stem = base_name(base)
47
+ existing = Array(existing_names).map(&:to_s)
48
+ numbers = existing.filter_map do |name|
49
+ match = name.match(/\A#{Regexp.escape(stem)}-(\d+)\z/)
50
+ match && match[1].to_i
51
+ end
52
+
53
+ next_number = numbers.empty? ? 1 : numbers.max + 1
54
+ "#{stem}-#{next_number}"
55
+ end
56
+
57
+ def self.step_name(step)
58
+ step.is_a?(Hash) ? step["name"].to_s : ""
59
+ end
60
+ private_class_method :step_name
61
+
62
+ def self.available_names(steps)
63
+ names = steps.map { |step| step_name(step) }.reject(&:empty?)
64
+ (names + names.map { |name| base_name(name) }).uniq.sort
65
+ end
66
+ private_class_method :available_names
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,100 +1,304 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ace/task"
4
+ require "yaml"
5
+ require "set"
6
+
3
7
  module Ace
4
8
  module Assign
5
9
  module CLI
6
10
  module Commands
7
- # Add a new step dynamically
8
- #
9
- # Supports hierarchical step injection:
10
- # - Default: adds after current/last done step
11
- # - --after: adds as sibling after specified step
12
- # - --after + --child: adds as child of specified step
13
- #
14
- # @example Add step after current
15
- # ace-assign add fix-tests -i "Fix the failing tests"
16
- #
17
- # @example Inject sibling after specific step
18
- # ace-assign add verify --after 010 -i "Verify initialization"
19
- # # Creates 011-verify.st.md (renumbers existing 011+ if needed)
20
- #
21
- # @example Inject child step
22
- # ace-assign add verify --after 010 --child -i "Verify"
23
- # # Creates 010.01-verify.st.md
11
+ # Add step trees dynamically to a running assignment.
24
12
  class Add < Ace::Support::Cli::Command
25
13
  include Ace::Support::Cli::Base
26
14
  include AssignmentTarget
27
15
 
28
- desc "Add a new step to the queue dynamically"
16
+ desc "Add step(s) to the queue dynamically"
29
17
 
30
- argument :name, required: true, desc: "Step name"
31
- option :instructions, aliases: ["-i"], desc: "Step instructions"
18
+ option :yaml, desc: "Path to YAML file with a top-level steps: array"
19
+ option :step, desc: "Preset step name(s), comma-separated"
20
+ option :task, desc: "Task reference to expand from preset child-template"
21
+ option :preset, desc: "Preset name (required only for --step/--task overrides)"
32
22
  option :after, aliases: ["-a"], desc: "Insert after this step number (e.g., 010)"
33
23
  option :child, aliases: ["-c"], type: :boolean, default: false, desc: "Insert as child of --after step"
34
24
  option :assignment, desc: "Target specific assignment ID"
35
25
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
36
26
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
37
27
 
38
- def call(name:, **options)
39
- # Validate: --child requires --after
40
- if options[:child] && !options[:after]
41
- raise Ace::Support::Cli::Error, "--child requires --after to specify the parent step"
28
+ def call(**options)
29
+ validate_mode_options!(options)
30
+ validate_child_requires_after!(options)
31
+
32
+ target = resolve_assignment_target(options)
33
+ executor = build_executor_for_target(target)
34
+
35
+ mode = selected_mode(options)
36
+ case mode
37
+ when :yaml
38
+ handle_yaml_mode(executor, options)
39
+ when :step
40
+ handle_step_mode(executor, options)
41
+ when :task
42
+ handle_task_mode(executor, options)
43
+ else
44
+ raise Ace::Support::Cli::Error, "Exactly one of --yaml, --step, or --task is required"
42
45
  end
46
+ end
43
47
 
44
- # Validate: adding child would not exceed MAX_DEPTH
45
- if options[:child] && options[:after]
46
- parsed = Atoms::StepNumbering.parse(options[:after])
47
- max_depth = Atoms::StepNumbering::MAX_DEPTH
48
- if parsed[:depth] >= max_depth
49
- raise Ace::Support::Cli::Error,
50
- "Cannot add child: would exceed maximum nesting depth of #{max_depth + 1} levels " \
51
- "(parent '#{options[:after]}' is at depth #{parsed[:depth]})"
52
- end
48
+ private
49
+
50
+ def validate_mode_options!(options)
51
+ selected = insertion_modes(options).count { |_, value| !value.to_s.strip.empty? }
52
+ raise Ace::Support::Cli::Error, "Exactly one of --yaml, --step, or --task is required" if selected.zero?
53
+ raise Ace::Support::Cli::Error, "--yaml, --step, and --task are mutually exclusive" if selected > 1
54
+
55
+ preset = options[:preset].to_s.strip
56
+ return if preset.empty?
57
+ return if !options[:step].to_s.strip.empty? || !options[:task].to_s.strip.empty?
58
+
59
+ raise Ace::Support::Cli::Error, "--preset requires --step or --task"
60
+ end
61
+
62
+ def validate_child_requires_after!(options)
63
+ if options[:child] && options[:after].to_s.strip.empty? && options[:task].to_s.strip.empty?
64
+ raise Ace::Support::Cli::Error, "--child requires --after to specify the parent step"
53
65
  end
66
+ end
54
67
 
55
- instructions = options[:instructions] || "Complete this step and finish with: ace-assign finish --message report.md"
68
+ def insertion_modes(options)
69
+ {
70
+ yaml: options[:yaml],
71
+ step: options[:step],
72
+ task: options[:task]
73
+ }
74
+ end
56
75
 
57
- target = resolve_assignment_target(options)
58
- executor = build_executor_for_target(target)
59
- result = executor.add(
60
- name,
61
- instructions,
76
+ def selected_mode(options)
77
+ insertion_modes(options).find { |_, value| !value.to_s.strip.empty? }&.first
78
+ end
79
+
80
+ def handle_yaml_mode(executor, options)
81
+ yaml_path = options[:yaml].to_s.strip
82
+ steps = load_steps_from_file(yaml_path)
83
+ result = executor.add_batch(
84
+ steps: steps,
62
85
  after: options[:after],
63
- as_child: options[:child]
86
+ as_child: options[:child],
87
+ source_file: yaml_path
64
88
  )
89
+ print_yaml_result(result, yaml_path) unless options[:quiet]
90
+ nil
91
+ end
65
92
 
66
- unless options[:quiet]
67
- added = result[:added]
68
- puts "Created: steps/#{File.basename(added.file_path)}"
69
- puts "Number: #{added.number}"
70
- puts "Status: #{added.status}"
71
-
72
- if options[:after]
73
- if options[:child]
74
- puts "Relationship: child of #{options[:after]}"
75
- else
76
- puts "Relationship: sibling after #{options[:after]}"
77
- end
78
- end
93
+ def handle_step_mode(executor, options)
94
+ preset_name = resolve_preset_name(executor, options)
95
+ preset = Atoms::PresetLoader.load(preset_name)
96
+ requested = parse_step_names(options[:step])
97
+ templates = Atoms::PresetStepResolver.find_steps(preset, requested)
98
+
99
+ steps = []
100
+ added_steps = []
101
+ renumbered = []
102
+ sibling_cursor = options[:after]
103
+
104
+ templates.each do |template|
105
+ state = executor.status[:state]
106
+ existing_names = state.steps.map(&:name)
107
+ step = build_preset_step(template, existing_names: existing_names)
108
+ steps << step
109
+
110
+ inserted = executor.add_batch(
111
+ steps: [step],
112
+ after: options[:child] ? options[:after] : sibling_cursor,
113
+ as_child: options[:child],
114
+ source_file: "preset:#{preset_name}"
115
+ )
116
+
117
+ inserted_added = Array(inserted[:added])
118
+ added_steps.concat(inserted_added)
119
+ renumbered.concat(Array(inserted[:renumbered]))
120
+ sibling_cursor = inserted_added.first&.number unless options[:child]
121
+ end
122
+
123
+ result = {added: added_steps, renumbered: renumbered.uniq}
124
+
125
+ print_step_result(result, steps, after: options[:after]) unless options[:quiet]
126
+ nil
127
+ end
128
+
129
+ def handle_task_mode(executor, options)
130
+ preset_name = resolve_preset_name(executor, options)
131
+ preset = Atoms::PresetLoader.load(preset_name)
132
+ task_ref = options[:task].to_s.strip
133
+ raise Ace::Support::Cli::Error, "--task requires a task reference" if task_ref.empty?
134
+ raise Ace::Support::Cli::Error, "Task not found: #{task_ref}" unless task_manager.show(task_ref)
79
135
 
80
- if result[:renumbered]&.any?
81
- puts
82
- puts "Renumbered steps:"
83
- result[:renumbered].each do |old_num|
84
- new_num = Atoms::StepNumbering.shift_number(old_num, 1)
85
- puts " #{old_num} -> #{new_num}"
86
- end
136
+ child_template = preset.dig("expansion", "child-template")
137
+ unless child_template.is_a?(Hash)
138
+ raise Ace::Support::Cli::Error, "Preset '#{preset_name}' has no expansion.child-template"
139
+ end
140
+
141
+ parent_step = options[:after].to_s.strip
142
+ parent_step = detect_batch_parent(executor) if parent_step.empty?
143
+ if parent_step.to_s.strip.empty?
144
+ raise Ace::Support::Cli::Error, "No batch parent found. Pass --after <step> to specify."
145
+ end
146
+
147
+ task_step = build_task_step(child_template, task_ref, debug: options[:debug])
148
+ insert_as_child = options[:child] || options[:after].to_s.strip.empty?
149
+ result = executor.add_batch(
150
+ steps: [task_step],
151
+ after: parent_step,
152
+ as_child: insert_as_child,
153
+ source_file: "preset:#{preset_name}:task:#{task_ref}"
154
+ )
155
+
156
+ print_task_result(result, task_ref, parent_step, as_child: insert_as_child) unless options[:quiet]
157
+ nil
158
+ end
159
+
160
+ def resolve_preset_name(executor, options)
161
+ explicit = options[:preset].to_s.strip
162
+ return explicit unless explicit.empty?
163
+
164
+ assignment = executor.status[:assignment]
165
+ Molecules::PresetInferrer.infer_from_assignment(assignment)
166
+ end
167
+
168
+ def parse_step_names(raw)
169
+ names = raw.to_s.split(",").map(&:strip).reject(&:empty?)
170
+ raise Ace::Support::Cli::Error, "--step requires at least one step name" if names.empty?
171
+
172
+ names
173
+ end
174
+
175
+ def build_preset_step(template, existing_names:)
176
+ step = deep_dup_hash(template)
177
+ step_name = step["name"].to_s
178
+ if Atoms::PresetStepResolver.iteration_name?(step_name)
179
+ next_name = Atoms::PresetStepResolver.next_iteration_name(
180
+ Atoms::PresetStepResolver.base_name(step_name),
181
+ existing_names
182
+ )
183
+ step["name"] = next_name
184
+ existing_names << next_name
185
+ else
186
+ existing_names << step_name
187
+ end
188
+ step
189
+ end
190
+
191
+ def build_task_step(template, task_ref, debug: false)
192
+ normalized = template.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
193
+ step = substitute_tokens(normalized, "item" => task_ref)
194
+ warn_unexpanded_template_tokens(step) if debug
195
+ step
196
+ end
197
+
198
+ def substitute_tokens(value, replacements)
199
+ case value
200
+ when Hash
201
+ value.each_with_object({}) do |(key, nested), memo|
202
+ memo[key] = substitute_tokens(nested, replacements)
87
203
  end
204
+ when Array
205
+ value.map { |item| substitute_tokens(item, replacements) }
206
+ when String
207
+ value.gsub(/\{\{([a-zA-Z0-9_]+)\}\}/) do
208
+ replacements.fetch(Regexp.last_match(1), Regexp.last_match(0))
209
+ end
210
+ else
211
+ value
212
+ end
213
+ end
214
+
215
+ def warn_unexpanded_template_tokens(value)
216
+ tokens = collect_template_tokens(value)
217
+ return if tokens.empty?
88
218
 
89
- if added.status == :in_progress
90
- puts
91
- puts "Instructions:"
92
- puts added.instructions
219
+ warn "[ace-assign] Warning: Unexpanded preset template token(s): " \
220
+ "#{tokens.join(', ')}. Supported tokens for --task mode: {{item}}"
221
+ end
222
+
223
+ def collect_template_tokens(value, tokens = Set.new)
224
+ case value
225
+ when Hash
226
+ value.each_value { |nested| collect_template_tokens(nested, tokens) }
227
+ when Array
228
+ value.each { |item| collect_template_tokens(item, tokens) }
229
+ when String
230
+ value.scan(/\{\{([a-zA-Z0-9_]+)\}\}/) do |match|
231
+ tokens << "{{#{match.first}}}"
93
232
  end
94
233
  end
234
+
235
+ tokens.to_a.sort
95
236
  end
96
237
 
97
- private
238
+ def detect_batch_parent(executor)
239
+ state = executor.status[:state]
240
+
241
+ batch_parent = state.top_level.find { |step| step.name == "batch-tasks" }
242
+ return batch_parent.number if batch_parent
243
+
244
+ fallback = state.top_level.find do |step|
245
+ state.children_of(step.number).any? { |child| child.name.start_with?("work-on-") }
246
+ end
247
+ fallback&.number
248
+ end
249
+
250
+ def task_manager
251
+ @task_manager ||= Ace::Task::Organisms::TaskManager.new
252
+ end
253
+
254
+ def load_steps_from_file(path)
255
+ raise Ace::Support::Cli::Error, "File not found: #{path}" unless File.exist?(path)
256
+
257
+ begin
258
+ data = YAML.safe_load_file(path, aliases: true)
259
+ rescue Psych::SyntaxError => e
260
+ raise Ace::Support::Cli::Error, "Invalid YAML in #{path}: #{e.message}"
261
+ end
262
+
263
+ steps = data.is_a?(Hash) ? data["steps"] : nil
264
+ unless steps.is_a?(Array) && steps.any?
265
+ raise Ace::Support::Cli::Error, "No steps defined in #{path}"
266
+ end
267
+
268
+ steps
269
+ end
270
+
271
+ def deep_dup_hash(value)
272
+ Marshal.load(Marshal.dump(value))
273
+ end
274
+
275
+ def print_yaml_result(result, source_path)
276
+ added_steps = Array(result[:added])
277
+ puts "Added #{added_steps.size} step(s) from #{File.basename(source_path)}"
278
+ print_added_steps(added_steps)
279
+ end
280
+
281
+ def print_step_result(result, requested_steps, after: nil)
282
+ added_steps = Array(result[:added])
283
+ root_name = requested_steps.first["name"]
284
+ relation = after.to_s.strip.empty? ? "" : " after #{after}"
285
+ puts "Added #{root_name} (#{added_steps.size} step(s))#{relation}".strip
286
+ print_added_steps(added_steps)
287
+ end
288
+
289
+ def print_task_result(result, task_ref, parent_step, as_child:)
290
+ added_steps = Array(result[:added])
291
+ relation = as_child ? "under" : "after"
292
+ puts "Added task #{task_ref} #{relation} #{parent_step}"
293
+ print_added_steps(added_steps)
294
+ end
295
+
296
+ def print_added_steps(steps)
297
+ steps.each do |step|
298
+ fork_suffix = step.context == "fork" ? " (fork)" : ""
299
+ puts " #{step.number}: #{step.name} [#{step.status}]#{fork_suffix}"
300
+ end
301
+ end
98
302
  end
99
303
  end
100
304
  end