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.
data/lib/letsdo/cli.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'shellwords'
4
+ require_relative 'cli/launch'
5
+ require_relative 'cli/init'
4
6
 
5
7
  module Letsdo
6
8
  # Command-line argument parsing and running an agent orchestrator loop.
@@ -10,46 +12,26 @@ module Letsdo
10
12
  # letsdo --version — version, exit code 0;
11
13
  # letsdo --help — usage, exit code 0;
12
14
  # letsdo <name> — run the <name> agent in the orchestrator
13
- # loop until SIGINT/SIGTERM: all open
14
- # tasks assigned to the agent are done
15
- # one run per task, the loop waits for
16
- # new ones; clean exit code 0;
17
- # letsdo <unknown name> "Unknown agent: <name>" + list,
18
- # exit code 1.
19
- # letsdo <unknown option> "letsdo: unknown option: X" + usage,
20
- # exit code 1.
21
- #
22
- # Environment:
23
- # LETSDO_ROOT project root (agents/ lives there); default — pwd.
24
- # LETSDO_PI_FLAGS extra pi flags (split on whitespace; if unset —
25
- # AGENT_PI_FLAGS is used for bin/agent
26
- # compatibility).
27
- # LETSDO_PI_COMMAND the pi command (default "pi"); overridable for
28
- # tests/fake pi.
29
- # AGENT_ASSIGNEE_HANDLE the agent's backlog assignee handle; default
30
- # "@<name>" (the one rule: handle = name).
31
- # LETSDO_WAIT_SECONDS retry interval when no tasks are open (if
32
- # unset — AGENT_WAIT_SECONDS, default 10).
33
- # LETSDO_BACKLOG_COMMAND the backlog CLI command (default "backlog");
34
- # overridable for tests/fake backlog.
15
+ # loop until SIGINT/SIGTERM;
16
+ # letsdo <name> (no prompt) — same, but on the built-in default prompt;
17
+ # one-time notification on stderr
18
+ # (path checked + 'letsdo <name> --init' hint);
19
+ # letsdo <name> --init create agents/<name>.md with the starter
20
+ # default prompt, never runs the agent;
21
+ # letsdo --init <name> same, flag-first form;
22
+ # letsdo <unknown option> — "letsdo: unknown option: X" + usage, exit 1.
35
23
  class CLI
24
+ include CLILaunch
25
+ include CLIInit
26
+
36
27
  USAGE = 'Usage: letsdo <agent_name>'
37
28
  AGENTS_HEADER = 'Available agents:'
38
29
  DEFAULT_WAIT_SECONDS = 10.0
39
30
 
40
- # @param argv [Array<String>] command-line arguments
41
- # @param env [Hash] process environment (LETSDO_ROOT, LETSDO_PI_FLAGS,
42
- # AGENT_PI_FLAGS, AGENT_ASSIGNEE_HANDLE, LETSDO_WAIT_SECONDS,
43
- # LETSDO_BACKLOG_COMMAND); injected in tests
44
- # @param stdout [IO] stream for normal output (usage, --help, list)
45
- # @param stderr [IO] stream for service output
46
- # @param stdin [IO] keyboard stream (the TUI reads keys from it)
47
- # @param sleeper [Proc, nil] waiting procedure for the loop (callable
48
- # with the interval); injected in tests for deterministic stops
49
- # @return [Integer] exit code: 0 — success (incl. loop stop), 1 — error,
50
- # otherwise — pi exit code
51
- def self.run(argv, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin, sleeper: nil)
52
- new(env: env, stdout: stdout, stderr: stderr, stdin: stdin, sleeper: sleeper).run(argv)
31
+ def self.run(argv, **opts)
32
+ new(env: opts.fetch(:env, ENV), stdout: opts.fetch(:stdout, $stdout),
33
+ stderr: opts.fetch(:stderr, $stderr), stdin: opts.fetch(:stdin, $stdin),
34
+ sleeper: opts[:sleeper]).run(argv)
53
35
  end
54
36
 
55
37
  def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
@@ -61,103 +43,66 @@ module Letsdo
61
43
  @root = env.fetch('LETSDO_ROOT', Dir.pwd)
62
44
  end
63
45
 
64
- # @param argv [Array<String>] command-line arguments
65
- # @return [Integer] exit code
66
46
  def run(argv)
67
47
  arg = argv[0]
68
- case arg
69
- when '--version', '-v'
70
- @stdout.puts(VERSION)
71
- 0
72
- when '--help', '-h'
73
- print_usage(@stdout)
74
- 0
75
- when nil
76
- print_usage(@stderr)
77
- 1
78
- else
79
- if arg.start_with?('-')
80
- @stderr.puts("letsdo: unknown option: #{arg}")
81
- print_usage(@stderr)
82
- 1
83
- else
84
- run_agent(arg)
85
- end
86
- end
48
+ return print_version if version_flag?(arg)
49
+ return print_help if help_flag?(arg)
50
+ return usage_error if arg.nil?
51
+ return init_command(argv) if argv.include?('--init')
52
+ return unknown_option(arg) if arg.start_with?('-')
53
+
54
+ run_agent(arg)
87
55
  end
88
56
 
89
57
  private
90
58
 
91
- def run_agent(name)
92
- # The prompt must exist before the loop starts: an unknown agent fails
93
- # fast (exit 1) instead of spinning in the loop.
94
- PromptStore.new(root: @root).read(name)
95
-
96
- if tui?
97
- run_agent_tui(name)
98
- else
99
- run_agent_plain(name)
100
- end
101
- rescue UnknownAgentError => e
102
- @stderr.puts(e.message)
103
- print_agents
59
+ def version_flag?(arg)
60
+ ['--version', '-v'].include?(arg)
61
+ end
62
+
63
+ def help_flag?(arg)
64
+ ['--help', '-h'].include?(arg)
65
+ end
66
+
67
+ def print_version
68
+ @stdout.puts(VERSION)
69
+ 0
70
+ end
71
+
72
+ def print_help
73
+ print_usage(@stdout)
74
+ 0
75
+ end
76
+
77
+ def usage_error
78
+ print_usage(@stderr)
79
+ 1
80
+ end
81
+
82
+ def unknown_option(arg)
83
+ @stderr.puts("letsdo: unknown option: #{arg}")
84
+ print_usage(@stderr)
104
85
  1
105
86
  end
106
87
 
107
- # The non-interactive path: the plain line-stream output, byte-identical
108
- # to the pre-TUI behavior (pipes, CI, tests, TERM=dumb).
109
- def run_agent_plain(name)
110
- streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
111
- agent = Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
112
- command: pi_command)
113
- handle = assignee_handle(name)
114
- provider = BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
115
- env: ENV.to_h.merge(@env))
116
- loop = AgentLoop.new(name: name, handle: handle, agent: agent,
117
- task_provider: -> { provider.call },
118
- wait_seconds: wait_seconds, sleeper: @sleeper, stderr: @stderr)
119
- loop.run
120
- end
121
-
122
- # The interactive path: full-screen TUI over the same orchestrator loop.
123
- # The streamer and the loop's service messages land in one combined log
124
- # buffer; the loop driver feeds the header metrics facade; the session
125
- # controller renders everything from a background input thread and
126
- # restores the terminal on every exit path. The loop itself runs on the
127
- # calling thread exactly as in plain mode.
128
- def run_agent_tui(name)
129
- clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
130
- handle = assignee_handle(name)
131
- log = Tui::LogBuffer.new
132
- metrics = Tui::Metrics.new(name: name, handle: handle, clock: clock,
133
- on_run_start: ->(_label) { log.divider })
134
- terminal = Tui::Terminal.new(stream: @stdout)
135
- input = Tui::Input.new(stdin: @stdin)
136
- streamer = OutputStreamer.new(log: log)
137
- agent = Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
138
- command: pi_command)
139
- provider = BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
140
- env: ENV.to_h.merge(@env))
141
- loop = AgentLoop.new(name: name, handle: handle, agent: agent,
142
- task_provider: -> { provider.call },
143
- wait_seconds: wait_seconds, sleeper: @sleeper, stderr: log,
144
- metrics: metrics)
145
- session = Tui::Session.new(name: name, handle: handle, log: log, metrics: metrics,
146
- terminal: terminal, input: input,
147
- refresh: -> { provider.call },
148
- wait_seconds: wait_seconds, clock: clock)
149
- session.run { loop.run }
150
- end
151
-
152
- # The TUI is engaged only when stdout and stdin are terminals and TERM
153
- # is not dumb; otherwise the plain line-stream output (pipes, CI,
154
- # tests) — no escape codes, no TUI.
88
+ def run_agent(name)
89
+ store = PromptStore.new(root: @root)
90
+ announce_default_prompt(name, store) if store.read(name).nil?
91
+ tui? ? run_agent_tui(name) : run_agent_plain(name)
92
+ end
93
+
94
+ # One-time fallback notification (before the first loop message): names
95
+ # the exact path checked and the placement hint. The agent still starts
96
+ # — Letsdo::Agent falls back to the built-in default prompt itself.
97
+ def announce_default_prompt(name, store)
98
+ @stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
99
+ @stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
100
+ end
101
+
155
102
  def tui?
156
103
  @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
157
104
  end
158
105
 
159
- # The agent's assignee handle: AGENT_ASSIGNEE_HANDLE override, otherwise
160
- # the one rule — '@' + agent name.
161
106
  def assignee_handle(name)
162
107
  env_handle = @env['AGENT_ASSIGNEE_HANDLE']
163
108
  env_handle && !env_handle.strip.empty? ? env_handle : "@#{name}"
@@ -167,8 +112,6 @@ module Letsdo
167
112
  @env.fetch('LETSDO_BACKLOG_COMMAND', 'backlog')
168
113
  end
169
114
 
170
- # The retry interval when no tasks are open: LETSDO_WAIT_SECONDS, then
171
- # AGENT_WAIT_SECONDS (bin/agent-loop compatibility), default 10 seconds.
172
115
  def wait_seconds
173
116
  value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
174
117
  value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
@@ -193,9 +136,6 @@ module Letsdo
193
136
  PromptStore.new(root: @root).list.each { |name| @stdout.puts(" #{name}") }
194
137
  end
195
138
 
196
- # LETSDO_PI_FLAGS → array of flags; empty value = no flags.
197
- # For bin/agent compatibility, when LETSDO_PI_FLAGS is absent
198
- # AGENT_PI_FLAGS is used.
199
139
  def parse_pi_flags
200
140
  value = @env['LETSDO_PI_FLAGS'].to_s
201
141
  value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Agent-control primitives shared by the TUI and the orchestrator loop
5
+ # (TASK-67 control model): pausing between runs.
6
+ module Control
7
+ # A thread-safe pause flag polled by Letsdo::AgentLoop between runs.
8
+ #
9
+ # Mid-run suspension is handled by Letsdo::PiRunner#pause (SIGSTOP to
10
+ # the pi group, kernel-level freeze). Between runs there is no pi to
11
+ # stop, so the pause lives in this gate: while #paused? is true the
12
+ # loop must not start a new run, and it waits until #resume.
13
+ #
14
+ # The flag is toggled from the TUI input thread ('p' key) and polled
15
+ # from the loop's main thread (AgentLoop#wrapped_run), so every access
16
+ # is mutex-guarded.
17
+ class PauseGate
18
+ def initialize
19
+ @mutex = Mutex.new
20
+ @paused = false
21
+ end
22
+
23
+ # Sets the paused state (a no-op when already paused).
24
+ def pause
25
+ @mutex.synchronize { @paused = true }
26
+ end
27
+
28
+ # Clears the paused state (a no-op when not paused).
29
+ def resume
30
+ @mutex.synchronize { @paused = false }
31
+ end
32
+
33
+ # @return [Boolean] whether the gate is paused
34
+ def paused?
35
+ @mutex.synchronize { @paused }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # The built-in default prompt (canonical text from the TASK-41 spike,
5
+ # comment "BUILT-IN DEFAULT PROMPT").
6
+ #
7
+ # Process-only: no role, project, or language specifics; one task per run
8
+ # (the orchestrator loop supplies one task per agent run — the prompt
9
+ # never picks multiple). Used as:
10
+ # 1. the fallback prompt when agents/<name>.md does not exist — every
11
+ # agent name is a valid worker, the file is an optimization (custom
12
+ # instructions), not a precondition;
13
+ # 2. the template that `letsdo <name> --init` writes to agents/<name>.md
14
+ # (TASK-44) — a single source of truth so an initialized file always
15
+ # matches what a fallback run uses.
16
+ module DefaultPrompt
17
+ # The canonical default prompt text.
18
+ TEXT = <<~'PROMPT'
19
+ # Task agent
20
+
21
+ You are an autonomous task agent. You pick up the tasks assigned to you and
22
+ execute them one at a time following the task-work process below. You do not
23
+ implement features or designs on your own initiative: your work is defined
24
+ by the assigned tasks, one task per run.
25
+
26
+ ## Main rule: exactly one task per run
27
+
28
+ In a single run you pick up and complete exactly one task assigned to you,
29
+ then stop. The next task is started only in the next run of the orchestrator
30
+ loop.
31
+
32
+ If there are no tasks assigned to you — do not invent work and do not create
33
+ tasks yourself. End the run with a message that there are no tasks.
34
+
35
+ ## Choosing a task
36
+
37
+ Take the highest priority task assigned to you in order:
38
+
39
+ 0. if a task is already in progress
40
+ 1. priority
41
+ 2. order (ordinal), if priorities are equal
42
+
43
+ If the chosen task is currently blocked, take the task that blocks it into
44
+ work, using the same selection algorithm: first the highest priority, then
45
+ in order.
46
+
47
+ ## Task-work process
48
+
49
+ Execute the task according to the established protocol:
50
+
51
+ 1. **Start**: read the task instructions, check its status and Acceptance
52
+ Criteria, move it to an active status and assign it to yourself.
53
+ 2. **Plan**: study the current state of the system, draft an implementation
54
+ plan and record it in the task.
55
+ 3. **Work**: do the work in short iterations, checking intermediate results
56
+ and recording progress in the task as you go.
57
+ 4. **Completion**: verify each Acceptance Criterion with objective evidence,
58
+ mark the completed items, write a final summary and move the task to the
59
+ terminal status.
60
+ 5. Commit your changes, including any project bookkeeping affected by the
61
+ task.
62
+
63
+ ## Prohibitions
64
+
65
+ - Do not take work that is not assigned to you.
66
+ - Do not complete several tasks in one run.
67
+ PROMPT
68
+ end
69
+ end
data/lib/letsdo/errors.rb CHANGED
@@ -4,19 +4,9 @@ module Letsdo
4
4
  # Base error of the package.
5
5
  class Error < StandardError; end
6
6
 
7
- # An agent with this name was not found in agents/.
8
- class UnknownAgentError < Error
9
- attr_reader :name
10
-
11
- def initialize(name)
12
- @name = name
13
- super("Unknown agent: #{name}")
14
- end
15
- end
16
-
17
7
  # Raised by the signal handler to interrupt whatever the main thread is
18
8
  # doing (reading pi output, waiting for tasks, ...) so the loop unwinds
19
9
  # cleanly. Not a StandardError — nothing rescues it accidentally.
20
- class Stopped < Exception
10
+ class Stopped < StandardError
21
11
  end
22
- end
12
+ end
data/lib/letsdo/loop.rb CHANGED
@@ -38,21 +38,37 @@ module Letsdo
38
38
  # @return [Integer] number of completed agent runs
39
39
  def run
40
40
  runs = 0
41
- until @stopped
42
- tasks = @task_provider.call
43
- if tasks.nil? || tasks.empty?
44
- @sleeper.call(@wait_seconds)
45
- next
46
- end
47
-
48
- tasks.each do |task|
49
- break if @stopped
50
-
51
- @run_task.call(task)
52
- runs += 1
53
- end
54
- end
41
+ runs += process_tasks_batch(@task_provider.call) until @stopped
55
42
  runs
56
43
  end
44
+
45
+ private
46
+
47
+ # One provider batch: when there are no tasks the loop waits and
48
+ # retries; otherwise it runs the agent once per task.
49
+ #
50
+ # @return [Integer] number of agent runs completed in this batch
51
+ def process_tasks_batch(tasks)
52
+ return wait_for_tasks if tasks.nil? || tasks.empty?
53
+
54
+ run_batch(tasks)
55
+ end
56
+
57
+ def wait_for_tasks
58
+ @sleeper.call(@wait_seconds)
59
+ 0
60
+ end
61
+
62
+ # Runs the agent once per task, stopping early if the loop is stopped.
63
+ def run_batch(tasks)
64
+ batch_runs = 0
65
+ tasks.each do |task|
66
+ break if @stopped
67
+
68
+ @run_task.call(task)
69
+ batch_runs += 1
70
+ end
71
+ batch_runs
72
+ end
57
73
  end
58
- end
74
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Letsdo
6
+ class OutputStreamer
7
+ # Short one-line representations of tool-call arguments.
8
+ class ArgSummary
9
+ MAX_ARGS_CHARS = OutputStreamer::MAX_ARGS_CHARS
10
+
11
+ def self.call(name, args)
12
+ new(name, args).to_s
13
+ end
14
+
15
+ def initialize(name, args)
16
+ @name = name
17
+ @args = args
18
+ end
19
+
20
+ def to_s
21
+ case @name
22
+ when 'bash', 'powershell' then one_line(value('command'))
23
+ when 'read' then read_summary
24
+ when 'write', 'edit', 'find', 'ls' then value('path') || value('file_path')
25
+ when 'grep' then grep_summary
26
+ else generic
27
+ end
28
+ end
29
+
30
+ def read_summary
31
+ path = value('path') || value('file_path')
32
+ path && "#{path}#{read_range}"
33
+ end
34
+
35
+ def grep_summary
36
+ pattern = one_line(value('pattern'))
37
+ return nil unless pattern
38
+
39
+ path = value('path') || value('file_path')
40
+ path ? "#{pattern} #{path}" : pattern
41
+ end
42
+
43
+ def generic
44
+ return nil if @args.nil? || (@args.is_a?(Hash) && @args.empty?)
45
+
46
+ one_line(generic_text)
47
+ end
48
+
49
+ def generic_text
50
+ case @args
51
+ when String then @args
52
+ when Hash then JSON.generate(@args)
53
+ else @args.to_s
54
+ end
55
+ end
56
+
57
+ def read_range
58
+ return nil unless @args.is_a?(Hash)
59
+
60
+ offset = @args['offset']
61
+ return nil unless offset
62
+
63
+ limit = @args['limit']
64
+ limit ? ":#{offset}-#{offset + limit - 1}" : ":#{offset}"
65
+ end
66
+
67
+ def value(key)
68
+ @args.is_a?(Hash) ? @args[key]&.to_s : nil
69
+ end
70
+
71
+ def one_line(raw, max: MAX_ARGS_CHARS)
72
+ return nil if raw.nil?
73
+
74
+ text = raw.to_s.gsub(/\s+/, ' ').strip
75
+ return nil if text.empty?
76
+
77
+ text.length > max ? "#{text.slice(0, max)}…" : text
78
+ end
79
+ end
80
+ end
81
+ end