agentilda 1.0.3
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 +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Gives a plan folder to every pull request that has no plan to point at.
|
|
5
|
+
#
|
|
6
|
+
# `resync prs` can resolve most pull requests from their branch or their
|
|
7
|
+
# diff. What it cannot resolve it used to flag for a human — "diff touches
|
|
8
|
+
# 021.00, 022.00, 024.00 and none is obviously primary" — and stop. That is
|
|
9
|
+
# honest but not useful: the work exists, it is unspecified, and leaving it
|
|
10
|
+
# unnumbered means it never appears in the index at all.
|
|
11
|
+
#
|
|
12
|
+
# So an orphan is adopted instead. It gets a retroactive plan of its own,
|
|
13
|
+
# numbered in the gap after the furthest plan it can see, holding the pull
|
|
14
|
+
# request that produced it.
|
|
15
|
+
#
|
|
16
|
+
# == Why the numbering is allocated serially
|
|
17
|
+
#
|
|
18
|
+
# The obvious parallel design — every worker reads the highest number and
|
|
19
|
+
# adds `.01` — is deterministic but not unique, and the collision is the
|
|
20
|
+
# common case rather than the rare one. Fifteen open pull requests branched
|
|
21
|
+
# off the same main all see the same highest number and all compute the same
|
|
22
|
+
# slot. Determinism is not the property that matters here; distinctness is.
|
|
23
|
+
#
|
|
24
|
+
# So the pipeline is **gather in parallel, allocate serially, apply in
|
|
25
|
+
# parallel**. The allocation is pure arithmetic over an array that is
|
|
26
|
+
# already in memory, so the serial section costs microseconds; the two
|
|
27
|
+
# expensive phases — reading each branch and writing each folder — keep the
|
|
28
|
+
# whole fan-out. And because the slots are handed out before any worker
|
|
29
|
+
# starts, no worker can want a number another worker holds: no locks, no
|
|
30
|
+
# retries, and the same input produces the same numbers every run.
|
|
31
|
+
class Adoption
|
|
32
|
+
# One pull request and the plan it is being given.
|
|
33
|
+
#
|
|
34
|
+
# @!attribute [r] pull
|
|
35
|
+
# @return [Hash] as returned by {GitHub#pulls}
|
|
36
|
+
# @!attribute [r] ordinal
|
|
37
|
+
# @return [Agentilda::Ordinal] the slot it was allocated
|
|
38
|
+
# @!attribute [r] major
|
|
39
|
+
# @return [Integer] the furthest plan its branch or diff could see
|
|
40
|
+
# @!attribute [r] path
|
|
41
|
+
# @return [String, nil] the folder, once created
|
|
42
|
+
Adoptee = Data.define(:pull, :ordinal, :major, :path) do
|
|
43
|
+
# @return [String] the folder name this pull request earns
|
|
44
|
+
def dirname = Agentilda.plan_dirname(ordinal, STATUS_BY_KEY.fetch(:retroactive), slug)
|
|
45
|
+
|
|
46
|
+
# The PR title, stripped of any prefix, as the folder's tail.
|
|
47
|
+
#
|
|
48
|
+
# @return [String]
|
|
49
|
+
def slug = Creator.slugify(pull[:title].to_s.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
|
|
50
|
+
|
|
51
|
+
# @return [String] a single auditable line
|
|
52
|
+
def to_s = "##{pull[:number]} → #{dirname}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param tree [Agentilda::Tree]
|
|
56
|
+
# @param github [Agentilda::GitHub]
|
|
57
|
+
# @param root [String] repository root, for reading branches
|
|
58
|
+
# @param jobs [Integer] workers for the two parallel phases
|
|
59
|
+
def initialize(tree:, github: GitHub.new, root: nil, jobs: UI.default_jobs)
|
|
60
|
+
@tree = tree
|
|
61
|
+
@github = github
|
|
62
|
+
@root = root || File.dirname(tree.dir)
|
|
63
|
+
@jobs = jobs
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Agentilda::Tree]
|
|
67
|
+
attr_reader :tree
|
|
68
|
+
|
|
69
|
+
# @return [Agentilda::GitHub]
|
|
70
|
+
attr_reader :github
|
|
71
|
+
|
|
72
|
+
# @return [String]
|
|
73
|
+
attr_reader :root
|
|
74
|
+
|
|
75
|
+
# @return [Integer]
|
|
76
|
+
attr_reader :jobs
|
|
77
|
+
|
|
78
|
+
# Work out what each orphan would be given, without creating anything.
|
|
79
|
+
#
|
|
80
|
+
# @param pulls [Array<Hash>] the orphans, from {Resync::Prs}
|
|
81
|
+
# @return [Array<Agentilda::Adoption::Adoptee>] in pull request order
|
|
82
|
+
def plan(pulls)
|
|
83
|
+
allocate(gather(pulls))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Adopt every orphan: create its folder and record its pull request.
|
|
87
|
+
#
|
|
88
|
+
# @param pulls [Array<Hash>]
|
|
89
|
+
# @return [Array<Agentilda::Adoption::Adoptee>] with `path` filled in
|
|
90
|
+
def call(pulls)
|
|
91
|
+
adoptees = plan(pulls)
|
|
92
|
+
return adoptees if adoptees.empty?
|
|
93
|
+
|
|
94
|
+
created = Parallel.map(adoptees, in_threads: jobs) { |adoptee| adopt(adoptee) }
|
|
95
|
+
tree.reload
|
|
96
|
+
created
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Phase one, in parallel: ask each pull request's branch how far the
|
|
102
|
+
# sequence had got when the work started. Branches are not all rebased
|
|
103
|
+
# onto the same main, so this is per-branch rather than tree-wide, and it
|
|
104
|
+
# is a network-free `ls-tree` per pull request.
|
|
105
|
+
#
|
|
106
|
+
# @param pulls [Array<Hash>]
|
|
107
|
+
# @return [Array<Array(Hash, Integer)>] each pull with its major
|
|
108
|
+
def gather(pulls)
|
|
109
|
+
ordered = pulls.sort_by { |pull| pull[:number].to_i }
|
|
110
|
+
|
|
111
|
+
Parallel.map(ordered, in_threads: jobs) { |pull| [pull, major_for(pull)] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The furthest whole plan this pull request can see: on its own branch
|
|
115
|
+
# first, then whatever it touched, then the tree we are standing in. A
|
|
116
|
+
# pull request whose branch is long gone still has a diff.
|
|
117
|
+
#
|
|
118
|
+
# @param pull [Hash]
|
|
119
|
+
# @return [Integer]
|
|
120
|
+
def major_for(pull)
|
|
121
|
+
candidates = Agentilda.plans_on_ref(root, pull[:branch].to_s)
|
|
122
|
+
candidates = touched(pull) if candidates.empty?
|
|
123
|
+
candidates = tree.subjects.map { |s| s.feature.ordinal } if candidates.empty?
|
|
124
|
+
|
|
125
|
+
candidates.map(&:major).max.to_i
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param pull [Hash]
|
|
129
|
+
# @return [Array<Agentilda::Ordinal>]
|
|
130
|
+
def touched(pull)
|
|
131
|
+
Array(pull[:files]).filter_map { |path|
|
|
132
|
+
parts = path.to_s.split("/")
|
|
133
|
+
index = parts.index(Agentilda::PLANS_DIR)
|
|
134
|
+
index && parts[index + 1] && Ordinal.from_dirname(parts[index + 1])
|
|
135
|
+
}.uniq
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Phase two, serial and deliberately so. Minors are handed out per major,
|
|
139
|
+
# continuing past every slot the tree already holds, in ascending pull
|
|
140
|
+
# request order — so the numbers follow the order the work was opened in,
|
|
141
|
+
# and two pull requests cannot be given the same one.
|
|
142
|
+
#
|
|
143
|
+
# @param gathered [Array<Array(Hash, Integer)>]
|
|
144
|
+
# @return [Array<Agentilda::Adoption::Adoptee>]
|
|
145
|
+
def allocate(gathered)
|
|
146
|
+
taken = Hash.new { |h, major| h[major] = minors_taken(major) }
|
|
147
|
+
|
|
148
|
+
gathered.filter_map do |pull, major|
|
|
149
|
+
minor = ((taken[major].max || 0) + 1)
|
|
150
|
+
next if minor > Ordinal::MAX_MINOR
|
|
151
|
+
|
|
152
|
+
taken[major] << minor
|
|
153
|
+
Adoptee.new(pull:, major:, ordinal: Ordinal.new(major:, minor:), path: nil)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param major [Integer]
|
|
158
|
+
# @return [Array<Integer>] minors already used under this major
|
|
159
|
+
def minors_taken(major)
|
|
160
|
+
tree.subjects.map { |s| s.feature.ordinal }.select { |o| o.major == major }.map(&:minor)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Phase three, in parallel: each worker owns a number nobody else can
|
|
164
|
+
# want, so it can create its folder without coordinating with anyone.
|
|
165
|
+
#
|
|
166
|
+
# @param adoptee [Agentilda::Adoption::Adoptee]
|
|
167
|
+
# @return [Agentilda::Adoption::Adoptee]
|
|
168
|
+
def adopt(adoptee)
|
|
169
|
+
path = File.join(tree.dir, adoptee.dirname)
|
|
170
|
+
return adoptee if File.exist?(path)
|
|
171
|
+
|
|
172
|
+
FileUtils.mkdir_p(path)
|
|
173
|
+
File.write(File.join(path, PullRequests::FILENAME),
|
|
174
|
+
PullRequests.render([pull_request_for(adoptee)]))
|
|
175
|
+
|
|
176
|
+
adoptee.with(path:)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The body is what a specification would be written from, so it is worth
|
|
180
|
+
# a second request. A failure here costs the description, not the folder.
|
|
181
|
+
#
|
|
182
|
+
# @param adoptee [Agentilda::Adoption::Adoptee]
|
|
183
|
+
# @return [Hash]
|
|
184
|
+
def pull_request_for(adoptee)
|
|
185
|
+
pull = adoptee.pull
|
|
186
|
+
github.pull_request(pull[:number].to_s)
|
|
187
|
+
rescue Agentilda::Error
|
|
188
|
+
{number: pull[:number], title: pull[:title], url: pull[:url],
|
|
189
|
+
state: pull[:state] || "Unknown", body: ""}
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# One specialist, loaded from `~/.agents/agents/<name>.md`.
|
|
5
|
+
#
|
|
6
|
+
# The definition files are the single source of truth for who does what: the
|
|
7
|
+
# frontmatter says which plan states this agent handles and what it may write,
|
|
8
|
+
# and the body is the prompt. Nothing about a specialty is duplicated in Ruby.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] name
|
|
11
|
+
# @return [String]
|
|
12
|
+
# @!attribute [r] description
|
|
13
|
+
# @return [String]
|
|
14
|
+
# @!attribute [r] handles
|
|
15
|
+
# @return [Array<Symbol>] plan states this agent is offered work from
|
|
16
|
+
# @!attribute [r] advances_to
|
|
17
|
+
# @return [Symbol, nil] the state it is expected to reach; nil = read-only
|
|
18
|
+
# @!attribute [r] model
|
|
19
|
+
# @return [String, nil]
|
|
20
|
+
# @!attribute [r] allowed_tools
|
|
21
|
+
# @return [Array<String>]
|
|
22
|
+
# @!attribute [r] may
|
|
23
|
+
# @return [Array<String>] commands lifted from {Executor::FORBIDDEN_COMMANDS}
|
|
24
|
+
# for this agent alone. Nothing in {Executor::UNGRANTABLE} can be lifted.
|
|
25
|
+
# @!attribute [r] timeout
|
|
26
|
+
# @return [Integer, nil] seconds before this agent is abandoned; nil
|
|
27
|
+
# defers to the executor's run-wide default
|
|
28
|
+
Agent = Data.define(:name, :description, :handles, :advances_to, :model,
|
|
29
|
+
:allowed_tools, :may, :network, :timeout, :prompt, :path) do
|
|
30
|
+
# @return [Boolean] whether this agent changes anything on disk
|
|
31
|
+
def read_only? = advances_to.nil?
|
|
32
|
+
|
|
33
|
+
# @param status [Agentilda::Status]
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def handles?(status) = handles.include?(status.key)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Loads and indexes the agent definitions.
|
|
39
|
+
class Agents
|
|
40
|
+
# Where definitions live, unless told otherwise.
|
|
41
|
+
DEFAULT_DIR = File.expand_path("../../agents", __dir__)
|
|
42
|
+
|
|
43
|
+
# @param dir [String]
|
|
44
|
+
# @param roster [Array<Agentilda::Agent>, nil] a pre-selected list, used
|
|
45
|
+
# by {#only} and {#without} to derive a narrower roster; nil (the
|
|
46
|
+
# default) loads every definition in `dir`
|
|
47
|
+
def initialize(dir: DEFAULT_DIR, roster: nil)
|
|
48
|
+
@dir = File.expand_path(dir)
|
|
49
|
+
@all = roster
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [String]
|
|
53
|
+
attr_reader :dir
|
|
54
|
+
|
|
55
|
+
# @return [Array<Agentilda::Agent>] in name order
|
|
56
|
+
def all
|
|
57
|
+
@all ||= Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse(path) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param name [String]
|
|
61
|
+
# @return [Agentilda::Agent, nil]
|
|
62
|
+
def find(name) = all.find { |a| a.name == name.to_s }
|
|
63
|
+
|
|
64
|
+
# A roster holding only the agents named — what `run --agent` hands the
|
|
65
|
+
# loop, so a restriction typed at the command line restricts assignments
|
|
66
|
+
# and not merely chaining.
|
|
67
|
+
#
|
|
68
|
+
# @param names [Array<String>]
|
|
69
|
+
# @return [Agentilda::Agents]
|
|
70
|
+
def only(*names)
|
|
71
|
+
wanted = names.flatten.map(&:to_s)
|
|
72
|
+
self.class.new(dir:, roster: all.select { |a| wanted.include?(a.name) })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A roster without the agents named — what `run --skip` hands the loop.
|
|
76
|
+
# A plan sitting in a skipped agent's state is simply never assigned, the
|
|
77
|
+
# same way a state no agent handles is stepped around.
|
|
78
|
+
#
|
|
79
|
+
# @param names [Array<String>]
|
|
80
|
+
# @return [Agentilda::Agents]
|
|
81
|
+
def without(*names)
|
|
82
|
+
unwanted = names.flatten.map(&:to_s)
|
|
83
|
+
self.class.new(dir:, roster: all.reject { |a| unwanted.include?(a.name) })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every agent the query could mean. An exact name wins outright; failing
|
|
87
|
+
# that the query matches as a prefix, and failing that anywhere in the
|
|
88
|
+
# name, so `leah` finds leah-researcher and `review` finds
|
|
89
|
+
# hansolo-reviewer. A directory or a trailing `.md` is stripped first,
|
|
90
|
+
# because tab completion hands those in.
|
|
91
|
+
#
|
|
92
|
+
# @param query [String]
|
|
93
|
+
# @return [Array<Agentilda::Agent>]
|
|
94
|
+
def match(query)
|
|
95
|
+
wanted = File.basename(query.to_s, ".md")
|
|
96
|
+
exact = all.select { |a| a.name == wanted }
|
|
97
|
+
return exact unless exact.empty?
|
|
98
|
+
|
|
99
|
+
prefixed = all.select { |a| a.name.start_with?(wanted) }
|
|
100
|
+
return prefixed unless prefixed.empty?
|
|
101
|
+
|
|
102
|
+
all.select { |a| a.name.include?(wanted) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Every agent that will act on a plan in this state, in definition order.
|
|
106
|
+
# A read-only agent is never offered work by the loop — it has nothing to
|
|
107
|
+
# advance, so including it would make every round look productive.
|
|
108
|
+
#
|
|
109
|
+
# @param status [Agentilda::Status]
|
|
110
|
+
# @return [Array<Agentilda::Agent>]
|
|
111
|
+
def for_status(status) = all.select { |a| a.handles?(status) && !a.read_only? }
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# @param path [String]
|
|
116
|
+
# @return [Agentilda::Agent, nil]
|
|
117
|
+
def parse(path)
|
|
118
|
+
meta, body = Frontmatter.split(File.read(path, encoding: "UTF-8"))
|
|
119
|
+
return nil if meta["name"].to_s.empty?
|
|
120
|
+
|
|
121
|
+
Agent.new(
|
|
122
|
+
name: meta["name"].to_s,
|
|
123
|
+
description: meta["description"].to_s,
|
|
124
|
+
handles: Array(meta["handles"]).map { |s| s.to_s.to_sym },
|
|
125
|
+
advances_to: meta["advances_to"]&.to_s&.then { |s| s.empty? ? nil : s.to_sym },
|
|
126
|
+
model: meta["model"],
|
|
127
|
+
allowed_tools: Array(meta["allowed_tools"]).map(&:to_s),
|
|
128
|
+
may: Array(meta["may"]).map { |c| c.to_s.strip.squeeze(" ") },
|
|
129
|
+
network: meta["network"] == true,
|
|
130
|
+
timeout: meta["timeout"].to_i.then { |s| s.positive? ? s : nil },
|
|
131
|
+
prompt: body.strip,
|
|
132
|
+
path: path
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# A new plan's opening brief: four questions, and a best-effort first pass
|
|
5
|
+
# at answering them from what the project already has on disk.
|
|
6
|
+
#
|
|
7
|
+
# This is for work that does not exist yet. A plan created `--after` a
|
|
8
|
+
# shipped pull request already has its facts — {CLI::Create} hands those to
|
|
9
|
+
# `yoda-writer`, which reconstructs a specification from a diff, not this.
|
|
10
|
+
# Guessing at a diff and guessing at a feature nobody has built are
|
|
11
|
+
# different jobs, and conflating them is how a retroactive spec ends up
|
|
12
|
+
# full of hedges about something that already, provably, happened.
|
|
13
|
+
#
|
|
14
|
+
# The four headings are the brief `leah-researcher` and `yoda-writer` both
|
|
15
|
+
# read *before* their own chapters: what research needs to settle is a
|
|
16
|
+
# question about the gaps in `## What already exists`, so a scaffold
|
|
17
|
+
# missing that heading forces both of them to first reconstruct a boundary
|
|
18
|
+
# this class already had for free — it just created the folder.
|
|
19
|
+
class Brief
|
|
20
|
+
# The half-agent's name. It has no `agents/*.md` of its own on purpose —
|
|
21
|
+
# a roster entry would put it in `Runner`'s routing, and this pass runs
|
|
22
|
+
# only inside `create`. The name exists so the UI can show it the same
|
|
23
|
+
# way it shows `leah-researcher` and `yoda-writer` on their spinner lines.
|
|
24
|
+
AGENT_NAME = "anakin-briefster"
|
|
25
|
+
|
|
26
|
+
HEADINGS = [
|
|
27
|
+
"What we are trying to achieve",
|
|
28
|
+
"Why it matters",
|
|
29
|
+
"What already exists",
|
|
30
|
+
"What research needs to settle"
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Read whole into the drafting prompt, in order, so the model sees the
|
|
34
|
+
# project's own account of itself before it sees the topic.
|
|
35
|
+
CONTEXT_FILES = %w[CLAUDE.md AGENTS.md README.md].freeze
|
|
36
|
+
|
|
37
|
+
# Relative to the repository root. Not every project keeps one — a
|
|
38
|
+
# missing backlog is silently skipped, not an error.
|
|
39
|
+
BACKLOG_FILE = File.join(Agentilda::PLANS_DIR, "BACKLOG.md")
|
|
40
|
+
|
|
41
|
+
# Long enough for a handful of Read/Grep/Glob calls over a local
|
|
42
|
+
# checkout; short enough that a hung `claude` does not stall `create`
|
|
43
|
+
# past the point where the scaffold is already there to open by hand.
|
|
44
|
+
TIMEOUT = 60
|
|
45
|
+
|
|
46
|
+
# The fastest model, and the draft is disposable: the prompt prefers an
|
|
47
|
+
# honest blank over a clever inference, so there is no reasoning premium
|
|
48
|
+
# worth paying a larger model for inside a 60-second budget.
|
|
49
|
+
BRIEF_MODEL = "haiku"
|
|
50
|
+
|
|
51
|
+
# Read-only, and no `Bash` — this pass surveys what the repository
|
|
52
|
+
# already says about itself, it does not go looking further. That is
|
|
53
|
+
# `leah-researcher`'s job, and denying her tools here is what keeps the
|
|
54
|
+
# two from quietly doing the same work twice.
|
|
55
|
+
ALLOWED_TOOLS = %w[Read Grep Glob Edit].freeze
|
|
56
|
+
DENIED_TOOLS = %w[WebFetch WebSearch Bash].freeze
|
|
57
|
+
|
|
58
|
+
# @param path [String] the new plan folder, absolute
|
|
59
|
+
# @param title [String] the feature's proper name, e.g. "Tax Rule DSL"
|
|
60
|
+
# @param root [String] repository root, for project context and `--add-dir`
|
|
61
|
+
# @param command [TTY::Command]
|
|
62
|
+
# @param timeout [Integer] seconds before the drafting attempt is abandoned
|
|
63
|
+
# @param dry_run [Boolean] plan the invocation, do not run it
|
|
64
|
+
# @param seed [String, nil] what the author already wrote about the
|
|
65
|
+
# feature (`create --from`); opens spec.md and outranks the repo survey
|
|
66
|
+
def initialize(
|
|
67
|
+
path:,
|
|
68
|
+
title:,
|
|
69
|
+
root:,
|
|
70
|
+
command: TTY::Command.new(printer: :null),
|
|
71
|
+
timeout: TIMEOUT,
|
|
72
|
+
dry_run: false,
|
|
73
|
+
seed: nil
|
|
74
|
+
)
|
|
75
|
+
@path = path
|
|
76
|
+
@title = title
|
|
77
|
+
@root = File.expand_path(root)
|
|
78
|
+
@command = command
|
|
79
|
+
@timeout = timeout
|
|
80
|
+
@dry_run = dry_run
|
|
81
|
+
@seed = seed.to_s.strip.then { |s| s.empty? ? nil : s }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [String]
|
|
85
|
+
attr_reader :path, :title, :root
|
|
86
|
+
|
|
87
|
+
# @return [String, nil]
|
|
88
|
+
attr_reader :seed
|
|
89
|
+
|
|
90
|
+
# @return [String] absolute path to the spec, whether or not it exists yet
|
|
91
|
+
def spec_path = File.join(path, "spec.md")
|
|
92
|
+
|
|
93
|
+
# The title and four empty headings — and, when `create --from` supplied
|
|
94
|
+
# one, the author's own prose between them, so the file already says
|
|
95
|
+
# something even if the drafting attempt never returns.
|
|
96
|
+
#
|
|
97
|
+
# @return [String]
|
|
98
|
+
def scaffold
|
|
99
|
+
opening = seed ? "#{seed}\n\n" : ""
|
|
100
|
+
"# #{title}\n\n#{opening}" + HEADINGS.map { |h| "## #{h}\n\n" }.join("\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [String] where it was written
|
|
104
|
+
def write_scaffold!
|
|
105
|
+
File.write(spec_path, scaffold)
|
|
106
|
+
spec_path
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Make a best-effort attempt at the four headings, in place, by shelling
|
|
110
|
+
# out to `claude` the same way {Executor} does for a specialist agent.
|
|
111
|
+
# A failure here is not this method's to hide: the scaffold {#write_scaffold!}
|
|
112
|
+
# already wrote stands on its own, so the caller reports the failure and
|
|
113
|
+
# moves on rather than treating it as fatal to `create`.
|
|
114
|
+
#
|
|
115
|
+
# @return [Array(Boolean, String)] ok, and a one-line note
|
|
116
|
+
def attempt!
|
|
117
|
+
return [true, "dry run — would draft from project context"] if @dry_run
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
@command.run(*invocation, timeout: @timeout)
|
|
121
|
+
rescue TTY::Command::TimeoutExceeded
|
|
122
|
+
return [false, "timed out after #{@timeout}s"]
|
|
123
|
+
rescue TTY::Command::ExitError => e
|
|
124
|
+
return [false, "claude #{Executor.failure_reason(e)}"]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
[true, "drafted"]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Exposed so a spec can assert the boundary without running anything.
|
|
131
|
+
#
|
|
132
|
+
# @return [Array<String>]
|
|
133
|
+
def invocation
|
|
134
|
+
["claude",
|
|
135
|
+
"-p", prompt, "--add-dir", root,
|
|
136
|
+
"--model", BRIEF_MODEL,
|
|
137
|
+
"--allowedTools", ALLOWED_TOOLS.join(","),
|
|
138
|
+
"--disallowedTools", DENIED_TOOLS.join(",")]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# @return [String]
|
|
144
|
+
def prompt
|
|
145
|
+
<<~PROMPT
|
|
146
|
+
#{author_statement}A new plan folder was just created for a feature that does not exist
|
|
147
|
+
yet: "#{title}".
|
|
148
|
+
|
|
149
|
+
#{spec_path} holds a title and four empty headings:
|
|
150
|
+
|
|
151
|
+
#{HEADINGS.map { |h| " * #{h}" }.join("\n")}
|
|
152
|
+
|
|
153
|
+
Make a best-effort first pass at each, drawn only from what THIS
|
|
154
|
+
project already has on disk under #{root} — its own docs, its own
|
|
155
|
+
conventions, code already built, and anything already downloaded or
|
|
156
|
+
gathered that bears on "#{title}". Read/Grep/Glob freely to look for
|
|
157
|
+
it; do not invent facts and do not research the web. Where the
|
|
158
|
+
repository gives you nothing to go on, leave that heading's body as
|
|
159
|
+
a short, honest one-line note saying so, e.g. "_Nothing in the repo
|
|
160
|
+
speaks to this yet — needs research._" A human completes this
|
|
161
|
+
afterward, and a confident-sounding guess costs them more to unwind
|
|
162
|
+
than an honest blank.
|
|
163
|
+
|
|
164
|
+
`## What already exists` matters most: name specific files, modules
|
|
165
|
+
or prior work you found, not just that "some code exists" — that is
|
|
166
|
+
exactly the context a researcher picking this up next cannot see for
|
|
167
|
+
themselves without being told where to look.
|
|
168
|
+
|
|
169
|
+
#{context}
|
|
170
|
+
#{backlog}
|
|
171
|
+
Edit #{spec_path} in place. Keep the title#{", the author's opening prose" if seed} and the four `##`
|
|
172
|
+
headings exactly as they are, in order; add prose under them; write
|
|
173
|
+
nothing outside them; touch no other file.
|
|
174
|
+
PROMPT
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The author's own words lead the prompt, ahead of even the feature's
|
|
178
|
+
# announcement, because they outrank everything this pass could survey:
|
|
179
|
+
# where the seed and the repository disagree, the seed is the intent and
|
|
180
|
+
# the repository is merely the past.
|
|
181
|
+
#
|
|
182
|
+
# @return [String] empty without a seed
|
|
183
|
+
def author_statement
|
|
184
|
+
return "" unless seed
|
|
185
|
+
|
|
186
|
+
<<~STATEMENT
|
|
187
|
+
The author has already written the following about this feature. It
|
|
188
|
+
is the primary source: where it answers one of the headings, draw
|
|
189
|
+
from it first, and use the repository survey below only to supplement
|
|
190
|
+
it — never to contradict it.
|
|
191
|
+
|
|
192
|
+
#{seed}
|
|
193
|
+
|
|
194
|
+
STATEMENT
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# AGENTS.md is commonly a symlink to CLAUDE.md; realpath-deduping keeps
|
|
198
|
+
# the shared content from being inlined twice into every drafting prompt.
|
|
199
|
+
#
|
|
200
|
+
# @return [String]
|
|
201
|
+
def context
|
|
202
|
+
seen = []
|
|
203
|
+
found = CONTEXT_FILES.filter_map { |name|
|
|
204
|
+
file = File.join(root, name)
|
|
205
|
+
next unless File.file?(file)
|
|
206
|
+
|
|
207
|
+
real = File.realpath(file)
|
|
208
|
+
next if seen.include?(real)
|
|
209
|
+
|
|
210
|
+
seen << real
|
|
211
|
+
"### #{name}\n\n#{File.read(file, encoding: "UTF-8")}"
|
|
212
|
+
}
|
|
213
|
+
return "" if found.empty?
|
|
214
|
+
|
|
215
|
+
"## Project context\n\n#{found.join("\n\n")}\n"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A relevant line in the project's own backlog is worth more than
|
|
219
|
+
# anything this pass could infer on its own — it is the one place "why
|
|
220
|
+
# now" might already be written down.
|
|
221
|
+
#
|
|
222
|
+
# @return [String]
|
|
223
|
+
def backlog
|
|
224
|
+
file = File.join(root, BACKLOG_FILE)
|
|
225
|
+
return "" unless File.file?(file)
|
|
226
|
+
|
|
227
|
+
"## #{BACKLOG_FILE}\n\n" \
|
|
228
|
+
"If this backlog mentions \"#{title}\", treat that entry as " \
|
|
229
|
+
"authoritative for `## Why it matters` — it is the project's own " \
|
|
230
|
+
"statement of intent.\n\n" \
|
|
231
|
+
"#{File.read(file, encoding: "UTF-8")}\n"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda agents …` — the specialist roster.
|
|
6
|
+
module Agents
|
|
7
|
+
# `agentilda agents describe [NAME]`
|
|
8
|
+
class Describe < Dry::CLI::Command
|
|
9
|
+
include UI
|
|
10
|
+
|
|
11
|
+
desc "Open one specialist's definition in a Markdown viewer"
|
|
12
|
+
|
|
13
|
+
argument :name, required: false,
|
|
14
|
+
desc: "Which specialist, full name or a fragment, e.g. leah (default: every one)"
|
|
15
|
+
|
|
16
|
+
option :mdfried, type: :boolean, default: false, aliases: ["-m"],
|
|
17
|
+
desc: "Render in the terminal through mdfried instead of the system viewer"
|
|
18
|
+
|
|
19
|
+
example [
|
|
20
|
+
"leah # leah-researcher, in the system Markdown viewer",
|
|
21
|
+
"luke-backend -m # the same, rendered in this terminal by mdfried",
|
|
22
|
+
" # every agent, one viewer window each"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# The registry instantiates commands bare, so both collaborators
|
|
26
|
+
# default to the real thing; the suite hands in its own to keep
|
|
27
|
+
# viewers from opening on the box running it.
|
|
28
|
+
#
|
|
29
|
+
# @param agents [Agentilda::Agents]
|
|
30
|
+
# @param viewer [Agentilda::Viewer]
|
|
31
|
+
def initialize(agents: Agentilda::Agents.new, viewer: Viewer.new)
|
|
32
|
+
super()
|
|
33
|
+
@agents = agents
|
|
34
|
+
@viewer = viewer
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param name [String, nil]
|
|
38
|
+
# @param mdfried [Boolean]
|
|
39
|
+
# @return [void]
|
|
40
|
+
def call(name: nil, mdfried: false, **_options)
|
|
41
|
+
agents = @agents
|
|
42
|
+
found = name ? agents.match(name) : agents.all
|
|
43
|
+
|
|
44
|
+
if found.empty?
|
|
45
|
+
known = agents.all.map(&:name).join(", ")
|
|
46
|
+
raise Error, name ? "No agent matches #{name}. Known: #{known}" : "No agent definitions in #{agents.dir}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A fragment that fits several agents is a question, not an order:
|
|
50
|
+
# opening all of them would bury the one that was meant.
|
|
51
|
+
raise Error, "#{name} could be any of #{found.map(&:name).join(", ")} — say which" if name && found.size > 1
|
|
52
|
+
|
|
53
|
+
paths = found.map(&:path)
|
|
54
|
+
mdfried ? @viewer.mdfried(paths) : @viewer.open(paths)
|
|
55
|
+
rescue Agentilda::Error => e
|
|
56
|
+
error(e.message)
|
|
57
|
+
exit 65
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda agents …` — the specialist roster.
|
|
6
|
+
module Agents
|
|
7
|
+
# `agentilda agents list`
|
|
8
|
+
class List < Dry::CLI::Command
|
|
9
|
+
include UI
|
|
10
|
+
|
|
11
|
+
desc "List every specialist, what it handles, and what it advances to"
|
|
12
|
+
|
|
13
|
+
example ["", "| less -R"]
|
|
14
|
+
|
|
15
|
+
# @return [void]
|
|
16
|
+
def call(**) = $stdout.write(Roster.new.list)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|