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.
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
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Execution
5
+ # One `claude` process the harness started and therefore owns.
6
+ #
7
+ # `TTY::Command` never exposes the pid it spawned, so the executor used to
8
+ # find its child by scraping `ps` for a fresh `claude` under this process
9
+ # and could never safely signal it. Kill (`k`) and the sixty-second
10
+ # backstop after STOP both need a pid that is certainly ours, so the
11
+ # process is spawned here and the pid kept.
12
+ #
13
+ # stderr is folded into the same pipe as stdout: `claude` prints the cause
14
+ # of a failure there, and the transcript keeps non-JSON lines for exactly
15
+ # that reason.
16
+ class Child
17
+ # @param argv [Array<String>]
18
+ # @param chdir [String, nil]
19
+ # @return [Agentilda::Execution::Child]
20
+ def self.spawn(argv, chdir: nil)
21
+ reader, writer = IO.pipe
22
+ options = { out: writer, err: writer, in: File::NULL, unsetenv_others: true }
23
+ options[:chdir] = chdir if chdir
24
+ pid = Process.spawn(environment, *argv, **options)
25
+ writer.close
26
+ new(pid:, reader:)
27
+ end
28
+
29
+ # The environment the child starts in: the one Bundler was loaded under,
30
+ # less what Bundler itself put there. `exe/agentilda` pins `BUNDLE_GEMFILE`
31
+ # to its own Gemfile and `bundler/setup` adds `-rbundler/setup` to
32
+ # `RUBYOPT`; a child that inherited them would run every `bundle exec`,
33
+ # `standardrb` and `alock` against the harness's checkout instead of the
34
+ # worktree it was handed, and be told the gems that worktree's Gemfile adds
35
+ # are "not currently included in the bundle". Bundler knows exactly what it
36
+ # changed, so it is asked; without Bundler there is nothing to strip.
37
+ #
38
+ # @return [Hash{String => String}]
39
+ def self.environment
40
+ defined?(::Bundler) ? ::Bundler.unbundled_env : ENV.to_h
41
+ end
42
+
43
+ # @param pid [Integer]
44
+ # @param reader [IO]
45
+ def initialize(pid:, reader:)
46
+ @pid = pid
47
+ @reader = reader
48
+ @status = nil
49
+ end
50
+
51
+ # @return [Integer]
52
+ attr_reader :pid
53
+
54
+ # Read until the process closes its end. Runs on the caller's thread;
55
+ # the executor reads on the same thread it always did.
56
+ #
57
+ # @yieldparam text [String] whatever arrived, possibly a partial line
58
+ # @return [void]
59
+ def each_chunk
60
+ loop { yield @reader.readpartial(65_536) }
61
+ rescue IOError
62
+ @reader.close unless @reader.closed?
63
+ end
64
+
65
+ # @return [Process::Status]
66
+ def wait
67
+ @status ||= begin
68
+ _, status = Process.wait2(pid)
69
+ status
70
+ end
71
+ rescue Errno::ECHILD
72
+ @status
73
+ end
74
+
75
+ # @param signal [String]
76
+ # @return [void]
77
+ def kill(signal = "KILL")
78
+ Process.kill(signal, pid)
79
+ rescue Errno::ESRCH
80
+ # Already gone, which is what was wanted.
81
+ end
82
+
83
+ # @return [Boolean]
84
+ def alive?
85
+ return false if @status
86
+
87
+ Process.kill(0, pid)
88
+ true
89
+ rescue Errno::ESRCH
90
+ false
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Execution
5
+ # The advisory clock one invocation runs against.
6
+ #
7
+ # It never terminates the agent on its own. It writes into the control file
8
+ # the agent polls: a warning ten and five minutes out, a wrap-up request at
9
+ # one, STOP at zero. Only a process still alive {GRACE} seconds after STOP
10
+ # is killed, and that is the executor's act on this clock's say-so, so the
11
+ # agent has every chance to write its ledger line first.
12
+ #
13
+ # `now` is injectable so the moments can be tested without sleeping.
14
+ class Clock
15
+ # Seconds before the deadline, and what the control file says then.
16
+ # A moment already in the past when the clock starts is skipped: a five
17
+ # minute agent told "10 minutes left" at t=0 has been lied to.
18
+ WARNINGS = [
19
+ [600, "WARN: 10 minutes left"],
20
+ [300, "WARN: 5 minutes left"],
21
+ [60, "WRAP_UP: 1 minute left, write to disk now"]
22
+ ].freeze
23
+
24
+ # Seconds after STOP before {#on_expire} fires.
25
+ GRACE = 60
26
+
27
+ # @param seconds [Integer] the budget
28
+ # @param control [String] the invocation's control file
29
+ # @param on_expire [Proc] called once, GRACE seconds after STOP
30
+ # @param now [Proc] a monotonic clock reading
31
+ def initialize(seconds:, control:, on_expire:, now: -> { UI.monotonic })
32
+ @control = control
33
+ @on_expire = on_expire
34
+ @now = now
35
+ @mutex = Mutex.new
36
+ arm(seconds)
37
+ end
38
+
39
+ # @return [Integer] whole seconds left, floored at zero
40
+ def remaining = [@deadline - @now.call, 0].max.round
41
+
42
+ # @return [Symbol] :calm, :warned, :wrap_up, :stopped or :expired
43
+ attr_reader :phase
44
+
45
+ # Evaluate every moment once. Idempotent within a phase.
46
+ #
47
+ # @return [void]
48
+ def tick
49
+ @mutex.synchronize do
50
+ left = @deadline - @now.call
51
+ WARNINGS.each do |before, text|
52
+ next if @fired.include?(before) || left > before
53
+
54
+ @fired << before
55
+ Control.write(@control, text)
56
+ @phase = before == 60 ? :wrap_up : :warned
57
+ end
58
+ if left <= 0 && !@fired.include?(:stop)
59
+ @fired << :stop
60
+ Control.write(@control, Control::STOP)
61
+ @phase = :stopped
62
+ end
63
+ if @fired.include?(:stop) && !@fired.include?(:expired) && left <= -GRACE
64
+ @fired << :expired
65
+ @phase = :expired
66
+ @on_expire.call
67
+ end
68
+ end
69
+ end
70
+
71
+ # @return [self]
72
+ def start
73
+ @thread ||= Thread.new do
74
+ Thread.current.report_on_exception = false
75
+ loop do
76
+ tick
77
+ break if phase == :expired
78
+
79
+ sleep(1)
80
+ end
81
+ end
82
+ self
83
+ end
84
+
85
+ # @return [void]
86
+ def stop
87
+ @thread&.kill
88
+ @thread = nil
89
+ end
90
+
91
+ # Ten more minutes, or however many. The file is emptied so the next poll
92
+ # reads a reprieve, and every moment is re-armed against the new deadline.
93
+ #
94
+ # @param seconds [Integer]
95
+ # @return [void]
96
+ def extend!(seconds)
97
+ @mutex.synchronize do
98
+ @deadline += seconds
99
+ @fired = []
100
+ @phase = :calm
101
+ Control.write(@control, "")
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ # @param seconds [Integer]
108
+ # @return [void]
109
+ def arm(seconds)
110
+ @deadline = @now.call + seconds
111
+ @fired = WARNINGS.map(&:first).select { |before| before >= seconds }
112
+ @phase = :calm
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Agentilda
6
+ module Execution
7
+ # The side channel between a keypress and a running agent.
8
+ #
9
+ # `claude -p` takes no input once it starts, so the only way to reach an
10
+ # agent mid-flight is a file it has been told to poll: each invocation gets
11
+ # a control file of its own, named in its prompt, and a keypress writes a
12
+ # word into every file currently registered. Like everything else about the
13
+ # prompt this is a request — an agent deep in a tool call reads the file at
14
+ # its next step, not instantly — which is why {#quit!} also arms a deadline
15
+ # the {Executor} checks, so "quit" is eventually a guarantee too.
16
+ module Control
17
+ # What a keypress writes into a control file. One word, one line, so an
18
+ # agent can act on `File.read(path).strip` and nothing subtler.
19
+ WRAP_UP = "WRAP_UP"
20
+ STOP = "STOP"
21
+
22
+ # Ctrl-C: STOP, and the run will be started again, so the agent leaves
23
+ # a resume note before it goes. The prompt says what that note is.
24
+ INTERRUPT = "INTERRUPT"
25
+
26
+ # The clock's warnings start with this; the agent's prompt explains them.
27
+ WARN = "WARN"
28
+
29
+ # Seconds between {#quit!} and the harness terminating whatever is still
30
+ # running. Long enough to write files and a handoff note; short enough
31
+ # that q means quit rather than "quit eventually".
32
+ GRACE = 60
33
+
34
+ @mutex = Mutex.new
35
+ @files = []
36
+ @quit = false
37
+ @interrupted = false
38
+ @deadline = nil
39
+
40
+ class << self
41
+ # Register a fresh control file for one invocation and hand back its
42
+ # path, to be named in the agent's prompt.
43
+ #
44
+ # @param dir [String] where the file lives (the trace dir — outside the
45
+ # repository for the same reason traces are)
46
+ # @param name [String] something findable: plan ordinal and agent name
47
+ # @return [String]
48
+ def register(dir, name)
49
+ FileUtils.mkdir_p(dir)
50
+ path = File.join(dir, "control-#{name}-#{Process.pid}-#{format("%04x", rand(0x10000))}")
51
+ File.write(path, "")
52
+ @mutex.synchronize { @files << path }
53
+ path
54
+ end
55
+
56
+ # Forget a finished invocation's file. The file itself is removed so a
57
+ # crashed run does not leave stale STOPs for the next one to find.
58
+ #
59
+ # @param path [String]
60
+ # @return [void]
61
+ def release(path)
62
+ @mutex.synchronize { @files.delete(path) }
63
+ FileUtils.rm_f(path)
64
+ end
65
+
66
+ # One line into one file, from the clock. Not a broadcast: a warning is
67
+ # about one agent's own deadline.
68
+ #
69
+ # @param path [String]
70
+ # @param text [String]
71
+ # @return [void]
72
+ def write(path, text)
73
+ File.write(path, text.empty? ? "" : "#{text}\n")
74
+ rescue SystemCallError
75
+ # The invocation finished and released its file; nothing to warn.
76
+ end
77
+
78
+ # `w`: every running agent is asked to finish the essential remainder
79
+ # as fast as it can. The loop itself keeps going.
80
+ #
81
+ # @return [void]
82
+ def wrap_up! = broadcast(WRAP_UP)
83
+
84
+ # `n`: every running agent is asked to write out what it has and end
85
+ # its turn. The loop keeps going — under chaining that is exactly what
86
+ # hands the plan to the next agent.
87
+ #
88
+ # @return [void]
89
+ def stop! = broadcast(STOP)
90
+
91
+ # `q`: {#stop!}, plus the loop ends after this round, plus a deadline —
92
+ # {GRACE} seconds from now — after which the {Executor} aborts whatever
93
+ # is still running rather than waiting on its goodwill.
94
+ #
95
+ # @return [void]
96
+ def quit!
97
+ stop!
98
+ @mutex.synchronize {
99
+ @quit = true
100
+ @deadline ||= UI.monotonic + GRACE
101
+ }
102
+ end
103
+
104
+ # Ctrl-C, the first time: {#quit!}, except that every running agent is
105
+ # told the run is being interrupted rather than stopped, so it writes
106
+ # down where it got to. A restart then reads that note instead of
107
+ # redoing, or half-redoing, the work.
108
+ #
109
+ # @return [void]
110
+ def interrupt!
111
+ broadcast(INTERRUPT)
112
+ @mutex.synchronize {
113
+ @quit = true
114
+ @interrupted = true
115
+ @deadline ||= UI.monotonic + GRACE
116
+ }
117
+ end
118
+
119
+ # @return [Boolean] whether `q` has been pressed
120
+ def quit? = @mutex.synchronize { @quit }
121
+
122
+ # @return [Boolean] whether Ctrl-C has been pressed
123
+ def interrupted? = @mutex.synchronize { @interrupted }
124
+
125
+ # Ctrl-C where no keyboard reads it: a run with no terminal to put in
126
+ # raw mode still gets the graceful first press and the hard second.
127
+ #
128
+ # A trap handler may not take a mutex, so the first press hands
129
+ # {#interrupt!} to a thread; the second raises, as Ctrl-C always has.
130
+ #
131
+ # @yieldreturn [Object]
132
+ # @return [Object] the block's value
133
+ def on_interrupt
134
+ previous = Signal.trap("INT") do
135
+ # A plain read, not {#interrupted?}: a trap handler may not lock.
136
+ raise Interrupt if @interrupted || @pending_interrupt
137
+
138
+ @pending_interrupt = true
139
+ Thread.new { interrupt! }
140
+ end
141
+ yield
142
+ ensure
143
+ @pending_interrupt = false
144
+ Signal.trap("INT", previous || "DEFAULT")
145
+ end
146
+
147
+ # @return [Boolean] whether the grace period after `q` has run out
148
+ def overdue?
149
+ @mutex.synchronize { !@deadline.nil? && UI.monotonic > @deadline }
150
+ end
151
+
152
+ # Back to rest, for the next `Runner#call` — and for the suite, where
153
+ # one example's q must not quit every example after it.
154
+ #
155
+ # @return [void]
156
+ def reset!
157
+ @mutex.synchronize {
158
+ @files.each { |f| FileUtils.rm_f(f) }
159
+ @files.clear
160
+ @quit = false
161
+ @interrupted = false
162
+ @deadline = nil
163
+ }
164
+ end
165
+
166
+ private
167
+
168
+ # @param word [String]
169
+ # @return [void]
170
+ def broadcast(word)
171
+ @mutex.synchronize { @files.dup }.each do |path|
172
+ File.write(path, "#{word}\n")
173
+ rescue SystemCallError
174
+ # A file whose invocation just finished is not an error to miss.
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end