letsdo 0.4.0 → 0.6.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -2
  3. data/README.md +150 -18
  4. data/bin/letsdo +8 -1
  5. data/docs/config.md +176 -0
  6. data/docs/prompts.md +210 -0
  7. data/docs/task-selection.md +244 -0
  8. data/docs/usage.md +296 -0
  9. data/letsdo.gemspec +3 -1
  10. data/lib/letsdo/agent.rb +30 -17
  11. data/lib/letsdo/agent_identity.rb +46 -0
  12. data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
  13. data/lib/letsdo/agent_loop/tasks.rb +88 -13
  14. data/lib/letsdo/agent_loop.rb +39 -3
  15. data/lib/letsdo/backends/backend.rb +137 -0
  16. data/lib/letsdo/backends/pi/events.rb +77 -0
  17. data/lib/letsdo/backends/pi.rb +88 -0
  18. data/lib/letsdo/cli/builder.rb +47 -95
  19. data/lib/letsdo/cli/builder_assembly.rb +154 -0
  20. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  21. data/lib/letsdo/cli/doctor.rb +16 -0
  22. data/lib/letsdo/cli.rb +15 -1
  23. data/lib/letsdo/config.rb +89 -8
  24. data/lib/letsdo/control/reader.rb +131 -0
  25. data/lib/letsdo/control.rb +6 -3
  26. data/lib/letsdo/doctor/checks.rb +144 -0
  27. data/lib/letsdo/doctor.rb +42 -0
  28. data/lib/letsdo/duration.rb +23 -0
  29. data/lib/letsdo/errors.rb +7 -0
  30. data/lib/letsdo/metrics/fanout.rb +47 -0
  31. data/lib/letsdo/prompt_store.rb +48 -1
  32. data/lib/letsdo/providers/backlog.rb +180 -0
  33. data/lib/letsdo/providers/task.rb +48 -0
  34. data/lib/letsdo/retry_policy.rb +98 -0
  35. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  36. data/lib/letsdo/session_recorder.rb +188 -0
  37. data/lib/letsdo/task_time_writeback.rb +106 -0
  38. data/lib/letsdo/tui/metrics.rb +10 -3
  39. data/lib/letsdo/tui/renderer.rb +9 -1
  40. data/lib/letsdo/tui/session/terminal.rb +47 -0
  41. data/lib/letsdo/tui/session/view.rb +10 -2
  42. data/lib/letsdo/tui/session.rb +27 -22
  43. data/lib/letsdo/tui/window_title.rb +133 -0
  44. data/lib/letsdo/tui.rb +4 -0
  45. data/lib/letsdo/version.rb +1 -1
  46. data/lib/letsdo.rb +16 -5
  47. metadata +27 -6
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/pi_runner/events.rb +0 -75
  50. data/lib/letsdo/pi_runner/process.rb +0 -68
  51. data/lib/letsdo/pi_runner.rb +0 -101
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class Doctor
5
+ # The individual environment checks behind `letsdo doctor` (TASK-71).
6
+ # Every check returns { status:, message:, hint: } where the hint is
7
+ # mandatory for FAIL/WARN so the report is always actionable. Kept in its
8
+ # own module so Letsdo::Doctor stays within the class-length limit.
9
+ module DoctorChecks
10
+ MINIMUM_RUBY = '3.3'
11
+
12
+ private
13
+
14
+ def checks
15
+ [ruby_check, pi_check, backlog_check, assignee_check,
16
+ root_check, agents_md_check, agents_dir_check, tty_check]
17
+ end
18
+
19
+ # ruby >= 3.3 is the gemspec floor: below it letsdo still reports the
20
+ # problem as a WARN instead of a hard failure.
21
+ def ruby_check
22
+ return result('OK', "ruby #{RUBY_VERSION} (>= #{MINIMUM_RUBY})") if ruby_supported?
23
+
24
+ result('WARN', "ruby #{RUBY_VERSION} (< #{MINIMUM_RUBY})",
25
+ "upgrade Ruby to #{MINIMUM_RUBY} or newer")
26
+ end
27
+
28
+ def ruby_supported?
29
+ Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(MINIMUM_RUBY)
30
+ end
31
+
32
+ def pi_check
33
+ command_check('pi', @config.pi_command, 'LETSDO_PI_COMMAND')
34
+ end
35
+
36
+ def backlog_check
37
+ command_check('backlog', @config.backlog_command, 'LETSDO_BACKLOG_COMMAND')
38
+ end
39
+
40
+ # Assignee-name convention (TASK-96): the tracker stores bare names
41
+ # ('developer'); '@' is prompt-only notation. The backlog CLI matches
42
+ # --assignee by exact string, so a legacy @-prefixed stored assignee
43
+ # (or handle override) is invisible to every exact-match query.
44
+ # letsdo still matches such tasks after normalization, but the
45
+ # deviation deserves a WARN so the data gets fixed. Uses a handle-less
46
+ # provider (no filtering) and reads the raw assignees; an unreadable
47
+ # backlog is reported as INFO because backlog_check already flags the
48
+ # cause.
49
+ def assignee_check
50
+ override = @config.assignee_handle_override
51
+ return warn_at_prefixed_override(override) if at_prefixed?(override)
52
+
53
+ tasks = provider_without_handle.call
54
+ return result('INFO', 'assignee names not checked - backlog task list failed') if tasks.nil?
55
+
56
+ warn_legacy_assignees(tasks)
57
+ end
58
+
59
+ # A handle-less provider sees every open task, so the check reads the
60
+ # raw stored assignees (no filtering, no variants recorded).
61
+ def provider_without_handle
62
+ Letsdo::Providers::Backlog.new(handle: nil, command: @config.backlog_command,
63
+ cwd: @config.root, env: @config.child_env)
64
+ end
65
+
66
+ def warn_legacy_assignees(tasks)
67
+ legacy = tasks.flat_map(&:assignees).select { |value| at_prefixed?(value) }.uniq.sort
68
+ return result('OK', 'assignee names are stored bare (canonical)') if legacy.empty?
69
+
70
+ quoted = legacy.map { |value| "'#{value}'" }.join(', ')
71
+ result('WARN', "tasks store legacy @-prefixed assignee(s): #{quoted}",
72
+ 'reassign them to bare names: backlog task edit <ID> -a <name>')
73
+ end
74
+
75
+ def warn_at_prefixed_override(override)
76
+ result('WARN', "AGENT_ASSIGNEE_HANDLE '#{override}' carries the legacy '@' prefix",
77
+ "set it to the bare name: AGENT_ASSIGNEE_HANDLE=#{override.sub(/\A@+/, '')}")
78
+ end
79
+
80
+ # '@developer' is prose notation; the canonical tracker value is the
81
+ # bare name, so any leading '@' is a legacy deviation worth a WARN.
82
+ def at_prefixed?(value)
83
+ value.to_s.strip.start_with?('@')
84
+ end
85
+
86
+ def command_check(label, command, env_var)
87
+ return result('OK', "#{label} command found: #{command}") if command_available?(command)
88
+
89
+ result('FAIL', "#{label} command not found: #{command}",
90
+ "install #{label} or set #{env_var}")
91
+ end
92
+
93
+ # An absolute or relative command path is checked directly; a bare name
94
+ # is resolved against the PATH exposed by Letsdo::Config.
95
+ def command_available?(command)
96
+ return File.executable?(command) if command.include?(File::SEPARATOR)
97
+
98
+ @config.path.split(File::PATH_SEPARATOR).any? do |dir|
99
+ File.executable?(File.join(dir, command))
100
+ end
101
+ end
102
+
103
+ def root_check
104
+ return result('OK', "project root #{@config.root} has backlog/tasks/") if backlog_tasks?
105
+
106
+ result('FAIL', "project root #{@config.root} has no backlog/tasks/",
107
+ 'run `backlog init` or set LETSDO_ROOT to a Backlog.md project')
108
+ end
109
+
110
+ def backlog_tasks?
111
+ File.directory?(File.join(@config.root, 'backlog', 'tasks'))
112
+ end
113
+
114
+ def agents_md_check
115
+ path = File.join(@config.root, 'AGENTS.md')
116
+ return result('OK', "AGENTS.md present at #{path}") if File.file?(path)
117
+
118
+ result('WARN', "no AGENTS.md at #{path}",
119
+ 'add AGENTS.md with the project instructions for agents')
120
+ end
121
+
122
+ def agents_dir_check
123
+ dir = File.join(@config.root, 'agents')
124
+ prompts = File.directory?(dir) ? Dir.children(dir).length : 0
125
+ return result('OK', "agents/ present with #{prompts} prompt(s)") if prompts.positive?
126
+
127
+ result('WARN', "agents/ missing or empty at #{dir}",
128
+ 'create an agent prompt with `letsdo <name> --init`')
129
+ end
130
+
131
+ # TUI engages only when stdout is a TTY (plus stdin/TERM, checked at
132
+ # launch); an INFO line tells the user which mode a run would pick.
133
+ def tty_check
134
+ return result('INFO', 'stdout is a TTY - TUI mode') if @stdout.tty?
135
+
136
+ result('INFO', 'stdout is not a TTY - plain mode')
137
+ end
138
+
139
+ def result(status, message, hint = nil)
140
+ { status: status, message: message, hint: hint }
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'doctor/checks'
4
+
5
+ module Letsdo
6
+ # `letsdo doctor`: one line per environment check with a status tag and an
7
+ # actionable hint for every FAIL/WARN (TASK-71). Exits 0 when nothing
8
+ # FAILs and 1 otherwise, so it can gate scripts.
9
+ #
10
+ # Every input comes from Letsdo::Config (env policy) plus the injected
11
+ # streams, so the command is fully testable without touching process ENV.
12
+ class Doctor
13
+ include DoctorChecks
14
+
15
+ def initialize(config:, stdout:, stdin: $stdin)
16
+ @config = config
17
+ @stdout = stdout
18
+ @stdin = stdin
19
+ end
20
+
21
+ # Prints the report and returns the process exit code: 1 when at least
22
+ # one check FAILed, 0 otherwise.
23
+ def run
24
+ results = checks
25
+ results.each { |check| print_result(check) }
26
+ results.any? { |check| check[:status] == 'FAIL' } ? 1 : 0
27
+ end
28
+
29
+ private
30
+
31
+ def print_result(check)
32
+ line = "[#{tag(check[:status])}] #{check[:message]}"
33
+ line += " - #{check[:hint]}" if check[:hint]
34
+ @stdout.puts(line)
35
+ end
36
+
37
+ # All tags render six characters wide, so the columns line up.
38
+ def tag(status)
39
+ status == 'OK' ? ' OK ' : status
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Shared rendering of a duration in seconds as "4m 12s" / "12s" / "0s".
5
+ #
6
+ # The stop summary (TASK-69) and the opt-in per-task elapsed write-back
7
+ # (TASK-70) both use this one formatter, so the number a session reports
8
+ # and the number written into the task record can never drift apart.
9
+ module Duration
10
+ module_function
11
+
12
+ # @param seconds [Numeric] duration in seconds (negative values clamp to 0)
13
+ # @return [String] human-readable duration, e.g. "4m 12s"
14
+ def format(seconds)
15
+ total = [seconds.to_f, 0.0].max.round
16
+ minutes, secs = total.divmod(60)
17
+ return '0s' if minutes.zero? && secs.zero?
18
+ return "#{secs}s" if minutes.zero?
19
+
20
+ "#{minutes}m #{secs}s"
21
+ end
22
+ end
23
+ end
data/lib/letsdo/errors.rb CHANGED
@@ -9,4 +9,11 @@ module Letsdo
9
9
  # cleanly. Not a StandardError — nothing rescues it accidentally.
10
10
  class Stopped < StandardError
11
11
  end
12
+
13
+ # Raised when the AI backend process cannot be started (missing or
14
+ # non-executable binary). This is a configuration error, not a loop
15
+ # problem: the CLI catches it, prints a clear message and exits 2 instead
16
+ # of dying with a Ruby backtrace.
17
+ class BackendUnavailableError < Error
18
+ end
12
19
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Metrics
5
+ # A small fan-out facade that forwards the loop-driver events to a
6
+ # list of observers. Used in TUI mode so the session recorder and
7
+ # the header metrics facade both receive the same callbacks from
8
+ # Letsdo::AgentLoop.
9
+ #
10
+ # Each observer must respond to provider_result, run_started and
11
+ # run_finished (the same interface Tui::Metrics and SessionRecorder
12
+ # implement). The exit code is forwarded to every observer so the
13
+ # session recorder can classify outcomes; Tui::Metrics ignores it.
14
+ class Fanout
15
+ # @param observers [Array<Object>] each must implement the three
16
+ # callback methods (provider_result, run_started, run_finished)
17
+ def initialize(*observers)
18
+ @observers = observers
19
+ end
20
+
21
+ # Forwards the provider open-task count to every observer.
22
+ #
23
+ # @param count [Integer, nil] number of open tasks; nil = the
24
+ # backlog state is unreadable
25
+ # @return [void]
26
+ def provider_result(count)
27
+ @observers.each { |observer| observer.provider_result(count) }
28
+ end
29
+
30
+ # Forwards a run-started event (task label) to every observer.
31
+ #
32
+ # @param task [String] task label
33
+ # @return [void]
34
+ def run_started(task)
35
+ @observers.each { |observer| observer.run_started(task) }
36
+ end
37
+
38
+ # Forwards a run-finished event (exit code) to every observer.
39
+ #
40
+ # @param exit_code [Integer, nil] the run exit code
41
+ # @return [void]
42
+ def run_finished(exit_code = nil)
43
+ @observers.each { |observer| observer.run_finished(exit_code) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'yaml'
4
5
 
5
6
  module Letsdo
6
7
  # Access to agent prompts: the agents/<name>.md directory in the project
@@ -40,7 +41,22 @@ module Letsdo
40
41
  path = agent_path(name)
41
42
  return nil unless File.file?(path)
42
43
 
43
- File.read(path)
44
+ content = File.read(path)
45
+ self.class.strip_front_matter(content)
46
+ end
47
+
48
+ # Parsed launch configuration for an agent.
49
+ #
50
+ # Returns a hash with symbolised keys. An empty hash when the file
51
+ # is missing or has no YAML front matter.
52
+ #
53
+ # @param name [String] agent name
54
+ # @return [Hash{Symbol => Object}]
55
+ def config(name)
56
+ path = agent_path(name)
57
+ return {} unless File.file?(path)
58
+
59
+ self.class.parse_front_matter(File.read(path))
44
60
  end
45
61
 
46
62
  # Absolute path of an agent's prompt file, whether or not it exists.
@@ -69,6 +85,37 @@ module Letsdo
69
85
  true
70
86
  end
71
87
 
88
+ # Strips an optional YAML front matter block delimited by `---` at
89
+ # the very start of a prompt file. Returns the remaining content.
90
+ #
91
+ # @param content [String] full file content
92
+ # @return [String] content without front matter
93
+ def self.strip_front_matter(content)
94
+ content.sub(/\A---\n.+?\n---\n?/m, '')
95
+ end
96
+
97
+ FRONT_MATTER = /\A---\n?\n(.+?)\n?\n---\n?\n/m
98
+
99
+ # Parses an optional YAML front matter block delimited by `---` at
100
+ # the very start of a prompt file. Returns the keys symbolised;
101
+ # returns an empty hash when there is no front matter or it is
102
+ # invalid.
103
+ #
104
+ # @param content [String] full file content
105
+ # @return [Hash{Symbol => Object}]
106
+ def self.parse_front_matter(content)
107
+ match = content.match(FRONT_MATTER)
108
+ return {} unless match
109
+
110
+ symbolize_keys(YAML.safe_load(match[1], permitted_classes: []))
111
+ rescue StandardError
112
+ {}
113
+ end
114
+
115
+ def self.symbolize_keys(parsed)
116
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : {}
117
+ end
118
+
72
119
  private
73
120
 
74
121
  def agents_dir
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'shellwords'
5
+ require_relative 'task'
6
+
7
+ module Letsdo
8
+ module Providers
9
+ # Task provider for Letsdo::Loop backed by the real backlog CLI:
10
+ #
11
+ # backlog task list --exclude-status Done --ready --sort priority --json
12
+ #
13
+ # Returns the runnable tasks assigned to the handle in the
14
+ # authoritative run order (an Array of Letsdo::Providers::Task), or nil
15
+ # when the backlog state is unreadable — the CLI is not on PATH, failed,
16
+ # or its output is not the expected JSON. The loop treats nil as "pause
17
+ # and retry, do not run the agent".
18
+ #
19
+ # The assignee filter is applied in Ruby, not via the CLI's --assignee:
20
+ # the CLI matches the assignee by exact string, so a task stored as
21
+ # '@developer' (legacy notation) would never reach a '--assignee
22
+ # developer' query and the loop would silently starve (TASK-96). The
23
+ # handle match here tolerates the leading '@', surrounding whitespace
24
+ # and case, so both notations keep working; the literal values that
25
+ # matched only after normalization are exposed by #assignee_variants
26
+ # for the doctor WARN and the once-per-run loop hint.
27
+ #
28
+ # The command runs in the project root (cwd), where the backlog CLI finds
29
+ # the backlog/ folder — the same context as a single agent run.
30
+ class Backlog
31
+ # Keys of the normalized Letsdo::Providers::Task shape. The backlog CLI
32
+ # emits more (reporter, parentTaskId, createdAt, updatedAt); the adapter
33
+ # projects onto these and ignores the rest so a growing CLI schema
34
+ # cannot crash the loop. Beyond the identity fields, the shape keeps
35
+ # what deterministic selection needs: ordinal (the stable tie-break) and
36
+ # type/labels/milestone (optional capability routing).
37
+ TASK_FIELDS = %w[id title status priority assignees ordinal type labels milestone].freeze
38
+
39
+ # Sort ranks for the deterministic batch order. Known priorities are
40
+ # compared case-insensitively; anything else (nil or a new label) ranks
41
+ # last instead of crashing the comparator.
42
+ PRIORITY_RANKS = { 'high' => 0, 'medium' => 1, 'low' => 2 }.freeze
43
+ UNKNOWN_RANK = PRIORITY_RANKS.size
44
+
45
+ # @param handle [String, nil] assignee name to filter by (canonical:
46
+ # 'developer'; a legacy '@developer' still matches the same
47
+ # tasks); nil disables the filter (doctor inspects all open
48
+ # tasks)
49
+ # @param command [String] backlog CLI command (overridable for tests)
50
+ # @param cwd [String, nil] project root for the CLI; nil = inherit cwd
51
+ # @param env [Hash, nil] environment for the CLI child (nil = inherit
52
+ # the process environment; injected in tests to control the
53
+ # fake backlog scenarios)
54
+ def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
55
+ @handle = handle
56
+ @command = command
57
+ @cwd = cwd
58
+ @env = env
59
+ @assignee_variants = []
60
+ end
61
+
62
+ # Literal assignee values from the last successful batch that matched
63
+ # the handle only after normalization (e.g. '@developer' for the
64
+ # handle 'developer') — the mismatch signature the doctor check and
65
+ # the loop hint surface. Empty when the handle matched exactly, no
66
+ # handle is configured, or the last call failed.
67
+ def assignee_variants
68
+ @assignee_variants.dup
69
+ end
70
+
71
+ # Reads the runnable open tasks once, in deterministic run order.
72
+ #
73
+ # @return [Array<Task>, nil] runnable open tasks; nil when the backlog is
74
+ # unreadable; empty array when there are no open tasks
75
+ def call
76
+ @assignee_variants = []
77
+ args = @env ? [@env, *command_line] : command_line
78
+ out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
79
+ return nil unless status.success?
80
+
81
+ tasks = JSON.parse(out)['tasks']
82
+ return nil unless tasks.is_a?(Array)
83
+
84
+ sort(select_by_assignee(tasks.map { |raw| normalize(raw) }))
85
+ rescue Errno::ENOENT, JSON::ParserError, TypeError
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ # Deterministic run order: In Progress first (resume before starting),
92
+ # then priority High > Medium > Low, then ordinal ascending, then id
93
+ # ascending. The CLI's --sort priority does not put In Progress first
94
+ # and cannot express the full tie-break, so the adapter sorts the batch
95
+ # itself. The original index is the final tie-break, so equal keys keep
96
+ # the provider's (deterministic) order.
97
+ def sort(tasks)
98
+ tasks.each_with_index.sort_by do |task, index|
99
+ [in_progress_rank(task), priority_rank(task.priority),
100
+ ordinal_rank(task.ordinal), id_rank(task.id), index]
101
+ end.map(&:first)
102
+ end
103
+
104
+ # Assignee filtering happens here, not in the CLI's --assignee: the
105
+ # CLI matches by exact string (TASK-96). The stored value may be the
106
+ # bare canonical name or the legacy '@'-prefixed one — both match.
107
+ # A nil handle (doctor) keeps every task and never records variants.
108
+ def select_by_assignee(tasks)
109
+ return tasks unless @handle
110
+
111
+ selected = tasks.select { |task| match_assignees(task) }
112
+ @assignee_variants = @assignee_variants.uniq.sort
113
+ selected
114
+ end
115
+
116
+ def match_assignees(task)
117
+ task.assignees.any? do |value|
118
+ next false if normalize_assignee(value) != normalized_handle
119
+
120
+ @assignee_variants << value unless value == @handle
121
+ true
122
+ end
123
+ end
124
+
125
+ def normalized_handle
126
+ normalize_assignee(@handle)
127
+ end
128
+
129
+ # '@Dev', ' dev ' and 'dev' are the same handle; only the leading '@'
130
+ # is stripped, so '@dev team' stays distinct from 'devteam'.
131
+ def normalize_assignee(value)
132
+ value.to_s.strip.sub(/\A@/, '').downcase
133
+ end
134
+
135
+ def in_progress_rank(task)
136
+ task.status.to_s.casecmp('In Progress').zero? ? 0 : 1
137
+ end
138
+
139
+ def priority_rank(priority)
140
+ PRIORITY_RANKS[priority.to_s.downcase] || UNKNOWN_RANK
141
+ end
142
+
143
+ # Unknown/nil ordinals sort last (their id still orders them).
144
+ def ordinal_rank(ordinal)
145
+ value = Integer(ordinal, exception: false)
146
+ value.nil? ? [1, 0] : [0, value]
147
+ end
148
+
149
+ # An absent id (malformed task) sorts after identified ones.
150
+ def id_rank(id)
151
+ [id.to_s.empty? ? 1 : 0, id.to_s]
152
+ end
153
+
154
+ # Projects one raw CLI task onto the normalized shape. Unknown keys are
155
+ # ignored (the CLI schema grows over time); a non-Hash entry means the
156
+ # payload is not the expected schema and raises TypeError, which #call
157
+ # turns into nil.
158
+ def normalize(raw)
159
+ raise TypeError, "task is not an object: #{raw.class}" unless raw.is_a?(Hash)
160
+
161
+ Task.new(**TASK_FIELDS.to_h { |field| [field.to_sym, raw[field]] })
162
+ end
163
+
164
+ # [command..., task, list, --exclude-status Done, --ready,
165
+ # --sort priority, --json] — no --assignee: the filter runs in Ruby
166
+ # (select_by_assignee), so exact-match deviations in the stored
167
+ # assignees cannot starve the loop (TASK-96).
168
+ def command_line
169
+ [
170
+ *Shellwords.split(@command),
171
+ 'task', 'list',
172
+ '--exclude-status', 'Done',
173
+ '--ready',
174
+ '--sort', 'priority',
175
+ '--json'
176
+ ]
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Providers
5
+ # Immutable value object representing a normalized task.
6
+ #
7
+ # Adapters map tracker-specific JSON onto this shape. Business logic
8
+ # consumes only this interface, not the raw tracker schema.
9
+ #
10
+ # The constructor is strict on purpose: adapters project the tracker
11
+ # payload onto these keywords instead of forwarding it, so extra tracker
12
+ # fields never reach here and a wrong key is caught as a programming
13
+ # error instead of silently ignored.
14
+ #
15
+ # Besides the identity/routing fields, the shape carries the fields a
16
+ # deterministic selector needs: +ordinal+ (the stable tie-break), and
17
+ # +type+/+labels+/+milestone+ for optional capability routing.
18
+ class Task
19
+ attr_reader :id, :title, :status, :priority, :assignees,
20
+ :ordinal, :type, :labels, :milestone
21
+
22
+ # rubocop:disable Metrics/ParameterLists -- the keywords are the
23
+ # normalized shape's explicit contract; adapters project tracker
24
+ # payloads onto exactly these and the strict list rejects a typo.
25
+ def initialize(id:, title: nil, status: nil, priority: nil, assignees: [],
26
+ ordinal: nil, type: nil, labels: [], milestone: nil)
27
+ @id = id
28
+ @title = title
29
+ @status = status
30
+ @priority = priority
31
+ @assignees = Array(assignees)
32
+ @ordinal = ordinal
33
+ @type = type
34
+ @labels = Array(labels)
35
+ @milestone = milestone
36
+ freeze
37
+ end
38
+ # rubocop:enable Metrics/ParameterLists
39
+
40
+ def to_s
41
+ identifier = id.to_s
42
+ return identifier unless identifier.empty?
43
+
44
+ title.to_s
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Pure policy object for per-task retry, exponential backoff and give-up.
5
+ #
6
+ # Letsdo::Loop stays generic and untouched; this object is wired at the
7
+ # AgentLoop level. The loop filters attempt batches through #cooldown? and
8
+ # records outcomes with #record_failure / #record_success.
9
+ #
10
+ # State is per-task (keyed by task id) and in-memory only: a fresh letsdo
11
+ # session starts with zero failures, so a broken task yields instead of
12
+ # hammering, and a temporarily-failing task is retried next session.
13
+ #
14
+ # The clock is injectable (monotonic, as in Tui::Metrics) for
15
+ # deterministic tests.
16
+ class RetryPolicy
17
+ DEFAULT_MAX_RETRIES = 3
18
+ DEFAULT_CAP = 300.0
19
+ DEFAULT_BASE = 10.0
20
+
21
+ # Tunables exposed for the reconciliation logic (give-up checks).
22
+ attr_reader :max_retries
23
+
24
+ # @param base [Float] base backoff seconds; default wait_seconds (10.0)
25
+ # @param cap [Float] maximum backoff seconds (default 300)
26
+ # @param max_retries [Integer] give-up after N consecutive failures
27
+ # @param clock [Proc] callable → monotonic epoch seconds
28
+ def initialize(base: nil, cap: nil, max_retries: nil, clock: nil)
29
+ @base = base || DEFAULT_BASE
30
+ @cap = cap || DEFAULT_CAP
31
+ @max_retries = max_retries || DEFAULT_MAX_RETRIES
32
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
33
+ @state = {}
34
+ end
35
+
36
+ # Consecutive failures recorded for task_key (0 when none).
37
+ def failures(task_key)
38
+ info = @state[task_key]
39
+ info ? info[:failures] : 0
40
+ end
41
+
42
+ # Records a failed run: bumps the failure count and (re)arms the cooldown
43
+ # deadline for exponential backoff.
44
+ def record_failure(task_key)
45
+ info = (@state[task_key] ||= { failures: 0, cool_until: 0 })
46
+ info[:failures] += 1
47
+ info[:cool_until] = cooldown_deadline(info[:failures])
48
+ info
49
+ end
50
+
51
+ # Records a successful run: clears the per-task retry state.
52
+ def record_success(task_key)
53
+ @state.delete(task_key)
54
+ end
55
+
56
+ # Clears all per-task retry state (fresh session).
57
+ def reset
58
+ @state.clear
59
+ end
60
+
61
+ # Whether the task is cooling down and must be excluded from attempt
62
+ # batches. Tasks skipped by cooldown are NOT re-recorded as failed.
63
+ def cooldown?(task_key, now = nil)
64
+ info = @state[task_key]
65
+ return false unless info
66
+
67
+ cool_until(info) > current_time(now)
68
+ end
69
+
70
+ # Whether the task has hit the give-up limit: it must not be attempted
71
+ # again for the rest of the session.
72
+ def gave_up?(task_key)
73
+ failures(task_key) >= @max_retries
74
+ end
75
+
76
+ # Earliest cooldown deadline across all tasks (for a bounded wait); nil
77
+ # when nothing is cooling down.
78
+ def earliest_cooldown(now = nil)
79
+ current = current_time(now)
80
+ deadlines = @state.values.map { |info| info[:cool_until] }.select { |d| d > current }
81
+ deadlines.empty? ? nil : deadlines.min
82
+ end
83
+
84
+ private
85
+
86
+ def current_time(now)
87
+ now.nil? ? @clock.call : now
88
+ end
89
+
90
+ def cooldown_deadline(failure_count)
91
+ @clock.call + [@base * (2**(failure_count - 1)), @cap].min
92
+ end
93
+
94
+ def cool_until(info)
95
+ info[:cool_until]
96
+ end
97
+ end
98
+ end