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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a847d927396a19813b8b643486bddabe1f9264f9ba38c6618110e24cc67b55d
4
- data.tar.gz: 6d8ad205231a4e833694d7471efffe40dfd8a1f5bea89c45fdf1babb099fbdfa
3
+ metadata.gz: 9576e7f61d65dbf1379b3e4fcce4831f3162432b9039dc2153f7952201420091
4
+ data.tar.gz: 679c98fd2628270bb62cf2f35c6a9d13653dd75a3ebe48aa83abc77c3aeccd97
5
5
  SHA512:
6
- metadata.gz: 39ba23f90a1c8c4add2bce03e9443071a7cb57dd0458c64e7be96ec0c803ff098cea7fbd0d4a9d37eb94050aa470e142f7820643d6738be759176424e2387b98
7
- data.tar.gz: 6bc4f0e7f03e01963bfb44d305e0ca76082727643a061eb042a58f71f49fce415a4616a4e6374c7461575ec28458f66c51fa864abc81048751367cf2ba4549a8
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`), 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,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.1.0.gem
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 (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,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
- # @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
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
- runner = @agent&.runner
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 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
- )
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
- # 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
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
- # 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
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
- # 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) }
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("SIGINT", method(:on_signal))
162
- Signal.trap("SIGTERM", method(:on_signal))
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("SIGINT", "DEFAULT")
167
- Signal.trap("SIGTERM", "DEFAULT")
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
@@ -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,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