agentilda 1.0.3 → 2.0.1

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 +10 -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 +92 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +46 -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 +65 -13
  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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Agentilda
6
+ # The dated notes an agent leaves in the document it owns.
7
+ #
8
+ # This is the only place in the codebase that knows the syntax. The
9
+ # dispatcher reads it to learn whether an agent finished and who it named
10
+ # next; the state file records it; the screen paints it. An agent writes
11
+ # one entry when it starts and one when it stops, in a GitHub NOTE alert,
12
+ # one entry per line:
13
+ #
14
+ # > [!NOTE]
15
+ # >
16
+ # > [2026-09-04 11:29:20 AM PDT] [ agent: leah-researcher status: Started, round 1 ]
17
+ # > [2026-09-04 11:44:03 AM PDT] [ agent: leah-researcher status: Completed, round 1 ]
18
+ # > [2026-09-04 11:44:04 AM PDT] [ next: yoda-writer ]
19
+ #
20
+ # The verbs are a closed set. A line that looks like an entry but does not
21
+ # parse is reported as a {Problem}, never dropped, because an agent that
22
+ # wrote "Done" has said nothing the harness can act on and silence would
23
+ # read as a stall.
24
+ module Ledger
25
+ # Everything an agent may claim. `Completed` is the only verb a `next:`
26
+ # line may follow.
27
+ STATUSES = ["Started", "Completed", "Almost completed", "Interrupted", "Blocked"].freeze
28
+
29
+ # Local time with the zone spelled out, so an entry written on one
30
+ # machine reads correctly on another.
31
+ TIME_FORMAT = "%Y-%m-%d %I:%M:%S %p %Z"
32
+
33
+ # An entry. `**` around the status is how the harness marks the one line
34
+ # it writes itself, the Interrupted that precedes a signature.
35
+ ENTRY = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*agent:\s*(?<agent>[\w-]+)\s+status:\s*(?<bold>\*\*)?
36
+ (?<status>#{STATUSES.map { |s| Regexp.escape(s) }.join("|")}),\s*round\s*(?<round>\d+)
37
+ (?:\s*\((?<note>[^)]*)\))?\s*(?:\*\*)?\s*\]\s*\z/x
38
+
39
+ # The handoff line.
40
+ NEXT = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*next:\s*(?<next>[\w-]+)\s*\]\s*\z/
41
+
42
+ # A line that is trying to be one of the two above. Anything matching
43
+ # this and neither of those is a {Problem}.
44
+ ATTEMPT = /\A>\s*\[[^\]]+\]\s*\[/
45
+
46
+ # The whole alert block, for {.stripped}.
47
+ BLOCK = /^[ \t]*>[ \t]*\[!NOTE\][ \t]*\n(?:[ \t]*>.*\n?)*/
48
+
49
+ # @!attribute [r] at
50
+ # @return [Time]
51
+ # @!attribute [r] agent
52
+ # @return [String] the definition's name, `leah-researcher`
53
+ # @!attribute [r] status
54
+ # @return [String] one of {STATUSES}
55
+ # @!attribute [r] round
56
+ # @return [Integer]
57
+ # @!attribute [r] note
58
+ # @return [String, nil] the parenthesised tail, `rejected 1/2`
59
+ # @!attribute [r] file
60
+ # @return [String] the document it was read from
61
+ # @!attribute [r] line
62
+ # @return [Integer] 1-based
63
+ # @!attribute [r] bold
64
+ # @return [Boolean] written `**bold**`, which only the harness does
65
+ Entry = Data.define(:at, :agent, :status, :round, :note, :file, :line, :bold) do
66
+ def initialize(note: nil, bold: false, **rest) = super
67
+
68
+ # @return [Boolean]
69
+ def completed? = status == "Completed"
70
+
71
+ # @return [Boolean] whether the agent gets another round
72
+ def retry? = ["Almost completed", "Interrupted"].include?(status)
73
+
74
+ # @return [Boolean]
75
+ def blocked? = status == "Blocked"
76
+ end
77
+
78
+ # @!attribute [r] next
79
+ # @return [String] the agent named
80
+ Handoff = Data.define(:at, :next, :file, :line)
81
+
82
+ # @!attribute [r] text
83
+ # @return [String] the line as written
84
+ Problem = Data.define(:file, :line, :text)
85
+
86
+ # What one read of a plan folder yielded.
87
+ Reading = Data.define(:entries, :handoffs, :problems) do
88
+ # @return [Agentilda::Ledger::Reading]
89
+ def self.empty = new(entries: [], handoffs: [], problems: [])
90
+
91
+ # @param other [Agentilda::Ledger::Reading]
92
+ # @return [Agentilda::Ledger::Reading]
93
+ def +(other)
94
+ self.class.new(entries: entries + other.entries,
95
+ handoffs: handoffs + other.handoffs,
96
+ problems: problems + other.problems)
97
+ end
98
+ end
99
+
100
+ class << self
101
+ # @param text [String] a whole document
102
+ # @param file [String] its name, carried on every entry
103
+ # @return [Agentilda::Ledger::Reading]
104
+ def parse(text, file:)
105
+ entries = []
106
+ handoffs = []
107
+ problems = []
108
+ text.to_s.each_line.with_index(1) do |raw, line|
109
+ stripped = raw.chomp
110
+ if (m = ENTRY.match(stripped))
111
+ entries << Entry.new(at: parse_time(m[:at]),
112
+ agent: m[:agent],
113
+ status: m[:status],
114
+ round: m[:round].to_i,
115
+ note: m[:note]&.strip,
116
+ file:,
117
+ line:,
118
+ bold: !m[:bold].nil?)
119
+ elsif (m = NEXT.match(stripped))
120
+ handoffs << Handoff.new(at: parse_time(m[:at]), next: m[:next], file:, line:)
121
+ elsif ATTEMPT.match?(stripped)
122
+ problems << Problem.new(file:, line:, text: stripped.strip)
123
+ end
124
+ end
125
+ Reading.new(entries:, handoffs:, problems:)
126
+ end
127
+
128
+ # Every document an agent may sign, in the order its definition lists
129
+ # them. A missing file is not a problem; an agent that has not reached
130
+ # `pull-requests.md` yet has not written it.
131
+ #
132
+ # @param dir [String] the plan folder
133
+ # @param files [Array<String>]
134
+ # @return [Agentilda::Ledger::Reading]
135
+ def read(dir, files)
136
+ files.each_with_index.reduce(Reading.empty) do |reading, (name, _index)|
137
+ path = File.join(dir, name)
138
+ next reading unless File.file?(path)
139
+
140
+ found = parse(File.read(path, encoding: "UTF-8"), file: name)
141
+ reading + Reading.new(entries: found.entries.map { |e| e.with(file: name) },
142
+ handoffs: found.handoffs,
143
+ problems: found.problems)
144
+ end
145
+ end
146
+
147
+ # @param time [Time]
148
+ # @return [String]
149
+ def timestamp(time = Time.now) = time.strftime(TIME_FORMAT)
150
+
151
+ # Agents write what `date` prints, and `Time.strptime` cannot read a
152
+ # zone abbreviation back into an offset. The abbreviation is dropped
153
+ # and the time read as local, which is right on the machine that wrote
154
+ # it and off by a zone anywhere else, which the tick tolerates: entries
155
+ # are compared with each other, never with the wall clock.
156
+ #
157
+ # @param text [String]
158
+ # @return [Time]
159
+ def parse_time(text)
160
+ Time.strptime(text.to_s.strip.sub(/\s+[A-Z]{2,5}\z/, ""), "%Y-%m-%d %I:%M:%S %p")
161
+ rescue ArgumentError
162
+ Time.at(0)
163
+ end
164
+
165
+ # @param entry [Agentilda::Ledger::Entry]
166
+ # @return [String] one quoted line, no newline
167
+ def render(entry)
168
+ tail = entry.note ? " (#{entry.note})" : ""
169
+ body = "#{entry.status}, round #{entry.round}#{tail}"
170
+ body = "**#{body}**" if entry.bold
171
+ "> [#{timestamp(entry.at)}] [ agent: #{entry.agent} status: #{body} ]"
172
+ end
173
+
174
+ # @param handoff [Agentilda::Ledger::Handoff]
175
+ # @return [String]
176
+ def render_handoff(handoff) = "> [#{timestamp(handoff.at)}] [ next: #{handoff.next} ]"
177
+
178
+ # @param lines [Array<String>] already quoted
179
+ # @return [String] a NOTE alert, newline-terminated
180
+ def block(*lines) = (["> [!NOTE]", ">"] + lines.flatten).join("\n") + "\n"
181
+
182
+ # Append one block. Separated from what is there by a blank line so the
183
+ # alert renders as its own block rather than merging into a paragraph.
184
+ #
185
+ # @param path [String]
186
+ # @param lines [Array<String>]
187
+ # @return [void]
188
+ def append(path, *lines)
189
+ existing = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
190
+ glue = if existing.empty?
191
+ ""
192
+ elsif existing.end_with?("\n\n")
193
+ ""
194
+ elsif existing.end_with?("\n")
195
+ "\n"
196
+ else
197
+ "\n\n"
198
+ end
199
+ File.write(path, existing + glue + block(*lines))
200
+ end
201
+
202
+ # The entry that tells the harness where this agent stands: the latest
203
+ # by clock, then by the order its documents are listed, then by line.
204
+ #
205
+ # @param reading [Agentilda::Ledger::Reading]
206
+ # @param agent [String]
207
+ # @return [Agentilda::Ledger::Entry, nil]
208
+ def last_for(reading, agent)
209
+ files = reading.entries.map(&:file).uniq
210
+ reading.entries.select { |e| e.agent == agent }
211
+ .max_by { |e| [e.at, files.index(e.file), e.line] }
212
+ end
213
+
214
+ # The `next:` that belongs to an entry: same file, a later line, and no
215
+ # other entry between the two. A handoff written under somebody else's
216
+ # entry is not this agent's handoff.
217
+ #
218
+ # @param reading [Agentilda::Ledger::Reading]
219
+ # @param entry [Agentilda::Ledger::Entry]
220
+ # @return [Agentilda::Ledger::Handoff, nil]
221
+ def handoff_after(reading, entry)
222
+ candidates = reading.handoffs.select { |h| h.file == entry.file && h.line > entry.line }
223
+ handoff = candidates.min_by(&:line) or return nil
224
+ between = reading.entries.any? { |e| e.file == entry.file && e.line > entry.line && e.line < handoff.line }
225
+ between ? nil : handoff
226
+ end
227
+
228
+ # @param text [String]
229
+ # @return [String] the document without its ledger blocks
230
+ def stripped(text) = text.to_s.gsub(BLOCK, "")
231
+ end
232
+ end
233
+ end
@@ -32,8 +32,9 @@ module Agentilda
32
32
  @endpoint = endpoint
33
33
  return if transport || (token && !token.empty?)
34
34
 
35
- raise Error, "no Linear token. Set #{TOKEN_VARIABLE}, or use the MCP transport:\n " \
36
- "agentilda linear import <TEAM> -p <PROJECT> --format json"
35
+ raise Error,
36
+ "no Linear token. Set #{TOKEN_VARIABLE}, or use the MCP transport:\n " \
37
+ "agentilda linear import <TEAM> -p <PROJECT> --format json"
37
38
  end
38
39
 
39
40
  # The team, its workflow states and its labels, in one round trip.
@@ -45,8 +46,8 @@ module Agentilda
45
46
  node = query(TEAM, key: key.to_s.upcase).dig("teams", "nodes")&.first
46
47
  raise Error, "no Linear team has the key #{key.to_s.upcase}" unless node
47
48
 
48
- {id: node["id"], name: node["name"], key: node["key"],
49
- states: node.dig("states", "nodes").to_a, labels: node.dig("labels", "nodes").to_a}
49
+ { id: node["id"], name: node["name"], key: node["key"],
50
+ states: node.dig("states", "nodes").to_a, labels: node.dig("labels", "nodes").to_a }
50
51
  end
51
52
 
52
53
  # @param team_id [String]
@@ -75,7 +76,7 @@ module Agentilda
75
76
  # @param team_id [String]
76
77
  # @return [Hash] `{id:, name:}`
77
78
  def create_label(name, team_id)
78
- unwrap(query(LABEL_CREATE, input: {name:, teamId: team_id}), "issueLabelCreate", "issueLabel")
79
+ unwrap(query(LABEL_CREATE, input: { name:, teamId: team_id }), "issueLabelCreate", "issueLabel")
79
80
  end
80
81
 
81
82
  # @param issue_id [String]
@@ -83,7 +84,7 @@ module Agentilda
83
84
  # @param title [String]
84
85
  # @return [void]
85
86
  def link(issue_id:, url:, title:)
86
- query(ATTACHMENT_CREATE, input: {issueId: issue_id, url:, title:})
87
+ query(ATTACHMENT_CREATE, input: { issueId: issue_id, url:, title: })
87
88
  end
88
89
 
89
90
  # @param query [String] a GraphQL document
@@ -139,8 +140,11 @@ module Agentilda
139
140
  request["Authorization"] = token
140
141
  request.body = JSON.generate(query: document, variables:)
141
142
 
142
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
143
- open_timeout: 10, read_timeout: 30) { |http| http.request(request) }
143
+ response = Net::HTTP.start(uri.hostname,
144
+ uri.port,
145
+ use_ssl: uri.scheme == "https",
146
+ open_timeout: 10,
147
+ read_timeout: 30) { |http| http.request(request) }
144
148
 
145
149
  parse(response)
146
150
  rescue JSON::ParserError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
@@ -153,8 +157,8 @@ module Agentilda
153
157
  return JSON.parse(response.body.to_s) if response.is_a?(Net::HTTPSuccess)
154
158
 
155
159
  hint = if ["400", "401"].include?(response.code)
156
- "\n\nCheck #{TOKEN_VARIABLE}. A personal API key is sent verbatim, without a `Bearer` prefix."
157
- end
160
+ "\n\nCheck #{TOKEN_VARIABLE}. A personal API key is sent verbatim, without a `Bearer` prefix."
161
+ end
158
162
  raise Error, "Linear returned HTTP #{response.code}#{hint}"
159
163
  end
160
164
 
@@ -75,7 +75,7 @@ module Agentilda
75
75
  MINIMUM_WORDS = 3
76
76
 
77
77
  NOISE = %w[the and for with from into that this add adds added fix fixes
78
- update updates use uses spec plan pull request pr tax app web api].freeze
78
+ update updates use uses spec plan pull request pr tax app web api].freeze
79
79
 
80
80
  # @param tree [Agentilda::Tree]
81
81
  def initialize(tree:)
@@ -113,9 +113,9 @@ module Agentilda
113
113
  # @return [Array<String>]
114
114
  def opening_words(body)
115
115
  text = body.to_s
116
- .gsub(/<!--.*?-->/m, "")
117
- .gsub(/```.*?```/m, "")
118
- .gsub(/^\s*[-*|#>]+/, " ")
116
+ .gsub(/<!--.*?-->/m, "")
117
+ .gsub(/```.*?```/m, "")
118
+ .gsub(/^\s*[-*|#>]+/, " ")
119
119
  words(text.split(/\s+/).first(OPENING_WORDS * 3).join(" ")).first(OPENING_WORDS)
120
120
  end
121
121
 
@@ -161,7 +161,7 @@ module Agentilda
161
161
  # @return [Array<String>]
162
162
  def words(text)
163
163
  text.to_s.downcase.gsub(/[^a-z0-9]+/, " ").split
164
- .reject { |w| w.length < 4 || NOISE.include?(w) }.uniq
164
+ .reject { |w| w.length < 4 || NOISE.include?(w) }.uniq
165
165
  end
166
166
 
167
167
  # How much of a folder's name a pull request title actually said.
@@ -41,7 +41,7 @@ module Agentilda
41
41
 
42
42
  # @return [Hash] for `--format json`
43
43
  def to_h_json
44
- {kind:, op:, plan: ordinal, unit:, identifier:, title:, digest:, args:}.compact
44
+ { kind:, op:, plan: ordinal, unit:, identifier:, title:, digest:, args: }.compact
45
45
  end
46
46
  end
47
47
 
@@ -103,7 +103,7 @@ module Agentilda
103
103
  # @return [Hash{Agentilda::Status => Array<String>}] state => ordinals
104
104
  def unplaced
105
105
  @unplaced ||= (chosen - subjects).group_by(&:status)
106
- .transform_values { |group| group.map { |s| s.feature.ordinal.to_s } }
106
+ .transform_values { |group| group.map { |s| s.feature.ordinal.to_s } }
107
107
  end
108
108
 
109
109
  private
@@ -152,8 +152,10 @@ module Agentilda
152
152
  # @param pull [Agentilda::PullRequest]
153
153
  # @return [Agentilda::Linear::Unit]
154
154
  def adopted_unit(pull)
155
- Unit.new(key: "##{pull.number}", title: Units.clean_title(pull.title),
156
- body: "", pull_requests: [pull])
155
+ Unit.new(key: "##{pull.number}",
156
+ title: Units.clean_title(pull.title),
157
+ body: "",
158
+ pull_requests: [pull])
157
159
  end
158
160
 
159
161
  # @param subject [Agentilda::Subject]
@@ -175,15 +177,22 @@ module Agentilda
175
177
  recorded = record_for(subject).by_unit[Issues::PARENT]
176
178
  title = plan_title(subject)
177
179
 
178
- args = {team:, project: project_name, title:, description: plan_description(subject),
179
- state: placement.name, labels: placement.labels}
180
+ args = { team:, project: project_name, title:, description: plan_description(subject),
181
+ state: placement.name, labels: placement.labels }
180
182
  digest = Issues.digest(args)
181
183
 
182
184
  op, reason = decide(recorded&.identifier, recorded&.digest, digest)
183
185
  args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
184
186
 
185
- Action.new(kind: :issue, op:, ordinal: subject.feature.ordinal.to_s, unit: Issues::PARENT,
186
- identifier: recorded&.identifier, title:, digest:, args:, reason:)
187
+ Action.new(kind: :issue,
188
+ op:,
189
+ ordinal: subject.feature.ordinal.to_s,
190
+ unit: Issues::PARENT,
191
+ identifier: recorded&.identifier,
192
+ title:,
193
+ digest:,
194
+ args:,
195
+ reason:)
187
196
  end
188
197
 
189
198
  # @param subject [Agentilda::Subject]
@@ -194,17 +203,24 @@ module Agentilda
194
203
  recorded = record_for(subject).by_unit[unit.key]
195
204
  parent = record_for(subject).by_unit[Issues::PARENT]
196
205
 
197
- args = {team:, project: project_name, title: unit.title,
206
+ args = { team:, project: project_name, title: unit.title,
198
207
  description: unit_description(subject, unit),
199
- state: placement.name, labels: placement.labels, links: links_for(unit)}
208
+ state: placement.name, labels: placement.labels, links: links_for(unit) }
200
209
  args = args.merge(parentId: parent.identifier) if parent
201
210
  digest = Issues.digest(args.except(:parentId))
202
211
 
203
212
  op, reason = decide(recorded&.identifier, recorded&.digest, digest)
204
213
  args = args.merge(id: recorded.identifier).except(:team, :project) if op == :update && recorded
205
214
 
206
- Action.new(kind: :subissue, op:, ordinal: subject.feature.ordinal.to_s, unit: unit.key,
207
- identifier: recorded&.identifier, title: unit.title, digest:, args:, reason:)
215
+ Action.new(kind: :subissue,
216
+ op:,
217
+ ordinal: subject.feature.ordinal.to_s,
218
+ unit: unit.key,
219
+ identifier: recorded&.identifier,
220
+ title: unit.title,
221
+ digest:,
222
+ args:,
223
+ reason:)
208
224
  end
209
225
 
210
226
  # The three-way decision, in one place so a plan and a unit cannot
@@ -271,7 +287,7 @@ module Agentilda
271
287
  # @param unit [Agentilda::Linear::Unit]
272
288
  # @return [Array<Hash>]
273
289
  def links_for(unit)
274
- unit.pull_requests.select(&:url).map { |pr| {url: pr.url, title: pr.label} }
290
+ unit.pull_requests.select(&:url).map { |pr| { url: pr.url, title: pr.label } }
275
291
  end
276
292
 
277
293
  # A plan section can run to several hundred lines of design notes and
@@ -291,7 +307,7 @@ module Agentilda
291
307
  # @return [String]
292
308
  def provenance
293
309
  "_Imported by `agentilda linear import`. The plan folder is the source of truth; " \
294
- "edits made here do not travel back._"
310
+ "edits made here do not travel back._"
295
311
  end
296
312
  end
297
313
  end
@@ -164,9 +164,12 @@ module Agentilda
164
164
  identifier = (link ? link[1] : cell["issue"]).gsub(/[*_`]/, "").strip
165
165
  return nil if identifier.empty?
166
166
 
167
- Issue.new(unit: cell["unit"].strip, identifier:, url: link && link[2],
167
+ Issue.new(unit: cell["unit"].strip,
168
+ identifier:,
169
+ url: link && link[2],
168
170
  title: cell["title"].gsub(/\\([|\\])/, '\1').strip,
169
- state: cell["state"].strip, digest: cell["synced"].gsub(/[`\s]/, ""))
171
+ state: cell["state"].strip,
172
+ digest: cell["synced"].gsub(/[`\s]/, ""))
170
173
  end
171
174
 
172
175
  # @return [Hash, nil]
@@ -177,7 +180,7 @@ module Agentilda
177
180
  link = line.match(/project\s+\[([^\]]+)\]\((\S+?)\)/i)
178
181
  return nil unless link
179
182
 
180
- {name: link[1].strip.gsub(/\\([|\\])/, '\1'), url: link[2]}
183
+ { name: link[1].strip.gsub(/\\([|\\])/, '\1'), url: link[2] }
181
184
  end
182
185
  end
183
186
  end
@@ -39,24 +39,25 @@ module Agentilda
39
39
  # nothing here changes, and its plans quietly import as Backlog with no
40
40
  # indication anything was missed.
41
41
  PLACEMENTS = {
42
- new: Placement.new(type: "backlog", name: "Backlog", labels: []),
43
- researched: Placement.new(type: "backlog", name: "Backlog", labels: %w[researched]),
44
- planned: Placement.new(type: "unstarted", name: "Todo", labels: []),
45
- building: Placement.new(type: "started", name: "In Progress", labels: []),
42
+ new: Placement.new(type: "backlog", name: "Backlog", labels: []),
43
+ researched: Placement.new(type: "backlog", name: "Backlog", labels: %w[researched]),
44
+ ready_for_planning: Placement.new(type: "backlog", name: "Backlog", labels: %w[spec-ready]),
45
+ planned: Placement.new(type: "unstarted", name: "Todo", labels: []),
46
+ building: Placement.new(type: "started", name: "In Progress", labels: []),
46
47
  # Both halves of building are one column on a board. A reader there
47
48
  # wants to know work is under way; which half is under way is this
48
49
  # tool's business, and the label carries it for anyone who does care.
49
- building_ui: Placement.new(type: "started", name: "In Progress", labels: %w[frontend]),
50
- ready_for_review: Placement.new(type: "started", name: "In Review", labels: []),
51
- in_review: Placement.new(type: "started", name: "In Review", labels: []),
52
- rejected: Placement.new(type: "started", name: "In Review", labels: %w[changes-requested]),
53
- approved: Placement.new(type: "completed", name: "Done", labels: []),
54
- deployed: Placement.new(type: "completed", name: "Done", labels: %w[deployed]),
55
- blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked]),
56
- product_blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked-on-product]),
57
- deferred: Placement.new(type: "backlog", name: "Backlog", labels: %w[deferred]),
58
- retroactive: Placement.new(type: "completed", name: "Done", labels: %w[retroactive]),
59
- discarded: Placement.new(type: "canceled", name: "Canceled", labels: [])
50
+ building_ui: Placement.new(type: "started", name: "In Progress", labels: %w[frontend]),
51
+ ready_for_review: Placement.new(type: "started", name: "In Review", labels: []),
52
+ in_review: Placement.new(type: "started", name: "In Review", labels: []),
53
+ rejected: Placement.new(type: "started", name: "In Review", labels: %w[changes-requested]),
54
+ approved: Placement.new(type: "completed", name: "Done", labels: []),
55
+ deployed: Placement.new(type: "completed", name: "Done", labels: %w[deployed]),
56
+ blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked]),
57
+ product_blocked: Placement.new(type: "unstarted", name: "Todo", labels: %w[blocked-on-product]),
58
+ deferred: Placement.new(type: "backlog", name: "Backlog", labels: %w[deferred]),
59
+ retroactive: Placement.new(type: "completed", name: "Done", labels: %w[retroactive]),
60
+ discarded: Placement.new(type: "canceled", name: "Canceled", labels: [])
60
61
  }.freeze
61
62
 
62
63
  # States deliberately left out, and why.
@@ -71,8 +72,8 @@ module Agentilda
71
72
  # A plan in one of these states is reported and skipped. To import them,
72
73
  # decide where they belong and move the entry into {PLACEMENTS}.
73
74
  UNPLACED = {
74
- shit: "the plan survives and its pull requests do not; whether that is work still to do " \
75
- "or work abandoned depends on what the team does next",
75
+ shit: "the plan survives and its pull requests do not; whether that is work still to do " \
76
+ "or work abandoned depends on what the team does next",
76
77
  rolled_back: "it shipped and was pulled; whether that reopens this work or opens new work " \
77
78
  "depends on what broke"
78
79
  }.freeze
@@ -78,16 +78,18 @@ module Agentilda
78
78
  # @return [Agentilda::Linear::Result]
79
79
  def apply(action, subject, parent)
80
80
  if parent && !parent.ok?
81
- return Result.new(action:, identifier: action.identifier, url: nil,
81
+ return Result.new(action:,
82
+ identifier: action.identifier,
83
+ url: nil,
82
84
  error: "the issue for its plan could not be created")
83
85
  end
84
86
 
85
87
  attempt(action) do
86
88
  node = case action.op
87
- when :skip then recorded(action, subject)
88
- when :update then attach(api.update_issue(action.args[:id], input(action, parent)), action)
89
- else attach(api.create_issue(input(action, parent).merge(teamId: team[:id], projectId: project_id)), action)
90
- end
89
+ when :skip then recorded(action, subject)
90
+ when :update then attach(api.update_issue(action.args[:id], input(action, parent)), action)
91
+ else attach(api.create_issue(input(action, parent).merge(teamId: team[:id], projectId: project_id)), action)
92
+ end
91
93
 
92
94
  [node["identifier"], node["url"]]
93
95
  end
@@ -100,8 +102,8 @@ module Agentilda
100
102
  # @param parent [Agentilda::Linear::Result, nil]
101
103
  # @return [Hash]
102
104
  def input(action, parent)
103
- base = {title: action.args[:title], description: action.args[:description],
104
- stateId: state_id(action.args[:state]), labelIds: label_ids(action.args[:labels])}.compact
105
+ base = { title: action.args[:title], description: action.args[:description],
106
+ stateId: state_id(action.args[:state]), labelIds: label_ids(action.args[:labels]) }.compact
105
107
  return base unless parent&.identifier
106
108
 
107
109
  base.merge(parentId: parent.identifier)
@@ -125,7 +127,7 @@ module Agentilda
125
127
  # @return [Hash]
126
128
  def recorded(action, subject)
127
129
  was = Issues.new(dir: subject.feature.path).by_unit[action.unit]
128
- {"identifier" => was&.identifier || action.identifier, "url" => was&.url}
130
+ { "identifier" => was&.identifier || action.identifier, "url" => was&.url }
129
131
  end
130
132
 
131
133
  # A team names its own workflow states, so the name we would prefer is a
@@ -177,13 +179,18 @@ module Agentilda
177
179
  # @return [void]
178
180
  def record(subject, results)
179
181
  issues = results.select { |r| r.ok? && r.identifier }.map { |r|
180
- Issue.new(unit: r.action.unit, identifier: r.identifier, url: r.url,
181
- title: r.action.title, state: r.action.args[:state] || "", digest: r.action.digest)
182
+ Issue.new(unit: r.action.unit,
183
+ identifier: r.identifier,
184
+ url: r.url,
185
+ title: r.action.title,
186
+ state: r.action.args[:state] || "",
187
+ digest: r.action.digest)
182
188
  }
183
189
  return if issues.empty?
184
190
 
185
- Issues.new(dir: subject.feature.path).write(team: import.team, issues:,
186
- project: {name: import.project_name, url: import.project["url"]})
191
+ Issues.new(dir: subject.feature.path).write(team: import.team,
192
+ issues:,
193
+ project: { name: import.project_name, url: import.project["url"] })
187
194
  end
188
195
  end
189
196
  end
@@ -22,7 +22,7 @@ module Agentilda
22
22
  # wrong plan is worse than one nobody adopted at all.
23
23
  Match = Data.define(:project, :subject, :rule) do
24
24
  # @return [String]
25
- def why = {ordinal: "its name carries the plan number", slug: "its name is the plan's slug"}.fetch(rule)
25
+ def why = { ordinal: "its name carries the plan number", slug: "its name is the plan's slug" }.fetch(rule)
26
26
  end
27
27
 
28
28
  # @param tree [Agentilda::Tree]
@@ -102,7 +102,8 @@ module Agentilda
102
102
  }
103
103
  return found if found
104
104
 
105
- raise Error, "no project matches #{reference.inspect}. This team has:\n" +
105
+ raise Error,
106
+ "no project matches #{reference.inspect}. This team has:\n" +
106
107
  projects.map { |p| " #{p["name"]}\n #{p["url"]}" }.join("\n")
107
108
  end
108
109
 
@@ -93,10 +93,10 @@ module Agentilda
93
93
  # @return [String]
94
94
  def self.clean_title(title)
95
95
  title.to_s
96
- .sub(/\A\[[^\]]*\]\s*/, "")
97
- .sub(/\A(?:spec\s*)?\d{1,3}(?:\.\d{2})?\s*[—–:.-]?\s*/i, "")
98
- .sub(/\APR[\s_-]?\d+(?:\.\d+)?\s*[—–:.-]?\s*/i, "")
99
- .strip
96
+ .sub(/\A\[[^\]]*\]\s*/, "")
97
+ .sub(/\A(?:spec\s*)?\d{1,3}(?:\.\d{2})?\s*[—–:.-]?\s*/i, "")
98
+ .sub(/\APR[\s_-]?\d+(?:\.\d+)?\s*[—–:.-]?\s*/i, "")
99
+ .strip
100
100
  end
101
101
 
102
102
  private
@@ -260,14 +260,14 @@ module Agentilda
260
260
  # @return [String]
261
261
  def clean(title)
262
262
  title.to_s
263
- .sub(/\A\([^)]*\)\s*/, "") # "(010a) — Per-person taxes" — a nickname, not a title
264
- .sub(/\A[—–:.-]+\s*/, "")
265
- .sub(GLYPH_NOTE, "")
266
- .sub(TRAILING_GLYPH, "")
267
- .gsub(/[*`]/, "") # before OWN_NUMBER: the number is often inside backticks
268
- .sub(/\A_+/, "").sub(/_+\z/, "") # emphasis, but never `signed_off`
269
- .sub(OWN_NUMBER, "")
270
- .strip
263
+ .sub(/\A\([^)]*\)\s*/, "") # "(010a) — Per-person taxes" — a nickname, not a title
264
+ .sub(/\A[—–:.-]+\s*/, "")
265
+ .sub(GLYPH_NOTE, "")
266
+ .sub(TRAILING_GLYPH, "")
267
+ .gsub(/[*`]/, "") # before OWN_NUMBER: the number is often inside backticks
268
+ .sub(/\A_+/, "").sub(/_+\z/, "") # emphasis, but never `signed_off`
269
+ .sub(OWN_NUMBER, "")
270
+ .strip
271
271
  end
272
272
  end
273
273
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/linear/mapping"
4
+
3
5
  module Agentilda
4
6
  # `.plans` as Linear projects and issues.
5
7
  #
@@ -31,12 +33,9 @@ module Agentilda
31
33
  key = prefix.to_s.strip.upcase
32
34
  return key if key.match?(KEY)
33
35
 
34
- raise Error, "#{prefix.inspect} is not a Linear team key. A key is 2–10 characters, " \
35
- "letters and digits, as in TAX or ENG the part before the dash in TAX-41."
36
+ raise Error,
37
+ "#{prefix.inspect} is not a Linear team key. A key is 2–10 characters, " \
38
+ "letters and digits, as in TAX or ENG — the part before the dash in TAX-41."
36
39
  end
37
40
  end
38
41
  end
39
-
40
- %w[mapping fuzzy unit issue survey attribution import api push].each do |part|
41
- require File.join(__dir__, "linear", "#{part}.rb")
42
- end