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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # Draining a plan's `blocked.md`, and saying what happened.
5
+ #
6
+ # `unblock` is the one command a human runs *because* they know something the
7
+ # tool cannot observe: an answer has arrived. So it owes them an account of
8
+ # what it did with it. Four things can happen to a plan named on the command
9
+ # line, and only one of them is the interesting one:
10
+ #
11
+ # * the number names no folder,
12
+ # * the folder is not blocked and there is nothing to drain,
13
+ # * the folder is blocked, and some, none or all of its questions fold in,
14
+ # * `blocked.md` is there but numbers nothing `## B<n>`, so no program can
15
+ # read it at all.
16
+ #
17
+ # Each is reported differently, because each needs a different thing done
18
+ # next. This object works out which one happened; {CLI::Unblock} prints it.
19
+ class Unblocker
20
+ # One open question, as `blocked.md` writes it.
21
+ #
22
+ # @!attribute [r] number
23
+ # @return [Integer] the `n` in `## B<n>`
24
+ # @!attribute [r] heading
25
+ # @return [String] the heading text, hashes stripped
26
+ # @!attribute [r] answered
27
+ # @return [Boolean] whether an `## A<n>` of the same number is waiting
28
+ Question = Data.define(:number, :heading, :answered) do
29
+ # @return [String]
30
+ def to_s = answered ? "#{heading} ← answer waiting" : heading
31
+ end
32
+
33
+ # One token from the command line, once the tree has had a look at it.
34
+ #
35
+ # @!attribute [r] token
36
+ # @return [String] what the user typed
37
+ # @!attribute [r] subject
38
+ # @return [Agentilda::Subject, nil]
39
+ # @!attribute [r] problem
40
+ # @return [Symbol, nil] :missing, :not_blocked, or nil when it is drainable
41
+ Target = Data.define(:token, :subject, :problem) do
42
+ # @return [Boolean]
43
+ def drainable? = problem.nil?
44
+ end
45
+
46
+ # What became of one plan.
47
+ #
48
+ # @!attribute [r] subject
49
+ # @return [Agentilda::Subject] as the folder stands *now*
50
+ # @!attribute [r] ok
51
+ # @return [Boolean] whether the agent ran without failing
52
+ # @!attribute [r] note
53
+ # @return [String] one line about how it went
54
+ # @!attribute [r] before
55
+ # @return [Array<Question>] open before the agent ran
56
+ # @!attribute [r] after
57
+ # @return [Array<Question>] open now
58
+ Outcome = Data.define(:subject, :ok, :note, :before, :after) do
59
+ # @return [Agentilda::Ordinal]
60
+ def ordinal = subject.feature.ordinal
61
+
62
+ # @return [Array<Integer>] the questions this run actually disposed of
63
+ def folded = before.map(&:number) - after.map(&:number)
64
+
65
+ # @return [Boolean] `blocked.md` is gone, so the plan is out of the block
66
+ def cleared? = !subject.file?("blocked.md")
67
+
68
+ # @return [Boolean] a `blocked.md` no program can read
69
+ def unreadable? = subject.unreadable_block?
70
+
71
+ # @return [Array<Question>] still open, with an answer sitting unfolded
72
+ def waiting = after.select(&:answered)
73
+ end
74
+
75
+ # The questions a folder still names, in the order the file names them.
76
+ #
77
+ # @param subject [Agentilda::Subject]
78
+ # @return [Array<Question>]
79
+ def self.questions(subject)
80
+ answered = subject.block_answers
81
+
82
+ subject.read("blocked.md").to_s.lines.grep(OPEN_BLOCK).map do |line|
83
+ number = line[OPEN_BLOCK, 1].to_i
84
+ Question.new(number:, heading: line.strip.sub(/\A\#+[ \t]*/, ""), answered: answered.include?(number))
85
+ end
86
+ end
87
+
88
+ # @param tree [Agentilda::Tree]
89
+ # @param agent [Agentilda::Agent]
90
+ # @param executor [#call] receives (agent, subject, root:) and yields each
91
+ # phrase describing what the agent is doing
92
+ # @param root [String] the repository the agent works in
93
+ # @param commit [Boolean] false plans the invocation and changes nothing
94
+ def initialize(tree:, agent:, executor:, root: nil, commit: false)
95
+ @tree = tree
96
+ @agent = agent
97
+ @executor = executor
98
+ @root = root || File.dirname(tree.dir)
99
+ @commit = commit
100
+ end
101
+
102
+ # @return [Agentilda::Tree]
103
+ attr_reader :tree
104
+
105
+ # @return [Agentilda::Agent]
106
+ attr_reader :agent
107
+
108
+ # @return [String]
109
+ attr_reader :root
110
+
111
+ # @return [Boolean]
112
+ def commit? = @commit
113
+
114
+ # Work out what each number on the command line refers to.
115
+ #
116
+ # Nothing is rejected here and nothing exits: a token that names no folder
117
+ # is as much a thing the user needs told about as a folder that drained, and
118
+ # stopping at the first bad one hides the rest of the report.
119
+ #
120
+ # The gate is `blocked.md` itself, not the folder's emoji. ⭕️ is *derived*
121
+ # from the file, so gating on the emoji made the tool circular: a
122
+ # `blocked.md` whose questions are not numbered `## B<n>` never earns the
123
+ # emoji, so `unblock` refused it, so the file could never drain and the
124
+ # folder could never leave 🟡.
125
+ #
126
+ # @param tokens [Array<String>] `NNN`, `NNN.MM`, or several comma separated
127
+ # @return [Array<Target>]
128
+ def resolve(tokens)
129
+ tokens.flat_map { |token| token.to_s.split(",") }.map(&:strip).reject(&:empty?).map do |token|
130
+ subject = tree.find(token)
131
+ next Target.new(token:, subject: nil, problem: :missing) if subject.nil?
132
+ next Target.new(token:, subject:, problem: :not_blocked) unless subject.file?("blocked.md")
133
+
134
+ Target.new(token:, subject:, problem: nil)
135
+ end
136
+ end
137
+
138
+ # Hand each folder to the agent, one at a time, and read the file back.
139
+ #
140
+ # What the agent claims it did is not evidence. The questions are counted
141
+ # off disk before and after, so "folded B3" means the heading is gone from
142
+ # `blocked.md`, not that an agent said so.
143
+ #
144
+ # @param subjects [Array<Agentilda::Subject>]
145
+ # @return [Array<Outcome>]
146
+ def call(subjects)
147
+ return [] if subjects.empty?
148
+
149
+ before = subjects.map { |subject| [subject.feature.ordinal.to_s, self.class.questions(subject)] }.to_h
150
+ results = UI.concurrently(subjects, headline(subjects), jobs: 1, label: method(:label),
151
+ fields: method(:log_fields)) do |subject, progress|
152
+ invoke(subject, &progress)
153
+ end
154
+
155
+ settle
156
+ subjects.zip(results).map { |subject, result| outcome(subject, result, before) }
157
+ end
158
+
159
+ private
160
+
161
+ # @param subject [Agentilda::Subject]
162
+ # @yieldparam phrase [String] what the agent is doing, as it changes
163
+ # @return [Array(Boolean, String)]
164
+ def invoke(subject, &on_activity)
165
+ @executor.call(agent, subject, root:, &on_activity)
166
+ rescue => e
167
+ [false, "#{e.class}: #{e.message.lines.first.to_s.strip}"]
168
+ end
169
+
170
+ # The same pass the run loop makes after every round, for the same reason:
171
+ # a drained folder is only *named* differently once something reads the
172
+ # file the agent just deleted.
173
+ #
174
+ # @return [void]
175
+ def settle
176
+ Resync::Dirs.new(tree:).call(commit: true) if commit?
177
+ tree.reload
178
+ end
179
+
180
+ # @param subject [Agentilda::Subject] as it was before the run
181
+ # @param result [Array, Exception]
182
+ # @param before [Hash{String => Array<Question>}]
183
+ # @return [Outcome]
184
+ def outcome(subject, result, before)
185
+ ok, note = result.is_a?(Exception) ? [false, result.message.lines.first.to_s.strip] : result
186
+ current = tree.find(subject.feature.ordinal) || subject
187
+
188
+ Outcome.new(subject: current, ok: !!ok, note: note.to_s,
189
+ before: before.fetch(subject.feature.ordinal.to_s, []), after: self.class.questions(current))
190
+ end
191
+
192
+ # @param subjects [Array<Agentilda::Subject>]
193
+ # @return [String]
194
+ def headline(subjects)
195
+ "#{commit? ? "handing" : "would hand"} #{subjects.size} plan#{"s" unless subjects.size == 1} " \
196
+ "to #{agent.name} in #{root}"
197
+ end
198
+
199
+ # @param subject [Agentilda::Subject]
200
+ # @return [String]
201
+ def label(subject) = "#{subject.feature.ordinal} → #{agent.name}"
202
+
203
+ # @param subject [Agentilda::Subject]
204
+ # @return [Hash] the columns this plan's log lines carry
205
+ def log_fields(subject) = {plan: subject.feature.ordinal.to_s, status: subject.status.to_s, agent: agent.name}
206
+ end
207
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Alone in its own file, requiring nothing, so the gemspec can read the version
4
+ # without loading the library — and therefore without the library's own
5
+ # dependencies having to be installed before the gemspec can be evaluated.
6
+ #
7
+ # © 2026 Konstantin Gredeskoul
8
+ module Agentilda
9
+ VERSION = "1.0.3"
10
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # Hands Markdown files to a renderer outside this process: the system
5
+ # viewer via `open`, or mdfried in the terminal.
6
+ #
7
+ # Both paths shell out, so the class takes its two effects as lambdas and
8
+ # the specs assert on the commands instead of launching viewers on the box
9
+ # running the suite.
10
+ class Viewer
11
+ # What to run when mdfried is asked for but not installed. Homebrew is the
12
+ # one package manager the toolchain already assumes (see the Brewfile
13
+ # convention), so there is no fallback chain to get wrong.
14
+ INSTALL = "brew install mdfried >/dev/null"
15
+
16
+ # How to notice mdfried is missing without depending on `which`, which
17
+ # differs across shells; `command -v` is POSIX.
18
+ DETECT = "command -v mdfried >/dev/null"
19
+
20
+ # @param launch [#call] runs a command, returns truthy on success
21
+ # @param pipe [#call] runs a command, yielding its stdin as an IO
22
+ def initialize(launch: ->(*cmd) { system(*cmd) },
23
+ pipe: ->(cmd, &block) { IO.popen(cmd, "w", &block) })
24
+ @launch = launch
25
+ @pipe = pipe
26
+ end
27
+
28
+ # Opens each file in whatever the system has configured for Markdown.
29
+ #
30
+ # @param paths [Array<String>]
31
+ # @return [void]
32
+ def open(paths)
33
+ Array(paths).each { |path| @launch.call("open", path) }
34
+ end
35
+
36
+ # Streams each file's content into mdfried, installing it first when it
37
+ # is missing.
38
+ #
39
+ # @param paths [Array<String>]
40
+ # @return [void]
41
+ # @raise [Agentilda::Error] when mdfried is absent and brew cannot fix that
42
+ def mdfried(paths)
43
+ ensure_mdfried!
44
+ Array(paths).each do |path|
45
+ @pipe.call("mdfried") { |io| io.write(File.read(path)) }
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # @return [void]
52
+ # @raise [Agentilda::Error]
53
+ def ensure_mdfried!
54
+ return if @launch.call(DETECT)
55
+
56
+ @launch.call(INSTALL) or
57
+ raise Error, "mdfried is not installed, and `brew install mdfried` failed"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # A git worktree per plan, so agents working on different plans cannot
5
+ # collide at all.
6
+ #
7
+ # A lock coordinates a shared tree; a worktree removes the sharing. That
8
+ # matters more than it sounds for an agent loop: two agents editing one
9
+ # checkout produce no git conflict — same branch, same files — so the last
10
+ # writer simply wins and the loser's work vanishes with nothing anywhere to
11
+ # say it happened.
12
+ #
13
+ # The branch carries the plan number (`<user>/NNN.MM-slug`), which is not
14
+ # decoration: it is the first thing `resync prs` reads when deciding which
15
+ # plan a pull request implements. Naming branches this way means the number
16
+ # carries itself from worktree creation through to a merged pull request with
17
+ # nobody having to remember it.
18
+ class Worktree
19
+ # `bin/setup-worktree`, which copies in the ignored-but-required files a new
20
+ # checkout does not get. Shelling out rather than reimplementing the rules
21
+ # here keeps one answer to "what does a worktree need", and keeps that answer
22
+ # usable from a shell on a machine that has never run `bundle install`.
23
+ SEEDER = File.expand_path("../../bin/setup-worktree", __dir__)
24
+
25
+ # One plan's isolated checkout.
26
+ #
27
+ # @!attribute [r] ordinal
28
+ # @return [Agentilda::Ordinal]
29
+ # @!attribute [r] branch
30
+ # @return [String]
31
+ # @!attribute [r] path
32
+ # @return [String] absolute
33
+ # @!attribute [r] created
34
+ # @return [Boolean] false when an existing worktree was reused
35
+ Checkout = Data.define(:ordinal, :branch, :path, :created) do
36
+ # @return [Boolean] whether the agent left anything behind
37
+ def dirty? = !`git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
38
+
39
+ # @return [String] the plans directory inside this checkout
40
+ def plans_dir = File.join(path, Agentilda::PLANS_DIR)
41
+
42
+ # @return [Boolean] whether this round made it, rather than reusing one
43
+ def created? = created
44
+ end
45
+
46
+ # @param root [String] the main repository
47
+ # @param dir [String, nil] where worktrees go; defaults to a sibling
48
+ # @param user [String] branch namespace
49
+ def initialize(root:, dir: nil, user: ENV["USER"] || "agent")
50
+ @root = File.expand_path(root)
51
+ @user = user
52
+ @dir = File.expand_path(dir || default_dir)
53
+ end
54
+
55
+ # @return [String] the main repository
56
+ attr_reader :root
57
+
58
+ # @return [String] where worktrees are kept
59
+ attr_reader :dir
60
+
61
+ # @return [Boolean] whether root is a git repository at all
62
+ def repository? = system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
63
+
64
+ # Get, or create, the isolated checkout for one plan.
65
+ #
66
+ # The checkout is seeded before it is handed back, on both paths. `git
67
+ # worktree add` brings every TRACKED file and nothing else, so an agent
68
+ # given a bare one cannot run the suite it was told to keep green: a Rails
69
+ # project dies on `MissingKeyError` because `config/credentials/*.key` is
70
+ # gitignored. The agent then reports a broken checkout as a broken plan,
71
+ # which is the worst failure this loop has, because the diagnosis points
72
+ # at the code rather than at the tree.
73
+ #
74
+ # A reused worktree is seeded too. {#seed} only ever adds a missing file,
75
+ # so running it again costs one fast subprocess and repairs the worktrees
76
+ # that were created before any of this existed.
77
+ #
78
+ # @param feature [Agentilda::Feature]
79
+ # @return [Agentilda::Worktree::Checkout]
80
+ # @raise [Agentilda::Error] when git refuses
81
+ def checkout_for(feature)
82
+ branch = branch_for(feature)
83
+ path = File.join(dir, "#{feature.ordinal}-#{feature.slug}")
84
+
85
+ if File.directory?(path)
86
+ seed(path)
87
+ return Checkout.new(ordinal: feature.ordinal, branch:, path:, created: false)
88
+ end
89
+
90
+ # git keeps a registration for a worktree whose directory was deleted by
91
+ # hand, marks it `prunable`, and then refuses to create a new one at that
92
+ # path. Anybody who has ever `rm -rf`d a worktree meets this, and the
93
+ # error says only "already exists" about a directory that does not.
94
+ forget_stale
95
+
96
+ FileUtils.mkdir_p(dir)
97
+ add(branch, path)
98
+ seed(path)
99
+ Checkout.new(ordinal: feature.ordinal, branch:, path:, created: true)
100
+ end
101
+
102
+ # Copy in the files git ignores and a new checkout therefore lacks: `.env`
103
+ # and friends, and credential keys.
104
+ #
105
+ # Never fatal. A repository with no ignored files is perfectly normal, and
106
+ # a plan is not worth abandoning over a seeding step, so this reports and
107
+ # carries on. It runs `--quiet`, which prints nothing on success and leaves
108
+ # a real failure on STDERR where the loop's other progress goes.
109
+ #
110
+ # @param path [String] the worktree to seed
111
+ # @return [Boolean] whether the seeder ran and succeeded
112
+ # `$stderr` rather than `Kernel#warn`, matching {Agentilda::UI}, which
113
+ # writes its progress to `$stderr` too. Style/StderrPuts prefers warn so the
114
+ # output can be silenced, but warn reaches file descriptor 2 through
115
+ # `Warning.warn` and ignores a reassigned `$stderr` entirely:
116
+ #
117
+ # $stderr = StringIO.new; warn "x"; $stderr.string # => ""
118
+ #
119
+ # So anything capturing this loop's output, the suite included, would never
120
+ # see a seeding failure. Being silenceable is worth less than being seen.
121
+ # rubocop: disable Style/StderrPuts
122
+ def seed(path)
123
+ unless File.executable?(SEEDER)
124
+ $stderr.puts "worktree: #{SEEDER} is missing, so #{path} has no .env or credential keys"
125
+ return false
126
+ end
127
+
128
+ return true if system(SEEDER, "--quiet", path, out: File::NULL)
129
+
130
+ $stderr.puts "worktree: could not seed #{path}; its suite may fail on a missing key"
131
+ false
132
+ end
133
+
134
+ # rubocop: enable Style/StderrPuts
135
+
136
+ # @param feature [Agentilda::Feature]
137
+ # @return [String] e.g. "kig/002.00-tenancy-households"
138
+ def branch_for(feature) = "#{@user}/#{feature.ordinal}-#{feature.slug}"
139
+
140
+ # Every worktree this class manages, as git sees them.
141
+ #
142
+ # @return [Array<String>] absolute paths
143
+ def list
144
+ # Compare canonical paths. git reports resolved ones, and on macOS the
145
+ # temp and home trees run through symlinks (/var -> /private/var), so a
146
+ # raw string compare matches nothing and prune silently does nothing.
147
+ mine = canonical(dir)
148
+
149
+ `git -C #{root.shellescape} worktree list --porcelain 2>/dev/null`
150
+ .lines(chomp: true)
151
+ .filter_map { |l| l.delete_prefix("worktree ") if l.start_with?("worktree ") }
152
+ .select { |p| canonical(p).start_with?(mine) }
153
+ end
154
+
155
+ # @param path [String]
156
+ # @return [String] the symlink-resolved path, or the input when it is gone
157
+ def canonical(path)
158
+ File.realpath(path)
159
+ rescue Errno::ENOENT
160
+ path
161
+ end
162
+
163
+ # Remove worktrees the agents left untouched.
164
+ #
165
+ # An agent that ran and changed nothing has produced a checkout that is
166
+ # pure cost: it looks like work in progress and is not. Dirty ones are
167
+ # always kept — that is the output.
168
+ #
169
+ # @return [Array<String>] paths removed
170
+ def prune
171
+ list.select { |path| clean?(path) }.each { |path| remove(path) }
172
+ end
173
+
174
+ # @param path [String]
175
+ # @return [void]
176
+ def remove(path)
177
+ system("git", "-C", root, "worktree", "remove", "--force", path, out: File::NULL, err: File::NULL)
178
+ end
179
+
180
+ private
181
+
182
+ # Sibling of the repository, not inside it: a worktree under the repo shows
183
+ # up in its own `git status` and in every glob anybody writes.
184
+ #
185
+ # @return [String]
186
+ def default_dir = "#{@root}.worktrees"
187
+
188
+ # Drop registrations whose directory is gone.
189
+ #
190
+ # @return [void]
191
+ def forget_stale
192
+ system("git", "-C", root, "worktree", "prune", out: File::NULL, err: File::NULL)
193
+ end
194
+
195
+ # @param path [String]
196
+ # @return [Boolean]
197
+ def clean?(path) = `git -C #{path.shellescape} status --porcelain 2>/dev/null`.strip.empty?
198
+
199
+ # @param branch [String]
200
+ # @param path [String]
201
+ # @return [void]
202
+ def add(branch, path)
203
+ exists = system("git", "-C", root, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
204
+ args = exists ? ["worktree", "add", path, branch] : ["worktree", "add", "-b", branch, path]
205
+
206
+ return if system("git", "-C", root, *args, out: File::NULL, err: File::NULL)
207
+
208
+ raise Error, "could not create a worktree for #{branch} at #{path}"
209
+ end
210
+ end
211
+ end
data/lib/agentilda.rb ADDED
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "dry/cli/banner"
5
+ require "dry/monads"
6
+ require "dry/inflector"
7
+ require "pastel"
8
+ require "unicode/display_width"
9
+ require "strings"
10
+ require "tty/box"
11
+ require "tty/command"
12
+ require "tty/screen"
13
+ require "tty/progressbar"
14
+ require "tty/spinner"
15
+ require "concurrent/array"
16
+ require "concurrent/hash"
17
+ require "etc"
18
+ require "fileutils"
19
+ require "tempfile"
20
+ require "tmpdir"
21
+ require "shellwords"
22
+ require "parallel"
23
+
24
+ # Spec → Plan → Build.
25
+ #
26
+ # Three phases, each with a file that proves it happened, and a guarded state
27
+ # machine that refuses to let a plan folder claim a phase it has not reached.
28
+ #
29
+ # spec ⚪️ New spec.md
30
+ # plan ⭐️ Ready plan.md
31
+ # build 🟡 → ✅ pull-requests.md
32
+ #
33
+ # The conventions are not documented anywhere else by hand: the status
34
+ # vocabulary, the numbering rules and the transition table live here and are
35
+ # emitted by `agentilda docs`. Three hand-maintained copies of that table
36
+ # have already drifted apart, which is why there is now exactly one.
37
+ #
38
+ require_relative "agentilda/version"
39
+
40
+ # © 2026 Konstantin Gredeskoul
41
+ module Agentilda
42
+ # The folder every project keeps its plans in.
43
+ PLANS_DIR = ".plans"
44
+
45
+ # Prefix for a pull request that deliberately implements no plan —
46
+ # dependency bumps, CI work, hotfixes, developer tooling.
47
+ #
48
+ # Lower case, and not a number, so it cannot be mistaken for one. It used to
49
+ # be `DEV.00`, which read as a plan number and sorted among them.
50
+ NO_PLAN_PREFIX = "dev"
51
+
52
+ # What it used to be. Titles still wearing it are rewritten rather than
53
+ # skipped as already-prefixed.
54
+ STALE_NO_PLAN_PREFIX = "DEV.00"
55
+
56
+ # Prefix for a pull request that implements *something* nothing could name.
57
+ # Unlike `dev` this asserts nothing; it marks a question left open.
58
+ NONE_PREFIX = "none"
59
+
60
+ # @return [Dry::Inflector] shared inflector
61
+ def self.inflector = @inflector ||= Dry::Inflector.new
62
+
63
+ # Move a directory, preferring `git mv` so its history follows it.
64
+ #
65
+ # Both the state machine and `resync dirs` move plan folders, and a folder
66
+ # that loses its history because one of them used `FileUtils` is a folder
67
+ # nobody can `git log`.
68
+ #
69
+ # @param source [String] absolute
70
+ # @param target [String] absolute
71
+ # @return [Boolean] false when the target was already occupied
72
+ def self.move_directory(source, target)
73
+ return false if File.exist?(target)
74
+
75
+ parent = File.dirname(source)
76
+ tracked = system("git", "-C", parent, "ls-files", "--error-unmatch", source,
77
+ out: File::NULL, err: File::NULL)
78
+ moved = tracked && system("git", "-C", parent, "mv", source, target,
79
+ out: File::NULL, err: File::NULL)
80
+ FileUtils.mv(source, target) unless moved
81
+ true
82
+ end
83
+
84
+ # Plan numbers visible in `.plans` on a git ref, without checking it out.
85
+ #
86
+ # A pull request's branch is the only place that knows how far the sequence
87
+ # had got when the work started, and branches are not all rebased onto the
88
+ # same main. Reading the ref directly costs one `ls-tree` and no worktree.
89
+ #
90
+ # @param root [String] repository root
91
+ # @param ref [String] a branch name; `origin/` is tried first
92
+ # @return [Array<Agentilda::Ordinal>] possibly empty
93
+ def self.plans_on_ref(root, ref)
94
+ ["origin/#{ref}", ref].each do |candidate|
95
+ out = `git -C #{root.shellescape} ls-tree -d --name-only #{candidate.shellescape} #{PLANS_DIR}/ 2>/dev/null`
96
+ next if out.to_s.strip.empty?
97
+
98
+ return out.lines.filter_map { |line| Ordinal.from_dirname(File.basename(line.strip)) }
99
+ end
100
+ []
101
+ end
102
+
103
+ # The agent that writes a specification for work that already shipped.
104
+ # Named here rather than in the CLI so the definition file stays the single
105
+ # source of truth about who does what.
106
+ RETROACTIVE_WRITER = "yoda-writer"
107
+
108
+ class Error < StandardError; end
109
+ end
110
+
111
+ # Each component is required only once it exists, so the suite loads — and
112
+ # stays a useful red/green signal — while the rest is being built. Drop the
113
+ # `File.exist?` guard once every file below is in place.
114
+ %w[
115
+ ui
116
+ config
117
+ ordinal
118
+ status
119
+ progress_log
120
+ state_machine
121
+ dev_work
122
+ frontmatter
123
+ markdown
124
+ pull_request
125
+ description
126
+ feature
127
+ github
128
+ tree
129
+ creator
130
+ brief
131
+ adoption
132
+ resolver
133
+ resync
134
+ reporter
135
+ tally
136
+ index
137
+ linear
138
+ agent
139
+ transcript
140
+ roster
141
+ viewer
142
+ worktree
143
+ publisher
144
+ control
145
+ keyboard
146
+ executor
147
+ runner
148
+ unblocker
149
+ documentation
150
+ diagram
151
+ cli
152
+ ].each do |component|
153
+ path = File.join(__dir__, "agentilda", "#{component}.rb")
154
+ require path if File.exist?(path)
155
+ end