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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +1 -0
  5. package/adapters/chatgpt/openapi.yaml +10 -0
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/mcp/server-stdio.js +109 -1
  8. package/adapters/opencode/opencode.json +1 -1
  9. package/bin/cli.js +247 -30
  10. package/config/mcp-allowlists.json +12 -6
  11. package/openapi/openapi.yaml +10 -0
  12. package/package.json +29 -5
  13. package/public/agent-manager.html +1 -1
  14. package/public/agents-cost-savings.html +151 -0
  15. package/public/ai-malpractice-prevention.html +183 -0
  16. package/public/codex-enterprise.html +123 -0
  17. package/public/codex-plugin.html +1 -1
  18. package/public/dashboard.html +18 -5
  19. package/public/index.html +13 -6
  20. package/public/lessons.html +34 -0
  21. package/public/numbers.html +2 -2
  22. package/public/pricing.html +1 -1
  23. package/scripts/auto-wire-hooks.js +14 -0
  24. package/scripts/build-metadata.js +32 -13
  25. package/scripts/cli-telemetry.js +6 -1
  26. package/scripts/gate-stats.js +89 -0
  27. package/scripts/gates-engine.js +133 -6
  28. package/scripts/hook-runtime.js +9 -3
  29. package/scripts/meta-agent-loop.js +32 -0
  30. package/scripts/pro-local-dashboard.js +4 -4
  31. package/scripts/rate-limiter.js +7 -1
  32. package/scripts/self-healing-check.js +193 -0
  33. package/scripts/silent-failure-cluster.js +512 -0
  34. package/scripts/telemetry-analytics.js +38 -0
  35. package/scripts/tool-registry.js +18 -0
  36. package/scripts/workflow-sentinel.js +6 -1
  37. package/src/api/server.js +311 -36
@@ -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 result;
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 result;
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
  // ---------------------------------------------------------------------------
@@ -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
 
@@ -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
+ }