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
@@ -21,8 +21,8 @@ module Agentilda
21
21
  # @param options [Hash]
22
22
  # @return [void]
23
23
  def call(**options)
24
- changes = Agentilda::Resync::Dirs.new(tree: tree_for(options))
25
- .call(commit: commit?(options))
24
+ changes = Agentilda::Lifecycle.resync_dirs(tree: tree_for(options))
25
+ .call(commit: commit?(options))
26
26
 
27
27
  if changes.empty?
28
28
  success("Every folder is already named NNN.MM-<emoji> → <slug> and the emoji matches.") unless quiet?(options)
@@ -31,13 +31,13 @@ module Agentilda
31
31
  # @param options [Hash]
32
32
  # @return [void]
33
33
  def call(**options)
34
- github = GitHub.new
34
+ github = Vcs.github
35
35
  tree = tree_for(options)
36
- changes = Agentilda::Resync::Prs.new(tree:,
36
+ changes = Agentilda::Lifecycle.resync_prs(tree:,
37
37
  github:,
38
38
  adopt: options.fetch(:adopt, true),
39
39
  root: File.dirname(tree.dir))
40
- .call(commit: commit?(options))
40
+ .call(commit: commit?(options))
41
41
 
42
42
  if changes.empty?
43
43
  success("Every pull request title already carries a prefix.") unless quiet?(options)
@@ -54,7 +54,7 @@ module Agentilda
54
54
  option :scroll_height,
55
55
  aliases: ["-s"],
56
56
  desc: "Lines of each agent's latest statuses shown under its row, newest first " \
57
- "(default: #{Screen::Ratatui::SCROLL_HEIGHT})"
57
+ "(default: #{Presentation::Screen::Ratatui::SCROLL_HEIGHT})"
58
58
  option :log, desc: "Append progress to this file (default: a per-project file under the system temp dir)"
59
59
 
60
60
  example [
@@ -89,7 +89,7 @@ module Agentilda
89
89
 
90
90
  tree = tree_for(options)
91
91
  root = options[:root] || File.dirname(tree.dir)
92
- agents = Agentilda::Agents.new
92
+ agents = Agentilda::Agents.registry
93
93
  agents = filtered(agents, options[:agent]) if options[:agent]
94
94
  agents = skipped(agents, options[:skip], options[:agent]) if options[:skip]
95
95
  plans = options[:plan] ? scoped(tree, options[:plan]) : nil
@@ -99,7 +99,7 @@ module Agentilda
99
99
  jobs = (options[:jobs] || config[:jobs] || UI.default_jobs).to_i
100
100
  timeout = (options[:timeout] || config[:timeout])&.to_i
101
101
 
102
- if isolation == :worktree && !::Agentilda::Worktree.new(root:).repository?
102
+ if isolation == :worktree && !Agentilda::Vcs.worktree(root:).repository?
103
103
  refuse("#{root} is not a git repository, so plans cannot be isolated.\n\n" \
104
104
  "Run with --isolation shared to work in one tree, serially.",
105
105
  66)
@@ -119,9 +119,9 @@ module Agentilda
119
119
  # so it never rides along in a commit. Editing somebody's .gitignore
120
120
  # is announced, the once it happens. The runner drops the file on a
121
121
  # dry run, which is why it can be built unconditionally.
122
- state = StateFile.new(path: StateFile.for(tree))
123
- if commit?(options) && StateFile.ensure_ignored!(root) && !quiet?(options)
124
- info("Added #{StateFile::IGNORE.join(" and ")} to .gitignore: the run keeps its state there.")
122
+ state = Engine.state_file(tree)
123
+ if commit?(options) && Engine::StateFile.ensure_ignored!(root) && !quiet?(options)
124
+ info("Added #{Engine::StateFile::IGNORE.join(" and ")} to .gitignore: the run keeps its state there.")
125
125
  end
126
126
 
127
127
  # The screen only on a terminal that is actually running agents: a dry
@@ -129,33 +129,33 @@ module Agentilda
129
129
  # screen the keys still work, so the one line says which.
130
130
  # With a screen, ratatui owns the terminal's input, so the keyboard is
131
131
  # fed from its loop rather than started on STDIN of its own.
132
- Control.reset!
133
- scroll_height = options.fetch(:scroll_height, Screen::Ratatui::SCROLL_HEIGHT)
132
+ Execution::Control.reset!
133
+ scroll_height = options.fetch(:scroll_height, Presentation::Screen::Ratatui::SCROLL_HEIGHT)
134
134
  unless scroll_height.to_s.match?(/\A[1-9]\d*\z/)
135
135
  refuse("--scroll-height must be a whole number of lines, 1 or more, not #{scroll_height.inspect}.", 64)
136
136
  end
137
- screen = (Screen::Ratatui.new(scroll_height: scroll_height.to_i) if UI.animate? && commit?(options))
138
- console = (Console.new(screen:) if screen)
137
+ screen = (Presentation::Screen::Ratatui.new(scroll_height: scroll_height.to_i) if UI.animate? && commit?(options))
138
+ console = (Presentation::Console.new(screen:) if screen)
139
139
  # A dry run gets no keyboard at all: every key it offers speaks to a
140
140
  # running agent, and listening costs the terminal's raw mode, whose
141
141
  # output post-processing the report below needs left alone.
142
142
  keyboard = if screen
143
- Keyboard.new(sink: console).tap { |kb| screen.attach_keyboard(kb) }
143
+ Presentation::Keyboard.new(sink: console).tap { |kb| screen.attach_keyboard(kb) }
144
144
  elsif commit?(options)
145
- Keyboard.listen(sink: console)
145
+ Presentation::Keyboard.listen(sink: console)
146
146
  end
147
147
  UI.line("keys: h for help - s select, k kill, x extend, w wrap up, n stop, q quit") if keyboard && console.nil? && !quiet?(options)
148
148
 
149
- runner = Runner.new(
149
+ runner = Engine.runner(
150
150
  tree:,
151
151
  agents:,
152
152
  isolation:,
153
153
  jobs:,
154
154
  plans:,
155
155
  state:,
156
- worktree: (::Agentilda::Worktree.new(root:) if isolation == :worktree),
156
+ worktree: (Agentilda::Vcs.worktree(root:) if isolation == :worktree),
157
157
  rounds: (options[:rounds] || config[:rounds])&.to_i,
158
- executor: Executor.new(root:,
158
+ executor: Execution.executor(root:,
159
159
  timeout:,
160
160
  dry_run: !commit?(options),
161
161
  instructions: instructions_from(options[:prompt]),
@@ -172,7 +172,7 @@ module Agentilda
172
172
  started = UI.monotonic
173
173
  attempts = begin
174
174
  screen&.open
175
- Control.on_interrupt { runner.call { |dispatcher| console&.attach(dispatcher) } }
175
+ Execution.on_interrupt { runner.call { |dispatcher| console&.attach(dispatcher) } }
176
176
  ensure
177
177
  screen&.close
178
178
  keyboard&.stop
@@ -192,7 +192,7 @@ module Agentilda
192
192
  # from the rows themselves, so the columns are as narrow as the run
193
193
  # allows and still square.
194
194
  #
195
- # @param attempts [Array<Agentilda::Runner::Attempt>]
195
+ # @param attempts [Array<Agentilda::Engine::Runner::Attempt>]
196
196
  # @return [Array<String>] one line per attempt, indented by two
197
197
  def attempt_lines(attempts)
198
198
  rows = attempts.map { |a| [a.ordinal.to_s, a.agent.to_s, "[R:#{a.round}]", mark(a), a.note.to_s] }
@@ -205,7 +205,7 @@ module Agentilda
205
205
  end
206
206
  end
207
207
 
208
- # @param attempt [Agentilda::Runner::Attempt]
208
+ # @param attempt [Agentilda::Engine::Runner::Attempt]
209
209
  # @return [String] what the attempt did to its plan's state
210
210
  def mark(attempt)
211
211
  return "FAIL" unless attempt.ok
@@ -228,9 +228,9 @@ module Agentilda
228
228
  File.read(prompt)
229
229
  end
230
230
 
231
- # @param agents [Agentilda::Agents]
231
+ # @param agents [Agentilda::Agents::Registry]
232
232
  # @param name [String]
233
- # @return [Agentilda::Agents] holding only the agent named — the
233
+ # @return [Agentilda::Agents::Registry] holding only the agent named — the
234
234
  # restriction has to reach assignments, not just chaining, or a flag
235
235
  # like `--prompt` aimed at one agent leaks to the whole round
236
236
  def filtered(agents, name)
@@ -243,12 +243,12 @@ module Agentilda
243
243
  # already-restricted one, so `--skip` misspelled is a refusal rather
244
244
  # than a silent no-op — the same reasoning `--plan` applies to numbers.
245
245
  #
246
- # @param agents [Agentilda::Agents]
246
+ # @param agents [Agentilda::Agents::Registry]
247
247
  # @param text [String] comma-separated agent names
248
248
  # @param restricted [String, nil] what `--agent` asked for, if anything
249
- # @return [Agentilda::Agents] without the agents named
249
+ # @return [Agentilda::Agents::Registry] without the agents named
250
250
  def skipped(agents, text, restricted)
251
- roster = Agentilda::Agents.new
251
+ roster = Agentilda::Agents.registry
252
252
  names = text.split(",").map(&:strip).reject(&:empty?)
253
253
  names.each do |name|
254
254
  roster.find(name) or
@@ -268,13 +268,13 @@ module Agentilda
268
268
  # this exists to prevent, so an unknown one is refused rather than
269
269
  # dropped.
270
270
  #
271
- # @param tree [Agentilda::Tree]
271
+ # @param tree [Agentilda::Plans::Tree]
272
272
  # @param text [String]
273
- # @return [Array<Agentilda::Ordinal>]
273
+ # @return [Array<Agentilda::Plans::Ordinal>]
274
274
  def scoped(tree, text)
275
275
  known = tree.subjects.map { |s| s.feature.ordinal }
276
276
  text.split(",").map(&:strip).reject(&:empty?).map { |token|
277
- ordinal = Ordinal.parse(token)
277
+ ordinal = Plans::Ordinal.parse(token)
278
278
  unless ordinal && known.include?(ordinal)
279
279
  refuse("No plan #{token} in #{tree.dir}.\n\n" \
280
280
  "Known: #{known.join(", ")}",
@@ -290,34 +290,34 @@ module Agentilda
290
290
  # agent and a plan meant them to meet, so a pairing that cannot happen
291
291
  # is refused up front with the agent that would actually take each plan.
292
292
  #
293
- # @param agents [Agentilda::Agents] already narrowed to the one agent
294
- # @param tree [Agentilda::Tree]
295
- # @param plans [Array<Agentilda::Ordinal>, nil]
293
+ # @param agents [Agentilda::Agents::Registry] already narrowed to the one agent
294
+ # @param tree [Agentilda::Plans::Tree]
295
+ # @param plans [Array<Agentilda::Plans::Ordinal>, nil]
296
296
  # @param name [String]
297
297
  # @return [void]
298
298
  def assignable!(agents, tree, plans, name)
299
299
  agent = agents.find(name)
300
300
  active = tree.subjects.select { |s|
301
301
  (plans.nil? || plans.include?(s.feature.ordinal)) &&
302
- !StateMachine::SETTLED.include?(s.status.key)
302
+ !Plans::StateMachine::SETTLED.include?(s.status.key)
303
303
  }
304
304
  return if active.empty? || active.any? { |s| agent.handles?(s.status) }
305
305
 
306
- roster = Agentilda::Agents.new
306
+ roster = Agentilda::Agents.registry
307
307
  lines = active.map { |s|
308
308
  takers = roster.for_status(s.status).map(&:name)
309
309
  verb = takers.size == 1 ? "takes" : "take"
310
310
  " #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
311
311
  "#{" - #{takers.join(" and ")} #{verb} it" unless takers.empty?}"
312
312
  }
313
- handled = agent.handles.map { |k| Agentilda::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
313
+ handled = agent.handles.map { |k| Agentilda::Plans::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
314
314
  refuse("#{name} handles #{handled.join(", ")}, and no plan in scope is there:\n\n" \
315
315
  "#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.",
316
316
  65)
317
317
  end
318
318
 
319
- # @param attempts [Array<Agentilda::Runner::Attempt>]
320
- # @return [Array<Agentilda::Runner::Attempt>]
319
+ # @param attempts [Array<Agentilda::Engine::Runner::Attempt>]
320
+ # @return [Array<Agentilda::Engine::Runner::Attempt>]
321
321
  def failures(attempts) = attempts.reject(&:ok)
322
322
 
323
323
  # The one thing this harness does that leaves the machine — pushing a
@@ -332,11 +332,11 @@ module Agentilda
332
332
  # @param root [String]
333
333
  # @param isolation [Symbol]
334
334
  # @param options [Hash]
335
- # @return [Agentilda::Publisher, nil]
335
+ # @return [Agentilda::Vcs::Publisher, nil]
336
336
  def publisher_for(root, isolation, options)
337
337
  return nil if isolation != :worktree || options[:git_push] == false
338
338
 
339
- Publisher.new(root:, dry_run: !commit?(options))
339
+ Vcs.publisher(root:, dry_run: !commit?(options))
340
340
  end
341
341
 
342
342
  # One line per attempt: the plan, who ran, which of that agent's rounds
@@ -344,8 +344,8 @@ module Agentilda
344
344
  # because the loop has no rounds of its own: each agent counts its own
345
345
  # per plan, so two agents on one plan can be on different numbers.
346
346
  #
347
- # @param runner [Agentilda::Runner]
348
- # @param attempts [Array<Agentilda::Runner::Attempt>]
347
+ # @param runner [Agentilda::Engine::Runner]
348
+ # @param attempts [Array<Agentilda::Engine::Runner::Attempt>]
349
349
  # @param options [Hash]
350
350
  # @param seconds [Float] wall clock for the whole loop
351
351
  # @return [void]
@@ -360,12 +360,12 @@ module Agentilda
360
360
  # What the run cost, on STDOUT with the attempts it belongs to, so a run
361
361
  # redirected to a file keeps its bill. A dry run spent nothing and gets
362
362
  # none of this.
363
- tally = Tally.new(attempts:, seconds:)
363
+ tally = Engine.tally(attempts:, seconds:)
364
364
  puts("", tally.render) if commit?(options)
365
365
 
366
366
  return if quiet?(options)
367
367
 
368
- if Control.quit?
368
+ if Execution::Control.quit?
369
369
  warn("Stopped from the keyboard — q. Unfinished plans keep their state; " \
370
370
  "re-run to resume where they stand.")
371
371
  end
@@ -9,7 +9,7 @@ module Agentilda
9
9
  example [""]
10
10
 
11
11
  # @return [void]
12
- def call(**) = $stdout.write(Diagram.new.render)
12
+ def call(**) = $stdout.write(Presentation.diagram)
13
13
  end
14
14
  end
15
15
  end
@@ -47,11 +47,11 @@ module Agentilda
47
47
  def call(plans:, **options)
48
48
  tree = tree_for(options)
49
49
  quiet?(options)
50
- unblocker = Unblocker.new(tree:,
50
+ unblocker = Lifecycle.unblocker(tree:,
51
51
  agent: agent_for(options),
52
52
  root: options[:root],
53
53
  commit: commit?(options),
54
- executor: Executor.new(root: options[:root] || File.dirname(tree.dir),
54
+ executor: Execution.executor(root: options[:root] || File.dirname(tree.dir),
55
55
  dry_run: !commit?(options)))
56
56
 
57
57
  targets = unblocker.resolve(plans)
@@ -62,7 +62,7 @@ module Agentilda
62
62
  credentials_warning if commit?(options) && !quiet?(options)
63
63
  preflight(subjects, unblocker, options)
64
64
 
65
- outcomes = Control.on_interrupt { unblocker.call(subjects) }
65
+ outcomes = Execution.on_interrupt { unblocker.call(subjects) }
66
66
  outcomes.each { |outcome| report(outcome, options) }
67
67
  footer(outcomes, options) unless quiet?(options)
68
68
  exit(worst(targets, outcomes))
@@ -71,10 +71,10 @@ module Agentilda
71
71
  private
72
72
 
73
73
  # @param options [Hash]
74
- # @return [Agentilda::Agent]
74
+ # @return [Agentilda::Agents::Agent]
75
75
  def agent_for(options)
76
76
  name = options.fetch(:agent, DEFAULT_AGENT)
77
- agents = Agentilda::Agents.new
77
+ agents = Agentilda::Agents.registry
78
78
  agents.find(name) or
79
79
  refuse("No agent called #{name}.\n\nKnown: #{agents.all.map(&:name).join(", ")}", 65)
80
80
  end
@@ -87,8 +87,8 @@ module Agentilda
87
87
  # Every bad token is reported, not just the first, because stopping at
88
88
  # the first one hides the rest of the answer.
89
89
  #
90
- # @param targets [Array<Agentilda::Unblocker::Target>]
91
- # @param tree [Agentilda::Tree]
90
+ # @param targets [Array<Agentilda::Lifecycle::Unblocker::Target>]
91
+ # @param tree [Agentilda::Plans::Tree]
92
92
  # @param options [Hash]
93
93
  # @return [void]
94
94
  def refused(targets, tree, options)
@@ -113,15 +113,15 @@ module Agentilda
113
113
  # terminal showed nothing at all — a run that had found nothing to do and
114
114
  # a run still working looked exactly alike.
115
115
  #
116
- # @param subjects [Array<Agentilda::Subject>]
117
- # @param unblocker [Agentilda::Unblocker]
116
+ # @param subjects [Array<Agentilda::Plans::Subject>]
117
+ # @param unblocker [Agentilda::Lifecycle::Unblocker]
118
118
  # @param options [Hash]
119
119
  # @return [void]
120
120
  def preflight(subjects, _unblocker, options)
121
121
  return if quiet?(options)
122
122
 
123
123
  subjects.each do |subject|
124
- questions = Unblocker.questions(subject)
124
+ questions = Lifecycle::Unblocker.questions(subject)
125
125
  answered = questions.count(&:answered)
126
126
  say("#{paint(subject.feature.ordinal.to_s, :bright_black)} #{subject.status.emoji} " \
127
127
  "#{subject.feature.title} — #{summary(questions.size, answered)}")
@@ -138,8 +138,8 @@ module Agentilda
138
138
  "#{open} open, #{answered.zero? ? "none answered yet" : "#{answered} with an answer waiting"}"
139
139
  end
140
140
 
141
- # @param subject [Agentilda::Subject]
142
- # @param questions [Array<Agentilda::Unblocker::Question>]
141
+ # @param subject [Agentilda::Plans::Subject]
142
+ # @param questions [Array<Agentilda::Lifecycle::Unblocker::Question>]
143
143
  # @return [void]
144
144
  def detail(subject, questions)
145
145
  if subject.unreadable_block?
@@ -157,7 +157,7 @@ module Agentilda
157
157
  # One plan, after the fact: the deliverable line on STDOUT, and what
158
158
  # changed on STDERR.
159
159
  #
160
- # @param outcome [Agentilda::Unblocker::Outcome]
160
+ # @param outcome [Agentilda::Lifecycle::Unblocker::Outcome]
161
161
  # @param options [Hash]
162
162
  # @return [void]
163
163
  def report(outcome, options)
@@ -177,7 +177,7 @@ module Agentilda
177
177
  # What the file says happened, rather than what the agent claims. The
178
178
  # questions are counted off disk on both sides of the run.
179
179
  #
180
- # @param outcome [Agentilda::Unblocker::Outcome]
180
+ # @param outcome [Agentilda::Lifecycle::Unblocker::Outcome]
181
181
  # @param commit [Boolean]
182
182
  # @return [String]
183
183
  def movement(outcome, commit)
@@ -189,7 +189,7 @@ module Agentilda
189
189
  "folded #{outcome.folded.map { |n| "B#{n}" }.join(", ")}"
190
190
  end
191
191
 
192
- # @param outcomes [Array<Agentilda::Unblocker::Outcome>]
192
+ # @param outcomes [Array<Agentilda::Lifecycle::Unblocker::Outcome>]
193
193
  # @param options [Hash]
194
194
  # @return [void]
195
195
  def footer(outcomes, options)
@@ -222,8 +222,8 @@ module Agentilda
222
222
  # The most serious thing that happened, as an exit status. A tree that
223
223
  # was partly drained still exits non-zero when part of it could not be.
224
224
  #
225
- # @param targets [Array<Agentilda::Unblocker::Target>]
226
- # @param outcomes [Array<Agentilda::Unblocker::Outcome>]
225
+ # @param targets [Array<Agentilda::Lifecycle::Unblocker::Target>]
226
+ # @param outcomes [Array<Agentilda::Lifecycle::Unblocker::Outcome>]
227
227
  # @return [Integer]
228
228
  def worst(targets, outcomes)
229
229
  problems = targets.map(&:problem)
@@ -35,7 +35,7 @@ module Agentilda
35
35
  end
36
36
 
37
37
  root = File.dirname(tree.dir)
38
- worktree = ::Agentilda::Worktree.new(root:)
38
+ worktree = Agentilda::Vcs.worktree(root:)
39
39
 
40
40
  checkout = worktree.checkout_for(feature)
41
41
 
@@ -66,12 +66,12 @@ module Agentilda
66
66
 
67
67
  # Find the plan that matches the given identifier.
68
68
  #
69
- # @param tree [Agentilda::Tree] plans directory wrapper
69
+ # @param tree [Agentilda::Plans::Tree] plans directory wrapper
70
70
  # @param plan [String] ordinal, folder name, or slug
71
- # @return [Agentilda::Feature, nil]
71
+ # @return [Agentilda::Plans::Feature, nil]
72
72
  def resolve_plan(tree, plan)
73
73
  # Try parsing as an ordinal first
74
- ordinal = ::Agentilda::Ordinal.parse(plan)
74
+ ordinal = ::Agentilda::Plans::Ordinal.parse(plan)
75
75
  if ordinal
76
76
  found = tree.features.find { |f| f.ordinal == ordinal }
77
77
  return found if found
data/lib/agentilda/cli.rb CHANGED
@@ -106,7 +106,7 @@ module Agentilda
106
106
  prefix.register "projects", Linear::Projects
107
107
  end
108
108
 
109
- # What a paired agent shells out to between steps. See {Agentilda::Mailbox}.
109
+ # What a paired agent shells out to between steps. See {Agentilda::Plans::Mailbox}.
110
110
  register "mail" do |prefix|
111
111
  prefix.register "send", Mail::Send
112
112
  prefix.register "read", Mail::Read
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Engine
5
+ # What the screen draws: everything about the run, as values, refreshed
6
+ # once a second by the dispatcher. The screen paints it and knows nothing
7
+ # about threads; the dispatcher builds it and knows nothing about cells.
8
+ #
9
+ # @!attribute [r] rows
10
+ # @return [Array<Agentilda::Engine::Board::Row>] one per agent invocation still
11
+ # worth showing: running, or finished within the last few seconds
12
+ Board = Data.define(:started_at,
13
+ :status,
14
+ :plans,
15
+ :up,
16
+ :down,
17
+ :rows,
18
+ :root,
19
+ :running,
20
+ :live_up,
21
+ :live_down,
22
+ :selected,
23
+ :dialog,
24
+ :help,
25
+ :about,
26
+ :hidden) do
27
+ def initialize(selected: nil, dialog: nil, help: false, about: nil, hidden: 0, **rest) = super
28
+ end
29
+
30
+ # @!attribute [r] key
31
+ # @return [String] `"001.00/leah-researcher"`, what selection and the
32
+ # keyboard address a row by
33
+ # @!attribute [r] file
34
+ # @return [String] the document holding the agent's latest ledger line
35
+ # @!attribute [r] pr
36
+ # @return [Hash, nil] `{number:, url:, rejected:}` once the plan is at
37
+ # the review stage
38
+ # @!attribute [r] state
39
+ # @return [Symbol] :running, :done, :failed or :stuck
40
+ # @!attribute [r] phase
41
+ # @return [Symbol, nil] the clock's phase while running
42
+ # @!attribute [r] frame
43
+ # @return [Integer] spinner frame counter
44
+ # @!attribute [r] history
45
+ # @return [Array<String>] what the agent has said, newest first, for
46
+ # `run --scroll-height` to show more than the latest line of
47
+ Board::Row = Data.define(:key,
48
+ :at,
49
+ :ordinal,
50
+ :file,
51
+ :agent,
52
+ :role,
53
+ :round,
54
+ :rounds,
55
+ :model,
56
+ :remaining,
57
+ :phase,
58
+ :up,
59
+ :down,
60
+ :message,
61
+ :state,
62
+ :pr,
63
+ :frame,
64
+ :bold,
65
+ :elapsed,
66
+ :subagents,
67
+ :history) do
68
+ def initialize(pr: nil, phase: nil, remaining: nil, frame: 0, bold: false, message: nil,
69
+ elapsed: 0, subagents: 0, history: [], **rest)
70
+ super
71
+ end
72
+
73
+ # @param history [Array<String>] newest first
74
+ # @param text [String, nil]
75
+ # @return [Array<String>] +text+ on top, unless it is empty or repeats
76
+ # the newest line: an agent restating its activity is not news
77
+ def self.remember(history, text)
78
+ text = text.to_s.strip
79
+ return history if text.empty? || history.first == text
80
+
81
+ [text, *history].first(Board::Row::HISTORY)
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def running? = state == :running
86
+ end
87
+
88
+ # Statuses a row keeps, whatever the screen shows of them. A cap, so a
89
+ # long-running agent's chatter cannot grow without bound. Outside the
90
+ # `Data.define` block, whose constants would land on Agentilda instead.
91
+ Board::Row::HISTORY = 50
92
+ end
93
+ end