agentilda 1.0.3 → 2.0.0

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +8 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +78 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +22 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +61 -15
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # The one channel two agents on the same plan have to each other.
5
+ #
6
+ # `luke-backend` and `rey-frontend` build one plan in one worktree at the
7
+ # same time, but each is its own `claude -p` process. Claude Code can
8
+ # message between local sessions, and the prompts used to say so, but a
9
+ # session is named after its directory plus a random suffix, so a pair had
10
+ # to guess its partner from a list of every session on the machine by start
11
+ # time. It worked once, by luck. With ten plans running there would have
12
+ # been ten `agentilda-xx` entries and nothing to tell them apart.
13
+ #
14
+ # So the mailbox is a file in the plan folder, append-only, one numbered
15
+ # entry per message, readable by `agentilda mail read` during the round and
16
+ # by a person after it. The harness names the file and the partner in each
17
+ # agent's prompt, so nobody guesses.
18
+ class Mailbox
19
+ FILENAME = "mailbox.md"
20
+
21
+ # Between the fields of a heading. Neither a name nor a timestamp
22
+ # contains it, which is what makes the heading parseable.
23
+ SEPARATOR = " · "
24
+
25
+ # `## 3 · 2026-09-06 10:30:53 -0700 · rey-frontend → luke-backend`
26
+ HEADING = /\A\#\# (\d+) · (.+?) · (\S+) → (\S+)\s*\z/
27
+
28
+ TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z"
29
+
30
+ # One entry.
31
+ #
32
+ # @!attribute [r] number
33
+ # @return [Integer] 1-based, in order of writing
34
+ # @!attribute [r] at
35
+ # @return [String] when, as {TIME_FORMAT} writes it
36
+ # @!attribute [r] from
37
+ # @return [String] the agent that wrote it
38
+ # @!attribute [r] to
39
+ # @return [String] the agent it is for
40
+ # @!attribute [r] body
41
+ # @return [String]
42
+ Message = Data.define(:number, :at, :from, :to, :body) do
43
+ # @return [String] as it stands in the file
44
+ def to_s = "## #{number}#{SEPARATOR}#{at}#{SEPARATOR}#{from} → #{to}\n\n#{body.strip}\n"
45
+ end
46
+
47
+ # @param dir [String] the plan folder
48
+ def initialize(dir:)
49
+ @dir = dir
50
+ end
51
+
52
+ # @return [String] the plan folder
53
+ attr_reader :dir
54
+
55
+ # @return [String]
56
+ def path = File.join(dir, FILENAME)
57
+
58
+ # @return [Boolean]
59
+ def exist? = File.file?(path)
60
+
61
+ # Every message, in the order it was written.
62
+ #
63
+ # @return [Array<Agentilda::Mailbox::Message>]
64
+ def messages
65
+ return [] unless exist?
66
+
67
+ parse(File.read(path, encoding: "UTF-8"))
68
+ end
69
+
70
+ # What is waiting for one agent.
71
+ #
72
+ # @param name [String] the reader
73
+ # @param after [Integer] a number the reader has already seen; only
74
+ # messages above it are returned
75
+ # @return [Array<Agentilda::Mailbox::Message>]
76
+ def for(name, after: 0)
77
+ messages.select { |m| m.to == name && m.number > after }
78
+ end
79
+
80
+ # Append one message and give it the next number.
81
+ #
82
+ # The append takes a lock on the file. Two agents finishing a unit in the
83
+ # same second would otherwise read the same last number and both write
84
+ # the one after it.
85
+ #
86
+ # @param from [String]
87
+ # @param to [String]
88
+ # @param body [String]
89
+ # @param at [Time]
90
+ # @return [Agentilda::Mailbox::Message] as written
91
+ # @raise [Agentilda::Error] on an empty body, which would be a heading
92
+ # with nothing under it for the reader to act on, or on a sender or
93
+ # recipient that is blank or holds whitespace: {HEADING} could not
94
+ # parse such an entry back, so it would be written and never read
95
+ def append(from:, to:, body:, at: Time.now)
96
+ text = body.to_s.strip
97
+ raise Error, "a message needs a body" if text.empty?
98
+ raise Error, "a message needs --from and --to, each one agent name" unless name?(from) && name?(to)
99
+
100
+ File.open(path, File::RDWR | File::CREAT | File::APPEND, 0o644) do |f|
101
+ f.flock(File::LOCK_EX)
102
+ f.rewind
103
+ existing = f.read
104
+ number = (parse(existing).last&.number || 0) + 1
105
+ message = Message.new(number:, at: at.strftime(TIME_FORMAT), from:, to:, body: text)
106
+ f.write(existing.empty? ? "# Mailbox\n\n#{message}" : "\n#{message}")
107
+ message
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # @param value [Object]
114
+ # @return [Boolean] one token, as {HEADING} expects a name to be
115
+ def name?(value) = value.to_s.match?(/\A\S+\z/)
116
+
117
+ # Only a line shaped exactly like {HEADING} starts a message, so a body
118
+ # may hold headings of its own, code, anything.
119
+ #
120
+ # @param text [String]
121
+ # @return [Array<Agentilda::Mailbox::Message>]
122
+ def parse(text)
123
+ messages = []
124
+ current = nil
125
+ body = []
126
+
127
+ text.to_s.each_line(chomp: true) do |line|
128
+ if (heading = HEADING.match(line))
129
+ messages << current.with(body: body.join("\n").strip) if current
130
+ current = Message.new(number: heading[1].to_i, at: heading[2], from: heading[3], to: heading[4], body: "")
131
+ body = []
132
+ elsif current
133
+ body << line
134
+ end
135
+ end
136
+
137
+ messages << current.with(body: body.join("\n").strip) if current
138
+ messages
139
+ end
140
+ end
141
+ end
@@ -46,7 +46,7 @@ module Agentilda
46
46
  cursor += 1
47
47
  end
48
48
 
49
- found << {header: cells(head), rows: body}
49
+ found << { header: cells(head), rows: body }
50
50
  index = cursor
51
51
  end
52
52
 
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/status"
4
+ require "agentilda/ui"
5
+
3
6
  module Agentilda
4
7
  # One line of `run --log FILE`, laid out as fixed-width columns.
5
8
  #
@@ -18,6 +21,7 @@ module Agentilda
18
21
  # @example
19
22
  # ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
20
23
  # agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
24
+
21
25
  # #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
22
26
  module ProgressLog
23
27
  # Wall clock only. A log line answers "when", and the date is the file's.
@@ -60,7 +64,7 @@ module Agentilda
60
64
  # that line happens to be missing, so a reader indenting a wrapped message
61
65
  # has a number to indent by.
62
66
  COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
63
- SECONDS_WIDTH].freeze
67
+ SECONDS_WIDTH].freeze
64
68
 
65
69
  # @see COLUMN_WIDTHS
66
70
  LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tempfile"
4
+ require "tty/command"
5
+
3
6
  module Agentilda
4
7
  # Turns a finished worktree into a pushed branch and a pull request.
5
8
  #
6
9
  # This is the one place the harness reaches off the machine, so it requires
7
- # `--commit`, is skipped with `--dont-push-anything`, and refuses anything
10
+ # `--commit`, is skipped with `--no-git-push`, and refuses anything
8
11
  # it is not certain about. Everything it does is reported before it does it.
9
12
  #
10
13
  # Titles follow the convention the whole system joins on:
@@ -80,7 +83,7 @@ module Agentilda
80
83
  stderr = lines.find { |l| l.start_with?("stderr:") }
81
84
  complaint = stderr&.delete_prefix("stderr:")&.strip
82
85
 
83
- (complaint.to_s.empty? || complaint == "Nothing written") ? lines.first.to_s : complaint
86
+ complaint.to_s.empty? || complaint == "Nothing written" ? lines.first.to_s : complaint
84
87
  end
85
88
 
86
89
  # The title a plan's next pull request should carry.
@@ -104,7 +107,7 @@ module Agentilda
104
107
 
105
108
  # @return [Hash]
106
109
  def base_fields(subject, checkout, title)
107
- {ordinal: subject.feature.ordinal, branch: checkout.branch, title:}
110
+ { ordinal: subject.feature.ordinal, branch: checkout.branch, title: }
108
111
  end
109
112
 
110
113
  # @return [Agentilda::Publisher::Publication]
@@ -24,190 +24,4 @@ module Agentilda
24
24
  # @return [String] "#92 — Send mail through Resend"
25
25
  def label = number ? "##{number} — #{title}" : title
26
26
  end
27
-
28
- # Extracts the pull request roll-up from a plan folder.
29
- class PullRequests
30
- # A markdown link whose text may contain escaped brackets, because every
31
- # title this tool writes now opens with `\[NNN.MM\]`. Matching `[^\]]+`
32
- # stops at the escaped `]` and loses the URL with it.
33
- LINK = /\[((?:\\.|[^\]\\])+)\]\((https?:[^)\s]+)\)/
34
-
35
- # Canonical wording for the states we recognise, most specific first.
36
- STATES = [
37
- [/\bclosed\b|\bunmerged\b|\babandoned\b/i, "Closed 🔴"],
38
- [/\bmerged\b/i, "Merged 🟣"],
39
- [/\bdraft\b|\bwip\b/i, "WIP 🟡"],
40
- [/\bopen\b/i, "Open 🟡"]
41
- ].freeze
42
-
43
- # Filenames that may hold the table.
44
- CANDIDATES = %w[pull-requests.md pull_requests.md prs.md].freeze
45
-
46
- # The file this class writes, and the first one it looks for.
47
- FILENAME = CANDIDATES.first
48
-
49
- # Render `pull-requests.md` for a set of pull requests fetched from GitHub.
50
- #
51
- # The table is what {#parse} reads back and what the state machine judges
52
- # a folder by. The descriptions below it are for a reader — human or
53
- # agent — synthesizing a specification from work that already shipped:
54
- # the table says *which* pull requests, the bodies say *what they did*,
55
- # and a retroactive `spec.md` cannot be written from numbers alone.
56
- #
57
- # Prose after the table is ignored by the parser, so the two can coexist
58
- # in one file rather than needing a scratch file the folder rules forbid.
59
- #
60
- # @param prs [Array<Hash>] `{number:, title:, url:, state:, body:}`
61
- # @return [String]
62
- def self.render(prs)
63
- rows = prs.map { |pr|
64
- "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |"
65
- }
66
-
67
- <<~MARKDOWN
68
- # Pull Requests
69
-
70
- | Pull Request Number | Pull Request Name | Status |
71
- | ------------------: | :---------------- | -----: |
72
- #{rows.join("\n")}
73
-
74
- ## What these pull requests did
75
-
76
- #{prs.map { |pr| describe(pr) }.join("\n")}
77
- MARKDOWN
78
- end
79
-
80
- # @param pr [Hash]
81
- # @return [String]
82
- def self.describe(pr)
83
- body = pr[:body].to_s.strip
84
- body = "_No description was written on the pull request._" if body.empty?
85
-
86
- <<~MARKDOWN
87
- ### ##{pr[:number]} — #{pr[:title]}
88
-
89
- #{pr[:url]}
90
-
91
- #{body}
92
- MARKDOWN
93
- end
94
-
95
- # Three characters have to survive a round trip through a table cell.
96
- #
97
- # A `|` ends the cell, and a title containing one is not unusual —
98
- # "fix: guard against a || b".
99
- #
100
- # `[` and `]` are newer and cost more. Every title this tool writes now
101
- # opens with a plan number, so the row reads `[[013.00] Ship it](url)` —
102
- # and a markdown link whose text starts with `[` does not parse. The
103
- # title came back with the URL glued to it and `url` came back nil, which
104
- # is not a cosmetic loss: it is every pull request link on the page, and
105
- # the attachment on every Linear issue.
106
- #
107
- # @param text [String]
108
- # @return [String]
109
- def self.escape(text)
110
- text.to_s.gsub(/([\[\]|])/) { "\\#{$1}" }.gsub(/\s+/, " ").strip
111
- end
112
-
113
- # @param dir [String] absolute path to the plan folder
114
- def initialize(dir:)
115
- @dir = dir
116
- end
117
-
118
- # @return [Array<Agentilda::PullRequest>] possibly empty
119
- def all = @all ||= parse
120
-
121
- private
122
-
123
- # @return [String]
124
- attr_reader :dir
125
-
126
- # @return [Array<Agentilda::PullRequest>]
127
- def parse
128
- path = CANDIDATES.map { |f| File.join(dir, f) }.find { |p| File.file?(p) }
129
- return [] unless path
130
-
131
- text = File.read(path, encoding: "UTF-8")
132
- table = pick_table(Markdown.tables(text))
133
- return scrape(text) unless table
134
-
135
- rows = table[:rows].filter_map { |cells| row_to_pr(cells, columns(table[:header])) }
136
- rows.empty? ? scrape(text) : rows
137
- end
138
-
139
- # @param tables [Array<Hash>]
140
- # @return [Hash, nil]
141
- def pick_table(tables)
142
- tables.find { |t|
143
- t[:header].any? { |h| h.match?(/pull\s*request|\bpr\b|\A#\z|\Anumber\z/i) } &&
144
- t[:rows].any? { |r| r.any? { |c| c.match?(%r{/(?:pull|merge_requests)/\d+}) } }
145
- } || tables.find { |t| t[:rows].any? { |r| r.any? { |c| c.match?(%r{/pull/\d+}) } } }
146
- end
147
-
148
- # @param header [Array<String>]
149
- # @return [Hash{Symbol => Integer, nil}]
150
- def columns(header)
151
- {
152
- number: header.index { |h| h.match?(/number|\A#\z|\Apr\z|\Apr\s*#/i) },
153
- title: header.index { |h| h.match?(/name|title|summary|description|scope/i) },
154
- state: header.index { |h| h.match?(/status|state/i) }
155
- }
156
- end
157
-
158
- # @param cells [Array<String>]
159
- # @param idx [Hash{Symbol => Integer, nil}]
160
- # @return [Agentilda::PullRequest, nil]
161
- def row_to_pr(cells, idx)
162
- title_cell = cells[idx[:title] || 1].to_s
163
-
164
- # The title comes from the title column and the URL from wherever it is.
165
- # Scanning every cell for the first link and taking both from it reads
166
- # `| [#24](…) | Split verified into signed_off |` as an issue called
167
- # "#24", because the number column is a link too and comes first.
168
- titled = title_cell.match(LINK)
169
- link = titled || cells.filter_map { |c| c.match(LINK) }.first
170
-
171
- url = link && link[2]
172
- title = titled ? titled[1] : unformat(title_cell)
173
- title = title.gsub(/\\([\[\]|\\])/, '\1') # undo the escaping {.render} applies
174
- number = cells[idx[:number] || 0].to_s[/\d+/] || url&.[](%r{/(?:pull|merge_requests)/(\d+)}, 1)
175
- return nil if title.empty? && url.nil?
176
-
177
- PullRequest.new(number:, url:, title: title.empty? ? "Pull request" : title,
178
- state: normalize(cells[idx[:state] || -1].to_s))
179
- end
180
-
181
- # When there is no parsable table, scrape bare links so a folder that
182
- # plainly has pull requests does not report none.
183
- #
184
- # @param text [String]
185
- # @return [Array<Agentilda::PullRequest>]
186
- def scrape(text)
187
- text.scan(%r{https?://\S+?/(?:pull|merge_requests)/(\d+)}).flatten.uniq.map do |num|
188
- PullRequest.new(number: num, title: "Pull request ##{num}", state: "Unknown",
189
- url: text[%r{https?://\S+?/(?:pull|merge_requests)/#{num}\b}])
190
- end
191
- end
192
-
193
- # Strip the markdown a title was wrapped in without eating the title.
194
- #
195
- # An underscore between letters is not emphasis, it is an identifier —
196
- # `signed_off`, `as_of`, `pull_requests`. Removing every underscore turned
197
- # "Split verified into signed_off" into "signedoff" on the way to Linear.
198
- #
199
- # @param cell [String]
200
- # @return [String]
201
- def unformat(cell)
202
- cell.to_s.gsub(/[*`]/, "").sub(/\A_+/, "").sub(/_+\z/, "").strip
203
- end
204
-
205
- # @param cell [String]
206
- # @return [String]
207
- def normalize(cell)
208
- STATES.each { |pattern, label| return label if cell.match?(pattern) }
209
- stripped = cell.gsub(/[*_`]/, "").strip
210
- stripped.empty? ? "Unknown" : stripped
211
- end
212
- end
213
27
  end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "agentilda/markdown"
4
+ require "agentilda/pull_request"
5
+
6
+ module Agentilda
7
+ # Extracts the pull request roll-up from a plan folder.
8
+ class PullRequests
9
+ # A markdown link whose text may contain escaped brackets, because every
10
+ # title this tool writes now opens with `\[NNN.MM\]`. Matching `[^\]]+`
11
+ # stops at the escaped `]` and loses the URL with it.
12
+ LINK = /\[((?:\\.|[^\]\\])+)\]\((https?:[^)\s]+)\)/
13
+
14
+ # Canonical wording for the states we recognise, most specific first.
15
+ STATES = [
16
+ [/\bclosed\b|\bunmerged\b|\babandoned\b/i, "Closed 🔴"],
17
+ [/\bmerged\b/i, "Merged 🟣"],
18
+ [/\bdraft\b|\bwip\b/i, "WIP 🟡"],
19
+ [/\bopen\b/i, "Open 🟡"]
20
+ ].freeze
21
+
22
+ # Filenames that may hold the table.
23
+ CANDIDATES = %w[pull-requests.md pull_requests.md prs.md].freeze
24
+
25
+ # The file this class writes, and the first one it looks for.
26
+ FILENAME = CANDIDATES.first
27
+
28
+ # Render `pull-requests.md` for a set of pull requests fetched from GitHub.
29
+ #
30
+ # The table is what {#parse} reads back and what the state machine judges
31
+ # a folder by. The descriptions below it are for a reader — human or
32
+ # agent — synthesizing a specification from work that already shipped:
33
+ # the table says *which* pull requests, the bodies say *what they did*,
34
+ # and a retroactive `spec.md` cannot be written from numbers alone.
35
+ #
36
+ # Prose after the table is ignored by the parser, so the two can coexist
37
+ # in one file rather than needing a scratch file the folder rules forbid.
38
+ #
39
+ # @param prs [Array<Hash>] `{number:, title:, url:, state:, body:}`
40
+ # @return [String]
41
+ def self.render(prs)
42
+ rows = prs.map { |pr|
43
+ "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |"
44
+ }
45
+
46
+ <<~MARKDOWN
47
+ # Pull Requests
48
+
49
+ | Pull Request Number | Pull Request Name | Status |
50
+ | ------------------: | :---------------- | -----: |
51
+ #{rows.join("\n")}
52
+
53
+ ## What these pull requests did
54
+
55
+ #{prs.map { |pr| describe(pr) }.join("\n")}
56
+ MARKDOWN
57
+ end
58
+
59
+ # The table alone, for splicing into a file that already says other
60
+ # things: the ledger blocks luke and rey write before a pull request
61
+ # exists. Rewriting the whole file deleted their signatures.
62
+ #
63
+ # @param prs [Array<Hash>]
64
+ # @return [String] header, rule and rows, newline-terminated
65
+ def self.table(prs)
66
+ rows = prs.map { |pr| "| #{pr[:number]} | [#{escape(pr[:title])}](#{pr[:url]}) | #{pr[:state]} |" }
67
+ ["| Pull Request Number | Pull Request Name | Status |",
68
+ "| ------------------: | :---------------- | -----: |", *rows].join("\n") + "\n"
69
+ end
70
+
71
+ # Write the table into +path+ without touching anything else there.
72
+ #
73
+ # @param path [String]
74
+ # @param prs [Array<Hash>]
75
+ # @return [void]
76
+ def self.upsert(path, prs)
77
+ return File.write(path, render(prs)) unless File.file?(path)
78
+
79
+ lines = File.read(path, encoding: "UTF-8").lines
80
+ start = lines.index { |l| l.strip.start_with?("|") && lines[lines.index(l) + 1].to_s.strip.start_with?("|") }
81
+ if start
82
+ stop = start
83
+ stop += 1 while lines[stop + 1]&.strip&.start_with?("|")
84
+ lines[start..stop] = [table(prs)]
85
+ else
86
+ at = lines.index { |l| l.start_with?("# ") }
87
+ insertion = ["\n", table(prs)]
88
+ at ? lines.insert(at + 1, *insertion) : lines.unshift("# Pull Requests\n", *insertion, "\n")
89
+ end
90
+ File.write(path, lines.join)
91
+ end
92
+
93
+ # @param pr [Hash]
94
+ # @return [String]
95
+ def self.describe(pr)
96
+ body = pr[:body].to_s.strip
97
+ body = "_No description was written on the pull request._" if body.empty?
98
+
99
+ <<~MARKDOWN
100
+ ### ##{pr[:number]} — #{pr[:title]}
101
+
102
+ #{pr[:url]}
103
+
104
+ #{body}
105
+ MARKDOWN
106
+ end
107
+
108
+ # Three characters have to survive a round trip through a table cell.
109
+ #
110
+ # A `|` ends the cell, and a title containing one is not unusual —
111
+ # "fix: guard against a || b".
112
+ #
113
+ # `[` and `]` are newer and cost more. Every title this tool writes now
114
+ # opens with a plan number, so the row reads `[[013.00] Ship it](url)` —
115
+ # and a markdown link whose text starts with `[` does not parse. The
116
+ # title came back with the URL glued to it and `url` came back nil, which
117
+ # is not a cosmetic loss: it is every pull request link on the page, and
118
+ # the attachment on every Linear issue.
119
+ #
120
+ # @param text [String]
121
+ # @return [String]
122
+ def self.escape(text)
123
+ text.to_s.gsub(/([\[\]|])/) { "\\#{$1}" }.gsub(/\s+/, " ").strip
124
+ end
125
+
126
+ # @param dir [String] absolute path to the plan folder
127
+ def initialize(dir:)
128
+ @dir = dir
129
+ end
130
+
131
+ # @return [Array<Agentilda::PullRequest>] possibly empty
132
+ def all = @all ||= parse
133
+
134
+ private
135
+
136
+ # @return [String]
137
+ attr_reader :dir
138
+
139
+ # @return [Array<Agentilda::PullRequest>]
140
+ def parse
141
+ path = CANDIDATES.map { |f| File.join(dir, f) }.find { |p| File.file?(p) }
142
+ return [] unless path
143
+
144
+ text = File.read(path, encoding: "UTF-8")
145
+ table = pick_table(Markdown.tables(text))
146
+ return scrape(text) unless table
147
+
148
+ rows = table[:rows].filter_map { |cells| row_to_pr(cells, columns(table[:header])) }
149
+ rows.empty? ? scrape(text) : rows
150
+ end
151
+
152
+ # @param tables [Array<Hash>]
153
+ # @return [Hash, nil]
154
+ def pick_table(tables)
155
+ tables.find { |t|
156
+ t[:header].any? { |h| h.match?(/pull\s*request|\bpr\b|\A#\z|\Anumber\z/i) } &&
157
+ t[:rows].any? { |r| r.any? { |c| c.match?(%r{/(?:pull|merge_requests)/\d+}) } }
158
+ } || tables.find { |t| t[:rows].any? { |r| r.any? { |c| c.match?(%r{/pull/\d+}) } } }
159
+ end
160
+
161
+ # @param header [Array<String>]
162
+ # @return [Hash{Symbol => Integer, nil}]
163
+ def columns(header)
164
+ {
165
+ number: header.index { |h| h.match?(/number|\A#\z|\Apr\z|\Apr\s*#/i) },
166
+ title: header.index { |h| h.match?(/name|title|summary|description|scope/i) },
167
+ state: header.index { |h| h.match?(/status|state/i) }
168
+ }
169
+ end
170
+
171
+ # @param cells [Array<String>]
172
+ # @param idx [Hash{Symbol => Integer, nil}]
173
+ # @return [Agentilda::PullRequest, nil]
174
+ def row_to_pr(cells, idx)
175
+ title_cell = cells[idx[:title] || 1].to_s
176
+
177
+ # The title comes from the title column and the URL from wherever it is.
178
+ # Scanning every cell for the first link and taking both from it reads
179
+ # `| [#24](...) | Split verified into signed_off |` as an issue called
180
+ # "#24", because the number column is a link too and comes first.
181
+ titled = title_cell.match(LINK)
182
+ link = titled || cells.filter_map { |c| c.match(LINK) }.first
183
+
184
+ url = link && link[2]
185
+ title = titled ? titled[1] : unformat(title_cell)
186
+ title = title.gsub(/\\([\[\]|\\])/, '\1') # undo the escaping {.render} applies
187
+ number = cells[idx[:number] || 0].to_s[/\d+/] || url&.[](%r{/(?:pull|merge_requests)/(\d+)}, 1)
188
+ return nil if title.empty? && url.nil?
189
+
190
+ PullRequest.new(number:,
191
+ url:,
192
+ title: title.empty? ? "Pull request" : title,
193
+ state: normalize(cells[idx[:state] || -1].to_s))
194
+ end
195
+
196
+ # When there is no parsable table, scrape bare links so a folder that
197
+ # plainly has pull requests does not report none.
198
+ #
199
+ # @param text [String]
200
+ # @return [Array<Agentilda::PullRequest>]
201
+ def scrape(text)
202
+ text.scan(%r{https?://\S+?/(?:pull|merge_requests)/(\d+)}).flatten.uniq.map do |num|
203
+ PullRequest.new(number: num,
204
+ title: "Pull request ##{num}",
205
+ state: "Unknown",
206
+ url: text[%r{https?://\S+?/(?:pull|merge_requests)/#{num}\b}])
207
+ end
208
+ end
209
+
210
+ # Strip the markdown a title was wrapped in without eating the title.
211
+ #
212
+ # An underscore between letters is not emphasis, it is an identifier —
213
+ # `signed_off`, `as_of`, `pull_requests`. Removing every underscore turned
214
+ # "Split verified into signed_off" into "signedoff" on the way to Linear.
215
+ #
216
+ # @param cell [String]
217
+ # @return [String]
218
+ def unformat(cell)
219
+ cell.to_s.gsub(/[*`]/, "").sub(/\A_+/, "").sub(/_+\z/, "").strip
220
+ end
221
+
222
+ # @param cell [String]
223
+ # @return [String]
224
+ def normalize(cell)
225
+ STATES.each { |pattern, label| return label if cell.match?(pattern) }
226
+ stripped = cell.gsub(/[*_`]/, "").strip
227
+ stripped.empty? ? "Unknown" : stripped
228
+ end
229
+ end
230
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/status"
4
+
3
5
  module Agentilda
4
6
  # `/spec-status` — every plan, its state in icon and words, and its pull
5
7
  # requests as clickable links.
@@ -76,8 +78,12 @@ module Agentilda
76
78
  #
77
79
  # @return [Integer]
78
80
  def pr_column
79
- @pr_column ||= row(" ", " " * ORDINAL_WIDTH, " " * EMOJI_WIDTH,
80
- " " * label_width, " " * title_width, "").length
81
+ @pr_column ||= row(" ",
82
+ " " * ORDINAL_WIDTH,
83
+ " " * EMOJI_WIDTH,
84
+ " " * label_width,
85
+ " " * title_width,
86
+ "").length
81
87
  end
82
88
 
83
89
  # @return [Integer] cell at which the feature name starts
@@ -158,7 +164,7 @@ module Agentilda
158
164
  # @return [String]
159
165
  def footer
160
166
  counts = totals.map { |key, n| "#{STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
161
- lines = [" #{tree.subjects.size} #{(tree.subjects.size == 1) ? "plan" : "plans"} #{counts}"]
167
+ lines = [" #{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}"]
162
168
 
163
169
  unless inconsistent.empty?
164
170
  lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"