agentilda 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
data/lib/agentilda/unblocker.rb
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Agentilda
|
|
4
|
-
# Draining a plan's `blocked.md`, and saying what happened.
|
|
5
|
-
#
|
|
6
|
-
# `unblock` is the one command a human runs *because* they know something the
|
|
7
|
-
# tool cannot observe: an answer has arrived. So it owes them an account of
|
|
8
|
-
# what it did with it. Four things can happen to a plan named on the command
|
|
9
|
-
# line, and only one of them is the interesting one:
|
|
10
|
-
#
|
|
11
|
-
# * the number names no folder,
|
|
12
|
-
# * the folder is not blocked and there is nothing to drain,
|
|
13
|
-
# * the folder is blocked, and some, none or all of its questions fold in,
|
|
14
|
-
# * `blocked.md` is there but numbers nothing `## B<n>`, so no program can
|
|
15
|
-
# read it at all.
|
|
16
|
-
#
|
|
17
|
-
# Each is reported differently, because each needs a different thing done
|
|
18
|
-
# next. This object works out which one happened; {CLI::Unblock} prints it.
|
|
19
|
-
class Unblocker
|
|
20
|
-
# One open question, as `blocked.md` writes it.
|
|
21
|
-
#
|
|
22
|
-
# @!attribute [r] number
|
|
23
|
-
# @return [Integer] the `n` in `## B<n>`
|
|
24
|
-
# @!attribute [r] heading
|
|
25
|
-
# @return [String] the heading text, hashes stripped
|
|
26
|
-
# @!attribute [r] answered
|
|
27
|
-
# @return [Boolean] whether an `## A<n>` of the same number is waiting
|
|
28
|
-
Question = Data.define(:number, :heading, :answered) do
|
|
29
|
-
# @return [String]
|
|
30
|
-
def to_s = answered ? "#{heading} ← answer waiting" : heading
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# One token from the command line, once the tree has had a look at it.
|
|
34
|
-
#
|
|
35
|
-
# @!attribute [r] token
|
|
36
|
-
# @return [String] what the user typed
|
|
37
|
-
# @!attribute [r] subject
|
|
38
|
-
# @return [Agentilda::Subject, nil]
|
|
39
|
-
# @!attribute [r] problem
|
|
40
|
-
# @return [Symbol, nil] :missing, :not_blocked, or nil when it is drainable
|
|
41
|
-
Target = Data.define(:token, :subject, :problem) do
|
|
42
|
-
# @return [Boolean]
|
|
43
|
-
def drainable? = problem.nil?
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# What became of one plan.
|
|
47
|
-
#
|
|
48
|
-
# @!attribute [r] subject
|
|
49
|
-
# @return [Agentilda::Subject] as the folder stands *now*
|
|
50
|
-
# @!attribute [r] ok
|
|
51
|
-
# @return [Boolean] whether the agent ran without failing
|
|
52
|
-
# @!attribute [r] note
|
|
53
|
-
# @return [String] one line about how it went
|
|
54
|
-
# @!attribute [r] before
|
|
55
|
-
# @return [Array<Question>] open before the agent ran
|
|
56
|
-
# @!attribute [r] after
|
|
57
|
-
# @return [Array<Question>] open now
|
|
58
|
-
Outcome = Data.define(:subject, :ok, :note, :before, :after) do
|
|
59
|
-
# @return [Agentilda::Ordinal]
|
|
60
|
-
def ordinal = subject.feature.ordinal
|
|
61
|
-
|
|
62
|
-
# @return [Array<Integer>] the questions this run actually disposed of
|
|
63
|
-
def folded = before.map(&:number) - after.map(&:number)
|
|
64
|
-
|
|
65
|
-
# @return [Boolean] `blocked.md` is gone, so the plan is out of the block
|
|
66
|
-
def cleared? = !subject.file?("blocked.md")
|
|
67
|
-
|
|
68
|
-
# @return [Boolean] a `blocked.md` no program can read
|
|
69
|
-
def unreadable? = subject.unreadable_block?
|
|
70
|
-
|
|
71
|
-
# @return [Array<Question>] still open, with an answer sitting unfolded
|
|
72
|
-
def waiting = after.select(&:answered)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# The questions a folder still names, in the order the file names them.
|
|
76
|
-
#
|
|
77
|
-
# @param subject [Agentilda::Subject]
|
|
78
|
-
# @return [Array<Question>]
|
|
79
|
-
def self.questions(subject)
|
|
80
|
-
answered = subject.block_answers
|
|
81
|
-
|
|
82
|
-
subject.read("blocked.md").to_s.lines.grep(OPEN_BLOCK).map do |line|
|
|
83
|
-
number = line[OPEN_BLOCK, 1].to_i
|
|
84
|
-
Question.new(number:, heading: line.strip.sub(/\A\#+[ \t]*/, ""), answered: answered.include?(number))
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# @param tree [Agentilda::Tree]
|
|
89
|
-
# @param agent [Agentilda::Agent]
|
|
90
|
-
# @param executor [#call] receives (agent, subject, root:) and yields each
|
|
91
|
-
# phrase describing what the agent is doing
|
|
92
|
-
# @param root [String] the repository the agent works in
|
|
93
|
-
# @param commit [Boolean] false plans the invocation and changes nothing
|
|
94
|
-
def initialize(tree:, agent:, executor:, root: nil, commit: false)
|
|
95
|
-
@tree = tree
|
|
96
|
-
@agent = agent
|
|
97
|
-
@executor = executor
|
|
98
|
-
@root = root || File.dirname(tree.dir)
|
|
99
|
-
@commit = commit
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# @return [Agentilda::Tree]
|
|
103
|
-
attr_reader :tree
|
|
104
|
-
|
|
105
|
-
# @return [Agentilda::Agent]
|
|
106
|
-
attr_reader :agent
|
|
107
|
-
|
|
108
|
-
# @return [String]
|
|
109
|
-
attr_reader :root
|
|
110
|
-
|
|
111
|
-
# @return [Boolean]
|
|
112
|
-
def commit? = @commit
|
|
113
|
-
|
|
114
|
-
# Work out what each number on the command line refers to.
|
|
115
|
-
#
|
|
116
|
-
# Nothing is rejected here and nothing exits: a token that names no folder
|
|
117
|
-
# is as much a thing the user needs told about as a folder that drained, and
|
|
118
|
-
# stopping at the first bad one hides the rest of the report.
|
|
119
|
-
#
|
|
120
|
-
# The gate is `blocked.md` itself, not the folder's emoji. ⭕️ is *derived*
|
|
121
|
-
# from the file, so gating on the emoji made the tool circular: a
|
|
122
|
-
# `blocked.md` whose questions are not numbered `## B<n>` never earns the
|
|
123
|
-
# emoji, so `unblock` refused it, so the file could never drain and the
|
|
124
|
-
# folder could never leave 🟡.
|
|
125
|
-
#
|
|
126
|
-
# @param tokens [Array<String>] `NNN`, `NNN.MM`, or several comma separated
|
|
127
|
-
# @return [Array<Target>]
|
|
128
|
-
def resolve(tokens)
|
|
129
|
-
tokens.flat_map { |token| token.to_s.split(",") }.map(&:strip).reject(&:empty?).map do |token|
|
|
130
|
-
subject = tree.find(token)
|
|
131
|
-
next Target.new(token:, subject: nil, problem: :missing) if subject.nil?
|
|
132
|
-
next Target.new(token:, subject:, problem: :not_blocked) unless subject.file?("blocked.md")
|
|
133
|
-
|
|
134
|
-
Target.new(token:, subject:, problem: nil)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# Hand each folder to the agent, one at a time, and read the file back.
|
|
139
|
-
#
|
|
140
|
-
# What the agent claims it did is not evidence. The questions are counted
|
|
141
|
-
# off disk before and after, so "folded B3" means the heading is gone from
|
|
142
|
-
# `blocked.md`, not that an agent said so.
|
|
143
|
-
#
|
|
144
|
-
# @param subjects [Array<Agentilda::Subject>]
|
|
145
|
-
# @return [Array<Outcome>]
|
|
146
|
-
def call(subjects)
|
|
147
|
-
return [] if subjects.empty?
|
|
148
|
-
|
|
149
|
-
before = subjects.map { |subject| [subject.feature.ordinal.to_s, self.class.questions(subject)] }.to_h
|
|
150
|
-
results = UI.concurrently(subjects,
|
|
151
|
-
headline(subjects),
|
|
152
|
-
jobs: 1,
|
|
153
|
-
label: method(:label),
|
|
154
|
-
fields: method(:log_fields),
|
|
155
|
-
file: ->(_) { "blocked.md" },
|
|
156
|
-
root:) do |subject, progress|
|
|
157
|
-
# After ctrl-c the plans not yet started stay as they are, blocked.md
|
|
158
|
-
# untouched, so the next run picks them up from the beginning.
|
|
159
|
-
next [false, "not started: interrupted by ctrl-c"] if Control.interrupted?
|
|
160
|
-
|
|
161
|
-
invoke(subject, progress)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
settle
|
|
165
|
-
subjects.zip(results).map { |subject, result| outcome(subject, result, before) }
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
private
|
|
169
|
-
|
|
170
|
-
# @param subject [Agentilda::Subject]
|
|
171
|
-
# @param progress [Agentilda::UI::Line] a dashboard row carries the
|
|
172
|
-
# executor handle its kill and extend keys act on; a plain line does not
|
|
173
|
-
# @return [Array(Boolean, String)]
|
|
174
|
-
def invoke(subject, progress)
|
|
175
|
-
handle = progress.handle if progress.is_a?(Dashboard::Tracker)
|
|
176
|
-
@executor.call(agent, subject, root:, handle:, &progress)
|
|
177
|
-
rescue StandardError => e
|
|
178
|
-
[false, "#{e.class}: #{e.message.lines.first.to_s.strip}"]
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# The same pass the run loop makes after every round, for the same reason:
|
|
182
|
-
# a drained folder is only *named* differently once something reads the
|
|
183
|
-
# file the agent just deleted.
|
|
184
|
-
#
|
|
185
|
-
# @return [void]
|
|
186
|
-
def settle
|
|
187
|
-
Resync::Dirs.new(tree:).call(commit: true) if commit?
|
|
188
|
-
tree.reload
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# @param subject [Agentilda::Subject] as it was before the run
|
|
192
|
-
# @param result [Array, Exception]
|
|
193
|
-
# @param before [Hash{String => Array<Question>}]
|
|
194
|
-
# @return [Outcome]
|
|
195
|
-
def outcome(subject, result, before)
|
|
196
|
-
ok, note = result.is_a?(Exception) ? [false, result.message.lines.first.to_s.strip] : result
|
|
197
|
-
current = tree.find(subject.feature.ordinal) || subject
|
|
198
|
-
|
|
199
|
-
Outcome.new(subject: current,
|
|
200
|
-
ok: !!ok,
|
|
201
|
-
note: note.to_s,
|
|
202
|
-
before: before.fetch(subject.feature.ordinal.to_s, []),
|
|
203
|
-
after: self.class.questions(current))
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# @param subjects [Array<Agentilda::Subject>]
|
|
207
|
-
# @return [String]
|
|
208
|
-
def headline(subjects)
|
|
209
|
-
"#{commit? ? "handing" : "would hand"} #{subjects.size} plan#{"s" unless subjects.size == 1} " \
|
|
210
|
-
"to #{agent.name} in #{root}"
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
# @param subject [Agentilda::Subject]
|
|
214
|
-
# @return [String]
|
|
215
|
-
def label(subject) = "#{subject.feature.ordinal} → #{agent.name}"
|
|
216
|
-
|
|
217
|
-
# @param subject [Agentilda::Subject]
|
|
218
|
-
# @return [Hash] the columns this plan's log lines carry
|
|
219
|
-
def log_fields(subject) = { plan: subject.feature.ordinal.to_s, status: subject.status.to_s, agent: agent.name }
|
|
220
|
-
end
|
|
221
|
-
end
|
data/lib/agentilda/viewer.rb
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Agentilda
|
|
4
|
-
# Hands Markdown files to a renderer outside this process: the system
|
|
5
|
-
# viewer via `open`, or mdfried in the terminal.
|
|
6
|
-
#
|
|
7
|
-
# Both paths shell out, so the class takes its two effects as lambdas and
|
|
8
|
-
# the specs assert on the commands instead of launching viewers on the box
|
|
9
|
-
# running the suite.
|
|
10
|
-
class Viewer
|
|
11
|
-
# What to run when mdfried is asked for but not installed. Homebrew is the
|
|
12
|
-
# one package manager the toolchain already assumes (see the Brewfile
|
|
13
|
-
# convention), so there is no fallback chain to get wrong.
|
|
14
|
-
INSTALL = "brew install mdfried >/dev/null"
|
|
15
|
-
|
|
16
|
-
# How to notice mdfried is missing without depending on `which`, which
|
|
17
|
-
# differs across shells; `command -v` is POSIX.
|
|
18
|
-
DETECT = "command -v mdfried >/dev/null"
|
|
19
|
-
|
|
20
|
-
# @param launch [#call] runs a command, returns truthy on success
|
|
21
|
-
# @param pipe [#call] runs a command, yielding its stdin as an IO
|
|
22
|
-
def initialize(launch: ->(*cmd) { system(*cmd) },
|
|
23
|
-
pipe: ->(cmd, &block) { IO.popen(cmd, "w", &block) })
|
|
24
|
-
@launch = launch
|
|
25
|
-
@pipe = pipe
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Opens each file in whatever the system has configured for Markdown.
|
|
29
|
-
#
|
|
30
|
-
# @param paths [Array<String>]
|
|
31
|
-
# @return [void]
|
|
32
|
-
def open(paths)
|
|
33
|
-
Array(paths).each { |path| @launch.call("open", path) }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Streams each file's content into mdfried, installing it first when it
|
|
37
|
-
# is missing.
|
|
38
|
-
#
|
|
39
|
-
# @param paths [Array<String>]
|
|
40
|
-
# @return [void]
|
|
41
|
-
# @raise [Agentilda::Error] when mdfried is absent and brew cannot fix that
|
|
42
|
-
def mdfried(paths)
|
|
43
|
-
ensure_mdfried!
|
|
44
|
-
Array(paths).each do |path|
|
|
45
|
-
@pipe.call("mdfried") { |io| io.write(File.read(path)) }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
# @return [void]
|
|
52
|
-
# @raise [Agentilda::Error]
|
|
53
|
-
def ensure_mdfried!
|
|
54
|
-
return if @launch.call(DETECT)
|
|
55
|
-
|
|
56
|
-
@launch.call(INSTALL) or
|
|
57
|
-
raise Error, "mdfried is not installed, and `brew install mdfried` failed"
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
data/lib/agentilda/worktree.rb
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "shellwords"
|
|
5
|
-
|
|
6
|
-
module Agentilda
|
|
7
|
-
# A git worktree per plan, so agents working on different plans cannot
|
|
8
|
-
# collide at all.
|
|
9
|
-
#
|
|
10
|
-
# A lock coordinates a shared tree; a worktree removes the sharing. That
|
|
11
|
-
# matters more than it sounds for an agent loop: two agents editing one
|
|
12
|
-
# checkout produce no git conflict — same branch, same files — so the last
|
|
13
|
-
# writer simply wins and the loser's work vanishes with nothing anywhere to
|
|
14
|
-
# say it happened.
|
|
15
|
-
#
|
|
16
|
-
# The branch carries the plan number (`<user>/NNN.MM-slug`), which is not
|
|
17
|
-
# decoration: it is the first thing `resync prs` reads when deciding which
|
|
18
|
-
# plan a pull request implements. Naming branches this way means the number
|
|
19
|
-
# carries itself from worktree creation through to a merged pull request with
|
|
20
|
-
# nobody having to remember it.
|
|
21
|
-
class Worktree
|
|
22
|
-
# `bin/setup-worktree`, which copies in the ignored-but-required files a new
|
|
23
|
-
# checkout does not get. Shelling out rather than reimplementing the rules
|
|
24
|
-
# here keeps one answer to "what does a worktree need", and keeps that answer
|
|
25
|
-
# usable from a shell on a machine that has never run `bundle install`.
|
|
26
|
-
SEEDER = File.expand_path("../../bin/setup-worktree", __dir__)
|
|
27
|
-
|
|
28
|
-
# One plan's isolated checkout.
|
|
29
|
-
#
|
|
30
|
-
# @!attribute [r] ordinal
|
|
31
|
-
# @return [Agentilda::Ordinal]
|
|
32
|
-
# @!attribute [r] branch
|
|
33
|
-
# @return [String]
|
|
34
|
-
# @!attribute [r] path
|
|
35
|
-
# @return [String] absolute
|
|
36
|
-
# @!attribute [r] created
|
|
37
|
-
# @return [Boolean] false when an existing worktree was reused
|
|
38
|
-
Checkout = Data.define(:ordinal, :branch, :path, :created) do
|
|
39
|
-
# @return [Boolean] whether the agent left anything behind
|
|
40
|
-
def dirty? = !`git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
|
|
41
|
-
|
|
42
|
-
# @return [String] the plans directory inside this checkout
|
|
43
|
-
def plans_dir = File.join(path, Agentilda::PLANS_DIR)
|
|
44
|
-
|
|
45
|
-
# @return [Boolean] whether this round made it, rather than reusing one
|
|
46
|
-
def created? = created
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# @param root [String] the main repository
|
|
50
|
-
# @param dir [String, nil] where worktrees go; defaults to a sibling
|
|
51
|
-
# @param user [String] branch namespace
|
|
52
|
-
def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
|
|
53
|
-
@root = File.expand_path(root)
|
|
54
|
-
@user = user
|
|
55
|
-
@dir = File.expand_path(dir || default_dir)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# @return [String] the main repository
|
|
59
|
-
attr_reader :root
|
|
60
|
-
|
|
61
|
-
# @return [String] where worktrees are kept
|
|
62
|
-
attr_reader :dir
|
|
63
|
-
|
|
64
|
-
# @return [Boolean] whether root is a git repository at all
|
|
65
|
-
def repository? = system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
|
|
66
|
-
|
|
67
|
-
# Get, or create, the isolated checkout for one plan.
|
|
68
|
-
#
|
|
69
|
-
# The checkout is seeded before it is handed back, on both paths. `git
|
|
70
|
-
# worktree add` brings every TRACKED file and nothing else, so an agent
|
|
71
|
-
# given a bare one cannot run the suite it was told to keep green: a Rails
|
|
72
|
-
# project dies on `MissingKeyError` because `config/credentials/*.key` is
|
|
73
|
-
# gitignored. The agent then reports a broken checkout as a broken plan,
|
|
74
|
-
# which is the worst failure this loop has, because the diagnosis points
|
|
75
|
-
# at the code rather than at the tree.
|
|
76
|
-
#
|
|
77
|
-
# A reused worktree is seeded too. {#seed} only ever adds a missing file,
|
|
78
|
-
# so running it again costs one fast subprocess and repairs the worktrees
|
|
79
|
-
# that were created before any of this existed.
|
|
80
|
-
#
|
|
81
|
-
# @param feature [Agentilda::Feature]
|
|
82
|
-
# @return [Agentilda::Worktree::Checkout]
|
|
83
|
-
# @raise [Agentilda::Error] when git refuses
|
|
84
|
-
def checkout_for(feature)
|
|
85
|
-
branch = branch_for(feature)
|
|
86
|
-
path = File.join(dir, "#{feature.ordinal}-#{feature.slug}")
|
|
87
|
-
|
|
88
|
-
if File.directory?(path)
|
|
89
|
-
seed(path)
|
|
90
|
-
return Checkout.new(ordinal: feature.ordinal, branch:, path:, created: false)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# git keeps a registration for a worktree whose directory was deleted by
|
|
94
|
-
# hand, marks it `prunable`, and then refuses to create a new one at that
|
|
95
|
-
# path. Anybody who has ever `rm -rf`d a worktree meets this, and the
|
|
96
|
-
# error says only "already exists" about a directory that does not.
|
|
97
|
-
forget_stale
|
|
98
|
-
|
|
99
|
-
FileUtils.mkdir_p(dir)
|
|
100
|
-
add(branch, path)
|
|
101
|
-
seed(path)
|
|
102
|
-
Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Copy in the files git ignores and a new checkout therefore lacks: `.env`
|
|
106
|
-
# and friends, and credential keys.
|
|
107
|
-
#
|
|
108
|
-
# Never fatal. A repository with no ignored files is perfectly normal, and
|
|
109
|
-
# a plan is not worth abandoning over a seeding step, so this notes it and
|
|
110
|
-
# carries on.
|
|
111
|
-
#
|
|
112
|
-
# The note goes to the progress log, never the terminal: this runs while
|
|
113
|
-
# the ratatui dashboard owns the screen, and a line written to STDERR
|
|
114
|
-
# lands on top of an agent's row. The seeder's own output is silenced for
|
|
115
|
-
# the same reason. A suite that later dies on a missing key is explained
|
|
116
|
-
# by the log line.
|
|
117
|
-
#
|
|
118
|
-
# @param path [String] the worktree to seed
|
|
119
|
-
# @return [Boolean] whether the seeder ran and succeeded
|
|
120
|
-
def seed(path)
|
|
121
|
-
unless File.executable?(SEEDER)
|
|
122
|
-
UI.log("worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys")
|
|
123
|
-
return false
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
return true if system(SEEDER, "--quiet", path, out: File::NULL, err: File::NULL)
|
|
127
|
-
|
|
128
|
-
UI.log("worktree: could not seed #{path}; its suite may fail on a missing key")
|
|
129
|
-
false
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# @param feature [Agentilda::Feature]
|
|
133
|
-
# @return [String] e.g. "kig/002.00-tenancy-households"
|
|
134
|
-
def branch_for(feature) = "#{@user}/#{feature.ordinal}-#{feature.slug}"
|
|
135
|
-
|
|
136
|
-
# Every worktree this class manages, as git sees them.
|
|
137
|
-
#
|
|
138
|
-
# @return [Array<String>] absolute paths
|
|
139
|
-
def list
|
|
140
|
-
# Compare canonical paths. git reports resolved ones, and on macOS the
|
|
141
|
-
# temp and home trees run through symlinks (/var -> /private/var), so a
|
|
142
|
-
# raw string compare matches nothing and prune silently does nothing.
|
|
143
|
-
mine = canonical(dir)
|
|
144
|
-
|
|
145
|
-
`git -C #{root.shellescape} worktree list --porcelain 2>/dev/null`
|
|
146
|
-
.lines(chomp: true)
|
|
147
|
-
.filter_map { |l| l.delete_prefix("worktree ") if l.start_with?("worktree ") }
|
|
148
|
-
.select { |p| canonical(p).start_with?(mine) }
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# @param path [String]
|
|
152
|
-
# @return [String] the symlink-resolved path, or the input when it is gone
|
|
153
|
-
def canonical(path)
|
|
154
|
-
File.realpath(path)
|
|
155
|
-
rescue Errno::ENOENT
|
|
156
|
-
path
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# Remove worktrees the agents left untouched.
|
|
160
|
-
#
|
|
161
|
-
# An agent that ran and changed nothing has produced a checkout that is
|
|
162
|
-
# pure cost: it looks like work in progress and is not. Dirty ones are
|
|
163
|
-
# always kept — that is the output.
|
|
164
|
-
#
|
|
165
|
-
# @return [Array<String>] paths removed
|
|
166
|
-
def prune
|
|
167
|
-
list.select { |path| clean?(path) }.each { |path| remove(path) }
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# @param path [String]
|
|
171
|
-
# @return [void]
|
|
172
|
-
def remove(path)
|
|
173
|
-
system("git", "-C", root, "worktree", "remove", "--force", path, out: File::NULL, err: File::NULL)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
private
|
|
177
|
-
|
|
178
|
-
# Sibling of the repository, not inside it: a worktree under the repo shows
|
|
179
|
-
# up in its own `git status` and in every glob anybody writes.
|
|
180
|
-
#
|
|
181
|
-
# @return [String]
|
|
182
|
-
def default_dir = "#{@root}.worktrees"
|
|
183
|
-
|
|
184
|
-
# Drop registrations whose directory is gone.
|
|
185
|
-
#
|
|
186
|
-
# @return [void]
|
|
187
|
-
def forget_stale
|
|
188
|
-
system("git", "-C", root, "worktree", "prune", out: File::NULL, err: File::NULL)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# @param path [String]
|
|
192
|
-
# @return [Boolean]
|
|
193
|
-
def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
|
|
194
|
-
|
|
195
|
-
# @param branch [String]
|
|
196
|
-
# @param path [String]
|
|
197
|
-
# @return [void]
|
|
198
|
-
def add(branch, path)
|
|
199
|
-
exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
|
|
200
|
-
args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path]
|
|
201
|
-
|
|
202
|
-
return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
|
|
203
|
-
|
|
204
|
-
raise Error, "could not create a worktree for #{branch} at #{path}"
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|