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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +76 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +823 -0
  5. data/exe/plan-driven +7 -0
  6. data/lib/generators/plan_driven/install_generator.rb +37 -0
  7. data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
  8. data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
  9. data/lib/plan_driven/agent_prompt.rb +86 -0
  10. data/lib/plan_driven/cli/plan_commands.rb +158 -0
  11. data/lib/plan_driven/cli/setup_commands.rb +97 -0
  12. data/lib/plan_driven/cli/ticket_commands.rb +155 -0
  13. data/lib/plan_driven/cli/ui.rb +101 -0
  14. data/lib/plan_driven/cli.rb +150 -0
  15. data/lib/plan_driven/configuration.rb +114 -0
  16. data/lib/plan_driven/credentials.rb +90 -0
  17. data/lib/plan_driven/cursor_agents.rb +82 -0
  18. data/lib/plan_driven/cursor_llm.mjs +70 -0
  19. data/lib/plan_driven/cursor_llm.rb +79 -0
  20. data/lib/plan_driven/delivery.rb +362 -0
  21. data/lib/plan_driven/drafter.rb +122 -0
  22. data/lib/plan_driven/errors.rb +22 -0
  23. data/lib/plan_driven/evidence.rb +108 -0
  24. data/lib/plan_driven/gherkin.rb +42 -0
  25. data/lib/plan_driven/github.rb +118 -0
  26. data/lib/plan_driven/guards/migration_guard.rb +94 -0
  27. data/lib/plan_driven/guards/plan_guard.rb +84 -0
  28. data/lib/plan_driven/guards/pr_guard.rb +140 -0
  29. data/lib/plan_driven/guards/ticket_guard.rb +178 -0
  30. data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
  31. data/lib/plan_driven/guards.rb +57 -0
  32. data/lib/plan_driven/http.rb +60 -0
  33. data/lib/plan_driven/json_reply.rb +26 -0
  34. data/lib/plan_driven/llm.rb +80 -0
  35. data/lib/plan_driven/models/approval.rb +12 -0
  36. data/lib/plan_driven/models/event.rb +13 -0
  37. data/lib/plan_driven/models/evidence_run.rb +17 -0
  38. data/lib/plan_driven/models/plan.rb +98 -0
  39. data/lib/plan_driven/models/record.rb +8 -0
  40. data/lib/plan_driven/models/ticket.rb +58 -0
  41. data/lib/plan_driven/models.rb +8 -0
  42. data/lib/plan_driven/railtie.rb +9 -0
  43. data/lib/plan_driven/renderer/html.rb +106 -0
  44. data/lib/plan_driven/renderer/markdown.rb +145 -0
  45. data/lib/plan_driven/renderer/pdf.rb +39 -0
  46. data/lib/plan_driven/renderer/style.css +22 -0
  47. data/lib/plan_driven/renderer.rb +41 -0
  48. data/lib/plan_driven/repository.rb +35 -0
  49. data/lib/plan_driven/schema_context.rb +98 -0
  50. data/lib/plan_driven/template.rb +113 -0
  51. data/lib/plan_driven/ticket_generator.rb +98 -0
  52. data/lib/plan_driven/version.rb +5 -0
  53. data/lib/plan_driven/workflow.rb +59 -0
  54. data/lib/plan_driven.rb +72 -0
  55. metadata +146 -0
data/exe/plan-driven ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "plan_driven"
5
+ require "plan_driven/cli"
6
+
7
+ exit PlanDriven::CLI.start(ARGV)
@@ -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