agentilda 2.0.1 → 2.0.4

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -1,466 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Agentilda
6
- # What an agent is doing, while it is still doing it.
7
- #
8
- # `claude -p` prints nothing until it exits, so a fifteen-minute agent used to
9
- # sit behind a spinner that said only its name. Asked for `--output-format
10
- # stream-json` it emits one JSON object per line as it works, and this turns
11
- # that stream into the short phrase a spinner line has room for: "editing
12
- # spec.md", "reading plan.md", whatever the agent last said about itself.
13
- #
14
- # Three things make this less trivial than parsing JSON.
15
- #
16
- # Chunks arrive from `readpartial`, so a line is split across two of them as
17
- # often as not. {#push} buffers and only ever parses whole lines.
18
- #
19
- # Not every line is JSON. `claude` reports its own failures as prose on
20
- # stdout, and the 401 that cost a whole run three minutes an agent was one of
21
- # those. Those lines are kept in {#plain} so a failure can still be explained
22
- # by what was actually printed.
23
- #
24
- # The reader runs on its own thread, so {#activity} is written from one
25
- # thread and read from another. It is a single assignment of an immutable
26
- # string, which is why there is no lock here.
27
- #
28
- # Every line is also kept verbatim in an optional trace file, written before
29
- # anything is made of it, so a run that failed can be read back afterwards
30
- # even where this parser made nothing of an event it had never seen. See
31
- # {Executor::TRACE_DIR} for where those land and how to read one.
32
- class Transcript
33
- # What one line of the display knows: the phrase, and the meter beside it.
34
- #
35
- # Yielded whole rather than as loose arguments so a caller cannot render
36
- # half an update, and frozen so the reader thread and the drawing thread
37
- # are never looking at the same mutable object.
38
- #
39
- # @!attribute [r] activity
40
- # @return [String, nil] the last thing the agent was seen doing
41
- # @!attribute [r] up
42
- # @return [Integer] tokens sent, cache reads included
43
- # @!attribute [r] down
44
- # @return [Integer] tokens generated
45
- # @!attribute [r] subagents
46
- # @return [Integer] sub-agents spawned so far
47
- # @!attribute [r] pid
48
- # @return [Integer, nil] the `claude` process, once the harness has
49
- # found it — nil until then, and for callers that never look
50
- # @!attribute [r] message
51
- # @return [String, nil] the last thing the agent chose to say directly,
52
- # via `SendUserMessage`, or nil if it never sent one
53
- Progress = Data.define(:activity, :up, :down, :subagents, :pid, :message) do
54
- def initialize(pid: nil, message: nil, **rest) = super
55
- end
56
-
57
- # The one kind of line the trace does not keep.
58
- #
59
- # `--include-partial-messages` is what makes {#meter} possible, and it
60
- # also emits one event per handful of generated characters. Those are the
61
- # bulk of the stream and the only part of it nothing reads back: a
62
- # fifteen-minute agent writes thousands of them, and they would bury the
63
- # events a failed run is actually read for. Matched as a substring rather
64
- # than parsed because the trace is written before a line is understood,
65
- # which is what makes it complete when the process dies mid-invocation.
66
- DELTA_NOISE = '"content_block_delta"'
67
-
68
- # Longest phrase kept. A sanity cap, not a layout rule: each place that
69
- # draws a phrase cuts it to the room it has, and the dashboard has most
70
- # of a terminal's width.
71
- LIMIT = 300
72
-
73
- # How each tool reads as something being done, rather than as a tool name.
74
- # A spinner says what is happening; "Edit" is a noun and says nothing.
75
- VERBS = {
76
- "Read" => "reading", "Write" => "writing", "Edit" => "editing",
77
- "MultiEdit" => "editing", "NotebookEdit" => "editing",
78
- "Bash" => "running", "Grep" => "searching for", "Glob" => "looking for",
79
- "Task" => "delegating", "TodoWrite" => "planning",
80
- "WebFetch" => "fetching", "WebSearch" => "searching the web for",
81
- "SendUserMessage" => "saying"
82
- }.freeze
83
-
84
- # The input key worth naming, per tool, in the order we would rather say it.
85
- # `description` outranks `command` deliberately: Claude's Bash tool carries
86
- # a written summary of why it is running something, and "Read NOTES.md" is
87
- # a better spinner line than sixty characters of absolute path.
88
- SUBJECTS = %w[file_path pattern description command query url].freeze
89
-
90
- # @param trace [String, nil] a file to keep the raw stream in, or nil to
91
- # keep none. Every line is written before anything is made of it, so a
92
- # run that failed can be read back afterwards even where this parser made
93
- # nothing of an event it had never seen.
94
- # @yieldparam progress [Agentilda::Transcript::Progress] each time the
95
- # agent moves on to something new, and each time the meter moves
96
- def initialize(trace: nil, &on_progress)
97
- @on_progress = on_progress
98
- @buffer = +""
99
- @plain = []
100
- @activity = nil
101
- @message = nil
102
- @result = nil
103
- @error = nil
104
- @trace_path = trace
105
- @trace = trace && File.open(trace, "a")
106
- @tools = 0
107
- @main = { up: 0, down: 0 }
108
- @streamed = {}
109
- @tasks = {}
110
- @spawned = 0
111
- end
112
-
113
- # Everything sent to the model, which on any turn after the first is
114
- # mostly cache reads: a typical agent turn is two fresh input tokens
115
- # against a quarter of a million read back from cache. Counting only the
116
- # fresh ones would report a fifteen-minute agent as having said nothing.
117
- #
118
- # @return [Integer]
119
- def up = @main[:up] + streamed[:up] + delegated
120
-
121
- # @return [Integer] tokens the model generated, thinking included
122
- def down = @main[:down] + streamed[:down]
123
-
124
- # @return [Integer] sub-agents this invocation started
125
- attr_reader :spawned
126
-
127
- # The `claude` process this transcript is reading, set by the {Executor}
128
- # once it has found the child, so the spinner line can name a pid someone
129
- # can actually `ps` while the agent runs.
130
- #
131
- # @return [Integer, nil]
132
- attr_accessor :pid
133
-
134
- # Sub-agent totals arrive as one number with no split between what went up
135
- # and what came back, so they are counted as {#up}. They are held here as
136
- # well, unmixed, because a report that says "of which N could not be
137
- # split" is honest and one that quietly rounds is not.
138
- #
139
- # @return [Integer]
140
- def delegated
141
- @tasks.reject { |_, task| @streamed.key?(task[:tool_use_id]) }
142
- .sum { |_, task| task[:total] }
143
- end
144
-
145
- # @return [Agentilda::Transcript::Progress] a snapshot, safe to keep
146
- def progress = Progress.new(activity: @activity, up:, down:, subagents: @spawned, pid: @pid, message: @message)
147
-
148
- # @return [Integer] how many tool calls have gone past, which is the one
149
- # honest measure of how much work an agent that says "done" actually did
150
- attr_reader :tools
151
-
152
- # @return [String, nil] the file the raw stream is being kept in
153
- attr_reader :trace_path
154
-
155
- # @return [String, nil] the last thing the agent was seen doing
156
- attr_reader :activity
157
-
158
- # @return [String, nil] the last thing the agent sent via `SendUserMessage`
159
- attr_reader :message
160
-
161
- # @return [String, nil] the final text, from the `result` event
162
- attr_reader :result
163
-
164
- # @return [Array<String>] lines that were not JSON, in order
165
- attr_reader :plain
166
-
167
- # @return [Boolean] whether the stream reported its own failure
168
- def failed? = !@error.nil?
169
-
170
- # @return [String, nil] what the agent said went wrong
171
- attr_reader :error
172
-
173
- # Feed one chunk of stdout. Safe to call with a partial line, which is the
174
- # normal case.
175
- #
176
- # @param chunk [String, nil]
177
- # @return [void]
178
- def push(chunk)
179
- return if chunk.nil?
180
-
181
- @buffer << chunk
182
- while (index = @buffer.index("\n"))
183
- line = @buffer.slice!(0..index).chomp
184
- consume(line)
185
- end
186
- end
187
-
188
- # Whatever is left when the process exits, which may be a last line with no
189
- # newline after it.
190
- #
191
- # @return [void]
192
- def finish
193
- consume(@buffer.slice!(0..-1).to_s)
194
- @trace&.close
195
- @trace = nil
196
- end
197
-
198
- private
199
-
200
- # @param line [String]
201
- # @return [void]
202
- def consume(line)
203
- text = line.strip
204
- return if text.empty?
205
-
206
- record(text)
207
-
208
- unless text.start_with?("{")
209
- @plain << text
210
- return
211
- end
212
-
213
- event = begin
214
- JSON.parse(text)
215
- rescue JSON::ParserError
216
- @plain << text
217
- return
218
- end
219
-
220
- handle(event)
221
- end
222
-
223
- # Written before the line is understood, and flushed, so the file is a
224
- # complete record even when the process dies mid-invocation.
225
- #
226
- # @param text [String]
227
- # @return [void]
228
- def record(text)
229
- return unless @trace
230
- return if text.include?(DELTA_NOISE)
231
-
232
- @trace.write("#{text}\n")
233
- @trace.flush
234
- end
235
-
236
- # @param event [Hash]
237
- # @return [void]
238
- def handle(event)
239
- case event["type"]
240
- when "assistant"
241
- capture_message(event)
242
- @tools += tool_calls(event)
243
- announce(phrase_for(event))
244
- when "stream_event" then meter(event)
245
- when "system" then delegation(event)
246
- when "result" then record_result(event)
247
- end
248
- end
249
-
250
- # `--brief` gives the agent SendUserMessage, and what it sends is the
251
- # one line on the screen the agent itself chose. It outranks the tool
252
- # phrase until the agent sends another.
253
- #
254
- # @param event [Hash]
255
- # @return [void]
256
- def capture_message(event)
257
- blocks = event.dig("message", "content")
258
- return unless blocks.is_a?(Array)
259
-
260
- blocks.each do |block|
261
- next unless block.is_a?(Hash) && block["type"] == "tool_use" && block["name"] == "SendUserMessage"
262
-
263
- text = block.dig("input", "message").to_s.strip
264
- @message = clip(text) unless text.empty?
265
- end
266
- end
267
-
268
- # @param event [Hash]
269
- # @return [Integer] how many tools this one message called
270
- def tool_calls(event)
271
- blocks = event.dig("message", "content")
272
- return 0 unless blocks.is_a?(Array)
273
-
274
- blocks.count { |block| block.is_a?(Hash) && block["type"] == "tool_use" }
275
- end
276
-
277
- # Where the token count comes from.
278
- #
279
- # Only `message_delta` is trusted. The `assistant` events carry a usage
280
- # block too, and its input side is right, but its `output_tokens` is the
281
- # placeholder the API sends when a message *starts*: a four-thousand-token
282
- # answer reports 2 there. `message_delta` closes each message with the
283
- # settled figure for both directions, which is why {Executor} asks for
284
- # `--include-partial-messages` at all.
285
- #
286
- # A message from a sub-agent carries `parent_tool_use_id`, and is kept in
287
- # its own bucket so {#delegated} knows not to count that sub-agent twice.
288
- #
289
- # @param event [Hash]
290
- # @return [void]
291
- def meter(event)
292
- return unless event.dig("event", "type") == "message_delta"
293
-
294
- usage = event.dig("event", "usage") or return
295
- bucket = bucket_for(event["parent_tool_use_id"])
296
- bucket[:up] += usage.values_at("input_tokens",
297
- "cache_creation_input_tokens",
298
- "cache_read_input_tokens").compact.sum
299
- bucket[:down] += usage["output_tokens"].to_i
300
- publish
301
- end
302
-
303
- # @param parent [String, nil] the tool call a sub-agent's message belongs to
304
- # @return [Hash] the counters that message belongs in
305
- def bucket_for(parent)
306
- return @main if parent.nil?
307
-
308
- @streamed[parent] ||= { up: 0, down: 0 }
309
- end
310
-
311
- # @return [Hash] every streamed sub-agent's counters, added together
312
- def streamed
313
- @streamed.values.each_with_object({ up: 0, down: 0 }) do |b, sum|
314
- sum[:up] += b[:up]
315
- sum[:down] += b[:down]
316
- end
317
- end
318
-
319
- # A sub-agent starting, and a sub-agent reporting what it spent.
320
- #
321
- # `claude` reports a sub-agent's spend as a single `total_tokens` with no
322
- # split, and reports it cumulatively: the last number for a task is the
323
- # whole of it, not an increment. So the total is stored per task and
324
- # replaced rather than added, which also makes a `task_progress` stream
325
- # and a closing `task_notification` for the same task agree instead of
326
- # counting it twice.
327
- #
328
- # @param event [Hash]
329
- # @return [void]
330
- def delegation(event)
331
- case event["subtype"]
332
- when "task_started" then start_task(event)
333
- when "task_progress", "task_notification" then update_task(event)
334
- end
335
- end
336
-
337
- # @param event [Hash]
338
- # @return [void]
339
- def start_task(event)
340
- id = event["task_id"] or return
341
-
342
- @tasks[id] ||= { tool_use_id: event["tool_use_id"], total: 0 }
343
- @spawned = @tasks.size
344
- publish
345
- end
346
-
347
- # @param event [Hash]
348
- # @return [void]
349
- def update_task(event)
350
- id = event["task_id"] or return
351
-
352
- task = (@tasks[id] ||= { tool_use_id: event["tool_use_id"], total: 0 })
353
- task[:tool_use_id] ||= event["tool_use_id"]
354
- task[:total] = event.dig("usage", "total_tokens").to_i
355
- @spawned = @tasks.size
356
- publish
357
- end
358
-
359
- # The `result` event settles what the stream could only accumulate: its
360
- # usage is the invocation's own final accounting, so it replaces the
361
- # running total rather than adding to it. Sub-agent spend is left alone,
362
- # because `claude` leaves it out of this figure — a run whose two
363
- # sub-agents burned 34k each reported 493 output tokens here.
364
- #
365
- # @param event [Hash]
366
- # @return [void]
367
- def record_result(event)
368
- @result = event["result"].to_s
369
- @error = @result if event["is_error"]
370
- settle(event["usage"])
371
- @spawned = [@spawned, event.dig("subagent_stats", "spawned").to_i].max
372
- publish
373
- end
374
-
375
- # The most recent block wins: an agent that says "now I'll edit the spec"
376
- # and then edits it is more usefully reported as editing it.
377
- #
378
- # @param event [Hash]
379
- # @return [String, nil]
380
- def phrase_for(event)
381
- blocks = event.dig("message", "content")
382
- return nil unless blocks.is_a?(Array)
383
-
384
- blocks.reverse.filter_map { |block| block_phrase(block) }.first
385
- end
386
-
387
- # @param block [Hash]
388
- # @return [String, nil]
389
- def block_phrase(block)
390
- case block["type"]
391
- when "tool_use" then tool_phrase(block)
392
- when "text" then sentence(block["text"])
393
- end
394
- end
395
-
396
- # @param block [Hash]
397
- # @return [String]
398
- def tool_phrase(block)
399
- verb = VERBS.fetch(block["name"].to_s, "using #{block["name"]}")
400
- input = block["input"]
401
- return verb unless input.is_a?(Hash)
402
-
403
- key = SUBJECTS.find { |k| input[k].to_s.strip != "" }
404
- return verb unless key
405
- # A description is already a phrase. Prefixing it with a verb gives
406
- # "running Read NOTES.md", which reads worse than either half alone.
407
- return clip(noun(key, input[key])) if key == "description"
408
-
409
- clip("#{verb} #{noun(key, input[key])}")
410
- end
411
-
412
- # A path is only ever interesting by its last segment on a line this short.
413
- #
414
- # @param key [String]
415
- # @param value [Object]
416
- # @return [String]
417
- def noun(key, value)
418
- text = value.to_s.strip.tr("\n", " ").squeeze(" ")
419
- key == "file_path" ? File.basename(text) : text
420
- end
421
-
422
- # The agent's own narration, first sentence only. Everything after it is
423
- # detail the spinner has no room for.
424
- #
425
- # @param text [String, nil]
426
- # @return [String, nil]
427
- def sentence(text)
428
- line = text.to_s.split("\n").map(&:strip).find { |l| !l.empty? } or return nil
429
- line = line.sub(/\A[#>*\-\s]+/, "").split(/(?<=[.!?])\s/).first.to_s.strip
430
- line.empty? ? nil : clip(line)
431
- end
432
-
433
- # @param text [String]
434
- # @return [String]
435
- def clip(text) = text.length > LIMIT ? "#{text[0, LIMIT - 1]}…" : text
436
-
437
- # @param usage [Hash, nil] the `result` event's own accounting
438
- # @return [void]
439
- def settle(usage)
440
- return unless usage.is_a?(Hash)
441
-
442
- @main = {
443
- up: usage.values_at("input_tokens",
444
- "cache_creation_input_tokens",
445
- "cache_read_input_tokens").compact.sum,
446
- down: usage["output_tokens"].to_i
447
- }
448
- end
449
-
450
- # @param phrase [String, nil]
451
- # @return [void]
452
- def announce(phrase)
453
- return if phrase.nil? || phrase == @activity
454
-
455
- @activity = phrase
456
- publish
457
- end
458
-
459
- # Hand the caller a whole snapshot rather than the one field that moved.
460
- # A spinner line draws the phrase and the meter together, and a caller
461
- # given only the half that changed would have to remember the other.
462
- #
463
- # @return [void]
464
- def publish = @on_progress&.call(progress)
465
- end
466
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "agentilda/feature"
4
- require "agentilda/ordinal"
5
- require "agentilda/subject"
6
-
7
- module Agentilda
8
- # A project's `.plans` directory: the folders in it, decoded and ordered.
9
- class Tree
10
- # @param dir [String] the `.plans` directory
11
- def initialize(dir:)
12
- @dir = File.expand_path(dir)
13
- end
14
-
15
- # @return [String] absolute path to the `.plans` directory
16
- attr_reader :dir
17
-
18
- # @return [Boolean]
19
- def exist? = File.directory?(dir)
20
-
21
- # @return [Array<Agentilda::Feature>] ordered by number
22
- def features = @features ||= directories.filter_map { |p| Feature.parse(p) }.sort
23
-
24
- # @return [Array<Agentilda::Subject>] ordered by number
25
- def subjects = @subjects ||= features.map { |f| Subject.new(f) }
26
-
27
- # @return [Array<Agentilda::Ordinal>]
28
- def ordinals = features.map(&:ordinal)
29
-
30
- # Directories that carry no plan number. Reported, never processed.
31
- #
32
- # @return [Array<String>]
33
- def skipped = @skipped ||= directories.reject { |p| Feature.parse(p) }.map { |p| File.basename(p) }
34
-
35
- # Numbers claimed by more than one folder.
36
- #
37
- # A plan's number is its identity: branch names, pull request titles and
38
- # every `pull-requests.md` join on it. Two folders wearing the same number
39
- # make every one of those joins ambiguous, and nothing downstream can tell
40
- # which folder a `[002.00]` pull request belongs to.
41
- #
42
- # @return [Hash{Agentilda::Ordinal => Array<String>}] number => dirnames
43
- def duplicates
44
- features.group_by(&:ordinal)
45
- .select { |_, group| group.size > 1 }
46
- .transform_values { |group| group.map(&:dirname) }
47
- end
48
-
49
- # @param ordinal [Agentilda::Ordinal, String]
50
- # @return [Agentilda::Subject, nil]
51
- def find(ordinal)
52
- wanted = ordinal.is_a?(Ordinal) ? ordinal : Ordinal.parse(ordinal)
53
- subjects.find { |s| s.feature.ordinal == wanted }
54
- end
55
-
56
- # @return [Boolean] whether a plan with this number exists on disk
57
- def include?(ordinal) = !find(ordinal).nil?
58
-
59
- # Forget everything read from disk, so a caller that has just renamed
60
- # folders sees the new state.
61
- #
62
- # @return [self]
63
- def reload
64
- @features = @subjects = @skipped = nil
65
- self
66
- end
67
-
68
- private
69
-
70
- # @return [Array<String>] absolute paths of every subdirectory
71
- def directories
72
- return [] unless exist?
73
-
74
- Dir.children(dir)
75
- .map { |c| File.join(dir, c) }
76
- .select { |p| File.directory?(p) }
77
- .reject { |p| File.basename(p).start_with?(".") }
78
- .sort
79
- end
80
- end
81
- end