@mcptoolshop/promo-kit 0.1.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.
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Promo Decision Engine
5
+ *
6
+ * Deterministic scoring algorithm that evaluates promo-queue candidates
7
+ * across four dimensions (proof, engagement, freshness, worthiness) and
8
+ * applies budget constraints + experiment analysis to produce promote /
9
+ * skip / defer decisions.
10
+ *
11
+ * Usage:
12
+ * node scripts/gen-promo-decisions.mjs [--dry-run]
13
+ *
14
+ * Reads:
15
+ * site/src/data/ops-history.json
16
+ * site/src/data/feedback-summary.json
17
+ * site/src/data/experiments.json
18
+ * site/src/data/worthy.json
19
+ * site/src/data/promo-queue.json
20
+ * site/src/data/baseline.json
21
+ * site/src/data/overrides.json
22
+ * site/src/data/governance.json
23
+ *
24
+ * Writes:
25
+ * site/src/data/promo-decisions.json
26
+ * site/public/lab/decisions/promo-decisions.md
27
+ */
28
+
29
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
30
+ import { resolve, join } from "node:path";
31
+ import { getConfig, getRoot } from "./lib/config.mjs";
32
+
33
+ const ROOT = getRoot();
34
+ const config = getConfig();
35
+ const DATA_DIR = join(ROOT, config.paths.dataDir);
36
+ const DECISIONS_DIR = join(ROOT, config.paths.publicDir, "lab", "decisions");
37
+
38
+ // ── Helpers ─────────────────────────────────────────────────
39
+
40
+ function safeParseJson(filePath, fallback = null) {
41
+ try {
42
+ return JSON.parse(readFileSync(filePath, "utf8"));
43
+ } catch {
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ // ── Scoring helpers ─────────────────────────────────────────
49
+
50
+ /**
51
+ * Proof score (0-30): +15 if publicProof exists, +3 per proven claim (max 5).
52
+ */
53
+ function computeProofScore(slug, overrides) {
54
+ const entry = overrides[slug] || {};
55
+ let score = 0;
56
+ const parts = [];
57
+
58
+ if (entry.publicProof) {
59
+ score += 15;
60
+ parts.push("+15 publicProof");
61
+ }
62
+
63
+ const claimsCount = Array.isArray(entry.provenClaims)
64
+ ? Math.min(entry.provenClaims.length, 5)
65
+ : 0;
66
+ const claimsPoints = claimsCount * 3;
67
+ score += claimsPoints;
68
+
69
+ if (claimsCount > 0) {
70
+ parts.push(`proven claims: ${claimsCount} -> +${claimsPoints}`);
71
+ }
72
+
73
+ return {
74
+ score,
75
+ explanation: `publicProof: ${parts.length > 0 ? parts.join(", ") : "+0"} (total proof: ${score})`,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Engagement score (0-30): reply rate mapped to 30-point scale.
81
+ */
82
+ function computeEngagementScore(slug, feedbackSummary) {
83
+ const slugData = feedbackSummary?.perSlug?.[slug];
84
+ if (!slugData) {
85
+ return { score: 0, explanation: "engagement: no data -> +0" };
86
+ }
87
+
88
+ const sent = slugData.sent || 0;
89
+ const opened = slugData.opened || 0;
90
+ const replied = slugData.replied || 0;
91
+ const ignored = slugData.ignored || 0;
92
+ const bounced = slugData.bounced || 0;
93
+ const total = sent + opened + replied + ignored + bounced;
94
+
95
+ const replyRate = total > 0 ? replied / total : 0;
96
+ const score = Math.round(replyRate * 30);
97
+
98
+ return {
99
+ score,
100
+ explanation: `engagement: replyRate ${replyRate.toFixed(2)} -> +${score}`,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Freshness score (0-20): full 20 if beyond cooldown or no history,
106
+ * 0 if within cooldown (also flags as "defer").
107
+ */
108
+ function computeFreshnessScore(slug, opsHistory, cooldownDays) {
109
+ const now = Date.now();
110
+
111
+ // Find most recent promotion for this slug in ops history
112
+ let lastPromoDate = null;
113
+ for (const entry of opsHistory) {
114
+ const promoted = entry.promotedSlugs || entry.slugs || [];
115
+ const slugList = Array.isArray(promoted) ? promoted : [];
116
+ if (slugList.includes(slug)) {
117
+ lastPromoDate = entry.date || null;
118
+ break; // history is newest-first
119
+ }
120
+ }
121
+
122
+ if (!lastPromoDate) {
123
+ return {
124
+ score: 20,
125
+ defer: false,
126
+ explanation: `freshness: no prior promotion -> +20`,
127
+ };
128
+ }
129
+
130
+ const daysSince = Math.floor(
131
+ (now - new Date(lastPromoDate).getTime()) / (1000 * 60 * 60 * 24)
132
+ );
133
+
134
+ if (daysSince < cooldownDays) {
135
+ return {
136
+ score: 0,
137
+ defer: true,
138
+ explanation: `DEFER: within cooldown (promoted ${daysSince}d ago, cooldown ${cooldownDays}d)`,
139
+ };
140
+ }
141
+
142
+ return {
143
+ score: 20,
144
+ defer: false,
145
+ explanation: `freshness: last promoted ${daysSince}d ago (cooldown ${cooldownDays}d) -> +20`,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Worthy score (0-20): +20 if worthy.repos[slug].worthy === true.
151
+ */
152
+ function computeWorthyScore(slug, worthy) {
153
+ const isWorthy = worthy?.repos?.[slug]?.worthy === true;
154
+ const worthyEntry = worthy?.repos?.[slug];
155
+ const worthyScoreVal = worthyEntry?.score ?? 0;
156
+
157
+ return {
158
+ score: isWorthy ? 20 : 0,
159
+ explanation: `worthy: score ${worthyScoreVal} -> ${isWorthy ? "+20" : "+0"}`,
160
+ };
161
+ }
162
+
163
+ // ── Core ────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Build promotion decisions from all input data.
167
+ *
168
+ * @param {object} inputs
169
+ * @param {object} inputs.promoQueue - promo-queue.json
170
+ * @param {object} inputs.promo - promo.json
171
+ * @param {object} inputs.overrides - overrides.json
172
+ * @param {object} inputs.worthy - worthy.json
173
+ * @param {object} inputs.feedbackSummary - feedback-summary.json
174
+ * @param {Array} inputs.opsHistory - ops-history.json (newest first)
175
+ * @param {object} inputs.baseline - baseline.json
176
+ * @param {object} inputs.governance - governance.json
177
+ * @param {object} inputs.experiments - experiments.json
178
+ * @returns {{ decisions: Array, budget: object, warnings: string[] }}
179
+ */
180
+ export function buildDecisions(inputs) {
181
+ const {
182
+ promoQueue = {},
183
+ promo = {},
184
+ overrides = {},
185
+ worthy = {},
186
+ feedbackSummary = {},
187
+ opsHistory = [],
188
+ baseline = {},
189
+ governance = {},
190
+ experiments = {},
191
+ } = inputs;
192
+
193
+ const warnings = [];
194
+ const cooldownDays = governance.cooldownDaysPerSlug || 14;
195
+ const maxPromosPerWeek = governance.maxPromosPerWeek || 3;
196
+ const minExpThreshold = governance.minExperimentDataThreshold || 10;
197
+
198
+ // ── Budget logic ────────────────────────────────────────
199
+
200
+ const minuteBudgets = baseline.minuteBudgets || {};
201
+ const tier200 = minuteBudgets["200"] || minuteBudgets[200] || null;
202
+ const avgMinutesPerRun = baseline.avgMinutesPerRun || 0;
203
+
204
+ const budgetTier = 200;
205
+ const budgetHeadroom = tier200 ? tier200.headroom : 200;
206
+
207
+ let itemsAllowed;
208
+ if (avgMinutesPerRun === 0) {
209
+ itemsAllowed = maxPromosPerWeek;
210
+ } else {
211
+ itemsAllowed = Math.min(
212
+ maxPromosPerWeek,
213
+ Math.floor(budgetHeadroom / avgMinutesPerRun)
214
+ );
215
+ }
216
+
217
+ const budget = {
218
+ tier: budgetTier,
219
+ headroom: budgetHeadroom,
220
+ itemsAllowed,
221
+ };
222
+
223
+ if (itemsAllowed === 0 && avgMinutesPerRun > 0) {
224
+ warnings.push(
225
+ `Budget headroom (${budgetHeadroom} min) insufficient for even one run (avg ${avgMinutesPerRun} min/run)`
226
+ );
227
+ }
228
+
229
+ // ── Gather candidates from promo queue ──────────────────
230
+
231
+ const rawSlugs = promoQueue.slugs || [];
232
+ const candidateSlugs = rawSlugs.map((s) =>
233
+ typeof s === "string" ? s : s.slug
234
+ );
235
+
236
+ if (candidateSlugs.length === 0) {
237
+ warnings.push("Promo queue is empty — no candidates to evaluate");
238
+ }
239
+
240
+ // ── Build experiment lookup ─────────────────────────────
241
+
242
+ const activeExperiments = {};
243
+ for (const exp of experiments.experiments || []) {
244
+ if (exp.status === "active" && exp.slug) {
245
+ activeExperiments[exp.slug] = activeExperiments[exp.slug] || [];
246
+ activeExperiments[exp.slug].push(exp);
247
+ }
248
+ }
249
+
250
+ // ── Score each candidate ────────────────────────────────
251
+
252
+ const scored = [];
253
+
254
+ for (const slug of candidateSlugs) {
255
+ const explanation = [];
256
+
257
+ const proof = computeProofScore(slug, overrides);
258
+ explanation.push(proof.explanation);
259
+
260
+ const engagement = computeEngagementScore(slug, feedbackSummary);
261
+ explanation.push(engagement.explanation);
262
+
263
+ const freshness = computeFreshnessScore(slug, opsHistory, cooldownDays);
264
+ explanation.push(freshness.explanation);
265
+
266
+ const worthiness = computeWorthyScore(slug, worthy);
267
+ explanation.push(worthiness.explanation);
268
+
269
+ const totalScore =
270
+ proof.score + engagement.score + freshness.score + worthiness.score;
271
+
272
+ // Experiment analysis (inline)
273
+ const slugExperiments = activeExperiments[slug] || [];
274
+ for (const exp of slugExperiments) {
275
+ const expData = feedbackSummary?.perExperiment?.[exp.id];
276
+ if (!expData) {
277
+ explanation.push(
278
+ `experiment ${exp.id}: no feedback data available`
279
+ );
280
+ continue;
281
+ }
282
+
283
+ const arms = Object.entries(expData);
284
+ const allAboveThreshold =
285
+ arms.length >= 2 &&
286
+ arms.every(([, stats]) => (stats.entries || 0) >= minExpThreshold);
287
+
288
+ if (!allAboveThreshold) {
289
+ explanation.push(
290
+ `experiment ${exp.id}: insufficient data (need >=${minExpThreshold} per arm)`
291
+ );
292
+ continue;
293
+ }
294
+
295
+ // Find best and second-best arm by reply rate or entries
296
+ const armScores = arms
297
+ .map(([key, stats]) => ({
298
+ key,
299
+ entries: stats.entries || 0,
300
+ replied: stats.replied || 0,
301
+ rate: (stats.entries || 0) > 0
302
+ ? (stats.replied || 0) / (stats.entries || 0)
303
+ : 0,
304
+ }))
305
+ .sort((a, b) => b.rate - a.rate);
306
+
307
+ const best = armScores[0];
308
+ const second = armScores[1];
309
+
310
+ if (second && second.rate > 0) {
311
+ const ratio = Math.round((best.rate / second.rate) * 100) / 100;
312
+ if (ratio > 2) {
313
+ explanation.push(
314
+ `experiment ${exp.id}: variant ${best.key} outperforms at ${ratio}x`
315
+ );
316
+ } else {
317
+ explanation.push(
318
+ `experiment ${exp.id}: no clear winner (best ${best.key} at ${ratio}x, needs >2x)`
319
+ );
320
+ }
321
+ } else if (best) {
322
+ explanation.push(
323
+ `experiment ${exp.id}: only variant ${best.key} has replies — no comparison possible`
324
+ );
325
+ }
326
+ }
327
+
328
+ scored.push({
329
+ slug,
330
+ score: totalScore,
331
+ defer: freshness.defer,
332
+ explanation,
333
+ });
334
+ }
335
+
336
+ // ── Sort and assign actions ─────────────────────────────
337
+
338
+ scored.sort((a, b) => b.score - a.score);
339
+
340
+ let promotedCount = 0;
341
+ const decisions = scored.map((candidate) => {
342
+ let action;
343
+ if (candidate.defer) {
344
+ action = "defer";
345
+ } else if (promotedCount < itemsAllowed) {
346
+ action = "promote";
347
+ promotedCount++;
348
+ } else {
349
+ action = "skip";
350
+ }
351
+
352
+ return {
353
+ slug: candidate.slug,
354
+ action,
355
+ score: candidate.score,
356
+ explanation: candidate.explanation,
357
+ };
358
+ });
359
+
360
+ return { decisions, budget, warnings };
361
+ }
362
+
363
+ // ── Markdown generator ──────────────────────────────────────
364
+
365
+ /**
366
+ * Build a markdown summary of decisions.
367
+ *
368
+ * @param {{ decisions: Array, budget: object, warnings: string[] }} result
369
+ * @returns {string}
370
+ */
371
+ function generateDecisionsMd(result) {
372
+ const { decisions, budget, warnings } = result;
373
+ const lines = [];
374
+
375
+ lines.push("# Promo Decisions");
376
+ lines.push("");
377
+ lines.push(`*Generated: ${new Date().toISOString().slice(0, 10)}*`);
378
+ lines.push("");
379
+
380
+ // Decision table
381
+ if (decisions.length > 0) {
382
+ lines.push("## Decisions");
383
+ lines.push("");
384
+ lines.push("| Slug | Action | Score | Top Reason |");
385
+ lines.push("|------|--------|-------|------------|");
386
+ for (const d of decisions) {
387
+ const topReason = d.explanation[0] || "-";
388
+ lines.push(`| ${d.slug} | ${d.action} | ${d.score} | ${topReason} |`);
389
+ }
390
+ lines.push("");
391
+ } else {
392
+ lines.push("## Decisions");
393
+ lines.push("");
394
+ lines.push("No candidates in queue.");
395
+ lines.push("");
396
+ }
397
+
398
+ // Budget summary
399
+ lines.push("## Budget");
400
+ lines.push("");
401
+ lines.push(`- **Tier:** ${budget.tier} min/month`);
402
+ lines.push(`- **Headroom:** ${budget.headroom} min`);
403
+ lines.push(`- **Items allowed this cycle:** ${budget.itemsAllowed}`);
404
+ lines.push("");
405
+
406
+ // Warnings
407
+ if (warnings.length > 0) {
408
+ lines.push("## Warnings");
409
+ lines.push("");
410
+ for (const w of warnings) {
411
+ lines.push(`- ${w}`);
412
+ }
413
+ lines.push("");
414
+ }
415
+
416
+ return lines.join("\n");
417
+ }
418
+
419
+ // ── Pipeline ────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Full pipeline: load data, build decisions, write outputs.
423
+ *
424
+ * @param {{ dataDir?: string, decisionsDir?: string, dryRun?: boolean }} opts
425
+ * @returns {{ decisionCount: number, outputPath: string }}
426
+ */
427
+ export function generatePromoDecisions(opts = {}) {
428
+ const {
429
+ dataDir = DATA_DIR,
430
+ decisionsDir = DECISIONS_DIR,
431
+ dryRun = false,
432
+ } = opts;
433
+
434
+ // Load all inputs
435
+ const promoQueue = safeParseJson(join(dataDir, "promo-queue.json"), {});
436
+ const promo = safeParseJson(join(dataDir, "promo.json"), {});
437
+ const overrides = safeParseJson(join(dataDir, "overrides.json"), {});
438
+ const worthy = safeParseJson(join(dataDir, "worthy.json"), {});
439
+ const feedbackSummary = safeParseJson(join(dataDir, "feedback-summary.json"), {});
440
+ const opsHistory = safeParseJson(join(dataDir, "ops-history.json"), []);
441
+ const baseline = safeParseJson(join(dataDir, "baseline.json"), {});
442
+ const governance = safeParseJson(join(dataDir, "governance.json"), {});
443
+ const experiments = safeParseJson(join(dataDir, "experiments.json"), {});
444
+
445
+ // Freeze check — preserve existing decisions when frozen
446
+ const outputPath = join(dataDir, "promo-decisions.json");
447
+ if (governance.decisionsFrozen === true) {
448
+ console.log(" Decisions frozen (governance.decisionsFrozen=true). Skipping regeneration.");
449
+ const existing = safeParseJson(outputPath, { decisions: [] });
450
+ return { decisionCount: (existing.decisions || []).length, outputPath, frozen: true };
451
+ }
452
+
453
+ const result = buildDecisions({
454
+ promoQueue,
455
+ promo,
456
+ overrides,
457
+ worthy,
458
+ feedbackSummary,
459
+ opsHistory,
460
+ baseline,
461
+ governance,
462
+ experiments,
463
+ });
464
+
465
+ if (dryRun) {
466
+ console.log(` [dry-run] Would write promo-decisions.json`);
467
+ console.log(` [dry-run] Would write promo-decisions.md`);
468
+ console.log(` [dry-run] Decisions: ${result.decisions.length}`);
469
+ console.log(` [dry-run] Budget: tier=${result.budget.tier}, allowed=${result.budget.itemsAllowed}`);
470
+ if (result.warnings.length > 0) {
471
+ for (const w of result.warnings) {
472
+ console.log(` [dry-run] Warning: ${w}`);
473
+ }
474
+ }
475
+ return { decisionCount: result.decisions.length, outputPath };
476
+ }
477
+
478
+ // Write JSON
479
+ const jsonOut = {
480
+ generatedAt: new Date().toISOString(),
481
+ ...result,
482
+ };
483
+ writeFileSync(outputPath, JSON.stringify(jsonOut, null, 2) + "\n", "utf8");
484
+ console.log(` Wrote promo-decisions.json (${result.decisions.length} decisions)`);
485
+
486
+ // Write markdown
487
+ mkdirSync(decisionsDir, { recursive: true });
488
+ const md = generateDecisionsMd(result);
489
+ writeFileSync(join(decisionsDir, "promo-decisions.md"), md, "utf8");
490
+ console.log(` Wrote promo-decisions.md`);
491
+
492
+ return { decisionCount: result.decisions.length, outputPath };
493
+ }
494
+
495
+ // ── Entry point ─────────────────────────────────────────────
496
+
497
+ const isMain = process.argv[1] &&
498
+ resolve(process.argv[1]).endsWith("gen-promo-decisions.mjs");
499
+
500
+ if (isMain) {
501
+ const dryRun = process.argv.includes("--dry-run");
502
+ console.log("Generating promo decisions...");
503
+ if (dryRun) console.log(" Mode: DRY RUN");
504
+
505
+ const result = generatePromoDecisions({ dryRun });
506
+ console.log(` Decisions: ${result.decisionCount}`);
507
+ }