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.
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c9a4385f83c40b57a76438a289cf74e55f7b9ca9814fea4c3da588ff3ae44a2
|
|
4
|
+
data.tar.gz: 6623390360484fd07adf1424c59ab67a6552da4d19ceb7d130bb6d7957ef6197
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2789d3e66bc6717cd7fdeff4f8ab2aaa3d85aadacc31ef01830dda9a108116512bfd5c498ffc17a69f0fd3aaa1ce7fb0a9ceae8e1c7cd011efdd98a2d4379f76
|
|
7
|
+
data.tar.gz: b28ee786d2f7696ade7d640080eaaef60132c2ddc6fe53bc5eabc5dc3c687098e63cc08ae1c30d7fd83b25e4b169c1d176351d188d483d4304cf6215931d7776
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Agents
|
|
5
|
+
# One specialist, loaded from `~/.agents/agents/<name>.md`.
|
|
6
|
+
#
|
|
7
|
+
# The definition files are the single source of truth for who does what: the
|
|
8
|
+
# frontmatter says which plan states this agent handles and what it may write,
|
|
9
|
+
# and the body is the prompt. Nothing about a specialty is duplicated in Ruby.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] name
|
|
12
|
+
# @return [String]
|
|
13
|
+
# @!attribute [r] description
|
|
14
|
+
# @return [String]
|
|
15
|
+
# @!attribute [r] handles
|
|
16
|
+
# @return [Array<Symbol>] plan states this agent is offered work from
|
|
17
|
+
# @!attribute [r] advances_to
|
|
18
|
+
# @return [Symbol, nil] the state it is expected to reach; nil = read-only
|
|
19
|
+
# @!attribute [r] model
|
|
20
|
+
# @return [String, nil]
|
|
21
|
+
# @!attribute [r] allowed_tools
|
|
22
|
+
# @return [Array<String>]
|
|
23
|
+
# @!attribute [r] may
|
|
24
|
+
# @return [Array<String>] commands lifted from {Executor::FORBIDDEN_COMMANDS}
|
|
25
|
+
# for this agent alone. Nothing in {Executor::UNGRANTABLE} can be lifted.
|
|
26
|
+
# @!attribute [r] timeout
|
|
27
|
+
# @return [Integer, nil] seconds before this agent is abandoned; nil
|
|
28
|
+
# defers to the executor's run-wide default
|
|
29
|
+
# @!attribute [r] ledger
|
|
30
|
+
# @return [Array<String>] the documents this agent signs, in the order
|
|
31
|
+
# it reaches them; the last entry it wrote is where it stands
|
|
32
|
+
# @!attribute [r] rounds
|
|
33
|
+
# @return [Integer] how many times it may be invoked on one plan in one
|
|
34
|
+
# state before the plan parks; a Completed never earns another
|
|
35
|
+
# @!attribute [r] effort
|
|
36
|
+
# @return [String, nil] passed to `claude --effort`
|
|
37
|
+
# @!attribute [r] starts_as
|
|
38
|
+
# @return [Symbol, nil] the state the harness renames the plan into the
|
|
39
|
+
# moment this agent is dispatched, where the topology permits it
|
|
40
|
+
# @!attribute [r] holds_at
|
|
41
|
+
# @return [Symbol, nil] the state the plan takes when this agent completes
|
|
42
|
+
# while its partner on the same plan is still running
|
|
43
|
+
Agent = Data.define(:name,
|
|
44
|
+
:description,
|
|
45
|
+
:handles,
|
|
46
|
+
:advances_to,
|
|
47
|
+
:model,
|
|
48
|
+
:allowed_tools,
|
|
49
|
+
:may,
|
|
50
|
+
:network,
|
|
51
|
+
:timeout,
|
|
52
|
+
:prompt,
|
|
53
|
+
:path,
|
|
54
|
+
:ledger,
|
|
55
|
+
:rounds,
|
|
56
|
+
:effort,
|
|
57
|
+
:starts_as,
|
|
58
|
+
:holds_at) do
|
|
59
|
+
def initialize(ledger: [], rounds: 1, effort: nil, starts_as: nil, holds_at: nil, **rest) = super
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] whether this agent changes anything on disk
|
|
62
|
+
def read_only? = advances_to.nil?
|
|
63
|
+
|
|
64
|
+
# @param status [Agentilda::Plans::Status]
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def handles?(status) = handles.include?(status.key)
|
|
67
|
+
|
|
68
|
+
# The word after the hyphen: `researcher`, `backend`. What the screen's
|
|
69
|
+
# agent column shows, the name being too long for it.
|
|
70
|
+
#
|
|
71
|
+
# @return [String]
|
|
72
|
+
def role = name.split("-", 2).last.to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# More rounds than this costs tokens and buys nothing: an agent that has
|
|
76
|
+
# not finished in five tries is not going to on the sixth.
|
|
77
|
+
Agent::MAX_ROUNDS = 5
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Agents
|
|
5
|
+
# Loads and indexes the agent definitions.
|
|
6
|
+
class Registry
|
|
7
|
+
# Where definitions live, unless told otherwise.
|
|
8
|
+
DEFAULT_DIR = File.expand_path("../../../agents", __dir__)
|
|
9
|
+
|
|
10
|
+
# @param dir [String]
|
|
11
|
+
# @param roster [Array<Agentilda::Agents::Agent>, nil] a pre-selected list, used
|
|
12
|
+
# by {#only} and {#without} to derive a narrower roster; nil (the
|
|
13
|
+
# default) loads every definition in `dir`
|
|
14
|
+
def initialize(dir: DEFAULT_DIR, roster: nil)
|
|
15
|
+
@dir = File.expand_path(dir)
|
|
16
|
+
@all = roster
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [String]
|
|
20
|
+
attr_reader :dir
|
|
21
|
+
|
|
22
|
+
# @return [Array<Agentilda::Agents::Agent>] in name order
|
|
23
|
+
def all
|
|
24
|
+
@all ||= Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse(path) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param name [String]
|
|
28
|
+
# @return [Agentilda::Agents::Agent, nil]
|
|
29
|
+
def find(name) = all.find { |a| a.name == name.to_s }
|
|
30
|
+
|
|
31
|
+
# A roster holding only the agents named — what `run --agent` hands the
|
|
32
|
+
# loop, so a restriction typed at the command line restricts assignments
|
|
33
|
+
# and not merely chaining.
|
|
34
|
+
#
|
|
35
|
+
# @param names [Array<String>]
|
|
36
|
+
# @return [Agentilda::Agents::Registry]
|
|
37
|
+
def only(*names)
|
|
38
|
+
wanted = names.flatten.map(&:to_s)
|
|
39
|
+
self.class.new(dir:, roster: all.select { |a| wanted.include?(a.name) })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A roster without the agents named — what `run --skip` hands the loop.
|
|
43
|
+
# A plan sitting in a skipped agent's state is simply never assigned, the
|
|
44
|
+
# same way a state no agent handles is stepped around.
|
|
45
|
+
#
|
|
46
|
+
# @param names [Array<String>]
|
|
47
|
+
# @return [Agentilda::Agents::Registry]
|
|
48
|
+
def without(*names)
|
|
49
|
+
unwanted = names.flatten.map(&:to_s)
|
|
50
|
+
self.class.new(dir:, roster: all.reject { |a| unwanted.include?(a.name) })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Every agent the query could mean. An exact name wins outright; failing
|
|
54
|
+
# that the query matches as a prefix, and failing that anywhere in the
|
|
55
|
+
# name, so `leah` finds leah-researcher and `review` finds
|
|
56
|
+
# hansolo-reviewer. A directory or a trailing `.md` is stripped first,
|
|
57
|
+
# because tab completion hands those in.
|
|
58
|
+
#
|
|
59
|
+
# @param query [String]
|
|
60
|
+
# @return [Array<Agentilda::Agents::Agent>]
|
|
61
|
+
def match(query)
|
|
62
|
+
wanted = File.basename(query.to_s, ".md")
|
|
63
|
+
exact = all.select { |a| a.name == wanted }
|
|
64
|
+
return exact unless exact.empty?
|
|
65
|
+
|
|
66
|
+
prefixed = all.select { |a| a.name.start_with?(wanted) }
|
|
67
|
+
return prefixed unless prefixed.empty?
|
|
68
|
+
|
|
69
|
+
all.select { |a| a.name.include?(wanted) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Every agent that will act on a plan in this state, in definition order.
|
|
73
|
+
# A read-only agent is never offered work by the loop — it has nothing to
|
|
74
|
+
# advance, so including it would make every round look productive.
|
|
75
|
+
#
|
|
76
|
+
# @param status [Agentilda::Plans::Status]
|
|
77
|
+
# @return [Array<Agentilda::Agents::Agent>]
|
|
78
|
+
def for_status(status) = all.select { |a| a.handles?(status) && !a.read_only? }
|
|
79
|
+
|
|
80
|
+
# Everyone on a plan in this state: the agents that work it, plus any
|
|
81
|
+
# agent whose `holds_at` parks the plan here after it finished its half.
|
|
82
|
+
# Rey resuming a plan at 🎨 still needs luke's messages in the mailbox.
|
|
83
|
+
#
|
|
84
|
+
# @param status [Agentilda::Plans::Status]
|
|
85
|
+
# @return [Array<Agentilda::Agents::Agent>]
|
|
86
|
+
def team_for(status) = all.select { |a| !a.read_only? && (a.handles?(status) || a.holds_at == status.key) }
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# @param path [String]
|
|
91
|
+
# @return [Agentilda::Agents::Agent, nil]
|
|
92
|
+
def parse(path)
|
|
93
|
+
meta, body = Frontmatter.split(File.read(path, encoding: "UTF-8"))
|
|
94
|
+
return nil if meta["name"].to_s.empty?
|
|
95
|
+
|
|
96
|
+
Agent.new(
|
|
97
|
+
name: meta["name"].to_s,
|
|
98
|
+
description: meta["description"].to_s,
|
|
99
|
+
handles: Array(meta["handles"]).map { |s| s.to_s.to_sym },
|
|
100
|
+
advances_to: meta["advances_to"]&.to_s&.then { |s| s.empty? ? nil : s.to_sym },
|
|
101
|
+
model: meta["model"],
|
|
102
|
+
allowed_tools: Array(meta["allowed_tools"]).map(&:to_s),
|
|
103
|
+
may: Array(meta["may"]).map { |c| c.to_s.strip.squeeze(" ") },
|
|
104
|
+
network: meta["network"] == true,
|
|
105
|
+
timeout: meta["timeout"].to_i.then { |s| s.positive? ? s : nil },
|
|
106
|
+
prompt: body.strip,
|
|
107
|
+
path: path,
|
|
108
|
+
ledger: Array(meta["ledger"]).map(&:to_s),
|
|
109
|
+
rounds: meta["rounds"].to_i.clamp(1, Agent::MAX_ROUNDS),
|
|
110
|
+
effort: meta["effort"]&.to_s,
|
|
111
|
+
starts_as: symbol_or_nil(meta["starts_as"]),
|
|
112
|
+
holds_at: symbol_or_nil(meta["holds_at"])
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param value [Object, nil]
|
|
117
|
+
# @return [Symbol, nil]
|
|
118
|
+
def symbol_or_nil(value) = value.to_s.empty? ? nil : value.to_s.to_sym
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Agents
|
|
5
|
+
# `agentilda agents` — who the specialists are and what each is offered
|
|
6
|
+
# work from. Reading one in full is {Viewer}'s job: the definition file is
|
|
7
|
+
# already the whole truth, so `agents describe` opens it rather than
|
|
8
|
+
# restating it.
|
|
9
|
+
#
|
|
10
|
+
# {#list} returns a string and prints nothing, the same bargain {Reporter}
|
|
11
|
+
# makes, so the caller decides where the text goes and the table stays
|
|
12
|
+
# pipeable.
|
|
13
|
+
#
|
|
14
|
+
# Everything here is read from the definition files. Nothing about a
|
|
15
|
+
# specialty is restated in Ruby, because a second copy of "who does what" is
|
|
16
|
+
# a copy that drifts from the frontmatter the loop actually routes on.
|
|
17
|
+
class Roster
|
|
18
|
+
# Columns, so the header and the rows cannot drift apart.
|
|
19
|
+
HEADINGS = ["Agent", "Handles", "Advances to", "Model"].freeze
|
|
20
|
+
|
|
21
|
+
# What an agent with no `advances_to` is, in the one word that explains why
|
|
22
|
+
# `run` never offers it work.
|
|
23
|
+
READ_ONLY = "read-only"
|
|
24
|
+
|
|
25
|
+
# What an agent that handles only {StateMachine::SETTLED} states advances
|
|
26
|
+
# to. `run` never starts it; a command does, and `resync` then names the
|
|
27
|
+
# folder by what its files justify.
|
|
28
|
+
BY_CONTENTS = "by contents"
|
|
29
|
+
|
|
30
|
+
# @param agents [Agentilda::Agents::Registry]
|
|
31
|
+
def initialize(agents: Registry.new)
|
|
32
|
+
@agents = agents
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Agentilda::Agents::Registry]
|
|
36
|
+
attr_reader :agents
|
|
37
|
+
|
|
38
|
+
# One row per agent, in name order, framed by {UI.table}.
|
|
39
|
+
#
|
|
40
|
+
# @return [String] newline-terminated
|
|
41
|
+
def list
|
|
42
|
+
all = agents.all
|
|
43
|
+
return "No agent definitions in #{agents.dir}\n" if all.empty?
|
|
44
|
+
|
|
45
|
+
UI.table(all.map { |agent| row(agent) }, header: HEADINGS)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# A state as its words alone, deliberately without the emoji that every
|
|
51
|
+
# other rendering of a state carries. The table widget pads a cell to a
|
|
52
|
+
# width it measures in characters, while the terminal draws a status emoji
|
|
53
|
+
# in two cells; the variation-selector ones (⭕️ 🅱️ ⚪️ ⭐️ 🕰️) are two
|
|
54
|
+
# characters drawn in two cells, so no single count is right for all of
|
|
55
|
+
# them. A row carrying one lands short or long and the frame stops lining
|
|
56
|
+
# up. The label says the same thing unambiguously, and is worth more than
|
|
57
|
+
# the emoji is. {Reporter} lays its own columns out with {UI.fit} and so
|
|
58
|
+
# keeps the emoji.
|
|
59
|
+
#
|
|
60
|
+
# @param key [Symbol]
|
|
61
|
+
# @return [String]
|
|
62
|
+
def state(key)
|
|
63
|
+
status = Plans::STATUS_BY_KEY[key] or return key.to_s
|
|
64
|
+
status.label
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param agent [Agentilda::Agents::Agent]
|
|
68
|
+
# @return [String]
|
|
69
|
+
def handles(agent) = agent.handles.map { |key| state(key) }.join(", ")
|
|
70
|
+
|
|
71
|
+
# @param agent [Agentilda::Agents::Agent]
|
|
72
|
+
# @return [String]
|
|
73
|
+
def advances(agent)
|
|
74
|
+
return state(agent.advances_to) if agent.advances_to
|
|
75
|
+
return BY_CONTENTS if agent.handles.any? && agent.handles.all? { |key| Plans::StateMachine::SETTLED.include?(key) }
|
|
76
|
+
|
|
77
|
+
READ_ONLY
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The widget measures and pads the cells, so they arrive painted only.
|
|
81
|
+
#
|
|
82
|
+
# @param agent [Agentilda::Agents::Agent]
|
|
83
|
+
# @return [Array<String>] one cell per entry in {HEADINGS}
|
|
84
|
+
def row(agent)
|
|
85
|
+
[UI.paint(agent.name, :bright_cyan),
|
|
86
|
+
handles(agent),
|
|
87
|
+
UI.paint(advances(agent), agent.read_only? ? :bright_black : :green),
|
|
88
|
+
UI.paint(agent.model.to_s, :bright_black)]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/agentilda/agents.rb
CHANGED
|
@@ -1,122 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "agentilda/agent"
|
|
4
|
-
require "agentilda/frontmatter"
|
|
5
|
-
|
|
6
3
|
module Agentilda
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
4
|
+
# The specialists: their definitions in `agents/*.md`, the registry that
|
|
5
|
+
# loads and filters them, and the roster that reports on them.
|
|
6
|
+
module Agents
|
|
7
|
+
module_function
|
|
20
8
|
|
|
21
|
-
# @
|
|
22
|
-
|
|
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
|
|
9
|
+
# @param dir [String] where the definitions live
|
|
10
|
+
# @return [Agentilda::Agents::Registry]
|
|
11
|
+
def registry(dir: Registry::DEFAULT_DIR) = Registry.new(dir:)
|
|
28
12
|
|
|
29
13
|
# @param name [String]
|
|
30
|
-
# @return [Agentilda::Agent, nil]
|
|
31
|
-
def find(name) =
|
|
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
|
|
14
|
+
# @return [Agentilda::Agents::Agent, nil]
|
|
15
|
+
def find(name, dir: Registry::DEFAULT_DIR) = registry(dir:).find(name)
|
|
117
16
|
|
|
118
|
-
# @
|
|
119
|
-
|
|
120
|
-
def symbol_or_nil(value) = value.to_s.empty? ? nil : value.to_s.to_sym
|
|
17
|
+
# @return [Agentilda::Agents::Roster]
|
|
18
|
+
def roster(**) = Roster.new(**)
|
|
121
19
|
end
|
|
122
20
|
end
|
|
@@ -30,9 +30,9 @@ module Agentilda
|
|
|
30
30
|
# default to the real thing; the suite hands in its own to keep
|
|
31
31
|
# viewers from opening on the box running it.
|
|
32
32
|
#
|
|
33
|
-
# @param agents [Agentilda::Agents]
|
|
34
|
-
# @param viewer [Agentilda::Viewer]
|
|
35
|
-
def initialize(agents: Agentilda::Agents.
|
|
33
|
+
# @param agents [Agentilda::Agents::Registry]
|
|
34
|
+
# @param viewer [Agentilda::Presentation::Viewer]
|
|
35
|
+
def initialize(agents: Agentilda::Agents.registry, viewer: Presentation.viewer)
|
|
36
36
|
super()
|
|
37
37
|
@agents = agents
|
|
38
38
|
@viewer = viewer
|
data/lib/agentilda/cli/base.rb
CHANGED
|
@@ -51,9 +51,9 @@ module Agentilda
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
53
|
# @param options [Hash]
|
|
54
|
-
# @return [Agentilda::Tree]
|
|
54
|
+
# @return [Agentilda::Plans::Tree]
|
|
55
55
|
def tree_for(options)
|
|
56
|
-
tree =
|
|
56
|
+
tree = Plans.tree(options.fetch(:dir, Agentilda::PLANS_DIR))
|
|
57
57
|
unless tree.exist?
|
|
58
58
|
refuse("No #{Agentilda::PLANS_DIR} directory at\n#{tree.dir}\n\n" \
|
|
59
59
|
"Run this from the project root, or pass -D.",
|
|
@@ -101,7 +101,7 @@ module Agentilda
|
|
|
101
101
|
#
|
|
102
102
|
# @return [void]
|
|
103
103
|
def credentials_warning
|
|
104
|
-
names = Executor.foreign_credentials
|
|
104
|
+
names = Execution::Executor.foreign_credentials
|
|
105
105
|
return if names.empty?
|
|
106
106
|
|
|
107
107
|
one = names.size == 1
|
|
@@ -67,12 +67,11 @@ module Agentilda
|
|
|
67
67
|
words, seed = resolve_seed(words, options)
|
|
68
68
|
prs = fetch_prs(options)
|
|
69
69
|
result =
|
|
70
|
-
|
|
70
|
+
Lifecycle.create(dir:,
|
|
71
71
|
words:,
|
|
72
|
-
after:
|
|
72
|
+
after: options[:after],
|
|
73
73
|
status: options[:status],
|
|
74
|
-
prs:
|
|
75
|
-
)
|
|
74
|
+
prs:)
|
|
76
75
|
|
|
77
76
|
result.either(
|
|
78
77
|
->(path) { created(path, prs, options, seed:) },
|
|
@@ -159,7 +158,7 @@ module Agentilda
|
|
|
159
158
|
)
|
|
160
159
|
end
|
|
161
160
|
|
|
162
|
-
|
|
161
|
+
Vcs.github.pull_requests(Vcs::GitHub.parse_refs(options[:prs]))
|
|
163
162
|
rescue Agentilda::Error => e
|
|
164
163
|
refuse("Could not read the pull requests:\n#{e.message}", 65)
|
|
165
164
|
end
|
|
@@ -183,7 +182,7 @@ module Agentilda
|
|
|
183
182
|
puts path
|
|
184
183
|
return if quiet?(options)
|
|
185
184
|
|
|
186
|
-
feature = Feature.parse(path)
|
|
185
|
+
feature = Plans::Feature.parse(path)
|
|
187
186
|
success(
|
|
188
187
|
"Created #{File.basename(path)}\n\n" \
|
|
189
188
|
"#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
|
|
@@ -199,17 +198,17 @@ module Agentilda
|
|
|
199
198
|
# @param options [Hash]
|
|
200
199
|
# @return [String] the folder's path, which the resync may have renamed
|
|
201
200
|
def synthesize(path, options)
|
|
202
|
-
agent = Agentilda::Agents.
|
|
201
|
+
agent = Agentilda::Agents.registry.find(Agentilda::RETROACTIVE_WRITER) or
|
|
203
202
|
return path
|
|
204
203
|
root = options[:root] || File.dirname(path, 2)
|
|
205
204
|
|
|
206
|
-
feature = Feature.parse(path)
|
|
205
|
+
feature = Plans::Feature.parse(path)
|
|
207
206
|
ok, note = on_agent_row("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}",
|
|
208
207
|
agent: agent.name,
|
|
209
208
|
plan: feature.ordinal.to_s,
|
|
210
209
|
root:) do |line|
|
|
211
|
-
handle = line.handle if line.is_a?(Dashboard::Tracker)
|
|
212
|
-
|
|
210
|
+
handle = line.handle if line.is_a?(Presentation::Dashboard::Tracker)
|
|
211
|
+
Execution.executor(root:).call(agent, Plans.subject(feature), handle:, &line)
|
|
213
212
|
end
|
|
214
213
|
warn_about(note) unless ok
|
|
215
214
|
|
|
@@ -228,19 +227,19 @@ module Agentilda
|
|
|
228
227
|
# @param options [Hash] the options hash
|
|
229
228
|
# @return [String] +path+, unchanged
|
|
230
229
|
def brief(path, options, seed: nil)
|
|
231
|
-
feature = Feature.parse(path)
|
|
230
|
+
feature = Plans::Feature.parse(path)
|
|
232
231
|
return path if feature.status.key == :retroactive
|
|
233
232
|
|
|
234
233
|
root = options[:root] || File.dirname(path, 2)
|
|
235
|
-
brief =
|
|
234
|
+
brief = Lifecycle.brief(path:, title: feature.title, root:, seed:)
|
|
236
235
|
brief.write_scaffold!
|
|
237
236
|
|
|
238
237
|
if options.fetch(:draft, true)
|
|
239
238
|
# Painted the way Runner::Task#label paints a roster agent, so the
|
|
240
239
|
# half-agent reads as one of them on the terminal.
|
|
241
240
|
label =
|
|
242
|
-
"#{UI.paint(Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
|
|
243
|
-
ok, note = on_agent_row(label, agent: Brief::AGENT_NAME, plan: feature.ordinal.to_s, root:) { brief.attempt! }
|
|
241
|
+
"#{UI.paint(Lifecycle::Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
|
|
242
|
+
ok, note = on_agent_row(label, agent: Lifecycle::Brief::AGENT_NAME, plan: feature.ordinal.to_s, root:) { brief.attempt! }
|
|
244
243
|
warn_about_draft(note) unless ok
|
|
245
244
|
end
|
|
246
245
|
|
|
@@ -258,7 +257,7 @@ module Agentilda
|
|
|
258
257
|
# @yieldparam line [Agentilda::UI::Line]
|
|
259
258
|
# @return [Object] the block's value
|
|
260
259
|
def on_agent_row(label, agent:, plan:, root:, &block)
|
|
261
|
-
|
|
260
|
+
Execution.on_interrupt do
|
|
262
261
|
UI.concurrently([plan],
|
|
263
262
|
label,
|
|
264
263
|
jobs: 1,
|
|
@@ -287,9 +286,9 @@ module Agentilda
|
|
|
287
286
|
# @param path [String]
|
|
288
287
|
# @return [String] where the folder ended up
|
|
289
288
|
def settle(path)
|
|
290
|
-
tree =
|
|
289
|
+
tree = Plans.tree(File.dirname(path))
|
|
291
290
|
change =
|
|
292
|
-
Agentilda::Resync::Dirs
|
|
291
|
+
Agentilda::Lifecycle::Resync::Dirs
|
|
293
292
|
.new(tree:)
|
|
294
293
|
.call(commit: true)
|
|
295
294
|
.find { |c| c.source == path }
|
|
@@ -315,7 +314,7 @@ module Agentilda
|
|
|
315
314
|
end
|
|
316
315
|
|
|
317
316
|
# @param path [String]
|
|
318
|
-
# @param feature [Agentilda::Feature]
|
|
317
|
+
# @param feature [Agentilda::Plans::Feature]
|
|
319
318
|
# @return [String]
|
|
320
319
|
def next_step(path, feature, from_prs:)
|
|
321
320
|
spec = File.join(File.basename(path), "spec.md")
|
|
@@ -22,7 +22,7 @@ module Agentilda
|
|
|
22
22
|
# @param options [Hash]
|
|
23
23
|
# @return [void]
|
|
24
24
|
def call(**options)
|
|
25
|
-
index = Agentilda::
|
|
25
|
+
index = Agentilda::Plans.index(tree: tree_for(options), project: options[:project])
|
|
26
26
|
|
|
27
27
|
if options[:output] == "-"
|
|
28
28
|
$stdout.write(index.render)
|
|
@@ -106,7 +106,7 @@ module Agentilda
|
|
|
106
106
|
return nil unless options[:status]
|
|
107
107
|
|
|
108
108
|
options[:status].to_s.split(",").map { |word|
|
|
109
|
-
Agentilda.status(word.strip)&.key ||
|
|
109
|
+
Agentilda::Plans.status(word.strip)&.key ||
|
|
110
110
|
raise(Agentilda::Error, "no such state: #{word.strip}")
|
|
111
111
|
}
|
|
112
112
|
end
|
|
@@ -11,7 +11,7 @@ module Agentilda
|
|
|
11
11
|
# @param options [Hash]
|
|
12
12
|
# @return [void]
|
|
13
13
|
def call(**options)
|
|
14
|
-
reporter =
|
|
14
|
+
reporter = Presentation.reporter(tree: tree_for(options))
|
|
15
15
|
$stdout.write(reporter.render)
|
|
16
16
|
|
|
17
17
|
exit 1 unless reporter.inconsistent.empty?
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Agentilda
|
|
4
4
|
module CLI
|
|
5
5
|
# `agentilda mail` — the two halves of a pair talking through the plan
|
|
6
|
-
# folder. See {Agentilda::Mailbox} for why a file rather than a session.
|
|
6
|
+
# folder. See {Agentilda::Plans::Mailbox} for why a file rather than a session.
|
|
7
7
|
module Mail
|
|
8
8
|
# Both subcommands name a plan and need its folder.
|
|
9
9
|
module Locating
|
|
@@ -14,16 +14,16 @@ module Agentilda
|
|
|
14
14
|
# silence as "my partner has nothing to say".
|
|
15
15
|
#
|
|
16
16
|
# @param options [Hash]
|
|
17
|
-
# @return [Agentilda::Mailbox]
|
|
17
|
+
# @return [Agentilda::Plans::Mailbox]
|
|
18
18
|
def mailbox_for(options)
|
|
19
19
|
tree = tree_for(options)
|
|
20
|
-
ordinal = Ordinal.parse(options[:plan].to_s)
|
|
20
|
+
ordinal = Plans::Ordinal.parse(options[:plan].to_s)
|
|
21
21
|
subject = ordinal && tree.find(ordinal)
|
|
22
22
|
unless subject
|
|
23
23
|
refuse("No plan #{options[:plan]} in #{tree.dir}.\n\nKnown: #{tree.ordinals.join(", ")}", 66)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Plans.mailbox(subject.feature.path)
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -35,7 +35,7 @@ module Agentilda
|
|
|
35
35
|
|
|
36
36
|
# One line, not a box: an agent runs this between steps, and a
|
|
37
37
|
# four-line frame around "#7 → rey-frontend" is noise it reads.
|
|
38
|
-
UI.line("##{message.number} → #{message.to}, in #{File.basename(mailbox.dir)}/#{Mailbox::FILENAME}")
|
|
38
|
+
UI.line("##{message.number} → #{message.to}, in #{File.basename(mailbox.dir)}/#{Plans::Mailbox::FILENAME}")
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
end
|