From 18b198f6554f922a7972d656ac62569251a8525b Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 30 May 2026 09:27:15 +0200 Subject: [PATCH] fix(linkedin-studio): close v4.0.0 audit review findings (S8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the 5 findings from the S7 /trekreview release gate (review.md, verdict BLOCK): - BLOCKER: comment-multiplier "5x" reconciled to the canonical order-only framing (no fixed multiplier) in agents/engagement-coach.md, linkedin-growth-playbook, linkedin-formats.md — per algorithm-signals-reference.md ("do not quote a comment multiplier"). - BLOCKER: carousel rate "6.60%/6.6% (highest)" reconciled to "~7% top organic format" in linkedin-formats.md:42 (was self-contradicting :50) and assets/templates/carousel-templates.md. - Lint hardening: test-runner.sh STALE_STATS now matches 6.60% + the 5x comment folklore and scans agents/ + assets/templates/ — the grep that defines the Phase-0 criterion now catches both BLOCKERs. - MAJOR: onboarding.md command count 26 -> 27. - MAJOR: add section-append-branch (production-path) tests for recordFirstHourPlan + recordOutreachContact against a template-layout fixture. - MINOR: move date-scalar changes.push inside the write branch in state-updater.mjs. Verify: node --test hooks/scripts/__tests__/*.test.mjs -> 92/92; bash scripts/test-runner.sh -> 66/0/0. NO push until /trekreview re-confirms ALLOW/WARN. Co-Authored-By: Claude Opus 4.8 --- .../agents/engagement-coach.md | 2 +- .../assets/templates/carousel-templates.md | 2 +- .../linkedin-studio/commands/onboarding.md | 4 +- .../scripts/__tests__/state-updater.test.mjs | 76 +++++++++++++++++++ .../hooks/scripts/state-updater.mjs | 14 +++- .../references/linkedin-formats.md | 4 +- .../linkedin-growth-playbook-2025-2026.md | 2 +- .../linkedin-studio/scripts/test-runner.sh | 13 +++- 8 files changed, 104 insertions(+), 13 deletions(-) diff --git a/plugins/linkedin-studio/agents/engagement-coach.md b/plugins/linkedin-studio/agents/engagement-coach.md index 717cb53..cf6da36 100644 --- a/plugins/linkedin-studio/agents/engagement-coach.md +++ b/plugins/linkedin-studio/agents/engagement-coach.md @@ -46,7 +46,7 @@ Help creators: **The math that most creators ignore:** - Comments rank above likes in the engagement order (see `references/algorithm-signals-reference.md`) -- Comments drive 5x more reach than reshares +- Substantive comments (15+ words) outweigh short ones and rank above plain reactions — but below saves and shares (no fixed comment-vs-reshare multiplier) - Posts with 15+ engagements in first hour unlock 2nd/3rd degree distribution - Your comments on others' posts expose you to their audience - Commenting within 30 minutes of a post = 64% more follow-up engagement on your comment diff --git a/plugins/linkedin-studio/assets/templates/carousel-templates.md b/plugins/linkedin-studio/assets/templates/carousel-templates.md index 9e05cea..09c6463 100644 --- a/plugins/linkedin-studio/assets/templates/carousel-templates.md +++ b/plugins/linkedin-studio/assets/templates/carousel-templates.md @@ -1,6 +1,6 @@ # Carousel Templates -Slide-by-slide blueprints for LinkedIn carousels (PDF document posts). Carousels have the highest engagement rate of all LinkedIn formats (6.6%) because they maximize dwell time and encourage swipe completion. +Slide-by-slide blueprints for LinkedIn carousels (PDF document posts). Carousels/documents are the top organic format on LinkedIn (~7%; see `references/algorithm-signals-reference.md`) because they maximize dwell time and encourage swipe completion. ## Universal Design Specs diff --git a/plugins/linkedin-studio/commands/onboarding.md b/plugins/linkedin-studio/commands/onboarding.md index 4bc29de..f6f5151 100644 --- a/plugins/linkedin-studio/commands/onboarding.md +++ b/plugins/linkedin-studio/commands/onboarding.md @@ -3,7 +3,7 @@ name: linkedin:onboarding description: | Multi-step onboarding wizard that guides new users through profile → setup → first-post as one cohesive flow. Designed for users who have just installed the plugin and want a - single guided path instead of navigating 26 commands on their own. + single guided path instead of navigating 27 commands on their own. Triggers on: "onboarding", "get started", "new user", "setup wizard", "start from scratch", "just installed", "how do I start", "walk me through", "linkedin onboarding". allowed-tools: @@ -214,4 +214,4 @@ First post: [Published DATE / Pending — run /linkedin:first-post] - `/linkedin:batch` — Plan a full week of content in one session - `/linkedin:react` — Turn articles and news into posts - `/linkedin:strategy` — Growth strategy tailored to your follower level -- `/linkedin` — See all 26 commands anytime +- `/linkedin` — See all 27 commands anytime diff --git a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs index 43433df..6bd82e4 100644 --- a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs +++ b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs @@ -36,6 +36,42 @@ growth_rate_needed: 0 ## Milestone Log `; +// Mirrors config/state-file.template.md: every template-initialized state file +// ships the ## First-Hour Plans and ## Outreach Pipeline sections pre-created, +// each already containing its two format comments. Real +// /linkedin:firsthour and /linkedin:outreach invocations therefore hit the +// section-APPEND branch — not the section-CREATE branch that SAMPLE_STATE (which +// omits the sections) exercises. These fixtures cover the production code path. +const TEMPLATE_STATE = `--- +last_post_date: "2026-04-05" +first_post_date: "2026-01-15" +last_post_topic: "AI strategy" +posts_this_week: 2 +weekly_goal: 3 +current_streak: 5 +longest_streak: 12 +follower_count: 850 +follower_target: 10000 +target_date: "2026-12-31" +--- + +# LinkedIn Session State + +## Recent Posts + +- [2026-04-05] "AI governance is not about..." (1450) - AI strategy + +## First-Hour Plans + + + + +## Outreach Pipeline + + + +`; + describe('updatePostTracking', () => { test('sets last_post_date to provided date', () => { const result = updatePostTracking(SAMPLE_STATE, { @@ -364,6 +400,28 @@ describe('recordFirstHourPlan', () => { assert.ok(Array.isArray(result.changes)); assert.ok(result.changes.length > 0); }); + + test('appends into the pre-existing First-Hour Plans section (production path) without duplicating it or dropping its format comments', () => { + // TEMPLATE_STATE ships the section + its two comments → the + // section-APPEND branch must fire (the path every real invocation takes), + // NOT section creation. + assert.ok(TEMPLATE_STATE.includes('## First-Hour Plans')); + const result = recordFirstHourPlan(TEMPLATE_STATE, PLAN_OPTS); + assert.notEqual(result, null); + // Heading not duplicated (the create branch did not also run) + const headings = result.content.match(/^## First-Hour Plans$/gm) || []; + assert.equal(headings.length, 1, 'section must not be duplicated'); + // Both template format comments survive the append + assert.ok(result.content.includes('')); + assert.ok(result.content.includes('')); + // Entry lands INSIDE the section (between its heading and the next section) + const section = result.content.slice( + result.content.indexOf('## First-Hour Plans'), + result.content.indexOf('## Outreach Pipeline') + ); + assert.ok(section.includes('[2026-05-30 09:00]'), 'entry must land inside the First-Hour Plans section'); + assert.ok(section.includes('AI governance')); + }); }); describe('recordOutreachContact', () => { @@ -438,4 +496,22 @@ describe('recordOutreachContact', () => { assert.ok(Array.isArray(result.changes)); assert.ok(result.changes.length > 0); }); + + test('appends into the pre-existing Outreach Pipeline section (production path) without duplicating it or dropping its format comments', () => { + // TEMPLATE_STATE ships the section + its two comments → the + // section-APPEND branch must fire, NOT section creation. + assert.ok(TEMPLATE_STATE.includes('## Outreach Pipeline')); + const result = recordOutreachContact(TEMPLATE_STATE, CONTACT_OPTS); + assert.notEqual(result, null); + // Heading not duplicated (the create branch did not also run) + const headings = result.content.match(/^## Outreach Pipeline$/gm) || []; + assert.equal(headings.length, 1, 'section must not be duplicated'); + // Both template format comments survive the append + assert.ok(result.content.includes('')); + assert.ok(result.content.includes('')); + // Entry lands INSIDE the section (it is the last section in the template) + const section = result.content.slice(result.content.indexOf('## Outreach Pipeline')); + assert.ok(section.includes('[2026-05-30 14:00]'), 'entry must land inside the Outreach Pipeline section'); + assert.ok(section.includes('@bigvoice')); + }); }); diff --git a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs index b9a6e7d..9e5972f 100644 --- a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs +++ b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs @@ -219,13 +219,16 @@ export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', ta let content = stateContent; const changes = []; - // 1. last_firsthour_date — replace in place, else insert after last_post_date (additive) + // 1. last_firsthour_date — replace in place, else insert after last_post_date (additive). + // Report the change only inside the branch that actually writes it: if neither + // anchor field exists, the scalar is not inserted and must not be reported as changed. if (/^last_firsthour_date: .*/m.test(content)) { content = replaceField(content, 'last_firsthour_date', `"${planDate}"`); + changes.push(`last_firsthour_date → ${planDate}`); } else if (/^last_post_date: .*/m.test(content)) { content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_firsthour_date: "${planDate}"`); + changes.push(`last_firsthour_date → ${planDate}`); } - changes.push(`last_firsthour_date → ${planDate}`); // 2. firsthour_active flag — only touch if the field is declared (additive) if (/^firsthour_active: .*/m.test(content)) { @@ -276,15 +279,18 @@ export function recordOutreachContact(stateContent, { contactDate, track = '', p const changes = []; // 1. last_outreach_date — replace in place, else insert after last_firsthour_date - // if present, else after last_post_date (additive — never required up front) + // if present, else after last_post_date (additive — never required up front). + // Report the change only inside the branch that actually writes it. if (/^last_outreach_date: .*/m.test(content)) { content = replaceField(content, 'last_outreach_date', `"${contactDate}"`); + changes.push(`last_outreach_date → ${contactDate}`); } else if (/^last_firsthour_date: .*/m.test(content)) { content = content.replace(/^(last_firsthour_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`); + changes.push(`last_outreach_date → ${contactDate}`); } else if (/^last_post_date: .*/m.test(content)) { content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`); + changes.push(`last_outreach_date → ${contactDate}`); } - changes.push(`last_outreach_date → ${contactDate}`); // 2. outreach_active flag — only touch if the field is declared (additive) if (/^outreach_active: .*/m.test(content)) { diff --git a/plugins/linkedin-studio/references/linkedin-formats.md b/plugins/linkedin-studio/references/linkedin-formats.md index b02908c..d50f6bf 100644 --- a/plugins/linkedin-studio/references/linkedin-formats.md +++ b/plugins/linkedin-studio/references/linkedin-formats.md @@ -39,7 +39,7 @@ Choosing the right format isn't just about engagement rates—it's about underst ### Engagement Rates by Format (With Strategic Context) -**1. Multi-image carousels: 6.60% engagement rate (highest)** +**1. Documents / carousels (PDF posts): top organic format (~7%; see `references/algorithm-signals-reference.md`)** - 6-10 slides optimal - 100-150 characters per slide - Caption: 300-500 characters @@ -171,7 +171,7 @@ The first hour after posting determines 70% of your post's total reach. See the 2. **Shares** (High signal - people want to show others) 3. **Comments 15+ words** (High signal - 2x impact vs short comments) 4. **Comments <15 words** (Medium signal) -5. **Reactions** (Lower signal - 5x less valuable than comments) +5. **Reactions** (Lower signal - baseline engagement unit; see `references/algorithm-signals-reference.md`) **AI-generated generic comments reduce reach by 30% and engagement by 55%** diff --git a/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md b/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md index 0564456..1ed5857 100644 --- a/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md +++ b/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md @@ -42,7 +42,7 @@ Complete reference guide for growing from hundreds to thousands of engaged follo **Comment Value:** - Comments rank above likes in the engagement order (saves > shares > quality comments > reactions; see `references/algorithm-signals-reference.md`) -- Comments: **5x more effective** than reshares +- Comments: high-value engagement signal, but ranked below saves and shares — no verified fixed multiplier - Comments over 15 words: **2x impact** vs shorter ones - Comments from relevant professionals: Significantly higher weight than generic responses diff --git a/plugins/linkedin-studio/scripts/test-runner.sh b/plugins/linkedin-studio/scripts/test-runner.sh index 3e2ee4e..e6ee8af 100755 --- a/plugins/linkedin-studio/scripts/test-runner.sh +++ b/plugins/linkedin-studio/scripts/test-runner.sh @@ -187,8 +187,17 @@ echo "--- Algorithm-Stat Consistency ---" # stale/competing magnitudes — and the unpublishable model brand/date — must not # reappear anywhere else (cite the reference, do not restate). This enforces # "one value per effect" by forbidding the known competing values from returning. -STALE_STATS='40-50%|25-40%|6\.6%|1\.92%|15x more reach|-40-60%|360Brew|January 2026' -STAT_HITS=$(grep -rnE "$STALE_STATS" references/ commands/ skills/ hooks/prompts/ CLAUDE.md README.md .claude-plugin/plugin.json 2>/dev/null | grep -v 'algorithm-signals-reference' || true) +# +# Pattern + scope must cover every file the criterion's grep covers. The carousel +# rate appears as the substring "6.60%" (which "6\.6%" does NOT match), and the +# retired comment multiplier as "5x more effective / 5x less valuable / 5x more +# reach than" — folklore the canonical reference forbids (it keeps only the +# engagement ORDER). Scope includes agents/ and assets/templates/ because the two +# v4.0.0 BLOCKERs survived there (agents/engagement-coach.md, assets/templates/ +# carousel-templates.md). assets/templates/ — not all of assets/ — keeps the scan +# off gitignored runtime data (assets/analytics/, assets/drafts/). +STALE_STATS='40-50%|25-40%|6\.6%|6\.60%|1\.92%|15x more reach|5x more effective|5x less valuable|5x more reach than|-40-60%|360Brew|January 2026' +STAT_HITS=$(grep -rnE "$STALE_STATS" references/ commands/ skills/ hooks/prompts/ agents/ assets/templates/ CLAUDE.md README.md .claude-plugin/plugin.json 2>/dev/null | grep -v 'algorithm-signals-reference' || true) if [ -z "$STAT_HITS" ]; then pass "no stale algorithm magnitudes / model brand outside the canonical reference" else