agentilda 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. metadata +349 -0
@@ -0,0 +1,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