agentilda 2.0.3 → 2.0.4

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Presentation
5
+ # What the keys do to the screen, and to the running agents.
6
+ #
7
+ # Selection and the dialog live here, never on the screen: the screen
8
+ # draws whatever board it is handed, and this decides what the board
9
+ # says. Pending changes are visible only in the dialog and take effect
10
+ # only on ENTER, so a stray `k` kills nobody.
11
+ class Console
12
+ # Seconds each `x` adds.
13
+ EXTENSION = 600
14
+
15
+ # @param screen [Agentilda::Presentation::Screen::Ratatui]
16
+ def initialize(screen:)
17
+ @screen = screen
18
+ @dispatcher = nil
19
+ @board = nil
20
+ @selected = nil
21
+ @dialog = false
22
+ @help = false
23
+ @about = false
24
+ @pending = { kill: false, extend: 0 }
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # @return [String, nil] the selected row's key
29
+ attr_reader :selected
30
+
31
+ # @return [Hash] `{kill: Boolean, extend: Integer}`
32
+ attr_reader :pending
33
+
34
+ # @return [Agentilda::Presentation::Screen::Ratatui]
35
+ attr_reader :screen
36
+
37
+ # @return [Boolean]
38
+ def dialog? = @dialog
39
+
40
+ # @return [Boolean]
41
+ def help? = @help
42
+
43
+ # @return [Boolean]
44
+ def about? = @about
45
+
46
+ # The dispatcher exists only once the run starts, after the keyboard is
47
+ # already listening, so it arrives late.
48
+ #
49
+ # @param dispatcher [Agentilda::Engine::Dispatcher]
50
+ # @return [void]
51
+ def attach(dispatcher) = @dispatcher = dispatcher
52
+
53
+ # Called by the dispatcher each tick.
54
+ #
55
+ # @param board [Agentilda::Engine::Board]
56
+ # @return [void]
57
+ def paint(board)
58
+ @mutex.synchronize do
59
+ @board = board
60
+ @selected = nil if @selected && board.rows.none? { |r| r.key == @selected && r.running? }
61
+ @screen.draw(board.with(selected: @selected,
62
+ dialog: (dialog_text if @dialog),
63
+ help: (help_text if @help),
64
+ about: (about_text if @about)))
65
+ end
66
+ end
67
+
68
+ # @return [void]
69
+ def select_next = move(1)
70
+
71
+ # @return [void]
72
+ def select_prev = move(-1)
73
+
74
+ # @return [void]
75
+ def toggle_kill
76
+ select_next unless @selected
77
+ return unless @selected
78
+
79
+ @pending[:kill] = !@pending[:kill]
80
+ @dialog = true
81
+ end
82
+
83
+ # @return [void]
84
+ def extend
85
+ select_next unless @selected
86
+ return unless @selected
87
+
88
+ @pending[:extend] += EXTENSION
89
+ @dialog = true
90
+ end
91
+
92
+ # ENTER.
93
+ #
94
+ # @return [void]
95
+ def apply
96
+ return unless @dialog && @selected && @dispatcher
97
+
98
+ @dispatcher.kill(@selected) if @pending[:kill]
99
+ @dispatcher.extend(@selected, @pending[:extend]) if @pending[:extend].positive?
100
+ UI.log("applied to #{@selected}: #{dialog_text.tr("\n", ", ")}")
101
+ close_dialog
102
+ end
103
+
104
+ # ESC: the dialog first, then the help, then the about screen, then the
105
+ # selection.
106
+ #
107
+ # @return [void]
108
+ def escape
109
+ if @dialog
110
+ close_dialog
111
+ elsif @help
112
+ @help = false
113
+ elsif @about
114
+ @about = false
115
+ else
116
+ @selected = nil
117
+ end
118
+ end
119
+
120
+ # @return [void]
121
+ def toggle_help = @help = !@help
122
+
123
+ # @return [void]
124
+ def toggle_about = @about = !@about
125
+
126
+ private
127
+
128
+ # @return [void]
129
+ def close_dialog
130
+ @dialog = false
131
+ @pending = { kill: false, extend: 0 }
132
+ end
133
+
134
+ # @param step [Integer]
135
+ # @return [void]
136
+ def move(step)
137
+ keys = (@board&.rows || []).select(&:running?).map(&:key)
138
+ return @selected = nil if keys.empty?
139
+
140
+ index = keys.index(@selected)
141
+ @selected = index.nil? ? keys.first : keys[(index + step) % keys.size]
142
+ end
143
+
144
+ # @return [String]
145
+ def dialog_text
146
+ row = @board&.rows&.find { |r| r.key == @selected }
147
+ who = row ? "#{row.ordinal} #{row.agent}" : @selected.to_s
148
+ extension = @pending[:extend].positive? ? "+#{@pending[:extend] / 60}m" : "none"
149
+ "#{who}\n\nkill: #{@pending[:kill] ? "yes" : "no"}\nextend: #{extension}"
150
+ end
151
+
152
+ # @return [String]
153
+ def help_text = Keyboard.new(input: $stdin).help
154
+
155
+ # @return [String]
156
+ def about_text = Keyboard.new(input: $stdin).about
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Presentation
5
+ # The ratatui dashboard for agent work that is not a `run`: `unblock`
6
+ # handing plans to lando-broker, `create` drafting a spec.md. `run` has a
7
+ # {Dispatcher} to build its {Board}; this builds one from the items
8
+ # {UI.concurrently} hands it, so every agent a person waits on is drawn
9
+ # the same way, with the same keys.
10
+ #
11
+ # It stands in for the dispatcher behind {Console}, which is why it
12
+ # answers {#kill} and {#extend}: a row whose work passed its
13
+ # {Tracker#handle} on to {Executor#call} can be killed or given more time.
14
+ class Dashboard
15
+ # Seconds between frames handed to the screen.
16
+ REFRESH = 0.25
17
+
18
+ # Seconds a finished row stays on screen, so a fast agent is still seen.
19
+ LINGER = 3
20
+
21
+ # One item's row, and the {UI::Line} its work streams progress through.
22
+ # Nothing is drawn by the line itself; the dashboard reads it each frame.
23
+ class Tracker < UI::Line
24
+ # @return [String]
25
+ attr_reader :key
26
+
27
+ # @return [Agentilda::Execution::Executor::Handle] handed to {Executor#call} by a
28
+ # caller that wants the kill and extend keys to reach its agent.
29
+ # Not `@handle`: {UI::Line} keeps its dry-cli-ui line under that name.
30
+ def handle = @executor_handle
31
+
32
+ # @return [Symbol] :running, :done or :failed
33
+ attr_reader :state
34
+
35
+ # @return [Float, nil] monotonic reading of when it stopped running
36
+ attr_reader :finished_at
37
+
38
+ # @param key [String]
39
+ # @param fields [Hash] plan, status, agent and round, for the log and the row
40
+ # @param file [String] what the row's feature column says
41
+ # @param message [String] shown until the agent says something
42
+ # @param timeout [Integer, nil]
43
+ def initialize(key:, fields:, file:, message:, timeout: nil)
44
+ super(fields:, timeout:)
45
+ @key = key
46
+ @file = file
47
+ @message = message
48
+ @executor_handle = Execution::Executor::Handle.new
49
+ @state = :running
50
+ @update = nil
51
+ @history = []
52
+ @finished_at = nil
53
+ end
54
+
55
+ # @param update [Agentilda::Execution::Transcript::Progress]
56
+ # @return [void]
57
+ def call(update)
58
+ super
59
+ @update = update
60
+ return unless update.activity
61
+
62
+ @message = update.activity
63
+ @history = Engine::Board::Row.remember(@history, @message)
64
+ end
65
+
66
+ # @return [void]
67
+ def done
68
+ super
69
+ finish(:done)
70
+ end
71
+
72
+ # @param reason [String]
73
+ # @return [void]
74
+ def failed(reason)
75
+ super
76
+ @message = reason
77
+ @history = Engine::Board::Row.remember(@history, reason)
78
+ finish(:failed)
79
+ end
80
+
81
+ # @return [Agentilda::Engine::Board::Row]
82
+ def row
83
+ Engine::Board::Row.new(key:,
84
+ at: Time.now,
85
+ ordinal: @fields[:plan].to_s,
86
+ file: @file,
87
+ agent: @fields[:agent].to_s,
88
+ role: @fields[:agent].to_s,
89
+ round: @fields[:round] || 1,
90
+ rounds: @fields[:round] || 1,
91
+ model: "default",
92
+ remaining: handle.remaining || remaining,
93
+ phase: handle.phase,
94
+ up: @update&.up.to_i,
95
+ down: @update&.down.to_i,
96
+ message: @message,
97
+ state:,
98
+ elapsed: seconds.round,
99
+ subagents: @update&.subagents.to_i,
100
+ history: @history)
101
+ end
102
+
103
+ private
104
+
105
+ # @param state [Symbol]
106
+ # @return [void]
107
+ def finish(state)
108
+ @state = state
109
+ @finished_at = UI.monotonic
110
+ end
111
+ end
112
+
113
+ # Opens the screen, runs the block, and always closes it again.
114
+ #
115
+ # @param root [String] shown in the bottom bar
116
+ # @param screen [Agentilda::Presentation::Screen::Ratatui]
117
+ # @yieldparam dashboard [Agentilda::Presentation::Dashboard]
118
+ # @return [Object] the block's value
119
+ def self.open(root: Dir.pwd, screen: Screen::Ratatui.new)
120
+ dashboard = new(root:, screen:)
121
+ dashboard.start
122
+ begin
123
+ yield dashboard
124
+ ensure
125
+ dashboard.stop
126
+ end
127
+ end
128
+
129
+ # @param root [String]
130
+ # @param screen [Agentilda::Presentation::Screen::Ratatui]
131
+ def initialize(root:, screen:)
132
+ @root = root
133
+ @screen = screen
134
+ @console = Console.new(screen:)
135
+ @console.attach(self)
136
+ @trackers = []
137
+ @lock = Mutex.new
138
+ @started_at = UI.monotonic
139
+ @closing = false
140
+ end
141
+
142
+ # @return [void]
143
+ def start
144
+ # ratatui owns the terminal's input, so the keyboard is fed from its
145
+ # loop rather than reading STDIN on a thread of its own.
146
+ @keyboard = Keyboard.new(sink: @console)
147
+ @screen.attach_keyboard(@keyboard)
148
+ @screen.open
149
+ @painter = Thread.new do
150
+ Thread.current.report_on_exception = false
151
+ until @closing
152
+ paint
153
+ sleep(REFRESH)
154
+ end
155
+ end
156
+ end
157
+
158
+ # @return [void]
159
+ def stop
160
+ @closing = true
161
+ @painter&.join
162
+ paint
163
+ @screen.close
164
+ end
165
+
166
+ # @param key [String]
167
+ # @param fields [Hash]
168
+ # @param file [String]
169
+ # @param message [String]
170
+ # @param timeout [Integer, nil]
171
+ # @return [Tracker]
172
+ def track(key:, fields: {}, file: "", message: "starting", timeout: nil)
173
+ Tracker.new(key:, fields:, file:, message:, timeout:).tap do |tracker|
174
+ @lock.synchronize { @trackers << tracker }
175
+ end
176
+ end
177
+
178
+ # @return [void]
179
+ def paint = @console.paint(board)
180
+
181
+ # @return [Agentilda::Engine::Board]
182
+ def board
183
+ rows = visible.map(&:row)
184
+ running = rows.select(&:running?)
185
+ Engine::Board.new(started_at: @started_at,
186
+ status: Execution::Control.quit? ? :quitting : :running,
187
+ plans: rows.map(&:ordinal).reject(&:empty?).uniq,
188
+ up: rows.sum(&:up),
189
+ down: rows.sum(&:down),
190
+ rows:,
191
+ root: @root,
192
+ running: running.size,
193
+ live_up: running.sum(&:up),
194
+ live_down: running.sum(&:down))
195
+ end
196
+
197
+ # @param key [String]
198
+ # @return [void]
199
+ def kill(key) = find(key)&.handle&.kill!
200
+
201
+ # @param key [String]
202
+ # @param seconds [Integer]
203
+ # @return [void]
204
+ def extend(key, seconds) = find(key)&.handle&.extend!(seconds)
205
+
206
+ private
207
+
208
+ # @return [Array<Tracker>] running, plus whatever finished a moment ago
209
+ def visible
210
+ now = UI.monotonic
211
+ @lock.synchronize do
212
+ @trackers.reject { |t| t.finished_at && now - t.finished_at > LINGER }
213
+ end
214
+ end
215
+
216
+ # @param key [String]
217
+ # @return [Tracker, nil]
218
+ def find(key) = @lock.synchronize { @trackers.find { |t| t.key == key } }
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Presentation
5
+ # `agentilda states` — the state machine as a terminal picture.
6
+ #
7
+ # {Documentation} already renders this machine as a Markdown table and a
8
+ # mermaid block, for a browser. This is the same machine, walked the same
9
+ # way, shaped for a terminal instead: unicode arrows, no browser required.
10
+ #
11
+ # Every section below is read off {StateMachine} and {STATUSES} rather than
12
+ # drawn by hand, for the reason {Documentation} gives for doing the same
13
+ # thing: three hand-maintained pictures of this machine have already
14
+ # drifted apart once, and a fourth is not the fix.
15
+ class Diagram
16
+ include UI
17
+
18
+ # Drawn between two states on the same line.
19
+ ARROW = "──▶"
20
+
21
+ # @return [String] the whole diagram, newline-terminated
22
+ def render
23
+ [
24
+ heading,
25
+ spine_section,
26
+ rejoin_section,
27
+ other_section,
28
+ terminal_section,
29
+ family_section
30
+ ].compact.join("\n\n") + "\n"
31
+ end
32
+
33
+ private
34
+
35
+ # @return [String]
36
+ def heading
37
+ paint("Spec → Plan → Build — the state machine", :bold) + "\n" +
38
+ paint("(derived from lib/agentilda/state_machine.rb — `agentilda docs` for the Markdown form)",
39
+ :bright_black)
40
+ end
41
+
42
+ # The path a bare `promote!` walks: {StateMachine::SPINE}, followed from
43
+ # `:new` until a state has no entry left to follow.
44
+ #
45
+ # @return [String]
46
+ def spine_section
47
+ section("MAIN SPINE",
48
+ "a bare `promote` walks this path",
49
+ [" " + spine_chain.map { |key| node(key) }.join(" #{ARROW} ")])
50
+ end
51
+
52
+ # {StateMachine::SPINE} entries whose source never appears on the walk
53
+ # above — states that sit off the spine but whose default `promote!`
54
+ # rejoins it, e.g. a rejected pull request resubmitted for review.
55
+ #
56
+ # @return [String, nil]
57
+ def rejoin_section
58
+ rows = Plans::StateMachine::SPINE
59
+ .except(*spine_chain)
60
+ .map { |from, to| " #{node(from)} #{ARROW} #{node(to)}" }
61
+
62
+ section("REJOINING THE SPINE", "off-spine states whose default promotion lands back on it", rows)
63
+ end
64
+
65
+ # Everything {StateMachine} allows that the two sections above do not
66
+ # already draw: blocking, deferring, discarding, review outcomes,
67
+ # rollback — read off {StateMachine.outbound} state by state, in
68
+ # {STATUSES} order, so nothing the machine permits is left undrawn.
69
+ #
70
+ # @return [String, nil]
71
+ def other_section
72
+ drawn = Plans::StateMachine::SPINE.to_a
73
+
74
+ groups = Plans::STATUSES.filter_map { |s|
75
+ targets = Plans::StateMachine.outbound(s.key).reject { |to| drawn.include?([s.key, to]) }
76
+ [s.key, targets] unless targets.empty?
77
+ }
78
+
79
+ section("OTHER TRANSITIONS",
80
+ "everything else the machine permits",
81
+ groups.flat_map { |key, targets|
82
+ fan_out(key, targets)
83
+ })
84
+ end
85
+
86
+ # @return [String, nil]
87
+ def terminal_section
88
+ rows = Plans::STATUSES.select(&:terminal?).map { |s| " #{node(s.key)}" }
89
+ section("TERMINAL", "nothing follows these", rows)
90
+ end
91
+
92
+ # {StateMachine::FAMILIES} share an invariant on purpose, and a folder's
93
+ # contents alone cannot tell their members apart — worth saying next to a
94
+ # diagram, or the members read like an oversight rather than a rule.
95
+ #
96
+ # @return [String, nil]
97
+ def family_section
98
+ rows = Plans::StateMachine::FAMILIES.map { |members| " #{members.map { |key| node(key) }.join(" / ")}" }
99
+
100
+ section("LOOK-ALIKE FAMILIES", "same contents on disk; only the folder name tells them apart", rows)
101
+ end
102
+
103
+ # `:new`, followed through {StateMachine::SPINE} until nothing follows.
104
+ #
105
+ # @return [Array<Symbol>]
106
+ def spine_chain
107
+ @spine_chain ||= [].tap { |chain|
108
+ key = :new
109
+ while key
110
+ chain << key
111
+ key = Plans::StateMachine::SPINE[key]
112
+ end
113
+ }
114
+ end
115
+
116
+ # One state and its outbound arrows, drawn as a small tree so several
117
+ # targets from one source read as a fan-out rather than as unrelated
118
+ # lines that happen to repeat the source.
119
+ #
120
+ # @param key [Symbol]
121
+ # @param targets [Array<Symbol>]
122
+ # @return [Array<String>]
123
+ def fan_out(key, targets)
124
+ lines = targets.each_with_index.map { |to, i|
125
+ connector = i == targets.size - 1 ? "└─▶" : "├─▶"
126
+ " #{connector} #{node(to)}"
127
+ }
128
+ [" #{node(key)}", *lines]
129
+ end
130
+
131
+ # @param key [Symbol]
132
+ # @return [String]
133
+ def node(key)
134
+ status = Plans::STATUS_BY_KEY.fetch(key)
135
+ paint("#{status.emoji} #{status.label}", :bold)
136
+ end
137
+
138
+ # @param title [String]
139
+ # @param subtitle [String]
140
+ # @param rows [Array<String>]
141
+ # @return [String, nil] nil when there is nothing to show, so {#render} can drop it
142
+ def section(title, subtitle, rows)
143
+ return nil if rows.empty?
144
+
145
+ "#{paint(title, :bold)} #{paint("(#{subtitle})", :bright_black)}\n\n#{rows.join("\n")}"
146
+ end
147
+ end
148
+ end
149
+ end