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