agentilda 2.0.1 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b707f3c9d7c48b2983152af1c7561480f1191a38b9d4998c28e8756dbf08c149
4
- data.tar.gz: 40f6f8f9764077d3f0120a76e9d6963465f763d6c8092a178d49973e6597198b
3
+ metadata.gz: 2c9a4385f83c40b57a76438a289cf74e55f7b9ca9814fea4c3da588ff3ae44a2
4
+ data.tar.gz: 6623390360484fd07adf1424c59ab67a6552da4d19ceb7d130bb6d7957ef6197
5
5
  SHA512:
6
- metadata.gz: 866812a595b01d7e57303414a8b91dc330498b87d117218119561d29420401ea252d6734e56dc34b0cdf07e85c9f81ea95e1ff2b295e0e332defc091f0a05ddc
7
- data.tar.gz: c716f989842f5400829f9ab1be7dba8817cd09c2b07b27eb2cacb7680c8c366bfef7d20e7c9053dd69909181ac5ba730fd916a82f96e13ae90ac828b7fcb42c3
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
@@ -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
- # 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
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
- # @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
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) = 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
14
+ # @return [Agentilda::Agents::Agent, nil]
15
+ def find(name, dir: Registry::DEFAULT_DIR) = registry(dir:).find(name)
117
16
 
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
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.new, viewer: Viewer.new)
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
@@ -13,7 +13,7 @@ module Agentilda
13
13
  example ["", "| less -R"]
14
14
 
15
15
  # @return [void]
16
- def call(**) = $stdout.write(Roster.new.list)
16
+ def call(**) = $stdout.write(Agentilda::Agents.roster.list)
17
17
  end
18
18
  end
19
19
  end
@@ -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 = Tree.new(dir: options.fetch(:dir, Agentilda::PLANS_DIR))
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
- Creator.new(dir:).create(
70
+ Lifecycle.create(dir:,
71
71
  words:,
72
- after: options[: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
- GitHub.new.pull_requests(GitHub.parse_refs(options[:prs]))
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.new.find(Agentilda::RETROACTIVE_WRITER) or
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
- Executor.new(root:).call(agent, Subject.new(feature), handle:, &line)
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 = Brief.new(path:, title: feature.title, root:, seed:)
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
- Control.on_interrupt do
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 = Tree.new(dir: File.dirname(path))
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")
@@ -18,7 +18,7 @@ module Agentilda
18
18
  # @param options [Hash]
19
19
  # @return [void]
20
20
  def call(**options)
21
- document = Documentation.new.render
21
+ document = Presentation.documentation
22
22
 
23
23
  if (path = options[:output])
24
24
  FileUtils.mkdir_p(File.dirname(path))
@@ -22,7 +22,7 @@ module Agentilda
22
22
  # @param options [Hash]
23
23
  # @return [void]
24
24
  def call(**options)
25
- index = Agentilda::Index.new(tree: tree_for(options), project: options[:project])
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 = Reporter.new(tree: tree_for(options))
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
- Mailbox.new(dir: subject.feature.path)
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