letsdo 0.1.0 → 0.2.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/README.md +4 -4
- data/bin/letsdo +20 -12
- data/lib/letsdo/agent.rb +6 -4
- data/lib/letsdo/agent_loop/tasks.rb +62 -0
- data/lib/letsdo/agent_loop.rb +41 -126
- data/lib/letsdo/backlog_tasks.rb +10 -11
- data/lib/letsdo/capture.rb +89 -0
- data/lib/letsdo/cli/init.rb +33 -0
- data/lib/letsdo/cli/launch.rb +82 -0
- data/lib/letsdo/cli.rb +64 -124
- data/lib/letsdo/control.rb +39 -0
- data/lib/letsdo/default_prompt.rb +69 -0
- data/lib/letsdo/errors.rb +2 -12
- data/lib/letsdo/loop.rb +31 -15
- data/lib/letsdo/output_streamer/arg_summary.rb +81 -0
- data/lib/letsdo/output_streamer.rb +13 -210
- data/lib/letsdo/pi_runner/events.rb +75 -0
- data/lib/letsdo/pi_runner/process.rb +68 -0
- data/lib/letsdo/pi_runner.rb +62 -221
- data/lib/letsdo/prompt_store.rb +47 -8
- data/lib/letsdo/tui/input.rb +4 -4
- data/lib/letsdo/tui/log_buffer.rb +3 -3
- data/lib/letsdo/tui/metrics.rb +1 -1
- data/lib/letsdo/tui/renderer.rb +103 -80
- data/lib/letsdo/tui/session/keys.rb +121 -0
- data/lib/letsdo/tui/session/view.rb +89 -0
- data/lib/letsdo/tui/session.rb +40 -183
- data/lib/letsdo/tui/terminal.rb +7 -4
- data/lib/letsdo/tui.rb +6 -6
- data/lib/letsdo/version.rb +2 -2
- data/lib/letsdo.rb +19 -13
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9576e7f61d65dbf1379b3e4fcce4831f3162432b9039dc2153f7952201420091
|
|
4
|
+
data.tar.gz: 679c98fd2628270bb62cf2f35c6a9d13653dd75a3ebe48aa83abc77c3aeccd97
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a6744c21cd6681ac8fde456977962f8a1c8a79cfd8e8157091b0d3ba42848d29f982dd622880e7b7c03f82ec065d014d56da1b61a2f9cd954f3693fc2218f21
|
|
7
|
+
data.tar.gz: 2925952a1d86f40fc54d1e20e57c83cec89e0f07579c2a0336e2cc370b94c462516c9686c981aedc4b456120ec42fcd51859386685be342c8999ca5bcc3d53d7
|
data/README.md
CHANGED
|
@@ -70,9 +70,9 @@ generation, any repeatable task flow you can express as assignee + prompt.
|
|
|
70
70
|
and stops instantly on `Ctrl+C`.
|
|
71
71
|
- **Streaming output** — agent text streams to stdout as it is generated;
|
|
72
72
|
service and tool lines go to stderr with a shared `HH:MM:SS` prefix:
|
|
73
|
-
tool start (`⚙ name: args`)
|
|
74
|
-
(`✓/✖ name:
|
|
75
|
-
|
|
73
|
+
tool start (`⚙ name: args`) and completion with duration
|
|
74
|
+
(`✓/✖ name: done/error (3s)`). Tool result bodies are not printed, so
|
|
75
|
+
the stream stays readable while the agent works.
|
|
76
76
|
- **`--version` / `--help`** — `Letsdo::VERSION` and usage, exit 0.
|
|
77
77
|
- **Available as a library** — `require "letsdo"` exposes the
|
|
78
78
|
`Letsdo` module (`Letsdo::VERSION`, `Letsdo::PromptStore`, `Letsdo::Agent`,
|
|
@@ -99,7 +99,7 @@ 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.2.0.gem
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
or run it straight from the checkout without installing:
|
data/bin/letsdo
CHANGED
|
@@ -4,18 +4,26 @@
|
|
|
4
4
|
#
|
|
5
5
|
# letsdo — a local agent worker for Backlog.md/markdown tasks.
|
|
6
6
|
#
|
|
7
|
-
# A thin wrapper over Letsdo::CLI: runs an agent by name
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# A thin wrapper over Letsdo::CLI: runs an agent by name as an orchestrator
|
|
8
|
+
# loop — all open tasks assigned to the agent are done one after another
|
|
9
|
+
# (one agent run = one task), then the loop waits for new ones until stopped
|
|
10
|
+
# with SIGINT/SIGTERM (clean exit, code 0). The agent's prompt comes from
|
|
11
|
+
# agents/<name>.md in the project root; when that file is missing the agent
|
|
12
|
+
# still starts on the built-in default prompt (Letsdo::DefaultPrompt) and a
|
|
13
|
+
# one-time notification is printed on stderr naming the looked-up path and
|
|
14
|
+
# the 'letsdo <name> --init' hint. --init creates agents/<name>.md with the
|
|
15
|
+
# starter default prompt (letsdo itself never runs the agent in that case,
|
|
16
|
+
# so the prompt can be customized before the first run).
|
|
12
17
|
#
|
|
13
18
|
# Usage:
|
|
14
|
-
# ./bin/letsdo <name>
|
|
15
|
-
# ./bin/letsdo --
|
|
16
|
-
#
|
|
17
|
-
# ./bin/letsdo
|
|
18
|
-
# ./bin/letsdo --
|
|
19
|
+
# ./bin/letsdo <name> # run the <name> agent in the loop (exit 0 on stop)
|
|
20
|
+
# ./bin/letsdo <name> --init # create agents/<name>.md with the starter
|
|
21
|
+
# # default prompt, never run the agent (exit 0)
|
|
22
|
+
# ./bin/letsdo --init <name> # same, flag-first form
|
|
23
|
+
# ./bin/letsdo --version # version, exit 0
|
|
24
|
+
# ./bin/letsdo --help # help, exit 0
|
|
25
|
+
# ./bin/letsdo # usage and agent list, exit 1
|
|
26
|
+
# ./bin/letsdo --badopt # "unknown option" + usage, exit 1
|
|
19
27
|
#
|
|
20
28
|
# Environment:
|
|
21
29
|
# LETSDO_ROOT project root with agents/ (default — current folder)
|
|
@@ -28,6 +36,6 @@
|
|
|
28
36
|
# A new agent = a new agents/<name>.md file, no code changes needed.
|
|
29
37
|
#
|
|
30
38
|
|
|
31
|
-
require_relative
|
|
39
|
+
require_relative '../lib/letsdo'
|
|
32
40
|
|
|
33
|
-
exit Letsdo::CLI.run(ARGV)
|
|
41
|
+
exit Letsdo::CLI.run(ARGV)
|
data/lib/letsdo/agent.rb
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Letsdo
|
|
4
4
|
# A single agent run: reads the prompt from agents/<name>.md by agent name
|
|
5
|
-
#
|
|
5
|
+
# (falling back to the built-in default prompt when the file is missing)
|
|
6
|
+
# and runs pi with that prompt. Returns the pi exit code. There are no
|
|
7
|
+
# unknown agents — every name runs, with the file prompt when present and
|
|
8
|
+
# with Letsdo::DefaultPrompt::TEXT otherwise.
|
|
6
9
|
#
|
|
7
10
|
# This is the logic of one bin/agent run: a prompt store + an output
|
|
8
11
|
# streamer + a pi runner. The orchestrator (a loop while tasks exist)
|
|
@@ -29,9 +32,8 @@ module Letsdo
|
|
|
29
32
|
# Runs the agent once.
|
|
30
33
|
#
|
|
31
34
|
# @return [Integer] pi exit code
|
|
32
|
-
# @raise [UnknownAgentError] if the agent is not in agents/
|
|
33
35
|
def run
|
|
34
|
-
prompt = prompt_store.read(@name)
|
|
36
|
+
prompt = prompt_store.read(@name) || Letsdo::DefaultPrompt::TEXT
|
|
35
37
|
@runner = PiRunner.new(prompt: prompt, flags: @flags, streamer: @streamer, command: @command)
|
|
36
38
|
@runner.run
|
|
37
39
|
end
|
|
@@ -42,4 +44,4 @@ module Letsdo
|
|
|
42
44
|
@prompt_store ||= PromptStore.new(root: @root)
|
|
43
45
|
end
|
|
44
46
|
end
|
|
45
|
-
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Provider reporting and per-task run wrapping for AgentLoop.
|
|
5
|
+
module AgentLoopTasks
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def wrapped_provider
|
|
9
|
+
lambda do
|
|
10
|
+
tasks = @task_provider.call
|
|
11
|
+
@metrics&.provider_result(tasks&.length)
|
|
12
|
+
report_provider(tasks)
|
|
13
|
+
tasks
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def report_provider(tasks)
|
|
18
|
+
if tasks.nil?
|
|
19
|
+
debug('provider: backlog unavailable')
|
|
20
|
+
@stderr.puts("letsdo: backlog unavailable, retrying in #{@wait_seconds}s")
|
|
21
|
+
elsif tasks.empty?
|
|
22
|
+
debug('provider: no open tasks')
|
|
23
|
+
@stderr.puts("letsdo: no open tasks for #{@name}, retrying in #{@wait_seconds}s")
|
|
24
|
+
else
|
|
25
|
+
debug("provider: #{tasks.length} open task(s)")
|
|
26
|
+
@stderr.puts("letsdo: #{@name} has #{tasks.length} open task(s)")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def wrapped_run
|
|
31
|
+
lambda do |task|
|
|
32
|
+
wait_while_paused
|
|
33
|
+
run_one_task(task)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_one_task(task)
|
|
38
|
+
label = task_label(task)
|
|
39
|
+
@metrics&.run_started(label)
|
|
40
|
+
@stderr.puts("letsdo: running #{@name} for #{label}")
|
|
41
|
+
debug("running agent for task #{label}")
|
|
42
|
+
code = @run_one.call(task)
|
|
43
|
+
debug("agent run exit #{code}")
|
|
44
|
+
@stderr.puts("letsdo: #{@name} exited with code #{code}") if code != 0
|
|
45
|
+
ensure
|
|
46
|
+
@metrics&.run_finished
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def task_label(task)
|
|
50
|
+
id = task.respond_to?(:[]) ? task['id'] : nil
|
|
51
|
+
return id.to_s unless id.nil? || id.to_s.empty?
|
|
52
|
+
|
|
53
|
+
task.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wait_while_paused
|
|
57
|
+
return unless @pause_gate
|
|
58
|
+
|
|
59
|
+
@sleeper.call(AgentLoop::PAUSE_POLL_SECONDS) while @pause_gate.paused?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/letsdo/agent_loop.rb
CHANGED
|
@@ -1,81 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'agent_loop/tasks'
|
|
4
|
+
|
|
3
5
|
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.
|
|
6
|
+
# The orchestrator loop wired to the real environment for `letsdo <name>`.
|
|
22
7
|
class AgentLoop
|
|
8
|
+
include AgentLoopTasks
|
|
9
|
+
|
|
23
10
|
STOP = :letsdo_stop
|
|
11
|
+
PAUSE_POLL_SECONDS = 0.05
|
|
24
12
|
|
|
25
|
-
|
|
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)
|
|
13
|
+
def initialize(name:, handle:, task_provider:, **opts)
|
|
46
14
|
@name = name
|
|
47
15
|
@handle = handle
|
|
48
|
-
@agent = agent
|
|
49
|
-
@run_one = run_one || ->(_task) { @agent.run }
|
|
50
16
|
@task_provider = task_provider
|
|
51
|
-
|
|
52
|
-
@stderr = stderr
|
|
53
|
-
@metrics = metrics
|
|
54
|
-
@debug = debug.nil? ? ENV["LETSDO_DEBUG"] == "1" : debug
|
|
55
|
-
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
17
|
+
assign_opts(opts)
|
|
56
18
|
end
|
|
57
19
|
|
|
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
20
|
def run
|
|
69
21
|
@loop = build_loop
|
|
70
22
|
install_signal_handlers
|
|
71
23
|
debug("loop start (agent=#{@name}, handle=#{@handle}, wait=#{@wait_seconds}s)")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
debug("stopped by signal")
|
|
76
|
-
end
|
|
77
|
-
debug("loop stopped")
|
|
78
|
-
@stderr.puts("letsdo: stopped")
|
|
24
|
+
run_until_stopped
|
|
25
|
+
debug('loop stopped')
|
|
26
|
+
@stderr.puts('letsdo: stopped')
|
|
79
27
|
0
|
|
80
28
|
ensure
|
|
81
29
|
restore_signal_handlers
|
|
@@ -85,86 +33,53 @@ module Letsdo
|
|
|
85
33
|
warn("[letsdo] loop: #{message}") if @debug
|
|
86
34
|
end
|
|
87
35
|
|
|
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
36
|
def on_signal(_signum)
|
|
93
|
-
|
|
94
|
-
runner&.terminate_now
|
|
37
|
+
@agent&.runner&.terminate_now
|
|
95
38
|
raise Letsdo::Stopped
|
|
96
39
|
end
|
|
97
40
|
|
|
98
41
|
private
|
|
99
42
|
|
|
100
|
-
def
|
|
101
|
-
@
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
43
|
+
def assign_opts(opts)
|
|
44
|
+
@agent = opts[:agent]
|
|
45
|
+
@run_one = opts[:run_one] || ->(_task) { @agent.run }
|
|
46
|
+
@wait_seconds = opts.fetch(:wait_seconds, 10.0)
|
|
47
|
+
@stderr = opts.fetch(:stderr, $stderr)
|
|
48
|
+
assign_control_opts(opts)
|
|
107
49
|
end
|
|
108
50
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
51
|
+
def assign_control_opts(opts)
|
|
52
|
+
@metrics = opts[:metrics]
|
|
53
|
+
@pause_gate = opts[:pause_gate]
|
|
54
|
+
@debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
|
|
55
|
+
@sleeper = opts[:sleeper] || ->(seconds) { sleep(seconds) }
|
|
127
56
|
end
|
|
128
57
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
58
|
+
def run_until_stopped
|
|
59
|
+
catch(STOP) { @loop.run }
|
|
60
|
+
rescue Letsdo::Stopped
|
|
61
|
+
debug('stopped by signal')
|
|
143
62
|
end
|
|
144
63
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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) }
|
|
64
|
+
def build_loop
|
|
65
|
+
Letsdo::Loop.new(
|
|
66
|
+
task_provider: wrapped_provider,
|
|
67
|
+
run_task: wrapped_run,
|
|
68
|
+
wait_seconds: @wait_seconds,
|
|
69
|
+
sleeper: @sleeper
|
|
70
|
+
)
|
|
158
71
|
end
|
|
159
72
|
|
|
160
73
|
def install_signal_handlers
|
|
161
|
-
Signal.trap(
|
|
162
|
-
Signal.trap(
|
|
74
|
+
Signal.trap('SIGINT', method(:on_signal))
|
|
75
|
+
Signal.trap('SIGTERM', method(:on_signal))
|
|
76
|
+
Signal.trap('SIGHUP', method(:on_signal))
|
|
163
77
|
end
|
|
164
78
|
|
|
165
79
|
def restore_signal_handlers
|
|
166
|
-
Signal.trap(
|
|
167
|
-
Signal.trap(
|
|
80
|
+
Signal.trap('SIGINT', 'DEFAULT')
|
|
81
|
+
Signal.trap('SIGTERM', 'DEFAULT')
|
|
82
|
+
Signal.trap('SIGHUP', 'DEFAULT')
|
|
168
83
|
end
|
|
169
84
|
end
|
|
170
|
-
end
|
|
85
|
+
end
|
data/lib/letsdo/backlog_tasks.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require "shellwords"
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'shellwords'
|
|
6
5
|
|
|
7
6
|
module Letsdo
|
|
8
7
|
# Task provider for Letsdo::Loop backed by the real backlog CLI:
|
|
@@ -23,7 +22,7 @@ module Letsdo
|
|
|
23
22
|
# @param env [Hash, nil] environment for the CLI child (nil = inherit
|
|
24
23
|
# the process environment; injected in tests to control the
|
|
25
24
|
# fake backlog scenarios)
|
|
26
|
-
def initialize(handle:, command:
|
|
25
|
+
def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
|
|
27
26
|
@handle = handle
|
|
28
27
|
@command = command
|
|
29
28
|
@cwd = cwd
|
|
@@ -35,10 +34,10 @@ module Letsdo
|
|
|
35
34
|
# @return [Array<Hash>, nil] open tasks; nil when the backlog is unreadable
|
|
36
35
|
def call
|
|
37
36
|
args = @env ? [@env, *command_line] : command_line
|
|
38
|
-
out, _err, status =
|
|
37
|
+
out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
|
|
39
38
|
return nil unless status.success?
|
|
40
39
|
|
|
41
|
-
tasks = JSON.parse(out)[
|
|
40
|
+
tasks = JSON.parse(out)['tasks']
|
|
42
41
|
tasks.is_a?(Array) ? tasks : nil
|
|
43
42
|
rescue Errno::ENOENT, JSON::ParserError, TypeError
|
|
44
43
|
nil
|
|
@@ -50,11 +49,11 @@ module Letsdo
|
|
|
50
49
|
def command_line
|
|
51
50
|
[
|
|
52
51
|
*Shellwords.split(@command),
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
'task', 'list',
|
|
53
|
+
'--assignee', @handle,
|
|
54
|
+
'--exclude-status', 'Done',
|
|
55
|
+
'--json'
|
|
57
56
|
]
|
|
58
57
|
end
|
|
59
58
|
end
|
|
60
|
-
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Captures a child command's stdout and stderr (like Open3.capture3)
|
|
5
|
+
# while tolerating an asynchronous Letsdo::Stopped that lands mid-call —
|
|
6
|
+
# a TUI quit or a signal raised into the main thread. Open3's capture3
|
|
7
|
+
# is not interruption-safe: its ensure closes the pipes while its reader
|
|
8
|
+
# threads are still blocked in IO#read, and each reader then dies with
|
|
9
|
+
# "stream closed in another thread" and prints a report_on_exception dump.
|
|
10
|
+
#
|
|
11
|
+
# Here the reader threads report no exception and rescue that benign
|
|
12
|
+
# IOError, and the child is reaped even when the wait is interrupted, so
|
|
13
|
+
# a stop can never leave dumps or zombies. Other reader errors still
|
|
14
|
+
# re-raise through Thread#value, keeping real failures visible.
|
|
15
|
+
class Capture
|
|
16
|
+
# @param args [Array] command arguments for Process.spawn (an optional
|
|
17
|
+
# leading environment hash is preserved)
|
|
18
|
+
# @param chdir [String, nil] working directory for the child (nil =
|
|
19
|
+
# inherit the current one)
|
|
20
|
+
def initialize(*args, chdir: nil)
|
|
21
|
+
@args = args
|
|
22
|
+
@chdir = chdir
|
|
23
|
+
@out_r, @out_w = IO.pipe
|
|
24
|
+
@err_r, @err_w = IO.pipe
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Runs the command.
|
|
28
|
+
#
|
|
29
|
+
# @return [Array(String, String, Process::Status)] stdout, stderr, status
|
|
30
|
+
def run
|
|
31
|
+
@pid = spawn_child
|
|
32
|
+
@out_w.close
|
|
33
|
+
@err_w.close
|
|
34
|
+
readers = [quiet_reader(@out_r), quiet_reader(@err_r)]
|
|
35
|
+
status = wait_and_reap(@pid)
|
|
36
|
+
[readers[0].value, readers[1].value, status]
|
|
37
|
+
ensure
|
|
38
|
+
cleanup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def spawn_child
|
|
44
|
+
opts = { out: @out_w, err: @err_w }
|
|
45
|
+
opts[:chdir] = @chdir if @chdir
|
|
46
|
+
Process.spawn(*@args, **opts)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A stdout/stderr reader thread that stays silent when its pipe is
|
|
50
|
+
# closed while still reading (the stop path) and returns nil instead.
|
|
51
|
+
def quiet_reader(pipe)
|
|
52
|
+
Thread.new do
|
|
53
|
+
Thread.current.report_on_exception = false
|
|
54
|
+
pipe.read
|
|
55
|
+
rescue IOError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def wait_and_reap(pid)
|
|
61
|
+
_, status = Process.wait2(pid)
|
|
62
|
+
status
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Reaps pid if the normal wait never ran (the wait was interrupted by a
|
|
66
|
+
# stop); a no-op when the child was already reaped.
|
|
67
|
+
def reattach_reaper(pid)
|
|
68
|
+
return unless pid
|
|
69
|
+
|
|
70
|
+
Process.detach(pid)
|
|
71
|
+
rescue Errno::ECHILD
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cleanup
|
|
76
|
+
close_quietly(@out_w)
|
|
77
|
+
close_quietly(@err_w)
|
|
78
|
+
close_quietly(@out_r)
|
|
79
|
+
close_quietly(@err_r)
|
|
80
|
+
reattach_reaper(@pid)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def close_quietly(io)
|
|
84
|
+
io.close unless io.nil? || io.closed?
|
|
85
|
+
rescue IOError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# The --init scaffold command (TASK-44): `letsdo <name> --init` and
|
|
5
|
+
# `letsdo --init <name>` create agents/<name>.md with the starter default
|
|
6
|
+
# prompt (Letsdo::DefaultPrompt::TEXT) so the agent can be customized
|
|
7
|
+
# later. The agent itself is never started. PromptStore#create_agent
|
|
8
|
+
# already guarantees no overwrite and nothing outside agents/ — this
|
|
9
|
+
# module only maps the results to messages and exit codes.
|
|
10
|
+
module CLIInit
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Creates agents/<name>.md. Success prints "created <path>" (stdout),
|
|
14
|
+
# exit 0; an unsafe name or an already existing file prints the error
|
|
15
|
+
# on stderr, exit 1; a missing name falls back to usage, exit 1.
|
|
16
|
+
def init_command(argv)
|
|
17
|
+
name = argv[0] == '--init' ? argv[1] : argv[0]
|
|
18
|
+
return usage_error if name.nil?
|
|
19
|
+
return init_error("invalid agent name: #{name}") if PromptStore.unsafe_name?(name)
|
|
20
|
+
|
|
21
|
+
store = PromptStore.new(root: @root)
|
|
22
|
+
return init_error("agents/#{name}.md already exists") unless store.create_agent(name, Letsdo::DefaultPrompt::TEXT)
|
|
23
|
+
|
|
24
|
+
@stdout.puts("created #{store.agent_path(name)}")
|
|
25
|
+
0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def init_error(message)
|
|
29
|
+
@stderr.puts("letsdo: #{message}")
|
|
30
|
+
1
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|