ace-assign 0.41.10 → 0.53.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
  4. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
  6. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
  7. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -15
  8. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
  9. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
  10. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -20
  11. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
  12. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
  13. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
  14. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -21
  15. data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
  16. data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
  17. data/.ace-defaults/assign/presets/work-on-task.yml +3 -16
  18. data/CHANGELOG.md +260 -0
  19. data/README.md +20 -43
  20. data/docs/demo/canonical-skill-source.gif +0 -0
  21. data/docs/demo/canonical-skill-source.tape.yml +51 -0
  22. data/docs/demo/fork-provider.cast +957 -0
  23. data/docs/demo/fork-provider.gif +0 -0
  24. data/docs/demo/fork-provider.recording.json +32 -0
  25. data/docs/demo/fork-provider.tape.yml +65 -20
  26. data/docs/getting-started.md +5 -2
  27. data/docs/usage.md +63 -3
  28. data/handbook/guides/fork-context.g.md +2 -2
  29. data/handbook/skills/as-assign-drive/SKILL.md +13 -1
  30. data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
  31. data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
  32. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
  33. data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
  34. data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
  35. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
  36. data/handbook/workflow-instructions/assign/create.wf.md +6 -3
  37. data/handbook/workflow-instructions/assign/drive.wf.md +273 -15
  38. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
  39. data/handbook/workflow-instructions/assign/prepare.wf.md +5 -5
  40. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
  41. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
  42. data/handbook/workflow-instructions/assign/start.wf.md +5 -2
  43. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
  44. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
  45. data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
  46. data/lib/ace/assign/atoms/preset_expander.rb +12 -1
  47. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  48. data/lib/ace/assign/cli/commands/assignment_target.rb +53 -0
  49. data/lib/ace/assign/cli/commands/finish.rb +7 -4
  50. data/lib/ace/assign/cli/commands/fork_run.rb +4 -1
  51. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  52. data/lib/ace/assign/cli/commands/start.rb +9 -3
  53. data/lib/ace/assign/cli/commands/status.rb +231 -226
  54. data/lib/ace/assign/cli/commands/step.rb +62 -0
  55. data/lib/ace/assign/cli.rb +8 -1
  56. data/lib/ace/assign/models/step.rb +4 -2
  57. data/lib/ace/assign/molecules/fork_session_launcher.rb +189 -8
  58. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  59. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +252 -50
  60. data/lib/ace/assign/molecules/tmux_fork_runner.rb +191 -0
  61. data/lib/ace/assign/organisms/assignment_executor.rb +294 -40
  62. data/lib/ace/assign/version.rb +1 -1
  63. metadata +21 -5
  64. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +0 -42
@@ -12,15 +12,40 @@ module Ace
12
12
  # start → advance → complete (with fail/add/retry branches)
13
13
  class AssignmentExecutor
14
14
  DEFAULT_DYNAMIC_STEP_INSTRUCTIONS = "Complete this step and finish with: ace-assign finish --message report.md".freeze
15
+ PROJECT_ROOT_SIGNAL = "project_root".freeze
16
+ CATALOG_SIGNAL = "catalog".freeze
15
17
 
16
18
  attr_reader :assignment_manager, :queue_scanner, :step_writer, :step_renumberer, :skill_source_resolver
17
19
 
18
- def initialize(cache_base: nil)
20
+ class << self
21
+ def clear_caches!
22
+ @cache_store = { step_catalog_cache: {} }
23
+ end
24
+
25
+ def cache_store
26
+ @cache_store ||= { step_catalog_cache: {} }
27
+ end
28
+
29
+ private
30
+
31
+ def cached_value(store_key, key)
32
+ cache_store[store_key][key]
33
+ end
34
+
35
+ def store_cached_value(store_key, key, value)
36
+ cache_store[store_key][key] = value
37
+ end
38
+ end
39
+
40
+ def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil)
19
41
  @assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
20
42
  @queue_scanner = Molecules::QueueScanner.new
21
43
  @step_writer = Molecules::StepWriter.new
22
- @skill_source_resolver = Molecules::SkillAssignSourceResolver.new
44
+ @skill_source_resolver = skill_source_resolver || Molecules::SkillAssignSourceResolver.new
23
45
  @step_catalog = nil
46
+ @step_catalog_from_fixture = step_catalog
47
+ @step_catalog_from_fixture_set = !step_catalog.nil?
48
+ @step_catalog_loaded = false
24
49
  @step_renumberer = Molecules::StepRenumberer.new(
25
50
  step_writer: @step_writer,
26
51
  queue_scanner: @queue_scanner
@@ -479,7 +504,11 @@ module Ace
479
504
  next step unless step.is_a?(Hash)
480
505
 
481
506
  sub_steps = step["sub_steps"] || step["sub-steps"]
482
- next step if sub_steps.is_a?(Array) && sub_steps.any?
507
+ if sub_steps.is_a?(Array) && sub_steps.any?
508
+ explicit = step.dup
509
+ explicit["sub_steps_origin"] ||= "explicit"
510
+ next explicit
511
+ end
483
512
 
484
513
  assign_config = resolve_step_assign_config(step)
485
514
  next step unless assign_config
@@ -487,7 +516,10 @@ module Ace
487
516
  resolved_sub_steps = assign_config[:sub_steps]
488
517
  next step unless resolved_sub_steps.is_a?(Array) && resolved_sub_steps.any?
489
518
 
490
- enriched = step.merge("sub_steps" => resolved_sub_steps)
519
+ enriched = step.merge(
520
+ "sub_steps" => resolved_sub_steps,
521
+ "sub_steps_origin" => "inferred"
522
+ )
491
523
  enriched["context"] ||= assign_config[:context] if assign_config[:context]
492
524
  enriched
493
525
  end
@@ -523,6 +555,7 @@ module Ace
523
555
  # Create split parent orchestration node
524
556
  parent_context = step["context"] || "fork"
525
557
  parent_instructions = step["instructions"]
558
+ sub_steps_origin = step["sub_steps_origin"] || "explicit"
526
559
  parent_step = build_split_parent_step(
527
560
  step: step,
528
561
  parent_number: parent_number,
@@ -540,7 +573,8 @@ module Ace
540
573
  parent_number: parent_number,
541
574
  parent_step: step,
542
575
  parent_instructions: parent_instructions,
543
- parent_context: parent_context
576
+ parent_context: parent_context,
577
+ sub_steps_origin: sub_steps_origin
544
578
  )
545
579
  end
546
580
  else
@@ -564,7 +598,10 @@ module Ace
564
598
  # @param sub_steps [Array<String>] Declared sub-step names
565
599
  # @return [Hash] Parent step config for runtime queue
566
600
  def build_split_parent_step(step:, parent_number:, parent_context:, sub_steps:)
567
- source_skill = step["skill"]
601
+ source_skill = step["source_skill"] || step["skill"]
602
+ if (source_skill.nil? || source_skill.to_s.strip.empty?) && step["source"].to_s.start_with?("skill://")
603
+ source_skill = step["source"].to_s.delete_prefix("skill://").strip
604
+ end
568
605
  original_text = normalize_instructions(step["instructions"]).strip
569
606
  definition = find_step_definition("split-subtree-root") || {}
570
607
 
@@ -591,6 +628,7 @@ module Ace
591
628
  )
592
629
  parent_step.delete("sub_steps")
593
630
  parent_step.delete("sub-steps")
631
+ parent_step.delete("source")
594
632
  parent_step.delete("skill")
595
633
  parent_step.delete("workflow")
596
634
  parent_step["source_skill"] = source_skill if source_skill
@@ -680,8 +718,9 @@ module Ace
680
718
  # @param parent_step [Hash] Parent step config
681
719
  # @param parent_instructions [String, Array<String>, nil] Parent instructions
682
720
  # @param parent_context [String, nil] Parent execution context
721
+ # @param sub_steps_origin [String] Whether the subtree was declared explicitly or inferred
683
722
  # @return [Hash] Child step config
684
- def build_child_sub_step(sub_name:, child_number:, parent_number:, parent_step:, parent_instructions:, parent_context:)
723
+ def build_child_sub_step(sub_name:, child_number:, parent_number:, parent_step:, parent_instructions:, parent_context:, sub_steps_origin: "inferred")
685
724
  step_def = find_step_definition(sub_name)
686
725
  parent_task_ref = extract_parent_taskref(parent_step, parent_instructions)
687
726
  instructions = if step_def&.dig("skill")
@@ -693,16 +732,25 @@ module Ace
693
732
  "number" => child_number,
694
733
  "name" => sub_name,
695
734
  "instructions" => instructions,
696
- "parent" => parent_number
735
+ "parent" => parent_number,
736
+ "sub_steps_origin" => sub_steps_origin
697
737
  }
698
738
  child["taskref"] = parent_task_ref if parent_task_ref
699
739
 
700
740
  if step_def
701
741
  child["workflow"] = step_def["workflow"] if step_def["workflow"]
702
- child["skill"] = step_def["skill"] if step_def["skill"] && !step_def["workflow"]
742
+ preserve_explicit_skill = (sub_steps_origin == "explicit")
743
+ child["skill"] = step_def["skill"] if step_def["skill"] && (preserve_explicit_skill || !step_def["workflow"])
744
+ child["source"] = if step_def["source"]
745
+ step_def["source"]
746
+ elsif step_def["workflow"]
747
+ step_def["workflow"]
748
+ elsif step_def["skill"]
749
+ "skill://#{step_def["skill"]}"
750
+ end
703
751
 
704
752
  context_default = step_def.dig("context", "default")
705
- child["context"] = context_default if context_default && parent_context != "fork"
753
+ child["context"] = context_default if context_default && !fork_context_value?(parent_context)
706
754
  fork_context = step_def.dig("context", "fork")
707
755
  if child["context"] == "fork" && fork_context.is_a?(Hash) && !fork_context.empty?
708
756
  # Generated child sub-steps have no explicit frontmatter overrides.
@@ -799,14 +847,9 @@ module Ace
799
847
  when "pre-commit-review"
800
848
  pre_commit_review_action_instructions(task_hint: task_hint)
801
849
  when "verify-test"
802
- "- 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."
850
+ "- Identify modified packages#{task_hint}.\n- For each modified package, run: cd <package> && ace-test all --profile 6\n- This subtree step verifies modified packages only; do not run the monorepo suite here.\n- If no package-level code changes are present, mark this step skipped with a clear reason."
803
851
  when /\Arelease(?:-.+)?\z/
804
852
  "- 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."
805
- when "verify-e2e"
806
- "- Check change scope: run `git diff origin/main --name-only` to list modified files.\n" \
807
- "- **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" \
808
- "- Otherwise: detect modified packages, run E2E scenarios for each package with `test/e2e/` scenarios#{task_hint}.\n" \
809
- "- If no modified package has E2E scenarios, mark step done with \"skipped: no E2E scenarios for modified packages\"."
810
853
  else
811
854
  "- Execute the #{sub_name} step."
812
855
  end
@@ -849,25 +892,52 @@ module Ace
849
892
  "instructions" => rendered_instructions,
850
893
  "workflow" => rendering["workflow"]
851
894
  )
852
- context_default = rendering.dig("context", "default")
853
- materialized["context"] ||= context_default if context_default
854
- fork_context = rendering.dig("context", "fork")
855
- if materialized["context"] == "fork" && fork_context.is_a?(Hash) && !fork_context.empty?
856
- # For materialized explicit steps, preserve frontmatter-provided fork config
857
- # (`||=` semantics). Rendering contributes defaults only when the step
858
- # itself did not declare fork options.
859
- materialized["fork"] ||= fork_context
895
+ resolved_source = resolved_step_source(step, rendering)
896
+ materialized["source"] = resolved_source if resolved_source && !resolved_source.empty?
897
+ unless split_child_without_explicit_fork?(step)
898
+ context_default = rendering.dig("context", "default")
899
+ materialized["context"] ||= context_default if context_default
900
+ fork_context = rendering.dig("context", "fork")
901
+ if materialized["context"] == "fork" && fork_context.is_a?(Hash) && !fork_context.empty?
902
+ # For materialized explicit steps, preserve frontmatter-provided fork config
903
+ # (`||=` semantics). Rendering contributes defaults only when the step
904
+ # itself did not declare fork options.
905
+ materialized["fork"] ||= fork_context
906
+ end
860
907
  end
861
908
  materialized["source_skill"] = rendering["source_skill"] || rendering["skill"] if rendering["source_skill"] || rendering["skill"]
862
909
  materialized["source_workflow"] = rendering["workflow"] if rendering["workflow"] && !rendering["workflow"].empty?
863
- materialized.delete("skill")
910
+ materialized.delete("skill") unless preserve_explicit_child_skill?(step)
864
911
  materialized
865
912
  end
866
913
 
867
914
  def resolve_step_rendering(step)
915
+ explicit_source = step["source"]&.to_s&.strip
916
+ if explicit_source && !explicit_source.empty?
917
+ canonical_step = find_step_definition_with_source_fallback(step, explicit_source: explicit_source)
918
+ if canonical_step && split_child_without_explicit_fork?(step)
919
+ canonical_step = canonical_step.dup
920
+ canonical_step.delete("context")
921
+ canonical_step.delete("fork")
922
+ end
923
+ source_skill = step["source_skill"]&.to_s&.strip
924
+ source_skill = canonical_step&.dig("source_skill") if source_skill.nil? || source_skill.empty?
925
+ rendering = skill_source_resolver.resolve_source_rendering(
926
+ explicit_source,
927
+ step_name: step["name"]&.to_s,
928
+ source_skill: source_skill
929
+ )
930
+ return canonical_step ? canonical_step.merge(rendering || {}) : rendering if rendering
931
+ end
932
+
868
933
  explicit_workflow = step["workflow"]&.to_s&.strip
869
934
  if explicit_workflow && !explicit_workflow.empty?
870
935
  canonical_step = find_step_definition(step["name"]&.to_s)
936
+ if canonical_step && split_child_without_explicit_fork?(step)
937
+ canonical_step = canonical_step.dup
938
+ canonical_step.delete("context")
939
+ canonical_step.delete("fork")
940
+ end
871
941
  source_skill = step["source_skill"]&.to_s&.strip
872
942
  source_skill = canonical_step&.dig("source_skill") if source_skill.nil? || source_skill.empty?
873
943
  rendering = skill_source_resolver.resolve_workflow_rendering(
@@ -893,6 +963,20 @@ module Ace
893
963
  skill_source_resolver.resolve_step_rendering(step["name"]&.to_s)
894
964
  end
895
965
 
966
+ def split_child_without_explicit_fork?(step)
967
+ step["parent"] && !step.key?("context") && !step.key?("fork")
968
+ end
969
+
970
+ def preserve_explicit_child_skill?(step)
971
+ step["parent"] && step["sub_steps_origin"] == "explicit"
972
+ end
973
+
974
+ def fork_context_value?(value)
975
+ normalized = value.to_s.strip.downcase
976
+ normalized = normalized.delete_prefix(":")
977
+ normalized == "fork"
978
+ end
979
+
896
980
  def render_skill_backed_step_instructions(step:, rendering:)
897
981
  if step_render_mode(rendering) == "step_template"
898
982
  return render_step_template_instructions(step: step, rendering: rendering)
@@ -1006,6 +1090,15 @@ module Ace
1006
1090
  end
1007
1091
 
1008
1092
  def resolve_step_assign_config(step)
1093
+ source_ref = step["source"]&.to_s&.strip
1094
+ if source_ref && !source_ref.empty?
1095
+ return skill_source_resolver.resolve_source_assign_config(
1096
+ source_ref,
1097
+ step_name: step["name"]&.to_s,
1098
+ source_skill: step["source_skill"]&.to_s
1099
+ )
1100
+ end
1101
+
1009
1102
  explicit_workflow = step["workflow"]&.to_s&.strip
1010
1103
  if explicit_workflow && !explicit_workflow.empty?
1011
1104
  return skill_source_resolver.resolve_workflow_assign_config(
@@ -1086,30 +1179,120 @@ module Ace
1086
1179
  Atoms::CatalogLoader.find_by_name(step_catalog, step_name)
1087
1180
  end
1088
1181
 
1182
+ def find_step_definition_with_source_fallback(step, explicit_source:)
1183
+ step_name = step["name"]&.to_s
1184
+ canonical_step = find_step_definition(step_name)
1185
+ return canonical_step if canonical_step
1186
+
1187
+ source = explicit_source.to_s.strip
1188
+ return nil if source.empty?
1189
+
1190
+ source_skill = step["source_skill"]&.to_s&.strip
1191
+ source_skill = source.delete_prefix("skill://").strip if source_skill.to_s.empty? && source.start_with?("skill://")
1192
+
1193
+ step_catalog.find do |entry|
1194
+ next unless entry.is_a?(Hash)
1195
+
1196
+ entry_source = entry["source"]&.to_s&.strip
1197
+ entry_workflow = entry["workflow"]&.to_s&.strip
1198
+ entry_source_skill = entry["source_skill"]&.to_s&.strip
1199
+ entry_skill = entry["skill"]&.to_s&.strip
1200
+
1201
+ next true if entry_source == source || entry_workflow == source
1202
+ next true if !source_skill.to_s.empty? && (entry_source_skill == source_skill || entry_skill == source_skill)
1203
+
1204
+ false
1205
+ end
1206
+ end
1207
+
1089
1208
  # Load step catalog from project override or gem defaults.
1090
1209
  #
1091
1210
  # @return [Array<Hash>] Loaded step definitions
1092
1211
  def step_catalog
1093
- @step_catalog ||= begin
1094
- project_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
1095
- gem_root = Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
1212
+ return @step_catalog if @step_catalog_loaded
1096
1213
 
1097
- project_catalog = File.join(project_root, ".ace", "assign", "catalog", "steps")
1098
- default_catalog = File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps")
1214
+ if @step_catalog_from_fixture_set
1215
+ @step_catalog_loaded = true
1216
+ @step_catalog = @step_catalog_from_fixture
1217
+ return @step_catalog
1218
+ end
1099
1219
 
1100
- default_steps = Atoms::CatalogLoader.load_all(default_catalog)
1101
- base_catalog = if File.directory?(project_catalog)
1102
- project_steps = Atoms::CatalogLoader.load_all(project_catalog)
1103
- merge_step_catalog(default_steps, project_steps)
1104
- else
1105
- default_steps
1106
- end
1220
+ cached = self.class.send(:cached_value, :step_catalog_cache, step_catalog_signature)
1221
+ return @step_catalog = cached if cached
1222
+
1223
+ @step_catalog_loaded = true
1224
+ @step_catalog = load_step_catalog
1225
+ self.class.send(:store_cached_value, :step_catalog_cache, step_catalog_signature, @step_catalog)
1226
+ @step_catalog
1227
+ end
1228
+
1229
+ def step_catalog_signature
1230
+ [
1231
+ PROJECT_ROOT_SIGNAL,
1232
+ project_catalog_signature,
1233
+ default_catalog_signature,
1234
+ step_catalog_cache_token,
1235
+ CATALOG_SIGNAL
1236
+ ].join("|")
1237
+ end
1238
+
1239
+ def project_catalog_signature
1240
+ @project_catalog_signature ||= catalog_signature(File.join(project_root, ".ace", "assign", "catalog", "steps"))
1241
+ end
1107
1242
 
1108
- canonical_steps = skill_source_resolver.assign_step_catalog
1109
- merge_step_catalog(base_catalog, canonical_steps)
1243
+ def default_catalog_signature
1244
+ @default_catalog_signature ||= catalog_signature(File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps"))
1245
+ end
1246
+
1247
+ def load_step_catalog
1248
+ project_catalog = File.join(project_root, ".ace", "assign", "catalog", "steps")
1249
+ default_catalog = File.join(gem_root, ".ace-defaults", "assign", "catalog", "steps")
1250
+
1251
+ canonical_steps = @skill_source_resolver.assign_step_catalog
1252
+ default_steps = Atoms::CatalogLoader.load_all(default_catalog, canonical_steps: false)
1253
+ base_catalog = merge_step_catalog(default_steps, canonical_steps)
1254
+
1255
+ if File.directory?(project_catalog)
1256
+ project_steps = Atoms::CatalogLoader.load_all(project_catalog, canonical_steps: false)
1257
+ merge_step_catalog(base_catalog, project_steps)
1258
+ else
1259
+ base_catalog
1110
1260
  end
1111
1261
  end
1112
1262
 
1263
+ def catalog_signature(catalog_dir)
1264
+ return "missing" unless File.directory?(catalog_dir)
1265
+
1266
+ Dir.glob(File.join(catalog_dir, "*.step.yml")).sort.map do |path|
1267
+ "#{path}:#{file_signature(path)}"
1268
+ end.join("|")
1269
+ end
1270
+
1271
+ def file_signature(path)
1272
+ stat = File.stat(path)
1273
+ "#{stat.mtime.to_f}:#{stat.size}"
1274
+ rescue
1275
+ "missing"
1276
+ end
1277
+
1278
+ def step_catalog_cache_token
1279
+ token = if @skill_source_resolver.respond_to?(:cache_signature)
1280
+ @skill_source_resolver.cache_signature
1281
+ else
1282
+ "resolver:#{@skill_source_resolver.object_id}"
1283
+ end
1284
+
1285
+ "resolver:#{token}"
1286
+ end
1287
+
1288
+ def project_root
1289
+ @project_root ||= Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
1290
+ end
1291
+
1292
+ def gem_root
1293
+ @gem_root ||= Gem.loaded_specs["ace-assign"]&.gem_dir || File.expand_path("../../../..", __dir__)
1294
+ end
1295
+
1113
1296
  # Merge default and project step catalogs by step name.
1114
1297
  # Later definitions override earlier ones with matching names.
1115
1298
  #
@@ -1145,6 +1328,11 @@ module Ace
1145
1328
 
1146
1329
  merged = base.dup
1147
1330
  override.each do |key, value|
1331
+ if runtime_binding_override_key?(key, base, override)
1332
+ merged[key] = base[key]
1333
+ next
1334
+ end
1335
+
1148
1336
  merged[key] =
1149
1337
  if merged[key].is_a?(Hash) && value.is_a?(Hash)
1150
1338
  deep_merge_step_definition(merged[key], value)
@@ -1155,6 +1343,33 @@ module Ace
1155
1343
  merged
1156
1344
  end
1157
1345
 
1346
+ def runtime_binding_override_key?(key, base, override)
1347
+ return false unless %w[source workflow skill source_skill].include?(key)
1348
+ return false unless local_runtime_binding_present?(base)
1349
+ canonical_binding_present?(override)
1350
+ end
1351
+
1352
+ def local_runtime_binding_present?(entry)
1353
+ entry.is_a?(Hash) && (
1354
+ present_string?(entry["source"]) ||
1355
+ present_string?(entry["workflow"]) ||
1356
+ present_string?(entry["skill"])
1357
+ )
1358
+ end
1359
+
1360
+ def canonical_binding_present?(entry)
1361
+ entry.is_a?(Hash) && (
1362
+ present_string?(entry["source"]) ||
1363
+ present_string?(entry["workflow"]) ||
1364
+ present_string?(entry["skill"]) ||
1365
+ present_string?(entry["source_skill"])
1366
+ )
1367
+ end
1368
+
1369
+ def present_string?(value)
1370
+ value.is_a?(String) && !value.strip.empty?
1371
+ end
1372
+
1158
1373
  # Archive source config into the task's jobs/ directory.
1159
1374
  # If config is already in a jobs/ or steps/ directory, keeps it in place.
1160
1375
  # Otherwise moves job.yaml to <task>/jobs/<assignment_id>-job.yml for provenance.
@@ -1201,6 +1416,7 @@ module Ace
1201
1416
 
1202
1417
  def insert_batch_step_tree(step_config, after:, as_child:, added_by:, location:)
1203
1418
  normalized = normalize_batch_step_hash(step_config, location: location)
1419
+ normalized = apply_inferred_parent_for_sibling_insert(normalized, after: after, as_child: as_child)
1204
1420
 
1205
1421
  if canonical_batch_insert_requested?(normalized)
1206
1422
  canonical_inserted = insert_canonical_batch_step_tree(
@@ -1247,10 +1463,27 @@ module Ace
1247
1463
  def canonical_batch_insert_requested?(step_config)
1248
1464
  raw_sub_steps = step_config["sub_steps"] || step_config["sub-steps"]
1249
1465
  has_declared_sub_steps = raw_sub_steps.is_a?(Array) && raw_sub_steps.any?
1466
+ has_source = !step_config["source"].to_s.strip.empty?
1250
1467
  has_workflow = !step_config["workflow"].to_s.strip.empty?
1251
1468
  has_skill = !step_config["skill"].to_s.strip.empty?
1252
1469
 
1253
- has_declared_sub_steps || has_workflow || has_skill
1470
+ has_declared_sub_steps || has_source || has_workflow || has_skill
1471
+ end
1472
+
1473
+ def resolved_step_source(step, rendering)
1474
+ explicit_source = step["source"]&.to_s&.strip
1475
+ return explicit_source unless explicit_source.nil? || explicit_source.empty?
1476
+
1477
+ rendered_source = rendering["source"]&.to_s&.strip
1478
+ return rendered_source unless rendered_source.nil? || rendered_source.empty?
1479
+
1480
+ workflow_source = rendering["workflow"]&.to_s&.strip
1481
+ return workflow_source unless workflow_source.nil? || workflow_source.empty?
1482
+
1483
+ skill_name = rendering["skill"]&.to_s&.strip
1484
+ return nil if skill_name.nil? || skill_name.empty?
1485
+
1486
+ "skill://#{skill_name}"
1254
1487
  end
1255
1488
 
1256
1489
  def insert_canonical_batch_step_tree(step_config, after:, as_child:, added_by:, location:)
@@ -1456,6 +1689,27 @@ module Ace
1456
1689
  end
1457
1690
  end
1458
1691
 
1692
+ def apply_inferred_parent_for_sibling_insert(step_config, after:, as_child:)
1693
+ return step_config unless step_config.is_a?(Hash)
1694
+ return step_config if as_child
1695
+ return step_config if after.nil? || after.to_s.strip.empty?
1696
+ return step_config if step_config.key?("parent")
1697
+
1698
+ inferred_parent = infer_parent_from_anchor(after)
1699
+ return step_config if inferred_parent.nil? || inferred_parent.to_s.strip.empty?
1700
+
1701
+ step_config.merge("parent" => inferred_parent)
1702
+ end
1703
+
1704
+ def infer_parent_from_anchor(anchor_number)
1705
+ assignment = assignment_manager.find_active
1706
+ return nil unless assignment
1707
+
1708
+ state = queue_scanner.scan(assignment.steps_dir, assignment: assignment)
1709
+ anchor = state.find_by_number(anchor_number.to_s.strip)
1710
+ anchor&.parent
1711
+ end
1712
+
1459
1713
  def default_dynamic_step_instructions
1460
1714
  DEFAULT_DYNAMIC_STEP_INSTRUCTIONS
1461
1715
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.41.10"
5
+ VERSION = '0.53.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.10
4
+ version: 0.53.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-01 00:00:00.000000000 Z
10
+ date: 2026-04-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -99,14 +99,14 @@ dependencies:
99
99
  requirements:
100
100
  - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: '0.30'
102
+ version: '0.34'
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '0.30'
109
+ version: '0.34'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: ace-task
112
112
  requirement: !ruby/object:Gem::Requirement
@@ -206,7 +206,6 @@ files:
206
206
  - ".ace-defaults/assign/catalog/steps/task-load.step.yml"
207
207
  - ".ace-defaults/assign/catalog/steps/update-docs.step.yml"
208
208
  - ".ace-defaults/assign/catalog/steps/update-pr-desc.step.yml"
209
- - ".ace-defaults/assign/catalog/steps/verify-e2e.step.yml"
210
209
  - ".ace-defaults/assign/catalog/steps/verify-test-suite.step.yml"
211
210
  - ".ace-defaults/assign/catalog/steps/verify-test.step.yml"
212
211
  - ".ace-defaults/assign/catalog/steps/work-on-task.step.yml"
@@ -221,6 +220,11 @@ files:
221
220
  - CHANGELOG.md
222
221
  - README.md
223
222
  - Rakefile
223
+ - docs/demo/canonical-skill-source.gif
224
+ - docs/demo/canonical-skill-source.tape.yml
225
+ - docs/demo/fork-provider.cast
226
+ - docs/demo/fork-provider.gif
227
+ - docs/demo/fork-provider.recording.json
224
228
  - docs/demo/fork-provider.tape.yml
225
229
  - docs/exit-codes.md
226
230
  - docs/getting-started.md
@@ -236,14 +240,23 @@ files:
236
240
  - handbook/skills/as-assign-recover-fork/SKILL.md
237
241
  - handbook/skills/as-assign-run-in-batches/SKILL.md
238
242
  - handbook/skills/as-assign-start/SKILL.md
243
+ - handbook/skills/as-create-retro-internal/SKILL.md
244
+ - handbook/skills/as-mark-task-done-internal/SKILL.md
245
+ - handbook/skills/as-reflect-and-refactor-internal/SKILL.md
246
+ - handbook/skills/as-task-load-internal/SKILL.md
239
247
  - handbook/workflow-instructions/assign/add-task.wf.md
240
248
  - handbook/workflow-instructions/assign/compose.wf.md
249
+ - handbook/workflow-instructions/assign/create-retro-internal.wf.md
241
250
  - handbook/workflow-instructions/assign/create.wf.md
242
251
  - handbook/workflow-instructions/assign/drive.wf.md
252
+ - handbook/workflow-instructions/assign/mark-task-done-internal.wf.md
243
253
  - handbook/workflow-instructions/assign/prepare.wf.md
244
254
  - handbook/workflow-instructions/assign/recover-fork.wf.md
255
+ - handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md
245
256
  - handbook/workflow-instructions/assign/run-in-batches.wf.md
246
257
  - handbook/workflow-instructions/assign/start.wf.md
258
+ - handbook/workflow-instructions/assign/task-load-internal.wf.md
259
+ - handbook/workflow-instructions/assign/verify-test-suite.wf.md
247
260
  - lib/ace/assign.rb
248
261
  - lib/ace/assign/atoms/assign_frontmatter_parser.rb
249
262
  - lib/ace/assign/atoms/catalog_loader.rb
@@ -263,11 +276,13 @@ files:
263
276
  - lib/ace/assign/cli/commands/fail.rb
264
277
  - lib/ace/assign/cli/commands/finish.rb
265
278
  - lib/ace/assign/cli/commands/fork_run.rb
279
+ - lib/ace/assign/cli/commands/fork_session.rb
266
280
  - lib/ace/assign/cli/commands/list.rb
267
281
  - lib/ace/assign/cli/commands/retry_cmd.rb
268
282
  - lib/ace/assign/cli/commands/select.rb
269
283
  - lib/ace/assign/cli/commands/start.rb
270
284
  - lib/ace/assign/cli/commands/status.rb
285
+ - lib/ace/assign/cli/commands/step.rb
271
286
  - lib/ace/assign/models/assignment.rb
272
287
  - lib/ace/assign/models/assignment_info.rb
273
288
  - lib/ace/assign/models/queue_state.rb
@@ -280,6 +295,7 @@ files:
280
295
  - lib/ace/assign/molecules/skill_assign_source_resolver.rb
281
296
  - lib/ace/assign/molecules/step_renumberer.rb
282
297
  - lib/ace/assign/molecules/step_writer.rb
298
+ - lib/ace/assign/molecules/tmux_fork_runner.rb
283
299
  - lib/ace/assign/organisms/assignment_executor.rb
284
300
  - lib/ace/assign/organisms/task_assignment_creator.rb
285
301
  - lib/ace/assign/version.rb
@@ -1,42 +0,0 @@
1
- name: verify-e2e
2
- skill: as-e2e-review
3
- render: step_template
4
- description: Review E2E coverage for modified packages and run targeted scenarios
5
-
6
- prerequisites:
7
- - name: work-on-task
8
- strength: required
9
- reason: "Must have implementation to test"
10
- - name: verify-test-suite
11
- strength: recommended
12
- reason: "Unit tests should pass before running E2E"
13
-
14
- produces: [e2e-results, coverage-matrix]
15
- consumes: [code-changes]
16
-
17
- context:
18
- default: null
19
- reason: "E2E review and execution run in the project environment"
20
-
21
- when_to_skip:
22
- - "No public CLI API changes (internal-only refactoring)"
23
- - "Package has no E2E test scenarios and no coverage gaps identified"
24
- - "All modified files are docs-only (*.md, *.yml non-config, task specs, retros)"
25
-
26
- effort: medium
27
- tags: [testing, e2e, verification]
28
-
29
- steps:
30
- - name: review-coverage
31
- description: "Run /as-e2e-review for each heavily modified package to get coverage matrix"
32
- tool: "ace-e2e-review <package>"
33
- note: "Identify gaps, overlaps, and which TCs need updating before running"
34
-
35
- - name: update-if-needed
36
- description: "If coverage matrix shows gaps or stale TCs, update or create E2E tests"
37
- conditional: "coverage-matrix shows gaps or outdated TCs"
38
-
39
- - name: run-targeted
40
- description: "Run ace-test-e2e-suite for each heavily modified package (not full suite)"
41
- tool: "ace-test-e2e-suite <package>"
42
- note: "Pass package name(s) of heavily modified packages only"