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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -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
@@ -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
@@ -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