plan_driven 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +98 -6
- data/app/controllers/plan_driven/wizard/plans_controller.rb +15 -4
- data/app/views/layouts/plan_driven/wizard/application.html.erb +12 -0
- data/app/views/plan_driven/wizard/plans/_finish.html.erb +7 -0
- data/app/views/plan_driven/wizard/plans/show.html.erb +1 -0
- data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
- data/config/routes.rb +1 -0
- data/lib/plan_driven/charts.rb +252 -0
- data/lib/plan_driven/cli/ticket_commands.rb +41 -0
- data/lib/plan_driven/cli.rb +1 -0
- data/lib/plan_driven/evidence.rb +6 -3
- data/lib/plan_driven/renderer/html.rb +34 -5
- data/lib/plan_driven/renderer/markdown.rb +41 -2
- data/lib/plan_driven/renderer/style.css +21 -0
- data/lib/plan_driven/renderer.rb +11 -4
- data/lib/plan_driven/statistics.rb +195 -0
- data/lib/plan_driven/version.rb +1 -1
- data/lib/plan_driven/wizard.rb +1 -1
- data/lib/plan_driven.rb +3 -1
- metadata +10 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ffc191c08d6787d03cfccdcb13fb559e2fb48f5d042fbe5597335544f36339f
|
|
4
|
+
data.tar.gz: f32edb2f3c5cd3fd1eed677bc834d6a60527c59d15d49a89065083dc07ee18b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a9a83308065071c48d13c575f06966a99fd517a3f6e2fbcacb5d804f937e433395305aef7578332fc171929c8e234923f5abc537ba21e5c6100cbb9e7ee7279
|
|
7
|
+
data.tar.gz: 79a273ff6f7d84e371445047646f9ea799a374ca0f2a838863b76eb3e9cf3a3a3ded7054235e68c3d500ef02f31e744bf2aa9e255364d071afc0112ed1c9af20
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Statistics, computed from the audit trail: how long planning, tickets, development and proof
|
|
12
|
+
took, each ticket's time split into queued, agent coding, waiting for review, agent fixing
|
|
13
|
+
feedback and approved but not merged, the agents' share of the work, first-time approvals,
|
|
14
|
+
and acceptance criteria merged and proven over time. `plan-driven stats PLAN` prints them,
|
|
15
|
+
the wizard has a Statistics page with charts, and the delivery report has a Statistics
|
|
16
|
+
section.
|
|
17
|
+
- Charts drawn in Ruby as SVG, with no JavaScript: a burn-up of criteria merged and proven,
|
|
18
|
+
a timeline of each ticket, the split between agents and people, and a bar of every
|
|
19
|
+
criterion's result. The delivery report writes them next to the Markdown, so GitHub shows
|
|
20
|
+
them, and inlines them in the HTML and the PDF.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- The delivery report has colour: every acceptance criterion's result is a green, red, amber
|
|
25
|
+
or grey pill with a matching edge on its row, failed rows are tinted red, merged tickets are
|
|
26
|
+
marked green, and the Markdown shows ✅, ❌, ⏸️ or ⚠️ before each result.
|
|
27
|
+
|
|
9
28
|
## [0.2.0] - 2026-09-30
|
|
10
29
|
|
|
11
30
|
### Added
|
data/README.md
CHANGED
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
[](#rails-and-ruby-support)
|
|
7
7
|
[](#rails-and-ruby-support)
|
|
8
8
|
|
|
9
|
-
**From implementation plan to merged, tested pull requests, driven from the terminal.**
|
|
9
|
+
**From implementation plan to merged, tested pull requests, driven from the browser or the terminal.**
|
|
10
10
|
|
|
11
|
-
`plan_driven` runs a Rails team's delivery process
|
|
12
|
-
the writing and your team making the decisions.
|
|
13
|
-
|
|
11
|
+
`plan_driven` runs a Rails team's delivery process inside your Rails app, with AI agents doing
|
|
12
|
+
the writing and your team making the decisions. Drive it the way you prefer: click through the
|
|
13
|
+
[wizard in the browser](#the-browser-wizard), mounted at `/plan_driven` in development, or type
|
|
14
|
+
the same steps in the [terminal](#commands). Every button in the wizard runs one `plan-driven`
|
|
15
|
+
command and shows it to you, so both are the same process, with the same rules and the same
|
|
16
|
+
audit trail, and you can switch between them at any step.
|
|
17
|
+
|
|
18
|
+
A short interview becomes an implementation plan grounded in your real schema and code. Guards written in Ruby check the
|
|
14
19
|
plan, you read it and approve it. The approved plan becomes tickets, each ticket goes to a
|
|
15
20
|
Cursor cloud agent that opens a pull request, and only the pull requests you approve are
|
|
16
21
|
merged. Acceptance criteria map to Cucumber scenarios, so the delivery report shows which
|
|
@@ -115,6 +120,7 @@ repository. The planner and the five agents ran on Claude Opus 5.5 through Curso
|
|
|
115
120
|
- [Configuration](#configuration)
|
|
116
121
|
- [Choosing the coding agents](#choosing-the-coding-agents)
|
|
117
122
|
- [Tokens and cost](#tokens-and-cost)
|
|
123
|
+
- [Statistics](#statistics)
|
|
118
124
|
- [How it compares](#how-it-compares)
|
|
119
125
|
- [Keys](#keys)
|
|
120
126
|
- [Working as a team](#working-as-a-team)
|
|
@@ -717,8 +723,9 @@ $ bin/plan-driven report PD-1
|
|
|
717
723
|
|
|
718
724
|
`evidence` runs the plan's scenarios on your machine and stores the result with the commit it
|
|
719
725
|
ran on; `--from cucumber.json` imports a run from CI instead. The delivery report lists each
|
|
720
|
-
ticket with its pull request, merge commit and approver,
|
|
721
|
-
|
|
726
|
+
ticket with its pull request, merge commit and approver, the [statistics](#statistics) with
|
|
727
|
+
their charts, then every acceptance criterion with the scenario that proves it, marked passed
|
|
728
|
+
or failed in colour, the guard findings, every approval and the full timeline. Commit
|
|
722
729
|
`docs/plans/` with it, and the plan and its proof stay next to the code.
|
|
723
730
|
|
|
724
731
|

|
|
@@ -840,6 +847,7 @@ key such as `PD-1`, and `PLAN/TICKET` is a ticket such as `PD-1/T3`.
|
|
|
840
847
|
| `report PLAN` | Write the delivery report |
|
|
841
848
|
| `log PLAN` | The audit trail |
|
|
842
849
|
| `usage PLAN` | Tokens, time and cost per step and per agent run |
|
|
850
|
+
| `stats PLAN` | Where the time went: phases, agents and people, each ticket |
|
|
843
851
|
| `questions` | The interview's questions, and which ones the team changed or added |
|
|
844
852
|
| `question KEY [--title T] [--ask Q] [--group G] [--required \| --optional] [--remove]` | Change or add an interview question, or put it back |
|
|
845
853
|
| `configure` | Store keys in `~/.plan_driven/config` |
|
|
@@ -985,6 +993,90 @@ For scale, these are the five cloud agents from the demo, read back from Cursor'
|
|
|
985
993
|
are code and text the agents wrote. Most of an agent's tokens go into reading the codebase, so
|
|
986
994
|
a small, conventional one is cheaper to work on.
|
|
987
995
|
|
|
996
|
+
## Statistics
|
|
997
|
+
|
|
998
|
+
Where did the time go? Was it the agents writing code, or the pull requests waiting for a
|
|
999
|
+
person? The same numbers are in three places:
|
|
1000
|
+
|
|
1001
|
+
- **In the wizard:** every plan has a Statistics page, linked under its title and from the
|
|
1002
|
+
Proof & report step. In development that's `http://localhost:3000/plan_driven/plans/PD-1/statistics`.
|
|
1003
|
+
- **In the terminal:** `bin/plan-driven stats PD-1`.
|
|
1004
|
+
- **In the delivery report:** a Statistics section with the same charts, in the Markdown, the
|
|
1005
|
+
HTML and the PDF.
|
|
1006
|
+
|
|
1007
|
+

|
|
1008
|
+
|
|
1009
|
+
This is PD-3 from the demo: six tickets delivered in 1 h 20 min, 42 of 42 criteria proven.
|
|
1010
|
+
|
|
1011
|
+
- **Cards:** idea to delivery, development time, criteria proven, pull requests approved the
|
|
1012
|
+
first time, the agents' share of the work, and tokens.
|
|
1013
|
+
- **Acceptance criteria, merged and proven:** a burn-up against the plan's scope. The blue
|
|
1014
|
+
line rises as each ticket merges with its criteria, and the green line rises when an
|
|
1015
|
+
evidence run proves them. Every run is a dot, red when it failed. Here, two runs failed
|
|
1016
|
+
around 14:50 and the third proved all 42.
|
|
1017
|
+
- **Where the time went, ticket by ticket:** one row per ticket on a shared clock. Grey is
|
|
1018
|
+
queued, waiting for the tickets it depends on. Blue is an agent coding, amber is the pull
|
|
1019
|
+
request waiting for review, purple is an agent fixing feedback, and green is approved but
|
|
1020
|
+
not merged. T3 has one round of feedback, and each ticket waited for the one before it.
|
|
1021
|
+
- **Agents and people:** how the time tickets were worked on splits. Here agents took 91% of
|
|
1022
|
+
it and reviews took 7%. On a plan where the donut is mostly amber, the bottleneck is review,
|
|
1023
|
+
not code.
|
|
1024
|
+
- **Ticket by ticket, and the phases:** the same times as a table, with the estimate and the
|
|
1025
|
+
review rounds, and how long planning, tickets, development and proof took.
|
|
1026
|
+
|
|
1027
|
+
```
|
|
1028
|
+
$ bin/plan-driven stats PD-3
|
|
1029
|
+
PD-3 Comments on events: statistics
|
|
1030
|
+
Planning 2 min
|
|
1031
|
+
Tickets 2 min
|
|
1032
|
+
Development 1 h 20 min
|
|
1033
|
+
Proof 10 min
|
|
1034
|
+
Idea to delivery 1 h 25 min
|
|
1035
|
+
Tickets merged 6 of 6, 5 approved the first time
|
|
1036
|
+
✓ 42 of 42 acceptance criteria proven
|
|
1037
|
+
|
|
1038
|
+
Where the time went while tickets were worked on (agents 91%):
|
|
1039
|
+
Agent coding 1 h 2 min ████████████████████ 79%
|
|
1040
|
+
Waiting for review 5 min ██ 7%
|
|
1041
|
+
Agent fixing feedback 9 min ███ 12%
|
|
1042
|
+
Approved, not merged 1 min █ 2%
|
|
1043
|
+
|
|
1044
|
+
# Est Queued Agent Review Fixes Merge Rounds Total
|
|
1045
|
+
T1 2 53s 10 min 4 min - 23s 0 16 min
|
|
1046
|
+
T2 2 17 min 10 min 8s - 12s 0 10 min
|
|
1047
|
+
T3 3 28 min 11 min 28s 9 min 10s 1 21 min
|
|
1048
|
+
...
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
### How it's worked out
|
|
1052
|
+
|
|
1053
|
+
Nothing is estimated, and no model is asked. Every number is the time between two events that
|
|
1054
|
+
plan-driven already records in the audit trail:
|
|
1055
|
+
|
|
1056
|
+
| From | To | Counts as |
|
|
1057
|
+
| --- | --- | --- |
|
|
1058
|
+
| `tickets.approved` | `ticket.agent_started` | Queued |
|
|
1059
|
+
| `ticket.agent_started` | `ticket.pr_opened` | Agent coding |
|
|
1060
|
+
| `ticket.pr_opened` | `ticket.pr_approved` or `ticket.changes_requested` | Waiting for review |
|
|
1061
|
+
| `ticket.changes_requested` | the next `ticket.pr_opened` | Agent fixing feedback |
|
|
1062
|
+
| `ticket.pr_approved` | `ticket.merged` | Approved, not merged |
|
|
1063
|
+
|
|
1064
|
+
The phases run from `plan.drafted` to the last `plan.approved` (planning), then to
|
|
1065
|
+
`tickets.approved` (tickets), then to `plan.delivered` (development), then to the first
|
|
1066
|
+
passing evidence run (proof). A plan still in development is counted up to now. A plan whose
|
|
1067
|
+
tickets aren't approved yet shows only its planning time.
|
|
1068
|
+
|
|
1069
|
+
The charts are SVG drawn in Ruby, with no JavaScript and nothing to install. `report` writes
|
|
1070
|
+
them next to `delivery-report.md` (`statistics-burnup.svg`, `statistics-timeline.svg`,
|
|
1071
|
+
`statistics-time.svg`, `statistics-proof.svg`), so GitHub shows them in the Markdown, and it
|
|
1072
|
+
inlines them in the HTML and the PDF so both stand alone.
|
|
1073
|
+
|
|
1074
|
+
The report also marks every acceptance criterion's result in colour: a green, red, amber or
|
|
1075
|
+
grey pill with a matching edge on its row, and failed rows tinted red. On GitHub, the Markdown
|
|
1076
|
+
shows ✅, ❌, ⏸️ or ⚠️ instead.
|
|
1077
|
+
|
|
1078
|
+

|
|
1079
|
+
|
|
988
1080
|
## How it compares
|
|
989
1081
|
|
|
990
1082
|
plan_driven sits next to spec-driven tools such as GitHub's Spec Kit and Kiro, which also start
|
|
@@ -6,7 +6,7 @@ module PlanDriven
|
|
|
6
6
|
module Wizard
|
|
7
7
|
# Every form posts here, and every post becomes one `plan-driven` command. The pages only read.
|
|
8
8
|
class PlansController < ApplicationController
|
|
9
|
-
before_action :find_plan, only: %i[show run file]
|
|
9
|
+
before_action :find_plan, only: %i[show statistics run file]
|
|
10
10
|
|
|
11
11
|
def index
|
|
12
12
|
@plans = Plan.order(id: :desc)
|
|
@@ -33,12 +33,16 @@ module PlanDriven
|
|
|
33
33
|
@template = PlanDriven.configuration.template
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def statistics
|
|
37
|
+
@step = "statistics"
|
|
38
|
+
@stats = Statistics.new(@plan)
|
|
39
|
+
end
|
|
40
|
+
|
|
36
41
|
def run
|
|
37
42
|
job = start(Commands.argv(params[:do], command_fields))
|
|
38
|
-
redirect_to
|
|
39
|
-
anchor: params[:open].presence)
|
|
43
|
+
redirect_to after_run(job)
|
|
40
44
|
rescue ArgumentError => e
|
|
41
|
-
redirect_to plan_path(@plan.key, params[:step].presence), alert: e.message
|
|
45
|
+
redirect_to plan_path(@plan.key, params[:step].presence.presence_in(Steps.keys)), alert: e.message
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def run_global
|
|
@@ -62,6 +66,13 @@ module PlanDriven
|
|
|
62
66
|
|
|
63
67
|
private
|
|
64
68
|
|
|
69
|
+
def after_run(job)
|
|
70
|
+
return plan_statistics_path(@plan.key, job: job.id) if params[:step] == "statistics"
|
|
71
|
+
|
|
72
|
+
plan_path(@plan.key, params[:step].presence, job: job.id, open: params[:open].presence,
|
|
73
|
+
anchor: params[:open].presence)
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
def find_plan
|
|
66
77
|
@plan = Plan.find_by_reference!(params[:key])
|
|
67
78
|
rescue ActiveRecord::RecordNotFound => e
|
|
@@ -64,6 +64,18 @@
|
|
|
64
64
|
.grid2 { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 2fr); gap:12px; }
|
|
65
65
|
label.check { display:inline-flex; gap:6px; align-items:center; font-weight:400; margin:0; }
|
|
66
66
|
.card.unused { opacity:.75; }
|
|
67
|
+
.kpis { display:grid; grid-template-columns:repeat(auto-fill, minmax(170px, 1fr)); gap:10px; margin:16px 0 4px; }
|
|
68
|
+
.kpi { background:#fff; border:1px solid var(--line); border-top:3px solid #3b82f6; border-radius:10px; padding:12px 14px; display:flex; flex-direction:column; gap:2px; }
|
|
69
|
+
.kpi small { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
|
70
|
+
.kpi strong { font-size:26px; line-height:1.15; } .kpi strong span { font-size:16px; color:var(--muted); font-weight:500; }
|
|
71
|
+
.kpi .muted { font-size:12px; }
|
|
72
|
+
.kpi.good { border-top-color:var(--ok); } .kpi.good strong { color:var(--ok); }
|
|
73
|
+
.kpi.bad { border-top-color:var(--bad); } .kpi.bad strong { color:var(--bad); }
|
|
74
|
+
.card.chart { padding:6px; } .card.chart svg { display:block; width:100%; height:auto; }
|
|
75
|
+
.swatch { display:inline-block; width:8px; height:8px; border-radius:2px; margin-right:6px; vertical-align:middle; }
|
|
76
|
+
.phases { display:flex; gap:0; border-radius:8px; overflow:hidden; border:1px solid var(--line); }
|
|
77
|
+
.phases div { flex:1; padding:10px 12px; border-right:1px solid var(--line); display:flex; flex-direction:column; }
|
|
78
|
+
.phases div:last-child { border-right:0; } .phases small { color:var(--muted); } .phases .pending { background:#f9fafb; color:var(--muted); }
|
|
67
79
|
.flash { padding:10px 14px; border-radius:8px; margin-bottom:12px; background:#fee2e2; color:var(--bad); }
|
|
68
80
|
</style>
|
|
69
81
|
</head>
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
</div>
|
|
16
16
|
<% end %>
|
|
17
17
|
|
|
18
|
+
<div class="card row">
|
|
19
|
+
<strong>Statistics</strong>
|
|
20
|
+
<span class="muted">Where the time went, criteria merged and proven, agents and people.</span>
|
|
21
|
+
<span style="margin-left:auto"></span>
|
|
22
|
+
<%= link_to "Open the statistics →", plan_statistics_path(@plan.key), class: "button" %>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
18
25
|
<% files = %w[delivery-report.html delivery-report.pdf].select { |name| PlanDriven::Renderer.directory(@plan).join(name).file? } %>
|
|
19
26
|
<% if files.any? %>
|
|
20
27
|
<div class="card">
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<p class="muted" style="margin:0">
|
|
5
5
|
<span class="pill <%= @plan.status == "delivered" ? "good" : "wait" %>"><%= @plan.status.tr("_", " ") %></span>
|
|
6
6
|
revision <%= @plan.revision %> · <%= PlanDriven::Workflow::PLAN_DESCRIPTIONS[@plan.status] %>
|
|
7
|
+
· <%= link_to "Statistics", plan_statistics_path(@plan.key) %>
|
|
7
8
|
</p>
|
|
8
9
|
|
|
9
10
|
<ol class="steps">
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<% content_for :title, "#{@plan.key} statistics" %>
|
|
2
|
+
<% duration = ->(seconds) { PlanDriven::Statistics.duration(seconds) } %>
|
|
3
|
+
<p class="muted" style="margin:0"><%= link_to "← #{@plan.key} #{@plan.title}", plan_path(@plan.key) %></p>
|
|
4
|
+
<h1>Statistics</h1>
|
|
5
|
+
<p class="muted" style="margin:0">
|
|
6
|
+
<span class="pill <%= @plan.status == "delivered" ? "good" : "wait" %>"><%= @plan.status.tr("_", " ") %></span>
|
|
7
|
+
Computed from the audit trail: every number is a recorded event, nothing is estimated.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<% if @stats.started? %>
|
|
11
|
+
<% s = @stats.summary %>
|
|
12
|
+
<div class="kpis">
|
|
13
|
+
<div class="kpi">
|
|
14
|
+
<small>Idea to delivery</small>
|
|
15
|
+
<strong><%= duration[s[:lead_time]] %></strong>
|
|
16
|
+
<span class="muted"><%= @plan.status == "delivered" ? "plan drafted to delivered" : "so far" %></span>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="kpi">
|
|
19
|
+
<small>Development</small>
|
|
20
|
+
<strong><%= duration[s[:development]] %></strong>
|
|
21
|
+
<span class="muted"><%= s[:merged] %> of <%= s[:tickets] %> tickets merged</span>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="kpi <%= s[:proven] == s[:criteria] && s[:criteria].positive? ? "good" : (s[:evidence] ? "bad" : "") %>">
|
|
24
|
+
<small>Criteria proven</small>
|
|
25
|
+
<strong><%= s[:proven] %> <span>/ <%= s[:criteria] %></span></strong>
|
|
26
|
+
<span class="muted"><%= s[:evidence] ? "by a passing scenario" : "no evidence run yet" %></span>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="kpi">
|
|
29
|
+
<small>Approved the first time</small>
|
|
30
|
+
<strong><%= s[:first_time] %> <span>/ <%= s[:merged] %></span></strong>
|
|
31
|
+
<span class="muted"><%= pluralize(s[:review_rounds], "review round") %></span>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="kpi">
|
|
34
|
+
<small>Agents' share of the work</small>
|
|
35
|
+
<strong><%= s[:agent_share] ? "#{s[:agent_share]}%" : "-" %></strong>
|
|
36
|
+
<span class="muted">the rest is waiting on people</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="kpi">
|
|
39
|
+
<small>Tokens</small>
|
|
40
|
+
<strong><%= PlanDriven::Usage.format_tokens(s[:tokens]) %></strong>
|
|
41
|
+
<span class="muted"><%= s[:cost] ? PlanDriven::Usage.format_cost(s[:cost]) : "set config.token_prices for dollars" %></span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<% charts = PlanDriven::Charts.report(@plan, stats: @stats) %>
|
|
46
|
+
<% %w[statistics-proof.svg statistics-burnup.svg statistics-timeline.svg statistics-time.svg].each do |name| %>
|
|
47
|
+
<% next unless charts[name] %>
|
|
48
|
+
<div class="card chart"><%= charts[name].html_safe %></div>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<div class="card">
|
|
52
|
+
<h3 style="margin-top:0">Ticket by ticket</h3>
|
|
53
|
+
<table>
|
|
54
|
+
<thead>
|
|
55
|
+
<tr><th>#</th><th>Ticket</th><th>Est.</th><th>Queued</th><th>Agent</th><th>Review</th><th>Fixes</th><th>Merge</th><th>Rounds</th><th>Start to merge</th></tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody>
|
|
58
|
+
<% @stats.tickets.each do |row| %>
|
|
59
|
+
<tr>
|
|
60
|
+
<td><b><%= row.ticket.key %></b></td>
|
|
61
|
+
<td><%= row.ticket.title %></td>
|
|
62
|
+
<td><%= row.ticket.estimate %></td>
|
|
63
|
+
<% %w[queued agent review fixes merge].each do |phase| %>
|
|
64
|
+
<td><span class="swatch" style="background:<%= PlanDriven::Charts::COLORS[phase] %>"></span><%= row.seconds(phase).positive? ? duration[row.seconds(phase)] : "-" %></td>
|
|
65
|
+
<% end %>
|
|
66
|
+
<td><%= row.review_rounds %></td>
|
|
67
|
+
<td><% if row.merged? %><span class="pill good"><%= duration[row.cycle_time] %></span><% else %><span class="pill wait"><%= row.ticket.status.tr("_", " ") %></span><% end %></td>
|
|
68
|
+
</tr>
|
|
69
|
+
<% end %>
|
|
70
|
+
</tbody>
|
|
71
|
+
</table>
|
|
72
|
+
</div>
|
|
73
|
+
<% else %>
|
|
74
|
+
<div class="card">
|
|
75
|
+
<strong>Statistics start when the tickets are approved.</strong>
|
|
76
|
+
<p class="muted">Until then, this is how long planning has taken.</p>
|
|
77
|
+
</div>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
80
|
+
<div class="card">
|
|
81
|
+
<h3 style="margin-top:0">Phases</h3>
|
|
82
|
+
<div class="phases">
|
|
83
|
+
<% @stats.phases.each do |label, seconds| %>
|
|
84
|
+
<div class="<%= "pending" unless seconds %>"><small><%= label %></small><b><%= seconds ? duration[seconds] : "not yet" %></b></div>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="card row">
|
|
90
|
+
<span class="muted">The same numbers in the terminal, and the charts in the delivery report.</span>
|
|
91
|
+
<span style="margin-left:auto"></span>
|
|
92
|
+
<%= run_button "Print in the terminal", "stats" %>
|
|
93
|
+
<%= run_button "Write the delivery report", "report", {}, primary: true %>
|
|
94
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
PlanDriven::Wizard::Engine.routes.draw do
|
|
4
4
|
root "plans#index"
|
|
5
5
|
resources :plans, only: %i[new create], param: :key
|
|
6
|
+
get "plans/:key/statistics", to: "plans#statistics", as: :plan_statistics
|
|
6
7
|
get "plans/:key(/:step)", to: "plans#show", as: :plan, constraints: { step: /plan|approve|tickets|agents|finish/ }
|
|
7
8
|
post "plans/:key/run", to: "plans#run", as: :run_plan
|
|
8
9
|
get "plans/:key/files/:name", to: "plans#file", as: :plan_file, constraints: { name: /[a-z-]+\.(pdf|html)/ }
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module PlanDriven
|
|
6
|
+
# A plan's statistics as SVG, drawn in Ruby. The same file shows in the wizard, on GitHub next
|
|
7
|
+
# to the delivery report and in its PDF: no JavaScript, nothing to install.
|
|
8
|
+
module Charts
|
|
9
|
+
COLORS = {
|
|
10
|
+
"queued" => "#d1d5db", "agent" => "#3b82f6", "review" => "#f59e0b", "fixes" => "#8b5cf6",
|
|
11
|
+
"merge" => "#10b981", "passed" => "#16a34a", "failed" => "#dc2626", "not run" => "#f59e0b",
|
|
12
|
+
"no scenario" => "#9ca3af"
|
|
13
|
+
}.freeze
|
|
14
|
+
INK = "#1f2330"
|
|
15
|
+
MUTED = "#6b7280"
|
|
16
|
+
GRID = "#eef0f3"
|
|
17
|
+
FONT = "-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
|
|
18
|
+
TICKS = [60, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400, 172_800, 604_800].freeze
|
|
19
|
+
STATUSES = ["passed", "failed", "not run", "no scenario"].freeze
|
|
20
|
+
|
|
21
|
+
# The report's charts by file name, leaving out any the plan has no data for yet.
|
|
22
|
+
def self.report(plan, stats: Statistics.new(plan))
|
|
23
|
+
{ "statistics-proof.svg" => proof(Evidence.matrix(plan)), "statistics-burnup.svg" => burnup(stats),
|
|
24
|
+
"statistics-timeline.svg" => timeline(stats), "statistics-time.svg" => breakdown(stats) }.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Criteria merged (blue) and proven by a passing test (green), against the plan's scope.
|
|
30
|
+
def burnup(stats)
|
|
31
|
+
data = stats.burnup or return
|
|
32
|
+
width = 760
|
|
33
|
+
height = 320
|
|
34
|
+
box = { left: 44, right: 24, top: 84, bottom: 36 }
|
|
35
|
+
x = scale_time(data[:from], data[:to], box[:left], width - box[:right])
|
|
36
|
+
top = [data[:scope], 1].max
|
|
37
|
+
y = ->(value) { box[:top] + ((1 - (value.to_f / top)) * (height - box[:top] - box[:bottom])) }
|
|
38
|
+
parts = [heading("Acceptance criteria, merged and proven", "#{data[:scope]} in scope")]
|
|
39
|
+
parts << legend([["Merged", COLORS["agent"]], ["Proven by a passing test", COLORS["passed"]],
|
|
40
|
+
["Scope", MUTED]], 20, 70)
|
|
41
|
+
parts << value_grid(top, y, box[:left], width - box[:right])
|
|
42
|
+
parts << time_axis(data[:from], data[:to], x, height - box[:bottom], box[:top])
|
|
43
|
+
parts << (%(<line x1="#{box[:left]}" x2="#{width - box[:right]}" y1="#{f(y[data[:scope]])}" ) +
|
|
44
|
+
%(y2="#{f(y[data[:scope]])}" stroke="#{MUTED}" stroke-width="1.5" stroke-dasharray="5 4"/>))
|
|
45
|
+
parts << step_series(data[:merged], data[:to], x, y, COLORS["agent"], fill: true)
|
|
46
|
+
parts << step_series(data[:proven], data[:to], x, y, COLORS["passed"])
|
|
47
|
+
parts << run_dots(data[:proven].drop(1), x, y)
|
|
48
|
+
svg(width, height, parts.join("\n"), title: "Acceptance criteria merged and proven over time")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One row per ticket on a shared clock: queued, agent coding, waiting for review, fixing
|
|
52
|
+
# feedback, approved but not merged.
|
|
53
|
+
def timeline(stats)
|
|
54
|
+
rows = stats.tickets.select { |row| row.segments.any? }
|
|
55
|
+
return if rows.empty?
|
|
56
|
+
|
|
57
|
+
from, to = stats.window
|
|
58
|
+
width = 760
|
|
59
|
+
box = { left: 190, right: 100, top: 84, bottom: 36 }
|
|
60
|
+
row_height = 30
|
|
61
|
+
height = box[:top] + (rows.size * row_height) + box[:bottom]
|
|
62
|
+
x = scale_time(from, to, box[:left], width - box[:right])
|
|
63
|
+
phases = Statistics::PHASES.select { |phase, _| rows.any? { |row| row.seconds(phase).positive? } }
|
|
64
|
+
parts = [heading("Where the time went, ticket by ticket", "#{Statistics.duration(to - from)} in development")]
|
|
65
|
+
parts << legend(phases.map { |phase, label| [label, COLORS[phase]] }, 20, 70)
|
|
66
|
+
parts << time_axis(from, to, x, height - box[:bottom], box[:top])
|
|
67
|
+
rows.each_with_index do |row, index|
|
|
68
|
+
parts << ticket_row(row, box[:top] + (index * row_height), x, box[:left])
|
|
69
|
+
end
|
|
70
|
+
svg(width, height, parts.join("\n"), title: "Where the time went, ticket by ticket")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# How the time tickets were worked on splits between agents and people.
|
|
74
|
+
def breakdown(stats)
|
|
75
|
+
totals = stats.totals.slice(*Statistics::WORK).select { |_, seconds| seconds.positive? }
|
|
76
|
+
sum = totals.values.sum
|
|
77
|
+
return if sum.zero?
|
|
78
|
+
|
|
79
|
+
width = 760
|
|
80
|
+
height = 250
|
|
81
|
+
center = [130, 150]
|
|
82
|
+
radius = 72
|
|
83
|
+
circumference = 2 * Math::PI * radius
|
|
84
|
+
offset = 0.0
|
|
85
|
+
rings = totals.map do |phase, seconds|
|
|
86
|
+
length = circumference * seconds / sum
|
|
87
|
+
ring = %(<circle cx="#{center[0]}" cy="#{center[1]}" r="#{radius}" fill="none" stroke="#{COLORS[phase]}" ) +
|
|
88
|
+
%(stroke-width="28" stroke-dasharray="#{f(length)} #{f(circumference - length)}" ) +
|
|
89
|
+
%(stroke-dashoffset="#{f(-offset)}" transform="rotate(-90 #{center[0]} #{center[1]})">) +
|
|
90
|
+
%(<title>#{esc(Statistics::PHASES[phase])}: #{Statistics.duration(seconds)}</title></circle>)
|
|
91
|
+
offset += length
|
|
92
|
+
ring
|
|
93
|
+
end
|
|
94
|
+
share = stats.agent_share
|
|
95
|
+
parts = [heading("Agents and people", "while tickets were being worked on")]
|
|
96
|
+
parts += rings
|
|
97
|
+
parts << (%(<text x="#{center[0]}" y="#{center[1] + 2}" text-anchor="middle" font-size="26" ) +
|
|
98
|
+
%(font-weight="700" fill="#{INK}">#{share}%</text>))
|
|
99
|
+
parts << %(<text x="#{center[0]}" y="#{center[1] + 22}" text-anchor="middle" fill="#{MUTED}">agents</text>)
|
|
100
|
+
totals.each_with_index do |(phase, seconds), index|
|
|
101
|
+
top = 100 + (index * 30)
|
|
102
|
+
percent = (seconds * 100.0 / sum).round
|
|
103
|
+
parts << %(<rect x="260" y="#{top - 11}" width="14" height="14" rx="3" fill="#{COLORS[phase]}"/>)
|
|
104
|
+
parts << %(<text x="284" y="#{top}" fill="#{INK}" font-size="13">#{esc(Statistics::PHASES[phase])}</text>)
|
|
105
|
+
parts << (%(<text x="560" y="#{top}" fill="#{INK}" font-size="13" text-anchor="end" ) +
|
|
106
|
+
%(font-weight="600">#{Statistics.duration(seconds)}</text>))
|
|
107
|
+
parts << %(<text x="620" y="#{top}" fill="#{MUTED}" font-size="13" text-anchor="end">#{percent}%</text>)
|
|
108
|
+
end
|
|
109
|
+
svg(width, height, parts.join("\n"), title: "Time split between agents and people")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Every acceptance criterion's result in one bar.
|
|
113
|
+
def proof(rows)
|
|
114
|
+
return if rows.empty?
|
|
115
|
+
|
|
116
|
+
width = 760
|
|
117
|
+
height = 104
|
|
118
|
+
counts = STATUSES.to_h { |status| [status, rows.count { |row| row.status == status }] }
|
|
119
|
+
passed = counts["passed"]
|
|
120
|
+
color = if passed == rows.size then COLORS["passed"]
|
|
121
|
+
elsif counts["failed"].positive? then COLORS["failed"]
|
|
122
|
+
else INK
|
|
123
|
+
end
|
|
124
|
+
parts = [%(<text x="20" y="34" font-size="17" font-weight="700" fill="#{color}">) +
|
|
125
|
+
%(#{passed} of #{rows.size} acceptance criteria proven</text>)]
|
|
126
|
+
left = 20.0
|
|
127
|
+
span = width - 40.0
|
|
128
|
+
parts << %(<clipPath id="proof-bar"><rect x="20" y="48" width="#{span}" height="16" rx="8"/></clipPath>)
|
|
129
|
+
segments = counts.filter_map do |status, count|
|
|
130
|
+
next if count.zero?
|
|
131
|
+
|
|
132
|
+
length = span * count / rows.size
|
|
133
|
+
bar = %(<rect x="#{f(left)}" y="48" width="#{f(length)}" height="16" fill="#{COLORS[status]}">) +
|
|
134
|
+
%(<title>#{count} #{status}</title></rect>)
|
|
135
|
+
left += length
|
|
136
|
+
bar
|
|
137
|
+
end
|
|
138
|
+
parts << %(<g clip-path="url(#proof-bar)">#{segments.join}</g>)
|
|
139
|
+
shown = counts.reject { |_, count| count.zero? }
|
|
140
|
+
parts << legend(shown.map { |status, count| ["#{count} #{status}", COLORS[status]] }, 20, 90)
|
|
141
|
+
svg(width, height, parts.join("\n"), title: "#{passed} of #{rows.size} acceptance criteria proven")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def svg(width, height, body, title:)
|
|
145
|
+
<<~SVG
|
|
146
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height}" width="#{width}" height="#{height}" role="img" font-family="#{FONT}" font-size="12">
|
|
147
|
+
<title>#{esc(title)}</title>
|
|
148
|
+
<rect width="#{width}" height="#{height}" rx="10" fill="#ffffff"/>
|
|
149
|
+
#{body}
|
|
150
|
+
</svg>
|
|
151
|
+
SVG
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def heading(text, note)
|
|
155
|
+
%(<text x="20" y="28" font-size="15" font-weight="700" fill="#{INK}">#{esc(text)}</text>) +
|
|
156
|
+
%(<text x="20" y="46" fill="#{MUTED}">#{esc(note)}</text>)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def legend(items, left, top)
|
|
160
|
+
x = left
|
|
161
|
+
items.map do |label, color|
|
|
162
|
+
item = %(<rect x="#{f(x)}" y="#{top - 10}" width="12" height="12" rx="3" fill="#{color}"/>) +
|
|
163
|
+
%(<text x="#{f(x + 18)}" y="#{top}" fill="#{MUTED}">#{esc(label)}</text>)
|
|
164
|
+
x += (label.length * 6.6) + 40
|
|
165
|
+
item
|
|
166
|
+
end.join
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def scale_time(from, to, left, right)
|
|
170
|
+
start = from.to_f
|
|
171
|
+
length = [to.to_f - start, 1].max
|
|
172
|
+
->(time) { left + ((time.to_f - start) / length * (right - left)) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Round values up the axis: 0, 10, 20, 30, 40 rather than 0, 11, 21, 32, 42.
|
|
176
|
+
def value_grid(top, y, left, right)
|
|
177
|
+
step = [1, 2, 5, 10, 20, 25, 50, 100, 200, 500].find { |size| top / size <= 5 } || (top / 5.0).ceil
|
|
178
|
+
(0..top).step(step).map do |value|
|
|
179
|
+
%(<line x1="#{left}" x2="#{right}" y1="#{f(y[value])}" y2="#{f(y[value])}" stroke="#{GRID}"/>) +
|
|
180
|
+
%(<text x="#{left - 8}" y="#{f(y[value] + 4)}" text-anchor="end" fill="#{MUTED}">#{value}</text>)
|
|
181
|
+
end.join
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Clock ticks at a round interval, labelled in the plan's own time zone.
|
|
185
|
+
def time_axis(from, to, x, bottom, top)
|
|
186
|
+
length = to.to_f - from.to_f
|
|
187
|
+
step = TICKS.find { |seconds| length / seconds <= 7 } || TICKS.last
|
|
188
|
+
format = step >= 86_400 || length > 86_400 ? "%-d %b" : "%H:%M"
|
|
189
|
+
first = (from.to_f / step).ceil * step
|
|
190
|
+
(first..to.to_f).step(step).map do |seconds|
|
|
191
|
+
time = local(seconds, from)
|
|
192
|
+
%(<line x1="#{f(x[time])}" x2="#{f(x[time])}" y1="#{top}" y2="#{bottom}" stroke="#{GRID}"/>) +
|
|
193
|
+
%(<text x="#{f(x[time])}" y="#{bottom + 18}" text-anchor="middle" fill="#{MUTED}">) +
|
|
194
|
+
%(#{time.strftime(format)}</text>)
|
|
195
|
+
end.join
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A whole number of seconds as a time in the same zone as `like`, without float drift.
|
|
199
|
+
def local(seconds, like)
|
|
200
|
+
time = Time.at(seconds)
|
|
201
|
+
like.respond_to?(:time_zone) ? time.in_time_zone(like.time_zone) : time.getlocal(like.utc_offset)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def step_series(points, to, x, y, color, fill: false)
|
|
205
|
+
path = "M#{f(x[points.first.at])},#{f(y[points.first.value])}"
|
|
206
|
+
points.drop(1).each { |point| path << " H#{f(x[point.at])} V#{f(y[point.value])}" }
|
|
207
|
+
path << " H#{f(x[to])}"
|
|
208
|
+
area = ""
|
|
209
|
+
area = %(<path d="#{path} V#{f(y[0])} H#{f(x[points.first.at])} Z" fill="#{color}" fill-opacity=".12"/>) if fill
|
|
210
|
+
%(#{area}<path d="#{path}" fill="none" stroke="#{color}" stroke-width="2.5" stroke-linejoin="round"/>)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# One dot per evidence run, red when it failed, with the count on the last one.
|
|
214
|
+
def run_dots(points, x, y)
|
|
215
|
+
points.each_with_index.map do |point, index|
|
|
216
|
+
color = point.status == "passed" ? COLORS["passed"] : COLORS["failed"]
|
|
217
|
+
label = if index == points.size - 1
|
|
218
|
+
%(<text x="#{f(x[point.at] - 8)}" y="#{f(y[point.value] - 10)}" text-anchor="end" ) +
|
|
219
|
+
%(font-weight="700" fill="#{color}">#{point.value} proven</text>)
|
|
220
|
+
end
|
|
221
|
+
%(<circle cx="#{f(x[point.at])}" cy="#{f(y[point.value])}" r="5" fill="#{color}" stroke="#ffffff" ) +
|
|
222
|
+
%(stroke-width="2"><title>Evidence #{point.status}: #{point.value} proven</title></circle>#{label})
|
|
223
|
+
end.join
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def ticket_row(row, top, x, left)
|
|
227
|
+
ticket = row.ticket
|
|
228
|
+
label = "#{ticket.key} #{ticket.title.to_s.truncate(24)}"
|
|
229
|
+
bars = row.segments.map do |segment|
|
|
230
|
+
start = x[segment.from]
|
|
231
|
+
length = [x[segment.to] - start, 2].max
|
|
232
|
+
%(<rect x="#{f(start)}" y="#{top + 6}" width="#{f(length)}" height="18" rx="3" ) +
|
|
233
|
+
%(fill="#{COLORS[segment.phase]}">) +
|
|
234
|
+
%(<title>#{esc(ticket.key)} · #{esc(Statistics::PHASES[segment.phase])} · ) +
|
|
235
|
+
%(#{Statistics.duration(segment.seconds)}</title></rect>)
|
|
236
|
+
end
|
|
237
|
+
finish = x[row.segments.last.to]
|
|
238
|
+
total = row.merged? ? Statistics.duration(row.cycle_time) : "open"
|
|
239
|
+
rounds = row.review_rounds.positive? ? " · #{row.review_rounds} fix" : ""
|
|
240
|
+
%(<text x="#{left - 12}" y="#{top + 19}" text-anchor="end" fill="#{INK}">#{esc(label)}</text>) +
|
|
241
|
+
bars.join + %(<text x="#{f(finish + 8)}" y="#{top + 19}" fill="#{MUTED}">#{esc(total + rounds)}</text>)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def f(number)
|
|
245
|
+
number.to_f.round(1).to_s.delete_suffix(".0")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def esc(text)
|
|
249
|
+
CGI.escapeHTML(text.to_s)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -143,6 +143,26 @@ module PlanDriven
|
|
|
143
143
|
ui.muted "No price for #{totals[:unpriced].join(", ")}; set config.token_prices to see dollars."
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
+
def cmd_stats(reference = nil)
|
|
147
|
+
plan = find_plan(reference)
|
|
148
|
+
stats = Statistics.new(plan)
|
|
149
|
+
duration = ->(seconds) { Statistics.duration(seconds) }
|
|
150
|
+
ui.heading "#{plan.key} #{plan.title}: statistics"
|
|
151
|
+
stats.phases.each { |label, seconds| ui.say " #{label.ljust(22)}#{seconds ? duration[seconds] : "not yet"}" }
|
|
152
|
+
return ui.muted("Ticket statistics start when the tickets are approved.") unless stats.started?
|
|
153
|
+
|
|
154
|
+
show_summary(stats.summary)
|
|
155
|
+
show_time_split(stats)
|
|
156
|
+
ui.say
|
|
157
|
+
ui.table(%w[# Est Queued Agent Review Fixes Merge Rounds Total], stats.tickets.map do |row|
|
|
158
|
+
[row.ticket.key, row.ticket.estimate.to_s,
|
|
159
|
+
*%w[queued agent review fixes merge].map do |phase|
|
|
160
|
+
row.seconds(phase).positive? ? duration[row.seconds(phase)] : "-"
|
|
161
|
+
end,
|
|
162
|
+
row.review_rounds.to_s, row.merged? ? duration[row.cycle_time] : row.ticket.status.tr("_", " ")]
|
|
163
|
+
end)
|
|
164
|
+
end
|
|
165
|
+
|
|
146
166
|
def cmd_report(reference = nil)
|
|
147
167
|
plan = find_plan(reference)
|
|
148
168
|
paths = delivery.report(plan)
|
|
@@ -152,6 +172,27 @@ module PlanDriven
|
|
|
152
172
|
|
|
153
173
|
private
|
|
154
174
|
|
|
175
|
+
def show_summary(summary)
|
|
176
|
+
ui.say " #{"Idea to delivery".ljust(22)}#{Statistics.duration(summary[:lead_time])}"
|
|
177
|
+
ui.say " #{"Tickets merged".ljust(22)}#{summary[:merged]} of #{summary[:tickets]}, " \
|
|
178
|
+
"#{summary[:first_time]} approved the first time"
|
|
179
|
+
proven = "#{summary[:proven]} of #{summary[:criteria]} acceptance criteria proven"
|
|
180
|
+
summary[:proven] == summary[:criteria] ? ui.success(proven) : ui.warn(proven)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def show_time_split(stats)
|
|
184
|
+
work = stats.work_seconds
|
|
185
|
+
return if work.zero?
|
|
186
|
+
|
|
187
|
+
ui.say
|
|
188
|
+
ui.say "Where the time went while tickets were worked on (agents #{stats.agent_share}%):"
|
|
189
|
+
stats.totals.slice(*Statistics::WORK).each do |phase, seconds|
|
|
190
|
+
share = seconds * 100.0 / work
|
|
191
|
+
ui.say " #{Statistics::PHASES[phase].ljust(22)}#{Statistics.duration(seconds).rjust(10)} " \
|
|
192
|
+
"#{"█" * (share / 4).ceil} #{share.round}%"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
155
196
|
def explain_waiting(plan)
|
|
156
197
|
running = plan.tickets.count(&:active_agent?)
|
|
157
198
|
if plan.tickets.any?(&:ready?) && running >= PlanDriven.configuration.max_parallel_agents
|
data/lib/plan_driven/cli.rb
CHANGED
|
@@ -41,6 +41,7 @@ module PlanDriven
|
|
|
41
41
|
"report" => ["PLAN", "Write the delivery report"],
|
|
42
42
|
"log" => ["PLAN", "The audit trail"],
|
|
43
43
|
"usage" => ["PLAN", "Tokens and cost per step and per agent run"],
|
|
44
|
+
"stats" => ["PLAN", "Where the time went: phases, agents and people, each ticket"],
|
|
44
45
|
"questions" => ["", "The interview's questions, with the team's changes"],
|
|
45
46
|
"question" => ["KEY", "Change or add a question (--title, --ask, --group, --required, --optional, --remove)"],
|
|
46
47
|
"configure" => ["", "Store API keys in ~/.plan_driven/config"],
|
data/lib/plan_driven/evidence.rb
CHANGED
|
@@ -21,6 +21,9 @@ module PlanDriven
|
|
|
21
21
|
# when plan-driven itself was started from a development server (the wizard).
|
|
22
22
|
CUCUMBER_ENV = { "RAILS_ENV" => "test", "RACK_ENV" => "test" }.freeze
|
|
23
23
|
|
|
24
|
+
# Shown before each result in the Markdown report, so it reads at a glance on GitHub too.
|
|
25
|
+
MARKS = { "passed" => "✅", "failed" => "❌", "not run" => "⏸️", "no scenario" => "⚠️" }.freeze
|
|
26
|
+
|
|
24
27
|
module_function
|
|
25
28
|
|
|
26
29
|
def tag_expression(plan)
|
|
@@ -76,8 +79,8 @@ module PlanDriven
|
|
|
76
79
|
"not run"
|
|
77
80
|
end
|
|
78
81
|
|
|
79
|
-
def matrix(plan)
|
|
80
|
-
scenarios =
|
|
82
|
+
def matrix(plan, run = plan.evidence_runs.last)
|
|
83
|
+
scenarios = run&.scenarios || []
|
|
81
84
|
plan.tickets.flat_map do |ticket|
|
|
82
85
|
ticket.criteria.each_with_index.map do |criterion, index|
|
|
83
86
|
matching = scenarios.select do |scenario|
|
|
@@ -94,7 +97,7 @@ module PlanDriven
|
|
|
94
97
|
|
|
95
98
|
rows = matrix(plan).map do |row|
|
|
96
99
|
scenario = row.scenarios.map { |s| "#{s["name"]} (`#{s["file"]}`)" }.join("; ").presence || "-"
|
|
97
|
-
["#{row.ticket.key}.#{row.number}", row.criterion, scenario, row.status]
|
|
100
|
+
["#{row.ticket.key}.#{row.number}", row.criterion, scenario, "#{MARKS[row.status]} #{row.status}"]
|
|
98
101
|
end
|
|
99
102
|
commit = run.commit_sha.present? ? " on commit `#{run.commit_sha[0, 7]}`" : ""
|
|
100
103
|
intro = "Cucumber, run #{run.created_at.strftime("%-d %b %Y %H:%M")}#{commit}: `#{run.command}`"
|
|
@@ -7,10 +7,14 @@ module PlanDriven
|
|
|
7
7
|
# and doesn't need to be.
|
|
8
8
|
module HTML
|
|
9
9
|
STYLE = File.read(File.expand_path("style.css", __dir__))
|
|
10
|
+
IMAGE = /\A!\[([^\]]*)\]\(([^)\s]+)\)\z/
|
|
11
|
+
# A table cell that is only a result becomes a coloured pill, and colours its row.
|
|
12
|
+
RESULT = /\A(?:#{Evidence::MARKS.values.map { |mark| Regexp.escape(mark) }.join("|")})?\s*
|
|
13
|
+
(passed|failed|not\ run|no\ scenario|merged)\z/x
|
|
10
14
|
|
|
11
15
|
module_function
|
|
12
16
|
|
|
13
|
-
def document(markdown, title:)
|
|
17
|
+
def document(markdown, title:, images: {})
|
|
14
18
|
<<~HTML
|
|
15
19
|
<!doctype html>
|
|
16
20
|
<html lang="en">
|
|
@@ -21,19 +25,22 @@ module PlanDriven
|
|
|
21
25
|
</head>
|
|
22
26
|
<body>
|
|
23
27
|
<main>
|
|
24
|
-
#{convert(markdown)}
|
|
28
|
+
#{convert(markdown, images: images)}
|
|
25
29
|
</main>
|
|
26
30
|
</body>
|
|
27
31
|
</html>
|
|
28
32
|
HTML
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
def convert(markdown)
|
|
35
|
+
def convert(markdown, images: {})
|
|
32
36
|
lines = markdown.to_s.lines.map(&:chomp)
|
|
33
37
|
out = []
|
|
34
38
|
until lines.empty?
|
|
35
39
|
line = lines.first
|
|
36
|
-
if line.
|
|
40
|
+
if (image = line.strip.match(IMAGE))
|
|
41
|
+
lines.shift
|
|
42
|
+
out << figure(image[1], image[2], images)
|
|
43
|
+
elsif line.start_with?("```")
|
|
37
44
|
out << code_block(lines)
|
|
38
45
|
elsif line.match?(/\A\s*\|/)
|
|
39
46
|
out << table(take_while(lines) { |l| l.match?(/\A\s*\|/) })
|
|
@@ -72,10 +79,32 @@ module PlanDriven
|
|
|
72
79
|
header, *body = cells
|
|
73
80
|
body = body.reject { |row| row.all? { |cell| cell.match?(/\A:?-+:?\z/) } }
|
|
74
81
|
head = header.map { |cell| "<th>#{inline(cell)}</th>" }.join
|
|
75
|
-
rows = body.map { |row|
|
|
82
|
+
rows = body.map { |row| table_row(row) }
|
|
76
83
|
"<table><thead><tr>#{head}</tr></thead><tbody>#{rows.join}</tbody></table>"
|
|
77
84
|
end
|
|
78
85
|
|
|
86
|
+
def table_row(row)
|
|
87
|
+
results = row.filter_map { |cell| cell.match(RESULT)&.[](1) }
|
|
88
|
+
cells = row.map do |cell|
|
|
89
|
+
result = cell.match(RESULT)&.[](1)
|
|
90
|
+
content = if result
|
|
91
|
+
%(<span class="result #{result.tr(" ",
|
|
92
|
+
"-")}">#{result}</span>)
|
|
93
|
+
else
|
|
94
|
+
inline(cell.gsub("\\|", "|"))
|
|
95
|
+
end
|
|
96
|
+
"<td>#{content}</td>"
|
|
97
|
+
end
|
|
98
|
+
klass = results.last ? %( class="#{results.last.tr(" ", "-")}") : ""
|
|
99
|
+
"<tr#{klass}>#{cells.join}</tr>"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A chart written next to the document is inlined, so the HTML and PDF stand alone.
|
|
103
|
+
def figure(alt, src, images)
|
|
104
|
+
body = images[src] || %(<img src="#{CGI.escapeHTML(src)}" alt="#{CGI.escapeHTML(alt)}">)
|
|
105
|
+
%(<figure class="chart">#{body}</figure>)
|
|
106
|
+
end
|
|
107
|
+
|
|
79
108
|
def list(lines)
|
|
80
109
|
ordered = lines.first.match?(/\A\s*\d+\./)
|
|
81
110
|
items = []
|
|
@@ -17,10 +17,12 @@ module PlanDriven
|
|
|
17
17
|
"#{parts.join("\n\n")}\n"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# `charts` are the SVG files written next to the report, by name.
|
|
21
|
+
def report(plan, charts: [])
|
|
21
22
|
parts = ["# #{plan.key}: #{plan.title} (delivery report)", meta(plan)]
|
|
22
|
-
parts << "## Summary\n\n#{summary(plan)}"
|
|
23
|
+
parts << "## Summary\n\n#{summary(plan)}#{chart(charts, "statistics-proof.svg", "Acceptance criteria proven")}"
|
|
23
24
|
parts << "## Tickets and pull requests\n\n#{delivery_table(plan)}"
|
|
25
|
+
parts << "## Statistics\n\n#{statistics(plan, charts)}"
|
|
24
26
|
parts << "## Acceptance criteria and proof\n\n#{Evidence.matrix_markdown(plan)}"
|
|
25
27
|
parts << "## Checks run on each pull request\n\n#{guard_findings(plan)}"
|
|
26
28
|
parts << "## Tokens and cost\n\n#{usage_table(plan)}"
|
|
@@ -89,6 +91,43 @@ module PlanDriven
|
|
|
89
91
|
table(["#", "Ticket", "Status", "Pull request", "Merge commit", "Approved by"], rows)
|
|
90
92
|
end
|
|
91
93
|
|
|
94
|
+
def statistics(plan, charts)
|
|
95
|
+
stats = Statistics.new(plan)
|
|
96
|
+
return "Statistics start when the tickets are approved." unless stats.started?
|
|
97
|
+
|
|
98
|
+
figures = [["statistics-burnup.svg", "Acceptance criteria merged and proven"],
|
|
99
|
+
["statistics-timeline.svg", "Where the time went, ticket by ticket"],
|
|
100
|
+
["statistics-time.svg", "Agents and people"]].map { |name, alt| chart(charts, name, alt) }.join
|
|
101
|
+
"#{table(%w[Measure Value], statistics_rows(stats))}#{figures}\n\n#{ticket_times(stats)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def statistics_rows(stats)
|
|
105
|
+
s = stats.summary
|
|
106
|
+
duration = ->(seconds) { Statistics.duration(seconds) }
|
|
107
|
+
rows = stats.phases.filter_map { |label, seconds| ["#{label} time", duration[seconds]] if seconds }
|
|
108
|
+
rows << ["Idea to delivery", duration[s[:lead_time]]]
|
|
109
|
+
rows << ["Pull requests approved the first time", "#{s[:first_time]} of #{s[:merged]}"]
|
|
110
|
+
rows << ["Review rounds (feedback sent to an agent)", s[:review_rounds].to_s]
|
|
111
|
+
rows << ["Agents' share of the time tickets were worked on", "#{s[:agent_share]}%"] if s[:agent_share]
|
|
112
|
+
rows << ["Estimated points", s[:points].to_s]
|
|
113
|
+
rows
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ticket_times(stats)
|
|
117
|
+
duration = ->(seconds) { seconds.positive? ? Statistics.duration(seconds) : "-" }
|
|
118
|
+
rows = stats.tickets.map do |row|
|
|
119
|
+
[row.ticket.key, row.ticket.estimate.to_s, duration[row.seconds("queued")], duration[row.seconds("agent")],
|
|
120
|
+
duration[row.seconds("review")], duration[row.seconds("fixes")], duration[row.seconds("merge")],
|
|
121
|
+
row.review_rounds.to_s, row.merged? ? Statistics.duration(row.cycle_time) : "open"]
|
|
122
|
+
end
|
|
123
|
+
table(["#", "Estimate", "Queued", "Agent coding", "Waiting for review", "Fixing feedback",
|
|
124
|
+
"Approved, not merged", "Review rounds", "Start to merge"], rows)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def chart(charts, name, alt)
|
|
128
|
+
charts.include?(name) ? "\n\n" : ""
|
|
129
|
+
end
|
|
130
|
+
|
|
92
131
|
def guard_findings(plan)
|
|
93
132
|
lines = plan.tickets.map do |ticket|
|
|
94
133
|
report = Guards::Report.from_h(ticket.guard_report)
|
|
@@ -20,3 +20,24 @@ th { background: #f6f8fa; font-weight: 600; }
|
|
|
20
20
|
tr { break-inside: avoid; }
|
|
21
21
|
a { color: #0969da; text-decoration: none; }
|
|
22
22
|
h1, h2, h3 { break-after: avoid; }
|
|
23
|
+
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
24
|
+
h2 { color: #1f2330; border-left: 4px solid #cc342d; padding-left: 10px; }
|
|
25
|
+
th { background: #f3f4f6; }
|
|
26
|
+
.result { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 8.5pt; font-weight: 600; white-space: nowrap; }
|
|
27
|
+
.result::before { margin-right: 4px; }
|
|
28
|
+
.result.passed, .result.merged { background: #dcfce7; color: #15803d; }
|
|
29
|
+
.result.passed::before, .result.merged::before { content: "✓"; }
|
|
30
|
+
.result.failed { background: #fee2e2; color: #b91c1c; }
|
|
31
|
+
.result.failed::before { content: "✗"; }
|
|
32
|
+
.result.not-run { background: #fef3c7; color: #b45309; }
|
|
33
|
+
.result.not-run::before { content: "•"; }
|
|
34
|
+
.result.no-scenario { background: #f3f4f6; color: #6b7280; }
|
|
35
|
+
.result.no-scenario::before { content: "–"; }
|
|
36
|
+
tr.passed td:first-child { border-left: 3px solid #16a34a; }
|
|
37
|
+
tr.failed td { background: #fef2f2; }
|
|
38
|
+
tr.failed td:first-child { border-left: 3px solid #dc2626; }
|
|
39
|
+
tr.not-run td { background: #fffbeb; }
|
|
40
|
+
tr.not-run td:first-child { border-left: 3px solid #f59e0b; }
|
|
41
|
+
tr.no-scenario td:first-child { border-left: 3px solid #9ca3af; }
|
|
42
|
+
figure.chart { margin: 12px 0 18px; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; break-inside: avoid; }
|
|
43
|
+
figure.chart svg, figure.chart img { display: block; width: 100%; height: auto; }
|
data/lib/plan_driven/renderer.rb
CHANGED
|
@@ -17,23 +17,30 @@ module PlanDriven
|
|
|
17
17
|
write(plan, "plan", Markdown.plan(plan, config: config), title: "#{plan.key} #{plan.title}", config: config)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The charts are SVG files beside the report, so GitHub shows them in the Markdown; the HTML
|
|
21
|
+
# and the PDF carry them inline.
|
|
20
22
|
def write_report(plan, config: PlanDriven.configuration)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
charts = Charts.report(plan)
|
|
24
|
+
dir = directory(plan, config: config)
|
|
25
|
+
FileUtils.mkdir_p(dir)
|
|
26
|
+
Dir[dir.join("statistics-*.svg")].each { |file| File.delete(file) }
|
|
27
|
+
charts.each { |name, svg| File.write(dir.join(name), svg) }
|
|
28
|
+
write(plan, "delivery-report", Markdown.report(plan, charts: charts.keys),
|
|
29
|
+
title: "#{plan.key} delivery report", config: config, images: charts)
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
def directory(plan, config: PlanDriven.configuration)
|
|
26
33
|
config.docs_root.join(plan.slug)
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
def write(plan, name, markdown, title:, config:)
|
|
36
|
+
def write(plan, name, markdown, title:, config:, images: {})
|
|
30
37
|
dir = directory(plan, config: config)
|
|
31
38
|
FileUtils.mkdir_p(dir)
|
|
32
39
|
md = dir.join("#{name}.md")
|
|
33
40
|
html = dir.join("#{name}.html")
|
|
34
41
|
pdf = dir.join("#{name}.pdf")
|
|
35
42
|
File.write(md, markdown)
|
|
36
|
-
File.write(html, HTML.document(markdown, title: title))
|
|
43
|
+
File.write(html, HTML.document(markdown, title: title, images: images))
|
|
37
44
|
written = PDF.render(html, pdf, config: config)
|
|
38
45
|
{ markdown: md, html: html, pdf: written ? pdf : nil }
|
|
39
46
|
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# Where a plan's time went, read from its audit trail: when each ticket was queued, when an
|
|
5
|
+
# agent was writing code, when it waited for a person, and when its acceptance criteria were
|
|
6
|
+
# merged and then proven. Arithmetic on recorded events, so it gives the same answer every time.
|
|
7
|
+
class Statistics
|
|
8
|
+
PHASES = {
|
|
9
|
+
"queued" => "Queued",
|
|
10
|
+
"agent" => "Agent coding",
|
|
11
|
+
"review" => "Waiting for review",
|
|
12
|
+
"fixes" => "Agent fixing feedback",
|
|
13
|
+
"merge" => "Approved, not merged"
|
|
14
|
+
}.freeze
|
|
15
|
+
WORK = %w[agent review fixes merge].freeze
|
|
16
|
+
AGENT = %w[agent fixes].freeze
|
|
17
|
+
|
|
18
|
+
# Each of these events starts the ticket's next phase; merging ends the last one.
|
|
19
|
+
NEXT_PHASE = {
|
|
20
|
+
"ticket.agent_started" => "agent", "ticket.pr_opened" => "review", "ticket.agent_failed" => "review",
|
|
21
|
+
"ticket.changes_requested" => "fixes", "ticket.pr_approved" => "merge", "ticket.merged" => nil
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
Segment = Struct.new(:phase, :from, :to) do
|
|
25
|
+
def seconds = to - from
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
TicketRow = Struct.new(:ticket, :segments, :review_rounds, :merged_at, keyword_init: true) do
|
|
29
|
+
def seconds(phase = nil)
|
|
30
|
+
segments.select { |segment| phase.nil? || segment.phase == phase }.sum(&:seconds)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def merged? = !merged_at.nil?
|
|
34
|
+
def first_time? = merged? && review_rounds.zero?
|
|
35
|
+
def started_at = segments.find { |segment| segment.phase != "queued" }&.from
|
|
36
|
+
def cycle_time = merged_at && started_at && (merged_at - started_at)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Point = Struct.new(:at, :value, :status)
|
|
40
|
+
|
|
41
|
+
attr_reader :plan, :now
|
|
42
|
+
|
|
43
|
+
def self.duration(seconds)
|
|
44
|
+
return "-" if seconds.nil?
|
|
45
|
+
|
|
46
|
+
seconds = seconds.round
|
|
47
|
+
return "#{seconds}s" if seconds < 60
|
|
48
|
+
|
|
49
|
+
minutes = seconds / 60
|
|
50
|
+
return "#{minutes} min" if minutes < 60
|
|
51
|
+
|
|
52
|
+
hours, minutes = minutes.divmod(60)
|
|
53
|
+
return "#{hours} h#{" #{minutes} min" if minutes.positive?}" if hours < 24
|
|
54
|
+
|
|
55
|
+
days, hours = hours.divmod(24)
|
|
56
|
+
"#{days} d#{" #{hours} h" if hours.positive?}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(plan, now: Time.now)
|
|
60
|
+
@plan = plan
|
|
61
|
+
@now = now
|
|
62
|
+
@events = plan.events.includes(:ticket).to_a
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def started? = !development_start.nil?
|
|
66
|
+
|
|
67
|
+
def development_start
|
|
68
|
+
@development_start ||= (first("tickets.approved") || first("ticket.agent_started"))&.created_at
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delivered_at
|
|
72
|
+
first("plan.delivered")&.created_at
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The span the charts draw: from approving the tickets to delivery, or to now while it runs.
|
|
76
|
+
def window
|
|
77
|
+
return unless started?
|
|
78
|
+
|
|
79
|
+
finish = [delivered_at || now, *plan.evidence_runs.map(&:created_at)].max
|
|
80
|
+
[development_start, [finish, development_start + 60].max]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def tickets
|
|
84
|
+
@tickets ||= plan.tickets.map { |ticket| ticket_row(ticket) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def totals
|
|
88
|
+
PHASES.keys.to_h { |phase| [phase, tickets.sum { |row| row.seconds(phase) }] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def work_seconds
|
|
92
|
+
totals.slice(*WORK).values.sum
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The agents' part of the time a ticket was being worked on, from 0 to 100.
|
|
96
|
+
def agent_share
|
|
97
|
+
work = work_seconds
|
|
98
|
+
return if work.zero?
|
|
99
|
+
|
|
100
|
+
(totals.slice(*AGENT).values.sum * 100.0 / work).round
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Planning phases, first to last, as [label, seconds].
|
|
104
|
+
def phases
|
|
105
|
+
drafted = first("plan.drafted")&.created_at
|
|
106
|
+
approved = last("plan.approved")&.created_at
|
|
107
|
+
proven = plan.evidence_runs.find(&:passed?)&.created_at
|
|
108
|
+
[["Planning", span(drafted, approved)], ["Tickets", span(approved, development_start)],
|
|
109
|
+
["Development", span(development_start, delivered_at || (now if started?))],
|
|
110
|
+
["Proof", span(delivered_at, proven)]]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Criteria merged and criteria proven by a passing test, over time, against the plan's scope.
|
|
114
|
+
def burnup
|
|
115
|
+
return unless started?
|
|
116
|
+
|
|
117
|
+
{ scope: plan.tickets.sum { |ticket| ticket.criteria.size }, from: window.first, to: window.last,
|
|
118
|
+
merged: merged_points, proven: proven_points }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def summary
|
|
122
|
+
rows = tickets
|
|
123
|
+
{ lead_time: span(first("plan.drafted")&.created_at, delivered_at || now), development: phases[2].last,
|
|
124
|
+
merged: rows.count(&:merged?), tickets: rows.size, first_time: rows.count(&:first_time?),
|
|
125
|
+
review_rounds: rows.sum(&:review_rounds), agent_share: agent_share,
|
|
126
|
+
points: plan.tickets.sum { |ticket| ticket.estimate.to_i } }.merge(proof, usage)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def proof
|
|
132
|
+
matrix = Evidence.matrix(plan)
|
|
133
|
+
{ proven: matrix.count { |row| row.status == "passed" }, criteria: matrix.size,
|
|
134
|
+
evidence: plan.evidence_runs.any? }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def usage
|
|
138
|
+
totals = Usage.totals(Usage.rows(plan))
|
|
139
|
+
{ tokens: totals[:total_tokens], cost: totals[:cost] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def ticket_row(ticket)
|
|
143
|
+
events = @events.select { |event| event.ticket_id == ticket.id && NEXT_PHASE.key?(event.name) }
|
|
144
|
+
TicketRow.new(ticket: ticket, segments: segments(events),
|
|
145
|
+
review_rounds: events.count { |event| event.name == "ticket.changes_requested" },
|
|
146
|
+
merged_at: events.reverse.find { |event| event.name == "ticket.merged" }&.created_at)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Every ticket is queued from the moment the tickets are approved until its agent starts.
|
|
150
|
+
def segments(events)
|
|
151
|
+
return [] unless development_start
|
|
152
|
+
|
|
153
|
+
phase = "queued"
|
|
154
|
+
from = development_start
|
|
155
|
+
list = events.filter_map do |event|
|
|
156
|
+
segment = Segment.new(phase, from, event.created_at) if phase && event.created_at > from
|
|
157
|
+
phase = NEXT_PHASE[event.name]
|
|
158
|
+
from = [from, event.created_at].max
|
|
159
|
+
segment
|
|
160
|
+
end
|
|
161
|
+
finish = delivered_at || now
|
|
162
|
+
list << Segment.new(phase, from, finish) if phase && finish > from
|
|
163
|
+
list
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def merged_points
|
|
167
|
+
seen = Set.new
|
|
168
|
+
count = 0
|
|
169
|
+
merges = @events.select { |event| event.name == "ticket.merged" && event.ticket && seen.add?(event.ticket_id) }
|
|
170
|
+
[Point.new(development_start, 0)] + merges.map do |event|
|
|
171
|
+
count += event.ticket.criteria.size
|
|
172
|
+
Point.new(event.created_at, count)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def proven_points
|
|
177
|
+
runs = plan.evidence_runs.to_a
|
|
178
|
+
[Point.new(development_start, 0)] + runs.map do |run|
|
|
179
|
+
Point.new(run.created_at, Evidence.matrix(plan, run).count { |row| row.status == "passed" }, run.status)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def first(name)
|
|
184
|
+
@events.find { |event| event.name == name }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def last(name)
|
|
188
|
+
@events.reverse.find { |event| event.name == name }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def span(from, to)
|
|
192
|
+
from && to && to >= from ? to - from : nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/plan_driven/version.rb
CHANGED
data/lib/plan_driven/wizard.rb
CHANGED
|
@@ -14,7 +14,7 @@ module PlanDriven
|
|
|
14
14
|
# The commands the wizard may run, built from form fields. Nothing else reaches a shell:
|
|
15
15
|
# arguments are passed as an array, never interpolated.
|
|
16
16
|
module Commands
|
|
17
|
-
PLAN_ONLY = %w[check submit pdf status evidence report usage log].freeze
|
|
17
|
+
PLAN_ONLY = %w[check submit pdf status evidence report usage stats log].freeze
|
|
18
18
|
|
|
19
19
|
BUILDERS = {
|
|
20
20
|
"doctor" => ->(_p) { [] },
|
data/lib/plan_driven.rb
CHANGED
|
@@ -31,8 +31,10 @@ require_relative "plan_driven/cursor_agents"
|
|
|
31
31
|
require_relative "plan_driven/local_agents"
|
|
32
32
|
require_relative "plan_driven/usage"
|
|
33
33
|
require_relative "plan_driven/repository"
|
|
34
|
-
require_relative "plan_driven/renderer"
|
|
35
34
|
require_relative "plan_driven/evidence"
|
|
35
|
+
require_relative "plan_driven/statistics"
|
|
36
|
+
require_relative "plan_driven/charts"
|
|
37
|
+
require_relative "plan_driven/renderer"
|
|
36
38
|
require_relative "plan_driven/delivery"
|
|
37
39
|
require_relative "plan_driven/wizard"
|
|
38
40
|
require_relative "plan_driven/railtie" if defined?(Rails::Railtie)
|
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.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-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
@@ -80,6 +81,7 @@ files:
|
|
|
80
81
|
- app/views/plan_driven/wizard/plans/index.html.erb
|
|
81
82
|
- app/views/plan_driven/wizard/plans/new.html.erb
|
|
82
83
|
- app/views/plan_driven/wizard/plans/show.html.erb
|
|
84
|
+
- app/views/plan_driven/wizard/plans/statistics.html.erb
|
|
83
85
|
- config/routes.rb
|
|
84
86
|
- exe/plan-driven
|
|
85
87
|
- lib/generators/plan_driven/install_generator.rb
|
|
@@ -87,6 +89,7 @@ files:
|
|
|
87
89
|
- lib/generators/plan_driven/templates/plan_driven.rb
|
|
88
90
|
- lib/plan_driven.rb
|
|
89
91
|
- lib/plan_driven/agent_prompt.rb
|
|
92
|
+
- lib/plan_driven/charts.rb
|
|
90
93
|
- lib/plan_driven/cli.rb
|
|
91
94
|
- lib/plan_driven/cli/config_commands.rb
|
|
92
95
|
- lib/plan_driven/cli/plan_commands.rb
|
|
@@ -131,6 +134,7 @@ files:
|
|
|
131
134
|
- lib/plan_driven/renderer/style.css
|
|
132
135
|
- lib/plan_driven/repository.rb
|
|
133
136
|
- lib/plan_driven/schema_context.rb
|
|
137
|
+
- lib/plan_driven/statistics.rb
|
|
134
138
|
- lib/plan_driven/template.rb
|
|
135
139
|
- lib/plan_driven/ticket_generator.rb
|
|
136
140
|
- lib/plan_driven/usage.rb
|
|
@@ -164,5 +168,5 @@ requirements: []
|
|
|
164
168
|
rubygems_version: 3.6.4
|
|
165
169
|
specification_version: 4
|
|
166
170
|
summary: From implementation plan to merged, tested pull requests, driven from the
|
|
167
|
-
terminal.
|
|
171
|
+
browser or the terminal.
|
|
168
172
|
test_files: []
|