thumbgate 1.5.0 → 1.5.2

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.
@@ -45,6 +45,23 @@ const CLAUDE_HOOKS = {
45
45
  },
46
46
  };
47
47
 
48
+ const CODEX_HOOKS = {
49
+ PreToolUse: {
50
+ matcher: 'Bash',
51
+ hooks: [{ type: 'command', command: preToolHookCommand() }],
52
+ },
53
+ UserPromptSubmit: {
54
+ hooks: [{ type: 'command', command: userPromptHookCommand() }],
55
+ },
56
+ PostToolUse: {
57
+ matcher: 'mcp__thumbgate__feedback_stats|mcp__thumbgate__dashboard',
58
+ hooks: [{ type: 'command', command: cacheUpdateHookCommand() }],
59
+ },
60
+ SessionStart: {
61
+ hooks: [{ type: 'command', command: sessionStartHookCommand() }],
62
+ },
63
+ };
64
+
48
65
  // --- Agent detection ---
49
66
 
50
67
  function detectAgent(flagAgent) {
@@ -338,49 +355,82 @@ function codexConfigPath() {
338
355
  return path.join(getHome(), '.codex', 'config.json');
339
356
  }
340
357
 
358
+ function writeJsonFile(filePath, payload, dryRun) {
359
+ if (dryRun) {
360
+ return;
361
+ }
362
+
363
+ const dir = path.dirname(filePath);
364
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
365
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n');
366
+ }
367
+
368
+ function upsertCodexHook(configHooks, lifecycle, hookDef, legacyPattern) {
369
+ const hookCommand = hookDef.hooks[0].command;
370
+ const pruned = pruneLegacyHookEntries(configHooks[lifecycle], hookCommand, legacyPattern);
371
+ configHooks[lifecycle] = pruned.hooks;
372
+
373
+ const added = [];
374
+ if (pruned.removed) {
375
+ added.push({ lifecycle, command: `${hookCommand} (replaced legacy ThumbGate hook)` });
376
+ }
377
+
378
+ if (hookAlreadyPresent(configHooks[lifecycle], hookCommand)) {
379
+ return added;
380
+ }
381
+
382
+ const entry = { hooks: hookDef.hooks };
383
+ if (hookDef.matcher) {
384
+ entry.matcher = hookDef.matcher;
385
+ }
386
+
387
+ configHooks[lifecycle] = configHooks[lifecycle] || [];
388
+ configHooks[lifecycle].push(entry);
389
+ added.push({ lifecycle, command: hookCommand });
390
+ return added;
391
+ }
392
+
393
+ function syncCodexStatusLine(config, desiredStatusLine) {
394
+ if (config.statusLine && config.statusLine.command === desiredStatusLine) {
395
+ return false;
396
+ }
397
+
398
+ config.statusLine = { type: 'command', command: desiredStatusLine };
399
+ return true;
400
+ }
401
+
341
402
  function wireCodexHooks(options) {
342
403
  const configPath = options.settingsPath || codexConfigPath();
343
404
  const dryRun = options.dryRun || false;
405
+ const desiredStatusLine = statuslineCommand();
344
406
 
345
407
  let config = loadJsonFile(configPath) || {};
346
408
  config.hooks = config.hooks || {};
347
409
 
348
410
  const added = [];
349
- const preToolCmd = preToolHookCommand();
350
- const userPromptCmd = userPromptHookCommand();
351
-
352
- const preToolPruned = pruneLegacyHookEntries(config.hooks.PreToolUse, preToolCmd, /(generate-pretool-hook\.sh|\bgate-check\b)/);
353
- config.hooks.PreToolUse = preToolPruned.hooks;
354
- const userPromptPruned = pruneLegacyHookEntries(config.hooks.UserPromptSubmit, userPromptCmd, /(hook-auto-capture\.sh|hook-auto-capture\b)/);
355
- config.hooks.UserPromptSubmit = userPromptPruned.hooks;
356
-
357
- if (!hookAlreadyPresent(config.hooks.PreToolUse, preToolCmd)) {
358
- config.hooks.PreToolUse = config.hooks.PreToolUse || [];
359
- config.hooks.PreToolUse.push({
360
- matcher: 'Bash',
361
- hooks: [{ type: 'command', command: preToolCmd }],
362
- });
363
- added.push({ lifecycle: 'PreToolUse', command: preToolCmd });
364
- }
411
+ const legacyPatterns = {
412
+ PreToolUse: /(generate-pretool-hook\.sh|\bgate-check\b)/,
413
+ UserPromptSubmit: /(hook-auto-capture\.sh|hook-auto-capture\b)/,
414
+ PostToolUse: /(hook-thumbgate-cache-updater|cache-update\b)/,
415
+ SessionStart: /(thumbgate_session_start\.sh|session-start\b)/,
416
+ };
365
417
 
366
- if (!hookAlreadyPresent(config.hooks.UserPromptSubmit, userPromptCmd)) {
367
- config.hooks.UserPromptSubmit = config.hooks.UserPromptSubmit || [];
368
- config.hooks.UserPromptSubmit.push({
369
- hooks: [{ type: 'command', command: userPromptCmd }],
370
- });
371
- added.push({ lifecycle: 'UserPromptSubmit', command: userPromptCmd });
418
+ for (const [lifecycle, hookDef] of Object.entries(CODEX_HOOKS)) {
419
+ added.push(...upsertCodexHook(config.hooks, lifecycle, hookDef, legacyPatterns[lifecycle]));
372
420
  }
373
421
 
374
422
  if (added.length === 0) {
423
+ if (syncCodexStatusLine(config, desiredStatusLine)) {
424
+ writeJsonFile(configPath, config, dryRun);
425
+ return { changed: true, settingsPath: configPath, added: [{ lifecycle: 'statusLine', command: desiredStatusLine }] };
426
+ }
375
427
  return { changed: false, settingsPath: configPath, added: [] };
376
428
  }
377
429
 
378
- if (!dryRun) {
379
- const dir = path.dirname(configPath);
380
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
381
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
382
- }
430
+ syncCodexStatusLine(config, desiredStatusLine);
431
+ writeJsonFile(configPath, config, dryRun);
383
432
 
433
+ added.push({ lifecycle: 'statusLine', command: desiredStatusLine });
384
434
  return { changed: true, settingsPath: configPath, added };
385
435
  }
386
436
 
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * bot-detection.js — Cheap heuristic to detect crawlers, link-preview
5
+ * fetchers, and LLM scrapers hitting the checkout endpoint.
6
+ *
7
+ * Why this matters:
8
+ * GET /checkout/pro immediately creates a live Stripe Checkout session
9
+ * and 302s to Stripe. Every bot that follows that redirect spawns a
10
+ * session that will never complete. Result: the funnel shows "100+
11
+ * sessions opened, 0 completed" — which looks like product failure but
12
+ * is actually bot noise.
13
+ *
14
+ * The fix: if the requester is probably a bot, serve an HTML interstitial
15
+ * that does NOT create a Stripe session. A real user on an interstitial
16
+ * page clicks through (or the page JS-redirects them). A bot sees HTML
17
+ * and moves on without dirtying the funnel.
18
+ *
19
+ * Heuristics (all cheap string checks):
20
+ * 1. User-Agent matches a known bot/crawler/preview pattern.
21
+ * 2. User-Agent is missing entirely (raw curl/Node fetch defaults).
22
+ * 3. Accept header lacks text/html (most browsers send it; bots don't).
23
+ * 4. Purpose / Sec-Purpose = 'prefetch' (Chrome's link prefetch).
24
+ * 5. Sec-Fetch-Mode indicates preflight/prefetch rather than navigate.
25
+ *
26
+ * We intentionally err on the side of classifying ambiguous traffic as
27
+ * "bot" — the downside (user sees an extra click) is tiny compared to
28
+ * the upside (clean conversion data).
29
+ */
30
+
31
+ const BOT_PATTERNS = [
32
+ // Search engine crawlers
33
+ /\bgooglebot\b/i,
34
+ /\bbingbot\b/i,
35
+ /\byandex(?:bot|images)\b/i,
36
+ /\bbaiduspider\b/i,
37
+ /\bduckduckbot\b/i,
38
+ /\bapplebot\b/i,
39
+ // LLM / AI crawlers — these started exploding in 2024+
40
+ /\bgptbot\b/i,
41
+ /\bchatgpt-user\b/i,
42
+ /\boai-searchbot\b/i,
43
+ /\bperplexitybot\b/i,
44
+ /\banthropic-ai\b/i,
45
+ /\bclaude(?:bot|-web)\b/i,
46
+ /\bccbot\b/i,
47
+ /\bcohere-ai\b/i,
48
+ /\bbytespider\b/i,
49
+ /\bmeta-externalagent\b/i,
50
+ /\bimagesiftbot\b/i,
51
+ /\bdiffbot\b/i,
52
+ // Link-preview fetchers
53
+ /\bfacebookexternalhit\b/i,
54
+ /\bfacebot\b/i,
55
+ /\blinkedinbot\b/i,
56
+ /\btwitterbot\b/i,
57
+ /\bslackbot(?:-linkexpanding)?\b/i,
58
+ /\btelegrambot\b/i,
59
+ /\bwhatsapp\b/i,
60
+ /\bdiscordbot\b/i,
61
+ /\bskypeuripreview\b/i,
62
+ /\bpinterest(?:bot|\/)/i,
63
+ /\bredditbot\b/i,
64
+ /\bembedly\b/i,
65
+ /\biframely\b/i,
66
+ // Generic bot/crawler/spider markers
67
+ /\bbot\b/i,
68
+ /\bcrawler\b/i,
69
+ /\bcrawl\b/i,
70
+ /\bspider\b/i,
71
+ /\brobot\b/i,
72
+ /\bheadless(?:chrome|browser)?\b/i,
73
+ /\bphantomjs\b/i,
74
+ /\bpuppeteer\b/i,
75
+ /\bplaywright\b/i,
76
+ /\bselenium\b/i,
77
+ // HTTP clients (not browsers)
78
+ /\bcurl\//i,
79
+ /\bwget\//i,
80
+ /\bnode-fetch\b/i,
81
+ /\bgot\s*\(/i,
82
+ /\bpython-requests\b/i,
83
+ /\bpython-urllib\b/i,
84
+ /\baxios\b/i,
85
+ /\bokhttp\b/i,
86
+ /\blibwww-perl\b/i,
87
+ /\bjava\//i,
88
+ /\bgo-http-client\b/i,
89
+ /\bruby\b/i,
90
+ // API/test tools
91
+ /\bpostman(?:runtime)?\b/i,
92
+ /\binsomnia\b/i,
93
+ /\bhttpie\b/i,
94
+ // Uptime/monitoring/security scanners
95
+ /\buptimerobot\b/i,
96
+ /\bbetteruptime\b/i,
97
+ /\bpingdom\b/i,
98
+ /\bstatuscake\b/i,
99
+ /\bnewrelic\b/i,
100
+ /\bdatadog\b/i,
101
+ /\bahrefs\b/i,
102
+ /\bsemrush\b/i,
103
+ /\bmj12bot\b/i,
104
+ /\bdotbot\b/i,
105
+ /\bsocket(?:bot|-io)\b/i,
106
+ /\bgitguardian\b/i,
107
+ /\bsnyk\b/i,
108
+ // Perf/audit
109
+ /\blighthouse\b/i,
110
+ /\bspeedcurve\b/i,
111
+ /\bpagespeed\b/i,
112
+ ];
113
+
114
+ function normalizeHeader(value) {
115
+ if (Array.isArray(value)) return value.join(',');
116
+ return typeof value === 'string' ? value : '';
117
+ }
118
+
119
+ /**
120
+ * @param {import('http').IncomingHttpHeaders | Record<string,string>} headers
121
+ * @returns {{ isBot: boolean, reason: string | null }}
122
+ */
123
+ function classifyRequester(headers = {}) {
124
+ const ua = normalizeHeader(headers['user-agent'] || headers['User-Agent']).trim();
125
+ if (!ua) {
126
+ return { isBot: true, reason: 'missing_user_agent' };
127
+ }
128
+ for (const pattern of BOT_PATTERNS) {
129
+ if (pattern.test(ua)) {
130
+ return { isBot: true, reason: `ua_match:${pattern.source}` };
131
+ }
132
+ }
133
+
134
+ const purpose = normalizeHeader(headers.purpose || headers['sec-purpose']).toLowerCase();
135
+ if (purpose.includes('prefetch')) {
136
+ return { isBot: true, reason: 'prefetch_purpose' };
137
+ }
138
+
139
+ const secFetchMode = normalizeHeader(headers['sec-fetch-mode']).toLowerCase();
140
+ if (secFetchMode && secFetchMode !== 'navigate' && secFetchMode !== 'cors' && secFetchMode !== 'same-origin') {
141
+ return { isBot: true, reason: `sec_fetch_mode:${secFetchMode}` };
142
+ }
143
+
144
+ const accept = normalizeHeader(headers.accept || headers.Accept).toLowerCase();
145
+ // Real browsers navigating to a page send an Accept header that includes
146
+ // text/html. Bots frequently send */* or application/json or nothing.
147
+ if (accept && !accept.includes('text/html') && !accept.includes('*/*')) {
148
+ return { isBot: true, reason: 'accept_no_html' };
149
+ }
150
+ if (!accept) {
151
+ return { isBot: true, reason: 'missing_accept' };
152
+ }
153
+
154
+ return { isBot: false, reason: null };
155
+ }
156
+
157
+ function isProbablyBot(headers) {
158
+ return classifyRequester(headers).isBot;
159
+ }
160
+
161
+ module.exports = {
162
+ classifyRequester,
163
+ isProbablyBot,
164
+ BOT_PATTERNS,
165
+ };
@@ -78,8 +78,12 @@ function formatCliOutput(result) {
78
78
  // Header
79
79
  if (result.feedbackResult && result.feedbackResult.accepted !== false) {
80
80
  lines.push(`${isDown ? R : G}${BD}${isDown ? '👎 Thumbs down recorded' : '👍 Thumbs up recorded'}${RST}`);
81
- if (result.feedbackResult.id) {
82
- lines.push(`${D} ID: ${result.feedbackResult.id}${RST}`);
81
+ const feedbackId = (result.feedbackResult.feedbackEvent && result.feedbackResult.feedbackEvent.id) || result.feedbackResult.id;
82
+ if (feedbackId) {
83
+ lines.push(`${D} ID: ${feedbackId}${RST}`);
84
+ // Echo feedback ID to stderr so it's visible directly in the terminal,
85
+ // not hidden behind Claude Code's "ctrl+o to expand" MCP call collapse.
86
+ process.stderr.write(`✅ Feedback captured (${feedbackId})\n`);
83
87
  }
84
88
  } else {
85
89
  lines.push(`${R}Feedback not accepted: ${(result.feedbackResult && result.feedbackResult.reason) || 'unknown'}${RST}`);
@@ -5,16 +5,16 @@ const PRO_ANNUAL_PAYMENT_LINK = 'https://buy.stripe.com/3cI8wPfCYaPs2dzdKz3sI07'
5
5
 
6
6
  const PRO_MONTHLY_PRICE_ID = 'price_1THQY7GGBpd520QYHoS7RG0J';
7
7
  const PRO_ANNUAL_PRICE_ID = 'price_1THQZ7GGBpd520QYxzDRnxhB';
8
- const TEAM_MONTHLY_PRICE_ID = 'price_1TKLUhGGBpd520QYr5pgEZit';
8
+ const TEAM_MONTHLY_PRICE_ID = 'price_1TMIagGGBpd520QY1fUOawZt';
9
9
 
10
10
  const PRO_MONTHLY_PRICE_DOLLARS = 19;
11
11
  const PRO_ANNUAL_PRICE_DOLLARS = 149;
12
- const TEAM_MONTHLY_PRICE_DOLLARS = 99;
13
- const TEAM_ANNUAL_PRICE_DOLLARS = 1188;
12
+ const TEAM_MONTHLY_PRICE_DOLLARS = 49;
13
+ const TEAM_ANNUAL_PRICE_DOLLARS = 588;
14
14
  const TEAM_MIN_SEATS = 3;
15
15
 
16
16
  const PRO_PRICE_LABEL = '$19/mo or $149/yr (individual)';
17
- const TEAM_PRICE_LABEL = '$99/seat/mo — Agent governance for engineering teams';
17
+ const TEAM_PRICE_LABEL = '$49/seat/mo — Agent governance for engineering teams';
18
18
 
19
19
  function normalizePlanId(value) {
20
20
  const text = String(value || '').trim().toLowerCase();
@@ -262,6 +262,93 @@ function computePreventionImpact(feedbackDir, gateStats) {
262
262
  };
263
263
  }
264
264
 
265
+ // ---------------------------------------------------------------------------
266
+ // Feedback time series (daily up/down for charts)
267
+ // ---------------------------------------------------------------------------
268
+
269
+ function computeFeedbackTimeSeries(entries, dayCount = 30) {
270
+ const today = new Date();
271
+ today.setHours(0, 0, 0, 0);
272
+ const days = [];
273
+
274
+ for (let offset = dayCount - 1; offset >= 0; offset -= 1) {
275
+ const day = new Date(today);
276
+ day.setDate(today.getDate() - offset);
277
+ const dayKey = toLocalDayKey(day);
278
+ days.push({ dayKey, up: 0, down: 0, lessons: 0 });
279
+ }
280
+
281
+ const dayMap = new Map(days.map((d) => [d.dayKey, d]));
282
+
283
+ for (const entry of entries) {
284
+ if (!entry.timestamp) continue;
285
+ const dayKey = toLocalDayKey(entry.timestamp);
286
+ const bucket = dayMap.get(dayKey);
287
+ if (!bucket) continue;
288
+ const signal = String(entry.signal || entry.feedback || '').toLowerCase();
289
+ if (['up', 'positive', 'thumbs_up'].includes(signal)) bucket.up += 1;
290
+ else if (['down', 'negative', 'thumbs_down'].includes(signal)) bucket.down += 1;
291
+ }
292
+
293
+ return { dayCount, days };
294
+ }
295
+
296
+ function isAuditTrailEntry(entry) {
297
+ return Array.isArray(entry.tags) && entry.tags.includes('audit-trail');
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Lesson pipeline (feedback → lesson → gate conversion)
302
+ // ---------------------------------------------------------------------------
303
+
304
+ function computeLessonPipeline(feedbackDir, entries, gateStats) {
305
+ const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
306
+ const memories = readJSONL(memoryLogPath);
307
+
308
+ const totalFeedback = entries.length;
309
+ const totalNegative = entries.filter((e) => {
310
+ const s = String(e.signal || e.feedback || '').toLowerCase();
311
+ return ['down', 'negative', 'thumbs_down'].includes(s);
312
+ }).length;
313
+ const totalPositive = totalFeedback - totalNegative;
314
+
315
+ const totalLessons = memories.filter((m) => m.category === 'error' || m.category === 'learning').length;
316
+ const errorLessons = memories.filter((m) => m.category === 'error').length;
317
+ const learningLessons = memories.filter((m) => m.category === 'learning').length;
318
+
319
+ const autoGatesPath = getAutoGatesPath();
320
+ const autoGates = readJsonFile(autoGatesPath);
321
+ const promotedGates = autoGates && Array.isArray(autoGates.gates) ? autoGates.gates.length : 0;
322
+
323
+ const feedbackToLessonRate = totalFeedback > 0
324
+ ? Math.round((totalLessons / totalFeedback) * 100) : 0;
325
+ const lessonToGateRate = totalLessons > 0
326
+ ? Math.min(100, Math.round((promotedGates / totalLessons) * 100)) : 0;
327
+ const totalBlocked = gateStats.blocked || 0;
328
+
329
+ // Populate lesson counts onto the time series if available
330
+ const lessonsByDay = new Map();
331
+ for (const m of memories) {
332
+ if (!m.timestamp) continue;
333
+ const dayKey = toLocalDayKey(m.timestamp);
334
+ if (dayKey) lessonsByDay.set(dayKey, (lessonsByDay.get(dayKey) || 0) + 1);
335
+ }
336
+
337
+ return {
338
+ stages: [
339
+ { id: 'feedback', label: 'Feedback Signals', count: totalFeedback, detail: `${totalPositive} up / ${totalNegative} down` },
340
+ { id: 'lessons', label: 'Lessons Distilled', count: totalLessons, detail: `${errorLessons} mistakes / ${learningLessons} good patterns` },
341
+ { id: 'gates', label: 'Gates Promoted', count: promotedGates, detail: `${lessonToGateRate}% of lessons become gates` },
342
+ { id: 'blocked', label: 'Actions Blocked', count: totalBlocked, detail: `Repeat mistakes prevented` },
343
+ ],
344
+ rates: {
345
+ feedbackToLesson: feedbackToLessonRate,
346
+ lessonToGate: lessonToGateRate,
347
+ },
348
+ lessonsByDay,
349
+ };
350
+ }
351
+
265
352
  // ---------------------------------------------------------------------------
266
353
  // Session trend (last N sessions)
267
354
  // ---------------------------------------------------------------------------
@@ -761,11 +848,61 @@ function resolveTeamWindowHours(analyticsWindow) {
761
848
  // Full dashboard data
762
849
  // ---------------------------------------------------------------------------
763
850
 
851
+ function collectAllFeedbackEntries(feedbackDir) {
852
+ const entries = [];
853
+ const seen = new Set();
854
+
855
+ function mergeFrom(logPath) {
856
+ if (!fs.existsSync(logPath)) return;
857
+ for (const entry of readJSONL(logPath)) {
858
+ const id = entry.id || entry.feedbackId;
859
+ if (id && seen.has(id)) continue;
860
+ if (id) seen.add(id);
861
+ entries.push(entry);
862
+ }
863
+ }
864
+
865
+ // Primary: the passed feedbackDir (global ~/.thumbgate)
866
+ mergeFrom(path.join(feedbackDir, 'feedback-log.jsonl'));
867
+
868
+ // Project-local .thumbgate directories (e.g. repo/.thumbgate/feedback-log.jsonl)
869
+ // The MCP server may write to a project-scoped dir that differs from the global one.
870
+ const projectsDir = path.join(feedbackDir, 'projects');
871
+ if (fs.existsSync(projectsDir)) {
872
+ try {
873
+ for (const project of fs.readdirSync(projectsDir)) {
874
+ mergeFrom(path.join(projectsDir, project, 'feedback-log.jsonl'));
875
+ }
876
+ } catch { /* ignore read errors */ }
877
+ }
878
+
879
+ // Also check the project root's .thumbgate if feedbackDir is global
880
+ // The MCP server often resolves to PROJECT_ROOT/.thumbgate for project-scoped feedback
881
+ // Skip this merge when feedbackDir is a temp/test directory (not ~/.thumbgate)
882
+ const homeThumbgate = path.join(process.env.HOME || '/tmp', '.thumbgate');
883
+ const projectLocalDir = path.join(PROJECT_ROOT, '.thumbgate');
884
+ if (
885
+ path.resolve(feedbackDir) === path.resolve(homeThumbgate) &&
886
+ projectLocalDir !== feedbackDir &&
887
+ fs.existsSync(projectLocalDir)
888
+ ) {
889
+ mergeFrom(path.join(projectLocalDir, 'feedback-log.jsonl'));
890
+ }
891
+
892
+ // Sort by timestamp for consistent ordering
893
+ entries.sort((a, b) => {
894
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
895
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
896
+ return ta - tb;
897
+ });
898
+
899
+ return entries;
900
+ }
901
+
764
902
  function generateDashboard(feedbackDir, options = {}) {
765
903
  const analyticsWindow = resolveAnalyticsWindow(options.analyticsWindow || options);
766
- const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
767
904
  const diagnosticLogPath = path.join(feedbackDir, 'diagnostic-log.jsonl');
768
- const entries = readJSONL(feedbackLogPath);
905
+ const entries = collectAllFeedbackEntries(feedbackDir);
769
906
  const diagnosticEntries = readJSONL(diagnosticLogPath);
770
907
  const billingSummary = options.billingSummary || getBillingSummary(analyticsWindow);
771
908
 
@@ -831,6 +968,14 @@ function generateDashboard(feedbackDir, options = {}) {
831
968
  },
832
969
  };
833
970
 
971
+ const feedbackTimeSeries = computeFeedbackTimeSeries(entries, 30);
972
+ const lessonPipeline = computeLessonPipeline(feedbackDir, entries, gateStats);
973
+
974
+ // Merge lesson counts into feedbackTimeSeries days
975
+ for (const day of feedbackTimeSeries.days) {
976
+ day.lessons = lessonPipeline.lessonsByDay.get(day.dayKey) || 0;
977
+ }
978
+
834
979
  const team = generateOrgDashboard({
835
980
  windowHours: resolveTeamWindowHours(analyticsWindow),
836
981
  authContext: options.authContext,
@@ -872,6 +1017,11 @@ function generateDashboard(feedbackDir, options = {}) {
872
1017
  templateLibrary,
873
1018
  liveMetrics,
874
1019
  predictive,
1020
+ feedbackTimeSeries,
1021
+ lessonPipeline: {
1022
+ stages: lessonPipeline.stages,
1023
+ rates: lessonPipeline.rates,
1024
+ },
875
1025
  };
876
1026
  }
877
1027