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,283 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "tty/command"
4
-
5
- module Agentilda
6
- # A new plan's opening brief: four questions, and a best-effort first pass
7
- # at answering them from what the project already has on disk.
8
- #
9
- # This is for work that does not exist yet. A plan created `--after` a
10
- # shipped pull request already has its facts — {CLI::Create} hands those to
11
- # `yoda-writer`, which reconstructs a specification from a diff, not this.
12
- # Guessing at a diff and guessing at a feature nobody has built are
13
- # different jobs, and conflating them is how a retroactive spec ends up
14
- # full of hedges about something that already, provably, happened.
15
- #
16
- # The four headings are the brief `leah-researcher` and `yoda-writer` both
17
- # read *before* their own chapters: what research needs to settle is a
18
- # question about the gaps in `## What already exists`, so a scaffold
19
- # missing that heading forces both of them to first reconstruct a boundary
20
- # this class already had for free — it just created the folder.
21
- class Brief
22
- # The half-agent's name. It has no `agents/*.md` of its own on purpose —
23
- # a roster entry would put it in `Runner`'s routing, and this pass runs
24
- # only inside `create`. The name exists so the UI can show it the same
25
- # way it shows `leah-researcher` and `yoda-writer` on their spinner lines.
26
- AGENT_NAME = "anakin-briefster"
27
-
28
- HEADINGS = [
29
- "What we are trying to achieve",
30
- "Why it matters",
31
- "What already exists",
32
- "What research needs to settle"
33
- ].freeze
34
-
35
- # Read whole into the drafting prompt, in order, so the model sees the
36
- # project's own account of itself before it sees the topic.
37
- CONTEXT_FILES = %w[CLAUDE.md AGENTS.md README.md].freeze
38
-
39
- # Relative to the repository root. Not every project keeps one — a
40
- # missing backlog is silently skipped, not an error.
41
- BACKLOG_FILE = File.join(Agentilda::PLANS_DIR, "BACKLOG.md")
42
-
43
- # Long enough for a handful of Read/Grep/Glob calls over a local
44
- # checkout; short enough that a hung `claude` does not stall `create`
45
- # past the point where the scaffold is already there to open by hand.
46
- TIMEOUT = 60
47
-
48
- # The fastest model, and the draft is disposable: the prompt prefers an
49
- # honest blank over a clever inference, so there is no reasoning premium
50
- # worth paying a larger model for inside a 60-second budget.
51
- BRIEF_MODEL = "haiku"
52
-
53
- # Read-only, and no `Bash` — this pass surveys what the repository
54
- # already says about itself, it does not go looking further. That is
55
- # `leah-researcher`'s job, and denying her tools here is what keeps the
56
- # two from quietly doing the same work twice.
57
- ALLOWED_TOOLS = %w[Read Grep Glob Edit].freeze
58
- DENIED_TOOLS = %w[WebFetch WebSearch Bash].freeze
59
-
60
- # The `stdout:` and `stderr:` sections of a {TTY::Command::ExitError}
61
- # message. stdout runs until stderr starts; stderr runs to the end, because
62
- # what `claude` prints there is not guaranteed to be one line.
63
- STDOUT_SECTION = /^[ \t]*stdout:[ \t]*(.*?)(?=\n[ \t]*stderr:|\z)/m
64
- STDERR_SECTION = /^[ \t]*stderr:[ \t]*(.*)\z/m
65
-
66
- # How much of what `claude` said survives into a one-line report.
67
- REASON_LIMIT = 300
68
-
69
- # A one-line account of a failed `claude` run, built from the error rather
70
- # than its first line: that first line is the escaped prompt, several
71
- # thousand characters of it, and reporting it says an invocation failed,
72
- # at length, and never why.
73
- #
74
- # Both streams are kept because they carry different halves. `claude`
75
- # reports its own failures on stdout; the line naming the *cause* of a 401
76
- # (`ANTHROPIC_API_KEY ... takes precedence over your claude.ai login`) is
77
- # on stderr. {Executor} used to own this; it now reads a child process
78
- # directly and has no exit error to parse, so the briefer keeps it.
79
- #
80
- # @param error [TTY::Command::ExitError]
81
- # @return [String]
82
- def self.failure_reason(error)
83
- status = error.message[/^[ \t]*exit status:[ \t]*(\S+)/, 1]
84
- outcome = status ? "exited #{status}" : "failed"
85
- said = [STDOUT_SECTION, STDERR_SECTION]
86
- .filter_map { |section| tail(error.message[section, 1]) }
87
- .join(" | ")
88
-
89
- said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}"
90
- end
91
-
92
- # @param text [String, nil]
93
- # @return [String, nil] the last few meaningful lines, on one line
94
- def self.tail(text)
95
- lines = text.to_s.split("\n").map(&:strip).reject { |line| line.empty? || line == "Nothing written" }
96
- return nil if lines.empty?
97
-
98
- joined = lines.last(3).join(" ")
99
- joined.length > REASON_LIMIT ? "#{joined[0, REASON_LIMIT - 1]}..." : joined
100
- end
101
- private_class_method :tail
102
-
103
- # @param path [String] the new plan folder, absolute
104
- # @param title [String] the feature's proper name, e.g. "Tax Rule DSL"
105
- # @param root [String] repository root, for project context and `--add-dir`
106
- # @param command [TTY::Command]
107
- # @param timeout [Integer] seconds before the drafting attempt is abandoned
108
- # @param dry_run [Boolean] plan the invocation, do not run it
109
- # @param seed [String, nil] what the author already wrote about the
110
- # feature (`create --from`); opens spec.md and outranks the repo survey
111
- def initialize(
112
- path:,
113
- title:,
114
- root:,
115
- command: TTY::Command.new(printer: :null),
116
- timeout: TIMEOUT,
117
- dry_run: false,
118
- seed: nil
119
- )
120
- @path = path
121
- @title = title
122
- @root = File.expand_path(root)
123
- @command = command
124
- @timeout = timeout
125
- @dry_run = dry_run
126
- @seed = seed.to_s.strip.then { |s| s.empty? ? nil : s }
127
- end
128
-
129
- # @return [String]
130
- attr_reader :path, :title, :root
131
-
132
- # @return [String, nil]
133
- attr_reader :seed
134
-
135
- # @return [String] absolute path to the spec, whether or not it exists yet
136
- def spec_path = File.join(path, "spec.md")
137
-
138
- # The title and four empty headings — and, when `create --from` supplied
139
- # one, the author's own prose between them, so the file already says
140
- # something even if the drafting attempt never returns.
141
- #
142
- # @return [String]
143
- def scaffold
144
- opening = seed ? "#{seed}\n\n" : ""
145
- "# #{title}\n\n#{opening}" + HEADINGS.map { |h| "## #{h}\n\n" }.join("\n")
146
- end
147
-
148
- # @return [String] where it was written
149
- def write_scaffold!
150
- File.write(spec_path, scaffold)
151
- spec_path
152
- end
153
-
154
- # Make a best-effort attempt at the four headings, in place, by shelling
155
- # out to `claude` the same way {Executor} does for a specialist agent.
156
- # A failure here is not this method's to hide: the scaffold {#write_scaffold!}
157
- # already wrote stands on its own, so the caller reports the failure and
158
- # moves on rather than treating it as fatal to `create`.
159
- #
160
- # @return [Array(Boolean, String)] ok, and a one-line note
161
- def attempt!
162
- return [true, "dry run — would draft from project context"] if @dry_run
163
-
164
- begin
165
- @command.run(*invocation, timeout: @timeout)
166
- rescue TTY::Command::TimeoutExceeded
167
- return [false, "timed out after #{@timeout}s"]
168
- rescue TTY::Command::ExitError => e
169
- return [false, "claude #{self.class.failure_reason(e)}"]
170
- end
171
-
172
- [true, "drafted"]
173
- end
174
-
175
- # Exposed so a spec can assert the boundary without running anything.
176
- #
177
- # @return [Array<String>]
178
- def invocation
179
- ["claude",
180
- "-p", prompt, "--add-dir", root,
181
- "--model", BRIEF_MODEL,
182
- "--allowedTools", ALLOWED_TOOLS.join(","),
183
- "--disallowedTools", DENIED_TOOLS.join(",")]
184
- end
185
-
186
- private
187
-
188
- # @return [String]
189
- def prompt
190
- <<~PROMPT
191
- #{author_statement}A new plan folder was just created for a feature that does not exist
192
- yet: "#{title}".
193
-
194
- You have 50 seconds. Explore the codebase for the first thirty and
195
- write for the last twenty; the process is killed at sixty and an
196
- unwritten draft is lost.
197
-
198
- #{spec_path} holds a title and four empty headings:
199
-
200
- #{HEADINGS.map { |h| " * #{h}" }.join("\n")}
201
-
202
- Make a best-effort first pass at each, drawn only from what THIS
203
- project already has on disk under #{root} — its own docs, its own
204
- conventions, code already built, and anything already downloaded or
205
- gathered that bears on "#{title}". Read/Grep/Glob freely to look for
206
- it; do not invent facts and do not research the web. Where the
207
- repository gives you nothing to go on, leave that heading's body as
208
- a short, honest one-line note saying so, e.g. "_Nothing in the repo
209
- speaks to this yet — needs research._" A human completes this
210
- afterward, and a confident-sounding guess costs them more to unwind
211
- than an honest blank.
212
-
213
- `## What already exists` matters most: name specific files, modules
214
- or prior work you found, not just that "some code exists" — that is
215
- exactly the context a researcher picking this up next cannot see for
216
- themselves without being told where to look.
217
-
218
- #{context}
219
- #{backlog}
220
- Edit #{spec_path} in place. Keep the title#{", the author's opening prose" if seed} and the four `##`
221
- headings exactly as they are, in order; add prose under them; write
222
- nothing outside them; touch no other file.
223
- PROMPT
224
- end
225
-
226
- # The author's own words lead the prompt, ahead of even the feature's
227
- # announcement, because they outrank everything this pass could survey:
228
- # where the seed and the repository disagree, the seed is the intent and
229
- # the repository is merely the past.
230
- #
231
- # @return [String] empty without a seed
232
- def author_statement
233
- return "" unless seed
234
-
235
- <<~STATEMENT
236
- The author has already written the following about this feature. It
237
- is the primary source: where it answers one of the headings, draw
238
- from it first, and use the repository survey below only to supplement
239
- it — never to contradict it.
240
-
241
- #{seed}
242
-
243
- STATEMENT
244
- end
245
-
246
- # AGENTS.md is commonly a symlink to CLAUDE.md; realpath-deduping keeps
247
- # the shared content from being inlined twice into every drafting prompt.
248
- #
249
- # @return [String]
250
- def context
251
- seen = []
252
- found = CONTEXT_FILES.filter_map { |name|
253
- file = File.join(root, name)
254
- next unless File.file?(file)
255
-
256
- real = File.realpath(file)
257
- next if seen.include?(real)
258
-
259
- seen << real
260
- "### #{name}\n\n#{File.read(file, encoding: "UTF-8")}"
261
- }
262
- return "" if found.empty?
263
-
264
- "## Project context\n\n#{found.join("\n\n")}\n"
265
- end
266
-
267
- # A relevant line in the project's own backlog is worth more than
268
- # anything this pass could infer on its own — it is the one place "why
269
- # now" might already be written down.
270
- #
271
- # @return [String]
272
- def backlog
273
- file = File.join(root, BACKLOG_FILE)
274
- return "" unless File.file?(file)
275
-
276
- "## #{BACKLOG_FILE}\n\n" \
277
- "If this backlog mentions \"#{title}\", treat that entry as " \
278
- "authoritative for `## Why it matters` — it is the project's own " \
279
- "statement of intent.\n\n" \
280
- "#{File.read(file, encoding: "UTF-8")}\n"
281
- end
282
- end
283
- end
@@ -1,92 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # One `claude` process the harness started and therefore owns.
5
- #
6
- # `TTY::Command` never exposes the pid it spawned, so the executor used to
7
- # find its child by scraping `ps` for a fresh `claude` under this process
8
- # and could never safely signal it. Kill (`k`) and the sixty-second
9
- # backstop after STOP both need a pid that is certainly ours, so the
10
- # process is spawned here and the pid kept.
11
- #
12
- # stderr is folded into the same pipe as stdout: `claude` prints the cause
13
- # of a failure there, and the transcript keeps non-JSON lines for exactly
14
- # that reason.
15
- class Child
16
- # @param argv [Array<String>]
17
- # @param chdir [String, nil]
18
- # @return [Agentilda::Child]
19
- def self.spawn(argv, chdir: nil)
20
- reader, writer = IO.pipe
21
- options = { out: writer, err: writer, in: File::NULL, unsetenv_others: true }
22
- options[:chdir] = chdir if chdir
23
- pid = Process.spawn(environment, *argv, **options)
24
- writer.close
25
- new(pid:, reader:)
26
- end
27
-
28
- # The environment the child starts in: the one Bundler was loaded under,
29
- # less what Bundler itself put there. `exe/agentilda` pins `BUNDLE_GEMFILE`
30
- # to its own Gemfile and `bundler/setup` adds `-rbundler/setup` to
31
- # `RUBYOPT`; a child that inherited them would run every `bundle exec`,
32
- # `standardrb` and `alock` against the harness's checkout instead of the
33
- # worktree it was handed, and be told the gems that worktree's Gemfile adds
34
- # are "not currently included in the bundle". Bundler knows exactly what it
35
- # changed, so it is asked; without Bundler there is nothing to strip.
36
- #
37
- # @return [Hash{String => String}]
38
- def self.environment
39
- defined?(::Bundler) ? ::Bundler.unbundled_env : ENV.to_h
40
- end
41
-
42
- # @param pid [Integer]
43
- # @param reader [IO]
44
- def initialize(pid:, reader:)
45
- @pid = pid
46
- @reader = reader
47
- @status = nil
48
- end
49
-
50
- # @return [Integer]
51
- attr_reader :pid
52
-
53
- # Read until the process closes its end. Runs on the caller's thread;
54
- # the executor reads on the same thread it always did.
55
- #
56
- # @yieldparam text [String] whatever arrived, possibly a partial line
57
- # @return [void]
58
- def each_chunk
59
- loop { yield @reader.readpartial(65_536) }
60
- rescue IOError
61
- @reader.close unless @reader.closed?
62
- end
63
-
64
- # @return [Process::Status]
65
- def wait
66
- @status ||= begin
67
- _, status = Process.wait2(pid)
68
- status
69
- end
70
- rescue Errno::ECHILD
71
- @status
72
- end
73
-
74
- # @param signal [String]
75
- # @return [void]
76
- def kill(signal = "KILL")
77
- Process.kill(signal, pid)
78
- rescue Errno::ESRCH
79
- # Already gone, which is what was wanted.
80
- end
81
-
82
- # @return [Boolean]
83
- def alive?
84
- return false if @status
85
-
86
- Process.kill(0, pid)
87
- true
88
- rescue Errno::ESRCH
89
- false
90
- end
91
- end
92
- end
@@ -1,114 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # The advisory clock one invocation runs against.
5
- #
6
- # It never terminates the agent on its own. It writes into the control file
7
- # the agent polls: a warning ten and five minutes out, a wrap-up request at
8
- # one, STOP at zero. Only a process still alive {GRACE} seconds after STOP
9
- # is killed, and that is the executor's act on this clock's say-so, so the
10
- # agent has every chance to write its ledger line first.
11
- #
12
- # `now` is injectable so the moments can be tested without sleeping.
13
- class Clock
14
- # Seconds before the deadline, and what the control file says then.
15
- # A moment already in the past when the clock starts is skipped: a five
16
- # minute agent told "10 minutes left" at t=0 has been lied to.
17
- WARNINGS = [
18
- [600, "WARN: 10 minutes left"],
19
- [300, "WARN: 5 minutes left"],
20
- [60, "WRAP_UP: 1 minute left, write to disk now"]
21
- ].freeze
22
-
23
- # Seconds after STOP before {#on_expire} fires.
24
- GRACE = 60
25
-
26
- # @param seconds [Integer] the budget
27
- # @param control [String] the invocation's control file
28
- # @param on_expire [Proc] called once, GRACE seconds after STOP
29
- # @param now [Proc] a monotonic clock reading
30
- def initialize(seconds:, control:, on_expire:, now: -> { UI.monotonic })
31
- @control = control
32
- @on_expire = on_expire
33
- @now = now
34
- @mutex = Mutex.new
35
- arm(seconds)
36
- end
37
-
38
- # @return [Integer] whole seconds left, floored at zero
39
- def remaining = [@deadline - @now.call, 0].max.round
40
-
41
- # @return [Symbol] :calm, :warned, :wrap_up, :stopped or :expired
42
- attr_reader :phase
43
-
44
- # Evaluate every moment once. Idempotent within a phase.
45
- #
46
- # @return [void]
47
- def tick
48
- @mutex.synchronize do
49
- left = @deadline - @now.call
50
- WARNINGS.each do |before, text|
51
- next if @fired.include?(before) || left > before
52
-
53
- @fired << before
54
- Control.write(@control, text)
55
- @phase = before == 60 ? :wrap_up : :warned
56
- end
57
- if left <= 0 && !@fired.include?(:stop)
58
- @fired << :stop
59
- Control.write(@control, Control::STOP)
60
- @phase = :stopped
61
- end
62
- if @fired.include?(:stop) && !@fired.include?(:expired) && left <= -GRACE
63
- @fired << :expired
64
- @phase = :expired
65
- @on_expire.call
66
- end
67
- end
68
- end
69
-
70
- # @return [self]
71
- def start
72
- @thread ||= Thread.new do
73
- Thread.current.report_on_exception = false
74
- loop do
75
- tick
76
- break if phase == :expired
77
-
78
- sleep(1)
79
- end
80
- end
81
- self
82
- end
83
-
84
- # @return [void]
85
- def stop
86
- @thread&.kill
87
- @thread = nil
88
- end
89
-
90
- # Ten more minutes, or however many. The file is emptied so the next poll
91
- # reads a reprieve, and every moment is re-armed against the new deadline.
92
- #
93
- # @param seconds [Integer]
94
- # @return [void]
95
- def extend!(seconds)
96
- @mutex.synchronize do
97
- @deadline += seconds
98
- @fired = []
99
- @phase = :calm
100
- Control.write(@control, "")
101
- end
102
- end
103
-
104
- private
105
-
106
- # @param seconds [Integer]
107
- # @return [void]
108
- def arm(seconds)
109
- @deadline = @now.call + seconds
110
- @fired = WARNINGS.map(&:first).select { |before| before >= seconds }
111
- @phase = :calm
112
- end
113
- end
114
- end
@@ -1,157 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Agentilda
4
- # What the keys do to the screen, and to the running agents.
5
- #
6
- # Selection and the dialog live here, never on the screen: the screen
7
- # draws whatever board it is handed, and this decides what the board
8
- # says. Pending changes are visible only in the dialog and take effect
9
- # only on ENTER, so a stray `k` kills nobody.
10
- class Console
11
- # Seconds each `x` adds.
12
- EXTENSION = 600
13
-
14
- # @param screen [Agentilda::Screen::Ratatui]
15
- def initialize(screen:)
16
- @screen = screen
17
- @dispatcher = nil
18
- @board = nil
19
- @selected = nil
20
- @dialog = false
21
- @help = false
22
- @about = false
23
- @pending = { kill: false, extend: 0 }
24
- @mutex = Mutex.new
25
- end
26
-
27
- # @return [String, nil] the selected row's key
28
- attr_reader :selected
29
-
30
- # @return [Hash] `{kill: Boolean, extend: Integer}`
31
- attr_reader :pending
32
-
33
- # @return [Agentilda::Screen::Ratatui]
34
- attr_reader :screen
35
-
36
- # @return [Boolean]
37
- def dialog? = @dialog
38
-
39
- # @return [Boolean]
40
- def help? = @help
41
-
42
- # @return [Boolean]
43
- def about? = @about
44
-
45
- # The dispatcher exists only once the run starts, after the keyboard is
46
- # already listening, so it arrives late.
47
- #
48
- # @param dispatcher [Agentilda::Dispatcher]
49
- # @return [void]
50
- def attach(dispatcher) = @dispatcher = dispatcher
51
-
52
- # Called by the dispatcher each tick.
53
- #
54
- # @param board [Agentilda::Board]
55
- # @return [void]
56
- def paint(board)
57
- @mutex.synchronize do
58
- @board = board
59
- @selected = nil if @selected && board.rows.none? { |r| r.key == @selected && r.running? }
60
- @screen.draw(board.with(selected: @selected,
61
- dialog: (dialog_text if @dialog),
62
- help: (help_text if @help),
63
- about: (about_text if @about)))
64
- end
65
- end
66
-
67
- # @return [void]
68
- def select_next = move(1)
69
-
70
- # @return [void]
71
- def select_prev = move(-1)
72
-
73
- # @return [void]
74
- def toggle_kill
75
- select_next unless @selected
76
- return unless @selected
77
-
78
- @pending[:kill] = !@pending[:kill]
79
- @dialog = true
80
- end
81
-
82
- # @return [void]
83
- def extend
84
- select_next unless @selected
85
- return unless @selected
86
-
87
- @pending[:extend] += EXTENSION
88
- @dialog = true
89
- end
90
-
91
- # ENTER.
92
- #
93
- # @return [void]
94
- def apply
95
- return unless @dialog && @selected && @dispatcher
96
-
97
- @dispatcher.kill(@selected) if @pending[:kill]
98
- @dispatcher.extend(@selected, @pending[:extend]) if @pending[:extend].positive?
99
- UI.log("applied to #{@selected}: #{dialog_text.tr("\n", ", ")}")
100
- close_dialog
101
- end
102
-
103
- # ESC: the dialog first, then the help, then the about screen, then the
104
- # selection.
105
- #
106
- # @return [void]
107
- def escape
108
- if @dialog
109
- close_dialog
110
- elsif @help
111
- @help = false
112
- elsif @about
113
- @about = false
114
- else
115
- @selected = nil
116
- end
117
- end
118
-
119
- # @return [void]
120
- def toggle_help = @help = !@help
121
-
122
- # @return [void]
123
- def toggle_about = @about = !@about
124
-
125
- private
126
-
127
- # @return [void]
128
- def close_dialog
129
- @dialog = false
130
- @pending = { kill: false, extend: 0 }
131
- end
132
-
133
- # @param step [Integer]
134
- # @return [void]
135
- def move(step)
136
- keys = (@board&.rows || []).select(&:running?).map(&:key)
137
- return @selected = nil if keys.empty?
138
-
139
- index = keys.index(@selected)
140
- @selected = index.nil? ? keys.first : keys[(index + step) % keys.size]
141
- end
142
-
143
- # @return [String]
144
- def dialog_text
145
- row = @board&.rows&.find { |r| r.key == @selected }
146
- who = row ? "#{row.ordinal} #{row.agent}" : @selected.to_s
147
- extension = @pending[:extend].positive? ? "+#{@pending[:extend] / 60}m" : "none"
148
- "#{who}\n\nkill: #{@pending[:kill] ? "yes" : "no"}\nextend: #{extension}"
149
- end
150
-
151
- # @return [String]
152
- def help_text = Keyboard.new(input: $stdin).help
153
-
154
- # @return [String]
155
- def about_text = Keyboard.new(input: $stdin).about
156
- end
157
- end