agentilda 2.0.1 → 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/brief.rb
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "tty/command"
|
|
4
|
-
|
|
5
|
-
module Agentilda
|
|
6
|
-
# A new plan's opening brief: four questions, and a best-effort first pass
|
|
7
|
-
# at answering them from what the project already has on disk.
|
|
8
|
-
#
|
|
9
|
-
# This is for work that does not exist yet. A plan created `--after` a
|
|
10
|
-
# shipped pull request already has its facts — {CLI::Create} hands those to
|
|
11
|
-
# `yoda-writer`, which reconstructs a specification from a diff, not this.
|
|
12
|
-
# Guessing at a diff and guessing at a feature nobody has built are
|
|
13
|
-
# different jobs, and conflating them is how a retroactive spec ends up
|
|
14
|
-
# full of hedges about something that already, provably, happened.
|
|
15
|
-
#
|
|
16
|
-
# The four headings are the brief `leah-researcher` and `yoda-writer` both
|
|
17
|
-
# read *before* their own chapters: what research needs to settle is a
|
|
18
|
-
# question about the gaps in `## What already exists`, so a scaffold
|
|
19
|
-
# missing that heading forces both of them to first reconstruct a boundary
|
|
20
|
-
# this class already had for free — it just created the folder.
|
|
21
|
-
class Brief
|
|
22
|
-
# The half-agent's name. It has no `agents/*.md` of its own on purpose —
|
|
23
|
-
# a roster entry would put it in `Runner`'s routing, and this pass runs
|
|
24
|
-
# only inside `create`. The name exists so the UI can show it the same
|
|
25
|
-
# way it shows `leah-researcher` and `yoda-writer` on their spinner lines.
|
|
26
|
-
AGENT_NAME = "anakin-briefster"
|
|
27
|
-
|
|
28
|
-
HEADINGS = [
|
|
29
|
-
"What we are trying to achieve",
|
|
30
|
-
"Why it matters",
|
|
31
|
-
"What already exists",
|
|
32
|
-
"What research needs to settle"
|
|
33
|
-
].freeze
|
|
34
|
-
|
|
35
|
-
# Read whole into the drafting prompt, in order, so the model sees the
|
|
36
|
-
# project's own account of itself before it sees the topic.
|
|
37
|
-
CONTEXT_FILES = %w[CLAUDE.md AGENTS.md README.md].freeze
|
|
38
|
-
|
|
39
|
-
# Relative to the repository root. Not every project keeps one — a
|
|
40
|
-
# missing backlog is silently skipped, not an error.
|
|
41
|
-
BACKLOG_FILE = File.join(Agentilda::PLANS_DIR, "BACKLOG.md")
|
|
42
|
-
|
|
43
|
-
# Long enough for a handful of Read/Grep/Glob calls over a local
|
|
44
|
-
# checkout; short enough that a hung `claude` does not stall `create`
|
|
45
|
-
# past the point where the scaffold is already there to open by hand.
|
|
46
|
-
TIMEOUT = 60
|
|
47
|
-
|
|
48
|
-
# The fastest model, and the draft is disposable: the prompt prefers an
|
|
49
|
-
# honest blank over a clever inference, so there is no reasoning premium
|
|
50
|
-
# worth paying a larger model for inside a 60-second budget.
|
|
51
|
-
BRIEF_MODEL = "haiku"
|
|
52
|
-
|
|
53
|
-
# Read-only, and no `Bash` — this pass surveys what the repository
|
|
54
|
-
# already says about itself, it does not go looking further. That is
|
|
55
|
-
# `leah-researcher`'s job, and denying her tools here is what keeps the
|
|
56
|
-
# two from quietly doing the same work twice.
|
|
57
|
-
ALLOWED_TOOLS = %w[Read Grep Glob Edit].freeze
|
|
58
|
-
DENIED_TOOLS = %w[WebFetch WebSearch Bash].freeze
|
|
59
|
-
|
|
60
|
-
# The `stdout:` and `stderr:` sections of a {TTY::Command::ExitError}
|
|
61
|
-
# message. stdout runs until stderr starts; stderr runs to the end, because
|
|
62
|
-
# what `claude` prints there is not guaranteed to be one line.
|
|
63
|
-
STDOUT_SECTION = /^[ \t]*stdout:[ \t]*(.*?)(?=\n[ \t]*stderr:|\z)/m
|
|
64
|
-
STDERR_SECTION = /^[ \t]*stderr:[ \t]*(.*)\z/m
|
|
65
|
-
|
|
66
|
-
# How much of what `claude` said survives into a one-line report.
|
|
67
|
-
REASON_LIMIT = 300
|
|
68
|
-
|
|
69
|
-
# A one-line account of a failed `claude` run, built from the error rather
|
|
70
|
-
# than its first line: that first line is the escaped prompt, several
|
|
71
|
-
# thousand characters of it, and reporting it says an invocation failed,
|
|
72
|
-
# at length, and never why.
|
|
73
|
-
#
|
|
74
|
-
# Both streams are kept because they carry different halves. `claude`
|
|
75
|
-
# reports its own failures on stdout; the line naming the *cause* of a 401
|
|
76
|
-
# (`ANTHROPIC_API_KEY ... takes precedence over your claude.ai login`) is
|
|
77
|
-
# on stderr. {Executor} used to own this; it now reads a child process
|
|
78
|
-
# directly and has no exit error to parse, so the briefer keeps it.
|
|
79
|
-
#
|
|
80
|
-
# @param error [TTY::Command::ExitError]
|
|
81
|
-
# @return [String]
|
|
82
|
-
def self.failure_reason(error)
|
|
83
|
-
status = error.message[/^[ \t]*exit status:[ \t]*(\S+)/, 1]
|
|
84
|
-
outcome = status ? "exited #{status}" : "failed"
|
|
85
|
-
said = [STDOUT_SECTION, STDERR_SECTION]
|
|
86
|
-
.filter_map { |section| tail(error.message[section, 1]) }
|
|
87
|
-
.join(" | ")
|
|
88
|
-
|
|
89
|
-
said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}"
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# @param text [String, nil]
|
|
93
|
-
# @return [String, nil] the last few meaningful lines, on one line
|
|
94
|
-
def self.tail(text)
|
|
95
|
-
lines = text.to_s.split("\n").map(&:strip).reject { |line| line.empty? || line == "Nothing written" }
|
|
96
|
-
return nil if lines.empty?
|
|
97
|
-
|
|
98
|
-
joined = lines.last(3).join(" ")
|
|
99
|
-
joined.length > REASON_LIMIT ? "#{joined[0, REASON_LIMIT - 1]}..." : joined
|
|
100
|
-
end
|
|
101
|
-
private_class_method :tail
|
|
102
|
-
|
|
103
|
-
# @param path [String] the new plan folder, absolute
|
|
104
|
-
# @param title [String] the feature's proper name, e.g. "Tax Rule DSL"
|
|
105
|
-
# @param root [String] repository root, for project context and `--add-dir`
|
|
106
|
-
# @param command [TTY::Command]
|
|
107
|
-
# @param timeout [Integer] seconds before the drafting attempt is abandoned
|
|
108
|
-
# @param dry_run [Boolean] plan the invocation, do not run it
|
|
109
|
-
# @param seed [String, nil] what the author already wrote about the
|
|
110
|
-
# feature (`create --from`); opens spec.md and outranks the repo survey
|
|
111
|
-
def initialize(
|
|
112
|
-
path:,
|
|
113
|
-
title:,
|
|
114
|
-
root:,
|
|
115
|
-
command: TTY::Command.new(printer: :null),
|
|
116
|
-
timeout: TIMEOUT,
|
|
117
|
-
dry_run: false,
|
|
118
|
-
seed: nil
|
|
119
|
-
)
|
|
120
|
-
@path = path
|
|
121
|
-
@title = title
|
|
122
|
-
@root = File.expand_path(root)
|
|
123
|
-
@command = command
|
|
124
|
-
@timeout = timeout
|
|
125
|
-
@dry_run = dry_run
|
|
126
|
-
@seed = seed.to_s.strip.then { |s| s.empty? ? nil : s }
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# @return [String]
|
|
130
|
-
attr_reader :path, :title, :root
|
|
131
|
-
|
|
132
|
-
# @return [String, nil]
|
|
133
|
-
attr_reader :seed
|
|
134
|
-
|
|
135
|
-
# @return [String] absolute path to the spec, whether or not it exists yet
|
|
136
|
-
def spec_path = File.join(path, "spec.md")
|
|
137
|
-
|
|
138
|
-
# The title and four empty headings — and, when `create --from` supplied
|
|
139
|
-
# one, the author's own prose between them, so the file already says
|
|
140
|
-
# something even if the drafting attempt never returns.
|
|
141
|
-
#
|
|
142
|
-
# @return [String]
|
|
143
|
-
def scaffold
|
|
144
|
-
opening = seed ? "#{seed}\n\n" : ""
|
|
145
|
-
"# #{title}\n\n#{opening}" + HEADINGS.map { |h| "## #{h}\n\n" }.join("\n")
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# @return [String] where it was written
|
|
149
|
-
def write_scaffold!
|
|
150
|
-
File.write(spec_path, scaffold)
|
|
151
|
-
spec_path
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Make a best-effort attempt at the four headings, in place, by shelling
|
|
155
|
-
# out to `claude` the same way {Executor} does for a specialist agent.
|
|
156
|
-
# A failure here is not this method's to hide: the scaffold {#write_scaffold!}
|
|
157
|
-
# already wrote stands on its own, so the caller reports the failure and
|
|
158
|
-
# moves on rather than treating it as fatal to `create`.
|
|
159
|
-
#
|
|
160
|
-
# @return [Array(Boolean, String)] ok, and a one-line note
|
|
161
|
-
def attempt!
|
|
162
|
-
return [true, "dry run — would draft from project context"] if @dry_run
|
|
163
|
-
|
|
164
|
-
begin
|
|
165
|
-
@command.run(*invocation, timeout: @timeout)
|
|
166
|
-
rescue TTY::Command::TimeoutExceeded
|
|
167
|
-
return [false, "timed out after #{@timeout}s"]
|
|
168
|
-
rescue TTY::Command::ExitError => e
|
|
169
|
-
return [false, "claude #{self.class.failure_reason(e)}"]
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
[true, "drafted"]
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# Exposed so a spec can assert the boundary without running anything.
|
|
176
|
-
#
|
|
177
|
-
# @return [Array<String>]
|
|
178
|
-
def invocation
|
|
179
|
-
["claude",
|
|
180
|
-
"-p", prompt, "--add-dir", root,
|
|
181
|
-
"--model", BRIEF_MODEL,
|
|
182
|
-
"--allowedTools", ALLOWED_TOOLS.join(","),
|
|
183
|
-
"--disallowedTools", DENIED_TOOLS.join(",")]
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
private
|
|
187
|
-
|
|
188
|
-
# @return [String]
|
|
189
|
-
def prompt
|
|
190
|
-
<<~PROMPT
|
|
191
|
-
#{author_statement}A new plan folder was just created for a feature that does not exist
|
|
192
|
-
yet: "#{title}".
|
|
193
|
-
|
|
194
|
-
You have 50 seconds. Explore the codebase for the first thirty and
|
|
195
|
-
write for the last twenty; the process is killed at sixty and an
|
|
196
|
-
unwritten draft is lost.
|
|
197
|
-
|
|
198
|
-
#{spec_path} holds a title and four empty headings:
|
|
199
|
-
|
|
200
|
-
#{HEADINGS.map { |h| " * #{h}" }.join("\n")}
|
|
201
|
-
|
|
202
|
-
Make a best-effort first pass at each, drawn only from what THIS
|
|
203
|
-
project already has on disk under #{root} — its own docs, its own
|
|
204
|
-
conventions, code already built, and anything already downloaded or
|
|
205
|
-
gathered that bears on "#{title}". Read/Grep/Glob freely to look for
|
|
206
|
-
it; do not invent facts and do not research the web. Where the
|
|
207
|
-
repository gives you nothing to go on, leave that heading's body as
|
|
208
|
-
a short, honest one-line note saying so, e.g. "_Nothing in the repo
|
|
209
|
-
speaks to this yet — needs research._" A human completes this
|
|
210
|
-
afterward, and a confident-sounding guess costs them more to unwind
|
|
211
|
-
than an honest blank.
|
|
212
|
-
|
|
213
|
-
`## What already exists` matters most: name specific files, modules
|
|
214
|
-
or prior work you found, not just that "some code exists" — that is
|
|
215
|
-
exactly the context a researcher picking this up next cannot see for
|
|
216
|
-
themselves without being told where to look.
|
|
217
|
-
|
|
218
|
-
#{context}
|
|
219
|
-
#{backlog}
|
|
220
|
-
Edit #{spec_path} in place. Keep the title#{", the author's opening prose" if seed} and the four `##`
|
|
221
|
-
headings exactly as they are, in order; add prose under them; write
|
|
222
|
-
nothing outside them; touch no other file.
|
|
223
|
-
PROMPT
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
# The author's own words lead the prompt, ahead of even the feature's
|
|
227
|
-
# announcement, because they outrank everything this pass could survey:
|
|
228
|
-
# where the seed and the repository disagree, the seed is the intent and
|
|
229
|
-
# the repository is merely the past.
|
|
230
|
-
#
|
|
231
|
-
# @return [String] empty without a seed
|
|
232
|
-
def author_statement
|
|
233
|
-
return "" unless seed
|
|
234
|
-
|
|
235
|
-
<<~STATEMENT
|
|
236
|
-
The author has already written the following about this feature. It
|
|
237
|
-
is the primary source: where it answers one of the headings, draw
|
|
238
|
-
from it first, and use the repository survey below only to supplement
|
|
239
|
-
it — never to contradict it.
|
|
240
|
-
|
|
241
|
-
#{seed}
|
|
242
|
-
|
|
243
|
-
STATEMENT
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
# AGENTS.md is commonly a symlink to CLAUDE.md; realpath-deduping keeps
|
|
247
|
-
# the shared content from being inlined twice into every drafting prompt.
|
|
248
|
-
#
|
|
249
|
-
# @return [String]
|
|
250
|
-
def context
|
|
251
|
-
seen = []
|
|
252
|
-
found = CONTEXT_FILES.filter_map { |name|
|
|
253
|
-
file = File.join(root, name)
|
|
254
|
-
next unless File.file?(file)
|
|
255
|
-
|
|
256
|
-
real = File.realpath(file)
|
|
257
|
-
next if seen.include?(real)
|
|
258
|
-
|
|
259
|
-
seen << real
|
|
260
|
-
"### #{name}\n\n#{File.read(file, encoding: "UTF-8")}"
|
|
261
|
-
}
|
|
262
|
-
return "" if found.empty?
|
|
263
|
-
|
|
264
|
-
"## Project context\n\n#{found.join("\n\n")}\n"
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
# A relevant line in the project's own backlog is worth more than
|
|
268
|
-
# anything this pass could infer on its own — it is the one place "why
|
|
269
|
-
# now" might already be written down.
|
|
270
|
-
#
|
|
271
|
-
# @return [String]
|
|
272
|
-
def backlog
|
|
273
|
-
file = File.join(root, BACKLOG_FILE)
|
|
274
|
-
return "" unless File.file?(file)
|
|
275
|
-
|
|
276
|
-
"## #{BACKLOG_FILE}\n\n" \
|
|
277
|
-
"If this backlog mentions \"#{title}\", treat that entry as " \
|
|
278
|
-
"authoritative for `## Why it matters` — it is the project's own " \
|
|
279
|
-
"statement of intent.\n\n" \
|
|
280
|
-
"#{File.read(file, encoding: "UTF-8")}\n"
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
end
|
data/lib/agentilda/child.rb
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Agentilda
|
|
4
|
-
# One `claude` process the harness started and therefore owns.
|
|
5
|
-
#
|
|
6
|
-
# `TTY::Command` never exposes the pid it spawned, so the executor used to
|
|
7
|
-
# find its child by scraping `ps` for a fresh `claude` under this process
|
|
8
|
-
# and could never safely signal it. Kill (`k`) and the sixty-second
|
|
9
|
-
# backstop after STOP both need a pid that is certainly ours, so the
|
|
10
|
-
# process is spawned here and the pid kept.
|
|
11
|
-
#
|
|
12
|
-
# stderr is folded into the same pipe as stdout: `claude` prints the cause
|
|
13
|
-
# of a failure there, and the transcript keeps non-JSON lines for exactly
|
|
14
|
-
# that reason.
|
|
15
|
-
class Child
|
|
16
|
-
# @param argv [Array<String>]
|
|
17
|
-
# @param chdir [String, nil]
|
|
18
|
-
# @return [Agentilda::Child]
|
|
19
|
-
def self.spawn(argv, chdir: nil)
|
|
20
|
-
reader, writer = IO.pipe
|
|
21
|
-
options = { out: writer, err: writer, in: File::NULL, unsetenv_others: true }
|
|
22
|
-
options[:chdir] = chdir if chdir
|
|
23
|
-
pid = Process.spawn(environment, *argv, **options)
|
|
24
|
-
writer.close
|
|
25
|
-
new(pid:, reader:)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# The environment the child starts in: the one Bundler was loaded under,
|
|
29
|
-
# less what Bundler itself put there. `exe/agentilda` pins `BUNDLE_GEMFILE`
|
|
30
|
-
# to its own Gemfile and `bundler/setup` adds `-rbundler/setup` to
|
|
31
|
-
# `RUBYOPT`; a child that inherited them would run every `bundle exec`,
|
|
32
|
-
# `standardrb` and `alock` against the harness's checkout instead of the
|
|
33
|
-
# worktree it was handed, and be told the gems that worktree's Gemfile adds
|
|
34
|
-
# are "not currently included in the bundle". Bundler knows exactly what it
|
|
35
|
-
# changed, so it is asked; without Bundler there is nothing to strip.
|
|
36
|
-
#
|
|
37
|
-
# @return [Hash{String => String}]
|
|
38
|
-
def self.environment
|
|
39
|
-
defined?(::Bundler) ? ::Bundler.unbundled_env : ENV.to_h
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# @param pid [Integer]
|
|
43
|
-
# @param reader [IO]
|
|
44
|
-
def initialize(pid:, reader:)
|
|
45
|
-
@pid = pid
|
|
46
|
-
@reader = reader
|
|
47
|
-
@status = nil
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# @return [Integer]
|
|
51
|
-
attr_reader :pid
|
|
52
|
-
|
|
53
|
-
# Read until the process closes its end. Runs on the caller's thread;
|
|
54
|
-
# the executor reads on the same thread it always did.
|
|
55
|
-
#
|
|
56
|
-
# @yieldparam text [String] whatever arrived, possibly a partial line
|
|
57
|
-
# @return [void]
|
|
58
|
-
def each_chunk
|
|
59
|
-
loop { yield @reader.readpartial(65_536) }
|
|
60
|
-
rescue IOError
|
|
61
|
-
@reader.close unless @reader.closed?
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# @return [Process::Status]
|
|
65
|
-
def wait
|
|
66
|
-
@status ||= begin
|
|
67
|
-
_, status = Process.wait2(pid)
|
|
68
|
-
status
|
|
69
|
-
end
|
|
70
|
-
rescue Errno::ECHILD
|
|
71
|
-
@status
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# @param signal [String]
|
|
75
|
-
# @return [void]
|
|
76
|
-
def kill(signal = "KILL")
|
|
77
|
-
Process.kill(signal, pid)
|
|
78
|
-
rescue Errno::ESRCH
|
|
79
|
-
# Already gone, which is what was wanted.
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# @return [Boolean]
|
|
83
|
-
def alive?
|
|
84
|
-
return false if @status
|
|
85
|
-
|
|
86
|
-
Process.kill(0, pid)
|
|
87
|
-
true
|
|
88
|
-
rescue Errno::ESRCH
|
|
89
|
-
false
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
data/lib/agentilda/clock.rb
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Agentilda
|
|
4
|
-
# The advisory clock one invocation runs against.
|
|
5
|
-
#
|
|
6
|
-
# It never terminates the agent on its own. It writes into the control file
|
|
7
|
-
# the agent polls: a warning ten and five minutes out, a wrap-up request at
|
|
8
|
-
# one, STOP at zero. Only a process still alive {GRACE} seconds after STOP
|
|
9
|
-
# is killed, and that is the executor's act on this clock's say-so, so the
|
|
10
|
-
# agent has every chance to write its ledger line first.
|
|
11
|
-
#
|
|
12
|
-
# `now` is injectable so the moments can be tested without sleeping.
|
|
13
|
-
class Clock
|
|
14
|
-
# Seconds before the deadline, and what the control file says then.
|
|
15
|
-
# A moment already in the past when the clock starts is skipped: a five
|
|
16
|
-
# minute agent told "10 minutes left" at t=0 has been lied to.
|
|
17
|
-
WARNINGS = [
|
|
18
|
-
[600, "WARN: 10 minutes left"],
|
|
19
|
-
[300, "WARN: 5 minutes left"],
|
|
20
|
-
[60, "WRAP_UP: 1 minute left, write to disk now"]
|
|
21
|
-
].freeze
|
|
22
|
-
|
|
23
|
-
# Seconds after STOP before {#on_expire} fires.
|
|
24
|
-
GRACE = 60
|
|
25
|
-
|
|
26
|
-
# @param seconds [Integer] the budget
|
|
27
|
-
# @param control [String] the invocation's control file
|
|
28
|
-
# @param on_expire [Proc] called once, GRACE seconds after STOP
|
|
29
|
-
# @param now [Proc] a monotonic clock reading
|
|
30
|
-
def initialize(seconds:, control:, on_expire:, now: -> { UI.monotonic })
|
|
31
|
-
@control = control
|
|
32
|
-
@on_expire = on_expire
|
|
33
|
-
@now = now
|
|
34
|
-
@mutex = Mutex.new
|
|
35
|
-
arm(seconds)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# @return [Integer] whole seconds left, floored at zero
|
|
39
|
-
def remaining = [@deadline - @now.call, 0].max.round
|
|
40
|
-
|
|
41
|
-
# @return [Symbol] :calm, :warned, :wrap_up, :stopped or :expired
|
|
42
|
-
attr_reader :phase
|
|
43
|
-
|
|
44
|
-
# Evaluate every moment once. Idempotent within a phase.
|
|
45
|
-
#
|
|
46
|
-
# @return [void]
|
|
47
|
-
def tick
|
|
48
|
-
@mutex.synchronize do
|
|
49
|
-
left = @deadline - @now.call
|
|
50
|
-
WARNINGS.each do |before, text|
|
|
51
|
-
next if @fired.include?(before) || left > before
|
|
52
|
-
|
|
53
|
-
@fired << before
|
|
54
|
-
Control.write(@control, text)
|
|
55
|
-
@phase = before == 60 ? :wrap_up : :warned
|
|
56
|
-
end
|
|
57
|
-
if left <= 0 && !@fired.include?(:stop)
|
|
58
|
-
@fired << :stop
|
|
59
|
-
Control.write(@control, Control::STOP)
|
|
60
|
-
@phase = :stopped
|
|
61
|
-
end
|
|
62
|
-
if @fired.include?(:stop) && !@fired.include?(:expired) && left <= -GRACE
|
|
63
|
-
@fired << :expired
|
|
64
|
-
@phase = :expired
|
|
65
|
-
@on_expire.call
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# @return [self]
|
|
71
|
-
def start
|
|
72
|
-
@thread ||= Thread.new do
|
|
73
|
-
Thread.current.report_on_exception = false
|
|
74
|
-
loop do
|
|
75
|
-
tick
|
|
76
|
-
break if phase == :expired
|
|
77
|
-
|
|
78
|
-
sleep(1)
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
self
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# @return [void]
|
|
85
|
-
def stop
|
|
86
|
-
@thread&.kill
|
|
87
|
-
@thread = nil
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# Ten more minutes, or however many. The file is emptied so the next poll
|
|
91
|
-
# reads a reprieve, and every moment is re-armed against the new deadline.
|
|
92
|
-
#
|
|
93
|
-
# @param seconds [Integer]
|
|
94
|
-
# @return [void]
|
|
95
|
-
def extend!(seconds)
|
|
96
|
-
@mutex.synchronize do
|
|
97
|
-
@deadline += seconds
|
|
98
|
-
@fired = []
|
|
99
|
-
@phase = :calm
|
|
100
|
-
Control.write(@control, "")
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
private
|
|
105
|
-
|
|
106
|
-
# @param seconds [Integer]
|
|
107
|
-
# @return [void]
|
|
108
|
-
def arm(seconds)
|
|
109
|
-
@deadline = @now.call + seconds
|
|
110
|
-
@fired = WARNINGS.map(&:first).select { |before| before >= seconds }
|
|
111
|
-
@phase = :calm
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
data/lib/agentilda/console.rb
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Agentilda
|
|
4
|
-
# What the keys do to the screen, and to the running agents.
|
|
5
|
-
#
|
|
6
|
-
# Selection and the dialog live here, never on the screen: the screen
|
|
7
|
-
# draws whatever board it is handed, and this decides what the board
|
|
8
|
-
# says. Pending changes are visible only in the dialog and take effect
|
|
9
|
-
# only on ENTER, so a stray `k` kills nobody.
|
|
10
|
-
class Console
|
|
11
|
-
# Seconds each `x` adds.
|
|
12
|
-
EXTENSION = 600
|
|
13
|
-
|
|
14
|
-
# @param screen [Agentilda::Screen::Ratatui]
|
|
15
|
-
def initialize(screen:)
|
|
16
|
-
@screen = screen
|
|
17
|
-
@dispatcher = nil
|
|
18
|
-
@board = nil
|
|
19
|
-
@selected = nil
|
|
20
|
-
@dialog = false
|
|
21
|
-
@help = false
|
|
22
|
-
@about = false
|
|
23
|
-
@pending = { kill: false, extend: 0 }
|
|
24
|
-
@mutex = Mutex.new
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# @return [String, nil] the selected row's key
|
|
28
|
-
attr_reader :selected
|
|
29
|
-
|
|
30
|
-
# @return [Hash] `{kill: Boolean, extend: Integer}`
|
|
31
|
-
attr_reader :pending
|
|
32
|
-
|
|
33
|
-
# @return [Agentilda::Screen::Ratatui]
|
|
34
|
-
attr_reader :screen
|
|
35
|
-
|
|
36
|
-
# @return [Boolean]
|
|
37
|
-
def dialog? = @dialog
|
|
38
|
-
|
|
39
|
-
# @return [Boolean]
|
|
40
|
-
def help? = @help
|
|
41
|
-
|
|
42
|
-
# @return [Boolean]
|
|
43
|
-
def about? = @about
|
|
44
|
-
|
|
45
|
-
# The dispatcher exists only once the run starts, after the keyboard is
|
|
46
|
-
# already listening, so it arrives late.
|
|
47
|
-
#
|
|
48
|
-
# @param dispatcher [Agentilda::Dispatcher]
|
|
49
|
-
# @return [void]
|
|
50
|
-
def attach(dispatcher) = @dispatcher = dispatcher
|
|
51
|
-
|
|
52
|
-
# Called by the dispatcher each tick.
|
|
53
|
-
#
|
|
54
|
-
# @param board [Agentilda::Board]
|
|
55
|
-
# @return [void]
|
|
56
|
-
def paint(board)
|
|
57
|
-
@mutex.synchronize do
|
|
58
|
-
@board = board
|
|
59
|
-
@selected = nil if @selected && board.rows.none? { |r| r.key == @selected && r.running? }
|
|
60
|
-
@screen.draw(board.with(selected: @selected,
|
|
61
|
-
dialog: (dialog_text if @dialog),
|
|
62
|
-
help: (help_text if @help),
|
|
63
|
-
about: (about_text if @about)))
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# @return [void]
|
|
68
|
-
def select_next = move(1)
|
|
69
|
-
|
|
70
|
-
# @return [void]
|
|
71
|
-
def select_prev = move(-1)
|
|
72
|
-
|
|
73
|
-
# @return [void]
|
|
74
|
-
def toggle_kill
|
|
75
|
-
select_next unless @selected
|
|
76
|
-
return unless @selected
|
|
77
|
-
|
|
78
|
-
@pending[:kill] = !@pending[:kill]
|
|
79
|
-
@dialog = true
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# @return [void]
|
|
83
|
-
def extend
|
|
84
|
-
select_next unless @selected
|
|
85
|
-
return unless @selected
|
|
86
|
-
|
|
87
|
-
@pending[:extend] += EXTENSION
|
|
88
|
-
@dialog = true
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# ENTER.
|
|
92
|
-
#
|
|
93
|
-
# @return [void]
|
|
94
|
-
def apply
|
|
95
|
-
return unless @dialog && @selected && @dispatcher
|
|
96
|
-
|
|
97
|
-
@dispatcher.kill(@selected) if @pending[:kill]
|
|
98
|
-
@dispatcher.extend(@selected, @pending[:extend]) if @pending[:extend].positive?
|
|
99
|
-
UI.log("applied to #{@selected}: #{dialog_text.tr("\n", ", ")}")
|
|
100
|
-
close_dialog
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# ESC: the dialog first, then the help, then the about screen, then the
|
|
104
|
-
# selection.
|
|
105
|
-
#
|
|
106
|
-
# @return [void]
|
|
107
|
-
def escape
|
|
108
|
-
if @dialog
|
|
109
|
-
close_dialog
|
|
110
|
-
elsif @help
|
|
111
|
-
@help = false
|
|
112
|
-
elsif @about
|
|
113
|
-
@about = false
|
|
114
|
-
else
|
|
115
|
-
@selected = nil
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# @return [void]
|
|
120
|
-
def toggle_help = @help = !@help
|
|
121
|
-
|
|
122
|
-
# @return [void]
|
|
123
|
-
def toggle_about = @about = !@about
|
|
124
|
-
|
|
125
|
-
private
|
|
126
|
-
|
|
127
|
-
# @return [void]
|
|
128
|
-
def close_dialog
|
|
129
|
-
@dialog = false
|
|
130
|
-
@pending = { kill: false, extend: 0 }
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# @param step [Integer]
|
|
134
|
-
# @return [void]
|
|
135
|
-
def move(step)
|
|
136
|
-
keys = (@board&.rows || []).select(&:running?).map(&:key)
|
|
137
|
-
return @selected = nil if keys.empty?
|
|
138
|
-
|
|
139
|
-
index = keys.index(@selected)
|
|
140
|
-
@selected = index.nil? ? keys.first : keys[(index + step) % keys.size]
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# @return [String]
|
|
144
|
-
def dialog_text
|
|
145
|
-
row = @board&.rows&.find { |r| r.key == @selected }
|
|
146
|
-
who = row ? "#{row.ordinal} #{row.agent}" : @selected.to_s
|
|
147
|
-
extension = @pending[:extend].positive? ? "+#{@pending[:extend] / 60}m" : "none"
|
|
148
|
-
"#{who}\n\nkill: #{@pending[:kill] ? "yes" : "no"}\nextend: #{extension}"
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# @return [String]
|
|
152
|
-
def help_text = Keyboard.new(input: $stdin).help
|
|
153
|
-
|
|
154
|
-
# @return [String]
|
|
155
|
-
def about_text = Keyboard.new(input: $stdin).about
|
|
156
|
-
end
|
|
157
|
-
end
|