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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda linear …`
|
|
6
|
+
module Linear
|
|
7
|
+
# `linear projects` — what the team already has.
|
|
8
|
+
class Projects < Team
|
|
9
|
+
desc "List the Linear projects a team owns, and which plans they already cover"
|
|
10
|
+
|
|
11
|
+
example [
|
|
12
|
+
"TAX # every project the team owns, matched against .plans"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# @param team [String]
|
|
16
|
+
# @param options [Hash]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def call(team:, **options)
|
|
19
|
+
tree = tree_for(options)
|
|
20
|
+
_api, survey = survey_for(team, tree)
|
|
21
|
+
|
|
22
|
+
survey.projects.each { |project| puts row(project) }
|
|
23
|
+
return if quiet?(options)
|
|
24
|
+
|
|
25
|
+
survey.projects.each do |project|
|
|
26
|
+
say("#{paint(project["name"], :cyan)}\n #{paint(project["url"].to_s, :bright_black)}")
|
|
27
|
+
end
|
|
28
|
+
report_descriptions(survey)
|
|
29
|
+
rescue Agentilda::Error => e
|
|
30
|
+
refuse(e.message, 69)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @param project [Hash]
|
|
36
|
+
# @return [String]
|
|
37
|
+
def row(project)
|
|
38
|
+
[project["name"], project.dig("status", "name") || "-", project["url"]].join("\t")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param survey [Agentilda::Linear::Survey]
|
|
42
|
+
# @return [void]
|
|
43
|
+
def report_descriptions(survey)
|
|
44
|
+
return if survey.undescribed.empty?
|
|
45
|
+
|
|
46
|
+
warn("#{survey.undescribed.size} of these say nothing about themselves:\n\n" \
|
|
47
|
+
"#{survey.undescribed.map { |p| " #{p["name"]}" }.join("\n")}\n\n" \
|
|
48
|
+
"A name is three or four words and half of them are the company's. If you " \
|
|
49
|
+
"want anything here to reason about which project a plan belongs to, that " \
|
|
50
|
+
"reasoning has to have a sentence to read.")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda list-plans` — backs /plan-status.
|
|
6
|
+
class ListPlans < Base
|
|
7
|
+
desc "Print every plan, its state, and its pull requests"
|
|
8
|
+
|
|
9
|
+
example ["", "-D .plans", "| less -R"]
|
|
10
|
+
|
|
11
|
+
# @param options [Hash]
|
|
12
|
+
# @return [void]
|
|
13
|
+
def call(**options)
|
|
14
|
+
reporter = Reporter.new(tree: tree_for(options))
|
|
15
|
+
$stdout.write(reporter.render)
|
|
16
|
+
|
|
17
|
+
exit 1 unless reporter.inconsistent.empty?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda resync …`
|
|
6
|
+
module Resync
|
|
7
|
+
# `resync dirs` — reconcile folder names with folder contents.
|
|
8
|
+
class Dirs < Base
|
|
9
|
+
desc "Rename plan folders so the name matches the contents and the NNN.MM form"
|
|
10
|
+
|
|
11
|
+
option :commit, type: :boolean, default: false,
|
|
12
|
+
desc: "Actually rename the folders (default: dry run)"
|
|
13
|
+
|
|
14
|
+
example [
|
|
15
|
+
" # show what would be renamed",
|
|
16
|
+
"--commit # do it"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# @param options [Hash]
|
|
20
|
+
# @return [void]
|
|
21
|
+
def call(**options)
|
|
22
|
+
changes = Agentilda::Resync::Dirs.new(tree: tree_for(options))
|
|
23
|
+
.call(commit: commit?(options))
|
|
24
|
+
|
|
25
|
+
if changes.empty?
|
|
26
|
+
success("Every folder is already named NNN.MM-<emoji>-<slug> and the emoji matches.") unless quiet?(options)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
changes.each do |change|
|
|
31
|
+
puts "#{change.dirname}\t#{File.basename(change.target)}\t#{change.reason}"
|
|
32
|
+
next if quiet?(options)
|
|
33
|
+
|
|
34
|
+
say("#{paint(change.dirname, :bright_black)} → #{File.basename(change.target)}")
|
|
35
|
+
say(" #{paint(change.reason, :yellow)}", bullet: " ")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return if quiet?(options)
|
|
39
|
+
|
|
40
|
+
if commit?(options)
|
|
41
|
+
success("Renamed #{changes.size} folder#{"s" unless changes.size == 1}.")
|
|
42
|
+
else
|
|
43
|
+
dry_run_footer(changes.size, "rename#{"s" unless changes.size == 1}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda resync …`
|
|
6
|
+
module Resync
|
|
7
|
+
# `resync prs` — put an [NNN.MM] prefix on every pull request title.
|
|
8
|
+
class Prs < Base
|
|
9
|
+
desc "Add missing [NNN.MM] prefixes to pull request titles"
|
|
10
|
+
|
|
11
|
+
option :commit, type: :boolean, default: false,
|
|
12
|
+
desc: "Actually retitle the pull requests (default: dry run)"
|
|
13
|
+
option :state, default: "all", values: %w[open closed merged all],
|
|
14
|
+
desc: "Which pull requests to consider"
|
|
15
|
+
option :adopt, type: :boolean, default: true,
|
|
16
|
+
desc: "Mint a retroactive plan folder for every pull request that resolves to none. --no-adopt flags them for a human instead"
|
|
17
|
+
|
|
18
|
+
example [
|
|
19
|
+
" # show what would be retitled, and what would be adopted",
|
|
20
|
+
"--state open # only open pull requests",
|
|
21
|
+
"--no-adopt # never create a folder; flag the unresolvable ones",
|
|
22
|
+
"--commit # do it"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# @param options [Hash]
|
|
26
|
+
# @return [void]
|
|
27
|
+
def call(**options)
|
|
28
|
+
github = GitHub.new
|
|
29
|
+
tree = tree_for(options)
|
|
30
|
+
changes = Agentilda::Resync::Prs.new(tree:, github:,
|
|
31
|
+
adopt: options.fetch(:adopt, true), root: File.dirname(tree.dir))
|
|
32
|
+
.call(commit: commit?(options))
|
|
33
|
+
|
|
34
|
+
if changes.empty?
|
|
35
|
+
success("Every pull request title already carries a prefix.") unless quiet?(options)
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
changes.each do |change|
|
|
40
|
+
puts "#{change.number}\t#{change.new_title || "-"}\t#{change.reason}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return if quiet?(options)
|
|
44
|
+
|
|
45
|
+
report_pr_changes(changes, options)
|
|
46
|
+
rescue Agentilda::Error => e
|
|
47
|
+
refuse(e.message, 69)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @param changes [Array]
|
|
53
|
+
# @param options [Hash]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def report_pr_changes(changes, options)
|
|
56
|
+
applicable, flagged = changes.partition(&:applicable?)
|
|
57
|
+
assumed = applicable.select(&:assumed?)
|
|
58
|
+
|
|
59
|
+
applicable.each do |c|
|
|
60
|
+
note = if c.adopted?
|
|
61
|
+
paint(" (new plan)", :magenta)
|
|
62
|
+
elsif c.assumed?
|
|
63
|
+
paint(" (assumed)", :yellow)
|
|
64
|
+
end
|
|
65
|
+
say("##{c.number} #{c.new_title}#{note}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
report_adoptions(applicable.select(&:adopted?), options)
|
|
69
|
+
|
|
70
|
+
flagged.each { |c| say("##{c.number} #{paint("SKIPPED", :red)} — #{c.reason}", bullet: "!") }
|
|
71
|
+
|
|
72
|
+
if commit?(options)
|
|
73
|
+
success("Retitled #{applicable.size} pull request#{"s" unless applicable.size == 1}.")
|
|
74
|
+
else
|
|
75
|
+
dry_run_footer(applicable.size, "retitle#{"s" unless applicable.size == 1}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return if assumed.empty?
|
|
79
|
+
|
|
80
|
+
warn("#{assumed.size} title#{"s" unless assumed.size == 1} would get " \
|
|
81
|
+
"[#{Agentilda::NO_PLAN_PREFIX}] because no plan resolved.\n\n" \
|
|
82
|
+
"That is an assertion about intent, and it is yours to make: check each one\n" \
|
|
83
|
+
"before committing. A pull request that writes a plan's spec belongs to that\n" \
|
|
84
|
+
"plan however its branch was named.")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Creating folders is a bigger act than editing a title, so it is
|
|
88
|
+
# reported separately rather than buried in the retitle list.
|
|
89
|
+
#
|
|
90
|
+
# @param adopted [Array]
|
|
91
|
+
# @param options [Hash]
|
|
92
|
+
# @return [void]
|
|
93
|
+
def report_adoptions(adopted, options)
|
|
94
|
+
return if adopted.empty?
|
|
95
|
+
|
|
96
|
+
verb = commit?(options) ? "Created" : "Would create"
|
|
97
|
+
info("#{verb} #{adopted.size} plan folder#{"s" unless adopted.size == 1} for pull " \
|
|
98
|
+
"requests that resolved to no plan:\n\n" \
|
|
99
|
+
"#{adopted.map { |c| " #{c.ordinal} ##{c.number} #{c.title}" }.join("\n")}\n\n" \
|
|
100
|
+
"Each holds its pull request and nothing else. Run `agentilda run --commit`\n" \
|
|
101
|
+
"to have the specification written from what the pull request actually did.")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda run` — drive the specialist agents until the tree settles.
|
|
6
|
+
class Run < Base
|
|
7
|
+
desc "Run specialist agents over the plans until nothing changes"
|
|
8
|
+
|
|
9
|
+
option :commit, type: :boolean, default: false,
|
|
10
|
+
desc: "Actually invoke the agents (default: dry run, prints the plan of work)"
|
|
11
|
+
option :rounds, desc: "Hard ceiling on loop iterations (default: 10)"
|
|
12
|
+
option :timeout, desc: "Seconds before one agent is abandoned (default: 900)"
|
|
13
|
+
option :chain, type: :boolean,
|
|
14
|
+
desc: "When an agent advances a plan, run the next agent in the same round " \
|
|
15
|
+
"(default: on; forced off by --agent)"
|
|
16
|
+
option :agent, desc: "Only run this one agent"
|
|
17
|
+
option :prompt, desc: "Extra instructions appended to the agent's prompt (only with --agent)"
|
|
18
|
+
option :skip, desc: "Never assign this agent; its plans wait, the rest of the pipeline runs. " \
|
|
19
|
+
"Comma separated for several"
|
|
20
|
+
option :model, desc: "Model for every agent this run, overriding each agent's own frontmatter " \
|
|
21
|
+
"(default: what the agent declares, else the claude CLI's default)"
|
|
22
|
+
option :max_tokens, desc: "Token budget per agent invocation, input plus output, sub-agents " \
|
|
23
|
+
"included. The agent is told the number so it can finish inside it; the meter aborts it past " \
|
|
24
|
+
"the number so the telling is true. (default: unmetered)"
|
|
25
|
+
option :plan, aliases: ["--plans"],
|
|
26
|
+
desc: "Only these plans, comma separated: NNN or NNN.MM, e.g. --plan 003,005.01. Default: the whole tree"
|
|
27
|
+
option :root, desc: "Repository root the agents work in (default: the .plans parent)"
|
|
28
|
+
option :isolation, default: "worktree", values: %w[worktree shared],
|
|
29
|
+
desc: "worktree: a checkout and branch per plan, run in parallel. shared: one tree, serial"
|
|
30
|
+
option :jobs, aliases: ["-j"],
|
|
31
|
+
desc: "Agents to run at once (default: cores - 2, capped at 12)"
|
|
32
|
+
option :dont_push_anything, type: :boolean, default: false,
|
|
33
|
+
desc: "With --commit and --isolation worktree, a finished branch is pushed and its pull request " \
|
|
34
|
+
"opened as soon as it lands, titled [NNN.MM](X). Pass this to turn that off and leave it uncommitted."
|
|
35
|
+
option :log, desc: "Append progress to this file (default: a per-project file under the system temp dir)"
|
|
36
|
+
|
|
37
|
+
example [
|
|
38
|
+
" # show which agent would take which plan",
|
|
39
|
+
"--commit # run them, one worktree per plan, in parallel",
|
|
40
|
+
"--commit -j 4 # …with four at a time",
|
|
41
|
+
"--isolation shared # one tree, serial — no git required",
|
|
42
|
+
"--commit --rounds 3 # …with a tighter ceiling",
|
|
43
|
+
"--commit --plan 005,006,007 # only the plans a batch step just created"
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Option precedence, quietest to loudest: the built-in default, then
|
|
47
|
+
# `~/.local/config/agentilda.json` under its `"run"` key, then the flag
|
|
48
|
+
# actually typed. The config never supplies `--commit`: an agent run
|
|
49
|
+
# that writes is something a person asks for each time.
|
|
50
|
+
#
|
|
51
|
+
# @param options [Hash]
|
|
52
|
+
# @return [void]
|
|
53
|
+
def call(**options)
|
|
54
|
+
config = begin
|
|
55
|
+
Agentilda::Config.for(:run)
|
|
56
|
+
rescue Agentilda::Error => e
|
|
57
|
+
refuse(e.message, 66)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if options[:prompt] && !options[:agent]
|
|
61
|
+
refuse("--prompt only works with --agent: it speaks to one agent, and without " \
|
|
62
|
+
"that restriction every agent in the round would hear it.", 64)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
tree = tree_for(options)
|
|
66
|
+
root = options[:root] || File.dirname(tree.dir)
|
|
67
|
+
agents = Agentilda::Agents.new
|
|
68
|
+
agents = filtered(agents, options[:agent]) if options[:agent]
|
|
69
|
+
agents = skipped(agents, options[:skip], options[:agent]) if options[:skip]
|
|
70
|
+
plans = options[:plan] ? scoped(tree, options[:plan]) : nil
|
|
71
|
+
assignable!(agents, tree, plans, options[:agent]) if options[:agent]
|
|
72
|
+
|
|
73
|
+
isolation = options.fetch(:isolation, "worktree").to_sym
|
|
74
|
+
jobs = (options[:jobs] || config[:jobs] || UI.default_jobs).to_i
|
|
75
|
+
timeout = (options[:timeout] || config[:timeout] || 900).to_i
|
|
76
|
+
chain = if options[:agent]
|
|
77
|
+
false
|
|
78
|
+
elsif options.key?(:chain)
|
|
79
|
+
options[:chain]
|
|
80
|
+
else
|
|
81
|
+
config.fetch(:chain, true)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if isolation == :worktree && !Worktree.new(root:).repository?
|
|
85
|
+
refuse("#{root} is not a git repository, so plans cannot be isolated.\n\n" \
|
|
86
|
+
"Run with --isolation shared to work in one tree, serially.", 66)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Set before the loop starts, not after: `UI.animate?` (and therefore
|
|
90
|
+
# whether a round prints anything at all) is read the whole time the
|
|
91
|
+
# loop runs, not just when `report` prints its closing summary.
|
|
92
|
+
quiet?(options)
|
|
93
|
+
UI.log_path = options[:log] || config[:log] ||
|
|
94
|
+
File.join(Dir.tmpdir, "agentilda-#{File.basename(root)}.log")
|
|
95
|
+
info("Progress: #{UI.log_path}") unless quiet?(options)
|
|
96
|
+
credentials_warning if commit?(options) && !quiet?(options)
|
|
97
|
+
|
|
98
|
+
Control.reset!
|
|
99
|
+
keyboard = Keyboard.listen
|
|
100
|
+
UI.line("keys: h for help — w wrap up · n write out and stop · q quit") if keyboard && !quiet?(options)
|
|
101
|
+
|
|
102
|
+
runner = Runner.new(
|
|
103
|
+
tree:, agents:, isolation:, jobs:, plans:, chain:,
|
|
104
|
+
worktree: (Worktree.new(root:) if isolation == :worktree),
|
|
105
|
+
max_rounds: (options[:rounds] || config[:rounds] || 10).to_i,
|
|
106
|
+
executor: Executor.new(root:, timeout:, dry_run: !commit?(options),
|
|
107
|
+
instructions: options[:prompt], model: options[:model],
|
|
108
|
+
max_tokens: (options[:max_tokens] || config[:max_tokens])&.to_i,
|
|
109
|
+
interactive: !keyboard.nil? && commit?(options)), dry_run: !commit?(options),
|
|
110
|
+
publisher: publisher_for(root, isolation, options)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
started = UI.monotonic
|
|
114
|
+
rounds = begin
|
|
115
|
+
runner.call
|
|
116
|
+
ensure
|
|
117
|
+
keyboard&.stop
|
|
118
|
+
end
|
|
119
|
+
report(runner, rounds, options, seconds: UI.monotonic - started)
|
|
120
|
+
exit(failures(rounds).empty? ? 0 : 1)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# @param agents [Agentilda::Agents]
|
|
126
|
+
# @param name [String]
|
|
127
|
+
# @return [Agentilda::Agents] holding only the agent named — the
|
|
128
|
+
# restriction has to reach assignments, not just chaining, or a flag
|
|
129
|
+
# like `--prompt` aimed at one agent leaks to the whole round
|
|
130
|
+
def filtered(agents, name)
|
|
131
|
+
agents.find(name) or
|
|
132
|
+
refuse("No agent called #{name}.\n\nKnown: #{agents.all.map(&:name).join(", ")}", 65)
|
|
133
|
+
agents.only(name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Each name is checked against the full roster, not the possibly
|
|
137
|
+
# already-restricted one, so `--skip` misspelled is a refusal rather
|
|
138
|
+
# than a silent no-op — the same reasoning `--plan` applies to numbers.
|
|
139
|
+
#
|
|
140
|
+
# @param agents [Agentilda::Agents]
|
|
141
|
+
# @param text [String] comma-separated agent names
|
|
142
|
+
# @param restricted [String, nil] what `--agent` asked for, if anything
|
|
143
|
+
# @return [Agentilda::Agents] without the agents named
|
|
144
|
+
def skipped(agents, text, restricted)
|
|
145
|
+
roster = Agentilda::Agents.new
|
|
146
|
+
names = text.split(",").map(&:strip).reject(&:empty?)
|
|
147
|
+
names.each do |name|
|
|
148
|
+
roster.find(name) or
|
|
149
|
+
refuse("No agent called #{name}.\n\nKnown: #{roster.all.map(&:name).join(", ")}", 65)
|
|
150
|
+
if name == restricted
|
|
151
|
+
refuse("--agent #{name} and --skip #{name} contradict each other: " \
|
|
152
|
+
"one asks for only this agent, the other for anything but.", 64)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
agents.without(*names)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# `--plan` names the whole point of scoping: a skill that just minted
|
|
159
|
+
# N folders hands off exactly those, not the tree. A typo'd or already-
|
|
160
|
+
# settled number silently running the whole tree instead is the failure
|
|
161
|
+
# this exists to prevent, so an unknown one is refused rather than
|
|
162
|
+
# dropped.
|
|
163
|
+
#
|
|
164
|
+
# @param tree [Agentilda::Tree]
|
|
165
|
+
# @param text [String]
|
|
166
|
+
# @return [Array<Agentilda::Ordinal>]
|
|
167
|
+
def scoped(tree, text)
|
|
168
|
+
known = tree.subjects.map { |s| s.feature.ordinal }
|
|
169
|
+
text.split(",").map(&:strip).reject(&:empty?).map { |token|
|
|
170
|
+
ordinal = Ordinal.parse(token)
|
|
171
|
+
unless ordinal && known.include?(ordinal)
|
|
172
|
+
refuse("No plan #{token} in #{tree.dir}.\n\n" \
|
|
173
|
+
"Known: #{known.join(", ")}", 66)
|
|
174
|
+
end
|
|
175
|
+
ordinal
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# `--agent` narrowing the round to nobody used to exit 0 in silence —
|
|
180
|
+
# "0 plans addressed", green box, no hint that the agent named simply
|
|
181
|
+
# does not handle any in-scope plan's state. Someone who typed both an
|
|
182
|
+
# agent and a plan meant them to meet, so a pairing that cannot happen
|
|
183
|
+
# is refused up front with the agent that would actually take each plan.
|
|
184
|
+
#
|
|
185
|
+
# @param agents [Agentilda::Agents] already narrowed to the one agent
|
|
186
|
+
# @param tree [Agentilda::Tree]
|
|
187
|
+
# @param plans [Array<Agentilda::Ordinal>, nil]
|
|
188
|
+
# @param name [String]
|
|
189
|
+
# @return [void]
|
|
190
|
+
def assignable!(agents, tree, plans, name)
|
|
191
|
+
agent = agents.find(name)
|
|
192
|
+
active = tree.subjects.select { |s|
|
|
193
|
+
(plans.nil? || plans.include?(s.feature.ordinal)) &&
|
|
194
|
+
!StateMachine::SETTLED.include?(s.status.key)
|
|
195
|
+
}
|
|
196
|
+
return if active.empty? || active.any? { |s| agent.handles?(s.status) }
|
|
197
|
+
|
|
198
|
+
roster = Agentilda::Agents.new
|
|
199
|
+
lines = active.map { |s|
|
|
200
|
+
takers = roster.for_status(s.status).map(&:name)
|
|
201
|
+
" #{s.feature.ordinal} is #{s.status.emoji} #{s.status.label}" \
|
|
202
|
+
"#{" — #{takers.join(", ")} takes it" unless takers.empty?}"
|
|
203
|
+
}
|
|
204
|
+
handled = agent.handles.map { |k| Agentilda::STATUS_BY_KEY[k]&.then { |st| "#{st.emoji} #{st.label}" } || k }
|
|
205
|
+
refuse("#{name} handles #{handled.join(", ")}, and no plan in scope is there:\n\n" \
|
|
206
|
+
"#{lines.join("\n")}\n\nName the agent that takes these states, or drop --agent.", 65)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @param rounds [Array]
|
|
210
|
+
# @return [Array]
|
|
211
|
+
def failures(rounds) = rounds.flat_map(&:attempts).reject(&:ok)
|
|
212
|
+
|
|
213
|
+
# The one thing this harness does that leaves the machine — pushing a
|
|
214
|
+
# branch and opening its pull request — so it takes one more thing to
|
|
215
|
+
# turn on than everything else here: `--commit` alone is not enough,
|
|
216
|
+
# isolation has to actually give each plan a branch of its own too.
|
|
217
|
+
#
|
|
218
|
+
# `nil` is how {Runner} is told to leave a finished worktree alone; it
|
|
219
|
+
# is what `--dont-push-anything` asks for, and what a shared tree gets
|
|
220
|
+
# by construction, since there is no separate branch there to push.
|
|
221
|
+
#
|
|
222
|
+
# @param root [String]
|
|
223
|
+
# @param isolation [Symbol]
|
|
224
|
+
# @param options [Hash]
|
|
225
|
+
# @return [Agentilda::Publisher, nil]
|
|
226
|
+
def publisher_for(root, isolation, options)
|
|
227
|
+
return nil if isolation != :worktree || options[:dont_push_anything]
|
|
228
|
+
|
|
229
|
+
Publisher.new(root:, dry_run: !commit?(options))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @param runner [Agentilda::Runner]
|
|
233
|
+
# @param rounds [Array]
|
|
234
|
+
# @param options [Hash]
|
|
235
|
+
# @param seconds [Float] wall clock for the whole loop
|
|
236
|
+
# @return [void]
|
|
237
|
+
def report(runner, rounds, options, seconds: 0.0)
|
|
238
|
+
rounds.each do |round|
|
|
239
|
+
puts "round #{round.number}"
|
|
240
|
+
round.attempts.each do |a|
|
|
241
|
+
mark = if !a.ok
|
|
242
|
+
"FAIL"
|
|
243
|
+
elsif a.advanced?
|
|
244
|
+
"#{a.from} -> #{a.to}"
|
|
245
|
+
else
|
|
246
|
+
"no change"
|
|
247
|
+
end
|
|
248
|
+
puts " #{a.ordinal}\t#{a.agent}\t#{mark}\t#{a.note}"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# What the run cost, on STDOUT with the rounds it belongs to, so a run
|
|
253
|
+
# redirected to a file keeps its bill. A dry run spent nothing and gets
|
|
254
|
+
# none of this.
|
|
255
|
+
tally = Tally.new(attempts: rounds.flat_map(&:attempts), seconds:, rounds: rounds.size)
|
|
256
|
+
puts("", tally.render) if commit?(options)
|
|
257
|
+
|
|
258
|
+
return if quiet?(options)
|
|
259
|
+
|
|
260
|
+
if Control.quit?
|
|
261
|
+
warn("Stopped from the keyboard — q. Unfinished plans keep their state; " \
|
|
262
|
+
"re-run to resume where they stand.")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
advanced = rounds.sum(&:advanced)
|
|
266
|
+
blocked = runner.blocked
|
|
267
|
+
summary = ["#{rounds.size} round#{"s" unless rounds.size == 1}", "#{advanced} advanced"]
|
|
268
|
+
summary << (runner.isolated? ? "#{runner.jobs} at a time, one worktree each" : "serial, shared tree")
|
|
269
|
+
summary << "#{blocked.size} blocked" unless blocked.empty?
|
|
270
|
+
summary << "#{failures(rounds).size} failed" unless failures(rounds).empty?
|
|
271
|
+
|
|
272
|
+
if !commit?(options)
|
|
273
|
+
warn("Dry run — no agent was invoked.\n#{summary.join(" · ")}\n\nRe-run with --commit.")
|
|
274
|
+
elsif failures(rounds).empty?
|
|
275
|
+
success("#{summary.join(" · ")}#{"\n\nEvery plan is done or deliberately parked." if runner.settled?}")
|
|
276
|
+
else
|
|
277
|
+
error("#{summary.join(" · ")}\n\n#{failures(rounds).map { |f| "#{f.ordinal} #{f.agent}: #{f.note}" }.join("\n")}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
return if blocked.empty?
|
|
281
|
+
|
|
282
|
+
warn("#{blocked.size} plan#{"s" unless blocked.size == 1} need a human decision:\n" +
|
|
283
|
+
blocked.map { |b| " #{b.feature.ordinal} #{b.status.emoji} #{b.feature.title} — see blocked.md" }.join("\n") +
|
|
284
|
+
"\n\nWrite the answers into blocked.md, then: agentilda unblock " \
|
|
285
|
+
"#{blocked.map { |b| b.feature.ordinal }.join(",")} --commit")
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module CLI
|
|
5
|
+
# `agentilda states` — the state machine, as a terminal diagram.
|
|
6
|
+
class States < Dry::CLI::Command
|
|
7
|
+
desc "Print a diagram of every state and the transitions between them"
|
|
8
|
+
|
|
9
|
+
example [""]
|
|
10
|
+
|
|
11
|
+
# @return [void]
|
|
12
|
+
def call(**) = $stdout.write(Diagram.new.render)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|