thumbgate 1.29.2 → 1.30.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +78 -7
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +7 -5
- package/config/mcp-allowlists.json +26 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/package.json +38 -7
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +21 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/loop.svg +34 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +4 -3
- package/public/numbers.html +2 -2
- package/public/whitepaper.html +189 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/cross-encoder-reranker.js +3 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +244 -182
- package/scripts/gates-engine.js +81 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-retrieval.js +71 -4
- package/scripts/lesson-search.js +26 -3
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/published-cli.js +11 -1
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +49 -1
- package/src/api/server.js +230 -86
package/scripts/gates-engine.js
CHANGED
|
@@ -146,10 +146,18 @@ const SELF_PROTECT_HARD_FLOOR_GATE_IDS = new Set([
|
|
|
146
146
|
'self-protect-env-override',
|
|
147
147
|
'self-protect-hooks-disable',
|
|
148
148
|
]);
|
|
149
|
+
// An expired task-scope lease gets its OWN gate id so it can be exempted from the two downgrade
|
|
150
|
+
// paths without touching ordinary task-scope denials. Without this the fail-closed guarantee is
|
|
151
|
+
// cosmetic: applyEnforcementPosture turns denials into warnings by default, and applyDailyBlockCap
|
|
152
|
+
// does the same for capped free-tier users — so an edit under a lapsed lease would execute anyway.
|
|
153
|
+
// A lease that stops binding when you are busy or over quota is not a lease.
|
|
154
|
+
const TASK_SCOPE_LEASE_EXPIRED_GATE_ID = 'task-scope-lease-expired';
|
|
155
|
+
|
|
149
156
|
const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
|
|
150
157
|
'secret-exfiltration',
|
|
151
158
|
'security-vuln-scan',
|
|
152
159
|
'slopsquat-guard',
|
|
160
|
+
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
153
161
|
...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
|
|
154
162
|
]);
|
|
155
163
|
// Issue #2782 (reported by Andy Martin, 2026-07-08): after the free-tier daily
|
|
@@ -161,6 +169,7 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
|
|
|
161
169
|
// map directly to CLAUDE.md's own hard-block list and must never be subject
|
|
162
170
|
// to the daily cap discount, regardless of tier or strict-mode setting.
|
|
163
171
|
const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
|
|
172
|
+
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
164
173
|
'force-push',
|
|
165
174
|
'git-reset-hard',
|
|
166
175
|
'git-clean-force',
|
|
@@ -425,6 +434,25 @@ function clampTtlMs(value, fallbackMs) {
|
|
|
425
434
|
return Math.min(Math.max(numeric, 60 * 1000), 24 * 60 * 60 * 1000);
|
|
426
435
|
}
|
|
427
436
|
|
|
437
|
+
// Default lease when a caller asks for one without saying how long. clampTtlMs floors at 60s.
|
|
438
|
+
const TASK_SCOPE_LEASE_MS = 15 * 60 * 1000;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* A task scope with no `expiresAt` is permanent — that is the historical contract and every
|
|
442
|
+
* existing scope on disk has it. Only a scope that explicitly took a lease can expire.
|
|
443
|
+
*/
|
|
444
|
+
function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
|
|
445
|
+
if (!taskScope || typeof taskScope !== 'object') return false;
|
|
446
|
+
// `expiresAt: null` means permanent, and it MUST be checked before the numeric coercion:
|
|
447
|
+
// Number(null) is 0, not NaN, so a null deadline would otherwise read as "expired in 1970".
|
|
448
|
+
// Combined with fail-closed enforcement that would revoke authority from every permanent
|
|
449
|
+
// scope the moment this shipped. Caught by tests/task-scope-lease.test.js.
|
|
450
|
+
if (taskScope.expiresAt == null) return false;
|
|
451
|
+
const deadline = Number(taskScope.expiresAt);
|
|
452
|
+
if (!Number.isFinite(deadline)) return false;
|
|
453
|
+
return nowMs >= deadline;
|
|
454
|
+
}
|
|
455
|
+
|
|
428
456
|
function loadGovernanceState() {
|
|
429
457
|
const raw = loadJSON(module.exports.GOVERNANCE_STATE_PATH);
|
|
430
458
|
const state = {
|
|
@@ -438,6 +466,12 @@ function loadGovernanceState() {
|
|
|
438
466
|
: null,
|
|
439
467
|
};
|
|
440
468
|
const now = Date.now();
|
|
469
|
+
// Annotate rather than delete. A vanished scope is indistinguishable from one never set, and
|
|
470
|
+
// the difference matters: "your lease lapsed, renew it" is a different instruction from
|
|
471
|
+
// "you never declared a scope".
|
|
472
|
+
if (state.taskScope) {
|
|
473
|
+
state.taskScope = { ...state.taskScope, expired: isTaskScopeExpired(state.taskScope, now) };
|
|
474
|
+
}
|
|
441
475
|
const activeApprovals = state.protectedApprovals.filter((entry) => {
|
|
442
476
|
if (!entry || typeof entry !== 'object') return false;
|
|
443
477
|
if (!entry.timestamp || !entry.expiresAt) return false;
|
|
@@ -485,6 +519,11 @@ function setTaskScope(scopeInput = {}) {
|
|
|
485
519
|
? scopeInput.protectedPaths
|
|
486
520
|
: DEFAULT_PROTECTED_FILE_GLOBS
|
|
487
521
|
), repoPath);
|
|
522
|
+
// Optional LEASE. Without ttlMs the scope is permanent, which is the historical behaviour
|
|
523
|
+
// and stays byte-identical. With ttlMs the scope becomes time-bounded authority: "write under
|
|
524
|
+
// ./src for 90 seconds" rather than a standing approval that never says when it stops.
|
|
525
|
+
const scopeNow = Date.now();
|
|
526
|
+
const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
|
|
488
527
|
const taskScope = {
|
|
489
528
|
taskId: String(scopeInput.taskId || '').trim() || null,
|
|
490
529
|
summary: String(scopeInput.summary || '').trim() || null,
|
|
@@ -493,7 +532,9 @@ function setTaskScope(scopeInput = {}) {
|
|
|
493
532
|
localOnly: scopeInput.localOnly === true,
|
|
494
533
|
repoPath,
|
|
495
534
|
createdAt: new Date().toISOString(),
|
|
496
|
-
timestamp:
|
|
535
|
+
timestamp: scopeNow,
|
|
536
|
+
leaseMs,
|
|
537
|
+
expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
|
|
497
538
|
};
|
|
498
539
|
const state = loadGovernanceState();
|
|
499
540
|
state.taskScope = taskScope;
|
|
@@ -1921,8 +1962,23 @@ function formatFileList(files, limit = 5) {
|
|
|
1921
1962
|
return `${items.slice(0, limit).join(', ')} (+${items.length - limit} more)`;
|
|
1922
1963
|
}
|
|
1923
1964
|
|
|
1924
|
-
function buildTaskScopeViolation(taskScope, affectedFiles) {
|
|
1965
|
+
function buildTaskScopeViolation(taskScope, affectedFiles, nowMs = Date.now()) {
|
|
1925
1966
|
if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return null;
|
|
1967
|
+
// EXPIRY FAILS CLOSED, and that direction is the whole point.
|
|
1968
|
+
//
|
|
1969
|
+
// A task scope is a restriction, so simply dropping it on expiry would make the agent MORE
|
|
1970
|
+
// powerful the moment its lease ran out — expiry would remove a boundary instead of removing
|
|
1971
|
+
// authority. A lease has to mean the opposite: while it is live you may work in these paths,
|
|
1972
|
+
// and when it lapses the authority is gone until it is renewed.
|
|
1973
|
+
if (taskScope && isTaskScopeExpired(taskScope, nowMs)) {
|
|
1974
|
+
return {
|
|
1975
|
+
reasonCode: 'expired_task_scope',
|
|
1976
|
+
outsideFiles: affectedFiles.slice(),
|
|
1977
|
+
allowedPaths: Array.isArray(taskScope.allowedPaths) ? taskScope.allowedPaths.slice() : [],
|
|
1978
|
+
summary: taskScope.summary || null,
|
|
1979
|
+
expiresAt: taskScope.expiresAt || null,
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1926
1982
|
if (!taskScope || !Array.isArray(taskScope.allowedPaths) || taskScope.allowedPaths.length === 0) {
|
|
1927
1983
|
return {
|
|
1928
1984
|
reasonCode: 'missing_task_scope',
|
|
@@ -1995,6 +2051,11 @@ function buildBranchGovernanceViolation(governanceState, toolInput = {}, affecte
|
|
|
1995
2051
|
function buildGateMessage(gate, matchDetails) {
|
|
1996
2052
|
if (matchDetails && matchDetails.taskScopeViolation) {
|
|
1997
2053
|
const violation = matchDetails.taskScopeViolation;
|
|
2054
|
+
if (violation.reasonCode === 'expired_task_scope') {
|
|
2055
|
+
const lapsed = violation.expiresAt ? new Date(violation.expiresAt).toISOString() : 'unknown time';
|
|
2056
|
+
return `The task-scope lease expired at ${lapsed}, so its authority no longer applies. `
|
|
2057
|
+
+ `Renew it with set_task_scope (allowed paths were: ${formatFileList(violation.allowedPaths)}).`;
|
|
2058
|
+
}
|
|
1998
2059
|
if (violation.reasonCode === 'missing_task_scope') {
|
|
1999
2060
|
return `No task scope is declared for this high-risk action. Affected files: ${formatFileList(violation.outsideFiles)}.`;
|
|
2000
2061
|
}
|
|
@@ -2760,7 +2821,13 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2760
2821
|
});
|
|
2761
2822
|
|
|
2762
2823
|
if (gate.action === 'block') {
|
|
2763
|
-
|
|
2824
|
+
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
2825
|
+
// the daily block cap can quietly turn this denial into a warning.
|
|
2826
|
+
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
2827
|
+
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
2828
|
+
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
2829
|
+
: gate.id;
|
|
2830
|
+
const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
|
|
2764
2831
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
2765
2832
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
2766
2833
|
if (cappedResult) {
|
|
@@ -2973,7 +3040,13 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
2973
3040
|
const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
|
|
2974
3041
|
|
|
2975
3042
|
if (gate.action === 'block') {
|
|
2976
|
-
|
|
3043
|
+
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
3044
|
+
// the daily block cap can quietly turn this denial into a warning.
|
|
3045
|
+
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
3046
|
+
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
3047
|
+
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
3048
|
+
: gate.id;
|
|
3049
|
+
const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
|
|
2977
3050
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
2978
3051
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
2979
3052
|
if (cappedResult) {
|
|
@@ -3968,6 +4041,10 @@ module.exports = {
|
|
|
3968
4041
|
loadGovernanceState,
|
|
3969
4042
|
saveGovernanceState,
|
|
3970
4043
|
setTaskScope,
|
|
4044
|
+
isTaskScopeExpired,
|
|
4045
|
+
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
4046
|
+
applyEnforcementPosture,
|
|
4047
|
+
buildTaskScopeViolation,
|
|
3971
4048
|
setBranchGovernance,
|
|
3972
4049
|
approveProtectedAction,
|
|
3973
4050
|
breakGlassEmergency,
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* generate-case-study-outreach.js — buyer outreach pack from dogfood case studies.
|
|
6
|
+
*
|
|
7
|
+
* No fabricated customer logos. Packs are built from public case-study anchors
|
|
8
|
+
* with first-party UTMs for the cash path.
|
|
9
|
+
*
|
|
10
|
+
* node scripts/generate-case-study-outreach.js --case=sudo-evasion
|
|
11
|
+
* node scripts/generate-case-study-outreach.js --case=sudo-evasion --json
|
|
12
|
+
* node scripts/generate-case-study-outreach.js --case=sudo-evasion --write
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
19
|
+
const DEFAULT_OUT_DIR = path.join(PROJECT_ROOT, 'docs', 'proof', 'outreach');
|
|
20
|
+
|
|
21
|
+
const CASES = Object.freeze({
|
|
22
|
+
'sudo-evasion': {
|
|
23
|
+
id: 'sudo-evasion',
|
|
24
|
+
title: 'A guardrail you could walk past with sudo',
|
|
25
|
+
anchor: 'sudo-evasion',
|
|
26
|
+
problem: 'Catastrophic PreToolUse gates matched the happy path but missed wrappers like `sudo rm -rf ~`.',
|
|
27
|
+
metric: '62 evasion holes → 0 on the published npm artifact',
|
|
28
|
+
result: 'Canonicalization + an adversarial grid (14 commands × 9 transforms) closed the class; CI + 6-hourly published-artifact jobs keep it closed.',
|
|
29
|
+
buyerPain: 'Your coding agent can re-spell a blocked command and walk past a regex denylist.',
|
|
30
|
+
ctaPrimary: 'diagnostic',
|
|
31
|
+
proofLinks: {
|
|
32
|
+
caseStudyPath: '/case-studies#sudo-evasion',
|
|
33
|
+
scorecardPath: '/eval-scorecard',
|
|
34
|
+
whitepaperPath: '/whitepaper',
|
|
35
|
+
diagnosticPath: '/diagnostic',
|
|
36
|
+
proPath: '/checkout/pro',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
'fail-open': {
|
|
40
|
+
id: 'fail-open',
|
|
41
|
+
title: 'Production failure: a firewall enforcing nothing',
|
|
42
|
+
anchor: 'fail-open',
|
|
43
|
+
problem: 'A missing PreToolUse hook binary fails open — the product looked fine while blocking nothing.',
|
|
44
|
+
metric: 'Silent-gate canary + published-artifact deny checks',
|
|
45
|
+
result: 'Enforcement restored and verified with known-dangerous commands; silence is now treated as a P0 class.',
|
|
46
|
+
buyerPain: 'Green uptime does not mean your agent firewall is still firing.',
|
|
47
|
+
ctaPrimary: 'diagnostic',
|
|
48
|
+
proofLinks: {
|
|
49
|
+
caseStudyPath: '/case-studies#fail-open',
|
|
50
|
+
scorecardPath: '/eval-scorecard',
|
|
51
|
+
whitepaperPath: '/whitepaper',
|
|
52
|
+
diagnosticPath: '/diagnostic',
|
|
53
|
+
proPath: '/checkout/pro',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
59
|
+
const args = {
|
|
60
|
+
caseId: 'sudo-evasion',
|
|
61
|
+
json: false,
|
|
62
|
+
write: false,
|
|
63
|
+
help: false,
|
|
64
|
+
outDir: DEFAULT_OUT_DIR,
|
|
65
|
+
baseUrl: 'https://thumbgate.ai',
|
|
66
|
+
};
|
|
67
|
+
for (const arg of argv) {
|
|
68
|
+
if (arg === '--json') args.json = true;
|
|
69
|
+
else if (arg === '--write') args.write = true;
|
|
70
|
+
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
71
|
+
else if (arg.startsWith('--case=')) args.caseId = arg.slice('--case='.length);
|
|
72
|
+
else if (arg.startsWith('--out-dir=')) args.outDir = path.resolve(arg.slice('--out-dir='.length));
|
|
73
|
+
else if (arg.startsWith('--base-url=')) args.baseUrl = arg.slice('--base-url='.length).replace(/\/$/, '');
|
|
74
|
+
}
|
|
75
|
+
return args;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printHelp() {
|
|
79
|
+
console.log(`Usage: node scripts/generate-case-study-outreach.js --case=<id> [--write] [--json]
|
|
80
|
+
|
|
81
|
+
Cases: ${Object.keys(CASES).join(', ')}
|
|
82
|
+
`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withUtm(baseUrl, pathAndHash, campaign, content) {
|
|
86
|
+
const [pathname, hash = ''] = pathAndHash.split('#');
|
|
87
|
+
const url = new URL(pathname, baseUrl);
|
|
88
|
+
url.searchParams.set('utm_source', 'case_study_outreach');
|
|
89
|
+
url.searchParams.set('utm_medium', content);
|
|
90
|
+
url.searchParams.set('utm_campaign', campaign);
|
|
91
|
+
url.searchParams.set('cta_id', `${campaign}_${content}`);
|
|
92
|
+
const hashPart = hash ? `#${hash}` : '';
|
|
93
|
+
return `${url.toString()}${hashPart}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildPack(caseDef, options = {}) {
|
|
97
|
+
const baseUrl = options.baseUrl || 'https://thumbgate.ai';
|
|
98
|
+
const campaign = `case_${caseDef.id.replace(/-/g, '_')}`;
|
|
99
|
+
const links = {
|
|
100
|
+
caseStudy: withUtm(baseUrl, caseDef.proofLinks.caseStudyPath, campaign, 'case_study'),
|
|
101
|
+
scorecard: withUtm(baseUrl, caseDef.proofLinks.scorecardPath, campaign, 'scorecard'),
|
|
102
|
+
whitepaper: withUtm(baseUrl, caseDef.proofLinks.whitepaperPath, campaign, 'whitepaper'),
|
|
103
|
+
diagnostic: withUtm(baseUrl, caseDef.proofLinks.diagnosticPath, campaign, 'diagnostic'),
|
|
104
|
+
pro: withUtm(baseUrl, `${caseDef.proofLinks.proPath}`, campaign, 'pro'),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const linkedin = [
|
|
108
|
+
caseDef.buyerPain,
|
|
109
|
+
'',
|
|
110
|
+
`We dogfooded this on ThumbGate itself: ${caseDef.metric}.`,
|
|
111
|
+
caseDef.result,
|
|
112
|
+
'',
|
|
113
|
+
`Full write-up (no fabricated logos): ${links.caseStudy}`,
|
|
114
|
+
`Live bench scorecard: ${links.scorecard}`,
|
|
115
|
+
'',
|
|
116
|
+
`If one repeated AI-agent failure is already costing you, the $499 Diagnostic installs one hard gate with regression proof: ${links.diagnostic}`,
|
|
117
|
+
].join('\n');
|
|
118
|
+
|
|
119
|
+
const email = [
|
|
120
|
+
`Subject: Your agent can walk past a regex denylist`,
|
|
121
|
+
'',
|
|
122
|
+
`Hi —`,
|
|
123
|
+
'',
|
|
124
|
+
caseDef.buyerPain,
|
|
125
|
+
'',
|
|
126
|
+
`Concrete proof from our own product loop (not a customer logo page):`,
|
|
127
|
+
`- ${caseDef.metric}`,
|
|
128
|
+
`- ${caseDef.result}`,
|
|
129
|
+
'',
|
|
130
|
+
`Case study: ${links.caseStudy}`,
|
|
131
|
+
`Scorecard: ${links.scorecard}`,
|
|
132
|
+
`White paper: ${links.whitepaper}`,
|
|
133
|
+
'',
|
|
134
|
+
`If you want this on one painful workflow this week: ${links.diagnostic}`,
|
|
135
|
+
`Self-serve Pro: ${links.pro}`,
|
|
136
|
+
'',
|
|
137
|
+
`— Igor`,
|
|
138
|
+
].join('\n');
|
|
139
|
+
|
|
140
|
+
const reddit = [
|
|
141
|
+
`**Problem:** ${caseDef.problem}`,
|
|
142
|
+
'',
|
|
143
|
+
`**What we measured:** ${caseDef.metric}`,
|
|
144
|
+
'',
|
|
145
|
+
`**What fixed it:** ${caseDef.result}`,
|
|
146
|
+
'',
|
|
147
|
+
`Public case study (dogfood, not a fake logo wall): ${links.caseStudy}`,
|
|
148
|
+
`Bench scorecard: ${links.scorecard}`,
|
|
149
|
+
].join('\n');
|
|
150
|
+
|
|
151
|
+
const markdown = [
|
|
152
|
+
`# Outreach pack — ${caseDef.title}`,
|
|
153
|
+
'',
|
|
154
|
+
`Case id: \`${caseDef.id}\``,
|
|
155
|
+
'',
|
|
156
|
+
'## Links (tracked)',
|
|
157
|
+
'',
|
|
158
|
+
`- Case study: ${links.caseStudy}`,
|
|
159
|
+
`- Scorecard: ${links.scorecard}`,
|
|
160
|
+
`- White paper: ${links.whitepaper}`,
|
|
161
|
+
`- Diagnostic $499: ${links.diagnostic}`,
|
|
162
|
+
`- Pro: ${links.pro}`,
|
|
163
|
+
'',
|
|
164
|
+
'## LinkedIn',
|
|
165
|
+
'',
|
|
166
|
+
linkedin,
|
|
167
|
+
'',
|
|
168
|
+
'## Email',
|
|
169
|
+
'',
|
|
170
|
+
'```',
|
|
171
|
+
email,
|
|
172
|
+
'```',
|
|
173
|
+
'',
|
|
174
|
+
'## Reddit / forum',
|
|
175
|
+
'',
|
|
176
|
+
reddit,
|
|
177
|
+
'',
|
|
178
|
+
'## Honesty',
|
|
179
|
+
'',
|
|
180
|
+
'First-party dogfood narrative only. Do not imply third-party customer endorsement.',
|
|
181
|
+
'',
|
|
182
|
+
].join('\n');
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
caseId: caseDef.id,
|
|
186
|
+
title: caseDef.title,
|
|
187
|
+
links,
|
|
188
|
+
channels: {
|
|
189
|
+
linkedin,
|
|
190
|
+
email,
|
|
191
|
+
reddit,
|
|
192
|
+
},
|
|
193
|
+
markdown,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function generate(options = {}) {
|
|
198
|
+
const caseId = options.caseId || 'sudo-evasion';
|
|
199
|
+
const caseDef = CASES[caseId];
|
|
200
|
+
if (!caseDef) {
|
|
201
|
+
throw new Error(`Unknown case id: ${caseId}. Known: ${Object.keys(CASES).join(', ')}`);
|
|
202
|
+
}
|
|
203
|
+
const pack = buildPack(caseDef, { baseUrl: options.baseUrl });
|
|
204
|
+
let outPath = null;
|
|
205
|
+
if (options.write) {
|
|
206
|
+
const outDir = options.outDir || DEFAULT_OUT_DIR;
|
|
207
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
208
|
+
outPath = path.join(outDir, `case-study-outreach-${caseId}.md`);
|
|
209
|
+
fs.writeFileSync(outPath, pack.markdown, 'utf8');
|
|
210
|
+
}
|
|
211
|
+
return { ...pack, outPath };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function main(argv = process.argv.slice(2)) {
|
|
215
|
+
const args = parseArgs(argv);
|
|
216
|
+
if (args.help) {
|
|
217
|
+
printHelp();
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
const result = generate(args);
|
|
221
|
+
if (args.json) {
|
|
222
|
+
console.log(JSON.stringify({
|
|
223
|
+
caseId: result.caseId,
|
|
224
|
+
title: result.title,
|
|
225
|
+
links: result.links,
|
|
226
|
+
channels: result.channels,
|
|
227
|
+
outPath: result.outPath,
|
|
228
|
+
}, null, 2));
|
|
229
|
+
} else if (result.outPath) {
|
|
230
|
+
console.log(`Wrote ${result.outPath}`);
|
|
231
|
+
} else {
|
|
232
|
+
console.log(result.markdown);
|
|
233
|
+
}
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
|
|
238
|
+
try {
|
|
239
|
+
process.exitCode = main();
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(err.message || err);
|
|
242
|
+
process.exitCode = 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
CASES,
|
|
248
|
+
parseArgs,
|
|
249
|
+
withUtm,
|
|
250
|
+
buildPack,
|
|
251
|
+
generate,
|
|
252
|
+
main,
|
|
253
|
+
};
|