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.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
data/lib/agentilda/executor.rb
DELETED
|
@@ -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
|