letsdo 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -0
- data/README.md +147 -17
- 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 +63 -0
- 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 +43 -4
- 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 +102 -0
- 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 +17 -56
- data/lib/letsdo/config.rb +133 -0
- 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 +17 -5
- metadata +37 -12
- data/lib/letsdo/backlog_tasks.rb +0 -59
- data/lib/letsdo/cli/launch.rb +0 -87
- 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,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
|
-
@
|
|
43
|
-
@
|
|
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,133 @@
|
|
|
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 handle. Default: @<name> (handle = name);
|
|
42
|
+
# a blank override also falls back to the derived handle.
|
|
43
|
+
def assignee_handle(name)
|
|
44
|
+
handle = @env['AGENT_ASSIGNEE_HANDLE']
|
|
45
|
+
handle && !handle.strip.empty? ? handle : "@#{name}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Retry interval when no tasks are open. LETSDO_WAIT_SECONDS wins, then
|
|
49
|
+
# AGENT_WAIT_SECONDS; an invalid value falls back to the default.
|
|
50
|
+
def wait_seconds
|
|
51
|
+
value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
|
|
52
|
+
value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
|
|
53
|
+
return DEFAULT_WAIT_SECONDS if value.empty?
|
|
54
|
+
|
|
55
|
+
Float(value)
|
|
56
|
+
rescue ArgumentError, TypeError
|
|
57
|
+
DEFAULT_WAIT_SECONDS
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The pi command used to run agents (same literal as Backends::Pi::COMMAND).
|
|
61
|
+
def pi_command
|
|
62
|
+
@env.fetch('LETSDO_PI_COMMAND', DEFAULT_PI_COMMAND)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The Backlog.md CLI used as the task provider.
|
|
66
|
+
def backlog_command
|
|
67
|
+
@env.fetch('LETSDO_BACKLOG_COMMAND', DEFAULT_BACKLOG_COMMAND)
|
|
68
|
+
end
|
|
69
|
+
|
|
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.
|
|
90
|
+
def debug?
|
|
91
|
+
@env['LETSDO_DEBUG'] == '1'
|
|
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
|
|
132
|
+
end
|
|
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
|