thumbgate 1.27.16 → 1.27.18

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +85 -0
  2. package/.claude-plugin/plugin.json +3 -4
  3. package/.well-known/llms.txt +12 -33
  4. package/.well-known/mcp/server-card.json +8 -8
  5. package/README.md +30 -215
  6. package/adapters/chatgpt/openapi.yaml +1695 -0
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/gemini/function-declarations.json +0 -1
  9. package/adapters/mcp/server-stdio.js +11 -263
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bench/thumbgate-bench.json +2 -2
  12. package/bin/cli.js +122 -1421
  13. package/bin/postinstall.js +27 -11
  14. package/config/gate-templates.json +0 -216
  15. package/config/gates/claim-verification.json +0 -12
  16. package/config/gates/default.json +2 -31
  17. package/config/github-about.json +2 -2
  18. package/config/mcp-allowlists.json +13 -23
  19. package/config/merge-quality-checks.json +1 -0
  20. package/config/model-candidates.json +6 -121
  21. package/openapi/openapi.yaml +0 -12
  22. package/package.json +98 -228
  23. package/public/blog.html +4 -4
  24. package/public/codex-plugin.html +20 -72
  25. package/public/compare.html +8 -31
  26. package/public/dashboard.html +166 -930
  27. package/public/federal.html +2 -2
  28. package/public/guide.html +9 -33
  29. package/public/index.html +116 -495
  30. package/public/learn.html +18 -191
  31. package/public/lessons.html +10 -168
  32. package/public/numbers.html +7 -7
  33. package/public/pro.html +11 -34
  34. package/scripts/agent-memory-lifecycle.js +0 -211
  35. package/scripts/agent-readiness.js +3 -167
  36. package/scripts/agent-reward-model.js +1 -53
  37. package/scripts/auto-promote-gates.js +10 -82
  38. package/scripts/auto-wire-hooks.js +0 -14
  39. package/scripts/billing.js +1 -93
  40. package/scripts/build-metadata.js +10 -50
  41. package/scripts/cli-feedback.js +6 -18
  42. package/scripts/cli-schema.js +0 -90
  43. package/scripts/cli-telemetry.js +1 -6
  44. package/scripts/commercial-offer.js +2 -82
  45. package/scripts/context-manager.js +6 -74
  46. package/scripts/dashboard.js +2 -68
  47. package/scripts/durability/step.js +3 -3
  48. package/scripts/export-databricks-bundle.js +1 -5
  49. package/scripts/export-dpo-pairs.js +2 -7
  50. package/scripts/feedback-loop.js +1 -123
  51. package/scripts/feedback-quality.js +0 -87
  52. package/scripts/filesystem-search.js +10 -35
  53. package/scripts/gate-stats.js +0 -89
  54. package/scripts/gates-engine.js +85 -1105
  55. package/scripts/gemini-embedding-policy.js +1 -2
  56. package/scripts/hook-runtime.js +14 -20
  57. package/scripts/hook-thumbgate-cache-updater.js +2 -18
  58. package/scripts/hybrid-feedback-context.js +7 -142
  59. package/scripts/lesson-inference.js +3 -8
  60. package/scripts/lesson-search.js +1 -17
  61. package/scripts/license.js +1 -0
  62. package/scripts/llm-client.js +9 -390
  63. package/scripts/local-model-profile.js +8 -15
  64. package/scripts/memory-scope-readiness.js +0 -159
  65. package/scripts/meta-agent-loop.js +0 -36
  66. package/scripts/operational-integrity.js +5 -39
  67. package/scripts/oss-pr-opportunity-scout.js +5 -35
  68. package/scripts/plausible-server-events.js +6 -9
  69. package/scripts/pro-local-dashboard.js +4 -4
  70. package/scripts/proxy-pointer-rag-guardrails.js +1 -42
  71. package/scripts/rate-limiter.js +13 -64
  72. package/scripts/secret-scanner.js +5 -44
  73. package/scripts/security-scanner.js +10 -260
  74. package/scripts/self-distill-agent.js +1 -3
  75. package/scripts/seo-gsd.js +2 -916
  76. package/scripts/statusline-cache-path.js +2 -17
  77. package/scripts/statusline-local-stats.js +1 -9
  78. package/scripts/statusline-meta.js +2 -28
  79. package/scripts/statusline.sh +4 -20
  80. package/scripts/telemetry-analytics.js +0 -357
  81. package/scripts/thompson-sampling.js +10 -31
  82. package/scripts/thumbgate-bench.js +1 -16
  83. package/scripts/thumbgate-search.js +19 -85
  84. package/scripts/tool-registry.js +1 -169
  85. package/scripts/vector-store.js +0 -45
  86. package/scripts/workflow-sentinel.js +53 -286
  87. package/scripts/workspace-evolver.js +2 -62
  88. package/src/api/server.js +290 -2578
  89. package/.claude/commands/dashboard.md +0 -15
  90. package/.claude/commands/thumbgate-blocked.md +0 -27
  91. package/.claude/commands/thumbgate-dashboard.md +0 -15
  92. package/.claude/commands/thumbgate-doctor.md +0 -30
  93. package/.claude/commands/thumbgate-guard.md +0 -36
  94. package/.claude/commands/thumbgate-protect.md +0 -30
  95. package/.claude/commands/thumbgate-rules.md +0 -30
  96. package/.well-known/agentic-verify.txt +0 -1
  97. package/adapters/gcp/dfcx-webhook-gate.js +0 -295
  98. package/adapters/letta/README.md +0 -41
  99. package/adapters/letta/thumbgate-letta-adapter.js +0 -133
  100. package/adapters/policy-engine/ethicore-guardian-client.js +0 -68
  101. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +0 -260
  102. package/bench/observability-eval-suite.json +0 -26
  103. package/bin/dashboard-cli.js +0 -7
  104. package/commands/dashboard.md +0 -15
  105. package/commands/thumbgate-dashboard.md +0 -15
  106. package/config/builtin-lessons.json +0 -23
  107. package/config/gate-classifier-routing.json +0 -98
  108. package/config/post-deploy-marketing-pages.json +0 -61
  109. package/public/about.html +0 -162
  110. package/public/agent-manager.html +0 -179
  111. package/public/agents-cost-savings.html +0 -153
  112. package/public/ai-malpractice-prevention.html +0 -818
  113. package/public/assets/brand/github-social-preview.png +0 -0
  114. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  115. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  116. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  117. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  118. package/public/assets/brand/thumbgate-logo-transparent.svg +0 -22
  119. package/public/assets/brand/thumbgate-mark-inline-v3.svg +0 -19
  120. package/public/assets/brand/thumbgate-mark-pro.svg +0 -23
  121. package/public/assets/brand/thumbgate-mark-team.svg +0 -26
  122. package/public/assets/brand/thumbgate-mark.svg +0 -21
  123. package/public/assets/brand/thumbgate-wordmark.svg +0 -20
  124. package/public/assets/claude-thumbgate-statusbar.svg +0 -8
  125. package/public/assets/codex-thumbgate-statusbar-test.svg +0 -9
  126. package/public/assets/legal-intake-control-flow.svg +0 -66
  127. package/public/brand/thumbgate-mark.svg +0 -19
  128. package/public/brand/thumbgate-og.svg +0 -16
  129. package/public/chatgpt-app.html +0 -330
  130. package/public/codex-enterprise.html +0 -123
  131. package/public/js/buyer-intent.js +0 -672
  132. package/public/pricing.html +0 -399
  133. package/scripts/action-receipts.js +0 -324
  134. package/scripts/activation-quickstart.js +0 -187
  135. package/scripts/agent-operations-planner.js +0 -621
  136. package/scripts/ai-component-inventory.js +0 -367
  137. package/scripts/async-eval-observability.js +0 -236
  138. package/scripts/audit.js +0 -65
  139. package/scripts/aws-blocks-guardrails.js +0 -228
  140. package/scripts/classifier-routing.js +0 -130
  141. package/scripts/dashboard-chat.js +0 -332
  142. package/scripts/feedback-aggregate.js +0 -281
  143. package/scripts/feedback-sanitizer.js +0 -105
  144. package/scripts/hob-pack.js +0 -591
  145. package/scripts/hook-stop-anti-claim.js +0 -301
  146. package/scripts/install-shim.js +0 -87
  147. package/scripts/mcp-oauth.js +0 -293
  148. package/scripts/noop-detect.js +0 -285
  149. package/scripts/omlx-smoke.js +0 -192
  150. package/scripts/parallel-workflow-orchestrator.js +0 -293
  151. package/scripts/plan-gate.js +0 -243
  152. package/scripts/plausible-domain-config.js +0 -99
  153. package/scripts/qa-scenario-planner.js +0 -136
  154. package/scripts/repeat-metric.js +0 -137
  155. package/scripts/secret-fixture-tokens.js +0 -61
  156. package/scripts/secret-redaction.js +0 -166
  157. package/scripts/self-harness-optimizer.js +0 -141
  158. package/scripts/self-healing-check.js +0 -193
  159. package/scripts/silent-failure-cluster.js +0 -531
  160. package/scripts/statusline-cache-read.js +0 -57
  161. package/scripts/sync-telemetry-from-prod.js +0 -374
  162. package/scripts/tool-contract-validator.js +0 -76
  163. package/scripts/trajectory-scorer.js +0 -63
  164. package/scripts/verify-marketing-pages-deployed.js +0 -195
  165. package/scripts/visitor-journey.js +0 -172
@@ -1,531 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * Silent-Failure Clustering — Unsupervised candidate source for the meta-agent loop
6
- *
7
- * Default-ON since 2026-05-21. Opt-out with: THUMBGATE_SILENT_FAILURE_CLUSTERING=0
8
- * (or set NODE_ENV=test to skip in test runs). Was opt-in for the initial
9
- * landing of PR #2285; flipped to default-on because the entire point is to
10
- * cover the case where users never give thumbs-down — keeping it opt-in
11
- * means lazy users (the ones who need it most) never benefit. Bounded risk:
12
- * candidates still flow through meta-agent-loop's existing fp-rate eval, so
13
- * a noisy cluster can't auto-promote to a real gate without passing the
14
- * same precision/recall thresholds as LLM-generated candidates.
15
- *
16
- * Problem: ThumbGate's HITL loop only learns from explicit thumbs-down. Tool calls
17
- * that fail without user feedback (exit_code != 0, regex-matched error in output,
18
- * agent silently recovers) are invisible to `auto-promote-gates.js`. This module
19
- * mines those silent failures from the JSONL conversation logs, clusters them by
20
- * (tool, normalized-arg-signature), and emits candidate prevention rules that
21
- * flow through the EXISTING meta-agent-loop fp-rate eval — never bypassed.
22
- *
23
- * Pipeline:
24
- * 1. Reuse `discoverConversationLogs` from `self-distill-agent.js` to find logs
25
- * 2. Read each JSONL line; extract tool calls (Bash, Edit, Write, …) with their args
26
- * and adjacent tool_result entries that carry exit_code / error text
27
- * 3. Filter to "failed" calls (exit_code != 0 OR matches one of ERROR_PATTERNS,
28
- * mirroring `self-distill-agent.js`)
29
- * 4. Drop any call whose timestamp is within ±5min of a feedback-log entry —
30
- * those are already in the HITL loop and would double-count
31
- * 5. Normalize args: absolute paths → `<HOME>/…`, redact secrets per the
32
- * canonical regex set in `~/.claude/hooks/daily-log-append.sh`
33
- * 6. Cluster by exact tuple `(tool, normalized-arg-signature)`, min size 3
34
- * 7. Emit each cluster as a candidate with `origin: 'silent-failure-cluster'`
35
- * so meta-agent-loop tags it for downstream precision measurement
36
- *
37
- * Known limitations (locked in by the spec):
38
- * - Only worthwhile on workspaces generating ≥ 50 tool calls/day. Surfaces
39
- * "insufficient data, skipped" cleanly rather than emitting noise.
40
- * - Cluster ≠ bad; we rely on the exit_code / ERROR_PATTERNS filter to make
41
- * a cluster a *failure* cluster.
42
- * - No drift detection. If tools change, old clusters pollute. Out of scope for v1.
43
- */
44
-
45
- const fs = require('fs');
46
- const os = require('os');
47
- const path = require('path');
48
-
49
- const {
50
- discoverConversationLogs,
51
- } = require('./self-distill-agent');
52
-
53
- // Mirrors self-distill-agent.js ERROR_PATTERNS exactly. self-distill does NOT
54
- // export this constant; duplicating here is the smallest-surface choice that
55
- // keeps both modules independently testable. If self-distill ever exports it,
56
- // switch to the import.
57
- const ERROR_PATTERNS = [
58
- /\bError:/i,
59
- /\bFAIL\b/,
60
- /\bnot ok\b/,
61
- /exit code\s*(?:!=\s*0|[1-9]\d*)/i,
62
- /\bERROR\b/,
63
- /\bTypeError\b/,
64
- /\bReferenceError\b/,
65
- /\bSyntaxError\b/,
66
- /\bcommand failed\b/i,
67
- /\bexited with\s+[1-9]/i,
68
- ];
69
-
70
- const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
71
-
72
- const MIN_CLUSTER_SIZE = 3;
73
- const MIN_DAILY_CALLS_FOR_USEFUL_CLUSTERING = 50;
74
- const FEEDBACK_PROXIMITY_WINDOW_MS = 5 * 60 * 1000; // ±5 min
75
-
76
- // ---------------------------------------------------------------------------
77
- // Redaction — keep in sync with ~/.claude/hooks/daily-log-append.sh
78
- // ---------------------------------------------------------------------------
79
-
80
- const SECRET_PATTERNS = [
81
- // Stripe + GitHub + Slack + AWS + Google + npm + Anthropic keys
82
- {
83
- re: /(sk_live_|sk_test_|rk_live_|rk_test_|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xoxb-|xoxp-|xapp-|AKIA|AIza|npm_|sk-ant-[A-Za-z0-9]*-?|sk-proj-|sk-svcacct-)[A-Za-z0-9_-]{8,}/g,
84
- replacement: '[REDACTED]',
85
- },
86
- // JWT (3 base64url segments)
87
- { re: /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replacement: '[REDACTED-JWT]' },
88
- // Slack webhook
89
- { re: /https:\/\/hooks\.slack\.com\/services\/[A-Z0-9/]+/g, replacement: '[REDACTED-SLACK-WEBHOOK]' },
90
- // Private key header
91
- { re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g, replacement: '[REDACTED-PRIVATE-KEY-HEADER]' },
92
- ];
93
-
94
- function redactSecrets(text) {
95
- let out = String(text == null ? '' : text);
96
- for (const { re, replacement } of SECRET_PATTERNS) {
97
- out = out.replace(re, replacement);
98
- }
99
- return out;
100
- }
101
-
102
- // ---------------------------------------------------------------------------
103
- // Path normalization
104
- // ---------------------------------------------------------------------------
105
-
106
- function normalizePaths(text) {
107
- let out = String(text == null ? '' : text);
108
- if (HOME) {
109
- // Replace exact HOME prefix
110
- out = out.split(HOME).join('<HOME>');
111
- }
112
- // Replace generic /Users/<name>/... and /home/<name>/... that don't match this HOME
113
- out = out.replace(/\/Users\/[^/\s"']+/g, '<HOME>');
114
- out = out.replace(/\/home\/[^/\s"']+/g, '<HOME>');
115
- out = out.replace(/\/tmp\/[A-Za-z0-9._-]+/g, '/tmp/<X>'); // NOSONAR — regex on strings, not filesystem
116
- out = out.replace(/\/private\/tmp\/[A-Za-z0-9._-]+/g, '/tmp/<X>'); // NOSONAR
117
- return out;
118
- }
119
-
120
- function normalizeForSignature(value) {
121
- // Order matters: redact first (some secrets contain path-ish chars), then paths.
122
- return normalizePaths(redactSecrets(value));
123
- }
124
-
125
- // ---------------------------------------------------------------------------
126
- // JSONL parsing
127
- // ---------------------------------------------------------------------------
128
-
129
- function readJsonlSafe(filePath) {
130
- if (!filePath || !fs.existsSync(filePath)) return [];
131
- try {
132
- const raw = fs.readFileSync(filePath, 'utf-8');
133
- if (!raw.trim()) return [];
134
- return raw
135
- .split('\n')
136
- .filter(Boolean)
137
- .map((line) => {
138
- try {
139
- return JSON.parse(line);
140
- } catch {
141
- return null;
142
- }
143
- })
144
- .filter(Boolean);
145
- } catch {
146
- return [];
147
- }
148
- }
149
-
150
- // ---------------------------------------------------------------------------
151
- // Tool-call + failure extraction
152
- // ---------------------------------------------------------------------------
153
-
154
- /**
155
- * Extract tool-call records from a parsed transcript entry.
156
- *
157
- * Supports two shapes:
158
- * (A) Claude Code transcript format:
159
- * { type:"assistant", message:{ content:[ { type:"tool_use", name, input, id } ] } }
160
- * { type:"user", message:{ content:[ { type:"tool_result", tool_use_id, content, is_error } ] }, toolUseResult: {...} }
161
- * (B) Simplified test fixture format:
162
- * { type:"tool_call", tool, args, timestamp }
163
- * { type:"tool_result", tool_use_id, exit_code, output, timestamp }
164
- *
165
- * Both shapes are normalized to:
166
- * { kind:'call', tool, args, callId, timestamp }
167
- * { kind:'result', callId, exitCode, output, isError, timestamp }
168
- */
169
- function extractToolEvents(entry) {
170
- if (!entry || typeof entry !== 'object') return [];
171
- const events = [];
172
- const ts = entry.timestamp || entry.ts || null;
173
-
174
- // Shape (B) — test fixture / simplified
175
- if (entry.type === 'tool_call' && entry.tool) {
176
- events.push({
177
- kind: 'call',
178
- tool: String(entry.tool),
179
- args: entry.args || entry.input || {},
180
- callId: entry.callId || entry.id || null,
181
- timestamp: ts,
182
- });
183
- return events;
184
- }
185
- if (entry.type === 'tool_result' && (entry.tool_use_id || entry.callId)) {
186
- events.push({
187
- kind: 'result',
188
- callId: entry.tool_use_id || entry.callId,
189
- exitCode: typeof entry.exit_code === 'number' ? entry.exit_code : (typeof entry.exitCode === 'number' ? entry.exitCode : null),
190
- output: String(entry.output || entry.content || ''),
191
- isError: Boolean(entry.is_error || entry.isError),
192
- timestamp: ts,
193
- });
194
- return events;
195
- }
196
-
197
- // Shape (A) — Claude Code transcript
198
- const msg = entry.message;
199
- if (entry.type === 'assistant' && msg && Array.isArray(msg.content)) {
200
- for (const part of msg.content) {
201
- if (part && part.type === 'tool_use' && part.name) {
202
- events.push({
203
- kind: 'call',
204
- tool: String(part.name),
205
- args: part.input || {},
206
- callId: part.id || null,
207
- timestamp: ts,
208
- });
209
- }
210
- }
211
- }
212
- if (entry.type === 'user' && msg && Array.isArray(msg.content)) {
213
- for (const part of msg.content) {
214
- if (part && part.type === 'tool_result') {
215
- const tur = entry.toolUseResult || {};
216
- const exitCode = typeof tur.stderr === 'string' && tur.stderr.length > 0 && typeof tur.interrupted === 'undefined'
217
- ? null
218
- : (typeof tur.exit_code === 'number' ? tur.exit_code : (typeof tur.exitCode === 'number' ? tur.exitCode : null));
219
- const outputText = typeof part.content === 'string'
220
- ? part.content
221
- : (Array.isArray(part.content)
222
- ? part.content.map((c) => (typeof c === 'string' ? c : (c && c.text) || '')).join('\n')
223
- : '');
224
- events.push({
225
- kind: 'result',
226
- callId: part.tool_use_id || null,
227
- exitCode,
228
- output: outputText,
229
- isError: Boolean(part.is_error),
230
- timestamp: ts,
231
- });
232
- }
233
- }
234
- }
235
- return events;
236
- }
237
-
238
- /**
239
- * Pair tool calls with their results by callId; for calls without a matching
240
- * result, treat them as having no failure signal (skipped).
241
- */
242
- function pairCallsWithResults(events) {
243
- const calls = new Map(); // callId → call
244
- const orphanCalls = [];
245
- for (const e of events) {
246
- if (e.kind === 'call') {
247
- if (e.callId) calls.set(e.callId, e);
248
- else orphanCalls.push(e);
249
- }
250
- }
251
- const paired = [];
252
- for (const e of events) {
253
- if (e.kind !== 'result') continue;
254
- const call = e.callId ? calls.get(e.callId) : null;
255
- if (!call) continue;
256
- paired.push({ call, result: e });
257
- }
258
- return paired;
259
- }
260
-
261
- function isFailedCall(pair) {
262
- const { result } = pair;
263
- if (!result) return false;
264
- if (result.isError === true) return true;
265
- if (typeof result.exitCode === 'number' && result.exitCode !== 0) return true;
266
- const output = String(result.output || '');
267
- for (const re of ERROR_PATTERNS) {
268
- if (re.test(output)) return true;
269
- }
270
- return false;
271
- }
272
-
273
- // ---------------------------------------------------------------------------
274
- // Feedback-log proximity filter
275
- // ---------------------------------------------------------------------------
276
-
277
- function loadFeedbackTimestamps(feedbackLogPath) {
278
- const entries = readJsonlSafe(feedbackLogPath);
279
- const timestamps = [];
280
- for (const e of entries) {
281
- const ts = e && (e.timestamp || e.ts);
282
- if (!ts) continue;
283
- const t = Date.parse(ts);
284
- if (Number.isFinite(t)) timestamps.push(t);
285
- }
286
- return timestamps.sort((a, b) => a - b);
287
- }
288
-
289
- function hasAdjacentFeedback(timestampIso, feedbackTimestamps, windowMs = FEEDBACK_PROXIMITY_WINDOW_MS) {
290
- if (!timestampIso || !feedbackTimestamps || feedbackTimestamps.length === 0) return false;
291
- const t = Date.parse(timestampIso);
292
- if (!Number.isFinite(t)) return false;
293
- // Linear scan — feedback log is small (HITL = sparse). If it gets large,
294
- // switch to binary search; not worth the complexity at v1.
295
- for (const f of feedbackTimestamps) {
296
- if (Math.abs(f - t) <= windowMs) return true;
297
- }
298
- return false;
299
- }
300
-
301
- // ---------------------------------------------------------------------------
302
- // Signature + clustering
303
- // ---------------------------------------------------------------------------
304
-
305
- function argsToSignature(tool, args) {
306
- // Stable string signature over args. For Bash we use the command (first ~120
307
- // chars after normalization); for file-tools we use the file_path; otherwise
308
- // a sorted-key shallow JSON.
309
- const norm = (v) => normalizeForSignature(String(v == null ? '' : v));
310
- if (tool === 'Bash' && args && typeof args.command === 'string') {
311
- return `Bash:${norm(args.command).slice(0, 160)}`;
312
- }
313
- if ((tool === 'Read' || tool === 'Edit' || tool === 'Write') && args && typeof args.file_path === 'string') {
314
- return `${tool}:${norm(args.file_path)}`;
315
- }
316
- // Generic fallback — sorted keys, normalized values
317
- try {
318
- const keys = Object.keys(args || {}).sort();
319
- const parts = keys.map((k) => {
320
- const v = args[k];
321
- const s = typeof v === 'string' ? v : JSON.stringify(v);
322
- return `${k}=${norm(s).slice(0, 80)}`;
323
- });
324
- return `${tool}:${parts.join('|')}`;
325
- } catch {
326
- return `${tool}:<unserializable>`;
327
- }
328
- }
329
-
330
- function clusterFailures(failures, { minClusterSize = MIN_CLUSTER_SIZE } = {}) {
331
- const buckets = new Map();
332
- for (const f of failures) {
333
- const sig = argsToSignature(f.tool, f.args);
334
- if (!buckets.has(sig)) buckets.set(sig, []);
335
- buckets.get(sig).push(f);
336
- }
337
- const clusters = [];
338
- for (const [signature, members] of buckets.entries()) {
339
- if (members.length < minClusterSize) continue;
340
- // Sample excerpt from the first member's output for the rule message.
341
- const sample = members[0];
342
- clusters.push({
343
- signature,
344
- tool: sample.tool,
345
- size: members.length,
346
- // Keep redacted+normalized excerpts only.
347
- sampleArgs: normalizeForSignature(JSON.stringify(sample.args || {})).slice(0, 200),
348
- sampleOutput: normalizeForSignature(String(sample.output || '')).slice(0, 200),
349
- });
350
- }
351
- return clusters.sort((a, b) => b.size - a.size);
352
- }
353
-
354
- // ---------------------------------------------------------------------------
355
- // Candidate emission — same shape as meta-agent-loop.js candidates
356
- // ---------------------------------------------------------------------------
357
-
358
- function candidateFromCluster(cluster) {
359
- // Build a regex that targets this normalized signature. We escape regex
360
- // metacharacters and cap the pattern length — meta-agent-loop's matchesEntry
361
- // will compile this with `new RegExp(pattern, 'i')`.
362
- const escape = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
363
- // Use a few keywords from the signature to form a flexible pattern.
364
- const sigBody = cluster.signature.replace(/^[^:]+:/, '');
365
- const words = sigBody
366
- .split(/[\s/|=]+/)
367
- .map((w) => w.replace(/[<>]/g, '').trim())
368
- .filter((w) => w.length >= 4 && !/^[0-9]+$/.test(w))
369
- .slice(0, 3);
370
- const pattern = words.length >= 2
371
- ? words.map(escape).join('.*')
372
- : escape(sigBody.slice(0, 60));
373
-
374
- return {
375
- pattern,
376
- action: 'warn',
377
- message: `Silent-failure cluster (${cluster.size}× ${cluster.tool}): ${cluster.sampleOutput.slice(0, 100) || cluster.sampleArgs.slice(0, 100)}`,
378
- severity: 'medium',
379
- rationale: `Observed ${cluster.size} silent failures matching ${cluster.tool} signature; never thumbed-down by user.`,
380
- source: 'silent-failure-cluster',
381
- origin: 'silent-failure-cluster',
382
- clusterSize: cluster.size,
383
- clusterSignature: cluster.signature,
384
- };
385
- }
386
-
387
- // ---------------------------------------------------------------------------
388
- // Main entry point
389
- // ---------------------------------------------------------------------------
390
-
391
- /**
392
- * Generate candidate rules from clustered silent failures.
393
- *
394
- * @param {object} opts
395
- * @param {string[]} [opts.logPaths] — override conversation-log discovery (tests)
396
- * @param {string} [opts.feedbackLogPath] — feedback-log.jsonl to exclude HITL'd calls
397
- * @param {number} [opts.minClusterSize]
398
- * @param {number} [opts.minDailyCalls]
399
- * @returns {{
400
- * candidates: object[],
401
- * stats: {
402
- * totalToolCalls: number,
403
- * failedCalls: number,
404
- * filteredByFeedback: number,
405
- * clusters: number,
406
- * skippedReason: string|null
407
- * }
408
- * }}
409
- */
410
- function generateSilentFailureCandidates(opts = {}) {
411
- const {
412
- logPaths = discoverConversationLogs({ limit: 50 }),
413
- feedbackLogPath = null,
414
- minClusterSize = MIN_CLUSTER_SIZE,
415
- minDailyCalls = MIN_DAILY_CALLS_FOR_USEFUL_CLUSTERING,
416
- } = opts;
417
-
418
- const stats = {
419
- totalToolCalls: 0,
420
- failedCalls: 0,
421
- filteredByFeedback: 0,
422
- clusters: 0,
423
- skippedReason: null,
424
- };
425
-
426
- const feedbackTimestamps = feedbackLogPath ? loadFeedbackTimestamps(feedbackLogPath) : [];
427
-
428
- const allFailures = [];
429
-
430
- for (const logPath of logPaths) {
431
- const entries = readJsonlSafe(logPath);
432
- const allEvents = entries.flatMap(extractToolEvents);
433
- const pairs = pairCallsWithResults(allEvents);
434
- stats.totalToolCalls += pairs.length;
435
-
436
- for (const pair of pairs) {
437
- if (!isFailedCall(pair)) continue;
438
- stats.failedCalls += 1;
439
- const ts = pair.call.timestamp || pair.result.timestamp;
440
- if (hasAdjacentFeedback(ts, feedbackTimestamps)) {
441
- stats.filteredByFeedback += 1;
442
- continue;
443
- }
444
- allFailures.push({
445
- tool: pair.call.tool,
446
- args: pair.call.args,
447
- output: pair.result.output,
448
- timestamp: ts,
449
- });
450
- }
451
- }
452
-
453
- // Insufficient-data path — emit empty cluster set cleanly.
454
- if (stats.totalToolCalls < minDailyCalls) {
455
- stats.skippedReason = `insufficient-data: ${stats.totalToolCalls} tool calls < ${minDailyCalls} threshold`;
456
- return { candidates: [], stats };
457
- }
458
-
459
- const clusters = clusterFailures(allFailures, { minClusterSize });
460
- stats.clusters = clusters.length;
461
-
462
- const candidates = clusters.map(candidateFromCluster);
463
- return { candidates, stats };
464
- }
465
-
466
- // ---------------------------------------------------------------------------
467
- // CLI
468
- // ---------------------------------------------------------------------------
469
-
470
- /**
471
- * Resolve the enabled state. Default ON. Explicit "0" or "false" opts out;
472
- * NODE_ENV=test also opts out to keep test runs deterministic.
473
- */
474
- function isSilentFailureClusteringEnabled(env = process.env) {
475
- if (env.NODE_ENV === 'test') return false;
476
- const raw = (env.THUMBGATE_SILENT_FAILURE_CLUSTERING || '').toLowerCase();
477
- if (raw === '0' || raw === 'false' || raw === 'off' || raw === 'no') return false;
478
- return true;
479
- }
480
-
481
- async function main() {
482
- if (!isSilentFailureClusteringEnabled()) {
483
- process.stdout.write('silent-failure-cluster: disabled (THUMBGATE_SILENT_FAILURE_CLUSTERING=0 or NODE_ENV=test)\n');
484
- return;
485
- }
486
-
487
- const { resolveFeedbackDir } = require('./feedback-paths');
488
- let feedbackLogPath = null;
489
- try {
490
- feedbackLogPath = path.join(resolveFeedbackDir(), 'feedback-log.jsonl');
491
- } catch {
492
- // running outside a configured feedback dir — fine, just skip the proximity filter
493
- }
494
-
495
- const result = generateSilentFailureCandidates({ feedbackLogPath });
496
- process.stdout.write(JSON.stringify({
497
- enabled: true,
498
- candidateCount: result.candidates.length,
499
- stats: result.stats,
500
- candidates: result.candidates,
501
- }, null, 2) + '\n');
502
- }
503
-
504
- if (require.main === module) {
505
- main().catch((err) => {
506
- process.stderr.write(`silent-failure-cluster failed: ${err.message}\n`);
507
- process.exitCode = 1;
508
- });
509
- }
510
-
511
- module.exports = {
512
- generateSilentFailureCandidates,
513
- isSilentFailureClusteringEnabled,
514
- // exported for testing
515
- redactSecrets,
516
- normalizePaths,
517
- normalizeForSignature,
518
- extractToolEvents,
519
- pairCallsWithResults,
520
- isFailedCall,
521
- hasAdjacentFeedback,
522
- loadFeedbackTimestamps,
523
- argsToSignature,
524
- clusterFailures,
525
- candidateFromCluster,
526
- readJsonlSafe,
527
- ERROR_PATTERNS,
528
- MIN_CLUSTER_SIZE,
529
- MIN_DAILY_CALLS_FOR_USEFUL_CLUSTERING,
530
- FEEDBACK_PROXIMITY_WINDOW_MS,
531
- };
@@ -1,57 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // Resolve the statusline cache to read for display.
5
- //
6
- // PRIOR BUG: the earlier version of this script "aggregated" by summing the
7
- // thumbs_up/down fields across every per-folder statusline_cache.json. That
8
- // double-counted, because the global aggregate cache at
9
- // ~/.thumbgate/statusline_cache.json is ITSELF already the cross-store sum
10
- // (written by feedback-aggregate.js / hook-thumbgate-cache-updater.js). Summing
11
- // the global aggregate plus per-folder caches counted every event twice or more
12
- // and produced bogus totals like 1152↑/747↓ when the true aggregate was 727/600.
13
- //
14
- // CORRECT BEHAVIOR: pick the highest-priority existing cache from the
15
- // candidate list (`statusline-cache-path.js` puts the canonical aggregate path
16
- // first when aggregation is enabled) and return its content unchanged. No
17
- // summing across files — the upstream aggregator already did that work.
18
-
19
- const fs = require('node:fs');
20
- const path = require('node:path');
21
- const { getStatuslineCacheCandidates } = require('./statusline-cache-path');
22
-
23
- function readCacheFile(filePath) {
24
- try {
25
- const raw = fs.readFileSync(filePath, 'utf8');
26
- const parsed = JSON.parse(raw);
27
- if (parsed && typeof parsed === 'object') return parsed;
28
- } catch {
29
- /* unreadable / unparseable caches are silently skipped */
30
- }
31
- return null;
32
- }
33
-
34
- function readResolvedStatuslineCache(options = {}) {
35
- const candidates = getStatuslineCacheCandidates(options);
36
- for (const candidate of candidates) {
37
- if (!fs.existsSync(candidate)) continue;
38
- const data = readCacheFile(candidate);
39
- if (data) {
40
- return { ...data, source: path.resolve(candidate) };
41
- }
42
- }
43
- return null;
44
- }
45
-
46
- const _invokedDirectly =
47
- process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename);
48
- if (_invokedDirectly) {
49
- const resolved = readResolvedStatuslineCache();
50
- if (resolved) {
51
- process.stdout.write(JSON.stringify(resolved));
52
- }
53
- }
54
-
55
- module.exports = {
56
- readResolvedStatuslineCache,
57
- };