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 +4 -4
- data/CHANGELOG.md +18 -1
- data/README.md +45 -37
- data/app/controllers/plan_driven/wizard/configuration_controller.rb +12 -2
- data/app/views/plan_driven/wizard/configuration/show.html.erb +47 -0
- data/config/routes.rb +1 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +4 -1
- data/lib/plan_driven/agent_prompt.rb +13 -1
- data/lib/plan_driven/cli/config_commands.rb +26 -0
- data/lib/plan_driven/cli.rb +3 -0
- data/lib/plan_driven/configuration.rb +37 -6
- data/lib/plan_driven/guards/pr_guard.rb +16 -7
- data/lib/plan_driven/guards/ticket_guard.rb +17 -2
- data/lib/plan_driven/settings.rb +208 -0
- data/lib/plan_driven/ticket_generator.rb +33 -5
- data/lib/plan_driven/version.rb +1 -1
- data/lib/plan_driven/wizard.rb +10 -0
- data/lib/plan_driven.rb +1 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1aa0eca4bbed9a924624eda3f8d7bc0708d0e39e379131ef0f440394e7492de5
|
|
4
|
+
data.tar.gz: ae73e611b52ebd21c3ec55f58706358c5335d3e92b3af7172823ca5c8362a638
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
[](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
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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}"
|
data/lib/plan_driven/cli.rb
CHANGED
|
@@ -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, :
|
|
32
|
-
:
|
|
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, :
|
|
42
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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.
|
|
78
|
-
report.
|
|
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.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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")}"
|
data/lib/plan_driven/version.rb
CHANGED
data/lib/plan_driven/wizard.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|
|
171
|
-
|
|
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: []
|