diff --git a/plugins/linkedin-studio/commands/carousel.md b/plugins/linkedin-studio/commands/carousel.md index 0e686ad..7f784f3 100644 --- a/plugins/linkedin-studio/commands/carousel.md +++ b/plugins/linkedin-studio/commands/carousel.md @@ -12,6 +12,7 @@ allowed-tools: - Bash - AskUserQuestion - mcp__mcp-image__generate_image + - Task --- @@ -117,6 +118,12 @@ Run against the Carousel Quality Checklist from `carousel-templates.md`: If any item fails, fix before presenting. +### De-AI / Differentiation Gate + +The caption is the feed text, and it rides the same low-substance down-rank LinkedIn confirmed. Confirm the caption and cover slide carry the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and use no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. (The voice-guardian hook scores the caption on save.) + +If the deck's premise is a list the audience has seen many times — commodity content — delegate an originality pass to the `differentiation-checker` agent: invoke it via `Task` with `subagent_type: linkedin-studio:differentiation-checker` (foreground, from this command layer), then sharpen the angle before generating slides. + ## Step 5.5: Generate Slide Images Generate a visual for each slide using mcp-image (Nano Banana Pro). If mcp-image is unavailable or fails, skip this step — the command degrades gracefully to text-only output with a manual design guide. diff --git a/plugins/linkedin-studio/commands/post.md b/plugins/linkedin-studio/commands/post.md index 7c517c2..db947d1 100644 --- a/plugins/linkedin-studio/commands/post.md +++ b/plugins/linkedin-studio/commands/post.md @@ -134,6 +134,12 @@ Before presenting, verify against `assets/checklists/quality-scorecard.md`: - [ ] Topic aligns with user's 5 core expertise areas - [ ] Passes thought leadership test (helps someone decide or think differently) +### De-AI / Differentiation Gate + +LinkedIn reach-suppresses low-substance AI content (officially confirmed — down to first-degree connections, not deleted). Confirm the draft carries the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and uses no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. (The voice-guardian hook scores this automatically on save.) + +If the angle risks being commodity content — a take the audience has seen many times — delegate an originality pass to the `differentiation-checker` agent: invoke it via `Task` with `subagent_type: linkedin-studio:differentiation-checker` (foreground, from this command layer), then apply its angle suggestions before presenting. + ## Step 6: Present Draft Present ONE draft with: diff --git a/plugins/linkedin-studio/commands/quick.md b/plugins/linkedin-studio/commands/quick.md index 941a383..f8ce101 100644 --- a/plugins/linkedin-studio/commands/quick.md +++ b/plugins/linkedin-studio/commands/quick.md @@ -14,6 +14,7 @@ allowed-tools: - Read - Bash - AskUserQuestion + - Task --- # Quick LinkedIn Post (5-Minute Workflow) @@ -144,6 +145,10 @@ Create the post, then verify: **All 6 = Yes? -> Ready to post.** +### De-AI / Differentiation Gate (fast) + +Even quick posts ride the low-substance down-rank LinkedIn confirmed. Confirm one concrete specific plus a genuine point of view (not generic advice), and no mechanical-response bait ("Comment YES", "Like for Part 2") — a real question is fine. (The voice-guardian hook scores this on save.) Only when the take feels like commodity content does an originality pass earn its time: delegate to the `differentiation-checker` agent via `Task` with `subagent_type: linkedin-studio:differentiation-checker` — otherwise keep the 5-minute promise and skip it. + ## Step 6: Present Draft Show the post with: diff --git a/plugins/linkedin-studio/commands/react.md b/plugins/linkedin-studio/commands/react.md index d26a9b0..c2334f1 100644 --- a/plugins/linkedin-studio/commands/react.md +++ b/plugins/linkedin-studio/commands/react.md @@ -14,6 +14,7 @@ allowed-tools: - WebFetch - Bash - AskUserQuestion + - Task --- # React to External Content — URL-to-Post Pipeline @@ -126,6 +127,12 @@ Verify against quality rules: - [ ] Topic aligns with expertise areas - [ ] CTA invites discussion, not just "What do you think?" +### De-AI / Differentiation Gate + +A reaction still has to add something only you can. Confirm the draft carries the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and uses no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. (The voice-guardian hook scores this on save.) + +If your take echoes the source instead of extending it — commodity reaction — delegate an originality pass to the `differentiation-checker` agent: invoke it via `Task` with `subagent_type: linkedin-studio:differentiation-checker` (foreground, from this command layer), then sharpen the angle before presenting. + ## Step 7: Present Draft Show: diff --git a/plugins/linkedin-studio/commands/video.md b/plugins/linkedin-studio/commands/video.md index 6e26fda..97246ff 100644 --- a/plugins/linkedin-studio/commands/video.md +++ b/plugins/linkedin-studio/commands/video.md @@ -121,6 +121,12 @@ Before presenting, verify the script passes the video quality gate: - [ ] Topic aligns with expertise pillars - [ ] No external links in post caption +### De-AI / Differentiation Gate + +The post caption rides the same low-substance down-rank LinkedIn confirmed for text. Confirm the script's core idea and caption carry the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and use no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. (The voice-guardian hook scores the caption on save.) + +If the idea is a take the audience has seen many times — commodity content — delegate an originality pass to the `differentiation-checker` agent: invoke it via `Task` with `subagent_type: linkedin-studio:differentiation-checker` (foreground, from this command layer), then sharpen the angle before presenting. + ## Step 6: Present the Script Present using the standardized output format: diff --git a/plugins/linkedin-studio/hooks/prompts/voice-guardian.md b/plugins/linkedin-studio/hooks/prompts/voice-guardian.md index 537d891..65ca5ba 100644 --- a/plugins/linkedin-studio/hooks/prompts/voice-guardian.md +++ b/plugins/linkedin-studio/hooks/prompts/voice-guardian.md @@ -13,6 +13,19 @@ Scan for these common AI writing patterns: If 3+ AI patterns detected, flag: 'Voice Guardian Alert: This content scores below authenticity threshold. AI patterns found: [list specific patterns]. Suggested fixes: [specific rewrites using natural language].' +### LinkedIn-named substance signals (official de-AI down-rank) + +LinkedIn confirmed (VP Laura Lorenzetti, 2026-05-19) an active program that **reach-suppresses** generic AI posts/comments and attention-bait — down to first-degree connections, not deletion — using ML trained on human-annotated "original thinking" vs "lacking substance." Beyond the generic tells above, check the draft for the four signals LinkedIn *named*. This is the differentiation surface, not an unverified SEO tell-list: + +- **Personal substance** — a lived detail, stake, or first-hand observation only this author has. Generic advice anyone could have written is the failure mode. +- **Original thinking** — a take or synthesis, not a restatement of the consensus. +- **Concrete specifics** — named tools, real numbers, a dated example — not abstract nouns. +- **Genuine voice** — reads as the author, not a model-default cadence. + +If two or more of these are missing, flag it alongside the AI-pattern alert: the post risks the low-substance down-rank, not merely sounding generic. + +**Soft engagement-bait check:** block mechanical-response CTAs — "Comment YES", "Like for Part 2", "DM me 'X'", "Repost if you agree" — which trigger a post-level throttle. A *genuine* open question is not penalized; the line is a real answer vs a reflexive token. + ## 2. Six-Dimension Voice Drift Scoring Read the voice profile and collected post samples from `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/authentic-voice-samples.md`. diff --git a/plugins/linkedin-studio/hooks/scripts/__tests__/linkedin-content-filter.test.mjs b/plugins/linkedin-studio/hooks/scripts/__tests__/linkedin-content-filter.test.mjs new file mode 100644 index 0000000..3a88e60 --- /dev/null +++ b/plugins/linkedin-studio/hooks/scripts/__tests__/linkedin-content-filter.test.mjs @@ -0,0 +1,87 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isLinkedInContent } from '../linkedin-content-filter.mjs'; + +// The short-form de-AI / differentiation gate (Step 14) is scoped by the same +// content/non-content boundary the content-gatekeeper hook uses. These tests +// pin that boundary so the gate fires on the short-form draft paths the five +// creation commands (post/quick/react/carousel/video) actually write to, and +// stays silent on plugin infrastructure (commands, hooks, references, config). + +describe('isLinkedInContent — short-form gate scope (positive)', () => { + test('fires on a draft post under assets/drafts/', () => { + assert.equal(isLinkedInContent('assets/drafts/2026-05-30-ai-governance.md'), true); + }); + + test('fires on a carousel slide image under assets/drafts/', () => { + assert.equal(isLinkedInContent('assets/drafts/carousel-20260530-ai/slide-1.png'), true); + }); + + test('fires on a nested absolute assets/drafts path', () => { + assert.equal( + isLinkedInContent('/Users/ktg/work/assets/drafts/video-script.md'), + true + ); + }); + + test('fires on a linkedin-posts content path', () => { + assert.equal(isLinkedInContent('/Users/ktg/linkedin-posts/quick-take.md'), true); + }); + + test('fires on a linkedin-studio/assets content path', () => { + assert.equal( + isLinkedInContent('/repo/plugins/linkedin-studio/assets/examples/post.md'), + true + ); + }); +}); + +describe('isLinkedInContent — gate stays silent on infrastructure (negative)', () => { + test('does not fire on a command file', () => { + assert.equal(isLinkedInContent('commands/post.md'), false); + }); + + test('does not fire on a hook prompt', () => { + assert.equal(isLinkedInContent('hooks/prompts/voice-guardian.md'), false); + }); + + test('does not fire on a reference doc', () => { + assert.equal(isLinkedInContent('references/linkedin-formats.md'), false); + }); + + test('does not fire on an agent definition', () => { + assert.equal(isLinkedInContent('agents/differentiation-checker.md'), false); + }); + + test('does not fire on a script or its tests', () => { + assert.equal(isLinkedInContent('hooks/scripts/state-updater.mjs'), false); + assert.equal( + isLinkedInContent('hooks/scripts/__tests__/linkedin-content-filter.test.mjs'), + false + ); + }); +}); + +describe('isLinkedInContent — code/config/template/meta files (negative)', () => { + test('does not fire on code/config extensions', () => { + for (const p of ['x.json', 'x.mjs', 'x.ts', 'x.yaml', 'x.css', 'x.html']) { + assert.equal(isLinkedInContent(p), false, `${p} should be non-content`); + } + }); + + test('does not fire on template files', () => { + assert.equal(isLinkedInContent('config/state-file.template.md'), false); + }); + + test('does not fire on known meta filenames', () => { + for (const p of ['CLAUDE.md', 'README.md', 'CHANGELOG.md', 'notes.local.md']) { + assert.equal(isLinkedInContent(p), false, `${p} should be non-content`); + } + }); + + test('returns false for empty or missing path', () => { + assert.equal(isLinkedInContent(''), false); + assert.equal(isLinkedInContent(null), false); + assert.equal(isLinkedInContent(undefined), false); + }); +});