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,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda unblock NNN…` — hand a stopped plan to the agent that
|
|
6
|
+
# drains its `blocked.md`.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately not part of `run`. ⭕️ and 🅱️ are {StateMachine::SETTLED}, so
|
|
9
|
+
# the loop never offers a blocked plan to anybody, and that is the property
|
|
10
|
+
# that makes the state mean anything: the plan waits for a human, and no
|
|
11
|
+
# agent quietly decides otherwise. Answers arriving is not a fact the tool
|
|
12
|
+
# can observe, so a human typing this command *is* the signal, and there is
|
|
13
|
+
# nothing else that could produce it.
|
|
14
|
+
class Unblock < Base
|
|
15
|
+
# The agent that knows the shape of `blocked.md`.
|
|
16
|
+
DEFAULT_AGENT = "lando-broker"
|
|
17
|
+
|
|
18
|
+
# What each token turned out to be, for whoever has to fix it.
|
|
19
|
+
PROBLEMS = {
|
|
20
|
+
missing: "no plan of that number",
|
|
21
|
+
not_blocked: "no blocked.md, so there is nothing to drain"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
desc "Fold answered blocks into a plan's documents and retire blocked.md"
|
|
25
|
+
|
|
26
|
+
argument :plans, type: :array, required: true,
|
|
27
|
+
desc: "Which plans to drain: NNN or NNN.MM, e.g. 003 005.01"
|
|
28
|
+
|
|
29
|
+
option :commit, type: :boolean, default: false,
|
|
30
|
+
desc: "Actually invoke the agent (default: dry run, prints the questions still open)"
|
|
31
|
+
option :agent, default: DEFAULT_AGENT, desc: "Hand the folder to a different agent"
|
|
32
|
+
option :root, desc: "Repository root the agent works in (default: the .plans parent)"
|
|
33
|
+
|
|
34
|
+
example [
|
|
35
|
+
"003 # what 003 is still waiting on",
|
|
36
|
+
"003 --commit # fold in whatever has been answered",
|
|
37
|
+
"003,005 --commit # both"
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# @param plans [Array<String>]
|
|
41
|
+
# @param options [Hash]
|
|
42
|
+
# @return [void]
|
|
43
|
+
def call(plans:, **options)
|
|
44
|
+
tree = tree_for(options)
|
|
45
|
+
quiet?(options)
|
|
46
|
+
unblocker = Unblocker.new(tree:, agent: agent_for(options), root: options[:root],
|
|
47
|
+
commit: commit?(options), executor: Executor.new(root: options[:root] || File.dirname(tree.dir),
|
|
48
|
+
dry_run: !commit?(options)))
|
|
49
|
+
|
|
50
|
+
targets = unblocker.resolve(plans)
|
|
51
|
+
refused(targets, tree, options)
|
|
52
|
+
subjects = targets.select(&:drainable?).map(&:subject)
|
|
53
|
+
exit(worst(targets, [])) if subjects.empty?
|
|
54
|
+
|
|
55
|
+
credentials_warning if commit?(options) && !quiet?(options)
|
|
56
|
+
preflight(subjects, unblocker, options)
|
|
57
|
+
|
|
58
|
+
outcomes = unblocker.call(subjects)
|
|
59
|
+
outcomes.each { |outcome| report(outcome, options) }
|
|
60
|
+
footer(outcomes, options) unless quiet?(options)
|
|
61
|
+
exit(worst(targets, outcomes))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# @param options [Hash]
|
|
67
|
+
# @return [Agentilda::Agent]
|
|
68
|
+
def agent_for(options)
|
|
69
|
+
name = options.fetch(:agent, DEFAULT_AGENT)
|
|
70
|
+
agents = Agentilda::Agents.new
|
|
71
|
+
agents.find(name) or
|
|
72
|
+
refuse("No agent called #{name}.\n\nKnown: #{agents.all.map(&:name).join(", ")}", 65)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A number that names no folder, or a folder that was never stopped, is
|
|
76
|
+
# refused rather than skipped. Whoever typed this believes an answer has
|
|
77
|
+
# arrived; running against a folder nobody blocked and saying nothing is
|
|
78
|
+
# how you come back later to a plan nobody touched and no record of why.
|
|
79
|
+
#
|
|
80
|
+
# Every bad token is reported, not just the first, because stopping at
|
|
81
|
+
# the first one hides the rest of the answer.
|
|
82
|
+
#
|
|
83
|
+
# @param targets [Array<Agentilda::Unblocker::Target>]
|
|
84
|
+
# @param tree [Agentilda::Tree]
|
|
85
|
+
# @param options [Hash]
|
|
86
|
+
# @return [void]
|
|
87
|
+
def refused(targets, tree, options)
|
|
88
|
+
problems = targets.reject(&:drainable?)
|
|
89
|
+
return if problems.empty?
|
|
90
|
+
|
|
91
|
+
problems.each do |target|
|
|
92
|
+
state = target.subject ? target.subject.status.key : "unknown"
|
|
93
|
+
puts "#{target.token}\t#{state}\t0 open\t#{PROBLEMS.fetch(target.problem)}"
|
|
94
|
+
end
|
|
95
|
+
return if quiet?(options)
|
|
96
|
+
|
|
97
|
+
error("#{problems.size} of the plans named cannot be drained:\n\n" +
|
|
98
|
+
problems.map { |t| " #{t.token} — #{PROBLEMS.fetch(t.problem)}" }.join("\n") +
|
|
99
|
+
"\n\nThe tree holds: #{tree.ordinals.join(", ")}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# What each folder is waiting on, said BEFORE the agent is invoked.
|
|
103
|
+
#
|
|
104
|
+
# This is the half that was missing. `--commit` hands the folder to an
|
|
105
|
+
# agent that can take a quarter of an hour, and until it came back the
|
|
106
|
+
# terminal showed nothing at all — a run that had found nothing to do and
|
|
107
|
+
# a run still working looked exactly alike.
|
|
108
|
+
#
|
|
109
|
+
# @param subjects [Array<Agentilda::Subject>]
|
|
110
|
+
# @param unblocker [Agentilda::Unblocker]
|
|
111
|
+
# @param options [Hash]
|
|
112
|
+
# @return [void]
|
|
113
|
+
def preflight(subjects, unblocker, options)
|
|
114
|
+
return if quiet?(options)
|
|
115
|
+
|
|
116
|
+
subjects.each do |subject|
|
|
117
|
+
questions = Unblocker.questions(subject)
|
|
118
|
+
answered = questions.count(&:answered)
|
|
119
|
+
say("#{paint(subject.feature.ordinal.to_s, :bright_black)} #{subject.status.emoji} " \
|
|
120
|
+
"#{subject.feature.title} — #{summary(questions.size, answered)}")
|
|
121
|
+
detail(subject, questions)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param open [Integer]
|
|
126
|
+
# @param answered [Integer]
|
|
127
|
+
# @return [String]
|
|
128
|
+
def summary(open, answered)
|
|
129
|
+
return "nothing open" if open.zero?
|
|
130
|
+
|
|
131
|
+
"#{open} open, #{answered.zero? ? "none answered yet" : "#{answered} with an answer waiting"}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param subject [Agentilda::Subject]
|
|
135
|
+
# @param questions [Array<Agentilda::Unblocker::Question>]
|
|
136
|
+
# @return [void]
|
|
137
|
+
def detail(subject, questions)
|
|
138
|
+
if subject.unreadable_block?
|
|
139
|
+
say(" #{paint("blocked.md names no `## B<n>` question, so nothing here can be drained", :red)}",
|
|
140
|
+
bullet: " ")
|
|
141
|
+
say(" #{paint("Number each open question `## B1`, `## B2`, and each answer `## A1`, `## A2`.", :yellow)}",
|
|
142
|
+
bullet: " ")
|
|
143
|
+
elsif questions.empty?
|
|
144
|
+
say(" #{paint("nothing left open", :green)}", bullet: " ")
|
|
145
|
+
else
|
|
146
|
+
questions.each { |question| say(" #{paint(question.to_s, :yellow)}", bullet: " ") }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# One plan, after the fact: the deliverable line on STDOUT, and what
|
|
151
|
+
# changed on STDERR.
|
|
152
|
+
#
|
|
153
|
+
# @param outcome [Agentilda::Unblocker::Outcome]
|
|
154
|
+
# @param options [Hash]
|
|
155
|
+
# @return [void]
|
|
156
|
+
def report(outcome, options)
|
|
157
|
+
moved = movement(outcome, commit?(options))
|
|
158
|
+
puts "#{outcome.ordinal}\t#{outcome.ok ? outcome.subject.status.key : "failed"}\t" \
|
|
159
|
+
"#{outcome.after.size} open\t#{moved}\t#{outcome.note}"
|
|
160
|
+
# A dry run changed nothing, so the preflight above it is still the
|
|
161
|
+
# whole truth. Printing the same three lines again reads as a second
|
|
162
|
+
# pass that found the same thing, which is not what happened.
|
|
163
|
+
return if quiet?(options) || !commit?(options)
|
|
164
|
+
|
|
165
|
+
say("#{paint(outcome.ordinal.to_s, :bright_black)} #{outcome.subject.status.emoji} " \
|
|
166
|
+
"#{outcome.subject.feature.title} — #{paint(moved, outcome.ok ? :green : :red)}")
|
|
167
|
+
detail(outcome.subject, outcome.after)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# What the file says happened, rather than what the agent claims. The
|
|
171
|
+
# questions are counted off disk on both sides of the run.
|
|
172
|
+
#
|
|
173
|
+
# @param outcome [Agentilda::Unblocker::Outcome]
|
|
174
|
+
# @param commit [Boolean]
|
|
175
|
+
# @return [String]
|
|
176
|
+
def movement(outcome, commit)
|
|
177
|
+
return "failed" unless outcome.ok
|
|
178
|
+
return "not attempted" unless commit
|
|
179
|
+
return "blocked.md retired" if outcome.cleared?
|
|
180
|
+
return "nothing folded" if outcome.folded.empty?
|
|
181
|
+
|
|
182
|
+
"folded #{outcome.folded.map { |n| "B#{n}" }.join(", ")}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @param outcomes [Array<Agentilda::Unblocker::Outcome>]
|
|
186
|
+
# @param options [Hash]
|
|
187
|
+
# @return [void]
|
|
188
|
+
def footer(outcomes, options)
|
|
189
|
+
failed = outcomes.reject(&:ok)
|
|
190
|
+
|
|
191
|
+
unless commit?(options)
|
|
192
|
+
warn("Dry run: no agent was invoked, and #{outcomes.size} " \
|
|
193
|
+
"plan#{"s" unless outcomes.size == 1} #{(outcomes.size == 1) ? "is" : "are"} unchanged.\n" \
|
|
194
|
+
"Re-run with --commit to fold in whatever has been answered.")
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return error(failed.map { |o| "#{o.ordinal}: #{o.note}" }.join("\n")) unless failed.empty?
|
|
199
|
+
|
|
200
|
+
cleared = outcomes.count(&:cleared?)
|
|
201
|
+
folded = outcomes.sum { |o| o.folded.size }
|
|
202
|
+
waiting = outcomes.sum { |o| o.after.size }
|
|
203
|
+
success("#{folded} question#{"s" unless folded == 1} folded in · " \
|
|
204
|
+
"#{cleared} plan#{"s" unless cleared == 1} out of the block · " \
|
|
205
|
+
"#{waiting} still waiting on a human." +
|
|
206
|
+
(folded.zero? ? "\n\nNothing moved. An answer has to be written into blocked.md as its own " \
|
|
207
|
+
"`## A<n>` section, answering the `## B<n>` of the same number, before " \
|
|
208
|
+
"there is anything to fold." : ""))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The most serious thing that happened, as an exit status. A tree that
|
|
212
|
+
# was partly drained still exits non-zero when part of it could not be.
|
|
213
|
+
#
|
|
214
|
+
# @param targets [Array<Agentilda::Unblocker::Target>]
|
|
215
|
+
# @param outcomes [Array<Agentilda::Unblocker::Outcome>]
|
|
216
|
+
# @return [Integer]
|
|
217
|
+
def worst(targets, outcomes)
|
|
218
|
+
problems = targets.map(&:problem)
|
|
219
|
+
return 66 if problems.include?(:missing)
|
|
220
|
+
return 65 if problems.include?(:not_blocked)
|
|
221
|
+
return 1 unless outcomes.all?(&:ok)
|
|
222
|
+
|
|
223
|
+
0
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda version`
|
|
6
|
+
class Version < Dry::CLI::Command
|
|
7
|
+
desc "Print the version and exit"
|
|
8
|
+
|
|
9
|
+
# @return [void]
|
|
10
|
+
def call(**) = puts("agentilda #{Agentilda::VERSION}")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli/autocomplete/command"
|
|
4
|
+
|
|
5
|
+
# One file per command, `subcommands/` under the prefixed ones. Base carries
|
|
6
|
+
# the shared flags and plumbing, so it loads first; the rest only meet each
|
|
7
|
+
# other here, at registration.
|
|
8
|
+
require_relative "cli/base"
|
|
9
|
+
require_relative "cli/create/create"
|
|
10
|
+
require_relative "cli/index/index"
|
|
11
|
+
require_relative "cli/list_plans/list_plans"
|
|
12
|
+
require_relative "cli/resync/subcommands/dirs"
|
|
13
|
+
require_relative "cli/resync/subcommands/prs"
|
|
14
|
+
require_relative "cli/linear/linear"
|
|
15
|
+
require_relative "cli/linear/subcommands/projects"
|
|
16
|
+
require_relative "cli/linear/subcommands/import"
|
|
17
|
+
require_relative "cli/docs/docs"
|
|
18
|
+
require_relative "cli/run/run"
|
|
19
|
+
require_relative "cli/unblock/unblock"
|
|
20
|
+
require_relative "cli/version/version"
|
|
21
|
+
require_relative "cli/states/states"
|
|
22
|
+
require_relative "cli/agents/subcommands/list"
|
|
23
|
+
require_relative "cli/agents/subcommands/describe"
|
|
24
|
+
|
|
25
|
+
module Agentilda
|
|
26
|
+
# The command line. Every command is a thin shell over one library class:
|
|
27
|
+
# it parses flags, calls one object, and prints the result.
|
|
28
|
+
#
|
|
29
|
+
# Two conventions hold throughout:
|
|
30
|
+
#
|
|
31
|
+
# * The deliverable goes to STDOUT, progress and boxes go to STDERR, so
|
|
32
|
+
# every command composes in a pipe.
|
|
33
|
+
# * Anything that writes to disk or to GitHub is DRY RUN by default and
|
|
34
|
+
# needs `--commit`. Folder names and pull request titles are joined on by
|
|
35
|
+
# branches, `pull-requests.md` and merged history; changing one silently
|
|
36
|
+
# is how a plan ends up filed under work it did not do.
|
|
37
|
+
module CLI
|
|
38
|
+
extend Dry::CLI::Registry
|
|
39
|
+
|
|
40
|
+
register "create", Create, aliases: %w[new c]
|
|
41
|
+
# Renamed from `status`, which read as "is the tool OK?" rather than "what
|
|
42
|
+
# plans are there?". The old spellings stay registered: other repos, agent
|
|
43
|
+
# prompts and scripts call this by name.
|
|
44
|
+
register "list-plans", ListPlans, aliases: %w[status st]
|
|
45
|
+
register "run", Run
|
|
46
|
+
register "unblock", Unblock
|
|
47
|
+
register "docs", Docs
|
|
48
|
+
register "states", States, aliases: %w[diagram]
|
|
49
|
+
register "index", Index, aliases: %w[idx]
|
|
50
|
+
register "version", Version, aliases: %w[--version -v]
|
|
51
|
+
|
|
52
|
+
register "agents" do |prefix|
|
|
53
|
+
prefix.register "list", Agents::List, aliases: %w[ls]
|
|
54
|
+
prefix.register "describe", Agents::Describe, aliases: %w[show]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `describe <name>` without the `agents` in front of it. The bare verb is
|
|
58
|
+
# how people ask for this out loud, and a near miss that prints usage is a
|
|
59
|
+
# worse answer than the thing they wanted.
|
|
60
|
+
register "describe", Agents::Describe
|
|
61
|
+
|
|
62
|
+
register "resync" do |prefix|
|
|
63
|
+
prefix.register "dirs", Resync::Dirs
|
|
64
|
+
prefix.register "prs", Resync::Prs
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
register "linear" do |prefix|
|
|
68
|
+
prefix.register "import", Linear::Import
|
|
69
|
+
prefix.register "projects", Linear::Projects
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
register "completion", ::Dry::CLI::Autocomplete::Command[::Agentilda::CLI]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
# Default option overrides, read from `~/.local/config/agentilda.json`.
|
|
7
|
+
#
|
|
8
|
+
# The file is keyed by command, so it can grow without every command
|
|
9
|
+
# seeing every key:
|
|
10
|
+
#
|
|
11
|
+
# {
|
|
12
|
+
# "run": { "timeout": 1800, "jobs": 4 }
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# Precedence is: an option passed on the command line, then this file,
|
|
16
|
+
# then the built-in default. The file supplies *defaults*, never
|
|
17
|
+
# mandates — nothing here can force `--commit`, which stays a flag a
|
|
18
|
+
# person types.
|
|
19
|
+
#
|
|
20
|
+
# An unreadable file is an error, not a shrug: a config silently ignored
|
|
21
|
+
# is how a timeout somebody set stops applying with no signal anywhere.
|
|
22
|
+
class Config
|
|
23
|
+
PATH = File.join(Dir.home, ".local", "config", "agentilda.json")
|
|
24
|
+
|
|
25
|
+
# @param command [Symbol, String] the command whose section to read
|
|
26
|
+
# @param path [String] the config file
|
|
27
|
+
# @return [Hash{Symbol => Object}] that command's overrides, {} when the
|
|
28
|
+
# file or the section is absent
|
|
29
|
+
# @raise [Agentilda::Error] when the file exists and cannot be used
|
|
30
|
+
def self.for(command, path: PATH)
|
|
31
|
+
return {} unless File.file?(path)
|
|
32
|
+
|
|
33
|
+
data = JSON.parse(File.read(path))
|
|
34
|
+
raise Error, "#{path}: expected a JSON object keyed by command" unless data.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
section = data.fetch(command.to_s, {})
|
|
37
|
+
raise Error, "#{path}: \"#{command}\" must be a JSON object" unless section.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
section.transform_keys(&:to_sym)
|
|
40
|
+
rescue JSON::ParserError => e
|
|
41
|
+
raise Error, "#{path} is not valid JSON: #{e.message.lines.first.to_s.strip}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# The side channel between a keypress and a running agent.
|
|
5
|
+
#
|
|
6
|
+
# `claude -p` takes no input once it starts, so the only way to reach an
|
|
7
|
+
# agent mid-flight is a file it has been told to poll: each invocation gets
|
|
8
|
+
# a control file of its own, named in its prompt, and a keypress writes a
|
|
9
|
+
# word into every file currently registered. Like everything else about the
|
|
10
|
+
# prompt this is a request — an agent deep in a tool call reads the file at
|
|
11
|
+
# its next step, not instantly — which is why {#quit!} also arms a deadline
|
|
12
|
+
# the {Executor} checks, so "quit" is eventually a guarantee too.
|
|
13
|
+
module Control
|
|
14
|
+
# What a keypress writes into a control file. One word, one line, so an
|
|
15
|
+
# agent can act on `File.read(path).strip` and nothing subtler.
|
|
16
|
+
WRAP_UP = "WRAP_UP"
|
|
17
|
+
STOP = "STOP"
|
|
18
|
+
|
|
19
|
+
# Seconds between {#quit!} and the harness terminating whatever is still
|
|
20
|
+
# running. Long enough to write files and a handoff note; short enough
|
|
21
|
+
# that q means quit rather than "quit eventually".
|
|
22
|
+
GRACE = 60
|
|
23
|
+
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@files = []
|
|
26
|
+
@quit = false
|
|
27
|
+
@deadline = nil
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Register a fresh control file for one invocation and hand back its
|
|
31
|
+
# path, to be named in the agent's prompt.
|
|
32
|
+
#
|
|
33
|
+
# @param dir [String] where the file lives (the trace dir — outside the
|
|
34
|
+
# repository for the same reason traces are)
|
|
35
|
+
# @param name [String] something findable: plan ordinal and agent name
|
|
36
|
+
# @return [String]
|
|
37
|
+
def register(dir, name)
|
|
38
|
+
FileUtils.mkdir_p(dir)
|
|
39
|
+
path = File.join(dir, "control-#{name}-#{Process.pid}-#{format("%04x", rand(0x10000))}")
|
|
40
|
+
File.write(path, "")
|
|
41
|
+
@mutex.synchronize { @files << path }
|
|
42
|
+
path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Forget a finished invocation's file. The file itself is removed so a
|
|
46
|
+
# crashed run does not leave stale STOPs for the next one to find.
|
|
47
|
+
#
|
|
48
|
+
# @param path [String]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def release(path)
|
|
51
|
+
@mutex.synchronize { @files.delete(path) }
|
|
52
|
+
FileUtils.rm_f(path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `w`: every running agent is asked to finish the essential remainder
|
|
56
|
+
# as fast as it can. The loop itself keeps going.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def wrap_up! = broadcast(WRAP_UP)
|
|
60
|
+
|
|
61
|
+
# `n`: every running agent is asked to write out what it has and end
|
|
62
|
+
# its turn. The loop keeps going — under chaining that is exactly what
|
|
63
|
+
# hands the plan to the next agent.
|
|
64
|
+
#
|
|
65
|
+
# @return [void]
|
|
66
|
+
def stop! = broadcast(STOP)
|
|
67
|
+
|
|
68
|
+
# `q`: {#stop!}, plus the loop ends after this round, plus a deadline —
|
|
69
|
+
# {GRACE} seconds from now — after which the {Executor} aborts whatever
|
|
70
|
+
# is still running rather than waiting on its goodwill.
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def quit!
|
|
74
|
+
stop!
|
|
75
|
+
@mutex.synchronize {
|
|
76
|
+
@quit = true
|
|
77
|
+
@deadline ||= UI.monotonic + GRACE
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Boolean] whether `q` has been pressed
|
|
82
|
+
def quit? = @mutex.synchronize { @quit }
|
|
83
|
+
|
|
84
|
+
# @return [Boolean] whether the grace period after `q` has run out
|
|
85
|
+
def overdue?
|
|
86
|
+
@mutex.synchronize { !@deadline.nil? && UI.monotonic > @deadline }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Back to rest, for the next `Runner#call` — and for the suite, where
|
|
90
|
+
# one example's q must not quit every example after it.
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
93
|
+
def reset!
|
|
94
|
+
@mutex.synchronize {
|
|
95
|
+
@files.each { |f| FileUtils.rm_f(f) }
|
|
96
|
+
@files.clear
|
|
97
|
+
@quit = false
|
|
98
|
+
@deadline = nil
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# @param word [String]
|
|
105
|
+
# @return [void]
|
|
106
|
+
def broadcast(word)
|
|
107
|
+
@mutex.synchronize { @files.dup }.each do |path|
|
|
108
|
+
File.write(path, "#{word}\n")
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
# A file whose invocation just finished is not an error to miss.
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Mints new plan folders. Backs `/spec-create`.
|
|
5
|
+
#
|
|
6
|
+
# Every failure is a `Failure`, never an exception: creating a folder is the
|
|
7
|
+
# one place a typo becomes permanent, so the caller is made to look.
|
|
8
|
+
class Creator
|
|
9
|
+
include Dry::Monads[:result]
|
|
10
|
+
|
|
11
|
+
# The state a new plan opens in — a specification and nothing else yet.
|
|
12
|
+
DEFAULT_STATUS = :new
|
|
13
|
+
|
|
14
|
+
# The state a retroactive plan is born in: the work is live, but it has
|
|
15
|
+
# neither a specification nor a plan.
|
|
16
|
+
RETROACTIVE_STATUS = :retroactive
|
|
17
|
+
|
|
18
|
+
# @param dir [String] the `.plans` directory
|
|
19
|
+
def initialize(dir:)
|
|
20
|
+
@dir = File.expand_path(dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String] the `.plans` directory
|
|
24
|
+
attr_reader :dir
|
|
25
|
+
|
|
26
|
+
# Create a plan folder.
|
|
27
|
+
#
|
|
28
|
+
# @param words [Array<String>] the topic; becomes the slug
|
|
29
|
+
# @param after [String, nil] anchor for a retroactive plan, e.g. "002"
|
|
30
|
+
# @param status [String, Symbol, nil] override the default state
|
|
31
|
+
# @param prs [Array<Hash>, nil] pull requests to record, already fetched;
|
|
32
|
+
# their presence is what makes the folder 🕰️ Retroactive rather than ⚪️
|
|
33
|
+
# @return [Dry::Monads::Result] Success(absolute path) or Failure(message)
|
|
34
|
+
def create(words:, after: nil, status: nil, prs: nil)
|
|
35
|
+
resolved = resolve_status(status, after) or
|
|
36
|
+
return Failure("unknown status: #{status}")
|
|
37
|
+
|
|
38
|
+
slug = self.class.slugify(words)
|
|
39
|
+
return Failure("the topic produced an empty slug") if slug.empty?
|
|
40
|
+
|
|
41
|
+
ordinal = after ? retroactive_ordinal(after) : Ordinal.next_major(existing)
|
|
42
|
+
return ordinal if ordinal.is_a?(Dry::Monads::Result)
|
|
43
|
+
|
|
44
|
+
build(ordinal, resolved, slug).fmap { |path| record_pull_requests(path, prs) }
|
|
45
|
+
rescue Agentilda::Error => e
|
|
46
|
+
Failure(e.message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<Agentilda::Ordinal>] every number already taken
|
|
50
|
+
def existing
|
|
51
|
+
@existing ||= begin
|
|
52
|
+
return [] unless File.directory?(dir)
|
|
53
|
+
|
|
54
|
+
Dir.children(dir)
|
|
55
|
+
.select { |c| File.directory?(File.join(dir, c)) }
|
|
56
|
+
.filter_map { |c| Ordinal.from_dirname(c) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Turn free text into the kebab tail of a folder name.
|
|
61
|
+
#
|
|
62
|
+
# @param words [Array<String>, String]
|
|
63
|
+
# @return [String] possibly empty, which the caller must reject
|
|
64
|
+
def self.slugify(words)
|
|
65
|
+
Array(words).join(" ").downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# @param status [String, Symbol, nil]
|
|
71
|
+
# @param after [String, nil]
|
|
72
|
+
# @return [Agentilda::Status, nil]
|
|
73
|
+
def resolve_status(status, after)
|
|
74
|
+
return Agentilda.status(status) if status
|
|
75
|
+
|
|
76
|
+
STATUS_BY_KEY.fetch(after ? RETROACTIVE_STATUS : DEFAULT_STATUS)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param after [String]
|
|
80
|
+
# @return [Agentilda::Ordinal, Dry::Monads::Result]
|
|
81
|
+
def retroactive_ordinal(after)
|
|
82
|
+
anchor = Ordinal.parse(after) or return Failure("not a plan number: #{after}")
|
|
83
|
+
|
|
84
|
+
unless existing.any? { |o| o.major == anchor.major }
|
|
85
|
+
return Failure("no plan #{format("%03d", anchor.major)} to anchor against — " \
|
|
86
|
+
"a retroactive plan names the plan its work landed after")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
Ordinal.next_minor(existing, major: anchor.major)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Write the pull requests the folder was created from, so 🕰️ Retroactive
|
|
93
|
+
# is justified the moment the folder exists — that state means "the work is
|
|
94
|
+
# live and undocumented", and it is the recorded pull requests that make
|
|
95
|
+
# the first half of that true.
|
|
96
|
+
#
|
|
97
|
+
# @param path [String] the new folder
|
|
98
|
+
# @param prs [Array<Hash>, nil]
|
|
99
|
+
# @return [String] the path, unchanged
|
|
100
|
+
def record_pull_requests(path, prs)
|
|
101
|
+
return path if prs.nil? || prs.empty?
|
|
102
|
+
|
|
103
|
+
File.write(File.join(path, PullRequests::FILENAME), PullRequests.render(prs))
|
|
104
|
+
path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @param ordinal [Agentilda::Ordinal]
|
|
108
|
+
# @param status [Agentilda::Status]
|
|
109
|
+
# @param slug [String]
|
|
110
|
+
# @return [Dry::Monads::Result]
|
|
111
|
+
def build(ordinal, status, slug)
|
|
112
|
+
target = File.join(dir, Agentilda.plan_dirname(ordinal, status, slug))
|
|
113
|
+
return Failure("already exists: #{File.basename(target)}") if File.exist?(target)
|
|
114
|
+
|
|
115
|
+
FileUtils.mkdir_p(target)
|
|
116
|
+
@existing = nil
|
|
117
|
+
Success(target)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Telling "this belongs to no specification" apart from "nobody could tell".
|
|
5
|
+
#
|
|
6
|
+
# Both end up without a plan number, and filing them under one marker loses
|
|
7
|
+
# the difference between a finished thought and an open question. A
|
|
8
|
+
# dependency bump genuinely implements no specification and never will —
|
|
9
|
+
# that is an assertion, and `[dev]` records it. A pull request that adds
|
|
10
|
+
# Schedule K-1 for four tax years plainly implements *something*; nothing
|
|
11
|
+
# here could work out what, and `[none]` records that instead, as a job left
|
|
12
|
+
# for a human rather than a verdict.
|
|
13
|
+
#
|
|
14
|
+
# The bar for `[dev]` is deliberately high. Marking real work as `[dev]`
|
|
15
|
+
# tells everyone afterwards it was never worth a plan, and nobody re-opens a
|
|
16
|
+
# question that looks settled. `[none]` is visibly unfinished, so the cost of
|
|
17
|
+
# guessing wrong in that direction is somebody spending a minute on it.
|
|
18
|
+
module DevWork
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Titles that say what they are: dependency bumps, release chores, CI.
|
|
22
|
+
TITLES = [
|
|
23
|
+
/\Abump\s+\S+\s+from\s+\S+\s+to\s+\S+/i,
|
|
24
|
+
/\A(?:chore|ci|build|deps|style|refactor)(?:\([^)]*\))?:/i,
|
|
25
|
+
/\Abump\s+(?:to\s+)?v?\d+\.\d+/i
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Paths that are how the project is built rather than what it does.
|
|
29
|
+
PLUMBING = %r{\A(?:\.github/|\.circleci/|\.devcontainer/|bin/|scripts/|
|
|
30
|
+
Dockerfile|\.dockerignore|Gemfile(?:\.lock)?|justfile|Rakefile|
|
|
31
|
+
\.rubocop\.ya?ml|\.standard\.ya?ml|\.gitignore|\.tool-versions)}x
|
|
32
|
+
|
|
33
|
+
# Files that say nothing either way, so they neither prove plumbing nor
|
|
34
|
+
# disprove it — every pull request in this tool's world touches `.plans`.
|
|
35
|
+
NEUTRAL = %r{\A(?:#{PLANS_DIR}/|CHANGELOG|README)}
|
|
36
|
+
|
|
37
|
+
# @param title [String]
|
|
38
|
+
# @param files [Array<String>] paths in the diff
|
|
39
|
+
# @return [Boolean] whether this can be *asserted* to implement no plan
|
|
40
|
+
def developer?(title, files = [])
|
|
41
|
+
return true if TITLES.any? { |pattern| title.to_s.match?(pattern) }
|
|
42
|
+
|
|
43
|
+
judged = Array(files).reject { |path| path.match?(NEUTRAL) }
|
|
44
|
+
return false if judged.empty?
|
|
45
|
+
|
|
46
|
+
judged.all? { |path| path.match?(PLUMBING) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param title [String]
|
|
50
|
+
# @param files [Array<String>]
|
|
51
|
+
# @return [String] the marker to stamp on it
|
|
52
|
+
def marker(title, files = []) = developer?(title, files) ? NO_PLAN_PREFIX : NONE_PREFIX
|
|
53
|
+
end
|
|
54
|
+
end
|