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
@@ -55,6 +55,12 @@ module Agentilda
55
55
  # would leave `yoda-writer` reading two files to write one.
56
56
  RESEARCH_CHAPTER = /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i
57
57
 
58
+ # What proves `plan.md` holds a plan rather than the blank file
59
+ # `yoda-writer` leaves for `palpatine-planner`: any section heading. The
60
+ # ledger notes are stripped first, so an agent's `Started` line does not
61
+ # count as a plan.
62
+ PLAN_HEADING = /^[ \t]{0,3}\#{1,3}[ \t]+\S/
63
+
58
64
  # Variation selectors make ⚪️ and ⚪ different strings that mean the same
59
65
  # thing to a human. Compare with them removed.
60
66
  #
@@ -135,22 +141,51 @@ module Agentilda
135
141
  # the mermaid source in the generated document moves with this list.
136
142
  STATUSES = [
137
143
  Status.new(
138
- key: :new, emoji: "⚪️", label: "New", requires: %w[spec.md],
139
- note: "a specification exists; it has not been planned yet",
144
+ key: :new,
145
+ emoji: "⚪️",
146
+ label: "New",
147
+ requires: %w[spec.md],
148
+ note: "a specification exists; it has not been planned yet",
140
149
  invariant: nil
141
150
  ),
142
151
  Status.new(
143
- key: :researched, emoji: "🔎", label: "Researched", requires: %w[spec.md],
144
- note: "the topic has been researched; `spec.md` carries a `## Research` chapter",
152
+ key: :researched,
153
+ emoji: "🔎",
154
+ label: "Researched",
155
+ requires: %w[spec.md],
156
+ note: "the topic has been researched; `spec.md` carries a `## Research` chapter",
145
157
  invariant: lambda { |s|
146
158
  body = s.read("spec.md").to_s
147
159
  "Researched, but `spec.md` has no `## Research` chapter" unless body.match?(RESEARCH_CHAPTER)
148
160
  }
149
161
  ),
150
162
  Status.new(
151
- key: :planned, emoji: "⭐️", label: "Planned", requires: %w[spec.md plan.md],
152
- note: "specified and planned; nobody has started building",
153
- invariant: nil
163
+ key: :ready_for_planning,
164
+ emoji: "📋",
165
+ label: "Ready for Planning",
166
+ requires: %w[spec.md plan.md],
167
+ note: "the specification is finished; `plan.md` exists and is still blank, waiting for the planner",
168
+ invariant: lambda { |s|
169
+ body = Ledger.stripped(s.read("plan.md"))
170
+ "Ready for Planning, but `plan.md` already holds a plan" if body.match?(PLAN_HEADING)
171
+ }
172
+ ),
173
+ Status.new(
174
+ key: :planned,
175
+ emoji: "⭐️",
176
+ label: "Planned",
177
+ requires: %w[spec.md plan.md],
178
+ note: "specified and planned; nobody has started building",
179
+ invariant: lambda { |s|
180
+ body = Ledger.stripped(s.read("plan.md"))
181
+ next "Planned, but `plan.md` has no work units yet" unless body.match?(PLAN_HEADING)
182
+
183
+ # An open pull request means somebody did start. Without this
184
+ # clause a ✅ folder found with a new pull request would fall back
185
+ # to ⭐️, the family's floor, and claim nobody had touched it.
186
+ open = s.pull_requests.count(&:open?)
187
+ "Planned, but #{Agentilda.pull_request_count(open)} already open" if open.positive?
188
+ }
154
189
  ),
155
190
  # `pull-requests.md` is deliberately absent from `requires` on both of the
156
191
  # building states, though it names the file they are about. That file is
@@ -162,9 +197,15 @@ module Agentilda
162
197
  # file becomes a real requirement, once something has had the chance to
163
198
  # write it.
164
199
  Status.new(
165
- key: :building, emoji: "🟡", label: "Building", requires: %w[spec.md plan.md],
166
- note: "the back end is under way: data, domain and the API the interface will call",
167
- invariant: nil
200
+ key: :building,
201
+ emoji: "🟡",
202
+ label: "Building",
203
+ requires: %w[spec.md plan.md],
204
+ note: "the back end is under way: data, domain and the API the interface will call",
205
+ invariant: lambda { |s|
206
+ body = Ledger.stripped(s.read("plan.md"))
207
+ "Building, but `plan.md` has no work units to build" unless body.match?(PLAN_HEADING)
208
+ }
168
209
  ),
169
210
  # Two building states rather than one, because the order is not a
170
211
  # preference: an interface is written against an API that already answers.
@@ -172,28 +213,46 @@ module Agentilda
172
213
  # at that half, and gives the second a working system to build on rather
173
214
  # than a description of one.
174
215
  Status.new(
175
- key: :building_ui, emoji: "🎨", label: "Building UI", requires: %w[spec.md plan.md],
176
- note: "the back end holds; the interface is being built against it",
177
- invariant: nil
216
+ key: :building_ui,
217
+ emoji: "🎨",
218
+ label: "Building UI",
219
+ requires: %w[spec.md plan.md],
220
+ note: "the back end holds; the interface is being built against it",
221
+ invariant: lambda { |s|
222
+ body = Ledger.stripped(s.read("plan.md"))
223
+ "Building UI, but `plan.md` has no work units to build" unless body.match?(PLAN_HEADING)
224
+ }
178
225
  ),
179
226
  Status.new(
180
- key: :ready_for_review, emoji: "🟢", label: "Ready for Review", requires: %w[spec.md plan.md pull-requests.md],
181
- note: "every pull request is green on CI and waiting for a reviewer",
227
+ key: :ready_for_review,
228
+ emoji: "🟢",
229
+ label: "Ready for Review",
230
+ requires: %w[spec.md plan.md pull-requests.md],
231
+ note: "every pull request is green on CI and waiting for a reviewer",
182
232
  invariant: open_pull_request("Ready for Review")
183
233
  ),
184
234
  Status.new(
185
- key: :in_review, emoji: "👀", label: "In Review", requires: %w[spec.md plan.md pull-requests.md],
186
- note: "a reviewer has picked it up and has not ruled yet",
235
+ key: :in_review,
236
+ emoji: "👀",
237
+ label: "In Review",
238
+ requires: %w[spec.md plan.md pull-requests.md],
239
+ note: "a reviewer has picked it up and has not ruled yet",
187
240
  invariant: open_pull_request("In Review")
188
241
  ),
189
242
  Status.new(
190
- key: :rejected, emoji: "🔴", label: "Changes Requested", requires: %w[spec.md plan.md pull-requests.md],
191
- note: "the review asked for fixes; resubmit once they are made",
243
+ key: :rejected,
244
+ emoji: "🔴",
245
+ label: "Changes Requested",
246
+ requires: %w[spec.md plan.md pull-requests.md],
247
+ note: "the review asked for fixes; resubmit once they are made",
192
248
  invariant: open_pull_request("Changes Requested")
193
249
  ),
194
250
  Status.new(
195
- key: :approved, emoji: "✅", label: "Approved & Merged", requires: %w[pull-requests.md],
196
- note: "reviewed, approved, and every pull request merged",
251
+ key: :approved,
252
+ emoji: "",
253
+ label: "Approved & Merged",
254
+ requires: %w[pull-requests.md],
255
+ note: "reviewed, approved, and every pull request merged",
197
256
  invariant: lambda { |s|
198
257
  return "Approved & Merged, but no pull requests are recorded" if s.pull_requests.empty?
199
258
 
@@ -202,41 +261,62 @@ module Agentilda
202
261
  }
203
262
  ),
204
263
  Status.new(
205
- key: :deployed, emoji: "😎", label: "Deployed", requires: %w[deployed.md],
206
- note: "live in production; `deployed.md` names the release, date and SHA",
264
+ key: :deployed,
265
+ emoji: "😎",
266
+ label: "Deployed",
267
+ requires: %w[deployed.md],
268
+ note: "live in production; `deployed.md` names the release, date and SHA",
207
269
  invariant: nil
208
270
  ),
209
271
  Status.new(
210
- key: :rolled_back, emoji: "😱", label: "Rolled Back", requires: %w[rollback.md],
211
- note: "it shipped and was pulled; `rollback.md` names what broke",
272
+ key: :rolled_back,
273
+ emoji: "😱",
274
+ label: "Rolled Back",
275
+ requires: %w[rollback.md],
276
+ note: "it shipped and was pulled; `rollback.md` names what broke",
212
277
  invariant: nil
213
278
  ),
214
279
  Status.new(
215
- key: :shit, emoji: "💩", label: "Scrapped by Review", requires: %w[rewrite.md],
216
- note: "the review scrapped the work; the plan survives, the pull requests do not",
280
+ key: :shit,
281
+ emoji: "💩",
282
+ label: "Scrapped by Review",
283
+ requires: %w[rewrite.md],
284
+ note: "the review scrapped the work; the plan survives, the pull requests do not",
217
285
  invariant: nil
218
286
  ),
219
287
  Status.new(
220
- key: :blocked, emoji: "⭕️", label: "Technical Block", requires: %w[blocked.md],
221
- note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide",
288
+ key: :blocked,
289
+ emoji: "⭕️",
290
+ label: "Technical Block",
291
+ requires: %w[blocked.md],
292
+ note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide",
222
293
  invariant: open_block("Technical Block")
223
294
  ),
224
295
  Status.new(
225
- key: :product_blocked, emoji: "🅱️", label: "Product Block", requires: %w[blocked.md],
226
- note: "cannot proceed; `blocked.md` names what a product manager must decide",
296
+ key: :product_blocked,
297
+ emoji: "🅱️",
298
+ label: "Product Block",
299
+ requires: %w[blocked.md],
300
+ note: "cannot proceed; `blocked.md` names what a product manager must decide",
227
301
  invariant: open_block("Product Block")
228
302
  ),
229
303
  Status.new(
230
- key: :deferred, emoji: "☢️", label: "Deferred", requires: %w[delayed.md],
231
- note: "could proceed and chose not to yet; `delayed.md` must name the trigger",
304
+ key: :deferred,
305
+ emoji: "☢️",
306
+ label: "Deferred",
307
+ requires: %w[delayed.md],
308
+ note: "could proceed and chose not to yet; `delayed.md` must name the trigger",
232
309
  invariant: lambda { |s|
233
310
  body = s.read("delayed.md").to_s
234
311
  "Deferred, but `delayed.md` names no trigger" unless body.match?(/trigger|revisit|when\b|until\b|once\b/i)
235
312
  }
236
313
  ),
237
314
  Status.new(
238
- key: :retroactive, emoji: "🕰️", label: "Retroactive", requires: [],
239
- note: "the feature is live, but has neither a specification nor a plan",
315
+ key: :retroactive,
316
+ emoji: "🕰️",
317
+ label: "Retroactive",
318
+ requires: [],
319
+ note: "the feature is live, but has neither a specification nor a plan",
240
320
  invariant: lambda { |s|
241
321
  next "Retroactive, but a `spec.md` already exists — it has been documented" if s.file?("spec.md")
242
322
 
@@ -244,8 +324,11 @@ module Agentilda
244
324
  }
245
325
  ),
246
326
  Status.new(
247
- key: :discarded, emoji: "❌", label: "Discarded", requires: %w[discarded.md],
248
- note: "dropped for good; `discarded.md` says why. A terminal state",
327
+ key: :discarded,
328
+ emoji: "",
329
+ label: "Discarded",
330
+ requires: %w[discarded.md],
331
+ note: "dropped for good; `discarded.md` says why. A terminal state",
249
332
  invariant: nil
250
333
  )
251
334
  ].freeze
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "agentilda/feature"
4
+ require "agentilda/pull_requests"
5
+ require "agentilda/state_machine"
6
+
7
+ module Agentilda
8
+ # A plan folder as the state machine sees it: which files exist, what they
9
+ # say, and which pull requests they record.
10
+ #
11
+ # It exists because the invariants ask the same questions repeatedly and
12
+ # {Feature} is a frozen `Data` with nowhere to memoize the answers.
13
+ class Subject
14
+ # @param feature [Agentilda::Feature]
15
+ def initialize(feature)
16
+ @feature = feature
17
+ @reads = {}
18
+ end
19
+
20
+ # @return [Agentilda::Feature]
21
+ attr_reader :feature
22
+
23
+ # @return [Agentilda::Status] the status the folder name claims
24
+ def status = feature.status
25
+
26
+ # @param name [String] a bare filename
27
+ # @return [Boolean]
28
+ def file?(name) = File.file?(File.join(feature.path, name))
29
+
30
+ # @param name [String]
31
+ # @return [String, nil] contents, or nil when absent
32
+ def read(name)
33
+ @reads.fetch(name) do
34
+ @reads[name] = file?(name) ? File.read(File.join(feature.path, name), encoding: "UTF-8") : nil
35
+ end
36
+ end
37
+
38
+ # @return [Array<Agentilda::PullRequest>]
39
+ def pull_requests = @pull_requests ||= PullRequests.new(dir: feature.path).all
40
+
41
+ # The specification's own Goal section, verbatim and at most two
42
+ # paragraphs. Anything that paraphrases the spec is a second copy that
43
+ # drifts; quoting it is not — which is why the pull request body and the
44
+ # index both read it from here rather than each writing their own.
45
+ #
46
+ # @return [Array<String>] paragraphs, empty when there is no Goal to read
47
+ def goal
48
+ body = read("spec.md").to_s
49
+ section = body[/^\#{"#"}{2,3}\s*Goals?\b[^\n]*\n+(.*?)(?=\n\#{"#"}{1,3}\s|\z)/mi, 1]
50
+
51
+ paragraphs(section) || paragraphs(body.sub(/\A\s*\#{"#"}[^\n]*\n/, "")) || []
52
+ end
53
+
54
+ # Specifications written before the template existed have no Goal section,
55
+ # and they are exactly the ones an index most needs to describe. So the
56
+ # opening prose stands in — skipping headings, quotes, lists and tables,
57
+ # which describe the document rather than the work.
58
+ #
59
+ # @param text [String, nil]
60
+ # @return [Array<String>, nil] nil when there is no prose to be had
61
+ def paragraphs(text)
62
+ found = text.to_s.strip.split(/\n{2,}/)
63
+ .map(&:strip)
64
+ .reject { |p| p.empty? || p.match?(/\A[\#>|\-*\d`_=]/) }
65
+ .first(2)
66
+
67
+ found.empty? ? nil : found
68
+ end
69
+
70
+ # The questions `blocked.md` still names, by number.
71
+ #
72
+ # Empty means one of two very different things, and a caller that treats
73
+ # them alike is how a folder with thirty kilobytes of open questions gets
74
+ # reported as "nothing left open": either there is no `blocked.md` at all,
75
+ # or there is one whose questions are not written as `## B<n>` and are
76
+ # therefore invisible to every part of this tool. Ask {#file?} which.
77
+ #
78
+ # @return [Array<Integer>]
79
+ def open_blocks = Agentilda.block_numbers(read("blocked.md"), OPEN_BLOCK)
80
+
81
+ # The answers waiting in `blocked.md`, by number. `## A1` settles `## B1`.
82
+ #
83
+ # Waiting, not folded. An `## A<n>` heading is not the same as a settled
84
+ # question: one may say in its own body that it is a draft pending a
85
+ # conversation. `lando-broker` makes that call; this only counts headings.
86
+ #
87
+ # @return [Array<Integer>]
88
+ def block_answers = Agentilda.block_numbers(read("blocked.md"), ANSWER_BLOCK)
89
+
90
+ # A `blocked.md` this tool cannot read: the file is there, and not one
91
+ # question in it is written as `## B<n>`. Nothing can drain it and nothing
92
+ # currently says so, which is the whole reason this exists.
93
+ #
94
+ # @return [Boolean]
95
+ def unreadable_block? = file?("blocked.md") && open_blocks.empty?
96
+
97
+ # @return [String, nil] why the folder's name is not justified
98
+ def violation = status.violation(self)
99
+
100
+ # @return [Boolean] whether the name matches the contents
101
+ def consistent? = violation.nil?
102
+
103
+ # @return [Agentilda::StateMachine] positioned at the current state
104
+ def machine = StateMachine.new(self)
105
+
106
+ # @return [Array<Symbol>] states reachable right now, guards applied
107
+ def allowed = machine.allowed
108
+
109
+ # @return [Agentilda::Status, nil] the state these contents justify
110
+ def best_fit = machine.best_fit
111
+
112
+ # Move the folder into +status+ — the side effect a transition *is*.
113
+ #
114
+ # The {Feature} is a frozen `Data` holding the old name, so it is replaced
115
+ # rather than mutated, and the memoized reads go with it.
116
+ #
117
+ # @param status [Agentilda::Status]
118
+ # @return [Agentilda::Feature] the feature under its new name
119
+ # @raise [Agentilda::Error] when the target name is already taken
120
+ def rename_to(status)
121
+ return @feature if status.key == @feature.status.key
122
+
123
+ target = File.join(File.dirname(@feature.path), @feature.dirname_as(status))
124
+ unless Agentilda.move_directory(@feature.path, target)
125
+ raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
126
+ end
127
+
128
+ @reads = {}
129
+ @pull_requests = nil
130
+ @feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
131
+ end
132
+ end
133
+ end
@@ -29,13 +29,15 @@ module Agentilda
29
29
  Row = Data.define(:name, :invocations, :subagents, :up, :down, :seconds)
30
30
 
31
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
32
+ WIDTHS = { name: 22, invocations: 6, subagents: 11, up: 9, down: 9, seconds: 8 }.freeze
33
33
 
34
34
  # @param attempts [Array<Agentilda::Runner::Attempt>]
35
35
  # @param seconds [Float] wall clock for the whole loop, which is not the
36
36
  # sum of the agents' own times: several of them run at once
37
- # @param rounds [Integer]
38
- def initialize(attempts:, seconds:, rounds: 0)
37
+ # @param rounds [Integer, nil] accepted so an older caller still works, and
38
+ # otherwise unused: the loop has no rounds of its own any more, each
39
+ # agent counts its own per plan, so the summary counts invocations
40
+ def initialize(attempts:, seconds:, rounds: nil)
39
41
  @attempts = attempts.reject { |a| a.ordinal == "?" }
40
42
  @seconds = seconds
41
43
  @rounds = rounds
@@ -47,7 +49,7 @@ module Agentilda
47
49
  # @return [Float] wall clock
48
50
  attr_reader :seconds
49
51
 
50
- # @return [Integer]
52
+ # @return [Integer, nil]
51
53
  attr_reader :rounds
52
54
 
53
55
  # Plans an agent was actually started against, counted once each however
@@ -85,8 +87,12 @@ module Agentilda
85
87
  # @return [Array<Agentilda::Tally::Row>] one per agent, dearest first
86
88
  def by_agent
87
89
  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
+ Row.new(name:,
91
+ invocations: list.size,
92
+ subagents: list.sum(&:subagents),
93
+ up: list.sum(&:up),
94
+ down: list.sum(&:down),
95
+ seconds: list.sum(&:seconds))
90
96
  }.sort_by { |row| -row.up }
91
97
  end
92
98
 
@@ -101,7 +107,7 @@ module Agentilda
101
107
  def summary
102
108
  [
103
109
  "#{plans} plan#{"s" unless plans == 1} addressed",
104
- "#{rounds} round#{"s" unless rounds == 1}",
110
+ "#{attempts.size} invocation#{"s" unless attempts.size == 1}",
105
111
  duration(seconds),
106
112
  "#{format("%.1f", concurrency)} agents at a time",
107
113
  "↑ #{UI.abbreviate(up)} ↓ #{UI.abbreviate(down)}"
@@ -115,18 +121,18 @@ module Agentilda
115
121
  return "" if delegated.zero?
116
122
 
117
123
  "\n#{subagents} sub-agent#{"s" unless subagents == 1} reported #{UI.abbreviate(delegated)} " \
118
- "tokens without splitting them, counted above as ↑."
124
+ "tokens without splitting them, counted above as ↑."
119
125
  end
120
126
 
121
127
  # @return [String]
122
128
  def header
123
129
  cell("Agent", :name) + %i[invocations subagents up down seconds]
124
- .zip(["runs", "sub-agents", "↑", "↓", "time"])
125
- .map { |key, text| right(text, key) }.join
130
+ .zip(["runs", "sub-agents", "↑", "↓", "time"])
131
+ .map { |key, text| right(text, key) }.join
126
132
  end
127
133
 
128
134
  # @return [String]
129
- def rule = WIDTHS.values.map { |width| "─" * (width - 1) + " " }.join
135
+ def rule = WIDTHS.values.map { |width| ("─" * (width - 1)) + " " }.join
130
136
 
131
137
  # @param row [Agentilda::Tally::Row]
132
138
  # @return [String]
@@ -47,8 +47,11 @@ module Agentilda
47
47
  # @!attribute [r] pid
48
48
  # @return [Integer, nil] the `claude` process, once the harness has
49
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
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
52
55
  end
53
56
 
54
57
  # The one kind of line the trace does not keep.
@@ -62,8 +65,10 @@ module Agentilda
62
65
  # which is what makes it complete when the process dies mid-invocation.
63
66
  DELTA_NOISE = '"content_block_delta"'
64
67
 
65
- # Longest phrase a spinner line can carry without wrapping into the next.
66
- LIMIT = 56
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
67
72
 
68
73
  # How each tool reads as something being done, rather than as a tool name.
69
74
  # A spinner says what is happening; "Edit" is a noun and says nothing.
@@ -72,7 +77,8 @@ module Agentilda
72
77
  "MultiEdit" => "editing", "NotebookEdit" => "editing",
73
78
  "Bash" => "running", "Grep" => "searching for", "Glob" => "looking for",
74
79
  "Task" => "delegating", "TodoWrite" => "planning",
75
- "WebFetch" => "fetching", "WebSearch" => "searching the web for"
80
+ "WebFetch" => "fetching", "WebSearch" => "searching the web for",
81
+ "SendUserMessage" => "saying"
76
82
  }.freeze
77
83
 
78
84
  # The input key worth naming, per tool, in the order we would rather say it.
@@ -92,12 +98,13 @@ module Agentilda
92
98
  @buffer = +""
93
99
  @plain = []
94
100
  @activity = nil
101
+ @message = nil
95
102
  @result = nil
96
103
  @error = nil
97
104
  @trace_path = trace
98
105
  @trace = trace && File.open(trace, "a")
99
106
  @tools = 0
100
- @main = {up: 0, down: 0}
107
+ @main = { up: 0, down: 0 }
101
108
  @streamed = {}
102
109
  @tasks = {}
103
110
  @spawned = 0
@@ -132,11 +139,11 @@ module Agentilda
132
139
  # @return [Integer]
133
140
  def delegated
134
141
  @tasks.reject { |_, task| @streamed.key?(task[:tool_use_id]) }
135
- .sum { |_, task| task[:total] }
142
+ .sum { |_, task| task[:total] }
136
143
  end
137
144
 
138
145
  # @return [Agentilda::Transcript::Progress] a snapshot, safe to keep
139
- def progress = Progress.new(activity: @activity, up:, down:, subagents: @spawned, pid: @pid)
146
+ def progress = Progress.new(activity: @activity, up:, down:, subagents: @spawned, pid: @pid, message: @message)
140
147
 
141
148
  # @return [Integer] how many tool calls have gone past, which is the one
142
149
  # honest measure of how much work an agent that says "done" actually did
@@ -148,6 +155,9 @@ module Agentilda
148
155
  # @return [String, nil] the last thing the agent was seen doing
149
156
  attr_reader :activity
150
157
 
158
+ # @return [String, nil] the last thing the agent sent via `SendUserMessage`
159
+ attr_reader :message
160
+
151
161
  # @return [String, nil] the final text, from the `result` event
152
162
  attr_reader :result
153
163
 
@@ -228,6 +238,7 @@ module Agentilda
228
238
  def handle(event)
229
239
  case event["type"]
230
240
  when "assistant"
241
+ capture_message(event)
231
242
  @tools += tool_calls(event)
232
243
  announce(phrase_for(event))
233
244
  when "stream_event" then meter(event)
@@ -236,6 +247,24 @@ module Agentilda
236
247
  end
237
248
  end
238
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
+
239
268
  # @param event [Hash]
240
269
  # @return [Integer] how many tools this one message called
241
270
  def tool_calls(event)
@@ -264,7 +293,8 @@ module Agentilda
264
293
 
265
294
  usage = event.dig("event", "usage") or return
266
295
  bucket = bucket_for(event["parent_tool_use_id"])
267
- bucket[:up] += usage.values_at("input_tokens", "cache_creation_input_tokens",
296
+ bucket[:up] += usage.values_at("input_tokens",
297
+ "cache_creation_input_tokens",
268
298
  "cache_read_input_tokens").compact.sum
269
299
  bucket[:down] += usage["output_tokens"].to_i
270
300
  publish
@@ -275,12 +305,12 @@ module Agentilda
275
305
  def bucket_for(parent)
276
306
  return @main if parent.nil?
277
307
 
278
- @streamed[parent] ||= {up: 0, down: 0}
308
+ @streamed[parent] ||= { up: 0, down: 0 }
279
309
  end
280
310
 
281
311
  # @return [Hash] every streamed sub-agent's counters, added together
282
312
  def streamed
283
- @streamed.values.each_with_object({up: 0, down: 0}) do |b, sum|
313
+ @streamed.values.each_with_object({ up: 0, down: 0 }) do |b, sum|
284
314
  sum[:up] += b[:up]
285
315
  sum[:down] += b[:down]
286
316
  end
@@ -309,7 +339,7 @@ module Agentilda
309
339
  def start_task(event)
310
340
  id = event["task_id"] or return
311
341
 
312
- @tasks[id] ||= {tool_use_id: event["tool_use_id"], total: 0}
342
+ @tasks[id] ||= { tool_use_id: event["tool_use_id"], total: 0 }
313
343
  @spawned = @tasks.size
314
344
  publish
315
345
  end
@@ -319,7 +349,7 @@ module Agentilda
319
349
  def update_task(event)
320
350
  id = event["task_id"] or return
321
351
 
322
- task = (@tasks[id] ||= {tool_use_id: event["tool_use_id"], total: 0})
352
+ task = (@tasks[id] ||= { tool_use_id: event["tool_use_id"], total: 0 })
323
353
  task[:tool_use_id] ||= event["tool_use_id"]
324
354
  task[:total] = event.dig("usage", "total_tokens").to_i
325
355
  @spawned = @tasks.size
@@ -386,7 +416,7 @@ module Agentilda
386
416
  # @return [String]
387
417
  def noun(key, value)
388
418
  text = value.to_s.strip.tr("\n", " ").squeeze(" ")
389
- (key == "file_path") ? File.basename(text) : text
419
+ key == "file_path" ? File.basename(text) : text
390
420
  end
391
421
 
392
422
  # The agent's own narration, first sentence only. Everything after it is
@@ -402,7 +432,7 @@ module Agentilda
402
432
 
403
433
  # @param text [String]
404
434
  # @return [String]
405
- def clip(text) = (text.length > LIMIT) ? "#{text[0, LIMIT - 1]}…" : text
435
+ def clip(text) = text.length > LIMIT ? "#{text[0, LIMIT - 1]}…" : text
406
436
 
407
437
  # @param usage [Hash, nil] the `result` event's own accounting
408
438
  # @return [void]
@@ -410,7 +440,8 @@ module Agentilda
410
440
  return unless usage.is_a?(Hash)
411
441
 
412
442
  @main = {
413
- up: usage.values_at("input_tokens", "cache_creation_input_tokens",
443
+ up: usage.values_at("input_tokens",
444
+ "cache_creation_input_tokens",
414
445
  "cache_read_input_tokens").compact.sum,
415
446
  down: usage["output_tokens"].to_i
416
447
  }
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/feature"
4
+ require "agentilda/ordinal"
5
+ require "agentilda/subject"
6
+
3
7
  module Agentilda
4
8
  # A project's `.plans` directory: the folders in it, decoded and ordered.
5
9
  class Tree
@@ -38,8 +42,8 @@ module Agentilda
38
42
  # @return [Hash{Agentilda::Ordinal => Array<String>}] number => dirnames
39
43
  def duplicates
40
44
  features.group_by(&:ordinal)
41
- .select { |_, group| group.size > 1 }
42
- .transform_values { |group| group.map(&:dirname) }
45
+ .select { |_, group| group.size > 1 }
46
+ .transform_values { |group| group.map(&:dirname) }
43
47
  end
44
48
 
45
49
  # @param ordinal [Agentilda::Ordinal, String]
@@ -68,10 +72,10 @@ module Agentilda
68
72
  return [] unless exist?
69
73
 
70
74
  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
+ .map { |c| File.join(dir, c) }
76
+ .select { |p| File.directory?(p) }
77
+ .reject { |p| File.basename(p).start_with?(".") }
78
+ .sort
75
79
  end
76
80
  end
77
81
  end