letsdo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +291 -0
- data/bin/letsdo +33 -0
- data/lib/letsdo/agent.rb +45 -0
- data/lib/letsdo/agent_loop.rb +170 -0
- data/lib/letsdo/backlog_tasks.rb +60 -0
- data/lib/letsdo/cli.rb +207 -0
- data/lib/letsdo/errors.rb +22 -0
- data/lib/letsdo/loop.rb +58 -0
- data/lib/letsdo/output_streamer.rb +282 -0
- data/lib/letsdo/pi_runner.rb +260 -0
- data/lib/letsdo/prompt_store.rb +39 -0
- data/lib/letsdo/tui/input.rb +93 -0
- data/lib/letsdo/tui/log_buffer.rb +111 -0
- data/lib/letsdo/tui/metrics.rb +97 -0
- data/lib/letsdo/tui/renderer.rb +173 -0
- data/lib/letsdo/tui/session.rb +253 -0
- data/lib/letsdo/tui/terminal.rb +67 -0
- data/lib/letsdo/tui.rb +24 -0
- data/lib/letsdo/version.rb +5 -0
- data/lib/letsdo.rb +30 -0
- metadata +121 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3a847d927396a19813b8b643486bddabe1f9264f9ba38c6618110e24cc67b55d
|
|
4
|
+
data.tar.gz: 6d8ad205231a4e833694d7471efffe40dfd8a1f5bea89c45fdf1babb099fbdfa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 39ba23f90a1c8c4add2bce03e9443071a7cb57dd0458c64e7be96ec0c803ff098cea7fbd0d4a9d37eb94050aa470e142f7820643d6738be759176424e2387b98
|
|
7
|
+
data.tar.gz: 6bc4f0e7f03e01963bfb44d305e0ca76082727643a061eb042a58f71f49fce415a4616a4e6374c7461575ec28458f66c51fa864abc81048751367cf2ba4549a8
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergei O. Udalov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# letsdo
|
|
2
|
+
|
|
3
|
+
> A local agent worker for Backlog.md/markdown tasks.
|
|
4
|
+
|
|
5
|
+
Letsdo turns a plain markdown backlog into a team of autonomous agents.
|
|
6
|
+
Each agent is just a prompt file in `agents/`; run `letsdo <name>` and the
|
|
7
|
+
agent picks up all open tasks assigned to it, one task per run, loops back
|
|
8
|
+
for new ones, and stops cleanly on `Ctrl+C`. No framework code, no hosted
|
|
9
|
+
platform — the backlog folder is the single source of truth.
|
|
10
|
+
|
|
11
|
+
[](https://github.com/sergio-fry/letsdo/actions)
|
|
12
|
+
|
|
13
|
+
## Table of contents
|
|
14
|
+
|
|
15
|
+
- [Why letsdo](#why-letsdo)
|
|
16
|
+
- [Features](#features)
|
|
17
|
+
- [Requirements](#requirements)
|
|
18
|
+
- [Installation](#installation)
|
|
19
|
+
- [Getting started](#getting-started)
|
|
20
|
+
- [Configuration](#configuration)
|
|
21
|
+
- [How it works](#how-it-works)
|
|
22
|
+
- [Guides](#guides)
|
|
23
|
+
- [Development](#development)
|
|
24
|
+
- [Alternatives](#alternatives)
|
|
25
|
+
- [Contributing](#contributing)
|
|
26
|
+
- [License](#license)
|
|
27
|
+
|
|
28
|
+
## Why letsdo
|
|
29
|
+
|
|
30
|
+
- **Your backlog already exists.** If you track work in a
|
|
31
|
+
Backlog.md/markdown project (a `backlog/` folder of markdown tasks), you
|
|
32
|
+
already have everything letsdo needs. The tasks are the instructions;
|
|
33
|
+
letsdo only executes them.
|
|
34
|
+
- **Zero-config team.** A new agent is a new file: `agents/<name>.md`
|
|
35
|
+
with the agent's instructions. The assignee handle is derived from the
|
|
36
|
+
name (`@developer` ↔ `developer`), so the agent automatically works on
|
|
37
|
+
the tasks already assigned to it. No code, no schemas, no setup.
|
|
38
|
+
- **One task per run — honest work.** Each run picks up exactly one open
|
|
39
|
+
task and completes it before the next. No context-switching, no runaway
|
|
40
|
+
loops: the orchestrator loop assigns the next task only after the current
|
|
41
|
+
one finishes, and pauses when there is nothing to do.
|
|
42
|
+
- **Local and private.** Everything runs on your machine — `pi` in
|
|
43
|
+
`--mode json` under the hood. No hosted agents, no task data leaving
|
|
44
|
+
your project.
|
|
45
|
+
- **Observable.** The stream shows exactly what the agent is doing:
|
|
46
|
+
agent text on stdout, tool calls with `HH:MM:SS` timestamps and
|
|
47
|
+
completion durations on stderr.
|
|
48
|
+
|
|
49
|
+
Use it when you want a local, convention-driven worker that executes
|
|
50
|
+
backlog tasks autonomously: development chores, analysis spikes, doc
|
|
51
|
+
generation, any repeatable task flow you can express as assignee + prompt.
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- **One-command agent run** — `letsdo <name>` starts the loop: all open
|
|
56
|
+
tasks assigned to `@<name>` are done one after another (one agent run =
|
|
57
|
+
one task), then the loop waits for new ones until stopped with
|
|
58
|
+
`SIGINT/SIGTERM` (clean exit, code 0).
|
|
59
|
+
- **Agents as prompt files** — `agents/<name>.md` is the whole identity of
|
|
60
|
+
an agent: role, rules, workflow. Add a file, get an agent.
|
|
61
|
+
- **Built-in default prompt** — an agent starts even without a prompt file:
|
|
62
|
+
it runs on the built-in default prompt (process-only instructions), and
|
|
63
|
+
letsdo announces once where the prompt was looked for and how to create
|
|
64
|
+
it (`letsdo <name> --init`).
|
|
65
|
+
- **`--init` scaffold** — `letsdo <name> --init` creates
|
|
66
|
+
`agents/<name>.md` with the starter default prompt so you can customize
|
|
67
|
+
it. It never runs the agent and never overwrites an existing file.
|
|
68
|
+
- **Orchestrator loop** — retries every 10 s (configurable) when there are
|
|
69
|
+
no open tasks, pauses when the backlog is unreadable instead of crashing,
|
|
70
|
+
and stops instantly on `Ctrl+C`.
|
|
71
|
+
- **Streaming output** — agent text streams to stdout as it is generated;
|
|
72
|
+
service and tool lines go to stderr with a shared `HH:MM:SS` prefix:
|
|
73
|
+
tool start (`⚙ name: args`), completion with duration
|
|
74
|
+
(`✓/✖ name: … (3s)`), indented results (trimmed with a summary note
|
|
75
|
+
when large), and error results marked (`✖ Error: ...`).
|
|
76
|
+
- **`--version` / `--help`** — `Letsdo::VERSION` and usage, exit 0.
|
|
77
|
+
- **Available as a library** — `require "letsdo"` exposes the
|
|
78
|
+
`Letsdo` module (`Letsdo::VERSION`, `Letsdo::PromptStore`, `Letsdo::Agent`,
|
|
79
|
+
...) for embedding or testing.
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- Ruby **>= 3.0**.
|
|
84
|
+
- The [pi](https://github.com/earendil-works/pi) agent CLI on
|
|
85
|
+
`PATH` — this is the AI backend that runs the agent (`pi --mode json`).
|
|
86
|
+
The command is configurable via `LETSDO_PI_COMMAND`.
|
|
87
|
+
- The Backlog.md CLI (`backlog`) on `PATH` — the task provider reads open
|
|
88
|
+
tasks via `backlog task list --assignee <handle>`. Configurable via
|
|
89
|
+
`LETSDO_BACKLOG_COMMAND`.
|
|
90
|
+
|
|
91
|
+
Tests and the build use only Ruby's bundled default gems (Minitest, Rake) —
|
|
92
|
+
no `bundle install` needed.
|
|
93
|
+
|
|
94
|
+
## Installation
|
|
95
|
+
|
|
96
|
+
The gem is built from the repository:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
git clone git@github.com:sergio-fry/letsdo.git
|
|
100
|
+
cd letsdo
|
|
101
|
+
gem build letsdo.gemspec
|
|
102
|
+
gem install letsdo-0.1.0.gem
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
or run it straight from the checkout without installing:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
cd letsdo
|
|
109
|
+
./bin/letsdo --version
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Getting started
|
|
113
|
+
|
|
114
|
+
Letsdo works in a Backlog.md project root — a folder that holds the
|
|
115
|
+
`backlog/` tasks and your `agents/` prompts:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
cd your-backlog-project
|
|
119
|
+
|
|
120
|
+
# create an agent prompt (once)
|
|
121
|
+
letsdo developer --init # writes agents/developer.md, never runs the agent
|
|
122
|
+
|
|
123
|
+
# or write agents/developer.md by hand — the file is the agent's instructions
|
|
124
|
+
|
|
125
|
+
# run the agent: it works through all open tasks assigned to @developer
|
|
126
|
+
letsdo developer
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The loop prints service messages on stderr (started, which task is being
|
|
130
|
+
run, no open tasks / backlog unavailable, stopped) and streams the agent's
|
|
131
|
+
text on stdout. Stop the loop with `Ctrl+C` — a running agent child is
|
|
132
|
+
terminated and the process exits with code 0.
|
|
133
|
+
|
|
134
|
+
No prompt file? No problem:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
$ letsdo newcomer
|
|
138
|
+
letsdo: no prompt for newcomer at /home/user/backlog-project/agents/newcomer.md
|
|
139
|
+
letsdo: using the built-in default prompt (create a prompt file with 'letsdo newcomer --init')
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The agent still runs — on the built-in default prompt. The notification is
|
|
143
|
+
printed once per process. The looked-up path is exactly
|
|
144
|
+
`<LETSDO_ROOT>/agents/<name>.md`.
|
|
145
|
+
|
|
146
|
+
For the full walkthrough — install, session anatomy (plain and TUI), the
|
|
147
|
+
loop/waiting model, exit codes — see the [usage guide](docs/usage.md).
|
|
148
|
+
|
|
149
|
+
CLI reference:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
letsdo <name> # run the <name> agent in the loop (exit 0 on stop)
|
|
153
|
+
letsdo <name> --init # create agents/<name>.md, never run the agent (exit 0)
|
|
154
|
+
letsdo --init <name> # same as above (flag-first form)
|
|
155
|
+
letsdo --version # gemspec version, exit 0
|
|
156
|
+
letsdo --help # usage and agent list, exit 0
|
|
157
|
+
letsdo # usage and agent list, exit 1
|
|
158
|
+
letsdo --badopt # "unknown option" + usage, exit 1
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`--init` fails with exit 1 and a message on stderr when the file already
|
|
162
|
+
exists (never overwrites) or the agent name is unsafe (contains `/` or `\`,
|
|
163
|
+
or is `.`/`..` — nothing is ever written outside `agents/`).
|
|
164
|
+
|
|
165
|
+
## Configuration
|
|
166
|
+
|
|
167
|
+
All knobs are environment variables:
|
|
168
|
+
|
|
169
|
+
| Variable | Default | Purpose |
|
|
170
|
+
| --- | --- | --- |
|
|
171
|
+
| `LETSDO_ROOT` | current folder | Project root where `agents/` lives (and where the `backlog` CLI finds `backlog/`). |
|
|
172
|
+
| `LETSDO_PI_FLAGS` | — | Extra pi flags, e.g. `--model anthropic/claude-sonnet-4-5` (split on whitespace). |
|
|
173
|
+
| `AGENT_PI_FLAGS` | — | Fallback for `LETSDO_PI_FLAGS` (compatibility with the old `bin/agent`). |
|
|
174
|
+
| `LETSDO_PI_COMMAND` | `pi` | The pi command used to run agents; overridable for tests / fake pi. |
|
|
175
|
+
| `AGENT_ASSIGNEE_HANDLE` | `@<name>` | The agent's backlog assignee handle. The one rule: handle = name. |
|
|
176
|
+
| `LETSDO_WAIT_SECONDS` | 10 | Retry interval when there are no open tasks. |
|
|
177
|
+
| `AGENT_WAIT_SECONDS` | — | Fallback for `LETSDO_WAIT_SECONDS` (`bin/agent-loop` compatibility). |
|
|
178
|
+
| `LETSDO_BACKLOG_COMMAND` | `backlog` | The Backlog.md CLI command used as the task provider. |
|
|
179
|
+
| `LETSDO_DEBUG` | — | Set to `1` to trace loop decisions on stderr. |
|
|
180
|
+
|
|
181
|
+
The comprehensive reference — every variable with defaults, precedences,
|
|
182
|
+
examples and where each one is read — lives in the
|
|
183
|
+
[configuration reference](docs/config.md).
|
|
184
|
+
|
|
185
|
+
## How it works
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
bin/letsdo ──► Letsdo::CLI ──► Letsdo::Agent ──► Letsdo::PiRunner (pi --mode json)
|
|
189
|
+
│ │ │
|
|
190
|
+
│ │ Letsdo::OutputStreamer (stdout/stderr)
|
|
191
|
+
▼ ▼
|
|
192
|
+
Letsdo::BacklogTasks Letsdo::AgentLoop
|
|
193
|
+
(backlog CLI → tasks) (orchestrator loop)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- `Letsdo::CLI` — argument parsing, usage, exit codes; builds the agent, the
|
|
197
|
+
task provider and the loop.
|
|
198
|
+
- `Letsdo::PromptStore` — access to `agents/<name>.md` prompts; `--init`
|
|
199
|
+
writes them via `create_agent` (no overwrite, safe names only).
|
|
200
|
+
- `Letsdo::DefaultPrompt` — the built-in default prompt: the single source
|
|
201
|
+
of truth used both for the fallback run and as the `--init` template.
|
|
202
|
+
- `Letsdo::Agent` — one agent run: the prompt (file or default) + a `pi`
|
|
203
|
+
child process; returns the pi exit code.
|
|
204
|
+
- `Letsdo::PiRunner` — spawns `pi --mode json <flags> <prompt>`, parses the
|
|
205
|
+
line-by-line event stream, feeds the streamer, propagates the pi exit
|
|
206
|
+
code (including 128+signal).
|
|
207
|
+
- `Letsdo::OutputStreamer` — routes agent text to stdout and service/tool
|
|
208
|
+
lines to stderr with `HH:MM:SS` prefixes and durations.
|
|
209
|
+
- `Letsdo::BacklogTasks` — the task provider: open tasks for a handle via
|
|
210
|
+
`backlog task list --assignee <handle> --exclude-status Done --json`;
|
|
211
|
+
`nil` when the backlog is unreadable (the loop pauses instead of running
|
|
212
|
+
the agent).
|
|
213
|
+
- `Letsdo::Loop` / `Letsdo::AgentLoop` — the orchestrator: tasks → one run
|
|
214
|
+
each → wait → repeat; stopped from outside via `SIGINT/SIGTERM` (the
|
|
215
|
+
running pi child is terminated, exit 0).
|
|
216
|
+
|
|
217
|
+
Multiple agents run as separate processes, each with its own loop and its
|
|
218
|
+
own assignee; they coordinate through the shared backlog — nothing else in
|
|
219
|
+
common. This repository itself is run by letsdo: `agents/developer.md` and
|
|
220
|
+
`agents/analyst.md` are its own workers on the `backlog/` tasks.
|
|
221
|
+
|
|
222
|
+
## Guides
|
|
223
|
+
|
|
224
|
+
- [Usage guide](docs/usage.md) — install, first run, loop semantics,
|
|
225
|
+
the interactive TUI and its keys, exit codes.
|
|
226
|
+
- [Prompt-authoring guide](docs/prompts.md) — what makes a good agent
|
|
227
|
+
prompt: must-haves, anti-patterns, worked examples.
|
|
228
|
+
- [Configuration reference](docs/config.md) — every environment variable,
|
|
229
|
+
its default, precedence and where it is read.
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
The gem uses Minitest (bundled with Ruby, plain `assert`/`refute`, no
|
|
234
|
+
external DSLs or mock frameworks), so tests run on a clean Ruby:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
rake test # all tests
|
|
238
|
+
ruby -Itest -Ilib test/cli_test.rb # one test file
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Test fixtures: `test/fixtures/fake_pi` emulates the `pi --mode json` event
|
|
242
|
+
stream (`FAKE_PI_SCENARIO=default|error|big|stub`); the CLI snapshots the
|
|
243
|
+
spawned fake-pi argv per run, which tests assert on.
|
|
244
|
+
|
|
245
|
+
Building the gem:
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
gem build letsdo.gemspec
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Cleanliness is enforced by the CI workflow
|
|
252
|
+
(`.github/workflows/ci.yml`): gem build + `rake test` on every push,
|
|
253
|
+
Ruby 3.3 (satisfies `required_ruby_version: ">= 3.0"`).
|
|
254
|
+
|
|
255
|
+
## Alternatives
|
|
256
|
+
|
|
257
|
+
| Tool | What it is | What's similar | What's different |
|
|
258
|
+
| --- | --- | --- | --- |
|
|
259
|
+
| [Claude Code](https://github.com/anthropics/claude-code) | Anthropic's terminal agent | Local, terminal-driven, works in your repository | Interactive chat sessions you drive; no backlog loop, no one-task-per-run contract, no multi-agent-by-convention |
|
|
260
|
+
| [OpenAI Codex CLI](https://github.com/openai/codex) | OpenAI's terminal coding agent | Local agent on the command line | Same interactive pattern; session-based, not a task-execution worker |
|
|
261
|
+
| [CrewAI](https://github.com/crewAIInc/crewAI) / [AutoGPT](https://github.com/Significant-Gravitas/AutoGPT) | Agent orchestration frameworks (Python) | Multi-agent teams and roles | The team, tools and workflow are code and configuration; no built-in task-tracker loop |
|
|
262
|
+
| [aider](https://github.com/Aider-AI/aider) | Pair-programming CLI | Local AI pair for code changes | Focused on interactive coding pairs, not executing a tracked backlog |
|
|
263
|
+
|
|
264
|
+
What none of them do out of the box: take an existing markdown backlog,
|
|
265
|
+
derive the team from the assignee handles, and execute the tasks one per
|
|
266
|
+
run with an observable loop. That is letsdo's niche — a thin convention
|
|
267
|
+
layer instead of a framework. If your project is tracked in Backlog.md
|
|
268
|
+
format and you want a local, observable, multi-agent worker on top of it,
|
|
269
|
+
letsdo is the smallest thing that does it.
|
|
270
|
+
|
|
271
|
+
## Contributing
|
|
272
|
+
|
|
273
|
+
Contributions are welcome. The project is small and intentionally so —
|
|
274
|
+
please keep it that way.
|
|
275
|
+
|
|
276
|
+
- **Language.** All task tracking, prompts, docs and comments are in
|
|
277
|
+
English (project convention). New code and docs follow suit.
|
|
278
|
+
- **Where the code lives.** `bin/letsdo` (entry point),
|
|
279
|
+
`lib/letsdo/` (CLI, PromptStore, DefaultPrompt, Agent, PiRunner,
|
|
280
|
+
OutputStreamer, BacklogTasks, Loop, AgentLoop), `test/` (Minitest +
|
|
281
|
+
fixtures), `letsdo.gemspec`, `.github/workflows/ci.yml`.
|
|
282
|
+
- **Before opening a PR:** `rake test` must pass with 0 failures and the
|
|
283
|
+
gem must build (`gem build letsdo.gemspec`) — the same checks CI runs on
|
|
284
|
+
every push.
|
|
285
|
+
- **Dogfooding.** This repository manages itself with letsdo: new work is
|
|
286
|
+
tracked as Backlog tasks, and `agents/developer.md` / `agents/analyst.md`
|
|
287
|
+
execute them. Every change is a chance to exercise the tool.
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
MIT — see [LICENSE](LICENSE).
|
data/bin/letsdo
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
#
|
|
5
|
+
# letsdo — a local agent worker for Backlog.md/markdown tasks.
|
|
6
|
+
#
|
|
7
|
+
# A thin wrapper over Letsdo::CLI: runs an agent by name (prompt from
|
|
8
|
+
# agents/<name>.md in the project root) as an orchestrator loop — all open
|
|
9
|
+
# tasks assigned to the agent are done one after another (one agent run =
|
|
10
|
+
# one task), then the loop waits for new ones until stopped with
|
|
11
|
+
# SIGINT/SIGTERM (clean exit, code 0).
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# ./bin/letsdo <name> # run the <name> agent in the loop (exit 0 on stop)
|
|
15
|
+
# ./bin/letsdo --version # version, exit 0
|
|
16
|
+
# ./bin/letsdo --help # help, exit 0
|
|
17
|
+
# ./bin/letsdo # usage and agent list, exit 1
|
|
18
|
+
# ./bin/letsdo --badopt # "unknown option" + usage, exit 1
|
|
19
|
+
#
|
|
20
|
+
# Environment:
|
|
21
|
+
# LETSDO_ROOT project root with agents/ (default — current folder)
|
|
22
|
+
# LETSDO_PI_FLAGS extra pi flags (e.g. "--model anthropic/claude-sonnet-4-5")
|
|
23
|
+
# AGENT_PI_FLAGS the same, for bin/agent compatibility if LETSDO_PI_FLAGS is unset
|
|
24
|
+
# AGENT_ASSIGNEE_HANDLE the agent's backlog assignee handle (default "@<name>")
|
|
25
|
+
# LETSDO_WAIT_SECONDS retry interval when no tasks are open (default 10)
|
|
26
|
+
# LETSDO_BACKLOG_COMMAND the backlog CLI command (default "backlog")
|
|
27
|
+
#
|
|
28
|
+
# A new agent = a new agents/<name>.md file, no code changes needed.
|
|
29
|
+
#
|
|
30
|
+
|
|
31
|
+
require_relative "../lib/letsdo"
|
|
32
|
+
|
|
33
|
+
exit Letsdo::CLI.run(ARGV)
|
data/lib/letsdo/agent.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# A single agent run: reads the prompt from agents/<name>.md by agent name
|
|
5
|
+
# and runs pi with that prompt. Returns the pi exit code.
|
|
6
|
+
#
|
|
7
|
+
# This is the logic of one bin/agent run: a prompt store + an output
|
|
8
|
+
# streamer + a pi runner. The orchestrator (a loop while tasks exist)
|
|
9
|
+
# lives in Letsdo::Loop.
|
|
10
|
+
class Agent
|
|
11
|
+
# @param name [String] agent name (agents/<name>.md)
|
|
12
|
+
# @param root [String] project root (agents/ lives there)
|
|
13
|
+
# @param flags [Array<String>] extra pi flags
|
|
14
|
+
# @param streamer [OutputStreamer] where to print output (by default
|
|
15
|
+
# the real stdout/stderr)
|
|
16
|
+
# @param command [String] the pi command (overridable for tests)
|
|
17
|
+
def initialize(name:, root:, flags: [], streamer: nil, command: PiRunner::COMMAND)
|
|
18
|
+
@name = name
|
|
19
|
+
@root = root
|
|
20
|
+
@flags = flags
|
|
21
|
+
@streamer = streamer || OutputStreamer.new
|
|
22
|
+
@command = command
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The runner of the last/current run — lets the orchestrator terminate
|
|
26
|
+
# a running pi when the loop is stopped.
|
|
27
|
+
attr_reader :runner
|
|
28
|
+
|
|
29
|
+
# Runs the agent once.
|
|
30
|
+
#
|
|
31
|
+
# @return [Integer] pi exit code
|
|
32
|
+
# @raise [UnknownAgentError] if the agent is not in agents/
|
|
33
|
+
def run
|
|
34
|
+
prompt = prompt_store.read(@name)
|
|
35
|
+
@runner = PiRunner.new(prompt: prompt, flags: @flags, streamer: @streamer, command: @command)
|
|
36
|
+
@runner.run
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def prompt_store
|
|
42
|
+
@prompt_store ||= PromptStore.new(root: @root)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# The orchestrator loop wired to the real environment for `letsdo <name>`:
|
|
5
|
+
#
|
|
6
|
+
# Letsdo::Loop — while the provider gives open tasks, runs the
|
|
7
|
+
# agent (one run = one task); no tasks (or the
|
|
8
|
+
# backlog is unreadable) — waits and checks again;
|
|
9
|
+
# task provider — open tasks assigned to the agent's handle
|
|
10
|
+
# (Letsdo::BacklogTasks by default);
|
|
11
|
+
# agent run — one Letsdo::Agent run per open task;
|
|
12
|
+
# signals — SIGINT/SIGTERM stop the loop: a running pi child
|
|
13
|
+
# is terminated and the process exits with 0.
|
|
14
|
+
#
|
|
15
|
+
# Waiting is interruptible: stop signals are delivered as
|
|
16
|
+
# Letsdo::Stopped raised from the trap, so the loop unwinds right away
|
|
17
|
+
# instead of waiting out the retry interval (see #on_signal).
|
|
18
|
+
#
|
|
19
|
+
# Service output goes to stderr: started, running <name> for <task>, no
|
|
20
|
+
# open tasks / backlog unavailable with the retry interval, non-zero agent
|
|
21
|
+
# exit codes, stopped.
|
|
22
|
+
class AgentLoop
|
|
23
|
+
STOP = :letsdo_stop
|
|
24
|
+
|
|
25
|
+
# @param name [String] agent name (for messages)
|
|
26
|
+
# @param handle [String] assignee handle of the agent (e.g. "@developer")
|
|
27
|
+
# @param agent [Letsdo::Agent, nil] agent to run once per task (its
|
|
28
|
+
# current runner is terminated on stop); ignored when run_one is
|
|
29
|
+
# given
|
|
30
|
+
# @param run_one [Proc, nil] callable(task) → agent exit code; by default
|
|
31
|
+
# the injected agent's #run
|
|
32
|
+
# @param task_provider [Proc] callable → Array of open tasks (empty = no
|
|
33
|
+
# tasks) or nil (backlog unreadable — pause)
|
|
34
|
+
# @param wait_seconds [Float] retry interval when there are no tasks
|
|
35
|
+
# @param sleeper [Proc, nil] callable(Float) → waiting; injectable for
|
|
36
|
+
# deterministic stops in tests (throw Letsdo::AgentLoop::STOP)
|
|
37
|
+
# @param stderr [IO] service output stream
|
|
38
|
+
# @param metrics [Object, nil] optional header-metrics facade
|
|
39
|
+
# (Letsdo::Tui::Metrics in TUI mode): receives provider_result
|
|
40
|
+
# on every provider call and run_started/run_finished around
|
|
41
|
+
# each agent run; nil in plain mode, so plain behavior is
|
|
42
|
+
# byte-identical
|
|
43
|
+
# @param debug [Boolean, nil] trace [letsdo] lines to stderr; nil = LETSDO_DEBUG
|
|
44
|
+
def initialize(name:, handle:, agent: nil, run_one: nil, task_provider:,
|
|
45
|
+
wait_seconds: 10.0, sleeper: nil, stderr: $stderr, metrics: nil, debug: nil)
|
|
46
|
+
@name = name
|
|
47
|
+
@handle = handle
|
|
48
|
+
@agent = agent
|
|
49
|
+
@run_one = run_one || ->(_task) { @agent.run }
|
|
50
|
+
@task_provider = task_provider
|
|
51
|
+
@wait_seconds = wait_seconds
|
|
52
|
+
@stderr = stderr
|
|
53
|
+
@metrics = metrics
|
|
54
|
+
@debug = debug.nil? ? ENV["LETSDO_DEBUG"] == "1" : debug
|
|
55
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Runs the loop until stopped (SIGINT/SIGTERM).
|
|
59
|
+
# Runs the loop until stopped (SIGINT/SIGTERM).
|
|
60
|
+
#
|
|
61
|
+
# Stopping is done by raising Letsdo::Stopped from the signal handler:
|
|
62
|
+
# the raise interrupts whatever the main thread is doing (reading pi
|
|
63
|
+
# output, waiting for new tasks, running the backlog CLI) and unwinds
|
|
64
|
+
# the loop. The handler itself only sends SIGTERM to a running pi group
|
|
65
|
+
# (no waits or IO — safe from a trap) and raises.
|
|
66
|
+
#
|
|
67
|
+
# @return [Integer] exit code — always 0 when stopped cleanly
|
|
68
|
+
def run
|
|
69
|
+
@loop = build_loop
|
|
70
|
+
install_signal_handlers
|
|
71
|
+
debug("loop start (agent=#{@name}, handle=#{@handle}, wait=#{@wait_seconds}s)")
|
|
72
|
+
begin
|
|
73
|
+
catch(STOP) { @loop.run }
|
|
74
|
+
rescue Letsdo::Stopped
|
|
75
|
+
debug("stopped by signal")
|
|
76
|
+
end
|
|
77
|
+
debug("loop stopped")
|
|
78
|
+
@stderr.puts("letsdo: stopped")
|
|
79
|
+
0
|
|
80
|
+
ensure
|
|
81
|
+
restore_signal_handlers
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def debug(message)
|
|
85
|
+
warn("[letsdo] loop: #{message}") if @debug
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Signal handler: sends SIGTERM to a running pi group and raises
|
|
89
|
+
# Letsdo::Stopped to interrupt the main thread. Nothing else — no IO,
|
|
90
|
+
# no sleeps (a trap writing to a busy stream deadlocks; the pi is
|
|
91
|
+
# reaped by Letsdo::PiRunner#run after the unwind).
|
|
92
|
+
def on_signal(_signum)
|
|
93
|
+
runner = @agent&.runner
|
|
94
|
+
runner&.terminate_now
|
|
95
|
+
raise Letsdo::Stopped
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def build_loop
|
|
101
|
+
@loop = Letsdo::Loop.new(
|
|
102
|
+
task_provider: wrapped_provider,
|
|
103
|
+
run_task: wrapped_run,
|
|
104
|
+
wait_seconds: @wait_seconds,
|
|
105
|
+
sleeper: @sleeper
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Messages on stderr: open task count before the batch and the wait
|
|
110
|
+
# reason (no tasks vs unreadable backlog) before every wait.
|
|
111
|
+
def wrapped_provider
|
|
112
|
+
lambda do
|
|
113
|
+
tasks = @task_provider.call
|
|
114
|
+
@metrics&.provider_result(tasks.nil? ? nil : tasks.length)
|
|
115
|
+
if tasks.nil?
|
|
116
|
+
debug("provider: backlog unavailable")
|
|
117
|
+
@stderr.puts("letsdo: backlog unavailable, retrying in #{@wait_seconds}s")
|
|
118
|
+
elsif tasks.empty?
|
|
119
|
+
debug("provider: no open tasks")
|
|
120
|
+
@stderr.puts("letsdo: no open tasks for #{@name}, retrying in #{@wait_seconds}s")
|
|
121
|
+
else
|
|
122
|
+
debug("provider: #{tasks.length} open task(s)")
|
|
123
|
+
@stderr.puts("letsdo: #{@name} has #{tasks.length} open task(s)")
|
|
124
|
+
end
|
|
125
|
+
tasks
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# One agent run per task; a non-zero exit code is noted but the loop
|
|
130
|
+
# continues. Metrics events bracket the run so the TUI can count done
|
|
131
|
+
# tasks and show the running one with its elapsed time.
|
|
132
|
+
def wrapped_run
|
|
133
|
+
lambda do |task|
|
|
134
|
+
@metrics&.run_started(task_label(task))
|
|
135
|
+
@stderr.puts("letsdo: running #{@name} for #{task_label(task)}")
|
|
136
|
+
debug("running agent for task #{task_label(task)}")
|
|
137
|
+
code = @run_one.call(task)
|
|
138
|
+
debug("agent run exit #{code}")
|
|
139
|
+
@stderr.puts("letsdo: #{@name} exited with code #{code}") if code != 0
|
|
140
|
+
ensure
|
|
141
|
+
@metrics&.run_finished
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A human-readable label of a task for messages: the id field when
|
|
146
|
+
# present, the object as-is otherwise.
|
|
147
|
+
def task_label(task)
|
|
148
|
+
id = task.respond_to?(:[]) ? task["id"] : nil
|
|
149
|
+
return id.to_s unless id.nil? || id.to_s.empty?
|
|
150
|
+
|
|
151
|
+
task.to_s
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The default waiting blocks only for the retry interval: a stop signal
|
|
155
|
+
# interrupts it as Letsdo::Stopped raised from the trap.
|
|
156
|
+
def interruptible_sleeper
|
|
157
|
+
->(seconds) { sleep(seconds) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def install_signal_handlers
|
|
161
|
+
Signal.trap("SIGINT", method(:on_signal))
|
|
162
|
+
Signal.trap("SIGTERM", method(:on_signal))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def restore_signal_handlers
|
|
166
|
+
Signal.trap("SIGINT", "DEFAULT")
|
|
167
|
+
Signal.trap("SIGTERM", "DEFAULT")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module Letsdo
|
|
8
|
+
# Task provider for Letsdo::Loop backed by the real backlog CLI:
|
|
9
|
+
#
|
|
10
|
+
# backlog task list --assignee <handle> --exclude-status Done --json
|
|
11
|
+
#
|
|
12
|
+
# Returns the list of open tasks assigned to the handle (an Array, empty
|
|
13
|
+
# when there are none), or nil when the backlog state is unreadable — the
|
|
14
|
+
# CLI is not on PATH, failed, or its output is not the expected JSON. The
|
|
15
|
+
# loop treats nil as "pause and retry, do not run the agent".
|
|
16
|
+
#
|
|
17
|
+
# The command runs in the project root (cwd), where the backlog CLI finds
|
|
18
|
+
# the backlog/ folder — the same context as a single agent run.
|
|
19
|
+
class BacklogTasks
|
|
20
|
+
# @param handle [String] assignee handle to filter by (e.g. "@developer")
|
|
21
|
+
# @param command [String] backlog CLI command (overridable for tests)
|
|
22
|
+
# @param cwd [String, nil] project root for the CLI; nil = inherit cwd
|
|
23
|
+
# @param env [Hash, nil] environment for the CLI child (nil = inherit
|
|
24
|
+
# the process environment; injected in tests to control the
|
|
25
|
+
# fake backlog scenarios)
|
|
26
|
+
def initialize(handle:, command: "backlog", cwd: nil, env: nil)
|
|
27
|
+
@handle = handle
|
|
28
|
+
@command = command
|
|
29
|
+
@cwd = cwd
|
|
30
|
+
@env = env
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Reads the open tasks once.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<Hash>, nil] open tasks; nil when the backlog is unreadable
|
|
36
|
+
def call
|
|
37
|
+
args = @env ? [@env, *command_line] : command_line
|
|
38
|
+
out, _err, status = Open3.capture3(*args, chdir: @cwd)
|
|
39
|
+
return nil unless status.success?
|
|
40
|
+
|
|
41
|
+
tasks = JSON.parse(out)["tasks"]
|
|
42
|
+
tasks.is_a?(Array) ? tasks : nil
|
|
43
|
+
rescue Errno::ENOENT, JSON::ParserError, TypeError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# [command..., task, list, --assignee <handle>, --exclude-status Done, --json]
|
|
50
|
+
def command_line
|
|
51
|
+
[
|
|
52
|
+
*Shellwords.split(@command),
|
|
53
|
+
"task", "list",
|
|
54
|
+
"--assignee", @handle,
|
|
55
|
+
"--exclude-status", "Done",
|
|
56
|
+
"--json"
|
|
57
|
+
]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|