agentilda 1.0.3

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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. metadata +349 -0
@@ -0,0 +1,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # Runs one agent against one plan by shelling out to the `claude` CLI.
5
+ #
6
+ # The autonomy boundary is "docs plus code, but nothing leaves the machine",
7
+ # and it is enforced twice over:
8
+ #
9
+ # BEFORE — the agent is told, and `--disallowedTools` withholds the tools
10
+ # that would let it push.
11
+ # AFTER — the harness checks that HEAD did not move and no new remote ref
12
+ # appeared. A prompt is a request; a check is a guarantee, and only
13
+ # one of them survives a model deciding it knows better.
14
+ 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
+ # What one invocation did, and what it spent doing it.
23
+ #
24
+ # {#to_ary} is deliberate: every caller of {Executor#call} destructures
25
+ # `ok, note = executor.call(...)`, and the meter is an addition to that
26
+ # answer rather than a replacement for it. Callers that want the tokens
27
+ # ask for them by name.
28
+ #
29
+ # @!attribute [r] ok
30
+ # @return [Boolean]
31
+ # @!attribute [r] note
32
+ # @return [String] one line, for the report
33
+ # @!attribute [r] up
34
+ # @return [Integer] tokens sent, sub-agents included
35
+ # @!attribute [r] down
36
+ # @return [Integer] tokens generated
37
+ # @!attribute [r] subagents
38
+ # @return [Integer] sub-agents this agent spawned
39
+ # @!attribute [r] delegated
40
+ # @return [Integer] of {#up}, how much arrived as an unsplit sub-agent
41
+ # total rather than as a direction of its own
42
+ # @!attribute [r] seconds
43
+ # @return [Float] wall clock, from argv to exit
44
+ Result = Data.define(:ok, :note, :up, :down, :subagents, :delegated, :seconds) do
45
+ # @return [Array(Boolean, String)]
46
+ def to_ary = [ok, note]
47
+ end
48
+
49
+ # Tools no agent may use under this autonomy level, whatever its definition
50
+ # asks for. Git itself is reachable through Bash, which is why the
51
+ # after-check exists as well.
52
+ #
53
+ # The exception is an agent that declares `network: true`. Closed is the
54
+ # right default — most specialists here read the repository and write to
55
+ # it, and a model that decides to go looking online mid-task is a model
56
+ # doing something nobody asked for. But a researcher inverts that: reading
57
+ # the internet is the entire job, and denying it silently produced an
58
+ # agent that ran, found nothing, and reported success.
59
+ DENIED_TOOLS = %w[WebFetch WebSearch].freeze
60
+
61
+ # Commands that leave the machine, denied to every agent by default.
62
+ #
63
+ # These are passed to `claude` as `Bash(<command>:*)` tool specifiers, so
64
+ # they are withheld rather than merely discouraged. This list spent a while
65
+ # as a regular expression that nothing referenced — a guard in the shape of
66
+ # a constant, enforcing nothing — which is exactly how `gh pr review` came
67
+ # to be reachable by an agent nobody had granted it to.
68
+ FORBIDDEN_COMMANDS = [
69
+ "git push", "git commit",
70
+ "gh pr create", "gh pr edit", "gh pr merge",
71
+ "gh pr review", "gh pr comment",
72
+ "gh release create"
73
+ ].freeze
74
+
75
+ # The subset no agent's `may:` can lift, however its definition is written.
76
+ #
77
+ # Pushing and merging change a branch everybody else builds on, and an
78
+ # unattended loop doing either has no way to be wrong quietly. Reviewing
79
+ # does not: an approval is reversible, visible, and attributable to the
80
+ # identity that made it. That difference is the whole line between
81
+ # `hansolo-reviewer` approving and `hansolo-reviewer` merging.
82
+ UNGRANTABLE = ["git push", "gh pr merge"].freeze
83
+
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
+ # How much of what the agent said survives into a one-line report.
91
+ REASON_LIMIT = 300
92
+
93
+ # Where the raw stream of each invocation is kept.
94
+ #
95
+ # Under `run -j` several agents work at once, and more than one
96
+ # `agentilda` may be driving the same checkout, so a trace is named
97
+ # per invocation rather than shared. To get an agent's final answer back
98
+ # out of one afterwards:
99
+ #
100
+ # jq -r 'select(.type=="result").result' <trace>
101
+ #
102
+ # and to replay what it did, tool call by tool call:
103
+ #
104
+ # jq -r 'select(.type=="assistant")
105
+ # | .message.content[]?
106
+ # | select(.type=="tool_use")
107
+ # | "\(.name) \(.input|tostring[0:80])"' <trace>
108
+ #
109
+ # Outside the repository on purpose: the harness checks afterwards that the
110
+ # agent moved nothing it should not have, and a megabyte of NDJSON dropped
111
+ # into the working tree is exactly the kind of thing that check would then
112
+ # have to learn to ignore.
113
+ TRACE_DIR = File.join(Dir.tmpdir, "agentilda-traces")
114
+
115
+ # Environment variables the `claude` CLI reads as credentials, in
116
+ # preference to a claude.ai login.
117
+ #
118
+ # A project `.env` that sets one of these for the application's own use
119
+ # reaches every agent a run spawns. `claude` then authenticates with that
120
+ # key rather than the login, and a stale or unrelated one turns an entire
121
+ # run into `401 API key is invalid`, three minutes per agent. The CLI warns
122
+ # rather than unsets. Driving it with an API key on purpose is legitimate,
123
+ # and nothing here can tell the two apart.
124
+ CREDENTIAL_VARS = %w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN].freeze
125
+
126
+ # @param env [Hash]
127
+ # @return [Array<String>] credential variables currently set
128
+ def self.foreign_credentials(env = ENV)
129
+ CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
130
+ end
131
+
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
+ # @param root [String] the repository the agents work in
230
+ # @param command [TTY::Command]
231
+ # @param timeout [Integer] seconds before one agent is abandoned
232
+ # @param dry_run [Boolean] plan the invocation, do not run it
233
+ # @param trace_dir [String] where each invocation's raw stream is kept
234
+ # @param instructions [String, nil] what `run --prompt` typed, appended
235
+ # to the agent's own prompt. The command only accepts it alongside
236
+ # `--agent`, so exactly one agent ever hears it.
237
+ # @param model [String, nil] what `run --model` typed. The flag actually
238
+ # typed beats what an agent's frontmatter declares, the same precedence
239
+ # every other flag here follows; nil leaves each agent its own choice.
240
+ # @param max_tokens [Integer, nil] budget per invocation, input plus
241
+ # output, sub-agents included. The prompt states it so the agent can
242
+ # plan to finish inside it, and the meter enforces it so the statement
243
+ # 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,
249
+ trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false)
250
+ @root = File.expand_path(root)
251
+ @command = command
252
+ @timeout = timeout
253
+ @dry_run = dry_run
254
+ @trace_dir = trace_dir
255
+ @instructions = instructions.to_s.strip
256
+ @model = model
257
+ @max_tokens = max_tokens
258
+ @interactive = interactive
259
+ end
260
+
261
+ # @return [String]
262
+ attr_reader :root
263
+
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.
269
+ #
270
+ # @param agent [Agentilda::Agent]
271
+ # @return [Integer]
272
+ def timeout_for(agent) = agent.timeout || @timeout
273
+
274
+ # @param agent [Agentilda::Agent]
275
+ # @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)
282
+ started = UI.monotonic
283
+ 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)
286
+ end
287
+
288
+ before = head(root)
289
+ trace = trace_path(agent, subject)
290
+ 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)
320
+ end
321
+
322
+ if transcript.failed?
323
+ return failure(transcript, started, "claude reported: #{transcript.error} — trace: #{trace}")
324
+ end
325
+
326
+ violation = boundary_violation(before, root)
327
+ return failure(transcript, started, violation) if violation
328
+
329
+ spent(transcript, started, ok: true,
330
+ note: "completed#{" · #{transcript.tools} tool calls" if transcript.tools.positive?}")
331
+ end
332
+
333
+ # A failed invocation still spent what it spent, and a run that burned two
334
+ # hundred thousand tokens before timing out is a different fact from one
335
+ # that failed to authenticate and spent nothing. Both used to report the
336
+ # same thing.
337
+ #
338
+ # @param transcript [Agentilda::Transcript]
339
+ # @param started [Float]
340
+ # @param note [String]
341
+ # @return [Agentilda::Executor::Result]
342
+ def failure(transcript, started, note) = spent(transcript, started, ok: false, note:)
343
+
344
+ # @param transcript [Agentilda::Transcript]
345
+ # @param started [Float]
346
+ # @param ok [Boolean]
347
+ # @param note [String]
348
+ # @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)
353
+ end
354
+
355
+ # The exact argv, exposed so a spec can assert the boundary flags without
356
+ # running anything.
357
+ #
358
+ # @param agent [Agentilda::Agent]
359
+ # @param subject [Agentilda::Subject]
360
+ # @return [Array<String>]
361
+ def invocation(agent, subject, root: @root, control: nil)
362
+ # `--include-partial-messages` is what the token meter runs on. Without
363
+ # it the stream reports a settled input count and a placeholder output
364
+ # count — 2 for a four-thousand-token answer — and a spinner counting
365
+ # 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"]
368
+ denied = denied_for(agent)
369
+ argv += ["--disallowedTools", denied.join(",")] unless denied.empty?
370
+ argv += ["--allowedTools", agent.allowed_tools.join(",")] unless agent.allowed_tools.empty?
371
+ model = @model || agent.model
372
+ argv += ["--model", model] if model
373
+ argv
374
+ end
375
+
376
+ # What this particular agent may not touch: the network tools unless it
377
+ # asked for them, plus every forbidden command it has not been granted.
378
+ # Both decisions are recorded in a reviewable file rather than passed as a
379
+ # flag by whoever happened to start the run.
380
+ #
381
+ # @param agent [Agentilda::Agent]
382
+ # @return [Array<String>]
383
+ def denied_for(agent)
384
+ tools = agent.network ? [] : DENIED_TOOLS
385
+ tools + denied_commands(agent).map { |command| "Bash(#{command}:*)" }
386
+ end
387
+
388
+ # @param agent [Agentilda::Agent]
389
+ # @return [Array<String>] commands withheld from this agent
390
+ def denied_commands(agent) = FORBIDDEN_COMMANDS - granted_to(agent)
391
+
392
+ # @param agent [Agentilda::Agent]
393
+ # @return [Array<String>] what its `may:` actually buys it
394
+ def granted_to(agent) = agent.may - UNGRANTABLE
395
+
396
+ private
397
+
398
+ # One file per invocation. The plan number and the agent's name make it
399
+ # findable; the pid and the clock keep two concurrent runs from writing to
400
+ # the same one. See {TRACE_DIR} for how to read one back.
401
+ #
402
+ # @param agent [Agentilda::Agent]
403
+ # @param subject [Agentilda::Subject]
404
+ # @return [String]
405
+ def trace_path(agent, subject)
406
+ 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))
409
+ File.join(@trace_dir, name)
410
+ end
411
+
412
+ # @param agent [Agentilda::Agent]
413
+ # @param subject [Agentilda::Subject]
414
+ # @return [String]
415
+ def prompt_for(agent, subject, root = @root, control: nil)
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}#{budget_section}#{control_section(control)}
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 what you are about to write with ~/.claude/agent-lock.sh first,
445
+ and release it when you are done.
446
+ PROMPT
447
+ end
448
+
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.
452
+ #
453
+ # @param transcript [Agentilda::Transcript]
454
+ # @return [void]
455
+ # @raise [Agentilda::Executor::Aborted]
456
+ def abort_if_over(transcript)
457
+ spent = transcript.up + transcript.down
458
+ if @max_tokens&.positive? && spent > @max_tokens
459
+ raise Aborted, "token budget of #{@max_tokens} exceeded (↑#{transcript.up} ↓#{transcript.down})"
460
+ end
461
+ raise Aborted, "still running #{Control::GRACE}s after q" if Control.overdue?
462
+ end
463
+
464
+ # The section `run --max-tokens` adds. Stating the number is what lets
465
+ # the agent finish before it, rather than discovering the cap by dying
466
+ # on it with half a file written.
467
+ #
468
+ # @return [String]
469
+ def budget_section
470
+ return "" unless @max_tokens&.positive?
471
+
472
+ "\n## Token budget — #{@max_tokens} tokens, enforced\n\n" \
473
+ "This invocation is aborted once its total spend (input plus output, " \
474
+ "sub-agents included) crosses #{@max_tokens} tokens. Budget the work: " \
475
+ "plan what fits, write results to disk as you go, and finish — or " \
476
+ "write a handoff note into the plan folder — before the meter runs " \
477
+ "out. Anything unwritten at the cap is lost.\n"
478
+ end
479
+
480
+ # The section a control file adds, present only when someone is at the
481
+ # keyboard to write into it.
482
+ #
483
+ # @param control [String, nil]
484
+ # @return [String]
485
+ def control_section(control)
486
+ return "" if control.nil?
487
+
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"
494
+ end
495
+
496
+ # The section `run --prompt` adds, labelled as coming from the person who
497
+ # started the run so the agent can tell a one-off steer from its own
498
+ # standing definition.
499
+ #
500
+ # @return [String]
501
+ def operator_instructions
502
+ return "" if @instructions.empty?
503
+
504
+ "\n## Operator instructions — this invocation only\n\n#{@instructions}\n"
505
+ end
506
+
507
+ # Spelled out in the prompt as well as withheld at the tool layer, because
508
+ # an agent that does not know it has been granted something does not use
509
+ # it — and `hansolo-reviewer` silently never approving anything looks
510
+ # exactly like `hansolo-reviewer` approving nothing worth approving.
511
+ #
512
+ # @param agent [Agentilda::Agent]
513
+ # @return [String]
514
+ def granted(agent)
515
+ granted = granted_to(agent)
516
+ return "" if granted.empty?
517
+
518
+ "\nYou may run these, which most agents may not:\n\n" +
519
+ granted.map { |c| " #{c}" }.join("\n") + "\n"
520
+ end
521
+
522
+ # @return [String, nil] current commit, or nil outside a repository
523
+ def head(root = @root)
524
+ out = `git -C #{root.shellescape} rev-parse HEAD 2>/dev/null`.strip
525
+ out.empty? ? nil : out
526
+ end
527
+
528
+ # @param before [String, nil]
529
+ # @return [String, nil] what boundary was crossed, or nil
530
+ def boundary_violation(before, root = @root)
531
+ return nil if before.nil?
532
+
533
+ after = head(root)
534
+ return "agent committed (HEAD moved #{before[0, 7]} → #{after[0, 7]}) — the boundary is docs plus code, no commits" if after != before
535
+
536
+ nil
537
+ end
538
+ end
539
+ end