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
@@ -1,697 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "shellwords"
5
- require "tmpdir"
6
-
7
- module Agentilda
8
- # Runs one agent against one plan by shelling out to the `claude` CLI.
9
- #
10
- # The autonomy boundary is "docs plus code, but nothing leaves the machine",
11
- # and it is enforced twice over:
12
- #
13
- # BEFORE — the agent is told, and `--disallowedTools` withholds the tools
14
- # that would let it push.
15
- # AFTER — the harness checks that HEAD did not move and no new remote ref
16
- # appeared. A prompt is a request; a check is a guarantee, and only
17
- # one of them survives a model deciding it knows better.
18
- class Executor
19
- # What one invocation did, and what it spent doing it.
20
- #
21
- # {#to_ary} is deliberate: every caller of {Executor#call} destructures
22
- # `ok, note = executor.call(...)`, and the meter is an addition to that
23
- # answer rather than a replacement for it. Callers that want the tokens
24
- # ask for them by name.
25
- #
26
- # @!attribute [r] ok
27
- # @return [Boolean]
28
- # @!attribute [r] note
29
- # @return [String] one line, for the report
30
- # @!attribute [r] up
31
- # @return [Integer] tokens sent, sub-agents included
32
- # @!attribute [r] down
33
- # @return [Integer] tokens generated
34
- # @!attribute [r] subagents
35
- # @return [Integer] sub-agents this agent spawned
36
- # @!attribute [r] delegated
37
- # @return [Integer] of {#up}, how much arrived as an unsplit sub-agent
38
- # total rather than as a direction of its own
39
- # @!attribute [r] seconds
40
- # @return [Float] wall clock, from argv to exit
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
-
51
- # @return [Array(Boolean, String)]
52
- def to_ary = [ok, note]
53
- end
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
-
100
- # Tools no agent may use under this autonomy level, whatever its definition
101
- # asks for. Git itself is reachable through Bash, which is why the
102
- # after-check exists as well.
103
- #
104
- # The exception is an agent that declares `network: true`. Closed is the
105
- # right default — most specialists here read the repository and write to
106
- # it, and a model that decides to go looking online mid-task is a model
107
- # doing something nobody asked for. But a researcher inverts that: reading
108
- # the internet is the entire job, and denying it silently produced an
109
- # agent that ran, found nothing, and reported success.
110
- DENIED_TOOLS = %w[WebFetch WebSearch].freeze
111
-
112
- # Commands that leave the machine, denied to every agent by default.
113
- #
114
- # These are passed to `claude` as `Bash(<command>:*)` tool specifiers, so
115
- # they are withheld rather than merely discouraged. This list spent a while
116
- # as a regular expression that nothing referenced — a guard in the shape of
117
- # a constant, enforcing nothing — which is exactly how `gh pr review` came
118
- # to be reachable by an agent nobody had granted it to.
119
- FORBIDDEN_COMMANDS = [
120
- "git push", "git commit",
121
- "gh pr create", "gh pr edit", "gh pr merge",
122
- "gh pr review", "gh pr comment",
123
- "gh release create"
124
- ].freeze
125
-
126
- # The subset no agent's `may:` can lift, however its definition is written.
127
- #
128
- # Pushing and merging change a branch everybody else builds on, and an
129
- # unattended loop doing either has no way to be wrong quietly. Reviewing
130
- # does not: an approval is reversible, visible, and attributable to the
131
- # identity that made it. That difference is the whole line between
132
- # `hansolo-reviewer` approving and `hansolo-reviewer` merging.
133
- UNGRANTABLE = ["git push", "gh pr merge"].freeze
134
-
135
- # How much of what the agent said survives into a one-line report.
136
- REASON_LIMIT = 300
137
-
138
- # Seconds an agent gets when neither its frontmatter nor `--timeout` says.
139
- DEFAULT_TIMEOUT = 900
140
-
141
- # Where the raw stream of each invocation is kept.
142
- #
143
- # Under `run -j` several agents work at once, and more than one
144
- # `agentilda` may be driving the same checkout, so a trace is named
145
- # per invocation rather than shared. To get an agent's final answer back
146
- # out of one afterwards:
147
- #
148
- # jq -r 'select(.type=="result").result' <trace>
149
- #
150
- # and to replay what it did, tool call by tool call:
151
- #
152
- # jq -r 'select(.type=="assistant")
153
- # | .message.content[]?
154
- # | select(.type=="tool_use")
155
- # | "\(.name) \(.input|tostring[0:80])"' <trace>
156
- #
157
- # Outside the repository on purpose: the harness checks afterwards that the
158
- # agent moved nothing it should not have, and a megabyte of NDJSON dropped
159
- # into the working tree is exactly the kind of thing that check would then
160
- # have to learn to ignore.
161
- TRACE_DIR = File.join(Dir.tmpdir, "agentilda-traces")
162
-
163
- # Environment variables the `claude` CLI reads as credentials, in
164
- # preference to a claude.ai login.
165
- #
166
- # A project `.env` that sets one of these for the application's own use
167
- # reaches every agent a run spawns. `claude` then authenticates with that
168
- # key rather than the login, and a stale or unrelated one turns an entire
169
- # run into `401 API key is invalid`, three minutes per agent. The CLI warns
170
- # rather than unsets. Driving it with an API key on purpose is legitimate,
171
- # and nothing here can tell the two apart.
172
- CREDENTIAL_VARS = %w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN].freeze
173
-
174
- # @param env [Hash]
175
- # @return [Array<String>] credential variables currently set
176
- def self.foreign_credentials(env = ENV)
177
- CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
178
- end
179
-
180
- # @param root [String] the repository the agents work in
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
185
- # @param dry_run [Boolean] plan the invocation, do not run it
186
- # @param trace_dir [String] where each invocation's raw stream is kept
187
- # @param instructions [String, nil] what `run --prompt` typed, appended
188
- # to the agent's own prompt. The command only accepts it alongside
189
- # `--agent`, so exactly one agent ever hears it.
190
- # @param model [String, nil] what `run --model` typed. The flag actually
191
- # typed beats what an agent's frontmatter declares, the same precedence
192
- # every other flag here follows; nil leaves each agent its own choice.
193
- # @param max_tokens [Integer, nil] budget per invocation, input plus
194
- # output, sub-agents included. The prompt states it so the agent can
195
- # plan to finish inside it, and the meter enforces it so the statement
196
- # is true. nil is unmetered.
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,
202
- trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false)
203
- @root = File.expand_path(root)
204
- @spawn = spawn
205
- @timeout = timeout
206
- @dry_run = dry_run
207
- @trace_dir = trace_dir
208
- @instructions = instructions.to_s.strip
209
- @model = model
210
- @max_tokens = max_tokens
211
- @interactive = interactive
212
- end
213
-
214
- # @return [String]
215
- attr_reader :root
216
-
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.
224
- #
225
- # @param agent [Agentilda::Agent]
226
- # @return [Integer]
227
- def timeout_for(agent) = [agent.timeout, @timeout].compact.min || DEFAULT_TIMEOUT
228
-
229
- # @param agent [Agentilda::Agent]
230
- # @param subject [Agentilda::Subject]
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)
240
- started = UI.monotonic
241
- if @dry_run
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)
249
- end
250
-
251
- handle ||= Handle.new
252
- before = head(root)
253
- trace = trace_path(agent, subject)
254
- transcript = Transcript.new(trace:, &on_progress)
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)
287
- end
288
- if transcript.failed?
289
- return failure(transcript, started, "claude reported: #{transcript.error} - trace: #{trace}", pid: child.pid)
290
- end
291
-
292
- violation = boundary_violation(before, root)
293
- return failure(transcript, started, violation, pid: child.pid) if violation
294
-
295
- spent(transcript,
296
- started,
297
- ok: true,
298
- pid: child.pid,
299
- note: "completed#{" - #{transcript.tools} tool calls" if transcript.tools.positive?}")
300
- end
301
-
302
- # A failed invocation still spent what it spent, and a run that burned two
303
- # hundred thousand tokens before timing out is a different fact from one
304
- # that failed to authenticate and spent nothing. Both used to report the
305
- # same thing.
306
- #
307
- # @return [Agentilda::Executor::Result]
308
- def failure(transcript, started, note, pid: nil) = spent(transcript, started, ok: false, note:, pid:)
309
-
310
- # @return [Agentilda::Executor::Result]
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
342
- end
343
-
344
- # The exact argv, exposed so a spec can assert the boundary flags without
345
- # running anything.
346
- #
347
- # @param agent [Agentilda::Agent]
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>]
352
- # @return [Array<String>]
353
- def invocation(agent, subject, root: @root, control: nil, round: 1, successor: nil, partners: [])
354
- # `--include-partial-messages` is what the token meter runs on. Without
355
- # it the stream reports a settled input count and a placeholder output
356
- # count — 2 for a four-thousand-token answer — and a spinner counting
357
- # what came back would read zero all run. See {Transcript#meter}.
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"]
360
- denied = denied_for(agent)
361
- argv += ["--disallowedTools", denied.join(",")] unless denied.empty?
362
- argv += ["--allowedTools", agent.allowed_tools.join(",")] unless agent.allowed_tools.empty?
363
- model = @model || agent.model
364
- argv += ["--model", model] if model
365
- argv += ["--effort", agent.effort] if agent.effort
366
- argv
367
- end
368
-
369
- # What this particular agent may not touch: the network tools unless it
370
- # asked for them, plus every forbidden command it has not been granted.
371
- # Both decisions are recorded in a reviewable file rather than passed as a
372
- # flag by whoever happened to start the run.
373
- #
374
- # @param agent [Agentilda::Agent]
375
- # @return [Array<String>]
376
- def denied_for(agent)
377
- tools = agent.network ? [] : DENIED_TOOLS
378
- tools + denied_commands(agent).map { |command| "Bash(#{command}:*)" }
379
- end
380
-
381
- # @param agent [Agentilda::Agent]
382
- # @return [Array<String>] commands withheld from this agent
383
- def denied_commands(agent) = FORBIDDEN_COMMANDS - granted_to(agent)
384
-
385
- # @param agent [Agentilda::Agent]
386
- # @return [Array<String>] what its `may:` actually buys it
387
- def granted_to(agent) = agent.may - UNGRANTABLE
388
-
389
- private
390
-
391
- # One file per invocation. The plan number and the agent's name make it
392
- # findable; the pid and the clock keep two concurrent runs from writing to
393
- # the same one. See {TRACE_DIR} for how to read one back.
394
- #
395
- # @param agent [Agentilda::Agent]
396
- # @param subject [Agentilda::Subject]
397
- # @return [String]
398
- def trace_path(agent, subject)
399
- FileUtils.mkdir_p(@trace_dir)
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))
406
- File.join(@trace_dir, name)
407
- end
408
-
409
- # @param agent [Agentilda::Agent]
410
- # @param subject [Agentilda::Subject]
411
- # @param round [Integer]
412
- # @param successor [String, nil]
413
- # @param partners [Array<Agentilda::Agent>]
414
- # @return [String]
415
- def prompt_for(agent, subject, root = @root, control: nil, round: 1, successor: nil, partners: [])
416
- <<~PROMPT
417
- #{agent.prompt}
418
-
419
- ---
420
-
421
- ## This invocation
422
-
423
- Plan folder : #{subject.feature.path}
424
- Plan number : #{subject.feature.ordinal}
425
- Current state : #{subject.status.emoji} #{subject.status.label}
426
- Repository root: #{root}
427
-
428
- #{"The folder's name is not currently justified: #{subject.violation}" if subject.violation}
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
- ## Boundary — enforced, not requested
431
-
432
- You may read anything, and write source, tests and the plan's own
433
- markdown.
434
-
435
- These are withheld from you, not merely discouraged — `claude` is
436
- invoked with them disallowed:
437
-
438
- #{denied_commands(agent).map { |c| " #{c}" }.join("\n")}
439
- #{granted(agent)}
440
- The harness checks afterwards that HEAD has not moved, and a round that
441
- moved it is reported as a failure and rolled into the report. A prompt
442
- is a request; a check is a guarantee.
443
-
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`.
455
- PROMPT
456
- end
457
-
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.
497
- #
498
- # @param transcript [Agentilda::Transcript]
499
- # @param handle [Agentilda::Executor::Handle]
500
- # @return [void]
501
- def abort_if_over(transcript, handle)
502
- spent = transcript.up + transcript.down
503
- if @max_tokens&.positive? && spent > @max_tokens
504
- handle.kill!(grace: 0, reason: :budget)
505
- elsif Control.overdue?
506
- handle.kill!(grace: 0, reason: :quit)
507
- end
508
- end
509
-
510
- # The section `run --max-tokens` adds. Stating the number is what lets
511
- # the agent finish before it, rather than discovering the cap by dying
512
- # on it with half a file written.
513
- #
514
- # @return [String]
515
- def budget_section
516
- return "" unless @max_tokens&.positive?
517
-
518
- "\n## Token budget — #{@max_tokens} tokens, enforced\n\n" \
519
- "This invocation is aborted once its total spend (input plus output, " \
520
- "sub-agents included) crosses #{@max_tokens} tokens. Budget the work: " \
521
- "plan what fits, write results to disk as you go, and finish — or " \
522
- "write a handoff note into the plan folder — before the meter runs " \
523
- "out. Anything unwritten at the cap is lost.\n"
524
- end
525
-
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.
613
- #
614
- # @param control [String, nil]
615
- # @param agent [Agentilda::Agent]
616
- # @param subject [Agentilda::Subject]
617
- # @return [String]
618
- def control_section(control, agent, subject)
619
- return "" if control.nil?
620
-
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
652
- end
653
-
654
- # The section `run --prompt` adds, labelled as coming from the person who
655
- # started the run so the agent can tell a one-off steer from its own
656
- # standing definition.
657
- #
658
- # @return [String]
659
- def operator_instructions
660
- return "" if @instructions.empty?
661
-
662
- "\n## Operator instructions — this invocation only\n\n#{@instructions}\n"
663
- end
664
-
665
- # Spelled out in the prompt as well as withheld at the tool layer, because
666
- # an agent that does not know it has been granted something does not use
667
- # it — and `hansolo-reviewer` silently never approving anything looks
668
- # exactly like `hansolo-reviewer` approving nothing worth approving.
669
- #
670
- # @param agent [Agentilda::Agent]
671
- # @return [String]
672
- def granted(agent)
673
- granted = granted_to(agent)
674
- return "" if granted.empty?
675
-
676
- "\nYou may run these, which most agents may not:\n\n" +
677
- granted.map { |c| " #{c}" }.join("\n") + "\n"
678
- end
679
-
680
- # @return [String, nil] current commit, or nil outside a repository
681
- def head(root = @root)
682
- out = `git -C #{root.shellescape} rev-parse HEAD 2>/dev/null`.strip
683
- out.empty? ? nil : out
684
- end
685
-
686
- # @param before [String, nil]
687
- # @return [String, nil] what boundary was crossed, or nil
688
- def boundary_violation(before, root = @root)
689
- return nil if before.nil?
690
-
691
- after = head(root)
692
- return "agent committed (HEAD moved #{before[0, 7]} → #{after[0, 7]}) — the boundary is docs plus code, no commits" if after != before
693
-
694
- nil
695
- end
696
- end
697
- end