thumbgate 1.25.2 → 1.26.1

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.
@@ -106,6 +106,7 @@
106
106
  <tr><td>Allocate spend to teams / features</td><td>✅</td><td>Per-gate breakdown via <code>byGate</code></td></tr>
107
107
  <tr><td>Stop a known-bad tool call before it hits the model</td><td>❌</td><td>✅ — PreToolUse gate fires, no API call made</td></tr>
108
108
  <tr><td>Promote a one-off failure into a permanent gate</td><td>❌</td><td>✅ — feedback loop + lesson DB</td></tr>
109
+ <tr><td>Surface gate candidates from <em>silent</em> failures (where no human ever gave thumbs-down)</td><td>❌</td><td>✅ — unsupervised silent-failure clustering, on by default</td></tr>
109
110
  <tr><td>Print conservative $ saved per day</td><td>❌</td><td>✅ — <code>thumbgate cost</code></td></tr>
110
111
  <tr><td>K8s pod-level allocation, finance-grade reporting</td><td>✅ (that's their core)</td><td>❌ (not our layer)</td></tr>
111
112
  </tbody>
@@ -117,6 +118,7 @@
117
118
  <li><strong>Every block is one fewer round trip.</strong> A blocked tool call doesn't reach the model. There's no "ThumbGate intercepted but the request still cost you" — the agent's tool-call execution is replaced with the gate's verdict, and the agent's next reasoning step takes the verdict as context instead of the failed result.</li>
118
119
  <li><strong>The avoided retry loop is the bulk of the saving.</strong> Failed tool calls don't just cost the call — they cost the model's next reasoning turn (which sees the failure and tries again), and often a third turn (which tries a different approach). Conservative 2k input + 600 output assumes one retry; in practice it's often more.</li>
119
120
  <li><strong>The numbers come from your local <code>gate-stats.json</code>.</strong> Not from a marketing model, not from "what enterprises like you saved." Your machine, your gates, your blocks.</li>
121
+ <li><strong>You don't have to give thumbs-down for the system to learn.</strong> ThumbGate's unsupervised <em>silent-failure clustering</em> runs by default — it mines your conversation logs for tool calls that failed (non-zero exit code or matched error patterns) but never got an explicit thumbs-down, clusters them by tool + argument shape, and surfaces them as gate candidates. The same false-positive-rate guardrail that vets human-feedback candidates vets these. Lazy users still get the savings. Opt out with <code>THUMBGATE_SILENT_FAILURE_CLUSTERING=0</code> if you want HITL-only.</li>
120
122
  </ol>
121
123
 
122
124
  <h2>Get the number on your machine</h2>
package/public/index.html CHANGED
@@ -20,7 +20,7 @@ __GOOGLE_SITE_VERIFICATION_META__
20
20
  <meta property="og:image" content="https://thumbgate.ai/og.png">
21
21
  <meta name="twitter:card" content="summary_large_image">
22
22
  <meta name="twitter:image" content="https://thumbgate.ai/og.png">
23
- <meta name="thumbgate-version" content="1.25.2">
23
+ <meta name="thumbgate-version" content="1.26.1">
24
24
  <meta name="keywords" content="ThumbGate, thumbgate, AI agent orchestration, AI experience orchestration, agentic development cycle, AC/DC framework, Guide Generate Verify Solve, agent enforcement layer, save LLM tokens, reduce Claude API cost, reduce OpenAI cost, AI agent token savings, prevent LLM retries, prevent hallucination retries, stop AI token waste, pre-action checks, agent governance, Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode, workflow hardening, context engineering, AI authenticity, brand authenticity AI">
25
25
  <link rel="canonical" href="__APP_ORIGIN__/">
26
26
  <link rel="alternate" type="text/markdown" title="ThumbGate LLM context" href="__APP_ORIGIN__/llm-context.md">
@@ -1536,6 +1536,14 @@ __GA_BOOTSTRAP__
1536
1536
  <div class="faq-q" role="button" tabindex="0" aria-expanded="false" onclick="toggleFaq(this)" onkeydown="handleFaqKeydown(event)">What does Pro cost?</div>
1537
1537
  <div class="faq-a">Pro is $19/mo or $149/yr for individual operators and bills immediately through Stripe. Team is $49/seat/mo with a 3-seat minimum and starts through the workflow intake so scope, shared rules, and rollout proof are explicit before a team rollout.</div>
1538
1538
  </div>
1539
+ <div class="faq-item">
1540
+ <div class="faq-q" role="button" tabindex="0" aria-expanded="false" onclick="toggleFaq(this)" onkeydown="handleFaqKeydown(event)">Does ThumbGate support enterprise Google Cloud / Vertex AI?</div>
1541
+ <div class="faq-a">Yes! ThumbGate features a zero-friction enterprise setup path via <code>npx thumbgate setup-vertex</code>. This command automatically detects your active gcloud session, enables the Vertex AI API on your Google Cloud project, and configures secure Application Default Credentials (ADC) to route all evaluations within your corporate VPC.</div>
1542
+ </div>
1543
+ <div class="faq-item">
1544
+ <div class="faq-q" role="button" tabindex="0" aria-expanded="false" onclick="toggleFaq(this)" onkeydown="handleFaqKeydown(event)">How does ThumbGate contain enterprise API costs?</div>
1545
+ <div class="faq-a">ThumbGate prevents runaway API costs through a local client-side token ledger (FrontierBudget) that enforces strict cost-containment limits (such as keeping monthly costs under $10/mo). Because GCP billing console alerts are delayed, our local circuit breaker halts runaway agent loops in milliseconds to guarantee budget protection.</div>
1546
+ </div>
1539
1547
  </div>
1540
1548
  </div>
1541
1549
  </section>
@@ -1586,7 +1594,7 @@ __GA_BOOTSTRAP__
1586
1594
  <a href="https://www.linkedin.com/in/igorganapolsky" target="_blank" rel="noopener">LinkedIn</a>
1587
1595
  <a href="/blog">Blog</a>
1588
1596
  </div>
1589
- <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.25.2</span>
1597
+ <span class="footer-copy">© 2026 ThumbGate · MIT License · npm v1.26.1</span>
1590
1598
  </div>
1591
1599
  </footer>
1592
1600
 
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.25.2",
28
+ "softwareVersion": "1.26.1",
29
29
  "url": "https://thumbgate.ai/numbers",
30
30
  "dateModified": "2026-05-07",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-05-07 · Version 1.25.2</div>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.26.1</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Action Receipts — outcome-paired lessons.
6
+ *
7
+ * Pairs each tracked tool call with its concrete result (diff / exit code /
8
+ * test outcome / state hash) so that a promoted prevention rule can encode
9
+ * "this action -> this outcome" rather than only a bare thumbs signal.
10
+ *
11
+ * Receipts are persisted as JSONL beside the other feedback artifacts
12
+ * (FEEDBACK_DIR/action-receipts.jsonl) using the same project-scoped
13
+ * resolution as the rest of the feedback pipeline (feedback-paths).
14
+ *
15
+ * This module is self-contained: it only depends on feedback-paths + fs and
16
+ * makes no edits to shared files. It is consumed from the MCP adapter wiring
17
+ * step (record_action_receipt / get_action_receipts tools) and threads into
18
+ * capture_feedback's lesson pipeline + construct_context_pack.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { getFeedbackPaths } = require('./feedback-paths');
24
+
25
+ const RECEIPTS_FILE = 'action-receipts.jsonl';
26
+
27
+ /**
28
+ * Resolve the absolute path to the receipts JSONL for the active project.
29
+ * @param {object} [options] - Passed through to getFeedbackPaths (e.g. for tests).
30
+ * @returns {string}
31
+ */
32
+ function getReceiptsPath(options = {}) {
33
+ const { FEEDBACK_DIR } = getFeedbackPaths(options);
34
+ return path.join(FEEDBACK_DIR, RECEIPTS_FILE);
35
+ }
36
+
37
+ function ensureDirFor(filePath) {
38
+ try {
39
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
40
+ } catch {
41
+ // best-effort; write will surface a real error if the dir truly cannot exist
42
+ }
43
+ }
44
+
45
+ function safeString(value) {
46
+ if (value === null || value === undefined) return '';
47
+ if (typeof value === 'string') return value;
48
+ try {
49
+ return String(value);
50
+ } catch {
51
+ return '';
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Build a short, human-readable summary of a tool input for the paired-lesson
57
+ * string. Never throws; clamps length so the lesson stays compact.
58
+ * @param {*} toolInput
59
+ * @returns {string}
60
+ */
61
+ function summarizeInput(toolInput) {
62
+ if (toolInput === null || toolInput === undefined) return '';
63
+ if (typeof toolInput === 'string') return clampText(toolInput, 120);
64
+
65
+ if (typeof toolInput === 'object') {
66
+ // Prefer the most lesson-relevant keys when present.
67
+ const preferredKeys = ['file', 'filePath', 'path', 'command', 'cmd', 'query', 'pattern'];
68
+ for (const key of preferredKeys) {
69
+ if (toolInput[key]) {
70
+ return `${key}=${clampText(safeString(toolInput[key]), 100)}`;
71
+ }
72
+ }
73
+ try {
74
+ return clampText(JSON.stringify(toolInput), 120);
75
+ } catch {
76
+ return '';
77
+ }
78
+ }
79
+
80
+ return clampText(safeString(toolInput), 120);
81
+ }
82
+
83
+ function clampText(text, max) {
84
+ const str = safeString(text);
85
+ if (str.length <= max) return str;
86
+ return `${str.slice(0, Math.max(0, max - 1))}…`;
87
+ }
88
+
89
+ /**
90
+ * Derive a compact outcome descriptor from an outcome object.
91
+ * @param {object} outcome
92
+ * @returns {string}
93
+ */
94
+ function summarizeOutcome(outcome) {
95
+ if (!outcome || typeof outcome !== 'object') return 'unknown outcome';
96
+ const parts = [];
97
+ if (outcome.testOutcome) parts.push(`tests:${clampText(safeString(outcome.testOutcome), 40)}`);
98
+ if (outcome.exitCode !== undefined && outcome.exitCode !== null) {
99
+ parts.push(`exit:${outcome.exitCode}`);
100
+ }
101
+ if (outcome.diff) {
102
+ const diffStr = safeString(outcome.diff);
103
+ parts.push(`diff:${diffStr.length}b`);
104
+ }
105
+ if (outcome.stateHash) parts.push(`hash:${clampText(safeString(outcome.stateHash), 12)}`);
106
+ return parts.length > 0 ? parts.join(' ') : 'no outcome fields';
107
+ }
108
+
109
+ /**
110
+ * Normalize a raw record_action_receipt payload into a stored receipt object.
111
+ * Accepts either a nested { outcome: {...} } shape or flat top-level fields
112
+ * (diff / exitCode / testOutcome / stateHash) as the MCP tool surfaces them.
113
+ * @param {object} params
114
+ * @returns {object}
115
+ */
116
+ function normalizeReceipt(params = {}) {
117
+ const outcomeSource = (params.outcome && typeof params.outcome === 'object')
118
+ ? params.outcome
119
+ : params;
120
+
121
+ const outcome = {
122
+ diff: outcomeSource.diff !== undefined ? outcomeSource.diff : null,
123
+ exitCode: (outcomeSource.exitCode !== undefined && outcomeSource.exitCode !== null)
124
+ ? Number(outcomeSource.exitCode)
125
+ : null,
126
+ testOutcome: outcomeSource.testOutcome !== undefined ? outcomeSource.testOutcome : null,
127
+ stateHash: outcomeSource.stateHash !== undefined ? outcomeSource.stateHash : null,
128
+ };
129
+
130
+ return {
131
+ actionId: safeString(params.actionId) || null,
132
+ toolName: params.toolName !== undefined ? safeString(params.toolName) : null,
133
+ toolInput: params.toolInput !== undefined ? params.toolInput : null,
134
+ outcome,
135
+ recordedAt: new Date().toISOString(),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Append a receipt to the JSONL ledger.
141
+ * @param {object} params - { actionId, toolName, toolInput, outcome:{ diff, exitCode, testOutcome, stateHash } }
142
+ * @param {object} [options] - feedback-paths options (e.g. for tests).
143
+ * @returns {object} The stored receipt record (with recorded:true).
144
+ */
145
+ function recordReceipt(params = {}, options = {}) {
146
+ const receipt = normalizeReceipt(params);
147
+ const receiptsPath = getReceiptsPath(options);
148
+ ensureDirFor(receiptsPath);
149
+ fs.appendFileSync(receiptsPath, `${JSON.stringify(receipt)}\n`, 'utf8');
150
+ return { recorded: true, ...receipt };
151
+ }
152
+
153
+ /**
154
+ * Read all receipts from the ledger (oldest first). Tolerates malformed lines.
155
+ * @param {object} [options]
156
+ * @returns {object[]}
157
+ */
158
+ function readAllReceipts(options = {}) {
159
+ const receiptsPath = getReceiptsPath(options);
160
+ let raw;
161
+ try {
162
+ raw = fs.readFileSync(receiptsPath, 'utf8');
163
+ } catch {
164
+ return [];
165
+ }
166
+ const receipts = [];
167
+ for (const line of raw.split('\n')) {
168
+ const trimmed = line.trim();
169
+ if (!trimmed) continue;
170
+ try {
171
+ receipts.push(JSON.parse(trimmed));
172
+ } catch {
173
+ // skip malformed line
174
+ }
175
+ }
176
+ return receipts;
177
+ }
178
+
179
+ /**
180
+ * Return the most recent receipt for a given actionId, or null.
181
+ * @param {string} actionId
182
+ * @param {object} [options]
183
+ * @returns {object|null}
184
+ */
185
+ function getReceiptForAction(actionId, options = {}) {
186
+ if (!actionId) return null;
187
+ const target = safeString(actionId);
188
+ const receipts = readAllReceipts(options);
189
+ for (let i = receipts.length - 1; i >= 0; i -= 1) {
190
+ if (safeString(receipts[i].actionId) === target) return receipts[i];
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Return the last n receipts (most recent last, preserving chronological order).
197
+ * @param {number} [n=20]
198
+ * @param {object} [options]
199
+ * @returns {object[]}
200
+ */
201
+ function getRecentReceipts(n = 20, options = {}) {
202
+ const limit = Number.isFinite(Number(n)) && Number(n) > 0 ? Math.floor(Number(n)) : 20;
203
+ const receipts = readAllReceipts(options);
204
+ return receipts.slice(-limit);
205
+ }
206
+
207
+ /**
208
+ * Build the "action -> outcome" lesson string for a matched receipt.
209
+ * @param {object} receipt
210
+ * @returns {string}
211
+ */
212
+ function buildOutcomePairedLesson(receipt) {
213
+ if (!receipt) return '';
214
+ const toolName = safeString(receipt.toolName) || 'action';
215
+ const inputSummary = summarizeInput(receipt.toolInput);
216
+ const outcomeSummary = summarizeOutcome(receipt.outcome);
217
+ return `${toolName}(${inputSummary}) -> ${outcomeSummary}`;
218
+ }
219
+
220
+ /**
221
+ * Resolve which actionId a feedback payload refers to. Supports both the
222
+ * legacy `lastAction` shape (object or string) and a flat `actionId` field.
223
+ * @param {object} feedbackParams
224
+ * @returns {string|null}
225
+ */
226
+ function resolveFeedbackActionId(feedbackParams = {}) {
227
+ if (feedbackParams.actionId) return safeString(feedbackParams.actionId);
228
+
229
+ const lastAction = feedbackParams.lastAction;
230
+ if (!lastAction) return null;
231
+ if (typeof lastAction === 'string') return safeString(lastAction);
232
+ if (typeof lastAction === 'object') {
233
+ if (lastAction.actionId) return safeString(lastAction.actionId);
234
+ if (lastAction.id) return safeString(lastAction.id);
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /**
240
+ * Enrich a capture_feedback payload with the most recent matching receipt's
241
+ * outcome so the lesson pipeline encodes action->outcome. If no receipt
242
+ * matches, the original payload is returned unchanged (never throws).
243
+ * @param {object} feedbackParams
244
+ * @param {object} [options]
245
+ * @returns {object}
246
+ */
247
+ function pairFeedbackWithReceipt(feedbackParams = {}, options = {}) {
248
+ const actionId = resolveFeedbackActionId(feedbackParams);
249
+ if (!actionId) return feedbackParams;
250
+
251
+ let receipt = null;
252
+ try {
253
+ receipt = getReceiptForAction(actionId, options);
254
+ } catch {
255
+ return feedbackParams;
256
+ }
257
+ if (!receipt) return feedbackParams;
258
+
259
+ const outcomePairedLesson = buildOutcomePairedLesson(receipt);
260
+
261
+ return {
262
+ ...feedbackParams,
263
+ outcome: { ...receipt.outcome },
264
+ outcomePairedLesson,
265
+ receiptActionId: actionId,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Build construct_context_pack-shaped candidate entries from receipts that
271
+ * match a free-text query. Returns [{ namespace, text, score }].
272
+ * @param {string} query
273
+ * @param {number} [limit=5]
274
+ * @param {object} [options]
275
+ * @returns {Array<{namespace:string, text:string, score:number}>}
276
+ */
277
+ function buildReceiptContextEntries(query, limit = 5, options = {}) {
278
+ const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 5;
279
+ const receipts = readAllReceipts(options);
280
+ if (receipts.length === 0) return [];
281
+
282
+ const queryTokens = safeString(query)
283
+ .toLowerCase()
284
+ .split(/[^a-z0-9]+/i)
285
+ .filter(Boolean);
286
+
287
+ const scored = receipts.map((receipt) => {
288
+ const lesson = buildOutcomePairedLesson(receipt);
289
+ const haystack = `${lesson} ${safeString(receipt.toolName)} ${summarizeInput(receipt.toolInput)}`.toLowerCase();
290
+ let score = 0;
291
+ for (const token of queryTokens) {
292
+ if (haystack.includes(token)) score += 1;
293
+ }
294
+ const text = `${lesson} [outcome: ${summarizeOutcome(receipt.outcome)}]`;
295
+ return { namespace: 'action-receipts', text, score };
296
+ });
297
+
298
+ // When the query is empty, surface the most recent receipts (score 0 but
299
+ // still useful for the pack); otherwise rank by token overlap.
300
+ const ranked = queryTokens.length === 0
301
+ ? scored.slice(-cap).reverse()
302
+ : scored
303
+ .filter((entry) => entry.score > 0)
304
+ .sort((a, b) => b.score - a.score)
305
+ .slice(0, cap);
306
+
307
+ return ranked;
308
+ }
309
+
310
+ module.exports = {
311
+ RECEIPTS_FILE,
312
+ getReceiptsPath,
313
+ recordReceipt,
314
+ readAllReceipts,
315
+ getReceiptForAction,
316
+ getRecentReceipts,
317
+ buildOutcomePairedLesson,
318
+ pairFeedbackWithReceipt,
319
+ buildReceiptContextEntries,
320
+ // exposed for testing / reuse
321
+ summarizeInput,
322
+ summarizeOutcome,
323
+ resolveFeedbackActionId,
324
+ };
@@ -51,6 +51,20 @@ const CLI_COMMANDS = [
51
51
  { name: 'json', type: 'boolean', description: 'Output as JSON' },
52
52
  ],
53
53
  },
54
+ {
55
+ name: 'feedback-self-test',
56
+ aliases: ['dogfood'],
57
+ description: 'Prove thumbs feedback capture works in the current runtime',
58
+ group: 'capture',
59
+ mcpTool: 'capture_feedback',
60
+ flags: [
61
+ { name: 'feedback', type: 'string', description: 'Signal to test: up or down (default down)' },
62
+ { name: 'context', type: 'string', description: 'Context to store in the test capture' },
63
+ { name: 'persist', type: 'boolean', description: 'Use the active ThumbGate store instead of an isolated test store' },
64
+ { name: 'feedback-dir', type: 'string', description: 'Explicit feedback directory for the self-test' },
65
+ { name: 'json', type: 'boolean', description: 'Output as JSON' },
66
+ ],
67
+ },
54
68
 
55
69
  // -------------------------------------------------------------------------
56
70
  // Discovery
@@ -590,6 +604,16 @@ const CLI_COMMANDS = [
590
604
  { name: 'info', type: 'boolean', description: 'Show Pro feature list' },
591
605
  ],
592
606
  },
607
+ {
608
+ name: 'brain',
609
+ description: 'Build the agent-readable context brain (lessons + rules + gates + project context)',
610
+ group: 'ops',
611
+ flags: [
612
+ { name: 'write', type: 'boolean', description: 'Save to .thumbgate/BRAIN.md (versioned, deterministic)' },
613
+ { name: 'limit', type: 'number', description: 'Max lessons to include (default 15)' },
614
+ { name: 'json', type: 'boolean', description: 'Output the structured model as JSON' },
615
+ ],
616
+ },
593
617
  ];
594
618
 
595
619
  /**
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
4
7
  /**
5
8
  * Context Manager — Unified Context-Augmented Generation (CAG) Orchestrator
6
9
  *
@@ -248,6 +251,13 @@ function assembleUnifiedContext(params = {}) {
248
251
  reliabilityDirective = 'CAUTION: Conflicting past patterns detected for this action. Prioritize absolute ground truth verification over rapid completion.';
249
252
  }
250
253
 
254
+ // v1.26.0: CodeRabbit Planning Directive
255
+ const planPath = path.join(repoPath || process.cwd(), 'PLAN.md');
256
+ if (!fs.existsSync(planPath) && ['Bash', 'Write', 'Edit', 'Deploy'].includes(toolName)) {
257
+ const planReminder = 'ORCHESTRATION: High-risk action detected without a PLAN.md. Please document your intent, assumptions, and verification steps before proceeding.';
258
+ reliabilityDirective = reliabilityDirective ? `${reliabilityDirective}\n\n${planReminder}` : planReminder;
259
+ }
260
+
251
261
  const result = {
252
262
  tier,
253
263
  agentType: agentType || 'default',
@@ -17,6 +17,7 @@ const { filterEntriesForWindow, resolveAnalyticsWindow } = require('./analytics-
17
17
  const { resolveHostedBillingConfig } = require('./hosted-config');
18
18
  const { generateAgentReadinessReport } = require('./agent-readiness');
19
19
  const { summarizeGateTemplates } = require('./gate-templates');
20
+ const { mergeRepeatMetricIntoGateStats } = require('./repeat-metric');
20
21
  const { buildPredictiveInsights } = loadOptionalModule('./predictive-insights', () => ({
21
22
  buildPredictiveInsights: () => ({
22
23
  upgradePropensity: {
@@ -1613,7 +1614,11 @@ function generateDashboard(feedbackDir, options = {}) {
1613
1614
  const billingSummary = options.billingSummary || getBillingSummary(analyticsWindow);
1614
1615
 
1615
1616
  const approval = computeApprovalStats(entries);
1616
- const gateStats = computeGateStats();
1617
+ // Surface the "repeat-attempts blocked before execution" metric on the
1618
+ // dashboard JSON and the /v1/dashboard HTTP route. Use the non-mutating
1619
+ // helper (mirrors server-stdio.js) instead of mutating computeGateStats()'s
1620
+ // return value. (mergeRepeatMetricIntoGateStats is imported at top of file.)
1621
+ const gateStats = mergeRepeatMetricIntoGateStats(computeGateStats());
1617
1622
  const prevention = computePreventionImpact(feedbackDir, gateStats);
1618
1623
  const trend = computeSessionTrend(entries, 10);
1619
1624
  const health = computeSystemHealth(feedbackDir, gateStats);
@@ -3,8 +3,9 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const os = require('os');
6
7
  const crypto = require('crypto');
7
- const { execSync, execFileSync } = require('child_process');
8
+ const { execFileSync } = require('child_process');
8
9
  const { loadOptionalModule } = require('./private-core-boundary');
9
10
 
10
11
  const { isProTier, isInTrialPeriod, FREE_TIER_MAX_GATES, FREE_TIER_DAILY_BLOCKS, todayKey } = require('./rate-limiter');
@@ -30,10 +31,12 @@ function computeExecutableHash(command) {
30
31
  const firstWord = command.trim().split(/\s+/)[0];
31
32
  if (!firstWord) return null;
32
33
 
33
- // Resolve absolute path using 'which'
34
+ // Resolve absolute path using 'which'. Use execFileSync (no shell) and pass
35
+ // firstWord as an argv element, never interpolated into a command string, so
36
+ // a hostile `command` value cannot inject shell metacharacters here.
34
37
  let fullPath;
35
38
  try {
36
- fullPath = execSync(`which ${firstWord}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
39
+ fullPath = execFileSync('which', [firstWord], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
37
40
  } catch (e) {
38
41
  // If 'which' fails, it might be an absolute path or a non-existent command
39
42
  fullPath = path.isAbsolute(firstWord) ? firstWord : null;
@@ -55,6 +58,8 @@ const {
55
58
  const {
56
59
  evaluateSecurityScan,
57
60
  } = require('./security-scanner');
61
+ const { evaluatePlanGate } = require('./plan-gate');
62
+ const { getTrajectoryScore } = require('./trajectory-scorer');
58
63
  const { evaluateSequenceState } = loadOptionalModule('./sequence-guard', () => ({
59
64
  evaluateSequenceState: () => null,
60
65
  }));
@@ -63,12 +68,28 @@ const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
63
68
 
64
69
  const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gates', 'default.json');
65
70
  const DEFAULT_CLAIM_GATES_PATH = path.join(__dirname, '..', 'config', 'gates', 'claim-verification.json');
66
- const STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'gate-state.json');
67
- const CONSTRAINTS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'session-constraints.json');
68
- const STATS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'gate-stats.json');
69
- const SESSION_ACTIONS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'session-actions.json');
70
- const CUSTOM_CLAIM_GATES_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'claim-verification.json');
71
- const GOVERNANCE_STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'governance-state.json');
71
+
72
+ function resolveThumbgateStateDir() {
73
+ if (process.env.THUMBGATE_STATE_DIR) return process.env.THUMBGATE_STATE_DIR;
74
+
75
+ if (process.env.XDG_STATE_HOME) {
76
+ return path.join(process.env.XDG_STATE_HOME, 'thumbgate');
77
+ }
78
+
79
+ if (process.env.CODEX_SANDBOX) {
80
+ return path.join(os.tmpdir(), 'thumbgate');
81
+ }
82
+
83
+ return path.join(process.env.HOME || os.tmpdir(), '.thumbgate');
84
+ }
85
+
86
+ const STATE_DIR = resolveThumbgateStateDir();
87
+ const STATE_PATH = path.join(STATE_DIR, 'gate-state.json');
88
+ const CONSTRAINTS_PATH = path.join(STATE_DIR, 'session-constraints.json');
89
+ const STATS_PATH = path.join(STATE_DIR, 'gate-stats.json');
90
+ const SESSION_ACTIONS_PATH = path.join(STATE_DIR, 'session-actions.json');
91
+ const CUSTOM_CLAIM_GATES_PATH = path.join(STATE_DIR, 'claim-verification.json');
92
+ const GOVERNANCE_STATE_PATH = path.join(STATE_DIR, 'governance-state.json');
72
93
  const TTL_MS = 5 * 60 * 1000; // 5 minutes
73
94
  const SESSION_ACTION_TTL_MS = 60 * 60 * 1000; // 1 hour
74
95
  const PROTECTED_APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -91,6 +112,10 @@ const REMOTE_SIDE_EFFECT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+(?:create|m
91
112
  const BOOSTED_RISK_BLOCK_SCORE = 0.8;
92
113
  const BOOSTED_RISK_MIN_EXAMPLES = 3;
93
114
  const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
115
+
116
+ function isRuntimePlanGateEnabled() {
117
+ return process.env.THUMBGATE_PLAN_GATE === '1' || process.env.THUMBGATE_PLAN_GATE === 'true';
118
+ }
94
119
  const PR_THREAD_RESOLUTION_CLAIM_PATTERN = '(?:thread|review|comment).*?(?:resolved|verified|checked|addressed|fixed)|(?:resolved|verified|checked|addressed|fixed).*?(?:thread|review|comment)';
95
120
  const PR_THREAD_RESOLUTION_REQUIRED_ACTIONS = ['pr_threads_checked', 'thread_resolution_verified'];
96
121
 
@@ -1512,6 +1537,23 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1512
1537
  return boostedRiskGuard;
1513
1538
  }
1514
1539
 
1540
+ // Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
1541
+ // Keep runtime enforcement explicit so advisory planning checks do not mask
1542
+ // higher-priority deny/approve gates in established workflows.
1543
+ if (isRuntimePlanGateEnabled()) {
1544
+ const planGate = evaluatePlanGate(toolName, toolInput);
1545
+ if (planGate) {
1546
+ recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn');
1547
+ return planGate;
1548
+ }
1549
+
1550
+ const trajectory = getTrajectoryScore();
1551
+ if (trajectory.isDrifting) {
1552
+ recordStat('strategic-drift', 'block');
1553
+ return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
1554
+ }
1555
+ }
1556
+
1515
1557
  // Fast-path: feedback/recall tools skip metric gates entirely (avoids Stripe API calls)
1516
1558
  const METRIC_SKIP_TOOLS = ['capture_feedback', 'feedback_stats', 'recall', 'feedback_summary', 'prevention_rules'];
1517
1559
  const skipMetrics = METRIC_SKIP_TOOLS.includes(toolName);
@@ -1709,6 +1751,23 @@ function evaluateGates(toolName, toolInput, configPath) {
1709
1751
  return boostedRiskGuard;
1710
1752
  }
1711
1753
 
1754
+ // Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
1755
+ // Keep runtime enforcement explicit so advisory planning checks do not mask
1756
+ // higher-priority deny/approve gates in established workflows.
1757
+ if (isRuntimePlanGateEnabled()) {
1758
+ const planGate = evaluatePlanGate(toolName, toolInput);
1759
+ if (planGate) {
1760
+ recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn');
1761
+ return planGate;
1762
+ }
1763
+
1764
+ const trajectory = getTrajectoryScore();
1765
+ if (trajectory.isDrifting) {
1766
+ recordStat('strategic-drift', 'block');
1767
+ return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
1768
+ }
1769
+ }
1770
+
1712
1771
  for (const gate of config.gates) {
1713
1772
  const matchDetails = matchGate(gate, toolName, toolInput);
1714
1773
  if (!matchDetails.matched) continue;