plan_driven 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -1
- data/README.md +437 -37
- data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
- data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
- data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
- data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
- data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
- data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
- data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
- data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
- data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
- data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
- data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
- data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
- data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
- data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
- data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
- data/config/routes.rb +17 -0
- data/exe/plan-driven +1 -0
- data/lib/generators/plan_driven/install_generator.rb +7 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
- data/lib/plan_driven/charts.rb +252 -0
- data/lib/plan_driven/cli/config_commands.rb +60 -0
- data/lib/plan_driven/cli/plan_commands.rb +6 -2
- data/lib/plan_driven/cli/setup_commands.rb +10 -0
- data/lib/plan_driven/cli/ticket_commands.rb +64 -3
- data/lib/plan_driven/cli/ui.rb +41 -2
- data/lib/plan_driven/cli.rb +21 -4
- data/lib/plan_driven/configuration.rb +31 -3
- data/lib/plan_driven/connections.rb +69 -0
- data/lib/plan_driven/cursor_agents.rb +10 -2
- data/lib/plan_driven/delivery.rb +23 -5
- data/lib/plan_driven/evidence.rb +11 -4
- data/lib/plan_driven/github.rb +4 -0
- data/lib/plan_driven/guards/migration_guard.rb +86 -9
- data/lib/plan_driven/guards/ticket_guard.rb +1 -2
- data/lib/plan_driven/interview.rb +161 -0
- data/lib/plan_driven/local_agents.rb +254 -0
- data/lib/plan_driven/renderer/html.rb +34 -5
- data/lib/plan_driven/renderer/markdown.rb +67 -3
- 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/template.rb +7 -2
- data/lib/plan_driven/usage.rb +114 -0
- data/lib/plan_driven/version.rb +1 -1
- data/lib/plan_driven/wizard/engine.rb +19 -0
- data/lib/plan_driven/wizard.rb +245 -0
- data/lib/plan_driven.rb +8 -1
- metadata +32 -6
|
@@ -17,12 +17,15 @@ 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)}"
|
|
28
|
+
parts << "## Tokens and cost\n\n#{usage_table(plan)}"
|
|
26
29
|
parts << "## Approvals\n\n#{approval_history(plan)}"
|
|
27
30
|
parts << "## Timeline\n\n#{timeline(plan)}"
|
|
28
31
|
parts << "## The approved plan\n\nThe plan this delivery implements is in `plan.md`, revision #{plan.revision}."
|
|
@@ -88,6 +91,43 @@ module PlanDriven
|
|
|
88
91
|
table(["#", "Ticket", "Status", "Pull request", "Merge commit", "Approved by"], rows)
|
|
89
92
|
end
|
|
90
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
|
+
|
|
91
131
|
def guard_findings(plan)
|
|
92
132
|
lines = plan.tickets.map do |ticket|
|
|
93
133
|
report = Guards::Report.from_h(ticket.guard_report)
|
|
@@ -116,8 +156,32 @@ module PlanDriven
|
|
|
116
156
|
table(%w[When Subject Role Decision By Note], rows)
|
|
117
157
|
end
|
|
118
158
|
|
|
159
|
+
def usage_table(plan)
|
|
160
|
+
rows = Usage.rows(plan)
|
|
161
|
+
return "No token usage recorded." if rows.empty?
|
|
162
|
+
|
|
163
|
+
"#{table(%w[Step Ticket Model Input Output Cache Time Cost], rows.map { |row| usage_row(row) })}\n\n" \
|
|
164
|
+
"#{usage_total(Usage.totals(rows))}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def usage_row(row)
|
|
168
|
+
tokens = row.tokens.transform_values { |count| Usage.format_tokens(count) }
|
|
169
|
+
cache = Usage.format_tokens(row.tokens["cache_write_tokens"] + row.tokens["cache_read_tokens"])
|
|
170
|
+
time = row.duration_ms ? "#{(row.duration_ms / 60_000.0).round(1)} min" : "-"
|
|
171
|
+
[row.step.to_s, row.ticket || "-", row.model.to_s, tokens["input_tokens"], tokens["output_tokens"], cache, time,
|
|
172
|
+
Usage.format_cost(row.cost)]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def usage_total(totals)
|
|
176
|
+
tokens = Usage.format_tokens(totals[:total_tokens])
|
|
177
|
+
return "**Total: #{tokens} tokens, #{Usage.format_cost(totals[:cost])}**" if totals[:cost]
|
|
178
|
+
|
|
179
|
+
"**Total: #{tokens} tokens.** No price is set for #{totals[:unpriced].join(", ")}; " \
|
|
180
|
+
"add it to `config.token_prices` to see dollars."
|
|
181
|
+
end
|
|
182
|
+
|
|
119
183
|
def timeline(plan)
|
|
120
|
-
plan.events.map do |event|
|
|
184
|
+
plan.events.where.not(name: %w[llm.usage agent.usage]).map do |event|
|
|
121
185
|
target = event.ticket ? " #{event.ticket.key}" : ""
|
|
122
186
|
"- #{event.created_at.strftime("%-d %b %Y %H:%M")} · #{event.name}#{target} · #{event.actor}"
|
|
123
187
|
end.join("\n")
|
|
@@ -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/template.rb
CHANGED
|
@@ -15,6 +15,11 @@ module PlanDriven
|
|
|
15
15
|
def drafted?
|
|
16
16
|
source == :draft
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
# The question as the interview shows it, in the terminal and in the wizard.
|
|
20
|
+
def prompt
|
|
21
|
+
required ? question.to_s : "#{question} (optional)"
|
|
22
|
+
end
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
GROUPS = ["Overview", "Background", "Architectural changes", "Work overview", "Risks", "Testing"].freeze
|
|
@@ -68,7 +73,7 @@ module PlanDriven
|
|
|
68
73
|
{ key: "when", title: "When", group: "Overview", source: :ask, required: false, min_words: 0,
|
|
69
74
|
question: "When is it needed? (date, milestone, or leave empty)", guidance: "Target date or milestone." },
|
|
70
75
|
{ key: "background", title: "Background", group: "Background", source: :ask, required: false, min_words: 0,
|
|
71
|
-
question: "Links, PRDs, existing tickets, earlier decisions
|
|
76
|
+
question: "Links, PRDs, existing tickets, earlier decisions",
|
|
72
77
|
guidance: "Links, product documents, related tickets and earlier decisions." },
|
|
73
78
|
{ key: "existing_data_structure", title: "Existing Data Structure", group: "Background", source: :draft,
|
|
74
79
|
required: true, min_words: 30,
|
|
@@ -91,7 +96,7 @@ module PlanDriven
|
|
|
91
96
|
source: :draft, required: true, min_words: 5,
|
|
92
97
|
guidance: "Queues, external services, feature flags and rollout order. Say so when there are none." },
|
|
93
98
|
{ key: "out_of_scope", title: "Out of Scope", group: "Work overview", source: :ask, required: false,
|
|
94
|
-
min_words: 0, question: "What is explicitly out of scope?
|
|
99
|
+
min_words: 0, question: "What is explicitly out of scope?",
|
|
95
100
|
guidance: "What this plan deliberately doesn't do." },
|
|
96
101
|
{ key: "risks", title: "Risks", group: "Risks", source: :draft, required: true, min_words: 15,
|
|
97
102
|
guidance: "The main risks as a list, and how each is mitigated." },
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# What a plan cost to deliver: tokens for every model call and agent run, recorded as events
|
|
5
|
+
# (`llm.usage`, `agent.usage`), and dollars when config.token_prices has a price for the model.
|
|
6
|
+
# Prices change and differ per account, so the gem ships none: put in what your provider charges.
|
|
7
|
+
module Usage
|
|
8
|
+
FIELDS = %w[input_tokens output_tokens cache_write_tokens cache_read_tokens].freeze
|
|
9
|
+
|
|
10
|
+
Row = Struct.new(:step, :ticket, :model, :calls, :tokens, :duration_ms, :cost, keyword_init: true) do
|
|
11
|
+
def total_tokens
|
|
12
|
+
tokens.values.sum
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Dollars for these tokens, or nil when the model has no price. Prices are per million tokens:
|
|
19
|
+
# config.token_prices = { "your-model-id" => { input: 3.0, output: 15.0, cache_write: 3.75, cache_read: 0.3 } }
|
|
20
|
+
def cost(model, tokens, config: PlanDriven.configuration)
|
|
21
|
+
price = config.token_prices.to_h.transform_keys(&:to_s)[model.to_s] or return
|
|
22
|
+
price = price.transform_keys(&:to_s)
|
|
23
|
+
FIELDS.sum { |field| tokens[field].to_i * price.fetch(field.delete_suffix("_tokens"), 0).to_f } / 1_000_000.0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What the metered model spent in one step, as an `llm.usage` event.
|
|
27
|
+
def record_llm(plan, llm, step, actor:)
|
|
28
|
+
spent = llm.take
|
|
29
|
+
return if spent["calls"].zero?
|
|
30
|
+
|
|
31
|
+
plan.log!("llm.usage", actor: actor, step: step, model: llm.model, **spent.symbolize_keys)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# One finished agent run. Usage is bookkeeping: a provider that can't report it mustn't stop
|
|
35
|
+
# the ticket moving.
|
|
36
|
+
def record_agent(ticket, run, agents, actor:, config: PlanDriven.configuration)
|
|
37
|
+
tokens = agents.respond_to?(:usage) ? agents.usage(ticket.agent_id, run.id) : {}
|
|
38
|
+
step = ticket.plan.events.exists?(name: "agent.usage", ticket: ticket) ? "agent follow-up" : "agent run"
|
|
39
|
+
ticket.plan.log!("agent.usage", actor: actor, ticket: ticket, step: step, run: run.id,
|
|
40
|
+
model: config.agent_model || "#{config.agent_provider} default",
|
|
41
|
+
calls: 1, duration_ms: run.duration_ms, **tokens.to_h.symbolize_keys)
|
|
42
|
+
rescue Error => e
|
|
43
|
+
ticket.plan.log!("agent.usage", actor: actor, ticket: ticket, run: run.id, error: e.message[0, 200])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tokens_from(payload)
|
|
47
|
+
FIELDS.to_h { |field| [field, payload.to_h[field].to_i] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rows(plan, config: PlanDriven.configuration)
|
|
51
|
+
plan.events.where(name: %w[llm.usage agent.usage]).order(:created_at).map do |event|
|
|
52
|
+
payload = event.payload.to_h
|
|
53
|
+
tokens = tokens_from(payload)
|
|
54
|
+
Row.new(step: payload["step"], ticket: event.ticket&.key, model: payload["model"], calls: payload["calls"].to_i,
|
|
55
|
+
tokens: tokens, duration_ms: payload["duration_ms"],
|
|
56
|
+
cost: cost(payload["model"], tokens, config: config))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def totals(rows)
|
|
61
|
+
tokens = FIELDS.to_h { |field| [field, rows.sum { |row| row.tokens[field] }] }
|
|
62
|
+
costs = rows.map(&:cost)
|
|
63
|
+
{ tokens: tokens, total_tokens: tokens.values.sum, cost: costs.any?(&:nil?) ? nil : costs.sum,
|
|
64
|
+
priced_cost: costs.compact.sum, unpriced: rows.select { |row| row.cost.nil? }.map(&:model).uniq }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def format_tokens(count)
|
|
68
|
+
count >= 1_000_000 ? format("%.2fM", count / 1_000_000.0) : count.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_cost(cost)
|
|
72
|
+
cost ? format("$%.2f", cost) : "-"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Wraps any model client and counts what each step spends, so Delivery can record it.
|
|
77
|
+
class MeteredLLM
|
|
78
|
+
def initialize(llm)
|
|
79
|
+
@llm = llm
|
|
80
|
+
reset
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def chat(**options)
|
|
84
|
+
reply = @llm.chat(**options)
|
|
85
|
+
@calls += 1
|
|
86
|
+
@input += reply.input_tokens.to_i
|
|
87
|
+
@output += reply.output_tokens.to_i
|
|
88
|
+
reply
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def label
|
|
92
|
+
@llm.label
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def model
|
|
96
|
+
label.split("/", 2).last
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# What was spent since the last take, as an event payload.
|
|
100
|
+
def take
|
|
101
|
+
spent = { "calls" => @calls, "input_tokens" => @input, "output_tokens" => @output }
|
|
102
|
+
reset
|
|
103
|
+
spent
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def reset
|
|
109
|
+
@calls = 0
|
|
110
|
+
@input = 0
|
|
111
|
+
@output = 0
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/plan_driven/version.rb
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Wizard
|
|
5
|
+
# Mounted by the install generator, in development only:
|
|
6
|
+
#
|
|
7
|
+
# mount PlanDriven::Wizard::Engine, at: "/plan_driven" if Rails.env.development?
|
|
8
|
+
class Engine < ::Rails::Engine
|
|
9
|
+
isolate_namespace PlanDriven::Wizard
|
|
10
|
+
engine_name "plan_driven_wizard"
|
|
11
|
+
config.root = File.expand_path("../../..", __dir__)
|
|
12
|
+
|
|
13
|
+
# Keys pasted on the Configuration page go to `plan-driven connect` on stdin, never to the log.
|
|
14
|
+
initializer "plan_driven_wizard.filter_parameters" do |app|
|
|
15
|
+
app.config.filter_parameters += [:api_key]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|