thumbgate 1.27.19 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +1 -1
  9. package/.well-known/mcp/server-card.json +1 -1
  10. package/README.md +63 -45
  11. package/adapters/claude/.mcp.json +2 -2
  12. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +1 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +52 -9
  21. package/bin/dashboard-cli.js +7 -0
  22. package/bin/postinstall.js +14 -23
  23. package/commands/dashboard.md +15 -0
  24. package/commands/thumbgate-dashboard.md +15 -0
  25. package/config/entitlement-public-keys.json +6 -0
  26. package/config/gates/default.json +1 -1
  27. package/config/github-about.json +2 -2
  28. package/config/merge-quality-checks.json +3 -0
  29. package/package.json +233 -100
  30. package/public/about.html +159 -0
  31. package/public/agent-manager.html +179 -0
  32. package/public/agents-cost-savings.html +153 -0
  33. package/public/ai-malpractice-prevention.html +818 -0
  34. package/public/assets/brand/github-social-preview.png +0 -0
  35. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  36. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  37. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  38. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  39. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  40. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  41. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  42. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  43. package/public/assets/brand/thumbgate-mark.svg +21 -0
  44. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  45. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  46. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  47. package/public/assets/legal-intake-control-flow.svg +66 -0
  48. package/public/blog.html +12 -9
  49. package/public/brand/thumbgate-mark.svg +19 -0
  50. package/public/brand/thumbgate-og.svg +16 -0
  51. package/public/chatgpt-app.html +330 -0
  52. package/public/codex-enterprise.html +123 -0
  53. package/public/codex-plugin.html +5 -5
  54. package/public/compare.html +10 -10
  55. package/public/dashboard.html +1 -1
  56. package/public/diagnostic.html +345 -0
  57. package/public/federal.html +2 -2
  58. package/public/guide.html +14 -14
  59. package/public/index.html +81 -70
  60. package/public/install.html +193 -0
  61. package/public/js/buyer-intent.js +672 -0
  62. package/public/learn.html +4 -17
  63. package/public/numbers.html +2 -2
  64. package/public/pricing.html +392 -0
  65. package/public/pro.html +2 -2
  66. package/scripts/action-receipts.js +324 -0
  67. package/scripts/activation-quickstart.js +187 -0
  68. package/scripts/agent-operations-planner.js +621 -0
  69. package/scripts/agent-reward-model.js +13 -0
  70. package/scripts/ai-component-inventory.js +367 -0
  71. package/scripts/async-eval-observability.js +236 -0
  72. package/scripts/audit.js +65 -0
  73. package/scripts/aws-blocks-guardrails.js +272 -0
  74. package/scripts/bayes-optimal-gate.js +6 -1
  75. package/scripts/classifier-routing.js +130 -0
  76. package/scripts/cli-feedback.js +17 -1
  77. package/scripts/commercial-offer.js +3 -3
  78. package/scripts/dashboard-chat.js +332 -0
  79. package/scripts/entitlement.js +250 -0
  80. package/scripts/export-databricks-bundle.js +5 -0
  81. package/scripts/export-dpo-pairs.js +6 -0
  82. package/scripts/export-hf-dataset.js +5 -0
  83. package/scripts/feedback-aggregate.js +281 -0
  84. package/scripts/feedback-sanitizer.js +105 -0
  85. package/scripts/gates-engine.js +15 -3
  86. package/scripts/hook-stop-anti-claim.js +301 -0
  87. package/scripts/imperative-detector.js +85 -0
  88. package/scripts/install-shim.js +87 -0
  89. package/scripts/intervention-policy.js +13 -0
  90. package/scripts/mcp-oauth.js +293 -0
  91. package/scripts/noop-detect.js +285 -0
  92. package/scripts/parallel-workflow-orchestrator.js +293 -0
  93. package/scripts/plan-gate.js +243 -0
  94. package/scripts/plausible-domain-config.js +99 -0
  95. package/scripts/pr-manager.js +9 -22
  96. package/scripts/pro-local-dashboard.js +198 -0
  97. package/scripts/qa-scenario-planner.js +136 -0
  98. package/scripts/repeat-metric.js +137 -0
  99. package/scripts/risk-scorer.js +6 -0
  100. package/scripts/secret-fixture-tokens.js +61 -0
  101. package/scripts/secret-redaction.js +166 -0
  102. package/scripts/self-harness-optimizer.js +141 -0
  103. package/scripts/self-healing-check.js +193 -0
  104. package/scripts/self-protection.js +90 -0
  105. package/scripts/seo-gsd.js +2 -2
  106. package/scripts/silent-failure-cluster.js +531 -0
  107. package/scripts/statusline-cache-read.js +57 -0
  108. package/scripts/sync-telemetry-from-prod.js +374 -0
  109. package/scripts/thompson-sampling.js +11 -2
  110. package/scripts/tool-contract-validator.js +76 -0
  111. package/scripts/trajectory-scorer.js +63 -0
  112. package/scripts/verify-marketing-pages-deployed.js +212 -0
  113. package/scripts/visitor-journey.js +172 -0
  114. package/src/api/server.js +81 -10
  115. package/.claude-plugin/marketplace.json +0 -85
  116. package/adapters/chatgpt/openapi.yaml +0 -1707
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const AWS_BLOCKS_DEPENDENCY_PATTERN = /(^|\/)@aws-blocks\/(?:blocks|[^/\s"']+)/;
8
+ const PRODUCTION_DEPLOY_PATTERNS = [
9
+ /\bcdk\s+deploy\b/i,
10
+ /\bnpm\s+run\s+deploy\b/i,
11
+ /\bpnpm\s+deploy\b/i,
12
+ /\byarn\s+deploy\b/i,
13
+ /\bsst\s+deploy\b/i,
14
+ /\bblocks?\s+deploy\b/i,
15
+ ];
16
+ const DESTROY_PATTERNS = [
17
+ /\bcdk\s+destroy\b/i,
18
+ /\bnpm\s+run\s+destroy\b/i,
19
+ /\bpnpm\s+destroy\b/i,
20
+ /\byarn\s+destroy\b/i,
21
+ /\bsandbox\b.*--destroy\b/i,
22
+ /\b--destroy\b/i,
23
+ /\baws\s+cloudformation\s+delete-stack\b/i,
24
+ ];
25
+ const DESTRUCTIVE_AWS_PATTERNS = [
26
+ /\baws\s+dynamodb\s+delete-table\b/i,
27
+ /\baws\s+rds\s+delete-db-instance\b/i,
28
+ /\baws\s+s3\s+rm\b[\s\S]*--recursive\b/i,
29
+ /\baws\s+lambda\s+delete-function\b/i,
30
+ /\baws\s+bedrock-agent\s+delete-/i,
31
+ /\baws\s+bedrock-agent-runtime\b/i,
32
+ ];
33
+
34
+ function toList(value) {
35
+ if (Array.isArray(value)) return value.map(String).map((entry) => entry.trim()).filter(Boolean);
36
+ if (typeof value === 'string') return value.split(',').map((entry) => entry.trim()).filter(Boolean);
37
+ return [];
38
+ }
39
+
40
+ function normalizeText(value) {
41
+ if (value == null) return '';
42
+ if (typeof value === 'string') return value;
43
+ try {
44
+ return JSON.stringify(value);
45
+ } catch {
46
+ return String(value);
47
+ }
48
+ }
49
+
50
+ function readPackageJson(projectDir) {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function matchesAny(text, patterns) {
59
+ return patterns.some((pattern) => pattern.test(text));
60
+ }
61
+
62
+ function hasDestructiveSql(text) {
63
+ if (/\bdrop\s+(table|database|schema|index|column)\b/i.test(text)) return true;
64
+ if (/\btruncate\s+table\b/i.test(text)) return true;
65
+ if (/\bdelete\s+from\s+[\w".-]+(?:\s*;|\s*$)/i.test(text)) return true;
66
+ const updateMatch = /\bupdate\s+[\w".-]+\s+set\b/i.exec(text);
67
+ if (!updateMatch) return false;
68
+ const afterUpdate = text.slice(updateMatch.index, updateMatch.index + 180);
69
+ return !/\bwhere\b/i.test(afterUpdate);
70
+ }
71
+
72
+ function packageUsesAwsBlocks(pkg) {
73
+ if (!pkg || typeof pkg !== 'object') return false;
74
+ const dependencyText = JSON.stringify({
75
+ dependencies: pkg.dependencies || {},
76
+ devDependencies: pkg.devDependencies || {},
77
+ peerDependencies: pkg.peerDependencies || {},
78
+ optionalDependencies: pkg.optionalDependencies || {},
79
+ });
80
+ const scriptsText = JSON.stringify(pkg.scripts || {});
81
+ return AWS_BLOCKS_DEPENDENCY_PATTERN.test(dependencyText) || /aws-blocks|blocks-app|cdk|sandbox/i.test(scriptsText);
82
+ }
83
+
84
+ function detectAwsBlocksProject(projectDir = process.cwd()) {
85
+ const pkg = readPackageJson(projectDir);
86
+ if (packageUsesAwsBlocks(pkg)) return true;
87
+ return fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.ts'))
88
+ || fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.js'))
89
+ || fs.existsSync(path.join(projectDir, 'blocks.spec.json'));
90
+ }
91
+
92
+ function detectAction(input = {}) {
93
+ const command = normalizeText(input.command || input.args || input.shell || input.toolInput);
94
+ const toolName = normalizeText(input.toolName || input.tool || input.name);
95
+ const code = normalizeText(input.code || input.file || input.diff || input.body);
96
+ const combined = `${toolName}\n${command}\n${code}`;
97
+ const lower = combined.toLowerCase();
98
+
99
+ const signals = [];
100
+ const add = (id, label, severity = 'medium') => signals.push({ id, label, severity });
101
+
102
+ if (matchesAny(combined, PRODUCTION_DEPLOY_PATTERNS)) {
103
+ add('aws-blocks-production-deploy', 'production deploy from an AWS Blocks workflow', 'high');
104
+ }
105
+
106
+ if (matchesAny(combined, DESTROY_PATTERNS)) {
107
+ add('aws-blocks-destroy', 'destroy command can remove AWS resources created from local Blocks code', 'critical');
108
+ }
109
+
110
+ if (hasDestructiveSql(combined)) {
111
+ add('destructive-sql-or-ddl', 'destructive or unscoped SQL mutation', 'critical');
112
+ }
113
+
114
+ if (matchesAny(combined, DESTRUCTIVE_AWS_PATTERNS)) {
115
+ add('destructive-aws-cli', 'destructive AWS CLI or Bedrock agent action', 'critical');
116
+ }
117
+
118
+ if (/\b(gcloud|aws)\b[\s\S]{0,220}\b(add-iam-policy-binding|put-role-policy|attach-role-policy|create-policy-version)\b/i.test(combined)
119
+ || /\b(AdministratorAccess|roles\/owner|iam:PassRole|serviceAccountTokenCreator)\b/i.test(combined)) {
120
+ add('iam-escalation', 'agent session attempts to grant broad IAM authority', 'critical');
121
+ }
122
+
123
+ if (/\b(new\s+Agent\s*\(|@aws-blocks\/(?:agent|blocks)|\bAgent\b[\s\S]{0,120}\btools?\b)/.test(combined)) {
124
+ add('blocks-agent-tool-call', 'AWS Blocks Agent or Bedrock-style tool action needs a tool boundary', 'medium');
125
+ }
126
+
127
+ if (/\b(npm\s+run\s+dev|npm\s+start|pnpm\s+dev|yarn\s+dev|create-blocks-app)\b/i.test(combined)) {
128
+ add('local-dev', 'local AWS Blocks development loop', 'low');
129
+ }
130
+
131
+ return {
132
+ command,
133
+ toolName,
134
+ code,
135
+ signals,
136
+ highRisk: signals.some((signal) => ['high', 'critical'].includes(signal.severity)),
137
+ localOnly: signals.some((signal) => signal.id === 'local-dev') && signals.every((signal) => signal.severity === 'low'),
138
+ lower,
139
+ };
140
+ }
141
+
142
+ function evaluateAwsBlocksAction(input = {}) {
143
+ const projectDir = input.projectDir || input.cwd || process.cwd();
144
+ const projectUsesAwsBlocks = Boolean(
145
+ input.projectUsesAwsBlocks
146
+ || input.awsBlocks
147
+ || input.blocksProject
148
+ || detectAwsBlocksProject(projectDir)
149
+ );
150
+ const evidence = new Set(toList(input.evidence || input.proof || input.receipts));
151
+ const action = detectAction(input);
152
+ const requiredEvidence = [];
153
+
154
+ const requireEvidence = (id, label) => {
155
+ if (!evidence.has(id)) requiredEvidence.push({ id, label });
156
+ };
157
+
158
+ if (projectUsesAwsBlocks && action.signals.some((signal) => signal.id === 'aws-blocks-production-deploy')) {
159
+ requireEvidence('local-tests-pass', 'local AWS Blocks tests pass against local implementations');
160
+ requireEvidence('cdk-diff-reviewed', 'CDK diff or synthesized CloudFormation change set reviewed');
161
+ requireEvidence('cost-blast-radius-reviewed', 'AWS cost and resource blast radius reviewed');
162
+ }
163
+
164
+ if (action.signals.some((signal) => signal.id === 'aws-blocks-destroy')) {
165
+ requireEvidence('resource-inventory-exported', 'resource inventory exported before destroy');
166
+ requireEvidence('human-destroy-approval', 'named human approval for destroy');
167
+ }
168
+
169
+ if (action.signals.some((signal) => signal.id === 'destructive-sql-or-ddl')) {
170
+ requireEvidence('backup-or-rollback-ready', 'backup, rollback, or restore point exists');
171
+ requireEvidence('bounded-row-count-reviewed', 'row/table impact was previewed before mutation');
172
+ }
173
+
174
+ if (action.signals.some((signal) => signal.id === 'destructive-aws-cli')) {
175
+ requireEvidence('aws-account-and-region-confirmed', 'target AWS account and region confirmed');
176
+ requireEvidence('rollback-plan-attached', 'rollback or recovery plan attached');
177
+ }
178
+
179
+ if (action.signals.some((signal) => signal.id === 'iam-escalation')) {
180
+ requireEvidence('least-privilege-review', 'least-privilege review completed');
181
+ requireEvidence('security-owner-approval', 'security owner approval captured');
182
+ }
183
+
184
+ if (action.signals.some((signal) => signal.id === 'blocks-agent-tool-call')) {
185
+ requireEvidence('agent-tool-allowlist', 'agent tool allowlist and data boundary declared');
186
+ }
187
+
188
+ const shouldBlock = projectUsesAwsBlocks && requiredEvidence.length > 0;
189
+ let status = 'allowed';
190
+ if (shouldBlock) {
191
+ status = 'blocked';
192
+ } else if (action.highRisk) {
193
+ status = 'needs-review';
194
+ }
195
+
196
+ return {
197
+ name: 'thumbgate-aws-blocks-guardrails',
198
+ status,
199
+ projectUsesAwsBlocks,
200
+ signals: action.signals,
201
+ requiredEvidence,
202
+ enforcementBoundary: 'local AWS Blocks confidence must not automatically become production AWS authority',
203
+ gates: [
204
+ 'allow local AWS Blocks dev loops without AWS credentials',
205
+ 'require local test proof plus CDK diff before production deploy',
206
+ 'block destroy and destructive data mutations until backup, blast-radius, and human approval evidence exists',
207
+ 'block IAM escalation and Bedrock/Agent tool expansion until an owner approves the tool boundary',
208
+ 'write a ThumbGate receipt for every allowed high-risk cloud action',
209
+ ],
210
+ nextActions: shouldBlock
211
+ ? requiredEvidence.map((item) => `Provide ${item.id}: ${item.label}`)
212
+ : ['Record an allow receipt when this action touches real AWS resources'],
213
+ };
214
+ }
215
+
216
+ function buildAwsBlocksHardeningOffer(input = {}) {
217
+ const workflow = String(input.workflow || input.name || 'AWS Blocks backend').trim();
218
+ const buyer = String(input.buyer || input.owner || 'platform owner').trim();
219
+ return {
220
+ name: 'thumbgate-aws-blocks-hardening-offer',
221
+ status: 'ready-for-positioning',
222
+ buyer,
223
+ workflow,
224
+ headline: 'AWS Blocks helps agents build the backend. ThumbGate stops them before unsafe cloud actions run.',
225
+ offer: 'AWS Blocks Agent Safety Review',
226
+ diagnosticPrice: '$499',
227
+ proofPlan: [
228
+ 'map one AWS Blocks local-to-cloud workflow',
229
+ 'install ThumbGate against the agent running that workflow',
230
+ 'add gates for deploy, destroy, data mutation, IAM, Bedrock Agent, and cost-blast-radius actions',
231
+ 'produce a receipt showing the first blocked repeat and the evidence required to allow it',
232
+ ],
233
+ cta: 'Send one AWS Blocks workflow that is about to deploy, mutate data, or call Bedrock tools.',
234
+ };
235
+ }
236
+
237
+ function parseArgs(argv = process.argv.slice(2)) {
238
+ const args = {};
239
+ for (const arg of argv) {
240
+ if (arg === '--json') args.json = true;
241
+ else if (arg === '--aws-blocks' || arg === '--project-uses-aws-blocks') args.projectUsesAwsBlocks = true;
242
+ else if (arg.startsWith('--command=')) args.command = arg.slice('--command='.length);
243
+ else if (arg.startsWith('--tool=')) args.toolName = arg.slice('--tool='.length);
244
+ else if (arg.startsWith('--code=')) args.code = arg.slice('--code='.length);
245
+ else if (arg.startsWith('--cwd=')) args.projectDir = arg.slice('--cwd='.length);
246
+ else if (arg.startsWith('--evidence=')) args.evidence = arg.slice('--evidence='.length);
247
+ else if (arg.startsWith('--workflow=')) args.workflow = arg.slice('--workflow='.length);
248
+ else if (arg.startsWith('--buyer=')) args.buyer = arg.slice('--buyer='.length);
249
+ else if (arg === 'offer') args.commandName = 'offer';
250
+ }
251
+ return args;
252
+ }
253
+
254
+ function runCli(args = parseArgs()) {
255
+ const report = args.commandName === 'offer'
256
+ ? buildAwsBlocksHardeningOffer(args)
257
+ : evaluateAwsBlocksAction(args);
258
+ if (args.json) console.log(JSON.stringify(report, null, 2));
259
+ else {
260
+ console.log(`${report.name}: ${report.status}`);
261
+ for (const action of report.nextActions || report.proofPlan || []) console.log(`- ${action}`);
262
+ }
263
+ }
264
+
265
+ if (require.main?.filename === __filename) runCli();
266
+
267
+ module.exports = {
268
+ detectAwsBlocksProject,
269
+ detectAction,
270
+ evaluateAwsBlocksAction,
271
+ buildAwsBlocksHardeningOffer,
272
+ };
@@ -32,6 +32,7 @@
32
32
 
33
33
  const fs = require('node:fs');
34
34
  const path = require('node:path');
35
+ const { requireLearnedModelsEntitlement } = require('./entitlement');
35
36
 
36
37
  // Baseline loss matrix. `default` applies when no tag-specific override
37
38
  // matches. Higher = more expensive. The asymmetry below reflects the
@@ -181,7 +182,11 @@ function computeBayesPosterior({ tags, riskByTag, baseRate, modelProbability } =
181
182
  *
182
183
  * This reduces to the usual Bayes classifier when both costs are equal.
183
184
  */
184
- function bayesOptimalDecision(posterior, tags, lossMatrix = DEFAULT_LOSS_MATRIX) {
185
+ function bayesOptimalDecision(posterior, tags, lossMatrix = DEFAULT_LOSS_MATRIX, options = {}) {
186
+ requireLearnedModelsEntitlement({
187
+ ...(options.entitlement || {}),
188
+ label: 'Bayes-optimal gate decisioning',
189
+ });
185
190
  const pHarmful = clip(Number(posterior?.pHarmful), 0, 1);
186
191
  const pSafe = clip(Number(posterior?.pSafe ?? 1 - pHarmful), 0, 1);
187
192
  const cFalseAllow = resolveCost(lossMatrix?.falseAllow || {}, tags);
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gate-classifier-routing.json');
8
+
9
+ function clamp01(value, fallback = 0) {
10
+ const number = Number(value);
11
+ if (!Number.isFinite(number)) return fallback;
12
+ return Math.min(1, Math.max(0, number));
13
+ }
14
+
15
+ function riskRank(risk) {
16
+ const normalized = String(risk || 'medium').toLowerCase();
17
+ if (['critical', 'block', 'regulated'].includes(normalized)) return 4;
18
+ if (['high', 'dangerous'].includes(normalized)) return 3;
19
+ if (['medium', 'warn'].includes(normalized)) return 2;
20
+ return 1;
21
+ }
22
+
23
+ function loadClassifierRoutingConfig(configPath = DEFAULT_CONFIG_PATH) {
24
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
25
+ }
26
+
27
+ function lane(config, laneName, reason, input, overrides = {}) {
28
+ const laneConfig = (config.lanes && config.lanes[laneName]) || {};
29
+ return {
30
+ lane: laneName,
31
+ reason,
32
+ requiresEvidence: Boolean(laneConfig.requiresEvidence || overrides.requiresEvidence),
33
+ cloudAllowed: Boolean(laneConfig.cloudAllowed),
34
+ maxLatencyMs: laneConfig.maxLatencyMs,
35
+ risk: String(input.risk || 'medium').toLowerCase(),
36
+ ambiguity: clamp01(input.ambiguity),
37
+ };
38
+ }
39
+
40
+ function routeClassifier(input = {}, config = loadClassifierRoutingConfig()) {
41
+ const thresholds = config.thresholds || {};
42
+ const labelCount = Number(input.labelCount || input.examples || 0);
43
+ const latencyBudgetMs = Number(input.latencyBudgetMs || 0);
44
+ const ambiguity = clamp01(input.ambiguity);
45
+ const risk = riskRank(input.risk);
46
+ const largeBatch = Number(input.batchRows || 0) >= Number(thresholds.largeBatchRows || 50);
47
+ const privacySensitive = Boolean(input.privacySensitive || input.containsSecrets || input.customerData);
48
+ const allowCloud = Boolean(input.allowCloud);
49
+
50
+ if (input.hasHardRule || input.exactPolicyMatch) {
51
+ return lane(config, 'deterministic', 'exact hard rule or policy match; do not spend model tokens', input);
52
+ }
53
+
54
+ if (input.semanticCacheHit || input.equivalentRepeat) {
55
+ return lane(config, 'semantic_cache', 'semantically equivalent repeat; reuse the proven prior decision without a model call', input);
56
+ }
57
+
58
+ if (input.rubricFailed || input.missingEvidence || input.completionClaimWithoutProof) {
59
+ return lane(config, 'rubric_gate', 'rubric or completion evidence failed; block done claims until proof exists', input);
60
+ }
61
+
62
+ if (input.structuredDataset && (input.missingProvenance || input.missingSources)) {
63
+ return lane(config, 'rubric_gate', 'structured data claim is missing source provenance', input);
64
+ }
65
+
66
+ if (privacySensitive && !allowCloud && risk >= 3 && ambiguity >= Number(thresholds.mediumAmbiguity || 0.35)) {
67
+ return lane(config, 'human_review', 'private high-risk ambiguous action; keep data local and require approval', input);
68
+ }
69
+
70
+ if (labelCount >= Number(thresholds.classicalMinExamples || 40) && (largeBatch || latencyBudgetMs <= Number(thresholds.lowLatencyBudgetMs || 300)) && ambiguity < Number(thresholds.mediumAmbiguity || 0.35)) {
71
+ return lane(config, 'local_classical', 'enough examples and low ambiguity; use cheap local classification', input);
72
+ }
73
+
74
+ if (risk >= 3 && ambiguity >= Number(thresholds.highRiskAmbiguity || 0.65)) {
75
+ if (allowCloud && latencyBudgetMs >= Number(thresholds.llmMinLatencyBudgetMs || 2000)) {
76
+ return lane(config, 'llm_judge', 'high-risk semantic ambiguity; use a budget-capped LLM judge with evidence', input);
77
+ }
78
+ return lane(config, 'human_review', 'high-risk ambiguity without approved cloud/budget route', input);
79
+ }
80
+
81
+ if (labelCount < Number(thresholds.classicalMinExamples || 40) || ambiguity >= Number(thresholds.mediumAmbiguity || 0.35)) {
82
+ return lane(config, 'local_semantic', 'sparse labels or fuzzy intent; use local semantic recall before any LLM', input);
83
+ }
84
+
85
+ return lane(config, config.defaultLane || 'local_classical', 'default local route for routine gate classification', input);
86
+ }
87
+
88
+ function explainClassifierRoute(input = {}, config = loadClassifierRoutingConfig()) {
89
+ const decision = routeClassifier(input, config);
90
+ const laneConfig = (config.lanes && config.lanes[decision.lane]) || {};
91
+ return {
92
+ ...decision,
93
+ description: laneConfig.description || '',
94
+ useFor: laneConfig.useFor || [],
95
+ };
96
+ }
97
+
98
+ function parseArgs(argv) {
99
+ const input = {};
100
+ for (const arg of argv) {
101
+ if (arg === '--hard-rule') input.hasHardRule = true;
102
+ else if (arg === '--privacy-sensitive') input.privacySensitive = true;
103
+ else if (arg === '--allow-cloud') input.allowCloud = true;
104
+ else if (arg === '--customer-data') input.customerData = true;
105
+ else if (arg === '--semantic-cache-hit') input.semanticCacheHit = true;
106
+ else if (arg === '--equivalent-repeat') input.equivalentRepeat = true;
107
+ else if (arg === '--rubric-failed') input.rubricFailed = true;
108
+ else if (arg === '--missing-evidence') input.missingEvidence = true;
109
+ else if (arg === '--structured-dataset') input.structuredDataset = true;
110
+ else if (arg === '--missing-provenance') input.missingProvenance = true;
111
+ else if (arg.startsWith('--risk=')) input.risk = arg.slice('--risk='.length);
112
+ else if (arg.startsWith('--ambiguity=')) input.ambiguity = Number(arg.slice('--ambiguity='.length));
113
+ else if (arg.startsWith('--labels=')) input.labelCount = Number(arg.slice('--labels='.length));
114
+ else if (arg.startsWith('--latency-ms=')) input.latencyBudgetMs = Number(arg.slice('--latency-ms='.length));
115
+ else if (arg.startsWith('--batch-rows=')) input.batchRows = Number(arg.slice('--batch-rows='.length));
116
+ }
117
+ return input;
118
+ }
119
+
120
+ module.exports = {
121
+ DEFAULT_CONFIG_PATH,
122
+ explainClassifierRoute,
123
+ loadClassifierRoutingConfig,
124
+ routeClassifier,
125
+ };
126
+
127
+ if (require.main === module) {
128
+ const decision = explainClassifierRoute(parseArgs(process.argv.slice(2)));
129
+ process.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
130
+ }
@@ -29,6 +29,7 @@ const G = '\x1b[32m';
29
29
  const R = '\x1b[31m';
30
30
  const C = '\x1b[36m';
31
31
  const M = '\x1b[35m';
32
+ const Y = '\x1b[33m';
32
33
  const D = '\x1b[90m';
33
34
  const BD = '\x1b[1m';
34
35
  const RST = '\x1b[0m';
@@ -73,7 +74,15 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
73
74
  const recentLesson = getRecentLesson();
74
75
  const stats = getLessonStats();
75
76
 
76
- return { feedbackResult, distillResult, recentLesson, stats };
77
+ // 4. If the user wrote an explicit "never …" / "always …" directive, surface
78
+ // an OFFER (never auto-act): "never" on a thumbs-down → offer force-gate now.
79
+ let forceGateHint = null;
80
+ try {
81
+ const { suggestForceGate } = require('./imperative-detector');
82
+ forceGateHint = suggestForceGate({ signal, text: whatWentWrong || whatWorked || context });
83
+ } catch { /* detector optional; never block capture on it */ }
84
+
85
+ return { feedbackResult, distillResult, recentLesson, stats, forceGateHint };
77
86
  }
78
87
 
79
88
  /**
@@ -100,6 +109,13 @@ function formatCliOutput(result) {
100
109
  lines.push(`${R}Feedback not accepted: ${(result.feedbackResult && result.feedbackResult.reason) || 'unknown'}${RST}`);
101
110
  }
102
111
 
112
+ // Explicit-directive offer (e.g. "never …" → offer immediate force-gate).
113
+ if (result.forceGateHint && result.forceGateHint.message) {
114
+ const color = result.forceGateHint.kind === 'force-gate-offer' ? Y : D;
115
+ lines.push('');
116
+ lines.push(`${color}💡 ${result.forceGateHint.message}${RST}`);
117
+ }
118
+
103
119
  // Distilled lesson (if thumbs down)
104
120
  if (result.distillResult) {
105
121
  lines.push('');
@@ -67,11 +67,11 @@ function buildCaptureReceipt({ signal, feedbackId, memoryId, actionType } = {})
67
67
  memoryId ? ` Local memory : ${memoryId}` : ' Local memory : saved locally',
68
68
  actionType ? ` Rule pressure : ${actionType}` : ' Rule pressure : available for promotion',
69
69
  ' Free today : this proof protects this local machine',
70
- ' Pro sync : keep this lesson, rule, and dashboard synced across machines and agent runtimes',
70
+ ' Pro recall : keep this lesson searchable, exportable, and visible',
71
71
  ' Next proof : npx thumbgate stats',
72
72
  ' Cost proof : npx thumbgate cost',
73
73
  '',
74
- ` Solo Pro : ${PRO_PRICE_LABEL} for hosted sync, search, dashboard, and exports`,
74
+ ` Solo Pro : ${PRO_PRICE_LABEL} for recall, dashboard, adapters, and exports`,
75
75
  ` Upgrade : ${trackedProUrl('cli_capture_receipt', actionType || normalizedSignal.toLowerCase())}`,
76
76
  ` Enterprise : ${ENTERPRISE_PRICE_LABEL}; start with one repeated workflow failure`,
77
77
  ' https://thumbgate.ai/#workflow-sprint-intake',
@@ -105,7 +105,7 @@ function buildStatsReceipt(stats = {}) {
105
105
  lines.push(` Failure pressure : ${negatives} negative ${pluralize(negatives, 'signal')}`);
106
106
  }
107
107
  lines.push(' Show the buyer : npx thumbgate cost');
108
- lines.push(' Pro sync value : keep these lessons/rules visible across laptops, CI, containers, and agent runtimes');
108
+ lines.push(' Pro recall value : keep lessons/rules searchable, exportable, and visible');
109
109
  lines.push(` Solo Pro : ${trackedProUrl('cli_stats_receipt', 'proof_seen')}`);
110
110
  lines.push(' Enterprise : https://thumbgate.ai/#workflow-sprint-intake');
111
111
  lines.push('');