agentilda 1.0.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +8 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +78 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +22 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +61 -15
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
@@ -1,437 +1,117 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agentilda
4
- # Drives specialist agents over a `.plans` tree until it stops changing.
4
+ # Drives specialist agents over a `.plans` tree until nothing is left to
5
+ # dispatch.
5
6
  #
6
- # The hard part of any agent loop is knowing when to stop, and this one does
7
- # not have to guess: the state machine already defines "satisfied". A round
8
- # advances plans; the loop ends at a FIXED POINT a round in which no plan
9
- # changed state or when nothing is left that an agent may touch.
10
- #
11
- # Blocked plans are not failures and not work. ⭕️ and 🅱️ mean a human must
12
- # decide, so the loop reports them and steps around them. An agent that could
13
- # move them would make the states meaningless.
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.
14
11
  class Runner
15
12
  # What one agent did to one plan.
16
- #
17
- # @!attribute [r] ordinal
18
- # @return [String]
19
- # @!attribute [r] agent
20
- # @return [String]
21
- # @!attribute [r] from
22
- # @return [Symbol] state before
23
- # @!attribute [r] to
24
- # @return [Symbol] state after
25
- # @!attribute [r] ok
26
- # @return [Boolean]
27
- # @!attribute [r] note
28
- # @return [String]
29
- # @!attribute [r] up
30
- # @return [Integer] tokens sent, sub-agents included
31
- # @!attribute [r] down
32
- # @return [Integer] tokens generated
33
- # @!attribute [r] subagents
34
- # @return [Integer] sub-agents this agent spawned
35
- # @!attribute [r] delegated
36
- # @return [Integer] of {#up}, how much arrived unsplit from a sub-agent
37
- # @!attribute [r] seconds
38
- # @return [Float] how long the agent ran
39
- Attempt = Data.define(:ordinal, :agent, :from, :to, :ok, :note, :up, :down, :subagents,
40
- :delegated, :seconds) do
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
+
41
30
  # @return [Boolean] whether the plan actually moved
42
31
  def advanced? = ok && from != to
43
32
  end
44
33
 
45
- # One pass over the tree.
46
- #
47
- # @!attribute [r] number
48
- # @return [Integer] 1-based
49
- # @!attribute [r] attempts
50
- # @return [Array<Agentilda::Runner::Attempt>]
51
- Round = Data.define(:number, :attempts) do
52
- # @return [Integer]
53
- def advanced = attempts.count(&:advanced?)
54
-
55
- # @return [Boolean] nothing moved, so another identical round is pointless
56
- def dry? = advanced.zero?
57
- end
58
-
59
34
  # One unit of work: an agent, a plan, and the checkout it happens in.
60
- #
61
- # @!attribute [r] agent
62
- # @return [Agentilda::Agent]
63
- # @!attribute [r] subject
64
- # @return [Agentilda::Subject]
65
- # @!attribute [r] root
66
- # @return [String] the tree this agent sees
67
- # @!attribute [r] checkout
68
- # @return [Agentilda::Worktree::Checkout, nil] nil when sharing a tree
69
- # @!attribute [r] round
70
- # @return [Integer] which pass over the tree this task belongs to
71
35
  Task = Data.define(:agent, :subject, :root, :checkout, :round) do
72
- # @return [String] for the spinner line — the pid and round render
73
- # beside this through the line's own `:pid` token, once known
74
- def label = "#{subject.feature.ordinal} #{UI.paint(agent.name, :yellow, :bold)}"
75
-
76
- # The same facts unpainted and apart, for the log file, where they
77
- # are columns rather than a sentence.
78
- #
79
- # @return [Hash]
36
+ # @return [Hash] columns for the log
80
37
  def log_fields
81
- {plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
82
- agent: agent.name, round: format("%02d", round)}
38
+ { plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
39
+ agent: agent.name, round: format("%02d", round) }
83
40
  end
84
41
  end
85
42
 
86
- # How a task's return value reads on its spinner line: any hop that was
87
- # not ok makes the whole line a failure, shown with that hop's note. The
88
- # executor reports failure by returning rather than raising, and a line
89
- # that drew "done" over a timed-out agent — directly above a round
90
- # table saying FAIL was the contradiction this closes.
91
- FAILURE = ->(result) { result.find { |a| !a.ok }&.note if result.is_a?(Array) }
92
-
93
- # Rounds with no movement before the loop concedes. One is not enough: an
94
- # agent can legitimately spend a round writing something another agent needs
95
- # before either can advance.
96
- DRY_ROUNDS = 2
97
-
98
- # The most agents one task will chain through in a single round. The
99
- # pipeline is shorter than this, so hitting the cap means states are
100
- # cycling, and a cap beats a loop.
101
- MAX_CHAIN_HOPS = 6
102
-
103
- # @param tree [Agentilda::Tree]
104
- # @param executor [#call] receives (agent, subject) and returns [ok, note]
105
- # @param agents [Agentilda::Agents]
106
- # @param max_rounds [Integer] a hard ceiling, so a loop cannot run forever
107
- # @param isolation [Symbol] `:worktree` gives each plan its own checkout
108
- # and branch; `:shared` runs every agent against one tree, which is only
109
- # safe serially
110
- # @param jobs [Integer] how many agents run at once
111
- # @param plans [Array<Agentilda::Ordinal>, nil] restrict the loop to
112
- # these plans; nil (the default) is the whole tree
113
- # @param publisher [Agentilda::Publisher, nil] pushes a finished
114
- # worktree and opens its pull request as soon as one lands, rather than
115
- # once at the very end of the whole loop. nil (the default) never
116
- # pushes anything — the caller's opt-out.
117
- # @param dry_run [Boolean] no agent is invoked, so the per-round resync
118
- # must not rename anything either — a preview that moves folders is
119
- # not a preview.
120
- # @param chain [Boolean] when an agent finishes and the plan's CONTENTS
121
- # now justify the next state, hand the plan straight to that state's
122
- # agent in the same round — researcher to writer to planner — instead
123
- # of paying a full round per hop. The folder is not renamed mid-round
124
- # (the serial resync still owns that); the chain reads {Subject#best_fit}
125
- # afresh, which needs no rename. Off when the caller restricted the run
126
- # to one agent, since chaining past the restriction would un-restrict it.
127
- def initialize(tree:, executor:, agents: Agents.new, max_rounds: 10,
128
- isolation: :shared, jobs: 1, worktree: nil, plans: nil, publisher: nil,
129
- dry_run: false, chain: false)
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)
130
50
  @tree = tree
131
51
  @executor = executor
132
52
  @agents = agents
133
- @max_rounds = max_rounds
134
53
  @isolation = isolation
135
54
  @worktree = worktree
136
55
  @plans = plans
137
56
  @publisher = publisher
138
57
  @dry_run = dry_run
139
- @chain = chain
140
- @rounds = []
141
-
142
- # Concurrency without isolation is the exact failure the worktree exists
143
- # to prevent: two agents editing one checkout produce no git conflict, so
144
- # the last writer wins silently. Refuse rather than corrupt.
58
+ @rounds = rounds
59
+ @state = dry_run ? nil : state
60
+ @sleeper = sleeper
61
+ @on_board = on_board
62
+ @attempts = []
145
63
  @jobs = isolated? ? jobs : 1
146
64
  end
147
65
 
66
+ attr_reader :jobs, :worktree, :tree, :attempts, :executor, :agents
67
+
148
68
  # @return [Boolean]
149
69
  def isolated? = @isolation == :worktree
150
70
 
151
- # @param subject [Agentilda::Subject]
152
- # @return [Boolean] whether this run's scope covers this plan at all —
153
- # `--plan` restricts it; with no `--plan` every plan is in scope
154
- def in_scope?(subject) = @plans.nil? || @plans.include?(subject.feature.ordinal)
155
-
156
- # @return [Integer] agents running at once
157
- attr_reader :jobs
71
+ # @return [Boolean]
72
+ def dry_run? = @dry_run
158
73
 
159
- # @return [Agentilda::Worktree, nil]
160
- attr_reader :worktree
74
+ # @return [String] the repository root
75
+ def root = isolated? ? worktree.root : shared_root
161
76
 
162
- # @return [Agentilda::Tree]
163
- attr_reader :tree
77
+ # @param subject [Agentilda::Subject]
78
+ # @return [Boolean]
79
+ def in_scope?(subject) = @plans.nil? || @plans.include?(subject.feature.ordinal)
164
80
 
165
- # @return [Array<Agentilda::Runner::Round>]
166
- attr_reader :rounds
81
+ # @return [Array<Agentilda::Subject>]
82
+ def in_scope = tree.subjects.select { |s| in_scope?(s) }
167
83
 
168
- # Run until the tree stops changing.
84
+ # Run until nothing is dispatchable.
169
85
  #
170
- # @return [Array<Agentilda::Runner::Round>]
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>]
171
89
  def call
172
- dry = 0
173
-
174
- 1.upto(@max_rounds) do |number|
175
- # `q` ends the loop at the next seam rather than instantly: the round
176
- # in flight finishes (its agents were asked to STOP and get a grace
177
- # period to write out), and no new round starts.
178
- break if Control.quit?
179
-
180
- round = run_round(number)
181
- @rounds << round
182
- break if round.attempts.empty?
183
-
184
- dry = round.dry? ? dry + 1 : 0
185
- break if dry >= DRY_ROUNDS
186
- end
187
-
188
- @rounds
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
189
93
  end
190
94
 
191
- # Plans nobody may act on, for the closing report.
192
- #
193
95
  # @return [Array<Agentilda::Subject>]
194
96
  def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
195
97
 
196
- # @return [Boolean] every plan in scope is either finished or deliberately parked
197
- def settled?
198
- in_scope.all? { |s| StateMachine::SETTLED.include?(s.status.key) }
199
- end
200
-
201
- private
202
-
203
- # @param number [Integer]
204
- # @return [Agentilda::Runner::Round]
205
- def run_round(number)
206
- tree.reload
207
- tasks = assignments.map { |agent, subject| prepare(agent, subject, number) }
208
- return Round.new(number:, attempts: []) if tasks.empty?
209
-
210
- results = UI.concurrently(tasks, "round #{number} — #{tasks.size} plans", jobs:,
211
- label: :label.to_proc, fields: :log_fields.to_proc, failure: FAILURE,
212
- header: {round: format("%02d", number)}, timeout: timeout_for) do |task, progress|
213
- attempt(task, &progress)
214
- end
215
-
216
- # One serial pass over the *main* tree, run once per round rather than
217
- # once per task. `Executor#prompt_for` always names a plan folder by its
218
- # main-tree path — that is where `spec.md`/`plan.md`/a rename actually
219
- # land, under every isolation mode — and running `Resync::Dirs` from
220
- # `jobs` threads at once on the one tree they all share would be exactly
221
- # the hazard a worktree exists to prevent for code, just aimed at
222
- # `.plans` instead. Doing it here, after `UI.concurrently` has already
223
- # joined every thread, costs nothing: nobody is still writing.
224
- #
225
- # It renames only what a real round may have moved: a dry run invoked
226
- # no agent, and "dry run" that renames a folder anyway is a preview
227
- # that already happened.
228
- Resync::Dirs.new(tree:).call(commit: !@dry_run)
229
-
230
- attempts = tasks.zip(results).flat_map do |task, r|
231
- next [failed(r)] unless r.is_a?(Array)
232
-
233
- *hops, last = r
234
- hops + [finish(task, last)]
235
- end
236
- Round.new(number:, attempts:)
237
- end
238
-
239
- # Each line's countdown starts from that agent's own clock. The executor
240
- # is a seam the suite fills with doubles that answer only `call`, so a
241
- # stand-in without the method simply draws no timer rather than failing
242
- # the round.
243
- #
244
- # @return [Proc] task -> seconds, or nil when the executor keeps no clock
245
- def timeout_for
246
- return UI::NO_TIMEOUT unless @executor.respond_to?(:timeout_for)
247
-
248
- ->(task) { @executor.timeout_for(task.agent) }
249
- end
98
+ # @return [Boolean]
99
+ def settled? = in_scope.all? { |s| StateMachine::SETTLED.include?(s.status.key) }
250
100
 
251
- # Give the task somewhere to work. Under isolation that is a fresh git
252
- # worktree on its own branch named `<user>/NNN.MM-slug` which is also
253
- # what `resync prs` reads first, so the plan number carries itself all the
254
- # way to a merged pull request.
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.
255
106
  #
256
- # @param agent [Agentilda::Agent]
257
- # @param subject [Agentilda::Subject]
258
- # @param round [Integer]
259
107
  # @return [Agentilda::Runner::Task]
260
108
  def prepare(agent, subject, round)
261
- return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) unless isolated?
109
+ return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) if !isolated? || dry_run?
262
110
 
263
111
  checkout = worktree.checkout_for(subject.feature)
264
112
  Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
265
113
  end
266
114
 
267
- # @return [String]
268
- def shared_root = File.dirname(tree.dir)
269
-
270
- # @return [Array<Agentilda::Subject>] the tree, or just the plans
271
- # `--plan` named — computed fresh each call, since {#run_round} reloads
272
- # {#tree} before reading it
273
- def in_scope = tree.subjects.select { |s| in_scope?(s) }
274
-
275
- # @param error [Exception]
276
- # @return [Agentilda::Runner::Attempt]
277
- def failed(error)
278
- Attempt.new(ordinal: "?", agent: "?", from: :unknown, to: :unknown, ok: false,
279
- note: error.is_a?(Exception) ? error.message.lines.first.to_s.strip : error.to_s,
280
- up: 0, down: 0, subagents: 0, delegated: 0, seconds: 0.0)
281
- end
282
-
283
- # Exactly one agent per plan per round — the first that handles its state.
284
- # Offering a plan to two agents in one round invites them to write the same
285
- # file from two directions.
286
- #
287
- # @return [Array<Array(Agentilda::Agent, Agentilda::Subject)>]
288
- def assignments
289
- in_scope.filter_map do |subject|
290
- next if StateMachine::SETTLED.include?(subject.status.key)
291
-
292
- agent = @agents.for_status(subject.status).first
293
- agent && [agent, subject]
294
- end
295
- end
296
-
297
- # Runs the agent and records what it claimed. `to` is left equal to
298
- # `from` here — deliberately unfinished — because whether the folder
299
- # actually moved cannot be answered yet: several of these run at once,
300
- # each in its own checkout, and the one tree that would prove it moved is
301
- # not safe to resync until every thread has stopped writing. {#finish}
302
- # settles it, once, after {#run_round}'s single serial resync.
303
- #
304
- # With chaining on, one task can carry a plan through several agents:
305
- # each hop re-reads the folder's contents, and only a state the contents
306
- # JUSTIFY (per {Subject#best_fit}) hands the plan to the next agent. A
307
- # state in {StateMachine::SETTLED} — parked, blocked, done — ends the
308
- # chain the same way it keeps a plan out of {#assignments}.
309
- #
310
- # @param task [Agentilda::Runner::Task]
311
- # @return [Array<Agentilda::Runner::Attempt>] one per agent that ran
312
- # @yieldparam progress [Agentilda::Transcript::Progress] what the agent
313
- # is doing and what it has spent, forwarded to its line
314
- def attempt(task, &on_progress)
315
- ordinal = task.subject.feature.ordinal.to_s
316
- agent = task.agent
317
- subject = task.subject
318
- from = subject.status.key
319
- attempts = []
320
-
321
- loop do
322
- result = @executor.call(agent, subject, root: task.root, &on_progress)
323
- ok, note = result
324
- note = "#{note} (#{task.checkout.branch})" if task.checkout
325
-
326
- attempts << Attempt.new(ordinal:, agent: agent.name, from:, ok: !!ok, note: note.to_s, to: from,
327
- up: spend(result, :up), down: spend(result, :down), subagents: spend(result, :subagents),
328
- delegated: spend(result, :delegated), seconds: spend(result, :seconds))
329
-
330
- # `n` deliberately does not appear here: stopping an agent early and
331
- # letting the chain hand its plan to the next one is what n means.
332
- # `q` stops the chain along with everything else.
333
- break unless @chain && ok && attempts.size < MAX_CHAIN_HOPS && !Control.quit?
334
-
335
- # Re-locate the plan by ordinal rather than by path: several agents
336
- # rename their own folder when they finish, so the path this hop
337
- # started with may already be stale. A fresh single-use Tree keeps the
338
- # read out of the shared, memoized one that other threads see.
339
- current = Tree.new(dir: tree.dir).find(task.subject.feature.ordinal)
340
- break if current.nil?
341
-
342
- fit = further_of(current)
343
- break if fit.nil? || fit.key == from || StateMachine::SETTLED.include?(fit.key)
344
-
345
- succ = @agents.for_status(fit).first
346
- break if succ.nil?
347
-
348
- attempts[-1] = attempts[-1].with(to: fit.key)
349
- agent = succ
350
- from = fit.key
351
- subject = current
352
- end
353
-
354
- attempts
355
- end
356
-
357
- # The state a hop hands forward: whichever of the name the folder claims
358
- # and the state its contents justify sits FURTHER along the pipeline.
359
- # Either alone misleads — an agent that renamed its folder is ahead of
360
- # what `best_fit` can prove (yoda-writer moves to ⭐️ before `plan.md`
361
- # exists), and an agent that only wrote files is ahead of its unrenamed
362
- # folder (leah-researcher leaves ⚪️ on the door of a researched spec).
363
- # Taking the maximum is forward-monotonic, so a chain cannot cycle.
364
- #
365
- # @param subject [Agentilda::Subject]
366
- # @return [Agentilda::Status, nil]
367
- def further_of(subject)
368
- [subject.status, subject.best_fit].compact.max_by { |s| STATUSES.index { |x| x.key == s.key } || -1 }
369
- end
370
-
371
- # An executor is anything that answers `call` and returns something that
372
- # destructures as `ok, note` — the specs pass an array, and a future one
373
- # may too. Whatever it spent is an extra it does not have to report.
374
- #
375
- # @param result [Object]
376
- # @param field [Symbol]
377
- # @return [Numeric]
378
- def spend(result, field) = result.respond_to?(field) ? result.public_send(field) : 0
379
-
380
- # Reads what {#run_round}'s resync just settled, rather than trusting
381
- # what the agent claims — an agent that says "done" but wrote nothing
382
- # shows up as not having moved. Publishing is the one further thing this
383
- # adds on top of that read.
384
- #
385
- # `resync` never moves a folder within a {StateMachine::FAMILIES} group on
386
- # its own — a `plan.md` and some pull requests look identical whether
387
- # nobody has looked yet or a reviewer just asked for changes, so guessing
388
- # between them would be a coin flip dressed up as a correction. Whether a
389
- # plan is ready to leave a building state is the implementer's call, not
390
- # the harness's: each renames the plan folder itself once there is no unit
391
- # of its own left in `plan.md` — `luke-backend` to 🎨 and `rey-frontend`
392
- # to 🟢 — and the resync
393
- # above leaves that rename standing — "the current name always wins"
394
- # inside a family — even though nothing has opened a pull request yet.
395
- # This is what reacts to it: publish, so the invariant that rename is
396
- # jumping ahead of becomes true within the same round.
397
- #
398
- # @param task [Agentilda::Runner::Task]
399
- # @param attempt [Agentilda::Runner::Attempt]
400
- # @return [Agentilda::Runner::Attempt]
401
- def finish(task, attempt)
402
- return attempt unless attempt.ok
403
-
404
- current = tree.find(task.subject.feature.ordinal)
405
- to = current&.status&.key || attempt.from
406
- settled = attempt.with(to:)
407
- # The agent that ran last is the attempt's, which under chaining is not
408
- # necessarily the one the task started with.
409
- finisher = @agents.find(attempt.agent) || task.agent
410
- return settled unless finisher.advances_to == :ready_for_review && to == :ready_for_review
411
-
412
- publication = publish(task, current)
413
- note = if publication&.published?
414
- "#{settled.note}; opened #{publication.url}"
415
- elsif publication&.refusal
416
- "#{settled.note}; publish refused: #{publication.refusal}"
417
- else
418
- settled.note
419
- end
420
-
421
- settled.with(note:)
422
- end
423
-
424
- # Push the finished branch and open its pull request, then record it in
425
- # the plan's own `pull-requests.md` — the file {Status::STATUS_BY_KEY}'s
426
- # `ready_for_review` invariant actually reads.
427
- #
428
- # `--isolation shared` has no branch of its own to push, so this is a
429
- # no-op there by construction rather than by a special case: {@publisher}
430
- # is nil unless the caller asked for pushing, and a shared task never has
431
- # a {Worktree::Checkout} to push in the first place.
432
- #
433
- # @param task [Agentilda::Runner::Task]
434
- # @param subject [Agentilda::Subject] read fresh, under its new name
435
115
  # @return [Agentilda::Publisher::Publication, nil]
436
116
  def publish(task, subject)
437
117
  return nil unless @publisher && task.checkout&.dirty?
@@ -441,16 +121,19 @@ module Agentilda
441
121
  publication
442
122
  end
443
123
 
444
- # @param path [String] the plan folder, in the main tree
445
- # @param publication [Agentilda::Publisher::Publication]
124
+ private
125
+
126
+ # @return [String]
127
+ def shared_root = File.dirname(tree.dir)
128
+
446
129
  # @return [void]
447
130
  def record_pull_request(path, publication)
448
131
  number = publication.url.to_s[%r{/pull/(\d+)}, 1]
449
132
  rows = PullRequests.new(dir: path).all.map { |pr|
450
- {number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: ""}
133
+ { number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: "" }
451
134
  }
452
- rows << {number:, title: publication.title, url: publication.url, state: "Open 🟡", body: ""}
453
- File.write(File.join(path, PullRequests::FILENAME), PullRequests.render(rows))
135
+ rows << { number:, title: publication.title, url: publication.url, state: "Open 🟡", body: "" }
136
+ PullRequests.upsert(File.join(path, PullRequests::FILENAME), rows)
454
137
  end
455
138
  end
456
139
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+
5
+ module Agentilda
6
+ module Screen
7
+ class Ratatui
8
+ module Bar
9
+ # Cells the fill portion occupies.
10
+ WIDTH = 10
11
+
12
+ # Seconds a bar visually maxes out at. Cosmetic only — the color
13
+ # thresholds below are independent, absolute-minute rules.
14
+ CAP = 1800
15
+
16
+ # @param tui [RatatuiRuby::TUI]
17
+ # @param seconds [Integer, nil] nil draws an empty, dim bar
18
+ # @param color [Symbol] the fill's foreground color
19
+ # @return [RatatuiRuby::Text::Line]
20
+ def self.cell(tui, seconds, color)
21
+ return empty(tui) if seconds.nil?
22
+
23
+ filled = ((seconds.to_f / CAP).clamp(0.0, 1.0) * WIDTH).round
24
+ bar = ("▓" * filled) + ("░" * (WIDTH - filled))
25
+ clock = format(" %2d:%02d", seconds / 60, seconds % 60)
26
+ tui.text_line(spans: [
27
+ tui.text_span(content: bar, style: tui.style(fg: color)),
28
+ tui.text_span(content: clock, style: tui.style(fg: :bright_black))
29
+ ])
30
+ end
31
+
32
+ # @param tui [RatatuiRuby::TUI]
33
+ # @return [RatatuiRuby::Text::Line]
34
+ def self.empty(tui)
35
+ tui.text_line(spans: [
36
+ tui.text_span(content: " " * WIDTH, style: tui.style(fg: :bright_black)),
37
+ tui.text_span(content: " --:--", style: tui.style(fg: :bright_black))
38
+ ])
39
+ end
40
+
41
+ # @param elapsed [Integer]
42
+ # @return [Symbol]
43
+ def self.elapsed_color(elapsed) = elapsed >= 900 ? :yellow : :green
44
+
45
+ # @param remaining [Integer, nil]
46
+ # @return [Symbol]
47
+ def self.remaining_color(remaining)
48
+ return :bright_black if remaining.nil?
49
+ return :red if remaining < 300
50
+ return :yellow if remaining < 900
51
+
52
+ :green
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+
5
+ module Agentilda
6
+ module Screen
7
+ class Ratatui
8
+ # RatatuiRuby::Event -> the exact string Keyboard#handle already
9
+ # expects, so every key binding (select, kill, extend, wrap-up, quit,
10
+ # the dialog, ctrl-c) is reused verbatim rather than reimplemented.
11
+ module KeyTranslator
12
+ # @param event [RatatuiRuby::Event]
13
+ # @return [String, nil] nil for anything that is not a key press
14
+ def self.call(event)
15
+ return nil unless event.key?
16
+ return "\u0003" if event.interrupt?
17
+
18
+ case event.code
19
+ when "up" then "\e[A"
20
+ when "down" then "\e[B"
21
+ when "enter" then "\r"
22
+ when "esc" then "\e"
23
+ else event.code
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end