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:
Kjell Tore Guttormsen 2026-04-08 06:16:35 +02:00
commit 1a8cc1942c
33 changed files with 1726 additions and 236 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "linkedin-thought-leadership", "name": "linkedin-thought-leadership",
"version": "1.0.0", "version": "1.1.0",
"description": "Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and authentic engagement. Updated for the January 2026 360Brew algorithm change.", "description": "Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and authentic engagement. Updated for the January 2026 360Brew algorithm change.",
"author": { "author": {
"name": "Kjell Tore Guttormsen" "name": "Kjell Tore Guttormsen"

View file

@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2026-04-08
### Summary
Q2 2026 feature release. 9 improvements across onboarding, content quality, and analytics pipeline.
### Added
- **`/linkedin:onboarding`** — multi-step onboarding wizard: profile → setup → first-post as one guided flow
- **`/linkedin:carousel`** — structured multi-slide carousel generator with 5 templates and design specs
- **Voice drift scoring** — 6-dimension rubric (sentence structure, word choice, openings, storytelling, tone, formatting) with AUTHENTIC/CAUTION/ALERT/REWRITE verdicts in voice-guardian hook
- **Industry angle variants** — 48 concrete variants (6 industries × 8 angles) in thought-leadership-angles reference
- **Multi-URL comparison**`/linkedin:react` now supports 2-3 URL synthesis with contrarian and pattern analysis angles
- **Day-of-week heatmap**`heatmap` CLI command and `HeatmapReport` type in analytics pipeline
- **Month-over-month reports**`report --month YYYY-MM` CLI command with MoM deltas, weekly breakdown, top performers
- **Automated week-rollover** — session-start hook now writes `posts_this_week: 0` and updates `current_week` on ISO week change
- **Collected Post Samples** — Stop hook passively accumulates published posts in voice-samples file for drift scoring
### Changed
- **README Quick Start** — replaced 4-step manual flow with single `/linkedin:onboarding` entry point
- **`/linkedin:report`** — Step 2 now offers report type choice (weekly/monthly/heatmap)
- **`/linkedin:post`** — Step 2 shows industry-specific angles when user-profile has industry set; Step 3 redirects to carousel when appropriate
- **`/linkedin` router** — added onboarding and carousel to menus and direct routing
- **Command count** — 25 → 27 (onboarding, carousel)
## [1.0.0] - 2026-04-07 ## [1.0.0] - 2026-04-07
### Summary ### Summary

View file

@ -1,4 +1,4 @@
# LinkedIn Thought Leadership Plugin (v1.0.0) # LinkedIn Thought Leadership Plugin (v1.1.0)
Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and authentic engagement. January 2026 360Brew algorithm update integrated. Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and authentic engagement. January 2026 360Brew algorithm update integrated.
@ -27,11 +27,12 @@ Build LinkedIn thought leadership with algorithmic understanding, strategic cons
**Hook editing:** Edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts are loaded at runtime by gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks. **Hook editing:** Edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts are loaded at runtime by gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks.
## Commands (25) ## Commands (27)
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `/linkedin` | Router — status line + command menu | | `/linkedin` | Router — status line + command menu |
| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post) |
| `/linkedin:first-post` | First-post accelerator (10 min) | | `/linkedin:first-post` | First-post accelerator (10 min) |
| `/linkedin:setup` | Guided personalization setup | | `/linkedin:setup` | Guided personalization setup |
| `/linkedin:react` | URL-to-post pipeline | | `/linkedin:react` | URL-to-post pipeline |
@ -42,6 +43,7 @@ Build LinkedIn thought leadership with algorithmic understanding, strategic cons
| `/linkedin:batch` | Create a full week of content | | `/linkedin:batch` | Create a full week of content |
| `/linkedin:calendar` | View/manage post scheduling queue | | `/linkedin:calendar` | View/manage post scheduling queue |
| `/linkedin:publish` | Mark scheduled posts as published | | `/linkedin:publish` | Mark scheduled posts as published |
| `/linkedin:carousel` | Structured multi-slide carousel generator |
| `/linkedin:video` | Video script generator (30s-2min) | | `/linkedin:video` | Video script generator (30s-2min) |
| `/linkedin:multiplatform` | Adapt content for other platforms | | `/linkedin:multiplatform` | Adapt content for other platforms |
| `/linkedin:analyze` | Content/performance analysis | | `/linkedin:analyze` | Content/performance analysis |

View file

@ -4,15 +4,15 @@
*Built for my own Claude Code workflow and shared openly for anyone who finds it useful. This is a solo project — bug reports and feature requests are welcome, but pull requests are not accepted.* *Built for my own Claude Code workflow and shared openly for anyone who finds it useful. This is a solo project — bug reports and feature requests are welcome, but pull requests are not accepted.*
![Version](https://img.shields.io/badge/version-1.0.0-blue) ![Version](https://img.shields.io/badge/version-1.1.0-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Commands](https://img.shields.io/badge/commands-25-green) ![Commands](https://img.shields.io/badge/commands-27-green)
![Agents](https://img.shields.io/badge/agents-16-orange) ![Agents](https://img.shields.io/badge/agents-16-orange)
![Hooks](https://img.shields.io/badge/hooks-9-red) ![Hooks](https://img.shields.io/badge/hooks-9-red)
![Reference Docs](https://img.shields.io/badge/reference_docs-24-teal) ![Reference Docs](https://img.shields.io/badge/reference_docs-24-teal)
![License](https://img.shields.io/badge/license-MIT-lightgrey) ![License](https://img.shields.io/badge/license-MIT-lightgrey)
A comprehensive Claude Code plugin that turns LinkedIn from a chore into a system. It covers the full content lifecycle — from ideation and drafting through publishing, analytics, and growth strategy — with 25 slash commands, 16 specialized agents, 9 automated hooks, and a 24-document knowledge base grounded in LinkedIn's actual algorithm signals. Updated for the January 2026 **360Brew** algorithm change, where LinkedIn now validates your profile before distributing content. A comprehensive Claude Code plugin that turns LinkedIn from a chore into a system. It covers the full content lifecycle — from ideation and drafting through publishing, analytics, and growth strategy — with 27 slash commands, 16 specialized agents, 9 automated hooks, and a 24-document knowledge base grounded in LinkedIn's actual algorithm signals. Updated for the January 2026 **360Brew** algorithm change, where LinkedIn now validates your profile before distributing content.
--- ---
@ -82,55 +82,39 @@ Or add to your `~/.claude/settings.json`:
} }
``` ```
### First Conversation ### Get Started (5 minutes)
#### 1. Optimize Your Profile (Critical) Run the onboarding wizard — it walks you through profile, setup, and your first post in one flow:
With the 360Brew update, profile optimization is no longer optional — LinkedIn validates your profile before distributing content:
``` ```
/linkedin:profile /linkedin:onboarding
``` ```
#### 2. Personalize the Plugin The wizard handles everything: 360Brew profile checklist, voice and user profile setup, and a guided first post.
```bash ### Already Set Up?
cp config/user-profile.template.md config/user-profile.local.md
# Edit with your name, expertise, audience, voice, and goals
```
Then run the guided setup to populate all asset templates: | Goal | Command |
|------|---------|
``` | Write a post | `/linkedin:post` |
/linkedin:setup | Quick 5-min post | `/linkedin:quick` |
``` | React to an article | `/linkedin:react` |
| View your stats | `/linkedin:report` |
#### 3. Create Your First Post | See all commands | `/linkedin` |
```
/linkedin:post
> I want to write about how AI is changing public sector procurement
```
For a faster start, try the 5-minute quick post:
```
/linkedin:quick
```
#### 4. Explore All Commands
```
/linkedin
```
The router shows your current posting status (streak, weekly progress) and lists all available commands.
--- ---
## Commands ## Commands
All 25 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. All 26 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc.
### Onboarding
| Command | Description |
|---------|-------------|
| `/linkedin:onboarding` | Multi-step onboarding wizard — guides you through profile optimization, plugin personalization, and your first post in one flow. |
| `/linkedin:first-post` | First-post accelerator — zero to published in 10 minutes with guided hand-holding. |
| `/linkedin:setup` | Guided setup to populate empty asset templates with your real voice, case studies, and audience data. |
### Content Creation ### Content Creation
@ -146,7 +130,6 @@ All 25 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc.
| `/linkedin:video` | Video script generator for 30s, 60s, 90s, or 2-minute LinkedIn videos with pacing and visual cues. | | `/linkedin:video` | Video script generator for 30s, 60s, 90s, or 2-minute LinkedIn videos with pacing and visual cues. |
| `/linkedin:multiplatform` | Adapt LinkedIn content for Twitter/X threads, newsletter sections, blog posts, presentation slides, and YouTube scripts. | | `/linkedin:multiplatform` | Adapt LinkedIn content for Twitter/X threads, newsletter sections, blog posts, presentation slides, and YouTube scripts. |
| `/linkedin:react` | URL-to-post pipeline — paste an article, research paper, or news link and generate a reaction post. | | `/linkedin:react` | URL-to-post pipeline — paste an article, research paper, or news link and generate a reaction post. |
| `/linkedin:first-post` | First-post accelerator — zero to published in 10 minutes with guided hand-holding. |
### Analytics ### Analytics
@ -518,6 +501,7 @@ Scheduled posts are tracked in `assets/drafts/queue.json`:
| Version | Date | Highlights | | Version | Date | Highlights |
|---------|------|-----------| |---------|------|-----------|
| **1.1.0** | 2026-04-08 | Q2 feature release. 27 commands (+onboarding, +carousel). Week-rollover automation, voice drift scoring, industry content matrix, multi-URL react, day-of-week heatmap, month-over-month reports. |
| **1.0.0** | 2026-04-07 | Public release. 25 commands, 16 agents, 9 hooks, 6 skills, 24 reference docs. Agent model tiering (Sonnet/Haiku), all scripts Node.js, comprehensive documentation. | | **1.0.0** | 2026-04-07 | Public release. 25 commands, 16 agents, 9 hooks, 6 skills, 24 reference docs. Agent model tiering (Sonnet/Haiku), all scripts Node.js, comprehensive documentation. |
| **0.6.0** | 2026-02-07 | First formal version. 20 commands, 15 agents, 8 hooks, analytics system, 360Brew profile optimization, content matrix system, personalization engine, 20 reference documents. | | **0.6.0** | 2026-02-07 | First formal version. 20 commands, 15 agents, 8 hooks, analytics system, 360Brew profile optimization, content matrix system, personalization engine, 20 reference documents. |

View file

@ -1,6 +1,6 @@
# LinkedIn Thought Leadership Plugin — Roadmap # LinkedIn Thought Leadership Plugin — Roadmap
**Current version:** v1.0.0 (April 2026) **Current version:** v1.1.0 (April 2026)
**Scope:** Planned improvements through Q4 2026 **Scope:** Planned improvements through Q4 2026
Items organized by quarter and track. Priority = Impact / Effort (High/Medium/Low). Items within each quarter ordered by priority. Items organized by quarter and track. Priority = Impact / Effort (High/Medium/Low). Items within each quarter ordered by priority.
@ -16,23 +16,23 @@ Items organized by quarter and track. Priority = Impact / Effort (High/Medium/Lo
- [x] Condensed getting-started menu for zero-post users in `/linkedin` router - [x] Condensed getting-started menu for zero-post users in `/linkedin` router
- [x] Readiness check in `/linkedin:post` and `/linkedin:quick` for unpersonalized state - [x] Readiness check in `/linkedin:post` and `/linkedin:quick` for unpersonalized state
- [x] Inline 5x5x5 engagement ritual explanation - [x] Inline 5x5x5 engagement ritual explanation
- [ ] `/linkedin:onboarding` — dedicated multi-step onboarding command that guides profile → setup → first-post as one flow - [x] `/linkedin:onboarding` — dedicated multi-step onboarding command that guides profile → setup → first-post as one flow
- [ ] README Quick Start refinement — 5-minute getting-started path with screenshots - [x] README Quick Start refinement — 5-minute getting-started path with single `/linkedin:onboarding` entry point
### Content Quality ### Content Quality
**Priority: High** | **Effort: Medium** **Priority: High** | **Effort: Medium**
- [ ] Enhanced voice-trainer agent: automatic drift scoring on every post draft (compare against voice samples) - [x] Enhanced voice-trainer agent: automatic drift scoring on every post draft (compare against voice samples)
- [ ] Content Matrix improvements: add industry-specific angle variants - [x] Content Matrix improvements: add industry-specific angle variants
- [ ] Carousel post support: structured multi-slide content generation with visual layout guidance - [x] Carousel post support: structured multi-slide content generation with visual layout guidance
- [ ] `/linkedin:react` enhancement: multi-URL comparison posts (compare 2-3 articles) - [x] `/linkedin:react` enhancement: multi-URL comparison posts (compare 2-3 articles)
### Analytics Pipeline ### Analytics Pipeline
**Priority: Medium** | **Effort: Medium** **Priority: Medium** | **Effort: Medium**
- [ ] Automated week-rollover: session-start hook resets `posts_this_week` and updates `current_week` on week change (currently warn-only) - [x] Automated week-rollover: session-start hook resets `posts_this_week` and updates `current_week` on week change
- [ ] Post-level heatmap generation: day-of-week x time-of-day performance matrix from imported CSV data - [x] Post-level heatmap generation: day-of-week performance matrix from imported CSV data (time-of-day not available in CSV export)
- [ ] `/linkedin:report` month-over-month comparison view - [x] `/linkedin:report` month-over-month comparison view
--- ---

View file

@ -183,7 +183,7 @@ APRIL
MAY MAY
- Microsoft Build (typically May) → AI announcements - Microsoft Build (typically May) → AI announcements
- [National/regional holiday] → Cultural content - 17. mai (Norwegian National Day) → Cultural content
- End of spring conference season wrap-ups - End of spring conference season wrap-ups
JUNE JUNE

View file

@ -5,7 +5,7 @@ description: |
who to engage with, tracks engagement history, and guides the 5x5x5 method with who to engage with, tracks engagement history, and guides the 5x5x5 method with
specific people and posts to target. Includes connection request templates (300-char limit), specific people and posts to target. Includes connection request templates (300-char limit),
collaboration pitch templates, follow-up sequences (day 1-30), and connection scoring collaboration pitch templates, follow-up sequences (day 1-30), and connection scoring
criteria. criteria. Inherits DM template functionality from cancelled UPYOU-2078.
Use when the user says: Use when the user says:
- "who should I connect with", "networking strategy", "build my network" - "who should I connect with", "networking strategy", "build my network"

View file

@ -52,7 +52,7 @@ Before scanning, load the user's content pillars and expertise areas:
### Tier 1: Breaking News (daily, respond within 24-48h) ### Tier 1: Breaking News (daily, respond within 24-48h)
- **OpenAI**, **Anthropic**, **Microsoft AI**, **Google AI** -- blog posts and announcements - **OpenAI**, **Anthropic**, **Microsoft AI**, **Google AI** -- blog posts and announcements
- **EU/[your region's] government** AI regulatory decisions - **EU/Norwegian government** AI regulatory decisions
### Tier 2: Analysis & Research (2-3x/week, post within a week) ### Tier 2: Analysis & Research (2-3x/week, post within a week)

View file

@ -14,58 +14,145 @@ Claude will study these to understand your successful patterns and apply them to
--- ---
<!-- Add your posts here using this format: ## Post 1: Ralph Wiggum / Vibe Coding (BASELINE)
## Post 1: [Title/Topic] **Posted:** 2026-01-23, 23:13 CET (suboptimal timing)
**Engagement:** Likes: 19 | Comments: 6 | Shares: 0
**Posted:** [Date, time, timezone] **Reach:** 502 impressions
**Engagement:** Likes: [N] | Comments: [N] | Shares: [N] **Engagement Rate:** 4.98%
**Reach:** [N] impressions **Your Follower Count:** ~1,000
**Engagement Rate:** [N]%
**Your Follower Count:** ~[N]
**The Post:** **The Post:**
``` ```
[Paste your full post text here] 𝗘𝗻 𝗱𝗮𝗴. 𝟭𝟬 𝟬𝟬𝟬 𝗹𝗶𝗻𝗷𝗲𝗿. 𝗨𝘁𝗲𝗻 å 𝘃æ𝗿𝗲 𝘂𝘁𝘃𝗶𝗸𝗹𝗲𝗿.
Jeg er ikke utvikler. Jeg er KI-rådgiver. Jeg kan ikke skrive kode fra bunnen av.
Men jeg kan kommunisere med Claude Code. Og det viser seg at det er nok.
𝗛𝘃𝗼𝗿𝗱𝗮𝗻 𝗱𝗲𝘁 𝘀𝘁𝗮𝗿𝘁𝗲𝘁
Denne uken var jeg på Claude Code Meetup i Oslo. 250+ deltakere. Arrangert av Aleksander Stensby og Mesh Oslo.
Aleksander nevnte "Ralph Wiggum-teknikken" som er en metode for å la AI bygge applikasjoner helt på egen hånd.
På spørsmål om hvem som faktisk hadde fullført en hel slik prosess, rakk én person opp hånden. Av 250.
Den kvelden bestemte jeg meg: I morgen tester jeg dette.
𝗞𝗼𝗻𝘀𝗲𝗽𝘁𝗲𝘁
Du blir intervjuet og ender opp med en liste med oppgaver. Starter en prosess. Går og lager kaffe, eller sover.
Når du kommer tilbake er applikasjonen bygget.
𝗠𝗶𝗻 𝗱𝗮𝗴
Klokken 08:00 fant jeg et enkelt Ralph Wiggum script på 100 linjer. Klokken 23:00 hadde jeg 10 000 linjer og et komplett rammeverk.
Ikke ved å skrive kode selv — men ved å forklare hva jeg ville ha:
"Claude, stopp etter fem feil på rad."
"Claude, send meg Slack-melding når du er ferdig."
"Claude, lag en AI som vurderer om ting ser bra ut visuelt."
Claude foreslo løsninger. Jeg sa ja. Ferdig.
𝗙ø𝗹𝗲𝗹𝘀𝗲𝗻
Starte prosessen med 30 oppgaver. Gjør noe annet. Komme tilbake og se oppgavene tikke av. Én etter én.
Å våkne til en Slack-melding: "🎉 Ferdig. Alle 30 oppgaver fullført."
Å åpne mappen og se en fungerende app. Som jeg ikke skrev. Men som jeg 𝘥𝘦𝘧𝘪𝘯𝘦𝘳𝘵𝘦.
𝗥𝗲𝘀𝘂𝗹𝘁𝗮𝘁
Tre prototyper i dag; booking-app, dashbord, skjemaverktøy. Hver tok én time. Null linjer kode. Bare beskrivelser.
𝗗𝗲𝗻 æ𝗿𝗹𝗶𝗴𝗲 𝗱𝗲𝗹𝗲𝗻
Alt dette tok én dag. Og jeg skraper bare i overflaten.
Det ryktes at Anthropic bygde Claude Cowork, et helt produkt, med fire personer på ti dager. Vi er i starten av noe stort.
De som eksperimenterer nå kommer til å ha et forsprang. Det er ikke lenger AI som er begrensningen, det er deg og meg.
𝗦å 𝗷𝗮. 𝗥𝗮𝗹𝗽𝗵 𝗪𝗶𝗴𝗴𝘂𝗺.
Oppkalt etter Simpsons-karakteren som sier: "I'm learnding!"
Det føles passende :-)
Jeg jobber i KI-seksjonen i Statens vegvesen. Mer om dette og andre eksperimenter i kommende innlegg.
𝗧𝗶𝗽𝘀: Claude Code Meetup i Oslo arrangeres jevnlig, sjekk [lenke]
#AI #ClaudeCode #VibeCoding #StatensVegvesen #Innovasjon
``` ```
**Why It Worked:** **Why It Worked (Despite Mistakes):**
- **Hook:** [What made people stop scrolling?] - **Hook:** Strong - "En dag. 10 000 linjer. Uten å være utvikler." Creates immediate curiosity gap with specific numbers and contrast
- **Angle:** [What framing did you use?] - **Angle:** Personal Lesson + Discovery narrative - "I tried this, here's what happened"
- **Timing:** [Was the timing good/bad?] - **Timing:** FAILED - Posted 23:13, missed Golden Hour entirely
- **CTA:** [Did you include a call-to-action?] - **CTA:** MISSING - No engagement prompt at end
- **Key insight:** Concrete numbers (10,000 lines, 250 people, 1 person raised hand) create credibility
**Mistakes Made:**
1. Posted at 23:13 (should be 08:00)
2. Link in post body (should be in first comment)
3. 5 hashtags (should be 3-4)
4. No CTA (should ask question or invite discussion)
5. Em dash used (should avoid)
6. Post was in Norwegian (strategy says English)
**Pattern to Replicate:** **Pattern to Replicate:**
- [What can you reuse in future posts?] - Hook with specific numbers + contrast works well
- "I'm not X, but I did Y" framing creates relatability
- Concrete timeline (08:00 to 23:00) adds credibility
- "Følelsen" section (emotional payoff) resonates
- Bold-formatted section headers improve readability
**Audience Response Themes:** **Audience Response Themes:**
- [What did people comment about?] - Interest in the technical process
- Questions about Ralph Wiggum technique
- Recognition from Claude Code community
**What to Test Next:**
- Same quality content, but posted at 08:00
- With proper CTA
- Without link in body
- In English
--- ---
-->
## Patterns Across All High-Performing Posts ## Patterns Across All High-Performing Posts
**Common Elements:** **Common Elements:**
- [ ] Specific numbers in hook - [x] Specific numbers in hook (10,000 lines, 250 people)
- [ ] Personal story structure (I did X, here's what happened) - [x] Personal story structure (I did X, here's what happened)
- [ ] Concrete timeline and details - [x] Concrete timeline and details
- [ ] Strong CTA - [ ] Strong CTA (not yet tested)
- [ ] Optimal timing - [ ] Optimal timing (not yet tested)
**Audience Preferences (What YOUR Audience Responds To):** **Audience Preferences (What YOUR Audience Responds To):**
- Format: [Discover from your data] - Format: Story-based posts with concrete details
- Length: [Your typical length] - Length: ~2,100 characters (slightly over optimal 1,800)
- Tone: [Your tone pattern] - Tone: Professional but personal, showing vulnerability ("I'm not a developer")
- CTAs: [What works for your audience?] - CTAs: Unknown - need to test
**Topics That Resonate:** **Topics That Resonate:**
1. [Add after 3+ posts] 1. AI-assisted coding / Vibe coding
2. [More data needed] 2. [More data needed]
3. [More data needed] 3. [More data needed]
**Best Posting Times (Based on YOUR Data):** **Best Posting Times (Based on YOUR Data):**
- Primary: [Test and record] - Primary: Unknown - need to test 08:00 CET
- Secondary: [Test and record] - Secondary: Unknown - need to test
- **Avoid:** [Based on your data] - **Avoid:** After 21:00 (confirmed by Ralph Wiggum failure)
## Update Log
- 2026-01-24: Added Ralph Wiggum post as baseline reference. Note: Post had good engagement rate (4.98%) despite multiple mistakes, suggesting content quality is strong. Focus on fixing timing, CTA, and link placement for next posts.

View file

@ -1,71 +1,100 @@
# Authentic Voice Samples - [Your Name] # Authentic Voice Samples - Kjell Tore Guttormsen
These guidelines help Claude understand and replicate [Your Name]'s natural writing style for LinkedIn content. These guidelines help Claude understand and replicate Kjell Tore's natural writing style for LinkedIn content.
## Voice Profile Summary ## Voice Profile Summary
Fill in this section with your own writing characteristics. Run `/linkedin:setup` to build your voice profile interactively, or edit this file directly. 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.
--- ---
## Core Voice Characteristics ## Core Voice Characteristics
<!-- Replace these with your own voice traits. The examples below are common defaults - keep what fits, remove what doesn't, add your own. --> ### 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. [Your Primary Trait] ### 2. Factual Grounding
- [Describe how this trait shows up in your writing] - Statements are based on facts, not assumptions
- [What makes your approach distinctive?] - If uncertain, acknowledges uncertainty openly
- Prefers data and evidence over opinions
- Avoids speculation presented as fact
### 2. [Your Secondary Trait] ### 3. Non-Judgmental Tone
- [Describe how this trait shows up in your writing] - 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"
### 3. [Your Third Trait] ### 4. Curiosity and Openness
- [Describe how this trait shows up in your writing] - 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
<!-- Add more traits as needed. Most voice profiles have 4-6 core characteristics. --> ### 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 ## Cross-Sample Analysis
### Do's (Things that sound like [Your Name]) ### Do's (Things that sound like Kjell Tore)
<!-- Replace these with patterns from YOUR best-performing posts. Run /linkedin:setup to analyze your writing samples. The items below are common best practices you can keep as defaults. --> - ✅ 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)
- Start with stories or concrete examples before explaining concepts ### Don'ts (Things Kjell Tore would NEVER say)
- 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
- Frame discoveries as shared learning, not lecturing
- Keep posts concise - short to medium length (800-1500 characters)
### Don'ts (Things [Your Name] would NEVER say) - ❌ Don't use buzzwords: "game-changer", "leverage", "synergy", "disrupt", "revolutionize"
- ❌ Don't criticize people, companies, or decisions
<!-- Replace these with your personal anti-patterns. The items below are universal LinkedIn best practices. --> - ❌ Don't use self-deprecating humor
- ❌ Don't make assumptions without facts
- Don't use buzzwords: "game-changer", "leverage", "synergy", "disrupt", "revolutionize" - ❌ Don't write overly long posts (stay under 1500 characters for posts)
- Don't criticize people, companies, or decisions - ❌ Don't use more than 1-2 emojis per post
- Don't make assumptions without facts - ❌ Don't discuss politics, religion, or personal matters
- Don't write overly long posts (stay under 1500 characters for posts) - ❌ Don't use em dashes (—) - use hyphens or alternatives instead
- Don't use more than 1-2 emojis per post - ❌ Don't start with "Let's dive deep into..."
- Don't start with "Let's dive deep into..." - ❌ Don't use excessive exclamation marks!!!
- Don't use excessive exclamation marks - ❌ Don't use generic motivational phrases
- Don't use generic motivational phrases - ❌ Don't be preachy or lecture the reader
- Don't be preachy or lecture the reader - ❌ Don't use "we" when you mean "I" (be direct about personal experience)
--- ---
## Signature Phrases ## Signature Phrases
<!-- Add 3-5 phrases that are distinctly yours. These help Claude maintain your voice. --> Use these naturally when appropriate - don't force them:
- "[Your phrase 1]" - "Let me show you..."
- "[Your phrase 2]" - "What I've learned is..."
- "[Your phrase 3]" - "Here is the secret to..."
These phrases signal a transition to insight or demonstration. Use them to introduce key points or revelations.
--- ---
@ -73,11 +102,11 @@ Fill in this section with your own writing characteristics. Run `/linkedin:setup
### Technical Terms - How to Handle ### Technical Terms - How to Handle
<!-- Replace with your domain-specific terms. Examples: --> - **RAG (Retrieval-Augmented Generation):** Always explain on first use
- **MCP (Model Context Protocol):** Explain what it enables, not just the acronym
- **[Term 1]:** [How to explain/use it] - **Copilot Studio:** Can assume some familiarity with Microsoft ecosystem
- **[Term 2]:** [How to explain/use it] - **Skills (Claude):** Explain as "reusable instruction sets" or similar
- **[Term 3]:** [How to explain/use it] - **Low-code:** Generally understood, but clarify scope if needed
**Principle:** Assume intelligence, not knowledge. Explain jargon without being condescending. **Principle:** Assume intelligence, not knowledge. Explain jargon without being condescending.
@ -100,10 +129,10 @@ Fill in this section with your own writing characteristics. Run `/linkedin:setup
## Humor and Personality ## Humor and Personality
- **Humor style:** [Describe your humor approach - absent, dry, observational, etc.] - **Humor style:** Mostly absent in professional content. If humor appears, it's observational and gentle - never at anyone's expense
- **Self-deprecation:** [Your preference] - **Self-deprecation:** Never. Don't undermine your own credibility.
- **Cultural references:** [Your approach] - **Cultural references:** Avoid pop culture references. Stick to professional/work context.
- **Analogies:** [What kind of analogies work for your audience?] - **Analogies:** Use when helpful for explanation. Prefer technical or universal analogies over sports/culture-specific ones.
--- ---
@ -136,14 +165,14 @@ Match technical depth to the target audience:
- ROI and outcomes - ROI and outcomes
- Avoid implementation details - Avoid implementation details
### For Practitioners ### For Low-Code Developers
- Practical tips and patterns - Practical tips and patterns
- Step-by-step guidance - Step-by-step guidance
- Tool-specific insights - Tool-specific insights
- Common pitfalls and solutions - Common pitfalls and solutions
- Can include some technical detail - Can include some technical detail
### For Technical Experts ### For AI Architects
- Technical depth welcome - Technical depth welcome
- Architecture patterns - Architecture patterns
- Integration approaches - Integration approaches
@ -163,24 +192,36 @@ Match technical depth to the target audience:
## Language Guidelines ## Language Guidelines
- Choose ONE language for all LinkedIn content and stick with it - **Always English** for all LinkedIn content
- Clear, international English accessible to non-native speakers - Clear, international English accessible to non-native speakers
- Avoid idioms that don't translate well internationally - Avoid idioms that don't translate well internationally
- Prefer simple sentence structures for complex ideas - Prefer simple sentence structures for complex ideas
- Never use em dashes (—) - use hyphens, commas, or separate sentences instead
--- ---
## Instructions for Claude ## Instructions for Claude
When generating LinkedIn content for [Your Name]: When generating LinkedIn content for Kjell Tore:
1. **Start with the voice profile** (from this document) 1. **Start with his voice profile** (from this document)
2. **Check the content pillar** - which audience is this for? 2. **Check the content pillar** - which audience is this for?
3. **Choose appropriate storytelling technique** for the content type 3. **Choose appropriate storytelling technique** for the content type
4. **Ensure actionable conclusion** - what can the reader DO? 4. **Ensure actionable conclusion** - what can the reader DO?
5. **Verify against Don'ts list** - no buzzwords, no criticism, no assumptions 5. **Verify against Don'ts list** - no buzzwords, no criticism, no assumptions
6. **Keep length in check** - 800-1500 characters for posts 6. **Keep length in check** - 800-1500 characters for posts
**Priority:** Sound like [Your Name] > Optimize for algorithm **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 the voice in everything else. **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
## Collected Post Samples
<!-- Posts are saved here automatically by the Stop hook after each session 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,138 @@
---
name: linkedin:carousel
description: |
Create a LinkedIn carousel post with structured slide-by-slide content and visual layout guidance.
Carousels have the highest engagement rate (6.6%) on LinkedIn. Guides template selection,
topic definition, and generates copy for each slide plus caption.
Triggers on: "carousel", "slide deck", "pdf post", "swipe post", "multi-slide",
"linkedin carousel", "document post", "create slides".
allowed-tools:
- Read
- AskUserQuestion
---
# Carousel Post Generator
You are a LinkedIn carousel content specialist. Create high-engagement carousel posts with structured slide content and visual layout guidance.
## Step 0: Load Context
- Read `~/.claude/linkedin-thought-leadership.local.md` for posting state and expertise areas
- Read `assets/voice-samples/authentic-voice-samples.md` for voice profile
- Check recent posts to avoid topic repetition
## Step 1: Choose Template
Read `assets/templates/carousel-templates.md` for the 5 templates.
Present the options:
```
LinkedIn carousels get 6.6% average engagement — highest of all formats.
Choose a template:
1. How-To Guide — Teach a process step-by-step (8-10 slides)
2. Listicle / Top N — Curated list of tips, tools, or lessons (8-12 slides)
3. Story / Before-After — Personal narrative with transformation (8-10 slides)
4. Comparison / vs. — Side-by-side analysis of two approaches (8-10 slides)
5. Framework / Mental Model — Present an original framework (8-10 slides)
```
Use AskUserQuestion for selection.
## Step 2: Define Topic and Audience
Ask:
1. "What's the core topic or insight for this carousel?"
2. "Who is the primary audience? (e.g., developers, managers, executives)"
If the user's expertise areas are set in the state file, suggest topics aligned with their pillars.
## Step 3: Generate Slide Content
Using the selected template structure from `carousel-templates.md`, generate content for each slide.
**Output format for each slide:**
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SLIDE [N] of [TOTAL] — [Purpose from template]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
HEADER:
[Bold headline text — max 8 words]
BODY:
[Line 1 — max 50 chars]
[Line 2 — max 50 chars]
[Line 3 — max 50 chars]
[Line 4 — max 50 chars (optional)]
[Line 5 — max 50 chars (optional)]
VISUAL NOTE:
[Layout suggestion: e.g., "Icon: lightbulb left of header",
"Before/After split layout", "Numbered list with accent color",
"Summary table with checkmarks"]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
**Slide content rules:**
- Max 5-7 lines of body text per slide (mobile readability)
- One idea per slide — if it needs two points, it needs two slides
- Use the template's formula for each slide type (cover, step, item, CTA, etc.)
- Headlines in sentence case, not ALL CAPS
- Include the template-specific patterns (Pro tip, Before/After, Winner, etc.)
## Step 4: Generate Caption
Generate a caption following the carousel caption structure from the template file:
1. **Hook** (first line): Question, bold claim, or surprising stat — 110-140 chars
2. **Context** (1-2 lines): Why this matters to the audience
3. **Swipe prompt**: Reference a specific slide to create curiosity
4. **Engagement CTA**: Question that invites comments
5. **Hashtags**: 3-4 maximum
Target: 300-500 characters total.
Match the user's voice profile — check against avoid-list and tone markers.
## Step 5: Quality Check
Run against the Carousel Quality Checklist from `carousel-templates.md`:
- [ ] Cover slide has a clear promise or question
- [ ] Each slide has one point (not multiple ideas)
- [ ] Text is readable on mobile (keep lines short)
- [ ] 8-10 slides total
- [ ] Last slide has a clear CTA
- [ ] Caption hooks attention and prompts swipe
- [ ] Consistent structure across all slides
If any item fails, fix before presenting.
## Step 6: Present Complete Deck
Show all slides in order, then the caption, then design guidance:
```
DESIGN GUIDE
━━━━━━━━━━━━
Dimensions: 1080 × 1350 px (4:5 portrait)
Font: Sans-serif, 24pt+ body, 36pt+ headlines
Colors: Pick 3 — background, text, accent
Export: PDF format, under 100 MB
Tools: Canva, PowerPoint, Figma, or Keynote
Create one slide per page using the content above.
Export as PDF and upload directly to LinkedIn.
```
Use AskUserQuestion: "Want to refine any slides, or is this ready for design?"
## Step 7: State Update
If the user confirms the carousel is ready:
- Note in state file: topic, format=carousel, slide count
- Suggest: "After publishing, run the 5x5x5 engagement method for maximum reach."

View file

@ -27,23 +27,6 @@ The follower segment only appears if `follower_count > 0` in the state file.
If the state file doesn't exist, show: "No LinkedIn state tracked yet. State tracking starts when you create your first post." If the state file doesn't exist, show: "No LinkedIn state tracked yet. State tracking starts when you create your first post."
## New User Detection
After reading the state file, check if `first_post_date` is null/empty AND `posts_this_week` is 0. If so, this is a new user. Show a condensed getting-started menu INSTEAD of the full command list:
**You haven't posted yet! Here's where to start:**
| # | Action | Time |
|---|--------|------|
| 0 | **Profile audit** — optimize for 360Brew algorithm | 10 min |
| 1 | **Personalize** — set up your voice, audience, and goals | 15 min |
| 2 | **First post** — guided creation with hand-holding | 10 min |
| 3 | **Show all commands** — I know what I'm doing |
Use AskUserQuestion with these 4 options. Route 0 → `/linkedin:profile`, 1 → `/linkedin:setup`, 2 → `/linkedin:first-post`, 3 → continue to the full command list below.
**Skip this section entirely if `first_post_date` is set or `posts_this_week` > 0.** Proceed to Upcoming Posts and the full command list.
## Upcoming Posts ## Upcoming Posts
After the status line, show upcoming scheduled posts from the queue: After the status line, show upcoming scheduled posts from the queue:
@ -81,6 +64,7 @@ Present these options to the user:
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `/linkedin:onboarding` | Full onboarding wizard — profile, setup, and first post in one flow |
| `/linkedin:first-post` | First-post accelerator — zero to published in under 10 minutes | | `/linkedin:first-post` | First-post accelerator — zero to published in under 10 minutes |
| `/linkedin:setup` | Guided setup to populate empty asset templates with your real voice, case studies, and audience data | | `/linkedin:setup` | Guided setup to populate empty asset templates with your real voice, case studies, and audience data |
@ -93,6 +77,7 @@ Present these options to the user:
| `/linkedin:quick` | Fast 5-minute post using the 3-line formula | | `/linkedin:quick` | Fast 5-minute post using the 3-line formula |
| `/linkedin:templates` | Browse and apply proven post templates | | `/linkedin:templates` | Browse and apply proven post templates |
| `/linkedin:pipeline` | Full end-to-end workflow from idea to post-publish analysis | | `/linkedin:pipeline` | Full end-to-end workflow from idea to post-publish analysis |
| `/linkedin:carousel` | Create structured multi-slide carousel with visual layout guidance |
| `/linkedin:video` | Create video scripts with hook, body, CTA, captions, and thumbnail suggestions | | `/linkedin:video` | Create video scripts with hook, body, CTA, captions, and thumbnail suggestions |
| `/linkedin:batch` | Create a full week of content in one session | | `/linkedin:batch` | Create a full week of content in one session |
| `/linkedin:calendar` | View and manage your post scheduling queue | | `/linkedin:calendar` | View and manage your post scheduling queue |
@ -133,7 +118,7 @@ Use AskUserQuestion to ask:
**What would you like to do?** **What would you like to do?**
0. **First post** — Never posted? Start here (10 min) 0. **Onboarding wizard** — Just installed? Full guided flow: profile → setup → first post
1. **Setup & personalize** — Guided setup to populate voice, case studies, frameworks, and audience data 1. **Setup & personalize** — Guided setup to populate voice, case studies, frameworks, and audience data
2. **Create a post** — Full post workflow with angle selection 2. **Create a post** — Full post workflow with angle selection
3. **React to a URL** — Turn an article/news into a post 3. **React to a URL** — Turn an article/news into a post
@ -170,6 +155,7 @@ If the user already has content they want to turn into a post:
## Direct Routing ## Direct Routing
If the user's intent is clear from context: If the user's intent is clear from context:
- Mentions "onboarding" or "just installed" or "walk me through" or "setup wizard" or "start from scratch" → Route to `/linkedin:onboarding`
- Mentions "first post" or "never posted" or "get started" or "new to linkedin" or "help me start" → Route to `/linkedin:first-post` - Mentions "first post" or "never posted" or "get started" or "new to linkedin" or "help me start" → Route to `/linkedin:first-post`
- Mentions "setup" or "personalize" or "templates empty" or "score" or "fill in assets" or "configure plugin" → Route to `/linkedin:setup` - Mentions "setup" or "personalize" or "templates empty" or "score" or "fill in assets" or "configure plugin" → Route to `/linkedin:setup`
- Mentions "react" or "this article" or "this url" or "turn this into" or "share this news" → Route to `/linkedin:react` - Mentions "react" or "this article" or "this url" or "turn this into" or "share this news" → Route to `/linkedin:react`
@ -182,6 +168,7 @@ If the user's intent is clear from context:
- Mentions "profile" or "360Brew" → Route to `/linkedin:profile` - Mentions "profile" or "360Brew" → Route to `/linkedin:profile`
- Mentions "not working" or "low reach" → Route to `/linkedin:analyze` - Mentions "not working" or "low reach" → Route to `/linkedin:analyze`
- Mentions "strategy" or "growth plan" → Route to `/linkedin:strategy` - Mentions "strategy" or "growth plan" → Route to `/linkedin:strategy`
- Mentions "carousel" or "slides" or "slide deck" or "pdf post" or "swipe" or "document post" → Route to `/linkedin:carousel`
- Mentions "template" → Route to `/linkedin:templates` - Mentions "template" → Route to `/linkedin:templates`
- Mentions "audit" or "review strategy" → Route to `/linkedin:audit` - Mentions "audit" or "review strategy" → Route to `/linkedin:audit`
- Mentions "authority" or "signature content" → Route to `/linkedin:authority` - Mentions "authority" or "signature content" → Route to `/linkedin:authority`

View file

@ -0,0 +1,182 @@
---
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 25 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:
- Read
- Bash
- AskUserQuestion
---
# LinkedIn Onboarding Wizard
You are a LinkedIn thought leadership onboarding guide. Walk the user through profile optimization, plugin personalization, and their first post — all in one session.
## Step 0: Load Context and Check State
Read `~/.claude/linkedin-thought-leadership.local.md` for current state.
**Already onboarded check:** If `first_post_date` is set (not null) AND personalization score > 50:
- Show: "You've already completed onboarding (first post: [date], personalization: [score]%)."
- Use AskUserQuestion: "Would you like to re-run a specific phase?"
1. Re-optimize profile (360Brew) → jump to Phase 1
2. Improve personalization → jump to Phase 2
3. Create another post → suggest `/linkedin:post` or `/linkedin:quick`
4. Exit
If not already onboarded, continue to Phase 1.
## Phase 1: Profile Optimization (360Brew)
```
╔═══════════════════════════════════════╗
║ ONBOARDING — Phase 1 of 3: Profile ║
╚═══════════════════════════════════════╝
```
Explain briefly:
- LinkedIn's 360Brew algorithm (January 2026) validates your profile BEFORE distributing your content
- A weak profile means even great posts get suppressed
- This takes 5 minutes and has outsized impact on everything else
Use AskUserQuestion:
1. **Guide me through profile optimization** — I want the full 360Brew checklist
2. **Already optimized** — I've already done this, skip ahead
3. **Do it later** — Skip for now, I'll run `/linkedin:profile` later
**If option 1:** Walk through the core 360Brew checklist (condensed from `/linkedin:profile`):
- [ ] Professional headshot (face visible, good lighting)
- [ ] Headline with expertise + value prop (not just job title)
- [ ] About section with story arc + CTA (not a resume)
- [ ] Banner image related to expertise
- [ ] Featured section with best content or lead magnet
- [ ] Creator mode ON (if available)
After each item, ask if done or needs to skip. Don't block — mark skipped items as "recommended later."
**If option 2 or 3:** Move to Phase 2.
## Phase 2: Plugin Personalization
```
╔═════════════════════════════════════════════╗
║ ONBOARDING — Phase 2 of 3: Personalization ║
╚═════════════════════════════════════════════╝
```
Calculate personalization score:
```bash
node --input-type=module -e "
import { calculateScore } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/personalization-score.mjs';
const result = calculateScore('${CLAUDE_PLUGIN_ROOT}');
console.log(JSON.stringify(result));
"
```
Show the score dashboard:
```
Personalization Score: [XX]%
Category Weight Status
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Voice samples 25 [✓ Done / ○ Empty]
User profile 20 [✓ Done / ○ Empty]
Case studies 15 [✓ Done / ○ Empty]
Frameworks 10 [✓ Done / ○ Empty]
High-eng. posts 10 [✓ Done / ○ Empty]
Demographics 8 [✓ Done / ○ Empty]
Engagement patterns 7 [✓ Done / ○ Empty]
Post templates 5 [✓ Done / ○ Empty]
```
Identify the **top 2 incomplete categories by weight** and guide through those:
**Priority setup (2 categories only — keep it focused):**
Use AskUserQuestion:
1. **Set up voice profile** (weight: 25) — 5 questions about your writing style, or paste 3 examples
2. **Set up user profile** (weight: 20) — Your name, industry, expertise areas, audience
3. **Both** — Do voice + user profile now
4. **Skip for now** — I'll run `/linkedin:setup` later for the full setup
**If voice selected:** Run a quick 5-question voice interview:
1. "How would you describe your communication style in one sentence?"
2. "What words or phrases do you naturally use?" (give examples)
3. "What tone turns you off in LinkedIn content?"
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).
**If user profile selected:** Ask for:
1. Full name
2. Industry
3. Job title / role
4. 3-5 expertise areas (these become your content pillars)
5. Target audience description
Save to `config/user-profile.local.md`.
After setup, recalculate and show updated score.
## Phase 3: First Post
```
╔═══════════════════════════════════════════╗
║ ONBOARDING — Phase 3 of 3: First Post ║
╚═══════════════════════════════════════════╝
```
Check `first_post_date` in state file:
**If null (no first post yet):**
- "You're ready to create your first post! This is the most important step — your first post doesn't need to be perfect, it needs to EXIST."
- Use AskUserQuestion:
1. **Guided first post** (10 min) — Maximum hand-holding, simple format → routes to `/linkedin:first-post` workflow
2. **Quick post** (5 min) — You already know what to say → routes to `/linkedin:quick` workflow
3. **Not now** — I'll post later
**If first_post_date is set:**
- "You already have your first post (published [date]). Ready to create your next one?"
- Use AskUserQuestion:
1. **Create a new post** → suggest `/linkedin:post`
2. **Quick post** → suggest `/linkedin:quick`
3. **Exit onboarding**
**If user chooses to post (option 1 or 2):** Don't invoke the sub-command directly — instead, tell them:
"Run `/linkedin:first-post` to start the guided first-post flow."
or
"Run `/linkedin:quick` to create a quick post."
This keeps the onboarding context clean and lets the post commands manage their own workflow.
## Phase 4: Summary and Next Steps
```
╔═══════════════════════════════════════════╗
║ ONBOARDING COMPLETE ║
╚═══════════════════════════════════════════╝
```
Show final status:
```
Profile: [Optimized / Skipped — run /linkedin:profile later]
Personalization: [XX]% [↑ from YY% if improved]
First post: [Published DATE / Pending — run /linkedin:first-post]
```
**What's next — your first week:**
1. Create 2-3 posts this week (`/linkedin:post` or `/linkedin:quick`)
2. Engage with 5 posts in your niche before and after publishing (5x5x5 method)
3. Import your first analytics data after 7 days (`/linkedin:import`)
4. Run `/linkedin:report` after your first week to see what's working
**Power commands to explore:**
- `/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 25 commands anytime

View file

@ -23,16 +23,6 @@ You are a LinkedIn thought leadership content creator. Guide the user through cr
First, load persistent state and personalization: First, load persistent state and personalization:
- Read `~/.claude/linkedin-thought-leadership.local.md` for posting state (streak, weekly progress, recent topics) - Read `~/.claude/linkedin-thought-leadership.local.md` for posting state (streak, weekly progress, recent topics)
- Read `skills/linkedin-thought-leadership/SKILL.md` for user profile, voice settings, and preferences - Read `skills/linkedin-thought-leadership/SKILL.md` for user profile, voice settings, and preferences
- Read `config/user-profile.local.md` (if it exists) for expertise areas and audience
### Readiness Check
If `config/user-profile.local.md` doesn't exist OR `assets/voice-samples/authentic-voice-samples.md` contains `[Your Name]` in the title line, show this non-blocking notice:
> This plugin isn't personalized yet. Content will use generic best practices.
> Run `/linkedin:setup` after this session to unlock voice-matched content.
Then proceed normally — do not block content creation.
Check state for topic planning: Check state for topic planning:
- Compare intended topic against "Recent Posts" in state file - Compare intended topic against "Recent Posts" in state file
@ -73,6 +63,8 @@ If they provide a URL, use WebFetch to extract the content first.
Read `references/thought-leadership-angles.md` for the 8 universal angles. Read `references/thought-leadership-angles.md` for the 8 universal angles.
**Industry-specific angles:** If `config/user-profile.local.md` exists and has an `industry` field, check the "Industry Angle Variants" section in `thought-leadership-angles.md` for the matching industry table. Use the industry-specific starter questions and example hooks to generate more targeted angle suggestions.
Present 2-3 possible angles for their content: Present 2-3 possible angles for their content:
``` ```
@ -101,7 +93,7 @@ Based on content type, recommend a format:
| Frameworks/processes | Carousel or Native document | | Frameworks/processes | Carousel or Native document |
| Opinions/takes | Text-only medium post | | Opinions/takes | Text-only medium post |
If carousel, outline the slide structure. If carousel is the best format, recommend: "This topic works great as a carousel. Run `/linkedin:carousel` for the full slide-by-slide generator with 5 proven templates."
## Step 4: Structure and Write ## Step 4: Structure and Write

View file

@ -27,17 +27,6 @@ Read `skills/linkedin-thought-leadership/SKILL.md` for:
- Core expertise areas (for topical alignment) - Core expertise areas (for topical alignment)
- Phrases they commonly use - Phrases they commonly use
Read `config/user-profile.local.md` (if it exists) for expertise areas and audience.
### Readiness Check
If `config/user-profile.local.md` doesn't exist OR `assets/voice-samples/authentic-voice-samples.md` contains `[Your Name]` in the title line, show this non-blocking notice:
> This plugin isn't personalized yet. Content will use generic best practices.
> Run `/linkedin:setup` after this session to unlock voice-matched content.
Then proceed normally — do not block content creation.
Read `assets/quick-post-resources.md` for: Read `assets/quick-post-resources.md` for:
- Hooks bank - Hooks bank
- CTAs bank - CTAs bank

View file

@ -26,7 +26,7 @@ First, load persistent state and personalization:
- Read `assets/voice-samples/authentic-voice-samples.md` for voice profile - Read `assets/voice-samples/authentic-voice-samples.md` for voice profile
- Check recent posts to avoid topic repetition within 7 days - Check recent posts to avoid topic repetition within 7 days
## Step 1: Get the URL ## Step 1: Get URL(s)
If the user hasn't provided a URL, ask for one. Accept: If the user hasn't provided a URL, ask for one. Accept:
- News articles - News articles
@ -36,6 +36,17 @@ If the user hasn't provided a URL, ask for one. Accept:
- Company announcements - Company announcements
- Social media threads - Social media threads
**Multiple URLs:** If the user provides 2-3 URLs, or if you detect multiple links, use AskUserQuestion:
```
I see multiple URLs. Would you like to:
1. React to a single article (pick the most interesting one)
2. Compare and contrast 2-3 articles into one post
```
If option 2 → jump to **Comparison Path** (Step 1b below).
If option 1 or single URL → continue to Step 2.
## Step 2: Fetch and Analyze Content ## Step 2: Fetch and Analyze Content
Use WebFetch to extract the content from the URL. Ask WebFetch to extract: Use WebFetch to extract the content from the URL. Ask WebFetch to extract:
@ -143,6 +154,96 @@ After the post is finalized, update `~/.claude/linkedin-thought-leadership.local
- Update `longest_streak` if current exceeds it - Update `longest_streak` if current exceeds it
- Add entry to "## Recent Posts": [YYYY-MM-DD] "Hook text..." (char count) - topic - Add entry to "## Recent Posts": [YYYY-MM-DD] "Hook text..." (char count) - topic
---
## Comparison Path (Multi-URL)
When the user wants to compare 2-3 articles into one post.
### Step 1b: Collect URLs
Collect 2-3 URLs. Minimum 2, maximum 3. If the user provided them already, confirm the list.
### Step 2b: Fetch All Sources
Use WebFetch on each URL. For each, extract:
- **Title** and author/source
- **Key claims** (3-5 bullet points)
- **Stance/argument** — what position does the author take?
- **Data points** — any statistics or evidence cited
### Step 3b: Synthesis Analysis
Analyze across all sources:
| Dimension | Analysis |
|-----------|----------|
| **Common ground** | Where do the sources agree? |
| **Tension points** | Where do they disagree or contradict? |
| **Blind spots** | What are ALL of them missing? |
| **Your unique angle** | Given your expertise, what perspective do you add? |
### Step 4b: Choose Comparison Angle
Present 3 angles via AskUserQuestion:
1. **Synthesis** — "These perspectives seem opposed, but the truth is more nuanced. Here's how I connect them."
2. **Contrarian to all** — "Both/all articles miss the real issue. Here's what actually matters."
3. **Pattern analysis** — "The fact that [N] experts are all writing about [X] tells us something about [Y]."
### Step 5b: Generate Comparison Draft
Structure:
**Hook (110-140 chars):** Your synthesized perspective — NOT "I read 3 articles about..." Avoid mentioning the number of sources in the hook.
**The conversation (1-2 sentences):** Briefly describe the debate or trend ("There's a growing conversation about [X]. Perspectives range from [A] to [B].")
**Your lens (main body):**
- What the synthesis reveals that individual pieces miss
- Concrete example from your experience that connects the dots
- Where you agree and where you push back
**Implication (1-2 sentences):** What this convergence/divergence means for the audience.
**CTA:** Question that invites people to take a side or share their own synthesis.
### Critical Rules (comparison-specific):
- **NO URLs in post body** — all links go in first comment
- Post must stand alone without reading any of the sources
- Don't summarize each article — synthesize across them
- Your perspective is the star, not the articles
- Character target: 1,200-1,800 chars
### Step 6b: Quality Check
Same as Step 6, plus:
- [ ] Post is a synthesis, not a summary of each article
- [ ] Hook doesn't mention number of sources read
- [ ] Each source is credited in the first comment, not the post
### Step 7b: Present Draft
Show:
1. The main draft with character count
2. 2 alternative hooks
3. Suggested first comment with ALL URLs:
```
Sources referenced:
1. "[Title]" by [Author] — [URL]
2. "[Title]" by [Author] — [URL]
3. "[Title]" by [Author] — [URL] (if applicable)
```
4. Recommended posting time
Offer same refinement options as Step 7.
### Step 8b: State Update
Same as Step 8 — update state file with topic, increment counts, etc.
---
## Reference Files ## Reference Files
- `assets/voice-samples/authentic-voice-samples.md` — Voice matching - `assets/voice-samples/authentic-voice-samples.md` — Voice matching

View file

@ -37,18 +37,32 @@ You need to import your LinkedIn analytics first:
1. Run `/linkedin:import` to import CSV data 1. Run `/linkedin:import` to import CSV data
2. Then come back to generate reports 2. Then come back to generate reports
## Step 2: Determine Week to Report On ## Step 2: Choose Report Type
If no week specified, default to current week or most recent available data.
**Ask the user** using AskUserQuestion: **Ask the user** using AskUserQuestion:
```
What kind of report would you like?
1. Weekly report (default) — performance for a specific ISO week
2. Monthly report — month summary with month-over-month comparison
3. Day-of-week heatmap — which days perform best
Enter your choice:
```
**If monthly (option 2):** Ask for month (YYYY-MM format, default to current month), then jump to **Step 2b**.
**If heatmap (option 3):** Run the heatmap CLI command and jump to **Step 6c**.
**If weekly (option 1 or default):** Continue below.
### Weekly: Determine Week
``` ```
Which week would you like a report for? Which week would you like a report for?
Available options: Available options:
- "current" or "this week" - Current ISO week (2026-W05) - "current" or "this week" - Current ISO week
- "last week" - Previous ISO week (2026-W04) - "last week" - Previous ISO week
- Specific week: "2026-W03", "2025-W52", etc. - Specific week: "2026-W03", "2025-W52", etc.
- "latest" - Most recent week with data - "latest" - Most recent week with data
@ -62,6 +76,26 @@ To get current ISO week:
date +%Y-W%V date +%Y-W%V
``` ```
### Step 2b: Monthly Report
If the user chose monthly:
```bash
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --month <YYYY-MM>
```
Read the generated JSON from `assets/analytics/monthly-reports/<YYYY-MM>.json`. Present the monthly summary with MoM comparison deltas, weekly breakdown, and top performers. Then jump to Step 7 for deep-dive options.
### Step 2c: Heatmap
If the user chose heatmap:
```bash
ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" heatmap
```
Present the day-of-week matrix and best-day findings. Then jump to Step 7 for deep-dive options.
## Step 3: Run Report Generation ## Step 3: Run Report Generation
Execute the report CLI command: Execute the report CLI command:

View file

@ -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) - Weekend: 10-11 AM CET (lower reach but less competition)
**3. 5x5x5 Engagement Reminder** **3. 5x5x5 Engagement Reminder**
Remind the user about the 5x5x5 engagement ritual: Remind: 'Before posting, spend 15-20 minutes on 5x5x5 pre-engagement: find 5 people with overlapping audiences, comment thoughtfully on their recent posts.'
> **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.
**4. Content Logging** **4. Content Logging**
Note: The post topic and hook should be logged to the state file when the session ends (handled by Stop hook). Note: The post topic and hook should be logged to the state file when the session ends (handled by Stop hook).

View file

@ -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. 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 - 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?" - **Ask the user for confirmation** before writing: "I'll save this post as a voice sample for drift detection. OK?"
- Only write if the user approves - This builds the voice sample library that enables automatic drift scoring (needs 5+ samples for reliable scoring)
- This passively grows the voice profile over time, improving personalization score - The more samples collected, the more accurate the voice-trainer's drift detection becomes
**5. Content History Log** (if a post was created) **5. Content History Log** (if a post was created)

View file

@ -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: Scan for these common AI writing patterns:
- Generic openings: 'In today's rapidly evolving...', 'As we navigate...', 'In the ever-changing landscape...' - 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' - 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' - Hedging language: 'It could be argued', 'One might say', 'Perhaps'
- Perfect structure: Every paragraph exactly the same length - 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].' 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:** ## 2. Six-Dimension Voice Drift Scoring
If voice samples exist at `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/`, compare the writing style against the user's authentic voice patterns. Flag deviations.
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 - Add specific personal anecdotes or observations
- Use conversational contractions (I've, don't, it's) - Use conversational contractions (I've, don't, it's)
- Include imperfect/real-world examples - Include imperfect/real-world examples

View file

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

View file

@ -7,6 +7,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { calculateScore } from './personalization-score.mjs'; import { calculateScore } from './personalization-score.mjs';
import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs'; import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs';
import { applyWeekRollover } from './week-rollover.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..'); 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(); const actualWeek = isoWeek();
let weekResetNote = ''; let weekResetNote = '';
if (currentWeek && currentWeek !== actualWeek) { try {
weekResetNote = `Note: Week has changed from ${currentWeek} to ${actualWeek}. posts_this_week should be reset to 0.`; 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 // Build status line
@ -253,9 +260,7 @@ if (existsSync(STATE_FILE)) {
} }
// Personalization score check // Personalization score check
if (pScore !== null && pScore === 0) { if (pScore !== null && pScore < 50) {
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) {
reminders += `- Personalization score is ${pScore}%. Run /linkedin:setup to improve content quality with your real voice, case studies, and audience data.\\n`; 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}"`); content = content.replace(/^current_week: .*/m, `current_week: "${actualWeek}"`);
writeFileSync(STATE_FILE, content); writeFileSync(STATE_FILE, content);
context = `LinkedIn state file auto-initialized from template at ${STATE_FILE}.\\n`; context = `LinkedIn state file auto-initialized from template at ${STATE_FILE}.\\n`;
context += `Current ISO week set to ${actualWeek}.\\n\\n`; context += `Current ISO week set to ${actualWeek}.\\n`;
context += '## Welcome to LinkedIn Thought Leadership\\n\\n'; context += 'Edit the file to set your expertise_areas and weekly_goal.\\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';
} else { } else {
context = `No LinkedIn state file found at ${STATE_FILE} and template missing.\\n`; context = `No LinkedIn state file found at ${STATE_FILE} and template missing.\\n`;
context += `Expected template at: ${templateFile}\\n`; context += `Expected template at: ${templateFile}\\n`;

View file

@ -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}).`
};
}

View file

@ -122,6 +122,88 @@ These angles work across all industries because they're about **types of thinkin
- **Education:** Personal Lesson + Human Story - **Education:** Personal Lesson + Human Story
- **Consulting:** Pattern Recognition + Practical Breakdown - **Consulting:** Pattern Recognition + Practical Breakdown
## Industry Angle Variants
Concrete starter questions and example hooks per industry. When the user's industry is known (from `config/user-profile.local.md`), surface the relevant table during angle selection.
### Tech / Software / AI
| Angle | Starter Question | Example Hook |
|-------|-----------------|--------------|
| Contrarian | "What does everyone assume about [tech trend] that data disproves?" | "Everyone says AI will replace developers. Our team shipped 40% more code WITH AI — and hired 3 more engineers." |
| Pattern Recognition | "What pattern across AI/cloud/DevOps haven't others connected?" | "I've noticed every team that fails at AI adoption makes the same infrastructure mistake first." |
| Uncomfortable Truth | "What is the industry avoiding saying about [tool/trend]?" | "We spent 6 months fine-tuning an LLM. A prompt template outperformed it in 2 hours." |
| Future Implication | "If [current trend] continues, what changes in 2-3 years?" | "If AI coding assistants keep improving at this rate, the most valuable developer skill in 2028 won't be coding." |
| Personal Lesson | "What did your last failed project teach you about [topic]?" | "Our AI pilot looked perfect in the demo. Here's what happened when real users touched it." |
| Reframe | "What common tech term means something different than people think?" | "We call it 'technical debt.' I call it 'decisions that were right then and wrong now.'" |
| Practical Breakdown | "What complex concept can you make actionable in 5 steps?" | "Everyone talks about RAG. Here's the 4-step checklist I use before building any retrieval system." |
| Human Story | "What moment with a colleague or user changed your perspective?" | "Our senior architect said 'I don't understand this AI stuff' in a meeting. What happened next changed our entire approach." |
### Healthcare / Life Sciences
| Angle | Starter Question | Example Hook |
|-------|-----------------|--------------|
| Contrarian | "What healthcare 'best practice' actually slows patient outcomes?" | "We digitized all our patient records. Patient satisfaction dropped. Here's why paper had one advantage we overlooked." |
| Pattern Recognition | "What pattern connects clinical and operational challenges?" | "I've worked with 12 hospitals this year. The ones with the best patient outcomes all share one non-clinical habit." |
| Uncomfortable Truth | "What is healthcare leadership not willing to discuss openly?" | "The biggest barrier to healthcare AI isn't regulation. It's that clinicians don't trust their own data." |
| Future Implication | "If [health tech trend] succeeds, what changes for patients?" | "If ambient clinical documentation works as promised, the doctor-patient relationship fundamentally changes." |
| Personal Lesson | "What did a patient interaction teach you about [system/process]?" | "A patient told me: 'Your portal has 47 clicks to book an appointment.' That sentence restructured our entire digital strategy." |
| Reframe | "What healthcare metric measures the wrong thing?" | "We measure 'patient throughput.' What if we measured 'patient understanding' instead?" |
| Practical Breakdown | "What regulatory/compliance challenge can you simplify?" | "HIPAA compliance for AI tools sounds impossible. Here are the 3 questions that solve 80% of the uncertainty." |
| Human Story | "What patient story illustrates a systemic issue?" | "A nurse spent 4 hours on documentation for every 1 hour of patient care. She quit. Her exit interview should be mandatory reading for every CIO." |
### Finance / Banking / Insurance
| Angle | Starter Question | Example Hook |
|-------|-----------------|--------------|
| Contrarian | "What financial 'innovation' is actually recycled risk?" | "Everyone's excited about embedded finance. The banks that remember 2008 are asking different questions." |
| Pattern Recognition | "What pattern connects fintech disruption and traditional banking?" | "I've noticed every fintech that struggles at scale hits the same wall — the one banks solved 30 years ago." |
| Uncomfortable Truth | "What is the industry avoiding about [regulation/risk/AI]?" | "Banks are spending millions on AI fraud detection. The fraud teams say the biggest vulnerability is still a phone call." |
| Future Implication | "If [regulatory change] passes, what does banking look like?" | "If open banking delivers on its promise, the most valuable asset in finance won't be capital — it'll be consent." |
| Personal Lesson | "What did a risk event teach you that no framework captures?" | "We built a perfect risk model. It missed the one variable that mattered: human panic." |
| Reframe | "What financial concept needs a new definition?" | "We call it 'customer acquisition cost.' But in financial services, the real cost is trust — and trust doesn't have a line item." |
| Practical Breakdown | "What compliance requirement can you make less painful?" | "RegTech sounds complex. Here's the 3-layer approach that cut our compliance reporting time by 60%." |
| Human Story | "What client interaction revealed a blind spot?" | "A small business owner asked me: 'Why does your app need to know my mother's maiden name to send an invoice?' Fair point." |
### Public Sector / Government
| Angle | Starter Question | Example Hook |
|-------|-----------------|--------------|
| Contrarian | "What public sector 'modernization' approach actually creates more bureaucracy?" | "We 'digitized' our forms by turning PDFs into web forms. Citizens still needed to visit the office. That's not digital transformation." |
| Pattern Recognition | "What pattern connects successful government IT projects?" | "I've studied 20 public sector IT projects. The 5 that succeeded all broke the same procurement rule." |
| Uncomfortable Truth | "What is the sector avoiding about [digital transformation/AI/procurement]?" | "The biggest obstacle to government AI isn't budget or policy. It's that we measure success by project completion, not citizen outcome." |
| Future Implication | "If [policy/tech] is adopted, what changes for citizens?" | "If government agencies actually share data across departments, we can stop asking citizens to prove who they are 47 times." |
| Personal Lesson | "What did a failed initiative teach you about public sector change?" | "We launched a citizen portal. 6 months later, the call center was busier than ever. The lesson wasn't about technology." |
| Reframe | "What government process looks different from the citizen's perspective?" | "We call it 'case processing.' Citizens call it 'waiting to hear if I can keep my home.'" |
| Practical Breakdown | "What complex regulation/process can you make tangible?" | "Government procurement for AI services sounds impossible. Here are 3 contract clauses that unlock 80% of the innovation." |
| Human Story | "What citizen interaction changed how you think about service delivery?" | "A retired teacher spent 3 hours navigating our website for a pension form. She said: 'I taught 2,000 students to learn. Your website taught me to give up.'" |
### Education / EdTech
| Angle | Starter Question | Example Hook |
|-------|-----------------|--------------|
| Contrarian | "What education 'innovation' actually hurts learning outcomes?" | "We gave every student a laptop. Test scores didn't change. Classroom engagement dropped. Here's what we missed." |
| Pattern Recognition | "What do successful learning programs have in common?" | "I've observed 15 AI-in-education pilots. The ones students actually use all share one design principle." |
| Uncomfortable Truth | "What is the sector avoiding about [AI/assessment/equity]?" | "Personalized learning algorithms optimize for engagement. But engagement and learning aren't the same thing." |
| Future Implication | "If [AI/policy trend] continues, how does education change?" | "If AI tutors become genuinely good, the teacher's most valuable skill won't be content delivery — it'll be asking the right question at the right moment." |
| Personal Lesson | "What did a student/classroom experience teach you?" | "I watched a student use ChatGPT to write an essay, then spent 2 hours explaining it to a classmate. That's when I realized the assignment was wrong, not the student." |
| Reframe | "What education metric measures the wrong thing?" | "We measure 'time on task.' What if the best indicator of learning is how quickly a student can teach it to someone else?" |
| Practical Breakdown | "What complex pedagogical concept can you make actionable?" | "Bloom's Taxonomy is in every education textbook. Here's how I actually use it to design a single lesson in 15 minutes." |
| Human Story | "What student moment illustrates a bigger truth?" | "A 10-year-old told me: 'Why do I have to learn this if I can just ask AI?' My answer surprised both of us." |
### Consulting / Professional Services
| Angle | Starter Question | Example Hook |
|-------|-----------------|--------------|
| Contrarian | "What consulting 'framework' actually prevents insight?" | "The best strategy I ever delivered had zero frameworks. It had one question the CEO couldn't answer." |
| Pattern Recognition | "What pattern connects client problems across industries?" | "I've worked with 30 organizations on AI strategy. The ones that succeed all start with the same non-technical conversation." |
| Uncomfortable Truth | "What is the industry avoiding about [value delivery/pricing/AI]?" | "Most consulting engagements solve the stated problem. The real problem — the one nobody mentioned in the RFP — stays unsolved." |
| Future Implication | "If [AI/market trend] continues, how does consulting change?" | "If AI can generate a strategy deck in 10 minutes, the consulting industry needs to answer one question: what are we actually selling?" |
| Personal Lesson | "What project failure taught you something the methodology didn't?" | "I delivered a perfect change management plan. The client implemented 10% of it. My methodology was right. My assumption about people was wrong." |
| Reframe | "What consulting term means something different than clients think?" | "Clients ask for 'digital transformation.' What they actually need is 'permission to stop doing things that don't work.'" |
| Practical Breakdown | "What complex client challenge can you simplify?" | "AI readiness assessments take 6 weeks and cost €200K. Here are the 5 questions that tell you 80% of what you need in one meeting." |
| Human Story | "What client moment changed your consulting approach?" | "A CTO told me: 'Your recommendation is brilliant. My team will ignore it by Thursday.' That conversation changed how I deliver every project." |
## Red Flags (Avoid These) ## Red Flags (Avoid These)
- **Echo chamber:** Repeating what everyone already says - **Echo chamber:** Repeating what everyone already says

View file

@ -8,6 +8,8 @@ import {
import { detectAlerts } from "./utils/alerts.js"; import { detectAlerts } from "./utils/alerts.js";
import { mean, standardDeviation } from "./utils/stats.js"; import { mean, standardDeviation } from "./utils/stats.js";
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js"; import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
import { join } from "node:path"; import { join } from "node:path";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import type { PostMetrics } from "./models/types.js"; import type { PostMetrics } from "./models/types.js";
@ -27,7 +29,9 @@ LinkedIn Analytics CLI
Usage: Usage:
node build/cli.js import <filename> Import a CSV export node build/cli.js import <filename> Import a CSV export
node build/cli.js report [--week W] Generate weekly report node build/cli.js report [--week W] Generate weekly report
node build/cli.js report --month YYYY-MM Generate monthly report with MoM comparison
node build/cli.js trends [--period P] [--metric M] Show trends and alerts node build/cli.js trends [--period P] [--metric M] Show trends and alerts
node build/cli.js heatmap Day-of-week performance matrix
Options: Options:
--week W ISO week (e.g., 2026-W05), defaults to current week --week W ISO week (e.g., 2026-W05), defaults to current week
@ -95,6 +99,11 @@ async function handleImport(root: string, args: string[]) {
} }
async function handleReport(root: string, args: string[]) { async function handleReport(root: string, args: string[]) {
const monthOption = parseOption(args, "--month");
if (monthOption) {
return handleMonthlyReport(root, monthOption);
}
const weekOption = parseOption(args, "--week"); const weekOption = parseOption(args, "--week");
const week = weekOption || getCurrentISOWeek(); const week = weekOption || getCurrentISOWeek();
@ -285,6 +294,130 @@ async function handleTrends(root: string, args: string[]) {
} }
} }
async function handleMonthlyReport(root: string, month: string) {
console.log(`Generating monthly report for ${month}...`);
try {
const report = generateMonthlyReport(root, month);
console.log("\nMonthly Report");
console.log("═════════════════════════════════════");
console.log(`Month: ${report.month}`);
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
console.log();
console.log("Summary");
console.log("─────────────────────────────────────");
const s = report.summary;
const fmtDelta = (val: number | null, suffix = "%") =>
val !== null ? ` (${val > 0 ? "+" : ""}${val}${suffix})` : "";
console.log(`Posts: ${s.totalPosts}${fmtDelta(report.trends.percentChange.postCount)}`);
console.log(`Impressions: ${s.totalImpressions.toLocaleString()}${fmtDelta(report.trends.percentChange.impressions)}`);
console.log(`Avg per post: ${s.avgImpressionsPerPost.toLocaleString()}`);
console.log(`Avg engagement: ${s.avgEngagementRate.toFixed(2)}%${fmtDelta(report.trends.percentChange.engagement)}`);
console.log(`Reactions: ${s.totalReactions.toLocaleString()}`);
console.log(`Comments: ${s.totalComments.toLocaleString()}`);
console.log(`Shares: ${s.totalShares.toLocaleString()}`);
console.log(`Clicks: ${s.totalClicks.toLocaleString()}`);
console.log();
if (report.byWeek.length > 0) {
console.log("Week Breakdown");
console.log("─────────────────────────────────────");
for (const w of report.byWeek) {
console.log(`${w.week}: ${w.postCount} posts | ${w.avgImpressions.toLocaleString()} avg impr | ${w.avgEngagementRate.toFixed(1)}% eng`);
}
console.log();
}
if (report.topPerformers.length > 0) {
console.log("Top Performers");
console.log("─────────────────────────────────────");
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng | ${post.publishedDate}`);
}
console.log();
}
if (report.trends.comparedTo) {
console.log(`Compared to: ${report.trends.comparedTo}`);
} else {
console.log("No previous month data for comparison.");
}
console.log();
if (report.alerts.length > 0) {
console.log("Alerts");
console.log("─────────────────────────────────────");
for (const alert of report.alerts) {
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "";
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
}
console.log();
}
console.log(`Report saved to: monthly-reports/${month}.json`);
} catch (err) {
console.error(`Error generating monthly report: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function handleHeatmap(root: string) {
console.log("Generating day-of-week heatmap...");
try {
const allPosts = loadAllPosts(root);
if (allPosts.length === 0) {
console.error("Error: No posts found. Import some data first.");
process.exit(1);
}
const report = generateHeatmap(allPosts);
console.log("\nDay-of-Week Performance Heatmap");
console.log("═════════════════════════════════════");
console.log(`Posts analyzed: ${report.postsAnalyzed}`);
console.log(`Date range: ${report.dateRange.from} to ${report.dateRange.to}`);
console.log();
// Print table header
const days = report.byDayOfWeek.map(d => d.dayName.slice(0, 3).padStart(7));
console.log(` ${days.join("")}`);
console.log(` ${"───────".repeat(7)}`);
// Posts row
const postCounts = report.byDayOfWeek.map(d => String(d.postCount).padStart(7));
console.log(`Posts: ${postCounts.join("")}`);
// Impressions row
const impressions = report.byDayOfWeek.map(d =>
d.postCount > 0 ? d.avgImpressions.toLocaleString().padStart(7) : " -"
);
console.log(`Impr: ${impressions.join("")}`);
// Engagement rate row
const engRates = report.byDayOfWeek.map(d =>
d.postCount > 0 ? `${d.avgEngagementRate.toFixed(1)}%`.padStart(7) : " -"
);
console.log(`Eng: ${engRates.join("")}`);
console.log();
console.log(`Best day for impressions: ${report.bestDayImpressions}`);
console.log(`Best day for engagement: ${report.bestDayEngagement}`);
console.log("\nNote: LinkedIn CSV exports do not include publish time.");
console.log("This heatmap shows day-of-week only.");
} catch (err) {
console.error(`Error generating heatmap: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function main() { async function main() {
const root = getAnalyticsRoot(); const root = getAnalyticsRoot();
ensureDirectories(root); ensureDirectories(root);
@ -299,6 +432,9 @@ async function main() {
case "trends": case "trends":
await handleTrends(root, args); await handleTrends(root, args);
break; break;
case "heatmap":
await handleHeatmap(root);
break;
default: default:
printUsage(); printUsage();
process.exit(command ? 1 : 0); process.exit(command ? 1 : 0);

View file

@ -65,6 +65,55 @@ export interface Alert {
deviations: number; deviations: number;
} }
export interface DayOfWeekMetrics {
dayName: string; // "Monday" through "Sunday"
dayIndex: number; // 1=Monday, 7=Sunday (ISO weekday)
postCount: number;
avgImpressions: number;
avgEngagementRate: number;
bestPost?: PostAnalytics;
}
export interface HeatmapReport {
generatedAt: string;
postsAnalyzed: number;
dateRange: { from: string; to: string };
byDayOfWeek: DayOfWeekMetrics[]; // 7 entries, Mon-Sun ordered
bestDayImpressions: string;
bestDayEngagement: string;
}
export interface MonthlyReport {
month: string; // "YYYY-MM"
generatedAt: string;
summary: {
totalPosts: number;
totalImpressions: number;
totalReactions: number;
totalComments: number;
totalShares: number;
totalClicks: number;
avgEngagementRate: number;
avgImpressionsPerPost: number;
};
topPerformers: PostAnalytics[];
byWeek: {
week: string;
postCount: number;
avgImpressions: number;
avgEngagementRate: number;
}[];
trends: {
comparedTo: string | null;
percentChange: {
impressions: number | null;
engagement: number | null;
postCount: number | null;
};
};
alerts: Alert[];
}
export const ALERT_THRESHOLDS = { export const ALERT_THRESHOLDS = {
spike: 2.0, spike: 2.0,
drop: -1.5, drop: -1.5,

View file

@ -0,0 +1,85 @@
import type { PostAnalytics, DayOfWeekMetrics, HeatmapReport } from "../models/types.js";
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
// Convert JS getDay() (0=Sun) to ISO weekday (1=Mon, 7=Sun)
function toISOWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay;
}
/**
* Generate a day-of-week performance heatmap from post analytics data.
* Groups posts by day of week and calculates average metrics per day.
*/
export function generateHeatmap(posts: PostAnalytics[]): HeatmapReport {
// Initialize buckets for all 7 days (ISO: 1=Mon to 7=Sun)
const buckets: Map<number, PostAnalytics[]> = new Map();
for (let i = 1; i <= 7; i++) {
buckets.set(i, []);
}
// Group posts by ISO weekday
for (const post of posts) {
const jsDay = new Date(post.publishedDate).getUTCDay();
const isoDay = toISOWeekday(jsDay);
buckets.get(isoDay)!.push(post);
}
// Build metrics per day
const byDayOfWeek: DayOfWeekMetrics[] = [];
for (let isoDay = 1; isoDay <= 7; isoDay++) {
const dayPosts = buckets.get(isoDay)!;
const jsDay = isoDay === 7 ? 0 : isoDay;
const dayName = DAY_NAMES[jsDay];
if (dayPosts.length === 0) {
byDayOfWeek.push({
dayName,
dayIndex: isoDay,
postCount: 0,
avgImpressions: 0,
avgEngagementRate: 0,
});
continue;
}
const totalImpressions = dayPosts.reduce((sum, p) => sum + p.metrics.impressions, 0);
const totalEngagement = dayPosts.reduce((sum, p) => sum + p.metrics.engagementRate, 0);
const bestPost = dayPosts.reduce((best, p) =>
p.metrics.impressions > best.metrics.impressions ? p : best
);
byDayOfWeek.push({
dayName,
dayIndex: isoDay,
postCount: dayPosts.length,
avgImpressions: Math.round(totalImpressions / dayPosts.length),
avgEngagementRate: parseFloat((totalEngagement / dayPosts.length).toFixed(1)),
bestPost,
});
}
// Find best days
const daysWithPosts = byDayOfWeek.filter(d => d.postCount > 0);
const bestDayImpressions = daysWithPosts.length > 0
? daysWithPosts.reduce((best, d) => d.avgImpressions > best.avgImpressions ? d : best).dayName
: "N/A";
const bestDayEngagement = daysWithPosts.length > 0
? daysWithPosts.reduce((best, d) => d.avgEngagementRate > best.avgEngagementRate ? d : best).dayName
: "N/A";
// Date range
const sortedDates = posts.map(p => p.publishedDate).sort();
const dateRange = posts.length > 0
? { from: sortedDates[0], to: sortedDates[sortedDates.length - 1] }
: { from: "", to: "" };
return {
generatedAt: new Date().toISOString(),
postsAnalyzed: posts.length,
dateRange,
byDayOfWeek,
bestDayImpressions,
bestDayEngagement,
};
}

View file

@ -0,0 +1,117 @@
import type { PostAnalytics, MonthlyReport } from "../models/types.js";
import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js";
import { mean } from "../utils/stats.js";
import { detectAlerts } from "../utils/alerts.js";
import { getISOWeek } from "./weekly.js";
/**
* Get previous month string (e.g., "2026-03" "2026-02")
*/
function getPreviousMonth(month: string): string {
const [year, m] = month.split("-").map(Number);
if (m === 1) return `${year - 1}-12`;
return `${year}-${String(m - 1).padStart(2, "0")}`;
}
/**
* Generate a monthly report with optional MoM comparison.
* Saves the report to disk and returns it.
*/
export function generateMonthlyReport(root: string, month: string): MonthlyReport {
const allPosts = loadAllPosts(root);
const monthPosts = allPosts.filter(p => p.publishedDate.startsWith(month));
// Summary
const totalPosts = monthPosts.length;
const totalImpressions = monthPosts.reduce((s, p) => s + p.metrics.impressions, 0);
const totalReactions = monthPosts.reduce((s, p) => s + p.metrics.reactions, 0);
const totalComments = monthPosts.reduce((s, p) => s + p.metrics.comments, 0);
const totalShares = monthPosts.reduce((s, p) => s + p.metrics.shares, 0);
const totalClicks = monthPosts.reduce((s, p) => s + p.metrics.clicks, 0);
const avgEngagementRate = totalPosts > 0
? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2))
: 0;
const avgImpressionsPerPost = totalPosts > 0
? Math.round(totalImpressions / totalPosts)
: 0;
// Top performers (sorted by impressions desc)
const topPerformers = [...monthPosts]
.sort((a, b) => b.metrics.impressions - a.metrics.impressions)
.slice(0, 5);
// Weekly breakdown
const weekBuckets = new Map<string, PostAnalytics[]>();
for (const post of monthPosts) {
const week = getISOWeek(new Date(post.publishedDate + "T00:00:00Z"));
if (!weekBuckets.has(week)) weekBuckets.set(week, []);
weekBuckets.get(week)!.push(post);
}
const byWeek = Array.from(weekBuckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([week, posts]) => ({
week,
postCount: posts.length,
avgImpressions: Math.round(mean(posts.map(p => p.metrics.impressions))),
avgEngagementRate: parseFloat(mean(posts.map(p => p.metrics.engagementRate)).toFixed(1)),
}));
// MoM comparison
const prevMonth = getPreviousMonth(month);
const prevReport = loadMonthlyReport(root, prevMonth);
let trends: MonthlyReport["trends"];
if (prevReport && prevReport.summary.totalPosts > 0) {
const pctImpr = prevReport.summary.totalImpressions > 0
? parseFloat(((totalImpressions - prevReport.summary.totalImpressions) / prevReport.summary.totalImpressions * 100).toFixed(1))
: null;
const pctEng = prevReport.summary.avgEngagementRate > 0
? parseFloat(((avgEngagementRate - prevReport.summary.avgEngagementRate) / prevReport.summary.avgEngagementRate * 100).toFixed(1))
: null;
const pctPosts = prevReport.summary.totalPosts > 0
? parseFloat(((totalPosts - prevReport.summary.totalPosts) / prevReport.summary.totalPosts * 100).toFixed(1))
: null;
trends = {
comparedTo: prevMonth,
percentChange: {
impressions: pctImpr,
engagement: pctEng,
postCount: pctPosts,
},
};
} else {
trends = {
comparedTo: null,
percentChange: { impressions: null, engagement: null, postCount: null },
};
}
// Alerts
const alerts = totalPosts > 0 ? detectAlerts(monthPosts, "impressions") : [];
const report: MonthlyReport = {
month,
generatedAt: new Date().toISOString(),
summary: {
totalPosts,
totalImpressions,
totalReactions,
totalComments,
totalShares,
totalClicks,
avgEngagementRate,
avgImpressionsPerPost,
},
topPerformers,
byWeek,
trends,
alerts,
};
// Save report
saveMonthlyReport(root, report);
return report;
}

View file

@ -1,7 +1,7 @@
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs"; import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
import { join, resolve, dirname } from "node:path"; import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { AnalyticsBatch, WeeklyReport, PostAnalytics } from "../models/types.js"; import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from "../models/types.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@ -25,7 +25,7 @@ export function getAnalyticsRoot(): string {
* Ensure required subdirectories exist under analytics root * Ensure required subdirectories exist under analytics root
*/ */
export function ensureDirectories(root: string): void { export function ensureDirectories(root: string): void {
const directories = ["exports", "posts", "weekly-reports"]; const directories = ["exports", "posts", "weekly-reports", "monthly-reports"];
if (!existsSync(root)) { if (!existsSync(root)) {
mkdirSync(root, { recursive: true }); mkdirSync(root, { recursive: true });
@ -252,3 +252,39 @@ export function loadAllWeeklyReports(root: string): WeeklyReport[] {
b.week.localeCompare(a.week) b.week.localeCompare(a.week)
); );
} }
/**
* Sanitize month string to only allow YYYY-MM format
*/
function sanitizeMonth(month: string): string {
if (!/^\d{4}-\d{2}$/.test(month)) {
throw new Error(`Invalid month format: ${month}. Expected YYYY-MM`);
}
return month;
}
/**
* Save a monthly report to disk
*/
export function saveMonthlyReport(root: string, report: MonthlyReport): string {
ensureDirectories(root);
const reportsDir = join(root, "monthly-reports");
const month = sanitizeMonth(report.month);
const filename = `${month}.json`;
const filepath = join(reportsDir, filename);
verifyPathWithinDirectory(filepath, reportsDir);
writeFileSync(filepath, JSON.stringify(report, null, 2), "utf-8");
return filename;
}
/**
* Load a specific monthly report by month identifier
*/
export function loadMonthlyReport(root: string, month: string): MonthlyReport | null {
month = sanitizeMonth(month);
const reportsDir = join(root, "monthly-reports");
const filepath = join(reportsDir, `${month}.json`);
if (!existsSync(filepath)) return null;
const content = readFileSync(filepath, "utf-8");
return JSON.parse(content) as MonthlyReport;
}

View file

@ -0,0 +1,113 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { generateHeatmap } from "../src/reports/heatmap.js";
import type { PostAnalytics } from "../src/models/types.js";
function createPost(date: string, impressions: number, engagementRate: number): PostAnalytics {
return {
id: `post-${date}`,
title: `Post on ${date}`,
publishedDate: date,
metrics: {
impressions,
reactions: 10,
comments: 5,
shares: 2,
clicks: 3,
engagementRate,
},
importedAt: "2026-04-01T00:00:00Z",
exportSource: "test.csv",
};
}
describe("generateHeatmap", () => {
// Verified days: 2026-04-06=Mon, 07=Tue, 08=Wed, 09=Thu, 12=Sun, 13=Mon, 14=Tue, 15=Wed
const posts: PostAnalytics[] = [
createPost("2026-04-06", 1000, 3.0), // Monday
createPost("2026-04-07", 2000, 4.0), // Tuesday
createPost("2026-04-08", 1500, 3.5), // Wednesday
createPost("2026-04-13", 3000, 5.0), // Monday
createPost("2026-04-14", 2500, 4.5), // Tuesday
createPost("2026-04-12", 800, 2.0), // Sunday
];
test("groups posts by day of week correctly", () => {
const report = generateHeatmap(posts);
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday");
const tuesday = report.byDayOfWeek.find(d => d.dayName === "Tuesday");
const sunday = report.byDayOfWeek.find(d => d.dayName === "Sunday");
assert.equal(monday?.postCount, 2);
assert.equal(tuesday?.postCount, 2);
assert.equal(sunday?.postCount, 1);
});
test("calculates correct averages per day", () => {
const report = generateHeatmap(posts);
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday")!;
const tuesday = report.byDayOfWeek.find(d => d.dayName === "Tuesday")!;
assert.equal(monday.avgImpressions, 2000); // (1000+3000)/2
assert.equal(tuesday.avgImpressions, 2250); // (2000+2500)/2
assert.equal(monday.avgEngagementRate, 4.0); // (3.0+5.0)/2
});
test("handles days with no posts", () => {
const report = generateHeatmap(posts);
const friday = report.byDayOfWeek.find(d => d.dayName === "Friday")!;
assert.equal(friday.postCount, 0);
assert.equal(friday.avgImpressions, 0);
assert.equal(friday.avgEngagementRate, 0);
});
test("returns 7 entries ordered Mon-Sun", () => {
const report = generateHeatmap(posts);
assert.equal(report.byDayOfWeek.length, 7);
assert.equal(report.byDayOfWeek[0].dayName, "Monday");
assert.equal(report.byDayOfWeek[6].dayName, "Sunday");
assert.deepEqual(
report.byDayOfWeek.map(d => d.dayIndex),
[1, 2, 3, 4, 5, 6, 7]
);
});
test("identifies best day for impressions", () => {
const report = generateHeatmap(posts);
assert.equal(report.bestDayImpressions, "Tuesday");
});
test("identifies best day for engagement", () => {
const report = generateHeatmap(posts);
assert.equal(report.bestDayEngagement, "Tuesday"); // (4.0+4.5)/2 = 4.25
});
test("sets correct postsAnalyzed count", () => {
const report = generateHeatmap(posts);
assert.equal(report.postsAnalyzed, 6);
});
test("handles empty post list", () => {
const report = generateHeatmap([]);
assert.equal(report.postsAnalyzed, 0);
assert.equal(report.byDayOfWeek.length, 7);
assert.equal(report.bestDayImpressions, "N/A");
assert.equal(report.bestDayEngagement, "N/A");
for (const day of report.byDayOfWeek) {
assert.equal(day.postCount, 0);
}
});
test("identifies best post per day", () => {
const report = generateHeatmap(posts);
const monday = report.byDayOfWeek.find(d => d.dayName === "Monday")!;
assert.equal(monday.bestPost?.publishedDate, "2026-04-13"); // 3000 impressions
});
test("calculates correct date range", () => {
const report = generateHeatmap(posts);
assert.equal(report.dateRange.from, "2026-04-06");
assert.equal(report.dateRange.to, "2026-04-14");
});
});

View file

@ -0,0 +1,135 @@
import { describe, test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { generateMonthlyReport } from "../src/reports/monthly.js";
import { saveBatch } from "../src/utils/storage.js";
import type { PostAnalytics, AnalyticsBatch, MonthlyReport } from "../src/models/types.js";
function createPost(date: string, impressions: number, engagementRate: number): PostAnalytics {
return {
id: `post-${date}-${impressions}`,
title: `Post on ${date}`,
publishedDate: date,
metrics: {
impressions,
reactions: Math.round(impressions * 0.05),
comments: Math.round(impressions * 0.01),
shares: Math.round(impressions * 0.005),
clicks: Math.round(impressions * 0.02),
engagementRate,
},
importedAt: "2026-04-01T00:00:00Z",
exportSource: "test.csv",
};
}
function createBatch(posts: PostAnalytics[]): AnalyticsBatch {
const dates = posts.map(p => p.publishedDate).sort();
return {
batchId: "test-batch-" + Math.random().toString(36).slice(2, 10),
importedAt: "2026-04-01T00:00:00Z",
exportFilename: "test.csv",
dateRange: { from: dates[0], to: dates[dates.length - 1] },
postCount: posts.length,
posts,
};
}
let tmpDir: string;
function setupTestRoot(posts: PostAnalytics[]): string {
tmpDir = mkdtempSync(join(tmpdir(), "monthly-test-"));
for (const dir of ["exports", "posts", "weekly-reports", "monthly-reports"]) {
mkdirSync(join(tmpDir, dir), { recursive: true });
}
if (posts.length > 0) {
saveBatch(tmpDir, createBatch(posts));
}
return tmpDir;
}
afterEach(() => {
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
});
describe("generateMonthlyReport", () => {
const marchPosts: PostAnalytics[] = [
createPost("2026-03-03", 1000, 3.0),
createPost("2026-03-05", 2000, 4.0),
createPost("2026-03-10", 1500, 3.5),
createPost("2026-03-17", 3000, 5.0),
createPost("2026-03-24", 2500, 4.5),
];
const febPosts: PostAnalytics[] = [
createPost("2026-02-03", 800, 2.5),
createPost("2026-02-10", 1200, 3.0),
createPost("2026-02-17", 900, 2.8),
];
test("filters posts to correct month", () => {
const root = setupTestRoot([...marchPosts, ...febPosts]);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalPosts, 5);
});
test("calculates correct monthly totals", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalImpressions, 10000); // 1000+2000+1500+3000+2500
assert.equal(report.summary.totalPosts, 5);
assert.equal(report.summary.avgImpressionsPerPost, 2000);
});
test("generates weekly breakdown within month", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.ok(report.byWeek.length > 0);
const totalPostsInWeeks = report.byWeek.reduce((sum, w) => sum + w.postCount, 0);
assert.equal(totalPostsInWeeks, 5);
});
test("calculates MoM deltas when previous month exists", () => {
const root = setupTestRoot([...febPosts, ...marchPosts]);
// First generate February report so it exists for comparison
generateMonthlyReport(root, "2026-02");
const report = generateMonthlyReport(root, "2026-03");
assert.notEqual(report.trends.comparedTo, null);
assert.equal(report.trends.comparedTo, "2026-02");
assert.notEqual(report.trends.percentChange.impressions, null);
assert.notEqual(report.trends.percentChange.postCount, null);
});
test("handles no previous month data", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.trends.comparedTo, null);
assert.equal(report.trends.percentChange.impressions, null);
assert.equal(report.trends.percentChange.engagement, null);
});
test("handles month with no posts", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-01");
assert.equal(report.summary.totalPosts, 0);
assert.equal(report.summary.totalImpressions, 0);
assert.equal(report.summary.avgImpressionsPerPost, 0);
assert.equal(report.byWeek.length, 0);
});
test("identifies top performers", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.ok(report.topPerformers.length > 0);
assert.equal(report.topPerformers[0].metrics.impressions, 3000);
});
test("sets correct month field", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.month, "2026-03");
});
});

View file

@ -34,6 +34,7 @@ This plugin uses **6 focused skills**. This main skill contains shared knowledge
| User Intent | Route To | | User Intent | Route To |
|-------------|----------| |-------------|----------|
| "Just installed" / "Walk me through" | `/linkedin:onboarding` |
| "Set up plugin" | `/linkedin:setup` | | "Set up plugin" | `/linkedin:setup` |
| "Personalize" | `/linkedin:setup` | | "Personalize" | `/linkedin:setup` |
| "Improve personalization" | `/linkedin:setup` | | "Improve personalization" | `/linkedin:setup` |
@ -115,6 +116,7 @@ These rules apply to ALL content created by any skill or command:
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `/linkedin` | Router -- shows status line + command menu | | `/linkedin` | Router -- shows status line + command menu |
| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post) |
| `/linkedin:first-post` | First-post accelerator (zero to published in 10 min) | | `/linkedin:first-post` | First-post accelerator (zero to published in 10 min) |
| `/linkedin:setup` | Guided setup to populate asset templates with real data | | `/linkedin:setup` | Guided setup to populate asset templates with real data |
| `/linkedin:react` | URL-to-post pipeline -- react to articles, news, research | | `/linkedin:react` | URL-to-post pipeline -- react to articles, news, research |

View file

@ -109,7 +109,7 @@ Transform each role with impact statements, not task lists:
WHO you help + RESULT you deliver WHO you help + RESULT you deliver
Strong: "Helping [target audience] achieve [specific result] | [Your Role] @ [Your Organization]" Strong: "Helping public sector leaders implement AI that actually works | AI Advisor @ Statens vegvesen"
--- ---