thumbgate 1.30.0 → 1.34.0

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 (92) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -165,6 +165,95 @@ function summarizePermissionTier(profileName = getActiveMcpProfile()) {
165
165
  };
166
166
  }
167
167
 
168
+
169
+ function detectStopHookRegistered(projectRoot, existsSync, readFileSync) {
170
+ try {
171
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
172
+ if (!existsSync(settingsPath)) return false;
173
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
174
+ const stopHooks = settings?.hooks?.Stop || [];
175
+ const flat = Array.isArray(stopHooks)
176
+ ? stopHooks.flatMap((entry) => entry?.hooks || [entry])
177
+ : [];
178
+ return flat.some((hook) => String(hook?.command || '').includes('hook-stop-anti-claim'));
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ function recommendationForClaimState({
185
+ evaluatorReady,
186
+ configLoadFailed,
187
+ verifierCount,
188
+ stopHookRegistered,
189
+ configSource,
190
+ loadErrorMessage,
191
+ }) {
192
+ if (!evaluatorReady) {
193
+ return 'Universal claim evaluator module is missing from this install.';
194
+ }
195
+ if (configLoadFailed) {
196
+ return loadErrorMessage;
197
+ }
198
+ if (verifierCount === 0) {
199
+ return 'No claim verifiers configured. Copy config/gates/claim-verifiers.example.json to .thumbgate/claim-verifiers.json and point subjects at your sources of truth.';
200
+ }
201
+ if (!stopHookRegistered) {
202
+ return 'Claim verifiers are present, but the Claude Stop anti-claim hook is not registered in .claude/settings.json.';
203
+ }
204
+ return `Factual claim recheck is ready (${verifierCount} verifier(s) from ${configSource}).`;
205
+ }
206
+
207
+ function summarizeClaimVerification(projectRoot = PROJECT_ROOT, deps = {}) {
208
+ const resolveEvaluator = deps.resolveEvaluator
209
+ || (() => require.resolve('./universal-claim-evaluator'));
210
+ const loadVerifierConfig = deps.loadVerifierConfig
211
+ || (() => require('./universal-claim-evaluator').loadVerifierConfig);
212
+ const readFileSync = deps.readFileSync || fs.readFileSync;
213
+ const existsSync = deps.existsSync || fs.existsSync;
214
+
215
+ let evaluatorReady = false;
216
+ try {
217
+ resolveEvaluator();
218
+ evaluatorReady = true;
219
+ } catch {
220
+ evaluatorReady = false;
221
+ }
222
+
223
+ let verifierCount = 0;
224
+ let configSource = 'none';
225
+ let configLoadFailed = false;
226
+ let loadErrorMessage = 'Install ThumbGate and configure claim verifiers under .thumbgate/claim-verifiers.json.';
227
+ try {
228
+ const loaded = loadVerifierConfig()({ cwd: projectRoot });
229
+ verifierCount = Array.isArray(loaded.verifiers) ? loaded.verifiers.length : 0;
230
+ configSource = loaded.source || 'none';
231
+ } catch (error) {
232
+ configLoadFailed = true;
233
+ loadErrorMessage = `Claim verifier config failed to load: ${error?.message || 'unknown error'}`;
234
+ }
235
+
236
+ const stopHookRegistered = detectStopHookRegistered(projectRoot, existsSync, readFileSync);
237
+ const recommendation = recommendationForClaimState({
238
+ evaluatorReady,
239
+ configLoadFailed,
240
+ verifierCount,
241
+ stopHookRegistered,
242
+ configSource,
243
+ loadErrorMessage,
244
+ });
245
+
246
+ return {
247
+ ready: evaluatorReady && verifierCount > 0 && stopHookRegistered && !configLoadFailed,
248
+ evaluatorReady,
249
+ verifierCount,
250
+ configSource,
251
+ stopHookRegistered,
252
+ configLoadFailed,
253
+ recommendation,
254
+ };
255
+ }
256
+
168
257
  function generateAgentReadinessReport({
169
258
  projectRoot = PROJECT_ROOT,
170
259
  mcpProfile = null,
@@ -172,11 +261,18 @@ function generateAgentReadinessReport({
172
261
  const runtime = detectRuntimeIsolation();
173
262
  const bootstrap = collectBootstrapFiles(projectRoot);
174
263
  const permissions = summarizePermissionTier(mcpProfile || getActiveMcpProfile());
264
+ const claimVerification = summarizeClaimVerification(projectRoot);
175
265
 
176
266
  const warnings = [];
177
267
  if (!runtime.isolated) warnings.push(runtime.recommendation);
178
268
  if (!bootstrap.ready) warnings.push(bootstrap.recommendation);
179
269
  if (!permissions.ready) warnings.push(permissions.recommendation);
270
+ // Missing operator verifiers is advisory (not every project asserts SQL row counts).
271
+ // A missing evaluator module or a broken claim-verifier config is not advisory —
272
+ // both make factual recheck fail closed at runtime and must surface here.
273
+ if (!claimVerification.evaluatorReady || claimVerification.configLoadFailed) {
274
+ warnings.push(claimVerification.recommendation);
275
+ }
180
276
 
181
277
  return {
182
278
  generatedAt: new Date().toISOString(),
@@ -185,10 +281,12 @@ function generateAgentReadinessReport({
185
281
  runtime,
186
282
  bootstrap,
187
283
  permissions,
284
+ claimVerification,
188
285
  articleAlignment: {
189
286
  runtimeIsolation: runtime.isolated,
190
287
  contextConditioning: bootstrap.ready,
191
288
  permissionEnvelope: permissions.ready,
289
+ factualClaimRecheck: claimVerification.ready,
192
290
  },
193
291
  warnings,
194
292
  };
@@ -208,6 +306,15 @@ function reportToText(report) {
208
306
  lines.push(`Permissions: ${report.permissions.profile} (${report.permissions.tier})`);
209
307
  lines.push(` Write-capable tools: ${report.permissions.writeCapableTools.length}`);
210
308
  lines.push(` Recommendation: ${report.permissions.recommendation}`);
309
+ if (report.claimVerification) {
310
+ lines.push(
311
+ `Claim verification: ${report.claimVerification.ready ? 'ready' : 'needs_attention'}`,
312
+ ` Evaluator: ${report.claimVerification.evaluatorReady ? 'present' : 'missing'}`,
313
+ ` Verifiers: ${report.claimVerification.verifierCount} (${report.claimVerification.configSource})`,
314
+ ` Stop hook: ${report.claimVerification.stopHookRegistered ? 'registered' : 'missing'}`,
315
+ ` Recommendation: ${report.claimVerification.recommendation}`,
316
+ );
317
+ }
211
318
 
212
319
  if (report.warnings.length > 0) {
213
320
  lines.push('');
@@ -226,6 +333,9 @@ module.exports = {
226
333
  detectRuntimeIsolation,
227
334
  collectBootstrapFiles,
228
335
  summarizePermissionTier,
336
+ summarizeClaimVerification,
337
+ recommendationForClaimState,
338
+ detectStopHookRegistered,
229
339
  generateAgentReadinessReport,
230
340
  reportToText,
231
341
  };
@@ -3,11 +3,19 @@
3
3
 
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const {
7
+ claimSupportScore,
8
+ evaluateAnswerQuality,
9
+ queryCoverage,
10
+ splitAnswerClaims,
11
+ } = require('./rag-structured-output');
6
12
 
7
13
  const DEFAULT_THRESHOLDS = {
8
14
  faithfulness: 0.72,
9
15
  answerRelevance: 0.45,
10
16
  contextPrecision: 0.5,
17
+ groundedness: 0.75,
18
+ citationPrecision: 1,
11
19
  };
12
20
 
13
21
  function tokenize(value) {
@@ -30,10 +38,7 @@ function overlapScore(left, right) {
30
38
  }
31
39
 
32
40
  function splitClaims(response) {
33
- return String(response || '')
34
- .split(/(?:[.!?]\s+|\n+)/)
35
- .map((claim) => claim.trim())
36
- .filter((claim) => claim.length > 0);
41
+ return splitAnswerClaims(response);
37
42
  }
38
43
 
39
44
  function normalizeContexts(contexts) {
@@ -44,11 +49,10 @@ function normalizeContexts(contexts) {
44
49
 
45
50
  function scoreFaithfulness(response, contexts) {
46
51
  const claims = splitClaims(response);
47
- const contextText = normalizeContexts(contexts).join('\n');
52
+ const contextItems = normalizeContexts(contexts);
48
53
  if (claims.length === 0) return { score: 0, supportedClaims: 0, totalClaims: 0 };
49
54
  const supportedClaims = claims.filter((claim) => {
50
- const normalized = claim.toLowerCase();
51
- return contextText.toLowerCase().includes(normalized) || overlapScore(claim, contextText) >= 0.58;
55
+ return contextItems.some((context) => claimSupportScore(claim, context) >= 0.4);
52
56
  }).length;
53
57
  return {
54
58
  score: Number((supportedClaims / claims.length).toFixed(4)),
@@ -58,7 +62,7 @@ function scoreFaithfulness(response, contexts) {
58
62
  }
59
63
 
60
64
  function scoreAnswerRelevance(question, response) {
61
- const score = overlapScore(question, response);
65
+ const score = queryCoverage(question, response);
62
66
  return {
63
67
  score: Number(score.toFixed(4)),
64
68
  matchedQuestionTerms: unique(tokenize(question).filter((token) => tokenize(response).includes(token))),
@@ -97,14 +101,29 @@ function evaluateGeneration(testCase, options = {}) {
97
101
  contexts,
98
102
  testCase.reference || testCase.groundTruth || ''
99
103
  );
104
+ const answerQuality = evaluateAnswerQuality({
105
+ query: testCase.question || testCase.user_input,
106
+ answer: testCase.response || testCase.answer,
107
+ referenceAnswer: testCase.reference || testCase.groundTruth || '',
108
+ citations: testCase.citations,
109
+ contexts: contexts.map((text, index) => ({ id: `context-${index + 1}`, text })),
110
+ }, {
111
+ minFaithfulness: thresholds.faithfulness,
112
+ minGroundedness: thresholds.groundedness,
113
+ minAnswerRelevance: thresholds.answerRelevance,
114
+ });
100
115
  const scores = {
101
116
  faithfulness: faithfulness.score,
102
117
  answerRelevance: answerRelevance.score,
103
118
  contextPrecision: contextPrecision.score,
119
+ groundedness: answerQuality.metrics.groundedness,
120
+ citationPrecision: answerQuality.metrics.citationPrecision,
104
121
  };
105
122
  const passed = scores.faithfulness >= thresholds.faithfulness
106
123
  && scores.answerRelevance >= thresholds.answerRelevance
107
- && scores.contextPrecision >= thresholds.contextPrecision;
124
+ && scores.contextPrecision >= thresholds.contextPrecision
125
+ && scores.groundedness >= thresholds.groundedness
126
+ && scores.citationPrecision >= thresholds.citationPrecision;
108
127
 
109
128
  return {
110
129
  id: String(testCase.id || testCase.traceId || 'case'),
@@ -116,6 +135,7 @@ function evaluateGeneration(testCase, options = {}) {
116
135
  faithfulness,
117
136
  answerRelevance,
118
137
  contextPrecision,
138
+ answerQuality,
119
139
  },
120
140
  };
121
141
  }
@@ -155,6 +175,8 @@ function buildEvalReport(cases, options = {}) {
155
175
  faithfulness: average(results.map((result) => result.scores.faithfulness)),
156
176
  answerRelevance: average(results.map((result) => result.scores.answerRelevance)),
157
177
  contextPrecision: average(results.map((result) => result.scores.contextPrecision)),
178
+ groundedness: average(results.map((result) => result.scores.groundedness)),
179
+ citationPrecision: average(results.map((result) => result.scores.citationPrecision)),
158
180
  };
159
181
 
160
182
  return {
@@ -166,7 +188,7 @@ function buildEvalReport(cases, options = {}) {
166
188
  passRate: results.length === 0 ? 0 : Number(((passed / results.length) * 100).toFixed(2)),
167
189
  aggregate,
168
190
  passedThreshold: failed === 0,
169
- metrics: ['faithfulness', 'answerRelevance', 'contextPrecision'],
191
+ metrics: ['faithfulness', 'answerRelevance', 'contextPrecision', 'groundedness', 'citationPrecision'],
170
192
  sinks: {
171
193
  ci: true,
172
194
  langsmithCompatible: true,
@@ -228,7 +250,10 @@ module.exports = {
228
250
  scoreFaithfulness,
229
251
  };
230
252
 
231
- if (require.main === module) {
253
+ const invokedDirectly = Boolean(process.argv[1])
254
+ && path.resolve(process.argv[1]) === __filename;
255
+
256
+ if (invokedDirectly) {
232
257
  main().catch((err) => {
233
258
  console.error(err.stack || err.message);
234
259
  process.exitCode = 1;
@@ -61,7 +61,18 @@ function recordAuditEvent(params = {}) {
61
61
  source: params.source || 'gates-engine',
62
62
  };
63
63
 
64
- fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
64
+ // Safe stringify: never let circular/toxic tool inputs crash the gate path
65
+ // (Antithesis-style invariant: evaluation + audit must not throw).
66
+ let line;
67
+ try {
68
+ line = JSON.stringify(record);
69
+ } catch (err) {
70
+ line = JSON.stringify({
71
+ ...record,
72
+ toolInput: { _unserializable: true, reason: String(err.message || err).slice(0, 120) },
73
+ });
74
+ }
75
+ fs.appendFileSync(logPath, `${line}\n`);
65
76
  try {
66
77
  const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
67
78
  trainAndPersistInterventionPolicy(path.dirname(logPath));
@@ -73,10 +84,13 @@ function recordAuditEvent(params = {}) {
73
84
 
74
85
  /**
75
86
  * Strip secrets and large payloads from tool input before audit storage.
87
+ * Drop circular references so JSON.stringify never throws mid-gate evaluation.
76
88
  */
77
89
  function sanitizeToolInput(toolInput) {
90
+ if (!toolInput || typeof toolInput !== 'object') return {};
78
91
  const safe = {};
79
92
  const MAX_VALUE_LEN = 200;
93
+ const seen = new WeakSet();
80
94
 
81
95
  for (const [key, value] of Object.entries(toolInput)) {
82
96
  if (typeof value === 'string') {
@@ -88,6 +102,17 @@ function sanitizeToolInput(toolInput) {
88
102
  ? value.slice(0, MAX_VALUE_LEN) + '...'
89
103
  : value;
90
104
  }
105
+ } else if (value && typeof value === 'object') {
106
+ if (seen.has(value)) {
107
+ safe[key] = '[Circular]';
108
+ continue;
109
+ }
110
+ seen.add(value);
111
+ try {
112
+ safe[key] = JSON.parse(JSON.stringify(value, getCircularReplacer()));
113
+ } catch {
114
+ safe[key] = `[unserializable:${typeof value}]`;
115
+ }
91
116
  } else {
92
117
  safe[key] = value;
93
118
  }
@@ -95,6 +120,17 @@ function sanitizeToolInput(toolInput) {
95
120
  return safe;
96
121
  }
97
122
 
123
+ function getCircularReplacer() {
124
+ const seen = new WeakSet();
125
+ return function circularReplacer(_key, value) {
126
+ if (value && typeof value === 'object') {
127
+ if (seen.has(value)) return '[Circular]';
128
+ seen.add(value);
129
+ }
130
+ return value;
131
+ };
132
+ }
133
+
98
134
  // ---------------------------------------------------------------------------
99
135
  // Auto-feedback from audit events
100
136
  // ---------------------------------------------------------------------------
@@ -84,8 +84,32 @@ function regressionCheck(gate, options = {}) {
84
84
  let matchesGate;
85
85
  try { ({ matchesGate } = require('./gates-engine')); } catch { return null; }
86
86
  if (typeof matchesGate !== 'function') return null;
87
- const allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
87
+ let allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
88
88
  if (!allowed.length) return null;
89
+
90
+ // The command we just learned to block was, by definition, ALLOWED before we
91
+ // learned it — that prior allow IS the incident the operator thumbs-downed.
92
+ // Counting it as a false block quarantines every gate learned from a real
93
+ // failure, which is the normal path (run it, get burned, 👎 it). Exclude the
94
+ // originating contexts so the check only measures collateral damage to
95
+ // genuinely unrelated actions.
96
+ // Match on normalized command EQUALITY, not substring containment: a longer,
97
+ // genuinely different command that merely quotes the incident text (e.g.
98
+ // `notify-team --dry-run "<incident>"`) is real collateral damage and must
99
+ // still count toward quarantine.
100
+ const incidentSignatures = new Set(
101
+ (options.incidentContexts || [])
102
+ .map((c) => normalizeCommandSignature(String(c || '')))
103
+ .filter(Boolean),
104
+ );
105
+ if (incidentSignatures.size > 0) {
106
+ allowed = allowed.filter((e) => {
107
+ const cmd = (e.toolInput && (e.toolInput.command || e.toolInput.pattern)) || '';
108
+ return !incidentSignatures.has(normalizeCommandSignature(String(cmd)));
109
+ });
110
+ if (!allowed.length) return { falseBlocks: 0, allowSampleSize: 0 };
111
+ }
112
+
89
113
  let falseBlocks = 0;
90
114
  for (const e of allowed) {
91
115
  try {
@@ -163,14 +187,43 @@ function normalizeCommandSignature(input) {
163
187
  return tokens.join(' ').trim();
164
188
  }
165
189
 
166
- function extractPatternKey(entry) {
167
- // Use tags as primary grouping key; fall back to context normalization
168
- const tags = (entry.tags || []).filter((t) => !['feedback', 'negative', 'positive'].includes(t));
169
- if (tags.length > 0) return tags.sort().join('+');
190
+ /**
191
+ * Prefer an executable action we can match at PreToolUse time.
192
+ * Tag-only or pure prose feedback is useful memory — not an enforcement pattern.
193
+ */
194
+ function extractExecutableAction(entry) {
195
+ if (!entry || typeof entry !== 'object') return null;
196
+ const fromTool =
197
+ (entry.toolInput && (entry.toolInput.command || entry.toolInput.pattern))
198
+ || (entry.tool_input && (entry.tool_input.command || entry.tool_input.pattern))
199
+ || entry.command
200
+ || entry.failedCommand
201
+ || null;
202
+ if (fromTool && String(fromTool).trim().length >= 4) {
203
+ return String(fromTool).trim();
204
+ }
170
205
 
171
- const ctx = (entry.context || entry.whatWentWrong || '').trim();
172
- if (ctx.length < 10) return null;
173
- return normalizeCommandSignature(ctx).slice(0, 100);
206
+ const ctx = String(entry.context || entry.whatWentWrong || '').trim();
207
+ if (ctx.length < 4) return null;
208
+
209
+ // Looks like a shell / CLI invocation (not free-form prose).
210
+ const looksExecutable = /^(?:sudo\s+)?(?:~\/|\.\/|\/)?(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+(?:\s|$)/.test(ctx)
211
+ && /\s|^[a-z0-9._+-]+(?:\s|$)/i.test(ctx)
212
+ && !/\s+(?:broke|failed|wrong|should|never|please|the agent)\b/i.test(ctx.slice(0, 80));
213
+ // Strong signal: known tool prefixes
214
+ const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
215
+ if (known || (looksExecutable && /[\s-]/.test(ctx) && ctx.length <= 200)) {
216
+ return ctx;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function extractPatternKey(entry) {
222
+ // Enforcement groups by executable action only. Tags remain diagnostic metadata
223
+ // and must not create hard-block thresholds for a single unrelated latest command.
224
+ const action = extractExecutableAction(entry);
225
+ if (!action) return null;
226
+ return normalizeCommandSignature(action).slice(0, 100);
174
227
  }
175
228
 
176
229
  function extractDiagnosticKeys(entry) {
@@ -203,27 +256,29 @@ function groupNegativeFeedback(entries, windowDays) {
203
256
  const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
204
257
  if (ts < cutoff) continue;
205
258
 
206
- const keys = [extractPatternKey(entry), ...extractDiagnosticKeys(entry)]
207
- .filter(Boolean)
208
- .filter((value, index, values) => values.indexOf(value) === index);
209
- if (keys.length === 0) continue;
210
-
211
- for (const key of keys) {
212
- if (!groups[key]) {
213
- groups[key] = {
214
- key,
215
- count: 0,
216
- entries: [],
217
- latestContext: '',
218
- latestTimestamp: '',
219
- };
220
- }
221
- groups[key].count++;
222
- groups[key].entries.push(entry);
223
- if (!groups[key].latestTimestamp || (entry.timestamp && entry.timestamp > groups[key].latestTimestamp)) {
224
- groups[key].latestTimestamp = entry.timestamp || '';
225
- groups[key].latestContext = entry.context || entry.whatWentWrong || '';
226
- }
259
+ // Enforcement groups ONLY by executable action. Tag/diagnosis metadata is
260
+ // useful for memory and dashboards, but must not create hard-block thresholds
261
+ // that attach to an unrelated latest command.
262
+ const key = extractPatternKey(entry);
263
+ if (!key) continue;
264
+
265
+ if (!groups[key]) {
266
+ groups[key] = {
267
+ key,
268
+ count: 0,
269
+ entries: [],
270
+ latestContext: '',
271
+ latestTimestamp: '',
272
+ latestExecutable: '',
273
+ };
274
+ }
275
+ groups[key].count++;
276
+ groups[key].entries.push(entry);
277
+ if (!groups[key].latestTimestamp || (entry.timestamp && entry.timestamp > groups[key].latestTimestamp)) {
278
+ groups[key].latestTimestamp = entry.timestamp || '';
279
+ const action = extractExecutableAction(entry);
280
+ groups[key].latestContext = action || entry.context || entry.whatWentWrong || '';
281
+ groups[key].latestExecutable = action || '';
227
282
  }
228
283
  }
229
284
 
@@ -234,15 +289,48 @@ function patternToGateId(key) {
234
289
  return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
235
290
  }
236
291
 
292
+ /**
293
+ * Turn a captured context string into a pattern the gates engine can actually
294
+ * match. `gates-engine.js` compiles `gate.pattern` with `new RegExp(...)` and
295
+ * tests it against the tool-call text, so the pattern MUST be regex-safe text
296
+ * drawn from the command itself.
297
+ *
298
+ * It must NOT be the group key: keys are frequently tag-derived
299
+ * ("entity:Customer+entity:Funnel"), which is both meaningless against a command
300
+ * string and actively hazardous as a regex ('+' is a quantifier). Grouping by tag
301
+ * is correct — reusing that key as the match pattern is not.
302
+ */
303
+ function contextToPattern(context) {
304
+ const raw = String(context || '').trim();
305
+ if (raw.length < 4) return null;
306
+ // Escape every regex metacharacter: the captured command is literal text.
307
+ return raw.slice(0, 120).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
+ }
309
+
310
+ /**
311
+ * A gate that cannot match the very context that produced it is inert — it
312
+ * shows up in the dashboard as an active blocking rule while enforcing nothing.
313
+ * That failure mode is worse than no gate at all, so callers drop these.
314
+ */
315
+ function gateMatchesOwnContext(gate, context) {
316
+ if (!gate || !gate.pattern) return false;
317
+ try {
318
+ return new RegExp(gate.pattern).test(String(context || ''));
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
237
324
  function buildGateRule(group, actionOverride) {
238
325
  const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
239
326
  const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
240
- const context = group.latestContext.slice(0, 120);
327
+ const executable = (group.latestExecutable || extractExecutableAction({ context: group.latestContext }) || group.latestContext || '').slice(0, 120);
328
+ const context = executable;
241
329
  const kind = group.key.startsWith('diagnosis:')
242
330
  ? 'repeated diagnosis'
243
331
  : group.key.startsWith('constraint:')
244
332
  ? 'repeated constraint violation'
245
- : 'repeated pattern';
333
+ : 'repeated executable action';
246
334
 
247
335
  const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
248
336
  const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
@@ -257,7 +345,8 @@ function buildGateRule(group, actionOverride) {
257
345
  return {
258
346
  id: patternToGateId(group.key),
259
347
  trigger: `auto:${group.key}`,
260
- pattern: group.key.replace(/^diagnosis:|constraint:/, ''),
348
+ // Derived from the executable action, NOT from tag keys — see contextToPattern.
349
+ pattern: contextToPattern(executable),
261
350
  action,
262
351
  message: suggestedMessage,
263
352
  severity,
@@ -393,6 +482,16 @@ function promote(feedbackLogPath, options) {
393
482
 
394
483
  const gateId = patternToGateId(group.key);
395
484
 
485
+ // Contexts that produced this gate. Their prior "allow" decisions are the
486
+ // incident the operator thumbs-downed, not false positives — both the
487
+ // new-gate and the warn->block upgrade path must exclude them from the
488
+ // regression check, or every gate learned from a real failure is held at warn.
489
+ const incidentContexts = [
490
+ group.latestContext,
491
+ ...(group.entries || []).map((e) => e && (e.context || e.whatWentWrong)),
492
+ ].filter(Boolean);
493
+ const regressionOpts = { ...opts, incidentContexts };
494
+
396
495
  // Check for existing gate — possibly upgrade
397
496
  const existingIdx = data.gates.findIndex((g) => g.id === gateId);
398
497
  if (existingIdx !== -1) {
@@ -400,7 +499,7 @@ function promote(feedbackLogPath, options) {
400
499
  const newAction = group.count >= BLOCK_THRESHOLD ? 'block' : 'warn';
401
500
  if (existing.action !== newAction && newAction === 'block') {
402
501
  // Self-Harness stage 3: regression-test before upgrading warn -> block.
403
- const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), opts);
502
+ const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), regressionOpts);
404
503
  if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
405
504
  // Would block prior safe actions — hold at warn instead of upgrading.
406
505
  promotions.push({ type: 'upgrade-quarantined', gateId, from: existing.action, occurrences: group.count, falseBlocks: regression.falseBlocks });
@@ -418,12 +517,25 @@ function promote(feedbackLogPath, options) {
418
517
  // New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
419
518
  const gate = buildGateRule(group, opts.gateAction);
420
519
 
520
+ // Never persist a gate that cannot match the context that produced it. Such a
521
+ // gate renders in the dashboard as an active blocking rule while enforcing
522
+ // nothing, which reads as "the agent learned" when it did not.
523
+ if (!gateMatchesOwnContext(gate, group.latestContext)) {
524
+ promotions.push({
525
+ type: 'skipped-unmatchable',
526
+ gateId: gate.id,
527
+ reason: 'derived pattern does not match originating context',
528
+ occurrences: group.count,
529
+ });
530
+ continue;
531
+ }
532
+
421
533
  // Self-Harness stage 3: before a feedback rule goes live as a hard block,
422
534
  // regression-test it against prior allowed actions. If it would have blocked
423
535
  // safe actions, quarantine it to `warn` instead of `block`.
424
536
  let regression = null;
425
537
  if (gate.action === 'block' && !opts.gateAction && !opts.skipRegression) {
426
- regression = safeRegressionCheck(gate, opts);
538
+ regression = safeRegressionCheck(gate, regressionOpts);
427
539
  if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
428
540
  gate.action = 'warn';
429
541
  gate.severity = 'medium';
@@ -506,10 +618,13 @@ module.exports = {
506
618
  groupNegativeFeedback,
507
619
  patternToGateId,
508
620
  buildGateRule,
621
+ contextToPattern,
622
+ gateMatchesOwnContext,
509
623
  regressionCheck,
510
624
  getAuditTrailPath,
511
625
  REGRESSION_FALSE_BLOCK_LIMIT,
512
626
  extractPatternKey,
627
+ extractExecutableAction,
513
628
  normalizeCommandSignature,
514
629
  isNegative,
515
630
  expireGates,