ace-assign 0.42.4 → 0.53.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
  4. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
  6. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
  7. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
  8. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
  9. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
  10. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
  11. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
  12. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
  13. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
  14. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
  15. data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
  16. data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
  17. data/.ace-defaults/assign/presets/work-on-task.yml +3 -16
  18. data/CHANGELOG.md +201 -0
  19. data/README.md +20 -43
  20. data/docs/demo/canonical-skill-source.gif +0 -0
  21. data/docs/demo/canonical-skill-source.tape.yml +51 -0
  22. data/docs/demo/fork-provider.cast +957 -0
  23. data/docs/demo/fork-provider.gif +0 -0
  24. data/docs/demo/fork-provider.recording.json +32 -0
  25. data/docs/demo/fork-provider.tape.yml +65 -20
  26. data/docs/getting-started.md +5 -2
  27. data/docs/usage.md +47 -0
  28. data/handbook/guides/fork-context.g.md +2 -2
  29. data/handbook/skills/as-assign-drive/SKILL.md +13 -1
  30. data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
  31. data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
  32. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
  33. data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
  34. data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
  35. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
  36. data/handbook/workflow-instructions/assign/create.wf.md +6 -3
  37. data/handbook/workflow-instructions/assign/drive.wf.md +231 -14
  38. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
  39. data/handbook/workflow-instructions/assign/prepare.wf.md +5 -5
  40. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
  41. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
  42. data/handbook/workflow-instructions/assign/start.wf.md +5 -2
  43. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
  44. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
  45. data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
  46. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  47. data/lib/ace/assign/cli/commands/assignment_target.rb +53 -0
  48. data/lib/ace/assign/cli/commands/finish.rb +7 -4
  49. data/lib/ace/assign/cli/commands/fork_run.rb +4 -1
  50. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  51. data/lib/ace/assign/cli/commands/start.rb +9 -3
  52. data/lib/ace/assign/cli/commands/status.rb +208 -227
  53. data/lib/ace/assign/cli/commands/step.rb +62 -0
  54. data/lib/ace/assign/cli.rb +8 -1
  55. data/lib/ace/assign/models/step.rb +4 -2
  56. data/lib/ace/assign/molecules/fork_session_launcher.rb +189 -8
  57. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  58. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
  59. data/lib/ace/assign/molecules/tmux_fork_runner.rb +191 -0
  60. data/lib/ace/assign/organisms/assignment_executor.rb +223 -24
  61. data/lib/ace/assign/version.rb +1 -1
  62. metadata +21 -5
  63. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +0 -42
@@ -6,27 +6,11 @@ module Ace
6
6
  module Assign
7
7
  module CLI
8
8
  module Commands
9
- # Display current queue status
10
- #
11
- # Shows the work queue with hierarchical step structure.
12
- # Nested steps are indented to show parent-child relationships.
13
- #
14
- # @example Basic usage
15
- # ace-assign status
16
- #
17
- # @example Flat output (no hierarchy)
18
- # ace-assign status --flat
19
- #
20
- # @example Status for specific assignment
21
- # ace-assign status --assignment abc123
22
- #
23
- # @example Show all assignments including completed
24
- # ace-assign status --all
9
+ # Display current queue status.
25
10
  class Status < Ace::Support::Cli::Command
26
11
  include Ace::Support::Cli::Base
27
12
  include AssignmentTarget
28
13
 
29
- # Status icons for consistent display
30
14
  STATUS_ICONS = {
31
15
  done: "✓ Done",
32
16
  in_progress: "▶ Active",
@@ -34,24 +18,26 @@ module Ace
34
18
  failed: "✗ Failed"
35
19
  }.freeze
36
20
 
37
- # State labels for other assignments section
38
21
  STATE_LABELS = {
39
22
  running: "running",
40
23
  paused: "paused",
41
24
  completed: "completed",
42
25
  failed: "failed",
43
- empty: "empty"
26
+ empty: "empty",
27
+ stalled: "stalled"
44
28
  }.freeze
29
+ PROGRESS_BAR_WIDTH = 10
45
30
 
46
- # Column widths for hierarchical display
47
31
  COL_NUMBER = 12
48
32
  COL_STATUS = 12
49
33
  COL_NAME = 30
50
34
  COL_FORK = 6
35
+ PREVIEW_LIMIT = 5
51
36
 
52
37
  desc "Display current workflow queue status"
53
38
 
54
- option :flat, aliases: ["-f"], type: :boolean, default: false, desc: "Show flat list (no hierarchy)"
39
+ option :flat, aliases: ["-f"], type: :boolean, default: false, desc: "Show flat list (full mode only)"
40
+ option :mode, desc: "Text output mode (compact, progress, full)", default: "compact"
55
41
  option :format, desc: "Output format (table, json)", default: "table"
56
42
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
57
43
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
@@ -60,81 +46,43 @@ module Ace
60
46
 
61
47
  def call(**options)
62
48
  target = resolve_assignment_target(options)
49
+ view = resolve_assignment_view(target)
63
50
 
64
- executor = build_executor_for_target(target)
65
- result = executor.status
66
- state = result[:state]
67
- assignment = result[:assignment]
68
- scoped = scoped_status_view(state, target.scope)
69
- scoped_state = scoped[:state]
70
- current_for_display = scoped[:current]
71
- scope_root = scoped[:root]
72
-
73
- unless options[:quiet]
74
- if options[:format] == "json"
75
- scoped_fork_step = scoped_fork_metadata_step(state, current_for_display, target.scope, scope_root)
76
- puts JSON.pretty_generate(status_to_h(assignment, scoped_state, current_for_display, scoped_fork_step: scoped_fork_step))
77
- return
78
- end
51
+ return if options[:quiet]
79
52
 
80
- print_queue_status(assignment, scoped_state, flat: options[:flat], root_number: scope_root)
81
-
82
- if current_for_display
83
- fork_root = fork_scope_root(state, current_for_display)
84
- scoped_fork_step = scoped_fork_metadata_step(state, current_for_display, target.scope, scope_root)
85
-
86
- puts
87
- puts "Current Step: #{current_for_display.number} - #{current_for_display.name}"
88
- puts "Current Status: #{current_for_display.status}"
89
- if current_for_display.stall_reason
90
- lines = current_for_display.stall_reason.to_s.strip.lines
91
- puts "Stall Reason: #{lines.first&.chomp}"
92
- lines[1..].each { |l| puts " #{l.chomp}" } if lines.length > 1
93
- print_hitl_stall_guidance(lines.first.to_s)
94
- end
95
- if current_for_display.workflow
96
- puts "Workflow: #{current_for_display.workflow}"
97
- elsif current_for_display.skill
98
- puts "Skill: #{current_for_display.skill}"
99
- end
100
- if current_for_display.context
101
- puts "Context: #{current_for_display.context}"
102
- end
103
- effective_fork_provider = effective_fork_provider_for(current_for_display, scoped_fork_step)
104
- if effective_fork_provider
105
- puts "Fork Provider: #{effective_fork_provider}"
106
- end
107
- puts
108
- print_scoped_fork_pid_info(scoped_fork_step)
109
-
110
- if current_for_display.fork? && %i[pending in_progress].include?(current_for_display.status)
111
- # Fork context: output Task tool instructions
112
- print_fork_instructions(current_for_display, assignment)
113
- else
114
- puts "Instructions:"
115
- puts current_for_display.instructions
116
-
117
- if fork_root && (target.scope.nil? || target.scope.strip.empty?)
118
- puts
119
- puts "Fork subtree detected (root: #{fork_root.number} - #{fork_root.name})."
120
- puts "Run in forked process:"
121
- puts " ace-assign fork-run --root #{fork_root.number} --assignment #{assignment.id}"
122
- end
123
- end
124
- elsif scoped_state.complete?
125
- puts
126
- puts "Assignment completed!"
127
- end
53
+ if options[:format] == "json"
54
+ scoped_fork_step = scoped_fork_metadata_step(view.state, view.current_step, target.scope, view.scope_root)
55
+ puts JSON.pretty_generate(
56
+ status_to_h(view.assignment, view.scoped_state, view.current_step, scoped_fork_step: scoped_fork_step)
57
+ )
58
+ return
59
+ end
128
60
 
129
- # Show other assignments section (unless targeting a specific assignment)
130
- unless target.assignment_id
131
- print_other_assignments(result[:assignment].id, include_completed: options[:all])
132
- end
61
+ mode = normalize_mode(options[:mode])
62
+ raise Ace::Support::Cli::Error, "--flat is supported only with --mode full" if options[:flat] && mode != "full"
63
+ raise Ace::Support::Cli::Error, "--all is supported only with --mode full or compact" if options[:all] && mode == "progress"
64
+
65
+ case mode
66
+ when "progress"
67
+ puts progress_summary_line(view.assignment, view.scoped_state, view.current_step)
68
+ when "full"
69
+ print_full_status(view, target, flat: options[:flat], include_completed: options[:all])
70
+ else
71
+ print_compact_status(view, target, include_completed: options[:all])
133
72
  end
134
73
  end
135
74
 
136
75
  private
137
76
 
77
+ def normalize_mode(value)
78
+ mode = value.to_s.strip
79
+ mode = "compact" if mode.empty?
80
+ allowed = %w[compact progress full]
81
+ raise Ace::Support::Cli::Error, "Unsupported status mode '#{mode}'. Use one of: #{allowed.join(', ')}." unless allowed.include?(mode)
82
+
83
+ mode
84
+ end
85
+
138
86
  def status_to_h(assignment, state, current_step, scoped_fork_step: nil)
139
87
  {
140
88
  assignment: {
@@ -143,7 +91,10 @@ module Ace
143
91
  state: state.assignment_state.to_s
144
92
  },
145
93
  steps: state.steps.map { |step| step_to_h(step) },
146
- current_step: step_to_h(current_step, effective_fork_provider: effective_fork_provider_for(current_step, scoped_fork_step)),
94
+ current_step: step_to_h(
95
+ current_step,
96
+ effective_fork_provider: effective_fork_provider_for(current_step, scoped_fork_step)
97
+ ),
147
98
  progress: "#{state.done.size}/#{state.size} done"
148
99
  }
149
100
  end
@@ -167,17 +118,149 @@ module Ace
167
118
  }.compact
168
119
  end
169
120
 
170
- def scoped_status_view(state, scope)
171
- return {state: state, current: state.current, root: nil} if scope.nil? || scope.strip.empty?
121
+ def print_compact_status(view, target, include_completed:)
122
+ lines = []
123
+ lines.concat(compact_summary_lines(view.assignment, view.scoped_state, view.current_step))
124
+
125
+ unless target.assignment_id
126
+ other_line = compact_other_assignments_line(view.assignment.id, include_completed: include_completed)
127
+ lines << other_line if other_line
128
+ end
129
+
130
+ puts lines.take(10).join("\n")
131
+ end
132
+
133
+ def compact_summary_lines(assignment, state, current_step)
134
+ lines = [
135
+ compact_assignment_line(assignment, state, current_step),
136
+ compact_last_done_line(state)
137
+ ]
138
+
139
+ preview_heading, preview_steps = compact_preview(state)
140
+ unless preview_steps.empty?
141
+ lines << preview_heading
142
+ preview_steps.each do |step|
143
+ lines << preview_step_line(step)
144
+ end
145
+ end
146
+
147
+ lines << compact_steps_summary_line(state)
148
+ lines
149
+ end
150
+
151
+ def progress_summary_line(assignment, state, current_step)
152
+ state_label = STATE_LABELS[state.assignment_state] || state.assignment_state.to_s
153
+ details = ["State: #{state_label}", "Progress: #{state.done.size}/#{state.size} done"]
154
+
155
+ if current_step
156
+ details << "Current: #{current_step.number} #{current_step.name}"
157
+ elsif state.complete?
158
+ details << "Current: complete"
159
+ end
160
+
161
+ if state.last_done
162
+ details << "Last: #{state.last_done.number} #{state.last_done.name}"
163
+ end
164
+
165
+ details.join(" | ")
166
+ end
167
+
168
+ def compact_assignment_line(assignment, state, current_step)
169
+ state_label = STATE_LABELS[state.assignment_state] || state.assignment_state.to_s
170
+ details = ["Assignment: #{assignment.id} #{compact_assignment_name(assignment.name)}", "Status: #{state_label}"]
171
+
172
+ if current_step
173
+ details << "Current: #{current_step.number} #{current_step.name}"
174
+ end
175
+ details.join(" | ")
176
+ end
177
+
178
+ def compact_last_done_line(state)
179
+ return "Last done: none" unless state.last_done
180
+
181
+ "Last done: #{state.last_done.number} #{state.last_done.name}"
182
+ end
183
+
184
+ def compact_steps_summary_line(state)
185
+ summary = state.summary
186
+ "Steps: #{progress_bar(state.done.size, state.size)} #{state.done.size}/#{state.size} done | Pending: #{summary[:pending]} | Failed: #{summary[:failed]}"
187
+ end
188
+
189
+ def compact_assignment_name(name)
190
+ File.basename(name.to_s, File.extname(name.to_s))
191
+ end
192
+
193
+ def compact_preview(state)
194
+ active_or_pending = state.steps.select { |step| %i[in_progress pending].include?(step.status) }.first(PREVIEW_LIMIT)
195
+ return ["Pending steps:", active_or_pending] unless active_or_pending.empty?
196
+
197
+ failed_preview = state.failed.first(PREVIEW_LIMIT)
198
+ return ["Failed steps:", failed_preview] if state.assignment_state == :failed && failed_preview.any?
199
+
200
+ [nil, []]
201
+ end
202
+
203
+ def preview_step_line(step)
204
+ status = case step.status
205
+ when :in_progress then "active"
206
+ when :pending then "next"
207
+ when :failed then "failed"
208
+ else step.status.to_s
209
+ end
210
+ "#{step.number} #{status} #{step.name}"
211
+ end
212
+
213
+ def progress_bar(done, total)
214
+ return "░" * PROGRESS_BAR_WIDTH if total <= 0
215
+
216
+ filled = ((done.to_f / total) * PROGRESS_BAR_WIDTH).round
217
+ filled = [[filled, 0].max, PROGRESS_BAR_WIDTH].min
218
+ ("█" * filled) + ("░" * (PROGRESS_BAR_WIDTH - filled))
219
+ end
220
+
221
+ def compact_other_assignments_line(current_assignment_id, include_completed:)
222
+ discoverer = Molecules::AssignmentDiscoverer.new
223
+ others = discoverer.find_all(include_completed: include_completed).reject { |info| info.id == current_assignment_id }
224
+ return nil if others.empty?
225
+
226
+ active = others.count { |info| %i[running stalled].include?(info.state) }
227
+ pending = others.count { |info| info.state == :paused }
228
+ failed = others.count { |info| info.state == :failed }
229
+ "other assignments: #{others.size} total | active: #{active} paused: #{pending} failed: #{failed}"
230
+ end
231
+
232
+ def print_full_status(view, target, flat:, include_completed:)
233
+ print_queue_status(view.assignment, view.scoped_state, flat: flat, root_number: view.scope_root)
234
+
235
+ if view.current_step
236
+ scoped_fork_step = scoped_fork_metadata_step(view.state, view.current_step, target.scope, view.scope_root)
237
+
238
+ puts
239
+ puts "Current Step: #{view.current_step.number} - #{view.current_step.name}"
240
+ puts "Current Status: #{view.current_step.status}"
241
+ print_stall_details(view.current_step)
242
+ puts "Workflow: #{view.current_step.workflow}" if view.current_step.workflow
243
+ puts "Skill: #{view.current_step.skill}" if !view.current_step.workflow && view.current_step.skill
244
+ puts "Context: #{view.current_step.context}" if view.current_step.context
245
+
246
+ effective_fork_provider = effective_fork_provider_for(view.current_step, scoped_fork_step)
247
+ puts "Fork Provider: #{effective_fork_provider}" if effective_fork_provider
248
+ print_scoped_fork_pid_info(scoped_fork_step)
249
+ elsif view.scoped_state.complete?
250
+ puts
251
+ puts "Assignment completed!"
252
+ end
172
253
 
173
- root = state.find_by_number(scope.strip)
174
- raise StepErrors::NotFound, "Step #{scope} not found in queue" unless root
254
+ print_other_assignments_table(view.assignment.id, include_completed: include_completed) unless target.assignment_id
255
+ end
175
256
 
176
- scoped_steps = state.subtree_steps(root.number)
177
- scoped_state = Models::QueueState.new(steps: scoped_steps, assignment: state.assignment)
178
- current = scoped_state.current || scoped_state.next_workable
257
+ def print_stall_details(step)
258
+ return unless step.stall_reason
179
259
 
180
- {state: scoped_state, current: current, root: root.number}
260
+ lines = step.stall_reason.to_s.strip.lines
261
+ puts "Stall Reason: #{lines.first&.chomp}"
262
+ lines[1..].each { |line| puts " #{line.chomp}" } if lines.length > 1
263
+ print_hitl_stall_guidance(lines.first.to_s)
181
264
  end
182
265
 
183
266
  def print_queue_status(assignment, state, flat: false, root_number: nil)
@@ -192,43 +275,31 @@ module Ace
192
275
  end
193
276
 
194
277
  def has_nested_steps?(state)
195
- state.steps.any? { |s| !Atoms::StepNumbering.top_level?(s.number) }
278
+ state.steps.any? { |step| !Atoms::StepNumbering.top_level?(step.number) }
196
279
  end
197
280
 
198
281
  def print_flat_status(state)
199
- # Calculate column widths
200
- file_width = [30, state.steps.map { |s| File.basename(s.file_path || "").length }.max || 20].max
282
+ file_width = [30, state.steps.map { |step| File.basename(step.file_path || "").length }.max || 20].max
201
283
  status_width = 12
202
284
  name_width = 20
203
285
 
204
- # Header
205
286
  puts format("%-#{file_width}s %-#{status_width}s %-#{name_width}s", "FILE", "STATUS", "NAME")
206
287
 
207
- # Rows
208
288
  state.steps.each do |step|
209
289
  file = File.basename(step.file_path || "#{step.number}-#{step.name}.st.md")
210
290
  status = format_status(step.status)
211
- name = step.name
212
-
213
- row = format("%-#{file_width}s %-#{status_width}s %-#{name_width}s", file, status, name)
214
-
215
- # Add error message for failed steps
216
- if step.status == :failed && step.error
217
- row += " (#{step.error})"
218
- end
219
-
291
+ row = format("%-#{file_width}s %-#{status_width}s %-#{name_width}s", file, status, step.name)
292
+ row += " (#{step.error})" if step.status == :failed && step.error
220
293
  puts row
221
294
  end
222
295
  end
223
296
 
224
297
  def print_hierarchical_status(state, root_number: nil)
225
- # Header
226
298
  puts format("%-#{COL_NUMBER}s %-#{COL_STATUS}s %-#{COL_NAME}s %-#{COL_FORK}s %s", "NUMBER", "STATUS", "NAME", "FORK", "CHILDREN")
227
299
  puts "-" * 78
228
300
 
229
- # Print hierarchy with tree structure
230
301
  nodes = root_hierarchy_nodes(state, root_number)
231
- print_hierarchy_level(nodes, state, depth: 0)
302
+ print_hierarchy_level(nodes, depth: 0)
232
303
  end
233
304
 
234
305
  def root_hierarchy_nodes(state, root_number)
@@ -241,63 +312,34 @@ module Ace
241
312
  end
242
313
 
243
314
  def build_hierarchy_node(state, step)
244
- children = state.children_of(step.number).map do |child|
245
- build_hierarchy_node(state, child)
246
- end
247
-
248
- {step: step, children: children}
315
+ {
316
+ step: step,
317
+ children: state.children_of(step.number).map { |child| build_hierarchy_node(state, child) }
318
+ }
249
319
  end
250
320
 
251
- def print_hierarchy_level(nodes, state, depth:)
321
+ def print_hierarchy_level(nodes, depth:)
252
322
  nodes.each_with_index do |node, index|
253
323
  step = node[:step]
254
324
  children = node[:children]
255
- is_last = index == nodes.size - 1
256
-
257
- # Build tree prefix
258
- prefix = if depth == 0
325
+ prefix = if depth.zero?
259
326
  ""
260
327
  else
261
- indent = " " * (depth - 1)
262
- connector = is_last ? "\\-- " : "|-- "
263
- indent + connector
328
+ (" " * (depth - 1)) + (index == nodes.size - 1 ? "\\-- " : "|-- ")
264
329
  end
265
330
 
266
- # Format number with hierarchy indicator
267
- number_display = prefix + step.number
268
-
269
- # Status with icon
270
331
  status_icon = STATUS_ICONS[step.status] || step.status.to_s.capitalize
271
-
272
- # Fork indicator reflects execution context, not child presence.
273
332
  fork_info = step.fork? ? "yes" : ""
274
-
275
- # Children count (progress visibility)
276
- child_info = if children.any?
277
- incomplete = children.count { |c| c[:step].status != :done }
278
- if incomplete > 0
279
- "(#{children.size - incomplete}/#{children.size} done)"
280
- else
281
- "(#{children.size}/#{children.size} done)"
282
- end
283
- else
284
- ""
285
- end
286
-
287
- # Error info for failed steps
333
+ child_info = children.any? ? "(#{children.count { |c| c[:step].status == :done }}/#{children.size} done)" : ""
288
334
  error_suffix = (step.status == :failed && step.error) ? " - #{step.error}" : ""
335
+ display_name = step.name.length > COL_NAME ? "#{step.name[0..COL_NAME - 4]}..." : step.name
289
336
 
290
- # Truncate name with ellipsis if too long
291
- display_name = if step.name.length > COL_NAME
292
- step.name[0..COL_NAME - 4] + "..."
293
- else
294
- step.name
295
- end
296
- puts format("%-#{COL_NUMBER}s %-#{COL_STATUS}s %-#{COL_NAME}s %-#{COL_FORK}s %s%s",
297
- number_display, status_icon, display_name, fork_info, child_info, error_suffix)
337
+ puts format(
338
+ "%-#{COL_NUMBER}s %-#{COL_STATUS}s %-#{COL_NAME}s %-#{COL_FORK}s %s%s",
339
+ prefix + step.number, status_icon, display_name, fork_info, child_info, error_suffix
340
+ )
298
341
 
299
- # Recurse for children
300
- print_hierarchy_level(children, state, depth: depth + 1) if children.any?
342
+ print_hierarchy_level(children, depth: depth + 1) if children.any?
301
343
  end
302
344
  end
303
345
 
@@ -305,57 +347,6 @@ module Ace
305
347
  STATUS_ICONS[status]&.split(" ")&.last || status.to_s.capitalize
306
348
  end
307
349
 
308
- # Print Task tool instructions for a fork context step
309
- def print_fork_instructions(step, assignment)
310
- escaped_name = step.name.gsub('"', '\\"')
311
- # Derive project root from cache_dir: /project/.ace-local/assign/assignment-id -> /project
312
- project_root = assignment.cache_dir ? File.expand_path("../../..", assignment.cache_dir) : Dir.pwd
313
-
314
- puts "Execute this step in a forked context:"
315
- puts
316
- puts " Task tool parameters:"
317
- puts " description: \"#{escaped_name}\""
318
- puts " prompt: (see below)"
319
- puts
320
- puts " Prompt for forked agent:"
321
- puts " ========================"
322
- puts step.instructions
323
- puts " ========================"
324
- puts
325
- puts " Working directory: #{project_root}"
326
- puts " Assignment: #{assignment.id}"
327
- puts
328
- puts "After completing, run:"
329
- puts " ace-assign finish --message <report-file.md>"
330
- puts
331
- puts "To execute entire subtree in one forked process:"
332
- puts " ace-assign fork-run --root #{step.number} --assignment #{assignment.id}"
333
- end
334
-
335
- def fork_scope_root(state, current_step)
336
- return nil unless current_step
337
- return current_step if current_step.fork?
338
-
339
- state.nearest_fork_ancestor(current_step.number)
340
- end
341
-
342
- def scoped_fork_metadata_step(state, current_step, scope, scope_root)
343
- return nil unless current_step
344
-
345
- if scope && !scope.strip.empty?
346
- return state.find_by_number(scope_root || scope.strip)
347
- end
348
-
349
- fork_scope_root(state, current_step)
350
- end
351
-
352
- def effective_fork_provider_for(current_step, scoped_fork_step)
353
- return nil unless current_step
354
-
355
- provider = current_step.fork_provider || scoped_fork_step&.fork_provider
356
- provider.to_s.strip.empty? ? nil : provider
357
- end
358
-
359
350
  def print_scoped_fork_pid_info(step)
360
351
  return unless step
361
352
 
@@ -365,9 +356,8 @@ module Ace
365
356
  return unless has_pid || has_tree || has_file
366
357
 
367
358
  puts "Scoped Fork PID: #{step.fork_launch_pid}" if has_pid
368
- puts "Scoped Fork PID Tree: #{step.fork_tracked_pids.join(", ")}" if has_tree
359
+ puts "Scoped Fork PID Tree: #{step.fork_tracked_pids.join(', ')}" if has_tree
369
360
  puts "Scoped Fork PID File: #{step.fork_pid_file}" if has_file
370
- puts
371
361
  end
372
362
 
373
363
  def print_hitl_stall_guidance(first_line)
@@ -393,13 +383,9 @@ module Ace
393
383
  {id: id, path: path.empty? ? nil : path}
394
384
  end
395
385
 
396
- # Print other assignments section
397
- def print_other_assignments(current_assignment_id, include_completed:)
386
+ def print_other_assignments_table(current_assignment_id, include_completed:)
398
387
  discoverer = Molecules::AssignmentDiscoverer.new
399
- all_assignments = discoverer.find_all(include_completed: include_completed)
400
-
401
- # Exclude the current assignment
402
- others = all_assignments.reject { |ai| ai.id == current_assignment_id }
388
+ others = discoverer.find_all(include_completed: include_completed).reject { |info| info.id == current_assignment_id }
403
389
  return if others.empty?
404
390
 
405
391
  puts
@@ -416,8 +402,7 @@ module Ace
416
402
  others.each do |info|
417
403
  state_label = STATE_LABELS[info.state] || info.state.to_s
418
404
  updated = format_relative_time(info.updated_at)
419
- step = (info.current_step.length > col_step) ? info.current_step[0..col_step - 4] + "..." : info.current_step
420
-
405
+ step = info.current_step.length > col_step ? "#{info.current_step[0..col_step - 4]}..." : info.current_step
421
406
  puts format("%-#{col_id}s %-#{col_status}s %-#{col_progress}s %-#{col_step}s %s",
422
407
  info.id, state_label, info.progress, step, updated)
423
408
  end
@@ -427,15 +412,11 @@ module Ace
427
412
  return "-" unless time
428
413
 
429
414
  diff = Time.now - time
430
- if diff < 60
431
- "#{diff.to_i}s ago"
432
- elsif diff < 3600
433
- "#{(diff / 60).to_i}m ago"
434
- elsif diff < 86_400
435
- "#{(diff / 3600).to_i}h ago"
436
- else
437
- "#{(diff / 86_400).to_i}d ago"
438
- end
415
+ return "#{diff.to_i}s ago" if diff < 60
416
+ return "#{(diff / 60).to_i}m ago" if diff < 3600
417
+ return "#{(diff / 3600).to_i}h ago" if diff < 86_400
418
+
419
+ "#{(diff / 86_400).to_i}d ago"
439
420
  end
440
421
  end
441
422
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module CLI
6
+ module Commands
7
+ # Print instructions for the current, next, or an explicit step.
8
+ class Step < Ace::Support::Cli::Command
9
+ include Ace::Support::Cli::Base
10
+ include AssignmentTarget
11
+
12
+ desc "Show instructions for the current, next, or explicit step"
13
+
14
+ argument :step, required: false, desc: "Exact step number to inspect"
15
+ option :assignment, desc: "Target specific assignment ID"
16
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
17
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
18
+
19
+ def call(step: nil, **options)
20
+ target = resolve_assignment_target(options)
21
+ view = resolve_assignment_view(target)
22
+ inspected = resolve_step(view, step, target)
23
+
24
+ return if options[:quiet]
25
+
26
+ if inspected
27
+ puts inspected.instructions
28
+ else
29
+ puts no_work_message(view)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_step(view, explicit_step, target)
36
+ if explicit_step && !explicit_step.to_s.strip.empty?
37
+ step = view.state.find_by_number(explicit_step)
38
+ raise StepErrors::NotFound, "Step #{explicit_step} not found in queue" unless step
39
+
40
+ if target.scope && !target.scope.strip.empty? && !view.scoped_state.in_subtree?(target.scope, step.number)
41
+ raise StepErrors::NotFound, "Step #{explicit_step} is outside subtree #{target.scope}"
42
+ end
43
+
44
+ return step
45
+ end
46
+
47
+ view.current_step
48
+ end
49
+
50
+ def no_work_message(view)
51
+ state_label = view.scoped_state.assignment_state.to_s
52
+ last_done = view.scoped_state.last_done ? "#{view.scoped_state.last_done.number} #{view.scoped_state.last_done.name}" : "none"
53
+ [
54
+ "Assignment: #{view.assignment.id} | Status: #{state_label} | Progress: #{view.scoped_state.done.size}/#{view.scoped_state.size} done",
55
+ "Last done: #{last_done} | No current or next workable step"
56
+ ].join("\n")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end