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
data/lib/plan_driven/delivery.rb
CHANGED
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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
|
|
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 ||=
|
|
377
|
+
@agents ||= Agents.build(config)
|
|
360
378
|
end
|
|
361
379
|
end
|
|
362
380
|
end
|
data/lib/plan_driven/evidence.rb
CHANGED
|
@@ -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
|
data/lib/plan_driven/github.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|