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.
- 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
|
@@ -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
|
data/lib/agentilda/linear/api.rb
CHANGED
|
@@ -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
|
#
|