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 +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +6 -5
- data/letsdo.gemspec +61 -0
- data/lib/letsdo/agent_loop.rb +7 -1
- data/lib/letsdo/cli/builder.rb +150 -0
- data/lib/letsdo/cli.rb +5 -58
- data/lib/letsdo/config.rb +71 -0
- data/lib/letsdo/pi_runner.rb +1 -1
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo.rb +1 -0
- metadata +13 -8
- data/lib/letsdo/cli/launch.rb +0 -87
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6c3ecf1ef07372a4f5b38843faafaf394ca49ee9ea73c48a59034c0c33ed1b9
|
|
4
|
+
data.tar.gz: 9349a23494802614dcdb7aa27d378b7ca2dae1512a395989678e166b3b68548d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
109
|
-
>
|
|
110
|
-
>
|
|
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.
|
|
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
|
data/lib/letsdo/agent_loop.rb
CHANGED
|
@@ -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
|
|
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
|
-
@
|
|
42
|
-
@
|
|
43
|
-
|
|
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
|
-
|
|
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
|
data/lib/letsdo/pi_runner.rb
CHANGED
|
@@ -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? ?
|
|
33
|
+
@debug = opts[:debug].nil? ? (opts[:config] || Config.new).debug? : opts[:debug]
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def run
|
data/lib/letsdo/version.rb
CHANGED
data/lib/letsdo.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: letsdo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.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:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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/
|
|
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.
|
|
147
|
+
version: '3.3'
|
|
143
148
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
144
149
|
requirements:
|
|
145
150
|
- - ">="
|
data/lib/letsdo/cli/launch.rb
DELETED
|
@@ -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
|