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 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
+ [![CI](https://github.com/sergio-fry/letsdo/actions/workflows/ci.yml/badge.svg)](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)
@@ -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