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
@@ -1,178 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
-
5
- module Agentilda
6
- # The side channel between a keypress and a running agent.
7
- #
8
- # `claude -p` takes no input once it starts, so the only way to reach an
9
- # agent mid-flight is a file it has been told to poll: each invocation gets
10
- # a control file of its own, named in its prompt, and a keypress writes a
11
- # word into every file currently registered. Like everything else about the
12
- # prompt this is a request — an agent deep in a tool call reads the file at
13
- # its next step, not instantly — which is why {#quit!} also arms a deadline
14
- # the {Executor} checks, so "quit" is eventually a guarantee too.
15
- module Control
16
- # What a keypress writes into a control file. One word, one line, so an
17
- # agent can act on `File.read(path).strip` and nothing subtler.
18
- WRAP_UP = "WRAP_UP"
19
- STOP = "STOP"
20
-
21
- # Ctrl-C: STOP, and the run will be started again, so the agent leaves
22
- # a resume note before it goes. The prompt says what that note is.
23
- INTERRUPT = "INTERRUPT"
24
-
25
- # The clock's warnings start with this; the agent's prompt explains them.
26
- WARN = "WARN"
27
-
28
- # Seconds between {#quit!} and the harness terminating whatever is still
29
- # running. Long enough to write files and a handoff note; short enough
30
- # that q means quit rather than "quit eventually".
31
- GRACE = 60
32
-
33
- @mutex = Mutex.new
34
- @files = []
35
- @quit = false
36
- @interrupted = false
37
- @deadline = nil
38
-
39
- class << self
40
- # Register a fresh control file for one invocation and hand back its
41
- # path, to be named in the agent's prompt.
42
- #
43
- # @param dir [String] where the file lives (the trace dir — outside the
44
- # repository for the same reason traces are)
45
- # @param name [String] something findable: plan ordinal and agent name
46
- # @return [String]
47
- def register(dir, name)
48
- FileUtils.mkdir_p(dir)
49
- path = File.join(dir, "control-#{name}-#{Process.pid}-#{format("%04x", rand(0x10000))}")
50
- File.write(path, "")
51
- @mutex.synchronize { @files << path }
52
- path
53
- end
54
-
55
- # Forget a finished invocation's file. The file itself is removed so a
56
- # crashed run does not leave stale STOPs for the next one to find.
57
- #
58
- # @param path [String]
59
- # @return [void]
60
- def release(path)
61
- @mutex.synchronize { @files.delete(path) }
62
- FileUtils.rm_f(path)
63
- end
64
-
65
- # One line into one file, from the clock. Not a broadcast: a warning is
66
- # about one agent's own deadline.
67
- #
68
- # @param path [String]
69
- # @param text [String]
70
- # @return [void]
71
- def write(path, text)
72
- File.write(path, text.empty? ? "" : "#{text}\n")
73
- rescue SystemCallError
74
- # The invocation finished and released its file; nothing to warn.
75
- end
76
-
77
- # `w`: every running agent is asked to finish the essential remainder
78
- # as fast as it can. The loop itself keeps going.
79
- #
80
- # @return [void]
81
- def wrap_up! = broadcast(WRAP_UP)
82
-
83
- # `n`: every running agent is asked to write out what it has and end
84
- # its turn. The loop keeps going — under chaining that is exactly what
85
- # hands the plan to the next agent.
86
- #
87
- # @return [void]
88
- def stop! = broadcast(STOP)
89
-
90
- # `q`: {#stop!}, plus the loop ends after this round, plus a deadline —
91
- # {GRACE} seconds from now — after which the {Executor} aborts whatever
92
- # is still running rather than waiting on its goodwill.
93
- #
94
- # @return [void]
95
- def quit!
96
- stop!
97
- @mutex.synchronize {
98
- @quit = true
99
- @deadline ||= UI.monotonic + GRACE
100
- }
101
- end
102
-
103
- # Ctrl-C, the first time: {#quit!}, except that every running agent is
104
- # told the run is being interrupted rather than stopped, so it writes
105
- # down where it got to. A restart then reads that note instead of
106
- # redoing, or half-redoing, the work.
107
- #
108
- # @return [void]
109
- def interrupt!
110
- broadcast(INTERRUPT)
111
- @mutex.synchronize {
112
- @quit = true
113
- @interrupted = true
114
- @deadline ||= UI.monotonic + GRACE
115
- }
116
- end
117
-
118
- # @return [Boolean] whether `q` has been pressed
119
- def quit? = @mutex.synchronize { @quit }
120
-
121
- # @return [Boolean] whether Ctrl-C has been pressed
122
- def interrupted? = @mutex.synchronize { @interrupted }
123
-
124
- # Ctrl-C where no keyboard reads it: a run with no terminal to put in
125
- # raw mode still gets the graceful first press and the hard second.
126
- #
127
- # A trap handler may not take a mutex, so the first press hands
128
- # {#interrupt!} to a thread; the second raises, as Ctrl-C always has.
129
- #
130
- # @yieldreturn [Object]
131
- # @return [Object] the block's value
132
- def on_interrupt
133
- previous = Signal.trap("INT") do
134
- # A plain read, not {#interrupted?}: a trap handler may not lock.
135
- raise Interrupt if @interrupted || @pending_interrupt
136
-
137
- @pending_interrupt = true
138
- Thread.new { interrupt! }
139
- end
140
- yield
141
- ensure
142
- @pending_interrupt = false
143
- Signal.trap("INT", previous || "DEFAULT")
144
- end
145
-
146
- # @return [Boolean] whether the grace period after `q` has run out
147
- def overdue?
148
- @mutex.synchronize { !@deadline.nil? && UI.monotonic > @deadline }
149
- end
150
-
151
- # Back to rest, for the next `Runner#call` — and for the suite, where
152
- # one example's q must not quit every example after it.
153
- #
154
- # @return [void]
155
- def reset!
156
- @mutex.synchronize {
157
- @files.each { |f| FileUtils.rm_f(f) }
158
- @files.clear
159
- @quit = false
160
- @interrupted = false
161
- @deadline = nil
162
- }
163
- end
164
-
165
- private
166
-
167
- # @param word [String]
168
- # @return [void]
169
- def broadcast(word)
170
- @mutex.synchronize { @files.dup }.each do |path|
171
- File.write(path, "#{word}\n")
172
- rescue SystemCallError
173
- # A file whose invocation just finished is not an error to miss.
174
- end
175
- end
176
- end
177
- end
178
- end
@@ -1,125 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/monads"
4
- require "fileutils"
5
-
6
- require "agentilda/status"
7
-
8
- module Agentilda
9
- # Mints new plan folders. Backs `/spec-create`.
10
- #
11
- # Every failure is a `Failure`, never an exception: creating a folder is the
12
- # one place a typo becomes permanent, so the caller is made to look.
13
- class Creator
14
- include Dry::Monads[:result]
15
-
16
- # The state a new plan opens in — a specification and nothing else yet.
17
- DEFAULT_STATUS = :new
18
-
19
- # The state a retroactive plan is born in: the work is live, but it has
20
- # neither a specification nor a plan.
21
- RETROACTIVE_STATUS = :retroactive
22
-
23
- # @param dir [String] the `.plans` directory
24
- def initialize(dir:)
25
- @dir = File.expand_path(dir)
26
- end
27
-
28
- # @return [String] the `.plans` directory
29
- attr_reader :dir
30
-
31
- # Create a plan folder.
32
- #
33
- # @param words [Array<String>] the topic; becomes the slug
34
- # @param after [String, nil] anchor for a retroactive plan, e.g. "002"
35
- # @param status [String, Symbol, nil] override the default state
36
- # @param prs [Array<Hash>, nil] pull requests to record, already fetched;
37
- # their presence is what makes the folder 🕰️ Retroactive rather than ⚪️
38
- # @return [Dry::Monads::Result] Success(absolute path) or Failure(message)
39
- def create(words:, after: nil, status: nil, prs: nil)
40
- resolved = resolve_status(status, after) or
41
- return Failure("unknown status: #{status}")
42
-
43
- slug = self.class.slugify(words)
44
- return Failure("the topic produced an empty slug") if slug.empty?
45
-
46
- ordinal = after ? retroactive_ordinal(after) : Ordinal.next_major(existing)
47
- return ordinal if ordinal.is_a?(Dry::Monads::Result)
48
-
49
- build(ordinal, resolved, slug).fmap { |path| record_pull_requests(path, prs) }
50
- rescue Agentilda::Error => e
51
- Failure(e.message)
52
- end
53
-
54
- # @return [Array<Agentilda::Ordinal>] every number already taken
55
- def existing
56
- @existing ||= begin
57
- return [] unless File.directory?(dir)
58
-
59
- Dir.children(dir)
60
- .select { |c| File.directory?(File.join(dir, c)) }
61
- .filter_map { |c| Ordinal.from_dirname(c) }
62
- end
63
- end
64
-
65
- # Turn free text into the kebab tail of a folder name.
66
- #
67
- # @param words [Array<String>, String]
68
- # @return [String] possibly empty, which the caller must reject
69
- def self.slugify(words)
70
- Array(words).join(" ").downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
71
- end
72
-
73
- private
74
-
75
- # @param status [String, Symbol, nil]
76
- # @param after [String, nil]
77
- # @return [Agentilda::Status, nil]
78
- def resolve_status(status, after)
79
- return Agentilda.status(status) if status
80
-
81
- STATUS_BY_KEY.fetch(after ? RETROACTIVE_STATUS : DEFAULT_STATUS)
82
- end
83
-
84
- # @param after [String]
85
- # @return [Agentilda::Ordinal, Dry::Monads::Result]
86
- def retroactive_ordinal(after)
87
- anchor = Ordinal.parse(after) or return Failure("not a plan number: #{after}")
88
-
89
- unless existing.any? { |o| o.major == anchor.major }
90
- return Failure("no plan #{format("%03d", anchor.major)} to anchor against — " \
91
- "a retroactive plan names the plan its work landed after")
92
- end
93
-
94
- Ordinal.next_minor(existing, major: anchor.major)
95
- end
96
-
97
- # Write the pull requests the folder was created from, so 🕰️ Retroactive
98
- # is justified the moment the folder exists — that state means "the work is
99
- # live and undocumented", and it is the recorded pull requests that make
100
- # the first half of that true.
101
- #
102
- # @param path [String] the new folder
103
- # @param prs [Array<Hash>, nil]
104
- # @return [String] the path, unchanged
105
- def record_pull_requests(path, prs)
106
- return path if prs.nil? || prs.empty?
107
-
108
- File.write(File.join(path, PullRequests::FILENAME), PullRequests.render(prs))
109
- path
110
- end
111
-
112
- # @param ordinal [Agentilda::Ordinal]
113
- # @param status [Agentilda::Status]
114
- # @param slug [String]
115
- # @return [Dry::Monads::Result]
116
- def build(ordinal, status, slug)
117
- target = File.join(dir, Agentilda.plan_dirname(ordinal, status, slug))
118
- return Failure("already exists: #{File.basename(target)}") if File.exist?(target)
119
-
120
- FileUtils.mkdir_p(target)
121
- @existing = nil
122
- Success(target)
123
- end
124
- end
125
- end
@@ -1,219 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # The ratatui dashboard for agent work that is not a `run`: `unblock`
5
- # handing plans to lando-broker, `create` drafting a spec.md. `run` has a
6
- # {Dispatcher} to build its {Board}; this builds one from the items
7
- # {UI.concurrently} hands it, so every agent a person waits on is drawn
8
- # the same way, with the same keys.
9
- #
10
- # It stands in for the dispatcher behind {Console}, which is why it
11
- # answers {#kill} and {#extend}: a row whose work passed its
12
- # {Tracker#handle} on to {Executor#call} can be killed or given more time.
13
- class Dashboard
14
- # Seconds between frames handed to the screen.
15
- REFRESH = 0.25
16
-
17
- # Seconds a finished row stays on screen, so a fast agent is still seen.
18
- LINGER = 3
19
-
20
- # One item's row, and the {UI::Line} its work streams progress through.
21
- # Nothing is drawn by the line itself; the dashboard reads it each frame.
22
- class Tracker < UI::Line
23
- # @return [String]
24
- attr_reader :key
25
-
26
- # @return [Agentilda::Executor::Handle] handed to {Executor#call} by a
27
- # caller that wants the kill and extend keys to reach its agent.
28
- # Not `@handle`: {UI::Line} keeps its dry-cli-ui line under that name.
29
- def handle = @executor_handle
30
-
31
- # @return [Symbol] :running, :done or :failed
32
- attr_reader :state
33
-
34
- # @return [Float, nil] monotonic reading of when it stopped running
35
- attr_reader :finished_at
36
-
37
- # @param key [String]
38
- # @param fields [Hash] plan, status, agent and round, for the log and the row
39
- # @param file [String] what the row's feature column says
40
- # @param message [String] shown until the agent says something
41
- # @param timeout [Integer, nil]
42
- def initialize(key:, fields:, file:, message:, timeout: nil)
43
- super(fields:, timeout:)
44
- @key = key
45
- @file = file
46
- @message = message
47
- @executor_handle = Executor::Handle.new
48
- @state = :running
49
- @update = nil
50
- @history = []
51
- @finished_at = nil
52
- end
53
-
54
- # @param update [Agentilda::Transcript::Progress]
55
- # @return [void]
56
- def call(update)
57
- super
58
- @update = update
59
- return unless update.activity
60
-
61
- @message = update.activity
62
- @history = Board::Row.remember(@history, @message)
63
- end
64
-
65
- # @return [void]
66
- def done
67
- super
68
- finish(:done)
69
- end
70
-
71
- # @param reason [String]
72
- # @return [void]
73
- def failed(reason)
74
- super
75
- @message = reason
76
- @history = Board::Row.remember(@history, reason)
77
- finish(:failed)
78
- end
79
-
80
- # @return [Agentilda::Board::Row]
81
- def row
82
- Board::Row.new(key:,
83
- at: Time.now,
84
- ordinal: @fields[:plan].to_s,
85
- file: @file,
86
- agent: @fields[:agent].to_s,
87
- role: @fields[:agent].to_s,
88
- round: @fields[:round] || 1,
89
- rounds: @fields[:round] || 1,
90
- model: "default",
91
- remaining: handle.remaining || remaining,
92
- phase: handle.phase,
93
- up: @update&.up.to_i,
94
- down: @update&.down.to_i,
95
- message: @message,
96
- state:,
97
- elapsed: seconds.round,
98
- subagents: @update&.subagents.to_i,
99
- history: @history)
100
- end
101
-
102
- private
103
-
104
- # @param state [Symbol]
105
- # @return [void]
106
- def finish(state)
107
- @state = state
108
- @finished_at = UI.monotonic
109
- end
110
- end
111
-
112
- # Opens the screen, runs the block, and always closes it again.
113
- #
114
- # @param root [String] shown in the bottom bar
115
- # @param screen [Agentilda::Screen::Ratatui]
116
- # @yieldparam dashboard [Agentilda::Dashboard]
117
- # @return [Object] the block's value
118
- def self.open(root: Dir.pwd, screen: Screen::Ratatui.new)
119
- dashboard = new(root:, screen:)
120
- dashboard.start
121
- begin
122
- yield dashboard
123
- ensure
124
- dashboard.stop
125
- end
126
- end
127
-
128
- # @param root [String]
129
- # @param screen [Agentilda::Screen::Ratatui]
130
- def initialize(root:, screen:)
131
- @root = root
132
- @screen = screen
133
- @console = Console.new(screen:)
134
- @console.attach(self)
135
- @trackers = []
136
- @lock = Mutex.new
137
- @started_at = UI.monotonic
138
- @closing = false
139
- end
140
-
141
- # @return [void]
142
- def start
143
- # ratatui owns the terminal's input, so the keyboard is fed from its
144
- # loop rather than reading STDIN on a thread of its own.
145
- @keyboard = Keyboard.new(sink: @console)
146
- @screen.attach_keyboard(@keyboard)
147
- @screen.open
148
- @painter = Thread.new do
149
- Thread.current.report_on_exception = false
150
- until @closing
151
- paint
152
- sleep(REFRESH)
153
- end
154
- end
155
- end
156
-
157
- # @return [void]
158
- def stop
159
- @closing = true
160
- @painter&.join
161
- paint
162
- @screen.close
163
- end
164
-
165
- # @param key [String]
166
- # @param fields [Hash]
167
- # @param file [String]
168
- # @param message [String]
169
- # @param timeout [Integer, nil]
170
- # @return [Tracker]
171
- def track(key:, fields: {}, file: "", message: "starting", timeout: nil)
172
- Tracker.new(key:, fields:, file:, message:, timeout:).tap do |tracker|
173
- @lock.synchronize { @trackers << tracker }
174
- end
175
- end
176
-
177
- # @return [void]
178
- def paint = @console.paint(board)
179
-
180
- # @return [Agentilda::Board]
181
- def board
182
- rows = visible.map(&:row)
183
- running = rows.select(&:running?)
184
- Board.new(started_at: @started_at,
185
- status: Control.quit? ? :quitting : :running,
186
- plans: rows.map(&:ordinal).reject(&:empty?).uniq,
187
- up: rows.sum(&:up),
188
- down: rows.sum(&:down),
189
- rows:,
190
- root: @root,
191
- running: running.size,
192
- live_up: running.sum(&:up),
193
- live_down: running.sum(&:down))
194
- end
195
-
196
- # @param key [String]
197
- # @return [void]
198
- def kill(key) = find(key)&.handle&.kill!
199
-
200
- # @param key [String]
201
- # @param seconds [Integer]
202
- # @return [void]
203
- def extend(key, seconds) = find(key)&.handle&.extend!(seconds)
204
-
205
- private
206
-
207
- # @return [Array<Tracker>] running, plus whatever finished a moment ago
208
- def visible
209
- now = UI.monotonic
210
- @lock.synchronize do
211
- @trackers.reject { |t| t.finished_at && now - t.finished_at > LINGER }
212
- end
213
- end
214
-
215
- # @param key [String]
216
- # @return [Tracker, nil]
217
- def find(key) = @lock.synchronize { @trackers.find { |t| t.key == key } }
218
- end
219
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "agentilda/status"
4
-
5
- module Agentilda
6
- # Telling "this belongs to no specification" apart from "nobody could tell".
7
- #
8
- # Both end up without a plan number, and filing them under one marker loses
9
- # the difference between a finished thought and an open question. A
10
- # dependency bump genuinely implements no specification and never will —
11
- # that is an assertion, and `[dev]` records it. A pull request that adds
12
- # Schedule K-1 for four tax years plainly implements *something*; nothing
13
- # here could work out what, and `[none]` records that instead, as a job left
14
- # for a human rather than a verdict.
15
- #
16
- # The bar for `[dev]` is deliberately high. Marking real work as `[dev]`
17
- # tells everyone afterwards it was never worth a plan, and nobody re-opens a
18
- # question that looks settled. `[none]` is visibly unfinished, so the cost of
19
- # guessing wrong in that direction is somebody spending a minute on it.
20
- module DevWork
21
- module_function
22
-
23
- # Titles that say what they are: dependency bumps, release chores, CI.
24
- TITLES = [
25
- /\Abump\s+\S+\s+from\s+\S+\s+to\s+\S+/i,
26
- /\A(?:chore|ci|build|deps|style|refactor)(?:\([^)]*\))?:/i,
27
- /\Abump\s+(?:to\s+)?v?\d+\.\d+/i
28
- ].freeze
29
-
30
- # Paths that are how the project is built rather than what it does.
31
- PLUMBING = %r{\A(?:\.github/|\.circleci/|\.devcontainer/|bin/|scripts/|
32
- Dockerfile|\.dockerignore|Gemfile(?:\.lock)?|justfile|Rakefile|
33
- \.rubocop\.ya?ml|\.standard\.ya?ml|\.gitignore|\.tool-versions)}x
34
-
35
- # Files that say nothing either way, so they neither prove plumbing nor
36
- # disprove it — every pull request in this tool's world touches `.plans`.
37
- NEUTRAL = %r{\A(?:#{PLANS_DIR}/|CHANGELOG|README)}
38
-
39
- # @param title [String]
40
- # @param files [Array<String>] paths in the diff
41
- # @return [Boolean] whether this can be *asserted* to implement no plan
42
- def developer?(title, files = [])
43
- return true if TITLES.any? { |pattern| title.to_s.match?(pattern) }
44
-
45
- judged = Array(files).reject { |path| path.match?(NEUTRAL) }
46
- return false if judged.empty?
47
-
48
- judged.all? { |path| path.match?(PLUMBING) }
49
- end
50
-
51
- # @param title [String]
52
- # @param files [Array<String>]
53
- # @return [String] the marker to stamp on it
54
- def marker(title, files = []) = developer?(title, files) ? NO_PLAN_PREFIX : NONE_PREFIX
55
- end
56
- end