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
@@ -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
@@ -6,5 +6,5 @@
6
6
  #
7
7
  # © 2026 Konstantin Gredeskoul
8
8
  module Agentilda
9
- VERSION = "2.0.1"
9
+ VERSION = "2.0.4"
10
10
  end
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 itself (`status`,
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
- # `Agentilda.status` names no constant, so without this those methods exist
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") }