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.
- checksums.yaml +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "aasm"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
# The state machine for one plan folder.
|
|
7
|
+
#
|
|
8
|
+
# The whole topology is the `aasm` block below, and it is nowhere else. To
|
|
9
|
+
# change what may follow what, edit an event's `from:` list. To add a state,
|
|
10
|
+
# add it to {STATUSES} and give it an event. There is no second table to keep
|
|
11
|
+
# in step: {inbound}, {outbound}, {edge?} and `terminal?` are all read back
|
|
12
|
+
# off these declarations.
|
|
13
|
+
#
|
|
14
|
+
# Two rules hold the rest together:
|
|
15
|
+
#
|
|
16
|
+
# 1. **The guard for entering a state is that state's own invariant.** There
|
|
17
|
+
# is one guard method, {#justified?}, and it reads the destination off the
|
|
18
|
+
# transition in flight — so a destination is named once, in `to:`.
|
|
19
|
+
# 2. **The state lives in the folder name.** There is no column and no
|
|
20
|
+
# database. A successful transition renames the directory, which is why
|
|
21
|
+
# firing an event is a real side effect and asking a question is not.
|
|
22
|
+
class StateMachine
|
|
23
|
+
include AASM
|
|
24
|
+
|
|
25
|
+
# Raised when a transition is refused, carrying the reason rather than
|
|
26
|
+
# AASM's "failed callback #17".
|
|
27
|
+
class Refused < Error; end
|
|
28
|
+
|
|
29
|
+
# The forward path a bare {#promote!} walks. Everything off this spine —
|
|
30
|
+
# blocking, deferring, discarding — has to be named explicitly, which is
|
|
31
|
+
# the entire reason for having a machine rather than a rename.
|
|
32
|
+
#
|
|
33
|
+
# 🔴 rejoins at 🎨 rather than continuing: fixing review comments puts the
|
|
34
|
+
# work back through both halves of building, it does not skip the reviewer
|
|
35
|
+
# and it does not assume the half nobody complained about still holds.
|
|
36
|
+
SPINE = {
|
|
37
|
+
retroactive: :planned,
|
|
38
|
+
new: :researched,
|
|
39
|
+
researched: :planned,
|
|
40
|
+
planned: :building,
|
|
41
|
+
building: :building_ui,
|
|
42
|
+
building_ui: :ready_for_review,
|
|
43
|
+
ready_for_review: :in_review,
|
|
44
|
+
in_review: :approved,
|
|
45
|
+
approved: :deployed,
|
|
46
|
+
rejected: :building_ui,
|
|
47
|
+
rolled_back: :ready_for_review,
|
|
48
|
+
shit: :planned
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# Preference order when several states fit a folder's contents at once.
|
|
52
|
+
# Read top to bottom: the loudest fact wins. A discard outranks everything,
|
|
53
|
+
# a block outranks progress, and `retroactive` is last because it is the
|
|
54
|
+
# absence of documents rather than the presence of any.
|
|
55
|
+
PREFERENCE = %i[
|
|
56
|
+
discarded rolled_back shit deferred blocked product_blocked
|
|
57
|
+
deployed approved rejected in_review ready_for_review building planned
|
|
58
|
+
researched new
|
|
59
|
+
retroactive
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# States whose members a folder's *contents* cannot tell apart. Within a
|
|
63
|
+
# family the current name always wins, because re-deriving it would be a
|
|
64
|
+
# guess dressed up as a correction.
|
|
65
|
+
#
|
|
66
|
+
# ⭕️ and 🅱️ both mean "a human must decide before this moves"; *which*
|
|
67
|
+
# human is recorded nowhere but the emoji.
|
|
68
|
+
#
|
|
69
|
+
# 🟡 🟢 👀 🔴 all look identical on disk — a `plan.md` and some open pull
|
|
70
|
+
# requests. Whether someone is still building, CI is green and a reviewer
|
|
71
|
+
# is wanted, a reviewer is reading it, or a reviewer asked for changes is
|
|
72
|
+
# not written down anywhere a program could read. So `resync` never moves
|
|
73
|
+
# between them; they advance by events alone.
|
|
74
|
+
#
|
|
75
|
+
# Order matters: the first member is the *weakest claim* in the family, and
|
|
76
|
+
# it is where a folder arriving from outside lands. Contents that fit the
|
|
77
|
+
# family justify only its floor, never its ceiling.
|
|
78
|
+
FAMILIES = [
|
|
79
|
+
%i[blocked product_blocked],
|
|
80
|
+
%i[building ready_for_review in_review rejected]
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
# States the agent loop leaves alone: work that is finished (✅ 😎), work
|
|
84
|
+
# that was dropped (❌), and work waiting on a human (⭕️ 🅱️ ☢️).
|
|
85
|
+
# Everything else is fair game for a specialist.
|
|
86
|
+
#
|
|
87
|
+
# ✅ Approved is here deliberately. `hansolo-reviewer` advancing a plan to
|
|
88
|
+
# it is the end of the loop, not a step in it: nothing merges, and no agent
|
|
89
|
+
# handles `approved`. Merging is the one act in this lifecycle that changes
|
|
90
|
+
# a branch everyone else builds on, and an autonomous loop that does it
|
|
91
|
+
# unattended has no way to be wrong quietly.
|
|
92
|
+
#
|
|
93
|
+
# Moving `approved` out of this list is therefore a decision about blast
|
|
94
|
+
# radius rather than about topology. If it ever moves, something has to own
|
|
95
|
+
# `approved -> deployed`, and today nothing does.
|
|
96
|
+
SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
|
|
97
|
+
|
|
98
|
+
# Rerouting a transition below makes the hand-drawn
|
|
99
|
+
# `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
|
|
100
|
+
# the mermaid source in the generated document is derived from this block.
|
|
101
|
+
aasm do
|
|
102
|
+
# The vocabulary is defined once, in {STATUSES}. This machine may not
|
|
103
|
+
# quietly know a different set.
|
|
104
|
+
Agentilda::STATUSES.each { |status| state status.key }
|
|
105
|
+
|
|
106
|
+
event :specify, guard: :justified? do
|
|
107
|
+
transitions from: %i[retroactive blocked product_blocked deferred], to: :new
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
event :research, guard: :justified? do
|
|
111
|
+
transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# `new` stays in this list. The spine routes a plan through research, and
|
|
115
|
+
# that is what the agent loop follows — but a specification somebody has
|
|
116
|
+
# already researched by hand should not have to pretend otherwise to get
|
|
117
|
+
# planned. What research buys is not enforced here; it is enforced by
|
|
118
|
+
# `yoda-writer` handling `researched` and nothing else.
|
|
119
|
+
event :plan, guard: :justified? do
|
|
120
|
+
transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
|
|
121
|
+
to: :planned
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
event :build, guard: :justified? do
|
|
125
|
+
transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked deferred],
|
|
126
|
+
to: :building
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Building hands off rather than finishing. A plan with no interface work
|
|
130
|
+
# still passes through Building UI: `rey-frontend` says there is nothing
|
|
131
|
+
# to build and moves it on, which costs one cheap round and keeps the
|
|
132
|
+
# spine one shape instead of two. `rejected` re-enters here rather than
|
|
133
|
+
# going straight back to review, because a change request reopens the
|
|
134
|
+
# implementation and neither half can assume the other still holds.
|
|
135
|
+
event :build_ui, guard: :justified? do
|
|
136
|
+
transitions from: %i[building rejected], to: :building_ui
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
event :submit, guard: :justified? do
|
|
140
|
+
transitions from: %i[building_ui rolled_back], to: :ready_for_review
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
event :review, guard: :justified? do
|
|
144
|
+
transitions from: %i[ready_for_review], to: :in_review
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
event :approve, guard: :justified? do
|
|
148
|
+
transitions from: %i[in_review retroactive], to: :approved
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
event :request_changes, guard: :justified? do
|
|
152
|
+
transitions from: %i[in_review], to: :rejected
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
event :slop, guard: :justified? do
|
|
156
|
+
transitions from: %i[in_review], to: :shit
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
event :deploy, guard: :justified? do
|
|
160
|
+
transitions from: %i[approved], to: :deployed
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# 🚀 is not the end of the line. A release that breaks in production comes
|
|
164
|
+
# back through ⏪, which needs its own proof on disk: by the time work is
|
|
165
|
+
# deployed every pull request is merged, so 🟢's "at least one open pull
|
|
166
|
+
# request" guard would refuse a direct return, permanently.
|
|
167
|
+
event :rollback, guard: :justified? do
|
|
168
|
+
transitions from: %i[deployed], to: :rolled_back
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
event :block, guard: :justified? do
|
|
172
|
+
transitions from: %i[new planned building], to: :blocked
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
event :block_on_product, guard: :justified? do
|
|
176
|
+
transitions from: %i[new planned building], to: :product_blocked
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
event :defer, guard: :justified? do
|
|
180
|
+
transitions from: %i[new planned building blocked product_blocked], to: :deferred
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Anything, from anywhere, may be dropped for good.
|
|
184
|
+
event :discard, guard: :justified? do
|
|
185
|
+
transitions from: Agentilda::STATUSES.map(&:key) - %i[discarded], to: :discarded
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A transition is not a bookkeeping entry: it is the folder moving.
|
|
189
|
+
after_all_transitions :rename!
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class << self
|
|
193
|
+
# Destination => the event that reaches it. Every event above has exactly
|
|
194
|
+
# one `to:`, which is what lets `--to <state>` name a destination rather
|
|
195
|
+
# than making callers learn the verbs.
|
|
196
|
+
#
|
|
197
|
+
# @return [Hash{Symbol => Symbol}]
|
|
198
|
+
def event_for
|
|
199
|
+
@event_for ||= aasm.events.each_with_object({}) { |event, map|
|
|
200
|
+
event.transitions.each { |t| map[t.to] ||= event.name }
|
|
201
|
+
}.freeze
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Destination => the states that may legitimately reach it.
|
|
205
|
+
#
|
|
206
|
+
# `retroactive` is absent: it is a birth state, produced by
|
|
207
|
+
# `create --after` for work that shipped undocumented.
|
|
208
|
+
#
|
|
209
|
+
# @return [Hash{Symbol => Array<Symbol>}]
|
|
210
|
+
def inbound
|
|
211
|
+
@inbound ||= aasm.events.each_with_object({}) { |event, map|
|
|
212
|
+
event.transitions.each { |t| (map[t.to] ||= []).concat(Array(t.from)) }
|
|
213
|
+
}.transform_values { |froms| froms.uniq.freeze }.freeze
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Source => the states it may reach.
|
|
217
|
+
#
|
|
218
|
+
# @return [Hash{Symbol => Array<Symbol>}]
|
|
219
|
+
def outbound_map
|
|
220
|
+
@outbound_map ||= inbound.each_with_object({}) { |(to, froms), map|
|
|
221
|
+
froms.each { |from| (map[from] ||= []) << to }
|
|
222
|
+
}.transform_values(&:freeze).freeze
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @param key [Symbol]
|
|
226
|
+
# @return [Array<Symbol>]
|
|
227
|
+
def outbound(key) = outbound_map.fetch(key, [])
|
|
228
|
+
|
|
229
|
+
# @param from [Symbol]
|
|
230
|
+
# @param to [Symbol]
|
|
231
|
+
# @return [Boolean] whether the topology permits this edge at all
|
|
232
|
+
def edge?(from, to) = outbound(from).include?(to)
|
|
233
|
+
|
|
234
|
+
# @param key [Symbol]
|
|
235
|
+
# @return [Array<Symbol>] the family +key+ belongs to, empty when it has none
|
|
236
|
+
def family_of(key) = FAMILIES.find { |family| family.include?(key) } || []
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# @param subject [#status, #file?, #read, #pull_requests, #rename_to]
|
|
240
|
+
def initialize(subject)
|
|
241
|
+
@subject = subject
|
|
242
|
+
@key = subject.status.key
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Object] the plan folder this machine speaks for
|
|
246
|
+
attr_reader :subject
|
|
247
|
+
|
|
248
|
+
# @return [Symbol] the state right now
|
|
249
|
+
attr_reader :key
|
|
250
|
+
|
|
251
|
+
# @return [Agentilda::Status]
|
|
252
|
+
def status = STATUS_BY_KEY.fetch(key)
|
|
253
|
+
|
|
254
|
+
# AASM keeps state in an ORM column; we keep it in a directory name. These
|
|
255
|
+
# three methods are the whole of that adaptation.
|
|
256
|
+
#
|
|
257
|
+
# @return [Symbol]
|
|
258
|
+
def aasm_read_state(_name = :default) = @key
|
|
259
|
+
|
|
260
|
+
# @return [Boolean]
|
|
261
|
+
def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name)
|
|
262
|
+
|
|
263
|
+
# @return [Boolean]
|
|
264
|
+
def aasm_write_state_without_persistence(new_state, _name = :default)
|
|
265
|
+
@key = new_state
|
|
266
|
+
true
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# States reachable right now, guards applied.
|
|
270
|
+
#
|
|
271
|
+
# @return [Array<Symbol>]
|
|
272
|
+
def allowed = aasm.states(permitted: true).map(&:name)
|
|
273
|
+
|
|
274
|
+
# @param to [Symbol]
|
|
275
|
+
# @return [Boolean] whether that move is permitted right now
|
|
276
|
+
def may?(to) = allowed.include?(to)
|
|
277
|
+
|
|
278
|
+
# The next state along the spine, whether or not its guard passes.
|
|
279
|
+
#
|
|
280
|
+
# @return [Symbol, nil]
|
|
281
|
+
def spine_next = SPINE[key]
|
|
282
|
+
|
|
283
|
+
# Move the folder. With no argument it walks one step along the {SPINE}.
|
|
284
|
+
#
|
|
285
|
+
# A refusal is information — it means the phase has not actually happened
|
|
286
|
+
# yet — so it arrives as {Refused} carrying the reason, never as `false`.
|
|
287
|
+
#
|
|
288
|
+
# @param to [Symbol] destination, defaulting to the next spine state
|
|
289
|
+
# @return [Agentilda::Status] the state now occupied
|
|
290
|
+
# @raise [Agentilda::StateMachine::Refused]
|
|
291
|
+
def promote!(to = spine_next)
|
|
292
|
+
raise Refused, "#{describe(key)} is terminal — nothing follows it" if to.nil?
|
|
293
|
+
raise Refused, "nothing can reach #{describe(to)}" unless self.class.event_for.key?(to)
|
|
294
|
+
raise Refused, refusal(to) unless may?(to)
|
|
295
|
+
|
|
296
|
+
aasm.fire!(self.class.event_for.fetch(to))
|
|
297
|
+
status
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# The state a folder's contents justify — the answer to "well, what should
|
|
301
|
+
# it be, then?". This is what `resync dirs` renames toward.
|
|
302
|
+
#
|
|
303
|
+
# Invariants are *minimum* requirements, not exact matches: a ⚪️ folder
|
|
304
|
+
# that has since grown a `plan.md` still satisfies ⚪️, and is nonetheless
|
|
305
|
+
# ⭐️ now. So the furthest-justified state wins, and the caller compares it
|
|
306
|
+
# against the current one to decide whether anything should move.
|
|
307
|
+
#
|
|
308
|
+
# The exception is a {FAMILIES family}, whose members contents cannot tell
|
|
309
|
+
# apart. Two rules cover it:
|
|
310
|
+
#
|
|
311
|
+
# - **Already inside one** — the current name wins. Re-deriving it would be
|
|
312
|
+
# a guess dressed up as a correction, and every ⭕️ would become 🅱️.
|
|
313
|
+
# - **Arriving from outside** — the family's *first* member wins, not the
|
|
314
|
+
# furthest. A ✅ folder found with an open pull request is demonstrably
|
|
315
|
+
# back in the PR phase; nothing shows whether a reviewer has seen it, so
|
|
316
|
+
# it lands on 🟡 rather than claiming 🔴.
|
|
317
|
+
#
|
|
318
|
+
# @return [Agentilda::Status, nil] nil when nothing fits at all
|
|
319
|
+
def best_fit
|
|
320
|
+
fitting = STATUSES.select { |s| s.satisfied_by?(subject) }
|
|
321
|
+
return nil if fitting.empty?
|
|
322
|
+
|
|
323
|
+
best = PREFERENCE.filter_map { |k| fitting.find { |s| s.key == k } }.first || fitting.first
|
|
324
|
+
return status if self.class.family_of(key).include?(best.key)
|
|
325
|
+
|
|
326
|
+
family = self.class.family_of(best.key)
|
|
327
|
+
family.empty? ? best : STATUS_BY_KEY.fetch(family.first)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
# The one guard. Entering a state is permitted exactly when that state's
|
|
333
|
+
# invariant holds for this folder's contents.
|
|
334
|
+
#
|
|
335
|
+
# @return [Boolean]
|
|
336
|
+
def justified? = STATUS_BY_KEY.fetch(aasm.to_state).satisfied_by?(subject)
|
|
337
|
+
|
|
338
|
+
# @param to [Symbol]
|
|
339
|
+
# @return [String]
|
|
340
|
+
def refusal(to)
|
|
341
|
+
return "#{describe(key)} cannot become #{describe(to)}" unless self.class.edge?(key, to)
|
|
342
|
+
|
|
343
|
+
STATUS_BY_KEY.fetch(to).violation(subject)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# @param key [Symbol]
|
|
347
|
+
# @return [String]
|
|
348
|
+
def describe(key) = STATUS_BY_KEY.fetch(key).to_s
|
|
349
|
+
|
|
350
|
+
# The folder moves. {Subject} owns the path, so it owns the move.
|
|
351
|
+
#
|
|
352
|
+
# @return [void]
|
|
353
|
+
def rename! = subject.rename_to(STATUS_BY_KEY.fetch(aasm.to_state))
|
|
354
|
+
end
|
|
355
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# A folder's state: what it means, which files it cannot be honest without,
|
|
5
|
+
# and the rule that decides whether the folder may legitimately claim it.
|
|
6
|
+
#
|
|
7
|
+
# `invariant` is asked in both directions. {StateMachine} asks it of a
|
|
8
|
+
# destination before moving there; `resync` and `check` ask it of the current
|
|
9
|
+
# state, to catch folders whose name has drifted from their contents. One
|
|
10
|
+
# definition, so the two can never disagree about what a state means.
|
|
11
|
+
#
|
|
12
|
+
# @!attribute [r] key
|
|
13
|
+
# @return [Symbol] machine-facing name, e.g. `:product_blocked`
|
|
14
|
+
# @!attribute [r] emoji
|
|
15
|
+
# @return [String] as it appears in the folder name
|
|
16
|
+
# @!attribute [r] label
|
|
17
|
+
# @return [String] the human words
|
|
18
|
+
# @!attribute [r] requires
|
|
19
|
+
# @return [Array<String>] files this state cannot be honest without
|
|
20
|
+
# @!attribute [r] note
|
|
21
|
+
# @return [String] one-line meaning, for tables and generated docs
|
|
22
|
+
# @!attribute [r] invariant
|
|
23
|
+
# @return [Proc, nil] `(subject) -> String | nil` — the reason it does not hold
|
|
24
|
+
Status = Data.define(:key, :emoji, :label, :requires, :note, :invariant) do
|
|
25
|
+
# @param subject [#file?, #read, #pull_requests]
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def satisfied_by?(subject) = violation(subject).nil?
|
|
28
|
+
|
|
29
|
+
# @param subject [#file?, #read, #pull_requests]
|
|
30
|
+
# @return [String, nil] why this state is not justified, nil when it is
|
|
31
|
+
def violation(subject)
|
|
32
|
+
missing = requires.reject { |f| subject.file?(f) }
|
|
33
|
+
return "#{label} requires #{missing.map { |f| "`#{f}`" }.join(" and ")}" unless missing.empty?
|
|
34
|
+
|
|
35
|
+
invariant&.call(subject)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Boolean] whether nothing may follow this state
|
|
39
|
+
def terminal? = StateMachine.outbound(key).empty?
|
|
40
|
+
|
|
41
|
+
# @return [String] emoji and words, as the tables print it
|
|
42
|
+
def to_s = "#{emoji} #{label}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "1 pull request", "3 pull requests".
|
|
46
|
+
#
|
|
47
|
+
# @param count [Integer]
|
|
48
|
+
# @return [String]
|
|
49
|
+
def self.pull_request_count(count) = "#{count} pull request#{"s" unless count == 1}"
|
|
50
|
+
|
|
51
|
+
# What proves a specification has been researched rather than merely
|
|
52
|
+
# written: the chapter `leah-researcher` contributes. It is a section of
|
|
53
|
+
# `spec.md` rather than a file of its own because research is not a separate
|
|
54
|
+
# document — it is the first half of the specification, and splitting it
|
|
55
|
+
# would leave `yoda-writer` reading two files to write one.
|
|
56
|
+
RESEARCH_CHAPTER = /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i
|
|
57
|
+
|
|
58
|
+
# Variation selectors make ⚪️ and ⚪ different strings that mean the same
|
|
59
|
+
# thing to a human. Compare with them removed.
|
|
60
|
+
#
|
|
61
|
+
# @param str [String, nil]
|
|
62
|
+
# @return [String]
|
|
63
|
+
def self.fold_emoji(str) = str.to_s.gsub(/[\u{FE0E}\u{FE0F}\u{200D}]/, "")
|
|
64
|
+
|
|
65
|
+
# 🟢 Ready for Review, 👀 In Review and 🔴 Changes Requested are the same
|
|
66
|
+
# thing on disk — the work exists and at least one pull request is still
|
|
67
|
+
# open. Which of the three it is lives in the folder name and nowhere else,
|
|
68
|
+
# so they share one invariant rather than three that could drift apart.
|
|
69
|
+
#
|
|
70
|
+
# @param label [String]
|
|
71
|
+
# @return [Proc]
|
|
72
|
+
def self.open_pull_request(label)
|
|
73
|
+
lambda { |subject|
|
|
74
|
+
return "#{label} requires an open pull request; none are recorded" if subject.pull_requests.empty?
|
|
75
|
+
|
|
76
|
+
"#{label}, but every pull request is already merged" if subject.pull_requests.none?(&:open?)
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A question that is still somebody's to answer, written as its own heading:
|
|
81
|
+
# `## B1`, `## B2`. The number is what `blocked.md` gets referenced by in
|
|
82
|
+
# conversation and in pull requests, so it is also the only part of the file
|
|
83
|
+
# a program can count.
|
|
84
|
+
#
|
|
85
|
+
# Whoever writes `blocked.md` MUST use this notation. A question written any
|
|
86
|
+
# other way is invisible to every part of this tool: the folder never becomes
|
|
87
|
+
# ⭕️, and `unblock` reports a file with nothing in it to drain. {ANSWER_BLOCK}
|
|
88
|
+
# is the other half of the pair.
|
|
89
|
+
OPEN_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**B(\d+)\b/
|
|
90
|
+
|
|
91
|
+
# The answer to the question of the same number: `## A1` settles `## B1`.
|
|
92
|
+
#
|
|
93
|
+
# Answers are an inbox, not a record. `lando-broker` folds each one into the
|
|
94
|
+
# document its question was stopping and deletes both, so a `blocked.md`
|
|
95
|
+
# holding nothing but history must not go on stopping the folder. An `A`
|
|
96
|
+
# never matches {OPEN_BLOCK}, which is what lets the two live in one file.
|
|
97
|
+
ANSWER_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**A(\d+)\b/
|
|
98
|
+
|
|
99
|
+
# The numbers named by every heading in +text+ matching +pattern+.
|
|
100
|
+
#
|
|
101
|
+
# @param text [String, nil]
|
|
102
|
+
# @param pattern [Regexp] {OPEN_BLOCK} or {ANSWER_BLOCK}
|
|
103
|
+
# @return [Array<Integer>] in the order they appear, without duplicates
|
|
104
|
+
def self.block_numbers(text, pattern)
|
|
105
|
+
text.to_s.lines.filter_map { |line| line[pattern, 1]&.to_i }.uniq
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ⭕️ Technical Block and 🅱️ Product Block are the same thing on disk: a
|
|
109
|
+
# `blocked.md` still naming at least one question nobody has answered. Which
|
|
110
|
+
# human is owed the answer lives in the folder name and nowhere else, so the
|
|
111
|
+
# two share one invariant rather than two that could drift apart.
|
|
112
|
+
#
|
|
113
|
+
# This is what lets a block drain in pieces. Answers land in `blocked.md`
|
|
114
|
+
# one at a time, `lando-broker` moves each into `spec.md` or `plan.md` and
|
|
115
|
+
# deletes it here, and the folder leaves ⭕️ on the pass that empties the
|
|
116
|
+
# file, by content rather than by anyone remembering to delete it.
|
|
117
|
+
#
|
|
118
|
+
# @param label [String]
|
|
119
|
+
# @return [Proc]
|
|
120
|
+
def self.open_block(label)
|
|
121
|
+
lambda { |subject|
|
|
122
|
+
"#{label}, but `blocked.md` names no open question" unless subject.read("blocked.md").to_s.match?(OPEN_BLOCK)
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Every state a plan folder may be in, in lifecycle order. That order drives
|
|
127
|
+
# the legend, the generated documentation and the `--to` help text.
|
|
128
|
+
#
|
|
129
|
+
# 🟣 Merged is deliberately absent: it is a pull request's status, not a
|
|
130
|
+
# folder's, and giving it a folder state invites folders that claim a pull
|
|
131
|
+
# request's condition as their own.
|
|
132
|
+
#
|
|
133
|
+
# Adding a state here, or changing an emoji, makes the hand-drawn
|
|
134
|
+
# `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
|
|
135
|
+
# the mermaid source in the generated document moves with this list.
|
|
136
|
+
STATUSES = [
|
|
137
|
+
Status.new(
|
|
138
|
+
key: :new, emoji: "⚪️", label: "New", requires: %w[spec.md],
|
|
139
|
+
note: "a specification exists; it has not been planned yet",
|
|
140
|
+
invariant: nil
|
|
141
|
+
),
|
|
142
|
+
Status.new(
|
|
143
|
+
key: :researched, emoji: "🔎", label: "Researched", requires: %w[spec.md],
|
|
144
|
+
note: "the topic has been researched; `spec.md` carries a `## Research` chapter",
|
|
145
|
+
invariant: lambda { |s|
|
|
146
|
+
body = s.read("spec.md").to_s
|
|
147
|
+
"Researched, but `spec.md` has no `## Research` chapter" unless body.match?(RESEARCH_CHAPTER)
|
|
148
|
+
}
|
|
149
|
+
),
|
|
150
|
+
Status.new(
|
|
151
|
+
key: :planned, emoji: "⭐️", label: "Planned", requires: %w[spec.md plan.md],
|
|
152
|
+
note: "specified and planned; nobody has started building",
|
|
153
|
+
invariant: nil
|
|
154
|
+
),
|
|
155
|
+
# `pull-requests.md` is deliberately absent from `requires` on both of the
|
|
156
|
+
# building states, though it names the file they are about. That file is
|
|
157
|
+
# written by opening a pull request, which happens once, when the last
|
|
158
|
+
# implementer advances the plan to Ready for Review. Requiring the file to
|
|
159
|
+
# *enter* Building would mean nothing could ever justify entering it, and
|
|
160
|
+
# `palpatine-planner` finishing `plan.md` would loop forever between itself
|
|
161
|
+
# and a state it can never actually reach. Ready for Review is where the
|
|
162
|
+
# file becomes a real requirement, once something has had the chance to
|
|
163
|
+
# write it.
|
|
164
|
+
Status.new(
|
|
165
|
+
key: :building, emoji: "🟡", label: "Building", requires: %w[spec.md plan.md],
|
|
166
|
+
note: "the back end is under way: data, domain and the API the interface will call",
|
|
167
|
+
invariant: nil
|
|
168
|
+
),
|
|
169
|
+
# Two building states rather than one, because the order is not a
|
|
170
|
+
# preference: an interface is written against an API that already answers.
|
|
171
|
+
# Splitting them lets each half be built by someone who only has to be good
|
|
172
|
+
# at that half, and gives the second a working system to build on rather
|
|
173
|
+
# than a description of one.
|
|
174
|
+
Status.new(
|
|
175
|
+
key: :building_ui, emoji: "🎨", label: "Building UI", requires: %w[spec.md plan.md],
|
|
176
|
+
note: "the back end holds; the interface is being built against it",
|
|
177
|
+
invariant: nil
|
|
178
|
+
),
|
|
179
|
+
Status.new(
|
|
180
|
+
key: :ready_for_review, emoji: "🟢", label: "Ready for Review", requires: %w[spec.md plan.md pull-requests.md],
|
|
181
|
+
note: "every pull request is green on CI and waiting for a reviewer",
|
|
182
|
+
invariant: open_pull_request("Ready for Review")
|
|
183
|
+
),
|
|
184
|
+
Status.new(
|
|
185
|
+
key: :in_review, emoji: "👀", label: "In Review", requires: %w[spec.md plan.md pull-requests.md],
|
|
186
|
+
note: "a reviewer has picked it up and has not ruled yet",
|
|
187
|
+
invariant: open_pull_request("In Review")
|
|
188
|
+
),
|
|
189
|
+
Status.new(
|
|
190
|
+
key: :rejected, emoji: "🔴", label: "Changes Requested", requires: %w[spec.md plan.md pull-requests.md],
|
|
191
|
+
note: "the review asked for fixes; resubmit once they are made",
|
|
192
|
+
invariant: open_pull_request("Changes Requested")
|
|
193
|
+
),
|
|
194
|
+
Status.new(
|
|
195
|
+
key: :approved, emoji: "✅", label: "Approved & Merged", requires: %w[pull-requests.md],
|
|
196
|
+
note: "reviewed, approved, and every pull request merged",
|
|
197
|
+
invariant: lambda { |s|
|
|
198
|
+
return "Approved & Merged, but no pull requests are recorded" if s.pull_requests.empty?
|
|
199
|
+
|
|
200
|
+
open = s.pull_requests.count(&:open?)
|
|
201
|
+
"Approved & Merged, but #{Agentilda.pull_request_count(open)} still open" if open.positive?
|
|
202
|
+
}
|
|
203
|
+
),
|
|
204
|
+
Status.new(
|
|
205
|
+
key: :deployed, emoji: "😎", label: "Deployed", requires: %w[deployed.md],
|
|
206
|
+
note: "live in production; `deployed.md` names the release, date and SHA",
|
|
207
|
+
invariant: nil
|
|
208
|
+
),
|
|
209
|
+
Status.new(
|
|
210
|
+
key: :rolled_back, emoji: "😱", label: "Rolled Back", requires: %w[rollback.md],
|
|
211
|
+
note: "it shipped and was pulled; `rollback.md` names what broke",
|
|
212
|
+
invariant: nil
|
|
213
|
+
),
|
|
214
|
+
Status.new(
|
|
215
|
+
key: :shit, emoji: "💩", label: "Scrapped by Review", requires: %w[rewrite.md],
|
|
216
|
+
note: "the review scrapped the work; the plan survives, the pull requests do not",
|
|
217
|
+
invariant: nil
|
|
218
|
+
),
|
|
219
|
+
Status.new(
|
|
220
|
+
key: :blocked, emoji: "⭕️", label: "Technical Block", requires: %w[blocked.md],
|
|
221
|
+
note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide",
|
|
222
|
+
invariant: open_block("Technical Block")
|
|
223
|
+
),
|
|
224
|
+
Status.new(
|
|
225
|
+
key: :product_blocked, emoji: "🅱️", label: "Product Block", requires: %w[blocked.md],
|
|
226
|
+
note: "cannot proceed; `blocked.md` names what a product manager must decide",
|
|
227
|
+
invariant: open_block("Product Block")
|
|
228
|
+
),
|
|
229
|
+
Status.new(
|
|
230
|
+
key: :deferred, emoji: "☢️", label: "Deferred", requires: %w[delayed.md],
|
|
231
|
+
note: "could proceed and chose not to yet; `delayed.md` must name the trigger",
|
|
232
|
+
invariant: lambda { |s|
|
|
233
|
+
body = s.read("delayed.md").to_s
|
|
234
|
+
"Deferred, but `delayed.md` names no trigger" unless body.match?(/trigger|revisit|when\b|until\b|once\b/i)
|
|
235
|
+
}
|
|
236
|
+
),
|
|
237
|
+
Status.new(
|
|
238
|
+
key: :retroactive, emoji: "🕰️", label: "Retroactive", requires: [],
|
|
239
|
+
note: "the feature is live, but has neither a specification nor a plan",
|
|
240
|
+
invariant: lambda { |s|
|
|
241
|
+
next "Retroactive, but a `spec.md` already exists — it has been documented" if s.file?("spec.md")
|
|
242
|
+
|
|
243
|
+
"Retroactive, but no pull requests are recorded" if s.pull_requests.empty?
|
|
244
|
+
}
|
|
245
|
+
),
|
|
246
|
+
Status.new(
|
|
247
|
+
key: :discarded, emoji: "❌", label: "Discarded", requires: %w[discarded.md],
|
|
248
|
+
note: "dropped for good; `discarded.md` says why. A terminal state",
|
|
249
|
+
invariant: nil
|
|
250
|
+
)
|
|
251
|
+
].freeze
|
|
252
|
+
|
|
253
|
+
# Emoji => status, folded so ⚪ and ⚪️ both resolve.
|
|
254
|
+
STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }.freeze
|
|
255
|
+
|
|
256
|
+
# Symbol => status.
|
|
257
|
+
STATUS_BY_KEY = STATUSES.to_h { |s| [s.key, s] }.freeze
|
|
258
|
+
|
|
259
|
+
# @param emoji [String, nil]
|
|
260
|
+
# @return [Agentilda::Status, nil]
|
|
261
|
+
def self.status_for_emoji(emoji) = STATUS_BY_EMOJI[fold_emoji(emoji)]
|
|
262
|
+
|
|
263
|
+
# Resolve a state from the only two names it has: its key, and its emoji.
|
|
264
|
+
#
|
|
265
|
+
# There is deliberately no synonym table. A state that also answers to
|
|
266
|
+
# "star", "green" and "completed" has four names to keep in step with the
|
|
267
|
+
# folder, the generated docs and the agent frontmatter — and the synonym
|
|
268
|
+
# table that used to live here went stale pointing six words at a state that
|
|
269
|
+
# no longer existed.
|
|
270
|
+
#
|
|
271
|
+
# @param text [String, Symbol, Agentilda::Status, nil]
|
|
272
|
+
# @return [Agentilda::Status, nil]
|
|
273
|
+
def self.status(text)
|
|
274
|
+
return nil if text.nil?
|
|
275
|
+
return text if text.is_a?(Status)
|
|
276
|
+
|
|
277
|
+
key = text.to_s.strip
|
|
278
|
+
STATUS_BY_KEY[key.to_sym] || status_for_emoji(key)
|
|
279
|
+
end
|
|
280
|
+
end
|