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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# The whole process, one method per step. Each step checks the phase, runs its guard, records
|
|
5
|
+
# who did it, and leaves documentation behind. The CLI is a thin layer over this.
|
|
6
|
+
class Delivery
|
|
7
|
+
attr_reader :actor, :config
|
|
8
|
+
|
|
9
|
+
def initialize(actor: PlanDriven.actor, config: PlanDriven.configuration, llm: nil, schema: nil,
|
|
10
|
+
github: nil, agents: nil)
|
|
11
|
+
@actor = actor
|
|
12
|
+
@config = config
|
|
13
|
+
@llm = llm
|
|
14
|
+
@schema = schema
|
|
15
|
+
@github = github
|
|
16
|
+
@agents = agents
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# -- Plan -------------------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def create_plan(title:, answers:)
|
|
22
|
+
result = drafter.draft(answers, title: title)
|
|
23
|
+
plan = Plan.create!(title: title, interview: answers, sections: result.sections, created_by: actor,
|
|
24
|
+
guard_report: result.report.to_h)
|
|
25
|
+
plan.log!("plan.drafted", actor: actor, model: llm.label, attempts: result.attempts,
|
|
26
|
+
assumptions: result.assumptions)
|
|
27
|
+
Renderer.write_plan(plan, config: config)
|
|
28
|
+
[plan, result]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check_plan(plan)
|
|
32
|
+
report = Guards::PlanGuard.new(plan.sections, schema: schema, template: config.template).call
|
|
33
|
+
plan.update!(guard_report: report.to_h)
|
|
34
|
+
report
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def edit_section(plan, key, text)
|
|
38
|
+
plan.update_sections!({ key => text }, actor: actor)
|
|
39
|
+
check_plan(plan)
|
|
40
|
+
Renderer.write_plan(plan, config: config)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def redraft_section(plan, key, instruction)
|
|
44
|
+
text = drafter.redraft(plan.sections, key, instruction)
|
|
45
|
+
edit_section(plan, key, text)
|
|
46
|
+
text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def submit(plan)
|
|
50
|
+
report = check_plan(plan)
|
|
51
|
+
raise GuardError, report.errors unless report.ok?
|
|
52
|
+
|
|
53
|
+
Workflow.plan_transition!(plan, "in_review")
|
|
54
|
+
plan.log!("plan.submitted", actor: actor, revision: plan.revision, warnings: report.warnings)
|
|
55
|
+
Renderer.write_plan(plan, config: config)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def approve_plan(plan, role:, note: nil)
|
|
59
|
+
decide(plan, role: role, decision: "approved", note: note)
|
|
60
|
+
if plan.missing_approvals.empty?
|
|
61
|
+
Workflow.plan_transition!(plan, "approved")
|
|
62
|
+
plan.log!("plan.approved", actor: actor, revision: plan.revision)
|
|
63
|
+
end
|
|
64
|
+
Renderer.write_plan(plan, config: config)
|
|
65
|
+
plan.missing_approvals
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reject_plan(plan, role:, note:)
|
|
69
|
+
decide(plan, role: role, decision: "rejected", note: note)
|
|
70
|
+
Workflow.plan_transition!(plan, "draft")
|
|
71
|
+
plan.log!("plan.changes_requested", actor: actor, note: note)
|
|
72
|
+
Renderer.write_plan(plan, config: config)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -- Tickets ----------------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def draft_tickets(plan, instruction: nil)
|
|
78
|
+
require_status!(plan, %w[approved ticketed])
|
|
79
|
+
result = TicketGenerator.new(llm: llm, schema: schema, config: config).generate(plan, instruction: instruction)
|
|
80
|
+
raise GuardError, result.report.errors unless result.report.ok?
|
|
81
|
+
|
|
82
|
+
replace_tickets(plan, result)
|
|
83
|
+
Renderer.write_plan(plan, config: config)
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def approve_tickets(plan, role:, note: nil)
|
|
88
|
+
require_status!(plan, %w[ticketed])
|
|
89
|
+
decide(plan, role: "tickets:#{role}", decision: "approved", note: note)
|
|
90
|
+
return plan.missing_ticket_approvals if plan.missing_ticket_approvals.any?
|
|
91
|
+
|
|
92
|
+
Plan.transaction do
|
|
93
|
+
plan.tickets.each { |ticket| Workflow.ticket_transition!(ticket, "approved") }
|
|
94
|
+
Workflow.plan_transition!(plan, "tickets_approved")
|
|
95
|
+
plan.log!("tickets.approved", actor: actor, count: plan.tickets.size)
|
|
96
|
+
end
|
|
97
|
+
sync_issues(plan) if config.sync_issues
|
|
98
|
+
Renderer.write_plan(plan, config: config)
|
|
99
|
+
[]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sync_issues(plan)
|
|
103
|
+
plan.tickets.reject(&:issue_number).each do |ticket|
|
|
104
|
+
issue = github.create_issue(title: "[#{ticket.reference}] #{ticket.title}", body: issue_body(ticket),
|
|
105
|
+
labels: config.issue_labels + [plan.key.downcase, ticket.kind])
|
|
106
|
+
ticket.update!(issue_number: issue["number"])
|
|
107
|
+
plan.log!("ticket.issue_created", actor: actor, ticket: ticket, issue: issue["number"])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# -- Development ------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
# Ready tickets, within the parallel agent limit, optionally narrowed to the given keys.
|
|
114
|
+
def startable(plan, only: nil)
|
|
115
|
+
capacity = [config.max_parallel_agents - plan.tickets.count(&:active_agent?), 0].max
|
|
116
|
+
candidates = plan.tickets.select(&:ready?)
|
|
117
|
+
keys = Array(only).map(&:upcase)
|
|
118
|
+
candidates = candidates.select { |ticket| keys.include?(ticket.key) } if keys.any?
|
|
119
|
+
candidates.first(capacity)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def develop(plan, only: nil)
|
|
123
|
+
require_status!(plan, %w[tickets_approved in_development])
|
|
124
|
+
launched = startable(plan, only: only).map { |ticket| launch(ticket) }
|
|
125
|
+
Workflow.plan_transition!(plan, "in_development") if launched.any? && plan.status == "tickets_approved"
|
|
126
|
+
launched
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def launch(ticket, prompt: AgentPrompt.new(ticket, config: config))
|
|
130
|
+
agent, run = agents.launch(prompt: prompt.to_s, repo_url: github.repo_url, name: prompt.pr_title)
|
|
131
|
+
Workflow.ticket_transition!(ticket, "running")
|
|
132
|
+
ticket.update!(agent_id: agent["id"], agent_run_id: run.id, agent_url: agent["url"])
|
|
133
|
+
ticket.plan.log!("ticket.agent_started", actor: actor, ticket: ticket, agent: agent["id"], run: run.id)
|
|
134
|
+
ticket
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Polls running agents and open pull requests, moving tickets along as work lands.
|
|
138
|
+
def refresh(plan)
|
|
139
|
+
plan.tickets.each do |ticket|
|
|
140
|
+
case ticket.status
|
|
141
|
+
when "running" then refresh_agent(ticket)
|
|
142
|
+
when "pr_open", "pr_approved" then refresh_pull(ticket)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
finish(plan)
|
|
146
|
+
plan.tickets.reset
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def review(ticket)
|
|
150
|
+
require_ticket_status!(ticket, %w[pr_open pr_approved])
|
|
151
|
+
pull = github.pull(ticket.pr_number)
|
|
152
|
+
files = github.pull_files(ticket.pr_number)
|
|
153
|
+
features = feature_files(files, pull.dig("head", "sha"))
|
|
154
|
+
checks = github.checks(pull.dig("head", "sha"))
|
|
155
|
+
report = Guards::PrGuard.new(ticket, pull: pull, files: files, checks: checks, features: features,
|
|
156
|
+
behind: github.behind_by(pull), config: config).call
|
|
157
|
+
ticket.update!(guard_report: report.to_h)
|
|
158
|
+
ticket.plan.log!("ticket.reviewed", actor: actor, ticket: ticket, errors: report.errors.size,
|
|
159
|
+
warnings: report.warnings.size)
|
|
160
|
+
report
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def approve_pr(ticket, note: nil)
|
|
164
|
+
report = review(ticket)
|
|
165
|
+
raise GuardError, report.errors unless report.ok?
|
|
166
|
+
|
|
167
|
+
decide(ticket, role: "pr", decision: "approved", note: note)
|
|
168
|
+
Workflow.ticket_transition!(ticket, "pr_approved") if ticket.status == "pr_open"
|
|
169
|
+
ticket.plan.log!("ticket.pr_approved", actor: actor, ticket: ticket, pr: ticket.pr_number)
|
|
170
|
+
github.review(ticket.pr_number, body: "Approved in plan-driven by #{actor}. #{note}".strip)
|
|
171
|
+
report
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def request_changes(ticket, feedback)
|
|
175
|
+
require_ticket_status!(ticket, %w[pr_open pr_approved failed])
|
|
176
|
+
decide(ticket, role: "pr", decision: "rejected", note: feedback) unless ticket.status == "failed"
|
|
177
|
+
Workflow.ticket_transition!(ticket, "changes_requested") unless ticket.status == "failed"
|
|
178
|
+
run = agents.follow_up(ticket.agent_id, follow_up_prompt(ticket, feedback))
|
|
179
|
+
Workflow.ticket_transition!(ticket, "running")
|
|
180
|
+
ticket.update!(agent_run_id: run.id)
|
|
181
|
+
ticket.plan.log!("ticket.changes_requested", actor: actor, ticket: ticket, feedback: feedback, run: run.id)
|
|
182
|
+
ticket
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def merge(ticket)
|
|
186
|
+
require_ticket_status!(ticket, %w[pr_approved])
|
|
187
|
+
require_mergeable!(ticket)
|
|
188
|
+
github.ready_for_review(ticket.pr_number) if github.pull(ticket.pr_number)["draft"]
|
|
189
|
+
result = github.merge(ticket.pr_number, title: "#{ticket.title} (#{ticket.reference})",
|
|
190
|
+
method: config.merge_method)
|
|
191
|
+
ticket.update!(merged_sha: result["sha"])
|
|
192
|
+
Workflow.ticket_transition!(ticket, "merged")
|
|
193
|
+
ticket.plan.log!("ticket.merged", actor: actor, ticket: ticket, sha: result["sha"])
|
|
194
|
+
finish(ticket.plan)
|
|
195
|
+
ticket
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# -- Evidence and report ----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def evidence(plan, command: nil)
|
|
201
|
+
Evidence.run_cucumber(plan, actor: actor, command: command)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def report(plan)
|
|
205
|
+
plan.log!("report.written", actor: actor)
|
|
206
|
+
Renderer.write_report(plan, config: config)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def require_mergeable!(ticket)
|
|
212
|
+
report = review(ticket)
|
|
213
|
+
raise GuardError, report.errors unless report.ok?
|
|
214
|
+
return unless report.warnings.any? { |warning| warning.include?("still running") }
|
|
215
|
+
|
|
216
|
+
raise GuardError, ["CI is still running; wait for it to finish"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def decide(record, role:, decision:, note:)
|
|
220
|
+
plan = record.is_a?(Plan) ? record : record.plan
|
|
221
|
+
if record.is_a?(Plan) && !role.start_with?("tickets:")
|
|
222
|
+
require_status!(plan, %w[in_review])
|
|
223
|
+
allowed = config.plan_approvals.map(&:to_s)
|
|
224
|
+
unless allowed.include?(role.to_s)
|
|
225
|
+
raise ArgumentError,
|
|
226
|
+
"unknown role #{role}; this project approves as #{allowed.join(", ")}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
record.approvals.create!(role: role.to_s, decision: decision, actor: actor, note: note, revision: plan.revision)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def refresh_agent(ticket)
|
|
233
|
+
run = agents.run(ticket.agent_id, ticket.agent_run_id)
|
|
234
|
+
return unless run.terminal?
|
|
235
|
+
|
|
236
|
+
if run.finished? && run.pr_url
|
|
237
|
+
ticket.update!(pr_url: run.pr_url, pr_number: GitHub.pr_number(run.pr_url), branch: run.branch)
|
|
238
|
+
Workflow.ticket_transition!(ticket, "pr_open")
|
|
239
|
+
ticket.plan.log!("ticket.pr_opened", actor: "cursor-agent", ticket: ticket, pr: run.pr_url)
|
|
240
|
+
else
|
|
241
|
+
Workflow.ticket_transition!(ticket, "failed")
|
|
242
|
+
ticket.plan.log!("ticket.agent_failed", actor: "cursor-agent", ticket: ticket, status: run.status,
|
|
243
|
+
result: run.result.to_s[0, 500])
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def refresh_pull(ticket)
|
|
248
|
+
pull = github.pull(ticket.pr_number)
|
|
249
|
+
return unless pull["merged"]
|
|
250
|
+
|
|
251
|
+
ticket.update!(merged_sha: pull["merge_commit_sha"])
|
|
252
|
+
Workflow.ticket_transition!(ticket, "merged")
|
|
253
|
+
ticket.plan.log!("ticket.merged", actor: pull.dig("merged_by", "login") || "github", ticket: ticket,
|
|
254
|
+
sha: pull["merge_commit_sha"], outside_plan_driven: true)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def finish(plan)
|
|
258
|
+
plan.tickets.reset
|
|
259
|
+
return unless plan.status == "in_development" && plan.tickets.all? { |ticket| ticket.status == "merged" }
|
|
260
|
+
|
|
261
|
+
Workflow.plan_transition!(plan, "delivered")
|
|
262
|
+
plan.log!("plan.delivered", actor: actor)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def feature_files(files, sha)
|
|
266
|
+
files.select { |file| file["filename"].end_with?(".feature") && file["status"] != "removed" }
|
|
267
|
+
.to_h { |file| [file["filename"], github.file(file["filename"], ref: sha)] }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def follow_up_prompt(ticket, feedback)
|
|
271
|
+
report = Guards::Report.from_h(ticket.guard_report)
|
|
272
|
+
checks = if report.errors.any?
|
|
273
|
+
"\n\nThe automated checks also found:\n#{report.errors.map do |e|
|
|
274
|
+
"- #{e}"
|
|
275
|
+
end.join("\n")}"
|
|
276
|
+
else
|
|
277
|
+
""
|
|
278
|
+
end
|
|
279
|
+
"Review feedback on the pull request for #{ticket.reference}:\n\n#{feedback}#{checks}\n\n" \
|
|
280
|
+
"Push the fixes to the same branch. Keep the pull request title and description format."
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def issue_body(ticket)
|
|
284
|
+
criteria = ticket.criteria.each_with_index.map { |criterion, index| "- [ ] #{index + 1}. #{unlinked(criterion)}" }
|
|
285
|
+
notes = ticket.implementation_notes.presence
|
|
286
|
+
<<~MD
|
|
287
|
+
#{unlinked(ticket.story)}
|
|
288
|
+
|
|
289
|
+
#{unlinked(ticket.description)}
|
|
290
|
+
|
|
291
|
+
### Acceptance criteria
|
|
292
|
+
#{criteria.join("\n")}
|
|
293
|
+
#{"\n### Implementation notes\n#{unlinked(notes)}\n" if notes}
|
|
294
|
+
---
|
|
295
|
+
Plan #{ticket.plan.key}: #{ticket.plan.title} · kind: #{ticket.kind} · estimate: #{ticket.estimate}
|
|
296
|
+
#{"· depends on #{ticket.dependencies.join(", ")}" if ticket.dependencies.any?}
|
|
297
|
+
MD
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# GitHub links "#1" and "@name" anywhere outside code, so "You're #1 on the waitlist" would point
|
|
301
|
+
# at an unrelated pull request and "(@event)" would mention a user. An empty comment breaks
|
|
302
|
+
# the link and renders nothing.
|
|
303
|
+
def unlinked(text)
|
|
304
|
+
text.to_s.split(/(```.*?```|`[^`\n]*`)/m).each_with_index.map do |part, index|
|
|
305
|
+
index.odd? ? part : part.gsub(/(?<![\w&])([#@])(?=\w)/, '\1<!-- -->')
|
|
306
|
+
end.join
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def replace_tickets(plan, result)
|
|
310
|
+
Plan.transaction do
|
|
311
|
+
plan.tickets.destroy_all
|
|
312
|
+
result.tickets.each { |attributes| plan.tickets.create!(ticket_attributes(attributes)) }
|
|
313
|
+
Workflow.plan_transition!(plan, "ticketed") if plan.status == "approved"
|
|
314
|
+
plan.log!("tickets.drafted", actor: actor, count: result.tickets.size, attempts: result.attempts,
|
|
315
|
+
fixes: result.report.fixes, warnings: result.report.warnings)
|
|
316
|
+
end
|
|
317
|
+
plan.tickets.reset
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def ticket_attributes(attributes)
|
|
321
|
+
{
|
|
322
|
+
key: attributes["key"], position: attributes["position"], title: attributes["title"],
|
|
323
|
+
kind: attributes["kind"], ticket_type: attributes["type"], story: attributes["story"],
|
|
324
|
+
description: attributes["description"], acceptance_criteria: attributes["acceptance_criteria"],
|
|
325
|
+
implementation_notes: attributes["implementation_notes"], estimate: attributes["estimate"],
|
|
326
|
+
depends_on: attributes["depends_on"], touches: attributes["touches"]
|
|
327
|
+
}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def require_status!(plan, statuses)
|
|
331
|
+
return if statuses.include?(plan.status)
|
|
332
|
+
|
|
333
|
+
raise TransitionError, "plan #{plan.key} is #{plan.status} (#{Workflow::PLAN_DESCRIPTIONS[plan.status]})"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def require_ticket_status!(ticket, statuses)
|
|
337
|
+
return if statuses.include?(ticket.status)
|
|
338
|
+
|
|
339
|
+
raise TransitionError, "ticket #{ticket.reference} is #{ticket.status}; this needs #{statuses.join(" or ")}"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def drafter
|
|
343
|
+
Drafter.new(llm: llm, schema: schema, config: config)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def llm
|
|
347
|
+
@llm ||= LLM.new(config)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def schema
|
|
351
|
+
@schema ||= SchemaContext.new
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def github
|
|
355
|
+
@github ||= GitHub.new
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def agents
|
|
359
|
+
@agents ||= CursorAgents.new(config: config)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# Turns interview answers into the drafted sections of a plan, then keeps the model honest:
|
|
5
|
+
# the guard runs on every draft, and its errors go back to the model as a list to fix.
|
|
6
|
+
class Drafter
|
|
7
|
+
Result = Struct.new(:sections, :report, :attempts, :assumptions, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize(llm: LLM.new, schema: SchemaContext.new, config: PlanDriven.configuration)
|
|
10
|
+
@llm = llm
|
|
11
|
+
@schema = schema
|
|
12
|
+
@config = config
|
|
13
|
+
@template = config.template
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def draft(answers, title:)
|
|
17
|
+
answers = answers.transform_keys(&:to_s)
|
|
18
|
+
messages = [{ role: "user", content: request(answers, title) }]
|
|
19
|
+
attempts = 0
|
|
20
|
+
|
|
21
|
+
loop do
|
|
22
|
+
attempts += 1
|
|
23
|
+
reply = @llm.chat(system: system_prompt, messages: messages)
|
|
24
|
+
begin
|
|
25
|
+
parsed = parse(reply.text)
|
|
26
|
+
rescue InvalidResponseError => e
|
|
27
|
+
raise if attempts > @config.max_repair_attempts
|
|
28
|
+
|
|
29
|
+
messages += [{ role: "assistant", content: reply.text },
|
|
30
|
+
{ role: "user",
|
|
31
|
+
content: "That reply can't be used: #{e.message}. Reply with the JSON object only." }]
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
sections = answers.merge(parsed.fetch("sections", {}).slice(*@template.drafted.map(&:key)))
|
|
35
|
+
report = Guards::PlanGuard.new(sections, schema: @schema, template: @template).call
|
|
36
|
+
if report.ok? || attempts > @config.max_repair_attempts
|
|
37
|
+
return Result.new(sections: sections, report: report, attempts: attempts,
|
|
38
|
+
assumptions: Array(parsed["assumptions"]))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
messages += [{ role: "assistant", content: reply.text }, { role: "user", content: repair(report) }]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One section again, with an instruction from the reviewer ("shorter", "add the index").
|
|
46
|
+
def redraft(sections, key, instruction)
|
|
47
|
+
section = @template[key] or raise ArgumentError, "unknown section #{key}"
|
|
48
|
+
content = <<~TEXT
|
|
49
|
+
The current plan, as JSON:
|
|
50
|
+
#{JSON.pretty_generate(sections)}
|
|
51
|
+
|
|
52
|
+
Rewrite only the section "#{section.key}" (#{section.title}). Reviewer's instruction: #{instruction}
|
|
53
|
+
Guidance for this section: #{section.guidance}
|
|
54
|
+
Reply with JSON: {"sections": {"#{section.key}": "..."}}
|
|
55
|
+
TEXT
|
|
56
|
+
reply = @llm.chat(system: system_prompt, messages: [{ role: "user", content: content }])
|
|
57
|
+
parse(reply.text).dig("sections", section.key).to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def system_prompt
|
|
61
|
+
<<~PROMPT
|
|
62
|
+
You write implementation plans for a Ruby on Rails team. A plan is read by developers,
|
|
63
|
+
QA, DevOps and a director, then split into tickets that coding agents implement.
|
|
64
|
+
|
|
65
|
+
Write in plain, specific English. Name real models, tables, columns and file paths.
|
|
66
|
+
Follow Rails conventions. Changes are additive and legacy-safe: expand, dual write,
|
|
67
|
+
backfill, switch reads, then contract. Never describe something as existing unless it
|
|
68
|
+
is in the schema you are given. When you don't know, say so under Outstanding questions.
|
|
69
|
+
|
|
70
|
+
Reply with one JSON object and nothing else:
|
|
71
|
+
{"sections": {"<key>": "<markdown>", ...}, "assumptions": ["..."]}
|
|
72
|
+
|
|
73
|
+
Sections to write, by key:
|
|
74
|
+
#{@template.drafted.map { |section| "- #{section.key} (#{section.title}): #{section.guidance}" }.join("\n")}
|
|
75
|
+
PROMPT
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def request(answers, title)
|
|
81
|
+
asked = @template.asked.filter_map do |section|
|
|
82
|
+
value = answers[section.key].to_s.strip
|
|
83
|
+
"#{section.title}:\n#{value}" unless value.empty?
|
|
84
|
+
end
|
|
85
|
+
<<~TEXT
|
|
86
|
+
Plan title: #{title}
|
|
87
|
+
|
|
88
|
+
The team's answers:
|
|
89
|
+
#{asked.join("\n\n")}
|
|
90
|
+
|
|
91
|
+
The application's schema:
|
|
92
|
+
#{@schema.to_prompt}
|
|
93
|
+
#{"\nMore context:\n#{@config.extra_context}" if @config.extra_context}
|
|
94
|
+
TEXT
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def repair(report)
|
|
98
|
+
<<~TEXT
|
|
99
|
+
The plan was checked and these problems must be fixed:
|
|
100
|
+
#{report.errors.map { |error| "- #{error}" }.join("\n")}
|
|
101
|
+
|
|
102
|
+
Reply with the full JSON again, every section included.
|
|
103
|
+
TEXT
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse(text)
|
|
107
|
+
parsed = JsonReply.parse(text)
|
|
108
|
+
raise InvalidResponseError, "the reply had no \"sections\" object" unless parsed["sections"].is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
parsed["sections"] = parsed["sections"].transform_values { |value| stringify(value) }
|
|
111
|
+
parsed
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def stringify(value)
|
|
115
|
+
case value
|
|
116
|
+
when Array then value.map { |item| "- #{item}" }.join("\n")
|
|
117
|
+
when Hash then value.map { |heading, body| "### #{heading}\n#{stringify(body)}" }.join("\n\n")
|
|
118
|
+
else value.to_s
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConfigurationError < Error; end
|
|
6
|
+
class ProviderError < Error; end
|
|
7
|
+
class InvalidResponseError < Error; end
|
|
8
|
+
|
|
9
|
+
# A phase transition that the workflow doesn't allow, such as generating tickets for a plan
|
|
10
|
+
# nobody has approved.
|
|
11
|
+
class TransitionError < Error; end
|
|
12
|
+
|
|
13
|
+
# A guard found problems that block the next step. `problems` holds the messages.
|
|
14
|
+
class GuardError < Error
|
|
15
|
+
attr_reader :problems
|
|
16
|
+
|
|
17
|
+
def initialize(problems, message = nil)
|
|
18
|
+
@problems = Array(problems)
|
|
19
|
+
super(message || "#{@problems.size} problem(s) block this step:\n#{@problems.map { |p| " - #{p}" }.join("\n")}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module PlanDriven
|
|
7
|
+
# Proof that the delivered code does what the plan promised: every acceptance criterion maps to
|
|
8
|
+
# a Cucumber scenario tagged `@<plan>-<ticket> @ac-N`, and the scenarios' results are stored with
|
|
9
|
+
# the commit they ran against.
|
|
10
|
+
module Evidence
|
|
11
|
+
Row = Struct.new(:ticket, :number, :criterion, :scenarios, keyword_init: true) do
|
|
12
|
+
def status
|
|
13
|
+
return "no scenario" if scenarios.empty?
|
|
14
|
+
return "passed" if scenarios.all? { |scenario| scenario["status"] == "passed" }
|
|
15
|
+
|
|
16
|
+
scenarios.any? { |scenario| scenario["status"] == "failed" } ? "failed" : "not run"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def tag_expression(plan)
|
|
23
|
+
plan.tickets.map(&:feature_tag).join(" or ")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run_cucumber(plan, actor:, command: nil, root: PlanDriven.configuration.root_path)
|
|
27
|
+
Dir.mktmpdir do |dir|
|
|
28
|
+
out = File.join(dir, "cucumber.json")
|
|
29
|
+
shown = command&.join(" ") || %(bundle exec cucumber --tags "#{tag_expression(plan)}")
|
|
30
|
+
command ||= ["bundle", "exec", "cucumber", "--tags", tag_expression(plan), "--format", "json", "--out", out,
|
|
31
|
+
"--format", "progress"]
|
|
32
|
+
output, status = Open3.capture2e(*command, chdir: root.to_s)
|
|
33
|
+
json = File.exist?(out) ? File.read(out) : "[]"
|
|
34
|
+
record(plan, json, command: shown, actor: actor, exit_ok: status.success?, output: output)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def record(plan, json, command:, actor:, exit_ok: true, output: nil)
|
|
39
|
+
scenarios = parse(json)
|
|
40
|
+
status = if scenarios.empty? then "no scenarios"
|
|
41
|
+
elsif exit_ok && scenarios.all? { |scenario| scenario["status"] == "passed" } then "passed"
|
|
42
|
+
else "failed"
|
|
43
|
+
end
|
|
44
|
+
run = plan.evidence_runs.create!(kind: "cucumber", command: command, status: status,
|
|
45
|
+
commit_sha: Repository.head_sha,
|
|
46
|
+
results: { "scenarios" => scenarios, "output" => output.to_s.last(4000) })
|
|
47
|
+
plan.log!("evidence.recorded", actor: actor, status: status, scenarios: scenarios.size)
|
|
48
|
+
run
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Cucumber's JSON formatter: features -> elements (scenarios) -> steps with results.
|
|
52
|
+
def parse(json)
|
|
53
|
+
Array(JSON.parse(json.to_s.strip.empty? ? "[]" : json)).flat_map do |feature|
|
|
54
|
+
feature_tags = Array(feature["tags"]).map { |tag| tag["name"] }
|
|
55
|
+
Array(feature["elements"]).select { |element| element["type"] == "scenario" }.map do |element|
|
|
56
|
+
tags = (feature_tags + Array(element["tags"]).map { |tag| tag["name"] }).uniq
|
|
57
|
+
{ "name" => element["name"], "tags" => tags, "file" => "#{feature["uri"]}:#{element["line"]}",
|
|
58
|
+
"status" => scenario_status(element) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scenario_status(element)
|
|
66
|
+
statuses = (Array(element["before"]) + Array(element["steps"]) + Array(element["after"]))
|
|
67
|
+
.map { |step| step.dig("result", "status") }
|
|
68
|
+
return "failed" if statuses.include?("failed")
|
|
69
|
+
return "passed" if statuses.any? && statuses.all? { |status| %w[passed skipped].include?(status) } &&
|
|
70
|
+
Array(element["steps"]).all? { |step| step.dig("result", "status") == "passed" }
|
|
71
|
+
|
|
72
|
+
"not run"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def matrix(plan)
|
|
76
|
+
scenarios = plan.evidence_runs.last&.scenarios || []
|
|
77
|
+
plan.tickets.flat_map do |ticket|
|
|
78
|
+
ticket.criteria.each_with_index.map do |criterion, index|
|
|
79
|
+
matching = scenarios.select do |scenario|
|
|
80
|
+
scenario["tags"].include?(ticket.feature_tag) && scenario["tags"].include?("@ac-#{index + 1}")
|
|
81
|
+
end
|
|
82
|
+
Row.new(ticket: ticket, number: index + 1, criterion: criterion, scenarios: matching)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def matrix_markdown(plan)
|
|
88
|
+
run = plan.evidence_runs.last
|
|
89
|
+
return "No test evidence recorded yet. Run `plan-driven evidence #{plan.key}`." unless run
|
|
90
|
+
|
|
91
|
+
rows = matrix(plan).map do |row|
|
|
92
|
+
scenario = row.scenarios.map { |s| "#{s["name"]} (`#{s["file"]}`)" }.join("; ").presence || "-"
|
|
93
|
+
["#{row.ticket.key}.#{row.number}", row.criterion, scenario, row.status]
|
|
94
|
+
end
|
|
95
|
+
commit = run.commit_sha.present? ? " on commit `#{run.commit_sha[0, 7]}`" : ""
|
|
96
|
+
intro = "Cucumber, run #{run.created_at.strftime("%-d %b %Y %H:%M")}#{commit}: `#{run.command}`"
|
|
97
|
+
"#{intro}\n\n#{Renderer::Markdown.table(%w[AC Criterion Scenario Result], rows)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def summary_line(plan)
|
|
101
|
+
return "No test evidence recorded yet." unless plan.evidence_runs.any?
|
|
102
|
+
|
|
103
|
+
rows = matrix(plan)
|
|
104
|
+
passed = rows.count { |row| row.status == "passed" }
|
|
105
|
+
"#{passed} of #{rows.size} acceptance criteria are proven by a passing scenario."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# Just enough Gherkin to know which scenarios exist and how they're tagged. Tags on the
|
|
5
|
+
# Feature line apply to every scenario in it, as they do in Cucumber.
|
|
6
|
+
module Gherkin
|
|
7
|
+
Scenario = Struct.new(:name, :tags, :line, :path, :steps, keyword_init: true)
|
|
8
|
+
Feature = Struct.new(:name, :tags, :path, :scenarios, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
SCENARIO = /\A\s*(Scenario(?: Outline| Template)?|Example):\s*(.*)\z/
|
|
11
|
+
FEATURE = /\A\s*(Feature|Ability|Business Need):\s*(.*)\z/
|
|
12
|
+
STEP = /\A\s*(Given|When|Then|And|But|\*)\s+(.*)\z/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def parse(text, path: nil)
|
|
17
|
+
state = { feature: Feature.new(name: nil, tags: [], path: path, scenarios: []), tags: [], scenario: nil }
|
|
18
|
+
text.to_s.each_line.with_index(1) { |raw, number| read_line(state, raw.strip, number) }
|
|
19
|
+
state[:feature]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read_line(state, line, number)
|
|
23
|
+
return if line.empty? || line.start_with?("#")
|
|
24
|
+
|
|
25
|
+
feature = state[:feature]
|
|
26
|
+
if line.start_with?("@")
|
|
27
|
+
state[:tags].concat(line.split(/\s+/).grep(/\A@/))
|
|
28
|
+
elsif (match = line.match(FEATURE))
|
|
29
|
+
feature.name = match[2].strip
|
|
30
|
+
feature.tags = state.delete(:tags)
|
|
31
|
+
state[:tags] = []
|
|
32
|
+
elsif (match = line.match(SCENARIO))
|
|
33
|
+
state[:scenario] = Scenario.new(name: match[2].strip, tags: (feature.tags + state[:tags]).uniq, line: number,
|
|
34
|
+
path: feature.path, steps: [])
|
|
35
|
+
feature.scenarios << state[:scenario]
|
|
36
|
+
state[:tags] = []
|
|
37
|
+
elsif state[:scenario] && (match = line.match(STEP))
|
|
38
|
+
state[:scenario].steps << "#{match[1]} #{match[2]}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|