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.
- 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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "erb"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
module Plans
|
|
8
|
+
# `agentilda index` — the project's plans as one browsable page.
|
|
9
|
+
#
|
|
10
|
+
# {Reporter} answers "what is the state of things" in a terminal, in columns,
|
|
11
|
+
# for someone standing at a prompt. This answers "what is this project, and
|
|
12
|
+
# where is everything" for someone arriving at the repository on the web: the
|
|
13
|
+
# goal of each plan in its own words, every pull request as a link, and every
|
|
14
|
+
# document in the folder one click away.
|
|
15
|
+
#
|
|
16
|
+
# It is generated for the same reason the conventions document is. A
|
|
17
|
+
# hand-written index of twenty plans cannot survive a rename, and renaming is
|
|
18
|
+
# exactly what `resync dirs` exists to do — the first hand-made INDEX.md this
|
|
19
|
+
# replaced had all 82 of its links pointing at folder names that had been
|
|
20
|
+
# padded to `NNN.MM` underneath it.
|
|
21
|
+
class Index
|
|
22
|
+
# The file this writes, relative to the `.plans` directory.
|
|
23
|
+
FILENAME = "INDEX.md"
|
|
24
|
+
|
|
25
|
+
# Documents that get a proper name rather than their filename.
|
|
26
|
+
ARTIFACT_NAMES = {
|
|
27
|
+
"spec.md" => "Spec",
|
|
28
|
+
"plan.md" => "Plan",
|
|
29
|
+
"pull-requests.md" => "Pull Requests",
|
|
30
|
+
"linear.md" => "Linear",
|
|
31
|
+
"blocked.md" => "Blocked",
|
|
32
|
+
"delayed.md" => "Deferred",
|
|
33
|
+
"rewrite.md" => "Rewrite",
|
|
34
|
+
"deployed.md" => "Deployed",
|
|
35
|
+
"rollback.md" => "Rollback",
|
|
36
|
+
"discarded.md" => "Discarded"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
40
|
+
# @param project [String, nil] the heading; defaults to the repository name
|
|
41
|
+
def initialize(tree:, project: nil)
|
|
42
|
+
@tree = tree
|
|
43
|
+
@project = project
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Agentilda::Plans::Tree]
|
|
47
|
+
attr_reader :tree
|
|
48
|
+
|
|
49
|
+
# @return [String] the project's name, titleized from its directory
|
|
50
|
+
def project = @project ||= Agentilda::Plans.titleize(File.basename(File.dirname(tree.dir)))
|
|
51
|
+
|
|
52
|
+
# @return [String] the whole document
|
|
53
|
+
def render
|
|
54
|
+
[heading, *tree.subjects.map { |subject| section(subject) }].join("\n") + footer
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Write it next to the plans it describes.
|
|
58
|
+
#
|
|
59
|
+
# @param path [String, nil] override the destination
|
|
60
|
+
# @return [String] where it was written
|
|
61
|
+
def write(path = nil)
|
|
62
|
+
path ||= File.join(tree.dir, FILENAME)
|
|
63
|
+
File.write(path, render)
|
|
64
|
+
path
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# @return [String]
|
|
70
|
+
def heading
|
|
71
|
+
<<~MARKDOWN
|
|
72
|
+
# Project #{project}
|
|
73
|
+
|
|
74
|
+
> [!IMPORTANT]
|
|
75
|
+
> **This file is auto generated.** To regenerate it, run `agentilda index`.
|
|
76
|
+
> Editing it by hand lasts until the next `resync dirs` renames a folder.
|
|
77
|
+
|
|
78
|
+
## Current specifications and their status
|
|
79
|
+
|
|
80
|
+
MARKDOWN
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# One plan: a heading that carries its number and state, then a table.
|
|
84
|
+
#
|
|
85
|
+
# Raw HTML rather than a markdown table because the cells are not one-liners
|
|
86
|
+
# — a goal is a paragraph or two and a plan may have a dozen pull requests,
|
|
87
|
+
# neither of which a pipe-delimited row can hold.
|
|
88
|
+
#
|
|
89
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
90
|
+
# @return [String]
|
|
91
|
+
def section(subject)
|
|
92
|
+
feature = subject.feature
|
|
93
|
+
|
|
94
|
+
<<~MARKDOWN
|
|
95
|
+
## #{feature.ordinal} — #{feature.status.emoji} #{escape(feature.title)}
|
|
96
|
+
|
|
97
|
+
<table>
|
|
98
|
+
<thead>
|
|
99
|
+
<tr>
|
|
100
|
+
<th align="left">Status</th>
|
|
101
|
+
<th align="left">Pull Requests</th>
|
|
102
|
+
<th align="left">What it is</th>
|
|
103
|
+
<th align="left">Artifacts</th>
|
|
104
|
+
</tr>
|
|
105
|
+
</thead>
|
|
106
|
+
<tbody>
|
|
107
|
+
<tr>
|
|
108
|
+
<td valign="top">#{status_cell(subject)}</td>
|
|
109
|
+
<td valign="top">#{pulls_cell(subject)}</td>
|
|
110
|
+
<td valign="top">#{goal_cell(subject)}</td>
|
|
111
|
+
<td valign="top">#{artifacts_cell(feature)}</td>
|
|
112
|
+
</tr>
|
|
113
|
+
</tbody>
|
|
114
|
+
</table>
|
|
115
|
+
|
|
116
|
+
MARKDOWN
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The emoji and the words, kept on one line — a status that wraps mid-label
|
|
120
|
+
# makes the column look like two states rather than one.
|
|
121
|
+
#
|
|
122
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
123
|
+
# @return [String]
|
|
124
|
+
def status_cell(subject)
|
|
125
|
+
cell = "#{subject.status.emoji} #{escape(subject.status.label)}"
|
|
126
|
+
return cell if subject.consistent?
|
|
127
|
+
|
|
128
|
+
"#{cell}<br><sub>⚠️ #{escape(subject.violation)}</sub>"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
132
|
+
# @return [String]
|
|
133
|
+
def pulls_cell(subject)
|
|
134
|
+
prs = subject.pull_requests
|
|
135
|
+
return "<em>—</em>" if prs.empty?
|
|
136
|
+
|
|
137
|
+
items = prs.map { |pr|
|
|
138
|
+
"<li>#{link(pr.url, pr.label)} #{escape(pr.state)}</li>"
|
|
139
|
+
}
|
|
140
|
+
"<ul>#{items.join}</ul>"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
144
|
+
# @return [String]
|
|
145
|
+
def goal_cell(subject)
|
|
146
|
+
paragraphs = subject.goal
|
|
147
|
+
return "<em>No specification yet.</em>" if paragraphs.empty?
|
|
148
|
+
|
|
149
|
+
paragraphs.map { |p| "<p>#{inline(p)}</p>" }.join
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Markdown inside a raw HTML block is not parsed — GitHub stops parsing
|
|
153
|
+
# markdown the moment it sees a block-level tag — so a goal quoted verbatim
|
|
154
|
+
# would show its own `**` and backticks. Only the three that carry meaning
|
|
155
|
+
# are translated; single-underscore emphasis is deliberately left alone,
|
|
156
|
+
# because `deleted_at` is more common in these documents than italics.
|
|
157
|
+
#
|
|
158
|
+
# @param text [String] markdown, unescaped
|
|
159
|
+
# @return [String] HTML
|
|
160
|
+
def inline(text)
|
|
161
|
+
escape(text)
|
|
162
|
+
.gsub(/\[([^\]]+)\]\((https?:[^)\s]+)\)/) { %(<a href="#{$2}">#{$1}</a>) }
|
|
163
|
+
.gsub(/\*\*([^*]+)\*\*/) { "<strong>#{$1}</strong>" }
|
|
164
|
+
.gsub(/`([^`]+)`/) { "<code>#{$1}</code>" }
|
|
165
|
+
.tr("\n", " ")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Every document in the folder, linked. The folder name is percent-encoded
|
|
169
|
+
# because it contains an emoji, and a raw one in an href is a broken link
|
|
170
|
+
# on GitHub.
|
|
171
|
+
#
|
|
172
|
+
# @param feature [Agentilda::Plans::Feature]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def artifacts_cell(feature)
|
|
175
|
+
files = Dir.children(feature.path).select { |f| File.file?(File.join(feature.path, f)) }
|
|
176
|
+
return "<em>empty</em>" if files.empty?
|
|
177
|
+
|
|
178
|
+
# The documents that carry the lifecycle come first, in lifecycle order;
|
|
179
|
+
# whatever else the folder holds follows alphabetically.
|
|
180
|
+
known = ARTIFACT_NAMES.keys
|
|
181
|
+
files = files.sort_by { |f| [known.index(f) || known.size, f] }
|
|
182
|
+
|
|
183
|
+
items = files.map { |file|
|
|
184
|
+
href = "#{ERB::Util.url_encode(feature.dirname)}/#{ERB::Util.url_encode(file)}"
|
|
185
|
+
"<li>#{link(href, ARTIFACT_NAMES.fetch(file, file))}</li>"
|
|
186
|
+
}
|
|
187
|
+
"<ul>#{items.join}</ul>"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @return [String]
|
|
191
|
+
def footer
|
|
192
|
+
counts = tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status] += 1 }
|
|
193
|
+
.map { |status, n| "#{status.emoji} #{n}" }.join(" ")
|
|
194
|
+
|
|
195
|
+
"\n---\n\n#{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}\n"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @param href [String]
|
|
199
|
+
# @param text [String]
|
|
200
|
+
# @return [String]
|
|
201
|
+
def link(href, text) = %(<a href="#{escape(href)}">#{escape(text)}</a>)
|
|
202
|
+
|
|
203
|
+
# @param text [String]
|
|
204
|
+
# @return [String]
|
|
205
|
+
def escape(text) = CGI.escapeHTML(text.to_s)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
module Plans
|
|
7
|
+
# The dated notes an agent leaves in the document it owns.
|
|
8
|
+
#
|
|
9
|
+
# This is the only place in the codebase that knows the syntax. The
|
|
10
|
+
# dispatcher reads it to learn whether an agent finished and who it named
|
|
11
|
+
# next; the state file records it; the screen paints it. An agent writes
|
|
12
|
+
# one entry when it starts and one when it stops, in a GitHub NOTE alert,
|
|
13
|
+
# one entry per line:
|
|
14
|
+
#
|
|
15
|
+
# > [!NOTE]
|
|
16
|
+
# >
|
|
17
|
+
# > [2026-09-04 11:29:20 AM PDT] [ agent: leah-researcher status: Started, round 1 ]
|
|
18
|
+
# > [2026-09-04 11:44:03 AM PDT] [ agent: leah-researcher status: Completed, round 1 ]
|
|
19
|
+
# > [2026-09-04 11:44:04 AM PDT] [ next: yoda-writer ]
|
|
20
|
+
#
|
|
21
|
+
# The verbs are a closed set. A line that looks like an entry but does not
|
|
22
|
+
# parse is reported as a {Problem}, never dropped, because an agent that
|
|
23
|
+
# wrote "Done" has said nothing the harness can act on and silence would
|
|
24
|
+
# read as a stall.
|
|
25
|
+
module Ledger
|
|
26
|
+
# Everything an agent may claim. `Completed` is the only verb a `next:`
|
|
27
|
+
# line may follow.
|
|
28
|
+
STATUSES = ["Started", "Completed", "Almost completed", "Interrupted", "Blocked"].freeze
|
|
29
|
+
|
|
30
|
+
# Local time with the zone spelled out, so an entry written on one
|
|
31
|
+
# machine reads correctly on another.
|
|
32
|
+
TIME_FORMAT = "%Y-%m-%d %I:%M:%S %p %Z"
|
|
33
|
+
|
|
34
|
+
# An entry. `**` around the status is how the harness marks the one line
|
|
35
|
+
# it writes itself, the Interrupted that precedes a signature.
|
|
36
|
+
ENTRY = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*agent:\s*(?<agent>[\w-]+)\s+status:\s*(?<bold>\*\*)?
|
|
37
|
+
(?<status>#{STATUSES.map { |s| Regexp.escape(s) }.join("|")}),\s*round\s*(?<round>\d+)
|
|
38
|
+
(?:\s*\((?<note>[^)]*)\))?\s*(?:\*\*)?\s*\]\s*\z/x
|
|
39
|
+
|
|
40
|
+
# The handoff line.
|
|
41
|
+
NEXT = /\A>\s*\[(?<at>[^\]]+)\]\s*\[\s*next:\s*(?<next>[\w-]+)\s*\]\s*\z/
|
|
42
|
+
|
|
43
|
+
# A line that is trying to be one of the two above. Anything matching
|
|
44
|
+
# this and neither of those is a {Problem}.
|
|
45
|
+
ATTEMPT = /\A>\s*\[[^\]]+\]\s*\[/
|
|
46
|
+
|
|
47
|
+
# The whole alert block, for {.stripped}.
|
|
48
|
+
BLOCK = /^[ \t]*>[ \t]*\[!NOTE\][ \t]*\n(?:[ \t]*>.*\n?)*/
|
|
49
|
+
|
|
50
|
+
# @!attribute [r] at
|
|
51
|
+
# @return [Time]
|
|
52
|
+
# @!attribute [r] agent
|
|
53
|
+
# @return [String] the definition's name, `leah-researcher`
|
|
54
|
+
# @!attribute [r] status
|
|
55
|
+
# @return [String] one of {STATUSES}
|
|
56
|
+
# @!attribute [r] round
|
|
57
|
+
# @return [Integer]
|
|
58
|
+
# @!attribute [r] note
|
|
59
|
+
# @return [String, nil] the parenthesised tail, `rejected 1/2`
|
|
60
|
+
# @!attribute [r] file
|
|
61
|
+
# @return [String] the document it was read from
|
|
62
|
+
# @!attribute [r] line
|
|
63
|
+
# @return [Integer] 1-based
|
|
64
|
+
# @!attribute [r] bold
|
|
65
|
+
# @return [Boolean] written `**bold**`, which only the harness does
|
|
66
|
+
Entry = Data.define(:at, :agent, :status, :round, :note, :file, :line, :bold) do
|
|
67
|
+
def initialize(note: nil, bold: false, **rest) = super
|
|
68
|
+
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def completed? = status == "Completed"
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] whether the agent gets another round
|
|
73
|
+
def retry? = ["Almost completed", "Interrupted"].include?(status)
|
|
74
|
+
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def blocked? = status == "Blocked"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @!attribute [r] next
|
|
80
|
+
# @return [String] the agent named
|
|
81
|
+
Handoff = Data.define(:at, :next, :file, :line)
|
|
82
|
+
|
|
83
|
+
# @!attribute [r] text
|
|
84
|
+
# @return [String] the line as written
|
|
85
|
+
Problem = Data.define(:file, :line, :text)
|
|
86
|
+
|
|
87
|
+
# What one read of a plan folder yielded.
|
|
88
|
+
Reading = Data.define(:entries, :handoffs, :problems) do
|
|
89
|
+
# @return [Agentilda::Plans::Ledger::Reading]
|
|
90
|
+
def self.empty = new(entries: [], handoffs: [], problems: [])
|
|
91
|
+
|
|
92
|
+
# @param other [Agentilda::Plans::Ledger::Reading]
|
|
93
|
+
# @return [Agentilda::Plans::Ledger::Reading]
|
|
94
|
+
def +(other)
|
|
95
|
+
self.class.new(entries: entries + other.entries,
|
|
96
|
+
handoffs: handoffs + other.handoffs,
|
|
97
|
+
problems: problems + other.problems)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class << self
|
|
102
|
+
# @param text [String] a whole document
|
|
103
|
+
# @param file [String] its name, carried on every entry
|
|
104
|
+
# @return [Agentilda::Plans::Ledger::Reading]
|
|
105
|
+
def parse(text, file:)
|
|
106
|
+
entries = []
|
|
107
|
+
handoffs = []
|
|
108
|
+
problems = []
|
|
109
|
+
text.to_s.each_line.with_index(1) do |raw, line|
|
|
110
|
+
stripped = raw.chomp
|
|
111
|
+
if (m = ENTRY.match(stripped))
|
|
112
|
+
entries << Entry.new(at: parse_time(m[:at]),
|
|
113
|
+
agent: m[:agent],
|
|
114
|
+
status: m[:status],
|
|
115
|
+
round: m[:round].to_i,
|
|
116
|
+
note: m[:note]&.strip,
|
|
117
|
+
file:,
|
|
118
|
+
line:,
|
|
119
|
+
bold: !m[:bold].nil?)
|
|
120
|
+
elsif (m = NEXT.match(stripped))
|
|
121
|
+
handoffs << Handoff.new(at: parse_time(m[:at]), next: m[:next], file:, line:)
|
|
122
|
+
elsif ATTEMPT.match?(stripped)
|
|
123
|
+
problems << Problem.new(file:, line:, text: stripped.strip)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
Reading.new(entries:, handoffs:, problems:)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every document an agent may sign, in the order its definition lists
|
|
130
|
+
# them. A missing file is not a problem; an agent that has not reached
|
|
131
|
+
# `pull-requests.md` yet has not written it.
|
|
132
|
+
#
|
|
133
|
+
# @param dir [String] the plan folder
|
|
134
|
+
# @param files [Array<String>]
|
|
135
|
+
# @return [Agentilda::Plans::Ledger::Reading]
|
|
136
|
+
def read(dir, files)
|
|
137
|
+
files.each_with_index.reduce(Reading.empty) do |reading, (name, _index)|
|
|
138
|
+
path = File.join(dir, name)
|
|
139
|
+
next reading unless File.file?(path)
|
|
140
|
+
|
|
141
|
+
found = parse(File.read(path, encoding: "UTF-8"), file: name)
|
|
142
|
+
reading + Reading.new(entries: found.entries.map { |e| e.with(file: name) },
|
|
143
|
+
handoffs: found.handoffs,
|
|
144
|
+
problems: found.problems)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @param time [Time]
|
|
149
|
+
# @return [String]
|
|
150
|
+
def timestamp(time = Time.now) = time.strftime(TIME_FORMAT)
|
|
151
|
+
|
|
152
|
+
# Agents write what `date` prints, and `Time.strptime` cannot read a
|
|
153
|
+
# zone abbreviation back into an offset. The abbreviation is dropped
|
|
154
|
+
# and the time read as local, which is right on the machine that wrote
|
|
155
|
+
# it and off by a zone anywhere else, which the tick tolerates: entries
|
|
156
|
+
# are compared with each other, never with the wall clock.
|
|
157
|
+
#
|
|
158
|
+
# @param text [String]
|
|
159
|
+
# @return [Time]
|
|
160
|
+
def parse_time(text)
|
|
161
|
+
Time.strptime(text.to_s.strip.sub(/\s+[A-Z]{2,5}\z/, ""), "%Y-%m-%d %I:%M:%S %p")
|
|
162
|
+
rescue ArgumentError
|
|
163
|
+
Time.at(0)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @param entry [Agentilda::Plans::Ledger::Entry]
|
|
167
|
+
# @return [String] one quoted line, no newline
|
|
168
|
+
def render(entry)
|
|
169
|
+
tail = entry.note ? " (#{entry.note})" : ""
|
|
170
|
+
body = "#{entry.status}, round #{entry.round}#{tail}"
|
|
171
|
+
body = "**#{body}**" if entry.bold
|
|
172
|
+
"> [#{timestamp(entry.at)}] [ agent: #{entry.agent} status: #{body} ]"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @param handoff [Agentilda::Plans::Ledger::Handoff]
|
|
176
|
+
# @return [String]
|
|
177
|
+
def render_handoff(handoff) = "> [#{timestamp(handoff.at)}] [ next: #{handoff.next} ]"
|
|
178
|
+
|
|
179
|
+
# @param lines [Array<String>] already quoted
|
|
180
|
+
# @return [String] a NOTE alert, newline-terminated
|
|
181
|
+
def block(*lines) = (["> [!NOTE]", ">"] + lines.flatten).join("\n") + "\n"
|
|
182
|
+
|
|
183
|
+
# Append one block. Separated from what is there by a blank line so the
|
|
184
|
+
# alert renders as its own block rather than merging into a paragraph.
|
|
185
|
+
#
|
|
186
|
+
# @param path [String]
|
|
187
|
+
# @param lines [Array<String>]
|
|
188
|
+
# @return [void]
|
|
189
|
+
def append(path, *lines)
|
|
190
|
+
existing = File.file?(path) ? File.read(path, encoding: "UTF-8") : ""
|
|
191
|
+
glue = if existing.empty?
|
|
192
|
+
""
|
|
193
|
+
elsif existing.end_with?("\n\n")
|
|
194
|
+
""
|
|
195
|
+
elsif existing.end_with?("\n")
|
|
196
|
+
"\n"
|
|
197
|
+
else
|
|
198
|
+
"\n\n"
|
|
199
|
+
end
|
|
200
|
+
File.write(path, existing + glue + block(*lines))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The entry that tells the harness where this agent stands: the latest
|
|
204
|
+
# by clock, then by the order its documents are listed, then by line.
|
|
205
|
+
#
|
|
206
|
+
# @param reading [Agentilda::Plans::Ledger::Reading]
|
|
207
|
+
# @param agent [String]
|
|
208
|
+
# @return [Agentilda::Plans::Ledger::Entry, nil]
|
|
209
|
+
def last_for(reading, agent)
|
|
210
|
+
files = reading.entries.map(&:file).uniq
|
|
211
|
+
reading.entries.select { |e| e.agent == agent }
|
|
212
|
+
.max_by { |e| [e.at, files.index(e.file), e.line] }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The `next:` that belongs to an entry: same file, a later line, and no
|
|
216
|
+
# other entry between the two. A handoff written under somebody else's
|
|
217
|
+
# entry is not this agent's handoff.
|
|
218
|
+
#
|
|
219
|
+
# @param reading [Agentilda::Plans::Ledger::Reading]
|
|
220
|
+
# @param entry [Agentilda::Plans::Ledger::Entry]
|
|
221
|
+
# @return [Agentilda::Plans::Ledger::Handoff, nil]
|
|
222
|
+
def handoff_after(reading, entry)
|
|
223
|
+
candidates = reading.handoffs.select { |h| h.file == entry.file && h.line > entry.line }
|
|
224
|
+
handoff = candidates.min_by(&:line) or return nil
|
|
225
|
+
between = reading.entries.any? { |e| e.file == entry.file && e.line > entry.line && e.line < handoff.line }
|
|
226
|
+
between ? nil : handoff
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @param text [String]
|
|
230
|
+
# @return [String] the document without its ledger blocks
|
|
231
|
+
def stripped(text) = text.to_s.gsub(BLOCK, "")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Plans
|
|
5
|
+
# The one channel two agents on the same plan have to each other.
|
|
6
|
+
#
|
|
7
|
+
# `luke-backend` and `rey-frontend` build one plan in one worktree at the
|
|
8
|
+
# same time, but each is its own `claude -p` process. Claude Code can
|
|
9
|
+
# message between local sessions, and the prompts used to say so, but a
|
|
10
|
+
# session is named after its directory plus a random suffix, so a pair had
|
|
11
|
+
# to guess its partner from a list of every session on the machine by start
|
|
12
|
+
# time. It worked once, by luck. With ten plans running there would have
|
|
13
|
+
# been ten `agentilda-xx` entries and nothing to tell them apart.
|
|
14
|
+
#
|
|
15
|
+
# So the mailbox is a file in the plan folder, append-only, one numbered
|
|
16
|
+
# entry per message, readable by `agentilda mail read` during the round and
|
|
17
|
+
# by a person after it. The harness names the file and the partner in each
|
|
18
|
+
# agent's prompt, so nobody guesses.
|
|
19
|
+
class Mailbox
|
|
20
|
+
FILENAME = "mailbox.md"
|
|
21
|
+
|
|
22
|
+
# Between the fields of a heading. Neither a name nor a timestamp
|
|
23
|
+
# contains it, which is what makes the heading parseable.
|
|
24
|
+
SEPARATOR = " · "
|
|
25
|
+
|
|
26
|
+
# `## 3 · 2026-09-06 10:30:53 -0700 · rey-frontend → luke-backend`
|
|
27
|
+
HEADING = /\A\#\# (\d+) · (.+?) · (\S+) → (\S+)\s*\z/
|
|
28
|
+
|
|
29
|
+
TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z"
|
|
30
|
+
|
|
31
|
+
# One entry.
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] number
|
|
34
|
+
# @return [Integer] 1-based, in order of writing
|
|
35
|
+
# @!attribute [r] at
|
|
36
|
+
# @return [String] when, as {TIME_FORMAT} writes it
|
|
37
|
+
# @!attribute [r] from
|
|
38
|
+
# @return [String] the agent that wrote it
|
|
39
|
+
# @!attribute [r] to
|
|
40
|
+
# @return [String] the agent it is for
|
|
41
|
+
# @!attribute [r] body
|
|
42
|
+
# @return [String]
|
|
43
|
+
Message = Data.define(:number, :at, :from, :to, :body) do
|
|
44
|
+
# @return [String] as it stands in the file
|
|
45
|
+
def to_s = "## #{number}#{SEPARATOR}#{at}#{SEPARATOR}#{from} → #{to}\n\n#{body.strip}\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param dir [String] the plan folder
|
|
49
|
+
def initialize(dir:)
|
|
50
|
+
@dir = dir
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String] the plan folder
|
|
54
|
+
attr_reader :dir
|
|
55
|
+
|
|
56
|
+
# @return [String]
|
|
57
|
+
def path = File.join(dir, FILENAME)
|
|
58
|
+
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def exist? = File.file?(path)
|
|
61
|
+
|
|
62
|
+
# Every message, in the order it was written.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<Agentilda::Plans::Mailbox::Message>]
|
|
65
|
+
def messages
|
|
66
|
+
return [] unless exist?
|
|
67
|
+
|
|
68
|
+
parse(File.read(path, encoding: "UTF-8"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# What is waiting for one agent.
|
|
72
|
+
#
|
|
73
|
+
# @param name [String] the reader
|
|
74
|
+
# @param after [Integer] a number the reader has already seen; only
|
|
75
|
+
# messages above it are returned
|
|
76
|
+
# @return [Array<Agentilda::Plans::Mailbox::Message>]
|
|
77
|
+
def for(name, after: 0)
|
|
78
|
+
messages.select { |m| m.to == name && m.number > after }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Append one message and give it the next number.
|
|
82
|
+
#
|
|
83
|
+
# The append takes a lock on the file. Two agents finishing a unit in the
|
|
84
|
+
# same second would otherwise read the same last number and both write
|
|
85
|
+
# the one after it.
|
|
86
|
+
#
|
|
87
|
+
# @param from [String]
|
|
88
|
+
# @param to [String]
|
|
89
|
+
# @param body [String]
|
|
90
|
+
# @param at [Time]
|
|
91
|
+
# @return [Agentilda::Plans::Mailbox::Message] as written
|
|
92
|
+
# @raise [Agentilda::Error] on an empty body, which would be a heading
|
|
93
|
+
# with nothing under it for the reader to act on, or on a sender or
|
|
94
|
+
# recipient that is blank or holds whitespace: {HEADING} could not
|
|
95
|
+
# parse such an entry back, so it would be written and never read
|
|
96
|
+
def append(from:, to:, body:, at: Time.now)
|
|
97
|
+
text = body.to_s.strip
|
|
98
|
+
raise Error, "a message needs a body" if text.empty?
|
|
99
|
+
raise Error, "a message needs --from and --to, each one agent name" unless name?(from) && name?(to)
|
|
100
|
+
|
|
101
|
+
File.open(path, File::RDWR | File::CREAT | File::APPEND, 0o644) do |f|
|
|
102
|
+
f.flock(File::LOCK_EX)
|
|
103
|
+
f.rewind
|
|
104
|
+
existing = f.read
|
|
105
|
+
number = (parse(existing).last&.number || 0) + 1
|
|
106
|
+
message = Message.new(number:, at: at.strftime(TIME_FORMAT), from:, to:, body: text)
|
|
107
|
+
f.write(existing.empty? ? "# Mailbox\n\n#{message}" : "\n#{message}")
|
|
108
|
+
message
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# @param value [Object]
|
|
115
|
+
# @return [Boolean] one token, as {HEADING} expects a name to be
|
|
116
|
+
def name?(value) = value.to_s.match?(/\A\S+\z/)
|
|
117
|
+
|
|
118
|
+
# Only a line shaped exactly like {HEADING} starts a message, so a body
|
|
119
|
+
# may hold headings of its own, code, anything.
|
|
120
|
+
#
|
|
121
|
+
# @param text [String]
|
|
122
|
+
# @return [Array<Agentilda::Plans::Mailbox::Message>]
|
|
123
|
+
def parse(text)
|
|
124
|
+
messages = []
|
|
125
|
+
current = nil
|
|
126
|
+
body = []
|
|
127
|
+
|
|
128
|
+
text.to_s.each_line(chomp: true) do |line|
|
|
129
|
+
if (heading = HEADING.match(line))
|
|
130
|
+
messages << current.with(body: body.join("\n").strip) if current
|
|
131
|
+
current = Message.new(number: heading[1].to_i, at: heading[2], from: heading[3], to: heading[4], body: "")
|
|
132
|
+
body = []
|
|
133
|
+
elsif current
|
|
134
|
+
body << line
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
messages << current.with(body: body.join("\n").strip) if current
|
|
139
|
+
messages
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Plans
|
|
5
|
+
# A plan's number, and therefore its identity. Set once when the folder is
|
|
6
|
+
# created and never changed: branch names, pull request titles and every
|
|
7
|
+
# `pull-requests.md` join on it, and renumbering breaks all of them silently.
|
|
8
|
+
#
|
|
9
|
+
# Always rendered `NNN.MM`. `002.00` is an ordinary plan, specified before it
|
|
10
|
+
# was built. `002.01` is retroactive — work that shipped between 002 and 003
|
|
11
|
+
# and was documented afterwards. The decimal marks a **sibling**, not
|
|
12
|
+
# containment: 002.01 is not part of 002.
|
|
13
|
+
#
|
|
14
|
+
# `000` is where the sequence starts, so the first plan of a project is
|
|
15
|
+
# `000.00`. It also absorbs the older meaning — work that predates the plan
|
|
16
|
+
# discipline — since either way it sorts first, which is where it belongs.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] major
|
|
19
|
+
# @return [Integer] 0..999
|
|
20
|
+
# @!attribute [r] minor
|
|
21
|
+
# @return [Integer] 0..99; zero for an ordinary plan
|
|
22
|
+
class Ordinal < Data.define(:major, :minor)
|
|
23
|
+
include Comparable
|
|
24
|
+
|
|
25
|
+
# The canonical form, plus the bare `NNN` that trees written before this
|
|
26
|
+
# rule still use and must remain readable.
|
|
27
|
+
PATTERN = /\A(\d{1,3})(?:\.(\d{1,2}))?\z/
|
|
28
|
+
|
|
29
|
+
# The most retroactive slots a single gap can hold.
|
|
30
|
+
MAX_MINOR = 99
|
|
31
|
+
|
|
32
|
+
# @param text [String, nil] e.g. "3", "003", "003.00", "018.01"
|
|
33
|
+
# @return [Agentilda::Plans::Ordinal, nil] nil when it is not a plan number
|
|
34
|
+
def self.parse(text)
|
|
35
|
+
m = PATTERN.match(text.to_s.strip)
|
|
36
|
+
m && new(major: m[1].to_i, minor: m[2].to_i)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Pull the number off the front of a folder name.
|
|
40
|
+
#
|
|
41
|
+
# @param dirname [String] e.g. "018.01-✅--verify-against-filed-returns"
|
|
42
|
+
# @return [Agentilda::Plans::Ordinal, nil]
|
|
43
|
+
def self.from_dirname(dirname) = parse(dirname.to_s[/\A[\d.]+/])
|
|
44
|
+
|
|
45
|
+
# The next ordinary plan after everything in +existing+.
|
|
46
|
+
#
|
|
47
|
+
# The first plan in an empty tree is `000.00`, not `001.00`: the sequence
|
|
48
|
+
# counts from zero so that the very first specification — usually the
|
|
49
|
+
# project's own — sorts above everything and needs no gap reserved for it.
|
|
50
|
+
#
|
|
51
|
+
# @param existing [Array<Agentilda::Plans::Ordinal>]
|
|
52
|
+
# @return [Agentilda::Plans::Ordinal]
|
|
53
|
+
def self.next_major(existing)
|
|
54
|
+
return new(major: 0, minor: 0) if existing.empty?
|
|
55
|
+
|
|
56
|
+
new(major: existing.map(&:major).max + 1, minor: 0)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The next retroactive slot in the gap after +major+.
|
|
60
|
+
#
|
|
61
|
+
# @param existing [Array<Agentilda::Plans::Ordinal>]
|
|
62
|
+
# @param major [Integer] the plan the work landed after
|
|
63
|
+
# @return [Agentilda::Plans::Ordinal]
|
|
64
|
+
# @raise [Agentilda::Error] when the gap is full
|
|
65
|
+
def self.next_minor(existing, major:)
|
|
66
|
+
taken = existing.select { |o| o.major == major }.map(&:minor).max || 0
|
|
67
|
+
if taken >= MAX_MINOR
|
|
68
|
+
raise Error, "all #{MAX_MINOR} retroactive slots after #{format("%03d", major)} are taken"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
new(major:, minor: taken + 1)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] whether this plan was documented after the fact
|
|
75
|
+
def retroactive? = minor.positive?
|
|
76
|
+
|
|
77
|
+
# @return [String] the canonical `NNN.MM`
|
|
78
|
+
def to_s = format("%03d.%02d", major, minor)
|
|
79
|
+
|
|
80
|
+
# @return [String] what a pull request title carries
|
|
81
|
+
def to_prefix = "[#{self}]"
|
|
82
|
+
|
|
83
|
+
# @param other [Object]
|
|
84
|
+
# @return [Integer, nil]
|
|
85
|
+
def <=>(other)
|
|
86
|
+
return nil unless other.is_a?(self.class)
|
|
87
|
+
|
|
88
|
+
[major, minor] <=> [other.major, other.minor]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|