letsdo 0.4.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -2
  3. data/README.md +150 -18
  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 +3 -1
  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 +39 -3
  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 +47 -95
  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 +15 -1
  23. data/lib/letsdo/config.rb +89 -8
  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 +16 -5
  47. metadata +27 -6
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/pi_runner/events.rb +0 -75
  50. data/lib/letsdo/pi_runner/process.rb +0 -68
  51. 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,9 +66,29 @@ 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]
@@ -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
@@ -1,38 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'builder_assembly'
4
+ require_relative 'builder_metrics'
5
+
3
6
  module Letsdo
4
7
  class CLI
5
8
  # TUI component assembly used by Letsdo::CLI::Builder. Kept in its own
6
9
  # module so Builder stays within the class-length limit; mirrors the
7
10
  # CLILaunch/CLIInit split pattern.
8
11
  module BuilderTui
9
- def run_tui(name)
10
- ctx = tui_context(name)
12
+ def run_tui(name, recorder)
13
+ ctx = tui_context(name, recorder)
11
14
  session = Tui::Session.new(**ctx[:session])
12
15
  session.run { ctx[:loop].run }
16
+ ensure
17
+ finish_session(name, recorder)
13
18
  end
14
19
 
15
- def tui_context(name)
16
- parts = tui_parts(name)
20
+ def tui_context(name, recorder)
21
+ parts = tui_parts(name, recorder)
17
22
  {
18
23
  loop: tui_loop(name, parts),
19
24
  session: tui_session_args(name, **parts)
20
25
  }
21
26
  end
22
27
 
23
- def tui_parts(name)
28
+ def tui_parts(name, recorder)
24
29
  clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
25
30
  handle = assignee_handle(name)
26
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)
27
42
  {
28
- clock: clock, handle: handle, log: log,
29
- metrics: tui_metrics(name, handle, clock, log),
43
+ clock: clock, handle: handle, log: log, header: header,
44
+ metrics: Letsdo::Metrics::Fanout.new(recorder, header),
30
45
  agent: agent_for(name, OutputStreamer.new(log: log)),
31
- provider: provider_for(handle),
32
- pause_gate: Control::PauseGate.new
46
+ provider: provider,
47
+ pause_gate: Control::PauseGate.new,
48
+ refresh: tui_refresh(provider, recorder)
33
49
  }
34
50
  end
35
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
+
36
65
  def tui_loop(name, parts)
37
66
  agent_loop(name, nil, agent: parts[:agent], provider: parts[:provider],
38
67
  handle: parts[:handle], stderr: parts[:log],
@@ -46,105 +75,28 @@ module Letsdo
46
75
 
47
76
  def tui_session_args(name, **parts)
48
77
  {
49
- name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:metrics],
78
+ name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:header],
50
79
  terminal: Tui::Terminal.new(stream: @stdout),
51
80
  input: Tui::Input.new(stdin: @stdin),
52
- refresh: -> { parts[:provider].call },
81
+ title: Tui::WindowTitle.new(stream: @stdout, env: @env),
82
+ refresh: parts[:refresh],
53
83
  wait_seconds: wait_seconds, clock: parts[:clock],
54
- pause_gate: parts[:pause_gate], runner: -> { parts[:agent].runner }
84
+ pause_gate: parts[:pause_gate], runner: -> { parts[:agent].backend }
55
85
  }
56
86
  end
57
87
  end
58
88
 
59
89
  # Owns all component assembly for running an agent (TASK-54): builds the
60
- # streamer, agent, backlog provider, orchestrator loop and the whole TUI,
90
+ # streamer, agent, task provider, orchestrator loop and the whole TUI,
61
91
  # and decides plain vs TUI mode via stdout/stdin TTY + TERM. Parsing stays
62
92
  # in Letsdo::CLI (TASK-54); this class is the wiring half — independently
63
93
  # testable and free to grow with provider/backend selection (TASK-51/52)
64
- # without bloating the parser.
94
+ # without bloating the parser. The wiring lives in BuilderAssembly so the
95
+ # class stays within the class-length limit.
65
96
  class Builder
66
97
  include BuilderTui
67
-
68
- def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
69
- @env = env
70
- @stdout = stdout
71
- @stderr = stderr
72
- @stdin = stdin
73
- @sleeper = sleeper
74
- @config = Config.new(env: env)
75
- @root = @config.root
76
- end
77
-
78
- # Runs <name> in plain or TUI mode; returns the process exit code.
79
- def run(name)
80
- store = PromptStore.new(root: @root)
81
- announce_default_prompt(name, store) if store.read(name).nil?
82
- tui? ? run_tui(name) : run_plain(name)
83
- end
84
-
85
- private
86
-
87
- def run_plain(name)
88
- streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
89
- agent_loop(name, streamer, stderr: @stderr).run
90
- end
91
-
92
- def agent_for(name, streamer)
93
- Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
94
- command: pi_command)
95
- end
96
-
97
- def provider_for(handle)
98
- BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
99
- env: ENV.to_h.merge(@env))
100
- end
101
-
102
- def agent_loop(name, streamer, **opts)
103
- handle = opts[:handle] || assignee_handle(name)
104
- agent = opts[:agent] || agent_for(name, streamer)
105
- provider = opts[:provider] || provider_for(handle)
106
- AgentLoop.new(name: name, handle: handle, agent: agent,
107
- task_provider: -> { provider.call },
108
- wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
109
- metrics: opts[:metrics], pause_gate: opts[:pause_gate],
110
- watcher: backlog_watcher)
111
- end
112
-
113
- def backlog_watcher
114
- Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
115
- end
116
-
117
- # One-time fallback notification (before the first loop message): names
118
- # the exact path checked and the placement hint. The agent still starts
119
- # — Letsdo::Agent falls back to the built-in default prompt itself.
120
- def announce_default_prompt(name, store)
121
- @stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
122
- @stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
123
- end
124
-
125
- def tui?
126
- @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
127
- end
128
-
129
- def assignee_handle(name)
130
- @config.assignee_handle(name)
131
- end
132
-
133
- def backlog_command
134
- @config.backlog_command
135
- end
136
-
137
- def wait_seconds
138
- @config.wait_seconds
139
- end
140
-
141
- def pi_command
142
- @config.pi_command
143
- end
144
-
145
- def parse_pi_flags
146
- @config.pi_flags
147
- end
98
+ include BuilderAssembly
99
+ include BuilderMetrics
148
100
  end
149
101
  end
150
102
  end