letsdo 0.4.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -2
  3. data/README.md +141 -12
  4. data/bin/letsdo +5 -0
  5. data/docs/config.md +171 -0
  6. data/docs/prompts.md +209 -0
  7. data/docs/task-selection.md +239 -0
  8. data/docs/usage.md +295 -0
  9. data/letsdo.gemspec +3 -1
  10. data/lib/letsdo/agent.rb +29 -17
  11. data/lib/letsdo/agent_identity.rb +42 -0
  12. data/lib/letsdo/agent_loop/tasks.rb +91 -13
  13. data/lib/letsdo/agent_loop.rb +36 -3
  14. data/lib/letsdo/backends/backend.rb +137 -0
  15. data/lib/letsdo/backends/pi/events.rb +77 -0
  16. data/lib/letsdo/backends/pi.rb +88 -0
  17. data/lib/letsdo/cli/builder.rb +47 -95
  18. data/lib/letsdo/cli/builder_assembly.rb +151 -0
  19. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  20. data/lib/letsdo/cli/doctor.rb +16 -0
  21. data/lib/letsdo/cli.rb +15 -1
  22. data/lib/letsdo/config.rb +66 -4
  23. data/lib/letsdo/control/reader.rb +131 -0
  24. data/lib/letsdo/control.rb +6 -3
  25. data/lib/letsdo/doctor/checks.rb +98 -0
  26. data/lib/letsdo/doctor.rb +42 -0
  27. data/lib/letsdo/duration.rb +23 -0
  28. data/lib/letsdo/errors.rb +7 -0
  29. data/lib/letsdo/metrics/fanout.rb +47 -0
  30. data/lib/letsdo/prompt_store.rb +48 -1
  31. data/lib/letsdo/providers/backlog.rb +124 -0
  32. data/lib/letsdo/providers/task.rb +48 -0
  33. data/lib/letsdo/retry_policy.rb +98 -0
  34. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  35. data/lib/letsdo/session_recorder.rb +188 -0
  36. data/lib/letsdo/task_time_writeback.rb +106 -0
  37. data/lib/letsdo/tui/metrics.rb +7 -2
  38. data/lib/letsdo/tui/session/terminal.rb +47 -0
  39. data/lib/letsdo/tui/session/view.rb +10 -2
  40. data/lib/letsdo/tui/session.rb +27 -22
  41. data/lib/letsdo/tui/window_title.rb +133 -0
  42. data/lib/letsdo/tui.rb +4 -0
  43. data/lib/letsdo/version.rb +1 -1
  44. data/lib/letsdo.rb +16 -5
  45. metadata +25 -5
  46. data/lib/letsdo/backlog_tasks.rb +0 -59
  47. data/lib/letsdo/pi_runner/events.rb +0 -75
  48. data/lib/letsdo/pi_runner/process.rb +0 -68
  49. 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
@@ -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
- def run_finished
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 StandardError
14
- nil
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
@@ -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
- install_winch_handler
27
- @terminal.enter
28
- run_work(&work)
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
- stop_input_thread
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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Letsdo
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
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::PiRunner - running pi --mode json and parsing the event stream
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/pi_runner'
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/backlog_tasks'
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.0
4
+ version: 0.5.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/backlog_tasks.rb
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
@@ -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