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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require_relative 'duration'
|
|
5
|
+
require_relative 'capture'
|
|
6
|
+
|
|
7
|
+
module Letsdo
|
|
8
|
+
# Opt-in per-task elapsed write-back (TASK-70; design TASK-63 C3.3).
|
|
9
|
+
#
|
|
10
|
+
# At session stop the CLI hands this provider-agnostic writer the session's
|
|
11
|
+
# run records and one fresh provider snapshot. Every run that exited 0 and
|
|
12
|
+
# whose task is no longer open gets a backlog comment recording the elapsed
|
|
13
|
+
# time:
|
|
14
|
+
#
|
|
15
|
+
# backlog task edit <id> --comment 'letsdo: completed in 4m 12s' \
|
|
16
|
+
# --comment-author @letsdo
|
|
17
|
+
#
|
|
18
|
+
# A task that is still open after its run is skipped — calling it completed
|
|
19
|
+
# would be wrong. The write-back is batched at stop (all agent runs are
|
|
20
|
+
# dead), so it cannot race the agent's own closing edit on the file-based
|
|
21
|
+
# backlog CLI.
|
|
22
|
+
#
|
|
23
|
+
# Failures never abort the stop path: a missing/renamed task or a failing
|
|
24
|
+
# command warns once for that task and is counted, so the summary can report
|
|
25
|
+
# how many comments were not written.
|
|
26
|
+
class TaskTimeWriteback
|
|
27
|
+
COMMENT_AUTHOR = '@letsdo'
|
|
28
|
+
COMMENT_PREFIX = 'letsdo: completed in'
|
|
29
|
+
|
|
30
|
+
# @param command [String] backlog CLI command (LETSDO_BACKLOG_COMMAND)
|
|
31
|
+
# @param cwd [String] project root the CLI runs in
|
|
32
|
+
# @param env [Hash, nil] child environment (nil = inherit the process one)
|
|
33
|
+
# @param stderr [IO] warning sink
|
|
34
|
+
def initialize(command:, cwd:, env: nil, stderr: $stderr)
|
|
35
|
+
@command = command
|
|
36
|
+
@cwd = cwd
|
|
37
|
+
@env = env
|
|
38
|
+
@stderr = stderr
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Writes one comment per eligible run.
|
|
42
|
+
#
|
|
43
|
+
# @param runs [Array<SessionRecorder::Run>] the session's run records
|
|
44
|
+
# @param open_tasks [Array<Providers::Task>, nil] fresh provider snapshot;
|
|
45
|
+
# nil when the backlog is unreadable (nothing can be verified)
|
|
46
|
+
# @return [Integer] number of comments not written
|
|
47
|
+
def call(runs, open_tasks)
|
|
48
|
+
return skip_unreadable(runs) if open_tasks.nil?
|
|
49
|
+
|
|
50
|
+
open_ids = open_tasks.map(&:id)
|
|
51
|
+
eligible(runs, open_ids).count { |run| !write(run) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Done runs whose task is absent from the fresh snapshot, one entry per
|
|
57
|
+
# task (a task retried after a late success cannot get two comments).
|
|
58
|
+
def eligible(runs, open_ids)
|
|
59
|
+
runs.select { |run| run.outcome == :done }
|
|
60
|
+
.reject { |run| open_ids.include?(run.task_id) }
|
|
61
|
+
.uniq(&:task_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Without a readable snapshot we cannot tell closed from still-open, so we
|
|
65
|
+
# write nothing and warn once instead of guessing.
|
|
66
|
+
def skip_unreadable(runs)
|
|
67
|
+
return 0 if runs.none? { |run| run.outcome == :done }
|
|
68
|
+
|
|
69
|
+
@stderr.puts('letsdo: cannot write task time comments: backlog unreadable')
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write(run)
|
|
74
|
+
_out, err, status = capture(run)
|
|
75
|
+
return true if status.success?
|
|
76
|
+
|
|
77
|
+
warn_once(run, failure_message(err, status))
|
|
78
|
+
false
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
warn_once(run, e.message)
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def capture(run)
|
|
85
|
+
args = @env ? [@env, *argv(run)] : argv(run)
|
|
86
|
+
Letsdo::Capture.new(*args, chdir: @cwd).run
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def argv(run)
|
|
90
|
+
[*Shellwords.split(@command), 'task', 'edit', run.task_id,
|
|
91
|
+
'--comment', "#{COMMENT_PREFIX} #{Duration.format(run.elapsed_s)}",
|
|
92
|
+
'--comment-author', COMMENT_AUTHOR]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def warn_once(run, reason)
|
|
96
|
+
@stderr.puts("letsdo: cannot write task time comment for #{run.task_id}: #{reason}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def failure_message(err, status)
|
|
100
|
+
message = err.to_s.strip
|
|
101
|
+
return message unless message.empty?
|
|
102
|
+
|
|
103
|
+
"backlog exited with code #{status.exitstatus}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/letsdo/tui/metrics.rb
CHANGED
|
@@ -60,8 +60,13 @@ module Letsdo
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# An agent run finished: increments the done counter and clears the
|
|
63
|
-
# current-task state.
|
|
64
|
-
|
|
63
|
+
# current-task state. The exit code is accepted for compatibility
|
|
64
|
+
# with the session recorder (TASK-69) but is ignored here — the
|
|
65
|
+
# header does not classify outcomes.
|
|
66
|
+
#
|
|
67
|
+
# @param _exit_code [Integer, nil] the run exit code (ignored)
|
|
68
|
+
# @return [void]
|
|
69
|
+
def run_finished(_exit_code = nil)
|
|
65
70
|
@mutex.synchronize do
|
|
66
71
|
@done += 1
|
|
67
72
|
@current_task = nil
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Tui
|
|
5
|
+
# Terminal lifecycle for the TUI session: entering/leaving the alternate
|
|
6
|
+
# screen, the tmux window label around it, and the SIGWINCH handler.
|
|
7
|
+
# Extracted from Session so the class stays within the class-length limit
|
|
8
|
+
# (same reason as SessionKeys / SessionView).
|
|
9
|
+
module SessionTerminal
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Prepares the session terminal: SIGWINCH tracking, the alternate
|
|
13
|
+
# screen, and — inside tmux — the agent-named window label.
|
|
14
|
+
def install_terminal
|
|
15
|
+
install_winch_handler
|
|
16
|
+
@terminal.enter
|
|
17
|
+
@title&.install(@name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Runs the full terminal cleanup on every exit path: stops the input
|
|
21
|
+
# thread, restores the tmux window label, leaves the alternate screen
|
|
22
|
+
# and restores the SIGWINCH handler.
|
|
23
|
+
def restore_terminal
|
|
24
|
+
stop_input_thread
|
|
25
|
+
@title&.restore
|
|
26
|
+
leave_terminal
|
|
27
|
+
restore_winch_handler
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def leave_terminal
|
|
31
|
+
@terminal.leave
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def install_winch_handler
|
|
35
|
+
Signal.trap('SIGWINCH') { @winch = true }
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def restore_winch_handler
|
|
41
|
+
Signal.trap('SIGWINCH', 'DEFAULT')
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -10,8 +10,16 @@ module Letsdo
|
|
|
10
10
|
repaint
|
|
11
11
|
@last_repaint = @clock.call
|
|
12
12
|
loop { break if @stop || !poll_once }
|
|
13
|
-
rescue
|
|
14
|
-
|
|
13
|
+
rescue Letsdo::Stopped
|
|
14
|
+
raise
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
# A paint/input/resize failure here must not leave a blank,
|
|
17
|
+
# unresponsive TUI: record it and interrupt the run so it is surfaced
|
|
18
|
+
# after the terminal is restored (TASK-92).
|
|
19
|
+
return if @stop
|
|
20
|
+
|
|
21
|
+
@input_error = e
|
|
22
|
+
quit
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
def poll_once
|
data/lib/letsdo/tui/session.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'session/keys'
|
|
4
4
|
require_relative 'session/view'
|
|
5
|
+
require_relative 'session/terminal'
|
|
5
6
|
|
|
6
7
|
module Letsdo
|
|
7
8
|
module Tui
|
|
@@ -10,6 +11,7 @@ module Letsdo
|
|
|
10
11
|
class Session
|
|
11
12
|
include SessionKeys
|
|
12
13
|
include SessionView
|
|
14
|
+
include SessionTerminal
|
|
13
15
|
|
|
14
16
|
REPAINT_INTERVAL = 1.0
|
|
15
17
|
IDLE_SLEEP = 0.01
|
|
@@ -23,15 +25,15 @@ module Letsdo
|
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def run(&work)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
install_terminal
|
|
29
|
+
result = run_work(&work)
|
|
30
|
+
raise_input_error!
|
|
31
|
+
result
|
|
29
32
|
rescue Letsdo::Stopped
|
|
33
|
+
raise_input_error!
|
|
30
34
|
0
|
|
31
35
|
ensure
|
|
32
|
-
|
|
33
|
-
leave_terminal
|
|
34
|
-
restore_winch_handler
|
|
36
|
+
restore_terminal
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def run_work(&work)
|
|
@@ -41,6 +43,23 @@ module Letsdo
|
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
|
|
46
|
+
# Re-raises a failure captured by the background input/render thread
|
|
47
|
+
# so a paint or input error is never silently swallowed into a blank,
|
|
48
|
+
# unresponsive TUI (TASK-92). Called after the work ends, on both the
|
|
49
|
+
# normal and the stopped path, before the ensure restores the terminal;
|
|
50
|
+
# the error therefore propagates only once the alternate screen is left.
|
|
51
|
+
#
|
|
52
|
+
# A fresh instance is raised rather than the captured object: quit
|
|
53
|
+
# interrupts the main thread from inside the input thread's rescue, so
|
|
54
|
+
# the injected Letsdo::Stopped already carries the captured error as its
|
|
55
|
+
# cause, and re-raising that object would form a circular cause chain.
|
|
56
|
+
def raise_input_error!
|
|
57
|
+
return unless @input_error
|
|
58
|
+
|
|
59
|
+
error = @input_error
|
|
60
|
+
raise error.class, error.message, error.backtrace
|
|
61
|
+
end
|
|
62
|
+
|
|
44
63
|
private
|
|
45
64
|
|
|
46
65
|
def assign_identity(opts)
|
|
@@ -53,6 +72,7 @@ module Letsdo
|
|
|
53
72
|
def assign_io(opts)
|
|
54
73
|
@terminal = opts.fetch(:terminal)
|
|
55
74
|
@input = opts.fetch(:input)
|
|
75
|
+
@title = opts[:title]
|
|
56
76
|
@refresh = opts[:refresh]
|
|
57
77
|
@wait_seconds = opts.fetch(:wait_seconds, 10.0)
|
|
58
78
|
@clock = opts[:clock] || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
@@ -72,6 +92,7 @@ module Letsdo
|
|
|
72
92
|
@body_height = 1
|
|
73
93
|
@seen_version = 0
|
|
74
94
|
@input_thread = nil
|
|
95
|
+
@input_error = nil
|
|
75
96
|
end
|
|
76
97
|
|
|
77
98
|
# Enters raw mode on the keyboard for the duration of the block. Held
|
|
@@ -86,18 +107,6 @@ module Letsdo
|
|
|
86
107
|
end
|
|
87
108
|
end
|
|
88
109
|
|
|
89
|
-
def install_winch_handler
|
|
90
|
-
Signal.trap('SIGWINCH') { @winch = true }
|
|
91
|
-
rescue ArgumentError
|
|
92
|
-
nil
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def restore_winch_handler
|
|
96
|
-
Signal.trap('SIGWINCH', 'DEFAULT')
|
|
97
|
-
rescue ArgumentError
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
100
|
-
|
|
101
110
|
def start_input_thread
|
|
102
111
|
@input_thread = Thread.new { input_loop }
|
|
103
112
|
@input_thread.report_on_exception = false
|
|
@@ -111,10 +120,6 @@ module Letsdo
|
|
|
111
120
|
|
|
112
121
|
thread.join(INPUT_JOIN_TIMEOUT) || thread.kill
|
|
113
122
|
end
|
|
114
|
-
|
|
115
|
-
def leave_terminal
|
|
116
|
-
@terminal.leave
|
|
117
|
-
end
|
|
118
123
|
end
|
|
119
124
|
end
|
|
120
125
|
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
module Tui
|
|
7
|
+
# Names the tmux window after the running agent (TASK-90) and restores
|
|
8
|
+
# the previous label on exit.
|
|
9
|
+
#
|
|
10
|
+
# tmux labels a window after its foreground process (automatic-rename),
|
|
11
|
+
# which for letsdo is always "ruby" — useless when one agent runs per
|
|
12
|
+
# pane. Two tmux facts (verified against tmux 3.4) shape this class:
|
|
13
|
+
#
|
|
14
|
+
# * an OSC-2 title escape only sets the *pane* title; the window label
|
|
15
|
+
# still follows the process name;
|
|
16
|
+
# * so the window must be renamed explicitly and automatic-rename
|
|
17
|
+
# disabled for the session, or tmux overwrites the label.
|
|
18
|
+
#
|
|
19
|
+
# Every tmux call goes through an injectable runner (tests stub it) and
|
|
20
|
+
# every failure is swallowed: title handling may never break a session.
|
|
21
|
+
# Being inside tmux on a TTY stream is the gate, so outside tmux nothing
|
|
22
|
+
# is written and the tmux binary is never invoked.
|
|
23
|
+
class WindowTitle
|
|
24
|
+
# OSC-2 title escape; only emitted inside tmux (see #install).
|
|
25
|
+
TITLE_TEMPLATE = "\e]2;%s\a"
|
|
26
|
+
# automatic-rename value meaning "no explicit setting" (tmux default on).
|
|
27
|
+
UNSET = ''
|
|
28
|
+
|
|
29
|
+
# @param stream [IO] terminal output stream (title escapes go here)
|
|
30
|
+
# @param env [Hash, ENV] source of TMUX / TMUX_PANE
|
|
31
|
+
# @param runner [Proc, nil] callable(Array<String>) → stdout String;
|
|
32
|
+
# defaults to the real tmux binary, stubbed in tests
|
|
33
|
+
def initialize(stream:, env:, runner: nil)
|
|
34
|
+
@stream = stream
|
|
35
|
+
@env = env
|
|
36
|
+
@runner = runner || method(:run_tmux)
|
|
37
|
+
@previous = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Saves the current window label and labels the window with `name`.
|
|
41
|
+
# No-op outside tmux, or when the stream is not a TTY.
|
|
42
|
+
def install(name)
|
|
43
|
+
return unless tmux?
|
|
44
|
+
|
|
45
|
+
state = capture
|
|
46
|
+
return unless state
|
|
47
|
+
|
|
48
|
+
label(name)
|
|
49
|
+
write_title(name)
|
|
50
|
+
@previous = state
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Puts the saved label back and restores automatic-rename, unsetting
|
|
54
|
+
# the option when it had no explicit value before (no leftover state).
|
|
55
|
+
def restore
|
|
56
|
+
state = @previous
|
|
57
|
+
return unless state
|
|
58
|
+
|
|
59
|
+
@previous = nil
|
|
60
|
+
label(state[:name])
|
|
61
|
+
restore_automatic_rename(state[:automatic_rename])
|
|
62
|
+
write_title(state[:name])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether a tmux session hosts this process and titles can be set.
|
|
66
|
+
def tmux?
|
|
67
|
+
return false unless @stream.respond_to?(:tty?) && @stream.tty?
|
|
68
|
+
|
|
69
|
+
!@env['TMUX'].to_s.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Renames the window, then disables automatic-rename so tmux keeps the
|
|
75
|
+
# label for the whole session.
|
|
76
|
+
def label(name)
|
|
77
|
+
tmux('rename-window', name)
|
|
78
|
+
tmux('set-option', '-w', 'automatic-rename', 'off')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def restore_automatic_rename(value)
|
|
82
|
+
return tmux('set-option', '-w', '-u', 'automatic-rename') if value == UNSET
|
|
83
|
+
|
|
84
|
+
tmux('set-option', '-w', 'automatic-rename', value)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The current window label and automatic-rename value, or nil when the
|
|
88
|
+
# label cannot be read (no tmux, no server, failed command).
|
|
89
|
+
def capture
|
|
90
|
+
name = tmux('display-message', '-p', "\#{window_name}").to_s.strip
|
|
91
|
+
return nil if name.empty?
|
|
92
|
+
|
|
93
|
+
{ name: name, automatic_rename: automatic_rename_option }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def automatic_rename_option
|
|
97
|
+
tmux('show-window-options', '-v', 'automatic-rename').to_s.strip
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def write_title(text)
|
|
101
|
+
@stream.write(format(TITLE_TEMPLATE, text))
|
|
102
|
+
@stream.flush
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# One tmux command through the injected runner; a missing binary, an
|
|
106
|
+
# unreachable server or a failed command all yield nil.
|
|
107
|
+
def tmux(*args)
|
|
108
|
+
@runner.call(window_args(*args))
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Targets the pane letsdo runs in, so the commands act on the right
|
|
114
|
+
# window even when the tmux server has no attached client. `-t` is a
|
|
115
|
+
# command option and must follow the subcommand.
|
|
116
|
+
def window_args(*args)
|
|
117
|
+
pane = @env['TMUX_PANE'].to_s
|
|
118
|
+
return args if pane.empty?
|
|
119
|
+
|
|
120
|
+
[args.first, '-t', pane, *args.drop(1)]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param args [Array<String>] tmux arguments
|
|
124
|
+
# @return [String] the command's stdout
|
|
125
|
+
def run_tmux(args)
|
|
126
|
+
out, _err, status = Open3.capture3('tmux', *args)
|
|
127
|
+
raise "tmux failed: #{args.join(' ')}" unless status.success?
|
|
128
|
+
|
|
129
|
+
out
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/letsdo/tui.rb
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
# timer, tasks remaining, current-task elapsed, waiting/PAUSED states), a
|
|
8
8
|
# scrollable combined log and a key-help footer, and repaints from a
|
|
9
9
|
# background input thread. Non-TTY output stays the plain line-stream.
|
|
10
|
+
# Inside tmux the window is labeled with the agent name for the session and
|
|
11
|
+
# the previous label is restored on exit (TASK-90).
|
|
10
12
|
#
|
|
11
13
|
# Components:
|
|
12
14
|
# Letsdo::Tui::LogBuffer - thread-safe combined log (streamer target)
|
|
@@ -14,11 +16,13 @@
|
|
|
14
16
|
# Letsdo::Tui::Renderer - pure function: state → framed String
|
|
15
17
|
# Letsdo::Tui::Terminal - alt screen, cursor, frame rendering
|
|
16
18
|
# Letsdo::Tui::Input - tty-reader key decoding (injectable)
|
|
19
|
+
# Letsdo::Tui::WindowTitle - names the tmux window after the agent
|
|
17
20
|
# Letsdo::Tui::Session - controller: terminal lifecycle + input thread
|
|
18
21
|
|
|
19
22
|
require_relative 'tui/log_buffer'
|
|
20
23
|
require_relative 'tui/metrics'
|
|
21
24
|
require_relative 'tui/renderer'
|
|
22
25
|
require_relative 'tui/terminal'
|
|
26
|
+
require_relative 'tui/window_title'
|
|
23
27
|
require_relative 'tui/input'
|
|
24
28
|
require_relative 'tui/session'
|
data/lib/letsdo/version.rb
CHANGED
data/lib/letsdo.rb
CHANGED
|
@@ -6,33 +6,44 @@
|
|
|
6
6
|
# Letsdo::Errors - error hierarchy (Letsdo::Error and others)
|
|
7
7
|
# Letsdo::PromptStore - access to agents/*.md prompts
|
|
8
8
|
# Letsdo::DefaultPrompt - the built-in default prompt (fallback run, --init template)
|
|
9
|
+
# Letsdo::AgentIdentity - the identity block injected into every agent prompt
|
|
9
10
|
# Letsdo::OutputStreamer - where and how agent text and service lines are printed
|
|
10
|
-
# Letsdo::
|
|
11
|
+
# Letsdo::Backends::Pi - running pi --mode json and parsing the event stream
|
|
11
12
|
# Letsdo::Agent - a single agent run: prompt from agents/ or default + pi
|
|
12
13
|
# Letsdo::Capture - interruption-safe child stdout/stderr capture
|
|
13
14
|
# Letsdo::BacklogTasks - open tasks from the backlog CLI (the task provider)
|
|
14
15
|
# Letsdo::Loop - generic orchestrator: tasks → runs → waiting
|
|
16
|
+
# Letsdo::RetryPolicy - per-task retry/backoff/give-up policy (used at AgentLoop level)
|
|
15
17
|
# Letsdo::AgentLoop - letsdo wiring: provider + agent + signals + messages
|
|
16
|
-
# Letsdo::Control - agent-control primitives (PauseGate)
|
|
18
|
+
# Letsdo::Control - agent-control primitives (PauseGate, Reader)
|
|
19
|
+
# Letsdo::Doctor - `letsdo doctor` environment self-check
|
|
17
20
|
# Letsdo::Tui - the interactive TUI (LogBuffer, Metrics,
|
|
18
|
-
# Renderer, Terminal, Input, Session)
|
|
21
|
+
# Renderer, Terminal, WindowTitle, Input, Session)
|
|
19
22
|
# Letsdo::CLI - command-line arguments, usage, exit code
|
|
20
23
|
#
|
|
21
24
|
# Entry point — bin/letsdo (a thin wrapper over Letsdo::CLI).
|
|
22
25
|
|
|
23
26
|
require_relative 'letsdo/version'
|
|
24
27
|
require_relative 'letsdo/errors'
|
|
28
|
+
require_relative 'letsdo/duration'
|
|
25
29
|
require_relative 'letsdo/config'
|
|
26
30
|
require_relative 'letsdo/prompt_store'
|
|
27
31
|
require_relative 'letsdo/default_prompt'
|
|
32
|
+
require_relative 'letsdo/agent_identity'
|
|
28
33
|
require_relative 'letsdo/output_streamer'
|
|
29
|
-
require_relative 'letsdo/
|
|
34
|
+
require_relative 'letsdo/backends/backend'
|
|
35
|
+
require_relative 'letsdo/backends/pi'
|
|
30
36
|
require_relative 'letsdo/agent'
|
|
31
37
|
require_relative 'letsdo/capture'
|
|
32
|
-
require_relative 'letsdo/
|
|
38
|
+
require_relative 'letsdo/providers/backlog'
|
|
33
39
|
require_relative 'letsdo/loop'
|
|
40
|
+
require_relative 'letsdo/retry_policy'
|
|
34
41
|
require_relative 'letsdo/watcher'
|
|
35
42
|
require_relative 'letsdo/agent_loop'
|
|
43
|
+
require_relative 'letsdo/session_recorder'
|
|
44
|
+
require_relative 'letsdo/task_time_writeback'
|
|
45
|
+
require_relative 'letsdo/metrics/fanout'
|
|
36
46
|
require_relative 'letsdo/control'
|
|
47
|
+
require_relative 'letsdo/doctor'
|
|
37
48
|
require_relative 'letsdo/tui'
|
|
38
49
|
require_relative 'letsdo/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: letsdo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergei O. Udalov
|
|
@@ -95,27 +95,45 @@ files:
|
|
|
95
95
|
- LICENSE
|
|
96
96
|
- README.md
|
|
97
97
|
- bin/letsdo
|
|
98
|
+
- docs/config.md
|
|
99
|
+
- docs/prompts.md
|
|
100
|
+
- docs/task-selection.md
|
|
101
|
+
- docs/usage.md
|
|
98
102
|
- letsdo.gemspec
|
|
99
103
|
- lib/letsdo.rb
|
|
100
104
|
- lib/letsdo/agent.rb
|
|
105
|
+
- lib/letsdo/agent_identity.rb
|
|
101
106
|
- lib/letsdo/agent_loop.rb
|
|
102
107
|
- lib/letsdo/agent_loop/tasks.rb
|
|
103
|
-
- lib/letsdo/
|
|
108
|
+
- lib/letsdo/backends/backend.rb
|
|
109
|
+
- lib/letsdo/backends/pi.rb
|
|
110
|
+
- lib/letsdo/backends/pi/events.rb
|
|
104
111
|
- lib/letsdo/capture.rb
|
|
105
112
|
- lib/letsdo/cli.rb
|
|
106
113
|
- lib/letsdo/cli/builder.rb
|
|
114
|
+
- lib/letsdo/cli/builder_assembly.rb
|
|
115
|
+
- lib/letsdo/cli/builder_metrics.rb
|
|
116
|
+
- lib/letsdo/cli/doctor.rb
|
|
107
117
|
- lib/letsdo/cli/init.rb
|
|
108
118
|
- lib/letsdo/config.rb
|
|
109
119
|
- lib/letsdo/control.rb
|
|
120
|
+
- lib/letsdo/control/reader.rb
|
|
110
121
|
- lib/letsdo/default_prompt.rb
|
|
122
|
+
- lib/letsdo/doctor.rb
|
|
123
|
+
- lib/letsdo/doctor/checks.rb
|
|
124
|
+
- lib/letsdo/duration.rb
|
|
111
125
|
- lib/letsdo/errors.rb
|
|
112
126
|
- lib/letsdo/loop.rb
|
|
127
|
+
- lib/letsdo/metrics/fanout.rb
|
|
113
128
|
- lib/letsdo/output_streamer.rb
|
|
114
129
|
- lib/letsdo/output_streamer/arg_summary.rb
|
|
115
|
-
- lib/letsdo/pi_runner.rb
|
|
116
|
-
- lib/letsdo/pi_runner/events.rb
|
|
117
|
-
- lib/letsdo/pi_runner/process.rb
|
|
118
130
|
- lib/letsdo/prompt_store.rb
|
|
131
|
+
- lib/letsdo/providers/backlog.rb
|
|
132
|
+
- lib/letsdo/providers/task.rb
|
|
133
|
+
- lib/letsdo/retry_policy.rb
|
|
134
|
+
- lib/letsdo/session_recorder.rb
|
|
135
|
+
- lib/letsdo/session_recorder/jsonl_writer.rb
|
|
136
|
+
- lib/letsdo/task_time_writeback.rb
|
|
119
137
|
- lib/letsdo/tui.rb
|
|
120
138
|
- lib/letsdo/tui/input.rb
|
|
121
139
|
- lib/letsdo/tui/log_buffer.rb
|
|
@@ -123,8 +141,10 @@ files:
|
|
|
123
141
|
- lib/letsdo/tui/renderer.rb
|
|
124
142
|
- lib/letsdo/tui/session.rb
|
|
125
143
|
- lib/letsdo/tui/session/keys.rb
|
|
144
|
+
- lib/letsdo/tui/session/terminal.rb
|
|
126
145
|
- lib/letsdo/tui/session/view.rb
|
|
127
146
|
- lib/letsdo/tui/terminal.rb
|
|
147
|
+
- lib/letsdo/tui/window_title.rb
|
|
128
148
|
- lib/letsdo/version.rb
|
|
129
149
|
- lib/letsdo/watcher.rb
|
|
130
150
|
homepage: https://github.com/sergio-fry/letsdo
|
data/lib/letsdo/backlog_tasks.rb
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'json'
|
|
4
|
-
require 'shellwords'
|
|
5
|
-
|
|
6
|
-
module Letsdo
|
|
7
|
-
# Task provider for Letsdo::Loop backed by the real backlog CLI:
|
|
8
|
-
#
|
|
9
|
-
# backlog task list --assignee <handle> --exclude-status Done --json
|
|
10
|
-
#
|
|
11
|
-
# Returns the list of open tasks assigned to the handle (an Array, empty
|
|
12
|
-
# when there are none), or nil when the backlog state is unreadable — the
|
|
13
|
-
# CLI is not on PATH, failed, or its output is not the expected JSON. The
|
|
14
|
-
# loop treats nil as "pause and retry, do not run the agent".
|
|
15
|
-
#
|
|
16
|
-
# The command runs in the project root (cwd), where the backlog CLI finds
|
|
17
|
-
# the backlog/ folder — the same context as a single agent run.
|
|
18
|
-
class BacklogTasks
|
|
19
|
-
# @param handle [String] assignee handle to filter by (e.g. "@developer")
|
|
20
|
-
# @param command [String] backlog CLI command (overridable for tests)
|
|
21
|
-
# @param cwd [String, nil] project root for the CLI; nil = inherit cwd
|
|
22
|
-
# @param env [Hash, nil] environment for the CLI child (nil = inherit
|
|
23
|
-
# the process environment; injected in tests to control the
|
|
24
|
-
# fake backlog scenarios)
|
|
25
|
-
def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
|
|
26
|
-
@handle = handle
|
|
27
|
-
@command = command
|
|
28
|
-
@cwd = cwd
|
|
29
|
-
@env = env
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Reads the open tasks once.
|
|
33
|
-
#
|
|
34
|
-
# @return [Array<Hash>, nil] open tasks; nil when the backlog is unreadable
|
|
35
|
-
def call
|
|
36
|
-
args = @env ? [@env, *command_line] : command_line
|
|
37
|
-
out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
|
|
38
|
-
return nil unless status.success?
|
|
39
|
-
|
|
40
|
-
tasks = JSON.parse(out)['tasks']
|
|
41
|
-
tasks.is_a?(Array) ? tasks : nil
|
|
42
|
-
rescue Errno::ENOENT, JSON::ParserError, TypeError
|
|
43
|
-
nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
# [command..., task, list, --assignee <handle>, --exclude-status Done, --json]
|
|
49
|
-
def command_line
|
|
50
|
-
[
|
|
51
|
-
*Shellwords.split(@command),
|
|
52
|
-
'task', 'list',
|
|
53
|
-
'--assignee', @handle,
|
|
54
|
-
'--exclude-status', 'Done',
|
|
55
|
-
'--json'
|
|
56
|
-
]
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|