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/status.rb
DELETED
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Agentilda
|
|
4
|
-
# A folder's state: what it means, which files it cannot be honest without,
|
|
5
|
-
# and the rule that decides whether the folder may legitimately claim it.
|
|
6
|
-
#
|
|
7
|
-
# `invariant` is asked in both directions. {StateMachine} asks it of a
|
|
8
|
-
# destination before moving there; `resync` and `check` ask it of the current
|
|
9
|
-
# state, to catch folders whose name has drifted from their contents. One
|
|
10
|
-
# definition, so the two can never disagree about what a state means.
|
|
11
|
-
#
|
|
12
|
-
# @!attribute [r] key
|
|
13
|
-
# @return [Symbol] machine-facing name, e.g. `:product_blocked`
|
|
14
|
-
# @!attribute [r] emoji
|
|
15
|
-
# @return [String] as it appears in the folder name
|
|
16
|
-
# @!attribute [r] label
|
|
17
|
-
# @return [String] the human words
|
|
18
|
-
# @!attribute [r] requires
|
|
19
|
-
# @return [Array<String>] files this state cannot be honest without
|
|
20
|
-
# @!attribute [r] note
|
|
21
|
-
# @return [String] one-line meaning, for tables and generated docs
|
|
22
|
-
# @!attribute [r] invariant
|
|
23
|
-
# @return [Proc, nil] `(subject) -> String | nil` — the reason it does not hold
|
|
24
|
-
Status = Data.define(:key, :emoji, :label, :requires, :note, :invariant) do
|
|
25
|
-
# @param subject [#file?, #read, #pull_requests]
|
|
26
|
-
# @return [Boolean]
|
|
27
|
-
def satisfied_by?(subject) = violation(subject).nil?
|
|
28
|
-
|
|
29
|
-
# @param subject [#file?, #read, #pull_requests]
|
|
30
|
-
# @return [String, nil] why this state is not justified, nil when it is
|
|
31
|
-
def violation(subject)
|
|
32
|
-
missing = requires.reject { |f| subject.file?(f) }
|
|
33
|
-
return "#{label} requires #{missing.map { |f| "`#{f}`" }.join(" and ")}" unless missing.empty?
|
|
34
|
-
|
|
35
|
-
invariant&.call(subject)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# @return [Boolean] whether nothing may follow this state
|
|
39
|
-
def terminal? = StateMachine.outbound(key).empty?
|
|
40
|
-
|
|
41
|
-
# @return [String] emoji and words, as the tables print it
|
|
42
|
-
def to_s = "#{emoji} #{label}"
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# "1 pull request", "3 pull requests".
|
|
46
|
-
#
|
|
47
|
-
# @param count [Integer]
|
|
48
|
-
# @return [String]
|
|
49
|
-
def self.pull_request_count(count) = "#{count} pull request#{"s" unless count == 1}"
|
|
50
|
-
|
|
51
|
-
# What proves a specification has been researched rather than merely
|
|
52
|
-
# written: the chapter `leah-researcher` contributes. It is a section of
|
|
53
|
-
# `spec.md` rather than a file of its own because research is not a separate
|
|
54
|
-
# document — it is the first half of the specification, and splitting it
|
|
55
|
-
# would leave `yoda-writer` reading two files to write one.
|
|
56
|
-
RESEARCH_CHAPTER = /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i
|
|
57
|
-
|
|
58
|
-
# What proves `plan.md` holds a plan rather than the blank file
|
|
59
|
-
# `yoda-writer` leaves for `palpatine-planner`: any section heading. The
|
|
60
|
-
# ledger notes are stripped first, so an agent's `Started` line does not
|
|
61
|
-
# count as a plan.
|
|
62
|
-
PLAN_HEADING = /^[ \t]{0,3}\#{1,3}[ \t]+\S/
|
|
63
|
-
|
|
64
|
-
# Variation selectors make ⚪️ and ⚪ different strings that mean the same
|
|
65
|
-
# thing to a human. Compare with them removed.
|
|
66
|
-
#
|
|
67
|
-
# @param str [String, nil]
|
|
68
|
-
# @return [String]
|
|
69
|
-
def self.fold_emoji(str) = str.to_s.gsub(/[\u{FE0E}\u{FE0F}\u{200D}]/, "")
|
|
70
|
-
|
|
71
|
-
# 🟢 Ready for Review, 👀 In Review and 🔴 Changes Requested are the same
|
|
72
|
-
# thing on disk — the work exists and at least one pull request is still
|
|
73
|
-
# open. Which of the three it is lives in the folder name and nowhere else,
|
|
74
|
-
# so they share one invariant rather than three that could drift apart.
|
|
75
|
-
#
|
|
76
|
-
# @param label [String]
|
|
77
|
-
# @return [Proc]
|
|
78
|
-
def self.open_pull_request(label)
|
|
79
|
-
lambda { |subject|
|
|
80
|
-
return "#{label} requires an open pull request; none are recorded" if subject.pull_requests.empty?
|
|
81
|
-
|
|
82
|
-
"#{label}, but every pull request is already merged" if subject.pull_requests.none?(&:open?)
|
|
83
|
-
}
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# A question that is still somebody's to answer, written as its own heading:
|
|
87
|
-
# `## B1`, `## B2`. The number is what `blocked.md` gets referenced by in
|
|
88
|
-
# conversation and in pull requests, so it is also the only part of the file
|
|
89
|
-
# a program can count.
|
|
90
|
-
#
|
|
91
|
-
# Whoever writes `blocked.md` MUST use this notation. A question written any
|
|
92
|
-
# other way is invisible to every part of this tool: the folder never becomes
|
|
93
|
-
# ⭕️, and `unblock` reports a file with nothing in it to drain. {ANSWER_BLOCK}
|
|
94
|
-
# is the other half of the pair.
|
|
95
|
-
OPEN_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**B(\d+)\b/
|
|
96
|
-
|
|
97
|
-
# The answer to the question of the same number: `## A1` settles `## B1`.
|
|
98
|
-
#
|
|
99
|
-
# Answers are an inbox, not a record. `lando-broker` folds each one into the
|
|
100
|
-
# document its question was stopping and deletes both, so a `blocked.md`
|
|
101
|
-
# holding nothing but history must not go on stopping the folder. An `A`
|
|
102
|
-
# never matches {OPEN_BLOCK}, which is what lets the two live in one file.
|
|
103
|
-
ANSWER_BLOCK = /^[ \t]{0,3}\#{0,4}[ \t]*\**A(\d+)\b/
|
|
104
|
-
|
|
105
|
-
# The numbers named by every heading in +text+ matching +pattern+.
|
|
106
|
-
#
|
|
107
|
-
# @param text [String, nil]
|
|
108
|
-
# @param pattern [Regexp] {OPEN_BLOCK} or {ANSWER_BLOCK}
|
|
109
|
-
# @return [Array<Integer>] in the order they appear, without duplicates
|
|
110
|
-
def self.block_numbers(text, pattern)
|
|
111
|
-
text.to_s.lines.filter_map { |line| line[pattern, 1]&.to_i }.uniq
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# ⭕️ Technical Block and 🅱️ Product Block are the same thing on disk: a
|
|
115
|
-
# `blocked.md` still naming at least one question nobody has answered. Which
|
|
116
|
-
# human is owed the answer lives in the folder name and nowhere else, so the
|
|
117
|
-
# two share one invariant rather than two that could drift apart.
|
|
118
|
-
#
|
|
119
|
-
# This is what lets a block drain in pieces. Answers land in `blocked.md`
|
|
120
|
-
# one at a time, `lando-broker` moves each into `spec.md` or `plan.md` and
|
|
121
|
-
# deletes it here, and the folder leaves ⭕️ on the pass that empties the
|
|
122
|
-
# file, by content rather than by anyone remembering to delete it.
|
|
123
|
-
#
|
|
124
|
-
# @param label [String]
|
|
125
|
-
# @return [Proc]
|
|
126
|
-
def self.open_block(label)
|
|
127
|
-
lambda { |subject|
|
|
128
|
-
"#{label}, but `blocked.md` names no open question" unless subject.read("blocked.md").to_s.match?(OPEN_BLOCK)
|
|
129
|
-
}
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# Every state a plan folder may be in, in lifecycle order. That order drives
|
|
133
|
-
# the legend, the generated documentation and the `--to` help text.
|
|
134
|
-
#
|
|
135
|
-
# 🟣 Merged is deliberately absent: it is a pull request's status, not a
|
|
136
|
-
# folder's, and giving it a folder state invites folders that claim a pull
|
|
137
|
-
# request's condition as their own.
|
|
138
|
-
#
|
|
139
|
-
# Adding a state here, or changing an emoji, makes the hand-drawn
|
|
140
|
-
# `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
|
|
141
|
-
# the mermaid source in the generated document moves with this list.
|
|
142
|
-
STATUSES = [
|
|
143
|
-
Status.new(
|
|
144
|
-
key: :new,
|
|
145
|
-
emoji: "⚪️",
|
|
146
|
-
label: "New",
|
|
147
|
-
requires: %w[spec.md],
|
|
148
|
-
note: "a specification exists; it has not been planned yet",
|
|
149
|
-
invariant: nil
|
|
150
|
-
),
|
|
151
|
-
Status.new(
|
|
152
|
-
key: :researched,
|
|
153
|
-
emoji: "🔎",
|
|
154
|
-
label: "Researched",
|
|
155
|
-
requires: %w[spec.md],
|
|
156
|
-
note: "the topic has been researched; `spec.md` carries a `## Research` chapter",
|
|
157
|
-
invariant: lambda { |s|
|
|
158
|
-
body = s.read("spec.md").to_s
|
|
159
|
-
"Researched, but `spec.md` has no `## Research` chapter" unless body.match?(RESEARCH_CHAPTER)
|
|
160
|
-
}
|
|
161
|
-
),
|
|
162
|
-
Status.new(
|
|
163
|
-
key: :ready_for_planning,
|
|
164
|
-
emoji: "📋",
|
|
165
|
-
label: "Ready for Planning",
|
|
166
|
-
requires: %w[spec.md plan.md],
|
|
167
|
-
note: "the specification is finished; `plan.md` exists and is still blank, waiting for the planner",
|
|
168
|
-
invariant: lambda { |s|
|
|
169
|
-
body = Ledger.stripped(s.read("plan.md"))
|
|
170
|
-
"Ready for Planning, but `plan.md` already holds a plan" if body.match?(PLAN_HEADING)
|
|
171
|
-
}
|
|
172
|
-
),
|
|
173
|
-
Status.new(
|
|
174
|
-
key: :planned,
|
|
175
|
-
emoji: "⭐️",
|
|
176
|
-
label: "Planned",
|
|
177
|
-
requires: %w[spec.md plan.md],
|
|
178
|
-
note: "specified and planned; nobody has started building",
|
|
179
|
-
invariant: lambda { |s|
|
|
180
|
-
body = Ledger.stripped(s.read("plan.md"))
|
|
181
|
-
next "Planned, but `plan.md` has no work units yet" unless body.match?(PLAN_HEADING)
|
|
182
|
-
|
|
183
|
-
# An open pull request means somebody did start. Without this
|
|
184
|
-
# clause a ✅ folder found with a new pull request would fall back
|
|
185
|
-
# to ⭐️, the family's floor, and claim nobody had touched it.
|
|
186
|
-
open = s.pull_requests.count(&:open?)
|
|
187
|
-
"Planned, but #{Agentilda.pull_request_count(open)} already open" if open.positive?
|
|
188
|
-
}
|
|
189
|
-
),
|
|
190
|
-
# `pull-requests.md` is deliberately absent from `requires` on both of the
|
|
191
|
-
# building states, though it names the file they are about. That file is
|
|
192
|
-
# written by opening a pull request, which happens once, when the last
|
|
193
|
-
# implementer advances the plan to Ready for Review. Requiring the file to
|
|
194
|
-
# *enter* Building would mean nothing could ever justify entering it, and
|
|
195
|
-
# `palpatine-planner` finishing `plan.md` would loop forever between itself
|
|
196
|
-
# and a state it can never actually reach. Ready for Review is where the
|
|
197
|
-
# file becomes a real requirement, once something has had the chance to
|
|
198
|
-
# write it.
|
|
199
|
-
Status.new(
|
|
200
|
-
key: :building,
|
|
201
|
-
emoji: "🟡",
|
|
202
|
-
label: "Building",
|
|
203
|
-
requires: %w[spec.md plan.md],
|
|
204
|
-
note: "the back end is under way: data, domain and the API the interface will call",
|
|
205
|
-
invariant: lambda { |s|
|
|
206
|
-
body = Ledger.stripped(s.read("plan.md"))
|
|
207
|
-
"Building, but `plan.md` has no work units to build" unless body.match?(PLAN_HEADING)
|
|
208
|
-
}
|
|
209
|
-
),
|
|
210
|
-
# Two building states rather than one, because the order is not a
|
|
211
|
-
# preference: an interface is written against an API that already answers.
|
|
212
|
-
# Splitting them lets each half be built by someone who only has to be good
|
|
213
|
-
# at that half, and gives the second a working system to build on rather
|
|
214
|
-
# than a description of one.
|
|
215
|
-
Status.new(
|
|
216
|
-
key: :building_ui,
|
|
217
|
-
emoji: "🎨",
|
|
218
|
-
label: "Building UI",
|
|
219
|
-
requires: %w[spec.md plan.md],
|
|
220
|
-
note: "the back end holds; the interface is being built against it",
|
|
221
|
-
invariant: lambda { |s|
|
|
222
|
-
body = Ledger.stripped(s.read("plan.md"))
|
|
223
|
-
"Building UI, but `plan.md` has no work units to build" unless body.match?(PLAN_HEADING)
|
|
224
|
-
}
|
|
225
|
-
),
|
|
226
|
-
Status.new(
|
|
227
|
-
key: :ready_for_review,
|
|
228
|
-
emoji: "🟢",
|
|
229
|
-
label: "Ready for Review",
|
|
230
|
-
requires: %w[spec.md plan.md pull-requests.md],
|
|
231
|
-
note: "every pull request is green on CI and waiting for a reviewer",
|
|
232
|
-
invariant: open_pull_request("Ready for Review")
|
|
233
|
-
),
|
|
234
|
-
Status.new(
|
|
235
|
-
key: :in_review,
|
|
236
|
-
emoji: "👀",
|
|
237
|
-
label: "In Review",
|
|
238
|
-
requires: %w[spec.md plan.md pull-requests.md],
|
|
239
|
-
note: "a reviewer has picked it up and has not ruled yet",
|
|
240
|
-
invariant: open_pull_request("In Review")
|
|
241
|
-
),
|
|
242
|
-
Status.new(
|
|
243
|
-
key: :rejected,
|
|
244
|
-
emoji: "🔴",
|
|
245
|
-
label: "Changes Requested",
|
|
246
|
-
requires: %w[spec.md plan.md pull-requests.md],
|
|
247
|
-
note: "the review asked for fixes; resubmit once they are made",
|
|
248
|
-
invariant: open_pull_request("Changes Requested")
|
|
249
|
-
),
|
|
250
|
-
Status.new(
|
|
251
|
-
key: :approved,
|
|
252
|
-
emoji: "✅",
|
|
253
|
-
label: "Approved & Merged",
|
|
254
|
-
requires: %w[pull-requests.md],
|
|
255
|
-
note: "reviewed, approved, and every pull request merged",
|
|
256
|
-
invariant: lambda { |s|
|
|
257
|
-
return "Approved & Merged, but no pull requests are recorded" if s.pull_requests.empty?
|
|
258
|
-
|
|
259
|
-
open = s.pull_requests.count(&:open?)
|
|
260
|
-
"Approved & Merged, but #{Agentilda.pull_request_count(open)} still open" if open.positive?
|
|
261
|
-
}
|
|
262
|
-
),
|
|
263
|
-
Status.new(
|
|
264
|
-
key: :deployed,
|
|
265
|
-
emoji: "😎",
|
|
266
|
-
label: "Deployed",
|
|
267
|
-
requires: %w[deployed.md],
|
|
268
|
-
note: "live in production; `deployed.md` names the release, date and SHA",
|
|
269
|
-
invariant: nil
|
|
270
|
-
),
|
|
271
|
-
Status.new(
|
|
272
|
-
key: :rolled_back,
|
|
273
|
-
emoji: "😱",
|
|
274
|
-
label: "Rolled Back",
|
|
275
|
-
requires: %w[rollback.md],
|
|
276
|
-
note: "it shipped and was pulled; `rollback.md` names what broke",
|
|
277
|
-
invariant: nil
|
|
278
|
-
),
|
|
279
|
-
Status.new(
|
|
280
|
-
key: :shit,
|
|
281
|
-
emoji: "💩",
|
|
282
|
-
label: "Scrapped by Review",
|
|
283
|
-
requires: %w[rewrite.md],
|
|
284
|
-
note: "the review scrapped the work; the plan survives, the pull requests do not",
|
|
285
|
-
invariant: nil
|
|
286
|
-
),
|
|
287
|
-
Status.new(
|
|
288
|
-
key: :blocked,
|
|
289
|
-
emoji: "⭕️",
|
|
290
|
-
label: "Technical Block",
|
|
291
|
-
requires: %w[blocked.md],
|
|
292
|
-
note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide",
|
|
293
|
-
invariant: open_block("Technical Block")
|
|
294
|
-
),
|
|
295
|
-
Status.new(
|
|
296
|
-
key: :product_blocked,
|
|
297
|
-
emoji: "🅱️",
|
|
298
|
-
label: "Product Block",
|
|
299
|
-
requires: %w[blocked.md],
|
|
300
|
-
note: "cannot proceed; `blocked.md` names what a product manager must decide",
|
|
301
|
-
invariant: open_block("Product Block")
|
|
302
|
-
),
|
|
303
|
-
Status.new(
|
|
304
|
-
key: :deferred,
|
|
305
|
-
emoji: "☢️",
|
|
306
|
-
label: "Deferred",
|
|
307
|
-
requires: %w[delayed.md],
|
|
308
|
-
note: "could proceed and chose not to yet; `delayed.md` must name the trigger",
|
|
309
|
-
invariant: lambda { |s|
|
|
310
|
-
body = s.read("delayed.md").to_s
|
|
311
|
-
"Deferred, but `delayed.md` names no trigger" unless body.match?(/trigger|revisit|when\b|until\b|once\b/i)
|
|
312
|
-
}
|
|
313
|
-
),
|
|
314
|
-
Status.new(
|
|
315
|
-
key: :retroactive,
|
|
316
|
-
emoji: "🕰️",
|
|
317
|
-
label: "Retroactive",
|
|
318
|
-
requires: [],
|
|
319
|
-
note: "the feature is live, but has neither a specification nor a plan",
|
|
320
|
-
invariant: lambda { |s|
|
|
321
|
-
next "Retroactive, but a `spec.md` already exists — it has been documented" if s.file?("spec.md")
|
|
322
|
-
|
|
323
|
-
"Retroactive, but no pull requests are recorded" if s.pull_requests.empty?
|
|
324
|
-
}
|
|
325
|
-
),
|
|
326
|
-
Status.new(
|
|
327
|
-
key: :discarded,
|
|
328
|
-
emoji: "❌",
|
|
329
|
-
label: "Discarded",
|
|
330
|
-
requires: %w[discarded.md],
|
|
331
|
-
note: "dropped for good; `discarded.md` says why. A terminal state",
|
|
332
|
-
invariant: nil
|
|
333
|
-
)
|
|
334
|
-
].freeze
|
|
335
|
-
|
|
336
|
-
# Emoji => status, folded so ⚪ and ⚪️ both resolve.
|
|
337
|
-
STATUS_BY_EMOJI = STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }.freeze
|
|
338
|
-
|
|
339
|
-
# Symbol => status.
|
|
340
|
-
STATUS_BY_KEY = STATUSES.to_h { |s| [s.key, s] }.freeze
|
|
341
|
-
|
|
342
|
-
# @param emoji [String, nil]
|
|
343
|
-
# @return [Agentilda::Status, nil]
|
|
344
|
-
def self.status_for_emoji(emoji) = STATUS_BY_EMOJI[fold_emoji(emoji)]
|
|
345
|
-
|
|
346
|
-
# Resolve a state from the only two names it has: its key, and its emoji.
|
|
347
|
-
#
|
|
348
|
-
# There is deliberately no synonym table. A state that also answers to
|
|
349
|
-
# "star", "green" and "completed" has four names to keep in step with the
|
|
350
|
-
# folder, the generated docs and the agent frontmatter — and the synonym
|
|
351
|
-
# table that used to live here went stale pointing six words at a state that
|
|
352
|
-
# no longer existed.
|
|
353
|
-
#
|
|
354
|
-
# @param text [String, Symbol, Agentilda::Status, nil]
|
|
355
|
-
# @return [Agentilda::Status, nil]
|
|
356
|
-
def self.status(text)
|
|
357
|
-
return nil if text.nil?
|
|
358
|
-
return text if text.is_a?(Status)
|
|
359
|
-
|
|
360
|
-
key = text.to_s.strip
|
|
361
|
-
STATUS_BY_KEY[key.to_sym] || status_for_emoji(key)
|
|
362
|
-
end
|
|
363
|
-
end
|
data/lib/agentilda/subject.rb
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "agentilda/feature"
|
|
4
|
-
require "agentilda/pull_requests"
|
|
5
|
-
require "agentilda/state_machine"
|
|
6
|
-
|
|
7
|
-
module Agentilda
|
|
8
|
-
# A plan folder as the state machine sees it: which files exist, what they
|
|
9
|
-
# say, and which pull requests they record.
|
|
10
|
-
#
|
|
11
|
-
# It exists because the invariants ask the same questions repeatedly and
|
|
12
|
-
# {Feature} is a frozen `Data` with nowhere to memoize the answers.
|
|
13
|
-
class Subject
|
|
14
|
-
# @param feature [Agentilda::Feature]
|
|
15
|
-
def initialize(feature)
|
|
16
|
-
@feature = feature
|
|
17
|
-
@reads = {}
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# @return [Agentilda::Feature]
|
|
21
|
-
attr_reader :feature
|
|
22
|
-
|
|
23
|
-
# @return [Agentilda::Status] the status the folder name claims
|
|
24
|
-
def status = feature.status
|
|
25
|
-
|
|
26
|
-
# @param name [String] a bare filename
|
|
27
|
-
# @return [Boolean]
|
|
28
|
-
def file?(name) = File.file?(File.join(feature.path, name))
|
|
29
|
-
|
|
30
|
-
# @param name [String]
|
|
31
|
-
# @return [String, nil] contents, or nil when absent
|
|
32
|
-
def read(name)
|
|
33
|
-
@reads.fetch(name) do
|
|
34
|
-
@reads[name] = file?(name) ? File.read(File.join(feature.path, name), encoding: "UTF-8") : nil
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# @return [Array<Agentilda::PullRequest>]
|
|
39
|
-
def pull_requests = @pull_requests ||= PullRequests.new(dir: feature.path).all
|
|
40
|
-
|
|
41
|
-
# The specification's own Goal section, verbatim and at most two
|
|
42
|
-
# paragraphs. Anything that paraphrases the spec is a second copy that
|
|
43
|
-
# drifts; quoting it is not — which is why the pull request body and the
|
|
44
|
-
# index both read it from here rather than each writing their own.
|
|
45
|
-
#
|
|
46
|
-
# @return [Array<String>] paragraphs, empty when there is no Goal to read
|
|
47
|
-
def goal
|
|
48
|
-
body = read("spec.md").to_s
|
|
49
|
-
section = body[/^\#{"#"}{2,3}\s*Goals?\b[^\n]*\n+(.*?)(?=\n\#{"#"}{1,3}\s|\z)/mi, 1]
|
|
50
|
-
|
|
51
|
-
paragraphs(section) || paragraphs(body.sub(/\A\s*\#{"#"}[^\n]*\n/, "")) || []
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Specifications written before the template existed have no Goal section,
|
|
55
|
-
# and they are exactly the ones an index most needs to describe. So the
|
|
56
|
-
# opening prose stands in — skipping headings, quotes, lists and tables,
|
|
57
|
-
# which describe the document rather than the work.
|
|
58
|
-
#
|
|
59
|
-
# @param text [String, nil]
|
|
60
|
-
# @return [Array<String>, nil] nil when there is no prose to be had
|
|
61
|
-
def paragraphs(text)
|
|
62
|
-
found = text.to_s.strip.split(/\n{2,}/)
|
|
63
|
-
.map(&:strip)
|
|
64
|
-
.reject { |p| p.empty? || p.match?(/\A[\#>|\-*\d`_=]/) }
|
|
65
|
-
.first(2)
|
|
66
|
-
|
|
67
|
-
found.empty? ? nil : found
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# The questions `blocked.md` still names, by number.
|
|
71
|
-
#
|
|
72
|
-
# Empty means one of two very different things, and a caller that treats
|
|
73
|
-
# them alike is how a folder with thirty kilobytes of open questions gets
|
|
74
|
-
# reported as "nothing left open": either there is no `blocked.md` at all,
|
|
75
|
-
# or there is one whose questions are not written as `## B<n>` and are
|
|
76
|
-
# therefore invisible to every part of this tool. Ask {#file?} which.
|
|
77
|
-
#
|
|
78
|
-
# @return [Array<Integer>]
|
|
79
|
-
def open_blocks = Agentilda.block_numbers(read("blocked.md"), OPEN_BLOCK)
|
|
80
|
-
|
|
81
|
-
# The answers waiting in `blocked.md`, by number. `## A1` settles `## B1`.
|
|
82
|
-
#
|
|
83
|
-
# Waiting, not folded. An `## A<n>` heading is not the same as a settled
|
|
84
|
-
# question: one may say in its own body that it is a draft pending a
|
|
85
|
-
# conversation. `lando-broker` makes that call; this only counts headings.
|
|
86
|
-
#
|
|
87
|
-
# @return [Array<Integer>]
|
|
88
|
-
def block_answers = Agentilda.block_numbers(read("blocked.md"), ANSWER_BLOCK)
|
|
89
|
-
|
|
90
|
-
# A `blocked.md` this tool cannot read: the file is there, and not one
|
|
91
|
-
# question in it is written as `## B<n>`. Nothing can drain it and nothing
|
|
92
|
-
# currently says so, which is the whole reason this exists.
|
|
93
|
-
#
|
|
94
|
-
# @return [Boolean]
|
|
95
|
-
def unreadable_block? = file?("blocked.md") && open_blocks.empty?
|
|
96
|
-
|
|
97
|
-
# @return [String, nil] why the folder's name is not justified
|
|
98
|
-
def violation = status.violation(self)
|
|
99
|
-
|
|
100
|
-
# @return [Boolean] whether the name matches the contents
|
|
101
|
-
def consistent? = violation.nil?
|
|
102
|
-
|
|
103
|
-
# @return [Agentilda::StateMachine] positioned at the current state
|
|
104
|
-
def machine = StateMachine.new(self)
|
|
105
|
-
|
|
106
|
-
# @return [Array<Symbol>] states reachable right now, guards applied
|
|
107
|
-
def allowed = machine.allowed
|
|
108
|
-
|
|
109
|
-
# @return [Agentilda::Status, nil] the state these contents justify
|
|
110
|
-
def best_fit = machine.best_fit
|
|
111
|
-
|
|
112
|
-
# Move the folder into +status+ — the side effect a transition *is*.
|
|
113
|
-
#
|
|
114
|
-
# The {Feature} is a frozen `Data` holding the old name, so it is replaced
|
|
115
|
-
# rather than mutated, and the memoized reads go with it.
|
|
116
|
-
#
|
|
117
|
-
# @param status [Agentilda::Status]
|
|
118
|
-
# @return [Agentilda::Feature] the feature under its new name
|
|
119
|
-
# @raise [Agentilda::Error] when the target name is already taken
|
|
120
|
-
def rename_to(status)
|
|
121
|
-
return @feature if status.key == @feature.status.key
|
|
122
|
-
|
|
123
|
-
target = File.join(File.dirname(@feature.path), @feature.dirname_as(status))
|
|
124
|
-
unless Agentilda.move_directory(@feature.path, target)
|
|
125
|
-
raise Error, "cannot rename #{@feature.dirname} — #{File.basename(target)} already exists"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
@reads = {}
|
|
129
|
-
@pull_requests = nil
|
|
130
|
-
@feature = Feature.parse(target) or raise Error, "#{File.basename(target)} is not a plan folder"
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|
data/lib/agentilda/tally.rb
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Agentilda
|
|
4
|
-
# What a `run` cost, once it is over.
|
|
5
|
-
#
|
|
6
|
-
# The loop reports movement — which plan went from ⭐️ to 🟡, which agent
|
|
7
|
-
# failed and why — and says nothing about the bill. A round of five agents
|
|
8
|
-
# against a large repository spends several million tokens and the better
|
|
9
|
-
# part of an hour, and until this existed the only record of that was a
|
|
10
|
-
# directory of trace files nobody reads.
|
|
11
|
-
#
|
|
12
|
-
# {#render} returns a string and prints nothing, so the caller decides where
|
|
13
|
-
# it goes, the same way {Reporter} works.
|
|
14
|
-
class Tally
|
|
15
|
-
# One agent's share of the run, added up across every round it ran in.
|
|
16
|
-
#
|
|
17
|
-
# @!attribute [r] name
|
|
18
|
-
# @return [String]
|
|
19
|
-
# @!attribute [r] invocations
|
|
20
|
-
# @return [Integer] times this agent was started
|
|
21
|
-
# @!attribute [r] subagents
|
|
22
|
-
# @return [Integer] sub-agents it spawned, across all of them
|
|
23
|
-
# @!attribute [r] up
|
|
24
|
-
# @return [Integer] tokens sent, sub-agent spend included
|
|
25
|
-
# @!attribute [r] down
|
|
26
|
-
# @return [Integer] tokens generated
|
|
27
|
-
# @!attribute [r] seconds
|
|
28
|
-
# @return [Float] how long it was running, added up
|
|
29
|
-
Row = Data.define(:name, :invocations, :subagents, :up, :down, :seconds)
|
|
30
|
-
|
|
31
|
-
# Cells per column, so the header, the rows and the rule cannot drift.
|
|
32
|
-
WIDTHS = { name: 22, invocations: 6, subagents: 11, up: 9, down: 9, seconds: 8 }.freeze
|
|
33
|
-
|
|
34
|
-
# @param attempts [Array<Agentilda::Runner::Attempt>]
|
|
35
|
-
# @param seconds [Float] wall clock for the whole loop, which is not the
|
|
36
|
-
# sum of the agents' own times: several of them run at once
|
|
37
|
-
# @param rounds [Integer, nil] accepted so an older caller still works, and
|
|
38
|
-
# otherwise unused: the loop has no rounds of its own any more, each
|
|
39
|
-
# agent counts its own per plan, so the summary counts invocations
|
|
40
|
-
def initialize(attempts:, seconds:, rounds: nil)
|
|
41
|
-
@attempts = attempts.reject { |a| a.ordinal == "?" }
|
|
42
|
-
@seconds = seconds
|
|
43
|
-
@rounds = rounds
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# @return [Array<Agentilda::Runner::Attempt>]
|
|
47
|
-
attr_reader :attempts
|
|
48
|
-
|
|
49
|
-
# @return [Float] wall clock
|
|
50
|
-
attr_reader :seconds
|
|
51
|
-
|
|
52
|
-
# @return [Integer, nil]
|
|
53
|
-
attr_reader :rounds
|
|
54
|
-
|
|
55
|
-
# Plans an agent was actually started against, counted once each however
|
|
56
|
-
# many rounds they took.
|
|
57
|
-
#
|
|
58
|
-
# @return [Integer]
|
|
59
|
-
def plans = attempts.map(&:ordinal).uniq.size
|
|
60
|
-
|
|
61
|
-
# @return [Integer] tokens sent, everything included
|
|
62
|
-
def up = attempts.sum(&:up)
|
|
63
|
-
|
|
64
|
-
# @return [Integer] tokens generated
|
|
65
|
-
def down = attempts.sum(&:down)
|
|
66
|
-
|
|
67
|
-
# @return [Integer] sub-agents spawned by every agent in the run
|
|
68
|
-
def subagents = attempts.sum(&:subagents)
|
|
69
|
-
|
|
70
|
-
# Sub-agent spend that arrived as one number. `claude` reports what a
|
|
71
|
-
# sub-agent cost without splitting it between the two directions, so this
|
|
72
|
-
# much of {#up} is really up and down together.
|
|
73
|
-
#
|
|
74
|
-
# @return [Integer]
|
|
75
|
-
def delegated = attempts.sum(&:delegated)
|
|
76
|
-
|
|
77
|
-
# @return [Float] seconds of agent time, added up across all of them
|
|
78
|
-
def busy = attempts.sum(&:seconds)
|
|
79
|
-
|
|
80
|
-
# How many agents were running at any given moment, averaged over the
|
|
81
|
-
# run: agent-seconds divided by wall-clock seconds. Below the `-j` ceiling
|
|
82
|
-
# by however much the round spent waiting for its slowest member.
|
|
83
|
-
#
|
|
84
|
-
# @return [Float]
|
|
85
|
-
def concurrency = seconds.positive? ? busy / seconds : 0.0
|
|
86
|
-
|
|
87
|
-
# @return [Array<Agentilda::Tally::Row>] one per agent, dearest first
|
|
88
|
-
def by_agent
|
|
89
|
-
attempts.group_by(&:agent).map { |name, list|
|
|
90
|
-
Row.new(name:,
|
|
91
|
-
invocations: list.size,
|
|
92
|
-
subagents: list.sum(&:subagents),
|
|
93
|
-
up: list.sum(&:up),
|
|
94
|
-
down: list.sum(&:down),
|
|
95
|
-
seconds: list.sum(&:seconds))
|
|
96
|
-
}.sort_by { |row| -row.up }
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# @return [String] the table and the summary line under it
|
|
100
|
-
def render
|
|
101
|
-
return summary if attempts.empty?
|
|
102
|
-
|
|
103
|
-
([header, rule] + by_agent.map { |row| line(row) } + [rule, totals, "", summary]).join("\n")
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# @return [String] the one-line version, for a box
|
|
107
|
-
def summary
|
|
108
|
-
[
|
|
109
|
-
"#{plans} plan#{"s" unless plans == 1} addressed",
|
|
110
|
-
"#{attempts.size} invocation#{"s" unless attempts.size == 1}",
|
|
111
|
-
duration(seconds),
|
|
112
|
-
"#{format("%.1f", concurrency)} agents at a time",
|
|
113
|
-
"↑ #{UI.abbreviate(up)} ↓ #{UI.abbreviate(down)}"
|
|
114
|
-
].join(" · ") + delegation
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
# @return [String] the caveat, where there is one to make
|
|
120
|
-
def delegation
|
|
121
|
-
return "" if delegated.zero?
|
|
122
|
-
|
|
123
|
-
"\n#{subagents} sub-agent#{"s" unless subagents == 1} reported #{UI.abbreviate(delegated)} " \
|
|
124
|
-
"tokens without splitting them, counted above as ↑."
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# @return [String]
|
|
128
|
-
def header
|
|
129
|
-
cell("Agent", :name) + %i[invocations subagents up down seconds]
|
|
130
|
-
.zip(["runs", "sub-agents", "↑", "↓", "time"])
|
|
131
|
-
.map { |key, text| right(text, key) }.join
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# @return [String]
|
|
135
|
-
def rule = WIDTHS.values.map { |width| ("─" * (width - 1)) + " " }.join
|
|
136
|
-
|
|
137
|
-
# @param row [Agentilda::Tally::Row]
|
|
138
|
-
# @return [String]
|
|
139
|
-
def line(row)
|
|
140
|
-
cell(row.name, :name) + right(row.invocations.to_s, :invocations) +
|
|
141
|
-
right(row.subagents.to_s, :subagents) + right(UI.abbreviate(row.up), :up) +
|
|
142
|
-
right(UI.abbreviate(row.down), :down) + right(duration(row.seconds), :seconds)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# @return [String]
|
|
146
|
-
def totals
|
|
147
|
-
cell("", :name) + right(attempts.size.to_s, :invocations) + right(subagents.to_s, :subagents) +
|
|
148
|
-
right(UI.abbreviate(up), :up) + right(UI.abbreviate(down), :down) + right(duration(busy), :seconds)
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# @param text [String]
|
|
152
|
-
# @param key [Symbol]
|
|
153
|
-
# @return [String] padded to its column, left-justified
|
|
154
|
-
def cell(text, key) = UI.fit(text, WIDTHS.fetch(key))
|
|
155
|
-
|
|
156
|
-
# @param text [String]
|
|
157
|
-
# @param key [Symbol]
|
|
158
|
-
# @return [String] padded to its column, right-justified
|
|
159
|
-
def right(text, key)
|
|
160
|
-
width = WIDTHS.fetch(key) - 1
|
|
161
|
-
text = UI.fit(text, width).rstrip
|
|
162
|
-
UI.fit("", width - UI.display_width(text)) + text + " "
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# @param seconds [Numeric]
|
|
166
|
-
# @return [String] e.g. "42s", "12m40s", "1h04m"
|
|
167
|
-
def duration(seconds)
|
|
168
|
-
total = seconds.to_i
|
|
169
|
-
return "#{total}s" if total < 60
|
|
170
|
-
return "#{total / 60}m#{format("%02ds", total % 60)}" if total < 3600
|
|
171
|
-
|
|
172
|
-
"#{total / 3600}h#{format("%02dm", (total % 3600) / 60)}"
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
end
|