letsdo 0.3.0 → 0.6.1

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +232 -0
  3. data/README.md +156 -23
  4. data/bin/letsdo +8 -1
  5. data/docs/config.md +176 -0
  6. data/docs/prompts.md +210 -0
  7. data/docs/task-selection.md +244 -0
  8. data/docs/usage.md +296 -0
  9. data/letsdo.gemspec +63 -0
  10. data/lib/letsdo/agent.rb +30 -17
  11. data/lib/letsdo/agent_identity.rb +46 -0
  12. data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
  13. data/lib/letsdo/agent_loop/tasks.rb +88 -13
  14. data/lib/letsdo/agent_loop.rb +46 -4
  15. data/lib/letsdo/backends/backend.rb +137 -0
  16. data/lib/letsdo/backends/pi/events.rb +77 -0
  17. data/lib/letsdo/backends/pi.rb +88 -0
  18. data/lib/letsdo/cli/builder.rb +102 -0
  19. data/lib/letsdo/cli/builder_assembly.rb +154 -0
  20. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  21. data/lib/letsdo/cli/doctor.rb +16 -0
  22. data/lib/letsdo/cli.rb +17 -56
  23. data/lib/letsdo/config.rb +152 -0
  24. data/lib/letsdo/control/reader.rb +131 -0
  25. data/lib/letsdo/control.rb +6 -3
  26. data/lib/letsdo/doctor/checks.rb +144 -0
  27. data/lib/letsdo/doctor.rb +42 -0
  28. data/lib/letsdo/duration.rb +23 -0
  29. data/lib/letsdo/errors.rb +7 -0
  30. data/lib/letsdo/metrics/fanout.rb +47 -0
  31. data/lib/letsdo/prompt_store.rb +48 -1
  32. data/lib/letsdo/providers/backlog.rb +180 -0
  33. data/lib/letsdo/providers/task.rb +48 -0
  34. data/lib/letsdo/retry_policy.rb +98 -0
  35. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  36. data/lib/letsdo/session_recorder.rb +188 -0
  37. data/lib/letsdo/task_time_writeback.rb +106 -0
  38. data/lib/letsdo/tui/metrics.rb +10 -3
  39. data/lib/letsdo/tui/renderer.rb +9 -1
  40. data/lib/letsdo/tui/session/terminal.rb +47 -0
  41. data/lib/letsdo/tui/session/view.rb +10 -2
  42. data/lib/letsdo/tui/session.rb +27 -22
  43. data/lib/letsdo/tui/window_title.rb +133 -0
  44. data/lib/letsdo/tui.rb +4 -0
  45. data/lib/letsdo/version.rb +1 -1
  46. data/lib/letsdo.rb +17 -5
  47. metadata +39 -13
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/cli/launch.rb +0 -87
  50. data/lib/letsdo/pi_runner/events.rb +0 -75
  51. data/lib/letsdo/pi_runner/process.rb +0 -68
  52. data/lib/letsdo/pi_runner.rb +0 -101
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'agent_loop/tasks'
4
+ require_relative 'agent_loop/assignee_hints'
4
5
 
5
6
  module Letsdo
6
7
  # The orchestrator loop wired to the real environment for `letsdo <name>`.
7
8
  class AgentLoop
8
9
  include AgentLoopTasks
10
+ include AgentLoopAssigneeHints
9
11
 
10
12
  STOP = :letsdo_stop
11
13
  PAUSE_POLL_SECONDS = 0.05
@@ -18,8 +20,7 @@ module Letsdo
18
20
  end
19
21
 
20
22
  def run
21
- @loop = build_loop
22
- install_signal_handlers
23
+ prepare_run
23
24
  debug("loop start (agent=#{@name}, handle=#{@handle}, wait=#{@wait_seconds}s)")
24
25
  run_until_stopped
25
26
  debug('loop stopped')
@@ -30,6 +31,15 @@ module Letsdo
30
31
  restore_signal_handlers
31
32
  end
32
33
 
34
+ # The `letsdo doctor` hint rides on the first unavailable backlog call
35
+ # of a run only: later nils repeat the same diagnosis, and a wall of
36
+ # hints would just add noise (TASK-93).
37
+ def unavailable_message
38
+ hint = @unavailable_hinted ? '' : ' - run `letsdo doctor` to diagnose'
39
+ @unavailable_hinted = true
40
+ "letsdo: backlog unavailable, retrying in #{@wait_seconds}s#{hint}"
41
+ end
42
+
33
43
  def close_watcher
34
44
  @watcher&.close
35
45
  end
@@ -38,8 +48,14 @@ module Letsdo
38
48
  warn("[letsdo] loop: #{message}") if @debug
39
49
  end
40
50
 
51
+ # Stop the running backend immediately. The signal handler is
52
+ # invoked from the main thread (Ruby 4.0) or from a dedicated signal
53
+ # thread (3.x fallback via AGENT_SIGNAL_THREAD=1). Thread.raise
54
+ # works reliably on CRuby 4.0: M:N fibers make threads interruptible
55
+ # everywhere, so a trap handler may safely raise Letsdo::Stopped on
56
+ # the main thread to unwind the current run.
41
57
  def on_signal(_signum)
42
- @agent&.runner&.terminate_now
58
+ @agent&.backend&.terminate_now
43
59
  raise Letsdo::Stopped
44
60
  end
45
61
 
@@ -50,13 +66,33 @@ module Letsdo
50
66
  @run_one = opts[:run_one] || ->(_task) { @agent.run }
51
67
  @wait_seconds = opts.fetch(:wait_seconds, 10.0)
52
68
  @stderr = opts.fetch(:stderr, $stderr)
69
+ @retry_policy = build_retry_policy(opts)
70
+ @last_attempted = {}
53
71
  assign_control_opts(opts)
54
72
  end
55
73
 
74
+ def prepare_run
75
+ @loop = build_loop
76
+ install_signal_handlers
77
+ @unavailable_hinted = false
78
+ @variants_hinted = false
79
+ end
80
+
81
+ def build_retry_policy(opts)
82
+ return opts[:retry_policy] if opts[:retry_policy]
83
+
84
+ Letsdo::RetryPolicy.new(
85
+ base: opts.fetch(:retry_base, @wait_seconds),
86
+ cap: opts.fetch(:retry_cap, Letsdo::RetryPolicy::DEFAULT_CAP),
87
+ max_retries: opts.fetch(:max_retries, Letsdo::RetryPolicy::DEFAULT_MAX_RETRIES),
88
+ clock: opts[:clock]
89
+ )
90
+ end
91
+
56
92
  def assign_control_opts(opts)
57
93
  @metrics = opts[:metrics]
58
94
  @pause_gate = opts[:pause_gate]
59
- @debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
95
+ @debug = resolve_debug(opts)
60
96
  @watcher = opts[:watcher] || (Watcher.new(path: opts[:watch_path]) if opts[:watch_path])
61
97
  # Precedence is deliberate: an explicitly injected sleeper always wins
62
98
  # over the watcher idle path — tests inject a stop/control sleeper that
@@ -64,6 +100,12 @@ module Letsdo
64
100
  @sleeper = opts[:sleeper] || watcher_sleeper || ->(seconds) { sleep(seconds) }
65
101
  end
66
102
 
103
+ def resolve_debug(opts)
104
+ return opts[:debug] unless opts[:debug].nil?
105
+
106
+ (opts[:config] || Config.new).debug?
107
+ end
108
+
67
109
  def watcher_sleeper
68
110
  @watcher && ->(seconds) { @watcher.wait(seconds) }
69
111
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../config'
4
+
5
+ module Letsdo
6
+ module Backends
7
+ # The documented backend protocol plus shared process-lifecycle helpers.
8
+ #
9
+ # Concrete adapters (Pi) add subprocess management and streaming.
10
+ # In-process fakes (test helpers) override terminate_now and skip
11
+ # spawning entirely.
12
+ #
13
+ # Protocol:
14
+ # run → Integer child exit code
15
+ # terminate_now trap-safe, no waits/IO
16
+ # terminate(signal:, grace:, tick:) → no-op when already finished
17
+ # pause / resume SIGSTOP/SIGCONT to the process group
18
+ # debug(message) '[letsdo] pi:' when debug is on
19
+ #
20
+ # Normalized events emitted at the injected streamer:
21
+ # text_delta(delta) tool_start(name, args: nil)
22
+ # tool_result(name, text) finish
23
+ # finish is called exactly once per run.
24
+ class Backend
25
+ # @param prompt [String] the stripped prompt text (no front-matter).
26
+ # @param streamer [Letsdo::OutputStreamer] normalized event sink.
27
+ # @param debug [Boolean, nil] trace override; nil → check env.
28
+ # @param config [Letsdo::Config, nil] debug source (LETSDO_DEBUG);
29
+ # nil → Config.new for env-only lookups.
30
+ def initialize(prompt:, streamer:, debug: nil, config: nil)
31
+ @prompt = prompt
32
+ @streamer = streamer
33
+ @debug = debug.nil? ? (config || Config.new).debug? : debug
34
+ @pid = nil
35
+ @reaped_status = nil
36
+ end
37
+
38
+ # Run the backend. Subclasses must override; the base returns nil.
39
+ def run
40
+ raise NotImplementedError,
41
+ "#{self.class.name} must implement #run"
42
+ end
43
+
44
+ # ── signal helpers ────────────────────────────────────────────
45
+ # All are trap-safe (no waits, no IO) and swallow ESRCH/EPERM.
46
+
47
+ def terminate_now
48
+ pid = @pid
49
+ send_signal('TERM', pid) if pid
50
+ end
51
+
52
+ def pause
53
+ pid = @pid
54
+ send_signal('CONT', pid) if pid
55
+ send_signal('SIGSTOP', pid) if pid
56
+ end
57
+
58
+ def resume
59
+ pid = @pid
60
+ send_signal('SIGCONT', pid) if pid
61
+ end
62
+
63
+ # Graceful stop, then SIGKILL after *grace* seconds.
64
+ # Returns true when the process already finished.
65
+ def terminate(signal: 'TERM', grace: 3.0, tick: 0.05)
66
+ pid = @pid
67
+ return true unless pid
68
+
69
+ send_signal('CONT', pid)
70
+ send_signal(signal, pid)
71
+ wait_for_exit(pid, grace, tick)
72
+ end
73
+
74
+ # ── tracing ───────────────────────────────────────────────────
75
+
76
+ def debug(message)
77
+ warn("[letsdo] pi: #{message}") if @debug
78
+ end
79
+
80
+ private
81
+
82
+ # ── process-lifecycle helpers (shared with concrete adapters) ──
83
+
84
+ def wait_status(pid, nonblock: false)
85
+ _, status = Process.wait2(pid, nonblock ? Process::WNOHANG : 0)
86
+ @reaped_status = status if status
87
+ status
88
+ rescue Errno::ECHILD
89
+ @reaped_status
90
+ end
91
+
92
+ def wait_for_exit(pid, grace, tick)
93
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
94
+ loop do
95
+ status = wait_status(pid, nonblock: true)
96
+ return status if status
97
+ break unless alive?(pid)
98
+ return kill_and_reap(pid, tick) if overdue_deadline?(deadline)
99
+
100
+ sleep(tick)
101
+ end
102
+ true
103
+ end
104
+
105
+ def overdue_deadline?(deadline)
106
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
107
+ end
108
+
109
+ def kill_and_reap(pid, tick)
110
+ send_signal('KILL', pid)
111
+ sleep(tick)
112
+ wait_status(pid, nonblock: true) || true
113
+ end
114
+
115
+ def send_signal(signal, pid)
116
+ Process.kill(signal, -pid)
117
+ rescue Errno::ESRCH, Errno::EPERM
118
+ nil
119
+ end
120
+
121
+ def alive?(pid)
122
+ Process.kill(0, pid)
123
+ true
124
+ rescue Errno::ESRCH, Errno::EPERM
125
+ false
126
+ end
127
+
128
+ # 0/N from a clean exit; 128+signal for a killed run; 1 when unknown.
129
+ def exit_code(status)
130
+ return 1 if status.nil?
131
+ return status.exitstatus if status.exitstatus
132
+
133
+ status.termsig ? 128 + status.termsig : 1
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Backends
5
+ # Pi JSON event-stream handlers used by Letsdo::Backends::Pi.
6
+ module PiEvents
7
+ private
8
+
9
+ def read_pi_stream(out_r)
10
+ out_r.each_line { |line| handle_line(line) }
11
+ debug('stream: EOF')
12
+ end
13
+
14
+ def handle_line(line)
15
+ line = line.strip
16
+ return if line.empty?
17
+
18
+ event = parse_event(line)
19
+ dispatch_event(event) if event
20
+ end
21
+
22
+ def dispatch_event(event)
23
+ case event['type']
24
+ when 'message_update' then handle_message_update(event)
25
+ when 'tool_execution_start' then handle_tool_start(event)
26
+ when 'tool_execution_end' then handle_tool_execution_end(event)
27
+ when 'agent_end' then flush_pending_tools
28
+ end
29
+ end
30
+
31
+ def handle_tool_start(event)
32
+ @pending_tools.delete(event['toolCallId'])
33
+ @streamer.tool_start(event['toolName'] || 'tool', args: event['args'])
34
+ end
35
+
36
+ def handle_message_update(event)
37
+ payload = event['assistantMessageEvent']
38
+ return unless payload
39
+
40
+ handle_text_delta(payload) || remember_toolcall(event, payload)
41
+ end
42
+
43
+ def handle_text_delta(payload)
44
+ return unless payload['type'] == 'text_delta'
45
+
46
+ delta = payload['delta']
47
+ @streamer.text_delta(delta) if delta && !delta.empty?
48
+ true
49
+ end
50
+
51
+ def remember_toolcall(event, payload)
52
+ return unless payload['type'] == 'toolcall_start'
53
+
54
+ id = event['id'] || payload['id']
55
+ name = event['toolName'] || payload['toolName'] || 'tool'
56
+ @pending_tools[id] = name unless id.nil?
57
+ end
58
+
59
+ def handle_tool_execution_end(event)
60
+ name = event['toolName'] || 'tool'
61
+ error = event['isError'] == true
62
+ @streamer.tool_result(name, error: error)
63
+ end
64
+
65
+ def flush_pending_tools
66
+ @pending_tools.each_value { |name| @streamer.tool_start(name) }
67
+ @pending_tools.clear
68
+ end
69
+
70
+ def parse_event(line)
71
+ JSON.parse(line)
72
+ rescue JSON::ParserError
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'pi/events'
5
+
6
+ module Letsdo
7
+ module Backends
8
+ # Runs pi --mode json and hands normalized events to the streamer.
9
+ #
10
+ # Pi-specific knowledge lives here (spawn command, JSON event schema,
11
+ # exit-code mapping, process-group termination) -- the business layer
12
+ # sees only the backend protocol.
13
+ class Pi < Backend
14
+ COMMAND = 'pi'
15
+ MODE = 'json'
16
+
17
+ def initialize(prompt:, streamer:, **options)
18
+ flags = options.fetch(:flags, [])
19
+ command = options.fetch(:command, COMMAND)
20
+ model = options[:model]
21
+ super(prompt: prompt, streamer: streamer, debug: options[:debug],
22
+ config: options[:config])
23
+ @command = command
24
+ @flags = flags.dup
25
+ @flags.unshift('--model', model) if model && !@flags.include?('--model')
26
+ @pending_tools = {}
27
+ end
28
+
29
+ def run
30
+ out_r = spawn_pi
31
+ drain_stream(out_r)
32
+ finish_run
33
+ ensure
34
+ @pid = nil
35
+ @reaped_status = nil
36
+ end
37
+
38
+ private
39
+
40
+ # spawn / drain / finish
41
+
42
+ def spawn_pi
43
+ cmd = [@command, '--mode', MODE, *@flags, @prompt]
44
+ out_r, out_w = IO.pipe
45
+ spawn_process(cmd, out_r, out_w)
46
+ end
47
+
48
+ def spawn_process(cmd, out_r, out_w)
49
+ @pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
50
+ out_w.close
51
+ debug("spawned pid=#{@pid} (own group)")
52
+ out_r
53
+ rescue Errno::ENOENT, Errno::EACCES => e
54
+ out_w.close
55
+ out_r.close
56
+ raise Letsdo::BackendUnavailableError, "cannot start AI backend '#{@command}': #{e.message}"
57
+ end
58
+
59
+ def drain_stream(out_r)
60
+ read_pi_stream(out_r)
61
+ rescue Letsdo::Stopped
62
+ debug('stopped by signal, terminating pi')
63
+ terminate
64
+ wait_status(@pid)
65
+ @streamer.finish
66
+ raise
67
+ ensure
68
+ close_pipe(out_r)
69
+ flush_pending_tools
70
+ end
71
+
72
+ def close_pipe(out_r)
73
+ out_r.close
74
+ rescue IOError
75
+ nil
76
+ end
77
+
78
+ def finish_run
79
+ status = wait_status(@pid)
80
+ @streamer.finish
81
+ debug("exit status=#{status.inspect} code=#{exit_code(status)}")
82
+ exit_code(status)
83
+ end
84
+
85
+ include PiEvents
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'builder_assembly'
4
+ require_relative 'builder_metrics'
5
+
6
+ module Letsdo
7
+ class CLI
8
+ # TUI component assembly used by Letsdo::CLI::Builder. Kept in its own
9
+ # module so Builder stays within the class-length limit; mirrors the
10
+ # CLILaunch/CLIInit split pattern.
11
+ module BuilderTui
12
+ def run_tui(name, recorder)
13
+ ctx = tui_context(name, recorder)
14
+ session = Tui::Session.new(**ctx[:session])
15
+ session.run { ctx[:loop].run }
16
+ ensure
17
+ finish_session(name, recorder)
18
+ end
19
+
20
+ def tui_context(name, recorder)
21
+ parts = tui_parts(name, recorder)
22
+ {
23
+ loop: tui_loop(name, parts),
24
+ session: tui_session_args(name, **parts)
25
+ }
26
+ end
27
+
28
+ def tui_parts(name, recorder)
29
+ clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
30
+ handle = assignee_handle(name)
31
+ log = Tui::LogBuffer.new
32
+ tui_parts_hash(name, recorder, clock, handle, log)
33
+ end
34
+
35
+ # Assembles the parts hash. The header facade (Tui::Metrics — the only
36
+ # object answering #snapshot, which the renderer needs) and the Fanout
37
+ # that forwards loop events to both recorder and header share the same
38
+ # header instance (TASK-92).
39
+ def tui_parts_hash(name, recorder, clock, handle, log)
40
+ header = tui_metrics(name, handle, clock, log)
41
+ provider = provider_for(handle)
42
+ {
43
+ clock: clock, handle: handle, log: log, header: header,
44
+ metrics: Letsdo::Metrics::Fanout.new(recorder, header),
45
+ agent: agent_for(name, OutputStreamer.new(log: log)),
46
+ provider: provider,
47
+ pause_gate: Control::PauseGate.new,
48
+ refresh: tui_refresh(provider, recorder)
49
+ }
50
+ end
51
+
52
+ # The manual 'r' refresh (TASK-42) queries the provider immediately.
53
+ # Its return value feeds the header; the same count is handed to the
54
+ # session recorder so the stop summary reports a fresh 'left' value
55
+ # (TASK-69).
56
+ def tui_refresh(provider, recorder)
57
+ lambda do
58
+ tasks = provider.call
59
+ count = tasks&.length
60
+ recorder.provider_result(count)
61
+ count
62
+ end
63
+ end
64
+
65
+ def tui_loop(name, parts)
66
+ agent_loop(name, nil, agent: parts[:agent], provider: parts[:provider],
67
+ handle: parts[:handle], stderr: parts[:log],
68
+ metrics: parts[:metrics], pause_gate: parts[:pause_gate])
69
+ end
70
+
71
+ def tui_metrics(name, handle, clock, log)
72
+ Tui::Metrics.new(name: name, handle: handle, clock: clock,
73
+ on_run_start: ->(_label) { log.divider })
74
+ end
75
+
76
+ def tui_session_args(name, **parts)
77
+ {
78
+ name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:header],
79
+ terminal: Tui::Terminal.new(stream: @stdout),
80
+ input: Tui::Input.new(stdin: @stdin),
81
+ title: Tui::WindowTitle.new(stream: @stdout, env: @env),
82
+ refresh: parts[:refresh],
83
+ wait_seconds: wait_seconds, clock: parts[:clock],
84
+ pause_gate: parts[:pause_gate], runner: -> { parts[:agent].backend }
85
+ }
86
+ end
87
+ end
88
+
89
+ # Owns all component assembly for running an agent (TASK-54): builds the
90
+ # streamer, agent, task provider, orchestrator loop and the whole TUI,
91
+ # and decides plain vs TUI mode via stdout/stdin TTY + TERM. Parsing stays
92
+ # in Letsdo::CLI (TASK-54); this class is the wiring half — independently
93
+ # testable and free to grow with provider/backend selection (TASK-51/52)
94
+ # without bloating the parser. The wiring lives in BuilderAssembly so the
95
+ # class stays within the class-length limit.
96
+ class Builder
97
+ include BuilderTui
98
+ include BuilderAssembly
99
+ include BuilderMetrics
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class CLI
5
+ # Provider and backend registries used by Letsdo::CLI::Builder: name ->
6
+ # factory maps, injectable via the constructor for tests. Kept separate so
7
+ # the assembly module stays within the module-length limit.
8
+ module BuilderRegistries
9
+ # Provider registry: maps LETSDO_PROVIDER names to factories that build
10
+ # a provider from handle, command, cwd, and env. Inject a custom
11
+ # registry via the constructor for tests.
12
+ PROVIDERS = {
13
+ 'backlog' => lambda do |handle:, command:, cwd:, env:|
14
+ Providers::Backlog.new(handle: handle, command: command, cwd: cwd, env: env)
15
+ end
16
+ }.freeze
17
+
18
+ # Backend registry: maps LETSDO_BACKEND names to factory builders that
19
+ # take the Letsdo::Config and return a backend_factory (callable with
20
+ # prompt:, streamer:, model:). The pi entry captures the pi command and
21
+ # flags from Config (LETSDO_PI_COMMAND / LETSDO_PI_FLAGS with the
22
+ # AGENT_PI_FLAGS fallback), keeping pi vocabulary out of the business
23
+ # layer. Inject a custom registry via the constructor for tests.
24
+ BACKENDS = {
25
+ 'pi' => lambda do |config:|
26
+ command = config.pi_command
27
+ flags = config.pi_flags
28
+ lambda { |prompt:, streamer:, model: nil|
29
+ Letsdo::Backends::Pi.new(prompt: prompt, streamer: streamer,
30
+ flags: flags, command: command,
31
+ model: model, config: config)
32
+ }
33
+ end
34
+ }.freeze
35
+ end
36
+
37
+ # Registry-driven component wiring for Letsdo::CLI::Builder: the run
38
+ # entry point, agent/provider/loop assembly and the plain-mode run path.
39
+ # Extracted into its own module so Builder stays within the class-length
40
+ # limit (same reason as the BuilderTui split).
41
+ module BuilderAssembly
42
+ include BuilderRegistries
43
+
44
+ def initialize(env:, stdout:, stderr:, **rest)
45
+ @env = env
46
+ @stdout = stdout
47
+ @stderr = stderr
48
+ @stdin = rest[:stdin] || $stdin
49
+ @sleeper = rest[:sleeper]
50
+ @config = Config.new(env: env)
51
+ @root = @config.root
52
+ @provider_registry = rest[:provider_registry] || PROVIDERS
53
+ @backend_registry = rest[:backend_registry] || BACKENDS
54
+ end
55
+
56
+ # Runs <name> in plain or TUI mode; returns the process exit code.
57
+ def run(name)
58
+ return 1 unless resolve_provider!
59
+ return 1 unless resolve_backend!
60
+
61
+ store = PromptStore.new(root: @root)
62
+ announce_default_prompt(name, store) if store.read(name).nil?
63
+ recorder = build_recorder(name)
64
+ tui? ? run_tui(name, recorder) : run_plain(name, recorder)
65
+ end
66
+
67
+ private
68
+
69
+ def agent_for(name, streamer)
70
+ Agent.new(name: name, root: @root, handle: assignee_handle(name),
71
+ backend_factory: @selected_backend_factory,
72
+ streamer: streamer)
73
+ end
74
+
75
+ # Resolves LETSDO_BACKEND through the backend registry once per run,
76
+ # so both wiring paths (plain and TUI) build the Agent's
77
+ # backend_factory through the same registry entry. An unknown backend
78
+ # name fails fast instead of silently falling back.
79
+ def resolve_backend!
80
+ builder = @backend_registry[@config.backend]
81
+ if builder
82
+ @selected_backend_factory = builder.call(config: @config)
83
+ return true
84
+ end
85
+
86
+ @stderr.puts("letsdo: unknown AI backend: #{@config.backend}")
87
+ false
88
+ end
89
+
90
+ def resolve_provider!
91
+ @selected_provider_factory = @provider_registry[@config.provider]
92
+ return true if @selected_provider_factory
93
+
94
+ @stderr.puts("letsdo: unknown task provider: #{@config.provider}")
95
+ false
96
+ end
97
+
98
+ def provider_for(handle)
99
+ @selected_provider_factory.call(
100
+ handle: handle,
101
+ command: backlog_command,
102
+ cwd: @root,
103
+ env: ENV.to_h.merge(@env)
104
+ )
105
+ end
106
+
107
+ def agent_loop(name, streamer, **opts)
108
+ handle = opts[:handle] || assignee_handle(name)
109
+ agent = opts[:agent] || agent_for(name, streamer)
110
+ provider = opts[:provider] || provider_for(handle)
111
+ # The provider object itself (not a wrapping lambda) reaches the loop
112
+ # so the once-per-run assignee mismatch hint can read its
113
+ # #assignee_variants (TASK-96).
114
+ AgentLoop.new(name: name, handle: handle, agent: agent,
115
+ task_provider: provider,
116
+ wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
117
+ metrics: opts[:metrics], pause_gate: opts[:pause_gate],
118
+ watcher: backlog_watcher, **retry_options)
119
+ end
120
+
121
+ def retry_options
122
+ { retry_base: @config.retry_base, retry_cap: @config.retry_cap, max_retries: @config.max_retries }
123
+ end
124
+
125
+ def backlog_watcher
126
+ Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
127
+ end
128
+
129
+ # One-time fallback notification (before the first loop message): names
130
+ # the exact path checked and the placement hint. The agent still starts
131
+ # — Letsdo::Agent falls back to the built-in default prompt itself.
132
+ def announce_default_prompt(name, store)
133
+ @stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
134
+ @stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
135
+ end
136
+
137
+ def tui?
138
+ @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
139
+ end
140
+
141
+ def assignee_handle(name)
142
+ @config.assignee_handle(name)
143
+ end
144
+
145
+ def backlog_command
146
+ @config.backlog_command
147
+ end
148
+
149
+ def wait_seconds
150
+ @config.wait_seconds
151
+ end
152
+ end
153
+ end
154
+ end