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,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Renderer
|
|
5
|
+
# The plan and the delivery report as Markdown, laid out like a Confluence implementation plan.
|
|
6
|
+
module Markdown
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def plan(plan, config: PlanDriven.configuration)
|
|
10
|
+
parts = ["# #{plan.key}: #{plan.title}", meta(plan)]
|
|
11
|
+
config.template.grouped.each do |group, sections|
|
|
12
|
+
body = sections.filter_map { |section| section_block(plan, section, group) }
|
|
13
|
+
body << tickets_table(plan) if group == "Work overview" && plan.tickets.any?
|
|
14
|
+
parts << "# #{group}\n\n#{body.join("\n\n")}" if body.any?
|
|
15
|
+
end
|
|
16
|
+
parts << approvals_table(plan, config)
|
|
17
|
+
"#{parts.join("\n\n")}\n"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def report(plan)
|
|
21
|
+
parts = ["# #{plan.key}: #{plan.title} (delivery report)", meta(plan)]
|
|
22
|
+
parts << "## Summary\n\n#{summary(plan)}"
|
|
23
|
+
parts << "## Tickets and pull requests\n\n#{delivery_table(plan)}"
|
|
24
|
+
parts << "## Acceptance criteria and proof\n\n#{Evidence.matrix_markdown(plan)}"
|
|
25
|
+
parts << "## Checks run on each pull request\n\n#{guard_findings(plan)}"
|
|
26
|
+
parts << "## Approvals\n\n#{approval_history(plan)}"
|
|
27
|
+
parts << "## Timeline\n\n#{timeline(plan)}"
|
|
28
|
+
parts << "## The approved plan\n\nThe plan this delivery implements is in `plan.md`, revision #{plan.revision}."
|
|
29
|
+
"#{parts.join("\n\n")}\n"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def section_block(plan, section, group)
|
|
33
|
+
text = plan.section(section.key).strip
|
|
34
|
+
return if text.empty?
|
|
35
|
+
|
|
36
|
+
heading = section.title == group ? "" : "## #{section.title}\n\n"
|
|
37
|
+
"#{heading}#{text}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def meta(plan)
|
|
41
|
+
"*Status: #{plan.status.tr("_", " ")} · Revision #{plan.revision} · Created by #{plan.created_by} · " \
|
|
42
|
+
"#{plan.created_at&.strftime("%-d %B %Y")}*"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tickets_table(plan)
|
|
46
|
+
rows = plan.tickets.map do |ticket|
|
|
47
|
+
[ticket.key, ticket.title, ticket.ticket_type, ticket.kind, ticket.estimate,
|
|
48
|
+
ticket.dependencies.join(", ").presence || "-", issue_link(ticket)]
|
|
49
|
+
end
|
|
50
|
+
details = plan.tickets.map { |ticket| ticket_detail(ticket) }
|
|
51
|
+
"## Work items\n\n#{table(%w[# Title Type Kind Estimate Depends Issue], rows)}\n\n" \
|
|
52
|
+
"**Estimated total: #{plan.tickets.sum { |ticket| ticket.estimate.to_i }} points**\n\n#{details.join("\n\n")}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ticket_detail(ticket)
|
|
56
|
+
lines = ["### #{ticket.key}. #{ticket.title}"]
|
|
57
|
+
lines << ticket.story if ticket.story.present?
|
|
58
|
+
lines << ticket.description.to_s
|
|
59
|
+
lines << "#### Acceptance Criteria\n\n#{ticket.criteria.each_with_index.map do |c, i|
|
|
60
|
+
"#{i + 1}. #{c}"
|
|
61
|
+
end.join("\n")}"
|
|
62
|
+
lines << "#### Implementation Notes\n\n#{ticket.implementation_notes}" if ticket.implementation_notes.present?
|
|
63
|
+
lines << "*Touches: #{ticket.tables.join(", ")}*" if ticket.tables.any?
|
|
64
|
+
lines.join("\n\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def approvals_table(plan, config)
|
|
68
|
+
roles = config.plan_approvals
|
|
69
|
+
rows = roles.map do |role|
|
|
70
|
+
approval = plan.approvals_for_revision.where(role: role).order(:created_at).last
|
|
71
|
+
[role.tr("_", " ").capitalize, approval&.decision || "pending", approval&.actor || "",
|
|
72
|
+
approval&.created_at&.strftime("%-d %b %Y %H:%M") || ""]
|
|
73
|
+
end
|
|
74
|
+
"# Sign-off\n\n#{table(%w[Role Decision By When], rows)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def summary(plan)
|
|
78
|
+
tickets = plan.tickets
|
|
79
|
+
merged = tickets.count { |ticket| ticket.status == "merged" }
|
|
80
|
+
"#{merged} of #{tickets.size} tickets merged. #{Evidence.summary_line(plan)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def delivery_table(plan)
|
|
84
|
+
rows = plan.tickets.map do |ticket|
|
|
85
|
+
[ticket.key, ticket.title, ticket.status.tr("_", " "), pr_link(ticket),
|
|
86
|
+
ticket.merged_sha.to_s[0, 7].presence || "-", ticket.pr_approved_by || "-"]
|
|
87
|
+
end
|
|
88
|
+
table(["#", "Ticket", "Status", "Pull request", "Merge commit", "Approved by"], rows)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def guard_findings(plan)
|
|
92
|
+
lines = plan.tickets.map do |ticket|
|
|
93
|
+
report = Guards::Report.from_h(ticket.guard_report)
|
|
94
|
+
if ticket.guard_report.nil?
|
|
95
|
+
"- **#{ticket.key}**: not reviewed"
|
|
96
|
+
elsif report.errors.empty? && report.warnings.empty?
|
|
97
|
+
"- **#{ticket.key}**: all checks passed"
|
|
98
|
+
else
|
|
99
|
+
items = report.errors.map { |e| "error: #{e}" } + report.warnings.map { |w| "warning: #{w}" }
|
|
100
|
+
"- **#{ticket.key}**: #{items.join("; ")}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
lines.join("\n")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def approval_history(plan)
|
|
107
|
+
approvals = Approval.where(approvable: [plan, *plan.tickets]).order(:created_at)
|
|
108
|
+
return "No approvals recorded." if approvals.empty?
|
|
109
|
+
|
|
110
|
+
rows = approvals.map do |approval|
|
|
111
|
+
approvable = approval.approvable
|
|
112
|
+
subject = approvable.is_a?(Ticket) ? "#{approvable.key} pull request" : "plan r#{approval.revision}"
|
|
113
|
+
[approval.created_at.strftime("%-d %b %Y %H:%M"), subject, approval.role, approval.decision, approval.actor,
|
|
114
|
+
approval.note.to_s]
|
|
115
|
+
end
|
|
116
|
+
table(%w[When Subject Role Decision By Note], rows)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def timeline(plan)
|
|
120
|
+
plan.events.map do |event|
|
|
121
|
+
target = event.ticket ? " #{event.ticket.key}" : ""
|
|
122
|
+
"- #{event.created_at.strftime("%-d %b %Y %H:%M")} · #{event.name}#{target} · #{event.actor}"
|
|
123
|
+
end.join("\n")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def issue_link(ticket)
|
|
127
|
+
return "-" unless ticket.issue_number
|
|
128
|
+
|
|
129
|
+
slug = Repository.slug
|
|
130
|
+
number = ticket.issue_number
|
|
131
|
+
slug ? "[##{number}](https://github.com/#{slug}/issues/#{number})" : "##{number}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def pr_link(ticket)
|
|
135
|
+
ticket.pr_url ? "[##{ticket.pr_number}](#{ticket.pr_url})" : "-"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def table(headers, rows)
|
|
139
|
+
escape = ->(value) { value.to_s.gsub("|", "\\|").gsub("\n", " ") }
|
|
140
|
+
lines = ["| #{headers.join(" | ")} |", "| #{headers.map { "---" }.join(" | ")} |"]
|
|
141
|
+
(lines + rows.map { |row| "| #{row.map(&escape).join(" | ")} |" }).join("\n")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Renderer
|
|
5
|
+
# Prints the HTML to PDF with headless Chrome or Chromium, when one is installed. A custom
|
|
6
|
+
# `config.pdf_renderer` (any callable taking the HTML and PDF paths) replaces it.
|
|
7
|
+
module PDF
|
|
8
|
+
BROWSERS = [
|
|
9
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
10
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
11
|
+
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser"
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def render(html_path, pdf_path, config: PlanDriven.configuration)
|
|
17
|
+
return config.pdf_renderer.call(html_path.to_s, pdf_path.to_s) && File.exist?(pdf_path) if config.pdf_renderer
|
|
18
|
+
|
|
19
|
+
browser = self.browser
|
|
20
|
+
return false unless browser
|
|
21
|
+
|
|
22
|
+
system(browser, "--headless", "--disable-gpu", "--no-pdf-header-footer", "--no-sandbox",
|
|
23
|
+
"--print-to-pdf=#{pdf_path}", "file://#{File.expand_path(html_path)}",
|
|
24
|
+
out: File::NULL, err: File::NULL)
|
|
25
|
+
File.exist?(pdf_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def browser
|
|
29
|
+
BROWSERS.find do |candidate|
|
|
30
|
+
candidate.start_with?("/") ? File.executable?(candidate) : executable_on_path?(candidate)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def executable_on_path?(name)
|
|
35
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
@page { size: A4; margin: 18mm 16mm; }
|
|
2
|
+
* { box-sizing: border-box; }
|
|
3
|
+
body { font: 10.5pt/1.55 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: #1f2328; margin: 0; }
|
|
4
|
+
main { max-width: 880px; margin: 0 auto; padding: 24px; }
|
|
5
|
+
h1 { font-size: 21pt; margin: 28px 0 8px; padding-bottom: 6px; border-bottom: 2px solid #d0d7de; }
|
|
6
|
+
h1:first-child { margin-top: 0; font-size: 24pt; border-bottom: 0; }
|
|
7
|
+
h2 { font-size: 14.5pt; margin: 22px 0 6px; }
|
|
8
|
+
h3 { font-size: 12pt; margin: 18px 0 4px; }
|
|
9
|
+
h4 { font-size: 10.5pt; margin: 14px 0 4px; text-transform: uppercase; letter-spacing: .04em; color: #57606a; }
|
|
10
|
+
p { margin: 6px 0 10px; }
|
|
11
|
+
ul, ol { margin: 6px 0 10px; padding-left: 22px; }
|
|
12
|
+
li { margin: 2px 0; }
|
|
13
|
+
em { color: #57606a; }
|
|
14
|
+
code { font: 9pt/1.4 "SFMono-Regular", Menlo, Consolas, monospace; background: #f6f8fa; padding: 1px 4px; border-radius: 4px; }
|
|
15
|
+
pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 10px 12px; overflow-x: auto; }
|
|
16
|
+
pre code { background: none; padding: 0; }
|
|
17
|
+
table { border-collapse: collapse; width: 100%; margin: 8px 0 14px; font-size: 9.5pt; break-inside: auto; }
|
|
18
|
+
th, td { border: 1px solid #d0d7de; padding: 5px 8px; text-align: left; vertical-align: top; }
|
|
19
|
+
th { background: #f6f8fa; font-weight: 600; }
|
|
20
|
+
tr { break-inside: avoid; }
|
|
21
|
+
a { color: #0969da; text-decoration: none; }
|
|
22
|
+
h1, h2, h3 { break-after: avoid; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "renderer/markdown"
|
|
7
|
+
require_relative "renderer/html"
|
|
8
|
+
require_relative "renderer/pdf"
|
|
9
|
+
|
|
10
|
+
module PlanDriven
|
|
11
|
+
# Writes a plan's documentation under docs/plans/<plan-slug>/: the plan and the delivery report,
|
|
12
|
+
# each as Markdown (for the repository), HTML and PDF (for people who don't read Markdown).
|
|
13
|
+
module Renderer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def write_plan(plan, config: PlanDriven.configuration)
|
|
17
|
+
write(plan, "plan", Markdown.plan(plan, config: config), title: "#{plan.key} #{plan.title}", config: config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write_report(plan, config: PlanDriven.configuration)
|
|
21
|
+
write(plan, "delivery-report", Markdown.report(plan),
|
|
22
|
+
title: "#{plan.key} delivery report", config: config)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def directory(plan, config: PlanDriven.configuration)
|
|
26
|
+
config.docs_root.join(plan.slug)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(plan, name, markdown, title:, config:)
|
|
30
|
+
dir = directory(plan, config: config)
|
|
31
|
+
FileUtils.mkdir_p(dir)
|
|
32
|
+
md = dir.join("#{name}.md")
|
|
33
|
+
html = dir.join("#{name}.html")
|
|
34
|
+
pdf = dir.join("#{name}.pdf")
|
|
35
|
+
File.write(md, markdown)
|
|
36
|
+
File.write(html, HTML.document(markdown, title: title))
|
|
37
|
+
written = PDF.render(html, pdf, config: config)
|
|
38
|
+
{ markdown: md, html: html, pdf: written ? pdf : nil }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module PlanDriven
|
|
6
|
+
# The GitHub repository the application lives in, from config or the `origin` remote.
|
|
7
|
+
module Repository
|
|
8
|
+
# Also matches SSH host aliases such as git@github.com-work:acme/app.git.
|
|
9
|
+
REMOTE = %r{github\.com(?:-[\w.-]+)?[:/]([\w.-]+/[\w.-]+?)(?:\.git)?/?\z}
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def slug(config = PlanDriven.configuration)
|
|
14
|
+
config.github_repository.presence || from_remote(config.root_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def from_remote(root)
|
|
18
|
+
output, status = Open3.capture2("git", "-C", root.to_s, "remote", "get-url", "origin", err: File::NULL)
|
|
19
|
+
return unless status.success?
|
|
20
|
+
|
|
21
|
+
parse(output.strip)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(url)
|
|
27
|
+
url.to_s.strip[REMOTE, 1]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def head_sha(root = PlanDriven.configuration.root_path)
|
|
31
|
+
output, status = Open3.capture2("git", "-C", root.to_s, "rev-parse", "HEAD", err: File::NULL)
|
|
32
|
+
status.success? ? output.strip : nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# What the application looks like, read from the database connection and ActiveRecord models.
|
|
5
|
+
# The model is told this so a plan describes the real schema, and the guards use it to catch a
|
|
6
|
+
# plan that describes tables or columns that don't exist.
|
|
7
|
+
class SchemaContext
|
|
8
|
+
IGNORED_TABLES = /\A(schema_migrations|ar_internal_metadata|plan_driven_|active_storage_|action_text_|
|
|
9
|
+
action_mailbox_|solid_(queue|cache|cable)_)/x
|
|
10
|
+
|
|
11
|
+
Model = Struct.new(:name, :table, :path, :associations, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(connection: ActiveRecord::Base.connection, root: PlanDriven.configuration.root_path)
|
|
14
|
+
@connection = connection
|
|
15
|
+
@root = Pathname(root)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def adapter
|
|
19
|
+
@connection.adapter_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tables
|
|
23
|
+
@tables ||= @connection.tables.grep_v(IGNORED_TABLES).sort
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def columns(table)
|
|
27
|
+
@columns ||= {}
|
|
28
|
+
@columns[table.to_s] ||= @connection.columns(table.to_s).map do |column|
|
|
29
|
+
[column.name, column.sql_type_metadata.type.to_s, column.null ? nil : "not null"].compact
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def column?(table, column)
|
|
34
|
+
tables.include?(table.to_s) && columns(table).any? { |name, *| name == column.to_s }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def table?(table)
|
|
38
|
+
tables.include?(table.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def models
|
|
42
|
+
@models ||= load_models
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def model_names
|
|
46
|
+
models.map(&:name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def model?(name)
|
|
50
|
+
model_names.include?(name.to_s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_prompt
|
|
54
|
+
return "The application has no tables yet." if tables.empty?
|
|
55
|
+
|
|
56
|
+
by_table = models.group_by(&:table)
|
|
57
|
+
tables.map { |table| describe(table, by_table[table]) }.join("\n\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def describe(table, table_models)
|
|
63
|
+
header = if table_models&.any?
|
|
64
|
+
"#{table_models.map { |model| "#{model.name} (#{model.path || "no file"})" }.join(", ")} -> #{table}"
|
|
65
|
+
else
|
|
66
|
+
"#{table} (no model)"
|
|
67
|
+
end
|
|
68
|
+
lines = [header, " columns: #{columns(table).map { |parts| parts.join(":") }.join(", ")}"]
|
|
69
|
+
Array(table_models).flat_map(&:associations).uniq.each { |association| lines << " #{association}" }
|
|
70
|
+
lines.join("\n")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_models
|
|
74
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
75
|
+
ActiveRecord::Base.descendants.filter_map do |klass|
|
|
76
|
+
next if klass.abstract_class? || klass.name.nil? || klass.name.start_with?("PlanDriven::")
|
|
77
|
+
next unless table?(klass.table_name)
|
|
78
|
+
|
|
79
|
+
Model.new(name: klass.name, table: klass.table_name, path: model_path(klass),
|
|
80
|
+
associations: klass.reflect_on_all_associations.map { |reflection| association_line(reflection) })
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end.sort_by(&:name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def association_line(reflection)
|
|
87
|
+
line = "#{reflection.macro} :#{reflection.name}"
|
|
88
|
+
line += " through: :#{reflection.options[:through]}" if reflection.options[:through]
|
|
89
|
+
line += " polymorphic" if reflection.options[:polymorphic]
|
|
90
|
+
line
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def model_path(klass)
|
|
94
|
+
relative = "app/models/#{klass.name.underscore}.rb"
|
|
95
|
+
@root.join(relative).exist? ? relative : nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# The shape of an implementation plan. The default follows the structure Rails teams already
|
|
5
|
+
# write in Confluence: Overview, Background, Architectural changes, Work overview, Risks,
|
|
6
|
+
# Testing and sign-off. Sections marked `ask` come from the person in the terminal; the rest
|
|
7
|
+
# are drafted by the model from the answers and the real schema, then checked by the guards.
|
|
8
|
+
class Template
|
|
9
|
+
Section = Struct.new(:key, :title, :group, :source, :required, :question, :guidance, :min_words,
|
|
10
|
+
keyword_init: true) do
|
|
11
|
+
def asked?
|
|
12
|
+
source == :ask
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def drafted?
|
|
16
|
+
source == :draft
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
GROUPS = ["Overview", "Background", "Architectural changes", "Work overview", "Risks", "Testing"].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :sections
|
|
23
|
+
|
|
24
|
+
def self.default
|
|
25
|
+
new(DEFAULT_SECTIONS)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(sections)
|
|
29
|
+
@sections = sections.map { |section| section.is_a?(Section) ? section : Section.new(**section) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def [](key)
|
|
33
|
+
sections.find { |section| section.key == key.to_s }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def keys
|
|
37
|
+
sections.map(&:key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def asked
|
|
41
|
+
sections.select(&:asked?)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def drafted
|
|
45
|
+
sections.select(&:drafted?)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def required
|
|
49
|
+
sections.select(&:required)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def grouped
|
|
53
|
+
GROUPS.to_h { |group| [group, sections.select { |section| section.group == group }] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
DEFAULT_SECTIONS = [
|
|
57
|
+
{ key: "what", title: "What", group: "Overview", source: :ask, required: true, min_words: 15,
|
|
58
|
+
question: "What are we building? Describe the change as the user will see it.",
|
|
59
|
+
guidance: "The change in two or three short paragraphs, in product terms." },
|
|
60
|
+
{ key: "why", title: "Why", group: "Overview", source: :ask, required: true, min_words: 10,
|
|
61
|
+
question: "Why now? What problem or gap does it close?",
|
|
62
|
+
guidance: "The problem, the gap or the business reason, as a short list if there are several." },
|
|
63
|
+
{ key: "where", title: "Where", group: "Overview", source: :ask, required: true, min_words: 2,
|
|
64
|
+
question: "Where in the product does it land? (modules, screens, APIs)",
|
|
65
|
+
guidance: "The modules, screens, APIs and jobs affected." },
|
|
66
|
+
{ key: "who", title: "Who", group: "Overview", source: :ask, required: true, min_words: 1,
|
|
67
|
+
question: "Who owns it? (team, people)", guidance: "Owning team and people." },
|
|
68
|
+
{ key: "when", title: "When", group: "Overview", source: :ask, required: false, min_words: 0,
|
|
69
|
+
question: "When is it needed? (date, milestone, or leave empty)", guidance: "Target date or milestone." },
|
|
70
|
+
{ key: "background", title: "Background", group: "Background", source: :ask, required: false, min_words: 0,
|
|
71
|
+
question: "Links, PRDs, existing tickets, earlier decisions (optional)",
|
|
72
|
+
guidance: "Links, product documents, related tickets and earlier decisions." },
|
|
73
|
+
{ key: "existing_data_structure", title: "Existing Data Structure", group: "Background", source: :draft,
|
|
74
|
+
required: true, min_words: 30,
|
|
75
|
+
guidance: "The existing models, tables, columns and associations this change touches, one subsection " \
|
|
76
|
+
"per model with its file path (for example `app/models/form.rb`). Only describe what exists " \
|
|
77
|
+
"in the schema you were given." },
|
|
78
|
+
{ key: "architecture", title: "Architectural changes", group: "Architectural changes", source: :draft,
|
|
79
|
+
required: true, min_words: 40,
|
|
80
|
+
guidance: "The target design and data flow. Keep the existing execution shape where possible. Explain " \
|
|
81
|
+
"how legacy data coexists with the new model during rollout." },
|
|
82
|
+
{ key: "database_changes", title: "Database changes", group: "Architectural changes", source: :draft,
|
|
83
|
+
required: true, min_words: 20,
|
|
84
|
+
guidance: "Every table and column added, changed or removed, with type, nullability, default and " \
|
|
85
|
+
"indexes. Changes must be additive first (expand, dual write, backfill, switch reads, " \
|
|
86
|
+
"then contract). Removing or renaming a column needs `ignored_columns` in an earlier step." },
|
|
87
|
+
{ key: "application_changes", title: "Application changes", group: "Architectural changes", source: :draft,
|
|
88
|
+
required: true, min_words: 40,
|
|
89
|
+
guidance: "Models, services, controllers, policies, jobs and UI that change, one subsection each." },
|
|
90
|
+
{ key: "infrastructure_changes", title: "Infrastructure changes", group: "Architectural changes",
|
|
91
|
+
source: :draft, required: true, min_words: 5,
|
|
92
|
+
guidance: "Queues, external services, feature flags and rollout order. Say so when there are none." },
|
|
93
|
+
{ key: "out_of_scope", title: "Out of Scope", group: "Work overview", source: :ask, required: false,
|
|
94
|
+
min_words: 0, question: "What is explicitly out of scope? (optional)",
|
|
95
|
+
guidance: "What this plan deliberately doesn't do." },
|
|
96
|
+
{ key: "risks", title: "Risks", group: "Risks", source: :draft, required: true, min_words: 15,
|
|
97
|
+
guidance: "The main risks as a list, and how each is mitigated." },
|
|
98
|
+
{ key: "performance", title: "Performance", group: "Risks", source: :draft, required: true, min_words: 10,
|
|
99
|
+
guidance: "Query counts, N+1 risks, indexes, large tables, batch sizes for backfills." },
|
|
100
|
+
{ key: "security", title: "Security", group: "Risks", source: :draft, required: true, min_words: 10,
|
|
101
|
+
guidance: "Authorization, data exposure and input validation. End with a line `Risk Level: LOW`, " \
|
|
102
|
+
"`MEDIUM` or `HIGH` and one sentence why." },
|
|
103
|
+
{ key: "monitoring", title: "Monitoring", group: "Risks", source: :draft, required: false, min_words: 0,
|
|
104
|
+
guidance: "What to watch after release: errors, jobs, metrics." },
|
|
105
|
+
{ key: "outstanding_questions", title: "Outstanding questions", group: "Risks", source: :draft,
|
|
106
|
+
required: false, min_words: 0,
|
|
107
|
+
guidance: "Open questions that must be answered before a phase ships. Don't invent answers." },
|
|
108
|
+
{ key: "testing", title: "Testing", group: "Testing", source: :draft, required: true, min_words: 20,
|
|
109
|
+
guidance: "Model, service, policy, request and system coverage, as a list of the main cases, " \
|
|
110
|
+
"including legacy data and permissions." }
|
|
111
|
+
].freeze
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# Splits an approved plan into tickets. Deterministic fixes come first, then the guard; errors
|
|
5
|
+
# go back to the model as a list, the same way plans are repaired.
|
|
6
|
+
class TicketGenerator
|
|
7
|
+
Result = Struct.new(:tickets, :report, :attempts, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
FIELDS = <<~TEXT.freeze
|
|
10
|
+
key: "T1", "T2", ... in delivery order
|
|
11
|
+
title: short, imperative ("Migration: Add formable columns to forms")
|
|
12
|
+
kind: one of #{Ticket::KINDS.join(", ")}
|
|
13
|
+
type: TASK, STORY or BUG
|
|
14
|
+
story: "I want ..., so that ..." (required for STORY)
|
|
15
|
+
description: what to build, precise enough for a developer who hasn't read the plan
|
|
16
|
+
acceptance_criteria: array of testable statements, each one becomes a Cucumber scenario
|
|
17
|
+
implementation_notes: file paths, column mappings, code hints (optional)
|
|
18
|
+
estimate: story points
|
|
19
|
+
depends_on: keys of tickets that must be merged first
|
|
20
|
+
touches: table names this ticket changes or reads in a new way
|
|
21
|
+
TEXT
|
|
22
|
+
|
|
23
|
+
def initialize(llm: LLM.new, schema: SchemaContext.new, config: PlanDriven.configuration)
|
|
24
|
+
@llm = llm
|
|
25
|
+
@schema = schema
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def generate(plan, instruction: nil)
|
|
30
|
+
content = request(plan)
|
|
31
|
+
content += "\n\nThe reviewer asks for this breakdown: #{instruction}" if instruction.present?
|
|
32
|
+
messages = [{ role: "user", content: content }]
|
|
33
|
+
attempts = 0
|
|
34
|
+
|
|
35
|
+
loop do
|
|
36
|
+
attempts += 1
|
|
37
|
+
reply = @llm.chat(system: system_prompt, messages: messages)
|
|
38
|
+
begin
|
|
39
|
+
drafted = parse(reply.text)
|
|
40
|
+
rescue InvalidResponseError => e
|
|
41
|
+
raise if attempts > @config.max_repair_attempts
|
|
42
|
+
|
|
43
|
+
messages += [{ role: "assistant", content: reply.text },
|
|
44
|
+
{ role: "user",
|
|
45
|
+
content: "That reply can't be used: #{e.message}. Reply with the JSON object only." }]
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
report = Guards::Report.new
|
|
49
|
+
tickets = Guards::TicketNormalizer.new(drafted, config: @config).call(report)
|
|
50
|
+
report.merge!(Guards::TicketGuard.new(tickets, plan_sections: plan.sections, schema: @schema,
|
|
51
|
+
config: @config).call)
|
|
52
|
+
if report.ok? || attempts > @config.max_repair_attempts
|
|
53
|
+
return Result.new(tickets: tickets, report: report, attempts: attempts)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
messages += [{ role: "assistant", content: reply.text }, { role: "user", content: repair(report) }]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def system_prompt
|
|
61
|
+
<<~PROMPT
|
|
62
|
+
You split an approved Rails implementation plan into tickets. Each ticket becomes one
|
|
63
|
+
pull request written by a coding agent and reviewed by a human, so each must merge on
|
|
64
|
+
its own without breaking the application.
|
|
65
|
+
|
|
66
|
+
Order the work the way zero-downtime Rails changes ship: migrations that add, dual
|
|
67
|
+
writes, backfills, switching reads, then cleanup that removes. A ticket may only depend
|
|
68
|
+
on tickets before it. Keep tickets small: at most #{@config.max_estimate} points on the
|
|
69
|
+
scale #{@config.estimate_scale.join(", ")}. Schema changes get their own migration tickets.
|
|
70
|
+
Split code by behaviour, not by layer: each code ticket delivers one thing a user can do,
|
|
71
|
+
with its model, service, controller, view and specs together, so its acceptance criteria
|
|
72
|
+
can be proven by Cucumber scenarios.
|
|
73
|
+
|
|
74
|
+
Reply with one JSON object: {"tickets": [ ... ]}. Each ticket has:
|
|
75
|
+
#{FIELDS}
|
|
76
|
+
PROMPT
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def request(plan)
|
|
82
|
+
sections = plan.sections.map { |key, value| "## #{@config.template[key]&.title || key}\n#{value}" }
|
|
83
|
+
"Plan #{plan.key}: #{plan.title}\n\n#{sections.join("\n\n")}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def repair(report)
|
|
87
|
+
"These problems must be fixed:\n#{report.errors.map { |error| "- #{error}" }.join("\n")}\n\n" \
|
|
88
|
+
"Reply with the full JSON again, every ticket included."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse(text)
|
|
92
|
+
tickets = JsonReply.parse(text)["tickets"]
|
|
93
|
+
raise InvalidResponseError, "the reply had no \"tickets\" array" unless tickets.is_a?(Array)
|
|
94
|
+
|
|
95
|
+
tickets
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# The phases a plan and its tickets move through. Transitions are data, so the CLI, the specs
|
|
5
|
+
# and the documentation all read the same table.
|
|
6
|
+
module Workflow
|
|
7
|
+
PLAN = {
|
|
8
|
+
"draft" => %w[in_review],
|
|
9
|
+
"in_review" => %w[draft approved],
|
|
10
|
+
"approved" => %w[draft ticketed],
|
|
11
|
+
"ticketed" => %w[approved tickets_approved],
|
|
12
|
+
"tickets_approved" => %w[in_development ticketed],
|
|
13
|
+
"in_development" => %w[delivered],
|
|
14
|
+
"delivered" => []
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
TICKET = {
|
|
18
|
+
"draft" => %w[approved],
|
|
19
|
+
"approved" => %w[running],
|
|
20
|
+
"running" => %w[pr_open failed],
|
|
21
|
+
"pr_open" => %w[changes_requested pr_approved merged],
|
|
22
|
+
"changes_requested" => %w[running],
|
|
23
|
+
"pr_approved" => %w[merged changes_requested],
|
|
24
|
+
"failed" => %w[running],
|
|
25
|
+
"merged" => []
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
PLAN_DESCRIPTIONS = {
|
|
29
|
+
"draft" => "being written; edit it, then `plan-driven submit`",
|
|
30
|
+
"in_review" => "waiting for approval: `plan-driven approve`",
|
|
31
|
+
"approved" => "approved; `plan-driven tickets` drafts the tickets",
|
|
32
|
+
"ticketed" => "tickets drafted; review them, then `plan-driven approve-tickets`",
|
|
33
|
+
"tickets_approved" => "ready; `plan-driven develop` hands tickets to agents",
|
|
34
|
+
"in_development" => "agents are working; `plan-driven status` and `plan-driven review`",
|
|
35
|
+
"delivered" => "every ticket merged; `plan-driven report` writes the delivery report"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def plan_transition!(plan, to)
|
|
41
|
+
transition!(PLAN, plan, to, "plan #{plan.key}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ticket_transition!(ticket, to)
|
|
45
|
+
transition!(TICKET, ticket, to, "ticket #{ticket.key}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def transition!(table, record, to, label)
|
|
49
|
+
from = record.status
|
|
50
|
+
unless table.fetch(from, []).include?(to)
|
|
51
|
+
allowed = table.fetch(from, [])
|
|
52
|
+
hint = allowed.empty? ? "it's final" : "it can move to #{allowed.join(", ")}"
|
|
53
|
+
raise TransitionError, "#{label} is #{from}; it can't move to #{to} (#{hint})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
record.update!(status: to)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|