thumbgate 1.21.2 → 1.23.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.
- 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 +1 -0
- package/adapters/chatgpt/openapi.yaml +10 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +109 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +247 -30
- package/config/mcp-allowlists.json +12 -6
- package/openapi/openapi.yaml +10 -0
- package/package.json +29 -5
- package/public/agent-manager.html +1 -1
- package/public/agents-cost-savings.html +151 -0
- package/public/ai-malpractice-prevention.html +183 -0
- package/public/codex-enterprise.html +123 -0
- package/public/codex-plugin.html +1 -1
- package/public/dashboard.html +18 -5
- package/public/index.html +13 -6
- package/public/lessons.html +34 -0
- package/public/numbers.html +2 -2
- package/public/pricing.html +1 -1
- package/scripts/auto-wire-hooks.js +14 -0
- package/scripts/build-metadata.js +32 -13
- package/scripts/cli-telemetry.js +6 -1
- package/scripts/gate-stats.js +89 -0
- package/scripts/gates-engine.js +133 -6
- package/scripts/hook-runtime.js +9 -3
- package/scripts/meta-agent-loop.js +32 -0
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/rate-limiter.js +7 -1
- package/scripts/self-healing-check.js +193 -0
- package/scripts/silent-failure-cluster.js +512 -0
- package/scripts/telemetry-analytics.js +38 -0
- package/scripts/tool-registry.js +18 -0
- package/scripts/workflow-sentinel.js +6 -1
- package/src/api/server.js +311 -36
package/scripts/gates-engine.js
CHANGED
|
@@ -7,7 +7,7 @@ const crypto = require('crypto');
|
|
|
7
7
|
const { execSync, execFileSync } = require('child_process');
|
|
8
8
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
9
9
|
|
|
10
|
-
const { isProTier, FREE_TIER_MAX_GATES } = require('./rate-limiter');
|
|
10
|
+
const { isProTier, isInTrialPeriod, FREE_TIER_MAX_GATES, FREE_TIER_DAILY_BLOCKS, todayKey } = require('./rate-limiter');
|
|
11
11
|
const {
|
|
12
12
|
DEFAULT_BASE_BRANCH,
|
|
13
13
|
evaluateOperationalIntegrity,
|
|
@@ -439,6 +439,20 @@ function recordStat(gateId, action, gate) {
|
|
|
439
439
|
else if (action === 'warn') stats.byGate[gateId].warned += 1;
|
|
440
440
|
else if (action === 'approve') stats.byGate[gateId].pendingApproval = (stats.byGate[gateId].pendingApproval || 0) + 1;
|
|
441
441
|
else if (action === 'log') stats.byGate[gateId].logged = (stats.byGate[gateId].logged || 0) + 1;
|
|
442
|
+
|
|
443
|
+
// Track per-gate recurrence within a session for first-time fix rate
|
|
444
|
+
if (action === 'block' || action === 'warn') {
|
|
445
|
+
if (!stats.sessionFiredGates) stats.sessionFiredGates = {};
|
|
446
|
+
const sessionKey = `session_${Math.floor(Date.now() / SESSION_ACTION_TTL_MS)}`;
|
|
447
|
+
if (!stats.sessionFiredGates[sessionKey]) stats.sessionFiredGates[sessionKey] = {};
|
|
448
|
+
if (stats.sessionFiredGates[sessionKey][gateId]) {
|
|
449
|
+
// Same gate fired again in this session — it's a recurring block
|
|
450
|
+
stats.recurringBlocks = (stats.recurringBlocks || 0) + 1;
|
|
451
|
+
} else {
|
|
452
|
+
stats.sessionFiredGates[sessionKey][gateId] = true;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
442
456
|
saveStats(stats);
|
|
443
457
|
// Track lesson freshness when an auto-promoted gate fires
|
|
444
458
|
if (gate && gate.sourceLessonId) {
|
|
@@ -452,6 +466,69 @@ function recordStat(gateId, action, gate) {
|
|
|
452
466
|
}
|
|
453
467
|
}
|
|
454
468
|
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Free-tier daily block cap
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Count today's gate blocks from stats. Free tier gets FREE_TIER_DAILY_BLOCKS
|
|
475
|
+
* blocks/day. After the limit, deny → warn + upgrade CTA so the action proceeds
|
|
476
|
+
* but the user sees they lost protection.
|
|
477
|
+
*/
|
|
478
|
+
function getTodayBlockCount() {
|
|
479
|
+
const stats = loadStats();
|
|
480
|
+
const today = todayKey();
|
|
481
|
+
if (!stats.dailyBlocks || !stats.dailyBlocks[today]) return 0;
|
|
482
|
+
return stats.dailyBlocks[today];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function incrementTodayBlockCount() {
|
|
486
|
+
const stats = loadStats();
|
|
487
|
+
const today = todayKey();
|
|
488
|
+
if (!stats.dailyBlocks) stats.dailyBlocks = {};
|
|
489
|
+
// Clean old dates (keep only last 7 days to prevent unbounded growth)
|
|
490
|
+
const keys = Object.keys(stats.dailyBlocks);
|
|
491
|
+
if (keys.length > 7) {
|
|
492
|
+
keys.sort();
|
|
493
|
+
for (const k of keys.slice(0, keys.length - 7)) {
|
|
494
|
+
delete stats.dailyBlocks[k];
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
stats.dailyBlocks[today] = (stats.dailyBlocks[today] || 0) + 1;
|
|
498
|
+
saveStats(stats);
|
|
499
|
+
return stats.dailyBlocks[today];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* If the user is free-tier and has exceeded daily block limit, downgrade
|
|
504
|
+
* a deny result to a warn with an upgrade CTA. Returns null if no cap applies.
|
|
505
|
+
*/
|
|
506
|
+
function applyDailyBlockCap(denyResult) {
|
|
507
|
+
// Pro, trial, CI, and THUMBGATE_NO_RATE_LIMIT users are uncapped
|
|
508
|
+
if (isProTier()) return null;
|
|
509
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
|
|
510
|
+
|
|
511
|
+
const todayCount = getTodayBlockCount();
|
|
512
|
+
if (todayCount < FREE_TIER_DAILY_BLOCKS) {
|
|
513
|
+
// Under limit: allow the block, increment counter
|
|
514
|
+
incrementTodayBlockCount();
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Over limit: downgrade deny → warn with upgrade CTA
|
|
519
|
+
const remaining = 0;
|
|
520
|
+
return {
|
|
521
|
+
decision: 'warn',
|
|
522
|
+
gate: denyResult.gate,
|
|
523
|
+
message: `⚠️ ${denyResult.message}\n\n🔓 Daily protection limit reached (${FREE_TIER_DAILY_BLOCKS}/${FREE_TIER_DAILY_BLOCKS} blocks used). This action was allowed through. Upgrade for unlimited protection: https://thumbgate.ai/go/pro`,
|
|
524
|
+
severity: denyResult.severity,
|
|
525
|
+
reasoning: (denyResult.reasoning || []).concat([
|
|
526
|
+
`Free-tier daily block limit (${FREE_TIER_DAILY_BLOCKS}) exceeded — deny downgraded to warn`,
|
|
527
|
+
]),
|
|
528
|
+
dailyBlockCapApplied: true,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
455
532
|
// ---------------------------------------------------------------------------
|
|
456
533
|
// Reasoning chain builder
|
|
457
534
|
// ---------------------------------------------------------------------------
|
|
@@ -1477,11 +1554,19 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1477
1554
|
});
|
|
1478
1555
|
|
|
1479
1556
|
if (gate.action === 'block') {
|
|
1557
|
+
const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1558
|
+
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
1559
|
+
const cappedResult = applyDailyBlockCap(denyResult);
|
|
1560
|
+
if (cappedResult) {
|
|
1561
|
+
recordStat(gate.id, 'warn', gate);
|
|
1562
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
|
|
1563
|
+
auditToFeedback(auditRecord);
|
|
1564
|
+
return cappedResult;
|
|
1565
|
+
}
|
|
1480
1566
|
recordStat(gate.id, 'block', gate);
|
|
1481
|
-
const result = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1482
1567
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1483
1568
|
auditToFeedback(auditRecord);
|
|
1484
|
-
return
|
|
1569
|
+
return denyResult;
|
|
1485
1570
|
}
|
|
1486
1571
|
|
|
1487
1572
|
if (gate.action === 'approve') {
|
|
@@ -1639,11 +1724,19 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1639
1724
|
const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
|
|
1640
1725
|
|
|
1641
1726
|
if (gate.action === 'block') {
|
|
1727
|
+
const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1728
|
+
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
1729
|
+
const cappedResult = applyDailyBlockCap(denyResult);
|
|
1730
|
+
if (cappedResult) {
|
|
1731
|
+
recordStat(gate.id, 'warn', gate);
|
|
1732
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
|
|
1733
|
+
auditToFeedback(auditRecord);
|
|
1734
|
+
return cappedResult;
|
|
1735
|
+
}
|
|
1642
1736
|
recordStat(gate.id, 'block', gate);
|
|
1643
|
-
const result = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1644
1737
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1645
1738
|
auditToFeedback(auditRecord);
|
|
1646
|
-
return
|
|
1739
|
+
return denyResult;
|
|
1647
1740
|
}
|
|
1648
1741
|
|
|
1649
1742
|
if (gate.action === 'approve') {
|
|
@@ -1842,6 +1935,35 @@ function buildReminderOutput(context) {
|
|
|
1842
1935
|
};
|
|
1843
1936
|
}
|
|
1844
1937
|
|
|
1938
|
+
// ---------------------------------------------------------------------------
|
|
1939
|
+
// Upgrade nudge: surfaces Pro value at usage milestones and trial expiry.
|
|
1940
|
+
// Block-action Pro CTA: brief upgrade mention after a deny/warn decision.
|
|
1941
|
+
// Highest-intent moment — user just saw ThumbGate save them from a mistake.
|
|
1942
|
+
// ---------------------------------------------------------------------------
|
|
1943
|
+
|
|
1944
|
+
function buildBlockActionProCta() {
|
|
1945
|
+
try {
|
|
1946
|
+
if (process.env.THUMBGATE_NO_NUDGE === '1') return null;
|
|
1947
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
|
|
1948
|
+
if (isProTier()) return null;
|
|
1949
|
+
if (isInTrialPeriod()) return null; // Already have full access
|
|
1950
|
+
|
|
1951
|
+
const stats = loadStats();
|
|
1952
|
+
const totalBlocks = stats.blocked || 0;
|
|
1953
|
+
if (totalBlocks < 5) return null; // Too early — let them experience the product
|
|
1954
|
+
|
|
1955
|
+
if (totalBlocks < 25) {
|
|
1956
|
+
return '\n\n💡 Pro: sync rules across machines + dashboard analytics → thumbgate.ai/go/pro';
|
|
1957
|
+
}
|
|
1958
|
+
if (totalBlocks < 100) {
|
|
1959
|
+
return `\n\n💡 ${totalBlocks} actions blocked. Pro keeps rules in sync everywhere → thumbgate.ai/go/pro ($19/mo)`;
|
|
1960
|
+
}
|
|
1961
|
+
return `\n\n💡 ${totalBlocks} mistakes caught. Your team could use this → thumbgate.ai/go/pro`;
|
|
1962
|
+
} catch (_) {
|
|
1963
|
+
return null;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1845
1967
|
function formatOutput(result, behavioralContext) {
|
|
1846
1968
|
if (!result) {
|
|
1847
1969
|
// No gate matched — inject behavioral context if available
|
|
@@ -1860,11 +1982,12 @@ function formatOutput(result, behavioralContext) {
|
|
|
1860
1982
|
if (result.decision === 'deny') {
|
|
1861
1983
|
const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
|
|
1862
1984
|
const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
|
|
1985
|
+
const proCta = buildBlockActionProCta() || '';
|
|
1863
1986
|
return JSON.stringify({
|
|
1864
1987
|
hookSpecificOutput: {
|
|
1865
1988
|
...reminder,
|
|
1866
1989
|
permissionDecision: 'deny',
|
|
1867
|
-
permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}`,
|
|
1990
|
+
permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}${proCta}`,
|
|
1868
1991
|
},
|
|
1869
1992
|
});
|
|
1870
1993
|
}
|
|
@@ -2454,6 +2577,10 @@ module.exports = {
|
|
|
2454
2577
|
isRemoteSideEffectCommand,
|
|
2455
2578
|
evaluateLocalOnlyRemoteSideEffectGate,
|
|
2456
2579
|
PR_THREAD_RESOLUTION_ACTION,
|
|
2580
|
+
buildBlockActionProCta,
|
|
2581
|
+
applyDailyBlockCap,
|
|
2582
|
+
getTodayBlockCount,
|
|
2583
|
+
incrementTodayBlockCount,
|
|
2457
2584
|
};
|
|
2458
2585
|
|
|
2459
2586
|
// ---------------------------------------------------------------------------
|
package/scripts/hook-runtime.js
CHANGED
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
publishedCliAvailable,
|
|
8
8
|
} = require('./mcp-config');
|
|
9
9
|
const { publishedCliShellCommand } = require('./published-cli');
|
|
10
|
+
const { shimInstalled, shimPath } = require('./install-shim');
|
|
10
11
|
|
|
11
12
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
12
13
|
const featureSupportCache = new Map();
|
|
@@ -34,13 +35,18 @@ function publishedHookCommandsAvailable(version) {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function resolveCliCommand(subcommand) {
|
|
38
|
+
// Source checkout: always use direct node command for development
|
|
39
|
+
if (isSourceCheckout(PKG_ROOT)) {
|
|
40
|
+
return `node ${shellQuote(path.join(PKG_ROOT, 'bin', 'cli.js'))} ${subcommand}`;
|
|
41
|
+
}
|
|
42
|
+
// Prefer stable shim — always resolves @latest, survives version bumps
|
|
43
|
+
if (shimInstalled()) {
|
|
44
|
+
return `${shellQuote(shimPath())} ${subcommand}`;
|
|
45
|
+
}
|
|
37
46
|
const version = packageVersion();
|
|
38
47
|
if (publishedHookCommandsAvailable(version)) {
|
|
39
48
|
return publishedCliShellCommand(version, [subcommand]);
|
|
40
49
|
}
|
|
41
|
-
if (isSourceCheckout(PKG_ROOT)) {
|
|
42
|
-
return `node ${shellQuote(path.join(PKG_ROOT, 'bin', 'cli.js'))} ${subcommand}`;
|
|
43
|
-
}
|
|
44
50
|
return publishedCliShellCommand(version, [subcommand]);
|
|
45
51
|
}
|
|
46
52
|
|
|
@@ -282,6 +282,8 @@ function buildPromotedGate(candidate, metrics, runId) {
|
|
|
282
282
|
occurrences: metrics.hits,
|
|
283
283
|
promotedAt: new Date().toISOString(),
|
|
284
284
|
source: 'meta-agent',
|
|
285
|
+
// origin distinguishes silent-failure-clustered candidates from feedback-derived ones
|
|
286
|
+
origin: candidate.origin || 'user-feedback',
|
|
285
287
|
runId,
|
|
286
288
|
score: parseFloat(metrics.score.toFixed(3)),
|
|
287
289
|
hitRate: parseFloat(metrics.hitRate.toFixed(3)),
|
|
@@ -371,6 +373,34 @@ async function runMetaAgentLoop({ dryRun = false, verbose = false } = {}) {
|
|
|
371
373
|
candidates = generateCandidatesHeuristic(failures, blockPatterns);
|
|
372
374
|
}
|
|
373
375
|
|
|
376
|
+
// Tag existing-pipeline candidates with their origin so downstream precision
|
|
377
|
+
// measurement (silentFailureDerivedGates vs user-feedback-derived) is possible.
|
|
378
|
+
candidates = candidates.map((c) => (c.origin ? c : { ...c, origin: 'user-feedback' }));
|
|
379
|
+
|
|
380
|
+
// Step 3b: Silent-failure clustering — behind THUMBGATE_SILENT_FAILURE_CLUSTERING=1.
|
|
381
|
+
// Candidates flow through the SAME scoring / fp-rate eval below; we do not
|
|
382
|
+
// bypass any guardrail. Off by default to preserve existing behavior.
|
|
383
|
+
let silentFailureStats = null;
|
|
384
|
+
if (process.env.THUMBGATE_SILENT_FAILURE_CLUSTERING === '1') {
|
|
385
|
+
try {
|
|
386
|
+
const { generateSilentFailureCandidates } = require('./silent-failure-cluster');
|
|
387
|
+
const sfResult = generateSilentFailureCandidates({ feedbackLogPath });
|
|
388
|
+
silentFailureStats = sfResult.stats;
|
|
389
|
+
if (sfResult.candidates && sfResult.candidates.length > 0) {
|
|
390
|
+
candidates = candidates.concat(sfResult.candidates);
|
|
391
|
+
}
|
|
392
|
+
if (verbose) {
|
|
393
|
+
process.stdout.write(
|
|
394
|
+
`[meta-agent] silent-failure-cluster: candidates=${sfResult.candidates.length} `
|
|
395
|
+
+ `failed=${sfResult.stats.failedCalls} clusters=${sfResult.stats.clusters} `
|
|
396
|
+
+ `skipped=${sfResult.stats.skippedReason || 'none'}\n`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (verbose) process.stdout.write(`[meta-agent] silent-failure-cluster failed (non-fatal): ${err.message}\n`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
374
404
|
if (verbose) {
|
|
375
405
|
process.stdout.write(`[meta-agent] candidates generated: ${candidates.length} (mode=${analysisMode})\n`);
|
|
376
406
|
}
|
|
@@ -507,6 +537,8 @@ async function runMetaAgentLoop({ dryRun = false, verbose = false } = {}) {
|
|
|
507
537
|
skipped: evolutionResult.skipped || false,
|
|
508
538
|
}
|
|
509
539
|
: null,
|
|
540
|
+
silentFailureCluster: silentFailureStats,
|
|
541
|
+
silentFailureDerivedGates: promotedGates.filter((g) => g.origin === 'silent-failure-cluster').length,
|
|
510
542
|
};
|
|
511
543
|
|
|
512
544
|
if (!dryRun) {
|
|
@@ -16,7 +16,7 @@ const CREATOR_SYNTHETIC_KEY = process.env.THUMBGATE_DEV_KEY || '';
|
|
|
16
16
|
* 2. Env var: THUMBGATE_DEV_BYPASS=[set via THUMBGATE_DEV_SECRET env var]
|
|
17
17
|
* Requires a specific non-obvious value (not boolean) to prevent accidental activation.
|
|
18
18
|
*/
|
|
19
|
-
function isCreatorDev({ env = process.env, homeDir = os.homedir() } = {}) {
|
|
19
|
+
function isCreatorDev({ env = process.env, homeDir = env.HOME || env.USERPROFILE || os.homedir() } = {}) {
|
|
20
20
|
// Layer 1: env var with specific value
|
|
21
21
|
if (CREATOR_BYPASS_VALUE && String(env[CREATOR_BYPASS_ENV] || '') === CREATOR_BYPASS_VALUE) {
|
|
22
22
|
return true;
|
|
@@ -37,7 +37,7 @@ function isCreatorDev({ env = process.env, homeDir = os.homedir() } = {}) {
|
|
|
37
37
|
* with any non-empty bypass value. No env var needed — just the config file.
|
|
38
38
|
* Used by the server to skip auth on localhost during local development.
|
|
39
39
|
*/
|
|
40
|
-
function hasDevOverride(homeDir = os.homedir()) {
|
|
40
|
+
function hasDevOverride(homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir()) {
|
|
41
41
|
// Disabled during test runs to avoid interfering with auth assertions
|
|
42
42
|
if (process.env.NODE_TEST_CONTEXT || process.env.THUMBGATE_TESTING) return false;
|
|
43
43
|
try {
|
|
@@ -47,11 +47,11 @@ function hasDevOverride(homeDir = os.homedir()) {
|
|
|
47
47
|
} catch { return false; }
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function getLicenseDir(homeDir = os.homedir()) {
|
|
50
|
+
function getLicenseDir(homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir()) {
|
|
51
51
|
return path.join(homeDir, '.thumbgate');
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function getLicensePath(homeDir = os.homedir()) {
|
|
54
|
+
function getLicensePath(homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir()) {
|
|
55
55
|
return path.join(getLicenseDir(homeDir), 'license.json');
|
|
56
56
|
}
|
|
57
57
|
|
package/scripts/rate-limiter.js
CHANGED
|
@@ -29,6 +29,7 @@ const FREE_TIER_LIMITS = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const FREE_TIER_MAX_GATES = 5; // 5 active prevention rules on free; Pro is unlimited
|
|
32
|
+
const FREE_TIER_DAILY_BLOCKS = 10; // 10 gate blocks/day on free; after limit, deny → warn + upgrade CTA
|
|
32
33
|
|
|
33
34
|
const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
|
|
34
35
|
|
|
@@ -45,7 +46,10 @@ function getInstallAgeDays() {
|
|
|
45
46
|
try {
|
|
46
47
|
const { INSTALL_ID_PATH } = require('./cli-telemetry');
|
|
47
48
|
if (!fs.existsSync(INSTALL_ID_PATH)) return null;
|
|
48
|
-
|
|
49
|
+
// Use mtimeMs — birthtimeMs is unreliable on Linux (ext4 doesn't backdate creation time).
|
|
50
|
+
// The install-id file is written once at install, so mtime == creation time in practice.
|
|
51
|
+
const stat = fs.statSync(INSTALL_ID_PATH);
|
|
52
|
+
const created = stat.mtimeMs || stat.birthtimeMs;
|
|
49
53
|
if (!Number.isFinite(created) || created <= 0) return null;
|
|
50
54
|
return (Date.now() - created) / (1000 * 60 * 60 * 24);
|
|
51
55
|
} catch (_) {
|
|
@@ -211,6 +215,7 @@ function getUsage(action, authContext) {
|
|
|
211
215
|
module.exports = {
|
|
212
216
|
checkLimit,
|
|
213
217
|
getUsage,
|
|
218
|
+
getInstallAgeDays,
|
|
214
219
|
isProTier,
|
|
215
220
|
isInTrialPeriod,
|
|
216
221
|
trialDaysRemaining,
|
|
@@ -219,6 +224,7 @@ module.exports = {
|
|
|
219
224
|
todayKey,
|
|
220
225
|
FREE_TIER_LIMITS,
|
|
221
226
|
FREE_TIER_MAX_GATES,
|
|
227
|
+
FREE_TIER_DAILY_BLOCKS,
|
|
222
228
|
TRIAL_DAYS,
|
|
223
229
|
UPGRADE_MESSAGE,
|
|
224
230
|
PAYWALL_MESSAGES,
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const { diagnoseFailure } = require('./failure-diagnostics');
|
|
7
|
+
const { appendDiagnosticRecord } = require('./feedback-loop');
|
|
8
|
+
|
|
9
|
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
10
|
+
const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
11
|
+
const DEFAULT_TESTS_TIMEOUT_MS = Number.parseInt(
|
|
12
|
+
process.env.THUMBGATE_SELF_HEAL_TEST_TIMEOUT_MS || '',
|
|
13
|
+
10,
|
|
14
|
+
) || 60 * 60_000;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CHECKS = [
|
|
17
|
+
{ name: 'budget_status', command: ['npm', 'run', 'budget:status'], timeoutMs: 60_000 },
|
|
18
|
+
{ name: 'tests', command: ['npm', 'test'], timeoutMs: DEFAULT_TESTS_TIMEOUT_MS },
|
|
19
|
+
{ name: 'prove_adapters', command: ['npm', 'run', 'prove:adapters'], timeoutMs: 10 * 60_000, useTempProofDir: true },
|
|
20
|
+
{ name: 'prove_automation', command: ['npm', 'run', 'prove:automation'], timeoutMs: 10 * 60_000, useTempProofDir: true },
|
|
21
|
+
{ name: 'prove_data_pipeline', command: ['npm', 'run', 'prove:data-pipeline'], timeoutMs: 10 * 60_000, useTempProofDir: true },
|
|
22
|
+
{ name: 'prove_tessl', command: ['npm', 'run', 'prove:tessl'], timeoutMs: 10 * 60_000, useTempProofDir: true },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function runCommand(command, {
|
|
26
|
+
cwd = PROJECT_ROOT,
|
|
27
|
+
timeoutMs = 5 * 60_000,
|
|
28
|
+
env = process.env,
|
|
29
|
+
maxBufferBytes = DEFAULT_MAX_BUFFER_BYTES,
|
|
30
|
+
} = {}) {
|
|
31
|
+
const [cmd, ...args] = command;
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
const result = spawnSync(cmd, args, {
|
|
34
|
+
cwd,
|
|
35
|
+
env,
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
timeout: timeoutMs,
|
|
38
|
+
maxBuffer: maxBufferBytes,
|
|
39
|
+
shell: false,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const durationMs = Date.now() - started;
|
|
43
|
+
const status = Number.isInteger(result.status) ? result.status : 1;
|
|
44
|
+
return {
|
|
45
|
+
exitCode: status,
|
|
46
|
+
durationMs,
|
|
47
|
+
stdout: result.stdout || '',
|
|
48
|
+
stderr: result.stderr || '',
|
|
49
|
+
error: result.error ? result.error.message : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createCheckEnvironment(check) {
|
|
54
|
+
const environment = { ...process.env };
|
|
55
|
+
let cleanup = null;
|
|
56
|
+
|
|
57
|
+
if (check.useTempProofDir) {
|
|
58
|
+
const proofDir = fs.mkdtempSync(path.join(os.tmpdir(), `thumbgate-${check.name}-`));
|
|
59
|
+
environment.THUMBGATE_PROOF_DIR = proofDir;
|
|
60
|
+
if (check.name === 'prove_automation') {
|
|
61
|
+
environment.THUMBGATE_AUTOMATION_PROOF_DIR = proofDir;
|
|
62
|
+
}
|
|
63
|
+
cleanup = () => {
|
|
64
|
+
fs.rmSync(proofDir, { recursive: true, force: true });
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { env: environment, cleanup };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectHealthReport({
|
|
72
|
+
checks = DEFAULT_CHECKS,
|
|
73
|
+
runner = runCommand,
|
|
74
|
+
cwd = PROJECT_ROOT,
|
|
75
|
+
persistDiagnostics = false,
|
|
76
|
+
} = {}) {
|
|
77
|
+
const startedAt = new Date();
|
|
78
|
+
const results = checks.map((check) => {
|
|
79
|
+
const { env, cleanup } = createCheckEnvironment(check);
|
|
80
|
+
let run;
|
|
81
|
+
try {
|
|
82
|
+
run = runner(check.command, { cwd, timeoutMs: check.timeoutMs, env });
|
|
83
|
+
} finally {
|
|
84
|
+
if (cleanup) {
|
|
85
|
+
cleanup();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const diagnosis = run.exitCode === 0
|
|
89
|
+
? null
|
|
90
|
+
: diagnoseFailure({
|
|
91
|
+
step: check.name,
|
|
92
|
+
context: check.command.join(' '),
|
|
93
|
+
healthCheck: {
|
|
94
|
+
name: check.name,
|
|
95
|
+
exitCode: run.exitCode,
|
|
96
|
+
status: 'unhealthy',
|
|
97
|
+
outputTail: `${run.stdout}\n${run.stderr}`.trim().slice(-2000),
|
|
98
|
+
},
|
|
99
|
+
exitCode: run.exitCode,
|
|
100
|
+
error: run.error,
|
|
101
|
+
output: `${run.stdout}\n${run.stderr}`.trim(),
|
|
102
|
+
});
|
|
103
|
+
const persistedDiagnosis = persistDiagnostics && diagnosis
|
|
104
|
+
? appendDiagnosticRecord({
|
|
105
|
+
source: 'self_heal_check',
|
|
106
|
+
step: check.name,
|
|
107
|
+
context: check.command.join(' '),
|
|
108
|
+
diagnosis,
|
|
109
|
+
metadata: {
|
|
110
|
+
command: check.command.join(' '),
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
: null;
|
|
114
|
+
return {
|
|
115
|
+
name: check.name,
|
|
116
|
+
command: check.command.join(' '),
|
|
117
|
+
status: run.exitCode === 0 ? 'healthy' : 'unhealthy',
|
|
118
|
+
exitCode: run.exitCode,
|
|
119
|
+
durationMs: run.durationMs,
|
|
120
|
+
error: run.error,
|
|
121
|
+
outputTail: `${run.stdout}\n${run.stderr}`.trim().slice(-2000),
|
|
122
|
+
diagnosis,
|
|
123
|
+
persistedDiagnosis,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const healthyCount = results.filter((x) => x.status === 'healthy').length;
|
|
128
|
+
const unhealthyCount = results.length - healthyCount;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
generatedAt: startedAt.toISOString(),
|
|
132
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
133
|
+
overall_status: unhealthyCount === 0 ? 'healthy' : 'unhealthy',
|
|
134
|
+
summary: {
|
|
135
|
+
total: results.length,
|
|
136
|
+
healthy: healthyCount,
|
|
137
|
+
unhealthy: unhealthyCount,
|
|
138
|
+
},
|
|
139
|
+
checks: results,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function reportToText(report) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
lines.push(`Self-Healing Health Check @ ${report.generatedAt}`);
|
|
146
|
+
lines.push(`Overall: ${report.overall_status.toUpperCase()}`);
|
|
147
|
+
lines.push(`Checks: ${report.summary.healthy}/${report.summary.total} healthy`);
|
|
148
|
+
lines.push('');
|
|
149
|
+
|
|
150
|
+
report.checks.forEach((check) => {
|
|
151
|
+
const icon = check.status === 'healthy' ? '✅' : '❌';
|
|
152
|
+
lines.push(`${icon} ${check.name} (${check.durationMs}ms)`);
|
|
153
|
+
if (check.status !== 'healthy') {
|
|
154
|
+
lines.push(` command: ${check.command}`);
|
|
155
|
+
if (check.error) lines.push(` error: ${check.error}`);
|
|
156
|
+
if (check.diagnosis && check.diagnosis.rootCauseCategory) {
|
|
157
|
+
lines.push(` diagnosis: ${check.diagnosis.rootCauseCategory}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return `${lines.join('\n')}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runCli() {
|
|
166
|
+
const args = new Set(process.argv.slice(2));
|
|
167
|
+
const emitJson = args.has('--json');
|
|
168
|
+
const noFail = args.has('--no-fail');
|
|
169
|
+
const report = collectHealthReport({ persistDiagnostics: true });
|
|
170
|
+
|
|
171
|
+
if (emitJson) {
|
|
172
|
+
console.log(JSON.stringify(report, null, 2));
|
|
173
|
+
} else {
|
|
174
|
+
process.stdout.write(reportToText(report));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!noFail && report.overall_status !== 'healthy') {
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
DEFAULT_CHECKS,
|
|
184
|
+
DEFAULT_TESTS_TIMEOUT_MS,
|
|
185
|
+
DEFAULT_MAX_BUFFER_BYTES,
|
|
186
|
+
runCommand,
|
|
187
|
+
collectHealthReport,
|
|
188
|
+
reportToText,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (require.main === module) {
|
|
192
|
+
runCli();
|
|
193
|
+
}
|