letsdo 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -2
- data/README.md +141 -12
- data/bin/letsdo +5 -0
- data/docs/config.md +171 -0
- data/docs/prompts.md +209 -0
- data/docs/task-selection.md +239 -0
- data/docs/usage.md +295 -0
- data/letsdo.gemspec +3 -1
- data/lib/letsdo/agent.rb +29 -17
- data/lib/letsdo/agent_identity.rb +42 -0
- data/lib/letsdo/agent_loop/tasks.rb +91 -13
- data/lib/letsdo/agent_loop.rb +36 -3
- data/lib/letsdo/backends/backend.rb +137 -0
- data/lib/letsdo/backends/pi/events.rb +77 -0
- data/lib/letsdo/backends/pi.rb +88 -0
- data/lib/letsdo/cli/builder.rb +47 -95
- data/lib/letsdo/cli/builder_assembly.rb +151 -0
- data/lib/letsdo/cli/builder_metrics.rb +82 -0
- data/lib/letsdo/cli/doctor.rb +16 -0
- data/lib/letsdo/cli.rb +15 -1
- data/lib/letsdo/config.rb +66 -4
- data/lib/letsdo/control/reader.rb +131 -0
- data/lib/letsdo/control.rb +6 -3
- data/lib/letsdo/doctor/checks.rb +98 -0
- data/lib/letsdo/doctor.rb +42 -0
- data/lib/letsdo/duration.rb +23 -0
- data/lib/letsdo/errors.rb +7 -0
- data/lib/letsdo/metrics/fanout.rb +47 -0
- data/lib/letsdo/prompt_store.rb +48 -1
- data/lib/letsdo/providers/backlog.rb +124 -0
- data/lib/letsdo/providers/task.rb +48 -0
- data/lib/letsdo/retry_policy.rb +98 -0
- data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
- data/lib/letsdo/session_recorder.rb +188 -0
- data/lib/letsdo/task_time_writeback.rb +106 -0
- data/lib/letsdo/tui/metrics.rb +7 -2
- data/lib/letsdo/tui/session/terminal.rb +47 -0
- data/lib/letsdo/tui/session/view.rb +10 -2
- data/lib/letsdo/tui/session.rb +27 -22
- data/lib/letsdo/tui/window_title.rb +133 -0
- data/lib/letsdo/tui.rb +4 -0
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo.rb +16 -5
- metadata +25 -5
- data/lib/letsdo/backlog_tasks.rb +0 -59
- data/lib/letsdo/pi_runner/events.rb +0 -75
- data/lib/letsdo/pi_runner/process.rb +0 -68
- 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
|
-
|
|
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,
|
|
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
|
|
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
|
-
#
|
|
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
|
data/lib/letsdo/control.rb
CHANGED
|
@@ -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
|
|
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::
|
|
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
|