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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -1,383 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "aasm"
4
-
5
- require "agentilda/status"
6
-
7
- module Agentilda
8
- # The state machine for one plan folder.
9
- #
10
- # The whole topology is the `aasm` block below, and it is nowhere else. To
11
- # change what may follow what, edit an event's `from:` list. To add a state,
12
- # add it to {STATUSES} and give it an event. There is no second table to keep
13
- # in step: {inbound}, {outbound}, {edge?} and `terminal?` are all read back
14
- # off these declarations.
15
- #
16
- # Two rules hold the rest together:
17
- #
18
- # 1. **The guard for entering a state is that state's own invariant.** There
19
- # is one guard method, {#justified?}, and it reads the destination off the
20
- # transition in flight — so a destination is named once, in `to:`.
21
- # 2. **The state lives in the folder name.** There is no column and no
22
- # database. A successful transition renames the directory, which is why
23
- # firing an event is a real side effect and asking a question is not.
24
- class StateMachine
25
- include AASM
26
-
27
- # Raised when a transition is refused, carrying the reason rather than
28
- # AASM's "failed callback #17".
29
- class Refused < Error; end
30
-
31
- # The forward path a bare {#promote!} walks. Everything off this spine —
32
- # blocking, deferring, discarding — has to be named explicitly, which is
33
- # the entire reason for having a machine rather than a rename.
34
- #
35
- # 🔴 rejoins at 🎨 rather than continuing: fixing review comments puts the
36
- # work back through both halves of building, it does not skip the reviewer
37
- # and it does not assume the half nobody complained about still holds.
38
- SPINE = {
39
- retroactive: :planned,
40
- new: :researched,
41
- researched: :ready_for_planning,
42
- ready_for_planning: :planned,
43
- planned: :building,
44
- building: :building_ui,
45
- building_ui: :ready_for_review,
46
- ready_for_review: :in_review,
47
- in_review: :approved,
48
- approved: :deployed,
49
- rejected: :building_ui,
50
- rolled_back: :ready_for_review,
51
- shit: :planned
52
- }.freeze
53
-
54
- # Preference order when several states fit a folder's contents at once.
55
- # Read top to bottom: the loudest fact wins. A discard outranks everything,
56
- # a block outranks progress, and `retroactive` is last because it is the
57
- # absence of documents rather than the presence of any.
58
- PREFERENCE = %i[
59
- discarded rolled_back shit deferred blocked product_blocked
60
- deployed approved rejected in_review ready_for_review building_ui building planned ready_for_planning
61
- researched new
62
- retroactive
63
- ].freeze
64
-
65
- # States whose members a folder's *contents* cannot tell apart. Within a
66
- # family the current name always wins, because re-deriving it would be a
67
- # guess dressed up as a correction.
68
- #
69
- # ⭕️ and 🅱️ both mean "a human must decide before this moves"; *which*
70
- # human is recorded nowhere but the emoji.
71
- #
72
- # ⭐️ 🟡 🎨 look identical on disk: a `spec.md` and a `plan.md` with work
73
- # units in it. Whether nobody has started, the back end is under way, or
74
- # the interface is, is recorded only by the agent that renamed the folder
75
- # when it started. 🟢 👀 🔴 add an open pull request and are just as
76
- # mute about whether a reviewer has seen it. So `resync` never moves
77
- # between any of them; they advance by events alone.
78
- #
79
- # Order matters: the members run weakest claim first, and a folder
80
- # arriving from outside lands on the weakest member its contents fit.
81
- # Contents that fit the family justify only its floor, never its ceiling.
82
- FAMILIES = [
83
- %i[blocked product_blocked],
84
- %i[planned building building_ui ready_for_review in_review rejected]
85
- ].freeze
86
-
87
- # States the agent loop leaves alone: work that is finished (✅ 😎), work
88
- # that was dropped (❌), and work waiting on a human (⭕️ 🅱️ ☢️).
89
- # Everything else is fair game for a specialist.
90
- #
91
- # ✅ Approved is here deliberately. `hansolo-reviewer` advancing a plan to
92
- # it is the end of the loop, not a step in it: nothing merges, and no agent
93
- # handles `approved`. Merging is the one act in this lifecycle that changes
94
- # a branch everyone else builds on, and an autonomous loop that does it
95
- # unattended has no way to be wrong quietly.
96
- #
97
- # Moving `approved` out of this list is therefore a decision about blast
98
- # radius rather than about topology. If it ever moves, something has to own
99
- # `approved -> deployed`, and today nothing does.
100
- SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
101
-
102
- # Every state an agent that may sign `Blocked` works in. A block the
103
- # machine refuses is reported as a failed attempt and parks nothing, so a
104
- # state missing here turns an honest "I need a decision" into a failure.
105
- # The agents spec checks every prompt that describes blocking against it.
106
- #
107
- # Review is absent: a reviewer rules, it does not ask.
108
- BLOCKABLE = %i[new researched retroactive ready_for_planning planned building building_ui rejected].freeze
109
-
110
- # Rerouting a transition below makes the hand-drawn
111
- # `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
112
- # the mermaid source in the generated document is derived from this block.
113
- aasm do
114
- # The vocabulary is defined once, in {STATUSES}. This machine may not
115
- # quietly know a different set.
116
- Agentilda::STATUSES.each { |status| state status.key }
117
-
118
- event :specify, guard: :justified? do
119
- transitions from: %i[retroactive blocked product_blocked deferred], to: :new
120
- end
121
-
122
- event :research, guard: :justified? do
123
- transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
124
- end
125
-
126
- # The specification is done and the planner has not started. Entered
127
- # by the harness when `yoda-writer` completes; `resync` reaches it on
128
- # a blank `plan.md`. Without it ⭐️ Planned required a file only the
129
- # agent handling ⭐️ Planned writes, and nothing could enter it.
130
- event :ready_to_plan, guard: :justified? do
131
- transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
132
- to: :ready_for_planning
133
- end
134
-
135
- # `new` stays in this list. The spine routes a plan through research, and
136
- # that is what the agent loop follows — but a specification somebody has
137
- # already researched by hand should not have to pretend otherwise to get
138
- # planned. What research buys is not enforced here; it is enforced by
139
- # `yoda-writer` handling `researched` and nothing else.
140
- event :plan, guard: :justified? do
141
- transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked deferred],
142
- to: :planned
143
- end
144
-
145
- event :build, guard: :justified? do
146
- transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked deferred],
147
- to: :building
148
- end
149
-
150
- # Building hands off rather than finishing. A plan with no interface work
151
- # still passes through Building UI: `rey-frontend` says there is nothing
152
- # to build and moves it on, which costs one cheap round and keeps the
153
- # spine one shape instead of two. `rejected` re-enters here rather than
154
- # going straight back to review, because a change request reopens the
155
- # implementation and neither half can assume the other still holds.
156
- event :build_ui, guard: :justified? do
157
- transitions from: %i[building rejected], to: :building_ui
158
- end
159
-
160
- # The pair lands one pull request, and whichever half finishes last
161
- # carries the plan to review from wherever the folder stands.
162
- event :submit, guard: :justified? do
163
- transitions from: %i[building building_ui rejected rolled_back], to: :ready_for_review
164
- end
165
-
166
- event :review, guard: :justified? do
167
- transitions from: %i[ready_for_review], to: :in_review
168
- end
169
-
170
- event :approve, guard: :justified? do
171
- transitions from: %i[in_review retroactive], to: :approved
172
- end
173
-
174
- event :request_changes, guard: :justified? do
175
- transitions from: %i[in_review], to: :rejected
176
- end
177
-
178
- event :slop, guard: :justified? do
179
- transitions from: %i[in_review], to: :shit
180
- end
181
-
182
- event :deploy, guard: :justified? do
183
- transitions from: %i[approved], to: :deployed
184
- end
185
-
186
- # 🚀 is not the end of the line. A release that breaks in production comes
187
- # back through ⏪, which needs its own proof on disk: by the time work is
188
- # deployed every pull request is merged, so 🟢's "at least one open pull
189
- # request" guard would refuse a direct return, permanently.
190
- event :rollback, guard: :justified? do
191
- transitions from: %i[deployed], to: :rolled_back
192
- end
193
-
194
- event :block, guard: :justified? do
195
- transitions from: BLOCKABLE, to: :blocked
196
- end
197
-
198
- event :block_on_product, guard: :justified? do
199
- transitions from: BLOCKABLE, to: :product_blocked
200
- end
201
-
202
- event :defer, guard: :justified? do
203
- transitions from: BLOCKABLE + %i[blocked product_blocked], to: :deferred
204
- end
205
-
206
- # Anything, from anywhere, may be dropped for good.
207
- event :discard, guard: :justified? do
208
- transitions from: Agentilda::STATUSES.map(&:key) - %i[discarded], to: :discarded
209
- end
210
-
211
- # A transition is not a bookkeeping entry: it is the folder moving.
212
- after_all_transitions :rename!
213
- end
214
-
215
- class << self
216
- # Destination => the event that reaches it. Every event above has exactly
217
- # one `to:`, which is what lets `--to <state>` name a destination rather
218
- # than making callers learn the verbs.
219
- #
220
- # @return [Hash{Symbol => Symbol}]
221
- def event_for
222
- @event_for ||= aasm.events.each_with_object({}) { |event, map|
223
- event.transitions.each { |t| map[t.to] ||= event.name }
224
- }.freeze
225
- end
226
-
227
- # Destination => the states that may legitimately reach it.
228
- #
229
- # `retroactive` is absent: it is a birth state, produced by
230
- # `create --after` for work that shipped undocumented.
231
- #
232
- # @return [Hash{Symbol => Array<Symbol>}]
233
- def inbound
234
- @inbound ||= aasm.events.each_with_object({}) { |event, map|
235
- event.transitions.each { |t| (map[t.to] ||= []).concat(Array(t.from)) }
236
- }.transform_values { |froms| froms.uniq.freeze }.freeze
237
- end
238
-
239
- # Source => the states it may reach.
240
- #
241
- # @return [Hash{Symbol => Array<Symbol>}]
242
- def outbound_map
243
- @outbound_map ||= inbound.each_with_object({}) { |(to, froms), map|
244
- froms.each { |from| (map[from] ||= []) << to }
245
- }.transform_values(&:freeze).freeze
246
- end
247
-
248
- # @param key [Symbol]
249
- # @return [Array<Symbol>]
250
- def outbound(key) = outbound_map.fetch(key, [])
251
-
252
- # @param from [Symbol]
253
- # @param to [Symbol]
254
- # @return [Boolean] whether the topology permits this edge at all
255
- def edge?(from, to) = outbound(from).include?(to)
256
-
257
- # @param key [Symbol]
258
- # @return [Array<Symbol>] the family +key+ belongs to, empty when it has none
259
- def family_of(key) = FAMILIES.find { |family| family.include?(key) } || []
260
- end
261
-
262
- # @param subject [#status, #file?, #read, #pull_requests, #rename_to]
263
- def initialize(subject)
264
- @subject = subject
265
- @key = subject.status.key
266
- end
267
-
268
- # @return [Object] the plan folder this machine speaks for
269
- attr_reader :subject
270
-
271
- # @return [Symbol] the state right now
272
- attr_reader :key
273
-
274
- # @return [Agentilda::Status]
275
- def status = STATUS_BY_KEY.fetch(key)
276
-
277
- # AASM keeps state in an ORM column; we keep it in a directory name. These
278
- # three methods are the whole of that adaptation.
279
- #
280
- # @return [Symbol]
281
- def aasm_read_state(_name = :default) = @key
282
-
283
- # @return [Boolean]
284
- def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name)
285
-
286
- # @return [Boolean]
287
- def aasm_write_state_without_persistence(new_state, _name = :default)
288
- @key = new_state
289
- true
290
- end
291
-
292
- # States reachable right now, guards applied.
293
- #
294
- # @return [Array<Symbol>]
295
- def allowed = aasm.states(permitted: true).map(&:name)
296
-
297
- # @param to [Symbol]
298
- # @return [Boolean] whether that move is permitted right now
299
- def may?(to) = allowed.include?(to)
300
-
301
- # The next state along the spine, whether or not its guard passes.
302
- #
303
- # @return [Symbol, nil]
304
- def spine_next = SPINE[key]
305
-
306
- # Move the folder. With no argument it walks one step along the {SPINE}.
307
- #
308
- # A refusal is information — it means the phase has not actually happened
309
- # yet — so it arrives as {Refused} carrying the reason, never as `false`.
310
- #
311
- # @param to [Symbol] destination, defaulting to the next spine state
312
- # @return [Agentilda::Status] the state now occupied
313
- # @raise [Agentilda::StateMachine::Refused]
314
- def promote!(to = spine_next)
315
- raise Refused, "#{describe(key)} is terminal — nothing follows it" if to.nil?
316
- raise Refused, "nothing can reach #{describe(to)}" unless self.class.event_for.key?(to)
317
- raise Refused, refusal(to) unless may?(to)
318
-
319
- aasm.fire!(self.class.event_for.fetch(to))
320
- status
321
- end
322
-
323
- # The state a folder's contents justify — the answer to "well, what should
324
- # it be, then?". This is what `resync dirs` renames toward.
325
- #
326
- # Invariants are *minimum* requirements, not exact matches: a ⚪️ folder
327
- # that has since grown a `plan.md` still satisfies ⚪️, and is nonetheless
328
- # ⭐️ now. So the furthest-justified state wins, and the caller compares it
329
- # against the current one to decide whether anything should move.
330
- #
331
- # The exception is a {FAMILIES family}, whose members contents cannot tell
332
- # apart. Two rules cover it:
333
- #
334
- # - **Already inside one** — the current name wins. Re-deriving it would be
335
- # a guess dressed up as a correction, and every ⭕️ would become 🅱️.
336
- # - **Arriving from outside** — the *weakest* member the contents fit
337
- # wins, not the furthest. A 📋 folder whose `plan.md` grew work units
338
- # lands on ⭐️, not 🟡, because nobody has started. A ✅ folder found
339
- # with an open pull request is demonstrably back in the PR phase, and
340
- # ⭐️ refuses an open pull request, so it lands on 🟡 rather than
341
- # claiming 🔴.
342
- #
343
- # @return [Agentilda::Status, nil] nil when nothing fits at all
344
- def best_fit
345
- fitting = STATUSES.select { |s| s.satisfied_by?(subject) }
346
- return nil if fitting.empty?
347
-
348
- best = PREFERENCE.filter_map { |k| fitting.find { |s| s.key == k } }.first || fitting.first
349
- return status if self.class.family_of(key).include?(best.key)
350
-
351
- family = self.class.family_of(best.key)
352
- return best if family.empty?
353
-
354
- floor = family.find { |k| fitting.any? { |s| s.key == k } }
355
- STATUS_BY_KEY.fetch(floor)
356
- end
357
-
358
- private
359
-
360
- # The one guard. Entering a state is permitted exactly when that state's
361
- # invariant holds for this folder's contents.
362
- #
363
- # @return [Boolean]
364
- def justified? = STATUS_BY_KEY.fetch(aasm.to_state).satisfied_by?(subject)
365
-
366
- # @param to [Symbol]
367
- # @return [String]
368
- def refusal(to)
369
- return "#{describe(key)} cannot become #{describe(to)}" unless self.class.edge?(key, to)
370
-
371
- STATUS_BY_KEY.fetch(to).violation(subject)
372
- end
373
-
374
- # @param key [Symbol]
375
- # @return [String]
376
- def describe(key) = STATUS_BY_KEY.fetch(key).to_s
377
-
378
- # The folder moves. {Subject} owns the path, so it owns the move.
379
- #
380
- # @return [void]
381
- def rename! = subject.rename_to(STATUS_BY_KEY.fetch(aasm.to_state))
382
- end
383
- end