plan_driven 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +76 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +823 -0
  5. data/exe/plan-driven +7 -0
  6. data/lib/generators/plan_driven/install_generator.rb +37 -0
  7. data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
  8. data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
  9. data/lib/plan_driven/agent_prompt.rb +86 -0
  10. data/lib/plan_driven/cli/plan_commands.rb +158 -0
  11. data/lib/plan_driven/cli/setup_commands.rb +97 -0
  12. data/lib/plan_driven/cli/ticket_commands.rb +155 -0
  13. data/lib/plan_driven/cli/ui.rb +101 -0
  14. data/lib/plan_driven/cli.rb +150 -0
  15. data/lib/plan_driven/configuration.rb +114 -0
  16. data/lib/plan_driven/credentials.rb +90 -0
  17. data/lib/plan_driven/cursor_agents.rb +82 -0
  18. data/lib/plan_driven/cursor_llm.mjs +70 -0
  19. data/lib/plan_driven/cursor_llm.rb +79 -0
  20. data/lib/plan_driven/delivery.rb +362 -0
  21. data/lib/plan_driven/drafter.rb +122 -0
  22. data/lib/plan_driven/errors.rb +22 -0
  23. data/lib/plan_driven/evidence.rb +108 -0
  24. data/lib/plan_driven/gherkin.rb +42 -0
  25. data/lib/plan_driven/github.rb +118 -0
  26. data/lib/plan_driven/guards/migration_guard.rb +94 -0
  27. data/lib/plan_driven/guards/plan_guard.rb +84 -0
  28. data/lib/plan_driven/guards/pr_guard.rb +140 -0
  29. data/lib/plan_driven/guards/ticket_guard.rb +178 -0
  30. data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
  31. data/lib/plan_driven/guards.rb +57 -0
  32. data/lib/plan_driven/http.rb +60 -0
  33. data/lib/plan_driven/json_reply.rb +26 -0
  34. data/lib/plan_driven/llm.rb +80 -0
  35. data/lib/plan_driven/models/approval.rb +12 -0
  36. data/lib/plan_driven/models/event.rb +13 -0
  37. data/lib/plan_driven/models/evidence_run.rb +17 -0
  38. data/lib/plan_driven/models/plan.rb +98 -0
  39. data/lib/plan_driven/models/record.rb +8 -0
  40. data/lib/plan_driven/models/ticket.rb +58 -0
  41. data/lib/plan_driven/models.rb +8 -0
  42. data/lib/plan_driven/railtie.rb +9 -0
  43. data/lib/plan_driven/renderer/html.rb +106 -0
  44. data/lib/plan_driven/renderer/markdown.rb +145 -0
  45. data/lib/plan_driven/renderer/pdf.rb +39 -0
  46. data/lib/plan_driven/renderer/style.css +22 -0
  47. data/lib/plan_driven/renderer.rb +41 -0
  48. data/lib/plan_driven/repository.rb +35 -0
  49. data/lib/plan_driven/schema_context.rb +98 -0
  50. data/lib/plan_driven/template.rb +113 -0
  51. data/lib/plan_driven/ticket_generator.rb +98 -0
  52. data/lib/plan_driven/version.rb +5 -0
  53. data/lib/plan_driven/workflow.rb +59 -0
  54. data/lib/plan_driven.rb +72 -0
  55. metadata +146 -0
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ VERSION = "0.1.0"
5
+ 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