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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a847d927396a19813b8b643486bddabe1f9264f9ba38c6618110e24cc67b55d
4
- data.tar.gz: 6d8ad205231a4e833694d7471efffe40dfd8a1f5bea89c45fdf1babb099fbdfa
3
+ metadata.gz: f15aee491b4d0954c628e52566908e6b7ea35428c347a79822f5781198ea4be9
4
+ data.tar.gz: 71b47328ea44b224cb250e4bb8d0e58c052ddd054cd950fa1b058c85933f6a91
5
5
  SHA512:
6
- metadata.gz: 39ba23f90a1c8c4add2bce03e9443071a7cb57dd0458c64e7be96ec0c803ff098cea7fbd0d4a9d37eb94050aa470e142f7820643d6738be759176424e2387b98
7
- data.tar.gz: 6bc4f0e7f03e01963bfb44d305e0ca76082727643a061eb042a58f71f49fce415a4616a4e6374c7461575ec28458f66c51fa864abc81048751367cf2ba4549a8
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`), completion with duration
74
- (`✓/✖ name: … (3s)`), indented results (trimmed with a summary note
75
- when large), and error results marked (`✖ Error: ...`).
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.1.0.gem
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 (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).
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> # 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
+ # ./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 "../lib/letsdo"
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
- # and runs pi with that prompt. Returns the pi exit code.
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
@@ -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
- # @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)
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
- @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) }
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
- 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")
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
- runner = @agent&.runner
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 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
- )
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
- # 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
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
- # 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
67
+ def watcher_sleeper
68
+ @watcher && ->(seconds) { @watcher.wait(seconds) }
143
69
  end
144
70
 
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
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
- # 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) }
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("SIGINT", method(:on_signal))
162
- Signal.trap("SIGTERM", method(:on_signal))
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("SIGINT", "DEFAULT")
167
- Signal.trap("SIGTERM", "DEFAULT")
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
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "open3"
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: "backlog", cwd: nil, env: nil)
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 = Open3.capture3(*args, chdir: @cwd)
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)["tasks"]
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
- "task", "list",
54
- "--assignee", @handle,
55
- "--exclude-status", "Done",
56
- "--json"
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