agentilda 1.0.3 → 2.0.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +10 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +92 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +46 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +65 -13
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
@@ -15,15 +15,21 @@ module Agentilda
15
15
  # folder whose status already holds is left alone, which is what stops
16
16
  # ⭕️ Blocked and 🅱️ Product Blocked — deliberately identical invariants,
17
17
  # distinguished only by the name — from collapsing into one.
18
- # - **The number is not in `NNN.MM` form** — a folder written `018-⚪️--foo`
19
- # before the padding rule, or `18.1-⚪️--foo` by hand. These read fine and
20
- # sort wrong, which is the whole reason the rule exists: `018.09` <
21
- # `018.1` < `018.10` puts a single mixed-width folder in the middle of
22
- # the range. Both are renamed to `018.00-⚪️--foo` and `018.01-⚪️--foo`.
18
+ # - **The spelling is not canonical** — a folder written `018-⚪️--foo`
19
+ # before the `NNN.MM` padding rule or the ` ` separator, or
20
+ # `18.1-⚪️--foo` by hand. Unpadded numbers read fine and sort wrong
21
+ # `018.09` < `018.1` < `018.10` puts a single mixed-width folder in the
22
+ # middle of the range. Both are renamed, to `018.00-⚪️ → foo` and
23
+ # `018.01-⚪️ → foo`.
23
24
  #
24
- # The two cases collapse into one rule **rename any folder that is not
25
- # already named what it should be named** which is why the emoji fix and
26
- # the renumber cannot disagree about the target.
25
+ # The repairs are applied in that order reversed, as **up to two renames
26
+ # per folder**: first the spelling, under the status the folder already
27
+ # claims which is how a `--`-era folder migrates to the ` → ` form —
28
+ # and only then the status its contents justify. Both renames aim at
29
+ # {Feature.plan_dirname}, so they cannot disagree about the final name;
30
+ # splitting them keeps each auditable on its own, instead of one report
31
+ # line where a spelling migration also appears to have opinions about
32
+ # the state of the work.
27
33
  class Dirs
28
34
  # A proposed rename.
29
35
  #
@@ -40,22 +46,41 @@ module Agentilda
40
46
  # @!attribute [r] reason
41
47
  # @return [String] why the current name is wrong
42
48
  Change = Data.define(:dirname, :from, :to, :source, :target, :reason) do
43
- # @return [String] a single auditable line
44
- def to_s = "#{dirname} #{File.basename(target)} (#{reason})"
49
+ # @return [String] a single auditable line — `⇒` rather than `→`,
50
+ # which the folder names themselves now contain
51
+ def to_s = "#{dirname} ⇒ #{File.basename(target)} (#{reason})"
45
52
  end
46
53
 
54
+ # The state a folder's contents justify, or the one it claims when
55
+ # nothing fits. What a rename moves toward, and what the dispatcher
56
+ # reads on a dry run, which renames nothing, so the preview still
57
+ # names the agents a real run would start.
58
+ #
59
+ # @param subject [Agentilda::Subject]
60
+ # @return [Agentilda::Status]
61
+ def self.target(subject) = subject.best_fit || subject.status
62
+
47
63
  # @param tree [Agentilda::Tree]
48
- def initialize(tree:)
64
+ # @param except [Array<String>] ordinals to leave alone however wrong
65
+ # their names are: the dispatcher passes the plans an agent is
66
+ # working in right now
67
+ def initialize(tree:, except: [])
49
68
  @tree = tree
69
+ @except = except.map(&:to_s)
50
70
  end
51
71
 
52
72
  # @return [Agentilda::Tree]
53
73
  attr_reader :tree
54
74
 
55
- # What would change, without changing anything.
75
+ # What would change, without changing anything. A folder can appear
76
+ # twice: its spelling migration first, then its status correction.
56
77
  #
57
78
  # @return [Array<Agentilda::Resync::Dirs::Change>]
58
- def plan = tree.subjects.filter_map { |subject| change_for(subject) }
79
+ def plan
80
+ tree.subjects.flat_map do |subject|
81
+ @except.include?(subject.feature.ordinal.to_s) ? [] : changes_for(subject)
82
+ end
83
+ end
59
84
 
60
85
  # @param commit [Boolean] actually rename
61
86
  # @return [Array<Agentilda::Resync::Dirs::Change>] what was proposed
@@ -71,48 +96,70 @@ module Agentilda
71
96
  private
72
97
 
73
98
  # A folder moves when the name it has is not the name it should have.
74
- #
75
- # `best_fit` answers "which state do these contents justify", and falls
76
- # back to the state already claimed when nothing fits so a folder with
77
- # an unreadable set of contents still gets its number padded rather than
78
- # being skipped for a reason that has nothing to do with its number.
79
- #
80
- # Invariants are minimum requirements, so a ⚪️ folder that has grown a
81
- # `plan.md` still satisfies ⚪️ and is ⭐️ anyway.
99
+ # {target} says what that name is; this says how to get there, as up
100
+ # to two renames applied in order. The status rename starts from where
101
+ # the spelling rename left the folder, not from where it stands now,
102
+ # so committing the steps sequentially never renames a path that has
103
+ # already moved out from under it.
82
104
  #
83
105
  # @param subject [Agentilda::Subject]
84
- # @return [Agentilda::Resync::Dirs::Change, nil]
85
- def change_for(subject)
106
+ # @return [Array<Agentilda::Resync::Dirs::Change>] empty when the name
107
+ # is already right
108
+ def changes_for(subject)
86
109
  feature = subject.feature
87
- fit = subject.best_fit || subject.status
88
- dirname = feature.dirname_as(fit)
89
- return nil if dirname == feature.dirname
110
+ fit = self.class.target(subject)
111
+ steps = []
112
+
113
+ spelled = feature.dirname_as(subject.status)
114
+ if spelled != feature.dirname
115
+ steps << step(feature,
116
+ from: subject.status,
117
+ to: subject.status,
118
+ source: feature.path,
119
+ reason: spelling_reason(feature))
120
+ end
90
121
 
122
+ if fit.key != subject.status.key
123
+ source = steps.empty? ? feature.path : steps.last.target
124
+ steps << step(feature,
125
+ from: subject.status,
126
+ to: fit,
127
+ source:,
128
+ reason: subject.violation || "contents now justify #{fit.label}")
129
+ end
130
+
131
+ steps
132
+ end
133
+
134
+ # @param feature [Agentilda::Feature]
135
+ # @param from [Agentilda::Status]
136
+ # @param to [Agentilda::Status]
137
+ # @param source [String] absolute path the rename starts from
138
+ # @param reason [String]
139
+ # @return [Agentilda::Resync::Dirs::Change]
140
+ def step(feature, from:, to:, source:, reason:)
91
141
  Change.new(
92
- dirname: feature.dirname,
93
- from: subject.status.key,
94
- to: fit.key,
95
- source: feature.path,
96
- target: File.join(File.dirname(feature.path), dirname),
97
- reason: reason_for(subject, fit)
142
+ dirname: File.basename(source),
143
+ from: from.key,
144
+ to: to.key,
145
+ source:,
146
+ target: File.join(File.dirname(feature.path), feature.dirname_as(to)),
147
+ reason:
98
148
  )
99
149
  end
100
150
 
101
- # @param subject [Agentilda::Subject]
102
- # @param fit [Agentilda::Status] the state the folder is moving to
103
- # @return [String] why the current name is wrong
104
- def reason_for(subject, fit)
105
- feature = subject.feature
106
- return subject.violation || "contents now justify #{fit.label}" unless fit.key == subject.status.key
151
+ # @param feature [Agentilda::Feature]
152
+ # @return [String] why the spelling is wrong, status aside
153
+ def spelling_reason(feature)
107
154
  return "#{feature.dirname_ordinal} is not padded to #{feature.ordinal}" unless feature.padded?
108
155
 
109
- "name is not in canonical NNN.MM-<emoji>--<slug> form"
156
+ "name is not in canonical NNN.MM-<emoji> → <slug> form"
110
157
  end
111
158
 
112
159
  # A rename that finds its target occupied has not happened, and saying
113
160
  # nothing about it would leave the folder misnamed with a report
114
161
  # claiming otherwise. Two folders can want the same canonical name —
115
- # `018-⚪️--foo` and `018.00-⚪️--foo` are the same plan written twice.
162
+ # `018-⚪️--foo` and `018.00-⚪️ → foo` are the same plan written twice.
116
163
  #
117
164
  # @param change [Agentilda::Resync::Dirs::Change]
118
165
  # @return [void]
@@ -236,10 +283,14 @@ module Agentilda
236
283
  # @param pull [Hash]
237
284
  # @return [Agentilda::Resync::Prs::Change]
238
285
  def restamped(pull)
239
- Change.new(number: pull[:number], title: pull[:title], ordinal: nil, ambiguous: false,
286
+ Change.new(number: pull[:number],
287
+ title: pull[:title],
288
+ ordinal: nil,
289
+ ambiguous: false,
240
290
  new_title: pull[:title].sub(STALE, "[#{Agentilda::NO_PLAN_PREFIX}] "),
241
291
  reason: "the no-plan marker is now [#{Agentilda::NO_PLAN_PREFIX}]",
242
- assumed: false, adopted: false)
292
+ assumed: false,
293
+ adopted: false)
243
294
  end
244
295
 
245
296
  # @param pulls [Array<Hash>]
@@ -261,7 +312,7 @@ module Agentilda
261
312
  adoptees = create ? adoption.call(pulls) : adoption.plan(pulls)
262
313
  by_number = adoptees.to_h { |a| [a.pull[:number], a] }
263
314
 
264
- changes.map { |c| (c.ambiguous? && by_number[c.number]) ? adopted(c, by_number[c.number]) : c }
315
+ changes.map { |c| c.ambiguous? && by_number[c.number] ? adopted(c, by_number[c.number]) : c }
265
316
  end
266
317
 
267
318
  # @return [Hash{Integer => Hash}]
@@ -272,11 +323,11 @@ module Agentilda
272
323
  # @return [Agentilda::Resync::Prs::Change]
273
324
  def adopted(change, adoptee)
274
325
  change.with(
275
- ordinal: adoptee.ordinal,
326
+ ordinal: adoptee.ordinal,
276
327
  new_title: "#{adoptee.ordinal.to_prefix} #{change.title}",
277
- reason: "#{change.reason} — adopted into #{adoptee.dirname}",
328
+ reason: "#{change.reason} — adopted into #{adoptee.dirname}",
278
329
  ambiguous: false,
279
- adopted: true
330
+ adopted: true
280
331
  )
281
332
  end
282
333
 
@@ -331,8 +382,14 @@ module Agentilda
331
382
  # @param why [String]
332
383
  # @return [Agentilda::Resync::Prs::Change]
333
384
  def resolved(pull, ordinal, why)
334
- Change.new(number: pull[:number], title: pull[:title], ordinal:, reason: why,
335
- new_title: "#{ordinal.to_prefix} #{pull[:title]}", ambiguous: false, assumed: false, adopted: false)
385
+ Change.new(number: pull[:number],
386
+ title: pull[:title],
387
+ ordinal:,
388
+ reason: why,
389
+ new_title: "#{ordinal.to_prefix} #{pull[:title]}",
390
+ ambiguous: false,
391
+ assumed: false,
392
+ adopted: false)
336
393
  end
337
394
 
338
395
  # Nothing resolved, so this is developer work — but that is an assertion
@@ -341,17 +398,28 @@ module Agentilda
341
398
  # @param pull [Hash]
342
399
  # @return [Agentilda::Resync::Prs::Change]
343
400
  def no_plan(pull)
344
- Change.new(number: pull[:number], title: pull[:title], ordinal: nil,
401
+ Change.new(number: pull[:number],
402
+ title: pull[:title],
403
+ ordinal: nil,
345
404
  new_title: "[#{Agentilda::NO_PLAN_PREFIX}] #{pull[:title]}",
346
- reason: "no plan resolved from the branch or the diff", ambiguous: false, assumed: true, adopted: false)
405
+ reason: "no plan resolved from the branch or the diff",
406
+ ambiguous: false,
407
+ assumed: true,
408
+ adopted: false)
347
409
  end
348
410
 
349
411
  # @param pull [Hash]
350
412
  # @param why [String]
351
413
  # @return [Agentilda::Resync::Prs::Change]
352
414
  def flag(pull, why)
353
- Change.new(number: pull[:number], title: pull[:title], new_title: nil, ordinal: nil,
354
- reason: why, ambiguous: true, assumed: false, adopted: false)
415
+ Change.new(number: pull[:number],
416
+ title: pull[:title],
417
+ new_title: nil,
418
+ ordinal: nil,
419
+ reason: why,
420
+ ambiguous: true,
421
+ assumed: false,
422
+ adopted: false)
355
423
  end
356
424
  end
357
425
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/status"
4
+ require "agentilda/state_machine"
5
+
3
6
  module Agentilda
4
7
  # `agentilda agents` — who the specialists are and what each is offered
5
8
  # work from. Reading one in full is {Viewer}'s job: the definition file is
@@ -21,6 +24,11 @@ module Agentilda
21
24
  # `run` never offers it work.
22
25
  READ_ONLY = "read-only"
23
26
 
27
+ # What an agent that handles only {StateMachine::SETTLED} states advances
28
+ # to. `run` never starts it; a command does, and `resync` then names the
29
+ # folder by what its files justify.
30
+ BY_CONTENTS = "by contents"
31
+
24
32
  # @param agents [Agentilda::Agents]
25
33
  def initialize(agents: Agents.new)
26
34
  @agents = agents
@@ -29,26 +37,33 @@ module Agentilda
29
37
  # @return [Agentilda::Agents]
30
38
  attr_reader :agents
31
39
 
32
- # One line per agent, in name order.
40
+ # One row per agent, in name order, framed by {UI.table}.
33
41
  #
34
42
  # @return [String] newline-terminated
35
43
  def list
36
44
  all = agents.all
37
45
  return "No agent definitions in #{agents.dir}\n" if all.empty?
38
46
 
39
- [header(all), *all.map { |agent| row(agent, all) }].join("\n") + "\n"
47
+ UI.table(all.map { |agent| row(agent) }, header: HEADINGS)
40
48
  end
41
49
 
42
50
  private
43
51
 
44
- # A state as the folder names write it, so what an agent handles reads the
45
- # same here as it does in `status` and on disk.
52
+ # A state as its words alone, deliberately without the emoji that every
53
+ # other rendering of a state carries. The table widget pads a cell to a
54
+ # width it measures in characters, while the terminal draws a status emoji
55
+ # in two cells; the variation-selector ones (⭕️ 🅱️ ⚪️ ⭐️ 🕰️) are two
56
+ # characters drawn in two cells, so no single count is right for all of
57
+ # them. A row carrying one lands short or long and the frame stops lining
58
+ # up. The label says the same thing unambiguously, and is worth more than
59
+ # the emoji is. {Reporter} lays its own columns out with {UI.fit} and so
60
+ # keeps the emoji.
46
61
  #
47
62
  # @param key [Symbol]
48
63
  # @return [String]
49
64
  def state(key)
50
65
  status = STATUS_BY_KEY[key] or return key.to_s
51
- "#{status.emoji} #{status.label}"
66
+ status.label
52
67
  end
53
68
 
54
69
  # @param agent [Agentilda::Agent]
@@ -57,54 +72,22 @@ module Agentilda
57
72
 
58
73
  # @param agent [Agentilda::Agent]
59
74
  # @return [String]
60
- def advances(agent) = agent.advances_to ? state(agent.advances_to) : READ_ONLY
75
+ def advances(agent)
76
+ return state(agent.advances_to) if agent.advances_to
77
+ return BY_CONTENTS if agent.handles.any? && agent.handles.all? { |key| StateMachine::SETTLED.include?(key) }
61
78
 
62
- # @param all [Array<Agentilda::Agent>]
63
- # @return [Integer]
64
- def name_width(all) = @name_width ||= all.map { |a| UI.display_width(a.name) }.max.to_i + 2
65
-
66
- # @param all [Array<Agentilda::Agent>]
67
- # @return [Integer]
68
- def handles_width(all)
69
- @handles_width ||= (all.map { |a| UI.display_width(handles(a)) } + [UI.display_width(HEADINGS[1])]).max.to_i + 2
79
+ READ_ONLY
70
80
  end
71
81
 
72
- # @param all [Array<Agentilda::Agent>]
73
- # @return [Integer]
74
- def advances_width(all)
75
- @advances_width ||= (all.map { |a| UI.display_width(advances(a)) } + [UI.display_width(HEADINGS[2])]).max.to_i + 2
76
- end
77
-
78
- # Cells arrive fitted and then painted, in that order: escape codes count
79
- # toward a string's length, so padding a coloured string pads it to a width
80
- # of which several characters are invisible. See {Reporter#row}.
82
+ # The widget measures and pads the cells, so they arrive painted only.
81
83
  #
82
- # @return [String]
83
- def line(name, handled, advances, model)
84
- format(" %s %s %s %s", name, handled, advances, model).rstrip
85
- end
86
-
87
- # @param all [Array<Agentilda::Agent>]
88
- # @return [String]
89
- def header(all)
90
- text = line(UI.fit(HEADINGS[0], name_width(all)),
91
- UI.fit(HEADINGS[1], handles_width(all)),
92
- UI.fit(HEADINGS[2], advances_width(all)),
93
- HEADINGS[3])
94
-
95
- rule = " " + ("─" * (UI.display_width(text) - 2))
96
-
97
- [UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
98
- end
99
-
100
84
  # @param agent [Agentilda::Agent]
101
- # @param all [Array<Agentilda::Agent>]
102
- # @return [String]
103
- def row(agent, all)
104
- line(UI.paint(UI.fit(agent.name, name_width(all)), :bright_cyan),
105
- UI.fit(handles(agent), handles_width(all)),
106
- UI.paint(UI.fit(advances(agent), advances_width(all)), agent.read_only? ? :bright_black : :green),
107
- UI.paint(agent.model.to_s, :bright_black))
85
+ # @return [Array<String>] one cell per entry in {HEADINGS}
86
+ def row(agent)
87
+ [UI.paint(agent.name, :bright_cyan),
88
+ handles(agent),
89
+ UI.paint(advances(agent), agent.read_only? ? :bright_black : :green),
90
+ UI.paint(agent.model.to_s, :bright_black)]
108
91
  end
109
92
  end
110
93
  end