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
|
@@ -24,9 +24,9 @@ module Agentilda
|
|
|
24
24
|
# One pull request, placed.
|
|
25
25
|
#
|
|
26
26
|
# @!attribute [r] pull
|
|
27
|
-
# @return [Agentilda::PullRequest]
|
|
27
|
+
# @return [Agentilda::Plans::PullRequest]
|
|
28
28
|
# @!attribute [r] subject
|
|
29
|
-
# @return [Agentilda::Subject, nil] nil when nothing matched
|
|
29
|
+
# @return [Agentilda::Plans::Subject, nil] nil when nothing matched
|
|
30
30
|
# @!attribute [r] score
|
|
31
31
|
# @return [Integer] words the title and the folder name shared
|
|
32
32
|
# @!attribute [r] rivals
|
|
@@ -77,7 +77,7 @@ module Agentilda
|
|
|
77
77
|
NOISE = %w[the and for with from into that this add adds added fix fixes
|
|
78
78
|
update updates use uses spec plan pull request pr tax app web api].freeze
|
|
79
79
|
|
|
80
|
-
# @param tree [Agentilda::Tree]
|
|
80
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
81
81
|
def initialize(tree:)
|
|
82
82
|
@tree = tree
|
|
83
83
|
end
|
|
@@ -89,7 +89,7 @@ module Agentilda
|
|
|
89
89
|
# the repository and match everything equally.
|
|
90
90
|
OPENING_WORDS = 20
|
|
91
91
|
|
|
92
|
-
# @param pulls [Array<Agentilda::PullRequest>] the ones with no number
|
|
92
|
+
# @param pulls [Array<Agentilda::Plans::PullRequest>] the ones with no number
|
|
93
93
|
# @param bodies [Hash{String => String}] descriptions, by pull request number
|
|
94
94
|
# @return [Array<Agentilda::Linear::Attribution::Placed>]
|
|
95
95
|
def call(pulls, bodies: {})
|
|
@@ -121,10 +121,10 @@ module Agentilda
|
|
|
121
121
|
|
|
122
122
|
private
|
|
123
123
|
|
|
124
|
-
# @return [Agentilda::Tree]
|
|
124
|
+
# @return [Agentilda::Plans::Tree]
|
|
125
125
|
attr_reader :tree
|
|
126
126
|
|
|
127
|
-
# @param pull [Agentilda::PullRequest]
|
|
127
|
+
# @param pull [Agentilda::Plans::PullRequest]
|
|
128
128
|
# @param wanted [Array<String>] the words to match on
|
|
129
129
|
# @param floor [Float] how much of a folder name is enough
|
|
130
130
|
# @return [Agentilda::Linear::Attribution::Placed]
|
|
@@ -150,7 +150,7 @@ module Agentilda
|
|
|
150
150
|
Placed.new(pull:, subject: nil, score:, rivals: tied.map { |s, _| s.feature.dirname }, widened: false)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
# @param subject [Agentilda::Subject]
|
|
153
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
154
154
|
# @return [Array<String>]
|
|
155
155
|
def folder_words(subject)
|
|
156
156
|
(@folder_words ||= {})[subject.feature.path] ||=
|
|
@@ -59,10 +59,10 @@ module Agentilda
|
|
|
59
59
|
# disagree. Everything that needs the network — the project, the repository
|
|
60
60
|
# pull request list — is handed in.
|
|
61
61
|
class Import
|
|
62
|
-
# @param tree [Agentilda::Tree]
|
|
62
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
63
63
|
# @param team [String] the team key, e.g. "TAX"
|
|
64
64
|
# @param project [Hash] the Linear project, `{"id", "name", "url"}`
|
|
65
|
-
# @param adopted [Hash{String => Array<Agentilda::PullRequest>}]
|
|
65
|
+
# @param adopted [Hash{String => Array<Agentilda::Plans::PullRequest>}]
|
|
66
66
|
# pull requests {Attribution} placed, by folder name
|
|
67
67
|
# @param since [String, nil] skip plans numbered below this
|
|
68
68
|
# @param statuses [Array<Symbol>, nil] only these states
|
|
@@ -72,7 +72,7 @@ module Agentilda
|
|
|
72
72
|
@team = team.to_s.strip.upcase
|
|
73
73
|
@project = project
|
|
74
74
|
@adopted = adopted
|
|
75
|
-
@since = since && Ordinal.parse(since)
|
|
75
|
+
@since = since && Plans::Ordinal.parse(since)
|
|
76
76
|
@statuses = statuses
|
|
77
77
|
@force = force
|
|
78
78
|
end
|
|
@@ -100,7 +100,7 @@ module Agentilda
|
|
|
100
100
|
|
|
101
101
|
# Plans in a state nobody has decided how to file, and why not.
|
|
102
102
|
#
|
|
103
|
-
# @return [Hash{Agentilda::Status => Array<String>}] state => ordinals
|
|
103
|
+
# @return [Hash{Agentilda::Plans::Status => Array<String>}] state => ordinals
|
|
104
104
|
def unplaced
|
|
105
105
|
@unplaced ||= (chosen - subjects).group_by(&:status)
|
|
106
106
|
.transform_values { |group| group.map { |s| s.feature.ordinal.to_s } }
|
|
@@ -108,13 +108,13 @@ module Agentilda
|
|
|
108
108
|
|
|
109
109
|
private
|
|
110
110
|
|
|
111
|
-
# @return [Agentilda::Tree]
|
|
111
|
+
# @return [Agentilda::Plans::Tree]
|
|
112
112
|
attr_reader :tree
|
|
113
113
|
|
|
114
114
|
# @return [Hash]
|
|
115
115
|
attr_reader :adopted
|
|
116
116
|
|
|
117
|
-
# @return [Agentilda::Ordinal, nil]
|
|
117
|
+
# @return [Agentilda::Plans::Ordinal, nil]
|
|
118
118
|
attr_reader :since
|
|
119
119
|
|
|
120
120
|
# @return [Array<Symbol>, nil]
|
|
@@ -123,10 +123,10 @@ module Agentilda
|
|
|
123
123
|
# @return [Boolean]
|
|
124
124
|
attr_reader :force
|
|
125
125
|
|
|
126
|
-
# @return [Array<Agentilda::Subject>]
|
|
126
|
+
# @return [Array<Agentilda::Plans::Subject>]
|
|
127
127
|
def subjects = @subjects ||= chosen.select { |s| Agentilda::Linear.placement(s.status) }
|
|
128
128
|
|
|
129
|
-
# @return [Array<Agentilda::Subject>] before the placement question
|
|
129
|
+
# @return [Array<Agentilda::Plans::Subject>] before the placement question
|
|
130
130
|
def chosen
|
|
131
131
|
@chosen ||= tree.subjects.select { |s|
|
|
132
132
|
(since.nil? || s.feature.ordinal >= since) &&
|
|
@@ -138,7 +138,7 @@ module Agentilda
|
|
|
138
138
|
# `plan.md` declares, plus any pull request {Attribution} placed here
|
|
139
139
|
# that none of them already claims.
|
|
140
140
|
#
|
|
141
|
-
# @param subject [Agentilda::Subject]
|
|
141
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
142
142
|
# @return [Array<Agentilda::Linear::Unit>]
|
|
143
143
|
def units_for(subject)
|
|
144
144
|
(@units ||= {})[subject.feature.path] ||= begin
|
|
@@ -149,7 +149,7 @@ module Agentilda
|
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
# @param pull [Agentilda::PullRequest]
|
|
152
|
+
# @param pull [Agentilda::Plans::PullRequest]
|
|
153
153
|
# @return [Agentilda::Linear::Unit]
|
|
154
154
|
def adopted_unit(pull)
|
|
155
155
|
Unit.new(key: "##{pull.number}",
|
|
@@ -158,11 +158,11 @@ module Agentilda
|
|
|
158
158
|
pull_requests: [pull])
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
# @param subject [Agentilda::Subject]
|
|
161
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
162
162
|
# @return [Agentilda::Linear::Issues]
|
|
163
163
|
def record_for(subject) = (@records ||= {})[subject.feature.path] ||= Issues.new(dir: subject.feature.path)
|
|
164
164
|
|
|
165
|
-
# @param subject [Agentilda::Subject]
|
|
165
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
166
166
|
# @return [Array<Agentilda::Linear::Action>]
|
|
167
167
|
def actions_for(subject)
|
|
168
168
|
[plan_action(subject)] + units_for(subject).map { |unit| unit_action(subject, unit) }
|
|
@@ -170,7 +170,7 @@ module Agentilda
|
|
|
170
170
|
|
|
171
171
|
# The issue that stands for the whole plan folder.
|
|
172
172
|
#
|
|
173
|
-
# @param subject [Agentilda::Subject]
|
|
173
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
174
174
|
# @return [Agentilda::Linear::Action]
|
|
175
175
|
def plan_action(subject)
|
|
176
176
|
placement = Agentilda::Linear.placement(subject.status)
|
|
@@ -195,7 +195,7 @@ module Agentilda
|
|
|
195
195
|
reason:)
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
-
# @param subject [Agentilda::Subject]
|
|
198
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
199
199
|
# @param unit [Agentilda::Linear::Unit]
|
|
200
200
|
# @return [Agentilda::Linear::Action]
|
|
201
201
|
def unit_action(subject, unit)
|
|
@@ -238,7 +238,7 @@ module Agentilda
|
|
|
238
238
|
[:update, "the plan has changed since #{existing} was pushed"]
|
|
239
239
|
end
|
|
240
240
|
|
|
241
|
-
# @param subject [Agentilda::Subject]
|
|
241
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
242
242
|
# @return [String]
|
|
243
243
|
def plan_title(subject) = "[#{subject.feature.ordinal}] #{heading(subject)}"
|
|
244
244
|
|
|
@@ -246,7 +246,7 @@ module Agentilda
|
|
|
246
246
|
# `spec.md` gives it a real sentence — "Tenancy: users, households,
|
|
247
247
|
# memberships" — while the folder slug can only carry kebab-case.
|
|
248
248
|
#
|
|
249
|
-
# @param subject [Agentilda::Subject]
|
|
249
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
250
250
|
# @return [String]
|
|
251
251
|
def heading(subject)
|
|
252
252
|
line = subject.read("spec.md").to_s[/^[ \t]{0,3}#[ \t]+(.+)$/, 1]
|
|
@@ -254,7 +254,7 @@ module Agentilda
|
|
|
254
254
|
cleaned.empty? ? subject.feature.title : cleaned
|
|
255
255
|
end
|
|
256
256
|
|
|
257
|
-
# @param subject [Agentilda::Subject]
|
|
257
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
258
258
|
# @return [String]
|
|
259
259
|
def plan_description(subject)
|
|
260
260
|
[
|
|
@@ -265,7 +265,7 @@ module Agentilda
|
|
|
265
265
|
].compact.reject(&:empty?).join("\n\n")
|
|
266
266
|
end
|
|
267
267
|
|
|
268
|
-
# @param subject [Agentilda::Subject]
|
|
268
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
269
269
|
# @param unit [Agentilda::Linear::Unit]
|
|
270
270
|
# @return [String]
|
|
271
271
|
def unit_description(subject, unit)
|
|
@@ -31,9 +31,9 @@ module Agentilda
|
|
|
31
31
|
TYPES = %w[backlog unstarted started completed canceled].freeze
|
|
32
32
|
|
|
33
33
|
# Every plan state this tool is willing to place, keyed by
|
|
34
|
-
# {Agentilda::Status#key}.
|
|
34
|
+
# {Agentilda::Plans::Status#key}.
|
|
35
35
|
#
|
|
36
|
-
# Between this and {UNPLACED} every entry in {Agentilda::STATUSES} is
|
|
36
|
+
# Between this and {UNPLACED} every entry in {Agentilda::Plans::STATUSES} is
|
|
37
37
|
# named exactly once, and a spec asserts it. That is the guard against the
|
|
38
38
|
# failure this file is most prone to: a sixteenth state gets added,
|
|
39
39
|
# nothing here changes, and its plans quietly import as Backlog with no
|
|
@@ -86,8 +86,8 @@ module Agentilda
|
|
|
86
86
|
# unit's pull requests are the better evidence, so they are used when
|
|
87
87
|
# there are any.
|
|
88
88
|
#
|
|
89
|
-
# @param status [Agentilda::Status] the plan's state
|
|
90
|
-
# @param pulls [Array<Agentilda::PullRequest>] the unit's
|
|
89
|
+
# @param status [Agentilda::Plans::Status] the plan's state
|
|
90
|
+
# @param pulls [Array<Agentilda::Plans::PullRequest>] the unit's
|
|
91
91
|
# @return [Agentilda::Linear::Placement, nil]
|
|
92
92
|
def self.placement_for(status, pulls)
|
|
93
93
|
return placement(status) if pulls.empty?
|
|
@@ -99,13 +99,13 @@ module Agentilda
|
|
|
99
99
|
|
|
100
100
|
# Where a plan in this state belongs on a Linear board.
|
|
101
101
|
#
|
|
102
|
-
# @param status [Agentilda::Status]
|
|
102
|
+
# @param status [Agentilda::Plans::Status]
|
|
103
103
|
# @return [Agentilda::Linear::Placement, nil] nil when nobody has decided
|
|
104
104
|
def self.placement(status) = PLACEMENTS[status.key]
|
|
105
105
|
|
|
106
106
|
# Why a state is not imported, for the report that says so.
|
|
107
107
|
#
|
|
108
|
-
# @param status [Agentilda::Status]
|
|
108
|
+
# @param status [Agentilda::Plans::Status]
|
|
109
109
|
# @return [String] the reason, or the louder one for a state nobody has considered at all
|
|
110
110
|
def self.reason_unplaced(status)
|
|
111
111
|
UNPLACED.fetch(status.key) do
|
|
@@ -28,7 +28,7 @@ module Agentilda
|
|
|
28
28
|
class Push
|
|
29
29
|
# @param import [Agentilda::Linear::Import]
|
|
30
30
|
# @param api [Agentilda::Linear::API]
|
|
31
|
-
# @param tree [Agentilda::Tree]
|
|
31
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
32
32
|
def initialize(import:, api:, tree:)
|
|
33
33
|
@import = import
|
|
34
34
|
@api = api
|
|
@@ -49,7 +49,7 @@ module Agentilda
|
|
|
49
49
|
# @return [Agentilda::Linear::API]
|
|
50
50
|
attr_reader :api
|
|
51
51
|
|
|
52
|
-
# @return [Agentilda::Tree]
|
|
52
|
+
# @return [Agentilda::Plans::Tree]
|
|
53
53
|
attr_reader :tree
|
|
54
54
|
|
|
55
55
|
# @return [Hash] the team, its states and its labels
|
|
@@ -73,7 +73,7 @@ module Agentilda
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# @param action [Agentilda::Linear::Action]
|
|
76
|
-
# @param subject [Agentilda::Subject]
|
|
76
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
77
77
|
# @param parent [Agentilda::Linear::Result, nil]
|
|
78
78
|
# @return [Agentilda::Linear::Result]
|
|
79
79
|
def apply(action, subject, parent)
|
|
@@ -123,7 +123,7 @@ module Agentilda
|
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
# @param action [Agentilda::Linear::Action]
|
|
126
|
-
# @param subject [Agentilda::Subject]
|
|
126
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
127
127
|
# @return [Hash]
|
|
128
128
|
def recorded(action, subject)
|
|
129
129
|
was = Issues.new(dir: subject.feature.path).by_unit[action.unit]
|
|
@@ -174,7 +174,7 @@ module Agentilda
|
|
|
174
174
|
# skips — a row dropped because its action was a no-op is a row the next
|
|
175
175
|
# run would recreate from scratch.
|
|
176
176
|
#
|
|
177
|
-
# @param subject [Agentilda::Subject]
|
|
177
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
178
178
|
# @param results [Array<Agentilda::Linear::Result>]
|
|
179
179
|
# @return [void]
|
|
180
180
|
def record(subject, results)
|
|
@@ -25,7 +25,7 @@ module Agentilda
|
|
|
25
25
|
def why = { ordinal: "its name carries the plan number", slug: "its name is the plan's slug" }.fetch(rule)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# @param tree [Agentilda::Tree]
|
|
28
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
29
29
|
# @param projects [Array<Hash>] `{"id", "name", "url", "status"}` from {API#projects}
|
|
30
30
|
def initialize(tree:, projects:)
|
|
31
31
|
@tree = tree
|
|
@@ -46,13 +46,13 @@ module Agentilda
|
|
|
46
46
|
|
|
47
47
|
# Plans with no project. An import would create one for each.
|
|
48
48
|
#
|
|
49
|
-
# @return [Array<Agentilda::Subject>]
|
|
49
|
+
# @return [Array<Agentilda::Plans::Subject>]
|
|
50
50
|
def uncovered = tree.subjects - matches.map(&:subject)
|
|
51
51
|
|
|
52
52
|
# Projects whose name shares the plan's words without matching either
|
|
53
53
|
# rule, best first.
|
|
54
54
|
#
|
|
55
|
-
# @param subject [Agentilda::Subject]
|
|
55
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
56
56
|
# @return [Array<Hash>]
|
|
57
57
|
def near(subject) = scored(subject).map(&:first)
|
|
58
58
|
|
|
@@ -65,7 +65,7 @@ module Agentilda
|
|
|
65
65
|
# obvious to a person and, to this, indistinguishable from
|
|
66
66
|
# `tax-engine-consolidation` matching `Tax Law Engine`, which is wrong.
|
|
67
67
|
#
|
|
68
|
-
# @param subject [Agentilda::Subject]
|
|
68
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
69
69
|
# @return [Array(Hash, Integer), nil] the project and its score
|
|
70
70
|
def guess(subject)
|
|
71
71
|
best, score = scored(subject).first
|
|
@@ -109,7 +109,7 @@ module Agentilda
|
|
|
109
109
|
|
|
110
110
|
private
|
|
111
111
|
|
|
112
|
-
# @return [Agentilda::Tree]
|
|
112
|
+
# @return [Agentilda::Plans::Tree]
|
|
113
113
|
attr_reader :tree
|
|
114
114
|
|
|
115
115
|
# @param project [Hash]
|
|
@@ -126,7 +126,7 @@ module Agentilda
|
|
|
126
126
|
|
|
127
127
|
# Every project that shares a word with this plan, best first.
|
|
128
128
|
#
|
|
129
|
-
# @param subject [Agentilda::Subject]
|
|
129
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
130
130
|
# @return [Array<Array(Hash, Integer)>]
|
|
131
131
|
def scored(subject)
|
|
132
132
|
wanted = plan_words(subject)
|
|
@@ -138,7 +138,7 @@ module Agentilda
|
|
|
138
138
|
}.sort_by { |_, overlap| -overlap }
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
-
# @param subject [Agentilda::Subject]
|
|
141
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
142
142
|
# @return [Array<String>]
|
|
143
143
|
def plan_words(subject)
|
|
144
144
|
words(subject.feature.slug) | words(slugify(subject.feature.title)) |
|
|
@@ -11,7 +11,7 @@ module Agentilda
|
|
|
11
11
|
# @!attribute [r] body
|
|
12
12
|
# @return [String] everything under the heading, as written
|
|
13
13
|
# @!attribute [r] pull_requests
|
|
14
|
-
# @return [Array<Agentilda::PullRequest>] the ones this unit claims
|
|
14
|
+
# @return [Array<Agentilda::Plans::PullRequest>] the ones this unit claims
|
|
15
15
|
Unit = Data.define(:key, :title, :body, :pull_requests)
|
|
16
16
|
|
|
17
17
|
# Reads the work units out of a plan folder.
|
|
@@ -65,7 +65,7 @@ module Agentilda
|
|
|
65
65
|
# Pull request numbers named in the heading: "(✔ #38)".
|
|
66
66
|
NUMBERED = /#(\d+)\b/
|
|
67
67
|
|
|
68
|
-
# @param subject [Agentilda::Subject]
|
|
68
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
69
69
|
def initialize(subject:)
|
|
70
70
|
@subject = subject
|
|
71
71
|
end
|
|
@@ -101,7 +101,7 @@ module Agentilda
|
|
|
101
101
|
|
|
102
102
|
private
|
|
103
103
|
|
|
104
|
-
# @return [Agentilda::Subject]
|
|
104
|
+
# @return [Agentilda::Plans::Subject]
|
|
105
105
|
attr_reader :subject
|
|
106
106
|
|
|
107
107
|
# @return [Array<Agentilda::Linear::Unit>] possibly empty
|
|
@@ -208,7 +208,7 @@ module Agentilda
|
|
|
208
208
|
# The heading said so: `### PR 020.03 — Clerk webhooks (✔ #44)`.
|
|
209
209
|
#
|
|
210
210
|
# @param unit [Agentilda::Linear::Unit]
|
|
211
|
-
# @return [Array<Agentilda::PullRequest>]
|
|
211
|
+
# @return [Array<Agentilda::Plans::PullRequest>]
|
|
212
212
|
def numbered(unit)
|
|
213
213
|
wanted = heading_for(unit.key).to_s.scan(NUMBERED).flatten
|
|
214
214
|
subject.pull_requests.select { |pr| wanted.include?(pr.number.to_s) }
|
|
@@ -217,7 +217,7 @@ module Agentilda
|
|
|
217
217
|
# The pull request title said so: "Spec 010 PR-2: MAGI registry".
|
|
218
218
|
#
|
|
219
219
|
# @param unit [Agentilda::Linear::Unit]
|
|
220
|
-
# @return [Array<Agentilda::PullRequest>]
|
|
220
|
+
# @return [Array<Agentilda::Plans::PullRequest>]
|
|
221
221
|
def named(unit)
|
|
222
222
|
pattern = /\bPR[\s_-]?#{unit.key[/\d+\z/]}\b/i
|
|
223
223
|
subject.pull_requests.select { |pr| pr.title.match?(pattern) }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Plans
|
|
5
|
+
# Telling "this belongs to no specification" apart from "nobody could tell".
|
|
6
|
+
#
|
|
7
|
+
# Both end up without a plan number, and filing them under one marker loses
|
|
8
|
+
# the difference between a finished thought and an open question. A
|
|
9
|
+
# dependency bump genuinely implements no specification and never will —
|
|
10
|
+
# that is an assertion, and `[dev]` records it. A pull request that adds
|
|
11
|
+
# Schedule K-1 for four tax years plainly implements *something*; nothing
|
|
12
|
+
# here could work out what, and `[none]` records that instead, as a job left
|
|
13
|
+
# for a human rather than a verdict.
|
|
14
|
+
#
|
|
15
|
+
# The bar for `[dev]` is deliberately high. Marking real work as `[dev]`
|
|
16
|
+
# tells everyone afterwards it was never worth a plan, and nobody re-opens a
|
|
17
|
+
# question that looks settled. `[none]` is visibly unfinished, so the cost of
|
|
18
|
+
# guessing wrong in that direction is somebody spending a minute on it.
|
|
19
|
+
module DevWork
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Titles that say what they are: dependency bumps, release chores, CI.
|
|
23
|
+
TITLES = [
|
|
24
|
+
/\Abump\s+\S+\s+from\s+\S+\s+to\s+\S+/i,
|
|
25
|
+
/\A(?:chore|ci|build|deps|style|refactor)(?:\([^)]*\))?:/i,
|
|
26
|
+
/\Abump\s+(?:to\s+)?v?\d+\.\d+/i
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Paths that are how the project is built rather than what it does.
|
|
30
|
+
PLUMBING = %r{\A(?:\.github/|\.circleci/|\.devcontainer/|bin/|scripts/|
|
|
31
|
+
Dockerfile|\.dockerignore|Gemfile(?:\.lock)?|justfile|Rakefile|
|
|
32
|
+
\.rubocop\.ya?ml|\.standard\.ya?ml|\.gitignore|\.tool-versions)}x
|
|
33
|
+
|
|
34
|
+
# Files that say nothing either way, so they neither prove plumbing nor
|
|
35
|
+
# disprove it — every pull request in this tool's world touches `.plans`.
|
|
36
|
+
NEUTRAL = %r{\A(?:#{PLANS_DIR}/|CHANGELOG|README)}
|
|
37
|
+
|
|
38
|
+
# @param title [String]
|
|
39
|
+
# @param files [Array<String>] paths in the diff
|
|
40
|
+
# @return [Boolean] whether this can be *asserted* to implement no plan
|
|
41
|
+
def developer?(title, files = [])
|
|
42
|
+
return true if TITLES.any? { |pattern| title.to_s.match?(pattern) }
|
|
43
|
+
|
|
44
|
+
judged = Array(files).reject { |path| path.match?(NEUTRAL) }
|
|
45
|
+
return false if judged.empty?
|
|
46
|
+
|
|
47
|
+
judged.all? { |path| path.match?(PLUMBING) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param title [String]
|
|
51
|
+
# @param files [Array<String>]
|
|
52
|
+
# @return [String] the marker to stamp on it
|
|
53
|
+
def marker(title, files = []) = developer?(title, files) ? NO_PLAN_PREFIX : NONE_PREFIX
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Plans
|
|
5
|
+
# Lowercase in the middle of a title, capitalised at the front.
|
|
6
|
+
SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
|
|
7
|
+
|
|
8
|
+
# Slug words that are really acronyms and should shout.
|
|
9
|
+
ACRONYMS = %w[
|
|
10
|
+
abac ai api aws cdn ci cd cli cms cors cpu crm css csv db dns dsl e2e ec2 etl gcp gdpr gpu gui
|
|
11
|
+
html http https iam id ide jwt json k8s llm ml mcp mvp npm oauth orm otp pdf pii poc pr prs qa
|
|
12
|
+
rbac rds rest rls rpc rss s3 saas sdk seo sns spa sql sqs sre ssh sso ssl ssr tls tui ts tsx ui
|
|
13
|
+
ux uuid vpc vpn xml yaml
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Words with a house spelling that neither capitalize nor upcase gets right.
|
|
17
|
+
SPECIAL_CASE = {
|
|
18
|
+
"github" => "GitHub", "gitlab" => "GitLab", "graphql" => "GraphQL", "ios" => "iOS",
|
|
19
|
+
"javascript" => "JavaScript", "macos" => "macOS", "nodejs" => "Node.js", "oauth" => "OAuth",
|
|
20
|
+
"openai" => "OpenAI", "postgres" => "PostgreSQL", "postgresql" => "PostgreSQL",
|
|
21
|
+
"typescript" => "TypeScript", "uuidv7" => "UUIDv7", "websocket" => "WebSocket"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Turn a kebab slug into a proper name: `law-as-data` → "Law as Data".
|
|
25
|
+
#
|
|
26
|
+
# @param slug [String]
|
|
27
|
+
# @return [String]
|
|
28
|
+
def self.titleize(slug)
|
|
29
|
+
words = slug.to_s.split(/[-_\s.]+/).reject(&:empty?)
|
|
30
|
+
return "" if words.empty?
|
|
31
|
+
|
|
32
|
+
words.each_with_index.map { |word, i|
|
|
33
|
+
lower = word.downcase
|
|
34
|
+
if SPECIAL_CASE.key?(lower)
|
|
35
|
+
SPECIAL_CASE[lower]
|
|
36
|
+
elsif ACRONYMS.include?(lower)
|
|
37
|
+
lower.upcase
|
|
38
|
+
elsif i.positive? && SMALL_WORDS.include?(lower)
|
|
39
|
+
lower
|
|
40
|
+
elsif lower.match?(/\A\d+\z/)
|
|
41
|
+
lower
|
|
42
|
+
else
|
|
43
|
+
lower.sub(/\A./, &:upcase)
|
|
44
|
+
end
|
|
45
|
+
}.join(" ")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
|
|
49
|
+
#
|
|
50
|
+
# The spaced arrow after the emoji is deliberate: an emoji renders two cells
|
|
51
|
+
# wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
|
|
52
|
+
# as if the emoji and the slug were touching. Everything that mints or
|
|
53
|
+
# renames a folder goes through here; {Feature.parse} accepts the older
|
|
54
|
+
# `--` and single-dash spellings too, and `resync dirs` migrates them on
|
|
55
|
+
# contact.
|
|
56
|
+
#
|
|
57
|
+
# @param ordinal [Agentilda::Plans::Ordinal, String]
|
|
58
|
+
# @param status [Agentilda::Plans::Status]
|
|
59
|
+
# @param slug [String]
|
|
60
|
+
# @return [String]
|
|
61
|
+
def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
|
|
62
|
+
|
|
63
|
+
# One `NNN.MM-<emoji> → <slug>` folder, decoded.
|
|
64
|
+
#
|
|
65
|
+
# @!attribute [r] ordinal
|
|
66
|
+
# @return [Agentilda::Plans::Ordinal]
|
|
67
|
+
# @!attribute [r] status
|
|
68
|
+
# @return [Agentilda::Plans::Status]
|
|
69
|
+
# @!attribute [r] slug
|
|
70
|
+
# @return [String]
|
|
71
|
+
# @!attribute [r] dirname
|
|
72
|
+
# @return [String] exactly as it is on disk
|
|
73
|
+
# @!attribute [r] path
|
|
74
|
+
# @return [String] absolute
|
|
75
|
+
Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
|
|
76
|
+
include Comparable
|
|
77
|
+
|
|
78
|
+
# Decode a folder name, or return nil when it is not a plan folder.
|
|
79
|
+
#
|
|
80
|
+
# @param path [String] absolute path to a candidate directory
|
|
81
|
+
# @return [Agentilda::Plans::Feature, nil]
|
|
82
|
+
def self.parse(path)
|
|
83
|
+
dirname = File.basename(path)
|
|
84
|
+
ordinal = Ordinal.from_dirname(dirname) or return nil
|
|
85
|
+
|
|
86
|
+
rest = dirname.sub(/\A[\d.]+[-_]/, "")
|
|
87
|
+
head, tail = rest.split(/\s*→\s*|[-_]/, 2)
|
|
88
|
+
|
|
89
|
+
# A leading segment with no ASCII word character is the status emoji.
|
|
90
|
+
# The slug strips any further separators: the canonical spelling puts
|
|
91
|
+
# a spaced arrow after the emoji, and folders from before that rule put
|
|
92
|
+
# two dashes, or one.
|
|
93
|
+
status = (Agentilda::Plans.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
|
|
94
|
+
slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
|
|
95
|
+
|
|
96
|
+
new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [String] proper name, e.g. "Law as Data"
|
|
100
|
+
def title = Agentilda::Plans.titleize(slug)
|
|
101
|
+
|
|
102
|
+
# The folder name this feature would carry in a given state — the slug
|
|
103
|
+
# never moves, and the number is always rendered canonically, so this is
|
|
104
|
+
# also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
|
|
105
|
+
#
|
|
106
|
+
# @param status [Agentilda::Plans::Status]
|
|
107
|
+
# @return [String]
|
|
108
|
+
def dirname_as(status) = Agentilda::Plans.plan_dirname(ordinal, status, slug)
|
|
109
|
+
|
|
110
|
+
# The number exactly as the folder writes it, which is not always the
|
|
111
|
+
# canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
|
|
112
|
+
# "018.00".
|
|
113
|
+
#
|
|
114
|
+
# @return [String]
|
|
115
|
+
def dirname_ordinal = dirname.to_s[/\A[\d.]+/].to_s
|
|
116
|
+
|
|
117
|
+
# @return [Boolean] whether the number is written in full `NNN.MM` form
|
|
118
|
+
def padded? = dirname_ordinal == ordinal.to_s
|
|
119
|
+
|
|
120
|
+
# Whether the folder is already named the way this tool would name it —
|
|
121
|
+
# number padded, emoji matching the state it claims, slug unchanged.
|
|
122
|
+
#
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def canonical? = dirname == dirname_as(status)
|
|
125
|
+
|
|
126
|
+
# @param other [Object]
|
|
127
|
+
# @return [Integer, nil]
|
|
128
|
+
def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|