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
@@ -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,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'shellwords'
4
- require_relative 'cli/launch'
5
3
  require_relative 'cli/init'
4
+ require_relative 'cli/doctor'
5
+ require_relative 'cli/builder'
6
6
 
7
7
  module Letsdo
8
8
  # Command-line argument parsing and running an agent orchestrator loop.
@@ -11,6 +11,8 @@ module Letsdo
11
11
  # letsdo — usage and agent list, exit code 1;
12
12
  # letsdo --version — version, exit code 0;
13
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;
14
16
  # letsdo <name> — run the <name> agent in the orchestrator
15
17
  # loop until SIGINT/SIGTERM;
16
18
  # letsdo <name> (no prompt) — same, but on the built-in default prompt;
@@ -21,12 +23,11 @@ module Letsdo
21
23
  # letsdo --init <name> — same, flag-first form;
22
24
  # letsdo <unknown option> — "letsdo: unknown option: X" + usage, exit 1.
23
25
  class CLI
24
- include CLILaunch
25
26
  include CLIInit
27
+ include CLIDoctor
26
28
 
27
29
  USAGE = 'Usage: letsdo <agent_name>'
28
30
  AGENTS_HEADER = 'Available agents:'
29
- DEFAULT_WAIT_SECONDS = 10.0
30
31
 
31
32
  def self.run(argv, **opts)
32
33
  new(env: opts.fetch(:env, ENV), stdout: opts.fetch(:stdout, $stdout),
@@ -35,12 +36,13 @@ module Letsdo
35
36
  end
36
37
 
37
38
  def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
38
- @env = env
39
39
  @stdout = stdout
40
40
  @stderr = stderr
41
+ @env = env
41
42
  @stdin = stdin
42
- @sleeper = sleeper
43
- @root = env.fetch('LETSDO_ROOT', Dir.pwd)
43
+ @root = Config.new(env: env).root
44
+ @builder = Builder.new(env: env, stdout: stdout, stderr: stderr,
45
+ stdin: stdin, sleeper: sleeper)
44
46
  end
45
47
 
46
48
  def run(argv)
@@ -48,6 +50,7 @@ module Letsdo
48
50
  return print_version if version_flag?(arg)
49
51
  return print_help if help_flag?(arg)
50
52
  return usage_error if arg.nil?
53
+ return doctor_command if arg == 'doctor'
51
54
  return init_command(argv) if argv.include?('--init')
52
55
  return unknown_option(arg) if arg.start_with?('-')
53
56
 
@@ -56,6 +59,13 @@ module Letsdo
56
59
 
57
60
  private
58
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
+
59
69
  def version_flag?(arg)
60
70
  ['--version', '-v'].include?(arg)
61
71
  end
@@ -85,47 +95,6 @@ module Letsdo
85
95
  1
86
96
  end
87
97
 
88
- def run_agent(name)
89
- store = PromptStore.new(root: @root)
90
- announce_default_prompt(name, store) if store.read(name).nil?
91
- tui? ? run_agent_tui(name) : run_agent_plain(name)
92
- end
93
-
94
- # One-time fallback notification (before the first loop message): names
95
- # the exact path checked and the placement hint. The agent still starts
96
- # — Letsdo::Agent falls back to the built-in default prompt itself.
97
- def announce_default_prompt(name, store)
98
- @stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
99
- @stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
100
- end
101
-
102
- def tui?
103
- @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
104
- end
105
-
106
- def assignee_handle(name)
107
- env_handle = @env['AGENT_ASSIGNEE_HANDLE']
108
- env_handle && !env_handle.strip.empty? ? env_handle : "@#{name}"
109
- end
110
-
111
- def backlog_command
112
- @env.fetch('LETSDO_BACKLOG_COMMAND', 'backlog')
113
- end
114
-
115
- def wait_seconds
116
- value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
117
- value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
118
- return DEFAULT_WAIT_SECONDS if value.empty?
119
-
120
- Float(value)
121
- rescue ArgumentError, TypeError
122
- DEFAULT_WAIT_SECONDS
123
- end
124
-
125
- def pi_command
126
- @env.fetch('LETSDO_PI_COMMAND', PiRunner::COMMAND)
127
- end
128
-
129
98
  def print_usage(stream)
130
99
  stream.puts(USAGE)
131
100
  print_agents
@@ -135,13 +104,5 @@ module Letsdo
135
104
  @stdout.puts(AGENTS_HEADER)
136
105
  PromptStore.new(root: @root).list.each { |name| @stdout.puts(" #{name}") }
137
106
  end
138
-
139
- def parse_pi_flags
140
- value = @env['LETSDO_PI_FLAGS'].to_s
141
- value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
142
- return [] if value.strip.empty?
143
-
144
- Shellwords.split(value)
145
- end
146
107
  end
147
108
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Letsdo
6
+ # The single place where every LETSDO_*/AGENT_* environment variable is
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
+ # testable in one place and gives future knobs (provider/backend
10
+ # selection) a single spot to add a variable.
11
+ #
12
+ # The env hash is injectable for tests and defaults to the process ENV.
13
+ class Config
14
+ DEFAULT_WAIT_SECONDS = 10.0
15
+ DEFAULT_PI_COMMAND = 'pi'
16
+ DEFAULT_BACKLOG_COMMAND = 'backlog'
17
+ DEFAULT_PROVIDER = 'backlog'
18
+ DEFAULT_BACKEND = 'pi'
19
+ DEFAULT_MAX_RETRIES = 3
20
+ DEFAULT_RETRY_CAP = 300.0
21
+
22
+ def initialize(env: ENV)
23
+ @env = env
24
+ end
25
+
26
+ # Project root with agents/ (and where the backlog CLI finds backlog/).
27
+ def root
28
+ @env.fetch('LETSDO_ROOT', Dir.pwd)
29
+ end
30
+
31
+ # Extra pi flags, split on whitespace. LETSDO_PI_FLAGS wins; when empty,
32
+ # AGENT_PI_FLAGS is the fallback (bin/agent-loop compatibility).
33
+ def pi_flags
34
+ value = @env['LETSDO_PI_FLAGS'].to_s
35
+ value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
36
+ return [] if value.strip.empty?
37
+
38
+ Shellwords.split(value)
39
+ end
40
+
41
+ # The agent's backlog assignee name. Default: the bare agent name —
42
+ # the tracker stores bare names ('developer'); the '@' prefix is
43
+ # prompt-only notation (TASK-96). AGENT_ASSIGNEE_HANDLE overrides for
44
+ # legacy setups (used verbatim; a @-prefixed value still matches after
45
+ # normalization in the provider, but `letsdo doctor` warns about it);
46
+ # a blank override also falls back to the derived bare name.
47
+ def assignee_handle(name)
48
+ assignee_handle_override || name.to_s
49
+ end
50
+
51
+ # Raw AGENT_ASSIGNEE_HANDLE (nil when unset or blank) — `letsdo
52
+ # doctor` uses it to warn about a legacy @-prefixed override instead
53
+ # of it silently missing every bare-named task.
54
+ def assignee_handle_override
55
+ value = @env['AGENT_ASSIGNEE_HANDLE']
56
+ value && !value.strip.empty? ? value : nil
57
+ end
58
+
59
+ # Retry interval when no tasks are open. LETSDO_WAIT_SECONDS wins, then
60
+ # AGENT_WAIT_SECONDS; an invalid value falls back to the default.
61
+ def wait_seconds
62
+ value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
63
+ value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
64
+ return DEFAULT_WAIT_SECONDS if value.empty?
65
+
66
+ Float(value)
67
+ rescue ArgumentError, TypeError
68
+ DEFAULT_WAIT_SECONDS
69
+ end
70
+
71
+ # The pi command used to run agents (same literal as Backends::Pi::COMMAND).
72
+ def pi_command
73
+ @env.fetch('LETSDO_PI_COMMAND', DEFAULT_PI_COMMAND)
74
+ end
75
+
76
+ # The Backlog.md CLI used as the task provider.
77
+ def backlog_command
78
+ @env.fetch('LETSDO_BACKLOG_COMMAND', DEFAULT_BACKLOG_COMMAND)
79
+ end
80
+
81
+ # Executable search path, used by `letsdo doctor` to resolve the
82
+ # configured command names. It lives here so doctor code never reads
83
+ # ENV directly (TASK-71).
84
+ def path
85
+ @env.fetch('PATH', '')
86
+ end
87
+
88
+ # Environment for CLI child processes spawned outside the builder
89
+ # (the doctor's handle-less provider): the process environment
90
+ # overlaid with the injected env, so shebang PATH resolution and test
91
+ # scenario variables both keep working (TASK-96).
92
+ def child_env
93
+ ENV.to_h.merge(@env)
94
+ end
95
+
96
+ # The task provider name. Reads LETSDO_PROVIDER with default 'backlog'.
97
+ # An empty value falls back to 'backlog'.
98
+ def provider
99
+ @env['LETSDO_PROVIDER'].to_s.strip.empty? ? DEFAULT_PROVIDER : @env['LETSDO_PROVIDER'].to_s.strip
100
+ end
101
+
102
+ # The AI backend name. Reads LETSDO_BACKEND with default 'pi'.
103
+ # An empty value falls back to 'pi'.
104
+ def backend
105
+ @env['LETSDO_BACKEND'].to_s.strip.empty? ? DEFAULT_BACKEND : @env['LETSDO_BACKEND'].to_s.strip
106
+ end
107
+
108
+ # Whether [letsdo] traces are enabled in the Pi backend and AgentLoop.
109
+ def debug?
110
+ @env['LETSDO_DEBUG'] == '1'
111
+ end
112
+
113
+ # Whether per-task elapsed is written back into the task record as a
114
+ # backlog comment at session stop (TASK-70). Opt-in: only the literal
115
+ # '1' enables it, so no task file is ever modified by default.
116
+ def task_time_comment?
117
+ @env['LETSDO_TASK_TIME_COMMENT'] == '1'
118
+ end
119
+
120
+ # Give-up after N consecutive failed runs of the same task in a session
121
+ # (default 3). Any invalid value falls back to the default.
122
+ def max_retries
123
+ value = @env['LETSDO_MAX_RETRIES'].to_s.strip
124
+ return DEFAULT_MAX_RETRIES if value.empty?
125
+
126
+ Integer(value)
127
+ rescue ArgumentError, TypeError
128
+ DEFAULT_MAX_RETRIES
129
+ end
130
+
131
+ # Base backoff seconds for the first retry; doubles per failure.
132
+ # Default = LETSDO_WAIT_SECONDS (the loop poll interval). An invalid
133
+ # value falls back to that default.
134
+ def retry_base
135
+ value = @env['LETSDO_RETRY_BASE'].to_s.strip
136
+ value.empty? ? wait_seconds : Float(value)
137
+ rescue ArgumentError, TypeError
138
+ wait_seconds
139
+ end
140
+
141
+ # Maximum backoff seconds (default 300). An invalid value falls back
142
+ # to the default.
143
+ def retry_cap
144
+ value = @env['LETSDO_RETRY_CAP'].to_s.strip
145
+ return DEFAULT_RETRY_CAP if value.empty?
146
+
147
+ Float(value)
148
+ rescue ArgumentError, TypeError
149
+ DEFAULT_RETRY_CAP
150
+ end
151
+ end
152
+ 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,144 @@
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, assignee_check,
16
+ root_check, 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
+ # Assignee-name convention (TASK-96): the tracker stores bare names
41
+ # ('developer'); '@' is prompt-only notation. The backlog CLI matches
42
+ # --assignee by exact string, so a legacy @-prefixed stored assignee
43
+ # (or handle override) is invisible to every exact-match query.
44
+ # letsdo still matches such tasks after normalization, but the
45
+ # deviation deserves a WARN so the data gets fixed. Uses a handle-less
46
+ # provider (no filtering) and reads the raw assignees; an unreadable
47
+ # backlog is reported as INFO because backlog_check already flags the
48
+ # cause.
49
+ def assignee_check
50
+ override = @config.assignee_handle_override
51
+ return warn_at_prefixed_override(override) if at_prefixed?(override)
52
+
53
+ tasks = provider_without_handle.call
54
+ return result('INFO', 'assignee names not checked - backlog task list failed') if tasks.nil?
55
+
56
+ warn_legacy_assignees(tasks)
57
+ end
58
+
59
+ # A handle-less provider sees every open task, so the check reads the
60
+ # raw stored assignees (no filtering, no variants recorded).
61
+ def provider_without_handle
62
+ Letsdo::Providers::Backlog.new(handle: nil, command: @config.backlog_command,
63
+ cwd: @config.root, env: @config.child_env)
64
+ end
65
+
66
+ def warn_legacy_assignees(tasks)
67
+ legacy = tasks.flat_map(&:assignees).select { |value| at_prefixed?(value) }.uniq.sort
68
+ return result('OK', 'assignee names are stored bare (canonical)') if legacy.empty?
69
+
70
+ quoted = legacy.map { |value| "'#{value}'" }.join(', ')
71
+ result('WARN', "tasks store legacy @-prefixed assignee(s): #{quoted}",
72
+ 'reassign them to bare names: backlog task edit <ID> -a <name>')
73
+ end
74
+
75
+ def warn_at_prefixed_override(override)
76
+ result('WARN', "AGENT_ASSIGNEE_HANDLE '#{override}' carries the legacy '@' prefix",
77
+ "set it to the bare name: AGENT_ASSIGNEE_HANDLE=#{override.sub(/\A@+/, '')}")
78
+ end
79
+
80
+ # '@developer' is prose notation; the canonical tracker value is the
81
+ # bare name, so any leading '@' is a legacy deviation worth a WARN.
82
+ def at_prefixed?(value)
83
+ value.to_s.strip.start_with?('@')
84
+ end
85
+
86
+ def command_check(label, command, env_var)
87
+ return result('OK', "#{label} command found: #{command}") if command_available?(command)
88
+
89
+ result('FAIL', "#{label} command not found: #{command}",
90
+ "install #{label} or set #{env_var}")
91
+ end
92
+
93
+ # An absolute or relative command path is checked directly; a bare name
94
+ # is resolved against the PATH exposed by Letsdo::Config.
95
+ def command_available?(command)
96
+ return File.executable?(command) if command.include?(File::SEPARATOR)
97
+
98
+ @config.path.split(File::PATH_SEPARATOR).any? do |dir|
99
+ File.executable?(File.join(dir, command))
100
+ end
101
+ end
102
+
103
+ def root_check
104
+ return result('OK', "project root #{@config.root} has backlog/tasks/") if backlog_tasks?
105
+
106
+ result('FAIL', "project root #{@config.root} has no backlog/tasks/",
107
+ 'run `backlog init` or set LETSDO_ROOT to a Backlog.md project')
108
+ end
109
+
110
+ def backlog_tasks?
111
+ File.directory?(File.join(@config.root, 'backlog', 'tasks'))
112
+ end
113
+
114
+ def agents_md_check
115
+ path = File.join(@config.root, 'AGENTS.md')
116
+ return result('OK', "AGENTS.md present at #{path}") if File.file?(path)
117
+
118
+ result('WARN', "no AGENTS.md at #{path}",
119
+ 'add AGENTS.md with the project instructions for agents')
120
+ end
121
+
122
+ def agents_dir_check
123
+ dir = File.join(@config.root, 'agents')
124
+ prompts = File.directory?(dir) ? Dir.children(dir).length : 0
125
+ return result('OK', "agents/ present with #{prompts} prompt(s)") if prompts.positive?
126
+
127
+ result('WARN', "agents/ missing or empty at #{dir}",
128
+ 'create an agent prompt with `letsdo <name> --init`')
129
+ end
130
+
131
+ # TUI engages only when stdout is a TTY (plus stdin/TERM, checked at
132
+ # launch); an INFO line tells the user which mode a run would pick.
133
+ def tty_check
134
+ return result('INFO', 'stdout is a TTY - TUI mode') if @stdout.tty?
135
+
136
+ result('INFO', 'stdout is not a TTY - plain mode')
137
+ end
138
+
139
+ def result(status, message, hint = nil)
140
+ { status: status, message: message, hint: hint }
141
+ end
142
+ end
143
+ end
144
+ end