agentilda 2.0.1 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -1,363 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # A folder's state: what it means, which files it cannot be honest without,
5
- # and the rule that decides whether the folder may legitimately claim it.
6
- #
7
- # `invariant` is asked in both directions. {StateMachine} asks it of a
8
- # destination before moving there; `resync` and `check` ask it of the current
9
- # state, to catch folders whose name has drifted from their contents. One
10
- # definition, so the two can never disagree about what a state means.
11
- #
12
- # @!attribute [r] key
13
- # @return [Symbol] machine-facing name, e.g. `:product_blocked`
14
- # @!attribute [r] emoji
15
- # @return [String] as it appears in the folder name
16
- # @!attribute [r] label
17
- # @return [String] the human words
18
- # @!attribute [r] requires
19
- # @return [Array<String>] files this state cannot be honest without
20
- # @!attribute [r] note
21
- # @return [String] one-line meaning, for tables and generated docs
22
- # @!attribute [r] invariant
23
- # @return [Proc, nil] `(subject) -> String | nil` — the reason it does not hold
24
- Status = Data.define(:key, :emoji, :label, :requires, :note, :invariant) do
25
- # @param subject [#file?, #read, #pull_requests]
26
- # @return [Boolean]
27
- def satisfied_by?(subject) = violation(subject).nil?
28
-
29
- # @param subject [#file?, #read, #pull_requests]
30
- # @return [String, nil] why this state is not justified, nil when it is
31
- def violation(subject)
32
- missing = requires.reject { |f| subject.file?(f) }
33
- return "#{label} requires #{missing.map { |f| "`#{f}`" }.join(" and ")}" unless missing.empty?
34
-
35
- invariant&.call(subject)
36
- end
37
-
38
- # @return [Boolean] whether nothing may follow this state
39
- def terminal? = StateMachine.outbound(key).empty?
40
-
41
- # @return [String] emoji and words, as the tables print it
42
- def to_s = "#{emoji} #{label}"
43
- end
44
-
45
- # "1 pull request", "3 pull requests".
46
- #
47
- # @param count [Integer]
48
- # @return [String]
49
- def self.pull_request_count(count) = "#{count} pull request#{"s" unless count == 1}"
50
-
51
- # What proves a specification has been researched rather than merely
52
- # written: the chapter `leah-researcher` contributes. It is a section of
53
- # `spec.md` rather than a file of its own because research is not a separate
54
- # document — it is the first half of the specification, and splitting it
55
- # would leave `yoda-writer` reading two files to write one.
56
- RESEARCH_CHAPTER = /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i
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
-
64
- # Variation selectors make ⚪️ and ⚪ different strings that mean the same
65
- # thing to a human. Compare with them removed.
66
- #
67
- # @param str [String, nil]
68
- # @return [String]
69
- def self.fold_emoji(str) = str.to_s.gsub(/[\u{FE0E}\u{FE0F}\u{200D}]/, "")
70
-
71
- # 🟢 Ready for Review, 👀 In Review and 🔴 Changes Requested are the same
72
- # thing on disk — the work exists and at least one pull request is still
73
- # open. Which of the three it is lives in the folder name and nowhere else,
74
- # so they share one invariant rather than three that could drift apart.
75
- #
76
- # @param label [String]
77
- # @return [Proc]
78
- def self.open_pull_request(label)
79
- lambda { |subject|
80
- return "#{label} requires an open pull request; none are recorded" if subject.pull_requests.empty?
81
-
82
- "#{label}, but every pull request is already merged" if subject.pull_requests.none?(&:open?)
83
- }
84
- end
85
-
86
- # A question that is still somebody's to answer, written as its own heading:
87
- # `## B1`, `## B2`. The number is what `blocked.md` gets referenced by in
88
- # conversation and in pull requests, so it is also the only part of the file
89
- # a program can count.
90
- #
91
- # Whoever writes `blocked.md` MUST use this notation. A question written any
92
- # other way is invisible to every part of this tool: the folder never becomes
93
- # ⭕️, and `unblock` reports a file with nothing in it to drain. {ANSWER_BLOCK}
94
- # is the other half of the pair.
95
- OPEN_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**B(\d+)\b/
96
-
97
- # The answer to the question of the same number: `## A1` settles `## B1`.
98
- #
99
- # Answers are an inbox, not a record. `lando-broker` folds each one into the
100
- # document its question was stopping and deletes both, so a `blocked.md`
101
- # holding nothing but history must not go on stopping the folder. An `A`
102
- # never matches {OPEN_BLOCK}, which is what lets the two live in one file.
103
- ANSWER_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**A(\d+)\b/
104
-
105
- # The numbers named by every heading in +text+ matching +pattern+.
106
- #
107
- # @param text [String, nil]
108
- # @param pattern [Regexp] {OPEN_BLOCK} or {ANSWER_BLOCK}
109
- # @return [Array<Integer>] in the order they appear, without duplicates
110
- def self.block_numbers(text, pattern)
111
- text.to_s.lines.filter_map { |line| line[pattern, 1]&.to_i }.uniq
112
- end
113
-
114
- # ⭕️ Technical Block and 🅱️ Product Block are the same thing on disk: a
115
- # `blocked.md` still naming at least one question nobody has answered. Which
116
- # human is owed the answer lives in the folder name and nowhere else, so the
117
- # two share one invariant rather than two that could drift apart.
118
- #
119
- # This is what lets a block drain in pieces. Answers land in `blocked.md`
120
- # one at a time, `lando-broker` moves each into `spec.md` or `plan.md` and
121
- # deletes it here, and the folder leaves ⭕️ on the pass that empties the
122
- # file, by content rather than by anyone remembering to delete it.
123
- #
124
- # @param label [String]
125
- # @return [Proc]
126
- def self.open_block(label)
127
- lambda { |subject|
128
- "#{label}, but `blocked.md` names no open question" unless subject.read("blocked.md").to_s.match?(OPEN_BLOCK)
129
- }
130
- end
131
-
132
- # Every state a plan folder may be in, in lifecycle order. That order drives
133
- # the legend, the generated documentation and the `--to` help text.
134
- #
135
- # 🟣 Merged is deliberately absent: it is a pull request's status, not a
136
- # folder's, and giving it a folder state invites folders that claim a pull
137
- # request's condition as their own.
138
- #
139
- # Adding a state here, or changing an emoji, makes the hand-drawn
140
- # `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
141
- # the mermaid source in the generated document moves with this list.
142
- STATUSES = [
143
- Status.new(
144
- key: :new,
145
- emoji: "⚪️",
146
- label: "New",
147
- requires: %w[spec.md],
148
- note: "a specification exists; it has not been planned yet",
149
- invariant: nil
150
- ),
151
- Status.new(
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",
157
- invariant: lambda { |s|
158
- body = s.read("spec.md").to_s
159
- "Researched, but `spec.md` has no `## Research` chapter" unless body.match?(RESEARCH_CHAPTER)
160
- }
161
- ),
162
- Status.new(
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
- }
189
- ),
190
- # `pull-requests.md` is deliberately absent from `requires` on both of the
191
- # building states, though it names the file they are about. That file is
192
- # written by opening a pull request, which happens once, when the last
193
- # implementer advances the plan to Ready for Review. Requiring the file to
194
- # *enter* Building would mean nothing could ever justify entering it, and
195
- # `palpatine-planner` finishing `plan.md` would loop forever between itself
196
- # and a state it can never actually reach. Ready for Review is where the
197
- # file becomes a real requirement, once something has had the chance to
198
- # write it.
199
- Status.new(
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
- }
209
- ),
210
- # Two building states rather than one, because the order is not a
211
- # preference: an interface is written against an API that already answers.
212
- # Splitting them lets each half be built by someone who only has to be good
213
- # at that half, and gives the second a working system to build on rather
214
- # than a description of one.
215
- Status.new(
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
- }
225
- ),
226
- Status.new(
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",
232
- invariant: open_pull_request("Ready for Review")
233
- ),
234
- Status.new(
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",
240
- invariant: open_pull_request("In Review")
241
- ),
242
- Status.new(
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",
248
- invariant: open_pull_request("Changes Requested")
249
- ),
250
- Status.new(
251
- key: :approved,
252
- emoji: "✅",
253
- label: "Approved & Merged",
254
- requires: %w[pull-requests.md],
255
- note: "reviewed, approved, and every pull request merged",
256
- invariant: lambda { |s|
257
- return "Approved & Merged, but no pull requests are recorded" if s.pull_requests.empty?
258
-
259
- open = s.pull_requests.count(&:open?)
260
- "Approved & Merged, but #{Agentilda.pull_request_count(open)} still open" if open.positive?
261
- }
262
- ),
263
- Status.new(
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",
269
- invariant: nil
270
- ),
271
- Status.new(
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",
277
- invariant: nil
278
- ),
279
- Status.new(
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",
285
- invariant: nil
286
- ),
287
- Status.new(
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",
293
- invariant: open_block("Technical Block")
294
- ),
295
- Status.new(
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",
301
- invariant: open_block("Product Block")
302
- ),
303
- Status.new(
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",
309
- invariant: lambda { |s|
310
- body = s.read("delayed.md").to_s
311
- "Deferred, but `delayed.md` names no trigger" unless body.match?(/trigger|revisit|when\b|until\b|once\b/i)
312
- }
313
- ),
314
- Status.new(
315
- key: :retroactive,
316
- emoji: "🕰️",
317
- label: "Retroactive",
318
- requires: [],
319
- note: "the feature is live, but has neither a specification nor a plan",
320
- invariant: lambda { |s|
321
- next "Retroactive, but a `spec.md` already exists — it has been documented" if s.file?("spec.md")
322
-
323
- "Retroactive, but no pull requests are recorded" if s.pull_requests.empty?
324
- }
325
- ),
326
- Status.new(
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",
332
- invariant: nil
333
- )
334
- ].freeze
335
-
336
- # Emoji => status, folded so ⚪ and ⚪️ both resolve.
337
- STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }.freeze
338
-
339
- # Symbol => status.
340
- STATUS_BY_KEY = STATUSES.to_h { |s| [s.key, s] }.freeze
341
-
342
- # @param emoji [String, nil]
343
- # @return [Agentilda::Status, nil]
344
- def self.status_for_emoji(emoji) = STATUS_BY_EMOJI[fold_emoji(emoji)]
345
-
346
- # Resolve a state from the only two names it has: its key, and its emoji.
347
- #
348
- # There is deliberately no synonym table. A state that also answers to
349
- # "star", "green" and "completed" has four names to keep in step with the
350
- # folder, the generated docs and the agent frontmatter — and the synonym
351
- # table that used to live here went stale pointing six words at a state that
352
- # no longer existed.
353
- #
354
- # @param text [String, Symbol, Agentilda::Status, nil]
355
- # @return [Agentilda::Status, nil]
356
- def self.status(text)
357
- return nil if text.nil?
358
- return text if text.is_a?(Status)
359
-
360
- key = text.to_s.strip
361
- STATUS_BY_KEY[key.to_sym] || status_for_emoji(key)
362
- end
363
- end
@@ -1,133 +0,0 @@
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
@@ -1,175 +0,0 @@
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, 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)
41
- @attempts = attempts.reject { |a| a.ordinal == "?" }
42
- @seconds = seconds
43
- @rounds = rounds
44
- end
45
-
46
- # @return [Array<Agentilda::Runner::Attempt>]
47
- attr_reader :attempts
48
-
49
- # @return [Float] wall clock
50
- attr_reader :seconds
51
-
52
- # @return [Integer, nil]
53
- attr_reader :rounds
54
-
55
- # Plans an agent was actually started against, counted once each however
56
- # many rounds they took.
57
- #
58
- # @return [Integer]
59
- def plans = attempts.map(&:ordinal).uniq.size
60
-
61
- # @return [Integer] tokens sent, everything included
62
- def up = attempts.sum(&:up)
63
-
64
- # @return [Integer] tokens generated
65
- def down = attempts.sum(&:down)
66
-
67
- # @return [Integer] sub-agents spawned by every agent in the run
68
- def subagents = attempts.sum(&:subagents)
69
-
70
- # Sub-agent spend that arrived as one number. `claude` reports what a
71
- # sub-agent cost without splitting it between the two directions, so this
72
- # much of {#up} is really up and down together.
73
- #
74
- # @return [Integer]
75
- def delegated = attempts.sum(&:delegated)
76
-
77
- # @return [Float] seconds of agent time, added up across all of them
78
- def busy = attempts.sum(&:seconds)
79
-
80
- # How many agents were running at any given moment, averaged over the
81
- # run: agent-seconds divided by wall-clock seconds. Below the `-j` ceiling
82
- # by however much the round spent waiting for its slowest member.
83
- #
84
- # @return [Float]
85
- def concurrency = seconds.positive? ? busy / seconds : 0.0
86
-
87
- # @return [Array<Agentilda::Tally::Row>] one per agent, dearest first
88
- def by_agent
89
- attempts.group_by(&:agent).map { |name, list|
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))
96
- }.sort_by { |row| -row.up }
97
- end
98
-
99
- # @return [String] the table and the summary line under it
100
- def render
101
- return summary if attempts.empty?
102
-
103
- ([header, rule] + by_agent.map { |row| line(row) } + [rule, totals, "", summary]).join("\n")
104
- end
105
-
106
- # @return [String] the one-line version, for a box
107
- def summary
108
- [
109
- "#{plans} plan#{"s" unless plans == 1} addressed",
110
- "#{attempts.size} invocation#{"s" unless attempts.size == 1}",
111
- duration(seconds),
112
- "#{format("%.1f", concurrency)} agents at a time",
113
- "↑ #{UI.abbreviate(up)} ↓ #{UI.abbreviate(down)}"
114
- ].join(" · ") + delegation
115
- end
116
-
117
- private
118
-
119
- # @return [String] the caveat, where there is one to make
120
- def delegation
121
- return "" if delegated.zero?
122
-
123
- "\n#{subagents} sub-agent#{"s" unless subagents == 1} reported #{UI.abbreviate(delegated)} " \
124
- "tokens without splitting them, counted above as ↑."
125
- end
126
-
127
- # @return [String]
128
- def header
129
- cell("Agent", :name) + %i[invocations subagents up down seconds]
130
- .zip(["runs", "sub-agents", "↑", "↓", "time"])
131
- .map { |key, text| right(text, key) }.join
132
- end
133
-
134
- # @return [String]
135
- def rule = WIDTHS.values.map { |width| ("─" * (width - 1)) + " " }.join
136
-
137
- # @param row [Agentilda::Tally::Row]
138
- # @return [String]
139
- def line(row)
140
- cell(row.name, :name) + right(row.invocations.to_s, :invocations) +
141
- right(row.subagents.to_s, :subagents) + right(UI.abbreviate(row.up), :up) +
142
- right(UI.abbreviate(row.down), :down) + right(duration(row.seconds), :seconds)
143
- end
144
-
145
- # @return [String]
146
- def totals
147
- cell("", :name) + right(attempts.size.to_s, :invocations) + right(subagents.to_s, :subagents) +
148
- right(UI.abbreviate(up), :up) + right(UI.abbreviate(down), :down) + right(duration(busy), :seconds)
149
- end
150
-
151
- # @param text [String]
152
- # @param key [Symbol]
153
- # @return [String] padded to its column, left-justified
154
- def cell(text, key) = UI.fit(text, WIDTHS.fetch(key))
155
-
156
- # @param text [String]
157
- # @param key [Symbol]
158
- # @return [String] padded to its column, right-justified
159
- def right(text, key)
160
- width = WIDTHS.fetch(key) - 1
161
- text = UI.fit(text, width).rstrip
162
- UI.fit("", width - UI.display_width(text)) + text + " "
163
- end
164
-
165
- # @param seconds [Numeric]
166
- # @return [String] e.g. "42s", "12m40s", "1h04m"
167
- def duration(seconds)
168
- total = seconds.to_i
169
- return "#{total}s" if total < 60
170
- return "#{total / 60}m#{format("%02ds", total % 60)}" if total < 3600
171
-
172
- "#{total / 3600}h#{format("%02dm", (total % 3600) / 60)}"
173
- end
174
- end
175
- end