agentilda 2.0.1 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
|
@@ -0,0 +1,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
|