agentilda 2.0.1 → 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,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Plans
5
+ # A folder's state: what it means, which files it cannot be honest without,
6
+ # and the rule that decides whether the folder may legitimately claim it.
7
+ #
8
+ # `invariant` is asked in both directions. {StateMachine} asks it of a
9
+ # destination before moving there; `resync` and `check` ask it of the current
10
+ # state, to catch folders whose name has drifted from their contents. One
11
+ # definition, so the two can never disagree about what a state means.
12
+ #
13
+ # @!attribute [r] key
14
+ # @return [Symbol] machine-facing name, e.g. `:product_blocked`
15
+ # @!attribute [r] emoji
16
+ # @return [String] as it appears in the folder name
17
+ # @!attribute [r] label
18
+ # @return [String] the human words
19
+ # @!attribute [r] requires
20
+ # @return [Array<String>] files this state cannot be honest without
21
+ # @!attribute [r] note
22
+ # @return [String] one-line meaning, for tables and generated docs
23
+ # @!attribute [r] invariant
24
+ # @return [Proc, nil] `(subject) -> String | nil` — the reason it does not hold
25
+ Status = Data.define(:key, :emoji, :label, :requires, :note, :invariant) do
26
+ # @param subject [#file?, #read, #pull_requests]
27
+ # @return [Boolean]
28
+ def satisfied_by?(subject) = violation(subject).nil?
29
+
30
+ # @param subject [#file?, #read, #pull_requests]
31
+ # @return [String, nil] why this state is not justified, nil when it is
32
+ def violation(subject)
33
+ missing = requires.reject { |f| subject.file?(f) }
34
+ return "#{label} requires #{missing.map { |f| "`#{f}`" }.join(" and ")}" unless missing.empty?
35
+
36
+ invariant&.call(subject)
37
+ end
38
+
39
+ # @return [Boolean] whether nothing may follow this state
40
+ def terminal? = StateMachine.outbound(key).empty?
41
+
42
+ # @return [String] emoji and words, as the tables print it
43
+ def to_s = "#{emoji} #{label}"
44
+ end
45
+
46
+ # "1 pull request", "3 pull requests".
47
+ #
48
+ # @param count [Integer]
49
+ # @return [String]
50
+ def self.pull_request_count(count) = "#{count} pull request#{"s" unless count == 1}"
51
+
52
+ # What proves a specification has been researched rather than merely
53
+ # written: the chapter `leah-researcher` contributes. It is a section of
54
+ # `spec.md` rather than a file of its own because research is not a separate
55
+ # document — it is the first half of the specification, and splitting it
56
+ # would leave `yoda-writer` reading two files to write one.
57
+ RESEARCH_CHAPTER = /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i
58
+
59
+ # What proves `plan.md` holds a plan rather than the blank file
60
+ # `yoda-writer` leaves for `palpatine-planner`: any section heading. The
61
+ # ledger notes are stripped first, so an agent's `Started` line does not
62
+ # count as a plan.
63
+ PLAN_HEADING = /^[ \t]{0,3}\#{1,3}[ \t]+\S/
64
+
65
+ # Variation selectors make ⚪️ and ⚪ different strings that mean the same
66
+ # thing to a human. Compare with them removed.
67
+ #
68
+ # @param str [String, nil]
69
+ # @return [String]
70
+ def self.fold_emoji(str) = str.to_s.gsub(/[\u{FE0E}\u{FE0F}\u{200D}]/, "")
71
+
72
+ # 🟢 Ready for Review, 👀 In Review and 🔴 Changes Requested are the same
73
+ # thing on disk — the work exists and at least one pull request is still
74
+ # open. Which of the three it is lives in the folder name and nowhere else,
75
+ # so they share one invariant rather than three that could drift apart.
76
+ #
77
+ # @param label [String]
78
+ # @return [Proc]
79
+ def self.open_pull_request(label)
80
+ lambda { |subject|
81
+ return "#{label} requires an open pull request; none are recorded" if subject.pull_requests.empty?
82
+
83
+ "#{label}, but every pull request is already merged" if subject.pull_requests.none?(&:open?)
84
+ }
85
+ end
86
+
87
+ # A question that is still somebody's to answer, written as its own heading:
88
+ # `## B1`, `## B2`. The number is what `blocked.md` gets referenced by in
89
+ # conversation and in pull requests, so it is also the only part of the file
90
+ # a program can count.
91
+ #
92
+ # Whoever writes `blocked.md` MUST use this notation. A question written any
93
+ # other way is invisible to every part of this tool: the folder never becomes
94
+ # ⭕️, and `unblock` reports a file with nothing in it to drain. {ANSWER_BLOCK}
95
+ # is the other half of the pair.
96
+ OPEN_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**B(\d+)\b/
97
+
98
+ # The answer to the question of the same number: `## A1` settles `## B1`.
99
+ #
100
+ # Answers are an inbox, not a record. `lando-broker` folds each one into the
101
+ # document its question was stopping and deletes both, so a `blocked.md`
102
+ # holding nothing but history must not go on stopping the folder. An `A`
103
+ # never matches {OPEN_BLOCK}, which is what lets the two live in one file.
104
+ ANSWER_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**A(\d+)\b/
105
+
106
+ # The numbers named by every heading in +text+ matching +pattern+.
107
+ #
108
+ # @param text [String, nil]
109
+ # @param pattern [Regexp] {OPEN_BLOCK} or {ANSWER_BLOCK}
110
+ # @return [Array<Integer>] in the order they appear, without duplicates
111
+ def self.block_numbers(text, pattern)
112
+ text.to_s.lines.filter_map { |line| line[pattern, 1]&.to_i }.uniq
113
+ end
114
+
115
+ # ⭕️ Technical Block and 🅱️ Product Block are the same thing on disk: a
116
+ # `blocked.md` still naming at least one question nobody has answered. Which
117
+ # human is owed the answer lives in the folder name and nowhere else, so the
118
+ # two share one invariant rather than two that could drift apart.
119
+ #
120
+ # This is what lets a block drain in pieces. Answers land in `blocked.md`
121
+ # one at a time, `lando-broker` moves each into `spec.md` or `plan.md` and
122
+ # deletes it here, and the folder leaves ⭕️ on the pass that empties the
123
+ # file, by content rather than by anyone remembering to delete it.
124
+ #
125
+ # @param label [String]
126
+ # @return [Proc]
127
+ def self.open_block(label)
128
+ lambda { |subject|
129
+ "#{label}, but `blocked.md` names no open question" unless subject.read("blocked.md").to_s.match?(OPEN_BLOCK)
130
+ }
131
+ end
132
+
133
+ # Every state a plan folder may be in, in lifecycle order. That order drives
134
+ # the legend, the generated documentation and the `--to` help text.
135
+ #
136
+ # 🟣 Merged is deliberately absent: it is a pull request's status, not a
137
+ # folder's, and giving it a folder state invites folders that claim a pull
138
+ # request's condition as their own.
139
+ #
140
+ # Adding a state here, or changing an emoji, makes the hand-drawn
141
+ # `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
142
+ # the mermaid source in the generated document moves with this list.
143
+ STATUSES = [
144
+ Status.new(
145
+ key: :new,
146
+ emoji: "⚪️",
147
+ label: "New",
148
+ requires: %w[spec.md],
149
+ note: "a specification exists; it has not been planned yet",
150
+ invariant: nil
151
+ ),
152
+ Status.new(
153
+ key: :researched,
154
+ emoji: "🔎",
155
+ label: "Researched",
156
+ requires: %w[spec.md],
157
+ note: "the topic has been researched; `spec.md` carries a `## Research` chapter",
158
+ invariant: lambda { |s|
159
+ body = s.read("spec.md").to_s
160
+ "Researched, but `spec.md` has no `## Research` chapter" unless body.match?(RESEARCH_CHAPTER)
161
+ }
162
+ ),
163
+ Status.new(
164
+ key: :ready_for_planning,
165
+ emoji: "📋",
166
+ label: "Ready for Planning",
167
+ requires: %w[spec.md plan.md],
168
+ note: "the specification is finished; `plan.md` exists and is still blank, waiting for the planner",
169
+ invariant: lambda { |s|
170
+ body = Ledger.stripped(s.read("plan.md"))
171
+ "Ready for Planning, but `plan.md` already holds a plan" if body.match?(PLAN_HEADING)
172
+ }
173
+ ),
174
+ Status.new(
175
+ key: :planned,
176
+ emoji: "⭐️",
177
+ label: "Planned",
178
+ requires: %w[spec.md plan.md],
179
+ note: "specified and planned; nobody has started building",
180
+ invariant: lambda { |s|
181
+ body = Ledger.stripped(s.read("plan.md"))
182
+ next "Planned, but `plan.md` has no work units yet" unless body.match?(PLAN_HEADING)
183
+
184
+ # An open pull request means somebody did start. Without this
185
+ # clause a ✅ folder found with a new pull request would fall back
186
+ # to ⭐️, the family's floor, and claim nobody had touched it.
187
+ open = s.pull_requests.count(&:open?)
188
+ "Planned, but #{Agentilda::Plans.pull_request_count(open)} already open" if open.positive?
189
+ }
190
+ ),
191
+ # `pull-requests.md` is deliberately absent from `requires` on both of the
192
+ # building states, though it names the file they are about. That file is
193
+ # written by opening a pull request, which happens once, when the last
194
+ # implementer advances the plan to Ready for Review. Requiring the file to
195
+ # *enter* Building would mean nothing could ever justify entering it, and
196
+ # `palpatine-planner` finishing `plan.md` would loop forever between itself
197
+ # and a state it can never actually reach. Ready for Review is where the
198
+ # file becomes a real requirement, once something has had the chance to
199
+ # write it.
200
+ Status.new(
201
+ key: :building,
202
+ emoji: "🟡",
203
+ label: "Building",
204
+ requires: %w[spec.md plan.md],
205
+ note: "the back end is under way: data, domain and the API the interface will call",
206
+ invariant: lambda { |s|
207
+ body = Ledger.stripped(s.read("plan.md"))
208
+ "Building, but `plan.md` has no work units to build" unless body.match?(PLAN_HEADING)
209
+ }
210
+ ),
211
+ # Two building states rather than one, because the order is not a
212
+ # preference: an interface is written against an API that already answers.
213
+ # Splitting them lets each half be built by someone who only has to be good
214
+ # at that half, and gives the second a working system to build on rather
215
+ # than a description of one.
216
+ Status.new(
217
+ key: :building_ui,
218
+ emoji: "🎨",
219
+ label: "Building UI",
220
+ requires: %w[spec.md plan.md],
221
+ note: "the back end holds; the interface is being built against it",
222
+ invariant: lambda { |s|
223
+ body = Ledger.stripped(s.read("plan.md"))
224
+ "Building UI, but `plan.md` has no work units to build" unless body.match?(PLAN_HEADING)
225
+ }
226
+ ),
227
+ Status.new(
228
+ key: :ready_for_review,
229
+ emoji: "🟢",
230
+ label: "Ready for Review",
231
+ requires: %w[spec.md plan.md pull-requests.md],
232
+ note: "every pull request is green on CI and waiting for a reviewer",
233
+ invariant: open_pull_request("Ready for Review")
234
+ ),
235
+ Status.new(
236
+ key: :in_review,
237
+ emoji: "👀",
238
+ label: "In Review",
239
+ requires: %w[spec.md plan.md pull-requests.md],
240
+ note: "a reviewer has picked it up and has not ruled yet",
241
+ invariant: open_pull_request("In Review")
242
+ ),
243
+ Status.new(
244
+ key: :rejected,
245
+ emoji: "🔴",
246
+ label: "Changes Requested",
247
+ requires: %w[spec.md plan.md pull-requests.md],
248
+ note: "the review asked for fixes; resubmit once they are made",
249
+ invariant: open_pull_request("Changes Requested")
250
+ ),
251
+ Status.new(
252
+ key: :approved,
253
+ emoji: "✅",
254
+ label: "Approved & Merged",
255
+ requires: %w[pull-requests.md],
256
+ note: "reviewed, approved, and every pull request merged",
257
+ invariant: lambda { |s|
258
+ return "Approved & Merged, but no pull requests are recorded" if s.pull_requests.empty?
259
+
260
+ open = s.pull_requests.count(&:open?)
261
+ "Approved & Merged, but #{Agentilda::Plans.pull_request_count(open)} still open" if open.positive?
262
+ }
263
+ ),
264
+ Status.new(
265
+ key: :deployed,
266
+ emoji: "😎",
267
+ label: "Deployed",
268
+ requires: %w[deployed.md],
269
+ note: "live in production; `deployed.md` names the release, date and SHA",
270
+ invariant: nil
271
+ ),
272
+ Status.new(
273
+ key: :rolled_back,
274
+ emoji: "😱",
275
+ label: "Rolled Back",
276
+ requires: %w[rollback.md],
277
+ note: "it shipped and was pulled; `rollback.md` names what broke",
278
+ invariant: nil
279
+ ),
280
+ Status.new(
281
+ key: :shit,
282
+ emoji: "💩",
283
+ label: "Scrapped by Review",
284
+ requires: %w[rewrite.md],
285
+ note: "the review scrapped the work; the plan survives, the pull requests do not",
286
+ invariant: nil
287
+ ),
288
+ Status.new(
289
+ key: :blocked,
290
+ emoji: "⭕️",
291
+ label: "Technical Block",
292
+ requires: %w[blocked.md],
293
+ note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide",
294
+ invariant: open_block("Technical Block")
295
+ ),
296
+ Status.new(
297
+ key: :product_blocked,
298
+ emoji: "🅱️",
299
+ label: "Product Block",
300
+ requires: %w[blocked.md],
301
+ note: "cannot proceed; `blocked.md` names what a product manager must decide",
302
+ invariant: open_block("Product Block")
303
+ ),
304
+ Status.new(
305
+ key: :deferred,
306
+ emoji: "☢️",
307
+ label: "Deferred",
308
+ requires: %w[delayed.md],
309
+ note: "could proceed and chose not to yet; `delayed.md` must name the trigger",
310
+ invariant: lambda { |s|
311
+ body = s.read("delayed.md").to_s
312
+ "Deferred, but `delayed.md` names no trigger" unless body.match?(/trigger|revisit|when\b|until\b|once\b/i)
313
+ }
314
+ ),
315
+ Status.new(
316
+ key: :retroactive,
317
+ emoji: "🕰️",
318
+ label: "Retroactive",
319
+ requires: [],
320
+ note: "the feature is live, but has neither a specification nor a plan",
321
+ invariant: lambda { |s|
322
+ next "Retroactive, but a `spec.md` already exists — it has been documented" if s.file?("spec.md")
323
+
324
+ "Retroactive, but no pull requests are recorded" if s.pull_requests.empty?
325
+ }
326
+ ),
327
+ Status.new(
328
+ key: :discarded,
329
+ emoji: "❌",
330
+ label: "Discarded",
331
+ requires: %w[discarded.md],
332
+ note: "dropped for good; `discarded.md` says why. A terminal state",
333
+ invariant: nil
334
+ )
335
+ ].freeze
336
+
337
+ # Emoji => status, folded so ⚪ and ⚪️ both resolve.
338
+ STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }.freeze
339
+
340
+ # Symbol => status.
341
+ STATUS_BY_KEY = STATUSES.to_h { |s| [s.key, s] }.freeze
342
+
343
+ # @param emoji [String, nil]
344
+ # @return [Agentilda::Plans::Status, nil]
345
+ def self.status_for_emoji(emoji) = STATUS_BY_EMOJI[fold_emoji(emoji)]
346
+
347
+ # Resolve a state from the only two names it has: its key, and its emoji.
348
+ #
349
+ # There is deliberately no synonym table. A state that also answers to
350
+ # "star", "green" and "completed" has four names to keep in step with the
351
+ # folder, the generated docs and the agent frontmatter — and the synonym
352
+ # table that used to live here went stale pointing six words at a state that
353
+ # no longer existed.
354
+ #
355
+ # @param text [String, Symbol, Agentilda::Plans::Status, nil]
356
+ # @return [Agentilda::Plans::Status, nil]
357
+ def self.status(text)
358
+ return nil if text.nil?
359
+ return text if text.is_a?(Status)
360
+
361
+ key = text.to_s.strip
362
+ STATUS_BY_KEY[key.to_sym] || status_for_emoji(key)
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Plans
5
+ # A plan folder as the state machine sees it: which files exist, what they
6
+ # say, and which pull requests they record.
7
+ #
8
+ # It exists because the invariants ask the same questions repeatedly and
9
+ # {Feature} is a frozen `Data` with nowhere to memoize the answers.
10
+ class Subject
11
+ # @param feature [Agentilda::Plans::Feature]
12
+ def initialize(feature)
13
+ @feature = feature
14
+ @reads = {}
15
+ end
16
+
17
+ # @return [Agentilda::Plans::Feature]
18
+ attr_reader :feature
19
+
20
+ # @return [Agentilda::Plans::Status] the status the folder name claims
21
+ def status = feature.status
22
+
23
+ # @param name [String] a bare filename
24
+ # @return [Boolean]
25
+ def file?(name) = File.file?(File.join(feature.path, name))
26
+
27
+ # @param name [String]
28
+ # @return [String, nil] contents, or nil when absent
29
+ def read(name)
30
+ @reads.fetch(name) do
31
+ @reads[name] = file?(name) ? File.read(File.join(feature.path, name), encoding: "UTF-8") : nil
32
+ end
33
+ end
34
+
35
+ # @return [Array<Agentilda::Plans::PullRequest>]
36
+ def pull_requests = @pull_requests ||= PullRequests.new(dir: feature.path).all
37
+
38
+ # The specification's own Goal section, verbatim and at most two
39
+ # paragraphs. Anything that paraphrases the spec is a second copy that
40
+ # drifts; quoting it is not — which is why the pull request body and the
41
+ # index both read it from here rather than each writing their own.
42
+ #
43
+ # @return [Array<String>] paragraphs, empty when there is no Goal to read
44
+ def goal
45
+ body = read("spec.md").to_s
46
+ section = body[/^\#{"#"}{2,3}\s*Goals?\b[^\n]*\n+(.*?)(?=\n\#{"#"}{1,3}\s|\z)/mi, 1]
47
+
48
+ paragraphs(section) || paragraphs(body.sub(/\A\s*\#{"#"}[^\n]*\n/, "")) || []
49
+ end
50
+
51
+ # Specifications written before the template existed have no Goal section,
52
+ # and they are exactly the ones an index most needs to describe. So the
53
+ # opening prose stands in — skipping headings, quotes, lists and tables,
54
+ # which describe the document rather than the work.
55
+ #
56
+ # @param text [String, nil]
57
+ # @return [Array<String>, nil] nil when there is no prose to be had
58
+ def paragraphs(text)
59
+ found = text.to_s.strip.split(/\n{2,}/)
60
+ .map(&:strip)
61
+ .reject { |p| p.empty? || p.match?(/\A[\#>|\-*\d`_=]/) }
62
+ .first(2)
63
+
64
+ found.empty? ? nil : found
65
+ end
66
+
67
+ # The questions `blocked.md` still names, by number.
68
+ #
69
+ # Empty means one of two very different things, and a caller that treats
70
+ # them alike is how a folder with thirty kilobytes of open questions gets
71
+ # reported as "nothing left open": either there is no `blocked.md` at all,
72
+ # or there is one whose questions are not written as `## B<n>` and are
73
+ # therefore invisible to every part of this tool. Ask {#file?} which.
74
+ #
75
+ # @return [Array<Integer>]
76
+ def open_blocks = Agentilda::Plans.block_numbers(read("blocked.md"), OPEN_BLOCK)
77
+
78
+ # The answers waiting in `blocked.md`, by number. `## A1` settles `## B1`.
79
+ #
80
+ # Waiting, not folded. An `## A<n>` heading is not the same as a settled
81
+ # question: one may say in its own body that it is a draft pending a
82
+ # conversation. `lando-broker` makes that call; this only counts headings.
83
+ #
84
+ # @return [Array<Integer>]
85
+ def block_answers = Agentilda::Plans.block_numbers(read("blocked.md"), ANSWER_BLOCK)
86
+
87
+ # A `blocked.md` this tool cannot read: the file is there, and not one
88
+ # question in it is written as `## B<n>`. Nothing can drain it and nothing
89
+ # currently says so, which is the whole reason this exists.
90
+ #
91
+ # @return [Boolean]
92
+ def unreadable_block? = file?("blocked.md") && open_blocks.empty?
93
+
94
+ # @return [String, nil] why the folder's name is not justified
95
+ def violation = status.violation(self)
96
+
97
+ # @return [Boolean] whether the name matches the contents
98
+ def consistent? = violation.nil?
99
+
100
+ # @return [Agentilda::Plans::StateMachine] positioned at the current state
101
+ def machine = StateMachine.new(self)
102
+
103
+ # @return [Array<Symbol>] states reachable right now, guards applied
104
+ def allowed = machine.allowed
105
+
106
+ # @return [Agentilda::Plans::Status, nil] the state these contents justify
107
+ def best_fit = machine.best_fit
108
+
109
+ # Move the folder into +status+ — the side effect a transition *is*.
110
+ #
111
+ # The {Feature} is a frozen `Data` holding the old name, so it is replaced
112
+ # rather than mutated, and the memoized reads go with it.
113
+ #
114
+ # @param status [Agentilda::Plans::Status]
115
+ # @return [Agentilda::Plans::Feature] the feature under its new name
116
+ # @raise [Agentilda::Error] when the target name is already taken
117
+ def rename_to(status)
118
+ return @feature if status.key == @feature.status.key
119
+
120
+ target = File.join(File.dirname(@feature.path), @feature.dirname_as(status))
121
+ unless Agentilda.move_directory(@feature.path, target)
122
+ raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
123
+ end
124
+
125
+ @reads = {}
126
+ @pull_requests = nil
127
+ @feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Plans
5
+ # A project's `.plans` directory: the folders in it, decoded and ordered.
6
+ class Tree
7
+ # @param dir [String] the `.plans` directory
8
+ def initialize(dir:)
9
+ @dir = File.expand_path(dir)
10
+ end
11
+
12
+ # @return [String] absolute path to the `.plans` directory
13
+ attr_reader :dir
14
+
15
+ # @return [Boolean]
16
+ def exist? = File.directory?(dir)
17
+
18
+ # @return [Array<Agentilda::Plans::Feature>] ordered by number
19
+ def features = @features ||= directories.filter_map { |p| Feature.parse(p) }.sort
20
+
21
+ # @return [Array<Agentilda::Plans::Subject>] ordered by number
22
+ def subjects = @subjects ||= features.map { |f| Subject.new(f) }
23
+
24
+ # @return [Array<Agentilda::Plans::Ordinal>]
25
+ def ordinals = features.map(&:ordinal)
26
+
27
+ # Directories that carry no plan number. Reported, never processed.
28
+ #
29
+ # @return [Array<String>]
30
+ def skipped = @skipped ||= directories.reject { |p| Feature.parse(p) }.map { |p| File.basename(p) }
31
+
32
+ # Numbers claimed by more than one folder.
33
+ #
34
+ # A plan's number is its identity: branch names, pull request titles and
35
+ # every `pull-requests.md` join on it. Two folders wearing the same number
36
+ # make every one of those joins ambiguous, and nothing downstream can tell
37
+ # which folder a `[002.00]` pull request belongs to.
38
+ #
39
+ # @return [Hash{Agentilda::Plans::Ordinal => Array<String>}] number => dirnames
40
+ def duplicates
41
+ features.group_by(&:ordinal)
42
+ .select { |_, group| group.size > 1 }
43
+ .transform_values { |group| group.map(&:dirname) }
44
+ end
45
+
46
+ # @param ordinal [Agentilda::Plans::Ordinal, String]
47
+ # @return [Agentilda::Plans::Subject, nil]
48
+ def find(ordinal)
49
+ wanted = ordinal.is_a?(Ordinal) ? ordinal : Ordinal.parse(ordinal)
50
+ subjects.find { |s| s.feature.ordinal == wanted }
51
+ end
52
+
53
+ # @return [Boolean] whether a plan with this number exists on disk
54
+ def include?(ordinal) = !find(ordinal).nil?
55
+
56
+ # Forget everything read from disk, so a caller that has just renamed
57
+ # folders sees the new state.
58
+ #
59
+ # @return [self]
60
+ def reload
61
+ @features = @subjects = @skipped = nil
62
+ self
63
+ end
64
+
65
+ private
66
+
67
+ # @return [Array<String>] absolute paths of every subdirectory
68
+ def directories
69
+ return [] unless exist?
70
+
71
+ Dir.children(dir)
72
+ .map { |c| File.join(dir, c) }
73
+ .select { |p| File.directory?(p) }
74
+ .reject { |p| File.basename(p).start_with?(".") }
75
+ .sort
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # The plan model: what a `.plans` folder is, which state it claims, and
5
+ # which transitions it may take. Nothing in here spawns a process or talks
6
+ # to a network; it reads and renames folders, and that is all.
7
+ #
8
+ # The module methods below are the front door. Callers outside this
9
+ # namespace go through them rather than constructing the classes, so the
10
+ # classes can be split or renamed without touching the CLI.
11
+ module Plans
12
+ module_function
13
+
14
+ # @param dir [String] the `.plans` directory
15
+ # @return [Agentilda::Plans::Tree]
16
+ def tree(dir = Agentilda::PLANS_DIR) = Tree.new(dir:)
17
+
18
+ # @param feature [Agentilda::Plans::Feature]
19
+ # @return [Agentilda::Plans::Subject]
20
+ def subject(feature) = Subject.new(feature)
21
+
22
+ # @param path [String] a plan folder
23
+ # @return [Agentilda::Plans::Feature, nil]
24
+ def feature(path) = Feature.parse(path)
25
+
26
+ # @param text [String]
27
+ # @return [Agentilda::Plans::Ordinal, nil]
28
+ def ordinal(text) = Ordinal.parse(text)
29
+
30
+ # @param dir [String] a plan folder
31
+ # @return [Agentilda::Plans::Mailbox]
32
+ def mailbox(dir) = Mailbox.new(dir:)
33
+
34
+ # @param tree [Agentilda::Plans::Tree]
35
+ # @param project [String, nil]
36
+ # @return [Agentilda::Plans::Index]
37
+ def index(tree:, project: nil) = Index.new(tree:, project:)
38
+ end
39
+ end