letsdo 0.4.0 → 0.5.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -2
  3. data/README.md +141 -12
  4. data/bin/letsdo +5 -0
  5. data/docs/config.md +171 -0
  6. data/docs/prompts.md +209 -0
  7. data/docs/task-selection.md +239 -0
  8. data/docs/usage.md +295 -0
  9. data/letsdo.gemspec +3 -1
  10. data/lib/letsdo/agent.rb +29 -17
  11. data/lib/letsdo/agent_identity.rb +42 -0
  12. data/lib/letsdo/agent_loop/tasks.rb +91 -13
  13. data/lib/letsdo/agent_loop.rb +36 -3
  14. data/lib/letsdo/backends/backend.rb +137 -0
  15. data/lib/letsdo/backends/pi/events.rb +77 -0
  16. data/lib/letsdo/backends/pi.rb +88 -0
  17. data/lib/letsdo/cli/builder.rb +47 -95
  18. data/lib/letsdo/cli/builder_assembly.rb +151 -0
  19. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  20. data/lib/letsdo/cli/doctor.rb +16 -0
  21. data/lib/letsdo/cli.rb +15 -1
  22. data/lib/letsdo/config.rb +66 -4
  23. data/lib/letsdo/control/reader.rb +131 -0
  24. data/lib/letsdo/control.rb +6 -3
  25. data/lib/letsdo/doctor/checks.rb +98 -0
  26. data/lib/letsdo/doctor.rb +42 -0
  27. data/lib/letsdo/duration.rb +23 -0
  28. data/lib/letsdo/errors.rb +7 -0
  29. data/lib/letsdo/metrics/fanout.rb +47 -0
  30. data/lib/letsdo/prompt_store.rb +48 -1
  31. data/lib/letsdo/providers/backlog.rb +124 -0
  32. data/lib/letsdo/providers/task.rb +48 -0
  33. data/lib/letsdo/retry_policy.rb +98 -0
  34. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  35. data/lib/letsdo/session_recorder.rb +188 -0
  36. data/lib/letsdo/task_time_writeback.rb +106 -0
  37. data/lib/letsdo/tui/metrics.rb +7 -2
  38. data/lib/letsdo/tui/session/terminal.rb +47 -0
  39. data/lib/letsdo/tui/session/view.rb +10 -2
  40. data/lib/letsdo/tui/session.rb +27 -22
  41. data/lib/letsdo/tui/window_title.rb +133 -0
  42. data/lib/letsdo/tui.rb +4 -0
  43. data/lib/letsdo/version.rb +1 -1
  44. data/lib/letsdo.rb +16 -5
  45. metadata +25 -5
  46. data/lib/letsdo/backlog_tasks.rb +0 -59
  47. data/lib/letsdo/pi_runner/events.rb +0 -75
  48. data/lib/letsdo/pi_runner/process.rb +0 -68
  49. data/lib/letsdo/pi_runner.rb +0 -101
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Pi JSON event-stream handlers used by PiRunner.
5
- module PiRunnerEvents
6
- private
7
-
8
- def read_pi_stream(out_r)
9
- out_r.each_line { |line| handle_line(line) }
10
- debug('stream: EOF')
11
- end
12
-
13
- def handle_line(line)
14
- line = line.strip
15
- return if line.empty?
16
-
17
- event = parse_event(line)
18
- dispatch_event(event) if event
19
- end
20
-
21
- def dispatch_event(event)
22
- case event['type']
23
- when 'message_update' then handle_message_update(event)
24
- when 'tool_execution_start' then handle_tool_start(event)
25
- when 'tool_execution_end' then handle_tool_execution_end(event)
26
- when 'agent_end' then flush_pending_tools
27
- end
28
- end
29
-
30
- def handle_tool_start(event)
31
- @pending_tools.delete(event['toolCallId'])
32
- @streamer.tool_start(event['toolName'] || 'tool', args: event['args'])
33
- end
34
-
35
- def handle_message_update(event)
36
- payload = event['assistantMessageEvent']
37
- return unless payload
38
-
39
- handle_text_delta(payload) || remember_toolcall(event, payload)
40
- end
41
-
42
- def handle_text_delta(payload)
43
- return unless payload['type'] == 'text_delta'
44
-
45
- delta = payload['delta']
46
- @streamer.text_delta(delta) if delta && !delta.empty?
47
- true
48
- end
49
-
50
- def remember_toolcall(event, payload)
51
- return unless payload['type'] == 'toolcall_start'
52
-
53
- id = event['id'] || payload['id']
54
- name = event['toolName'] || payload['toolName'] || 'tool'
55
- @pending_tools[id] = name unless id.nil?
56
- end
57
-
58
- def handle_tool_execution_end(event)
59
- name = event['toolName'] || 'tool'
60
- error = event['isError'] == true
61
- @streamer.tool_result(name, error: error)
62
- end
63
-
64
- def flush_pending_tools
65
- @pending_tools.each_value { |name| @streamer.tool_start(name) }
66
- @pending_tools.clear
67
- end
68
-
69
- def parse_event(line)
70
- JSON.parse(line)
71
- rescue JSON::ParserError
72
- nil
73
- end
74
- end
75
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Process-group control for a running pi child.
5
- module PiRunnerProcess
6
- def terminate(signal: 'TERM', grace: 3.0, tick: 0.05)
7
- pid = @pid
8
- return true unless pid
9
-
10
- send_signal('CONT', pid)
11
- send_signal(signal, pid)
12
- wait_for_exit(pid, grace, tick)
13
- end
14
-
15
- private
16
-
17
- def wait_for_exit(pid, grace, tick)
18
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
19
- loop do
20
- status = wait_status(pid, nonblock: true)
21
- return status if status
22
- break unless alive?(pid)
23
- return kill_and_reap(pid, tick) if overdue_deadline?(deadline)
24
-
25
- sleep(tick)
26
- end
27
- true
28
- end
29
-
30
- def overdue_deadline?(deadline)
31
- Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
32
- end
33
-
34
- def kill_and_reap(pid, tick)
35
- send_signal('KILL', pid)
36
- sleep(tick)
37
- wait_status(pid, nonblock: true) || true
38
- end
39
-
40
- def wait_status(pid, nonblock: false)
41
- _, status = Process.wait2(pid, nonblock ? Process::WNOHANG : 0)
42
- @reaped_status = status if status
43
- status
44
- rescue Errno::ECHILD
45
- @reaped_status
46
- end
47
-
48
- def send_signal(signal, pid)
49
- Process.kill(signal, -pid)
50
- rescue Errno::ESRCH, Errno::EPERM
51
- nil
52
- end
53
-
54
- def alive?(pid)
55
- Process.kill(0, pid)
56
- true
57
- rescue Errno::ESRCH, Errno::EPERM
58
- false
59
- end
60
-
61
- def exit_code(status)
62
- return 1 if status.nil?
63
- return status.exitstatus if status.exitstatus
64
-
65
- status.termsig ? 128 + status.termsig : 1
66
- end
67
- end
68
- end
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
-
5
- module Letsdo
6
- # Runs pi in --mode json and hands events to the output streamer.
7
- class PiRunner
8
- COMMAND = 'pi'
9
- MODE = 'json'
10
- MESSAGE_UPDATE = 'message_update'
11
- TOOL_EXECUTION_START = 'tool_execution_start'
12
- TOOL_EXECUTION_END = 'tool_execution_end'
13
- AGENT_END = 'agent_end'
14
- end
15
- end
16
-
17
- require_relative 'pi_runner/events'
18
- require_relative 'pi_runner/process'
19
-
20
- module Letsdo
21
- # Pi process spawn, stream drain, and pause/resume signals.
22
- class PiRunner
23
- include PiRunnerEvents
24
- include PiRunnerProcess
25
-
26
- def initialize(prompt:, streamer:, **opts)
27
- @prompt = prompt
28
- @streamer = streamer
29
- @flags = opts.fetch(:flags, [])
30
- @command = opts.fetch(:command, COMMAND)
31
- @pending_tools = {}
32
- @reaped_status = nil
33
- @debug = opts[:debug].nil? ? (opts[:config] || Config.new).debug? : opts[:debug]
34
- end
35
-
36
- def run
37
- out_r = spawn_pi
38
- drain_stream(out_r)
39
- finish_run
40
- ensure
41
- @pid = nil
42
- @reaped_status = nil
43
- end
44
-
45
- def debug(message)
46
- warn("[letsdo] pi: #{message}") if @debug
47
- end
48
-
49
- def terminate_now
50
- pid = @pid
51
- send_signal('TERM', pid) if pid
52
- end
53
-
54
- def pause
55
- pid = @pid
56
- send_signal('STOP', pid) if pid
57
- end
58
-
59
- def resume
60
- pid = @pid
61
- send_signal('CONT', pid) if pid
62
- end
63
-
64
- private
65
-
66
- def spawn_pi
67
- cmd = [@command, '--mode', MODE, *@flags, @prompt]
68
- out_r, out_w = IO.pipe
69
- @pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
70
- out_w.close
71
- debug("spawned pid=#{@pid} (own group)")
72
- out_r
73
- end
74
-
75
- def drain_stream(out_r)
76
- read_pi_stream(out_r)
77
- rescue Letsdo::Stopped
78
- debug('stopped by signal, terminating pi')
79
- terminate
80
- wait_status(@pid)
81
- @streamer.finish
82
- raise
83
- ensure
84
- close_pipe(out_r)
85
- flush_pending_tools
86
- end
87
-
88
- def close_pipe(out_r)
89
- out_r.close
90
- rescue IOError
91
- nil
92
- end
93
-
94
- def finish_run
95
- status = wait_status(@pid)
96
- @streamer.finish
97
- debug("exit status=#{status.inspect} code=#{exit_code(status)}")
98
- exit_code(status)
99
- end
100
- end
101
- end