letsdo 0.3.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: f15aee491b4d0954c628e52566908e6b7ea35428c347a79822f5781198ea4be9
4
- data.tar.gz: 71b47328ea44b224cb250e4bb8d0e58c052ddd054cd950fa1b058c85933f6a91
3
+ metadata.gz: f6c3ecf1ef07372a4f5b38843faafaf394ca49ee9ea73c48a59034c0c33ed1b9
4
+ data.tar.gz: 9349a23494802614dcdb7aa27d378b7ca2dae1512a395989678e166b3b68548d
5
5
  SHA512:
6
- metadata.gz: eab4462fe5a7bec0981c9b2a803a02063d1714cc049f51f5add4053f80c302b917491465dda6d90aa66a59fb668b33a5d2166ea95589d317e1958a3756564f1c
7
- data.tar.gz: 5f1b243e5bce00874965683d68bebf22c2c9cec992e6f8ab1e825db3a0796781a47bf51be934b5c8c5409c7ad1cf85a83d8a872c807b8d4e5a6023c888f8ca73
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`.
@@ -105,9 +105,10 @@ gem install letsdo-0.3.0.gem
105
105
  > **Ruby 4.0.x note.** Some Ruby 4.0.x builds ship default gems out of sync —
106
106
  > rdoc 8.0.0 declares `rbs >= 4.0.0` while rbs 3.x is bundled — so the
107
107
  > post-install RDoc hook can raise `Gem::ConflictError` even though the gem
108
- > files are already installed. Install without documentation to skip the
109
- > hook (`gem install letsdo-0.3.0.gem --no-document`), or install a matching
110
- > rbs first (`gem install rbs -v '>= 4.0.0'`).
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`).
111
112
 
112
113
  or run it straight from the checkout without installing:
113
114
 
@@ -257,7 +258,7 @@ gem build letsdo.gemspec
257
258
 
258
259
  Cleanliness is enforced by the CI workflow
259
260
  (`.github/workflows/ci.yml`): gem build + `rake test` on every push,
260
- Ruby 3.3 (satisfies `required_ruby_version: ">= 3.0"`).
261
+ Ruby 3.3 and 4.0 (satisfies `required_ruby_version: ">= 3.3"`).
261
262
 
262
263
  ## Alternatives
263
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
@@ -56,7 +56,7 @@ module Letsdo
56
56
  def assign_control_opts(opts)
57
57
  @metrics = opts[:metrics]
58
58
  @pause_gate = opts[:pause_gate]
59
- @debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
59
+ @debug = resolve_debug(opts)
60
60
  @watcher = opts[:watcher] || (Watcher.new(path: opts[:watch_path]) if opts[:watch_path])
61
61
  # Precedence is deliberate: an explicitly injected sleeper always wins
62
62
  # over the watcher idle path — tests inject a stop/control sleeper that
@@ -64,6 +64,12 @@ module Letsdo
64
64
  @sleeper = opts[:sleeper] || watcher_sleeper || ->(seconds) { sleep(seconds) }
65
65
  end
66
66
 
67
+ def resolve_debug(opts)
68
+ return opts[:debug] unless opts[:debug].nil?
69
+
70
+ (opts[:config] || Config.new).debug?
71
+ end
72
+
67
73
  def watcher_sleeper
68
74
  @watcher && ->(seconds) { @watcher.wait(seconds) }
69
75
  end
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Letsdo
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  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'
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.3.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
@@ -127,9 +131,10 @@ homepage: https://github.com/sergio-fry/letsdo
127
131
  licenses:
128
132
  - MIT
129
133
  metadata:
130
- homepage_uri: https://github.com/sergio-fry/letsdo
131
134
  source_code_uri: https://github.com/sergio-fry/letsdo
135
+ bug_tracker_uri: https://github.com/sergio-fry/letsdo/issues
132
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
133
138
  rubygems_mfa_required: 'true'
134
139
  allowed_push_host: https://rubygems.org
135
140
  rdoc_options: []
@@ -139,7 +144,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
144
  requirements:
140
145
  - - ">="
141
146
  - !ruby/object:Gem::Version
142
- version: '3.0'
147
+ version: '3.3'
143
148
  required_rubygems_version: !ruby/object:Gem::Requirement
144
149
  requirements:
145
150
  - - ">="
@@ -1,87 +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
- watcher: backlog_watcher)
81
- end
82
-
83
- def backlog_watcher
84
- Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
85
- end
86
- end
87
- end