@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,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
|
+
}
|