agentilda 2.0.3 → 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,426 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # Reconciling what is recorded with what is true.
5
- #
6
- # Both resyncs are dry-run by default and both refuse to act where they are
7
- # not certain, because both write to things other people join on: folder
8
- # names, and pull request titles.
9
- module Resync
10
- # `resync dirs` — makes every folder's name say what the folder is.
11
- #
12
- # Two things can be wrong with a name, and both are repaired here:
13
- #
14
- # - **The emoji is wrong** — the contents justify a different state. A
15
- # folder whose status already holds is left alone, which is what stops
16
- # ⭕️ Blocked and 🅱️ Product Blocked — deliberately identical invariants,
17
- # distinguished only by the name — from collapsing into one.
18
- # - **The spelling is not canonical** — a folder written `018-⚪️--foo`
19
- # before the `NNN.MM` padding rule or the ` → ` separator, or
20
- # `18.1-⚪️--foo` by hand. Unpadded numbers read fine and sort wrong —
21
- # `018.09` < `018.1` < `018.10` puts a single mixed-width folder in the
22
- # middle of the range. Both are renamed, to `018.00-⚪️ → foo` and
23
- # `018.01-⚪️ → foo`.
24
- #
25
- # The repairs are applied in that order reversed, as **up to two renames
26
- # per folder**: first the spelling, under the status the folder already
27
- # claims — which is how a `--`-era folder migrates to the ` → ` form —
28
- # and only then the status its contents justify. Both renames aim at
29
- # {Feature.plan_dirname}, so they cannot disagree about the final name;
30
- # splitting them keeps each auditable on its own, instead of one report
31
- # line where a spelling migration also appears to have opinions about
32
- # the state of the work.
33
- class Dirs
34
- # A proposed rename.
35
- #
36
- # @!attribute [r] dirname
37
- # @return [String] the folder as it stands
38
- # @!attribute [r] from
39
- # @return [Symbol] the state it claims
40
- # @!attribute [r] to
41
- # @return [Symbol] the state its contents justify
42
- # @!attribute [r] source
43
- # @return [String] absolute path now
44
- # @!attribute [r] target
45
- # @return [String] absolute path after
46
- # @!attribute [r] reason
47
- # @return [String] why the current name is wrong
48
- Change = Data.define(:dirname, :from, :to, :source, :target, :reason) do
49
- # @return [String] a single auditable line — `⇒` rather than `→`,
50
- # which the folder names themselves now contain
51
- def to_s = "#{dirname} ⇒ #{File.basename(target)} (#{reason})"
52
- end
53
-
54
- # The state a folder's contents justify, or the one it claims when
55
- # nothing fits. What a rename moves toward, and what the dispatcher
56
- # reads on a dry run, which renames nothing, so the preview still
57
- # names the agents a real run would start.
58
- #
59
- # @param subject [Agentilda::Subject]
60
- # @return [Agentilda::Status]
61
- def self.target(subject) = subject.best_fit || subject.status
62
-
63
- # @param tree [Agentilda::Tree]
64
- # @param except [Array<String>] ordinals to leave alone however wrong
65
- # their names are: the dispatcher passes the plans an agent is
66
- # working in right now
67
- def initialize(tree:, except: [])
68
- @tree = tree
69
- @except = except.map(&:to_s)
70
- end
71
-
72
- # @return [Agentilda::Tree]
73
- attr_reader :tree
74
-
75
- # What would change, without changing anything. A folder can appear
76
- # twice: its spelling migration first, then its status correction.
77
- #
78
- # @return [Array<Agentilda::Resync::Dirs::Change>]
79
- def plan
80
- tree.subjects.flat_map do |subject|
81
- @except.include?(subject.feature.ordinal.to_s) ? [] : changes_for(subject)
82
- end
83
- end
84
-
85
- # @param commit [Boolean] actually rename
86
- # @return [Array<Agentilda::Resync::Dirs::Change>] what was proposed
87
- def call(commit: false)
88
- changes = plan
89
- return changes unless commit
90
-
91
- UI.stepping(changes, "Renaming") { |change| rename(change) }
92
- tree.reload
93
- changes
94
- end
95
-
96
- private
97
-
98
- # A folder moves when the name it has is not the name it should have.
99
- # {target} says what that name is; this says how to get there, as up
100
- # to two renames applied in order. The status rename starts from where
101
- # the spelling rename left the folder, not from where it stands now,
102
- # so committing the steps sequentially never renames a path that has
103
- # already moved out from under it.
104
- #
105
- # @param subject [Agentilda::Subject]
106
- # @return [Array<Agentilda::Resync::Dirs::Change>] empty when the name
107
- # is already right
108
- def changes_for(subject)
109
- feature = subject.feature
110
- fit = self.class.target(subject)
111
- steps = []
112
-
113
- spelled = feature.dirname_as(subject.status)
114
- if spelled != feature.dirname
115
- steps << step(feature,
116
- from: subject.status,
117
- to: subject.status,
118
- source: feature.path,
119
- reason: spelling_reason(feature))
120
- end
121
-
122
- if fit.key != subject.status.key
123
- source = steps.empty? ? feature.path : steps.last.target
124
- steps << step(feature,
125
- from: subject.status,
126
- to: fit,
127
- source:,
128
- reason: subject.violation || "contents now justify #{fit.label}")
129
- end
130
-
131
- steps
132
- end
133
-
134
- # @param feature [Agentilda::Feature]
135
- # @param from [Agentilda::Status]
136
- # @param to [Agentilda::Status]
137
- # @param source [String] absolute path the rename starts from
138
- # @param reason [String]
139
- # @return [Agentilda::Resync::Dirs::Change]
140
- def step(feature, from:, to:, source:, reason:)
141
- Change.new(
142
- dirname: File.basename(source),
143
- from: from.key,
144
- to: to.key,
145
- source:,
146
- target: File.join(File.dirname(feature.path), feature.dirname_as(to)),
147
- reason:
148
- )
149
- end
150
-
151
- # @param feature [Agentilda::Feature]
152
- # @return [String] why the spelling is wrong, status aside
153
- def spelling_reason(feature)
154
- return "#{feature.dirname_ordinal} is not padded to #{feature.ordinal}" unless feature.padded?
155
-
156
- "name is not in canonical NNN.MM-<emoji> → <slug> form"
157
- end
158
-
159
- # A rename that finds its target occupied has not happened, and saying
160
- # nothing about it would leave the folder misnamed with a report
161
- # claiming otherwise. Two folders can want the same canonical name —
162
- # `018-⚪️--foo` and `018.00-⚪️ → foo` are the same plan written twice.
163
- #
164
- # @param change [Agentilda::Resync::Dirs::Change]
165
- # @return [void]
166
- # @raise [Agentilda::Error] when the target name is already taken
167
- def rename(change)
168
- return if Agentilda.move_directory(change.source, change.target)
169
-
170
- raise Error, "cannot rename #{change.dirname} — #{File.basename(change.target)} already exists"
171
- end
172
- end
173
-
174
- # `resync prs` — puts an `[NNN.MM]` prefix on every pull request title that
175
- # lacks one, and `[DEV.00]` on the ones that implement no plan.
176
- #
177
- # The resolution order is the branch name first, then the diff, and only
178
- # when the diff touches exactly one plan. Anything else is reported for a
179
- # human and never edited: `pull-requests.md` is generated from these
180
- # titles, and a wrong number files work under a plan that did not do it,
181
- # leaving the plan that did looking untouched.
182
- class Prs
183
- # The marker this tool used to write, now replaced. A title wearing it is
184
- # rewritten in place: the assertion it makes — "this belongs to no
185
- # specification" — is still true and still somebody's deliberate call,
186
- # so it is restamped rather than re-resolved.
187
- STALE = /\A\[#{Regexp.escape(Agentilda::STALE_NO_PLAN_PREFIX)}\]\s*/
188
-
189
- # Titles that already carry a prefix — a plan number, the no-plan marker,
190
- # or the legacy `[XXX]`.
191
- PREFIXED = /\A\[(?:\d{3}(?:\.\d{2})?|#{Agentilda::NO_PLAN_PREFIX}|XXX)\](?:\([A-Z]\))?\s/
192
-
193
- # Finds a plan number in a branch name: `kig/018.01-verify`, `002-slug`.
194
- BRANCH_PATTERN = %r{(?:\A|[/\-_])(\d{3}(?:\.\d{2})?)(?:\z|[-_])}
195
-
196
- # A proposed retitle.
197
- #
198
- # @!attribute [r] number
199
- # @return [Integer] the pull request
200
- # @!attribute [r] title
201
- # @return [String] as it stands
202
- # @!attribute [r] new_title
203
- # @return [String, nil] nil when nothing may safely be done
204
- # @!attribute [r] ordinal
205
- # @return [Agentilda::Ordinal, nil]
206
- # @!attribute [r] reason
207
- # @return [String] how it was resolved, or why it was not
208
- # @!attribute [r] ambiguous
209
- # @return [Boolean] a human must decide; never edited
210
- # @!attribute [r] assumed
211
- # @return [Boolean] "no plan" was inferred, not asserted by the author
212
- Change = Data.define(:number, :title, :new_title, :ordinal, :reason, :ambiguous, :assumed, :adopted) do
213
- # @return [Boolean]
214
- def ambiguous? = ambiguous
215
-
216
- # @return [Boolean]
217
- def assumed? = assumed
218
-
219
- # @return [Boolean] whether a plan folder was minted for this one
220
- def adopted? = adopted
221
-
222
- # @return [Boolean] safe to apply without a human looking
223
- def applicable? = !ambiguous && !new_title.nil?
224
- end
225
-
226
- # @param tree [Agentilda::Tree]
227
- # @param github [Agentilda::GitHub]
228
- # @param adopt [Boolean] give a plan folder to every pull request that
229
- # resolves to none, rather than flagging it and moving on
230
- # @param root [String, nil] repository root, for reading branches
231
- def initialize(tree:, github: GitHub.new, adopt: true, root: nil)
232
- @tree = tree
233
- @github = github
234
- @adopt = adopt
235
- @root = root
236
- end
237
-
238
- # @return [Boolean]
239
- def adopt? = @adopt
240
-
241
- # @return [Agentilda::Tree]
242
- attr_reader :tree
243
-
244
- # @return [Agentilda::GitHub]
245
- attr_reader :github
246
-
247
- # What would change, without changing anything.
248
- #
249
- # @return [Array<Agentilda::Resync::Prs::Change>]
250
- def plan
251
- restamps + resolve(candidates).then { |changes| adopt? ? with_adoptions(changes) : changes }
252
- end
253
-
254
- # @return [Array<Agentilda::Resync::Prs::Change>]
255
- def restamps = stale.map { |pull| restamped(pull) }
256
-
257
- # @param commit [Boolean] actually retitle, and mint the folders
258
- # @return [Array<Agentilda::Resync::Prs::Change>] what was proposed
259
- def call(commit: false)
260
- changes = resolve(candidates)
261
- return restamps + (adopt? ? with_adoptions(changes) : changes) unless commit
262
-
263
- changes = with_adoptions(changes, create: true) if adopt?
264
- changes = restamps + changes
265
- applicable = changes.select(&:applicable?)
266
- UI.stepping(applicable, "Retitling") { |c| github.retitle(number: c.number, title: c.new_title) }
267
- changes
268
- end
269
-
270
- # @return [Array<Agentilda::Adoption>] the adopter, memoized
271
- def adoption = @adoption ||= Adoption.new(tree:, github:, root: @root)
272
-
273
- private
274
-
275
- # @return [Array<Hash>] pull requests with no prefix yet
276
- def candidates
277
- github.pulls.reject { |pr| pr[:title].to_s.match?(PREFIXED) || pr[:title].to_s.match?(STALE) }
278
- end
279
-
280
- # @return [Array<Hash>] pull requests still wearing the old marker
281
- def stale = github.pulls.select { |pr| pr[:title].to_s.match?(STALE) }
282
-
283
- # @param pull [Hash]
284
- # @return [Agentilda::Resync::Prs::Change]
285
- def restamped(pull)
286
- Change.new(number: pull[:number],
287
- title: pull[:title],
288
- ordinal: nil,
289
- ambiguous: false,
290
- new_title: pull[:title].sub(STALE, "[#{Agentilda::NO_PLAN_PREFIX}] "),
291
- reason: "the no-plan marker is now [#{Agentilda::NO_PLAN_PREFIX}]",
292
- assumed: false,
293
- adopted: false)
294
- end
295
-
296
- # @param pulls [Array<Hash>]
297
- # @return [Array<Agentilda::Resync::Prs::Change>]
298
- def resolve(pulls) = pulls.map { |pr| change_for(pr) }
299
-
300
- # Replace every flagged change with one that points at a plan folder the
301
- # pull request now owns. The unresolvable ones were unresolvable because
302
- # no plan described them — so the answer is a plan, not a shrug.
303
- #
304
- # @param changes [Array<Agentilda::Resync::Prs::Change>]
305
- # @param create [Boolean] mint the folders, rather than only saying so
306
- # @return [Array<Agentilda::Resync::Prs::Change>]
307
- def with_adoptions(changes, create: false)
308
- orphans = changes.select(&:ambiguous?)
309
- return changes if orphans.empty?
310
-
311
- pulls = orphans.map { |c| pull_by_number.fetch(c.number) }
312
- adoptees = create ? adoption.call(pulls) : adoption.plan(pulls)
313
- by_number = adoptees.to_h { |a| [a.pull[:number], a] }
314
-
315
- changes.map { |c| c.ambiguous? && by_number[c.number] ? adopted(c, by_number[c.number]) : c }
316
- end
317
-
318
- # @return [Hash{Integer => Hash}]
319
- def pull_by_number = @pull_by_number ||= candidates.to_h { |pr| [pr[:number], pr] }
320
-
321
- # @param change [Agentilda::Resync::Prs::Change]
322
- # @param adoptee [Agentilda::Adoption::Adoptee]
323
- # @return [Agentilda::Resync::Prs::Change]
324
- def adopted(change, adoptee)
325
- change.with(
326
- ordinal: adoptee.ordinal,
327
- new_title: "#{adoptee.ordinal.to_prefix} #{change.title}",
328
- reason: "#{change.reason} — adopted into #{adoptee.dirname}",
329
- ambiguous: false,
330
- adopted: true
331
- )
332
- end
333
-
334
- # @param pull [Hash]
335
- # @return [Agentilda::Resync::Prs::Change]
336
- def change_for(pull)
337
- from_branch(pull) || from_files(pull) || no_plan(pull)
338
- end
339
-
340
- # 1. The branch name — the one moment the author certainly knew.
341
- #
342
- # @param pull [Hash]
343
- # @return [Agentilda::Resync::Prs::Change, nil]
344
- def from_branch(pull)
345
- match = BRANCH_PATTERN.match(pull[:branch].to_s) or return nil
346
- ordinal = Ordinal.parse(match[1])
347
-
348
- unless tree.include?(ordinal)
349
- return flag(pull, "branch names #{ordinal}, which has no folder in #{File.basename(tree.dir)}")
350
- end
351
-
352
- resolved(pull, ordinal, "branch #{pull[:branch]}")
353
- end
354
-
355
- # 2. The diff, and only when it touches exactly one plan.
356
- #
357
- # @param pull [Hash]
358
- # @return [Agentilda::Resync::Prs::Change, nil]
359
- def from_files(pull)
360
- touched = Array(pull[:files]).filter_map { |path| ordinal_for_path(path) }.uniq
361
- case touched.size
362
- when 1 then resolved(pull, touched.first, "diff touches only #{touched.first}")
363
- when 0 then nil
364
- else flag(pull, "diff touches #{touched.join(", ")} and none is obviously primary")
365
- end
366
- end
367
-
368
- # @param path [String] a path from the diff
369
- # @return [Agentilda::Ordinal, nil]
370
- def ordinal_for_path(path)
371
- parts = path.to_s.split("/")
372
- index = parts.index(Agentilda::PLANS_DIR) or return nil
373
- folder = parts[index + 1] or return nil
374
- return nil if parts.length <= index + 2 # a file directly under .plans belongs to no plan
375
-
376
- ordinal = Ordinal.from_dirname(folder)
377
- ordinal if ordinal && tree.include?(ordinal)
378
- end
379
-
380
- # @param pull [Hash]
381
- # @param ordinal [Agentilda::Ordinal]
382
- # @param why [String]
383
- # @return [Agentilda::Resync::Prs::Change]
384
- def resolved(pull, ordinal, why)
385
- Change.new(number: pull[:number],
386
- title: pull[:title],
387
- ordinal:,
388
- reason: why,
389
- new_title: "#{ordinal.to_prefix} #{pull[:title]}",
390
- ambiguous: false,
391
- assumed: false,
392
- adopted: false)
393
- end
394
-
395
- # Nothing resolved, so this is developer work — but that is an assertion
396
- # about intent, so it is marked as assumed rather than stated as fact.
397
- #
398
- # @param pull [Hash]
399
- # @return [Agentilda::Resync::Prs::Change]
400
- def no_plan(pull)
401
- Change.new(number: pull[:number],
402
- title: pull[:title],
403
- ordinal: nil,
404
- new_title: "[#{Agentilda::NO_PLAN_PREFIX}] #{pull[:title]}",
405
- reason: "no plan resolved from the branch or the diff",
406
- ambiguous: false,
407
- assumed: true,
408
- adopted: false)
409
- end
410
-
411
- # @param pull [Hash]
412
- # @param why [String]
413
- # @return [Agentilda::Resync::Prs::Change]
414
- def flag(pull, why)
415
- Change.new(number: pull[:number],
416
- title: pull[:title],
417
- new_title: nil,
418
- ordinal: nil,
419
- reason: why,
420
- ambiguous: true,
421
- assumed: false,
422
- adopted: false)
423
- end
424
- end
425
- end
426
- end
@@ -1,93 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "agentilda/status"
4
- require "agentilda/state_machine"
5
-
6
- module Agentilda
7
- # `agentilda agents` — who the specialists are and what each is offered
8
- # work from. Reading one in full is {Viewer}'s job: the definition file is
9
- # already the whole truth, so `agents describe` opens it rather than
10
- # restating it.
11
- #
12
- # {#list} returns a string and prints nothing, the same bargain {Reporter}
13
- # makes, so the caller decides where the text goes and the table stays
14
- # pipeable.
15
- #
16
- # Everything here is read from the definition files. Nothing about a
17
- # specialty is restated in Ruby, because a second copy of "who does what" is
18
- # a copy that drifts from the frontmatter the loop actually routes on.
19
- class Roster
20
- # Columns, so the header and the rows cannot drift apart.
21
- HEADINGS = ["Agent", "Handles", "Advances to", "Model"].freeze
22
-
23
- # What an agent with no `advances_to` is, in the one word that explains why
24
- # `run` never offers it work.
25
- READ_ONLY = "read-only"
26
-
27
- # What an agent that handles only {StateMachine::SETTLED} states advances
28
- # to. `run` never starts it; a command does, and `resync` then names the
29
- # folder by what its files justify.
30
- BY_CONTENTS = "by contents"
31
-
32
- # @param agents [Agentilda::Agents]
33
- def initialize(agents: Agents.new)
34
- @agents = agents
35
- end
36
-
37
- # @return [Agentilda::Agents]
38
- attr_reader :agents
39
-
40
- # One row per agent, in name order, framed by {UI.table}.
41
- #
42
- # @return [String] newline-terminated
43
- def list
44
- all = agents.all
45
- return "No agent definitions in #{agents.dir}\n" if all.empty?
46
-
47
- UI.table(all.map { |agent| row(agent) }, header: HEADINGS)
48
- end
49
-
50
- private
51
-
52
- # A state as its words alone, deliberately without the emoji that every
53
- # other rendering of a state carries. The table widget pads a cell to a
54
- # width it measures in characters, while the terminal draws a status emoji
55
- # in two cells; the variation-selector ones (⭕️ 🅱️ ⚪️ ⭐️ 🕰️) are two
56
- # characters drawn in two cells, so no single count is right for all of
57
- # them. A row carrying one lands short or long and the frame stops lining
58
- # up. The label says the same thing unambiguously, and is worth more than
59
- # the emoji is. {Reporter} lays its own columns out with {UI.fit} and so
60
- # keeps the emoji.
61
- #
62
- # @param key [Symbol]
63
- # @return [String]
64
- def state(key)
65
- status = STATUS_BY_KEY[key] or return key.to_s
66
- status.label
67
- end
68
-
69
- # @param agent [Agentilda::Agent]
70
- # @return [String]
71
- def handles(agent) = agent.handles.map { |key| state(key) }.join(", ")
72
-
73
- # @param agent [Agentilda::Agent]
74
- # @return [String]
75
- def advances(agent)
76
- return state(agent.advances_to) if agent.advances_to
77
- return BY_CONTENTS if agent.handles.any? && agent.handles.all? { |key| StateMachine::SETTLED.include?(key) }
78
-
79
- READ_ONLY
80
- end
81
-
82
- # The widget measures and pads the cells, so they arrive painted only.
83
- #
84
- # @param agent [Agentilda::Agent]
85
- # @return [Array<String>] one cell per entry in {HEADINGS}
86
- def row(agent)
87
- [UI.paint(agent.name, :bright_cyan),
88
- handles(agent),
89
- UI.paint(advances(agent), agent.read_only? ? :bright_black : :green),
90
- UI.paint(agent.model.to_s, :bright_black)]
91
- end
92
- end
93
- end
@@ -1,139 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # Drives specialist agents over a `.plans` tree until nothing is left to
5
- # dispatch.
6
- #
7
- # The loop itself is {Dispatcher}. This holds what the loop needs about
8
- # the run: the tree and its scope, the roster, the executor, how many run
9
- # at once, where each plan is checked out, and how a finished plan is
10
- # published. Blocked plans (⭕️ 🅱️) are stepped around, never assigned.
11
- class Runner
12
- # What one agent did to one plan.
13
- Attempt = Data.define(:ordinal,
14
- :agent,
15
- :from,
16
- :to,
17
- :ok,
18
- :note,
19
- :up,
20
- :down,
21
- :subagents,
22
- :delegated,
23
- :seconds,
24
- :round,
25
- :file,
26
- :model,
27
- :status) do
28
- def initialize(round: 1, file: "", model: nil, status: nil, **rest) = super
29
-
30
- # @return [Boolean] whether the plan actually moved
31
- def advanced? = ok && from != to
32
- end
33
-
34
- # One unit of work: an agent, a plan, and the checkout it happens in.
35
- Task = Data.define(:agent, :subject, :root, :checkout, :round) do
36
- # @return [Hash] columns for the log
37
- def log_fields
38
- { plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
39
- agent: agent.name, round: format("%02d", round) }
40
- end
41
- end
42
-
43
- # @param rounds [Integer, nil] `--rounds`, capping each agent's own
44
- # @param state [Agentilda::StateFile, nil]
45
- # @param sleeper [Proc]
46
- # @param on_board [Proc, nil]
47
- def initialize(tree:, executor:, agents: Agents.new, isolation: :shared, jobs: 1, worktree: nil,
48
- plans: nil, publisher: nil, dry_run: false, rounds: nil, state: nil,
49
- sleeper: ->(seconds) { sleep(seconds) }, on_board: nil)
50
- @tree = tree
51
- @executor = executor
52
- @agents = agents
53
- @isolation = isolation
54
- @worktree = worktree
55
- @plans = plans
56
- @publisher = publisher
57
- @dry_run = dry_run
58
- @rounds = rounds
59
- @state = dry_run ? nil : state
60
- @sleeper = sleeper
61
- @on_board = on_board
62
- @attempts = []
63
- @jobs = isolated? ? jobs : 1
64
- end
65
-
66
- attr_reader :jobs, :worktree, :tree, :attempts, :executor, :agents
67
-
68
- # @return [Boolean]
69
- def isolated? = @isolation == :worktree
70
-
71
- # @return [Boolean]
72
- def dry_run? = @dry_run
73
-
74
- # @return [String] the repository root
75
- def root = isolated? ? worktree.root : shared_root
76
-
77
- # @param subject [Agentilda::Subject]
78
- # @return [Boolean]
79
- def in_scope?(subject) = @plans.nil? || @plans.include?(subject.feature.ordinal)
80
-
81
- # @return [Array<Agentilda::Subject>]
82
- def in_scope = tree.subjects.select { |s| in_scope?(s) }
83
-
84
- # Run until nothing is dispatchable.
85
- #
86
- # @yieldparam dispatcher [Agentilda::Dispatcher] before it runs, so a
87
- # console can attach itself and reach the running jobs
88
- # @return [Array<Agentilda::Runner::Attempt>]
89
- def call
90
- dispatcher = Dispatcher.new(runner: self, state: @state, rounds: @rounds, sleeper: @sleeper, on_board: @on_board)
91
- yield dispatcher if block_given?
92
- @attempts = dispatcher.run
93
- end
94
-
95
- # @return [Array<Agentilda::Subject>]
96
- def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
97
-
98
- # @return [Boolean]
99
- def settled? = in_scope.all? { |s| StateMachine::SETTLED.include?(s.status.key) }
100
-
101
- # A dry run gets the shared root whatever the isolation says: nothing
102
- # runs in the checkout, and `git worktree add` is a side effect a
103
- # preview must not have. It also fails outright when the plan's branch
104
- # is the one checked out where the run was typed, which is where a dry
105
- # run of that plan is typed.
106
- #
107
- # @return [Agentilda::Runner::Task]
108
- def prepare(agent, subject, round)
109
- return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) if !isolated? || dry_run?
110
-
111
- checkout = worktree.checkout_for(subject.feature)
112
- Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
113
- end
114
-
115
- # @return [Agentilda::Publisher::Publication, nil]
116
- def publish(task, subject)
117
- return nil unless @publisher && task.checkout&.dirty?
118
-
119
- publication = @publisher.publish(checkout: task.checkout, subject:)
120
- record_pull_request(subject.feature.path, publication) if publication.published?
121
- publication
122
- end
123
-
124
- private
125
-
126
- # @return [String]
127
- def shared_root = File.dirname(tree.dir)
128
-
129
- # @return [void]
130
- def record_pull_request(path, publication)
131
- number = publication.url.to_s[%r{/pull/(\d+)}, 1]
132
- rows = PullRequests.new(dir: path).all.map { |pr|
133
- { number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: "" }
134
- }
135
- rows << { number:, title: publication.title, url: publication.url, state: "Open 🟡", body: "" }
136
- PullRequests.upsert(File.join(path, PullRequests::FILENAME), rows)
137
- end
138
- end
139
- end