ace-assign 0.42.4 → 0.55.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 (76) 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/split-subtree-root.step.yml +4 -2
  12. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
  13. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
  14. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
  15. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
  16. data/.ace-defaults/assign/config.yml +1 -0
  17. data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
  18. data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
  19. data/.ace-defaults/assign/presets/work-on-task.yml +6 -16
  20. data/CHANGELOG.md +216 -0
  21. data/README.md +20 -43
  22. data/docs/demo/canonical-skill-source.gif +0 -0
  23. data/docs/demo/canonical-skill-source.tape.yml +51 -0
  24. data/docs/demo/fork-provider.cast +834 -0
  25. data/docs/demo/fork-provider.gif +0 -0
  26. data/docs/demo/fork-provider.recording.json +30 -0
  27. data/docs/demo/fork-provider.tape.yml +77 -20
  28. data/docs/getting-started.md +5 -2
  29. data/docs/usage.md +74 -4
  30. data/handbook/guides/fork-context.g.md +31 -7
  31. data/handbook/skills/as-assign-drive/SKILL.md +13 -1
  32. data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
  33. data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
  34. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
  35. data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
  36. data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
  37. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
  38. data/handbook/workflow-instructions/assign/create.wf.md +6 -3
  39. data/handbook/workflow-instructions/assign/drive.wf.md +330 -40
  40. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
  41. data/handbook/workflow-instructions/assign/prepare.wf.md +10 -5
  42. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
  43. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
  44. data/handbook/workflow-instructions/assign/start.wf.md +5 -2
  45. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
  46. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
  47. data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
  48. data/lib/ace/assign/atoms/preset_expander.rb +4 -0
  49. data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
  50. data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
  51. data/lib/ace/assign/cli/commands/add.rb +20 -11
  52. data/lib/ace/assign/cli/commands/assignment_target.rb +87 -3
  53. data/lib/ace/assign/cli/commands/create.rb +1 -1
  54. data/lib/ace/assign/cli/commands/fail.rb +1 -1
  55. data/lib/ace/assign/cli/commands/finish.rb +32 -8
  56. data/lib/ace/assign/cli/commands/fork_run.rb +58 -16
  57. data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
  58. data/lib/ace/assign/cli/commands/list.rb +4 -3
  59. data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
  60. data/lib/ace/assign/cli/commands/start.rb +9 -3
  61. data/lib/ace/assign/cli/commands/status.rb +237 -230
  62. data/lib/ace/assign/cli/commands/step.rb +62 -0
  63. data/lib/ace/assign/cli.rb +8 -1
  64. data/lib/ace/assign/models/assignment_info.rb +33 -4
  65. data/lib/ace/assign/models/queue_state.rb +101 -39
  66. data/lib/ace/assign/models/step.rb +17 -5
  67. data/lib/ace/assign/molecules/fork_session_launcher.rb +218 -21
  68. data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
  69. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
  70. data/lib/ace/assign/molecules/step_writer.rb +3 -3
  71. data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
  72. data/lib/ace/assign/organisms/assignment_executor.rb +355 -106
  73. data/lib/ace/assign/version.rb +1 -1
  74. data/lib/ace/assign.rb +1 -0
  75. metadata +35 -5
  76. data/.ace-defaults/assign/catalog/steps/verify-e2e.step.yml +0 -42
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/tmux"
4
+ require "fileutils"
5
+ require "shellwords"
6
+ require "yaml"
7
+
8
+ module Ace
9
+ module Assign
10
+ module Molecules
11
+ # Shared ace-tmux backed runtime helper for tmux fork launches.
12
+ class TmuxControlSurfaceRunner
13
+ def initialize(executor: Ace::Tmux::Molecules::TmuxExecutor.new, resolver: nil, control_surface: nil, tmux: "tmux", env: ENV)
14
+ @executor = executor
15
+ @tmux = tmux
16
+ @env = env
17
+ @resolver = resolver || Ace::Tmux::Molecules::RuntimeTargetResolver.new(executor: executor, tmux: tmux, env: env)
18
+ @control_surface = control_surface || Ace::Tmux::Organisms::ControlSurface.new(
19
+ executor: executor,
20
+ resolver: @resolver,
21
+ tmux: tmux
22
+ )
23
+ end
24
+
25
+ def tmux_context?
26
+ !current_session.to_s.strip.empty?
27
+ end
28
+
29
+ def current_session
30
+ resolver.resolve_session.session
31
+ rescue Ace::Tmux::TargetResolutionError
32
+ nil
33
+ end
34
+
35
+ def current_window
36
+ explicit = env["ACE_ASSIGN_FORK_WINDOW"].to_s.strip
37
+ return explicit unless explicit.empty?
38
+
39
+ resolver.resolve_window(session: current_session).window
40
+ rescue Ace::Tmux::TargetResolutionError
41
+ nil
42
+ end
43
+
44
+ def current_pane
45
+ explicit = env["ACE_ASSIGN_CALLBACK_PANE"].to_s.strip
46
+ return explicit unless explicit.empty?
47
+
48
+ resolver.resolve_pane(session: current_session, window: current_window).pane_target
49
+ rescue Ace::Tmux::TargetResolutionError
50
+ nil
51
+ end
52
+
53
+ def fork_window_name(base_window)
54
+ base = base_window.to_s.strip.sub(/-fs\z/, "")
55
+ sanitized = Ace::Tmux::Atoms::WindowNameSanitizer.call(base, fallback: "fork")
56
+
57
+ "#{sanitized}-fs"
58
+ end
59
+
60
+ def ensure_window(session:, name:, root:)
61
+ if (window_id = find_window_id(session: session, name: name))
62
+ return {created: false, target: window_id, window_id: window_id, name: name}
63
+ end
64
+
65
+ result = executor.capture(
66
+ Ace::Tmux::Atoms::TmuxCommandBuilder.new_window(
67
+ session,
68
+ name: name,
69
+ root: root,
70
+ print_format: '#{window_id}',
71
+ tmux: tmux
72
+ )
73
+ )
74
+ raise Error, "Failed to create tmux fork window #{name}: #{result.stderr}" unless result.success?
75
+
76
+ window_id = result.stdout.to_s.strip
77
+ raise Error, "Failed to create tmux fork window #{name}: empty window id" if window_id.empty?
78
+
79
+ {created: true, target: window_id, window_id: window_id, name: name}
80
+ end
81
+
82
+ def prepare_pane(session:, window:, root:, keep_existing:, window_target: nil)
83
+ target = window_target || "#{session}:#{window}"
84
+ pane = if keep_existing
85
+ first_pane(target)
86
+ else
87
+ create_pane(target, root)
88
+ end
89
+
90
+ set_pane_remain_on_exit(pane)
91
+ select_layout(target)
92
+ pane
93
+ end
94
+
95
+ def select_window(session:, window:, window_target: nil)
96
+ target = window_target || "#{session}:#{window}"
97
+ run!(
98
+ Ace::Tmux::Atoms::TmuxCommandBuilder.select_window(target, tmux: tmux),
99
+ "select tmux fork window #{window}"
100
+ )
101
+ end
102
+
103
+ def run_invocation_in_pane(pane_target:, command:, env: nil, working_dir: nil, visible_handoff: nil)
104
+ shell_command = build_pane_shell_command(
105
+ command: command,
106
+ env: env,
107
+ working_dir: working_dir,
108
+ visible_handoff: visible_handoff
109
+ )
110
+ control_surface.send_command(pane: pane_target, command: shell_command)
111
+ rescue Ace::Tmux::Error => e
112
+ raise Error, "Failed to send tmux fork command: #{e.message}"
113
+ end
114
+
115
+ def run_script_in_pane(pane_target:, script_path:)
116
+ control_surface.send_command(pane: pane_target, command: "bash #{File.expand_path(script_path).shellescape}")
117
+ rescue Ace::Tmux::Error => e
118
+ raise Error, "Failed to send tmux fork command: #{e.message}"
119
+ end
120
+
121
+ def capture_recent_output(pane_target:, lines: 40)
122
+ control_surface.capture_recent_output(pane: pane_target, lines: lines)
123
+ rescue Ace::Tmux::Error => e
124
+ raise Error, "Failed to capture pane #{pane_target}: #{e.message}"
125
+ end
126
+
127
+ def merge_tmux_metadata(session_meta_file:, session:, window:, pane:, window_id: nil, callback_pane: nil)
128
+ data = if File.exist?(session_meta_file)
129
+ YAML.safe_load_file(session_meta_file) || {}
130
+ else
131
+ {}
132
+ end
133
+ data["launch_mode"] = "tmux"
134
+ data["tmux_session"] = session
135
+ data["tmux_window"] = window
136
+ data["tmux_window_id"] = window_id if window_id
137
+ data["tmux_pane_id"] = pane
138
+ data["callback_pane"] = callback_pane if callback_pane && !callback_pane.empty?
139
+ File.write(session_meta_file, data.to_yaml)
140
+ end
141
+
142
+ private
143
+
144
+ attr_reader :control_surface, :env, :executor, :resolver, :tmux
145
+
146
+ def find_window_id(session:, name:)
147
+ result = executor.capture(
148
+ Ace::Tmux::Atoms::TmuxCommandBuilder.list_windows(
149
+ session,
150
+ format: '#{window_id}' + "\t" + '#{window_name}',
151
+ tmux: tmux
152
+ )
153
+ )
154
+ return nil unless result.success?
155
+
156
+ result.stdout.split("\n").map(&:strip).reject(&:empty?).each do |line|
157
+ window_id, window_name = line.split("\t", 2)
158
+ return window_id if window_name == name && !window_id.to_s.empty?
159
+ end
160
+
161
+ nil
162
+ end
163
+
164
+ def first_pane(target)
165
+ result = executor.capture(
166
+ Ace::Tmux::Atoms::TmuxCommandBuilder.list_panes(target, format: '#{pane_id}', tmux: tmux)
167
+ )
168
+ raise Error, "Failed to inspect panes for #{target}: #{result.stderr}" unless result.success?
169
+
170
+ pane = result.stdout.split("\n").map(&:strip).reject(&:empty?).first
171
+ raise Error, "No panes found for #{target}" if pane.to_s.empty?
172
+
173
+ pane
174
+ end
175
+
176
+ def create_pane(target, root)
177
+ result = executor.capture(
178
+ Ace::Tmux::Atoms::TmuxCommandBuilder.split_window(
179
+ target,
180
+ root: root,
181
+ print_format: '#{pane_id}',
182
+ tmux: tmux
183
+ )
184
+ )
185
+ raise Error, "Failed to create tmux fork pane in #{target}: #{result.stderr}" unless result.success?
186
+
187
+ pane = result.stdout.to_s.strip
188
+ raise Error, "Failed to create tmux fork pane in #{target}: empty pane id" if pane.empty?
189
+
190
+ pane
191
+ end
192
+
193
+ def set_pane_remain_on_exit(pane_target)
194
+ run!(
195
+ Ace::Tmux::Atoms::TmuxCommandBuilder.set_pane_option(pane_target, "remain-on-exit", "on", tmux: tmux),
196
+ "enable remain-on-exit"
197
+ )
198
+ end
199
+
200
+ def select_layout(target)
201
+ run!(
202
+ Ace::Tmux::Atoms::TmuxCommandBuilder.select_layout(target, "tiled", tmux: tmux),
203
+ "apply tiled layout"
204
+ )
205
+ end
206
+
207
+ def run!(cmd, action)
208
+ return if executor.run(cmd)
209
+
210
+ raise Error, "Failed to #{action}"
211
+ end
212
+
213
+ def build_pane_shell_command(command:, env:, working_dir:, visible_handoff:)
214
+ steps = []
215
+ resolved_working_dir = working_dir.to_s.strip
216
+ steps << "cd #{Shellwords.escape(File.expand_path(resolved_working_dir))}" unless resolved_working_dir.empty?
217
+
218
+ handoff = visible_handoff.to_s
219
+ steps << "printf '%s\\n' #{Shellwords.escape(handoff)}" unless handoff.empty?
220
+
221
+ steps << "exec #{build_exec_command(command: command, env: env)}"
222
+ steps.join(" && ")
223
+ end
224
+
225
+ def build_exec_command(command:, env:)
226
+ cmd = Array(command).map { |part| Shellwords.escape(part.to_s) }.join(" ")
227
+ env_hash = env.respond_to?(:to_h) ? env.to_h : {}
228
+ unset_parts = []
229
+ assign_parts = []
230
+
231
+ env_hash.each do |key, value|
232
+ next if key.to_s.strip.empty?
233
+
234
+ if value.nil?
235
+ unset_parts << "-u #{Shellwords.escape(key.to_s)}"
236
+ else
237
+ assign_parts << "#{key}=#{Shellwords.escape(value.to_s)}"
238
+ end
239
+ end
240
+ env_parts = unset_parts + assign_parts
241
+
242
+ return cmd if env_parts.empty?
243
+
244
+ "env #{env_parts.join(' ')} #{cmd}"
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end