thumbgate 1.31.0 → 1.34.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.
@@ -10,12 +10,12 @@
10
10
  <meta property="og:title" content="ThumbGate — The Numbers">
11
11
  <meta property="og:description" content="Generated first-party operational snapshot: configured gates, recorded interventions, and explicit zero-evidence caveats.">
12
12
  <meta property="og:type" content="website">
13
- <meta property="og:url" content="https://thumbgate.ai/numbers">
13
+ <meta property="og:url" content="https://thumbgate-production.up.railway.app/numbers">
14
14
  <meta name="twitter:card" content="summary_large_image">
15
- <link rel="canonical" href="https://thumbgate.ai/numbers">
15
+ <link rel="canonical" href="https://thumbgate-production.up.railway.app/numbers">
16
16
  <link rel="icon" type="image/png" href="/thumbgate-icon.png">
17
17
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
18
- <script defer data-domain="thumbgate.ai" src="https://plausible.io/js/script.js"></script>
18
+ <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
19
19
 
20
20
  <script type="application/ld+json">
21
21
  {
@@ -25,9 +25,9 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.31.0",
29
- "url": "https://thumbgate.ai/numbers",
30
- "dateModified": "2026-05-07",
28
+ "softwareVersion": "1.34.1",
29
+ "url": "https://thumbgate-production.up.railway.app/numbers",
30
+ "dateModified": "2026-08-03",
31
31
  "creator": {
32
32
  "@type": "Person",
33
33
  "name": "Igor Ganapolsky",
@@ -46,7 +46,7 @@
46
46
  "@type": "Dataset",
47
47
  "name": "ThumbGate Operational Snapshot",
48
48
  "description": "First-party operational snapshot from the ThumbGate pre-action check runtime: configured checks, recorded block/warn events, estimated token savings from recorded blocks, and Bayes error rate when the sample supports it.",
49
- "url": "https://thumbgate.ai/numbers",
49
+ "url": "https://thumbgate-production.up.railway.app/numbers",
50
50
  "license": "https://opensource.org/licenses/MIT",
51
51
  "creator": {
52
52
  "@type": "Person",
@@ -57,8 +57,8 @@
57
57
  "https://www.linkedin.com/in/igorganapolsky"
58
58
  ]
59
59
  },
60
- "dateModified": "2026-05-07",
61
- "datePublished": "2026-05-07",
60
+ "dateModified": "2026-08-03",
61
+ "datePublished": "2026-08-03",
62
62
  "keywords": [
63
63
  "AI agent gates",
64
64
  "LLM token savings",
@@ -70,7 +70,7 @@
70
70
  {
71
71
  "@type": "PropertyValue",
72
72
  "name": "configured_gates",
73
- "value": 36
73
+ "value": 40
74
74
  },
75
75
  {
76
76
  "@type": "PropertyValue",
@@ -195,7 +195,6 @@
195
195
  <a href="/learn">Learn</a>
196
196
  <a href="/compare">Compare</a>
197
197
  <a href="/numbers">Numbers</a>
198
- <a href="/evaluations">Evaluations</a>
199
198
  <a href="/dashboard">Dashboard</a>
200
199
  <a href="/pro">Pro</a>
201
200
  </nav>
@@ -203,15 +202,15 @@
203
202
  <main class="container">
204
203
  <h1>The Numbers</h1>
205
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
206
- <div class="freshness">Updated: 2026-05-07 · Version 1.31.0</div>
205
+ <div class="freshness">Updated: 2026-08-03 · Version 1.34.1</div>
207
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
208
207
 
209
208
  <h2>Gate enforcement</h2>
210
209
  <div class="stats-grid">
211
210
  <div class="stat-card">
212
211
  <div class="stat-label">Configured checks</div>
213
- <div class="stat-value">36</div>
214
- <div class="stat-sub">36 shipped defaults · 0 auto-promoted; inventory, not usage</div>
212
+ <div class="stat-value">40</div>
213
+ <div class="stat-sub">40 shipped defaults · 0 auto-promoted; inventory, not usage</div>
215
214
  <a class="stat-source" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/scripts/gate-stats.js">source: gate-stats.js</a>
216
215
  </div>
217
216
  <div class="stat-card">
@@ -278,7 +277,7 @@
278
277
  <div class="cta">
279
278
  <a href="https://www.npmjs.com/package/thumbgate">Install ThumbGate — npx thumbgate init</a>
280
279
  <div class="footer-note">Prefer the raw feed? See <a href="https://github.com/IgorGanapolsky/ThumbGate">GitHub</a> or run <code>npm run gate:stats</code> locally.</div>
281
- <div class="footer-note">Generated at 2026-05-07T14:59:51.816Z UTC.</div>
280
+ <div class="footer-note">Generated at 2026-08-03T05:26:21.201Z UTC.</div>
282
281
  </div>
283
282
  </main>
284
283
  </body>
@@ -165,6 +165,95 @@ function summarizePermissionTier(profileName = getActiveMcpProfile()) {
165
165
  };
166
166
  }
167
167
 
168
+
169
+ function detectStopHookRegistered(projectRoot, existsSync, readFileSync) {
170
+ try {
171
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
172
+ if (!existsSync(settingsPath)) return false;
173
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
174
+ const stopHooks = settings?.hooks?.Stop || [];
175
+ const flat = Array.isArray(stopHooks)
176
+ ? stopHooks.flatMap((entry) => entry?.hooks || [entry])
177
+ : [];
178
+ return flat.some((hook) => String(hook?.command || '').includes('hook-stop-anti-claim'));
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ function recommendationForClaimState({
185
+ evaluatorReady,
186
+ configLoadFailed,
187
+ verifierCount,
188
+ stopHookRegistered,
189
+ configSource,
190
+ loadErrorMessage,
191
+ }) {
192
+ if (!evaluatorReady) {
193
+ return 'Universal claim evaluator module is missing from this install.';
194
+ }
195
+ if (configLoadFailed) {
196
+ return loadErrorMessage;
197
+ }
198
+ if (verifierCount === 0) {
199
+ return 'No claim verifiers configured. Copy config/gates/claim-verifiers.example.json to .thumbgate/claim-verifiers.json and point subjects at your sources of truth.';
200
+ }
201
+ if (!stopHookRegistered) {
202
+ return 'Claim verifiers are present, but the Claude Stop anti-claim hook is not registered in .claude/settings.json.';
203
+ }
204
+ return `Factual claim recheck is ready (${verifierCount} verifier(s) from ${configSource}).`;
205
+ }
206
+
207
+ function summarizeClaimVerification(projectRoot = PROJECT_ROOT, deps = {}) {
208
+ const resolveEvaluator = deps.resolveEvaluator
209
+ || (() => require.resolve('./universal-claim-evaluator'));
210
+ const loadVerifierConfig = deps.loadVerifierConfig
211
+ || (() => require('./universal-claim-evaluator').loadVerifierConfig);
212
+ const readFileSync = deps.readFileSync || fs.readFileSync;
213
+ const existsSync = deps.existsSync || fs.existsSync;
214
+
215
+ let evaluatorReady = false;
216
+ try {
217
+ resolveEvaluator();
218
+ evaluatorReady = true;
219
+ } catch {
220
+ evaluatorReady = false;
221
+ }
222
+
223
+ let verifierCount = 0;
224
+ let configSource = 'none';
225
+ let configLoadFailed = false;
226
+ let loadErrorMessage = 'Install ThumbGate and configure claim verifiers under .thumbgate/claim-verifiers.json.';
227
+ try {
228
+ const loaded = loadVerifierConfig()({ cwd: projectRoot });
229
+ verifierCount = Array.isArray(loaded.verifiers) ? loaded.verifiers.length : 0;
230
+ configSource = loaded.source || 'none';
231
+ } catch (error) {
232
+ configLoadFailed = true;
233
+ loadErrorMessage = `Claim verifier config failed to load: ${error?.message || 'unknown error'}`;
234
+ }
235
+
236
+ const stopHookRegistered = detectStopHookRegistered(projectRoot, existsSync, readFileSync);
237
+ const recommendation = recommendationForClaimState({
238
+ evaluatorReady,
239
+ configLoadFailed,
240
+ verifierCount,
241
+ stopHookRegistered,
242
+ configSource,
243
+ loadErrorMessage,
244
+ });
245
+
246
+ return {
247
+ ready: evaluatorReady && verifierCount > 0 && stopHookRegistered && !configLoadFailed,
248
+ evaluatorReady,
249
+ verifierCount,
250
+ configSource,
251
+ stopHookRegistered,
252
+ configLoadFailed,
253
+ recommendation,
254
+ };
255
+ }
256
+
168
257
  function generateAgentReadinessReport({
169
258
  projectRoot = PROJECT_ROOT,
170
259
  mcpProfile = null,
@@ -172,11 +261,18 @@ function generateAgentReadinessReport({
172
261
  const runtime = detectRuntimeIsolation();
173
262
  const bootstrap = collectBootstrapFiles(projectRoot);
174
263
  const permissions = summarizePermissionTier(mcpProfile || getActiveMcpProfile());
264
+ const claimVerification = summarizeClaimVerification(projectRoot);
175
265
 
176
266
  const warnings = [];
177
267
  if (!runtime.isolated) warnings.push(runtime.recommendation);
178
268
  if (!bootstrap.ready) warnings.push(bootstrap.recommendation);
179
269
  if (!permissions.ready) warnings.push(permissions.recommendation);
270
+ // Missing operator verifiers is advisory (not every project asserts SQL row counts).
271
+ // A missing evaluator module or a broken claim-verifier config is not advisory —
272
+ // both make factual recheck fail closed at runtime and must surface here.
273
+ if (!claimVerification.evaluatorReady || claimVerification.configLoadFailed) {
274
+ warnings.push(claimVerification.recommendation);
275
+ }
180
276
 
181
277
  return {
182
278
  generatedAt: new Date().toISOString(),
@@ -185,10 +281,12 @@ function generateAgentReadinessReport({
185
281
  runtime,
186
282
  bootstrap,
187
283
  permissions,
284
+ claimVerification,
188
285
  articleAlignment: {
189
286
  runtimeIsolation: runtime.isolated,
190
287
  contextConditioning: bootstrap.ready,
191
288
  permissionEnvelope: permissions.ready,
289
+ factualClaimRecheck: claimVerification.ready,
192
290
  },
193
291
  warnings,
194
292
  };
@@ -208,6 +306,15 @@ function reportToText(report) {
208
306
  lines.push(`Permissions: ${report.permissions.profile} (${report.permissions.tier})`);
209
307
  lines.push(` Write-capable tools: ${report.permissions.writeCapableTools.length}`);
210
308
  lines.push(` Recommendation: ${report.permissions.recommendation}`);
309
+ if (report.claimVerification) {
310
+ lines.push(
311
+ `Claim verification: ${report.claimVerification.ready ? 'ready' : 'needs_attention'}`,
312
+ ` Evaluator: ${report.claimVerification.evaluatorReady ? 'present' : 'missing'}`,
313
+ ` Verifiers: ${report.claimVerification.verifierCount} (${report.claimVerification.configSource})`,
314
+ ` Stop hook: ${report.claimVerification.stopHookRegistered ? 'registered' : 'missing'}`,
315
+ ` Recommendation: ${report.claimVerification.recommendation}`,
316
+ );
317
+ }
211
318
 
212
319
  if (report.warnings.length > 0) {
213
320
  lines.push('');
@@ -226,6 +333,9 @@ module.exports = {
226
333
  detectRuntimeIsolation,
227
334
  collectBootstrapFiles,
228
335
  summarizePermissionTier,
336
+ summarizeClaimVerification,
337
+ recommendationForClaimState,
338
+ detectStopHookRegistered,
229
339
  generateAgentReadinessReport,
230
340
  reportToText,
231
341
  };
@@ -17,6 +17,7 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
  const {
19
19
  cacheUpdateHookCommand,
20
+ claimStopHookCommand,
20
21
  codexCacheUpdateHookCommand,
21
22
  codexPreToolHookCommand,
22
23
  codexSessionStartHookCommand,
@@ -36,7 +37,7 @@ function getHome() {
36
37
  // --- Hook definitions ---
37
38
  const CLAUDE_HOOKS = {
38
39
  PreToolUse: {
39
- matcher: 'Bash|Edit|Write|MultiEdit',
40
+ matcher: '.*',
40
41
  hooks: [{ type: 'command', command: preToolHookCommand() }],
41
42
  },
42
43
  UserPromptSubmit: {
@@ -49,11 +50,14 @@ const CLAUDE_HOOKS = {
49
50
  SessionStart: {
50
51
  hooks: [{ type: 'command', command: sessionStartHookCommand() }],
51
52
  },
53
+ Stop: {
54
+ hooks: [{ type: 'command', command: claimStopHookCommand() }],
55
+ },
52
56
  };
53
57
 
54
58
  const CODEX_HOOKS = {
55
59
  PreToolUse: {
56
- matcher: 'Bash',
60
+ matcher: '.*',
57
61
  hooks: [{ type: 'command', command: codexPreToolHookCommand() }],
58
62
  },
59
63
  UserPromptSubmit: {
@@ -384,6 +388,7 @@ function wireClaudeHooks(options) {
384
388
  UserPromptSubmit: /(hook-auto-capture\.sh|hook-auto-capture\b)/,
385
389
  PostToolUse: /(hook-thumbgate-cache-updater|cache-update\b)/,
386
390
  SessionStart: /(thumbgate_session_start\.sh|session-start\b)/,
391
+ Stop: /(hook-stop-anti-claim\.js|claim-stop-check\b)/,
387
392
  };
388
393
 
389
394
  for (const [lifecycle, hookDef] of Object.entries(CLAUDE_HOOKS)) {
@@ -456,8 +461,13 @@ function codexTomlConfigPath(configPath = codexConfigPath()) {
456
461
  return path.join(path.dirname(configPath), 'config.toml');
457
462
  }
458
463
 
464
+ function escapeRegexLiteral(value) {
465
+ return String(value).replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
466
+ }
467
+
459
468
  function tomlSectionRegex(name) {
460
- return new RegExp(`^\\[${String(name).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]\\n(?:^(?!\\[).*(?:\\n|$))*`, 'm');
469
+ const sectionName = escapeRegexLiteral(name);
470
+ return new RegExp(String.raw`^\[${sectionName}\]\n(?:^(?!\[).*(?:\n|$))*`, 'm');
461
471
  }
462
472
 
463
473
  function codexUserPromptTomlBlock() {
@@ -470,10 +480,11 @@ function upsertCodexUserPromptToml(configPath, dryRun = false) {
470
480
  const current = fs.existsSync(tomlPath) ? fs.readFileSync(tomlPath, 'utf8') : '';
471
481
  const canonicalBlock = codexUserPromptTomlBlock();
472
482
  const sectionRegex = tomlSectionRegex('hooks.user_prompt_submit');
473
- let next = current;
483
+ const existingMatch = sectionRegex.exec(current);
484
+ let next;
474
485
 
475
- if (sectionRegex.test(current)) {
476
- const existingBlock = current.match(sectionRegex)[0];
486
+ if (existingMatch) {
487
+ const existingBlock = existingMatch[0];
477
488
  if (existingBlock === canonicalBlock) {
478
489
  return { changed: false, settingsPath: tomlPath };
479
490
  }
@@ -610,7 +621,7 @@ function wireGeminiHooks(options) {
610
621
  if (!hookAlreadyPresent(settings.hooks.PreToolUse, preToolCmd)) {
611
622
  settings.hooks.PreToolUse = settings.hooks.PreToolUse || [];
612
623
  settings.hooks.PreToolUse.push({
613
- matcher: 'Bash',
624
+ matcher: '.*',
614
625
  hooks: [{ type: 'command', command: preToolCmd }],
615
626
  });
616
627
  added.push({ lifecycle: 'PreToolUse', command: preToolCmd });
@@ -661,7 +672,7 @@ function wireForgeHooks(options) {
661
672
  if (!hookAlreadyPresent(existing.hooks.PreToolUse, preToolCmd)) {
662
673
  existing.hooks.PreToolUse = existing.hooks.PreToolUse || [];
663
674
  existing.hooks.PreToolUse.push({
664
- matcher: 'Bash',
675
+ matcher: '.*',
665
676
  hooks: [{ type: 'command', command: preToolCmd }],
666
677
  });
667
678
  added.push({ lifecycle: 'PreToolUse', command: preToolCmd });
@@ -765,6 +776,7 @@ module.exports = {
765
776
  pruneStaleHooksInFile,
766
777
  CLAUDE_HOOKS,
767
778
  preToolHookCommand,
779
+ claimStopHookCommand,
768
780
  userPromptHookCommand,
769
781
  sessionStartHookCommand,
770
782
  };
@@ -505,6 +505,20 @@ const CLI_COMMANDS = [
505
505
  group: 'gates',
506
506
  flags: [],
507
507
  },
508
+ {
509
+ name: 'verify-claims',
510
+ aliases: ['verify-claim'],
511
+ description: 'Recheck factual claims against configured SQLite, filesystem, and JSON sources',
512
+ group: 'gates',
513
+ mcpTool: 'verify_claim',
514
+ flags: [
515
+ { name: 'claim', type: 'string', required: true, description: 'Factual claim text to verify' },
516
+ { name: 'config', type: 'string', description: 'Verifier config path (default .thumbgate/claim-verifiers.json)' },
517
+ { name: 'cwd', type: 'string', description: 'Root directory that contains configured sources' },
518
+ { name: 'advisory', type: 'boolean', description: 'Do not fail an otherwise parseable claim only because no verifier is configured' },
519
+ { name: 'json', type: 'boolean', description: 'Output a machine-readable verdict' },
520
+ ],
521
+ },
508
522
  {
509
523
  name: 'hermes-gate',
510
524
  description: 'Hermes Agent pre_tool_call hook: gate runtime tool calls (incl. skill_manage) before they run',
@@ -31,6 +31,9 @@ const {
31
31
  } = require('./feedback-quality');
32
32
 
33
33
  const INFERRED_TAG_RULES = [
34
+ { tag: 'claw-style', keywords: ['claw', 'enterprise-claw', 'openshell', 'dynamic-tool', 'screen-interaction', 'computer-use'] },
35
+ { tag: 'hybrid-inference', keywords: ['hybrid', 'cloud-escalation', 'local-route', 'hybrid-route', 'perplexity-pc'] },
36
+ { tag: 'agent-identity', keywords: ['agent identity', 'audit trail', 'identity separation', 'agent-credential'] },
34
37
  { tag: 'thumbgate', keywords: ['thumbgate', 'feedback-loop', 'statusline', 'dashboard', 'mcp'] },
35
38
  { tag: 'testing', keywords: ['test', 'testing', 'jest', 'coverage', 'verify', 'verification'] },
36
39
  { tag: 'security', keywords: ['security', 'secret', 'credential', 'token', 'auth'] },
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Cross-process lock for append-only local ledgers.
6
+ *
7
+ * `mkdir` is the atomic acquisition primitive. An owner record prevents a
8
+ * crashed process from permanently wedging the control plane, while a nonce
9
+ * check prevents an old owner from deleting a replacement lock.
10
+ */
11
+
12
+ const crypto = require('node:crypto');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const DEFAULT_STALE_MS = 30 * 1000;
17
+
18
+ function withFileLedgerLock(lockPath, callback, options = {}) {
19
+ const resolvedLockPath = path.resolve(lockPath);
20
+ fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
21
+ const owner = acquireLock(resolvedLockPath, options);
22
+ try {
23
+ if (typeof options.beforeCallback === 'function') options.beforeCallback();
24
+ return callback();
25
+ } finally {
26
+ releaseOwnedLock(resolvedLockPath, owner);
27
+ }
28
+ }
29
+
30
+ function acquireLock(lockPath, options) {
31
+ const now = options.now || new Date();
32
+ const staleMs = positiveNumber(options.lockStaleMs, DEFAULT_STALE_MS);
33
+ const owner = {
34
+ schemaVersion: 'thumbgate-ledger-lock-v1',
35
+ pid: process.pid,
36
+ nonce: crypto.randomUUID(),
37
+ acquiredAt: now.toISOString(),
38
+ };
39
+ for (let attempt = 0; attempt < 3; attempt += 1) {
40
+ try {
41
+ fs.mkdirSync(lockPath);
42
+ writeOwner(lockPath, owner);
43
+ return owner;
44
+ } catch (error) {
45
+ if (error.code !== 'EEXIST') throw error;
46
+ if (!recoverStaleLock(lockPath, now, staleMs)) {
47
+ throw lockError(options, 'ledger is busy; deny and retry only after the active writer finishes');
48
+ }
49
+ }
50
+ }
51
+ throw lockError(options, 'ledger lock could not be acquired after stale-lock recovery');
52
+ }
53
+
54
+ function recoverStaleLock(lockPath, now, staleMs) {
55
+ const observed = readOwner(lockPath);
56
+ const ageMs = lockAgeMs(lockPath, observed, now);
57
+ if (ageMs < staleMs || processIsAlive(observed?.pid)) return false;
58
+
59
+ // Rename is atomic. If another process already recovered or replaced the
60
+ // lock, this attempt loses harmlessly and acquisition is retried.
61
+ const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`;
62
+ try {
63
+ fs.renameSync(lockPath, quarantine);
64
+ } catch (error) {
65
+ if (['ENOENT', 'EEXIST'].includes(error.code)) return true;
66
+ throw error;
67
+ }
68
+ fs.rmSync(quarantine, { recursive: true, force: true });
69
+ return true;
70
+ }
71
+
72
+ function writeOwner(lockPath, owner) {
73
+ const target = path.join(lockPath, 'owner.json');
74
+ const temporary = `${target}.tmp-${process.pid}-${owner.nonce}`;
75
+ fs.writeFileSync(temporary, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600 });
76
+ fs.renameSync(temporary, target);
77
+ }
78
+
79
+ function readOwner(lockPath) {
80
+ try {
81
+ return JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function lockAgeMs(lockPath, owner, now) {
88
+ const recorded = Date.parse(owner?.acquiredAt || '');
89
+ if (Number.isFinite(recorded)) return Math.max(0, now.getTime() - recorded);
90
+ try {
91
+ return Math.max(0, now.getTime() - fs.statSync(lockPath).mtimeMs);
92
+ } catch {
93
+ return Number.POSITIVE_INFINITY;
94
+ }
95
+ }
96
+
97
+ function processIsAlive(pid) {
98
+ if (!Number.isInteger(pid) || pid <= 0) return false;
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ } catch (error) {
103
+ return error.code !== 'ESRCH';
104
+ }
105
+ }
106
+
107
+ function releaseOwnedLock(lockPath, owner) {
108
+ const recorded = readOwner(lockPath);
109
+ if (!recorded || recorded.nonce !== owner.nonce) return;
110
+ try { fs.unlinkSync(path.join(lockPath, 'owner.json')); } catch (error) {
111
+ if (error.code !== 'ENOENT') return;
112
+ }
113
+ try { fs.rmdirSync(lockPath); } catch { /* a replacement owner wins */ }
114
+ }
115
+
116
+ function positiveNumber(value, fallback) {
117
+ const number = Number(value);
118
+ return Number.isFinite(number) && number > 0 ? number : fallback;
119
+ }
120
+
121
+ function lockError(options, message) {
122
+ return typeof options.errorFactory === 'function'
123
+ ? options.errorFactory(message)
124
+ : Object.assign(new Error(message), { code: 'THUMBGATE_LEDGER_BUSY' });
125
+ }
126
+
127
+ module.exports = {
128
+ DEFAULT_STALE_MS,
129
+ withFileLedgerLock,
130
+ };