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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/composition-rules.yml +2 -17
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +0 -26
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +1 -2
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -17
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -11
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +3 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +3 -2
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -16
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +7 -34
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +7 -4
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -17
- data/.ace-defaults/assign/config.yml +1 -0
- data/.ace-defaults/assign/presets/fix-bug.yml +4 -3
- data/.ace-defaults/assign/presets/quick-implement.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +6 -16
- data/CHANGELOG.md +216 -0
- data/README.md +20 -43
- data/docs/demo/canonical-skill-source.gif +0 -0
- data/docs/demo/canonical-skill-source.tape.yml +51 -0
- data/docs/demo/fork-provider.cast +834 -0
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +30 -0
- data/docs/demo/fork-provider.tape.yml +77 -20
- data/docs/getting-started.md +5 -2
- data/docs/usage.md +74 -4
- data/handbook/guides/fork-context.g.md +31 -7
- data/handbook/skills/as-assign-drive/SKILL.md +13 -1
- data/handbook/skills/as-create-retro-internal/SKILL.md +29 -0
- data/handbook/skills/as-mark-task-done-internal/SKILL.md +29 -0
- data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +30 -0
- data/handbook/skills/as-task-load-internal/SKILL.md +28 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +3 -3
- data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +11 -0
- data/handbook/workflow-instructions/assign/create.wf.md +6 -3
- data/handbook/workflow-instructions/assign/drive.wf.md +330 -40
- data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/prepare.wf.md +10 -5
- data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +14 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +4 -1
- data/handbook/workflow-instructions/assign/start.wf.md +5 -2
- data/handbook/workflow-instructions/assign/task-load-internal.wf.md +12 -0
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +36 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +105 -2
- data/lib/ace/assign/atoms/preset_expander.rb +4 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +15 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +2 -2
- data/lib/ace/assign/cli/commands/add.rb +20 -11
- data/lib/ace/assign/cli/commands/assignment_target.rb +87 -3
- data/lib/ace/assign/cli/commands/create.rb +1 -1
- data/lib/ace/assign/cli/commands/fail.rb +1 -1
- data/lib/ace/assign/cli/commands/finish.rb +32 -8
- data/lib/ace/assign/cli/commands/fork_run.rb +58 -16
- data/lib/ace/assign/cli/commands/fork_session.rb +52 -0
- data/lib/ace/assign/cli/commands/list.rb +4 -3
- data/lib/ace/assign/cli/commands/retry_cmd.rb +1 -1
- data/lib/ace/assign/cli/commands/start.rb +9 -3
- data/lib/ace/assign/cli/commands/status.rb +237 -230
- data/lib/ace/assign/cli/commands/step.rb +62 -0
- data/lib/ace/assign/cli.rb +8 -1
- data/lib/ace/assign/models/assignment_info.rb +33 -4
- data/lib/ace/assign/models/queue_state.rb +101 -39
- data/lib/ace/assign/models/step.rb +17 -5
- data/lib/ace/assign/molecules/fork_session_launcher.rb +218 -21
- data/lib/ace/assign/molecules/queue_scanner.rb +1 -0
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +223 -47
- data/lib/ace/assign/molecules/step_writer.rb +3 -3
- data/lib/ace/assign/molecules/tmux_control_surface_runner.rb +249 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +355 -106
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -0
- metadata +35 -5
- 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
|