thumbgate 1.27.19 → 1.27.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/dashboard.md +15 -0
- package/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-dashboard.md +15 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +0 -3
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bin/cli.js +27 -1
- package/bin/dashboard-cli.js +7 -0
- package/bin/postinstall.js +14 -23
- package/commands/dashboard.md +15 -0
- package/commands/thumbgate-dashboard.md +15 -0
- package/package.json +225 -100
- package/public/about.html +162 -0
- package/public/agent-manager.html +179 -0
- package/public/agents-cost-savings.html +153 -0
- package/public/ai-malpractice-prevention.html +818 -0
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +21 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/brand/thumbgate-mark.svg +19 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-enterprise.html +123 -0
- package/public/diagnostic.html +345 -0
- package/public/index.html +2 -2
- package/public/install.html +193 -0
- package/public/js/buyer-intent.js +672 -0
- package/public/numbers.html +2 -2
- package/public/pricing.html +399 -0
- package/scripts/action-receipts.js +324 -0
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/audit.js +65 -0
- package/scripts/aws-blocks-guardrails.js +272 -0
- package/scripts/classifier-routing.js +130 -0
- package/scripts/dashboard-chat.js +332 -0
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/hook-stop-anti-claim.js +301 -0
- package/scripts/install-shim.js +87 -0
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/noop-detect.js +285 -0
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plan-gate.js +243 -0
- package/scripts/plausible-domain-config.js +99 -0
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/repeat-metric.js +137 -0
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-redaction.js +166 -0
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/self-healing-check.js +193 -0
- package/scripts/self-protection.js +90 -0
- package/scripts/silent-failure-cluster.js +531 -0
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/trajectory-scorer.js +63 -0
- package/scripts/verify-marketing-pages-deployed.js +212 -0
- package/scripts/visitor-journey.js +172 -0
- package/.claude-plugin/marketplace.json +0 -85
- package/adapters/chatgpt/openapi.yaml +0 -1707
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Activation: guided first prevention rule + a live demonstrated block.
|
|
5
|
+
//
|
|
6
|
+
// ~98.5% of `init` users never promote their first rule, so they never see
|
|
7
|
+
// "ThumbGate just blocked a repeat mistake" — the aha moment that drives
|
|
8
|
+
// activation (and, downstream, conversion). `quickstart` walks a new user
|
|
9
|
+
// through capturing one real example, promoting it to a block rule, and then
|
|
10
|
+
// firing that rule against the exact action so they watch it get blocked.
|
|
11
|
+
//
|
|
12
|
+
// SAFETY: this is additive and lives in its own module. `init` is untouched.
|
|
13
|
+
// The interactive flow ONLY runs in a real TTY. Non-interactive / piped / CI
|
|
14
|
+
// invocations print a one-line hint and exit 0 without prompting or hanging.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
20
|
+
const PRO_URL = 'https://thumbgate.ai';
|
|
21
|
+
|
|
22
|
+
// Turn a free-text mistake description into a safe, literal gate pattern. We
|
|
23
|
+
// escape regex metacharacters so arbitrary user input can never produce an
|
|
24
|
+
// invalid or runaway regex inside the gates engine.
|
|
25
|
+
function escapeRegex(text) {
|
|
26
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pkgVersion() {
|
|
30
|
+
try {
|
|
31
|
+
return require(path.join(PKG_ROOT, 'package.json')).version;
|
|
32
|
+
} catch {
|
|
33
|
+
// Version is cosmetic in the banner; fall back if package.json is unreadable.
|
|
34
|
+
return '0.0.0';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Core, dependency-injected activation flow so it is testable without a real
|
|
39
|
+
// TTY. Callers provide ask() (returns a Promise<string>), out() (line writer),
|
|
40
|
+
// and isTTY. Returns a structured result describing what was promoted/blocked.
|
|
41
|
+
//
|
|
42
|
+
// Optional injectables (deps) keep the test hermetic and let the CLI pass the
|
|
43
|
+
// real modules:
|
|
44
|
+
// deps.forcePromote(pattern, action) -> { gateId, totalGates }
|
|
45
|
+
// deps.runGate(input) -> Promise<rawJsonString>
|
|
46
|
+
// deps.captureFeedback(params)
|
|
47
|
+
// deps.trackEvent(name, props)
|
|
48
|
+
async function runActivationFlow({ ask, out, isTTY, deps = {} }) {
|
|
49
|
+
// Hard safety gate: never prompt or hang in non-interactive contexts.
|
|
50
|
+
if (!isTTY) {
|
|
51
|
+
out('thumbgate quickstart is an interactive walkthrough — run it in a terminal.');
|
|
52
|
+
out('Non-interactive setup: npx thumbgate init (or: npx thumbgate quick-start)');
|
|
53
|
+
return { interactive: false, promoted: false, blocked: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const forcePromote = deps.forcePromote
|
|
57
|
+
|| require(path.join(PKG_ROOT, 'scripts', 'auto-promote-gates')).forcePromote;
|
|
58
|
+
const runGate = deps.runGate
|
|
59
|
+
|| ((input) => require(path.join(PKG_ROOT, 'scripts', 'gates-engine')).runAsync(input));
|
|
60
|
+
let captureFeedback = deps.captureFeedback;
|
|
61
|
+
if (captureFeedback === undefined) {
|
|
62
|
+
try {
|
|
63
|
+
({ captureFeedback } = require(path.join(PKG_ROOT, 'scripts', 'feedback-loop')));
|
|
64
|
+
} catch {
|
|
65
|
+
// Feedback capture is a nice-to-have here; the rule + block is the aha.
|
|
66
|
+
captureFeedback = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const trackEvent = deps.trackEvent || (() => {});
|
|
70
|
+
|
|
71
|
+
out('');
|
|
72
|
+
out(`thumbgate quickstart v${pkgVersion()}`);
|
|
73
|
+
out('');
|
|
74
|
+
out("Let's set up your first prevention rule and watch it block a repeat mistake.");
|
|
75
|
+
out('');
|
|
76
|
+
out('Think of one thing an AI agent did that you never want it to do again.');
|
|
77
|
+
out('Examples: "git push --force to main", "rm -rf node_modules", "edit .env directly".');
|
|
78
|
+
out('');
|
|
79
|
+
|
|
80
|
+
let mistake = String(await ask('The mistake to block: ') || '').trim();
|
|
81
|
+
if (!mistake) {
|
|
82
|
+
mistake = 'git push --force to main';
|
|
83
|
+
out(`(using a starter example: ${mistake})`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 1. Capture it as a real thumbs-down example (best-effort).
|
|
87
|
+
if (typeof captureFeedback === 'function') {
|
|
88
|
+
try {
|
|
89
|
+
captureFeedback({
|
|
90
|
+
signal: 'down',
|
|
91
|
+
context: mistake,
|
|
92
|
+
whatWentWrong: mistake,
|
|
93
|
+
whatToChange: `Block this action: ${mistake}`,
|
|
94
|
+
tags: 'quickstart,activation,first-rule',
|
|
95
|
+
gateAction: 'block',
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
// Capture failure should not abort the activation aha.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Promote it into a hard block rule using the existing primitive.
|
|
103
|
+
const pattern = escapeRegex(mistake);
|
|
104
|
+
const promotion = forcePromote(pattern, 'block');
|
|
105
|
+
out('');
|
|
106
|
+
out(` Rule promoted: ${promotion.gateId} [block]`);
|
|
107
|
+
out(` Active rules now: ${promotion.totalGates}`);
|
|
108
|
+
|
|
109
|
+
// 3. Demonstrate the block. Force strict enforcement for the demo so the
|
|
110
|
+
// user's brand-new rule hard-blocks (default posture is warn-by-default).
|
|
111
|
+
// We restore the prior value so we never mutate the user's environment.
|
|
112
|
+
const priorStrict = process.env.THUMBGATE_STRICT_ENFORCEMENT;
|
|
113
|
+
process.env.THUMBGATE_STRICT_ENFORCEMENT = '1';
|
|
114
|
+
let verdict;
|
|
115
|
+
try {
|
|
116
|
+
const demoInput = { tool_name: 'Bash', tool_input: { command: mistake } };
|
|
117
|
+
const raw = await runGate(demoInput);
|
|
118
|
+
verdict = JSON.parse(raw);
|
|
119
|
+
} finally {
|
|
120
|
+
if (priorStrict === undefined) delete process.env.THUMBGATE_STRICT_ENFORCEMENT;
|
|
121
|
+
else process.env.THUMBGATE_STRICT_ENFORCEMENT = priorStrict;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const decision = verdict?.decision
|
|
125
|
+
|| verdict?.hookSpecificOutput?.permissionDecision;
|
|
126
|
+
const blocked = decision === 'block' || decision === 'deny';
|
|
127
|
+
|
|
128
|
+
out('');
|
|
129
|
+
if (blocked) {
|
|
130
|
+
out(' | ThumbGate just blocked it.');
|
|
131
|
+
out(` | Action attempted: ${mistake}`);
|
|
132
|
+
out(' | Verdict: BLOCKED — the agent cannot repeat this mistake.');
|
|
133
|
+
} else {
|
|
134
|
+
// Even in warn posture the action is flagged + logged. Be honest about it.
|
|
135
|
+
out(' | ThumbGate flagged it.');
|
|
136
|
+
out(` | Action attempted: ${mistake}`);
|
|
137
|
+
out(' | Verdict: flagged and logged. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 4. Tie the value the user just saw to what Pro keeps working.
|
|
141
|
+
out('');
|
|
142
|
+
out(' That rule lives in this project. Pro keeps your rules — and the mistakes');
|
|
143
|
+
out(' you teach it — synced across every machine and agent runtime you work in,');
|
|
144
|
+
out(` so the block you just saw follows your whole team. ${PRO_URL}/pricing`);
|
|
145
|
+
out('');
|
|
146
|
+
|
|
147
|
+
try { trackEvent('cli_quickstart_activated', { command: 'quickstart', blocked }); } catch { /* telemetry best-effort */ }
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
interactive: true,
|
|
151
|
+
promoted: true,
|
|
152
|
+
blocked,
|
|
153
|
+
gateId: promotion.gateId,
|
|
154
|
+
pattern,
|
|
155
|
+
mistake,
|
|
156
|
+
verdict,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// CLI entrypoint: wires runActivationFlow to a real readline prompt + stdout.
|
|
161
|
+
function quickstart() {
|
|
162
|
+
const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
163
|
+
const out = (line = '') => console.log(line);
|
|
164
|
+
|
|
165
|
+
const trackEvent = (() => {
|
|
166
|
+
try { return require(path.join(PKG_ROOT, 'scripts', 'cli-telemetry')).trackEvent; } catch { return () => {}; }
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
if (!isTTY) {
|
|
170
|
+
// Mirror the non-interactive branch without opening readline (which would
|
|
171
|
+
// otherwise keep the process alive waiting for input that never comes).
|
|
172
|
+
return runActivationFlow({ ask: async () => '', out, isTTY: false, deps: { trackEvent } });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const readline = require('node:readline');
|
|
176
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
177
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
178
|
+
|
|
179
|
+
return runActivationFlow({ ask, out, isTTY: true, deps: { trackEvent } })
|
|
180
|
+
.catch((err) => {
|
|
181
|
+
console.error(`quickstart error: ${err?.message ?? err}`);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
})
|
|
184
|
+
.finally(() => rl.close());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { runActivationFlow, quickstart, escapeRegex };
|