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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # What a `run` cost, once it is over.
5
+ #
6
+ # The loop reports movement — which plan went from ⭐️ to 🟡, which agent
7
+ # failed and why — and says nothing about the bill. A round of five agents
8
+ # against a large repository spends several million tokens and the better
9
+ # part of an hour, and until this existed the only record of that was a
10
+ # directory of trace files nobody reads.
11
+ #
12
+ # {#render} returns a string and prints nothing, so the caller decides where
13
+ # it goes, the same way {Reporter} works.
14
+ class Tally
15
+ # One agent's share of the run, added up across every round it ran in.
16
+ #
17
+ # @!attribute [r] name
18
+ # @return [String]
19
+ # @!attribute [r] invocations
20
+ # @return [Integer] times this agent was started
21
+ # @!attribute [r] subagents
22
+ # @return [Integer] sub-agents it spawned, across all of them
23
+ # @!attribute [r] up
24
+ # @return [Integer] tokens sent, sub-agent spend included
25
+ # @!attribute [r] down
26
+ # @return [Integer] tokens generated
27
+ # @!attribute [r] seconds
28
+ # @return [Float] how long it was running, added up
29
+ Row = Data.define(:name, :invocations, :subagents, :up, :down, :seconds)
30
+
31
+ # Cells per column, so the header, the rows and the rule cannot drift.
32
+ WIDTHS = {name: 22, invocations: 6, subagents: 11, up: 9, down: 9, seconds: 8}.freeze
33
+
34
+ # @param attempts [Array<Agentilda::Runner::Attempt>]
35
+ # @param seconds [Float] wall clock for the whole loop, which is not the
36
+ # sum of the agents' own times: several of them run at once
37
+ # @param rounds [Integer]
38
+ def initialize(attempts:, seconds:, rounds: 0)
39
+ @attempts = attempts.reject { |a| a.ordinal == "?" }
40
+ @seconds = seconds
41
+ @rounds = rounds
42
+ end
43
+
44
+ # @return [Array<Agentilda::Runner::Attempt>]
45
+ attr_reader :attempts
46
+
47
+ # @return [Float] wall clock
48
+ attr_reader :seconds
49
+
50
+ # @return [Integer]
51
+ attr_reader :rounds
52
+
53
+ # Plans an agent was actually started against, counted once each however
54
+ # many rounds they took.
55
+ #
56
+ # @return [Integer]
57
+ def plans = attempts.map(&:ordinal).uniq.size
58
+
59
+ # @return [Integer] tokens sent, everything included
60
+ def up = attempts.sum(&:up)
61
+
62
+ # @return [Integer] tokens generated
63
+ def down = attempts.sum(&:down)
64
+
65
+ # @return [Integer] sub-agents spawned by every agent in the run
66
+ def subagents = attempts.sum(&:subagents)
67
+
68
+ # Sub-agent spend that arrived as one number. `claude` reports what a
69
+ # sub-agent cost without splitting it between the two directions, so this
70
+ # much of {#up} is really up and down together.
71
+ #
72
+ # @return [Integer]
73
+ def delegated = attempts.sum(&:delegated)
74
+
75
+ # @return [Float] seconds of agent time, added up across all of them
76
+ def busy = attempts.sum(&:seconds)
77
+
78
+ # How many agents were running at any given moment, averaged over the
79
+ # run: agent-seconds divided by wall-clock seconds. Below the `-j` ceiling
80
+ # by however much the round spent waiting for its slowest member.
81
+ #
82
+ # @return [Float]
83
+ def concurrency = seconds.positive? ? busy / seconds : 0.0
84
+
85
+ # @return [Array<Agentilda::Tally::Row>] one per agent, dearest first
86
+ def by_agent
87
+ attempts.group_by(&:agent).map { |name, list|
88
+ Row.new(name:, invocations: list.size, subagents: list.sum(&:subagents),
89
+ up: list.sum(&:up), down: list.sum(&:down), seconds: list.sum(&:seconds))
90
+ }.sort_by { |row| -row.up }
91
+ end
92
+
93
+ # @return [String] the table and the summary line under it
94
+ def render
95
+ return summary if attempts.empty?
96
+
97
+ ([header, rule] + by_agent.map { |row| line(row) } + [rule, totals, "", summary]).join("\n")
98
+ end
99
+
100
+ # @return [String] the one-line version, for a box
101
+ def summary
102
+ [
103
+ "#{plans} plan#{"s" unless plans == 1} addressed",
104
+ "#{rounds} round#{"s" unless rounds == 1}",
105
+ duration(seconds),
106
+ "#{format("%.1f", concurrency)} agents at a time",
107
+ "↑ #{UI.abbreviate(up)} ↓ #{UI.abbreviate(down)}"
108
+ ].join(" · ") + delegation
109
+ end
110
+
111
+ private
112
+
113
+ # @return [String] the caveat, where there is one to make
114
+ def delegation
115
+ return "" if delegated.zero?
116
+
117
+ "\n#{subagents} sub-agent#{"s" unless subagents == 1} reported #{UI.abbreviate(delegated)} " \
118
+ "tokens without splitting them, counted above as ↑."
119
+ end
120
+
121
+ # @return [String]
122
+ def header
123
+ cell("Agent", :name) + %i[invocations subagents up down seconds]
124
+ .zip(["runs", "sub-agents", "↑", "↓", "time"])
125
+ .map { |key, text| right(text, key) }.join
126
+ end
127
+
128
+ # @return [String]
129
+ def rule = WIDTHS.values.map { |width| "─" * (width - 1) + " " }.join
130
+
131
+ # @param row [Agentilda::Tally::Row]
132
+ # @return [String]
133
+ def line(row)
134
+ cell(row.name, :name) + right(row.invocations.to_s, :invocations) +
135
+ right(row.subagents.to_s, :subagents) + right(UI.abbreviate(row.up), :up) +
136
+ right(UI.abbreviate(row.down), :down) + right(duration(row.seconds), :seconds)
137
+ end
138
+
139
+ # @return [String]
140
+ def totals
141
+ cell("", :name) + right(attempts.size.to_s, :invocations) + right(subagents.to_s, :subagents) +
142
+ right(UI.abbreviate(up), :up) + right(UI.abbreviate(down), :down) + right(duration(busy), :seconds)
143
+ end
144
+
145
+ # @param text [String]
146
+ # @param key [Symbol]
147
+ # @return [String] padded to its column, left-justified
148
+ def cell(text, key) = UI.fit(text, WIDTHS.fetch(key))
149
+
150
+ # @param text [String]
151
+ # @param key [Symbol]
152
+ # @return [String] padded to its column, right-justified
153
+ def right(text, key)
154
+ width = WIDTHS.fetch(key) - 1
155
+ text = UI.fit(text, width).rstrip
156
+ UI.fit("", width - UI.display_width(text)) + text + " "
157
+ end
158
+
159
+ # @param seconds [Numeric]
160
+ # @return [String] e.g. "42s", "12m40s", "1h04m"
161
+ def duration(seconds)
162
+ total = seconds.to_i
163
+ return "#{total}s" if total < 60
164
+ return "#{total / 60}m#{format("%02ds", total % 60)}" if total < 3600
165
+
166
+ "#{total / 3600}h#{format("%02dm", (total % 3600) / 60)}"
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,435 @@
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
+ Progress = Data.define(:activity, :up, :down, :subagents, :pid) do
51
+ def initialize(pid: nil, **rest) = super
52
+ end
53
+
54
+ # The one kind of line the trace does not keep.
55
+ #
56
+ # `--include-partial-messages` is what makes {#meter} possible, and it
57
+ # also emits one event per handful of generated characters. Those are the
58
+ # bulk of the stream and the only part of it nothing reads back: a
59
+ # fifteen-minute agent writes thousands of them, and they would bury the
60
+ # events a failed run is actually read for. Matched as a substring rather
61
+ # than parsed because the trace is written before a line is understood,
62
+ # which is what makes it complete when the process dies mid-invocation.
63
+ DELTA_NOISE = '"content_block_delta"'
64
+
65
+ # Longest phrase a spinner line can carry without wrapping into the next.
66
+ LIMIT = 56
67
+
68
+ # How each tool reads as something being done, rather than as a tool name.
69
+ # A spinner says what is happening; "Edit" is a noun and says nothing.
70
+ VERBS = {
71
+ "Read" => "reading", "Write" => "writing", "Edit" => "editing",
72
+ "MultiEdit" => "editing", "NotebookEdit" => "editing",
73
+ "Bash" => "running", "Grep" => "searching for", "Glob" => "looking for",
74
+ "Task" => "delegating", "TodoWrite" => "planning",
75
+ "WebFetch" => "fetching", "WebSearch" => "searching the web for"
76
+ }.freeze
77
+
78
+ # The input key worth naming, per tool, in the order we would rather say it.
79
+ # `description` outranks `command` deliberately: Claude's Bash tool carries
80
+ # a written summary of why it is running something, and "Read NOTES.md" is
81
+ # a better spinner line than sixty characters of absolute path.
82
+ SUBJECTS = %w[file_path pattern description command query url].freeze
83
+
84
+ # @param trace [String, nil] a file to keep the raw stream in, or nil to
85
+ # keep none. Every line is written before anything is made of it, so a
86
+ # run that failed can be read back afterwards even where this parser made
87
+ # nothing of an event it had never seen.
88
+ # @yieldparam progress [Agentilda::Transcript::Progress] each time the
89
+ # agent moves on to something new, and each time the meter moves
90
+ def initialize(trace: nil, &on_progress)
91
+ @on_progress = on_progress
92
+ @buffer = +""
93
+ @plain = []
94
+ @activity = nil
95
+ @result = nil
96
+ @error = nil
97
+ @trace_path = trace
98
+ @trace = trace && File.open(trace, "a")
99
+ @tools = 0
100
+ @main = {up: 0, down: 0}
101
+ @streamed = {}
102
+ @tasks = {}
103
+ @spawned = 0
104
+ end
105
+
106
+ # Everything sent to the model, which on any turn after the first is
107
+ # mostly cache reads: a typical agent turn is two fresh input tokens
108
+ # against a quarter of a million read back from cache. Counting only the
109
+ # fresh ones would report a fifteen-minute agent as having said nothing.
110
+ #
111
+ # @return [Integer]
112
+ def up = @main[:up] + streamed[:up] + delegated
113
+
114
+ # @return [Integer] tokens the model generated, thinking included
115
+ def down = @main[:down] + streamed[:down]
116
+
117
+ # @return [Integer] sub-agents this invocation started
118
+ attr_reader :spawned
119
+
120
+ # The `claude` process this transcript is reading, set by the {Executor}
121
+ # once it has found the child, so the spinner line can name a pid someone
122
+ # can actually `ps` while the agent runs.
123
+ #
124
+ # @return [Integer, nil]
125
+ attr_accessor :pid
126
+
127
+ # Sub-agent totals arrive as one number with no split between what went up
128
+ # and what came back, so they are counted as {#up}. They are held here as
129
+ # well, unmixed, because a report that says "of which N could not be
130
+ # split" is honest and one that quietly rounds is not.
131
+ #
132
+ # @return [Integer]
133
+ def delegated
134
+ @tasks.reject { |_, task| @streamed.key?(task[:tool_use_id]) }
135
+ .sum { |_, task| task[:total] }
136
+ end
137
+
138
+ # @return [Agentilda::Transcript::Progress] a snapshot, safe to keep
139
+ def progress = Progress.new(activity: @activity, up:, down:, subagents: @spawned, pid: @pid)
140
+
141
+ # @return [Integer] how many tool calls have gone past, which is the one
142
+ # honest measure of how much work an agent that says "done" actually did
143
+ attr_reader :tools
144
+
145
+ # @return [String, nil] the file the raw stream is being kept in
146
+ attr_reader :trace_path
147
+
148
+ # @return [String, nil] the last thing the agent was seen doing
149
+ attr_reader :activity
150
+
151
+ # @return [String, nil] the final text, from the `result` event
152
+ attr_reader :result
153
+
154
+ # @return [Array<String>] lines that were not JSON, in order
155
+ attr_reader :plain
156
+
157
+ # @return [Boolean] whether the stream reported its own failure
158
+ def failed? = !@error.nil?
159
+
160
+ # @return [String, nil] what the agent said went wrong
161
+ attr_reader :error
162
+
163
+ # Feed one chunk of stdout. Safe to call with a partial line, which is the
164
+ # normal case.
165
+ #
166
+ # @param chunk [String, nil]
167
+ # @return [void]
168
+ def push(chunk)
169
+ return if chunk.nil?
170
+
171
+ @buffer << chunk
172
+ while (index = @buffer.index("\n"))
173
+ line = @buffer.slice!(0..index).chomp
174
+ consume(line)
175
+ end
176
+ end
177
+
178
+ # Whatever is left when the process exits, which may be a last line with no
179
+ # newline after it.
180
+ #
181
+ # @return [void]
182
+ def finish
183
+ consume(@buffer.slice!(0..-1).to_s)
184
+ @trace&.close
185
+ @trace = nil
186
+ end
187
+
188
+ private
189
+
190
+ # @param line [String]
191
+ # @return [void]
192
+ def consume(line)
193
+ text = line.strip
194
+ return if text.empty?
195
+
196
+ record(text)
197
+
198
+ unless text.start_with?("{")
199
+ @plain << text
200
+ return
201
+ end
202
+
203
+ event = begin
204
+ JSON.parse(text)
205
+ rescue JSON::ParserError
206
+ @plain << text
207
+ return
208
+ end
209
+
210
+ handle(event)
211
+ end
212
+
213
+ # Written before the line is understood, and flushed, so the file is a
214
+ # complete record even when the process dies mid-invocation.
215
+ #
216
+ # @param text [String]
217
+ # @return [void]
218
+ def record(text)
219
+ return unless @trace
220
+ return if text.include?(DELTA_NOISE)
221
+
222
+ @trace.write("#{text}\n")
223
+ @trace.flush
224
+ end
225
+
226
+ # @param event [Hash]
227
+ # @return [void]
228
+ def handle(event)
229
+ case event["type"]
230
+ when "assistant"
231
+ @tools += tool_calls(event)
232
+ announce(phrase_for(event))
233
+ when "stream_event" then meter(event)
234
+ when "system" then delegation(event)
235
+ when "result" then record_result(event)
236
+ end
237
+ end
238
+
239
+ # @param event [Hash]
240
+ # @return [Integer] how many tools this one message called
241
+ def tool_calls(event)
242
+ blocks = event.dig("message", "content")
243
+ return 0 unless blocks.is_a?(Array)
244
+
245
+ blocks.count { |block| block.is_a?(Hash) && block["type"] == "tool_use" }
246
+ end
247
+
248
+ # Where the token count comes from.
249
+ #
250
+ # Only `message_delta` is trusted. The `assistant` events carry a usage
251
+ # block too, and its input side is right, but its `output_tokens` is the
252
+ # placeholder the API sends when a message *starts*: a four-thousand-token
253
+ # answer reports 2 there. `message_delta` closes each message with the
254
+ # settled figure for both directions, which is why {Executor} asks for
255
+ # `--include-partial-messages` at all.
256
+ #
257
+ # A message from a sub-agent carries `parent_tool_use_id`, and is kept in
258
+ # its own bucket so {#delegated} knows not to count that sub-agent twice.
259
+ #
260
+ # @param event [Hash]
261
+ # @return [void]
262
+ def meter(event)
263
+ return unless event.dig("event", "type") == "message_delta"
264
+
265
+ usage = event.dig("event", "usage") or return
266
+ bucket = bucket_for(event["parent_tool_use_id"])
267
+ bucket[:up] += usage.values_at("input_tokens", "cache_creation_input_tokens",
268
+ "cache_read_input_tokens").compact.sum
269
+ bucket[:down] += usage["output_tokens"].to_i
270
+ publish
271
+ end
272
+
273
+ # @param parent [String, nil] the tool call a sub-agent's message belongs to
274
+ # @return [Hash] the counters that message belongs in
275
+ def bucket_for(parent)
276
+ return @main if parent.nil?
277
+
278
+ @streamed[parent] ||= {up: 0, down: 0}
279
+ end
280
+
281
+ # @return [Hash] every streamed sub-agent's counters, added together
282
+ def streamed
283
+ @streamed.values.each_with_object({up: 0, down: 0}) do |b, sum|
284
+ sum[:up] += b[:up]
285
+ sum[:down] += b[:down]
286
+ end
287
+ end
288
+
289
+ # A sub-agent starting, and a sub-agent reporting what it spent.
290
+ #
291
+ # `claude` reports a sub-agent's spend as a single `total_tokens` with no
292
+ # split, and reports it cumulatively: the last number for a task is the
293
+ # whole of it, not an increment. So the total is stored per task and
294
+ # replaced rather than added, which also makes a `task_progress` stream
295
+ # and a closing `task_notification` for the same task agree instead of
296
+ # counting it twice.
297
+ #
298
+ # @param event [Hash]
299
+ # @return [void]
300
+ def delegation(event)
301
+ case event["subtype"]
302
+ when "task_started" then start_task(event)
303
+ when "task_progress", "task_notification" then update_task(event)
304
+ end
305
+ end
306
+
307
+ # @param event [Hash]
308
+ # @return [void]
309
+ def start_task(event)
310
+ id = event["task_id"] or return
311
+
312
+ @tasks[id] ||= {tool_use_id: event["tool_use_id"], total: 0}
313
+ @spawned = @tasks.size
314
+ publish
315
+ end
316
+
317
+ # @param event [Hash]
318
+ # @return [void]
319
+ def update_task(event)
320
+ id = event["task_id"] or return
321
+
322
+ task = (@tasks[id] ||= {tool_use_id: event["tool_use_id"], total: 0})
323
+ task[:tool_use_id] ||= event["tool_use_id"]
324
+ task[:total] = event.dig("usage", "total_tokens").to_i
325
+ @spawned = @tasks.size
326
+ publish
327
+ end
328
+
329
+ # The `result` event settles what the stream could only accumulate: its
330
+ # usage is the invocation's own final accounting, so it replaces the
331
+ # running total rather than adding to it. Sub-agent spend is left alone,
332
+ # because `claude` leaves it out of this figure — a run whose two
333
+ # sub-agents burned 34k each reported 493 output tokens here.
334
+ #
335
+ # @param event [Hash]
336
+ # @return [void]
337
+ def record_result(event)
338
+ @result = event["result"].to_s
339
+ @error = @result if event["is_error"]
340
+ settle(event["usage"])
341
+ @spawned = [@spawned, event.dig("subagent_stats", "spawned").to_i].max
342
+ publish
343
+ end
344
+
345
+ # The most recent block wins: an agent that says "now I'll edit the spec"
346
+ # and then edits it is more usefully reported as editing it.
347
+ #
348
+ # @param event [Hash]
349
+ # @return [String, nil]
350
+ def phrase_for(event)
351
+ blocks = event.dig("message", "content")
352
+ return nil unless blocks.is_a?(Array)
353
+
354
+ blocks.reverse.filter_map { |block| block_phrase(block) }.first
355
+ end
356
+
357
+ # @param block [Hash]
358
+ # @return [String, nil]
359
+ def block_phrase(block)
360
+ case block["type"]
361
+ when "tool_use" then tool_phrase(block)
362
+ when "text" then sentence(block["text"])
363
+ end
364
+ end
365
+
366
+ # @param block [Hash]
367
+ # @return [String]
368
+ def tool_phrase(block)
369
+ verb = VERBS.fetch(block["name"].to_s, "using #{block["name"]}")
370
+ input = block["input"]
371
+ return verb unless input.is_a?(Hash)
372
+
373
+ key = SUBJECTS.find { |k| input[k].to_s.strip != "" }
374
+ return verb unless key
375
+ # A description is already a phrase. Prefixing it with a verb gives
376
+ # "running Read NOTES.md", which reads worse than either half alone.
377
+ return clip(noun(key, input[key])) if key == "description"
378
+
379
+ clip("#{verb} #{noun(key, input[key])}")
380
+ end
381
+
382
+ # A path is only ever interesting by its last segment on a line this short.
383
+ #
384
+ # @param key [String]
385
+ # @param value [Object]
386
+ # @return [String]
387
+ def noun(key, value)
388
+ text = value.to_s.strip.tr("\n", " ").squeeze(" ")
389
+ (key == "file_path") ? File.basename(text) : text
390
+ end
391
+
392
+ # The agent's own narration, first sentence only. Everything after it is
393
+ # detail the spinner has no room for.
394
+ #
395
+ # @param text [String, nil]
396
+ # @return [String, nil]
397
+ def sentence(text)
398
+ line = text.to_s.split("\n").map(&:strip).find { |l| !l.empty? } or return nil
399
+ line = line.sub(/\A[#>*\-\s]+/, "").split(/(?<=[.!?])\s/).first.to_s.strip
400
+ line.empty? ? nil : clip(line)
401
+ end
402
+
403
+ # @param text [String]
404
+ # @return [String]
405
+ def clip(text) = (text.length > LIMIT) ? "#{text[0, LIMIT - 1]}…" : text
406
+
407
+ # @param usage [Hash, nil] the `result` event's own accounting
408
+ # @return [void]
409
+ def settle(usage)
410
+ return unless usage.is_a?(Hash)
411
+
412
+ @main = {
413
+ up: usage.values_at("input_tokens", "cache_creation_input_tokens",
414
+ "cache_read_input_tokens").compact.sum,
415
+ down: usage["output_tokens"].to_i
416
+ }
417
+ end
418
+
419
+ # @param phrase [String, nil]
420
+ # @return [void]
421
+ def announce(phrase)
422
+ return if phrase.nil? || phrase == @activity
423
+
424
+ @activity = phrase
425
+ publish
426
+ end
427
+
428
+ # Hand the caller a whole snapshot rather than the one field that moved.
429
+ # A spinner line draws the phrase and the meter together, and a caller
430
+ # given only the half that changed would have to remember the other.
431
+ #
432
+ # @return [void]
433
+ def publish = @on_progress&.call(progress)
434
+ end
435
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # A project's `.plans` directory: the folders in it, decoded and ordered.
5
+ class Tree
6
+ # @param dir [String] the `.plans` directory
7
+ def initialize(dir:)
8
+ @dir = File.expand_path(dir)
9
+ end
10
+
11
+ # @return [String] absolute path to the `.plans` directory
12
+ attr_reader :dir
13
+
14
+ # @return [Boolean]
15
+ def exist? = File.directory?(dir)
16
+
17
+ # @return [Array<Agentilda::Feature>] ordered by number
18
+ def features = @features ||= directories.filter_map { |p| Feature.parse(p) }.sort
19
+
20
+ # @return [Array<Agentilda::Subject>] ordered by number
21
+ def subjects = @subjects ||= features.map { |f| Subject.new(f) }
22
+
23
+ # @return [Array<Agentilda::Ordinal>]
24
+ def ordinals = features.map(&:ordinal)
25
+
26
+ # Directories that carry no plan number. Reported, never processed.
27
+ #
28
+ # @return [Array<String>]
29
+ def skipped = @skipped ||= directories.reject { |p| Feature.parse(p) }.map { |p| File.basename(p) }
30
+
31
+ # Numbers claimed by more than one folder.
32
+ #
33
+ # A plan's number is its identity: branch names, pull request titles and
34
+ # every `pull-requests.md` join on it. Two folders wearing the same number
35
+ # make every one of those joins ambiguous, and nothing downstream can tell
36
+ # which folder a `[002.00]` pull request belongs to.
37
+ #
38
+ # @return [Hash{Agentilda::Ordinal => Array<String>}] number => dirnames
39
+ def duplicates
40
+ features.group_by(&:ordinal)
41
+ .select { |_, group| group.size > 1 }
42
+ .transform_values { |group| group.map(&:dirname) }
43
+ end
44
+
45
+ # @param ordinal [Agentilda::Ordinal, String]
46
+ # @return [Agentilda::Subject, nil]
47
+ def find(ordinal)
48
+ wanted = ordinal.is_a?(Ordinal) ? ordinal : Ordinal.parse(ordinal)
49
+ subjects.find { |s| s.feature.ordinal == wanted }
50
+ end
51
+
52
+ # @return [Boolean] whether a plan with this number exists on disk
53
+ def include?(ordinal) = !find(ordinal).nil?
54
+
55
+ # Forget everything read from disk, so a caller that has just renamed
56
+ # folders sees the new state.
57
+ #
58
+ # @return [self]
59
+ def reload
60
+ @features = @subjects = @skipped = nil
61
+ self
62
+ end
63
+
64
+ private
65
+
66
+ # @return [Array<String>] absolute paths of every subdirectory
67
+ def directories
68
+ return [] unless exist?
69
+
70
+ Dir.children(dir)
71
+ .map { |c| File.join(dir, c) }
72
+ .select { |p| File.directory?(p) }
73
+ .reject { |p| File.basename(p).start_with?(".") }
74
+ .sort
75
+ end
76
+ end
77
+ end