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.
- checksums.yaml +7 -0
- data/.ace-defaults/assign/catalog/composition-rules.yml +211 -0
- data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +44 -0
- data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +35 -0
- data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +32 -0
- data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +29 -0
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +48 -0
- data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/commit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/lint.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +17 -0
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +34 -0
- data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +57 -0
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/release.step.yml +23 -0
- data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/research.step.yml +19 -0
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +22 -0
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +25 -0
- data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +29 -0
- data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +38 -0
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +28 -0
- data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +42 -0
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +48 -0
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +36 -0
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +23 -0
- data/.ace-defaults/assign/config.yml +48 -0
- data/.ace-defaults/assign/presets/fix-bug.yml +65 -0
- data/.ace-defaults/assign/presets/quick-implement.yml +41 -0
- data/.ace-defaults/assign/presets/release-only.yml +35 -0
- data/.ace-defaults/assign/presets/work-on-docs.yml +41 -0
- data/.ace-defaults/assign/presets/work-on-task.yml +179 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +19 -0
- data/CHANGELOG.md +1415 -0
- data/README.md +87 -0
- data/Rakefile +16 -0
- data/docs/exit-codes.md +61 -0
- data/docs/getting-started.md +121 -0
- data/docs/handbook.md +40 -0
- data/docs/usage.md +224 -0
- data/exe/ace-assign +16 -0
- data/handbook/guides/fork-context.g.md +231 -0
- data/handbook/skills/as-assign-compose/SKILL.md +24 -0
- data/handbook/skills/as-assign-create/SKILL.md +23 -0
- data/handbook/skills/as-assign-drive/SKILL.md +24 -0
- data/handbook/skills/as-assign-prepare/SKILL.md +23 -0
- data/handbook/skills/as-assign-recover-fork/SKILL.md +22 -0
- data/handbook/skills/as-assign-run-in-batches/SKILL.md +23 -0
- data/handbook/skills/as-assign-start/SKILL.md +25 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +256 -0
- data/handbook/workflow-instructions/assign/create.wf.md +215 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +666 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +469 -0
- data/handbook/workflow-instructions/assign/recover-fork.wf.md +233 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +212 -0
- data/handbook/workflow-instructions/assign/start.wf.md +46 -0
- data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +173 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +101 -0
- data/lib/ace/assign/atoms/composition_rules.rb +219 -0
- data/lib/ace/assign/atoms/number_generator.rb +110 -0
- data/lib/ace/assign/atoms/preset_expander.rb +277 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +207 -0
- data/lib/ace/assign/atoms/step_numbering.rb +227 -0
- data/lib/ace/assign/atoms/step_sorter.rb +66 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +106 -0
- data/lib/ace/assign/cli/commands/add.rb +102 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +55 -0
- data/lib/ace/assign/cli/commands/create.rb +63 -0
- data/lib/ace/assign/cli/commands/fail.rb +43 -0
- data/lib/ace/assign/cli/commands/finish.rb +88 -0
- data/lib/ace/assign/cli/commands/fork_run.rb +229 -0
- data/lib/ace/assign/cli/commands/list.rb +166 -0
- data/lib/ace/assign/cli/commands/retry_cmd.rb +42 -0
- data/lib/ace/assign/cli/commands/select.rb +45 -0
- data/lib/ace/assign/cli/commands/start.rb +40 -0
- data/lib/ace/assign/cli/commands/status.rb +407 -0
- data/lib/ace/assign/cli.rb +144 -0
- data/lib/ace/assign/models/assignment.rb +107 -0
- data/lib/ace/assign/models/assignment_info.rb +66 -0
- data/lib/ace/assign/models/queue_state.rb +326 -0
- data/lib/ace/assign/models/step.rb +197 -0
- data/lib/ace/assign/molecules/assignment_discoverer.rb +57 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +276 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +102 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +130 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +376 -0
- data/lib/ace/assign/molecules/step_renumberer.rb +227 -0
- data/lib/ace/assign/molecules/step_writer.rb +246 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +1299 -0
- data/lib/ace/assign/version.rb +7 -0
- data/lib/ace/assign.rb +141 -0
- 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
|