plan_driven 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/README.md +339 -31
  4. data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
  5. data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
  6. data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
  7. data/app/controllers/plan_driven/wizard/plans_controller.rb +102 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +139 -0
  9. data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
  10. data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
  11. data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
  12. data/app/views/plan_driven/wizard/plans/_finish.html.erb +24 -0
  13. data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
  14. data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
  15. data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
  16. data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
  17. data/app/views/plan_driven/wizard/plans/show.html.erb +36 -0
  18. data/config/routes.rb +16 -0
  19. data/exe/plan-driven +1 -0
  20. data/lib/generators/plan_driven/install_generator.rb +7 -0
  21. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  22. data/lib/plan_driven/cli/config_commands.rb +60 -0
  23. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  24. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  25. data/lib/plan_driven/cli/ticket_commands.rb +23 -3
  26. data/lib/plan_driven/cli/ui.rb +41 -2
  27. data/lib/plan_driven/cli.rb +20 -4
  28. data/lib/plan_driven/configuration.rb +31 -3
  29. data/lib/plan_driven/connections.rb +69 -0
  30. data/lib/plan_driven/cursor_agents.rb +10 -2
  31. data/lib/plan_driven/delivery.rb +23 -5
  32. data/lib/plan_driven/evidence.rb +5 -1
  33. data/lib/plan_driven/github.rb +4 -0
  34. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  35. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  36. data/lib/plan_driven/interview.rb +161 -0
  37. data/lib/plan_driven/local_agents.rb +254 -0
  38. data/lib/plan_driven/renderer/markdown.rb +26 -1
  39. data/lib/plan_driven/template.rb +7 -2
  40. data/lib/plan_driven/usage.rb +114 -0
  41. data/lib/plan_driven/version.rb +1 -1
  42. data/lib/plan_driven/wizard/engine.rb +19 -0
  43. data/lib/plan_driven/wizard.rb +245 -0
  44. data/lib/plan_driven.rb +5 -0
  45. metadata +23 -1
@@ -24,6 +24,7 @@ module PlanDriven
24
24
  guard_report: result.report.to_h)
25
25
  plan.log!("plan.drafted", actor: actor, model: llm.label, attempts: result.attempts,
26
26
  assumptions: result.assumptions)
27
+ Usage.record_llm(plan, llm, "plan drafted", actor: actor)
27
28
  Renderer.write_plan(plan, config: config)
28
29
  [plan, result]
29
30
  end
@@ -43,6 +44,7 @@ module PlanDriven
43
44
  def redraft_section(plan, key, instruction)
44
45
  text = drafter.redraft(plan.sections, key, instruction)
45
46
  edit_section(plan, key, text)
47
+ Usage.record_llm(plan, llm, "#{key} redrafted", actor: actor)
46
48
  text
47
49
  end
48
50
 
@@ -76,7 +78,11 @@ module PlanDriven
76
78
 
77
79
  def draft_tickets(plan, instruction: nil)
78
80
  require_status!(plan, %w[approved ticketed])
79
- result = TicketGenerator.new(llm: llm, schema: schema, config: config).generate(plan, instruction: instruction)
81
+ result = begin
82
+ TicketGenerator.new(llm: llm, schema: schema, config: config).generate(plan, instruction: instruction)
83
+ ensure
84
+ Usage.record_llm(plan, llm, instruction ? "tickets redrafted" : "tickets drafted", actor: actor)
85
+ end
80
86
  raise GuardError, result.report.errors unless result.report.ok?
81
87
 
82
88
  replace_tickets(plan, result)
@@ -190,6 +196,7 @@ module PlanDriven
190
196
  method: config.merge_method)
191
197
  ticket.update!(merged_sha: result["sha"])
192
198
  Workflow.ticket_transition!(ticket, "merged")
199
+ clean_up_agent(ticket)
193
200
  ticket.plan.log!("ticket.merged", actor: actor, ticket: ticket, sha: result["sha"])
194
201
  finish(ticket.plan)
195
202
  ticket
@@ -233,13 +240,14 @@ module PlanDriven
233
240
  run = agents.run(ticket.agent_id, ticket.agent_run_id)
234
241
  return unless run.terminal?
235
242
 
243
+ Usage.record_agent(ticket, run, agents, actor: actor, config: config)
236
244
  if run.finished? && run.pr_url
237
245
  ticket.update!(pr_url: run.pr_url, pr_number: GitHub.pr_number(run.pr_url), branch: run.branch)
238
246
  Workflow.ticket_transition!(ticket, "pr_open")
239
- ticket.plan.log!("ticket.pr_opened", actor: "cursor-agent", ticket: ticket, pr: run.pr_url)
247
+ ticket.plan.log!("ticket.pr_opened", actor: agent_actor, ticket: ticket, pr: run.pr_url)
240
248
  else
241
249
  Workflow.ticket_transition!(ticket, "failed")
242
- ticket.plan.log!("ticket.agent_failed", actor: "cursor-agent", ticket: ticket, status: run.status,
250
+ ticket.plan.log!("ticket.agent_failed", actor: agent_actor, ticket: ticket, status: run.status,
243
251
  result: run.result.to_s[0, 500])
244
252
  end
245
253
  end
@@ -250,6 +258,7 @@ module PlanDriven
250
258
 
251
259
  ticket.update!(merged_sha: pull["merge_commit_sha"])
252
260
  Workflow.ticket_transition!(ticket, "merged")
261
+ clean_up_agent(ticket)
253
262
  ticket.plan.log!("ticket.merged", actor: pull.dig("merged_by", "login") || "github", ticket: ticket,
254
263
  sha: pull["merge_commit_sha"], outside_plan_driven: true)
255
264
  end
@@ -262,6 +271,14 @@ module PlanDriven
262
271
  plan.log!("plan.delivered", actor: actor)
263
272
  end
264
273
 
274
+ def clean_up_agent(ticket)
275
+ agents.cleanup(ticket.agent_id) if ticket.agent_id && agents.respond_to?(:cleanup)
276
+ end
277
+
278
+ def agent_actor
279
+ "#{config.agent_provider}-agent"
280
+ end
281
+
265
282
  def feature_files(files, sha)
266
283
  files.select { |file| file["filename"].end_with?(".feature") && file["status"] != "removed" }
267
284
  .to_h { |file| [file["filename"], github.file(file["filename"], ref: sha)] }
@@ -344,7 +361,8 @@ module PlanDriven
344
361
  end
345
362
 
346
363
  def llm
347
- @llm ||= LLM.new(config)
364
+ @llm = MeteredLLM.new(@llm || LLM.new(config)) unless @llm.is_a?(MeteredLLM)
365
+ @llm
348
366
  end
349
367
 
350
368
  def schema
@@ -356,7 +374,7 @@ module PlanDriven
356
374
  end
357
375
 
358
376
  def agents
359
- @agents ||= CursorAgents.new(config: config)
377
+ @agents ||= Agents.build(config)
360
378
  end
361
379
  end
362
380
  end
@@ -17,6 +17,10 @@ module PlanDriven
17
17
  end
18
18
  end
19
19
 
20
+ # Scenarios commit and wipe data, so they never run against the development database, even
21
+ # when plan-driven itself was started from a development server (the wizard).
22
+ CUCUMBER_ENV = { "RAILS_ENV" => "test", "RACK_ENV" => "test" }.freeze
23
+
20
24
  module_function
21
25
 
22
26
  def tag_expression(plan)
@@ -29,7 +33,7 @@ module PlanDriven
29
33
  shown = command&.join(" ") || %(bundle exec cucumber --tags "#{tag_expression(plan)}")
30
34
  command ||= ["bundle", "exec", "cucumber", "--tags", tag_expression(plan), "--format", "json", "--out", out,
31
35
  "--format", "progress"]
32
- output, status = Open3.capture2e(*command, chdir: root.to_s)
36
+ output, status = Open3.capture2e(CUCUMBER_ENV, *command, chdir: root.to_s)
33
37
  json = File.exist?(out) ? File.read(out) : "[]"
34
38
  record(plan, json, command: shown, actor: actor, exit_ok: status.success?, output: output)
35
39
  end
@@ -33,6 +33,10 @@ module PlanDriven
33
33
  request(:post, "/repos/#{repository}/issues/#{number}/comments", { body: body })
34
34
  end
35
35
 
36
+ def create_pull(title:, head:, base:, body:)
37
+ request(:post, "/repos/#{repository}/pulls", { title: title, head: head, base: base, body: body })
38
+ end
39
+
36
40
  def pull(number)
37
41
  request(:get, "/repos/#{repository}/pulls/#{number}")
38
42
  end
@@ -4,16 +4,29 @@ module PlanDriven
4
4
  module Guards
5
5
  # Zero-downtime rules for the Database changes section: expand first, contract last.
6
6
  class MigrationGuard
7
- DESTRUCTIVE = /\b(remove|drop|delete|rename)\w*\b[^.\n]{0,80}\b(column|table|field)s?\b|
8
- \b(remove_column|drop_table|rename_column|rename_table|change_column)\b/ix
9
- SAFE_REMOVAL = /ignored_columns|expand|contract|in a later (step|phase|release)|after (the )?backfill/i
7
+ # Removing a constraint, an index or a foreign key loses no data, so `remove_check_constraint`
8
+ # is not a removal; the migration methods that drop columns are listed by name.
9
+ DESTRUCTIVE = /
10
+ \b(remov(e|es|ed|ing|al)|drop(s|ped|ping)?|delet(e|es|ed|ing|ion)|renam(e|es|ed|ing))\b
11
+ [^.\n]{0,80}\b(column|table|field)s?\b
12
+ | \b(remove_columns?|remove_reference|remove_belongs_to|remove_timestamps|drop_table|
13
+ rename_column|rename_table|change_column)\b
14
+ /ix
15
+ # A removal is staged when its own sentence says so, or when it sits under a contract step.
16
+ SAFE_REMOVAL = Regexp.union(/ignored_columns|\bcontract\b/i, /in a later (step|phase|release|migration|deploy)/i,
17
+ /after (the )?(backfill|deploy)|once (reads|the code|no code)/i)
18
+ LATER_STEP = /\b(contract|clean[\s-]?up|later (step|phase|release|migration|deploy)|follow[\s-]?up)\b/i
19
+ ROLLBACK = /\b(roll[\s-]?back|down migration|undo)\b/i
20
+ NEGATED = /\b(no|not|never|none|nothing|don't|doesn't|won't|without|isn't|aren't)\b/i
21
+ UNCHANGED = /\b(not\s(be\s)?(changed|modified|touched)|unchanged|untouched|no\schanges?\b|
22
+ keeps?\s(its|their)\s(current|existing)|stays?\sthe\ssame|as\s(it|they)\s(is|are)\stoday)/ix
10
23
  NOT_NULL = /\bnot[\s_-]?null\b|null:\s*false/i
11
24
  NOT_NULL_SAFE = /default|backfill|nullable first|after (the )?backfill|validate/i
12
25
  INDEX = /\badd_index\b|\bindex(es)?\b/i
13
26
  CONCURRENT = /concurrent|algorithm:\s*:concurrently|disable_ddl_transaction/i
14
27
  NEW_TABLE = /
15
28
  create_table\s+[:"']?([a-z][a-z0-9_]+)
16
- | new\s+table:?\s*`?([a-z][a-z0-9_]+)
29
+ | new\s+table(?::\s*`?|\s+`)([a-z][a-z0-9_]+)
17
30
  | create\s+(?:a\s+|the\s+)?(?:new\s+)?`([a-z][a-z0-9_]+)`\s+table
18
31
  | create\s+(?:a\s+|the\s+)?(?:new\s+)?table\s+`?([a-z][a-z0-9_]+)
19
32
  | create:?\s+`([a-z][a-z0-9_]+)`(?!\s+(?:column|index))
@@ -35,18 +48,82 @@ module PlanDriven
35
48
  report
36
49
  end
37
50
 
51
+ # Tables the section creates. One that's already in the schema isn't new, however it's
52
+ # mentioned (a plan citing `20260929_create_rsvps.rb` as an example creates nothing).
38
53
  def new_tables
39
- @text.scan(NEW_TABLE).flatten.compact.map(&:downcase).uniq
54
+ @text.scan(NEW_TABLE).flatten.compact.map(&:downcase).uniq - @schema.tables
40
55
  end
41
56
 
42
57
  private
43
58
 
44
59
  def check_destructive(report)
45
- return unless @text.match?(DESTRUCTIVE)
46
- return if @text.match?(SAFE_REMOVAL)
60
+ created = new_tables
61
+ unsafe = destructive_statements.reject do |statement, heading|
62
+ statement.match?(SAFE_REMOVAL) || statement.match?(ROLLBACK) ||
63
+ heading.to_s.match?(LATER_STEP) || heading.to_s.match?(ROLLBACK) ||
64
+ only_new_tables?(statement, created)
65
+ end
66
+ return if unsafe.empty?
67
+
68
+ report.error("Database changes remove or rename a column or table in one step " \
69
+ "(\"#{unsafe.first.first.strip[0, 90]}\"). Split it: stop using it and add it to " \
70
+ "`ignored_columns`, deploy, then remove it in a later contract step.")
71
+ end
72
+
73
+ # [sentence or code line, the heading it's under] for every change that removes or renames.
74
+ # Headings are only context ("### Removed or renamed columns"), and a negated sentence
75
+ # ("No column is removed") changes nothing.
76
+ def destructive_statements
77
+ statements.select do |statement, _heading, fenced|
78
+ statement.match?(DESTRUCTIVE) && (fenced || !negated?(statement))
79
+ end
80
+ end
81
+
82
+ public
83
+
84
+ # Existing tables the section changes: something is added to them or removed from them.
85
+ # Tables it only compares with ("same as `rsvps`") or names as staying the same
86
+ # ("### Tables not changed") don't count.
87
+ def changed_tables
88
+ removals = destructive_statements.reject { |statement, heading| unchanged?(statement, heading) }
89
+ @schema.tables.select do |table|
90
+ named = /\b#{Regexp.escape(table)}\b/
91
+ paragraph_changing(table).split("\n").any? { |line| !line.match?(UNCHANGED) } ||
92
+ removals.any? { |statement, _| statement.match?(named) }
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def unchanged?(statement, heading)
99
+ statement.match?(UNCHANGED) || heading.to_s.match?(UNCHANGED)
100
+ end
101
+
102
+ # [sentence or code line, the heading above it, inside a code block?] for the whole section.
103
+ def statements
104
+ heading = nil
105
+ fenced = false
106
+ @text.each_line.with_object([]) do |line, found|
107
+ if line.start_with?("```")
108
+ fenced = !fenced
109
+ elsif !fenced && line.match?(/\A\#{1,6}\s/)
110
+ heading = line
111
+ else
112
+ (fenced ? [line] : line.split(/(?<=[.!?])\s+/)).each { |statement| found << [statement, heading, fenced] }
113
+ end
114
+ end
115
+ end
116
+
117
+ # Dropping or changing a table this plan creates touches nothing that runs today.
118
+ def only_new_tables?(statement, created)
119
+ named = statement.scan(/[:`"']([a-z][a-z0-9_]*)\b/).flatten & @schema.tables
120
+ created.any? && named.empty? && created.any? { |table| statement.match?(/\b#{Regexp.escape(table)}\b/) }
121
+ end
47
122
 
48
- report.error("Database changes remove or rename a column or table in one step. Split it: stop using " \
49
- "it and add it to `ignored_columns`, deploy, then remove it in a later migration.")
123
+ # Only a negation before the change counts: "No column is removed", not "Drop it; no code reads it".
124
+ def negated?(statement)
125
+ change = statement =~ /\b(remove|drop|delete|rename|change_column)/i
126
+ change && statement[0, change].match?(NEGATED)
50
127
  end
51
128
 
52
129
  def check_not_null(report)
@@ -129,8 +129,7 @@ module PlanDriven
129
129
  touched = @tickets.flat_map { |ticket| ticket["touches"] }.uniq
130
130
  planned = MigrationGuard.new(@sections["database_changes"].to_s, schema: @schema)
131
131
  new_tables = planned.new_tables
132
- mentioned = @schema.tables.select { |table| @sections["database_changes"].to_s.match?(/\b#{table}\b/) }
133
- (new_tables + mentioned).uniq.each do |table|
132
+ (new_tables + planned.changed_tables).uniq.each do |table|
134
133
  next if touched.include?(table)
135
134
 
136
135
  report.warning("Database changes mention #{table}, but no ticket touches it")
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module PlanDriven
7
+ # The interview as the team changed it, with `plan-driven question` or the wizard's
8
+ # Configuration page. It lives in the application, in config/plan_driven/interview.yml, so it
9
+ # is committed and everyone on the team gets the same questions:
10
+ #
11
+ # questions:
12
+ # who:
13
+ # question: Who owns it, and who reviews the pull requests?
14
+ # success_metric:
15
+ # title: Success metric
16
+ # question: How will we know it worked?
17
+ # group: Overview
18
+ # required: false
19
+ #
20
+ # A key the template already asks changes that question; any other key adds one. Drafted
21
+ # sections belong to the model and can't be changed here.
22
+ module Interview
23
+ PATH = "config/plan_driven/interview.yml"
24
+ KEY = /\A[a-z][a-z0-9_]{1,39}\z/
25
+ FIELDS = %w[title question group required].freeze
26
+ HEADER = "# The plan_driven interview, as changed with `plan-driven question` or the wizard.\n" \
27
+ "# Commit it: everyone on the team gets the same questions.\n"
28
+
29
+ module_function
30
+
31
+ def path(root = PlanDriven.configuration.root_path)
32
+ Pathname(root).join(PATH)
33
+ end
34
+
35
+ def read(root = PlanDriven.configuration.root_path)
36
+ file = path(root)
37
+ return {} unless file.exist?
38
+
39
+ data = YAML.safe_load(file.read) || {}
40
+ questions = data.is_a?(Hash) ? data["questions"] : nil
41
+ questions.is_a?(Hash) ? questions.transform_values { |fields| fields.to_h.slice(*FIELDS) } : {}
42
+ rescue Psych::Exception => e
43
+ raise ConfigurationError, "#{PATH} isn't valid YAML: #{e.message}"
44
+ end
45
+
46
+ # The template with the team's changes applied. Asked sections keep their place; an added
47
+ # question goes after the last section of its group.
48
+ def apply(template, root = PlanDriven.configuration.root_path)
49
+ changes = read(root)
50
+ return template if changes.empty?
51
+
52
+ sections = template.sections.map(&:dup)
53
+ changes.each do |key, fields|
54
+ existing = sections.find { |section| section.key == key }
55
+ next if existing&.drafted?
56
+
57
+ if existing
58
+ update(existing, fields)
59
+ else
60
+ insert(sections, build(key, fields))
61
+ end
62
+ end
63
+ Template.new(sections)
64
+ end
65
+
66
+ # Adds or changes one question. Returns :added or :changed.
67
+ def change(key, fields, template:, root: PlanDriven.configuration.root_path)
68
+ key = validate_key(key, template)
69
+ fields = normalize(fields)
70
+ raise ArgumentError, "Nothing to change: pass --title, --ask, --group, --required or --optional" if fields.empty?
71
+
72
+ changes = read(root)
73
+ changes[key] = changes.fetch(key, {}).merge(fields)
74
+ added = template[key].nil?
75
+ validate_new(changes[key]) if added
76
+ write(changes, root)
77
+ added ? :added : :changed
78
+ end
79
+
80
+ # Removes an added question, or puts a changed one back to the template's default.
81
+ def remove(key, template:, root: PlanDriven.configuration.root_path)
82
+ changes = read(root)
83
+ raise ArgumentError, "#{key} has no changes to remove" unless changes.key?(key.to_s)
84
+
85
+ changes.delete(key.to_s)
86
+ write(changes, root)
87
+ template[key].nil? ? :removed : :reset
88
+ end
89
+
90
+ def origin(key, template:, root: PlanDriven.configuration.root_path)
91
+ return "default" unless read(root).key?(key.to_s)
92
+
93
+ template[key].nil? ? "added" : "changed"
94
+ end
95
+
96
+ def update(section, fields)
97
+ section.title = fields["title"] if fields["title"].to_s.strip != ""
98
+ section.question = fields["question"] if fields["question"].to_s.strip != ""
99
+ section.group = fields["group"] if Template::GROUPS.include?(fields["group"])
100
+ return unless fields.key?("required")
101
+
102
+ section.required = fields["required"] == true
103
+ section.min_words = section.required ? [section.min_words.to_i, 1].max : 0
104
+ end
105
+
106
+ def build(key, fields)
107
+ required = fields["required"] == true
108
+ group = Template::GROUPS.include?(fields["group"]) ? fields["group"] : "Overview"
109
+ Template::Section.new(
110
+ key: key, title: fields["title"].to_s, group: group, source: :ask, required: required,
111
+ min_words: required ? 1 : 0, question: fields["question"].to_s,
112
+ guidance: "The team's answer to: #{fields["question"]}"
113
+ )
114
+ end
115
+
116
+ def normalize(fields)
117
+ fields = fields.to_h.transform_keys(&:to_s).slice(*FIELDS).compact
118
+ %w[title question].each { |name| fields[name] = fields[name].to_s.strip if fields.key?(name) }
119
+ validate_group(fields["group"]) if fields.key?("group")
120
+ fields
121
+ end
122
+
123
+ def validate_new(fields)
124
+ raise ArgumentError, "A new question needs --title" if fields["title"].to_s.empty?
125
+ raise ArgumentError, "A new question needs --ask \"the question\"" if fields["question"].to_s.empty?
126
+ end
127
+
128
+ def insert(sections, section)
129
+ index = sections.rindex { |existing| existing.group == section.group }
130
+ index ? sections.insert(index + 1, section) : sections << section
131
+ end
132
+
133
+ def validate_key(key, template)
134
+ key = key.to_s.strip
135
+ unless key.match?(KEY)
136
+ raise ArgumentError,
137
+ "#{key.inspect} isn't a question key: lower case letters, digits and _, like success_metric"
138
+ end
139
+ if template[key]&.drafted?
140
+ raise ArgumentError, "#{key} is drafted by the model, not asked; only asked questions can be changed"
141
+ end
142
+
143
+ key
144
+ end
145
+
146
+ def validate_group(group)
147
+ return if Template::GROUPS.include?(group)
148
+
149
+ raise ArgumentError, "Unknown group #{group.inspect}. One of: #{Template::GROUPS.join(", ")}"
150
+ end
151
+
152
+ def write(changes, root)
153
+ file = path(root)
154
+ return file.tap { FileUtils.rm_f(file) } if changes.empty?
155
+
156
+ FileUtils.mkdir_p(file.dirname)
157
+ file.write(HEADER + YAML.dump("questions" => changes).delete_prefix("---\n"))
158
+ file
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "securerandom"
7
+ require "shellwords"
8
+
9
+ module PlanDriven
10
+ # Which coding agents take the tickets: config.agent_provider.
11
+ module Agents
12
+ def self.build(config = PlanDriven.configuration)
13
+ case config.agent_provider.to_sym
14
+ when :cursor then CursorAgents.new(config: config)
15
+ when :local then LocalAgents.new(config: config)
16
+ else raise ConfigurationError, "config.agent_provider is #{config.agent_provider.inspect}; use :cursor or :local"
17
+ end
18
+ end
19
+ end
20
+
21
+ # Runs a coding agent CLI on this machine: Claude Code, Codex, the Cursor CLI, or anything else
22
+ # that edits the files in its working directory. The prompt arrives on stdin, or wherever the
23
+ # command says `{prompt_file}`.
24
+ #
25
+ # config.agent_provider = :local
26
+ # config.agent_command = "claude -p --permission-mode acceptEdits --output-format json"
27
+ # config.agent_command = "codex exec --full-auto -"
28
+ # config.agent_command = 'cursor-agent -p --force --output-format json "$(cat {prompt_file})"'
29
+ #
30
+ # Each ticket gets its own git worktree and branch under tmp/plan_driven/agents. When the
31
+ # command exits cleanly, plan_driven commits what it left, pushes the branch and opens the pull
32
+ # request, so the rest of the workflow (review, feedback, merge) is the same as with Cursor.
33
+ class LocalAgents
34
+ PR_BODY = "PR_DESCRIPTION.md"
35
+ INSTRUCTIONS = <<~TEXT.freeze
36
+ # Working locally
37
+ You are in a git worktree on the ticket's own branch. Don't push and don't open the pull request:
38
+ plan_driven does both when you finish. Write the pull request description (the part described
39
+ above) to `#{PR_BODY}` in the repository root; it becomes the pull request body and isn't committed.
40
+ Committing is optional, anything you leave uncommitted is committed for you.
41
+ TEXT
42
+
43
+ Run = CursorAgents::Run
44
+
45
+ def initialize(config: PlanDriven.configuration, github: nil)
46
+ @config = config
47
+ @github = github
48
+ end
49
+
50
+ def launch(prompt:, repo_url:, name:) # rubocop:disable Lint/UnusedMethodArgument
51
+ command!
52
+ id = "local-#{SecureRandom.hex(4)}"
53
+ state = { "id" => id, "name" => name, "branch" => "plan-driven/#{slug(name)}-#{id.delete_prefix("local-")}",
54
+ "dir" => root.join(id).to_s, "runs" => [] }
55
+ git(@config.root_path, "fetch", "--quiet", "origin", @config.base_branch)
56
+ git(@config.root_path, "worktree", "add", "--quiet", "-b", state["branch"], state["dir"],
57
+ "origin/#{@config.base_branch}")
58
+ run = start(state, "#{prompt}\n\n#{INSTRUCTIONS}")
59
+ [{ "id" => id, "url" => nil }, run]
60
+ end
61
+
62
+ def run(agent_id, run_id)
63
+ state = load(agent_id)
64
+ entry = state["runs"].find { |item| item["id"] == run_id } or raise ProviderError, "no local run #{run_id}"
65
+ return to_run(state, entry) if entry["status"]
66
+
67
+ exit_file = Pathname(entry["exit_file"])
68
+ if !exit_file.exist? && alive?(entry["pid"])
69
+ return to_run(state, entry, "RUNNING") unless overdue?(entry)
70
+
71
+ stop(entry["pid"])
72
+ return settle(state, entry, "EXPIRED", "stopped after #{@config.agent_timeout}s (config.agent_timeout)")
73
+ end
74
+
75
+ code = exit_file.exist? ? exit_file.read.strip.to_i : 1
76
+ unless code.zero?
77
+ return settle(state, entry, "ERROR",
78
+ "the agent command exited with #{code}: #{log_tail(entry)}")
79
+ end
80
+
81
+ publish(state, entry)
82
+ end
83
+
84
+ def follow_up(agent_id, text)
85
+ state = load(agent_id)
86
+ start(state, "#{text}\n\n#{INSTRUCTIONS}")
87
+ end
88
+
89
+ # Tokens, when the command reports them the way Claude Code's `--output-format json` does.
90
+ def usage(agent_id, run_id)
91
+ entry = load(agent_id)["runs"].find { |item| item["id"] == run_id }
92
+ data = entry && reported_usage(Pathname(entry["log"]))
93
+ return {} unless data
94
+
95
+ { "input_tokens" => data["input_tokens"].to_i, "output_tokens" => data["output_tokens"].to_i,
96
+ "cache_write_tokens" => data["cache_creation_input_tokens"].to_i,
97
+ "cache_read_tokens" => data["cache_read_input_tokens"].to_i }
98
+ end
99
+
100
+ # Once the pull request is merged, the worktree and its local branch go.
101
+ def cleanup(agent_id)
102
+ state = load(agent_id)
103
+ git(@config.root_path, "worktree", "remove", "--force", state["dir"]) if Dir.exist?(state["dir"])
104
+ git_ok?(@config.root_path, "branch", "-D", state["branch"])
105
+ rescue ProviderError
106
+ nil
107
+ end
108
+
109
+ def me
110
+ { "apiKeyName" => "local: #{command!}" }
111
+ end
112
+
113
+ def model_ids
114
+ []
115
+ end
116
+
117
+ private
118
+
119
+ def start(state, prompt)
120
+ number = state["runs"].size + 1
121
+ base = root.join("#{state["id"]}-run-#{number}")
122
+ File.write("#{base}.prompt", prompt)
123
+ prompt_file = Shellwords.escape("#{base}.prompt")
124
+ agent = if command!.include?("{prompt_file}")
125
+ "#{command!.gsub("{prompt_file}", prompt_file)} < /dev/null"
126
+ else
127
+ "#{command!} < #{prompt_file}"
128
+ end
129
+ script = "#{agent} > #{Shellwords.escape("#{base}.log")} 2>&1; echo $? > #{Shellwords.escape("#{base}.exit")}"
130
+ pid = Process.spawn("sh", "-c", script, chdir: state["dir"], pgroup: true, in: File::NULL)
131
+ Process.detach(pid)
132
+ entry = { "id" => "#{state["id"]}-run-#{number}", "pid" => pid, "started_at" => Time.now.to_i,
133
+ "log" => "#{base}.log", "exit_file" => "#{base}.exit" }
134
+ state["runs"] << entry
135
+ save(state)
136
+ to_run(state, entry, "RUNNING")
137
+ end
138
+
139
+ # Commit, push, and open the pull request the first time; later runs push to the same one.
140
+ def publish(state, entry)
141
+ dir = Pathname(state["dir"])
142
+ body = commit_all(dir, state["name"])
143
+ if git(dir, "rev-list", "--count", "origin/#{@config.base_branch}..HEAD").strip == "0"
144
+ return settle(state, entry, "ERROR", "the agent finished without changing anything: #{log_tail(entry)}")
145
+ end
146
+
147
+ git(dir, "push", "--quiet", "--set-upstream", "origin", state["branch"])
148
+ state["pr_url"] ||= github.create_pull(title: state["name"], head: state["branch"], base: @config.base_branch,
149
+ body: body || state["name"])["html_url"]
150
+ settle(state, entry, "FINISHED", body.to_s[0, 500])
151
+ rescue ProviderError => e
152
+ settle(state, entry, "ERROR", e.message)
153
+ end
154
+
155
+ # Commits what the agent left, without its pull request description, which is returned.
156
+ def commit_all(dir, message)
157
+ body_file = dir.join(PR_BODY)
158
+ body = body_file.exist? ? body_file.read : nil
159
+ FileUtils.rm_f(body_file)
160
+ git(dir, "add", "--all")
161
+ git(dir, "commit", "--quiet", "-m", message) unless git_ok?(dir, "diff", "--cached", "--quiet")
162
+ body
163
+ end
164
+
165
+ def settle(state, entry, status, result)
166
+ entry["status"] = status
167
+ entry["result"] = result
168
+ entry["duration_ms"] = (Time.now.to_i - entry["started_at"]) * 1000
169
+ save(state)
170
+ to_run(state, entry)
171
+ end
172
+
173
+ def to_run(state, entry, status = entry["status"])
174
+ Run.new(id: entry["id"], agent_id: state["id"], status: status, result: entry["result"],
175
+ branch: state["branch"], pr_url: status == "FINISHED" ? state["pr_url"] : nil,
176
+ duration_ms: entry["duration_ms"])
177
+ end
178
+
179
+ def reported_usage(log)
180
+ return unless log.exist?
181
+
182
+ log.read.lines.reverse_each do |line|
183
+ data = JSON.parse(line)
184
+ return data["usage"] if data.is_a?(Hash) && data["usage"].is_a?(Hash)
185
+ rescue JSON::ParserError
186
+ next
187
+ end
188
+ nil
189
+ end
190
+
191
+ def command!
192
+ @config.agent_command.presence or
193
+ raise ConfigurationError, "config.agent_provider is :local, so set config.agent_command, for example " \
194
+ "\"claude -p --permission-mode acceptEdits --output-format json\""
195
+ end
196
+
197
+ def git(dir, *args)
198
+ out, status = Open3.capture2e("git", *args, chdir: dir.to_s)
199
+ raise ProviderError, "git #{args.first} failed: #{out.strip.last(300)}" unless status.success?
200
+
201
+ out
202
+ end
203
+
204
+ def git_ok?(dir, *args)
205
+ _out, status = Open3.capture2e("git", *args, chdir: dir.to_s)
206
+ status.success?
207
+ end
208
+
209
+ def alive?(pid)
210
+ Process.kill(0, pid)
211
+ true
212
+ rescue Errno::ESRCH, Errno::EPERM
213
+ false
214
+ end
215
+
216
+ def overdue?(entry)
217
+ Time.now.to_i - entry["started_at"] > @config.agent_timeout.to_i
218
+ end
219
+
220
+ def stop(pid)
221
+ Process.kill("TERM", -pid)
222
+ rescue Errno::ESRCH, Errno::EPERM
223
+ nil
224
+ end
225
+
226
+ def log_tail(entry)
227
+ log = Pathname(entry["log"])
228
+ log.exist? ? log.read.strip.last(300) : "no output"
229
+ end
230
+
231
+ def slug(name)
232
+ name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")[0, 40]
233
+ end
234
+
235
+ def root
236
+ @config.root_path.join("tmp/plan_driven/agents").tap { |dir| FileUtils.mkdir_p(dir) }
237
+ end
238
+
239
+ def load(agent_id)
240
+ path = root.join("#{agent_id}.json")
241
+ raise ProviderError, "no local agent #{agent_id} (#{path} is missing)" unless path.exist?
242
+
243
+ JSON.parse(path.read)
244
+ end
245
+
246
+ def save(state)
247
+ File.write(root.join("#{state["id"]}.json"), JSON.pretty_generate(state))
248
+ end
249
+
250
+ def github
251
+ @github ||= GitHub.new
252
+ end
253
+ end
254
+ end