ace-assign 0.41.5 → 0.42.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ddca0a9931586c159c1992b8d9f81c1fbae353632e05663695a374635adc2bc
4
- data.tar.gz: '037835071ab79291750ae81850a223a7daaca927ca905b3ab90fd5a2b3a19396'
3
+ metadata.gz: 214f383018bb2b9861e03cedf52c8eca4df627d9787357d00998725be36cbe36
4
+ data.tar.gz: 5f8d577f6f0eacd399c2a317d8dee720f237194af70968b65fcca8a1de676dea
5
5
  SHA512:
6
- metadata.gz: 8fb182a82b08aa5716585310b381ffb6a0d64de277d63a1113fa74725e7a2aae40a8d527d3a4a59493680b4c0045a3dc61392483943573bb38f2d568fe3ad480
7
- data.tar.gz: 4628c3a6060877ef0b1ffdb3c6e87c6686f3ec4d9f5b951dbda3c8432828d0a842c2c77ba45cdafbe46827548fc3086e5b48239d2d5077659d3212fec7944a86
6
+ metadata.gz: 3be4c93c1264e83f808bd0fed4d92780a65e00da59561c428bf3d7a6f65ce63c21398d256113d601e3849ba25dc546c1eb841e6dbf9db21804809c3afac35239
7
+ data.tar.gz: daab1a233b937ef79fa3d3b406404350b6b34ca0ade254cb7ff96b6f11c810f0c7dc255fe271e8e00cd8c194c6a941330ed8af725e22d49dfcceb0c627ad1ddf
@@ -20,7 +20,7 @@ instructions: |
20
20
 
21
21
  1. Run the status update command:
22
22
  ```bash
23
- ace-task update {{taskref}} --set status=done --move-to archive
23
+ ace-task update {{taskref}} --set status=done --move-to archive --git-commit
24
24
  ```
25
25
 
26
26
  2. **VERIFY the status change** (do not skip):
@@ -38,16 +38,16 @@ instructions: |
38
38
  ```
39
39
  If all subtasks show `✓` (done), mark the parent done too:
40
40
  ```bash
41
- ace-task update {{parent_taskref}} --set status=done --move-to archive
41
+ ace-task update {{parent_taskref}} --set status=done --move-to archive --git-commit
42
42
  ace-task show {{parent_taskref}}
43
43
  ```
44
44
  Repeat upward if the parent itself has a parent.
45
45
 
46
- 4. **Commit the task file moves:**
46
+ 4. **Optional validation**: verify the moved task is now archived and complete:
47
47
  ```bash
48
- ace-git-commit .ace-tasks/ -i "archive completed tasks"
48
+ ace-task show {{taskref}}
49
+ ace-task show {{parent_taskref}}
49
50
  ```
50
- The `--move-to archive` flag relocates task files. This commit captures those moves.
51
51
 
52
52
  when_to_skip:
53
53
  - "Task was already marked done (verified via ace-task show)"
@@ -5,10 +5,6 @@ description: Analyze task requirements and create an implementation plan
5
5
  produces: [implementation-plan]
6
6
  consumes: [project-base-context, task-spec]
7
7
 
8
- context:
9
- default: fork
10
- reason: "Planning benefits from focused exploration"
11
-
12
8
  when_to_skip:
13
9
  - "Task is trivial and doesn't need a plan"
14
10
  - "Implementation plan already exists"
@@ -10,10 +10,6 @@ prerequisites:
10
10
  produces: [review-feedback]
11
11
  consumes: [pull-request]
12
12
 
13
- context:
14
- default: fork
15
- reason: "Review benefits from isolated analysis without implementation bias"
16
-
17
13
  when_to_skip:
18
14
  - "No code changes since last review"
19
15
  - "Changes are trivial (typo fix, config update)"
@@ -11,10 +11,6 @@ intent:
11
11
  produces: [code-changes, commits]
12
12
  consumes: [project-base-context, task-spec]
13
13
 
14
- context:
15
- default: fork
16
- reason: "Substantial implementation benefits from focused agent"
17
-
18
14
  when_to_skip:
19
15
  - "Task is documentation-only"
20
16
  - "Changes are already implemented"
@@ -35,14 +35,3 @@ subtree:
35
35
  native_review_clients:
36
36
  - "claude"
37
37
  - "codex"
38
-
39
- # Provider-level defaults for CLI invocation
40
- providers:
41
- cli:
42
- - "claude"
43
- - "claudeoai"
44
- - "gemini"
45
- - "codex"
46
- - "codexoss"
47
- - "opencode"
48
- - "pi"
@@ -162,12 +162,7 @@ steps:
162
162
  - `TASKREFS="{{taskrefs}}"`
163
163
  - `IFS=',' read -ra refs <<< "${TASKREFS// /}"`
164
164
  - For each `ref`:
165
- - `ace-task update "$ref" --set status=done --move-to archive`
166
- - |
167
- After all tasks are archived, commit the file moves:
168
- ```bash
169
- ace-git-commit .ace-tasks/ -i "archive completed tasks"
170
- ```
165
+ - `ace-task update "$ref" --set status=done --move-to archive --gc`
171
166
 
172
167
  - name: create-retro
173
168
  number: "160"
data/CHANGELOG.md CHANGED
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.42.4] - 2026-04-05
11
+
12
+ ### Fixed
13
+ - Scoped canonical `skill://` and `wfi://` source discovery to in-project defaults and explicitly registered external sources so ambient installed gems no longer leak into assign resolution.
14
+ - Preserved child `skill` metadata only for hand-authored explicit split sub-steps while keeping inferred and preset-expanded canonical sub-steps fully materialized.
15
+
16
+ ## [0.42.3] - 2026-04-02
17
+
18
+ ### Changed
19
+ - Updated HITL guidance wording in `ace-assign status` and assignment workflow/docs to use canonical "event" terminology (`Review event`, `HITL event`).
20
+
21
+ ### Technical
22
+ - Refreshed status command test expectations for the updated HITL wording contract.
23
+
24
+ ## [0.42.2] - 2026-04-02
25
+
26
+ ### Technical
27
+ - Updated HITL stall-path fixture expectations in status command coverage from `.ace-hitl/...` to `.ace-local/hitl/...` to match the new default HITL root.
28
+
29
+ ## [0.42.1] - 2026-04-02
30
+
31
+ ### Changed
32
+ - Updated `wfi://assign/drive` HITL guidance to use per-item polling (`ace-hitl wait <id>`) as the default requester path and `ace-hitl update --resume` as fallback dispatch.
33
+
34
+ ### Fixed
35
+ - Updated `ace-assign status` HITL operator guidance output to display polling-first and fallback resume commands aligned with the current HITL contract.
36
+
37
+ ### Technical
38
+ - Refreshed command-level status tests for the new HITL guidance text contract.
39
+
40
+ ## [0.42.0] - 2026-04-01
41
+
42
+ ### Changed
43
+ - Added a dedicated HITL stall protocol to `wfi://assign/drive`, documenting `ace-hitl` create/list/show/update usage, canonical `ace-assign fail --message "HITL: <id> <path>"` formatting, and resume/archive flow without reintroducing gate-state mechanics.
44
+
45
+ ### Fixed
46
+ - `ace-assign status` now detects HITL-formatted stall reasons and prints direct operator guidance for `ace-hitl show <id>` plus stored-path hints.
47
+
48
+ ### Technical
49
+ - Added command-level status coverage for HITL and non-HITL stall reason rendering to prevent regressions in current-step guidance output.
50
+
51
+ ## [0.41.12] - 2026-04-01
52
+
53
+ ### Fixed
54
+ - Restored parent-only fork boundaries for generated split subtrees by preventing child steps from inheriting fork context defaults when the parent step is the fork root.
55
+ - Removed default fork context declarations from canonical `plan-task`, `work-on-task`, and `review-pr` catalog steps so only explicitly fork-root parent steps run in forked context.
56
+
57
+ ### Technical
58
+ - Added regression coverage for symbolized/string fork-context normalization in child-step materialization paths.
59
+
60
+ ## [0.41.11] - 2026-04-01
61
+
62
+ ### Fixed
63
+ - Restored parent-only fork semantics for split-subtree execution by preventing canonical child-step `context: fork` defaults from being materialized onto generated subtree children (notably `plan-task` and `work-on-task`).
64
+ - Updated workflow-backed child-step rendering to strip inherited fork context/provider metadata when the child did not explicitly declare fork settings.
65
+
66
+ ### Technical
67
+ - Added regression coverage for split-subtree child materialization and scoped status output to ensure child `plan-task` does not emit fork-execution guidance.
68
+
69
+ ## [0.41.10] - 2026-03-31
70
+
71
+ ### Changed
72
+ - Role-based assignment execution defaults.
73
+
74
+ ## [0.41.9] - 2026-03-31
75
+
76
+ ### Changed
77
+ - Updated task archival commands to persist task-state transitions during workflow execution using `--gc`/`--git-commit` modes in terminal task completion steps.
78
+
79
+ ## [0.41.8] - 2026-03-30
80
+
81
+ ### Fixed
82
+ - Clarified the shipped `wfi://release/publish` workflow release contract so root changelog updates consistently include package versions and RubyGems propagation proof guidance.
83
+
84
+ ## [0.41.7] - 2026-03-30
85
+
86
+ ### Fixed
87
+ - Made the shipped `wfi://release/publish` workflow verify modified packages with package-scoped `ace-test` execution derived from the resolved release set.
88
+
89
+ ## [0.41.6] - 2026-03-29
90
+
91
+ ### Added
92
+ - Added a shipped generic `wfi://release/publish` workflow under `ace-assign` so `work-on-task` release steps have a default resolvable path in plain projects.
93
+
94
+ ### Fixed
95
+ - Aligned `assign.source` `wfi://` resolution with registered/default nav workflow sources by removing implicit workspace workflow-directory fallback behavior.
96
+ - Added resolver coverage for project-level `wfi://` workflow overrides and for unregistered-workflow failure behavior.
97
+
10
98
  ## [0.41.5] - 2026-03-29
11
99
 
12
100
  ### Technical
data/README.md CHANGED
@@ -78,6 +78,8 @@ The easiest way to start is through [ace-overseer](../ace-overseer) -- define a
78
78
 
79
79
  **Define assignments from presets** - pick a [preset](.ace-defaults/assign/presets/) like [`work-on-task`](.ace-defaults/assign/presets/work-on-task.yml) or `release-only`, pass parameters (task refs, packages), and run `ace-assign create --task ...` or [`ace-assign create --yaml ...`](docs/usage.md) to expand them into a concrete step queue. Steps are defined in the [catalog](.ace-defaults/assign/catalog/steps/) (e.g., [`work-on-task.step.yml`](.ace-defaults/assign/catalog/steps/work-on-task.step.yml)) and ordered by [composition rules](.ace-defaults/assign/catalog/composition-rules.yml). Compose custom assignments with `/as-assign-compose`.
80
80
 
81
+ `work-on-task` release steps resolve `wfi://release/publish` from shipped workflow sources by default, and project-level `wfi://` source overrides registered under `.ace/nav/protocols/wfi-sources/` are honored by both `ace-bundle` and `ace-assign`.
82
+
81
83
  **Run with orchestrator and fork agents** - use `/as-assign-drive` to walk through steps, forking long-running work (implementation, review, release) to isolated agent subprocesses with configurable [execution defaults](.ace-defaults/assign/config.yml) or per-step `fork.provider` overrides. Forks can run sequentially or as parallel batches, each producing inspectable traces and session reports under `.ace-local/assign/`.
82
84
 
83
85
  **Recover from failure without losing history** - keep failed-step lineage intact, inject targeted retries or fix steps, and continue execution with auditable failure evidence.
data/docs/usage.md CHANGED
@@ -1,10 +1,11 @@
1
1
  ---
2
2
  doc-type: user
3
3
  title: ace-assign Usage Guide
4
- purpose: Complete command reference for ace-assign queue orchestration, hierarchy, and fork execution.
4
+ purpose: Complete command reference for ace-assign queue orchestration, hierarchy,
5
+ and fork execution.
5
6
  ace-docs:
6
- last-updated: 2026-03-26
7
- last-checked: 2026-03-26
7
+ last-updated: '2026-04-01'
8
+ last-checked: '2026-04-01'
8
9
  ---
9
10
 
10
11
  # ace-assign Usage Guide
@@ -92,6 +93,18 @@ Options:
92
93
  - `--quiet, -q`
93
94
  - `--debug, -d`
94
95
 
96
+ HITL stall behavior:
97
+
98
+ - Canonical contract lives in `wfi://hitl` (`ace-hitl` package workflow).
99
+ - If a step is failed with canonical message format `HITL: <id> <path>`, `ace-assign status` prints operator guidance with the matching `ace-hitl show <id>` command and available path hint.
100
+ - Recommended resume flow:
101
+ - `ace-hitl show <id>`
102
+ - requester path (default): `ace-hitl wait <id>`
103
+ - fallback path (when waiter inactive): `ace-hitl update <id> --answer "<decision>" --resume`
104
+ - `ace-assign retry <failed-step> --assignment <assignment-id>`
105
+ - Completion-attention flow:
106
+ - When assignment work is complete but explicit user action is needed, create an approval HITL event (`kind=approval`) and include the resume instruction for `/as-assign-drive <assignment-id>`.
107
+
95
108
  ### `ace-assign start [STEP]`
96
109
 
97
110
  Start next workable pending step, or an explicit pending step in the active assignment.
@@ -437,6 +437,41 @@ For external-facing steps (for example PR/review/release/push/update lifecycle s
437
437
  ace-assign fail --message "Command failed: <cmd>. Error: <exact stderr>" --assignment "$ASSIGNMENT_TARGET"
438
438
  ```
439
439
 
440
+ ### Human-in-the-Loop (HITL) Stall Protocol
441
+
442
+ Canonical source of truth: `wfi://hitl`.
443
+
444
+ Use HITL when:
445
+
446
+ - the active step is blocked by human judgment (ambiguity, product decision, policy choice), or
447
+ - the assignment is complete but explicit user attention is required before next action.
448
+
449
+ For a blocked step:
450
+
451
+ 1. Create a HITL event with assignment and step context:
452
+ ```bash
453
+ ace-hitl create "Need product decision" --question "Should retries be visible?" --assignment <id> --step <number> --step-name <name> --resume "/as-assign-drive <id>"
454
+ ```
455
+ 2. Fail the step using canonical stall format:
456
+ ```bash
457
+ ace-assign fail --message "HITL: <hitl-id> <hitl-path>" --assignment "$ASSIGNMENT_TARGET"
458
+ ```
459
+ 3. Human/operator resolves:
460
+ ```bash
461
+ ace-hitl show <hitl-id>
462
+ ace-hitl update <hitl-id> --answer "Yes, show retries in user-facing output."
463
+ ace-hitl wait <hitl-id>
464
+ ```
465
+ 4. Discover pending HITL work:
466
+ - Main checkout default (smart local-first): `ace-hitl list`
467
+ - Explicit scope controls: `ace-hitl list --scope current` and `ace-hitl list --scope all`
468
+ 5. Polling is default: requesting agent waits on its own HITL id (`ace-hitl wait <hitl-id>`), not global queues.
469
+ 6. Resume dispatch is fallback: if waiter is no longer active, run:
470
+ ```bash
471
+ ace-hitl update <hitl-id> --answer "<decision>" --resume
472
+ ```
473
+ 7. On retry/resume, read the answer from the HITL event and continue normal fail/retry mechanics. Do not introduce gate phases, assignment-level paused state, or extra resume commands in `ace-assign`.
474
+
440
475
  ### 5. Write Report (Only After Real Execution)
441
476
 
442
477
  After completing the step work, write a brief report summarizing what was accomplished:
@@ -536,6 +571,12 @@ Summarize the assignment results to the user:
536
571
  - Any artifacts created (PRs, commits, etc.)
537
572
  - Next steps or follow-up actions
538
573
 
574
+ If completion requires explicit user action/decision, create an approval HITL event:
575
+
576
+ ```bash
577
+ ace-hitl create "Review completed assignment results" --kind approval --question "Please confirm next action for <assignment-id>." --assignment <assignment-id> --step completion --step-name assignment-complete --resume "/as-assign-drive <assignment-id>"
578
+ ```
579
+
539
580
  ## Skill Invocation Pattern
540
581
 
541
582
  When executing a step with a `skill:` field:
@@ -663,4 +704,4 @@ $ ace-assign finish --message task-done.md --assignment "$ASSIGNMENT_TARGET"
663
704
  # 7. Eventually...
664
705
  $ ace-assign status --assignment "$ASSIGNMENT_TARGET"
665
706
  All steps complete!
666
- ```
707
+ ```
@@ -233,7 +233,8 @@ module Ace
233
233
  # @param parameters [Hash] Parameter values
234
234
  # @return [Hash] Step with substituted values
235
235
  def self.substitute_parameters(step, parameters)
236
- substitute_value(step, parameters)
236
+ substituted = substitute_value(step, parameters)
237
+ mark_preset_sub_steps_origin(substituted)
237
238
  end
238
239
  private_class_method :substitute_parameters
239
240
 
@@ -253,6 +254,16 @@ module Ace
253
254
  end
254
255
  private_class_method :substitute_value
255
256
 
257
+ def self.mark_preset_sub_steps_origin(step)
258
+ return step unless step.is_a?(Hash)
259
+
260
+ sub_steps = step["sub_steps"] || step["sub-steps"]
261
+ return step unless sub_steps.is_a?(Array) && sub_steps.any?
262
+
263
+ step.merge("sub_steps_origin" => "preset")
264
+ end
265
+ private_class_method :mark_preset_sub_steps_origin
266
+
256
267
  # Substitute {{placeholder}} tokens in a string.
257
268
  #
258
269
  # @param text [String, nil] Text with placeholders
@@ -90,6 +90,7 @@ module Ace
90
90
  lines = current_for_display.stall_reason.to_s.strip.lines
91
91
  puts "Stall Reason: #{lines.first&.chomp}"
92
92
  lines[1..].each { |l| puts " #{l.chomp}" } if lines.length > 1
93
+ print_hitl_stall_guidance(lines.first.to_s)
93
94
  end
94
95
  if current_for_display.workflow
95
96
  puts "Workflow: #{current_for_display.workflow}"
@@ -369,6 +370,29 @@ module Ace
369
370
  puts
370
371
  end
371
372
 
373
+ def print_hitl_stall_guidance(first_line)
374
+ hitl = parse_hitl_stall_reason(first_line)
375
+ return unless hitl
376
+
377
+ puts "HITL Guidance:"
378
+ puts " Review event: ace-hitl show #{hitl[:id]}"
379
+ puts " Stored path: #{hitl[:path]}" if hitl[:path]
380
+ puts " Requester default: ace-hitl wait #{hitl[:id]}"
381
+ puts " Fallback dispatch: ace-hitl update #{hitl[:id]} --answer \"<decision>\" --resume"
382
+ end
383
+
384
+ def parse_hitl_stall_reason(line)
385
+ stripped = line.to_s.strip
386
+ return nil unless stripped.start_with?("HITL:")
387
+
388
+ payload = stripped.sub(/^HITL:\s*/, "")
389
+ id, path = payload.split(/\s+/, 2)
390
+ return nil if id.to_s.strip.empty?
391
+
392
+ path = path.to_s.strip
393
+ {id: id, path: path.empty? ? nil : path}
394
+ end
395
+
372
396
  # Print other assignments section
373
397
  def print_other_assignments(current_assignment_id, include_completed:)
374
398
  discoverer = Molecules::AssignmentDiscoverer.new
@@ -78,6 +78,7 @@ module Ace
78
78
  # Normalize to string without leading zeros for comparison
79
79
  normalized = number.to_s.sub(/^0+/, "")
80
80
  steps.find do |s|
81
+ next unless s.number
81
82
  s.number.sub(/^0+/, "") == normalized || s.number == number.to_s
82
83
  end
83
84
  end
@@ -25,12 +25,9 @@ module Ace
25
25
  canonical_paths = discover_canonical_skill_source_paths
26
26
  canonical_workflow_paths = discover_canonical_workflow_source_paths
27
27
  override_paths = normalize_paths(configured_skill_paths || [])
28
- if canonical_workflow_paths.empty? && (configured_workflow_paths.nil? || configured_workflow_paths.empty?)
29
- configured_workflow_paths = discover_workspace_workflow_paths
30
- end
31
- configured_workflow_paths = canonical_workflow_paths if configured_workflow_paths.nil? || configured_workflow_paths.empty?
28
+ configured_workflow_paths = normalize_paths(configured_workflow_paths || [])
32
29
  @skill_paths = (canonical_paths + override_paths).uniq
33
- @workflow_paths = (canonical_workflow_paths + normalize_paths(configured_workflow_paths || [])).uniq
30
+ @workflow_paths = (canonical_workflow_paths + configured_workflow_paths).uniq
34
31
  @skill_index = nil
35
32
  end
36
33
 
@@ -300,13 +297,16 @@ module Ace
300
297
  end
301
298
 
302
299
  def discover_protocol_source_paths(protocol:, package_glob:)
303
- registry_paths = Dir.chdir(project_root) do
304
- registry = Ace::Support::Nav::Molecules::SourceRegistry.new
300
+ registry_paths = with_project_root do
301
+ registry = Ace::Support::Nav::Molecules::SourceRegistry.new(start_path: project_root)
305
302
  registry.sources_for_protocol(protocol).filter_map do |source|
306
303
  next if source.config.is_a?(Hash) && source.config["enabled"] == false
307
304
 
308
305
  candidate = resolve_source_directory(source)
309
- File.directory?(candidate) ? candidate : nil
306
+ next unless File.directory?(candidate)
307
+ next if external_implicit_source?(source, candidate)
308
+
309
+ candidate
310
310
  rescue
311
311
  nil
312
312
  end
@@ -315,6 +315,12 @@ module Ace
315
315
  (registry_paths + discover_package_default_source_paths(package_glob)).uniq
316
316
  end
317
317
 
318
+ def with_project_root
319
+ Dir.chdir(project_root) { yield }
320
+ rescue Errno::ENOENT
321
+ yield
322
+ end
323
+
318
324
  def resolve_source_directory(source)
319
325
  candidate = source.full_path
320
326
  return candidate if File.directory?(candidate)
@@ -329,6 +335,23 @@ module Ace
329
335
  File.directory?(fallback) ? fallback : nil
330
336
  end
331
337
 
338
+ def external_implicit_source?(source, directory)
339
+ return false if path_within_project?(directory)
340
+ return false if explicit_registration?(source)
341
+
342
+ true
343
+ end
344
+
345
+ def explicit_registration?(source)
346
+ %w[project user].include?(source.origin.to_s)
347
+ end
348
+
349
+ def path_within_project?(path)
350
+ candidate = Pathname.new(File.expand_path(path))
351
+ root = Pathname.new(File.expand_path(project_root))
352
+ candidate == root || candidate.to_s.start_with?("#{root}/")
353
+ end
354
+
332
355
  def discover_package_default_source_paths(source_glob)
333
356
  source_files = Dir.glob(source_glob).sort
334
357
  source_files.filter_map do |source_file|
@@ -344,10 +367,6 @@ module Ace
344
367
  end
345
368
  end
346
369
 
347
- def discover_workspace_workflow_paths
348
- Dir.glob(File.join(project_root, "*", "handbook", "workflow-instructions")).sort
349
- end
350
-
351
370
  def resolve_source_uri(uri, skill_name)
352
371
  if uri.start_with?("wfi://")
353
372
  resolve_wfi_uri(uri, skill_name)
@@ -381,9 +381,6 @@ module Ace
381
381
  raise Error, "Child insertion requires an after step reference."
382
382
  end
383
383
 
384
- source_label = source_file.to_s.strip.empty? ? "batch" : File.basename(source_file.to_s)
385
- batch_added_by = "batch_from:#{source_label}"
386
-
387
384
  prevalidate_batch_trees!(steps)
388
385
 
389
386
  added_steps = []
@@ -395,7 +392,7 @@ module Ace
395
392
  step_config,
396
393
  after: as_child ? after : sibling_cursor,
397
394
  as_child: as_child,
398
- added_by: batch_added_by,
395
+ added_by: nil,
399
396
  location: "steps[#{index}]"
400
397
  )
401
398
  added_steps.concat(inserted[:added])
@@ -482,7 +479,11 @@ module Ace
482
479
  next step unless step.is_a?(Hash)
483
480
 
484
481
  sub_steps = step["sub_steps"] || step["sub-steps"]
485
- next step if sub_steps.is_a?(Array) && sub_steps.any?
482
+ if sub_steps.is_a?(Array) && sub_steps.any?
483
+ explicit = step.dup
484
+ explicit["sub_steps_origin"] ||= "explicit"
485
+ next explicit
486
+ end
486
487
 
487
488
  assign_config = resolve_step_assign_config(step)
488
489
  next step unless assign_config
@@ -490,7 +491,10 @@ module Ace
490
491
  resolved_sub_steps = assign_config[:sub_steps]
491
492
  next step unless resolved_sub_steps.is_a?(Array) && resolved_sub_steps.any?
492
493
 
493
- enriched = step.merge("sub_steps" => resolved_sub_steps)
494
+ enriched = step.merge(
495
+ "sub_steps" => resolved_sub_steps,
496
+ "sub_steps_origin" => "inferred"
497
+ )
494
498
  enriched["context"] ||= assign_config[:context] if assign_config[:context]
495
499
  enriched
496
500
  end
@@ -526,6 +530,7 @@ module Ace
526
530
  # Create split parent orchestration node
527
531
  parent_context = step["context"] || "fork"
528
532
  parent_instructions = step["instructions"]
533
+ sub_steps_origin = step["sub_steps_origin"] || "explicit"
529
534
  parent_step = build_split_parent_step(
530
535
  step: step,
531
536
  parent_number: parent_number,
@@ -543,7 +548,8 @@ module Ace
543
548
  parent_number: parent_number,
544
549
  parent_step: step,
545
550
  parent_instructions: parent_instructions,
546
- parent_context: parent_context
551
+ parent_context: parent_context,
552
+ sub_steps_origin: sub_steps_origin
547
553
  )
548
554
  end
549
555
  else
@@ -683,8 +689,9 @@ module Ace
683
689
  # @param parent_step [Hash] Parent step config
684
690
  # @param parent_instructions [String, Array<String>, nil] Parent instructions
685
691
  # @param parent_context [String, nil] Parent execution context
692
+ # @param sub_steps_origin [String] Whether the subtree was declared explicitly or inferred
686
693
  # @return [Hash] Child step config
687
- def build_child_sub_step(sub_name:, child_number:, parent_number:, parent_step:, parent_instructions:, parent_context:)
694
+ def build_child_sub_step(sub_name:, child_number:, parent_number:, parent_step:, parent_instructions:, parent_context:, sub_steps_origin: "inferred")
688
695
  step_def = find_step_definition(sub_name)
689
696
  parent_task_ref = extract_parent_taskref(parent_step, parent_instructions)
690
697
  instructions = if step_def&.dig("skill")
@@ -696,16 +703,18 @@ module Ace
696
703
  "number" => child_number,
697
704
  "name" => sub_name,
698
705
  "instructions" => instructions,
699
- "parent" => parent_number
706
+ "parent" => parent_number,
707
+ "sub_steps_origin" => sub_steps_origin
700
708
  }
701
709
  child["taskref"] = parent_task_ref if parent_task_ref
702
710
 
703
711
  if step_def
704
712
  child["workflow"] = step_def["workflow"] if step_def["workflow"]
705
- child["skill"] = step_def["skill"] if step_def["skill"] && !step_def["workflow"]
713
+ preserve_explicit_skill = (sub_steps_origin == "explicit")
714
+ child["skill"] = step_def["skill"] if step_def["skill"] && (preserve_explicit_skill || !step_def["workflow"])
706
715
 
707
716
  context_default = step_def.dig("context", "default")
708
- child["context"] = context_default if context_default && parent_context != "fork"
717
+ child["context"] = context_default if context_default && !fork_context_value?(parent_context)
709
718
  fork_context = step_def.dig("context", "fork")
710
719
  if child["context"] == "fork" && fork_context.is_a?(Hash) && !fork_context.empty?
711
720
  # Generated child sub-steps have no explicit frontmatter overrides.
@@ -852,18 +861,20 @@ module Ace
852
861
  "instructions" => rendered_instructions,
853
862
  "workflow" => rendering["workflow"]
854
863
  )
855
- context_default = rendering.dig("context", "default")
856
- materialized["context"] ||= context_default if context_default
857
- fork_context = rendering.dig("context", "fork")
858
- if materialized["context"] == "fork" && fork_context.is_a?(Hash) && !fork_context.empty?
859
- # For materialized explicit steps, preserve frontmatter-provided fork config
860
- # (`||=` semantics). Rendering contributes defaults only when the step
861
- # itself did not declare fork options.
862
- materialized["fork"] ||= fork_context
864
+ unless split_child_without_explicit_fork?(step)
865
+ context_default = rendering.dig("context", "default")
866
+ materialized["context"] ||= context_default if context_default
867
+ fork_context = rendering.dig("context", "fork")
868
+ if materialized["context"] == "fork" && fork_context.is_a?(Hash) && !fork_context.empty?
869
+ # For materialized explicit steps, preserve frontmatter-provided fork config
870
+ # (`||=` semantics). Rendering contributes defaults only when the step
871
+ # itself did not declare fork options.
872
+ materialized["fork"] ||= fork_context
873
+ end
863
874
  end
864
875
  materialized["source_skill"] = rendering["source_skill"] || rendering["skill"] if rendering["source_skill"] || rendering["skill"]
865
876
  materialized["source_workflow"] = rendering["workflow"] if rendering["workflow"] && !rendering["workflow"].empty?
866
- materialized.delete("skill")
877
+ materialized.delete("skill") unless preserve_explicit_child_skill?(step)
867
878
  materialized
868
879
  end
869
880
 
@@ -871,6 +882,11 @@ module Ace
871
882
  explicit_workflow = step["workflow"]&.to_s&.strip
872
883
  if explicit_workflow && !explicit_workflow.empty?
873
884
  canonical_step = find_step_definition(step["name"]&.to_s)
885
+ if canonical_step && split_child_without_explicit_fork?(step)
886
+ canonical_step = canonical_step.dup
887
+ canonical_step.delete("context")
888
+ canonical_step.delete("fork")
889
+ end
874
890
  source_skill = step["source_skill"]&.to_s&.strip
875
891
  source_skill = canonical_step&.dig("source_skill") if source_skill.nil? || source_skill.empty?
876
892
  rendering = skill_source_resolver.resolve_workflow_rendering(
@@ -896,6 +912,20 @@ module Ace
896
912
  skill_source_resolver.resolve_step_rendering(step["name"]&.to_s)
897
913
  end
898
914
 
915
+ def split_child_without_explicit_fork?(step)
916
+ step["parent"] && !step.key?("context") && !step.key?("fork")
917
+ end
918
+
919
+ def preserve_explicit_child_skill?(step)
920
+ step["parent"] && step["sub_steps_origin"] == "explicit"
921
+ end
922
+
923
+ def fork_context_value?(value)
924
+ normalized = value.to_s.strip.downcase
925
+ normalized = normalized.delete_prefix(":")
926
+ normalized == "fork"
927
+ end
928
+
899
929
  def render_skill_backed_step_instructions(step:, rendering:)
900
930
  if step_render_mode(rendering) == "step_template"
901
931
  return render_step_template_instructions(step: step, rendering: rendering)
@@ -1204,6 +1234,7 @@ module Ace
1204
1234
 
1205
1235
  def insert_batch_step_tree(step_config, after:, as_child:, added_by:, location:)
1206
1236
  normalized = normalize_batch_step_hash(step_config, location: location)
1237
+ normalized = apply_inferred_parent_for_sibling_insert(normalized, after: after, as_child: as_child)
1207
1238
 
1208
1239
  if canonical_batch_insert_requested?(normalized)
1209
1240
  canonical_inserted = insert_canonical_batch_step_tree(
@@ -1459,6 +1490,27 @@ module Ace
1459
1490
  end
1460
1491
  end
1461
1492
 
1493
+ def apply_inferred_parent_for_sibling_insert(step_config, after:, as_child:)
1494
+ return step_config unless step_config.is_a?(Hash)
1495
+ return step_config if as_child
1496
+ return step_config if after.nil? || after.to_s.strip.empty?
1497
+ return step_config if step_config.key?("parent")
1498
+
1499
+ inferred_parent = infer_parent_from_anchor(after)
1500
+ return step_config if inferred_parent.nil? || inferred_parent.to_s.strip.empty?
1501
+
1502
+ step_config.merge("parent" => inferred_parent)
1503
+ end
1504
+
1505
+ def infer_parent_from_anchor(anchor_number)
1506
+ assignment = assignment_manager.find_active
1507
+ return nil unless assignment
1508
+
1509
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
1510
+ anchor = state.find_by_number(anchor_number.to_s.strip)
1511
+ anchor&.parent
1512
+ end
1513
+
1462
1514
  def default_dynamic_step_instructions
1463
1515
  DEFAULT_DYNAMIC_STEP_INSTRUCTIONS
1464
1516
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.41.5"
5
+ VERSION = "0.42.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-assign
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.41.5
4
+ version: 0.42.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-29 00:00:00.000000000 Z
10
+ date: 2026-04-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli