plan_driven 0.1.0 → 0.2.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/README.md +339 -31
  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 +102 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +139 -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 +24 -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 +36 -0
  18. data/config/routes.rb +16 -0
  19. data/exe/plan-driven +1 -0
  20. data/lib/generators/plan_driven/install_generator.rb +7 -0
  21. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  22. data/lib/plan_driven/cli/config_commands.rb +60 -0
  23. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  24. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  25. data/lib/plan_driven/cli/ticket_commands.rb +23 -3
  26. data/lib/plan_driven/cli/ui.rb +41 -2
  27. data/lib/plan_driven/cli.rb +20 -4
  28. data/lib/plan_driven/configuration.rb +31 -3
  29. data/lib/plan_driven/connections.rb +69 -0
  30. data/lib/plan_driven/cursor_agents.rb +10 -2
  31. data/lib/plan_driven/delivery.rb +23 -5
  32. data/lib/plan_driven/evidence.rb +5 -1
  33. data/lib/plan_driven/github.rb +4 -0
  34. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  35. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  36. data/lib/plan_driven/interview.rb +161 -0
  37. data/lib/plan_driven/local_agents.rb +254 -0
  38. data/lib/plan_driven/renderer/markdown.rb +26 -1
  39. data/lib/plan_driven/template.rb +7 -2
  40. data/lib/plan_driven/usage.rb +114 -0
  41. data/lib/plan_driven/version.rb +1 -1
  42. data/lib/plan_driven/wizard/engine.rb +19 -0
  43. data/lib/plan_driven/wizard.rb +245 -0
  44. data/lib/plan_driven.rb +5 -0
  45. metadata +23 -1
@@ -0,0 +1,31 @@
1
+ <% content_for :title, "plan_driven" %>
2
+ <h1>Implementation plans</h1>
3
+ <p class="muted">From an idea to merged pull requests, one step at a time. Each button runs the same
4
+ <code>plan-driven</code> command you could type yourself; the panel on the right shows it.</p>
5
+
6
+ <div class="row">
7
+ <%= link_to "New plan", new_plan_path, class: "button primary" %>
8
+ <%= button_to "Check the setup", run_path(do: "doctor"), form: { style: "display:inline" } %>
9
+ </div>
10
+
11
+ <% if @plans.any? %>
12
+ <div class="card">
13
+ <table>
14
+ <thead><tr><th>Plan</th><th>Title</th><th>Phase</th><th>Revision</th><th>Tickets</th><th></th></tr></thead>
15
+ <tbody>
16
+ <% @plans.each do |plan| %>
17
+ <tr>
18
+ <td><strong><%= plan.key %></strong></td>
19
+ <td><%= plan.title %></td>
20
+ <td><span class="pill <%= plan.status == "delivered" ? "good" : "wait" %>"><%= plan.status.tr("_", " ") %></span></td>
21
+ <td><%= plan.revision %></td>
22
+ <td><%= plan.tickets.size %></td>
23
+ <td><%= link_to "Open", plan_path(plan.key) %></td>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ </div>
29
+ <% else %>
30
+ <div class="card muted">No plans yet. Start with “New plan”.</div>
31
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <% content_for :title, "New plan" %>
2
+ <h1>New plan</h1>
3
+ <p class="muted">The same questions <code>plan-driven new</code> asks in the terminal. Your answers go to the
4
+ command as its input; the model drafts the rest from them and your schema, then the guards check it.</p>
5
+
6
+ <%= form_with url: plans_path, method: :post do |f| %>
7
+ <label for="title">Title of the change</label>
8
+ <%= f.text_field :title, value: params[:title], id: "title", required: true %>
9
+
10
+ <% @template.asked.each do |section| %>
11
+ <label for="answer_<%= section.key %>"><%= section.title %>
12
+ <small><%= section.prompt %></small></label>
13
+ <%= text_area_tag "answers[#{section.key}]", params.dig(:answers, section.key), id: "answer_#{section.key}",
14
+ rows: section.required ? 4 : 2 %>
15
+ <% end %>
16
+
17
+ <div class="nav">
18
+ <%= link_to "← Back", root_path, class: "button" %>
19
+ <button type="submit" class="primary">Draft the plan →</button>
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,36 @@
1
+ <% content_for :title, "#{@plan.key} #{@plan.title}" %>
2
+ <p class="muted" style="margin:0"><%= link_to "All plans", root_path %></p>
3
+ <h1><%= @plan.key %> · <%= @plan.title %></h1>
4
+ <p class="muted" style="margin:0">
5
+ <span class="pill <%= @plan.status == "delivered" ? "good" : "wait" %>"><%= @plan.status.tr("_", " ") %></span>
6
+ revision <%= @plan.revision %> · <%= PlanDriven::Workflow::PLAN_DESCRIPTIONS[@plan.status] %>
7
+ </p>
8
+
9
+ <ol class="steps">
10
+ <% PlanDriven::Wizard::Steps::ORDER.each_with_index do |(key, title, hint), index| %>
11
+ <li>
12
+ <% if key == @step %>
13
+ <span class="here"><b><%= index + 1 %>. <%= title %></b><%= hint %></span>
14
+ <% elsif PlanDriven::Wizard::Steps.reached?(@plan, key) %>
15
+ <%= link_to plan_path(@plan.key, key) do %><b><%= index + 1 %>. <%= title %></b><%= hint %><% end %>
16
+ <% else %>
17
+ <span><b><%= index + 1 %>. <%= title %></b><%= hint %></span>
18
+ <% end %>
19
+ </li>
20
+ <% end %>
21
+ </ol>
22
+
23
+ <%= render @step %>
24
+
25
+ <% keys = PlanDriven::Wizard::Steps.keys; position = keys.index(@step) %>
26
+ <div class="nav">
27
+ <% if position.zero? %>
28
+ <%= link_to "← All plans", root_path, class: "button" %>
29
+ <% else %>
30
+ <%= link_to "← Back", plan_path(@plan.key, keys[position - 1]), class: "button" %>
31
+ <% end %>
32
+ <% if (following = keys[position + 1]) %>
33
+ <%= link_to "Next →", plan_path(@plan.key, following),
34
+ class: "button primary #{"disabled" unless PlanDriven::Wizard::Steps.reached?(@plan, following)}" %>
35
+ <% end %>
36
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ PlanDriven::Wizard::Engine.routes.draw do
4
+ root "plans#index"
5
+ resources :plans, only: %i[new create], param: :key
6
+ get "plans/:key(/:step)", to: "plans#show", as: :plan, constraints: { step: /plan|approve|tickets|agents|finish/ }
7
+ post "plans/:key/run", to: "plans#run", as: :run_plan
8
+ get "plans/:key/files/:name", to: "plans#file", as: :plan_file, constraints: { name: /[a-z-]+\.(pdf|html)/ }
9
+ post "run", to: "plans#run_global", as: :run
10
+ post "actor", to: "plans#actor", as: :actor
11
+ get "configuration", to: "configuration#show", as: :configuration
12
+ post "configuration/question", to: "configuration#question", as: :configuration_question
13
+ post "configuration/connect", to: "configuration#connect", as: :configuration_connect
14
+ post "configuration/check", to: "configuration#check", as: :configuration_check
15
+ get "jobs/:id", to: "jobs#show", as: :job
16
+ end
data/exe/plan-driven CHANGED
@@ -4,4 +4,5 @@
4
4
  require "plan_driven"
5
5
  require "plan_driven/cli"
6
6
 
7
+ $stdout.sync = true
7
8
  exit PlanDriven::CLI.start(ARGV)
@@ -23,6 +23,10 @@ module PlanDriven
23
23
  create_file "docs/plans/.keep"
24
24
  end
25
25
 
26
+ def mount_wizard
27
+ route 'mount PlanDriven::Wizard::Engine, at: "/plan_driven" if Rails.env.development?'
28
+ end
29
+
26
30
  def show_next_steps
27
31
  say <<~TEXT
28
32
 
@@ -30,6 +34,9 @@ module PlanDriven
30
34
  bin/rails db:migrate
31
35
  bundle exec plan-driven configure # keys for the LLM, Cursor and GitHub
32
36
  bundle exec plan-driven new "Short title of the change"
37
+
38
+ Or do it all in the browser: start the app and open /plan_driven
39
+ (development only; every step runs the same plan-driven command).
33
40
  TEXT
34
41
  end
35
42
  end
@@ -14,11 +14,17 @@ if defined?(PlanDriven.configure)
14
14
  # config.plan_approvals = %w[review qa devops director]
15
15
  # config.ticket_approvals = %w[review]
16
16
 
17
- # Cursor cloud agents: one agent and one pull request per ticket.
17
+ # Coding agents: one agent and one pull request per ticket. Cursor cloud agents by default,
18
+ # or a local CLI in its own git worktree per ticket.
18
19
  # config.agent_model = "composer-2.5"
20
+ # config.agent_provider = :local
21
+ # config.agent_command = "claude -p --permission-mode acceptEdits --output-format json"
19
22
  # config.base_branch = "main"
20
23
  # config.max_parallel_agents = 3
21
24
 
25
+ # Dollars per million tokens, for the cost in `plan-driven usage` and the delivery report.
26
+ # config.token_prices = { "your-model-id" => { input: 3.0, output: 15.0, cache_read: 0.3 } }
27
+
22
28
  # GitHub. The repository is read from `git remote get-url origin` when not set.
23
29
  # config.github_repository = "your-org/your-app"
24
30
  # config.sync_issues = true
@@ -35,5 +41,8 @@ if defined?(PlanDriven.configure)
35
41
  # "Service objects live in app/services and respond to .call.",
36
42
  # "Authorization goes through Pundit policies, never in controllers."
37
43
  # ]
44
+
45
+ # The browser wizard at /plan_driven: development only by default, local requests always.
46
+ # config.wizard_enabled = true
38
47
  end
39
48
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class CLI
5
+ # The interview's questions, and connecting the services with a key that's checked first.
6
+ module ConfigCommands
7
+ def cmd_questions
8
+ config = PlanDriven.configuration
9
+ ui.table(%w[Key Source Asks], config.template.asked.map do |section|
10
+ [section.key, Interview.origin(section.key, template: config.base_template), section.prompt]
11
+ end)
12
+ ui.muted "Changes are in #{Interview::PATH}; commit it so the team gets the same interview."
13
+ end
14
+
15
+ def cmd_question(key = nil)
16
+ raise ArgumentError, "Which question? Pass its key, for example who." if key.to_s.empty?
17
+
18
+ base = PlanDriven.configuration.base_template
19
+ if @options[:remove]
20
+ result = Interview.remove(key, template: base)
21
+ ui.success(result == :removed ? "Question #{key} removed" : "Question #{key} is back to the default")
22
+ else
23
+ result = Interview.change(key, question_fields, template: base)
24
+ ui.success "Question #{key} #{result}"
25
+ end
26
+ cmd_questions
27
+ end
28
+
29
+ def cmd_connect(name = nil)
30
+ service = Connections.find(name)
31
+ ui.muted "#{service.title}: #{service.purpose}. Get a key at #{service.url}"
32
+ value = ui.secret("#{service.title} key:")
33
+ raise ArgumentError, "No key given; nothing was stored." if value.empty?
34
+
35
+ ui.say "Checking the key with #{service.title}..."
36
+ detail = verify_connection(service, value)
37
+ path = Credentials.store(service.key, value)
38
+ ui.success "#{service.title}: #{detail}"
39
+ ui.muted " stored in #{path} (0600), never in the app"
40
+ return if ENV[service.env].to_s.strip.empty?
41
+
42
+ ui.warn "#{service.env} is set in this environment, and it wins over the stored key."
43
+ end
44
+
45
+ private
46
+
47
+ def question_fields
48
+ fields = { "title" => @options[:title], "question" => @options[:ask], "group" => @options[:group] }.compact
49
+ fields["required"] = @options[:required] unless @options[:required].nil?
50
+ fields
51
+ end
52
+
53
+ def verify_connection(service, value)
54
+ Connections.verify(service, value)
55
+ rescue Error => e
56
+ raise ProviderError, "#{e.message}; nothing was stored."
57
+ end
58
+ end
59
+ end
60
+ end
@@ -44,7 +44,11 @@ module PlanDriven
44
44
  plan = find_plan(reference)
45
45
  section = PlanDriven.configuration.template[key] or
46
46
  raise ArgumentError, "Which section? One of: #{PlanDriven.configuration.template.keys.join(", ")}"
47
- text = edit_in_editor(plan.section(section.key), "#{plan.key}-#{section.key}")
47
+ text = if @options[:from]
48
+ File.read(@options[:from], encoding: "UTF-8")
49
+ else
50
+ edit_in_editor(plan.section(section.key), "#{plan.key}-#{section.key}")
51
+ end
48
52
  return ui.muted("No change.") if text.strip == plan.section(section.key).strip
49
53
 
50
54
  delivery.edit_section(plan, section.key, text)
@@ -124,7 +128,7 @@ module PlanDriven
124
128
  PlanDriven.configuration.template.asked.to_h do |section|
125
129
  answer = ""
126
130
  loop do
127
- answer = ui.ask_multiline("#{section.title}: #{section.question}")
131
+ answer = ui.ask_multiline("#{section.title}: #{section.prompt}")
128
132
  break unless section.required && answer.strip.empty?
129
133
 
130
134
  ui.warn "#{section.title} is required."
@@ -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,24 @@ 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
+
126
146
  def cmd_report(reference = nil)
127
147
  plan = find_plan(reference)
128
148
  paths = delivery.report(plan)
@@ -147,7 +167,7 @@ module PlanDriven
147
167
  def show_tickets(tickets)
148
168
  ui.table(%w[# Title Kind Pts Status PR], tickets.map do |ticket|
149
169
  [ticket.key, ticket.title.truncate(48), ticket.kind, ticket.estimate, ticket.status.tr("_", " "),
150
- ticket.pr_url.to_s]
170
+ ticket.pr_number ? "##{ticket.pr_number}" : ""]
151
171
  end)
152
172
  end
153
173
  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,15 @@ 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
+ "questions" => ["", "The interview's questions, with the team's changes"],
45
+ "question" => ["KEY", "Change or add a question (--title, --ask, --group, --required, --optional, --remove)"],
41
46
  "configure" => ["", "Store API keys in ~/.plan_driven/config"],
47
+ "connect" => ["SERVICE", "Check a key with cursor, openai, anthropic or github, then store it"],
42
48
  "doctor" => ["", "Check keys, repository and connections"]
43
49
  }.freeze
44
50
 
45
- NO_APP = %w[configure doctor help version].freeze
51
+ NO_APP = %w[configure connect doctor help version].freeze
46
52
 
47
53
  # Plans are UTF-8 whatever the terminal's locale says, so answers typed with č or ž under
48
54
  # LANG=C are read as text, not bytes.
@@ -64,7 +70,7 @@ module PlanDriven
64
70
  @ui = UI.new(input: @input, output: @output, assume_yes: options[:yes])
65
71
  @options = options
66
72
  return help if %w[help -h --help].include?(command)
67
- return @ui.say(PlanDriven::VERSION) if %w[version -v --version].include?(command)
73
+ return @ui.say(PlanDriven::VERSION) || 0 if %w[version -v --version].include?(command)
68
74
  raise Error, "Unknown command `#{command}`. `plan-driven help` lists them." unless COMMANDS.key?(command)
69
75
 
70
76
  boot_application unless NO_APP.include?(command)
@@ -109,10 +115,20 @@ module PlanDriven
109
115
  parser.on("--section KEY") { |value| options[:section] = value }
110
116
  parser.on("--from FILE") { |value| options[:from] = value }
111
117
  parser.on("-y", "--yes") { options[:yes] = true }
118
+ question_options(parser, options)
112
119
  end.parse!(argv)
113
120
  options
114
121
  end
115
122
 
123
+ def question_options(parser, options)
124
+ parser.on("--title TEXT") { |value| options[:title] = value }
125
+ parser.on("--ask TEXT") { |value| options[:ask] = value }
126
+ parser.on("--group NAME") { |value| options[:group] = value }
127
+ parser.on("--required") { options[:required] = true }
128
+ parser.on("--optional") { options[:required] = false }
129
+ parser.on("--remove") { options[:remove] = true }
130
+ end
131
+
116
132
  def boot_application
117
133
  return unless @boot
118
134
  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)