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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +26 -0
  3. data/Gemfile.lock +261 -0
  4. data/agentilda.gemspec +57 -0
  5. data/agents/hansolo-reviewer.md +29 -0
  6. data/agents/lando-broker.md +74 -0
  7. data/agents/leah-researcher.md +80 -0
  8. data/agents/luke-backend.md +81 -0
  9. data/agents/palpatine-planner.md +40 -0
  10. data/agents/rey-frontend.md +106 -0
  11. data/agents/yoda-writer.md +54 -0
  12. data/bin/create-plan-folder +125 -0
  13. data/bin/plan-number +164 -0
  14. data/exe/agentilda +111 -0
  15. data/exe/tilda +1 -0
  16. data/lib/agentilda/adoption.rb +192 -0
  17. data/lib/agentilda/agent.rb +136 -0
  18. data/lib/agentilda/brief.rb +234 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
  20. data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
  21. data/lib/agentilda/cli/base.rb +88 -0
  22. data/lib/agentilda/cli/create/create.rb +309 -0
  23. data/lib/agentilda/cli/docs/docs.rb +30 -0
  24. data/lib/agentilda/cli/index/index.rb +38 -0
  25. data/lib/agentilda/cli/linear/linear.rb +35 -0
  26. data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
  27. data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
  28. data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
  31. data/lib/agentilda/cli/run/run.rb +289 -0
  32. data/lib/agentilda/cli/states/states.rb +15 -0
  33. data/lib/agentilda/cli/unblock/unblock.rb +227 -0
  34. data/lib/agentilda/cli/version/version.rb +13 -0
  35. data/lib/agentilda/cli.rb +74 -0
  36. data/lib/agentilda/config.rb +44 -0
  37. data/lib/agentilda/control.rb +115 -0
  38. data/lib/agentilda/creator.rb +120 -0
  39. data/lib/agentilda/dev_work.rb +54 -0
  40. data/lib/agentilda/diagram.rb +144 -0
  41. data/lib/agentilda/documentation.rb +429 -0
  42. data/lib/agentilda/executor.rb +539 -0
  43. data/lib/agentilda/feature.rb +253 -0
  44. data/lib/agentilda/frontmatter.rb +36 -0
  45. data/lib/agentilda/github.rb +160 -0
  46. data/lib/agentilda/index.rb +206 -0
  47. data/lib/agentilda/keyboard.rb +88 -0
  48. data/lib/agentilda/linear/api.rb +220 -0
  49. data/lib/agentilda/linear/attribution.rb +185 -0
  50. data/lib/agentilda/linear/fuzzy.rb +68 -0
  51. data/lib/agentilda/linear/import.rb +298 -0
  52. data/lib/agentilda/linear/issue.rb +184 -0
  53. data/lib/agentilda/linear/mapping.rb +115 -0
  54. data/lib/agentilda/linear/push.rb +190 -0
  55. data/lib/agentilda/linear/survey.rb +173 -0
  56. data/lib/agentilda/linear/unit.rb +274 -0
  57. data/lib/agentilda/linear.rb +42 -0
  58. data/lib/agentilda/markdown.rb +56 -0
  59. data/lib/agentilda/ordinal.rb +90 -0
  60. data/lib/agentilda/progress_log.rb +122 -0
  61. data/lib/agentilda/publisher.rb +172 -0
  62. data/lib/agentilda/pull_request.rb +213 -0
  63. data/lib/agentilda/reporter.rb +175 -0
  64. data/lib/agentilda/resync.rb +358 -0
  65. data/lib/agentilda/roster.rb +110 -0
  66. data/lib/agentilda/runner.rb +456 -0
  67. data/lib/agentilda/state_machine.rb +355 -0
  68. data/lib/agentilda/status.rb +280 -0
  69. data/lib/agentilda/tally.rb +169 -0
  70. data/lib/agentilda/transcript.rb +435 -0
  71. data/lib/agentilda/tree.rb +77 -0
  72. data/lib/agentilda/ui.rb +681 -0
  73. data/lib/agentilda/unblocker.rb +207 -0
  74. data/lib/agentilda/version.rb +10 -0
  75. data/lib/agentilda/viewer.rb +60 -0
  76. data/lib/agentilda/worktree.rb +211 -0
  77. data/lib/agentilda.rb +155 -0
  78. data/lib/dry/cli/banner.rb +293 -0
  79. 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