plan_driven 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -1
- data/README.md +437 -37
- data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
- data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
- data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
- data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
- data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
- data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
- data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
- data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
- data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
- data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
- data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
- data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
- data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
- data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
- data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
- data/config/routes.rb +17 -0
- data/exe/plan-driven +1 -0
- data/lib/generators/plan_driven/install_generator.rb +7 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
- data/lib/plan_driven/charts.rb +252 -0
- data/lib/plan_driven/cli/config_commands.rb +60 -0
- data/lib/plan_driven/cli/plan_commands.rb +6 -2
- data/lib/plan_driven/cli/setup_commands.rb +10 -0
- data/lib/plan_driven/cli/ticket_commands.rb +64 -3
- data/lib/plan_driven/cli/ui.rb +41 -2
- data/lib/plan_driven/cli.rb +21 -4
- data/lib/plan_driven/configuration.rb +31 -3
- data/lib/plan_driven/connections.rb +69 -0
- data/lib/plan_driven/cursor_agents.rb +10 -2
- data/lib/plan_driven/delivery.rb +23 -5
- data/lib/plan_driven/evidence.rb +11 -4
- data/lib/plan_driven/github.rb +4 -0
- data/lib/plan_driven/guards/migration_guard.rb +86 -9
- data/lib/plan_driven/guards/ticket_guard.rb +1 -2
- data/lib/plan_driven/interview.rb +161 -0
- data/lib/plan_driven/local_agents.rb +254 -0
- data/lib/plan_driven/renderer/html.rb +34 -5
- data/lib/plan_driven/renderer/markdown.rb +67 -3
- data/lib/plan_driven/renderer/style.css +21 -0
- data/lib/plan_driven/renderer.rb +11 -4
- data/lib/plan_driven/statistics.rb +195 -0
- data/lib/plan_driven/template.rb +7 -2
- data/lib/plan_driven/usage.rb +114 -0
- data/lib/plan_driven/version.rb +1 -1
- data/lib/plan_driven/wizard/engine.rb +19 -0
- data/lib/plan_driven/wizard.rb +245 -0
- data/lib/plan_driven.rb +8 -1
- metadata +32 -6
|
@@ -83,6 +83,7 @@ module PlanDriven
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
def check_cursor
|
|
86
|
+
return check_local_agents if PlanDriven.configuration.agent_provider.to_sym == :local
|
|
86
87
|
return unless Credentials.fetch(:cursor_api_key)
|
|
87
88
|
|
|
88
89
|
agents = CursorAgents.new
|
|
@@ -92,6 +93,15 @@ module PlanDriven
|
|
|
92
93
|
rescue Error => e
|
|
93
94
|
ui.error "Cursor API: #{e.message}"
|
|
94
95
|
end
|
|
96
|
+
|
|
97
|
+
def check_local_agents
|
|
98
|
+
command = PlanDriven.configuration.agent_command.to_s
|
|
99
|
+
return ui.error("Agents: local, but config.agent_command isn't set") if command.empty?
|
|
100
|
+
|
|
101
|
+
program = Shellwords.split(command).first
|
|
102
|
+
found = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, program)) }
|
|
103
|
+
found ? ui.success("Agents: local, #{command}") : ui.error("Agents: #{program} isn't on the PATH")
|
|
104
|
+
end
|
|
95
105
|
end
|
|
96
106
|
end
|
|
97
107
|
end
|
|
@@ -23,7 +23,7 @@ module PlanDriven
|
|
|
23
23
|
plan.tickets.select(&:issue_number).each do |ticket|
|
|
24
24
|
ui.muted " #{ticket.key} -> issue ##{ticket.issue_number}"
|
|
25
25
|
end
|
|
26
|
-
ui.say "Next: `plan-driven develop #{plan.key}` hands the ready tickets to
|
|
26
|
+
ui.say "Next: `plan-driven develop #{plan.key}` hands the ready tickets to the coding agents."
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def cmd_prompt(reference = nil)
|
|
@@ -36,7 +36,9 @@ module PlanDriven
|
|
|
36
36
|
return explain_waiting(plan) if starting.empty?
|
|
37
37
|
|
|
38
38
|
starting.each { |ticket| ui.say " #{ticket.key} #{ticket.title}" }
|
|
39
|
-
|
|
39
|
+
unless ui.confirm?("Start #{starting.size} #{PlanDriven.configuration.agent_provider} agent(s)?")
|
|
40
|
+
return ui.muted("Nothing started.")
|
|
41
|
+
end
|
|
40
42
|
|
|
41
43
|
delivery.develop(plan, only: starting.map(&:key)).each do |ticket|
|
|
42
44
|
ui.success "#{ticket.key} agent started: #{ticket.agent_url}"
|
|
@@ -123,6 +125,44 @@ module PlanDriven
|
|
|
123
125
|
run.passed? ? ui.success(Evidence.summary_line(plan)) : ui.warn("#{run.status}: #{Evidence.summary_line(plan)}")
|
|
124
126
|
end
|
|
125
127
|
|
|
128
|
+
def cmd_usage(reference = nil)
|
|
129
|
+
plan = find_plan(reference)
|
|
130
|
+
rows = Usage.rows(plan)
|
|
131
|
+
return ui.muted("No token usage recorded for #{plan.key} yet.") if rows.empty?
|
|
132
|
+
|
|
133
|
+
ui.table(%w[Step Ticket Model Tokens Time Cost], rows.map do |row|
|
|
134
|
+
[row.step.to_s, row.ticket.to_s, row.model.to_s, Usage.format_tokens(row.total_tokens),
|
|
135
|
+
row.duration_ms ? "#{(row.duration_ms / 60_000.0).round(1)} min" : "", Usage.format_cost(row.cost)]
|
|
136
|
+
end)
|
|
137
|
+
totals = Usage.totals(rows)
|
|
138
|
+
ui.say
|
|
139
|
+
ui.success "#{Usage.format_tokens(totals[:total_tokens])} tokens" \
|
|
140
|
+
"#{", #{Usage.format_cost(totals[:cost])}" if totals[:cost]}"
|
|
141
|
+
return if totals[:unpriced].empty?
|
|
142
|
+
|
|
143
|
+
ui.muted "No price for #{totals[:unpriced].join(", ")}; set config.token_prices to see dollars."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def cmd_stats(reference = nil)
|
|
147
|
+
plan = find_plan(reference)
|
|
148
|
+
stats = Statistics.new(plan)
|
|
149
|
+
duration = ->(seconds) { Statistics.duration(seconds) }
|
|
150
|
+
ui.heading "#{plan.key} #{plan.title}: statistics"
|
|
151
|
+
stats.phases.each { |label, seconds| ui.say " #{label.ljust(22)}#{seconds ? duration[seconds] : "not yet"}" }
|
|
152
|
+
return ui.muted("Ticket statistics start when the tickets are approved.") unless stats.started?
|
|
153
|
+
|
|
154
|
+
show_summary(stats.summary)
|
|
155
|
+
show_time_split(stats)
|
|
156
|
+
ui.say
|
|
157
|
+
ui.table(%w[# Est Queued Agent Review Fixes Merge Rounds Total], stats.tickets.map do |row|
|
|
158
|
+
[row.ticket.key, row.ticket.estimate.to_s,
|
|
159
|
+
*%w[queued agent review fixes merge].map do |phase|
|
|
160
|
+
row.seconds(phase).positive? ? duration[row.seconds(phase)] : "-"
|
|
161
|
+
end,
|
|
162
|
+
row.review_rounds.to_s, row.merged? ? duration[row.cycle_time] : row.ticket.status.tr("_", " ")]
|
|
163
|
+
end)
|
|
164
|
+
end
|
|
165
|
+
|
|
126
166
|
def cmd_report(reference = nil)
|
|
127
167
|
plan = find_plan(reference)
|
|
128
168
|
paths = delivery.report(plan)
|
|
@@ -132,6 +172,27 @@ module PlanDriven
|
|
|
132
172
|
|
|
133
173
|
private
|
|
134
174
|
|
|
175
|
+
def show_summary(summary)
|
|
176
|
+
ui.say " #{"Idea to delivery".ljust(22)}#{Statistics.duration(summary[:lead_time])}"
|
|
177
|
+
ui.say " #{"Tickets merged".ljust(22)}#{summary[:merged]} of #{summary[:tickets]}, " \
|
|
178
|
+
"#{summary[:first_time]} approved the first time"
|
|
179
|
+
proven = "#{summary[:proven]} of #{summary[:criteria]} acceptance criteria proven"
|
|
180
|
+
summary[:proven] == summary[:criteria] ? ui.success(proven) : ui.warn(proven)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def show_time_split(stats)
|
|
184
|
+
work = stats.work_seconds
|
|
185
|
+
return if work.zero?
|
|
186
|
+
|
|
187
|
+
ui.say
|
|
188
|
+
ui.say "Where the time went while tickets were worked on (agents #{stats.agent_share}%):"
|
|
189
|
+
stats.totals.slice(*Statistics::WORK).each do |phase, seconds|
|
|
190
|
+
share = seconds * 100.0 / work
|
|
191
|
+
ui.say " #{Statistics::PHASES[phase].ljust(22)}#{Statistics.duration(seconds).rjust(10)} " \
|
|
192
|
+
"#{"█" * (share / 4).ceil} #{share.round}%"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
135
196
|
def explain_waiting(plan)
|
|
136
197
|
running = plan.tickets.count(&:active_agent?)
|
|
137
198
|
if plan.tickets.any?(&:ready?) && running >= PlanDriven.configuration.max_parallel_agents
|
|
@@ -147,7 +208,7 @@ module PlanDriven
|
|
|
147
208
|
def show_tickets(tickets)
|
|
148
209
|
ui.table(%w[# Title Kind Pts Status PR], tickets.map do |ticket|
|
|
149
210
|
[ticket.key, ticket.title.truncate(48), ticket.kind, ticket.estimate, ticket.status.tr("_", " "),
|
|
150
|
-
ticket.
|
|
211
|
+
ticket.pr_number ? "##{ticket.pr_number}" : ""]
|
|
151
212
|
end)
|
|
152
213
|
end
|
|
153
214
|
end
|
data/lib/plan_driven/cli/ui.rb
CHANGED
|
@@ -42,7 +42,8 @@ module PlanDriven
|
|
|
42
42
|
answer.empty? ? default.to_s : answer
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# Several lines, finished by an empty line.
|
|
45
|
+
# Several lines, finished by an empty line. Piped answers (from the wizard, or a script) are
|
|
46
|
+
# echoed, so the transcript reads like a typed interview.
|
|
46
47
|
def ask_multiline(prompt)
|
|
47
48
|
say paint(prompt, :cyan)
|
|
48
49
|
muted " (finish with an empty line)"
|
|
@@ -50,6 +51,7 @@ module PlanDriven
|
|
|
50
51
|
loop do
|
|
51
52
|
output.print " > "
|
|
52
53
|
line = read_line(nil)
|
|
54
|
+
output.puts(line.to_s.rstrip) if piped?
|
|
53
55
|
break if line.nil? || line.strip.empty?
|
|
54
56
|
|
|
55
57
|
lines << line.rstrip
|
|
@@ -83,8 +85,12 @@ module PlanDriven
|
|
|
83
85
|
success ok_message if report.errors.empty? && report.warnings.empty?
|
|
84
86
|
end
|
|
85
87
|
|
|
88
|
+
MIN_COLUMN = 16
|
|
89
|
+
|
|
90
|
+
# The widest columns are cut to fit the terminal's width, so rows don't wrap.
|
|
86
91
|
def table(headers, rows)
|
|
87
|
-
|
|
92
|
+
rows = fit_columns(headers, rows)
|
|
93
|
+
widths = column_widths(headers, rows)
|
|
88
94
|
line = ->(cells) { cells.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ") }
|
|
89
95
|
say paint(line.call(headers), :bold)
|
|
90
96
|
rows.each { |row| say line.call(row) }
|
|
@@ -92,6 +98,39 @@ module PlanDriven
|
|
|
92
98
|
|
|
93
99
|
private
|
|
94
100
|
|
|
101
|
+
def column_widths(headers, rows)
|
|
102
|
+
headers.each_index.map { |i| ([headers[i]] + rows.map { |row| row[i] }).map { |v| v.to_s.length }.max }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Cuts the widest columns short, never below MIN_COLUMN, until the table fits the terminal.
|
|
106
|
+
def fit_columns(headers, rows)
|
|
107
|
+
columns = terminal_width or return rows
|
|
108
|
+
widths = column_widths(headers, rows)
|
|
109
|
+
room = columns - (2 * (widths.size - 1)) - 1
|
|
110
|
+
while widths.sum > room
|
|
111
|
+
widest = widths.each_index.max_by { |i| widths[i] }
|
|
112
|
+
cut = [widths[widest] - (widths.sum - room), MIN_COLUMN, headers[widest].length].max
|
|
113
|
+
break if cut >= widths[widest]
|
|
114
|
+
|
|
115
|
+
widths[widest] = cut
|
|
116
|
+
end
|
|
117
|
+
rows.map { |row| row.each_with_index.map { |cell, i| cell.is_a?(String) ? cell.truncate(widths[i]) : cell } }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The terminal's width, or COLUMNS when the output isn't a terminal (the wizard sets it).
|
|
121
|
+
def terminal_width
|
|
122
|
+
if output.respond_to?(:tty?) && output.tty? && output.respond_to?(:winsize)
|
|
123
|
+
output.winsize[1].then { |columns| return columns if columns.positive? }
|
|
124
|
+
end
|
|
125
|
+
ENV["COLUMNS"].to_i.then { |columns| columns.positive? ? columns : nil }
|
|
126
|
+
rescue StandardError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def piped?
|
|
131
|
+
!(input.respond_to?(:tty?) && input.tty?)
|
|
132
|
+
end
|
|
133
|
+
|
|
95
134
|
def read_line(at_end = "")
|
|
96
135
|
line = input.gets
|
|
97
136
|
line.nil? ? at_end : PlanDriven.utf8(line)
|
data/lib/plan_driven/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "cli/ui"
|
|
|
7
7
|
require_relative "cli/plan_commands"
|
|
8
8
|
require_relative "cli/ticket_commands"
|
|
9
9
|
require_relative "cli/setup_commands"
|
|
10
|
+
require_relative "cli/config_commands"
|
|
10
11
|
|
|
11
12
|
module PlanDriven
|
|
12
13
|
# bundle exec plan-driven <command> [arguments]
|
|
@@ -14,12 +15,13 @@ module PlanDriven
|
|
|
14
15
|
include PlanCommands
|
|
15
16
|
include TicketCommands
|
|
16
17
|
include SetupCommands
|
|
18
|
+
include ConfigCommands
|
|
17
19
|
|
|
18
20
|
COMMANDS = {
|
|
19
21
|
"new" => ["TITLE", "Interview in the terminal, then draft the plan from your answers and the schema"],
|
|
20
22
|
"list" => ["", "Every plan and its phase"],
|
|
21
23
|
"show" => ["PLAN", "Print the plan, or one section with --section"],
|
|
22
|
-
"edit" => ["PLAN SECTION", "Edit a section in $EDITOR"],
|
|
24
|
+
"edit" => ["PLAN SECTION", "Edit a section in $EDITOR, or replace it with --from FILE"],
|
|
23
25
|
"redraft" => ["PLAN SECTION \"instruction\"", "Have the model rewrite one section"],
|
|
24
26
|
"check" => ["PLAN", "Run the plan guards"],
|
|
25
27
|
"submit" => ["PLAN", "Send the plan for approval (guards must pass)"],
|
|
@@ -29,7 +31,7 @@ module PlanDriven
|
|
|
29
31
|
"tickets" => ["PLAN [\"instruction\"]", "Draft tickets from the approved plan, or redraft them"],
|
|
30
32
|
"approve-tickets" => ["PLAN", "Approve the tickets (--as ROLE); creates GitHub issues"],
|
|
31
33
|
"prompt" => ["PLAN/TICKET", "Show what the agent will be told"],
|
|
32
|
-
"develop" => ["PLAN [TICKET...]", "Hand ready tickets to Cursor cloud
|
|
34
|
+
"develop" => ["PLAN [TICKET...]", "Hand ready tickets to the coding agents (Cursor cloud, or local)"],
|
|
33
35
|
"status" => ["PLAN", "Poll agents and pull requests, then show every ticket"],
|
|
34
36
|
"review" => ["PLAN/TICKET", "Run the pull request guards"],
|
|
35
37
|
"approve-pr" => ["PLAN/TICKET", "Approve the pull request (guards must pass)"],
|
|
@@ -38,11 +40,16 @@ module PlanDriven
|
|
|
38
40
|
"evidence" => ["PLAN", "Run the plan's Cucumber scenarios and record the results (--from FILE)"],
|
|
39
41
|
"report" => ["PLAN", "Write the delivery report"],
|
|
40
42
|
"log" => ["PLAN", "The audit trail"],
|
|
43
|
+
"usage" => ["PLAN", "Tokens and cost per step and per agent run"],
|
|
44
|
+
"stats" => ["PLAN", "Where the time went: phases, agents and people, each ticket"],
|
|
45
|
+
"questions" => ["", "The interview's questions, with the team's changes"],
|
|
46
|
+
"question" => ["KEY", "Change or add a question (--title, --ask, --group, --required, --optional, --remove)"],
|
|
41
47
|
"configure" => ["", "Store API keys in ~/.plan_driven/config"],
|
|
48
|
+
"connect" => ["SERVICE", "Check a key with cursor, openai, anthropic or github, then store it"],
|
|
42
49
|
"doctor" => ["", "Check keys, repository and connections"]
|
|
43
50
|
}.freeze
|
|
44
51
|
|
|
45
|
-
NO_APP = %w[configure doctor help version].freeze
|
|
52
|
+
NO_APP = %w[configure connect doctor help version].freeze
|
|
46
53
|
|
|
47
54
|
# Plans are UTF-8 whatever the terminal's locale says, so answers typed with č or ž under
|
|
48
55
|
# LANG=C are read as text, not bytes.
|
|
@@ -64,7 +71,7 @@ module PlanDriven
|
|
|
64
71
|
@ui = UI.new(input: @input, output: @output, assume_yes: options[:yes])
|
|
65
72
|
@options = options
|
|
66
73
|
return help if %w[help -h --help].include?(command)
|
|
67
|
-
return @ui.say(PlanDriven::VERSION) if %w[version -v --version].include?(command)
|
|
74
|
+
return @ui.say(PlanDriven::VERSION) || 0 if %w[version -v --version].include?(command)
|
|
68
75
|
raise Error, "Unknown command `#{command}`. `plan-driven help` lists them." unless COMMANDS.key?(command)
|
|
69
76
|
|
|
70
77
|
boot_application unless NO_APP.include?(command)
|
|
@@ -109,10 +116,20 @@ module PlanDriven
|
|
|
109
116
|
parser.on("--section KEY") { |value| options[:section] = value }
|
|
110
117
|
parser.on("--from FILE") { |value| options[:from] = value }
|
|
111
118
|
parser.on("-y", "--yes") { options[:yes] = true }
|
|
119
|
+
question_options(parser, options)
|
|
112
120
|
end.parse!(argv)
|
|
113
121
|
options
|
|
114
122
|
end
|
|
115
123
|
|
|
124
|
+
def question_options(parser, options)
|
|
125
|
+
parser.on("--title TEXT") { |value| options[:title] = value }
|
|
126
|
+
parser.on("--ask TEXT") { |value| options[:ask] = value }
|
|
127
|
+
parser.on("--group NAME") { |value| options[:group] = value }
|
|
128
|
+
parser.on("--required") { options[:required] = true }
|
|
129
|
+
parser.on("--optional") { options[:required] = false }
|
|
130
|
+
parser.on("--remove") { options[:remove] = true }
|
|
131
|
+
end
|
|
132
|
+
|
|
116
133
|
def boot_application
|
|
117
134
|
return unless @boot
|
|
118
135
|
return if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.initialized?
|
|
@@ -26,8 +26,13 @@ module PlanDriven
|
|
|
26
26
|
# Where documentation is written, relative to the application root.
|
|
27
27
|
attr_accessor :docs_path, :root
|
|
28
28
|
|
|
29
|
-
# Cursor cloud agents
|
|
30
|
-
|
|
29
|
+
# Coding agents: :cursor (Cursor cloud agents), or :local to run a command such as Claude Code
|
|
30
|
+
# or Codex on this machine, one git worktree per ticket (see LocalAgents).
|
|
31
|
+
attr_accessor :agent_provider, :agent_command, :agent_model, :base_branch, :max_parallel_agents,
|
|
32
|
+
:skip_reviewer_request, :agent_timeout
|
|
33
|
+
|
|
34
|
+
# Dollars per million tokens, by model id, for the cost in the delivery report (see Usage).
|
|
35
|
+
attr_accessor :token_prices
|
|
31
36
|
|
|
32
37
|
# GitHub.
|
|
33
38
|
attr_accessor :github_repository, :sync_issues, :merge_method, :issue_labels
|
|
@@ -45,11 +50,29 @@ module PlanDriven
|
|
|
45
50
|
# Anything the LLM should know that the schema doesn't show.
|
|
46
51
|
attr_accessor :extra_context
|
|
47
52
|
|
|
53
|
+
# The browser wizard at /plan_driven runs commands on this machine. nil means development
|
|
54
|
+
# only; it answers local requests either way.
|
|
55
|
+
attr_accessor :wizard_enabled
|
|
56
|
+
|
|
48
57
|
# The plan's sections. Template.default mirrors the usual Confluence implementation plan.
|
|
49
58
|
attr_writer :template
|
|
50
59
|
|
|
60
|
+
# The template with the team's interview changes (config/plan_driven/interview.yml) applied.
|
|
51
61
|
def template
|
|
52
|
-
|
|
62
|
+
file = Interview.path(root_path)
|
|
63
|
+
stamp = [base_template.object_id, file.to_s, file.exist? && file.mtime]
|
|
64
|
+
unless @interview_stamp == stamp
|
|
65
|
+
@interview_template = Interview.apply(base_template, root_path)
|
|
66
|
+
@interview_stamp = stamp
|
|
67
|
+
end
|
|
68
|
+
@interview_template
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The template as the initializer set it, before the interview changes.
|
|
72
|
+
def base_template
|
|
73
|
+
return @template if @template
|
|
74
|
+
|
|
75
|
+
@template = Template.default
|
|
53
76
|
end
|
|
54
77
|
|
|
55
78
|
def initialize
|
|
@@ -68,10 +91,14 @@ module PlanDriven
|
|
|
68
91
|
@docs_path = "docs/plans"
|
|
69
92
|
@root = nil
|
|
70
93
|
|
|
94
|
+
@agent_provider = :cursor
|
|
95
|
+
@agent_command = nil
|
|
71
96
|
@agent_model = nil
|
|
72
97
|
@base_branch = "main"
|
|
73
98
|
@max_parallel_agents = 3
|
|
74
99
|
@skip_reviewer_request = false
|
|
100
|
+
@agent_timeout = 3600
|
|
101
|
+
@token_prices = {}
|
|
75
102
|
|
|
76
103
|
@github_repository = nil
|
|
77
104
|
@sync_issues = true
|
|
@@ -89,6 +116,7 @@ module PlanDriven
|
|
|
89
116
|
@team_rules = []
|
|
90
117
|
@pdf_renderer = nil
|
|
91
118
|
@extra_context = nil
|
|
119
|
+
@wizard_enabled = nil
|
|
92
120
|
end
|
|
93
121
|
|
|
94
122
|
def llm_model
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# The services plan_driven talks to, which of them this app's configuration uses, and a check
|
|
5
|
+
# that a key works before `plan-driven connect` stores it.
|
|
6
|
+
module Connections
|
|
7
|
+
Service = Struct.new(:name, :title, :key, :purpose, :url, keyword_init: true) do
|
|
8
|
+
def env
|
|
9
|
+
Credentials::KEYS.fetch(key)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
SERVICES = [
|
|
14
|
+
Service.new(name: "cursor", title: "Cursor", key: "cursor_api_key",
|
|
15
|
+
purpose: "Cloud agents, one per ticket, and drafting with llm_provider :cursor",
|
|
16
|
+
url: "https://cursor.com/dashboard?tab=integrations"),
|
|
17
|
+
Service.new(name: "openai", title: "OpenAI", key: "openai_api_key",
|
|
18
|
+
purpose: "Drafting plans and tickets with llm_provider :openai",
|
|
19
|
+
url: "https://platform.openai.com/api-keys"),
|
|
20
|
+
Service.new(name: "anthropic", title: "Anthropic", key: "anthropic_api_key",
|
|
21
|
+
purpose: "Drafting plans and tickets with llm_provider :anthropic",
|
|
22
|
+
url: "https://console.anthropic.com/settings/keys"),
|
|
23
|
+
Service.new(name: "github", title: "GitHub", key: "github_token",
|
|
24
|
+
purpose: "Issues, pull requests, reviews and merges",
|
|
25
|
+
url: "https://github.com/settings/personal-access-tokens")
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def find(name)
|
|
31
|
+
SERVICES.find { |service| service.name == name.to_s.downcase } or
|
|
32
|
+
raise ArgumentError, "Connect what? One of: #{SERVICES.map(&:name).join(", ")}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The services this app needs with its current configuration.
|
|
36
|
+
def needed(config = PlanDriven.configuration)
|
|
37
|
+
names = ["github"]
|
|
38
|
+
names << "cursor" if config.agent_provider.to_sym == :cursor || config.llm_provider.to_sym == :cursor
|
|
39
|
+
names << config.llm_provider.to_s if %i[openai anthropic].include?(config.llm_provider.to_sym)
|
|
40
|
+
SERVICES.select { |service| names.include?(service.name) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Who the key belongs to, or a ProviderError when the service refuses it.
|
|
44
|
+
def verify(service, key, config = PlanDriven.configuration)
|
|
45
|
+
case service.name
|
|
46
|
+
when "cursor"
|
|
47
|
+
info = CursorAgents.new(api_key: key, config: config).me
|
|
48
|
+
"connected as #{info["userEmail"] || info["apiKeyName"] || "this key"}"
|
|
49
|
+
when "github"
|
|
50
|
+
headers = { "Authorization" => "Bearer #{key}", "Accept" => "application/vnd.github+json" }
|
|
51
|
+
"connected as #{get(service, "https://api.github.com/user", headers)["login"]}"
|
|
52
|
+
when "openai"
|
|
53
|
+
base = config.llm_provider.to_sym == :openai && config.llm_api_base ? config.llm_api_base : "https://api.openai.com/v1"
|
|
54
|
+
get(service, "#{base.chomp("/")}/models", "Authorization" => "Bearer #{key}")
|
|
55
|
+
"the key works"
|
|
56
|
+
when "anthropic"
|
|
57
|
+
get(service, "https://api.anthropic.com/v1/models", "x-api-key" => key, "anthropic-version" => "2023-06-01")
|
|
58
|
+
"the key works"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get(service, url, headers)
|
|
63
|
+
response = HTTP.request(:get, url, headers: headers.merge("User-Agent" => "plan_driven"), timeout: 30)
|
|
64
|
+
return response.json if response.success?
|
|
65
|
+
|
|
66
|
+
raise ProviderError, "#{service.title} refused the key (HTTP #{response.status})"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -10,7 +10,7 @@ module PlanDriven
|
|
|
10
10
|
BASE = "https://api.cursor.com/v1"
|
|
11
11
|
TERMINAL = %w[FINISHED ERROR CANCELLED EXPIRED].freeze
|
|
12
12
|
|
|
13
|
-
Run = Struct.new(:id, :agent_id, :status, :result, :branch, :pr_url, keyword_init: true) do
|
|
13
|
+
Run = Struct.new(:id, :agent_id, :status, :result, :branch, :pr_url, :duration_ms, keyword_init: true) do
|
|
14
14
|
def terminal?
|
|
15
15
|
TERMINAL.include?(status)
|
|
16
16
|
end
|
|
@@ -48,6 +48,14 @@ module PlanDriven
|
|
|
48
48
|
to_run(request(:post, "/agents/#{agent_id}/runs", { prompt: { text: text } }).fetch("run"))
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Tokens one run spent, in Usage's field names.
|
|
52
|
+
def usage(agent_id, run_id)
|
|
53
|
+
data = request(:get, "/agents/#{agent_id}/usage?runId=#{run_id}")
|
|
54
|
+
tokens = Array(data["runs"]).first.to_h["usage"] || data["totalUsage"] || {}
|
|
55
|
+
{ "input_tokens" => tokens["inputTokens"].to_i, "output_tokens" => tokens["outputTokens"].to_i,
|
|
56
|
+
"cache_write_tokens" => tokens["cacheWriteTokens"].to_i, "cache_read_tokens" => tokens["cacheReadTokens"].to_i }
|
|
57
|
+
end
|
|
58
|
+
|
|
51
59
|
def me
|
|
52
60
|
request(:get, "/me")
|
|
53
61
|
end
|
|
@@ -62,7 +70,7 @@ module PlanDriven
|
|
|
62
70
|
def to_run(data)
|
|
63
71
|
branch = Array(data.dig("git", "branches")).first || {}
|
|
64
72
|
Run.new(id: data["id"], agent_id: data["agentId"], status: data["status"], result: data["result"],
|
|
65
|
-
branch: branch["branch"], pr_url: branch["prUrl"])
|
|
73
|
+
branch: branch["branch"], pr_url: branch["prUrl"], duration_ms: data["durationMs"])
|
|
66
74
|
end
|
|
67
75
|
|
|
68
76
|
def request(method, path, body = nil)
|
data/lib/plan_driven/delivery.rb
CHANGED
|
@@ -24,6 +24,7 @@ module PlanDriven
|
|
|
24
24
|
guard_report: result.report.to_h)
|
|
25
25
|
plan.log!("plan.drafted", actor: actor, model: llm.label, attempts: result.attempts,
|
|
26
26
|
assumptions: result.assumptions)
|
|
27
|
+
Usage.record_llm(plan, llm, "plan drafted", actor: actor)
|
|
27
28
|
Renderer.write_plan(plan, config: config)
|
|
28
29
|
[plan, result]
|
|
29
30
|
end
|
|
@@ -43,6 +44,7 @@ module PlanDriven
|
|
|
43
44
|
def redraft_section(plan, key, instruction)
|
|
44
45
|
text = drafter.redraft(plan.sections, key, instruction)
|
|
45
46
|
edit_section(plan, key, text)
|
|
47
|
+
Usage.record_llm(plan, llm, "#{key} redrafted", actor: actor)
|
|
46
48
|
text
|
|
47
49
|
end
|
|
48
50
|
|
|
@@ -76,7 +78,11 @@ module PlanDriven
|
|
|
76
78
|
|
|
77
79
|
def draft_tickets(plan, instruction: nil)
|
|
78
80
|
require_status!(plan, %w[approved ticketed])
|
|
79
|
-
result =
|
|
81
|
+
result = begin
|
|
82
|
+
TicketGenerator.new(llm: llm, schema: schema, config: config).generate(plan, instruction: instruction)
|
|
83
|
+
ensure
|
|
84
|
+
Usage.record_llm(plan, llm, instruction ? "tickets redrafted" : "tickets drafted", actor: actor)
|
|
85
|
+
end
|
|
80
86
|
raise GuardError, result.report.errors unless result.report.ok?
|
|
81
87
|
|
|
82
88
|
replace_tickets(plan, result)
|
|
@@ -190,6 +196,7 @@ module PlanDriven
|
|
|
190
196
|
method: config.merge_method)
|
|
191
197
|
ticket.update!(merged_sha: result["sha"])
|
|
192
198
|
Workflow.ticket_transition!(ticket, "merged")
|
|
199
|
+
clean_up_agent(ticket)
|
|
193
200
|
ticket.plan.log!("ticket.merged", actor: actor, ticket: ticket, sha: result["sha"])
|
|
194
201
|
finish(ticket.plan)
|
|
195
202
|
ticket
|
|
@@ -233,13 +240,14 @@ module PlanDriven
|
|
|
233
240
|
run = agents.run(ticket.agent_id, ticket.agent_run_id)
|
|
234
241
|
return unless run.terminal?
|
|
235
242
|
|
|
243
|
+
Usage.record_agent(ticket, run, agents, actor: actor, config: config)
|
|
236
244
|
if run.finished? && run.pr_url
|
|
237
245
|
ticket.update!(pr_url: run.pr_url, pr_number: GitHub.pr_number(run.pr_url), branch: run.branch)
|
|
238
246
|
Workflow.ticket_transition!(ticket, "pr_open")
|
|
239
|
-
ticket.plan.log!("ticket.pr_opened", actor:
|
|
247
|
+
ticket.plan.log!("ticket.pr_opened", actor: agent_actor, ticket: ticket, pr: run.pr_url)
|
|
240
248
|
else
|
|
241
249
|
Workflow.ticket_transition!(ticket, "failed")
|
|
242
|
-
ticket.plan.log!("ticket.agent_failed", actor:
|
|
250
|
+
ticket.plan.log!("ticket.agent_failed", actor: agent_actor, ticket: ticket, status: run.status,
|
|
243
251
|
result: run.result.to_s[0, 500])
|
|
244
252
|
end
|
|
245
253
|
end
|
|
@@ -250,6 +258,7 @@ module PlanDriven
|
|
|
250
258
|
|
|
251
259
|
ticket.update!(merged_sha: pull["merge_commit_sha"])
|
|
252
260
|
Workflow.ticket_transition!(ticket, "merged")
|
|
261
|
+
clean_up_agent(ticket)
|
|
253
262
|
ticket.plan.log!("ticket.merged", actor: pull.dig("merged_by", "login") || "github", ticket: ticket,
|
|
254
263
|
sha: pull["merge_commit_sha"], outside_plan_driven: true)
|
|
255
264
|
end
|
|
@@ -262,6 +271,14 @@ module PlanDriven
|
|
|
262
271
|
plan.log!("plan.delivered", actor: actor)
|
|
263
272
|
end
|
|
264
273
|
|
|
274
|
+
def clean_up_agent(ticket)
|
|
275
|
+
agents.cleanup(ticket.agent_id) if ticket.agent_id && agents.respond_to?(:cleanup)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def agent_actor
|
|
279
|
+
"#{config.agent_provider}-agent"
|
|
280
|
+
end
|
|
281
|
+
|
|
265
282
|
def feature_files(files, sha)
|
|
266
283
|
files.select { |file| file["filename"].end_with?(".feature") && file["status"] != "removed" }
|
|
267
284
|
.to_h { |file| [file["filename"], github.file(file["filename"], ref: sha)] }
|
|
@@ -344,7 +361,8 @@ module PlanDriven
|
|
|
344
361
|
end
|
|
345
362
|
|
|
346
363
|
def llm
|
|
347
|
-
@llm
|
|
364
|
+
@llm = MeteredLLM.new(@llm || LLM.new(config)) unless @llm.is_a?(MeteredLLM)
|
|
365
|
+
@llm
|
|
348
366
|
end
|
|
349
367
|
|
|
350
368
|
def schema
|
|
@@ -356,7 +374,7 @@ module PlanDriven
|
|
|
356
374
|
end
|
|
357
375
|
|
|
358
376
|
def agents
|
|
359
|
-
@agents ||=
|
|
377
|
+
@agents ||= Agents.build(config)
|
|
360
378
|
end
|
|
361
379
|
end
|
|
362
380
|
end
|
data/lib/plan_driven/evidence.rb
CHANGED
|
@@ -17,6 +17,13 @@ module PlanDriven
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Scenarios commit and wipe data, so they never run against the development database, even
|
|
21
|
+
# when plan-driven itself was started from a development server (the wizard).
|
|
22
|
+
CUCUMBER_ENV = { "RAILS_ENV" => "test", "RACK_ENV" => "test" }.freeze
|
|
23
|
+
|
|
24
|
+
# Shown before each result in the Markdown report, so it reads at a glance on GitHub too.
|
|
25
|
+
MARKS = { "passed" => "✅", "failed" => "❌", "not run" => "⏸️", "no scenario" => "⚠️" }.freeze
|
|
26
|
+
|
|
20
27
|
module_function
|
|
21
28
|
|
|
22
29
|
def tag_expression(plan)
|
|
@@ -29,7 +36,7 @@ module PlanDriven
|
|
|
29
36
|
shown = command&.join(" ") || %(bundle exec cucumber --tags "#{tag_expression(plan)}")
|
|
30
37
|
command ||= ["bundle", "exec", "cucumber", "--tags", tag_expression(plan), "--format", "json", "--out", out,
|
|
31
38
|
"--format", "progress"]
|
|
32
|
-
output, status = Open3.capture2e(*command, chdir: root.to_s)
|
|
39
|
+
output, status = Open3.capture2e(CUCUMBER_ENV, *command, chdir: root.to_s)
|
|
33
40
|
json = File.exist?(out) ? File.read(out) : "[]"
|
|
34
41
|
record(plan, json, command: shown, actor: actor, exit_ok: status.success?, output: output)
|
|
35
42
|
end
|
|
@@ -72,8 +79,8 @@ module PlanDriven
|
|
|
72
79
|
"not run"
|
|
73
80
|
end
|
|
74
81
|
|
|
75
|
-
def matrix(plan)
|
|
76
|
-
scenarios =
|
|
82
|
+
def matrix(plan, run = plan.evidence_runs.last)
|
|
83
|
+
scenarios = run&.scenarios || []
|
|
77
84
|
plan.tickets.flat_map do |ticket|
|
|
78
85
|
ticket.criteria.each_with_index.map do |criterion, index|
|
|
79
86
|
matching = scenarios.select do |scenario|
|
|
@@ -90,7 +97,7 @@ module PlanDriven
|
|
|
90
97
|
|
|
91
98
|
rows = matrix(plan).map do |row|
|
|
92
99
|
scenario = row.scenarios.map { |s| "#{s["name"]} (`#{s["file"]}`)" }.join("; ").presence || "-"
|
|
93
|
-
["#{row.ticket.key}.#{row.number}", row.criterion, scenario, row.status]
|
|
100
|
+
["#{row.ticket.key}.#{row.number}", row.criterion, scenario, "#{MARKS[row.status]} #{row.status}"]
|
|
94
101
|
end
|
|
95
102
|
commit = run.commit_sha.present? ? " on commit `#{run.commit_sha[0, 7]}`" : ""
|
|
96
103
|
intro = "Cucumber, run #{run.created_at.strftime("%-d %b %Y %H:%M")}#{commit}: `#{run.command}`"
|
data/lib/plan_driven/github.rb
CHANGED
|
@@ -33,6 +33,10 @@ module PlanDriven
|
|
|
33
33
|
request(:post, "/repos/#{repository}/issues/#{number}/comments", { body: body })
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def create_pull(title:, head:, base:, body:)
|
|
37
|
+
request(:post, "/repos/#{repository}/pulls", { title: title, head: head, base: base, body: body })
|
|
38
|
+
end
|
|
39
|
+
|
|
36
40
|
def pull(number)
|
|
37
41
|
request(:get, "/repos/#{repository}/pulls/#{number}")
|
|
38
42
|
end
|