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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # Shared flags and the plumbing every command needs.
6
+ class Base < Dry::CLI::Command
7
+ include UI
8
+
9
+ def self.inherited(klass)
10
+ super
11
+ klass.option :dir, default: Agentilda::PLANS_DIR, aliases: ["-D"],
12
+ desc: "The .plans directory"
13
+ klass.option :quiet, type: :boolean, default: false, aliases: ["-q"],
14
+ desc: "Suppress progress output on STDERR"
15
+ end
16
+
17
+ private
18
+
19
+ # @param options [Hash]
20
+ # @return [Agentilda::Tree]
21
+ def tree_for(options)
22
+ tree = Tree.new(dir: options.fetch(:dir, Agentilda::PLANS_DIR))
23
+ unless tree.exist?
24
+ refuse("No #{Agentilda::PLANS_DIR} directory at\n#{tree.dir}\n\n" \
25
+ "Run this from the project root, or pass -D.", 66)
26
+ end
27
+ tree
28
+ end
29
+
30
+ # Report and stop, in the shape every refusal here takes. The statuses
31
+ # follow sysexits where one fits: 64 usage, 65 bad data, 66 no input,
32
+ # 69 a service (GitHub, Linear) refused us.
33
+ #
34
+ # @param message [String]
35
+ # @param status [Integer]
36
+ # @return [void] never returns
37
+ def refuse(message, status)
38
+ error(message)
39
+ exit status
40
+ end
41
+
42
+ # @param options [Hash]
43
+ # @return [Boolean]
44
+ def quiet?(options)
45
+ quiet = options.fetch(:quiet, false)
46
+ UI.quiet = quiet
47
+ quiet
48
+ end
49
+
50
+ # @param options [Hash]
51
+ # @return [Boolean]
52
+ def commit?(options) = options.fetch(:commit, false)
53
+
54
+ # `claude` prefers a credential in the environment to a claude.ai login,
55
+ # so a project `.env` that direnv loads on `cd` can redirect every agent
56
+ # a run spawns to a key meant for the application itself.
57
+ #
58
+ # Say it once, before the run starts, because the failure arrives three
59
+ # minutes later, once per agent, and reads like an agent problem rather
60
+ # than an environment one.
61
+ #
62
+ # @return [void]
63
+ def credentials_warning
64
+ names = Executor.foreign_credentials
65
+ return if names.empty?
66
+
67
+ one = names.size == 1
68
+ warn("#{names.join(" and ")} #{one ? "is" : "are"} set in this shell, so every agent authenticates " \
69
+ "with #{one ? "it" : "them"} rather than with your claude.ai login.\n" \
70
+ "A stale one fails every agent in the run with `401 API key is invalid`, " \
71
+ "three minutes at a time.\n" \
72
+ "If it came from a project .env, run `unset #{names.join(" ")}` first.")
73
+ end
74
+
75
+ # The line every dry run ends with, so nobody mistakes a preview for the
76
+ # thing having happened.
77
+ #
78
+ # @param count [Integer] how much work is pending
79
+ # @param what [String]
80
+ # @return [void]
81
+ def dry_run_footer(count, what)
82
+ return if count.zero?
83
+
84
+ warn("#{count} #{what} pending — nothing was changed.\nRe-run with --commit to apply.")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda create <words…>` — backs /spec-create.
6
+ class Create < Base
7
+ desc "Create the next numbered plan folder"
8
+
9
+ argument :words,
10
+ type: :array,
11
+ required: false,
12
+ default: [],
13
+ desc:
14
+ "The topic, two to five words; becomes the folder slug. Omit when --from names it"
15
+
16
+ option :from,
17
+ aliases: ["-f"],
18
+ desc:
19
+ "Seed from a markdown file: its frontmatter `title:` names the folder, and its body fills the spec."
20
+ option :after,
21
+ aliases: ["-a"],
22
+ desc:
23
+ "Create a retroactive plan in the gap after this plan, e.g. 002"
24
+ option :status,
25
+ aliases: ["-s"],
26
+ desc: "Open in a state other than the default"
27
+ option :prs,
28
+ aliases: ["--pr"],
29
+ desc:
30
+ "Document work that already shipped: pull request numbers or URLs, comma separated. Requires --after"
31
+ option :spec,
32
+ type: :boolean,
33
+ default: true,
34
+ desc:
35
+ "With --prs, write spec.md from what the pull requests did. --no-spec records them and stops, which is fast and offline"
36
+ option :draft,
37
+ type: :boolean,
38
+ default: true,
39
+ desc:
40
+ "For a new feature (no --prs), attempt spec.md's four headings from project context via `claude`. --no-draft leaves them bare"
41
+ option :open,
42
+ type: :boolean,
43
+ default: true,
44
+ desc:
45
+ "Open the new spec.md in the system editor when done (macOS `open`). --no-open leaves it for you to open"
46
+
47
+ # noinspection RubyMismatchedArgumentType
48
+ example [
49
+ "tax rule dsl # 003.00-⚪️--tax-rule-dsl, spec.md scaffolded and drafted",
50
+ "tax rule dsl --no-draft --no-open # scaffold only, nothing shelled out, nothing opened",
51
+ "--after 002 schedule k1 # 002.01-🕰️-schedule-k1 (documented after the fact)",
52
+ "--after 018 --prs 12,15 verify # …and write spec.md from what those PRs did",
53
+ "--after 018 --pr https://…/pull/12 verify # verify the pull request by number or URL",
54
+ "--status ready billing sync # opens at ⭐️ instead of ⚪️",
55
+ "--from notes/tax-dsl.md # title and opening prose from the file's frontmatter and body"
56
+ ]
57
+
58
+ # @param words [Array<String>]
59
+ # @param options [Hash]
60
+ # @return [void]
61
+ def call(words:, **options)
62
+ dir = options.fetch(:dir, Agentilda::PLANS_DIR)
63
+ FileUtils.mkdir_p(dir)
64
+
65
+ words, seed = resolve_seed(words, options)
66
+ prs = fetch_prs(options)
67
+ result =
68
+ Creator.new(dir:).create(
69
+ words:,
70
+ after: options[:after],
71
+ status: options[:status],
72
+ prs:
73
+ )
74
+
75
+ result.either(
76
+ ->(path) { created(path, prs, options, seed:) },
77
+ ->(message) { refuse("Could not create the plan:\n#{message}", 65) }
78
+ )
79
+ end
80
+
81
+ private
82
+
83
+ # `--from FILE` trades the words argument for a markdown file: the
84
+ # frontmatter's `title:` names the folder, and the body travels on to
85
+ # {Brief} as the author's own opening statement. Words and a file are
86
+ # two answers to one question — the folder's name — so holding both is
87
+ # refused rather than silently ranked.
88
+ #
89
+ # @param words [Array<String>]
90
+ # @param options [Hash]
91
+ # @return [Array(Array<String>, String, nil)] the slug words, and the
92
+ # seed body when a file supplied them
93
+ def resolve_seed(words, options)
94
+ file = options[:from]
95
+ return words, nil unless file
96
+
97
+ unless words.empty?
98
+ refuse(
99
+ "--from names the topic by its frontmatter title;\n" \
100
+ "drop the words, or drop --from",
101
+ 64
102
+ )
103
+ end
104
+ if options[:prs]
105
+ refuse(
106
+ "--prs reconstructs spec.md from the pull requests, so a seed file\n" \
107
+ "would be ignored; use one or the other",
108
+ 64
109
+ )
110
+ end
111
+ unless File.file?(file)
112
+ refuse("Could not read the seed file:\n#{file} does not exist", 66)
113
+ end
114
+
115
+ title, body = parse_seed(File.read(file, encoding: "UTF-8"))
116
+ if title.to_s.strip.empty?
117
+ refuse(
118
+ "The seed file needs a frontmatter title, e.g.\n" \
119
+ "---\ntitle: Tax Rule DSL\n---",
120
+ 65
121
+ )
122
+ end
123
+
124
+ [title.split, body]
125
+ end
126
+
127
+ # Frontmatter that will not parse used to be reported as a missing
128
+ # title, which sends the author looking for a `title:` line that is
129
+ # sitting right there. Say which of the two actually went wrong.
130
+ #
131
+ # @param content [String]
132
+ # @return [Array(String, String)] title and body
133
+ def parse_seed(content)
134
+ meta, body = Frontmatter.split(content)
135
+ [meta["title"].to_s, body.strip]
136
+ rescue Psych::Exception => e
137
+ refuse(
138
+ "The seed file's frontmatter is not valid YAML:\n#{e.message}",
139
+ 65
140
+ )
141
+ end
142
+
143
+ # A retroactive plan documents work that landed *somewhere* in the
144
+ # sequence, and only its author knows where. Guessing would put the
145
+ # number — the one thing that never changes — in the wrong place.
146
+ #
147
+ # @param options [Hash]
148
+ # @return [Array<Hash>, nil]
149
+ def fetch_prs(options)
150
+ return nil unless options[:prs]
151
+
152
+ unless options[:after]
153
+ refuse(
154
+ "--prs documents work that already shipped;\n" \
155
+ "name the plan it landed after with --after, e.g. --after 018",
156
+ 64
157
+ )
158
+ end
159
+
160
+ GitHub.new.pull_requests(GitHub.parse_refs(options[:prs]))
161
+ rescue Agentilda::Error => e
162
+ refuse("Could not read the pull requests:\n#{e.message}", 65)
163
+ end
164
+
165
+ # A plan with recorded pull requests already has its facts — hand it to
166
+ # the writer that reconstructs a specification from a diff. One without
167
+ # them does not exist yet, and reconstructing is not the job; {#brief} is.
168
+ #
169
+ # @param path [String]
170
+ # @param prs [Array<Hash>, nil]
171
+ # @param options [Hash]
172
+ # @return [void]
173
+ def created(path, prs, options, seed: nil)
174
+ from_prs = prs && !prs.empty?
175
+ path =
176
+ if from_prs
177
+ synthesize(path, options) if options.fetch(:spec, true)
178
+ else
179
+ brief(path, options, seed:)
180
+ end || path
181
+ puts path
182
+ return if quiet?(options)
183
+
184
+ feature = Feature.parse(path)
185
+ success(
186
+ "Created #{File.basename(path)}\n\n" \
187
+ "#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
188
+ "#{next_step(path, feature, from_prs:)}"
189
+ )
190
+ end
191
+
192
+ # Hand the folder to the writer that already knows how to write a
193
+ # retroactive specification, then let `resync dirs` decide what the
194
+ # folder has become — 🕰️ is only true while there is no `spec.md`.
195
+ #
196
+ # @param path [String]
197
+ # @param options [Hash]
198
+ # @return [String] the folder's path, which the resync may have renamed
199
+ def synthesize(path, options)
200
+ agent = Agentilda::Agents.new.find(Agentilda::RETROACTIVE_WRITER) or
201
+ return path
202
+ root = options[:root] || File.dirname(path, 2)
203
+
204
+ ok, note =
205
+ UI.spinning("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}") do
206
+ Executor.new(root:).call(agent, Subject.new(Feature.parse(path)))
207
+ end
208
+ warn_about(note) unless ok
209
+
210
+ settle(path)
211
+ end
212
+
213
+ # A folder with no pull requests to reconstruct from is a feature that
214
+ # does not exist yet. {Brief} writes the four headings a human still has
215
+ # to answer, makes a best-effort pass at them from what the project
216
+ # already has on disk, and — unless told not to — opens the result for
217
+ # a human to finish. The folder's state never moves: ⚪️ New only ever
218
+ # claimed that a specification exists, not that it is complete.
219
+ #
220
+ # @param path [String] the path to the folder
221
+ # @param seed [String, nil] the seed body from the `--from` file
222
+ # @param options [Hash] the options hash
223
+ # @return [String] +path+, unchanged
224
+ def brief(path, options, seed: nil)
225
+ feature = Feature.parse(path)
226
+ return path if feature.status.key == :retroactive
227
+
228
+ root = options[:root] || File.dirname(path, 2)
229
+ brief = Brief.new(path:, title: feature.title, root:, seed:)
230
+ brief.write_scaffold!
231
+
232
+ if options.fetch(:draft, true)
233
+ # Painted the way Runner::Task#label paints a roster agent, so the
234
+ # half-agent reads as one of them on the terminal.
235
+ label =
236
+ "#{UI.paint(Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
237
+ ok, note = UI.spinning(label) { brief.attempt! }
238
+ warn_about_draft(note) unless ok
239
+ end
240
+
241
+ open_spec(brief.spec_path) if options.fetch(:open, true)
242
+ path
243
+ end
244
+
245
+ # @param spec_path [String]
246
+ # @return [void]
247
+ def open_spec(spec_path)
248
+ return unless macos?
249
+
250
+ system("open", spec_path, out: File::NULL, err: File::NULL)
251
+ end
252
+
253
+ # `open` is a macOS command; anywhere else the viewer step is skipped
254
+ # rather than failed. A named seam so a spec can assert the viewer was
255
+ # asked for without inheriting the platform CI happens to run on.
256
+ #
257
+ # @return [Boolean]
258
+ def macos? = RbConfig::CONFIG["host_os"].to_s.match?(/darwin/)
259
+
260
+ # @param path [String]
261
+ # @return [String] where the folder ended up
262
+ def settle(path)
263
+ tree = Tree.new(dir: File.dirname(path))
264
+ change =
265
+ Agentilda::Resync::Dirs
266
+ .new(tree:)
267
+ .call(commit: true)
268
+ .find { |c| c.source == path }
269
+ change ? change.target : path
270
+ end
271
+
272
+ # @param note [String]
273
+ # @return [void]
274
+ def warn_about(note)
275
+ error(
276
+ "The folder was created, but spec.md was not written:\n#{note}\n\n" \
277
+ "The pull requests are recorded. Run `agentilda run --commit` to retry."
278
+ )
279
+ end
280
+
281
+ # @param note [String]
282
+ # @return [void]
283
+ def warn_about_draft(note)
284
+ error(
285
+ "spec.md was scaffolded, but the drafting attempt did not finish:\n#{note}\n\n" \
286
+ "The four headings are there, empty. Fill them in by hand, or hand off to leah-researcher."
287
+ )
288
+ end
289
+
290
+ # @param path [String]
291
+ # @param feature [Agentilda::Feature]
292
+ # @return [String]
293
+ def next_step(path, feature, from_prs:)
294
+ spec = File.join(File.basename(path), "spec.md")
295
+ unless feature.status.key == :new &&
296
+ File.file?(File.join(path, "spec.md"))
297
+ return "Next: write #{spec}"
298
+ end
299
+ if from_prs
300
+ return (
301
+ "Next: read #{spec} — it was written from the pull requests, so check it against what actually shipped"
302
+ )
303
+ end
304
+
305
+ "Next: fill in the four headings in #{spec}, then hand off to leah-researcher"
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda docs` — regenerate the conventions document.
6
+ class Docs < Base
7
+ desc "Generate the conventions document from the state machine itself"
8
+
9
+ option :output, aliases: ["-o"], desc: "Write here instead of STDOUT",
10
+ default: "#{ENV["HOME"]}/.agents/context/workflow.md"
11
+
12
+ example ["", "-o ~/.agents/context/workflow.md"]
13
+
14
+ # @param options [Hash]
15
+ # @return [void]
16
+ def call(**options)
17
+ document = Documentation.new.render
18
+
19
+ if (path = options[:output])
20
+ FileUtils.mkdir_p(File.dirname(path))
21
+ File.write(path, document)
22
+ system("command -v mdformat>/dev/null 2>&1 && mdformat --wrap no #{path} 2>/dev/null")
23
+ success("created workflow description in [#{path}]") unless quiet?(options)
24
+ else
25
+ $stdout.write(document)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda index` — the plans as one browsable page.
6
+ class Index < Base
7
+ desc "Create or update .plans/INDEX.md — a global index of every plan."
8
+
9
+ option :output, aliases: ["-o"],
10
+ desc: "Write somewhere other than <plans>/INDEX.md; - for STDOUT"
11
+ option :project, aliases: ["-p"],
12
+ desc: "Heading for the page, default: the repository's directory name"
13
+
14
+ example [
15
+ " # write .plans/INDEX.md",
16
+ "-o - # print it instead",
17
+ "-p 'Equilibris App' # override the heading"
18
+ ]
19
+
20
+ # @param options [Hash]
21
+ # @return [void]
22
+ def call(**options)
23
+ index = Agentilda::Index.new(tree: tree_for(options), project: options[:project])
24
+
25
+ if options[:output] == "-"
26
+ $stdout.write(index.render)
27
+ return
28
+ end
29
+
30
+ path = index.write(options[:output])
31
+ puts path
32
+ return if quiet?(options)
33
+
34
+ success("Wrote #{path}\n\nRegenerate it after any `resync dirs`, which renames folders\nand would otherwise leave every link here pointing at nothing.")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda linear …`
6
+ module Linear
7
+ # Shared by both linear commands: the team is which workspace this is
8
+ # about, so it is an argument rather than a flag. There is no default —
9
+ # a tool that picks a team for you when you forget to say which is a
10
+ # tool that files a quarter of somebody's work in the wrong place.
11
+ class Team < Base
12
+ def self.inherited(klass)
13
+ super
14
+ klass.argument :team, required: true,
15
+ desc: "The Linear team key that prefixes its issues, e.g. TAX"
16
+ end
17
+
18
+ private
19
+
20
+ # The key is checked before anything reaches for a token, so a typo in
21
+ # the team name reports the typo rather than an authentication
22
+ # problem the user does not have.
23
+ #
24
+ # @param team [String]
25
+ # @return [Agentilda::Linear::Survey]
26
+ def survey_for(team, tree)
27
+ key = Agentilda::Linear.key!(team)
28
+ api = Agentilda::Linear::API.new(token: Agentilda::Linear::API.token_from_env)
29
+ projects = UI.spinning("Listing #{key} projects") { api.projects(api.team(key)[:id]) }
30
+ [api, Agentilda::Linear::Survey.new(tree:, projects:)]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda linear …`
6
+ module Linear
7
+ # `linear import` — the plans, as Linear issues under one of your projects.
8
+ class Import < Team
9
+ desc "Create Linear issues from the plans: one per folder, one child per work unit"
10
+
11
+ option :project, aliases: ["-p", "--project-url", "--project-id"],
12
+ desc: "The project to file everything under: its URL, its name, or its id"
13
+ option :commit, type: :boolean, default: false,
14
+ desc: "Actually create and update in Linear (default: dry run)"
15
+ option :format, default: "text", values: %w[text json],
16
+ desc: "json emits the exact arguments the Linear MCP tools take"
17
+ option :since, aliases: ["-s"],
18
+ desc: "Skip plans numbered below this, e.g. 010.00"
19
+ option :status,
20
+ desc: "Only plans in these states: a comma-separated list of status keys"
21
+ option :force, type: :boolean, default: false,
22
+ desc: "Update everything, whether the plan has changed or not"
23
+
24
+ example [
25
+ "TAX -p 'US Tax Law: Self Contained Ruby Gem' # show what would be created",
26
+ "TAX -p https://linear.app/acme/project/… # a URL works too",
27
+ "TAX -p 'Ruby Gem' --commit # do it, using LINEAR_API_KEY",
28
+ "TAX -p 'Ruby Gem' --format json # hand it to the MCP transport instead",
29
+ "TAX -p 'Ruby Gem' --since 010.00 # only the recent plans",
30
+ "TAX -p 'Ruby Gem' --status building,in_review"
31
+ ]
32
+
33
+ # @param team [String]
34
+ # @param options [Hash]
35
+ # @return [void]
36
+ def call(team:, **options)
37
+ tree = tree_for(options)
38
+ require_project!(options)
39
+ import = build(tree, team, resolve_project(team, tree, options), options)
40
+
41
+ return $stdout.puts(import.to_json) if options[:format] == "json"
42
+
43
+ if import.pending.empty?
44
+ success("Linear is already in step with #{tree.dir}.") unless quiet?(options)
45
+ return
46
+ end
47
+
48
+ preview(import, options)
49
+ rescue Agentilda::Error => e
50
+ refuse(e.message, 69)
51
+ end
52
+
53
+ private
54
+
55
+ # Looking the project up costs a token, and the whole point of
56
+ # `--format json` is to work without one. A name needs no lookup — the
57
+ # MCP server resolves a project by name itself — so only a URL or an
58
+ # id, which do not carry a name, force the network.
59
+ #
60
+ # @return [Hash] `{"id", "name", "url"}`
61
+ def resolve_project(team, tree, options)
62
+ reference = options[:project].to_s
63
+ looks_up = reference.match?(%r{\Ahttps?://}) || reference.match?(/\A[0-9a-f-]{32,}\z/)
64
+ return {"id" => nil, "name" => reference, "url" => nil} if !looks_up && offline?
65
+
66
+ _api, survey = survey_for(team, tree)
67
+ survey.project(reference)
68
+ end
69
+
70
+ # @return [Boolean]
71
+ def offline? = Agentilda::Linear::API.token_from_env.nil?
72
+
73
+ # @param options [Hash]
74
+ # @return [void]
75
+ def require_project!(options)
76
+ return if options[:project]
77
+
78
+ raise Agentilda::Error,
79
+ "which project? Pass -p with a project's URL, name or id.\n\n" \
80
+ "This never creates one: a team's project list is something you curated, and " \
81
+ "every plan is filed under one you named.\n\n " \
82
+ "agentilda linear projects <TEAM> lists them"
83
+ end
84
+
85
+ # @return [Agentilda::Linear::Import]
86
+ def build(tree, team, project, options)
87
+ Agentilda::Linear::Import.new(tree:, team: Agentilda::Linear.key!(team), project:,
88
+ since: options[:since], statuses: statuses(options),
89
+ force: options.fetch(:force, false))
90
+ end
91
+
92
+ # @param options [Hash]
93
+ # @return [Array<Symbol>, nil]
94
+ def statuses(options)
95
+ return nil unless options[:status]
96
+
97
+ options[:status].to_s.split(",").map { |word|
98
+ Agentilda.status(word.strip)&.key ||
99
+ raise(Agentilda::Error, "no such state: #{word.strip}")
100
+ }
101
+ end
102
+
103
+ # @param import [Agentilda::Linear::Import]
104
+ # @param options [Hash]
105
+ # @return [void]
106
+ def preview(import, options)
107
+ import.pending.each { |action| puts row(action) }
108
+ return if quiet?(options)
109
+
110
+ import.pending.each { |action| say(line(action)) }
111
+ report_unplaced(import)
112
+ dry_run_footer(import.pending.size, "Linear change#{"s" unless import.pending.size == 1}")
113
+ end
114
+
115
+ # @param action [Agentilda::Linear::Action]
116
+ # @return [String]
117
+ def row(action)
118
+ [action.op, action.kind, action.ordinal, action.unit || "-",
119
+ action.identifier || "-", action.title].join("\t")
120
+ end
121
+
122
+ # @param action [Agentilda::Linear::Action]
123
+ # @return [String]
124
+ def line(action)
125
+ verb = paint(action.op.to_s.ljust(6), (action.op == :create) ? :green : :yellow)
126
+ indent = action.child? ? " " : ""
127
+ "#{verb} #{indent}#{action.title} #{paint("(#{action.reason})", :bright_black)}"
128
+ end
129
+
130
+ # @param import [Agentilda::Linear::Import]
131
+ # @return [void]
132
+ def report_unplaced(import)
133
+ return if import.unplaced.empty?
134
+
135
+ listed = import.unplaced.map { |status, ordinals|
136
+ " #{status} — #{ordinals.join(", ")}\n#{wrapped(Agentilda::Linear.reason_unplaced(status))}"
137
+ }
138
+ warn("Not imported, because nothing here knows where they belong:\n\n" \
139
+ "#{listed.join("\n\n")}\n\n" \
140
+ "Decide where they go on your board and add it to Linear::PLACEMENTS.")
141
+ end
142
+
143
+ # A box re-wraps a line that overruns it, and the wrapped remainder
144
+ # comes back at column zero — which reads as a new entry rather than
145
+ # the continuation of one. Wrapping it here keeps the indent.
146
+ #
147
+ # @param text [String]
148
+ # @param width [Integer] narrower than the narrowest box
149
+ # @return [String]
150
+ def wrapped(text, width: 58)
151
+ text.split.each_with_object([+""]) { |word, lines|
152
+ lines << +"" if lines.last.length + word.length + 1 > width
153
+ lines.last << " " unless lines.last.empty?
154
+ lines.last << word
155
+ }.map { |line| " #{line}" }.join("\n")
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end