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,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