thumbgate 1.35.0 → 1.37.2

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 (204) hide show
  1. package/.agents/skills/cobble-hot-store-compare-not-clone/SKILL.md +80 -0
  2. package/.agents/skills/colab-compute-honesty-not-clone/SKILL.md +70 -0
  3. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  4. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  5. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  6. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  7. package/.agents/skills/token-shunt-honesty-not-clone/SKILL.md +67 -0
  8. package/.agents/skills/typesafe-typed-questions-not-clone/SKILL.md +82 -0
  9. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  10. package/.claude-plugin/plugin.json +1 -1
  11. package/.well-known/llms.txt +1 -0
  12. package/.well-known/mcp/server-card.json +1 -1
  13. package/CONTRIBUTING.md +95 -0
  14. package/README.md +195 -632
  15. package/THIRD_PARTY_NOTICES.md +89 -0
  16. package/adapters/claude/.mcp.json +2 -2
  17. package/adapters/forge/forge.yaml +3 -3
  18. package/adapters/future-agi/.mcp.json +8 -0
  19. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  20. package/adapters/future-agi/config.toml +3 -0
  21. package/adapters/future-agi/future-agi-bridge.js +9 -0
  22. package/adapters/future-agi/opencode.json +8 -0
  23. package/adapters/herdr/herdr-plugin.toml +18 -0
  24. package/adapters/mcp/server-stdio.js +238 -25
  25. package/adapters/opencode/opencode.json +1 -1
  26. package/adapters/workos/WORKOS.md +52 -0
  27. package/bin/cli.js +465 -3
  28. package/bin/futureagi-bridge +9 -0
  29. package/config/gate-templates.json +629 -4
  30. package/config/gates/actor-critic-audit.json +34 -0
  31. package/config/gates/default.json +9 -3
  32. package/config/gates/five-walls-governance.json +34 -0
  33. package/config/gates/future-agi-guardrails.json +34 -0
  34. package/config/gates/radware-threat-defense-2026.json +61 -0
  35. package/config/gates/simatree-data-governance.json +33 -0
  36. package/config/mcp-allowlists.json +4 -0
  37. package/config/merge-quality-checks.json +6 -0
  38. package/config/model-candidates.json +312 -29
  39. package/config/model-tiers.json +18 -0
  40. package/config/post-deploy-marketing-pages.json +10 -0
  41. package/config/progressive/01-wire-only.json +11 -0
  42. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  43. package/config/progressive/03-one-lesson.json +10 -0
  44. package/config/progressive/04-warn-fires.json +11 -0
  45. package/config/progressive/05-strict-optional.json +11 -0
  46. package/config/progressive/README.md +15 -0
  47. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  48. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  49. package/conformance/provider-attestation/vectors.json +320 -0
  50. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  51. package/openapi/openapi.yaml +15 -0
  52. package/package.json +408 -150
  53. package/public/about.html +2 -2
  54. package/public/ai-malpractice-prevention.html +7 -7
  55. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  56. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  57. package/public/blog/git-at-agent-scale.html +374 -0
  58. package/public/blog/no-llm-in-the-gate.html +133 -0
  59. package/public/blog.html +80 -0
  60. package/public/case-studies.html +16 -1
  61. package/public/compare.html +28 -0
  62. package/public/diagnostic.html +216 -7
  63. package/public/docs/connectors.html +39 -0
  64. package/public/federal.html +2 -2
  65. package/public/founders.html +639 -0
  66. package/public/index.html +87 -9
  67. package/public/install.html +8 -8
  68. package/public/learn.html +39 -0
  69. package/public/numbers.html +2 -2
  70. package/public/peter.html +310 -0
  71. package/public/platform-partners.html +119 -0
  72. package/public/pricing.html +24 -3
  73. package/public/privacy.html +117 -0
  74. package/public/pro.html +17 -0
  75. package/public/support.html +62 -0
  76. package/public/terms.html +130 -0
  77. package/public/third-party-notices.html +95 -0
  78. package/public/yt.html +351 -0
  79. package/scripts/action-receipts.js +133 -3
  80. package/scripts/adaptive-governance-arena.js +349 -0
  81. package/scripts/admin-override.js +205 -0
  82. package/scripts/agent-action-inventory.js +869 -0
  83. package/scripts/agent-audit-trace.js +42 -2
  84. package/scripts/agent-egress-policy.js +1117 -0
  85. package/scripts/agent-memory-lifecycle.js +141 -2
  86. package/scripts/agent-operations-planner.js +441 -1
  87. package/scripts/agent-readiness.js +68 -0
  88. package/scripts/agent-security-central.js +647 -0
  89. package/scripts/allowlist-bridge-honesty.js +417 -0
  90. package/scripts/async-job-runner.js +102 -11
  91. package/scripts/audit-trail.js +212 -0
  92. package/scripts/billing.js +1 -1
  93. package/scripts/broker-execution-receipts.js +719 -0
  94. package/scripts/budget-aware-gates-proof.js +423 -0
  95. package/scripts/claude-feedback-sync.js +29 -3
  96. package/scripts/claw-harness-production.js +237 -0
  97. package/scripts/cli-schema.js +203 -1
  98. package/scripts/cobble-hot-store-split.js +600 -0
  99. package/scripts/codex-runbook-flywheel.js +318 -0
  100. package/scripts/colab-compute-honesty.js +281 -0
  101. package/scripts/context-footprint.js +186 -0
  102. package/scripts/contextfs.js +143 -61
  103. package/scripts/dashboard.js +251 -32
  104. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  105. package/scripts/docker-sandbox-planner.js +18 -0
  106. package/scripts/double-blind-eval-protocol.js +252 -0
  107. package/scripts/edotenv-rl-gateway.js +259 -0
  108. package/scripts/ensure-production-search-corpus.js +162 -0
  109. package/scripts/eval-holdout.js +311 -0
  110. package/scripts/feedback-aggregate.js +21 -2
  111. package/scripts/feedback-loop.js +87 -5
  112. package/scripts/feedback-quality.js +9 -0
  113. package/scripts/file-ledger-lock.js +4 -1
  114. package/scripts/financial-control-plane.js +41 -1
  115. package/scripts/find-dormant-requires.js +118 -0
  116. package/scripts/fs-utils.js +84 -8
  117. package/scripts/gates-engine.js +826 -68
  118. package/scripts/generate-case-study-outreach.js +24 -15
  119. package/scripts/git-at-scale.js +628 -0
  120. package/scripts/governance-conflict-audit.js +1650 -0
  121. package/scripts/governance-difficulty-curriculum.js +328 -0
  122. package/scripts/graphrag-retrieval.js +275 -0
  123. package/scripts/gurobi-optimizer.js +324 -0
  124. package/scripts/gurobi_optimizer.py +485 -0
  125. package/scripts/harness-selector.js +82 -1
  126. package/scripts/hidden-entry-points.js +284 -0
  127. package/scripts/human-escalation.js +199 -1
  128. package/scripts/hybrid-feedback-context.js +152 -19
  129. package/scripts/intent-governed-execution.js +602 -0
  130. package/scripts/intervention-policy.js +123 -20
  131. package/scripts/jit-harness-compose.js +628 -0
  132. package/scripts/jsonl-watcher.js +10 -0
  133. package/scripts/lesson-embedding-index.js +95 -12
  134. package/scripts/lesson-retrieval.js +105 -19
  135. package/scripts/local-model-profile.js +19 -2
  136. package/scripts/mailer/resend-mailer.js +1 -1
  137. package/scripts/matryoshka-embedding.js +235 -0
  138. package/scripts/mcp-oauth.js +42 -4
  139. package/scripts/mcp-session-handles.js +1016 -0
  140. package/scripts/mcp-wiring-doctor.js +314 -0
  141. package/scripts/memory-firewall.js +115 -2
  142. package/scripts/memory-scope-readiness.js +299 -0
  143. package/scripts/memory-vs-rag-route.js +161 -0
  144. package/scripts/model-tier-router.js +148 -21
  145. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  146. package/scripts/openui-catalog-compose-honesty.js +593 -0
  147. package/scripts/operational-integrity.js +19 -1
  148. package/scripts/override-audit.js +213 -0
  149. package/scripts/package-manager-honesty-doctor.js +458 -0
  150. package/scripts/pr-manager.js +63 -1
  151. package/scripts/prove-herdr-adapter.js +52 -0
  152. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  153. package/scripts/prove-workos.js +73 -0
  154. package/scripts/provider-attestation-conformance.js +192 -0
  155. package/scripts/provider-receipt-contract.js +136 -0
  156. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  157. package/scripts/radware-threat-defense.js +280 -0
  158. package/scripts/rag-embedding-identity.js +221 -0
  159. package/scripts/rag-precision-guardrails.js +112 -2
  160. package/scripts/remote-feedback-capture.js +159 -0
  161. package/scripts/research-agent-harness.js +256 -0
  162. package/scripts/rsi-safety-hillclimb.js +200 -0
  163. package/scripts/rule-sprawl.js +188 -0
  164. package/scripts/schedule-manager.js +147 -0
  165. package/scripts/self-heal.js +8 -0
  166. package/scripts/session-lease.js +415 -0
  167. package/scripts/simatree-data-governance.js +347 -0
  168. package/scripts/slo-alert-engine.js +172 -7
  169. package/scripts/solver-parity.js +539 -0
  170. package/scripts/stealth-memory-injection-gate.js +333 -0
  171. package/scripts/switchyard-router.js +366 -0
  172. package/scripts/telemetry-analytics.js +84 -27
  173. package/scripts/temporal-decay-weighting.js +138 -0
  174. package/scripts/test-all.js +165 -0
  175. package/scripts/token-savings.js +42 -0
  176. package/scripts/token-shunt-honesty.js +502 -0
  177. package/scripts/tool-kpi-tracker.js +108 -5
  178. package/scripts/tool-registry.js +193 -5
  179. package/scripts/typesafe-typed-questions.js +813 -0
  180. package/scripts/universal-claim-evaluator.js +14 -2
  181. package/scripts/vector-store.js +279 -9
  182. package/scripts/workflow-notebook.js +391 -0
  183. package/scripts/workflow-sentinel.js +111 -12
  184. package/scripts/workos-production-guard.js +260 -0
  185. package/scripts/workspace-search-route.js +515 -0
  186. package/server.json +2 -2
  187. package/src/agent-identity-boundary.js +76 -0
  188. package/src/agent-retrieval-cache.js +155 -0
  189. package/src/alert-noise-ledger.js +502 -0
  190. package/src/api/server.js +724 -153
  191. package/src/git-fast-cache.js +220 -0
  192. package/src/git-wal-sync.js +156 -0
  193. package/src/hash-anchored-edit.js +82 -0
  194. package/src/hermes-platform-protocol.js +475 -0
  195. package/src/hermes-sync-plane.js +241 -0
  196. package/src/index.js +30 -1
  197. package/src/iso42001-compliance-guard.js +97 -0
  198. package/src/latency-budget.js +244 -0
  199. package/src/mcp-writeguard.js +316 -0
  200. package/src/miminions-adapter.js +106 -0
  201. package/src/pipeline-compass.js +104 -0
  202. package/src/ppl-alert-pipeline.js +284 -0
  203. package/src/rendezvous-router.js +90 -0
  204. package/src/security-questionnaire.js +195 -0
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Spotify Portal / shunt FORMAT steal — not a product clone.
6
+ *
7
+ * Sources:
8
+ * https://github.com/spotify/portal-ai-plugins
9
+ * GitHub App install 162279530 (Igor Spotify Portal) — catalog connector, not cash
10
+ *
11
+ * Transfers:
12
+ * 1. Untargeted Read above 350 lines is blocked (SHUNT_MIN_LINES)
13
+ * 2. Bare cat/head/tail/less/more of a large file is blocked; pipes to grep/rg pass
14
+ * 3. Return a slice to the frontier model; do not dump the whole file back
15
+ * 4. Do not delegate architecture/debug to Portal/AiKA/Gemini Flash
16
+ *
17
+ * Does NOT install shunt@portal, buy Spotify Portal, or sell ThumbGate through it.
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+
23
+ const SOURCE_URLS = Object.freeze([
24
+ 'https://github.com/spotify/portal-ai-plugins',
25
+ 'https://github.com/settings/installations/162279530',
26
+ ]);
27
+
28
+ const DEFAULT_MIN_LINES = 350;
29
+ const DEFAULT_SLICE_LINES = 80;
30
+ const REASONING_KINDS = Object.freeze(['reasoning', 'debug', 'architecture', 'safety']);
31
+ const DELEGABLE_KINDS = Object.freeze(['boilerplate', 'config', 'test_scaffold', 'large_read']);
32
+ const METERED_WORKERS = Object.freeze([
33
+ 'gemini-2.5-flash', 'gemini_2_5_flash', 'gemini_flash', 'portal', 'aika',
34
+ ]);
35
+ const FRONTIER_WORKERS = Object.freeze([
36
+ 'hermes_main', 'hermes-main', 'grok', 'grok_4', 'grok_4_6', 'frontier',
37
+ ]);
38
+
39
+ const CLONE_PATTERNS = Object.freeze([
40
+ { id: 'portal_plugin', re: /\b(install|vendor|clone)\b.{0,40}\b(shunt@portal|@spotify\/portal-cli|portal-ai-plugins)\b/i },
41
+ { id: 'portal_sku', re: /\b(buy|subscribe|checkout)\b.{0,40}\bspotify portal\b/i },
42
+ ]);
43
+
44
+ const PIPE_TARGET_RE = /\|\s*(grep|rg|awk|sed)\b/i;
45
+ const HEAD_LIMIT_RE = /\b(?:head|tail)\b.*(?:-n|--lines)\b/i;
46
+ const BARE_READER_RE = /^(?:cat|head|tail|less|more)\b/i;
47
+
48
+ function normalizeBoolean(value) {
49
+ if (value === true || value === 1) return true;
50
+ if (value === false || value === 0 || value == null) return false;
51
+ return /^(1|true|yes|on)$/i.test(String(value).trim());
52
+ }
53
+
54
+ function norm(value) {
55
+ return String(value || '').trim().toLowerCase().replace(/[.-]/g, '_');
56
+ }
57
+
58
+ function detectCloneAttempt(text) {
59
+ return CLONE_PATTERNS.filter((p) => p.re.test(String(text || ''))).map((p) => p.id);
60
+ }
61
+
62
+ function evaluateReadIntercept({ lineCount, targeted, threshold = DEFAULT_MIN_LINES } = {}) {
63
+ const lines = Number(lineCount) || 0;
64
+ const limit = Number(threshold) || DEFAULT_MIN_LINES;
65
+ if (lines > limit && !targeted) {
66
+ return {
67
+ action: 'block_full_read',
68
+ ok: false,
69
+ id: 'untargeted_bulk_read',
70
+ reason: `Untargeted read of ${lines} lines > ${limit}. Slice with offset/limit (default ${DEFAULT_SLICE_LINES}) instead of dumping the file into the frontier model.`,
71
+ };
72
+ }
73
+ return {
74
+ action: 'allow_read',
75
+ ok: true,
76
+ id: 'allow_read',
77
+ reason: 'Small file or targeted offset/limit.',
78
+ };
79
+ }
80
+
81
+ function countFileLines(filePath) {
82
+ if (!filePath) return null;
83
+ try {
84
+ const text = fs.readFileSync(filePath, 'utf8');
85
+ if (!text) return 0;
86
+ return text.split(/\r?\n/).length;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function stripWrappers(command) {
93
+ let cmd = String(command || '').trim();
94
+ cmd = cmd.replace(/^(?:env\s+)+/i, '');
95
+ cmd = cmd.replace(/^(?:\/(?:usr\/)?bin\/)/, '');
96
+ return cmd.trim();
97
+ }
98
+
99
+ function splitCompound(command) {
100
+ return String(command).split(/\s*(?:\|\||&&|;)\s*/).map((s) => s.trim()).filter(Boolean);
101
+ }
102
+
103
+ function parseHeadTailLimit(stage) {
104
+ const m = String(stage).match(/\b(?:head|tail)\b(?:\s+-n\s*|\s+--lines(?:=|\s+))(\d+)/i);
105
+ return m ? Number(m[1]) : null;
106
+ }
107
+
108
+ function extractReaderPath(stage) {
109
+ const tokens = String(stage).trim().split(/\s+/).filter((t) => !t.startsWith('-'));
110
+ if (tokens.length < 2) return null;
111
+ return tokens[tokens.length - 1];
112
+ }
113
+
114
+ function evaluateBashRead({ command, lineCount, cwd = process.cwd(), threshold = DEFAULT_MIN_LINES } = {}) {
115
+ const raw = String(command || '').trim();
116
+ const limit = Number(threshold) || DEFAULT_MIN_LINES;
117
+ if (!raw) {
118
+ return { action: 'skip_bash', ok: true, id: 'skip_bash', reason: 'No bash command.' };
119
+ }
120
+ const compounds = splitCompound(raw);
121
+ for (const part of compounds) {
122
+ const stages = part.split('|').map((s) => stripWrappers(s)).filter(Boolean);
123
+ const last = stages[stages.length - 1] || '';
124
+ const first = stages[0] || '';
125
+ const headCount = parseHeadTailLimit(first) ?? parseHeadTailLimit(last);
126
+ if (headCount != null) {
127
+ if (headCount > limit) {
128
+ return {
129
+ action: 'block_bare_reader',
130
+ ok: false,
131
+ id: 'bare_bulk_cat',
132
+ reason: `head/tail -n ${headCount} exceeds ${limit}-line slice. Cap --lines at the threshold.`,
133
+ };
134
+ }
135
+ continue;
136
+ }
137
+ const lastIsBoundedFilter = /^(?:grep|rg)\b/i.test(last) && stages.length > 1;
138
+ if (lastIsBoundedFilter) continue;
139
+ const reader = first.match(/^(?:cat|head|tail|less|more)\b/i);
140
+ if (reader) {
141
+ let lines = Number.isFinite(Number(lineCount)) ? Number(lineCount) : null;
142
+ if (lines == null) {
143
+ const fileArg = extractReaderPath(first);
144
+ lines = countFileLines(fileArg ? path.resolve(cwd, fileArg) : null);
145
+ }
146
+ if (lines == null) {
147
+ return {
148
+ action: 'warn_unknown_size',
149
+ ok: false,
150
+ id: 'unknown_size',
151
+ reason: 'Bare reader without --lines and without a readable file. Do not invent a size; pass --lines or a path.',
152
+ };
153
+ }
154
+ if (lines > limit) {
155
+ return {
156
+ action: 'block_bare_reader',
157
+ ok: false,
158
+ id: 'bare_bulk_cat',
159
+ reason: `Bare ${reader[0]} of ${lines} lines bypasses Read intercept. Pipe to grep/rg or use head -n <= ${limit}.`,
160
+ };
161
+ }
162
+ }
163
+ }
164
+ return { action: 'allow_bash', ok: true, id: 'allow_bash', reason: 'Not a bulk file dump.' };
165
+ }
166
+
167
+ function evaluateTaskRoute({ taskKind, model } = {}) {
168
+ const kind = norm(taskKind);
169
+ const worker = norm(model);
170
+ if (!kind && !worker) {
171
+ return { action: 'skip_route', ok: true, id: 'skip_route', reason: 'No task route to audit.' };
172
+ }
173
+ const metered = METERED_WORKERS.includes(worker) || worker.includes('flash');
174
+ if (REASONING_KINDS.includes(kind)) {
175
+ if (metered || worker.includes('local_slice') || worker.startsWith('portal') || worker.startsWith('aika')) {
176
+ return {
177
+ action: 'block_delegate_reason',
178
+ ok: false,
179
+ id: 'cheap_worker_reasoning',
180
+ reason: 'Do not delegate debug/architecture to Portal, AiKA, or Gemini Flash.',
181
+ };
182
+ }
183
+ if (!FRONTIER_WORKERS.includes(worker)) {
184
+ return {
185
+ action: 'block_metered',
186
+ ok: false,
187
+ id: 'unknown_route',
188
+ reason: 'Reasoning needs an explicit frontier worker (hermes-main/grok). Missing or unrecognized models are denied.',
189
+ };
190
+ }
191
+ return {
192
+ action: 'allow_frontier',
193
+ ok: true,
194
+ id: 'allow_frontier',
195
+ reason: 'Reasoning stays on the capable route.',
196
+ };
197
+ }
198
+ if (DELEGABLE_KINDS.includes(kind)) {
199
+ if (metered) {
200
+ return {
201
+ action: 'block_metered',
202
+ ok: false,
203
+ id: 'metered_worker',
204
+ reason: 'Portal and Gemini Flash are outside the monthly cap. Use local_slice.',
205
+ };
206
+ }
207
+ if (worker === 'local_slice') {
208
+ return {
209
+ action: 'allow_delegate',
210
+ ok: true,
211
+ id: 'allow_delegate',
212
+ reason: 'Boilerplate and large reads use a local slice, not a frontier dump.',
213
+ };
214
+ }
215
+ }
216
+ if (kind || worker) {
217
+ return {
218
+ action: 'block_metered',
219
+ ok: false,
220
+ id: 'unknown_route',
221
+ reason: 'Unknown task/worker is denied. Name boilerplate|config|test_scaffold|large_read + local_slice, or keep reasoning on frontier.',
222
+ };
223
+ }
224
+ return { action: 'skip_route', ok: true, id: 'skip_route', reason: 'No task route to audit.' };
225
+ }
226
+
227
+ function evaluateContextReturn({ sourceLines, returnedLines, threshold = DEFAULT_MIN_LINES } = {}) {
228
+ const source = Number(sourceLines) || 0;
229
+ const returned = Number(returnedLines) || 0;
230
+ const limit = Number(threshold) || DEFAULT_MIN_LINES;
231
+ if (!source && !returned) {
232
+ return { action: 'skip_return', ok: true, id: 'skip_return', reason: 'No return sizes.' };
233
+ }
234
+ if (source > limit && returned >= source) {
235
+ return {
236
+ action: 'block_full_dump',
237
+ ok: false,
238
+ id: 'full_file_dump',
239
+ reason: 'Return only the relevant slice to the frontier model.',
240
+ };
241
+ }
242
+ return {
243
+ action: 'allow_slice',
244
+ ok: true,
245
+ id: 'allow_slice',
246
+ reason: 'Frontier context is a slice, not the whole file.',
247
+ };
248
+ }
249
+
250
+ function isTargetedRead(toolInput = {}) {
251
+ const offset = Number(toolInput.offset);
252
+ const limit = Number(toolInput.limit);
253
+ return Number.isFinite(offset) || (Number.isFinite(limit) && limit > 0);
254
+ }
255
+
256
+ function evaluatePreToolUse({
257
+ toolName,
258
+ toolInput = {},
259
+ cwd = process.cwd(),
260
+ threshold = DEFAULT_MIN_LINES,
261
+ } = {}) {
262
+ const name = String(toolName || '');
263
+ const input = toolInput && typeof toolInput === 'object' ? toolInput : {};
264
+ if (/^read$/i.test(name) || name === 'read_file') {
265
+ const rel = input.file_path || input.path || input.target_file;
266
+ const abs = rel ? path.resolve(cwd, String(rel)) : null;
267
+ const lines = countFileLines(abs);
268
+ if (lines == null) {
269
+ return {
270
+ action: 'warn_unknown_size',
271
+ ok: true,
272
+ id: 'unknown_size',
273
+ reason: 'Read path missing or unreadable; not inventing a line count.',
274
+ };
275
+ }
276
+ return evaluateReadIntercept({
277
+ lineCount: lines,
278
+ targeted: isTargetedRead(input),
279
+ threshold,
280
+ });
281
+ }
282
+ if (/^bash$/i.test(name) || name === 'Shell') {
283
+ return evaluateBashRead({
284
+ command: input.command || input.cmd,
285
+ cwd,
286
+ threshold,
287
+ });
288
+ }
289
+ return {
290
+ action: 'skip_tool',
291
+ ok: true,
292
+ id: 'skip_tool',
293
+ reason: 'Not a Read/Bash dump surface.',
294
+ };
295
+ }
296
+
297
+ function collectHaystack(options = {}) {
298
+ const parts = [
299
+ options.task,
300
+ options.query,
301
+ options['clone-portal'] ? 'install shunt@portal vendor @spotify/portal-cli buy Spotify Portal' : '',
302
+ ];
303
+ if (Array.isArray(options.argv)) parts.push(...options.argv);
304
+ return parts.filter(Boolean).join(' ');
305
+ }
306
+
307
+ function buildTokenShuntHonestyReport(options = {}) {
308
+ const findings = [];
309
+ const cloneHits = [...new Set(detectCloneAttempt(collectHaystack(options)))];
310
+ if (cloneHits.length) {
311
+ findings.push({
312
+ severity: 'fail',
313
+ id: 'portal_clone_refused',
314
+ message: `Refusing Portal / shunt@portal clones (${cloneHits.join(', ')}). Lint local Read/bash intercepts; do not buy Spotify Portal.`,
315
+ });
316
+ }
317
+
318
+ const threshold = Number(options.threshold || options['min-lines']) || DEFAULT_MIN_LINES;
319
+ const lineCount = options.lines != null ? Number(options.lines) : null;
320
+ const targeted = normalizeBoolean(options.targeted);
321
+ const hasRead = lineCount != null && !Number.isNaN(lineCount);
322
+ const hasBash = Boolean(options.bash || options.command);
323
+ const hasRoute = Boolean(options['task-kind'] || options.model);
324
+ const hasReturn = options['source-lines'] != null || options['returned-lines'] != null;
325
+
326
+ if (hasRead) {
327
+ const d = evaluateReadIntercept({ lineCount, targeted, threshold });
328
+ findings.push({
329
+ severity: d.ok ? 'info' : 'fail',
330
+ id: d.id,
331
+ message: d.reason,
332
+ action: d.action,
333
+ });
334
+ }
335
+ if (hasBash) {
336
+ const d = evaluateBashRead({
337
+ command: options.bash || options.command,
338
+ lineCount,
339
+ cwd: options.cwd,
340
+ threshold,
341
+ });
342
+ findings.push({
343
+ severity: d.ok ? 'info' : 'fail',
344
+ id: d.id,
345
+ message: d.reason,
346
+ action: d.action,
347
+ });
348
+ }
349
+ if (hasRoute) {
350
+ const d = evaluateTaskRoute({
351
+ taskKind: options['task-kind'] || options.kind,
352
+ model: options.model,
353
+ });
354
+ findings.push({
355
+ severity: d.ok ? 'info' : 'fail',
356
+ id: d.id,
357
+ message: d.reason,
358
+ action: d.action,
359
+ });
360
+ }
361
+ if (hasReturn) {
362
+ const d = evaluateContextReturn({
363
+ sourceLines: options['source-lines'],
364
+ returnedLines: options['returned-lines'],
365
+ threshold,
366
+ });
367
+ findings.push({
368
+ severity: d.ok ? 'info' : 'fail',
369
+ id: d.id,
370
+ message: d.reason,
371
+ action: d.action,
372
+ });
373
+ }
374
+
375
+ if (!cloneHits.length && !hasRead && !hasBash && !hasRoute && !hasReturn) {
376
+ findings.push({
377
+ severity: 'warn',
378
+ id: 'no_trace',
379
+ message: 'No --lines/--bash/--task-kind/--source-lines. Pass a Read/bash/return trace to lint. Complements PreToolUse; does not install Portal.',
380
+ });
381
+ }
382
+
383
+ if (normalizeBoolean(options['all-repos-write'])) {
384
+ findings.push({
385
+ severity: 'warn',
386
+ id: 'github_app_all_repos',
387
+ message: 'GitHub App All-repositories write is a Portal catalog connector, not a cash rail and not a license to spam buy links on every repo.',
388
+ });
389
+ }
390
+
391
+ let status = 'ready';
392
+ if (findings.some((f) => f.severity === 'fail')) status = 'fail';
393
+ else if (findings.some((f) => f.severity === 'warn')) status = 'ready_with_warnings';
394
+
395
+ return {
396
+ name: 'thumbgate-token-shunt-honesty',
397
+ status,
398
+ ok: status !== 'fail',
399
+ minLines: threshold,
400
+ sliceLines: DEFAULT_SLICE_LINES,
401
+ compareNotClone: true,
402
+ never: [
403
+ 'install shunt@portal or @spotify/portal-cli',
404
+ 'buy Spotify Portal / click Contact Sales as the cash path',
405
+ 'claim 90% token savings from installing the GitHub App',
406
+ 'delegate architecture/debug to Flash/Portal/AiKA',
407
+ 'dump a whole file back into the frontier context',
408
+ 'sell ThumbGate through Portal (ECI pause on paid outreach)',
409
+ ],
410
+ source: SOURCE_URLS,
411
+ disclaimer: 'FORMAT steal only. Not affiliated with Spotify. Local Read/bash intercepts; no Portal SKU. GitHub App 162279530 is not revenue.',
412
+ findings,
413
+ };
414
+ }
415
+
416
+ function formatTokenShuntHonestyReport(report) {
417
+ const lines = [
418
+ 'ThumbGate token-shunt honesty (Spotify Portal FORMAT steal)',
419
+ `Status : ${report.status}`,
420
+ `ok : ${report.ok}`,
421
+ `MinLines : ${report.minLines} (untargeted Read above this is blocked)`,
422
+ ];
423
+ if (report.findings?.length) {
424
+ lines.push('Findings:');
425
+ for (const f of report.findings) lines.push(` [${f.severity}] ${f.id}: ${f.message}`);
426
+ }
427
+ lines.push(`Never : ${(report.never || []).join('; ')}`);
428
+ lines.push(report.disclaimer);
429
+ return `${lines.join('\n')}\n`;
430
+ }
431
+
432
+ function parseArgv(argv) {
433
+ const options = { argv };
434
+ for (const arg of argv) {
435
+ if (arg === '--json') options.json = true;
436
+ else if (arg === '--strict') options.strict = true;
437
+ else if (arg === '--targeted') options.targeted = true;
438
+ else if (arg === '--clone-portal') options['clone-portal'] = true;
439
+ else if (arg === '--all-repos-write') options['all-repos-write'] = true;
440
+ else if (arg.startsWith('--lines=')) options.lines = arg.slice('--lines='.length);
441
+ else if (arg.startsWith('--threshold=')) options.threshold = arg.slice('--threshold='.length);
442
+ else if (arg.startsWith('--min-lines=')) options['min-lines'] = arg.slice('--min-lines='.length);
443
+ else if (arg.startsWith('--bash=')) options.bash = arg.slice('--bash='.length);
444
+ else if (arg.startsWith('--command=')) options.command = arg.slice('--command='.length);
445
+ else if (arg.startsWith('--task-kind=')) options['task-kind'] = arg.slice('--task-kind='.length);
446
+ else if (arg.startsWith('--model=')) options.model = arg.slice('--model='.length);
447
+ else if (arg.startsWith('--source-lines=')) options['source-lines'] = arg.slice('--source-lines='.length);
448
+ else if (arg.startsWith('--returned-lines=')) options['returned-lines'] = arg.slice('--returned-lines='.length);
449
+ else if (arg.startsWith('--targeted=')) options.targeted = arg.slice('--targeted='.length);
450
+ }
451
+ return options;
452
+ }
453
+
454
+ function printHelp() {
455
+ process.stdout.write(`Usage: node scripts/token-shunt-honesty.js [options]
456
+
457
+ Spotify Portal FORMAT: intercept untargeted bulk reads.
458
+ Does not install shunt@portal or buy Portal.
459
+
460
+ Options:
461
+ --lines=N --targeted
462
+ --bash='cat file.js'
463
+ --task-kind=architecture --model=gemini-flash
464
+ --source-lines=N --returned-lines=N
465
+ --clone-portal Fail closed
466
+ --all-repos-write Warn: GitHub App is not cash
467
+ --json --strict
468
+ `);
469
+ }
470
+
471
+ function main(argv = process.argv.slice(2)) {
472
+ if (argv.includes('--help') || argv.includes('-h')) {
473
+ printHelp();
474
+ return 0;
475
+ }
476
+ const options = parseArgv(argv);
477
+ const report = buildTokenShuntHonestyReport(options);
478
+ if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
479
+ else process.stdout.write(formatTokenShuntHonestyReport(report));
480
+ if (options.strict && report.status !== 'ready') return 1;
481
+ if (report.status === 'fail') return 1;
482
+ return 0;
483
+ }
484
+
485
+ module.exports = {
486
+ DEFAULT_MIN_LINES,
487
+ DEFAULT_SLICE_LINES,
488
+ SOURCE_URLS,
489
+ detectCloneAttempt,
490
+ evaluateReadIntercept,
491
+ evaluateBashRead,
492
+ evaluateTaskRoute,
493
+ evaluateContextReturn,
494
+ evaluatePreToolUse,
495
+ buildTokenShuntHonestyReport,
496
+ formatTokenShuntHonestyReport,
497
+ main,
498
+ };
499
+
500
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
501
+ process.exitCode = main();
502
+ }
@@ -7,6 +7,12 @@ const path = require('node:path');
7
7
  const { resolveFeedbackDir } = require('./feedback-paths');
8
8
  const { readJsonl } = require('./fs-utils');
9
9
 
10
+ const TOOL_CALL_OUTCOMES = Object.freeze({
11
+ SUCCESSFUL: 'successful',
12
+ FAILED: 'failed',
13
+ BLOCKED: 'blocked',
14
+ });
15
+
10
16
  function getKpiLogPath(options = {}) {
11
17
  return path.join(
12
18
  options.feedbackDir ? path.resolve(options.feedbackDir) : resolveFeedbackDir(),
@@ -14,25 +20,69 @@ function getKpiLogPath(options = {}) {
14
20
  );
15
21
  }
16
22
 
23
+ /**
24
+ * WriteGuard-aligned outcome taxonomy for every MCP attempt.
25
+ * Prefer explicit `outcome`; otherwise derive from blocked/denied/success.
26
+ */
27
+ function normalizeToolCallOutcome({
28
+ outcome,
29
+ success,
30
+ blocked,
31
+ metadata,
32
+ } = {}) {
33
+ if (
34
+ outcome === TOOL_CALL_OUTCOMES.SUCCESSFUL
35
+ || outcome === TOOL_CALL_OUTCOMES.FAILED
36
+ || outcome === TOOL_CALL_OUTCOMES.BLOCKED
37
+ ) {
38
+ return outcome;
39
+ }
40
+ if (
41
+ blocked === true
42
+ || metadata?.denied === true
43
+ || metadata?.outcome === TOOL_CALL_OUTCOMES.BLOCKED
44
+ ) {
45
+ return TOOL_CALL_OUTCOMES.BLOCKED;
46
+ }
47
+ if (success === false) return TOOL_CALL_OUTCOMES.FAILED;
48
+ return TOOL_CALL_OUTCOMES.SUCCESSFUL;
49
+ }
50
+
17
51
  function recordToolCall({
18
52
  toolName,
19
53
  serverName,
20
54
  latencyMs,
21
55
  success,
56
+ blocked,
57
+ outcome,
22
58
  agentId,
59
+ clientId,
60
+ sessionId,
61
+ writeRiskTier,
23
62
  metadata,
24
63
  feedbackDir,
25
64
  } = {}) {
26
65
  const logPath = getKpiLogPath({ feedbackDir });
27
66
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
67
+ const normalizedOutcome = normalizeToolCallOutcome({
68
+ outcome,
69
+ success,
70
+ blocked,
71
+ metadata,
72
+ });
28
73
  const entry = {
29
74
  id: `kpi_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
30
75
  timestamp: new Date().toISOString(),
31
76
  toolName: toolName || 'unknown',
32
77
  serverName: serverName || 'default',
33
78
  latencyMs: typeof latencyMs === 'number' ? latencyMs : 0,
34
- success: success !== false,
79
+ // Backward-compatible boolean: blocked and failed are both non-success.
80
+ success: normalizedOutcome === TOOL_CALL_OUTCOMES.SUCCESSFUL,
81
+ outcome: normalizedOutcome,
35
82
  agentId: agentId || 'unknown',
83
+ clientId: clientId || metadata?.clientId || null,
84
+ sessionId: sessionId || metadata?.sessionId || null,
85
+ writeRiskTier: writeRiskTier || metadata?.writeRiskTier || null,
36
86
  metadata: metadata || {},
37
87
  };
38
88
  fs.appendFileSync(logPath, `${JSON.stringify(entry)}\n`);
@@ -45,11 +95,32 @@ function percentile(sorted, quantile) {
45
95
  return sorted[Math.max(0, index)];
46
96
  }
47
97
 
98
+ function emptyOutcomeCounts() {
99
+ return {
100
+ successful: 0,
101
+ failed: 0,
102
+ blocked: 0,
103
+ };
104
+ }
105
+
106
+ function pickDominantTier(tierCounts = {}) {
107
+ let best = null;
108
+ let bestCount = 0;
109
+ for (const [tier, count] of Object.entries(tierCounts)) {
110
+ if (count > bestCount) {
111
+ best = tier;
112
+ bestCount = count;
113
+ }
114
+ }
115
+ return best;
116
+ }
117
+
48
118
  function computeToolKpis({ periodHours = 24, feedbackDir } = {}) {
49
119
  const entries = readJsonl(getKpiLogPath({ feedbackDir }));
50
120
  const cutoff = Date.now() - periodHours * 60 * 60 * 1000;
51
121
  const recent = entries.filter((entry) => new Date(entry.timestamp).getTime() > cutoff);
52
122
  const byTool = {};
123
+ const outcomes = emptyOutcomeCounts();
53
124
  for (const entry of recent) {
54
125
  const key = entry.toolName;
55
126
  if (!byTool[key]) {
@@ -58,11 +129,27 @@ function computeToolKpis({ periodHours = 24, feedbackDir } = {}) {
58
129
  calls: [],
59
130
  successes: 0,
60
131
  failures: 0,
132
+ blocked: 0,
133
+ outcomes: emptyOutcomeCounts(),
134
+ tierCounts: {},
61
135
  };
62
136
  }
63
137
  byTool[key].calls.push(entry.latencyMs);
64
- if (entry.success) byTool[key].successes += 1;
65
- else byTool[key].failures += 1;
138
+ const tier = entry.writeRiskTier || entry.metadata?.writeRiskTier || null;
139
+ if (tier) {
140
+ byTool[key].tierCounts[tier] = (byTool[key].tierCounts[tier] || 0) + 1;
141
+ }
142
+ const normalized = normalizeToolCallOutcome(entry);
143
+ byTool[key].outcomes[normalized] += 1;
144
+ outcomes[normalized] += 1;
145
+ if (normalized === TOOL_CALL_OUTCOMES.SUCCESSFUL) {
146
+ byTool[key].successes += 1;
147
+ } else if (normalized === TOOL_CALL_OUTCOMES.BLOCKED) {
148
+ byTool[key].blocked += 1;
149
+ byTool[key].failures += 1;
150
+ } else {
151
+ byTool[key].failures += 1;
152
+ }
66
153
  }
67
154
  const tools = Object.values(byTool)
68
155
  .map((tool) => {
@@ -77,6 +164,9 @@ function computeToolKpis({ periodHours = 24, feedbackDir } = {}) {
77
164
  p95: Math.round(percentile(sorted, 95)),
78
165
  successes: tool.successes,
79
166
  failures: tool.failures,
167
+ blocked: tool.blocked,
168
+ outcomes: tool.outcomes,
169
+ writeRiskTier: pickDominantTier(tool.tierCounts),
80
170
  };
81
171
  })
82
172
  .sort((left, right) => right.requestCount - left.requestCount);
@@ -84,9 +174,18 @@ function computeToolKpis({ periodHours = 24, feedbackDir } = {}) {
84
174
  const byServer = {};
85
175
  for (const entry of recent) {
86
176
  const key = entry.serverName;
87
- if (!byServer[key]) byServer[key] = { serverName: key, total: 0, successes: 0 };
177
+ if (!byServer[key]) {
178
+ byServer[key] = {
179
+ serverName: key,
180
+ total: 0,
181
+ successes: 0,
182
+ outcomes: emptyOutcomeCounts(),
183
+ };
184
+ }
88
185
  byServer[key].total += 1;
89
- if (entry.success) byServer[key].successes += 1;
186
+ const normalized = normalizeToolCallOutcome(entry);
187
+ byServer[key].outcomes[normalized] += 1;
188
+ if (normalized === TOOL_CALL_OUTCOMES.SUCCESSFUL) byServer[key].successes += 1;
90
189
  }
91
190
  const servers = Object.values(byServer).map((server) => ({
92
191
  serverName: server.serverName,
@@ -94,11 +193,13 @@ function computeToolKpis({ periodHours = 24, feedbackDir } = {}) {
94
193
  successRate: server.total > 0
95
194
  ? Math.round((server.successes / server.total) * 1000) / 10
96
195
  : 100,
196
+ outcomes: server.outcomes,
97
197
  }));
98
198
  return {
99
199
  periodHours,
100
200
  totalCalls: recent.length,
101
201
  evidenceStatus: recent.length > 0 ? 'measured' : 'insufficient_evidence',
202
+ outcomes,
102
203
  tools,
103
204
  servers,
104
205
  };
@@ -116,9 +217,11 @@ function getAtRiskTools({
116
217
  }
117
218
 
118
219
  module.exports = {
220
+ TOOL_CALL_OUTCOMES,
119
221
  computeToolKpis,
120
222
  getAtRiskTools,
121
223
  getKpiLogPath,
224
+ normalizeToolCallOutcome,
122
225
  percentile,
123
226
  recordToolCall,
124
227
  };