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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. metadata +349 -0
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # Lowercase in the middle of a title, capitalised at the front.
5
+ SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
6
+
7
+ # Slug words that are really acronyms and should shout.
8
+ ACRONYMS = %w[
9
+ abac ai api aws cdn ci cd cli cms cors cpu crm css csv db dns dsl e2e ec2 etl gcp gdpr gpu gui
10
+ html http https iam id ide jwt json k8s llm ml mcp mvp npm oauth orm otp pdf pii poc pr prs qa
11
+ rbac rds rest rls rpc rss s3 saas sdk seo sns spa sql sqs sre ssh sso ssl ssr tls tui ts tsx ui
12
+ ux uuid vpc vpn xml yaml
13
+ ].freeze
14
+
15
+ # Words with a house spelling that neither capitalize nor upcase gets right.
16
+ SPECIAL_CASE = {
17
+ "github" => "GitHub", "gitlab" => "GitLab", "graphql" => "GraphQL", "ios" => "iOS",
18
+ "javascript" => "JavaScript", "macos" => "macOS", "nodejs" => "Node.js", "oauth" => "OAuth",
19
+ "openai" => "OpenAI", "postgres" => "PostgreSQL", "postgresql" => "PostgreSQL",
20
+ "typescript" => "TypeScript", "uuidv7" => "UUIDv7", "websocket" => "WebSocket"
21
+ }.freeze
22
+
23
+ # Turn a kebab slug into a proper name: `law-as-data` → "Law as Data".
24
+ #
25
+ # @param slug [String]
26
+ # @return [String]
27
+ def self.titleize(slug)
28
+ words = slug.to_s.split(/[-_\s.]+/).reject(&:empty?)
29
+ return "" if words.empty?
30
+
31
+ words.each_with_index.map { |word, i|
32
+ lower = word.downcase
33
+ if SPECIAL_CASE.key?(lower)
34
+ SPECIAL_CASE[lower]
35
+ elsif ACRONYMS.include?(lower)
36
+ lower.upcase
37
+ elsif i.positive? && SMALL_WORDS.include?(lower)
38
+ lower
39
+ elsif lower.match?(/\A\d+\z/)
40
+ lower
41
+ else
42
+ lower.sub(/\A./, &:upcase)
43
+ end
44
+ }.join(" ")
45
+ end
46
+
47
+ # The one place a plan folder's name is spelled out: `NNN.MM-<emoji>--<slug>`.
48
+ #
49
+ # The double dash after the emoji is deliberate: an emoji renders two cells
50
+ # wide and visually swallows a single dash beside it, so `🔎-refactor` reads
51
+ # as if the emoji and the slug were touching. Everything that mints or
52
+ # renames a folder goes through here; {Feature.parse} accepts the older
53
+ # single-dash spelling too, and `resync dirs` normalises it on contact.
54
+ #
55
+ # @param ordinal [Agentilda::Ordinal, String]
56
+ # @param status [Agentilda::Status]
57
+ # @param slug [String]
58
+ # @return [String]
59
+ def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji}--#{slug}"
60
+
61
+ # One `NNN.MM-<emoji>--<slug>` folder, decoded.
62
+ #
63
+ # @!attribute [r] ordinal
64
+ # @return [Agentilda::Ordinal]
65
+ # @!attribute [r] status
66
+ # @return [Agentilda::Status]
67
+ # @!attribute [r] slug
68
+ # @return [String]
69
+ # @!attribute [r] dirname
70
+ # @return [String] exactly as it is on disk
71
+ # @!attribute [r] path
72
+ # @return [String] absolute
73
+ Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
74
+ include Comparable
75
+
76
+ # Decode a folder name, or return nil when it is not a plan folder.
77
+ #
78
+ # @param path [String] absolute path to a candidate directory
79
+ # @return [Agentilda::Feature, nil]
80
+ def self.parse(path)
81
+ dirname = File.basename(path)
82
+ ordinal = Ordinal.from_dirname(dirname) or return nil
83
+
84
+ rest = dirname.sub(/\A[\d.]+[-_]/, "")
85
+ head, tail = rest.split(/[-_]/, 2)
86
+
87
+ # A leading segment with no ASCII word character is the status emoji.
88
+ # The slug strips any further separators: the canonical spelling puts
89
+ # two dashes after the emoji, and folders from before that rule put one.
90
+ status = (Agentilda.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
91
+ slug = status ? tail.sub(/\A[-_]+/, "") : rest
92
+
93
+ new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
94
+ end
95
+
96
+ # @return [String] proper name, e.g. "Law as Data"
97
+ def title = Agentilda.titleize(slug)
98
+
99
+ # The folder name this feature would carry in a given state — the slug
100
+ # never moves, and the number is always rendered canonically, so this is
101
+ # also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
102
+ #
103
+ # @param status [Agentilda::Status]
104
+ # @return [String]
105
+ def dirname_as(status) = Agentilda.plan_dirname(ordinal, status, slug)
106
+
107
+ # The number exactly as the folder writes it, which is not always the
108
+ # canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
109
+ # "018.00".
110
+ #
111
+ # @return [String]
112
+ def dirname_ordinal = dirname.to_s[/\A[\d.]+/].to_s
113
+
114
+ # @return [Boolean] whether the number is written in full `NNN.MM` form
115
+ def padded? = dirname_ordinal == ordinal.to_s
116
+
117
+ # Whether the folder is already named the way this tool would name it —
118
+ # number padded, emoji matching the state it claims, slug unchanged.
119
+ #
120
+ # @return [Boolean]
121
+ def canonical? = dirname == dirname_as(status)
122
+
123
+ # @param other [Object]
124
+ # @return [Integer, nil]
125
+ def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
126
+ end
127
+
128
+ # A plan folder as the state machine sees it: which files exist, what they
129
+ # say, and which pull requests they record.
130
+ #
131
+ # It exists because the invariants ask the same questions repeatedly and
132
+ # {Feature} is a frozen `Data` with nowhere to memoize the answers.
133
+ class Subject
134
+ # @param feature [Agentilda::Feature]
135
+ def initialize(feature)
136
+ @feature = feature
137
+ @reads = {}
138
+ end
139
+
140
+ # @return [Agentilda::Feature]
141
+ attr_reader :feature
142
+
143
+ # @return [Agentilda::Status] the status the folder name claims
144
+ def status = feature.status
145
+
146
+ # @param name [String] a bare filename
147
+ # @return [Boolean]
148
+ def file?(name) = File.file?(File.join(feature.path, name))
149
+
150
+ # @param name [String]
151
+ # @return [String, nil] contents, or nil when absent
152
+ def read(name)
153
+ @reads.fetch(name) do
154
+ @reads[name] = file?(name) ? File.read(File.join(feature.path, name), encoding: "UTF-8") : nil
155
+ end
156
+ end
157
+
158
+ # @return [Array<Agentilda::PullRequest>]
159
+ def pull_requests = @pull_requests ||= PullRequests.new(dir: feature.path).all
160
+
161
+ # The specification's own Goal section, verbatim and at most two
162
+ # paragraphs. Anything that paraphrases the spec is a second copy that
163
+ # drifts; quoting it is not — which is why the pull request body and the
164
+ # index both read it from here rather than each writing their own.
165
+ #
166
+ # @return [Array<String>] paragraphs, empty when there is no Goal to read
167
+ def goal
168
+ body = read("spec.md").to_s
169
+ section = body[/^\#{"#"}{2,3}\s*Goals?\b[^\n]*\n+(.*?)(?=\n\#{"#"}{1,3}\s|\z)/mi, 1]
170
+
171
+ paragraphs(section) || paragraphs(body.sub(/\A\s*\#{"#"}[^\n]*\n/, "")) || []
172
+ end
173
+
174
+ # Specifications written before the template existed have no Goal section,
175
+ # and they are exactly the ones an index most needs to describe. So the
176
+ # opening prose stands in — skipping headings, quotes, lists and tables,
177
+ # which describe the document rather than the work.
178
+ #
179
+ # @param text [String, nil]
180
+ # @return [Array<String>, nil] nil when there is no prose to be had
181
+ def paragraphs(text)
182
+ found = text.to_s.strip.split(/\n{2,}/)
183
+ .map(&:strip)
184
+ .reject { |p| p.empty? || p.match?(/\A[\#>|\-*\d`_=]/) }
185
+ .first(2)
186
+
187
+ found.empty? ? nil : found
188
+ end
189
+
190
+ # The questions `blocked.md` still names, by number.
191
+ #
192
+ # Empty means one of two very different things, and a caller that treats
193
+ # them alike is how a folder with thirty kilobytes of open questions gets
194
+ # reported as "nothing left open": either there is no `blocked.md` at all,
195
+ # or there is one whose questions are not written as `## B<n>` and are
196
+ # therefore invisible to every part of this tool. Ask {#file?} which.
197
+ #
198
+ # @return [Array<Integer>]
199
+ def open_blocks = Agentilda.block_numbers(read("blocked.md"), OPEN_BLOCK)
200
+
201
+ # The answers waiting in `blocked.md`, by number. `## A1` settles `## B1`.
202
+ #
203
+ # Waiting, not folded. An `## A<n>` heading is not the same as a settled
204
+ # question: one may say in its own body that it is a draft pending a
205
+ # conversation. `lando-broker` makes that call; this only counts headings.
206
+ #
207
+ # @return [Array<Integer>]
208
+ def block_answers = Agentilda.block_numbers(read("blocked.md"), ANSWER_BLOCK)
209
+
210
+ # A `blocked.md` this tool cannot read: the file is there, and not one
211
+ # question in it is written as `## B<n>`. Nothing can drain it and nothing
212
+ # currently says so, which is the whole reason this exists.
213
+ #
214
+ # @return [Boolean]
215
+ def unreadable_block? = file?("blocked.md") && open_blocks.empty?
216
+
217
+ # @return [String, nil] why the folder's name is not justified
218
+ def violation = status.violation(self)
219
+
220
+ # @return [Boolean] whether the name matches the contents
221
+ def consistent? = violation.nil?
222
+
223
+ # @return [Agentilda::StateMachine] positioned at the current state
224
+ def machine = StateMachine.new(self)
225
+
226
+ # @return [Array<Symbol>] states reachable right now, guards applied
227
+ def allowed = machine.allowed
228
+
229
+ # @return [Agentilda::Status, nil] the state these contents justify
230
+ def best_fit = machine.best_fit
231
+
232
+ # Move the folder into +status+ — the side effect a transition *is*.
233
+ #
234
+ # The {Feature} is a frozen `Data` holding the old name, so it is replaced
235
+ # rather than mutated, and the memoized reads go with it.
236
+ #
237
+ # @param status [Agentilda::Status]
238
+ # @return [Agentilda::Feature] the feature under its new name
239
+ # @raise [Agentilda::Error] when the target name is already taken
240
+ def rename_to(status)
241
+ return @feature if status.key == @feature.status.key
242
+
243
+ target = File.join(File.dirname(@feature.path), @feature.dirname_as(status))
244
+ unless Agentilda.move_directory(@feature.path, target)
245
+ raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
246
+ end
247
+
248
+ @reads = {}
249
+ @pull_requests = nil
250
+ @feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+
6
+ module Agentilda
7
+ # Splits a markdown file into its YAML frontmatter and its body.
8
+ #
9
+ # The agent definitions and `create --from` both read frontmatter, and both
10
+ # used to call `YAML.safe_load` on their own. Its defaults refuse to build a
11
+ # `Date`, so an ordinary `date: 2026-08-31` raised Psych::DisallowedClass and
12
+ # `create` reported a missing `title:` on a file whose title was right there.
13
+ # One parser, one list of permitted classes, so that cannot happen twice.
14
+ module Frontmatter
15
+ # Frontmatter, then body.
16
+ PATTERN = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
17
+
18
+ # Dates and timestamps are ordinary frontmatter, so they load. Nothing else
19
+ # does: the point of `safe_load` is that a seed file cannot name a class.
20
+ PERMITTED_CLASSES = [Date, Time].freeze
21
+
22
+ class << self
23
+ # @param content [String] a whole markdown file
24
+ # @return [Array(Hash, String)] the frontmatter and the body. A file with
25
+ # no frontmatter is all body, and frontmatter that is not a mapping —
26
+ # a bare list, a lone string — reads as no keys rather than raising.
27
+ # @raise [Psych::Exception] when the frontmatter is not valid YAML
28
+ def split(content)
29
+ match = PATTERN.match(content) or return [{}, content]
30
+
31
+ meta = YAML.safe_load(match[1], permitted_classes: PERMITTED_CLASSES)
32
+ [meta.is_a?(Hash) ? meta : {}, match[2]]
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Agentilda
6
+ # The `gh` CLI, wrapped thinly.
7
+ #
8
+ # It is a seam rather than a convenience: every example in the suite injects
9
+ # a double here, so nothing in the tests reaches the network or a real
10
+ # repository.
11
+ class GitHub
12
+ # Fields asked of `gh pr list`.
13
+ FIELDS = %w[number title url headRefName files state isDraft mergedAt].freeze
14
+
15
+ # @param command [TTY::Command] runner, injectable for tests
16
+ # @param limit [Integer] how many pull requests to fetch
17
+ def initialize(command: TTY::Command.new(printer: :null), limit: 200)
18
+ @command = command
19
+ @limit = limit
20
+ end
21
+
22
+ # Every pull request, normalised into plain hashes.
23
+ #
24
+ # @param state [String] "open", "closed", "merged" or "all"
25
+ # @return [Array<Hash>] `{number:, title:, url:, branch:, files:}`
26
+ def pulls(state: "all")
27
+ out = UI.spinning("Fetching pull requests from GitHub") {
28
+ @command.run("gh", "pr", "list", "--state", state, "--limit", @limit.to_s,
29
+ "--json", FIELDS.join(",")).out
30
+ }
31
+
32
+ # `gh` can exit 0 having printed NOTHING — most often when it cannot reach
33
+ # the credential store, as in a non-interactive shell that has no keyring
34
+ # access, or when GH_TOKEN is set to something invalid and shadows a
35
+ # working login. Left alone this parses as a JSON error and reports as
36
+ # "bad output", sending you to look at the wrong thing entirely.
37
+ raise Error, no_output_message if out.to_s.strip.empty?
38
+
39
+ JSON.parse(out).map do |pr|
40
+ {
41
+ number: pr["number"],
42
+ title: pr["title"].to_s,
43
+ url: pr["url"],
44
+ branch: pr["headRefName"].to_s,
45
+ files: Array(pr["files"]).map { |f| f["path"] }.compact,
46
+ state: self.class.state_label(pr),
47
+ open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
48
+ }
49
+ end
50
+ rescue TTY::Command::ExitError, JSON::ParserError => e
51
+ raise Error, "could not list pull requests via `gh`: #{e.message.lines.first.to_s.strip}"
52
+ end
53
+
54
+ # @return [String] the diagnosis for a silent `gh`
55
+ def no_output_message
56
+ <<~MESSAGE.strip
57
+ `gh` produced no output and did not report an error.
58
+
59
+ That is almost always authentication rather than an empty repository:
60
+
61
+ - Check `gh auth status`. An invalid GH_TOKEN in the environment
62
+ shadows a working keyring login and fails without saying so.
63
+
64
+ - A non-interactive shell may have no access to the system keyring
65
+ even when an interactive one does.
66
+
67
+ Verify with: gh pr list --state all --limit 1
68
+ MESSAGE
69
+ end
70
+
71
+ # Fields asked of `gh pr view`, which unlike `pr list` can be told about
72
+ # one pull request in another repository.
73
+ VIEW_FIELDS = %w[number title url state isDraft mergedAt body].freeze
74
+
75
+ # A reference to one pull request: a bare number, a `#`-prefixed number,
76
+ # or a full URL to a GitHub pull request or a GitLab merge request.
77
+ REF = %r{\A(?:\#?\d+|https?://\S+?/(?:pull|merge_requests)/\d+/?)\z}
78
+
79
+ # Split and validate a `--prs` value before any of it reaches the network,
80
+ # so a typo fails in a hundredth of a second with the offending token named
81
+ # rather than after four round trips with a `gh` diagnostic.
82
+ #
83
+ # @param text [String] e.g. "12,15,18" or "https://…/pull/12, #15"
84
+ # @return [Array<String>] references, in the order given, de-duplicated
85
+ # @raise [Agentilda::Error] on anything that is not a reference
86
+ def self.parse_refs(text)
87
+ refs = text.to_s.split(",").map(&:strip).reject(&:empty?)
88
+ raise Error, "no pull requests given" if refs.empty?
89
+
90
+ bad = refs.reject { |r| r.match?(REF) }
91
+ unless bad.empty?
92
+ raise Error, "not a pull request number or URL: #{bad.join(", ")}"
93
+ end
94
+
95
+ refs.uniq
96
+ end
97
+
98
+ # One pull request, by number or URL.
99
+ #
100
+ # @param ref [String] "12", "#12" or "https://github.com/o/r/pull/12"
101
+ # @return [Hash] `{number:, title:, url:, state:, body:}`
102
+ # @raise [Agentilda::Error]
103
+ def pull_request(ref)
104
+ out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
105
+ raise Error, no_output_message if out.to_s.strip.empty?
106
+
107
+ pr = JSON.parse(out)
108
+ {
109
+ number: pr["number"],
110
+ title: pr["title"].to_s,
111
+ url: pr["url"].to_s,
112
+ state: self.class.state_label(pr),
113
+ body: pr["body"].to_s
114
+ }
115
+ rescue TTY::Command::ExitError, JSON::ParserError => e
116
+ raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
117
+ end
118
+
119
+ # Several pull requests, in the order asked for.
120
+ #
121
+ # @param refs [Array<String>]
122
+ # @return [Array<Hash>]
123
+ def pull_requests(refs)
124
+ UI.stepping(refs, "Fetching pull requests") { |ref| ref }
125
+ refs.map { |ref| pull_request(ref) }
126
+ end
127
+
128
+ # `gh` speaks in enums; `pull-requests.md` speaks in the words the state
129
+ # machine parses. Translate once, here, rather than at each call site.
130
+ #
131
+ # @param pr [Hash] a decoded `gh pr view` payload
132
+ # @return [String] one of the labels in {PullRequests::STATES}
133
+ def self.state_label(pr)
134
+ return "Merged 🟣" if pr["mergedAt"]
135
+ return "WIP 🟡" if pr["isDraft"]
136
+
137
+ case pr["state"].to_s.upcase
138
+ when "OPEN" then "Open 🟡"
139
+ when "CLOSED" then "Closed 🔴"
140
+ else "Unknown"
141
+ end
142
+ end
143
+
144
+ # Change a pull request's title.
145
+ #
146
+ # @param number [Integer]
147
+ # @param title [String]
148
+ # @return [void]
149
+ def retitle(number:, title:)
150
+ @command.run("gh", "pr", "edit", number.to_s, "--title", title)
151
+ rescue TTY::Command::ExitError => e
152
+ raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
153
+ end
154
+
155
+ # @return [Boolean] whether `gh` is installed and authenticated
156
+ def available?
157
+ @command.run!("gh", "auth", "status").success?
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+
6
+ module Agentilda
7
+ # `agentilda index` — the project's plans as one browsable page.
8
+ #
9
+ # {Reporter} answers "what is the state of things" in a terminal, in columns,
10
+ # for someone standing at a prompt. This answers "what is this project, and
11
+ # where is everything" for someone arriving at the repository on the web: the
12
+ # goal of each plan in its own words, every pull request as a link, and every
13
+ # document in the folder one click away.
14
+ #
15
+ # It is generated for the same reason the conventions document is. A
16
+ # hand-written index of twenty plans cannot survive a rename, and renaming is
17
+ # exactly what `resync dirs` exists to do — the first hand-made INDEX.md this
18
+ # replaced had all 82 of its links pointing at folder names that had been
19
+ # padded to `NNN.MM` underneath it.
20
+ class Index
21
+ # The file this writes, relative to the `.plans` directory.
22
+ FILENAME = "INDEX.md"
23
+
24
+ # Documents that get a proper name rather than their filename.
25
+ ARTIFACT_NAMES = {
26
+ "spec.md" => "Spec",
27
+ "plan.md" => "Plan",
28
+ "pull-requests.md" => "Pull Requests",
29
+ "linear.md" => "Linear",
30
+ "blocked.md" => "Blocked",
31
+ "delayed.md" => "Deferred",
32
+ "rewrite.md" => "Rewrite",
33
+ "deployed.md" => "Deployed",
34
+ "rollback.md" => "Rollback",
35
+ "discarded.md" => "Discarded"
36
+ }.freeze
37
+
38
+ # @param tree [Agentilda::Tree]
39
+ # @param project [String, nil] the heading; defaults to the repository name
40
+ def initialize(tree:, project: nil)
41
+ @tree = tree
42
+ @project = project
43
+ end
44
+
45
+ # @return [Agentilda::Tree]
46
+ attr_reader :tree
47
+
48
+ # @return [String] the project's name, titleized from its directory
49
+ def project = @project ||= Agentilda.titleize(File.basename(File.dirname(tree.dir)))
50
+
51
+ # @return [String] the whole document
52
+ def render
53
+ [heading, *tree.subjects.map { |subject| section(subject) }].join("\n") + footer
54
+ end
55
+
56
+ # Write it next to the plans it describes.
57
+ #
58
+ # @param path [String, nil] override the destination
59
+ # @return [String] where it was written
60
+ def write(path = nil)
61
+ path ||= File.join(tree.dir, FILENAME)
62
+ File.write(path, render)
63
+ path
64
+ end
65
+
66
+ private
67
+
68
+ # @return [String]
69
+ def heading
70
+ <<~MARKDOWN
71
+ # Project #{project}
72
+
73
+ > [!IMPORTANT]
74
+ > **This file is auto generated.** To regenerate it, run `agentilda index`.
75
+ > Editing it by hand lasts until the next `resync dirs` renames a folder.
76
+
77
+ ## Current specifications and their status
78
+
79
+ MARKDOWN
80
+ end
81
+
82
+ # One plan: a heading that carries its number and state, then a table.
83
+ #
84
+ # Raw HTML rather than a markdown table because the cells are not one-liners
85
+ # — a goal is a paragraph or two and a plan may have a dozen pull requests,
86
+ # neither of which a pipe-delimited row can hold.
87
+ #
88
+ # @param subject [Agentilda::Subject]
89
+ # @return [String]
90
+ def section(subject)
91
+ feature = subject.feature
92
+
93
+ <<~MARKDOWN
94
+ ## #{feature.ordinal} — #{feature.status.emoji} #{escape(feature.title)}
95
+
96
+ <table>
97
+ <thead>
98
+ <tr>
99
+ <th align="left">Status</th>
100
+ <th align="left">Pull Requests</th>
101
+ <th align="left">What it is</th>
102
+ <th align="left">Artifacts</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody>
106
+ <tr>
107
+ <td valign="top">#{status_cell(subject)}</td>
108
+ <td valign="top">#{pulls_cell(subject)}</td>
109
+ <td valign="top">#{goal_cell(subject)}</td>
110
+ <td valign="top">#{artifacts_cell(feature)}</td>
111
+ </tr>
112
+ </tbody>
113
+ </table>
114
+
115
+ MARKDOWN
116
+ end
117
+
118
+ # The emoji and the words, kept on one line — a status that wraps mid-label
119
+ # makes the column look like two states rather than one.
120
+ #
121
+ # @param subject [Agentilda::Subject]
122
+ # @return [String]
123
+ def status_cell(subject)
124
+ cell = "#{subject.status.emoji}&nbsp;#{escape(subject.status.label)}"
125
+ return cell if subject.consistent?
126
+
127
+ "#{cell}<br><sub>⚠️ #{escape(subject.violation)}</sub>"
128
+ end
129
+
130
+ # @param subject [Agentilda::Subject]
131
+ # @return [String]
132
+ def pulls_cell(subject)
133
+ prs = subject.pull_requests
134
+ return "<em>—</em>" if prs.empty?
135
+
136
+ items = prs.map { |pr|
137
+ "<li>#{link(pr.url, pr.label)} #{escape(pr.state)}</li>"
138
+ }
139
+ "<ul>#{items.join}</ul>"
140
+ end
141
+
142
+ # @param subject [Agentilda::Subject]
143
+ # @return [String]
144
+ def goal_cell(subject)
145
+ paragraphs = subject.goal
146
+ return "<em>No specification yet.</em>" if paragraphs.empty?
147
+
148
+ paragraphs.map { |p| "<p>#{inline(p)}</p>" }.join
149
+ end
150
+
151
+ # Markdown inside a raw HTML block is not parsed — GitHub stops parsing
152
+ # markdown the moment it sees a block-level tag — so a goal quoted verbatim
153
+ # would show its own `**` and backticks. Only the three that carry meaning
154
+ # are translated; single-underscore emphasis is deliberately left alone,
155
+ # because `deleted_at` is more common in these documents than italics.
156
+ #
157
+ # @param text [String] markdown, unescaped
158
+ # @return [String] HTML
159
+ def inline(text)
160
+ escape(text)
161
+ .gsub(/\[([^\]]+)\]\((https?:[^)\s]+)\)/) { %(<a href="#{$2}">#{$1}</a>) }
162
+ .gsub(/\*\*([^*]+)\*\*/) { "<strong>#{$1}</strong>" }
163
+ .gsub(/`([^`]+)`/) { "<code>#{$1}</code>" }
164
+ .tr("\n", " ")
165
+ end
166
+
167
+ # Every document in the folder, linked. The folder name is percent-encoded
168
+ # because it contains an emoji, and a raw one in an href is a broken link
169
+ # on GitHub.
170
+ #
171
+ # @param feature [Agentilda::Feature]
172
+ # @return [String]
173
+ def artifacts_cell(feature)
174
+ files = Dir.children(feature.path).select { |f| File.file?(File.join(feature.path, f)) }
175
+ return "<em>empty</em>" if files.empty?
176
+
177
+ # The documents that carry the lifecycle come first, in lifecycle order;
178
+ # whatever else the folder holds follows alphabetically.
179
+ known = ARTIFACT_NAMES.keys
180
+ files = files.sort_by { |f| [known.index(f) || known.size, f] }
181
+
182
+ items = files.map { |file|
183
+ href = "#{ERB::Util.url_encode(feature.dirname)}/#{ERB::Util.url_encode(file)}"
184
+ "<li>#{link(href, ARTIFACT_NAMES.fetch(file, file))}</li>"
185
+ }
186
+ "<ul>#{items.join}</ul>"
187
+ end
188
+
189
+ # @return [String]
190
+ def footer
191
+ counts = tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status] += 1 }
192
+ .map { |status, n| "#{status.emoji} #{n}" }.join(" &nbsp; ")
193
+
194
+ "\n---\n\n#{tree.subjects.size} #{(tree.subjects.size == 1) ? "plan" : "plans"} &nbsp; #{counts}\n"
195
+ end
196
+
197
+ # @param href [String]
198
+ # @param text [String]
199
+ # @return [String]
200
+ def link(href, text) = %(<a href="#{escape(href)}">#{escape(text)}</a>)
201
+
202
+ # @param text [String]
203
+ # @return [String]
204
+ def escape(text) = CGI.escapeHTML(text.to_s)
205
+ end
206
+ end