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.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Tui
5
+ # The frame renderer: a pure function from state to text.
6
+ #
7
+ # (metrics snapshot, log lines, terminal width/height,
8
+ # view offset / follow / paused) → a framed String
9
+ #
10
+ # Pure means: no IO, no terminal, no clock — the same inputs always
11
+ # produce the same string, so every rendering path (session controller,
12
+ # tests) feeds it identically and tests need no real TTY.
13
+ #
14
+ # Layout (bottom line is the last, no trailing newline):
15
+ #
16
+ # letsdo · developer (@developer) session 00:12:34
17
+ # done 3 · left 2 · task TASK-42 · 00:03:21
18
+ # ├────────────────────────────────────────────────────────────┤
19
+ # <body_height scrollable log lines, newest at the bottom>
20
+ # ↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit
21
+ module Renderer
22
+ HEADER_HEIGHT = 2
23
+ FOOTER_HEIGHT = 1
24
+ DIVIDER_HEIGHT = 1
25
+ ELLIPSIS = "…"
26
+
27
+ # Renders the full frame.
28
+ #
29
+ # @param metrics [Metrics::Snapshot] header metrics snapshot
30
+ # @param lines [Array<String>] log lines, oldest first
31
+ # @param width [Integer] terminal width in columns
32
+ # @param height [Integer] terminal height in rows (>= HEADER_HEIGHT +
33
+ # FOOTER_HEIGHT + DIVIDER_HEIGHT + 1)
34
+ # @param offset [Integer] index of the first visible log line
35
+ # @param follow [Boolean] whether the view sticks to the newest line
36
+ # @param paused [Boolean] display-freeze state (PAUSED in the header)
37
+ # @param wait_seconds [Numeric] retry interval shown in the waiting
38
+ # state
39
+ # @return [String] the framed screen, lines joined with "\n"
40
+ def self.render(metrics:, lines:, width:, height:, offset:, follow:, paused:, wait_seconds: 10.0)
41
+ body_height = body_height_for(height)
42
+ frame = []
43
+ frame << header_line(metrics, width)
44
+ frame << state_line(metrics, width, paused, wait_seconds)
45
+ frame << divider_line(width)
46
+ body_lines(lines, offset, body_height, width).each { |line| frame << line }
47
+ frame << footer_line(width)
48
+ frame.join("\n")
49
+ end
50
+
51
+ # The number of body lines a terminal of the given height fits.
52
+ def self.body_height_for(height)
53
+ [height - HEADER_HEIGHT - FOOTER_HEIGHT - DIVIDER_HEIGHT, 1].max
54
+ end
55
+
56
+ # The index of the last line that can be the first visible one.
57
+ def self.max_offset(lines, body_height)
58
+ [lines.length - body_height, 0].max
59
+ end
60
+
61
+ # Clamps a view offset into the valid range.
62
+ def self.clamp_offset(offset, lines, body_height)
63
+ [[offset, max_offset(lines, body_height)].min, 0].max
64
+ end
65
+
66
+ def self.header_line(metrics, width)
67
+ title = "letsdo · #{metrics.name} (#{metrics.handle})"
68
+ timer = "session #{format_duration(metrics.session_seconds)}"
69
+ fit_line_with_right(title, timer, width)
70
+ end
71
+
72
+ # The state line: done/left plus either the running task with its
73
+ # elapsed time, a waiting reason, or the PAUSED overlay.
74
+ def self.state_line(metrics, width, paused, wait_seconds)
75
+ parts = ["done #{metrics.done}", "left #{metrics.left.nil? ? "?" : metrics.left}"]
76
+ if paused
77
+ parts << "PAUSED"
78
+ elsif metrics.current_task
79
+ parts << "task #{metrics.current_task}"
80
+ parts << format_duration(metrics.current_task_seconds) if metrics.current_task_seconds
81
+ else
82
+ parts << "waiting: #{waiting_reason(metrics.left, wait_seconds)}"
83
+ end
84
+ fit_line(parts.join(" · "), width)
85
+ end
86
+
87
+ def self.body_lines(lines, offset, body_height, width)
88
+ Array.new(body_height) do |index|
89
+ line = lines[offset + index]
90
+ line.nil? ? (" " * width) : fit_line(line, width)
91
+ end
92
+ end
93
+
94
+ def self.divider_line(width)
95
+ return "" if width < 4
96
+
97
+ body = "─" * (width - 2)
98
+ "├#{body}┤"
99
+ end
100
+
101
+ def self.footer_line(width)
102
+ fit_line("↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit", width)
103
+ end
104
+
105
+ # "no open tasks, retrying in 10s" style reason for the waiting state.
106
+ def self.waiting_reason(left, wait_seconds)
107
+ formatted = wait_seconds == wait_seconds.to_i ? wait_seconds.to_i : wait_seconds
108
+ suffix = " (retry in #{formatted}s)"
109
+ if left.nil?
110
+ "backlog unavailable#{suffix}"
111
+ elsif left.zero?
112
+ "no open tasks#{suffix}"
113
+ else
114
+ "next task"
115
+ end
116
+ end
117
+
118
+ # HH:MM:SS (hours can exceed two digits for long sessions).
119
+ def self.format_duration(seconds)
120
+ total = [seconds.to_i, 0].max
121
+ hours, remainder = total.divmod(3600)
122
+ minutes, secs = remainder.divmod(60)
123
+ format("%02d:%02d:%02d", hours, minutes, secs)
124
+ end
125
+
126
+ def self.fit_line_with_right(left, right, width)
127
+ gap = [width - display_width(left) - display_width(right), 0].max
128
+ fit_line("#{left}#{" " * gap}#{right}", width)
129
+ end
130
+
131
+ # Fits a line to the width: display-width truncation with an
132
+ # ellipsis, then whitespace padding so the whole line is rewritten
133
+ # (a narrow terminal resize leaves no stale content).
134
+ def self.fit_line(line, width)
135
+ fitted = fit(line, width)
136
+ pad = width - display_width(fitted)
137
+ pad.positive? ? fitted + (" " * pad) : fitted
138
+ end
139
+
140
+ # Cuts the line on a character boundary by display width, adding an
141
+ # ellipsis when anything was cut. Lines within the width are kept
142
+ # verbatim.
143
+ def self.fit(line, width)
144
+ text_width = display_width(line)
145
+ return line if width >= text_width
146
+ return "" if width <= 1
147
+
148
+ out = +""
149
+ acc = 0
150
+ limit = width - display_width(ELLIPSIS)
151
+ line.each_char do |char|
152
+ char_width = display_width(char)
153
+ break if acc + char_width > limit
154
+
155
+ out << char
156
+ acc += char_width
157
+ end
158
+ out << ELLIPSIS
159
+ out
160
+ end
161
+
162
+ # Display width of a string (Unicode-aware: ✓/⚙/…/CJK are counted
163
+ # correctly). Requires "unicode/display_width" lazily so the plain
164
+ # (non-TTY) path needs no extra gems.
165
+ def self.display_width(text)
166
+ return 0 if text.nil? || text.empty?
167
+
168
+ require "unicode/display_width"
169
+ Unicode::DisplayWidth.of(text)
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Tui
5
+ # The TUI controller: owns the terminal lifecycle, the background input
6
+ # thread and the repaint loop around an orchestrator run.
7
+ #
8
+ # Threading model (the orchestrator loop stays on the calling thread,
9
+ # exactly as in plain mode — Letsdo::PiRunner's signal design keeps
10
+ # working unchanged):
11
+ # main thread — the injected block (Letsdo::AgentLoop#run with its
12
+ # own signal traps): provider queries, agent runs,
13
+ # streamer writes into the log, metrics events;
14
+ # input thread — the SOLE repainter: polls keys (tty-reader),
15
+ # drains the log snapshot, tracks the metrics
16
+ # snapshot and repaints on keys, data, 1s timer
17
+ # ticks and SIGWINCH. Never touches the loop.
18
+ #
19
+ # Interactive actions (footer lists them):
20
+ # ↑ / ↓ / PgUp / PgDn / Home / End — scroll; the view auto-follows
21
+ # the newest line while at the bottom (tail -f semantics);
22
+ # p — pause/resume: display freeze (PAUSED in the header, the log
23
+ # keeps buffering, the view stays); keys still repaint;
24
+ # r — immediate backlog re-query for the 'left' metric;
25
+ # q / Ctrl-C — quit: exactly the signal-stop unwinding — Letsdo::Stopped
26
+ # raised into the main thread terminates the pi child (PiRunner
27
+ # rescue), stops the loop (AgentLoop rescue) and restores the
28
+ # terminal from the ensure block; exit code 0.
29
+ #
30
+ # Signals: SIGWINCH sets a flag → the input thread re-queries the size
31
+ # and repaints (no corruption). SIGINT/SIGTERM keep working through the
32
+ # loop's own traps (main thread).
33
+ class Session
34
+ # 1s session-timer repaint interval.
35
+ REPAINT_INTERVAL = 1.0
36
+ # Poll sleep when the display is frozen or nothing happened (avoids a
37
+ # busy loop while still repainting at the timer interval).
38
+ IDLE_SLEEP = 0.01
39
+ # How long teardown waits for the input thread to finish its poll.
40
+ INPUT_JOIN_TIMEOUT = 2.0
41
+
42
+ # @param name [String] agent name (header identity)
43
+ # @param handle [String] assignee handle (header identity)
44
+ # @param log [LogBuffer] the combined log the streamer writes into
45
+ # @param metrics [Metrics] the header metrics facade
46
+ # @param terminal [Terminal] the terminal wrapper
47
+ # @param input [Input] the key reader
48
+ # @param refresh [Proc, nil] callable → open task count (Integer) or
49
+ # nil (backlog unreadable); 'r' uses it for an immediate
50
+ # re-query
51
+ # @param wait_seconds [Numeric] retry interval for the waiting state
52
+ # @param clock [Proc] monotonic clock for tick timing (injectable)
53
+ def initialize(name:, handle:, log:, metrics:, terminal:, input:,
54
+ refresh: nil, wait_seconds: 10.0, clock: nil)
55
+ @name = name
56
+ @handle = handle
57
+ @log = log
58
+ @metrics = metrics
59
+ @terminal = terminal
60
+ @input = input
61
+ @refresh = refresh
62
+ @wait_seconds = wait_seconds
63
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
64
+ @offset = 0
65
+ @follow = true
66
+ @paused = false
67
+ @stop = false
68
+ @winch = false
69
+ @body_height = 1
70
+ @seen_version = 0
71
+ @input_thread = nil
72
+ end
73
+
74
+ # Runs the TUI around the given orchestrator work.
75
+ #
76
+ # The block runs on the calling thread. On quit (q/Ctrl-C) or a stop
77
+ # signal Letsdo::Stopped unwinds it; the terminal is restored from
78
+ # the ensure block on every exit path.
79
+ #
80
+ # @yield the orchestrator run (e.g. Letsdo::AgentLoop#run)
81
+ # @return [Integer] the block's result, or 0 when stopped
82
+ def run(&work)
83
+ install_winch_handler
84
+ @terminal.enter
85
+ start_input_thread
86
+ result = work.call
87
+ result
88
+ rescue Letsdo::Stopped
89
+ 0
90
+ ensure
91
+ stop_input_thread
92
+ leave_terminal
93
+ restore_winch_handler
94
+ end
95
+
96
+ private
97
+
98
+ # The input thread body: the only place that writes to the terminal.
99
+ def input_loop
100
+ with_raw_input do
101
+ repaint
102
+ @last_repaint = @clock.call
103
+ loop do
104
+ break if @stop
105
+
106
+ key = @input.next_key
107
+ handled = key ? handle_key(key) : false
108
+ winch = take_winch
109
+ handled = true if winch
110
+ now = @clock.call
111
+ tick = now - @last_repaint >= REPAINT_INTERVAL
112
+ new_data = @log.version != @seen_version
113
+ if handled || (!@paused && (tick || new_data))
114
+ repaint
115
+ @last_repaint = @clock.call
116
+ else
117
+ sleep(IDLE_SLEEP)
118
+ end
119
+ end
120
+ end
121
+ rescue StandardError
122
+ # The input thread must never die loudly into the middle of the
123
+ # screen, and a quiet exit still leaves the loop running (teardown
124
+ # joins it). Terminal state is restored by the ensure blocks.
125
+ nil
126
+ end
127
+
128
+ def handle_key(key)
129
+ case key
130
+ when :up, :page_up
131
+ @offset -= key == :page_up ? page_step : 1
132
+ @follow = false
133
+ true
134
+ when :down, :page_down
135
+ @offset += key == :page_down ? page_step : 1
136
+ true # repaint decides re-sticking at the bottom
137
+ when :home
138
+ @offset = 0
139
+ @follow = false
140
+ true
141
+ when :end
142
+ @follow = true
143
+ true
144
+ when :p
145
+ @paused = !@paused
146
+ true
147
+ when :r
148
+ refresh
149
+ true
150
+ when :q, :ctrl_c
151
+ quit
152
+ false # leaving the session; no repaint needed
153
+ else
154
+ false
155
+ end
156
+ end
157
+
158
+ # Immediate backlog re-query: the provider is re-run on this thread
159
+ # (a subprocess call, thread-safe) and the count reaches the header
160
+ # snapshot.
161
+ def refresh
162
+ return unless @refresh
163
+
164
+ @metrics.provider_result(@refresh.call)
165
+ end
166
+
167
+ # Quit = the same unwinding as a stop signal: Letsdo::Stopped raised
168
+ # into the main thread (inside the orchestrator work) terminates the
169
+ # pi child and stops the loop; the ensure block restores the
170
+ # terminal. No-op after teardown already started.
171
+ def quit
172
+ return if @stop
173
+
174
+ Thread.main.raise(Letsdo::Stopped)
175
+ end
176
+
177
+ # Repaints the whole frame from the current state.
178
+ def repaint
179
+ lines, version = @log.lines
180
+ @seen_version = version
181
+ height, width = @terminal.size
182
+ @body_height = Renderer.body_height_for(height)
183
+ max_offset = Renderer.max_offset(lines, @body_height)
184
+ if @paused
185
+ @offset = [[@offset, max_offset].min, 0].max
186
+ elsif @follow
187
+ @offset = max_offset
188
+ else
189
+ @offset = [[@offset, max_offset].min, 0].max
190
+ @follow = true if @offset == max_offset
191
+ end
192
+ frame = Renderer.render(
193
+ metrics: @metrics.snapshot, lines: lines, width: width, height: height,
194
+ offset: @offset, follow: @follow, paused: @paused,
195
+ wait_seconds: @wait_seconds
196
+ )
197
+ @terminal.render(frame)
198
+ end
199
+
200
+ # Page height for PgUp/PgDn.
201
+ def page_step
202
+ @body_height.positive? ? @body_height : 1
203
+ end
204
+
205
+ def take_winch
206
+ flag = @winch
207
+ @winch = false
208
+ flag
209
+ end
210
+
211
+ # Raw keyboard mode for the whole input session (the tty-reader
212
+ # per-read raw wraps would leave the terminal echoing between polls);
213
+ # no-op for non-tty inputs (tests use a pipe).
214
+ def with_raw_input(&block)
215
+ if @input.stdin.tty? && @input.stdin.respond_to?(:raw)
216
+ @input.stdin.raw(&block)
217
+ else
218
+ block.call
219
+ end
220
+ end
221
+
222
+ def install_winch_handler
223
+ Signal.trap("SIGWINCH") { @winch = true }
224
+ rescue ArgumentError
225
+ nil # no SIGWINCH on this platform — no resize repaints
226
+ end
227
+
228
+ def restore_winch_handler
229
+ Signal.trap("SIGWINCH", "DEFAULT")
230
+ rescue ArgumentError
231
+ nil
232
+ end
233
+
234
+ def start_input_thread
235
+ @input_thread = Thread.new { input_loop }
236
+ @input_thread.report_on_exception = false
237
+ @input_thread
238
+ end
239
+
240
+ def stop_input_thread
241
+ @stop = true
242
+ thread = @input_thread
243
+ return unless thread
244
+
245
+ thread.join(INPUT_JOIN_TIMEOUT) || thread.kill
246
+ end
247
+
248
+ def leave_terminal
249
+ @terminal.leave
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-cursor"
4
+
5
+ module Letsdo
6
+ module Tui
7
+ # Thin wrapper over the terminal: alternate screen, cursor handling and
8
+ # frame rendering, all through an injected stream (StringIO in tests).
9
+ #
10
+ # The alternate screen is entered/exited with the raw ANSI sequences
11
+ # (tty-cursor has no alt-screen helper); cursor hide/show and the home
12
+ # position come from TTY::Cursor. The frame render is a single write:
13
+ # cursor-to-top + the framed text. Resizes are handled by re-querying
14
+ # the size provider before each render.
15
+ class Terminal
16
+ # Alternate screen buffer on (keeps the caller's terminal content).
17
+ ENTER_ALT_SCREEN = "\e[?1049h"
18
+ # Alternate screen buffer off.
19
+ LEAVE_ALT_SCREEN = "\e[?1049l"
20
+ # Cursor-to-home-column-1-row-1 (cell 1,1 per tty-cursor convention).
21
+ HOME = "\e[1;1H"
22
+
23
+ # @param stream [IO] the terminal output stream
24
+ # @param size_provider [Proc] callable → [height, width]; defaults to
25
+ # TTY::Screen.size; injected in tests for deterministic size
26
+ def initialize(stream:, size_provider: nil)
27
+ require "tty-screen" unless defined?(TTY::Screen)
28
+
29
+ @stream = stream
30
+ @size_provider = size_provider ||
31
+ -> { size = TTY::Screen.size; [size[0] || 24, size[1] || 80] }
32
+ end
33
+
34
+ # Enters the alternate screen and hides the cursor.
35
+ def enter
36
+ write(ENTER_ALT_SCREEN + TTY::Cursor.hide)
37
+ end
38
+
39
+ # Shows the cursor and leaves the alternate screen.
40
+ def leave
41
+ write(TTY::Cursor.show + LEAVE_ALT_SCREEN)
42
+ end
43
+
44
+ # Repaints the whole frame: cursor to the top left, then the frame.
45
+ #
46
+ # @param frame [String] the rendered screen (see Letsdo::Tui::Renderer)
47
+ def render(frame)
48
+ write(HOME + frame)
49
+ end
50
+
51
+ # The current terminal size.
52
+ #
53
+ # @return [Array(Integer, Integer)] height and width in rows/columns
54
+ def size
55
+ dims = @size_provider.call
56
+ [dims[0] || 24, dims[1] || 80]
57
+ end
58
+
59
+ private
60
+
61
+ def write(text)
62
+ @stream.write(text)
63
+ @stream.flush
64
+ end
65
+ end
66
+ end
67
+ end
data/lib/letsdo/tui.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The interactive TUI (TASK-42): a full-screen interface for letsdo <name>
4
+ # when stdout and stdin are terminals (TERM != "dumb"). In TUI mode the
5
+ # streamer and the loop driver feed a log buffer + metrics facade; the
6
+ # session renders a header (name + handle, done-in-session counter, session
7
+ # timer, tasks remaining, current-task elapsed, waiting/PAUSED states), a
8
+ # scrollable combined log and a key-help footer, and repaints from a
9
+ # background input thread. Non-TTY output stays the plain line-stream.
10
+ #
11
+ # Components:
12
+ # Letsdo::Tui::LogBuffer - thread-safe combined log (streamer target)
13
+ # Letsdo::Tui::Metrics - header metrics facade (loop driver events)
14
+ # Letsdo::Tui::Renderer - pure function: state → framed String
15
+ # Letsdo::Tui::Terminal - alt screen, cursor, frame rendering
16
+ # Letsdo::Tui::Input - tty-reader key decoding (injectable)
17
+ # Letsdo::Tui::Session - controller: terminal lifecycle + input thread
18
+
19
+ require_relative "tui/log_buffer"
20
+ require_relative "tui/metrics"
21
+ require_relative "tui/renderer"
22
+ require_relative "tui/terminal"
23
+ require_relative "tui/input"
24
+ require_relative "tui/session"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ VERSION = "0.1.0"
5
+ end
data/lib/letsdo.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Letsdo package — a local agent worker for Backlog.md/markdown tasks.
4
+ #
5
+ # OOP structure:
6
+ # Letsdo::Errors - error hierarchy (UnknownAgentError and others)
7
+ # Letsdo::PromptStore - access to agents/*.md prompts
8
+ # Letsdo::OutputStreamer - where and how agent text and service lines are printed
9
+ # Letsdo::PiRunner - running pi --mode json and parsing the event stream
10
+ # Letsdo::Agent - a single agent run: prompt from agents/ + pi
11
+ # Letsdo::BacklogTasks - open tasks from the backlog CLI (the task provider)
12
+ # Letsdo::Loop - generic orchestrator: tasks → runs → waiting
13
+ # Letsdo::AgentLoop - letsdo wiring: provider + agent + signals + messages
14
+ # Letsdo::Tui - the interactive TUI (LogBuffer, Metrics,
15
+ # Renderer, Terminal, Input, Session)
16
+ # Letsdo::CLI - command-line arguments, usage, exit code
17
+ #
18
+ # Entry point — bin/letsdo (a thin wrapper over Letsdo::CLI).
19
+
20
+ require_relative "letsdo/version"
21
+ require_relative "letsdo/errors"
22
+ require_relative "letsdo/prompt_store"
23
+ require_relative "letsdo/output_streamer"
24
+ require_relative "letsdo/pi_runner"
25
+ require_relative "letsdo/agent"
26
+ require_relative "letsdo/backlog_tasks"
27
+ require_relative "letsdo/loop"
28
+ require_relative "letsdo/agent_loop"
29
+ require_relative "letsdo/tui"
30
+ require_relative "letsdo/cli"
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: letsdo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sergei O. Udalov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: tty-screen
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tty-cursor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.7'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tty-reader
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
54
+ - !ruby/object:Gem::Dependency
55
+ name: unicode-display_width
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ description: 'letsdo — a local agent worker for Backlog.md/markdown tasks. OOP structure:
69
+ Letsdo::PromptStore (agents/), Letsdo::OutputStreamer and Letsdo::PiRunner (pi --mode
70
+ json), Letsdo::Agent (one run), Letsdo::Loop (orchestrator loop), Letsdo::CLI. Minitest
71
+ tests. Will later be split into a separate repository.'
72
+ email:
73
+ - udalov.x@mail.ru
74
+ executables:
75
+ - letsdo
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - LICENSE
80
+ - README.md
81
+ - bin/letsdo
82
+ - lib/letsdo.rb
83
+ - lib/letsdo/agent.rb
84
+ - lib/letsdo/agent_loop.rb
85
+ - lib/letsdo/backlog_tasks.rb
86
+ - lib/letsdo/cli.rb
87
+ - lib/letsdo/errors.rb
88
+ - lib/letsdo/loop.rb
89
+ - lib/letsdo/output_streamer.rb
90
+ - lib/letsdo/pi_runner.rb
91
+ - lib/letsdo/prompt_store.rb
92
+ - lib/letsdo/tui.rb
93
+ - lib/letsdo/tui/input.rb
94
+ - lib/letsdo/tui/log_buffer.rb
95
+ - lib/letsdo/tui/metrics.rb
96
+ - lib/letsdo/tui/renderer.rb
97
+ - lib/letsdo/tui/session.rb
98
+ - lib/letsdo/tui/terminal.rb
99
+ - lib/letsdo/version.rb
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ rubygems_mfa_required: 'true'
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '3.0'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 4.0.6
119
+ specification_version: 4
120
+ summary: A local agent worker for Backlog.md/markdown tasks
121
+ test_files: []