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.
- package/.claude-plugin/marketplace.json +85 -0
- package/.claude-plugin/plugin.json +3 -4
- package/.well-known/llms.txt +12 -33
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +30 -215
- package/adapters/chatgpt/openapi.yaml +1695 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gemini/function-declarations.json +0 -1
- package/adapters/mcp/server-stdio.js +11 -263
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +122 -1421
- package/bin/postinstall.js +27 -11
- package/config/gate-templates.json +0 -216
- package/config/gates/claim-verification.json +0 -12
- package/config/gates/default.json +2 -31
- package/config/github-about.json +2 -2
- package/config/mcp-allowlists.json +13 -23
- package/config/merge-quality-checks.json +1 -0
- package/config/model-candidates.json +6 -121
- package/openapi/openapi.yaml +0 -12
- package/package.json +98 -228
- package/public/blog.html +4 -4
- package/public/codex-plugin.html +20 -72
- package/public/compare.html +8 -31
- package/public/dashboard.html +166 -930
- package/public/federal.html +2 -2
- package/public/guide.html +9 -33
- package/public/index.html +116 -495
- package/public/learn.html +18 -191
- package/public/lessons.html +10 -168
- package/public/numbers.html +7 -7
- package/public/pro.html +11 -34
- package/scripts/agent-memory-lifecycle.js +0 -211
- package/scripts/agent-readiness.js +3 -167
- package/scripts/agent-reward-model.js +1 -53
- package/scripts/auto-promote-gates.js +10 -82
- package/scripts/auto-wire-hooks.js +0 -14
- package/scripts/billing.js +1 -93
- package/scripts/build-metadata.js +10 -50
- package/scripts/cli-feedback.js +6 -18
- package/scripts/cli-schema.js +0 -90
- package/scripts/cli-telemetry.js +1 -6
- package/scripts/commercial-offer.js +2 -82
- package/scripts/context-manager.js +6 -74
- package/scripts/dashboard.js +2 -68
- package/scripts/durability/step.js +3 -3
- package/scripts/export-databricks-bundle.js +1 -5
- package/scripts/export-dpo-pairs.js +2 -7
- package/scripts/feedback-loop.js +1 -123
- package/scripts/feedback-quality.js +0 -87
- package/scripts/filesystem-search.js +10 -35
- package/scripts/gate-stats.js +0 -89
- package/scripts/gates-engine.js +85 -1105
- package/scripts/gemini-embedding-policy.js +1 -2
- package/scripts/hook-runtime.js +14 -20
- package/scripts/hook-thumbgate-cache-updater.js +2 -18
- package/scripts/hybrid-feedback-context.js +7 -142
- package/scripts/lesson-inference.js +3 -8
- package/scripts/lesson-search.js +1 -17
- package/scripts/license.js +1 -0
- package/scripts/llm-client.js +9 -390
- package/scripts/local-model-profile.js +8 -15
- package/scripts/memory-scope-readiness.js +0 -159
- package/scripts/meta-agent-loop.js +0 -36
- package/scripts/operational-integrity.js +5 -39
- package/scripts/oss-pr-opportunity-scout.js +5 -35
- package/scripts/plausible-server-events.js +6 -9
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/proxy-pointer-rag-guardrails.js +1 -42
- package/scripts/rate-limiter.js +13 -64
- package/scripts/secret-scanner.js +5 -44
- package/scripts/security-scanner.js +10 -260
- package/scripts/self-distill-agent.js +1 -3
- package/scripts/seo-gsd.js +2 -916
- package/scripts/statusline-cache-path.js +2 -17
- package/scripts/statusline-local-stats.js +1 -9
- package/scripts/statusline-meta.js +2 -28
- package/scripts/statusline.sh +4 -20
- package/scripts/telemetry-analytics.js +0 -357
- package/scripts/thompson-sampling.js +10 -31
- package/scripts/thumbgate-bench.js +1 -16
- package/scripts/thumbgate-search.js +19 -85
- package/scripts/tool-registry.js +1 -169
- package/scripts/vector-store.js +0 -45
- package/scripts/workflow-sentinel.js +53 -286
- package/scripts/workspace-evolver.js +2 -62
- package/src/api/server.js +290 -2578
- package/.claude/commands/dashboard.md +0 -15
- package/.claude/commands/thumbgate-blocked.md +0 -27
- package/.claude/commands/thumbgate-dashboard.md +0 -15
- package/.claude/commands/thumbgate-doctor.md +0 -30
- package/.claude/commands/thumbgate-guard.md +0 -36
- package/.claude/commands/thumbgate-protect.md +0 -30
- package/.claude/commands/thumbgate-rules.md +0 -30
- package/.well-known/agentic-verify.txt +0 -1
- package/adapters/gcp/dfcx-webhook-gate.js +0 -295
- package/adapters/letta/README.md +0 -41
- package/adapters/letta/thumbgate-letta-adapter.js +0 -133
- package/adapters/policy-engine/ethicore-guardian-client.js +0 -68
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +0 -260
- package/bench/observability-eval-suite.json +0 -26
- package/bin/dashboard-cli.js +0 -7
- package/commands/dashboard.md +0 -15
- package/commands/thumbgate-dashboard.md +0 -15
- package/config/builtin-lessons.json +0 -23
- package/config/gate-classifier-routing.json +0 -98
- package/config/post-deploy-marketing-pages.json +0 -61
- package/public/about.html +0 -162
- package/public/agent-manager.html +0 -179
- package/public/agents-cost-savings.html +0 -153
- package/public/ai-malpractice-prevention.html +0 -818
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-logo-transparent.svg +0 -22
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +0 -19
- package/public/assets/brand/thumbgate-mark-pro.svg +0 -23
- package/public/assets/brand/thumbgate-mark-team.svg +0 -26
- package/public/assets/brand/thumbgate-mark.svg +0 -21
- package/public/assets/brand/thumbgate-wordmark.svg +0 -20
- package/public/assets/claude-thumbgate-statusbar.svg +0 -8
- package/public/assets/codex-thumbgate-statusbar-test.svg +0 -9
- package/public/assets/legal-intake-control-flow.svg +0 -66
- package/public/brand/thumbgate-mark.svg +0 -19
- package/public/brand/thumbgate-og.svg +0 -16
- package/public/chatgpt-app.html +0 -330
- package/public/codex-enterprise.html +0 -123
- package/public/js/buyer-intent.js +0 -672
- package/public/pricing.html +0 -399
- package/scripts/action-receipts.js +0 -324
- package/scripts/activation-quickstart.js +0 -187
- package/scripts/agent-operations-planner.js +0 -621
- package/scripts/ai-component-inventory.js +0 -367
- package/scripts/async-eval-observability.js +0 -236
- package/scripts/audit.js +0 -65
- package/scripts/aws-blocks-guardrails.js +0 -228
- package/scripts/classifier-routing.js +0 -130
- package/scripts/dashboard-chat.js +0 -332
- package/scripts/feedback-aggregate.js +0 -281
- package/scripts/feedback-sanitizer.js +0 -105
- package/scripts/hob-pack.js +0 -591
- package/scripts/hook-stop-anti-claim.js +0 -301
- package/scripts/install-shim.js +0 -87
- package/scripts/mcp-oauth.js +0 -293
- package/scripts/noop-detect.js +0 -285
- package/scripts/omlx-smoke.js +0 -192
- package/scripts/parallel-workflow-orchestrator.js +0 -293
- package/scripts/plan-gate.js +0 -243
- package/scripts/plausible-domain-config.js +0 -99
- package/scripts/qa-scenario-planner.js +0 -136
- package/scripts/repeat-metric.js +0 -137
- package/scripts/secret-fixture-tokens.js +0 -61
- package/scripts/secret-redaction.js +0 -166
- package/scripts/self-harness-optimizer.js +0 -141
- package/scripts/self-healing-check.js +0 -193
- package/scripts/silent-failure-cluster.js +0 -531
- package/scripts/statusline-cache-read.js +0 -57
- package/scripts/sync-telemetry-from-prod.js +0 -374
- package/scripts/tool-contract-validator.js +0 -76
- package/scripts/trajectory-scorer.js +0 -63
- package/scripts/verify-marketing-pages-deployed.js +0 -195
- 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
|
-
};
|