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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/README.md +437 -37
  4. data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
  5. data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
  6. data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
  7. data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
  9. data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
  10. data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
  11. data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
  12. data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
  13. data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
  14. data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
  15. data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
  16. data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
  17. data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
  18. data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
  19. data/config/routes.rb +17 -0
  20. data/exe/plan-driven +1 -0
  21. data/lib/generators/plan_driven/install_generator.rb +7 -0
  22. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  23. data/lib/plan_driven/charts.rb +252 -0
  24. data/lib/plan_driven/cli/config_commands.rb +60 -0
  25. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  26. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  27. data/lib/plan_driven/cli/ticket_commands.rb +64 -3
  28. data/lib/plan_driven/cli/ui.rb +41 -2
  29. data/lib/plan_driven/cli.rb +21 -4
  30. data/lib/plan_driven/configuration.rb +31 -3
  31. data/lib/plan_driven/connections.rb +69 -0
  32. data/lib/plan_driven/cursor_agents.rb +10 -2
  33. data/lib/plan_driven/delivery.rb +23 -5
  34. data/lib/plan_driven/evidence.rb +11 -4
  35. data/lib/plan_driven/github.rb +4 -0
  36. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  37. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  38. data/lib/plan_driven/interview.rb +161 -0
  39. data/lib/plan_driven/local_agents.rb +254 -0
  40. data/lib/plan_driven/renderer/html.rb +34 -5
  41. data/lib/plan_driven/renderer/markdown.rb +67 -3
  42. data/lib/plan_driven/renderer/style.css +21 -0
  43. data/lib/plan_driven/renderer.rb +11 -4
  44. data/lib/plan_driven/statistics.rb +195 -0
  45. data/lib/plan_driven/template.rb +7 -2
  46. data/lib/plan_driven/usage.rb +114 -0
  47. data/lib/plan_driven/version.rb +1 -1
  48. data/lib/plan_driven/wizard/engine.rb +19 -0
  49. data/lib/plan_driven/wizard.rb +245 -0
  50. data/lib/plan_driven.rb +8 -1
  51. 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 Cursor agents."
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
- return ui.muted("Nothing started.") unless ui.confirm?("Start #{starting.size} Cursor cloud agent(s)?")
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.pr_url.to_s]
211
+ ticket.pr_number ? "##{ticket.pr_number}" : ""]
151
212
  end)
152
213
  end
153
214
  end
@@ -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
- widths = headers.each_index.map { |i| ([headers[i]] + rows.map { |row| row[i] }).map { |v| v.to_s.length }.max }
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)
@@ -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 agents"],
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
- attr_accessor :agent_model, :base_branch, :max_parallel_agents, :skip_reviewer_request
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
- @template ||= Template.default
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)
@@ -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 = TicketGenerator.new(llm: llm, schema: schema, config: config).generate(plan, instruction: instruction)
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: "cursor-agent", ticket: ticket, pr: run.pr_url)
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: "cursor-agent", ticket: ticket, status: run.status,
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 ||= LLM.new(config)
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 ||= CursorAgents.new(config: config)
377
+ @agents ||= Agents.build(config)
360
378
  end
361
379
  end
362
380
  end
@@ -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 = plan.evidence_runs.last&.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}`"
@@ -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