Wave 2 / Step 5 of the remediation plan (coupled criticals: voice-leak + placeholder-detection). Voice profile (the adopter-default leak): - Ship a PII-free placeholder at authentic-voice-samples.md carrying a <!-- VOICE_PLACEHOLDER --> sentinel + neutral default voice principles. - Migrate the author's real profile to gitignored authentic-voice-samples.local.md (already matched by *.local.md; added an explicit, commented .gitignore entry so the intent is unmissable). NO git-history rewrite — the historical file is attributed authorship, not a secret (per the plan threat model). - Add authentic-voice-samples.template.md — a clean fill-in template for adopters. - personalization-score.mjs: detect the sentinel (deterministic) instead of the unreliable `[Your Name]` heuristic, so the placeholder scores 0 voice points and a populated profile (sentinel removed) earns the 25. - Both voice writers replace-not-append on the placeholder: setup.md (merge -> replace-if-placeholder) and onboarding.md (append -> replace-if-placeholder), so populating removes the sentinel; updated setup.md's stale heuristic table. Operator decisions (deviations from plan-literal, approved this session): - KEEP the plugin.json author name. The plan said scrub author -> neutral/org, but that contradicts its own LICENSE reasoning (intentional MIT attribution) and all 5 sibling plugins keep author = the author; scrubbing only this one would create inconsistency for zero security gain (the name is public-by-design). The voice placeholder fully fixes the adopter-inheritance bug. - Scrub the stale "January 2026 360Brew" brand from the plugin.json description and the "360brew" keyword (locked decision: no publishable model name/date). This is a Wave-1 propagation miss surfaced here because plugin.json was in Step 5's touch-scope. Flagged for follow-up (NOT done here — out of Session 2 scope): - The lint's stat-consistency grep (scripts/test-runner.sh) scans references/, commands/, skills/, hooks/prompts/, CLAUDE.md, README.md — but NOT .claude-plugin/plugin.json, which is why the 360Brew brand slipped Wave 1. Needs a Session-1-scoped lint extension to add plugin.json to the scan set. - Readers (user-prompt-context.mjs, voice-guardian.md, state-update-reminder.md) read the tracked .md (placeholder), per the plan. The operator's real voice now lives in the gitignored .local.md, which nothing reads. To use it, readers + the voice score should prefer .local.md (matching the user-profile.local.md precedent). Deferred as a coherence follow-up for operator review. Test-first: hooks/scripts/__tests__/personalization-score.test.mjs (red on the placeholder scoring 25 under the old heuristic, green after the sentinel fix). Hook suite 62/62, structural lint 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
4.7 KiB
JavaScript
124 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// Personalization score calculator for linkedin-studio plugin
|
|
// Checks 8 asset categories for real user data vs placeholder templates
|
|
// Standalone: outputs SCORE:N|M/8 assets personalized
|
|
// Import: export function calculateScore(pluginRoot) => { score, personalized, categories }
|
|
|
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
import { join, basename, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
export function calculateScore(pluginRoot) {
|
|
let score = 0;
|
|
let personalized = 0;
|
|
const categories = 8;
|
|
|
|
// --- 1. Voice samples (25 points) ---
|
|
// The shipped file is a PII-free placeholder carrying the VOICE_PLACEHOLDER
|
|
// sentinel. Key detection on the sentinel (deterministic) rather than the old
|
|
// `[Your Name]` heuristic: a populated profile removes the sentinel and earns
|
|
// the points; the placeholder (or any file still carrying it) scores 0.
|
|
const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md');
|
|
if (existsSync(voiceFile)) {
|
|
const content = readFileSync(voiceFile, 'utf-8');
|
|
const lineCount = content.split('\n').length;
|
|
if (lineCount > 50 && !content.includes('<!-- VOICE_PLACEHOLDER -->')) {
|
|
score += 25;
|
|
personalized += 1;
|
|
}
|
|
}
|
|
|
|
// --- 2. User profile (20 points) ---
|
|
const profileFile = join(pluginRoot, 'config', 'user-profile.local.md');
|
|
if (existsSync(profileFile)) {
|
|
const content = readFileSync(profileFile, 'utf-8');
|
|
const placeholderCount = (content.match(/\[Your /g) || []).length;
|
|
if (placeholderCount < 3) {
|
|
score += 20;
|
|
personalized += 1;
|
|
}
|
|
}
|
|
|
|
// --- 3. Case studies (15 points) ---
|
|
const caseDir = join(pluginRoot, 'assets', 'case-studies');
|
|
if (existsSync(caseDir)) {
|
|
let realCases = 0;
|
|
try {
|
|
for (const f of readdirSync(caseDir)) {
|
|
if (!f.endsWith('.md')) continue;
|
|
if (f === 'case-study-template.md') continue;
|
|
realCases++;
|
|
}
|
|
} catch { /* ignore */ }
|
|
if (realCases >= 2) { score += 15; personalized += 1; }
|
|
else if (realCases >= 1) { score += 8; }
|
|
}
|
|
|
|
// --- 4. Frameworks (10 points) ---
|
|
const fwDir = join(pluginRoot, 'assets', 'frameworks');
|
|
if (existsSync(fwDir)) {
|
|
let realFw = 0;
|
|
try {
|
|
for (const f of readdirSync(fwDir)) {
|
|
if (!f.endsWith('.md')) continue;
|
|
if (f === 'framework-template.md') continue;
|
|
realFw++;
|
|
}
|
|
} catch { /* ignore */ }
|
|
if (realFw >= 2) { score += 10; personalized += 1; }
|
|
else if (realFw >= 1) { score += 5; }
|
|
}
|
|
|
|
// --- 5. High-engagement posts (10 points) ---
|
|
const postsFile = join(pluginRoot, 'assets', 'examples', 'high-engagement-posts.md');
|
|
if (existsSync(postsFile)) {
|
|
const content = readFileSync(postsFile, 'utf-8');
|
|
const postCount = (content.match(/^## Post [0-9]/gm) || []).length;
|
|
if (postCount >= 3) { score += 10; personalized += 1; }
|
|
else if (postCount >= 1) { score += 4; }
|
|
}
|
|
|
|
// --- 6. Demographics (8 points) ---
|
|
const demoFile = join(pluginRoot, 'assets', 'audience-insights', 'demographics.md');
|
|
if (existsSync(demoFile)) {
|
|
const content = readFileSync(demoFile, 'utf-8');
|
|
const placeholderCount = (content.match(/\[Industry name\]|\[Function\]|\[Country\]|\[X\]%/g) || []).length;
|
|
if (placeholderCount < 5) {
|
|
score += 8;
|
|
personalized += 1;
|
|
}
|
|
}
|
|
|
|
// --- 7. Engagement patterns (7 points) ---
|
|
const patternsFile = join(pluginRoot, 'assets', 'audience-insights', 'engagement-patterns.md');
|
|
if (existsSync(patternsFile)) {
|
|
const content = readFileSync(patternsFile, 'utf-8');
|
|
const placeholderCount = (content.match(/\[Day\]|\[Time\]|\[Topic\]|\[Format\]|\[Hook type\]/g) || []).length;
|
|
if (placeholderCount < 5) {
|
|
score += 7;
|
|
personalized += 1;
|
|
}
|
|
}
|
|
|
|
// --- 8. Post templates (5 points) ---
|
|
const templatesFile = join(pluginRoot, 'assets', 'templates', 'my-post-templates.md');
|
|
if (existsSync(templatesFile)) {
|
|
const content = readFileSync(templatesFile, 'utf-8');
|
|
const unfilled = (content.match(/\[Name - e\.g\./g) || []).length;
|
|
const totalTemplates = (content.match(/^## Template [0-9]/gm) || []).length;
|
|
const filled = totalTemplates - unfilled;
|
|
if (filled >= 2) { score += 5; personalized += 1; }
|
|
else if (filled >= 1) { score += 2; }
|
|
}
|
|
|
|
return { score, personalized, categories };
|
|
}
|
|
|
|
// Standalone execution (guarded to prevent stdout contamination on import)
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const pluginRoot = join(__dirname, '..', '..');
|
|
const { score, personalized, categories } = calculateScore(pluginRoot);
|
|
process.stdout.write(`SCORE:${score}|${personalized}/${categories} assets personalized\n`);
|
|
}
|