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
|
@@ -66,7 +66,7 @@ module Agentilda
|
|
|
66
66
|
# These are two readers of one story and used to be told it separately:
|
|
67
67
|
# the spinner got the phrase, the log got a start and a finish, and an
|
|
68
68
|
# animated run wrote nothing about what any agent was actually doing. A
|
|
69
|
-
# {Agentilda::Transcript::Progress} arrives here several times a
|
|
69
|
+
# {Agentilda::Execution::Transcript::Progress} arrives here several times a
|
|
70
70
|
# second; the line is redrawn every time, and the log takes a line only
|
|
71
71
|
# when the phrase itself changes, which is a few dozen times an agent.
|
|
72
72
|
#
|
|
@@ -188,7 +188,7 @@ module Agentilda
|
|
|
188
188
|
note("failed after #{alive}: #{reason}")
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
-
# @param update [Agentilda::Transcript::Progress]
|
|
191
|
+
# @param update [Agentilda::Execution::Transcript::Progress]
|
|
192
192
|
# @return [void]
|
|
193
193
|
def call(update)
|
|
194
194
|
if update.respond_to?(:pid) && update.pid && update.pid != @pid
|
|
@@ -262,7 +262,7 @@ module Agentilda
|
|
|
262
262
|
def log(message, **fields)
|
|
263
263
|
path = log_path or return
|
|
264
264
|
|
|
265
|
-
line = ProgressLog.render(message, **fields)
|
|
265
|
+
line = Engine::ProgressLog.render(message, **fields)
|
|
266
266
|
(@log_mutex ||= Mutex.new).synchronize do
|
|
267
267
|
FileUtils.mkdir_p(File.dirname(path))
|
|
268
268
|
File.open(path, "a") { |f| f.puts(line) }
|
|
@@ -282,7 +282,7 @@ module Agentilda
|
|
|
282
282
|
# is what the model generated. Sub-agent spend is folded into up, since
|
|
283
283
|
# `claude` reports a sub-agent's total without splitting it.
|
|
284
284
|
#
|
|
285
|
-
# @param update [Agentilda::Transcript::Progress, nil]
|
|
285
|
+
# @param update [Agentilda::Execution::Transcript::Progress, nil]
|
|
286
286
|
# @return [String]
|
|
287
287
|
def meter(update)
|
|
288
288
|
paint(fit("↑#{abbreviate(update&.up)}", METER_WIDTH), :bright_blue) +
|
|
@@ -463,7 +463,7 @@ module Agentilda
|
|
|
463
463
|
#
|
|
464
464
|
# @return [Array] one result per item, in input order
|
|
465
465
|
def on_dashboard(list, jobs:, label:, fields:, failure:, timeout:, file:, root:, &)
|
|
466
|
-
Dashboard.open(root:) do |dashboard|
|
|
466
|
+
Presentation::Dashboard.open(root:) do |dashboard|
|
|
467
467
|
# Tracked when it starts, not when it is queued: a row's clock is
|
|
468
468
|
# the agent's clock, and a queued plan has no agent yet.
|
|
469
469
|
run = lambda do |item|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tty/command"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
module Vcs
|
|
8
|
+
# The `gh` CLI, wrapped thinly.
|
|
9
|
+
#
|
|
10
|
+
# It is a seam rather than a convenience: every example in the suite injects
|
|
11
|
+
# a double here, so nothing in the tests reaches the network or a real
|
|
12
|
+
# repository.
|
|
13
|
+
class GitHub
|
|
14
|
+
# Fields asked of `gh pr list`.
|
|
15
|
+
FIELDS = %w[number title url headRefName files state isDraft mergedAt].freeze
|
|
16
|
+
|
|
17
|
+
# @param command [TTY::Command] runner, injectable for tests
|
|
18
|
+
# @param limit [Integer] how many pull requests to fetch
|
|
19
|
+
def initialize(command: TTY::Command.new(printer: :null), limit: 200)
|
|
20
|
+
@command = command
|
|
21
|
+
@limit = limit
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every pull request, normalised into plain hashes.
|
|
25
|
+
#
|
|
26
|
+
# @param state [String] "open", "closed", "merged" or "all"
|
|
27
|
+
# @return [Array<Hash>] `{number:, title:, url:, branch:, files:}`
|
|
28
|
+
def pulls(state: "all")
|
|
29
|
+
out = UI.spinning("Fetching pull requests from GitHub") {
|
|
30
|
+
@command.run("gh",
|
|
31
|
+
"pr",
|
|
32
|
+
"list",
|
|
33
|
+
"--state",
|
|
34
|
+
state,
|
|
35
|
+
"--limit",
|
|
36
|
+
@limit.to_s,
|
|
37
|
+
"--json",
|
|
38
|
+
FIELDS.join(",")).out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# `gh` can exit 0 having printed NOTHING — most often when it cannot reach
|
|
42
|
+
# the credential store, as in a non-interactive shell that has no keyring
|
|
43
|
+
# access, or when GH_TOKEN is set to something invalid and shadows a
|
|
44
|
+
# working login. Left alone this parses as a JSON error and reports as
|
|
45
|
+
# "bad output", sending you to look at the wrong thing entirely.
|
|
46
|
+
raise Error, no_output_message if out.to_s.strip.empty?
|
|
47
|
+
|
|
48
|
+
JSON.parse(out).map do |pr|
|
|
49
|
+
{
|
|
50
|
+
number: pr["number"],
|
|
51
|
+
title: pr["title"].to_s,
|
|
52
|
+
url: pr["url"],
|
|
53
|
+
branch: pr["headRefName"].to_s,
|
|
54
|
+
files: Array(pr["files"]).map { |f| f["path"] }.compact,
|
|
55
|
+
state: self.class.state_label(pr),
|
|
56
|
+
open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
60
|
+
raise Error, "could not list pull requests via `gh`: #{e.message.lines.first.to_s.strip}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [String] the diagnosis for a silent `gh`
|
|
64
|
+
def no_output_message
|
|
65
|
+
<<~MESSAGE.strip
|
|
66
|
+
`gh` produced no output and did not report an error.
|
|
67
|
+
|
|
68
|
+
That is almost always authentication rather than an empty repository:
|
|
69
|
+
|
|
70
|
+
- Check `gh auth status`. An invalid GH_TOKEN in the environment
|
|
71
|
+
shadows a working keyring login and fails without saying so.
|
|
72
|
+
|
|
73
|
+
- A non-interactive shell may have no access to the system keyring
|
|
74
|
+
even when an interactive one does.
|
|
75
|
+
|
|
76
|
+
Verify with: gh pr list --state all --limit 1
|
|
77
|
+
MESSAGE
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Fields asked of `gh pr view`, which unlike `pr list` can be told about
|
|
81
|
+
# one pull request in another repository.
|
|
82
|
+
VIEW_FIELDS = %w[number title url state isDraft mergedAt body].freeze
|
|
83
|
+
|
|
84
|
+
# A reference to one pull request: a bare number, a `#`-prefixed number,
|
|
85
|
+
# or a full URL to a GitHub pull request or a GitLab merge request.
|
|
86
|
+
REF = %r{\A(?:\#?\d+|https?://\S+?/(?:pull|merge_requests)/\d+/?)\z}
|
|
87
|
+
|
|
88
|
+
# Split and validate a `--prs` value before any of it reaches the network,
|
|
89
|
+
# so a typo fails in a hundredth of a second with the offending token named
|
|
90
|
+
# rather than after four round trips with a `gh` diagnostic.
|
|
91
|
+
#
|
|
92
|
+
# @param text [String] e.g. "12,15,18" or "https://…/pull/12, #15"
|
|
93
|
+
# @return [Array<String>] references, in the order given, de-duplicated
|
|
94
|
+
# @raise [Agentilda::Error] on anything that is not a reference
|
|
95
|
+
def self.parse_refs(text)
|
|
96
|
+
refs = text.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
97
|
+
raise Error, "no pull requests given" if refs.empty?
|
|
98
|
+
|
|
99
|
+
bad = refs.reject { |r| r.match?(REF) }
|
|
100
|
+
unless bad.empty?
|
|
101
|
+
raise Error, "not a pull request number or URL: #{bad.join(", ")}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
refs.uniq
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# One pull request, by number or URL.
|
|
108
|
+
#
|
|
109
|
+
# @param ref [String] "12", "#12" or "https://github.com/o/r/pull/12"
|
|
110
|
+
# @return [Hash] `{number:, title:, url:, state:, body:}`
|
|
111
|
+
# @raise [Agentilda::Error]
|
|
112
|
+
def pull_request(ref)
|
|
113
|
+
out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
|
|
114
|
+
raise Error, no_output_message if out.to_s.strip.empty?
|
|
115
|
+
|
|
116
|
+
pr = JSON.parse(out)
|
|
117
|
+
{
|
|
118
|
+
number: pr["number"],
|
|
119
|
+
title: pr["title"].to_s,
|
|
120
|
+
url: pr["url"].to_s,
|
|
121
|
+
state: self.class.state_label(pr),
|
|
122
|
+
body: pr["body"].to_s
|
|
123
|
+
}
|
|
124
|
+
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
125
|
+
raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Several pull requests, in the order asked for.
|
|
129
|
+
#
|
|
130
|
+
# @param refs [Array<String>]
|
|
131
|
+
# @return [Array<Hash>]
|
|
132
|
+
def pull_requests(refs)
|
|
133
|
+
UI.stepping(refs, "Fetching pull requests") { |ref| ref }
|
|
134
|
+
refs.map { |ref| pull_request(ref) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# `gh` speaks in enums; `pull-requests.md` speaks in the words the state
|
|
138
|
+
# machine parses. Translate once, here, rather than at each call site.
|
|
139
|
+
#
|
|
140
|
+
# @param pr [Hash] a decoded `gh pr view` payload
|
|
141
|
+
# @return [String] one of the labels in {PullRequests::STATES}
|
|
142
|
+
def self.state_label(pr)
|
|
143
|
+
return "Merged 🟣" if pr["mergedAt"]
|
|
144
|
+
return "WIP 🟡" if pr["isDraft"]
|
|
145
|
+
|
|
146
|
+
case pr["state"].to_s.upcase
|
|
147
|
+
when "OPEN" then "Open 🟡"
|
|
148
|
+
when "CLOSED" then "Closed 🔴"
|
|
149
|
+
else "Unknown"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Change a pull request's title.
|
|
154
|
+
#
|
|
155
|
+
# @param number [Integer]
|
|
156
|
+
# @param title [String]
|
|
157
|
+
# @return [void]
|
|
158
|
+
def retitle(number:, title:)
|
|
159
|
+
@command.run("gh", "pr", "edit", number.to_s, "--title", title)
|
|
160
|
+
rescue TTY::Command::ExitError => e
|
|
161
|
+
raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @return [Boolean] whether `gh` is installed and authenticated
|
|
165
|
+
def available?
|
|
166
|
+
@command.run!("gh", "auth", "status").success?
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "tty/command"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
module Vcs
|
|
8
|
+
# Turns a finished worktree into a pushed branch and a pull request.
|
|
9
|
+
#
|
|
10
|
+
# This is the one place the harness reaches off the machine, so it requires
|
|
11
|
+
# `--commit`, is skipped with `--no-git-push`, and refuses anything
|
|
12
|
+
# it is not certain about. Everything it does is reported before it does it.
|
|
13
|
+
#
|
|
14
|
+
# Titles follow the convention the whole system joins on:
|
|
15
|
+
#
|
|
16
|
+
# [002.00](A) Tenancy Households — a plan's first pull request
|
|
17
|
+
# [002.00](B) Tenancy Households — its second
|
|
18
|
+
#
|
|
19
|
+
# The slug is title-cased from the folder name, so the title reads as a
|
|
20
|
+
# sentence rather than as a path fragment.
|
|
21
|
+
class Publisher
|
|
22
|
+
# A published, or publishable, pull request.
|
|
23
|
+
#
|
|
24
|
+
# @!attribute [r] ordinal
|
|
25
|
+
# @return [Agentilda::Plans::Ordinal]
|
|
26
|
+
# @!attribute [r] branch
|
|
27
|
+
# @return [String]
|
|
28
|
+
# @!attribute [r] title
|
|
29
|
+
# @return [String]
|
|
30
|
+
# @!attribute [r] url
|
|
31
|
+
# @return [String, nil] nil on a dry run or a refusal
|
|
32
|
+
# @!attribute [r] refusal
|
|
33
|
+
# @return [String, nil]
|
|
34
|
+
Publication = Data.define(:ordinal, :branch, :title, :url, :refusal) do
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def published? = !url.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A..Z, which is more parts than any feature should be split into.
|
|
40
|
+
LETTERS = ("A".."Z").to_a.freeze
|
|
41
|
+
|
|
42
|
+
# @param root [String] repository the worktree belongs to
|
|
43
|
+
# @param command [TTY::Command]
|
|
44
|
+
# @param base [String] the branch pull requests target
|
|
45
|
+
# @param dry_run [Boolean]
|
|
46
|
+
def initialize(root:, command: TTY::Command.new(printer: :null), base: "main", dry_run: true)
|
|
47
|
+
@root = root
|
|
48
|
+
@command = command
|
|
49
|
+
@base = base
|
|
50
|
+
@dry_run = dry_run
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Push one plan's branch and open its pull request.
|
|
54
|
+
#
|
|
55
|
+
# @param checkout [Agentilda::Vcs::Worktree::Checkout]
|
|
56
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
57
|
+
# @param letter [String, nil] force a part letter; nil auto-assigns
|
|
58
|
+
# @return [Agentilda::Vcs::Publisher::Publication]
|
|
59
|
+
def publish(checkout:, subject:, letter: nil)
|
|
60
|
+
title = title_for(subject, letter)
|
|
61
|
+
|
|
62
|
+
unless checkout.dirty?
|
|
63
|
+
return refuse(subject, checkout, title, "nothing to publish — the worktree is unchanged")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: nil) if @dry_run
|
|
67
|
+
|
|
68
|
+
commit(checkout, title)
|
|
69
|
+
push(checkout)
|
|
70
|
+
url = create_pull_request(checkout, subject, title)
|
|
71
|
+
|
|
72
|
+
Publication.new(**base_fields(subject, checkout, title), url:, refusal: nil)
|
|
73
|
+
rescue TTY::Command::ExitError => e
|
|
74
|
+
refuse(subject, checkout, title, git_complaint(e))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# git and gh put the useful sentence in stderr, several lines below the
|
|
78
|
+
# command they echo. Reporting only the first line reports "it failed".
|
|
79
|
+
#
|
|
80
|
+
# @param error [TTY::Command::ExitError]
|
|
81
|
+
# @return [String]
|
|
82
|
+
def git_complaint(error)
|
|
83
|
+
lines = error.message.lines.map(&:strip).reject(&:empty?)
|
|
84
|
+
stderr = lines.find { |l| l.start_with?("stderr:") }
|
|
85
|
+
complaint = stderr&.delete_prefix("stderr:")&.strip
|
|
86
|
+
|
|
87
|
+
complaint.to_s.empty? || complaint == "Nothing written" ? lines.first.to_s : complaint
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The title a plan's next pull request should carry.
|
|
91
|
+
#
|
|
92
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
93
|
+
# @param letter [String, nil]
|
|
94
|
+
# @return [String]
|
|
95
|
+
def title_for(subject, letter = nil)
|
|
96
|
+
"[#{subject.feature.ordinal}](#{letter || auto_letter(subject)}) #{subject.feature.title}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Which part this is. Always a letter, starting at A: a plan that turns out
|
|
100
|
+
# to need only one pull request still reads consistently with every other,
|
|
101
|
+
# and nothing has to be renamed when a second arrives.
|
|
102
|
+
#
|
|
103
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
104
|
+
# @return [String]
|
|
105
|
+
def auto_letter(subject) = LETTERS.fetch(subject.pull_requests.size, LETTERS.last)
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# @return [Hash]
|
|
110
|
+
def base_fields(subject, checkout, title)
|
|
111
|
+
{ ordinal: subject.feature.ordinal, branch: checkout.branch, title: }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Agentilda::Vcs::Publisher::Publication]
|
|
115
|
+
def refuse(subject, checkout, title, why)
|
|
116
|
+
Publication.new(**base_fields(subject, checkout, title), url: nil, refusal: why)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param checkout [Agentilda::Vcs::Worktree::Checkout]
|
|
120
|
+
# @param title [String]
|
|
121
|
+
# @return [void]
|
|
122
|
+
def commit(checkout, title)
|
|
123
|
+
run(checkout, "git", "add", "-A")
|
|
124
|
+
# The subject line drops the bracketed prefix: it belongs in the pull
|
|
125
|
+
# request title, where things join on it, not in every commit message.
|
|
126
|
+
run(checkout, "git", "commit", "-m", title.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @param checkout [Agentilda::Vcs::Worktree::Checkout]
|
|
130
|
+
# @return [void]
|
|
131
|
+
def push(checkout) = run(checkout, "git", "push", "-u", "origin", checkout.branch)
|
|
132
|
+
|
|
133
|
+
# @return [String, nil] the pull request URL
|
|
134
|
+
def create_pull_request(checkout, subject, title)
|
|
135
|
+
Tempfile.create(["pr", ".md"]) do |file|
|
|
136
|
+
file.write(description(subject, title))
|
|
137
|
+
file.flush
|
|
138
|
+
result = run(checkout, "gh", "pr", "create", "-a", "@me", "-B", @base, "-t", title, "-F", file.path)
|
|
139
|
+
return result.out.to_s[%r{https://\S+}]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The body `/create-pr` would have written: a title line, a summary lifted
|
|
144
|
+
# from the specification, and a link back to the plan folder so a reviewer
|
|
145
|
+
# can find the rest.
|
|
146
|
+
#
|
|
147
|
+
# @return [String]
|
|
148
|
+
def description(subject, title)
|
|
149
|
+
<<~MARKDOWN
|
|
150
|
+
# #{title}
|
|
151
|
+
|
|
152
|
+
## Summary
|
|
153
|
+
|
|
154
|
+
#{summary_text(subject)}
|
|
155
|
+
|
|
156
|
+
## Plan
|
|
157
|
+
|
|
158
|
+
Implements `#{subject.feature.dirname}`. The specification and the plan
|
|
159
|
+
for this work live in that folder.
|
|
160
|
+
MARKDOWN
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The specification's own Goal section, verbatim. A pull request body that
|
|
164
|
+
# paraphrases the spec is a second copy that drifts; quoting it is not.
|
|
165
|
+
#
|
|
166
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
167
|
+
# @return [String]
|
|
168
|
+
def summary_text(subject)
|
|
169
|
+
text = subject.goal.join("\n\n")
|
|
170
|
+
text.empty? ? "See `#{subject.feature.dirname}/spec.md`." : text
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [TTY::Command::Result]
|
|
174
|
+
def run(checkout, *argv) = @command.run(*argv, chdir: checkout.path)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
module Vcs
|
|
8
|
+
# A git worktree per plan, so agents working on different plans cannot
|
|
9
|
+
# collide at all.
|
|
10
|
+
#
|
|
11
|
+
# A lock coordinates a shared tree; a worktree removes the sharing. That
|
|
12
|
+
# matters more than it sounds for an agent loop: two agents editing one
|
|
13
|
+
# checkout produce no git conflict — same branch, same files — so the last
|
|
14
|
+
# writer simply wins and the loser's work vanishes with nothing anywhere to
|
|
15
|
+
# say it happened.
|
|
16
|
+
#
|
|
17
|
+
# The branch carries the plan number (`<user>/NNN.MM-slug`), which is not
|
|
18
|
+
# decoration: it is the first thing `resync prs` reads when deciding which
|
|
19
|
+
# plan a pull request implements. Naming branches this way means the number
|
|
20
|
+
# carries itself from worktree creation through to a merged pull request with
|
|
21
|
+
# nobody having to remember it.
|
|
22
|
+
class Worktree
|
|
23
|
+
# `bin/setup-worktree`, which copies in the ignored-but-required files a new
|
|
24
|
+
# checkout does not get. Shelling out rather than reimplementing the rules
|
|
25
|
+
# here keeps one answer to "what does a worktree need", and keeps that answer
|
|
26
|
+
# usable from a shell on a machine that has never run `bundle install`.
|
|
27
|
+
SEEDER = File.expand_path("../../../bin/setup-worktree", __dir__)
|
|
28
|
+
|
|
29
|
+
# One plan's isolated checkout.
|
|
30
|
+
#
|
|
31
|
+
# @!attribute [r] ordinal
|
|
32
|
+
# @return [Agentilda::Plans::Ordinal]
|
|
33
|
+
# @!attribute [r] branch
|
|
34
|
+
# @return [String]
|
|
35
|
+
# @!attribute [r] path
|
|
36
|
+
# @return [String] absolute
|
|
37
|
+
# @!attribute [r] created
|
|
38
|
+
# @return [Boolean] false when an existing worktree was reused
|
|
39
|
+
Checkout = Data.define(:ordinal, :branch, :path, :created) do
|
|
40
|
+
# @return [Boolean] whether the agent left anything behind
|
|
41
|
+
def dirty? = !`git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
|
|
42
|
+
|
|
43
|
+
# @return [String] the plans directory inside this checkout
|
|
44
|
+
def plans_dir = File.join(path, Agentilda::PLANS_DIR)
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] whether this round made it, rather than reusing one
|
|
47
|
+
def created? = created
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param root [String] the main repository
|
|
51
|
+
# @param dir [String, nil] where worktrees go; defaults to a sibling
|
|
52
|
+
# @param user [String] branch namespace
|
|
53
|
+
def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
|
|
54
|
+
@root = File.expand_path(root)
|
|
55
|
+
@user = user
|
|
56
|
+
@dir = File.expand_path(dir || default_dir)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] the main repository
|
|
60
|
+
attr_reader :root
|
|
61
|
+
|
|
62
|
+
# @return [String] where worktrees are kept
|
|
63
|
+
attr_reader :dir
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] whether root is a git repository at all
|
|
66
|
+
def repository? = system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
|
|
67
|
+
|
|
68
|
+
# Get, or create, the isolated checkout for one plan.
|
|
69
|
+
#
|
|
70
|
+
# The checkout is seeded before it is handed back, on both paths. `git
|
|
71
|
+
# worktree add` brings every TRACKED file and nothing else, so an agent
|
|
72
|
+
# given a bare one cannot run the suite it was told to keep green: a Rails
|
|
73
|
+
# project dies on `MissingKeyError` because `config/credentials/*.key` is
|
|
74
|
+
# gitignored. The agent then reports a broken checkout as a broken plan,
|
|
75
|
+
# which is the worst failure this loop has, because the diagnosis points
|
|
76
|
+
# at the code rather than at the tree.
|
|
77
|
+
#
|
|
78
|
+
# A reused worktree is seeded too. {#seed} only ever adds a missing file,
|
|
79
|
+
# so running it again costs one fast subprocess and repairs the worktrees
|
|
80
|
+
# that were created before any of this existed.
|
|
81
|
+
#
|
|
82
|
+
# @param feature [Agentilda::Plans::Feature]
|
|
83
|
+
# @return [Agentilda::Vcs::Worktree::Checkout]
|
|
84
|
+
# @raise [Agentilda::Error] when git refuses
|
|
85
|
+
def checkout_for(feature)
|
|
86
|
+
branch = branch_for(feature)
|
|
87
|
+
path = File.join(dir, "#{feature.ordinal}-#{feature.slug}")
|
|
88
|
+
|
|
89
|
+
if File.directory?(path)
|
|
90
|
+
seed(path)
|
|
91
|
+
return Checkout.new(ordinal: feature.ordinal, branch:, path:, created: false)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# git keeps a registration for a worktree whose directory was deleted by
|
|
95
|
+
# hand, marks it `prunable`, and then refuses to create a new one at that
|
|
96
|
+
# path. Anybody who has ever `rm -rf`d a worktree meets this, and the
|
|
97
|
+
# error says only "already exists" about a directory that does not.
|
|
98
|
+
forget_stale
|
|
99
|
+
|
|
100
|
+
FileUtils.mkdir_p(dir)
|
|
101
|
+
add(branch, path)
|
|
102
|
+
seed(path)
|
|
103
|
+
Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Copy in the files git ignores and a new checkout therefore lacks: `.env`
|
|
107
|
+
# and friends, and credential keys.
|
|
108
|
+
#
|
|
109
|
+
# Never fatal. A repository with no ignored files is perfectly normal, and
|
|
110
|
+
# a plan is not worth abandoning over a seeding step, so this notes it and
|
|
111
|
+
# carries on.
|
|
112
|
+
#
|
|
113
|
+
# The note goes to the progress log, never the terminal: this runs while
|
|
114
|
+
# the ratatui dashboard owns the screen, and a line written to STDERR
|
|
115
|
+
# lands on top of an agent's row. The seeder's own output is silenced for
|
|
116
|
+
# the same reason. A suite that later dies on a missing key is explained
|
|
117
|
+
# by the log line.
|
|
118
|
+
#
|
|
119
|
+
# @param path [String] the worktree to seed
|
|
120
|
+
# @return [Boolean] whether the seeder ran and succeeded
|
|
121
|
+
def seed(path)
|
|
122
|
+
unless File.executable?(SEEDER)
|
|
123
|
+
UI.log("worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys")
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
return true if system(SEEDER, "--quiet", path, out: File::NULL, err: File::NULL)
|
|
128
|
+
|
|
129
|
+
UI.log("worktree: could not seed #{path}; its suite may fail on a missing key")
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param feature [Agentilda::Plans::Feature]
|
|
134
|
+
# @return [String] e.g. "kig/002.00-tenancy-households"
|
|
135
|
+
def branch_for(feature) = "#{@user}/#{feature.ordinal}-#{feature.slug}"
|
|
136
|
+
|
|
137
|
+
# Every worktree this class manages, as git sees them.
|
|
138
|
+
#
|
|
139
|
+
# @return [Array<String>] absolute paths
|
|
140
|
+
def list
|
|
141
|
+
# Compare canonical paths. git reports resolved ones, and on macOS the
|
|
142
|
+
# temp and home trees run through symlinks (/var -> /private/var), so a
|
|
143
|
+
# raw string compare matches nothing and prune silently does nothing.
|
|
144
|
+
mine = canonical(dir)
|
|
145
|
+
|
|
146
|
+
`git -C #{root.shellescape} worktree list --porcelain 2>/dev/null`
|
|
147
|
+
.lines(chomp: true)
|
|
148
|
+
.filter_map { |l| l.delete_prefix("worktree ") if l.start_with?("worktree ") }
|
|
149
|
+
.select { |p| canonical(p).start_with?(mine) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param path [String]
|
|
153
|
+
# @return [String] the symlink-resolved path, or the input when it is gone
|
|
154
|
+
def canonical(path)
|
|
155
|
+
File.realpath(path)
|
|
156
|
+
rescue Errno::ENOENT
|
|
157
|
+
path
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Remove worktrees the agents left untouched.
|
|
161
|
+
#
|
|
162
|
+
# An agent that ran and changed nothing has produced a checkout that is
|
|
163
|
+
# pure cost: it looks like work in progress and is not. Dirty ones are
|
|
164
|
+
# always kept — that is the output.
|
|
165
|
+
#
|
|
166
|
+
# @return [Array<String>] paths removed
|
|
167
|
+
def prune
|
|
168
|
+
list.select { |path| clean?(path) }.each { |path| remove(path) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param path [String]
|
|
172
|
+
# @return [void]
|
|
173
|
+
def remove(path)
|
|
174
|
+
system("git", "-C", root, "worktree", "remove", "--force", path, out: File::NULL, err: File::NULL)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Sibling of the repository, not inside it: a worktree under the repo shows
|
|
180
|
+
# up in its own `git status` and in every glob anybody writes.
|
|
181
|
+
#
|
|
182
|
+
# @return [String]
|
|
183
|
+
def default_dir = "#{@root}.worktrees"
|
|
184
|
+
|
|
185
|
+
# Drop registrations whose directory is gone.
|
|
186
|
+
#
|
|
187
|
+
# @return [void]
|
|
188
|
+
def forget_stale
|
|
189
|
+
system("git", "-C", root, "worktree", "prune", out: File::NULL, err: File::NULL)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @param path [String]
|
|
193
|
+
# @return [Boolean]
|
|
194
|
+
def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
|
|
195
|
+
|
|
196
|
+
# @param branch [String]
|
|
197
|
+
# @param path [String]
|
|
198
|
+
# @return [void]
|
|
199
|
+
def add(branch, path)
|
|
200
|
+
exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
|
|
201
|
+
args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path]
|
|
202
|
+
|
|
203
|
+
return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
|
|
204
|
+
|
|
205
|
+
raise Error, "could not create a worktree for #{branch} at #{path}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Git and GitHub: a checkout per plan, pushing a branch, and reading
|
|
5
|
+
# pull requests. Everything that leaves the machine leaves through here.
|
|
6
|
+
module Vcs
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @return [Agentilda::Vcs::GitHub]
|
|
10
|
+
def github = GitHub.new
|
|
11
|
+
|
|
12
|
+
# @param root [String] repository root
|
|
13
|
+
# @return [Agentilda::Vcs::Worktree]
|
|
14
|
+
def worktree(root:, **) = Worktree.new(root:, **)
|
|
15
|
+
|
|
16
|
+
# @return [Agentilda::Vcs::Publisher]
|
|
17
|
+
def publisher(**) = Publisher.new(**)
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/agentilda/version.rb
CHANGED
data/lib/agentilda.rb
CHANGED
|
@@ -19,6 +19,7 @@ loader.inflector.inflect(
|
|
|
19
19
|
"unit" => "Units",
|
|
20
20
|
"ui" => "UI"
|
|
21
21
|
)
|
|
22
|
+
loader.collapse("#{__dir__}/agentilda/support")
|
|
22
23
|
loader.collapse("#{__dir__}/agentilda/cli/create")
|
|
23
24
|
loader.collapse("#{__dir__}/agentilda/cli/docs")
|
|
24
25
|
loader.collapse("#{__dir__}/agentilda/cli/index")
|
|
@@ -115,13 +116,13 @@ module Agentilda
|
|
|
115
116
|
#
|
|
116
117
|
# @param root [String] repository root
|
|
117
118
|
# @param ref [String] a branch name; `origin/` is tried first
|
|
118
|
-
# @return [Array<Agentilda::Ordinal>] possibly empty
|
|
119
|
+
# @return [Array<Agentilda::Plans::Ordinal>] possibly empty
|
|
119
120
|
def self.plans_on_ref(root, ref)
|
|
120
121
|
["origin/#{ref}", ref].each do |candidate|
|
|
121
122
|
out = `git -C #{root.shellescape} ls-tree -d --name-only #{candidate.shellescape} #{PLANS_DIR}/ 2>/dev/null`
|
|
122
123
|
next if out.to_s.strip.empty?
|
|
123
124
|
|
|
124
|
-
return out.lines.filter_map { |line| Ordinal.from_dirname(File.basename(line.strip)) }
|
|
125
|
+
return out.lines.filter_map { |line| Plans::Ordinal.from_dirname(File.basename(line.strip)) }
|
|
125
126
|
end
|
|
126
127
|
[]
|
|
127
128
|
end
|
|
@@ -134,9 +135,9 @@ module Agentilda
|
|
|
134
135
|
class Error < StandardError; end
|
|
135
136
|
end
|
|
136
137
|
|
|
137
|
-
# status.rb and feature.rb define methods on Agentilda
|
|
138
|
+
# plans/status.rb and plans/feature.rb define methods on Agentilda::Plans (`status`,
|
|
138
139
|
# `plan_dirname`, `titleize` and friends) beside the class each is named for.
|
|
139
140
|
# Zeitwerk loads a file when its constant is first named, and a caller of
|
|
140
|
-
# `
|
|
141
|
+
# `Plans.status` names no constant, so without this those methods exist
|
|
141
142
|
# or not depending on what happened to load first.
|
|
142
|
-
%w[status feature].each { |name| loader.load_file("#{__dir__}/agentilda/#{name}.rb") }
|
|
143
|
+
%w[status feature].each { |name| loader.load_file("#{__dir__}/agentilda/plans/#{name}.rb") }
|