letsdo 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9576e7f61d65dbf1379b3e4fcce4831f3162432b9039dc2153f7952201420091
4
- data.tar.gz: 679c98fd2628270bb62cf2f35c6a9d13653dd75a3ebe48aa83abc77c3aeccd97
3
+ metadata.gz: f6c3ecf1ef07372a4f5b38843faafaf394ca49ee9ea73c48a59034c0c33ed1b9
4
+ data.tar.gz: 9349a23494802614dcdb7aa27d378b7ca2dae1512a395989678e166b3b68548d
5
5
  SHA512:
6
- metadata.gz: 8a6744c21cd6681ac8fde456977962f8a1c8a79cfd8e8157091b0d3ba42848d29f982dd622880e7b7c03f82ec065d014d56da1b61a2f9cd954f3693fc2218f21
7
- data.tar.gz: 2925952a1d86f40fc54d1e20e57c83cec89e0f07579c2a0336e2cc370b94c462516c9686c981aedc4b456120ec42fcd51859386685be342c8999ca5bcc3d53d7
6
+ metadata.gz: 74a49f0c9d8a27c25acc94205f57a9ba9f5e63ac18a8d97e15c270b02e661e45cd30da84fe998f382d6baaa5945f59148b4edf4e62db8b292150dcb56e4e0ad9
7
+ data.tar.gz: ca34be59f778f078ab0766088d70acb4fe1935d3021315f98a8d1c402c6897cc2267ff5a525c0e5db6c28d3f71238fb782f8972c3db844282353a263732b604d
data/CHANGELOG.md ADDED
@@ -0,0 +1,142 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-09-08
11
+
12
+ ### Changed
13
+
14
+ - `Letsdo::CLI::Builder` extracted from `CLI` — all component assembly
15
+ (agent, provider, loop, TUI setup) now lives in Builder; `CLI` keeps only
16
+ argv parsing and delegates to Builder. The former `CLILaunch` module is
17
+ deleted. New `test/cli_builder_test.rb` covers Builder assembly and TUI
18
+ detection. Zero behavioral change.
19
+ - The gemspec now has a user-facing description (what letsdo does and why)
20
+ instead of an internal class inventory; `required_ruby_version` is
21
+ narrowed from `>= 3.0` to `>= 3.3` to match what CI actually tests
22
+ (Ruby 3.0–3.2 are end-of-life); `bug_tracker_uri` and `documentation_uri`
23
+ metadata are added; and `spec.files` ships `CHANGELOG.md` and the gemspec
24
+ itself alongside the code, README and LICENSE.
25
+
26
+ ### Fixed
27
+
28
+ - The documented Ruby 4.0.x install workaround now leads with fixing the
29
+ environment (`gem install rbs -v '>= 4.0.0'`), with `--no-document` as a
30
+ fallback — the rbs upgrade actually makes the plain `gem install letsdo`
31
+ post-install RDoc hook succeed instead of just skipping it. The CI
32
+ "Verify install" step now installs the built gem without `--no-document`,
33
+ repairs a broken rdoc/rbs pair when `require "rdoc"` fails, runs on Ruby
34
+ 4.0 as well as 3.3, and checks `letsdo --version` — so the exact plain
35
+ install path a user runs (including the RDoc hook) is guarded against the
36
+ rdoc/rbs conflict crashing it again.
37
+
38
+ ## [0.3.0] - 2026-09-07
39
+
40
+ ### Added
41
+
42
+ - `Letsdo::Watcher` — OS file-change watching of the backlog folder via
43
+ inotify (Linux, through Fiddle with no external gem) with a self-pipe
44
+ polling fallback; the orchestrator loop now wakes on a backlog change
45
+ instead of waiting out the full `LETSDO_WAIT_SECONDS` interval. The wake
46
+ replaces the idle-phase wait only — the startup check and the re-check
47
+ after each finished task stay immediate provider queries.
48
+
49
+ ### Fixed
50
+
51
+ - An explicitly injected control/stop sleeper is honored over the watcher
52
+ idle path, so a CLI test that injects a sleeper completes normally even
53
+ with the loop watcher enabled — `rake test` no longer hangs in an infinite
54
+ idle wait.
55
+ - `Letsdo::Capture` runs its child in its own process group and terminates
56
+ the whole group when the capture is interrupted, so a stopped capture can
57
+ no longer leave an orphaned grandchild holding the stdout/stderr pipes.
58
+ - Quitting the TUI no longer leaves the terminal in raw mode: raw-mode entry
59
+ moved to the main thread, so the saved termios (echo + canonical line
60
+ editing) is restored on every quit path — the shell in a tmux pane stays
61
+ usable.
62
+ - A tool-only agent run after a text run no longer writes a spurious blank
63
+ line on stdout (`OutputStreamer#finish` resets its last-char state).
64
+ - `require "letsdo"` no longer eagerly loads `tty-cursor` (or any tty-* gem),
65
+ so `rake test` runs on a clean Ruby without the gem installed; the TUI
66
+ still loads its gems lazily on the interactive path.
67
+ - Documented a Ruby 4.0.x install note: the post-install RDoc hook can crash
68
+ on a mismatched rdoc/rbs pair — install with `--no-document`.
69
+
70
+ ## [0.2.0] - 2026-09-04
71
+
72
+ ### Added
73
+
74
+ - `letsdo <name> --init` (and the flag-first form `letsdo --init <name>`)
75
+ creates `agents/<name>.md` with the starter default prompt
76
+ (`Letsdo::DefaultPrompt::TEXT`) so the prompt can be customized — the
77
+ agent is never started. An already existing file or an unsafe name
78
+ (contains `/` or `\`, or is `.`/`..`) is refused with a message on
79
+ stderr and exit 1; nothing is ever written outside `agents/`
80
+ (`Letsdo::PromptStore#create_agent`).
81
+ - Built-in default prompt (`Letsdo::DefaultPrompt::TEXT`): `letsdo <name>`
82
+ starts the agent even without `agents/<name>.md` — the run falls back to
83
+ the built-in process-only prompt, and letsdo announces once on stderr
84
+ the exact path checked (`<root>/agents/<name>.md`) plus the
85
+ `letsdo <name> --init` placement hint. `UnknownAgentError` is removed:
86
+ with the fallback there are no unknown agents, the base `Letsdo::Error`
87
+ remains the package error surface.
88
+ - Real pause semantics for the TUI 'p' key: mid-run the pi group is
89
+ suspended at the kernel level (SIGSTOP via `Letsdo::PiRunner#pause`,
90
+ resume via SIGCONT); between runs a shared `Letsdo::Control::PauseGate`
91
+ holds new runs until resume. The footer flips between `p pause` and
92
+ `p resume`.
93
+ - `SIGHUP` (terminal closed) stops the loop the same way as `SIGINT`/
94
+ `SIGTERM`.
95
+ - Prompt stop of a paused pi: `PiRunner#terminate` now reaps the child
96
+ during its grace wait (WNOHANG) instead of polling the process table,
97
+ so a signal-killed child's zombie state can no longer stall the stop
98
+ for the full grace period.
99
+ - Gemspec metadata: `homepage`, `homepage_uri`, `source_code_uri`,
100
+ `changelog_uri`, `allowed_push_host`.
101
+ - `CHANGELOG.md`.
102
+ - Default RuboCop 1.77 as a development dependency; CI fails the build
103
+ on style violations.
104
+
105
+ ### Changed
106
+
107
+ - Tool result bodies (indented stdout/stderr blocks, truncation notes,
108
+ `✖ Error:` markers) are no longer printed to the aux output; the stream
109
+ shows only tool invocations (`HH:MM:SS ⚙ name: args`) and a one-line
110
+ completion with duration (`✓/✖ name: done/error (Ns)`).
111
+ - `Session#quit` sets the stop flag before raising `Letsdo::Stopped`, so
112
+ the input thread renders no frames during teardown.
113
+
114
+ ### Fixed
115
+
116
+ - `rake test` no longer prints `Open3.capture3` reader-thread dumps
117
+ (`IOError: stream closed in another thread`): `Letsdo::BacklogTasks` now
118
+ captures the backlog CLI output through `Letsdo::Capture`, whose reader
119
+ threads tolerate the pipes being closed when a stop (TUI quit, signal)
120
+ interrupts an in-flight backlog call and whose cleanup reaps the child
121
+ even then.
122
+ - CLI tests no longer collide on a shared `/tmp` “tasks already served”
123
+ marker after Minitest reseeds `Kernel.srand` per test class.
124
+
125
+ ## [0.1.0] - 2026-09-04
126
+
127
+ ### Added
128
+
129
+ - Initial release of letsdo — a local agent worker for Backlog.md/markdown tasks.
130
+ - OOP structure: `Letsdo::PromptStore` (agents/), `Letsdo::OutputStreamer` and
131
+ `Letsdo::PiRunner` (pi --mode json), `Letsdo::Agent` (one run), `Letsdo::Loop`
132
+ (orchestrator loop), `Letsdo::CLI`.
133
+ - Interactive TUI (header metrics, scrollable stream) in TTY mode; plain
134
+ line-stream mode for pipes/CI/tests.
135
+ - Minitest tests; CI (GitHub Actions) builds the gem and runs tests on every push.
136
+ - Local executable `letsdo`.
137
+
138
+ [Unreleased]: https://github.com/sergio-fry/letsdo/compare/v0.4.0...HEAD
139
+ [0.4.0]: https://github.com/sergio-fry/letsdo/compare/v0.3.0...v0.4.0
140
+ [0.3.0]: https://github.com/sergio-fry/letsdo/compare/v0.2.0...v0.3.0
141
+ [0.2.0]: https://github.com/sergio-fry/letsdo/compare/v0.1.0...v0.2.0
142
+ [0.1.0]: https://github.com/sergio-fry/letsdo/releases/tag/v0.1.0
data/README.md CHANGED
@@ -80,7 +80,7 @@ generation, any repeatable task flow you can express as assignee + prompt.
80
80
 
81
81
  ## Requirements
82
82
 
83
- - Ruby **>= 3.0**.
83
+ - Ruby **>= 3.3**.
84
84
  - The [pi](https://github.com/earendil-works/pi) agent CLI on
85
85
  `PATH` — this is the AI backend that runs the agent (`pi --mode json`).
86
86
  The command is configurable via `LETSDO_PI_COMMAND`.
@@ -99,9 +99,17 @@ The gem is built from the repository:
99
99
  git clone git@github.com:sergio-fry/letsdo.git
100
100
  cd letsdo
101
101
  gem build letsdo.gemspec
102
- gem install letsdo-0.2.0.gem
102
+ gem install letsdo-0.3.0.gem
103
103
  ```
104
104
 
105
+ > **Ruby 4.0.x note.** Some Ruby 4.0.x builds ship default gems out of sync —
106
+ > rdoc 8.0.0 declares `rbs >= 4.0.0` while rbs 3.x is bundled — so the
107
+ > post-install RDoc hook can raise `Gem::ConflictError` even though the gem
108
+ > files are already installed. Fix the environment by installing a matching
109
+ > rbs first (`gem install rbs -v '>= 4.0.0'`); if that is not possible,
110
+ > install the gem without documentation to skip the hook
111
+ > (`gem install letsdo-0.3.0.gem --no-document`).
112
+
105
113
  or run it straight from the checkout without installing:
106
114
 
107
115
  ```sh
@@ -250,7 +258,7 @@ gem build letsdo.gemspec
250
258
 
251
259
  Cleanliness is enforced by the CI workflow
252
260
  (`.github/workflows/ci.yml`): gem build + `rake test` on every push,
253
- Ruby 3.3 (satisfies `required_ruby_version: ">= 3.0"`).
261
+ Ruby 3.3 and 4.0 (satisfies `required_ruby_version: ">= 3.3"`).
254
262
 
255
263
  ## Alternatives
256
264
 
data/letsdo.gemspec ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/letsdo/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "letsdo"
7
+ spec.version = Letsdo::VERSION
8
+ spec.authors = ["Sergei O. Udalov"]
9
+ spec.email = ["udalov.x@mail.ru"]
10
+
11
+ spec.summary = "A local agent worker for Backlog.md/markdown tasks"
12
+ spec.description = "letsdo turns a plain markdown backlog into a team of " \
13
+ "autonomous local agents. Each agent is just a prompt " \
14
+ "file in agents/; run `letsdo <name>` and it works " \
15
+ "through every open task assigned to that agent, one " \
16
+ "task per run, and stops cleanly on Ctrl+C. No " \
17
+ "framework, no hosted platform — the backlog folder is " \
18
+ "the single source of truth and task data never leaves " \
19
+ "your machine."
20
+ spec.license = "MIT"
21
+
22
+ # Supported Ruby floor matches what CI actually tests (3.3 and 4.0).
23
+ # Ruby 3.0/3.1/3.2 are end-of-life, so claiming support for them while
24
+ # never testing them would be dishonest packaging.
25
+ spec.required_ruby_version = ">= 3.3"
26
+
27
+ # Runtime dependencies: the interactive TUI. Required lazily inside the
28
+ # tui classes so the plain line-stream mode (pipes/CI/tests) works with
29
+ # zero extra gems.
30
+ spec.add_runtime_dependency "tty-screen", "~> 0.8"
31
+ spec.add_runtime_dependency "tty-cursor", "~> 0.7"
32
+ spec.add_runtime_dependency "tty-reader", "~> 0.9"
33
+ spec.add_runtime_dependency "unicode-display_width", ">= 2.0"
34
+
35
+ # Development dependency: style checks (default RuboCop config, enforced
36
+ # in CI). Not installed by the CI workflow via bundler — the workflow
37
+ # installs the same constraint with `gem install` on a clean Ruby.
38
+ spec.add_development_dependency "rubocop", "~> 1.77.0"
39
+
40
+ # The AI backend is the external `pi` CLI (default, overridable via
41
+ # LETSDO_PI_COMMAND) — a runtime *requirement*, not a rubygem, so it is
42
+ # documented in the README rather than declared as a dependency.
43
+ spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE",
44
+ "CHANGELOG.md", "letsdo.gemspec"]
45
+ spec.bindir = "bin"
46
+ spec.executables = ["letsdo"]
47
+ spec.require_paths = ["lib"]
48
+
49
+ spec.homepage = "https://github.com/sergio-fry/letsdo"
50
+
51
+ # source_code_uri shares the homepage URL on purpose (the source *is* the
52
+ # project home); homepage_uri is deliberately not duplicated in metadata
53
+ # because an identical homepage_uri + source_code_uri pair makes
54
+ # `gem build` warn.
55
+ spec.metadata["source_code_uri"] = "https://github.com/sergio-fry/letsdo"
56
+ spec.metadata["bug_tracker_uri"] = "https://github.com/sergio-fry/letsdo/issues"
57
+ spec.metadata["changelog_uri"] = "https://github.com/sergio-fry/letsdo/blob/main/CHANGELOG.md"
58
+ spec.metadata["documentation_uri"] = "https://github.com/sergio-fry/letsdo/blob/main/README.md"
59
+ spec.metadata["rubygems_mfa_required"] = "true"
60
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
61
+ end
@@ -26,9 +26,14 @@ module Letsdo
26
26
  @stderr.puts('letsdo: stopped')
27
27
  0
28
28
  ensure
29
+ close_watcher
29
30
  restore_signal_handlers
30
31
  end
31
32
 
33
+ def close_watcher
34
+ @watcher&.close
35
+ end
36
+
32
37
  def debug(message)
33
38
  warn("[letsdo] loop: #{message}") if @debug
34
39
  end
@@ -51,8 +56,22 @@ module Letsdo
51
56
  def assign_control_opts(opts)
52
57
  @metrics = opts[:metrics]
53
58
  @pause_gate = opts[:pause_gate]
54
- @debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
55
- @sleeper = opts[:sleeper] || ->(seconds) { sleep(seconds) }
59
+ @debug = resolve_debug(opts)
60
+ @watcher = opts[:watcher] || (Watcher.new(path: opts[:watch_path]) if opts[:watch_path])
61
+ # Precedence is deliberate: an explicitly injected sleeper always wins
62
+ # over the watcher idle path — tests inject a stop/control sleeper that
63
+ # must never be bypassed by a configured watcher (TASK-84).
64
+ @sleeper = opts[:sleeper] || watcher_sleeper || ->(seconds) { sleep(seconds) }
65
+ end
66
+
67
+ def resolve_debug(opts)
68
+ return opts[:debug] unless opts[:debug].nil?
69
+
70
+ (opts[:config] || Config.new).debug?
71
+ end
72
+
73
+ def watcher_sleeper
74
+ @watcher && ->(seconds) { @watcher.wait(seconds) }
56
75
  end
57
76
 
58
77
  def run_until_stopped
@@ -33,6 +33,7 @@ module Letsdo
33
33
  @err_w.close
34
34
  readers = [quiet_reader(@out_r), quiet_reader(@err_r)]
35
35
  status = wait_and_reap(@pid)
36
+ @reaped = true
36
37
  [readers[0].value, readers[1].value, status]
37
38
  ensure
38
39
  cleanup
@@ -41,7 +42,7 @@ module Letsdo
41
42
  private
42
43
 
43
44
  def spawn_child
44
- opts = { out: @out_w, err: @err_w }
45
+ opts = { out: @out_w, err: @err_w, pgroup: true }
45
46
  opts[:chdir] = @chdir if @chdir
46
47
  Process.spawn(*@args, **opts)
47
48
  end
@@ -62,12 +63,26 @@ module Letsdo
62
63
  status
63
64
  end
64
65
 
65
- # Reaps pid if the normal wait never ran (the wait was interrupted by a
66
- # stop); a no-op when the child was already reaped.
67
- def reattach_reaper(pid)
66
+ # Kills the child's process group and reaps it when the normal wait was
67
+ # interrupted (a stop raised into the main thread). Killing the whole
68
+ # group also terminates any grandchildren that inherited the child's
69
+ # stdout/stderr pipes, so none of them is left holding the pipes open
70
+ # (TASK-84). A no-op when the child was already reaped normally.
71
+ def terminate_and_reap(pid)
68
72
  return unless pid
69
73
 
70
- Process.detach(pid)
74
+ kill_group(pid)
75
+ reap(pid)
76
+ end
77
+
78
+ def kill_group(pid)
79
+ Process.kill('TERM', -pid)
80
+ rescue Errno::ESRCH, Errno::EPERM
81
+ nil
82
+ end
83
+
84
+ def reap(pid)
85
+ Process.wait2(pid)
71
86
  rescue Errno::ECHILD
72
87
  nil
73
88
  end
@@ -77,7 +92,7 @@ module Letsdo
77
92
  close_quietly(@err_w)
78
93
  close_quietly(@out_r)
79
94
  close_quietly(@err_r)
80
- reattach_reaper(@pid)
95
+ terminate_and_reap(@pid) unless @reaped
81
96
  end
82
97
 
83
98
  def close_quietly(io)
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class CLI
5
+ # TUI component assembly used by Letsdo::CLI::Builder. Kept in its own
6
+ # module so Builder stays within the class-length limit; mirrors the
7
+ # CLILaunch/CLIInit split pattern.
8
+ module BuilderTui
9
+ def run_tui(name)
10
+ ctx = tui_context(name)
11
+ session = Tui::Session.new(**ctx[:session])
12
+ session.run { ctx[:loop].run }
13
+ end
14
+
15
+ def tui_context(name)
16
+ parts = tui_parts(name)
17
+ {
18
+ loop: tui_loop(name, parts),
19
+ session: tui_session_args(name, **parts)
20
+ }
21
+ end
22
+
23
+ def tui_parts(name)
24
+ clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
25
+ handle = assignee_handle(name)
26
+ log = Tui::LogBuffer.new
27
+ {
28
+ clock: clock, handle: handle, log: log,
29
+ metrics: tui_metrics(name, handle, clock, log),
30
+ agent: agent_for(name, OutputStreamer.new(log: log)),
31
+ provider: provider_for(handle),
32
+ pause_gate: Control::PauseGate.new
33
+ }
34
+ end
35
+
36
+ def tui_loop(name, parts)
37
+ agent_loop(name, nil, agent: parts[:agent], provider: parts[:provider],
38
+ handle: parts[:handle], stderr: parts[:log],
39
+ metrics: parts[:metrics], pause_gate: parts[:pause_gate])
40
+ end
41
+
42
+ def tui_metrics(name, handle, clock, log)
43
+ Tui::Metrics.new(name: name, handle: handle, clock: clock,
44
+ on_run_start: ->(_label) { log.divider })
45
+ end
46
+
47
+ def tui_session_args(name, **parts)
48
+ {
49
+ name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:metrics],
50
+ terminal: Tui::Terminal.new(stream: @stdout),
51
+ input: Tui::Input.new(stdin: @stdin),
52
+ refresh: -> { parts[:provider].call },
53
+ wait_seconds: wait_seconds, clock: parts[:clock],
54
+ pause_gate: parts[:pause_gate], runner: -> { parts[:agent].runner }
55
+ }
56
+ end
57
+ end
58
+
59
+ # Owns all component assembly for running an agent (TASK-54): builds the
60
+ # streamer, agent, backlog provider, orchestrator loop and the whole TUI,
61
+ # and decides plain vs TUI mode via stdout/stdin TTY + TERM. Parsing stays
62
+ # in Letsdo::CLI (TASK-54); this class is the wiring half — independently
63
+ # testable and free to grow with provider/backend selection (TASK-51/52)
64
+ # without bloating the parser.
65
+ class Builder
66
+ include BuilderTui
67
+
68
+ def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
69
+ @env = env
70
+ @stdout = stdout
71
+ @stderr = stderr
72
+ @stdin = stdin
73
+ @sleeper = sleeper
74
+ @config = Config.new(env: env)
75
+ @root = @config.root
76
+ end
77
+
78
+ # Runs <name> in plain or TUI mode; returns the process exit code.
79
+ def run(name)
80
+ store = PromptStore.new(root: @root)
81
+ announce_default_prompt(name, store) if store.read(name).nil?
82
+ tui? ? run_tui(name) : run_plain(name)
83
+ end
84
+
85
+ private
86
+
87
+ def run_plain(name)
88
+ streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
89
+ agent_loop(name, streamer, stderr: @stderr).run
90
+ end
91
+
92
+ def agent_for(name, streamer)
93
+ Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
94
+ command: pi_command)
95
+ end
96
+
97
+ def provider_for(handle)
98
+ BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
99
+ env: ENV.to_h.merge(@env))
100
+ end
101
+
102
+ def agent_loop(name, streamer, **opts)
103
+ handle = opts[:handle] || assignee_handle(name)
104
+ agent = opts[:agent] || agent_for(name, streamer)
105
+ provider = opts[:provider] || provider_for(handle)
106
+ AgentLoop.new(name: name, handle: handle, agent: agent,
107
+ task_provider: -> { provider.call },
108
+ wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
109
+ metrics: opts[:metrics], pause_gate: opts[:pause_gate],
110
+ watcher: backlog_watcher)
111
+ end
112
+
113
+ def backlog_watcher
114
+ Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
115
+ end
116
+
117
+ # One-time fallback notification (before the first loop message): names
118
+ # the exact path checked and the placement hint. The agent still starts
119
+ # — Letsdo::Agent falls back to the built-in default prompt itself.
120
+ def announce_default_prompt(name, store)
121
+ @stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
122
+ @stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
123
+ end
124
+
125
+ def tui?
126
+ @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
127
+ end
128
+
129
+ def assignee_handle(name)
130
+ @config.assignee_handle(name)
131
+ end
132
+
133
+ def backlog_command
134
+ @config.backlog_command
135
+ end
136
+
137
+ def wait_seconds
138
+ @config.wait_seconds
139
+ end
140
+
141
+ def pi_command
142
+ @config.pi_command
143
+ end
144
+
145
+ def parse_pi_flags
146
+ @config.pi_flags
147
+ end
148
+ end
149
+ end
150
+ end
data/lib/letsdo/cli.rb CHANGED
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'shellwords'
4
- require_relative 'cli/launch'
5
3
  require_relative 'cli/init'
4
+ require_relative 'cli/builder'
6
5
 
7
6
  module Letsdo
8
7
  # Command-line argument parsing and running an agent orchestrator loop.
@@ -21,12 +20,10 @@ module Letsdo
21
20
  # letsdo --init <name> — same, flag-first form;
22
21
  # letsdo <unknown option> — "letsdo: unknown option: X" + usage, exit 1.
23
22
  class CLI
24
- include CLILaunch
25
23
  include CLIInit
26
24
 
27
25
  USAGE = 'Usage: letsdo <agent_name>'
28
26
  AGENTS_HEADER = 'Available agents:'
29
- DEFAULT_WAIT_SECONDS = 10.0
30
27
 
31
28
  def self.run(argv, **opts)
32
29
  new(env: opts.fetch(:env, ENV), stdout: opts.fetch(:stdout, $stdout),
@@ -35,12 +32,11 @@ module Letsdo
35
32
  end
36
33
 
37
34
  def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
38
- @env = env
39
35
  @stdout = stdout
40
36
  @stderr = stderr
41
- @stdin = stdin
42
- @sleeper = sleeper
43
- @root = env.fetch('LETSDO_ROOT', Dir.pwd)
37
+ @root = Config.new(env: env).root
38
+ @builder = Builder.new(env: env, stdout: stdout, stderr: stderr,
39
+ stdin: stdin, sleeper: sleeper)
44
40
  end
45
41
 
46
42
  def run(argv)
@@ -51,7 +47,7 @@ module Letsdo
51
47
  return init_command(argv) if argv.include?('--init')
52
48
  return unknown_option(arg) if arg.start_with?('-')
53
49
 
54
- run_agent(arg)
50
+ @builder.run(arg)
55
51
  end
56
52
 
57
53
  private
@@ -85,47 +81,6 @@ module Letsdo
85
81
  1
86
82
  end
87
83
 
88
- def run_agent(name)
89
- store = PromptStore.new(root: @root)
90
- announce_default_prompt(name, store) if store.read(name).nil?
91
- tui? ? run_agent_tui(name) : run_agent_plain(name)
92
- end
93
-
94
- # One-time fallback notification (before the first loop message): names
95
- # the exact path checked and the placement hint. The agent still starts
96
- # — Letsdo::Agent falls back to the built-in default prompt itself.
97
- def announce_default_prompt(name, store)
98
- @stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
99
- @stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
100
- end
101
-
102
- def tui?
103
- @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
104
- end
105
-
106
- def assignee_handle(name)
107
- env_handle = @env['AGENT_ASSIGNEE_HANDLE']
108
- env_handle && !env_handle.strip.empty? ? env_handle : "@#{name}"
109
- end
110
-
111
- def backlog_command
112
- @env.fetch('LETSDO_BACKLOG_COMMAND', 'backlog')
113
- end
114
-
115
- def wait_seconds
116
- value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
117
- value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
118
- return DEFAULT_WAIT_SECONDS if value.empty?
119
-
120
- Float(value)
121
- rescue ArgumentError, TypeError
122
- DEFAULT_WAIT_SECONDS
123
- end
124
-
125
- def pi_command
126
- @env.fetch('LETSDO_PI_COMMAND', PiRunner::COMMAND)
127
- end
128
-
129
84
  def print_usage(stream)
130
85
  stream.puts(USAGE)
131
86
  print_agents
@@ -135,13 +90,5 @@ module Letsdo
135
90
  @stdout.puts(AGENTS_HEADER)
136
91
  PromptStore.new(root: @root).list.each { |name| @stdout.puts(" #{name}") }
137
92
  end
138
-
139
- def parse_pi_flags
140
- value = @env['LETSDO_PI_FLAGS'].to_s
141
- value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
142
- return [] if value.strip.empty?
143
-
144
- Shellwords.split(value)
145
- end
146
93
  end
147
94
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Letsdo
6
+ # The single place where every LETSDO_*/AGENT_* environment variable is
7
+ # read, with exactly the defaults and precedence the CLI, PiRunner and
8
+ # AgentLoop used to apply inline. Giving env policy one home makes it
9
+ # testable in one place and gives future knobs (provider/backend
10
+ # selection) a single spot to add a variable.
11
+ #
12
+ # The env hash is injectable for tests and defaults to the process ENV.
13
+ class Config
14
+ DEFAULT_WAIT_SECONDS = 10.0
15
+ DEFAULT_PI_COMMAND = 'pi'
16
+ DEFAULT_BACKLOG_COMMAND = 'backlog'
17
+
18
+ def initialize(env: ENV)
19
+ @env = env
20
+ end
21
+
22
+ # Project root with agents/ (and where the backlog CLI finds backlog/).
23
+ def root
24
+ @env.fetch('LETSDO_ROOT', Dir.pwd)
25
+ end
26
+
27
+ # Extra pi flags, split on whitespace. LETSDO_PI_FLAGS wins; when empty,
28
+ # AGENT_PI_FLAGS is the fallback (bin/agent-loop compatibility).
29
+ def pi_flags
30
+ value = @env['LETSDO_PI_FLAGS'].to_s
31
+ value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
32
+ return [] if value.strip.empty?
33
+
34
+ Shellwords.split(value)
35
+ end
36
+
37
+ # The agent's backlog assignee handle. Default: @<name> (handle = name);
38
+ # a blank override also falls back to the derived handle.
39
+ def assignee_handle(name)
40
+ handle = @env['AGENT_ASSIGNEE_HANDLE']
41
+ handle && !handle.strip.empty? ? handle : "@#{name}"
42
+ end
43
+
44
+ # Retry interval when no tasks are open. LETSDO_WAIT_SECONDS wins, then
45
+ # AGENT_WAIT_SECONDS; an invalid value falls back to the default.
46
+ def wait_seconds
47
+ value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
48
+ value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
49
+ return DEFAULT_WAIT_SECONDS if value.empty?
50
+
51
+ Float(value)
52
+ rescue ArgumentError, TypeError
53
+ DEFAULT_WAIT_SECONDS
54
+ end
55
+
56
+ # The pi command used to run agents (same literal as PiRunner::COMMAND).
57
+ def pi_command
58
+ @env.fetch('LETSDO_PI_COMMAND', DEFAULT_PI_COMMAND)
59
+ end
60
+
61
+ # The Backlog.md CLI used as the task provider.
62
+ def backlog_command
63
+ @env.fetch('LETSDO_BACKLOG_COMMAND', DEFAULT_BACKLOG_COMMAND)
64
+ end
65
+
66
+ # Whether [letsdo] traces are enabled in PiRunner and AgentLoop.
67
+ def debug?
68
+ @env['LETSDO_DEBUG'] == '1'
69
+ end
70
+ end
71
+ end
@@ -37,10 +37,14 @@ module Letsdo
37
37
  end
38
38
 
39
39
  def finish
40
- return unless @last_char && @last_char != "\n"
41
-
42
- text_sink.write("\n")
43
- text_sink.flush unless @log
40
+ if @last_char && @last_char != "\n"
41
+ text_sink.write("\n")
42
+ text_sink.flush unless @log
43
+ end
44
+ # Reset so the next run's finish is a no-op unless new text arrived —
45
+ # otherwise a tool-only run after a text run writes a spurious blank
46
+ # line (TASK-77).
47
+ @last_char = nil
44
48
  end
45
49
 
46
50
  private
@@ -30,7 +30,7 @@ module Letsdo
30
30
  @command = opts.fetch(:command, COMMAND)
31
31
  @pending_tools = {}
32
32
  @reaped_status = nil
33
- @debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
33
+ @debug = opts[:debug].nil? ? (opts[:config] || Config.new).debug? : opts[:debug]
34
34
  end
35
35
 
36
36
  def run
@@ -7,11 +7,9 @@ module Letsdo
7
7
  private
8
8
 
9
9
  def input_loop
10
- with_raw_input do
11
- repaint
12
- @last_repaint = @clock.call
13
- loop { break if @stop || !poll_once }
14
- end
10
+ repaint
11
+ @last_repaint = @clock.call
12
+ loop { break if @stop || !poll_once }
15
13
  rescue StandardError
16
14
  nil
17
15
  end
@@ -25,8 +25,7 @@ module Letsdo
25
25
  def run(&work)
26
26
  install_winch_handler
27
27
  @terminal.enter
28
- start_input_thread
29
- work.call
28
+ run_work(&work)
30
29
  rescue Letsdo::Stopped
31
30
  0
32
31
  ensure
@@ -35,6 +34,13 @@ module Letsdo
35
34
  restore_winch_handler
36
35
  end
37
36
 
37
+ def run_work(&work)
38
+ with_raw_input do
39
+ start_input_thread
40
+ work.call
41
+ end
42
+ end
43
+
38
44
  private
39
45
 
40
46
  def assign_identity(opts)
@@ -68,6 +74,10 @@ module Letsdo
68
74
  @input_thread = nil
69
75
  end
70
76
 
77
+ # Enters raw mode on the keyboard for the duration of the block. Held
78
+ # by the main thread (not the killable input thread) so the terminal
79
+ # state is restored on every quit path — a killed input thread would
80
+ # leave the shared tty in raw mode and break the calling shell (TASK-83).
71
81
  def with_raw_input(&block)
72
82
  if @input.stdin.tty? && @input.stdin.respond_to?(:raw)
73
83
  @input.stdin.raw(&block)
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'tty-cursor'
4
-
5
3
  module Letsdo
6
4
  module Tui
7
5
  # Thin wrapper over the terminal: alternate screen, cursor handling and
@@ -36,19 +34,28 @@ module Letsdo
36
34
 
37
35
  # Enters the alternate screen and hides the cursor.
38
36
  def enter
39
- write(ENTER_ALT_SCREEN + TTY::Cursor.hide)
37
+ write(ENTER_ALT_SCREEN + cursor.hide)
40
38
  end
41
39
 
42
40
  # Shows the cursor and leaves the alternate screen.
43
41
  def leave
44
- write(TTY::Cursor.show + LEAVE_ALT_SCREEN)
42
+ write(cursor.show + LEAVE_ALT_SCREEN)
45
43
  end
46
44
 
47
45
  # Repaints the whole frame: cursor to the top left, then the frame.
48
46
  #
47
+ # Row separators are written as CRLF (\r\n), never bare LF. The TUI
48
+ # input thread holds stdin in io-console raw mode for the whole
49
+ # session, and raw mode clears OPOST on the shared tty — without it a
50
+ # bare \n no longer implies a carriage return, so every row after the
51
+ # first would start at the previous row's end column and the frame
52
+ # would wrap/scroll into the "blank activity pane" scramble (TASK-82).
53
+ # Explicit CRLF renders correctly both in raw mode and on terminals
54
+ # whose ONLCR already expands \n (a doubled CR is harmless).
55
+ #
49
56
  # @param frame [String] the rendered screen (see Letsdo::Tui::Renderer)
50
57
  def render(frame)
51
- write(HOME + frame)
58
+ write(HOME + frame.gsub("\n", "\r\n"))
52
59
  end
53
60
 
54
61
  # The current terminal size.
@@ -61,6 +68,14 @@ module Letsdo
61
68
 
62
69
  private
63
70
 
71
+ # Loaded lazily (like tty-screen/tty-reader) so requiring letsdo on a
72
+ # clean Ruby loads no tty-* gems — only the interactive TTY path needs
73
+ # them (TASK-78).
74
+ def cursor
75
+ require 'tty-cursor' unless defined?(TTY::Cursor)
76
+ TTY::Cursor
77
+ end
78
+
64
79
  def write(text)
65
80
  @stream.write(text)
66
81
  @stream.flush
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Letsdo
4
- VERSION = '0.2.0'
4
+ VERSION = '0.4.0'
5
5
  end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle/import'
4
+
5
+ module Letsdo
6
+ # OS file-change watcher for the backlog folder.
7
+ #
8
+ # On Linux it subscribes to inotify (via Fiddle, no external gem) and wakes
9
+ # as soon as a backlog entry changes; everywhere else it degrades to the
10
+ # same periodic polling a plain sleeper would do. Either way the wait is
11
+ # interruptible — a signal trap raising Letsdo::Stopped breaks the
12
+ # underlying IO.select — and an internal self-pipe lets #wake interrupt a
13
+ # blocked #wait from another thread.
14
+ class Watcher
15
+ # inotify event masks we subscribe to (Linux <sys/inotify.h>).
16
+ IN_ACCESS = 0x00000001
17
+ IN_MODIFY = 0x00000002
18
+ IN_ATTRIB = 0x00000004
19
+ IN_CLOSE_WRITE = 0x00000008
20
+ IN_CLOSE_NOWRITE = 0x00000010
21
+ IN_OPEN = 0x00000020
22
+ IN_MOVED_FROM = 0x00000040
23
+ IN_MOVED_TO = 0x00000080
24
+ IN_CREATE = 0x00000100
25
+ IN_DELETE = 0x00000200
26
+ IN_DELETE_SELF = 0x00000400
27
+ IN_MOVE_SELF = 0x00000800
28
+
29
+ RELEVANT_MASK = IN_CREATE | IN_MODIFY | IN_CLOSE_WRITE | IN_DELETE |
30
+ IN_MOVED_FROM | IN_MOVED_TO | IN_DELETE_SELF | IN_MOVE_SELF
31
+
32
+ EXCLUDED_DIR = '.locks'
33
+
34
+ # @param path [String] the watched folder (its direct entries)
35
+ # @param poll_seconds [Float] fallback poll interval / select timeout
36
+ def initialize(path:, poll_seconds: 10.0)
37
+ @poll_seconds = poll_seconds
38
+ @wake_r, @wake_w = IO.pipe
39
+ @backend = build_backend(path)
40
+ end
41
+
42
+ # Blocks until a relevant backlog change, an explicit #wake, or the poll
43
+ # timeout. Irrelevant events (e.g. .locks churn) keep the wait going for
44
+ # the remainder of the timeout.
45
+ #
46
+ # @return [Symbol] :change, :wake, or :timeout
47
+ def wait(timeout = @poll_seconds)
48
+ deadline = monotonic + timeout
49
+ loop do
50
+ result = wait_once(deadline)
51
+ return result if result
52
+ end
53
+ end
54
+
55
+ # Interrupts a blocked #wait from another thread.
56
+ def wake
57
+ @wake_w.write_nonblock('.')
58
+ rescue IO::WaitWritable, Errno::EPIPE
59
+ nil
60
+ end
61
+
62
+ def close
63
+ @backend&.close
64
+ close_io(@wake_w)
65
+ close_io(@wake_r)
66
+ end
67
+
68
+ private
69
+
70
+ def wait_once(deadline)
71
+ remaining = deadline - monotonic
72
+ return :timeout if remaining <= 0
73
+
74
+ ready = IO.select(read_fds, nil, nil, remaining)
75
+ return :timeout if ready.nil?
76
+ return :wake if wake_ready?(ready)
77
+
78
+ @backend.relevant_event? ? :change : nil
79
+ end
80
+
81
+ def read_fds
82
+ fds = [@wake_r]
83
+ fds << @backend.io if @backend
84
+ fds
85
+ end
86
+
87
+ def wake_ready?(ready)
88
+ return false unless ready.first.include?(@wake_r)
89
+
90
+ drain_wake
91
+ true
92
+ end
93
+
94
+ def drain_wake
95
+ @wake_r.read_nonblock(4096)
96
+ rescue IO::WaitReadable, EOFError
97
+ nil
98
+ end
99
+
100
+ def build_backend(path)
101
+ InotifyBackend.new(path, RELEVANT_MASK)
102
+ rescue InotifyBackend::Unavailable, Fiddle::DLError
103
+ nil
104
+ end
105
+
106
+ def monotonic
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ end
109
+
110
+ def close_io(io)
111
+ io.close unless io.nil? || io.closed?
112
+ rescue IOError
113
+ nil
114
+ end
115
+
116
+ # Linux inotify backend. Construction raises InotifyBackend::Unavailable
117
+ # when inotify is missing or the watch cannot be set up, so the watcher
118
+ # falls back to polling through its self-pipe timeout.
119
+ class InotifyBackend
120
+ # libc inotify bindings via Fiddle (no external gem).
121
+ module Lib
122
+ extend Fiddle::Importer
123
+ dlload Fiddle.dlopen(nil)
124
+ extern 'int inotify_init1(int)'
125
+ extern 'int inotify_add_watch(int, const char*, uint32_t)'
126
+ end
127
+
128
+ # Signals that inotify is unavailable and the watcher should fall back.
129
+ class Unavailable < StandardError; end
130
+
131
+ IN_CLOEXEC = 0x00080000
132
+ EVENT_HEADER_SIZE = 16
133
+
134
+ attr_reader :io
135
+
136
+ def initialize(path, mask)
137
+ fd = Lib.inotify_init1(IN_CLOEXEC)
138
+ guard(fd)
139
+ @io = IO.new(fd, 'r', autoclose: true)
140
+ guard(Lib.inotify_add_watch(fd, path, mask))
141
+ rescue Unavailable, Fiddle::DLError
142
+ @io&.close
143
+ raise Unavailable
144
+ end
145
+
146
+ # Drains pending events and reports whether any relevant (non-.locks)
147
+ # entry changed.
148
+ def relevant_event?
149
+ buffer = @io.read_nonblock(8192)
150
+ parse_relevant(buffer)
151
+ rescue IO::WaitReadable, EOFError
152
+ false
153
+ end
154
+
155
+ def close
156
+ @io&.close
157
+ rescue IOError
158
+ nil
159
+ end
160
+
161
+ private
162
+
163
+ def guard(value)
164
+ raise Unavailable if value.negative?
165
+ end
166
+
167
+ def parse_relevant(buffer)
168
+ offset = 0
169
+ relevant = false
170
+ while offset + EVENT_HEADER_SIZE <= buffer.bytesize
171
+ _wd, mask, _cookie, len = buffer.byteslice(offset, EVENT_HEADER_SIZE).unpack('L4')
172
+ name = buffer.byteslice(offset + EVENT_HEADER_SIZE, len).to_s.sub(/\0.*/m, '')
173
+ relevant ||= relevant_mask?(mask) && !excluded?(name)
174
+ offset += EVENT_HEADER_SIZE + len
175
+ end
176
+ relevant
177
+ end
178
+
179
+ def relevant_mask?(mask)
180
+ (mask & Watcher::RELEVANT_MASK) != 0
181
+ end
182
+
183
+ def excluded?(name)
184
+ name == Watcher::EXCLUDED_DIR || name.start_with?("#{Watcher::EXCLUDED_DIR}/")
185
+ end
186
+ end
187
+ end
188
+ end
data/lib/letsdo.rb CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  require_relative 'letsdo/version'
24
24
  require_relative 'letsdo/errors'
25
+ require_relative 'letsdo/config'
25
26
  require_relative 'letsdo/prompt_store'
26
27
  require_relative 'letsdo/default_prompt'
27
28
  require_relative 'letsdo/output_streamer'
@@ -30,6 +31,7 @@ require_relative 'letsdo/agent'
30
31
  require_relative 'letsdo/capture'
31
32
  require_relative 'letsdo/backlog_tasks'
32
33
  require_relative 'letsdo/loop'
34
+ require_relative 'letsdo/watcher'
33
35
  require_relative 'letsdo/agent_loop'
34
36
  require_relative 'letsdo/control'
35
37
  require_relative 'letsdo/tui'
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei O. Udalov
@@ -79,10 +79,11 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: 1.77.0
82
- description: 'letsdo — a local agent worker for Backlog.md/markdown tasks. OOP structure:
83
- Letsdo::PromptStore (agents/), Letsdo::OutputStreamer and Letsdo::PiRunner (pi --mode
84
- json), Letsdo::Agent (one run), Letsdo::Loop (orchestrator loop), Letsdo::CLI. Minitest
85
- tests. Will later be split into a separate repository.'
82
+ description: letsdo turns a plain markdown backlog into a team of autonomous local
83
+ agents. Each agent is just a prompt file in agents/; run `letsdo <name>` and it
84
+ works through every open task assigned to that agent, one task per run, and stops
85
+ cleanly on Ctrl+C. No framework, no hosted platform — the backlog folder is the
86
+ single source of truth and task data never leaves your machine.
86
87
  email:
87
88
  - udalov.x@mail.ru
88
89
  executables:
@@ -90,9 +91,11 @@ executables:
90
91
  extensions: []
91
92
  extra_rdoc_files: []
92
93
  files:
94
+ - CHANGELOG.md
93
95
  - LICENSE
94
96
  - README.md
95
97
  - bin/letsdo
98
+ - letsdo.gemspec
96
99
  - lib/letsdo.rb
97
100
  - lib/letsdo/agent.rb
98
101
  - lib/letsdo/agent_loop.rb
@@ -100,8 +103,9 @@ files:
100
103
  - lib/letsdo/backlog_tasks.rb
101
104
  - lib/letsdo/capture.rb
102
105
  - lib/letsdo/cli.rb
106
+ - lib/letsdo/cli/builder.rb
103
107
  - lib/letsdo/cli/init.rb
104
- - lib/letsdo/cli/launch.rb
108
+ - lib/letsdo/config.rb
105
109
  - lib/letsdo/control.rb
106
110
  - lib/letsdo/default_prompt.rb
107
111
  - lib/letsdo/errors.rb
@@ -122,13 +126,15 @@ files:
122
126
  - lib/letsdo/tui/session/view.rb
123
127
  - lib/letsdo/tui/terminal.rb
124
128
  - lib/letsdo/version.rb
129
+ - lib/letsdo/watcher.rb
125
130
  homepage: https://github.com/sergio-fry/letsdo
126
131
  licenses:
127
132
  - MIT
128
133
  metadata:
129
- homepage_uri: https://github.com/sergio-fry/letsdo
130
134
  source_code_uri: https://github.com/sergio-fry/letsdo
135
+ bug_tracker_uri: https://github.com/sergio-fry/letsdo/issues
131
136
  changelog_uri: https://github.com/sergio-fry/letsdo/blob/main/CHANGELOG.md
137
+ documentation_uri: https://github.com/sergio-fry/letsdo/blob/main/README.md
132
138
  rubygems_mfa_required: 'true'
133
139
  allowed_push_host: https://rubygems.org
134
140
  rdoc_options: []
@@ -138,7 +144,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
138
144
  requirements:
139
145
  - - ">="
140
146
  - !ruby/object:Gem::Version
141
- version: '3.0'
147
+ version: '3.3'
142
148
  required_rubygems_version: !ruby/object:Gem::Requirement
143
149
  requirements:
144
150
  - - ">="
@@ -1,82 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Builds the agent, provider, and orchestrator loop for CLI run paths.
5
- module CLILaunch
6
- private
7
-
8
- def run_agent_plain(name)
9
- streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
10
- agent_loop(name, streamer, stderr: @stderr).run
11
- end
12
-
13
- def run_agent_tui(name)
14
- ctx = tui_context(name)
15
- session = Tui::Session.new(**ctx[:session])
16
- session.run { ctx[:loop].run }
17
- end
18
-
19
- def tui_context(name)
20
- parts = tui_parts(name)
21
- {
22
- loop: tui_loop(name, parts),
23
- session: tui_session_args(name, **parts)
24
- }
25
- end
26
-
27
- def tui_parts(name)
28
- clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
29
- handle = assignee_handle(name)
30
- log = Tui::LogBuffer.new
31
- {
32
- clock: clock, handle: handle, log: log,
33
- metrics: tui_metrics(name, handle, clock, log),
34
- agent: agent_for(name, OutputStreamer.new(log: log)),
35
- provider: provider_for(handle),
36
- pause_gate: Control::PauseGate.new
37
- }
38
- end
39
-
40
- def tui_loop(name, parts)
41
- agent_loop(name, nil, agent: parts[:agent], provider: parts[:provider],
42
- handle: parts[:handle], stderr: parts[:log],
43
- metrics: parts[:metrics], pause_gate: parts[:pause_gate])
44
- end
45
-
46
- def tui_metrics(name, handle, clock, log)
47
- Tui::Metrics.new(name: name, handle: handle, clock: clock,
48
- on_run_start: ->(_label) { log.divider })
49
- end
50
-
51
- def tui_session_args(name, **parts)
52
- {
53
- name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:metrics],
54
- terminal: Tui::Terminal.new(stream: @stdout),
55
- input: Tui::Input.new(stdin: @stdin),
56
- refresh: -> { parts[:provider].call },
57
- wait_seconds: wait_seconds, clock: parts[:clock],
58
- pause_gate: parts[:pause_gate], runner: -> { parts[:agent].runner }
59
- }
60
- end
61
-
62
- def agent_for(name, streamer)
63
- Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
64
- command: pi_command)
65
- end
66
-
67
- def provider_for(handle)
68
- BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
69
- env: ENV.to_h.merge(@env))
70
- end
71
-
72
- def agent_loop(name, streamer, **opts)
73
- handle = opts[:handle] || assignee_handle(name)
74
- agent = opts[:agent] || agent_for(name, streamer)
75
- provider = opts[:provider] || provider_for(handle)
76
- AgentLoop.new(name: name, handle: handle, agent: agent,
77
- task_provider: -> { provider.call },
78
- wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
79
- metrics: opts[:metrics], pause_gate: opts[:pause_gate])
80
- end
81
- end
82
- end