agentilda 1.0.3

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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. metadata +349 -0
@@ -0,0 +1,456 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # Drives specialist agents over a `.plans` tree until it stops changing.
5
+ #
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.
14
+ class Runner
15
+ # 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
41
+ # @return [Boolean] whether the plan actually moved
42
+ def advanced? = ok && from != to
43
+ end
44
+
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
+ # 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
+ 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]
80
+ def log_fields
81
+ {plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
82
+ agent: agent.name, round: format("%02d", round)}
83
+ end
84
+ end
85
+
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)
130
+ @tree = tree
131
+ @executor = executor
132
+ @agents = agents
133
+ @max_rounds = max_rounds
134
+ @isolation = isolation
135
+ @worktree = worktree
136
+ @plans = plans
137
+ @publisher = publisher
138
+ @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.
145
+ @jobs = isolated? ? jobs : 1
146
+ end
147
+
148
+ # @return [Boolean]
149
+ def isolated? = @isolation == :worktree
150
+
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
158
+
159
+ # @return [Agentilda::Worktree, nil]
160
+ attr_reader :worktree
161
+
162
+ # @return [Agentilda::Tree]
163
+ attr_reader :tree
164
+
165
+ # @return [Array<Agentilda::Runner::Round>]
166
+ attr_reader :rounds
167
+
168
+ # Run until the tree stops changing.
169
+ #
170
+ # @return [Array<Agentilda::Runner::Round>]
171
+ 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
189
+ end
190
+
191
+ # Plans nobody may act on, for the closing report.
192
+ #
193
+ # @return [Array<Agentilda::Subject>]
194
+ def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
195
+
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
250
+
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.
255
+ #
256
+ # @param agent [Agentilda::Agent]
257
+ # @param subject [Agentilda::Subject]
258
+ # @param round [Integer]
259
+ # @return [Agentilda::Runner::Task]
260
+ def prepare(agent, subject, round)
261
+ return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) unless isolated?
262
+
263
+ checkout = worktree.checkout_for(subject.feature)
264
+ Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
265
+ end
266
+
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
+ # @return [Agentilda::Publisher::Publication, nil]
436
+ def publish(task, subject)
437
+ return nil unless @publisher && task.checkout&.dirty?
438
+
439
+ publication = @publisher.publish(checkout: task.checkout, subject:)
440
+ record_pull_request(subject.feature.path, publication) if publication.published?
441
+ publication
442
+ end
443
+
444
+ # @param path [String] the plan folder, in the main tree
445
+ # @param publication [Agentilda::Publisher::Publication]
446
+ # @return [void]
447
+ def record_pull_request(path, publication)
448
+ number = publication.url.to_s[%r{/pull/(\d+)}, 1]
449
+ rows = PullRequests.new(dir: path).all.map { |pr|
450
+ {number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: ""}
451
+ }
452
+ rows << {number:, title: publication.title, url: publication.url, state: "Open 🟡", body: ""}
453
+ File.write(File.join(path, PullRequests::FILENAME), PullRequests.render(rows))
454
+ end
455
+ end
456
+ end