letsdo 0.4.0 → 0.5.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -2
  3. data/README.md +141 -12
  4. data/bin/letsdo +5 -0
  5. data/docs/config.md +171 -0
  6. data/docs/prompts.md +209 -0
  7. data/docs/task-selection.md +239 -0
  8. data/docs/usage.md +295 -0
  9. data/letsdo.gemspec +3 -1
  10. data/lib/letsdo/agent.rb +29 -17
  11. data/lib/letsdo/agent_identity.rb +42 -0
  12. data/lib/letsdo/agent_loop/tasks.rb +91 -13
  13. data/lib/letsdo/agent_loop.rb +36 -3
  14. data/lib/letsdo/backends/backend.rb +137 -0
  15. data/lib/letsdo/backends/pi/events.rb +77 -0
  16. data/lib/letsdo/backends/pi.rb +88 -0
  17. data/lib/letsdo/cli/builder.rb +47 -95
  18. data/lib/letsdo/cli/builder_assembly.rb +151 -0
  19. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  20. data/lib/letsdo/cli/doctor.rb +16 -0
  21. data/lib/letsdo/cli.rb +15 -1
  22. data/lib/letsdo/config.rb +66 -4
  23. data/lib/letsdo/control/reader.rb +131 -0
  24. data/lib/letsdo/control.rb +6 -3
  25. data/lib/letsdo/doctor/checks.rb +98 -0
  26. data/lib/letsdo/doctor.rb +42 -0
  27. data/lib/letsdo/duration.rb +23 -0
  28. data/lib/letsdo/errors.rb +7 -0
  29. data/lib/letsdo/metrics/fanout.rb +47 -0
  30. data/lib/letsdo/prompt_store.rb +48 -1
  31. data/lib/letsdo/providers/backlog.rb +124 -0
  32. data/lib/letsdo/providers/task.rb +48 -0
  33. data/lib/letsdo/retry_policy.rb +98 -0
  34. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  35. data/lib/letsdo/session_recorder.rb +188 -0
  36. data/lib/letsdo/task_time_writeback.rb +106 -0
  37. data/lib/letsdo/tui/metrics.rb +7 -2
  38. data/lib/letsdo/tui/session/terminal.rb +47 -0
  39. data/lib/letsdo/tui/session/view.rb +10 -2
  40. data/lib/letsdo/tui/session.rb +27 -22
  41. data/lib/letsdo/tui/window_title.rb +133 -0
  42. data/lib/letsdo/tui.rb +4 -0
  43. data/lib/letsdo/version.rb +1 -1
  44. data/lib/letsdo.rb +16 -5
  45. metadata +25 -5
  46. data/lib/letsdo/backlog_tasks.rb +0 -59
  47. data/lib/letsdo/pi_runner/events.rb +0 -75
  48. data/lib/letsdo/pi_runner/process.rb +0 -68
  49. data/lib/letsdo/pi_runner.rb +0 -101
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class CLI
5
+ # Session recorder and metrics-file wiring for Letsdo::CLI::Builder.
6
+ # Kept in its own module so BuilderAssembly stays within the module-length
7
+ # limit.
8
+ module BuilderMetrics
9
+ # Returns the IO for the LETSDO_METRICS_FILE, or nil if unset or
10
+ # unwritable. Emits a warning to stderr on invalid path.
11
+ def metrics_io
12
+ path = @env['LETSDO_METRICS_FILE']
13
+ return nil if path.nil? || path.empty?
14
+
15
+ File.open(path, 'a', encoding: 'UTF-8')
16
+ rescue SystemCallError, IOError => e
17
+ @stderr.puts("letsdo: cannot open metrics file #{path}: #{e.message}")
18
+ nil
19
+ end
20
+
21
+ private
22
+
23
+ def build_recorder(name)
24
+ Letsdo::SessionRecorder.new(name: name, handle: assignee_handle(name),
25
+ metrics_io: metrics_io)
26
+ end
27
+
28
+ def run_plain(name, recorder)
29
+ streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
30
+ agent = agent_for(name, streamer)
31
+ pause_gate = Control::PauseGate.new
32
+ reader = plain_control_reader(agent, pause_gate)
33
+ agent_loop(name, streamer, agent: agent, stderr: @stderr, metrics: recorder,
34
+ pause_gate: pause_gate).run
35
+ ensure
36
+ reader&.stop
37
+ finish_session(name, recorder)
38
+ end
39
+
40
+ # Plain-mode control (TASK-75): with a terminal stdin the reader maps
41
+ # p/q to the same actions as the TUI keys — the shared PauseGate plus
42
+ # the current backend (nil between runs is a no-op). Reader#start is a
43
+ # no-op when stdin is not a terminal (pipes, CI), so stopping stays
44
+ # signal-only there. The reader writes nothing, so the plain stream
45
+ # stays byte-identical.
46
+ def plain_control_reader(agent, pause_gate)
47
+ Control::Reader.new(input: @stdin, pause_gate: pause_gate,
48
+ runner: -> { agent.backend }).tap(&:start)
49
+ end
50
+
51
+ # One shared stop path for plain and TUI mode (TASK-69/TASK-70): close
52
+ # the recorder, optionally write per-task elapsed back into the task
53
+ # records, then print the summary. Write-back failures are counted and
54
+ # reported, never raised.
55
+ def finish_session(name, recorder)
56
+ recorder.session_stop
57
+ not_written = task_time_comments(name, recorder)
58
+ @stderr.puts(recorder.summary_line)
59
+ @stderr.puts(comment_failure_line(not_written)) if not_written.positive?
60
+ end
61
+
62
+ # Opt-in (LETSDO_TASK_TIME_COMMENT=1): re-query the provider once at
63
+ # stop and comment the elapsed time on runs whose task is gone. Off by
64
+ # default — no subprocess, no task file mutation.
65
+ def task_time_comments(name, recorder)
66
+ return 0 unless @config.task_time_comment?
67
+
68
+ writeback = TaskTimeWriteback.new(command: backlog_command, cwd: @root,
69
+ env: ENV.to_h.merge(@env), stderr: @stderr)
70
+ writeback.call(recorder.summary.runs, final_open_tasks(name))
71
+ end
72
+
73
+ def final_open_tasks(name)
74
+ provider_for(assignee_handle(name)).call
75
+ end
76
+
77
+ def comment_failure_line(count)
78
+ "letsdo: #{count} #{count == 1 ? 'comment' : 'comments'} not written"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class CLI
5
+ # `letsdo doctor` dispatch (TASK-71): builds the environment self-check
6
+ # from the same Letsdo::Config the rest of the CLI uses and returns its
7
+ # exit code. `doctor` is a reserved name -- it never launches an agent.
8
+ module CLIDoctor
9
+ private
10
+
11
+ def doctor_command
12
+ Doctor.new(config: Config.new(env: @env), stdout: @stdout, stdin: @stdin).run
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/letsdo/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'cli/init'
4
+ require_relative 'cli/doctor'
4
5
  require_relative 'cli/builder'
5
6
 
6
7
  module Letsdo
@@ -10,6 +11,8 @@ module Letsdo
10
11
  # letsdo — usage and agent list, exit code 1;
11
12
  # letsdo --version — version, exit code 0;
12
13
  # letsdo --help — usage, exit code 0;
14
+ # letsdo doctor — environment self-check, exit 0 unless a
15
+ # check FAILs (then 1); `doctor` is reserved;
13
16
  # letsdo <name> — run the <name> agent in the orchestrator
14
17
  # loop until SIGINT/SIGTERM;
15
18
  # letsdo <name> (no prompt) — same, but on the built-in default prompt;
@@ -21,6 +24,7 @@ module Letsdo
21
24
  # letsdo <unknown option> — "letsdo: unknown option: X" + usage, exit 1.
22
25
  class CLI
23
26
  include CLIInit
27
+ include CLIDoctor
24
28
 
25
29
  USAGE = 'Usage: letsdo <agent_name>'
26
30
  AGENTS_HEADER = 'Available agents:'
@@ -34,6 +38,8 @@ module Letsdo
34
38
  def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
35
39
  @stdout = stdout
36
40
  @stderr = stderr
41
+ @env = env
42
+ @stdin = stdin
37
43
  @root = Config.new(env: env).root
38
44
  @builder = Builder.new(env: env, stdout: stdout, stderr: stderr,
39
45
  stdin: stdin, sleeper: sleeper)
@@ -44,14 +50,22 @@ module Letsdo
44
50
  return print_version if version_flag?(arg)
45
51
  return print_help if help_flag?(arg)
46
52
  return usage_error if arg.nil?
53
+ return doctor_command if arg == 'doctor'
47
54
  return init_command(argv) if argv.include?('--init')
48
55
  return unknown_option(arg) if arg.start_with?('-')
49
56
 
50
- @builder.run(arg)
57
+ run_agent(arg)
51
58
  end
52
59
 
53
60
  private
54
61
 
62
+ def run_agent(arg)
63
+ @builder.run(arg)
64
+ rescue Letsdo::BackendUnavailableError => e
65
+ @stderr.puts("letsdo: #{e.message}")
66
+ 2
67
+ end
68
+
55
69
  def version_flag?(arg)
56
70
  ['--version', '-v'].include?(arg)
57
71
  end
data/lib/letsdo/config.rb CHANGED
@@ -4,8 +4,8 @@ require 'shellwords'
4
4
 
5
5
  module Letsdo
6
6
  # The single place where every LETSDO_*/AGENT_* environment variable is
7
- # read, with exactly the defaults and precedence the CLI, PiRunner and
8
- # AgentLoop used to apply inline. Giving env policy one home makes it
7
+ # read, with exactly the defaults and precedence the CLI, the Pi backend
8
+ # and AgentLoop used to apply inline. Giving env policy one home makes it
9
9
  # testable in one place and gives future knobs (provider/backend
10
10
  # selection) a single spot to add a variable.
11
11
  #
@@ -14,6 +14,10 @@ module Letsdo
14
14
  DEFAULT_WAIT_SECONDS = 10.0
15
15
  DEFAULT_PI_COMMAND = 'pi'
16
16
  DEFAULT_BACKLOG_COMMAND = 'backlog'
17
+ DEFAULT_PROVIDER = 'backlog'
18
+ DEFAULT_BACKEND = 'pi'
19
+ DEFAULT_MAX_RETRIES = 3
20
+ DEFAULT_RETRY_CAP = 300.0
17
21
 
18
22
  def initialize(env: ENV)
19
23
  @env = env
@@ -53,7 +57,7 @@ module Letsdo
53
57
  DEFAULT_WAIT_SECONDS
54
58
  end
55
59
 
56
- # The pi command used to run agents (same literal as PiRunner::COMMAND).
60
+ # The pi command used to run agents (same literal as Backends::Pi::COMMAND).
57
61
  def pi_command
58
62
  @env.fetch('LETSDO_PI_COMMAND', DEFAULT_PI_COMMAND)
59
63
  end
@@ -63,9 +67,67 @@ module Letsdo
63
67
  @env.fetch('LETSDO_BACKLOG_COMMAND', DEFAULT_BACKLOG_COMMAND)
64
68
  end
65
69
 
66
- # Whether [letsdo] traces are enabled in PiRunner and AgentLoop.
70
+ # Executable search path, used by `letsdo doctor` to resolve the
71
+ # configured command names. It lives here so doctor code never reads
72
+ # ENV directly (TASK-71).
73
+ def path
74
+ @env.fetch('PATH', '')
75
+ end
76
+
77
+ # The task provider name. Reads LETSDO_PROVIDER with default 'backlog'.
78
+ # An empty value falls back to 'backlog'.
79
+ def provider
80
+ @env['LETSDO_PROVIDER'].to_s.strip.empty? ? DEFAULT_PROVIDER : @env['LETSDO_PROVIDER'].to_s.strip
81
+ end
82
+
83
+ # The AI backend name. Reads LETSDO_BACKEND with default 'pi'.
84
+ # An empty value falls back to 'pi'.
85
+ def backend
86
+ @env['LETSDO_BACKEND'].to_s.strip.empty? ? DEFAULT_BACKEND : @env['LETSDO_BACKEND'].to_s.strip
87
+ end
88
+
89
+ # Whether [letsdo] traces are enabled in the Pi backend and AgentLoop.
67
90
  def debug?
68
91
  @env['LETSDO_DEBUG'] == '1'
69
92
  end
93
+
94
+ # Whether per-task elapsed is written back into the task record as a
95
+ # backlog comment at session stop (TASK-70). Opt-in: only the literal
96
+ # '1' enables it, so no task file is ever modified by default.
97
+ def task_time_comment?
98
+ @env['LETSDO_TASK_TIME_COMMENT'] == '1'
99
+ end
100
+
101
+ # Give-up after N consecutive failed runs of the same task in a session
102
+ # (default 3). Any invalid value falls back to the default.
103
+ def max_retries
104
+ value = @env['LETSDO_MAX_RETRIES'].to_s.strip
105
+ return DEFAULT_MAX_RETRIES if value.empty?
106
+
107
+ Integer(value)
108
+ rescue ArgumentError, TypeError
109
+ DEFAULT_MAX_RETRIES
110
+ end
111
+
112
+ # Base backoff seconds for the first retry; doubles per failure.
113
+ # Default = LETSDO_WAIT_SECONDS (the loop poll interval). An invalid
114
+ # value falls back to that default.
115
+ def retry_base
116
+ value = @env['LETSDO_RETRY_BASE'].to_s.strip
117
+ value.empty? ? wait_seconds : Float(value)
118
+ rescue ArgumentError, TypeError
119
+ wait_seconds
120
+ end
121
+
122
+ # Maximum backoff seconds (default 300). An invalid value falls back
123
+ # to the default.
124
+ def retry_cap
125
+ value = @env['LETSDO_RETRY_CAP'].to_s.strip
126
+ return DEFAULT_RETRY_CAP if value.empty?
127
+
128
+ Float(value)
129
+ rescue ArgumentError, TypeError
130
+ DEFAULT_RETRY_CAP
131
+ end
70
132
  end
71
133
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Control
5
+ # The plain-mode control reader (TASK-94): when letsdo runs in plain
6
+ # line-stream mode (stdout not a TTY) but stdin IS a terminal, a
7
+ # background thread reads Enter-terminated commands and maps them to
8
+ # the same actions as the TUI keys (TASK-67/74 control model):
9
+ #
10
+ # p - toggle pause: on -> Control::PauseGate#pause plus the running
11
+ # backend's SIGSTOP (a no-op when nothing runs), off -> resume;
12
+ # q - clean stop: Thread.main.raise(Letsdo::Stopped), the exact
13
+ # teardown path of SIGINT/SIGTERM and the TUI 'q'.
14
+ #
15
+ # The reader only starts for a terminal stdin: with a pipe or
16
+ # /dev/null it never reads a single byte, so a user's piped stdin is
17
+ # left untouched and stopping stays signal-only. It writes nothing
18
+ # (no echo, no escape codes, no partial output) and its thread exits
19
+ # quietly on EOF or a read error.
20
+ class Reader
21
+ # @param input [IO] the stdin stream to read lines from
22
+ # @param pause_gate [Letsdo::Control::PauseGate, nil] the between-runs
23
+ # gate toggled by 'p'
24
+ # @param runner [#call, nil] callable returning the current backend
25
+ # (answers pause/resume) or nil; mirrors the TUI wiring, so a
26
+ # nil between runs is simply a no-op
27
+ # @param on_stop [#call, nil] stop action; defaults to raising
28
+ # Letsdo::Stopped into the main thread (injected in tests)
29
+ # @param tty [Boolean, nil] overrides the input.tty? check (tests only)
30
+ def initialize(input:, pause_gate: nil, runner: nil, on_stop: nil, tty: nil)
31
+ @input = input
32
+ @pause_gate = pause_gate
33
+ @runner = runner
34
+ @on_stop = on_stop
35
+ @tty = tty
36
+ @paused = false
37
+ @thread = nil
38
+ end
39
+
40
+ # Whether the reader may run: stdin must be a terminal that can be
41
+ # read line by line, otherwise a pipe, /dev/null or CI stdin would be
42
+ # read from (and stolen from the user's pipe).
43
+ #
44
+ # @return [Boolean]
45
+ def available?
46
+ @tty.nil? ? terminal_input? : @tty
47
+ end
48
+
49
+ # Starts the background reader thread. A non-terminal stdin starts
50
+ # nothing at all.
51
+ #
52
+ # @return [Thread, nil] the reader thread, or nil when not a terminal
53
+ def start
54
+ return nil unless available?
55
+ return @thread if @thread
56
+
57
+ @thread = build_thread
58
+ end
59
+
60
+ # Waits for the reader thread so tests and shutdown can join it.
61
+ #
62
+ # @param timeout [Numeric, nil] seconds to wait, nil waits forever
63
+ # @return [Thread, nil]
64
+ def join(timeout = nil)
65
+ @thread&.join(timeout)
66
+ end
67
+
68
+ # Ends the reader: kills the background thread when it is still
69
+ # blocked on input, so no reader outlives the run that started it.
70
+ # Safe to call when it was never started (non-TTY stdin).
71
+ #
72
+ # @return [void]
73
+ def stop
74
+ thread = @thread
75
+ return unless thread
76
+
77
+ thread.kill
78
+ thread.join(1)
79
+ @thread = nil
80
+ end
81
+
82
+ private
83
+
84
+ def terminal_input?
85
+ @input.respond_to?(:tty?) && @input.tty? && @input.respond_to?(:gets)
86
+ end
87
+
88
+ def build_thread
89
+ Thread.new { read_loop }.tap { |thread| thread.report_on_exception = false }
90
+ end
91
+
92
+ # Reads Enter-terminated commands until EOF or a read error; never
93
+ # writes and never lets an error escape the thread.
94
+ def read_loop
95
+ while (line = @input.gets)
96
+ handle_command(line)
97
+ end
98
+ rescue IOError, SystemCallError
99
+ nil
100
+ end
101
+
102
+ def handle_command(line)
103
+ case line.strip
104
+ when 'p' then toggle_pause
105
+ when 'q' then request_stop
106
+ end
107
+ end
108
+
109
+ def toggle_pause
110
+ @paused = !@paused
111
+ action = @paused ? :pause : :resume
112
+ invoke(@pause_gate, action)
113
+ invoke(current_runner, action)
114
+ end
115
+
116
+ def current_runner
117
+ @runner.respond_to?(:call) ? @runner.call : @runner
118
+ end
119
+
120
+ def invoke(target, action)
121
+ return unless target.respond_to?(action)
122
+
123
+ target.public_send(action)
124
+ end
125
+
126
+ def request_stop
127
+ (@on_stop || -> { Thread.main.raise(Letsdo::Stopped) }).call
128
+ end
129
+ end
130
+ end
131
+ end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'control/reader'
4
+
3
5
  module Letsdo
4
- # Agent-control primitives shared by the TUI and the orchestrator loop
5
- # (TASK-67 control model): pausing between runs.
6
+ # Agent-control primitives shared by the TUI, the plain-mode reader and
7
+ # the orchestrator loop (TASK-67 control model): pausing between runs
8
+ # (PauseGate) and the stdin control reader (Reader).
6
9
  module Control
7
10
  # A thread-safe pause flag polled by Letsdo::AgentLoop between runs.
8
11
  #
9
- # Mid-run suspension is handled by Letsdo::PiRunner#pause (SIGSTOP to
12
+ # Mid-run suspension is handled by Letsdo::Backends::Pi#pause (SIGSTOP to
10
13
  # the pi group, kernel-level freeze). Between runs there is no pi to
11
14
  # stop, so the pause lives in this gate: while #paused? is true the
12
15
  # loop must not start a new run, and it waits until #resume.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class Doctor
5
+ # The individual environment checks behind `letsdo doctor` (TASK-71).
6
+ # Every check returns { status:, message:, hint: } where the hint is
7
+ # mandatory for FAIL/WARN so the report is always actionable. Kept in its
8
+ # own module so Letsdo::Doctor stays within the class-length limit.
9
+ module DoctorChecks
10
+ MINIMUM_RUBY = '3.3'
11
+
12
+ private
13
+
14
+ def checks
15
+ [ruby_check, pi_check, backlog_check, root_check,
16
+ agents_md_check, agents_dir_check, tty_check]
17
+ end
18
+
19
+ # ruby >= 3.3 is the gemspec floor: below it letsdo still reports the
20
+ # problem as a WARN instead of a hard failure.
21
+ def ruby_check
22
+ return result('OK', "ruby #{RUBY_VERSION} (>= #{MINIMUM_RUBY})") if ruby_supported?
23
+
24
+ result('WARN', "ruby #{RUBY_VERSION} (< #{MINIMUM_RUBY})",
25
+ "upgrade Ruby to #{MINIMUM_RUBY} or newer")
26
+ end
27
+
28
+ def ruby_supported?
29
+ Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(MINIMUM_RUBY)
30
+ end
31
+
32
+ def pi_check
33
+ command_check('pi', @config.pi_command, 'LETSDO_PI_COMMAND')
34
+ end
35
+
36
+ def backlog_check
37
+ command_check('backlog', @config.backlog_command, 'LETSDO_BACKLOG_COMMAND')
38
+ end
39
+
40
+ def command_check(label, command, env_var)
41
+ return result('OK', "#{label} command found: #{command}") if command_available?(command)
42
+
43
+ result('FAIL', "#{label} command not found: #{command}",
44
+ "install #{label} or set #{env_var}")
45
+ end
46
+
47
+ # An absolute or relative command path is checked directly; a bare name
48
+ # is resolved against the PATH exposed by Letsdo::Config.
49
+ def command_available?(command)
50
+ return File.executable?(command) if command.include?(File::SEPARATOR)
51
+
52
+ @config.path.split(File::PATH_SEPARATOR).any? do |dir|
53
+ File.executable?(File.join(dir, command))
54
+ end
55
+ end
56
+
57
+ def root_check
58
+ return result('OK', "project root #{@config.root} has backlog/tasks/") if backlog_tasks?
59
+
60
+ result('FAIL', "project root #{@config.root} has no backlog/tasks/",
61
+ 'run `backlog init` or set LETSDO_ROOT to a Backlog.md project')
62
+ end
63
+
64
+ def backlog_tasks?
65
+ File.directory?(File.join(@config.root, 'backlog', 'tasks'))
66
+ end
67
+
68
+ def agents_md_check
69
+ path = File.join(@config.root, 'AGENTS.md')
70
+ return result('OK', "AGENTS.md present at #{path}") if File.file?(path)
71
+
72
+ result('WARN', "no AGENTS.md at #{path}",
73
+ 'add AGENTS.md with the project instructions for agents')
74
+ end
75
+
76
+ def agents_dir_check
77
+ dir = File.join(@config.root, 'agents')
78
+ prompts = File.directory?(dir) ? Dir.children(dir).length : 0
79
+ return result('OK', "agents/ present with #{prompts} prompt(s)") if prompts.positive?
80
+
81
+ result('WARN', "agents/ missing or empty at #{dir}",
82
+ 'create an agent prompt with `letsdo <name> --init`')
83
+ end
84
+
85
+ # TUI engages only when stdout is a TTY (plus stdin/TERM, checked at
86
+ # launch); an INFO line tells the user which mode a run would pick.
87
+ def tty_check
88
+ return result('INFO', 'stdout is a TTY - TUI mode') if @stdout.tty?
89
+
90
+ result('INFO', 'stdout is not a TTY - plain mode')
91
+ end
92
+
93
+ def result(status, message, hint = nil)
94
+ { status: status, message: message, hint: hint }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'doctor/checks'
4
+
5
+ module Letsdo
6
+ # `letsdo doctor`: one line per environment check with a status tag and an
7
+ # actionable hint for every FAIL/WARN (TASK-71). Exits 0 when nothing
8
+ # FAILs and 1 otherwise, so it can gate scripts.
9
+ #
10
+ # Every input comes from Letsdo::Config (env policy) plus the injected
11
+ # streams, so the command is fully testable without touching process ENV.
12
+ class Doctor
13
+ include DoctorChecks
14
+
15
+ def initialize(config:, stdout:, stdin: $stdin)
16
+ @config = config
17
+ @stdout = stdout
18
+ @stdin = stdin
19
+ end
20
+
21
+ # Prints the report and returns the process exit code: 1 when at least
22
+ # one check FAILed, 0 otherwise.
23
+ def run
24
+ results = checks
25
+ results.each { |check| print_result(check) }
26
+ results.any? { |check| check[:status] == 'FAIL' } ? 1 : 0
27
+ end
28
+
29
+ private
30
+
31
+ def print_result(check)
32
+ line = "[#{tag(check[:status])}] #{check[:message]}"
33
+ line += " - #{check[:hint]}" if check[:hint]
34
+ @stdout.puts(line)
35
+ end
36
+
37
+ # All tags render six characters wide, so the columns line up.
38
+ def tag(status)
39
+ status == 'OK' ? ' OK ' : status
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Shared rendering of a duration in seconds as "4m 12s" / "12s" / "0s".
5
+ #
6
+ # The stop summary (TASK-69) and the opt-in per-task elapsed write-back
7
+ # (TASK-70) both use this one formatter, so the number a session reports
8
+ # and the number written into the task record can never drift apart.
9
+ module Duration
10
+ module_function
11
+
12
+ # @param seconds [Numeric] duration in seconds (negative values clamp to 0)
13
+ # @return [String] human-readable duration, e.g. "4m 12s"
14
+ def format(seconds)
15
+ total = [seconds.to_f, 0.0].max.round
16
+ minutes, secs = total.divmod(60)
17
+ return '0s' if minutes.zero? && secs.zero?
18
+ return "#{secs}s" if minutes.zero?
19
+
20
+ "#{minutes}m #{secs}s"
21
+ end
22
+ end
23
+ end
data/lib/letsdo/errors.rb CHANGED
@@ -9,4 +9,11 @@ module Letsdo
9
9
  # cleanly. Not a StandardError — nothing rescues it accidentally.
10
10
  class Stopped < StandardError
11
11
  end
12
+
13
+ # Raised when the AI backend process cannot be started (missing or
14
+ # non-executable binary). This is a configuration error, not a loop
15
+ # problem: the CLI catches it, prints a clear message and exits 2 instead
16
+ # of dying with a Ruby backtrace.
17
+ class BackendUnavailableError < Error
18
+ end
12
19
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Metrics
5
+ # A small fan-out facade that forwards the loop-driver events to a
6
+ # list of observers. Used in TUI mode so the session recorder and
7
+ # the header metrics facade both receive the same callbacks from
8
+ # Letsdo::AgentLoop.
9
+ #
10
+ # Each observer must respond to provider_result, run_started and
11
+ # run_finished (the same interface Tui::Metrics and SessionRecorder
12
+ # implement). The exit code is forwarded to every observer so the
13
+ # session recorder can classify outcomes; Tui::Metrics ignores it.
14
+ class Fanout
15
+ # @param observers [Array<Object>] each must implement the three
16
+ # callback methods (provider_result, run_started, run_finished)
17
+ def initialize(*observers)
18
+ @observers = observers
19
+ end
20
+
21
+ # Forwards the provider open-task count to every observer.
22
+ #
23
+ # @param count [Integer, nil] number of open tasks; nil = the
24
+ # backlog state is unreadable
25
+ # @return [void]
26
+ def provider_result(count)
27
+ @observers.each { |observer| observer.provider_result(count) }
28
+ end
29
+
30
+ # Forwards a run-started event (task label) to every observer.
31
+ #
32
+ # @param task [String] task label
33
+ # @return [void]
34
+ def run_started(task)
35
+ @observers.each { |observer| observer.run_started(task) }
36
+ end
37
+
38
+ # Forwards a run-finished event (exit code) to every observer.
39
+ #
40
+ # @param exit_code [Integer, nil] the run exit code
41
+ # @return [void]
42
+ def run_finished(exit_code = nil)
43
+ @observers.each { |observer| observer.run_finished(exit_code) }
44
+ end
45
+ end
46
+ end
47
+ end