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
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Lifecycle
5
+ # Reconciling what is recorded with what is true.
6
+ #
7
+ # Both resyncs are dry-run by default and both refuse to act where they are
8
+ # not certain, because both write to things other people join on: folder
9
+ # names, and pull request titles.
10
+ module Resync
11
+ # `resync dirs` — makes every folder's name say what the folder is.
12
+ #
13
+ # Two things can be wrong with a name, and both are repaired here:
14
+ #
15
+ # - **The emoji is wrong** — the contents justify a different state. A
16
+ # folder whose status already holds is left alone, which is what stops
17
+ # ⭕️ Blocked and 🅱️ Product Blocked — deliberately identical invariants,
18
+ # distinguished only by the name — from collapsing into one.
19
+ # - **The spelling is not canonical** — a folder written `018-⚪️--foo`
20
+ # before the `NNN.MM` padding rule or the ` → ` separator, or
21
+ # `18.1-⚪️--foo` by hand. Unpadded numbers read fine and sort wrong —
22
+ # `018.09` < `018.1` < `018.10` puts a single mixed-width folder in the
23
+ # middle of the range. Both are renamed, to `018.00-⚪️ → foo` and
24
+ # `018.01-⚪️ → foo`.
25
+ #
26
+ # The repairs are applied in that order reversed, as **up to two renames
27
+ # per folder**: first the spelling, under the status the folder already
28
+ # claims — which is how a `--`-era folder migrates to the ` → ` form —
29
+ # and only then the status its contents justify. Both renames aim at
30
+ # {Feature.plan_dirname}, so they cannot disagree about the final name;
31
+ # splitting them keeps each auditable on its own, instead of one report
32
+ # line where a spelling migration also appears to have opinions about
33
+ # the state of the work.
34
+ class Dirs
35
+ # A proposed rename.
36
+ #
37
+ # @!attribute [r] dirname
38
+ # @return [String] the folder as it stands
39
+ # @!attribute [r] from
40
+ # @return [Symbol] the state it claims
41
+ # @!attribute [r] to
42
+ # @return [Symbol] the state its contents justify
43
+ # @!attribute [r] source
44
+ # @return [String] absolute path now
45
+ # @!attribute [r] target
46
+ # @return [String] absolute path after
47
+ # @!attribute [r] reason
48
+ # @return [String] why the current name is wrong
49
+ Change = Data.define(:dirname, :from, :to, :source, :target, :reason) do
50
+ # @return [String] a single auditable line — `⇒` rather than `→`,
51
+ # which the folder names themselves now contain
52
+ def to_s = "#{dirname} ⇒ #{File.basename(target)} (#{reason})"
53
+ end
54
+
55
+ # The state a folder's contents justify, or the one it claims when
56
+ # nothing fits. What a rename moves toward, and what the dispatcher
57
+ # reads on a dry run, which renames nothing, so the preview still
58
+ # names the agents a real run would start.
59
+ #
60
+ # @param subject [Agentilda::Plans::Subject]
61
+ # @return [Agentilda::Plans::Status]
62
+ def self.target(subject) = subject.best_fit || subject.status
63
+
64
+ # @param tree [Agentilda::Plans::Tree]
65
+ # @param except [Array<String>] ordinals to leave alone however wrong
66
+ # their names are: the dispatcher passes the plans an agent is
67
+ # working in right now
68
+ def initialize(tree:, except: [])
69
+ @tree = tree
70
+ @except = except.map(&:to_s)
71
+ end
72
+
73
+ # @return [Agentilda::Plans::Tree]
74
+ attr_reader :tree
75
+
76
+ # What would change, without changing anything. A folder can appear
77
+ # twice: its spelling migration first, then its status correction.
78
+ #
79
+ # @return [Array<Agentilda::Lifecycle::Resync::Dirs::Change>]
80
+ def plan
81
+ tree.subjects.flat_map do |subject|
82
+ @except.include?(subject.feature.ordinal.to_s) ? [] : changes_for(subject)
83
+ end
84
+ end
85
+
86
+ # @param commit [Boolean] actually rename
87
+ # @return [Array<Agentilda::Lifecycle::Resync::Dirs::Change>] what was proposed
88
+ def call(commit: false)
89
+ changes = plan
90
+ return changes unless commit
91
+
92
+ UI.stepping(changes, "Renaming") { |change| rename(change) }
93
+ tree.reload
94
+ changes
95
+ end
96
+
97
+ private
98
+
99
+ # A folder moves when the name it has is not the name it should have.
100
+ # {target} says what that name is; this says how to get there, as up
101
+ # to two renames applied in order. The status rename starts from where
102
+ # the spelling rename left the folder, not from where it stands now,
103
+ # so committing the steps sequentially never renames a path that has
104
+ # already moved out from under it.
105
+ #
106
+ # @param subject [Agentilda::Plans::Subject]
107
+ # @return [Array<Agentilda::Lifecycle::Resync::Dirs::Change>] empty when the name
108
+ # is already right
109
+ def changes_for(subject)
110
+ feature = subject.feature
111
+ fit = self.class.target(subject)
112
+ steps = []
113
+
114
+ spelled = feature.dirname_as(subject.status)
115
+ if spelled != feature.dirname
116
+ steps << step(feature,
117
+ from: subject.status,
118
+ to: subject.status,
119
+ source: feature.path,
120
+ reason: spelling_reason(feature))
121
+ end
122
+
123
+ if fit.key != subject.status.key
124
+ source = steps.empty? ? feature.path : steps.last.target
125
+ steps << step(feature,
126
+ from: subject.status,
127
+ to: fit,
128
+ source:,
129
+ reason: subject.violation || "contents now justify #{fit.label}")
130
+ end
131
+
132
+ steps
133
+ end
134
+
135
+ # @param feature [Agentilda::Plans::Feature]
136
+ # @param from [Agentilda::Plans::Status]
137
+ # @param to [Agentilda::Plans::Status]
138
+ # @param source [String] absolute path the rename starts from
139
+ # @param reason [String]
140
+ # @return [Agentilda::Lifecycle::Resync::Dirs::Change]
141
+ def step(feature, from:, to:, source:, reason:)
142
+ Change.new(
143
+ dirname: File.basename(source),
144
+ from: from.key,
145
+ to: to.key,
146
+ source:,
147
+ target: File.join(File.dirname(feature.path), feature.dirname_as(to)),
148
+ reason:
149
+ )
150
+ end
151
+
152
+ # @param feature [Agentilda::Plans::Feature]
153
+ # @return [String] why the spelling is wrong, status aside
154
+ def spelling_reason(feature)
155
+ return "#{feature.dirname_ordinal} is not padded to #{feature.ordinal}" unless feature.padded?
156
+
157
+ "name is not in canonical NNN.MM-<emoji> → <slug> form"
158
+ end
159
+
160
+ # A rename that finds its target occupied has not happened, and saying
161
+ # nothing about it would leave the folder misnamed with a report
162
+ # claiming otherwise. Two folders can want the same canonical name —
163
+ # `018-⚪️--foo` and `018.00-⚪️ → foo` are the same plan written twice.
164
+ #
165
+ # @param change [Agentilda::Lifecycle::Resync::Dirs::Change]
166
+ # @return [void]
167
+ # @raise [Agentilda::Error] when the target name is already taken
168
+ def rename(change)
169
+ return if Agentilda.move_directory(change.source, change.target)
170
+
171
+ raise Error, "cannot rename #{change.dirname} — #{File.basename(change.target)} already exists"
172
+ end
173
+ end
174
+
175
+ # `resync prs` — puts an `[NNN.MM]` prefix on every pull request title that
176
+ # lacks one, and `[DEV.00]` on the ones that implement no plan.
177
+ #
178
+ # The resolution order is the branch name first, then the diff, and only
179
+ # when the diff touches exactly one plan. Anything else is reported for a
180
+ # human and never edited: `pull-requests.md` is generated from these
181
+ # titles, and a wrong number files work under a plan that did not do it,
182
+ # leaving the plan that did looking untouched.
183
+ class Prs
184
+ # The marker this tool used to write, now replaced. A title wearing it is
185
+ # rewritten in place: the assertion it makes — "this belongs to no
186
+ # specification" — is still true and still somebody's deliberate call,
187
+ # so it is restamped rather than re-resolved.
188
+ STALE = /\A\[#{Regexp.escape(Agentilda::STALE_NO_PLAN_PREFIX)}\]\s*/
189
+
190
+ # Titles that already carry a prefix — a plan number, the no-plan marker,
191
+ # or the legacy `[XXX]`.
192
+ PREFIXED = /\A\[(?:\d{3}(?:\.\d{2})?|#{Agentilda::NO_PLAN_PREFIX}|XXX)\](?:\([A-Z]\))?\s/
193
+
194
+ # Finds a plan number in a branch name: `kig/018.01-verify`, `002-slug`.
195
+ BRANCH_PATTERN = %r{(?:\A|[/\-_])(\d{3}(?:\.\d{2})?)(?:\z|[-_])}
196
+
197
+ # A proposed retitle.
198
+ #
199
+ # @!attribute [r] number
200
+ # @return [Integer] the pull request
201
+ # @!attribute [r] title
202
+ # @return [String] as it stands
203
+ # @!attribute [r] new_title
204
+ # @return [String, nil] nil when nothing may safely be done
205
+ # @!attribute [r] ordinal
206
+ # @return [Agentilda::Plans::Ordinal, nil]
207
+ # @!attribute [r] reason
208
+ # @return [String] how it was resolved, or why it was not
209
+ # @!attribute [r] ambiguous
210
+ # @return [Boolean] a human must decide; never edited
211
+ # @!attribute [r] assumed
212
+ # @return [Boolean] "no plan" was inferred, not asserted by the author
213
+ Change = Data.define(:number, :title, :new_title, :ordinal, :reason, :ambiguous, :assumed, :adopted) do
214
+ # @return [Boolean]
215
+ def ambiguous? = ambiguous
216
+
217
+ # @return [Boolean]
218
+ def assumed? = assumed
219
+
220
+ # @return [Boolean] whether a plan folder was minted for this one
221
+ def adopted? = adopted
222
+
223
+ # @return [Boolean] safe to apply without a human looking
224
+ def applicable? = !ambiguous && !new_title.nil?
225
+ end
226
+
227
+ # @param tree [Agentilda::Plans::Tree]
228
+ # @param github [Agentilda::Vcs::GitHub]
229
+ # @param adopt [Boolean] give a plan folder to every pull request that
230
+ # resolves to none, rather than flagging it and moving on
231
+ # @param root [String, nil] repository root, for reading branches
232
+ def initialize(tree:, github: Vcs::GitHub.new, adopt: true, root: nil)
233
+ @tree = tree
234
+ @github = github
235
+ @adopt = adopt
236
+ @root = root
237
+ end
238
+
239
+ # @return [Boolean]
240
+ def adopt? = @adopt
241
+
242
+ # @return [Agentilda::Plans::Tree]
243
+ attr_reader :tree
244
+
245
+ # @return [Agentilda::Vcs::GitHub]
246
+ attr_reader :github
247
+
248
+ # What would change, without changing anything.
249
+ #
250
+ # @return [Array<Agentilda::Lifecycle::Resync::Prs::Change>]
251
+ def plan
252
+ restamps + resolve(candidates).then { |changes| adopt? ? with_adoptions(changes) : changes }
253
+ end
254
+
255
+ # @return [Array<Agentilda::Lifecycle::Resync::Prs::Change>]
256
+ def restamps = stale.map { |pull| restamped(pull) }
257
+
258
+ # @param commit [Boolean] actually retitle, and mint the folders
259
+ # @return [Array<Agentilda::Lifecycle::Resync::Prs::Change>] what was proposed
260
+ def call(commit: false)
261
+ changes = resolve(candidates)
262
+ return restamps + (adopt? ? with_adoptions(changes) : changes) unless commit
263
+
264
+ changes = with_adoptions(changes, create: true) if adopt?
265
+ changes = restamps + changes
266
+ applicable = changes.select(&:applicable?)
267
+ UI.stepping(applicable, "Retitling") { |c| github.retitle(number: c.number, title: c.new_title) }
268
+ changes
269
+ end
270
+
271
+ # @return [Array<Agentilda::Lifecycle::Adoption>] the adopter, memoized
272
+ def adoption = @adoption ||= Adoption.new(tree:, github:, root: @root)
273
+
274
+ private
275
+
276
+ # @return [Array<Hash>] pull requests with no prefix yet
277
+ def candidates
278
+ github.pulls.reject { |pr| pr[:title].to_s.match?(PREFIXED) || pr[:title].to_s.match?(STALE) }
279
+ end
280
+
281
+ # @return [Array<Hash>] pull requests still wearing the old marker
282
+ def stale = github.pulls.select { |pr| pr[:title].to_s.match?(STALE) }
283
+
284
+ # @param pull [Hash]
285
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change]
286
+ def restamped(pull)
287
+ Change.new(number: pull[:number],
288
+ title: pull[:title],
289
+ ordinal: nil,
290
+ ambiguous: false,
291
+ new_title: pull[:title].sub(STALE, "[#{Agentilda::NO_PLAN_PREFIX}] "),
292
+ reason: "the no-plan marker is now [#{Agentilda::NO_PLAN_PREFIX}]",
293
+ assumed: false,
294
+ adopted: false)
295
+ end
296
+
297
+ # @param pulls [Array<Hash>]
298
+ # @return [Array<Agentilda::Lifecycle::Resync::Prs::Change>]
299
+ def resolve(pulls) = pulls.map { |pr| change_for(pr) }
300
+
301
+ # Replace every flagged change with one that points at a plan folder the
302
+ # pull request now owns. The unresolvable ones were unresolvable because
303
+ # no plan described them — so the answer is a plan, not a shrug.
304
+ #
305
+ # @param changes [Array<Agentilda::Lifecycle::Resync::Prs::Change>]
306
+ # @param create [Boolean] mint the folders, rather than only saying so
307
+ # @return [Array<Agentilda::Lifecycle::Resync::Prs::Change>]
308
+ def with_adoptions(changes, create: false)
309
+ orphans = changes.select(&:ambiguous?)
310
+ return changes if orphans.empty?
311
+
312
+ pulls = orphans.map { |c| pull_by_number.fetch(c.number) }
313
+ adoptees = create ? adoption.call(pulls) : adoption.plan(pulls)
314
+ by_number = adoptees.to_h { |a| [a.pull[:number], a] }
315
+
316
+ changes.map { |c| c.ambiguous? && by_number[c.number] ? adopted(c, by_number[c.number]) : c }
317
+ end
318
+
319
+ # @return [Hash{Integer => Hash}]
320
+ def pull_by_number = @pull_by_number ||= candidates.to_h { |pr| [pr[:number], pr] }
321
+
322
+ # @param change [Agentilda::Lifecycle::Resync::Prs::Change]
323
+ # @param adoptee [Agentilda::Lifecycle::Adoption::Adoptee]
324
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change]
325
+ def adopted(change, adoptee)
326
+ change.with(
327
+ ordinal: adoptee.ordinal,
328
+ new_title: "#{adoptee.ordinal.to_prefix} #{change.title}",
329
+ reason: "#{change.reason} — adopted into #{adoptee.dirname}",
330
+ ambiguous: false,
331
+ adopted: true
332
+ )
333
+ end
334
+
335
+ # @param pull [Hash]
336
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change]
337
+ def change_for(pull)
338
+ from_branch(pull) || from_files(pull) || no_plan(pull)
339
+ end
340
+
341
+ # 1. The branch name — the one moment the author certainly knew.
342
+ #
343
+ # @param pull [Hash]
344
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change, nil]
345
+ def from_branch(pull)
346
+ match = BRANCH_PATTERN.match(pull[:branch].to_s) or return nil
347
+ ordinal = Plans::Ordinal.parse(match[1])
348
+
349
+ unless tree.include?(ordinal)
350
+ return flag(pull, "branch names #{ordinal}, which has no folder in #{File.basename(tree.dir)}")
351
+ end
352
+
353
+ resolved(pull, ordinal, "branch #{pull[:branch]}")
354
+ end
355
+
356
+ # 2. The diff, and only when it touches exactly one plan.
357
+ #
358
+ # @param pull [Hash]
359
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change, nil]
360
+ def from_files(pull)
361
+ touched = Array(pull[:files]).filter_map { |path| ordinal_for_path(path) }.uniq
362
+ case touched.size
363
+ when 1 then resolved(pull, touched.first, "diff touches only #{touched.first}")
364
+ when 0 then nil
365
+ else flag(pull, "diff touches #{touched.join(", ")} and none is obviously primary")
366
+ end
367
+ end
368
+
369
+ # @param path [String] a path from the diff
370
+ # @return [Agentilda::Plans::Ordinal, nil]
371
+ def ordinal_for_path(path)
372
+ parts = path.to_s.split("/")
373
+ index = parts.index(Agentilda::PLANS_DIR) or return nil
374
+ folder = parts[index + 1] or return nil
375
+ return nil if parts.length <= index + 2 # a file directly under .plans belongs to no plan
376
+
377
+ ordinal = Plans::Ordinal.from_dirname(folder)
378
+ ordinal if ordinal && tree.include?(ordinal)
379
+ end
380
+
381
+ # @param pull [Hash]
382
+ # @param ordinal [Agentilda::Plans::Ordinal]
383
+ # @param why [String]
384
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change]
385
+ def resolved(pull, ordinal, why)
386
+ Change.new(number: pull[:number],
387
+ title: pull[:title],
388
+ ordinal:,
389
+ reason: why,
390
+ new_title: "#{ordinal.to_prefix} #{pull[:title]}",
391
+ ambiguous: false,
392
+ assumed: false,
393
+ adopted: false)
394
+ end
395
+
396
+ # Nothing resolved, so this is developer work — but that is an assertion
397
+ # about intent, so it is marked as assumed rather than stated as fact.
398
+ #
399
+ # @param pull [Hash]
400
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change]
401
+ def no_plan(pull)
402
+ Change.new(number: pull[:number],
403
+ title: pull[:title],
404
+ ordinal: nil,
405
+ new_title: "[#{Agentilda::NO_PLAN_PREFIX}] #{pull[:title]}",
406
+ reason: "no plan resolved from the branch or the diff",
407
+ ambiguous: false,
408
+ assumed: true,
409
+ adopted: false)
410
+ end
411
+
412
+ # @param pull [Hash]
413
+ # @param why [String]
414
+ # @return [Agentilda::Lifecycle::Resync::Prs::Change]
415
+ def flag(pull, why)
416
+ Change.new(number: pull[:number],
417
+ title: pull[:title],
418
+ new_title: nil,
419
+ ordinal: nil,
420
+ reason: why,
421
+ ambiguous: true,
422
+ assumed: false,
423
+ adopted: false)
424
+ end
425
+ end
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Lifecycle
5
+ # Draining a plan's `blocked.md`, and saying what happened.
6
+ #
7
+ # `unblock` is the one command a human runs *because* they know something the
8
+ # tool cannot observe: an answer has arrived. So it owes them an account of
9
+ # what it did with it. Four things can happen to a plan named on the command
10
+ # line, and only one of them is the interesting one:
11
+ #
12
+ # * the number names no folder,
13
+ # * the folder is not blocked and there is nothing to drain,
14
+ # * the folder is blocked, and some, none or all of its questions fold in,
15
+ # * `blocked.md` is there but numbers nothing `## B<n>`, so no program can
16
+ # read it at all.
17
+ #
18
+ # Each is reported differently, because each needs a different thing done
19
+ # next. This object works out which one happened; {CLI::Unblock} prints it.
20
+ class Unblocker
21
+ # One open question, as `blocked.md` writes it.
22
+ #
23
+ # @!attribute [r] number
24
+ # @return [Integer] the `n` in `## B<n>`
25
+ # @!attribute [r] heading
26
+ # @return [String] the heading text, hashes stripped
27
+ # @!attribute [r] answered
28
+ # @return [Boolean] whether an `## A<n>` of the same number is waiting
29
+ Question = Data.define(:number, :heading, :answered) do
30
+ # @return [String]
31
+ def to_s = answered ? "#{heading} ← answer waiting" : heading
32
+ end
33
+
34
+ # One token from the command line, once the tree has had a look at it.
35
+ #
36
+ # @!attribute [r] token
37
+ # @return [String] what the user typed
38
+ # @!attribute [r] subject
39
+ # @return [Agentilda::Plans::Subject, nil]
40
+ # @!attribute [r] problem
41
+ # @return [Symbol, nil] :missing, :not_blocked, or nil when it is drainable
42
+ Target = Data.define(:token, :subject, :problem) do
43
+ # @return [Boolean]
44
+ def drainable? = problem.nil?
45
+ end
46
+
47
+ # What became of one plan.
48
+ #
49
+ # @!attribute [r] subject
50
+ # @return [Agentilda::Plans::Subject] as the folder stands *now*
51
+ # @!attribute [r] ok
52
+ # @return [Boolean] whether the agent ran without failing
53
+ # @!attribute [r] note
54
+ # @return [String] one line about how it went
55
+ # @!attribute [r] before
56
+ # @return [Array<Question>] open before the agent ran
57
+ # @!attribute [r] after
58
+ # @return [Array<Question>] open now
59
+ Outcome = Data.define(:subject, :ok, :note, :before, :after) do
60
+ # @return [Agentilda::Plans::Ordinal]
61
+ def ordinal = subject.feature.ordinal
62
+
63
+ # @return [Array<Integer>] the questions this run actually disposed of
64
+ def folded = before.map(&:number) - after.map(&:number)
65
+
66
+ # @return [Boolean] `blocked.md` is gone, so the plan is out of the block
67
+ def cleared? = !subject.file?("blocked.md")
68
+
69
+ # @return [Boolean] a `blocked.md` no program can read
70
+ def unreadable? = subject.unreadable_block?
71
+
72
+ # @return [Array<Question>] still open, with an answer sitting unfolded
73
+ def waiting = after.select(&:answered)
74
+ end
75
+
76
+ # The questions a folder still names, in the order the file names them.
77
+ #
78
+ # @param subject [Agentilda::Plans::Subject]
79
+ # @return [Array<Question>]
80
+ def self.questions(subject)
81
+ answered = subject.block_answers
82
+
83
+ subject.read("blocked.md").to_s.lines.grep(Plans::OPEN_BLOCK).map do |line|
84
+ number = line[Plans::OPEN_BLOCK, 1].to_i
85
+ Question.new(number:, heading: line.strip.sub(/\A\#+[ \t]*/, ""), answered: answered.include?(number))
86
+ end
87
+ end
88
+
89
+ # @param tree [Agentilda::Plans::Tree]
90
+ # @param agent [Agentilda::Agents::Agent]
91
+ # @param executor [#call] receives (agent, subject, root:) and yields each
92
+ # phrase describing what the agent is doing
93
+ # @param root [String] the repository the agent works in
94
+ # @param commit [Boolean] false plans the invocation and changes nothing
95
+ def initialize(tree:, agent:, executor:, root: nil, commit: false)
96
+ @tree = tree
97
+ @agent = agent
98
+ @executor = executor
99
+ @root = root || File.dirname(tree.dir)
100
+ @commit = commit
101
+ end
102
+
103
+ # @return [Agentilda::Plans::Tree]
104
+ attr_reader :tree
105
+
106
+ # @return [Agentilda::Agents::Agent]
107
+ attr_reader :agent
108
+
109
+ # @return [String]
110
+ attr_reader :root
111
+
112
+ # @return [Boolean]
113
+ def commit? = @commit
114
+
115
+ # Work out what each number on the command line refers to.
116
+ #
117
+ # Nothing is rejected here and nothing exits: a token that names no folder
118
+ # is as much a thing the user needs told about as a folder that drained, and
119
+ # stopping at the first bad one hides the rest of the report.
120
+ #
121
+ # The gate is `blocked.md` itself, not the folder's emoji. ⭕️ is *derived*
122
+ # from the file, so gating on the emoji made the tool circular: a
123
+ # `blocked.md` whose questions are not numbered `## B<n>` never earns the
124
+ # emoji, so `unblock` refused it, so the file could never drain and the
125
+ # folder could never leave 🟡.
126
+ #
127
+ # @param tokens [Array<String>] `NNN`, `NNN.MM`, or several comma separated
128
+ # @return [Array<Target>]
129
+ def resolve(tokens)
130
+ tokens.flat_map { |token| token.to_s.split(",") }.map(&:strip).reject(&:empty?).map do |token|
131
+ subject = tree.find(token)
132
+ next Target.new(token:, subject: nil, problem: :missing) if subject.nil?
133
+ next Target.new(token:, subject:, problem: :not_blocked) unless subject.file?("blocked.md")
134
+
135
+ Target.new(token:, subject:, problem: nil)
136
+ end
137
+ end
138
+
139
+ # Hand each folder to the agent, one at a time, and read the file back.
140
+ #
141
+ # What the agent claims it did is not evidence. The questions are counted
142
+ # off disk before and after, so "folded B3" means the heading is gone from
143
+ # `blocked.md`, not that an agent said so.
144
+ #
145
+ # @param subjects [Array<Agentilda::Plans::Subject>]
146
+ # @return [Array<Outcome>]
147
+ def call(subjects)
148
+ return [] if subjects.empty?
149
+
150
+ before = subjects.map { |subject| [subject.feature.ordinal.to_s, self.class.questions(subject)] }.to_h
151
+ results = UI.concurrently(subjects,
152
+ headline(subjects),
153
+ jobs: 1,
154
+ label: method(:label),
155
+ fields: method(:log_fields),
156
+ file: ->(_) { "blocked.md" },
157
+ root:) do |subject, progress|
158
+ # After ctrl-c the plans not yet started stay as they are, blocked.md
159
+ # untouched, so the next run picks them up from the beginning.
160
+ next [false, "not started: interrupted by ctrl-c"] if Execution::Control.interrupted?
161
+
162
+ invoke(subject, progress)
163
+ end
164
+
165
+ settle
166
+ subjects.zip(results).map { |subject, result| outcome(subject, result, before) }
167
+ end
168
+
169
+ private
170
+
171
+ # @param subject [Agentilda::Plans::Subject]
172
+ # @param progress [Agentilda::UI::Line] a dashboard row carries the
173
+ # executor handle its kill and extend keys act on; a plain line does not
174
+ # @return [Array(Boolean, String)]
175
+ def invoke(subject, progress)
176
+ handle = progress.handle if progress.is_a?(Presentation::Dashboard::Tracker)
177
+ @executor.call(agent, subject, root:, handle:, &progress)
178
+ rescue StandardError => e
179
+ [false, "#{e.class}: #{e.message.lines.first.to_s.strip}"]
180
+ end
181
+
182
+ # The same pass the run loop makes after every round, for the same reason:
183
+ # a drained folder is only *named* differently once something reads the
184
+ # file the agent just deleted.
185
+ #
186
+ # @return [void]
187
+ def settle
188
+ Resync::Dirs.new(tree:).call(commit: true) if commit?
189
+ tree.reload
190
+ end
191
+
192
+ # @param subject [Agentilda::Plans::Subject] as it was before the run
193
+ # @param result [Array, Exception]
194
+ # @param before [Hash{String => Array<Question>}]
195
+ # @return [Outcome]
196
+ def outcome(subject, result, before)
197
+ ok, note = result.is_a?(Exception) ? [false, result.message.lines.first.to_s.strip] : result
198
+ current = tree.find(subject.feature.ordinal) || subject
199
+
200
+ Outcome.new(subject: current,
201
+ ok: !!ok,
202
+ note: note.to_s,
203
+ before: before.fetch(subject.feature.ordinal.to_s, []),
204
+ after: self.class.questions(current))
205
+ end
206
+
207
+ # @param subjects [Array<Agentilda::Plans::Subject>]
208
+ # @return [String]
209
+ def headline(subjects)
210
+ "#{commit? ? "handing" : "would hand"} #{subjects.size} plan#{"s" unless subjects.size == 1} " \
211
+ "to #{agent.name} in #{root}"
212
+ end
213
+
214
+ # @param subject [Agentilda::Plans::Subject]
215
+ # @return [String]
216
+ def label(subject) = "#{subject.feature.ordinal} → #{agent.name}"
217
+
218
+ # @param subject [Agentilda::Plans::Subject]
219
+ # @return [Hash] the columns this plan's log lines carry
220
+ def log_fields(subject) = { plan: subject.feature.ordinal.to_s, status: subject.status.to_s, agent: agent.name }
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # Operations that change which plans exist or what they are called:
5
+ # minting a folder, drafting its brief, adopting an orphan pull request,
6
+ # reconciling names with contents, and draining answered questions.
7
+ module Lifecycle
8
+ module_function
9
+
10
+ # @param dir [String] the `.plans` directory
11
+ # @param options [Hash] passed to {Creator#create}
12
+ # @return [Dry::Monads::Result] Success(path) or Failure(message)
13
+ def create(dir:, **) = Creator.new(dir:).create(**)
14
+
15
+ # @param tree [Agentilda::Plans::Tree]
16
+ # @return [Agentilda::Lifecycle::Resync::Dirs]
17
+ def resync_dirs(tree:, **) = Resync::Dirs.new(tree:, **)
18
+
19
+ # @return [Agentilda::Lifecycle::Resync::Prs]
20
+ def resync_prs(**) = Resync::Prs.new(**)
21
+
22
+ # @return [Agentilda::Lifecycle::Brief]
23
+ def brief(**) = Brief.new(**)
24
+
25
+ # @return [Agentilda::Lifecycle::Unblocker]
26
+ def unblocker(**) = Unblocker.new(**)
27
+ end
28
+ end
@@ -6,7 +6,7 @@ require "uri"
6
6
 
7
7
  module Agentilda
8
8
  module Linear
9
- # Linear's GraphQL API, wrapped as thinly as {Agentilda::GitHub} wraps
9
+ # Linear's GraphQL API, wrapped as thinly as {Agentilda::Vcs::GitHub} wraps
10
10
  # `gh`, and for the same reason: it is a seam. Every example in the suite
11
11
  # injects a transport here, so nothing in the tests reaches Linear.
12
12
  #