thumbgate 1.22.0 → 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.
@@ -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
 
@@ -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
- const created = fs.statSync(INSTALL_ID_PATH).birthtimeMs || fs.statSync(INSTALL_ID_PATH).mtimeMs;
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
+ }