agentilda 1.0.3

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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. metadata +349 -0
@@ -0,0 +1,681 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty/spinner/multi"
4
+
5
+ module Agentilda
6
+ # Everything the user sees that is not the deliverable itself.
7
+ #
8
+ # Include it and you get `info`, `warn`, `error` and `success` as instance
9
+ # methods, each drawing a TTY::Box on **STDERR**. STDERR is deliberate: the
10
+ # documents and tables these commands produce own STDOUT, so every command
11
+ # composes in a pipe.
12
+ #
13
+ # @example
14
+ # class Thing
15
+ # include Agentilda::UI
16
+ # def run = success("Linked 4 skills")
17
+ # end
18
+ module UI
19
+ # Widest a box may be drawn, regardless of how wide the terminal is.
20
+ MAX_WIDTH = 100
21
+
22
+ # Narrowest, so a small terminal still produces readable boxes.
23
+ MIN_WIDTH = 60
24
+
25
+ # Below this many items a progress bar is noise: it appears and vanishes
26
+ # before the eye resolves it, and the line it prints is longer than the work.
27
+ PROGRESS_THRESHOLD = 3
28
+
29
+ # What an item contributes to its log line's columns when its caller has
30
+ # nothing to say about it. A round header is such an item, and it still
31
+ # has to line up with the agent lines under it.
32
+ NO_FIELDS = ->(_item) { {} }
33
+
34
+ # What an item's countdown starts from when its caller has no opinion:
35
+ # nothing, so no timer is drawn. A line without a deadline showing 0:00
36
+ # forever would read as an agent perpetually out of time.
37
+ NO_TIMEOUT = ->(_item) {}
38
+
39
+ # The countdown turns red with this many seconds left — late enough to
40
+ # stay calm through a normal run, early enough to look up before the
41
+ # executor pulls the plug.
42
+ TIMER_WARNING = 60
43
+
44
+ # How a block's RETURN VALUE is judged when its caller has no opinion:
45
+ # nothing is ever a failure. The distinction exists because {Runner}'s
46
+ # executor reports failure by returning a not-ok result rather than by
47
+ # raising — and a line that drew ✓ "done" over a timed-out agent, while
48
+ # the round table under it said FAIL, was the contradiction this closes.
49
+ NO_FAILURE = ->(_result) {}
50
+
51
+ # One item's spinner line, and the same news written to the log.
52
+ #
53
+ # These are two readers of one story and used to be told it separately:
54
+ # the spinner got the phrase, the log got a start and a finish, and an
55
+ # animated run wrote nothing about what any agent was actually doing. A
56
+ # {Agentilda::Transcript::Progress} arrives here several times a
57
+ # second; the spinner is redrawn every time, and the log takes a line only
58
+ # when the phrase itself changes, which is a few dozen times an agent.
59
+ class Line
60
+ # @param fields [Hash] plan, status and agent, for the log's columns
61
+ # @param spinner [TTY::Spinner, nil] nil where nothing is being drawn
62
+ # @param mark [String] what the spinner says once the work succeeds
63
+ # @param timeout [Integer, nil] seconds until the executor abandons
64
+ # this agent; drawn as a countdown on the line, nil draws nothing
65
+ def initialize(fields: {}, spinner: nil, mark: "", timeout: nil)
66
+ @fields = fields
67
+ @spinner = spinner
68
+ @mark = mark
69
+ @timeout = timeout
70
+ @started = UI.monotonic
71
+ @phrase = nil
72
+ @pid = nil
73
+ @ticker = nil
74
+ end
75
+
76
+ # @return [Float] seconds this agent has been alive
77
+ def seconds = UI.monotonic - @started
78
+
79
+ # @return [String] the same, as the log and the report write it
80
+ def alive = "#{seconds.round}s"
81
+
82
+ # @param message [String]
83
+ # @return [void]
84
+ def note(message) = UI.log(message, **@fields, seconds:)
85
+
86
+ # @return [void]
87
+ def start
88
+ @spinner&.update(pid: identity)
89
+ tick
90
+ note("started")
91
+ end
92
+
93
+ # What remains on this agent's clock, or nil when it has none. Floored
94
+ # at zero: the executor's kill and this thread's wake-up race by up to
95
+ # a second, and a line reading `-0:01` accuses the wrong party.
96
+ #
97
+ # @return [Integer, nil]
98
+ def remaining
99
+ return nil unless @timeout
100
+
101
+ [@timeout - seconds, 0].max.round
102
+ end
103
+
104
+ # The countdown, redrawn once a second on its own thread. Progress
105
+ # updates cannot drive it — they arrive only while the agent is
106
+ # talking, and a stalled agent is exactly when the clock matters most.
107
+ #
108
+ # @return [void]
109
+ def tick
110
+ return unless @spinner && @timeout
111
+
112
+ @spinner.update(timer: UI.countdown(remaining))
113
+ @ticker ||= Thread.new do
114
+ loop do
115
+ sleep(1)
116
+ left = remaining
117
+ @spinner.update(timer: UI.countdown(left))
118
+ break unless left.positive?
119
+ end
120
+ rescue
121
+ # A dying spinner must not take the round down with it.
122
+ end
123
+ end
124
+
125
+ # @return [void]
126
+ def stop_ticker
127
+ @ticker&.kill
128
+ @ticker = nil
129
+ end
130
+
131
+ # What sits between the agent's name and its activity: the `claude`
132
+ # process and the round, `[36123, round 01]`, so a line on the screen
133
+ # can be matched to a process in `ps` and a row in the report. The pid
134
+ # is unknowable until the child has been spawned and found, so it reads
135
+ # `…` until then; a caller with neither fact gets nothing at all.
136
+ #
137
+ # @return [String]
138
+ def identity
139
+ round = @fields[:round]
140
+ return "" if round.nil? && @pid.nil?
141
+
142
+ inner = [(@pid || "…").to_s, ("round #{round}" if round)].compact.join(", ")
143
+ UI.paint("[#{inner}]", :bright_black)
144
+ end
145
+
146
+ # @return [void]
147
+ def done
148
+ stop_ticker
149
+ @spinner&.success(@mark)
150
+ note("finished after #{alive}")
151
+ end
152
+
153
+ # @param reason [String]
154
+ # @return [void]
155
+ def failed(reason)
156
+ stop_ticker
157
+ @spinner&.error(UI.paint(reason, :red))
158
+ note("failed after #{alive}: #{reason}")
159
+ end
160
+
161
+ # @param update [Agentilda::Transcript::Progress]
162
+ # @return [void]
163
+ def call(update)
164
+ if update.respond_to?(:pid) && update.pid && update.pid != @pid
165
+ @pid = update.pid
166
+ @spinner&.update(pid: identity)
167
+ note("claude is pid #{@pid}")
168
+ end
169
+ @spinner&.update(meter: UI.meter(update), activity: UI.said(update.activity))
170
+ return if update.activity.nil? || update.activity == @phrase
171
+
172
+ @phrase = update.activity
173
+ note(update.activity)
174
+ end
175
+
176
+ # So a caller can pass this straight on as a block: {Executor} yields
177
+ # to it from the thread it reads the agent's stream on.
178
+ #
179
+ # @return [Proc]
180
+ def to_proc = method(:call).to_proc
181
+ end
182
+
183
+ class << self
184
+ # Set by the CLI's --quiet. Silences spinners and bars along with
185
+ # everything else, so a quiet run really is quiet.
186
+ #
187
+ # @return [Boolean]
188
+ attr_accessor :quiet
189
+
190
+ # @return [Pastel] colour engine, disabled when STDERR is not a terminal
191
+ def pastel = @pastel ||= Pastel.new(enabled: color?)
192
+
193
+ # Forget everything memoized here.
194
+ #
195
+ # Pastel captures `enabled:` once, at construction. Anything that changes
196
+ # the answer afterwards — a test stubbing `tty?`, a caller setting
197
+ # NO_COLOR late — would otherwise be ignored for the rest of the process,
198
+ # and the first answer would leak into every later call.
199
+ #
200
+ # @return [void]
201
+ def reset!
202
+ remove_instance_variable(:@pastel) if instance_variable_defined?(:@pastel)
203
+ self.quiet = false
204
+ self.log_path = nil
205
+ end
206
+
207
+ # Where {.log} appends to, if anywhere. nil (the default) means nowhere;
208
+ # `agentilda run` sets this before the loop starts, so a round
209
+ # started under a tool that discards STDERR — an agent's own Bash call,
210
+ # for instance — still leaves something to `tail -f`.
211
+ #
212
+ # @return [String, nil]
213
+ attr_accessor :log_path
214
+
215
+ # Append one timestamped line to {.log_path}. A no-op with nothing set.
216
+ # Safe to call from several threads at once.
217
+ #
218
+ # @param message [String]
219
+ # @return [void]
220
+ def log(message, **fields)
221
+ path = log_path or return
222
+
223
+ line = ProgressLog.render(message, **fields)
224
+ (@log_mutex ||= Mutex.new).synchronize do
225
+ FileUtils.mkdir_p(File.dirname(path))
226
+ File.open(path, "a") { |f| f.puts(line) }
227
+ end
228
+ end
229
+
230
+ # Cells the meter takes on a spinner line, per direction.
231
+ #
232
+ # Fixed, and padded to it, because the numbers grow as the agent works and
233
+ # a column that sizes itself to them drags the whole line sideways every
234
+ # few seconds.
235
+ METER_WIDTH = 7
236
+
237
+ # The token counter that sits between the spinner and the agent's name.
238
+ #
239
+ # Up is everything sent, cache reads included, which is most of it. Down
240
+ # is what the model generated. Sub-agent spend is folded into up, since
241
+ # `claude` reports a sub-agent's total without splitting it.
242
+ #
243
+ # @param update [Agentilda::Transcript::Progress, nil]
244
+ # @return [String]
245
+ def meter(update)
246
+ paint(fit("↑#{abbreviate(update&.up)}", METER_WIDTH), :bright_blue) +
247
+ paint(fit("↓#{abbreviate(update&.down)}", METER_WIDTH), :bright_magenta)
248
+ end
249
+
250
+ # Cells the countdown takes, `MM:SS ` included — the run default of
251
+ # 900s reads `15:00`, and padding to a fixed width keeps the columns
252
+ # to its right from stepping sideways once `9:59` loses a digit.
253
+ TIMER_WIDTH = 6
254
+
255
+ # The countdown that sits between the spinner and the meter: what is
256
+ # left of the agent's timeout, quiet grey until the last
257
+ # {TIMER_WARNING} seconds, red from there down.
258
+ #
259
+ # @param left [Integer, nil] seconds remaining; nil draws nothing
260
+ # @return [String]
261
+ def countdown(left)
262
+ return "" if left.nil?
263
+
264
+ text = fit(format("%d:%02d", left / 60, left % 60), TIMER_WIDTH)
265
+ paint(text, (left <= TIMER_WARNING) ? :red : :bright_black)
266
+ end
267
+
268
+ # Token counts run to seven figures, and seven figures on a spinner line
269
+ # is four cells of noise about a number nobody reads to the digit.
270
+ #
271
+ # @param count [Integer]
272
+ # @return [String] e.g. "512", "4.9k", "121k", "1.6M"
273
+ def abbreviate(count)
274
+ count = count.to_i
275
+ case count
276
+ when 0...1_000 then count.to_s
277
+ when 1_000...10_000 then "#{(count / 1000.0).round(1)}k"
278
+ when 10_000...1_000_000 then "#{(count / 1000.0).round}k"
279
+ when 1_000_000...10_000_000 then "#{(count / 1_000_000.0).round(1)}M"
280
+ else "#{(count / 1_000_000.0).round}M"
281
+ end
282
+ end
283
+
284
+ # @param phrase [String, nil]
285
+ # @return [String] the phrase as a spinner line carries it
286
+ def said(phrase) = phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold)
287
+
288
+ # @return [Boolean] whether STDERR is an interactive terminal
289
+ def tty? = $stderr.tty?
290
+
291
+ # Whether animated output is worth drawing at all. A pipe, a CI log or a
292
+ # --quiet run gets none: spinner frames written to a file are line noise.
293
+ #
294
+ # @return [Boolean]
295
+ def animate? = tty? && !quiet
296
+
297
+ # Indeterminate work — one call whose duration cannot be predicted, such
298
+ # as a network round trip. The spinner runs until the block returns.
299
+ #
300
+ # @param message [String] what is being waited on
301
+ # @yieldreturn [Object] whatever the work produces
302
+ # @return [Object] the block's value, untouched
303
+ def spinning(message)
304
+ return yield(logging_activity(message)) unless animate?
305
+
306
+ spinner = TTY::Spinner.new("[:spinner] #{message}:activity", format: :dots, output: $stderr,
307
+ success_mark: paint("✓", :green), error_mark: paint("✖", :red))
308
+ spinner.update(activity: "")
309
+ spinner.auto_spin
310
+ begin
311
+ result = yield(activity_for(spinner))
312
+ spinner.success(paint("done", :bright_black))
313
+ result
314
+ rescue
315
+ spinner.error(paint("failed", :red))
316
+ raise
317
+ end
318
+ end
319
+
320
+ # Determinate work — N items of roughly equal cost. Yields each item and
321
+ # advances the bar; returns the collection so it can be chained.
322
+ #
323
+ # @param items [Array]
324
+ # @param message [String]
325
+ # @yieldparam item [Object]
326
+ # @return [Array] +items+
327
+ def stepping(items, message, &)
328
+ list = items.to_a
329
+ return list.each(&) unless animate? && list.size >= PROGRESS_THRESHOLD
330
+
331
+ bar = TTY::ProgressBar.new(
332
+ "#{message} [:bar] :current/:total :percent",
333
+ total: list.size, output: $stderr, width: 24,
334
+ complete: "█", incomplete: "░", head: "█"
335
+ )
336
+ list.each do |item|
337
+ yield item
338
+ bar.advance
339
+ end
340
+ bar.finish
341
+ list
342
+ end
343
+
344
+ # Run a block over many items at once, one spinner each.
345
+ #
346
+ # This is the shape for work that is independent and slow: each item gets
347
+ # its own line, its own thread and its own success or failure mark, so a
348
+ # long round reads as progress rather than as a hang.
349
+ #
350
+ # Results come back in the order the items were given, not the order they
351
+ # finished — a caller that had to re-sort them would be a caller that
352
+ # eventually forgets to.
353
+ #
354
+ # Every path here — one item, several without a terminal, several with
355
+ # one — reports something. `jobs <= 1 || list.size <= 1` used to bypass
356
+ # all of it and run silently, which is exactly the shape a `--plan
357
+ # NNN.MM` round takes: one plan, one agent, nothing printed until the
358
+ # whole thing finished and it was too late to tell "working" from "hung."
359
+ #
360
+ # @param items [Array]
361
+ # @param message [String] the header line
362
+ # @param jobs [Integer] how many run at once
363
+ # @param label [Proc] item -> the text on its line
364
+ # @param failure [Proc] the block's return value -> a reason when that
365
+ # value reports a failure, nil when it reports success. The block
366
+ # returning normally is not the same fact as the work having worked.
367
+ # @param header [Hash] log columns for the header line itself, so the
368
+ # line announcing a round carries the same round number as the agent
369
+ # lines under it rather than a blank cell
370
+ # @yieldparam item [Object]
371
+ # @return [Array] one result per item, in input order
372
+ def concurrently(items, message, jobs:, label: :to_s.to_proc, fields: NO_FIELDS,
373
+ failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT, &block)
374
+ list = items.to_a
375
+ return [] if list.empty?
376
+
377
+ log(message, **header)
378
+
379
+ if jobs <= 1 || list.size <= 1
380
+ report_line(message) unless animate?
381
+ return list.map { |item| once(item, label, fields, failure:, timeout:, &block) }
382
+ end
383
+
384
+ return threaded(list, jobs, message, label:, fields:, failure:, &block) unless animate?
385
+
386
+ results = Concurrent::Hash.new
387
+ spinners = TTY::Spinner::Multi.new(
388
+ ":spinner #{paint(message, :bold)}",
389
+ format: :dots, output: $stderr,
390
+ success_mark: paint("✓", :green), error_mark: paint("✖", :red)
391
+ )
392
+
393
+ list.each_with_index do |item, index|
394
+ text = label.call(item)
395
+ child = spinners.register("[:spinner] :timer:meter#{text}:pid:activity") do |spinner|
396
+ line = Line.new(fields: fields.call(item), spinner:, timeout: timeout.call(item))
397
+ line.start
398
+ result = results[index] = block.call(item, line)
399
+ if (reason = failure.call(result))
400
+ line.failed(reason)
401
+ else
402
+ line.done
403
+ end
404
+ rescue => e
405
+ results[index] = e
406
+ line&.failed(e.message.lines.first.to_s.strip)
407
+ end
408
+ # An unset token renders as the literal `:activity`, so every line
409
+ # says so until its agent gets far enough to have news. The meter
410
+ # starts at zero for the same reason, and because a counter that
411
+ # appears once the first number arrives shifts the whole line.
412
+ child.update(timer: "", meter: meter(nil), activity: "", pid: "")
413
+ end
414
+
415
+ spinners.auto_spin
416
+ list.each_index.map { |i| results[i] }
417
+ end
418
+
419
+ # The same news, with no spinner to put it on. A piped or CI run still
420
+ # wants it, in the log where the rest of that run's progress goes.
421
+ #
422
+ # @param text [String] the item's label
423
+ # @return [Proc] phrase -> void
424
+ def logging_activity(text) = ->(phrase) { log("#{text}: #{phrase}") }
425
+
426
+ # A callable that writes what an agent is doing onto its own spinner line.
427
+ #
428
+ # The `:activity` token is empty until something calls this, so a line
429
+ # reads as it always did until there is news. It is written from the
430
+ # reader thread the command's output arrives on, which is why the token
431
+ # is replaced whole rather than appended to.
432
+ #
433
+ # @param spinner [TTY::Spinner]
434
+ # @return [Proc] phrase -> void
435
+ def activity_for(spinner)
436
+ lambda { |phrase|
437
+ spinner.update(activity: phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold))
438
+ }
439
+ end
440
+
441
+ # One item, no concurrency to speak of: a serial round (`--isolation
442
+ # shared`), or the last plan left in a parallel one. A live spinner on a
443
+ # terminal; a start line and a finish line with an elapsed time otherwise.
444
+ #
445
+ # @param item [Object]
446
+ # @param label [Proc]
447
+ # @yieldparam item [Object]
448
+ # @return [Object]
449
+ def once(item, label, fields = NO_FIELDS, failure: NO_FAILURE, timeout: NO_TIMEOUT, &block)
450
+ text = label.call(item)
451
+ line = Line.new(fields: fields.call(item), mark: paint("done", :bright_black),
452
+ timeout: timeout.call(item), spinner: (solo_spinner(text) if animate?))
453
+ line.start
454
+ begin
455
+ result = block.call(item, line)
456
+ rescue => e
457
+ reason = e.message.lines.first.to_s.strip
458
+ line.failed(reason)
459
+ report_line("#{text}: #{reason}", bullet: "✗") unless animate?
460
+ raise
461
+ end
462
+ if (reason = failure.call(result))
463
+ line.failed(reason)
464
+ report_line("#{text}: #{reason}", bullet: "✗") unless animate?
465
+ else
466
+ line.done
467
+ report_line("#{text} (#{line.alive})", bullet: "✓") unless animate?
468
+ end
469
+ result
470
+ end
471
+
472
+ # The spinner a lone agent gets. Registered nowhere, because there is no
473
+ # second line for it to line up with.
474
+ #
475
+ # @param text [String]
476
+ # @return [TTY::Spinner]
477
+ def solo_spinner(text)
478
+ spinner = TTY::Spinner.new("[:spinner] :timer:meter#{text}:pid:activity", format: :dots, output: $stderr,
479
+ success_mark: paint("✓", :green), error_mark: paint("✖", :red))
480
+ spinner.update(timer: "", meter: meter(nil), activity: "", pid: "")
481
+ spinner.auto_spin
482
+ spinner
483
+ end
484
+
485
+ # Parallelism with no spinner to draw: a pipe, a CI log, or a headless
486
+ # agent's own tool call. Still reports a start and a finish line per
487
+ # item, because "no terminal" is not the same question as "no one is
488
+ # reading this."
489
+ #
490
+ # @param list [Array]
491
+ # @param jobs [Integer]
492
+ # @param message [String]
493
+ # @param label [Proc]
494
+ # @return [Array]
495
+ def threaded(list, jobs, message, label: :to_s.to_proc, fields: NO_FIELDS,
496
+ failure: NO_FAILURE, &)
497
+ report_line(message)
498
+ results = Concurrent::Hash.new
499
+ queue = Queue.new
500
+ list.each_with_index { |item, index| queue << [item, index] }
501
+
502
+ [jobs, list.size].min.times.map {
503
+ Thread.new do
504
+ while (pair = begin
505
+ queue.pop(true)
506
+ rescue ThreadError
507
+ nil
508
+ end)
509
+ item, index = pair
510
+ results[index] = begin
511
+ once(item, label, fields, failure:, &)
512
+ rescue => e
513
+ e
514
+ end
515
+ end
516
+ end
517
+ }.each(&:join)
518
+
519
+ list.each_index.map { |i| results[i] }
520
+ end
521
+
522
+ # @return [Float] a monotonic clock reading, immune to wall-clock changes
523
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
524
+
525
+ # @param started [Float] a {#monotonic} reading taken before the work began
526
+ # @return [String] e.g. "42s"
527
+ def elapsed(started) = "#{(monotonic - started).round}s"
528
+
529
+ # A progress line, thread-safe and quiet-aware — the one thing every
530
+ # non-spinner path above needs and would otherwise have to reimplement.
531
+ #
532
+ # @param text [String]
533
+ # @param bullet [String]
534
+ # @return [void]
535
+ def report_line(text, bullet: "·")
536
+ return if quiet
537
+
538
+ (@print_mutex ||= Mutex.new).synchronize { line(text, bullet:) }
539
+ end
540
+
541
+ # A sensible worker count: agents are mostly waiting on a model rather
542
+ # than burning CPU, so this is deliberately close to the core count. Two
543
+ # are left for the machine, and the cap keeps a very large tree from
544
+ # opening fifty subprocesses at once.
545
+ #
546
+ # @return [Integer]
547
+ def default_jobs = (Etc.nprocessors - 2).clamp(1, 12)
548
+
549
+ # Honours NO_COLOR — https://no-color.org
550
+ #
551
+ # @return [Boolean]
552
+ def color? = tty? && !ENV.key?("NO_COLOR")
553
+
554
+ # @return [Integer] a box width that fits the terminal but stays readable
555
+ def width = (TTY::Screen.width - 4).clamp(MIN_WIDTH, MAX_WIDTH)
556
+
557
+ # @param text [String]
558
+ # @param styles [Array<Symbol>] pastel style names
559
+ # @return [String] decorated when colour is on, bare otherwise
560
+ def paint(text, *styles) = color? ? pastel.decorate(text.to_s, *styles) : text.to_s
561
+
562
+ # @param text [String]
563
+ # @return [Integer] how many terminal cells the text occupies
564
+ def display_width(text) = Unicode::DisplayWidth.of(text.to_s)
565
+
566
+ # Pad or truncate to an exact number of terminal *cells*.
567
+ #
568
+ # `format`'s "%-20.20s" counts characters, and a character is not a cell.
569
+ # "✅" is one character two cells wide; "🅱️" is two characters one cell
570
+ # wide. Any column laid out with %s therefore drifts by one for every
571
+ # emoji whose two counts disagree — which is every emoji, in one
572
+ # direction or the other.
573
+ #
574
+ # @param text [String] unpainted; escape codes count as characters and
575
+ # would be padded like any other
576
+ # @param width [Integer] terminal cells
577
+ # @return [String]
578
+ def fit(text, width)
579
+ text = text.to_s
580
+ text = text[0..-2] while display_width(text) > width
581
+ text + (" " * (width - display_width(text)))
582
+ end
583
+
584
+ # Everything the user sees goes through here, so it writes to `$stderr`
585
+ # directly rather than through `Kernel.warn`.
586
+ #
587
+ # That is not a style preference. `Kernel.warn` is a **no-op** when
588
+ # `$VERBOSE` is nil, which is what `-W0` sets — and `RUBYOPT=-W0` is
589
+ # common in CI images and agent harnesses. Routed through `Kernel.warn`,
590
+ # every box this tool draws silently disappears in exactly the
591
+ # environments where a failure most needs explaining.
592
+ #
593
+ # @param kind [Symbol] :info, :warn, :error or :success
594
+ # @param message [String]
595
+ # @return [void]
596
+ # standard:disable Style/StderrPuts -- the cop's own rationale, "to allow
597
+ # such output to be disabled", is the behaviour being removed here.
598
+ def box(kind, message)
599
+ text = message.to_s
600
+ $stderr.puts TTY::Box.public_send(kind, text, enable_color: color?, width:, height: box_height(text))
601
+ end
602
+
603
+ # standard:enable Style/StderrPuts
604
+
605
+ # A framed panel centered on the screen, for the keyboard help. Unlike
606
+ # {.box} it positions itself absolutely, so it overlays whatever the
607
+ # spinners are drawing rather than scrolling in below them — the next
608
+ # repaint draws over it, which is all the dismissal a help screen needs.
609
+ #
610
+ # @param title [String]
611
+ # @param text [String]
612
+ # @return [void]
613
+ # standard:disable Style/StderrPuts -- see {.box}: `warn` is a no-op under -W0.
614
+ def popup(title, text)
615
+ lines = text.to_s.lines
616
+ box_width = [lines.map { |l| display_width(l.chomp) }.max.to_i + 6, TTY::Screen.width].min
617
+ box_height = lines.size + 4
618
+ $stderr.print TTY::Box.frame(
619
+ top: [(TTY::Screen.height - box_height) / 2, 0].max,
620
+ left: [(TTY::Screen.width - box_width) / 2, 0].max,
621
+ width: box_width, height: box_height, padding: 1,
622
+ title: {top_left: " #{title} "}, enable_color: color?,
623
+ style: color? ? {border: {fg: :cyan}} : {}
624
+ ) { text.to_s }
625
+ end
626
+ # standard:enable Style/StderrPuts
627
+
628
+ # TTY::Box sizes itself from the number of lines you hand it, not from
629
+ # the number those lines occupy once wrapped to the box's width. So it
630
+ # draws any message containing a line longer than the box a row or two
631
+ # short, and what falls off is the bottom, which is where the instruction
632
+ # lives. The run that found this reported four of its ten failures and
633
+ # cut the fifth mid-sentence.
634
+ #
635
+ # This wraps with the same library TTY::Box wraps with rather than
636
+ # dividing by the width, because TTY::Box wraps on words. A rough
637
+ # estimate is wrong in exactly the cases this exists for.
638
+ #
639
+ # @param text [String]
640
+ # @return [Integer] rows the box needs: its content, two borders, one pad
641
+ def box_height(text) = Strings.wrap(text.to_s, width - 4).lines.size + 3
642
+
643
+ # A single unadorned line, for per-item progress that does not deserve
644
+ # a box of its own.
645
+ #
646
+ # @param message [String]
647
+ # @param bullet [String]
648
+ # @return [void]
649
+ # standard:disable Style/StderrPuts -- see {.box}: `warn` is a no-op under -W0.
650
+ def line(message, bullet: "·") = $stderr.puts(" #{paint(bullet, :bright_black)} #{message}")
651
+
652
+ # standard:enable Style/StderrPuts
653
+ end
654
+
655
+ # @param message [String]
656
+ # @return [void]
657
+ def info(message) = UI.box(:info, message)
658
+
659
+ # @param message [String]
660
+ # @return [void]
661
+ def warn(message) = UI.box(:warn, message)
662
+
663
+ # @param message [String]
664
+ # @return [void]
665
+ def error(message) = UI.box(:error, message)
666
+
667
+ # @param message [String]
668
+ # @return [void]
669
+ def success(message) = UI.box(:success, message)
670
+
671
+ # @param message [String]
672
+ # @param bullet [String]
673
+ # @return [void]
674
+ def say(message, bullet: "·") = UI.line(message, bullet:)
675
+
676
+ # @param text [String]
677
+ # @param styles [Array<Symbol>]
678
+ # @return [String]
679
+ def paint(text, *styles) = UI.paint(text, *styles)
680
+ end
681
+ end