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
data/lib/agentilda/feature.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "agentilda/ordinal"
|
|
4
|
+
require "agentilda/status"
|
|
5
|
+
|
|
3
6
|
module Agentilda
|
|
4
7
|
# Lowercase in the middle of a title, capitalised at the front.
|
|
5
8
|
SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
|
|
@@ -44,21 +47,22 @@ module Agentilda
|
|
|
44
47
|
}.join(" ")
|
|
45
48
|
end
|
|
46
49
|
|
|
47
|
-
# The one place a plan folder's name is spelled out: `NNN.MM-<emoji
|
|
50
|
+
# The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
|
|
48
51
|
#
|
|
49
|
-
# The
|
|
50
|
-
# wide and visually swallows a
|
|
52
|
+
# The spaced arrow after the emoji is deliberate: an emoji renders two cells
|
|
53
|
+
# wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
|
|
51
54
|
# as if the emoji and the slug were touching. Everything that mints or
|
|
52
55
|
# renames a folder goes through here; {Feature.parse} accepts the older
|
|
53
|
-
# single-dash
|
|
56
|
+
# `--` and single-dash spellings too, and `resync dirs` migrates them on
|
|
57
|
+
# contact.
|
|
54
58
|
#
|
|
55
59
|
# @param ordinal [Agentilda::Ordinal, String]
|
|
56
60
|
# @param status [Agentilda::Status]
|
|
57
61
|
# @param slug [String]
|
|
58
62
|
# @return [String]
|
|
59
|
-
def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji}
|
|
63
|
+
def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
|
|
60
64
|
|
|
61
|
-
# One `NNN.MM-<emoji
|
|
65
|
+
# One `NNN.MM-<emoji> → <slug>` folder, decoded.
|
|
62
66
|
#
|
|
63
67
|
# @!attribute [r] ordinal
|
|
64
68
|
# @return [Agentilda::Ordinal]
|
|
@@ -82,13 +86,14 @@ module Agentilda
|
|
|
82
86
|
ordinal = Ordinal.from_dirname(dirname) or return nil
|
|
83
87
|
|
|
84
88
|
rest = dirname.sub(/\A[\d.]+[-_]/, "")
|
|
85
|
-
head, tail = rest.split(
|
|
89
|
+
head, tail = rest.split(/\s*→\s*|[-_]/, 2)
|
|
86
90
|
|
|
87
91
|
# A leading segment with no ASCII word character is the status emoji.
|
|
88
92
|
# The slug strips any further separators: the canonical spelling puts
|
|
89
|
-
#
|
|
93
|
+
# a spaced arrow after the emoji, and folders from before that rule put
|
|
94
|
+
# two dashes, or one.
|
|
90
95
|
status = (Agentilda.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
|
|
91
|
-
slug = status ? tail.sub(/\A[-_]+/, "") : rest
|
|
96
|
+
slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
|
|
92
97
|
|
|
93
98
|
new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
|
|
94
99
|
end
|
|
@@ -124,130 +129,4 @@ module Agentilda
|
|
|
124
129
|
# @return [Integer, nil]
|
|
125
130
|
def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
|
|
126
131
|
end
|
|
127
|
-
|
|
128
|
-
# A plan folder as the state machine sees it: which files exist, what they
|
|
129
|
-
# say, and which pull requests they record.
|
|
130
|
-
#
|
|
131
|
-
# It exists because the invariants ask the same questions repeatedly and
|
|
132
|
-
# {Feature} is a frozen `Data` with nowhere to memoize the answers.
|
|
133
|
-
class Subject
|
|
134
|
-
# @param feature [Agentilda::Feature]
|
|
135
|
-
def initialize(feature)
|
|
136
|
-
@feature = feature
|
|
137
|
-
@reads = {}
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# @return [Agentilda::Feature]
|
|
141
|
-
attr_reader :feature
|
|
142
|
-
|
|
143
|
-
# @return [Agentilda::Status] the status the folder name claims
|
|
144
|
-
def status = feature.status
|
|
145
|
-
|
|
146
|
-
# @param name [String] a bare filename
|
|
147
|
-
# @return [Boolean]
|
|
148
|
-
def file?(name) = File.file?(File.join(feature.path, name))
|
|
149
|
-
|
|
150
|
-
# @param name [String]
|
|
151
|
-
# @return [String, nil] contents, or nil when absent
|
|
152
|
-
def read(name)
|
|
153
|
-
@reads.fetch(name) do
|
|
154
|
-
@reads[name] = file?(name) ? File.read(File.join(feature.path, name), encoding: "UTF-8") : nil
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# @return [Array<Agentilda::PullRequest>]
|
|
159
|
-
def pull_requests = @pull_requests ||= PullRequests.new(dir: feature.path).all
|
|
160
|
-
|
|
161
|
-
# The specification's own Goal section, verbatim and at most two
|
|
162
|
-
# paragraphs. Anything that paraphrases the spec is a second copy that
|
|
163
|
-
# drifts; quoting it is not — which is why the pull request body and the
|
|
164
|
-
# index both read it from here rather than each writing their own.
|
|
165
|
-
#
|
|
166
|
-
# @return [Array<String>] paragraphs, empty when there is no Goal to read
|
|
167
|
-
def goal
|
|
168
|
-
body = read("spec.md").to_s
|
|
169
|
-
section = body[/^\#{"#"}{2,3}\s*Goals?\b[^\n]*\n+(.*?)(?=\n\#{"#"}{1,3}\s|\z)/mi, 1]
|
|
170
|
-
|
|
171
|
-
paragraphs(section) || paragraphs(body.sub(/\A\s*\#{"#"}[^\n]*\n/, "")) || []
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Specifications written before the template existed have no Goal section,
|
|
175
|
-
# and they are exactly the ones an index most needs to describe. So the
|
|
176
|
-
# opening prose stands in — skipping headings, quotes, lists and tables,
|
|
177
|
-
# which describe the document rather than the work.
|
|
178
|
-
#
|
|
179
|
-
# @param text [String, nil]
|
|
180
|
-
# @return [Array<String>, nil] nil when there is no prose to be had
|
|
181
|
-
def paragraphs(text)
|
|
182
|
-
found = text.to_s.strip.split(/\n{2,}/)
|
|
183
|
-
.map(&:strip)
|
|
184
|
-
.reject { |p| p.empty? || p.match?(/\A[\#>|\-*\d`_=]/) }
|
|
185
|
-
.first(2)
|
|
186
|
-
|
|
187
|
-
found.empty? ? nil : found
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# The questions `blocked.md` still names, by number.
|
|
191
|
-
#
|
|
192
|
-
# Empty means one of two very different things, and a caller that treats
|
|
193
|
-
# them alike is how a folder with thirty kilobytes of open questions gets
|
|
194
|
-
# reported as "nothing left open": either there is no `blocked.md` at all,
|
|
195
|
-
# or there is one whose questions are not written as `## B<n>` and are
|
|
196
|
-
# therefore invisible to every part of this tool. Ask {#file?} which.
|
|
197
|
-
#
|
|
198
|
-
# @return [Array<Integer>]
|
|
199
|
-
def open_blocks = Agentilda.block_numbers(read("blocked.md"), OPEN_BLOCK)
|
|
200
|
-
|
|
201
|
-
# The answers waiting in `blocked.md`, by number. `## A1` settles `## B1`.
|
|
202
|
-
#
|
|
203
|
-
# Waiting, not folded. An `## A<n>` heading is not the same as a settled
|
|
204
|
-
# question: one may say in its own body that it is a draft pending a
|
|
205
|
-
# conversation. `lando-broker` makes that call; this only counts headings.
|
|
206
|
-
#
|
|
207
|
-
# @return [Array<Integer>]
|
|
208
|
-
def block_answers = Agentilda.block_numbers(read("blocked.md"), ANSWER_BLOCK)
|
|
209
|
-
|
|
210
|
-
# A `blocked.md` this tool cannot read: the file is there, and not one
|
|
211
|
-
# question in it is written as `## B<n>`. Nothing can drain it and nothing
|
|
212
|
-
# currently says so, which is the whole reason this exists.
|
|
213
|
-
#
|
|
214
|
-
# @return [Boolean]
|
|
215
|
-
def unreadable_block? = file?("blocked.md") && open_blocks.empty?
|
|
216
|
-
|
|
217
|
-
# @return [String, nil] why the folder's name is not justified
|
|
218
|
-
def violation = status.violation(self)
|
|
219
|
-
|
|
220
|
-
# @return [Boolean] whether the name matches the contents
|
|
221
|
-
def consistent? = violation.nil?
|
|
222
|
-
|
|
223
|
-
# @return [Agentilda::StateMachine] positioned at the current state
|
|
224
|
-
def machine = StateMachine.new(self)
|
|
225
|
-
|
|
226
|
-
# @return [Array<Symbol>] states reachable right now, guards applied
|
|
227
|
-
def allowed = machine.allowed
|
|
228
|
-
|
|
229
|
-
# @return [Agentilda::Status, nil] the state these contents justify
|
|
230
|
-
def best_fit = machine.best_fit
|
|
231
|
-
|
|
232
|
-
# Move the folder into +status+ — the side effect a transition *is*.
|
|
233
|
-
#
|
|
234
|
-
# The {Feature} is a frozen `Data` holding the old name, so it is replaced
|
|
235
|
-
# rather than mutated, and the memoized reads go with it.
|
|
236
|
-
#
|
|
237
|
-
# @param status [Agentilda::Status]
|
|
238
|
-
# @return [Agentilda::Feature] the feature under its new name
|
|
239
|
-
# @raise [Agentilda::Error] when the target name is already taken
|
|
240
|
-
def rename_to(status)
|
|
241
|
-
return @feature if status.key == @feature.status.key
|
|
242
|
-
|
|
243
|
-
target = File.join(File.dirname(@feature.path), @feature.dirname_as(status))
|
|
244
|
-
unless Agentilda.move_directory(@feature.path, target)
|
|
245
|
-
raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
@reads = {}
|
|
249
|
-
@pull_requests = nil
|
|
250
|
-
@feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
132
|
end
|
|
@@ -12,12 +12,14 @@ module Agentilda
|
|
|
12
12
|
# `create` reported a missing `title:` on a file whose title was right there.
|
|
13
13
|
# One parser, one list of permitted classes, so that cannot happen twice.
|
|
14
14
|
module Frontmatter
|
|
15
|
+
# rubocop: disable Lint/OrAssignmentToConstant
|
|
15
16
|
# Frontmatter, then body.
|
|
16
|
-
PATTERN
|
|
17
|
+
PATTERN ||= /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
|
|
17
18
|
|
|
18
19
|
# Dates and timestamps are ordinary frontmatter, so they load. Nothing else
|
|
19
20
|
# does: the point of `safe_load` is that a seed file cannot name a class.
|
|
20
|
-
PERMITTED_CLASSES
|
|
21
|
+
PERMITTED_CLASSES ||= [Date, Time].freeze
|
|
22
|
+
# rubocop: enable Lint/OrAssignmentToConstant
|
|
21
23
|
|
|
22
24
|
class << self
|
|
23
25
|
# @param content [String] a whole markdown file
|
data/lib/agentilda/github.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "tty/command"
|
|
4
5
|
|
|
5
6
|
module Agentilda
|
|
6
7
|
# The `gh` CLI, wrapped thinly.
|
|
@@ -25,8 +26,15 @@ module Agentilda
|
|
|
25
26
|
# @return [Array<Hash>] `{number:, title:, url:, branch:, files:}`
|
|
26
27
|
def pulls(state: "all")
|
|
27
28
|
out = UI.spinning("Fetching pull requests from GitHub") {
|
|
28
|
-
@command.run("gh",
|
|
29
|
-
"
|
|
29
|
+
@command.run("gh",
|
|
30
|
+
"pr",
|
|
31
|
+
"list",
|
|
32
|
+
"--state",
|
|
33
|
+
state,
|
|
34
|
+
"--limit",
|
|
35
|
+
@limit.to_s,
|
|
36
|
+
"--json",
|
|
37
|
+
FIELDS.join(",")).out
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
# `gh` can exit 0 having printed NOTHING — most often when it cannot reach
|
|
@@ -39,12 +47,12 @@ module Agentilda
|
|
|
39
47
|
JSON.parse(out).map do |pr|
|
|
40
48
|
{
|
|
41
49
|
number: pr["number"],
|
|
42
|
-
title:
|
|
43
|
-
url:
|
|
50
|
+
title: pr["title"].to_s,
|
|
51
|
+
url: pr["url"],
|
|
44
52
|
branch: pr["headRefName"].to_s,
|
|
45
|
-
files:
|
|
46
|
-
state:
|
|
47
|
-
open:
|
|
53
|
+
files: Array(pr["files"]).map { |f| f["path"] }.compact,
|
|
54
|
+
state: self.class.state_label(pr),
|
|
55
|
+
open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
|
|
48
56
|
}
|
|
49
57
|
end
|
|
50
58
|
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
@@ -107,10 +115,10 @@ module Agentilda
|
|
|
107
115
|
pr = JSON.parse(out)
|
|
108
116
|
{
|
|
109
117
|
number: pr["number"],
|
|
110
|
-
title:
|
|
111
|
-
url:
|
|
112
|
-
state:
|
|
113
|
-
body:
|
|
118
|
+
title: pr["title"].to_s,
|
|
119
|
+
url: pr["url"].to_s,
|
|
120
|
+
state: self.class.state_label(pr),
|
|
121
|
+
body: pr["body"].to_s
|
|
114
122
|
}
|
|
115
123
|
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
116
124
|
raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
|
data/lib/agentilda/index.rb
CHANGED
|
@@ -23,16 +23,16 @@ module Agentilda
|
|
|
23
23
|
|
|
24
24
|
# Documents that get a proper name rather than their filename.
|
|
25
25
|
ARTIFACT_NAMES = {
|
|
26
|
-
"spec.md"
|
|
27
|
-
"plan.md"
|
|
26
|
+
"spec.md" => "Spec",
|
|
27
|
+
"plan.md" => "Plan",
|
|
28
28
|
"pull-requests.md" => "Pull Requests",
|
|
29
|
-
"linear.md"
|
|
30
|
-
"blocked.md"
|
|
31
|
-
"delayed.md"
|
|
32
|
-
"rewrite.md"
|
|
33
|
-
"deployed.md"
|
|
34
|
-
"rollback.md"
|
|
35
|
-
"discarded.md"
|
|
29
|
+
"linear.md" => "Linear",
|
|
30
|
+
"blocked.md" => "Blocked",
|
|
31
|
+
"delayed.md" => "Deferred",
|
|
32
|
+
"rewrite.md" => "Rewrite",
|
|
33
|
+
"deployed.md" => "Deployed",
|
|
34
|
+
"rollback.md" => "Rollback",
|
|
35
|
+
"discarded.md" => "Discarded"
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
38
|
# @param tree [Agentilda::Tree]
|
|
@@ -189,9 +189,9 @@ module Agentilda
|
|
|
189
189
|
# @return [String]
|
|
190
190
|
def footer
|
|
191
191
|
counts = tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status] += 1 }
|
|
192
|
-
|
|
192
|
+
.map { |status, n| "#{status.emoji} #{n}" }.join(" ")
|
|
193
193
|
|
|
194
|
-
"\n---\n\n#{tree.subjects.size} #{
|
|
194
|
+
"\n---\n\n#{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}\n"
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
# @param href [String]
|
data/lib/agentilda/keyboard.rb
CHANGED
|
@@ -17,31 +17,42 @@ module Agentilda
|
|
|
17
17
|
class Keyboard
|
|
18
18
|
# Key → what it does, rendered by {#help} and dispatched by {#handle}.
|
|
19
19
|
BINDINGS = [
|
|
20
|
-
["h
|
|
20
|
+
["h", "this help"],
|
|
21
|
+
["?", "about agentilda"],
|
|
22
|
+
["s", "select the next running agent; arrows move the selection"],
|
|
23
|
+
["k", "mark the selected agent to be killed (STOP, 15s, then kill -9)"],
|
|
24
|
+
["x", "extend the selected agent's clock by 10 minutes, per press"],
|
|
25
|
+
["ENTER", "apply the pending changes in the dialog"],
|
|
26
|
+
["ESC", "discard the dialog, then clear the selection"],
|
|
21
27
|
["w", "ask every running agent to wrap up as fast as possible"],
|
|
22
28
|
["n", "ask agents to write out what they have and stop; the loop continues"],
|
|
23
29
|
["q", "write out, stop everything, and quit after a #{Control::GRACE}s grace"],
|
|
24
|
-
["ctrl-c", "
|
|
30
|
+
["ctrl-c", "agents leave a resume note and stop, then quit; press again to abort now"]
|
|
25
31
|
].freeze
|
|
26
32
|
|
|
33
|
+
# @param input [IO]
|
|
34
|
+
# @param sink [Agentilda::Console, nil] where the table keys go; nil
|
|
35
|
+
# when there is no screen, in which case they do nothing
|
|
27
36
|
# @return [Agentilda::Keyboard, nil] a running listener, or nil when
|
|
28
37
|
# STDIN is not a terminal
|
|
29
|
-
def self.listen(input: $stdin)
|
|
38
|
+
def self.listen(input: $stdin, sink: nil)
|
|
30
39
|
return nil unless input.tty?
|
|
31
40
|
|
|
32
|
-
new(input:).start
|
|
41
|
+
new(input:, sink:).start
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
# @param input [IO]
|
|
36
|
-
|
|
45
|
+
# @param sink [Agentilda::Console, nil]
|
|
46
|
+
def initialize(input: $stdin, sink: nil)
|
|
37
47
|
@input = input
|
|
48
|
+
@sink = sink
|
|
38
49
|
end
|
|
39
50
|
|
|
40
51
|
# @return [self]
|
|
41
52
|
def start
|
|
42
53
|
@thread = Thread.new do
|
|
43
54
|
Thread.current.report_on_exception = false
|
|
44
|
-
loop { handle(
|
|
55
|
+
loop { handle(read_key) }
|
|
45
56
|
rescue IOError, Errno::EIO
|
|
46
57
|
# STDIN went away; a keyboard with no keys just stops listening.
|
|
47
58
|
end
|
|
@@ -52,17 +63,41 @@ module Agentilda
|
|
|
52
63
|
def stop
|
|
53
64
|
@thread&.kill
|
|
54
65
|
@thread = nil
|
|
66
|
+
restore
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `getch` puts the terminal in raw mode for the length of one read and
|
|
70
|
+
# restores it afterwards — but the listener spends almost all its life
|
|
71
|
+
# blocked inside that read, and {#stop} kills it there, which leaves
|
|
72
|
+
# the mode behind. Raw mode has output post-processing off, so a "\n"
|
|
73
|
+
# written after it drops a line without returning the cursor to column
|
|
74
|
+
# zero: the run report then starts wherever the previous line ended and
|
|
75
|
+
# staircases down the screen. Nothing else puts the mode back, so this
|
|
76
|
+
# does.
|
|
77
|
+
#
|
|
78
|
+
# @return [void]
|
|
79
|
+
def restore
|
|
80
|
+
@input.cooked! if @input.tty?
|
|
81
|
+
rescue IOError, SystemCallError
|
|
82
|
+
# No terminal left to put back; there is nothing to restore it to.
|
|
55
83
|
end
|
|
56
84
|
|
|
57
85
|
# @param key [String, nil]
|
|
58
86
|
# @return [void]
|
|
59
87
|
def handle(key)
|
|
60
88
|
case key
|
|
61
|
-
when "h"
|
|
89
|
+
when "h" then @sink ? @sink.toggle_help : UI.popup("Keys", help)
|
|
90
|
+
when "?" then @sink ? @sink.toggle_about : UI.popup("About", about)
|
|
91
|
+
when "s", "\e[B" then @sink&.select_next
|
|
92
|
+
when "\e[A" then @sink&.select_prev
|
|
93
|
+
when "k" then @sink&.toggle_kill
|
|
94
|
+
when "x" then @sink&.extend
|
|
95
|
+
when "\r", "\n" then @sink&.apply
|
|
96
|
+
when "\e" then @sink&.escape
|
|
62
97
|
when "w" then acted("w — agents asked to wrap up") { Control.wrap_up! }
|
|
63
98
|
when "n" then acted("n — agents asked to write out and stop") { Control.stop! }
|
|
64
99
|
when "q" then acted("q — quitting; #{Control::GRACE}s grace to write out") { Control.quit! }
|
|
65
|
-
when "\u0003" then
|
|
100
|
+
when "\u0003" then interrupt
|
|
66
101
|
end
|
|
67
102
|
end
|
|
68
103
|
|
|
@@ -72,8 +107,36 @@ module Agentilda
|
|
|
72
107
|
BINDINGS.map { |key, does| "#{key.ljust(width)} #{does}" }.join("\n")
|
|
73
108
|
end
|
|
74
109
|
|
|
110
|
+
# @return [String] the version and a reminder of how to leave
|
|
111
|
+
def about = "Agentilda Version #{Agentilda::VERSION}\n\nThanks for using this! Press 'q' to exit and stop all agents."
|
|
112
|
+
|
|
75
113
|
private
|
|
76
114
|
|
|
115
|
+
# ESC alone and ESC-[-A are the same first byte. Wait a few
|
|
116
|
+
# milliseconds for the rest before deciding it was a bare ESC.
|
|
117
|
+
#
|
|
118
|
+
# @return [String]
|
|
119
|
+
def read_key
|
|
120
|
+
key = @input.getch
|
|
121
|
+
return key unless key == "\e"
|
|
122
|
+
|
|
123
|
+
if IO.select([@input], nil, nil, 0.05)
|
|
124
|
+
rest = @input.read_nonblock(2, exception: false)
|
|
125
|
+
return "\e#{rest}" if rest.is_a?(String)
|
|
126
|
+
end
|
|
127
|
+
key
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Raw mode swallows the signal, so the key stands in for it: the first
|
|
131
|
+
# press lets agents write down where they got to, the second aborts.
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
def interrupt
|
|
135
|
+
return Thread.main.raise(Interrupt) if Control.interrupted?
|
|
136
|
+
|
|
137
|
+
acted("ctrl-c — agents leaving resume notes; #{Control::GRACE}s grace, ctrl-c again to abort") { Control.interrupt! }
|
|
138
|
+
end
|
|
139
|
+
|
|
77
140
|
# A keypress with no acknowledgement looks like a keypress that did
|
|
78
141
|
# nothing, so each one says what it just asked for.
|
|
79
142
|
#
|
|
@@ -82,6 +145,11 @@ module Agentilda
|
|
|
82
145
|
def acted(note)
|
|
83
146
|
yield
|
|
84
147
|
UI.log(note)
|
|
148
|
+
# Under the dashboard, RatatuiRuby.run owns the alternate screen and
|
|
149
|
+
# repaints it continuously, so this raw `$stderr` write either never
|
|
150
|
+
# appears or is immediately overwritten — `UI.log` above is the only
|
|
151
|
+
# guaranteed record of w/n/q there. The line still matters without a
|
|
152
|
+
# screen (a dry run, a pipe), where this is all the feedback there is.
|
|
85
153
|
UI.line(note)
|
|
86
154
|
end
|
|
87
155
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Agentilda
|
|
2
|
+
class Launcher
|
|
3
|
+
# The one flag every command takes, so no command declares it. Removed
|
|
4
|
+
# from the arguments before dry-cli sees them, which would otherwise
|
|
5
|
+
# reject it as an argument the command never asked for.
|
|
6
|
+
NO_COLOR_FLAGS = %w[--no-color -C].freeze
|
|
7
|
+
|
|
8
|
+
attr_accessor :argv,
|
|
9
|
+
:stdin,
|
|
10
|
+
:stdout,
|
|
11
|
+
:stderr,
|
|
12
|
+
:kernel
|
|
13
|
+
|
|
14
|
+
def initialize(argv,
|
|
15
|
+
stdin = $stdin,
|
|
16
|
+
stdout = $stdout,
|
|
17
|
+
stderr = $stderr,
|
|
18
|
+
kernel = Kernel)
|
|
19
|
+
self.argv = argv
|
|
20
|
+
self.stdin = stdin
|
|
21
|
+
self.stdout = stdout
|
|
22
|
+
self.stderr = stderr
|
|
23
|
+
self.kernel = kernel
|
|
24
|
+
|
|
25
|
+
# Bare `agentilda` prints its help. It used to print the plan table, which
|
|
26
|
+
# meant the one command that reads a tree was also the one you got by accident.
|
|
27
|
+
@argv = argv.empty? ? %w(-h) : argv
|
|
28
|
+
|
|
29
|
+
# `run --push-pr` takes an OPTIONAL part letter: bare means "continue this
|
|
30
|
+
# plan"s sequence", `--push-pr C` means "use C". dry-cli has no optional-value
|
|
31
|
+
# option, so the bare form is filled in here rather than made to look like a
|
|
32
|
+
# mistake.
|
|
33
|
+
#
|
|
34
|
+
# Scoped to `run`, and to the long spelling when it is not. This rewrote every
|
|
35
|
+
# `-p` in every command for a while, which quietly turned
|
|
36
|
+
# `linear import TAX -p "Some Project"` into `-p auto "Some Project"` — the
|
|
37
|
+
# option took "auto" and the project name became a stray argument. A global
|
|
38
|
+
# rewrite of a one-letter flag will collide with the next command that wants
|
|
39
|
+
# it; this one collided within the week.
|
|
40
|
+
return unless argv.first == "run"
|
|
41
|
+
|
|
42
|
+
@argv = argv.each_with_object([]) do |token, out|
|
|
43
|
+
out << token
|
|
44
|
+
next unless ["--push-pr", "-p"].include?(token)
|
|
45
|
+
|
|
46
|
+
following = argv[argv.index(token) + 1]
|
|
47
|
+
out << "auto" unless following&.match?(/\A[A-Z]\z/)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `--no-color` becomes NO_COLOR rather than a setting of its own: the help
|
|
52
|
+
# screens, the runtime UI and every child process already honour it, so
|
|
53
|
+
# one variable switches all three off together.
|
|
54
|
+
#
|
|
55
|
+
# @return [void] never returns; exits with the command's status
|
|
56
|
+
def execute!
|
|
57
|
+
ENV["NO_COLOR"] = "1" if NO_COLOR_FLAGS.intersect?(argv)
|
|
58
|
+
code = 0
|
|
59
|
+
Dry::CLI.new(::Agentilda::CLI).call(arguments: argv - NO_COLOR_FLAGS)
|
|
60
|
+
rescue SystemExit => e
|
|
61
|
+
code = e.status
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
stderr.puts UI.paint("Error: #{e.message}", :red)
|
|
64
|
+
code = 1
|
|
65
|
+
ensure
|
|
66
|
+
kernel.exit(code)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|