fix(linkedin-studio): ship placeholder voice profile, gitignore real, sentinel detection

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-30 00:23:32 +02:00
commit 911871ff53
8 changed files with 198 additions and 209 deletions

View file

@ -0,0 +1,66 @@
import { describe, test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { calculateScore } from '../personalization-score.mjs';
// Step 5 (remediation): the shipped voice profile is a PII-free placeholder
// carrying the sentinel below. Detection must key on the sentinel — NOT the old
// `[Your Name]` heuristic — so the placeholder earns 0 of the 25 voice points,
// and a populated profile (sentinel removed) earns them.
const SENTINEL = '<!-- VOICE_PLACEHOLDER -->';
// A >50-line body that contains NEITHER the sentinel NOR `[Your Name]`, so the
// only thing distinguishing placeholder from real profile is the sentinel.
const LONG_BODY = Array.from({ length: 60 }, (_, i) => `- voice characteristic line ${i + 1}`).join('\n');
function makePluginRoot() {
const root = mkdtempSync(join(tmpdir(), 'lis-personalization-'));
mkdirSync(join(root, 'assets', 'voice-samples'), { recursive: true });
return root;
}
function writeVoice(root, content) {
writeFileSync(join(root, 'assets', 'voice-samples', 'authentic-voice-samples.md'), content, 'utf-8');
}
describe('calculateScore — voice samples (sentinel-based placeholder detection)', () => {
let root;
afterEach(() => {
if (root && existsSync(root)) {
rmSync(root, { recursive: true, force: true });
}
root = undefined;
});
test('placeholder with the sentinel scores 0 voice points (even >50 lines, no [Your Name])', () => {
root = makePluginRoot();
writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n\n${LONG_BODY}\n`);
const { score, personalized } = calculateScore(root);
assert.equal(score, 0, 'placeholder must not earn the 25 voice points');
assert.equal(personalized, 0);
});
test('a real profile (no sentinel, >50 lines) earns the 25 voice points', () => {
root = makePluginRoot();
writeVoice(root, `# My Voice Profile\n\n${LONG_BODY}\n`);
const { score, personalized } = calculateScore(root);
assert.equal(score, 25, 'a populated profile must earn the 25 voice points');
assert.equal(personalized, 1);
});
test('a short placeholder (<50 lines) also scores 0 voice points', () => {
root = makePluginRoot();
writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n`);
const { score } = calculateScore(root);
assert.equal(score, 0);
});
});

View file

@ -16,11 +16,15 @@ export function calculateScore(pluginRoot) {
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('[Your Name]')) {
if (lineCount > 50 && !content.includes('<!-- VOICE_PLACEHOLDER -->')) {
score += 25;
personalized += 1;
}