agentilda 1.0.3 → 2.0.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +10 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +92 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +46 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +65 -13
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/ordinal"
4
+ require "agentilda/status"
5
+
3
6
  module Agentilda
4
7
  # Lowercase in the middle of a title, capitalised at the front.
5
8
  SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
@@ -44,21 +47,22 @@ module Agentilda
44
47
  }.join(" ")
45
48
  end
46
49
 
47
- # The one place a plan folder's name is spelled out: `NNN.MM-<emoji>--<slug>`.
50
+ # The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
48
51
  #
49
- # The double dash after the emoji is deliberate: an emoji renders two cells
50
- # wide and visually swallows a single dash beside it, so `🔎-refactor` reads
52
+ # The spaced arrow after the emoji is deliberate: an emoji renders two cells
53
+ # wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
51
54
  # as if the emoji and the slug were touching. Everything that mints or
52
55
  # renames a folder goes through here; {Feature.parse} accepts the older
53
- # single-dash spelling too, and `resync dirs` normalises it on contact.
56
+ # `--` and single-dash spellings too, and `resync dirs` migrates them on
57
+ # contact.
54
58
  #
55
59
  # @param ordinal [Agentilda::Ordinal, String]
56
60
  # @param status [Agentilda::Status]
57
61
  # @param slug [String]
58
62
  # @return [String]
59
- def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji}--#{slug}"
63
+ def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
60
64
 
61
- # One `NNN.MM-<emoji>--<slug>` folder, decoded.
65
+ # One `NNN.MM-<emoji> → <slug>` folder, decoded.
62
66
  #
63
67
  # @!attribute [r] ordinal
64
68
  # @return [Agentilda::Ordinal]
@@ -82,13 +86,14 @@ module Agentilda
82
86
  ordinal = Ordinal.from_dirname(dirname) or return nil
83
87
 
84
88
  rest = dirname.sub(/\A[\d.]+[-_]/, "")
85
- head, tail = rest.split(/[-_]/, 2)
89
+ head, tail = rest.split(/\s*→\s*|[-_]/, 2)
86
90
 
87
91
  # A leading segment with no ASCII word character is the status emoji.
88
92
  # The slug strips any further separators: the canonical spelling puts
89
- # two dashes after the emoji, and folders from before that rule put one.
93
+ # a spaced arrow after the emoji, and folders from before that rule put
94
+ # two dashes, or one.
90
95
  status = (Agentilda.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
91
- slug = status ? tail.sub(/\A[-_]+/, "") : rest
96
+ slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
92
97
 
93
98
  new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
94
99
  end
@@ -124,130 +129,4 @@ module Agentilda
124
129
  # @return [Integer, nil]
125
130
  def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
126
131
  end
127
-
128
- # A plan folder as the state machine sees it: which files exist, what they
129
- # say, and which pull requests they record.
130
- #
131
- # It exists because the invariants ask the same questions repeatedly and
132
- # {Feature} is a frozen `Data` with nowhere to memoize the answers.
133
- class Subject
134
- # @param feature [Agentilda::Feature]
135
- def initialize(feature)
136
- @feature = feature
137
- @reads = {}
138
- end
139
-
140
- # @return [Agentilda::Feature]
141
- attr_reader :feature
142
-
143
- # @return [Agentilda::Status] the status the folder name claims
144
- def status = feature.status
145
-
146
- # @param name [String] a bare filename
147
- # @return [Boolean]
148
- def file?(name) = File.file?(File.join(feature.path, name))
149
-
150
- # @param name [String]
151
- # @return [String, nil] contents, or nil when absent
152
- def read(name)
153
- @reads.fetch(name) do
154
- @reads[name] = file?(name) ? File.read(File.join(feature.path, name), encoding: "UTF-8") : nil
155
- end
156
- end
157
-
158
- # @return [Array<Agentilda::PullRequest>]
159
- def pull_requests = @pull_requests ||= PullRequests.new(dir: feature.path).all
160
-
161
- # The specification's own Goal section, verbatim and at most two
162
- # paragraphs. Anything that paraphrases the spec is a second copy that
163
- # drifts; quoting it is not — which is why the pull request body and the
164
- # index both read it from here rather than each writing their own.
165
- #
166
- # @return [Array<String>] paragraphs, empty when there is no Goal to read
167
- def goal
168
- body = read("spec.md").to_s
169
- section = body[/^\#{"#"}{2,3}\s*Goals?\b[^\n]*\n+(.*?)(?=\n\#{"#"}{1,3}\s|\z)/mi, 1]
170
-
171
- paragraphs(section) || paragraphs(body.sub(/\A\s*\#{"#"}[^\n]*\n/, "")) || []
172
- end
173
-
174
- # Specifications written before the template existed have no Goal section,
175
- # and they are exactly the ones an index most needs to describe. So the
176
- # opening prose stands in — skipping headings, quotes, lists and tables,
177
- # which describe the document rather than the work.
178
- #
179
- # @param text [String, nil]
180
- # @return [Array<String>, nil] nil when there is no prose to be had
181
- def paragraphs(text)
182
- found = text.to_s.strip.split(/\n{2,}/)
183
- .map(&:strip)
184
- .reject { |p| p.empty? || p.match?(/\A[\#>|\-*\d`_=]/) }
185
- .first(2)
186
-
187
- found.empty? ? nil : found
188
- end
189
-
190
- # The questions `blocked.md` still names, by number.
191
- #
192
- # Empty means one of two very different things, and a caller that treats
193
- # them alike is how a folder with thirty kilobytes of open questions gets
194
- # reported as "nothing left open": either there is no `blocked.md` at all,
195
- # or there is one whose questions are not written as `## B<n>` and are
196
- # therefore invisible to every part of this tool. Ask {#file?} which.
197
- #
198
- # @return [Array<Integer>]
199
- def open_blocks = Agentilda.block_numbers(read("blocked.md"), OPEN_BLOCK)
200
-
201
- # The answers waiting in `blocked.md`, by number. `## A1` settles `## B1`.
202
- #
203
- # Waiting, not folded. An `## A<n>` heading is not the same as a settled
204
- # question: one may say in its own body that it is a draft pending a
205
- # conversation. `lando-broker` makes that call; this only counts headings.
206
- #
207
- # @return [Array<Integer>]
208
- def block_answers = Agentilda.block_numbers(read("blocked.md"), ANSWER_BLOCK)
209
-
210
- # A `blocked.md` this tool cannot read: the file is there, and not one
211
- # question in it is written as `## B<n>`. Nothing can drain it and nothing
212
- # currently says so, which is the whole reason this exists.
213
- #
214
- # @return [Boolean]
215
- def unreadable_block? = file?("blocked.md") && open_blocks.empty?
216
-
217
- # @return [String, nil] why the folder's name is not justified
218
- def violation = status.violation(self)
219
-
220
- # @return [Boolean] whether the name matches the contents
221
- def consistent? = violation.nil?
222
-
223
- # @return [Agentilda::StateMachine] positioned at the current state
224
- def machine = StateMachine.new(self)
225
-
226
- # @return [Array<Symbol>] states reachable right now, guards applied
227
- def allowed = machine.allowed
228
-
229
- # @return [Agentilda::Status, nil] the state these contents justify
230
- def best_fit = machine.best_fit
231
-
232
- # Move the folder into +status+ — the side effect a transition *is*.
233
- #
234
- # The {Feature} is a frozen `Data` holding the old name, so it is replaced
235
- # rather than mutated, and the memoized reads go with it.
236
- #
237
- # @param status [Agentilda::Status]
238
- # @return [Agentilda::Feature] the feature under its new name
239
- # @raise [Agentilda::Error] when the target name is already taken
240
- def rename_to(status)
241
- return @feature if status.key == @feature.status.key
242
-
243
- target = File.join(File.dirname(@feature.path), @feature.dirname_as(status))
244
- unless Agentilda.move_directory(@feature.path, target)
245
- raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
246
- end
247
-
248
- @reads = {}
249
- @pull_requests = nil
250
- @feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
251
- end
252
- end
253
132
  end
@@ -12,12 +12,14 @@ module Agentilda
12
12
  # `create` reported a missing `title:` on a file whose title was right there.
13
13
  # One parser, one list of permitted classes, so that cannot happen twice.
14
14
  module Frontmatter
15
+ # rubocop: disable Lint/OrAssignmentToConstant
15
16
  # Frontmatter, then body.
16
- PATTERN = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
17
+ PATTERN ||= /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
17
18
 
18
19
  # Dates and timestamps are ordinary frontmatter, so they load. Nothing else
19
20
  # does: the point of `safe_load` is that a seed file cannot name a class.
20
- PERMITTED_CLASSES = [Date, Time].freeze
21
+ PERMITTED_CLASSES ||= [Date, Time].freeze
22
+ # rubocop: enable Lint/OrAssignmentToConstant
21
23
 
22
24
  class << self
23
25
  # @param content [String] a whole markdown file
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "tty/command"
4
5
 
5
6
  module Agentilda
6
7
  # The `gh` CLI, wrapped thinly.
@@ -25,8 +26,15 @@ module Agentilda
25
26
  # @return [Array<Hash>] `{number:, title:, url:, branch:, files:}`
26
27
  def pulls(state: "all")
27
28
  out = UI.spinning("Fetching pull requests from GitHub") {
28
- @command.run("gh", "pr", "list", "--state", state, "--limit", @limit.to_s,
29
- "--json", FIELDS.join(",")).out
29
+ @command.run("gh",
30
+ "pr",
31
+ "list",
32
+ "--state",
33
+ state,
34
+ "--limit",
35
+ @limit.to_s,
36
+ "--json",
37
+ FIELDS.join(",")).out
30
38
  }
31
39
 
32
40
  # `gh` can exit 0 having printed NOTHING — most often when it cannot reach
@@ -39,12 +47,12 @@ module Agentilda
39
47
  JSON.parse(out).map do |pr|
40
48
  {
41
49
  number: pr["number"],
42
- title: pr["title"].to_s,
43
- url: pr["url"],
50
+ title: pr["title"].to_s,
51
+ url: pr["url"],
44
52
  branch: pr["headRefName"].to_s,
45
- files: Array(pr["files"]).map { |f| f["path"] }.compact,
46
- state: self.class.state_label(pr),
47
- open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
53
+ files: Array(pr["files"]).map { |f| f["path"] }.compact,
54
+ state: self.class.state_label(pr),
55
+ open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
48
56
  }
49
57
  end
50
58
  rescue TTY::Command::ExitError, JSON::ParserError => e
@@ -107,10 +115,10 @@ module Agentilda
107
115
  pr = JSON.parse(out)
108
116
  {
109
117
  number: pr["number"],
110
- title: pr["title"].to_s,
111
- url: pr["url"].to_s,
112
- state: self.class.state_label(pr),
113
- body: pr["body"].to_s
118
+ title: pr["title"].to_s,
119
+ url: pr["url"].to_s,
120
+ state: self.class.state_label(pr),
121
+ body: pr["body"].to_s
114
122
  }
115
123
  rescue TTY::Command::ExitError, JSON::ParserError => e
116
124
  raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
@@ -23,16 +23,16 @@ module Agentilda
23
23
 
24
24
  # Documents that get a proper name rather than their filename.
25
25
  ARTIFACT_NAMES = {
26
- "spec.md" => "Spec",
27
- "plan.md" => "Plan",
26
+ "spec.md" => "Spec",
27
+ "plan.md" => "Plan",
28
28
  "pull-requests.md" => "Pull Requests",
29
- "linear.md" => "Linear",
30
- "blocked.md" => "Blocked",
31
- "delayed.md" => "Deferred",
32
- "rewrite.md" => "Rewrite",
33
- "deployed.md" => "Deployed",
34
- "rollback.md" => "Rollback",
35
- "discarded.md" => "Discarded"
29
+ "linear.md" => "Linear",
30
+ "blocked.md" => "Blocked",
31
+ "delayed.md" => "Deferred",
32
+ "rewrite.md" => "Rewrite",
33
+ "deployed.md" => "Deployed",
34
+ "rollback.md" => "Rollback",
35
+ "discarded.md" => "Discarded"
36
36
  }.freeze
37
37
 
38
38
  # @param tree [Agentilda::Tree]
@@ -189,9 +189,9 @@ module Agentilda
189
189
  # @return [String]
190
190
  def footer
191
191
  counts = tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status] += 1 }
192
- .map { |status, n| "#{status.emoji} #{n}" }.join(" &nbsp; ")
192
+ .map { |status, n| "#{status.emoji} #{n}" }.join(" &nbsp; ")
193
193
 
194
- "\n---\n\n#{tree.subjects.size} #{(tree.subjects.size == 1) ? "plan" : "plans"} &nbsp; #{counts}\n"
194
+ "\n---\n\n#{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} &nbsp; #{counts}\n"
195
195
  end
196
196
 
197
197
  # @param href [String]
@@ -17,31 +17,42 @@ module Agentilda
17
17
  class Keyboard
18
18
  # Key → what it does, rendered by {#help} and dispatched by {#handle}.
19
19
  BINDINGS = [
20
- ["h ?", "this help"],
20
+ ["h", "this help"],
21
+ ["?", "about agentilda"],
22
+ ["s", "select the next running agent; arrows move the selection"],
23
+ ["k", "mark the selected agent to be killed (STOP, 15s, then kill -9)"],
24
+ ["x", "extend the selected agent's clock by 10 minutes, per press"],
25
+ ["ENTER", "apply the pending changes in the dialog"],
26
+ ["ESC", "discard the dialog, then clear the selection"],
21
27
  ["w", "ask every running agent to wrap up as fast as possible"],
22
28
  ["n", "ask agents to write out what they have and stop; the loop continues"],
23
29
  ["q", "write out, stop everything, and quit after a #{Control::GRACE}s grace"],
24
- ["ctrl-c", "interrupt the run, as ever"]
30
+ ["ctrl-c", "agents leave a resume note and stop, then quit; press again to abort now"]
25
31
  ].freeze
26
32
 
33
+ # @param input [IO]
34
+ # @param sink [Agentilda::Console, nil] where the table keys go; nil
35
+ # when there is no screen, in which case they do nothing
27
36
  # @return [Agentilda::Keyboard, nil] a running listener, or nil when
28
37
  # STDIN is not a terminal
29
- def self.listen(input: $stdin)
38
+ def self.listen(input: $stdin, sink: nil)
30
39
  return nil unless input.tty?
31
40
 
32
- new(input:).start
41
+ new(input:, sink:).start
33
42
  end
34
43
 
35
44
  # @param input [IO]
36
- def initialize(input: $stdin)
45
+ # @param sink [Agentilda::Console, nil]
46
+ def initialize(input: $stdin, sink: nil)
37
47
  @input = input
48
+ @sink = sink
38
49
  end
39
50
 
40
51
  # @return [self]
41
52
  def start
42
53
  @thread = Thread.new do
43
54
  Thread.current.report_on_exception = false
44
- loop { handle(@input.getch) }
55
+ loop { handle(read_key) }
45
56
  rescue IOError, Errno::EIO
46
57
  # STDIN went away; a keyboard with no keys just stops listening.
47
58
  end
@@ -52,17 +63,41 @@ module Agentilda
52
63
  def stop
53
64
  @thread&.kill
54
65
  @thread = nil
66
+ restore
67
+ end
68
+
69
+ # `getch` puts the terminal in raw mode for the length of one read and
70
+ # restores it afterwards — but the listener spends almost all its life
71
+ # blocked inside that read, and {#stop} kills it there, which leaves
72
+ # the mode behind. Raw mode has output post-processing off, so a "\n"
73
+ # written after it drops a line without returning the cursor to column
74
+ # zero: the run report then starts wherever the previous line ended and
75
+ # staircases down the screen. Nothing else puts the mode back, so this
76
+ # does.
77
+ #
78
+ # @return [void]
79
+ def restore
80
+ @input.cooked! if @input.tty?
81
+ rescue IOError, SystemCallError
82
+ # No terminal left to put back; there is nothing to restore it to.
55
83
  end
56
84
 
57
85
  # @param key [String, nil]
58
86
  # @return [void]
59
87
  def handle(key)
60
88
  case key
61
- when "h", "?" then UI.popup("Keys", help)
89
+ when "h" then @sink ? @sink.toggle_help : UI.popup("Keys", help)
90
+ when "?" then @sink ? @sink.toggle_about : UI.popup("About", about)
91
+ when "s", "\e[B" then @sink&.select_next
92
+ when "\e[A" then @sink&.select_prev
93
+ when "k" then @sink&.toggle_kill
94
+ when "x" then @sink&.extend
95
+ when "\r", "\n" then @sink&.apply
96
+ when "\e" then @sink&.escape
62
97
  when "w" then acted("w — agents asked to wrap up") { Control.wrap_up! }
63
98
  when "n" then acted("n — agents asked to write out and stop") { Control.stop! }
64
99
  when "q" then acted("q — quitting; #{Control::GRACE}s grace to write out") { Control.quit! }
65
- when "\u0003" then Thread.main.raise(Interrupt)
100
+ when "\u0003" then interrupt
66
101
  end
67
102
  end
68
103
 
@@ -72,8 +107,36 @@ module Agentilda
72
107
  BINDINGS.map { |key, does| "#{key.ljust(width)} #{does}" }.join("\n")
73
108
  end
74
109
 
110
+ # @return [String] the version and a reminder of how to leave
111
+ def about = "Agentilda Version #{Agentilda::VERSION}\n\nThanks for using this! Press 'q' to exit and stop all agents."
112
+
75
113
  private
76
114
 
115
+ # ESC alone and ESC-[-A are the same first byte. Wait a few
116
+ # milliseconds for the rest before deciding it was a bare ESC.
117
+ #
118
+ # @return [String]
119
+ def read_key
120
+ key = @input.getch
121
+ return key unless key == "\e"
122
+
123
+ if IO.select([@input], nil, nil, 0.05)
124
+ rest = @input.read_nonblock(2, exception: false)
125
+ return "\e#{rest}" if rest.is_a?(String)
126
+ end
127
+ key
128
+ end
129
+
130
+ # Raw mode swallows the signal, so the key stands in for it: the first
131
+ # press lets agents write down where they got to, the second aborts.
132
+ #
133
+ # @return [void]
134
+ def interrupt
135
+ return Thread.main.raise(Interrupt) if Control.interrupted?
136
+
137
+ acted("ctrl-c — agents leaving resume notes; #{Control::GRACE}s grace, ctrl-c again to abort") { Control.interrupt! }
138
+ end
139
+
77
140
  # A keypress with no acknowledgement looks like a keypress that did
78
141
  # nothing, so each one says what it just asked for.
79
142
  #
@@ -82,6 +145,11 @@ module Agentilda
82
145
  def acted(note)
83
146
  yield
84
147
  UI.log(note)
148
+ # Under the dashboard, RatatuiRuby.run owns the alternate screen and
149
+ # repaints it continuously, so this raw `$stderr` write either never
150
+ # appears or is immediately overwritten — `UI.log` above is the only
151
+ # guaranteed record of w/n/q there. The line still matters without a
152
+ # screen (a dry run, a pipe), where this is all the feedback there is.
85
153
  UI.line(note)
86
154
  end
87
155
  end
@@ -0,0 +1,69 @@
1
+ module Agentilda
2
+ class Launcher
3
+ # The one flag every command takes, so no command declares it. Removed
4
+ # from the arguments before dry-cli sees them, which would otherwise
5
+ # reject it as an argument the command never asked for.
6
+ NO_COLOR_FLAGS = %w[--no-color -C].freeze
7
+
8
+ attr_accessor :argv,
9
+ :stdin,
10
+ :stdout,
11
+ :stderr,
12
+ :kernel
13
+
14
+ def initialize(argv,
15
+ stdin = $stdin,
16
+ stdout = $stdout,
17
+ stderr = $stderr,
18
+ kernel = Kernel)
19
+ self.argv = argv
20
+ self.stdin = stdin
21
+ self.stdout = stdout
22
+ self.stderr = stderr
23
+ self.kernel = kernel
24
+
25
+ # Bare `agentilda` prints its help. It used to print the plan table, which
26
+ # meant the one command that reads a tree was also the one you got by accident.
27
+ @argv = argv.empty? ? %w(-h) : argv
28
+
29
+ # `run --push-pr` takes an OPTIONAL part letter: bare means "continue this
30
+ # plan"s sequence", `--push-pr C` means "use C". dry-cli has no optional-value
31
+ # option, so the bare form is filled in here rather than made to look like a
32
+ # mistake.
33
+ #
34
+ # Scoped to `run`, and to the long spelling when it is not. This rewrote every
35
+ # `-p` in every command for a while, which quietly turned
36
+ # `linear import TAX -p "Some Project"` into `-p auto "Some Project"` — the
37
+ # option took "auto" and the project name became a stray argument. A global
38
+ # rewrite of a one-letter flag will collide with the next command that wants
39
+ # it; this one collided within the week.
40
+ return unless argv.first == "run"
41
+
42
+ @argv = argv.each_with_object([]) do |token, out|
43
+ out << token
44
+ next unless ["--push-pr", "-p"].include?(token)
45
+
46
+ following = argv[argv.index(token) + 1]
47
+ out << "auto" unless following&.match?(/\A[A-Z]\z/)
48
+ end
49
+ end
50
+
51
+ # `--no-color` becomes NO_COLOR rather than a setting of its own: the help
52
+ # screens, the runtime UI and every child process already honour it, so
53
+ # one variable switches all three off together.
54
+ #
55
+ # @return [void] never returns; exits with the command's status
56
+ def execute!
57
+ ENV["NO_COLOR"] = "1" if NO_COLOR_FLAGS.intersect?(argv)
58
+ code = 0
59
+ Dry::CLI.new(::Agentilda::CLI).call(arguments: argv - NO_COLOR_FLAGS)
60
+ rescue SystemExit => e
61
+ code = e.status
62
+ rescue StandardError => e
63
+ stderr.puts UI.paint("Error: #{e.message}", :red)
64
+ code = 1
65
+ ensure
66
+ kernel.exit(code)
67
+ end
68
+ end
69
+ end