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,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require "date"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Resolves assignment metadata from skill frontmatter.
11
+ #
12
+ # Flow:
13
+ # 1) Find SKILL.md by skill name (e.g., "ace-task-work")
14
+ # 2) Read assign.source URI from skill frontmatter (e.g., wfi://task/work)
15
+ # 3) Resolve workflow file from URI
16
+ # 4) Parse workflow assign frontmatter (sub-steps/context)
17
+ class SkillAssignSourceResolver
18
+ ASSIGN_CAPABLE_KINDS = %w[workflow orchestration].freeze
19
+
20
+ def initialize(project_root: nil, skill_paths: nil, workflow_paths: nil)
21
+ @project_root = project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
22
+ configured_skill_paths = skill_paths || Ace::Assign.config["skill_source_paths"]
23
+ configured_workflow_paths = workflow_paths || Ace::Assign.config["workflow_source_paths"]
24
+
25
+ canonical_paths = discover_canonical_skill_source_paths
26
+ canonical_workflow_paths = discover_canonical_workflow_source_paths
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?
32
+ @skill_paths = (canonical_paths + override_paths).uniq
33
+ @workflow_paths = (canonical_workflow_paths + normalize_paths(configured_workflow_paths || [])).uniq
34
+ @skill_index = nil
35
+ end
36
+
37
+ # Resolve assign config for a skill.
38
+ #
39
+ # @param skill_name [String] Skill identifier (e.g., "ace-task-work")
40
+ # @return [Hash, nil] Parsed assign config (keys: :sub_steps, :context, etc.) or nil if not declared
41
+ # @raise [Ace::Assign::Error] If skill declares an invalid/unresolvable source
42
+ def resolve_assign_config(skill_name)
43
+ skill_path = skill_index[skill_name] || find_skill_by_convention(skill_name)
44
+ return nil unless skill_path
45
+
46
+ skill_frontmatter = parse_frontmatter(File.read(skill_path))
47
+ if assign_capable_skill_frontmatter?(skill_frontmatter) && skill_frontmatter["assign"]
48
+ validate_assign_source!(skill_frontmatter["assign"], skill_name)
49
+ end
50
+
51
+ assign_block = skill_frontmatter["assign"]
52
+ return nil unless assign_block.is_a?(Hash)
53
+
54
+ source = assign_block["source"]&.to_s&.strip
55
+ return nil if source.nil? || source.empty?
56
+
57
+ workflow_path = resolve_source_uri(source, skill_name)
58
+ workflow_frontmatter = parse_frontmatter(File.read(workflow_path))
59
+ parsed = Atoms::AssignFrontmatterParser.parse(workflow_frontmatter)
60
+
61
+ unless parsed[:valid]
62
+ raise Error, "Invalid assign frontmatter in '#{workflow_path}' for skill '#{skill_name}': #{parsed[:errors].join("; ")}"
63
+ end
64
+
65
+ parsed[:config]
66
+ end
67
+
68
+ # List assign-capable canonical skills discovered from skill sources.
69
+ #
70
+ # Assign-capable skills are canonical skills with:
71
+ # - skill.kind: workflow|orchestration
72
+ # - assign.source present and non-empty
73
+ #
74
+ # @return [Array<String>] Skill names
75
+ # @raise [Ace::Assign::Error] When assign-capable skill has invalid assign metadata
76
+ def assign_capable_skill_names
77
+ skill_index.keys.sort.filter do |skill_name|
78
+ skill_path = skill_index[skill_name]
79
+ frontmatter = parse_frontmatter(File.read(skill_path))
80
+ next false unless assign_capable_skill_frontmatter?(frontmatter)
81
+ next false unless frontmatter["assign"].is_a?(Hash)
82
+
83
+ validate_assign_source!(frontmatter["assign"], skill_name)
84
+ true
85
+ end
86
+ end
87
+
88
+ # Build assignment step entries from canonical skills.
89
+ #
90
+ # Only steps declared under `assign.steps` are emitted here. This keeps
91
+ # canonical skills authoritative for public skill-backed assignment steps,
92
+ # while internal helper steps can continue to use catalog templates.
93
+ #
94
+ # @return [Array<Hash>] Step definitions keyed by canonical precedence
95
+ def assign_step_catalog
96
+ catalog = {}
97
+
98
+ each_assign_capable_skill do |skill_name, frontmatter|
99
+ steps = frontmatter.dig("assign", "steps")
100
+ workflow_source = frontmatter.dig("assign", "source")&.to_s&.strip
101
+ workflow_source = frontmatter.dig("skill", "execution", "workflow")&.to_s&.strip if workflow_source.nil? || workflow_source.empty?
102
+ next unless steps.is_a?(Array)
103
+
104
+ steps.each do |step|
105
+ next unless step.is_a?(Hash)
106
+
107
+ step_name = step["name"]&.to_s&.strip
108
+ next if step_name.nil? || step_name.empty?
109
+ next if catalog.key?(step_name)
110
+
111
+ entry = step.dup
112
+ entry["name"] = step_name
113
+ entry["skill"] = skill_name
114
+ entry["source_skill"] = skill_name
115
+ entry["workflow"] = workflow_source if workflow_source && !workflow_source.empty?
116
+ entry["description"] ||= frontmatter["description"]
117
+ catalog[step_name] = entry
118
+ end
119
+ end
120
+
121
+ catalog.values
122
+ end
123
+
124
+ # Resolve canonical skill rendering details for a skill-backed step.
125
+ #
126
+ # @param skill_name [String]
127
+ # @return [Hash, nil]
128
+ def resolve_skill_rendering(skill_name)
129
+ skill_path = skill_index[skill_name] || find_skill_by_convention(skill_name)
130
+ return nil unless skill_path
131
+
132
+ frontmatter, skill_body = parse_frontmatter_and_body(File.read(skill_path))
133
+ workflow_source = frontmatter.dig("assign", "source")&.to_s&.strip
134
+ if workflow_source.nil? || workflow_source.empty?
135
+ workflow_source = frontmatter.dig("skill", "execution", "workflow")&.to_s&.strip
136
+ end
137
+
138
+ workflow_path = resolve_workflow_path(workflow_source, skill_name)
139
+ workflow_body = workflow_path ? parse_frontmatter_and_body(File.read(workflow_path)).last.to_s.strip : ""
140
+
141
+ {
142
+ "name" => frontmatter["name"] || skill_name,
143
+ "description" => frontmatter["description"],
144
+ "skill" => skill_name,
145
+ "workflow" => workflow_source,
146
+ "workflow_path" => workflow_path,
147
+ "body" => workflow_body.empty? ? skill_body.to_s.strip : workflow_body
148
+ }
149
+ end
150
+
151
+ def resolve_workflow_rendering(workflow_source, step_name: nil, source_skill: nil)
152
+ workflow_ref = workflow_source&.to_s&.strip
153
+ return nil if workflow_ref.nil? || workflow_ref.empty?
154
+
155
+ workflow_path = resolve_workflow_path(workflow_ref, step_name || source_skill || workflow_ref)
156
+ return nil unless workflow_path
157
+
158
+ frontmatter, body = parse_frontmatter_and_body(File.read(workflow_path))
159
+ {
160
+ "name" => step_name || frontmatter["name"],
161
+ "description" => frontmatter["description"],
162
+ "workflow" => workflow_ref,
163
+ "workflow_path" => workflow_path,
164
+ "source_skill" => source_skill,
165
+ "body" => body.to_s.strip
166
+ }
167
+ end
168
+
169
+ def resolve_workflow_assign_config(workflow_source, step_name: nil, source_skill: nil)
170
+ rendering = resolve_workflow_rendering(workflow_source, step_name: step_name, source_skill: source_skill)
171
+ return nil unless rendering && rendering["workflow_path"]
172
+
173
+ workflow_frontmatter = parse_frontmatter(File.read(rendering["workflow_path"]))
174
+ parsed = Atoms::AssignFrontmatterParser.parse(workflow_frontmatter)
175
+ return nil unless parsed[:valid]
176
+
177
+ parsed[:config]
178
+ end
179
+
180
+ # Resolve canonical rendering details for a public step name.
181
+ #
182
+ # @param step_name [String]
183
+ # @return [Hash, nil]
184
+ def resolve_step_rendering(step_name)
185
+ entry = assign_step_catalog.find { |step| step["name"] == step_name }
186
+ return nil unless entry
187
+
188
+ workflow_rendering = resolve_workflow_rendering(
189
+ entry["workflow"],
190
+ step_name: entry["name"],
191
+ source_skill: entry["source_skill"] || entry["skill"]
192
+ )
193
+ return entry.merge(workflow_rendering) if workflow_rendering
194
+
195
+ rendering = resolve_skill_rendering(entry["skill"])
196
+ return nil unless rendering
197
+
198
+ entry.merge(rendering)
199
+ end
200
+
201
+ private
202
+
203
+ attr_reader :project_root, :skill_paths, :workflow_paths
204
+
205
+ def skill_index
206
+ @skill_index ||= begin
207
+ index = {}
208
+ skill_paths.each do |base_path|
209
+ Dir.glob(File.join(base_path, "**", "SKILL.md")).sort.each do |path|
210
+ frontmatter = parse_frontmatter(File.read(path))
211
+ name = frontmatter["name"]&.to_s
212
+ index[name] ||= path if name && !name.empty?
213
+ rescue
214
+ next
215
+ end
216
+ end
217
+ index
218
+ end
219
+ end
220
+
221
+ def normalize_paths(paths)
222
+ paths.map do |path|
223
+ path_str = path.to_s
224
+ if Pathname.new(path_str).absolute?
225
+ path_str
226
+ else
227
+ File.expand_path(path_str, project_root)
228
+ end
229
+ end
230
+ end
231
+
232
+ def find_skill_by_convention(skill_name)
233
+ dir_name = skill_name.to_s.tr(":", "_")
234
+ skill_paths.each do |base_path|
235
+ candidate = File.join(base_path, dir_name, "SKILL.md")
236
+ return candidate if File.exist?(candidate)
237
+ end
238
+ nil
239
+ end
240
+
241
+ def parse_frontmatter(content)
242
+ parse_frontmatter_and_body(content).first
243
+ end
244
+
245
+ def parse_frontmatter_and_body(content)
246
+ lines = content.lines
247
+ return [{}, content.to_s] unless lines.first&.strip == "---"
248
+
249
+ closing_index = lines[1..]&.index { |line| line.strip == "---" }
250
+ return [{}, content.to_s] unless closing_index
251
+
252
+ frontmatter_yaml = lines[1, closing_index].join
253
+ frontmatter = YAML.safe_load(frontmatter_yaml, permitted_classes: [Date, Time]) || {}
254
+ body_lines = lines[(closing_index + 2)..] || []
255
+ [frontmatter, body_lines.join]
256
+ end
257
+
258
+ def assign_capable_skill_frontmatter?(frontmatter)
259
+ kind = frontmatter.dig("skill", "kind")&.to_s
260
+ ASSIGN_CAPABLE_KINDS.include?(kind)
261
+ end
262
+
263
+ def validate_assign_source!(assign_block, skill_name)
264
+ source = assign_block["source"]&.to_s&.strip
265
+ if source.nil? || source.empty?
266
+ raise Error, "Missing assign.source for assign-capable skill '#{skill_name}'"
267
+ end
268
+ end
269
+
270
+ def resolve_workflow_path(workflow_source, reference_name)
271
+ return nil if workflow_source.nil? || workflow_source.empty?
272
+ return nil unless workflow_source.start_with?("wfi://")
273
+
274
+ resolve_source_uri(workflow_source, reference_name)
275
+ end
276
+
277
+ def each_assign_capable_skill
278
+ skill_index.each do |skill_name, skill_path|
279
+ frontmatter = parse_frontmatter(File.read(skill_path))
280
+ next unless assign_capable_skill_frontmatter?(frontmatter)
281
+ next unless frontmatter["assign"].is_a?(Hash)
282
+
283
+ validate_assign_source!(frontmatter["assign"], skill_name)
284
+ yield skill_name, frontmatter
285
+ end
286
+ end
287
+
288
+ def discover_canonical_skill_source_paths
289
+ discover_protocol_source_paths(
290
+ protocol: "skill",
291
+ package_glob: File.join(project_root, "*", ".ace-defaults", "nav", "protocols", "skill-sources", "*.yml")
292
+ )
293
+ end
294
+
295
+ def discover_canonical_workflow_source_paths
296
+ discover_protocol_source_paths(
297
+ protocol: "wfi",
298
+ package_glob: File.join(project_root, "*", ".ace-defaults", "nav", "protocols", "wfi-sources", "*.yml")
299
+ )
300
+ end
301
+
302
+ def discover_protocol_source_paths(protocol:, package_glob:)
303
+ registry_paths = Dir.chdir(project_root) do
304
+ registry = Ace::Support::Nav::Molecules::SourceRegistry.new
305
+ registry.sources_for_protocol(protocol).filter_map do |source|
306
+ next if source.config.is_a?(Hash) && source.config["enabled"] == false
307
+
308
+ candidate = resolve_source_directory(source)
309
+ File.directory?(candidate) ? candidate : nil
310
+ rescue
311
+ nil
312
+ end
313
+ end
314
+
315
+ (registry_paths + discover_package_default_source_paths(package_glob)).uniq
316
+ end
317
+
318
+ def resolve_source_directory(source)
319
+ candidate = source.full_path
320
+ return candidate if File.directory?(candidate)
321
+
322
+ relative_path = source.config&.dig("relative_path")&.to_s&.strip
323
+ return nil if relative_path.nil? || relative_path.empty?
324
+
325
+ return nil unless source.config_file&.include?("/.ace-defaults/")
326
+
327
+ package_root = source.config_file.split("/.ace-defaults/").first
328
+ fallback = File.expand_path(relative_path, package_root)
329
+ File.directory?(fallback) ? fallback : nil
330
+ end
331
+
332
+ def discover_package_default_source_paths(source_glob)
333
+ source_files = Dir.glob(source_glob).sort
334
+ source_files.filter_map do |source_file|
335
+ source_data = YAML.safe_load_file(source_file, permitted_classes: [Date, Time]) || {}
336
+ relative_path = source_data.dig("config", "relative_path")&.to_s&.strip
337
+ next if relative_path.nil? || relative_path.empty?
338
+
339
+ package_root = File.expand_path("../../../../..", source_file)
340
+ candidate = File.expand_path(relative_path, package_root)
341
+ File.directory?(candidate) ? candidate : nil
342
+ rescue
343
+ nil
344
+ end
345
+ end
346
+
347
+ def discover_workspace_workflow_paths
348
+ Dir.glob(File.join(project_root, "*", "handbook", "workflow-instructions")).sort
349
+ end
350
+
351
+ def resolve_source_uri(uri, skill_name)
352
+ if uri.start_with?("wfi://")
353
+ resolve_wfi_uri(uri, skill_name)
354
+ else
355
+ raise Error, "Unsupported assign.source '#{uri}' for skill '#{skill_name}'. Supported: wfi://..."
356
+ end
357
+ end
358
+
359
+ def resolve_wfi_uri(uri, skill_name)
360
+ workflow_name = uri.delete_prefix("wfi://").strip
361
+ if workflow_name.empty?
362
+ raise Error, "Empty workflow name in assign.source '#{uri}' for skill '#{skill_name}'"
363
+ end
364
+
365
+ workflow_paths.each do |base_path|
366
+ candidate = File.join(base_path, "#{workflow_name}.wf.md")
367
+ return candidate if File.exist?(candidate)
368
+ end
369
+
370
+ searched = workflow_paths.join(", ")
371
+ raise Error, "Could not resolve assign.source '#{uri}' for skill '#{skill_name}'. Searched: #{searched}"
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ # Handles step file renumbering with cascade support and atomic operations.
9
+ #
10
+ # Extracted from AssignmentExecutor to provide:
11
+ # - Testable renumbering logic
12
+ # - Transactional rename with rollback on failure
13
+ # - Parent metadata updates for descendants
14
+ #
15
+ # @example Basic usage
16
+ # renumberer = StepRenumberer.new(step_writer: step_writer, queue_scanner: scanner)
17
+ # renumberer.renumber(steps_dir, ["010", "011"])
18
+ class StepRenumberer
19
+ attr_reader :step_writer, :queue_scanner
20
+
21
+ def initialize(step_writer:, queue_scanner:)
22
+ @step_writer = step_writer
23
+ @queue_scanner = queue_scanner
24
+ end
25
+
26
+ # Renumber steps by shifting their file numbers.
27
+ # Also cascades to all descendants to prevent orphaning children.
28
+ #
29
+ # @param steps_dir [String] Path to steps directory
30
+ # @param numbers_to_shift [Array<String>] Step numbers to shift
31
+ # @return [Hash] Result with :renamed (count) and :rollback_needed (bool)
32
+ def renumber(steps_dir, numbers_to_shift)
33
+ return {renamed: 0, rollback_needed: false} if numbers_to_shift.empty?
34
+
35
+ all_numbers = queue_scanner.step_numbers(steps_dir)
36
+ sorted_steps = build_shift_list(numbers_to_shift, all_numbers)
37
+
38
+ # Track operations for potential rollback
39
+ completed_renames = []
40
+ rollback_needed = false
41
+
42
+ begin
43
+ sorted_steps.each do |old_number|
44
+ new_number = calculate_new_number(old_number, numbers_to_shift)
45
+ next if new_number == old_number
46
+
47
+ # Calculate new parent for frontmatter update
48
+ new_parent = calculate_new_parent(old_number, numbers_to_shift)
49
+
50
+ rename_result = rename_step_files(
51
+ steps_dir,
52
+ old_number,
53
+ new_number,
54
+ new_parent: new_parent
55
+ )
56
+
57
+ completed_renames << {old: old_number, new: new_number, files: rename_result[:files]}
58
+ end
59
+ rescue => e
60
+ rollback_needed = true
61
+ rollback_renames(completed_renames)
62
+ raise e
63
+ end
64
+
65
+ {renamed: completed_renames.size, rollback_needed: rollback_needed}
66
+ end
67
+
68
+ private
69
+
70
+ # Build complete list of steps to shift including descendants.
71
+ #
72
+ # @param numbers_to_shift [Array<String>] Explicit steps to shift
73
+ # @param all_numbers [Array<String>] All existing step numbers
74
+ # @return [Array<String>] Sorted list (deepest children first)
75
+ def build_shift_list(numbers_to_shift, all_numbers)
76
+ all_to_shift = []
77
+ numbers_to_shift.each do |num|
78
+ all_to_shift << num
79
+ # Find all descendants (steps starting with "num.")
80
+ all_numbers.each do |n|
81
+ all_to_shift << n if Atoms::StepNumbering.child_of?(n, num)
82
+ end
83
+ end
84
+ all_to_shift.uniq!
85
+
86
+ # Sort in reverse by full number (deeper children first, then parents)
87
+ # This prevents filename collisions and ensures proper cascade
88
+ all_to_shift.sort.reverse
89
+ end
90
+
91
+ # Calculate the new number for a step being shifted.
92
+ #
93
+ # @param old_number [String] Current step number
94
+ # @param numbers_to_shift [Array<String>] Steps explicitly being shifted
95
+ # @return [String] New step number
96
+ def calculate_new_number(old_number, numbers_to_shift)
97
+ if numbers_to_shift.include?(old_number)
98
+ Atoms::StepNumbering.shift_number(old_number, 1)
99
+ else
100
+ # This is a descendant - cascade parent shift
101
+ parent_old = Atoms::StepNumbering.parse(old_number)[:parent]
102
+ parent_new = if parent_old && numbers_to_shift.include?(parent_old)
103
+ Atoms::StepNumbering.shift_number(parent_old, 1)
104
+ end
105
+
106
+ # Replace old parent prefix with new parent prefix
107
+ if parent_new
108
+ old_number.sub(/^#{Regexp.escape(parent_old)}/, parent_new)
109
+ else
110
+ # Find shifted ancestor
111
+ ancestor = numbers_to_shift.find { |n| Atoms::StepNumbering.child_of?(old_number, n) }
112
+ if ancestor
113
+ new_ancestor = Atoms::StepNumbering.shift_number(ancestor, 1)
114
+ old_number.sub(/^#{Regexp.escape(ancestor)}/, new_ancestor)
115
+ else
116
+ old_number
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ # Calculate new parent number for frontmatter update.
123
+ #
124
+ # @param old_number [String] Step being renamed
125
+ # @param numbers_to_shift [Array<String>] Steps explicitly being shifted
126
+ # @return [String, nil] New parent number or nil if no parent update needed
127
+ def calculate_new_parent(old_number, numbers_to_shift)
128
+ old_parent = Atoms::StepNumbering.parse(old_number)[:parent]
129
+ return nil unless old_parent
130
+
131
+ ancestor = numbers_to_shift.find { |n| old_parent == n || Atoms::StepNumbering.child_of?(old_parent, n) }
132
+ if ancestor
133
+ new_ancestor = Atoms::StepNumbering.shift_number(ancestor, 1)
134
+ old_parent.sub(/^#{Regexp.escape(ancestor)}/, new_ancestor)
135
+ else
136
+ old_parent
137
+ end
138
+ end
139
+
140
+ # Rename step and report files from one number to another.
141
+ #
142
+ # @param steps_dir [String] Path to steps directory
143
+ # @param old_number [String] Old step number
144
+ # @param new_number [String] New step number
145
+ # @param new_parent [String, nil] New parent number for frontmatter update
146
+ # @return [Hash] Result with :files (renamed file paths)
147
+ def rename_step_files(steps_dir, old_number, new_number, new_parent: nil)
148
+ renamed_files = []
149
+
150
+ # Find step file with this number
151
+ pattern = File.join(steps_dir, "#{old_number}-*.st.md")
152
+ step_files = Dir.glob(pattern)
153
+
154
+ step_files.each do |old_path|
155
+ filename = File.basename(old_path)
156
+ new_filename = filename.sub(/^#{Regexp.escape(old_number)}/, new_number)
157
+ new_path = File.join(steps_dir, new_filename)
158
+
159
+ FileUtils.mv(old_path, new_path)
160
+ renamed_files << {old_path: old_path, new_path: new_path, type: :step}
161
+
162
+ # Add audit trail metadata to track renumbering history
163
+ metadata = {
164
+ "renumbered_from" => old_number,
165
+ "renumbered_at" => Time.now.utc.iso8601
166
+ }
167
+ metadata["parent"] = new_parent if new_parent
168
+ step_writer.update_frontmatter(new_path, metadata)
169
+ end
170
+
171
+ # Also rename any report files
172
+ cache_dir = File.dirname(steps_dir)
173
+ reports_dir = File.join(cache_dir, "reports")
174
+ if File.directory?(reports_dir)
175
+ report_pattern = File.join(reports_dir, "#{old_number}-*.r.md")
176
+ report_files = Dir.glob(report_pattern)
177
+
178
+ report_files.each do |old_path|
179
+ filename = File.basename(old_path)
180
+ new_filename = filename.sub(/^#{Regexp.escape(old_number)}/, new_number)
181
+ new_path = File.join(reports_dir, new_filename)
182
+
183
+ FileUtils.mv(old_path, new_path)
184
+ renamed_files << {old_path: old_path, new_path: new_path, type: :report}
185
+ end
186
+ end
187
+
188
+ {files: renamed_files}
189
+ end
190
+
191
+ # Rollback completed renames on failure.
192
+ #
193
+ # @param completed_renames [Array<Hash>] List of completed rename operations
194
+ # @return [Array<Hash>] List of rollback errors (empty if all succeeded)
195
+ def rollback_renames(completed_renames)
196
+ rollback_errors = []
197
+
198
+ # Reverse order to undo in opposite sequence
199
+ completed_renames.reverse_each do |rename|
200
+ rename[:files].each do |file_info|
201
+ next unless File.exist?(file_info[:new_path])
202
+
203
+ FileUtils.mv(file_info[:new_path], file_info[:old_path])
204
+ rescue => e
205
+ # Capture rollback errors but continue attempting remaining rollbacks
206
+ rollback_errors << {
207
+ file: file_info[:new_path],
208
+ target: file_info[:old_path],
209
+ error: e.message
210
+ }
211
+ end
212
+ end
213
+
214
+ # Warn about rollback failures if any occurred
215
+ if rollback_errors.any?
216
+ warn "[ace-assign] Warning: #{rollback_errors.size} file(s) failed to rollback during renumber recovery:"
217
+ rollback_errors.each do |err|
218
+ warn " - #{err[:file]} -> #{err[:target]}: #{err[:error]}"
219
+ end
220
+ end
221
+
222
+ rollback_errors
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end