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
@@ -1,37 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tmpdir"
4
+
3
5
  module Agentilda
4
6
  module CLI
5
7
  # `agentilda run` — drive the specialist agents until the tree settles.
6
8
  class Run < Base
7
9
  desc "Run specialist agents over the plans until nothing changes"
8
10
 
9
- option :commit, type: :boolean, default: false,
10
- desc: "Actually invoke the agents (default: dry run, prints the plan of work)"
11
- option :rounds, desc: "Hard ceiling on loop iterations (default: 10)"
12
- option :timeout, desc: "Seconds before one agent is abandoned (default: 900)"
13
- option :chain, type: :boolean,
14
- desc: "When an agent advances a plan, run the next agent in the same round " \
15
- "(default: on; forced off by --agent)"
11
+ option :commit,
12
+ type: :boolean,
13
+ default: false,
14
+ desc: "Actually invoke the agents (default: dry run, prints the plan of work)"
15
+ option :rounds,
16
+ desc: "Cap on rounds per agent per plan; each agent declares its own, at most 5 " \
17
+ "(default: the agent's own)"
18
+ option :timeout,
19
+ desc: "Seconds before one agent is abandoned; only tightens an agent's own clock " \
20
+ "(default: the agent's own, else 900)"
16
21
  option :agent, desc: "Only run this one agent"
17
- option :prompt, desc: "Extra instructions appended to the agent's prompt (only with --agent)"
18
- option :skip, desc: "Never assign this agent; its plans wait, the rest of the pipeline runs. " \
19
- "Comma separated for several"
20
- option :model, desc: "Model for every agent this run, overriding each agent's own frontmatter " \
21
- "(default: what the agent declares, else the claude CLI's default)"
22
- option :max_tokens, desc: "Token budget per agent invocation, input plus output, sub-agents " \
23
- "included. The agent is told the number so it can finish inside it; the meter aborts it past " \
24
- "the number so the telling is true. (default: unmetered)"
25
- option :plan, aliases: ["--plans"],
26
- desc: "Only these plans, comma separated: NNN or NNN.MM, e.g. --plan 003,005.01. Default: the whole tree"
22
+ # `--prompt` shorter than this that names an existing file is read.
23
+ PROMPT_PATH_LIMIT = 80
24
+
25
+ option :prompt,
26
+ desc: "Extra instructions appended to the agent's prompt (only with --agent); " \
27
+ "a path to an existing text file under #{PROMPT_PATH_LIMIT} characters is read instead"
28
+ option :skip,
29
+ desc: "Never assign this agent; its plans wait, the rest of the pipeline runs. " \
30
+ "Comma separated for several"
31
+ option :model,
32
+ desc: "Model for every agent this run, overriding each agent's own frontmatter " \
33
+ "(default: what the agent declares, else the claude CLI's default)"
34
+ option :max_tokens,
35
+ desc: "Token budget per agent invocation, input plus output, sub-agents " \
36
+ "included. The agent is told the number so it can finish inside it; the meter aborts it past " \
37
+ "the number so the telling is true. (default: unmetered)"
38
+ option :plan,
39
+ aliases: ["--plans"],
40
+ desc: "Only these plans, comma separated: NNN or NNN.MM, e.g. --plan 003,005.01. Default: the whole tree"
27
41
  option :root, desc: "Repository root the agents work in (default: the .plans parent)"
28
- option :isolation, default: "worktree", values: %w[worktree shared],
29
- desc: "worktree: a checkout and branch per plan, run in parallel. shared: one tree, serial"
30
- option :jobs, aliases: ["-j"],
31
- desc: "Agents to run at once (default: cores - 2, capped at 12)"
32
- option :dont_push_anything, type: :boolean, default: false,
33
- desc: "With --commit and --isolation worktree, a finished branch is pushed and its pull request " \
34
- "opened as soon as it lands, titled [NNN.MM](X). Pass this to turn that off and leave it uncommitted."
42
+ option :isolation,
43
+ default: "worktree",
44
+ values: %w[worktree shared],
45
+ desc: "worktree: a checkout and branch per plan, run in parallel. shared: one tree, serial"
46
+ option :jobs,
47
+ aliases: ["-j"],
48
+ desc: "Agents to run at once (default: cores - 2, capped at 12)"
49
+ option :git_push,
50
+ type: :boolean,
51
+ default: true,
52
+ desc: "With --commit and --isolation worktree, a finished branch is pushed and its pull request " \
53
+ "opened as soon as it lands, titled [NNN.MM](X). --no-git-push turns that off and leaves it uncommitted."
54
+ option :scroll_height,
55
+ aliases: ["-s"],
56
+ desc: "Lines of each agent's latest statuses shown under its row, newest first " \
57
+ "(default: #{Screen::Ratatui::SCROLL_HEIGHT})"
35
58
  option :log, desc: "Append progress to this file (default: a per-project file under the system temp dir)"
36
59
 
37
60
  example [
@@ -39,8 +62,9 @@ module Agentilda
39
62
  "--commit # run them, one worktree per plan, in parallel",
40
63
  "--commit -j 4 # …with four at a time",
41
64
  "--isolation shared # one tree, serial — no git required",
42
- "--commit --rounds 3 # …with a tighter ceiling",
43
- "--commit --plan 005,006,007 # only the plans a batch step just created"
65
+ "--commit --rounds 3 # …capping every agent at three rounds per plan",
66
+ "--commit --plan 005,006,007 # only the plans a batch step just created",
67
+ "--commit -s 5 # …each agent's last five statuses under its row"
44
68
  ]
45
69
 
46
70
  # Option precedence, quietest to loudest: the built-in default, then
@@ -59,31 +83,26 @@ module Agentilda
59
83
 
60
84
  if options[:prompt] && !options[:agent]
61
85
  refuse("--prompt only works with --agent: it speaks to one agent, and without " \
62
- "that restriction every agent in the round would hear it.", 64)
86
+ "that restriction every agent in the round would hear it.",
87
+ 64)
63
88
  end
64
89
 
65
- tree = tree_for(options)
66
- root = options[:root] || File.dirname(tree.dir)
90
+ tree = tree_for(options)
91
+ root = options[:root] || File.dirname(tree.dir)
67
92
  agents = Agentilda::Agents.new
68
93
  agents = filtered(agents, options[:agent]) if options[:agent]
69
94
  agents = skipped(agents, options[:skip], options[:agent]) if options[:skip]
70
- plans = options[:plan] ? scoped(tree, options[:plan]) : nil
95
+ plans = options[:plan] ? scoped(tree, options[:plan]) : nil
71
96
  assignable!(agents, tree, plans, options[:agent]) if options[:agent]
72
97
 
73
98
  isolation = options.fetch(:isolation, "worktree").to_sym
74
- jobs = (options[:jobs] || config[:jobs] || UI.default_jobs).to_i
75
- timeout = (options[:timeout] || config[:timeout] || 900).to_i
76
- chain = if options[:agent]
77
- false
78
- elsif options.key?(:chain)
79
- options[:chain]
80
- else
81
- config.fetch(:chain, true)
82
- end
99
+ jobs = (options[:jobs] || config[:jobs] || UI.default_jobs).to_i
100
+ timeout = (options[:timeout] || config[:timeout])&.to_i
83
101
 
84
- if isolation == :worktree && !Worktree.new(root:).repository?
102
+ if isolation == :worktree && !::Agentilda::Worktree.new(root:).repository?
85
103
  refuse("#{root} is not a git repository, so plans cannot be isolated.\n\n" \
86
- "Run with --isolation shared to work in one tree, serially.", 66)
104
+ "Run with --isolation shared to work in one tree, serially.",
105
+ 66)
87
106
  end
88
107
 
89
108
  # Set before the loop starts, not after: `UI.animate?` (and therefore
@@ -91,37 +110,124 @@ module Agentilda
91
110
  # loop runs, not just when `report` prints its closing summary.
92
111
  quiet?(options)
93
112
  UI.log_path = options[:log] || config[:log] ||
94
- File.join(Dir.tmpdir, "agentilda-#{File.basename(root)}.log")
113
+ File.join(Dir.tmpdir, "agentilda-#{File.basename(root)}.log")
95
114
  info("Progress: #{UI.log_path}") unless quiet?(options)
96
115
  credentials_warning if commit?(options) && !quiet?(options)
97
116
 
117
+ # The state file is what a harness that dies leaves for the next one.
118
+ # It sits in .plans/ so a reboot keeps it, and it is ignored by name
119
+ # so it never rides along in a commit. Editing somebody's .gitignore
120
+ # is announced, the once it happens. The runner drops the file on a
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.")
125
+ end
126
+
127
+ # The screen only on a terminal that is actually running agents: a dry
128
+ # run has nothing to draw and a pipe has nowhere to draw it. Without a
129
+ # screen the keys still work, so the one line says which.
130
+ # With a screen, ratatui owns the terminal's input, so the keyboard is
131
+ # fed from its loop rather than started on STDIN of its own.
98
132
  Control.reset!
99
- keyboard = Keyboard.listen
100
- UI.line("keys: h for help — w wrap up · n write out and stop · q quit") if keyboard && !quiet?(options)
133
+ scroll_height = options.fetch(:scroll_height, Screen::Ratatui::SCROLL_HEIGHT)
134
+ unless scroll_height.to_s.match?(/\A[1-9]\d*\z/)
135
+ refuse("--scroll-height must be a whole number of lines, 1 or more, not #{scroll_height.inspect}.", 64)
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)
139
+ # A dry run gets no keyboard at all: every key it offers speaks to a
140
+ # running agent, and listening costs the terminal's raw mode, whose
141
+ # output post-processing the report below needs left alone.
142
+ keyboard = if screen
143
+ Keyboard.new(sink: console).tap { |kb| screen.attach_keyboard(kb) }
144
+ elsif commit?(options)
145
+ Keyboard.listen(sink: console)
146
+ end
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)
101
148
 
102
149
  runner = Runner.new(
103
- tree:, agents:, isolation:, jobs:, plans:, chain:,
104
- worktree: (Worktree.new(root:) if isolation == :worktree),
105
- max_rounds: (options[:rounds] || config[:rounds] || 10).to_i,
106
- executor: Executor.new(root:, timeout:, dry_run: !commit?(options),
107
- instructions: options[:prompt], model: options[:model],
108
- max_tokens: (options[:max_tokens] || config[:max_tokens])&.to_i,
109
- interactive: !keyboard.nil? && commit?(options)), dry_run: !commit?(options),
110
- publisher: publisher_for(root, isolation, options)
150
+ tree:,
151
+ agents:,
152
+ isolation:,
153
+ jobs:,
154
+ plans:,
155
+ state:,
156
+ worktree: (::Agentilda::Worktree.new(root:) if isolation == :worktree),
157
+ rounds: (options[:rounds] || config[:rounds])&.to_i,
158
+ executor: Executor.new(root:,
159
+ timeout:,
160
+ dry_run: !commit?(options),
161
+ instructions: instructions_from(options[:prompt]),
162
+ model: options[:model],
163
+ max_tokens: (options[:max_tokens] || config[:max_tokens])&.to_i,
164
+ interactive: !keyboard.nil? && commit?(options)),
165
+ dry_run: !commit?(options),
166
+ publisher: publisher_for(root, isolation, options),
167
+ on_board: console&.method(:paint)
111
168
  )
112
169
 
113
- started = UI.monotonic
114
- rounds = begin
115
- runner.call
170
+ # The dispatcher exists only once the run starts, so the console is
171
+ # introduced to it through the block rather than up front.
172
+ started = UI.monotonic
173
+ attempts = begin
174
+ screen&.open
175
+ Control.on_interrupt { runner.call { |dispatcher| console&.attach(dispatcher) } }
116
176
  ensure
177
+ screen&.close
117
178
  keyboard&.stop
118
179
  end
119
- report(runner, rounds, options, seconds: UI.monotonic - started)
120
- exit(failures(rounds).empty? ? 0 : 1)
180
+ report(runner, attempts, options, seconds: UI.monotonic - started)
181
+ exit(failures(attempts).empty? ? 0 : 1)
121
182
  end
122
183
 
123
184
  private
124
185
 
186
+ # The attempts list, padded into columns.
187
+ #
188
+ # Tabs used to separate these, which put every column on the next
189
+ # multiple of eight: one agent's name a character longer than the
190
+ # next one's pushed its round and mark a whole stop to the right, so
191
+ # nothing under `luke-backend` and `yoda-writer` lined up. Widths come
192
+ # from the rows themselves, so the columns are as narrow as the run
193
+ # allows and still square.
194
+ #
195
+ # @param attempts [Array<Agentilda::Runner::Attempt>]
196
+ # @return [Array<String>] one line per attempt, indented by two
197
+ def attempt_lines(attempts)
198
+ rows = attempts.map { |a| [a.ordinal.to_s, a.agent.to_s, "[R:#{a.round}]", mark(a), a.note.to_s] }
199
+ widths = rows.transpose.map { |column| column.map { |cell| UI.display_width(cell) }.max.to_i }
200
+ rows.map do |cells|
201
+ # The note is last and is left as it is: padding the final column
202
+ # only adds trailing whitespace a copy-paste then carries.
203
+ last = cells.size - 1
204
+ " #{cells.each_with_index.map { |cell, i| i == last ? cell : UI.fit(cell, widths[i] + 2) }.join}"
205
+ end
206
+ end
207
+
208
+ # @param attempt [Agentilda::Runner::Attempt]
209
+ # @return [String] what the attempt did to its plan's state
210
+ def mark(attempt)
211
+ return "FAIL" unless attempt.ok
212
+ return "#{attempt.from} -> #{attempt.to}" if attempt.advanced?
213
+
214
+ "no change"
215
+ end
216
+
217
+ # `--prompt` takes the instructions themselves or a file holding them.
218
+ # A short argument naming an existing file is read; anything else is
219
+ # the text, so a sentence that happens to match a filename in the
220
+ # project is not silently swapped for that file once it is long enough
221
+ # to be a sentence.
222
+ #
223
+ # @param prompt [String, nil]
224
+ # @return [String, nil]
225
+ def instructions_from(prompt)
226
+ return prompt unless prompt && prompt.length < PROMPT_PATH_LIMIT && File.file?(prompt)
227
+
228
+ File.read(prompt)
229
+ end
230
+
125
231
  # @param agents [Agentilda::Agents]
126
232
  # @param name [String]
127
233
  # @return [Agentilda::Agents] holding only the agent named — the
@@ -143,14 +249,15 @@ module Agentilda
143
249
  # @return [Agentilda::Agents] without the agents named
144
250
  def skipped(agents, text, restricted)
145
251
  roster = Agentilda::Agents.new
146
- names = text.split(",").map(&:strip).reject(&:empty?)
252
+ names = text.split(",").map(&:strip).reject(&:empty?)
147
253
  names.each do |name|
148
254
  roster.find(name) or
149
255
  refuse("No agent called #{name}.\n\nKnown: #{roster.all.map(&:name).join(", ")}", 65)
150
- if name == restricted
151
- refuse("--agent #{name} and --skip #{name} contradict each other: " \
152
- "one asks for only this agent, the other for anything but.", 64)
153
- end
256
+ next unless name == restricted
257
+
258
+ refuse("--agent #{name} and --skip #{name} contradict each other: " \
259
+ "one asks for only this agent, the other for anything but.",
260
+ 64)
154
261
  end
155
262
  agents.without(*names)
156
263
  end
@@ -170,7 +277,8 @@ module Agentilda
170
277
  ordinal = Ordinal.parse(token)
171
278
  unless ordinal && known.include?(ordinal)
172
279
  refuse("No plan #{token} in #{tree.dir}.\n\n" \
173
- "Known: #{known.join(", ")}", 66)
280
+ "Known: #{known.join(", ")}",
281
+ 66)
174
282
  end
175
283
  ordinal
176
284
  }
@@ -188,27 +296,29 @@ module Agentilda
188
296
  # @param name [String]
189
297
  # @return [void]
190
298
  def assignable!(agents, tree, plans, name)
191
- agent = agents.find(name)
299
+ agent = agents.find(name)
192
300
  active = tree.subjects.select { |s|
193
301
  (plans.nil? || plans.include?(s.feature.ordinal)) &&
194
302
  !StateMachine::SETTLED.include?(s.status.key)
195
303
  }
196
304
  return if active.empty? || active.any? { |s| agent.handles?(s.status) }
197
305
 
198
- roster = Agentilda::Agents.new
199
- lines = active.map { |s|
306
+ roster = Agentilda::Agents.new
307
+ lines = active.map { |s|
200
308
  takers = roster.for_status(s.status).map(&:name)
309
+ verb = takers.size == 1 ? "takes" : "take"
201
310
  " #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
202
- "#{" #{takers.join(", ")} takes it" unless takers.empty?}"
311
+ "#{" - #{takers.join(" and ")} #{verb} it" unless takers.empty?}"
203
312
  }
204
313
  handled = agent.handles.map { |k| Agentilda::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
205
314
  refuse("#{name} handles #{handled.join(", ")}, and no plan in scope is there:\n\n" \
206
- "#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.", 65)
315
+ "#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.",
316
+ 65)
207
317
  end
208
318
 
209
- # @param rounds [Array]
210
- # @return [Array]
211
- def failures(rounds) = rounds.flat_map(&:attempts).reject(&:ok)
319
+ # @param attempts [Array<Agentilda::Runner::Attempt>]
320
+ # @return [Array<Agentilda::Runner::Attempt>]
321
+ def failures(attempts) = attempts.reject(&:ok)
212
322
 
213
323
  # The one thing this harness does that leaves the machine — pushing a
214
324
  # branch and opening its pull request — so it takes one more thing to
@@ -216,7 +326,7 @@ module Agentilda
216
326
  # isolation has to actually give each plan a branch of its own too.
217
327
  #
218
328
  # `nil` is how {Runner} is told to leave a finished worktree alone; it
219
- # is what `--dont-push-anything` asks for, and what a shared tree gets
329
+ # is what `--no-git-push` asks for, and what a shared tree gets
220
330
  # by construction, since there is no separate branch there to push.
221
331
  #
222
332
  # @param root [String]
@@ -224,35 +334,33 @@ module Agentilda
224
334
  # @param options [Hash]
225
335
  # @return [Agentilda::Publisher, nil]
226
336
  def publisher_for(root, isolation, options)
227
- return nil if isolation != :worktree || options[:dont_push_anything]
337
+ return nil if isolation != :worktree || options[:git_push] == false
228
338
 
229
339
  Publisher.new(root:, dry_run: !commit?(options))
230
340
  end
231
341
 
342
+ # One line per attempt: the plan, who ran, which of that agent's rounds
343
+ # it was, what moved, and the note. There is no round header any more
344
+ # because the loop has no rounds of its own: each agent counts its own
345
+ # per plan, so two agents on one plan can be on different numbers.
346
+ #
232
347
  # @param runner [Agentilda::Runner]
233
- # @param rounds [Array]
348
+ # @param attempts [Array<Agentilda::Runner::Attempt>]
234
349
  # @param options [Hash]
235
350
  # @param seconds [Float] wall clock for the whole loop
236
351
  # @return [void]
237
- def report(runner, rounds, options, seconds: 0.0)
238
- rounds.each do |round|
239
- puts "round #{round.number}"
240
- round.attempts.each do |a|
241
- mark = if !a.ok
242
- "FAIL"
243
- elsif a.advanced?
244
- "#{a.from} -> #{a.to}"
245
- else
246
- "no change"
247
- end
248
- puts " #{a.ordinal}\t#{a.agent}\t#{mark}\t#{a.note}"
249
- end
250
- end
352
+ def report(runner, attempts, options, seconds: 0.0)
353
+ # A leading blank line, because the spinners this replaces write to
354
+ # STDERR and the last of them may have left the cursor mid-line:
355
+ # without it the first attempt starts wherever that line stopped,
356
+ # indented by however much it had drawn.
357
+ puts ""
358
+ puts attempt_lines(attempts)
251
359
 
252
- # What the run cost, on STDOUT with the rounds it belongs to, so a run
360
+ # What the run cost, on STDOUT with the attempts it belongs to, so a run
253
361
  # redirected to a file keeps its bill. A dry run spent nothing and gets
254
362
  # none of this.
255
- tally = Tally.new(attempts: rounds.flat_map(&:attempts), seconds:, rounds: rounds.size)
363
+ tally = Tally.new(attempts:, seconds:)
256
364
  puts("", tally.render) if commit?(options)
257
365
 
258
366
  return if quiet?(options)
@@ -262,27 +370,27 @@ module Agentilda
262
370
  "re-run to resume where they stand.")
263
371
  end
264
372
 
265
- advanced = rounds.sum(&:advanced)
266
- blocked = runner.blocked
267
- summary = ["#{rounds.size} round#{"s" unless rounds.size == 1}", "#{advanced} advanced"]
373
+ advanced = attempts.count(&:advanced?)
374
+ blocked = runner.blocked
375
+ summary = ["#{attempts.size} invocation#{"s" unless attempts.size == 1}", "#{advanced} advanced"]
268
376
  summary << (runner.isolated? ? "#{runner.jobs} at a time, one worktree each" : "serial, shared tree")
269
377
  summary << "#{blocked.size} blocked" unless blocked.empty?
270
- summary << "#{failures(rounds).size} failed" unless failures(rounds).empty?
378
+ summary << "#{failures(attempts).size} failed" unless failures(attempts).empty?
271
379
 
272
380
  if !commit?(options)
273
381
  warn("Dry run — no agent was invoked.\n#{summary.join(" · ")}\n\nRe-run with --commit.")
274
- elsif failures(rounds).empty?
382
+ elsif failures(attempts).empty?
275
383
  success("#{summary.join(" · ")}#{"\n\nEvery plan is done or deliberately parked." if runner.settled?}")
276
384
  else
277
- error("#{summary.join(" · ")}\n\n#{failures(rounds).map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
385
+ error("#{summary.join(" · ")}\n\n#{failures(attempts).map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
278
386
  end
279
387
 
280
388
  return if blocked.empty?
281
389
 
282
390
  warn("#{blocked.size} plan#{"s" unless blocked.size == 1} need a human decision:\n" +
283
- blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
284
- "\n\nWrite the answers into blocked.md, then: agentilda unblock " \
285
- "#{blocked.map { |b| b.feature.ordinal }.join(",")} --commit")
391
+ blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
392
+ "\n\nWrite the answers into blocked.md, then: agentilda unblock " \
393
+ "#{blocked.map { |b| b.feature.ordinal }.join(",")} --commit")
286
394
  end
287
395
  end
288
396
  end
@@ -17,17 +17,21 @@ module Agentilda
17
17
 
18
18
  # What each token turned out to be, for whoever has to fix it.
19
19
  PROBLEMS = {
20
- missing: "no plan of that number",
20
+ missing: "no plan of that number",
21
21
  not_blocked: "no blocked.md, so there is nothing to drain"
22
22
  }.freeze
23
23
 
24
24
  desc "Fold answered blocks into a plan's documents and retire blocked.md"
25
25
 
26
- argument :plans, type: :array, required: true,
27
- desc: "Which plans to drain: NNN or NNN.MM, e.g. 003 005.01"
26
+ argument :plans,
27
+ type: :array,
28
+ required: true,
29
+ desc: "Which plans to drain: NNN or NNN.MM, e.g. 003 005.01"
28
30
 
29
- option :commit, type: :boolean, default: false,
30
- desc: "Actually invoke the agent (default: dry run, prints the questions still open)"
31
+ option :commit,
32
+ type: :boolean,
33
+ default: false,
34
+ desc: "Actually invoke the agent (default: dry run, prints the questions still open)"
31
35
  option :agent, default: DEFAULT_AGENT, desc: "Hand the folder to a different agent"
32
36
  option :root, desc: "Repository root the agent works in (default: the .plans parent)"
33
37
 
@@ -43,8 +47,11 @@ module Agentilda
43
47
  def call(plans:, **options)
44
48
  tree = tree_for(options)
45
49
  quiet?(options)
46
- unblocker = Unblocker.new(tree:, agent: agent_for(options), root: options[:root],
47
- commit: commit?(options), executor: Executor.new(root: options[:root] || File.dirname(tree.dir),
50
+ unblocker = Unblocker.new(tree:,
51
+ agent: agent_for(options),
52
+ root: options[:root],
53
+ commit: commit?(options),
54
+ executor: Executor.new(root: options[:root] || File.dirname(tree.dir),
48
55
  dry_run: !commit?(options)))
49
56
 
50
57
  targets = unblocker.resolve(plans)
@@ -55,7 +62,7 @@ module Agentilda
55
62
  credentials_warning if commit?(options) && !quiet?(options)
56
63
  preflight(subjects, unblocker, options)
57
64
 
58
- outcomes = unblocker.call(subjects)
65
+ outcomes = Control.on_interrupt { unblocker.call(subjects) }
59
66
  outcomes.each { |outcome| report(outcome, options) }
60
67
  footer(outcomes, options) unless quiet?(options)
61
68
  exit(worst(targets, outcomes))
@@ -110,7 +117,7 @@ module Agentilda
110
117
  # @param unblocker [Agentilda::Unblocker]
111
118
  # @param options [Hash]
112
119
  # @return [void]
113
- def preflight(subjects, unblocker, options)
120
+ def preflight(subjects, _unblocker, options)
114
121
  return if quiet?(options)
115
122
 
116
123
  subjects.each do |subject|
@@ -190,7 +197,7 @@ module Agentilda
190
197
 
191
198
  unless commit?(options)
192
199
  warn("Dry run: no agent was invoked, and #{outcomes.size} " \
193
- "plan#{"s" unless outcomes.size == 1} #{(outcomes.size == 1) ? "is" : "are"} unchanged.\n" \
200
+ "plan#{"s" unless outcomes.size == 1} #{outcomes.size == 1 ? "is" : "are"} unchanged.\n" \
194
201
  "Re-run with --commit to fold in whatever has been answered.")
195
202
  return
196
203
  end
@@ -203,9 +210,13 @@ module Agentilda
203
210
  success("#{folded} question#{"s" unless folded == 1} folded in · " \
204
211
  "#{cleared} plan#{"s" unless cleared == 1} out of the block · " \
205
212
  "#{waiting} still waiting on a human." +
206
- (folded.zero? ? "\n\nNothing moved. An answer has to be written into blocked.md as its own " \
207
- "`## A<n>` section, answering the `## B<n>` of the same number, before " \
208
- "there is anything to fold." : ""))
213
+ (if folded.zero?
214
+ "\n\nNothing moved. An answer has to be written into blocked.md as its own " \
215
+ "`## A<n>` section, answering the `## B<n>` of the same number, before " \
216
+ "there is anything to fold."
217
+ else
218
+ ""
219
+ end))
209
220
  end
210
221
 
211
222
  # The most serious thing that happened, as an exit status. A tree that
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda worktree` — create and seed worktrees for plans.
6
+ class Worktree < Base
7
+ desc "Create and seed a worktree for a plan"
8
+
9
+ argument :plan,
10
+ required: true,
11
+ desc: "Plan ordinal (e.g. 002, 002.00, or a plan folder name)"
12
+
13
+ option :skip_seed,
14
+ type: :boolean,
15
+ default: false,
16
+ desc: "Create the worktree but skip seeding it with .env and credential files"
17
+
18
+ example [
19
+ "002 # create/seed the worktree for plan 002.00",
20
+ "002.01 # create/seed the worktree for plan 002.01",
21
+ "002.00-⚪️ → update-dsl # find the plan by its folder name",
22
+ "--skip-seed 003 # create the worktree but don't seed it"
23
+ ]
24
+
25
+ # @param plan [String] plan identifier
26
+ # @param options [Hash]
27
+ # @return [void]
28
+ def call(plan:, **options)
29
+ tree = tree_for(options)
30
+ is_quiet = quiet?(options)
31
+
32
+ feature = resolve_plan(tree, plan)
33
+ unless feature
34
+ refuse("Could not find plan: #{plan}", 66)
35
+ end
36
+
37
+ root = File.dirname(tree.dir)
38
+ worktree = ::Agentilda::Worktree.new(root:)
39
+
40
+ checkout = worktree.checkout_for(feature)
41
+
42
+ return if is_quiet
43
+
44
+ if checkout.created?
45
+ puts("✓ Created worktree for #{feature.ordinal}: #{checkout.path}")
46
+ else
47
+ puts("✓ Using existing worktree for #{feature.ordinal}: #{checkout.path}")
48
+ end
49
+
50
+ unless options[:skip_seed]
51
+ seeded = worktree.seed(checkout.path)
52
+ if seeded
53
+ puts("✓ Seeded with .env and credential files")
54
+ else
55
+ warn("⚠ Could not seed worktree, see above for details")
56
+ end
57
+ end
58
+
59
+ puts("")
60
+ success("Worktree ready for #{feature.ordinal}")
61
+ puts("Path: #{checkout.path}")
62
+ puts("Branch: #{checkout.branch}")
63
+ end
64
+
65
+ private
66
+
67
+ # Find the plan that matches the given identifier.
68
+ #
69
+ # @param tree [Agentilda::Tree] plans directory wrapper
70
+ # @param plan [String] ordinal, folder name, or slug
71
+ # @return [Agentilda::Feature, nil]
72
+ def resolve_plan(tree, plan)
73
+ # Try parsing as an ordinal first
74
+ ordinal = ::Agentilda::Ordinal.parse(plan)
75
+ if ordinal
76
+ found = tree.features.find { |f| f.ordinal == ordinal }
77
+ return found if found
78
+ end
79
+
80
+ # Try matching by folder name
81
+ tree.features.find { |f| f.dirname == plan }
82
+ end
83
+ end
84
+ end
85
+ end