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

@ -1,11 +1,11 @@
{
"name": "linkedin-studio",
"version": "3.1.0",
"description": "LinkedIn Studio — full-spectrum LinkedIn content engine: feed posts, carousels, video scripts, and long-form newsletter editions, with the January 2026 360Brew algorithm baked in. v3.1.0 (Endring 9) adds a cold adversarial review package to `/linkedin:newsletter` — Step 6.5 + the standalone `/linkedin:headless-review` command run three new headless archetypes (content-reviewer, language-reviewer, fact-reviewer) plus the persona reviewer with NO drafting-session context — a `/linkedin:pivot` command that re-opens cleared gates after a late change, and per-artifact personas (one or more readers configurable per edition). v3.0.0 renamed the plugin (was `linkedin-thought-leadership`): slug, agent namespace, and state-file path are `linkedin-studio`; the `/linkedin:*` commands are unchanged.",
"description": "LinkedIn Studio — full-spectrum LinkedIn content engine: feed posts, carousels, video scripts, and long-form newsletter editions, with the 2026 relevance-ranking model baked in. v3.1.0 (Endring 9) adds a cold adversarial review package to `/linkedin:newsletter` — Step 6.5 + the standalone `/linkedin:headless-review` command run three new headless archetypes (content-reviewer, language-reviewer, fact-reviewer) plus the persona reviewer with NO drafting-session context — a `/linkedin:pivot` command that re-opens cleared gates after a late change, and per-artifact personas (one or more readers configurable per edition). v3.0.0 renamed the plugin (was `linkedin-thought-leadership`): slug, agent namespace, and state-file path are `linkedin-studio`; the `/linkedin:*` commands are unchanged.",
"author": {
"name": "Kjell Tore Guttormsen"
},
"license": "MIT",
"repository": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace",
"keywords": ["linkedin", "content-creation", "newsletter", "analytics", "360brew"]
"keywords": ["linkedin", "content-creation", "newsletter", "analytics", "relevance-ranking"]
}

View file

@ -5,6 +5,10 @@
# Local configuration
*.local.md
# Real voice profile is personal data — adopters keep theirs local; the tracked
# authentic-voice-samples.md ships as a sentinel placeholder. (Already matched by
# *.local.md above; listed explicitly so the intent is unmissable.)
assets/voice-samples/authentic-voice-samples.local.md
# Session state (personal activity, auto-initialized from template)
REMEMBER.md

View file

@ -1,227 +1,70 @@
# Authentic Voice Samples - Kjell Tore Guttormsen
<!-- VOICE_PLACEHOLDER -->
<!--
This is the SHIPPED PLACEHOLDER voice profile — neutral defaults, not anyone's
personal voice. The VOICE_PLACEHOLDER sentinel above keeps your voice
personalization score at 0 until you replace this file with your own profile.
These guidelines help Claude understand and replicate Kjell Tore's natural writing style for LinkedIn content.
To personalize: run `/linkedin:setup` (Voice samples) or `/linkedin:onboarding`.
Those workflows overwrite this file with a profile built from your own samples.
Prefer to start from a clean form? Copy `authentic-voice-samples.template.md`.
## Voice Profile Summary
If you want to keep your real profile out of version control, save it as
`authentic-voice-samples.local.md` (gitignored) instead of editing this file.
-->
Kjell Tore does not have traditional writing samples to share. Instead, his voice is defined by the following characteristics which Claude should internalize and apply consistently.
# Authentic Voice Samples — Placeholder (neutral defaults)
---
These are neutral, widely-applicable defaults so the plugin produces reasonable
content before you personalize. Replace them with your own voice via
`/linkedin:setup`. Until you do, the voice category scores 0.
## Core Voice Characteristics
### 1. Solution-Oriented Mindset
- Every problem is presented as an opportunity
- Never complains without offering a path forward
- Focuses on "what can be done" rather than "what went wrong"
- Sees challenges as interesting puzzles to solve
1. **Solution-oriented** — frame problems with a path forward, not just complaints.
2. **Factually grounded** — base claims on evidence; acknowledge uncertainty openly.
3. **Non-judgmental** — explain without criticizing people, companies, or decisions.
4. **Curious and open** — treat "I don't know" as a starting point, not a weakness.
5. **Story-led** — open with a concrete example before the abstract point.
6. **Actionable** — end with something the reader can do, or a clear takeaway.
### 2. Factual Grounding
- Statements are based on facts, not assumptions
- If uncertain, acknowledges uncertainty openly
- Prefers data and evidence over opinions
- Avoids speculation presented as fact
## Do's
### 3. Non-Judgmental Tone
- Observes and explains without criticizing others
- Builds up, never tears down
- Avoids negative commentary about people, companies, or decisions
- When discussing alternatives, frames as "different approaches" not "better/worse"
- ✅ Open with a story or concrete example before the concept.
- ✅ Use clear, accessible language even for technical topics.
- ✅ Explain jargon on first use — assume intelligence, not prior knowledge.
- ✅ Show rather than tell.
- ✅ End with a specific, actionable takeaway.
- ✅ Keep standard posts concise (≈8001500 characters).
### 4. Curiosity and Openness
- Genuinely interested in learning new things
- Open to new ideas and approaches
- Asks questions to understand, not to challenge
- Embraces "I don't know" as a starting point for exploration
## Don'ts
### 5. Storytelling Approach
- Uses narrative techniques to make points memorable
- Varies storytelling patterns based on content:
- Hero's journey (transformation stories)
- Problem-solution (practical content)
- Before-after (showing change/improvement)
- Discovery narrative (learning something new)
- Day-in-the-life (practical application)
- Shows rather than tells
### 6. Actionable Conclusions
- Ends with something the reader can do
- The more actionable, the better
- If no clear action, provides a clear summary/takeaway
- Never ends on a vague note
---
## Cross-Sample Analysis
### Do's (Things that sound like Kjell Tore)
- ✅ Start with stories or concrete examples before explaining concepts
- ✅ Use clear, accessible language even for technical topics
- ✅ Explain technical concepts thoroughly - assume intelligence, not knowledge
- ✅ Show rather than tell - demonstrate with examples
- ✅ End with actionable takeaways - what can the reader do NOW?
- ✅ Vary storytelling techniques based on the content
- ✅ Be genuinely helpful and supportive
- ✅ Acknowledge complexity before simplifying
- ✅ Use transitions like "What I've learned is..." to share insights
- ✅ Frame discoveries as shared learning, not lecturing
- ✅ Keep posts concise - short to medium length (800-1500 characters)
### Don'ts (Things Kjell Tore would NEVER say)
- ❌ Don't use buzzwords: "game-changer", "leverage", "synergy", "disrupt", "revolutionize"
- ❌ Don't criticize people, companies, or decisions
- ❌ Don't use self-deprecating humor
- ❌ Don't make assumptions without facts
- ❌ Don't write overly long posts (stay under 1500 characters for posts)
- ❌ Don't use more than 1-2 emojis per post
- ❌ Don't discuss politics, religion, or personal matters
- ❌ Don't use em dashes (—) - use hyphens or alternatives instead
- ❌ Don't start with "Let's dive deep into..."
- ❌ Don't use excessive exclamation marks!!!
- ❌ Don't use generic motivational phrases
- ❌ Don't be preachy or lecture the reader
- ❌ Don't use "we" when you mean "I" (be direct about personal experience)
---
- ❌ Corporate buzzwords ("game-changer", "leverage", "synergy", "disrupt").
- ❌ Criticizing people, companies, or decisions.
- ❌ Claims without evidence.
- ❌ More than 12 emojis per post.
- ❌ Generic motivational filler or preachy lecturing.
## Signature Phrases
Use these naturally when appropriate - don't force them:
- "Let me show you..."
- "What I've learned is..."
- "Here is the secret to..."
These phrases signal a transition to insight or demonstration. Use them to introduce key points or revelations.
---
_(Add the phrases you naturally use once you personalize this profile.)_
## Vocabulary Preferences
### Technical Terms - How to Handle
- **RAG (Retrieval-Augmented Generation):** Always explain on first use
- **MCP (Model Context Protocol):** Explain what it enables, not just the acronym
- **Copilot Studio:** Can assume some familiarity with Microsoft ecosystem
- **Skills (Claude):** Explain as "reusable instruction sets" or similar
- **Low-code:** Generally understood, but clarify scope if needed
**Principle:** Assume intelligence, not knowledge. Explain jargon without being condescending.
### Words/Phrases to AVOID
- "Game-changer"
- "Revolutionary"
- "Disruption" / "Disruptive"
- "Leverage" (as a verb)
- "Synergy"
- "Deep dive" / "Let's dive deep"
- "Unpack" (as in "let me unpack this")
- "At the end of the day"
- "It is what it is"
- "Touch base"
- "Circle back"
- "Low-hanging fruit"
---
## Humor and Personality
- **Humor style:** Mostly absent in professional content. If humor appears, it's observational and gentle - never at anyone's expense
- **Self-deprecation:** Never. Don't undermine your own credibility.
- **Cultural references:** Avoid pop culture references. Stick to professional/work context.
- **Analogies:** Use when helpful for explanation. Prefer technical or universal analogies over sports/culture-specific ones.
---
## Transitions and Flow
### How to Move Between Ideas
- Use questions: "So what does this mean for..."
- Use signposting: "Three things matter here..."
- Use revelation: "Here's what I discovered..."
- Use contrast: "The common approach is X. But what actually works is Y."
### How to Conclude
- Always tie back to practical implications
- End with a specific action the reader can take
- If no action possible, summarize the key insight clearly
- Occasionally invite discussion, but don't overuse "What do you think?" as a crutch
---
## Technical Depth Adaptation
Match technical depth to the target audience:
### For Leaders
- High-level concepts
- Business implications
- Strategic decisions
- ROI and outcomes
- Avoid implementation details
### For Low-Code Developers
- Practical tips and patterns
- Step-by-step guidance
- Tool-specific insights
- Common pitfalls and solutions
- Can include some technical detail
### For AI Architects
- Technical depth welcome
- Architecture patterns
- Integration approaches
- Trade-offs and decisions
- Code snippets when relevant
### For Power Users
- Productivity gains
- Workflow improvements
- Tool comparisons
- Time-saving techniques
- Practical shortcuts
**Key principle:** Always ensure technical content is well-explained and followable, regardless of depth. If you go technical, go all the way - don't half-explain.
---
_(List the terms you always explain, and the words/phrases you avoid.)_
## Language Guidelines
- **Always English** for all LinkedIn content
- Clear, international English accessible to non-native speakers
- Avoid idioms that don't translate well internationally
- Prefer simple sentence structures for complex ideas
- Never use em dashes (—) - use hyphens, commas, or separate sentences instead
---
- Write in one consistent language per post; keep it accessible to non-native readers.
- Prefer simple sentence structures for complex ideas.
## Instructions for Claude
When generating LinkedIn content for Kjell Tore:
1. **Start with his voice profile** (from this document)
2. **Check the content pillar** - which audience is this for?
3. **Choose appropriate storytelling technique** for the content type
4. **Ensure actionable conclusion** - what can the reader DO?
5. **Verify against Don'ts list** - no buzzwords, no criticism, no assumptions
6. **Keep length in check** - 800-1500 characters for posts
**Priority:** Sound like Kjell Tore > Optimize for algorithm
**Exception:** If a phrase or approach would harm reach (external links, engagement bait), flag it but maintain his voice in everything else.
---
## Update Log
- 2025-11-30: Initial voice profile created based on interview
This is a placeholder. When it is in place (sentinel present), treat the defaults
above as a reasonable baseline, and prompt the user to personalize via
`/linkedin:setup`. Once personalized, these instructions are replaced by the
user's own profile.
## Collected Post Samples
<!-- Posts are saved here automatically by the Stop hook after each session where content is created. -->
<!-- Posts are appended here automatically by the Stop hook after sessions where content is created. -->
<!-- The voice-trainer agent uses these for 6-dimension drift scoring. Needs 5+ samples for reliable results. -->

View file

@ -0,0 +1,61 @@
# Authentic Voice Samples — Template
Fill this in with YOUR voice, then save it as `authentic-voice-samples.md`
(or, to keep it out of version control, `authentic-voice-samples.local.md`).
`/linkedin:setup` can build this for you from 35 of your own posts.
Delete every `[bracketed]` prompt as you replace it. Do NOT leave the
`<!-- VOICE_PLACEHOLDER -->` sentinel anywhere in your finished profile — its
presence keeps the voice personalization score at 0.
## Core Voice Characteristics
[List 46 traits that define how you write. For each: a one-line description and,
where useful, the patterns you reach for. Examples: solution-oriented, factually
grounded, story-led, non-judgmental, actionable.]
## Do's
- ✅ [Things that sound like you — openings, structure, language level, how you close.]
## Don'ts
- ❌ [Words, tones, and moves you avoid — buzzwords, em dashes, over-long posts, etc.]
## Signature Phrases
[The phrases you genuinely use to transition into insight or demonstration.
Keep the list short and real — forced catchphrases read as fake.]
## Vocabulary Preferences
### Terms to always explain on first use
[Domain acronyms/terms your audience may not know.]
### Words/phrases to AVOID
[Your personal no-go list.]
## Language Guidelines
- [Which language(s) you publish in, and any accessibility rules — e.g. simple
sentences for complex ideas, avoid hard-to-translate idioms.]
## Technical Depth Adaptation
[If you write for multiple audiences, note how depth shifts per audience
(leaders vs practitioners vs power users).]
## Instructions for Claude
When generating content in this voice:
1. Start from this profile.
2. [Your priority order — e.g. "sound like me > optimize for algorithm".]
3. Verify against the Don'ts list before finishing.
## Update Log
- [YYYY-MM-DD]: Initial voice profile created.
## Collected Post Samples
<!-- Posts are appended here automatically by the Stop hook after sessions where content is created. -->

View file

@ -139,7 +139,13 @@ Use AskUserQuestion:
4. "Paste a paragraph you've written that sounds like YOU (email, doc, anything)"
5. "Any words or phrases you'd NEVER use?"
Save responses to `assets/voice-samples/authentic-voice-samples.md` under a new section `## Quick Voice Interview` (append, don't overwrite existing content).
Save the responses to `assets/voice-samples/authentic-voice-samples.md`. **If the
file is the shipped placeholder** (it contains `<!-- VOICE_PLACEHOLDER -->`),
**REPLACE it entirely** with the profile built from the answers — the
`<!-- VOICE_PLACEHOLDER -->` sentinel must NOT remain, or the voice score stays at
0 after the user fills it in. **If the file is already a populated profile**, add a
`## Quick Voice Interview` section instead of overwriting. Either way, the final
file must contain no `<!-- VOICE_PLACEHOLDER -->`.
**If user profile selected:** Ask for:
1. Full name

View file

@ -25,7 +25,7 @@ Read these 8 asset files and detect placeholder patterns to calculate the curren
| Category | Weight | File/Directory | Placeholder Detection |
|----------|--------|----------------|----------------------|
| Voice samples | 25 | `assets/voice-samples/authentic-voice-samples.md` | Check for `[Your Name]` or if file has <50 lines |
| Voice samples | 25 | `assets/voice-samples/authentic-voice-samples.md` | Placeholder if it contains the `<!-- VOICE_PLACEHOLDER -->` sentinel (or has <50 lines) |
| User profile | 20 | `config/user-profile.local.md` | Check if file exists; count `[Your ` placeholders |
| Case studies | 15 | `assets/case-studies/*.md` | Count non-template `.md` files (exclude `case-study-template.md`) |
| Frameworks | 10 | `assets/frameworks/*.md` | Count non-template `.md` files (exclude `framework-template.md`) |
@ -96,14 +96,19 @@ Based on their answer, run the corresponding sub-workflow below.
- How they handle technical depth
- How they conclude (CTA style, takeaway style)
4. Read the existing `assets/voice-samples/authentic-voice-samples.md`
5. **Merge** new findings with existing content (don't overwrite existing data):
5. **If the file is the shipped placeholder** (it contains `<!-- VOICE_PLACEHOLDER -->`):
**REPLACE it entirely** with the profile built from the user's samples. The
placeholder's `<!-- VOICE_PLACEHOLDER -->` sentinel must NOT survive — if it
does, the voice category stays at 0 even after the user fills in real data.
**Otherwise** (the file is already a populated profile), **merge** new findings
into the existing content (don't discard existing data):
- Update "Core Voice Characteristics" if new patterns found
- Add new entries to "Do's" and "Don'ts" lists
- Update "Signature Phrases" with newly detected phrases
- Add "Vocabulary Preferences" based on word analysis
- Update "Update Log" with today's date
6. Write the updated file back.
6. Write the file back, and confirm it contains no `<!-- VOICE_PLACEHOLDER -->`.
**Important:** Ask "Would you like to paste more samples?" after analyzing the first batch. More samples = better voice model.

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;
}