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
@@ -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