@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,223 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Queue Health Analyzer
5
+ *
6
+ * Analyzes submissions.json for queue health metrics:
7
+ * time-in-status, stuck submissions, lint failure reasons, throughput.
8
+ *
9
+ * Usage:
10
+ * node scripts/gen-queue-health.mjs [--dry-run]
11
+ *
12
+ * Reads:
13
+ * site/src/data/submissions.json
14
+ * lint-reports/*.json (if present)
15
+ *
16
+ * Writes:
17
+ * site/src/data/queue-health.json
18
+ */
19
+
20
+ import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs";
21
+ import { resolve, join } from "node:path";
22
+ import { getConfig, getRoot } from "./lib/config.mjs";
23
+
24
+ const ROOT = getRoot();
25
+ const config = getConfig();
26
+
27
+ const STUCK_THRESHOLD_DAYS = 7;
28
+ const THROUGHPUT_WINDOW_DAYS = 30;
29
+
30
+ // ── Helpers ───────────────────────────────────────────────────
31
+
32
+ function daysBetween(isoA, isoB) {
33
+ const a = new Date(isoA);
34
+ const b = new Date(isoB);
35
+ return Math.abs(b - a) / (1000 * 60 * 60 * 24);
36
+ }
37
+
38
+ function median(values) {
39
+ if (values.length === 0) return null;
40
+ const sorted = [...values].sort((a, b) => a - b);
41
+ const mid = Math.floor(sorted.length / 2);
42
+ return sorted.length % 2 === 0
43
+ ? (sorted[mid - 1] + sorted[mid]) / 2
44
+ : sorted[mid];
45
+ }
46
+
47
+ // ── Core analysis ─────────────────────────────────────────────
48
+
49
+ /**
50
+ * Compute time-in-status for submissions that have updatedAt.
51
+ * @param {object[]} submissions
52
+ * @returns {Record<string, number|null>} status → median days
53
+ */
54
+ export function computeTimeInStatus(submissions) {
55
+ const durationsByStatus = {};
56
+
57
+ for (const s of submissions) {
58
+ if (!s.submittedAt) continue;
59
+ const endDate = s.updatedAt || new Date().toISOString();
60
+ const days = daysBetween(s.submittedAt, endDate);
61
+
62
+ if (!durationsByStatus[s.status]) durationsByStatus[s.status] = [];
63
+ durationsByStatus[s.status].push(Math.round(days * 10) / 10);
64
+ }
65
+
66
+ const result = {};
67
+ for (const [status, durations] of Object.entries(durationsByStatus)) {
68
+ result[status] = median(durations);
69
+ }
70
+ return result;
71
+ }
72
+
73
+ /**
74
+ * Analyze queue health from submissions data.
75
+ * @param {object[]} submissions
76
+ * @param {{ lintReports?: Record<string, object>, now?: Date }} opts
77
+ * @returns {object}
78
+ */
79
+ export function analyzeQueueHealth(submissions, opts = {}) {
80
+ const { lintReports = {}, now = new Date() } = opts;
81
+ const nowIso = now.toISOString();
82
+
83
+ if (!submissions || submissions.length === 0) {
84
+ return {
85
+ generatedAt: nowIso,
86
+ submissions: 0,
87
+ byStatus: {},
88
+ stuckCount: 0,
89
+ stuckSlugs: [],
90
+ topLintFailures: [],
91
+ medianDaysPending: null,
92
+ throughput: 0,
93
+ };
94
+ }
95
+
96
+ // Count by status
97
+ const byStatus = {};
98
+ for (const s of submissions) {
99
+ byStatus[s.status] = (byStatus[s.status] || 0) + 1;
100
+ }
101
+
102
+ // Stuck submissions (pending or needs-info > 7 days)
103
+ const stuckSlugs = [];
104
+ for (const s of submissions) {
105
+ if (s.status === "pending" || s.status === "needs-info") {
106
+ const days = daysBetween(s.submittedAt, nowIso);
107
+ if (days > STUCK_THRESHOLD_DAYS) {
108
+ stuckSlugs.push({ slug: s.slug, status: s.status, daysPending: Math.round(days) });
109
+ }
110
+ }
111
+ }
112
+
113
+ // Median days pending (for completed submissions: accepted or rejected)
114
+ const completedDays = submissions
115
+ .filter((s) => (s.status === "accepted" || s.status === "rejected") && s.updatedAt)
116
+ .map((s) => daysBetween(s.submittedAt, s.updatedAt));
117
+ const medianDaysPending = median(completedDays);
118
+
119
+ // Throughput (accepted in trailing 30 days)
120
+ const windowStart = new Date(now.getTime() - THROUGHPUT_WINDOW_DAYS * 24 * 60 * 60 * 1000);
121
+ const throughput = submissions.filter(
122
+ (s) => s.status === "accepted" && s.updatedAt && new Date(s.updatedAt) >= windowStart,
123
+ ).length;
124
+
125
+ // Top lint failures from lint reports
126
+ const failureCounts = {};
127
+ for (const report of Object.values(lintReports)) {
128
+ if (report.errors) {
129
+ for (const err of report.errors) {
130
+ failureCounts[err] = (failureCounts[err] || 0) + 1;
131
+ }
132
+ }
133
+ if (report.warnings) {
134
+ for (const warn of report.warnings) {
135
+ failureCounts[warn] = (failureCounts[warn] || 0) + 1;
136
+ }
137
+ }
138
+ }
139
+ const topLintFailures = Object.entries(failureCounts)
140
+ .sort((a, b) => b[1] - a[1])
141
+ .slice(0, 10)
142
+ .map(([reason, count]) => ({ reason, count }));
143
+
144
+ return {
145
+ generatedAt: nowIso,
146
+ submissions: submissions.length,
147
+ byStatus,
148
+ stuckCount: stuckSlugs.length,
149
+ stuckSlugs,
150
+ topLintFailures,
151
+ medianDaysPending: medianDaysPending !== null ? Math.round(medianDaysPending * 10) / 10 : null,
152
+ throughput,
153
+ };
154
+ }
155
+
156
+ // ── Pipeline ──────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Read submissions + lint reports, analyze, write output.
160
+ * @param {{ submissionsPath?: string, lintDir?: string, outputPath?: string, dryRun?: boolean }} opts
161
+ */
162
+ export function genQueueHealth(opts = {}) {
163
+ const {
164
+ submissionsPath = join(ROOT, config.paths.dataDir, "submissions.json"),
165
+ lintDir = join(ROOT, "lint-reports"),
166
+ outputPath = join(ROOT, config.paths.dataDir, "queue-health.json"),
167
+ dryRun = false,
168
+ } = opts;
169
+
170
+ // Load submissions
171
+ let submissions = [];
172
+ try {
173
+ const data = JSON.parse(readFileSync(submissionsPath, "utf8"));
174
+ submissions = data.submissions || [];
175
+ } catch {
176
+ // no submissions file
177
+ }
178
+
179
+ // Load lint reports
180
+ const lintReports = {};
181
+ if (existsSync(lintDir)) {
182
+ const files = readdirSync(lintDir).filter((f) => f.endsWith(".json"));
183
+ for (const file of files) {
184
+ try {
185
+ const slug = file.replace(".json", "");
186
+ lintReports[slug] = JSON.parse(readFileSync(join(lintDir, file), "utf8"));
187
+ } catch {
188
+ // skip malformed
189
+ }
190
+ }
191
+ }
192
+
193
+ const result = analyzeQueueHealth(submissions, { lintReports });
194
+
195
+ if (dryRun) {
196
+ console.log(` [dry-run] Queue health analysis complete.`);
197
+ console.log(` Submissions: ${result.submissions}`);
198
+ console.log(` Stuck: ${result.stuckCount}`);
199
+ console.log(` Throughput (30d): ${result.throughput}`);
200
+ return result;
201
+ }
202
+
203
+ writeFileSync(outputPath, JSON.stringify(result, null, 2) + "\n", "utf8");
204
+ return result;
205
+ }
206
+
207
+ // ── Entry point ───────────────────────────────────────────────
208
+
209
+ const isMain = process.argv[1] && resolve(process.argv[1]).endsWith("gen-queue-health.mjs");
210
+ if (isMain) {
211
+ const dryRun = process.argv.includes("--dry-run");
212
+ console.log("Analyzing queue health...");
213
+ if (dryRun) console.log(" Mode: DRY RUN");
214
+
215
+ const result = genQueueHealth({ dryRun });
216
+ if (!dryRun) {
217
+ console.log(` Submissions: ${result.submissions}`);
218
+ console.log(` By status: ${JSON.stringify(result.byStatus)}`);
219
+ console.log(` Stuck: ${result.stuckCount}`);
220
+ console.log(` Median days pending: ${result.medianDaysPending ?? "N/A"}`);
221
+ console.log(` Throughput (30d): ${result.throughput}`);
222
+ }
223
+ }
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gen-recommendation-patch.mjs
4
+ *
5
+ * Translates advisory recommendations into governed, auditable patches.
6
+ * Only certain recommendation categories produce actual data file changes;
7
+ * the rest become advisory notes included in the PR body.
8
+ *
9
+ * Respects freeze modes, promo queue caps, and max patch limits.
10
+ * Deterministic: same inputs → same patch plan (timestamps only in audit artifact).
11
+ *
12
+ * Usage:
13
+ * node scripts/gen-recommendation-patch.mjs [--dry-run]
14
+ *
15
+ * Reads:
16
+ * site/src/data/recommendations.json
17
+ * site/src/data/governance.json
18
+ * site/src/data/promo-queue.json
19
+ * site/src/data/experiments.json
20
+ *
21
+ * Writes:
22
+ * site/src/data/promo-queue.json (if re-feature patches)
23
+ * site/src/data/experiments.json (if graduation patches)
24
+ * site/src/data/recommendation-patch.json (audit artifact, always)
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { getConfig, getRoot } from "./lib/config.mjs";
31
+
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+ const ROOT = getRoot();
34
+ const config = getConfig();
35
+ const DATA_DIR = path.join(ROOT, config.paths.dataDir);
36
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
37
+
38
+ // ── Constants ────────────────────────────────────────────────
39
+
40
+ export const ALLOWED_TARGET_FILES = new Set(["promo-queue.json", "experiments.json"]);
41
+ export const MAX_DATA_PATCHES_DEFAULT = 5;
42
+
43
+ // Categories that produce actual data file changes
44
+ const PATCHABLE_CATEGORIES = new Set(["re-feature", "experiment-graduation"]);
45
+
46
+ // ── Helpers ──────────────────────────────────────────────────
47
+
48
+ function loadJsonSafe(filePath, fallback = null) {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
51
+ } catch {
52
+ return fallback;
53
+ }
54
+ }
55
+
56
+ // ── Core Functions (exported for testing) ────────────────────
57
+
58
+ /**
59
+ * Translate a single recommendation into a patch, advisory note, or frozen action.
60
+ * @param {object} rec - recommendation from recommendations.json
61
+ * @param {object} governance - governance.json contents
62
+ * @param {object} currentData - { promoQueue, experiments }
63
+ * @returns {{ type: "patch"|"advisory"|"frozen", patch?: object, note: string, riskNote?: string }}
64
+ */
65
+ export function translateRecommendation(rec, governance, currentData) {
66
+ const { category, slug } = rec;
67
+
68
+ // ── re-feature: add slug to promo queue ──
69
+ if (category === "re-feature") {
70
+ if (governance.decisionsFrozen) {
71
+ return {
72
+ type: "frozen",
73
+ note: `decisionsFrozen — skipped re-feature for "${slug}"`,
74
+ };
75
+ }
76
+
77
+ const queue = currentData.promoQueue;
78
+ const currentSlugs = queue.slugs || [];
79
+ const maxPerWeek = governance.maxPromosPerWeek || 3;
80
+
81
+ if (currentSlugs.includes(slug)) {
82
+ return {
83
+ type: "advisory",
84
+ note: `Already in promo queue`,
85
+ };
86
+ }
87
+
88
+ if (currentSlugs.length >= maxPerWeek) {
89
+ return {
90
+ type: "advisory",
91
+ note: `Promo queue full (${currentSlugs.length}/${maxPerWeek})`,
92
+ };
93
+ }
94
+
95
+ const newSlugs = [...currentSlugs, slug];
96
+ return {
97
+ type: "patch",
98
+ patch: {
99
+ category: "re-feature",
100
+ slug,
101
+ targetFile: "promo-queue.json",
102
+ description: `Add ${slug} to promo queue (${rec.evidence?.proofEngagementScore ? `proof engagement: ${rec.evidence.proofEngagementScore}` : "re-feature"})`,
103
+ riskNote: `Promo queue now has ${newSlugs.length}/${maxPerWeek} slots filled`,
104
+ apply: { slugs: newSlugs },
105
+ },
106
+ note: `Add to promo queue`,
107
+ riskNote: `Promo queue now has ${newSlugs.length}/${maxPerWeek} slots filled`,
108
+ };
109
+ }
110
+
111
+ // ── experiment-graduation: set experiment status to concluded ──
112
+ if (category === "experiment-graduation") {
113
+ if (governance.experimentsFrozen) {
114
+ return {
115
+ type: "frozen",
116
+ note: `experimentsFrozen — skipped graduation for "${slug}"`,
117
+ };
118
+ }
119
+
120
+ const experiments = currentData.experiments.experiments || [];
121
+ const match = experiments.find((e) => e.id === slug);
122
+
123
+ if (!match) {
124
+ return {
125
+ type: "advisory",
126
+ note: `Experiment "${slug}" not found in experiments.json`,
127
+ };
128
+ }
129
+
130
+ if (match.status === "concluded") {
131
+ return {
132
+ type: "advisory",
133
+ note: `Experiment "${slug}" already concluded`,
134
+ };
135
+ }
136
+
137
+ const updatedExperiments = experiments.map((e) =>
138
+ e.id === slug ? { ...e, status: "concluded" } : e,
139
+ );
140
+
141
+ return {
142
+ type: "patch",
143
+ patch: {
144
+ category: "experiment-graduation",
145
+ slug,
146
+ targetFile: "experiments.json",
147
+ description: `Graduate experiment ${slug} (winner: ${rec.evidence?.winnerKey || "unknown"})`,
148
+ riskNote: `Experiment ${slug} will be marked concluded`,
149
+ apply: { experiments: updatedExperiments },
150
+ },
151
+ note: `Graduate experiment`,
152
+ riskNote: `Experiment ${slug} will be marked concluded`,
153
+ };
154
+ }
155
+
156
+ // ── Advisory-only categories ──
157
+ if (category === "improve-proof") {
158
+ return {
159
+ type: "advisory",
160
+ note: `${rec.insight || "Low proof engagement — improve evidence quality"}`,
161
+ };
162
+ }
163
+
164
+ if (category === "stuck-submission") {
165
+ return {
166
+ type: "advisory",
167
+ note: `${rec.insight || "High friction submission — needs attention"}`,
168
+ };
169
+ }
170
+
171
+ if (category === "lint-promotion") {
172
+ return {
173
+ type: "advisory",
174
+ note: `${rec.insight || "Lint warning pattern — consider promoting to error"}`,
175
+ };
176
+ }
177
+
178
+ // ── Unknown category fallback ──
179
+ return {
180
+ type: "advisory",
181
+ note: `Unknown category "${category}" — advisory only`,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Build a complete patch plan from all recommendations.
187
+ * @param {object[]} recommendations
188
+ * @param {object} governance
189
+ * @param {object} currentData - { promoQueue, experiments }
190
+ * @param {{ maxPatches?: number }} opts
191
+ * @returns {{ patches: object[], advisoryNotes: object[], riskNotes: string[], frozenActions: object[] }}
192
+ */
193
+ export function buildPatchPlan(recommendations, governance, currentData, opts = {}) {
194
+ const { maxPatches = MAX_DATA_PATCHES_DEFAULT } = opts;
195
+
196
+ const patches = [];
197
+ const advisoryNotes = [];
198
+ const riskNotes = [];
199
+ const frozenActions = [];
200
+
201
+ // Track evolving state for incremental queue fills
202
+ const evolving = {
203
+ promoQueue: JSON.parse(JSON.stringify(currentData.promoQueue)),
204
+ experiments: JSON.parse(JSON.stringify(currentData.experiments)),
205
+ };
206
+
207
+ for (const rec of recommendations) {
208
+ const result = translateRecommendation(rec, governance, evolving);
209
+
210
+ if (result.type === "patch") {
211
+ if (patches.length >= maxPatches) {
212
+ // Exceeded cap — downgrade to advisory
213
+ advisoryNotes.push({
214
+ category: rec.category,
215
+ slug: rec.slug,
216
+ note: `Exceeded max patch cap (${maxPatches}) — ${result.note}`,
217
+ });
218
+ continue;
219
+ }
220
+
221
+ patches.push(result.patch);
222
+ if (result.riskNote) riskNotes.push(result.riskNote);
223
+
224
+ // Update evolving state so subsequent recommendations see the changes
225
+ if (result.patch.targetFile === "promo-queue.json" && result.patch.apply?.slugs) {
226
+ evolving.promoQueue = { ...evolving.promoQueue, slugs: result.patch.apply.slugs };
227
+ }
228
+ if (result.patch.targetFile === "experiments.json" && result.patch.apply?.experiments) {
229
+ evolving.experiments = { ...evolving.experiments, experiments: result.patch.apply.experiments };
230
+ }
231
+ } else if (result.type === "frozen") {
232
+ frozenActions.push({
233
+ category: rec.category,
234
+ slug: rec.slug,
235
+ note: result.note,
236
+ });
237
+ } else {
238
+ // advisory
239
+ advisoryNotes.push({
240
+ category: rec.category,
241
+ slug: rec.slug,
242
+ note: result.note,
243
+ });
244
+ }
245
+ }
246
+
247
+ return { patches, advisoryNotes, riskNotes, frozenActions };
248
+ }
249
+
250
+ /**
251
+ * Apply patch plan to data files on disk.
252
+ * @param {object[]} patches - from buildPatchPlan().patches
253
+ * @param {object} currentData - { promoQueue, experiments }
254
+ * @param {{ dataDir?: string }} opts
255
+ * @returns {{ filesWritten: string[] }}
256
+ */
257
+ export function applyPatchesToFiles(patches, currentData, opts = {}) {
258
+ const { dataDir = DATA_DIR } = opts;
259
+ const filesWritten = [];
260
+
261
+ // Group patches by target file to coalesce writes
262
+ const byFile = {};
263
+ for (const p of patches) {
264
+ if (!byFile[p.targetFile]) byFile[p.targetFile] = [];
265
+ byFile[p.targetFile].push(p);
266
+ }
267
+
268
+ for (const [file, filePatches] of Object.entries(byFile)) {
269
+ const filePath = path.join(dataDir, file);
270
+ let current = loadJsonSafe(filePath, {});
271
+
272
+ // Apply each patch's changes sequentially
273
+ for (const p of filePatches) {
274
+ if (p.apply) {
275
+ current = { ...current, ...p.apply };
276
+ }
277
+ }
278
+
279
+ fs.writeFileSync(filePath, JSON.stringify(current, null, 2) + "\n");
280
+ filesWritten.push(file);
281
+ }
282
+
283
+ return { filesWritten };
284
+ }
285
+
286
+ // ── Pipeline ─────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Generate and optionally apply recommendation patches.
290
+ * @param {{ dataDir?: string, dryRun?: boolean, maxPatches?: number }} opts
291
+ * @returns {object} The patch plan with metadata
292
+ */
293
+ export function genRecommendationPatch(opts = {}) {
294
+ const { dataDir = DATA_DIR, dryRun = false, maxPatches = MAX_DATA_PATCHES_DEFAULT } = opts;
295
+
296
+ console.log("Generating recommendation patches...");
297
+ console.log(` Mode: ${dryRun ? "DRY RUN" : "LIVE"}`);
298
+
299
+ // Load inputs (fail-soft)
300
+ const recommendations = loadJsonSafe(path.join(dataDir, "recommendations.json"), { recommendations: [] });
301
+ const governance = loadJsonSafe(path.join(dataDir, "governance.json"), {});
302
+ const promoQueue = loadJsonSafe(path.join(dataDir, "promo-queue.json"), { slugs: [] });
303
+ const experiments = loadJsonSafe(path.join(dataDir, "experiments.json"), { experiments: [] });
304
+
305
+ const currentData = { promoQueue, experiments };
306
+
307
+ // Build patch plan
308
+ const plan = buildPatchPlan(
309
+ recommendations.recommendations || [],
310
+ governance,
311
+ currentData,
312
+ { maxPatches },
313
+ );
314
+
315
+ // Audit artifact (includes timestamp — only non-deterministic part)
316
+ const artifact = {
317
+ generatedAt: new Date().toISOString(),
318
+ ...plan,
319
+ };
320
+
321
+ if (dryRun) {
322
+ console.log(` [dry-run] ${plan.patches.length} data patches, ${plan.advisoryNotes.length} advisory, ${plan.frozenActions.length} frozen`);
323
+ } else {
324
+ // Apply data file changes
325
+ if (plan.patches.length > 0) {
326
+ const { filesWritten } = applyPatchesToFiles(plan.patches, currentData, { dataDir });
327
+ console.log(` Applied patches to: ${filesWritten.join(", ")}`);
328
+ } else {
329
+ console.log(" No data patches to apply.");
330
+ }
331
+
332
+ // Write audit artifact
333
+ const artifactPath = path.join(dataDir, "recommendation-patch.json");
334
+ fs.writeFileSync(artifactPath, JSON.stringify(artifact, null, 2) + "\n");
335
+ console.log(` Wrote audit artifact: ${artifactPath}`);
336
+ }
337
+
338
+ // Summary
339
+ console.log(` Patches: ${plan.patches.length}`);
340
+ console.log(` Advisory: ${plan.advisoryNotes.length}`);
341
+ console.log(` Frozen: ${plan.frozenActions.length}`);
342
+ console.log(` Risk notes: ${plan.riskNotes.length}`);
343
+
344
+ return artifact;
345
+ }
346
+
347
+ // ── CLI ──────────────────────────────────────────────────────
348
+
349
+ if (isMain) {
350
+ const dryRun = process.argv.includes("--dry-run");
351
+ genRecommendationPatch({ dryRun });
352
+ }