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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -1
- data/README.md +339 -31
- 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 +102 -0
- data/app/views/layouts/plan_driven/wizard/application.html.erb +139 -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 +24 -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 +36 -0
- data/config/routes.rb +16 -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/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 +23 -3
- data/lib/plan_driven/cli/ui.rb +41 -2
- data/lib/plan_driven/cli.rb +20 -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 +5 -1
- 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/markdown.rb +26 -1
- 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 +5 -0
- 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
|
@@ -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
|
-
#
|
|
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 =
|
|
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.
|
|
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
|
|
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,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.
|
|
170
|
+
ticket.pr_number ? "##{ticket.pr_number}" : ""]
|
|
151
171
|
end)
|
|
152
172
|
end
|
|
153
173
|
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,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
|
-
|
|
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)
|