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
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Plans
5
+ # One row of a plan's `pull-requests.md`.
6
+ #
7
+ # @!attribute [r] number
8
+ # @return [String, nil] as written, without the `#`
9
+ # @!attribute [r] title
10
+ # @return [String]
11
+ # @!attribute [r] url
12
+ # @return [String, nil]
13
+ # @!attribute [r] state
14
+ # @return [String] normalised words plus emoji, e.g. "Open 🟡"
15
+ PullRequest = Data.define(:number, :title, :url, :state) do
16
+ # Still awaiting a decision. A closed-unmerged pull request is finished
17
+ # business, so it is neither open nor merged.
18
+ #
19
+ # @return [Boolean]
20
+ def open? = state.match?(/\b(?:open|wip|draft)\b/i)
21
+
22
+ # @return [Boolean]
23
+ def merged? = state.match?(/\bmerged\b/i) && !state.match?(/\bunmerged\b/i)
24
+
25
+ # @return [String] "#92 — Send mail through Resend"
26
+ def label = number ? "##{number} — #{title}" : title
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Plans
5
+ # Extracts the pull request roll-up from a plan folder.
6
+ class PullRequests
7
+ # A markdown link whose text may contain escaped brackets, because every
8
+ # title this tool writes now opens with `\[NNN.MM\]`. Matching `[^\]]+`
9
+ # stops at the escaped `]` and loses the URL with it.
10
+ LINK = /\[((?:\\.|[^\]\\])+)\]\((https?:[^)\s]+)\)/
11
+
12
+ # Canonical wording for the states we recognise, most specific first.
13
+ STATES = [
14
+ [/\bclosed\b|\bunmerged\b|\babandoned\b/i, "Closed 🔴"],
15
+ [/\bmerged\b/i, "Merged 🟣"],
16
+ [/\bdraft\b|\bwip\b/i, "WIP 🟡"],
17
+ [/\bopen\b/i, "Open 🟡"]
18
+ ].freeze
19
+
20
+ # Filenames that may hold the table.
21
+ CANDIDATES = %w[pull-requests.md pull_requests.md prs.md].freeze
22
+
23
+ # The file this class writes, and the first one it looks for.
24
+ FILENAME = CANDIDATES.first
25
+
26
+ # Render `pull-requests.md` for a set of pull requests fetched from GitHub.
27
+ #
28
+ # The table is what {#parse} reads back and what the state machine judges
29
+ # a folder by. The descriptions below it are for a reader — human or
30
+ # agent — synthesizing a specification from work that already shipped:
31
+ # the table says *which* pull requests, the bodies say *what they did*,
32
+ # and a retroactive `spec.md` cannot be written from numbers alone.
33
+ #
34
+ # Prose after the table is ignored by the parser, so the two can coexist
35
+ # in one file rather than needing a scratch file the folder rules forbid.
36
+ #
37
+ # @param prs [Array<Hash>] `{number:, title:, url:, state:, body:}`
38
+ # @return [String]
39
+ def self.render(prs)
40
+ rows = prs.map { |pr|
41
+ "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |"
42
+ }
43
+
44
+ <<~MARKDOWN
45
+ # Pull Requests
46
+
47
+ | Pull Request Number | Pull Request Name | Status |
48
+ | ------------------: | :---------------- | -----: |
49
+ #{rows.join("\n")}
50
+
51
+ ## What these pull requests did
52
+
53
+ #{prs.map { |pr| describe(pr) }.join("\n")}
54
+ MARKDOWN
55
+ end
56
+
57
+ # The table alone, for splicing into a file that already says other
58
+ # things: the ledger blocks luke and rey write before a pull request
59
+ # exists. Rewriting the whole file deleted their signatures.
60
+ #
61
+ # @param prs [Array<Hash>]
62
+ # @return [String] header, rule and rows, newline-terminated
63
+ def self.table(prs)
64
+ rows = prs.map { |pr| "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |" }
65
+ ["| Pull Request Number | Pull Request Name | Status |",
66
+ "| ------------------: | :---------------- | -----: |", *rows].join("\n") + "\n"
67
+ end
68
+
69
+ # Write the table into +path+ without touching anything else there.
70
+ #
71
+ # @param path [String]
72
+ # @param prs [Array<Hash>]
73
+ # @return [void]
74
+ def self.upsert(path, prs)
75
+ return File.write(path, render(prs)) unless File.file?(path)
76
+
77
+ lines = File.read(path, encoding: "UTF-8").lines
78
+ start = lines.index { |l| l.strip.start_with?("|") && lines[lines.index(l) + 1].to_s.strip.start_with?("|") }
79
+ if start
80
+ stop = start
81
+ stop += 1 while lines[stop + 1]&.strip&.start_with?("|")
82
+ lines[start..stop] = [table(prs)]
83
+ else
84
+ at = lines.index { |l| l.start_with?("# ") }
85
+ insertion = ["\n", table(prs)]
86
+ at ? lines.insert(at + 1, *insertion) : lines.unshift("# Pull Requests\n", *insertion, "\n")
87
+ end
88
+ File.write(path, lines.join)
89
+ end
90
+
91
+ # @param pr [Hash]
92
+ # @return [String]
93
+ def self.describe(pr)
94
+ body = pr[:body].to_s.strip
95
+ body = "_No description was written on the pull request._" if body.empty?
96
+
97
+ <<~MARKDOWN
98
+ ### ##{pr[:number]} — #{pr[:title]}
99
+
100
+ #{pr[:url]}
101
+
102
+ #{body}
103
+ MARKDOWN
104
+ end
105
+
106
+ # Three characters have to survive a round trip through a table cell.
107
+ #
108
+ # A `|` ends the cell, and a title containing one is not unusual —
109
+ # "fix: guard against a || b".
110
+ #
111
+ # `[` and `]` are newer and cost more. Every title this tool writes now
112
+ # opens with a plan number, so the row reads `[[013.00] Ship it](url)` —
113
+ # and a markdown link whose text starts with `[` does not parse. The
114
+ # title came back with the URL glued to it and `url` came back nil, which
115
+ # is not a cosmetic loss: it is every pull request link on the page, and
116
+ # the attachment on every Linear issue.
117
+ #
118
+ # @param text [String]
119
+ # @return [String]
120
+ def self.escape(text)
121
+ text.to_s.gsub(/([\[\]|])/) { "\\#{$1}" }.gsub(/\s+/, " ").strip
122
+ end
123
+
124
+ # @param dir [String] absolute path to the plan folder
125
+ def initialize(dir:)
126
+ @dir = dir
127
+ end
128
+
129
+ # @return [Array<Agentilda::Plans::PullRequest>] possibly empty
130
+ def all = @all ||= parse
131
+
132
+ private
133
+
134
+ # @return [String]
135
+ attr_reader :dir
136
+
137
+ # @return [Array<Agentilda::Plans::PullRequest>]
138
+ def parse
139
+ path = CANDIDATES.map { |f| File.join(dir, f) }.find { |p| File.file?(p) }
140
+ return [] unless path
141
+
142
+ text = File.read(path, encoding: "UTF-8")
143
+ table = pick_table(Markdown.tables(text))
144
+ return scrape(text) unless table
145
+
146
+ rows = table[:rows].filter_map { |cells| row_to_pr(cells, columns(table[:header])) }
147
+ rows.empty? ? scrape(text) : rows
148
+ end
149
+
150
+ # @param tables [Array<Hash>]
151
+ # @return [Hash, nil]
152
+ def pick_table(tables)
153
+ tables.find { |t|
154
+ t[:header].any? { |h| h.match?(/pull\s*request|\bpr\b|\A#\z|\Anumber\z/i) } &&
155
+ t[:rows].any? { |r| r.any? { |c| c.match?(%r{/(?:pull|merge_requests)/\d+}) } }
156
+ } || tables.find { |t| t[:rows].any? { |r| r.any? { |c| c.match?(%r{/pull/\d+}) } } }
157
+ end
158
+
159
+ # @param header [Array<String>]
160
+ # @return [Hash{Symbol => Integer, nil}]
161
+ def columns(header)
162
+ {
163
+ number: header.index { |h| h.match?(/number|\A#\z|\Apr\z|\Apr\s*#/i) },
164
+ title: header.index { |h| h.match?(/name|title|summary|description|scope/i) },
165
+ state: header.index { |h| h.match?(/status|state/i) }
166
+ }
167
+ end
168
+
169
+ # @param cells [Array<String>]
170
+ # @param idx [Hash{Symbol => Integer, nil}]
171
+ # @return [Agentilda::Plans::PullRequest, nil]
172
+ def row_to_pr(cells, idx)
173
+ title_cell = cells[idx[:title] || 1].to_s
174
+
175
+ # The title comes from the title column and the URL from wherever it is.
176
+ # Scanning every cell for the first link and taking both from it reads
177
+ # `| [#24](...) | Split verified into signed_off |` as an issue called
178
+ # "#24", because the number column is a link too and comes first.
179
+ titled = title_cell.match(LINK)
180
+ link = titled || cells.filter_map { |c| c.match(LINK) }.first
181
+
182
+ url = link && link[2]
183
+ title = titled ? titled[1] : unformat(title_cell)
184
+ title = title.gsub(/\\([\[\]|\\])/, '\1') # undo the escaping {.render} applies
185
+ number = cells[idx[:number] || 0].to_s[/\d+/] || url&.[](%r{/(?:pull|merge_requests)/(\d+)}, 1)
186
+ return nil if title.empty? && url.nil?
187
+
188
+ PullRequest.new(number:,
189
+ url:,
190
+ title: title.empty? ? "Pull request" : title,
191
+ state: normalize(cells[idx[:state] || -1].to_s))
192
+ end
193
+
194
+ # When there is no parsable table, scrape bare links so a folder that
195
+ # plainly has pull requests does not report none.
196
+ #
197
+ # @param text [String]
198
+ # @return [Array<Agentilda::Plans::PullRequest>]
199
+ def scrape(text)
200
+ text.scan(%r{https?://\S+?/(?:pull|merge_requests)/(\d+)}).flatten.uniq.map do |num|
201
+ PullRequest.new(number: num,
202
+ title: "Pull request ##{num}",
203
+ state: "Unknown",
204
+ url: text[%r{https?://\S+?/(?:pull|merge_requests)/#{num}\b}])
205
+ end
206
+ end
207
+
208
+ # Strip the markdown a title was wrapped in without eating the title.
209
+ #
210
+ # An underscore between letters is not emphasis, it is an identifier —
211
+ # `signed_off`, `as_of`, `pull_requests`. Removing every underscore turned
212
+ # "Split verified into signed_off" into "signedoff" on the way to Linear.
213
+ #
214
+ # @param cell [String]
215
+ # @return [String]
216
+ def unformat(cell)
217
+ cell.to_s.gsub(/[*`]/, "").sub(/\A_+/, "").sub(/_+\z/, "").strip
218
+ end
219
+
220
+ # @param cell [String]
221
+ # @return [String]
222
+ def normalize(cell)
223
+ STATES.each { |pattern, label| return label if cell.match?(pattern) }
224
+ stripped = cell.gsub(/[*_`]/, "").strip
225
+ stripped.empty? ? "Unknown" : stripped
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aasm"
4
+
5
+ module Agentilda
6
+ module Plans
7
+ # The state machine for one plan folder.
8
+ #
9
+ # The whole topology is the `aasm` block below, and it is nowhere else. To
10
+ # change what may follow what, edit an event's `from:` list. To add a state,
11
+ # add it to {STATUSES} and give it an event. There is no second table to keep
12
+ # in step: {inbound}, {outbound}, {edge?} and `terminal?` are all read back
13
+ # off these declarations.
14
+ #
15
+ # Two rules hold the rest together:
16
+ #
17
+ # 1. **The guard for entering a state is that state's own invariant.** There
18
+ # is one guard method, {#justified?}, and it reads the destination off the
19
+ # transition in flight — so a destination is named once, in `to:`.
20
+ # 2. **The state lives in the folder name.** There is no column and no
21
+ # database. A successful transition renames the directory, which is why
22
+ # firing an event is a real side effect and asking a question is not.
23
+ class StateMachine
24
+ include AASM
25
+
26
+ # Raised when a transition is refused, carrying the reason rather than
27
+ # AASM's "failed callback #17".
28
+ class Refused < Error; end
29
+
30
+ # The forward path a bare {#promote!} walks. Everything off this spine —
31
+ # blocking, deferring, discarding — has to be named explicitly, which is
32
+ # the entire reason for having a machine rather than a rename.
33
+ #
34
+ # 🔴 rejoins at 🎨 rather than continuing: fixing review comments puts the
35
+ # work back through both halves of building, it does not skip the reviewer
36
+ # and it does not assume the half nobody complained about still holds.
37
+ SPINE = {
38
+ retroactive: :planned,
39
+ new: :researched,
40
+ researched: :ready_for_planning,
41
+ ready_for_planning: :planned,
42
+ planned: :building,
43
+ building: :building_ui,
44
+ building_ui: :ready_for_review,
45
+ ready_for_review: :in_review,
46
+ in_review: :approved,
47
+ approved: :deployed,
48
+ rejected: :building_ui,
49
+ rolled_back: :ready_for_review,
50
+ shit: :planned
51
+ }.freeze
52
+
53
+ # Preference order when several states fit a folder's contents at once.
54
+ # Read top to bottom: the loudest fact wins. A discard outranks everything,
55
+ # a block outranks progress, and `retroactive` is last because it is the
56
+ # absence of documents rather than the presence of any.
57
+ PREFERENCE = %i[
58
+ discarded rolled_back shit deferred blocked product_blocked
59
+ deployed approved rejected in_review ready_for_review building_ui building planned ready_for_planning
60
+ researched new
61
+ retroactive
62
+ ].freeze
63
+
64
+ # States whose members a folder's *contents* cannot tell apart. Within a
65
+ # family the current name always wins, because re-deriving it would be a
66
+ # guess dressed up as a correction.
67
+ #
68
+ # ⭕️ and 🅱️ both mean "a human must decide before this moves"; *which*
69
+ # human is recorded nowhere but the emoji.
70
+ #
71
+ # ⭐️ 🟡 🎨 look identical on disk: a `spec.md` and a `plan.md` with work
72
+ # units in it. Whether nobody has started, the back end is under way, or
73
+ # the interface is, is recorded only by the agent that renamed the folder
74
+ # when it started. 🟢 👀 🔴 add an open pull request and are just as
75
+ # mute about whether a reviewer has seen it. So `resync` never moves
76
+ # between any of them; they advance by events alone.
77
+ #
78
+ # Order matters: the members run weakest claim first, and a folder
79
+ # arriving from outside lands on the weakest member its contents fit.
80
+ # Contents that fit the family justify only its floor, never its ceiling.
81
+ FAMILIES = [
82
+ %i[blocked product_blocked],
83
+ %i[planned building building_ui ready_for_review in_review rejected]
84
+ ].freeze
85
+
86
+ # States the agent loop leaves alone: work that is finished (✅ 😎), work
87
+ # that was dropped (❌), and work waiting on a human (⭕️ 🅱️ ☢️).
88
+ # Everything else is fair game for a specialist.
89
+ #
90
+ # ✅ Approved is here deliberately. `hansolo-reviewer` advancing a plan to
91
+ # it is the end of the loop, not a step in it: nothing merges, and no agent
92
+ # handles `approved`. Merging is the one act in this lifecycle that changes
93
+ # a branch everyone else builds on, and an autonomous loop that does it
94
+ # unattended has no way to be wrong quietly.
95
+ #
96
+ # Moving `approved` out of this list is therefore a decision about blast
97
+ # radius rather than about topology. If it ever moves, something has to own
98
+ # `approved -> deployed`, and today nothing does.
99
+ SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
100
+
101
+ # Every state an agent that may sign `Blocked` works in. A block the
102
+ # machine refuses is reported as a failed attempt and parks nothing, so a
103
+ # state missing here turns an honest "I need a decision" into a failure.
104
+ # The agents spec checks every prompt that describes blocking against it.
105
+ #
106
+ # Review is absent: a reviewer rules, it does not ask.
107
+ BLOCKABLE = %i[new researched retroactive ready_for_planning planned building building_ui rejected].freeze
108
+
109
+ # Rerouting a transition below makes the hand-drawn
110
+ # `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
111
+ # the mermaid source in the generated document is derived from this block.
112
+ aasm do
113
+ # The vocabulary is defined once, in {STATUSES}. This machine may not
114
+ # quietly know a different set.
115
+ Agentilda::Plans::STATUSES.each { |status| state status.key }
116
+
117
+ event :specify, guard: :justified? do
118
+ transitions from: %i[retroactive blocked product_blocked deferred], to: :new
119
+ end
120
+
121
+ event :research, guard: :justified? do
122
+ transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
123
+ end
124
+
125
+ # The specification is done and the planner has not started. Entered
126
+ # by the harness when `yoda-writer` completes; `resync` reaches it on
127
+ # a blank `plan.md`. Without it ⭐️ Planned required a file only the
128
+ # agent handling ⭐️ Planned writes, and nothing could enter it.
129
+ event :ready_to_plan, guard: :justified? do
130
+ transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
131
+ to: :ready_for_planning
132
+ end
133
+
134
+ # `new` stays in this list. The spine routes a plan through research, and
135
+ # that is what the agent loop follows — but a specification somebody has
136
+ # already researched by hand should not have to pretend otherwise to get
137
+ # planned. What research buys is not enforced here; it is enforced by
138
+ # `yoda-writer` handling `researched` and nothing else.
139
+ event :plan, guard: :justified? do
140
+ transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked deferred],
141
+ to: :planned
142
+ end
143
+
144
+ event :build, guard: :justified? do
145
+ transitions from: %i[planned shit approved rolled_back retroactive blocked product_blocked deferred],
146
+ to: :building
147
+ end
148
+
149
+ # Building hands off rather than finishing. A plan with no interface work
150
+ # still passes through Building UI: `rey-frontend` says there is nothing
151
+ # to build and moves it on, which costs one cheap round and keeps the
152
+ # spine one shape instead of two. `rejected` re-enters here rather than
153
+ # going straight back to review, because a change request reopens the
154
+ # implementation and neither half can assume the other still holds.
155
+ event :build_ui, guard: :justified? do
156
+ transitions from: %i[building rejected], to: :building_ui
157
+ end
158
+
159
+ # The pair lands one pull request, and whichever half finishes last
160
+ # carries the plan to review from wherever the folder stands.
161
+ event :submit, guard: :justified? do
162
+ transitions from: %i[building building_ui rejected rolled_back], to: :ready_for_review
163
+ end
164
+
165
+ event :review, guard: :justified? do
166
+ transitions from: %i[ready_for_review], to: :in_review
167
+ end
168
+
169
+ event :approve, guard: :justified? do
170
+ transitions from: %i[in_review retroactive], to: :approved
171
+ end
172
+
173
+ event :request_changes, guard: :justified? do
174
+ transitions from: %i[in_review], to: :rejected
175
+ end
176
+
177
+ event :slop, guard: :justified? do
178
+ transitions from: %i[in_review], to: :shit
179
+ end
180
+
181
+ event :deploy, guard: :justified? do
182
+ transitions from: %i[approved], to: :deployed
183
+ end
184
+
185
+ # 🚀 is not the end of the line. A release that breaks in production comes
186
+ # back through ⏪, which needs its own proof on disk: by the time work is
187
+ # deployed every pull request is merged, so 🟢's "at least one open pull
188
+ # request" guard would refuse a direct return, permanently.
189
+ event :rollback, guard: :justified? do
190
+ transitions from: %i[deployed], to: :rolled_back
191
+ end
192
+
193
+ event :block, guard: :justified? do
194
+ transitions from: BLOCKABLE, to: :blocked
195
+ end
196
+
197
+ event :block_on_product, guard: :justified? do
198
+ transitions from: BLOCKABLE, to: :product_blocked
199
+ end
200
+
201
+ event :defer, guard: :justified? do
202
+ transitions from: BLOCKABLE + %i[blocked product_blocked], to: :deferred
203
+ end
204
+
205
+ # Anything, from anywhere, may be dropped for good.
206
+ event :discard, guard: :justified? do
207
+ transitions from: Agentilda::Plans::STATUSES.map(&:key) - %i[discarded], to: :discarded
208
+ end
209
+
210
+ # A transition is not a bookkeeping entry: it is the folder moving.
211
+ after_all_transitions :rename!
212
+ end
213
+
214
+ class << self
215
+ # Destination => the event that reaches it. Every event above has exactly
216
+ # one `to:`, which is what lets `--to <state>` name a destination rather
217
+ # than making callers learn the verbs.
218
+ #
219
+ # @return [Hash{Symbol => Symbol}]
220
+ def event_for
221
+ @event_for ||= aasm.events.each_with_object({}) { |event, map|
222
+ event.transitions.each { |t| map[t.to] ||= event.name }
223
+ }.freeze
224
+ end
225
+
226
+ # Destination => the states that may legitimately reach it.
227
+ #
228
+ # `retroactive` is absent: it is a birth state, produced by
229
+ # `create --after` for work that shipped undocumented.
230
+ #
231
+ # @return [Hash{Symbol => Array<Symbol>}]
232
+ def inbound
233
+ @inbound ||= aasm.events.each_with_object({}) { |event, map|
234
+ event.transitions.each { |t| (map[t.to] ||= []).concat(Array(t.from)) }
235
+ }.transform_values { |froms| froms.uniq.freeze }.freeze
236
+ end
237
+
238
+ # Source => the states it may reach.
239
+ #
240
+ # @return [Hash{Symbol => Array<Symbol>}]
241
+ def outbound_map
242
+ @outbound_map ||= inbound.each_with_object({}) { |(to, froms), map|
243
+ froms.each { |from| (map[from] ||= []) << to }
244
+ }.transform_values(&:freeze).freeze
245
+ end
246
+
247
+ # @param key [Symbol]
248
+ # @return [Array<Symbol>]
249
+ def outbound(key) = outbound_map.fetch(key, [])
250
+
251
+ # @param from [Symbol]
252
+ # @param to [Symbol]
253
+ # @return [Boolean] whether the topology permits this edge at all
254
+ def edge?(from, to) = outbound(from).include?(to)
255
+
256
+ # @param key [Symbol]
257
+ # @return [Array<Symbol>] the family +key+ belongs to, empty when it has none
258
+ def family_of(key) = FAMILIES.find { |family| family.include?(key) } || []
259
+ end
260
+
261
+ # @param subject [#status, #file?, #read, #pull_requests, #rename_to]
262
+ def initialize(subject)
263
+ @subject = subject
264
+ @key = subject.status.key
265
+ end
266
+
267
+ # @return [Object] the plan folder this machine speaks for
268
+ attr_reader :subject
269
+
270
+ # @return [Symbol] the state right now
271
+ attr_reader :key
272
+
273
+ # @return [Agentilda::Plans::Status]
274
+ def status = STATUS_BY_KEY.fetch(key)
275
+
276
+ # AASM keeps state in an ORM column; we keep it in a directory name. These
277
+ # three methods are the whole of that adaptation.
278
+ #
279
+ # @return [Symbol]
280
+ def aasm_read_state(_name = :default) = @key
281
+
282
+ # @return [Boolean]
283
+ def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name)
284
+
285
+ # @return [Boolean]
286
+ def aasm_write_state_without_persistence(new_state, _name = :default)
287
+ @key = new_state
288
+ true
289
+ end
290
+
291
+ # States reachable right now, guards applied.
292
+ #
293
+ # @return [Array<Symbol>]
294
+ def allowed = aasm.states(permitted: true).map(&:name)
295
+
296
+ # @param to [Symbol]
297
+ # @return [Boolean] whether that move is permitted right now
298
+ def may?(to) = allowed.include?(to)
299
+
300
+ # The next state along the spine, whether or not its guard passes.
301
+ #
302
+ # @return [Symbol, nil]
303
+ def spine_next = SPINE[key]
304
+
305
+ # Move the folder. With no argument it walks one step along the {SPINE}.
306
+ #
307
+ # A refusal is information — it means the phase has not actually happened
308
+ # yet — so it arrives as {Refused} carrying the reason, never as `false`.
309
+ #
310
+ # @param to [Symbol] destination, defaulting to the next spine state
311
+ # @return [Agentilda::Plans::Status] the state now occupied
312
+ # @raise [Agentilda::Plans::StateMachine::Refused]
313
+ def promote!(to = spine_next)
314
+ raise Refused, "#{describe(key)} is terminal — nothing follows it" if to.nil?
315
+ raise Refused, "nothing can reach #{describe(to)}" unless self.class.event_for.key?(to)
316
+ raise Refused, refusal(to) unless may?(to)
317
+
318
+ aasm.fire!(self.class.event_for.fetch(to))
319
+ status
320
+ end
321
+
322
+ # The state a folder's contents justify — the answer to "well, what should
323
+ # it be, then?". This is what `resync dirs` renames toward.
324
+ #
325
+ # Invariants are *minimum* requirements, not exact matches: a ⚪️ folder
326
+ # that has since grown a `plan.md` still satisfies ⚪️, and is nonetheless
327
+ # ⭐️ now. So the furthest-justified state wins, and the caller compares it
328
+ # against the current one to decide whether anything should move.
329
+ #
330
+ # The exception is a {FAMILIES family}, whose members contents cannot tell
331
+ # apart. Two rules cover it:
332
+ #
333
+ # - **Already inside one** — the current name wins. Re-deriving it would be
334
+ # a guess dressed up as a correction, and every ⭕️ would become 🅱️.
335
+ # - **Arriving from outside** — the *weakest* member the contents fit
336
+ # wins, not the furthest. A 📋 folder whose `plan.md` grew work units
337
+ # lands on ⭐️, not 🟡, because nobody has started. A ✅ folder found
338
+ # with an open pull request is demonstrably back in the PR phase, and
339
+ # ⭐️ refuses an open pull request, so it lands on 🟡 rather than
340
+ # claiming 🔴.
341
+ #
342
+ # @return [Agentilda::Plans::Status, nil] nil when nothing fits at all
343
+ def best_fit
344
+ fitting = STATUSES.select { |s| s.satisfied_by?(subject) }
345
+ return nil if fitting.empty?
346
+
347
+ best = PREFERENCE.filter_map { |k| fitting.find { |s| s.key == k } }.first || fitting.first
348
+ return status if self.class.family_of(key).include?(best.key)
349
+
350
+ family = self.class.family_of(best.key)
351
+ return best if family.empty?
352
+
353
+ floor = family.find { |k| fitting.any? { |s| s.key == k } }
354
+ STATUS_BY_KEY.fetch(floor)
355
+ end
356
+
357
+ private
358
+
359
+ # The one guard. Entering a state is permitted exactly when that state's
360
+ # invariant holds for this folder's contents.
361
+ #
362
+ # @return [Boolean]
363
+ def justified? = STATUS_BY_KEY.fetch(aasm.to_state).satisfied_by?(subject)
364
+
365
+ # @param to [Symbol]
366
+ # @return [String]
367
+ def refusal(to)
368
+ return "#{describe(key)} cannot become #{describe(to)}" unless self.class.edge?(key, to)
369
+
370
+ STATUS_BY_KEY.fetch(to).violation(subject)
371
+ end
372
+
373
+ # @param key [Symbol]
374
+ # @return [String]
375
+ def describe(key) = STATUS_BY_KEY.fetch(key).to_s
376
+
377
+ # The folder moves. {Subject} owns the path, so it owns the move.
378
+ #
379
+ # @return [void]
380
+ def rename! = subject.rename_to(STATUS_BY_KEY.fetch(aasm.to_state))
381
+ end
382
+ end
383
+ end