agentilda 1.0.3 → 2.0.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +10 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +92 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +46 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +65 -13
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+
5
+ module Agentilda
6
+ module Screen
7
+ # A ratatui_ruby-backed alternative to {Screen}, selected with `--tui
8
+ # ratatui`. Unlike {Screen}, whose #draw renders synchronously from the
9
+ # dispatcher's own tick, ratatui_ruby owns a blocking render+input loop
10
+ # of its own — see the lifecycle methods added in the next task. This
11
+ # class carries the parts of that loop with no terminal dependency: the
12
+ # widget tree built from a {Board}.
13
+ class Ratatui
14
+ # Cells each column takes. The last one is stretched to the edge by
15
+ # ratatui's legacy flex. The elapsed/time-left columns (indices 8 and 9) are 17,
16
+ # not 13: {Bar.cell} emits a 10-cell bar plus a clock suffix up to 7
17
+ # characters wide (e.g. " 12:41"), and a narrower column clips the
18
+ # clock digits once ratatui actually lays the table out (see
19
+ # spec/agentilda/screen/ratatui_spec.rb's real-terminal regression
20
+ # test, which is the only one of this file's specs that can see
21
+ # that — the rest inspect the pre-layout `Text::Line` object).
22
+ COLUMNS = [9, 7, 16, 18, 8, 8, 5, 13, 17, 17].freeze
23
+
24
+ HEADER = %w[time plan feature agent round model subs tokens elapsed left].freeze
25
+
26
+ # Blank lines between two agents.
27
+ ROW_GAP = 1
28
+
29
+ # The highlight symbol's column. Reserved even with nothing selected,
30
+ # so the activity line's x never shifts when a selection appears.
31
+ HIGHLIGHT_SYMBOL = "> "
32
+
33
+ # The activity line starts under the feature column: past the
34
+ # highlight symbol, time and plan, each column plus its 1-cell gap.
35
+ ACTIVITY_INDENT = HIGHLIGHT_SYMBOL.length + COLUMNS[0] + 1 + COLUMNS[1] + 1
36
+
37
+ # Cells a status line stops short of the terminal's right edge.
38
+ ACTIVITY_MARGIN = 5
39
+
40
+ # Status lines drawn under an agent's row when nobody says otherwise.
41
+ SCROLL_HEIGHT = 3
42
+
43
+ # Seconds between samples for the running-agent-count sparkline.
44
+ SAMPLE_INTERVAL = 10
45
+
46
+ # Points the sparkline keeps before the oldest scrolls off the left.
47
+ HISTORY = 60
48
+
49
+ # @return [Array<Integer>] running-agent-count samples, oldest first
50
+ attr_reader :history
51
+
52
+ # @return [Integer] status lines drawn under each agent's row
53
+ attr_reader :scroll_height
54
+
55
+ # @param runner [#call] `RatatuiRuby.method(:run)` by default; a fake
56
+ # in tests, so this class never needs a real terminal to exercise
57
+ # its own orchestration
58
+ # @param scroll_height [Integer] how many of an agent's latest statuses
59
+ # show under its row, newest at the top
60
+ def initialize(runner: RatatuiRuby.method(:run), scroll_height: SCROLL_HEIGHT)
61
+ @runner = runner
62
+ @scroll_height = Integer(scroll_height).clamp(1, Board::Row::HISTORY)
63
+ @board = nil
64
+ @keyboard = nil
65
+ @history = []
66
+ @last_sample = nil
67
+ # Read from the dispatcher's thread in #tick's `until @closing` via
68
+ # #run_loop, written from the caller's thread in #close. Safe only
69
+ # under MRI/CRuby's GVL, which serializes the read and the write;
70
+ # a GVL-free Ruby would need a real memory barrier here.
71
+ @closing = false
72
+ @error = nil
73
+ @mutex = Mutex.new
74
+ end
75
+
76
+ # @param keyboard [Agentilda::Keyboard] built with `.new`, never
77
+ # `.start`'d — this is what feeds it keys, from the thread
78
+ # ratatui_ruby's own loop runs on.
79
+ # @return [void]
80
+ def attach_keyboard(keyboard) = @keyboard = keyboard
81
+
82
+ # @return [void]
83
+ def open
84
+ @closing = false
85
+ @error = nil
86
+ @thread = Thread.new do
87
+ Thread.current.report_on_exception = false
88
+ begin
89
+ @runner.call { |tui| run_loop(tui) }
90
+ rescue StandardError => e
91
+ # A background render thread's job is never to crash the run:
92
+ # #close's `@thread&.join` would otherwise re-raise this inside
93
+ # `run.rb`'s `ensure`, after every agent has already run,
94
+ # discarding the whole run report. Stash it; #close surfaces it
95
+ # with a one-line warning instead.
96
+ @error = e
97
+ end
98
+ end
99
+ end
100
+
101
+ # Called by {Console#paint} from the dispatcher's own thread, exactly
102
+ # as {Screen#draw} is. Stores the board; the ratatui thread picks it
103
+ # up on its own next tick, a fraction of a frame later.
104
+ #
105
+ # @param board [Agentilda::Board]
106
+ # @return [void]
107
+ def draw(board) = @mutex.synchronize { @board = board }
108
+
109
+ # @return [void]
110
+ def close
111
+ @closing = true
112
+ @thread&.join
113
+ @thread = nil
114
+ UI.line("ratatui backend failed: #{@error.message} (the run itself continued; nothing was drawn)") if @error
115
+ end
116
+
117
+ # @param tui [RatatuiRuby::TUI]
118
+ # @return [void]
119
+ def run_loop(tui)
120
+ # `TableState.new`'s documented `selected = nil` default isn't
121
+ # honored by the native binding — it raises ArgumentError given
122
+ # zero arguments (see spec/agentilda/screen/ratatui_spec.rb's own
123
+ # `table_state` let) — so the nil has to be explicit.
124
+ table_state = tui.table_state(nil)
125
+ tick(tui, table_state) until @closing
126
+ end
127
+
128
+ # One frame: draw the latest board, sample the sparkline, forward the
129
+ # next key. Public so the lifecycle spec can drive it without needing
130
+ # to manage the `until @closing` loop.
131
+ #
132
+ # @param tui [RatatuiRuby::TUI]
133
+ # @param table_state [RatatuiRuby::TableState]
134
+ # @return [void]
135
+ def tick(tui, table_state)
136
+ board = @mutex.synchronize { @board }
137
+ unless board
138
+ # `tui.poll_event` below is what paces this loop (ratatui's
139
+ # default poll timeout is ~16ms); returning ahead of it, as here,
140
+ # would otherwise spin a CPU core flat out until the dispatcher's
141
+ # first paint.
142
+ sleep(0.05)
143
+ return
144
+ end
145
+
146
+ sample(board)
147
+ tui.draw { |frame| render(tui, frame, frame.area, board, table_state) }
148
+ forward(tui.poll_event)
149
+ end
150
+
151
+ # @param tui [RatatuiRuby::TUI]
152
+ # @param frame [RatatuiRuby::Frame]
153
+ # @param area [RatatuiRuby::Layout::Rect]
154
+ # @param board [Agentilda::Board]
155
+ # @param table_state [RatatuiRuby::TableState]
156
+ # @return [void]
157
+ def render(tui, frame, area, board, table_state)
158
+ sync_selection(board, table_state)
159
+ _, top, _, table_area, bottom = tui.layout_split(area,
160
+ direction: :vertical,
161
+ constraints: [tui.constraint_length(1), tui.constraint_length(1), tui.constraint_length(1),
162
+ tui.constraint_fill(1), tui.constraint_length(1)])
163
+ _bottom_text, bottom_spark = tui.layout_split(bottom,
164
+ direction: :horizontal,
165
+ constraints: [tui.constraint_fill(3), tui.constraint_fill(1)])
166
+
167
+ frame.render_widget(top_bar(tui, board), top)
168
+ frame.render_stateful_widget(table(tui, board), table_area, table_state)
169
+ render_activities(tui, frame, table_area, board, table_state)
170
+ # The bar is painted across the whole strip first, sparkline area
171
+ # included, so the cyan reaches both edges; the sparkline is then
172
+ # drawn over its right-hand share of it, in the same colors.
173
+ frame.render_widget(bottom_bar(tui, board), bottom)
174
+ frame.render_widget(tui.sparkline(data: @history, style: tui.style(fg: :black, bg: :cyan)), bottom_spark)
175
+ render_overlay(tui, frame, area, board)
176
+ end
177
+
178
+ # @param board [Agentilda::Board]
179
+ # @return [void]
180
+ def sample(board)
181
+ now = UI.monotonic
182
+ return if @last_sample && now - @last_sample < SAMPLE_INTERVAL
183
+
184
+ @last_sample = now
185
+ @history << board.running
186
+ @history.shift while @history.size > HISTORY
187
+ end
188
+
189
+ # @param event [RatatuiRuby::Event]
190
+ # @return [void]
191
+ def forward(event)
192
+ key = KeyTranslator.call(event) or return
193
+ @keyboard&.handle(key)
194
+ end
195
+
196
+ private
197
+
198
+ # @param board [Agentilda::Board]
199
+ # @param table_state [RatatuiRuby::TableState]
200
+ # @return [void]
201
+ def sync_selection(board, table_state)
202
+ index = board.selected && board.rows.index { |r| r.key == board.selected }
203
+ table_state.select(index)
204
+ end
205
+
206
+ # @return [RatatuiRuby::Widgets::Paragraph]
207
+ def top_bar(tui, board)
208
+ tui.paragraph(
209
+ text: "#{board.status} · plans: #{board.plans.join(", ")} " \
210
+ "· tokens ↑#{UI.abbreviate(board.up)} ↓#{UI.abbreviate(board.down)}",
211
+ style: tui.style(fg: :black, bg: :cyan)
212
+ )
213
+ end
214
+
215
+ # @return [RatatuiRuby::Widgets::Paragraph]
216
+ def bottom_bar(tui, board)
217
+ tui.paragraph(
218
+ text: "working in #{board.root} · agents running: #{board.running} " \
219
+ "· live ↑#{UI.abbreviate(board.live_up)} ↓#{UI.abbreviate(board.live_down)}",
220
+ style: tui.style(fg: :black, bg: :cyan)
221
+ )
222
+ end
223
+
224
+ # @return [RatatuiRuby::Widgets::Table]
225
+ def table(tui, board)
226
+ tui.table(header: HEADER,
227
+ rows: board.rows.map { |row| table_row(tui, row) },
228
+ widths: COLUMNS.map { |w| tui.constraint_length(w) },
229
+ highlight_symbol: HIGHLIGHT_SYMBOL,
230
+ highlight_spacing: :always,
231
+ row_highlight_style: tui.style(modifiers: [:reversed]))
232
+ end
233
+
234
+ # Lines one agent takes: its table row, then its statuses.
235
+ #
236
+ # @return [Integer]
237
+ def row_height = 1 + scroll_height
238
+
239
+ # Paints each visible agent's latest statuses over the empty lines
240
+ # under its table row, newest first: the newest bold, the rest plain,
241
+ # all yellow. A table cell cannot span columns, so a status would
242
+ # otherwise be clipped to one column's width; drawn on top, it gets
243
+ # everything from the feature column to {ACTIVITY_MARGIN} cells before
244
+ # the right edge, and a longer status ends in an ellipsis.
245
+ #
246
+ # @return [void]
247
+ def render_activities(tui, frame, table_area, board, table_state)
248
+ width = table_area.width - ACTIVITY_INDENT - ACTIVITY_MARGIN
249
+ return if width <= 0
250
+
251
+ bottom = table_area.y + table_area.height
252
+ board.rows.drop(table_state.offset.to_i).each_with_index do |row, index|
253
+ # +1 for the header line, +1 more to land under the row's own line.
254
+ top = table_area.y + 1 + (index * (row_height + ROW_GAP)) + 1
255
+ break if top >= bottom
256
+
257
+ statuses(row).each_with_index do |text, line|
258
+ y = top + line
259
+ break if y >= bottom
260
+
261
+ modifiers = line.zero? ? [:bold] : []
262
+ frame.render_widget(
263
+ tui.paragraph(text: ellipsize(text, width), style: tui.style(fg: :yellow, modifiers:)),
264
+ tui.rect(x: table_area.x + ACTIVITY_INDENT, y:, width:, height: 1)
265
+ )
266
+ end
267
+ end
268
+ end
269
+
270
+ # @param text [String]
271
+ # @param width [Integer] cells available
272
+ # @return [String] the text, or its head and an ellipsis within +width+
273
+ def ellipsize(text, width) = text.length > width ? "#{text[0, width - 1]}…" : text
274
+
275
+ # @param row [Agentilda::Board::Row]
276
+ # @return [Array<String>] newest first, at most {#scroll_height}
277
+ def statuses(row)
278
+ lines = row.history.empty? ? [row.message.to_s] : row.history
279
+ lines.first(scroll_height)
280
+ end
281
+
282
+ # @return [RatatuiRuby::Widgets::Row]
283
+ def table_row(tui, row)
284
+ tui.row(height: row_height,
285
+ bottom_margin: ROW_GAP,
286
+ cells: [
287
+ row.at.strftime("%H:%M:%S"),
288
+ row.ordinal,
289
+ row.file,
290
+ tui.text_span(content: row.agent, style: tui.style(fg: :yellow, modifiers: [:bold])),
291
+ "R:#{row.round}/#{row.rounds}",
292
+ row.model.to_s,
293
+ row.subagents.to_s,
294
+ "↑#{UI.abbreviate(row.up)} ↓#{UI.abbreviate(row.down)}",
295
+ Bar.cell(tui, row.elapsed, Bar.elapsed_color(row.elapsed)),
296
+ Bar.cell(tui, row.remaining, Bar.remaining_color(row.remaining))
297
+ ])
298
+ end
299
+
300
+ # @return [void]
301
+ def render_overlay(tui, frame, area, board)
302
+ text = board.dialog || board.help || board.about or return
303
+ title = if board.dialog
304
+ "Agent"
305
+ else
306
+ (board.help ? "Keys" : "About")
307
+ end
308
+ lines = text.lines.size + 4
309
+ width = [text.lines.map { |l| l.chomp.length }.max.to_i + 6, area.width].min
310
+ overlay_area = tui.rect(x: [(area.width - width) / 2, 0].max,
311
+ y: [(area.height - lines) / 2, 0].max,
312
+ width:,
313
+ height: lines)
314
+ frame.render_widget(tui.paragraph(text:, block: tui.block(title:, borders: [:all])), overlay_area)
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Agentilda
7
+ # Where a run stands, written every second, so a harness that dies leaves
8
+ # the next one something to restart from.
9
+ #
10
+ # The ledger inside each document is the record humans read and the
11
+ # dispatcher acts on; this is the index of it, plus what the ledger cannot
12
+ # hold: which run wrote what, process ids, tokens, and whether the run
13
+ # that wrote a `Started` is still alive. It sits in `.plans/` rather than
14
+ # the system temp dir because a reboot must not lose it, and it is
15
+ # gitignored because it is about one machine's run, not the plan.
16
+ class StateFile
17
+ FILENAME = "agentilda-state.json"
18
+
19
+ # Where it used to live, one directory down. Read by {#load} when the
20
+ # current path holds nothing, so a run started under the old layout is
21
+ # picked up rather than silently restarted from zero. Nothing writes
22
+ # here any more and nothing deletes it: the file left behind is the
23
+ # only record a half-finished run has.
24
+ LEGACY_DIRNAME = "tmp"
25
+
26
+ # The lines {.ensure_ignored!} adds. The second covers the sibling
27
+ # {#save} writes and renames over the first — brief, but `.plans/` is a
28
+ # directory whose other contents are committed, so a `git status` run
29
+ # during that instant must not offer it.
30
+ IGNORE = ["#{Agentilda::PLANS_DIR}/#{FILENAME}", "#{Agentilda::PLANS_DIR}/#{FILENAME}.*.tmp"].freeze
31
+
32
+ # @param tree [Agentilda::Tree]
33
+ # @return [String]
34
+ def self.for(tree) = File.join(tree.dir, FILENAME)
35
+
36
+ # @param tree [Agentilda::Tree]
37
+ # @return [String] where {LEGACY_DIRNAME} put it
38
+ def self.legacy_for(tree) = File.join(tree.dir, LEGACY_DIRNAME, FILENAME)
39
+
40
+ # Add the ignore lines once. Editing somebody's `.gitignore` is a thing
41
+ # to announce, which is why this returns whether it did.
42
+ #
43
+ # Only the first line is asked about: git answers for a literal path,
44
+ # and the second is a glob no path equals. They are written together,
45
+ # so one being ignored means both are.
46
+ #
47
+ # @param root [String] repository root
48
+ # @return [Boolean] true when lines were added
49
+ def self.ensure_ignored!(root)
50
+ return false unless system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
51
+ return false if system("git", "-C", root, "check-ignore", "-q", IGNORE.first, out: File::NULL, err: File::NULL)
52
+
53
+ path = File.join(root, ".gitignore")
54
+ existing = File.file?(path) ? File.read(path) : ""
55
+ glue = existing.empty? || existing.end_with?("\n") ? "" : "\n"
56
+ File.write(path, "#{existing}#{glue}#{IGNORE.join("\n")}\n")
57
+ true
58
+ end
59
+
60
+ # @param path [String]
61
+ # @param pid [Integer] this run's process id
62
+ def initialize(path:, pid: Process.pid)
63
+ @path = path
64
+ @pid = pid
65
+ @data = { "run" => {}, "plans" => {} }
66
+ @mutex = Mutex.new
67
+ end
68
+
69
+ # @return [String]
70
+ attr_reader :path
71
+
72
+ # Where a run started before the file moved up out of {LEGACY_DIRNAME}
73
+ # would have written. {#load} falls back to it; {#save} never uses it.
74
+ #
75
+ # @return [String]
76
+ def legacy_path = File.join(File.dirname(path), LEGACY_DIRNAME, File.basename(path))
77
+
78
+ # Reads the current path, or the legacy one when the current path holds
79
+ # nothing. A run interrupted under the old layout is otherwise invisible
80
+ # to the run that picks it up, and {#stranded} would report no stranded
81
+ # stages for a harness that died with several.
82
+ #
83
+ # @return [self]
84
+ def load
85
+ @mutex.synchronize do
86
+ source = [path, legacy_path].find { |candidate| File.file?(candidate) }
87
+ @data = JSON.parse(File.read(source)) if source
88
+ @data = { "run" => {}, "plans" => {} } unless @data.is_a?(Hash) && @data["plans"].is_a?(Hash)
89
+ end
90
+ self
91
+ rescue JSON::ParserError
92
+ self
93
+ end
94
+
95
+ # Written whole to a sibling and renamed over, so a reader never sees
96
+ # half a file.
97
+ #
98
+ # @return [void]
99
+ def save
100
+ @mutex.synchronize do
101
+ FileUtils.mkdir_p(File.dirname(path))
102
+ temp = "#{path}.#{@pid}.tmp"
103
+ File.write(temp, JSON.pretty_generate(@data))
104
+ File.rename(temp, path)
105
+ end
106
+ end
107
+
108
+ # @param root [String]
109
+ # @return [void]
110
+ def begin_run!(root:)
111
+ @mutex.synchronize do
112
+ @data["run"] = { "pid" => @pid, "root" => root, "started_at" => Time.now.iso8601,
113
+ "heartbeat_at" => Time.now.iso8601 }
114
+ end
115
+ end
116
+
117
+ # @return [void]
118
+ def heartbeat!
119
+ @mutex.synchronize { @data["run"]["heartbeat_at"] = Time.now.iso8601 }
120
+ end
121
+
122
+ # @param ordinal [String]
123
+ # @param agent [String]
124
+ # @param round [Integer]
125
+ # @param fields [Hash] anything else worth keeping: status, model, file,
126
+ # pid, up, down, state, next, exit
127
+ # @return [void]
128
+ def record(ordinal, agent:, round:, **fields)
129
+ @mutex.synchronize do
130
+ plan = (@data["plans"][ordinal.to_s] ||= { "stages" => [] })
131
+ stage = plan["stages"].find { |s| s["agent"] == agent && s["round"] == round }
132
+ unless stage
133
+ stage = { "agent" => agent, "round" => round, "run_pid" => @pid }
134
+ plan["stages"] << stage
135
+ end
136
+ fields.each { |key, value| stage[key.to_s] = value }
137
+ end
138
+ end
139
+
140
+ # @param ordinal [String]
141
+ # @return [Array<Hash>]
142
+ def stages(ordinal) = @mutex.synchronize { (@data.dig("plans", ordinal.to_s, "stages") || []).map(&:dup) }
143
+
144
+ # @return [Array<String>] every ordinal with a stage recorded
145
+ def plans = @mutex.synchronize { @data["plans"].keys }
146
+
147
+ # @return [Boolean] whether the run that last wrote this file is gone
148
+ def previous_run_dead?
149
+ previous = @data.dig("run", "pid")
150
+ return false if previous.nil? || previous == @pid
151
+
152
+ Process.kill(0, previous)
153
+ false
154
+ rescue Errno::ESRCH, Errno::EPERM
155
+ true
156
+ end
157
+
158
+ # Stages a dead run left `Started`. Each is an agent that was cut off
159
+ # mid-flight and has to be re-run, with the harness's own Interrupted
160
+ # line written so the document agrees with this file.
161
+ #
162
+ # @return [Array<Array(String, Hash)>] ordinal and stage
163
+ def stranded
164
+ return [] unless previous_run_dead?
165
+
166
+ @mutex.synchronize do
167
+ @data["plans"].flat_map { |ordinal, plan|
168
+ plan["stages"].select { |s| s["status"] == "Started" && s["run_pid"] != @pid }
169
+ .map { |s| [ordinal, s.dup] }
170
+ }
171
+ end
172
+ end
173
+ end
174
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "aasm"
4
4
 
5
+ require "agentilda/status"
6
+
5
7
  module Agentilda
6
8
  # The state machine for one plan folder.
7
9
  #
@@ -34,18 +36,19 @@ module Agentilda
34
36
  # work back through both halves of building, it does not skip the reviewer
35
37
  # and it does not assume the half nobody complained about still holds.
36
38
  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
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
49
52
  }.freeze
50
53
 
51
54
  # Preference order when several states fit a folder's contents at once.
@@ -54,7 +57,7 @@ module Agentilda
54
57
  # absence of documents rather than the presence of any.
55
58
  PREFERENCE = %i[
56
59
  discarded rolled_back shit deferred blocked product_blocked
57
- deployed approved rejected in_review ready_for_review building planned
60
+ deployed approved rejected in_review ready_for_review building_ui building planned ready_for_planning
58
61
  researched new
59
62
  retroactive
60
63
  ].freeze
@@ -66,18 +69,19 @@ module Agentilda
66
69
  # ⭕️ and 🅱️ both mean "a human must decide before this moves"; *which*
67
70
  # human is recorded nowhere but the emoji.
68
71
  #
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.
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.
74
78
  #
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.
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.
78
82
  FAMILIES = [
79
83
  %i[blocked product_blocked],
80
- %i[building ready_for_review in_review rejected]
84
+ %i[planned building building_ui ready_for_review in_review rejected]
81
85
  ].freeze
82
86
 
83
87
  # States the agent loop leaves alone: work that is finished (✅ 😎), work
@@ -95,6 +99,14 @@ module Agentilda
95
99
  # `approved -> deployed`, and today nothing does.
96
100
  SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
97
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
+
98
110
  # Rerouting a transition below makes the hand-drawn
99
111
  # `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
100
112
  # the mermaid source in the generated document is derived from this block.
@@ -111,13 +123,22 @@ module Agentilda
111
123
  transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
112
124
  end
113
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
+
114
135
  # `new` stays in this list. The spine routes a plan through research, and
115
136
  # that is what the agent loop follows — but a specification somebody has
116
137
  # already researched by hand should not have to pretend otherwise to get
117
138
  # planned. What research buys is not enforced here; it is enforced by
118
139
  # `yoda-writer` handling `researched` and nothing else.
119
140
  event :plan, guard: :justified? do
120
- transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
141
+ transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked deferred],
121
142
  to: :planned
122
143
  end
123
144
 
@@ -136,8 +157,10 @@ module Agentilda
136
157
  transitions from: %i[building rejected], to: :building_ui
137
158
  end
138
159
 
160
+ # The pair lands one pull request, and whichever half finishes last
161
+ # carries the plan to review from wherever the folder stands.
139
162
  event :submit, guard: :justified? do
140
- transitions from: %i[building_ui rolled_back], to: :ready_for_review
163
+ transitions from: %i[building building_ui rejected rolled_back], to: :ready_for_review
141
164
  end
142
165
 
143
166
  event :review, guard: :justified? do
@@ -169,15 +192,15 @@ module Agentilda
169
192
  end
170
193
 
171
194
  event :block, guard: :justified? do
172
- transitions from: %i[new planned building], to: :blocked
195
+ transitions from: BLOCKABLE, to: :blocked
173
196
  end
174
197
 
175
198
  event :block_on_product, guard: :justified? do
176
- transitions from: %i[new planned building], to: :product_blocked
199
+ transitions from: BLOCKABLE, to: :product_blocked
177
200
  end
178
201
 
179
202
  event :defer, guard: :justified? do
180
- transitions from: %i[new planned building blocked product_blocked], to: :deferred
203
+ transitions from: BLOCKABLE + %i[blocked product_blocked], to: :deferred
181
204
  end
182
205
 
183
206
  # Anything, from anywhere, may be dropped for good.
@@ -310,10 +333,12 @@ module Agentilda
310
333
  #
311
334
  # - **Already inside one** — the current name wins. Re-deriving it would be
312
335
  # 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 🔴.
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 🔴.
317
342
  #
318
343
  # @return [Agentilda::Status, nil] nil when nothing fits at all
319
344
  def best_fit
@@ -324,7 +349,10 @@ module Agentilda
324
349
  return status if self.class.family_of(key).include?(best.key)
325
350
 
326
351
  family = self.class.family_of(best.key)
327
- family.empty? ? best : STATUS_BY_KEY.fetch(family.first)
352
+ return best if family.empty?
353
+
354
+ floor = family.find { |k| fitting.any? { |s| s.key == k } }
355
+ STATUS_BY_KEY.fetch(floor)
328
356
  end
329
357
 
330
358
  private