thumbgate 1.5.0 → 1.5.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +230 -228
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -2
- package/adapters/mcp/server-stdio.js +34 -3
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +21 -8
- package/bin/postinstall.js +25 -17
- package/config/evals/agent-safety-eval.json +131 -0
- package/config/github-about.json +5 -2
- package/config/specs/agent-safety.json +79 -0
- package/package.json +44 -8
- package/public/compare.html +3 -3
- package/public/guide.html +2 -2
- package/public/index.html +230 -98
- package/scripts/auto-wire-hooks.js +77 -27
- package/scripts/bot-detection.js +165 -0
- package/scripts/cli-feedback.js +6 -2
- package/scripts/commercial-offer.js +4 -4
- package/scripts/dashboard.js +152 -2
- package/scripts/decision-trace.js +354 -0
- package/scripts/feedback-loop.js +4 -8
- package/scripts/rate-limiter.js +77 -24
- package/scripts/sales-pipeline.js +681 -0
- package/scripts/session-episode-store.js +329 -0
- package/scripts/session-health-sensor.js +242 -0
- package/scripts/spec-gate.js +362 -0
- package/scripts/statusline.sh +6 -9
- package/skills/thumbgate/SKILL.md +1 -1
- package/src/api/server.js +368 -12
|
@@ -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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
367
|
-
config.hooks
|
|
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
|
-
|
|
379
|
-
|
|
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
|
+
};
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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 = '
|
|
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 =
|
|
13
|
-
const TEAM_ANNUAL_PRICE_DOLLARS =
|
|
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 = '$
|
|
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();
|
package/scripts/dashboard.js
CHANGED
|
@@ -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 =
|
|
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
|
|