letsdo 0.1.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.
data/lib/letsdo/cli.rb ADDED
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Letsdo
6
+ # Command-line argument parsing and running an agent orchestrator loop.
7
+ #
8
+ # CLI keeps the scaffold contract (TASK-20) and adds agent launching:
9
+ # letsdo — usage and agent list, exit code 1;
10
+ # letsdo --version — version, exit code 0;
11
+ # letsdo --help — usage, exit code 0;
12
+ # letsdo <name> — run the <name> agent in the orchestrator
13
+ # loop until SIGINT/SIGTERM: all open
14
+ # tasks assigned to the agent are done
15
+ # one run per task, the loop waits for
16
+ # new ones; clean exit code 0;
17
+ # letsdo <unknown name> — "Unknown agent: <name>" + list,
18
+ # exit code 1.
19
+ # letsdo <unknown option> — "letsdo: unknown option: X" + usage,
20
+ # exit code 1.
21
+ #
22
+ # Environment:
23
+ # LETSDO_ROOT project root (agents/ lives there); default — pwd.
24
+ # LETSDO_PI_FLAGS extra pi flags (split on whitespace; if unset —
25
+ # AGENT_PI_FLAGS is used for bin/agent
26
+ # compatibility).
27
+ # LETSDO_PI_COMMAND the pi command (default "pi"); overridable for
28
+ # tests/fake pi.
29
+ # AGENT_ASSIGNEE_HANDLE the agent's backlog assignee handle; default
30
+ # "@<name>" (the one rule: handle = name).
31
+ # LETSDO_WAIT_SECONDS retry interval when no tasks are open (if
32
+ # unset — AGENT_WAIT_SECONDS, default 10).
33
+ # LETSDO_BACKLOG_COMMAND the backlog CLI command (default "backlog");
34
+ # overridable for tests/fake backlog.
35
+ class CLI
36
+ USAGE = 'Usage: letsdo <agent_name>'
37
+ AGENTS_HEADER = 'Available agents:'
38
+ DEFAULT_WAIT_SECONDS = 10.0
39
+
40
+ # @param argv [Array<String>] command-line arguments
41
+ # @param env [Hash] process environment (LETSDO_ROOT, LETSDO_PI_FLAGS,
42
+ # AGENT_PI_FLAGS, AGENT_ASSIGNEE_HANDLE, LETSDO_WAIT_SECONDS,
43
+ # LETSDO_BACKLOG_COMMAND); injected in tests
44
+ # @param stdout [IO] stream for normal output (usage, --help, list)
45
+ # @param stderr [IO] stream for service output
46
+ # @param stdin [IO] keyboard stream (the TUI reads keys from it)
47
+ # @param sleeper [Proc, nil] waiting procedure for the loop (callable
48
+ # with the interval); injected in tests for deterministic stops
49
+ # @return [Integer] exit code: 0 — success (incl. loop stop), 1 — error,
50
+ # otherwise — pi exit code
51
+ def self.run(argv, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin, sleeper: nil)
52
+ new(env: env, stdout: stdout, stderr: stderr, stdin: stdin, sleeper: sleeper).run(argv)
53
+ end
54
+
55
+ def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
56
+ @env = env
57
+ @stdout = stdout
58
+ @stderr = stderr
59
+ @stdin = stdin
60
+ @sleeper = sleeper
61
+ @root = env.fetch('LETSDO_ROOT', Dir.pwd)
62
+ end
63
+
64
+ # @param argv [Array<String>] command-line arguments
65
+ # @return [Integer] exit code
66
+ def run(argv)
67
+ arg = argv[0]
68
+ case arg
69
+ when '--version', '-v'
70
+ @stdout.puts(VERSION)
71
+ 0
72
+ when '--help', '-h'
73
+ print_usage(@stdout)
74
+ 0
75
+ when nil
76
+ print_usage(@stderr)
77
+ 1
78
+ else
79
+ if arg.start_with?('-')
80
+ @stderr.puts("letsdo: unknown option: #{arg}")
81
+ print_usage(@stderr)
82
+ 1
83
+ else
84
+ run_agent(arg)
85
+ end
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def run_agent(name)
92
+ # The prompt must exist before the loop starts: an unknown agent fails
93
+ # fast (exit 1) instead of spinning in the loop.
94
+ PromptStore.new(root: @root).read(name)
95
+
96
+ if tui?
97
+ run_agent_tui(name)
98
+ else
99
+ run_agent_plain(name)
100
+ end
101
+ rescue UnknownAgentError => e
102
+ @stderr.puts(e.message)
103
+ print_agents
104
+ 1
105
+ end
106
+
107
+ # The non-interactive path: the plain line-stream output, byte-identical
108
+ # to the pre-TUI behavior (pipes, CI, tests, TERM=dumb).
109
+ def run_agent_plain(name)
110
+ streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
111
+ agent = Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
112
+ command: pi_command)
113
+ handle = assignee_handle(name)
114
+ provider = BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
115
+ env: ENV.to_h.merge(@env))
116
+ loop = AgentLoop.new(name: name, handle: handle, agent: agent,
117
+ task_provider: -> { provider.call },
118
+ wait_seconds: wait_seconds, sleeper: @sleeper, stderr: @stderr)
119
+ loop.run
120
+ end
121
+
122
+ # The interactive path: full-screen TUI over the same orchestrator loop.
123
+ # The streamer and the loop's service messages land in one combined log
124
+ # buffer; the loop driver feeds the header metrics facade; the session
125
+ # controller renders everything from a background input thread and
126
+ # restores the terminal on every exit path. The loop itself runs on the
127
+ # calling thread exactly as in plain mode.
128
+ def run_agent_tui(name)
129
+ clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
130
+ handle = assignee_handle(name)
131
+ log = Tui::LogBuffer.new
132
+ metrics = Tui::Metrics.new(name: name, handle: handle, clock: clock,
133
+ on_run_start: ->(_label) { log.divider })
134
+ terminal = Tui::Terminal.new(stream: @stdout)
135
+ input = Tui::Input.new(stdin: @stdin)
136
+ streamer = OutputStreamer.new(log: log)
137
+ agent = Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
138
+ command: pi_command)
139
+ provider = BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
140
+ env: ENV.to_h.merge(@env))
141
+ loop = AgentLoop.new(name: name, handle: handle, agent: agent,
142
+ task_provider: -> { provider.call },
143
+ wait_seconds: wait_seconds, sleeper: @sleeper, stderr: log,
144
+ metrics: metrics)
145
+ session = Tui::Session.new(name: name, handle: handle, log: log, metrics: metrics,
146
+ terminal: terminal, input: input,
147
+ refresh: -> { provider.call },
148
+ wait_seconds: wait_seconds, clock: clock)
149
+ session.run { loop.run }
150
+ end
151
+
152
+ # The TUI is engaged only when stdout and stdin are terminals and TERM
153
+ # is not dumb; otherwise the plain line-stream output (pipes, CI,
154
+ # tests) — no escape codes, no TUI.
155
+ def tui?
156
+ @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
157
+ end
158
+
159
+ # The agent's assignee handle: AGENT_ASSIGNEE_HANDLE override, otherwise
160
+ # the one rule — '@' + agent name.
161
+ def assignee_handle(name)
162
+ env_handle = @env['AGENT_ASSIGNEE_HANDLE']
163
+ env_handle && !env_handle.strip.empty? ? env_handle : "@#{name}"
164
+ end
165
+
166
+ def backlog_command
167
+ @env.fetch('LETSDO_BACKLOG_COMMAND', 'backlog')
168
+ end
169
+
170
+ # The retry interval when no tasks are open: LETSDO_WAIT_SECONDS, then
171
+ # AGENT_WAIT_SECONDS (bin/agent-loop compatibility), default 10 seconds.
172
+ def wait_seconds
173
+ value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
174
+ value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
175
+ return DEFAULT_WAIT_SECONDS if value.empty?
176
+
177
+ Float(value)
178
+ rescue ArgumentError, TypeError
179
+ DEFAULT_WAIT_SECONDS
180
+ end
181
+
182
+ def pi_command
183
+ @env.fetch('LETSDO_PI_COMMAND', PiRunner::COMMAND)
184
+ end
185
+
186
+ def print_usage(stream)
187
+ stream.puts(USAGE)
188
+ print_agents
189
+ end
190
+
191
+ def print_agents
192
+ @stdout.puts(AGENTS_HEADER)
193
+ PromptStore.new(root: @root).list.each { |name| @stdout.puts(" #{name}") }
194
+ end
195
+
196
+ # LETSDO_PI_FLAGS → array of flags; empty value = no flags.
197
+ # For bin/agent compatibility, when LETSDO_PI_FLAGS is absent
198
+ # AGENT_PI_FLAGS is used.
199
+ def parse_pi_flags
200
+ value = @env['LETSDO_PI_FLAGS'].to_s
201
+ value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
202
+ return [] if value.strip.empty?
203
+
204
+ Shellwords.split(value)
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Base error of the package.
5
+ class Error < StandardError; end
6
+
7
+ # An agent with this name was not found in agents/.
8
+ class UnknownAgentError < Error
9
+ attr_reader :name
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ super("Unknown agent: #{name}")
14
+ end
15
+ end
16
+
17
+ # Raised by the signal handler to interrupt whatever the main thread is
18
+ # doing (reading pi output, waiting for tasks, ...) so the loop unwinds
19
+ # cleanly. Not a StandardError — nothing rescues it accidentally.
20
+ class Stopped < Exception
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Orchestrator: while the backlog has open tasks assigned to the agent,
5
+ # runs the agent (one run = one task). When there are no tasks — waits
6
+ # and checks again. Stopping — only from outside: #stop (usually by a
7
+ # SIGINT/SIGTERM handler, as in bin/agent-loop).
8
+ #
9
+ # The task provider and the runner are injected so the loop is testable
10
+ # without a real backlog and pi; by default they are assembled from the
11
+ # project environment (backlog CLI + Letsdo::Agent).
12
+ class Loop
13
+ # @param task_provider [Proc] callable → Array of open tasks
14
+ # (empty = no tasks; nil = the backlog state is unreadable,
15
+ # in this case the loop does not run the agent and retries)
16
+ # @param run_task [Proc] callable(task) → agent run exit code
17
+ # @param wait_seconds [Float] wait interval when there are no tasks
18
+ # @param sleeper [Proc] callable(Float) → waiting (injected in tests)
19
+ def initialize(task_provider:, run_task:, wait_seconds: 10.0, sleeper: nil)
20
+ @task_provider = task_provider
21
+ @run_task = run_task
22
+ @wait_seconds = wait_seconds
23
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
24
+ @stopped = false
25
+ end
26
+
27
+ # Requests a stop after the current step.
28
+ def stop
29
+ @stopped = true
30
+ end
31
+
32
+ def stopped?
33
+ @stopped
34
+ end
35
+
36
+ # Runs the loop; ends only via #stop.
37
+ #
38
+ # @return [Integer] number of completed agent runs
39
+ def run
40
+ runs = 0
41
+ until @stopped
42
+ tasks = @task_provider.call
43
+ if tasks.nil? || tasks.empty?
44
+ @sleeper.call(@wait_seconds)
45
+ next
46
+ end
47
+
48
+ tasks.each do |task|
49
+ break if @stopped
50
+
51
+ @run_task.call(task)
52
+ runs += 1
53
+ end
54
+ end
55
+ runs
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Letsdo
6
+ # Routes pi output across two streams:
7
+ # stdout — only the agent's answer text (text_delta), no service lines;
8
+ # stderr — service lines: tool calls (name, arguments, result) as
9
+ # execution progress.
10
+ #
11
+ # Every action line gets a shared HH:MM:SS time prefix: the difference
12
+ # between prefixes shows how long ago an action happened and how long
13
+ # a tool ran. Format of service tool lines:
14
+ # HH:MM:SS ⚙ name: arguments — tool call on one line (for bash the
15
+ # command text, for read/write/edit the
16
+ # path, etc.);
17
+ # indented " " lines — execution result (stdout/stderr),
18
+ # no time prefix (data, not actions);
19
+ # HH:MM:SS ✓ name: done (Xs) — tool completion (success), printed
20
+ # after the result block;
21
+ # HH:MM:SS ✖ name: error (Xs) — tool completion (error);
22
+ # ✖ Error: ... — error result marked explicitly;
23
+ # … [output truncated: N lines, M] — big output → summary note.
24
+ # Remembers the last response character so that on finish a final newline
25
+ # is guaranteed — output must not break in the middle.
26
+ class OutputStreamer
27
+ # Max lines of a tool result in service output.
28
+ MAX_RESULT_LINES = 100
29
+ # Max characters of a tool result in service output.
30
+ MAX_RESULT_CHARS = 4_000
31
+ # Max characters of a short call-arguments representation.
32
+ MAX_ARGS_CHARS = 300
33
+
34
+ # @param stdout [IO] stream for the agent's answer text
35
+ # @param stderr [IO] stream for service lines
36
+ # @param log [Object, nil] an alternate log target (TUI mode): when
37
+ # given, ALL output — answer text and service tool lines — is
38
+ # written there as one combined stream (a
39
+ # Letsdo::Tui::LogBuffer); without it the plain stdout/stderr
40
+ # split is byte-identical to before
41
+ # @param clock [Proc] callable → Time, the source of time for prefixes
42
+ # (injected in tests for deterministic HH:MM:SS)
43
+ def initialize(stdout: $stdout, stderr: $stderr, log: nil, clock: nil)
44
+ @stdout = stdout
45
+ @stderr = stderr
46
+ @log = log
47
+ @clock = clock || -> { Time.now }
48
+ @last_char = nil
49
+ @action_started_at = nil
50
+ end
51
+
52
+ # Prints a chunk of the agent's answer text immediately.
53
+ #
54
+ # @param delta [String] the next text fragment
55
+ def text_delta(delta)
56
+ return if delta.nil? || delta.empty?
57
+
58
+ text_sink.write(delta)
59
+ text_sink.flush unless @log
60
+ @last_char = delta[-1]
61
+ end
62
+
63
+ # A service line about a tool call: HH:MM:SS time prefix, tool name and
64
+ # short arguments (e.g. the executed bash command text). One line without
65
+ # empty placeholders, to stderr so it does not mix with the agent text
66
+ # on stdout. The call moment is remembered — the action duration in the
67
+ # completion line is measured from it.
68
+ #
69
+ # @param name [String] tool name
70
+ # @param args [Hash, String, nil] call arguments (pi event)
71
+ def tool_start(name, args: nil)
72
+ @action_started_at = @clock.call
73
+ line = +"#{timestamp} ⚙ #{name}"
74
+ summary = summarize_args(name, args)
75
+ line << ": #{summary}" if summary
76
+ write_service("#{line}\n")
77
+ end
78
+
79
+ # Service lines of the tool result (command/file output): an indented
80
+ # block without a time prefix (action data) plus a completion line with
81
+ # the HH:MM:SS prefix, name, verdict and duration. Very large output is
82
+ # trimmed neatly with a summary note, errors are marked explicitly.
83
+ # An empty result yields only the completion line.
84
+ #
85
+ # @param name [String] tool name
86
+ # @param text [String] result text
87
+ # @param error [Boolean] whether the execution failed
88
+ def tool_result(name, text, error: false)
89
+ out = String.new
90
+ unless text.nil? || text.empty?
91
+ lines = text.lines
92
+ kept, truncated = truncate_result(text)
93
+ kept.each_with_index do |line, index|
94
+ line = line.chomp
95
+ next if line.empty? && index == kept.length - 1 # no trailing empty line
96
+
97
+ prefix = index.zero? ? (error ? " ✖ Error: " : " ") : " "
98
+ out << "#{prefix}#{line}\n"
99
+ end
100
+ out << result_note(lines) if truncated
101
+ end
102
+ out << completion_line(name, error)
103
+ write_service(out)
104
+ end
105
+
106
+ # Finishes the output: if the answer did not end with a newline — adds
107
+ # one so the next terminal output does not stick to the agent's answer.
108
+ def finish
109
+ return unless @last_char && @last_char != "\n"
110
+
111
+ text_sink.write("\n")
112
+ text_sink.flush unless @log
113
+ end
114
+
115
+ private
116
+
117
+ # Shared time prefix for all action lines: HH:MM:SS.
118
+ #
119
+ # @return [String] local time as hours:minutes:seconds
120
+ def timestamp
121
+ @clock.call.strftime("%H:%M:%S")
122
+ end
123
+
124
+ # Tool completion line: time prefix, verdict, name and action duration
125
+ # (seconds between call and completion). The duration is not printed
126
+ # when no action start was recorded (e.g. a result without a preceding
127
+ # call).
128
+ #
129
+ # @param name [String] tool name
130
+ # @param error [Boolean] whether the execution failed
131
+ # @return [String] completion line with a trailing newline
132
+ def completion_line(name, error)
133
+ mark = error ? "✖" : "✓"
134
+ verdict = error ? "error" : "done"
135
+ elapsed = elapsed_seconds
136
+ duration = elapsed ? " (#{format_elapsed(elapsed)})" : ""
137
+ "#{timestamp} #{mark} #{name}: #{verdict}#{duration}\n"
138
+ end
139
+
140
+ # Seconds from the current action start to its completion.
141
+ #
142
+ # @return [Float, nil] duration, or nil if there was no start
143
+ def elapsed_seconds
144
+ return nil unless @action_started_at
145
+
146
+ @clock.call - @action_started_at
147
+ end
148
+
149
+ # A neat duration: seconds with one decimal place up to 10s, whole
150
+ # seconds after.
151
+ def format_elapsed(seconds)
152
+ seconds < 10 ? format("%.1fs", seconds) : "#{seconds.round}s"
153
+ end
154
+
155
+ # Truncates the result text by line and character limits.
156
+ #
157
+ # @param text [String] the whole result text
158
+ # @return [Array(Array<String>, Boolean)] kept lines and truncation flag
159
+ def truncate_result(text)
160
+ lines = text.lines
161
+ kept = []
162
+ chars = 0
163
+ truncated = false
164
+ lines.each do |line|
165
+ if kept.length >= MAX_RESULT_LINES || (!kept.empty? && chars + line.length > MAX_RESULT_CHARS)
166
+ truncated = true
167
+ break
168
+ end
169
+ kept << line
170
+ chars += line.length
171
+ end
172
+ # A single line longer than the char limit — cut the line itself.
173
+ if kept.first && kept.first.length > MAX_RESULT_CHARS
174
+ kept[0] = kept[0].slice(0, MAX_RESULT_CHARS)
175
+ truncated = true
176
+ end
177
+ [kept, truncated]
178
+ end
179
+
180
+ # Summary note about the truncated result.
181
+ def result_note(lines)
182
+ total_lines = lines.length
183
+ total_chars = lines.sum(&:length)
184
+ lines_word = plural(total_lines, "line", "lines")
185
+ chars_word = plural(total_chars, "character", "characters")
186
+ " … [output truncated: #{total_lines} #{lines_word}, #{total_chars} #{chars_word}]\n"
187
+ end
188
+
189
+ # English singular/plural for a noun after a number.
190
+ def plural(count, singular, plural_form)
191
+ count == 1 ? singular : plural_form
192
+ end
193
+
194
+ # Short one-line representation of the tool call arguments.
195
+ #
196
+ # @param name [String] tool name
197
+ # @param args [Hash, String, nil] call arguments
198
+ # @return [String, nil] arguments string or nil (print nothing)
199
+ def summarize_args(name, args)
200
+ case name
201
+ when "bash", "powershell"
202
+ one_line(args_value(args, "command"))
203
+ when "read"
204
+ path = args_value(args, "path") || args_value(args, "file_path")
205
+ return nil unless path
206
+
207
+ range = read_range(args)
208
+ "#{path}#{range}"
209
+ when "write", "edit", "find", "ls"
210
+ args_value(args, "path") || args_value(args, "file_path")
211
+ when "grep"
212
+ pattern = one_line(args_value(args, "pattern"))
213
+ path = args_value(args, "path") || args_value(args, "file_path")
214
+ return nil unless pattern
215
+
216
+ path ? "#{pattern} #{path}" : pattern
217
+ else
218
+ summarize_generic(args)
219
+ end
220
+ end
221
+
222
+ # Read line range (offset/limit) as ":N-M", when given.
223
+ def read_range(args)
224
+ return nil unless args.is_a?(Hash)
225
+
226
+ offset = args["offset"]
227
+ return nil unless offset
228
+
229
+ limit = args["limit"]
230
+ limit ? ":#{offset}-#{offset + limit - 1}" : ":#{offset}"
231
+ end
232
+
233
+ # Tool argument value as a string; nil when absent.
234
+ def args_value(args, key)
235
+ return nil unless args.is_a?(Hash)
236
+
237
+ value = args[key]
238
+ value.nil? ? nil : value.to_s
239
+ end
240
+
241
+ # Universal arguments representation (other tools): short JSON on one line.
242
+ def summarize_generic(args)
243
+ return nil if args.nil? || (args.is_a?(Hash) && args.empty?)
244
+
245
+ text =
246
+ case args
247
+ when String then args
248
+ when Hash then JSON.generate(args)
249
+ else args.to_s
250
+ end
251
+ one_line(text)
252
+ end
253
+
254
+ # Collapses whitespace into a single line and cuts by the limit.
255
+ def one_line(value, max: MAX_ARGS_CHARS)
256
+ return nil if value.nil?
257
+
258
+ text = value.to_s.gsub(/\s+/, " ").strip
259
+ return nil if text.empty?
260
+
261
+ text.length > max ? "#{text.slice(0, max)}…" : text
262
+ end
263
+
264
+ # Writes a service line to stderr and flushes immediately so output
265
+ # appears as it is produced; in TUI mode the line goes to the shared
266
+ # log buffer instead.
267
+ def write_service(text)
268
+ if @log
269
+ @log.write(text)
270
+ else
271
+ @stderr.write(text)
272
+ @stderr.flush
273
+ end
274
+ end
275
+
276
+ # Where the agent's answer text goes: the shared log in TUI mode,
277
+ # stdout otherwise.
278
+ def text_sink
279
+ @log || @stdout
280
+ end
281
+ end
282
+ end