letsdo 0.1.0 → 0.3.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 +11 -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 +52 -124
- data/lib/letsdo/backlog_tasks.rb +10 -11
- data/lib/letsdo/capture.rb +104 -0
- data/lib/letsdo/cli/init.rb +33 -0
- data/lib/letsdo/cli/launch.rb +87 -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 +21 -214
- 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 +87 -0
- data/lib/letsdo/tui/session.rb +48 -181
- data/lib/letsdo/tui/terminal.rb +26 -8
- data/lib/letsdo/tui.rb +6 -6
- data/lib/letsdo/version.rb +2 -2
- data/lib/letsdo/watcher.rb +188 -0
- data/lib/letsdo.rb +20 -13
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f15aee491b4d0954c628e52566908e6b7ea35428c347a79822f5781198ea4be9
|
|
4
|
+
data.tar.gz: 71b47328ea44b224cb250e4bb8d0e58c052ddd054cd950fa1b058c85933f6a91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eab4462fe5a7bec0981c9b2a803a02063d1714cc049f51f5add4053f80c302b917491465dda6d90aa66a59fb668b33a5d2166ea95589d317e1958a3756564f1c
|
|
7
|
+
data.tar.gz: 5f1b243e5bce00874965683d68bebf22c2c9cec992e6f8ab1e825db3a0796781a47bf51be934b5c8c5409c7ad1cf85a83d8a872c807b8d4e5a6023c888f8ca73
|
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,9 +99,16 @@ 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. Install without documentation to skip the
|
|
109
|
+
> hook (`gem install letsdo-0.3.0.gem --no-document`), or install a matching
|
|
110
|
+
> rbs first (`gem install rbs -v '>= 4.0.0'`).
|
|
111
|
+
|
|
105
112
|
or run it straight from the checkout without installing:
|
|
106
113
|
|
|
107
114
|
```sh
|
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,170 +1,98 @@
|
|
|
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
|
|
29
|
+
close_watcher
|
|
81
30
|
restore_signal_handlers
|
|
82
31
|
end
|
|
83
32
|
|
|
33
|
+
def close_watcher
|
|
34
|
+
@watcher&.close
|
|
35
|
+
end
|
|
36
|
+
|
|
84
37
|
def debug(message)
|
|
85
38
|
warn("[letsdo] loop: #{message}") if @debug
|
|
86
39
|
end
|
|
87
40
|
|
|
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
41
|
def on_signal(_signum)
|
|
93
|
-
|
|
94
|
-
runner&.terminate_now
|
|
42
|
+
@agent&.runner&.terminate_now
|
|
95
43
|
raise Letsdo::Stopped
|
|
96
44
|
end
|
|
97
45
|
|
|
98
46
|
private
|
|
99
47
|
|
|
100
|
-
def
|
|
101
|
-
@
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
48
|
+
def assign_opts(opts)
|
|
49
|
+
@agent = opts[:agent]
|
|
50
|
+
@run_one = opts[:run_one] || ->(_task) { @agent.run }
|
|
51
|
+
@wait_seconds = opts.fetch(:wait_seconds, 10.0)
|
|
52
|
+
@stderr = opts.fetch(:stderr, $stderr)
|
|
53
|
+
assign_control_opts(opts)
|
|
107
54
|
end
|
|
108
55
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
56
|
+
def assign_control_opts(opts)
|
|
57
|
+
@metrics = opts[:metrics]
|
|
58
|
+
@pause_gate = opts[:pause_gate]
|
|
59
|
+
@debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
|
|
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) }
|
|
127
65
|
end
|
|
128
66
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
67
|
+
def watcher_sleeper
|
|
68
|
+
@watcher && ->(seconds) { @watcher.wait(seconds) }
|
|
143
69
|
end
|
|
144
70
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return id.to_s unless id.nil? || id.to_s.empty?
|
|
150
|
-
|
|
151
|
-
task.to_s
|
|
71
|
+
def run_until_stopped
|
|
72
|
+
catch(STOP) { @loop.run }
|
|
73
|
+
rescue Letsdo::Stopped
|
|
74
|
+
debug('stopped by signal')
|
|
152
75
|
end
|
|
153
76
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
77
|
+
def build_loop
|
|
78
|
+
Letsdo::Loop.new(
|
|
79
|
+
task_provider: wrapped_provider,
|
|
80
|
+
run_task: wrapped_run,
|
|
81
|
+
wait_seconds: @wait_seconds,
|
|
82
|
+
sleeper: @sleeper
|
|
83
|
+
)
|
|
158
84
|
end
|
|
159
85
|
|
|
160
86
|
def install_signal_handlers
|
|
161
|
-
Signal.trap(
|
|
162
|
-
Signal.trap(
|
|
87
|
+
Signal.trap('SIGINT', method(:on_signal))
|
|
88
|
+
Signal.trap('SIGTERM', method(:on_signal))
|
|
89
|
+
Signal.trap('SIGHUP', method(:on_signal))
|
|
163
90
|
end
|
|
164
91
|
|
|
165
92
|
def restore_signal_handlers
|
|
166
|
-
Signal.trap(
|
|
167
|
-
Signal.trap(
|
|
93
|
+
Signal.trap('SIGINT', 'DEFAULT')
|
|
94
|
+
Signal.trap('SIGTERM', 'DEFAULT')
|
|
95
|
+
Signal.trap('SIGHUP', 'DEFAULT')
|
|
168
96
|
end
|
|
169
97
|
end
|
|
170
|
-
end
|
|
98
|
+
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,104 @@
|
|
|
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
|
+
@reaped = true
|
|
37
|
+
[readers[0].value, readers[1].value, status]
|
|
38
|
+
ensure
|
|
39
|
+
cleanup
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def spawn_child
|
|
45
|
+
opts = { out: @out_w, err: @err_w, pgroup: true }
|
|
46
|
+
opts[:chdir] = @chdir if @chdir
|
|
47
|
+
Process.spawn(*@args, **opts)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A stdout/stderr reader thread that stays silent when its pipe is
|
|
51
|
+
# closed while still reading (the stop path) and returns nil instead.
|
|
52
|
+
def quiet_reader(pipe)
|
|
53
|
+
Thread.new do
|
|
54
|
+
Thread.current.report_on_exception = false
|
|
55
|
+
pipe.read
|
|
56
|
+
rescue IOError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def wait_and_reap(pid)
|
|
62
|
+
_, status = Process.wait2(pid)
|
|
63
|
+
status
|
|
64
|
+
end
|
|
65
|
+
|
|
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)
|
|
72
|
+
return unless pid
|
|
73
|
+
|
|
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)
|
|
86
|
+
rescue Errno::ECHILD
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cleanup
|
|
91
|
+
close_quietly(@out_w)
|
|
92
|
+
close_quietly(@err_w)
|
|
93
|
+
close_quietly(@out_r)
|
|
94
|
+
close_quietly(@err_r)
|
|
95
|
+
terminate_and_reap(@pid) unless @reaped
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def close_quietly(io)
|
|
99
|
+
io.close unless io.nil? || io.closed?
|
|
100
|
+
rescue IOError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
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
|