@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/promo-kit.mjs +150 -0
- package/index.mjs +9 -0
- package/kit.config.example.json +19 -0
- package/package.json +45 -0
- package/scripts/apply-control-patch.mjs +205 -0
- package/scripts/apply-submission-status.mjs +225 -0
- package/scripts/gen-baseline.mjs +402 -0
- package/scripts/gen-decision-drift.mjs +253 -0
- package/scripts/gen-experiment-decisions.mjs +282 -0
- package/scripts/gen-feedback-summary.mjs +278 -0
- package/scripts/gen-promo-decisions.mjs +507 -0
- package/scripts/gen-queue-health.mjs +223 -0
- package/scripts/gen-recommendation-patch.mjs +352 -0
- package/scripts/gen-recommendations.mjs +409 -0
- package/scripts/gen-telemetry-aggregate.mjs +266 -0
- package/scripts/gen-trust-receipt.mjs +184 -0
- package/scripts/kit-bootstrap.mjs +246 -0
- package/scripts/kit-migrate.mjs +111 -0
- package/scripts/kit-selftest.mjs +207 -0
- package/scripts/lib/config.mjs +124 -0
|
@@ -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
|
+
}
|