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,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "ace/b36ts"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Manages assignment YAML file operations.
11
+ #
12
+ # Handles creation, loading, and updating of assignment.yaml files.
13
+ # Uses ace-b36ts for assignment ID generation.
14
+ class AssignmentManager
15
+ # @param cache_base [String] Base cache directory
16
+ def initialize(cache_base: nil)
17
+ @cache_base = cache_base || Ace::Assign.cache_dir
18
+ end
19
+
20
+ # Create a new assignment
21
+ #
22
+ # @param name [String] Assignment name
23
+ # @param description [String, nil] Assignment description
24
+ # @param source_config [String] Path to source config file
25
+ # @param parent [String, nil] Parent assignment ID for hierarchy linking
26
+ # @return [Models::Assignment] Created assignment
27
+ def create(name:, source_config:, description: nil, parent: nil)
28
+ # Ensure cache base directory exists before generate_assignment_id
29
+ FileUtils.mkdir_p(@cache_base)
30
+
31
+ assignment_id = generate_assignment_id
32
+ cache_dir = File.join(@cache_base, assignment_id)
33
+
34
+ # Create directories
35
+ FileUtils.mkdir_p(cache_dir)
36
+ FileUtils.mkdir_p(File.join(cache_dir, "steps"))
37
+ FileUtils.mkdir_p(File.join(cache_dir, "reports"))
38
+
39
+ now = Time.now.utc
40
+
41
+ assignment = Models::Assignment.new(
42
+ id: assignment_id,
43
+ name: name,
44
+ description: description,
45
+ created_at: now,
46
+ updated_at: now,
47
+ source_config: source_config,
48
+ cache_dir: cache_dir,
49
+ parent: parent
50
+ )
51
+
52
+ # Write assignment.yaml
53
+ write_assignment_file(assignment)
54
+
55
+ # Update .latest symlink for O(1) active assignment lookup
56
+ update_latest_symlink(assignment_id)
57
+
58
+ assignment
59
+ end
60
+
61
+ # Load an existing assignment by ID
62
+ #
63
+ # @param assignment_id [String] Assignment ID
64
+ # @return [Models::Assignment, nil] Loaded assignment or nil
65
+ def load(assignment_id)
66
+ cache_dir = File.join(@cache_base, assignment_id)
67
+ assignment_file = File.join(cache_dir, "assignment.yaml")
68
+
69
+ return nil unless File.exist?(assignment_file)
70
+
71
+ data = YAML.safe_load_file(assignment_file, permitted_classes: [Time, Date])
72
+ Models::Assignment.from_h(data, cache_dir: cache_dir)
73
+ end
74
+
75
+ # Find the most recent active assignment
76
+ #
77
+ # Uses resolution order:
78
+ # 1. .current symlink (explicit user selection)
79
+ # 2. .latest symlink (auto, most recent)
80
+ # 3. Scan all assignments (fallback)
81
+ #
82
+ # @return [Models::Assignment, nil] Most recent assignment or nil
83
+ def find_active
84
+ return nil unless File.directory?(@cache_base)
85
+
86
+ # Priority 1: use .current symlink if it exists (explicit selection)
87
+ current_symlink = File.join(@cache_base, ".current")
88
+ if File.symlink?(current_symlink)
89
+ assignment_id = File.basename(File.readlink(current_symlink))
90
+ assignment = load(assignment_id)
91
+ return assignment if assignment
92
+ end
93
+
94
+ # Priority 2: use .latest symlink if it exists
95
+ latest_symlink = File.join(@cache_base, ".latest")
96
+ if File.symlink?(latest_symlink)
97
+ assignment_id = File.basename(File.readlink(latest_symlink))
98
+ assignment = load(assignment_id)
99
+ return assignment if assignment
100
+ end
101
+
102
+ # Fallback: find all assignment directories
103
+ assignments = Dir.glob(File.join(@cache_base, "*", "assignment.yaml"))
104
+ .map { |f| load_from_file(f) }
105
+ .compact
106
+ .sort_by(&:updated_at)
107
+ .reverse
108
+
109
+ assignments.first
110
+ end
111
+
112
+ # Set current assignment via .current symlink
113
+ #
114
+ # @param assignment_id [String] Assignment ID to set as current
115
+ # @raise [AssignmentErrors::NotFound] if assignment doesn't exist
116
+ def set_current(assignment_id)
117
+ assignment = load(assignment_id)
118
+ raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment
119
+
120
+ current_symlink = File.join(@cache_base, ".current")
121
+
122
+ # Remove old symlink if it exists
123
+ File.delete(current_symlink) if File.symlink?(current_symlink)
124
+
125
+ # Create new symlink
126
+ target_dir = File.join(@cache_base, assignment_id)
127
+ File.symlink(target_dir, current_symlink)
128
+
129
+ assignment
130
+ end
131
+
132
+ # Clear current assignment selection
133
+ #
134
+ # Removes the .current symlink, falling back to .latest resolution
135
+ def clear_current
136
+ current_symlink = File.join(@cache_base, ".current")
137
+ File.delete(current_symlink) if File.symlink?(current_symlink)
138
+ end
139
+
140
+ # Get the currently selected assignment ID (from .current symlink)
141
+ #
142
+ # @return [String, nil] Current assignment ID or nil
143
+ def current_id
144
+ current_symlink = File.join(@cache_base, ".current")
145
+ return nil unless File.symlink?(current_symlink)
146
+
147
+ File.basename(File.readlink(current_symlink))
148
+ end
149
+
150
+ # Delete an assignment's cache directory and clean up symlinks
151
+ #
152
+ # @param assignment_id [String] Assignment ID to delete
153
+ # @return [Boolean] true if deleted, false if not found
154
+ def delete(assignment_id)
155
+ dir = File.join(@cache_base, assignment_id)
156
+ return false unless File.directory?(dir)
157
+
158
+ cleanup_symlink(".current", assignment_id)
159
+ cleanup_symlink(".latest", assignment_id)
160
+ FileUtils.rm_rf(dir)
161
+ true
162
+ end
163
+
164
+ # Update assignment metadata
165
+ #
166
+ # @param assignment [Models::Assignment] Assignment to update
167
+ # @return [Models::Assignment] Updated assignment
168
+ def update(assignment)
169
+ # Create new assignment with updated timestamp
170
+ updated = Models::Assignment.new(
171
+ id: assignment.id,
172
+ name: assignment.name,
173
+ description: assignment.description,
174
+ created_at: assignment.created_at,
175
+ updated_at: Time.now.utc,
176
+ source_config: assignment.source_config,
177
+ cache_dir: assignment.cache_dir,
178
+ parent: assignment.parent
179
+ )
180
+
181
+ write_assignment_file(updated)
182
+
183
+ # Update .latest symlink since this assignment was just updated
184
+ update_latest_symlink(assignment.id)
185
+
186
+ updated
187
+ end
188
+
189
+ # List all assignments
190
+ #
191
+ # @return [Array<Models::Assignment>] All assignments
192
+ def list
193
+ return [] unless File.directory?(@cache_base)
194
+
195
+ Dir.glob(File.join(@cache_base, "*", "assignment.yaml"))
196
+ .map { |f| load_from_file(f) }
197
+ .compact
198
+ .sort_by(&:updated_at)
199
+ .reverse
200
+ end
201
+
202
+ private
203
+
204
+ def generate_assignment_id
205
+ base_id = Ace::B36ts.now
206
+ candidate = base_id
207
+ max_attempts = 100
208
+
209
+ # Handle collision by appending suffix
210
+ suffix = 0
211
+ max_attempts.times do
212
+ dir_path = File.join(@cache_base, candidate)
213
+ # Atomic directory creation using Dir.mkdir - fails if exists
214
+ begin
215
+ Dir.mkdir(dir_path)
216
+ return candidate
217
+ rescue Errno::EEXIST
218
+ # Directory already exists, try next candidate
219
+ suffix += 1
220
+ candidate = "#{base_id}#{suffix.to_s(36)}"
221
+ end
222
+ end
223
+
224
+ # Max attempts exceeded - this should never happen in practice
225
+ raise Error, "Failed to generate unique assignment ID after #{max_attempts} attempts. Cache directory may be corrupted."
226
+ end
227
+
228
+ def write_assignment_file(assignment)
229
+ assignment_file = File.join(assignment.cache_dir, "assignment.yaml")
230
+ File.write(assignment_file, assignment.to_h.to_yaml)
231
+ end
232
+
233
+ def load_from_file(assignment_file)
234
+ cache_dir = File.dirname(assignment_file)
235
+ data = YAML.safe_load_file(assignment_file, permitted_classes: [Time, Date])
236
+ Models::Assignment.from_h(data, cache_dir: cache_dir)
237
+ rescue => e
238
+ warn "Failed to load assignment from #{assignment_file}: #{e.message}" if Ace::Assign.debug?
239
+ nil
240
+ end
241
+
242
+ # Remove a symlink if it points to the specified assignment
243
+ #
244
+ # @param symlink_name [String] Symlink name (e.g., ".current", ".latest")
245
+ # @param assignment_id [String] Assignment ID to match
246
+ def cleanup_symlink(symlink_name, assignment_id)
247
+ symlink_path = File.join(@cache_base, symlink_name)
248
+ return unless File.symlink?(symlink_path)
249
+
250
+ target = File.basename(File.readlink(symlink_path))
251
+ File.delete(symlink_path) if target == assignment_id
252
+ rescue
253
+ # Non-fatal: continue without cleanup
254
+ end
255
+
256
+ # Update .latest symlink to point to the specified assignment
257
+ # Provides O(1) active assignment lookup
258
+ #
259
+ # @param assignment_id [String] Assignment ID to link as .latest
260
+ def update_latest_symlink(assignment_id)
261
+ latest_symlink = File.join(@cache_base, ".latest")
262
+ target_dir = File.join(@cache_base, assignment_id)
263
+
264
+ # Remove old symlink if it exists
265
+ File.delete(latest_symlink) if File.symlink?(latest_symlink)
266
+
267
+ # Create new symlink
268
+ File.symlink(target_dir, latest_symlink)
269
+ rescue => e
270
+ warn "Failed to update .latest symlink: #{e.message}" if Ace::Assign.debug?
271
+ # Non-fatal: continue without symlink
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/llm"
4
+ require "fileutils"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Launches a forked assignment-driving session via CLI LLM providers.
10
+ class ForkSessionLauncher
11
+ DEFAULT_PROVIDER = "claude:sonnet"
12
+ DEFAULT_TIMEOUT = 1800
13
+
14
+ def initialize(config: nil, query_interface: Ace::LLM::QueryInterface)
15
+ @config = config || Ace::Assign.config
16
+ @query_interface = query_interface
17
+ end
18
+
19
+ # Launch forked subtree execution synchronously.
20
+ #
21
+ # @param assignment_id [String] Assignment identifier
22
+ # @param fork_root [String] Subtree root step number
23
+ # @param provider [String, nil] Optional provider override
24
+ # @param cli_args [String, nil] Optional provider CLI args
25
+ # @param timeout [Integer, nil] Optional timeout override (seconds)
26
+ # @param cache_dir [String, nil] Assignment cache directory for last-message capture
27
+ # @return [Hash] QueryInterface response
28
+ def launch(assignment_id:, fork_root:, provider: nil, cli_args: nil, timeout: nil, cache_dir: nil)
29
+ resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
30
+ resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
31
+ scoped_assignment = "#{assignment_id}@#{fork_root}"
32
+ last_msg_file = build_last_message_file(cache_dir, fork_root)
33
+
34
+ result = query_interface.query(
35
+ resolved_provider,
36
+ "/as-assign-drive #{scoped_assignment}",
37
+ system: nil,
38
+ cli_args: cli_args,
39
+ timeout: resolved_timeout,
40
+ fallback: false,
41
+ last_message_file: last_msg_file
42
+ )
43
+
44
+ # Layer 1 write: capture last message for non-Codex providers (or when Codex didn't write).
45
+ # Safety: `query` blocks until the subprocess exits, so by this point Layer 2 (Codex
46
+ # --output-last-message) has already finished writing. No other writer exists at this point.
47
+ if last_msg_file && result[:text] && !result[:text].strip.empty?
48
+ existing = File.exist?(last_msg_file) ? File.read(last_msg_file).strip : ""
49
+ File.write(last_msg_file, result[:text]) if existing.empty?
50
+ end
51
+
52
+ write_session_metadata(last_msg_file, result, prompt: "/as-assign-drive #{scoped_assignment}")
53
+
54
+ result
55
+ rescue Ace::LLM::Error => e
56
+ raise Error, "Fork session execution failed via #{resolved_provider}: #{e.message}"
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :config, :query_interface
62
+
63
+ def write_session_metadata(last_msg_file, result, prompt:)
64
+ return unless last_msg_file
65
+
66
+ session_id = result.dig(:metadata, :session_id)
67
+
68
+ if session_id.nil? || session_id.to_s.strip.empty?
69
+ detected = detect_provider_session(result[:provider], prompt)
70
+ session_id = detected&.dig(:session_id)
71
+ end
72
+
73
+ session_meta_file = last_msg_file.sub(/-last-message\.md$/, "-session.yml")
74
+ meta = {
75
+ "session_id" => session_id,
76
+ "provider" => result[:provider],
77
+ "model" => result[:model],
78
+ "completed_at" => Time.now.utc.iso8601
79
+ }.compact
80
+ File.write(session_meta_file, meta.to_yaml) unless meta.empty?
81
+ end
82
+
83
+ def detect_provider_session(provider, prompt)
84
+ require "ace/llm/providers/cli/molecules/session_finder"
85
+ Ace::LLM::Providers::CLI::Molecules::SessionFinder.call(
86
+ provider: provider, working_dir: Dir.pwd, prompt: prompt
87
+ )
88
+ rescue LoadError, StandardError
89
+ nil
90
+ end
91
+
92
+ def build_last_message_file(cache_dir, fork_root)
93
+ return nil unless cache_dir
94
+
95
+ sessions_dir = File.join(cache_dir, "sessions")
96
+ FileUtils.mkdir_p(sessions_dir)
97
+ File.join(sessions_dir, "#{fork_root}-last-message.md")
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Molecules
6
+ # Scans and builds queue state from step files.
7
+ #
8
+ # Reconstructs queue by scanning steps/*.md files, sorting them,
9
+ # and parsing each file to build the complete queue state.
10
+ class QueueScanner
11
+ # Scan a steps directory and build queue state
12
+ #
13
+ # @param steps_dir [String] Path to steps directory
14
+ # @param assignment [Models::Assignment] Assignment metadata
15
+ # @return [Models::QueueState] Queue state
16
+ def scan(steps_dir, assignment:)
17
+ return Models::QueueState.new(steps: [], assignment: assignment) unless File.directory?(steps_dir)
18
+
19
+ # Get all step files
20
+ files = Dir.glob(File.join(steps_dir, "*.st.md"))
21
+
22
+ # Sort files
23
+ sorted_files = Atoms::StepSorter.sort(files.map { |f| File.basename(f) })
24
+ .map { |f| File.join(steps_dir, f) }
25
+
26
+ # Parse each file into a Step
27
+ steps = sorted_files.map { |file| parse_step_file(file) }.compact
28
+
29
+ Models::QueueState.new(steps: steps, assignment: assignment)
30
+ end
31
+
32
+ # Get current step from queue
33
+ #
34
+ # @param steps_dir [String] Path to steps directory
35
+ # @param assignment [Models::Assignment] Assignment metadata
36
+ # @return [Models::Step, nil] Current in-progress step
37
+ def current(steps_dir, assignment:)
38
+ state = scan(steps_dir, assignment: assignment)
39
+ state.current
40
+ end
41
+
42
+ # Get all step numbers in the queue
43
+ #
44
+ # @param steps_dir [String] Path to steps directory
45
+ # @return [Array<String>] Step numbers
46
+ def step_numbers(steps_dir)
47
+ return [] unless File.directory?(steps_dir)
48
+
49
+ files = Dir.glob(File.join(steps_dir, "*.st.md"))
50
+ files.map do |file|
51
+ parsed = Atoms::StepFileParser.parse_filename(File.basename(file))
52
+ parsed[:number]
53
+ end.compact
54
+ end
55
+
56
+ private
57
+
58
+ def parse_step_file(file_path)
59
+ content = File.read(file_path)
60
+ parsed = Atoms::StepFileParser.parse(content)
61
+ fields = Atoms::StepFileParser.extract_fields(parsed)
62
+
63
+ # Extract number and name from filename
64
+ filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
65
+
66
+ # Load report from separate file if it exists
67
+ report = load_report(file_path, filename_info[:number], filename_info[:name])
68
+
69
+ Models::Step.new(
70
+ number: filename_info[:number],
71
+ name: fields[:name] || filename_info[:name],
72
+ status: fields[:status],
73
+ instructions: fields[:instructions],
74
+ report: report,
75
+ error: fields[:error],
76
+ started_at: fields[:started_at],
77
+ completed_at: fields[:completed_at],
78
+ fork_launch_pid: fields[:fork_launch_pid],
79
+ fork_tracked_pids: fields[:fork_tracked_pids],
80
+ fork_pid_updated_at: fields[:fork_pid_updated_at],
81
+ fork_pid_file: fields[:fork_pid_file],
82
+ added_by: fields[:added_by],
83
+ parent: fields[:parent],
84
+ skill: fields[:skill],
85
+ workflow: fields[:workflow],
86
+ context: fields[:context],
87
+ batch_parent: fields[:batch_parent],
88
+ parallel: fields[:parallel],
89
+ max_parallel: fields[:max_parallel],
90
+ fork_retry_limit: fields[:fork_retry_limit],
91
+ stall_reason: fields[:stall_reason],
92
+ file_path: file_path
93
+ )
94
+ rescue ArgumentError => e
95
+ # ArgumentError indicates invalid data (e.g., invalid context value)
96
+ # Surface these errors visibly to help users fix configuration
97
+ warn "Invalid step file #{file_path}: #{e.message}"
98
+ nil
99
+ rescue => e
100
+ warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
101
+ nil
102
+ end
103
+
104
+ # Load report from the reports/ directory
105
+ # @param step_file_path [String] Path to step file
106
+ # @param number [String] Step number
107
+ # @param name [String] Step name
108
+ # @return [String, nil] Report content or nil
109
+ def load_report(step_file_path, number, name)
110
+ # reports/ is sibling of steps/
111
+ steps_dir = File.dirname(step_file_path)
112
+ cache_dir = File.dirname(steps_dir)
113
+ reports_dir = File.join(cache_dir, "reports")
114
+
115
+ return nil unless File.directory?(reports_dir)
116
+
117
+ report_filename = Atoms::StepFileParser.generate_report_filename(number, name)
118
+ report_path = File.join(reports_dir, report_filename)
119
+
120
+ return nil unless File.exist?(report_path)
121
+
122
+ # Read report file and extract body (skip frontmatter)
123
+ content = File.read(report_path)
124
+ parsed = Atoms::StepFileParser.parse(content)
125
+ parsed[:body].strip
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end