fix(linkedin-studio): close v4.0.0 audit review findings (S8)
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 <noreply@anthropic.com>
This commit is contained in:
parent
1fa2cc945e
commit
18b198f655
8 changed files with 104 additions and 13 deletions
|
|
@ -46,7 +46,7 @@ Help creators:
|
||||||
|
|
||||||
**The math that most creators ignore:**
|
**The math that most creators ignore:**
|
||||||
- Comments rank above likes in the engagement order (see `references/algorithm-signals-reference.md`)
|
- 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
|
- Posts with 15+ engagements in first hour unlock 2nd/3rd degree distribution
|
||||||
- Your comments on others' posts expose you to their audience
|
- 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
|
- Commenting within 30 minutes of a post = 64% more follow-up engagement on your comment
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Carousel Templates
|
# 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
|
## Universal Design Specs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ name: linkedin:onboarding
|
||||||
description: |
|
description: |
|
||||||
Multi-step onboarding wizard that guides new users through profile → setup → first-post
|
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
|
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",
|
Triggers on: "onboarding", "get started", "new user", "setup wizard", "start from scratch",
|
||||||
"just installed", "how do I start", "walk me through", "linkedin onboarding".
|
"just installed", "how do I start", "walk me through", "linkedin onboarding".
|
||||||
allowed-tools:
|
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:batch` — Plan a full week of content in one session
|
||||||
- `/linkedin:react` — Turn articles and news into posts
|
- `/linkedin:react` — Turn articles and news into posts
|
||||||
- `/linkedin:strategy` — Growth strategy tailored to your follower level
|
- `/linkedin:strategy` — Growth strategy tailored to your follower level
|
||||||
- `/linkedin` — See all 26 commands anytime
|
- `/linkedin` — See all 27 commands anytime
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,42 @@ growth_rate_needed: 0
|
||||||
## Milestone Log
|
## 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
|
||||||
|
|
||||||
|
<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->
|
||||||
|
<!-- Format: ### [YYYY-MM-DD HH:MM] topic -->
|
||||||
|
|
||||||
|
## Outreach Pipeline
|
||||||
|
|
||||||
|
<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->
|
||||||
|
<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->
|
||||||
|
`;
|
||||||
|
|
||||||
describe('updatePostTracking', () => {
|
describe('updatePostTracking', () => {
|
||||||
test('sets last_post_date to provided date', () => {
|
test('sets last_post_date to provided date', () => {
|
||||||
const result = updatePostTracking(SAMPLE_STATE, {
|
const result = updatePostTracking(SAMPLE_STATE, {
|
||||||
|
|
@ -364,6 +400,28 @@ describe('recordFirstHourPlan', () => {
|
||||||
assert.ok(Array.isArray(result.changes));
|
assert.ok(Array.isArray(result.changes));
|
||||||
assert.ok(result.changes.length > 0);
|
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('<!-- First-hour / reply-loop plans, newest first. Written by /linkedin:firsthour. -->'));
|
||||||
|
assert.ok(result.content.includes('<!-- Format: ### [YYYY-MM-DD HH:MM] topic -->'));
|
||||||
|
// 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', () => {
|
describe('recordOutreachContact', () => {
|
||||||
|
|
@ -438,4 +496,22 @@ describe('recordOutreachContact', () => {
|
||||||
assert.ok(Array.isArray(result.changes));
|
assert.ok(Array.isArray(result.changes));
|
||||||
assert.ok(result.changes.length > 0);
|
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('<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->'));
|
||||||
|
assert.ok(result.content.includes('<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->'));
|
||||||
|
// 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'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -219,13 +219,16 @@ export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', ta
|
||||||
let content = stateContent;
|
let content = stateContent;
|
||||||
const changes = [];
|
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)) {
|
if (/^last_firsthour_date: .*/m.test(content)) {
|
||||||
content = replaceField(content, 'last_firsthour_date', `"${planDate}"`);
|
content = replaceField(content, 'last_firsthour_date', `"${planDate}"`);
|
||||||
|
changes.push(`last_firsthour_date → ${planDate}`);
|
||||||
} else if (/^last_post_date: .*/m.test(content)) {
|
} else if (/^last_post_date: .*/m.test(content)) {
|
||||||
content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_firsthour_date: "${planDate}"`);
|
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)
|
// 2. firsthour_active flag — only touch if the field is declared (additive)
|
||||||
if (/^firsthour_active: .*/m.test(content)) {
|
if (/^firsthour_active: .*/m.test(content)) {
|
||||||
|
|
@ -276,15 +279,18 @@ export function recordOutreachContact(stateContent, { contactDate, track = '', p
|
||||||
const changes = [];
|
const changes = [];
|
||||||
|
|
||||||
// 1. last_outreach_date — replace in place, else insert after last_firsthour_date
|
// 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)) {
|
if (/^last_outreach_date: .*/m.test(content)) {
|
||||||
content = replaceField(content, 'last_outreach_date', `"${contactDate}"`);
|
content = replaceField(content, 'last_outreach_date', `"${contactDate}"`);
|
||||||
|
changes.push(`last_outreach_date → ${contactDate}`);
|
||||||
} else if (/^last_firsthour_date: .*/m.test(content)) {
|
} else if (/^last_firsthour_date: .*/m.test(content)) {
|
||||||
content = content.replace(/^(last_firsthour_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
|
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)) {
|
} else if (/^last_post_date: .*/m.test(content)) {
|
||||||
content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`);
|
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)
|
// 2. outreach_active flag — only touch if the field is declared (additive)
|
||||||
if (/^outreach_active: .*/m.test(content)) {
|
if (/^outreach_active: .*/m.test(content)) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
### 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
|
- 6-10 slides optimal
|
||||||
- 100-150 characters per slide
|
- 100-150 characters per slide
|
||||||
- Caption: 300-500 characters
|
- 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)
|
2. **Shares** (High signal - people want to show others)
|
||||||
3. **Comments 15+ words** (High signal - 2x impact vs short comments)
|
3. **Comments 15+ words** (High signal - 2x impact vs short comments)
|
||||||
4. **Comments <15 words** (Medium signal)
|
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%**
|
**AI-generated generic comments reduce reach by 30% and engagement by 55%**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ Complete reference guide for growing from hundreds to thousands of engaged follo
|
||||||
|
|
||||||
**Comment Value:**
|
**Comment Value:**
|
||||||
- Comments rank above likes in the engagement order (saves > shares > quality comments > reactions; see `references/algorithm-signals-reference.md`)
|
- 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 over 15 words: **2x impact** vs shorter ones
|
||||||
- Comments from relevant professionals: Significantly higher weight than generic responses
|
- Comments from relevant professionals: Significantly higher weight than generic responses
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,8 +187,17 @@ echo "--- Algorithm-Stat Consistency ---"
|
||||||
# stale/competing magnitudes — and the unpublishable model brand/date — must not
|
# stale/competing magnitudes — and the unpublishable model brand/date — must not
|
||||||
# reappear anywhere else (cite the reference, do not restate). This enforces
|
# reappear anywhere else (cite the reference, do not restate). This enforces
|
||||||
# "one value per effect" by forbidding the known competing values from returning.
|
# "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
|
if [ -z "$STAT_HITS" ]; then
|
||||||
pass "no stale algorithm magnitudes / model brand outside the canonical reference"
|
pass "no stale algorithm magnitudes / model brand outside the canonical reference"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue