agentilda 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
data/lib/agentilda/ledger.rb
DELETED
|
@@ -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
|
data/lib/agentilda/mailbox.rb
DELETED
|
@@ -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
|
data/lib/agentilda/ordinal.rb
DELETED
|
@@ -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
|