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.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +124 -129
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/mcp/server-stdio.js +18 -33
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +95 -78
  8. package/bin/postinstall.js +1 -1
  9. package/config/entitlement-public-keys.json +6 -0
  10. package/config/gates/default.json +3 -3
  11. package/config/github-about.json +2 -5
  12. package/config/merge-quality-checks.json +3 -0
  13. package/config/post-deploy-marketing-pages.json +1 -1
  14. package/hooks/hooks.json +38 -0
  15. package/package.json +28 -10
  16. package/public/about.html +1 -4
  17. package/public/agent-manager.html +6 -6
  18. package/public/agents-cost-savings.html +3 -3
  19. package/public/ai-malpractice-prevention.html +1 -1
  20. package/public/blog.html +12 -9
  21. package/public/chatgpt-app.html +3 -3
  22. package/public/codex-enterprise.html +3 -3
  23. package/public/codex-plugin.html +5 -5
  24. package/public/compare.html +10 -10
  25. package/public/dashboard.html +1 -1
  26. package/public/diagnostic.html +23 -1
  27. package/public/federal.html +2 -2
  28. package/public/guide.html +14 -14
  29. package/public/index.html +202 -220
  30. package/public/install.html +14 -14
  31. package/public/learn.html +4 -17
  32. package/public/numbers.html +2 -2
  33. package/public/partner-intake.html +198 -0
  34. package/public/pricing.html +61 -31
  35. package/public/pro.html +2 -2
  36. package/scripts/agent-reward-model.js +13 -0
  37. package/scripts/bayes-optimal-gate.js +6 -1
  38. package/scripts/billing.js +815 -137
  39. package/scripts/checkout-attribution-reference.js +85 -0
  40. package/scripts/claude-feedback-sync.js +23 -6
  41. package/scripts/cli-feedback.js +33 -8
  42. package/scripts/commercial-offer.js +3 -3
  43. package/scripts/entitlement.js +250 -0
  44. package/scripts/export-databricks-bundle.js +5 -0
  45. package/scripts/export-dpo-pairs.js +6 -0
  46. package/scripts/export-hf-dataset.js +5 -0
  47. package/scripts/feedback-loop.js +290 -1
  48. package/scripts/feedback-quality.js +25 -26
  49. package/scripts/feedback-sanitizer.js +151 -3
  50. package/scripts/gates-engine.js +134 -43
  51. package/scripts/hosted-config.js +9 -2
  52. package/scripts/imperative-detector.js +85 -0
  53. package/scripts/intervention-policy.js +13 -0
  54. package/scripts/mailer/resend-mailer.js +11 -5
  55. package/scripts/pr-manager.js +9 -22
  56. package/scripts/pro-local-dashboard.js +198 -0
  57. package/scripts/risk-scorer.js +6 -0
  58. package/scripts/secret-scanner.js +363 -68
  59. package/scripts/self-protection.js +21 -36
  60. package/scripts/seo-gsd.js +2 -2
  61. package/scripts/thompson-sampling.js +11 -2
  62. package/src/api/server.js +202 -22
  63. package/public/assets/brand/github-social-preview.png +0 -0
@@ -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
- // The firewall fires and LOGS every decision, but downgrades deny/approve -> warn so
146
- // legitimate work is never hard-blocked. We deliberately do NOT try to hard-block
147
- // arbitrary destructive commands here: a regex "catastrophic floor" is unwinnable
148
- // (sudo / bash -c / find -exec / eval / base64|sh all evade it) and gives false confidence.
149
- // HARD enforcement is an explicit opt-in via THUMBGATE_STRICT_ENFORCEMENT=1, which keeps
150
- // the engine's FULL gate set (its high-risk-command gates catch prefixed/obfuscated forms
151
- // far better than any single regex). Secret exfiltration and the security-vulnerability
152
- // scan hard-deny on their OWN paths before this runs, so irreversible data-leak / supply
153
- // chain risks stay blocked regardless of posture.
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, or THUMBGATE_HOTFIX_BYPASS=1 to disable checks entirely.`,
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 synced across laptops, CI, containers, and agent runtimes → thumbgate.ai/go/pro';
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 keeps these lessons/rules synced everywhere → thumbgate.ai/go/pro ($19/mo)`;
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 secretGuard = evaluateSecretGuard(input);
2988
- if (secretGuard) {
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 secretGuard = evaluateSecretGuard(input);
3035
- if (secretGuard) {
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,
@@ -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
- const workflowSprintCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_WORKFLOW_SPRINT_CHECKOUT_URL);
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({ fetcher, apiKey, payload });
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() {
@@ -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
- // 5. Ready to Merge
297
- if (pr.mergeStateStatus === 'CLEAN' && pr.mergeable === 'MERGEABLE') {
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