thumbgate 1.27.10 → 1.27.12
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/config/builtin-lessons.json +23 -0
- package/package.json +1 -1
- package/scripts/gate-stats.js +11 -5
- package/scripts/gates-engine.js +49 -0
- package/scripts/hook-stop-anti-claim.js +93 -12
- package/scripts/lesson-search.js +15 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"lessons": [
|
|
4
|
+
{
|
|
5
|
+
"id": "builtin-response-quality-shallow-positive-closeout",
|
|
6
|
+
"title": "MISTAKE: Assistant gave shallow acknowledgement after user said thumbs up",
|
|
7
|
+
"content": "What went wrong: Assistant gave shallow acknowledgement after positive feedback, such as thumbs up, perfect, good, or thank you, instead of staying quiet or giving an evidence checkpoint.\nHow to avoid: If the previous user message is positive feedback and the proposed final response is a low-value social closeout, block it and require either silence-level brevity or a compact evidence checkpoint with proof, result, residual risk, and next state.\nAction needed: Enforce this at the final-response boundary, not only as PreToolUse context.\nReasoning: Stored lessons and retrieval are not enough when the model can still ignore the lesson at generation time.",
|
|
8
|
+
"category": "error",
|
|
9
|
+
"importance": "high",
|
|
10
|
+
"tags": [
|
|
11
|
+
"feedback",
|
|
12
|
+
"negative",
|
|
13
|
+
"response-quality",
|
|
14
|
+
"final-response",
|
|
15
|
+
"positive-feedback",
|
|
16
|
+
"shallow-closeout",
|
|
17
|
+
"enforcement"
|
|
18
|
+
],
|
|
19
|
+
"timestamp": "2026-06-20T19:01:58.000Z",
|
|
20
|
+
"source": "packaged-builtin"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thumbgate",
|
|
3
|
-
"version": "1.27.
|
|
3
|
+
"version": "1.27.12",
|
|
4
4
|
"description": "ThumbGate self-improving agent governance: thumbs-up/down turns every mistake into a prevention rule and blocks repeat patterns. 36 pre-action checks, budget enforcement, and self-protection for Claude Code, Cursor, Codex, Gemini CLI, and Amp.",
|
|
5
5
|
"homepage": "https://thumbgate.ai",
|
|
6
6
|
"repository": {
|
package/scripts/gate-stats.js
CHANGED
|
@@ -11,6 +11,11 @@ const PROJECT_ROOT = path.join(__dirname, '..');
|
|
|
11
11
|
const MANUAL_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
|
|
12
12
|
const STATS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'gate-stats.json');
|
|
13
13
|
|
|
14
|
+
function safeOccurrenceCount(value) {
|
|
15
|
+
const n = Number(value);
|
|
16
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
function loadGatesFile(filePath) {
|
|
15
20
|
if (!fs.existsSync(filePath)) return [];
|
|
16
21
|
try {
|
|
@@ -39,16 +44,16 @@ function calculateStats() {
|
|
|
39
44
|
// Count total blocks/warns from occurrences in auto-promoted gates
|
|
40
45
|
const totalBlocked = autoGates
|
|
41
46
|
.filter((g) => g.action === 'block')
|
|
42
|
-
.reduce((sum, g) => sum + (g.occurrences
|
|
47
|
+
.reduce((sum, g) => sum + safeOccurrenceCount(g.occurrences), 0);
|
|
43
48
|
const totalWarned = autoGates
|
|
44
49
|
.filter((g) => g.action === 'warn')
|
|
45
|
-
.reduce((sum, g) => sum + (g.occurrences
|
|
50
|
+
.reduce((sum, g) => sum + safeOccurrenceCount(g.occurrences), 0);
|
|
46
51
|
|
|
47
52
|
// Top blocked gate. A configured block rule with zero occurrences is not a
|
|
48
53
|
// "top blocker"; only recorded block events should appear here.
|
|
49
54
|
const topBlocked = [...allGates]
|
|
50
|
-
.filter((g) => g.action === 'block' &&
|
|
51
|
-
.sort((a, b) => (b.occurrences
|
|
55
|
+
.filter((g) => g.action === 'block' && safeOccurrenceCount(g.occurrences) > 0)
|
|
56
|
+
.sort((a, b) => safeOccurrenceCount(b.occurrences) - safeOccurrenceCount(a.occurrences))
|
|
52
57
|
.at(0) || null;
|
|
53
58
|
|
|
54
59
|
// Last promotion event
|
|
@@ -105,7 +110,7 @@ function computeCalibration(gates) {
|
|
|
105
110
|
const calibration = [];
|
|
106
111
|
for (const gate of gates || []) {
|
|
107
112
|
if (!gate || !gate.id) continue;
|
|
108
|
-
const occurrences =
|
|
113
|
+
const occurrences = safeOccurrenceCount(gate.occurrences);
|
|
109
114
|
const action = gate.action || 'unknown';
|
|
110
115
|
// Only annotate gates with recorded occurrence data
|
|
111
116
|
if (occurrences === 0) continue;
|
|
@@ -258,6 +263,7 @@ module.exports = {
|
|
|
258
263
|
loadGatesFile,
|
|
259
264
|
tryComputeBayesErrorRate,
|
|
260
265
|
computeCalibration,
|
|
266
|
+
safeOccurrenceCount,
|
|
261
267
|
MANUAL_GATES_PATH,
|
|
262
268
|
STATS_PATH,
|
|
263
269
|
};
|
package/scripts/gates-engine.js
CHANGED
|
@@ -16,6 +16,13 @@ const {
|
|
|
16
16
|
const {
|
|
17
17
|
evaluateWorkflowSentinel,
|
|
18
18
|
} = require('./workflow-sentinel');
|
|
19
|
+
const {
|
|
20
|
+
extractPayloadText,
|
|
21
|
+
extractPayloadPreviousUserText,
|
|
22
|
+
hasPositiveFeedback,
|
|
23
|
+
isLowValueCloseout,
|
|
24
|
+
buildResponseQualityReason,
|
|
25
|
+
} = require('./hook-stop-anti-claim');
|
|
19
26
|
const {
|
|
20
27
|
recordDecisionEvaluation,
|
|
21
28
|
recordDecisionOutcome,
|
|
@@ -2585,6 +2592,39 @@ function buildReminderOutput(context) {
|
|
|
2585
2592
|
});
|
|
2586
2593
|
}
|
|
2587
2594
|
|
|
2595
|
+
function inferHookEventName(input = {}) {
|
|
2596
|
+
const explicit = input.hook_event_name || input.hookEventName || input.event || input.lifecycle;
|
|
2597
|
+
if (explicit) return String(explicit);
|
|
2598
|
+
return extractPayloadText(input) ? 'Stop' : 'PreToolUse';
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
function buildResponseQualityBlockOutput(reason, input = {}) {
|
|
2602
|
+
return JSON.stringify({
|
|
2603
|
+
decision: 'block',
|
|
2604
|
+
reason,
|
|
2605
|
+
hookSpecificOutput: {
|
|
2606
|
+
hookEventName: inferHookEventName(input),
|
|
2607
|
+
permissionDecision: 'deny',
|
|
2608
|
+
permissionDecisionReason: reason,
|
|
2609
|
+
},
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function evaluateFinalResponseQualityGate(input = {}) {
|
|
2614
|
+
const finalText = extractPayloadText(input) || process.env.CLAUDE_RESPONSE || '';
|
|
2615
|
+
const previousUserText = extractPayloadPreviousUserText(input)
|
|
2616
|
+
|| process.env.CLAUDE_PREVIOUS_USER_TEXT
|
|
2617
|
+
|| process.env.CLAUDE_PREVIOUS_USER
|
|
2618
|
+
|| '';
|
|
2619
|
+
|
|
2620
|
+
if (!finalText || !hasPositiveFeedback(previousUserText)) return null;
|
|
2621
|
+
if (!isLowValueCloseout(finalText, '')) return null;
|
|
2622
|
+
recordStat('response-quality-shallow-closeout', 'block', null, {
|
|
2623
|
+
hookEventName: inferHookEventName(input),
|
|
2624
|
+
});
|
|
2625
|
+
return buildResponseQualityBlockOutput(buildResponseQualityReason(), input);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2588
2628
|
// ---------------------------------------------------------------------------
|
|
2589
2629
|
// Upgrade nudge: surfaces Pro value at usage milestones and trial expiry.
|
|
2590
2630
|
// Block-action Pro CTA: brief upgrade mention after a deny/warn decision.
|
|
@@ -2915,6 +2955,9 @@ function mergeContextStrings(...ctxs) {
|
|
|
2915
2955
|
}
|
|
2916
2956
|
|
|
2917
2957
|
async function runAsync(input) {
|
|
2958
|
+
const responseQualityGate = evaluateFinalResponseQualityGate(input);
|
|
2959
|
+
if (responseQualityGate) return responseQualityGate;
|
|
2960
|
+
|
|
2918
2961
|
const secretGuard = evaluateSecretGuard(input);
|
|
2919
2962
|
if (secretGuard) {
|
|
2920
2963
|
return formatOutput(secretGuard);
|
|
@@ -2962,6 +3005,9 @@ async function runAsync(input) {
|
|
|
2962
3005
|
}
|
|
2963
3006
|
|
|
2964
3007
|
function run(input) {
|
|
3008
|
+
const responseQualityGate = evaluateFinalResponseQualityGate(input);
|
|
3009
|
+
if (responseQualityGate) return responseQualityGate;
|
|
3010
|
+
|
|
2965
3011
|
const secretGuard = evaluateSecretGuard(input);
|
|
2966
3012
|
if (secretGuard) {
|
|
2967
3013
|
return formatOutput(secretGuard);
|
|
@@ -3296,6 +3342,9 @@ module.exports = {
|
|
|
3296
3342
|
evaluateGatesAsync,
|
|
3297
3343
|
computeExecutableHash,
|
|
3298
3344
|
formatOutput,
|
|
3345
|
+
inferHookEventName,
|
|
3346
|
+
buildResponseQualityBlockOutput,
|
|
3347
|
+
evaluateFinalResponseQualityGate,
|
|
3299
3348
|
isApprovalGatesEnabled,
|
|
3300
3349
|
run,
|
|
3301
3350
|
runAsync,
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* ThumbGate dogfood — we are the prevention-rule generator and a perfect
|
|
17
17
|
* customer for our own gate.
|
|
18
18
|
*
|
|
19
|
-
* Wires through .claude/settings.json Stop hooks list. Always exits 0
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the turn
|
|
19
|
+
* Wires through .claude/settings.json Stop hooks list. Always exits 0 and
|
|
20
|
+
* emits a Claude-hook `decision:"block"` JSON payload when a final-response
|
|
21
|
+
* violation can be evaluated before the response is accepted. Transcript-only
|
|
22
|
+
* runs still surface a reminder for the next turn when the host has already
|
|
23
|
+
* written the assistant response.
|
|
23
24
|
*
|
|
24
25
|
* Stdin: Claude Code passes the hook payload as JSON on stdin. We read
|
|
25
26
|
* `transcript_path` to locate the JSONL session log and scan the last
|
|
@@ -203,6 +204,79 @@ function hasProof(combined) {
|
|
|
203
204
|
return PROOF_PATTERNS.some((p) => p.test(combined));
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
function extractPayloadText(payload) {
|
|
208
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
209
|
+
const candidates = [
|
|
210
|
+
payload.response,
|
|
211
|
+
payload.assistant_response,
|
|
212
|
+
payload.assistantResponse,
|
|
213
|
+
payload.final_response,
|
|
214
|
+
payload.finalResponse,
|
|
215
|
+
payload.text,
|
|
216
|
+
payload.output,
|
|
217
|
+
payload.message,
|
|
218
|
+
];
|
|
219
|
+
for (const candidate of candidates) {
|
|
220
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate;
|
|
221
|
+
if (candidate && typeof candidate === 'object') {
|
|
222
|
+
const extracted = extractText(candidate);
|
|
223
|
+
if (extracted.trim()) return extracted;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function extractPayloadPreviousUserText(payload) {
|
|
230
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
231
|
+
const candidates = [
|
|
232
|
+
payload.previous_user_text,
|
|
233
|
+
payload.previousUserText,
|
|
234
|
+
payload.user_prompt,
|
|
235
|
+
payload.userPrompt,
|
|
236
|
+
payload.prompt,
|
|
237
|
+
];
|
|
238
|
+
for (const candidate of candidates) {
|
|
239
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate;
|
|
240
|
+
if (candidate && typeof candidate === 'object') {
|
|
241
|
+
const extracted = extractText(candidate);
|
|
242
|
+
if (extracted.trim()) return extracted;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildResponseQualityReason() {
|
|
249
|
+
return [
|
|
250
|
+
'ThumbGate response-quality gate: final response answered positive feedback',
|
|
251
|
+
'with a low-value social closeout instead of silence-level brevity or an evidence checkpoint.',
|
|
252
|
+
'Positive feedback after operational work should trigger either no extra noise,',
|
|
253
|
+
'a compact evidence checkpoint, or a concrete next-state update.',
|
|
254
|
+
'Do not reply with generic "Good / Great / Use X/Y" filler.',
|
|
255
|
+
].join(' ');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildResponseQualityReminder() {
|
|
259
|
+
return [
|
|
260
|
+
'⚠️ ThumbGate response-quality gate: previous turn answered positive feedback',
|
|
261
|
+
' with a low-value social closeout instead of silence-level brevity or an evidence checkpoint.',
|
|
262
|
+
' Positive feedback after operational work should trigger either no extra noise,',
|
|
263
|
+
' a compact evidence checkpoint, or a concrete next-state update.',
|
|
264
|
+
' Do not reply with generic "Good / Great / Use X/Y" filler.',
|
|
265
|
+
].join('\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function writeBlock(reason) {
|
|
269
|
+
process.stdout.write(JSON.stringify({
|
|
270
|
+
decision: 'block',
|
|
271
|
+
reason,
|
|
272
|
+
hookSpecificOutput: {
|
|
273
|
+
hookEventName: 'Stop',
|
|
274
|
+
permissionDecision: 'deny',
|
|
275
|
+
permissionDecisionReason: reason,
|
|
276
|
+
},
|
|
277
|
+
}) + '\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
206
280
|
function readStdinSync() {
|
|
207
281
|
try {
|
|
208
282
|
return fs.readFileSync(0, 'utf8');
|
|
@@ -221,6 +295,17 @@ function main() {
|
|
|
221
295
|
}
|
|
222
296
|
|
|
223
297
|
const transcriptPath = payload.transcript_path || process.env.CLAUDE_TRANSCRIPT_PATH;
|
|
298
|
+
const directText = extractPayloadText(payload) || process.env.CLAUDE_RESPONSE || '';
|
|
299
|
+
const directPreviousUserText = extractPayloadPreviousUserText(payload)
|
|
300
|
+
|| process.env.CLAUDE_PREVIOUS_USER_TEXT
|
|
301
|
+
|| process.env.CLAUDE_PREVIOUS_USER
|
|
302
|
+
|| '';
|
|
303
|
+
|
|
304
|
+
if (directText && hasPositiveFeedback(directPreviousUserText) && isLowValueCloseout(directText, '')) {
|
|
305
|
+
writeBlock(buildResponseQualityReason());
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
224
309
|
const message = readLastAssistantTurn(transcriptPath);
|
|
225
310
|
if (!message) return; // no transcript visible; nothing to check
|
|
226
311
|
|
|
@@ -229,14 +314,7 @@ function main() {
|
|
|
229
314
|
const previousUserText = readPreviousUserText(transcriptPath);
|
|
230
315
|
|
|
231
316
|
if (hasPositiveFeedback(previousUserText) && isLowValueCloseout(text, toolUseSummary)) {
|
|
232
|
-
|
|
233
|
-
'⚠️ ThumbGate response-quality gate: previous turn answered positive feedback',
|
|
234
|
-
' with a low-value social closeout instead of silence-level brevity or an evidence checkpoint.',
|
|
235
|
-
' Positive feedback after operational work should trigger either no extra noise,',
|
|
236
|
-
' a compact evidence checkpoint, or a concrete next-state update.',
|
|
237
|
-
' Do not reply with generic "Good / Great / Use X/Y" filler.',
|
|
238
|
-
].join('\n');
|
|
239
|
-
process.stdout.write(reminder + '\n');
|
|
317
|
+
process.stdout.write(buildResponseQualityReminder() + '\n');
|
|
240
318
|
return;
|
|
241
319
|
}
|
|
242
320
|
|
|
@@ -282,6 +360,9 @@ module.exports = {
|
|
|
282
360
|
hasProof,
|
|
283
361
|
hasPositiveFeedback,
|
|
284
362
|
isLowValueCloseout,
|
|
363
|
+
extractPayloadText,
|
|
364
|
+
extractPayloadPreviousUserText,
|
|
365
|
+
buildResponseQualityReason,
|
|
285
366
|
extractText,
|
|
286
367
|
extractToolUseSummary,
|
|
287
368
|
readPreviousUserText,
|
package/scripts/lesson-search.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
4
5
|
const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
|
|
5
6
|
const { buildMemoryLifecycleView, scoreHybridMemoryMatch } = require('./agent-memory-lifecycle');
|
|
6
7
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
@@ -166,6 +167,17 @@ function resolveLessonPaths(options = {}) {
|
|
|
166
167
|
};
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
function readPackagedBuiltinLessons() {
|
|
171
|
+
if (process.env.THUMBGATE_DISABLE_BUILTIN_LESSONS === '1') return [];
|
|
172
|
+
const builtinPath = path.resolve(__dirname, '..', 'config', 'builtin-lessons.json');
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(fs.readFileSync(builtinPath, 'utf8'));
|
|
175
|
+
return Array.isArray(parsed.lessons) ? parsed.lessons : [];
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
169
181
|
function readPreventionRuleMatches(queryText, limit = 3, options = {}) {
|
|
170
182
|
const { PREVENTION_RULES_PATH } = resolveLessonPaths(options);
|
|
171
183
|
if (!PREVENTION_RULES_PATH) return [];
|
|
@@ -481,7 +493,9 @@ function searchLessons(query = '', options = {}) {
|
|
|
481
493
|
const sqliteResults = tryFts5Search(query, options);
|
|
482
494
|
if (sqliteResults) return sqliteResults;
|
|
483
495
|
|
|
484
|
-
const
|
|
496
|
+
const localMemories = readJSONL(MEMORY_LOG_PATH);
|
|
497
|
+
const builtinMemories = options.includeBuiltinLessons === false ? [] : readPackagedBuiltinLessons();
|
|
498
|
+
const memories = [...builtinMemories, ...localMemories];
|
|
485
499
|
const feedbackEntries = readJSONL(FEEDBACK_LOG_PATH);
|
|
486
500
|
const feedbackById = new Map(feedbackEntries.map((entry) => [entry.id, entry]));
|
|
487
501
|
const parsedLimit = Number(options.limit || 10);
|