plan_driven 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ffc191c08d6787d03cfccdcb13fb559e2fb48f5d042fbe5597335544f36339f
4
- data.tar.gz: f32edb2f3c5cd3fd1eed677bc834d6a60527c59d15d49a89065083dc07ee18b8
3
+ metadata.gz: 1aa0eca4bbed9a924624eda3f8d7bc0708d0e39e379131ef0f440394e7492de5
4
+ data.tar.gz: ae73e611b52ebd21c3ec55f58706358c5335d3e92b3af7172823ca5c8362a638
5
5
  SHA512:
6
- metadata.gz: 8a9a83308065071c48d13c575f06966a99fd517a3f6e2fbcacb5d804f937e433395305aef7578332fc171929c8e234923f5abc537ba21e5c6100cbb9e7ee7279
7
- data.tar.gz: 79a273ff6f7d84e371445047646f9ea799a374ca0f2a838863b76eb3e9cf3a3a3ded7054235e68c3d500ef02f31e744bf2aa9e255364d071afc0112ed1c9af20
6
+ metadata.gz: 254283d951789508706067cc257a5ceb839467abe07953eb3457a2553a2eb21eb9698199b158efbbc4db22fef417e2e32a7f1430fe86fa7ea3a7cc1ca63cf31b
7
+ data.tar.gz: f69bdd8456fa7cda4e6e00b8767a1d4d3876e25b06c683e7c9909b5a941ca89261ea26ac25a3c0ba772bd9543f8e96e6e4dd57ac95a9c92a1c18e88698ed66ec
data/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-10-05
10
+
11
+ ### Added
12
+
13
+ - Settings on the wizard's Configuration page, and `plan-driven settings` / `plan-driven setting`:
14
+ how finely tickets are split (`ticket_split`, `max_tickets`, `max_estimate`), whether schema
15
+ changes get their own pull requests (`separate_migrations`), pull request size, specs and
16
+ Cucumber proof, whether agents run only the specs they need while working (`targeted_tests`),
17
+ and how many agents work at once. Each starts at the recommended practice and explains its
18
+ trade-off. The choices are written to `config/plan_driven/settings.yml`, which wins over the
19
+ initializer.
20
+
21
+ ## [0.3.0] - 2026-10-01
22
+
9
23
  ### Added
10
24
 
11
25
  - Statistics, computed from the audit trail: how long planning, tickets, development and proof
@@ -24,6 +38,8 @@ All notable changes to this project are documented here. The format follows
24
38
  - The delivery report has colour: every acceptance criterion's result is a green, red, amber
25
39
  or grey pill with a matching edge on its row, failed rows are tinted red, merged tickets are
26
40
  marked green, and the Markdown shows ✅, ❌, ⏸️ or ⚠️ before each result.
41
+ - The README and the gem description say up front that the whole process runs from the
42
+ browser wizard or the terminal, whichever you prefer.
27
43
 
28
44
  ## [0.2.0] - 2026-09-30
29
45
 
@@ -144,6 +160,7 @@ First public release.
144
160
  - `plan-driven doctor` checks keys, the repository, the PDF browser, the Cursor API, the agent
145
161
  model, and Node and the SDK for `:cursor`.
146
162
 
147
- [Unreleased]: https://github.com/blaz1988/plan-driven/compare/v0.2.0...HEAD
163
+ [Unreleased]: https://github.com/blaz1988/plan-driven/compare/v0.3.0...HEAD
164
+ [0.3.0]: https://github.com/blaz1988/plan-driven/compare/v0.2.0...v0.3.0
148
165
  [0.2.0]: https://github.com/blaz1988/plan-driven/compare/v0.1.0...v0.2.0
149
166
  [0.1.0]: https://github.com/blaz1988/plan-driven/releases/tag/v0.1.0
data/README.md CHANGED
@@ -29,44 +29,9 @@ Zagreb. [Need Rails engineers?](#about-rubycode)
29
29
 
30
30
  ## Watch it deliver a feature
31
31
 
32
- [![Watch the plan_driven wizard demo (12 min)](docs/images/wizard-demo.png)](https://github.com/blaz1988/plan-driven/releases/download/v0.1.0/plan-driven-wizard.mp4)
33
-
34
- **[▶ Watch the wizard demo](https://github.com/blaz1988/plan-driven/releases/download/v0.1.0/plan-driven-wizard.mp4)**
35
- (12 minutes, narrated, with captions). One feature, comments on events, is delivered from
36
- the [browser wizard](#the-browser-wizard) in [Gather](https://github.com/blaz1988/gather):
37
- the plan drafted and one section redrafted, six tickets as issues
38
- ([#34](https://github.com/blaz1988/gather/issues/34) to
39
- [#39](https://github.com/blaz1988/gather/issues/39)), six agents and six pull requests
40
- ([#40](https://github.com/blaz1988/gather/pull/40) to
41
- [#45](https://github.com/blaz1988/gather/pull/45)), one round of feedback, and 42 of 42
42
- acceptance criteria proven by a passing scenario. After every click the video zooms into the
43
- wizard's terminal panel, which shows the `plan-driven` command that ran and its output. The
44
- wizard ships with 0.2.0.
45
32
 
46
- <details>
47
- <summary>Chapters</summary>
33
+ https://github.com/user-attachments/assets/4f3c26e1-437b-4bb5-92c2-31e693859e99
48
34
 
49
- | Time | Chapter |
50
- | ---: | --- |
51
- | 0:00 | What plan_driven is |
52
- | 0:24 | Installing it, and how the wizard works |
53
- | 1:13 | `doctor`, from the wizard |
54
- | 1:48 | The interview as a form, and the draft |
55
- | 2:43 | Reading the plan, redrafting a section, submitting and approving |
56
- | 3:51 | Tickets drafted and approved, as GitHub issues |
57
- | 4:37 | Starting the agents |
58
- | 5:18 | Refreshing, and `review` |
59
- | 6:01 | Reading the first pull request |
60
- | 6:46 | Approving and merging, and the next agent starts |
61
- | 7:43 | Feedback to the agent on T3 |
62
- | 8:24 | Every ticket merged |
63
- | 8:53 | Evidence (42 of 42), tokens, and the delivery report |
64
- | 9:47 | Reading the delivery report |
65
- | 10:11 | The feature in the app |
66
- | 10:46 | The same commands in a terminal |
67
- | 11:22 | Recap |
68
-
69
- </details>
70
35
 
71
36
  ### The CLI deep dive
72
37
 
@@ -359,7 +324,7 @@ the audit trail is the same either way. A few things to know:
359
324
 
360
325
  ### Configuration: connections and the interview
361
326
 
362
- **Configuration**, at the top of every page, has two parts.
327
+ **Configuration**, at the top of every page, has three parts.
363
328
 
364
329
  **Connections** shows which services this app's configuration uses (Cursor for cloud agents
365
330
  or drafting, OpenAI or Anthropic for drafting, GitHub for issues and pull requests), whether
@@ -393,6 +358,39 @@ whole team gets the same interview, in the wizard and in the terminal. An added
393
358
  to the model with the other answers, and it's a section of the plan under the group you pick.
394
359
  Drafted sections (Database changes, Risks...) belong to the model and can't be changed here.
395
360
 
361
+ **Tickets and pull requests** holds the choices that change how work is split and checked. Every
362
+ option starts at the recommended Rails practice, and each one explains what changing it trades
363
+ off, mostly fewer pull requests and tokens against bigger reviews:
364
+
365
+ | Setting | Recommended | What it decides |
366
+ | --- | --- | --- |
367
+ | `ticket_split` | `small` | One user-visible behaviour per ticket, or related behaviours together (`larger`) |
368
+ | `max_tickets` | no limit | At most this many tickets per plan |
369
+ | `max_estimate` | `5` | The largest ticket, in story points |
370
+ | `separate_migrations` | on | Schema changes in their own pull requests, deployed before the code |
371
+ | `max_pr_changed_lines` | `800` | The largest pull request the guard accepts |
372
+ | `require_specs_in_pr` | on | Every code pull request changes specs or features |
373
+ | `cucumber` | on | Every acceptance criterion is proved by a scenario |
374
+ | `targeted_tests` | on | Agents run only the specs they need while working, the whole suite once at the end |
375
+ | `max_parallel_agents` | `3` | Agents working at the same time |
376
+
377
+ With `separate_migrations` off, an additive migration goes in the pull request of the first code
378
+ that needs it; removing or renaming a column still gets its own cleanup ticket. Each Save runs
379
+ `plan-driven setting`:
380
+
381
+ ```
382
+ $ bin/plan-driven setting separate_migrations off
383
+ ✓ separate_migrations: off
384
+ Off saves a pull request and an agent run for each migration, but the code and the schema
385
+ change ship together, so a rollback undoes both.
386
+ $ bin/plan-driven setting separate_migrations --default
387
+ ✓ separate_migrations is back to on
388
+ ```
389
+
390
+ The choices are written to `config/plan_driven/settings.yml`, which wins over the initializer.
391
+ Commit it so the whole team plans and reviews the same way. `plan-driven settings` lists every
392
+ option, its value, and where the value comes from.
393
+
396
394
  ## Walkthrough: one feature from idea to merged
397
395
 
398
396
  This is the run from the demo video, in [Gather](https://github.com/blaz1988/gather), with the
@@ -850,6 +848,8 @@ key such as `PD-1`, and `PLAN/TICKET` is a ticket such as `PD-1/T3`.
850
848
  | `stats PLAN` | Where the time went: phases, agents and people, each ticket |
851
849
  | `questions` | The interview's questions, and which ones the team changed or added |
852
850
  | `question KEY [--title T] [--ask Q] [--group G] [--required \| --optional] [--remove]` | Change or add an interview question, or put it back |
851
+ | `settings` | How tickets and pull requests are split and checked, and where each value comes from |
852
+ | `setting KEY VALUE [--default]` | Change a setting, or put it back |
853
853
  | `configure` | Store keys in `~/.plan_driven/config` |
854
854
  | `connect SERVICE` | Check a key with `cursor`, `openai`, `anthropic` or `github`, then store it |
855
855
  | `doctor` | Check keys, repository, PDF browser, and the Cursor connection or local agent command |
@@ -911,6 +911,10 @@ PlanDriven.configure do |config|
911
911
  config.spec_paths = %w[spec/ test/ features/]
912
912
  config.cucumber = true
913
913
  config.features_path = "features"
914
+ config.ticket_split = "small" # or "larger"
915
+ config.max_tickets = nil # nil: no limit
916
+ config.separate_migrations = true
917
+ config.targeted_tests = true
914
918
 
915
919
  # What the model and the agents should know
916
920
  config.team_rules = ["Authorization goes through Pundit policies, never in controllers."]
@@ -927,6 +931,10 @@ end
927
931
  read-only tools (read, grep, glob, ls), so it reads the application's code while it writes the
928
932
  plan and can't change a file.
929
933
 
934
+ The settings on the Configuration page (`ticket_split` to `max_parallel_agents`, see
935
+ [Configuration: connections and the interview](#configuration-connections-and-the-interview))
936
+ can be set here too; `config/plan_driven/settings.yml` wins over the initializer.
937
+
930
938
  `config.template` replaces the plan's sections if your template differs.
931
939
 
932
940
  ## Choosing the coding agents
@@ -19,6 +19,13 @@ module PlanDriven
19
19
  redirect_to configuration_path(anchor: "questions"), alert: e.message
20
20
  end
21
21
 
22
+ def setting
23
+ job = start(Commands.argv("setting", params.permit(:key, :value, :default).to_h))
24
+ redirect_to configuration_path(job: job.id, anchor: "setting_#{params[:key]}")
25
+ rescue ArgumentError => e
26
+ redirect_to configuration_path(anchor: "settings"), alert: e.message
27
+ end
28
+
22
29
  def connect
23
30
  service = Connections.find(params[:service])
24
31
  key = params[:api_key].to_s.strip
@@ -30,9 +37,12 @@ module PlanDriven
30
37
  redirect_to configuration_path(anchor: "connections"), alert: e.message
31
38
  end
32
39
 
40
+ CHECKS = { "questions" => "questions", "settings" => "settings" }.freeze
41
+
33
42
  def check
34
- job = start(Commands.argv(params[:do] == "questions" ? "questions" : "doctor", {}))
35
- redirect_to configuration_path(job: job.id, anchor: params[:do] == "questions" ? "questions" : "connections")
43
+ command = CHECKS.fetch(params[:do].to_s, "doctor")
44
+ job = start(Commands.argv(command, {}))
45
+ redirect_to configuration_path(job: job.id, anchor: command == "doctor" ? "connections" : command)
36
46
  end
37
47
 
38
48
  private
@@ -47,6 +47,53 @@
47
47
  the panel or the logs. A key in the environment always wins.</p>
48
48
  <%= button_to "Check every connection", configuration_check_path(do: "doctor"), form: { style: "display:inline" } %>
49
49
 
50
+ <h2 id="settings">Tickets and pull requests</h2>
51
+ <p class="muted">How the plan is split into tickets, and what every pull request has to pass. Each default is the
52
+ practice we recommend; change one when the trade-off suits your team. Changes are written to
53
+ <code><%= PlanDriven::Settings::PATH %></code> and win over the initializer; commit it so the whole team works the same way.</p>
54
+
55
+ <% PlanDriven::Settings::OPTIONS.group_by(&:group).each do |group, options| %>
56
+ <h3><%= group %></h3>
57
+ <% options.each do |option| %>
58
+ <% value = config.public_send(option.key) %>
59
+ <% origin = PlanDriven::Settings.origin(option.key, config: config) %>
60
+ <% recommended = PlanDriven::Settings.recommended(option.key) %>
61
+ <div class="card setting" id="setting_<%= option.key %>">
62
+ <%= form_with url: configuration_setting_path, method: :post do %>
63
+ <%= hidden_field_tag :key, option.key, id: nil %>
64
+ <div class="row" style="justify-content:space-between">
65
+ <span><strong><%= option.title %></strong> <code><%= option.key %></code>
66
+ <span class="pill <%= value == recommended ? "good" : "wait" %>">
67
+ <%= value == recommended ? "recommended" : "recommended: #{PlanDriven::Settings.label(recommended)}" %></span>
68
+ <% unless origin == "default" %><span class="pill"><%= origin %></span><% end %></span>
69
+ <span class="row">
70
+ <% case option.type %>
71
+ <% when :boolean %>
72
+ <%= select_tag :value, options_for_select([%w[on on], %w[off off]], PlanDriven::Settings.label(value)),
73
+ id: "value_#{option.key}", "aria-label": option.title %>
74
+ <% when :choice %>
75
+ <%= select_tag :value, options_for_select(option.choices, value), id: "value_#{option.key}",
76
+ "aria-label": option.title %>
77
+ <% else %>
78
+ <%= number_field_tag :value, value, min: option.minimum, id: "value_#{option.key}", style: "width:110px",
79
+ placeholder: (option.blank ? "no limit" : nil), "aria-label": option.title %>
80
+ <% end %>
81
+ <button type="submit">Save</button>
82
+ <% if origin == "settings" %>
83
+ <% initial = config.initializer_value(option.key) %>
84
+ <button type="submit" name="default" value="1">Back to
85
+ <%= initial == recommended ? "recommended" : "the initializer's #{PlanDriven::Settings.label(initial)}" %></button>
86
+ <% end %>
87
+ </span>
88
+ </div>
89
+ <p style="margin:8px 0 4px"><%= option.explanation %></p>
90
+ <p class="muted" style="margin:0;font-size:13px"><strong>Trade-off:</strong> <%= option.tradeoff %></p>
91
+ <% end %>
92
+ </div>
93
+ <% end %>
94
+ <% end %>
95
+ <%= button_to "List the settings", configuration_check_path(do: "settings"), form: { style: "display:inline" } %>
96
+
50
97
  <h2 id="questions">Interview questions</h2>
51
98
  <p class="muted">What <code>plan-driven new</code> and the New plan form ask. Changes are written to
52
99
  <code><%= PlanDriven::Interview::PATH %></code>; commit it so the whole team gets the same interview. A new required question
data/config/routes.rb CHANGED
@@ -11,6 +11,7 @@ PlanDriven::Wizard::Engine.routes.draw do
11
11
  post "actor", to: "plans#actor", as: :actor
12
12
  get "configuration", to: "configuration#show", as: :configuration
13
13
  post "configuration/question", to: "configuration#question", as: :configuration_question
14
+ post "configuration/setting", to: "configuration#setting", as: :configuration_setting
14
15
  post "configuration/connect", to: "configuration#connect", as: :configuration_connect
15
16
  post "configuration/check", to: "configuration#check", as: :configuration_check
16
17
  get "jobs/:id", to: "jobs#show", as: :job
@@ -30,7 +30,10 @@ if defined?(PlanDriven.configure)
30
30
  # config.sync_issues = true
31
31
  # config.merge_method = "squash"
32
32
 
33
- # Guards.
33
+ # Guards. How tickets are split and checked can also be chosen on the wizard's Configuration
34
+ # page; those choices go to config/plan_driven/settings.yml and win over these.
35
+ # config.ticket_split = "small" # or "larger": fewer, bigger tickets
36
+ # config.separate_migrations = true # false: additive migrations ship with their code
34
37
  # config.estimate_scale = [1, 2, 3, 5, 8]
35
38
  # config.max_estimate = 5
36
39
  # config.max_pr_changed_lines = 800
@@ -52,6 +52,10 @@ module PlanDriven
52
52
  def done_block
53
53
  lines = ["# Definition of done"]
54
54
  lines << "- Specs cover the change (#{@config.spec_paths.join(", ")}), and the existing suite still passes."
55
+ if @config.targeted_tests
56
+ lines << "- While you work, run only the specs and features for the files you change. Run the whole " \
57
+ "suite once, before you open the pull request."
58
+ end
55
59
  if @config.cucumber && @ticket.kind != "docs"
56
60
  lines << "- Every acceptance criterion has a Cucumber scenario in " \
57
61
  "`#{@config.features_path}/#{@plan.slug}/#{@ticket.key.downcase}.feature`. Tag the feature " \
@@ -65,13 +69,21 @@ module PlanDriven
65
69
 
66
70
  def rules_block
67
71
  rules = ["Follow the conventions already used in this codebase.",
68
- "Schema changes only in migration tickets; this ticket is a #{@ticket.kind} ticket.",
72
+ migration_rule,
69
73
  "Migrations are additive and reversible. Never remove or rename a column that code still reads.",
70
74
  "Don't edit files under #{@config.docs_path}; they are the approved plan.",
71
75
  *@config.team_rules]
72
76
  "# Rules\n#{rules.map { |rule| "- #{rule}" }.join("\n")}"
73
77
  end
74
78
 
79
+ def migration_rule
80
+ if @config.separate_migrations || %w[migration backfill].include?(@ticket.kind)
81
+ "Schema changes only in migration tickets; this ticket is a #{@ticket.kind} ticket."
82
+ else
83
+ "If this ticket needs a schema change, add it as a migration in this pull request, with db/schema.rb."
84
+ end
85
+ end
86
+
75
87
  def pr_block
76
88
  closes = @ticket.issue_number ? "\n- a line `Closes ##{@ticket.issue_number}`" : ""
77
89
  <<~TEXT.strip
@@ -26,6 +26,32 @@ module PlanDriven
26
26
  cmd_questions
27
27
  end
28
28
 
29
+ def cmd_settings
30
+ config = PlanDriven.configuration
31
+ ui.table(%w[Setting Value Source Recommended], Settings::OPTIONS.map do |option|
32
+ [option.key, Settings.label(config.public_send(option.key)), Settings.origin(option.key, config: config),
33
+ Settings.label(Settings.recommended(option.key))]
34
+ end)
35
+ ui.muted "Changes are in #{Settings::PATH}; commit it so the team works the same way. " \
36
+ "`plan-driven setting KEY VALUE` changes one."
37
+ end
38
+
39
+ def cmd_setting(key = nil, value = nil)
40
+ raise ArgumentError, "Which setting? Pass its key, for example separate_migrations." if key.to_s.empty?
41
+
42
+ option = Settings.find(key)
43
+ if @options[:default]
44
+ Settings.reset(option.key)
45
+ ui.success "#{option.key} is back to #{Settings.label(PlanDriven.configuration.public_send(option.key))}"
46
+ else
47
+ raise ArgumentError, "Pass a value for #{option.key}, or --default." if value.nil?
48
+
49
+ ui.success "#{option.key}: #{Settings.label(Settings.change(option.key, value))}"
50
+ end
51
+ ui.muted " #{option.tradeoff}"
52
+ cmd_settings
53
+ end
54
+
29
55
  def cmd_connect(name = nil)
30
56
  service = Connections.find(name)
31
57
  ui.muted "#{service.title}: #{service.purpose}. Get a key at #{service.url}"
@@ -44,6 +44,8 @@ module PlanDriven
44
44
  "stats" => ["PLAN", "Where the time went: phases, agents and people, each ticket"],
45
45
  "questions" => ["", "The interview's questions, with the team's changes"],
46
46
  "question" => ["KEY", "Change or add a question (--title, --ask, --group, --required, --optional, --remove)"],
47
+ "settings" => ["", "How tickets are split and checked, and what each option trades off"],
48
+ "setting" => ["KEY VALUE", "Change a setting, or put it back with --default"],
47
49
  "configure" => ["", "Store API keys in ~/.plan_driven/config"],
48
50
  "connect" => ["SERVICE", "Check a key with cursor, openai, anthropic or github, then store it"],
49
51
  "doctor" => ["", "Check keys, repository and connections"]
@@ -128,6 +130,7 @@ module PlanDriven
128
130
  parser.on("--required") { options[:required] = true }
129
131
  parser.on("--optional") { options[:required] = false }
130
132
  parser.on("--remove") { options[:remove] = true }
133
+ parser.on("--default") { options[:default] = true }
131
134
  end
132
135
 
133
136
  def boot_application
@@ -28,8 +28,8 @@ module PlanDriven
28
28
 
29
29
  # Coding agents: :cursor (Cursor cloud agents), or :local to run a command such as Claude Code
30
30
  # or Codex on this machine, one git worktree per ticket (see LocalAgents).
31
- attr_accessor :agent_provider, :agent_command, :agent_model, :base_branch, :max_parallel_agents,
32
- :skip_reviewer_request, :agent_timeout
31
+ attr_accessor :agent_provider, :agent_command, :agent_model, :base_branch, :skip_reviewer_request,
32
+ :agent_timeout
33
33
 
34
34
  # Dollars per million tokens, by model id, for the cost in the delivery report (see Usage).
35
35
  attr_accessor :token_prices
@@ -38,8 +38,18 @@ module PlanDriven
38
38
  attr_accessor :github_repository, :sync_issues, :merge_method, :issue_labels
39
39
 
40
40
  # Guards.
41
- attr_accessor :estimate_scale, :max_estimate, :max_pr_changed_lines, :require_specs_in_pr,
42
- :spec_paths, :cucumber, :features_path
41
+ attr_accessor :estimate_scale, :spec_paths, :features_path
42
+
43
+ # How the work is split and checked: ticket_split, max_tickets, max_estimate,
44
+ # separate_migrations, max_pr_changed_lines, require_specs_in_pr, cucumber, targeted_tests and
45
+ # max_parallel_agents (see Settings). The team's choices in config/plan_driven/settings.yml
46
+ # win over what the initializer sets.
47
+ attr_writer(*Settings.keys)
48
+
49
+ Settings::OPTIONS.each do |option|
50
+ name = option.key
51
+ define_method(name) { settings.fetch(name) { instance_variable_get("@#{name}") } }
52
+ end
43
53
 
44
54
  # Extra rules appended to every agent prompt: your team's conventions, in plain English.
45
55
  attr_accessor :team_rules
@@ -68,6 +78,22 @@ module PlanDriven
68
78
  @interview_template
69
79
  end
70
80
 
81
+ # The values in config/plan_driven/settings.yml, read again when the file changes.
82
+ def settings
83
+ file = Settings.path(root_path)
84
+ stamp = [file.to_s, file.exist? && file.mtime]
85
+ unless @settings_stamp == stamp
86
+ @settings = Settings.read(root_path)
87
+ @settings_stamp = stamp
88
+ end
89
+ @settings
90
+ end
91
+
92
+ # What the initializer (or the gem's default) sets, without the settings file.
93
+ def initializer_value(name)
94
+ instance_variable_get("@#{Settings.find(name).key}")
95
+ end
96
+
71
97
  # The template as the initializer set it, before the interview changes.
72
98
  def base_template
73
99
  return @template if @template
@@ -106,12 +132,17 @@ module PlanDriven
106
132
  @issue_labels = %w[plan-driven]
107
133
 
108
134
  @estimate_scale = [1, 2, 3, 5, 8]
135
+ @spec_paths = %w[spec/ test/ features/]
136
+ @features_path = "features"
137
+
138
+ @ticket_split = "small"
139
+ @max_tickets = nil
109
140
  @max_estimate = 5
141
+ @separate_migrations = true
110
142
  @max_pr_changed_lines = 800
111
143
  @require_specs_in_pr = true
112
- @spec_paths = %w[spec/ test/ features/]
113
144
  @cucumber = true
114
- @features_path = "features"
145
+ @targeted_tests = true
115
146
 
116
147
  @team_rules = []
117
148
  @pdf_renderer = nil
@@ -67,19 +67,28 @@ module PlanDriven
67
67
 
68
68
  def check_migrations(report)
69
69
  added = @files.select { |file| file["filename"].start_with?("db/migrate/") && file["status"] == "added" }
70
- if added.any? && !%w[migration backfill].include?(@ticket.kind)
71
- report.error("A #{@ticket.kind} ticket adds a migration (#{added.first["filename"]}); " \
72
- "schema changes belong in their own migration ticket")
73
- end
70
+ check_migration_ticket(report, added)
74
71
  check_migration_scope(report)
75
- if added.empty? && @ticket.kind != "migration"
76
- report.pass("No migrations, so the schema stays with the migration tickets")
77
- end
78
72
  return if added.empty? || paths.any? { |path| path.match?(%r{\Adb/(schema\.rb|structure\.sql)\z}) }
79
73
 
80
74
  report.warning("A migration was added but db/schema.rb didn't change")
81
75
  end
82
76
 
77
+ def check_migration_ticket(report, added)
78
+ return if %w[migration backfill].include?(@ticket.kind) && added.any?
79
+
80
+ if added.empty?
81
+ return unless @config.separate_migrations && @ticket.kind != "migration"
82
+
83
+ report.pass("No migrations, so the schema stays with the migration tickets")
84
+ elsif @config.separate_migrations
85
+ report.error("A #{@ticket.kind} ticket adds a migration (#{added.first["filename"]}); " \
86
+ "schema changes belong in their own migration ticket")
87
+ else
88
+ report.pass("Adds #{added.size} migration(s) with the code that needs them (separate_migrations is off)")
89
+ end
90
+ end
91
+
83
92
  def check_migration_scope(report)
84
93
  return unless @ticket.kind == "migration"
85
94
 
@@ -23,6 +23,7 @@ module PlanDriven
23
23
  return report
24
24
  end
25
25
 
26
+ check_count(report)
26
27
  @tickets.each { |ticket| check_ticket(ticket, report) }
27
28
  check_slicing(report)
28
29
  check_dependencies(report)
@@ -33,6 +34,14 @@ module PlanDriven
33
34
 
34
35
  private
35
36
 
37
+ def check_count(report)
38
+ limit = @config.max_tickets
39
+ return unless limit && @tickets.size > limit
40
+
41
+ report.error("There are #{@tickets.size} tickets, above the limit of #{limit} (max_tickets); " \
42
+ "combine related tickets")
43
+ end
44
+
36
45
  # A model ticket, then a controller ticket, then a view ticket: none of them does anything
37
46
  # a user can see, so no scenario can prove its criteria.
38
47
  def check_slicing(report)
@@ -70,12 +79,18 @@ module PlanDriven
70
79
  report.warning("#{label} has #{criteria.size} acceptance criteria; consider splitting it")
71
80
  end
72
81
 
82
+ # With ticket_split larger the limit is the top of the scale. Under max_tickets the breakdown
83
+ # can't always split a big ticket, so going over the limit is only a warning.
73
84
  def check_estimate(ticket, label, report)
74
85
  estimate = ticket["estimate"]
86
+ limit = @config.ticket_split == "larger" ? @config.estimate_scale.max : @config.max_estimate
75
87
  if estimate.nil?
76
88
  report.error("#{label} has no estimate")
77
- elsif estimate > @config.max_estimate
78
- report.error("#{label} is estimated at #{estimate}, above the limit of #{@config.max_estimate}; split it")
89
+ elsif estimate > limit && @config.max_tickets
90
+ report.warning("#{label} is estimated at #{estimate}, above #{limit}; kept whole to stay within " \
91
+ "#{@config.max_tickets} tickets")
92
+ elsif estimate > limit
93
+ report.error("#{label} is estimated at #{estimate}, above the limit of #{limit}; split it")
79
94
  end
80
95
  end
81
96
 
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module PlanDriven
7
+ # How the work is split into tickets and checked, as the team chose it with
8
+ # `plan-driven setting` or the wizard's Configuration page. It lives in the application, in
9
+ # config/plan_driven/settings.yml, so it is committed and the whole team works the same way:
10
+ #
11
+ # settings:
12
+ # separate_migrations: false
13
+ # max_tickets: 4
14
+ #
15
+ # A value here wins over the initializer. Every default is the practice we recommend; each
16
+ # option says what changing it gains and what it costs.
17
+ module Settings
18
+ PATH = "config/plan_driven/settings.yml"
19
+ HEADER = "# plan_driven settings, as changed with `plan-driven setting` or the wizard.\n" \
20
+ "# Commit it: everyone on the team splits and checks the work the same way.\n"
21
+ ON = %w[true on yes 1].freeze
22
+ OFF = %w[false off no 0].freeze
23
+ NONE = %w[none no-limit unlimited].freeze
24
+
25
+ Option = Struct.new(:key, :type, :choices, :minimum, :blank, :group, :title, :explanation, :tradeoff,
26
+ keyword_init: true)
27
+
28
+ OPTIONS = [
29
+ Option.new(
30
+ key: "ticket_split", type: :choice, choices: %w[small larger], group: "Tickets",
31
+ title: "How finely the plan is split",
32
+ explanation: "small: one thing a user can do per ticket, at most max_estimate points, so every pull " \
33
+ "request is small and quick to review. larger: related behaviours share a ticket, up to " \
34
+ "the top of the estimate scale.",
35
+ tradeoff: "larger means fewer pull requests and agent runs, so fewer tokens, but each review is bigger " \
36
+ "and a problem in one part holds back the whole ticket."
37
+ ),
38
+ Option.new(
39
+ key: "max_tickets", type: :integer, minimum: 1, blank: true, group: "Tickets",
40
+ title: "At most this many tickets per plan",
41
+ explanation: "Blank means no limit, and the plan decides. With a limit, the breakdown must fit in it, " \
42
+ "and a ticket above max_estimate is a warning instead of an error.",
43
+ tradeoff: "A low limit gives fewer, larger pull requests and fewer agent runs; reviews get bigger."
44
+ ),
45
+ Option.new(
46
+ key: "max_estimate", type: :integer, minimum: 1, group: "Tickets",
47
+ title: "Largest ticket, in story points",
48
+ explanation: "A ticket estimated above this must be split (with ticket_split small).",
49
+ tradeoff: "Higher means fewer, bigger tickets."
50
+ ),
51
+ Option.new(
52
+ key: "separate_migrations", type: :boolean, group: "Pull requests",
53
+ title: "Schema changes in their own pull requests",
54
+ explanation: "On: every schema change is a migration ticket of its own, merged and deployed before the " \
55
+ "code that uses it. That is the zero-downtime practice: each migration is reviewed on its " \
56
+ "own, and can be rolled out and rolled back separately. Off: an additive migration goes in " \
57
+ "the same pull request as the first code that needs it. Removing or renaming a column " \
58
+ "always keeps its own cleanup ticket.",
59
+ tradeoff: "Off saves a pull request and an agent run for each migration, but the code and the schema " \
60
+ "change ship together, so a rollback undoes both."
61
+ ),
62
+ Option.new(
63
+ key: "max_pr_changed_lines", type: :integer, minimum: 50, group: "Pull requests",
64
+ title: "Largest pull request, in changed lines",
65
+ explanation: "The pull request guard blocks a pull request that adds and removes more lines than this.",
66
+ tradeoff: "Higher allows bigger tickets; past a few hundred lines, reviews get less careful."
67
+ ),
68
+ Option.new(
69
+ key: "require_specs_in_pr", type: :boolean, group: "Quality",
70
+ title: "Every pull request changes specs",
71
+ explanation: "The pull request guard blocks a code pull request that doesn't add or change a spec or " \
72
+ "feature file.",
73
+ tradeoff: "Off lets untested changes reach review; the reviewer has to catch them."
74
+ ),
75
+ Option.new(
76
+ key: "cucumber", type: :boolean, group: "Quality",
77
+ title: "Prove every acceptance criterion with a Cucumber scenario",
78
+ explanation: "Each criterion needs a scenario tagged with it, the proof step runs them on the merged " \
79
+ "code, and the delivery report shows the result of every criterion.",
80
+ tradeoff: "Off saves the agents writing scenarios, but nothing proves the criteria automatically and " \
81
+ "the delivery report has no proof."
82
+ ),
83
+ Option.new(
84
+ key: "targeted_tests", type: :boolean, group: "Agents",
85
+ title: "Agents run the specs they need while they work",
86
+ explanation: "While working, an agent runs only the specs for the files it changes, then the whole suite " \
87
+ "once before it opens the pull request. CI and the guards still check everything.",
88
+ tradeoff: "The biggest token saving: the output of every full-suite run stays in the agent's context. " \
89
+ "Off: agents run the whole suite as often as they like."
90
+ ),
91
+ Option.new(
92
+ key: "max_parallel_agents", type: :integer, minimum: 1, group: "Agents",
93
+ title: "Agents working at the same time",
94
+ explanation: "How many tickets are handed to agents at once. Tickets still wait for the ones they " \
95
+ "depend on.",
96
+ tradeoff: "More is faster, but more pull requests wait for review at the same time."
97
+ )
98
+ ].freeze
99
+
100
+ module_function
101
+
102
+ def keys
103
+ OPTIONS.map(&:key)
104
+ end
105
+
106
+ def find(key)
107
+ OPTIONS.find { |option| option.key == key.to_s } or
108
+ raise ArgumentError, "Unknown setting #{key.inspect}. One of: #{keys.join(", ")}"
109
+ end
110
+
111
+ def path(root = PlanDriven.configuration.root_path)
112
+ Pathname(root).join(PATH)
113
+ end
114
+
115
+ def read(root = PlanDriven.configuration.root_path)
116
+ file = path(root)
117
+ return {} unless file.exist?
118
+
119
+ data = YAML.safe_load(file.read) || {}
120
+ values = data.is_a?(Hash) ? data["settings"] : nil
121
+ return {} unless values.is_a?(Hash)
122
+
123
+ values.to_h { |key, value| [key.to_s, cast(find(key), value)] }
124
+ rescue Psych::Exception => e
125
+ raise ConfigurationError, "#{PATH} isn't valid YAML: #{e.message}"
126
+ rescue ArgumentError => e
127
+ raise ConfigurationError, "#{PATH}: #{e.message}"
128
+ end
129
+
130
+ # Sets one option from what was typed ("off", "4", "none"...). Returns the stored value.
131
+ def change(key, value, root: PlanDriven.configuration.root_path)
132
+ option = find(key)
133
+ values = read(root)
134
+ values[option.key] = cast(option, value)
135
+ write(values, root)
136
+ values[option.key]
137
+ end
138
+
139
+ # Back to what the initializer (or the gem) sets.
140
+ def reset(key, root: PlanDriven.configuration.root_path)
141
+ option = find(key)
142
+ values = read(root)
143
+ raise ArgumentError, "#{option.key} isn't changed in #{PATH}" unless values.key?(option.key)
144
+
145
+ values.delete(option.key)
146
+ write(values, root)
147
+ end
148
+
149
+ def origin(key, config: PlanDriven.configuration)
150
+ return "settings" if config.settings.key?(key.to_s)
151
+
152
+ config.initializer_value(key) == recommended(key) ? "default" : "initializer"
153
+ end
154
+
155
+ def recommended(key)
156
+ Configuration.new.initializer_value(key)
157
+ end
158
+
159
+ def label(value)
160
+ case value
161
+ when true then "on"
162
+ when false then "off"
163
+ when nil then "no limit"
164
+ else value.to_s
165
+ end
166
+ end
167
+
168
+ def cast(option, value)
169
+ text = value.to_s.strip.downcase
170
+ case option.type
171
+ when :boolean then boolean(option, value, text)
172
+ when :choice
173
+ option.choices.include?(text) or
174
+ raise ArgumentError, "#{option.key} is one of #{option.choices.join(", ")}, not #{value.inspect}"
175
+ text
176
+ else integer(option, value, text)
177
+ end
178
+ end
179
+
180
+ def boolean(option, value, text)
181
+ return value if [true, false].include?(value)
182
+ return true if ON.include?(text)
183
+ return false if OFF.include?(text)
184
+
185
+ raise ArgumentError, "#{option.key} is on or off, not #{value.inspect}"
186
+ end
187
+
188
+ def integer(option, value, text)
189
+ return if option.blank && (value.nil? || text.empty? || NONE.include?(text))
190
+
191
+ number = Integer(text, exception: false)
192
+ unless number && number >= option.minimum
193
+ raise ArgumentError, "#{option.key} is a whole number of at least #{option.minimum}" \
194
+ "#{" (or none)" if option.blank}, not #{value.inspect}"
195
+ end
196
+ number
197
+ end
198
+
199
+ def write(values, root)
200
+ file = path(root)
201
+ return file.tap { FileUtils.rm_f(file) } if values.empty?
202
+
203
+ FileUtils.mkdir_p(file.dirname)
204
+ file.write(HEADER + YAML.dump("settings" => values).delete_prefix("---\n"))
205
+ file
206
+ end
207
+ end
208
+ end
@@ -65,11 +65,10 @@ module PlanDriven
65
65
 
66
66
  Order the work the way zero-downtime Rails changes ship: migrations that add, dual
67
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.
68
+ on tickets before it. #{size_rule} #{migration_rule}
69
+ Split code by behaviour, not by layer: each code ticket delivers #{behaviour_rule}, with its
70
+ model, service, controller, view and specs together, so its acceptance criteria can be
71
+ proven by Cucumber scenarios.#{count_rule}
73
72
 
74
73
  Reply with one JSON object: {"tickets": [ ... ]}. Each ticket has:
75
74
  #{FIELDS}
@@ -78,6 +77,35 @@ module PlanDriven
78
77
 
79
78
  private
80
79
 
80
+ def size_rule
81
+ scale = @config.estimate_scale
82
+ return "Keep tickets small: at most #{@config.max_estimate} points on the scale #{scale.join(", ")}." if small?
83
+
84
+ "Group related behaviours into one ticket, up to #{scale.max} points on the scale #{scale.join(", ")}, " \
85
+ "so the plan needs as few pull requests as it can."
86
+ end
87
+
88
+ def migration_rule
89
+ return "Schema changes get their own migration tickets." if @config.separate_migrations
90
+
91
+ "Put each additive schema change in the same ticket as the first code that needs it, as a migration in " \
92
+ "that pull request. Removing or renaming a column still gets its own cleanup ticket, after everything " \
93
+ "that stops using it."
94
+ end
95
+
96
+ def behaviour_rule
97
+ small? ? "one thing a user can do" : "a group of related things a user can do"
98
+ end
99
+
100
+ def count_rule
101
+ limit = @config.max_tickets
102
+ limit ? "\nUse at most #{limit} ticket#{"s" unless limit == 1} for the whole plan." : ""
103
+ end
104
+
105
+ def small?
106
+ @config.ticket_split != "larger"
107
+ end
108
+
81
109
  def request(plan)
82
110
  sections = plan.sections.map { |key, value| "## #{@config.template[key]&.title || key}\n#{value}" }
83
111
  "Plan #{plan.key}: #{plan.title}\n\n#{sections.join("\n\n")}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanDriven
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -32,6 +32,8 @@ module PlanDriven
32
32
  "feedback" => ->(p) { [ticket(p), required(p, "feedback")] },
33
33
  "questions" => ->(_p) { [] },
34
34
  "question" => ->(p) { [question_key(p), *question_flags(p)] },
35
+ "settings" => ->(_p) { [] },
36
+ "setting" => ->(p) { setting_args(p) },
35
37
  # The key itself goes on stdin, so it's never in the command line or the panel.
36
38
  "connect" => ->(p) { [Connections.find(required(p, "service")).name] }
37
39
  }.merge(PLAN_ONLY.to_h { |name| [name, ->(p) { [plan(p)] }] }).freeze
@@ -91,6 +93,14 @@ module PlanDriven
91
93
  end
92
94
  end
93
95
 
96
+ def setting_args(params)
97
+ key = Settings.find(required(params, "key")).key
98
+ return [key, "--default"] if params["default"].to_s == "1"
99
+
100
+ value = params["value"].to_s.strip
101
+ [key, value.empty? ? "none" : value]
102
+ end
103
+
94
104
  def question_flags(params)
95
105
  return ["--remove"] if params["remove"].to_s == "1"
96
106
 
data/lib/plan_driven.rb CHANGED
@@ -10,6 +10,7 @@ require "set"
10
10
  require_relative "plan_driven/version"
11
11
  require_relative "plan_driven/errors"
12
12
  require_relative "plan_driven/credentials"
13
+ require_relative "plan_driven/settings"
13
14
  require_relative "plan_driven/configuration"
14
15
  require_relative "plan_driven/template"
15
16
  require_relative "plan_driven/interview"
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Blažević
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-01 00:00:00.000000000 Z
10
+ date: 2026-10-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -134,6 +134,7 @@ files:
134
134
  - lib/plan_driven/renderer/style.css
135
135
  - lib/plan_driven/repository.rb
136
136
  - lib/plan_driven/schema_context.rb
137
+ - lib/plan_driven/settings.rb
137
138
  - lib/plan_driven/statistics.rb
138
139
  - lib/plan_driven/template.rb
139
140
  - lib/plan_driven/ticket_generator.rb
@@ -167,6 +168,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
168
  requirements: []
168
169
  rubygems_version: 3.6.4
169
170
  specification_version: 4
170
- summary: From implementation plan to merged, tested pull requests, driven from the
171
- browser or the terminal.
171
+ summary: 'From implementation plan to merged, tested pull requests: AI agents write
172
+ the code, developers review, guardrails and tests protect quality. Browser wizard
173
+ or CLI.'
172
174
  test_files: []