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,1299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Organisms
9
+ # Orchestrates workflow operations on the work queue.
10
+ #
11
+ # Implements the state machine for queue operations:
12
+ # start → advance → complete (with fail/add/retry branches)
13
+ class AssignmentExecutor
14
+ attr_reader :assignment_manager, :queue_scanner, :step_writer, :step_renumberer, :skill_source_resolver
15
+
16
+ def initialize(cache_base: nil)
17
+ @assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
18
+ @queue_scanner = Molecules::QueueScanner.new
19
+ @step_writer = Molecules::StepWriter.new
20
+ @skill_source_resolver = Molecules::SkillAssignSourceResolver.new
21
+ @step_catalog = nil
22
+ @step_renumberer = Molecules::StepRenumberer.new(
23
+ step_writer: @step_writer,
24
+ queue_scanner: @queue_scanner
25
+ )
26
+ end
27
+
28
+ # Start a new workflow assignment from config file
29
+ #
30
+ # @param config_path [String] Path to job.yaml config
31
+ # @param parent_id [String, nil] Parent assignment ID for hierarchy linking
32
+ # @return [Hash] Result with assignment and first step
33
+ def start(config_path, parent_id: nil)
34
+ raise ConfigErrors::NotFound, "Config file not found: #{config_path}" unless File.exist?(config_path)
35
+
36
+ config = YAML.safe_load_file(config_path, permitted_classes: [Time, Date])
37
+
38
+ assignment_config = config["assignment"] || {}
39
+ steps_config = config["steps"] || []
40
+
41
+ raise Error, "No steps defined in config" if steps_config.empty?
42
+
43
+ # Enrich steps using declared workflow/skill assign metadata.
44
+ steps_config = enrich_declared_sub_steps(steps_config)
45
+
46
+ # Expand sub-step declarations into batch parent + child steps
47
+ steps_config = expand_sub_steps(steps_config)
48
+ steps_config = materialize_skill_backed_steps(steps_config)
49
+
50
+ # Create assignment
51
+ assignment = assignment_manager.create(
52
+ name: assignment_config["name"] || File.basename(config_path, ".yaml"),
53
+ description: assignment_config["description"],
54
+ source_config: config_path,
55
+ parent: parent_id
56
+ )
57
+
58
+ # Create initial step files
59
+ # Steps may have pre-assigned numbers (from expansion) or need auto-numbering
60
+ steps_config.each_with_index do |step, index|
61
+ # Use pre-assigned number if present, otherwise generate from index
62
+ number = step["number"] || Atoms::NumberGenerator.from_index(index)
63
+ extra = step.reject { |k, _| %w[name instructions number].include?(k) }
64
+ step_writer.create(
65
+ steps_dir: assignment.steps_dir,
66
+ number: number,
67
+ name: step["name"],
68
+ instructions: normalize_instructions(step["instructions"]),
69
+ status: :pending,
70
+ extra: extra
71
+ )
72
+ end
73
+
74
+ # Mark first workable step as in_progress.
75
+ # This skips batch parent containers that have incomplete children.
76
+ initial_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
77
+ first_workable = initial_state.next_workable
78
+ step_writer.mark_in_progress(first_workable.file_path) if first_workable
79
+
80
+ # Archive source config into task's steps directory and update assignment metadata
81
+ archived_path = archive_source_config(config_path, assignment.id)
82
+ assignment = Models::Assignment.new(
83
+ id: assignment.id,
84
+ name: assignment.name,
85
+ description: assignment.description,
86
+ created_at: assignment.created_at,
87
+ updated_at: assignment.updated_at,
88
+ source_config: archived_path,
89
+ cache_dir: assignment.cache_dir,
90
+ parent: assignment.parent
91
+ )
92
+ assignment_manager.update(assignment)
93
+
94
+ # Return result
95
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
96
+ {
97
+ assignment: assignment,
98
+ state: state,
99
+ current: state.current
100
+ }
101
+ end
102
+
103
+ # Get current assignment and queue state
104
+ #
105
+ # @return [Hash] Result with assignment and state
106
+ def status
107
+ assignment = assignment_manager.find_active
108
+ raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
109
+
110
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
111
+ {
112
+ assignment: assignment,
113
+ state: state,
114
+ current: state.current
115
+ }
116
+ end
117
+
118
+ # Start a pending step.
119
+ #
120
+ # Rules:
121
+ # - Fails if any step is already in progress (strict mode)
122
+ # - Starts an explicit pending target when provided
123
+ # - Otherwise starts the next workable pending step
124
+ #
125
+ # @param step_number [String, nil] Optional target step number
126
+ # @param fork_root [String, nil] Optional subtree root scope
127
+ # @return [Hash] Result with started step and updated state
128
+ def start_step(step_number: nil, fork_root: nil)
129
+ assignment = assignment_manager.find_active
130
+ raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
131
+
132
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
133
+ raise StepErrors::InvalidState, "Cannot start: step #{state.current.number} is already in progress. Finish or fail it first." if state.current
134
+
135
+ fork_root = fork_root&.strip
136
+ target_step = if step_number && !step_number.to_s.strip.empty?
137
+ find_target_step_for_start(state, step_number, fork_root)
138
+ elsif fork_root && !fork_root.empty?
139
+ raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
140
+ state.next_workable_in_subtree(fork_root)
141
+ else
142
+ state.next_workable
143
+ end
144
+
145
+ unless target_step
146
+ if fork_root && !fork_root.empty?
147
+ raise StepErrors::InvalidState, "No pending workable step found in subtree #{fork_root}."
148
+ end
149
+ raise StepErrors::InvalidState, "No pending workable step found."
150
+ end
151
+
152
+ step_writer.mark_in_progress(target_step.file_path)
153
+ assignment_manager.update(assignment)
154
+
155
+ new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
156
+ {
157
+ assignment: assignment,
158
+ state: new_state,
159
+ started: new_state.find_by_number(target_step.number),
160
+ current: new_state.current
161
+ }
162
+ end
163
+
164
+ # Finish an in-progress step and advance queue state.
165
+ #
166
+ # @param report_content [String] Completion report content
167
+ # @param step_number [String, nil] Optional in-progress step number to finish
168
+ # @param fork_root [String, nil] Optional subtree root to constrain advancement
169
+ # @return [Hash] Result with completed step and updated state
170
+ def finish_step(report_content:, step_number: nil, fork_root: nil)
171
+ assignment = assignment_manager.find_active
172
+ raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
173
+
174
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
175
+ current = find_target_step_for_finish(state, step_number, fork_root)
176
+ raise Error, "No step currently in progress. Try 'ace-assign start' or 'ace-assign retry'." unless current
177
+
178
+ # Enforce hierarchy: cannot mark parent as done with incomplete children
179
+ if state.has_incomplete_children?(current.number)
180
+ incomplete = state.children_of(current.number).reject { |c| c.status == :done }
181
+ incomplete_nums = incomplete.map(&:number).join(", ")
182
+ raise Error, "Cannot complete step #{current.number}: has incomplete children (#{incomplete_nums}). Complete children first or use 'ace-assign fail' to mark as failed."
183
+ end
184
+
185
+ # Mark current step as done
186
+ step_writer.mark_done(current.file_path, report_content: report_content, reports_dir: assignment.reports_dir)
187
+
188
+ # Rescan to get updated state after marking done
189
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
190
+
191
+ # Auto-complete parent steps if all their children are done
192
+ auto_complete_parents(state, assignment)
193
+
194
+ # Re-scan to get fresh state after auto-completions
195
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
196
+
197
+ fork_root = fork_root&.strip
198
+ # Find next step to work on using hierarchical rules.
199
+ # When fork_root is provided, keep advancement inside that subtree.
200
+ next_step = if fork_root && !fork_root.empty? && state.find_by_number(fork_root)
201
+ find_next_step_in_subtree(state, current.number, fork_root)
202
+ else
203
+ find_next_step(state, current.number)
204
+ end
205
+ if next_step
206
+ step_writer.mark_in_progress(next_step.file_path)
207
+ end
208
+
209
+ assignment_manager.update(assignment)
210
+
211
+ new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
212
+ {
213
+ assignment: assignment,
214
+ state: new_state,
215
+ completed: current,
216
+ current: new_state.current
217
+ }
218
+ end
219
+
220
+ # Complete current step with report and advance
221
+ #
222
+ # Legacy bridge: preserves single-call semantics for fork-run callers.
223
+ # Previously, advance() auto-started the next step as a side effect.
224
+ # The new start/finish split makes this explicit, but advance() retains
225
+ # the auto-start behavior for subtree entry so fork-run workflows
226
+ # (which call advance() with fork_root) continue to work unchanged.
227
+ #
228
+ # @param report_path [String] Path to report file
229
+ # @param fork_root [String, nil] Optional subtree root to constrain advancement
230
+ # @return [Hash] Result with updated state
231
+ def advance(report_path, fork_root: nil)
232
+ raise ConfigErrors::NotFound, "Report file not found: #{report_path}" unless File.exist?(report_path)
233
+
234
+ # Auto-start the next workable subtree step when fork_root is given but
235
+ # no step in the subtree is yet in_progress (subtree entry case).
236
+ fork_root_str = fork_root&.strip
237
+ if fork_root_str && !fork_root_str.empty?
238
+ assignment = assignment_manager.find_active
239
+ if assignment
240
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
241
+ active_in_subtree = state.in_progress_in_subtree(fork_root_str)
242
+ if active_in_subtree.size > 1
243
+ active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
244
+ raise StepErrors::InvalidState, "Cannot advance subtree #{fork_root_str}: multiple steps are in progress (#{active_refs})."
245
+ end
246
+
247
+ if active_in_subtree.empty?
248
+ next_workable = state.next_workable_in_subtree(fork_root_str)
249
+ step_writer.mark_in_progress(next_workable.file_path) if next_workable
250
+ end
251
+ end
252
+ end
253
+
254
+ finish_step(report_content: File.read(report_path), fork_root: fork_root)
255
+ end
256
+
257
+ # Mark current step as failed
258
+ #
259
+ # @param message [String] Error message
260
+ # @return [Hash] Result with updated state
261
+ def fail(message)
262
+ assignment = assignment_manager.find_active
263
+ raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
264
+
265
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
266
+ current = state.current
267
+ raise Error, "No step currently in progress. Try 'ace-assign add' to add a new step or 'ace-assign retry' to retry a failed step." unless current
268
+
269
+ # Mark step as failed
270
+ step_writer.mark_failed(current.file_path, error_message: message)
271
+
272
+ # Update assignment timestamp
273
+ assignment_manager.update(assignment)
274
+
275
+ # Return updated state (no automatic advancement after failure)
276
+ new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
277
+ {
278
+ assignment: assignment,
279
+ state: new_state,
280
+ failed: current
281
+ }
282
+ end
283
+
284
+ # Add a new step dynamically
285
+ #
286
+ # @param name [String] Step name
287
+ # @param instructions [String] Step instructions
288
+ # @param after [String, nil] Insert after this step number (optional)
289
+ # @param as_child [Boolean] Insert as child of 'after' step (default: false, sibling)
290
+ # @return [Hash] Result with new step
291
+ def add(name, instructions, after: nil, as_child: false)
292
+ assignment = assignment_manager.find_active
293
+ raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
294
+
295
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
296
+ existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
297
+
298
+ # Validate --after step exists
299
+ if after && !existing_numbers.include?(after)
300
+ raise StepErrors::NotFound, "Step #{after} not found. Available steps: #{existing_numbers.join(", ")}"
301
+ end
302
+
303
+ new_number, renumbered = calculate_insertion_point(
304
+ after: after,
305
+ as_child: as_child,
306
+ state: state,
307
+ existing_numbers: existing_numbers
308
+ )
309
+
310
+ # Renumber existing steps if needed (uses molecule with rollback support)
311
+ if renumbered.any?
312
+ step_renumberer.renumber(assignment.steps_dir, renumbered)
313
+ # Refresh existing numbers after renumbering
314
+ queue_scanner.step_numbers(assignment.steps_dir)
315
+ end
316
+
317
+ # Determine initial status upfront to avoid redundant I/O
318
+ initial_status = state.current ? :pending : :in_progress
319
+
320
+ # Build added_by metadata for audit trail
321
+ added_by = if after && as_child
322
+ "child_of:#{after}"
323
+ elsif after
324
+ "injected_after:#{after}"
325
+ else
326
+ "dynamic"
327
+ end
328
+
329
+ # Create new step file with correct status
330
+ step_writer.create(
331
+ steps_dir: assignment.steps_dir,
332
+ number: new_number,
333
+ name: name,
334
+ instructions: instructions,
335
+ status: initial_status,
336
+ added_by: added_by,
337
+ parent: as_child ? after : nil
338
+ )
339
+
340
+ rebalance_after_child_injection(assignment: assignment, state: state, parent_number: after) if as_child && after
341
+
342
+ # Update assignment timestamp
343
+ assignment_manager.update(assignment)
344
+
345
+ # Return updated state
346
+ new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
347
+ new_step = new_state.steps.find { |s| s.number == new_number }
348
+
349
+ {
350
+ assignment: assignment,
351
+ state: new_state,
352
+ added: new_step,
353
+ renumbered: renumbered
354
+ }
355
+ end
356
+
357
+ # Retry a failed step (creates new step linked to original)
358
+ #
359
+ # @param step_ref [String] Step number or reference to retry
360
+ # @return [Hash] Result with new retry step
361
+ def retry_step(step_ref)
362
+ assignment = assignment_manager.find_active
363
+ raise AssignmentErrors::NoActive, "No active assignment. Use 'ace-assign create <job.yaml>' to begin." unless assignment
364
+
365
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
366
+
367
+ # Find the step to retry
368
+ original = state.find_by_number(step_ref.to_s)
369
+ raise StepErrors::NotFound, "Step #{step_ref} not found in queue" unless original
370
+
371
+ # Get existing numbers
372
+ existing_numbers = queue_scanner.step_numbers(assignment.steps_dir)
373
+
374
+ # Insert after all current steps (at end of queue before pending)
375
+ # Find last done or failed step
376
+ base_number = if state.current
377
+ state.current.number
378
+ elsif state.last_done
379
+ state.last_done.number
380
+ else
381
+ original.number
382
+ end
383
+
384
+ new_number = Atoms::NumberGenerator.next_after(base_number, existing_numbers)
385
+
386
+ # Create retry step with link to original
387
+ step_writer.create(
388
+ steps_dir: assignment.steps_dir,
389
+ number: new_number,
390
+ name: original.name,
391
+ instructions: original.instructions,
392
+ status: :pending,
393
+ added_by: "retry_of:#{original.number}"
394
+ )
395
+
396
+ # Update assignment timestamp
397
+ assignment_manager.update(assignment)
398
+
399
+ # Return updated state
400
+ new_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
401
+ retry_step = new_state.steps.find { |s| s.number == new_number }
402
+
403
+ {
404
+ assignment: assignment,
405
+ state: new_state,
406
+ retry: retry_step,
407
+ original: original
408
+ }
409
+ end
410
+
411
+ private
412
+
413
+ # Enrich steps by resolving workflow-level or legacy skill-level assign source metadata.
414
+ #
415
+ # If a step has `workflow: ...` or `skill: ...` and no explicit sub_steps,
416
+ # this resolves the workflow and applies
417
+ # workflow `assign.sub-steps` as step sub_steps for deterministic runtime expansion.
418
+ #
419
+ # @param steps_config [Array<Hash>] Original steps from config
420
+ # @return [Array<Hash>] Enriched steps
421
+ def enrich_declared_sub_steps(steps_config)
422
+ steps_config.map do |step|
423
+ next step unless step.is_a?(Hash)
424
+
425
+ sub_steps = step["sub_steps"] || step["sub-steps"]
426
+ next step if sub_steps.is_a?(Array) && sub_steps.any?
427
+
428
+ assign_config = resolve_step_assign_config(step)
429
+ next step unless assign_config
430
+
431
+ resolved_sub_steps = assign_config[:sub_steps]
432
+ next step unless resolved_sub_steps.is_a?(Array) && resolved_sub_steps.any?
433
+
434
+ enriched = step.merge("sub_steps" => resolved_sub_steps)
435
+ enriched["context"] ||= assign_config[:context] if assign_config[:context]
436
+ enriched
437
+ end
438
+ end
439
+
440
+ # Expand steps with sub_steps into batch parent + child structure.
441
+ #
442
+ # When a step declares `sub_steps` (from workflow frontmatter), it becomes
443
+ # a batch parent with fork context, and each sub-step becomes a child step.
444
+ # This reuses the existing batch-parent pattern from compose.
445
+ #
446
+ # Numbers are pre-assigned based on the original index position so that
447
+ # subsequent steps keep their expected numbering (e.g., 010, 020, 030)
448
+ # regardless of how many children are expanded.
449
+ #
450
+ # @param steps_config [Array<Hash>] Original steps from config
451
+ # @return [Array<Hash>] Expanded steps with parent-child numbers
452
+ def expand_sub_steps(steps_config)
453
+ # Check if any step has sub_steps; return early if none
454
+ has_sub_steps = steps_config.any? do |step|
455
+ subs = step["sub_steps"] || step["sub-steps"]
456
+ subs.is_a?(Array) && subs.any?
457
+ end
458
+ return steps_config unless has_sub_steps
459
+
460
+ expanded = []
461
+
462
+ steps_config.each_with_index do |step, index|
463
+ sub_steps = step["sub_steps"] || step["sub-steps"]
464
+ parent_number = step["number"] || Atoms::NumberGenerator.from_index(index)
465
+
466
+ if sub_steps.is_a?(Array) && sub_steps.any?
467
+ # Create split parent orchestration node
468
+ parent_context = step["context"] || "fork"
469
+ parent_instructions = step["instructions"]
470
+ parent_step = build_split_parent_step(
471
+ step: step,
472
+ parent_number: parent_number,
473
+ parent_context: parent_context,
474
+ sub_steps: sub_steps
475
+ )
476
+ expanded << parent_step
477
+
478
+ # Create child steps under the parent
479
+ sub_steps.each_with_index do |sub_name, sub_idx|
480
+ child_number = Atoms::NumberGenerator.subtask(parent_number, sub_idx + 1)
481
+ expanded << build_child_sub_step(
482
+ sub_name: sub_name,
483
+ child_number: child_number,
484
+ parent_number: parent_number,
485
+ parent_step: step,
486
+ parent_instructions: parent_instructions,
487
+ parent_context: parent_context
488
+ )
489
+ end
490
+ else
491
+ # Pre-assign number to non-sub-step entries to maintain position
492
+ expanded << step.merge("number" => parent_number)
493
+ end
494
+ end
495
+
496
+ expanded
497
+ end
498
+
499
+ # Build a split parent orchestration step.
500
+ #
501
+ # Parent nodes with sub_steps are subtree delegation roots and should not
502
+ # execute the original skill directly. The parent instructions explain
503
+ # how to drive the subtree; the original goals are preserved for context.
504
+ #
505
+ # @param step [Hash] Original parent step config
506
+ # @param parent_number [String] Parent step number
507
+ # @param parent_context [String] Parent execution context
508
+ # @param sub_steps [Array<String>] Declared sub-step names
509
+ # @return [Hash] Parent step config for runtime queue
510
+ def build_split_parent_step(step:, parent_number:, parent_context:, sub_steps:)
511
+ source_skill = step["skill"]
512
+ original_text = normalize_instructions(step["instructions"]).strip
513
+ definition = find_step_definition("split-subtree-root") || {}
514
+
515
+ lines = split_parent_instruction_lines(
516
+ definition: definition,
517
+ parent_number: parent_number,
518
+ parent_context: parent_context,
519
+ source_skill: source_skill,
520
+ sub_steps: sub_steps
521
+ )
522
+
523
+ unless original_text.empty?
524
+ lines << ""
525
+ lines << (definition["goal_header"] || "Goal to satisfy through child steps:")
526
+ original_text.lines.map(&:strip).reject(&:empty?).each do |line|
527
+ lines << "- #{line}"
528
+ end
529
+ end
530
+
531
+ parent_step = step.merge(
532
+ "number" => parent_number,
533
+ "context" => parent_context,
534
+ "instructions" => lines.join("\n")
535
+ )
536
+ parent_step.delete("sub_steps")
537
+ parent_step.delete("sub-steps")
538
+ parent_step.delete("skill")
539
+ parent_step.delete("workflow")
540
+ parent_step["source_skill"] = source_skill if source_skill
541
+ parent_step["split_step_type"] = definition["name"] || "split-subtree-root"
542
+ parent_step
543
+ end
544
+
545
+ # Render split parent instructions from catalog with fallback defaults.
546
+ #
547
+ # @param definition [Hash] Catalog definition for split parent step
548
+ # @param parent_number [String] Parent step number
549
+ # @param parent_context [String] Parent execution context
550
+ # @param source_skill [String, nil] Source skill of original parent step
551
+ # @param sub_steps [Array<String>] Child step names
552
+ # @return [Array<String>] Rendered instruction lines
553
+ def split_parent_instruction_lines(definition:, parent_number:, parent_context:, source_skill:, sub_steps:)
554
+ instructions = definition["instructions"].is_a?(Hash) ? definition["instructions"] : {}
555
+ context_key = (parent_context == "fork") ? "fork" : "inline"
556
+ template_lines = Array(instructions["common"]) + Array(instructions[context_key])
557
+ template_lines = default_split_parent_instruction_lines(parent_context) if template_lines.empty?
558
+
559
+ template_lines = template_lines.map(&:to_s)
560
+ template_lines.reject! { |line| line.include?("{{source_skill}}") && source_skill.to_s.strip.empty? }
561
+
562
+ variables = {
563
+ "parent_number" => parent_number,
564
+ "parent_context" => parent_context,
565
+ "source_skill" => source_skill.to_s,
566
+ "sub_steps" => sub_steps.join(", ")
567
+ }
568
+
569
+ template_lines
570
+ .map { |line| interpolate_template_line(line, variables) }
571
+ .map(&:strip)
572
+ .reject(&:empty?)
573
+ end
574
+
575
+ # Default split parent instruction lines used when catalog entry is missing.
576
+ #
577
+ # @param parent_context [String]
578
+ # @return [Array<String>]
579
+ def default_split_parent_instruction_lines(parent_context)
580
+ lines = [
581
+ "Subtree root orchestrator step.",
582
+ "This step is orchestration-only.",
583
+ "Do not execute the parent workflow directly in this step.",
584
+ "Child steps: {{sub_steps}}."
585
+ ]
586
+
587
+ if parent_context == "fork"
588
+ lines.concat(
589
+ [
590
+ "Delegate this subtree into forked context:",
591
+ "- ace-assign fork-run --assignment <assignment-id>@{{parent_number}}",
592
+ "Inside the forked agent, continue execution within this subtree scope only."
593
+ ]
594
+ )
595
+ else
596
+ lines << "Execute only child steps under this node."
597
+ end
598
+
599
+ lines
600
+ end
601
+
602
+ # Apply simple {{token}} template substitution.
603
+ #
604
+ # @param line [String]
605
+ # @param variables [Hash]
606
+ # @return [String]
607
+ def interpolate_template_line(line, variables)
608
+ rendered = line.dup
609
+ variables.each do |key, value|
610
+ rendered = rendered.gsub("{{#{key}}}", value.to_s)
611
+ end
612
+ rendered
613
+ end
614
+
615
+ # Build a concrete child step from a sub-step name.
616
+ #
617
+ # Child steps inherit parent task context in instructions so skills can
618
+ # extract concrete parameters (e.g., task refs) during execution.
619
+ # Skill and context defaults are sourced from the step catalog when available.
620
+ #
621
+ # @param sub_name [String] Child sub-step name
622
+ # @param child_number [String] Generated child step number
623
+ # @param parent_number [String] Parent step number
624
+ # @param parent_step [Hash] Parent step config
625
+ # @param parent_instructions [String, Array<String>, nil] Parent instructions
626
+ # @param parent_context [String, nil] Parent execution context
627
+ # @return [Hash] Child step config
628
+ def build_child_sub_step(sub_name:, child_number:, parent_number:, parent_step:, parent_instructions:, parent_context:)
629
+ step_def = find_step_definition(sub_name)
630
+ parent_task_ref = extract_parent_taskref(parent_step, parent_instructions)
631
+ instructions = if step_def&.dig("skill")
632
+ build_skill_backed_child_notes(sub_name, parent_instructions, task_ref: parent_task_ref)
633
+ else
634
+ build_child_instructions(sub_name, parent_instructions, step_def, task_ref: parent_task_ref)
635
+ end
636
+ child = {
637
+ "number" => child_number,
638
+ "name" => sub_name,
639
+ "instructions" => instructions,
640
+ "parent" => parent_number
641
+ }
642
+ child["taskref"] = parent_task_ref if parent_task_ref
643
+
644
+ if step_def
645
+ child["workflow"] = step_def["workflow"] if step_def["workflow"]
646
+ child["skill"] = step_def["skill"] if step_def["skill"] && !step_def["workflow"]
647
+
648
+ context_default = step_def.dig("context", "default")
649
+ child["context"] = context_default if context_default && parent_context != "fork"
650
+ end
651
+
652
+ child
653
+ end
654
+
655
+ # Build child instructions with parent context and step focus.
656
+ #
657
+ # @param sub_name [String] Child sub-step name
658
+ # @param parent_instructions [String, Array<String>, nil] Parent instructions
659
+ # @param step_def [Hash, nil] Catalog definition for this sub-step
660
+ # @param task_ref [String, nil] Explicit task reference from parent metadata
661
+ # @return [String] Rendered instructions
662
+ def build_child_instructions(sub_name, parent_instructions, step_def, task_ref: nil)
663
+ parent_text = normalize_instructions(parent_instructions).strip
664
+ focus = (step_def && step_def["description"]) ? step_def["description"] : "Execute #{sub_name} sub-step."
665
+ focus = focus.gsub("<taskref>", task_ref) if task_ref && !task_ref.empty?
666
+ context = compact_task_context(parent_text, task_ref: task_ref)
667
+ action = child_action_instructions(sub_name, parent_text, task_ref: task_ref)
668
+
669
+ sections = []
670
+ sections << "Task context:\n#{context}" unless context.empty?
671
+ sections << "Sub-step focus:\n#{focus}"
672
+ sections << "Action:\n#{action}"
673
+ sections.join("\n\n")
674
+ end
675
+
676
+ def build_skill_backed_child_notes(sub_name, parent_instructions, task_ref: nil)
677
+ parent_text = normalize_instructions(parent_instructions).strip
678
+ context = compact_task_context(parent_text, task_ref: task_ref)
679
+ notes = child_specific_notes(sub_name, parent_text)
680
+
681
+ sections = []
682
+ sections << "Task context:\n#{context}" unless context.empty?
683
+ sections << "Assignment-specific context:\n#{notes}" unless notes.empty?
684
+ sections.join("\n\n")
685
+ end
686
+
687
+ # Build compact task context for child sub-steps.
688
+ # Avoid copying parent orchestration boilerplate into every child step.
689
+ #
690
+ # @param parent_text [String]
691
+ # @param task_ref [String, nil]
692
+ # @return [String]
693
+ def compact_task_context(parent_text, task_ref: nil)
694
+ unless task_ref.nil? || task_ref.to_s.strip.empty?
695
+ return "Task reference: #{task_ref}"
696
+ end
697
+
698
+ return "" if parent_text.nil? || parent_text.empty?
699
+
700
+ task_refs = parent_text.scan(/\b\d+\.\d+\b/).uniq
701
+ return "Task reference: #{task_refs.join(", ")}" if task_refs.any?
702
+
703
+ relevant_lines = parent_text.lines.map(&:strip).reject(&:empty?).reject do |line|
704
+ line == "Task context:" || line == "Assignment-specific context:"
705
+ end
706
+ first_line = relevant_lines.first
707
+ return "" unless first_line
708
+
709
+ return first_line if first_line.start_with?("Task request:", "Task reference:")
710
+
711
+ "Task request: #{first_line}"
712
+ end
713
+
714
+ # Build explicit, step-specific action instructions.
715
+ #
716
+ # @param sub_name [String]
717
+ # @param parent_text [String]
718
+ # @param task_ref [String, nil]
719
+ # @return [String]
720
+ def child_action_instructions(sub_name, parent_text, task_ref: nil)
721
+ task_refs = if task_ref && !task_ref.to_s.strip.empty?
722
+ [task_ref.to_s]
723
+ else
724
+ parent_text.to_s.scan(/\b\d+\.\d+\b/).uniq
725
+ end
726
+ task_hint = task_refs.any? ? " for task #{task_refs.join(", ")}" : ""
727
+
728
+ case sub_name
729
+ when "onboard"
730
+ "- Load project context#{task_hint} using the step workflow instructions.\n- Confirm required files and workflow context are available."
731
+ when "plan-task"
732
+ "- Analyze requirements#{task_hint}.\n- Plan against the behavioral spec structure: cover Interface Contract, Error Handling, Edge Cases, and operating modes (dry-run, force, verbose, quiet) where relevant.\n- If the spec is missing details needed for implementation, include them in a \"Behavioral Gaps\" section instead of silently working around omissions.\n- Produce a concrete implementation plan with acceptance checks."
733
+ when "work-on-task"
734
+ "- Implement the required changes#{task_hint}.\n- Verify behavior with relevant checks/tests before reporting completion.\n- Before marking complete, verify working tree is clean (`git status --short`). If dirty, commit remaining changes with `ace-git-commit`."
735
+ when "pre-commit-review"
736
+ pre_commit_review_action_instructions(task_hint: task_hint)
737
+ when "verify-test"
738
+ "- Identify modified packages#{task_hint}.\n- For each modified package, run: cd <package> && ace-test --profile 6\n- If no package-level code changes are present, mark this step skipped with a clear reason."
739
+ when /\Arelease(?:-.+)?\z/
740
+ "- Release all modified packages and update both package and root changelogs.\n- Follow semantic versioning expectations for this step.\n- When auto-detecting packages, include `git diff origin/main...HEAD --name-only` in addition to working-tree state — prior steps may have already committed changes."
741
+ when "verify-e2e"
742
+ "- Check change scope: run `git diff origin/main --name-only` to list modified files.\n" \
743
+ "- **Skip criteria**: If ALL modified files match `*.md`, `*.yml` (non-CI config), `.ace-tasks/**`, or `.ace-retros/**`, skip E2E verification — mark step done with \"skipped: docs/task-spec only changes, no runnable code affected\".\n" \
744
+ "- Otherwise: detect modified packages, run E2E scenarios for each package with `test/e2e/` scenarios#{task_hint}.\n" \
745
+ "- If no modified package has E2E scenarios, mark step done with \"skipped: no E2E scenarios for modified packages\"."
746
+ else
747
+ "- Execute the #{sub_name} step."
748
+ end
749
+ end
750
+
751
+ def child_specific_notes(sub_name, parent_text)
752
+ return "" if parent_text.nil? || parent_text.empty?
753
+
754
+ lines = parent_text.lines.map(&:strip).reject(&:empty?)
755
+ relevant = lines.filter_map do |line|
756
+ if line.match?(/\AChild #{Regexp.escape(sub_name)}:/i)
757
+ "- #{line.sub(/\AChild #{Regexp.escape(sub_name)}:\s*/i, "")}"
758
+ elsif line.start_with?("Focus:")
759
+ "- #{line}"
760
+ end
761
+ end
762
+
763
+ relevant.join("\n")
764
+ end
765
+
766
+ def materialize_skill_backed_steps(steps_config)
767
+ steps_config.map do |step|
768
+ materialize_skill_backed_step(step)
769
+ end
770
+ end
771
+
772
+ def materialize_skill_backed_step(step)
773
+ return step unless step.is_a?(Hash)
774
+ return step if step["split_step_type"]
775
+
776
+ rendering = resolve_step_rendering(step)
777
+ return step unless rendering
778
+
779
+ rendered_instructions = render_skill_backed_step_instructions(
780
+ step: step,
781
+ rendering: rendering
782
+ )
783
+
784
+ materialized = step.merge(
785
+ "instructions" => rendered_instructions,
786
+ "workflow" => rendering["workflow"]
787
+ )
788
+ materialized["source_skill"] = rendering["source_skill"] || rendering["skill"] if rendering["source_skill"] || rendering["skill"]
789
+ materialized["source_workflow"] = rendering["workflow"] if rendering["workflow"] && !rendering["workflow"].empty?
790
+ materialized.delete("skill")
791
+ materialized
792
+ end
793
+
794
+ def resolve_step_rendering(step)
795
+ explicit_workflow = step["workflow"]&.to_s&.strip
796
+ if explicit_workflow && !explicit_workflow.empty?
797
+ canonical_step = find_step_definition(step["name"]&.to_s)
798
+ source_skill = step["source_skill"]&.to_s&.strip
799
+ source_skill = canonical_step&.dig("source_skill") if source_skill.nil? || source_skill.empty?
800
+ rendering = skill_source_resolver.resolve_workflow_rendering(
801
+ explicit_workflow,
802
+ step_name: step["name"]&.to_s,
803
+ source_skill: source_skill
804
+ )
805
+ return canonical_step ? canonical_step.merge(rendering || {}) : rendering if rendering
806
+ end
807
+
808
+ explicit_skill = step["skill"]&.to_s&.strip
809
+ if explicit_skill && !explicit_skill.empty?
810
+ return skill_source_resolver.resolve_skill_rendering(explicit_skill)
811
+ end
812
+
813
+ skill_source_resolver.resolve_step_rendering(step["name"]&.to_s)
814
+ end
815
+
816
+ def render_skill_backed_step_instructions(step:, rendering:)
817
+ if step_render_mode(rendering) == "step_template"
818
+ return render_step_template_instructions(step: step, rendering: rendering)
819
+ end
820
+
821
+ sections = []
822
+
823
+ task_ref = extract_parent_taskref(step, step["instructions"])
824
+ task_context = (task_ref && !task_ref.empty?) ? "Task reference: #{task_ref}" : compact_task_context(normalize_instructions(step["instructions"]), task_ref: task_ref)
825
+ sections << "Task context:\n#{task_context}" unless task_context.empty?
826
+
827
+ body = rendering["body"].to_s.strip
828
+ sections << body unless body.empty?
829
+
830
+ assignment_notes = assignment_specific_notes(
831
+ step_name: step["name"]&.to_s,
832
+ instructions: step["instructions"]
833
+ )
834
+ sections << "Assignment-specific context:\n#{assignment_notes}" unless assignment_notes.empty?
835
+
836
+ sections.join("\n\n")
837
+ end
838
+
839
+ def render_step_template_instructions(step:, rendering:)
840
+ sections = []
841
+
842
+ task_ref = extract_parent_taskref(step, step["instructions"])
843
+ task_context = (task_ref && !task_ref.empty?) ? "Task reference: #{task_ref}" : compact_task_context(normalize_instructions(step["instructions"]), task_ref: task_ref)
844
+ sections << "Task context:\n#{task_context}" unless task_context.empty?
845
+
846
+ description = rendering["description"].to_s.strip
847
+ sections << "Step focus:\n#{description}" unless description.empty?
848
+
849
+ steps = render_step_template_steps(rendering["steps"])
850
+ sections << "Steps:\n#{steps}" unless steps.empty?
851
+
852
+ skip_guidance = render_step_template_skip_guidance(rendering["when_to_skip"])
853
+ sections << "Skip when:\n#{skip_guidance}" unless skip_guidance.empty?
854
+
855
+ assignment_notes = assignment_specific_notes(
856
+ step_name: step["name"]&.to_s,
857
+ instructions: step["instructions"]
858
+ )
859
+ sections << "Assignment-specific context:\n#{assignment_notes}" unless assignment_notes.empty?
860
+
861
+ sections.join("\n\n")
862
+ end
863
+
864
+ def render_step_template_steps(steps)
865
+ Array(steps).filter_map do |step|
866
+ next unless step.is_a?(Hash)
867
+
868
+ description = step["description"]&.to_s&.strip
869
+ next if description.nil? || description.empty?
870
+
871
+ line = "- #{description}"
872
+ conditional = step["conditional"]&.to_s&.strip
873
+ note = step["note"]&.to_s&.strip
874
+ line += " If #{conditional}." unless conditional.nil? || conditional.empty?
875
+ line += " #{note}" unless note.nil? || note.empty?
876
+ line
877
+ end.join("\n")
878
+ end
879
+
880
+ def render_step_template_skip_guidance(conditions)
881
+ Array(conditions).filter_map do |condition|
882
+ text = condition&.to_s&.strip
883
+ next if text.nil? || text.empty?
884
+
885
+ "- #{text}"
886
+ end.join("\n")
887
+ end
888
+
889
+ def step_render_mode(rendering)
890
+ mode = rendering["render"]&.to_s&.strip
891
+ return "workflow_body" if mode.nil? || mode.empty?
892
+
893
+ mode
894
+ end
895
+
896
+ def assignment_specific_notes(step_name:, instructions:)
897
+ text = normalize_instructions(instructions).strip
898
+ return "" if text.empty?
899
+
900
+ filtered = text.lines.filter_map do |line|
901
+ normalized = normalize_assignment_overlay_line(line)
902
+ next if normalized.nil? || normalized.empty?
903
+ next if normalized.start_with?("Task reference:", "Task request:")
904
+
905
+ normalized
906
+ end
907
+
908
+ if step_name == "work-on-task"
909
+ filtered = filtered.reject do |line|
910
+ line.start_with?("Implement task ", "When complete, mark the task as done:")
911
+ end
912
+ end
913
+
914
+ filtered = filtered.uniq
915
+ filtered.map { |line| "- #{line}" }.join("\n")
916
+ end
917
+
918
+ def normalize_assignment_overlay_line(line)
919
+ stripped = line.to_s.strip
920
+ return nil if stripped.empty?
921
+ return nil if stripped == "Task context:" || stripped == "Assignment-specific context:"
922
+
923
+ stripped = stripped.sub(/\A-\s*/, "")
924
+ stripped = stripped.sub(/\A-\s*/, "")
925
+ stripped.strip
926
+ end
927
+
928
+ def resolve_step_assign_config(step)
929
+ explicit_workflow = step["workflow"]&.to_s&.strip
930
+ if explicit_workflow && !explicit_workflow.empty?
931
+ return skill_source_resolver.resolve_workflow_assign_config(
932
+ explicit_workflow,
933
+ step_name: step["name"]&.to_s,
934
+ source_skill: step["source_skill"]&.to_s
935
+ )
936
+ end
937
+
938
+ skill_name = step["skill"]&.to_s
939
+ return nil if skill_name.nil? || skill_name.empty?
940
+
941
+ skill_source_resolver.resolve_assign_config(skill_name)
942
+ end
943
+
944
+ def pre_commit_review_action_instructions(task_hint:)
945
+ subtree_cfg = normalized_subtree_config
946
+ allowlist = subtree_cfg[:native_review_clients]
947
+ allowlist_text = allowlist.empty? ? "<none>" : allowlist.join(", ")
948
+
949
+ lines = []
950
+ lines << "- Resolve subtree review config#{task_hint}: pre_commit_review=#{subtree_cfg[:pre_commit_review]}, mode=#{subtree_cfg[:pre_commit_review_provider]}, block=#{subtree_cfg[:pre_commit_review_block]}."
951
+ if subtree_cfg[:pre_commit_review] == false || subtree_cfg[:pre_commit_review_provider] == "skip"
952
+ lines << "- Pre-commit review is disabled by config; mark this step skipped with the config reason and continue."
953
+ return lines.join("\n")
954
+ end
955
+
956
+ lines << "- Detect active client/provider from fork session metadata first (`.ace-local/assign/<assignment-id>/sessions/<fork-root>-session.yml`, key: provider)."
957
+ lines << "- If session metadata is unavailable, fallback to `execution.provider` from assign config."
958
+ lines << "- Allowed native review clients: #{allowlist_text}."
959
+ lines << "- If detected client is allowed and mode is `auto` or `native`, review uncommitted changes and find issues (use the `/review` agent slash command — this is a conversation command, NOT a bash command)."
960
+ lines << "- If the `/review` agent command is not available in the current execution environment, run `ace-lint` on modified files as a fallback quality gate, then continue."
961
+ lines << "- Summarize findings with severity counts and keep raw output when structure is incomplete."
962
+ lines << "- If `pre_commit_review_block` is true and a critical finding is confidently detected, fail this step with evidence to block release."
963
+ lines.join("\n")
964
+ end
965
+
966
+ def normalized_subtree_config
967
+ subtree = Ace::Assign.config["subtree"]
968
+ subtree = {} unless subtree.is_a?(Hash)
969
+
970
+ config = {
971
+ pre_commit_review: subtree.key?("pre_commit_review") ? subtree["pre_commit_review"] : true,
972
+ pre_commit_review_provider: (subtree["pre_commit_review_provider"] || "auto").to_s,
973
+ pre_commit_review_block: subtree.key?("pre_commit_review_block") ? subtree["pre_commit_review_block"] : false,
974
+ native_review_clients: Array(subtree["native_review_clients"]).map(&:to_s).map(&:strip).reject(&:empty?)
975
+ }
976
+
977
+ if config[:pre_commit_review] && config[:native_review_clients].empty?
978
+ warn "[ace-assign] pre_commit_review enabled but native_review_clients is empty - review will always skip"
979
+ end
980
+
981
+ config
982
+ end
983
+
984
+ # Resolve task reference from explicit metadata first, then parent instruction text.
985
+ #
986
+ # @param parent_step [Hash]
987
+ # @param parent_instructions [String, Array<String>, nil]
988
+ # @return [String, nil]
989
+ def extract_parent_taskref(parent_step, parent_instructions)
990
+ explicit = parent_step["taskref"] || parent_step["task_ref"]
991
+ explicit_value = explicit.to_s.strip
992
+ return explicit_value unless explicit_value.empty?
993
+
994
+ parent_text = normalize_instructions(parent_instructions)
995
+ inferred = parent_text.scan(/\b\d+\.\d+\b/).uniq
996
+ return nil if inferred.empty?
997
+
998
+ inferred.join(", ")
999
+ end
1000
+
1001
+ # Lookup step definition from catalog by step name.
1002
+ #
1003
+ # @param step_name [String] Name of step
1004
+ # @return [Hash, nil] Catalog definition
1005
+ def find_step_definition(step_name)
1006
+ Atoms::CatalogLoader.find_by_name(step_catalog, step_name)
1007
+ end
1008
+
1009
+ # Load step catalog from project override or gem defaults.
1010
+ #
1011
+ # @return [Array<Hash>] Loaded step definitions
1012
+ def step_catalog
1013
+ @step_catalog ||= begin
1014
+ project_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
1015
+ gem_root = Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
1016
+
1017
+ project_catalog = File.join(project_root, ".ace", "assign", "catalog", "steps")
1018
+ default_catalog = File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps")
1019
+
1020
+ default_steps = Atoms::CatalogLoader.load_all(default_catalog)
1021
+ base_catalog = if File.directory?(project_catalog)
1022
+ project_steps = Atoms::CatalogLoader.load_all(project_catalog)
1023
+ merge_step_catalog(default_steps, project_steps)
1024
+ else
1025
+ default_steps
1026
+ end
1027
+
1028
+ canonical_steps = skill_source_resolver.assign_step_catalog
1029
+ merge_step_catalog(base_catalog, canonical_steps)
1030
+ end
1031
+ end
1032
+
1033
+ # Merge default and project step catalogs by step name.
1034
+ # Later definitions override earlier ones with matching names.
1035
+ #
1036
+ # @param default_steps [Array<Hash>]
1037
+ # @param project_steps [Array<Hash>]
1038
+ # @return [Array<Hash>]
1039
+ def merge_step_catalog(default_steps, project_steps)
1040
+ index = {}
1041
+ order = []
1042
+
1043
+ default_steps.each do |step|
1044
+ name = step["name"]
1045
+ next if name.nil? || name.empty?
1046
+
1047
+ index[name] = step
1048
+ order << name
1049
+ end
1050
+
1051
+ project_steps.each do |step|
1052
+ name = step["name"]
1053
+ next if name.nil? || name.empty?
1054
+
1055
+ order << name unless index.key?(name)
1056
+ index[name] = deep_merge_step_definition(index[name], step)
1057
+ end
1058
+
1059
+ order.map { |name| index[name] }.compact
1060
+ end
1061
+
1062
+ def deep_merge_step_definition(base, override)
1063
+ return override unless base.is_a?(Hash)
1064
+ return base unless override.is_a?(Hash)
1065
+
1066
+ merged = base.dup
1067
+ override.each do |key, value|
1068
+ merged[key] =
1069
+ if merged[key].is_a?(Hash) && value.is_a?(Hash)
1070
+ deep_merge_step_definition(merged[key], value)
1071
+ else
1072
+ value
1073
+ end
1074
+ end
1075
+ merged
1076
+ end
1077
+
1078
+ # Archive source config into the task's jobs/ directory.
1079
+ # If config is already in a jobs/ or steps/ directory, keeps it in place.
1080
+ # Otherwise moves job.yaml to <task>/jobs/<assignment_id>-job.yml for provenance.
1081
+ #
1082
+ # @param config_path [String] Path to the original job.yaml
1083
+ # @param assignment_id [String] Assignment identifier for filename prefix
1084
+ # @return [String] Path to archived file
1085
+ def archive_source_config(config_path, assignment_id)
1086
+ expanded_path = File.expand_path(config_path)
1087
+ parent_dir = File.dirname(expanded_path)
1088
+
1089
+ # Keep pre-rendered hidden/job specs and legacy step archives stable.
1090
+ return expanded_path if %w[jobs steps].include?(File.basename(parent_dir))
1091
+
1092
+ # Otherwise, move to task's jobs/ directory.
1093
+ jobs_dir = File.join(parent_dir, "jobs")
1094
+ FileUtils.mkdir_p(jobs_dir)
1095
+
1096
+ dest = File.join(jobs_dir, "#{assignment_id}-job.yml")
1097
+ FileUtils.mv(expanded_path, dest)
1098
+ dest
1099
+ end
1100
+
1101
+ def rebalance_after_child_injection(assignment:, state:, parent_number:)
1102
+ current = state.current
1103
+ return unless current && current.number == parent_number
1104
+
1105
+ step_writer.mark_pending(current.file_path)
1106
+ rebalanced_state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
1107
+ next_step = rebalanced_state.next_workable_in_subtree(parent_number)
1108
+ step_writer.mark_in_progress(next_step.file_path) if next_step
1109
+ end
1110
+
1111
+ # Normalize instructions to a string.
1112
+ # Accepts arrays (joined with newlines) or strings (returned as-is).
1113
+ #
1114
+ # @param instructions [Array<String>, String, nil] Raw instructions from config
1115
+ # @return [String] Normalized instruction text
1116
+ def normalize_instructions(instructions)
1117
+ return "" if instructions.nil?
1118
+
1119
+ instructions.is_a?(Array) ? instructions.join("\n") : instructions.to_s
1120
+ end
1121
+
1122
+ def find_target_step_for_start(state, step_number, fork_root)
1123
+ target = state.find_by_number(step_number)
1124
+ raise StepErrors::NotFound, "Step #{step_number} not found in queue" unless target
1125
+
1126
+ if fork_root && !fork_root.empty?
1127
+ raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
1128
+ raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}." unless state.in_subtree?(fork_root, target.number)
1129
+ end
1130
+ raise StepErrors::InvalidState, "Cannot start step #{target.number}: status is #{target.status}, expected pending." unless target.status == :pending
1131
+ if state.has_incomplete_children?(target.number)
1132
+ raise StepErrors::InvalidState, "Cannot start step #{target.number}: has incomplete children."
1133
+ end
1134
+
1135
+ target
1136
+ end
1137
+
1138
+ def find_target_step_for_finish(state, step_number, fork_root)
1139
+ fork_root = fork_root&.strip
1140
+ if step_number && !step_number.to_s.strip.empty?
1141
+ target = state.find_by_number(step_number)
1142
+ raise StepErrors::NotFound, "Step #{step_number} not found in queue" unless target
1143
+ if fork_root && !fork_root.empty? && !state.in_subtree?(fork_root, target.number)
1144
+ raise StepErrors::InvalidState, "Step #{target.number} is outside scoped subtree #{fork_root}."
1145
+ end
1146
+ raise StepErrors::InvalidState, "Cannot finish step #{target.number}: status is #{target.status}, expected in_progress." unless target.status == :in_progress
1147
+
1148
+ return target
1149
+ end
1150
+
1151
+ current = state.current
1152
+ if fork_root && !fork_root.empty?
1153
+ raise StepErrors::NotFound, "Subtree root #{fork_root} not found in assignment." unless state.find_by_number(fork_root)
1154
+ active_in_subtree = state.in_progress_in_subtree(fork_root)
1155
+ if active_in_subtree.size > 1
1156
+ active_refs = active_in_subtree.map { |step| "#{step.number}(#{step.name})" }.join(", ")
1157
+ raise StepErrors::InvalidState, "Cannot finish in subtree #{fork_root}: multiple steps are in progress (#{active_refs})."
1158
+ end
1159
+ if current.nil? || !state.in_subtree?(fork_root, current.number)
1160
+ current = state.current_in_subtree(fork_root)
1161
+ end
1162
+ return nil if current.nil?
1163
+ end
1164
+
1165
+ current
1166
+ end
1167
+
1168
+ # Auto-complete parent steps when all their children are done.
1169
+ # Walks up the hierarchy marking parents as done, handling multi-level
1170
+ # completion in a single pass (grandparents become eligible when parents complete).
1171
+ #
1172
+ # @param state [Models::QueueState] Current queue state
1173
+ # @param assignment [Models::Assignment] Current assignment
1174
+ def auto_complete_parents(state, assignment)
1175
+ completed_any = true
1176
+ # Track completed step numbers in this pass (avoids fragile ivar mutation)
1177
+ completed_this_pass = Set.new
1178
+
1179
+ # Safety guard: max iterations = total steps to prevent infinite loops
1180
+ max_iterations = state.steps.size
1181
+
1182
+ # Loop until no more parents can be completed
1183
+ # This handles multi-level hierarchies where completing a parent
1184
+ # makes the grandparent eligible for completion
1185
+ iterations = 0
1186
+ while completed_any && iterations < max_iterations
1187
+ iterations += 1
1188
+ completed_any = false
1189
+
1190
+ # Find all pending/in_progress parent steps that have children
1191
+ eligible_parents = state.steps.select do |s|
1192
+ (s.status == :pending || s.status == :in_progress) &&
1193
+ !completed_this_pass.include?(s.number)
1194
+ end
1195
+
1196
+ eligible_parents.each do |step|
1197
+ children = state.children_of(step.number)
1198
+ next if children.empty?
1199
+
1200
+ # If all children are done (or completed this pass), mark parent as done too
1201
+ all_done = children.all? do |c|
1202
+ c.status == :done || completed_this_pass.include?(c.number)
1203
+ end
1204
+
1205
+ if all_done
1206
+ step_writer.mark_done(
1207
+ step.file_path,
1208
+ report_content: "Auto-completed: all child steps finished.",
1209
+ reports_dir: assignment.reports_dir
1210
+ )
1211
+ completed_this_pass << step.number
1212
+ completed_any = true
1213
+ end
1214
+ end
1215
+ end
1216
+
1217
+ # Warn if safety limit was reached while still completing parents
1218
+ if iterations >= max_iterations && completed_any
1219
+ warn "[ace-assign] Warning: auto_complete_parents reached iteration limit (#{max_iterations}). " \
1220
+ "Some parent steps may not have been auto-completed."
1221
+ end
1222
+ end
1223
+
1224
+ # Find the next step to work on using hierarchical rules.
1225
+ #
1226
+ # @param state [Models::QueueState] Current queue state
1227
+ # @param completed_number [String] Number of just-completed step
1228
+ # @return [Models::Step, nil] Next step to work on
1229
+ def find_next_step(state, completed_number)
1230
+ # First priority: pending children of the completed step
1231
+ children = state.children_of(completed_number)
1232
+ pending_child = children.find { |c| c.status == :pending }
1233
+ return pending_child if pending_child
1234
+
1235
+ # Second priority: next workable step (respects hierarchy)
1236
+ # Uses next_workable to skip parents that have incomplete children
1237
+ state.next_workable
1238
+ end
1239
+
1240
+ # Find next step within a constrained subtree.
1241
+ #
1242
+ # @param state [Models::QueueState] Current queue state
1243
+ # @param completed_number [String] Number of just-completed step
1244
+ # @param root_number [String] Root of fork-scoped subtree
1245
+ # @return [Models::Step, nil] Next step in subtree, or nil when subtree done
1246
+ def find_next_step_in_subtree(state, completed_number, root_number)
1247
+ # First priority: pending direct children of completed step within subtree
1248
+ children = state.children_of(completed_number)
1249
+ pending_child = children.find { |c| c.status == :pending && state.in_subtree?(root_number, c.number) }
1250
+ return pending_child if pending_child
1251
+
1252
+ # Second priority: next workable step within subtree
1253
+ state.next_workable_in_subtree(root_number)
1254
+ end
1255
+
1256
+ # Calculate insertion point for a new step.
1257
+ #
1258
+ # @param after [String, nil] Insert after this step number
1259
+ # @param as_child [Boolean] Insert as child (true) or sibling (false)
1260
+ # @param state [Models::QueueState] Current queue state
1261
+ # @param existing_numbers [Array<String>] Existing step numbers
1262
+ # @return [Array<String, Array>] [new_number, steps_to_renumber]
1263
+ def calculate_insertion_point(after:, as_child:, state:, existing_numbers:)
1264
+ if after
1265
+ if as_child
1266
+ # Insert as first child of 'after'
1267
+ new_number = Atoms::StepNumbering.next_child(after, existing_numbers)
1268
+ [new_number, []]
1269
+ else
1270
+ # Insert as sibling after 'after'
1271
+ new_number = Atoms::StepNumbering.next_sibling(after)
1272
+
1273
+ # Check if this number already exists
1274
+ if existing_numbers.include?(new_number)
1275
+ # Need to renumber
1276
+ renumber_list = Atoms::StepNumbering.steps_to_renumber(new_number, existing_numbers)
1277
+ [new_number, renumber_list]
1278
+ else
1279
+ [new_number, []]
1280
+ end
1281
+ end
1282
+ else
1283
+ # Default behavior: insert after current or last done
1284
+ base_number = if state.current
1285
+ state.current.number
1286
+ elsif state.last_done
1287
+ state.last_done.number
1288
+ else
1289
+ "000" # Will generate 001
1290
+ end
1291
+
1292
+ new_number = Atoms::NumberGenerator.next_after(base_number, existing_numbers)
1293
+ [new_number, []]
1294
+ end
1295
+ end
1296
+ end
1297
+ end
1298
+ end
1299
+ end