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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda linear …`
6
+ module Linear
7
+ # `linear projects` — what the team already has.
8
+ class Projects < Team
9
+ desc "List the Linear projects a team owns, and which plans they already cover"
10
+
11
+ example [
12
+ "TAX # every project the team owns, matched against .plans"
13
+ ]
14
+
15
+ # @param team [String]
16
+ # @param options [Hash]
17
+ # @return [void]
18
+ def call(team:, **options)
19
+ tree = tree_for(options)
20
+ _api, survey = survey_for(team, tree)
21
+
22
+ survey.projects.each { |project| puts row(project) }
23
+ return if quiet?(options)
24
+
25
+ survey.projects.each do |project|
26
+ say("#{paint(project["name"], :cyan)}\n #{paint(project["url"].to_s, :bright_black)}")
27
+ end
28
+ report_descriptions(survey)
29
+ rescue Agentilda::Error => e
30
+ refuse(e.message, 69)
31
+ end
32
+
33
+ private
34
+
35
+ # @param project [Hash]
36
+ # @return [String]
37
+ def row(project)
38
+ [project["name"], project.dig("status", "name") || "-", project["url"]].join("\t")
39
+ end
40
+
41
+ # @param survey [Agentilda::Linear::Survey]
42
+ # @return [void]
43
+ def report_descriptions(survey)
44
+ return if survey.undescribed.empty?
45
+
46
+ warn("#{survey.undescribed.size} of these say nothing about themselves:\n\n" \
47
+ "#{survey.undescribed.map { |p| " #{p["name"]}" }.join("\n")}\n\n" \
48
+ "A name is three or four words and half of them are the company's. If you " \
49
+ "want anything here to reason about which project a plan belongs to, that " \
50
+ "reasoning has to have a sentence to read.")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda list-plans` — backs /plan-status.
6
+ class ListPlans < Base
7
+ desc "Print every plan, its state, and its pull requests"
8
+
9
+ example ["", "-D .plans", "| less -R"]
10
+
11
+ # @param options [Hash]
12
+ # @return [void]
13
+ def call(**options)
14
+ reporter = Reporter.new(tree: tree_for(options))
15
+ $stdout.write(reporter.render)
16
+
17
+ exit 1 unless reporter.inconsistent.empty?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda resync …`
6
+ module Resync
7
+ # `resync dirs` — reconcile folder names with folder contents.
8
+ class Dirs < Base
9
+ desc "Rename plan folders so the name matches the contents and the NNN.MM form"
10
+
11
+ option :commit, type: :boolean, default: false,
12
+ desc: "Actually rename the folders (default: dry run)"
13
+
14
+ example [
15
+ " # show what would be renamed",
16
+ "--commit # do it"
17
+ ]
18
+
19
+ # @param options [Hash]
20
+ # @return [void]
21
+ def call(**options)
22
+ changes = Agentilda::Resync::Dirs.new(tree: tree_for(options))
23
+ .call(commit: commit?(options))
24
+
25
+ if changes.empty?
26
+ success("Every folder is already named NNN.MM-<emoji>-<slug> and the emoji matches.") unless quiet?(options)
27
+ return
28
+ end
29
+
30
+ changes.each do |change|
31
+ puts "#{change.dirname}\t#{File.basename(change.target)}\t#{change.reason}"
32
+ next if quiet?(options)
33
+
34
+ say("#{paint(change.dirname, :bright_black)} → #{File.basename(change.target)}")
35
+ say(" #{paint(change.reason, :yellow)}", bullet: " ")
36
+ end
37
+
38
+ return if quiet?(options)
39
+
40
+ if commit?(options)
41
+ success("Renamed #{changes.size} folder#{"s" unless changes.size == 1}.")
42
+ else
43
+ dry_run_footer(changes.size, "rename#{"s" unless changes.size == 1}")
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda resync …`
6
+ module Resync
7
+ # `resync prs` — put an [NNN.MM] prefix on every pull request title.
8
+ class Prs < Base
9
+ desc "Add missing [NNN.MM] prefixes to pull request titles"
10
+
11
+ option :commit, type: :boolean, default: false,
12
+ desc: "Actually retitle the pull requests (default: dry run)"
13
+ option :state, default: "all", values: %w[open closed merged all],
14
+ desc: "Which pull requests to consider"
15
+ option :adopt, type: :boolean, default: true,
16
+ desc: "Mint a retroactive plan folder for every pull request that resolves to none. --no-adopt flags them for a human instead"
17
+
18
+ example [
19
+ " # show what would be retitled, and what would be adopted",
20
+ "--state open # only open pull requests",
21
+ "--no-adopt # never create a folder; flag the unresolvable ones",
22
+ "--commit # do it"
23
+ ]
24
+
25
+ # @param options [Hash]
26
+ # @return [void]
27
+ def call(**options)
28
+ github = GitHub.new
29
+ tree = tree_for(options)
30
+ changes = Agentilda::Resync::Prs.new(tree:, github:,
31
+ adopt: options.fetch(:adopt, true), root: File.dirname(tree.dir))
32
+ .call(commit: commit?(options))
33
+
34
+ if changes.empty?
35
+ success("Every pull request title already carries a prefix.") unless quiet?(options)
36
+ return
37
+ end
38
+
39
+ changes.each do |change|
40
+ puts "#{change.number}\t#{change.new_title || "-"}\t#{change.reason}"
41
+ end
42
+
43
+ return if quiet?(options)
44
+
45
+ report_pr_changes(changes, options)
46
+ rescue Agentilda::Error => e
47
+ refuse(e.message, 69)
48
+ end
49
+
50
+ private
51
+
52
+ # @param changes [Array]
53
+ # @param options [Hash]
54
+ # @return [void]
55
+ def report_pr_changes(changes, options)
56
+ applicable, flagged = changes.partition(&:applicable?)
57
+ assumed = applicable.select(&:assumed?)
58
+
59
+ applicable.each do |c|
60
+ note = if c.adopted?
61
+ paint(" (new plan)", :magenta)
62
+ elsif c.assumed?
63
+ paint(" (assumed)", :yellow)
64
+ end
65
+ say("##{c.number} #{c.new_title}#{note}")
66
+ end
67
+
68
+ report_adoptions(applicable.select(&:adopted?), options)
69
+
70
+ flagged.each { |c| say("##{c.number} #{paint("SKIPPED", :red)} — #{c.reason}", bullet: "!") }
71
+
72
+ if commit?(options)
73
+ success("Retitled #{applicable.size} pull request#{"s" unless applicable.size == 1}.")
74
+ else
75
+ dry_run_footer(applicable.size, "retitle#{"s" unless applicable.size == 1}")
76
+ end
77
+
78
+ return if assumed.empty?
79
+
80
+ warn("#{assumed.size} title#{"s" unless assumed.size == 1} would get " \
81
+ "[#{Agentilda::NO_PLAN_PREFIX}] because no plan resolved.\n\n" \
82
+ "That is an assertion about intent, and it is yours to make: check each one\n" \
83
+ "before committing. A pull request that writes a plan's spec belongs to that\n" \
84
+ "plan however its branch was named.")
85
+ end
86
+
87
+ # Creating folders is a bigger act than editing a title, so it is
88
+ # reported separately rather than buried in the retitle list.
89
+ #
90
+ # @param adopted [Array]
91
+ # @param options [Hash]
92
+ # @return [void]
93
+ def report_adoptions(adopted, options)
94
+ return if adopted.empty?
95
+
96
+ verb = commit?(options) ? "Created" : "Would create"
97
+ info("#{verb} #{adopted.size} plan folder#{"s" unless adopted.size == 1} for pull " \
98
+ "requests that resolved to no plan:\n\n" \
99
+ "#{adopted.map { |c| " #{c.ordinal} ##{c.number} #{c.title}" }.join("\n")}\n\n" \
100
+ "Each holds its pull request and nothing else. Run `agentilda run --commit`\n" \
101
+ "to have the specification written from what the pull request actually did.")
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda run` — drive the specialist agents until the tree settles.
6
+ class Run < Base
7
+ desc "Run specialist agents over the plans until nothing changes"
8
+
9
+ option :commit, type: :boolean, default: false,
10
+ desc: "Actually invoke the agents (default: dry run, prints the plan of work)"
11
+ option :rounds, desc: "Hard ceiling on loop iterations (default: 10)"
12
+ option :timeout, desc: "Seconds before one agent is abandoned (default: 900)"
13
+ option :chain, type: :boolean,
14
+ desc: "When an agent advances a plan, run the next agent in the same round " \
15
+ "(default: on; forced off by --agent)"
16
+ option :agent, desc: "Only run this one agent"
17
+ option :prompt, desc: "Extra instructions appended to the agent's prompt (only with --agent)"
18
+ option :skip, desc: "Never assign this agent; its plans wait, the rest of the pipeline runs. " \
19
+ "Comma separated for several"
20
+ option :model, desc: "Model for every agent this run, overriding each agent's own frontmatter " \
21
+ "(default: what the agent declares, else the claude CLI's default)"
22
+ option :max_tokens, desc: "Token budget per agent invocation, input plus output, sub-agents " \
23
+ "included. The agent is told the number so it can finish inside it; the meter aborts it past " \
24
+ "the number so the telling is true. (default: unmetered)"
25
+ option :plan, aliases: ["--plans"],
26
+ desc: "Only these plans, comma separated: NNN or NNN.MM, e.g. --plan 003,005.01. Default: the whole tree"
27
+ option :root, desc: "Repository root the agents work in (default: the .plans parent)"
28
+ option :isolation, default: "worktree", values: %w[worktree shared],
29
+ desc: "worktree: a checkout and branch per plan, run in parallel. shared: one tree, serial"
30
+ option :jobs, aliases: ["-j"],
31
+ desc: "Agents to run at once (default: cores - 2, capped at 12)"
32
+ option :dont_push_anything, type: :boolean, default: false,
33
+ desc: "With --commit and --isolation worktree, a finished branch is pushed and its pull request " \
34
+ "opened as soon as it lands, titled [NNN.MM](X). Pass this to turn that off and leave it uncommitted."
35
+ option :log, desc: "Append progress to this file (default: a per-project file under the system temp dir)"
36
+
37
+ example [
38
+ " # show which agent would take which plan",
39
+ "--commit # run them, one worktree per plan, in parallel",
40
+ "--commit -j 4 # …with four at a time",
41
+ "--isolation shared # one tree, serial — no git required",
42
+ "--commit --rounds 3 # …with a tighter ceiling",
43
+ "--commit --plan 005,006,007 # only the plans a batch step just created"
44
+ ]
45
+
46
+ # Option precedence, quietest to loudest: the built-in default, then
47
+ # `~/.local/config/agentilda.json` under its `"run"` key, then the flag
48
+ # actually typed. The config never supplies `--commit`: an agent run
49
+ # that writes is something a person asks for each time.
50
+ #
51
+ # @param options [Hash]
52
+ # @return [void]
53
+ def call(**options)
54
+ config = begin
55
+ Agentilda::Config.for(:run)
56
+ rescue Agentilda::Error => e
57
+ refuse(e.message, 66)
58
+ end
59
+
60
+ if options[:prompt] && !options[:agent]
61
+ refuse("--prompt only works with --agent: it speaks to one agent, and without " \
62
+ "that restriction every agent in the round would hear it.", 64)
63
+ end
64
+
65
+ tree = tree_for(options)
66
+ root = options[:root] || File.dirname(tree.dir)
67
+ agents = Agentilda::Agents.new
68
+ agents = filtered(agents, options[:agent]) if options[:agent]
69
+ agents = skipped(agents, options[:skip], options[:agent]) if options[:skip]
70
+ plans = options[:plan] ? scoped(tree, options[:plan]) : nil
71
+ assignable!(agents, tree, plans, options[:agent]) if options[:agent]
72
+
73
+ isolation = options.fetch(:isolation, "worktree").to_sym
74
+ jobs = (options[:jobs] || config[:jobs] || UI.default_jobs).to_i
75
+ timeout = (options[:timeout] || config[:timeout] || 900).to_i
76
+ chain = if options[:agent]
77
+ false
78
+ elsif options.key?(:chain)
79
+ options[:chain]
80
+ else
81
+ config.fetch(:chain, true)
82
+ end
83
+
84
+ if isolation == :worktree && !Worktree.new(root:).repository?
85
+ refuse("#{root} is not a git repository, so plans cannot be isolated.\n\n" \
86
+ "Run with --isolation shared to work in one tree, serially.", 66)
87
+ end
88
+
89
+ # Set before the loop starts, not after: `UI.animate?` (and therefore
90
+ # whether a round prints anything at all) is read the whole time the
91
+ # loop runs, not just when `report` prints its closing summary.
92
+ quiet?(options)
93
+ UI.log_path = options[:log] || config[:log] ||
94
+ File.join(Dir.tmpdir, "agentilda-#{File.basename(root)}.log")
95
+ info("Progress: #{UI.log_path}") unless quiet?(options)
96
+ credentials_warning if commit?(options) && !quiet?(options)
97
+
98
+ Control.reset!
99
+ keyboard = Keyboard.listen
100
+ UI.line("keys: h for help — w wrap up · n write out and stop · q quit") if keyboard && !quiet?(options)
101
+
102
+ runner = Runner.new(
103
+ tree:, agents:, isolation:, jobs:, plans:, chain:,
104
+ worktree: (Worktree.new(root:) if isolation == :worktree),
105
+ max_rounds: (options[:rounds] || config[:rounds] || 10).to_i,
106
+ executor: Executor.new(root:, timeout:, dry_run: !commit?(options),
107
+ instructions: options[:prompt], model: options[:model],
108
+ max_tokens: (options[:max_tokens] || config[:max_tokens])&.to_i,
109
+ interactive: !keyboard.nil? && commit?(options)), dry_run: !commit?(options),
110
+ publisher: publisher_for(root, isolation, options)
111
+ )
112
+
113
+ started = UI.monotonic
114
+ rounds = begin
115
+ runner.call
116
+ ensure
117
+ keyboard&.stop
118
+ end
119
+ report(runner, rounds, options, seconds: UI.monotonic - started)
120
+ exit(failures(rounds).empty? ? 0 : 1)
121
+ end
122
+
123
+ private
124
+
125
+ # @param agents [Agentilda::Agents]
126
+ # @param name [String]
127
+ # @return [Agentilda::Agents] holding only the agent named — the
128
+ # restriction has to reach assignments, not just chaining, or a flag
129
+ # like `--prompt` aimed at one agent leaks to the whole round
130
+ def filtered(agents, name)
131
+ agents.find(name) or
132
+ refuse("No agent called #{name}.\n\nKnown: #{agents.all.map(&:name).join(", ")}", 65)
133
+ agents.only(name)
134
+ end
135
+
136
+ # Each name is checked against the full roster, not the possibly
137
+ # already-restricted one, so `--skip` misspelled is a refusal rather
138
+ # than a silent no-op — the same reasoning `--plan` applies to numbers.
139
+ #
140
+ # @param agents [Agentilda::Agents]
141
+ # @param text [String] comma-separated agent names
142
+ # @param restricted [String, nil] what `--agent` asked for, if anything
143
+ # @return [Agentilda::Agents] without the agents named
144
+ def skipped(agents, text, restricted)
145
+ roster = Agentilda::Agents.new
146
+ names = text.split(",").map(&:strip).reject(&:empty?)
147
+ names.each do |name|
148
+ roster.find(name) or
149
+ refuse("No agent called #{name}.\n\nKnown: #{roster.all.map(&:name).join(", ")}", 65)
150
+ if name == restricted
151
+ refuse("--agent #{name} and --skip #{name} contradict each other: " \
152
+ "one asks for only this agent, the other for anything but.", 64)
153
+ end
154
+ end
155
+ agents.without(*names)
156
+ end
157
+
158
+ # `--plan` names the whole point of scoping: a skill that just minted
159
+ # N folders hands off exactly those, not the tree. A typo'd or already-
160
+ # settled number silently running the whole tree instead is the failure
161
+ # this exists to prevent, so an unknown one is refused rather than
162
+ # dropped.
163
+ #
164
+ # @param tree [Agentilda::Tree]
165
+ # @param text [String]
166
+ # @return [Array<Agentilda::Ordinal>]
167
+ def scoped(tree, text)
168
+ known = tree.subjects.map { |s| s.feature.ordinal }
169
+ text.split(",").map(&:strip).reject(&:empty?).map { |token|
170
+ ordinal = Ordinal.parse(token)
171
+ unless ordinal && known.include?(ordinal)
172
+ refuse("No plan #{token} in #{tree.dir}.\n\n" \
173
+ "Known: #{known.join(", ")}", 66)
174
+ end
175
+ ordinal
176
+ }
177
+ end
178
+
179
+ # `--agent` narrowing the round to nobody used to exit 0 in silence —
180
+ # "0 plans addressed", green box, no hint that the agent named simply
181
+ # does not handle any in-scope plan's state. Someone who typed both an
182
+ # agent and a plan meant them to meet, so a pairing that cannot happen
183
+ # is refused up front with the agent that would actually take each plan.
184
+ #
185
+ # @param agents [Agentilda::Agents] already narrowed to the one agent
186
+ # @param tree [Agentilda::Tree]
187
+ # @param plans [Array<Agentilda::Ordinal>, nil]
188
+ # @param name [String]
189
+ # @return [void]
190
+ def assignable!(agents, tree, plans, name)
191
+ agent = agents.find(name)
192
+ active = tree.subjects.select { |s|
193
+ (plans.nil? || plans.include?(s.feature.ordinal)) &&
194
+ !StateMachine::SETTLED.include?(s.status.key)
195
+ }
196
+ return if active.empty? || active.any? { |s| agent.handles?(s.status) }
197
+
198
+ roster = Agentilda::Agents.new
199
+ lines = active.map { |s|
200
+ takers = roster.for_status(s.status).map(&:name)
201
+ " #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
202
+ "#{" — #{takers.join(", ")} takes it" unless takers.empty?}"
203
+ }
204
+ handled = agent.handles.map { |k| Agentilda::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
205
+ refuse("#{name} handles #{handled.join(", ")}, and no plan in scope is there:\n\n" \
206
+ "#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.", 65)
207
+ end
208
+
209
+ # @param rounds [Array]
210
+ # @return [Array]
211
+ def failures(rounds) = rounds.flat_map(&:attempts).reject(&:ok)
212
+
213
+ # The one thing this harness does that leaves the machine — pushing a
214
+ # branch and opening its pull request — so it takes one more thing to
215
+ # turn on than everything else here: `--commit` alone is not enough,
216
+ # isolation has to actually give each plan a branch of its own too.
217
+ #
218
+ # `nil` is how {Runner} is told to leave a finished worktree alone; it
219
+ # is what `--dont-push-anything` asks for, and what a shared tree gets
220
+ # by construction, since there is no separate branch there to push.
221
+ #
222
+ # @param root [String]
223
+ # @param isolation [Symbol]
224
+ # @param options [Hash]
225
+ # @return [Agentilda::Publisher, nil]
226
+ def publisher_for(root, isolation, options)
227
+ return nil if isolation != :worktree || options[:dont_push_anything]
228
+
229
+ Publisher.new(root:, dry_run: !commit?(options))
230
+ end
231
+
232
+ # @param runner [Agentilda::Runner]
233
+ # @param rounds [Array]
234
+ # @param options [Hash]
235
+ # @param seconds [Float] wall clock for the whole loop
236
+ # @return [void]
237
+ def report(runner, rounds, options, seconds: 0.0)
238
+ rounds.each do |round|
239
+ puts "round #{round.number}"
240
+ round.attempts.each do |a|
241
+ mark = if !a.ok
242
+ "FAIL"
243
+ elsif a.advanced?
244
+ "#{a.from} -> #{a.to}"
245
+ else
246
+ "no change"
247
+ end
248
+ puts " #{a.ordinal}\t#{a.agent}\t#{mark}\t#{a.note}"
249
+ end
250
+ end
251
+
252
+ # What the run cost, on STDOUT with the rounds it belongs to, so a run
253
+ # redirected to a file keeps its bill. A dry run spent nothing and gets
254
+ # none of this.
255
+ tally = Tally.new(attempts: rounds.flat_map(&:attempts), seconds:, rounds: rounds.size)
256
+ puts("", tally.render) if commit?(options)
257
+
258
+ return if quiet?(options)
259
+
260
+ if Control.quit?
261
+ warn("Stopped from the keyboard — q. Unfinished plans keep their state; " \
262
+ "re-run to resume where they stand.")
263
+ end
264
+
265
+ advanced = rounds.sum(&:advanced)
266
+ blocked = runner.blocked
267
+ summary = ["#{rounds.size} round#{"s" unless rounds.size == 1}", "#{advanced} advanced"]
268
+ summary << (runner.isolated? ? "#{runner.jobs} at a time, one worktree each" : "serial, shared tree")
269
+ summary << "#{blocked.size} blocked" unless blocked.empty?
270
+ summary << "#{failures(rounds).size} failed" unless failures(rounds).empty?
271
+
272
+ if !commit?(options)
273
+ warn("Dry run — no agent was invoked.\n#{summary.join(" · ")}\n\nRe-run with --commit.")
274
+ elsif failures(rounds).empty?
275
+ success("#{summary.join(" · ")}#{"\n\nEvery plan is done or deliberately parked." if runner.settled?}")
276
+ else
277
+ error("#{summary.join(" · ")}\n\n#{failures(rounds).map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
278
+ end
279
+
280
+ return if blocked.empty?
281
+
282
+ warn("#{blocked.size} plan#{"s" unless blocked.size == 1} need a human decision:\n" +
283
+ blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
284
+ "\n\nWrite the answers into blocked.md, then: agentilda unblock " \
285
+ "#{blocked.map { |b| b.feature.ordinal }.join(",")} --commit")
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module CLI
5
+ # `agentilda states` — the state machine, as a terminal diagram.
6
+ class States < Dry::CLI::Command
7
+ desc "Print a diagram of every state and the transitions between them"
8
+
9
+ example [""]
10
+
11
+ # @return [void]
12
+ def call(**) = $stdout.write(Diagram.new.render)
13
+ end
14
+ end
15
+ end