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
@@ -31,6 +31,7 @@ require_relative "molecules/step_writer"
31
31
  require_relative "molecules/step_renumberer"
32
32
  require_relative "molecules/skill_assign_source_resolver"
33
33
  require_relative "molecules/fork_session_launcher"
34
+ require_relative "molecules/tmux_fork_runner"
34
35
  require_relative "molecules/preset_inferrer"
35
36
 
36
37
  # Organisms
@@ -40,6 +41,7 @@ require_relative "organisms/assignment_executor"
40
41
  require_relative "cli/commands/create"
41
42
  require_relative "cli/commands/assignment_target"
42
43
  require_relative "cli/commands/status"
44
+ require_relative "cli/commands/step"
43
45
  require_relative "cli/commands/start"
44
46
  require_relative "cli/commands/finish"
45
47
  require_relative "cli/commands/fail"
@@ -48,6 +50,7 @@ require_relative "cli/commands/retry_cmd"
48
50
  require_relative "cli/commands/list"
49
51
  require_relative "cli/commands/select"
50
52
  require_relative "cli/commands/fork_run"
53
+ require_relative "cli/commands/fork_session"
51
54
 
52
55
  module Ace
53
56
  module Assign
@@ -61,6 +64,7 @@ module Ace
61
64
  REGISTERED_COMMANDS = [
62
65
  ["create", "Create assignment from preset or YAML"],
63
66
  ["status", "Show assignment status"],
67
+ ["step", "Show step instructions"],
64
68
  ["start", "Start next workable step"],
65
69
  ["finish", "Complete current step with report"],
66
70
  ["fail", "Mark step as failed"],
@@ -73,7 +77,8 @@ module Ace
73
77
 
74
78
  HELP_EXAMPLES = [
75
79
  "ace-assign create --preset review # Start review assignment",
76
- "ace-assign status # Current step progress",
80
+ "ace-assign status # Compact queue progress",
81
+ "ace-assign step # Current or next step instructions",
77
82
  "ace-assign start # Start next workable step",
78
83
  "ace-assign finish --message done.md # Complete active step",
79
84
  "cat report.md | ace-assign finish # Complete step via stdin",
@@ -115,6 +120,7 @@ module Ace
115
120
  # Register commands (wrapped to capture exit codes)
116
121
  register "create", wrap_command(Commands::Create)
117
122
  register "status", wrap_command(Commands::Status)
123
+ register "step", wrap_command(Commands::Step)
118
124
  register "start", wrap_command(Commands::Start)
119
125
  register "finish", wrap_command(Commands::Finish)
120
126
  register "fail", wrap_command(Commands::Fail)
@@ -123,6 +129,7 @@ module Ace
123
129
  register "list", wrap_command(Commands::List)
124
130
  register "select", wrap_command(Commands::Select)
125
131
  register "fork-run", wrap_command(Commands::ForkRun)
132
+ register "fork-session", wrap_command(Commands::ForkSession)
126
133
 
127
134
  # Register version command
128
135
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -23,7 +23,7 @@ module Ace
23
23
  VALID_CONTEXTS = %w[fork].freeze
24
24
 
25
25
  attr_reader :number, :name, :status, :instructions, :report, :error,
26
- :started_at, :completed_at, :added_by, :parent, :file_path, :skill, :context,
26
+ :started_at, :completed_at, :added_by, :parent, :file_path, :source, :skill, :context,
27
27
  :workflow,
28
28
  :batch_parent, :parallel, :max_parallel, :fork_retry_limit, :fork_options,
29
29
  :fork_launch_pid, :fork_tracked_pids, :fork_pid_updated_at, :fork_pid_file,
@@ -54,7 +54,7 @@ module Ace
54
54
  # @param stall_reason [String, nil] Last agent message captured when fork stalled
55
55
  def initialize(number:, name:, status:, instructions:, report: nil, error: nil,
56
56
  started_at: nil, completed_at: nil, added_by: nil, parent: nil,
57
- file_path: nil, skill: nil, workflow: nil, context: nil,
57
+ file_path: nil, source: nil, skill: nil, workflow: nil, context: nil,
58
58
  batch_parent: nil, parallel: nil, max_parallel: nil, fork_retry_limit: nil,
59
59
  fork_options: nil,
60
60
  fork_launch_pid: nil, fork_tracked_pids: nil, fork_pid_updated_at: nil,
@@ -78,6 +78,7 @@ module Ace
78
78
  @added_by = added_by&.freeze
79
79
  @parent = parent&.freeze
80
80
  @file_path = file_path&.freeze
81
+ @source = source&.freeze
81
82
  @skill = skill&.freeze
82
83
  @workflow = workflow&.freeze
83
84
  @context = context&.freeze
@@ -141,6 +142,7 @@ module Ace
141
142
  {
142
143
  "name" => name,
143
144
  "status" => status.to_s,
145
+ "source" => source,
144
146
  "skill" => skill,
145
147
  "workflow" => workflow,
146
148
  "context" => context,
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "ace/llm"
4
4
  require "fileutils"
5
+ require "rbconfig"
6
+ require "shellwords"
5
7
 
6
8
  module Ace
7
9
  module Assign
@@ -10,10 +12,15 @@ module Ace
10
12
  class ForkSessionLauncher
11
13
  DEFAULT_PROVIDER = "claude:sonnet"
12
14
  DEFAULT_TIMEOUT = 1800
15
+ DEFAULT_LAUNCH_MODE = "auto"
16
+ VALID_LAUNCH_MODES = %w[auto headless tmux].freeze
17
+ TMUX_POLL_INTERVAL = 0.5
13
18
 
14
- def initialize(config: nil, query_interface: Ace::LLM::QueryInterface)
19
+ def initialize(config: nil, query_interface: Ace::LLM::QueryInterface, tmux_runner: nil, interactive_builder: nil)
15
20
  @config = config || Ace::Assign.config
16
21
  @query_interface = query_interface
22
+ @tmux_runner = tmux_runner || TmuxForkRunner.new
23
+ @interactive_builder = interactive_builder || Ace::LLM::Molecules::InteractiveCommandBuilder.new
17
24
  end
18
25
 
19
26
  # Launch forked subtree execution synchronously.
@@ -24,16 +31,45 @@ module Ace
24
31
  # @param cli_args [String, nil] Optional provider CLI args
25
32
  # @param timeout [Integer, nil] Optional timeout override (seconds)
26
33
  # @param cache_dir [String, nil] Assignment cache directory for last-message capture
34
+ # @param launch_mode [String, nil] Launch mode override (auto|headless|tmux)
27
35
  # @return [Hash] QueryInterface response
28
- def launch(assignment_id:, fork_root:, provider: nil, cli_args: nil, timeout: nil, cache_dir: nil)
36
+ def launch(assignment_id:, fork_root:, provider: nil, cli_args: nil, timeout: nil, cache_dir: nil, launch_mode: nil)
37
+ resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
38
+ resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
39
+ resolved_mode = resolve_launch_mode(launch_mode)
40
+
41
+ if resolved_mode == "tmux"
42
+ launch_tmux(
43
+ assignment_id: assignment_id,
44
+ fork_root: fork_root,
45
+ provider: resolved_provider,
46
+ cli_args: cli_args,
47
+ timeout: resolved_timeout,
48
+ cache_dir: cache_dir
49
+ )
50
+ else
51
+ launch_provider_session(
52
+ assignment_id: assignment_id,
53
+ fork_root: fork_root,
54
+ provider: resolved_provider,
55
+ cli_args: cli_args,
56
+ timeout: resolved_timeout,
57
+ cache_dir: cache_dir
58
+ )
59
+ end
60
+ end
61
+
62
+ def launch_provider_session(assignment_id:, fork_root:, provider:, cli_args: nil, timeout: nil, cache_dir: nil,
63
+ last_message_file: nil, session_meta_file: nil)
29
64
  resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
30
65
  resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
31
66
  scoped_assignment = "#{assignment_id}@#{fork_root}"
32
- last_msg_file = build_last_message_file(cache_dir, fork_root)
67
+ prompt = "/as-assign-drive #{scoped_assignment}"
68
+ last_msg_file = last_message_file || build_last_message_file(cache_dir, fork_root)
33
69
 
34
70
  result = query_interface.query(
35
71
  resolved_provider,
36
- "/as-assign-drive #{scoped_assignment}",
72
+ prompt,
37
73
  system: nil,
38
74
  cli_args: cli_args,
39
75
  timeout: resolved_timeout,
@@ -49,7 +85,7 @@ module Ace
49
85
  File.write(last_msg_file, result[:text]) if existing.empty?
50
86
  end
51
87
 
52
- write_session_metadata(last_msg_file, result, prompt: "/as-assign-drive #{scoped_assignment}")
88
+ write_session_metadata(last_msg_file, result, prompt: prompt, session_meta_file: session_meta_file)
53
89
 
54
90
  result
55
91
  rescue Ace::LLM::Error => e
@@ -58,9 +94,94 @@ module Ace
58
94
 
59
95
  private
60
96
 
61
- attr_reader :config, :query_interface
97
+ attr_reader :config, :query_interface, :tmux_runner, :interactive_builder
98
+
99
+ def resolve_launch_mode(explicit_mode)
100
+ mode = explicit_mode.to_s.strip
101
+ mode = DEFAULT_LAUNCH_MODE if mode.empty?
102
+ unless VALID_LAUNCH_MODES.include?(mode)
103
+ raise Error, "Invalid launch mode '#{mode}'. Expected one of: #{VALID_LAUNCH_MODES.join(', ')}"
104
+ end
105
+
106
+ return mode unless mode == "auto"
107
+
108
+ tmux_runner.tmux_context? ? "tmux" : "headless"
109
+ end
110
+
111
+ def launch_tmux(assignment_id:, fork_root:, provider:, cli_args:, timeout:, cache_dir:)
112
+ session = tmux_runner.current_session
113
+ raise Error, "Launch mode tmux requires an active tmux session (TMUX or ACE_TMUX_SESSION)." unless session
114
+ raise Error, "Tmux launch requires assignment cache_dir for subtree polling." if cache_dir.to_s.strip.empty?
115
+
116
+ current_window = tmux_runner.current_window
117
+ raise Error, "Could not resolve current tmux window for fork launch." if current_window.to_s.strip.empty?
118
+
119
+ fork_window = ENV["ACE_ASSIGN_FORK_WINDOW"].to_s.strip
120
+ fork_window = tmux_runner.fork_window_name(current_window) if fork_window.empty?
121
+
122
+ window_info = tmux_runner.ensure_window(session: session, name: fork_window, root: Dir.pwd)
123
+ pane_target = tmux_runner.prepare_pane(
124
+ session: session,
125
+ window: fork_window,
126
+ root: Dir.pwd,
127
+ keep_existing: window_info[:created]
128
+ )
129
+
130
+ session_meta_file = build_session_meta_file(cache_dir, fork_root)
131
+ prompt = "/as-assign-drive #{assignment_id}@#{fork_root}"
132
+ invocation = interactive_builder.build(
133
+ provider_model: provider,
134
+ prompt: prompt,
135
+ cli_args: cli_args
136
+ )
137
+ script_path = build_tmux_wrapper(
138
+ assignment_id: assignment_id,
139
+ fork_root: fork_root,
140
+ provider: provider,
141
+ cli_args: cli_args,
142
+ timeout: timeout,
143
+ session_meta_file: session_meta_file,
144
+ session: session,
145
+ fork_window: fork_window,
146
+ visible_handoff: invocation[:prompt]
147
+ )
148
+
149
+ tmux_runner.run_script_in_pane(pane_target: pane_target, script_path: script_path)
150
+ tmux_runner.select_window(session: session, window: fork_window) if window_info[:created] && current_window != fork_window
151
+ write_tmux_launch_metadata(
152
+ session_meta_file: session_meta_file,
153
+ provider: invocation[:provider],
154
+ model: invocation[:model],
155
+ prompt: invocation[:prompt]
156
+ )
157
+ tmux_runner.merge_tmux_metadata(
158
+ session_meta_file: session_meta_file,
159
+ session: session,
160
+ window: fork_window,
161
+ pane: pane_target
162
+ )
163
+ wait_for_subtree_terminal(
164
+ assignment_id: assignment_id,
165
+ fork_root: fork_root,
166
+ cache_dir: cache_dir,
167
+ timeout: timeout
168
+ )
169
+
170
+ {tmux: true, pane_target: pane_target}
171
+ end
172
+
173
+ def write_tmux_launch_metadata(session_meta_file:, provider:, model:, prompt:)
174
+ detected = detect_provider_session(provider, prompt)
175
+ meta = {}
176
+ meta = YAML.safe_load_file(session_meta_file) || {} if File.exist?(session_meta_file)
177
+ meta["provider"] ||= provider
178
+ meta["model"] ||= model
179
+ meta["session_id"] ||= detected&.dig(:session_id)
180
+ meta["launched_at"] ||= Time.now.utc.iso8601
181
+ File.write(session_meta_file, meta.to_yaml) unless meta.empty?
182
+ end
62
183
 
63
- def write_session_metadata(last_msg_file, result, prompt:)
184
+ def write_session_metadata(last_msg_file, result, prompt:, session_meta_file: nil)
64
185
  return unless last_msg_file
65
186
 
66
187
  session_id = result.dig(:metadata, :session_id)
@@ -70,7 +191,7 @@ module Ace
70
191
  session_id = detected&.dig(:session_id)
71
192
  end
72
193
 
73
- session_meta_file = last_msg_file.sub(/-last-message\.md$/, "-session.yml")
194
+ session_meta_file ||= last_msg_file.sub(/-last-message\.md$/, "-session.yml")
74
195
  meta = {
75
196
  "session_id" => session_id,
76
197
  "provider" => result[:provider],
@@ -96,6 +217,66 @@ module Ace
96
217
  FileUtils.mkdir_p(sessions_dir)
97
218
  File.join(sessions_dir, "#{fork_root}-last-message.md")
98
219
  end
220
+
221
+ def build_session_meta_file(cache_dir, fork_root)
222
+ return nil unless cache_dir
223
+
224
+ sessions_dir = File.join(cache_dir, "sessions")
225
+ FileUtils.mkdir_p(sessions_dir)
226
+ File.join(sessions_dir, "#{fork_root}-session.yml")
227
+ end
228
+
229
+ def build_tmux_wrapper(assignment_id:, fork_root:, provider:, cli_args:, timeout:, session_meta_file:, session:, fork_window:, visible_handoff:)
230
+ sessions_dir = File.dirname(session_meta_file)
231
+ FileUtils.mkdir_p(sessions_dir)
232
+ script_path = File.join(sessions_dir, "#{fork_root}-tmux-launch.sh")
233
+
234
+ command = [
235
+ "ace-llm", provider, "/as-assign-drive #{assignment_id}@#{fork_root}",
236
+ "--interactive"
237
+ ]
238
+ command.concat(["--cli-args", cli_args]) if cli_args && !cli_args.strip.empty?
239
+
240
+ script = <<~BASH
241
+ #!/usr/bin/env bash
242
+ set -uo pipefail
243
+ cd #{Shellwords.escape(Dir.pwd)}
244
+ export PROJECT_ROOT_PATH=#{Shellwords.escape(Dir.pwd)}
245
+ export ACE_TMUX_SESSION=#{Shellwords.escape(session)}
246
+ export ACE_ASSIGN_LAUNCH_MODE=tmux
247
+ export ACE_ASSIGN_FORK_WINDOW=#{Shellwords.escape(fork_window)}
248
+ printf '%s\n' #{Shellwords.escape(visible_handoff.to_s)}
249
+ exec #{command.map { |part| Shellwords.escape(part) }.join(" ")}
250
+ BASH
251
+
252
+ File.write(script_path, script)
253
+ FileUtils.chmod(0o755, script_path)
254
+ script_path
255
+ end
256
+
257
+ def wait_for_subtree_terminal(assignment_id:, fork_root:, cache_dir:, timeout:)
258
+ assignment = Models::Assignment.new(
259
+ id: assignment_id,
260
+ name: "fork",
261
+ created_at: Time.now.utc,
262
+ source_config: "fork-run",
263
+ cache_dir: cache_dir
264
+ )
265
+ scanner = QueueScanner.new
266
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_i
267
+
268
+ loop do
269
+ state = scanner.scan(assignment.steps_dir, assignment: assignment)
270
+ return state if state.subtree_complete?(fork_root)
271
+ raise Error, "Fork session execution failed in tmux subtree #{fork_root}." if state.subtree_failed?(fork_root)
272
+
273
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
274
+ raise Error, "Timed out waiting for tmux fork subtree #{fork_root} to reach a terminal state."
275
+ end
276
+
277
+ sleep(TMUX_POLL_INTERVAL)
278
+ end
279
+ end
99
280
  end
100
281
  end
101
282
  end
@@ -81,6 +81,7 @@ module Ace
81
81
  fork_pid_file: fields[:fork_pid_file],
82
82
  added_by: fields[:added_by],
83
83
  parent: fields[:parent],
84
+ source: fields[:source],
84
85
  skill: fields[:skill],
85
86
  workflow: fields[:workflow],
86
87
  context: fields[:context],