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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2cad74cf1d61e2bc83cc71e30037048a81a0042a25a355695e97f1ec5ebdd823
4
- data.tar.gz: f9b7139fbc8cac5475fd0b1429ebf90d8f06884c71d49c122b48c310c180c442
3
+ metadata.gz: 7ffc191c08d6787d03cfccdcb13fb559e2fb48f5d042fbe5597335544f36339f
4
+ data.tar.gz: f32edb2f3c5cd3fd1eed677bc834d6a60527c59d15d49a89065083dc07ee18b8
5
5
  SHA512:
6
- metadata.gz: 4b1de072864c1a701b49cff10ff0fa25cbe1c8c111c354884dcaaec308637ed1b62f2a12db0dac5be768f77706d9a03cbd24498cfdc93cd10d07611250ee266b
7
- data.tar.gz: 65eb69253b06990105156e72f2a61a9b8b77e7b39b444414ce4516d065ae5a1eabcd0b2a671e1fd33a2929d86031688d8967f3a4d3a97246bac5ca97b3741641
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
  [![Ruby](https://img.shields.io/badge/Ruby-3.1%20to%203.4-CC342D.svg)](#rails-and-ruby-support)
7
7
  [![Rails](https://img.shields.io/badge/Rails-7.0%20to%208.1-D30001.svg)](#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 from the command line, with AI agents doing
12
- the writing and your team making the decisions. A short interview in the terminal becomes an
13
- implementation plan grounded in your real schema and code. Guards written in Ruby check the
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, then every acceptance criterion with
721
- the scenario that proves it, the guard findings, every approval and the full timeline. Commit
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
  ![The delivery report](docs/images/15-delivery-report.png)
@@ -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
+ ![The Statistics page in the wizard, for a delivered plan](docs/images/statistics.png)
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
+ ![Acceptance criteria and proof in the delivery report](docs/images/statistics-report.png)
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 plan_path(@plan.key, params[:step].presence, job: job.id, open: params[:open].presence,
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
@@ -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"],
@@ -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 = plan.evidence_runs.last&.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.start_with?("```")
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| "<tr>#{row.map { |cell| "<td>#{inline(cell.gsub("\\|", "|"))}</td>" }.join}</tr>" }
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
- def report(plan)
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![#{alt}](#{name})" : ""
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; }
@@ -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
- write(plan, "delivery-report", Markdown.report(plan),
22
- title: "#{plan.key} delivery report", config: config)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanDriven
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Blažević
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-30 00:00:00.000000000 Z
10
+ date: 2026-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -50,9 +50,10 @@ dependencies:
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9'
52
52
  description: |
53
- plan_driven runs a Rails team's delivery process from the command line. A terminal interview
54
- turns an idea into an implementation plan grounded in your real schema; the plan is checked
55
- by guards written in Ruby, rendered to PDF and approved. Approved plans become tickets, each
53
+ plan_driven runs a Rails team's delivery process inside your Rails app, from a browser wizard
54
+ or the command line: every button in the wizard runs the same plan-driven command. A short
55
+ interview turns an idea into an implementation plan grounded in your real schema; the plan
56
+ is checked by guards written in Ruby, rendered to PDF and approved. Approved plans become tickets, each
56
57
  ticket is handed to a Cursor cloud agent that opens a pull request, and only pull requests
57
58
  you approve are merged. Acceptance criteria map to Cucumber scenarios, and every phase leaves
58
59
  documentation behind: the plan, the tickets, the pull requests and a delivery report.
@@ -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: []