agentilda 1.0.3 → 2.0.1
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/agentilda.gemspec +10 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +92 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +46 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +65 -13
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
|
@@ -1,37 +1,60 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
module CLI
|
|
5
7
|
# `agentilda run` — drive the specialist agents until the tree settles.
|
|
6
8
|
class Run < Base
|
|
7
9
|
desc "Run specialist agents over the plans until nothing changes"
|
|
8
10
|
|
|
9
|
-
option :commit,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
option :
|
|
14
|
-
desc: "
|
|
15
|
-
"(default:
|
|
11
|
+
option :commit,
|
|
12
|
+
type: :boolean,
|
|
13
|
+
default: false,
|
|
14
|
+
desc: "Actually invoke the agents (default: dry run, prints the plan of work)"
|
|
15
|
+
option :rounds,
|
|
16
|
+
desc: "Cap on rounds per agent per plan; each agent declares its own, at most 5 " \
|
|
17
|
+
"(default: the agent's own)"
|
|
18
|
+
option :timeout,
|
|
19
|
+
desc: "Seconds before one agent is abandoned; only tightens an agent's own clock " \
|
|
20
|
+
"(default: the agent's own, else 900)"
|
|
16
21
|
option :agent, desc: "Only run this one agent"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
option :
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"the
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
# `--prompt` shorter than this that names an existing file is read.
|
|
23
|
+
PROMPT_PATH_LIMIT = 80
|
|
24
|
+
|
|
25
|
+
option :prompt,
|
|
26
|
+
desc: "Extra instructions appended to the agent's prompt (only with --agent); " \
|
|
27
|
+
"a path to an existing text file under #{PROMPT_PATH_LIMIT} characters is read instead"
|
|
28
|
+
option :skip,
|
|
29
|
+
desc: "Never assign this agent; its plans wait, the rest of the pipeline runs. " \
|
|
30
|
+
"Comma separated for several"
|
|
31
|
+
option :model,
|
|
32
|
+
desc: "Model for every agent this run, overriding each agent's own frontmatter " \
|
|
33
|
+
"(default: what the agent declares, else the claude CLI's default)"
|
|
34
|
+
option :max_tokens,
|
|
35
|
+
desc: "Token budget per agent invocation, input plus output, sub-agents " \
|
|
36
|
+
"included. The agent is told the number so it can finish inside it; the meter aborts it past " \
|
|
37
|
+
"the number so the telling is true. (default: unmetered)"
|
|
38
|
+
option :plan,
|
|
39
|
+
aliases: ["--plans"],
|
|
40
|
+
desc: "Only these plans, comma separated: NNN or NNN.MM, e.g. --plan 003,005.01. Default: the whole tree"
|
|
27
41
|
option :root, desc: "Repository root the agents work in (default: the .plans parent)"
|
|
28
|
-
option :isolation,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
desc:
|
|
32
|
-
option :
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
option :isolation,
|
|
43
|
+
default: "worktree",
|
|
44
|
+
values: %w[worktree shared],
|
|
45
|
+
desc: "worktree: a checkout and branch per plan, run in parallel. shared: one tree, serial"
|
|
46
|
+
option :jobs,
|
|
47
|
+
aliases: ["-j"],
|
|
48
|
+
desc: "Agents to run at once (default: cores - 2, capped at 12)"
|
|
49
|
+
option :git_push,
|
|
50
|
+
type: :boolean,
|
|
51
|
+
default: true,
|
|
52
|
+
desc: "With --commit and --isolation worktree, a finished branch is pushed and its pull request " \
|
|
53
|
+
"opened as soon as it lands, titled [NNN.MM](X). --no-git-push turns that off and leaves it uncommitted."
|
|
54
|
+
option :scroll_height,
|
|
55
|
+
aliases: ["-s"],
|
|
56
|
+
desc: "Lines of each agent's latest statuses shown under its row, newest first " \
|
|
57
|
+
"(default: #{Screen::Ratatui::SCROLL_HEIGHT})"
|
|
35
58
|
option :log, desc: "Append progress to this file (default: a per-project file under the system temp dir)"
|
|
36
59
|
|
|
37
60
|
example [
|
|
@@ -39,8 +62,9 @@ module Agentilda
|
|
|
39
62
|
"--commit # run them, one worktree per plan, in parallel",
|
|
40
63
|
"--commit -j 4 # …with four at a time",
|
|
41
64
|
"--isolation shared # one tree, serial — no git required",
|
|
42
|
-
"--commit --rounds 3 # …
|
|
43
|
-
"--commit --plan 005,006,007 # only the plans a batch step just created"
|
|
65
|
+
"--commit --rounds 3 # …capping every agent at three rounds per plan",
|
|
66
|
+
"--commit --plan 005,006,007 # only the plans a batch step just created",
|
|
67
|
+
"--commit -s 5 # …each agent's last five statuses under its row"
|
|
44
68
|
]
|
|
45
69
|
|
|
46
70
|
# Option precedence, quietest to loudest: the built-in default, then
|
|
@@ -59,31 +83,26 @@ module Agentilda
|
|
|
59
83
|
|
|
60
84
|
if options[:prompt] && !options[:agent]
|
|
61
85
|
refuse("--prompt only works with --agent: it speaks to one agent, and without " \
|
|
62
|
-
"that restriction every agent in the round would hear it.",
|
|
86
|
+
"that restriction every agent in the round would hear it.",
|
|
87
|
+
64)
|
|
63
88
|
end
|
|
64
89
|
|
|
65
|
-
tree
|
|
66
|
-
root
|
|
90
|
+
tree = tree_for(options)
|
|
91
|
+
root = options[:root] || File.dirname(tree.dir)
|
|
67
92
|
agents = Agentilda::Agents.new
|
|
68
93
|
agents = filtered(agents, options[:agent]) if options[:agent]
|
|
69
94
|
agents = skipped(agents, options[:skip], options[:agent]) if options[:skip]
|
|
70
|
-
plans
|
|
95
|
+
plans = options[:plan] ? scoped(tree, options[:plan]) : nil
|
|
71
96
|
assignable!(agents, tree, plans, options[:agent]) if options[:agent]
|
|
72
97
|
|
|
73
98
|
isolation = options.fetch(:isolation, "worktree").to_sym
|
|
74
|
-
jobs
|
|
75
|
-
timeout
|
|
76
|
-
chain = if options[:agent]
|
|
77
|
-
false
|
|
78
|
-
elsif options.key?(:chain)
|
|
79
|
-
options[:chain]
|
|
80
|
-
else
|
|
81
|
-
config.fetch(:chain, true)
|
|
82
|
-
end
|
|
99
|
+
jobs = (options[:jobs] || config[:jobs] || UI.default_jobs).to_i
|
|
100
|
+
timeout = (options[:timeout] || config[:timeout])&.to_i
|
|
83
101
|
|
|
84
|
-
if isolation == :worktree &&
|
|
102
|
+
if isolation == :worktree && !::Agentilda::Worktree.new(root:).repository?
|
|
85
103
|
refuse("#{root} is not a git repository, so plans cannot be isolated.\n\n" \
|
|
86
|
-
"Run with --isolation shared to work in one tree, serially.",
|
|
104
|
+
"Run with --isolation shared to work in one tree, serially.",
|
|
105
|
+
66)
|
|
87
106
|
end
|
|
88
107
|
|
|
89
108
|
# Set before the loop starts, not after: `UI.animate?` (and therefore
|
|
@@ -91,37 +110,124 @@ module Agentilda
|
|
|
91
110
|
# loop runs, not just when `report` prints its closing summary.
|
|
92
111
|
quiet?(options)
|
|
93
112
|
UI.log_path = options[:log] || config[:log] ||
|
|
94
|
-
|
|
113
|
+
File.join(Dir.tmpdir, "agentilda-#{File.basename(root)}.log")
|
|
95
114
|
info("Progress: #{UI.log_path}") unless quiet?(options)
|
|
96
115
|
credentials_warning if commit?(options) && !quiet?(options)
|
|
97
116
|
|
|
117
|
+
# The state file is what a harness that dies leaves for the next one.
|
|
118
|
+
# It sits in .plans/ so a reboot keeps it, and it is ignored by name
|
|
119
|
+
# so it never rides along in a commit. Editing somebody's .gitignore
|
|
120
|
+
# is announced, the once it happens. The runner drops the file on a
|
|
121
|
+
# dry run, which is why it can be built unconditionally.
|
|
122
|
+
state = StateFile.new(path: StateFile.for(tree))
|
|
123
|
+
if commit?(options) && StateFile.ensure_ignored!(root) && !quiet?(options)
|
|
124
|
+
info("Added #{StateFile::IGNORE.join(" and ")} to .gitignore: the run keeps its state there.")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The screen only on a terminal that is actually running agents: a dry
|
|
128
|
+
# run has nothing to draw and a pipe has nowhere to draw it. Without a
|
|
129
|
+
# screen the keys still work, so the one line says which.
|
|
130
|
+
# With a screen, ratatui owns the terminal's input, so the keyboard is
|
|
131
|
+
# fed from its loop rather than started on STDIN of its own.
|
|
98
132
|
Control.reset!
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
scroll_height = options.fetch(:scroll_height, Screen::Ratatui::SCROLL_HEIGHT)
|
|
134
|
+
unless scroll_height.to_s.match?(/\A[1-9]\d*\z/)
|
|
135
|
+
refuse("--scroll-height must be a whole number of lines, 1 or more, not #{scroll_height.inspect}.", 64)
|
|
136
|
+
end
|
|
137
|
+
screen = (Screen::Ratatui.new(scroll_height: scroll_height.to_i) if UI.animate? && commit?(options))
|
|
138
|
+
console = (Console.new(screen:) if screen)
|
|
139
|
+
# A dry run gets no keyboard at all: every key it offers speaks to a
|
|
140
|
+
# running agent, and listening costs the terminal's raw mode, whose
|
|
141
|
+
# output post-processing the report below needs left alone.
|
|
142
|
+
keyboard = if screen
|
|
143
|
+
Keyboard.new(sink: console).tap { |kb| screen.attach_keyboard(kb) }
|
|
144
|
+
elsif commit?(options)
|
|
145
|
+
Keyboard.listen(sink: console)
|
|
146
|
+
end
|
|
147
|
+
UI.line("keys: h for help - s select, k kill, x extend, w wrap up, n stop, q quit") if keyboard && console.nil? && !quiet?(options)
|
|
101
148
|
|
|
102
149
|
runner = Runner.new(
|
|
103
|
-
tree:,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
150
|
+
tree:,
|
|
151
|
+
agents:,
|
|
152
|
+
isolation:,
|
|
153
|
+
jobs:,
|
|
154
|
+
plans:,
|
|
155
|
+
state:,
|
|
156
|
+
worktree: (::Agentilda::Worktree.new(root:) if isolation == :worktree),
|
|
157
|
+
rounds: (options[:rounds] || config[:rounds])&.to_i,
|
|
158
|
+
executor: Executor.new(root:,
|
|
159
|
+
timeout:,
|
|
160
|
+
dry_run: !commit?(options),
|
|
161
|
+
instructions: instructions_from(options[:prompt]),
|
|
162
|
+
model: options[:model],
|
|
163
|
+
max_tokens: (options[:max_tokens] || config[:max_tokens])&.to_i,
|
|
164
|
+
interactive: !keyboard.nil? && commit?(options)),
|
|
165
|
+
dry_run: !commit?(options),
|
|
166
|
+
publisher: publisher_for(root, isolation, options),
|
|
167
|
+
on_board: console&.method(:paint)
|
|
111
168
|
)
|
|
112
169
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
170
|
+
# The dispatcher exists only once the run starts, so the console is
|
|
171
|
+
# introduced to it through the block rather than up front.
|
|
172
|
+
started = UI.monotonic
|
|
173
|
+
attempts = begin
|
|
174
|
+
screen&.open
|
|
175
|
+
Control.on_interrupt { runner.call { |dispatcher| console&.attach(dispatcher) } }
|
|
116
176
|
ensure
|
|
177
|
+
screen&.close
|
|
117
178
|
keyboard&.stop
|
|
118
179
|
end
|
|
119
|
-
report(runner,
|
|
120
|
-
exit(failures(
|
|
180
|
+
report(runner, attempts, options, seconds: UI.monotonic - started)
|
|
181
|
+
exit(failures(attempts).empty? ? 0 : 1)
|
|
121
182
|
end
|
|
122
183
|
|
|
123
184
|
private
|
|
124
185
|
|
|
186
|
+
# The attempts list, padded into columns.
|
|
187
|
+
#
|
|
188
|
+
# Tabs used to separate these, which put every column on the next
|
|
189
|
+
# multiple of eight: one agent's name a character longer than the
|
|
190
|
+
# next one's pushed its round and mark a whole stop to the right, so
|
|
191
|
+
# nothing under `luke-backend` and `yoda-writer` lined up. Widths come
|
|
192
|
+
# from the rows themselves, so the columns are as narrow as the run
|
|
193
|
+
# allows and still square.
|
|
194
|
+
#
|
|
195
|
+
# @param attempts [Array<Agentilda::Runner::Attempt>]
|
|
196
|
+
# @return [Array<String>] one line per attempt, indented by two
|
|
197
|
+
def attempt_lines(attempts)
|
|
198
|
+
rows = attempts.map { |a| [a.ordinal.to_s, a.agent.to_s, "[R:#{a.round}]", mark(a), a.note.to_s] }
|
|
199
|
+
widths = rows.transpose.map { |column| column.map { |cell| UI.display_width(cell) }.max.to_i }
|
|
200
|
+
rows.map do |cells|
|
|
201
|
+
# The note is last and is left as it is: padding the final column
|
|
202
|
+
# only adds trailing whitespace a copy-paste then carries.
|
|
203
|
+
last = cells.size - 1
|
|
204
|
+
" #{cells.each_with_index.map { |cell, i| i == last ? cell : UI.fit(cell, widths[i] + 2) }.join}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @param attempt [Agentilda::Runner::Attempt]
|
|
209
|
+
# @return [String] what the attempt did to its plan's state
|
|
210
|
+
def mark(attempt)
|
|
211
|
+
return "FAIL" unless attempt.ok
|
|
212
|
+
return "#{attempt.from} -> #{attempt.to}" if attempt.advanced?
|
|
213
|
+
|
|
214
|
+
"no change"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# `--prompt` takes the instructions themselves or a file holding them.
|
|
218
|
+
# A short argument naming an existing file is read; anything else is
|
|
219
|
+
# the text, so a sentence that happens to match a filename in the
|
|
220
|
+
# project is not silently swapped for that file once it is long enough
|
|
221
|
+
# to be a sentence.
|
|
222
|
+
#
|
|
223
|
+
# @param prompt [String, nil]
|
|
224
|
+
# @return [String, nil]
|
|
225
|
+
def instructions_from(prompt)
|
|
226
|
+
return prompt unless prompt && prompt.length < PROMPT_PATH_LIMIT && File.file?(prompt)
|
|
227
|
+
|
|
228
|
+
File.read(prompt)
|
|
229
|
+
end
|
|
230
|
+
|
|
125
231
|
# @param agents [Agentilda::Agents]
|
|
126
232
|
# @param name [String]
|
|
127
233
|
# @return [Agentilda::Agents] holding only the agent named — the
|
|
@@ -143,14 +249,15 @@ module Agentilda
|
|
|
143
249
|
# @return [Agentilda::Agents] without the agents named
|
|
144
250
|
def skipped(agents, text, restricted)
|
|
145
251
|
roster = Agentilda::Agents.new
|
|
146
|
-
names
|
|
252
|
+
names = text.split(",").map(&:strip).reject(&:empty?)
|
|
147
253
|
names.each do |name|
|
|
148
254
|
roster.find(name) or
|
|
149
255
|
refuse("No agent called #{name}.\n\nKnown: #{roster.all.map(&:name).join(", ")}", 65)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
256
|
+
next unless name == restricted
|
|
257
|
+
|
|
258
|
+
refuse("--agent #{name} and --skip #{name} contradict each other: " \
|
|
259
|
+
"one asks for only this agent, the other for anything but.",
|
|
260
|
+
64)
|
|
154
261
|
end
|
|
155
262
|
agents.without(*names)
|
|
156
263
|
end
|
|
@@ -170,7 +277,8 @@ module Agentilda
|
|
|
170
277
|
ordinal = Ordinal.parse(token)
|
|
171
278
|
unless ordinal && known.include?(ordinal)
|
|
172
279
|
refuse("No plan #{token} in #{tree.dir}.\n\n" \
|
|
173
|
-
"Known: #{known.join(", ")}",
|
|
280
|
+
"Known: #{known.join(", ")}",
|
|
281
|
+
66)
|
|
174
282
|
end
|
|
175
283
|
ordinal
|
|
176
284
|
}
|
|
@@ -188,27 +296,29 @@ module Agentilda
|
|
|
188
296
|
# @param name [String]
|
|
189
297
|
# @return [void]
|
|
190
298
|
def assignable!(agents, tree, plans, name)
|
|
191
|
-
agent
|
|
299
|
+
agent = agents.find(name)
|
|
192
300
|
active = tree.subjects.select { |s|
|
|
193
301
|
(plans.nil? || plans.include?(s.feature.ordinal)) &&
|
|
194
302
|
!StateMachine::SETTLED.include?(s.status.key)
|
|
195
303
|
}
|
|
196
304
|
return if active.empty? || active.any? { |s| agent.handles?(s.status) }
|
|
197
305
|
|
|
198
|
-
roster
|
|
199
|
-
lines
|
|
306
|
+
roster = Agentilda::Agents.new
|
|
307
|
+
lines = active.map { |s|
|
|
200
308
|
takers = roster.for_status(s.status).map(&:name)
|
|
309
|
+
verb = takers.size == 1 ? "takes" : "take"
|
|
201
310
|
" #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
|
|
202
|
-
"#{"
|
|
311
|
+
"#{" - #{takers.join(" and ")} #{verb} it" unless takers.empty?}"
|
|
203
312
|
}
|
|
204
313
|
handled = agent.handles.map { |k| Agentilda::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
|
|
205
314
|
refuse("#{name} handles #{handled.join(", ")}, and no plan in scope is there:\n\n" \
|
|
206
|
-
"#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.",
|
|
315
|
+
"#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.",
|
|
316
|
+
65)
|
|
207
317
|
end
|
|
208
318
|
|
|
209
|
-
# @param
|
|
210
|
-
# @return [Array]
|
|
211
|
-
def failures(
|
|
319
|
+
# @param attempts [Array<Agentilda::Runner::Attempt>]
|
|
320
|
+
# @return [Array<Agentilda::Runner::Attempt>]
|
|
321
|
+
def failures(attempts) = attempts.reject(&:ok)
|
|
212
322
|
|
|
213
323
|
# The one thing this harness does that leaves the machine — pushing a
|
|
214
324
|
# branch and opening its pull request — so it takes one more thing to
|
|
@@ -216,7 +326,7 @@ module Agentilda
|
|
|
216
326
|
# isolation has to actually give each plan a branch of its own too.
|
|
217
327
|
#
|
|
218
328
|
# `nil` is how {Runner} is told to leave a finished worktree alone; it
|
|
219
|
-
# is what `--
|
|
329
|
+
# is what `--no-git-push` asks for, and what a shared tree gets
|
|
220
330
|
# by construction, since there is no separate branch there to push.
|
|
221
331
|
#
|
|
222
332
|
# @param root [String]
|
|
@@ -224,35 +334,33 @@ module Agentilda
|
|
|
224
334
|
# @param options [Hash]
|
|
225
335
|
# @return [Agentilda::Publisher, nil]
|
|
226
336
|
def publisher_for(root, isolation, options)
|
|
227
|
-
return nil if isolation != :worktree || options[:
|
|
337
|
+
return nil if isolation != :worktree || options[:git_push] == false
|
|
228
338
|
|
|
229
339
|
Publisher.new(root:, dry_run: !commit?(options))
|
|
230
340
|
end
|
|
231
341
|
|
|
342
|
+
# One line per attempt: the plan, who ran, which of that agent's rounds
|
|
343
|
+
# it was, what moved, and the note. There is no round header any more
|
|
344
|
+
# because the loop has no rounds of its own: each agent counts its own
|
|
345
|
+
# per plan, so two agents on one plan can be on different numbers.
|
|
346
|
+
#
|
|
232
347
|
# @param runner [Agentilda::Runner]
|
|
233
|
-
# @param
|
|
348
|
+
# @param attempts [Array<Agentilda::Runner::Attempt>]
|
|
234
349
|
# @param options [Hash]
|
|
235
350
|
# @param seconds [Float] wall clock for the whole loop
|
|
236
351
|
# @return [void]
|
|
237
|
-
def report(runner,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"#{a.from} -> #{a.to}"
|
|
245
|
-
else
|
|
246
|
-
"no change"
|
|
247
|
-
end
|
|
248
|
-
puts " #{a.ordinal}\t#{a.agent}\t#{mark}\t#{a.note}"
|
|
249
|
-
end
|
|
250
|
-
end
|
|
352
|
+
def report(runner, attempts, options, seconds: 0.0)
|
|
353
|
+
# A leading blank line, because the spinners this replaces write to
|
|
354
|
+
# STDERR and the last of them may have left the cursor mid-line:
|
|
355
|
+
# without it the first attempt starts wherever that line stopped,
|
|
356
|
+
# indented by however much it had drawn.
|
|
357
|
+
puts ""
|
|
358
|
+
puts attempt_lines(attempts)
|
|
251
359
|
|
|
252
|
-
# What the run cost, on STDOUT with the
|
|
360
|
+
# What the run cost, on STDOUT with the attempts it belongs to, so a run
|
|
253
361
|
# redirected to a file keeps its bill. A dry run spent nothing and gets
|
|
254
362
|
# none of this.
|
|
255
|
-
tally = Tally.new(attempts
|
|
363
|
+
tally = Tally.new(attempts:, seconds:)
|
|
256
364
|
puts("", tally.render) if commit?(options)
|
|
257
365
|
|
|
258
366
|
return if quiet?(options)
|
|
@@ -262,27 +370,27 @@ module Agentilda
|
|
|
262
370
|
"re-run to resume where they stand.")
|
|
263
371
|
end
|
|
264
372
|
|
|
265
|
-
advanced =
|
|
266
|
-
blocked
|
|
267
|
-
summary
|
|
373
|
+
advanced = attempts.count(&:advanced?)
|
|
374
|
+
blocked = runner.blocked
|
|
375
|
+
summary = ["#{attempts.size} invocation#{"s" unless attempts.size == 1}", "#{advanced} advanced"]
|
|
268
376
|
summary << (runner.isolated? ? "#{runner.jobs} at a time, one worktree each" : "serial, shared tree")
|
|
269
377
|
summary << "#{blocked.size} blocked" unless blocked.empty?
|
|
270
|
-
summary << "#{failures(
|
|
378
|
+
summary << "#{failures(attempts).size} failed" unless failures(attempts).empty?
|
|
271
379
|
|
|
272
380
|
if !commit?(options)
|
|
273
381
|
warn("Dry run — no agent was invoked.\n#{summary.join(" · ")}\n\nRe-run with --commit.")
|
|
274
|
-
elsif failures(
|
|
382
|
+
elsif failures(attempts).empty?
|
|
275
383
|
success("#{summary.join(" · ")}#{"\n\nEvery plan is done or deliberately parked." if runner.settled?}")
|
|
276
384
|
else
|
|
277
|
-
error("#{summary.join(" · ")}\n\n#{failures(
|
|
385
|
+
error("#{summary.join(" · ")}\n\n#{failures(attempts).map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
|
|
278
386
|
end
|
|
279
387
|
|
|
280
388
|
return if blocked.empty?
|
|
281
389
|
|
|
282
390
|
warn("#{blocked.size} plan#{"s" unless blocked.size == 1} need a human decision:\n" +
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
391
|
+
blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
|
|
392
|
+
"\n\nWrite the answers into blocked.md, then: agentilda unblock " \
|
|
393
|
+
"#{blocked.map { |b| b.feature.ordinal }.join(",")} --commit")
|
|
286
394
|
end
|
|
287
395
|
end
|
|
288
396
|
end
|
|
@@ -17,17 +17,21 @@ module Agentilda
|
|
|
17
17
|
|
|
18
18
|
# What each token turned out to be, for whoever has to fix it.
|
|
19
19
|
PROBLEMS = {
|
|
20
|
-
missing:
|
|
20
|
+
missing: "no plan of that number",
|
|
21
21
|
not_blocked: "no blocked.md, so there is nothing to drain"
|
|
22
22
|
}.freeze
|
|
23
23
|
|
|
24
24
|
desc "Fold answered blocks into a plan's documents and retire blocked.md"
|
|
25
25
|
|
|
26
|
-
argument :plans,
|
|
27
|
-
|
|
26
|
+
argument :plans,
|
|
27
|
+
type: :array,
|
|
28
|
+
required: true,
|
|
29
|
+
desc: "Which plans to drain: NNN or NNN.MM, e.g. 003 005.01"
|
|
28
30
|
|
|
29
|
-
option :commit,
|
|
30
|
-
|
|
31
|
+
option :commit,
|
|
32
|
+
type: :boolean,
|
|
33
|
+
default: false,
|
|
34
|
+
desc: "Actually invoke the agent (default: dry run, prints the questions still open)"
|
|
31
35
|
option :agent, default: DEFAULT_AGENT, desc: "Hand the folder to a different agent"
|
|
32
36
|
option :root, desc: "Repository root the agent works in (default: the .plans parent)"
|
|
33
37
|
|
|
@@ -43,8 +47,11 @@ module Agentilda
|
|
|
43
47
|
def call(plans:, **options)
|
|
44
48
|
tree = tree_for(options)
|
|
45
49
|
quiet?(options)
|
|
46
|
-
unblocker = Unblocker.new(tree:,
|
|
47
|
-
|
|
50
|
+
unblocker = Unblocker.new(tree:,
|
|
51
|
+
agent: agent_for(options),
|
|
52
|
+
root: options[:root],
|
|
53
|
+
commit: commit?(options),
|
|
54
|
+
executor: Executor.new(root: options[:root] || File.dirname(tree.dir),
|
|
48
55
|
dry_run: !commit?(options)))
|
|
49
56
|
|
|
50
57
|
targets = unblocker.resolve(plans)
|
|
@@ -55,7 +62,7 @@ module Agentilda
|
|
|
55
62
|
credentials_warning if commit?(options) && !quiet?(options)
|
|
56
63
|
preflight(subjects, unblocker, options)
|
|
57
64
|
|
|
58
|
-
outcomes = unblocker.call(subjects)
|
|
65
|
+
outcomes = Control.on_interrupt { unblocker.call(subjects) }
|
|
59
66
|
outcomes.each { |outcome| report(outcome, options) }
|
|
60
67
|
footer(outcomes, options) unless quiet?(options)
|
|
61
68
|
exit(worst(targets, outcomes))
|
|
@@ -110,7 +117,7 @@ module Agentilda
|
|
|
110
117
|
# @param unblocker [Agentilda::Unblocker]
|
|
111
118
|
# @param options [Hash]
|
|
112
119
|
# @return [void]
|
|
113
|
-
def preflight(subjects,
|
|
120
|
+
def preflight(subjects, _unblocker, options)
|
|
114
121
|
return if quiet?(options)
|
|
115
122
|
|
|
116
123
|
subjects.each do |subject|
|
|
@@ -190,7 +197,7 @@ module Agentilda
|
|
|
190
197
|
|
|
191
198
|
unless commit?(options)
|
|
192
199
|
warn("Dry run: no agent was invoked, and #{outcomes.size} " \
|
|
193
|
-
"plan#{"s" unless outcomes.size == 1} #{
|
|
200
|
+
"plan#{"s" unless outcomes.size == 1} #{outcomes.size == 1 ? "is" : "are"} unchanged.\n" \
|
|
194
201
|
"Re-run with --commit to fold in whatever has been answered.")
|
|
195
202
|
return
|
|
196
203
|
end
|
|
@@ -203,9 +210,13 @@ module Agentilda
|
|
|
203
210
|
success("#{folded} question#{"s" unless folded == 1} folded in · " \
|
|
204
211
|
"#{cleared} plan#{"s" unless cleared == 1} out of the block · " \
|
|
205
212
|
"#{waiting} still waiting on a human." +
|
|
206
|
-
(folded.zero?
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
(if folded.zero?
|
|
214
|
+
"\n\nNothing moved. An answer has to be written into blocked.md as its own " \
|
|
215
|
+
"`## A<n>` section, answering the `## B<n>` of the same number, before " \
|
|
216
|
+
"there is anything to fold."
|
|
217
|
+
else
|
|
218
|
+
""
|
|
219
|
+
end))
|
|
209
220
|
end
|
|
210
221
|
|
|
211
222
|
# The most serious thing that happened, as an exit status. A tree that
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda worktree` — create and seed worktrees for plans.
|
|
6
|
+
class Worktree < Base
|
|
7
|
+
desc "Create and seed a worktree for a plan"
|
|
8
|
+
|
|
9
|
+
argument :plan,
|
|
10
|
+
required: true,
|
|
11
|
+
desc: "Plan ordinal (e.g. 002, 002.00, or a plan folder name)"
|
|
12
|
+
|
|
13
|
+
option :skip_seed,
|
|
14
|
+
type: :boolean,
|
|
15
|
+
default: false,
|
|
16
|
+
desc: "Create the worktree but skip seeding it with .env and credential files"
|
|
17
|
+
|
|
18
|
+
example [
|
|
19
|
+
"002 # create/seed the worktree for plan 002.00",
|
|
20
|
+
"002.01 # create/seed the worktree for plan 002.01",
|
|
21
|
+
"002.00-⚪️ → update-dsl # find the plan by its folder name",
|
|
22
|
+
"--skip-seed 003 # create the worktree but don't seed it"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# @param plan [String] plan identifier
|
|
26
|
+
# @param options [Hash]
|
|
27
|
+
# @return [void]
|
|
28
|
+
def call(plan:, **options)
|
|
29
|
+
tree = tree_for(options)
|
|
30
|
+
is_quiet = quiet?(options)
|
|
31
|
+
|
|
32
|
+
feature = resolve_plan(tree, plan)
|
|
33
|
+
unless feature
|
|
34
|
+
refuse("Could not find plan: #{plan}", 66)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
root = File.dirname(tree.dir)
|
|
38
|
+
worktree = ::Agentilda::Worktree.new(root:)
|
|
39
|
+
|
|
40
|
+
checkout = worktree.checkout_for(feature)
|
|
41
|
+
|
|
42
|
+
return if is_quiet
|
|
43
|
+
|
|
44
|
+
if checkout.created?
|
|
45
|
+
puts("✓ Created worktree for #{feature.ordinal}: #{checkout.path}")
|
|
46
|
+
else
|
|
47
|
+
puts("✓ Using existing worktree for #{feature.ordinal}: #{checkout.path}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless options[:skip_seed]
|
|
51
|
+
seeded = worktree.seed(checkout.path)
|
|
52
|
+
if seeded
|
|
53
|
+
puts("✓ Seeded with .env and credential files")
|
|
54
|
+
else
|
|
55
|
+
warn("⚠ Could not seed worktree, see above for details")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
puts("")
|
|
60
|
+
success("Worktree ready for #{feature.ordinal}")
|
|
61
|
+
puts("Path: #{checkout.path}")
|
|
62
|
+
puts("Branch: #{checkout.branch}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Find the plan that matches the given identifier.
|
|
68
|
+
#
|
|
69
|
+
# @param tree [Agentilda::Tree] plans directory wrapper
|
|
70
|
+
# @param plan [String] ordinal, folder name, or slug
|
|
71
|
+
# @return [Agentilda::Feature, nil]
|
|
72
|
+
def resolve_plan(tree, plan)
|
|
73
|
+
# Try parsing as an ordinal first
|
|
74
|
+
ordinal = ::Agentilda::Ordinal.parse(plan)
|
|
75
|
+
if ordinal
|
|
76
|
+
found = tree.features.find { |f| f.ordinal == ordinal }
|
|
77
|
+
return found if found
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Try matching by folder name
|
|
81
|
+
tree.features.find { |f| f.dirname == plan }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|