plan_driven 0.1.0 → 0.3.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/README.md +437 -37
  4. data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
  5. data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
  6. data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
  7. data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
  9. data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
  10. data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
  11. data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
  12. data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
  13. data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
  14. data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
  15. data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
  16. data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
  17. data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
  18. data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
  19. data/config/routes.rb +17 -0
  20. data/exe/plan-driven +1 -0
  21. data/lib/generators/plan_driven/install_generator.rb +7 -0
  22. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  23. data/lib/plan_driven/charts.rb +252 -0
  24. data/lib/plan_driven/cli/config_commands.rb +60 -0
  25. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  26. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  27. data/lib/plan_driven/cli/ticket_commands.rb +64 -3
  28. data/lib/plan_driven/cli/ui.rb +41 -2
  29. data/lib/plan_driven/cli.rb +21 -4
  30. data/lib/plan_driven/configuration.rb +31 -3
  31. data/lib/plan_driven/connections.rb +69 -0
  32. data/lib/plan_driven/cursor_agents.rb +10 -2
  33. data/lib/plan_driven/delivery.rb +23 -5
  34. data/lib/plan_driven/evidence.rb +11 -4
  35. data/lib/plan_driven/github.rb +4 -0
  36. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  37. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  38. data/lib/plan_driven/interview.rb +161 -0
  39. data/lib/plan_driven/local_agents.rb +254 -0
  40. data/lib/plan_driven/renderer/html.rb +34 -5
  41. data/lib/plan_driven/renderer/markdown.rb +67 -3
  42. data/lib/plan_driven/renderer/style.css +21 -0
  43. data/lib/plan_driven/renderer.rb +11 -4
  44. data/lib/plan_driven/statistics.rb +195 -0
  45. data/lib/plan_driven/template.rb +7 -2
  46. data/lib/plan_driven/usage.rb +114 -0
  47. data/lib/plan_driven/version.rb +1 -1
  48. data/lib/plan_driven/wizard/engine.rb +19 -0
  49. data/lib/plan_driven/wizard.rb +245 -0
  50. data/lib/plan_driven.rb +8 -1
  51. metadata +32 -6
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "securerandom"
7
+ require "shellwords"
8
+
9
+ module PlanDriven
10
+ # The browser wizard at /plan_driven. Every button runs a real `plan-driven` command in the
11
+ # background and streams its output to the page, so the wizard can do nothing the CLI can't,
12
+ # and the terminal panel shows exactly which command ran.
13
+ module Wizard
14
+ # The commands the wizard may run, built from form fields. Nothing else reaches a shell:
15
+ # arguments are passed as an array, never interpolated.
16
+ module Commands
17
+ PLAN_ONLY = %w[check submit pdf status evidence report usage stats log].freeze
18
+
19
+ BUILDERS = {
20
+ "doctor" => ->(_p) { [] },
21
+ "new" => ->(p) { [required(p, "title")] },
22
+ "edit" => ->(p) { [plan(p), section(p), "--from", required(p, "file")] },
23
+ "redraft" => ->(p) { [plan(p), section(p), required(p, "instruction")] },
24
+ "approve" => ->(p) { [plan(p), *role(p), *note(p)] },
25
+ "reject" => ->(p) { [plan(p), *role(p), "--note", required(p, "note")] },
26
+ "tickets" => ->(p) { [plan(p), *p["instruction"].to_s.strip.presence] },
27
+ "approve-tickets" => ->(p) { [plan(p), *role(p)] },
28
+ "develop" => ->(p) { [plan(p), *Array(p["tickets"]).map { |key| ticket_key(key) }] },
29
+ "review" => ->(p) { [ticket(p)] },
30
+ "merge" => ->(p) { [ticket(p)] },
31
+ "approve-pr" => ->(p) { [ticket(p), *note(p)] },
32
+ "feedback" => ->(p) { [ticket(p), required(p, "feedback")] },
33
+ "questions" => ->(_p) { [] },
34
+ "question" => ->(p) { [question_key(p), *question_flags(p)] },
35
+ # The key itself goes on stdin, so it's never in the command line or the panel.
36
+ "connect" => ->(p) { [Connections.find(required(p, "service")).name] }
37
+ }.merge(PLAN_ONLY.to_h { |name| [name, ->(p) { [plan(p)] }] }).freeze
38
+
39
+ module_function
40
+
41
+ def argv(action, params)
42
+ builder = BUILDERS[action.to_s] or raise ArgumentError, "the wizard can't run `#{action}`"
43
+ [action.to_s, *builder.call(params.to_h.transform_keys(&:to_s)), "--yes"]
44
+ end
45
+
46
+ # What `new` reads on stdin. The interview takes each answer up to an empty line, so blank
47
+ # lines inside an answer are folded away, and an empty answer is the empty line alone.
48
+ def interview_input(template, answers)
49
+ template.asked.map do |section|
50
+ answer = answers[section.key].to_s.strip.gsub(/\r\n?/, "\n").gsub(/\n\s*\n+/, "\n")
51
+ answer.empty? ? "\n" : "#{answer}\n\n"
52
+ end.join
53
+ end
54
+
55
+ def required(params, name)
56
+ params[name].to_s.strip.presence or raise ArgumentError, "#{name.tr("_", " ")} is required"
57
+ end
58
+
59
+ def plan(params)
60
+ key = required(params, "plan").upcase
61
+ raise ArgumentError, "not a plan key" unless key.match?(/\A[A-Z]+-\d+\z/)
62
+
63
+ key
64
+ end
65
+
66
+ def ticket(params)
67
+ "#{plan(params)}/#{ticket_key(required(params, "ticket"))}"
68
+ end
69
+
70
+ def ticket_key(key)
71
+ key.to_s.upcase.tap { |value| raise ArgumentError, "not a ticket key" unless value.match?(/\AT\d+\z/) }
72
+ end
73
+
74
+ def section(params)
75
+ key = required(params, "section")
76
+ PlanDriven.configuration.template[key] or raise ArgumentError, "unknown section #{key}"
77
+ key
78
+ end
79
+
80
+ def role(params)
81
+ params["role"].to_s.strip.empty? ? [] : ["--as", params["role"].strip]
82
+ end
83
+
84
+ def note(params)
85
+ params["note"].to_s.strip.empty? ? [] : ["--note", params["note"].strip]
86
+ end
87
+
88
+ def question_key(params)
89
+ required(params, "key").tap do |key|
90
+ raise ArgumentError, "not a question key" unless key.match?(Interview::KEY)
91
+ end
92
+ end
93
+
94
+ def question_flags(params)
95
+ return ["--remove"] if params["remove"].to_s == "1"
96
+
97
+ flags = []
98
+ flags += ["--title", params["title"].strip] if params["title"].to_s.strip != ""
99
+ flags += ["--ask", params["question"].strip] if params["question"].to_s.strip != ""
100
+ flags += ["--group", params["group"]] if params["group"].to_s != ""
101
+ flags << "--required" if params["required"].to_s == "1"
102
+ flags << "--optional" if params["required"].to_s == "0"
103
+ flags
104
+ end
105
+ end
106
+
107
+ # One command run: its output in a log file, its state in a JSON file next to it, so any
108
+ # request (and any Puma thread) can read how it's going.
109
+ class Job
110
+ # About as many characters as fit on a line of the terminal panel, for tables.
111
+ PANEL_COLUMNS = 76
112
+ SERVER_ENV = %w[RAILS_ENV RACK_ENV PORT PIDFILE].freeze
113
+
114
+ attr_reader :id
115
+
116
+ def self.dir(root = PlanDriven.configuration.root_path)
117
+ root.join("tmp/plan_driven/wizard").tap { |path| FileUtils.mkdir_p(path) }
118
+ end
119
+
120
+ def self.find(id, root: PlanDriven.configuration.root_path)
121
+ raise ArgumentError, "not a job" unless id.to_s.match?(/\A[a-f0-9]{16}\z/)
122
+
123
+ new(id, root: root).tap { |job| raise ArgumentError, "no job #{id}" unless job.state_file.exist? }
124
+ end
125
+
126
+ # Starts `argv` in a thread and returns at once.
127
+ def self.start(argv, stdin: nil, actor: nil, root: PlanDriven.configuration.root_path)
128
+ job = new(SecureRandom.hex(8), root: root)
129
+ job.begin!(argv, stdin: stdin, actor: actor)
130
+ job
131
+ end
132
+
133
+ def initialize(id, root:)
134
+ @id = id
135
+ @root = Pathname(root)
136
+ end
137
+
138
+ def begin!(argv, stdin:, actor:)
139
+ command = executable + argv
140
+ write_state("command" => display(command, actor), "status" => "running", "started_at" => Time.now.to_i)
141
+ File.write(log_file, "")
142
+ Thread.new { execute(command, stdin, actor) }
143
+ end
144
+
145
+ def state
146
+ JSON.parse(state_file.read)
147
+ end
148
+
149
+ def output
150
+ log_file.exist? ? log_file.read.force_encoding(Encoding::UTF_8).scrub : ""
151
+ end
152
+
153
+ def to_h
154
+ state.merge("id" => id, "output" => output)
155
+ end
156
+
157
+ def state_file
158
+ self.class.dir(@root).join("#{id}.json")
159
+ end
160
+
161
+ def log_file
162
+ self.class.dir(@root).join("#{id}.log")
163
+ end
164
+
165
+ private
166
+
167
+ def execute(command, stdin, actor)
168
+ env = { "LANG" => "en_US.UTF-8", "LC_ALL" => "en_US.UTF-8", "NO_COLOR" => "1", "COLUMNS" => PANEL_COLUMNS.to_s }
169
+ # The server's own environment stays with the server: a command runs as it would when typed.
170
+ SERVER_ENV.each { |name| env[name] = nil }
171
+ env["PLAN_DRIVEN_ACTOR"] = actor if actor.present?
172
+ status = Open3.popen2e(env, *command, chdir: @root.to_s) do |input, output, thread|
173
+ input.write(stdin.to_s)
174
+ input.close
175
+ File.open(log_file, "a") do |log|
176
+ output.each_char do |char|
177
+ log.write(char)
178
+ log.flush if char == "\n"
179
+ end
180
+ end
181
+ thread.value
182
+ end
183
+ finish(status.exitstatus)
184
+ rescue StandardError => e
185
+ File.write(log_file, "\n✗ #{e.message}\n", mode: "a")
186
+ finish(1)
187
+ end
188
+
189
+ def finish(code)
190
+ write_state(state.merge("status" => code.to_i.zero? ? "succeeded" : "failed", "exit" => code,
191
+ "finished_at" => Time.now.to_i))
192
+ end
193
+
194
+ def write_state(data)
195
+ File.write(state_file, JSON.generate(data))
196
+ end
197
+
198
+ def executable
199
+ binstub = @root.join("bin/plan-driven")
200
+ binstub.exist? ? ["bin/plan-driven"] : %w[bundle exec plan-driven]
201
+ end
202
+
203
+ def display(command, actor)
204
+ prefix = actor.present? ? "PLAN_DRIVEN_ACTOR=#{Shellwords.escape(actor)} " : ""
205
+ shown = command.map { |arg| arg.start_with?(@root.to_s) ? arg.delete_prefix("#{@root}/") : arg }
206
+ prefix + Shellwords.join(shown)
207
+ end
208
+ end
209
+
210
+ # The wizard's pages, in order, and which ones a plan in a given phase has reached.
211
+ module Steps
212
+ ORDER = [
213
+ ["plan", "Plan", "Read it, edit or redraft any section, then submit"],
214
+ ["approve", "Approve", "Every role signs off on this revision"],
215
+ ["tickets", "Tickets", "Draft the tickets, steer them, approve them"],
216
+ ["agents", "Agents & PRs", "One agent and one pull request per ticket"],
217
+ ["finish", "Proof & report", "Evidence, the delivery report, tokens and cost"]
218
+ ].freeze
219
+
220
+ REACHED = {
221
+ "draft" => "plan", "in_review" => "approve", "approved" => "tickets", "ticketed" => "tickets",
222
+ "tickets_approved" => "agents", "in_development" => "agents", "delivered" => "finish"
223
+ }.freeze
224
+
225
+ module_function
226
+
227
+ def keys
228
+ ORDER.map(&:first)
229
+ end
230
+
231
+ def current(plan)
232
+ REACHED.fetch(plan.status, "plan")
233
+ end
234
+
235
+ def reached?(plan, step)
236
+ keys.index(step).to_i <= keys.index(current(plan)).to_i
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ if defined?(Rails::Railtie)
243
+ require "rails/engine"
244
+ require_relative "wizard/engine"
245
+ end
data/lib/plan_driven.rb CHANGED
@@ -12,6 +12,8 @@ require_relative "plan_driven/errors"
12
12
  require_relative "plan_driven/credentials"
13
13
  require_relative "plan_driven/configuration"
14
14
  require_relative "plan_driven/template"
15
+ require_relative "plan_driven/interview"
16
+ require_relative "plan_driven/connections"
15
17
  require_relative "plan_driven/schema_context"
16
18
  require_relative "plan_driven/workflow"
17
19
  require_relative "plan_driven/models"
@@ -26,10 +28,15 @@ require_relative "plan_driven/agent_prompt"
26
28
  require_relative "plan_driven/http"
27
29
  require_relative "plan_driven/github"
28
30
  require_relative "plan_driven/cursor_agents"
31
+ require_relative "plan_driven/local_agents"
32
+ require_relative "plan_driven/usage"
29
33
  require_relative "plan_driven/repository"
30
- require_relative "plan_driven/renderer"
31
34
  require_relative "plan_driven/evidence"
35
+ require_relative "plan_driven/statistics"
36
+ require_relative "plan_driven/charts"
37
+ require_relative "plan_driven/renderer"
32
38
  require_relative "plan_driven/delivery"
39
+ require_relative "plan_driven/wizard"
33
40
  require_relative "plan_driven/railtie" if defined?(Rails::Railtie)
34
41
 
35
42
  module PlanDriven
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plan_driven
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Blažević
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-30 00:00:00.000000000 Z
10
+ date: 2026-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -50,9 +50,10 @@ dependencies:
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9'
52
52
  description: |
53
- plan_driven runs a Rails team's delivery process from the command line. A terminal interview
54
- turns an idea into an implementation plan grounded in your real schema; the plan is checked
55
- by guards written in Ruby, rendered to PDF and approved. Approved plans become tickets, each
53
+ plan_driven runs a Rails team's delivery process inside your Rails app, from a browser wizard
54
+ or the command line: every button in the wizard runs the same plan-driven command. A short
55
+ interview turns an idea into an implementation plan grounded in your real schema; the plan
56
+ is checked by guards written in Ruby, rendered to PDF and approved. Approved plans become tickets, each
56
57
  ticket is handed to a Cursor cloud agent that opens a pull request, and only pull requests
57
58
  you approve are merged. Acceptance criteria map to Cucumber scenarios, and every phase leaves
58
59
  documentation behind: the plan, the tickets, the pull requests and a delivery report.
@@ -66,18 +67,37 @@ files:
66
67
  - CHANGELOG.md
67
68
  - LICENSE.txt
68
69
  - README.md
70
+ - app/controllers/plan_driven/wizard/application_controller.rb
71
+ - app/controllers/plan_driven/wizard/configuration_controller.rb
72
+ - app/controllers/plan_driven/wizard/jobs_controller.rb
73
+ - app/controllers/plan_driven/wizard/plans_controller.rb
74
+ - app/views/layouts/plan_driven/wizard/application.html.erb
75
+ - app/views/plan_driven/wizard/configuration/show.html.erb
76
+ - app/views/plan_driven/wizard/plans/_agents.html.erb
77
+ - app/views/plan_driven/wizard/plans/_approve.html.erb
78
+ - app/views/plan_driven/wizard/plans/_finish.html.erb
79
+ - app/views/plan_driven/wizard/plans/_plan.html.erb
80
+ - app/views/plan_driven/wizard/plans/_tickets.html.erb
81
+ - app/views/plan_driven/wizard/plans/index.html.erb
82
+ - app/views/plan_driven/wizard/plans/new.html.erb
83
+ - app/views/plan_driven/wizard/plans/show.html.erb
84
+ - app/views/plan_driven/wizard/plans/statistics.html.erb
85
+ - config/routes.rb
69
86
  - exe/plan-driven
70
87
  - lib/generators/plan_driven/install_generator.rb
71
88
  - lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt
72
89
  - lib/generators/plan_driven/templates/plan_driven.rb
73
90
  - lib/plan_driven.rb
74
91
  - lib/plan_driven/agent_prompt.rb
92
+ - lib/plan_driven/charts.rb
75
93
  - lib/plan_driven/cli.rb
94
+ - lib/plan_driven/cli/config_commands.rb
76
95
  - lib/plan_driven/cli/plan_commands.rb
77
96
  - lib/plan_driven/cli/setup_commands.rb
78
97
  - lib/plan_driven/cli/ticket_commands.rb
79
98
  - lib/plan_driven/cli/ui.rb
80
99
  - lib/plan_driven/configuration.rb
100
+ - lib/plan_driven/connections.rb
81
101
  - lib/plan_driven/credentials.rb
82
102
  - lib/plan_driven/cursor_agents.rb
83
103
  - lib/plan_driven/cursor_llm.mjs
@@ -95,8 +115,10 @@ files:
95
115
  - lib/plan_driven/guards/ticket_guard.rb
96
116
  - lib/plan_driven/guards/ticket_normalizer.rb
97
117
  - lib/plan_driven/http.rb
118
+ - lib/plan_driven/interview.rb
98
119
  - lib/plan_driven/json_reply.rb
99
120
  - lib/plan_driven/llm.rb
121
+ - lib/plan_driven/local_agents.rb
100
122
  - lib/plan_driven/models.rb
101
123
  - lib/plan_driven/models/approval.rb
102
124
  - lib/plan_driven/models/event.rb
@@ -112,9 +134,13 @@ files:
112
134
  - lib/plan_driven/renderer/style.css
113
135
  - lib/plan_driven/repository.rb
114
136
  - lib/plan_driven/schema_context.rb
137
+ - lib/plan_driven/statistics.rb
115
138
  - lib/plan_driven/template.rb
116
139
  - lib/plan_driven/ticket_generator.rb
140
+ - lib/plan_driven/usage.rb
117
141
  - lib/plan_driven/version.rb
142
+ - lib/plan_driven/wizard.rb
143
+ - lib/plan_driven/wizard/engine.rb
118
144
  - lib/plan_driven/workflow.rb
119
145
  homepage: https://github.com/blaz1988/plan-driven
120
146
  licenses:
@@ -142,5 +168,5 @@ requirements: []
142
168
  rubygems_version: 3.6.4
143
169
  specification_version: 4
144
170
  summary: From implementation plan to merged, tested pull requests, driven from the
145
- terminal.
171
+ browser or the terminal.
146
172
  test_files: []