thumbgate 1.27.20 → 1.28.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.
- package/.claude-plugin/plugin.json +2 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +124 -129
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +18 -33
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +95 -78
- package/bin/postinstall.js +1 -1
- package/config/entitlement-public-keys.json +6 -0
- package/config/gates/default.json +3 -3
- package/config/github-about.json +2 -5
- package/config/merge-quality-checks.json +3 -0
- package/config/post-deploy-marketing-pages.json +1 -1
- package/hooks/hooks.json +38 -0
- package/package.json +28 -10
- package/public/about.html +1 -4
- package/public/agent-manager.html +6 -6
- package/public/agents-cost-savings.html +3 -3
- package/public/ai-malpractice-prevention.html +1 -1
- package/public/blog.html +12 -9
- package/public/chatgpt-app.html +3 -3
- package/public/codex-enterprise.html +3 -3
- package/public/codex-plugin.html +5 -5
- package/public/compare.html +10 -10
- package/public/dashboard.html +1 -1
- package/public/diagnostic.html +23 -1
- package/public/federal.html +2 -2
- package/public/guide.html +14 -14
- package/public/index.html +202 -220
- package/public/install.html +14 -14
- package/public/learn.html +4 -17
- package/public/numbers.html +2 -2
- package/public/partner-intake.html +198 -0
- package/public/pricing.html +61 -31
- package/public/pro.html +2 -2
- package/scripts/agent-reward-model.js +13 -0
- package/scripts/bayes-optimal-gate.js +6 -1
- package/scripts/billing.js +815 -137
- package/scripts/checkout-attribution-reference.js +85 -0
- package/scripts/claude-feedback-sync.js +23 -6
- package/scripts/cli-feedback.js +33 -8
- package/scripts/commercial-offer.js +3 -3
- package/scripts/entitlement.js +250 -0
- package/scripts/export-databricks-bundle.js +5 -0
- package/scripts/export-dpo-pairs.js +6 -0
- package/scripts/export-hf-dataset.js +5 -0
- package/scripts/feedback-loop.js +290 -1
- package/scripts/feedback-quality.js +25 -26
- package/scripts/feedback-sanitizer.js +151 -3
- package/scripts/gates-engine.js +134 -43
- package/scripts/hosted-config.js +9 -2
- package/scripts/imperative-detector.js +85 -0
- package/scripts/intervention-policy.js +13 -0
- package/scripts/mailer/resend-mailer.js +11 -5
- package/scripts/pr-manager.js +9 -22
- package/scripts/pro-local-dashboard.js +198 -0
- package/scripts/risk-scorer.js +6 -0
- package/scripts/secret-scanner.js +363 -68
- package/scripts/self-protection.js +21 -36
- package/scripts/seo-gsd.js +2 -2
- package/scripts/thompson-sampling.js +11 -2
- package/src/api/server.js +202 -22
- package/public/assets/brand/github-social-preview.png +0 -0
package/scripts/gates-engine.js
CHANGED
|
@@ -131,28 +131,38 @@ const DOWNLOAD_EXEC_CHAIN_PATTERN = /\b(?:curl|wget)\b[\s\S]{0,400}(?:\|\s*(?:ba
|
|
|
131
131
|
const DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN = /\b(?:rm\s+-rf|chmod\s+(?:\+x|777)|chown\b|sudo\b|dd\s+if=|mkfs|git\s+reset\s+--hard|git\s+clean\s+-f[a-z]*|kubectl\s+(?:apply|delete)|terraform\s+(?:apply|destroy)|railway\s+(?:deploy|up)|gcloud\s+(?:run\s+deploy|app\s+deploy)|vercel\s+--prod|firebase\s+deploy)\b/i;
|
|
132
132
|
|
|
133
133
|
// ---------------------------------------------------------------------------
|
|
134
|
-
// Enforcement posture (CEO decision 2026-06-04): warn-by-default.
|
|
135
|
-
// The firewall ALWAYS fires and logs every decision, but most gates WARN rather
|
|
136
|
-
// than hard-block — only TRULY CATASTROPHIC, irreversible actions hard-block:
|
|
137
|
-
// - secret exfiltration (handled on its own deny path; never downgraded)
|
|
138
|
-
// - security-vulnerability / supply-chain denies (own deny path; not downgraded)
|
|
139
|
-
// - irreversibly destructive filesystem commands (rm -rf class, mkfs, dd to disk,
|
|
140
|
-
// fork bomb) — kept as hard deny via DESTRUCTIVE_FS_PATTERN below.
|
|
141
|
-
// Everything else (memory-high-risk, workflow-sequence, off-scope, git push, deploy,
|
|
142
|
-
// approval gates) downgrades deny/approve -> warn so legitimate work is never blocked.
|
|
143
|
-
// Opt back into full hard enforcement with THUMBGATE_STRICT_ENFORCEMENT=1.
|
|
144
134
|
// Enforcement posture (CEO decision 2026-06-04): WARN + AUDIT by default.
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
135
|
+
// Unconditional floors run before posture conversion: secret exfiltration, deny results
|
|
136
|
+
// from the security scanner, and the four canonical self-protection gates. Arbitrary
|
|
137
|
+
// destructive commands do not get a regex-based "catastrophic" floor; wrappers and
|
|
138
|
+
// obfuscation make that boundary misleading. Every other deny/approve becomes a warning
|
|
139
|
+
// unless the operator explicitly enables THUMBGATE_STRICT_ENFORCEMENT=1.
|
|
140
|
+
const SELF_PROTECT_HARD_FLOOR_GATE_IDS = new Set([
|
|
141
|
+
'self-protect-config',
|
|
142
|
+
'self-protect-kill',
|
|
143
|
+
'self-protect-env-override',
|
|
144
|
+
'self-protect-hooks-disable',
|
|
145
|
+
]);
|
|
146
|
+
const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
|
|
147
|
+
'secret-exfiltration',
|
|
148
|
+
'security-vuln-scan',
|
|
149
|
+
'slopsquat-guard',
|
|
150
|
+
...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
|
|
151
|
+
]);
|
|
152
|
+
const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
|
|
153
|
+
const SELF_PROTECT_HOOK_TARGET_PATTERN = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^/]+\.(?:js|sh))$/i;
|
|
154
|
+
const SELF_PROTECT_CONFIG_COMMAND_PATTERN = /(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json\b|\.thumbgate\/config\.json\b|thumbgate\.json\b)/i;
|
|
155
|
+
const SELF_PROTECT_HOOK_COMMAND_PATTERN = /(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^\s'";|]+\.(?:js|sh))\b/i;
|
|
156
|
+
const SHELL_FILE_MUTATION_PATTERN = /\b(?:sed\s+-i|perl\s+-pi|python\d*\s+-c|node\s+-e|ruby\s+-e|tee|truncate|rm|mv|cp|install|patch|jq)\b|(?:^|[\s;&|])>{1,2}\s*\S/i;
|
|
157
|
+
|
|
158
|
+
function isSelfProtectGate(gateId) {
|
|
159
|
+
return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
|
|
160
|
+
}
|
|
161
|
+
|
|
154
162
|
function applyEnforcementPosture(result) {
|
|
155
163
|
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
164
|
+
// Defensive backstop: hard-floor results must never be posture-downgraded.
|
|
165
|
+
if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) return result;
|
|
156
166
|
// Full hard enforcement opt-in: keep every deny.
|
|
157
167
|
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
|
|
158
168
|
// Honor the explicit strict-knowledge-conflict opt-in for that gate.
|
|
@@ -163,7 +173,7 @@ function applyEnforcementPosture(result) {
|
|
|
163
173
|
...result,
|
|
164
174
|
decision: 'warn',
|
|
165
175
|
warnByDefault: true,
|
|
166
|
-
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block
|
|
176
|
+
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block other flagged actions.`,
|
|
167
177
|
};
|
|
168
178
|
}
|
|
169
179
|
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
@@ -1698,6 +1708,10 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1698
1708
|
const governanceState = loadGovernanceState();
|
|
1699
1709
|
const constraints = loadConstraints();
|
|
1700
1710
|
|
|
1711
|
+
if (isSelfProtectGate(gate.id) && hasActiveProtectedApproval(governanceState, affectedFiles)) {
|
|
1712
|
+
return { matched: false, matchText, affectedFiles };
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1701
1715
|
if (gate.id === 'on-demand-freeze-mode' || (gate.when && gate.when.constraints && gate.when.constraints.freeze_mode)) {
|
|
1702
1716
|
let freezePaths = [];
|
|
1703
1717
|
if (process.env.THUMBGATE_FREEZE_PATHS) {
|
|
@@ -1805,6 +1819,78 @@ function matchesGate(gate, toolName, toolInput) {
|
|
|
1805
1819
|
return matchGate(gate, toolName, toolInput).matched;
|
|
1806
1820
|
}
|
|
1807
1821
|
|
|
1822
|
+
function hasActiveProtectedApproval(governanceState, affectedFiles) {
|
|
1823
|
+
if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return false;
|
|
1824
|
+
const approvals = Array.isArray(governanceState && governanceState.protectedApprovals)
|
|
1825
|
+
? governanceState.protectedApprovals
|
|
1826
|
+
: [];
|
|
1827
|
+
return affectedFiles.every((filePath) => approvals.some((entry) => {
|
|
1828
|
+
return matchesAnyGlob(filePath, sanitizeGlobList(entry && entry.pathGlobs));
|
|
1829
|
+
}));
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
1833
|
+
const affected = extractAffectedFiles(toolName, toolInput);
|
|
1834
|
+
const affectedFiles = affected.files;
|
|
1835
|
+
if (hasActiveProtectedApproval(loadGovernanceState(), affectedFiles)) return null;
|
|
1836
|
+
|
|
1837
|
+
const command = String(toolInput.command || '');
|
|
1838
|
+
let matchText = command;
|
|
1839
|
+
if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
|
|
1840
|
+
const targetPattern = gate.id === 'self-protect-config'
|
|
1841
|
+
? SELF_PROTECT_CONFIG_TARGET_PATTERN
|
|
1842
|
+
: SELF_PROTECT_HOOK_TARGET_PATTERN;
|
|
1843
|
+
if (EDIT_LIKE_TOOLS.has(toolName)) {
|
|
1844
|
+
matchText = affectedFiles.join(' ');
|
|
1845
|
+
if (!targetPattern.test(matchText)) return null;
|
|
1846
|
+
} else if (toolName === 'Bash') {
|
|
1847
|
+
const commandTargetPattern = gate.id === 'self-protect-config'
|
|
1848
|
+
? SELF_PROTECT_CONFIG_COMMAND_PATTERN
|
|
1849
|
+
: SELF_PROTECT_HOOK_COMMAND_PATTERN;
|
|
1850
|
+
if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
|
|
1851
|
+
} else {
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
} else {
|
|
1855
|
+
if (!Array.isArray(gate.toolNames) || !gate.toolNames.includes(toolName)) return null;
|
|
1856
|
+
if (!matchText || !gate.pattern) return null;
|
|
1857
|
+
try {
|
|
1858
|
+
if (!new RegExp(gate.pattern).test(matchText)) return null;
|
|
1859
|
+
} catch {
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
return {
|
|
1865
|
+
matched: true,
|
|
1866
|
+
matchText,
|
|
1867
|
+
affectedFiles,
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function evaluateSelfProtectHardFloor(input = {}) {
|
|
1872
|
+
const toolName = input.tool_name || input.toolName || '';
|
|
1873
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
1874
|
+
const config = loadGatesConfig(DEFAULT_CONFIG_PATH);
|
|
1875
|
+
|
|
1876
|
+
for (const gate of config.gates) {
|
|
1877
|
+
if (!isSelfProtectGate(gate.id)) continue;
|
|
1878
|
+
const matchDetails = matchSelfProtectHardFloor(gate, toolName, toolInput);
|
|
1879
|
+
if (!matchDetails) continue;
|
|
1880
|
+
|
|
1881
|
+
const result = {
|
|
1882
|
+
decision: 'deny',
|
|
1883
|
+
gate: gate.id,
|
|
1884
|
+
message: buildGateMessage(gate, matchDetails),
|
|
1885
|
+
severity: gate.severity,
|
|
1886
|
+
reasoning: buildReasoning(gate, toolName, toolInput, matchDetails),
|
|
1887
|
+
};
|
|
1888
|
+
return recordStructuralGateBlock(toolName, toolInput, result);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
return null;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1808
1894
|
function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
|
|
1809
1895
|
if (toolName !== 'Bash') return false;
|
|
1810
1896
|
const command = String(toolInput.command || '').trim();
|
|
@@ -2631,6 +2717,26 @@ function evaluateSecretGuard(input = {}) {
|
|
|
2631
2717
|
return result;
|
|
2632
2718
|
}
|
|
2633
2719
|
|
|
2720
|
+
function evaluateUnconditionalHardFloor(input = {}) {
|
|
2721
|
+
const secretGuard = evaluateSecretGuard(input);
|
|
2722
|
+
if (secretGuard) return { hardFloor: secretGuard, securityScan: null };
|
|
2723
|
+
|
|
2724
|
+
const securityScan = evaluateSecurityScan(input);
|
|
2725
|
+
if (securityScan && securityScan.decision === 'deny') {
|
|
2726
|
+
return { hardFloor: securityScan, securityScan };
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
return {
|
|
2730
|
+
hardFloor: evaluateSelfProtectHardFloor(input),
|
|
2731
|
+
securityScan,
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
function runHardFloor(input) {
|
|
2736
|
+
const { hardFloor } = evaluateUnconditionalHardFloor(input);
|
|
2737
|
+
return hardFloor ? formatOutput(hardFloor) : null;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2634
2740
|
// ---------------------------------------------------------------------------
|
|
2635
2741
|
function isApprovalGatesEnabled() {
|
|
2636
2742
|
return process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
@@ -2672,12 +2778,12 @@ function buildBlockActionProCta() {
|
|
|
2672
2778
|
if (totalBlocks < 5) return null; // Too early — let them experience the product
|
|
2673
2779
|
|
|
2674
2780
|
if (totalBlocks < 25) {
|
|
2675
|
-
return '\n\n💡 Pro: keep this rule
|
|
2781
|
+
return '\n\n💡 Pro: keep this rule searchable, exportable, and visible → thumbgate.ai/go/pro';
|
|
2676
2782
|
}
|
|
2677
2783
|
if (totalBlocks < 100) {
|
|
2678
|
-
return `\n\n💡 ${totalBlocks} actions blocked. Pro
|
|
2784
|
+
return `\n\n💡 ${totalBlocks} actions blocked. Pro adds recall, dashboard proof, and exports → thumbgate.ai/go/pro ($19/mo)`;
|
|
2679
2785
|
}
|
|
2680
|
-
return `\n\n💡 ${totalBlocks} mistakes caught. Your team could use shared hosted enforcement → thumbgate.ai/go/pro`;
|
|
2786
|
+
return `\n\n💡 ${totalBlocks} mistakes caught. Your team could use Enterprise shared hosted enforcement → thumbgate.ai/go/pro`;
|
|
2681
2787
|
} catch (_) {
|
|
2682
2788
|
return null;
|
|
2683
2789
|
}
|
|
@@ -2984,16 +3090,8 @@ function mergeContextStrings(...ctxs) {
|
|
|
2984
3090
|
}
|
|
2985
3091
|
|
|
2986
3092
|
async function runAsync(input) {
|
|
2987
|
-
const
|
|
2988
|
-
if (
|
|
2989
|
-
return formatOutput(secretGuard);
|
|
2990
|
-
}
|
|
2991
|
-
|
|
2992
|
-
// Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
|
|
2993
|
-
const securityScan = evaluateSecurityScan(input);
|
|
2994
|
-
if (securityScan && securityScan.decision === 'deny') {
|
|
2995
|
-
return formatOutput(securityScan);
|
|
2996
|
-
}
|
|
3093
|
+
const { hardFloor, securityScan } = evaluateUnconditionalHardFloor(input);
|
|
3094
|
+
if (hardFloor) return formatOutput(hardFloor);
|
|
2997
3095
|
|
|
2998
3096
|
const toolName = input.tool_name || '';
|
|
2999
3097
|
const toolInput = input.tool_input || {};
|
|
@@ -3031,16 +3129,8 @@ async function runAsync(input) {
|
|
|
3031
3129
|
}
|
|
3032
3130
|
|
|
3033
3131
|
function run(input) {
|
|
3034
|
-
const
|
|
3035
|
-
if (
|
|
3036
|
-
return formatOutput(secretGuard);
|
|
3037
|
-
}
|
|
3038
|
-
|
|
3039
|
-
// Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
|
|
3040
|
-
const securityScan = evaluateSecurityScan(input);
|
|
3041
|
-
if (securityScan && securityScan.decision === 'deny') {
|
|
3042
|
-
return formatOutput(securityScan);
|
|
3043
|
-
}
|
|
3132
|
+
const { hardFloor, securityScan } = evaluateUnconditionalHardFloor(input);
|
|
3133
|
+
if (hardFloor) return formatOutput(hardFloor);
|
|
3044
3134
|
|
|
3045
3135
|
const toolName = input.tool_name || '';
|
|
3046
3136
|
const toolInput = input.tool_input || {};
|
|
@@ -3367,6 +3457,7 @@ module.exports = {
|
|
|
3367
3457
|
computeExecutableHash,
|
|
3368
3458
|
formatOutput,
|
|
3369
3459
|
isApprovalGatesEnabled,
|
|
3460
|
+
runHardFloor,
|
|
3370
3461
|
run,
|
|
3371
3462
|
runAsync,
|
|
3372
3463
|
trackAction,
|
package/scripts/hosted-config.js
CHANGED
|
@@ -13,6 +13,9 @@ const DEFAULT_PRO_PRICE_DOLLARS = PRO_MONTHLY_PRICE_DOLLARS;
|
|
|
13
13
|
const DEFAULT_PRO_PRICE_LABEL = PRO_PRICE_LABEL;
|
|
14
14
|
const DEFAULT_SPRINT_DIAGNOSTIC_PRICE_DOLLARS = 499;
|
|
15
15
|
const DEFAULT_WORKFLOW_SPRINT_PRICE_DOLLARS = 1500;
|
|
16
|
+
// Keep fallbacks price-aligned. Sprint must NOT share the diagnostic Stripe slug.
|
|
17
|
+
const DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/9B69ATbmI4r4aK5eOD3sI3k';
|
|
18
|
+
const DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL = 'https://www.paypal.com/ncp/payment/LTQFR7P9AR3QG';
|
|
16
19
|
const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i;
|
|
17
20
|
|
|
18
21
|
function normalizeOrigin(value) {
|
|
@@ -127,8 +130,10 @@ function resolveHostedBillingConfig({ requestOrigin } = {}, env = process.env) {
|
|
|
127
130
|
const gaMeasurementId = normalizeTrackingId(env.THUMBGATE_GA_MEASUREMENT_ID, GA_MEASUREMENT_ID_PATTERN);
|
|
128
131
|
const posthogApiKey = env.POSTHOG_API_KEY || '';
|
|
129
132
|
const googleSiteVerification = normalizeTrackingId(env.THUMBGATE_GOOGLE_SITE_VERIFICATION);
|
|
130
|
-
const sprintDiagnosticCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_SPRINT_DIAGNOSTIC_CHECKOUT_URL)
|
|
131
|
-
|
|
133
|
+
const sprintDiagnosticCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_SPRINT_DIAGNOSTIC_CHECKOUT_URL)
|
|
134
|
+
|| DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL;
|
|
135
|
+
const workflowSprintCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_WORKFLOW_SPRINT_CHECKOUT_URL)
|
|
136
|
+
|| DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL;
|
|
132
137
|
|
|
133
138
|
return {
|
|
134
139
|
appOrigin,
|
|
@@ -159,6 +164,8 @@ module.exports = {
|
|
|
159
164
|
DEFAULT_PRO_PRICE_LABEL,
|
|
160
165
|
DEFAULT_SPRINT_DIAGNOSTIC_PRICE_DOLLARS,
|
|
161
166
|
DEFAULT_WORKFLOW_SPRINT_PRICE_DOLLARS,
|
|
167
|
+
DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL,
|
|
168
|
+
DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL,
|
|
162
169
|
GA_MEASUREMENT_ID_PATTERN,
|
|
163
170
|
normalizeAbsoluteUrl,
|
|
164
171
|
normalizeOrigin,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* imperative-detector.js — detect an explicit NEVER / ALWAYS directive in a
|
|
5
|
+
* feedback message so the CLI can OFFER immediate enforcement (force-gate) when
|
|
6
|
+
* the user clearly asked for it.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists: auto-promotion is deliberately occurrence-gated (a rule
|
|
9
|
+
* blocks only after a pattern repeats) — that accumulate-evidence-before-you-
|
|
10
|
+
* enforce default is what makes ThumbGate trustworthy rather than a one-signal
|
|
11
|
+
* lockout. But when a user types "never do X again", that is an explicit intent
|
|
12
|
+
* to guard NOW. This module recognizes that intent and lets the CLI SURFACE the
|
|
13
|
+
* one-shot path (force-gate) as an offer. It never blocks on its own — the user
|
|
14
|
+
* still confirms. Detecting intent ≠ acting on it.
|
|
15
|
+
*
|
|
16
|
+
* Pure and deterministic: no state, no I/O.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// A leading (or clause-initial) negative imperative → intent to forbid.
|
|
20
|
+
const NEVER_RE = /(?:^|[.;:,]\s*)(never|do\s*not|don'?t|stop\s+\w|must\s*not|no\s+longer|quit\s+\w|avoid\s+\w)/i;
|
|
21
|
+
// A leading (or clause-initial) positive imperative → intent to always do.
|
|
22
|
+
const ALWAYS_RE = /(?:^|[.;:,]\s*)(always|make\s+sure|ensure\s+\w|be\s+sure\s+to)/i;
|
|
23
|
+
|
|
24
|
+
function firstMeaningfulText(text) {
|
|
25
|
+
// Strip common feedback prefixes/quote marks so "❯ never …" still matches.
|
|
26
|
+
return String(text || '').trim().replace(/^[\s>❯"'`*-]+/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @returns {{isImperative:boolean, polarity:('never'|'always'|null), directive:string}}
|
|
32
|
+
*/
|
|
33
|
+
function detectImperative(text) {
|
|
34
|
+
const t = firstMeaningfulText(text);
|
|
35
|
+
if (!t) return { isImperative: false, polarity: null, directive: '' };
|
|
36
|
+
// Negative wins if both appear — a forbid directive is the actionable one.
|
|
37
|
+
if (NEVER_RE.test(t)) return { isImperative: true, polarity: 'never', directive: t };
|
|
38
|
+
if (ALWAYS_RE.test(t)) return { isImperative: true, polarity: 'always', directive: t };
|
|
39
|
+
return { isImperative: false, polarity: null, directive: '' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shellQuote(text) {
|
|
43
|
+
return String(text).trim().slice(0, 140).replace(/"/g, "'").replace(/\s+/g, ' ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a suggestion for the capture confirmation. Returns null when there's no
|
|
48
|
+
* explicit directive to act on.
|
|
49
|
+
* - down + "never …" → OFFER immediate force-gate (the user asked to forbid it).
|
|
50
|
+
* - up + "always …" → clarify it's stored as guidance, not a hard block.
|
|
51
|
+
* @param {{signal?:string, text?:string}} opts
|
|
52
|
+
* @returns {{kind:string, message:string}|null}
|
|
53
|
+
*/
|
|
54
|
+
function suggestForceGate({ signal, text } = {}) {
|
|
55
|
+
const isDown = signal === 'down' || signal === 'negative' || signal === 'thumbs_down';
|
|
56
|
+
const det = detectImperative(text);
|
|
57
|
+
if (!det.isImperative) return null;
|
|
58
|
+
|
|
59
|
+
if (isDown && det.polarity === 'never') {
|
|
60
|
+
const ctx = shellQuote(text);
|
|
61
|
+
return {
|
|
62
|
+
kind: 'force-gate-offer',
|
|
63
|
+
message: 'You said "never" — block this immediately instead of waiting for it to recur:\n'
|
|
64
|
+
+ ` npx thumbgate force-gate --context="${ctx}" --action=block\n`
|
|
65
|
+
+ ' (or run /thumbgate-guard). Left alone, it auto-promotes to a gate after the pattern repeats.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!isDown && det.polarity === 'always') {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'always-note',
|
|
72
|
+
message: 'You said "always" — stored as an ALWAYS guidance principle. '
|
|
73
|
+
+ 'It is surfaced as context on future actions (guidance, not a hard block; positive patterns are not gate-enforced).',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
detectImperative,
|
|
82
|
+
suggestForceGate,
|
|
83
|
+
NEVER_RE,
|
|
84
|
+
ALWAYS_RE,
|
|
85
|
+
};
|
|
@@ -5,6 +5,7 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
7
7
|
const { getDecisionLogPath, readDecisionLog, collapseDecisionTimeline } = require('./decision-journal');
|
|
8
|
+
const { requireLearnedModelsEntitlement } = require('./entitlement');
|
|
8
9
|
|
|
9
10
|
const LABELS = ['allow', 'recall', 'verify', 'warn', 'deny'];
|
|
10
11
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -551,6 +552,10 @@ function summarizeTopTokens(model, limit = 5) {
|
|
|
551
552
|
}
|
|
552
553
|
|
|
553
554
|
function trainInterventionPolicy(examples, options = {}) {
|
|
555
|
+
requireLearnedModelsEntitlement({
|
|
556
|
+
...(options.entitlement || {}),
|
|
557
|
+
label: 'intervention-policy training',
|
|
558
|
+
});
|
|
554
559
|
const split = splitExamples(examples);
|
|
555
560
|
const evaluationModel = fitNaiveBayes(split.train);
|
|
556
561
|
const deployedModel = fitNaiveBayes(examples);
|
|
@@ -631,6 +636,10 @@ function buildRuntimeCandidate(params = {}) {
|
|
|
631
636
|
}
|
|
632
637
|
|
|
633
638
|
function getInterventionRecommendation(params = {}, options = {}) {
|
|
639
|
+
requireLearnedModelsEntitlement({
|
|
640
|
+
...(options.entitlement || {}),
|
|
641
|
+
label: 'intervention-policy recommendation',
|
|
642
|
+
});
|
|
634
643
|
const resolvedDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir || params.feedbackDir });
|
|
635
644
|
let model = options.model || loadInterventionPolicy(resolvedDir);
|
|
636
645
|
const candidate = options.candidate || buildRuntimeCandidate(params);
|
|
@@ -693,6 +702,10 @@ function computeDailySeries(examples, dayCount = 14) {
|
|
|
693
702
|
}
|
|
694
703
|
|
|
695
704
|
function getInterventionPolicySummary(feedbackDir, options = {}) {
|
|
705
|
+
requireLearnedModelsEntitlement({
|
|
706
|
+
...(options.entitlement || {}),
|
|
707
|
+
label: 'intervention-policy summary',
|
|
708
|
+
});
|
|
696
709
|
const resolvedDir = resolveFeedbackDir({ feedbackDir });
|
|
697
710
|
const { examples, sourceCounts } = buildExamplesFromFeedbackDir(resolvedDir);
|
|
698
711
|
const model = loadInterventionPolicy(resolvedDir) || trainInterventionPolicy(examples);
|
|
@@ -226,12 +226,13 @@ function parseJsonOrNull(bodyText) {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
async function postResendEmail({ fetcher, apiKey, payload }) {
|
|
229
|
+
async function postResendEmail({ fetcher, apiKey, payload, idempotencyKey }) {
|
|
230
230
|
const res = await fetcher(RESEND_ENDPOINT, {
|
|
231
231
|
method: 'POST',
|
|
232
232
|
headers: {
|
|
233
233
|
'Content-Type': 'application/json',
|
|
234
234
|
Authorization: `Bearer ${apiKey}`,
|
|
235
|
+
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
235
236
|
},
|
|
236
237
|
body: JSON.stringify(payload),
|
|
237
238
|
});
|
|
@@ -256,7 +257,7 @@ function buildSendSuccess({ bodyJson, status, senderFallback }) {
|
|
|
256
257
|
* Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
|
|
257
258
|
* missing. Never throws on network errors; returns a structured result instead.
|
|
258
259
|
*/
|
|
259
|
-
async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dnsResolver } = {}) {
|
|
260
|
+
async function sendEmail({ to, subject, html, text, from, replyTo, idempotencyKey, fetchImpl, dnsResolver } = {}) {
|
|
260
261
|
validateSendEmailInput({ to, subject, html, text });
|
|
261
262
|
|
|
262
263
|
const apiKey = getApiKey();
|
|
@@ -278,7 +279,12 @@ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dn
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
try {
|
|
281
|
-
const { res, bodyText, bodyJson } = await postResendEmail({
|
|
282
|
+
const { res, bodyText, bodyJson } = await postResendEmail({
|
|
283
|
+
fetcher,
|
|
284
|
+
apiKey,
|
|
285
|
+
payload,
|
|
286
|
+
idempotencyKey: isNonEmptyString(idempotencyKey) ? idempotencyKey.slice(0, 256) : null,
|
|
287
|
+
});
|
|
282
288
|
if (!res.ok) {
|
|
283
289
|
// eslint-disable-next-line no-console
|
|
284
290
|
console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
|
|
@@ -490,7 +496,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
|
|
|
490
496
|
* Never throws on send failures (beyond input validation); the Stripe webhook
|
|
491
497
|
* must keep working even if email breaks.
|
|
492
498
|
*/
|
|
493
|
-
async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl, dnsResolver } = {}) {
|
|
499
|
+
async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, idempotencyKey, fetchImpl, dnsResolver } = {}) {
|
|
494
500
|
if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
|
|
495
501
|
if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
|
|
496
502
|
|
|
@@ -500,7 +506,7 @@ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName,
|
|
|
500
506
|
? `${name}, your ThumbGate Pro key is inside`
|
|
501
507
|
: 'Your ThumbGate Pro key is inside';
|
|
502
508
|
|
|
503
|
-
return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl, dnsResolver });
|
|
509
|
+
return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), idempotencyKey, fetchImpl, dnsResolver });
|
|
504
510
|
}
|
|
505
511
|
|
|
506
512
|
function renderNewsletterWelcomeBodies() {
|
package/scripts/pr-manager.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PR Manager — High-Throughput Merge & Blocker Diagnosis
|
|
4
|
-
*
|
|
5
|
-
* Inspired by the 2026 GitHub 'Quick Access' update. Centralizes merge status
|
|
6
|
-
* detection and triggers autonomous self-healing for common blockers.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
2
|
'use strict';
|
|
10
3
|
|
|
11
4
|
const fs = require('node:fs');
|
|
@@ -34,6 +27,8 @@ const PASSING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.passingBuckets || []).map(
|
|
|
34
27
|
const PENDING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.pendingBuckets || []).map((value) => String(value || '').toLowerCase()));
|
|
35
28
|
const FAILING_BUCKETS = new Set((MERGE_QUALITY_CHECKS.failingBuckets || []).map((value) => String(value || '').toLowerCase()));
|
|
36
29
|
|
|
30
|
+
const SELF_REFERENTIAL_CHECKS = new Set(MERGE_QUALITY_CHECKS.selfReferentialChecks || []);
|
|
31
|
+
|
|
37
32
|
function assertSafeGhArgs(args) {
|
|
38
33
|
if (!Array.isArray(args) || args.length === 0) {
|
|
39
34
|
throw new Error('GH CLI args must be a non-empty array.');
|
|
@@ -119,9 +114,6 @@ function isMissingCurrentBranchPr(result, prNumber) {
|
|
|
119
114
|
|| /could not determine current branch:.*not on any branch/i.test(formatGhError(result));
|
|
120
115
|
}
|
|
121
116
|
|
|
122
|
-
/**
|
|
123
|
-
* Fetch granular PR status using GH CLI
|
|
124
|
-
*/
|
|
125
117
|
function getPrStatus(prNumber = '', runner = runGh) {
|
|
126
118
|
const normalizedPrNumber = normalizePrNumber(prNumber);
|
|
127
119
|
const args = ['pr', 'view'];
|
|
@@ -182,6 +174,9 @@ function summarizeChecks(checks = []) {
|
|
|
182
174
|
|
|
183
175
|
for (const check of checks) {
|
|
184
176
|
const name = check.name || 'unknown-check';
|
|
177
|
+
|
|
178
|
+
if (SELF_REFERENTIAL_CHECKS.has(name)) continue;
|
|
179
|
+
|
|
185
180
|
const bucket = String(check.bucket || '').toLowerCase();
|
|
186
181
|
if (bucket) {
|
|
187
182
|
if (FAILING_BUCKETS.has(bucket)) {
|
|
@@ -225,9 +220,6 @@ function sleep(ms) {
|
|
|
225
220
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
226
221
|
}
|
|
227
222
|
|
|
228
|
-
/**
|
|
229
|
-
* Diagnose and resolve blockers autonomously
|
|
230
|
-
*/
|
|
231
223
|
async function resolveBlockers(pr, runner = runGh) {
|
|
232
224
|
const title = pr.title || 'Untitled PR';
|
|
233
225
|
const mergeState = pr.mergeStateStatus || 'UNKNOWN';
|
|
@@ -241,7 +233,6 @@ async function resolveBlockers(pr, runner = runGh) {
|
|
|
241
233
|
return { status: 'skipped', reason: 'draft' };
|
|
242
234
|
}
|
|
243
235
|
|
|
244
|
-
// 1. Handle Outdated Branch (BEHIND)
|
|
245
236
|
if (pr.mergeStateStatus === 'BEHIND') {
|
|
246
237
|
console.log('[PR Manager] PR is behind main. Triggering auto-update...');
|
|
247
238
|
const update = runner(['pr', 'update-branch', pr.number.toString()]);
|
|
@@ -250,13 +241,11 @@ async function resolveBlockers(pr, runner = runGh) {
|
|
|
250
241
|
}
|
|
251
242
|
}
|
|
252
243
|
|
|
253
|
-
// 2. Handle Merge Conflicts (DIRTY)
|
|
254
244
|
if (pr.mergeStateStatus === 'DIRTY' || pr.mergeable === 'CONFLICTING') {
|
|
255
245
|
console.log('[PR Manager] CRITICAL: Merge conflicts detected. Manual intervention or advanced rebase required.');
|
|
256
246
|
return { status: 'blocked', reason: 'conflicts' };
|
|
257
247
|
}
|
|
258
248
|
|
|
259
|
-
// 3. Handle CI Failures
|
|
260
249
|
let checks = pr.statusCheckRollup || [];
|
|
261
250
|
let checkSource = 'statusCheckRollup';
|
|
262
251
|
|
|
@@ -282,7 +271,6 @@ async function resolveBlockers(pr, runner = runGh) {
|
|
|
282
271
|
return { status: 'blocked', reason: 'ci_pending', checks: checkSummary.pending, checkSource };
|
|
283
272
|
}
|
|
284
273
|
|
|
285
|
-
// 4. Handle Review Blockers
|
|
286
274
|
if (pr.reviewDecision === 'CHANGES_REQUESTED') {
|
|
287
275
|
console.log('[PR Manager] BLOCKED: Changes requested by reviewer.');
|
|
288
276
|
return { status: 'blocked', reason: 'changes_requested' };
|
|
@@ -293,8 +281,10 @@ async function resolveBlockers(pr, runner = runGh) {
|
|
|
293
281
|
return { status: 'blocked', reason: 'review_required' };
|
|
294
282
|
}
|
|
295
283
|
|
|
296
|
-
|
|
297
|
-
|
|
284
|
+
if (
|
|
285
|
+
(pr.mergeStateStatus === 'CLEAN' || pr.mergeStateStatus === 'UNSTABLE')
|
|
286
|
+
&& pr.mergeable === 'MERGEABLE'
|
|
287
|
+
) {
|
|
298
288
|
console.log('[PR Manager] SUCCESS: PR is ready for protected autonomous merge.');
|
|
299
289
|
return { status: 'ready' };
|
|
300
290
|
}
|
|
@@ -368,9 +358,6 @@ function submitTrunkMergeRequest(prNumber, runner = runGh) {
|
|
|
368
358
|
};
|
|
369
359
|
}
|
|
370
360
|
|
|
371
|
-
/**
|
|
372
|
-
* Perform autonomous merge
|
|
373
|
-
*/
|
|
374
361
|
function performMerge(prInput, runner = runGh, options = {}) {
|
|
375
362
|
const pr = (prInput && typeof prInput === 'object')
|
|
376
363
|
? prInput
|