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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +40 -3
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +21 -0
- package/config/gates/claim-verifiers.example.json +42 -0
- package/config/gates/claim-verifiers.json +25 -0
- package/config/mcp-allowlists.json +21 -0
- package/hooks/hooks.json +1 -1
- package/package.json +14 -9
- package/public/index.html +2 -2
- package/public/numbers.html +14 -15
- package/scripts/agent-readiness.js +110 -0
- package/scripts/auto-wire-hooks.js +20 -8
- package/scripts/cli-schema.js +14 -0
- package/scripts/feedback-schema.js +3 -0
- package/scripts/file-ledger-lock.js +130 -0
- package/scripts/financial-control-plane.js +1600 -0
- package/scripts/gates-engine.js +186 -7
- package/scripts/gemini-embedding-policy.js +1 -0
- package/scripts/hook-runtime.js +5 -0
- package/scripts/hook-stop-anti-claim.js +63 -3
- package/scripts/human-escalation.js +353 -41
- package/scripts/provider-action-normalizer.js +11 -4
- package/scripts/tool-registry.js +95 -5
- package/scripts/universal-claim-evaluator.js +767 -0
- package/scripts/vector-store.js +60 -27
- package/scripts/workflow-sentinel.js +77 -11
- package/server.json +2 -2
- package/src/api/server.js +2 -0
package/public/numbers.html
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
29
|
-
"url": "https://thumbgate.
|
|
30
|
-
"dateModified": "2026-
|
|
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.
|
|
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-
|
|
61
|
-
"datePublished": "2026-
|
|
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":
|
|
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-
|
|
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">
|
|
214
|
-
<div class="stat-sub">
|
|
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-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
483
|
+
const existingMatch = sectionRegex.exec(current);
|
|
484
|
+
let next;
|
|
474
485
|
|
|
475
|
-
if (
|
|
476
|
-
const existingBlock =
|
|
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: '
|
|
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: '
|
|
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
|
};
|
package/scripts/cli-schema.js
CHANGED
|
@@ -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
|
+
};
|