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,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Presentation
5
+ # Generates the conventions document from the state machine itself.
6
+ #
7
+ # The tables and the diagram are DERIVED — from {STATUSES}, {StateMachine.inbound}
8
+ # and {Ordinal} — so they cannot drift from the tool the way three
9
+ # hand-maintained copies of the status table already did.
10
+ #
11
+ # The prose is not derived, because a machine cannot invent the reasoning: why
12
+ # the tool refuses rather than guesses, why a retroactive plan gets its own
13
+ # number shape, what happens to this scheme if a real issue tracker arrives.
14
+ # That lives in the heredocs below, in one place, and is emitted alongside the
15
+ # generated parts.
16
+ class Documentation
17
+ # @return [String] the whole document
18
+ def render
19
+ [
20
+ header,
21
+ numbering,
22
+ starting,
23
+ status_table,
24
+ blocking,
25
+ transitions,
26
+ diagram,
27
+ files_section,
28
+ pull_requests,
29
+ footer
30
+ ].join("\n")
31
+ end
32
+
33
+ private
34
+
35
+ # @return [String]
36
+ def header
37
+ <<~MARKDOWN
38
+ # Spec → Plan → Build
39
+
40
+ > [!IMPORTANT]
41
+ > **This file is auto generated.**
42
+ >
43
+ > To regenerate it, run `agentilda docs` (which by the default
44
+ > writes to `${HOME}/.agents/context/workflow.md`). To override
45
+ > the destination, use the `-o | --output <file>` option.
46
+ >
47
+ > After changing the state machine. Editing it by hand puts it back into the
48
+ > condition it was written to end: three copies of the same table, quietly
49
+ > disagreeing.
50
+
51
+ Every project keeps its plans in a `#{Agentilda::PLANS_DIR}/` directory at its root.
52
+ Each medium-to-large feature gets one folder, and the folder's **name is its
53
+ state**: the number identifies it forever, the emoji says what phase it is in,
54
+ and the slug says what it is.
55
+
56
+ There are three phases, and each one has a file that proves it happened:
57
+
58
+ | Phase | State | The file that proves it |
59
+ | :---- | :---- | :---------------------- |
60
+ | **spec** | #{status(:new).emoji} #{status(:new).label} | `spec.md` |
61
+ | **plan** | #{status(:planned).emoji} #{status(:planned).label} | `plan.md` |
62
+ | **build** | #{status(:building).emoji} → #{status(:ready_for_review).emoji} → #{status(:in_review).emoji} → #{status(:approved).emoji} | `pull-requests.md` |
63
+
64
+ Those files are not paperwork. They are what the tool checks: a folder may not
65
+ claim a phase whose file is missing, and `agentilda resync dirs` renames
66
+ any folder whose emoji its contents do not support.
67
+
68
+ MARKDOWN
69
+ end
70
+
71
+ # @return [String]
72
+ def numbering
73
+ <<~MARKDOWN
74
+ ## The number
75
+
76
+ A plan's number is its identity. It is set once, when the folder is created,
77
+ and never changes: branch names, pull request titles and every
78
+ `pull-requests.md` join on it, and renumbering breaks all of them silently.
79
+
80
+ The shape is always `NNN.MM`:
81
+
82
+ ```
83
+ #{Agentilda::PLANS_DIR}/000.00-#{status(:new).emoji}-initial-spec
84
+ #{Agentilda::PLANS_DIR}/001.00-#{status(:approved).emoji}-dev-foundation
85
+ #{Agentilda::PLANS_DIR}/001.01-#{status(:approved).emoji}-schedule-k1-backfill <- shipped between 001 and 002,
86
+ #{Agentilda::PLANS_DIR}/002.00-#{status(:planned).emoji}-tenancy-households specified afterwards
87
+ ```
88
+
89
+ - **`NNN` counts from `000`.** The first plan of a project is `000.00`; after
90
+ that it is the highest major plus one, zero-padded to three digits.
91
+ - **`MM` is `00` for an ordinary plan** — one specified before it was built.
92
+ - **`MM` from `01` to `99` marks a retroactive plan**: work that shipped with no
93
+ specification, documented after the fact. `agentilda create --after 001`
94
+ takes the next free slot in the gap after 001.
95
+
96
+ `001.01` is a **sibling of 001 that arrived later, not a part of 001**. The dot
97
+ reads as containment in almost every other numbering scheme, and here it does
98
+ not — which is worth saying wherever the scheme is described, because the
99
+ containment reading is the one a new reader brings.
100
+
101
+ Two digits, always. One would sort into the middle of the two-digit range —
102
+ `001.09` < `001.1` < `001.10` — so a single mixed-width folder silently
103
+ reorders the index. Two digits also retire the question of running out: 99
104
+ slots per gap, against a gap that closes the moment the next plan is created.
105
+
106
+ > [!NOTE]
107
+ > Padding every plan to `NNN.MM` is a deliberate choice, and it costs something.
108
+ > A bare `018` next to `018.01` would have told you at a glance which plan was
109
+ > specified in advance and which was written afterwards. With uniform padding
110
+ > that distinction is no longer readable from the number alone — it is carried
111
+ > by `MM > 0`, which you have to know to look for. What padding buys is
112
+ > alignment and one shape to parse everywhere.
113
+
114
+ **A retroactive `spec.md` must open with a dated line saying so**, naming the
115
+ pull requests it describes. It documents what exists; it does not pretend to
116
+ have decided anything in advance. Anchor by *when the work merged*, not by what
117
+ it is about — compare merge dates against folder creation dates
118
+ (`git log --diff-filter=A`). Anchoring by topic invites an argument nobody can
119
+ settle, and the number is a slot, not a claim about subject matter.
120
+
121
+ MARKDOWN
122
+ end
123
+
124
+ # @return [String]
125
+ def starting
126
+ headings = Agentilda::Lifecycle::Brief::HEADINGS.map { |h| "- `## #{h}`" }.join("\n")
127
+
128
+ <<~MARKDOWN
129
+ ## Starting a feature that does not exist yet
130
+
131
+ `agentilda create <two to five words>` mints the folder at #{status(:new).emoji} #{status(:new).label}
132
+ and, for a genuinely new feature, scaffolds `spec.md` with a title and four
133
+ headings, verbatim:
134
+
135
+ #{headings}
136
+
137
+ `#{Agentilda::Lifecycle::Brief::AGENT_NAME}` — a half-agent: it runs only inside `create` and
138
+ holds no roster entry — then makes a best-effort attempt at each from what
139
+ the project already has on disk — its own docs, `#{Agentilda::Lifecycle::Brief::BACKLOG_FILE}` when
140
+ the project keeps one, anything already downloaded or already built that
141
+ bears on the topic — and opens the result for a human to finish.
142
+ `--no-draft` skips the attempt and leaves the headings bare; `--no-open`
143
+ leaves the file for you to open.
144
+
145
+ **Nothing else belongs in that first pass.** It writes no `## Goals`, no
146
+ `## Non-Goals`, no conclusions — deciding those before `leah-researcher` and
147
+ `yoda-writer` have looked is choosing the answer before the research runs.
148
+ Above all, it writes no heading beginning with the word "Research": that
149
+ heading is not decoration, it is the #{status(:researched).emoji} #{status(:researched).label} invariant, so writing
150
+ one — even empty — flips the folder's state out from under whoever reads it
151
+ next, and the research nobody did gets skipped rather than assigned.
152
+
153
+ This is only for work that does not exist yet. `create --after <plan> --prs
154
+ <n,...>` is the other path — the work already shipped, so `yoda-writer`
155
+ reconstructs the specification from the pull requests instead of guessing at
156
+ a feature that has no facts yet to guess from.
157
+
158
+ MARKDOWN
159
+ end
160
+
161
+ # @return [String]
162
+ def status_table
163
+ rows = Agentilda::Plans::STATUSES.map do |s|
164
+ required = s.requires.empty? ? "—" : s.requires.map { |f| "`#{f}`" }.join(", ")
165
+ "| #{s.emoji} | **#{s.label}** | `#{s.key}` | #{required} | #{s.note} |"
166
+ end
167
+
168
+ <<~MARKDOWN
169
+ ## The states
170
+
171
+ | Symbol | Meaning | Key | Files required | Description |
172
+ | :----: | :------ | :-- | :------------- | :---------- |
173
+ #{rows.join("\n")}
174
+
175
+ "Files required" is a **minimum**, not an exact match: a #{status(:new).emoji} folder that
176
+ has grown a `plan.md` still satisfies #{status(:new).emoji}, and is #{status(:planned).emoji} anyway. That is
177
+ why `resync dirs` moves a folder to the furthest state its contents justify
178
+ rather than only fixing outright lies.
179
+
180
+ Some states share their requirements on purpose, and are told apart only by the
181
+ folder name. #{status(:blocked).emoji} #{status(:blocked).label} and #{status(:product_blocked).emoji} #{status(:product_blocked).label} both mean "a human must
182
+ decide before this can move"; *which* human is recorded nowhere but the emoji.
183
+ #{Plans::StateMachine::FAMILIES.last.map { |k| status(k).emoji }.join(" ")} all mean "the work exists and pull requests are open"; whether
184
+ anyone has started reviewing is written down nowhere either.
185
+
186
+ So nothing re-derives one of them from a folder's contents — otherwise every
187
+ #{status(:blocked).emoji} would silently become #{status(:product_blocked).emoji} the first time anything resynced. A folder
188
+ falling back into that group from outside lands on its weakest member,
189
+ #{status(Plans::StateMachine::FAMILIES.last.first).emoji} #{status(Plans::StateMachine::FAMILIES.last.first).label}, because that is all its contents can prove.
190
+
191
+ #{merged_note}
192
+
193
+ MARKDOWN
194
+ end
195
+
196
+ # @return [String]
197
+ def merged_note
198
+ "🟣 Merged is deliberately **not** a folder state. It describes a pull request, " \
199
+ "and a folder that claimed it would be claiming a pull request's condition as its own."
200
+ end
201
+
202
+ # Why this is prose rather than another derived table: the mechanism is a
203
+ # rule about the *contents* of one file, and the only part of it a program
204
+ # can see is the invariant. Everything that makes the rule safe — a human
205
+ # types the command, an agent never answers anything — is a decision, and
206
+ # decisions belong in sentences.
207
+ #
208
+ # @return [String]
209
+ def blocking
210
+ settled = Plans::StateMachine::SETTLED.map { |k| status(k).emoji }.join(" ")
211
+
212
+ <<~MARKDOWN
213
+ ## When a plan is stopped, and how it starts again
214
+
215
+ #{status(:blocked).emoji} #{status(:blocked).label} and #{status(:product_blocked).emoji} #{status(:product_blocked).label} mean an agent hit a question that was not
216
+ its to answer and wrote `blocked.md` instead of guessing past it. Both belong to
217
+ the settled group `agentilda run` leaves alone (#{settled}), so the loop
218
+ never offers a stopped plan to anybody. A loop that could move one would make
219
+ the state mean nothing.
220
+
221
+ Questions are numbered `B1`, `B2`, and that numbering is how the file gets
222
+ referenced in conversation and in pull requests. **`blocked.md` holds open
223
+ questions and nothing else.** That is what the #{status(:blocked).emoji}/#{status(:product_blocked).emoji} invariant reads, so a
224
+ file naming no question justifies neither state and `resync dirs` renames the
225
+ folder out of it.
226
+
227
+ Which is how a block drains, in pieces:
228
+
229
+ 1. A human writes each answer under `## Answers` as it arrives, dated and
230
+ attributed to whoever decided.
231
+ 1. `agentilda unblock NNN --commit` hands the folder to `lando-broker`.
232
+ Nothing else reaches it: answers arriving is not a fact the tool can
233
+ observe, so a human running the command *is* the signal.
234
+ 1. `lando-broker` folds each answered question into the document that question
235
+ was stopping — `spec.md` when the answer changes what we are building or
236
+ why, `plan.md` when it changes how or in what order — and deletes the
237
+ question and its answer from `blocked.md`.
238
+ 1. When the last question goes, the file goes with it, and the folder leaves
239
+ #{status(:blocked).emoji} on the resync that follows.
240
+
241
+ An entry that names no decider, carries no date, or restates the options
242
+ instead of choosing one is not an answer, and is left exactly where it is.
243
+ So is any question nobody has answered yet: two answers out of five is a
244
+ successful run, and the plan stays stopped on the other three, which is true.
245
+ `lando-broker` never answers a question itself, and never retires one for
246
+ being stale — dropping a question is a human's call, and it makes the plan
247
+ #{status(:deferred).emoji} #{status(:deferred).label} or #{status(:discarded).emoji} #{status(:discarded).label}, not quietly shorter.
248
+
249
+ MARKDOWN
250
+ end
251
+
252
+ # @return [String]
253
+ def transitions
254
+ rows = Agentilda::Plans::STATUSES.map do |s|
255
+ out = Plans::StateMachine.outbound(s.key)
256
+ targets = out.empty? ? "_terminal_" : out.map { |k| status(k).emoji }.join(" ")
257
+ spine = Plans::StateMachine::SPINE[s.key]
258
+ "| #{s.emoji} #{s.label} | #{targets} | #{spine ? "#{status(spine).emoji} #{status(spine).label}" : "—"} |"
259
+ end
260
+
261
+ <<~MARKDOWN
262
+ ## Transitions
263
+
264
+ | From | May become | `promote` goes to |
265
+ | :--- | :--------- | :---------------- |
266
+ #{rows.join("\n")}
267
+
268
+ A bare promote walks the **spine** — spec → plan → build. Everything off it
269
+ (blocking, deferring, rejecting) has to be named explicitly. That is the whole
270
+ reason there is a machine here rather than a rename: a transition is refused
271
+ when the destination's requirements are not already met, and that refusal is
272
+ information — it means the phase has not actually happened yet.
273
+
274
+ MARKDOWN
275
+ end
276
+
277
+ # The picture is a hand-drawn PNG, not the mermaid block, because mermaid
278
+ # lays fifteen states out in a way nobody wants to read.
279
+ #
280
+ # The mermaid source is still emitted underneath it, and still derived from
281
+ # the machine — which is precisely what makes it the staleness check for a
282
+ # hand-drawn image. Regenerating this document rewrites that block from
283
+ # {STATUSES} and {StateMachine.inbound}, so a diff there means the PNG no
284
+ # longer matches. No diff there, no redraw.
285
+ #
286
+ # @return [String]
287
+ def diagram
288
+ edges = Plans::StateMachine.inbound.flat_map { |to, froms|
289
+ froms.map { |from| " #{from} --> #{to}" }
290
+ }
291
+
292
+ <<~MARKDOWN
293
+
294
+ <details>
295
+ <summary>Mermaid source for the diagram above</summary>
296
+
297
+ ```mermaid
298
+ stateDiagram-v2
299
+ direction LR
300
+ #{Agentilda::Plans::STATUSES.map { |s| " #{s.key} : #{s.emoji} #{s.label}" }.join("\n")}
301
+
302
+ #{edges.join("\n")}
303
+ ```
304
+
305
+ </details>
306
+
307
+ MARKDOWN
308
+ end
309
+
310
+ # @return [String]
311
+ def files_section
312
+ known = Agentilda::Plans::STATUSES
313
+ .flat_map(&:requires)
314
+ .uniq
315
+ .map { |f| "| `#{f}` | #{holders_of(f)} |" }
316
+
317
+ <<~MARKDOWN
318
+ ## Files allowed in a plan folder
319
+
320
+ | File | Required by |
321
+ | :--- | :---------- |
322
+ | `spec.md` | the specification; written first |
323
+ | `plan.md` | the execution plan; written from the spec |
324
+ | `implementation-plan.md` | the contract between the two halves of building |
325
+ #{known.reject { |r| r.start_with?("| `spec.md`", "| `plan.md`") }.join("\n")}
326
+
327
+ `implementation-plan.md` is the one file no state requires. `luke-backend`
328
+ writes it before it writes code, and `rey-frontend` builds from it a round
329
+ later: the interfaces the back end landed, their shapes and their errors,
330
+ which files each half owns, which units may run concurrently, and the one
331
+ test that proves the halves are joined rather than merely both present. No
332
+ state requires it because a plan with no back-end work never needs one.
333
+
334
+ Nothing else belongs there. A folder holding notes, diagrams or scratch files
335
+ is a folder nobody can audit at a glance.
336
+
337
+ MARKDOWN
338
+ end
339
+
340
+ # @param file [String]
341
+ # @return [String]
342
+ def holders_of(file)
343
+ Agentilda::Plans::STATUSES
344
+ .select { |s| s.requires.include?(file) }
345
+ .map { |s| "#{s.emoji} #{s.label}" }
346
+ .join(", ")
347
+ end
348
+
349
+ # @return [String]
350
+ def pull_requests
351
+ <<~MARKDOWN
352
+ ## Pull requests carry the number
353
+
354
+ A pull request that implements a plan says so in its title:
355
+
356
+ ```
357
+ [003.00] Make the core deterministic and require as_of
358
+ ```
359
+
360
+ `pull-requests.md` is generated from these titles, so the prefix is the join key
361
+ between a pull request and a plan, not decoration. Name branches
362
+ `<user>/NNN.MM-slug` and the number carries itself from branch creation through
363
+ to a merged, squashed pull request with nobody having to remember it.
364
+
365
+ `agentilda resync prs` fills in missing prefixes. It reads the branch name
366
+ first and falls back to the diff only when that touches exactly one plan folder.
367
+ **It refuses rather than guessing.** A wrong number does not announce itself: it
368
+ files the work under a plan that did not do it, and leaves the plan that did
369
+ looking untouched.
370
+
371
+ ### `[#{Agentilda::NO_PLAN_PREFIX}]` when there is no plan
372
+
373
+ Not every pull request implements a feature. Dependency bumps, CI configuration,
374
+ hotfixes and developer tooling implement no plan, and forcing a number onto them
375
+ produces a number chosen to satisfy the rule. Those are titled:
376
+
377
+ ```
378
+ [#{Agentilda::NO_PLAN_PREFIX}] Bump json from 2.21.1 to 2.21.2
379
+ ```
380
+
381
+ `#{Agentilda::NO_PLAN_PREFIX}` means **"this deliberately belongs to no specification"**, and it
382
+ exists so that "no plan" is *asserted* rather than merely absent. A title with no
383
+ prefix is ambiguous between "no plan applies" and "nobody looked".
384
+
385
+ `resync prs` will propose it, but marks every such title as **assumed** and never
386
+ applies one without you seeing it. Emitting it silently on a failed lookup would
387
+ launder "I could not tell" into "there is definitely none", which is the same
388
+ lie as guessing a number, told in the other direction.
389
+
390
+ ### What does not deserve a retroactive plan
391
+
392
+ Most unmatched pull requests. The test is whether **somebody would need to read
393
+ it** — a capability with behaviour, an interface, or invariants that are not
394
+ obvious from the code. "Fix a typo", "remove dead code" and "bump a dependency"
395
+ are `[#{Agentilda::NO_PLAN_PREFIX}]` and always were. Backfilling those produces an index that is
396
+ longer without being more informative, which makes the real plans harder to find.
397
+
398
+ MARKDOWN
399
+ end
400
+
401
+ # @return [String]
402
+ def footer
403
+ <<~MARKDOWN
404
+ ## If a real issue tracker arrives, this scheme retires
405
+
406
+ This numbering is homegrown because there is nothing else to join on. If the
407
+ work moves to Linear or Jira, **the issue key replaces it**: `[EQL-142] <title>`
408
+ in pull request titles, `EQL-142-<status>-<slug>` for the folder, and the issue
409
+ becomes the thing `pull-requests.md` is generated against.
410
+
411
+ Recording that matters more than it looks. A numbering scheme with no stated
412
+ exit becomes permanent by default: it accretes tooling, the tooling accretes
413
+ rules, and by the time a real tracker shows up, migrating is a project rather
414
+ than a decision. The exit is cheap only while it is written down and unbuilt.
415
+
416
+ Two things to hold to when that day comes:
417
+
418
+ - **Existing numbers are not rewritten.** A plan's number is its identity and
419
+ merged pull request titles are immutable history. `000` stays `000` forever and
420
+ new plans start taking issue keys. A mixed index is ugly for a while and honest
421
+ permanently, which beats a renumbering that breaks every link that ever pointed
422
+ at a plan.
423
+ - **Do not teach the tooling to accept issue keys before the tracker exists.** A
424
+ validator that accepts `[EQL-142]` with nothing to check it against is a guard
425
+ that passes anything shaped like an answer, which is worse than one that fails
426
+ loudly on first use.
427
+
428
+ ______________________________________________________________________
429
+
430
+ Generated by `agentilda docs` — version #{Agentilda::VERSION}.
431
+ MARKDOWN
432
+ end
433
+
434
+ # @param key [Symbol]
435
+ # @return [Agentilda::Plans::Status]
436
+ def status(key) = Agentilda::Plans::STATUS_BY_KEY.fetch(key)
437
+ end
438
+ end
439
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Agentilda
6
+ module Presentation
7
+ # Listens for single keypresses while a run is in flight, and turns them
8
+ # into {Control} broadcasts.
9
+ #
10
+ # The listener is a thread reading STDIN in raw mode, which only makes
11
+ # sense when STDIN is a terminal: piped input must never be eaten one byte
12
+ # at a time, and a run under cron has nobody at the keys. {.listen} returns
13
+ # nil in both cases and every caller treats nil as "no keyboard".
14
+ #
15
+ # Raw mode swallows Ctrl-C along with everything else, so ETX is forwarded
16
+ # to the main thread as the Interrupt it would have been — the listener
17
+ # must never make a run harder to kill than it was without one.
18
+ class Keyboard
19
+ # Key → what it does, rendered by {#help} and dispatched by {#handle}.
20
+ BINDINGS = [
21
+ ["h", "this help"],
22
+ ["?", "about agentilda"],
23
+ ["s", "select the next running agent; arrows move the selection"],
24
+ ["k", "mark the selected agent to be killed (STOP, 15s, then kill -9)"],
25
+ ["x", "extend the selected agent's clock by 10 minutes, per press"],
26
+ ["ENTER", "apply the pending changes in the dialog"],
27
+ ["ESC", "discard the dialog, then clear the selection"],
28
+ ["w", "ask every running agent to wrap up as fast as possible"],
29
+ ["n", "ask agents to write out what they have and stop; the loop continues"],
30
+ ["q", "write out, stop everything, and quit after a #{Execution::Control::GRACE}s grace"],
31
+ ["ctrl-c", "agents leave a resume note and stop, then quit; press again to abort now"]
32
+ ].freeze
33
+
34
+ # @param input [IO]
35
+ # @param sink [Agentilda::Presentation::Console, nil] where the table keys go; nil
36
+ # when there is no screen, in which case they do nothing
37
+ # @return [Agentilda::Presentation::Keyboard, nil] a running listener, or nil when
38
+ # STDIN is not a terminal
39
+ def self.listen(input: $stdin, sink: nil)
40
+ return nil unless input.tty?
41
+
42
+ new(input:, sink:).start
43
+ end
44
+
45
+ # @param input [IO]
46
+ # @param sink [Agentilda::Presentation::Console, nil]
47
+ def initialize(input: $stdin, sink: nil)
48
+ @input = input
49
+ @sink = sink
50
+ end
51
+
52
+ # @return [self]
53
+ def start
54
+ @thread = Thread.new do
55
+ Thread.current.report_on_exception = false
56
+ loop { handle(read_key) }
57
+ rescue IOError, Errno::EIO
58
+ # STDIN went away; a keyboard with no keys just stops listening.
59
+ end
60
+ self
61
+ end
62
+
63
+ # @return [void]
64
+ def stop
65
+ @thread&.kill
66
+ @thread = nil
67
+ restore
68
+ end
69
+
70
+ # `getch` puts the terminal in raw mode for the length of one read and
71
+ # restores it afterwards — but the listener spends almost all its life
72
+ # blocked inside that read, and {#stop} kills it there, which leaves
73
+ # the mode behind. Raw mode has output post-processing off, so a "\n"
74
+ # written after it drops a line without returning the cursor to column
75
+ # zero: the run report then starts wherever the previous line ended and
76
+ # staircases down the screen. Nothing else puts the mode back, so this
77
+ # does.
78
+ #
79
+ # @return [void]
80
+ def restore
81
+ @input.cooked! if @input.tty?
82
+ rescue IOError, SystemCallError
83
+ # No terminal left to put back; there is nothing to restore it to.
84
+ end
85
+
86
+ # @param key [String, nil]
87
+ # @return [void]
88
+ def handle(key)
89
+ case key
90
+ when "h" then @sink ? @sink.toggle_help : UI.popup("Keys", help)
91
+ when "?" then @sink ? @sink.toggle_about : UI.popup("About", about)
92
+ when "s", "\e[B" then @sink&.select_next
93
+ when "\e[A" then @sink&.select_prev
94
+ when "k" then @sink&.toggle_kill
95
+ when "x" then @sink&.extend
96
+ when "\r", "\n" then @sink&.apply
97
+ when "\e" then @sink&.escape
98
+ when "w" then acted("w — agents asked to wrap up") { Execution::Control.wrap_up! }
99
+ when "n" then acted("n — agents asked to write out and stop") { Execution::Control.stop! }
100
+ when "q" then acted("q — quitting; #{Execution::Control::GRACE}s grace to write out") { Execution::Control.quit! }
101
+ when "\u0003" then interrupt
102
+ end
103
+ end
104
+
105
+ # @return [String] the bindings, one per line, widest key first
106
+ def help
107
+ width = BINDINGS.map { |key, _| key.length }.max
108
+ BINDINGS.map { |key, does| "#{key.ljust(width)} #{does}" }.join("\n")
109
+ end
110
+
111
+ # @return [String] the version and a reminder of how to leave
112
+ def about = "Agentilda Version #{Agentilda::VERSION}\n\nThanks for using this! Press 'q' to exit and stop all agents."
113
+
114
+ private
115
+
116
+ # ESC alone and ESC-[-A are the same first byte. Wait a few
117
+ # milliseconds for the rest before deciding it was a bare ESC.
118
+ #
119
+ # @return [String]
120
+ def read_key
121
+ key = @input.getch
122
+ return key unless key == "\e"
123
+
124
+ if IO.select([@input], nil, nil, 0.05)
125
+ rest = @input.read_nonblock(2, exception: false)
126
+ return "\e#{rest}" if rest.is_a?(String)
127
+ end
128
+ key
129
+ end
130
+
131
+ # Raw mode swallows the signal, so the key stands in for it: the first
132
+ # press lets agents write down where they got to, the second aborts.
133
+ #
134
+ # @return [void]
135
+ def interrupt
136
+ return Thread.main.raise(Interrupt) if Execution::Control.interrupted?
137
+
138
+ acted("ctrl-c — agents leaving resume notes; #{Execution::Control::GRACE}s grace, ctrl-c again to abort") { Execution::Control.interrupt! }
139
+ end
140
+
141
+ # A keypress with no acknowledgement looks like a keypress that did
142
+ # nothing, so each one says what it just asked for.
143
+ #
144
+ # @param note [String]
145
+ # @return [void]
146
+ def acted(note)
147
+ yield
148
+ UI.log(note)
149
+ # Under the dashboard, RatatuiRuby.run owns the alternate screen and
150
+ # repaints it continuously, so this raw `$stderr` write either never
151
+ # appears or is immediately overwritten — `UI.log` above is the only
152
+ # guaranteed record of w/n/q there. The line still matters without a
153
+ # screen (a dry run, a pipe), where this is all the feedback there is.
154
+ UI.line(note)
155
+ end
156
+ end
157
+ end
158
+ end