agentilda 1.0.3 → 2.0.0
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/agentilda.gemspec +8 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +78 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +22 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +61 -15
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "agentilda/agent"
|
|
4
|
+
require "agentilda/frontmatter"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
# Loads and indexes the agent definitions.
|
|
8
|
+
class Agents
|
|
9
|
+
# Where definitions live, unless told otherwise.
|
|
10
|
+
DEFAULT_DIR = File.expand_path("../../agents", __dir__)
|
|
11
|
+
|
|
12
|
+
# @param dir [String]
|
|
13
|
+
# @param roster [Array<Agentilda::Agent>, nil] a pre-selected list, used
|
|
14
|
+
# by {#only} and {#without} to derive a narrower roster; nil (the
|
|
15
|
+
# default) loads every definition in `dir`
|
|
16
|
+
def initialize(dir: DEFAULT_DIR, roster: nil)
|
|
17
|
+
@dir = File.expand_path(dir)
|
|
18
|
+
@all = roster
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_reader :dir
|
|
23
|
+
|
|
24
|
+
# @return [Array<Agentilda::Agent>] in name order
|
|
25
|
+
def all
|
|
26
|
+
@all ||= Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse(path) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param name [String]
|
|
30
|
+
# @return [Agentilda::Agent, nil]
|
|
31
|
+
def find(name) = all.find { |a| a.name == name.to_s }
|
|
32
|
+
|
|
33
|
+
# A roster holding only the agents named — what `run --agent` hands the
|
|
34
|
+
# loop, so a restriction typed at the command line restricts assignments
|
|
35
|
+
# and not merely chaining.
|
|
36
|
+
#
|
|
37
|
+
# @param names [Array<String>]
|
|
38
|
+
# @return [Agentilda::Agents]
|
|
39
|
+
def only(*names)
|
|
40
|
+
wanted = names.flatten.map(&:to_s)
|
|
41
|
+
self.class.new(dir:, roster: all.select { |a| wanted.include?(a.name) })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A roster without the agents named — what `run --skip` hands the loop.
|
|
45
|
+
# A plan sitting in a skipped agent's state is simply never assigned, the
|
|
46
|
+
# same way a state no agent handles is stepped around.
|
|
47
|
+
#
|
|
48
|
+
# @param names [Array<String>]
|
|
49
|
+
# @return [Agentilda::Agents]
|
|
50
|
+
def without(*names)
|
|
51
|
+
unwanted = names.flatten.map(&:to_s)
|
|
52
|
+
self.class.new(dir:, roster: all.reject { |a| unwanted.include?(a.name) })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every agent the query could mean. An exact name wins outright; failing
|
|
56
|
+
# that the query matches as a prefix, and failing that anywhere in the
|
|
57
|
+
# name, so `leah` finds leah-researcher and `review` finds
|
|
58
|
+
# hansolo-reviewer. A directory or a trailing `.md` is stripped first,
|
|
59
|
+
# because tab completion hands those in.
|
|
60
|
+
#
|
|
61
|
+
# @param query [String]
|
|
62
|
+
# @return [Array<Agentilda::Agent>]
|
|
63
|
+
def match(query)
|
|
64
|
+
wanted = File.basename(query.to_s, ".md")
|
|
65
|
+
exact = all.select { |a| a.name == wanted }
|
|
66
|
+
return exact unless exact.empty?
|
|
67
|
+
|
|
68
|
+
prefixed = all.select { |a| a.name.start_with?(wanted) }
|
|
69
|
+
return prefixed unless prefixed.empty?
|
|
70
|
+
|
|
71
|
+
all.select { |a| a.name.include?(wanted) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Every agent that will act on a plan in this state, in definition order.
|
|
75
|
+
# A read-only agent is never offered work by the loop — it has nothing to
|
|
76
|
+
# advance, so including it would make every round look productive.
|
|
77
|
+
#
|
|
78
|
+
# @param status [Agentilda::Status]
|
|
79
|
+
# @return [Array<Agentilda::Agent>]
|
|
80
|
+
def for_status(status) = all.select { |a| a.handles?(status) && !a.read_only? }
|
|
81
|
+
|
|
82
|
+
# Everyone on a plan in this state: the agents that work it, plus any
|
|
83
|
+
# agent whose `holds_at` parks the plan here after it finished its half.
|
|
84
|
+
# Rey resuming a plan at 🎨 still needs luke's messages in the mailbox.
|
|
85
|
+
#
|
|
86
|
+
# @param status [Agentilda::Status]
|
|
87
|
+
# @return [Array<Agentilda::Agent>]
|
|
88
|
+
def team_for(status) = all.select { |a| !a.read_only? && (a.handles?(status) || a.holds_at == status.key) }
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# @param path [String]
|
|
93
|
+
# @return [Agentilda::Agent, nil]
|
|
94
|
+
def parse(path)
|
|
95
|
+
meta, body = Frontmatter.split(File.read(path, encoding: "UTF-8"))
|
|
96
|
+
return nil if meta["name"].to_s.empty?
|
|
97
|
+
|
|
98
|
+
Agent.new(
|
|
99
|
+
name: meta["name"].to_s,
|
|
100
|
+
description: meta["description"].to_s,
|
|
101
|
+
handles: Array(meta["handles"]).map { |s| s.to_s.to_sym },
|
|
102
|
+
advances_to: meta["advances_to"]&.to_s&.then { |s| s.empty? ? nil : s.to_sym },
|
|
103
|
+
model: meta["model"],
|
|
104
|
+
allowed_tools: Array(meta["allowed_tools"]).map(&:to_s),
|
|
105
|
+
may: Array(meta["may"]).map { |c| c.to_s.strip.squeeze(" ") },
|
|
106
|
+
network: meta["network"] == true,
|
|
107
|
+
timeout: meta["timeout"].to_i.then { |s| s.positive? ? s : nil },
|
|
108
|
+
prompt: body.strip,
|
|
109
|
+
path: path,
|
|
110
|
+
ledger: Array(meta["ledger"]).map(&:to_s),
|
|
111
|
+
rounds: meta["rounds"].to_i.clamp(1, Agent::MAX_ROUNDS),
|
|
112
|
+
effort: meta["effort"]&.to_s,
|
|
113
|
+
starts_as: symbol_or_nil(meta["starts_as"]),
|
|
114
|
+
holds_at: symbol_or_nil(meta["holds_at"])
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param value [Object, nil]
|
|
119
|
+
# @return [Symbol, nil]
|
|
120
|
+
def symbol_or_nil(value) = value.to_s.empty? ? nil : value.to_s.to_sym
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# What the screen draws: everything about the run, as values, refreshed
|
|
5
|
+
# once a second by the dispatcher. The screen paints it and knows nothing
|
|
6
|
+
# about threads; the dispatcher builds it and knows nothing about cells.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] rows
|
|
9
|
+
# @return [Array<Agentilda::Board::Row>] one per agent invocation still
|
|
10
|
+
# worth showing: running, or finished within the last few seconds
|
|
11
|
+
Board = Data.define(:started_at,
|
|
12
|
+
:status,
|
|
13
|
+
:plans,
|
|
14
|
+
:up,
|
|
15
|
+
:down,
|
|
16
|
+
:rows,
|
|
17
|
+
:root,
|
|
18
|
+
:running,
|
|
19
|
+
:live_up,
|
|
20
|
+
:live_down,
|
|
21
|
+
:selected,
|
|
22
|
+
:dialog,
|
|
23
|
+
:help,
|
|
24
|
+
:about,
|
|
25
|
+
:hidden) do
|
|
26
|
+
def initialize(selected: nil, dialog: nil, help: false, about: nil, hidden: 0, **rest) = super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @!attribute [r] key
|
|
30
|
+
# @return [String] `"001.00/leah-researcher"`, what selection and the
|
|
31
|
+
# keyboard address a row by
|
|
32
|
+
# @!attribute [r] file
|
|
33
|
+
# @return [String] the document holding the agent's latest ledger line
|
|
34
|
+
# @!attribute [r] pr
|
|
35
|
+
# @return [Hash, nil] `{number:, url:, rejected:}` once the plan is at
|
|
36
|
+
# the review stage
|
|
37
|
+
# @!attribute [r] state
|
|
38
|
+
# @return [Symbol] :running, :done, :failed or :stuck
|
|
39
|
+
# @!attribute [r] phase
|
|
40
|
+
# @return [Symbol, nil] the clock's phase while running
|
|
41
|
+
# @!attribute [r] frame
|
|
42
|
+
# @return [Integer] spinner frame counter
|
|
43
|
+
# @!attribute [r] history
|
|
44
|
+
# @return [Array<String>] what the agent has said, newest first, for
|
|
45
|
+
# `run --scroll-height` to show more than the latest line of
|
|
46
|
+
Board::Row = Data.define(:key,
|
|
47
|
+
:at,
|
|
48
|
+
:ordinal,
|
|
49
|
+
:file,
|
|
50
|
+
:agent,
|
|
51
|
+
:role,
|
|
52
|
+
:round,
|
|
53
|
+
:rounds,
|
|
54
|
+
:model,
|
|
55
|
+
:remaining,
|
|
56
|
+
:phase,
|
|
57
|
+
:up,
|
|
58
|
+
:down,
|
|
59
|
+
:message,
|
|
60
|
+
:state,
|
|
61
|
+
:pr,
|
|
62
|
+
:frame,
|
|
63
|
+
:bold,
|
|
64
|
+
:elapsed,
|
|
65
|
+
:subagents,
|
|
66
|
+
:history) do
|
|
67
|
+
def initialize(pr: nil, phase: nil, remaining: nil, frame: 0, bold: false, message: nil,
|
|
68
|
+
elapsed: 0, subagents: 0, history: [], **rest)
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param history [Array<String>] newest first
|
|
73
|
+
# @param text [String, nil]
|
|
74
|
+
# @return [Array<String>] +text+ on top, unless it is empty or repeats
|
|
75
|
+
# the newest line: an agent restating its activity is not news
|
|
76
|
+
def self.remember(history, text)
|
|
77
|
+
text = text.to_s.strip
|
|
78
|
+
return history if text.empty? || history.first == text
|
|
79
|
+
|
|
80
|
+
[text, *history].first(Board::Row::HISTORY)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def running? = state == :running
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Statuses a row keeps, whatever the screen shows of them. A cap, so a
|
|
88
|
+
# long-running agent's chatter cannot grow without bound. Outside the
|
|
89
|
+
# `Data.define` block, whose constants would land on Agentilda instead.
|
|
90
|
+
Board::Row::HISTORY = 50
|
|
91
|
+
end
|
data/lib/agentilda/brief.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tty/command"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
# A new plan's opening brief: four questions, and a best-effort first pass
|
|
5
7
|
# at answering them from what the project already has on disk.
|
|
@@ -55,6 +57,49 @@ module Agentilda
|
|
|
55
57
|
ALLOWED_TOOLS = %w[Read Grep Glob Edit].freeze
|
|
56
58
|
DENIED_TOOLS = %w[WebFetch WebSearch Bash].freeze
|
|
57
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
|
+
|
|
58
103
|
# @param path [String] the new plan folder, absolute
|
|
59
104
|
# @param title [String] the feature's proper name, e.g. "Tax Rule DSL"
|
|
60
105
|
# @param root [String] repository root, for project context and `--add-dir`
|
|
@@ -121,7 +166,7 @@ module Agentilda
|
|
|
121
166
|
rescue TTY::Command::TimeoutExceeded
|
|
122
167
|
return [false, "timed out after #{@timeout}s"]
|
|
123
168
|
rescue TTY::Command::ExitError => e
|
|
124
|
-
return [false, "claude #{
|
|
169
|
+
return [false, "claude #{self.class.failure_reason(e)}"]
|
|
125
170
|
end
|
|
126
171
|
|
|
127
172
|
[true, "drafted"]
|
|
@@ -132,10 +177,10 @@ module Agentilda
|
|
|
132
177
|
# @return [Array<String>]
|
|
133
178
|
def invocation
|
|
134
179
|
["claude",
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
180
|
+
"-p", prompt, "--add-dir", root,
|
|
181
|
+
"--model", BRIEF_MODEL,
|
|
182
|
+
"--allowedTools", ALLOWED_TOOLS.join(","),
|
|
183
|
+
"--disallowedTools", DENIED_TOOLS.join(",")]
|
|
139
184
|
end
|
|
140
185
|
|
|
141
186
|
private
|
|
@@ -146,6 +191,10 @@ module Agentilda
|
|
|
146
191
|
#{author_statement}A new plan folder was just created for a feature that does not exist
|
|
147
192
|
yet: "#{title}".
|
|
148
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
|
+
|
|
149
198
|
#{spec_path} holds a title and four empty headings:
|
|
150
199
|
|
|
151
200
|
#{HEADINGS.map { |h| " * #{h}" }.join("\n")}
|
|
@@ -0,0 +1,78 @@
|
|
|
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 }
|
|
22
|
+
options[:chdir] = chdir if chdir
|
|
23
|
+
pid = Process.spawn(*argv, **options)
|
|
24
|
+
writer.close
|
|
25
|
+
new(pid:, reader:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param pid [Integer]
|
|
29
|
+
# @param reader [IO]
|
|
30
|
+
def initialize(pid:, reader:)
|
|
31
|
+
@pid = pid
|
|
32
|
+
@reader = reader
|
|
33
|
+
@status = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Integer]
|
|
37
|
+
attr_reader :pid
|
|
38
|
+
|
|
39
|
+
# Read until the process closes its end. Runs on the caller's thread;
|
|
40
|
+
# the executor reads on the same thread it always did.
|
|
41
|
+
#
|
|
42
|
+
# @yieldparam text [String] whatever arrived, possibly a partial line
|
|
43
|
+
# @return [void]
|
|
44
|
+
def each_chunk
|
|
45
|
+
loop { yield @reader.readpartial(65_536) }
|
|
46
|
+
rescue IOError
|
|
47
|
+
@reader.close unless @reader.closed?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Process::Status]
|
|
51
|
+
def wait
|
|
52
|
+
@status ||= begin
|
|
53
|
+
_, status = Process.wait2(pid)
|
|
54
|
+
status
|
|
55
|
+
end
|
|
56
|
+
rescue Errno::ECHILD
|
|
57
|
+
@status
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param signal [String]
|
|
61
|
+
# @return [void]
|
|
62
|
+
def kill(signal = "KILL")
|
|
63
|
+
Process.kill(signal, pid)
|
|
64
|
+
rescue Errno::ESRCH
|
|
65
|
+
# Already gone, which is what was wanted.
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def alive?
|
|
70
|
+
return false if @status
|
|
71
|
+
|
|
72
|
+
Process.kill(0, pid)
|
|
73
|
+
true
|
|
74
|
+
rescue Errno::ESRCH
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -10,11 +10,15 @@ module Agentilda
|
|
|
10
10
|
|
|
11
11
|
desc "Open one specialist's definition in a Markdown viewer"
|
|
12
12
|
|
|
13
|
-
argument :name,
|
|
14
|
-
|
|
13
|
+
argument :name,
|
|
14
|
+
required: false,
|
|
15
|
+
desc: "Which specialist, full name or a fragment, e.g. leah (default: every one)"
|
|
15
16
|
|
|
16
|
-
option :mdfried,
|
|
17
|
-
|
|
17
|
+
option :mdfried,
|
|
18
|
+
type: :boolean,
|
|
19
|
+
default: false,
|
|
20
|
+
aliases: ["-m"],
|
|
21
|
+
desc: "Render in the terminal through mdfried instead of the system viewer"
|
|
18
22
|
|
|
19
23
|
example [
|
|
20
24
|
"leah # leah-researcher, in the system Markdown viewer",
|
data/lib/agentilda/cli/base.rb
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
module CLI
|
|
5
7
|
# Shared flags and the plumbing every command needs.
|
|
6
8
|
class Base < Dry::CLI::Command
|
|
7
9
|
include UI
|
|
8
10
|
|
|
11
|
+
# Values of AGENTILDA_AUTOCOMMIT that turn it on.
|
|
12
|
+
AUTOCOMMIT = %w[true yes 1].freeze
|
|
13
|
+
|
|
9
14
|
def self.inherited(klass)
|
|
10
15
|
super
|
|
11
|
-
klass.option :dir,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
desc:
|
|
16
|
+
klass.option :dir,
|
|
17
|
+
default: Agentilda::PLANS_DIR,
|
|
18
|
+
aliases: ["-D"],
|
|
19
|
+
desc: "The .plans directory"
|
|
20
|
+
klass.option :quiet,
|
|
21
|
+
type: :boolean,
|
|
22
|
+
default: false,
|
|
23
|
+
aliases: ["-q"],
|
|
24
|
+
desc: "Suppress progress output on STDERR"
|
|
15
25
|
end
|
|
16
26
|
|
|
17
27
|
private
|
|
@@ -22,7 +32,8 @@ module Agentilda
|
|
|
22
32
|
tree = Tree.new(dir: options.fetch(:dir, Agentilda::PLANS_DIR))
|
|
23
33
|
unless tree.exist?
|
|
24
34
|
refuse("No #{Agentilda::PLANS_DIR} directory at\n#{tree.dir}\n\n" \
|
|
25
|
-
"Run this from the project root, or pass -D.",
|
|
35
|
+
"Run this from the project root, or pass -D.",
|
|
36
|
+
66)
|
|
26
37
|
end
|
|
27
38
|
tree
|
|
28
39
|
end
|
|
@@ -47,9 +58,14 @@ module Agentilda
|
|
|
47
58
|
quiet
|
|
48
59
|
end
|
|
49
60
|
|
|
61
|
+
# `--commit`, or AGENTILDA_AUTOCOMMIT=true, which is `--commit` on every
|
|
62
|
+
# command for someone who never wants the dry run.
|
|
63
|
+
#
|
|
50
64
|
# @param options [Hash]
|
|
51
65
|
# @return [Boolean]
|
|
52
|
-
def commit?(options)
|
|
66
|
+
def commit?(options)
|
|
67
|
+
options.fetch(:commit, false) || AUTOCOMMIT.include?(ENV.fetch("AGENTILDA_AUTOCOMMIT", "").strip.downcase)
|
|
68
|
+
end
|
|
53
69
|
|
|
54
70
|
# `claude` prefers a credential in the environment to a claude.ai login,
|
|
55
71
|
# so a project `.env` that direnv loads on `cd` can redirect every agent
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
module CLI
|
|
5
7
|
# `agentilda create <words…>` — backs /spec-create.
|
|
@@ -23,7 +25,7 @@ module Agentilda
|
|
|
23
25
|
"Create a retroactive plan in the gap after this plan, e.g. 002"
|
|
24
26
|
option :status,
|
|
25
27
|
aliases: ["-s"],
|
|
26
|
-
desc:
|
|
28
|
+
desc: "Open in a state other than the default"
|
|
27
29
|
option :prs,
|
|
28
30
|
aliases: ["--pr"],
|
|
29
31
|
desc:
|
|
@@ -46,7 +48,7 @@ module Agentilda
|
|
|
46
48
|
|
|
47
49
|
# noinspection RubyMismatchedArgumentType
|
|
48
50
|
example [
|
|
49
|
-
"tax rule dsl # 003.00
|
|
51
|
+
"tax rule dsl # 003.00-⚪️ → tax-rule-dsl, spec.md scaffolded and drafted",
|
|
50
52
|
"tax rule dsl --no-draft --no-open # scaffold only, nothing shelled out, nothing opened",
|
|
51
53
|
"--after 002 schedule k1 # 002.01-🕰️-schedule-k1 (documented after the fact)",
|
|
52
54
|
"--after 018 --prs 12,15 verify # …and write spec.md from what those PRs did",
|
|
@@ -97,14 +99,14 @@ module Agentilda
|
|
|
97
99
|
unless words.empty?
|
|
98
100
|
refuse(
|
|
99
101
|
"--from names the topic by its frontmatter title;\n" \
|
|
100
|
-
|
|
102
|
+
"drop the words, or drop --from",
|
|
101
103
|
64
|
|
102
104
|
)
|
|
103
105
|
end
|
|
104
106
|
if options[:prs]
|
|
105
107
|
refuse(
|
|
106
108
|
"--prs reconstructs spec.md from the pull requests, so a seed file\n" \
|
|
107
|
-
|
|
109
|
+
"would be ignored; use one or the other",
|
|
108
110
|
64
|
|
109
111
|
)
|
|
110
112
|
end
|
|
@@ -116,7 +118,7 @@ module Agentilda
|
|
|
116
118
|
if title.to_s.strip.empty?
|
|
117
119
|
refuse(
|
|
118
120
|
"The seed file needs a frontmatter title, e.g.\n" \
|
|
119
|
-
|
|
121
|
+
"---\ntitle: Tax Rule DSL\n---",
|
|
120
122
|
65
|
|
121
123
|
)
|
|
122
124
|
end
|
|
@@ -152,7 +154,7 @@ module Agentilda
|
|
|
152
154
|
unless options[:after]
|
|
153
155
|
refuse(
|
|
154
156
|
"--prs documents work that already shipped;\n" \
|
|
155
|
-
|
|
157
|
+
"name the plan it landed after with --after, e.g. --after 018",
|
|
156
158
|
64
|
|
157
159
|
)
|
|
158
160
|
end
|
|
@@ -184,8 +186,8 @@ module Agentilda
|
|
|
184
186
|
feature = Feature.parse(path)
|
|
185
187
|
success(
|
|
186
188
|
"Created #{File.basename(path)}\n\n" \
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
"#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
|
|
190
|
+
"#{next_step(path, feature, from_prs:)}"
|
|
189
191
|
)
|
|
190
192
|
end
|
|
191
193
|
|
|
@@ -201,10 +203,14 @@ module Agentilda
|
|
|
201
203
|
return path
|
|
202
204
|
root = options[:root] || File.dirname(path, 2)
|
|
203
205
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
feature = Feature.parse(path)
|
|
207
|
+
ok, note = on_agent_row("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}",
|
|
208
|
+
agent: agent.name,
|
|
209
|
+
plan: feature.ordinal.to_s,
|
|
210
|
+
root:) do |line|
|
|
211
|
+
handle = line.handle if line.is_a?(Dashboard::Tracker)
|
|
212
|
+
Executor.new(root:).call(agent, Subject.new(feature), handle:, &line)
|
|
213
|
+
end
|
|
208
214
|
warn_about(note) unless ok
|
|
209
215
|
|
|
210
216
|
settle(path)
|
|
@@ -234,7 +240,7 @@ module Agentilda
|
|
|
234
240
|
# half-agent reads as one of them on the terminal.
|
|
235
241
|
label =
|
|
236
242
|
"#{UI.paint(Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
|
|
237
|
-
ok, note =
|
|
243
|
+
ok, note = on_agent_row(label, agent: Brief::AGENT_NAME, plan: feature.ordinal.to_s, root:) { brief.attempt! }
|
|
238
244
|
warn_about_draft(note) unless ok
|
|
239
245
|
end
|
|
240
246
|
|
|
@@ -242,6 +248,27 @@ module Agentilda
|
|
|
242
248
|
path
|
|
243
249
|
end
|
|
244
250
|
|
|
251
|
+
# One agent writing spec.md, drawn as a row on the ratatui dashboard on
|
|
252
|
+
# a terminal and as a spinner line anywhere else.
|
|
253
|
+
#
|
|
254
|
+
# @param label [String] the spinner line's text
|
|
255
|
+
# @param agent [String]
|
|
256
|
+
# @param plan [String]
|
|
257
|
+
# @param root [String]
|
|
258
|
+
# @yieldparam line [Agentilda::UI::Line]
|
|
259
|
+
# @return [Object] the block's value
|
|
260
|
+
def on_agent_row(label, agent:, plan:, root:, &block)
|
|
261
|
+
Control.on_interrupt do
|
|
262
|
+
UI.concurrently([plan],
|
|
263
|
+
label,
|
|
264
|
+
jobs: 1,
|
|
265
|
+
label: ->(_) { label },
|
|
266
|
+
fields: ->(_) { { plan:, agent: } },
|
|
267
|
+
file: ->(_) { "spec.md" },
|
|
268
|
+
root:) { |_, line| block.call(line) }.first
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
245
272
|
# @param spec_path [String]
|
|
246
273
|
# @return [void]
|
|
247
274
|
def open_spec(spec_path)
|
|
@@ -263,9 +290,9 @@ module Agentilda
|
|
|
263
290
|
tree = Tree.new(dir: File.dirname(path))
|
|
264
291
|
change =
|
|
265
292
|
Agentilda::Resync::Dirs
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
293
|
+
.new(tree:)
|
|
294
|
+
.call(commit: true)
|
|
295
|
+
.find { |c| c.source == path }
|
|
269
296
|
change ? change.target : path
|
|
270
297
|
end
|
|
271
298
|
|
|
@@ -274,7 +301,7 @@ module Agentilda
|
|
|
274
301
|
def warn_about(note)
|
|
275
302
|
error(
|
|
276
303
|
"The folder was created, but spec.md was not written:\n#{note}\n\n" \
|
|
277
|
-
|
|
304
|
+
"The pull requests are recorded. Run `agentilda run --commit` to retry."
|
|
278
305
|
)
|
|
279
306
|
end
|
|
280
307
|
|
|
@@ -283,7 +310,7 @@ module Agentilda
|
|
|
283
310
|
def warn_about_draft(note)
|
|
284
311
|
error(
|
|
285
312
|
"spec.md was scaffolded, but the drafting attempt did not finish:\n#{note}\n\n" \
|
|
286
|
-
|
|
313
|
+
"The four headings are there, empty. Fill them in by hand, or hand off to leah-researcher."
|
|
287
314
|
)
|
|
288
315
|
end
|
|
289
316
|
|
|
@@ -293,7 +320,7 @@ module Agentilda
|
|
|
293
320
|
def next_step(path, feature, from_prs:)
|
|
294
321
|
spec = File.join(File.basename(path), "spec.md")
|
|
295
322
|
unless feature.status.key == :new &&
|
|
296
|
-
|
|
323
|
+
File.file?(File.join(path, "spec.md"))
|
|
297
324
|
return "Next: write #{spec}"
|
|
298
325
|
end
|
|
299
326
|
if from_prs
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
module CLI
|
|
5
7
|
# `agentilda docs` — regenerate the conventions document.
|
|
6
8
|
class Docs < Base
|
|
7
9
|
desc "Generate the conventions document from the state machine itself"
|
|
8
10
|
|
|
9
|
-
option :output,
|
|
10
|
-
|
|
11
|
+
option :output,
|
|
12
|
+
aliases: ["-o"],
|
|
13
|
+
desc: "Write here instead of STDOUT",
|
|
14
|
+
default: "#{ENV.fetch("HOME", nil)}/.agents/context/workflow.md"
|
|
11
15
|
|
|
12
16
|
example ["", "-o ~/.agents/context/workflow.md"]
|
|
13
17
|
|
|
@@ -6,10 +6,12 @@ module Agentilda
|
|
|
6
6
|
class Index < Base
|
|
7
7
|
desc "Create or update .plans/INDEX.md — a global index of every plan."
|
|
8
8
|
|
|
9
|
-
option :output,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
option :output,
|
|
10
|
+
aliases: ["-o"],
|
|
11
|
+
desc: "Write somewhere other than <plans>/INDEX.md; - for STDOUT"
|
|
12
|
+
option :project,
|
|
13
|
+
aliases: ["-p"],
|
|
14
|
+
desc: "Heading for the page, default: the repository's directory name"
|
|
13
15
|
|
|
14
16
|
example [
|
|
15
17
|
" # write .plans/INDEX.md",
|
|
@@ -11,8 +11,9 @@ module Agentilda
|
|
|
11
11
|
class Team < Base
|
|
12
12
|
def self.inherited(klass)
|
|
13
13
|
super
|
|
14
|
-
klass.argument :team,
|
|
15
|
-
|
|
14
|
+
klass.argument :team,
|
|
15
|
+
required: true,
|
|
16
|
+
desc: "The Linear team key that prefixes its issues, e.g. TAX"
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
private
|