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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -0,0 +1,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}&nbsp;#{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(" &nbsp; ")
194
+
195
+ "\n---\n\n#{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} &nbsp; #{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