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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # `/spec-status` — every plan, its state in icon and words, and its pull
5
+ # requests as clickable links.
6
+ #
7
+ # {#render} returns a string and prints nothing. Whoever calls it decides
8
+ # where it goes, which keeps the table pipeable.
9
+ class Reporter
10
+ # Columns, so the header and the rows cannot drift apart.
11
+ HEADINGS = ["Plan", "⬇", "Status", "Feature", "Pull Requests"].freeze
12
+
13
+ # Cells given to the plan number: "018.01" with room to grow.
14
+ ORDINAL_WIDTH = 7
15
+
16
+ # Cells given to a status icon. Emoji vary in both character count and
17
+ # display width, so the cell is padded to this by {UI.fit} rather than
18
+ # trusted to be any particular size — see the note there.
19
+ EMOJI_WIDTH = 2
20
+
21
+ # Cells given to a pull request's state, so the URLs line up beneath each
22
+ # other whichever states the tree happens to contain.
23
+ STATE_WIDTH = PullRequests::STATES.map { |_, label| UI.display_width(label) }.max
24
+
25
+ # @param tree [Agentilda::Tree]
26
+ def initialize(tree:)
27
+ @tree = tree
28
+ end
29
+
30
+ # @return [Agentilda::Tree]
31
+ attr_reader :tree
32
+
33
+ # @return [String] the whole table, newline-terminated
34
+ def render
35
+ return "No plans found in #{tree.dir}\n" if tree.subjects.empty?
36
+
37
+ [header, *tree.subjects.flat_map { |s| rows_for(s) }, "", footer].join("\n") + "\n"
38
+ end
39
+
40
+ # @return [Hash{Symbol => Integer}] how many plans sit in each state
41
+ def totals
42
+ tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status.key] += 1 }
43
+ end
44
+
45
+ # Plans whose folder name their contents do not justify.
46
+ #
47
+ # @return [Array<Agentilda::Subject>]
48
+ def inconsistent = tree.subjects.reject(&:consistent?)
49
+
50
+ private
51
+
52
+ # @return [Integer] widest status label, so the column lines up
53
+ def label_width = @label_width ||= tree.subjects.map { |s| UI.display_width(s.status.label) }.max.to_i + 4
54
+
55
+ # @return [Integer] widest feature name
56
+ def title_width = @title_width ||= tree.subjects.map { |s| UI.display_width(s.feature.title) }.max.to_i + 2
57
+
58
+ # The one place the table's shape is written down. The heading, the plan
59
+ # rows, the continuation lines and the violations all come through here, so
60
+ # a column cannot widen in one of them and stay put in the others.
61
+ #
62
+ # Cells arrive already fitted and already painted, in that order, because
63
+ # the order matters: escape codes count toward a string's length, so
64
+ # padding a coloured string pads it to a width of which several characters
65
+ # are invisible, and the column collapses by exactly that much.
66
+ #
67
+ # @return [String]
68
+ def row(flag, ordinal, emoji, label, title, tail)
69
+ format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
70
+ end
71
+
72
+ # Cell at which the pull requests start, so a plan's second and third pull
73
+ # requests sit under its first. Measured by running blanks through {#row}
74
+ # rather than counted by hand, so the indent cannot drift when the layout
75
+ # changes.
76
+ #
77
+ # @return [Integer]
78
+ def pr_column
79
+ @pr_column ||= row(" ", " " * ORDINAL_WIDTH, " " * EMOJI_WIDTH,
80
+ " " * label_width, " " * title_width, "").length
81
+ end
82
+
83
+ # @return [Integer] cell at which the feature name starts
84
+ def title_column = pr_column - title_width - 1
85
+
86
+ # @return [String]
87
+ def header
88
+ text = row(" ",
89
+ UI.fit(HEADINGS[0], ORDINAL_WIDTH),
90
+ UI.fit(HEADINGS[1], EMOJI_WIDTH),
91
+ UI.fit(HEADINGS[2], label_width),
92
+ UI.fit(HEADINGS[3], title_width),
93
+ HEADINGS[4])
94
+
95
+ rule = " " + ("─" * (UI.display_width(text) - 2))
96
+
97
+ [UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
98
+ end
99
+
100
+ # One line per plan, plus a continuation line for each extra pull request
101
+ # so that every URL is visible and clickable rather than truncated.
102
+ #
103
+ # @param subject [Agentilda::Subject]
104
+ # @return [Array<String>]
105
+ def rows_for(subject)
106
+ feature = subject.feature
107
+ prs = subject.pull_requests
108
+
109
+ first = row(
110
+ subject.consistent? ? " " : UI.paint("!", :red),
111
+ UI.paint(UI.fit(feature.ordinal.to_s, ORDINAL_WIDTH), :bold),
112
+ UI.fit(feature.status.emoji, EMOJI_WIDTH),
113
+ UI.paint(UI.fit(feature.status.label, label_width), :yellow),
114
+ UI.paint(UI.fit(feature.title, title_width), :blue, :bold),
115
+ prs.empty? ? UI.paint("[none yet]", :bright_red) : link(prs.first)
116
+ )
117
+
118
+ rest = prs.drop(1).map { |pr| (" " * pr_column) + link(pr) }
119
+
120
+ [first, *rest, *violation_line(subject)]
121
+ end
122
+
123
+ # The URL is left bare rather than wrapped in an OSC 8 hyperlink: the table
124
+ # is as likely to be piped into a file as read in a terminal, and every
125
+ # terminal worth the name linkifies a bare URL anyway.
126
+ #
127
+ # @param pr [Agentilda::PullRequest]
128
+ # @return [String]
129
+ def link(pr)
130
+ colour = state_colour(pr)
131
+
132
+ format("#%4s %s %s",
133
+ pr.number,
134
+ UI.paint(UI.fit(pr.state, STATE_WIDTH), colour),
135
+ UI.paint(pr.url.to_s, colour))
136
+ end
137
+
138
+ # @param pr [Agentilda::PullRequest]
139
+ # @return [Symbol] a pastel style name
140
+ def state_colour(pr)
141
+ if pr.merged?
142
+ :magenta
143
+ elsif pr.open?
144
+ :yellow
145
+ else
146
+ :blue
147
+ end
148
+ end
149
+
150
+ # @param subject [Agentilda::Subject]
151
+ # @return [Array<String>]
152
+ def violation_line(subject)
153
+ return [] if subject.consistent?
154
+
155
+ [(" " * title_column) + UI.paint("↳ #{subject.violation}", :red)]
156
+ end
157
+
158
+ # @return [String]
159
+ def footer
160
+ counts = totals.map { |key, n| "#{STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
161
+ lines = [" #{tree.subjects.size} #{(tree.subjects.size == 1) ? "plan" : "plans"} #{counts}"]
162
+
163
+ unless inconsistent.empty?
164
+ lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"
165
+ end
166
+
167
+ tree.duplicates.each do |ordinal, dirnames|
168
+ lines << " #{UI.paint("#{ordinal} is claimed by #{dirnames.size} folders — a number is an identity:", :red)}"
169
+ dirnames.each { |d| lines << " #{UI.paint(d, :bright_black)}" }
170
+ end
171
+
172
+ lines.join("\n")
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,358 @@
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 number is not in `NNN.MM` form** — a folder written `018-⚪️--foo`
19
+ # before the padding rule, or `18.1-⚪️--foo` by hand. These read fine and
20
+ # sort wrong, which is the whole reason the rule exists: `018.09` <
21
+ # `018.1` < `018.10` puts a single mixed-width folder in the middle of
22
+ # the range. Both are renamed to `018.00-⚪️--foo` and `018.01-⚪️--foo`.
23
+ #
24
+ # The two cases collapse into one rule — **rename any folder that is not
25
+ # already named what it should be named** — which is why the emoji fix and
26
+ # the renumber cannot disagree about the target.
27
+ class Dirs
28
+ # A proposed rename.
29
+ #
30
+ # @!attribute [r] dirname
31
+ # @return [String] the folder as it stands
32
+ # @!attribute [r] from
33
+ # @return [Symbol] the state it claims
34
+ # @!attribute [r] to
35
+ # @return [Symbol] the state its contents justify
36
+ # @!attribute [r] source
37
+ # @return [String] absolute path now
38
+ # @!attribute [r] target
39
+ # @return [String] absolute path after
40
+ # @!attribute [r] reason
41
+ # @return [String] why the current name is wrong
42
+ Change = Data.define(:dirname, :from, :to, :source, :target, :reason) do
43
+ # @return [String] a single auditable line
44
+ def to_s = "#{dirname} → #{File.basename(target)} (#{reason})"
45
+ end
46
+
47
+ # @param tree [Agentilda::Tree]
48
+ def initialize(tree:)
49
+ @tree = tree
50
+ end
51
+
52
+ # @return [Agentilda::Tree]
53
+ attr_reader :tree
54
+
55
+ # What would change, without changing anything.
56
+ #
57
+ # @return [Array<Agentilda::Resync::Dirs::Change>]
58
+ def plan = tree.subjects.filter_map { |subject| change_for(subject) }
59
+
60
+ # @param commit [Boolean] actually rename
61
+ # @return [Array<Agentilda::Resync::Dirs::Change>] what was proposed
62
+ def call(commit: false)
63
+ changes = plan
64
+ return changes unless commit
65
+
66
+ UI.stepping(changes, "Renaming") { |change| rename(change) }
67
+ tree.reload
68
+ changes
69
+ end
70
+
71
+ private
72
+
73
+ # A folder moves when the name it has is not the name it should have.
74
+ #
75
+ # `best_fit` answers "which state do these contents justify", and falls
76
+ # back to the state already claimed when nothing fits — so a folder with
77
+ # an unreadable set of contents still gets its number padded rather than
78
+ # being skipped for a reason that has nothing to do with its number.
79
+ #
80
+ # Invariants are minimum requirements, so a ⚪️ folder that has grown a
81
+ # `plan.md` still satisfies ⚪️ and is ⭐️ anyway.
82
+ #
83
+ # @param subject [Agentilda::Subject]
84
+ # @return [Agentilda::Resync::Dirs::Change, nil]
85
+ def change_for(subject)
86
+ feature = subject.feature
87
+ fit = subject.best_fit || subject.status
88
+ dirname = feature.dirname_as(fit)
89
+ return nil if dirname == feature.dirname
90
+
91
+ Change.new(
92
+ dirname: feature.dirname,
93
+ from: subject.status.key,
94
+ to: fit.key,
95
+ source: feature.path,
96
+ target: File.join(File.dirname(feature.path), dirname),
97
+ reason: reason_for(subject, fit)
98
+ )
99
+ end
100
+
101
+ # @param subject [Agentilda::Subject]
102
+ # @param fit [Agentilda::Status] the state the folder is moving to
103
+ # @return [String] why the current name is wrong
104
+ def reason_for(subject, fit)
105
+ feature = subject.feature
106
+ return subject.violation || "contents now justify #{fit.label}" unless fit.key == subject.status.key
107
+ return "#{feature.dirname_ordinal} is not padded to #{feature.ordinal}" unless feature.padded?
108
+
109
+ "name is not in canonical NNN.MM-<emoji>--<slug> form"
110
+ end
111
+
112
+ # A rename that finds its target occupied has not happened, and saying
113
+ # nothing about it would leave the folder misnamed with a report
114
+ # claiming otherwise. Two folders can want the same canonical name —
115
+ # `018-⚪️--foo` and `018.00-⚪️--foo` are the same plan written twice.
116
+ #
117
+ # @param change [Agentilda::Resync::Dirs::Change]
118
+ # @return [void]
119
+ # @raise [Agentilda::Error] when the target name is already taken
120
+ def rename(change)
121
+ return if Agentilda.move_directory(change.source, change.target)
122
+
123
+ raise Error, "cannot rename #{change.dirname} — #{File.basename(change.target)} already exists"
124
+ end
125
+ end
126
+
127
+ # `resync prs` — puts an `[NNN.MM]` prefix on every pull request title that
128
+ # lacks one, and `[DEV.00]` on the ones that implement no plan.
129
+ #
130
+ # The resolution order is the branch name first, then the diff, and only
131
+ # when the diff touches exactly one plan. Anything else is reported for a
132
+ # human and never edited: `pull-requests.md` is generated from these
133
+ # titles, and a wrong number files work under a plan that did not do it,
134
+ # leaving the plan that did looking untouched.
135
+ class Prs
136
+ # The marker this tool used to write, now replaced. A title wearing it is
137
+ # rewritten in place: the assertion it makes — "this belongs to no
138
+ # specification" — is still true and still somebody's deliberate call,
139
+ # so it is restamped rather than re-resolved.
140
+ STALE = /\A\[#{Regexp.escape(Agentilda::STALE_NO_PLAN_PREFIX)}\]\s*/
141
+
142
+ # Titles that already carry a prefix — a plan number, the no-plan marker,
143
+ # or the legacy `[XXX]`.
144
+ PREFIXED = /\A\[(?:\d{3}(?:\.\d{2})?|#{Agentilda::NO_PLAN_PREFIX}|XXX)\](?:\([A-Z]\))?\s/
145
+
146
+ # Finds a plan number in a branch name: `kig/018.01-verify`, `002-slug`.
147
+ BRANCH_PATTERN = %r{(?:\A|[/\-_])(\d{3}(?:\.\d{2})?)(?:\z|[-_])}
148
+
149
+ # A proposed retitle.
150
+ #
151
+ # @!attribute [r] number
152
+ # @return [Integer] the pull request
153
+ # @!attribute [r] title
154
+ # @return [String] as it stands
155
+ # @!attribute [r] new_title
156
+ # @return [String, nil] nil when nothing may safely be done
157
+ # @!attribute [r] ordinal
158
+ # @return [Agentilda::Ordinal, nil]
159
+ # @!attribute [r] reason
160
+ # @return [String] how it was resolved, or why it was not
161
+ # @!attribute [r] ambiguous
162
+ # @return [Boolean] a human must decide; never edited
163
+ # @!attribute [r] assumed
164
+ # @return [Boolean] "no plan" was inferred, not asserted by the author
165
+ Change = Data.define(:number, :title, :new_title, :ordinal, :reason, :ambiguous, :assumed, :adopted) do
166
+ # @return [Boolean]
167
+ def ambiguous? = ambiguous
168
+
169
+ # @return [Boolean]
170
+ def assumed? = assumed
171
+
172
+ # @return [Boolean] whether a plan folder was minted for this one
173
+ def adopted? = adopted
174
+
175
+ # @return [Boolean] safe to apply without a human looking
176
+ def applicable? = !ambiguous && !new_title.nil?
177
+ end
178
+
179
+ # @param tree [Agentilda::Tree]
180
+ # @param github [Agentilda::GitHub]
181
+ # @param adopt [Boolean] give a plan folder to every pull request that
182
+ # resolves to none, rather than flagging it and moving on
183
+ # @param root [String, nil] repository root, for reading branches
184
+ def initialize(tree:, github: GitHub.new, adopt: true, root: nil)
185
+ @tree = tree
186
+ @github = github
187
+ @adopt = adopt
188
+ @root = root
189
+ end
190
+
191
+ # @return [Boolean]
192
+ def adopt? = @adopt
193
+
194
+ # @return [Agentilda::Tree]
195
+ attr_reader :tree
196
+
197
+ # @return [Agentilda::GitHub]
198
+ attr_reader :github
199
+
200
+ # What would change, without changing anything.
201
+ #
202
+ # @return [Array<Agentilda::Resync::Prs::Change>]
203
+ def plan
204
+ restamps + resolve(candidates).then { |changes| adopt? ? with_adoptions(changes) : changes }
205
+ end
206
+
207
+ # @return [Array<Agentilda::Resync::Prs::Change>]
208
+ def restamps = stale.map { |pull| restamped(pull) }
209
+
210
+ # @param commit [Boolean] actually retitle, and mint the folders
211
+ # @return [Array<Agentilda::Resync::Prs::Change>] what was proposed
212
+ def call(commit: false)
213
+ changes = resolve(candidates)
214
+ return restamps + (adopt? ? with_adoptions(changes) : changes) unless commit
215
+
216
+ changes = with_adoptions(changes, create: true) if adopt?
217
+ changes = restamps + changes
218
+ applicable = changes.select(&:applicable?)
219
+ UI.stepping(applicable, "Retitling") { |c| github.retitle(number: c.number, title: c.new_title) }
220
+ changes
221
+ end
222
+
223
+ # @return [Array<Agentilda::Adoption>] the adopter, memoized
224
+ def adoption = @adoption ||= Adoption.new(tree:, github:, root: @root)
225
+
226
+ private
227
+
228
+ # @return [Array<Hash>] pull requests with no prefix yet
229
+ def candidates
230
+ github.pulls.reject { |pr| pr[:title].to_s.match?(PREFIXED) || pr[:title].to_s.match?(STALE) }
231
+ end
232
+
233
+ # @return [Array<Hash>] pull requests still wearing the old marker
234
+ def stale = github.pulls.select { |pr| pr[:title].to_s.match?(STALE) }
235
+
236
+ # @param pull [Hash]
237
+ # @return [Agentilda::Resync::Prs::Change]
238
+ def restamped(pull)
239
+ Change.new(number: pull[:number], title: pull[:title], ordinal: nil, ambiguous: false,
240
+ new_title: pull[:title].sub(STALE, "[#{Agentilda::NO_PLAN_PREFIX}] "),
241
+ reason: "the no-plan marker is now [#{Agentilda::NO_PLAN_PREFIX}]",
242
+ assumed: false, adopted: false)
243
+ end
244
+
245
+ # @param pulls [Array<Hash>]
246
+ # @return [Array<Agentilda::Resync::Prs::Change>]
247
+ def resolve(pulls) = pulls.map { |pr| change_for(pr) }
248
+
249
+ # Replace every flagged change with one that points at a plan folder the
250
+ # pull request now owns. The unresolvable ones were unresolvable because
251
+ # no plan described them — so the answer is a plan, not a shrug.
252
+ #
253
+ # @param changes [Array<Agentilda::Resync::Prs::Change>]
254
+ # @param create [Boolean] mint the folders, rather than only saying so
255
+ # @return [Array<Agentilda::Resync::Prs::Change>]
256
+ def with_adoptions(changes, create: false)
257
+ orphans = changes.select(&:ambiguous?)
258
+ return changes if orphans.empty?
259
+
260
+ pulls = orphans.map { |c| pull_by_number.fetch(c.number) }
261
+ adoptees = create ? adoption.call(pulls) : adoption.plan(pulls)
262
+ by_number = adoptees.to_h { |a| [a.pull[:number], a] }
263
+
264
+ changes.map { |c| (c.ambiguous? && by_number[c.number]) ? adopted(c, by_number[c.number]) : c }
265
+ end
266
+
267
+ # @return [Hash{Integer => Hash}]
268
+ def pull_by_number = @pull_by_number ||= candidates.to_h { |pr| [pr[:number], pr] }
269
+
270
+ # @param change [Agentilda::Resync::Prs::Change]
271
+ # @param adoptee [Agentilda::Adoption::Adoptee]
272
+ # @return [Agentilda::Resync::Prs::Change]
273
+ def adopted(change, adoptee)
274
+ change.with(
275
+ ordinal: adoptee.ordinal,
276
+ new_title: "#{adoptee.ordinal.to_prefix} #{change.title}",
277
+ reason: "#{change.reason} — adopted into #{adoptee.dirname}",
278
+ ambiguous: false,
279
+ adopted: true
280
+ )
281
+ end
282
+
283
+ # @param pull [Hash]
284
+ # @return [Agentilda::Resync::Prs::Change]
285
+ def change_for(pull)
286
+ from_branch(pull) || from_files(pull) || no_plan(pull)
287
+ end
288
+
289
+ # 1. The branch name — the one moment the author certainly knew.
290
+ #
291
+ # @param pull [Hash]
292
+ # @return [Agentilda::Resync::Prs::Change, nil]
293
+ def from_branch(pull)
294
+ match = BRANCH_PATTERN.match(pull[:branch].to_s) or return nil
295
+ ordinal = Ordinal.parse(match[1])
296
+
297
+ unless tree.include?(ordinal)
298
+ return flag(pull, "branch names #{ordinal}, which has no folder in #{File.basename(tree.dir)}")
299
+ end
300
+
301
+ resolved(pull, ordinal, "branch #{pull[:branch]}")
302
+ end
303
+
304
+ # 2. The diff, and only when it touches exactly one plan.
305
+ #
306
+ # @param pull [Hash]
307
+ # @return [Agentilda::Resync::Prs::Change, nil]
308
+ def from_files(pull)
309
+ touched = Array(pull[:files]).filter_map { |path| ordinal_for_path(path) }.uniq
310
+ case touched.size
311
+ when 1 then resolved(pull, touched.first, "diff touches only #{touched.first}")
312
+ when 0 then nil
313
+ else flag(pull, "diff touches #{touched.join(", ")} and none is obviously primary")
314
+ end
315
+ end
316
+
317
+ # @param path [String] a path from the diff
318
+ # @return [Agentilda::Ordinal, nil]
319
+ def ordinal_for_path(path)
320
+ parts = path.to_s.split("/")
321
+ index = parts.index(Agentilda::PLANS_DIR) or return nil
322
+ folder = parts[index + 1] or return nil
323
+ return nil if parts.length <= index + 2 # a file directly under .plans belongs to no plan
324
+
325
+ ordinal = Ordinal.from_dirname(folder)
326
+ ordinal if ordinal && tree.include?(ordinal)
327
+ end
328
+
329
+ # @param pull [Hash]
330
+ # @param ordinal [Agentilda::Ordinal]
331
+ # @param why [String]
332
+ # @return [Agentilda::Resync::Prs::Change]
333
+ def resolved(pull, ordinal, why)
334
+ Change.new(number: pull[:number], title: pull[:title], ordinal:, reason: why,
335
+ new_title: "#{ordinal.to_prefix} #{pull[:title]}", ambiguous: false, assumed: false, adopted: false)
336
+ end
337
+
338
+ # Nothing resolved, so this is developer work — but that is an assertion
339
+ # about intent, so it is marked as assumed rather than stated as fact.
340
+ #
341
+ # @param pull [Hash]
342
+ # @return [Agentilda::Resync::Prs::Change]
343
+ def no_plan(pull)
344
+ Change.new(number: pull[:number], title: pull[:title], ordinal: nil,
345
+ new_title: "[#{Agentilda::NO_PLAN_PREFIX}] #{pull[:title]}",
346
+ reason: "no plan resolved from the branch or the diff", ambiguous: false, assumed: true, adopted: false)
347
+ end
348
+
349
+ # @param pull [Hash]
350
+ # @param why [String]
351
+ # @return [Agentilda::Resync::Prs::Change]
352
+ def flag(pull, why)
353
+ Change.new(number: pull[:number], title: pull[:title], new_title: nil, ordinal: nil,
354
+ reason: why, ambiguous: true, assumed: false, adopted: false)
355
+ end
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # `agentilda agents` — who the specialists are and what each is offered
5
+ # work from. Reading one in full is {Viewer}'s job: the definition file is
6
+ # already the whole truth, so `agents describe` opens it rather than
7
+ # restating it.
8
+ #
9
+ # {#list} returns a string and prints nothing, the same bargain {Reporter}
10
+ # makes, so the caller decides where the text goes and the table stays
11
+ # pipeable.
12
+ #
13
+ # Everything here is read from the definition files. Nothing about a
14
+ # specialty is restated in Ruby, because a second copy of "who does what" is
15
+ # a copy that drifts from the frontmatter the loop actually routes on.
16
+ class Roster
17
+ # Columns, so the header and the rows cannot drift apart.
18
+ HEADINGS = ["Agent", "Handles", "Advances to", "Model"].freeze
19
+
20
+ # What an agent with no `advances_to` is, in the one word that explains why
21
+ # `run` never offers it work.
22
+ READ_ONLY = "read-only"
23
+
24
+ # @param agents [Agentilda::Agents]
25
+ def initialize(agents: Agents.new)
26
+ @agents = agents
27
+ end
28
+
29
+ # @return [Agentilda::Agents]
30
+ attr_reader :agents
31
+
32
+ # One line per agent, in name order.
33
+ #
34
+ # @return [String] newline-terminated
35
+ def list
36
+ all = agents.all
37
+ return "No agent definitions in #{agents.dir}\n" if all.empty?
38
+
39
+ [header(all), *all.map { |agent| row(agent, all) }].join("\n") + "\n"
40
+ end
41
+
42
+ private
43
+
44
+ # A state as the folder names write it, so what an agent handles reads the
45
+ # same here as it does in `status` and on disk.
46
+ #
47
+ # @param key [Symbol]
48
+ # @return [String]
49
+ def state(key)
50
+ status = STATUS_BY_KEY[key] or return key.to_s
51
+ "#{status.emoji} #{status.label}"
52
+ end
53
+
54
+ # @param agent [Agentilda::Agent]
55
+ # @return [String]
56
+ def handles(agent) = agent.handles.map { |key| state(key) }.join(", ")
57
+
58
+ # @param agent [Agentilda::Agent]
59
+ # @return [String]
60
+ def advances(agent) = agent.advances_to ? state(agent.advances_to) : READ_ONLY
61
+
62
+ # @param all [Array<Agentilda::Agent>]
63
+ # @return [Integer]
64
+ def name_width(all) = @name_width ||= all.map { |a| UI.display_width(a.name) }.max.to_i + 2
65
+
66
+ # @param all [Array<Agentilda::Agent>]
67
+ # @return [Integer]
68
+ def handles_width(all)
69
+ @handles_width ||= (all.map { |a| UI.display_width(handles(a)) } + [UI.display_width(HEADINGS[1])]).max.to_i + 2
70
+ end
71
+
72
+ # @param all [Array<Agentilda::Agent>]
73
+ # @return [Integer]
74
+ def advances_width(all)
75
+ @advances_width ||= (all.map { |a| UI.display_width(advances(a)) } + [UI.display_width(HEADINGS[2])]).max.to_i + 2
76
+ end
77
+
78
+ # Cells arrive fitted and then painted, in that order: escape codes count
79
+ # toward a string's length, so padding a coloured string pads it to a width
80
+ # of which several characters are invisible. See {Reporter#row}.
81
+ #
82
+ # @return [String]
83
+ def line(name, handled, advances, model)
84
+ format(" %s %s %s %s", name, handled, advances, model).rstrip
85
+ end
86
+
87
+ # @param all [Array<Agentilda::Agent>]
88
+ # @return [String]
89
+ def header(all)
90
+ text = line(UI.fit(HEADINGS[0], name_width(all)),
91
+ UI.fit(HEADINGS[1], handles_width(all)),
92
+ UI.fit(HEADINGS[2], advances_width(all)),
93
+ HEADINGS[3])
94
+
95
+ rule = " " + ("─" * (UI.display_width(text) - 2))
96
+
97
+ [UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
98
+ end
99
+
100
+ # @param agent [Agentilda::Agent]
101
+ # @param all [Array<Agentilda::Agent>]
102
+ # @return [String]
103
+ def row(agent, all)
104
+ line(UI.paint(UI.fit(agent.name, name_width(all)), :bright_cyan),
105
+ UI.fit(handles(agent), handles_width(all)),
106
+ UI.paint(UI.fit(advances(agent), advances_width(all)), agent.read_only? ? :bright_black : :green),
107
+ UI.paint(agent.model.to_s, :bright_black))
108
+ end
109
+ end
110
+ end