agentilda 1.0.3
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 +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
|
@@ -0,0 +1,207 @@
|
|
|
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, headline(subjects), jobs: 1, label: method(:label),
|
|
151
|
+
fields: method(:log_fields)) do |subject, progress|
|
|
152
|
+
invoke(subject, &progress)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
settle
|
|
156
|
+
subjects.zip(results).map { |subject, result| outcome(subject, result, before) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# @param subject [Agentilda::Subject]
|
|
162
|
+
# @yieldparam phrase [String] what the agent is doing, as it changes
|
|
163
|
+
# @return [Array(Boolean, String)]
|
|
164
|
+
def invoke(subject, &on_activity)
|
|
165
|
+
@executor.call(agent, subject, root:, &on_activity)
|
|
166
|
+
rescue => e
|
|
167
|
+
[false, "#{e.class}: #{e.message.lines.first.to_s.strip}"]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The same pass the run loop makes after every round, for the same reason:
|
|
171
|
+
# a drained folder is only *named* differently once something reads the
|
|
172
|
+
# file the agent just deleted.
|
|
173
|
+
#
|
|
174
|
+
# @return [void]
|
|
175
|
+
def settle
|
|
176
|
+
Resync::Dirs.new(tree:).call(commit: true) if commit?
|
|
177
|
+
tree.reload
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param subject [Agentilda::Subject] as it was before the run
|
|
181
|
+
# @param result [Array, Exception]
|
|
182
|
+
# @param before [Hash{String => Array<Question>}]
|
|
183
|
+
# @return [Outcome]
|
|
184
|
+
def outcome(subject, result, before)
|
|
185
|
+
ok, note = result.is_a?(Exception) ? [false, result.message.lines.first.to_s.strip] : result
|
|
186
|
+
current = tree.find(subject.feature.ordinal) || subject
|
|
187
|
+
|
|
188
|
+
Outcome.new(subject: current, ok: !!ok, note: note.to_s,
|
|
189
|
+
before: before.fetch(subject.feature.ordinal.to_s, []), after: self.class.questions(current))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @param subjects [Array<Agentilda::Subject>]
|
|
193
|
+
# @return [String]
|
|
194
|
+
def headline(subjects)
|
|
195
|
+
"#{commit? ? "handing" : "would hand"} #{subjects.size} plan#{"s" unless subjects.size == 1} " \
|
|
196
|
+
"to #{agent.name} in #{root}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @param subject [Agentilda::Subject]
|
|
200
|
+
# @return [String]
|
|
201
|
+
def label(subject) = "#{subject.feature.ordinal} → #{agent.name}"
|
|
202
|
+
|
|
203
|
+
# @param subject [Agentilda::Subject]
|
|
204
|
+
# @return [Hash] the columns this plan's log lines carry
|
|
205
|
+
def log_fields(subject) = {plan: subject.feature.ordinal.to_s, status: subject.status.to_s, agent: agent.name}
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Alone in its own file, requiring nothing, so the gemspec can read the version
|
|
4
|
+
# without loading the library — and therefore without the library's own
|
|
5
|
+
# dependencies having to be installed before the gemspec can be evaluated.
|
|
6
|
+
#
|
|
7
|
+
# © 2026 Konstantin Gredeskoul
|
|
8
|
+
module Agentilda
|
|
9
|
+
VERSION = "1.0.3"
|
|
10
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# A git worktree per plan, so agents working on different plans cannot
|
|
5
|
+
# collide at all.
|
|
6
|
+
#
|
|
7
|
+
# A lock coordinates a shared tree; a worktree removes the sharing. That
|
|
8
|
+
# matters more than it sounds for an agent loop: two agents editing one
|
|
9
|
+
# checkout produce no git conflict — same branch, same files — so the last
|
|
10
|
+
# writer simply wins and the loser's work vanishes with nothing anywhere to
|
|
11
|
+
# say it happened.
|
|
12
|
+
#
|
|
13
|
+
# The branch carries the plan number (`<user>/NNN.MM-slug`), which is not
|
|
14
|
+
# decoration: it is the first thing `resync prs` reads when deciding which
|
|
15
|
+
# plan a pull request implements. Naming branches this way means the number
|
|
16
|
+
# carries itself from worktree creation through to a merged pull request with
|
|
17
|
+
# nobody having to remember it.
|
|
18
|
+
class Worktree
|
|
19
|
+
# `bin/setup-worktree`, which copies in the ignored-but-required files a new
|
|
20
|
+
# checkout does not get. Shelling out rather than reimplementing the rules
|
|
21
|
+
# here keeps one answer to "what does a worktree need", and keeps that answer
|
|
22
|
+
# usable from a shell on a machine that has never run `bundle install`.
|
|
23
|
+
SEEDER = File.expand_path("../../bin/setup-worktree", __dir__)
|
|
24
|
+
|
|
25
|
+
# One plan's isolated checkout.
|
|
26
|
+
#
|
|
27
|
+
# @!attribute [r] ordinal
|
|
28
|
+
# @return [Agentilda::Ordinal]
|
|
29
|
+
# @!attribute [r] branch
|
|
30
|
+
# @return [String]
|
|
31
|
+
# @!attribute [r] path
|
|
32
|
+
# @return [String] absolute
|
|
33
|
+
# @!attribute [r] created
|
|
34
|
+
# @return [Boolean] false when an existing worktree was reused
|
|
35
|
+
Checkout = Data.define(:ordinal, :branch, :path, :created) do
|
|
36
|
+
# @return [Boolean] whether the agent left anything behind
|
|
37
|
+
def dirty? = !`git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
|
|
38
|
+
|
|
39
|
+
# @return [String] the plans directory inside this checkout
|
|
40
|
+
def plans_dir = File.join(path, Agentilda::PLANS_DIR)
|
|
41
|
+
|
|
42
|
+
# @return [Boolean] whether this round made it, rather than reusing one
|
|
43
|
+
def created? = created
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param root [String] the main repository
|
|
47
|
+
# @param dir [String, nil] where worktrees go; defaults to a sibling
|
|
48
|
+
# @param user [String] branch namespace
|
|
49
|
+
def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
|
|
50
|
+
@root = File.expand_path(root)
|
|
51
|
+
@user = user
|
|
52
|
+
@dir = File.expand_path(dir || default_dir)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String] the main repository
|
|
56
|
+
attr_reader :root
|
|
57
|
+
|
|
58
|
+
# @return [String] where worktrees are kept
|
|
59
|
+
attr_reader :dir
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] whether root is a git repository at all
|
|
62
|
+
def repository? = system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
|
|
63
|
+
|
|
64
|
+
# Get, or create, the isolated checkout for one plan.
|
|
65
|
+
#
|
|
66
|
+
# The checkout is seeded before it is handed back, on both paths. `git
|
|
67
|
+
# worktree add` brings every TRACKED file and nothing else, so an agent
|
|
68
|
+
# given a bare one cannot run the suite it was told to keep green: a Rails
|
|
69
|
+
# project dies on `MissingKeyError` because `config/credentials/*.key` is
|
|
70
|
+
# gitignored. The agent then reports a broken checkout as a broken plan,
|
|
71
|
+
# which is the worst failure this loop has, because the diagnosis points
|
|
72
|
+
# at the code rather than at the tree.
|
|
73
|
+
#
|
|
74
|
+
# A reused worktree is seeded too. {#seed} only ever adds a missing file,
|
|
75
|
+
# so running it again costs one fast subprocess and repairs the worktrees
|
|
76
|
+
# that were created before any of this existed.
|
|
77
|
+
#
|
|
78
|
+
# @param feature [Agentilda::Feature]
|
|
79
|
+
# @return [Agentilda::Worktree::Checkout]
|
|
80
|
+
# @raise [Agentilda::Error] when git refuses
|
|
81
|
+
def checkout_for(feature)
|
|
82
|
+
branch = branch_for(feature)
|
|
83
|
+
path = File.join(dir, "#{feature.ordinal}-#{feature.slug}")
|
|
84
|
+
|
|
85
|
+
if File.directory?(path)
|
|
86
|
+
seed(path)
|
|
87
|
+
return Checkout.new(ordinal: feature.ordinal, branch:, path:, created: false)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# git keeps a registration for a worktree whose directory was deleted by
|
|
91
|
+
# hand, marks it `prunable`, and then refuses to create a new one at that
|
|
92
|
+
# path. Anybody who has ever `rm -rf`d a worktree meets this, and the
|
|
93
|
+
# error says only "already exists" about a directory that does not.
|
|
94
|
+
forget_stale
|
|
95
|
+
|
|
96
|
+
FileUtils.mkdir_p(dir)
|
|
97
|
+
add(branch, path)
|
|
98
|
+
seed(path)
|
|
99
|
+
Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Copy in the files git ignores and a new checkout therefore lacks: `.env`
|
|
103
|
+
# and friends, and credential keys.
|
|
104
|
+
#
|
|
105
|
+
# Never fatal. A repository with no ignored files is perfectly normal, and
|
|
106
|
+
# a plan is not worth abandoning over a seeding step, so this reports and
|
|
107
|
+
# carries on. It runs `--quiet`, which prints nothing on success and leaves
|
|
108
|
+
# a real failure on STDERR where the loop's other progress goes.
|
|
109
|
+
#
|
|
110
|
+
# @param path [String] the worktree to seed
|
|
111
|
+
# @return [Boolean] whether the seeder ran and succeeded
|
|
112
|
+
# `$stderr` rather than `Kernel#warn`, matching {Agentilda::UI}, which
|
|
113
|
+
# writes its progress to `$stderr` too. Style/StderrPuts prefers warn so the
|
|
114
|
+
# output can be silenced, but warn reaches file descriptor 2 through
|
|
115
|
+
# `Warning.warn` and ignores a reassigned `$stderr` entirely:
|
|
116
|
+
#
|
|
117
|
+
# $stderr = StringIO.new; warn "x"; $stderr.string # => ""
|
|
118
|
+
#
|
|
119
|
+
# So anything capturing this loop's output, the suite included, would never
|
|
120
|
+
# see a seeding failure. Being silenceable is worth less than being seen.
|
|
121
|
+
# rubocop: disable Style/StderrPuts
|
|
122
|
+
def seed(path)
|
|
123
|
+
unless File.executable?(SEEDER)
|
|
124
|
+
$stderr.puts "worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys"
|
|
125
|
+
return false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return true if system(SEEDER, "--quiet", path, out: File::NULL)
|
|
129
|
+
|
|
130
|
+
$stderr.puts "worktree: could not seed #{path}; its suite may fail on a missing key"
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# rubocop: enable Style/StderrPuts
|
|
135
|
+
|
|
136
|
+
# @param feature [Agentilda::Feature]
|
|
137
|
+
# @return [String] e.g. "kig/002.00-tenancy-households"
|
|
138
|
+
def branch_for(feature) = "#{@user}/#{feature.ordinal}-#{feature.slug}"
|
|
139
|
+
|
|
140
|
+
# Every worktree this class manages, as git sees them.
|
|
141
|
+
#
|
|
142
|
+
# @return [Array<String>] absolute paths
|
|
143
|
+
def list
|
|
144
|
+
# Compare canonical paths. git reports resolved ones, and on macOS the
|
|
145
|
+
# temp and home trees run through symlinks (/var -> /private/var), so a
|
|
146
|
+
# raw string compare matches nothing and prune silently does nothing.
|
|
147
|
+
mine = canonical(dir)
|
|
148
|
+
|
|
149
|
+
`git -C #{root.shellescape} worktree list --porcelain 2>/dev/null`
|
|
150
|
+
.lines(chomp: true)
|
|
151
|
+
.filter_map { |l| l.delete_prefix("worktree ") if l.start_with?("worktree ") }
|
|
152
|
+
.select { |p| canonical(p).start_with?(mine) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @param path [String]
|
|
156
|
+
# @return [String] the symlink-resolved path, or the input when it is gone
|
|
157
|
+
def canonical(path)
|
|
158
|
+
File.realpath(path)
|
|
159
|
+
rescue Errno::ENOENT
|
|
160
|
+
path
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Remove worktrees the agents left untouched.
|
|
164
|
+
#
|
|
165
|
+
# An agent that ran and changed nothing has produced a checkout that is
|
|
166
|
+
# pure cost: it looks like work in progress and is not. Dirty ones are
|
|
167
|
+
# always kept — that is the output.
|
|
168
|
+
#
|
|
169
|
+
# @return [Array<String>] paths removed
|
|
170
|
+
def prune
|
|
171
|
+
list.select { |path| clean?(path) }.each { |path| remove(path) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param path [String]
|
|
175
|
+
# @return [void]
|
|
176
|
+
def remove(path)
|
|
177
|
+
system("git", "-C", root, "worktree", "remove", "--force", path, out: File::NULL, err: File::NULL)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# Sibling of the repository, not inside it: a worktree under the repo shows
|
|
183
|
+
# up in its own `git status` and in every glob anybody writes.
|
|
184
|
+
#
|
|
185
|
+
# @return [String]
|
|
186
|
+
def default_dir = "#{@root}.worktrees"
|
|
187
|
+
|
|
188
|
+
# Drop registrations whose directory is gone.
|
|
189
|
+
#
|
|
190
|
+
# @return [void]
|
|
191
|
+
def forget_stale
|
|
192
|
+
system("git", "-C", root, "worktree", "prune", out: File::NULL, err: File::NULL)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @param path [String]
|
|
196
|
+
# @return [Boolean]
|
|
197
|
+
def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
|
|
198
|
+
|
|
199
|
+
# @param branch [String]
|
|
200
|
+
# @param path [String]
|
|
201
|
+
# @return [void]
|
|
202
|
+
def add(branch, path)
|
|
203
|
+
exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
|
|
204
|
+
args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path]
|
|
205
|
+
|
|
206
|
+
return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
|
|
207
|
+
|
|
208
|
+
raise Error, "could not create a worktree for #{branch} at #{path}"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
data/lib/agentilda.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require_relative "dry/cli/banner"
|
|
5
|
+
require "dry/monads"
|
|
6
|
+
require "dry/inflector"
|
|
7
|
+
require "pastel"
|
|
8
|
+
require "unicode/display_width"
|
|
9
|
+
require "strings"
|
|
10
|
+
require "tty/box"
|
|
11
|
+
require "tty/command"
|
|
12
|
+
require "tty/screen"
|
|
13
|
+
require "tty/progressbar"
|
|
14
|
+
require "tty/spinner"
|
|
15
|
+
require "concurrent/array"
|
|
16
|
+
require "concurrent/hash"
|
|
17
|
+
require "etc"
|
|
18
|
+
require "fileutils"
|
|
19
|
+
require "tempfile"
|
|
20
|
+
require "tmpdir"
|
|
21
|
+
require "shellwords"
|
|
22
|
+
require "parallel"
|
|
23
|
+
|
|
24
|
+
# Spec → Plan → Build.
|
|
25
|
+
#
|
|
26
|
+
# Three phases, each with a file that proves it happened, and a guarded state
|
|
27
|
+
# machine that refuses to let a plan folder claim a phase it has not reached.
|
|
28
|
+
#
|
|
29
|
+
# spec ⚪️ New spec.md
|
|
30
|
+
# plan ⭐️ Ready plan.md
|
|
31
|
+
# build 🟡 → ✅ pull-requests.md
|
|
32
|
+
#
|
|
33
|
+
# The conventions are not documented anywhere else by hand: the status
|
|
34
|
+
# vocabulary, the numbering rules and the transition table live here and are
|
|
35
|
+
# emitted by `agentilda docs`. Three hand-maintained copies of that table
|
|
36
|
+
# have already drifted apart, which is why there is now exactly one.
|
|
37
|
+
#
|
|
38
|
+
require_relative "agentilda/version"
|
|
39
|
+
|
|
40
|
+
# © 2026 Konstantin Gredeskoul
|
|
41
|
+
module Agentilda
|
|
42
|
+
# The folder every project keeps its plans in.
|
|
43
|
+
PLANS_DIR = ".plans"
|
|
44
|
+
|
|
45
|
+
# Prefix for a pull request that deliberately implements no plan —
|
|
46
|
+
# dependency bumps, CI work, hotfixes, developer tooling.
|
|
47
|
+
#
|
|
48
|
+
# Lower case, and not a number, so it cannot be mistaken for one. It used to
|
|
49
|
+
# be `DEV.00`, which read as a plan number and sorted among them.
|
|
50
|
+
NO_PLAN_PREFIX = "dev"
|
|
51
|
+
|
|
52
|
+
# What it used to be. Titles still wearing it are rewritten rather than
|
|
53
|
+
# skipped as already-prefixed.
|
|
54
|
+
STALE_NO_PLAN_PREFIX = "DEV.00"
|
|
55
|
+
|
|
56
|
+
# Prefix for a pull request that implements *something* nothing could name.
|
|
57
|
+
# Unlike `dev` this asserts nothing; it marks a question left open.
|
|
58
|
+
NONE_PREFIX = "none"
|
|
59
|
+
|
|
60
|
+
# @return [Dry::Inflector] shared inflector
|
|
61
|
+
def self.inflector = @inflector ||= Dry::Inflector.new
|
|
62
|
+
|
|
63
|
+
# Move a directory, preferring `git mv` so its history follows it.
|
|
64
|
+
#
|
|
65
|
+
# Both the state machine and `resync dirs` move plan folders, and a folder
|
|
66
|
+
# that loses its history because one of them used `FileUtils` is a folder
|
|
67
|
+
# nobody can `git log`.
|
|
68
|
+
#
|
|
69
|
+
# @param source [String] absolute
|
|
70
|
+
# @param target [String] absolute
|
|
71
|
+
# @return [Boolean] false when the target was already occupied
|
|
72
|
+
def self.move_directory(source, target)
|
|
73
|
+
return false if File.exist?(target)
|
|
74
|
+
|
|
75
|
+
parent = File.dirname(source)
|
|
76
|
+
tracked = system("git", "-C", parent, "ls-files", "--error-unmatch", source,
|
|
77
|
+
out: File::NULL, err: File::NULL)
|
|
78
|
+
moved = tracked && system("git", "-C", parent, "mv", source, target,
|
|
79
|
+
out: File::NULL, err: File::NULL)
|
|
80
|
+
FileUtils.mv(source, target) unless moved
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Plan numbers visible in `.plans` on a git ref, without checking it out.
|
|
85
|
+
#
|
|
86
|
+
# A pull request's branch is the only place that knows how far the sequence
|
|
87
|
+
# had got when the work started, and branches are not all rebased onto the
|
|
88
|
+
# same main. Reading the ref directly costs one `ls-tree` and no worktree.
|
|
89
|
+
#
|
|
90
|
+
# @param root [String] repository root
|
|
91
|
+
# @param ref [String] a branch name; `origin/` is tried first
|
|
92
|
+
# @return [Array<Agentilda::Ordinal>] possibly empty
|
|
93
|
+
def self.plans_on_ref(root, ref)
|
|
94
|
+
["origin/#{ref}", ref].each do |candidate|
|
|
95
|
+
out = `git -C #{root.shellescape} ls-tree -d --name-only #{candidate.shellescape} #{PLANS_DIR}/ 2>/dev/null`
|
|
96
|
+
next if out.to_s.strip.empty?
|
|
97
|
+
|
|
98
|
+
return out.lines.filter_map { |line| Ordinal.from_dirname(File.basename(line.strip)) }
|
|
99
|
+
end
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The agent that writes a specification for work that already shipped.
|
|
104
|
+
# Named here rather than in the CLI so the definition file stays the single
|
|
105
|
+
# source of truth about who does what.
|
|
106
|
+
RETROACTIVE_WRITER = "yoda-writer"
|
|
107
|
+
|
|
108
|
+
class Error < StandardError; end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Each component is required only once it exists, so the suite loads — and
|
|
112
|
+
# stays a useful red/green signal — while the rest is being built. Drop the
|
|
113
|
+
# `File.exist?` guard once every file below is in place.
|
|
114
|
+
%w[
|
|
115
|
+
ui
|
|
116
|
+
config
|
|
117
|
+
ordinal
|
|
118
|
+
status
|
|
119
|
+
progress_log
|
|
120
|
+
state_machine
|
|
121
|
+
dev_work
|
|
122
|
+
frontmatter
|
|
123
|
+
markdown
|
|
124
|
+
pull_request
|
|
125
|
+
description
|
|
126
|
+
feature
|
|
127
|
+
github
|
|
128
|
+
tree
|
|
129
|
+
creator
|
|
130
|
+
brief
|
|
131
|
+
adoption
|
|
132
|
+
resolver
|
|
133
|
+
resync
|
|
134
|
+
reporter
|
|
135
|
+
tally
|
|
136
|
+
index
|
|
137
|
+
linear
|
|
138
|
+
agent
|
|
139
|
+
transcript
|
|
140
|
+
roster
|
|
141
|
+
viewer
|
|
142
|
+
worktree
|
|
143
|
+
publisher
|
|
144
|
+
control
|
|
145
|
+
keyboard
|
|
146
|
+
executor
|
|
147
|
+
runner
|
|
148
|
+
unblocker
|
|
149
|
+
documentation
|
|
150
|
+
diagram
|
|
151
|
+
cli
|
|
152
|
+
].each do |component|
|
|
153
|
+
path = File.join(__dir__, "agentilda", "#{component}.rb")
|
|
154
|
+
require path if File.exist?(path)
|
|
155
|
+
end
|