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
@@ -0,0 +1,699 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require "tmpdir"
6
+
7
+ module Agentilda
8
+ module Execution
9
+ # Runs one agent against one plan by shelling out to the `claude` CLI.
10
+ #
11
+ # The autonomy boundary is "docs plus code, but nothing leaves the machine",
12
+ # and it is enforced twice over:
13
+ #
14
+ # BEFORE — the agent is told, and `--disallowedTools` withholds the tools
15
+ # that would let it push.
16
+ # AFTER — the harness checks that HEAD did not move and no new remote ref
17
+ # appeared. A prompt is a request; a check is a guarantee, and only
18
+ # one of them survives a model deciding it knows better.
19
+ class Executor
20
+ # What one invocation did, and what it spent doing it.
21
+ #
22
+ # {#to_ary} is deliberate: every caller of {Executor#call} destructures
23
+ # `ok, note = executor.call(...)`, and the meter is an addition to that
24
+ # answer rather than a replacement for it. Callers that want the tokens
25
+ # ask for them by name.
26
+ #
27
+ # @!attribute [r] ok
28
+ # @return [Boolean]
29
+ # @!attribute [r] note
30
+ # @return [String] one line, for the report
31
+ # @!attribute [r] up
32
+ # @return [Integer] tokens sent, sub-agents included
33
+ # @!attribute [r] down
34
+ # @return [Integer] tokens generated
35
+ # @!attribute [r] subagents
36
+ # @return [Integer] sub-agents this agent spawned
37
+ # @!attribute [r] delegated
38
+ # @return [Integer] of {#up}, how much arrived as an unsplit sub-agent
39
+ # total rather than as a direction of its own
40
+ # @!attribute [r] seconds
41
+ # @return [Float] wall clock, from argv to exit
42
+ # @!attribute [r] killed
43
+ # @return [Symbol, nil] :timeout when the clock's backstop fired, :key
44
+ # when somebody pressed k, :budget when the token meter crossed the
45
+ # cap, :quit when the grace period after q ran out, nil otherwise
46
+ # @!attribute [r] pid
47
+ # @return [Integer, nil]
48
+ # @return [Data]
49
+ Result = Data.define(:ok, :note, :up, :down, :subagents, :delegated, :seconds, :killed, :pid) do
50
+ def initialize(killed: nil, pid: nil, **rest) = super
51
+
52
+ # @return [Array(Boolean, String)]
53
+ def to_ary = [ok, note]
54
+ end
55
+
56
+ # What the dispatcher holds on a running invocation, so a keypress can
57
+ # reach it: the process, its clock and its control file.
58
+ #
59
+ # Filled in by {#call} once the child exists. Every method tolerates the
60
+ # gap before that, because the keyboard does not wait.
61
+ class Handle
62
+ # @return [Agentilda::Execution::Child, nil]
63
+ attr_accessor :child
64
+
65
+ # @return [Agentilda::Execution::Clock, nil]
66
+ attr_accessor :clock
67
+
68
+ # @return [String, nil]
69
+ attr_accessor :control
70
+
71
+ # @return [Symbol, nil] why the process was killed, once it was
72
+ attr_reader :killed
73
+
74
+ # @return [Integer, nil]
75
+ def pid = child&.pid
76
+
77
+ # @return [Integer, nil]
78
+ def remaining = clock&.remaining
79
+
80
+ # @return [Symbol, nil]
81
+ def phase = clock&.phase
82
+
83
+ # STOP, a grace period, then SIGKILL if it is still there. The grace is
84
+ # what lets an agent mid-write finish the line.
85
+ #
86
+ # @param grace [Integer] seconds
87
+ # @param reason [Symbol] :key, :timeout, :budget or :quit
88
+ # @return [void]
89
+ def kill!(grace: 15, reason: :key)
90
+ @killed ||= reason
91
+ Control.write(control, Control::STOP) if control
92
+ sleep(grace) if grace.positive?
93
+ child&.kill("KILL") if child&.alive?
94
+ end
95
+
96
+ # @param seconds [Integer]
97
+ # @return [void]
98
+ def extend!(seconds) = clock&.extend!(seconds)
99
+ end
100
+
101
+ # Tools no agent may use under this autonomy level, whatever its definition
102
+ # asks for. Git itself is reachable through Bash, which is why the
103
+ # after-check exists as well.
104
+ #
105
+ # The exception is an agent that declares `network: true`. Closed is the
106
+ # right default — most specialists here read the repository and write to
107
+ # it, and a model that decides to go looking online mid-task is a model
108
+ # doing something nobody asked for. But a researcher inverts that: reading
109
+ # the internet is the entire job, and denying it silently produced an
110
+ # agent that ran, found nothing, and reported success.
111
+ DENIED_TOOLS = %w[WebFetch WebSearch].freeze
112
+
113
+ # Commands that leave the machine, denied to every agent by default.
114
+ #
115
+ # These are passed to `claude` as `Bash(<command>:*)` tool specifiers, so
116
+ # they are withheld rather than merely discouraged. This list spent a while
117
+ # as a regular expression that nothing referenced — a guard in the shape of
118
+ # a constant, enforcing nothing — which is exactly how `gh pr review` came
119
+ # to be reachable by an agent nobody had granted it to.
120
+ FORBIDDEN_COMMANDS = [
121
+ "git push", "git commit",
122
+ "gh pr create", "gh pr edit", "gh pr merge",
123
+ "gh pr review", "gh pr comment",
124
+ "gh release create"
125
+ ].freeze
126
+
127
+ # The subset no agent's `may:` can lift, however its definition is written.
128
+ #
129
+ # Pushing and merging change a branch everybody else builds on, and an
130
+ # unattended loop doing either has no way to be wrong quietly. Reviewing
131
+ # does not: an approval is reversible, visible, and attributable to the
132
+ # identity that made it. That difference is the whole line between
133
+ # `hansolo-reviewer` approving and `hansolo-reviewer` merging.
134
+ UNGRANTABLE = ["git push", "gh pr merge"].freeze
135
+
136
+ # How much of what the agent said survives into a one-line report.
137
+ REASON_LIMIT = 300
138
+
139
+ # Seconds an agent gets when neither its frontmatter nor `--timeout` says.
140
+ DEFAULT_TIMEOUT = 900
141
+
142
+ # Where the raw stream of each invocation is kept.
143
+ #
144
+ # Under `run -j` several agents work at once, and more than one
145
+ # `agentilda` may be driving the same checkout, so a trace is named
146
+ # per invocation rather than shared. To get an agent's final answer back
147
+ # out of one afterwards:
148
+ #
149
+ # jq -r 'select(.type=="result").result' <trace>
150
+ #
151
+ # and to replay what it did, tool call by tool call:
152
+ #
153
+ # jq -r 'select(.type=="assistant")
154
+ # | .message.content[]?
155
+ # | select(.type=="tool_use")
156
+ # | "\(.name) \(.input|tostring[0:80])"' <trace>
157
+ #
158
+ # Outside the repository on purpose: the harness checks afterwards that the
159
+ # agent moved nothing it should not have, and a megabyte of NDJSON dropped
160
+ # into the working tree is exactly the kind of thing that check would then
161
+ # have to learn to ignore.
162
+ TRACE_DIR = File.join(Dir.tmpdir, "agentilda-traces")
163
+
164
+ # Environment variables the `claude` CLI reads as credentials, in
165
+ # preference to a claude.ai login.
166
+ #
167
+ # A project `.env` that sets one of these for the application's own use
168
+ # reaches every agent a run spawns. `claude` then authenticates with that
169
+ # key rather than the login, and a stale or unrelated one turns an entire
170
+ # run into `401 API key is invalid`, three minutes per agent. The CLI warns
171
+ # rather than unsets. Driving it with an API key on purpose is legitimate,
172
+ # and nothing here can tell the two apart.
173
+ CREDENTIAL_VARS = %w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN].freeze
174
+
175
+ # @param env [Hash]
176
+ # @return [Array<String>] credential variables currently set
177
+ def self.foreign_credentials(env = ENV)
178
+ CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
179
+ end
180
+
181
+ # @param root [String] the repository the agents work in
182
+ # @param spawn [Proc] `argv, chdir:` → {Agentilda::Execution::Child}, swappable so the
183
+ # suite can play back a fake process without spawning one
184
+ # @param timeout [Integer, nil] `--timeout`: a cap on every agent's clock,
185
+ # or nil to let each agent's own frontmatter clock stand
186
+ # @param dry_run [Boolean] plan the invocation, do not run it
187
+ # @param trace_dir [String] where each invocation's raw stream is kept
188
+ # @param instructions [String, nil] what `run --prompt` typed, appended
189
+ # to the agent's own prompt. The command only accepts it alongside
190
+ # `--agent`, so exactly one agent ever hears it.
191
+ # @param model [String, nil] what `run --model` typed. The flag actually
192
+ # typed beats what an agent's frontmatter declares, the same precedence
193
+ # every other flag here follows; nil leaves each agent its own choice.
194
+ # @param max_tokens [Integer, nil] budget per invocation, input plus
195
+ # output, sub-agents included. The prompt states it so the agent can
196
+ # plan to finish inside it, and the meter enforces it so the statement
197
+ # is true. nil is unmetered.
198
+ # @param interactive [Boolean] whether someone is at the keyboard, which
199
+ # the keyboard help reads. Every invocation gets a control file either
200
+ # way, because the clock writes its own warnings into it regardless of
201
+ # whether anyone is watching.
202
+ def initialize(root:, spawn: Child.method(:spawn), timeout: nil, dry_run: false,
203
+ trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false)
204
+ @root = File.expand_path(root)
205
+ @spawn = spawn
206
+ @timeout = timeout
207
+ @dry_run = dry_run
208
+ @trace_dir = trace_dir
209
+ @instructions = instructions.to_s.strip
210
+ @model = model
211
+ @max_tokens = max_tokens
212
+ @interactive = interactive
213
+ end
214
+
215
+ # @return [String]
216
+ attr_reader :root
217
+
218
+ # The clock this agent runs against: the tighter of its own frontmatter
219
+ # and `--timeout`. A flag that could only loosen was useless the day
220
+ # somebody wanted a quick run.
221
+ #
222
+ # {DEFAULT_TIMEOUT} steps in only when neither names a figure. It used
223
+ # to be the flag's default instead, which silently cut every 1200-second
224
+ # agent to 900 on the runs where nobody had typed `--timeout` at all.
225
+ #
226
+ # @param agent [Agentilda::Agents::Agent]
227
+ # @return [Integer]
228
+ def timeout_for(agent) = [agent.timeout, @timeout].compact.min || DEFAULT_TIMEOUT
229
+
230
+ # @param agent [Agentilda::Agents::Agent]
231
+ # @param subject [Agentilda::Plans::Subject]
232
+ # @param root [String] the checkout the agent works in
233
+ # @param round [Integer] which attempt on this plan this is
234
+ # @param successor [String, nil] who the agent names on its `next:` line
235
+ # @param handle [Agentilda::Execution::Executor::Handle, nil] filled in for the caller
236
+ # @param partners [Array<Agentilda::Agents::Agent>] the other agents working this
237
+ # plan right now, named in the prompt beside the plan's mailbox
238
+ # @return [Agentilda::Execution::Executor::Result]
239
+ # @yieldparam progress [Agentilda::Execution::Transcript::Progress]
240
+ def call(agent, subject, root: @root, round: 1, successor: nil, handle: nil, partners: [], &on_progress)
241
+ started = UI.monotonic
242
+ if @dry_run
243
+ return Result.new(ok: true,
244
+ note: "dry run - would invoke #{agent.name}",
245
+ up: 0,
246
+ down: 0,
247
+ subagents: 0,
248
+ delegated: 0,
249
+ seconds: 0.0)
250
+ end
251
+
252
+ handle ||= Handle.new
253
+ before = head(root)
254
+ trace = trace_path(agent, subject)
255
+ transcript = Transcript.new(trace:, &on_progress)
256
+ control = Control.register(@trace_dir, "#{subject.feature.ordinal}-#{agent.name}")
257
+ handle.control = control
258
+ argv = invocation(agent, subject, root:, control:, round:, successor:, partners:)
259
+
260
+ child = @spawn.call(argv, chdir: root)
261
+ handle.child = child
262
+ transcript.pid = child.pid
263
+ clock = Clock.new(seconds: timeout_for(agent),
264
+ control:,
265
+ on_expire: -> { handle.kill!(grace: 0, reason: :timeout) })
266
+ handle.clock = clock
267
+ clock.start
268
+
269
+ child.each_chunk do |out|
270
+ transcript.push(out)
271
+ abort_if_over(transcript, handle)
272
+ end
273
+ status = child.wait
274
+ transcript.finish
275
+ clock.stop
276
+ Control.release(control)
277
+
278
+ if handle.killed
279
+ return spent(transcript,
280
+ started,
281
+ ok: false,
282
+ pid: child.pid,
283
+ killed: handle.killed,
284
+ note: "#{killed_note(handle.killed, clock)}, last seen #{transcript.activity || "starting up"} - trace: #{trace}")
285
+ end
286
+ unless status.success?
287
+ return failure(transcript, started, "claude exited #{status.exitstatus}: #{said(transcript)} - trace: #{trace}", pid: child.pid)
288
+ end
289
+ if transcript.failed?
290
+ return failure(transcript, started, "claude reported: #{transcript.error} - trace: #{trace}", pid: child.pid)
291
+ end
292
+
293
+ violation = boundary_violation(before, root)
294
+ return failure(transcript, started, violation, pid: child.pid) if violation
295
+
296
+ spent(transcript,
297
+ started,
298
+ ok: true,
299
+ pid: child.pid,
300
+ note: "completed#{" - #{transcript.tools} tool calls" if transcript.tools.positive?}")
301
+ end
302
+
303
+ # A failed invocation still spent what it spent, and a run that burned two
304
+ # hundred thousand tokens before timing out is a different fact from one
305
+ # that failed to authenticate and spent nothing. Both used to report the
306
+ # same thing.
307
+ #
308
+ # @return [Agentilda::Execution::Executor::Result]
309
+ def failure(transcript, started, note, pid: nil) = spent(transcript, started, ok: false, note:, pid:)
310
+
311
+ # @return [Agentilda::Execution::Executor::Result]
312
+ def spent(transcript, started, ok:, note:, pid: nil, killed: nil)
313
+ Result.new(ok:,
314
+ note:,
315
+ up: transcript.up,
316
+ down: transcript.down,
317
+ subagents: transcript.spawned,
318
+ delegated: transcript.delegated,
319
+ seconds: UI.monotonic - started,
320
+ killed:,
321
+ pid:)
322
+ end
323
+
324
+ # @param transcript [Agentilda::Execution::Transcript]
325
+ # @return [String] the last few plain lines, clipped
326
+ def said(transcript)
327
+ text = (transcript.failed? ? transcript.error : transcript.plain.last(3).join(" ")).to_s.strip
328
+ return "said nothing" if text.empty?
329
+
330
+ text.length > REASON_LIMIT ? "#{text[0, REASON_LIMIT - 1]}..." : text
331
+ end
332
+
333
+ # @param reason [Symbol]
334
+ # @param clock [Agentilda::Execution::Clock]
335
+ # @return [String]
336
+ def killed_note(reason, _clock)
337
+ case reason
338
+ when :timeout then "timed out (killed #{Clock::GRACE}s after STOP)"
339
+ when :budget then "token budget of #{@max_tokens} exceeded"
340
+ when :quit then "still running after #{Control.interrupted? ? "ctrl-c" : "q"}, killed once the grace period ran out"
341
+ else "killed from the keyboard"
342
+ end
343
+ end
344
+
345
+ # The exact argv, exposed so a spec can assert the boundary flags without
346
+ # running anything.
347
+ #
348
+ # @param agent [Agentilda::Agents::Agent]
349
+ # @param subject [Agentilda::Plans::Subject]
350
+ # @param round [Integer] which attempt on this plan this is
351
+ # @param successor [String, nil] who the agent names on its `next:` line
352
+ # @param partners [Array<Agentilda::Agents::Agent>]
353
+ # @return [Array<String>]
354
+ def invocation(agent, subject, root: @root, control: nil, round: 1, successor: nil, partners: [])
355
+ # `--include-partial-messages` is what the token meter runs on. Without
356
+ # it the stream reports a settled input count and a placeholder output
357
+ # count — 2 for a four-thousand-token answer — and a spinner counting
358
+ # what came back would read zero all run. See {Transcript#meter}.
359
+ argv = ["claude", "-p", prompt_for(agent, subject, root, control:, round:, successor:, partners:), "--add-dir", root,
360
+ "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--brief"]
361
+ denied = denied_for(agent)
362
+ argv += ["--disallowedTools", denied.join(",")] unless denied.empty?
363
+ argv += ["--allowedTools", agent.allowed_tools.join(",")] unless agent.allowed_tools.empty?
364
+ model = @model || agent.model
365
+ argv += ["--model", model] if model
366
+ argv += ["--effort", agent.effort] if agent.effort
367
+ argv
368
+ end
369
+
370
+ # What this particular agent may not touch: the network tools unless it
371
+ # asked for them, plus every forbidden command it has not been granted.
372
+ # Both decisions are recorded in a reviewable file rather than passed as a
373
+ # flag by whoever happened to start the run.
374
+ #
375
+ # @param agent [Agentilda::Agents::Agent]
376
+ # @return [Array<String>]
377
+ def denied_for(agent)
378
+ tools = agent.network ? [] : DENIED_TOOLS
379
+ tools + denied_commands(agent).map { |command| "Bash(#{command}:*)" }
380
+ end
381
+
382
+ # @param agent [Agentilda::Agents::Agent]
383
+ # @return [Array<String>] commands withheld from this agent
384
+ def denied_commands(agent) = FORBIDDEN_COMMANDS - granted_to(agent)
385
+
386
+ # @param agent [Agentilda::Agents::Agent]
387
+ # @return [Array<String>] what its `may:` actually buys it
388
+ def granted_to(agent) = agent.may - UNGRANTABLE
389
+
390
+ private
391
+
392
+ # One file per invocation. The plan number and the agent's name make it
393
+ # findable; the pid and the clock keep two concurrent runs from writing to
394
+ # the same one. See {TRACE_DIR} for how to read one back.
395
+ #
396
+ # @param agent [Agentilda::Agents::Agent]
397
+ # @param subject [Agentilda::Plans::Subject]
398
+ # @return [String]
399
+ def trace_path(agent, subject)
400
+ FileUtils.mkdir_p(@trace_dir)
401
+ name = format("%s-%s-%s-%d-%04x.ndjson",
402
+ Time.now.strftime("%Y%m%d-%H%M%S"),
403
+ subject.feature.ordinal,
404
+ agent.name,
405
+ Process.pid,
406
+ rand(0x10000))
407
+ File.join(@trace_dir, name)
408
+ end
409
+
410
+ # @param agent [Agentilda::Agents::Agent]
411
+ # @param subject [Agentilda::Plans::Subject]
412
+ # @param round [Integer]
413
+ # @param successor [String, nil]
414
+ # @param partners [Array<Agentilda::Agents::Agent>]
415
+ # @return [String]
416
+ def prompt_for(agent, subject, root = @root, control: nil, round: 1, successor: nil, partners: [])
417
+ <<~PROMPT
418
+ #{agent.prompt}
419
+
420
+ ---
421
+
422
+ ## This invocation
423
+
424
+ Plan folder : #{subject.feature.path}
425
+ Plan number : #{subject.feature.ordinal}
426
+ Current state : #{subject.status.emoji} #{subject.status.label}
427
+ Repository root: #{root}
428
+
429
+ #{"The folder's name is not currently justified: #{subject.violation}" if subject.violation}
430
+ #{operator_instructions}#{ledger_section(agent, round:, successor:)}#{budget_section}#{time_budget_section(agent)}#{control_section(control, agent, subject)}#{mailbox_section(agent, subject, partners)}
431
+ ## Boundary — enforced, not requested
432
+
433
+ You may read anything, and write source, tests and the plan's own
434
+ markdown.
435
+
436
+ These are withheld from you, not merely discouraged — `claude` is
437
+ invoked with them disallowed:
438
+
439
+ #{denied_commands(agent).map { |c| " #{c}" }.join("\n")}
440
+ #{granted(agent)}
441
+ The harness checks afterwards that HEAD has not moved, and a round that
442
+ moved it is reported as a failure and rolled into the report. A prompt
443
+ is a request; a check is a guarantee.
444
+
445
+ Claim each directory or file before you write it, and release it when
446
+ that write is done. Name yourself on every call, since `alo` would
447
+ otherwise sign with a fingerprint your sub-agents share; give each
448
+ sub-agent its own suffix, e.g. `AGENT_ID=#{agent.name}-schema`:
449
+
450
+ AGENT_ID=#{agent.name} alo acquire <path> "<why>"
451
+ AGENT_ID=#{agent.name} alo release <path>
452
+
453
+ A refused `acquire` means another agent holds it: work on something
454
+ else, never write it anyway. Before your closing ledger line, run
455
+ `AGENT_ID=#{agent.name} alo release-all`.
456
+ PROMPT
457
+ end
458
+
459
+ # The one paragraph every agent gets, identically, about the ledger. It
460
+ # lives here rather than in seven definition files so the wording cannot
461
+ # drift between agents, and so the names, the round and the successor are
462
+ # the harness's facts rather than the agent's guesses.
463
+ #
464
+ # @param agent [Agentilda::Agents::Agent]
465
+ # @param round [Integer]
466
+ # @param successor [String, nil]
467
+ # @return [String]
468
+ def ledger_section(agent, round:, successor:)
469
+ documents = agent.ledger.map { |f| "`#{f}`" }.join(", then ")
470
+ handoff = successor ? "\n > [<now>] [ next: #{successor} ]" : ""
471
+ <<~SECTION
472
+
473
+ ## The ledger - write this at the start and at the end
474
+
475
+ You sign the document you are working in: #{documents}. Get `<now>` from
476
+ `date "+%Y-%m-%d %I:%M:%S %p %Z"`. Before you do any work, append:
477
+
478
+ > [!NOTE]
479
+ >
480
+ > [<now>] [ agent: #{agent.name} status: Started, round #{round} ]
481
+
482
+ When you finish, append:
483
+
484
+ > [!NOTE]
485
+ >
486
+ > [<now>] [ agent: #{agent.name} status: Completed, round #{round} ]#{handoff}
487
+
488
+ Write `Completed` only if your assignment is genuinely done. Otherwise write
489
+ `Almost completed`, `Interrupted` or `Blocked` in its place and NO `next:` line.
490
+ The harness renames the plan folder and starts the next agent from these
491
+ lines; you never rename the folder yourself. A short note in parentheses
492
+ after the round is welcome, e.g. `Completed, round 1 (approved)`.
493
+ SECTION
494
+ end
495
+
496
+ # The token budget crossed, or the grace period after `q` spent. Both go
497
+ # through the handle so the kill is the same kill a keypress makes.
498
+ #
499
+ # @param transcript [Agentilda::Execution::Transcript]
500
+ # @param handle [Agentilda::Execution::Executor::Handle]
501
+ # @return [void]
502
+ def abort_if_over(transcript, handle)
503
+ spent = transcript.up + transcript.down
504
+ if @max_tokens&.positive? && spent > @max_tokens
505
+ handle.kill!(grace: 0, reason: :budget)
506
+ elsif Control.overdue?
507
+ handle.kill!(grace: 0, reason: :quit)
508
+ end
509
+ end
510
+
511
+ # The section `run --max-tokens` adds. Stating the number is what lets
512
+ # the agent finish before it, rather than discovering the cap by dying
513
+ # on it with half a file written.
514
+ #
515
+ # @return [String]
516
+ def budget_section
517
+ return "" unless @max_tokens&.positive?
518
+
519
+ "\n## Token budget — #{@max_tokens} tokens, enforced\n\n" \
520
+ "This invocation is aborted once its total spend (input plus output, " \
521
+ "sub-agents included) crosses #{@max_tokens} tokens. Budget the work: " \
522
+ "plan what fits, write results to disk as you go, and finish — or " \
523
+ "write a handoff note into the plan folder — before the meter runs " \
524
+ "out. Anything unwritten at the cap is lost.\n"
525
+ end
526
+
527
+ # The section describing the advisory clock this invocation runs against,
528
+ # stated in the prompt so an agent can pace itself. The number is
529
+ # whatever {#timeout_for} will actually enforce, so the prompt and the
530
+ # clock can never disagree — an agent whose prose names its own figure
531
+ # goes stale the first time someone passes `--timeout`, and stale is
532
+ # worse than silent.
533
+ #
534
+ # @param agent [Agentilda::Agents::Agent]
535
+ # @return [String]
536
+ def time_budget_section(agent)
537
+ seconds = timeout_for(agent)
538
+ return "" unless seconds&.positive?
539
+
540
+ minutes = (seconds / 60.0).round
541
+ "\n## Time budget - #{seconds} seconds\n\n" \
542
+ "You have about #{minutes} minute#{"s" unless minutes == 1} of wall clock. The control " \
543
+ "file below tells you how it is going: `WARN: 10 minutes left`, `WARN: 5 minutes left`, " \
544
+ "`WRAP_UP: 1 minute left, write to disk now`, then `STOP`. Sixty seconds after STOP " \
545
+ "the process is killed, and anything unwritten is lost. Write each result to disk as " \
546
+ "you reach it, and write your closing ledger line before anything else once you see " \
547
+ "WRAP_UP.#{concurrency_advice(agent)}\n"
548
+ end
549
+
550
+ # Only worth saying to an agent that can actually do it. Telling an agent
551
+ # without `Task` to parallelise is telling it to feel bad about a tool it
552
+ # was not given.
553
+ #
554
+ # @param agent [Agentilda::Agents::Agent]
555
+ # @return [String]
556
+ def concurrency_advice(agent)
557
+ return "" unless agent.allowed_tools.include?("Task")
558
+
559
+ " Where the work divides into parts that do not read each other's output, run them " \
560
+ "as one wave of concurrent sub-agents rather than in series: the wave costs one " \
561
+ "part's wall clock, and the series costs the sum of all of them."
562
+ end
563
+
564
+ # Two agents on one plan are two processes. Naming the partner and the
565
+ # exact commands is what replaced guessing the partner's session from
566
+ # every Claude session on the machine.
567
+ #
568
+ # @param agent [Agentilda::Agents::Agent]
569
+ # @param subject [Agentilda::Plans::Subject]
570
+ # @param partners [Array<Agentilda::Agents::Agent>]
571
+ # @return [String]
572
+ def mailbox_section(agent, subject, partners)
573
+ return "" if partners.empty?
574
+
575
+ plans_dir = File.dirname(subject.feature.path)
576
+ plan = subject.feature.ordinal
577
+ names = partners.map { |p| "`#{p.name}`" }
578
+ who = names.size == 1 ? "Your partner on this plan is #{names.first}" : "Your partners on this plan are #{names.join(" and ")}"
579
+
580
+ <<~SECTION
581
+
582
+ ## Mailbox - poll it between steps
583
+
584
+ #{who}. You share one worktree and one branch, but not a process, so
585
+ the only way to reach each other is the plan's mailbox:
586
+
587
+ #{File.join(subject.feature.path, Plans::Mailbox::FILENAME)}
588
+
589
+ Read it before each significant step and whenever you finish a unit:
590
+
591
+ agentilda mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
592
+
593
+ Pass `--after N`, with the number of the last message you have read,
594
+ to see only what is new. Write to it when you land an interface your
595
+ partner is waiting on, when you amend the contract, when you need
596
+ something from their half, and when you finish:
597
+
598
+ agentilda mail send --dir "#{plans_dir}" --plan #{plan} --from #{agent.name} --to #{partners.first.name} "what you need them to know"
599
+
600
+ Every message is appended with a number and a timestamp and never
601
+ edited, so a person can read the exchange after the round. A question
602
+ your partner has not answered within a few steps is not a reason to
603
+ stop: write your assumption into implementation-plan.md and carry on.
604
+ SECTION
605
+ end
606
+
607
+ # The section a control file adds. Present on every invocation, because
608
+ # the advisory clock writes into it whether or not anyone is watching.
609
+ #
610
+ # INTERRUPT is what makes a restart after Ctrl-C safe: the agent that
611
+ # was cut off leaves a mailbox note to whoever picks the plan up, and
612
+ # every agent is told to read its mail first, so the next run resumes
613
+ # from the note rather than redoing work already on disk.
614
+ #
615
+ # @param control [String, nil]
616
+ # @param agent [Agentilda::Agents::Agent]
617
+ # @param subject [Agentilda::Plans::Subject]
618
+ # @return [String]
619
+ def control_section(control, agent, subject)
620
+ return "" if control.nil?
621
+
622
+ plans_dir = File.dirname(subject.feature.path)
623
+ plan = subject.feature.ordinal
624
+ <<~SECTION
625
+
626
+ ## Control file — poll it between steps
627
+
628
+ #{control}
629
+
630
+ Read this file before each significant step. Empty means carry on.
631
+ A line starting WARN: tells you how much time is left. WRAP_UP: means
632
+ finish the essential remainder now. STOP means write what you have,
633
+ write your ledger line, and end your turn.
634
+
635
+ INTERRUPT means the operator pressed Ctrl-C and the run will be started
636
+ again later. Start nothing new. Finish the write you are in the middle
637
+ of, so no file is left half-written, then leave a resume note for
638
+ whoever runs next — most likely you — saying what is done, what is
639
+ not, and the exact next step:
640
+
641
+ agentilda mail send --dir "#{plans_dir}" --plan #{plan} --from #{agent.name} --to #{agent.name} "RESUME: ..."
642
+
643
+ Then write your ledger line as `Interrupted` with NO `next:` line, and
644
+ end your turn.
645
+
646
+ Before you start, check for such a note from an earlier run:
647
+
648
+ agentilda mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
649
+
650
+ If there is a RESUME note, continue from it and do not redo what it
651
+ says is done; check the files it names rather than taking it on trust.
652
+ SECTION
653
+ end
654
+
655
+ # The section `run --prompt` adds, labelled as coming from the person who
656
+ # started the run so the agent can tell a one-off steer from its own
657
+ # standing definition.
658
+ #
659
+ # @return [String]
660
+ def operator_instructions
661
+ return "" if @instructions.empty?
662
+
663
+ "\n## Operator instructions — this invocation only\n\n#{@instructions}\n"
664
+ end
665
+
666
+ # Spelled out in the prompt as well as withheld at the tool layer, because
667
+ # an agent that does not know it has been granted something does not use
668
+ # it — and `hansolo-reviewer` silently never approving anything looks
669
+ # exactly like `hansolo-reviewer` approving nothing worth approving.
670
+ #
671
+ # @param agent [Agentilda::Agents::Agent]
672
+ # @return [String]
673
+ def granted(agent)
674
+ granted = granted_to(agent)
675
+ return "" if granted.empty?
676
+
677
+ "\nYou may run these, which most agents may not:\n\n" +
678
+ granted.map { |c| " #{c}" }.join("\n") + "\n"
679
+ end
680
+
681
+ # @return [String, nil] current commit, or nil outside a repository
682
+ def head(root = @root)
683
+ out = `git -C #{root.shellescape} rev-parse HEAD 2>/dev/null`.strip
684
+ out.empty? ? nil : out
685
+ end
686
+
687
+ # @param before [String, nil]
688
+ # @return [String, nil] what boundary was crossed, or nil
689
+ def boundary_violation(before, root = @root)
690
+ return nil if before.nil?
691
+
692
+ after = head(root)
693
+ return "agent committed (HEAD moved #{before[0, 7]} → #{after[0, 7]}) — the boundary is docs plus code, no commits" if after != before
694
+
695
+ nil
696
+ end
697
+ end
698
+ end
699
+ end