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.
@@ -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.10",
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": {
@@ -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 || 0), 0);
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 || 0), 0);
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' && Number(g.occurrences || 0) > 0)
51
- .sort((a, b) => (b.occurrences || 0) - (a.occurrences || 0))
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 = Number(gate.occurrences || 0);
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
  };
@@ -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
- * (informational): the goal is to surface a system reminder in the next
21
- * turn so the agent corrects mid-conversation rather than to hard-block
22
- * the turn that already happened.
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
- const reminder = [
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,
@@ -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 memories = readJSONL(MEMORY_LOG_PATH);
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);