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.
- checksums.yaml +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- 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
|