agentilda 1.0.3 → 2.0.0

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 +8 -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 +78 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +22 -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 +61 -15
  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,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require "tmpdir"
6
+
3
7
  module Agentilda
4
8
  # Runs one agent against one plan by shelling out to the `claude` CLI.
5
9
  #
@@ -12,13 +16,6 @@ module Agentilda
12
16
  # appeared. A prompt is a request; a check is a guarantee, and only
13
17
  # one of them survives a model deciding it knows better.
14
18
  class Executor
15
- # Raised from inside the streaming block to stop an invocation
16
- # mid-flight — the token budget crossed, or the grace period after `q`
17
- # run out. TTY::Command's reader thread re-raises it out of `run`, and
18
- # its `ensure` terminates the child on the way, so raising here is how
19
- # the child is killed rather than merely abandoned.
20
- class Aborted < StandardError; end
21
-
22
19
  # What one invocation did, and what it spent doing it.
23
20
  #
24
21
  # {#to_ary} is deliberate: every caller of {Executor#call} destructures
@@ -41,11 +38,65 @@ module Agentilda
41
38
  # total rather than as a direction of its own
42
39
  # @!attribute [r] seconds
43
40
  # @return [Float] wall clock, from argv to exit
44
- Result = Data.define(:ok, :note, :up, :down, :subagents, :delegated, :seconds) do
41
+ # @!attribute [r] killed
42
+ # @return [Symbol, nil] :timeout when the clock's backstop fired, :key
43
+ # when somebody pressed k, :budget when the token meter crossed the
44
+ # cap, :quit when the grace period after q ran out, nil otherwise
45
+ # @!attribute [r] pid
46
+ # @return [Integer, nil]
47
+ # @return [Data]
48
+ Result = Data.define(:ok, :note, :up, :down, :subagents, :delegated, :seconds, :killed, :pid) do
49
+ def initialize(killed: nil, pid: nil, **rest) = super
50
+
45
51
  # @return [Array(Boolean, String)]
46
52
  def to_ary = [ok, note]
47
53
  end
48
54
 
55
+ # What the dispatcher holds on a running invocation, so a keypress can
56
+ # reach it: the process, its clock and its control file.
57
+ #
58
+ # Filled in by {#call} once the child exists. Every method tolerates the
59
+ # gap before that, because the keyboard does not wait.
60
+ class Handle
61
+ # @return [Agentilda::Child, nil]
62
+ attr_accessor :child
63
+
64
+ # @return [Agentilda::Clock, nil]
65
+ attr_accessor :clock
66
+
67
+ # @return [String, nil]
68
+ attr_accessor :control
69
+
70
+ # @return [Symbol, nil] why the process was killed, once it was
71
+ attr_reader :killed
72
+
73
+ # @return [Integer, nil]
74
+ def pid = child&.pid
75
+
76
+ # @return [Integer, nil]
77
+ def remaining = clock&.remaining
78
+
79
+ # @return [Symbol, nil]
80
+ def phase = clock&.phase
81
+
82
+ # STOP, a grace period, then SIGKILL if it is still there. The grace is
83
+ # what lets an agent mid-write finish the line.
84
+ #
85
+ # @param grace [Integer] seconds
86
+ # @param reason [Symbol] :key, :timeout, :budget or :quit
87
+ # @return [void]
88
+ def kill!(grace: 15, reason: :key)
89
+ @killed ||= reason
90
+ Control.write(control, Control::STOP) if control
91
+ sleep(grace) if grace.positive?
92
+ child&.kill("KILL") if child&.alive?
93
+ end
94
+
95
+ # @param seconds [Integer]
96
+ # @return [void]
97
+ def extend!(seconds) = clock&.extend!(seconds)
98
+ end
99
+
49
100
  # Tools no agent may use under this autonomy level, whatever its definition
50
101
  # asks for. Git itself is reachable through Bash, which is why the
51
102
  # after-check exists as well.
@@ -81,15 +132,12 @@ module Agentilda
81
132
  # `hansolo-reviewer` approving and `hansolo-reviewer` merging.
82
133
  UNGRANTABLE = ["git push", "gh pr merge"].freeze
83
134
 
84
- # The `stdout:` and `stderr:` sections of a {TTY::Command::ExitError}
85
- # message. stdout runs until stderr starts; stderr runs to the end, because
86
- # what an agent prints there is not guaranteed to be one line.
87
- STDOUT_SECTION = /^[ \t]*stdout:[ \t]*(.*?)(?=\n[ \t]*stderr:|\z)/m
88
- STDERR_SECTION = /^[ \t]*stderr:[ \t]*(.*)\z/m
89
-
90
135
  # How much of what the agent said survives into a one-line report.
91
136
  REASON_LIMIT = 300
92
137
 
138
+ # Seconds an agent gets when neither its frontmatter nor `--timeout` says.
139
+ DEFAULT_TIMEOUT = 900
140
+
93
141
  # Where the raw stream of each invocation is kept.
94
142
  #
95
143
  # Under `run -j` several agents work at once, and more than one
@@ -129,106 +177,11 @@ module Agentilda
129
177
  CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
130
178
  end
131
179
 
132
- # Guards {.claim_child}'s registry: under `-j` several invocations spawn
133
- # at once, and two of them finding the same fresh child would put one pid
134
- # on two spinner lines.
135
- CHILDREN_MUTEX = Mutex.new
136
- @claimed_children = []
137
-
138
- # The pid of a `claude` child this process spawned and nobody has claimed
139
- # yet, so a spinner line can name the process it is narrating.
140
- #
141
- # TTY::Command never exposes the pid it spawned, so this reads the
142
- # process table instead: direct children of this process whose command is
143
- # `claude`. With several invocations racing, first-come order cannot say
144
- # which child belongs to which caller — a claimed pid might in principle
145
- # label a sibling's line — which is why the pid decorates the UI and is
146
- # never used to signal or kill anything.
147
- #
148
- # @param parent [Integer]
149
- # @param listing [String, nil] `ps` output, injectable for the suite
150
- # @return [Integer, nil] nil when no unclaimed child is found
151
- def self.claim_child(parent: Process.pid, listing: nil)
152
- listing ||= `ps -ax -o pid=,ppid=,command= 2>/dev/null`
153
- CHILDREN_MUTEX.synchronize do
154
- pid = listing.lines.filter_map { |line|
155
- child, ppid, command = line.strip.split(/\s+/, 3)
156
- child.to_i if ppid.to_i == parent && command.to_s.match?(%r{(\A|/)claude(\s|\z)})
157
- }.find { |candidate| !@claimed_children.include?(candidate) }
158
- @claimed_children << pid if pid
159
- pid
160
- end
161
- end
162
-
163
- # Forget a finished invocation's pid, so the registry does not grow for
164
- # the life of a long run and a recycled pid stays claimable.
165
- #
166
- # @param pid [Integer, nil]
167
- # @return [void]
168
- def self.release_child(pid)
169
- CHILDREN_MUTEX.synchronize { @claimed_children.delete(pid) } if pid
170
- end
171
-
172
- # What `claude` said, out of the four labelled sections
173
- # {TTY::Command::ExitError} builds its message from.
174
- #
175
- # The first of those sections is the command line, which for an agent is a
176
- # shell-escaped copy of its several-thousand-character prompt. Reporting it
177
- # said that an invocation had failed, at length, and nothing at all about
178
- # why. The run that found this printed the same escaped prompt ten times
179
- # while the answer, `401 API key is invalid`, sat unread in `stdout:`.
180
- #
181
- # This keeps both streams, because they carry different halves. `claude`
182
- # reports its own failures on stdout; the line naming the *cause* of that
183
- # 401 (`ANTHROPIC_API_KEY … takes precedence over your claude.ai login`)
184
- # was on stderr.
185
- #
186
- # @param error [TTY::Command::ExitError]
187
- # @return [String]
188
- def self.failure_reason(error)
189
- status = error.message[/^[ \t]*exit status:[ \t]*(\S+)/, 1]
190
- outcome = status ? "exited #{status}" : "failed"
191
- said = [STDOUT_SECTION, STDERR_SECTION]
192
- .filter_map { |section| tail(error.message[section, 1]) }
193
- .join(" | ")
194
-
195
- said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}"
196
- end
197
-
198
- # @param text [String, nil]
199
- # @return [String, nil] the last few meaningful lines, on one line
200
- def self.tail(text)
201
- lines = text.to_s.split("\n").map(&:strip).reject { |line| line.empty? || line == "Nothing written" }
202
- return nil if lines.empty?
203
-
204
- joined = lines.last(3).join(" ")
205
- (joined.length > REASON_LIMIT) ? "#{joined[0, REASON_LIMIT - 1]}…" : joined
206
- end
207
- private_class_method :tail
208
-
209
- # What went wrong, preferring what the stream managed to parse.
210
- #
211
- # `claude` reports its own failures two different ways. A run that got far
212
- # enough emits a `result` event saying so, and that is the readable one. A
213
- # run that failed before it started — the 401 that cost a whole round three
214
- # minutes an agent — prints prose on stdout and never emits an event at
215
- # all, so those lines are what {Transcript#plain} holds and what is left to
216
- # report. {.failure_reason} stays the last resort, for a failure that
217
- # printed nothing either way.
218
- #
219
- # @param error [TTY::Command::ExitError]
220
- # @param transcript [Agentilda::Transcript]
221
- # @return [String]
222
- def reason_for(error, transcript)
223
- return "failed: #{transcript.error}" if transcript.failed?
224
-
225
- said = transcript.plain.last(3).join(" ")
226
- said.empty? ? self.class.failure_reason(error) : "failed: #{said}"
227
- end
228
-
229
180
  # @param root [String] the repository the agents work in
230
- # @param command [TTY::Command]
231
- # @param timeout [Integer] seconds before one agent is abandoned
181
+ # @param spawn [Proc] `argv, chdir:` → {Agentilda::Child}, swappable so the
182
+ # suite can play back a fake process without spawning one
183
+ # @param timeout [Integer, nil] `--timeout`: a cap on every agent's clock,
184
+ # or nil to let each agent's own frontmatter clock stand
232
185
  # @param dry_run [Boolean] plan the invocation, do not run it
233
186
  # @param trace_dir [String] where each invocation's raw stream is kept
234
187
  # @param instructions [String, nil] what `run --prompt` typed, appended
@@ -241,14 +194,14 @@ module Agentilda
241
194
  # output, sub-agents included. The prompt states it so the agent can
242
195
  # plan to finish inside it, and the meter enforces it so the statement
243
196
  # is true. nil is unmetered.
244
- # @param interactive [Boolean] whether someone is at the keyboard. Only
245
- # then does each invocation get a control file, because a prompt that
246
- # says "poll this file" when nothing will ever write to it is asking
247
- # for wasted reads all run long.
248
- def initialize(root:, command: TTY::Command.new(printer: :null), timeout: 900, dry_run: false,
197
+ # @param interactive [Boolean] whether someone is at the keyboard, which
198
+ # the keyboard help reads. Every invocation gets a control file either
199
+ # way, because the clock writes its own warnings into it regardless of
200
+ # whether anyone is watching.
201
+ def initialize(root:, spawn: Child.method(:spawn), timeout: nil, dry_run: false,
249
202
  trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false)
250
203
  @root = File.expand_path(root)
251
- @command = command
204
+ @spawn = spawn
252
205
  @timeout = timeout
253
206
  @dry_run = dry_run
254
207
  @trace_dir = trace_dir
@@ -261,73 +214,89 @@ module Agentilda
261
214
  # @return [String]
262
215
  attr_reader :root
263
216
 
264
- # The seconds this agent gets before it is abandoned: its own
265
- # `timeout:` frontmatter when it declares one, the run-wide default
266
- # otherwise. Public so the UI can count the same clock down that this
267
- # class will enforce — two clocks is how a timer hits zero and the
268
- # agent keeps running.
217
+ # The clock this agent runs against: the tighter of its own frontmatter
218
+ # and `--timeout`. A flag that could only loosen was useless the day
219
+ # somebody wanted a quick run.
220
+ #
221
+ # {DEFAULT_TIMEOUT} steps in only when neither names a figure. It used
222
+ # to be the flag's default instead, which silently cut every 1200-second
223
+ # agent to 900 on the runs where nobody had typed `--timeout` at all.
269
224
  #
270
225
  # @param agent [Agentilda::Agent]
271
226
  # @return [Integer]
272
- def timeout_for(agent) = agent.timeout || @timeout
227
+ def timeout_for(agent) = [agent.timeout, @timeout].compact.min || DEFAULT_TIMEOUT
273
228
 
274
229
  # @param agent [Agentilda::Agent]
275
230
  # @param subject [Agentilda::Subject]
276
- # @return [Agentilda::Executor::Result] whether it worked, a one-line
277
- # note, and what it spent. Destructures as `ok, note` for callers that
278
- # want no more than that.
279
- # @yieldparam progress [Agentilda::Transcript::Progress] what the
280
- # agent is doing and what it has spent, as both change
281
- def call(agent, subject, root: @root, &on_progress)
231
+ # @param root [String] the checkout the agent works in
232
+ # @param round [Integer] which attempt on this plan this is
233
+ # @param successor [String, nil] who the agent names on its `next:` line
234
+ # @param handle [Agentilda::Executor::Handle, nil] filled in for the caller
235
+ # @param partners [Array<Agentilda::Agent>] the other agents working this
236
+ # plan right now, named in the prompt beside the plan's mailbox
237
+ # @return [Agentilda::Executor::Result]
238
+ # @yieldparam progress [Agentilda::Transcript::Progress]
239
+ def call(agent, subject, root: @root, round: 1, successor: nil, handle: nil, partners: [], &on_progress)
282
240
  started = UI.monotonic
283
241
  if @dry_run
284
- return Result.new(ok: true, note: "dry run — would invoke #{agent.name}", up: 0, down: 0,
285
- subagents: 0, delegated: 0, seconds: 0.0)
242
+ return Result.new(ok: true,
243
+ note: "dry run - would invoke #{agent.name}",
244
+ up: 0,
245
+ down: 0,
246
+ subagents: 0,
247
+ delegated: 0,
248
+ seconds: 0.0)
286
249
  end
287
250
 
251
+ handle ||= Handle.new
288
252
  before = head(root)
289
253
  trace = trace_path(agent, subject)
290
254
  transcript = Transcript.new(trace:, &on_progress)
291
- control = (Control.register(@trace_dir, "#{subject.feature.ordinal}-#{agent.name}") if @interactive)
292
-
293
- timeout = timeout_for(agent)
294
- begin
295
- hunted = false
296
- @command.run(*invocation(agent, subject, root:, control:), timeout:) do |out, _err|
297
- # Once, on the first chunk: the child exists by the time it has
298
- # produced output, and a `ps` per chunk would be a `ps` per token.
299
- unless hunted
300
- hunted = true
301
- transcript.pid = self.class.claim_child
302
- end
303
- transcript.push(out)
304
- abort_if_over(transcript)
305
- end
306
- transcript.finish
307
- rescue Aborted => e
308
- transcript.finish
309
- return failure(transcript, started, "aborted: #{e.message} — trace: #{trace}")
310
- rescue TTY::Command::TimeoutExceeded
311
- transcript.finish
312
- return failure(transcript, started,
313
- "timed out after #{timeout}s, last seen #{transcript.activity || "starting up"} — trace: #{trace}")
314
- rescue TTY::Command::ExitError => e
315
- transcript.finish
316
- return failure(transcript, started, "claude #{reason_for(e, transcript)} — trace: #{trace}")
317
- ensure
318
- Control.release(control) if control
319
- self.class.release_child(transcript.pid)
255
+ control = Control.register(@trace_dir, "#{subject.feature.ordinal}-#{agent.name}")
256
+ handle.control = control
257
+ argv = invocation(agent, subject, root:, control:, round:, successor:, partners:)
258
+
259
+ child = @spawn.call(argv, chdir: root)
260
+ handle.child = child
261
+ transcript.pid = child.pid
262
+ clock = Clock.new(seconds: timeout_for(agent),
263
+ control:,
264
+ on_expire: -> { handle.kill!(grace: 0, reason: :timeout) })
265
+ handle.clock = clock
266
+ clock.start
267
+
268
+ child.each_chunk do |out|
269
+ transcript.push(out)
270
+ abort_if_over(transcript, handle)
271
+ end
272
+ status = child.wait
273
+ transcript.finish
274
+ clock.stop
275
+ Control.release(control)
276
+
277
+ if handle.killed
278
+ return spent(transcript,
279
+ started,
280
+ ok: false,
281
+ pid: child.pid,
282
+ killed: handle.killed,
283
+ note: "#{killed_note(handle.killed, clock)}, last seen #{transcript.activity || "starting up"} - trace: #{trace}")
284
+ end
285
+ unless status.success?
286
+ return failure(transcript, started, "claude exited #{status.exitstatus}: #{said(transcript)} - trace: #{trace}", pid: child.pid)
320
287
  end
321
-
322
288
  if transcript.failed?
323
- return failure(transcript, started, "claude reported: #{transcript.error} trace: #{trace}")
289
+ return failure(transcript, started, "claude reported: #{transcript.error} - trace: #{trace}", pid: child.pid)
324
290
  end
325
291
 
326
292
  violation = boundary_violation(before, root)
327
- return failure(transcript, started, violation) if violation
293
+ return failure(transcript, started, violation, pid: child.pid) if violation
328
294
 
329
- spent(transcript, started, ok: true,
330
- note: "completed#{" · #{transcript.tools} tool calls" if transcript.tools.positive?}")
295
+ spent(transcript,
296
+ started,
297
+ ok: true,
298
+ pid: child.pid,
299
+ note: "completed#{" - #{transcript.tools} tool calls" if transcript.tools.positive?}")
331
300
  end
332
301
 
333
302
  # A failed invocation still spent what it spent, and a run that burned two
@@ -335,21 +304,41 @@ module Agentilda
335
304
  # that failed to authenticate and spent nothing. Both used to report the
336
305
  # same thing.
337
306
  #
338
- # @param transcript [Agentilda::Transcript]
339
- # @param started [Float]
340
- # @param note [String]
341
307
  # @return [Agentilda::Executor::Result]
342
- def failure(transcript, started, note) = spent(transcript, started, ok: false, note:)
308
+ def failure(transcript, started, note, pid: nil) = spent(transcript, started, ok: false, note:, pid:)
343
309
 
344
- # @param transcript [Agentilda::Transcript]
345
- # @param started [Float]
346
- # @param ok [Boolean]
347
- # @param note [String]
348
310
  # @return [Agentilda::Executor::Result]
349
- def spent(transcript, started, ok:, note:)
350
- Result.new(ok:, note:, up: transcript.up, down: transcript.down,
351
- subagents: transcript.spawned, delegated: transcript.delegated,
352
- seconds: UI.monotonic - started)
311
+ def spent(transcript, started, ok:, note:, pid: nil, killed: nil)
312
+ Result.new(ok:,
313
+ note:,
314
+ up: transcript.up,
315
+ down: transcript.down,
316
+ subagents: transcript.spawned,
317
+ delegated: transcript.delegated,
318
+ seconds: UI.monotonic - started,
319
+ killed:,
320
+ pid:)
321
+ end
322
+
323
+ # @param transcript [Agentilda::Transcript]
324
+ # @return [String] the last few plain lines, clipped
325
+ def said(transcript)
326
+ text = (transcript.failed? ? transcript.error : transcript.plain.last(3).join(" ")).to_s.strip
327
+ return "said nothing" if text.empty?
328
+
329
+ text.length > REASON_LIMIT ? "#{text[0, REASON_LIMIT - 1]}..." : text
330
+ end
331
+
332
+ # @param reason [Symbol]
333
+ # @param clock [Agentilda::Clock]
334
+ # @return [String]
335
+ def killed_note(reason, _clock)
336
+ case reason
337
+ when :timeout then "timed out (killed #{Clock::GRACE}s after STOP)"
338
+ when :budget then "token budget of #{@max_tokens} exceeded"
339
+ when :quit then "still running after #{Control.interrupted? ? "ctrl-c" : "q"}, killed once the grace period ran out"
340
+ else "killed from the keyboard"
341
+ end
353
342
  end
354
343
 
355
344
  # The exact argv, exposed so a spec can assert the boundary flags without
@@ -357,19 +346,23 @@ module Agentilda
357
346
  #
358
347
  # @param agent [Agentilda::Agent]
359
348
  # @param subject [Agentilda::Subject]
349
+ # @param round [Integer] which attempt on this plan this is
350
+ # @param successor [String, nil] who the agent names on its `next:` line
351
+ # @param partners [Array<Agentilda::Agent>]
360
352
  # @return [Array<String>]
361
- def invocation(agent, subject, root: @root, control: nil)
353
+ def invocation(agent, subject, root: @root, control: nil, round: 1, successor: nil, partners: [])
362
354
  # `--include-partial-messages` is what the token meter runs on. Without
363
355
  # it the stream reports a settled input count and a placeholder output
364
356
  # count — 2 for a four-thousand-token answer — and a spinner counting
365
357
  # what came back would read zero all run. See {Transcript#meter}.
366
- argv = ["claude", "-p", prompt_for(agent, subject, root, control:), "--add-dir", root,
367
- "--output-format", "stream-json", "--verbose", "--include-partial-messages"]
358
+ argv = ["claude", "-p", prompt_for(agent, subject, root, control:, round:, successor:, partners:), "--add-dir", root,
359
+ "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--brief"]
368
360
  denied = denied_for(agent)
369
361
  argv += ["--disallowedTools", denied.join(",")] unless denied.empty?
370
362
  argv += ["--allowedTools", agent.allowed_tools.join(",")] unless agent.allowed_tools.empty?
371
363
  model = @model || agent.model
372
364
  argv += ["--model", model] if model
365
+ argv += ["--effort", agent.effort] if agent.effort
373
366
  argv
374
367
  end
375
368
 
@@ -404,15 +397,22 @@ module Agentilda
404
397
  # @return [String]
405
398
  def trace_path(agent, subject)
406
399
  FileUtils.mkdir_p(@trace_dir)
407
- name = format("%s-%s-%s-%d-%04x.ndjson", Time.now.strftime("%Y%m%d-%H%M%S"),
408
- subject.feature.ordinal, agent.name, Process.pid, rand(0x10000))
400
+ name = format("%s-%s-%s-%d-%04x.ndjson",
401
+ Time.now.strftime("%Y%m%d-%H%M%S"),
402
+ subject.feature.ordinal,
403
+ agent.name,
404
+ Process.pid,
405
+ rand(0x10000))
409
406
  File.join(@trace_dir, name)
410
407
  end
411
408
 
412
409
  # @param agent [Agentilda::Agent]
413
410
  # @param subject [Agentilda::Subject]
411
+ # @param round [Integer]
412
+ # @param successor [String, nil]
413
+ # @param partners [Array<Agentilda::Agent>]
414
414
  # @return [String]
415
- def prompt_for(agent, subject, root = @root, control: nil)
415
+ def prompt_for(agent, subject, root = @root, control: nil, round: 1, successor: nil, partners: [])
416
416
  <<~PROMPT
417
417
  #{agent.prompt}
418
418
 
@@ -426,7 +426,7 @@ module Agentilda
426
426
  Repository root: #{root}
427
427
 
428
428
  #{"The folder's name is not currently justified: #{subject.violation}" if subject.violation}
429
- #{operator_instructions}#{budget_section}#{control_section(control)}
429
+ #{operator_instructions}#{ledger_section(agent, round:, successor:)}#{budget_section}#{time_budget_section(agent)}#{control_section(control, agent, subject)}#{mailbox_section(agent, subject, partners)}
430
430
  ## Boundary — enforced, not requested
431
431
 
432
432
  You may read anything, and write source, tests and the plan's own
@@ -441,24 +441,70 @@ module Agentilda
441
441
  moved it is reported as a failure and rolled into the report. A prompt
442
442
  is a request; a check is a guarantee.
443
443
 
444
- Claim what you are about to write with ~/.claude/agent-lock.sh first,
445
- and release it when you are done.
444
+ Claim each directory or file before you write it, and release it when
445
+ that write is done. Name yourself on every call, since `alo` would
446
+ otherwise sign with a fingerprint your sub-agents share; give each
447
+ sub-agent its own suffix, e.g. `AGENT_ID=#{agent.name}-schema`:
448
+
449
+ AGENT_ID=#{agent.name} alo acquire <path> "<why>"
450
+ AGENT_ID=#{agent.name} alo release <path>
451
+
452
+ A refused `acquire` means another agent holds it: work on something
453
+ else, never write it anyway. Before your closing ledger line, run
454
+ `AGENT_ID=#{agent.name} alo release-all`.
446
455
  PROMPT
447
456
  end
448
457
 
449
- # The budget crossed, or the grace period after `q` spent — checked as
450
- # each chunk of output arrives, which is as often as an agent can be
451
- # observed at all. An agent producing nothing is the timeout's problem.
458
+ # The one paragraph every agent gets, identically, about the ledger. It
459
+ # lives here rather than in seven definition files so the wording cannot
460
+ # drift between agents, and so the names, the round and the successor are
461
+ # the harness's facts rather than the agent's guesses.
462
+ #
463
+ # @param agent [Agentilda::Agent]
464
+ # @param round [Integer]
465
+ # @param successor [String, nil]
466
+ # @return [String]
467
+ def ledger_section(agent, round:, successor:)
468
+ documents = agent.ledger.map { |f| "`#{f}`" }.join(", then ")
469
+ handoff = successor ? "\n > [<now>] [ next: #{successor} ]" : ""
470
+ <<~SECTION
471
+
472
+ ## The ledger - write this at the start and at the end
473
+
474
+ You sign the document you are working in: #{documents}. Get `<now>` from
475
+ `date "+%Y-%m-%d %I:%M:%S %p %Z"`. Before you do any work, append:
476
+
477
+ > [!NOTE]
478
+ >
479
+ > [<now>] [ agent: #{agent.name} status: Started, round #{round} ]
480
+
481
+ When you finish, append:
482
+
483
+ > [!NOTE]
484
+ >
485
+ > [<now>] [ agent: #{agent.name} status: Completed, round #{round} ]#{handoff}
486
+
487
+ Write `Completed` only if your assignment is genuinely done. Otherwise write
488
+ `Almost completed`, `Interrupted` or `Blocked` in its place and NO `next:` line.
489
+ The harness renames the plan folder and starts the next agent from these
490
+ lines; you never rename the folder yourself. A short note in parentheses
491
+ after the round is welcome, e.g. `Completed, round 1 (approved)`.
492
+ SECTION
493
+ end
494
+
495
+ # The token budget crossed, or the grace period after `q` spent. Both go
496
+ # through the handle so the kill is the same kill a keypress makes.
452
497
  #
453
498
  # @param transcript [Agentilda::Transcript]
499
+ # @param handle [Agentilda::Executor::Handle]
454
500
  # @return [void]
455
- # @raise [Agentilda::Executor::Aborted]
456
- def abort_if_over(transcript)
501
+ def abort_if_over(transcript, handle)
457
502
  spent = transcript.up + transcript.down
458
503
  if @max_tokens&.positive? && spent > @max_tokens
459
- raise Aborted, "token budget of #{@max_tokens} exceeded (↑#{transcript.up} ↓#{transcript.down})"
504
+ handle.kill!(grace: 0, reason: :budget)
505
+ elsif Control.overdue?
506
+ handle.kill!(grace: 0, reason: :quit)
460
507
  end
461
- raise Aborted, "still running #{Control::GRACE}s after q" if Control.overdue?
462
508
  end
463
509
 
464
510
  # The section `run --max-tokens` adds. Stating the number is what lets
@@ -477,20 +523,132 @@ module Agentilda
477
523
  "out. Anything unwritten at the cap is lost.\n"
478
524
  end
479
525
 
480
- # The section a control file adds, present only when someone is at the
481
- # keyboard to write into it.
526
+ # The section describing the advisory clock this invocation runs against,
527
+ # stated in the prompt so an agent can pace itself. The number is
528
+ # whatever {#timeout_for} will actually enforce, so the prompt and the
529
+ # clock can never disagree — an agent whose prose names its own figure
530
+ # goes stale the first time someone passes `--timeout`, and stale is
531
+ # worse than silent.
532
+ #
533
+ # @param agent [Agentilda::Agent]
534
+ # @return [String]
535
+ def time_budget_section(agent)
536
+ seconds = timeout_for(agent)
537
+ return "" unless seconds&.positive?
538
+
539
+ minutes = (seconds / 60.0).round
540
+ "\n## Time budget - #{seconds} seconds\n\n" \
541
+ "You have about #{minutes} minute#{"s" unless minutes == 1} of wall clock. The control " \
542
+ "file below tells you how it is going: `WARN: 10 minutes left`, `WARN: 5 minutes left`, " \
543
+ "`WRAP_UP: 1 minute left, write to disk now`, then `STOP`. Sixty seconds after STOP " \
544
+ "the process is killed, and anything unwritten is lost. Write each result to disk as " \
545
+ "you reach it, and write your closing ledger line before anything else once you see " \
546
+ "WRAP_UP.#{concurrency_advice(agent)}\n"
547
+ end
548
+
549
+ # Only worth saying to an agent that can actually do it. Telling an agent
550
+ # without `Task` to parallelise is telling it to feel bad about a tool it
551
+ # was not given.
552
+ #
553
+ # @param agent [Agentilda::Agent]
554
+ # @return [String]
555
+ def concurrency_advice(agent)
556
+ return "" unless agent.allowed_tools.include?("Task")
557
+
558
+ " Where the work divides into parts that do not read each other's output, run them " \
559
+ "as one wave of concurrent sub-agents rather than in series: the wave costs one " \
560
+ "part's wall clock, and the series costs the sum of all of them."
561
+ end
562
+
563
+ # Two agents on one plan are two processes. Naming the partner and the
564
+ # exact commands is what replaced guessing the partner's session from
565
+ # every Claude session on the machine.
566
+ #
567
+ # @param agent [Agentilda::Agent]
568
+ # @param subject [Agentilda::Subject]
569
+ # @param partners [Array<Agentilda::Agent>]
570
+ # @return [String]
571
+ def mailbox_section(agent, subject, partners)
572
+ return "" if partners.empty?
573
+
574
+ plans_dir = File.dirname(subject.feature.path)
575
+ plan = subject.feature.ordinal
576
+ names = partners.map { |p| "`#{p.name}`" }
577
+ who = names.size == 1 ? "Your partner on this plan is #{names.first}" : "Your partners on this plan are #{names.join(" and ")}"
578
+
579
+ <<~SECTION
580
+
581
+ ## Mailbox - poll it between steps
582
+
583
+ #{who}. You share one worktree and one branch, but not a process, so
584
+ the only way to reach each other is the plan's mailbox:
585
+
586
+ #{File.join(subject.feature.path, Mailbox::FILENAME)}
587
+
588
+ Read it before each significant step and whenever you finish a unit:
589
+
590
+ agentilda mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
591
+
592
+ Pass `--after N`, with the number of the last message you have read,
593
+ to see only what is new. Write to it when you land an interface your
594
+ partner is waiting on, when you amend the contract, when you need
595
+ something from their half, and when you finish:
596
+
597
+ agentilda mail send --dir "#{plans_dir}" --plan #{plan} --from #{agent.name} --to #{partners.first.name} "what you need them to know"
598
+
599
+ Every message is appended with a number and a timestamp and never
600
+ edited, so a person can read the exchange after the round. A question
601
+ your partner has not answered within a few steps is not a reason to
602
+ stop: write your assumption into implementation-plan.md and carry on.
603
+ SECTION
604
+ end
605
+
606
+ # The section a control file adds. Present on every invocation, because
607
+ # the advisory clock writes into it whether or not anyone is watching.
608
+ #
609
+ # INTERRUPT is what makes a restart after Ctrl-C safe: the agent that
610
+ # was cut off leaves a mailbox note to whoever picks the plan up, and
611
+ # every agent is told to read its mail first, so the next run resumes
612
+ # from the note rather than redoing work already on disk.
482
613
  #
483
614
  # @param control [String, nil]
615
+ # @param agent [Agentilda::Agent]
616
+ # @param subject [Agentilda::Subject]
484
617
  # @return [String]
485
- def control_section(control)
618
+ def control_section(control, agent, subject)
486
619
  return "" if control.nil?
487
620
 
488
- "\n## Control file — poll it between steps\n\n" \
489
- " #{control}\n\n" \
490
- "Read this file before each significant step. Empty means carry on. " \
491
- "A line saying WRAP_UP means finish the essential remainder as fast " \
492
- "as possible. STOP means write what you have to disk, note where you " \
493
- "stopped in the plan folder's markdown, and end your turn now.\n"
621
+ plans_dir = File.dirname(subject.feature.path)
622
+ plan = subject.feature.ordinal
623
+ <<~SECTION
624
+
625
+ ## Control file poll it between steps
626
+
627
+ #{control}
628
+
629
+ Read this file before each significant step. Empty means carry on.
630
+ A line starting WARN: tells you how much time is left. WRAP_UP: means
631
+ finish the essential remainder now. STOP means write what you have,
632
+ write your ledger line, and end your turn.
633
+
634
+ INTERRUPT means the operator pressed Ctrl-C and the run will be started
635
+ again later. Start nothing new. Finish the write you are in the middle
636
+ of, so no file is left half-written, then leave a resume note for
637
+ whoever runs next — most likely you — saying what is done, what is
638
+ not, and the exact next step:
639
+
640
+ agentilda mail send --dir "#{plans_dir}" --plan #{plan} --from #{agent.name} --to #{agent.name} "RESUME: ..."
641
+
642
+ Then write your ledger line as `Interrupted` with NO `next:` line, and
643
+ end your turn.
644
+
645
+ Before you start, check for such a note from an earlier run:
646
+
647
+ agentilda mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
648
+
649
+ If there is a RESUME note, continue from it and do not redo what it
650
+ says is done; check the files it names rather than taking it on trust.
651
+ SECTION
494
652
  end
495
653
 
496
654
  # The section `run --prompt` adds, labelled as coming from the person who