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 +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +11 -3
- data/letsdo.gemspec +61 -0
- data/lib/letsdo/agent_loop.rb +21 -2
- data/lib/letsdo/capture.rb +21 -6
- 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/output_streamer.rb +8 -4
- data/lib/letsdo/pi_runner.rb +1 -1
- data/lib/letsdo/tui/session/view.rb +3 -5
- data/lib/letsdo/tui/session.rb +12 -2
- data/lib/letsdo/tui/terminal.rb +20 -5
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo/watcher.rb +188 -0
- data/lib/letsdo.rb +2 -0
- metadata +14 -8
- data/lib/letsdo/cli/launch.rb +0 -82
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`.
|
|
@@ -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.
|
|
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.
|
|
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
|
data/lib/letsdo/agent_loop.rb
CHANGED
|
@@ -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
|
|
55
|
-
@
|
|
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
|
data/lib/letsdo/capture.rb
CHANGED
|
@@ -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
|
-
#
|
|
66
|
-
#
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|
@@ -37,10 +37,14 @@ module Letsdo
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def finish
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
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
|
|
@@ -7,11 +7,9 @@ module Letsdo
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def input_loop
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
data/lib/letsdo/tui/session.rb
CHANGED
|
@@ -25,8 +25,7 @@ module Letsdo
|
|
|
25
25
|
def run(&work)
|
|
26
26
|
install_winch_handler
|
|
27
27
|
@terminal.enter
|
|
28
|
-
|
|
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)
|
data/lib/letsdo/tui/terminal.rb
CHANGED
|
@@ -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 +
|
|
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(
|
|
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
|
data/lib/letsdo/version.rb
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -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.
|
|
147
|
+
version: '3.3'
|
|
142
148
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
143
149
|
requirements:
|
|
144
150
|
- - ">="
|
data/lib/letsdo/cli/launch.rb
DELETED
|
@@ -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
|