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