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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/README.md +437 -37
  4. data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
  5. data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
  6. data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
  7. data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
  9. data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
  10. data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
  11. data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
  12. data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
  13. data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
  14. data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
  15. data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
  16. data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
  17. data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
  18. data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
  19. data/config/routes.rb +17 -0
  20. data/exe/plan-driven +1 -0
  21. data/lib/generators/plan_driven/install_generator.rb +7 -0
  22. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  23. data/lib/plan_driven/charts.rb +252 -0
  24. data/lib/plan_driven/cli/config_commands.rb +60 -0
  25. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  26. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  27. data/lib/plan_driven/cli/ticket_commands.rb +64 -3
  28. data/lib/plan_driven/cli/ui.rb +41 -2
  29. data/lib/plan_driven/cli.rb +21 -4
  30. data/lib/plan_driven/configuration.rb +31 -3
  31. data/lib/plan_driven/connections.rb +69 -0
  32. data/lib/plan_driven/cursor_agents.rb +10 -2
  33. data/lib/plan_driven/delivery.rb +23 -5
  34. data/lib/plan_driven/evidence.rb +11 -4
  35. data/lib/plan_driven/github.rb +4 -0
  36. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  37. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  38. data/lib/plan_driven/interview.rb +161 -0
  39. data/lib/plan_driven/local_agents.rb +254 -0
  40. data/lib/plan_driven/renderer/html.rb +34 -5
  41. data/lib/plan_driven/renderer/markdown.rb +67 -3
  42. data/lib/plan_driven/renderer/style.css +21 -0
  43. data/lib/plan_driven/renderer.rb +11 -4
  44. data/lib/plan_driven/statistics.rb +195 -0
  45. data/lib/plan_driven/template.rb +7 -2
  46. data/lib/plan_driven/usage.rb +114 -0
  47. data/lib/plan_driven/version.rb +1 -1
  48. data/lib/plan_driven/wizard/engine.rb +19 -0
  49. data/lib/plan_driven/wizard.rb +245 -0
  50. data/lib/plan_driven.rb +8 -1
  51. metadata +32 -6
@@ -17,12 +17,15 @@ 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)}"
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![#{alt}](#{name})" : ""
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; }
@@ -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
@@ -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 (optional)",
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? (optional)",
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanDriven
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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