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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Atoms
8
+ # Pure functions for parsing step markdown files.
9
+ #
10
+ # Step files have frontmatter + body structure:
11
+ # ---
12
+ # name: step-name
13
+ # status: pending
14
+ # ---
15
+ # # Instructions
16
+ # ...
17
+ module StepFileParser
18
+ FRONTMATTER_REGEX = /\A---\s*\n(.*?)\n---\s*\n/m
19
+
20
+ # Parse step file content into structured data
21
+ #
22
+ # @param content [String] File content with frontmatter + body
23
+ # @return [Hash] Parsed data with :frontmatter and :body keys
24
+ def self.parse(content)
25
+ match = content.match(FRONTMATTER_REGEX)
26
+
27
+ if match
28
+ frontmatter_yaml = match[1]
29
+ body = content[match.end(0)..]
30
+
31
+ frontmatter = YAML.safe_load(frontmatter_yaml, permitted_classes: [Time, Date]) || {}
32
+ {frontmatter: frontmatter, body: body.strip}
33
+ else
34
+ {frontmatter: {}, body: content.strip}
35
+ end
36
+ end
37
+
38
+ # Extract specific fields from parsed content
39
+ #
40
+ # @param parsed [Hash] Result from parse()
41
+ # @return [Hash] Extracted fields
42
+ def self.extract_fields(parsed)
43
+ fm = parsed[:frontmatter]
44
+ body = parsed[:body]
45
+
46
+ context = fm["context"]
47
+ validate_context!(context)
48
+
49
+ {
50
+ name: fm["name"],
51
+ status: (fm["status"] || "pending").to_sym,
52
+ skill: fm["skill"],
53
+ workflow: fm["workflow"],
54
+ context: context, # "fork" triggers Task tool execution
55
+ batch_parent: parse_boolean(fm["batch_parent"]),
56
+ parallel: parse_boolean(fm["parallel"]),
57
+ max_parallel: parse_positive_integer(fm["max_parallel"]),
58
+ fork_retry_limit: parse_non_negative_integer(fm["fork_retry_limit"]),
59
+ started_at: parse_time(fm["started_at"]),
60
+ completed_at: parse_time(fm["completed_at"]),
61
+ fork_launch_pid: parse_integer(fm["fork_launch_pid"]),
62
+ fork_tracked_pids: parse_integer_array(fm["fork_tracked_pids"]),
63
+ fork_pid_updated_at: parse_time(fm["fork_pid_updated_at"]),
64
+ fork_pid_file: fm["fork_pid_file"],
65
+ error: fm["error"],
66
+ stall_reason: fm["stall_reason"],
67
+ added_by: fm["added_by"],
68
+ parent: fm["parent"],
69
+ instructions: body.strip,
70
+ report: nil # Reports are loaded separately from reports/ dir
71
+ }
72
+ end
73
+
74
+ # Extract instructions section from body
75
+ # Body is now just instructions (report is in separate file)
76
+ #
77
+ # @param body [String] File body after frontmatter
78
+ # @return [String] Instructions content
79
+ def self.extract_instructions(body)
80
+ body.strip
81
+ end
82
+
83
+ # Parse filename to extract number, name, and parent.
84
+ #
85
+ # @param filename [String] Filename like "010-init-project.st.md" or "010-init-project.r.md"
86
+ # @return [Hash] Extracted number, name, and parent (if nested)
87
+ def self.parse_filename(filename)
88
+ # Remove .st.md or .r.md extension
89
+ base = filename.sub(/\.(st|r)\.md$/, "")
90
+
91
+ # Match number pattern (with optional dot-separated parts) and name
92
+ match = base.match(/^([\d.]+)-(.+)$/)
93
+
94
+ if match
95
+ number = match[1]
96
+ name = match[2]
97
+ parent = extract_parent_from_number(number)
98
+ {number: number, name: name, parent: parent}
99
+ else
100
+ {number: nil, name: base, parent: nil}
101
+ end
102
+ end
103
+
104
+ # Extract parent number from a hierarchical step number.
105
+ #
106
+ # @param number [String] Step number (e.g., "010.01")
107
+ # @return [String, nil] Parent number or nil for top-level
108
+ def self.extract_parent_from_number(number)
109
+ return nil if number.nil?
110
+
111
+ parts = number.split(".")
112
+ return nil if parts.length <= 1
113
+
114
+ parts[0..-2].join(".")
115
+ end
116
+
117
+ # Generate step filename from number and name
118
+ #
119
+ # @param number [String] Step number
120
+ # @param name [String] Step name
121
+ # @return [String] Step filename with .st.md extension
122
+ def self.generate_filename(number, name)
123
+ # Sanitize name for filename
124
+ safe_name = name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
125
+ "#{number}-#{safe_name}.st.md"
126
+ end
127
+
128
+ # Generate report filename from number and name
129
+ #
130
+ # @param number [String] Step number
131
+ # @param name [String] Step name
132
+ # @return [String] Report filename with .r.md extension
133
+ def self.generate_report_filename(number, name)
134
+ # Sanitize name for filename
135
+ safe_name = name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
136
+ "#{number}-#{safe_name}.r.md"
137
+ end
138
+
139
+ private
140
+
141
+ def self.validate_context!(context)
142
+ return if context.nil?
143
+
144
+ valid_contexts = Ace::Assign::Models::Step::VALID_CONTEXTS
145
+ return if valid_contexts.include?(context)
146
+
147
+ raise ArgumentError, "Invalid context '#{context}'. Valid values: #{valid_contexts.join(", ")}"
148
+ end
149
+ private_class_method :validate_context!
150
+
151
+ def self.parse_time(value)
152
+ return nil if value.nil?
153
+ return value if value.is_a?(Time)
154
+
155
+ Time.parse(value.to_s)
156
+ rescue ArgumentError
157
+ nil
158
+ end
159
+ private_class_method :parse_time
160
+
161
+ def self.parse_integer(value)
162
+ return nil if value.nil?
163
+
164
+ Integer(value)
165
+ rescue ArgumentError, TypeError
166
+ nil
167
+ end
168
+ private_class_method :parse_integer
169
+
170
+ def self.parse_integer_array(value)
171
+ return [] if value.nil?
172
+
173
+ Array(value).map { |v| parse_integer(v) }.compact.uniq.sort
174
+ end
175
+ private_class_method :parse_integer_array
176
+
177
+ def self.parse_boolean(value)
178
+ return nil if value.nil?
179
+ return value if value == true || value == false
180
+
181
+ normalized = value.to_s.strip.downcase
182
+ return true if %w[true yes 1].include?(normalized)
183
+ return false if %w[false no 0].include?(normalized)
184
+
185
+ nil
186
+ end
187
+ private_class_method :parse_boolean
188
+
189
+ def self.parse_positive_integer(value)
190
+ parsed = parse_integer(value)
191
+ return nil if parsed.nil? || parsed <= 0
192
+
193
+ parsed
194
+ end
195
+ private_class_method :parse_positive_integer
196
+
197
+ def self.parse_non_negative_integer(value)
198
+ parsed = parse_integer(value)
199
+ return nil if parsed.nil? || parsed.negative?
200
+
201
+ parsed
202
+ end
203
+ private_class_method :parse_non_negative_integer
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Pure functions for hierarchical step numbering operations.
7
+ #
8
+ # Supports nested step structure where steps can have sub-steps:
9
+ # - Main steps: 010, 020, 030
10
+ # - Nested steps: 010.01, 010.02, 010.03
11
+ # - Deeply nested: 010.01.01 (if needed)
12
+ #
13
+ # This enables verification-as-step patterns where parent steps
14
+ # wait for all children to complete before advancing.
15
+ #
16
+ # @example
17
+ # StepNumbering.parse("010.02")
18
+ # # => { parent: "010", index: 2, depth: 1, full: "010.02" }
19
+ #
20
+ # StepNumbering.next_sibling("010.02")
21
+ # # => "010.03"
22
+ #
23
+ # StepNumbering.first_child("010")
24
+ # # => "010.01"
25
+ #
26
+ # StepNumbering.child_of?("010.02", "010")
27
+ # # => true
28
+ module StepNumbering
29
+ # Maximum allowed nesting depth for step numbers.
30
+ # Prevents unbounded hierarchy (e.g., 010.01.01.01.01...).
31
+ # Depth 0 = top-level (010), 1 = first nest (010.01), 2 = second nest (010.01.01).
32
+ # Maximum is 010.01.01 (3 levels total).
33
+ MAX_DEPTH = 2
34
+
35
+ # Maximum siblings per level.
36
+ # Child indexes use %02d format (01-99). Top-level uses %03d (001-999).
37
+ # Exceeding these limits will cause lexicographical sorting issues.
38
+ MAX_SIBLINGS_TOP_LEVEL = 999
39
+ MAX_SIBLINGS_NESTED = 99
40
+
41
+ # Parse a step number into its components.
42
+ #
43
+ # @param number [String] Step number (e.g., "010", "010.02", "010.02.03")
44
+ # @return [Hash] Parsed components with keys:
45
+ # - :parent [String, nil] Parent step number (nil for top-level steps)
46
+ # - :index [Integer] The final sequence number
47
+ # - :depth [Integer] Nesting depth (0 for top-level, 1 for first nest, etc.)
48
+ # - :full [String] Original full number
49
+ def self.parse(number)
50
+ parts = number.to_s.split(".")
51
+
52
+ {
53
+ parent: (parts.length > 1) ? parts[0..-2].join(".") : nil,
54
+ index: parts.last.to_i,
55
+ depth: parts.length - 1,
56
+ full: number.to_s
57
+ }
58
+ end
59
+
60
+ # Generate the next sibling step number.
61
+ #
62
+ # @param number [String] Current step number
63
+ # @return [String] Next sibling number with same parent
64
+ def self.next_sibling(number)
65
+ parsed = parse(number)
66
+ new_index = parsed[:index] + 1
67
+ limit = parsed[:parent] ? MAX_SIBLINGS_NESTED : MAX_SIBLINGS_TOP_LEVEL
68
+
69
+ if new_index > limit
70
+ raise ArgumentError, "Cannot create sibling: would exceed maximum siblings " \
71
+ "(#{limit}) at this level (current index: #{parsed[:index]})"
72
+ end
73
+
74
+ if parsed[:parent]
75
+ "#{parsed[:parent]}.#{format("%02d", new_index)}"
76
+ else
77
+ # Top-level numbers use 3-digit padding
78
+ format("%03d", new_index)
79
+ end
80
+ end
81
+
82
+ # Generate the first child step number.
83
+ #
84
+ # @param number [String] Parent step number
85
+ # @return [String] First child number (e.g., "010" -> "010.01")
86
+ # @raise [ArgumentError] If adding a child would exceed MAX_DEPTH
87
+ def self.first_child(number)
88
+ parent_depth = parse(number)[:depth]
89
+ child_depth = parent_depth + 1
90
+ if child_depth > MAX_DEPTH
91
+ raise ArgumentError, "Cannot create child: would exceed maximum nesting depth of #{MAX_DEPTH} " \
92
+ "(parent '#{number}' is at depth #{parent_depth})"
93
+ end
94
+ "#{number}.01"
95
+ end
96
+
97
+ # Generate the next child step number based on existing children.
98
+ #
99
+ # @param parent [String] Parent step number
100
+ # @param existing_children [Array<String>] Existing child numbers
101
+ # @return [String] Next child number
102
+ # @raise [ArgumentError] If adding a child would exceed MAX_DEPTH
103
+ def self.next_child(parent, existing_children = [])
104
+ parent_depth = parse(parent)[:depth]
105
+ child_depth = parent_depth + 1
106
+ if child_depth > MAX_DEPTH
107
+ raise ArgumentError, "Cannot create child: would exceed maximum nesting depth of #{MAX_DEPTH} " \
108
+ "(parent '#{parent}' is at depth #{parent_depth})"
109
+ end
110
+
111
+ return first_child(parent) if existing_children.empty?
112
+
113
+ # Find highest existing child index
114
+ max_index = existing_children
115
+ .select { |n| child_of?(n, parent) && direct_child_of?(n, parent) }
116
+ .map { |n| parse(n)[:index] }
117
+ .max || 0
118
+
119
+ "#{parent}.#{format("%02d", max_index + 1)}"
120
+ end
121
+
122
+ # Check if a step number is a child (direct or nested) of another.
123
+ #
124
+ # @param child [String] Potential child number
125
+ # @param parent [String] Potential parent number
126
+ # @return [Boolean] True if child is descended from parent
127
+ def self.child_of?(child, parent)
128
+ child.to_s.start_with?("#{parent}.")
129
+ end
130
+
131
+ # Check if a step number is a direct (immediate) child of another.
132
+ #
133
+ # @param child [String] Potential child number
134
+ # @param parent [String] Potential parent number
135
+ # @return [Boolean] True if child is immediate child of parent
136
+ def self.direct_child_of?(child, parent)
137
+ return false unless child_of?(child, parent)
138
+
139
+ # Direct child has exactly one more level
140
+ child_parsed = parse(child)
141
+ parent_parsed = parse(parent)
142
+
143
+ child_parsed[:depth] == parent_parsed[:depth] + 1
144
+ end
145
+
146
+ # Get all direct children of a parent from a list of numbers.
147
+ #
148
+ # @param parent [String] Parent step number
149
+ # @param all_numbers [Array<String>] All step numbers to filter
150
+ # @return [Array<String>] Direct children of parent
151
+ def self.direct_children(parent, all_numbers)
152
+ all_numbers.select { |n| direct_child_of?(n, parent) }
153
+ end
154
+
155
+ # Get the parent number of a step, if it has one.
156
+ #
157
+ # @param number [String] Step number
158
+ # @return [String, nil] Parent number or nil for top-level steps
159
+ def self.parent_of(number)
160
+ parse(number)[:parent]
161
+ end
162
+
163
+ # Check if a number is a top-level (root) step.
164
+ #
165
+ # @param number [String] Step number
166
+ # @return [Boolean] True if top-level
167
+ def self.top_level?(number)
168
+ parse(number)[:depth] == 0
169
+ end
170
+
171
+ # Generate a step number to insert after another step.
172
+ # This creates a sibling at the same nesting level.
173
+ #
174
+ # Note: This method does not check for collisions. Use steps_to_renumber
175
+ # to determine which existing steps need to be shifted when inserting.
176
+ #
177
+ # @param after [String] Step number to insert after
178
+ # @return [String] New step number (next sibling)
179
+ def self.insert_after(after)
180
+ next_sibling(after)
181
+ end
182
+
183
+ # Find step numbers that need to be renumbered when inserting at a position.
184
+ # Returns steps that have numbers >= the insertion point.
185
+ #
186
+ # @param at_number [String] Number where new step will be inserted
187
+ # @param existing [Array<String>] All existing step numbers
188
+ # @return [Array<String>] Numbers that need to be shifted (in ascending order)
189
+ def self.steps_to_renumber(at_number, existing)
190
+ parsed_at = parse(at_number)
191
+ parent = parsed_at[:parent]
192
+
193
+ existing
194
+ .select { |n|
195
+ parsed = parse(n)
196
+ # Same parent (or both top-level) and index >= insertion point
197
+ parsed[:parent] == parent && parsed[:index] >= parsed_at[:index]
198
+ }
199
+ .sort_by { |n| parse(n)[:index] }
200
+ end
201
+
202
+ # Generate the shifted number for a step being renumbered.
203
+ #
204
+ # @param number [String] Original step number
205
+ # @param shift [Integer] Amount to shift by (default: 1)
206
+ # @return [String] New shifted number
207
+ # @raise [ArgumentError] If shifting would exceed sibling limits
208
+ def self.shift_number(number, shift = 1)
209
+ parsed = parse(number)
210
+ new_index = parsed[:index] + shift
211
+ limit = parsed[:parent] ? MAX_SIBLINGS_NESTED : MAX_SIBLINGS_TOP_LEVEL
212
+
213
+ if new_index > limit
214
+ raise ArgumentError, "Cannot shift step number: would exceed maximum siblings " \
215
+ "(#{limit}) at this level (new index would be: #{new_index})"
216
+ end
217
+
218
+ if parsed[:parent]
219
+ "#{parsed[:parent]}.#{format("%02d", new_index)}"
220
+ else
221
+ format("%03d", new_index)
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Pure functions for sorting step files lexicographically.
7
+ #
8
+ # Steps are sorted by their numeric components:
9
+ # 010 < 010.01 < 010.01.01 < 010.02 < 020
10
+ module StepSorter
11
+ # Sort filenames lexicographically by step number
12
+ #
13
+ # @param filenames [Array<String>] Array of filenames
14
+ # @return [Array<String>] Sorted filenames
15
+ def self.sort(filenames)
16
+ filenames.sort_by { |f| sort_key(f) }
17
+ end
18
+
19
+ # Generate a sort key for a filename
20
+ #
21
+ # The key is an array of integers that can be compared
22
+ # to produce correct lexicographic ordering.
23
+ #
24
+ # @param filename [String] Filename to generate key for
25
+ # @return [Array<Integer>] Sort key
26
+ def self.sort_key(filename)
27
+ # Extract number from filename (strip .st.md or .r.md extension)
28
+ base = filename.sub(/\.(ph|r)\.md$/, "")
29
+ number_part = base.split("-").first
30
+
31
+ # Split by dots and convert to integers
32
+ parts = number_part.split(".").map(&:to_i)
33
+
34
+ # Pad to 3 parts for consistent comparison
35
+ parts + Array.new(3 - parts.size, 0)
36
+ end
37
+
38
+ # Sort step numbers directly
39
+ #
40
+ # @param numbers [Array<String>] Array of step numbers
41
+ # @return [Array<String>] Sorted step numbers
42
+ def self.sort_numbers(numbers)
43
+ numbers.sort_by { |n| number_key(n) }
44
+ end
45
+
46
+ # Generate a sort key for a step number
47
+ #
48
+ # @param number [String] Step number
49
+ # @return [Array<Integer>] Sort key
50
+ def self.number_key(number)
51
+ parts = number.split(".").map(&:to_i)
52
+ parts + Array.new(3 - parts.size, 0)
53
+ end
54
+
55
+ # Compare two step numbers
56
+ #
57
+ # @param a [String] First step number
58
+ # @param b [String] Second step number
59
+ # @return [Integer] -1, 0, or 1
60
+ def self.compare(a, b)
61
+ number_key(a) <=> number_key(b)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Atoms
6
+ # Pure function: renders assignment hierarchy as an indented tree string.
7
+ #
8
+ # Takes a flat list of AssignmentInfo objects with parent fields
9
+ # and renders them as a visual tree with Unicode box-drawing characters.
10
+ #
11
+ # @example
12
+ # TreeFormatter.format(assignments)
13
+ # # => task-148-implement
14
+ # # +-- onboard (completed)
15
+ # # +-- work-on-task (running)
16
+ # # | +-- onboard (completed)
17
+ # # | +-- implement (in_progress)
18
+ # # | \-- verify-tests (pending)
19
+ # # \-- review-pr (pending)
20
+ module TreeFormatter
21
+ # State display labels matching list command
22
+ STATE_LABELS = {
23
+ pending: "pending",
24
+ in_progress: "in_progress",
25
+ running: "running",
26
+ paused: "paused",
27
+ completed: "completed",
28
+ failed: "failed",
29
+ empty: "empty"
30
+ }.freeze
31
+
32
+ # Format a flat list of assignment info objects as a tree.
33
+ #
34
+ # @param assignments [Array<Models::AssignmentInfo>] Flat list with parent metadata
35
+ # @return [String] Formatted tree string
36
+ def self.format(assignments)
37
+ return "No assignments found." if assignments.empty?
38
+
39
+ # Build ID index first (pass 1), then attach children (pass 2)
40
+ by_id = {}
41
+ assignments.each { |info| by_id[info.id] = info }
42
+
43
+ children_of = Hash.new { |h, k| h[k] = [] }
44
+ assignments.each do |info|
45
+ parent_id = extract_parent_id(info)
46
+ if parent_id && by_id.key?(parent_id)
47
+ children_of[parent_id] << info
48
+ end
49
+ end
50
+
51
+ # Find roots: assignments whose parent is nil or not in the set
52
+ roots = assignments.reject do |info|
53
+ parent_id = extract_parent_id(info)
54
+ parent_id && by_id.key?(parent_id)
55
+ end
56
+
57
+ lines = []
58
+ roots.each do |root|
59
+ render_node(root, children_of, lines, prefix: "", is_last: true, is_root: true)
60
+ end
61
+
62
+ lines.join("\n")
63
+ end
64
+
65
+ # Extract parent assignment ID from an AssignmentInfo.
66
+ #
67
+ # @param info [Models::AssignmentInfo] Assignment info
68
+ # @return [String, nil] Parent assignment ID or nil
69
+ def self.extract_parent_id(info)
70
+ return nil unless info.assignment.respond_to?(:parent)
71
+
72
+ info.assignment.parent
73
+ end
74
+ private_class_method :extract_parent_id
75
+
76
+ # Render a single node and its children recursively.
77
+ #
78
+ # @param info [Models::AssignmentInfo] Current node
79
+ # @param children_of [Hash] Children index
80
+ # @param lines [Array<String>] Output lines accumulator
81
+ # @param prefix [String] Indentation prefix
82
+ # @param is_last [Boolean] Whether this is the last sibling
83
+ # @param is_root [Boolean] Whether this is a root node
84
+ def self.render_node(info, children_of, lines, prefix:, is_last:, is_root:)
85
+ state_label = STATE_LABELS[info.state] || info.state.to_s
86
+
87
+ if is_root
88
+ lines << "#{info.name} [#{info.id}] (#{state_label}) #{info.progress}"
89
+ child_prefix = ""
90
+ else
91
+ connector = is_last ? "\\-- " : "+-- "
92
+ lines << "#{prefix}#{connector}#{info.name} [#{info.id}] (#{state_label}) #{info.progress}"
93
+ child_prefix = prefix + (is_last ? " " : "| ")
94
+ end
95
+
96
+ children = children_of[info.id] || []
97
+ children.each_with_index do |child, idx|
98
+ child_is_last = idx == children.size - 1
99
+ render_node(child, children_of, lines, prefix: child_prefix, is_last: child_is_last, is_root: false)
100
+ end
101
+ end
102
+ private_class_method :render_node
103
+ end
104
+ end
105
+ end
106
+ end