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
data/lib/agentilda/control.rb
DELETED
|
@@ -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
|
data/lib/agentilda/creator.rb
DELETED
|
@@ -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
|
data/lib/agentilda/dashboard.rb
DELETED
|
@@ -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
|
data/lib/agentilda/dev_work.rb
DELETED
|
@@ -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
|