thumbgate 1.27.19 → 1.28.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 (116) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +1 -1
  9. package/.well-known/mcp/server-card.json +1 -1
  10. package/README.md +63 -45
  11. package/adapters/claude/.mcp.json +2 -2
  12. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +1 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +52 -9
  21. package/bin/dashboard-cli.js +7 -0
  22. package/bin/postinstall.js +14 -23
  23. package/commands/dashboard.md +15 -0
  24. package/commands/thumbgate-dashboard.md +15 -0
  25. package/config/entitlement-public-keys.json +6 -0
  26. package/config/gates/default.json +1 -1
  27. package/config/github-about.json +2 -2
  28. package/config/merge-quality-checks.json +3 -0
  29. package/package.json +233 -100
  30. package/public/about.html +159 -0
  31. package/public/agent-manager.html +179 -0
  32. package/public/agents-cost-savings.html +153 -0
  33. package/public/ai-malpractice-prevention.html +818 -0
  34. package/public/assets/brand/github-social-preview.png +0 -0
  35. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  36. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  37. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  38. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  39. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  40. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  41. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  42. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  43. package/public/assets/brand/thumbgate-mark.svg +21 -0
  44. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  45. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  46. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  47. package/public/assets/legal-intake-control-flow.svg +66 -0
  48. package/public/blog.html +12 -9
  49. package/public/brand/thumbgate-mark.svg +19 -0
  50. package/public/brand/thumbgate-og.svg +16 -0
  51. package/public/chatgpt-app.html +330 -0
  52. package/public/codex-enterprise.html +123 -0
  53. package/public/codex-plugin.html +5 -5
  54. package/public/compare.html +10 -10
  55. package/public/dashboard.html +1 -1
  56. package/public/diagnostic.html +345 -0
  57. package/public/federal.html +2 -2
  58. package/public/guide.html +14 -14
  59. package/public/index.html +81 -70
  60. package/public/install.html +193 -0
  61. package/public/js/buyer-intent.js +672 -0
  62. package/public/learn.html +4 -17
  63. package/public/numbers.html +2 -2
  64. package/public/pricing.html +392 -0
  65. package/public/pro.html +2 -2
  66. package/scripts/action-receipts.js +324 -0
  67. package/scripts/activation-quickstart.js +187 -0
  68. package/scripts/agent-operations-planner.js +621 -0
  69. package/scripts/agent-reward-model.js +13 -0
  70. package/scripts/ai-component-inventory.js +367 -0
  71. package/scripts/async-eval-observability.js +236 -0
  72. package/scripts/audit.js +65 -0
  73. package/scripts/aws-blocks-guardrails.js +272 -0
  74. package/scripts/bayes-optimal-gate.js +6 -1
  75. package/scripts/classifier-routing.js +130 -0
  76. package/scripts/cli-feedback.js +17 -1
  77. package/scripts/commercial-offer.js +3 -3
  78. package/scripts/dashboard-chat.js +332 -0
  79. package/scripts/entitlement.js +250 -0
  80. package/scripts/export-databricks-bundle.js +5 -0
  81. package/scripts/export-dpo-pairs.js +6 -0
  82. package/scripts/export-hf-dataset.js +5 -0
  83. package/scripts/feedback-aggregate.js +281 -0
  84. package/scripts/feedback-sanitizer.js +105 -0
  85. package/scripts/gates-engine.js +15 -3
  86. package/scripts/hook-stop-anti-claim.js +301 -0
  87. package/scripts/imperative-detector.js +85 -0
  88. package/scripts/install-shim.js +87 -0
  89. package/scripts/intervention-policy.js +13 -0
  90. package/scripts/mcp-oauth.js +293 -0
  91. package/scripts/noop-detect.js +285 -0
  92. package/scripts/parallel-workflow-orchestrator.js +293 -0
  93. package/scripts/plan-gate.js +243 -0
  94. package/scripts/plausible-domain-config.js +99 -0
  95. package/scripts/pr-manager.js +9 -22
  96. package/scripts/pro-local-dashboard.js +198 -0
  97. package/scripts/qa-scenario-planner.js +136 -0
  98. package/scripts/repeat-metric.js +137 -0
  99. package/scripts/risk-scorer.js +6 -0
  100. package/scripts/secret-fixture-tokens.js +61 -0
  101. package/scripts/secret-redaction.js +166 -0
  102. package/scripts/self-harness-optimizer.js +141 -0
  103. package/scripts/self-healing-check.js +193 -0
  104. package/scripts/self-protection.js +90 -0
  105. package/scripts/seo-gsd.js +2 -2
  106. package/scripts/silent-failure-cluster.js +531 -0
  107. package/scripts/statusline-cache-read.js +57 -0
  108. package/scripts/sync-telemetry-from-prod.js +374 -0
  109. package/scripts/thompson-sampling.js +11 -2
  110. package/scripts/tool-contract-validator.js +76 -0
  111. package/scripts/trajectory-scorer.js +63 -0
  112. package/scripts/verify-marketing-pages-deployed.js +212 -0
  113. package/scripts/visitor-journey.js +172 -0
  114. package/src/api/server.js +81 -10
  115. package/.claude-plugin/marketplace.json +0 -85
  116. package/adapters/chatgpt/openapi.yaml +0 -1707
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Stop hook: anti-claim enforcement.
6
+ *
7
+ * Scans the assistant's most recent turn (assistant text + same-turn tool_use
8
+ * blocks) and blocks the "deployed / live / done / fixed / ready" claim
9
+ * unless that same turn included a proof tool call (curl / grep / test).
10
+ *
11
+ * Why: CLAUDE.md anti-lying directive ("Never claim fix done until
12
+ * committed+pushed. Never claim 'ready' without running e2e.") was
13
+ * aspirational, not enforced. Per CEO 2026-05-13 feedback after a session in
14
+ * which 5+ unverified claims slipped through, this is the harness-level
15
+ * enforcement that ends the recurring trust-burn pattern. ThumbGate-on-
16
+ * ThumbGate dogfood — we are the prevention-rule generator and a perfect
17
+ * customer for our own gate.
18
+ *
19
+ * Wires through .claude/settings.json Stop hooks list. Always exits 0
20
+ * (informational): the goal is to surface a system reminder in the next
21
+ * turn so the agent corrects mid-conversation rather than to hard-block
22
+ * the turn that already happened.
23
+ *
24
+ * Stdin: Claude Code passes the hook payload as JSON on stdin. We read
25
+ * `transcript_path` to locate the JSONL session log and scan the last
26
+ * assistant message.
27
+ *
28
+ * Stdout: any text printed is shown to the agent on the next turn.
29
+ */
30
+
31
+ const fs = require('node:fs');
32
+
33
+ // Lie-phrase patterns. These match common "claim of completion" wording
34
+ // the agent emits without verification. Word-boundary anchored to avoid
35
+ // false positives ("ready-made", "live-streaming", etc).
36
+ const CLAIM_PATTERNS = [
37
+ /\bis\s+live\b/i,
38
+ /\bnow\s+live\b/i,
39
+ /\bgoing\s+live\b/i,
40
+ /\bdeployed\b(?!\s*(yet|to\s+staging|on\s+a\s+branch))/i,
41
+ /\b(?:is|are|it'?s)\s+(?:now\s+)?(?:fully\s+)?(?:fixed|resolved|merged|shipped)\b/i,
42
+ /\bproduction[-\s]ready\b/i,
43
+ /\beverything\s+(?:is\s+)?(?:done|working|ready)\b/i,
44
+ /\b(?:github|repo|repository)\s+(?:about|metadata|description|topics?)\b.*\b(?:updated|verified|fixed|match(?:es|ed)?)\b/i,
45
+ /\b(?:about|metadata|description|topics?)\b.*\b(?:updated|verified|fixed|match(?:es|ed)?)\b.*\b(?:github|repo|repository)\b/i,
46
+ // Added 2026-06-11 after a cross-project failure analysis: these completion
47
+ // claims ("all green / stable / verified / race over / tests pass") were
48
+ // asserted without proof and slipped past the original set. The proof-gate
49
+ // below suppresses them whenever the SAME turn ran a verification tool, so a
50
+ // "verified" claim backed by a test/curl/Read stays silent.
51
+ /\b(?:all\s+)?(?:tests?|checks?|ci)\s+(?:are\s+)?(?:now\s+)?passing\b/i,
52
+ /\ball\s+(?:tests?|checks?)\s+pass(?:ed)?\b/i,
53
+ /\bverified\b/i,
54
+ /\bconfirmed\b/i,
55
+ /\b(?:is|are|it'?s|now)\s+stable\b/i,
56
+ /\ball\s+clear\b/i,
57
+ /\bgood\s+to\s+go\b/i,
58
+ /\brace\s+(?:is\s+)?over\b/i,
59
+ /\bno\s+longer\s+racing\b/i,
60
+ ];
61
+
62
+ // Proof-of-verification patterns. If the SAME turn included one of these
63
+ // tool calls or shell command tokens, the claim is considered backed and
64
+ // the hook stays silent.
65
+ const PROOF_PATTERNS = [
66
+ /\bcurl\b/,
67
+ /\bgh\s+pr\s+(?:view|checks|status)\b/,
68
+ /\bgh\s+run\s+view\b/,
69
+ /\bgh\s+api\b/,
70
+ /\bnode\s+--test\b/,
71
+ /\bnpm\s+(?:run\s+)?test\b/,
72
+ /\bnpm\s+pack\b/,
73
+ /\bjest\b/,
74
+ /\bmocha\b/,
75
+ /\bpytest\b/,
76
+ /\bplaywright\b/,
77
+ /\bgrep\b/,
78
+ /\bstripe\b/,
79
+ /\bplaid\b/,
80
+ /\bshopify\b/,
81
+ /\bsquare\b/,
82
+ /\bquickbooks\b/,
83
+ /Read\s*\(/, // Claude Code Read tool call
84
+ /Bash\s*\(/, // Claude Code Bash tool call
85
+ ];
86
+
87
+ const COMMERCIAL_CLAIM_SUBJECTS = [
88
+ 'money',
89
+ 'payment',
90
+ 'charge',
91
+ 'checkout',
92
+ 'revenue',
93
+ 'price',
94
+ 'pricing',
95
+ 'invoice',
96
+ 'billing',
97
+ 'tax',
98
+ 'sales tax',
99
+ 'inventory',
100
+ 'stock',
101
+ 'permission',
102
+ 'access',
103
+ 'customer facing',
104
+ ];
105
+
106
+ const COMMERCIAL_CLAIM_STATES = [
107
+ 'correct',
108
+ 'accurate',
109
+ 'verified',
110
+ 'valid',
111
+ 'matches',
112
+ 'working',
113
+ 'fixed',
114
+ 'resolved',
115
+ 'calculated',
116
+ 'configured',
117
+ ];
118
+
119
+ const GREEN_CLAIM_PHRASES = [
120
+ 'all green',
121
+ 'all tests green',
122
+ 'all checks green',
123
+ 'all the tests green',
124
+ 'all the checks green',
125
+ 'all tests are green',
126
+ 'all checks are green',
127
+ ];
128
+
129
+ function normalizeClaimText(text) {
130
+ return String(text || '')
131
+ .toLowerCase()
132
+ .replace(/[-_]+/g, ' ')
133
+ .replace(/[^a-z0-9]+/g, ' ')
134
+ .replace(/\s+/g, ' ')
135
+ .trim();
136
+ }
137
+
138
+ function containsPhrase(normalizedText, phrase) {
139
+ return ` ${normalizedText} `.includes(` ${phrase} `);
140
+ }
141
+
142
+ function findTokenListClaim(text, subjects, states, label) {
143
+ const normalized = normalizeClaimText(text);
144
+ if (!normalized) return null;
145
+ const subject = subjects.find((candidate) => containsPhrase(normalized, candidate));
146
+ const state = states.find((candidate) => containsPhrase(normalized, candidate));
147
+ return subject && state ? `${label}: ${subject} ${state}` : null;
148
+ }
149
+
150
+ function findGreenClaim(text) {
151
+ const normalized = normalizeClaimText(text);
152
+ return GREEN_CLAIM_PHRASES.find((phrase) => containsPhrase(normalized, phrase)) || null;
153
+ }
154
+
155
+ function readLastAssistantTurn(transcriptPath) {
156
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
157
+ let content;
158
+ try {
159
+ content = fs.readFileSync(transcriptPath, 'utf8');
160
+ } catch {
161
+ return null;
162
+ }
163
+ const lines = content.trim().split('\n');
164
+ // Walk backwards to find the last assistant message
165
+ for (let i = lines.length - 1; i >= 0; i--) {
166
+ const raw = lines[i].trim();
167
+ if (!raw) continue;
168
+ let entry;
169
+ try {
170
+ entry = JSON.parse(raw);
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (entry.type === 'assistant' && entry.message) {
175
+ return entry.message;
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function extractText(message) {
182
+ if (!message || !Array.isArray(message.content)) return '';
183
+ return message.content
184
+ .filter((b) => b && typeof b.text === 'string')
185
+ .map((b) => b.text)
186
+ .join('\n');
187
+ }
188
+
189
+ function extractToolUseSummary(message) {
190
+ if (!message || !Array.isArray(message.content)) return '';
191
+ return message.content
192
+ .filter((b) => b?.type === 'tool_use')
193
+ .map((b) => {
194
+ const name = b.name || 'tool';
195
+ let summary = '';
196
+ if (b.input && typeof b.input === 'object') {
197
+ if (typeof b.input.command === 'string') summary = b.input.command;
198
+ else if (typeof b.input.file_path === 'string') summary = b.input.file_path;
199
+ else if (typeof b.input.query === 'string') summary = b.input.query;
200
+ else summary = JSON.stringify(b.input).slice(0, 200);
201
+ }
202
+ return `${name}: ${summary}`;
203
+ })
204
+ .join('\n');
205
+ }
206
+
207
+ function findClaim(text) {
208
+ const commercialClaim = findTokenListClaim(
209
+ text,
210
+ COMMERCIAL_CLAIM_SUBJECTS,
211
+ COMMERCIAL_CLAIM_STATES,
212
+ 'commercial truth',
213
+ );
214
+ if (commercialClaim) return commercialClaim;
215
+ const greenClaim = findGreenClaim(text);
216
+ if (greenClaim) return greenClaim;
217
+ for (const p of CLAIM_PATTERNS) {
218
+ const m = text.match(p);
219
+ if (m) return m[0];
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function hasProof(combined) {
225
+ return PROOF_PATTERNS.some((p) => p.test(combined));
226
+ }
227
+
228
+ function readStdinSync() {
229
+ try {
230
+ return fs.readFileSync(0, 'utf8');
231
+ } catch {
232
+ return '';
233
+ }
234
+ }
235
+
236
+ function main() {
237
+ const raw = readStdinSync();
238
+ let payload = {};
239
+ try {
240
+ payload = raw ? JSON.parse(raw) : {};
241
+ } catch {
242
+ payload = {};
243
+ }
244
+
245
+ const transcriptPath = payload.transcript_path || process.env.CLAUDE_TRANSCRIPT_PATH;
246
+ const message = readLastAssistantTurn(transcriptPath);
247
+ if (!message) return; // no transcript visible; nothing to check
248
+
249
+ const text = extractText(message);
250
+ const toolUseSummary = extractToolUseSummary(message);
251
+ const claim = findClaim(text);
252
+ if (!claim) return; // no completion claim made; silent
253
+
254
+ const proofText = `${text}\n${toolUseSummary}`;
255
+ if (hasProof(proofText)) return; // claim backed by proof in same turn
256
+
257
+ // Strict mode (THUMBGATE_STRICT_ENFORCEMENT=1): hard-block the stop. Emit a
258
+ // Stop-hook block decision so Claude Code does NOT end the turn — the agent
259
+ // must run the verification (or retract) before it can stop. Default mode
260
+ // stays soft (a reminder for the next turn) so we don't break existing wiring.
261
+ if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') {
262
+ const reason = `ThumbGate anti-claim gate (strict): you claimed completion ("${claim}") without a proof tool call in the same message. Run the verification (curl / grep / test / Read) and restate with the proof, or retract — do not end the turn on an unverified claim.`;
263
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n');
264
+ return;
265
+ }
266
+
267
+ // Default (soft): surface a system reminder for the NEXT turn. Do not hard-block.
268
+ const reminder = [
269
+ '⚠️ ThumbGate anti-claim gate: previous turn claimed completion',
270
+ ` ("${claim}") without a proof tool call in the same message.`,
271
+ ' Per CLAUDE.md anti-lying: never claim "done / live / deployed / fixed /',
272
+ ' verified / all green / stable" or commercial truth (money / tax / inventory /',
273
+ ' permissions / customer-facing state) without curl / grep / test / source-of-truth output in the SAME turn.',
274
+ ' If the work really is verified, re-state the claim with the proof.',
275
+ ' If not, retract and run the verification before re-asserting.',
276
+ ].join('\n');
277
+ process.stdout.write(reminder + '\n');
278
+ }
279
+
280
+ // Path-resolve check instead of `require.main === module`. SonarCloud's
281
+ // strict type inference (rule S3403) flags the === form as always-false
282
+ // in CommonJS, and CLAUDE.md "Hard-Won Lessons" pins the path-based form
283
+ // as the portable fix (incident 2026-04-21 / PR #1115). Resolve BOTH sides
284
+ // so the comparison is between two normalized absolute paths.
285
+ const path = require('node:path');
286
+ if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
287
+ try {
288
+ main();
289
+ } catch {
290
+ // never crash the agent
291
+ }
292
+ }
293
+
294
+ module.exports = {
295
+ CLAIM_PATTERNS,
296
+ PROOF_PATTERNS,
297
+ findClaim,
298
+ hasProof,
299
+ extractText,
300
+ extractToolUseSummary,
301
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * imperative-detector.js — detect an explicit NEVER / ALWAYS directive in a
5
+ * feedback message so the CLI can OFFER immediate enforcement (force-gate) when
6
+ * the user clearly asked for it.
7
+ *
8
+ * Why this exists: auto-promotion is deliberately occurrence-gated (a rule
9
+ * blocks only after a pattern repeats) — that accumulate-evidence-before-you-
10
+ * enforce default is what makes ThumbGate trustworthy rather than a one-signal
11
+ * lockout. But when a user types "never do X again", that is an explicit intent
12
+ * to guard NOW. This module recognizes that intent and lets the CLI SURFACE the
13
+ * one-shot path (force-gate) as an offer. It never blocks on its own — the user
14
+ * still confirms. Detecting intent ≠ acting on it.
15
+ *
16
+ * Pure and deterministic: no state, no I/O.
17
+ */
18
+
19
+ // A leading (or clause-initial) negative imperative → intent to forbid.
20
+ const NEVER_RE = /(?:^|[.;:,]\s*)(never|do\s*not|don'?t|stop\s+\w|must\s*not|no\s+longer|quit\s+\w|avoid\s+\w)/i;
21
+ // A leading (or clause-initial) positive imperative → intent to always do.
22
+ const ALWAYS_RE = /(?:^|[.;:,]\s*)(always|make\s+sure|ensure\s+\w|be\s+sure\s+to)/i;
23
+
24
+ function firstMeaningfulText(text) {
25
+ // Strip common feedback prefixes/quote marks so "❯ never …" still matches.
26
+ return String(text || '').trim().replace(/^[\s>❯"'`*-]+/, '');
27
+ }
28
+
29
+ /**
30
+ * @param {string} text
31
+ * @returns {{isImperative:boolean, polarity:('never'|'always'|null), directive:string}}
32
+ */
33
+ function detectImperative(text) {
34
+ const t = firstMeaningfulText(text);
35
+ if (!t) return { isImperative: false, polarity: null, directive: '' };
36
+ // Negative wins if both appear — a forbid directive is the actionable one.
37
+ if (NEVER_RE.test(t)) return { isImperative: true, polarity: 'never', directive: t };
38
+ if (ALWAYS_RE.test(t)) return { isImperative: true, polarity: 'always', directive: t };
39
+ return { isImperative: false, polarity: null, directive: '' };
40
+ }
41
+
42
+ function shellQuote(text) {
43
+ return String(text).trim().slice(0, 140).replace(/"/g, "'").replace(/\s+/g, ' ');
44
+ }
45
+
46
+ /**
47
+ * Build a suggestion for the capture confirmation. Returns null when there's no
48
+ * explicit directive to act on.
49
+ * - down + "never …" → OFFER immediate force-gate (the user asked to forbid it).
50
+ * - up + "always …" → clarify it's stored as guidance, not a hard block.
51
+ * @param {{signal?:string, text?:string}} opts
52
+ * @returns {{kind:string, message:string}|null}
53
+ */
54
+ function suggestForceGate({ signal, text } = {}) {
55
+ const isDown = signal === 'down' || signal === 'negative' || signal === 'thumbs_down';
56
+ const det = detectImperative(text);
57
+ if (!det.isImperative) return null;
58
+
59
+ if (isDown && det.polarity === 'never') {
60
+ const ctx = shellQuote(text);
61
+ return {
62
+ kind: 'force-gate-offer',
63
+ message: 'You said "never" — block this immediately instead of waiting for it to recur:\n'
64
+ + ` npx thumbgate force-gate --context="${ctx}" --action=block\n`
65
+ + ' (or run /thumbgate-guard). Left alone, it auto-promotes to a gate after the pattern repeats.',
66
+ };
67
+ }
68
+
69
+ if (!isDown && det.polarity === 'always') {
70
+ return {
71
+ kind: 'always-note',
72
+ message: 'You said "always" — stored as an ALWAYS guidance principle. '
73
+ + 'It is surfaced as context on future actions (guidance, not a hard block; positive patterns are not gate-enforced).',
74
+ };
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ module.exports = {
81
+ detectImperative,
82
+ suggestForceGate,
83
+ NEVER_RE,
84
+ ALWAYS_RE,
85
+ };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * install-shim.js — Install a stable shim at ~/.thumbgate/bin/thumbgate-hook
5
+ *
6
+ * The shim is a tiny shell script that resolves the cached ThumbGate runtime
7
+ * first, so hook commands in settings.local.json stay stable across projects
8
+ * and agent restarts.
9
+ *
10
+ * The shim checks for a cached runtime binary first (fast path), and falls
11
+ * back to `npx --yes thumbgate@latest` (slow path, first-time self-install).
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+
18
+ const SHIM_DIR = path.join(os.homedir(), '.thumbgate', 'bin');
19
+ const SHIM_PATH = path.join(SHIM_DIR, 'thumbgate-hook');
20
+ const RUNTIME_BIN = path.join(os.homedir(), '.thumbgate', 'runtime', 'node_modules', '.bin', 'thumbgate');
21
+
22
+ /**
23
+ * The shim script. Key design choices:
24
+ * - Uses `exec` to replace the shell process (no zombie processes)
25
+ * - Fast path: if cached runtime binary exists, exec it directly
26
+ * - Slow path: npx --yes thumbgate@latest (first-time auto-installs)
27
+ * - No default self-mutation: background upgrades are opt-in via
28
+ * THUMBGATE_SHIM_AUTO_UPDATE=1 so source checkouts, enterprise pins, and
29
+ * dogfood runtimes cannot be overwritten by a hook side effect.
30
+ */
31
+ function shimContent() {
32
+ const escapedRuntimeBin = JSON.stringify(RUNTIME_BIN);
33
+ const escapedRuntimeDir = JSON.stringify(path.join(os.homedir(), '.thumbgate', 'runtime'));
34
+
35
+ return `#!/usr/bin/env bash
36
+ # ThumbGate hook shim — DO NOT EDIT
37
+ # Installed by: thumbgate init
38
+ # Purpose: stable hook entry point that runs the cached ThumbGate runtime
39
+ # Pattern: Volta-style stable shim (see https://volta.sh)
40
+
41
+ set -euo pipefail
42
+
43
+ RUNTIME_BIN=${escapedRuntimeBin}
44
+ RUNTIME_DIR=${escapedRuntimeDir}
45
+
46
+ # Fast path: cached runtime binary exists and is executable
47
+ if [ -x "$RUNTIME_BIN" ]; then
48
+ # Optional background upgrade. Disabled by default so hooks never mutate a
49
+ # source checkout, enterprise pin, or dogfood runtime behind the operator's back.
50
+ if [ "\${THUMBGATE_SHIM_AUTO_UPDATE:-0}" = "1" ]; then
51
+ ( nohup npm install --prefix "$RUNTIME_DIR" --no-save --omit=dev thumbgate@latest >/dev/null 2>&1 & ) 2>/dev/null || true
52
+ fi
53
+ exec "$RUNTIME_BIN" "$@"
54
+ fi
55
+
56
+ # Slow path: no cached binary — install + exec via npx
57
+ mkdir -p "$RUNTIME_DIR"
58
+ exec npx --yes --package thumbgate@latest -- thumbgate "$@"
59
+ `;
60
+ }
61
+
62
+ function installShim() {
63
+ fs.mkdirSync(SHIM_DIR, { recursive: true });
64
+ fs.writeFileSync(SHIM_PATH, shimContent(), { mode: 0o755 });
65
+ return SHIM_PATH;
66
+ }
67
+
68
+ function shimInstalled() {
69
+ try {
70
+ return fs.existsSync(SHIM_PATH) && (fs.statSync(SHIM_PATH).mode & 0o111) !== 0;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function shimPath() {
77
+ return SHIM_PATH;
78
+ }
79
+
80
+ module.exports = {
81
+ installShim,
82
+ shimInstalled,
83
+ shimPath,
84
+ shimContent,
85
+ SHIM_DIR,
86
+ SHIM_PATH,
87
+ };
@@ -5,6 +5,7 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { resolveFeedbackDir } = require('./feedback-paths');
7
7
  const { getDecisionLogPath, readDecisionLog, collapseDecisionTimeline } = require('./decision-journal');
8
+ const { requireLearnedModelsEntitlement } = require('./entitlement');
8
9
 
9
10
  const LABELS = ['allow', 'recall', 'verify', 'warn', 'deny'];
10
11
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -551,6 +552,10 @@ function summarizeTopTokens(model, limit = 5) {
551
552
  }
552
553
 
553
554
  function trainInterventionPolicy(examples, options = {}) {
555
+ requireLearnedModelsEntitlement({
556
+ ...(options.entitlement || {}),
557
+ label: 'intervention-policy training',
558
+ });
554
559
  const split = splitExamples(examples);
555
560
  const evaluationModel = fitNaiveBayes(split.train);
556
561
  const deployedModel = fitNaiveBayes(examples);
@@ -631,6 +636,10 @@ function buildRuntimeCandidate(params = {}) {
631
636
  }
632
637
 
633
638
  function getInterventionRecommendation(params = {}, options = {}) {
639
+ requireLearnedModelsEntitlement({
640
+ ...(options.entitlement || {}),
641
+ label: 'intervention-policy recommendation',
642
+ });
634
643
  const resolvedDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir || params.feedbackDir });
635
644
  let model = options.model || loadInterventionPolicy(resolvedDir);
636
645
  const candidate = options.candidate || buildRuntimeCandidate(params);
@@ -693,6 +702,10 @@ function computeDailySeries(examples, dayCount = 14) {
693
702
  }
694
703
 
695
704
  function getInterventionPolicySummary(feedbackDir, options = {}) {
705
+ requireLearnedModelsEntitlement({
706
+ ...(options.entitlement || {}),
707
+ label: 'intervention-policy summary',
708
+ });
696
709
  const resolvedDir = resolveFeedbackDir({ feedbackDir });
697
710
  const { examples, sourceCounts } = buildExamplesFromFeedbackDir(resolvedDir);
698
711
  const model = loadInterventionPolicy(resolvedDir) || trainInterventionPolicy(examples);