ace-assign 0.41.10 → 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 (64) 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 -15
  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 -20
  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 -21
  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 +260 -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 +63 -3
  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 +273 -15
  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/preset_expander.rb +12 -1
  47. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  48. data/lib/ace/assign/cli/commands/assignment_target.rb +53 -0
  49. data/lib/ace/assign/cli/commands/finish.rb +7 -4
  50. data/lib/ace/assign/cli/commands/fork_run.rb +4 -1
  51. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  52. data/lib/ace/assign/cli/commands/start.rb +9 -3
  53. data/lib/ace/assign/cli/commands/status.rb +231 -226
  54. data/lib/ace/assign/cli/commands/step.rb +62 -0
  55. data/lib/ace/assign/cli.rb +8 -1
  56. data/lib/ace/assign/models/step.rb +4 -2
  57. data/lib/ace/assign/molecules/fork_session_launcher.rb +189 -8
  58. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  59. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +252 -50
  60. data/lib/ace/assign/molecules/tmux_fork_runner.rb +191 -0
  61. data/lib/ace/assign/organisms/assignment_executor.rb +294 -40
  62. data/lib/ace/assign/version.rb +1 -1
  63. metadata +21 -5
  64. 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,80 +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
- end
94
- if current_for_display.workflow
95
- puts "Workflow: #{current_for_display.workflow}"
96
- elsif current_for_display.skill
97
- puts "Skill: #{current_for_display.skill}"
98
- end
99
- if current_for_display.context
100
- puts "Context: #{current_for_display.context}"
101
- end
102
- effective_fork_provider = effective_fork_provider_for(current_for_display, scoped_fork_step)
103
- if effective_fork_provider
104
- puts "Fork Provider: #{effective_fork_provider}"
105
- end
106
- puts
107
- print_scoped_fork_pid_info(scoped_fork_step)
108
-
109
- if current_for_display.fork? && %i[pending in_progress].include?(current_for_display.status)
110
- # Fork context: output Task tool instructions
111
- print_fork_instructions(current_for_display, assignment)
112
- else
113
- puts "Instructions:"
114
- puts current_for_display.instructions
115
-
116
- if fork_root && (target.scope.nil? || target.scope.strip.empty?)
117
- puts
118
- puts "Fork subtree detected (root: #{fork_root.number} - #{fork_root.name})."
119
- puts "Run in forked process:"
120
- puts " ace-assign fork-run --root #{fork_root.number} --assignment #{assignment.id}"
121
- end
122
- end
123
- elsif scoped_state.complete?
124
- puts
125
- puts "Assignment completed!"
126
- 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
127
60
 
128
- # Show other assignments section (unless targeting a specific assignment)
129
- unless target.assignment_id
130
- print_other_assignments(result[:assignment].id, include_completed: options[:all])
131
- 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])
132
72
  end
133
73
  end
134
74
 
135
75
  private
136
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
+
137
86
  def status_to_h(assignment, state, current_step, scoped_fork_step: nil)
138
87
  {
139
88
  assignment: {
@@ -142,7 +91,10 @@ module Ace
142
91
  state: state.assignment_state.to_s
143
92
  },
144
93
  steps: state.steps.map { |step| step_to_h(step) },
145
- 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
+ ),
146
98
  progress: "#{state.done.size}/#{state.size} done"
147
99
  }
148
100
  end
@@ -166,17 +118,149 @@ module Ace
166
118
  }.compact
167
119
  end
168
120
 
169
- def scoped_status_view(state, scope)
170
- 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
171
 
172
- root = state.find_by_number(scope.strip)
173
- raise StepErrors::NotFound, "Step #{scope} not found in queue" unless root
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
174
202
 
175
- scoped_steps = state.subtree_steps(root.number)
176
- scoped_state = Models::QueueState.new(steps: scoped_steps, assignment: state.assignment)
177
- current = scoped_state.current || scoped_state.next_workable
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?
178
225
 
179
- {state: scoped_state, current: current, root: root.number}
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
253
+
254
+ print_other_assignments_table(view.assignment.id, include_completed: include_completed) unless target.assignment_id
255
+ end
256
+
257
+ def print_stall_details(step)
258
+ return unless step.stall_reason
259
+
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)
180
264
  end
181
265
 
182
266
  def print_queue_status(assignment, state, flat: false, root_number: nil)
@@ -191,43 +275,31 @@ module Ace
191
275
  end
192
276
 
193
277
  def has_nested_steps?(state)
194
- state.steps.any? { |s| !Atoms::StepNumbering.top_level?(s.number) }
278
+ state.steps.any? { |step| !Atoms::StepNumbering.top_level?(step.number) }
195
279
  end
196
280
 
197
281
  def print_flat_status(state)
198
- # Calculate column widths
199
- 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
200
283
  status_width = 12
201
284
  name_width = 20
202
285
 
203
- # Header
204
286
  puts format("%-#{file_width}s %-#{status_width}s %-#{name_width}s", "FILE", "STATUS", "NAME")
205
287
 
206
- # Rows
207
288
  state.steps.each do |step|
208
289
  file = File.basename(step.file_path || "#{step.number}-#{step.name}.st.md")
209
290
  status = format_status(step.status)
210
- name = step.name
211
-
212
- row = format("%-#{file_width}s %-#{status_width}s %-#{name_width}s", file, status, name)
213
-
214
- # Add error message for failed steps
215
- if step.status == :failed && step.error
216
- row += " (#{step.error})"
217
- end
218
-
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
219
293
  puts row
220
294
  end
221
295
  end
222
296
 
223
297
  def print_hierarchical_status(state, root_number: nil)
224
- # Header
225
298
  puts format("%-#{COL_NUMBER}s %-#{COL_STATUS}s %-#{COL_NAME}s %-#{COL_FORK}s %s", "NUMBER", "STATUS", "NAME", "FORK", "CHILDREN")
226
299
  puts "-" * 78
227
300
 
228
- # Print hierarchy with tree structure
229
301
  nodes = root_hierarchy_nodes(state, root_number)
230
- print_hierarchy_level(nodes, state, depth: 0)
302
+ print_hierarchy_level(nodes, depth: 0)
231
303
  end
232
304
 
233
305
  def root_hierarchy_nodes(state, root_number)
@@ -240,63 +312,34 @@ module Ace
240
312
  end
241
313
 
242
314
  def build_hierarchy_node(state, step)
243
- children = state.children_of(step.number).map do |child|
244
- build_hierarchy_node(state, child)
245
- end
246
-
247
- {step: step, children: children}
315
+ {
316
+ step: step,
317
+ children: state.children_of(step.number).map { |child| build_hierarchy_node(state, child) }
318
+ }
248
319
  end
249
320
 
250
- def print_hierarchy_level(nodes, state, depth:)
321
+ def print_hierarchy_level(nodes, depth:)
251
322
  nodes.each_with_index do |node, index|
252
323
  step = node[:step]
253
324
  children = node[:children]
254
- is_last = index == nodes.size - 1
255
-
256
- # Build tree prefix
257
- prefix = if depth == 0
325
+ prefix = if depth.zero?
258
326
  ""
259
327
  else
260
- indent = " " * (depth - 1)
261
- connector = is_last ? "\\-- " : "|-- "
262
- indent + connector
328
+ (" " * (depth - 1)) + (index == nodes.size - 1 ? "\\-- " : "|-- ")
263
329
  end
264
330
 
265
- # Format number with hierarchy indicator
266
- number_display = prefix + step.number
267
-
268
- # Status with icon
269
331
  status_icon = STATUS_ICONS[step.status] || step.status.to_s.capitalize
270
-
271
- # Fork indicator reflects execution context, not child presence.
272
332
  fork_info = step.fork? ? "yes" : ""
273
-
274
- # Children count (progress visibility)
275
- child_info = if children.any?
276
- incomplete = children.count { |c| c[:step].status != :done }
277
- if incomplete > 0
278
- "(#{children.size - incomplete}/#{children.size} done)"
279
- else
280
- "(#{children.size}/#{children.size} done)"
281
- end
282
- else
283
- ""
284
- end
285
-
286
- # Error info for failed steps
333
+ child_info = children.any? ? "(#{children.count { |c| c[:step].status == :done }}/#{children.size} done)" : ""
287
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
288
336
 
289
- # Truncate name with ellipsis if too long
290
- display_name = if step.name.length > COL_NAME
291
- step.name[0..COL_NAME - 4] + "..."
292
- else
293
- step.name
294
- end
295
- puts format("%-#{COL_NUMBER}s %-#{COL_STATUS}s %-#{COL_NAME}s %-#{COL_FORK}s %s%s",
296
- 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
+ )
297
341
 
298
- # Recurse for children
299
- print_hierarchy_level(children, state, depth: depth + 1) if children.any?
342
+ print_hierarchy_level(children, depth: depth + 1) if children.any?
300
343
  end
301
344
  end
302
345
 
@@ -304,57 +347,6 @@ module Ace
304
347
  STATUS_ICONS[status]&.split(" ")&.last || status.to_s.capitalize
305
348
  end
306
349
 
307
- # Print Task tool instructions for a fork context step
308
- def print_fork_instructions(step, assignment)
309
- escaped_name = step.name.gsub('"', '\\"')
310
- # Derive project root from cache_dir: /project/.ace-local/assign/assignment-id -> /project
311
- project_root = assignment.cache_dir ? File.expand_path("../../..", assignment.cache_dir) : Dir.pwd
312
-
313
- puts "Execute this step in a forked context:"
314
- puts
315
- puts " Task tool parameters:"
316
- puts " description: \"#{escaped_name}\""
317
- puts " prompt: (see below)"
318
- puts
319
- puts " Prompt for forked agent:"
320
- puts " ========================"
321
- puts step.instructions
322
- puts " ========================"
323
- puts
324
- puts " Working directory: #{project_root}"
325
- puts " Assignment: #{assignment.id}"
326
- puts
327
- puts "After completing, run:"
328
- puts " ace-assign finish --message <report-file.md>"
329
- puts
330
- puts "To execute entire subtree in one forked process:"
331
- puts " ace-assign fork-run --root #{step.number} --assignment #{assignment.id}"
332
- end
333
-
334
- def fork_scope_root(state, current_step)
335
- return nil unless current_step
336
- return current_step if current_step.fork?
337
-
338
- state.nearest_fork_ancestor(current_step.number)
339
- end
340
-
341
- def scoped_fork_metadata_step(state, current_step, scope, scope_root)
342
- return nil unless current_step
343
-
344
- if scope && !scope.strip.empty?
345
- return state.find_by_number(scope_root || scope.strip)
346
- end
347
-
348
- fork_scope_root(state, current_step)
349
- end
350
-
351
- def effective_fork_provider_for(current_step, scoped_fork_step)
352
- return nil unless current_step
353
-
354
- provider = current_step.fork_provider || scoped_fork_step&.fork_provider
355
- provider.to_s.strip.empty? ? nil : provider
356
- end
357
-
358
350
  def print_scoped_fork_pid_info(step)
359
351
  return unless step
360
352
 
@@ -364,18 +356,36 @@ module Ace
364
356
  return unless has_pid || has_tree || has_file
365
357
 
366
358
  puts "Scoped Fork PID: #{step.fork_launch_pid}" if has_pid
367
- 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
368
360
  puts "Scoped Fork PID File: #{step.fork_pid_file}" if has_file
369
- puts
370
361
  end
371
362
 
372
- # Print other assignments section
373
- def print_other_assignments(current_assignment_id, include_completed:)
374
- discoverer = Molecules::AssignmentDiscoverer.new
375
- all_assignments = discoverer.find_all(include_completed: include_completed)
363
+ def print_hitl_stall_guidance(first_line)
364
+ hitl = parse_hitl_stall_reason(first_line)
365
+ return unless hitl
366
+
367
+ puts "HITL Guidance:"
368
+ puts " Review event: ace-hitl show #{hitl[:id]}"
369
+ puts " Stored path: #{hitl[:path]}" if hitl[:path]
370
+ puts " Requester default: ace-hitl wait #{hitl[:id]}"
371
+ puts " Fallback dispatch: ace-hitl update #{hitl[:id]} --answer \"<decision>\" --resume"
372
+ end
373
+
374
+ def parse_hitl_stall_reason(line)
375
+ stripped = line.to_s.strip
376
+ return nil unless stripped.start_with?("HITL:")
377
+
378
+ payload = stripped.sub(/^HITL:\s*/, "")
379
+ id, path = payload.split(/\s+/, 2)
380
+ return nil if id.to_s.strip.empty?
381
+
382
+ path = path.to_s.strip
383
+ {id: id, path: path.empty? ? nil : path}
384
+ end
376
385
 
377
- # Exclude the current assignment
378
- others = all_assignments.reject { |ai| ai.id == current_assignment_id }
386
+ def print_other_assignments_table(current_assignment_id, include_completed:)
387
+ discoverer = Molecules::AssignmentDiscoverer.new
388
+ others = discoverer.find_all(include_completed: include_completed).reject { |info| info.id == current_assignment_id }
379
389
  return if others.empty?
380
390
 
381
391
  puts
@@ -392,8 +402,7 @@ module Ace
392
402
  others.each do |info|
393
403
  state_label = STATE_LABELS[info.state] || info.state.to_s
394
404
  updated = format_relative_time(info.updated_at)
395
- step = (info.current_step.length > col_step) ? info.current_step[0..col_step - 4] + "..." : info.current_step
396
-
405
+ step = info.current_step.length > col_step ? "#{info.current_step[0..col_step - 4]}..." : info.current_step
397
406
  puts format("%-#{col_id}s %-#{col_status}s %-#{col_progress}s %-#{col_step}s %s",
398
407
  info.id, state_label, info.progress, step, updated)
399
408
  end
@@ -403,15 +412,11 @@ module Ace
403
412
  return "-" unless time
404
413
 
405
414
  diff = Time.now - time
406
- if diff < 60
407
- "#{diff.to_i}s ago"
408
- elsif diff < 3600
409
- "#{(diff / 60).to_i}m ago"
410
- elsif diff < 86_400
411
- "#{(diff / 3600).to_i}h ago"
412
- else
413
- "#{(diff / 86_400).to_i}d ago"
414
- 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"
415
420
  end
416
421
  end
417
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