@sienklogic/plan-build-run 2.27.0 → 2.27.1

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 (45) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dashboard/public/css/layout.css +7 -283
  3. package/dashboard/public/css/status-colors.css +7 -0
  4. package/dashboard/public/css/tokens.css +3 -3
  5. package/dashboard/public/js/sidebar-toggle.js +9 -31
  6. package/dashboard/public/js/theme-toggle.js +4 -4
  7. package/dashboard/src/views/partials/activity-feed.ejs +17 -9
  8. package/dashboard/src/views/partials/analytics-content.ejs +178 -88
  9. package/dashboard/src/views/partials/audit-detail-content.ejs +6 -4
  10. package/dashboard/src/views/partials/audits-content.ejs +28 -26
  11. package/dashboard/src/views/partials/breadcrumbs.ejs +8 -4
  12. package/dashboard/src/views/partials/config-content.ejs +98 -95
  13. package/dashboard/src/views/partials/dashboard-content.ejs +69 -60
  14. package/dashboard/src/views/partials/dependencies-content.ejs +5 -3
  15. package/dashboard/src/views/partials/empty-state.ejs +10 -5
  16. package/dashboard/src/views/partials/footer.ejs +8 -2
  17. package/dashboard/src/views/partials/head.ejs +2 -1
  18. package/dashboard/src/views/partials/header.ejs +16 -19
  19. package/dashboard/src/views/partials/layout-bottom.ejs +5 -40
  20. package/dashboard/src/views/partials/layout-top.ejs +6 -5
  21. package/dashboard/src/views/partials/logs-content.ejs +26 -29
  22. package/dashboard/src/views/partials/milestone-detail-content.ejs +5 -5
  23. package/dashboard/src/views/partials/milestones-content.ejs +40 -31
  24. package/dashboard/src/views/partials/note-detail-content.ejs +7 -5
  25. package/dashboard/src/views/partials/notes-content.ejs +4 -4
  26. package/dashboard/src/views/partials/phase-content.ejs +6 -8
  27. package/dashboard/src/views/partials/phase-doc-content.ejs +13 -15
  28. package/dashboard/src/views/partials/phase-timeline.ejs +22 -15
  29. package/dashboard/src/views/partials/phases-content.ejs +98 -84
  30. package/dashboard/src/views/partials/quick-content.ejs +34 -32
  31. package/dashboard/src/views/partials/quick-detail-content.ejs +20 -19
  32. package/dashboard/src/views/partials/requirements-content.ejs +6 -6
  33. package/dashboard/src/views/partials/research-content.ejs +14 -14
  34. package/dashboard/src/views/partials/research-detail-content.ejs +13 -11
  35. package/dashboard/src/views/partials/roadmap-content.ejs +145 -128
  36. package/dashboard/src/views/partials/sidebar.ejs +86 -140
  37. package/dashboard/src/views/partials/todo-create-content.ejs +51 -46
  38. package/dashboard/src/views/partials/todo-detail-content.ejs +26 -25
  39. package/dashboard/src/views/partials/todos-content.ejs +65 -62
  40. package/dashboard/src/views/partials/todos-done-content.ejs +33 -31
  41. package/package.json +1 -1
  42. package/plugins/copilot-pbr/plugin.json +1 -1
  43. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  44. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  45. package/plugins/pbr/scripts/local-llm/metrics.js +121 -33
@@ -1,54 +1,59 @@
1
1
  <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
2
2
  <h1>Create Todo</h1>
3
3
 
4
- <p><a href="/todos">&larr; Back to Todos</a></p>
4
+ <p><a href="/todos" class="btn btn-outline-secondary">&larr; Back to Todos</a></p>
5
5
 
6
- <article>
7
- <form method="POST" action="/todos"
8
- hx-post="/todos"
9
- hx-target="#main-content">
10
- <label>
11
- Title
12
- <input
13
- type="text"
14
- name="title"
15
- placeholder="Todo title"
16
- required
17
- maxlength="200"
18
- />
19
- </label>
6
+ <div class="card">
7
+ <div class="card-body">
8
+ <form method="POST" action="/todos"
9
+ hx-post="/todos"
10
+ hx-target="#main-content">
11
+ <div class="mb-3">
12
+ <label class="form-label">Title</label>
13
+ <input
14
+ type="text"
15
+ name="title"
16
+ placeholder="Todo title"
17
+ required
18
+ maxlength="200"
19
+ class="form-control"
20
+ />
21
+ </div>
20
22
 
21
- <label>
22
- Priority
23
- <select name="priority" required>
24
- <option value="">Select priority...</option>
25
- <option value="P0">P0 - Critical</option>
26
- <option value="P1">P1 - High</option>
27
- <option value="P2">P2 - Medium</option>
28
- <option value="PX">PX - Low</option>
29
- </select>
30
- </label>
23
+ <div class="mb-3">
24
+ <label class="form-label">Priority</label>
25
+ <select name="priority" required class="form-select">
26
+ <option value="">Select priority...</option>
27
+ <option value="P0">P0 - Critical</option>
28
+ <option value="P1">P1 - High</option>
29
+ <option value="P2">P2 - Medium</option>
30
+ <option value="PX">PX - Low</option>
31
+ </select>
32
+ </div>
31
33
 
32
- <label>
33
- Phase (optional)
34
- <input
35
- type="text"
36
- name="phase"
37
- placeholder="e.g., 09-todo-write-operations"
38
- />
39
- <small>Format: 01-phase-name</small>
40
- </label>
34
+ <div class="mb-3">
35
+ <label class="form-label">Phase (optional)</label>
36
+ <input
37
+ type="text"
38
+ name="phase"
39
+ placeholder="e.g., 09-todo-write-operations"
40
+ class="form-control"
41
+ />
42
+ <small class="form-text text-muted">Format: 01-phase-name</small>
43
+ </div>
41
44
 
42
- <label>
43
- Description
44
- <textarea
45
- name="description"
46
- rows="10"
47
- required
48
- placeholder="Markdown content..."
49
- ></textarea>
50
- </label>
45
+ <div class="mb-3">
46
+ <label class="form-label">Description</label>
47
+ <textarea
48
+ name="description"
49
+ rows="10"
50
+ required
51
+ placeholder="Markdown content..."
52
+ class="form-control"
53
+ ></textarea>
54
+ </div>
51
55
 
52
- <button type="submit">Create Todo</button>
53
- </form>
54
- </article>
56
+ <button type="submit" class="btn btn-primary">Create Todo</button>
57
+ </form>
58
+ </div>
59
+ </div>
@@ -3,8 +3,8 @@
3
3
 
4
4
  <p><a href="/todos">&larr; Back to Todos</a></p>
5
5
 
6
- <article>
7
- <header>
6
+ <div class="card mb-3">
7
+ <div class="card-header">
8
8
  <strong>Todo <%= id %></strong>
9
9
  &nbsp;
10
10
  <span class="status-badge" data-priority="<%= priority %>">
@@ -14,29 +14,30 @@
14
14
  <span class="status-badge" data-status="<%= status %>">
15
15
  <%= status %>
16
16
  </span>
17
- </header>
17
+ </div>
18
+ <div class="card-body">
19
+ <% if (phase) { %>
20
+ <p><strong>Phase:</strong> <%= phase %></p>
21
+ <% } %>
22
+ <% if (created) { %>
23
+ <p><strong>Created:</strong> <%= created %></p>
24
+ <% } %>
18
25
 
19
- <% if (phase) { %>
20
- <p><strong>Phase:</strong> <%= phase %></p>
21
- <% } %>
22
- <% if (created) { %>
23
- <p><strong>Created:</strong> <%= created %></p>
24
- <% } %>
26
+ <hr>
25
27
 
26
- <hr>
28
+ <%- html %>
27
29
 
28
- <%- html %>
29
-
30
- <% if (status === 'pending') { %>
31
- <hr>
32
- <form method="POST" action="/todos/<%= id %>/done"
33
- hx-post="/todos/<%= id %>/done"
34
- hx-target="closest article"
35
- hx-swap="outerHTML"
36
- hx-indicator="#done-spinner"
37
- style="display:inline;">
38
- <button type="submit" class="secondary">Mark as Done</button>
39
- <span id="done-spinner" class="htmx-indicator" aria-busy="true"></span>
40
- </form>
41
- <% } %>
42
- </article>
30
+ <% if (status === 'pending') { %>
31
+ <hr>
32
+ <form method="POST" action="/todos/<%= id %>/done"
33
+ hx-post="/todos/<%= id %>/done"
34
+ hx-target="closest .card"
35
+ hx-swap="outerHTML"
36
+ hx-indicator="#done-spinner"
37
+ style="display:inline;">
38
+ <button type="submit" class="btn btn-outline-secondary">Mark as Done</button>
39
+ <span id="done-spinner" class="spinner-border spinner-border-sm htmx-indicator" role="status"></span>
40
+ </form>
41
+ <% } %>
42
+ </div>
43
+ </div>
@@ -2,7 +2,7 @@
2
2
  <h1>Todos</h1>
3
3
 
4
4
  <p>
5
- <a href="/todos/new" role="button"
5
+ <a href="/todos/new" class="btn btn-primary"
6
6
  hx-get="/todos/new"
7
7
  hx-target="#main-content"
8
8
  hx-push-url="true">Create Todo</a>
@@ -14,12 +14,12 @@
14
14
  </p>
15
15
 
16
16
  <% const f = typeof filters !== 'undefined' ? filters : { priority: '', status: '', q: '' }; %>
17
- <article>
18
- <form hx-get="/todos" hx-target="#main-content" hx-push-url="true">
19
- <div class="grid">
20
- <label>
21
- Priority
22
- <select name="priority" onchange="this.form.requestSubmit()">
17
+ <form hx-get="/todos" hx-target="#main-content" hx-push-url="true" class="card mb-3">
18
+ <div class="card-body">
19
+ <div class="row g-2">
20
+ <div class="col-auto">
21
+ <label class="form-label">Priority</label>
22
+ <select name="priority" class="form-select" onchange="this.form.requestSubmit()">
23
23
  <option value="">All</option>
24
24
  <option value="high"<%= f.priority === 'high' ? ' selected' : '' %>>High</option>
25
25
  <option value="medium"<%= f.priority === 'medium' ? ' selected' : '' %>>Medium</option>
@@ -29,27 +29,28 @@
29
29
  <option value="P2"<%= f.priority === 'P2' ? ' selected' : '' %>>P2</option>
30
30
  <option value="PX"<%= f.priority === 'PX' ? ' selected' : '' %>>PX</option>
31
31
  </select>
32
- </label>
33
- <label>
34
- Status
35
- <select name="status" onchange="this.form.requestSubmit()">
32
+ </div>
33
+ <div class="col-auto">
34
+ <label class="form-label">Status</label>
35
+ <select name="status" class="form-select" onchange="this.form.requestSubmit()">
36
36
  <option value="">All</option>
37
37
  <option value="pending"<%= f.status === 'pending' ? ' selected' : '' %>>pending</option>
38
38
  </select>
39
- </label>
40
- <label>
41
- Search
39
+ </div>
40
+ <div class="col-auto">
41
+ <label class="form-label">Search</label>
42
42
  <input type="search" name="q" value="<%= f.q %>" placeholder="Search titles..."
43
+ class="form-control"
43
44
  hx-get="/todos" hx-target="#main-content" hx-push-url="true"
44
45
  hx-trigger="keyup changed delay:300ms, search" hx-include="closest form">
45
- </label>
46
+ </div>
46
47
  </div>
47
- </form>
48
- </article>
48
+ </div>
49
+ </form>
49
50
 
50
51
  <% if (todos.length > 0) { %>
51
52
  <p>
52
- <button class="secondary"
53
+ <button class="btn btn-outline-secondary"
53
54
  hx-post="/todos/bulk-complete?priority=<%= encodeURIComponent(f.priority) %>&status=<%= encodeURIComponent(f.status) %>&q=<%= encodeURIComponent(f.q) %>"
54
55
  hx-target="#main-content"
55
56
  hx-swap="outerHTML"
@@ -57,51 +58,53 @@
57
58
  hx-confirm="Complete all <%= todos.length %> visible todo(s)?">
58
59
  Complete All Visible (<%= todos.length %>)
59
60
  </button>
60
- <span id="bulk-spinner" class="htmx-indicator" aria-busy="true"></span>
61
+ <span id="bulk-spinner" class="spinner-border spinner-border-sm htmx-indicator" role="status"></span>
61
62
  </p>
62
- <article>
63
- <div class="table-wrap">
64
- <table>
65
- <thead>
66
- <tr>
67
- <th scope="col">ID</th>
68
- <th scope="col">Title</th>
69
- <th scope="col">Priority</th>
70
- <th scope="col">Phase</th>
71
- <th scope="col">Status</th>
72
- <th scope="col">Created</th>
73
- </tr>
74
- </thead>
75
- <tbody>
76
- <% todos.forEach(function(todo) { %>
77
- <tr>
78
- <td><%= todo.id %></td>
79
- <td>
80
- <a href="/todos/<%= todo.id %>"
81
- hx-get="/todos/<%= todo.id %>"
82
- hx-target="#main-content"
83
- hx-push-url="true">
84
- <%= todo.title %>
85
- </a>
86
- </td>
87
- <td>
88
- <span class="status-badge" data-priority="<%= todo.priority %>">
89
- <%= todo.priority %>
90
- </span>
91
- </td>
92
- <td><%= todo.phase || '—' %></td>
93
- <td>
94
- <span class="status-badge" data-status="<%= todo.status %>">
95
- <%= todo.status %>
96
- </span>
97
- </td>
98
- <td><%= todo.created ? new Date(todo.created).toISOString().slice(0, 10) : '—' %></td>
99
- </tr>
100
- <% }); %>
101
- </tbody>
102
- </table>
63
+ <div class="card">
64
+ <div class="card-body p-0">
65
+ <div class="table-responsive">
66
+ <table class="table table-vcenter table-hover card-table">
67
+ <thead>
68
+ <tr>
69
+ <th scope="col">ID</th>
70
+ <th scope="col">Title</th>
71
+ <th scope="col">Priority</th>
72
+ <th scope="col">Phase</th>
73
+ <th scope="col">Status</th>
74
+ <th scope="col">Created</th>
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ <% todos.forEach(function(todo) { %>
79
+ <tr>
80
+ <td><%= todo.id %></td>
81
+ <td>
82
+ <a href="/todos/<%= todo.id %>"
83
+ hx-get="/todos/<%= todo.id %>"
84
+ hx-target="#main-content"
85
+ hx-push-url="true">
86
+ <%= todo.title %>
87
+ </a>
88
+ </td>
89
+ <td>
90
+ <span class="status-badge" data-priority="<%= todo.priority %>">
91
+ <%= todo.priority %>
92
+ </span>
93
+ </td>
94
+ <td><%= todo.phase || '—' %></td>
95
+ <td>
96
+ <span class="status-badge" data-status="<%= todo.status %>">
97
+ <%= todo.status %>
98
+ </span>
99
+ </td>
100
+ <td><%= todo.created ? new Date(todo.created).toISOString().slice(0, 10) : '—' %></td>
101
+ </tr>
102
+ <% }); %>
103
+ </tbody>
104
+ </table>
105
+ </div>
103
106
  </div>
104
- </article>
107
+ </div>
105
108
  <% } else { %>
106
- <%- include('empty-state', { icon: '📋', title: 'No todos found', action: '<p><a href="/todos/new" role="button" hx-get="/todos/new" hx-target="#main-content" hx-push-url="true">Create Todo</a></p>' }) %>
109
+ <%- include('empty-state', { icon: '📋', title: 'No todos found', action: '<p><a href="/todos/new" class="btn btn-primary" hx-get="/todos/new" hx-target="#main-content" hx-push-url="true">Create Todo</a></p>' }) %>
107
110
  <% } %>
@@ -7,38 +7,40 @@
7
7
  hx-push-url="true">Back to Pending Todos</a></p>
8
8
 
9
9
  <% if (todos.length > 0) { %>
10
- <article>
11
- <div class="table-wrap">
12
- <table>
13
- <thead>
14
- <tr>
15
- <th scope="col">ID</th>
16
- <th scope="col">Title</th>
17
- <th scope="col">Priority</th>
18
- <th scope="col">Phase</th>
19
- <th scope="col">Completed</th>
20
- </tr>
21
- </thead>
22
- <tbody>
23
- <% todos.forEach(function(todo) { %>
24
- <tr>
25
- <td><%= todo.id %></td>
26
- <td><%= todo.title %></td>
27
- <td>
28
- <% if (todo.priority) { %>
29
- <span class="status-badge" data-priority="<%= todo.priority %>">
30
- <%= todo.priority %>
31
- </span>
32
- <% } %>
33
- </td>
34
- <td><%= todo.phase %></td>
35
- <td><%= todo.completedAt || '' %></td>
36
- </tr>
37
- <% }); %>
38
- </tbody>
39
- </table>
10
+ <div class="card">
11
+ <div class="card-body p-0">
12
+ <div class="table-responsive">
13
+ <table class="table table-vcenter card-table">
14
+ <thead>
15
+ <tr>
16
+ <th scope="col">ID</th>
17
+ <th scope="col">Title</th>
18
+ <th scope="col">Priority</th>
19
+ <th scope="col">Phase</th>
20
+ <th scope="col">Completed</th>
21
+ </tr>
22
+ </thead>
23
+ <tbody>
24
+ <% todos.forEach(function(todo) { %>
25
+ <tr>
26
+ <td><%= todo.id %></td>
27
+ <td><%= todo.title %></td>
28
+ <td>
29
+ <% if (todo.priority) { %>
30
+ <span class="status-badge" data-priority="<%= todo.priority %>">
31
+ <%= todo.priority %>
32
+ </span>
33
+ <% } %>
34
+ </td>
35
+ <td><%= todo.phase %></td>
36
+ <td><%= todo.completedAt || '' %></td>
37
+ </tr>
38
+ <% }); %>
39
+ </tbody>
40
+ </table>
41
+ </div>
40
42
  </div>
41
- </article>
43
+ </div>
42
44
  <% } else { %>
43
45
  <%- include('empty-state', { icon: '✓', title: 'No completed todos', action: '<p><a href="/todos" hx-get="/todos" hx-target="#main-content" hx-push-url="true">View pending todos</a></p>' }) %>
44
46
  <% } %>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sienklogic/plan-build-run",
3
- "version": "2.27.0",
3
+ "version": "2.27.1",
4
4
  "description": "Plan it, Build it, Run it — structured development workflow for Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.27.0",
4
+ "version": "2.27.1",
5
5
  "description": "Plan-Build-Run — Structured development workflow for GitHub Copilot CLI. Solves context rot through disciplined agent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.27.0",
4
+ "version": "2.27.1",
5
5
  "description": "Plan-Build-Run — Structured development workflow for Cursor. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbr",
3
- "version": "2.27.0",
3
+ "version": "2.27.1",
4
4
  "description": "Plan-Build-Run — Structured development workflow for Claude Code. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
5
5
  "author": {
6
6
  "name": "SienkLogic",
@@ -29,6 +29,15 @@ function logMetric(planningDir, entry) {
29
29
  const logFile = path.join(logsDir, 'local-llm-metrics.jsonl');
30
30
 
31
31
  fs.mkdirSync(logsDir, { recursive: true });
32
+
33
+ // Update lifetime totals BEFORE appending to JSONL so seeding
34
+ // (which reads the JSONL) doesn't double-count this entry
35
+ try {
36
+ updateLifetimeTotals(logsDir, entry);
37
+ } catch (_) {
38
+ // Totals update failure is non-fatal
39
+ }
40
+
32
41
  fs.appendFileSync(logFile, JSON.stringify(entry) + '\n', 'utf8');
33
42
 
34
43
  // Rotate if over MAX_ENTRIES
@@ -47,6 +56,63 @@ function logMetric(planningDir, entry) {
47
56
  }
48
57
  }
49
58
 
59
+ /**
60
+ * Atomically increments the lifetime-totals.json running counters.
61
+ * This file persists across JSONL log rotations so lifetime metrics never plateau.
62
+ *
63
+ * @param {string} logsDir - path to the .planning/logs directory
64
+ * @param {object} entry - the metric entry being logged
65
+ */
66
+ function updateLifetimeTotals(logsDir, entry) {
67
+ const totalsFile = path.join(logsDir, 'lifetime-totals.json');
68
+ let totals = null;
69
+
70
+ try {
71
+ totals = JSON.parse(fs.readFileSync(totalsFile, 'utf8'));
72
+ } catch (_) {
73
+ // File doesn't exist yet or is corrupt — seed from existing JSONL
74
+ totals = seedTotalsFromJsonl(logsDir);
75
+ }
76
+
77
+ totals.total_calls = (totals.total_calls || 0) + 1;
78
+ totals.fallback_count = (totals.fallback_count || 0) + (entry.fallback_used ? 1 : 0);
79
+ totals.tokens_saved = (totals.tokens_saved || 0) + (entry.tokens_saved_frontier || 0);
80
+ totals.total_latency_ms = (totals.total_latency_ms || 0) + (entry.latency_ms || 0);
81
+
82
+ fs.writeFileSync(totalsFile, JSON.stringify(totals) + '\n', 'utf8');
83
+ }
84
+
85
+ /**
86
+ * Seeds lifetime totals by scanning the existing JSONL log.
87
+ * Called once when lifetime-totals.json doesn't exist yet (migration from pre-totals installs).
88
+ * Returns the accumulated totals from whatever entries remain in the JSONL.
89
+ *
90
+ * @param {string} logsDir - path to the .planning/logs directory
91
+ * @returns {{ total_calls: number, fallback_count: number, tokens_saved: number, total_latency_ms: number }}
92
+ */
93
+ function seedTotalsFromJsonl(logsDir) {
94
+ const seed = { total_calls: 0, fallback_count: 0, tokens_saved: 0, total_latency_ms: 0 };
95
+ try {
96
+ const logFile = path.join(logsDir, 'local-llm-metrics.jsonl');
97
+ const contents = fs.readFileSync(logFile, 'utf8');
98
+ const lines = contents.split(/\r?\n/).filter((l) => l.trim() !== '');
99
+ for (const line of lines) {
100
+ try {
101
+ const e = JSON.parse(line);
102
+ seed.total_calls += 1;
103
+ seed.fallback_count += e.fallback_used ? 1 : 0;
104
+ seed.tokens_saved += e.tokens_saved_frontier || 0;
105
+ seed.total_latency_ms += e.latency_ms || 0;
106
+ } catch (_) {
107
+ // Skip malformed lines
108
+ }
109
+ }
110
+ } catch (_) {
111
+ // No JSONL file — start at zero
112
+ }
113
+ return seed;
114
+ }
115
+
50
116
  /**
51
117
  * Reads metric entries from the JSONL log that occurred at or after sessionStartTime.
52
118
  *
@@ -131,47 +197,69 @@ function computeLifetimeMetrics(planningDir, frontierTokenRate) {
131
197
  };
132
198
 
133
199
  try {
134
- const logFile = path.join(planningDir, 'logs', 'local-llm-metrics.jsonl');
135
- let contents;
200
+ const rate = frontierTokenRate != null ? frontierTokenRate : 3.0;
201
+ const logsDir = path.join(planningDir, 'logs');
202
+ const totalsFile = path.join(logsDir, 'lifetime-totals.json');
203
+
204
+ // Primary path: read from lifetime-totals.json (survives log rotation)
205
+ let totals = null;
136
206
  try {
137
- contents = fs.readFileSync(logFile, 'utf8');
207
+ totals = JSON.parse(fs.readFileSync(totalsFile, 'utf8'));
138
208
  } catch (_) {
139
- return zero;
209
+ // No totals file — fall back to JSONL scan (migration path for existing installs)
140
210
  }
141
211
 
142
- const entries = contents
143
- .split(/\r?\n/)
144
- .filter((l) => l.trim() !== '')
145
- .map((l) => {
146
- try {
147
- return JSON.parse(l);
148
- } catch (_) {
149
- return null;
150
- }
151
- })
152
- .filter((e) => e !== null);
153
-
154
- if (entries.length === 0) return zero;
212
+ // Build by_operation from the JSONL (only covers recent entries, but still useful)
213
+ const by_operation = {};
214
+ const logFile = path.join(logsDir, 'local-llm-metrics.jsonl');
215
+ try {
216
+ const contents = fs.readFileSync(logFile, 'utf8');
217
+ const entries = contents
218
+ .split(/\r?\n/)
219
+ .filter((l) => l.trim() !== '')
220
+ .map((l) => {
221
+ try {
222
+ return JSON.parse(l);
223
+ } catch (_) {
224
+ return null;
225
+ }
226
+ })
227
+ .filter((e) => e !== null);
155
228
 
156
- const rate = frontierTokenRate != null ? frontierTokenRate : 3.0;
157
- const total_calls = entries.length;
158
- const fallback_count = entries.filter((e) => e.fallback_used).length;
159
- const totalLatency = entries.reduce((sum, e) => sum + (e.latency_ms || 0), 0);
160
- const avg_latency_ms = total_calls > 0 ? totalLatency / total_calls : 0;
161
- const tokens_saved = entries.reduce((sum, e) => sum + (e.tokens_saved_frontier || 0), 0);
162
- const cost_saved_usd = tokens_saved * (rate / 1_000_000);
229
+ for (const e of entries) {
230
+ const op = e.operation || 'unknown';
231
+ if (!by_operation[op]) {
232
+ by_operation[op] = { calls: 0, fallbacks: 0, tokens_saved: 0 };
233
+ }
234
+ by_operation[op].calls += 1;
235
+ if (e.fallback_used) by_operation[op].fallbacks += 1;
236
+ by_operation[op].tokens_saved += e.tokens_saved_frontier || 0;
237
+ }
163
238
 
164
- const by_operation = {};
165
- for (const e of entries) {
166
- const op = e.operation || 'unknown';
167
- if (!by_operation[op]) {
168
- by_operation[op] = { calls: 0, fallbacks: 0, tokens_saved: 0 };
239
+ // If no totals file, compute from JSONL (legacy/migration path)
240
+ if (!totals) {
241
+ if (entries.length === 0) return zero;
242
+ const total_calls = entries.length;
243
+ const fallback_count = entries.filter((e) => e.fallback_used).length;
244
+ const totalLatency = entries.reduce((sum, e) => sum + (e.latency_ms || 0), 0);
245
+ const avg_latency_ms = total_calls > 0 ? totalLatency / total_calls : 0;
246
+ const tokens_saved = entries.reduce((sum, e) => sum + (e.tokens_saved_frontier || 0), 0);
247
+ const cost_saved_usd = tokens_saved * (rate / 1_000_000);
248
+ return { total_calls, fallback_count, avg_latency_ms, tokens_saved, cost_saved_usd, by_operation };
169
249
  }
170
- by_operation[op].calls += 1;
171
- if (e.fallback_used) by_operation[op].fallbacks += 1;
172
- by_operation[op].tokens_saved += e.tokens_saved_frontier || 0;
250
+ } catch (_) {
251
+ // No JSONL file — if we have totals, continue; otherwise return zero
252
+ if (!totals) return zero;
173
253
  }
174
254
 
255
+ // Use lifetime totals for the headline numbers
256
+ const total_calls = totals.total_calls || 0;
257
+ const fallback_count = totals.fallback_count || 0;
258
+ const tokens_saved = totals.tokens_saved || 0;
259
+ const total_latency_ms = totals.total_latency_ms || 0;
260
+ const avg_latency_ms = total_calls > 0 ? total_latency_ms / total_calls : 0;
261
+ const cost_saved_usd = tokens_saved * (rate / 1_000_000);
262
+
175
263
  return { total_calls, fallback_count, avg_latency_ms, tokens_saved, cost_saved_usd, by_operation };
176
264
  } catch (_) {
177
265
  return zero;
@@ -249,4 +337,4 @@ function logAgreement(planningDir, entry) {
249
337
  }
250
338
  }
251
339
 
252
- module.exports = { logMetric, readSessionMetrics, summarizeMetrics, computeLifetimeMetrics, formatSessionSummary, logAgreement };
340
+ module.exports = { logMetric, readSessionMetrics, summarizeMetrics, computeLifetimeMetrics, formatSessionSummary, logAgreement, updateLifetimeTotals, seedTotalsFromJsonl };