feat(linkedin-thought-leadership): v1.1.0 — Q2 2026 feature release
9 improvements across 3 tracks: Onboarding: /linkedin:onboarding wizard, README Quick Start rewrite Content Quality: voice drift scoring, industry angle variants, /linkedin:carousel, /linkedin:react multi-URL comparison Analytics: automated week-rollover, day-of-week heatmap, month-over-month reports 25→27 commands. All Q2 ROADMAP items completed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abf7322200
commit
1a8cc1942c
33 changed files with 1726 additions and 236 deletions
|
|
@ -16,14 +16,7 @@ Based on the day of the week, suggest the next optimal posting window:
|
|||
- Weekend: 10-11 AM CET (lower reach but less competition)
|
||||
|
||||
**3. 5x5x5 Engagement Reminder**
|
||||
Remind the user about the 5x5x5 engagement ritual:
|
||||
|
||||
> **5x5x5 Engagement Ritual** (15-20 min before AND in the first hour after posting):
|
||||
> - **5 comments** — find 5 people with overlapping audiences and leave thoughtful comments on their recent posts
|
||||
> - **5 connection requests** — send personalized requests to people who engaged with your niche today
|
||||
> - **5 replies** — reply to every comment on YOUR post within the first hour
|
||||
>
|
||||
> This signals active participation to LinkedIn's algorithm and boosts your post's initial distribution.
|
||||
Remind: 'Before posting, spend 15-20 minutes on 5x5x5 pre-engagement: find 5 people with overlapping audiences, comment thoughtfully on their recent posts.'
|
||||
|
||||
**4. Content Logging**
|
||||
Note: The post topic and hook should be logged to the state file when the session ends (handled by Stop hook).
|
||||
|
|
|
|||
|
|
@ -38,19 +38,20 @@ If a scheduled post was published during this session:
|
|||
|
||||
Provide reminders naturally based on what was done in the session. If no LinkedIn content was created, skip the reminders and just ensure state is consistent.
|
||||
|
||||
**4. Voice Sample Extraction** (if a post was created)
|
||||
**4. Voice Sample Collection** (if a post was created)
|
||||
|
||||
If a LinkedIn post was created or finalized in this session, consider extracting the hook line as a voice sample:
|
||||
If a LinkedIn post was created or finalized in this session, save the full post text as a voice sample:
|
||||
|
||||
- Read the hook line from the post that was just created
|
||||
- Read the full post text from the draft that was just created
|
||||
- Check if `assets/voice-samples/authentic-voice-samples.md` exists
|
||||
- If it does, suggest appending a new entry to the "## Update Log" section at the bottom:
|
||||
- Append the full post to the `## Collected Post Samples` section:
|
||||
```
|
||||
- [YYYY-MM-DD]: "[Hook text]" — [post type] (extracted from session post)
|
||||
### [YYYY-MM-DD] — [post type] ([char count] chars)
|
||||
[Full post text exactly as written]
|
||||
```
|
||||
- **Ask the user for approval before writing.** Say: "Would you like me to save this hook as a voice sample for future reference?"
|
||||
- Only write if the user approves
|
||||
- This passively grows the voice profile over time, improving personalization score
|
||||
- **Ask the user for confirmation** before writing: "I'll save this post as a voice sample for drift detection. OK?"
|
||||
- This builds the voice sample library that enables automatic drift scoring (needs 5+ samples for reliable scoring)
|
||||
- The more samples collected, the more accurate the voice-trainer's drift detection becomes
|
||||
|
||||
**5. Content History Log** (if a post was created)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
VOICE GUARDIAN — AI AUTHENTICITY CHECK: If the file being written/edited is LinkedIn content (post draft, article, or content file — NOT config, state, scripts, docs), check for AI-sounding patterns:
|
||||
VOICE GUARDIAN — DRIFT SCORING & AI AUTHENTICITY CHECK: If the file being written/edited is LinkedIn content (post draft, article, or content file — NOT config, state, scripts, docs), perform both AI detection and voice drift scoring:
|
||||
|
||||
## 1. AI Pattern Detection
|
||||
|
||||
**AI Pattern Detection:**
|
||||
Scan for these common AI writing patterns:
|
||||
- Generic openings: 'In today's rapidly evolving...', 'As we navigate...', 'In the ever-changing landscape...'
|
||||
- Filler phrases: 'It's worth noting that', 'It goes without saying', 'At the end of the day'
|
||||
|
|
@ -10,13 +11,42 @@ Scan for these common AI writing patterns:
|
|||
- Hedging language: 'It could be argued', 'One might say', 'Perhaps'
|
||||
- Perfect structure: Every paragraph exactly the same length
|
||||
|
||||
**Authenticity Score:**
|
||||
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].'
|
||||
|
||||
**Voice Matching:**
|
||||
If voice samples exist at `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/`, compare the writing style against the user's authentic voice patterns. Flag deviations.
|
||||
## 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`.
|
||||
|
||||
Score the draft against these 6 dimensions (0 = perfect match, 1 = minor drift per dimension):
|
||||
|
||||
| Dimension | What to Compare |
|
||||
|-----------|----------------|
|
||||
| **Sentence structure** | Average length, complexity, use of fragments vs. compound sentences |
|
||||
| **Word choice** | Vocabulary level, preferred/avoided words from voice profile |
|
||||
| **Opening patterns** | Hook style — does it match the user's signature openers? |
|
||||
| **Storytelling** | Anecdote usage, narrative arc, concrete vs. abstract |
|
||||
| **Tone markers** | Humor, directness, formality level, empathy signals |
|
||||
| **Formatting** | Paragraph length, whitespace, emoji usage, punctuation habits |
|
||||
|
||||
**Sum the 6 scores (0-6 total) and output a verdict:**
|
||||
|
||||
| Score | Verdict | Action |
|
||||
|-------|---------|--------|
|
||||
| 0-1 | AUTHENTIC | No changes needed |
|
||||
| 2-3 | CAUTION | Flag specific dimensions that drifted, suggest fixes |
|
||||
| 4-5 | ALERT | Significant drift — list all deviating dimensions with rewrites |
|
||||
| 6 | REWRITE | Content doesn't sound like the user — recommend starting over |
|
||||
|
||||
**Confidence gate:** If `## Collected Post Samples` has fewer than 5 posts, output: "Voice drift: LOW CONFIDENCE (X/5 samples). Scoring based on voice profile only." and score only against the profile description (dimensions 1-2 and 4-6), skipping opening patterns (dimension 3) which needs real samples.
|
||||
|
||||
**Output format (always include at end of system message):**
|
||||
```
|
||||
Voice Drift: [VERDICT] ([score]/6) [confidence: HIGH/LOW]
|
||||
[If CAUTION+: list dimensions that scored 1 with brief fix suggestion]
|
||||
```
|
||||
|
||||
## 3. Humanization Tips (for CAUTION or higher)
|
||||
|
||||
**Humanization Tips:**
|
||||
- Add specific personal anecdotes or observations
|
||||
- Use conversational contractions (I've, don't, it's)
|
||||
- Include imperfect/real-world examples
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyWeekRollover } from '../week-rollover.mjs';
|
||||
|
||||
const SAMPLE_STATE = `---
|
||||
last_post_date: "2026-04-05"
|
||||
first_post_date: "2026-01-15"
|
||||
last_post_topic: "AI strategy"
|
||||
posts_this_week: 3
|
||||
weekly_goal: 3
|
||||
current_streak: 5
|
||||
longest_streak: 12
|
||||
current_week: "2026-W14"
|
||||
last_import_date: "2026-04-01"
|
||||
follower_count: 850
|
||||
follower_target: 10000
|
||||
target_date: "2026-12-31"
|
||||
---
|
||||
|
||||
## Recent Posts
|
||||
- 2026-04-05: AI strategy post
|
||||
`;
|
||||
|
||||
describe('applyWeekRollover', () => {
|
||||
test('resets posts_this_week to 0 on week change', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^posts_this_week: 0$/m);
|
||||
});
|
||||
|
||||
test('updates current_week to new week', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^current_week: "2026-W15"$/m);
|
||||
});
|
||||
|
||||
test('returns descriptive message on rollover', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.message.includes('2026-W15'));
|
||||
assert.ok(result.message.includes('2026-W14'));
|
||||
});
|
||||
|
||||
test('returns null when week matches (no change needed)', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W14');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('preserves all other YAML fields unchanged', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
|
||||
assert.match(result.content, /^current_streak: 5$/m);
|
||||
assert.match(result.content, /^weekly_goal: 3$/m);
|
||||
assert.match(result.content, /^follower_count: 850$/m);
|
||||
});
|
||||
|
||||
test('preserves markdown body after frontmatter', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.ok(result.content.includes('## Recent Posts'));
|
||||
assert.ok(result.content.includes('AI strategy post'));
|
||||
});
|
||||
|
||||
test('initializes current_week when empty without resetting posts', () => {
|
||||
const stateWithEmptyWeek = SAMPLE_STATE.replace(
|
||||
'current_week: "2026-W14"',
|
||||
'current_week: ""'
|
||||
);
|
||||
const result = applyWeekRollover(stateWithEmptyWeek, '', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^current_week: "2026-W15"$/m);
|
||||
// posts_this_week should NOT be reset (user may have manually tracked)
|
||||
assert.match(result.content, /^posts_this_week: 3$/m);
|
||||
});
|
||||
|
||||
test('returns null when actualWeek is empty', () => {
|
||||
const result = applyWeekRollover(SAMPLE_STATE, '2026-W14', '');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('returns null when actualWeek is null/undefined', () => {
|
||||
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', null), null);
|
||||
assert.equal(applyWeekRollover(SAMPLE_STATE, '2026-W14', undefined), null);
|
||||
});
|
||||
|
||||
test('handles year boundary rollover (W52 → W01)', () => {
|
||||
const yearEndState = SAMPLE_STATE.replace('2026-W14', '2025-W52');
|
||||
const result = applyWeekRollover(yearEndState, '2025-W52', '2026-W01');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^posts_this_week: 0$/m);
|
||||
assert.match(result.content, /^current_week: "2026-W01"$/m);
|
||||
});
|
||||
|
||||
test('handles posts_this_week already at 0', () => {
|
||||
const zeroPostsState = SAMPLE_STATE.replace('posts_this_week: 3', 'posts_this_week: 0');
|
||||
const result = applyWeekRollover(zeroPostsState, '2026-W14', '2026-W15');
|
||||
assert.notEqual(result, null);
|
||||
assert.match(result.content, /^posts_this_week: 0$/m);
|
||||
assert.match(result.content, /^current_week: "2026-W15"$/m);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { join, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { calculateScore } from './personalization-score.mjs';
|
||||
import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs';
|
||||
import { applyWeekRollover } from './week-rollover.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
|
|
@ -135,11 +136,17 @@ if (existsSync(STATE_FILE)) {
|
|||
}
|
||||
}
|
||||
|
||||
// Week rollover check
|
||||
// Week rollover — auto-reset posts_this_week on week change
|
||||
const actualWeek = isoWeek();
|
||||
let weekResetNote = '';
|
||||
if (currentWeek && currentWeek !== actualWeek) {
|
||||
weekResetNote = `Note: Week has changed from ${currentWeek} to ${actualWeek}. posts_this_week should be reset to 0.`;
|
||||
try {
|
||||
const rollover = applyWeekRollover(stateContent, currentWeek, actualWeek);
|
||||
if (rollover) {
|
||||
writeFileSync(STATE_FILE, rollover.content, 'utf-8');
|
||||
weekResetNote = rollover.message;
|
||||
}
|
||||
} catch (err) {
|
||||
weekResetNote = `Warning: Week rollover failed (${err.message}). Manual reset may be needed.`;
|
||||
}
|
||||
|
||||
// Build status line
|
||||
|
|
@ -253,9 +260,7 @@ if (existsSync(STATE_FILE)) {
|
|||
}
|
||||
|
||||
// Personalization score check
|
||||
if (pScore !== null && pScore === 0) {
|
||||
context += '## Quick Win\\nPersonalization: 0%. Run /linkedin:setup (15 min) to unlock voice-matched, audience-specific content.\\n\\n';
|
||||
} else if (pScore !== null && pScore < 50) {
|
||||
if (pScore !== null && pScore < 50) {
|
||||
reminders += `- Personalization score is ${pScore}%. Run /linkedin:setup to improve content quality with your real voice, case studies, and audience data.\\n`;
|
||||
}
|
||||
|
||||
|
|
@ -369,13 +374,8 @@ if (existsSync(STATE_FILE)) {
|
|||
content = content.replace(/^current_week: .*/m, `current_week: "${actualWeek}"`);
|
||||
writeFileSync(STATE_FILE, content);
|
||||
context = `LinkedIn state file auto-initialized from template at ${STATE_FILE}.\\n`;
|
||||
context += `Current ISO week set to ${actualWeek}.\\n\\n`;
|
||||
context += '## Welcome to LinkedIn Thought Leadership\\n\\n';
|
||||
context += 'Your state file has been initialized. Here is how to get started:\\n\\n';
|
||||
context += '1. Run /linkedin:profile — Optimize your LinkedIn profile for 360Brew (critical before first post)\\n';
|
||||
context += '2. Run /linkedin:setup — Personalize with your voice, case studies, and audience data\\n';
|
||||
context += '3. Run /linkedin:first-post — Create your first post in under 10 minutes\\n\\n';
|
||||
context += 'Your personalization score is 0%. Content quality improves as you fill in your profile.\\n';
|
||||
context += `Current ISO week set to ${actualWeek}.\\n`;
|
||||
context += 'Edit the file to set your expertise_areas and weekly_goal.\\n';
|
||||
} else {
|
||||
context = `No LinkedIn state file found at ${STATE_FILE} and template missing.\\n`;
|
||||
context += `Expected template at: ${templateFile}\\n`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
// Pure function for week-rollover logic.
|
||||
// Exported separately for testability.
|
||||
|
||||
/**
|
||||
* Apply week rollover to state file content.
|
||||
* Returns updated content string if rollover was applied, null otherwise.
|
||||
*
|
||||
* @param {string} stateContent - Full state file content (with YAML frontmatter)
|
||||
* @param {string} currentWeek - Week value from state file (e.g. "2026-W14")
|
||||
* @param {string} actualWeek - Computed current ISO week (e.g. "2026-W15")
|
||||
* @returns {{ content: string, message: string } | null}
|
||||
*/
|
||||
export function applyWeekRollover(stateContent, currentWeek, actualWeek) {
|
||||
if (!actualWeek) return null;
|
||||
|
||||
// Case 1: current_week is empty — initialize without resetting posts
|
||||
if (!currentWeek) {
|
||||
const updated = stateContent.replace(
|
||||
/^current_week: .*/m,
|
||||
`current_week: "${actualWeek}"`
|
||||
);
|
||||
if (updated === stateContent) return null;
|
||||
return {
|
||||
content: updated,
|
||||
message: `Initialized current_week to ${actualWeek}.`
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: week matches — no action needed
|
||||
if (currentWeek === actualWeek) return null;
|
||||
|
||||
// Case 3: week changed — reset posts_this_week and update current_week
|
||||
let updated = stateContent;
|
||||
updated = updated.replace(
|
||||
/^posts_this_week: .*/m,
|
||||
'posts_this_week: 0'
|
||||
);
|
||||
updated = updated.replace(
|
||||
/^current_week: .*/m,
|
||||
`current_week: "${actualWeek}"`
|
||||
);
|
||||
|
||||
if (updated === stateContent) return null;
|
||||
|
||||
return {
|
||||
content: updated,
|
||||
message: `Auto-reset: posts_this_week → 0 for new week ${actualWeek} (was ${currentWeek}).`
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue