plan_driven 0.1.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 +7 -0
- data/CHANGELOG.md +76 -0
- data/LICENSE.txt +21 -0
- data/README.md +823 -0
- data/exe/plan-driven +7 -0
- data/lib/generators/plan_driven/install_generator.rb +37 -0
- data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
- data/lib/plan_driven/agent_prompt.rb +86 -0
- data/lib/plan_driven/cli/plan_commands.rb +158 -0
- data/lib/plan_driven/cli/setup_commands.rb +97 -0
- data/lib/plan_driven/cli/ticket_commands.rb +155 -0
- data/lib/plan_driven/cli/ui.rb +101 -0
- data/lib/plan_driven/cli.rb +150 -0
- data/lib/plan_driven/configuration.rb +114 -0
- data/lib/plan_driven/credentials.rb +90 -0
- data/lib/plan_driven/cursor_agents.rb +82 -0
- data/lib/plan_driven/cursor_llm.mjs +70 -0
- data/lib/plan_driven/cursor_llm.rb +79 -0
- data/lib/plan_driven/delivery.rb +362 -0
- data/lib/plan_driven/drafter.rb +122 -0
- data/lib/plan_driven/errors.rb +22 -0
- data/lib/plan_driven/evidence.rb +108 -0
- data/lib/plan_driven/gherkin.rb +42 -0
- data/lib/plan_driven/github.rb +118 -0
- data/lib/plan_driven/guards/migration_guard.rb +94 -0
- data/lib/plan_driven/guards/plan_guard.rb +84 -0
- data/lib/plan_driven/guards/pr_guard.rb +140 -0
- data/lib/plan_driven/guards/ticket_guard.rb +178 -0
- data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
- data/lib/plan_driven/guards.rb +57 -0
- data/lib/plan_driven/http.rb +60 -0
- data/lib/plan_driven/json_reply.rb +26 -0
- data/lib/plan_driven/llm.rb +80 -0
- data/lib/plan_driven/models/approval.rb +12 -0
- data/lib/plan_driven/models/event.rb +13 -0
- data/lib/plan_driven/models/evidence_run.rb +17 -0
- data/lib/plan_driven/models/plan.rb +98 -0
- data/lib/plan_driven/models/record.rb +8 -0
- data/lib/plan_driven/models/ticket.rb +58 -0
- data/lib/plan_driven/models.rb +8 -0
- data/lib/plan_driven/railtie.rb +9 -0
- data/lib/plan_driven/renderer/html.rb +106 -0
- data/lib/plan_driven/renderer/markdown.rb +145 -0
- data/lib/plan_driven/renderer/pdf.rb +39 -0
- data/lib/plan_driven/renderer/style.css +22 -0
- data/lib/plan_driven/renderer.rb +41 -0
- data/lib/plan_driven/repository.rb +35 -0
- data/lib/plan_driven/schema_context.rb +98 -0
- data/lib/plan_driven/template.rb +113 -0
- data/lib/plan_driven/ticket_generator.rb +98 -0
- data/lib/plan_driven/version.rb +5 -0
- data/lib/plan_driven/workflow.rb +59 -0
- data/lib/plan_driven.rb +72 -0
- metadata +146 -0
data/exe/plan-driven
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module PlanDriven
|
|
7
|
+
module Generators
|
|
8
|
+
# bin/rails generate plan_driven:install
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def copy_migration
|
|
15
|
+
migration_template "create_plan_driven_tables.rb.tt", "db/migrate/create_plan_driven_tables.rb"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def copy_initializer
|
|
19
|
+
template "plan_driven.rb", "config/initializers/plan_driven.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_docs_directory
|
|
23
|
+
create_file "docs/plans/.keep"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show_next_steps
|
|
27
|
+
say <<~TEXT
|
|
28
|
+
|
|
29
|
+
Next:
|
|
30
|
+
bin/rails db:migrate
|
|
31
|
+
bundle exec plan-driven configure # keys for the LLM, Cursor and GitHub
|
|
32
|
+
bundle exec plan-driven new "Short title of the change"
|
|
33
|
+
TEXT
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
class CreatePlanDrivenTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :plan_driven_plans do |t|
|
|
4
|
+
t.string :key, null: false
|
|
5
|
+
t.string :title, null: false
|
|
6
|
+
t.string :status, null: false, default: "draft"
|
|
7
|
+
t.integer :revision, null: false, default: 1
|
|
8
|
+
t.json :interview
|
|
9
|
+
t.json :sections
|
|
10
|
+
t.json :guard_report
|
|
11
|
+
t.string :created_by
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :plan_driven_plans, :key, unique: true
|
|
15
|
+
|
|
16
|
+
create_table :plan_driven_tickets do |t|
|
|
17
|
+
t.references :plan, null: false, foreign_key: { to_table: :plan_driven_plans }
|
|
18
|
+
t.string :key, null: false
|
|
19
|
+
t.integer :position, null: false, default: 0
|
|
20
|
+
t.string :title, null: false
|
|
21
|
+
t.string :kind, null: false, default: "code"
|
|
22
|
+
t.string :ticket_type, null: false, default: "TASK"
|
|
23
|
+
t.text :story
|
|
24
|
+
t.text :description
|
|
25
|
+
t.json :acceptance_criteria
|
|
26
|
+
t.text :implementation_notes
|
|
27
|
+
t.integer :estimate
|
|
28
|
+
t.json :depends_on
|
|
29
|
+
t.json :touches
|
|
30
|
+
t.string :status, null: false, default: "draft"
|
|
31
|
+
t.integer :issue_number
|
|
32
|
+
t.string :agent_id
|
|
33
|
+
t.string :agent_run_id
|
|
34
|
+
t.string :agent_url
|
|
35
|
+
t.string :branch
|
|
36
|
+
t.string :pr_url
|
|
37
|
+
t.integer :pr_number
|
|
38
|
+
t.string :merged_sha
|
|
39
|
+
t.json :guard_report
|
|
40
|
+
t.timestamps
|
|
41
|
+
end
|
|
42
|
+
add_index :plan_driven_tickets, %i[plan_id key], unique: true
|
|
43
|
+
|
|
44
|
+
create_table :plan_driven_approvals do |t|
|
|
45
|
+
t.references :approvable, polymorphic: true, null: false
|
|
46
|
+
t.string :role, null: false
|
|
47
|
+
t.string :decision, null: false
|
|
48
|
+
t.string :actor, null: false
|
|
49
|
+
t.integer :revision
|
|
50
|
+
t.text :note
|
|
51
|
+
t.timestamps
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
create_table :plan_driven_events do |t|
|
|
55
|
+
t.references :plan, null: false, foreign_key: { to_table: :plan_driven_plans }
|
|
56
|
+
t.references :ticket, foreign_key: { to_table: :plan_driven_tickets }
|
|
57
|
+
t.string :name, null: false
|
|
58
|
+
t.string :actor, null: false
|
|
59
|
+
t.json :payload
|
|
60
|
+
t.datetime :created_at, null: false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
create_table :plan_driven_evidence_runs do |t|
|
|
64
|
+
t.references :plan, null: false, foreign_key: { to_table: :plan_driven_plans }
|
|
65
|
+
t.string :kind, null: false
|
|
66
|
+
t.string :command
|
|
67
|
+
t.string :status, null: false
|
|
68
|
+
t.string :commit_sha
|
|
69
|
+
t.json :results
|
|
70
|
+
t.datetime :created_at, null: false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Keys are never set here. They come from the environment or ~/.plan_driven/config,
|
|
4
|
+
# written by `bundle exec plan-driven configure`. The gem is usually in the :development group
|
|
5
|
+
# only, hence the check.
|
|
6
|
+
if defined?(PlanDriven.configure)
|
|
7
|
+
PlanDriven.configure do |config|
|
|
8
|
+
# The model that drafts plans and tickets. A stronger model is worth it here.
|
|
9
|
+
# config.llm_provider = :openai # or :anthropic, or :cursor (any model on your Cursor
|
|
10
|
+
# config.llm_model = "gpt-4.1" # account, e.g. "claude-opus-5-5"; reads your code)
|
|
11
|
+
# config.request_timeout = 180 # seconds; a large model can need 600 for a full plan
|
|
12
|
+
|
|
13
|
+
# Who must approve before the next phase starts.
|
|
14
|
+
# config.plan_approvals = %w[review qa devops director]
|
|
15
|
+
# config.ticket_approvals = %w[review]
|
|
16
|
+
|
|
17
|
+
# Cursor cloud agents: one agent and one pull request per ticket.
|
|
18
|
+
# config.agent_model = "composer-2.5"
|
|
19
|
+
# config.base_branch = "main"
|
|
20
|
+
# config.max_parallel_agents = 3
|
|
21
|
+
|
|
22
|
+
# GitHub. The repository is read from `git remote get-url origin` when not set.
|
|
23
|
+
# config.github_repository = "your-org/your-app"
|
|
24
|
+
# config.sync_issues = true
|
|
25
|
+
# config.merge_method = "squash"
|
|
26
|
+
|
|
27
|
+
# Guards.
|
|
28
|
+
# config.estimate_scale = [1, 2, 3, 5, 8]
|
|
29
|
+
# config.max_estimate = 5
|
|
30
|
+
# config.max_pr_changed_lines = 800
|
|
31
|
+
# config.cucumber = true
|
|
32
|
+
|
|
33
|
+
# Your team's conventions, added to every agent prompt.
|
|
34
|
+
# config.team_rules = [
|
|
35
|
+
# "Service objects live in app/services and respond to .call.",
|
|
36
|
+
# "Authorization goes through Pundit policies, never in controllers."
|
|
37
|
+
# ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# What a coding agent is told about one ticket. The plan supplies the context, the ticket the
|
|
5
|
+
# scope, and the rules are the same ones PrGuard checks afterwards, so the agent is told in
|
|
6
|
+
# advance exactly what will be verified.
|
|
7
|
+
class AgentPrompt
|
|
8
|
+
CONTEXT_SECTIONS = %w[what why architecture database_changes application_changes security testing].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(ticket, config: PlanDriven.configuration)
|
|
11
|
+
@ticket = ticket
|
|
12
|
+
@plan = ticket.plan
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
[intro, ticket_block, plan_block, done_block, rules_block, pr_block].compact.join("\n\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pr_title
|
|
21
|
+
"[#{@ticket.reference}] #{@ticket.title}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def intro
|
|
27
|
+
"You are implementing one ticket of an approved implementation plan in this Rails application. " \
|
|
28
|
+
"Implement this ticket only. Other tickets of the plan are handled in their own pull requests."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ticket_block
|
|
32
|
+
lines = ["# Ticket #{@ticket.reference}: #{@ticket.title}",
|
|
33
|
+
"Kind: #{@ticket.kind}. Type: #{@ticket.ticket_type}."]
|
|
34
|
+
lines << "\n#{@ticket.story}" if @ticket.story.present?
|
|
35
|
+
lines << "\n#{@ticket.description}"
|
|
36
|
+
lines << "\n## Acceptance criteria"
|
|
37
|
+
@ticket.criteria.each_with_index { |criterion, index| lines << "#{index + 1}. #{criterion}" }
|
|
38
|
+
lines << "\n## Implementation notes\n#{@ticket.implementation_notes}" if @ticket.implementation_notes.present?
|
|
39
|
+
merged = @ticket.dependency_tickets.map { |ticket| "#{ticket.key} #{ticket.title}" }
|
|
40
|
+
lines << "\nAlready merged into the base branch: #{merged.join("; ")}." if merged.any?
|
|
41
|
+
lines.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def plan_block
|
|
45
|
+
sections = CONTEXT_SECTIONS.filter_map do |key|
|
|
46
|
+
text = @plan.section(key).strip
|
|
47
|
+
"## #{@config.template[key]&.title || key}\n#{text}" unless text.empty?
|
|
48
|
+
end
|
|
49
|
+
"# Context from plan #{@plan.key}: #{@plan.title}\n\n#{sections.join("\n\n")}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def done_block
|
|
53
|
+
lines = ["# Definition of done"]
|
|
54
|
+
lines << "- Specs cover the change (#{@config.spec_paths.join(", ")}), and the existing suite still passes."
|
|
55
|
+
if @config.cucumber && @ticket.kind != "docs"
|
|
56
|
+
lines << "- Every acceptance criterion has a Cucumber scenario in " \
|
|
57
|
+
"`#{@config.features_path}/#{@plan.slug}/#{@ticket.key.downcase}.feature`. Tag the feature " \
|
|
58
|
+
"`#{@ticket.feature_tag}` and each scenario `@ac-N`, where N is the criterion's number above."
|
|
59
|
+
lines << "- If the app has no Cucumber setup yet, add `cucumber-rails` to the test group and run " \
|
|
60
|
+
"`bin/rails generate cucumber:install` in this pull request."
|
|
61
|
+
end
|
|
62
|
+
lines << "- Keep the change within #{@config.max_pr_changed_lines} changed lines."
|
|
63
|
+
lines.join("\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rules_block
|
|
67
|
+
rules = ["Follow the conventions already used in this codebase.",
|
|
68
|
+
"Schema changes only in migration tickets; this ticket is a #{@ticket.kind} ticket.",
|
|
69
|
+
"Migrations are additive and reversible. Never remove or rename a column that code still reads.",
|
|
70
|
+
"Don't edit files under #{@config.docs_path}; they are the approved plan.",
|
|
71
|
+
*@config.team_rules]
|
|
72
|
+
"# Rules\n#{rules.map { |rule| "- #{rule}" }.join("\n")}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def pr_block
|
|
76
|
+
closes = @ticket.issue_number ? "\n- a line `Closes ##{@ticket.issue_number}`" : ""
|
|
77
|
+
<<~TEXT.strip
|
|
78
|
+
# Pull request
|
|
79
|
+
Title it exactly: #{pr_title}
|
|
80
|
+
In the description include:
|
|
81
|
+
- `#{@ticket.reference}`#{closes}
|
|
82
|
+
- each acceptance criterion as a checklist, with the spec or scenario that proves it
|
|
83
|
+
TEXT
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class CLI
|
|
5
|
+
# Writing, checking and approving the plan.
|
|
6
|
+
module PlanCommands
|
|
7
|
+
def cmd_new(*words)
|
|
8
|
+
title = words.join(" ").strip
|
|
9
|
+
title = ui.ask("Title of the change:") if title.empty?
|
|
10
|
+
raise ArgumentError, "A plan needs a title." if title.empty?
|
|
11
|
+
|
|
12
|
+
ui.heading "Plan: #{title}"
|
|
13
|
+
ui.muted "A few questions first. The rest of the plan is drafted from your answers and the schema."
|
|
14
|
+
answers = interview
|
|
15
|
+
ui.say
|
|
16
|
+
ui.say "Drafting with #{LLM.new.label}..."
|
|
17
|
+
plan, result = delivery.create_plan(title: title, answers: answers)
|
|
18
|
+
ui.success "#{plan.key} drafted (#{result.attempts} attempt#{"s" unless result.attempts == 1})"
|
|
19
|
+
result.assumptions.each { |assumption| ui.muted " assumed: #{assumption}" }
|
|
20
|
+
ui.report(result.report)
|
|
21
|
+
show_paths(Renderer.write_plan(plan).values)
|
|
22
|
+
next_step(plan, result.report)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def cmd_list
|
|
26
|
+
plans = Plan.order(:id)
|
|
27
|
+
return ui.say("No plans yet. Start one with `plan-driven new \"Title\"`.") if plans.empty?
|
|
28
|
+
|
|
29
|
+
ui.table(%w[Plan Title Phase Tickets Revision], plans.map do |plan|
|
|
30
|
+
[plan.key, plan.title.truncate(50), plan.status.tr("_", " "), plan.tickets.size, plan.revision]
|
|
31
|
+
end)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def cmd_show(reference = nil)
|
|
35
|
+
plan = find_plan(reference)
|
|
36
|
+
if @options[:section]
|
|
37
|
+
ui.say plan.section(@options[:section])
|
|
38
|
+
else
|
|
39
|
+
ui.say Renderer::Markdown.plan(plan)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cmd_edit(reference = nil, key = nil)
|
|
44
|
+
plan = find_plan(reference)
|
|
45
|
+
section = PlanDriven.configuration.template[key] or
|
|
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}")
|
|
48
|
+
return ui.muted("No change.") if text.strip == plan.section(section.key).strip
|
|
49
|
+
|
|
50
|
+
delivery.edit_section(plan, section.key, text)
|
|
51
|
+
ui.success "#{section.title} updated; #{plan.key} is now revision #{plan.revision} (#{plan.status})"
|
|
52
|
+
ui.report(Guards::Report.from_h(plan.guard_report))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cmd_redraft(reference = nil, key = nil, *instruction)
|
|
56
|
+
plan = find_plan(reference)
|
|
57
|
+
if instruction.empty?
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"Say what to change, for example: redraft PD-1 testing \"add permission cases\""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
ui.say "Redrafting #{key}..."
|
|
63
|
+
ui.say delivery.redraft_section(plan, key, instruction.join(" "))
|
|
64
|
+
ui.report(Guards::Report.from_h(plan.reload.guard_report))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cmd_check(reference = nil)
|
|
68
|
+
plan = find_plan(reference)
|
|
69
|
+
report = delivery.check_plan(plan)
|
|
70
|
+
ui.report(report)
|
|
71
|
+
next_step(plan, report)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cmd_submit(reference = nil)
|
|
75
|
+
plan = find_plan(reference)
|
|
76
|
+
delivery.submit(plan)
|
|
77
|
+
ui.success "#{plan.key} revision #{plan.revision} is in review"
|
|
78
|
+
ui.say "Approvals needed: #{plan.missing_approvals.join(", ")}. `plan-driven approve #{plan.key} --as ROLE`"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cmd_approve(reference = nil)
|
|
82
|
+
plan = find_plan(reference)
|
|
83
|
+
role = @options[:role] || PlanDriven.configuration.plan_approvals.first
|
|
84
|
+
missing = delivery.approve_plan(plan, role: role, note: @options[:note])
|
|
85
|
+
ui.success "#{plan.key} approved as #{role} by #{delivery.actor}"
|
|
86
|
+
if missing.empty?
|
|
87
|
+
ui.success "Every approval is in. #{plan.key} is approved; " \
|
|
88
|
+
"`plan-driven tickets #{plan.key}` drafts the tickets."
|
|
89
|
+
else
|
|
90
|
+
ui.say "Still needed: #{missing.join(", ")}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cmd_reject(reference = nil)
|
|
95
|
+
plan = find_plan(reference)
|
|
96
|
+
raise ArgumentError, "Say what needs to change with --note." if @options[:note].to_s.empty?
|
|
97
|
+
|
|
98
|
+
role = @options[:role] || PlanDriven.configuration.plan_approvals.first
|
|
99
|
+
delivery.reject_plan(plan, role: role, note: @options[:note])
|
|
100
|
+
ui.warn "#{plan.key} sent back to draft: #{@options[:note]}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def cmd_pdf(reference = nil)
|
|
104
|
+
plan = find_plan(reference)
|
|
105
|
+
paths = Renderer.write_plan(plan)
|
|
106
|
+
ui.success "#{plan.key} written"
|
|
107
|
+
show_paths(paths.values)
|
|
108
|
+
return if paths[:pdf]
|
|
109
|
+
|
|
110
|
+
ui.warn "No Chrome or Chromium found, so no PDF; set config.pdf_renderer to use another tool."
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def cmd_log(reference = nil)
|
|
114
|
+
plan = find_plan(reference)
|
|
115
|
+
ui.table(%w[When Event Ticket By Details], plan.events.map do |event|
|
|
116
|
+
[event.created_at.strftime("%Y-%m-%d %H:%M"), event.name, event.ticket&.key.to_s, event.actor,
|
|
117
|
+
event.payload.to_h.map { |k, v| "#{k}=#{Array(v).join(",")}" }.join(" ").truncate(60)]
|
|
118
|
+
end)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def interview
|
|
124
|
+
PlanDriven.configuration.template.asked.to_h do |section|
|
|
125
|
+
answer = ""
|
|
126
|
+
loop do
|
|
127
|
+
answer = ui.ask_multiline("#{section.title}: #{section.question}")
|
|
128
|
+
break unless section.required && answer.strip.empty?
|
|
129
|
+
|
|
130
|
+
ui.warn "#{section.title} is required."
|
|
131
|
+
end
|
|
132
|
+
[section.key, answer]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def next_step(plan, report)
|
|
137
|
+
if report.ok?
|
|
138
|
+
ui.say "Next: read #{plan.key} (`plan-driven show #{plan.key}` or the PDF), " \
|
|
139
|
+
"then `plan-driven submit #{plan.key}`."
|
|
140
|
+
else
|
|
141
|
+
ui.say "Fix the errors with `plan-driven edit #{plan.key} SECTION` or `plan-driven redraft #{plan.key} " \
|
|
142
|
+
"SECTION \"instruction\"`, then `plan-driven check #{plan.key}`."
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def edit_in_editor(text, name)
|
|
147
|
+
editor = ENV["VISUAL"].presence || ENV["EDITOR"].presence or
|
|
148
|
+
raise ConfigurationError, "Set $EDITOR (for example `export EDITOR=\"code --wait\"`) to edit sections."
|
|
149
|
+
Tempfile.create([name, ".md"]) do |file|
|
|
150
|
+
file.write(text)
|
|
151
|
+
file.flush
|
|
152
|
+
system("#{editor} #{file.path}") or raise Error, "The editor exited with an error; nothing changed."
|
|
153
|
+
File.read(file.path)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class CLI
|
|
5
|
+
# Keys and a health check. These work outside a Rails application too.
|
|
6
|
+
module SetupCommands
|
|
7
|
+
PROMPTS = {
|
|
8
|
+
"openai_api_key" => "OpenAI API key (drafting plans and tickets)",
|
|
9
|
+
"anthropic_api_key" => "Anthropic API key (optional, if you draft with Claude)",
|
|
10
|
+
"cursor_api_key" => "Cursor API key (cloud agents, and drafting with llm_provider :cursor)",
|
|
11
|
+
"github_token" => "GitHub token with repo scope (issues, pull requests, merge)"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def cmd_configure
|
|
15
|
+
ui.heading "plan-driven keys"
|
|
16
|
+
ui.muted "Stored in #{Credentials.path} (0600). Keys already in the environment are used as they are."
|
|
17
|
+
PROMPTS.each do |name, label|
|
|
18
|
+
source = Credentials.source(name)
|
|
19
|
+
if source
|
|
20
|
+
ui.muted " #{label}: set (#{source})"
|
|
21
|
+
next unless ui.confirm?(" Replace it in #{Credentials.path}?")
|
|
22
|
+
end
|
|
23
|
+
value = ui.secret(" #{label}, Enter to skip:")
|
|
24
|
+
next if value.empty?
|
|
25
|
+
|
|
26
|
+
Credentials.store(name, value)
|
|
27
|
+
ui.success " stored"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cmd_doctor
|
|
32
|
+
ui.heading "plan-driven #{PlanDriven::VERSION}"
|
|
33
|
+
check_application
|
|
34
|
+
ui.muted "LLM: #{LLM.new.label}"
|
|
35
|
+
check_cursor_llm
|
|
36
|
+
Credentials::KEYS.each_key do |name|
|
|
37
|
+
next if unused_llm_key?(name)
|
|
38
|
+
|
|
39
|
+
source = Credentials.source(name)
|
|
40
|
+
source ? ui.success("#{name}: #{source}") : ui.warn("#{name}: not set")
|
|
41
|
+
end
|
|
42
|
+
repository = Repository.slug
|
|
43
|
+
repository ? ui.success("GitHub repository: #{repository}") : ui.warn("GitHub repository: none found")
|
|
44
|
+
browser = Renderer::PDF.browser
|
|
45
|
+
browser ? ui.success("PDF: #{File.basename(browser)}") : ui.warn("PDF: no Chrome or Chromium, HTML only")
|
|
46
|
+
check_cursor
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Outside an app the keys can still be checked; inside one, the initializer decides the
|
|
52
|
+
# provider and repository, so boot it first.
|
|
53
|
+
def check_application
|
|
54
|
+
return ui.muted("Not in a Rails application; showing defaults.") unless File.exist?("config/environment.rb")
|
|
55
|
+
|
|
56
|
+
boot_application
|
|
57
|
+
ui.success "Rails application: plan_driven tables present"
|
|
58
|
+
rescue Error => e
|
|
59
|
+
ui.error e.message
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def check_agent_model(agents)
|
|
63
|
+
model = PlanDriven.configuration.agent_model
|
|
64
|
+
return ui.warn("Agent model: Cursor's default. Set config.agent_model if agents fail to start.") unless model
|
|
65
|
+
|
|
66
|
+
if agents.model_ids.include?(model)
|
|
67
|
+
ui.success "Agent model: #{model}"
|
|
68
|
+
else
|
|
69
|
+
ui.error "Agent model: #{model} isn't available to this key; `GET /v1/models` lists the ones that are"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def unused_llm_key?(name)
|
|
74
|
+
%w[openai_api_key anthropic_api_key].include?(name) && name != PlanDriven.configuration.llm_key_name.to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_cursor_llm
|
|
78
|
+
config = PlanDriven.configuration
|
|
79
|
+
return unless config.llm_provider.to_sym == :cursor
|
|
80
|
+
|
|
81
|
+
ok, detail = CursorLLM.new(config).check
|
|
82
|
+
ok ? ui.success("Cursor SDK: #{detail}") : ui.error("Cursor SDK: #{detail}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_cursor
|
|
86
|
+
return unless Credentials.fetch(:cursor_api_key)
|
|
87
|
+
|
|
88
|
+
agents = CursorAgents.new
|
|
89
|
+
info = agents.me
|
|
90
|
+
ui.success "Cursor API: #{info["userEmail"] || info["apiKeyName"] || "connected"}"
|
|
91
|
+
check_agent_model(agents)
|
|
92
|
+
rescue Error => e
|
|
93
|
+
ui.error "Cursor API: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class CLI
|
|
5
|
+
# Tickets, agents, pull requests, evidence and the report.
|
|
6
|
+
module TicketCommands
|
|
7
|
+
def cmd_tickets(reference = nil, *instruction)
|
|
8
|
+
plan = find_plan(reference)
|
|
9
|
+
ui.say "Drafting tickets for #{plan.key} with #{LLM.new.label}..."
|
|
10
|
+
result = delivery.draft_tickets(plan, instruction: instruction.join(" ").presence)
|
|
11
|
+
ui.report(result.report, ok_message: "Tickets pass every check")
|
|
12
|
+
show_tickets(plan.tickets)
|
|
13
|
+
ui.say "Next: review them in docs/plans/#{plan.slug}/plan.pdf, then `plan-driven approve-tickets #{plan.key}`."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cmd_approve_tickets(reference = nil)
|
|
17
|
+
plan = find_plan(reference)
|
|
18
|
+
role = @options[:role] || PlanDriven.configuration.ticket_approvals.first
|
|
19
|
+
missing = delivery.approve_tickets(plan, role: role, note: @options[:note])
|
|
20
|
+
return ui.say("Approved as #{role}. Still needed: #{missing.join(", ")}") if missing.any?
|
|
21
|
+
|
|
22
|
+
ui.success "#{plan.tickets.size} tickets approved"
|
|
23
|
+
plan.tickets.select(&:issue_number).each do |ticket|
|
|
24
|
+
ui.muted " #{ticket.key} -> issue ##{ticket.issue_number}"
|
|
25
|
+
end
|
|
26
|
+
ui.say "Next: `plan-driven develop #{plan.key}` hands the ready tickets to Cursor agents."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cmd_prompt(reference = nil)
|
|
30
|
+
ui.say AgentPrompt.new(find_ticket(reference)).to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cmd_develop(reference = nil, *only)
|
|
34
|
+
plan = find_plan(reference)
|
|
35
|
+
starting = delivery.startable(plan, only: only)
|
|
36
|
+
return explain_waiting(plan) if starting.empty?
|
|
37
|
+
|
|
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)?")
|
|
40
|
+
|
|
41
|
+
delivery.develop(plan, only: starting.map(&:key)).each do |ticket|
|
|
42
|
+
ui.success "#{ticket.key} agent started: #{ticket.agent_url}"
|
|
43
|
+
end
|
|
44
|
+
ui.say "Each agent opens a pull request when it finishes. `plan-driven status #{plan.key}` checks on them."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cmd_status(reference = nil)
|
|
48
|
+
plan = find_plan(reference)
|
|
49
|
+
delivery.refresh(plan)
|
|
50
|
+
ui.heading "#{plan.key} #{plan.title}: #{plan.status.tr("_", " ")}"
|
|
51
|
+
show_tickets(plan.tickets)
|
|
52
|
+
ui.muted(plan.status == "in_development" ? development_hint(plan) : Workflow::PLAN_DESCRIPTIONS[plan.status])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def development_hint(plan)
|
|
56
|
+
tickets = plan.tickets.to_a
|
|
57
|
+
waiting = tickets.find { |t| t.status == "pr_open" }
|
|
58
|
+
return "Next: `plan-driven review #{waiting.reference}`" if waiting
|
|
59
|
+
|
|
60
|
+
approved = tickets.find { |t| t.status == "pr_approved" }
|
|
61
|
+
return "Next: `plan-driven merge #{approved.reference}`" if approved
|
|
62
|
+
return "Next: `plan-driven develop #{plan.key}`" if delivery.startable(plan).any?
|
|
63
|
+
|
|
64
|
+
Workflow::PLAN_DESCRIPTIONS[plan.status]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cmd_review(reference = nil)
|
|
68
|
+
ticket = find_ticket(reference)
|
|
69
|
+
ui.say "Reviewing #{ticket.pr_url}"
|
|
70
|
+
report = delivery.review(ticket)
|
|
71
|
+
report.passes.each { |message| ui.say " ✓ #{message}" }
|
|
72
|
+
ui.report(report, ok_message: "The pull request passes every check")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cmd_approve_pr(reference = nil)
|
|
76
|
+
ticket = find_ticket(reference)
|
|
77
|
+
report = delivery.approve_pr(ticket, note: @options[:note])
|
|
78
|
+
ui.report(report)
|
|
79
|
+
ui.success "#{ticket.reference} pull request approved by #{delivery.actor}"
|
|
80
|
+
ui.say "Next: `plan-driven merge #{ticket.reference}`"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def cmd_feedback(reference = nil, *words)
|
|
84
|
+
ticket = find_ticket(reference)
|
|
85
|
+
feedback = words.join(" ").presence || ui.ask_multiline("Feedback for the agent:")
|
|
86
|
+
raise ArgumentError, "No feedback given." if feedback.strip.empty?
|
|
87
|
+
|
|
88
|
+
delivery.request_changes(ticket, feedback)
|
|
89
|
+
ui.success "Sent to #{ticket.reference}'s agent; it will push to the same pull request"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cmd_merge(reference = nil)
|
|
93
|
+
ticket = find_ticket(reference)
|
|
94
|
+
ui.say "#{ticket.reference} #{ticket.title}"
|
|
95
|
+
ui.say "#{ticket.pr_url}, approved by #{ticket.pr_approved_by}"
|
|
96
|
+
return ui.muted("Not merged.") unless ui.confirm_word?(
|
|
97
|
+
"This merges into #{PlanDriven.configuration.base_branch}.", ticket.key
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
delivery.merge(ticket)
|
|
101
|
+
ui.success "#{ticket.reference} merged (#{ticket.merged_sha.to_s[0, 7]})"
|
|
102
|
+
waiting = ticket.plan.tickets.select(&:ready?)
|
|
103
|
+
ui.say "Now ready: #{waiting.map(&:key).join(", ")}. `plan-driven develop #{ticket.plan.key}`" if waiting.any?
|
|
104
|
+
return unless ticket.plan.status == "delivered"
|
|
105
|
+
|
|
106
|
+
ui.success "Every ticket is merged. `plan-driven report #{ticket.plan.key}`"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cmd_evidence(reference = nil)
|
|
110
|
+
plan = find_plan(reference)
|
|
111
|
+
run = if @options[:from]
|
|
112
|
+
Evidence.record(plan, File.read(@options[:from]), command: "imported #{@options[:from]}",
|
|
113
|
+
actor: delivery.actor)
|
|
114
|
+
else
|
|
115
|
+
ui.say "Running cucumber --tags \"#{Evidence.tag_expression(plan)}\""
|
|
116
|
+
delivery.evidence(plan)
|
|
117
|
+
end
|
|
118
|
+
rows = Evidence.matrix(plan)
|
|
119
|
+
ui.table(%w[AC Result Criterion], rows.map do |row|
|
|
120
|
+
["#{row.ticket.key}.#{row.number}", row.status, row.criterion.truncate(70)]
|
|
121
|
+
end)
|
|
122
|
+
ui.say
|
|
123
|
+
run.passed? ? ui.success(Evidence.summary_line(plan)) : ui.warn("#{run.status}: #{Evidence.summary_line(plan)}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cmd_report(reference = nil)
|
|
127
|
+
plan = find_plan(reference)
|
|
128
|
+
paths = delivery.report(plan)
|
|
129
|
+
ui.success "Delivery report for #{plan.key} written"
|
|
130
|
+
show_paths(paths.values)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def explain_waiting(plan)
|
|
136
|
+
running = plan.tickets.count(&:active_agent?)
|
|
137
|
+
if plan.tickets.any?(&:ready?) && running >= PlanDriven.configuration.max_parallel_agents
|
|
138
|
+
return ui.say("#{running} agents are already running (the limit).")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
plan.tickets.select { |ticket| ticket.status == "approved" }.each do |ticket|
|
|
142
|
+
ui.muted " #{ticket.key} waits for #{ticket.blocked_by.join(", ")} to merge"
|
|
143
|
+
end
|
|
144
|
+
ui.say("No ticket is ready to start.")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def show_tickets(tickets)
|
|
148
|
+
ui.table(%w[# Title Kind Pts Status PR], tickets.map do |ticket|
|
|
149
|
+
[ticket.key, ticket.title.truncate(48), ticket.kind, ticket.estimate, ticket.status.tr("_", " "),
|
|
150
|
+
ticket.pr_url.to_s]
|
|
151
|
+
end)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|