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.
- checksums.yaml +4 -4
- data/agentilda.gemspec +10 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +92 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +46 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +65 -13
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
data/lib/agentilda/status.rb
CHANGED
|
@@ -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:
|
|
139
|
-
|
|
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:
|
|
144
|
-
|
|
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:
|
|
152
|
-
|
|
153
|
-
|
|
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:
|
|
166
|
-
|
|
167
|
-
|
|
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:
|
|
176
|
-
|
|
177
|
-
|
|
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:
|
|
181
|
-
|
|
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:
|
|
186
|
-
|
|
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:
|
|
191
|
-
|
|
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:
|
|
196
|
-
|
|
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:
|
|
206
|
-
|
|
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:
|
|
211
|
-
|
|
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:
|
|
216
|
-
|
|
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:
|
|
221
|
-
|
|
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:
|
|
226
|
-
|
|
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:
|
|
231
|
-
|
|
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:
|
|
239
|
-
|
|
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:
|
|
248
|
-
|
|
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
|
data/lib/agentilda/tally.rb
CHANGED
|
@@ -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
|
-
|
|
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:,
|
|
89
|
-
|
|
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
|
-
"#{
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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]
|
data/lib/agentilda/transcript.rb
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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) =
|
|
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:
|
|
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
|
}
|
data/lib/agentilda/tree.rb
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|