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
@@ -1,233 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module Agentilda
6
- # The dated notes an agent leaves in the document it owns.
7
- #
8
- # This is the only place in the codebase that knows the syntax. The
9
- # dispatcher reads it to learn whether an agent finished and who it named
10
- # next; the state file records it; the screen paints it. An agent writes
11
- # one entry when it starts and one when it stops, in a GitHub NOTE alert,
12
- # one entry per line:
13
- #
14
- # > [!NOTE]
15
- # >
16
- # > [2026-09-04 11:29:20 AM PDT] [ agent: leah-researcher status: Started, round 1 ]
17
- # > [2026-09-04 11:44:03 AM PDT] [ agent: leah-researcher status: Completed, round 1 ]
18
- # > [2026-09-04 11:44:04 AM PDT] [ next: yoda-writer ]
19
- #
20
- # The verbs are a closed set. A line that looks like an entry but does not
21
- # parse is reported as a {Problem}, never dropped, because an agent that
22
- # wrote "Done" has said nothing the harness can act on and silence would
23
- # read as a stall.
24
- module Ledger
25
- # Everything an agent may claim. `Completed` is the only verb a `next:`
26
- # line may follow.
27
- STATUSES = ["Started", "Completed", "Almost completed", "Interrupted", "Blocked"].freeze
28
-
29
- # Local time with the zone spelled out, so an entry written on one
30
- # machine reads correctly on another.
31
- TIME_FORMAT = "%Y-%m-%d %I:%M:%S %p %Z"
32
-
33
- # An entry. `**` around the status is how the harness marks the one line
34
- # it writes itself, the Interrupted that precedes a signature.
35
- ENTRY = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*agent:\s*(?<agent>[\w-]+)\s+status:\s*(?<bold>\*\*)?
36
- (?<status>#{STATUSES.map { |s| Regexp.escape(s) }.join("|")}),\s*round\s*(?<round>\d+)
37
- (?:\s*\((?<note>[^)]*)\))?\s*(?:\*\*)?\s*\]\s*\z/x
38
-
39
- # The handoff line.
40
- NEXT = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*next:\s*(?<next>[\w-]+)\s*\]\s*\z/
41
-
42
- # A line that is trying to be one of the two above. Anything matching
43
- # this and neither of those is a {Problem}.
44
- ATTEMPT = /\A>\s*\[[^\]]+\]\s*\[/
45
-
46
- # The whole alert block, for {.stripped}.
47
- BLOCK = /^[ \t]*>[ \t]*\[!NOTE\][ \t]*\n(?:[ \t]*>.*\n?)*/
48
-
49
- # @!attribute [r] at
50
- # @return [Time]
51
- # @!attribute [r] agent
52
- # @return [String] the definition's name, `leah-researcher`
53
- # @!attribute [r] status
54
- # @return [String] one of {STATUSES}
55
- # @!attribute [r] round
56
- # @return [Integer]
57
- # @!attribute [r] note
58
- # @return [String, nil] the parenthesised tail, `rejected 1/2`
59
- # @!attribute [r] file
60
- # @return [String] the document it was read from
61
- # @!attribute [r] line
62
- # @return [Integer] 1-based
63
- # @!attribute [r] bold
64
- # @return [Boolean] written `**bold**`, which only the harness does
65
- Entry = Data.define(:at, :agent, :status, :round, :note, :file, :line, :bold) do
66
- def initialize(note: nil, bold: false, **rest) = super
67
-
68
- # @return [Boolean]
69
- def completed? = status == "Completed"
70
-
71
- # @return [Boolean] whether the agent gets another round
72
- def retry? = ["Almost completed", "Interrupted"].include?(status)
73
-
74
- # @return [Boolean]
75
- def blocked? = status == "Blocked"
76
- end
77
-
78
- # @!attribute [r] next
79
- # @return [String] the agent named
80
- Handoff = Data.define(:at, :next, :file, :line)
81
-
82
- # @!attribute [r] text
83
- # @return [String] the line as written
84
- Problem = Data.define(:file, :line, :text)
85
-
86
- # What one read of a plan folder yielded.
87
- Reading = Data.define(:entries, :handoffs, :problems) do
88
- # @return [Agentilda::Ledger::Reading]
89
- def self.empty = new(entries: [], handoffs: [], problems: [])
90
-
91
- # @param other [Agentilda::Ledger::Reading]
92
- # @return [Agentilda::Ledger::Reading]
93
- def +(other)
94
- self.class.new(entries: entries + other.entries,
95
- handoffs: handoffs + other.handoffs,
96
- problems: problems + other.problems)
97
- end
98
- end
99
-
100
- class << self
101
- # @param text [String] a whole document
102
- # @param file [String] its name, carried on every entry
103
- # @return [Agentilda::Ledger::Reading]
104
- def parse(text, file:)
105
- entries = []
106
- handoffs = []
107
- problems = []
108
- text.to_s.each_line.with_index(1) do |raw, line|
109
- stripped = raw.chomp
110
- if (m = ENTRY.match(stripped))
111
- entries << Entry.new(at: parse_time(m[:at]),
112
- agent: m[:agent],
113
- status: m[:status],
114
- round: m[:round].to_i,
115
- note: m[:note]&.strip,
116
- file:,
117
- line:,
118
- bold: !m[:bold].nil?)
119
- elsif (m = NEXT.match(stripped))
120
- handoffs << Handoff.new(at: parse_time(m[:at]), next: m[:next], file:, line:)
121
- elsif ATTEMPT.match?(stripped)
122
- problems << Problem.new(file:, line:, text: stripped.strip)
123
- end
124
- end
125
- Reading.new(entries:, handoffs:, problems:)
126
- end
127
-
128
- # Every document an agent may sign, in the order its definition lists
129
- # them. A missing file is not a problem; an agent that has not reached
130
- # `pull-requests.md` yet has not written it.
131
- #
132
- # @param dir [String] the plan folder
133
- # @param files [Array<String>]
134
- # @return [Agentilda::Ledger::Reading]
135
- def read(dir, files)
136
- files.each_with_index.reduce(Reading.empty) do |reading, (name, _index)|
137
- path = File.join(dir, name)
138
- next reading unless File.file?(path)
139
-
140
- found = parse(File.read(path, encoding: "UTF-8"), file: name)
141
- reading + Reading.new(entries: found.entries.map { |e| e.with(file: name) },
142
- handoffs: found.handoffs,
143
- problems: found.problems)
144
- end
145
- end
146
-
147
- # @param time [Time]
148
- # @return [String]
149
- def timestamp(time = Time.now) = time.strftime(TIME_FORMAT)
150
-
151
- # Agents write what `date` prints, and `Time.strptime` cannot read a
152
- # zone abbreviation back into an offset. The abbreviation is dropped
153
- # and the time read as local, which is right on the machine that wrote
154
- # it and off by a zone anywhere else, which the tick tolerates: entries
155
- # are compared with each other, never with the wall clock.
156
- #
157
- # @param text [String]
158
- # @return [Time]
159
- def parse_time(text)
160
- Time.strptime(text.to_s.strip.sub(/\s+[A-Z]{2,5}\z/, ""), "%Y-%m-%d %I:%M:%S %p")
161
- rescue ArgumentError
162
- Time.at(0)
163
- end
164
-
165
- # @param entry [Agentilda::Ledger::Entry]
166
- # @return [String] one quoted line, no newline
167
- def render(entry)
168
- tail = entry.note ? " (#{entry.note})" : ""
169
- body = "#{entry.status}, round #{entry.round}#{tail}"
170
- body = "**#{body}**" if entry.bold
171
- "> [#{timestamp(entry.at)}] [ agent: #{entry.agent} status: #{body} ]"
172
- end
173
-
174
- # @param handoff [Agentilda::Ledger::Handoff]
175
- # @return [String]
176
- def render_handoff(handoff) = "> [#{timestamp(handoff.at)}] [ next: #{handoff.next} ]"
177
-
178
- # @param lines [Array<String>] already quoted
179
- # @return [String] a NOTE alert, newline-terminated
180
- def block(*lines) = (["> [!NOTE]", ">"] + lines.flatten).join("\n") + "\n"
181
-
182
- # Append one block. Separated from what is there by a blank line so the
183
- # alert renders as its own block rather than merging into a paragraph.
184
- #
185
- # @param path [String]
186
- # @param lines [Array<String>]
187
- # @return [void]
188
- def append(path, *lines)
189
- existing = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
190
- glue = if existing.empty?
191
- ""
192
- elsif existing.end_with?("\n\n")
193
- ""
194
- elsif existing.end_with?("\n")
195
- "\n"
196
- else
197
- "\n\n"
198
- end
199
- File.write(path, existing + glue + block(*lines))
200
- end
201
-
202
- # The entry that tells the harness where this agent stands: the latest
203
- # by clock, then by the order its documents are listed, then by line.
204
- #
205
- # @param reading [Agentilda::Ledger::Reading]
206
- # @param agent [String]
207
- # @return [Agentilda::Ledger::Entry, nil]
208
- def last_for(reading, agent)
209
- files = reading.entries.map(&:file).uniq
210
- reading.entries.select { |e| e.agent == agent }
211
- .max_by { |e| [e.at, files.index(e.file), e.line] }
212
- end
213
-
214
- # The `next:` that belongs to an entry: same file, a later line, and no
215
- # other entry between the two. A handoff written under somebody else's
216
- # entry is not this agent's handoff.
217
- #
218
- # @param reading [Agentilda::Ledger::Reading]
219
- # @param entry [Agentilda::Ledger::Entry]
220
- # @return [Agentilda::Ledger::Handoff, nil]
221
- def handoff_after(reading, entry)
222
- candidates = reading.handoffs.select { |h| h.file == entry.file && h.line > entry.line }
223
- handoff = candidates.min_by(&:line) or return nil
224
- between = reading.entries.any? { |e| e.file == entry.file && e.line > entry.line && e.line < handoff.line }
225
- between ? nil : handoff
226
- end
227
-
228
- # @param text [String]
229
- # @return [String] the document without its ledger blocks
230
- def stripped(text) = text.to_s.gsub(BLOCK, "")
231
- end
232
- end
233
- end
@@ -1,141 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # The one channel two agents on the same plan have to each other.
5
- #
6
- # `luke-backend` and `rey-frontend` build one plan in one worktree at the
7
- # same time, but each is its own `claude -p` process. Claude Code can
8
- # message between local sessions, and the prompts used to say so, but a
9
- # session is named after its directory plus a random suffix, so a pair had
10
- # to guess its partner from a list of every session on the machine by start
11
- # time. It worked once, by luck. With ten plans running there would have
12
- # been ten `agentilda-xx` entries and nothing to tell them apart.
13
- #
14
- # So the mailbox is a file in the plan folder, append-only, one numbered
15
- # entry per message, readable by `agentilda mail read` during the round and
16
- # by a person after it. The harness names the file and the partner in each
17
- # agent's prompt, so nobody guesses.
18
- class Mailbox
19
- FILENAME = "mailbox.md"
20
-
21
- # Between the fields of a heading. Neither a name nor a timestamp
22
- # contains it, which is what makes the heading parseable.
23
- SEPARATOR = " · "
24
-
25
- # `## 3 · 2026-09-06 10:30:53 -0700 · rey-frontend → luke-backend`
26
- HEADING = /\A\#\# (\d+) · (.+?) · (\S+) → (\S+)\s*\z/
27
-
28
- TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z"
29
-
30
- # One entry.
31
- #
32
- # @!attribute [r] number
33
- # @return [Integer] 1-based, in order of writing
34
- # @!attribute [r] at
35
- # @return [String] when, as {TIME_FORMAT} writes it
36
- # @!attribute [r] from
37
- # @return [String] the agent that wrote it
38
- # @!attribute [r] to
39
- # @return [String] the agent it is for
40
- # @!attribute [r] body
41
- # @return [String]
42
- Message = Data.define(:number, :at, :from, :to, :body) do
43
- # @return [String] as it stands in the file
44
- def to_s = "## #{number}#{SEPARATOR}#{at}#{SEPARATOR}#{from} → #{to}\n\n#{body.strip}\n"
45
- end
46
-
47
- # @param dir [String] the plan folder
48
- def initialize(dir:)
49
- @dir = dir
50
- end
51
-
52
- # @return [String] the plan folder
53
- attr_reader :dir
54
-
55
- # @return [String]
56
- def path = File.join(dir, FILENAME)
57
-
58
- # @return [Boolean]
59
- def exist? = File.file?(path)
60
-
61
- # Every message, in the order it was written.
62
- #
63
- # @return [Array<Agentilda::Mailbox::Message>]
64
- def messages
65
- return [] unless exist?
66
-
67
- parse(File.read(path, encoding: "UTF-8"))
68
- end
69
-
70
- # What is waiting for one agent.
71
- #
72
- # @param name [String] the reader
73
- # @param after [Integer] a number the reader has already seen; only
74
- # messages above it are returned
75
- # @return [Array<Agentilda::Mailbox::Message>]
76
- def for(name, after: 0)
77
- messages.select { |m| m.to == name && m.number > after }
78
- end
79
-
80
- # Append one message and give it the next number.
81
- #
82
- # The append takes a lock on the file. Two agents finishing a unit in the
83
- # same second would otherwise read the same last number and both write
84
- # the one after it.
85
- #
86
- # @param from [String]
87
- # @param to [String]
88
- # @param body [String]
89
- # @param at [Time]
90
- # @return [Agentilda::Mailbox::Message] as written
91
- # @raise [Agentilda::Error] on an empty body, which would be a heading
92
- # with nothing under it for the reader to act on, or on a sender or
93
- # recipient that is blank or holds whitespace: {HEADING} could not
94
- # parse such an entry back, so it would be written and never read
95
- def append(from:, to:, body:, at: Time.now)
96
- text = body.to_s.strip
97
- raise Error, "a message needs a body" if text.empty?
98
- raise Error, "a message needs --from and --to, each one agent name" unless name?(from) && name?(to)
99
-
100
- File.open(path, File::RDWR | File::CREAT | File::APPEND, 0o644) do |f|
101
- f.flock(File::LOCK_EX)
102
- f.rewind
103
- existing = f.read
104
- number = (parse(existing).last&.number || 0) + 1
105
- message = Message.new(number:, at: at.strftime(TIME_FORMAT), from:, to:, body: text)
106
- f.write(existing.empty? ? "# Mailbox\n\n#{message}" : "\n#{message}")
107
- message
108
- end
109
- end
110
-
111
- private
112
-
113
- # @param value [Object]
114
- # @return [Boolean] one token, as {HEADING} expects a name to be
115
- def name?(value) = value.to_s.match?(/\A\S+\z/)
116
-
117
- # Only a line shaped exactly like {HEADING} starts a message, so a body
118
- # may hold headings of its own, code, anything.
119
- #
120
- # @param text [String]
121
- # @return [Array<Agentilda::Mailbox::Message>]
122
- def parse(text)
123
- messages = []
124
- current = nil
125
- body = []
126
-
127
- text.to_s.each_line(chomp: true) do |line|
128
- if (heading = HEADING.match(line))
129
- messages << current.with(body: body.join("\n").strip) if current
130
- current = Message.new(number: heading[1].to_i, at: heading[2], from: heading[3], to: heading[4], body: "")
131
- body = []
132
- elsif current
133
- body << line
134
- end
135
- end
136
-
137
- messages << current.with(body: body.join("\n").strip) if current
138
- messages
139
- end
140
- end
141
- end
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # A plan's number, and therefore its identity. Set once when the folder is
5
- # created and never changed: branch names, pull request titles and every
6
- # `pull-requests.md` join on it, and renumbering breaks all of them silently.
7
- #
8
- # Always rendered `NNN.MM`. `002.00` is an ordinary plan, specified before it
9
- # was built. `002.01` is retroactive — work that shipped between 002 and 003
10
- # and was documented afterwards. The decimal marks a **sibling**, not
11
- # containment: 002.01 is not part of 002.
12
- #
13
- # `000` is where the sequence starts, so the first plan of a project is
14
- # `000.00`. It also absorbs the older meaning — work that predates the plan
15
- # discipline — since either way it sorts first, which is where it belongs.
16
- #
17
- # @!attribute [r] major
18
- # @return [Integer] 0..999
19
- # @!attribute [r] minor
20
- # @return [Integer] 0..99; zero for an ordinary plan
21
- class Ordinal < Data.define(:major, :minor)
22
- include Comparable
23
-
24
- # The canonical form, plus the bare `NNN` that trees written before this
25
- # rule still use and must remain readable.
26
- PATTERN = /\A(\d{1,3})(?:\.(\d{1,2}))?\z/
27
-
28
- # The most retroactive slots a single gap can hold.
29
- MAX_MINOR = 99
30
-
31
- # @param text [String, nil] e.g. "3", "003", "003.00", "018.01"
32
- # @return [Agentilda::Ordinal, nil] nil when it is not a plan number
33
- def self.parse(text)
34
- m = PATTERN.match(text.to_s.strip)
35
- m && new(major: m[1].to_i, minor: m[2].to_i)
36
- end
37
-
38
- # Pull the number off the front of a folder name.
39
- #
40
- # @param dirname [String] e.g. "018.01-✅--verify-against-filed-returns"
41
- # @return [Agentilda::Ordinal, nil]
42
- def self.from_dirname(dirname) = parse(dirname.to_s[/\A[\d.]+/])
43
-
44
- # The next ordinary plan after everything in +existing+.
45
- #
46
- # The first plan in an empty tree is `000.00`, not `001.00`: the sequence
47
- # counts from zero so that the very first specification — usually the
48
- # project's own — sorts above everything and needs no gap reserved for it.
49
- #
50
- # @param existing [Array<Agentilda::Ordinal>]
51
- # @return [Agentilda::Ordinal]
52
- def self.next_major(existing)
53
- return new(major: 0, minor: 0) if existing.empty?
54
-
55
- new(major: existing.map(&:major).max + 1, minor: 0)
56
- end
57
-
58
- # The next retroactive slot in the gap after +major+.
59
- #
60
- # @param existing [Array<Agentilda::Ordinal>]
61
- # @param major [Integer] the plan the work landed after
62
- # @return [Agentilda::Ordinal]
63
- # @raise [Agentilda::Error] when the gap is full
64
- def self.next_minor(existing, major:)
65
- taken = existing.select { |o| o.major == major }.map(&:minor).max || 0
66
- if taken >= MAX_MINOR
67
- raise Error, "all #{MAX_MINOR} retroactive slots after #{format("%03d", major)} are taken"
68
- end
69
-
70
- new(major:, minor: taken + 1)
71
- end
72
-
73
- # @return [Boolean] whether this plan was documented after the fact
74
- def retroactive? = minor.positive?
75
-
76
- # @return [String] the canonical `NNN.MM`
77
- def to_s = format("%03d.%02d", major, minor)
78
-
79
- # @return [String] what a pull request title carries
80
- def to_prefix = "[#{self}]"
81
-
82
- # @param other [Object]
83
- # @return [Integer, nil]
84
- def <=>(other)
85
- return nil unless other.is_a?(self.class)
86
-
87
- [major, minor] <=> [other.major, other.minor]
88
- end
89
- end
90
- end
@@ -1,126 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "agentilda/status"
4
- require "agentilda/ui"
5
-
6
- module Agentilda
7
- # One line of `run --log FILE`, laid out as fixed-width columns.
8
- #
9
- # The log file is the only record of a headless run, and it is shared: two
10
- # `agentilda run` invocations pointed at the same `--log` both append to
11
- # it, interleaved, and so does every round within one run. That is what the
12
- # process id column is for. Without it a reader cannot tell one run's agents
13
- # from another's, and "started 003.00" twice looks like a bug rather than two
14
- # machines doing the same work.
15
- #
16
- # Fixed columns are what make the file readable by eye and by `awk` alike. A
17
- # value wider than its column is cut rather than allowed to push the columns
18
- # out, and a value that is missing still occupies its width, so a round header
19
- # with no plan or agent of its own lines up with the lines underneath it.
20
- #
21
- # @example
22
- # ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
23
- # agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
24
-
25
- # #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
26
- module ProgressLog
27
- # Wall clock only. A log line answers "when", and the date is the file's.
28
- TIME_FORMAT = "%H:%M:%S"
29
-
30
- # Width of {TIME_FORMAT}'s output.
31
- TIME_WIDTH = 8
32
-
33
- # "003.00".
34
- PLAN_WIDTH = 6
35
-
36
- # Derived from the widest state as {Status#to_s} renders it, emoji
37
- # included, so a state added to {STATUSES} with a longer label cannot
38
- # silently knock the columns after it out of line — then trimmed by five
39
- # cells: the emoji already names the state, so the longest labels can
40
- # afford to lose their tails to keep the line short.
41
- STATUS_WIDTH = STATUSES.map { |status| UI.display_width(status.to_s) }.max - 5
42
-
43
- # The longest specialist name in `agents/` is `palpatine-planner` at 17,
44
- # and the names are hyphenated words rather than a bounded vocabulary, so
45
- # this leaves room for one more without a reflow.
46
- AGENT_WIDTH = 20
47
-
48
- # Enough for a 32-bit process id, right justified.
49
- PID_WIDTH = 7
50
-
51
- # "01". Rounds cap at two digits; a run that reaches a third has bigger
52
- # problems than this column.
53
- ROUND_WIDTH = 2
54
-
55
- # " 907s". Anything longer than four digits of seconds is an agent nobody
56
- # is still waiting on.
57
- SECONDS_WIDTH = 6
58
-
59
- # Between columns, inside the brackets.
60
- SEPARATOR = " | "
61
-
62
- # Cells from the opening bracket to the closing one, separators included.
63
- # The message starts one space after this on every line, whatever fields
64
- # that line happens to be missing, so a reader indenting a wrapped message
65
- # has a number to indent by.
66
- COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
67
- SECONDS_WIDTH].freeze
68
-
69
- # @see COLUMN_WIDTHS
70
- LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
71
-
72
- class << self
73
- # Render one log line: bracketed fixed-width columns, then the message.
74
- #
75
- # No colour. The line goes to a file, where an escape sequence is neither
76
- # readable nor the width it claims to be.
77
- #
78
- # @param message [String] the free-form tail, the only variable-width part
79
- # @param plan [String, nil] the plan's ordinal, e.g. "003.00"
80
- # @param status [String, nil] emoji and label, e.g. "⭐️ Planned"
81
- # @param agent [String, nil] the specialist's name, e.g. "yoda-writer"
82
- # @param seconds [Numeric, nil] how long this agent has been alive
83
- # @param round [String, nil] which pass over the tree, e.g. "01"
84
- # @param pid [Integer, nil] which run wrote the line
85
- # @param at [Time] injectable, so a caller can render a fixed clock
86
- # @return [String] one line, with no trailing newline
87
- def render(message, plan: nil, status: nil, agent: nil, seconds: nil,
88
- round: nil, pid: Process.pid, at: Time.now)
89
- columns = [
90
- left(at.strftime(TIME_FORMAT), TIME_WIDTH),
91
- left(plan, PLAN_WIDTH),
92
- left(status, STATUS_WIDTH),
93
- left(agent, AGENT_WIDTH),
94
- right(round, ROUND_WIDTH),
95
- right(pid, PID_WIDTH),
96
- right(duration(seconds), SECONDS_WIDTH)
97
- ]
98
-
99
- text = message.to_s
100
- prefix = "[#{columns.join(SEPARATOR)}]"
101
- text.empty? ? prefix : "#{prefix} #{text}"
102
- end
103
-
104
- # @param seconds [Numeric, nil]
105
- # @return [String] whole seconds with a trailing "s", or blank for nil
106
- def duration(seconds) = seconds.nil? ? "" : "#{seconds.round}s"
107
-
108
- # @param value [Object, nil]
109
- # @param width [Integer] terminal cells
110
- # @return [String] padded on the right to exactly +width+ cells
111
- def left(value, width) = UI.fit(value, width)
112
-
113
- # {UI.fit} pads on the right, which is the wrong end for a number. Fit
114
- # first so an over-long value is still truncated to the column, then move
115
- # the padding to the front.
116
- #
117
- # @param value [Object, nil]
118
- # @param width [Integer] terminal cells
119
- # @return [String] padded on the left to exactly +width+ cells
120
- def right(value, width)
121
- text = UI.fit(value, width).rstrip
122
- UI.fit("", width - UI.display_width(text)) + text
123
- end
124
- end
125
- end
126
- end